Blog | About

Writing a Zig extension for Zephyr - Part II

Oct 07, 2025 - Last edited: Oct 12, 2025

In previous installment, we built and run the Zephyr RTOS EDK sample, in preparation to change one of the extensions there for a Zig written one. Now lets get Zig into play!

We’ll be focusing on the extension that runs in kernel space, as things are simpler there - no need to worry about syscalls. Let’s try to get a simple, “hello world” printing extension written in Zig first. Later we can try to make it feature equivalent with the C one.

A look into the C extension

If one looks at the code of the extension (at samples/subsys/llext/edk/k-ext1/src/main.c), they will see some code related to the subscription side of the extension, some semaphores and some messages being printed. We don’t care (right now) about the former, only about printing things. So one could simplify it to something like:

#include <zephyr/kernel.h>

int start(void)
{
    printk("[k-ext1]Hello world!\n");

    return 0;
}
EXPORT_SYMBOL(start);

(In fact, you can try to build this slimmed down version and check if the “Hello world!” message does appear on the logs.)

Starting with Zig

Thus, let’s try to get this on our Zig version. First, create a main.zig:

const c = @cImport({
    @cInclude("zephyr/kernel.h");
});

pub fn start() callconv(.c) c_int {
    c.printk("[zig][k-ext1]Hello world!\n");

    return 0;
}

Looks quite similar, right? We’re still missing the EXPORT_SYMBOL, but we’ll get to that later. Zig has some nice provisions to integrate with C code1, and we can see some builtin structures to import C headers into Zig, the @cImport and @cInclude bits. After that, the Zephyr functions should be available at the c “namespace”. The start function, which will be called by the Zephyr application needs the callconv(.c).

We still can’t build it though. I mean, where is the Zig compiler supposed to look for the Zephyr headers, after all?

A look into the EDK

That question is actually valid for the C extensions as well. Let’s take a look into the CMake files used to build them. First, there’s CMakeLists.txt, that starts like this:

cmake_minimum_required(VERSION 3.20.0)

set(CMAKE_TOOLCHAIN_FILE toolchain.cmake)
set(CMAKE_C_COMPILER_FORCED TRUE)
set(CMAKE_CXX_COMPILER_FORCED TRUE)

project(kext1)

# (...)

The only interesting bit here is that it uses a toolchain.cmake, which is basically:

set(CMAKE_C_COMPILER   arm-zephyr-eabi-gcc)
set(CMAKE_FIND_ROOT_PATH $ENV{ZEPHYR_SDK_INSTALL_DIR}/arm-zephyr-eabi)

That points to the Zephyr SDK ARM toolchain (surprise!). Well, at least we now know that our target should probably be similar.

Continuing to inspect the CMakeLists.txt, we’ll see:

# (...)
# Include EDK CFLAGS
if(NOT DEFINED LLEXT_EDK_INSTALL_DIR)
    set(LLEXT_EDK_INSTALL_DIR $ENV{LLEXT_EDK_INSTALL_DIR})
endif()
include(${LLEXT_EDK_INSTALL_DIR}/cmake.cflags)

# Add LLEXT_CFLAGS to our flags
add_compile_options(${LLEXT_CFLAGS})
add_compile_options("-c")

# Get flags from COMPILE_OPTIONS
get_property(COMPILE_OPTIONS_PROP DIRECTORY PROPERTY COMPILE_OPTIONS)

# (...)

It gets the flags needed to build the extension from the EDK! There’s a cmake.flags there. Let’s take a look into it:

 $ cat <llext-install-dir>/cmake.cflags

# Target information
set(LLEXT_EDK_BOARD_NAME "cortex_r8_virtual")
set(LLEXT_EDK_BOARD_QUALIFIERS "cortex_r8_virtual")
set(LLEXT_EDK_BOARD_REVISION "")
set(LLEXT_EDK_BOARD_TARGET "cortex_r8_virtual_cortex_r8_virtual")

