First steps with Zephyr
I recently started playing around with Zephyr, reading about it and doing some experiments, and I figured I’d rather jot down my impressions and findings so that the me in the future, who’ll have no recollection of ever doing this, can come back to it as a reference. And if it’s helpful for anybody else, that’s a nice bonus.
It’s been a really long time since I last dove into embedded programming for low-powered hardware and things have changed quite a bit, positively, both in terms of hardware availability for professionals and hobbyists and in the software options. Back in the day, most of the open source embedded OSs1 I tried felt like toy operating systems: enough for simple applications but not really suitable for more complex systems (eg. not having a proper preemptive scheduler is a serious limitation). In the proprietary side things looked better and there were many more options but, of course, those weren’t freely available.
Nowadays, Zephyr has filled that gap in the open source embedded OSs field2, even becoming the de facto OS to use, something like a “Linux for embedded”: it feels like a full-fledged OS, it’s feature rich, flexible and scalable, it has an enormous traction in embedded, it’s widely supported by many of the big names in the industry and it has plenty of available documentation, resources and a thriving community. Currently, if you need to pick an OS for embedded platforms, unless you’re targetting very minimal hardware (8/16bit microcontrollers), it’s a no brainer.
Noteworthy features
One of the most interesting qualities of Zephyr is its flexibility: the base system is lean and has a small footprint, and at the same time it’s easy to grow a Zephyr-based firmware for more complex applications thanks to the variety of supported features. These are some of them:
- Feature-rich kernel core services: for a small operating system, the amount of core services available is quite remarkable. Most of the usual tools for general application development are there: thread-based runtime with preemptive and cooperative scheduling, multiple synchronization and IPC mechanisms, basic memory management functions, asynchronous and event-based programming support, task management, etc.
- SMP support.
- Extensive core library: including common data structures, shell support and a POSIX compatibility layer.
- Out-of-the-box hardware support for a large number of boards.
- Logging and tracing: simple but capable facilities with support for different backends, easy to adapt to the hardware and application needs.
- Native simulation target and device emulation: allows to build applications as native binaries that can run on the development platform for prototyping and debugging purposes.
- Device tree support for hardware description and configuration.
- Configurable scheduler.
- Memory protection support and usermode applications on supported architectures.
- Powerful and easy to use build tool.
Find more information and details in the Zephyr online documentation.
Getting started
Now let’s move on and get some actual hands on experience with Zephyr. The first thing we’ll do is to set up a basic development environment so we can start writing some experiments and testing them. It’s a good idea to keep a browser tab open on the Zephyr docs, so we can reference them when needed or search for more detailed info.
Development environment setup
The development environment is set up and contained within a python venv. The Zephyr project provides the west command line tool to carry out all the setup and build steps.
The basic tool requirements in Linux are CMake, Python3 and the device tree compiler. Assuming they are installed and available, we can then set up a development environment like this:
python3 -m venv zephyrproject/.venv
. zephyrproject/.venv/bin/activate
# Now inside the venv
pip install west
west init zephyrproject
cd zephyrproject
west update
west zephyr-export
west packages pip --install
Some basic nomenclature: the zephyrproject directory is known as a west “workspace”. Inside it, the zephyr directory contains the repo of Zephyr itself.
Next step is to install the Zephyr SDK, ie. the toolchains and other
host tools. I found this step a bit troublesome and it could have better
defaults. By default it will install all the available SDKs (many of
which we won’t need) and then all the host tools (which we may not need
either). Also, in my setup, the script that install the host tools fails
with a buffer overflow, so instead of relying on it to install the host
tools (in my case I only needed qemu) I installed it myself. This has
some drawbacks: we might be missing some features that are in the custom
qemu binaries provided by the SDK, and west won’t be able
to run our apps on qemu automatically, we’ll have to do that ourselves.
Not ideal but not a dealbreaker either, I could figure it out and run
that myself just fine.
So I recommend to install the SDK interactively so we can select the toolchains we want and whether we want to install the host tools or not (in my case I didn’t):
cd zephyr
west sdk install -i
For the initial tests I’m targetting riscv64 on qemu, we’ll pick up
other targets later. In my case, since the host tools installation
failed on my setup, I needed to provide qemu-system-riscv64
myself, you probably won’t have to do that.
Now, to see if everything is set up correctly, we can try to build
the simplest example program there is: samples/hello_world.
To build it for qemu_riscv64 we can use west
like this:
west build -p always -b qemu_riscv64 samples/hello_world
Where -p always tells west to do a pristine build ,ie.
build everything every time. We may not need that necessarily but for
now it’s a safe flag to use.
Then, to run the app in qemu, the standard way is to do west
build -t run, but if we didn’t install the Zephyr host
tools we’ll need to run qemu ourselves:
qemu-system-riscv64 -nographic -machine virt -bios none -m 256 -net none \
-pidfile qemu.pid -chardev stdio,id=con,mux=on -serial chardev:con \
-mon chardev=con,mode=readline -icount shift=6,align=off,sleep=off \
-rtc clock=vm \
-kernel zephyr/build/zephyr/zephyr.elf
*** Booting Zephyr OS build v4.1.0-6569-gf4a0beb2b7b1 ***
Hello World! qemu_riscv64/qemu_virt_riscv64
Architecture-specific note: we’re calling
qemu-system-riscv64 with -bios none to prevent
qemu from loading OpenSBI into address 0x80000000. Zephyr doesn’t need
OpenSBI and it’s loaded into that address, which is where qemu-riscv’s
ZSBL jumps to3.
Starting a new application
The Zephyr
Example Application repo contains an example application that we can
use as a reference for a workspace application (ie. an application that
lives in the zephyrproject workspace we created earlier).
Although we can use it as a reference, I didn’t have a good experience
with it
According to the docs, we can simply clone the example
application repo into an existing workspace, but that doesn’t seem to
work, and it looks like
the
docs are wrong about that. , so I recommend to start from
scratch or to take the example applications in the
zephyr/samples directory as templates as needed.
To create a new application, we simply have to make a directory for it in the workspace dir and write a minimum set of required files:
.
├── CMakeLists.txt
├── prj.conf
├── README.rst
└── src
└── main.c
CMakeLists.txt contains the required instructions for
CMake to find and build the sources (only main.c in this
example):
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(test_app)
target_sources(app PRIVATE src/main.c)
where test_app is the name of the application.
prj.conf is meant to contain application-specific config
options and will be empty for now. README.rst is
optional.
Assuming the code in main.c is correct, we can then build the application for a specific target with:
west build -p always -b <target> <app_name>
where <app_name> is the directory containing the
application files listed above. Note that west uses CMake under the
hood, so the build will be based on whatever build system CMake uses
(apparently, ninja by default), so many of these operations
can also be done at a lower level using the underlying build system
commands (not recommended).
Building for different targets
Zephyr supports building applications for different target types or abstractions. While the end goal will normally be to have a firmare running on a SoC, for debugging purposes, for testing or simply to carry out most of the development without relying on hardware, we can target qemu to run the application on an emulated environment, or we can even build the app as a native binary to run on the development machine.
The differences between targets can be abstracted through proper use of APIs and device tree definitions so, in theory, the same application (with certain limitations) can be seamlessly built for different targets without modifications, and the build process takes care of doing the right thing depending on the target.
As an example, let’s build and run the hello_world
sample program in three different targets with different architectures:
native_sim (x86_64 with emulated devices), qemu (Risc-V64
with full system emulation) and a real board, a Raspberry
Pi Pico 2W (ARM Cortex-M33).
Before starting, let’s clean up any previous builds:
west build -t clean
Now, to build and run the application as a native binary:
west build -p always -b native_sim/native/64 zephyr/samples/hello_world
[... omitted build output]
./build/zephyr/zephyr.exe
*** Booting Zephyr OS build v4.1.0-6569-gf4a0beb2b7b1 ***
Hello World! native_sim/native/64
For Risc-V64 on qemu:
west build -t clean
west build -p always -b qemu_riscv64 zephyr/samples/hello_world
[... omitted build output]
west build -t run
*** Booting Zephyr OS build v4.1.0-6569-gf4a0beb2b7b1 ***
Hello World! qemu_riscv64/qemu_virt_riscv64
For the Raspberry Pi Pico 2W:
west build -t clean
west build -p always -b rpi_pico2/rp2350a/m33 zephyr/samples/hello_world
[... omitted build output]
west flash -r uf2
In this case, flashing and checking the console output are board-specific steps. Assuming the flashing process worked, if we connect to the board UART0, we can see the output message:
*** Booting Zephyr OS build v4.1.0-6569-gf4a0beb2b7b1 ***
Hello World! rpi_pico2/rp2350a/m33
Note that the application prints that line like this:
#include <stdio.h>
int main(void)
{
printf("Hello World! %s\n", CONFIG_BOARD_TARGET);
return 0;
}
The output of printf will be sent through the target zephyr,console
device, however it’s defined in its device tree. So, for native_sim:
/ {
[...]
chosen {
zephyr,console = &uart0;
[...]
uart0: uart {
status = "okay";
compatible = "zephyr,native-pty-uart";
/* Dummy current-speed entry to comply with serial
* DTS binding
*/
current-speed = <0>;
};
Which will eventually print to stdout (see drivers/console/posix_arch_console.c
and scripts/native_simulator/native/src/nsi_trace.c).
For qemu_riscv64:
/ {
chosen {
zephyr,console = &uart0;
[...]
&uart0 {
status = "okay";
};
and from virt-riscv.dtsi:
uart0: uart@10000000 {
interrupts = < 0x0a 1 >;
interrupt-parent = < &plic >;
clock-frequency = < 0x384000 >;
reg = < 0x10000000 0x100 >;
compatible = "ns16550";
reg-shift = < 0 >;
};
For the Raspberry Pi Pico 2W:
/ {
chosen {
[...]
zephyr,console = &uart0;
[...]
&uart0 {
current-speed = <115200>;
status = "okay";
pinctrl-0 = <&uart0_default>;
pinctrl-names = "default";
};
and from rp2350.dtsi:
uart0: uart@40070000 {
compatible = "raspberrypi,pico-uart", "arm,pl011";
reg = <0x40070000 DT_SIZE_K(4)>;
clocks = <&clocks RPI_PICO_CLKID_CLK_PERI>;
resets = <&reset RPI_PICO_RESETS_RESET_UART0>;
interrupts = <33 RPI_PICO_DEFAULT_IRQ_PRIORITY>;
interrupt-names = "uart0";
status = "disabled";
};
This shows we can easily build our applications using hardware abstractions and have them working on different platforms using the same code and build environment.
What’s next?
Now that we’re set and ready to work and the environment is all set up, we can start doing more interesting things. In a follow-up post I’ll show a concrete example of an application that showcases most of the features listed above.
1: Most of them are generally labelled as RTOSs, although the “RT” there is used rather loosely.↩︎
2: ThreadX is now an option too, having become open source recently. It brings certain features that are more common in proprietary systems, such as security certifications, and it looks like it was designed in a more focused way. In contrast, it lacks the ecosystem and other perks of open source projects (ease of adoption, rapid community-based growth).↩︎
3: https://popovicu.com/posts/risc-v-sbi-and-full-boot-process/.↩︎