OLED Basics - Microcontroller display guide

How OLED Displays Work on Microcontrollers

The BLEShark Nano has a small OLED display. It shows menus, scan results, status, and on-device DuckyScript. It's the first thing you notice when you pick the device up. But how does a microcontroller actually drive that display? What's happening between the ESP32-C3 and the glass you're looking at? This article covers the SSD1306 controller, the I2C protocol, frame buffers, and why monochrome OLED is the right choice for a battery-powered pocket device.

Table of Contents

How OLED Pixels Work

OLED stands for Organic Light-Emitting Diode. Each pixel is an individual light-emitting element - a thin-film organic compound that emits light when current passes through it. Unlike an LCD (which uses a backlight and liquid crystals to block or pass light), each OLED pixel generates its own light independently.

This has two immediate consequences. First, a black pixel on an OLED is truly black (well, on the monochrome OLED on the Nano, it's got a deep, dark blue/purple-like color) - the pixel is simply off, emitting no light at all. An LCD's "black" is the backlight filtered through a polarizer, which is never perfect. Second, brightness is proportional to current through the organic layer. Dim the pixel by reducing current; turn it off entirely by cutting current completely.

For small monochrome displays like the 64*48 used on BLEShark Nano, each pixel is either on (white, full brightness) or off (black). No grayscale complexity, no color subpixel management. A grid of 3,072 pixels, each independently switched.

graph TD
    subgraph "ESP32-C3 to SSD1306 OLED Communication"
        subgraph "ESP32-C3 (I2C Master)"
            APP["Application Code
Draws UI elements"] FB["Frame Buffer
128x64 = 1024 bytes
1 bit per pixel"] I2C_DRV["I2C Driver
400kHz (Fast Mode)"] end subgraph "I2C Bus" SDA["SDA (Data Line)"] SCL["SCL (Clock Line)"] ADDR["Device Address:
0x3C or 0x3D"] end subgraph "SSD1306 Controller" CMD["Command Register
0x00 prefix"] DATA["Data Register
0x40 prefix"] GDDRAM["GDDRAM
128x64 bit display RAM"] DRIVER["Column/Row Drivers
Charge pump circuit"] end subgraph "OLED Panel" PIX["128 x 64 Pixels
Organic LED per pixel
Self-emitting"] end end APP --> FB FB -->|"Full buffer transfer"| I2C_DRV I2C_DRV --> SDA I2C_DRV --> SCL SDA --> ADDR SCL --> ADDR ADDR --> CMD ADDR --> DATA CMD --> GDDRAM DATA --> GDDRAM GDDRAM --> DRIVER DRIVER --> PIX

Data path from ESP32 application code to OLED pixels - the frame buffer is transferred over I2C to the SSD1306 controller, which drives individual organic LEDs.

The SSD1306 Controller

The SSD1306 is a display controller IC made by Solomon Systech. It's the most common driver chip for small monochrome OLED panels in the microcontroller world. You'll find it in essentially every OLED module sold on hobbyist platforms.

The SSD1306 sits between the microcontroller and the OLED panel. It handles all the low-level driving: managing the charge pump that generates the panel's high voltage, multiplexing across the 64 row lines (COM outputs), providing the column segment drivers (SEG outputs), and accepting display data via I2C or SPI from the host microcontroller.

Key specifications for the SSD1306:

  • 128*64 dot matrix panel driver
  • On-chip 128*64 display RAM (GDDRAM)
  • I2C or SPI host interface
  • Built-in charge pump regulator for the OLED panel voltage
  • Operating voltage: 1.65V to 3.3V (I/O), 7V to 15V (panel, generated internally)
  • Current consumption: ~10-15mA at typical brightness

For the BLEShark Nano, it has a 64*48 monochrome OLED display. If SSD1306 is for 128*64, it should not work, but it does since SSD1306 drives 64*48 OLED matrices via configurable cropping or partial buffer addressing of its 128*64 SRAM.

The SSD1306 has 128 x 64 = 8,192 bits of on-chip GDDRAM (Graphics Display Data RAM). Each bit corresponds to one pixel: 1 = pixel on, 0 = pixel off. The display continuously reads this RAM and refreshes the panel. Your job as the programmer is simply to write the right bits to the right addresses in GDDRAM, and the SSD1306 handles the rest.

I2C vs SPI: How the MCU Talks to the Display

The SSD1306 supports two host interfaces: I2C and SPI. Most small OLED modules in embedded projects use I2C because it only requires two wires (SDA and SCL), which saves GPIO pins on constrained microcontrollers. SPI is faster but needs at least four wires (MOSI, SCK, CS, DC) plus optional reset.

graph TD
    subgraph "I2C Connection (BLEShark Nano)"
        I2C_MCU["ESP32-C3"] -->|"SDA"| I2C_OLED["SSD1306 OLED"]
        I2C_MCU -->|"SCL"| I2C_OLED
        I2C_PINS["2 GPIO pins used
400kHz max
~60 FPS achievable
Shared bus (multi-device)"] end subgraph "SPI Connection (Alternative)" SPI_MCU["ESP32"] -->|"MOSI"| SPI_OLED["SSD1306 OLED"] SPI_MCU -->|"SCK"| SPI_OLED SPI_MCU -->|"CS"| SPI_OLED SPI_MCU -->|"DC"| SPI_OLED SPI_PINS["4 GPIO pins used
10MHz+ possible
~200+ FPS achievable
Dedicated bus"] end subgraph "Trade-off Analysis" I2C_PRO["I2C Advantages
Fewer pins
Simpler wiring
Share with sensors"] SPI_PRO["SPI Advantages
Much faster refresh
Lower latency
Better for animation"] CHOICE["BLEShark uses I2C:
Pin-constrained design
Status UI is low-refresh
Saves GPIOs for WiFi/BLE/IR"] end I2C_PINS --> I2C_PRO SPI_PINS --> SPI_PRO I2C_PRO --> CHOICE SPI_PRO --> CHOICE

I2C vs SPI for OLED displays - BLEShark Nano uses I2C to conserve GPIO pins on the ESP32-C3, trading raw refresh speed for wiring simplicity and bus sharing.

I2C (Inter-Integrated Circuit) is a two-wire synchronous serial protocol. The ESP32-C3 is the master; the SSD1306 is the slave. The SSD1306's I2C address is either 0x3C or 0x3D depending on the state of one address pin on the module. Communication looks like this:

  1. ESP32-C3 pulls SDA low (START condition)
  2. Sends 7-bit address (0x3C) + write bit
  3. SSD1306 acknowledges (pulls SDA low)
  4. ESP32-C3 sends a control byte (0x00 for command, 0x40 for data)
  5. ESP32-C3 sends the command or data bytes
  6. ESP32-C3 sends STOP condition

Standard I2C runs at 100 kbps, fast mode at 400 kbps, and fast-mode-plus at 1 Mbps. To push 128 x 64 bits (1KB) to the display via I2C at 400 kbps, you need about 20ms. That's a full frame refresh rate of roughly 50 Hz over I2C - fine for a UI display that doesn't need to animate rapidly.

SPI mode, by contrast, can push data at 10-20 MHz on an ESP32, which makes full-screen updates essentially instant. If you needed a fast-updating display - video playback, high-frame-rate games - SPI would be necessary. For the BLEShark Nano's menu system and scan results display, I2C is entirely adequate.

The Frame Buffer

On the ESP32-C3 side, the display driver typically maintains a frame buffer in RAM - a 1,024-byte array that mirrors the SSD1306's GDDRAM. The application code writes to this in-memory buffer: set a pixel here, draw text there, draw a rectangle. Then, when the frame is ready, the driver flushes the entire buffer to the SSD1306 in a single I2C transaction.

This approach (double-buffering in RAM, flush on demand) avoids partial updates being visible during construction. Without a frame buffer, you'd write directly to GDDRAM one pixel at a time, and the partially-constructed frame would be visible briefly on the display before completion - producing flicker.

The 1KB frame buffer is trivial on a device with 400KB of SRAM (ESP32-C3). It's allocated at startup and lives there permanently. The BLEShark firmware uses a display abstraction layer that lets different apps (scanner, deauth, BLE tools, settings) draw to the frame buffer independently, with the display manager handling refresh timing.

Text rendering uses a bitmap font - a lookup table that maps each ASCII character to a pattern of pixels. A 6x8 font (6 pixels wide, 8 pixels tall) fits 21 characters per row on a 128-pixel-wide display, and 8 rows on a 64-pixel-tall display. That's 168 characters on screen at once, which is more than enough for menus and scan results.

Pixel Addressing and Pages

The SSD1306's GDDRAM is organized into pages. Each page is 8 pixels tall and 128 pixels wide. A 128x64 display has 8 pages (pages 0-7), each containing 128 bytes. Within a page, each byte represents one 8-pixel-tall column segment: bit 0 is the top row of the page, bit 7 is the bottom row.

graph LR
    subgraph "SSD1306 Page Addressing"
        subgraph "Page 0 (rows 0-7)"
            P0C0["Col 0
8 bits"] P0C1["Col 1
8 bits"] P0CDot["..."] P0C127["Col 127
8 bits"] end subgraph "Page 1 (rows 8-15)" P1C0["Col 0"] P1C1["Col 1"] P1CDot["..."] P1C127["Col 127"] end subgraph "Pages 2-6" MID["...6 more pages..."] end subgraph "Page 7 (rows 56-63)" P7C0["Col 0"] P7C1["Col 1"] P7CDot["..."] P7C127["Col 127"] end end subgraph "Byte Layout" BYTE["Each byte = 8 vertical pixels
Bit 0 = top pixel
Bit 7 = bottom pixel
Written column by column"] end subgraph "Total Memory" CALC["8 pages x 128 columns
= 1024 bytes
= 8192 pixels"] end P0C0 --> BYTE BYTE --> CALC

SSD1306 page-based addressing - the 64-row display is divided into 8 pages of 8 rows each, with each byte controlling a vertical strip of 8 pixels in a column.

This page-based organization is a historical artifact of how early OLED controllers worked, but it has a practical implication: updating a single pixel requires reading the current byte for that column-page, modifying the appropriate bit, and writing the byte back. The frame buffer approach handles this transparently - you just set bits in the 1KB array and flush the whole thing.

The SSD1306 supports several addressing modes:

  • Page addressing mode: Update one page at a time. The column pointer auto-increments within a page, then stops at the end.
  • Horizontal addressing mode: Column pointer auto-increments, page pointer increments when column wraps. Ideal for writing a full frame sequentially.
  • Vertical addressing mode: Page pointer increments first within a column. Useful for some specific rendering patterns.

For a full-frame flush, horizontal addressing mode is most efficient. Set the column range to 0-127 and page range to 0-7, then write 1,024 bytes in sequence. The SSD1306 handles the rest.

Power Efficiency: Why OLED Wins for Battery Devices

For a battery-powered device like the BLEShark Nano (500mAh battery), every milliamp matters. The display is a significant power consumer, and this is where OLED's pixel-level control pays off.

An LCD backlight draws power regardless of what's displayed. A display showing a black screen still has the backlight running at full brightness, burning the same current as a white screen. A typical backlight for a small LCD module draws 20-40mA continuously.

An OLED panel's power consumption is directly proportional to the number of lit pixels and their brightness. A screen showing mostly dark content (a menu with white text on black background, which is what the BLEShark Nano displays) draws very little current - perhaps 3-6mA for the SSD1306 plus panel. A full white screen at maximum brightness draws more like 15-25mA.

The BLEShark Nano's default display theme isn't only an aesthetic choice (it does look great). It's the lowest-power display mode for an OLED. Most pixels are off most of the time.

The BLEShark Nano firmware also implements a display timeout. After a configurable idle period (default around 60 seconds), the display dims and moves to a screensaver. This cuts display power consumption to near zero while the device continues operating. Press any button to wake the display.

Burn-In and Why Screensavers Exist

OLED has one known drawback: organic materials degrade over time, and pixels that emit light continuously degrade faster than pixels that stay dark. If a static image is displayed for thousands of hours - say, a fixed status bar that never changes - those pixels will fade faster than surrounding pixels, creating a ghost image visible even when the display is off or showing other content.

This is burn-in, and it's a real phenomenon. High-end OLED TVs and phones deal with it through pixel shifting (moving the image by 1-2 pixels periodically), pixel refresh cycles, and screensavers that prevent static images from being displayed for too long.

For the BLEShark, the display timeout (dimming the display and optionally going to screensaver after idle) is the primary protection against burn-in. The relatively low brightness of the SSD1306 panel (lower current through the organic layer) also extends lifespan compared to a high-brightness consumer display.

In practice, burn-in is unlikely to be a problem for a device that spends most of its time in a pocket with the display off. It's a concern if you continuously display a static UI for months without the display ever turning off, which isn't a normal usage pattern.

The BLEShark Display in Practice

The BLEShark Nano uses a 0.66" SSD1306 at 64*48 resolution, connected via I2C. The display driver uses horizontal addressing mode for full-frame flushes, maintains a 1KB frame buffer in SRAM, and renders a 6x8 bitmap font for text.

For a device with no external app or wireless connection required to use it, the OLED is the entire user interface. Its small footprint and low power consumption make it the right choice for what the Nano needs to display - and the SSD1306's ubiquity in the embedded ecosystem means well-tested drivers and abundant community knowledge for anyone who wants to dig into the firmware.

Get BLEShark Nano

Back to blog

Leave a comment