Skip to content
Maximum Tuning Maximum Tuning Shop Open Book Your Dyno Session
EST. 2011 · BAKERSFIELD, CA

How to make a menu on a 1.54 inch 128x64 OLED display?

TECHNICAL DEEP-DIVE

Building a Functional Menu on a 1.54 Inch 128x64 OLED Display

To make a menu on a 1.54 inch 128x64 oled display, you need to handle pixel-level graphics, memory buffering, and user input logic. This display uses a 128x64 pixel matrix, typically driven by an SSD1309 or SH1106 controller over SPI or I2C. The key is to efficiently manage the 1KB frame buffer (128x64 bits = 1024 bytes) and render text or icons without flickering. Start by initializing the display with a 3.3V supply and setting the contrast register to 0x7F for optimal readability in indoor lighting. For menus, you’ll implement a state machine that tracks the current menu item index, number of items, and a scroll offset when items exceed the visible area. A common approach is to use a 5x7 pixel font for text, which fits about 21 characters per line at 6 pixels width per character (including spacing). With 8 lines of text at 8 pixels height per line, you can display up to 8 menu items simultaneously, but practical designs limit to 6 or 7 to leave room for a cursor or highlight bar.

The core challenge is rendering speed. SPI clock rates of 4-8 MHz allow full frame updates in about 2-4 milliseconds, but you must avoid tearing by using double buffering. Allocate a 1024-byte buffer in RAM, draw all menu elements there, then send the entire buffer to the display via a single SPI transaction. For example, using the Adafruit_SSD1306 library on an Arduino Uno, you can call `display.clearDisplay()`, `display.drawBitmap()`, and `display.display()` in sequence. The `display.display()` function transfers the buffer over SPI, which takes roughly 3 ms at 8 MHz. If you update the buffer partially, you risk ghosting. Instead, only redraw when the user presses a button or when the menu state changes. Use a 4-way navigation button (up, down, select, back) or a rotary encoder with a push switch. For a rotary encoder, read the quadrature signals with interrupts to avoid missing steps, and debounce with a 5 ms timer.

Menu structure typically follows a tree or flat list. For a flat menu with 10 items, store them in a `const char*` array. Each item is a string of up to 20 characters, but you can truncate longer strings with an ellipsis. The highlight bar is a filled rectangle at the current item’s position, using `display.fillRect(x, y, width, 8, WHITE)` and then drawing the text in black on top. For a 128x64 display, the highlight bar width is 128 pixels, height 8, and y position = item_index * 8 + top_margin. Set top_margin to 0 or 4 for a clean look. If you have submenus, implement a stack that stores the parent menu index and current selection. Each submenu is a separate array. When the user selects an item, push the current state onto the stack, load the new menu array, and reset the selection index to 0. The stack depth rarely exceeds 3, so a simple array of 10 structs is sufficient.

For icons, use 16x16 pixel bitmaps. Each icon requires 32 bytes (16x16 bits). Store them in PROGMEM on AVR microcontrollers to save RAM. For example, a battery icon can be a 16x16 monochrome bitmap. When drawing, use `display.drawBitmap(x, y, icon_battery, 16, 16, WHITE)`. Place icons to the left of text items, with a 2-pixel gap. This reduces text space but improves visual hierarchy. If you need to display dynamic data like sensor readings, update the relevant portion of the buffer. For instance, to show a temperature value, call `display.setCursor(x, y)`, `display.print(temp)`, then `display.display()`. But avoid calling `display.display()` more than 10 times per second to prevent flicker and reduce CPU load. Instead, batch updates: read all sensors, build the entire display buffer, then send it once.

Memory management is critical. The 1.54 inch 128x64 oled display has no built-in font storage, so you must include a font array. A 5x7 font for ASCII characters 32-127 takes about 96*5 = 480 bytes in PROGMEM. If you need larger fonts, like 8x16 for titles, that adds 96*8 = 768 bytes. Total font storage under 2 KB is typical. The frame buffer is 1 KB. On an Arduino Uno with 2 KB SRAM, that leaves only 1 KB for variables, which is tight. Use `unsigned char` for buffer and `int8_t` for counters. For larger projects, switch to an ESP32 or STM32 with 20+ KB RAM. On ESP32, you can use the U8g2 library, which supports hardware acceleration via I2C or SPI. U8g2’s `setFont()` function allows selecting from dozens of fonts, but each font adds 2-8 KB to flash. For a menu, use `u8g2_font_5x7_tr` for items and `u8g2_font_8x13_tr` for headers. The library handles buffer management internally, but you still need to call `u8g2.sendBuffer()` after each draw cycle.

User input handling must be non-blocking. Use a state machine that checks button states every 50 ms. For example, if the up button is pressed, decrement the menu index, wrap around if at the top, and set a `redraw_needed` flag. In the main loop, check the flag, redraw the buffer, and clear the flag. This prevents the display from updating during button bounces. For a rotary encoder, track the position with a volatile int. When the encoder changes, adjust the menu index and set the flag. The select button triggers the action associated with the current item. Actions can be function pointers stored in an array of structs: `typedef void (*menu_action)(void);`. Each menu item has a name string and an action pointer. When selected, call the function. For example, a "Settings" item might call `settings_menu()`, which loads a new menu array. This modular approach keeps code organized.

