Skip to content
Published On:
Apr 19, 2020
Last Updated:
Nov 14, 2024

Overview

Zephyr is the combination of a real-time operating system, peripheral/system API framework, and build system that is designed for resource-constrained devices such as microcontrollers. Is is part of the Linux Foundation.

The Zephyr Project logo.

Zephyr provides a firmware developer with a rich ecosystem of out-of-the-box OS and peripheral functionality that is consistent across MCU manufacturers (e.g. you can use the same UART API on both a STM32 and nRF53 MCU). It also features an integrated build system called west.

Some criticism arises from Zephyr’s complexity. Once such example is that Zephyr adds another layer of configurability with the use of Kconfig (a configuration language that was originally designed to configure the Linux kernel1). Firmware behaviour can now be controlled via a combination of Kconfig settings, preprocessor macros and API calls at runtime. The Kconfig is layered in a hierarchical manner and dependencies and overrides can make it difficult to determine how your MCU is being configured.

The main repo can be found on GitHub.

Zephyr provides cooperative and preemptive scheduling.

Uses a CMake build environment.

Zephyr is cross-platform and supported on Linux, Windows and macOS (i.e. you can cross-compile projects and flash embedded devices from any of those systems). You can also compile Zephyr for Linux, which is useful for testing and debugging.

Zephyr is also a platform supported by the PlatformIO build system and IDE.

Child Pages

Installation

Below are some basic Zephyr installation guides for various OSes. Another good read is the official Getting Started Guide.

Windows

The easiest way to install Zephyr on Windows is to use the chocolatey package manager. Once that is installed, run the following steps from an elevated command prompt:

  1. Enable global confirmation so that you don’t have to manually confirm the installation of individual programs:

    Terminal window
    > choco feature enable -n allowGlobalConfirmation
    Chocolatey v0.10.15
    Enabled allowGlobalConfirmation
  2. Install Zephyr dependencies:

    Terminal window
    > choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System'
    > choco install ninja gperf python git

    If you already have some of these packages installed on your machine via means other than chocolatey, it’s advisable to remove them from the above command so you don’t install them again and have one program shadow/conflict the other.

  3. Install the Zephyr command-line tool called west (NOTE: I have run into problems using a Python virtual environment, so I install west to the OS version of Python, more on this below):

    Terminal window
    > pip install west
  4. Create a new Zephyr project:

    Terminal window
    > west init myproject
    === Initializing in C:\Users\gbmhunter\myproject
    --- Cloning manifest repository from https://github.com/zephyrproject-rtos/zephyr, rev. master
    Initialized empty Git repository in C:/Users/gbmhunter/myproject/.west/manifest-tmp/.git/
    remote: Enumerating objects: 55, done.
    remote: Counting objects: 100% (55/55), done.
    remote: Compressing objects: 100% (42/42), done.
    ...
    ...
    Branch 'master' set up to track remote branch 'master' from 'origin'.
    --- setting manifest.path to zephyr
    === Initialized. Now run "west update" inside C:\Users\gbmhunter\zephyrproject.

    WARNING: I got the following error when trying to use a Python virtual environment rather than the OS Python environment to install west and then run west init:

    > python -m venv .venv
    > .\.venv\Scripts\activate
    > pip install west
    > west init myproject
    Updating files: 100% (14758/14758), done.
    Already on 'master'
    Branch 'master' set up to track remote branch 'master' from 'origin'.
    Traceback (most recent call last):
    File "C:\Users\gbmhunter\AppData\Local\Programs\Python\Python38-32\lib\shutil.py", line 788, in move
    os.rename(src, real_dst)
    PermissionError: [WinError 5] Access is denied: 'C:\\Users\\gbmhunter\\myproject\\.west\\manifest-tmp' -> 'C:\\Users\\gbmhunter\\myproject\\zephyr'
  5. Change into the project directory and run west update:

    Terminal window
    > west update

    This command can take a few minutes to run as it clones a number of repositories into the project directory.

  6. Export a Zephyr CMake package to your local CMake user package registry. This allows CMake to automatically find a Zephyr “base”:

    Terminal window
    > west zephyr-export
    Zephyr (C:/Users/gbmhunter/zephyrproject/zephyr/share/zephyr-package/cmake)
    has been added to the user package registry in:
    HKEY_CURRENT_USER\Software\Kitware\CMake\Packages\Zephyr
    ZephyrUnittest (C:/Users/gbmhunter/zephyrproject/zephyr/share/zephyrunittest-package/cmake)
    has been added to the user package registry in:
    HKEY_CURRENT_USER\Software\Kitware\CMake\Packages\ZephyrUnittest
  7. Download and install the GNU Arm Embedded Toolchain from https://developer.arm.com/tools-and-software/open-source-software/developer-tools/gnu-toolchain/gnu-rm/downloads. By default it will want to be installed on your system at C:\Program Files (x86)\GNU Arm Embedded Toolchain\10 2020-q4-major>, but don’t let it! Zephyr does not like spaces in the path, so install it to a path which has none. I chose C:\gnu-arm-embedded-toolchain\10-2020-q4-major\:

  8. Setup environment variables:

    Terminal window
    set ZEPHYR_TOOLCHAIN_VARIANT=gnuarmemb
    set GNUARMEMB_TOOLCHAIN_PATH=C:\gnu-arm-embedded-toolchain\10-2020-q4-major
  9. Install the Python dependencies that Zephyr

  10. Build the application! For this example I will be using the ST NUCLEO F070RB development board. Note that you can’t build this from the project root directory, first you have to change into the zephyr subdirectory:

    Terminal window
    > cd .\zephyr
    > west build -b nucleo_f070rb samples/basic/blinky
    C:\Users\gbmhunter\temp\myproject\zephyr>west build -b nucleo_f070rb samples/basic/blinky
    [121/128] Linking C executable zephyr\zephyr_prebuilt.elf
    [128/128] Linking C executable zephyr\zephyr.elf
    Memory region Used Size Region Size %age Used
    FLASH: 13544 B 128 KB 10.33%
    SRAM: 4416 B 16 KB 26.95%
    IDT_LIST: 0 GB 2 KB 0.00%
  11. Install OpenOCD from https://github.com/xpack-dev-tools/openocd-xpack/releases. I extracted the .zip file and then copied the files to C:\Program Files\OpenOCD\bin.

  12. Flash the application:

    Terminal window
    west flash

    NOTE: I got an error when running this:

    File "C:\Users\gbmhunter\temp\myproject\zephyr\scripts/west_commands\runners\core.py", line 504, in require
    if shutil.which(program) is None:
    File "C:\Users\gbmhunter\AppData\Local\Programs\Python\Python38-32\lib\shutil.py", line 1365, in which
    if os.path.dirname(cmd):
    File "C:\Users\gbmhunter\AppData\Local\Programs\Python\Python38-32\lib\ntpath.py", line 223, in dirname
    return split(p)[0]
    File "C:\Users\gbmhunter\AppData\Local\Programs\Python\Python38-32\lib\ntpath.py", line 185, in split
    p = os.fspath(p)
    TypeError: expected str, bytes or os.PathLike object, not NoneType

Ubuntu/WSL

Follow these instructions to setup/install Zephyr on UNIX like systems, including Ubuntu and WSL. Use of apt assumes you are running a Debian like system.

  1. Firstly, install system dependencies:

    Terminal window
    sudo apt install --no-install-recommends git cmake ninja-build gperf \
    ccache dfu-util device-tree-compiler wget \
    python3-dev python3-pip python3-setuptools python3-tk python3-wheel xz-utils file \
    make gcc gcc-multilib g++-multilib libsdl2-dev
  2. Next, download the Zephyr SDK. The SDK contains toolchains (compilers, linkers, assemblers and other tools such as QEMU and OpenOCD) for all of Zephyr’s supported architectures (e.g. ARM, RISCV64, x86_64, Xtensa, aarch64). Find the release you want from https://github.com/zephyrproject-rtos/sdk-ng/tags (use the latest if you don’t otherwise care). The example below uses v0.16.4 for x86-64 Linux.

    This is downloaded and installed outside of the Zephyr project directory. You only need one copy of this for many Zephyr projects.

    Terminal window
    cd ~
    wget https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v0.16.4/zephyr-sdk-0.16.4_linux-x86_64.tar.xz

    This file is over 1GB in size, so make sure you have space! There is also a minimal release which doesn’t come with all the toolchains, but instead let’s you select and download the ones you needed.

  3. Extract the downloaded SDK:

    Terminal window
    tar xvf zephyr-sdk-0.16.4_linux-x86_64.tar.xz

    This will extract the SDK to ~/zephyr-sdk-0.16.4. You can choose to move it somewhere else if you want. Other common choices include /opt/ and /usr/local/. This is another big operation which might take minutes to complete!

  4. Run the Zephyr SDK setup script:

    Terminal window
    cd zephyr-sdk-0.16.4
    ./setup.sh
  5. Install udev rules, which let’s you program most boards as a regular user (serial port permissions):

    Terminal window
    sudo cp ~/zephyr-sdk-0.16.4/sysroots/x86_64-pokysdk-linux/usr/share/openocd/contrib/60-openocd.rules /etc/udev/rules.d
    sudo udevadm control --reload
  6. Create a new directory for the Zephyr project and a Python virtual environment in a .venv sub-directory:

    Terminal window
    mkdir ~/zephyr-project
    cd ~/zephyr-project
    python3 -m venv .venv
    source .venv/bin/activate
  7. Install west in the new virtual environment (west is a Python package). As long as the virtual environment is activated, this will make west available from the command line:

    Terminal window
    pip install west
  8. Initialize the west workspace:

    Terminal window
    west init .

    This creates a directory called .west inside the current working directory. Inside .west another directory called manifest-tmp.

  9. Update:

    Terminal window
    west update

    west update downloads a lot of Git submodules that are present in the project under ./modules/. This may take a few minutes to complete.

  10. Export a Zephyr CMake package:

    Terminal window
    west zephyr-export

    This adds Zephyr to the user package registry at ~/.cmake/packages/Zephyr (which is outside of the project root directory). You should get a message like this:

    Zephyr (/home/gbmhunter/zephyr-project/zephyr/share/zephyr-package/cmake)
    has been added to the user package registry in:
    ~/.cmake/packages/Zephyr
  11. Install additional Python dependencies:

    Terminal window
    pip install -r ./zephyr/scripts/requirements.txt
  12. cd into the zephyr directory (a sub-directory of the project directory) and build the Blinky sample:

    Terminal window
    cd ~/zephyr-project/zephyr
    west build -p auto -b nucleo_f070rb samples/basic/blinky
  13. Make sure the dev. kit is plugged into the computer, and then flash the application onto the dev kit (remember we were using the NUCLEO-F070RB for this example):

    Terminal window
    west flash

If you don’t have a dev. board, you could always test out Zephyr by building it for Linux.

Terminal window
west build -p auto -b native_sim samples/basic/blinky

The build executable is at ./zephyr/build/zephyr/zephyr.exe (even though the extension is .exe, it is compiled for Linux not Windows). Of course, there is no basic LED we can blink when running on Linux, but it is mocked for us and instead prints to stdout:

Terminal window
~/zephyr-project/zephyr/build/zephyr$ ./zephyr.exe
*** Booting Zephyr OS build zephyr-v3.5.0-4014-g0d7d39d44172 ***
LED state: OFF
LED state: ON
LED state: OFF
LED state: ON
LED state: OFF
LED state: ON
...

Creating An Application

Once you’ve followed the installation instructions, you should be able to build and flash repository applications that are contained with the Zephyr repo (e.g. blinky). But what if you want to develop your own application? This is were is pays to understand the three different application types:

  • Repository Application: An application contained with the Zephyr repository itself (inside the zephyr/ directory in the workspace you created above). Typically these are example projects under samples/ like “Hello, world!” and “blinky”. In the directory structure below, hello_world is a repository application.
    zephyr-project/
    ├─── .west/
    │ └─── config
    └─── zephyr/
    ├── arch/
    ├── boards/
    ├── cmake/
    ├── samples/
    │ ├── hello_world/
    │ └── ...
    ├── tests/
    └── ...
  • Workspace Application: An application that is within the west workspace, but not within the zephyr repository (zephyr/). This is the way I recommended you create an application if you’re learning! In the directory structure below, app is a workspace application:
    <home>/
    ├─── zephyr-project/
    │ ├── .west/
    │ ├── app/
    │ │ ├── CMakeLists.txt
    │ │ ├── prj.conf
    │ │ └── src/
    │ │ └── main.c
    │ ├── zephyr/
    │ ├── bootloader/
    │ ├── modules/
    │ └── ...
  • Freestanding Application: An application that is not within the west workspace, i.e. stored somewhere else entirely on your disk. In the directory structure below, app is a freestanding application:
    <home>/
    ├─── zephyr-project/
    │ ├─── .west/
    │ │ └─── config
    │ ├── zephyr/
    │ ├── bootloader/
    │ ├── modules/
    │ └── ...
    └─── app/
    ├── CMakeLists.txt
    ├── prj.conf
    └── src/
    └── main.c

Creating a Workspace Application

If you are learning, I’d recommend you start by creating a bare bones workspace application to learn what the core files are for. Create a directory called app in the workspace, and then cd into it:

Terminal window
cd ~/zephyr-project
mkdir app
cd app

Now create a file called CMakeLists.txt with the follow context:

app/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr)
project(my_zephyr_app)
target_sources(app PRIVATE src/main.c)

Create an empty prj.conf inside this app directory. It needs to exist, but you don’t need anything in it for now.

The last thing you need to create a directory called src to store all the source code, and then create a file in it called main.c with the following contents.

app/src/main.c
#include <stdio.h>
#include <zephyr/kernel.h>
int main(void) {
while (1) {
printf("Hello, world!\n");
k_msleep(1000);
}
return 0;
}

Your basic app is done! Your file structure should look like this:

<home>/
├─── zephyr-project/
│ ├── .west/
│ ├── app/
│ │ ├── CMakeLists.txt
│ │ ├── prj.conf
│ │ └── src/
│ │ └── main.c
│ ├── zephyr/
│ ├── bootloader/
│ ├── modules/
│ └── ...

To build and run, first cd back into the west workspace at ~/zephyr-project/. The run this at the command-line:

Terminal window
west build -b native_sim ./app
west build -t run

The first command performs a build, telling west to use the native_sim board (runs on Linux) and to build the app located at ./app. The second command tells west to run it, and you should get output similar to this:

Terminal window
(.venv) geoff@my-computer:~/zephyr-project$ west build -t run
-- west build: running target run
[1/2] cd /home/geoff/zephyr-project/build && /home/geoff/zephyr-project/build/zephyr/zephyr.exe
*** Booting Zephyr OS build zephyr-v3.5.0-4014-g0d7d39d44172 ***
Hello, world!
Hello, world!
Hello, world!
...

Getting VSCode IntelliSense Working

The most effective way of getting VS Code’s IntelliSense working well with Zephyr is to use the “compile_commands” method as described below. I’ve found this to be much more effective than trying to provide include paths.

Add the following to your .west/config file:

[build]
cmake-args = -DCMAKE_EXPORT_COMPILE_COMMANDS=ON

This will tell CMake to always generate a compile_commands.json file when building.

Add the following to your .vscode/c_cpp_properties.json file:

{
"configurations": [
{
"name": "App Config",
"compileCommands": "${workspaceFolder}/build/compile_commands.json"
}
],
"version": 4
}

This will tell VS Code to use the compile_commands.json file for IntelliSense. The above snippet assumes your build directory is build/. Change this as needed.

Rebuild your project from scratch. A compile_commands.json should be generated in your build directory. Thus will be picked up by VSCode’s Intellisense and should fix any include errors you have!

If you are working on multiple apps with one west workspace, you can separate configurations for each. The following example sets up two configurations, one for the application and one for it’s ztest unit tests that are in a tests/ sub-directory, and with a build folder set to build-tests:

{
"configurations": [
{
"name": "App Config",
"compileCommands": "${workspaceFolder}/build/compile_commands.json"
},
{
"name": "Tests Config",
"compileCommands": "${workspaceFolder}/build-tests/compile_commands.json"
}
],
"version": 4,
"enableConfigurationSquiggles": true
}

Once this is added, you can select the active configuration from the right-hand side of the bottom toolbar within VS Code.

Instead of adding to .vscode/c_cpp_properties.json, if you just have one configuration, you can instead add it to .vscode/settings.json:

{
"C_Cpp.default.compileCommands": "${workspaceFolder}/build/compile_commands.json",
}

If you want more info on Zephyr unit tests and IntelliSense, see the Tests section of this page.

Moving a West Workspace

I haven’t had much luck moving a West workspace on Linux. After moving, the west executable could not be found (making sure the Python virtual environment was activated).

Hardware Abstraction Layers

Examples of some of the HALs (which are installed under <project root dir>/modules/hal/) supported by Zephyr:

  • ST
  • STM32
  • NXP
  • Atmel
  • Microchip

Supported Boards

See https://docs.zephyrproject.org/latest/boards/index.html#boards for a comprehensive list of all the development boards supported by the Zephyr platform.

Native Simulator (native_sim)

native_sim allows you to build a Zephyr application to run on POSIX-like OSes, e.g. Linux.

To build for POSIX, provide native_sim as the build target to west with the -b option, e.g.:

Terminal window
west build -b native_sim samples/hello_world

You can then run the built application with:

Terminal window
west build -t run

The executable will start up and run until you kill it with Ctrl+C. Note that you cannot terminate the program from the code by returning from main() or by calling exit(). If you do want to exit, you can call nsi_exit() instead, providing a int status code which will be returned as if you had returned it from main(). For example:

void my_function(void) {
// ...
// This will cause the Zephyr native_sim executable to correctly exit
nsi_exit(0);
}

If the code you want to return from needs to support other boards, you can use the preprocessor CONFIG_ARCH_POSIX to conditionally include the call:

void my_function(void) {
// ...
#ifdef CONFIG_ARCH_POSIX
nsi_exit(0);
#endif
}

I couldn’t find out what Zephyr header file declared this function, so if you want to get rid of the compiler warning about the function being undefined, declare it yourself (above where you want to use it in a .c file, or in your own header file) with:

// Declaration of the function provided by Zephyr. Couldn't find the header file where it's declared,
// so it's declared here.
void nsi_exit(int status);

The native_sim board supports the following APIs (among others):

  • GPIO (mocked)
  • Watchdog
  • Timers

Programming

west has built-in support for programming a number of different MCUs via west flash. You can specify the programmer you want to use with the --runner option. For example, to use a JLink programmer:

Terminal window
west flash --runner jlink

I got the following error when the target board was not powered up (when programming a Nordic Semiconductor nRF52840):

FATAL ERROR: command exited with status 1: /opt/SEGGER/JLink_V794i/JLinkExe -nogui 1 -if swd -speed 4000 -device nrf52840_xxaa -CommanderScript /tmp/tmp48nqgzxijlink/runner.jlink -nogui 1

Device Trees

Zephyr borrows the concept of device trees popularized by Linux to describe the hardware that the firmware is running on. Device trees are computer-readable text files that have a hierarchical structure.

Terminology

/dts-v1/;
/ {
a-node {
a-subnode-node-label: a-sub-node {
a-property = <3>;
};
};
};

a-node is a node. It is freely chosen identifier for a container. a-sub-node is a subnode. It is also a freely chosen identifier.

Basic Example

/dts-v1/;
/ {
soc {
uart0: serial@12340000 {
...
};
};
};

Getting Nodes

In C, to grab a node from the device tree you can use:

By path

Use DT_PATH() to get the node from a fully quality path from the root node. e.g DT_PATH(soc, serial_12340000). Note that non alphanumeric characters in the devicetree are converted to _, including hyphens and at symbols.

Do not use camel case when naming nodes, as DT_PATH() cannot work with these.

/ {
mySoc { // Bad! DT_PATH() won't like this
serial@12340000 {
...
};
};
mysoc { // Good!
serial@12340000 {
...
};
};
};

By node label

Use DT_NODELABEL(), e.g. DT_NODELABEL(uart0).

Compiled Full Device Tree

When you perform a build, a final, merged device tree gets generated at build/zephyr/zephyr.dts. This file is useful for debugging device tree issues.

Full Example

Example device tree (for the STM32F070RB development board):

/*
* Copyright (c) 2018 qianfan Zhao
*
* SPDX-License-Identifier: Apache-2.0
*/
/dts-v1/;
#include <st/f0/stm32f070Xb.dtsi>
#include "arduino_r3_connector.dtsi"
/ {
model = "STMicroelectronics NUCLEO-F070RB board";
compatible = "st,stm32f070rb-nucleo", "st,stm32f070";
chosen {
zephyr,console = &usart2;
zephyr,shell-uart = &usart2;
zephyr,sram = &sram0;
zephyr,flash = &flash0;
};
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 {
label = "User";
gpios = <&gpioc 13 GPIO_ACTIVE_LOW>;
};
};
aliases {
led0 = &green_led_2;
sw0 = &user_button;
};
};
&usart1 {
current-speed = <115200>;
status = "okay";
};
&usart2 {
current-speed = <115200>;
status = "okay";
};
&i2c1 {
status = "okay";
clock-frequency = <I2C_BITRATE_FAST>;
};
&i2c2 {
status = "okay";
clock-frequency = <I2C_BITRATE_FAST>;
};
&spi1 {
status = "okay";
};
&spi2 {
status = "okay";
};
&iwdg {
status = "okay";
};

System/OS Features

Time

System on time can be read with k_uptime_get() which returns a int64_t with the number of milliseconds since system start.

For higher level precision, you can measure time in either ticks or cycles.

Cycles are the fastest clock that you have available. You can use k_cycle_get_32() to get a uint32_t of the system’s hardware clock. You can then use functions like k_cyc_to_us_floor64() to convert this into an equivalent number of microseconds:

uint64_t currentTime_us = k_cyc_to_us_floor64(k_cycle_get_32());

