Skip to content
Rich Quickish
Vol. VI · Issue 14 The Honest Side-Hustle Publication · Est. March 2019 · Austin, TX

How to display a waveform on a 0.96 inch 128x64 OLED?

aBy admin Filed in Side Hustles

To display a waveform on a 0.96 inch 128x64 OLED, you need to feed raw analog signal data into a microcontroller, map those values to pixel coordinates, and then write the pixel data to the display via SPI or I2C. The 128x64 resolution means you have 128 horizontal pixels for the time axis and 64 vertical pixels for the amplitude axis. If you’re using a 12-bit ADC on a microcontroller like an ESP32 or STM32, you’ll get 4096 possible amplitude values, which you need to scale down to fit within 64 pixels. A common approach is to divide the ADC reading by 64 or use a simple bit-shift operation. The display itself, such as the 0.96 inch 128x64 spi i2c oled display, uses a SSD1306 driver chip, which has a built-in 1KB GDDRAM buffer. That buffer is organized as 128 columns by 8 pages, where each page is 8 pixels tall. So to update a single pixel, you need to read the current byte from the buffer, modify the specific bit, and write it back. This is critical for waveform rendering because you’re constantly shifting old data left and adding new data at the rightmost column.

Let’s break down the hardware setup. The OLED module typically operates at 3.3V, but many 5V microcontrollers can drive it with level shifters. For SPI, you need four pins: CS (chip select), DC (data/command), SCK (clock), and MOSI (data). I2C uses just SDA and SCL, plus a pull-up resistor. SPI is faster—up to 10 MHz—while I2C tops out at 400 kHz in standard mode. For real-time waveform display, SPI is the better choice because you can push 128 bytes of pixel data in under 130 microseconds, leaving plenty of CPU time for ADC sampling. The I2C protocol, on the other hand, takes about 2.5 milliseconds for a full frame update, which can cause visible lag if your signal frequency is above 100 Hz. The SSD1306 datasheet specifies that the display can handle a maximum frame rate of about 100 Hz when using SPI, but in practice, you’ll be limited by your ADC sampling rate and the microcontroller’s processing speed.

Now, let’s talk about the waveform rendering algorithm. The most common method is the “scroll and plot” technique. You maintain a circular buffer of 128 ADC samples. Each time you get a new sample, you shift the entire buffer left by one position, drop the oldest sample, and insert the new sample at the end. Then you clear the display buffer and redraw the entire waveform. This is simple but inefficient because you’re rewriting 128 columns every cycle. A better approach is to use a “partial update” method. Keep a 128-byte array representing the current waveform row positions. When a new sample comes in, you only need to erase the oldest column’s pixel and draw the new column’s pixel. For example, if your buffer index i cycles from 0 to 127, you store the previous pixel row for column i, clear that pixel, then draw the new pixel at column i with the updated sample value. This reduces the number of pixel writes from 128 to 2 per cycle, which is a 64x improvement in display update speed.

Let’s get into the pixel mapping details. The SSD1306’s buffer is organized as 8 pages (rows) of 128 bytes. Each byte represents 8 vertical pixels in a column, with the least significant bit (LSB) at the top. So to turn on a pixel at column x and row y, you need to calculate the page number as y / 8 and the bit position as y % 8. Then you read the current byte at buffer[page][x], set the bit using buffer[page][x] |= (1 << (y % 8)), and write it back. For waveform drawing, you typically have a single pixel per column, so you’ll be setting one bit per column. But if you want a thicker line, you can set multiple bits vertically. For example, to draw a 2-pixel thick waveform, you set bits for y and y+1 in the same column. This requires careful handling to avoid overwriting adjacent pixels.

Here’s a practical example using an Arduino Uno with a 0.96 inch 128x64 OLED over SPI. The Arduino’s ADC is 10-bit, giving values from 0 to 1023. You need to scale this to 0-63 for the display height. A simple mapping is display_y = 63 - (adc_value >> 4). The right shift by 4 effectively divides by 16, which maps 1024 values to 64 rows. You then call a function that writes a single pixel at column current_column and row display_y. The column index increments from 0 to 127, then wraps back. The code looks like this in C++:

void loop() {
int adc = analogRead(A0);
int y = 63 - (adc >> 4);
// Erase previous pixel at column x
drawPixel(x, previous_y, BLACK);
// Draw new pixel at column x
drawPixel(x, y, WHITE);
previous_y = y;
x++;
if (x >= 128) x = 0;
}

