Build an LVGL Touchscreen App with Zig

📝 12 Jul 2022

LVGL Touchscreen App on Pine64’s PineDio Stack BL604

LVGL is a popular GUI Library in C that powers the User Interfaces of many Embedded Devices. (Like smartwatches)

Zig is a new-ish Programming Language that works well with C. And it comes with built-in Safety Checks at runtime.

Can we use Zig to code an LVGL Touchscreen Application?

Maybe make LVGL a little safer and friendlier… By wrapping the LVGL API in Zig?

Or will we get blocked by something beyond our control? (Like Bit Fields in LVGL Structs)

Let’s find out! We’ll do this on Pine64’s PineDio Stack BL604 RISC-V Board (pic above) with Apache NuttX RTOS.

(The steps will be similar for other platforms)

Join me as we dive into our LVGL Touchscreen App in Zig

(Spoiler: Answers are Yes, Maybe, Somewhat)

LVGL App in C

(Source)

§1 LVGL App in C

We begin with a barebones LVGL App in C that renders a line of text…

(Like the pic at the top of this article)

static void create_widgets(void) {

  // Get the Active Screen
  lv_obj_t *screen = lv_scr_act();

  // Create a Label Widget
  lv_obj_t *label = lv_label_create(screen, NULL);

  // Wrap long lines in the label text
  lv_label_set_long_mode(label, LV_LABEL_LONG_BREAK);

  // Interpret color codes in the label text
  lv_label_set_recolor(label, true);

  // Center align the label text
  lv_label_set_align(label, LV_LABEL_ALIGN_CENTER);

  // Set the label text and colors
  lv_label_set_text(
    label, 
    "#ff0000 HELLO# "    //  Red Text
    "#00aa00 PINEDIO# "  //  Green Text
    "#0000ff STACK!# "   //  Blue Text
  );

  // Set the label width
  lv_obj_set_width(label, 200);

  // Align the label to the center of the screen, shift 30 pixels up
  lv_obj_align(label, NULL, LV_ALIGN_CENTER, 0, -30);

  // Omitted: LVGL Canvas (we'll find out why)
}

(Source)

(Docs for LVGL Label)

In a while we shall convert this LVGL App to Zig.

What if we’re not familiar with Zig?

The following sections assume that we’re familiar with C.

The parts that look Zig-ish shall be explained with examples in C.

(If we’re keen to learn Zig, see this)

Where’s the rest of the code that initialises LVGL?

We hit some complications converting the code to Zig, more about this in a while.

Zig LVGL App

(Source)

§2 Zig LVGL App

Now the same LVGL App, but in Zig

fn createWidgetsUnwrapped() !void {

  // Get the Active Screen
  const screen = c.lv_scr_act().?;

  // Create a Label Widget
  const label = c.lv_label_create(screen, null).?;

  // Wrap long lines in the label text
  c.lv_label_set_long_mode(label, c.LV_LABEL_LONG_BREAK);

  // Interpret color codes in the label text
  c.lv_label_set_recolor(label, true);

  // Center align the label text
  c.lv_label_set_align(label, c.LV_LABEL_ALIGN_CENTER);

  // Set the label text and colors.
  // `++` concatenates two strings or arrays.
  c.lv_label_set_text(
    label, 
    "#ff0000 HELLO# "   ++  // Red Text
    "#00aa00 PINEDIO# " ++  // Green Text
    "#0000ff STACK!# "      // Blue Text
  );

  // Set the label width
  c.lv_obj_set_width(label, 200);

  // Align the label to the center of the screen, shift 30 pixels up
  c.lv_obj_align(label, null, c.LV_ALIGN_CENTER, 0, -30);
}

(Source)

Our Zig App calls the LVGL Functions imported from C, as denoted by “c.something”.

But this looks mighty similar to C!

Yep and we see that…

What’s “!void”?

!void” is the Return Type for our Zig Function…

Let’s talk about Null Pointers and Runtime Safety…

LVGL App: C vs Zig

§3 Zig Checks Null Pointers

Earlier we saw our Zig App calling the LVGL Functions imported from C…

// Zig calls a C function
const disp_drv = c.get_disp_drv().?;

Note that we write “.?” to catch Null Pointers returned by C Functions.

What happens if the C Function returns a Null Pointer to Zig?

// Suppose this C Function...
lv_disp_drv_t *get_disp_drv(void) {
  // Returns a Null Pointer to Zig
  return NULL;
}

When we run this code, we’ll see a Zig Panic and a Stack Trace…

!ZIG PANIC!
attempt to use null value
Stack Trace:
0x23023606

Looking up address 23023606 in the RISC-V Disassembly for our firmware…

zig-lvgl-nuttx/lvgltest.zig:50
    const disp_drv = c.get_disp_drv().?;
230235f4: 23089537 lui   a0,0x23089
230235f8: 5ac50513 addi  a0,a0,1452 # 230895ac <__unnamed_10>
230235fc: 4581     li    a1,0
230235fe: 00000097 auipc ra,0x0
23023602: c92080e7 jalr  -878(ra) # 23023290 <panic>
23023606: ff042503 lw    a0,-16(s0)
2302360a: fea42623 sw    a0,-20(s0)

We discover that 23023606 points to the line of code that caught the Null Pointer.

Hence Zig is super helpful for writing safer programs.

What if we omit “.?” and do this?

const disp_drv = c.get_disp_drv();

