Porting LittlevGL for a monochrome display

Mattia Maldini
Aug 11 · 10 min read

Leveraging LvGL’s capabilities on a low resource project

My line of work brings me close to small embedded systems with minimal UI: a few LEDs or some digits displays. From time to time however I end up working with small monochrome screens, sparking the need for a working albeit simple GUI library.

In this field, the available resources are barren to say the least. Most results found online refer either to proprietary libraries distributed by LCD vendors with nonexistent support or to dubious code generators that fail to deliver anything beyond a few supported drivers, if at all. My same company’s previous solution was a cracked and modified API whose reference manual was long lost in time. The only reliable alternative to baking your own GUI is LittlevGL.

Microchip’s attempt at GUI support. Needless to say, it’s barely functional.

LittlevGL (or LvGL) is an open source graphic library for embedded systems. It is mainly focused on color TFT displays, but can in principle work for monochrome LCDs as well. My first approach was discouraged by its main purpose being color and the size of the compiled result (about 250 KB of binary for PIC architecture). After a little fiddling however I can say I’m very satisfied, and I wish to make a little tutorial on how to use it.

For reference, my project uses a PIC24 microcontroller managing a 240x128 monochrome resistive touch LCD; in principle however all of the code is abstracted from the actual hardware and most of the explanations are valid in general. Everything written here is based on the extensive LittlevGL documentation.

Monochrome Configuration

LittlevGL is written entirely in C and uses a macro system to configure its settings and features. There is a template configuration on the root of the repository that needs to be copied into a file named lv_conf.h and to be included by all other sources. There are a lot of options, but for the sake of simplicity we can focus on just a few of them:

/* Maximal horizontal and vertical resolution to support by the library.*/
#define LV_HOR_RES_MAX (240)
#define LV_VER_RES_MAX (128)
/* Color depth:
* - 1: 1 byte per pixel
* - 8: RGB233
* - 16: RGB565
* - 32: ARGB8888
*/
#define LV_COLOR_DEPTH 1
/* Enable anti-aliasing (lines, and radiuses will be smoothed) */
#define LV_ANTIALIAS 0
/*1: Enable the Animations */
#define LV_USE_ANIMATION 0
/* Enable GPU optimization */
#define USE_LV_GPU 0

Setting the horizontal and vertical resolution is a must for any display; color depth must be 1 since we are working on monochrome, and by the way we disable antialiasing, GPU and animations as well (won’t need them in such a reduced GUI).

The most important setting in this case is LV_COLOR_DEPTH. It configures the size for each pixel which in this case is just 1 bit, allowing to save some much needed RAM.

Library Initialization

Let’s look at how to actually initialize the library. The following code manages all the necessary operations:

    static uint8_t gbuf[8*240];
static lv_disp_buf_t disp_buf;

lv_disp_buf_init(&disp_buf, gbuf, NULL, 8*240);

lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv); /*Basic initialization*/
disp_drv.buffer = &disp_buf; /*Set an initialized buffer*/
disp_drv.flush_cb = my_flush_cb; /*Callback*/
disp_drv.set_px_cb = my_set_px_cb; /*Callback*/
disp_drv.rounder_cb = my_rounder; /*Callback*/
lv_disp_t * disp;
disp = lv_disp_drv_register(&disp_drv); /*Register the driver and save the created display objects*/
/*Input device*/
lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv); /*Basic initialization*/
indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb = my_input_read;
/*Register the driver in LittlevGL and save the created input device object*/
lv_indev_t * my_indev = lv_indev_drv_register(&indev_drv);

Let’s unwrap it line by line.

gbuf is our framebuffer, the memory region where drawing and graphical operation take place. My screen is 240x128 pixels, with pixel lines grouped horizontally into bytes: the resulting size is 240*128/8 = 240*8.

In this example pixels are grouped by vertical bytes; each “segment” draws an 8-bit column.

Note that it is not necessary for the buffer to accomodate the entire screen: if need be I can use a smaller area, and LvGL will conveniently split GUI operations that do not fit there in multiple, smaller parts. However, I happen to have enough RAM to spare.

lv_disp_buf_init initializes the disp_buf structure, knowing that we are using a single buffer (gbuf) 8*240 bytes wide.

The disp_drv structure contains information pertaining the interface with out display driver; more on this later. Once it is properly populated with callbacks we register it with the lv_disp_drv_register function.

