Post

ENG | Zephyr RTOS: Displays

Guide to using ST7567 and ILI9341 displays with Zephyr RTOS (framebuffer, CFB, LVGL).

ENG | Zephyr RTOS: Displays

So long, dark late autumn evenings are here and with them comes boredom. Thus I returned to experiments with Zephyr RTOS. I forgot how frustrating it can be to write device tree and cryptic book-long compiler error outputs, which can be caused by a single typo or copy-paste error.

Nonetheless, I set a goal to make Sitronix ST7567, Ilitek ILI9341 running to see how much device tree differ. I also want to explore using direct framebuffer writes, which I used with MicroPython to render font I want, then I want to experiment with character frame buffer and LVGL.

This examples required some exploration of zephyr device tree and source code of ST7567 driver.

It was good to start with a simple framebuffer example before LVGL, because it helped to resolve later issues. At least I knew what can go wrong, having some fundamental knowledge about framebuffer layout.

Recap

First recap of useful commands on Linux

1
2
3
4
5
6
7
8
9
10
# Activate virtual environment for west command
source ~/zephyrproject/.venv/bin/activate
# Build project in the current directory (`-p always` for clean rebuild)
west build -p auto -b xiao_rp2040
# Mount virtual usb drive while rp2040 is in boot mode and flash firmware
# To enter boot mode press RESET while holding BOOT button on XIAO RP2040
# or plug in USB cable while holding BOOTSEL on Raspberry Pi Pico
# To list available block device, use `lsblk -o NAME,SIZE,MODEL,LABEL,TRAN`
# which list device name, size, disk model, partition label and type (usb, nvme)
udisksctl mount -b /dev/sdb1 && west flash -r uf2

Cryptic compilation errors usually mean:

  • Missing non-optional keys in the device tree overlay
  • Mismatch/bad reference between name in the program and the device tree
  • Typos in the d.t.

Random crashes are:

  • Stack overflow

Sitronix ST7567 display with XIAO RP2040 board

Overlay file which defines wiring and is HOPEFULLY common for all examples. Typically takes hours to write correctly. Note that integer values are written as <42> and bool values are just defined when true.

Support for this display was added just recently and will appear in Zephyr 4.3.0.

Useful links

Example using raw framebuffer

Photo with pinout and result of the first example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/ {
    chosen {
        zephyr,display = &st7567;
        zephyr,console = &usb_cdc;
    };

    mipi_dbi {
        compatible      = "zephyr,mipi-dbi-spi";
        spi-dev         = <&xiao_spi>;
        dc-gpios        = <&gpio0 27 GPIO_ACTIVE_HIGH>; // pin P27/D1
        reset-gpios     = <&gpio0 28 GPIO_ACTIVE_LOW>;  // pin P28/D2
        write-only;
        #address-cells  = <1>;
        #size-cells     = <0>;

        st7567: st7567@0 {
            compatible          = "sitronix,st7567";
            mipi-max-frequency  = <20000000>;       // 20MHz
            mipi-mode = "MIPI_DBI_MODE_SPI_4WIRE";
            reg                 = <0>;              // Uses cs-gpios[0]
            width               = <128>;
            height              = <64>;
            status              = "okay";
            column-offset       = <0>;
            line-offset         = <0>;
            regulation-ratio    = <0x3>;            // 3bits
            //segment-invdir;                         // flipX=True
            com-invdir;
            bias;
        };
    };
};

// XIAO RP2040 SPI_TX P3, SPI_SCK P2
&xiao_spi {
    status          = "okay";
    clock-frequency = <8000000>; // be safe
    cs-gpios        = <&gpio0 26 GPIO_ACTIVE_LOW>;  // PIN D0/P26
};

&zephyr_udc0 {
    usb_cdc: usb_cdc_0 {
        compatible = "zephyr,cdc-acm-uart";
    };
};

CMake file is basically the same for every project, just project name can differ and multiple source files could be used.

1
2
3
4
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(st7567)
target_sources(app PRIVATE src/main.c)