This crashes with a RISC-V Exception when our program tries to dereference the Null Pointer in a later part of the code.

Which isn’t as helpful as an immediate Zig Panic (upon receiving the Null Pointer).

Thus we always write “.?” to catch Null Pointers returned by C Functions.

(Hopefully someday we’ll have a Zig Lint Tool that will warn us if we forget to use “.?”)

Import C Functions and Macros

§4 Import C Functions

How do we import the C Functions and Macros for LVGL?

This is how we import the Functions and Macros from C into Zig: lvgltest.zig

/// Import the LVGL Library from C
const c = @cImport({
  // NuttX Defines
  @cDefine("__NuttX__",  "");
  @cDefine("NDEBUG",     "");
  @cDefine("ARCH_RISCV", "");
  @cDefine("LV_LVGL_H_INCLUDE_SIMPLE", "");

  // This is equivalent to...
  // #define __NuttX__
  // #define NDEBUG
  // #define ARCH_RISCV
  // #define LV_LVGL_H_INCLUDE_SIMPLE

(@cImport is documented here)

At the top of our Zig App we set the #define Macros that will be referenced by the C Header Files coming up.

The settings above are specific to Apache NuttX RTOS and the BL602 RISC-V SoC. (Here’s why)

Next comes a workaround for a C Macro Error that appears on Zig with Apache NuttX RTOS…

  // Workaround for "Unable to translate macro: undefined identifier `LL`"
  @cDefine("LL", "");
  @cDefine("__int_c_join(a, b)", "a");  //  Bypass zig/lib/include/stdint.h

(More about this)

We import the C Header Files for Apache NuttX RTOS…

  // NuttX Header Files. This is equivalent to...
  // #include "...";
  @cInclude("arch/types.h");
  @cInclude("../../nuttx/include/limits.h");
  @cInclude("stdio.h");
  @cInclude("nuttx/config.h");
  @cInclude("sys/boardctl.h");
  @cInclude("unistd.h");
  @cInclude("stddef.h");
  @cInclude("stdlib.h");

(More about the includes)

Followed by the C Header Files for the LVGL Library

  // LVGL Header Files
  @cInclude("lvgl/lvgl.h");

  // App Header Files
  @cInclude("fbdev.h");
  @cInclude("lcddev.h");
  @cInclude("tp.h");
  @cInclude("tp_cal.h");
});

And our Application-Specific Header Files for LCD Display and Touch Panel.

That’s how we import the LVGL Library into our Zig App!

Why do we write “c.something” when we call C functions? Like “c.lv_scr_act()”?

Remember that we import all C Functions and Macros into the c” Namespace

/// Import Functions and Macros into "c" Namespace
const c = @cImport({ ... });

That’s why we write “c.something” when we refer to C Functions and Macros.

What about the Main Function of our Zig App?

It gets complicated. We’ll talk later about the Main Function.

§5 Compile Zig App

Below are the steps to compile our Zig LVGL App for Apache NuttX RTOS and BL602 RISC-V SoC.

First we download the latest version of Zig Compiler (0.10.0 or later), extract it and add to PATH…

Then we download and compile Apache NuttX RTOS for PineDio Stack BL604…

After building NuttX, we download and compile our Zig LVGL App

##  Download our Zig LVGL App for NuttX
git clone --recursive https://github.com/lupyuen/zig-lvgl-nuttx
cd zig-lvgl-nuttx

##  Compile the Zig App for BL602
##  (RV32IMACF with Hardware Floating-Point)
##  TODO: Change "$HOME/nuttx" to your NuttX Project Directory
zig build-obj \
  --verbose-cimport \
  -target riscv32-freestanding-none \
  -mcpu=baseline_rv32-d \
  -isystem "$HOME/nuttx/nuttx/include" \
  -I "$HOME/nuttx/apps/graphics/lvgl" \
  -I "$HOME/nuttx/apps/graphics/lvgl/lvgl" \
  -I "$HOME/nuttx/apps/include" \
  -I "$HOME/nuttx/apps/examples/lvgltest" \
  lvgltest.zig

(See the Compile Log)

Note that target and mcpu are specific to BL602…

How did we get the Compiler Options -isystem and -I?

Remember that we’ll link our Compiled Zig App with Apache NuttX RTOS.

Hence the Zig Compiler Options must be the same as the GCC Options used to compile NuttX.

(See the GCC Options for NuttX)

Next comes a quirk specific to BL602: We must patch the ELF Header from Software Floating-Point ABI to Hardware Floating-Point ABI…

##  Patch the ELF Header of `lvgltest.o` 
##  from Soft-Float ABI to Hard-Float ABI
xxd -c 1 lvgltest.o \
  | sed 's/00000024: 01/00000024: 03/' \
  | xxd -r -c 1 - lvgltest2.o
cp lvgltest2.o lvgltest.o

(More about this)

Finally we inject our Compiled Zig App into the NuttX Project Directory and link it into the NuttX Firmware

##  Copy the compiled app to NuttX and overwrite `lvgltest.o`
##  TODO: Change "$HOME/nuttx" to your NuttX Project Directory
cp lvgltest.o $HOME/nuttx/apps/examples/lvgltest/lvgltest*.o

##  Build NuttX to link the Zig Object from `lvgltest.o`
##  TODO: Change "$HOME/nuttx" to your NuttX Project Directory
cd $HOME/nuttx/nuttx
make

