top of page

Zephyr's kernel APIs on ESP-IDF

If you write firmware on Espressif parts you use ESP-IDF, and ESP-IDF runs FreeRTOS. If you write firmware on almost anything else, there is a good chance you use Zephyr. Teams that move between the two spend a lot of time rewriting the same logic against a different set of primitives.

Boreas is our answer to that: a set of ESP-IDF components that put Zephyr's kernel API surface and device model on top of FreeRTOS, so application code written for one reads the same on the other. It is Apache 2.0, and the source is at github.com/intercreate/boreas.

Zephyr does support ESP32 parts, and this is not an argument against using it. Boreas is for projects already committed to ESP-IDF: a vendor component that exists nowhere else, or a codebase too large to move.

What it gives you

Four ordinary ESP-IDF components. zkernel stands alone; zdevice needs zkernel and zsys, and zshell needs both of those as well.

ComponentWhat is in it
zkernelk_timer, k_work, k_sem, k_mutex, k_msgq, k_event, k_thread, sys_slist, sys_dlist, ring_buf, the timeout API, atomics, byte-order helpers and SYS_INIT
zdevicestruct device and DEVICE_DEFINE, I2C, SPI and GPIO pin and bus descriptions, and driver-class vtables for GPIO and UART
zsysPer-module log levels and backends, a thread analyzer, a subsystem watchdog, and retry with backoff
zshellA shell with command registration, history, completion and VT100 handling

Kernel objects are statically allocated: the structs are yours. The exception is anything built on a timer. k_timer, delayable work and a thread created with a start delay each create an esp_timer, which allocates its own handle inside ESP-IDF. Every blocking call handles K_NO_WAIT, K_FOREVER and finite timeouts, and where a call has an ISR-safe variant it is chosen for you.

Timeouts are stored in microseconds and converted to FreeRTOS ticks at the point of use, so K_USEC(500) is lossless for esp_timer even though the scheduler cannot see it.

Here is the device-model blink example from the repository, trimmed:

#include <boreas/device_model.h>
#include <boreas/gpio_dt.h>
#include <boreas/zephyr/kernel.h>
#include <boreas/zsys/log.h>

LOG_MODULE_REGISTER(blink, LOG_LEVEL_INF);

#define BLINK_GPIO GPIO_NUM_2

/* GPIO controller device, wrapping the ESP-IDF GPIO driver */
DEVICE_DEFINE(gpio0, gpio_esp32_init, &gpio_esp32_api, NULL, NULL, NULL);

/* The pin spec ties one pin to that controller */
static const struct gpio_dt_spec led_spec = {
	.port = &gpio0,
	.pin = BLINK_GPIO,
	.dt_flags = 0,
};

static void blink_timer_expiry(struct k_timer *timer)
{
	gpio_pin_toggle_dt(&led_spec);
}

Everything after the pin spec is application logic with no hardware detail in it, which is the point of the exercise. Changing the LED pin, or adding a second board revision, is a change to the description rather than to the code.

The design principle

The useful thing to take from Boreas, whether or not you use it, is the rule we arrived at after months of chasing the same bug: wrap the scheduler, own the synchronization state.

Upstream Zephyr kernel objects are caller-owned plain structs whose lifecycle ends are synchronous. When k_thread_abort, k_thread_join, k_timer_stop or k_work_cancel_sync returns, the kernel holds zero references into your memory. That is what makes stack-allocating a kernel object idiomatic in Zephyr code.

FreeRTOS assumes the opposite on every count. Objects are long-lived. Ownership is by handle. Teardown is asynchronous, with the idle task reaping afterwards. Kernel list nodes are threaded through the control blocks, and nothing signals that the kernel has finished with a piece of memory.

Boreas embeds FreeRTOS control blocks (StaticTask_t, StaticSemaphore_t and the rest) inside caller-owned Zephyr-shaped structs. That imports Zephyr's memory model without the guarantees underneath it. The zkernel README records the cost: a parked task whose control block outlived its stack frame caused "months of intermittent scheduler corruption", kernel list operations writing through dangling nodes into reused frames.

What came out of that is the split. Tasks, blocking and the tick have to come from FreeRTOS. That is ESP-IDF's ground truth, and there is no version of this where you reimplement the scheduler. But synchronization object state, the counts and bits and waiter queues, is cheap to own, and owning it removes the whole bug family at once.

