Blog | About

Zig extension for Zephyr - blinking an LED

Apr 19, 2026

One thing that is conspicuosly missing from the Zig extension for Zephyr RTOS series is the real “Hello World!” for embedded software: blinking an LED. Let’s fix that!

Devicetree

Zephyr uses devicetree to describe hardware. To access the device information from the code, a series of macros is provided. They basically work by concatenating devicetree node names in a fairly complex fashion1. During build, Zephyr build system will create a series of ancillary macros in a file called devicetree_generated.h based on the devicetree2. Usually, Zephyr users don’t care about the details of those macros3, but as we’re going to need their functionality, we will care!

There are many devicetree macros in Zephyr devicetree.h, but we won’t need all of them for our sample. Using the blinking led sample, we see essentially two macros: DT_ALIAS and GPIO_DT_SPEC_GET.

When we check cimport.zig4, we see that DT_ALIAS was indeed translated to @compileError. But we don’t see GPIO_DT_SPEC_GET at all! After panicking for a while, we just remember that we didn’t include zephyr/drivers/gpio.h in our imports.h file. We fix that:

#include <autoconf.h>
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/llext/symbol.h>
#include <app_api.h>

After building, we can see that GPIO_DT_SPEC_GET was translated, and is not a @compileError! But our joy fades when we notice that it calls GPIO_DT_SPEC_GET_BY_IDX, and that one is indeed a @compileError. Time to work on their translation!

Translating devicetree macros

Let’s start with DT_ALIAS. If we check its definition, we’ll see:

#define DT_ALIAS(alias) DT_CAT(DT_N_ALIAS_, alias)

Where DT_CAT just concatenates the two arguments. This will create the name of a new identifier. So that something like:

#define LED0_NODE DT_ALIAS(led0)

Becomes:

#define LED0_NODE DT_N_ALIAS_led0

Indeed, if we look at cimport.zig, we can see that there’s a identifier called DT_N_ALIAS_led0:

pub const DT_N_ALIAS_led0 = @compileError("unable to translate macro: undefined identifier `DT_N_S_leds_S_led_3`");

Err... It seems that even if we can properly translate DT_ALIAS, it will still just fail down the line. But let’s not suffer before time, translating just DT_ALIAS should be straightforward in Zig:

pub inline fn DT_ALIAS(comptime alias: []const u8) []const u8 {
    return @field(@This(), "DT_N_ALIAS_" ++ alias);
}

There are some bits to unpack here. First, we’re using comptime as this information - the alias we’re going to use - is available at compile time. The @field builtin function is used to access the field from first argument with the name of the second argument. We form the name of the field by concatenating DT_N_ALIAS_ to the name of the alias, as Zephyr expects. The first argument is the struct (or enum or union) where the field is located. In this case, we use @This builtin function to get the current file, where all things are being translated. Finally, the function returns a string, the contents of this field.

Keenly eyed readers will question why are we returning a string here. First, the field itself is a @compileError, so we need to fix that. Taking a look at devicetree_generated.h5, we see that DT_N_S_leds_S_led_3 is not ever defined. What?!

It is referenced many times, and a bunch of other definitions are prefixed with it, but it isn’t ever defined. It’s basically used as a prefix for the other definitions. To us, in Zig, it would be perfect if it was just a string that we can then concatenate with other stuff to get the real identifiers. How to do that?

An obvious way to approach this is to use our build.sh to handle this issue: for instance, use sed to change those @compileError identifiers to a string. Worth a try:

sed -ri 's|(DT_N_ALIAS_\w+ = )@compileError..unable.to.translate.macro..undefined.identifier..(\w+).*$|\1"\2";|g' $TRANSLATED_FILE

With that, all DT_N_ALIAS_ will simply become a string constant with the missing prefix. We can check that by rebuilding and checking what become of DT_N_ALIAS_led0:

pub const DT_N_ALIAS_led0 = "DT_N_S_leds_S_led_3";

Great! Now it makes sense that our DT_ALIAS return a string: it will concatenate the passed alias with DT_N_ALIAS_ to get the identifier which contains the prefix to be used in subsequent calls. Easy!