Project file, which defines driver for display, GPIO ports and serial console over USB.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
CONFIG_GPIO=y
CONFIG_SPI=y
CONFIG_HEAP_MEM_POOL_SIZE=32678
CONFIG_MAIN_STACK_SIZE=4096

CONFIG_LOG=y
CONFIG_DISPLAY=y
CONFIG_ST7567=y
CONFIG_ST7567_DEFAULT_CONTRAST=0
CONFIG_MIPI_DBI=y
CONFIG_MIPI_DBI_SPI=y

#CONFIG_USB_DEVICE_STACK=y
#CONFIG_USB_CDC_ACM=y
#CONFIG_USB_DEVICE_INITIALIZE_AT_BOOT=y
CONFIG_USB_DEVICE_STACK_NEXT=y
CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT=y
CONFIG_USB_DEVICE_PRODUCT="Zephyr Weather Station"
CONFIG_USB_DEVICE_PID=0x0004
CONFIG_CONSOLE=y
CONFIG_SERIAL=y
CONFIG_UART_CONSOLE=y
CONFIG_UART_LINE_CTRL=y

Actual source code, which draws space invader.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/display.h>

int main() {
    printk("** Starting ST7567 test **\n");
    const struct device *display_dev;
    display_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_display));
    if (!device_is_ready(display_dev)) {
        printk("Display device not ready!\n");
        return -1;
    }
    display_blanking_off(display_dev);

    // Check display capabilities
    struct display_capabilities caps;
    display_get_capabilities(display_dev, &caps);
    printk("Display size: %dx%d\n",      caps.x_resolution, caps.y_resolution);
    printk("Current pixel format: %d\n", caps.current_pixel_format);
    printk("Supported formats: 0x%x\n",  caps.supported_pixel_formats);
    printk("Screen info: 0x%x\n",        caps.screen_info);

    // Write 11x8 pixels space invader
    struct display_buffer_descriptor buf_desc = {
        .buf_size = 11, .width = 11, .height = 8, .pitch = 11
    };
    static uint8_t buf[11] = "\x70\x18\x7d\xb6\xbc\x3c\xbc\xb6\x7d\x18\x70";
    display_write(display_dev, 0, 0, &buf_desc, buf);

    printk("Done.\n");
    return 0;
}

Example using monochrome character framebuffer

Zephyr’s CFB documentation

prj.conf file needs one extra line: CONFIG_CHARACTER_FRAMEBUFFER=y CMakeLists.txt is unchanged.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/display.h>
#include <zephyr/display/cfb.h>

int main() {
    printk("** Starting ST7567 CFB test **\n");

    const struct device *display_dev;
    display_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_display));

    if (!device_is_ready(display_dev)) {
        printk("Display device not ready!\n");
        return -1;
    }

    // Initialize CFB
    if (display_set_pixel_format(display_dev, PIXEL_FORMAT_MONO10) != 0) {
        printk("Failed to set pixel format\n");
    }

    if (display_set_contrast(display_dev, 0)) {
        printk("Failed to set contrast\n");
    }

    if (cfb_framebuffer_init(display_dev)) {
        printk("CFB init failed!\n");
        return -1;
    }

    cfb_framebuffer_clear(display_dev, true);
    display_blanking_off(display_dev);

    // Print available fonts
    uint8_t font_count = cfb_get_numof_fonts(display_dev);
    printk("Available fonts: %d\n", font_count);
    for (uint8_t i = 0; i < font_count; ++i) {
        uint8_t w, h;
        cfb_get_font_size(display_dev, i, &w, &h);
        printk("Font: %d %02dx%02d\n", i, w, h);
    }

    // Set font (try font 0)
    cfb_framebuffer_set_font(display_dev, 0);

    // Print hello world
    cfb_print(display_dev, "Hello World!", 0, 0);
    cfb_framebuffer_finalize(display_dev);

    printk("Done.\n");
    return 0;
}

Example using LVGL (Light and Versatile Graphic Library)

LVGL homepage

I guess this is overkill for a small monochrome display, but here it is…