So k_sem is no longer a FreeRTOS semaphore. The count and the waiter list live in the Boreas struct; blocking rides direct-to-task notifications, whose state lives inside the task control block where the kernel owns it. Nothing of the caller's memory ever enters a kernel list. Four things fell out of that one change: K_SEM_DEFINE became a real compile-time initializer, k_sem_reset got upstream's semantics of waking waiters with -EAGAIN, k_sem_give wakes the highest-priority waiter, and when k_sem_take returns the kernel holds no reference into your struct.

k_work and k_timer already owned their state, which is why they came out of the audit lightest. k_event followed k_sem onto the same architecture, and it shows what wrapping had been costing: a FreeRTOS event group stores its bits in an EventBits_t, whose top byte is reserved, so you got 24 usable events where Zephyr gives you 32, and no way to express upstream's rule that setting events replaces the set rather than merging into it. Neither of those is fixable from above the wrapper.

Three primitives stay wrapped on purpose. k_thread wraps FreeRTOS tasks because tasks are the scheduler. k_mutex wraps a FreeRTOS mutex because priority inheritance is the entire value of the primitive, and it is the hardest thing in the catalog to reimplement correctly. k_msgq wraps a FreeRTOS queue because a copy-ring with two wait queues is a job FreeRTOS already does well.

Linker sections, archives, and registrations that vanish

This part is pure ESP-IDF, and it applies whether or not you use Boreas.

Several Boreas macros register entries into iterable linker sections instead of running constructor functions:

MacroSection
SYS_INIT().sys_init_entries
LOG_MODULE_REGISTER().log_module_entries
LOG_BACKEND_DEFINE().log_backends
SHELL_CMD_REGISTER().shell_root_cmds

The obvious implementation is a constructor plus a static registration struct. It does not work, and the reason is the GNU linker's archive-member rule.

ESP-IDF compiles components into static libraries. When the final image is linked, an object file is pulled out of an archive only to resolve an unresolved symbol reference. A translation unit whose only contribution is a constructor and a static struct has no externally-referenced symbol, so the whole .o is dropped. The constructor never runs and the registration disappears. The struct compiled fine. It made it into the .a. It was gone at final link, with no warning.

ESP-IDF documents the same constraint for its own ESP_SYSTEM_INIT_FN:

Initialization functions should be placed in a compilation unit where at least one other symbol is referenced in another compilation unit.

So the rule for every macro in that table, and for DEVICE_DEFINE, which still registers through a constructor, is to put the callsite in main/, or in a component translation unit that exports some other symbol referenced from outside it.

Linker sections remove the need for a constructor, and KEEP stops --gc-sections throwing the entries away. But linker scripts do not pull archive members either. Only unresolved-symbol references do. The section approach fixes garbage collection. It does not fix the archive-pull problem, and nothing except a real symbol reference does.

If you do need a registration pulled out of a component archive, export an anchor and take its address from code that actually runs:

extern const int foo_anchor;
const int * volatile p = &foo_anchor;
(void)*p;

A file-scope __attribute__((used)) pointer is not enough. --gc-sections strips unreferenced exported rodata even with used.

We also tried raw .ld scripts supplied through target_link_options, which looked tidier and failed two ways. A raw SECTIONS { … } > dram0_0_seg block assigns an address in DRAM but is not part of ESP-IDF's flash-to-DRAM load segment, so at boot the address holds whatever the SRAM powered up with. The section appears to exist, iteration reads wild pointers, and the device crashes. And INSERT AFTER .dram0.data does nothing across separate -T scripts, because the output section in ESP-IDF's own sections.ld is not visible to a supplementary script when INSERT is resolved. ESP-IDF's ldgen .lf fragments avoid both by merging into the existing .dram0.data output section, which is already in the load segment. That is what Boreas ships.

To check it worked on a real target, look for the boundary symbols in the ELF:

xtensa-esp32s3-elf-nm build/<proj>.elf | grep -E "_sys_init_entries_(start|end)"

Both should land in DRAM, and end - start should be an exact multiple of the entry struct's size. Zero means a translation unit was stripped. A non-multiple means the boundary symbols are not aligned. Check for ALIGN(4, pre, post) in the .lf mapping.

Getting it into a project

Four steps, and the third is the one people miss.