Moving on to GPIO_DT_SPEC_GET_BY_IDX, we see its definition:

#define GPIO_DT_SPEC_GET_BY_IDX(node_id, prop, idx)                \
    {                                      \
        .port = DEVICE_DT_GET(DT_GPIO_CTLR_BY_IDX(node_id, prop, idx)),\
        .pin = DT_GPIO_PIN_BY_IDX(node_id, prop, idx),             \
        .dt_flags = DT_GPIO_FLAGS_BY_IDX(node_id, prop, idx),          \
    }

It initialises a struct using even more devicetree macros. Let’s unpeel this onion. Starting with DT_GPIO_CTLR_BY_IDX, we see that it’s actually translated in our cimport.zig. However, it does refer to DT_PHANDLE_BY_IDX, which is not. It’s definition in C is fairly simple, actually:

#define DT_PHANDLE_BY_IDX(node_id, prop, idx) \
    DT_CAT6(node_id, _P_, prop, _IDX_, idx, _PH)

It just concatenates a bunch of things, we know how to do that:

pub inline fn DT_PHANDLE_BY_IDX(comptime node_id: []const u8, comptime prop: []const u8, comptime idx: u32) []const u8 {
    return @field(@This(), std.fmt.comptimePrint("{s}_P_{s}_IDX_{d}_PH", .{node_id, prop, idx}));
}

Note that we used comptimePrint because Zig won’t allow ++ with numbers.

However, this is another definition that only “maps” tokens, so we also need to account for that in our build.sh:

sed -ri 's|(DT_N_\w+_PH = )@compileError..unable.to.translate.macro..undefined.identifier..(\w+).*$|\1"\2";|g' $TRANSLATED_FILE

Next, DT_GPIO_PIN_BY_IDX. This one is @compileError in cimport.zig. Its definition is:

#define DT_GPIO_PIN_BY_IDX(node_id, gpio_pha, idx) \
    DT_PHA_BY_IDX(node_id, gpio_pha, idx, pin)

And DT_PHA_BY_IDX was also not translated, becoming a @compileError. But its definition is just the final concatenation step:

#define DT_PHA_BY_IDX(node_id, pha, idx, cell) \
    DT_CAT7(node_id, _P_, pha, _IDX_, idx, _VAL_, cell)

We got this:

pub inline fn DT_PHA_BY_IDX(comptime node_id: []const u8, comptime pha: []const u8, comptime idx: u32, comptime cell: []const u8) u5 {
    return @field(@This(), std.fmt.comptimePrint("{s}_P_{s}_IDX_{d}_VAL_{s}", .{node_id, pha, idx, cell}));
}

Note that this returns a small unsigned integer. If we look into devicetree_generated.h, we see that it expands to something like:

#define DT_N_S_leds_S_led_3_P_gpios_IDX_0_VAL_pin 10

Hence the return type6.

Then DT_GPIO_PIN_BY_IDX becomes:

pub inline fn DT_GPIO_PIN_BY_IDX(comptime node_id: []const u8, comptime pha: []const u8, comptime idx: u32) u5 {
    return DT_PHA_BY_IDX(node_id, pha, idx, "pin");
}

For DT_GPIO_FLAGS_BY_IDX, its definition is almost familiar:

#define DT_GPIO_FLAGS_BY_IDX(node_id, gpio_pha, idx) \
    DT_PHA_BY_IDX_OR(node_id, gpio_pha, idx, flags, 0)

But instead of using DT_PHA_BY_IDX, it uses DT_PHA_BY_IDX_OR, which we still didn’t translate. In cimport.zig it’s a @compileError. In Zephyr, its definition is:

#define DT_PHA_BY_IDX_OR(node_id, pha, idx, cell, default_value) \
    DT_PROP_OR(node_id, DT_CAT5(pha, _IDX_, idx, _VAL_, cell), default_value)

The good news is that DT_PROP_OR is translated in cimport.zig, but looking carefully, it depends on DT_NODE_HAS_PROP and DT_PROP, which are both @compileError, and COND_CODE_1, which indirectly depends on the untranslated Z_COND_CODE_1. Lots of work, let’s get into it.

