Introduction

This site contains the labs for the class ECE_5SE03_TP: Embedded Systems and IoT at Télécom Paris (DIOC : développement intégré d'objets connectés). You may freely use this lab for your own use, but please get in touch if you intend to reuse it in another context.

You can work offline by downloading the current state of this lab: book.tar.xz.

ⓒ 2022–2024 Samuel Tardieu — All rights reserved

Zephyr and application setup

This lab will use the Zephyr real-time operating system. Zephyr is an open-source projet of the Linux Foundation.

You will have two options for working on this lab: you can use your own computer, or you can use Telecom Paris lab computers.

Using your own computer

If you wish to do this lab on your own computer, it must be running a Unix-like system such as Linux or macOS. You may be able to do the lab in Microsoft Windows but do not expect help from the teachers (you're welcome to provide a step-by-step guide that would be included here).

The Zephyr web site contains instructions to install Zephyr.

You will have perform the following steps:

  • Install the required packages on your computer (such as cmake).
  • Decide where you will install Zephyr (for example $HOME/zephyrproject).
  • Create a virtual Python 3 environment (for example in $HOME/zephyrproject/venv):
$ mkdir $HOME/zephyr
$ python3 -mvenv $HOME/zephyr/venv
  • Activate this environment by sourcing the activate file:
$ source $HOME/zephyr/venv/bin/activate
  • Install west, Zephyr toolbox utility:
$ pip install west
  • Get Zephyr source code from git using west and update its dependent git repositories:
$ cd $HOME/zephyrproject
$ west init
$ west update
  • Register Zephyr location for cmake:
$ west zephyr-export
  • Install Zephyr Python requirements:
$ pip install -r zephyr/scripts/requirements.txt
  • Install Zephyr SDK (see the instructions to install the SDK).
  • Register Zephyr SDK location for cmake by running setup.sh found inside the SDK directory.
  • Install udev rules as per the instructions page.

Keep in mind the following points:

  • Zephyr and its SDK might take up to 25GB on your computer. Please ensure that you have enough room available.
  • Do not forget to activate the virtual Python environment in any terminal where you wish to use Zephyr.
  • You need to set the ZEPHYR_BASE environment variable to the zephyr directory created by west init (for example $HOME/zephyrproject/zephyr).

Using Telecom Paris computer lab

It is possible to use Telecom Paris computer lab instead of your own computer. However, this will be much slower because many files will transit over the network.

Install Zephyr

You must set the environment variable ZEPHYR_BASE to /comelec/softs/opt/zephyr-rtos/sources/zephyr. Modify your shell init file so that this variable is always in your environment.

You must execute the /comelec/softs/opt/zephyr-rtos/conf/cmake-register-zephyr.sh script once. It will update your cmake configuration (in $HOME/.cmake/packages) with Zephyr location information.

You must activate the Python virtual environment in every shell where you want to use west, by executing source /comelec/softs/opt/zephyr-rtos/sources/west-venv/bin/activate.

You're all set. This is a recent stable version of Zephyr, and we might update it from time to time, but that should be a transparent operation for you.

☹️ If something is wrong the way the Telecom Paris computer lab environment is set up, please notify the staff through the course mailing-list so that it can be fixed promptly.

Zephyr application

Now that Zephyr has been installed, we will be able to write our first application. Rather than using a real board, we will start by using a PC emulator.

Anatomy of an application

Our Zephyr applications will be freestanding. It means that we will keep our application code outside Zephyr source tree in order to only keep the application in our git repository.

A freestanding Zephyr application will make use of several directories:

  • The virtual Python environment in which we have installed west and the required Python libraries.
  • The Zephyr source code and its associated libraries. In order to use west, we will set the ZEPHYR_BASE environment variable to point at it.
  • Your application source code, which you will create in your git repository.
  • A build directory: Zephyr applications are built out of tree. It means that the build process will never mix the source code with the build artifacts. If your build directory is named build, you can remove it at any time in order to free up some disk space.

Creating an application

Your first Zephyr application

Create a directory in your git working directory. Create the following two files.

In CMakeLists.txt:

cmake_minimum_required(VERSION 3.20.0)

find_package(Zephyr)
project(my_zephyr_app)

target_sources(app PRIVATE src/main.c)

In src/main.c:

#include <stdio.h>

int main() {
    printf("Hello, world\n");
    return 0;
}

You also need to create a configuration file prj.conf. In our case, we do not want to configure anything, so it can be empty:

$ touch prj.conf

We will now build the application for our target. In this case, we will generate code for a PC using an Intel CPU in bare-board mode and emulate the PC using the QEMU emulator.

Provided that west is in your $PATH and that $ZEPHYR_BASE is properly set if needed, you should be able to build your application for the board qemu_x86:

$ west build -b qemu_x86

(if you do not use west, you can use the equivalent cmake -B build -DBOARD=qemu_x86 -GNinja && ninja -C build zephyr/zephyr.elf)

This builds zephyr/zephyr.elf into the build directory. You should ignore this directory in your .gitignore as its content should never be added to the git repository.

You can now run the application. You do not need to give the board again as it is remembered.

$ west build -t run
-- west build: running target run
[0/1] To exit from QEMU enter: 'CTRL+a, x'[QEMU] CPU: qemu32,+nx,+pae
SeaBIOS (version zephyr-v1.0.0-0-g31d4e0e-dirty-20200714_234759-fv-az50-zephyr)
Booting from ROM..
*** Booting Zephyr OS build v4.2.0-48-g116c4a9e4014 ***
Hello, world

(without west: ninja -C build run)

Congratulations: you just ran your first Zephyr application. You can quit qemu with ctrl-a x (control key + a, then x), or simply kill the qemu process with ctrl-c.

Adding a shell

Zephyr comes with an interactive shell. We want to add it to our project.

Modify prj.conf at the top-level of your application and add the option CONFIG_SHELL to enable the shell. By default, a shell backend using the serial port will be enabled. You can customize the prompt through the CONFIG_SHELL_PROMPT_UART option in prj.conf as well:

CONFIG_SHELL=y
CONFIG_SHELL_PROMPT_UART="(zephyr) "

We can now rebuild and run the application:

$ west build -t run
[0/1] To exit from QEMU enter: 'CTRL+a, x'[QEMU] CPU: qemu32,+nx,+pae
SeaBIOS (version zephyr-v1.0.0-0-g31d4e0e-dirty-20200714_234759-fv-az50-zephyr)
Booting from ROM..
*** Booting Zephyr OS build v4.2.0-48-g116c4a9e4014 ***
Hello, world

(Zephyr)

Use the "help" command in the shell to see what you can do with it in its default configuration. Later, you will be able to select what commands should be made available through the shell, and you'll even be able to add your own commands.

Do not forget to add prj.conf to git. Commit, then push.

Exploring the threads

Use the kernel thread list shell command to examine the running threads. You should be able to identify the following ones:

  • idle: the idle thread, which takes care of putting the system to sleep. Zephyr will run the threads with lower priority values first. The idle thread has the highest priority value, which means that it will run only when nothing else needs to run.

  • shell_uart: the shell we are executing commands into. The shell runs into its own thread. It uses a very low priority task (with a high numerical value) as to not disturb the system it is interacting with.

  • sysworkq: Zephyr default workqueue. A workqueue is a thread on which jobs are sent to be executed serially, for example from an interrupt service routine (ISR). Note the negative priority: the workqueue is a very important thread.

Finding main

However, we don't see main in the list. Indeed, our main function exits as soon as it has printed.

Add an infinite loop at the end of main(). Why can't you use the shell? Use a proper way of making main() wait forever without consuming any resource. Zephyr documentation will be a very useful resource. Do not forget to include zephyr/kernel.h to use kernel services.

Find the priority of the main thread. Change it in the project configuration file and check that it has been properly set.

Switching board

Using ARM architecture

It is easy to switch to a new board in Zephyr. For example, instead of qemu_x86 which emulates a PC, we will emulate a Cortex-M3 based system using qemu_cortex_m3.

Recompile and run your application for the qemu_cortex_m3 board and check that everything still works as expected. If you run the command file build/zephyr/zephyr.elf, you should see that it is indeed an ARM application.

Using a real physical board

We will now use a IoT node board as we did in the SE203 class. This board has a number of sensors and effectors that can be useful in the future.

You can see the list of supported boards using west boards. The board we are interested in is the disco_l475_iot1. Compile your application for this board.

⚠️ If you use Telecom Paris computer lab, you must place the following directory at the beginning of your PATH: /comelec/softs/opt/Segger/JLink_current/. You should update your shell configuration file to do so each time you log in.

Our boards are configured to use Segger JLink tools. We can flash the program and reset the board by using:

$ west flash --runner jlink --reset

(if you don't use west, you can use ninja -C build flash provided that you set the BOARD_FLASH_RUNNER variable to jlink at cmake time, see this page for more information)

You can now connect to the /dev/ttyACM0 (for Linux, other systems do have equivalent names) and interact with the shell:

$ tio /dev/ttyACM0
(Zephyr) 

Note how Zephyr was able to use the serial port on your board without you telling it explicitly. In build/zephyr/zephyr.dts you will have access to the application device tree. The device tree describes the hardware and interacts with the right device drivers.

Note the following fragments:

/ {
        chosen {
                zephyr,console = &usart1;
                zephyr,shell-uart = &usart1;
        };

        soc {
                usart1: serial@40013800 {
                        compatible = "st,stm32-usart", "st,stm32-uart";
                        reg = < 0x40013800 0x400 >;
                        clocks = < &rcc 0x60 0x4000 >;
                        resets = < &rctl 0x80e >;
                        interrupts = < 0x25 0x0 >;
                        status = "okay";
                        current-speed = < 0x1c200 >;
                        pinctrl-0 = < &usart1_tx_pb6 &usart1_rx_pb7 >;
                        pinctrl-names = "default";
                };
        };
};

You can notice that the console (printf()) and the shell both opted to choose the device whose alias is usart1 (the serial port connected to the JLink compatible chip on the board, accessible under /dev/ttyACM0 or a similar name). Also, you can see how usart1 is defined and require the use of the st,stm32-usart and st,stm32-uart device drivers.

Using RTT for the shell instead of the UART

Since we use the Segger JLink tools, we can request the use of Segger RTT instead of a serial port for the shell. By adding the following line to prj.conf you can indicate that the shell must use a RTT interface instead of a shell:

CONFIG_USE_SEGGER_RTT=y
CONFIG_SHELL_PROMPT_RTT="(Zephyr) "
CONFIG_SHELL_BACKEND_SERIAL=n
CONFIG_SHELL_BACKEND_RTT=y
CONFIG_SEGGER_RTT_BUFFER_SIZE_DOWN=256

(increasing the buffer size is required if you want to type long commands at once such as kernel thread list)

You will get a warning about the now unused CONFIG_SHELL_PROMPT_UART: this is due to the fact that the serial backend for the shell is no longer used.

Flash and run the application on the board. You should notice that only the "Hello, world!" arrives through the serial port when you reset the board. Launch JLink RTT logger after flashing the board:

$ JLinkRTTLogger -device STM32L475VG -RTTChannel 1 -if SWD -Speed 4000

and connect to port 19021 using nc:

$ nc localhost 19021

You can now type command in the nc window and interact with the shell.

Now revert your changes as we will keep the shell on the serial port for now.

Some Zephyr concepts

In this part, you will learn how to use some Zephyr concept, such as the device drivers, the device tree and kernel services.

Using the device tree

You can find the consolidated device tree for your application in build/zephyr/zephyr.dts after the build phase. This device tree brings together your board-specific device tree, as well as the overlays defined specifically for your application or for additional components that you may declare to be using (such as an Arduino compatible shield).

Zephyr uses C (or C++) macros to access the device tree. The use of macros allows those to be resolved at compile time: a pre-processing phase compiles the device tree into a set of C headers whose content is manipulated through the macros.

An example: GPIOA

Here is a very simplified excerpt of the device tree for your application centered around gpioa:

/ {
  soc {
    pinctrl: pin-controller@48000000 {
      compatible = "st,stm32-pinctrl";

      gpioa: gpio@48000000 {
        compatible = "st,stm32-gpio";
        gpio-controller;
        #gpio-cells = < 0x2 >;
        reg = < 0x48000000 0x400 >;
        clocks = < &rcc 0x4c 0x1 >;
        phandle = < 0xb >;
      };
    };
  };
};

The device tree is made of nodes arranged in a tree fashion: a node may have children. Also, every node can have attributes.

In this example:

  • / represents the root node which is the base of the tree.
  • soc is a child of /, its path is /soc.
  • pin-controller@48000000 is a child of /soc. Its path is /soc/pin-controller@48000000. We also give this node a label pinctrl. From now on, we will be able to reference this node by its label instead of using its path. From other parts of the device tree itself, we may use &pinctrl to reference the node.
  • gpio@48000000 is a child of /soc/pin-controller@48000000. Its path is /soc/pin-controller@48000000/gpio@48000000. Its label is gpioa. It can be referenced by label as &gpioa from anywhere in the tree.

Let's assume that we want to use gpioa in our application. We can reference the gpioa device by its label and ask Zephyr to find it at compile time:

#include <zephyr/device.h>

// Define `GPIOA_NODE` as the node whose label is `gpioa` in the device tree
#define GPIOA_NODE DT_NODELABEL(gpioa)

// Get the struct device pointer to `gpioa` at compile time
static const struct device * const gpioa_dev = DEVICE_DT_GET(GPIOA_NODE);

Now that you have a reference to gpioa, you can setup the led at run-time and make it blink:

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

// Define `GPIOA_NODE` as the node whose label is `gpioa` in the device tree
#define GPIOA_NODE DT_NODELABEL(gpioa)

// Get the struct device pointer to `gpioa` at compile time
static const struct device * const gpioa_dev = DEVICE_DT_GET(GPIOA_NODE);

int main() {
  if (!device_is_ready(gpioa_dev)) {
    return -ENODEV;  // Device is not ready
  }
  // Configure the led as an output, initially inactive
  gpio_pin_configure(gpioa_dev, 5, GPIO_OUTPUT_INACTIVE);
  // Toggle the led every second
  for (;;) {
    gpio_pin_toggle(gpioa_dev, 5);
    k_sleep(K_SECONDS(1));
  }
  return 0;
}

Note that Zephyr peripherals are initialized during different boot phases. If somehow you have misconfigured a peripheral, it might not be ready when you start, at which case you should abort. This is what is done at the beginning of main.

Ensure that you can make the led blink. Easy, no? Yes, but you can do much better.

Commit, push.

Ensure that you understand everything you've used before going on.

Let's do less manual work

As you might have noted, we had to retrieve the const struct device * corresponding to gpioa and configure and toggle pin 5. Of course, we do not want to hardcode the port and the pin number in our code.

Finding the led

If you look at $ZEPHYR_BASE/boards/st/disco_l475_iot1/disco_l475_iot1.dts, you'll notice some interesting fragments, such as:

/ {
  aliases {
    led0 = &green_led_2;
  };
  leds {
    compatible="gpio-leds";
    green_led_1: {
      gpios = < &gpiob 14 GPIO_ACTIVE_HIGH >;
    };
  };
};

The node /aliases is a special one: it defines global aliases on nodes that can then be found with macro DT_ALIAS, such as in DT_ALIAS(led0).

⚠️ Make sure that you use DT_ALIAS to refer to a global alias, and DT_NODELABEL to refer to a node label, those are not interchangeable.

This would in turn reference the green_led_2 node label, which designated a node which contains an interesting gpios property < &gpiob 14 GPIO_ACTIVE_HIGH > with:

  • a reference to a port (&gpiob)
  • a pin number (14)
  • a GPIO_ACTIVE_HIGH flag

Note that the flags could have contained GPIO_ACTIVE_LOW, which would have meant that the ACTIVE state is the LOW state, and the INACTIVE state the HIGH state. By using these flags when initializing the port in your code, you can then use ACTIVE and INACTIVE (rather than HIGH and LOW) and get the proper behaviour.

Our goal will be to retrieve and use this gpio structure.

Looking at the consolidated device tree

You will find the consolidated device tree in build/zephyr/zephyr.dts: it contains a merge of $ZEPHYR_BASE/boards/arm/disco_l475_iot1/disco_l475_iot1.dts and of your own device tree fragments. Right now we don't have any, but we will create some later.

Note that in this file, all numbers are printed in hexadecimal and flags have been replaced by their values. For example, you will find

/ {
  aliases {
    led0 = &green_led_2;
  };
  leds {
    compatible="gpio-leds";
    green_led_1: {
      gpios = < &gpiob 0xe 0 >;
    };
  };
};

This is exactly the same thing as before: 14 == 0xe, and GPIO_ACTIVE_HIGH has a value of 0.

Retrieving the GPIO structure

Zephyr has a handy struct gpio_dt_spec structure which contains three fields: a port device (such as gpioa), a pin number and the flags. Functions from the GPIO driver have an equivalent that takes such struct as a parameter. Also, some macros allow the easy parsing of this structure:

// Designate the `led0` alias from the device tree
#define LED_NODE DT_ALIAS(led0)

// Get the GPIO (port, pin and flags) corresponding to the
// `led0` node alias at compile time.
static const struct gpio_dt_spec led_gpio = GPIO_DT_SPEC_GET(LED_NODE, gpios);

int main() {
  if (!device_is_ready(led_gpio.port)) {
    return -ENODEV;
  }
  // Note that we use `INACTIVE`, not `LOW`. On some boards,
  // the behaviour of the output may be reversed, but this
  // is not our concern. This info belongs to the device tree.
  gpio_pin_configure_dt(&led_gpio, GPIO_OUTPUT_INACTIVE);
  for (;;) {
    gpio_pin_toggle_dt(&led_gpio);
    k_sleep(K_SECONDS(1));
  }
  return 0;
}

Do it, check that it works. Commit push.

Note how the only reference to the hardware is now the led0 name. But you can do better.

Change the led

What if you wanted to use another led as led0?

Create a file named boards/disco_l475_iot1.overlay at the top of your project. Now, you can redefine the alias:

/ {
  aliases {
    led0 = &green_led_1;
  };
};

Since this is a new file, you must reconfigure the project. Remove the build directory before rebuilding the project.

Check that the other green led blinks. Did you notice that you haven't changed one single line of C code?

Oh, enough played, you can remove this board specific files that you created and return the led to its original location.

The led device driver

In fact, Zephyr also has a led device driver. Each device can drive several leds. You can retrieve a const struct device * const directly from the /leds node and forget about even initializing the ports and so on.

Retrieve a pointer to the leds device and make the two leds at index 0 and 1 alternate every second (one is on, the other is off). Do not try to use led_blink(): this function is optional and is not implemented for the GPIO led driver.

If you get an error, maybe you should see if you have enabled everything you need in prj.conf. Is the led driver enabled?

Is it working now? Great!

However, note that we work with const struct device * all the time: the device drivers are not strongly typed. This is your responsibility to only call the right functions on the right device.

Adding a new led

If you look at the board user manual you'll notice two other leds: a yellow one and a blue one. Both are driven at the same time: writing a high level to the corresponding port turns the yellow led on and the blue led off, writing a low level turns the yellow led off and the blue led on.

We want to add this new led as a child to the /leds node. Create a file named boards/disco_l475_iot1.overlay at the top of your project. In it, you will be able to add things to enrich the device tree:

/ {
  leds {
    /* What you will add here will be added to the /leds node */
  };
};

Add a new led blue_led in leds which corresponds to the blue led. Use the board user manual to find on which port the led is connected. Is its active state GPIO_ACTIVE_HIGH or GPIO_ACTIVE_LOW?. What extra flag must you add so that the yellow led does not turn on when you turn off the blue led? Note that you can combine flags in the device tree by putting them between parentheses and separating them with a pipe symbol ((FLAG1 | FLAG2 | FLAG3)).

You will have to remove the build directory in order to have the build system consider the new file.

Once this is done, modify your code so that:

  • turn on all leds (at index 0, 1, and 2)
  • wait 1s
  • turn all leds off
  • wait 1s
  • start over

Does the blue led light up at the same time as the green leds? Great!

Changing the meaning of the output

What if you consider that the led that should get turned on is the yellow led instead of the blue led? Change the flag in the overlay file that you have created to GPIO_ACTIVE_HIGH. Run your program again.

Interesting. How can you drive the yellow led without driving the blue one? (you don't have to make it do so, just think about it)

If you have not done so, you need to read the introduction to device trees in Zephyr documentation.

Being more autonomous

You'll do more things on your own from now on.

Copy the example code from $ZEPHYR_BASE/samples/basic/button and run it on your board. You'll see that:

  • The main program does some setup (gpio and interrupt) then copies the state of the switch sw0 to the led led0.
  • When the button is pressed, an interrupt is received and a message is printed.

Make sure you understand how the interrupts work, and what the ISR does.

Removing the loop

In order to let the system sleep, you will receive an interrupt both when the button is pressed and when it is released. In the interrupt service routine, you'll copy its state to the led.

Commit and push your code when it's done.

Adding a pause

We now want a different behaviour: when the button is pressed, the led will light up. It will go down one second later, unless the button is pressed again in the meantime.

Of course you do not want to use k_sleep() in an interrupt callback routine. You will use a workqueue in which you'll submit delayable work. You can use the system workqueue, or you can start your own in a new thread.