Zephyr
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.
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:
-
Enable global confirmation so that you don’t have to manually confirm the installation of individual programs:
-
Install Zephyr dependencies:
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.
-
Install the Zephyr command-line tool called
west
(NOTE: I have run into problems using a Python virtual environment, so I installwest
to the OS version of Python, more on this below): -
Create a new Zephyr project:
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
: -
Change into the project directory and run
west update
:This command can take a few minutes to run as it clones a number of repositories into the project directory.
-
Export a Zephyr CMake package to your local CMake user package registry. This allows CMake to automatically find a Zephyr “base”:
-
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 choseC:\gnu-arm-embedded-toolchain\10-2020-q4-major\
: -
Setup environment variables:
-
Install the Python dependencies that Zephyr
-
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: -
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
. -
Flash the application:
NOTE: I got an error when running this:
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.
-
Firstly, install system dependencies:
-
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 usesv0.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.
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.
-
Extract the downloaded SDK:
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! -
Run the Zephyr SDK setup script:
-
Install
udev
rules, which let’s you program most boards as a regular user (serial port permissions): -
Create a new directory for the Zephyr project and a Python virtual environment in a
.venv
sub-directory: -
Install
west
in the new virtual environment (west
is a Python package). As long as the virtual environment is activated, this will makewest
available from the command line: -
Initialize the west workspace:
This creates a directory called
.west
inside the current working directory. Inside.west
another directory calledmanifest-tmp
. -
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. -
Export a Zephyr CMake package:
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: -
Install additional Python dependencies:
-
cd
into thezephyr
directory (a sub-directory of the project directory) and build the Blinky sample: -
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):
If you don’t have a dev. board, you could always test out Zephyr by building it for Linux.
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
:
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 undersamples/
like “Hello, world!” and “blinky”. In the directory structure below,hello_world
is a repository application. - 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: - 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:
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:
Now create a file called CMakeLists.txt
with the follow context:
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.
Your basic app is done! Your file structure should look like this:
To build and run, first cd
back into the west workspace at ~/zephyr-project/
. The run this at the command-line:
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:
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:
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:
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
:
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
:
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.:
You can then run the built application with:
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:
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:
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:
The native_sim
board supports the following APIs (among others):
- GPIO (mocked)
- Watchdog
- Timers
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
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
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.
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):
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:
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:
You create a timer object and initialize it with:
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)
.
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:
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:
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).
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:
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:
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:
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.
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:
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).
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
:
You’ll then need to include the header file that provides the API:
You then need to enable the watchdog task with int task_wdt_init (const struct device * hw_wdt)
:
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:
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.
Semaphores
Zephyr provides traditional counting semaphores.
A semaphore can be created with:
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
:
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.
You can then wait until one or more of these events occurs by calling k_poll()
:
Full Working Example
Here is a full working example:
and the output is:
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
:
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:
You can also set a default compiled log level with:
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”:
Now you can use log statements in your code:
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:
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).
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
:
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
:
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).
GPIO
To setup a single GPIO pin your device tree, you can add the following to your .dts
file:
You can then get access to this GPIO from your C code with:
GPIO Outputs
You can statically configure your GPIO as an output in your .dts
file by using compatible = "gpio-leds";
:
You can configure a GPIO as an output at runtime in C with:
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()
:
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()
:
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:
GPIO Interrupts
If you want to configure an interrupt for a GPIO pin, you can use gpio_pin_interrupt_configure_dt()
:
You then need to setup a callback:
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.
Flag | Description | Physical or Logical |
---|---|---|
GPIO_INT_DISABLE | Disable interrupts for the GPIO pin. | n/a |
GPIO_INT_EDGE_RISING | Trigger interrupt on rising edge. | Physical |
GPIO_INT_EDGE_FALLING | Trigger interrupt on falling edge. | Physical |
GPIO_INT_EDGE_BOTH | Trigger interrupt on both rising and falling edges. | Physical |
GPIO_INT_LEVEL_LOW | Trigger interrupt when the GPIO pin is low. | Physical |
GPIO_INT_LEVEL_HIGH | Trigger interrupt when the GPIO pin is high. | Physical |
GPIO_INT_LEVEL_TO_INACTIVE | Trigger interrupt when pin changes to inactive (logical level 0). | Logical |
GPIO_INT_LEVEL_TO_ACTIVE | Trigger interrupt when pin changes to active (logical level 1). | Logical |
GPIO_INT_LEVEL_INACTIVE | Trigger interrupt when pin is inactive (logical level 0). | Logical |
GPIO_INT_LEVEL_ACTIVE | Trigger interrupt when pin is active (logical level 1). | Logical |
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:
In your .dts file:
This defines 3 PWM peripherals.
PWM can be set up with:
And then enabled with:
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.
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:
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:
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:
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):
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:
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):
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.
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:
Simulating Zephyr Applications
There are two options for simulating Zephyr applications:
- Native: Zephyr supports a native target (a “board”). This generates an executable that runs directly on the host machine.
- QEMU: Zephyr supports the targets
qemu_x86
andqemu_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
ordelete
. 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
:
You can then change main.c
to main.cpp
. Remember to update the path in the CMakeLists.txt
file also!
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:
Would give output something like this:
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.
puncover can be used to visualize the memory usage via a web GUI. Install puncover in the projects Python virtual environment:
Then perform a clean build of west (it will detect puncover and add a target for it):
Then run puncover by invoking west
and giving it a specific target:
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.
You can also look at reducing the stack size of some of the default stacks you will likely have in your project:
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 directories called twister-out
, twister-out1
, twister-out2
, e.t.c.
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:
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.
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:
- Include the source code in
app/src
when building the test application. - 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:
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:
- 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.
- Intellisense does not work as well as it does for the Zephyr app. This is because
compile_commands.json
is not created in thetwister
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
):
and then from then on you can use the faster:
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:
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.
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).
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.
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!
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:
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:
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:
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.
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:
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:
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’
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:
“__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:
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:
”ERROR: Build directory xxx is for application yyy, but source directory zzz was specified”
The error:
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):
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:
ModuleNotFoundError: No module named ‘elftools’
The error:
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:
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
:
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
-
Kernel.org. Kconfig Language [documentation]. Retrieved 2024-10-12, from https://www.kernel.org/doc/html/next/kbuild/kconfig-language.html. ↩
-
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. ↩
-
Zephyr (2023, Nov 7). Mutexes [documentation]. Retrieved 2024-02-14, from https://docs.zephyrproject.org/latest/kernel/services/synchronization/mutexes.html. ↩ ↩2
-
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. ↩
-
Zephyr (2024, Feb 19). Logging [documentation]. Retrieved 2024-02-19, from https://docs.zephyrproject.org/latest/services/logging/index.html. ↩
-
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. ↩
-
Zephyr. gpio.h File Reference [documentation]. Retrieved 2024-11-12, from https://docs.zephyrproject.org/apidoc/latest/drivers_2gpio_8h.html. ↩
-
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/. ↩
-
Docker Hub. zephyrprojectrtos [user page]. Retrieved 2024-11-26, from https://hub.docker.com/u/zephyrprojectrtos. ↩
-
GitHub. zephyrproject-rtos/docker-image [repository]. Retrieved 2024-11-26, from https://github.com/zephyrproject-rtos/docker-image. ↩
-
Wikipedia (2023, Oct 20). Zephyr (operating system). Retrieved 2024-02-21, from https://en.wikipedia.org/wiki/Zephyr_(operating_system). ↩
-
Scaler. Scaler Topics - How does the Zephyr Operating System Work?. Retrieved 2024-02-1, from https://www.scaler.com/topics/zephyr-operating-system/. ↩
-
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. ↩