This was not fun at all. I struggled with crashes, even with simple example code. Adding printk right after main() caused crashes, but placing return 0; before the main loop worked fine, so something in the loop was a culprit.

The root cause became clear when I examined the ST7567 driver source code. The driver enforces that framebuffer updates must be aligned to 8-pixel boundaries (because each byte represents 8 vertical pixels). LVGL, by default, was trying to update arbitrary screen regions, which violated this constraint.

I initially considered implementing a custom callback to handle this, but then discovered via west -t build menuconfig that Zephyr has configuration options to work around this limitation. By setting LVGL to update the entire display at once (rather than in smaller chunks) and allocating 100% of the display as the video buffer, I could avoid misaligned updates entirely. Two lines in prj.conf solved the problem:

1
2
CONFIG_LV_Z_VDB_SIZE=100
CONFIG_LV_Z_FULL_REFRESH=y

Now simple C code. We just need to set colors, cause default background with all pixels active looks ugly. The following code is stripped of some debugging messages.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/display.h>
#include <lvgl.h>

int main(void) {
    const struct device *display_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_display));    
    if (!device_is_ready(display_dev)) {
        printk("Display not ready!\n");
        return -1;
    }

    // Create UI
    lv_obj_t *scr = lv_screen_active();
    lv_obj_set_style_bg_color(scr, lv_color_black(), 0);
    lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);

    lv_obj_t *label = lv_label_create(scr);
    lv_label_set_text(label, "Hello");
    lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
    lv_obj_set_style_text_color(label, lv_color_white(), 0);

    display_blanking_off(display_dev);

    // Main loop
    while (1) {
        lv_timer_handler();
        k_msleep(50);
    }

    return 0;
}

/* vim: set expandtab shiftwidth=4 softtabstop=4 tabstop=4 : */

Full prj.conf, technically we don’t need log, memory pool is maybe overkill,but starting poing was file for ILI9341 display, which partially worked before I tried this one:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
CONFIG_GPIO=y
CONFIG_SPI=y
CONFIG_HEAP_MEM_POOL_SIZE=32678
CONFIG_MAIN_STACK_SIZE=4096

CONFIG_LOG=y
CONFIG_LOG_DEFAULT_LEVEL=3
CONFIG_DISPLAY=y
CONFIG_ST7567=y
CONFIG_ST7567_DEFAULT_CONTRAST=0
CONFIG_MIPI_DBI=y
CONFIG_MIPI_DBI_SPI=y
CONFIG_LVGL=y
CONFIG_LV_USE_GENERIC_MIPI=y
CONFIG_LV_COLOR_DEPTH_1=y
CONFIG_LV_Z_MEM_POOL_SIZE=16384
CONFIG_LV_Z_VDB_SIZE=100
CONFIG_LV_Z_FULL_REFRESH=y
CONFIG_DISPLAY_LOG_LEVEL_ERR=y
CONFIG_LV_USE_LABEL=y
#CONFIG_LV_FONT_MONTSERRAT_14=y
CONFIG_LV_FONT_DEFAULT_UNSCII_16=y # 16 or 8 monospace
CONFIG_LV_FONT_UNSCII_16=y
CONFIG_LV_USE_LOG=y

CONFIG_USB_DEVICE_STACK_NEXT=y
CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT=y
CONFIG_USB_DEVICE_PRODUCT="Zephyr Weather Station"
CONFIG_USB_DEVICE_PID=0x0004
CONFIG_CONSOLE=y
CONFIG_SERIAL=y
CONFIG_UART_CONSOLE=y
CONFIG_UART_LINE_CTRL=y

CMakeLists.txt and xiao_rp2040.overlay files are unchanged.

ST7567 Summary

Using LVGL for ST7567 display was a pain, program did not work and I did not know where to even get started. Code looks like Zephyr’s official example, but all I got was a blank screen and no console output at all. Next morning, I somehow get idea to update whole framebuffer, when I saw callback for this generated by GROK LLM and I realized it does not satisfy assertions in driver source code, which I found while exploring a single frame buffer code.

Monochrome character frame buffer (CFB) has adequate level of complexity, but default fonts are big and ugly.

