Blog | About

Zig extension for Zephyr running on device

Jan 04, 2026

In a previous series of posts, I’ve written about writing an extension for Zephyr using Zig. There, I’ve used Renode as a simulator to run Zephyr and the extensions.

What about running that Zig extensions on a real device? I’ve got hold of an FRDM-MCXN947, which is supported on Zephyr, so I’ve decided to try.

Note that if you want to properly follow this post, reading my previous series can be helpful.

Updating Zephyr

When I’ve first tried this, it didn’t work - even for the extensions written in C! So I’ve debugged and fixed the issues on Pull Requests #98882 and #98883. So we’ll need a recent Zephyr to proceed. That’s basically a set of git pull (or git fetch origin and git rebase origin/main) and west update.

Updating the EDK

As we’re targeting a different platform, we’ll need a different EDK. Simply rebuild it:

(.venv) $ west build -p -b frdm_mcxn947/mcxn947/cpu0 samples/subsys/llext/edk/app/
(.venv) $ west build -t llext-edk

Then extract the EDK file, llext-edk.tar.xz inside build directory, to some location:

(.venv) $ tar xf build/zephyr/llext-edk.tar.xz

Updating the build scripts

We also need to update our build scripts. I’ll be referring to a single build script, but in our previous series we had two: one for the kernel extension, k-ext1, and another for the userspace one, ext1. All changes apply to both.

Replacing the LLEXT_ALL_INCLUDE_CFLAGS with the new content from the new EDK flags is the first step. If we try to build after that, however, we get:

(...)zephyr/edk/llext-edk/include/modules/hal/nxp/mcux/mcux-sdk-ng/devices/MCX/MCXN/MCXN947/fsl_device_registers.h:21:4: error: "No valid CPU defined!"
  #error "No valid CPU defined!"

Hmmm... If we look into the offending file, we’ll see:

#if (defined(CPU_MCXN947VAB_cm33_core0) || defined(CPU_MCXN947VDF_cm33_core0) || defined(CPU_MCXN947VKL_cm33_core0) || defined(CPU_MCXN947VNL_cm33_core0) || defined(CPU_MCXN947VPB_cm33_core0))
#include "MCXN947_cm33_core0.h"
#elif (defined(CPU_MCXN947VAB_cm33_core1) || defined(CPU_MCXN947VDF_cm33_core1) || defined(CPU_MCXN947VKL_cm33_core1) || defined(CPU_MCXN947VNL_cm33_core1) || defined(CPU_MCXN947VPB_cm33_core1))
#include "MCXN947_cm33_core1.h"
#else
  #error "No valid CPU defined!"
#endif

So it’s a matter of adding the proper definition to our build. Which one? If we inspect the cmake.cflags or Makefile.cflags at the EDK directory, we can see that LLEXT_CFLAGS has -DCPU_MCXN947VDF_cm33_core0. So that’s what we need. Modify the build.sh:

# (...)
zig build-obj -target thumb-freestanding-eabi $LLEXT_ALL_INCLUDE_CFLAGS -OReleaseSmall -DCPU_MCXN947VDF_cm33_core0 ../src/main.zig
# (...)

That error is gone, but now we see a bunch of errors similar to:

(...) zephyr/edk/llext-edk/include/sdk/zephyr-sdk-0.17.2/arm-zephyr-eabi/bin/../lib/gcc/arm-zephyr-eabi/12.2.0/include/arm_acle.h:41:3: error: argument to '__builtin_arm_cdp' must be a constant integer
  __builtin_arm_cdp (__coproc, __opc1, __CRd, __CRn, __CRm, __opc2);
  ^

Wait, where does that gcc come from? We’re not using GCC, Zig is LLVM based. What if we remove the two gcc related entries from LLEXT_ALL_INCLUDE_CFLAGS? (They are -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 and -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 in case you didn’t find them.)

When we try again, those errors are gone, but now we do have some new ones like:

(...)zephyr/edk/llext-edk/include/modules/hal/nxp/mcux/mcux-sdk-ng/drivers/common/fsl_common_arm.h:930:12: error: call to undeclared function '__get_PRIMASK'; ISO C99 and later do not support implicit function declarations
    mask = __get_PRIMASK();
           ^

Are we even getting anywhere? Looking at the problem file, we see weird stuff like __ARM_ARCH_7M__. WTH? Those are actually coming from the compiler1, thus we need to tell it which architecture we’re compiling to. Looking at the EDK flags, we see -mcpu=cortex-m33. So we need to modify our build.sh:

# (...)
zig build-obj -target thumb-freestanding-eabi $LLEXT_ALL_INCLUDE_CFLAGS -OReleaseSmall -DCPU_MCXN947VDF_cm33_core0 -mcpu=cortex_m33 ../src/main.zig
# (...)

Now, we can finally build it! But will it run? If we follow the instructions to flash and open a serial connection to the board with something like:

screen /dev/ttyACM0 115200

We’ll see a lot of output, going pretty fast... Something is not right! If we hold the screen (on screen, I type Ctrl+A,Esc to enter copy mode), we can see something like:

E: sym 'z_impl_k_sem_give': relocation out of range (0x3000be9a -> 0x10007fd9)

E: sym 'printk': relocation out of range (0x3000bea6 -> 0x1000c3c5)

E: Failed to link, ret -8
E: ***** HARD FAULT *****

How? What’s the problem with those relocations? Out of range? We check the ELF files with objdump to see if we get any hint:

arm-none-eabi-objdump -xD kext1.llext | less

Looking for some of the problematic symbols, like z_impl_k_sem_give, we’ll eventually see:

  fa:   f7ff fffe       bl      0 <z_impl_k_sem_give>
                        fa: R_ARM_THM_CALL      z_impl_k_sem_give

The suspect is R_ARM_THM_CALL - what is that about? Some research will show that it’s a kind of relative relocation on ARM, and that it can be avoided with -mlong-calls, which is indeed in the EDK cflags. So we modify yet again our build.sh:

# (...)
zig build-obj -target thumb-freestanding-eabi $LLEXT_ALL_INCLUDE_CFLAGS -OReleaseSmall -DCPU_MCXN947VDF_cm33_core0 -mcpu=cortex_m33+long_calls ../src/main.zig
# (...)

This time, when we run it, we can finally see:

(...)
[zig][ext1]Got event, reading channel
[zig][ext1]Read val: 1
[zig][ext1]Waiting event
[zig][k-ext1]Got event, giving sem
[zig][k-ext1]Got sem, reading channel
[zig][k-ext1]Read val: 1
[zig][k-ext1]Waiting sem
[zig][k-ext1]Waiting event
[ext3]Got event, giving sem
[ext3]Got sem, reading channel
[ext3]Read val: 1
[ext3]Waiting sem
[ext3]Waiting event
[ext2]Publishing tick
[app][subscriber_thread]Got channel tick_chan
[zig][ext1]Got event, reading channel
[zig][ext1]Read val: 2
[zig][ext1]Waiting event
[zig][k-ext1]Got event, giving sem
[zig][k-ext1]Got sem, reading channel
[zig][k-ext1]Read val: 2
[zig][k-ext1]Waiting sem
[zig][k-ext1]Waiting event
(...)

It worked!!!

Wasn’t it too hard?

Probably not: we’re changing toolchains, some hiccups along the way are expected. I’ve also went lean on copying flags from the EDK - -mcpu=cortex-m33 and -mlong-calls were the only ones needed. We could’ve simply copied all of them instead. But again, because Zig is a different toolchain, their equivalences need to be figured out first. Of course there are more definitions in the EDK, like the -DCPU_MCXN947VDF_cm33_core0. For those, I think that copying them all is probably the best, but I wanted to see what was really needed.

There we have it, Zig running on a Zephyr extension on a real device!


tags: zig, zephyr

  1. Figuring that out is left as an exercise to the reader