Getting started with Zephyr RTOS
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:
- Zephyr SDK - provides compiler and debug toolchains for various architectures, optimized to work with the zephyr framework
- 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 device
s, 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!