Although I could not find it explicitly mentioned anywhere in the Zephyr documentation, it appears that it is safe to use cycles for time measurements even if the microcontroller is sleeping. I suspect Zephyr updates the cycle count when the system wakes back up to account for the duration of the sleep.

Be careful with the 32-bit value from k_cycle_get_32(). With a fast clock, this could overflow pretty quickly. If you are just interested in the duration between two time points, luckily the maths of subtracting a large unsigned number from a smaller one still gives you the right duration, until of course the later time catches up and passes the same uint32_t value the previous time point was at.

Timers

Zephyr Timers are an OS primitive that you can start and then have timeout after a certain duration. If you provide a callback, you can to run things after a fixed duration in the future in either a one-off (one-shot) or continuous manner. If you don’t provide a callback, you can still inspect the state of the timer from your application.

You do not have to add anything to prj.conf to use timers. First you’ll need to include the following header file which defines the timer API:

#include <zephyr/kernel.h>

You create a timer object and initialize it with:

struct k_timer myTimer;
void MyHandlerFn(struct k_timer * timer)
{
LOG_DBG("Hello!");
}
// Initialize it with an expiry function, but no stop function
void k_timer_init(&myTimer, &MyHandlerFn, NULL);

expiryFn and stopFn are both optional and can be NULL if you don’t want anything to be called when the timer expires or stops.

You can then start a timer with void k_timer_start(struct k_timer * timer, k_timeout_t duration, k_timeout_t period).

// Start the timer
k_timer_start(struct k_timer * myTimer, K_MSEC(1000), K_NO_WAIT);

duration is the time before the timer expires for the first time. period is the time between expires after the first one. period can be set to K_NO_WAIT or K_FOREVER to make the timer only expire once (one-shot).

Here is a basic example:

#include <stdio.h>
#include <zephyr/kernel.h>
extern void MyExpiryFn(struct k_timer * timerId) {
printf("Timer expired!\n");
}
int main(void) {
struct k_timer myTimer;
printf("Creating timer to expire every 1s...\n");
k_timer_init(&myTimer, MyExpiryFn, NULL);
k_timer_start(&myTimer, K_MSEC(1000), K_MSEC(1000));
while (1) {
k_msleep(1000);
}
return 0;
}
Running the timer example code above and observing the expiry function run every second.

You can read the official Zephyr documentation for Timers here.

Threads

A Zephyr thread is a kernel object which can be used to execute code asynchronously to other threads. Threads can operate both cooperatively (a thread continues running until it gives up control) and pre-emptively (the thread is interrupted by the kernel when the kernel decides to run something else).

A basic thread can be created and started with the following code:

#define PRIORITY (5)
#define STACK_SIZE (500)
// Statically define the thread stack. Would be handy if we could do this dynamically.
K_THREAD_STACK_DEFINE(myThreadStack, STACK_SIZE);
void MyThreadFn(void *, void *, void *)
{
LOG_INF("Thread started!");
while(1) {
k_msleep(1000);
}
}
int main() {
// Dynamically create thread
struct k_thread myThread;
k_tid_t myTid = k_thread_create(
&myThread, myThreadStack,
K_THREAD_STACK_SIZEOF(myThreadStack),
MyThreadFn,
NULL, NULL, NULL, // User data you can pass to your thread function if desired
PRIORITY, 0, K_NO_WAIT);
// Wait for thread to finish (which won't happen because we never return from the
// thread function)
k_thread_join(myTid, K_FOREVER);
}

Make sure you call K_THREAD_STACK_SIZEOF() in the same file as the K_THREAD_STACK_DEFINE() macro. If you pass the stack into a different file, and call K_THREAD_STACK_SIZEOF() on it, you will get back the wrong size (I got -60 when doing this). Thus you have to pass both the stack and the size into functions in other files (just like you would for a basic C array).

Dynamic Thread Stack Allocation

You’ll notice that in the above example, although the thread is created at runtime, the stack is statically defined at compile time with K_THREAD_STACK_DEFINE(). Zephyr is meant to support dynamic thread stack allocation. However I could not get it working.

Workqueues

A Zephyr workqueue is like a thread but a few extra features included, the main one being a “queue” in which you can add work to for the thread to complete.

What you submit to a workqueue is a function pointer. This function will be run when the thread processes the item from the queue. This is very similar to the way you would typically create a thread, except that usually thread functions in embedded systems are designed to never return (i.e. they are designed to be created when the firmware starts-up and run continuously).

The following C code shows a basic work object being statically defined using the K_WORK_DEFINE() macro, and then work submitted in main() using k_work_submit(). The program will log the “Hello” message when the workqueue processes the work in the workqueue thread. Note that k_work_submit() submits work to the special system workqueue (explained below).

static void myWorkQueueHandler(struct k_work * work)
{
LOG_INF("Hello from the work thread!");
}
K_WORK_DEFINE(my_work_queue, &myWorkQueueHandler);
int main()
{
int rc = k_work_submit(&my_work_queue); // Submits work to the system workqueue, myWorkQueueHandler() will get called soon from a different thread...
// Allow any of the positive return codes but don't allow errors
__ASSERT_NO_MSG(rc >= 0);
return 0;
}

System Workqueue

The Kernel defines a standardized “system workqueue” that you can use. It is recommended that you use this workqueue by default, and only create additional ones only if you need multiple work queue jobs to run in parallel (e.g. in one job may block or otherwise take a long time to complete). The reason for this is that every new workqueue requires a stack, and a typical stack size could be 2kB or more. Having many workqueues will quickly eat into your remaining available RAM2.

Work can be submitted to the system workqueue by using the function k_work_submit(). Use the more generic k_work_submit_to_queue() if you want to submit work to a queue that you created (in this case, you also have to pass in a pointer to the queue).

Creating Your Own Workqueue

If you can’t just use the system workqueue and want to create your own workqueues, you can use the functions k_work_queue_init() and k_work_queue_start() to do so. You first have to create a stack object before creating the workqueue, passing the stack object into it.

The following code example shows how to do this:

#define STACK_SIZE 512 // Stack size of work queue
#define PRIORITY 5 // Priority of work queue
K_THREAD_STACK_DEFINE(myStackArea, STACK_SIZE);
struct k_work_q myWorkQueue;
k_work_queue_init(&myWorkQueue);
k_work_queue_start(&myWorkQueue,
myStackArea,
K_THREAD_STACK_SIZEOF(myStackArea),
PRIORITY,
NULL);

Mutexes

A Zephyr mutex is a kernel primitive that allows multiple threads to safely access a shared resource by ensuring mutually exclusive access. It is provides the same functionality as mutexes in most other operating systems.

First you need to define and initialize the mutex:

struct k_mutex myMutex;
int rc = k_mutex_init(&myMutex);
__ASSERT_NO_MSG(rc == 0);

If you want to declare a mutex statically with file-level scope, rather than the above you can just use K_MUTEX_DEFINE(myMutex);. Presumably it declares the struct and sets up the init function to be run at startup.

You can then lock the mutex with:

int rc = k_mutex_lock(&myMutex, K_FOREVER);
__ASSERT_NO_MSG(rc == 0);

This is a blocking call which will sleep the current thread until the mutex is unlocked and available to be locked by this thread. K_FOREVER states to wait indefinitely for the mutex to be unlocked. Generally, I would not recommend using K_FOREVER, but specifying a timeout with a sensible time limit such that if it expires, something has gone really wrong. Then log an error! This is useful for debugging purposes, as forgetting to unlock mutexes is a common mistake (especially in functions which have many exit points). Without a timeout, your application will hang if you forget to unlock the mutex, and you’ll get no helpful debug info. With a timeout, you can get a helpful log message stating which mutex failed to lock and where.

int rc = k_mutex_lock(&myMutex, K_MSEC(1000));
if (rc != 0)
{
LOG_ERR("Failed to lock myMutex.");
// ... do appropriate action here
// If not being able to lock should be considered a fatal error in your firmware, you
// could replace this if() with an assert: __ASSERT_NO_MSG(rc == 0)
}

Sometimes you will want to use the timeout to do something useful after a period of time, or give up and try something else. That is entirely application specific!

You then unlock a mutex with:

k_mutex_unlock(&myMutex);

Zephyr mutexes support reentrant locking3. This means that a thread is allowed to lock a mutex more than once. This is a useful feature that allows a thread to lock the mutex more than once. The same thread must unlock the mutex just as many times before it can be used by another thread. A common use of this pattern is if you have a shared resource that can be accessed via an API. You can lock the mutex inside the API functions themselves so that they are individually guaranteed to be exclusive, but also allow the caller access to the mutex so they can lock it if they want to chain together multiple API calls in one single “atomic” operation.

Zephyr mutexes also support priority inheritance. The Zephyr kernel will elevate the priority of a thread that has currently locked the mutex if a thread of a higher priority begins waiting on the mutex. This works well if there is only ever one mutex locked at once by a thread. If multiple mutexes are locked, then less-than-ideal behaviour occurs if the mutex is not unlocked in the reverse order to which the owning thread’s priority was originally raised. It is recommended that only one mutex is locked at a time when multiple mutexes are used between multiple threads of different priorities.

Asserts

Zephyr provides support for standard C library assert() function as well as providing more powerful assert macros if you wish you use them.

ASSERT_NO_MSG() can be used if you don’t want to have to provide a message. I end up using this a lot as adding a message to every assert becomes tiresome (a typical project ends up with hundreds of assert calls, checking things like passed-in pointers are non-null, integers are within range, e.t.c).

ASSERT_NO_MSG();

Watchdog

Zephyr provides a software/hardware based watchdog API you can use to monitor your threads and perform actions (usually a system reset) in the case that your threads become unresponsive and do not feed the watchdog in time. The API provides the ability to monitor multiple application threads at once via it’s software watchdog. This software watchdog can in turn be monitored by a hardware watchdog. A hardware watchdog (i.e. a physical peripheral provided by the MCU) can be trusted to reliably reset the device, even if the software watchdog locks up (which can be the case with certain errors).

To use the watchdog, first add the following to your prf.conf:

CONFIG_TASK_WDT=y

You’ll then need to include the header file that provides the API:

#include <zephyr/task_wdt/task_wdt.h>

You then need to enable the watchdog task with int task_wdt_init (const struct device * hw_wdt):

int wdRc = task_wdt_init(NULL);
__ASSERT_NO_MSG(wdRc == 0);

Passing in NULL for hw_wdt says you don’t want to connect the software watchdog task up with a hardware watchdog. In most real life applications you do want to provide a hardware watchdog, as the probability of the software watchdog failing is too high to rely solely on it (whereas a hardware watchdog is very reliable).

You can “install” a new channel with the software watchdog by using int task_wdt_add(uint32_t reload_period, task_wdt_callback_t callback, void *user_data). You then need to regularly feed the channel with int task_wdt_feed (int channel_id), as shown in the below code snippet:

void my_thread_fn() {
// When your thread starts, install a new watchdog timeout for this thread
// Passing NULL as second param means system reset handler will be called
// if watchdog timer expires
int wdtChannelId = task_wdt_add(5000, NULL, NULL);
__ASSERT_NO_MSG(wdtChannelId == 0);
while(1) {
int rc = task_wdt_feed(wdtChannelId); // Regularly feed the watchdog to prevent system reset
__ASSERT_NO_MSG(rc == 0);
// Sleep for a second before feeding again
k_sleep(K_MSEC(1000));
}
}

Make sure you have enough available channels to be able to install timeouts. You can change this in prf.conf with CONFIG_TASK_WDT_CHANNELS. By default is set to 5, but can be changed to anything in the range [2, 100]4.

The following code is a complete example showing watchdog task functionality that can be built for the native_sim board (Linux). It sets the watchdog up with a 3s timeout. It feeds the watchdog once a second for 5 seconds, and then pretends there is a bug which locks up the code. The software watchdog successfully resets the device 3 seconds later.

It does not use multiple threads (as a real world application typically would), but just shows watchdog working in the main thread.

#include <stdio.h>
#include <zephyr/kernel.h>
#include <zephyr/task_wdt/task_wdt.h>
int main(void) {
// Initialize, passing NULL so we are not using a hardware watchdog
// (for real applications you normally want a hardware watchdog backing the software one!)
int wdRc = task_wdt_init(NULL);
__ASSERT_NO_MSG(wdRc == 0);
// Install a new WDT channel
int wdtChannelId = task_wdt_add(3000, NULL, NULL);
__ASSERT_NO_MSG(wdtChannelId == 0);
uint32_t cycleCount = 0;
while(1) {
printf("Feeding watchdog.\n");
int rc = task_wdt_feed(wdtChannelId); // Regularly feed the watchdog to prevent system reset
__ASSERT_NO_MSG(rc == 0);
if (cycleCount == 5) {
printf("Oh oh, bug has got this thread stuck!\n");
while(1) {
// Do nothing, just hang here
k_msleep(1000);
}
}
cycleCount += 1;
// Sleep for a second before cycling around again
k_msleep(1000);
}
return 0;
}
Running the watchdog example code and seeing it timeout.

Semaphores

Zephyr provides traditional counting semaphores.

A semaphore can be created with:

struct k_sem mySem;
k_sem_init(&mySem, 0, 1);
// OR
K_SEM_DEFINE(mySem, 0, 1);

Semaphores can be used with the Polling API to wait to multiple conditions simultaneously.

Polling API

Zephyr’s Polling API lets you wait (block) on multiple events at the same time, e.g. block waiting one of two or more semaphore to become available, or until either a semaphore is available or FIFO has data in it.

You first have to enable the polling API by adding the following into your prf.conf:

CONFIG_POLL=y

then the API becomes available through the standard #include <zephyr/kernel.h>. Before you can call k_poll() to wait for events, you have to declare an array of k_poll_event and initialize them.

struct k_poll_event myEvents[2];
k_poll_event_init(&myEvents[0],
K_POLL_TYPE_SEM_AVAILABLE,
K_POLL_MODE_NOTIFY_ONLY,
&mySem);
k_poll_event_init(&myEvents[1],
K_POLL_TYPE_FIFO_DATA_AVAILABLE,
K_POLL_MODE_NOTIFY_ONLY,
&myFifo);

You can then wait until one or more of these events occurs by calling k_poll():

k_poll(myEvents, 2, K_FOREVER); // This will block until 1 or more events occur

Full Working Example

Here is a full working example:

#include <stdio.h>
#include <zephyr/kernel.h>
struct k_sem mySem1;
struct k_sem mySem2;
void MyThreadFn(void * v1, void * v2, void * v3) {
printf("THREAD: Thread started.\n");
struct k_poll_event events[2];
k_poll_event_init(&events[0],
K_POLL_TYPE_SEM_AVAILABLE,
K_POLL_MODE_NOTIFY_ONLY,
&mySem1);
k_poll_event_init(&events[1],
K_POLL_TYPE_SEM_AVAILABLE,
K_POLL_MODE_NOTIFY_ONLY,
&mySem2);
while(1) {
printk("THREAD: Waiting on k_poll()...\n");
int rc = k_poll(events, 2, K_FOREVER);
if (rc != 0)
{
// Handle error. If a finite time was provided to k_poll()
// we would also need to check for -EAGAIN returned, which indicates
// a timeout
}
if (events[0].state == K_POLL_STATE_SEM_AVAILABLE) {
printk("THREAD: Semaphore 1 available.\n");
k_sem_take(events[0].sem, K_NO_WAIT); // Careful! If this thread was preempted, we might not actually be able to take the semaphore
} else if (events[1].state == K_POLL_STATE_SEM_AVAILABLE) {
printk("THREAD: Semaphore 2 available.\n");
k_sem_take(events[1].sem, K_NO_WAIT);
}
// Because we are going to check again, we need to clear the state!
events[0].state = K_POLL_STATE_NOT_READY;
events[1].state = K_POLL_STATE_NOT_READY;
}
}
K_THREAD_STACK_DEFINE(myStack, 500);
struct k_thread my_thread_data;
int main(void) {
// printf("Creating timer to expire every 1s...\n");
// Initialize semaphores
k_sem_init(&mySem1, 0, 1);
k_sem_init(&mySem2, 0, 1);
// Create thread
k_thread_create(&my_thread_data, myStack,
K_THREAD_STACK_SIZEOF(myStack),
MyThreadFn,
NULL, NULL, NULL,
5, 0, K_NO_WAIT);
k_msleep(1000);
// Give semaphore, this should wake up the thread
printf("MAIN: Giving semaphore 1...\n");
k_sem_give(&mySem1);
k_msleep(1000);
// Give semaphore, this should wake up the thread
printf("MAIN: Giving semaphore 2...\n");
k_sem_give(&mySem2);
return 0;
}

and the output is:

The output from the full working Polling API example above, showing how a thread blocks on two separate semaphores.

Memory Allocation

Zephyr provides k_malloc() and k_free() for dynamic memory allocation of a shared block of heap memory. To use these (and have them defined) you need to add the following to your prf.conf:

prf.conf
# Required for k_malloc()
CONFIG_HEAP_MEM_POOL_SIZE=1024

Logging

Zephyr has very powerful logging features (compared to what you typically expect for embedded devices) provided via it’s logging API.

First you have to enable logging in your prj.conf with:

CONFIG_LOG=y

You can also set a default compiled log level with:

# The default compile time log level. Recommended to leave this as verbose as
# possible given memory constraints and then set the runtime log level as needed.
# 0 = LOG_LEVEL_OFF
# 1 = LOG_LEVEL_ERR
# 2 = LOG_LEVEL_WRN
# 3 = LOG_LEVEL_INFO
# 4 = LOG_LEVEL_DBG
CONFIG_LOG_DEFAULT_LEVEL=4

If you want to add logs to a .c file, first include #include <zephyr/logging/log.h>. Then you have to register the source file as a “module”:

// Uses the compiled log level set in prj.conf with CONFIG_LOG_DEFAULT_LEVEL
LOG_MODULE_REGISTER(MyModule);
// OR:
// Optionally override the compiled log level with a custom level as a second
// parameter to the macro.
LOG_MODULE_REGISTER(MyModule, LOG_LEVEL_INF);

Now you can use log statements in your code:

void MyFunction() {
LOG_DBG("Here is a debug level log!"); // Prints: [00:10:04.267,242] <dbg> MyModule: MyFunction: Here is a debug level log!
LOG_INF("Here is a info level log!"); // Prints: [00:00:01.024,291] <inf> MyModule: Here is a info level log!
LOG_WRN("Here is a warning level log!"); // Prints: [00:00:01.024,291] <wrn> MyModule: Here is a warning level log!
LOG_ERR("Here is a error level log!"); // Prints: [00:00:01.024,291] <err> MyModule: Here is a error level log!
}

Note that the debug level log prints additional information — it also prints the function name (in the above example this is MyFunction) that the log message was printed from. For outputs that support ANSI escape codes, the warning log is printed in yellow (except for the timestamp), and similarly the error log is printed in red.

Compile Time vs. Runtime Log Levels

It’s important to make the distinction between compile-time log levels and runtime levels. Providing CONFIG_LOG_DEFAULT_LEVEL or a second parameter to LOG_MODULE_REGISTER sets a compile-time log level. All log levels higher than this (both in number and verbosity) are not included in the compiled firmware binary, meaning you cannot change the level to high levels at runtime. Runtime log levels are set via log_filter_set() or with the shell command log enable <log_level> (e.g. log enable dbg). Runtime adjustable log levels also depend on CONFIG_LOG_RUNTIME_FILTERING=y, which is set automatically if the shell is enabled. My recommendation is to leave the compile time log level to LOG_LEVEL_DBG if you have enough flash to allow that, and then set the log level at runtime. This will give you the ability to dynamically change the levels as needed without having to re-compile firmware. It would be a pain to have to recompile and re-flash firmware on a buggy device just to get the “debug” logs you need to diagnose the problem. And you may not want to re-flash as you have just caught an intermittent bug that is hard to reproduce!

The code below shows how you can change the logging levels at runtime:

#include <zephyr/logging/log.h>
#include <zephyr/logging/log_ctrl.h>
int main() {
// ...
// Change all log levels at runtime
// NOTE: If you do this for a particular event, you may want to save all
// the previous levels and restore them after the event is finished
uint32_t logLevel = LOG_LEVEL_INF;
uint32_t numLogSources = log_src_cnt_get(0);
for (uint32_t sourceId = 0; sourceId < numLogSources; sourceId++)
{
char * sourceName = (char *)log_source_name_get(0, sourceId);
__ASSERT_NO_MSG(sourceName); // Should not be null
log_filter_set(NULL, 0, sourceId, level);
}
// ...
}

X Messages Dropped Errors

Zephyr implements a character based circular buffer for storing messages to be processed (remember — Zephyr logging is typically done asynchronously). If other threads create logs too quickly for the log thread to process, at some point Zephyr will drop logs. You will typically see a log error generated when this happens stating --- x messages dropped --- (as shown below).

A screenshot of the “x messages dropped” error that can occur with Zephyr logging.

If you have extra RAM space, one way to reduce the probability of this error is to bump up the circular buffer Zephyr uses to write messages into in your prj.conf:

# Increased from 1024 to reduce probability of --- x messages dropped --- errors
CONFIG_LOG_BUFFER_SIZE=2048

CONFIG_LOG_BUFFER_SIZE sets the number of bytes assigned to the circular packet buffer5.

Deferred vs. Immediate Logging

Zephyr supports two different modes for logging, deferred and immediate. In deferred mode, whenever your code calls LOG_DBG(...) or similar, the passed in format string and variables get saved for for further processing in a separate logging thread (further processing entails inserting the arguments into the format string, and sending it to backends such as UART). In immediate mode, when your code calls LOG_DBG(...), the log is processed and emitted to backends in the calling thread.

Deferred logging is a great choice when you don’t want to slow down your threads emitting log messages (it even allows you to log from an interrupt context!). The biggest downside is that log messages are not synchronous with what your microcontroller is actually doing in the real world at any point of time. Deferred logging can mask errors such as hard faults and segmentation faults — when a serious fault occurs the processor will crash/hang (or a fault handler will be called) but you won’t see the last 1 second or so of log messages, making it hard to track down the problem.