Not only display needs to be updated by whole 8 pixels high line, also memory buffer has room for 132 pixels. When image is rotated upside down there is four pixels offset. For a simple framebuffer it’s enough to configure display as 132x64 and draw from column x=4 to x=132. Or hide this into driver, pass 128x64 framebuffer line by line and add four to x position or line start. For LVGL I’m not sure. Display driver works with 128x64 bitmap only because it does not send whole buffer at once, but by lines. It sets cursor to column 0 and line number from 0 to 7 before sending 128 bytes of data.

ILI9341 with Raspberry Pi Pico

This started somewhat earlier, but I decided to try smaller monochrome display, cause I thought it will be more simple. Well, at least reuploading firmware to XIAO RP2040 is more comfortable, without unplugging cable and larger breadboard seems to have less reliable contacts.

But both projects were done somewhat in parallel.

My display seems to be elecrow DLS23028B. Well, it’s likely from China, but this eshop at least have specifications, documentation and code examples with complete init sequence, not relying on Arduino libraries.

Framebuffer example

1
2
3
4
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(ili9341)
target_sources(app PRIVATE src/main.c)

The overlay file is actually pretty similar to overlay for XIAO RP2040 and ST7567 display, we are using default SPI0 pins and maybe configuration of display is more simple.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/* ======[ WIRING ]=======
 * PICO     DISPLAY MODULE
 * 3V3   -> 1+8 VCC+LED
 * GP21  -> 4 RST
 * GP20  -> 5 D/C
 * GP19  -> 6 SDI/MOSI
 * GP18  -> 7 SCK
 * GND   -> 2 GND
 * GP17  -> 3 CS
 * NOTE: GP18,GP19 are standard SPI0 pins
 * NOTE: another CS and MISO are needed for touch on Pico's side
 */

/ {
    chosen {
        zephyr,display = &ili9341;
        zephyr,console = &usb_cdc;
    };

    mipi_dbi {
        compatible      = "zephyr,mipi-dbi-spi";
        spi-dev         = <&spi0>;
        dc-gpios        = <&gpio0 20 GPIO_ACTIVE_HIGH>;
        reset-gpios     = <&gpio0 21 GPIO_ACTIVE_LOW>;
        write-only;
        #address-cells  = <1>;
        #size-cells     = <0>;

        ili9341: ili9341@0 {
            compatible          = "ilitek,ili9341";
            mipi-max-frequency  = <15000000>;
            reg                 = <0>;  // Uses cs-gpios[0]
            width               = <320>;
            height              = <240>;
            rotation            = <90>; // Default is portait 240x320, 90 for landscape ...
            status              = "okay";
        };
    };
};

&spi0 {
    status          = "okay";
    clock-frequency = <15000000>;
    // Note: multiple devices can have CS pins configured here, separated by ","
    // They are referenced via reg=<n>;
    cs-gpios        = <&gpio0 17 GPIO_ACTIVE_LOW>;
};

&zephyr_udc0 {
    usb_cdc: usb_cdc_0 {
        compatible = "zephyr,cdc-acm-uart";
    };
};

Project config maybe needs more memory, note that whole framebuffer is 320x240x2=153600 bytes (150kB) - more than half of total microcontroller’s memory.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CONFIG_GPIO=y
CONFIG_SPI=y
CONFIG_HEAP_MEM_POOL_SIZE=65536
CONFIG_MAIN_STACK_SIZE=4096

CONFIG_LOG=y
CONFIG_DISPLAY=y
CONFIG_ILI9341=y
CONFIG_MIPI_DBI=y
CONFIG_MIPI_DBI_SPI=y

CONFIG_USB_DEVICE_PRODUCT="Zephyr Weather Station"
CONFIG_USB_DEVICE_STACK_NEXT=y
CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT=y
CONFIG_CONSOLE=y
CONFIG_SERIAL=y
CONFIG_UART_CONSOLE=y
CONFIG_UART_LINE_CTRL=y