# Compile flags
set(LLEXT_CFLAGS "-DKERNEL;-D__ZEPHYR__=1;-D__LINUX_ERRNO_EXTENSIONS__;-DPICOLIBC_DOUBLE_PRINTF_SCANF;-D__PROGRAM_START;-DK_HEAP_MEM_POOL_SIZE=0;-DLL_EXTENSION_BUILD;-fno-strict-aliasing;-fno-common;-fdiagnostics-color=always;-mcpu=cortex-r8;-mthumb;-mabi=aapcs;-mfpu=vfpv3-d16;-mfloat-abi=hard;-mfp16-format=ieee;-mtp=soft;-Wall;-Wformat;-Wformat-security;-Wformat;-Wno-format-zero-length;-Wdouble-promotion;-Wno-pointer-sign;-Wpointer-arith;-Wexpansion-to-defined;-Wno-unused-but-set-variable;-Werror=implicit-int;-fno-asynchronous-unwind-tables;-ftls-model=local-exec;-fno-reorder-functions;--param=min-pagesize=0;-fno-defer-pop;-specs=picolibc.specs;-std=c99;-mlong-calls;-mthumb;-nodefaultlibs;-imacros${CMAKE_CURRENT_LIST_DIR}/include/zephyr/include/zephyr/toolchain/zephyr_stdint.h;-imacros${CMAKE_CURRENT_LIST_DIR}/include/zephyr/include/generated/zephyr/autoconf.h;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/include/generated/zephyr;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/include;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/include/generated;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/soc/renode/cortex_r8_virtual;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/lib/libc/common/include;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/soc/renode/cortex_r8_virtual/.;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/cmsis/CMSIS/Core_R/Include;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/modules/cmsis/.;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/.;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/m0p;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals/m0p;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals/m0p/sysctl;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/samples/subsys/llext/edk/app/include")
set(LLEXT_ALL_INCLUDE_CFLAGS "-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/include/generated/zephyr;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/include;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/include/generated;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/soc/renode/cortex_r8_virtual;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/lib/libc/common/include;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/soc/renode/cortex_r8_virtual/.;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/cmsis/CMSIS/Core_R/Include;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/modules/cmsis/.;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/.;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/m0p;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals/m0p;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals/m0p/sysctl;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/samples/subsys/llext/edk/app/include")
set(LLEXT_INCLUDE_CFLAGS "-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/include;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/soc/renode/cortex_r8_virtual;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/lib/libc/common/include;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/soc/renode/cortex_r8_virtual/.;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/cmsis/CMSIS/Core_R/Include;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/modules/cmsis/.;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/.;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/m0p;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals/m0p;-I${CMAKE_CURRENT_LIST_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals/m0p/sysctl;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/samples/subsys/llext/edk/app/include")
set(LLEXT_GENERATED_INCLUDE_CFLAGS "-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/include/generated/zephyr;-I${CMAKE_CURRENT_LIST_DIR}/include/zephyr/include/generated")
set(LLEXT_BASE_CFLAGS "-DKERNEL;-D__ZEPHYR__=1;-D__LINUX_ERRNO_EXTENSIONS__;-DPICOLIBC_DOUBLE_PRINTF_SCANF;-D__PROGRAM_START;-DK_HEAP_MEM_POOL_SIZE=0;-DLL_EXTENSION_BUILD;-fno-strict-aliasing;-fno-common;-fdiagnostics-color=always;-mcpu=cortex-r8;-mthumb;-mabi=aapcs;-mfpu=vfpv3-d16;-mfloat-abi=hard;-mfp16-format=ieee;-mtp=soft;-Wall;-Wformat;-Wformat-security;-Wformat;-Wno-format-zero-length;-Wdouble-promotion;-Wno-pointer-sign;-Wpointer-arith;-Wexpansion-to-defined;-Wno-unused-but-set-variable;-Werror=implicit-int;-fno-asynchronous-unwind-tables;-ftls-model=local-exec;-fno-reorder-functions;--param=min-pagesize=0;-fno-defer-pop;-specs=picolibc.specs;-std=c99;-mlong-calls;-mthumb;-nodefaultlibs;-imacros${CMAKE_CURRENT_LIST_DIR}/include/zephyr/include/zephyr/toolchain/zephyr_stdint.h")
set(LLEXT_GENERATED_IMACROS_CFLAGS "-imacros${CMAKE_CURRENT_LIST_DIR}/include/zephyr/include/generated/zephyr/autoconf.h")