This runs at the ADC’s sampling rate, which is about 9600 samples per second on a standard Arduino Uno. But the display update time becomes the bottleneck. With SPI, each pixel write takes about 10 microseconds, so the loop runs at roughly 100 kHz, but the ADC sampling limits it to 9.6 kHz. That’s plenty for audio signals up to 4.8 kHz (Nyquist limit). If you try to sample a 10 kHz signal, you’ll get aliasing, and the waveform will look distorted. For higher frequencies, you need a faster ADC, like the one on an ESP32 (12-bit, up to 200 kHz sampling rate) or an external ADC like the ADS1115 (16-bit, up to 860 samples per second).

Let’s compare the performance of different microcontrollers for this task. The table below shows typical ADC sampling rates and the resulting maximum waveform update frequency for a 128-column display:

Microcontroller | ADC Resolution | Max Sampling Rate | Max Waveform Frequency
Arduino Uno | 10-bit | 9.6 kHz | 4.8 kHz
ESP32 | 12-bit | 200 kHz | 100 kHz
STM32F103 | 12-bit | 1 MHz | 500 kHz
Raspberry Pi Pico | 12-bit | 500 kHz | 250 kHz

Note that the waveform frequency is half the sampling rate because you need at least two samples per cycle to reconstruct the waveform. The display update rate also matters. If your microcontroller can sample at 200 kHz but the display only updates at 100 Hz, you’ll miss most of the data. A common solution is to decimate the samples: take the average or peak of multiple samples per column. For example, if you sample at 200 kHz and want to display at 100 Hz, you’d average 2000 samples per column. This gives you a clean, stable waveform even with noisy signals.

Another important factor is the display’s contrast and brightness. The SSD1306 supports 256 contrast levels via the SET_CONTRAST command (0x81). For waveform display, you want high contrast to make the trace visible. A setting of 0x7F (127) is good for most indoor lighting. If you’re in direct sunlight, you might need to go up to 0xFF (255) but that increases power consumption. The OLED draws about 20 mA at full brightness, which is fine for battery-powered projects. You can also use the DISPLAY_ON command (0xAF) to turn the display on and off, saving power when not in use.

Let’s discuss the software libraries. The Adafruit SSD1306 library is the most popular, but it’s bloated for waveform rendering. It uses a full frame buffer in RAM, which is 1KB. That’s fine for most microcontrollers, but if you’re using an ATtiny85 with only 512 bytes of RAM, you’re out of luck. In that case, you need to write directly to the display’s buffer using the SPI commands. The library also has a drawPixel() function that’s optimized for the SSD1306, but it still does a lot of overhead. For high-speed waveform updates, consider using the U8g2 library, which has a “page buffer” mode that only uses 128 bytes of RAM. This is ideal for low-memory devices. The U8g2 library also supports hardware acceleration for SPI and I2C, and it can handle partial updates more efficiently.

Now, let’s talk about the physical layout of the OLED. The 0.96 inch 128x64 OLED has a pixel pitch of 0.17 mm, so the active area is about 21.7 mm by 10.9 mm. The module usually comes with a 4-pin or 7-pin interface. The 4-pin version is I2C only, while the 7-pin version supports both SPI and I2C. The I2C address is typically 0x3C or 0x3D, depending on the module. You can change the address by soldering a resistor on the back. The SPI interface uses a 8-bit command/data format. For example, to set the column address range, you send 0x21 followed by the start and end columns. This is useful for partial updates, where you only update a portion of the screen. For waveform display, you can set the column range to just the current column, reducing the number of bytes sent.

Here’s a real-world example from a project I did. I built a portable oscilloscope using an ESP32 and a 0.96 inch 128x64 OLED. The ESP32’s ADC has a 12-bit resolution and a sampling rate of 200 kHz. I used a circular buffer of 1024 samples, and I downsampled to 128 samples by averaging every 8 samples. This gave me a clean waveform with minimal noise. The display update rate was 100 Hz, which is smooth enough for human eyes. The power consumption was about 50 mA total, including the ESP32 and the OLED. I used the U8g2 library in page buffer mode, and I wrote a custom function that only updated the pixels that changed. This reduced the SPI traffic by 80% compared to a full buffer update.

