LittlevGL on a Monochrome OLED

This tutorial describes how I got LittlevGL working on a small 128x64 OLED display with a PIC24FJ microcontroller. The OLED display board I used had a SH1106 driver chip, but the code would probably work nealry unchanged with an SSD1306, and should be easily adaptable to other display drivers. You should read the LittlevGL porting guide first as I’ll only cover the extra things you need to do for the monochrome OLED display here.

The SH1106 contains a frame buffer which is arranged into “Pages” which are horizontal 8 pixel stripes. Each page extends the whole width of the display, so has 128 columns. Writes to the display frame buffer are done by selecting a column number and a page number and 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 just be sending more data bytes.

The first file to set up is lv_conf.h. The main settings I used are shown below. As the PIC has limitted resources I set LV_MEM_SIZE fairly small. I also set LV_COLOR_DEPTH to 1 bit as the display is monochrome and LV_VDB_PX_BPP to 1 so that the VDB only uses one pit per pixel. I set the VDB size to 2048 pixels which works out to 256 bytes. I also turned off a lot of the fancier effects like shadows and antialiasing (as they don’t make sense in monochrome), and most of the themes except “MONO”.

#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*/

Next we need to copy lv_porting/lv_port_disp_templ.c to lv_port_disp.c and add three functions to it:

So here is the set_px_cb() callback function: it just sets the correct bit in the passed in buf, using the page arangement that the SH1103 uses. It receives 7 arguments:

Note that it inverts the colors, this was the easiest way to get the effect I wanted:

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 );
  }
}

Next is the flush_cb() code. This works out 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 finally ends the transfer. It takes 3 arguments:

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();
}

Finally the simplest function, the round_cb() callback, it just increases the y limits of an area to the nearest page boundaries (in this case, the lower and bigger 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;
}

Then to hook it all up you need to add some code into 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;

The last low level task is to implement the OLED communication functions, these are very specific to the microcontroller and interface used, but here are mine for the PIC24FJ and SPI, this could get much fancier using the Enhanced FIFO or DMA, but I haven’t got that far yet:

#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
}

And then the last extra piece you need 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() );

When initializing the display buffer you can use any size you see fit. lvgl will use this memory range to draw widgets to be refreshed: if a widget does not fit into the provided range it will be drawn and flushed in more than one go. To maximize performances one would use the entire screen size (in bytes, so pixels divided by 8):

#define DISP_BUF_SIZE (128*64/8)