This is what immediate mode is great for — debugging crashes and other time-sensitive issues when you need the logs to print at the same time the LOG_DBG() calls are being made. If a crash occurs, you can look at the last few log messages and get a good idea were the problem might be in your source code. Immediate mode is not suitable when you are logging from an interrupt context or from a time-sensitive thread.

Speeding Up Log Output

By default, Zephyr logs can be seem to be quite laggy and “slow” when being used to emit logs across a serial port for debugging during development. One of the ways to speed things up is to add the following to your prj.conf:

CONFIG_LOG_PROCESS_TRIGGER_THRESHOLD=1

The setting tells Zephyr to only wait for 1 log message in the queue before waking up the logging thread to process it. By default this value is set to 10, which can make logs seem slow!6 CONFIG_LOG_PROCESS_TRIGGER_THRESHOLD is only applicable when CONFIG_LOG_MODE_IMMEDIATE=n.

C++ Compatibility

In general logging will work just fine in C++. However, there is an issue if trying to log messages from a class which uses templates. The function bodies for templated functions are usually contained with the header file, because the compiler needs to know how to create these. This is a problem for LOG_MODULE_REGISTER().

Peripheral APIs

Not only does Zephyr provide you with an RTOS, but it also gives you a collection of well defined APIs for interacting with microcontroller peripherals.

The latest documentation for peripheral (UART, SPI, PWM, PIN, e.t.c.) APIs can be found at https://docs.zephyrproject.org/latest/reference/peripherals/index.html.

Hardware Info

The Zephyr Hardware Info API can be used to access the device ID and reset cause of a microcontroller. The reset cause comes from a list of standardised reasons that apply to most microcontrollers such as reset pin, a software reset, a watchdog timer reset, brownout reset, debug event, e.t.c (not all reset causes may be applicable to a particular microcontroller).

hwinfo_get_reset_cause() sets a uint32_t with each bit representing a different reason for reset (a bit field). More than 1 bit can be set. Below is a C function which decodes the reset cause bit field returned from hwinfo_get_reset_cause() and creates a human-readable string of the reset reason(s) (great for logging).

#include <stdint.h>
#include <zephyr/drivers/hwinfo.h>
#include <zephyr/logging/log.h>
typedef struct {
uint32_t flag;
char name[20]; // Make sure this is as large as the largest name
} resetCauseFlag_t;
/**
* @brief This function logs the reset cause in a human-readable manner, obtained via hwinfo_get_reset_cause().
*/
void LogResetCause()
{
uint32_t resetCause;
int resetCauseRc = hwinfo_get_reset_cause(&resetCause);
__ASSERT_NO_MSG(resetCauseRc == 0);
resetCauseFlag_t resetCauseFlags[] = {
{ RESET_PIN, "RESET_PIN" },
{ RESET_SOFTWARE, "RESET_SOFTWARE" },
{ RESET_BROWNOUT, "RESET_BROWNOUT" },
{ RESET_POR, "RESET_POR" },
{ RESET_WATCHDOG, "RESET_WATCHDOG" },
{ RESET_DEBUG, "RESET_DEBUG" },
{ RESET_SECURITY, "RESET_SECURITY" },
{ RESET_LOW_POWER_WAKE, "RESET_LOW_POWER_WAKE" },
{ RESET_CPU_LOCKUP, "RESET_CPU_LOCKUP" },
{ RESET_PARITY, "RESET_PARITY" },
{ RESET_PLL, "RESET_PLL" },
{ RESET_CLOCK, "RESET_CLOCK" },
{ RESET_HARDWARE, "RESET_HARDWARE" },
{ RESET_USER, "RESET_USER" },
{ RESET_TEMPERATURE, "RESET_TEMPERATURE" },
};
const uint32_t buffSize = 100;
char resetReasonBuffer[buffSize];
resetReasonBuffer[0] = '\0'; // Set the initial byte to 0 so that strncat will work correctly
uint32_t currLength = 0;
for(int i = 0; i < sizeof(resetCauseFlags) / sizeof(resetCauseFlag_t); i++)
{
if(resetCause & resetCauseFlags[i].flag)
{
// Append reset cause string to message
strncat(resetReasonBuffer, resetCauseFlags[i].name, buffSize - currLength);
currLength = strlen(resetReasonBuffer);
// Add ", " between reasons
strncat(resetReasonBuffer, ", ", buffSize - currLength);
currLength = strlen(resetReasonBuffer);
}
}
LOG_INF("Reset reason as a bit field: 0x%04X. This means the following flags were set: %s", resetCause, resetReasonBuffer);
// Clear the reset cause
resetCauseRc = hwinfo_clear_reset_cause();
__ASSERT_NO_MSG(resetCauseRc == 0);
}

GPIO

To setup a single GPIO pin your device tree, you can add the following to your .dts file:

my_gpio {
# Pin 7 in GPIO controller bank 0
gpios = <&gpio0 7 GPIO_ACTIVE_HIGH>;
};

You can then get access to this GPIO from your C code with:

const struct gpio_dt_spec myGpio = GPIO_DT_SPEC_GET(DT_PATH(my_gpio), gpios);
bool boolRc = gpio_is_ready_dt(&myGpio);
__ASSERT_NO_MSG(boolRc);

GPIO Outputs

You can statically configure your GPIO as an output in your .dts file by using compatible = "gpio-leds";:

/ {
gpio_outputs {
// It doesn't sound right but we use gpio-leds for general I/O outputs
compatible = "gpio-leds";
my_gpio {
gpios = <&gpio0 7 GPIO_ACTIVE_HIGH>;
label = "My GPIO";
};
};
};

You can configure a GPIO as an output at runtime in C with:

int intRc = gpio_pin_configure_dt(&myGpio, GPIO_OUTPUT_INACTIVE);
__ASSERT_NO_MSG(intRc == 0);

The above code gets the device tree spec based of it’s path, checks that the GPIO is ready for use (I’m not sure why it wouldn’t me, but good practice!) and then configures it as an output. To then set the pin high or low, you use gpio_pin_set_dt():

int rc = gpio_pin_set_dt(&myGpio, 0); // Set it low
__ASSERT_NO_MSG(rc == 0);
rc = gpio_pin_set_dt(&myGpio, 1); // Set it high
__ASSERT_NO_MSG(rc == 0);

GPIO_OUTPUT_LOW and GPIO_OUTPUT_HIGH both set the physical state of the pin, and do not care if the pin is active high or active low. GPIO_OUTPUT_INACTIVE and GPIO_OUTPUT_ACTIVE take into account whether the pin is active high or active low. In the case the pin is active high, setting the pin to a logical level of INACTIVE results in a physical LOW, and ACTIVE is HIGH. In the case it is active low, INACTIVE

If you are using a Nordic MCU with NFC pins (e.g. NFC1, NFC2) make sure to disable NFC with CONFIG_NFCT_PINS_AS_GPIOS=y in your prj.conf if you want to use these pins for GPIO (or anything else for that matter, including PWM).

To read the value of a GPIO input, use gpio_pin_get_dt():

int rc = gpio_pin_get_dt(&myGpio);
__ASSERT_NO_MSG(rc >= 0); // 0 or 1 for low/high, negative number for error
if (rc == 0) {
LOG_INF("GPIO was low.");
} else if (rc == 1) {
LOG_INF("GPIO was high.");
}

You can also read the current value of a GPIO output using gpio_pin_get_dt(). HOWEVER — you must make sure to provide the GPIO_INPUT flag along with the appropriate output flag when configuring the GPIO pin. For example:

int rc = gpio_pin_configure_dt(&myGpio, GPIO_OUTPUT_LOW | GPIO_INPUT);
__ASSERT_NO_MSG(rc == 0);
// Now you can read the value of the GPIO output!
int value = gpio_pin_get_dt(&myGpio);

GPIO Interrupts

If you want to configure an interrupt for a GPIO pin, you can use gpio_pin_interrupt_configure_dt():

intRc = gpio_pin_interrupt_configure_dt(&myGpio, GPIO_INT_EDGE_TO_ACTIVE);
__ASSERT_NO_MSG(intRc == 0);

You then need to setup a callback:

static struct gpio_callback gpioCallbackData;
void GpioCallback(const struct device *dev, struct gpio_callback *cb, uint32_t pins)
{
// GPIO interrupt callback
}
int main() {
gpio_init_callback(&gpioCallbackData, &GpioCallback, BIT(myGpio.pin));
gpio_add_callback(myGpio.port, &gpioCallbackData);
}

There are many interrupt configuration flags to choose from, including both physical (actual voltage at the pin) and logical (depends on the pin’s active high or active low configuration) triggers. For both physical and logical, there are both edge (transition) and level triggers. This is a placeholder for the reference: tbl-gpio-interrupt-config-flags lists the different flags7.

FlagDescriptionPhysical or Logical
GPIO_INT_DISABLEDisable interrupts for the GPIO pin.n/a
GPIO_INT_EDGE_RISINGTrigger interrupt on rising edge.Physical
GPIO_INT_EDGE_FALLINGTrigger interrupt on falling edge.Physical
GPIO_INT_EDGE_BOTHTrigger interrupt on both rising and falling edges.Physical
GPIO_INT_LEVEL_LOWTrigger interrupt when the GPIO pin is low.Physical
GPIO_INT_LEVEL_HIGHTrigger interrupt when the GPIO pin is high.Physical
GPIO_INT_LEVEL_TO_INACTIVETrigger interrupt when pin changes to inactive (logical level 0).Logical
GPIO_INT_LEVEL_TO_ACTIVETrigger interrupt when pin changes to active (logical level 1).Logical
GPIO_INT_LEVEL_INACTIVETrigger interrupt when pin is inactive (logical level 0).Logical
GPIO_INT_LEVEL_ACTIVETrigger interrupt when pin is active (logical level 1).Logical
GPIO interrupt configuration flags.

See https://github.com/ubieda/zephyr_button_debouncing/tree/master for an example of using a Zephyr GPIO to handle button presses along with debouncing using a delayable work queue.

PWM

For example, looking at zephyr/dts/arm/nordic/nrf52832.dtsi and searching for “pwm” we find:

/ {
soc {
pwm0: pwm@4001c000 {
compatible = "nordic,nrf-pwm";
reg = <0x4001c000 0x1000>;
interrupts = <28 NRF_DEFAULT_IRQ_PRIORITY>;
status = "disabled";
#pwm-cells = <3>;
};
pwm1: pwm@40021000 {
compatible = "nordic,nrf-pwm";
reg = <0x40021000 0x1000>;
interrupts = <33 NRF_DEFAULT_IRQ_PRIORITY>;
status = "disabled";
#pwm-cells = <3>;
};
pwm2: pwm@40022000 {
compatible = "nordic,nrf-pwm";
reg = <0x40022000 0x1000>;
interrupts = <34 NRF_DEFAULT_IRQ_PRIORITY>;
status = "disabled";
#pwm-cells = <3>;
};
}
}

In your .dts file:

my-pwm-group {
compatible = "pwm-leds";
status = "okay";
my-pwm-pin-1 {
// Use pwm peripheral 0 and channel 0
pwms = <&pwm0 0 PWM_MSEC(5) PWM_POLARITY_NORMAL>;
};
my-pwm-pin-2 {
// Use pwm peripheral 0 and channel 1
pwms = <&pwm0 1 PWM_MSEC(5) PWM_POLARITY_NORMAL>;
};
};

This defines 3 PWM peripherals.

PWM can be set up with:

const struct pwm_dt_spec myPwmSpec = PWM_DT_SPEC_GET(DT_PATH(my_pwm_group, my_pwm_pin_1));
boolRc = pwm_is_ready_dt(&myPwmSpec);
__ASSERT_NO_MSG(boolRc);

And then enabled with:

// This will enable the PWM with a period of 1000us and a pulse width of 500us (i.e. 50% duty cycle)
int intRc = pwm_set_dt(&myPwmSpec, PWM_USEC(1000), PWM_USEC(500));
__ASSERT_NO_MSG(intRc == 0);

ADCs

The Zephyr ADC API is documented here.

There are working ADC examples in the Zephyr repo at samples/drivers/adc/. As of September 2024, there are two examples:

  • adc_dt: Basic ADC example.
  • adc_sequence: An ADC example showing how to take readings using sequences.

A full example showing how to setup and use the ADC is shown in the below code block.

#include <zephyr/drivers/adc.h>
#define DT_SPEC_AND_COMMA(node_id, prop, idx) \
ADC_DT_SPEC_GET_BY_IDX(node_id, idx),
/* Data of ADC io-channels specified in devicetree. */
static const struct adc_dt_spec adc_channels[] = {
DT_FOREACH_PROP_ELEM(DT_PATH(zephyr_user), io_channels,
DT_SPEC_AND_COMMA)
};
int main() {
int rc = 0;
// Configure channels individually prior to sampling.
for (size_t i = 0; i < ARRAY_SIZE(adc_channels); i++) {
if (!device_is_ready(adc_channels[i].dev)) {
LOG_INF("ADC controller device %s not ready\n", adc_channels[i].dev->name);
return -1;
}
rc = adc_channel_setup_dt(&adc_channels[i]);
if (rc < 0) {
LOG_INF("Could not setup channel #%d (%d)\n", i, rc);
return -1;
}
}
// Call our function to perform a measurement
int32_t sample_mV = 0;
rc = SampleChannel(0, NULL, &sample_mV);
__ASSERT_NO_MSG(rc == 0);
}
int SampleChannel(uint8_t channelId, int32_t * sample_counts, int32_t * sample_mV)
{
// We are doing just one measurement, so buffer does not need to be an array
uint16_t buf; // NOTE: Nordic's driver uses int16_t, see below
struct adc_sequence sequence = {
.buffer = &buf,
// buffer size in bytes, not number of samples
.buffer_size = sizeof(buf),
};
int rc;
rc = adc_sequence_init_dt(&adc_channels[channelId], &sequence);
if (rc)
{
LOG_ERR("Couldn't configure the ADC sequence. adc_sequence_init_dt() rc: %d.", rc);
return -1;
}
// Perform the actual measurements
rc = adc_read(adc_channels[channelId].dev, &sequence);
if (rc < 0) {
LOG_ERR("Could not read value from ADC. adc_read() rc: %d.", rc);
return -1;
}
int32_t tempSample_counts = 0;
if (adc_channels[channelId].channel_cfg.differential) {
tempSample_counts = (int32_t)((int16_t)buf);
} else {
tempSample_counts = (int32_t)buf;
}
// Save raw value if provided pointer
if (sample_counts) {
*sample_counts = tempSample_counts;
}
// Convert raw sample_mV to mV, more useful to caller
int32_t convertedVoltage_mV = tempSample_counts; // Needs to start of with raw value, function reads then modified it!
rc = adc_raw_to_millivolts_dt(&adc_channels[channelId],
&convertedVoltage_mV);
if (rc < 0) {
LOG_ERR("Could not convert ADC reading to mV. adc_raw_to_millivolts_dt() rc: %d.", rc);
return -1;
}
if (sample_mV) {
*sample_mV = convertedVoltage_mV;
}
return 0;
}

Nordic Semiconductor has a guide on setting the ADC for their nRF MCUs using Zephyr here.

I2C

Your microcontroller file will already define the I2C peripherals available (e.g. i2c0, i2c1, …). All you have to do is enable them in your .dts file with:

&i2c0 {
status = "okay";
pinctrl-0 = <&i2c0_default_alt>;
pinctrl-1 = <&i2c0_sleep_alt>;
pinctrl-names = "default", "sleep";
};

And then in the .dts file setup pinctrl to define i2c0_default_alt and i2c0_sleep_alt (NOTE: this code includes nRF specific macros, this will be different on other vendor’s MCUs). This defines SDA to use pin 0.0 and SCL to use pin 0.1:

&pinctrl {
i2c0_default_alt: i2c0_default_alt {
group1 {
psels = <NRF_PSEL(TWIM_SDA, 0, 0)>,
<NRF_PSEL(TWIM_SCL, 0, 1)>;
};
};
i2c0_sleep_alt: i2c0_sleep_alt {
group1 {
psels = <NRF_PSEL(TWIM_SDA, 0, 0)>,
<NRF_PSEL(TWIM_SCL, 0, 1)>;
low-power-enable;
};
};
};

UART

Your microcontroller file will already define the UART peripherals available (e.g. uart0, uart1, …). You have to do is enable them in your .dts file, and provide things like the baud rate:

&uart0 {
status = "okay";
current-speed = <115200>;
pinctrl-0 = <&uart0_default>;
pinctrl-1 = <&uart0_sleep>;
pinctrl-names = "default", "sleep";
};

And then configure pinctrl to define the pins that the UART peripheral uses. The following example configures uart0 to use pin 1.3 for TX and pin 1.4 for RX (NOTE: this code includes nRF specific macros, this will be different on other vendor’s MCUs):

&pinctrl {
uart0_default: uart0_default {
group1 {
psels = <NRF_PSEL(UART_TX, 1, 3)>;
};
group2 {
psels = <NRF_PSEL(UART_RX, 1, 4)>;
bias-pull-up;
};
};
uart0_sleep: uart0_sleep {
group1 {
psels =
<NRF_PSEL(UART_TX, 0, 24)>,
<NRF_PSEL(UART_RX, 1, 0)>;
low-power-enable;
};
};
};

I2S

Your microcontroller file will already define the I2S peripherals available (e.g. i2s0, i2s1, …). You have to enable them in your .dts file with:

&i2s0 {
status = "okay";
pinctrl-0 = <&i2s0_default_alt>;
pinctrl-names = "default";
};

And then in the pinctrl peripheral you can configure the pins that is20 uses. The following example configures i2s0 to use pin 0.5 for SCK, pin 0.7 for LRCK, pin 0.8 for SDOUT and pin 0.4 for SDIN (NOTE: this code includes nRF specific macros, this will be different on other vendor’s MCUs):

i2s0_default_alt: i2s0_default_alt {
group1 {
psels =
<NRF_PSEL(I2S_SCK_M, 0, 5)>,
<NRF_PSEL(I2S_LRCK_M, 0, 7)>,
<NRF_PSEL(I2S_SDOUT, 0, 8)>,
<NRF_PSEL(I2S_SDIN, 0, 4)>;
};
};

Non-Volatile Storage

Zephyr provides an API for storing things in non-volatile storage (flash memory). The NVS API is provided by #include <zephyr/fs/nvs.h>.

Typically a partition called storage_partition is setup in the main flash for the NVS system to use. This can be defined in the board files.

An official code example of the NVS can be found at https://github.com/zephyrproject-rtos/zephyr/blob/main/samples/subsys/nvs/src/main.c[^github-zephyr-nvs-code-example].

Bluetooth

Nordic has contributed significantly to the Zephyr Bluetooth API ever since they adopted Zephyr as their official platform for the nRF52, nRF53 and nRF91 MCU families.

Update the LE Connection Interval

After you are connected, you can call bt_conn_le_param_update() to update the Bluetooth connection interval. This is useful if you want to save power by increasing the connection interval when you don’t need to send/receive data as often.

struct bt_le_conn_param conn_param = { .interval_min = (708), .interval_max = (800), .latency = (0), .timeout = (400), };
int rc = bt_conn_le_param_update(conn, &conn_param);
__ASSERT_NO_MSG(rc == 0);

conn is a pointer to a struct bt_conn which is the connection object. It’s assumed you have that handy to pass in! The connection interval is in units of 1.25ms, so the above code sets the connection interval min. to 885ms and the max. to 1000ms. By default the min. and max were set to 15ms and 30ms respectively, so this is a significant slow down and results in good power savings for small battery powered devices. The timeout is in units of 10ms, so the above code sets the timeout to 4s. You can use the helper macro BT_LE_CONN_PARAM() to create the struct bt_le_conn_param object if you want.

If you are a Bluetooth central device, these settings will take effect. If you a peripheral device, these settings are “suggestions”. They are sent to the central device and it is up to the central device to accept them. The central device may reject them or choose other values.

What Does A Basic Zephyr Firmware Application Look Like?

The following example shows main.c (the only .c file) for the Blinky sample project:

#include <zephyr.h>
#include <device.h>
#include <devicetree.h>
#include <drivers/gpio.h>
/* 1000 msec = 1 sec */
#define SLEEP_TIME_MS 1000
/* The devicetree node identifier for the "led0" alias. */
#define LED0_NODE DT_ALIAS(led0)
#if DT_NODE_HAS_STATUS(LED0_NODE, okay)
#define LED0 DT_GPIO_LABEL(LED0_NODE, gpios)
#define PIN DT_GPIO_PIN(LED0_NODE, gpios)
#define FLAGS DT_GPIO_FLAGS(LED0_NODE, gpios)
#else
/* A build error here means your board isn't set up to blink an LED. */
#error "Unsupported board: led0 devicetree alias is not defined"
#define LED0 ""
#define PIN 0
#define FLAGS 0
#endif
void main(void)
{
const struct device *dev;
bool led_is_on = true;
int ret;
dev = device_get_binding(LED0);
if (dev == NULL) {
return;
}
ret = gpio_pin_configure(dev, PIN, GPIO_OUTPUT_ACTIVE | FLAGS);
if (ret < 0) {
return;
}
while (1) {
gpio_pin_set(dev, PIN, (int)led_is_on);
led_is_on = !led_is_on;
k_msleep(SLEEP_TIME_MS);
}
}

Simulating Zephyr Applications

There are two options for simulating Zephyr applications:

  1. Native: Zephyr supports a native target (a “board”). This generates an executable that runs directly on the host machine.
  2. QEMU: Zephyr supports the targets qemu_x86 and qemu_cortex_m3 for running applications inside QEMU. In this case, the application is built for the same architecture as the real target, but runs inside the QEMU virtual machine on the host machine.

Javad Rahamipetroudi’s “Using emulators and fake devices in Zephyr” blog post is a good read when it comes to emulating Zephyr peripherals8.

