Try out LVGL Pro - A complete toolkit to build, test, share, and ship UIs efficiently!
LVGL
Tutorial

LVGL on Monochrome OLED Displays

Step-by-step guide to porting LVGL to a 128x64 monochrome OLED display with SH1106 driver using PIC24FJ microcontroller.

Costas CalamvokisCostas Calamvokis8 min read

This tutorial describes how to get LVGL working on a small 128x64 OLED display with a PIC24FJ microcontroller. The OLED display board used here has a SH1106 driver chip, but the code works nearly unchanged with an SSD1306, and should be easily adaptable to other display drivers. You should read the LVGL porting guide first as this tutorial only covers the extra considerations needed for monochrome OLED displays.

Display Memory Organization#

The SH1106 contains a frame buffer arranged into "Pages" which are horizontal 8-pixel stripes. Each page extends the full width of the display (128 columns). Writes to the display frame buffer are done by selecting a column number and a page number, then sending data bytes containing the pixels for each 8-pixel column. Any number of columns up to the end of the page can be sent by transmitting more data bytes.

Configuring lv_conf.h#

The first file to set up is lv_conf.h. The main settings are shown below. As the PIC has limited resources, LV_MEM_SIZE is set fairly small. LV_COLOR_DEPTH is set to 1 bit since the display is monochrome, and LV_VDB_PX_BPP to 1 so that the VDB only uses one bit per pixel. The VDB size is set to 2048 pixels (256 bytes). Fancy effects like shadows and antialiasing are disabled (they don't make sense in monochrome), and most themes except "MONO" are turned off.

#define LV_MEM_SIZE      (2U * 1024U)        /*Size memory used by `lv_mem_alloc` in bytes (>= 2kB)*/
#define LV_HOR_RES          (128)
#define LV_VER_RES          (64)
#define LV_ANTIALIAS         0       /*1: Enable anti-aliasing*/
 
#define LV_COLOR_DEPTH      1                     /*Color depth: 1/8/16/32*/
#define USE_LV_SHADOW           0               /*1: Enable shadows*/
#define USE_LV_GROUP            0               /*1: Enable object groups (for keyboards)*/
#define USE_LV_GPU              0               /*1: Enable GPU interface*/
#define USE_LV_REAL_DRAW        0               /*1: Enable function which draw directly to the frame buffer instead of VDB (required if LV_VDB_SIZE = 0)*/
#define USE_LV_FILESYSTEM       0               /*1: Enable file system (might be required for images*/
#define USE_LV_LOG      0   /*Enable/disable the log module*/
// I turned off all the other themes
#define USE_LV_THEME_MONO       1       /*Mono color theme for monochrome displays*/
c

Display Driver Callbacks#

Next, copy lv_porting/lv_port_disp_templ.c to lv_port_disp.c and add three functions:

  • set_px_cb() - Called whenever LVGL wants to write a pixel into the VDB. Handles writing pixel data into bytes in the correct format to send to the display. For displays that accept vertically aligned pixels (one byte represents an 8-pixel vertical line), this callback disposes them correctly.
  • flush_cb() - LVGL uses this call to flush an area to the display.
  • rounder_cb() - Ensures the area sent to flush_cb() is always a whole number of pages, since displays are updated using pages. Most monochrome displays only accept data at least 1 byte wide, so it wouldn't make sense for LVGL to try and refresh an area of 3x5 pixels. This callback rounds buffer areas to the closest page (vertical byte boundary).

The set_px_cb() Callback#

This callback sets the correct bit in the passed buffer, using the page arrangement that the SH1106 uses. It receives 7 arguments:

  • disp_drv: the display driver structure reference (unused in this implementation)
  • buf: the buffer where we should change the pixel (reference to part of a possibly bigger buffer indicated during LVGL initialization)
  • buf_w: the width of the area starting at buf, the number of pixel columns that compose it
  • x and y: coordinates of the pixel to change
  • color: the value of the pixel, either 0 or 1. When LV_COLOR_DEPTH is 1 (monochrome), the lv_color_t type collapses to a union with a single significant bit field
  • opa: color opacity (unused in monochrome configurations)

Note that this implementation inverts the colors to achieve the desired visual effect:

#define BIT_SET(a,b) ((a) |= (1U<<(b)))
#define BIT_CLEAR(a,b) ((a) &= ~(1U<<(b)))
 
void set_px_cb(struct _disp_drv_t * disp_drv, uint8_t * buf, lv_coord_t buf_w, lv_coord_t x, lv_coord_t y,
            lv_color_t color, lv_opa_t opa) {
  uint16_t byte_index = x + (( y>>3 ) * buf_w);
  uint8_t  bit_index  = y & 0x7;
  // == 0 inverts, so we get blue on black
  if ( color.full == 0 ) {
    BIT_SET( buf[ byte_index ] , bit_index );
  }
  else {
    BIT_CLEAR( buf[ byte_index ] , bit_index );
  }
}
c

The flush_cb() Callback#

This callback calculates the page range that needs to be written (row1 → row2), sends the OLED command for the starting row and column, then sends the bytes out (one per 8-pixel column) and ends the transfer.

It takes 3 arguments:

  • disp_drv: the display driver structure reference (unused in this implementation)
  • area: the coordinates for the screen area to refresh, packed in a lv_area_t structure (should have been rounded by the rounder callback)
  • color_p: the buffer containing the actual data to flush. An lv_color_t array that can be safely converted to uint8_t for monochrome displays
static void flush_cb(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p)
{
    uint8_t row1 = area->y1>>3;
    uint8_t row2 = area->y2>>3;
    uint8_t *buf = (uint8_t*) color_p;
 
    for(uint8_t row = row1; row <= row2; row++) {
      oledStartSend( row, area->x1 );
      for(uint16_t x = area->x1; x <= area->x2; x++) {
        oledByteOut(*buf);
        buf++;
      }
      oledEndSend();
    }
    /* IMPORTANT!!!
     * Inform the graphics library that you are ready with the flushing*/
    lv_flush_ready();
}
c

The rounder_cb() Callback#

The simplest function, this callback increases the y limits of an area to the nearest page boundaries (the lower and higher multiple of 8, or byte boundary):

void rounder_cb(struct _disp_drv_t * disp_drv, lv_area_t *area) {
  area->y1 = (area->y1 & (~0x7));
  area->y2 = (area->y2 & (~0x7)) + 7;
}
c

Hooking Up the Callbacks#

Add this code to the lv_port_disp_init() function:

    lv_refr_set_round_cb( round_cb );
 
    disp_drv_p->vdb_wr = vdb_wr;
    disp_drv_p->disp_flush = disp_flush;
c

OLED Communication Functions#

The last low-level task is to implement the OLED communication functions. These are specific to the microcontroller and interface used. Here's an implementation for the PIC24FJ using SPI. This could be enhanced using the Enhanced FIFO or DMA:

#define SPI_DC        LATAbits.LATA1
#define SPI_DATA()    SPI_DC = 1
#define SPI_COMMAND() SPI_DC = 0
 
void oledByteOut( uint8_t data )
{
  SPI1BUF = data;
  while(SPI1STATbits.SPIRBF == 0);
  data = SPI1BUF; // have to read as well
}
 
void oledCommand( uint8_t data )
{
    SPI_COMMAND();
    oledByteOut(data);
}
 
void oledStartSend( uint8_t row, uint8_t col ) {
  oledCommand( 0xB0 | row);
  // goto first column
  col += 2; // +2 for SH1106 - remove for SSD1306
  oledCommand( 0x00 | (col & 0xF) );      // col LS 4 bits
  oledCommand( 0x10 | ((col>>4) & 0xF) ); // col MS 4 bits
  SPI_DATA();
}
 
void oledEndSend() {
}
 
void oledInit()
{
    TRISBbits.TRISB7 = 0; RPOR3bits.RP7R = 9;  // RP7 is pin 16 on GB002, 9 selects SS1OUT to drive Chip Select
 
    SPI1CON1 = 0; // Clear the content of SPI1CON1 register
    SPI1CON1bits.CKP = 1;   // Idle state for clock is a high level; active state is a low level
    SPI1CON1bits.CKE = 0;   // Serial output data changes on transition from idle clock state to active clock state
    SPI1CON1bits.MSTEN = 1; // Enable master mode
    // oLed says 600ns -> 1.67Mhz, so use 4:1,4:1 = 1MHz (pg180)
    //  measured period was 750ns, which is 1.3MHz - don't know why
    SPI1CON1bits.PPRE = 2;  // primary prescaler 3 = 1:1, 2 = 4:1
    SPI1CON1bits.SPRE = 5;  // secondary prescaler 5 = 4:1
    SPI1STATbits.SPIEN = 1; // Enable SPI peripheral
}
c

Initializing the Application#

The final step is to select the MONO theme after initializing the display in your main application:

    oledInit();
 
    static lv_disp_buf_t disp_buf;    /*Descriptor of a display buffer */
    static lv_disp_drv_t disp_drv;    /*Descriptor of a display driver*/
    static lv_color_t gbuf[DISP_BUF_SIZE];  /*Memory area used as display buffer */
 
    lv_init();
    lv_disp_buf_init(&disp_buf, gbuf, NULL, DISP_BUF_SIZE); /* DISP_BUF_SIZE can be smaller than the actual display */
    lv_port_disp_init(&disp_drv);
    // set mono theme - maybe this is the default for 1 bit/pixel anyway?
    //  hue doesn't seem to make a difference
    lv_theme_mono_init(0 /* hue */,NULL /* use LV_FONT_DEFAULT */);
    lv_theme_set_current( lv_theme_get_mono() );
c

When initializing the display buffer you can use any size that fits your memory constraints. LVGL will use this memory range to draw widgets to be refreshed. If a widget doesn't fit into the provided range, it will be drawn and flushed in multiple passes. To maximize performance, use the entire screen size (in bytes, so pixels divided by 8):

#define DISP_BUF_SIZE (128*64/8)
c

Summary#

This tutorial covered the essential steps for porting LVGL to monochrome OLED displays with page-based memory organization. The key points are:

  1. Configure lv_conf.h for 1-bit color depth and monochrome theme
  2. Implement three callbacks: set_px_cb(), flush_cb(), and rounder_cb()
  3. Implement display communication functions for your specific hardware
  4. Initialize LVGL and activate the MONO theme

The same approach works for both SH1106 and SSD1306 drivers with minimal modifications (mainly the column offset adjustment). This foundation can be adapted to other monochrome display drivers by adjusting the memory organization details in the callbacks.

About the author

Costas Calamvokis
Costas Calamvokis

Community Contributor

Embedded developer and LVGL community contributor with experience in OLED display integration.

Meet the people behind the blog

Discover the talented writers sharing their knowledge about LVGL

View Authors

Subscribe to our newsletter to not miss any news about LVGL. We will send maximum of 2 mails per month.

LVGL

LVGL is the most popular free and open source embedded graphics library targeting any MCU, MPU and display type to build beautiful UIs.

We also do services like UI design, implementation and consulting.

© 2026 LVGL. All rights reserved.
YouTubeGitHubLinkedIn