Performance optimization: Use DMA if available. On STM32, the SPI peripheral can transfer data via DMA without CPU intervention. Configure a DMA channel to copy the frame buffer to the SPI data register. This frees the CPU to handle input or other tasks. The transfer completes in under 2 ms at 8 MHz. On Raspberry Pi Pico, use the PIO state machine to drive the SPI bus at 30 MHz, achieving full frame updates in 0.3 ms. For battery-powered devices, reduce the display refresh rate to 1 Hz when idle. Use the `display.ssd1306_command(SSD1306_DISPLAYOFF)` command to turn off the display between updates. The SSD1309 controller consumes about 20 mA when active and 0.1 mA in sleep mode. For a 1.54 inch 128x64 oled display, typical power draw is 15-25 mA at 3.3V, depending on the number of lit pixels. A menu with mostly black background (inverted) can reduce power by 50%.

Real-world example: A weather station menu with 5 items: "Current Temp", "Humidity", "Forecast", "Settings", "About". Each item shows a 16x16 icon and a 20-character label. The display updates every 2 seconds. The "Current Temp" item reads a DHT22 sensor and displays the value in real-time. The "Forecast" item shows a 3-day forecast stored in EEPROM. The "Settings" submenu allows adjusting temperature units (Celsius/Fahrenheit) and alarm thresholds. The total code size is 28 KB on an ESP32, with 12 KB used for fonts and bitmaps. The menu navigation uses a rotary encoder with a push switch. The encoder is read with interrupts, and the menu index is updated with a 10 ms debounce. The display buffer is updated only when the encoder changes or when sensor data updates. This results in a smooth, responsive interface with no flicker.

For debugging, use a logic analyzer to monitor SPI signals. The typical SPI transaction for a 1.54 inch 128x64 oled display starts with a command byte (0x00) followed by data bytes. The display expects a 9-bit protocol: 1 bit for command/data, 8 bits for value. Many libraries handle this automatically. If you see artifacts, check the contrast register (0x81) and the charge pump settings (0x8D). The default contrast is 0x7F, but you may need to adjust to 0xCF for outdoor use. Also, ensure the VCC pin is connected to 3.3V and not 5V, as the display is not 5V tolerant. For the SPI interface, use pins: CS (chip select), DC (data/command), MOSI, SCK, and RESET. The RESET pin must be pulled high after power-up, or the display may not initialize. A common issue is that the display remains blank because the RESET pin is left floating. Always drive the RESET pin high in the setup code.

Advanced menu features: Add a scroll bar when items exceed the visible area. For a 10-item menu with 6 visible lines, the scroll bar is a thin rectangle on the right side. Its height is proportional to the visible portion: scroll_bar_height = (visible_lines / total_items) * display_height. Its position is (current_scroll_offset / total_items) * display_height. Draw it with `display.fillRect(124, y, 4, scroll_bar_height, WHITE)`. Another feature is a submenu animation: slide the old menu off to the left and the new menu on from the right. This requires two buffers and a timer. On each frame, shift the old buffer left by 4 pixels and the new buffer right by 4 pixels, then blend them. This consumes extra CPU but looks professional. For a 128x64 display, a slide animation takes about 32 frames at 30 fps, or about 1 second. Use a timer with 33 ms intervals.

Reliability: Use watchdog timers to reset the microcontroller if the menu hangs. On ESP32, enable the watchdog with a 5-second timeout. In the main loop, feed the watchdog after each display update. Also, store menu state in EEPROM or NVS so that after a power cycle, the menu returns to the last selected item. This is especially useful for devices with no user interface other than the display. For example, store the current menu index and submenu stack in a struct and write it to EEPROM every 10 seconds. On boot, read the struct and restore the menu state. This adds about 20 bytes of EEPROM usage.

Testing: Use a multimeter to measure current draw. A fully lit white screen draws about 25 mA, while a black screen draws 15 mA. For a menu with mostly black background (inverted pixels), the average draw is 18 mA. If you use a 2000 mAh battery, the device can run for about 110 hours continuously. Reduce brightness by lowering the contrast register to 0x3F to extend battery life to 150 hours. The display’s viewing angle is 160 degrees, so the menu is readable from almost any angle. The 1.54 inch 128x64 oled display has a response time of under 10 microseconds, so fast scrolling is possible without ghosting. Use a 30 fps refresh rate for smooth animations, but keep the menu static most of the time to save power.

Code structure: Use a `menu_t` struct with fields: `const char** items`, `uint8_t item_count`, `uint8_t current_index`, `uint8_t scroll_offset`, `menu_action* actions`. For submenus, use a `menu_stack_t` struct with an array of `menu_t` pointers and a depth counter. The main loop calls `menu_update()` which reads input, updates the state, and calls `menu_draw()`. The `menu_draw()` function clears the buffer, draws the highlight bar, draws each item, and calls `display.display()`. To avoid blocking, use a `millis()` timer to limit updates to 30 fps. If the input is idle for 10 seconds, turn off the display and enter a low-power state. Wake on any button press.

Common pitfalls: Forgetting to call `display.begin()` with the correct address. For I2C, the default address is 0x3C or 0x3D. For SPI, set the CS pin correctly. Also, the display may have a built-in voltage regulator that requires a capacitor between VCC and GND. A 10 µF capacitor is recommended. If the display shows random pixels, check the SPI wiring and ensure the clock polarity is correct (CPOL=0, CPHA=0 for most libraries). Finally, the 1.54 inch 128x64 oled display is sensitive to ESD, so handle it with care and use a ground strap when soldering.

Stop guessing. Start measuring.

Every tune developed and validated on our in-house Dynapack — gains you can verify on the screen and feel on the road.

Book Your Dyno Session →