##  For WSL: Copy the NuttX Firmware to c:\blflash for flashing
mkdir /mnt/c/blflash
cp nuttx.bin /mnt/c/blflash

We’re ready to run our Zig App!

LVGL Test App

§6 Run Zig App

Follow these steps to flash and boot NuttX (with our Zig App inside) on PineDio Stack…

In the NuttX Shell, enter this command to start our Zig App…

lvgltest

We should see…

Zig LVGL Test
tp_init: Opening /dev/input0
cst816s_get_touch_data: DOWN: id=0, touch=0, x=176, y=23
...
tp_cal result
offset x:23, y:14
range x:189, y:162
invert x/y:1, x:0, y:1

(See the complete log)

Our Zig App responds to touch and correctly renders the LVGL Screen (pic above).

Yep we have successfully built an LVGL Touchscreen App with Zig!

(We’ll talk about Touch Input in a while)

§7 Simplify LVGL API

Can we make LVGL a little friendlier with Zig? Such that this code…

// Get the Active Screen
const screen = c.lv_scr_act().?;

// Create a Label Widget
const label = c.lv_label_create(screen, null).?;

// Wrap long lines in the label text
c.lv_label_set_long_mode(label, c.LV_LABEL_LONG_BREAK);

// Interpret color codes in the label text
c.lv_label_set_recolor(label, true);

(Source)

Becomes this?

// Get the Active Screen
var screen = try lvgl.getActiveScreen();

// Create a Label Widget
var label = try screen.createLabel();

// Wrap long lines in the label text
label.setLongMode(c.LV_LABEL_LONG_BREAK);

// Interpret color codes in the label text
label.setRecolor(true);

(Source)

Yes we can! By wrapping the LVGL API in Zig, which we’ll do in the next section.

Note that we now use “try” instead of “.?” to check the values returned by LVGL…

// Check that Active Screen is valid with `try`
var screen = try lvgl.getActiveScreen();

What happens if we forget to “try”?

If we don’t “try”, like this…

// Get the Active Screen without `try`
var screen = lvgl.getActiveScreen();

// Attempt to use the Active Screen
_ = screen;

Zig Compiler stops us with an error…

./lvgltest.zig:109:9:
error: error is discarded. 
consider using `try`, `catch`, or `if`
  _ = screen;
      ^

Thus “try” is actually safer than “.?”, Zig Compiler mandates that we check for errors.

What if LVGL returns a Null Pointer to Zig?

Our app will fail gracefully with an Application Error

lv_scr_act failed
createWidgets failed: error.UnknownError

(Because of this Error Handler)

§8 Wrap LVGL API

Earlier we saw the hypothetical LVGL API wrapped with Zig, let’s make it real in 3 steps…

§8.1 Get Active Screen

Below is the implementation of getActiveScreen, which fetches the Active Screen from LVGL…

/// Return the Active Screen
pub fn getActiveScreen() !Object {

  // Get the Active Screen
  const screen = c.lv_scr_act();

  // If successfully fetched...
  if (screen) |s| {
    // Wrap Active Screen as Object and return it
    return Object.init(s);
  } else {
    // Unable to get Active Screen
    std.log.err("lv_scr_act failed", .{});
    return LvglError.UnknownError;
  }
}

(Source)

What’s this unusual if expression?

if (screen) |s| 
  { ... } else { ... }

That’s how we check if screen is null.

If screen is not null, then s becomes the non-null value of screen. And we create an Object Struct with s inside…

if (screen) |s| 
  { return Object.init(s); }
  ...

But if screen is null, we do the else clause and return an Error…

if (screen) |s| 
  { ... }
else
  { return LvglError.UnknownError; }

(LvglError is defined here)

That’s why the Return Type for our function is !Object

pub fn getActiveScreen() !Object
  { ... }

It returns either an Object Struct or an Error. (“!” means Error)

Let’s talk about the Object Struct…

§8.2 Object Struct

Object is a Zig Struct that wraps around an LVGL Object (like the Active Screen).

It defines 2 Methods…

/// LVGL Object
pub const Object = struct {

  /// Pointer to LVGL Object
  obj: *c.lv_obj_t,

  /// Init the Object
  pub fn init(obj: *c.lv_obj_t) Object {
    return .{ .obj = obj };
  }

  /// Create a Label as a child of the Object
  pub fn createLabel(self: *Object) !Label {

    // Assume we won't copy from another Object 
    const copy: ?*const c.lv_obj_t = null;

    // Create the Label
    const label = c.lv_label_create(self.obj, copy);

    // If successfully created...
    if (label) |l| {
      // Wrap as Label and return it
      return Label.init(l);
    } else {
      // Unable to create Label
      std.log.err("lv_label_create failed", .{});
      return LvglError.UnknownError;
    }
  }
};

(Source)

§8.3 Label Struct

Finally we have Label, a Zig Struct that wraps around an LVGL Label.

It defines a whole bunch of Methods to set the Label Properties, Text and Position

