This article uses the STM32 Nucleo F103RB as the worked example. Every concept and pattern applies equally to any Zephyr-supported board - Nordic nRF52, NXP i.MX RT, Raspberry Pi Pico, and others.
Introduction
In the first article, we set up Zephyr from scratch on the STM32 Nucleo F103RB, built the blinky sample from inside the Zephyr source tree, and flashed it successfully. If you followed along with any supported Zephyr board, your LED is blinking and your toolchain is working.
But there is something uncomfortable about that first approach: our application code was sitting inside the Zephyr source tree, under zephyrproject/zephyr/samples/. That is fine for experimenting with official samples, but it is the wrong structure for real projects. You do not want your firmware mixed in with the Zephyr kernel source.
In this article we fix that. We will move blinky out of the Zephyr tree, understand why that matters, then go deeper into the three systems that make every Zephyr build work: the build pipeline, the Device Tree, and Kconfig. The concepts apply to any board Zephyr supports - Nordic nRF52, NXP i.MX RT, Raspberry Pi Pico, STM32, or any other. We use the STM32 Nucleo F103RB as the concrete example throughout, but every principle translates directly to your board. By the end, you will understand exactly what happens between west build and zephyr.elf.
Part 1 - The Right Project Structure
Out-of-tree Applications
Zephyr was designed from the start to support applications that live completely outside the Zephyr source tree. This is called an out-of-tree application and it is how every production Zephyr project should be structured.
Your workspace should look like this:
zephyrproject/ ← West workspace root
├── .venv/ ← Python virtual environment
├── zephyr/ ← Zephyr kernel source (do not touch)
├── modules/ ← HAL drivers, Bluetooth, crypto, etc.
├── bootloader/ ← MCUboot
└── blinky/ ← Your application (out of tree)
├── CMakeLists.txt
├── prj.conf
└── src/
└── main.c
Zephyr lives in zephyrproject/zephyr/. Your application lives alongside it in zephyrproject/blinky/. The two never mix.
Moving Blinky Out of the Tree
Copy the blinky sample out of the Zephyr source:
cp -r ~/zephyrproject/zephyr/samples/basic/blinky ~/zephyrproject/blinky
cd ~/zephyrproject/blinky
Your blinky folder now contains three files:
blinky/
├── CMakeLists.txt
├── prj.conf
└── src/
└── main.c
Build it, replacing nucleo_f103rb with your own board identifier:
west build -p always -b <your_board>
For example:
west build -p always -b nucleo_f103rb # STM32 Nucleo F103RB
west build -p always -b nrf52840dk # Nordic nRF52840 DK
west build -p always -b rpi_pico # Raspberry Pi Pico
west build -p always -b mimxrt1060_evk # NXP i.MX RT1060
Not sure of your board identifier? Browse the full list:
west boards
Notice that West finds everything automatically. You do not need to specify a path because you are already inside the project directory. West locates the CMakeLists.txt, resolves the Zephyr base from your workspace, and builds.
The Three Files Every Zephyr App Needs
CMakeLists.txt is always the same boilerplate:
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(blinky)
target_sources(app PRIVATE src/main.c)
The find_package(Zephyr) line is what connects your application to the entire Zephyr build system. Everything else follows from there. You rarely need to change this file beyond the project name and source file list.
prj.conf contains your Kconfig overrides. For blinky it is nearly empty:
CONFIG_GPIO=y
The board's default configuration already enables everything else needed. Your prj.conf only needs to declare what you add on top.
src/main.c is your application. In Zephyr, main() runs in a dedicated thread with its own stack, inside the scheduler. The blinky code uses the Device Tree API to find the LED without any hardcoded pin numbers:
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#define LED0_NODE DT_ALIAS(led0)
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);
int main(void) {
gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
while (1) {
gpio_pin_toggle_dt(&led);
k_msleep(1000);
}
return 0;
}
DT_ALIAS(led0) resolves at compile time to the correct GPIO pin for your board - GPIOA pin 5 on the Nucleo F103RB, but a completely different pin on an nRF52840 DK or a Raspberry Pi Pico. The key point: you change nothing in main.c when porting. Only the Device Tree changes.
Part 2 - The Build Pipeline
What west build Actually Does
west build is a convenience wrapper around two standard CMake commands:
# These two commands are equivalent to west build -p always -b <your_board>
cmake -S . -B build -G "Ninja" -DBOARD=<your_board>
cmake --build build
Running them manually produces identical output. Understanding this is useful when integrating Zephyr into a CI pipeline or a larger CMake project.
The full build pipeline has four stages:
Stage 1 - Device Tree compilation. CMake finds the board DTS, merges all included DTSI files, runs the Device Tree Compiler (DTC), and produces build/zephyr/zephyr.dts (the final merged tree) and build/zephyr/include/generated/zephyr/devicetree_generated.h (a C header your code uses at compile time).
Stage 2 - Kconfig resolution. CMake parses the Zephyr Kconfig tree, loads the board's _defconfig, merges your prj.conf, and writes the final build/zephyr/.config and build/zephyr/include/generated/zephyr/autoconf.h.
Stage 3 - Ninja build generation. With the resolved DTS and Kconfig, CMake generates Ninja build files that describe exactly which source files to compile, which compiler flags to use, and how to link the final binary.
Stage 4 - Compilation and linking. Ninja compiles all selected source files using arm-zephyr-eabi-gcc, links against the Zephyr kernel, and produces zephyr.elf and zephyr.hex.
You can see all four stages in the CMake configure output:
Found BOARD.dts: .../nucleo_f103rb.dts
Generated zephyr.dts: .../build/zephyr/zephyr.dts
Generated devicetree_generated.h
Parsing Kconfig
Loaded nucleo_f103rb_defconfig
Merged prj.conf
Saved .config and autoconf.h
Found toolchain: zephyr 1.0.1
The Board Definition Folder
When you pass -b <your_board>, CMake looks for the board definition under zephyr/boards/<vendor>/<board_name>/. For the Nucleo F103RB that is:
zephyr/boards/st/nucleo_f103rb/
├── nucleo_f103rb.dts ← hardware description
├── nucleo_f103rb_defconfig ← default Kconfig options
├── board.cmake ← flash runner configuration
├── board.yml ← board metadata
├── Kconfig.nucleo_f103rb ← board-specific Kconfig symbols
├── arduino_r3_connector.dtsi ← Arduino header pin mapping
├── st_morpho_connector.dtsi ← ST Morpho header pin mapping
└── support/ ← OpenOCD configuration files
The structure is identical for every supported board. For a Nordic nRF52840 DK it would be zephyr/boards/nordic/nrf52840dk/, for a Raspberry Pi Pico it would be zephyr/boards/raspberrypi/rpi_pico/. The file names change but the role of each file is always the same. When you eventually write your own custom board support, you create exactly this folder with these files.
Part 3 - The Device Tree
What the Device Tree Is
The Device Tree is a data structure that describes hardware. It tells Zephyr what peripherals exist on your SoC, where they are in the memory map, which interrupts they use, how they are clocked, and how they are physically wired on the board.
Crucially, this information lives in .dts files - not in your C code. Your C code uses macros that resolve to Device Tree values at compile time, which means the same application code can run on different hardware just by changing the DTS.
How DTS Files Are Layered
The Device Tree for your board is not a single file. It is assembled from multiple layers. For the Nucleo F103RB:
stm32f103Xb.dtsi ← SoC level: all F103 peripherals, all disabled by default
+ stm32f103r(8-b)tx-pinctrl.dtsi ← all possible pin configurations for this package
+ arduino_r3_connector.dtsi ← Arduino header abstraction
+ st_morpho_connector.dtsi ← ST Morpho header abstraction
+ nucleo_f103rb.dts ← board level: what is actually enabled and wired
+ your app.overlay (optional) ← your application-specific additions
= build/zephyr/zephyr.dts ← final merged result
The pattern is the same for every board. The SoC DTSI defines every peripheral the chip has, all with status = "disabled". The board DTS then selectively enables what is physically wired on your specific board and configures it. Your overlay can add sensors, change pin assignments, or enable peripherals that the board DTS left disabled. The final merged zephyr.dts in your build directory is always the source of truth - inspect it when something does not behave as expected.
Reading a Board DTS
Let's walk through the key sections of the Nucleo F103RB board DTS. The same patterns appear in every board DTS regardless of vendor or architecture.
The clock tree - this is how the F103 reaches its 72 MHz system clock:
&clk_hse {
hse-bypass;
clock-frequency = <DT_FREQ_M(8)>; /* 8 MHz from ST-Link */
status = "okay";
};
&pll {
mul = <9>; /* 8 MHz x 9 = 72 MHz */
clocks = <&clk_hse>;
status = "okay";
};
&rcc {
clocks = <&pll>;
clock-frequency = <DT_FREQ_M(72)>;
ahb-prescaler = <1>;
apb1-prescaler = <2>; /* APB1 runs at 36 MHz */
apb2-prescaler = <1>; /* APB2 runs at 72 MHz */
};
The ST-Link on the Nucleo provides an 8 MHz clock to the HSE input. The PLL multiplies it by 9 to reach the maximum 72 MHz system clock. All of this is declared in the DTS - not in startup code, not in CubeMX - just data.
The UART console - USART2 enabled on PA2/PA3 at 115200:
&usart2 {
pinctrl-0 = <&usart2_tx_pa2 &usart2_rx_pa3>;
pinctrl-names = "default";
current-speed = <115200>;
status = "okay";
};
The LED and button - defined as logical devices with GPIO references:
leds {
compatible = "gpio-leds";
green_led_2: led_2 {
gpios = <&gpioa 5 GPIO_ACTIVE_HIGH>;
label = "User LD2";
};
};
gpio_keys {
compatible = "gpio-keys";
user_button: button {
gpios = <&gpioc 13 GPIO_ACTIVE_LOW>;
zephyr,code = <INPUT_KEY_0>;
};
};
The aliases node - the bridge between your application and the hardware:
aliases {
led0 = &green_led_2;
sw0 = &user_button;
};
DT_ALIAS(led0) in your C code resolves to &gpioa 5 at compile time through this alias. Your application never has a hardcoded GPIOA, GPIO_PIN_5, or any register address - it just asks for led0 and the build system provides the correct hardware reference.
The chosen Node
The chosen node is a special Device Tree node that acts as a routing table. It tells Zephyr which peripheral instance to use for each system-level role:
chosen {
zephyr,console = &usart2; /* printk output goes here */
zephyr,shell-uart = &usart2; /* Zephyr interactive shell */
zephyr,sram = &sram0; /* RAM region for the linker */
zephyr,flash = &flash0; /* Flash region */
};
Your SoC likely has multiple UART instances. Without chosen, Zephyr would not know which one to use as the console. This node makes the decision explicit and overridable - and the same pattern applies regardless of vendor or architecture.
To switch the console to USART1, you just add one line to your overlay - no C code changes:
/ {
chosen {
zephyr,console = &usart1;
};
};
The Device Tree Overlay
An overlay is an optional file that you create when your application needs to extend or override the board's default hardware configuration. You place it inside a boards/ folder in your project, named after your board:
my_app/
├── CMakeLists.txt
├── prj.conf
├── boards/
│ └── <your_board>.overlay ← e.g. nucleo_f103rb.overlay or nrf52840dk.overlay
└── src/
└── main.c
Zephyr automatically picks up the overlay that matches the board you are building for. If you build for multiple boards, you can have one overlay per board in the same boards/ folder.
Common uses for overlays:
Adding an external sensor on I2C:
&i2c1 {
mpu6050@68 {
compatible = "invensense,mpu6050";
reg = <0x68>;
status = "okay";
};
};
Enabling a peripheral that the board DTS left disabled:
&usart3 {
status = "okay";
};
You do not need an overlay for most beginner projects. Your board's DTS already configures the standard peripherals, the LED, and any buttons that are physically present. You only create an overlay when you connect external hardware or need to override the board's default configuration.
Part 4 - Kconfig
What Kconfig Is
Kconfig is the configuration system Zephyr inherited from the Linux kernel. It controls which drivers, subsystems, and features are compiled into your firmware. Every option is a CONFIG_ symbol that is either enabled or disabled.
Kconfig answers a different question than Device Tree. The Device Tree says "USART2 exists at address 0x40004400 with TX on PA2". Kconfig says "compile the STM32 UART driver". You need both: hardware description and software selection.
How Kconfig Is Resolved
The final .config is assembled from three sources in order:
Zephyr core Kconfig defaults
+ <your_board>_defconfig ← board sets its required options
+ your prj.conf ← your application overrides
= build/zephyr/.config ← final resolved configuration
Your prj.conf always wins. If the board defconfig sets CONFIG_UART_CONSOLE=y and you add CONFIG_UART_CONSOLE=n to your prj.conf, the console will be disabled.
Reading the UART Kconfig for Blinky
Running grep on the built .config shows the full UART configuration chain:
CONFIG_SERIAL=y ← serial subsystem compiled in
CONFIG_UART_STM32=y ← STM32-specific UART driver selected
CONFIG_UART_CONSOLE=y ← printk routed to UART
CONFIG_STDOUT_CONSOLE=y ← C stdout connected to console
CONFIG_EARLY_CONSOLE=y ← console available before scheduler starts
CONFIG_SERIAL_SUPPORT_INTERRUPT=y ← interrupt-driven UART mode
None of these were in the blinky prj.conf. They all came from the board's _defconfig because the board knows it has a UART console. This is true for most development boards - their defconfig already enables the console UART, GPIO, and basic peripherals. Your prj.conf only needs to add what the board does not already provide.
Exploring Kconfig Interactively
Zephyr provides two interactive tools for exploring and modifying Kconfig options:
# Terminal-based (ncurses, same as Linux kernel menuconfig)
west build -t menuconfig
# Graphical (requires python3.12-tk)
west build -t guiconfig
Both work on your existing build without a full rebuild. Use them to browse subsystems, understand which options are available, and discover what enabling a new feature requires. Every option has a help text that explains what it does and what it depends on.
The Relationship Between Device Tree and Kconfig
A common point of confusion: do you need both? Yes, and they are not interchangeable.
Consider enabling I2C on your board. You need both.
In the Device Tree (hardware wiring) - this is the Nucleo F103RB example, your pin names will differ:
&i2c1 {
pinctrl-0 = <&i2c1_scl_remap1_pb8 &i2c1_sda_remap1_pb9>;
clock-frequency = <I2C_BITRATE_FAST>;
status = "okay";
};
In Kconfig (driver compilation):
CONFIG_I2C=y
Without the DTS node, the driver has no hardware to bind to. Without the Kconfig option, the driver is not compiled even if the hardware is described. Both must be present.
The mental model: Device Tree is hardware, Kconfig is software.
Part 5 - The Board board.cmake and Flash Runners
One last piece of the puzzle: how does west flash know which programmer to use?
The answer is in board.cmake:
board_runner_args(stm32cubeprogrammer "--port=swd" "--reset-mode=hw")
board_runner_args(jlink "--device=STM32F103RB" "--speed=4000")
include(${ZEPHYR_BASE}/boards/common/stm32cubeprogrammer.board.cmake)
include(${ZEPHYR_BASE}/boards/common/openocd-stm32.board.cmake)
include(${ZEPHYR_BASE}/boards/common/jlink.board.cmake)
This file declares which runners are available for this board and what arguments they need. STM32CubeProgrammer is listed first, so west flash selects it automatically when it is installed. The arguments --port=swd --reset-mode=hw match exactly what the Nucleo's onboard ST-Link requires.
To override the runner explicitly:
west flash --runner openocd
west flash --runner jlink
Conclusion
You now understand the full picture of a Zephyr build:
The project structure keeps your application code cleanly separated from the Zephyr kernel source. Your application has exactly three files: CMakeLists.txt, prj.conf, and your source code. An optional overlay extends the hardware configuration when needed.
The build pipeline is two CMake commands under the hood. It runs in four stages: Device Tree compilation, Kconfig resolution, Ninja build generation, and compilation. West wraps all of this into a single command.
The Device Tree describes your hardware in a layered, composable format. SoC DTSI defines everything. The board DTS enables what is physically present. Your overlay adds what your application needs. The chosen node routes system roles to specific peripheral instances. Aliases let your application code stay hardware-agnostic.
Kconfig controls which software is compiled. It is resolved from board defaults and your prj.conf. Device Tree and Kconfig are complementary - one describes hardware, the other selects software - and you need both.
This foundation is what everything else in Zephyr builds on: threads, GPIO drivers, I2C sensors, CAN stacks, and bootloaders all follow the same patterns you have seen here.
Found this useful? Subscribe to the TA Embedded newsletter for more embedded systems content.