Getting started with Zephyr RTOS

Dec. 22, 2020

Introduction

Lately, I’ve been writing a lot small embedded projects using the Zephyr RTOS. I’ve been very impressed with the low learning curve (compared to some other open source RTOS), efficiency, rich feature set, and documentation. However, while the first-party technical documentation is very good, there is very little third-party documentation and tutorials. Hence I thought I’d write a few articles on how I use zephyr for my various projects, starting with a standard Hello World project!

What is an RTOS?

I’m not going to go into detail on what an RTOS is in this article, since there are many other articles out there that explain in detail. However, to recap, an RTOS is a lightweight “operating system” for embedded systems. It allows you to write multithreaded applications, and provides a kernel that handles scheduling various threads with different priorities. Additionally, depending on the RTOS framework in question, the framework may provide HALs (hardware abstraction layers) for different hardware APIs, memory and threading utilities, or other utilities.

What is Zephyr?

To quote https://zephyrproject.org: “The Zephyr Project strives to deliver the best-in-class RTOS for connected resource-constrained devices, built to be secure and safe.”. Zephyr is a relatively new open source RTOS that a lot of embedded companies are putting development into. It’s efficient, portable, and in my opinion, provides very good utilities and HALs. It supports many architectures, including (but not limited to) ARM, x86, and RISC-V. Additionally, it provides the usual set of HALs, a minimal libc implementation, heap management, and many other utilities, including data structures for inter-thread communication, workqueue threads, etc.

Getting Started

Okay, so let’s create our first project with zephyr! First, you’re going to need to install some dependencies, most notibly:

  1. Zephyr SDK - provides compiler and debug toolchains for various architectures, optimized to work with the zephyr framework
  2. west - zephyr’s build meta-tool. While this is technically optional, it is the recommended way to use zephyr, so I will be using it for the rest of this tutorial and future articles.

Take a look here for a full setup list. In fact, that article also goes through building the existing blinky sample project and flashing it. In this tutorial, we’re going to create a new zephyr project and write the blink code ourselves - but if you’d rather cut to the chase, feel free to look at the official getting started documentation.

Creating a Project

Now that we have the SDK and west installed, we can create an application:

$ cd /path/to/projects
$ mkdir zephyr-hello-world && cd zephyr-hello-world
$ # Initialize the zephyr project scaffolding
$ west init
=== Initializing in /path/to/zephyr-hello-world
--- Cloning manifest repository from https://github.com/zephyrproject-rtos/zephyr, rev. master
...
--- setting manifest.path to zephyr
=== Initialized. Now run "west update" inside /path/to/zephyr-hello-world.
$ # Pull in dependencies (zephyr framework and its dependencies)
$ west update
...
$

Let’s take a look at what west created for us, and what we need to do ourselves:

$ ls -l
drwxr-xr-x - kalyan 22 Dec 20:27 bootloader
drwxr-xr-x - kalyan 22 Dec 20:27 modules
drwxr-xr-x - kalyan 22 Dec 20:27 tools
drwxr-xr-x - kalyan 22 Dec 20:25 zephyr
$

Actually, we don’t have to worry about most of the stuff here. zephyr contains the source code for the zephyr kernel and framework. modules includes some other utility modules, tools has some - well - tools, and bootloader holds the source code for mcuboot, a 32bit bootloader that has out of the box support for zephyr. We won’t be using any of these directly in this tutorial, although. Additional documentation on the directory structure can be found here.

Additional Scaffolding

West didn’t create any source code or build files - so we’ll have to create them ourselves:

$ # for main source files
$ mkdir src/
$ # you'll probably want an include/ directory as well, for your headers.
$ # this isn't required for our simple blink example, though.
$ # mkdir include/
$ touch src/main.c
$ touch include/blink.h
$ touch CMakeLists.txt # zephyr uses CMake as its build system

Let’s code!

Finally, we can get to actually writing some code! Let’s write a simple program to blink an LED connected to a GPIO pin. Note that this example uses C - but zephyr supports C++ as well.

Let’s start with zephyr-hello-world/src/main.c:

#include <stdio.h>

#include <zephyr.h>
#include <device.h>
#include <drivers/gpio.h>

#define LED_PORT "GPIOC"
#define LED_PIN 13

void main(void)
{
	printf("Hello, world! %s\n", CONFIG_BOARD);
	 
	const struct device *gpio_port_dev;
	int ret;

	gpio_port_dev = device_get_binding(LED_PORT);
	if (gpio_port_dev == NULL) {
		printf("Unable to initialize device %s\n", LED_PORT);
	 	return;
	}

	printf("Initialized GPIO device %s\n", LED_PORT);

	ret = gpio_pin_configure(gpio_port_dev, LED_PIN, GPIO_OUTPUT);
	if (ret < 0) {
	 	printf("Unable to configure %s pin %d: error %d\n", LED_PORT, LED_PIN, ret);
	 	return;
	}

	while (1)
	{
	 	printf("Toggling!\n");
	 	gpio_pin_toggle(gpio_port_dev, LED_PIN);
	 	k_msleep(1000);
	}
}

That should be pretty self explanatory, but let’s go over the code bit by bit:

#include <stdio.h>

#include <zephyr.h>
#include <device.h>
#include <drivers/gpio.h>

Include standard library input/output, zephyr’s core and device library, as well as the gpio HAL driver.

#define LED_PORT "GPIOC"
#define LED_PIN 13

Change this to reflect the GPIO port and pin your LED is connected to.

printf("Hello, world! %s\n", CONFIG_BOARD);

Zephyr comes with a system console which allows you to log to standard output, which is really helpful in development (especially compared to some lower level frameworks that don’t provide such a utility). We’ll discuss how to connect to and view the console output later on.

