ENG | Zephyr RTOS: Displays
Guide to using ST7567 and ILI9341 displays with Zephyr RTOS (framebuffer, CFB, LVGL).
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
- Zephyr - XIAO RP2040 documentation, pinout
- Zephyr - XIAO RP2040 device tree source code, pinctrl
- Github issue showing device tree overlay - this way I found out display must be defined under
mipi_dbi - ST7567 driver, device tree
- OpenSmart 1.8” 128x64 LCD, ST7567
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
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)
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;
}
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
| Model | Diagonal | Width x Height [mm] | Pixels | Dot [mm] | Technology |
|---|---|---|---|---|---|
| SSD1306 0.96” I2C OLED | 0.95” | 22.3x11 | 128x64 | 0.174 | OLED |
| SH1106 1.3” I2C OLED | 1.3” | 29.4x14.7 | 128x64 | 0.22 | OLED |
| PCD8544 / Nokia 5110 | 1.5” | 28x20 | 84x48 | 0.33 | Transflective LCD |
| OpenSmart 1.8” | 1.8” | 38.8x19 | 128x64 | 0.3 | Transflective LCD |
| Sharp LS027B7DH01 | 2.7” | 58.8 x 35.28 | 400x240 | 0.147 | Reflective LCD |
| Sharp LS032B7DD02 | 3.16” | 46x73.2 | 336x536 | 0.13 | Reflective LCD |
| Pimoroni Display Pack 2.8” | 2.8” | 43.2x57.5 | 320x240 | 0.18 | TFT IPS |
| Elecrow DLS23028B | 2.8” | 43.2x57.6 | 320x240 | 0.18 | TFT TN |
| 2.9” E-Ink Display GDEY029T94-T01 | 2.9” | 29x66.9 | 128x296 | 0.227 | e-ink |
| 2.13” E-Ink Display GDEM0213B74 | 2.13” | 23.7x48.55 | 122x250 | 0.194 | e-ink |
For comparison:
| Display | Resolution | Pixel Pitch | PPI |
|---|---|---|---|
| 14” laptop | 1920×1080 | 0.161 mm | 157 |
| 15.6” laptop | 1920×1080 | 0.179 mm | 141 |
| 24” monitor | 1920×1200 | 0.270 mm | 94 |
| 27” monitor | 2560×1440 | 0.233 mm | 109 |
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.
Links
I’m keeping it here as reference for datasheets, dimension etc.
- OpenSmart 1.8” 128x64 LCD, ST7567
- Pimoroni Pico display 2.8” 320x240, ST7789V display with four buttons and QwST I2C connector (4pin JST-SH) as Raspberry Pi Pico shield, driver
- Elecrow 3.5” 320x480 IPS, ST7796U, cap. touch FT6336U - note: maybe not supported by Zephyr (ST7796S is)
- Elecrow 2.8” 240x320 IPS, ILI9341V, cap. touch FT6336G
- Waveshare 17344 2.0” 240x320 IPS, ST7789VW no touch screen, PH2 8pin connector
- Waveshare 19804 2.8” IPS 320x240 touch, ST7796, XPT2046
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.