/// LVGL Label
pub const Label = struct {

  /// Pointer to LVGL Label
  obj: *c.lv_obj_t,

  /// Init the Label
  pub fn init(obj: *c.lv_obj_t) Label {
    return .{ .obj = obj };
  }

  /// Set the wrapping of long lines in the label text
  pub fn setLongMode(self: *Label, long_mode: c.lv_label_long_mode_t) void {
    c.lv_label_set_long_mode(self.obj, long_mode);
  }

  /// Set the label text alignment
  pub fn setAlign(self: *Label, alignment: c.lv_label_align_t) void {
    c.lv_label_set_align(self.obj, alignment);
  }

  /// Enable or disable color codes in the label text
  pub fn setRecolor(self: *Label, en: bool) void {
    c.lv_label_set_recolor(self.obj, en);
  }

  /// Set the label text and colors
  pub fn setText(self: *Label, text: [*c]const u8) void {
    c.lv_label_set_text(self.obj, text);
  }

  /// Set the object width
  pub fn setWidth(self: *Label, w: c.lv_coord_t) void {
    c.lv_obj_set_width(self.obj, w);
  }

  /// Set the object alignment
  pub fn alignObject(self: *Label, alignment: c.lv_align_t, x_ofs: c.lv_coord_t, y_ofs: c.lv_coord_t) void {
    const base: ?*const c.lv_obj_t = null;
    c.lv_obj_align(self.obj, base, alignment, x_ofs, y_ofs);
  }
};

(Source)

Let’s call the wrapped LVGL API…

Our app calling the LVGL API wrapped with Zig

(Source)

§8.4 After Wrapping

With the wrapped LVGL API, our Zig App looks neater and safer…

/// Create the LVGL Widgets that will be rendered on the display. Calls the
/// LVGL API that has been wrapped in Zig. Based on
/// https://docs.lvgl.io/7.11/widgets/label.html#label-recoloring-and-scrolling
fn createWidgetsWrapped() !void {

  // Get the Active Screen
  var screen = try lvgl.getActiveScreen();

  // Create a Label Widget
  var label = try screen.createLabel();

  // Wrap long lines in the label text
  label.setLongMode(c.LV_LABEL_LONG_BREAK);

  // Interpret color codes in the label text
  label.setRecolor(true);

  // Center align the label text
  label.setAlign(c.LV_LABEL_ALIGN_CENTER);

  // Set the label text and colors
  label.setText(
    "#ff0000 HELLO# " ++    // Red Text
    "#00aa00 PINEDIO# " ++  // Green Text
    "#0000ff STACK!# "      // Blue Text
  );

  // Set the label width
  label.setWidth(200);

  // Align the label to the center of the screen, shift 30 pixels up
  label.alignObject(c.LV_ALIGN_CENTER, 0, -30);
}

(Source)

(Compare with earlier unwrapped version)

No more worries about catching Null Pointers!

(Someday LV_LABEL_LONG_BREAK and the other constants will become Enums)

Wrapping the LVGL API in Zig sounds like a lot of work?

Yep probably. Here are some ways to Auto-Generate the Zig Wrapper for LVGL…

Also remember that LVGL is Object-Oriented. Designing the right wrapper with Zig might be challenging…

§9 Zig vs Bit Fields

Zig sounds amazing! Is there anything that Zig won’t do?

Sadly Zig won’t import C Structs containing Bit Fields.

Zig calls it an Opaque Type because Zig can’t access the fields inside these structs.

Any struct that contains an Opaque Type also becomes an Opaque Type. So yeah this quirk snowballs quickly.

(Zig Compiler version 0.10.0 has this Bit Field limitation, it might have been fixed in later versions of the compiler)

LVGL uses Bit Fields?

If we look at LVGL’s Color Type lv_color_t (for 16-bit color)…

// LVGL Color Type (16-bit color)
typedef union {
  struct {
    // Bit Fields for RGB Color
    uint16_t blue  : 5;
    uint16_t green : 6;
    uint16_t red   : 5;
  } ch;
  uint16_t full;
} lv_color16_t;

It uses Bit Fields to represent the RGB Colors.

Which means Zig can’t access the red / green / blue fields of the struct.

(But passing a pointer to the struct is OK)

Which LVGL Structs are affected?

So far we have identified these LVGL Structs that contain Bit Fields…

Is there a workaround?

Our workaround is to access the structs for Color, Display Buffer, Display Driver and Input Driver inside C Functions

And we pass the Struct Pointers to Zig.

Which explains why we see pointers to LVGL Structs in our Main Function

Also that’s why we handle Touch Input in C (instead of Zig), until we find a better solution.

Touch Input Calibration in our Zig LVGL App

§10 Zig Outcomes

Have we gained anything by coding our LVGL App in Zig?

The parts coded in Zig will benefit from the Safety Checks enforced by Zig at runtime: Overflow, Underflow, Array Out-of-Bounds, …

We’ve seen earlier that Zig works well at catching Null Pointers and Application Errors

And that it’s possible (with some effort) to create a friendlier, safer interface for LVGL…

What about the downsides of Zig?

Zig doesn’t validate pointers (like with a Borrow Checker) so it isn’t Memory Safe (yet)…

Zig (version 0.10.0) has a serious limitation: It won’t work with C Structs containing Bit Fields

We found a crude workaround: Handle these structs in C and pass the Struct Pointers to Zig…

But this might become a showstopper as we work with Complex LVGL Widgets. (Like LVGL Canvas)

I’ll run more experiments with LVGL on Zig and report the outcome.

§11 What’s Next

I hope this article has inspired you to create LVGL Apps in Zig!

(But if you prefer to wait for Zig 1.0… That’s OK too!)

Here are some tips for learning Zig…

Check out my earlier work on Zig, NuttX and LoRaWAN…

Many Thanks to my GitHub Sponsors for supporting my work! This article wouldn’t have been possible without your support.

