When I first moved a commercial sensor node from bare-metal C to Zephyr RTOS, the initial hurdle was not the kernel itself but understanding how Zephyr separates what your hardware is from what your software does. If you come from Arduino, FreeRTOS, or direct register programming on STM32 or nRF52, that separation feels unfamiliar at first. In Zephyr, your code rarely hardcodes a pin number or enables a peripheral with a #define. Instead, you describe hardware in Device Tree and select software features in Kconfig, and the build system stitches everything together at compile time. Once that mental model clicks, you gain a level of portability and configurability that is hard to match elsewhere. This article walks through that model from the ground up and gets you to a working first application on real hardware, sharing the practical details I wish I had when I started.
Why Zephyr RTOS Earns Its Place in Constrained IoT Hardware
Zephyr is a permissive, Apache 2.0-licensed RTOS designed for microcontrollers with as little as 8KB of RAM, yet it scales up to powerful application processors. Unlike a simple scheduler bolted onto a HAL, Zephyr provides a complete, integrated ecosystem: a preemptive multi-threaded kernel, a hardware abstraction layer, networking stacks including Bluetooth Low Energy, BLE Mesh, Wi-Fi, 802.15.4 and Thread, file systems, cryptography, and power management. In my experience, that integration is its biggest advantage for IoT products. You are not hunting for five separate third-party libraries that may or may not play nicely together.
The kernel itself will feel familiar if you have used other real-time operating systems. You create threads with priorities, use mutexes and semaphores for synchronization, and rely on interrupt handling and system work queues. Where Zephyr diverges is its driver model. Drivers are not initialized by calling MX_GPIO_Init() or editing a CubeMX generated file. They are instantiated automatically at boot based on Device Tree nodes and Kconfig selections. That means your application code interacts with a generic device handle, not a vendor-specific peripheral struct.
This matters immensely for product longevity. I recently helped port a fleet of environmental monitors from an nRF52840 to an STM32WB55. Because the application code used Zephyr's device model, over 90% of the firmware remained untouched. We updated the board definition, adjusted the Device Tree overlay, and rebuilt. If you are building IoT devices you expect to maintain for five to ten years, that kind of hardware abstraction pays for itself quickly. For context on how scheduling compares to lighter RTOS options, see FreeRTOS Task Scheduling: Priorities, Preemption and Time Slicing.
The Upstream-First Hardware Support Model
Zephyr supports over 500 boards out of the box, and the key is that board support is upstreamed and maintained by the community and vendors. When you select BOARD=nrf52840dk_nrf52840, you are not just getting a linker script. You are pulling in a vetted Device Tree description, Kconfig defaults, and driver selections for that specific PCB, including clocks, pinmuxing, and flash partitions. The Zephyr Project Documentation maintains an exhaustive boards catalog where you can verify what is supported for your target before you buy hardware.
Navigating West, Toolchains and the Zephyr Build System
Before you write a single line of application code, you need to understand West. West is Zephyr's meta-tool that manages multiple Git repositories, which is how Zephyr handles its modular structure. Zephyr itself, plus modules like CMSIS, mbedTLS, and Segger RTT, live in separate repositories. West pulls them all at the correct revision defined in the manifest.
I've found that newcomers get tripped up by installation because they try to skip steps. Follow the Getting Started Guide for your OS verbatim. On Ubuntu, that means installing system dependencies, creating a Python virtual environment, and installing the Zephyr SDK which contains cross-compilers for Arm, RISC-V, Xtensa and others. Do not rely on your Linux distribution's arm-none-eabi-gcc unless you enjoy subtle toolchain bugs.
The canonical workflow after installation looks like this:
# Create a workspace and fetch Zephyr
west init ~/zephyrproject
cd ~/zephyrproject
west update
west zephyr-export
# Install Python dependencies and setup
pip install -r zephyr/scripts/requirements.txt
west packages pip --install
# Install the Zephyr SDK
cd ~
wget https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v0.16.8/zephyr-sdk-0.16.8_linux-x86_64.tar.xz
tar xvf zephyr-sdk-0.16.8_linux-x86_64.tar.xz
cd zephyr-sdk-0.16.8
./setup.sh -c
Every Zephyr application is built with CMake and Ninja. You never compile a single .c file manually. Instead you invoke west build which wraps CMake. The two mandatory arguments are the source directory and the board target.
west build -b nrf52840dk_nrf52840 samples/basic/blinky
west flash
west espressif monitor # or west rtt, depending on debug probe
In my experience, building the blinky sample for your own board first is the best sanity check for your environment. If that builds and flashes, your toolchain and West setup are correct. If it fails, the error is in your environment, not your future application.
Demystifying Kconfig: Sculpting Your Firmware Footprint Feature by Feature
Kconfig is Zephyr's configuration system, inherited from the Linux kernel. Its job is to decide what software gets compiled in. Every driver, kernel feature, and subsystem has a Kconfig symbol. You do not edit Makefiles to enable the UART driver; you set CONFIG_SERIAL=y.
The mental model I use is: Kconfig answers "What capabilities does my firmware need?" Device Tree answers "What hardware is on my board?" They are complementary and you need both.
Configuration is stored in prj.conf in your application directory. A minimal project configuration for a sensor node that needs GPIO, UART, and the sensor subsystem might look like this:
# prj.conf - Project configuration for my sensor application
CONFIG_GPIO=y
CONFIG_SERIAL=y
CONFIG_UART_INTERRUPT_DRIVEN=y
# Enable the sensor subsystem and the specific driver
CONFIG_SENSOR=y
CONFIG_BME280=y
# Kernel options
CONFIG_MAIN_STACK_SIZE=1024
CONFIG_HEAP_MEM_POOL_SIZE=2048
# Logging - essential during development
CONFIG_LOG=y
CONFIG_LOG_PRINTK=y
CONFIG_LOG_DEFAULT_LEVEL=3
# Power management if battery operated
CONFIG_PM=y
CONFIG_PM_DEVICE=y
Notice you enable subsystems, not files. Setting CONFIG_BME280=y tells the build system to compile the BME280 driver and register it, but the driver will only probe if Device Tree tells it where the sensor is connected. That separation prevents you from shipping dead code. On an nRF52840 with 256KB RAM, a full-featured build with logging, shell, and network stack might be 180KB of flash, while a minimal blinky is under 30KB.
To explore available options, use the interactive configurator:
west build -b nrf52840dk_nrf52840 -t menuconfig
west build -b nrf52840dk_nrf52840 -t guiconfig
Inside menuconfig, you can search with /. I've found this invaluable for discovering dependencies. For example, if you enable CONFIG_BT, it will automatically select required crypto and heap options. If you try to manually edit prj.conf without understanding dependencies, you will see cryptic CMake errors about unsatisfied dependencies. When that happens, run menuconfig and let it resolve them, then save and copy the generated .config insight back into your prj.conf.
Practical Kconfig Advice for Memory-Constrained Builds
For production builds, be deliberate about logging and debugging features. CONFIG_LOG and CONFIG_SHELL are fantastic for development but consume significant RAM and flash. I maintain two configuration files: prj.conf for common settings and prj_release.conf which I merge at build time with west build -- -DCONF_FILE="prj.conf prj_release.conf" to disable debug overhead. Also, prefer CONFIG_HEAP_MEM_POOL_SIZE adjustments over dynamic heap growth if you need predictable memory behavior. For a deeper view of allocation trade-offs, see RTOS Memory Management: Static Allocation, Pools and Heap Strategies.
Describing Hardware Without Code: How Device Tree and Overlays Work
Device Tree is the most foreign concept for developers coming from STM32Cube or Arduino, but it is central to Zephyr. A Device Tree (DTS) file is a hierarchical data structure that describes hardware. It does not contain code. It tells Zephyr and its drivers what peripherals exist, where they are memory-mapped, what pins they use, and how they are connected.
You rarely write a Device Tree from scratch. Your board already has a base DTS file, for example boards/arm/nrf52840dk_nrf52840/nrf52840dk_nrf52840.dts which includes the SoC definition nrf52840.dtsi. Your job as an application developer is usually to write an overlay, a small DTS fragment that modifies or extends the base description for your specific product or prototyping setup.
Consider a common scenario: you have a BME280 temperature sensor connected via I2C0 at address 0x76, and an LED on P0.13 that you want to blink. On the nRF52840 DK, I2C0 is disabled by default to save power. Your overlay enables it and adds your sensor:
/* boards/nrf52840dk_nrf52840.overlay - Application-specific hardware */
&i2c0 {
status = "okay";
pinctrl-0 = <&i2c0_default>;
pinctrl-1 = <&i2c0_sleep>;
pinctrl-names = "default", "sleep";
clock-frequency = <I2C_BITRATE_STANDARD>;
bme280@76 {
compatible = "bosch,bme280";
reg = <0x76>;
status = "okay";
};
};
/ {
aliases {
my-led = &led0;
};
};
/* Optional: define led0 if your custom board lacks it */
&led0 {
gpios = <&gpio0 13 GPIO_ACTIVE_LOW>;
};
The key field is compatible. This string binds the hardware node to a specific Zephyr driver. "bosch,bme280" matches the driver that is compiled when you set CONFIG_BME280=y. If the compatible does not match any driver, the node is ignored at boot. This is a common debugging dead end: a typo in compatible string results in a device that silently never probes.
Device Tree also generates C macros at build time. Zephyr's code generator parses the DTS and creates devicetree_generated.h with macros like DT_NODELABEL and DT_ALIAS. Your application uses these to get device handles without hardcoding addresses or pins.
| Aspect | Kconfig | Device Tree |
|---|---|---|
| Primary Question Answered | What software features should be built? | What hardware is present and how is it wired? |
| Time of Evaluation | Build-time (CMake/Kconfig) | Build-time (DTS preprocessing) + Boot-time (device instantiation) |
| Typical File Location | prj.conf, Kconfig, .config | .dts, .dtsi, .overlay, bindings YAML |
| Controls | Driver inclusion, kernel features, stack sizes, subsystems | Peripheral base addresses, pinmux, interrupts, bus connections |
| Example Symbol | CONFIG_I2C=y, CONFIG_SENSOR=y | &i2c0 { status = "okay"; }, compatible = "bosch,bme280" |
In my experience, the best workflow is to keep your base board files untouched and place all product-specific wiring in boards/<board>.overlay inside your application. That overlay travels with your application repository, making it version-controlled and portable without forking Zephyr itself.
Building Your First Zephyr Application: Blinky Beyond the Basics
The standard "blinky" example is often dismissed as trivial, but a Zephyr blinky that uses Device Tree properly teaches you the patterns you will reuse for every subsequent driver. We will build a version that does not hardcode the LED pin and demonstrates logging and timing.
Create a new application outside the Zephyr tree for portability. Zephyr project structure expects this layout:
my_first_app/
├── CMakeLists.txt
├── prj.conf
├── boards/
│ └── nrf52840dk_nrf52840.overlay
└── src/
└── main.c
The CMakeLists.txt is minimal:
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_first_app)
target_sources(app PRIVATE src/main.c)
And the prj.conf for this application:
CONFIG_GPIO=y
CONFIG_LOG=y
CONFIG_LOG_PRINTK=y
Now the core of the application. Notice we never write #define LED_PIN 13. We fetch the LED from Device Tree alias and use the Zephyr GPIO API, which works identically on nRF, STM32, ESP32, and any other supported SoC:
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(main, LOG_LEVEL_INF);
/* Get LED from Device Tree alias - works across boards */
#define LED0_NODE DT_ALIAS(my_led)
#if !DT_NODE_HAS_STATUS(LED0_NODE, okay)
#error "Unsupported board: my-led devicetree alias is not defined"
#endif
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);
int main(void)
{
int ret;
if (!gpio_is_ready_dt(&led)) {
LOG_ERR("LED device %s is not ready", led.port->name);
return 0;
}
ret = gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
if (ret < 0) {
LOG_ERR("Failed to configure LED pin (err %d)", ret);
return 0;
}
LOG_INF("Blinky started on %s pin %d", led.port->name, led.pin);
while (1) {
gpio_pin_toggle_dt(&led);
LOG_INF("LED %s", gpio_pin_get_dt(&led) ? "ON" : "OFF");
k_msleep(1000);
}
return 0;
}
Several things in this code are worth noting. GPIO_DT_SPEC_GET expands to a struct containing the port device, pin number, and flags (active high/low) directly from Device Tree. gpio_is_ready_dt checks if the underlying GPIO driver has initialized correctly, which catches overlay errors early. The check with #error fails at compile time if your overlay did not define the alias, giving you a clear error rather than a runtime fault.
This pattern extends verbatim to every other subsystem. For I2C sensors, UART, SPI flash, or PWM, you get the device with DEVICE_DT_GET(DT_NODELABEL(bme280)) and pass that handle to the subsystem API. Your business logic remains decoupled from board wiring.
Thread Usage and Synchronization From the Start
Even in a simple application, use k_msleep() instead of a busy loop. It yields the CPU to Zephyr's idle thread, enabling power management. If you later add a second thread for sensor reading, you will use the same device handles with proper synchronization. The Zephyr kernel provides mutexes, semaphores, and message queues that align closely with other RTOS primitives, though configuration and naming differ. If you are transitioning from FreeRTOS, the mapping of those concepts is covered in RTOS Synchronization: Mutexes, Semaphores and Event Groups.
From Build to Flash: Debugging and Iterating on Real Hardware
Building is deceptively smooth; flashing and debugging is where hardware realities appear. Zephyr integrates with all major probes: J-Link, DAPLink, ST-Link, and ESP-Prog. The command west flash auto-detects your probe, but I recommend being explicit during development via west flash --runner jlink or openocd to avoid confusion when you have multiple probes connected.
After flashing, monitor output with west espressif monitor for ESP32, or more generally west rtt or minicom -D /dev/ttyACM0 for UART logging. If you see no log output, verify three things: first, that CONFIG_LOG_PRINTK and CONFIG_UART_CONSOLE are configured if you use UART; second, that your Device Tree enables the correct UART instance; and third, that your host terminal is set to the correct baud rate, typically 115200.
For debugging, use hardware breakpoints rather than printf. Zephyr applications run with full symbol information when built in debug configuration. Launch GDB through West:
west debug --runner jlink
# Inside GDB: break main, continue, bt, info threads
I've found the most effective iterative loop is to enable the Zephyr shell over UART. Just add CONFIG_SHELL=y and CONFIG_SHELL_BACKEND_SERIAL=y to your prj.conf. Then you can introspect device status at runtime with device list and kernel threads without reflashing. When combined with RTT logging, which streams logs over the debug probe without using a UART, you get a low-overhead observability channel suitable for field issues. For a full methodology on real-time tracing and hard fault analysis, see Real-Time Debugging: Trace Tools, Logic Analyzers and JTAG Techniques.
A final practical note: always version lock your Zephyr SDK and West workspace. In CI, pin the manifest revision with west update --rev and archive your build/zephyr/.config. I've spent days chasing bugs that were simply a difference between Zephyr v3.5 and v3.6 Kconfig defaults on a colleague's machine.
Frequently Asked Questions
Should I edit the board's .dts file directly or use an overlay?
Always use an overlay for application-specific changes. Keep the base board DTS as it is upstream. An overlay like boards/nrf52840dk_nrf52840.overlay in your application folder is merged at build time and stays with your application repository. Direct edits to files under zephyr/boards will be lost on west update and make your project impossible to reproduce on another machine.
Why does my driver not probe even though CONFIG_* is enabled?
This almost always means Device Tree and Kconfig are not aligned. Enabling CONFIG_BME280=y compiles the driver, but the driver only creates a device instance if it finds a node with compatible = "bosch,bme280" and status = "okay" on an enabled bus like &i2c0 with status = "okay". Check that the bus is enabled, the compatible string exactly matches the driver's binding YAML, and that you have re-run the build after changing the overlay.
Can I use Zephyr without Device Tree for simple projects?
Technically yes for some subsystems, but it is not recommended. Device Tree is the standard path for all drivers and future-proofing. Bypassing it with manual pin defines forces you to rewrite code for every new board and disables automatic power management and pin control integration. Even for simple prototypes, defining the hardware in an overlay pays off as soon as you move from a development kit to custom hardware.
How does Zephyr's Kconfig relate to sysbuild and multi-image builds?
Sysbuild is Zephyr's newer multi-image build system used when you need a bootloader like MCUboot alongside your application. Kconfig still configures each image independently, but sysbuild orchestrates how those images are built and combined into a single binary. For a single-app blinky, you do not need sysbuild. When you add firmware updates or trusted execution, you enable CONFIG_SB_CONFIG_BOOTLOADER_MCUBOOT and sysbuild handles the dependencies between images.