Zephyr
Bringing Up a Custom Board in Zephyr RTOS
September 15, 2026
Zephyr has become one of our default recommendations for RTOS-based products, alongside FreeRTOS. It’s structured differently enough from a typical vendor SDK that board bring-up can feel unfamiliar at first. Once devicetree and Kconfig click, though, most bring-up work is a handful of configuration lines rather than new driver code. This article walks through why that is.
What makes Zephyr different
Zephyr is a Linux Foundation project, Apache 2.0 licensed, with support spanning ARM Cortex-M, RISC-V, Xtensa, ARC, and other architectures. That part is table stakes for a modern RTOS. The more consequential difference is scope: Zephyr isn’t just a scheduler and a kernel, it’s a kernel plus drivers, subsystems, and board/SoC definitions, all built from source per application and versioned together as one tree (the Zephyr repository, plus any modules) rather than a separate vendor SDK fork per board.
Hardware and software configuration are also kept declarative and separate from application code:
- Devicetree describes what hardware exists and how it’s wired.
- Kconfig describes which drivers and subsystems get compiled in.
Both are resolved at build time into fixed C code, not parsed at runtime.
west and the workspace
west is Zephyr’s meta-tool, similar in spirit to the repo tool used to manage multi-repository Yocto builds (something we’ve covered before). It manages a workspace of Git repositories, typically the zephyr tree itself, any modules (vendor HALs, third-party libraries), and your own application, all pinned to specific revisions by a manifest file (west.yml).
The build command reflects that structure:
west build -b <board> <path-to-app>
west flash
The -b flag selects a board target, which resolves to a directory holding that board’s base devicetree source and default configuration.
Devicetree: describing the hardware
Coming from a Linux BSP background, it’s easy to assume Zephyr’s devicetree works the way Linux’s does: parsed from a flattened blob at boot. It doesn’t. Zephyr resolves devicetree entirely at compile time, into a fixed set of C macros that application and driver code reference directly. Nothing about the hardware description changes at runtime, which is deliberate: it keeps the memory footprint small and the behavior deterministic.
A board ships a base .dts file describing its fixed hardware. Adding a new sensor or changing a pin assignment doesn’t mean editing that file; it means writing a devicetree overlay (a .overlay file), scoped to your application, that gets merged in at build time. A minimal example, adding a BME280 environmental sensor on I2C:
&i2c1 {
status = "okay";
bme280@76 {
compatible = "bosch,bme280";
reg = <0x76>;
};
};
&i2c1 references the I2C1 bus node already defined in the board’s base devicetree, enabling it if it isn’t already. bme280@76 adds a new child node at I2C address 0x76. The compatible string is what matters most: Zephyr’s driver tree registers drivers against specific compatible strings, so this one line is what wires the BME280 driver to this specific device instance. On a bare-metal or FreeRTOS project, the equivalent step is usually a manual I2C init call and a hand-instantiated driver struct in application code; here, it’s a devicetree node.
Kconfig: describing the software
Kconfig reuses the same configuration model the Linux kernel uses: symbols that gate which drivers and subsystems get compiled into the final image. Every board ships its own defaults, and your application adds a prj.conf on top with whatever extra symbols it needs:
CONFIG_I2C=y
CONFIG_SENSOR=y
CONFIG_BME280=y
| Devicetree | Kconfig | |
|---|---|---|
| Question it answers | What hardware exists, and how is it wired | Which drivers and subsystems get built |
| Where it lives | Board .dts plus your app’s .overlay |
Board defconfig plus your app’s prj.conf |
| Resolved | Into fixed C macros | Into #defines guarding compiled code |
A devicetree node with no matching Kconfig symbol enabled won’t get a driver instance behind it, and a Kconfig symbol with no matching devicetree node has nothing to attach to. Missing one side or the other is one of the most common bring-up mistakes, and the error messages from each failure mode look nothing alike, so it’s worth checking both whenever a new peripheral doesn’t show up.
Reading the sensor in application code
With the overlay and prj.conf in place, application code doesn’t open a device path or call an init function; it asks the devicetree for the instance by compatible string, resolved entirely at compile time:
#include <zephyr/drivers/sensor.h>
static const struct device *bme280 = DEVICE_DT_GET_ANY(bosch_bme280);
if (!device_is_ready(bme280)) {
return -ENODEV;
}
DEVICE_DT_GET_ANY turns the compatible string (commas and dashes become underscores, lowercased) into a specific device pointer at build time. From there, the sensor subsystem’s generic read/fetch/get API works the same regardless of which sensor driver is behind it.
A note on board layout
Zephyr’s board directory layout has changed between major versions (the hardware model was reorganized in the 3.7 release), so exact file locations from an older tutorial may not match a newer checkout, or the reverse. Worth checking west list zephyr for the exact revision pinned in your workspace’s manifest before copying board file paths from search results.
Once the devicetree/Kconfig split makes sense, adding a well-supported sensor or module to a new board is usually an overlay and a few config lines, no low-level driver code required. The exception is hardware Zephyr doesn’t already support upstream, which becomes its own driver, worth planning for separately during architecture selection.