Got a question, comment or suggestion? Create an Issue or submit a Pull Request here…

lupyuen.github.io/src/lvgl.md

§12 Notes

  1. This article is the expanded version of this Twitter Thread

Touch Input Calibration in our Zig LVGL App

§13 Appendix: Main Function

Below is our Main Function in Zig that does the following…

We begin by importing the libraries and declaring our Main Function: lvgltest.zig

/// Import the Zig Standard Library
const std = @import("std");

/// Import our Wrapped LVGL Module
const lvgl = @import("lvgl.zig");

/// Omitted: Import the LVGL Library from C
const c = @cImport({ ... });

/// Main Function that will be called by NuttX. We render an LVGL Screen and
/// handle Touch Input.
pub export fn lvgltest_main(
    _argc: c_int, 
    _argv: [*]const [*]const u8
) c_int {
  debug("Zig LVGL Test", .{});
  // Command-line args are not used
  _ = _argc;
  _ = _argv;

(lvgl.zig is located here)

Why is argv declared as “[*]const [*]const u8”?

That’s because…

(More about Zig Pointers)

Why the “_ = something”?

This tells the Zig Compiler that we’re not using the value of “something”.

The Zig Compiler helpfully stops us if we forget to use a Variable (like _argc) or the Returned Value for a Function (like for lv_task_handler).

Next we initialise the LVGL Library

  // Init LVGL Library
  c.lv_init();

  // Init Display Buffer
  const disp_buf = c.get_disp_buf().?;
  c.init_disp_buf(disp_buf);

  // Init Display Driver
  const disp_drv = c.get_disp_drv().?;
  c.init_disp_drv(disp_drv, disp_buf, monitorCallback);

Because Zig won’t work with C Structs containing Bit Fields, we handle them in C instead…

(monitorCallback is defined here)

We initialise the LCD Driver

  // Init LCD Driver
  if (c.lcddev_init(disp_drv) != c.EXIT_SUCCESS) {

    // If failed, try Framebuffer Driver
    if (c.fbdev_init(disp_drv) != c.EXIT_SUCCESS) {

      // No possible drivers left, fail
      return c.EXIT_FAILURE;
    }
  }

  // Register Display Driver with LVGL
  _ = c.lv_disp_drv_register(disp_drv);

  // Init Touch Panel
  _ = c.tp_init();

These C Functions are specific to Apache NuttX RTOS

  // Init Input Driver. tp_read will be called periodically
  // to get the touched position and state
  const indev_drv = c.get_indev_drv().?;
  c.init_indev_drv(indev_drv, c.tp_read);

Again, Zig won’t work with the Input Driver because the C Struct contains Bit Fields, so we handle it in C…

We create the LVGL Widgets (the wrapped or unwrapped way)…

  // Create the widgets for display
  createWidgetsUnwrapped()
    catch |e| {
      // In case of error, quit
      std.log.err("createWidgets failed: {}", .{e});
      return c.EXIT_FAILURE;
    };

  // To call the LVGL API that's wrapped in Zig, change
  // `createWidgetsUnwrapped` above to `createWidgetsWrapped`

We’ve seen these Zig Functions earlier…

We prepare the Touch Panel Calibration

  // Start Touch Panel calibration
  c.tp_cal_create();

(tp_cal_create is defined here)

This renders (in C) the LVGL Widgets for Touch Panel Calibration, as shown in the pic above.

(Watch the calibration demo on YouTube)

(Can this be done in Zig? Needs exploration)

Finally we loop forever handling LVGL Events

  // Loop forever handing LVGL tasks
  while (true) {

    // Handle LVGL tasks
    _ = c.lv_task_handler();

    // Sleep a while
    _ = c.usleep(10000);
  }
  return 0;
}

Our Zig App includes a Custom Logger and Panic Handler.

They are explained below…

§14 Appendix: Compiler Options

For the LVGL App in C, Apache NuttX RTOS compiles it with this GCC Command…

##  App Source Directory
cd $HOME/nuttx/apps/examples/lvgltest

##  Compile lvgltest.c with GCC
riscv64-unknown-elf-gcc \
  -c \
  -fno-common \
  -Wall \
  -Wstrict-prototypes \
  -Wshadow \
  -Wundef \
  -Os \
  -fno-strict-aliasing \
  -fomit-frame-pointer \
  -fstack-protector-all \
  -ffunction-sections \
  -fdata-sections \
  -g \
  -march=rv32imafc \
  -mabi=ilp32f \
  -mno-relax \
  -isystem "$HOME/nuttx/nuttx/include" \
  -D__NuttX__ \
  -DNDEBUG \
  -DARCH_RISCV  \
  -pipe \
  -I "$HOME/nuttx/apps/graphics/lvgl" \
  -I "$HOME/nuttx/apps/graphics/lvgl/lvgl" \
  -I "$HOME/nuttx/apps/include" \
  -DLV_LVGL_H_INCLUDE_SIMPLE \
  -Wno-format \
  -Dmain=lvgltest_main \
  -lvgltest.c \
  -o lvgltest.c.home.user.nuttx.apps.examples.lvgltest.o

(As observed from “make --trace”)

(lvgltest.c is located here)

The above options for “-isystem” and “-I”…

-isystem "$HOME/nuttx/nuttx/include"
-I "$HOME/nuttx/apps/graphics/lvgl"
-I "$HOME/nuttx/apps/graphics/lvgl/lvgl"
-I "$HOME/nuttx/apps/include"