For DT_PROP, its definition is a simple concatenation:

#define DT_PROP(node_id, prop) DT_CAT3(node_id, _P_, prop)

Which we translate to:

pub inline fn DT_PROP(comptime T: type, comptime node_id: []const u8, comptime prop: []const u8) T {
    return @field(@This(), node_id ++ "_P_" ++ prop);
}

Note that we added a generic type to DT_PROP, since it can be a string or a number (or more). With that, the call sites can inform the proper type to get. And Zig will complain if something is off.

And DT_NODE_HAS_PROP definition in Zephyr is:

#define DT_NODE_HAS_PROP(node_id, prop) \
    IS_ENABLED(DT_CAT4(node_id, _P_, prop, _EXISTS))

It basically concatenates some tokens to get the “_EXISTS” macro for a property, which is like:

#define DT_N_S_leds_S_led_3_P_gpios_IDX_0_EXISTS 1

So we can avoid caring about IS_ENABLED macro, which will be another rabbit hole of macrobatics7. Our translation then becomes:

pub inline fn DT_NODE_HAS_PROP(comptime node_id: []const u8, comptime prop: []const u8) bool {
    return @hasField(@This(), node_id ++ "_P_" ++ prop ++ "_EXISTS");
}

Note that we used @hasField - for properties, it doesn’t really matter the value of the definition, just that it exists.

Now a look into COND_CODE_1. Its a “typeless” expression that evaluates the first argument, and if it’s true (or equals to 1), it runs the second argument, or the third argument if not. This is a simplified overview, and it does some involved macro trickery. The absence of clear type is annoying. Maybe we shouldn’t try to go down this route. Let’s back up a bit and focus on DT_PROP_OR instead.

In Zephyr, it is defined as:

#define DT_PROP_OR(node_id, prop, default_value) \
    COND_CODE_1(DT_NODE_HAS_PROP(node_id, prop), \
        (DT_PROP(node_id, prop)), (default_value))

Basically, it checks if the device has the property and returns its value, or a default one if the device doesn’t have the property. We already have all the bits to translate that to Zig:

pub inline fn DT_PROP_OR(comptime T: type, comptime node_id: []const u8, comptime prop: []const u8, comptime default: T) T {
    return if (DT_NODE_HAS_PROP(node_id, prop)) DT_PROP(T, node_id, prop) else default;
}

However, this time we’re not replacing a @compileError constant, but a function that has a translation. So we need to update our build.sh to account for that:

DUPLICATE_INLINE_FUNCTIONS=$(grep -Pro "(?<=pub inline fn )(\w+)" "$MANUAL_IMPORTS")

for function in $DUPLICATE_INLINE_FUNCTIONS ; do
    sed -ri "/pub inline fn $function\>.*$/,/^}/d" $TRANSLATED_FILE
done

This will take care of removing the whole inline function.

We have everything we need to finish DT_PHA_BY_IDX_OR:

pub inline fn DT_PHA_BY_IDX_OR(comptime T: type, comptime node_id: []const u8, comptime pha: []const u8, comptime idx: u32, comptime cell: []const u8, comptime default: T) T {
    return DT_PROP_OR(T, node_id, std.fmt.comptimePrint("{s}_IDX_{d}_VAL_{s}", .{pha, idx, cell}), default);
}

So we can now complete DT_GPIO_FLAGS_BY_IDX:

pub inline fn DT_GPIO_FLAGS_BY_IDX(comptime node_id: []const u8, comptime pha: []const u8, comptime idx: u32) u5 {
    return DT_PHA_BY_IDX_OR(u5, node_id, pha, idx, "flags", 0);
}

Before tackling GPIO_DT_SPEC_GET_BY_IDX, we need to sort DEVICE_DT_GET out. But its C definition is a bit tricky:

#define DEVICE_DT_GET(node_id) (&DEVICE_DT_NAME_GET(node_id))