git submodule add https://github.com/intercreate/boreas.git components/boreas
git submodule update --init --recursive

Point ESP-IDF at the inner components/ directory and add our sdkconfig defaults. Both have to happen before the project.cmake include:

cmake_minimum_required(VERSION 3.16)

set(EXTRA_COMPONENT_DIRS components/boreas/components)
# Append to your existing defaults, do not overwrite them
set(SDKCONFIG_DEFAULTS "sdkconfig.defaults;components/boreas/sdkconfig.boreas")

include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(my_app)

Then regenerate the configuration, because ESP-IDF reads SDKCONFIG_DEFAULTS only when it generates a fresh sdkconfig. This throws away anything you set in menuconfig, so move those settings into sdkconfig.defaults first:

rm -rf build sdkconfig && idf.py set-target esp32s3

Skip that and a stale sdkconfig wins. sdkconfig.boreas carries the one setting we cannot apply from inside a component, CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2. The notification-backed primitives are the reason. k_sem and k_event block on notification index 1, because index 0 is already in use by ESP-IDF internals such as esp_ipc and pthread. A second index has to be configured project-wide. If the setting is missing the build stops with an #error naming the file. Do not use notification index 1 in your own code.

Finally, list the components you use in REQUIRES:

idf_component_register(
    SRCS "main.c"
    INCLUDE_DIRS "."
    REQUIRES zkernel zdevice zsys
)

Tests run on ESP-IDF's Linux preview target, with no board involved:

cd test
idf.py --preview set-target linux
idf.py build
./build/boreas_test.elf

One caveat on that. The Linux target does not expose the DRAM layout ldgen needs, so the sections are not built there: the logging and shell macros fall back to constructors, and SYS_INIT is compiled out entirely. The host binary is linked whole in any case, so archive stripping never applies. The Linux run checks the kernel primitives and not the registration mechanism. For that, either run the same suite on a board (idf.py set-target esp32s3) or use the nm check above.

Where it diverges

FreeRTOS is still the thing underneath, and in places that shows. Most meaningful differences carry an @note on the declaration. These are the ones to know going in, documented or not.

  • Every thread is pinned to core 0. Nothing in the header or the README says so, and on a dual-core part that is the whole second core unused.
  • Do not abort a thread blocked in k_sem_take or k_event_wait, or one that is inside k_sem_give. Upstream unpends aborted threads; Boreas cannot.
  • k_thread_join polls at 10 ms rather than waking immediately, so a join costs about 10 ms whatever the tick rate. Mostly visible as test-suite time.
  • k_msleep() truncates sub-tick sleeps to zero. At the default 100 Hz tick, k_msleep(5) returns immediately, while k_sleep(K_MSEC(5)) correctly rounds up to one tick. The two entry points disagree. This one cost us real time: an SPI NOR driver polled the flash status register with k_msleep(5) between reads and bounded the wait by iteration count, so with the sleep doing nothing it burned all 401 polls in about 25 ms and reported a timeout before a roughly 30 ms sector erase could finish. Page programs are short enough to fit, so only erases failed. Measured on an ESP32-C5 under ESP-IDF 5.5.3.

One thing that is not a divergence, in case you are upgrading rather than starting fresh: k_work's return codes match upstream Zephyr, and have since 0.1.0. Success is not 0: 0 means no-op, 1 queued, 2 was running and queued again, and negative is an error. k_work_cancel returns the remaining busy state. That is upstream's contract, but it is the opposite of what earlier Boreas did, so a == 0 success check or a boolean test of k_work_cancel needs revisiting.

Should you use it?

If you are starting an ESP-IDF project and your team already thinks in Zephyr, yes. If you expect the product to move to another silicon vendor later, yes, because the application layer is what survives that move.

If you are committed to one Espressif part and nobody on the team has written Zephyr, probably not. Plain ESP-IDF is well documented and the abstraction will not pay for itself.

Either way, the two general lessons hold. Putting caller-owned objects over a handle-based kernel does not work unless you own the state the caller can see. And a linker will not keep something nothing references, however many KEEP directives you give it.

Boreas has the short version: what the components are and who should reach for them. The repository is at github.com/intercreate/boreas. Issues and pull requests are welcome, and if you are weighing this kind of decision on a product, we are happy to talk it through.

What this relates to