Were passed to the Zig Compiler when compiling our Zig App…

As for the above “#defines”…

-D__NuttX__
-DNDEBUG
-DARCH_RISCV
-DLV_LVGL_H_INCLUDE_SIMPLE

We set them at the top of our Zig Program

The GCC Options above were also passed to the Zig Compiler for Auto-Translating the LVGL App from C to Zig…

§15 Appendix: Auto-Translate LVGL App to Zig

Zig Compiler can Auto-Translate C code to Zig. (See this)

We used the Auto-Translation as a Reference when converting our LVGL App from C to Zig.

Here’s how we Auto-Translate our LVGL App lvgltest.c from C to Zig…

Like this…

##  App Source Directory
cd $HOME/nuttx/apps/examples/lvgltest

##  Auto-translate lvgltest.c from C to Zig
zig translate-c \
  -target riscv32-freestanding-none \
  -mcpu=baseline_rv32-d \
  -cflags \
    -fno-common \
    -Wall \
    -Wstrict-prototypes \
    -Wshadow \
    -Wundef \
    -Os \
    -fno-strict-aliasing \
    -fomit-frame-pointer \
    -fstack-protector-all \
    -ffunction-sections \
    -fdata-sections \
    -g \
    -mabi=ilp32f \
    -mno-relax \
    -Wno-format \
  -- \
  -isystem "$HOME/nuttx/nuttx/include" \
  -D__NuttX__ \
  -DNDEBUG \
  -DARCH_RISCV  \
  -I "$HOME/nuttx/apps/graphics/lvgl" \
  -I "$HOME/nuttx/apps/graphics/lvgl/lvgl" \
  -I "$HOME/nuttx/apps/include" \
  -DLV_LVGL_H_INCLUDE_SIMPLE \
  -Dmain=lvgltest_main  \
  lvgltest.c \
  >lvgltest.zig

Note that target and mcpu are specific to BL602…

Zig Compiler internally uses Clang (instead of GCC) to interpret our C code.

We made 2 fixes to our C code to support Clang…

Here’s the original C code: lvgltest.c

And the Auto-Translation from C to Zig: translated/lvgltest.zig

The Auto-Translation looks way too verbose for a Zig Program, but it’s a good start for converting our LVGL App from C to Zig.

We hit some issues with Opaque Types in the Auto-Translation, this is how we fixed them…

§16 Appendix: Zig Auto-Translation is Incomplete

(Note: We observed this issue with Zig Compiler version 0.10.0, it might have been fixed in later versions of the compiler)

The Auto-Translation from C to Zig was initially missing 2 key functions

The Auto-Translated Zig code shows…

// lvgltest.c:129:13: warning: unable to translate function, demoted to extern
pub extern fn create_widgets() callconv(.C) void;
// lvgltest.c:227:17: warning: local variable has opaque type

// (no file):353:14: warning: unable to translate function, demoted to extern
pub extern fn lvgltest_main(arg_argc: c_int, arg_argv: [*c][*c]u8) c_int;

(Source)

When we look up lvgltest.c line 227…