That & to get a pointer from the name provided by DEVICE_DT_NAME_GET will be problematic. Not because getting a pointer is an issue, it’s actually all we can do, since the symbol is extern in Zig. The issue is that if we try to replicate the “macro call chain” as is, we’ll end up with things that can not be evaluated at comptime. We’ll get back to that later, for now, let’s press on. The definition of DEVICE_DT_NAME_GET is:

#define DEVICE_DT_NAME_GET(node_id) DEVICE_NAME_GET(Z_DEVICE_DT_DEV_ID(node_id))

Where DEVICE_NAME_GET is fairly straightforward:

#define DEVICE_NAME_GET(dev_id) _CONCAT(__device_, dev_id)

The ordinary concatenation we know:

pub inline fn DEVICE_NAME_GET(comptime dev_id: []const u8) struct_device {
    return @field(@This(), "__device_" ++ dev_id);
}

For Z_DEVICE_DT_DEV_ID, we see that it’s already translated, but that Z_DEVICE_DT_DEP_ORD is not. Its definition is:

#define Z_DEVICE_DT_DEP_ORD(node_id) _CONCAT(dts_ord_, DT_DEP_ORD(node_id))

So we need DT_DEP_ORD, which is:

#define DT_DEP_ORD(node_id) DT_CAT(node_id, _ORD)

Another simple concatenation, in Zig:

pub inline fn DT_DEP_ORD(comptime node_id: []const u8) u5 {
    return @field(@This(), node_id ++ "_ORD");
}

At this, point, if we were to finally translate GPIO_DT_SPEC_GET_BY_IDX, we’d get something like:

pub inline fn GPIO_DT_SPEC_GET_BY_IDX(comptime node_id: []const u8, comptime prop: []const u8, comptime idx: u32) gpio_dt_spec  {
    return .{
        .port = DEVICE_DT_GET(DT_GPIO_CTLR_BY_IDX(node_id, prop, idx)),
        .pin = DT_GPIO_PIN_BY_IDX(node_id, prop, idx),
        .dt_flags = DT_GPIO_FLAGS_BY_IDX(node_id, prop, idx),
    };
}

That looks great, but if we were to move on and try to build, we’d be greeted by:

(...)zephyr/samples/subsys/llext/edk/k-ext1/src/cimport.zig:167468:12: error: unable to resolve comptime value
    return @field(@This(), "__device_" ++ dev_id);
           ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
(...)zephyr/samples/subsys/llext/edk/k-ext1/src/cimport.zig:102182:27: note: called at comptime from here
    return DEVICE_NAME_GET(Z_DEVICE_DT_DEV_ID(node_id));
           ~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~
(...)zephyr/samples/subsys/llext/edk/k-ext1/src/cimport.zig:102186:31: note: called at comptime from here
    return &DEVICE_DT_NAME_GET(node_id);
            ~~~~~~~~~~~~~~~~~~^~~~~~~~~
(...)zephyr/samples/subsys/llext/edk/k-ext1/src/cimport.zig:167488:30: note: called at comptime from here
        .port = DEVICE_DT_GET(DT_GPIO_CTLR_BY_IDX(node_id, prop, idx)),
                ~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
(...)zephyr/samples/subsys/llext/edk/k-ext1/src/cimport.zig:166947:35: note: called at comptime from here
    return GPIO_DT_SPEC_GET_BY_IDX(node_id, prop, @as(c_int, 0));
           ~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
(...)zephyr/samples/subsys/llext/edk/k-ext1/src/main.zig:12:31: note: called at comptime from here
const led = c.GPIO_DT_SPEC_GET(c.DT_ALIAS("led0"), "gpios");
            ~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~
(...)zephyr/samples/subsys/llext/edk/k-ext1/src/main.zig:12:31: note: initializer of container-level variable must be comptime-known

Note that we return the struct_device from DEVICE_NAME_GET, as that’s what’s needed to get DEVICE_DT_GET to work, which was automatically translated as:

pub inline fn DEVICE_DT_GET(node_id: anytype) @TypeOf(&DEVICE_DT_NAME_GET(node_id)) {
    _ = &node_id;
    return &DEVICE_DT_NAME_GET(node_id);
}