Source code has ultimate goal to draw red, green, blue, white squares just to test layout and colors. Confusing is the need to swap endianity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/display.h>
#include <zephyr/logging/log.h>

#define TILE_WIDTH  32
#define TILE_HEIGHT 32

LOG_MODULE_REGISTER(main, LOG_LEVEL_INF);

static void test_display(const struct device *display) {
    struct display_buffer_descriptor desc = {
        .width  = TILE_WIDTH,
        .height = TILE_HEIGHT,
        .pitch  = TILE_WIDTH,
    };

    // colors are 0xRRRR RGGG GGGB BBBB = 0x07e0 but there is endianity
    // so green is not 0x07e0, but 0xe007
    static uint16_t buf[TILE_WIDTH * TILE_HEIGHT];
    uint16_t colors[] = {
        0x00f8, // red
        0xe007, // green
        0x1f00, // blue
        0xffff, // white
        0x0000, // black
    };

    for (int i = 0; i < sizeof(colors)/sizeof(uint16_t); ++i) {
        // Fill buffer with color
        for (int j = 0; j < TILE_WIDTH * TILE_HEIGHT; ++j) {
            buf[j] = colors[i];
        }

        display_write(display, i * TILE_WIDTH, 0, &desc, buf);
    }
}

int main(void) {
    LOG_INF("Starting ILI9341 framebuffer test.");

    /* Get display device */
    const struct device *display_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_display));
    if (!device_is_ready(display_dev)) {
        LOG_ERR("Display device not ready");
        return -1;
    }

    test_display(display_dev);

    display_blanking_off(display_dev);

    LOG_INF("Done.");
    return 0;
}

Framebuffer example

LVGL example

Important part of prj.conf are CONFIG_LV_USE_ILI9341 and likely CONFIG_LV_Z... parameters so 20% of screen fits into memory. As before, I had problem with program crashing in main loop.

Without configuring CONFIG_LV_COLOR_16_SWAP=y, colors are somewhat random and there are aliasing errors.

CMakeLists.txt and rpi_pico.overlay are unchanged.

Program itself was mostly without issues.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
CONFIG_GPIO=y
CONFIG_SPI=y
CONFIG_HEAP_MEM_POOL_SIZE=65536
CONFIG_MAIN_STACK_SIZE=4096

CONFIG_LOG=y
CONFIG_DISPLAY=y
CONFIG_ILI9341=y
CONFIG_MIPI_DBI=y
CONFIG_MIPI_DBI_SPI=y

CONFIG_LVGL=y
# 20% of 240 is 48 rows
CONFIG_LV_Z_VDB_SIZE=20
CONFIG_LV_Z_MEM_POOL_SIZE=32678
CONFIG_LV_USE_IMAGE=y
CONFIG_LV_USE_LABEL=y
CONFIG_LV_COLOR_16_SWAP=y
CONFIG_LV_FONT_MONTSERRAT_14=n
CONFIG_LV_FONT_MONTSERRAT_24=y
CONFIG_LV_FONT_DEFAULT_MONTSERRAT_24=y
CONFIG_LV_USE_LOG=y
CONFIG_LV_USE_ILI9341=y
CONFIG_USB_DEVICE_STACK_NEXT=y
CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT=y
CONFIG_USB_DEVICE_PRODUCT="Zephyr Weather Station"
CONFIG_CONSOLE=y
CONFIG_SERIAL=y
CONFIG_UART_CONSOLE=y
CONFIG_UART_LINE_CTRL=y
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/display.h>
#include <lvgl.h>
#include <zephyr/logging/log.h>

// https://docs.lvgl.io/9.4/examples.html

LOG_MODULE_REGISTER(main, LOG_LEVEL_INF);

static int display_caps(const struct device *dev) {
#if 0
    struct display_capabilities caps;

    if (display_get_capabilities(dev, &caps) != 0) {
        LOG_ERR("Can't get display capabilities");
        return -1;
    }

    LOG_INF("Display: %dx%d %dbpp", caps.x_resolution, caps.y_resolution, caps.current_pixel_format);
#endif
    return 0;
}