int lvgltest_main(int argc, FAR char *argv[]) {
  // lvgltest.c:227:17: warning: local variable has opaque type
  lv_disp_drv_t disp_drv;
  lv_disp_buf_t disp_buf;
  ...

(Source)

We see that Zig couldn’t import the struct for LVGL Display Driver lv_disp_drv_t because it’s an Opaque Type.

Let’s find out why…

§17 Appendix: Zig Opaque Types

(Note: We observed this issue with Zig Compiler version 0.10.0, it might have been fixed in later versions of the compiler)

What’s an Opaque Type in Zig?

Opaque Types are Zig Structs with unknown, inaccessible fields.

When we import into Zig a C Struct that contains Bit Fields, it becomes an Opaque Type…

For example, LVGL’s Color Type contains Bit Fields. It becomes an Opaque Type when we import into Zig…

// LVGL Color Type (16-bit color)
typedef union {
  struct {
    // Bit Fields for RGB Color
    uint16_t blue  : 5;
    uint16_t green : 6;
    uint16_t red   : 5;
  } ch;
  uint16_t full;
} lv_color16_t;

What’s wrong with Opaque Types?

Zig Compiler won’t let us access the fields of an Opaque Type. But it’s OK to pass a pointer to an Opaque Type.

Any struct that contains a (non-pointer) Opaque Type, also becomes an Opaque Type.

How do we discover Opaque Types?

In the previous section, Zig Compiler has identified the LVGL Display Driver lv_disp_drv_t as an Opaque Type…

// warning: local variable has opaque type
lv_disp_drv_t disp_drv;

To find out why, we search for lv_disp_drv_t in the Auto-Translated Zig code…

// nuttx/apps/graphics/lvgl/lvgl/src/lv_hal/lv_hal_disp.h:154:9: 
// warning: struct demoted to opaque type - has bitfield
pub const lv_disp_drv_t = struct__disp_drv_t; 
pub const struct__disp_drv_t = opaque {};

// nuttx/apps/graphics/lvgl/lvgl/src/lv_hal/lv_hal_disp.h:59:23: 
// warning: struct demoted to opaque type - has bitfield
pub const lv_disp_t = struct__disp_t;
pub const struct__disp_t = opaque {};

pub const lv_disp_buf_t = opaque {};

(Source)

Which says that lv_disp_drv_t contains Bit Fields.

To verify, we look up the C definitions of lv_disp_drv_t, lv_disp_t and lv_disp_buf_t from LVGL…

// LVGL Display Driver
typedef struct _disp_drv_t {
  uint32_t rotated : 1;
  uint32_t dpi     : 10;
  ...
} lv_disp_drv_t;

// LVGL Display
typedef struct _disp_t {
  uint8_t del_prev  : 1;
  uint32_t inv_p    : 10;
  ...
} lv_disp_t;

// LVGL Display Buffer
typedef struct {
  volatile uint32_t last_area : 1;
  volatile uint32_t last_part : 1;
  ...
} lv_disp_buf_t;

We’re now certain that Zig Compiler couldn’t import the structs because they indeed contain Bit Fields.

Let’s fix the Opaque Types, by passing them as pointers…

§17.1 Fix Opaque Types

(Note: We observed this issue with Zig Compiler version 0.10.0, it might have been fixed in later versions of the compiler)

Earlier we saw that Zig couldn’t import these C Structs because they contain Bit Fields

Instead of creating and accessing these structs in Zig, we do it in C instead

// Return the static instance of Display Driver, because Zig can't
// allocate structs wth bitfields inside.
lv_disp_drv_t *get_disp_drv(void) {
  static lv_disp_drv_t disp_drv;
  return &disp_drv;
}

// Return the static instance of Display Buffer, because Zig can't
// allocate structs wth bitfields inside.
lv_disp_buf_t *get_disp_buf(void) {
  static lv_disp_buf_t disp_buf;
  return &disp_buf;
}

// Initialise the Display Driver, because Zig can't access its fields.
void init_disp_drv(lv_disp_drv_t *disp_drv,
  lv_disp_buf_t *disp_buf,
  void (*monitor_cb)(struct _disp_drv_t *, uint32_t, uint32_t)) {
  assert(disp_drv != NULL);
  assert(disp_buf != NULL);
  assert(monitor_cb != NULL);

  lv_disp_drv_init(disp_drv);
  disp_drv->buffer = disp_buf;
  disp_drv->monitor_cb = monitor_cb;
}

// Initialise the Display Buffer, because Zig can't access the fields.
void init_disp_buf(lv_disp_buf_t *disp_buf) {
  assert(disp_buf != NULL);
  lv_disp_buf_init(disp_buf, buffer1, buffer2, DISPLAY_BUFFER_SIZE);
}

(Source)

Then we modify our C Main Function to access these structs via pointers

int lvgltest_main(int argc, FAR char *argv[]) {
  // Fetch pointers to Display Driver and Display Buffer
  lv_disp_drv_t *disp_drv = get_disp_drv();
  lv_disp_buf_t *disp_buf = get_disp_buf();
  ...
  // Init Display Buffer and Display Driver as pointers
  init_disp_buf(disp_buf);
  init_disp_drv(disp_drv, disp_buf, monitor_cb);
  ...
  // Init Input Driver as pointer
  lv_indev_drv_t *indev_drv = get_indev_drv();
  init_indev_drv(indev_drv, tp_read);

(Source)

(get_indev_drv and init_indev_drv are explained in the next section)

After this modification, our Auto-Translation from C to Zig now contains the 2 missing functions…

Which we used as a reference to convert our LVGL App from C to Zig.

That’s why our Zig Main Function passes pointers to the Display Buffer and Display Driver, instead of working directly with the structs…

§17.2 Input Driver

(Note: We observed this issue with Zig Compiler version 0.10.0, it might have been fixed in later versions of the compiler)

LVGL Input Driver lv_indev_drv_t is another Opaque Type because it contains Bit Fields.

We fix lv_indev_drv_t the same way as other Opaque Types: We allocate and initialise the structs in C (instead of Zig)…

// Return the static instance of Input Driver, because Zig can't
// allocate structs wth bitfields inside.
lv_indev_drv_t *get_indev_drv(void) {
  static lv_indev_drv_t indev_drv;
  return &indev_drv;
}

// Initialise the Input Driver, because Zig can't access its fields.
void init_indev_drv(lv_indev_drv_t *indev_drv,
  bool (*read_cb)(struct _lv_indev_drv_t *, lv_indev_data_t *)) {
  assert(indev_drv != NULL);
  assert(read_cb != NULL);

  lv_indev_drv_init(indev_drv);
  indev_drv->type = LV_INDEV_TYPE_POINTER;

  // This function will be called periodically (by the library) to get the
  // mouse position and state.
  indev_drv->read_cb = read_cb;
  lv_indev_drv_register(indev_drv);
}

(Source)

These functions are called by our Zig Main Function during initialisation…

§17.3 Color Type

(Note: We observed this issue with Zig Compiler version 0.10.0, it might have been fixed in later versions of the compiler)

We fixed all references to LVGL Color Type lv_color_t

// LVGL Canvas Demo doesn't work with zig cc because of `lv_color_t`
#if defined(CONFIG_USE_LV_CANVAS) && !defined(__clang__)  

  // Set the Canvas Buffer (Warning: Might take a lot of RAM!)
  static lv_color_t cbuf[LV_CANVAS_BUF_SIZE_TRUE_COLOR(CANVAS_WIDTH, CANVAS_HEIGHT)];
  ...

(Source)

That’s because lv_color_t is another Opaque Type…

// From Zig Auto-Translation
pub const lv_color_t = lv_color16_t;

pub const lv_color16_t = extern union {
  ch: struct_unnamed_7,
  full: u16,
};

// nuttx/apps/graphics/lvgl/lvgl/src/lv_core/../lv_draw/../lv_misc/lv_color.h:240:18:
// warning: struct demoted to opaque type - has bitfield
const struct_unnamed_7 = opaque {};

(Source)

That contains Bit Fields

// LVGL Color Type (16-bit color)
typedef union {
  struct {
    // Bit fields for lv_color16_t (aliased to lv_color_t)
    uint16_t blue : 5;
    uint16_t green : 6;
    uint16_t red : 5;
  } ch;
  uint16_t full;
} lv_color16_t;

Hence we can’t work directly with the LVGL Color Type in Zig. (But we can pass pointers to it)

Is that a problem?

Some LVGL Widgets need us to specify the LVGL Color. (Like for LVGL Canvas)

This gets tricky in Zig, since we can’t manipulate LVGL Color.

(More about LVGL Canvas)

Why not fake the LVGL Color Type in Zig?

// Fake the LVGL Color Type in Zig
const lv_color16_t = extern union {
  ch:   u16,  // Bit Fields add up to 16 bits
  full: u16,
};

We could, but then the LVGL Types in Zig would become out of sync with the original LVGL Definitions in C.

This might cause problems when we upgrade the LVGL Library.

§18 Appendix: Auto-Generate Zig Wrapper

Can we auto-generate the Zig Wrapper for LVGL?

Earlier we talked about the Zig Wrapper for LVGL that will make LVGL a little safer and friendlier…

To Auto-Generate the LVGL Wrapper, we might use Type Reflection in Zig…

Or we can look up the Type Info JSON generated by Zig Compiler…

## Emit IR (LLVM), BC (LLVM) and Type Info JSON
zig build-obj \
  -femit-llvm-ir \
  -femit-llvm-bc \
  -femit-analysis \
  --verbose-cimport \
  -target riscv32-freestanding-none \
  -mcpu=baseline_rv32-d \
  -isystem "$HOME/nuttx/nuttx/include" \
  -I "$HOME/nuttx/apps/graphics/lvgl" \
  -I "$HOME/nuttx/apps/graphics/lvgl/lvgl" \
  -I "$HOME/nuttx/apps/include" \
  -I "$HOME/nuttx/apps/examples/lvgltest" \
  lvgltest.zig

This produces the IR (LLVM), BC (LLVM) and Type Info JSON files…

lvgltest.ll
lvgltest.bc
lvgltest-analysis.json

(See the files)

Let’s look up the Type Info for the LVGL Function lv_obj_align.

We search for lv_obj_align in lvgltest-analysis.json

"decls":
  ...
  {
   "import": 99,
   "src": 1962,
   "name": "lv_obj_align",
   "kind": "const",
   "type": 148,  // lv_obj_align has Type 148
   "value": 60
  },

Then we look up Type 148 in lvgltest-analysis.json

$ jq '.types[148]' lvgltest-analysis.json
{
  "kind": 18,
  "name": "fn(?*.cimport:10:11.struct__lv_obj_t, ?*const .cimport:10:11.struct__lv_obj_t, u8, i16, i16) callconv(.C) void",
  "generic": false,
  "ret": 70,
  "args": [
    79,  // First Para has Type 79
    194,
    95,
    134,
    134
  ]
}

The First Parameter has Type 79, so we look up lvgltest-analysis.json and follow the trail…

$ jq '.types[79]' lvgltest-analysis.json
{
  "kind": 13,
  "child": 120
}
## Kind 13 is `?` (Optional)

$ jq '.types[120]' lvgltest-analysis.json
{
  "kind": 6,
  "elem": 137
}
## Kind 6 is `*` (Pointer)

$ jq '.types[137]' lvgltest-analysis.json
{
  "kind": 20,
  "name": ".cimport:10:11.struct__lv_obj_t"
}
## Kind 20 is `struct`???

Which gives us the complete type of the First Parameter

?*.cimport:10:11.struct__lv_obj_t

With this Type Info, we could generate the Zig Wrapper for all LVGL Functions.

We don’t have the Parameter Names though, we might need to parse the “.cimport” file.

(More about jq)

§18.1 Object-Oriented Wrapper for LVGL

Is LVGL really Object-Oriented?

Yep the LVGL API is actually Object-Oriented since it uses Inheritance.

All LVGL Widgets (Labels, Buttons, etc) have the same Base Type: lv_obj_t.

But some LVGL Functions will work only for specific Widgets, whereas some LVGL Functions will work on any Widget

The LVGL Docs also say that LVGL is Object-Oriented

Designing an Object-Oriented Zig Wrapper for LVGL might be challenging…

Our Zig Wrapper needs to support setWidth (and similar methods) for all LVGL Widgets.

To do this we might use Zig Interfaces and @fieldParentPtr

Which look somewhat similar to VTables in C++

Are there any Object-Oriented Bindings for LVGL?

The official Python Bindings for LVGL seem to be Object-Oriented. This could inspire our Object-Oriented Wrapper in Zig…

However the Python Bindings are Dynamically Typed, might be tricky implementing them as Static Types in Zig.

The LVGL Wrapper in this article was inspired by the zgt GUI Library, which wraps the GUI APIs for GTK, Win32 and WebAssembly…