Mar 30, 2026
In my Zig extension for Zephyr saga, I’ve written about writing an extension for Zephyr using Zig. One thing that I’ve been meaning to improve there was the import of C headers into Zig.
For simple enough headers, the @cImport built-in function in Zig is enough. However, it’s too limited, and Zig documentation actually recommends using another approach: translate-c.
A recurring issue in previous posts is that some C constructs can’t be automatically translated to Zig and we need to do it manually. For these cases, instead of relying on the automatic translation provided by @cImport, it makes sense to do the translation using translate-c Zig utility. That way, we can directly edit the generated translation, which would live inside Zig cache when using @cImport.
To use it, we create a .h file that include our desired headers:
#include <autoconf.h>
#include <zephyr/kernel.h>
#include <zephyr/llext/symbol.h>
#include <app_api.h>
We can save it as imports.h, for instance. Then1:
zig translate-c -target thumb-freestanding-eabi $LLEXT_ALL_INCLUDE_CFLAGS -fno-unwind-tables -DCPU_MCXN947VDF_cm33_core0 -mcpu=cortex_m33+long_calls imports.h > $TRANSLATED_FILE
(We’ll place this and other commands into a script later, where variables such as $LLEXT_ALL_INCLUDE_CFLAGS will make sense.)
Assuming the destination file is called cimport.zig (TRANSLATED_FILE=cimport.zig), we can now use the regular @import function instead on our Zig file:
const c = @import("cimport.zig");
If we build with these changes, it should Just Work (TM).
When we used syscalls on the kernel extension, we couldn’t call them directly, as they used some assembly code and couldn’t be translated. Back then, our workaround was to invoke the implementation method directly. So k_object_alloc became z_impl_k_object_alloc. With the translation file in our reach, we can revisit that.
If we search for k_object_alloc in our cimport.zig, we’ll find that it was demoted to extern:
// (...)
// (...) //include/zephyr/include/zephyr/arch/arm/syscall.h:75:22: warning: unable to translate function, demoted to extern
pub extern fn k_object_alloc(arg_otype: enum_k_objects) callconv(.c) ?*anyopaque;
// (...)
For inspiration, let’s first see what the C implementation does2:
__pinned_func
static inline void * k_object_alloc(enum k_objects otype)
{
#ifdef CONFIG_USERSPACE
if (z_syscall_trap()) {
union { uintptr_t x; enum k_objects val; } parm0 = { .val = otype };
return (void *) arch_syscall_invoke1(parm0.x, K_SYSCALL_K_OBJECT_ALLOC);
}
#endif
compiler_barrier();
return z_impl_k_object_alloc(otype);
}
We can ignore __pinned_func for now3. The first interesting thing is the #ifdef CONFIG_USERSPACE - if Zephyr is built without support for userspace, no need to care about it. We can have something similar by using comptime:
pub fn k_object_alloc(arg_otype: enum_k_objects) callconv(.c) ?*anyopaque {
if (comptime CONFIG_USERSPACE == 1) {
// TODO handle userspace
}
// TODO handle `compiler_barrier`
return z_impl_k_object_alloc(arg_otype);
}
Remember that being a macro available on Zephyr headers, CONFIG_USERSPACE should already be translated. For the kernel side, let’s just call the implementation as before. Later we’ll tackle the compiler barrier.
Now, if we replace all three occurrences of z_impl_k_object_alloc by pure k_object_alloc, rebuild and flash, things should just work.
Now let’s look into the compiler_barrier method. Its definition on toolchain/gcc.h is dead simple:
#define compiler_barrier() do { \
__asm__ __volatile__ ("" ::: "memory"); \
} while (false)
Basically an asm statement that states memory may be touched, so that the compiler knows it has to flush registers to memory before it and will reload them after, like a... compiler barrier!
In the translated file, compiler_barrier was “translated” to a compiler error:
pub const compiler_barrier = @compileError("unable to translate C expr: unexpected token 'do'");
Fortunately, reimplementing it in Zig is fairly straightforward:
inline fn compiler_barrier() void {
asm volatile ("" : : : .{.memory = true});
}
Now we can update our k_object_alloc:
pub fn k_object_alloc(arg_otype: enum_k_objects) callconv(.c) ?*anyopaque {
if (comptime CONFIG_USERSPACE == 1) {
// TODO handle userspace
}
compiler_barrier();
return z_impl_k_object_alloc(arg_otype);
}
When we wrote the userspace extension, we had made k_object_alloc a bit more ziggy, by incorporating some typing information and error handling. Let’s use that here, too:
pub fn k_object_alloc(arg_otype: enum_k_objects, comptime T: type) !*T{
var ret: usize = undefined;
if (comptime CONFIG_USERSPACE == 1) {
// TODO handle userspace
}
compiler_barrier();
ret = @intFromPtr(z_impl_k_object_alloc(arg_otype));
return if (ret == 0) error.OutOfMemory else @ptrFromInt(ret);
}
Now, if the k_object allocation fails, we get an error. And by passing which type of k_object we are allocating, we get the correct type in return. Time to change the calling sites, at start:
pub fn start() callconv(.c) c_int {
my_sem = c.k_object_alloc(c.K_OBJ_EVENT, c.k_sem) catch {
c.printk("[zig][k-ext1]k_object_alloc failed!\n");
return 2;
};
// (...)
const sub_thread = c.k_object_alloc(c.K_OBJ_THREAD, c.k_thread) catch {
c.printk("[zig][k-ext1]k_object_alloc failed!\n");
return 3;
};
// (...)
And at tick_sub:
pub fn tick_sub(_: ?*anyopaque, _: ?*anyopaque, _: ?*anyopaque) callconv(.c) void {
const tick_evt = c.k_object_alloc(c.K_OBJ_EVENT, c.k_event) catch {
c.printk("[zig][k-ext1]k_object_alloc failed!\n");
return;
};
// (...)
Note that we went from orelse to catch, as we changed the return type from an optional C pointer to a non-optional Zig pointer, which errors if it can’t be allocated.
If we build and flash, everything should work as before.
Time to handle the case when userspace is enabled. First, let’s look at z_syscall_trap, defined at syscall.h:
/* True if a syscall function must trap to the kernel, usually a
* compile-time decision.
*/
static ALWAYS_INLINE bool z_syscall_trap(void)
{
bool ret = false;
#ifdef CONFIG_USERSPACE
#if defined(__ZEPHYR_SUPERVISOR__)
ret = false;
#elif defined(__ZEPHYR_USER__)
ret = true;
#else
ret = arch_is_user_context();
#endif
#endif
return ret;
}
Seems simple enough. Indeed, if we look at our cimport.zig file, it was translated without issue:
pub inline fn z_syscall_trap() bool {
var ret: bool = @as(c_int, 0) != 0;
_ = &ret;
ret = arch_is_user_context();
return ret;
}
Had we defined __ZEPHYR_SUPERVISOR__ or __ZEPHYR_USER__, it wouldn’t even have the arch_is_user_context call. As we haven’t, we need to check it:
// (...) //include/zephyr/include/zephyr/arch/arm/syscall.h:175:20: warning: unable to translate function, demoted to extern
pub extern fn arch_is_user_context() callconv(.c) bool;
Oh, demoted to extern. It must use some assembly. Let’s see its C (ha!) implementation4:
static inline bool arch_is_user_context(void)
{
#if defined(CONFIG_CPU_CORTEX_M)
uint32_t value;
/* check for handler mode */
__asm__ volatile("mrs %0, IPSR\n\t" : "=r"(value));
if (value) {
return false;
}
#endif
return z_arm_thread_is_in_user_mode();
}
Indeed! And some CONFIG_ usage too, but now we should know how to deal with it in Zig, so we can replace the extern declaration with:
pub fn arch_is_user_context() bool {
if (comptime CONFIG_CPU_CORTEX_M == 1) {
var value: u32 = undefined;
asm volatile ("mrs %[value], IPSR\n"
: [value] "=r" (value) : : .{});
if (value != 0) {
return false;
}
}
return z_arm_thread_is_in_user_mode();
}
With this, we can update our k_object_alloc:
pub fn k_object_alloc(arg_otype: enum_k_objects, comptime T: type) !*T{
var ret: usize = undefined;
if (comptime CONFIG_USERSPACE == 1) {
if (z_syscall_trap()) {
// TODO handle userspace
}
}
compiler_barrier();
ret = @intFromPtr(z_impl_k_object_alloc(arg_otype));
return if (ret == 0) error.OutOfMemory else @ptrFromInt(ret);
}
After that, we can rebuild and flash to ensure everything is still working: it should do the test to verify if we’re in kernel space, conclude that we are, and call z_impl_k_object_alloc, as before. You may be wondering if z_arm_thread_is_in_user_mode also needs to be touched, but if we look at its translation:
// (...)
pub extern fn z_arm_thread_is_in_user_mode() bool;
We see that it is extern, but there’s no comment before stating that it couldn’t be translated and was demoted: this is actually an ordinary extern method, exported by Zephyr5, so we’re good.
Converting the other syscalls is now just a matter of working on them.
We can reuse all this for the userspace extension, ext1. But here we’ll need to complete the last TODO item: handle userspace.
Fortunately, we’ve already did this before, so it’s just a matter of adapting it to our new implementation:
pub fn k_object_alloc(arg_otype: enum_k_objects, comptime T: type) !*T{
var ret: usize = undefined;
if (comptime CONFIG_USERSPACE == 1) {
if (z_syscall_trap()) {
ret = arch_syscall_invoke1(arg_otype, K_SYSCALL_K_OBJECT_ALLOC);
return if (ret == 0) error.OutOfMemory else @ptrFromInt(ret);
}
}
compiler_barrier();
ret = @intFromPtr(z_impl_k_object_alloc(arg_otype));
return if (ret == 0) error.OutOfMemory else @ptrFromInt(ret);
And replace arch_syscall_invoke1 by the code we’ve written before:
pub fn arch_syscall_invoke1(arg1: usize, call_id: usize) usize {
return asm volatile ("svc %[svid]\n"
: [ret] "={r0}" (-> usize),
: [svid] "i" (_SVC_CALL_SYSTEM_CALL),
[call_id] "{r6}" (call_id),
[arg1] "{r0}" (arg1),
: .{ .r8 = true, .memory = true, .r1 = true, .r2 = true, .r3 = true, .r12 = true }
);
}
Now it’s just a matter of doing for all other syscalls. Left as an exercise to the reader. Or you can just check this branch.
Everything is actually going smooth so far, but there’s a bit of a problem: what if we want to get some updates from Zephyr or the application API to our extensions? We could simply re-run zig translate-c, but that will kill our nice customizations. Can we keep them?
The answer is yes, but not without work. The ideal world would be to Zephyr to provide us this, but that’s a bit of wishful thinking. The good news is that we’re changing just some bits, so it shouldn’t be too hard to keep our changes somewhere and somehow “merge” them with the result of a new run of translate-c.
So far, we replaced some functions and macros:
compiler_barrier macro;k_object_alloc function;arch_is_user_context function;arch_syscall_invoke1 function.We can instead write them on a separate file, such as manual_imports.zig:
pub fn arch_syscall_invoke1(arg1: usize, call_id: usize) usize {
return asm volatile ("svc %[svid]\n"
: [ret] "={r0}" (-> usize),
: [svid] "i" (_SVC_CALL_SYSTEM_CALL),
[call_id] "{r6}" (call_id),
[arg1] "{r0}" (arg1),
: .{ .r8 = true, .memory = true, .r1 = true, .r2 = true, .r3 = true, .r12 = true }
);
}
pub fn arch_is_user_context() bool {
if (comptime CONFIG_CPU_CORTEX_M == 1) {
var value: u32 = undefined;
asm volatile ("mrs %[value], IPSR\n"
: [value] "=r" (value) : : .{});
if (value != 0) {
return false;
}
}
return z_arm_thread_is_in_user_mode();
}
inline fn compiler_barrier() void {
asm volatile ("" : : : .{.memory = true});
}
pub fn k_object_alloc(arg_otype: enum_k_objects, comptime T: type) !*T{
var ret: usize = undefined;
if (comptime CONFIG_USERSPACE == 1) {
if (z_syscall_trap()) {
ret = arch_syscall_invoke1(arg_otype, K_SYSCALL_K_OBJECT_ALLOC);
return if (ret == 0) error.OutOfMemory else @ptrFromInt(ret);
}
}
compiler_barrier();
ret = @intFromPtr(z_impl_k_object_alloc(arg_otype));
return if (ret == 0) error.OutOfMemory else @ptrFromInt(ret);
}
The idea is to concatenate this with the output of translate-c, but we need to remove the duplicates. Bash script to the rescue:
DUPLICATE_FUNCTIONS=$(grep -Pro "(?<=pub fn )(\w+)" manual_import.zig)
for function in $DUPLICATE_FUNCTIONS ; do
sed -ri "s|pub extern fn $function.*$||g" $TRANSLATED_FILE
done
This snippet will get all pub fn function names from manual_import.zig using grep, save them in DUPLICATE_FUNCTIONS array, and iterate the array to remove their cimport.zig counterparts. Easy!
However, it won’t work for the compiler_barrier macro, as it becomes a pub const in the translated file, and we manually translated it using an inline fn. We can address that too:
DUPLICATE_MACROS=$(grep -Pro "(?<=inline fn )(\w+)" manual_imports.zig)
for macro in $DUPLICATE_MACROS ; do
sed -ri "s|pub const $macro.*$||g" $TRANSLATED_FILE
done
Finally, we can do the concatenation:
cat manual_imports.zig >> $TRANSLATED_FILE
And there we have it! We have better control of the Zig-translated C headers, not (very) afraid of updates to Zephyr or the underlying app, and we can even share this among our extensions. Again, refer to the repository for a complete example.
For future work, we may note that the build system is becoming a bit more complex. Probably time to jump into build.zig or CMakeLists.txt - or both! Another area to explore is to increase content available on manual_imports.zig - they are more “ziggy” than an automated import, so they are nicer to use. As there are lots of APIs, would it make sense to enlist LLM to help? Stay tuned!
This post builds on top of latest post, hence the flags. ↩
You can find the k_object_alloc implementation at <edk-install-dir>/include/zephyr/include/generated/zephyr/syscalls/kobject.h. ↩
It relates to where text is placed on the ELF file by the linker. For now, we only care that it is placed somewhere. ↩
We’re building for ARM, hence the ARM implementation. ↩
You can check its implementation for Cortex M. ↩