int main(void) {
    LOG_INF("Starting LVGL Hello World example");
    
    /* Get display device */
    const struct device* display_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_display));
    if (!device_is_ready(display_dev)) {
        LOG_ERR("Display device not ready");
        return -1;
    }
    LOG_INF("Display device ready");

    display_caps(display_dev);
    display_blanking_off(display_dev);

    /* Create the main screen */
    lv_obj_t* screen = lv_obj_create(NULL);
    lv_scr_load(screen);

    /* Set background color */
    lv_obj_set_style_bg_color(screen, lv_color_hex(0x000000), LV_PART_MAIN);

    /* Create a container box with border (frame around display) */
    lv_obj_t *border_box = lv_obj_create(screen);
    lv_obj_set_size(border_box, LV_HOR_RES - 10, LV_VER_RES - 10);
    lv_obj_center(border_box);
    
    /* Style the border */
    lv_obj_set_style_border_color(border_box, lv_color_hex(0xaab0ad), LV_PART_MAIN);
    lv_obj_set_style_border_width(border_box, 3, LV_PART_MAIN);
    lv_obj_set_style_bg_color(border_box, lv_color_hex(0xf6f2ee), LV_PART_MAIN);
    lv_obj_set_style_radius(border_box, 15, LV_PART_MAIN);

    /* Create label for "Hello World" */
    lv_obj_t* label = lv_label_create(border_box);
    lv_label_set_text(label, "Hello World!");

    /* Style the label */
    lv_obj_set_style_text_color(label, lv_color_hex(0x4863b6), LV_PART_MAIN);
    lv_obj_set_style_text_font(label, &lv_font_montserrat_24, LV_PART_MAIN);

    /* Center the label in the border box */
    lv_obj_center(label);

    LOG_INF("LVGL widgets created");

    /* Main loop */
    while (1) {
        lv_timer_handler();
        k_msleep(10);
    }

    return 0;
}

/* vim: set expandtab shiftwidth=4 softtabstop=4 tabstop=4 : */

Displaying image/wallpaper

This was just for fun. File holedna.h is renamed holedna.c from image holedna.webp which I took two days ago and converted to c file using online LVGL image converter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/display.h>
#include <zephyr/logging/log.h>
#include <lvgl.h>
#include "holedna.h"

// https://docs.lvgl.io/master/details/main-modules/image.html

LOG_MODULE_REGISTER(main, LOG_LEVEL_INF);

int main(void) {
    /* Get display device */
    const struct device *display_dev;
    display_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_display));
    if (!device_is_ready(display_dev)) {
        LOG_ERR("Display device not ready");
        return -1;
    }
    LOG_INF("Display device ready");

    display_blanking_off(display_dev);

    /* Set background color */
    lv_obj_set_style_bg_color(lv_screen_active(), lv_color_hex(0x000000), LV_PART_MAIN);

    LV_IMAGE_DECLARE(holedna);
    lv_obj_t* wp_img = lv_image_create(lv_screen_active());
    lv_image_set_src(wp_img, &holedna);
    lv_obj_align(wp_img, LV_ALIGN_CENTER, 0, 0);

    /* Main loop */
    while (1) {
        lv_timer_handler();
        k_msleep(10);
    }

    return 0;
}

/* vim: set expandtab shiftwidth=4 softtabstop=4 tabstop=4 : */

ILI9341 summary

The display is reasonably large and includes a touch panel (which I have not tested yet). As every TFT display, it suffers from bad viewing angles and color shifts. Hopefully there are IPS variants (or maybe not, what I found is TFT after research). LVGL makes sense here given the color depth and screen size. This might be issue of initialization sequence which was left at defaults.

Getting the raw framebuffer running was straightforward. LVGL required some additional tweaking, but after dealing with issues on the ST7567 and learning to handle byte swapping for correct colors, it worked as intended.

Summary

Smaller OPEN-SMART 1.8” LCD display based on ST7567 controller is good for displaying sensor data and it’s not that bad - after all, mobile screens before year 2005 were even smaller. Displaying text across 8 pixels row boundaries is not straightforward (but it’s doable) and so on. It has some retro feel … well there are still worse displays on printers, transport ticket automats, heater controllers etc.