Zephyr and C++

Zephyr has pretty good support for C++. All of it’s headers are wrapped in extern C allowing you to call them easily from C++.

Zephyr does not support the following C++ features:

  • No dynamic memory allocation support via new or delete. Dynamic memory allocation in the embedded land is a contentious subject, but it’s nice to have, especially if you just allow dynamic allocation at initialization time.
  • No RTTI (run-time type information). This is not really an issue for embedded development as it’s commonly disabled anyway.
  • No support for exceptions. This is not really an issue for embedded development as it’s commonly disabled anyway.

You can enable support for compiling C++ by adding the following into prj.conf:

CONFIG_CPP=y

You can then change main.c to main.cpp. Remember to update the path in the CMakeLists.txt file also!

# Adds everything!
CONFIG_REQUIRES_FULL_LIBCPP=y

You should now be able to compile the Zephyr application with C++ code.

The Zephyr Shell

Zephyr features a “shell” (one of it’s modules) that it can provide over a serial transport such as a UART, USB or Segger RTT. The shell provides to the user things such as Linux command-line style commands, logging, auto-complete, command history and more. In It is great for implementing a debug interface to control your microcontroller from a terminal application such as NinjaTerm (shameless plug, I developed this app).

Zephyr provides an API that the firmware can use to define the commands available to the user over the shell.

Debugging

You can use the addr2line executable to decode a memory address back into a source code file and line number. addr2line should be provided by the toolchain as part of the compiler’s suite of executables. For example, with Nordic toolchains in Zephyr:

Terminal window
c:\ncs\toolchains\31f4403e35\opt\zephyr-sdk\arm-zephyr-eabi\bin\arm-zephyr-eabi-addr2line.exe -e .\app\build\zephyr\zephyr.elf -a 0x0

Would give output something like this:

0x00000000
C:/my-project/zephyr/subsys/debug/thread_analyzer.c:75

Reducing Flash and RAM Usage in Zephyr

Zephyr-based applications can get large, in part due to the powerful features it provides out-of-the-box. Just things like using logging throughout your code can increase flash usage significantly, due to every call saving the log message (before substitution takes place at runtime) as a string literal in ROM. This can easily use up many “kB” of space. If you weren’t using float printing before hand, this call also bring in float formatting functionality. Similarly, all ASSERT() style macros save the file name and line number of the assert as a string literal in ROM. However these are quite useful, even in production, so think carefully before disabling them.

CONFIG_SIZE_OPTIMIZATIONS=y can be set in prj.conf to reduce the flash size. One thing this does is set the compiler flag -Os which tells the compiler to optimize for size, not speed or debug ability.

On one project I was working on, just setting CONFIG_SIZE_OPTIMIZATIONS=y in prf.conf resulted in a flash size reduction from 421kB to 330kB!

You can execute the west command with -t ram_report to make Zephyr generate and print a table of RAM usage to the terminal.

A screenshot of the output produce by the west build -t ram_report command.

puncover can be used to visualize the memory usage via a web GUI. Install puncover in the projects Python virtual environment:

Terminal window
# Activate python environment if needed, then
pip install puncover

Then perform a clean build of west (it will detect puncover and add a target for it):

Terminal window
west build -c

Then run puncover by invoking west and giving it a specific target:

west build -t puncover

If you get the error ninja: error: unknown target 'puncover' (as shown below) when trying to run puncover, it might be because you have not done a clean rebuild. You must do a clean rebuild after installing puncover. This is needed because CMake looks for the puncover executable during the build process. If it can’t find it, no target is made for it.

A screenshot of the build error you can get if you don’t clean build after installing puncover.

You can also look at reducing the stack size of some of the default stacks you will likely have in your project:

# Stack size of the main thread.
# Defaults to 2048
CONFIG_MAIN_STACK_SIZE=1024
# Stack used for Zephyr initialization and interrupts
# If you have a stack overflow before your code gets to main() you will
# likely need to make this bigger
# Defaults to 2048
CONFIG_ISR_STACK_SIZE=1024
# Stack size of the shell thread (if you are using a shell)
# Defaults to 2048
CONFIG_SHELL_STACK_SIZE=1024

Tests

There is a template test project located at zephyr/samples/subsys/testsuite/integration.

Twister is Zephyrs test runner tool. It is a command-line tool that collects tests, builds the test application and runs it. The best way to access it is via a subcommand of west, i.e. west twister.

Twister dumps it’s build output into a directory called twister-out. If twister-out already exists (i.e. twister has already been run before) then it will rename the existing directory to twister-out1, twister-out2, e.t.c. Part of the build output are the test reports. These are JUnit style XML reports that can be read by CI tools such as GitHub Actions, GitLab, Jenkins, etc. See the Setting Up CI for Zephyr Projects section for more information.

Adding Native Unit Tests To Your Application

Let’s assume you have a workspace application at ~/zephyr-project/app/ and want to add unit tests to it using Zephyr and ztest. We’ll run the tests on the native_sim board so that we can

Let’s create a directory called tests under app, and then copy all of the files from ~/zephyr-project/zephyr/samples/subsys/testsuite/integration into this new tests directory.

tests will be an entire Zephyr application in it’s own right. The directory structure for your project should look like this:

<home>/
├─── zephyr-project/
│ ├── .west/
│ ├── app/
│ │ ├── CMakeLists.txt
│ │ ├── prj.conf
│ │ ├── src/
│ │ | ├── main.c
| | | ├── RgbLed.c
| | | └── RgbLed.h
| | └── tests/
| | ├── src/
| | | ├── main.c
| | | ├── SomeUnitTests.c
| | | └── SomeOtherUnitTests.c
| | ├── CMakeLists.txt
| | ├── prj.conf
| | └── testcase.yaml
│ ├── zephyr/
│ ├── bootloader/
│ ├── modules/
│ └── ...

Assuming you are currently in the root of the west workspace, you can run your tests with the following command. You do not need to pass in the tests directory, west twister can work that out so you just need to pass in the directory to your app.

Terminal window
west twister -T app/

This should build the example tests included in the template. But what we really want to test is code in the app/src/ directory. To do this, we need to do two things:

  1. Include the source code in app/src when building the test application.
  2. Add the app/src directory as a include path when building the test application.

To do this, we can modify the file tests/CMakeLists.txt to the following:

cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(integration)
# Include all source files in the main application
# except main.c, which we will filter out
FILE(GLOB appSources ../src/*.c)
list(FILTER appSources EXCLUDE REGEX ".*main\\.c$")
# Include all source files in the test application
FILE(GLOB testAppSources src/*.c)
target_sources(app PRIVATE ${testAppSources} ${appSources})
# Add the main application directory to the include path
target_include_directories(app PRIVATE "../src/")

Now in our tests .c files (tests/src/*.c), we can include header files from app/src/ and test the code.

When writing the tests, just using west twister -T app/ is not that useful because:

  1. It takes a long time to build and run the tests, and the output is hidden in a log file rather than being printed to the terminal. This slows down development when you want to write some test code and then run it to see if it works.
  2. Intellisense does not work as well as it does for the Zephyr app. This is because compile_commands.json is not created in the twister build directories, and the build directory keeps changing.

A better way is to reserve west twister -T app/ only for running your tests once you have finished writing the tests. While writing the tests, use the standard west build instead. You can do this because the test/ directory is a self-contained west application in it’s own right!

First use (assuming you are building the tests to run on native_sim):

Terminal window
west build -b native_sim ./<path_to_zephyr_app>/tests/ --pristine

and then from then on you can use the faster:

Terminal window
west build -t run

You can run specific tests by not using the shortcut -t run and instead calling the built zephyr.exe (yes, it’s even suffixed with .exe on Linux) with command-line options. For example:

Terminal window
west build -b native_sim
./build/zephyr/zephyr.exe -test="my_suite::my_test"

You can use the command line option -list with a ztest zephyr.exe to list all the tests that are available to run. Chain the above two commands together with && for a quick one liner while developing.

You can also chain together multiple selectors in the -test option with a comma, e.g.:

Terminal window
./build/zephyr/zephyr.exe -test="my_suite::my_test,my_suite2::my_test2"

Zephyr’s ztest framework provides the standard setup of test suites and tests.

You can use ZTEST_F(my_suite, my_test) to define a test which automatically pulls in the fixture returned from the setup function and gives it the name <suite_name>_fixture (i.e. my_suite_fixture in this case).

#include <zephyr/ztest.h>
// This name is important! Must be `<suite_name>_fixture`!
struct my_suite_fixture {
uint32_t number;
};
static void *my_suite_setup(void)
{
struct my_suite_fixture *fixture = malloc(sizeof(struct my_suite_fixture));
zassume_not_null(fixture, NULL);
// Initialize the fixture
fixture->number = 99;
return fixture;
}
static void my_suite_before(void *f)
{
struct my_suite_fixture *fixture = (struct my_suite_fixture *)f;
}
static void my_suite_teardown(void *f)
{
// Remember to free the memory allocated in the setup function!
free(f);
}
ZTEST_SUITE(my_suite, NULL, my_suite_setup, my_suite_before, NULL, my_suite_teardown);
ZTEST_F(my_suite, test_feature_x)
{
// Because we use `ZTEST_F()` we automatically get access to the `fixture` object
zassert_equal(99, fixture->number);
}

Your Own Entry Point

ztest provides a default entry point for your tests, but if you want you can provide your own. This is useful if you need to setup some global state before running the tests, or have more control over when they start. All you need to do is define a function called test_main(), and then call ztest_run_all() from it as shown below:

#include <zephyr/ztest.h>
void test_main(void)
{
// Run all tests once
ztest_run_all(NULL, false, 1, 1);
}

ztest_run_all() has the following signature: const void *state, bool shuffle, int suite_iter, int case_iter. suite_iter is the number of times to run each suite, and case_iter is the number of times to run each test in a suite. Setting this higher than 1 can be a good way to catch memory leaks (all tests are run in the same executable program, so memory leak issues will accumulate and hopefully make themselves apparent). shuffle can be set to true to randomize the order of the tests, which can be useful to make sure your tests are independent of one another.

Cleaning Up

Many firmware applications are designed to run forever on a MCU (well, until it’s reset of course). Because of this, you rarely need to clean up resources you have created. In a testing environment however, you will typically be setting up and tearing down parts of your application for each test. Because of this, you will need to put more focus on cleaning up resources correctly, otherwise it can lead to memory leaks, segmentation faults or other bugs.

You should stop any Zephyr timers you have started in your test, as you can get segmentation faults if you reuse the same struct as a new timer with the same callback function.

ZTEST_F(my_suite, test_example)
{
struct k_timer my_timer;
k_timer_init(&my_timer, my_timer_callback, NULL);
k_timer_start(&my_timer, K_SECONDS(1), K_SECONDS(1));
// Stop the timer. Perfectly fine to call even if the timer
// has already stopped.
k_timer_stop(&my_timer);
}

Setting Up CI for Zephyr Projects

The Zephyr Project provides a Docker container that is suitable for CI pipelines. The images can be found on Docker Hub here (they are also on GitHub’s container registry). This is a placeholder for the reference: fig-zephyr-docker-images-on-docker-hub shows a screenshot of some of the available images as of November 2024. Note the images are quite large, with some of them being around 4GB in size!

The Zephyr Docker images on Docker Hub.9

Some of the images are:10

  • ci-base: Minimal image with just the basic installed. No toolchains installed.
  • ci: Contains the Zephyr SDK, west and other things you would typically need to CI use.
  • zephyr-build: Like the ci image, but with additional developer tools installed.

If you need to customize the container further, you can extend the image by making your own Dockerfile. Use FROM zephyrprojectrtos/ci:v0.27.4 and then you own additional RUN commands. For example:

FROM zephyrprojectrtos/ci:v0.27.4
RUN echo "Do other things here!"

You would then want to build the image with docker build -t my-custom-zephyr-image . and upload it to a container registry so it can be used in your CI pipelines to build/run Zephyr applications.

The command line tool west is globally available inside the container, so you don’t need to create a Python virtual environment and install it.

When building a Zephyr application, you will likely want to cache all the dependencies installed from the west update command, as these can take a long time to install. Most cloud-based CI services (e.g. GitHub Actions, GitLab Pipelines) will allow you to cache files/directories between jobs. I have found it easiest to group all dependencies into a directory for easy caching, and so your .west/config file might look like this:

[zephyr]
base = external/zephyr

Twister generates standard JUnit style XML test reports. The test report is written to twister-out/twister_report.xml. These test reports can be parsed by most CI services. For example, GitLab pipelines supports adding a reports: junit: ... field which will parse the test report and display the results in the UI. Your .gitlab-ci.yml file might look like this:

my_job:
script:
- west twister -T .
artifacts:
when: always
paths:
- $CI_PROJECT_DIR/twister-out/twister_report.xml
reports:
junit: $CI_PROJECT_DIR/twister-out/twister_report.xml

GitLab will display the test results in a merge request, as shown in This is a placeholder for the reference: fig-gitlab-test-summary-in-merge-request.

The Zephyr twister test results displayed in a GitLab merge request.

Distributing Zephyr Libraries

See https://github.com/coderkalyan/pubsub for an example.

Common Errors

File not found (on Windows)

If you get an error when running west build similar to:

CMake Error at C:/Users/Geoffrey Hunter/temp/zephyrproject/zephyr/cmake/kconfig.cmake:206 (message):
File not found: C:/Users/Geoffrey

It is due to there being one or more spaces in the path to your Zephyr project directory. This isn’t a bug that is going to be fixed anytime soon, Zephyr is very clear on the matter in their documentation:

A warning on Zephyr’s documentation about using spaces in paths.

I found this out the hard way and went through all the trouble of renaming my user directory to fix the issue.

No module named ‘elftools’

FAILED: zephyr/include/generated/kobj-types-enum.h zephyr/include/generated/otype-to-str.h zephyr/include/generated/otype-to-size.h
cmd.exe /C "cd /D C:\Users\gbmhunter\temp\myproject\zephyr\build\zephyr && C:\Users\gbmhunter\AppData\Local\Programs\Python\Python38-32\python.exe C:/Users/gbmhunter/temp/myproject/zephyr/scripts/gen_kobject_list.py --kobj-types-output C:/Users/gbmhunter/temp/myproject/zephyr/build/zephyr/include/generated/kobj-types-enum.h --kobj-otype-output C:/Users/gbmhunter/temp/myproject/zephyr/build/zephyr/include/generated/otype-to-str.h --kobj-size-output C:/Users/gbmhunter/temp/myproject/zephyr/build/zephyr/include/generated/otype-to-size.h --include C:/Users/gbmhunter/temp/myproject/zephyr/build/zephyr/misc/generated/struct_tags.json "
Traceback (most recent call last):
File "C:/Users/gbmhunter/temp/myproject/zephyr/scripts/gen_kobject_list.py", line 62, in <module>
import elftools
ModuleNotFoundError: No module named 'elftools'

You typically get the error No module named 'elftools' if you haven’t installed the Python modules that Zephyr requires to build. To install the required modules:

Terminal window
> pip3 install -r scripts/requirements.txt

“__device_dts_ord_DT_N_NODELABEL_xxx_ORD” undeclared

Zephyr can produce some really obscure error messages when there are errors relating to the device tree, for example:

C:/project/zephyr/include/zephyr/device.h:83:41: error: '__device_dts_ord_DT_N_NODELABEL_hs_0_ORD'
undeclared (first use in this function)
83 | #define DEVICE_NAME_GET(dev_id) _CONCAT(__device_, dev_id)
| ^~~~~~~~~

If you are using the VS Code and the nRF Connect extension, sometimes this can be fixed by making when you setup the build configuration you set the Configuration to “Use build system default” as shown below:

Selecting “Use build system default” can sometimes fix device tree errors.

”ERROR: Build directory xxx is for application yyy, but source directory zzz was specified”

The error:

ERROR: Build directory xxx is for application yyy, but source directory zzz was specified;
please clean it, use --pristine, or use --build-dir to set another build directory

typically occurs when you try to build a second project for the first time. By default, west creates build directories outside of the application you are currently building, in a directory called build directly under the west workspace directory (e.g. zephyr-project/build/).

When you tell west to build a different project (say, you tested out a sample like samples/hello_world but now want to build your own workspace application), west will try and re-use build. Except that it notices that the remnants from the last build do not belong to the same project, and gives you this error. Because build artifacts can be reproduced by rebuilding, it is generally save to provide the --pristine option and override the contents (this would be equivalent to you deleting the build directory and re-running west). If you want to have multiple builds on-the-go at the same time (perhaps because builds can take a long time to rebuild from scratch!), you can specify a different build directory with the --build-dir option.

”By not providing “FindZephyr.cmake” in CMAKE_MODULE_PATH …”

If you get the following warning (which then results in an error further down in the build process):

(.venv) geoff@geoffs-laptop:~/zephyr-project$ west build -b native_sim ./apps/hello-world/
-- west build: generating a build system
CMake Warning at CMakeLists.txt:3 (find_package):
By not providing "FindZephyr.cmake" in CMAKE_MODULE_PATH this project has
asked CMake to find a package configuration file provided by "Zephyr", but
CMake did not find one.
Could not find a package configuration file provided by "Zephyr" with any
of the following names:
ZephyrConfig.cmake
zephyr-config.cmake
Add the installation prefix of "Zephyr" to CMAKE_PREFIX_PATH or set
"Zephyr_DIR" to a directory containing one of the above files. If "Zephyr"
provides a separate development package or SDK, be sure it has been
installed.
Screenshot of the error “By not providing “FindZephyr.cmake” in CMAKE_MODULE_PATH …“.

It usually can be due to forgetting to export Zephyr to the CMake user package registry. Run the following command from the west workspace directory:

Terminal window
west zephyr-export

ModuleNotFoundError: No module named ‘elftools’

The error:

Traceback (most recent call last):
File "/home/geoff/zephyr-project/zephyr/scripts/build/gen_kobject_list.py", line 62, in <module>
import elftools
ModuleNotFoundError: No module named 'elftools'
Screenshot of the error “No module named ‘elftools’”.

can occur if you have forgotten to install the additional Zephyr dependencies into your Python environment (which can happen if you delete the existing virtual environment and recreate it). This can be fixed by running the following command, assuming you have activated the Python virtual environment if relevant:

Terminal window
pip install -r ./zephyr/scripts/requirements.txt

Printing Floats Results in float

If tring to print a float using %f in any printf style functions (or log macros) results in the output *float*, it’s likely you need to enable floating-point print support with CONFIG_FPU=y in prj.conf:

CONFIG_FPU=y # Required for printing floating point numbers

History

In February 2016, Wind River (the same company the makes VxWorks, one of the leading commercial RTOSes for safety critical systems) donated the Rocket OS kernel to the Linux Foundation and Zephyr was born11 12. Rocket still existed in parallel as a commercial version of Zephyr.

Nordic chose to move from their nRF5 platform to Zephyr as the officially supported development environment for their nRF52, nRF53 and nRF92 MCU/SoC families. Zephyr support for the nRF52 family was added around April 202013.

Other Resources

Check out the Zephyr Discord channel.

Footnotes

  1. Kernel.org. Kconfig Language [documentation]. Retrieved 2024-10-12, from https://www.kernel.org/doc/html/next/kbuild/kconfig-language.html.

  2. Zephyr. Docs / Latest -> Kernel -> Kernel Services -> Workqueue Threads [documentation]. Zephyr Docs. Retrieved 2024-01-10, from https://docs.zephyrproject.org/latest/kernel/services/threads/workqueue.html.

  3. Zephyr (2023, Nov 7). Mutexes [documentation]. Retrieved 2024-02-14, from https://docs.zephyrproject.org/latest/kernel/services/synchronization/mutexes.html. 2

  4. Zephyr (2024, Jan 16). Kconfig Search - CONFIG_TASK_WDT_CHANNELS [documentation]. Zephyr Docs. Retrieved 2024-01-17, from https://docs.zephyrproject.org/latest/kconfig.html#CONFIG_TASK_WDT_CHANNELS.

  5. Zephyr (2024, Feb 19). Logging [documentation]. Retrieved 2024-02-19, from https://docs.zephyrproject.org/latest/services/logging/index.html.

  6. Zephyr (2022, Jan 12). Kconfig Search > CONFIG_LOG_PROCESS_TRIGGER_THRESHOLD [documentation]. Retrieved 2024-11-27, from https://docs.zephyrproject.org/latest/kconfig.html#CONFIG_LOG_PROCESS_TRIGGER_THRESHOLD.

  7. Zephyr. gpio.h File Reference [documentation]. Retrieved 2024-11-12, from https://docs.zephyrproject.org/apidoc/latest/drivers_2gpio_8h.html.

  8. Javad Rahamipetroudi (2024, May 22). Using emulators and fake devices in Zephyr. Retrieved 2024-10-24, from https://mind.be/using-emulators-and-fake-devices-in-zephyr/.

  9. Docker Hub. zephyrprojectrtos [user page]. Retrieved 2024-11-26, from https://hub.docker.com/u/zephyrprojectrtos.

  10. GitHub. zephyrproject-rtos/docker-image [repository]. Retrieved 2024-11-26, from https://github.com/zephyrproject-rtos/docker-image.

  11. Wikipedia (2023, Oct 20). Zephyr (operating system). Retrieved 2024-02-21, from https://en.wikipedia.org/wiki/Zephyr_(operating_system).

  12. Scaler. Scaler Topics - How does the Zephyr Operating System Work?. Retrieved 2024-02-1, from https://www.scaler.com/topics/zephyr-operating-system/.

  13. Nordic Semiconductor (2020, Apr 2). Nordic Semiconductor now offering broad product line support for its short-range and cellular IoT devices on nRF Connect platform including a suite of development tools and open source nRF Connect SDK [blog post]. Retrieved 2024-02-21, from https://www.nordicsemi.com/Nordic-news/2020/04/nordic-now-offering-support-for-its-shortrange-and-cellular-iot-devices-on-nrf-connect-platform.