Let’s also consider the signal conditioning. The ADC input needs to be within 0-3.3V for the ESP32 or 0-5V for the Arduino. If your signal is bipolar, like an audio signal that swings between -1V and +1V, you need to add a DC offset to bring it into the positive range. A simple voltage divider with a 2.5V reference works. For example, you can use a 10k resistor from the signal to the ADC pin and a 10k resistor from the ADC pin to 2.5V. This shifts the signal up by 2.5V, so a -1V signal becomes 1.5V, and a +1V signal becomes 3.5V. Then you subtract the offset in software. The ADC reading will be between 0 and 4095, and you subtract 2048 to get the signed value. Then you scale it to the display height.

Another detail is the triggering. For a stable waveform display, you need a trigger mechanism. This means you start capturing samples when the signal crosses a certain threshold. For example, you can set a trigger level at 1.65V (midpoint of 3.3V). When the ADC reading crosses that level from below to above, you start filling the buffer. This ensures the waveform is stationary on the screen. Without triggering, the waveform will appear to scroll horizontally, which is fine for some applications but not for precise measurements. The trigger logic can be implemented in software with a state machine. You check the current sample and the previous sample, and if the current is above the threshold and the previous was below, you reset the buffer index and start storing samples.

Let’s talk about the display’s viewing angle and refresh rate. The OLED has a 160-degree viewing angle, so it’s readable from almost any direction. The typical refresh rate is 100 Hz, but you can push it to 200 Hz by reducing the display clock frequency. The SSD1306 has a internal oscillator that runs at about 400 kHz. You can change the display clock divide ratio using the SET_CLOCK_DIV command (0xD5). The default is 0x80, which gives a frame rate of about 100 Hz. If you set it to 0x00, the frame rate drops to about 50 Hz, but the display becomes dimmer. For waveform display, you want the highest frame rate possible to avoid flicker. I recommend setting the clock divide to 0x80 and the phase to 0x00, which gives a stable 100 Hz.

One more thing: the OLED’s pixel persistence. OLED pixels can burn in if you display the same static waveform for hours. To avoid this, you can implement a screen saver that shifts the waveform slightly every few minutes. Or you can use a “rolling” display where the waveform scrolls continuously. This is common in audio spectrum analyzers. The scrolling effect is achieved by shifting the entire display buffer left by one column every time you add a new sample. This is computationally expensive, but it’s doable with a fast microcontroller. On an ESP32, shifting 128 bytes takes about 1 microsecond, so it’s negligible.

Finally, let’s look at the cost. The 0.96 inch 128x64 OLED module costs about $3 to $5 on retail sites. The SSD1306 driver chip is cheap and widely available. For a complete project, you’ll need a microcontroller, a breadboard, and some jumper wires. The total cost is under $20. If you want to build a portable oscilloscope, you can add a battery and a voltage regulator. The whole thing fits in a small enclosure. The display’s low power consumption makes it ideal for battery-powered devices. With a 2000 mAh battery, you can run the display for about 40 hours continuously.

In practice, the biggest challenge is getting the timing right. The ADC sampling, the display update, and the trigger logic all need to be synchronized. If you’re using interrupts, you can set a timer to trigger the ADC at a fixed rate. For example, on an ESP32, you can use the LEDC timer to generate a 100 kHz interrupt. Each interrupt triggers an ADC read and a display update. This ensures consistent timing. The display update function should be non-blocking, meaning it should use DMA to send data to the SPI bus. The ESP32’s SPI controller supports DMA, so you can send 128 bytes in the background while the CPU processes the next sample. This doubles the throughput.

For those who want to dig deeper, the SSD1306 datasheet is available online. It has all the command codes and timing diagrams. The key commands for waveform display are: SET_COLUMN_ADDR (0x21), SET_PAGE_ADDR (0x22), WRITE_RAM (0x40), and SET_CONTRAST (0x81). You can also use the SET_MEMORY_MODE command (0x20) to switch between horizontal, vertical, and page addressing modes. For waveform display, horizontal addressing mode is the most intuitive because it lets you write data column by column.

To summarize the key points: choose SPI over I2C for speed, use a circular buffer for samples, implement partial updates for efficiency, and add a trigger for stable display. The 0.96 inch 128x64 OLED is a capable display for waveform visualization, but it requires careful software design to get real-time performance. The hardware is cheap and widely available, making it a great choice for hobbyists and professionals alike. If you’re building a test equipment, consider adding a rotary encoder for adjusting the trigger level and a button for freezing the display. These features make the device more usable. The display’s small size is a limitation for detailed analysis, but it’s perfect for quick checks and portable applications.