Larger display can be used as a dashboard, view angles and backlight of TFT/TN technology are not perfect and I would recommend IPS display for that purpose - cause dashboards are meant to look good.

In MicroPython I also used OLED I2C displays based on SSD1306 or SSD1315 controllers, but these are IMHO not worth it. Size is 0.9” or 1.3”, they don’t seem reliable and their major advantage is their good contrast regardless of light without backlight which is too bright at night.

It was pretty boring rainy weekend and holiday spend by playing with Zephyr and theming Linux desktop to Terafox color scheme

Article freely continues by notes about Pimoroni 2.8” Display pack.

Sidenote: Comparing Small Display Technologies Today

Small display modules Small display modules

ModelDiagonalWidth x Height [mm]PixelsDot [mm]Technology
SSD1306 0.96” I2C OLED0.95”22.3x11128x640.174OLED
SH1106 1.3” I2C OLED1.3”29.4x14.7128x640.22OLED
PCD8544 / Nokia 51101.5”28x2084x480.33Transflective LCD
OpenSmart 1.8”1.8”38.8x19128x640.3Transflective LCD
Sharp LS027B7DH012.7”58.8 x 35.28400x2400.147Reflective LCD
Sharp LS032B7DD023.16”46x73.2336x5360.13Reflective LCD
Pimoroni Display Pack 2.8”2.8”43.2x57.5320x2400.18TFT IPS
Elecrow DLS23028B2.8”43.2x57.6320x2400.18TFT TN
2.9” E-Ink Display GDEY029T94-T012.9”29x66.9128x2960.227e-ink
2.13” E-Ink Display GDEM0213B742.13”23.7x48.55122x2500.194e-ink

For comparison:

DisplayResolutionPixel PitchPPI
14” laptop1920×10800.161 mm157
15.6” laptop1920×10800.179 mm141
24” monitor1920×12000.270 mm94
27” monitor2560×14400.233 mm109

Note that reading 8 pixels high font on laptop is not exactly convenient, but at the same time, monochrome display require bitmap fonts. Bitmap points on CRTs in 90s were not pixelated as monitors had blurring.

What’s interesting is that there aren’t many options. IPS panels are likely the best, offering great colors and viewing angles; the price is a bit higher, but acceptable. Another reasonable option is character displays with 16x2 or 20x4 characters—they’re somewhat bigger, readable, and often sufficient.

Personally, I’d avoid TFT TN panels—they don’t feel premium enough for a dedicated device, viewing angles are very narrow.

E-ink is interesting but expensive and slow to refresh, with a single supplier options (Good Display) and quirky drivers. Tiny, low-resolution transreflective LCDs still exist. The Sharp LCD is quite expensive—more than IPS—and appears to be the very last of its kind, offering decent resolution, but there’s likely no demand for displays that were premium 25 years ago. And it has no backlight or touch. Modern transreflective displays with colors, grayscale, or resolutions comparable to old PDAs simply don’t exist today. OLED displays are very expensive at any meaningful size. They can work well for smartwatches or similar devices (I still prefer the transflective display readable in direct sunlight on my Garmin Forerunner 255), but I can’t find a use case for them.

This market situation is somewhat understandable: monochrome displays have limitations and require special care when designing graphics or icons as pixel art, whereas with color displays, you can simply render TrueType fonts and vector icons without even worrying about pixel alignment. And since cheap microcontrollers now have power comparable to PCs from 1993, color rendering is no longer a bottleneck. Phones aren’t always-on devices, and we’ve gotten used to recharging them daily instead of every 10 days (yes, that was realistic before smartphones), so demand for low-power options has diminished.

I’m keeping it here as reference for datasheets, dimension etc.

NOTE: botland.cz (Polish eshop) sells few RPI pico display shields, but without I2C and Pimoroni modules that are more expensive, but at least have Qwiic/Stemma connectors. Lack of I2C can be solved by using dual male/female pins.

This post is licensed under CC BY 4.0 by the author.