Understanding Zephyr’s Blinky Sample

After our initial blog post on Zephyr in which we discovered how to download, build and flash Zephyr on two different boards, in this second blog post, we will dive into the code of Zephyr to understand how exactly the Blinky example works. To illustrate this, we will use the same boards as in our last blog post: an Arduino Nano 33 BLE, and a STM32L562E-DK.

We will first look at how the example application determines which LED to blink and where it’s plugged in, and then we will look at the code responsible for blinking the LED.

LED Configuration in the Device Tree

In the Blinky application code, the two parts responsible for getting the information about the LED are these two lines:

15: #define LED0_NODE DT_ALIAS(led0)


21: static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);

In Zephyr, all the information about devices is stored in the Device Tree. When you want to interact with a device, you first need a node identifier (a macro referring to a Device Tree node). This is what the macro DT_ALIAS(led0) does: it finds the node with the alias led0. This alias is set in the Device Tree of the board: for the Arduino Nano 33 BLE, you can see at line 92 the declaration of the alias:

aliases {
	led0 = &led0;

This code defines an alias for the node having the label led0, which is itself described at line 22:

leds {
	compatible = "gpio-leds";
	led0: led_0 {
		gpios = <&gpio0 24 GPIO_ACTIVE_LOW>;
		label = "Red LED";

This describes the led0: it’s plugged into pin 24 of gpio0, and the LED is on when the pin is low. gpio0 is another label and is defined in the Device Tree of the SoC.

For the stm32l562e-dk board, the alias is defined in boards/arm/stm32l562e_dk/stm32l562e_dk.dts:

aliases {
	led0 = &green_led_10;

The node is described in another file: stm32l562e_dk_common.dtsi, at line 19.

green_led_10: led_10 {
	gpios = <&gpiog 12 GPIO_ACTIVE_LOW>;
	label = "User LD10";

It’s the same structure: the LED is on pin 12 of gpiog, and is on when the pin is low.

Back to the Blinky main.c code, we then need to get the relevant information in the code:

static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);

This populates a struct gpio_dt_spec:

struct gpio_dt_spec {
	/** GPIO device controlling the pin */
	const struct device *port;
	/** The pin's number on the device */
	gpio_pin_t pin;
	/** The pin's configuration flags as specified in devicetree */
	gpio_dt_flags_t dt_flags;

It’s composed of three elements, matching the elements described in the Device Tree: a struct device for the gpio, the pin number, and the flags. The struct device is the struct representing a device at runtime. It is created by the driver of the device.

With this struct available, it is then possible to use the LED.

Using the LED

First, we have to call gpio_is_ready_dt(&led) to check if the gpio is ready. Generally, in Zephyr, you always want to call device_is_ready before using a device: gpio_is_ready_dt just calls this function with the struct device.

We can now use the LED. The Blinky example uses only two functions: gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE) and gpio_pin_toggle_dt(&led).

These two functions are defined in include/zephyr/drivers/gpio.h.

The function gpio_pin_configure_dt() is responsible for correctly configuring the LED in output mode with a starting value of 1 (if we wanted the LED to start in the off state, we would have called gpio_pin_configure_dt(&led, GPIO_OUTPUT_INACTIVE)).

gpio_pin_toggle_dt() is the function responsible for turning the LED on and off. We are now going to focus on this function.

If we look at the code of this function, we see that it’s just a wrapper calling gpio_pin_toggle:

static inline int gpio_pin_toggle_dt(const struct gpio_dt_spec *spec)
	return gpio_pin_toggle(spec->port, spec->pin);

This function is also a wrapper, and will just convert the pin number from an integer n representing the pin number to an integer with the n-th bit set to 1:

return gpio_port_toggle_bits(port, (gpio_port_pins_t)BIT(pin));

gpio_port_toggle_bits() is the last function in this chain: it will look at the api field of the struct device passed as an argument, and then call the right function of the API, port_toggle_bits() in this case.

const struct gpio_driver_api *api =
	(const struct gpio_driver_api *)port->api;

return api->port_toggle_bits(port, pins);

If we want to understand the rest of this function, we have to look at the implementation of this function. This function is implemented by the driver, who is responsible for creating the struct device and populating its api field with an implementation of the right functions. The driver is obviously specific to the hardware, so it will be different for the two boards we are considering in this blog post.

We will first look at the Arduino Nano 33 BLE. As seen before, the LED that is targeted by the sample is led0, which uses the node gpio0.
The GPIO ports are part of the System on Chip (the nRF52840 in this case). So we have to look at the Device Tree for the SoC, which is located at dts/arm/nordic/nrf52840.dtsi. At line 522, we can see the declaration of gpio0:

gpio0: gpio@50000000 {
        compatible = "nordic,nrf-gpio";
        reg = <0x50000000 0x200
               0x50000500 0x300>;
        #gpio-cells = <2>;
        status = "disabled";
        port = <0>;
        gpiote-instance = <&gpiote>;

The compatible property indicates the name of the hardware the device represents and is used by Zephyr to compile and use the right driver for the device.

For the vast majority of drivers, there will be, at the beginning of the file, a definition of DT_DRV_COMPAT with the compatible string that the driver supports. In our case, we are looking for #define DT_DRV_COMPAT nordic_nrf_gpio. To find the driver, we can just grep the codebase for this string. I personally use ripgrep, but you can also use git grep, or any other grep tool.

rg "#define DT_DRV_COMPAT nordic_nrf_gpio"

We then get the name of the file with the driver: drivers/gpio/gpio_nrfx.c.

To communicate with a driver, we have to use some functions, part of an API, that the driver implements. We can see at line 418 the list of functions that this driver implements. As this driver drives a GPIO, it implements the gpio_driver_api. The function we are looking for is port_toggle_bits(): we can see that it is implemented by the function gpio_nrfx_port_toggle_bits():

static int gpio_nrfx_port_toggle_bits(const struct device *port,
				      gpio_port_pins_t mask)
	NRF_GPIO_Type *reg = get_port_cfg(port)->port;
	const uint32_t value = nrf_gpio_port_out_read(reg) ^ mask;
	const uint32_t set_mask = value & mask;
	const uint32_t clear_mask = (~value) & mask;

	nrf_gpio_port_out_set(reg, set_mask);
	nrf_gpio_port_out_clear(reg, clear_mask);

	return 0;

We see that the code reads the current state of the GPIO, inverts the pins it needs to (with ^ mask), and then sets all the pins that should be on and clears all the others.

But if we try to look at the code of these three functions, we can see that we can’t find them in the zephyr/ directory. That’s because these functions are part of the HAL (Hardware Abstraction Layer), which is written by the silicon vendor (Nordic in this case). In Zephyr, the HALs are stored in zephyrproject/modules/hal. For our function, we have to look in zephyrproject/modules/hal/nordic/nrfx/hal/nrf_gpio.h. You can find the code online in the GitHub repository of the Nordic HAL, still in the Zephyr project.

We can see the definition of, for example, nrf_gpio_port_out_set() at line 1202:

NRF_STATIC_INLINE void nrf_gpio_port_out_set(NRF_GPIO_Type * p_reg, uint32_t set_mask)
    p_reg->OUTSET = set_mask;

This code writes a field in a variable of type NRF_GPIO_Type. To find out what is this register, we have to go back to the driver. We see that this value is stored in the struct gpio_nrfx_cfg, which we can access through the config field of the struct device.

The config struct is created at boot time by the driver. You can see it at line 455. The relevant part is this line:

		.port = _CONCAT(NRF_P, DT_INST_PROP(id, port)),		\

DT_INST_PROP(id, port) is a macro that gives the value of the port properties of the device in the Device Tree. For gpio0, the value is 0. So, the macro will expand to .port = NRF_P0. If we grep for this name, we find that it’s defined in multiple files for the different Nordic SoCs. As our SoC is the nrf52840, we have to look in modules/hal/nordic/nrfx/mdk/nrf52840.h. Here we can find the value of NRF_P0:

#define NRF_P0                      ((NRF_GPIO_Type*)          NRF_P0_BASE)

and at line 1765:

#define NRF_P0_BASE                 0x50000000UL

We can also find the definition of the NRF_GPIO_Type type (line 1101):

typedef struct {                   /*!< (@ 0x50000000) P0 Structure                                       */
  __IM  uint32_t  RESERVED[321];
  __IOM uint32_t  OUT;             /*!< (@ 0x00000504) Write GPIO port                                    */
  __IOM uint32_t  OUTSET;          /*!< (@ 0x00000508) Set individual bits in GPIO port                   */
  __IOM uint32_t  OUTCLR;          /*!< (@ 0x0000050C) Clear individual bits in GPIO port                 */
  __IM  uint32_t  IN;              /*!< (@ 0x00000510) Read GPIO port                                     */
  __IOM uint32_t  DIR;             /*!< (@ 0x00000514) Direction of GPIO pins                             */
  __IOM uint32_t  DIRSET;          /*!< (@ 0x00000518) DIR set register                                   */
  __IOM uint32_t  DIRCLR;          /*!< (@ 0x0000051C) DIR clear register                                 */
  __IOM uint32_t  LATCH;           /*!< (@ 0x00000520) Latch register indicating what GPIO pins that
                                                          have met the criteria set in the PIN_CNF[n].SENSE
                                                          registers                                          */
  __IOM uint32_t  DETECTMODE;      /*!< (@ 0x00000524) Select between default DETECT signal behavior
                                                          and LDETECT mode                                   */
  __IM  uint32_t  RESERVED1[118];
  __IOM uint32_t  PIN_CNF[32];     /*!< (@ 0x00000700) Description collection: Configuration of GPIO
                                                          pins                                               */
} NRF_GPIO_Type;                   /*!< Size = 1920 (0x780)                                               */

So when the HAL function does p_reg->OUTSET = set_mask;, it directly writes into the memory-mapped register of the GPIO. We can check in the datasheet of the SoC that the registers are at the right adress.

We now understand the entire code responsible for blinking the LED on the Arduino. For the STM32 board, the situation is similar: the GPIO node is declared with compatible = "st,stm32-gpio";. By running rg "DT_DRV_COMPAT st_stm32_gpio", we find the driver at zephyr/drivers/gpio/gpio_stm32.c. In the function gpio_stm32_port_toggle_bits, we see that line:

const struct gpio_stm32_config *cfg = dev->config;
GPIO_TypeDef *gpio = (GPIO_TypeDef *)cfg->base;
WRITE_REG(gpio->ODR, READ_REG(gpio->ODR) ^ pins);

WRITE_REG is a macro from the ST HAL: we can find it in modules/hal/stm32/stm32cube/stm32l5xx/soc/stm32l5xx.h, at line 146. This macro is just a normal assignment, so we need to understand from where the gpio variable comes. The code which initializes the config struct is normally at the end of the file: the macro which initializes the struct device is at line 732.

We see that the address of the GPIO comes from this macro: DT_REG_ADDR(DT_NODELABEL(gpio##__suffix)). This macro gives the address specified in the Device Tree: the GPIO was declared as gpiog: gpio@42021800, so the macro will expand to 42021800. The struct GPIO_TypeDef is defined in the HAL, in modules/hal/stm32/stm32cube/stm32l5xx/soc/stm32l562xx.h, line 667.


While exploring the Zephyr code, we saw different parts of the Zephyr codebase: first, the Device Tree, which allows to describe the system in a generic way. Then, the code of the subsystem, which is generic for all devices. When the subsystem needs to interact with a device, you need the drivers: this one is specific to each hardware device and must adhere to a specific API, depending on the type of component. You can’t directly call the driver functions: you have to go through the API, exposed through the struct device.
Finally, the HAL. This one is a bit different because it’s written by the silicon vendor. The drivers call the relevant functions of the HAL to do their tasks. Depending on the vendor, a lot can be implemented in the HAL, or just the bare minimum of register definitions. With our two boards, we can already see that the two drivers use the HAL differently:

  • The HAL for the Nordic SoC is responsible for selecting the right register in the structure and providing the address of the component.
  • For the ST SoC, the situation is different. In the port_toggle_bits function, the HAL was almost not used: we just used the register definition, and the address of the component was derived from the Device Tree. But if you look at the rest of the driver, you will see quite a lot of calls to functions of the “Low level” library, a library in the HAL that is a bit more high level than just the raw register access present in the Nordic HAL.

It’s the interaction between these parts that allow the Zephyr codebase to remain both modular and generic, and enable you to write code that will work seamlessly on different boards.

We hope you enjoyed this deep dive into the Zephyr code base, and stay tuned for our next blog posts on Zephyr!

Leave a Reply