rcn at work

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:

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 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/.