Then we register an input device: it is a LV_INDEV_TYPE_POINTER, a touchscreen or mouse (the latter here). my_input_read simply returns the current touch coordinates, so that LvGL can know where the user is clicking:

bool my_input_read(lv_indev_drv_t * drv, lv_indev_data_t*data)
{
data->point.x = Touch_Coord[0];
data->point.y = Touch_Coord[1];
data->state = f_touch_detected ? LV_INDEV_STATE_PR : LV_INDEV_STATE_REL;
return false; /*No buffering now so no more data read*/
}

In it, I populate a structure with the coordinates I’m reading.

Runtime

Other than booting up, LvGL also requires two function to be called periodically to work: lv_task_handler and lv_tick_inc.

lv_task_handler is how LvGL actually does its work. lv_tick_inc is how it knows that time has passed (milliseconds, specifically).

To abide to this requirements it’s enough to call both of them in the main loop. The period with which lv_tick_inc is called should be passed to the function as its only argument.

while (1) {
lv_task_handler();
lv_tick_inc(1);
delay_ms(1);
}

Alternatively they can be called in interrupt routines, but in this case one must take care to have lv_tick_inc on a higher priority handler, for concurrency reasons. In fact, having the tick increase in an interrupt is good practice because it’s more precise; in the previous example the time taken by lv_task_handler might put the millisecond period off track. For the purpose of this example, it’s good enough.

After these functions the library is ready to be used. Before showing an example however, let’s dig deeper into display driver callbacks.

Porting LvGL

One of LvGL’s most endearing aspects is how well it manages integration with the display driver. One cannot expect to find a common API in embedded systems, so the best solution is simply to leave to the developer the task to connect hardware and GUI: LVGL exposes a handful of callbacks that signal when and what to show on the screen; how to do it is up to you.

Some of those hooks only make sense in a color environment, so for a monochrome LCD we need to define just three functions:

  • flush_cb : the function that signals your application to refresh part of the screen with some data

Here I will show my implementations, but keep in mind that they will differ wildly in other setups.

flush_cb

Possibly the most important callback for LvGL, it signals what must actually be sent on the screen. LvGL optimizes drawing by only refreshing the areas that changed, and flush_cb is its way to tell the driver to proceed.

It receives 3 arguments:

  • lv_disp_drv_t *drv : a pointer to the currently configured display driver structure

The first argument can be ignored; area tells you where you should refresh the screen and color_p contains the data to flush.

This is my implementation of the callback:

void my_flush_cb(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p)
{
int row = area->y1, col;
unsigned int address = (unsigned int)row * HW_COLUMNS + area->x1/8;
uint8_t *buffer;
int linelen = (area->x2 - area->x1)/8;

buffer = (uint8_t*) color_p;

for (row = area->y1; row <= area->y2; row++) {
flush_one_row(address, buffer, linelen);
buffer += linelen+1;
address += HW_COLUMNS;
}

lv_disp_flush_ready(disp_drv);
}

Note that:

  • color_p is a pointer to an lv_color_t buffer, but when LV_COLOR_DEPTH is 1, that structure collapses to a single-byte union; thus, it can be safely cast to uint8_t* for handing purposes

rounder_cb

This one is a little tricky. I mentioned before that in monochrome LCDs pixels are often grouped in bytes; other than that, they might accept updates to their RAM only in special formats (like in specific batches, or by writing to fixed-size memory pages). Because of that LvGL may use areas that don’t make sense during the updating process.

LvGL might want, for example, to refresh the rectangle comprised of coordinates x=(3,14) and y =(1,4). If pixels are grouped by bytes, there is no way to send an odd number of 11 (14–3) bits to the screen. The solution is to round this area to x = (0,16) and y = (1,4) (considering horizontal pixel alignment).

rounder_cb has exactly this function: LvGL notifies the code of the next screen update and asks to round it up to the smallest possible size.

void my_rounder(struct _disp_drv_t * disp_drv, lv_area_t *a)
{
a->x1 = a->x1 & ~(0x7);
a->x2 = a->x2 | (0x7);
}

To round the horizontal byte I just need to move the left x coordinate to the smaller multiple of 8 (logical and with 0b11111000) and the right one to the bigger multiple of 8 (logical or with 0b00000111). These values will be sent to flush_cb as well.