But we can’t get the struct itself from Zig, as it’s external to it. We can only get a pointer. So, some changes are needed. First, let’s make DEVICE_NAME_GET simply return a string, which is kinda what its name implies:

pub inline fn DEVICE_NAME_GET(comptime dev_id: []const u8) []const u8 {
    return "__device_" ++ dev_id;
}

And reimplement DEVICE_DT_GET to this new DEVICE_NAME_GET signature:

pub inline fn DEVICE_DT_GET(comptime dev_id: []const u8) *const struct_device {
    return &@field(@This(), DEVICE_DT_NAME_GET(dev_id));
}

Now things would build as expected.

GPIO syscalls

That should complete the devicetree part. But from the blinky sample, we’ll also need to use some GPIO API syscalls. Namely, we’ll need:

  • gpio_is_ready_dt
  • gpio_pin_configure_dt
  • gpio_pin_toggle_dt

And those are translated in our cimport.zig! But the joy is short lived, as when we follow their call chains, we get to the untranslated:

  • device_is_ready
  • gpio_pin_configure
  • gpio_port_toggle_bits

But translating syscalls is old stuff at this point. They just become:

pub fn gpio_pin_configure(port: *const struct_device, pin: gpio_pin_t, flags: gpio_flags_t) i32 {
    if (comptime CONFIG_USERSPACE == 1) {
        if (z_syscall_trap()) {
            return @bitCast(arch_syscall_invoke3(@intFromPtr(port), pin, flags));
        }
    }

    compiler_barrier();
    return z_impl_gpio_pin_configure(port, pin, flags);
}

pub fn gpio_port_toggle_bits(port: *const struct_device, pins: gpio_port_pins_t) i32 {
    if (comptime CONFIG_USERSPACE == 1) {
        if (z_syscall_trap()) {
            return @bitCast(arch_syscall_invoke2(@intFromPtr(port), pins));
        }
    }

    compiler_barrier();
    return z_impl_gpio_port_toggle_bits(port, pins);
}

pub fn device_is_ready(dev: *const struct_device) bool {
    if (comptime CONFIG_USERSPACE == 1) {
        if (z_syscall_trap()) {
            return arch_syscall_invoke1(@intFromPtr(dev)) != 0;
        }
    }

    compiler_barrier();
    return z_impl_device_is_ready(dev);
}

Phew! That was a lot of work! But now we should have all the pieces we need to make this board blink. From the blinky sample, we need to do four things. First, get the device:

const led = c.GPIO_DT_SPEC_GET(c.DT_ALIAS("led0"), "gpios");

Second, ensure it’s ready (adding this after the thread creation in the sample, so we follow the error return numbers from there):

if (!c.gpio_is_ready_dt(&led)) {
    c.printk("[zig][k-ext1]LED is not ready!\n");
    return 6;
}

Third, configure it:

var ret = c.gpio_pin_configure_dt(&led, c.GPIO_OUTPUT_ACTIVE);
if (ret < 0) {
    c.printk("[zig][k-ext1]gpio_pin_configure_dt failed!\n");
    return 7;
}

Finally, toggle the LED, inside the while loop:

c.printk("[zig][k-ext1]Toggling light!\n");
ret = c.gpio_pin_toggle_dt(&led);
if (ret < 0) {
    c.printk("[zig][k-ext1]Failed to toggle light!\n");
}

And the red LED shall blink twice!

It was a lot of work, specially the devicetree macro translation, but once done, it can be reused for other work. Naturally, there are more devicetree macros to translate, but now we have some samples to expand on... Ideas abound!

Again, the complete code is available on my Zephyr fork.


tags: zig, zephyr

  1. Martí Bolívar once called this macrobatics 

  2. Zephyr uses this approach instead of creating the binary database used on Linux to save space and complexity 

  3. Except when something goes wrong, as the error codes are “cryptic” at best. 

  4. This post builds on top of previous ones, you may want to check it first, if not familiar (and all the series, probably). 

  5. At <edk-install-dir>/include/zephyr/include/generated/zephyr/devicetree_generated.h 

  6. Why not a “normal” sized unsigned integer? I encourage you to try and see =D 

  7. Albeit a nice one. If curious, check here