Aaaarghh! That’s not pretty, but as expected, it defines a set of flags that the extension should use, and directories/files are relative to the EDK directory (that is its purpose after all). Let’s take a deep breath, and get the necessary bits to build our Zig extension.

At the extension directory, samples/subsys/llext/edk/k-ext1, we can create a new directory for the Zig build, and in it a bash script2:

 $ mkdir zigbuild && cd zigbuild
 $ <your-editor-of-choice> build.sh

And in the build.sh we can have:

#!/bin/bash

LLEXT_ALL_INCLUDE_CFLAGS="-I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/include/generated/zephyr -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/include -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/include/generated -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/soc/renode/cortex_r8_virtual -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/lib/libc/common/include -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/soc/renode/cortex_r8_virtual/. -I${LLEXT_EDK_INSTALL_DIR}/include/modules/hal/cmsis/CMSIS/Core_R/Include -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/modules/cmsis/. -I${LLEXT_EDK_INSTALL_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/. -I${LLEXT_EDK_INSTALL_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/m0p -I${LLEXT_EDK_INSTALL_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals -I${LLEXT_EDK_INSTALL_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals/m0p -I${LLEXT_EDK_INSTALL_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals/m0p/sysctl -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/samples/subsys/llext/edk/app/include"

zig build-obj -target thumb-freestanding-eabi $LLEXT_ALL_INCLUDE_CFLAGS ../src/main.zig

Some things to unpack here. Looking carefully, the cmake.cflags has a variable LLEXT_CFLAGS with every flag needed, but it also has a breakdown of them that can be better tailored for different build environments. Some of the gcc flags used (such as -mthumb) are not really recognised by the Zig compiler. So, it made more sense to use the LLEXT_ALL_INCLUDE_CFLAGS. I’ve also replaced the ; between the flags for space, as Zig compiler doesn’t seem to understand that. And I’ve replaced CMAKE_CURRENT_LIST_DIR by LLEXT_EDK_INSTALL_DIR as we are already using that variable, and don’t want to use cmake stuff where there is no need.

Then, the line that does the build is:

zig build-obj -target thumb-freestanding-eabi $LLEXT_ALL_INCLUDE_CFLAGS ../src/main.zig

Here, we’re building an object (not an executable), so we use build-obj3. And we’ve chosen thumb-freestanding-eabi as it’s the corresponding Zig target (check with zig targets).

Going back to the CMakeLists.txt, we have:

# (...)
add_custom_command(
    OUTPUT
        ${PROJECT_BINARY_DIR}/${PROJECT_NAME}.llext
        ${PROJECT_BINARY_DIR}/${PROJECT_NAME}.inc
    COMMAND ${CMAKE_C_COMPILER} ${COMPILE_OPTIONS_PROP}
        -o ${PROJECT_BINARY_DIR}/${PROJECT_NAME}.llext
        ${PROJECT_SOURCE_DIR}/src/main.c
    COMMAND xxd -ip ${PROJECT_NAME}.llext
        ${PROJECT_NAME}.inc
)

add_custom_target(kext1 ALL DEPENDS ${PROJECT_BINARY_DIR}/kext1.llext)

There are two things being done here:

  • The object file is built from the source file, and generates a k-ext1.llext file;
  • This file is converted to the C include file by the xxd utility.

We are already taking care of the first thing in our script, let’s do the second:

# (...)
mv main.o kext1.llext
xxd -ip main.o kext1.inc

Note that main.o is the default name of the object file generated by the Zig compiler, but xxd uses the name of the input file to generate the variables inside the output file. As the Zephyr application expects a given name, let’s just change the object file name before processing it4.

Export symbol

Before we can actually build our Zig extension, we need to take a look at what that last line in the C extension does. The EXPORT_SYMBOL basically places a struct containing the exported function and its name into a defined section of the ELF object file. The section is named .exported_sym, and Zephy llext subsystem looks for this section to find the exported symbols that need to be linked into the Zephyr application.

We can replicate that in Zig with:

// (...)
const StartSym = extern struct {
    name: [*:0]const u8,
    addr: *const fn() callconv(.c) c_int,
};

export const start_sym: StartSym linksection(".exported_sym") = .{
    .name = "start",
    .addr = start,
};

We first define the struct as Zephyr expects it, then populate an instance that points to our start function, that is placed at a section called .exported_sym.

Building

Now, we only need to build it. Let’s try!

 $ ./build.sh

And you’ll get some errors, like:

 error: C import failed
const c = @cImport({
          ^~~~~~~~
(...)zephyr/samples/subsys/llext/edk/k-ext1/src/main.zig:1:11: note: libc headers not available; compilation does not link against libc
referenced by:
    start: (...)zephyr/samples/subsys/llext/edk/k-ext1/src/main.zig:6:5
    start_sym: (...)zephyr/samples/subsys/llext/edk/k-ext1/src/main.zig:18:6
    4 reference(s) hidden; use '-freference-trace=6' to see all references
(...)zephyr/llext-edk//include/zephyr/include/zephyr/toolchain/gcc.h:622:2: error: processor architecture not supported
#error processor architecture not supported

Wut? How come “processor architecture not supported”? What is causing that? Let’s look at the offending file, gcc.h inside the EDK directory:

// (...)
#else
#error processor architecture not supported
#endif
// (...)

That’s the end of a long #ifdef chain checking which CONFIG_XXX is being used (CONFIG_ARM, CONFIG_X86, CONFIG_RISCV, etc). These CONFIG_ symbols come from Zephyr Kconfig system. If we try to find where inside the EDK directory they are defined, like, by using grep, we get:

(.venv) $ grep -r "#define CONFIG_ARM" llext-edk/
llext-edk/include/zephyr/include/generated/zephyr/autoconf.h:#define CONFIG_ARM 1

Those are defined in the autoconf.h file. Why is not being included? A careful examination of the flags in the EDK cmake.cflags will reveal that autoconf.h is passed via -imacro flag, which is not understood by Zig compiler. Fortunately, we can include that ourselves in the Zig cImport:

const c = @cImport({
    @cInclude("autoconf.h");
    @cInclude("zephyr/kernel.h");
});

It needs to come before the zephyr/kernel.h one, as its definitions are needed there. If we try again, we can confirm that error is gone. But there’s another one:

zephyr/llext-edk//include/zephyr/include/zephyr/sys/util.h:31:10: error: 'string.h' file not found
#include <string.h>
        ^

Why is not finding it? Indeed, if we try to find it on the EDK directory, we won’t find it:

$ find llext-edk/ -name "string.h"
$

Is this an EDK bug? Not really. The string.h can also be provided by the toolchain (the Zephyr SDK, in this case). But we are not using it - it would be unfortunate to hack another include in our build script just to get that C include. Are we out of luck here?

Again, not really. While Picolibc, Zephyr’s default libc, will get those from the toolchain, one can also build it as module, so it won’t use toolchain stuff. Thus its bits will be part of the EDK.

To do that, add CONFIG_PICOLIBC_USE_MODULE=y to the application configuration file, samples/subsys/llext/edk/app/prj.conf:

# (...)
CONFIG_EVENTS=y

CONFIG_PICOLIBC_USE_MODULE=y

Then rebuild the EDK, west build -t llext-edk, extract it again and update the flags in the build file:

LLEXT_ALL_INCLUDE_CFLAGS="-I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/include/generated/zephyr -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/include -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/include/generated -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/soc/renode/cortex_r8_virtual -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/lib/libc/common/include -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/soc/renode/cortex_r8_virtual/. -I${LLEXT_EDK_INSTALL_DIR}/include/modules/hal/cmsis/CMSIS/Core_R/Include -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/modules/cmsis/. -I${LLEXT_EDK_INSTALL_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/. -I${LLEXT_EDK_INSTALL_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/m0p -I${LLEXT_EDK_INSTALL_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals -I${LLEXT_EDK_INSTALL_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals/m0p -I${LLEXT_EDK_INSTALL_DIR}/include/modules/hal/ti/mspm0/source/ti/devices/msp/peripherals/m0p/sysctl -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/build/modules/picolibc/picolibc/include -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/build/modules/picolibc/picolibc/include -I${LLEXT_EDK_INSTALL_DIR}/include/sdk/zephyr-sdk-0.17.2/arm-zephyr-eabi/bin/../lib/gcc/arm-zephyr-eabi/12.2.0/include -I${LLEXT_EDK_INSTALL_DIR}/include/sdk/zephyr-sdk-0.17.2/arm-zephyr-eabi/bin/../lib/gcc/arm-zephyr-eabi/12.2.0/include-fixed -I${LLEXT_EDK_INSTALL_DIR}/include/zephyr/samples/subsys/llext/edk/app/include"

Now if we try again, it should build it and generate the include file, kext1.inc.

Running

Phew! That was some work. Now let’s run it. But before, we need to modify the Zephyr application to use the Zig extension. At samples/subsys/llext/edk/app/src/main.c, change the line that includes the built extension that runs in kernel space, k-ext1:

// (...)
#ifndef CONFIG_LLEXT_EDK_USERSPACE_ONLY
#include "../../k-ext1/zigbuild/kext1.inc"
#define kext1_inc kext1_llext
// (...)

(Simply changing build to zigbuild should be enough.)

Now, let’s run the application again:

(.venv) $ west build -t run

Looking at the output, it’s clear something went wrong:

0:47:01.1776 [INFO] uart0: [host: 0.68s (+0.68s)|virt: 5.8ms (+5.8ms)] *** Booting Zephyr OS build v4.2.0-2921-gb2273bd24342 ***
00:47:01.1787 [INFO] uart0: [host: 0.68s (+1.21ms)|virt: 6.7ms (+0.9ms)] [app]Subscriber thread [0x17f60] started.
00:47:01.1847 [INFO] uart0: [host: 0.69s (+6.03ms)|virt: 9.5ms (+2.8ms)] [app]Loading extension [kext1].
00:47:01.1891 [INFO] uart0: [host: 0.69s (+4.42ms)|virt: 16.8ms (+7.3ms)] [app]Thread 0x17b40 created to run extension [kext1], at privileged mode.
00:47:01.2833 [WARNING] uart0: Unhandled write to offset 0xC. Unhandled bits: [4, 6-7, 9-13] when writing value 0x3FFF. Tags: rxBreakDetectInterruptDisable (0x1), txFifoOverflowInterruptDisable (0x1), txFifoNearlyFullInterruptDisable (0x1), txFifoTriggerInterruptDisable (0x1), deltaModemStatusInterruptDisable (0x1), rxParityErrorInterruptDisable (0x1), rxFramingErrorInterruptDisable (0x1), txFifoFullInterruptDisable (0x1).
00:47:01.2834 [WARNING] ttc0: Unhandled write to offset 0xC. Unhandled bits: [5] when writing value 0x21. Tags: WaveformOutputDisable (0x1).
00:47:01.2854 [INFO] uart0: [host: 0.79s (+96.26ms)|virt: 22.7ms (+5.9ms)] *** Booting Zephyr OS build v4.2.0-2921-gb2273bd24342 ***
00:47:01.2860 [INFO] uart0: [host:  0.79s (+0.49ms)|virt: 23.5ms (+0.8ms)] [app]Subscriber thread [0x17f60] started.
00:47:01.2873 [INFO] uart0: [host:  0.79s (+1.32ms)|virt: 26.3ms (+2.8ms)] [app]Loading extension [kext1].
00:47:01.2891 [INFO] uart0: [host:  0.79s (+1.78ms)|virt: 33.7ms (+7.4ms)] [app]Thread 0x17b40 created to run extension [kext1], at privileged mode.
00:47:01.3833 [WARNING] uart0: Unhandled write to offset 0xC. Unhandled bits: [4, 6-7, 9-13] when writing value 0x3FFF. Tags: rxBreakDetectInterruptDisable (0x1), txFifoOverflowInterruptDisable (0x1), txFifoNearlyFullInterruptDisable (0x1), txFifoTriggerInterruptDisable (0x1), deltaModemStatusInterruptDisable (0x1), rxParityErrorInterruptDisable (0x1), rxFramingErrorInterruptDisable (0x1), txFifoFullInterruptDisable (0x1).
00:47:01.3834 [WARNING] ttc0: Unhandled write to offset 0xC. Unhandled bits: [5] when writing value 0x21. Tags: WaveformOutputDisable (0x1).
00:47:01.3855 [INFO] uart0: [host: 0.89s (+96.51ms)|virt: 39.5ms (+5.8ms)] *** Booting Zephyr OS build v4.2.0-2921-gb2273bd24342 ***

This repeats forever. It tries to run the extension, but it fails. And reboots, again, and again, and again...

But let’s not give it up! Let’s enable logging and try to see if we can get some idea of what’s going on. Adding a bit more configs to the application prj.conf:

# (...)
CONFIG_LOG=y
CONFIG_LLEXT_LOG_LEVEL_DBG=y
CONFIG_LOG_MODE_MINIMAL=y

And running it again:

(.venv) $ west build -t run

Peering into the log, we can find this interesting bit:

00:50:49.6365 [INFO] uart0: [host:  2.31s (+0.44ms)|virt:  1.66s (+0.6ms)] D: 10 31b4c a3f9 printk
00:50:49.6367 [INFO] uart0: [host:  2.31s (+0.27ms)|virt:  1.66s (+0.8ms)] D: relocation section .rel.ARM.exidx (4) acting on section 2 has 2 relocations
00:50:49.6368 [INFO] uart0: [host:  2.31s (+65.6µs)|virt:  1.66s (+0.2ms)] D: mem_idx 11
00:50:49.6368 [INFO] uart0: [host:   2.31s (+5.8µs)|virt:     1.66s (+0s)]
00:50:49.6370 [INFO] uart0: [host:  2.31s (+0.17ms)|virt:  1.67s (+0.5ms)] E: Section 2 not loaded in any memory region
00:50:49.6371 [INFO] uart0: [host:   2.31s (+0.1ms)|virt:  1.67s (+0.3ms)] E: Failed to link, ret -8
00:50:49.6372 [INFO] uart0: [host:  2.31s (+0.15ms)|virt:  1.67s (+0.4ms)] D: Failed to load extension: -8

The first error (the E: line) states that “section 2” is not loaded in any memory region. And a few lines prior, it states that relocation acting on section 2 is relocation section .rel.ARM.exidx. Some search on the internet will tell that this is a section used to store (potentially C++) exception information. It happens that Zephyr llext doesn’t handle this section, hence the failure. But we don’t need it, really. So let’s remove it just before generating the include file. So our build.sh becomes:

# (...)
arm-none-eabi-objcopy --remove-section .ARM.exidx main.o kext1.llext
xxd -ip kext1.llext kext1.inc

Note that arm-none-eabi-objcopy is part of arm-none-eabi-binutils on Arch Linux. Or you could use the one from the Zephyr SDK5.

Rebuild the zig extension and the application (it may be handy to disable the extra logging here as well), and run it again:

(.venv) $ west build -t run

If you disabled the log it should be easy to find the following among the output:

(...)
01:02:16.2370 [INFO] uart0: [host: 0.71s (+0.28ms)|virt: 17.2ms (+0.3ms)] [zig][k-ext1]Hello world!
(...)

We did it! We printed the “Hello world!” from the Zig extension!

Of course, there were some rough edges and hacks, but that’s the fun, isn’t it? In the next installment, let’s make this extension have a bit more feature parity with the C version.


tags: zig, zephyr

  1. In fact, you could use it as an ordinary C compiler at first, before creating code in Zig. It’s one of the “pathways to Zig”. 

  2. For simplicity - I don’t want to deal with cmake (or any other build system) idiosyncrasies at this point. 

  3. In the C extension, the same happens due to the use of -c flag, added before the build. 

  4. Yes, we could use the -n <name> option of xxd. But a next step will already make us pre-process the object file, so the name change becomes natural. 

  5. Zig actually has an objcopy utility, but it doesn’t support the --remove-section option. See https://github.com/ziglang/zig/issues/24573