set_px_cb

The biggest shortcoming of LvGL (for my purposes) is that it does not natively manage single-bit pixel values. It is mainly used for color display where every pixel is at least one byte wide, but it would be absurd to use a buffer 8 times bigger than necessary with a monochrome LCD. Instead of integrating this functionality, LvGL delegates it to you.

This callback requires the driver to set or clear a single pixel given the buffer and the coordinates; it is up to your code to correctly modify the memory, eventually taking into account pixel alignment.

void my_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)
{
buf += buf_w/8 * y;
buf += x/8;
if(lv_color_brightness(color) > 128) {(*buf) |= (1 << (7 - x % 8));}
else {(*buf) &= ~(1 << (7 - x % 8));}
}
  • disp_drv is the usual display driver structure

All of those pointer arithmetic and bitwise operations are to set a single bit in the correct horizontal segment/byte.

A small example

LvGL provides many practical tutorials, examples and demos. Unfortunately, many of them will fail or malfunction in a monochrome environment: for me either the screen or the MCU’s available memory were too small. This is a small example with just a click button and a changing label:

lv_obj_t *label2, *label1;
static void btn_event_cb(lv_obj_t * btn, lv_event_t event)
{
if(event == LV_EVENT_RELEASED) {
lv_label_set_text(label1, "RELEASED");
} else if (event == LV_EVENT_PRESSED) {
lv_label_set_text(label1, "CLICKED");
}
}
int main (void)
{
ConfigureOscillator();
InitializeSystem();

lv_init();

static lv_disp_buf_t disp_buf;
lv_disp_buf_init(&disp_buf, gbuf, NULL, 8*240);
lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv);
disp_drv.buffer = &disp_buf;
disp_drv.flush_cb = my_flush_cb;
disp_drv.set_px_cb = my_set_px_cb;
disp_drv.rounder_cb = my_rounder;

lv_disp_t * disp;
disp = lv_disp_drv_register(&disp_drv);

lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb =my_input_read;
lv_indev_t * my_indev = lv_indev_drv_register(&indev_drv);

lv_obj_t * scr = lv_disp_get_scr_act(NULL);
lv_theme_t * th = lv_theme_mono_init(0, NULL);
/* Set the mono system theme */
lv_theme_set_current(th);

/*Create a Label on the currently active screen*/
label1 = lv_label_create(scr, NULL);
lv_label_set_text(label1, "");
lv_obj_set_pos(label1,30, 30);// position, position);
/*Create a button on the currently loaded screen*/
lv_obj_t * btn1 = lv_btn_create(scr, NULL);
lv_obj_set_event_cb(btn1, btn_event_cb); /*Set function to be called when the button is released*/
//lv_obj_align(btn1, label2, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 20); /*Align below the label*/
lv_obj_set_pos(btn1, 30, 50);

/*Create a label on the button (the 'label' variable can be reused)*/
label2 = lv_label_create(btn1, NULL);
lv_label_set_text(label2, "Click me!");
lv_obj_set_size(btn1, 100, 20);
while (1) {
lv_task_handler();
lv_tick_inc(1);
delay_ms(1);
}
}

After the initialization I’ve already explained the most notable parts are setting the mono theme — the only usable one in a monochrome environment — and creating the actual widgets. The button event works because of the touch input callback that provides touch coordinates to the library, managing the entire input system.


Additional Notes

As I’ve mentioned in the introduction, at first I could not use LvGL because of its memory occupation. The MCU I used for this tutorial is a PIC24EP512GP206, which sports 512 KB of flash memory: LvGL alone takes up half of that.

I’ve since fiddled with the configuration options and found out that a lot of unused components can be disabled to save space. Besides all of the themes and fonts, LvGL allows you to remove every single widget with macro options: if I’m not using a calendar on a 128x64 display I can disable it by setting LV_USE_CALENDAR to 0 and it won’t take up precious flash memory. The configuration template file has everything to a default value.

By selecting only what I plan on using (and keeping my options decently open) I’ve reached a reasonable 150 KB binary output, and I look forward to using LvGL in all of my future work!

Mattia Maldini

Written by

Computer Science Master from Alma Mater Studiorum, Bologna; interested in a wide range of topics, from functional programming to embedded systems.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade