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*/cDisplay 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 toflush_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 atbuf, the number of pixel columns that compose itxandy: coordinates of the pixel to changecolor: the value of the pixel, either 0 or 1. WhenLV_COLOR_DEPTHis 1 (monochrome), thelv_color_ttype collapses to a union with a single significant bit fieldopa: 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 );
}
}cThe 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 alv_area_tstructure (should have been rounded by the rounder callback)color_p: the buffer containing the actual data to flush. Anlv_color_tarray that can be safely converted touint8_tfor 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();
}cThe 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;
}cHooking 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;cOLED 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
}cInitializing 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() );cWhen 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)cSummary#
This tutorial covered the essential steps for porting LVGL to monochrome OLED displays with page-based memory organization. The key points are:
- Configure
lv_conf.hfor 1-bit color depth and monochrome theme - Implement three callbacks:
set_px_cb(),flush_cb(), androunder_cb() - Implement display communication functions for your specific hardware
- 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.