const struct device *gpio_port_dev;
int ret;

gpio_port_dev = device_get_binding(LED_PORT);
if (gpio_port_dev == NULL) {
	printf("Unable to initialize device %s\n", LED_PORT);
	return;
}

printf("Initialized GPIO device %s\n", LED_PORT);

Zephyr’s HALs are based on the concept of devices, which are an abstract construct that can reference any peripheral - from a GPIO pin or I2C driver to a higher level construct like a sensor. In this case, we’re just looking for the GPIO device corresponding to the LED_PORT GPIO port. These devices will automatically be available to us, as long as we enable them in the build configuration - more on that later.

ret = gpio_pin_configure(gpio_port_dev, LED_PIN, GPIO_OUTPUT);
if (ret < 0) {
	printf("Unable to configure %s pin %d: error %d\n", LED_PORT, LED_PIN, ret);
	return;
}

Now that we have a pointer to the GPIO device, we can use the gpio driver to configure it. In this case, we’re setting LED_PIN to be an output pin.

while (1)
{
	printf("Toggling!\n");
	gpio_pin_toggle(gpio_port_dev, LED_PIN);
	k_msleep(1000);
}

Now, we entire a while true loop to blink the LED. Pretty self explanatory - we toggle the state of LED_PIN, then sleep for 1000 milliseconds.

Build configuration

Now that we have our code, it’s time to get it built. As mentioned before, Zephyr uses the Cmake build system - so we need to populate our CMakeLists.txt configuration file zephyr-hello-world/CMakeLists.txt:

cmake_minimum_required(VERSION 3.13.1)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(hello-world)

# if you have an include directory, you can include it here
# target_include_directories(app PRIVATE include)

FILE(GLOB app_sources src/*.c)
target_sources(app PRIVATE ${app_sources})

Additionally, we need a configuration file to tell the framework what features we want enabled. This is usually named zephyr-hello-world/prj.conf, and in this case, we just want to enable the GPIO driver:

CONFIG_GPIO=y

Building

We’re finally ready to build our project! For ease of getting started, build for a board that is already supported - you can find a list of supported boards here. I’m building for the blackpill_f401ce target, which is one of my favorite STM32 development boards due to its low cost and small form factor. However, it’s pretty easy to create a custom board definition if you’re working with something that’s not officially supported. In fact, I added the blackpill_f401ce board definition to upstream in this PR. Feel free to take a look there if you’re interested in adding support for your board.

Anyway, we can now just use west to build:

$ west build -p auto --board=blackpill_f401ce

Using -p auto automatically cleans any byproducts from previous builds, so be sure to use it if you’re i.e. trying out multiple samples, etc.

When that finishes, you should have an ELF and BIN file under build/zephyr.{elf, bin}. You can use your programmer/debugger of choice to flash this onto your board - I’m using west itself: west flash --runner blackmagicprobe.

Note: As of time of writing (12/22/20), on Arch Linux and other distributions that have updated to Python 3.9, west flash stopped working due to a library mismatch. A “quick hack” workaround is to force west to use a no-python version of the toolchain: west flash --gdb ~/zephyr-sdk/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb-no-py --runner blackmagicprobe

Running

If everything went well, you should see the LED blinking.

System Console

Remember that system console we talked about earlier? Let’s see how we can access it to take a look at our log output.

Unfortunately, the documentation for the console setup is a bit disorganized, so I had to do a bit of digging to figure out how to access it the first time. Turns out that it’s quite simple - zephyr publishes the system console over one of your board’s USARTs. For the blackpill_f401ce, this is usart1. How do we know this? Well, taking a look at the board’s main DTS (device tree spec) file at zephyr-hello-world/zephyr/boards/arm/blackpill_f401ce/blackpill_f401ce.dts, we find the following information:

...
chosen {
	zephyr,console = &usart1;
};
...
&usart1 {
	pinctrl-0 = <&usart1_tx_pa9 &usart1_rx_pa10>;
	status = "okay";
	current-speed = <115200>;
};

I haven’t gone over how the device tree works yet, since that is a complex topic and deserves its own (upcoming) article. However, think of it as a specification for describing the hardware of a board. In this case, we can see that under the chosen section, we are configuring the zephyr console to output to usart1. The main definition of usart1 is actually in the DTS spec file for the SoC (in this case, the STM32F401CE), but if we keep searching in this file, we see some extra configuration for usart1. Setting the status to “okay” enables the USART. Additionally, it is configured to use PA9 for TX and PA10 for RX - this will vary based on what pin(s) your SoC can output a specific USART/other peripheral to. Finally, we see the baud rate being set to 115200.

So, we have everything we need to know to connect to the console. Grab an FTDI or other USB-TTL converter, wire the ground up, and connect the RX pin on the FTDI to the correct TX pin on the board (and vice versa for TX-RX). Plug the USB into your computer, and open up a serial console (such as screen, minicom, or picocom). Reset the board, and you should see the following output:

$ picocom -b 115200 /dev/ttyUSB0
picocom v3.1
...
Terminal ready
*** Booting Zephyr OS build zephyr-v2.4.0-2674-g4ca7942411f7  ***
Hello, world! blackpill_f401ce
Initialized GPIO device GPIOC
Toggling!
Toggling!
Toggling!

What’s next?

I’ll write some more articles soon, regarding how to manage a larger zephyr project, best practices, and how to use some of zephyr’s other peripheral APIs. A commented version of this project can be found at https://git.sr.ht/~coder_kalyan/zephyr-hello-world/. In the meantime, feel free to check out Zephyr’s documentation for more information!