Practical Zephyr - Devicetree practice (Part 5)
In the previous articles, we covered Devicetree in great detail: We’ve seen how we can create our own nodes, we’ve seen the supported property types, we know what bindings are, and we’ve seen how to access the Devicetree using Zephyr’s devicetree.h
API. In this fifth article of the Practical Zephyr series, we’ll look at how Devicetree is used in practice by dissecting the Blinky application.
Using a practical example, we’ll see that our deep dive into Devicetree was really only covering the basics that we need to understand more advanced concepts, such as pin control and device drivers.
👉 Find the other parts of the Practical Zephyr series here.
Table of Contents
Prerequisites
This article is part of the Practical Zephyr article series. In case you haven’t read the previous articles, please go ahead and have a look. This article requires that you’re able to build and flash a Zephyr application to the board of your choice and that you’re familiar with Devicetree.
We’ll be mainly using the development kit for the nRF52840 but will also refer to example files from the STM32 Nucleo-64 development board towards the end of the article. You can follow along with any target - real or virtual.
Note: A full example application including all files that we’ll see throughout this article is available in the
04_practice
folder of the accompanying GitHub repository.
Warm-up
If you’ve been following along, you know the drill by now: Let’s create a new freestanding application with the files listed below:
$ tree --charset=utf-8 --dirsfirst
.
├── src
│ └── main.c
├── CMakeLists.txt
└── prj.conf
The prj.conf
remains empty for now, and the CMakeLists.txt
only includes the necessary boilerplate to create a Zephyr application with a single main.c
source file. As an application, we have a main
function that simply puts the MCU back to sleep at fixed intervals:
#include <zephyr/kernel.h>
#define SLEEP_TIME_MS 100U
void main(void)
{
printk("Message in a bottle.\n");
while (1)
{
k_msleep(SLEEP_TIME_MS);
}
}
As usual, I’ll build the application for my nRF52840 Development Kit from Nordic, but you can use any of Zephyr’s long list of supported boards - or an emulation target.
$ west build --board nrf52840dk_nrf52840 --build-dir ../build
Devicetree with gpio
Let’s start with a classic: The blinking LED. The easiest way to control a low-power LED is using a GPIO, and one obvious way to approach this problem is to jump straight to the gpio
API documentation. In contrast to Zephyr’s OS service documentation, however, the documentation for peripherals is typically restricted to the API, without further explaining the subsystem.
Instead, Zephyr’s chosen approach is to provide a list of related code samples for each subsystem, showing the API in action. And Zephyr comes with hundreds of samples and demos! In the list of related samples for the gpio
subsystem, we also find the good old blinky example.
Note: Blinky is an example for a Zephyr repository application. We continue using a freestanding application, and in the next article, we’ll finally create a workspace application.
Straight from the documentation, we follow the “Open in GitHub” link to find the application in Zephyr’s repository and simply copy the contents from main.c
into our own application. We’ll now compare the gpio
Devicetree API with the “plain” API in zephyr/include/zephyr/devicetree.h
that we’ve seen in the previous article.
Blinky with a /chosen
LED node
The Blinky example chooses the LED Devicetree node using the alias led0
. Zephyr keeps its Devicetrees clean and we’ve seen that aliases, including led0
are usually consistent throughout supported boards. Thus, if there’s a board supported by Zephyr that has at least one light on it that works like LED, you can be sure that there’s also a matching led0
alias:
zephyr/boards/arm/nrf52840dk_nrf52840/nrf52840dk_nrf52840.dts
/ {
leds {
compatible = "gpio-leds";
led0: led_0 {
gpios = <&gpio0 13 GPIO_ACTIVE_LOW>;
label = "Green LED 0";
};
};
aliases {
led0 = &led0;
};
};
Since we’re practicing, however, we’ll use a /chosen
node called app-led
instead. We add this node using a new board overlay file boards/nrf52840dk_nrf52840.overlay
for the nRF52840 development kit:
$ tree --charset=utf-8 --dirsfirst
.
├── boards
│ └── nrf52840dk_nrf52840.overlay
├── src
│ └── main.c
├── CMakeLists.txt
└── prj.conf
boards/nrf52840dk_nrf52840.overlay
/ {
chosen {
app-led = &led0;
};
};
In the example application, we use DT_CHOSEN(app_led)
instead of DT_ALIAS(led0)
, but that’s about the only change for the original Blinky sources that we need. Notice that again we specify the chosen node’s name using its “lowercase-and-underscore” form app_led
instead of the node’s name app-led
in the Devicetree. With a few minor adaptions, our application looks as follows:
/** \file main.c */
#include <zephyr/drivers/gpio.h>
#include <zephyr/kernel.h>
#define SLEEP_TIME_MS 1000U
#define LED_NODE DT_CHOSEN(app_led)
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED_NODE, gpios);
void main(void)
{
int err = 0;
if (!gpio_is_ready_dt(&led)) { return; }
err = gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
if (err < 0) { return; }
while (1)
{
(void) gpio_pin_toggle_dt(&led);
k_msleep(SLEEP_TIME_MS);
}
}
Building and flashing the application we indeed end up with a blinking LED.
$ west build --board nrf52840dk_nrf52840 --build-dir ../build
$ west flash --build-dir ../build
I don’t know about you, but even after all those years a blinking LED still puts a smile on my face.
Dissecting the GPIO pin information type
Looking through the function prototypes of gpio_is_ready_dt
, gpio_pin_configure_dt
, and gpio_pin_toggle_dt
, you’ll notice that they take a “GPIO specification” const struct gpio_dt_spec *spec
as a parameter. We find the matching declaration in Zephyr’s gpio.h
:
zephyr/include/zephyr/drivers/gpio.h
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;
};
Disclaimer: This section is a nose dive deep into Zephyr’s use of the generated Devicetree files. In case you don’t want - or don’t need - to know about the inner workings, I highly recommend sticking to Zephyr’s samples and demos or the official documentation in general. Skip ahead to the next section about the
status
property instead. In case I couldn’t talk you out of it, hold on tight!
Applying the Devicetree API
Let’s ignore the gpio_dt_spec
structure’s contents for now. Instead, let’s see how GPIO_DT_SPEC_GET
is used to populate the structure and how it compares with what we’ve learned about the Devicetree API in the previous articles:
So far, we’ve only used the macros from the Devicetree API zephyr/include/zephyr/devicetree.h
. Now we see how Zephyr’s drivers create their own Devicetree macros on top of this basic API. We can find the macro declaration in Zephyr’s gpio.h
header file.
zephyr/include/zephyr/drivers/gpio.h
#define GPIO_DT_SPEC_GET(node_id, prop) GPIO_DT_SPEC_GET_BY_IDX(node_id, prop, 0)
// At some other location ...
#define GPIO_DT_SPEC_GET_BY_IDX(node_id, prop, idx) \
{ \
.port = DEVICE_DT_GET(DT_GPIO_CTLR_BY_IDX(node_id, prop, idx)), \
.pin = DT_GPIO_PIN_BY_IDX(node_id, prop, idx), \
.dt_flags = DT_GPIO_FLAGS_BY_IDX(node_id, prop, idx), \
}
Similar to what we’ve seen for our own example in the previous article, Zephyr uses Devicetree macros to create an initializer expression for the matching type that should be used with the corresponding macro. The macros DT_GPIO_PIN_BY_IDX
and DT_GPIO_FLAGS_BY_IDX
simply expand to the DT_PHA_<x>
macros that we used when accessing phandle-array
s:
#define DT_GPIO_PIN_BY_IDX(node_id, gpio_pha, idx) \
DT_PHA_BY_IDX(node_id, gpio_pha, idx, pin)
#define DT_GPIO_FLAGS_BY_IDX(node_id, gpio_pha, idx) \
DT_PHA_BY_IDX_OR(node_id, gpio_pha, idx, flags, 0)
Ignoring the .port
field (we’ll get to that, don’t worry), GPIO_DT_SPEC_GET
expands to a very familiar format, where .pin
and .dt_flags
are initialized using the pin
and flags
specifier cells from our Devicetree:
#define GPIO_DT_SPEC_GET(node_id, prop) \
{ \
.port = DEVICE_DT_GET(/* --snip-- */), \
.pin = DT_PHA_BY_IDX(node_id, prop, /*idx*/0, pin), \
.dt_flags = DT_PHA_BY_IDX_OR(node_id, prop, /*idx*/0, flags, 0), \
}
Note: The downside of using C macros is that, well, they’re macros. Macros are expanded by the preprocessor, and the preprocessor doesn’t care about types. It therefore also doesn’t care if you use
GPIO_DT_SPEC_GET
as an initializer for an incompatible type. This typically fails during compile time in case the assigned variable or constant doesn’t have a compatible type.
Reviewing phandle-arrays
Properties of type phandle-array
are heavily used in Devicetrees. Since we now have one at hand with our Blinky example, let’s use it to review what we’ve learned about phandle-array
s - practice and repetition is the key to learning new concepts! The /leds/led0
is defined in the board’s DTS file as follows:
(reduced) zephyr/boards/arm/nrf52840dk_nrf52840/nrf52840dk_nrf52840.dts
/ {
leds {
compatible = "gpio-leds";
led0: led_0 {
gpios = <&gpio0 13 GPIO_ACTIVE_LOW>;
};
};
};
The matching binding specifies that all child nodes of led
have the required property gpios
of type phandle-array
:
(reduced) zephyr/dts/bindings/led/gpio-leds.yaml
compatible: "gpio-leds"
child-binding:
description: GPIO LED child node
properties:
gpios:
type: phandle-array
required: true
Nothing unexpected here: Since gpios
is of type phandle-array
, we can use it to refer to any of the board’s GPIO instances. Looking at the above binding you might ask yourself: Except for the property’s name, how do we know which nodes we can refer to in this phandle-array
?
We don’t.
Looking at the Devicetree in isolation, you can, in fact, use references to any node, as long as the node has the matching #gpio-cells
property. You could, e.g., create your own node and binding, where #gpio-cells
doesn’t use pin
and flags
as specifiers. E.g., we could define our own binding custom-cells-a
:
dts/bindings/custom-cells-a.yaml
description: Dummy for matching "cells"
compatible: "custom-cells-a"
gpio-cells:
- name-of-cell-one
- name-of-cell-two
- name-of-cell-three
In our board overlay, we can create a dummy node_a
with the above binding, and overwrite the property gpio
of led0
with a reference to this dummy node:
boards/nrf52840dk_nrf52840.overlay
/ {
chosen {
app-led = &led0;
};
node_a {
compatible = "custom-cells-a";
#gpio-cells = <3>;
};
};
&led0 {
gpios = <&{/node_a} 1 2 3>;
};
If we were to revert our main.c
file to the original dummy application that doesn’t use the gpio
subsystem, the build passes without warnings and we’d find our /leds/led_0
node with the following properties in the merged zephyr.dts
file:
build/zephyr/zephyr.dts
/ {
leds {
compatible = "gpio-leds";
led0: led_0 {
gpios = < &{/node_a} 0x1 0x2 0x3 >;
label = "Green LED 0";
};
};
};
There is no mechanism in Devicetree that allows declaring a phandle-array
which is “generic over a type” or “something GPIO compatible”. E.g., there’s no such thing as a base class, interface, or trait that you might know from programming languages.
Even if there was an annotation phandle-array<T>
, e.g., to specify the required compatible
property, what would we provide for T
? GPIO nodes use different models depending on the vendor, e.g., the following snippets show the compatible
properties of the GPIO nodes of the nRF52840 and STM32:
zephyr/dts/arm/nordic/nrf52840.dtsi
/ {
soc {
gpio0: gpio@50000000 { compatible = "nordic,nrf-gpio"; };
gpio1: gpio@50000300 { compatible = "nordic,nrf-gpio"; };
};
};
zephyr/dts/arm/st/c0/stm32c0.dtsi
/ {
soc {
pinctrl: pin-controller@50000000 {
gpioa: gpio@50000000 { compatible = "st,stm32-gpio"; };
gpiob: gpio@50000400 { compatible = "st,stm32-gpio"; };
gpioc: gpio@50000800 { compatible = "st,stm32-gpio"; };
gpiof: gpio@50001400 { compatible = "st,stm32-gpio"; };
};
};
};
Sure, both “nordic,nrf-gpio” and “st,stm32-gpio” could claim compatibility with some “base binding”, but there’d always be the odd corner case that doesn’t entirely match the model.
By discarding such a requirement, we gain flexibility - at the cost of “loose typing” within the Devicetree. If we were to try and use the Zephyr GPIO API with the above assignment to gpios
, the application would fail to compile since the required specifier cells do not have the names pin
and flags
read using the DT_PHA_BY_IDX[_OR]
macros in the GPIO_DT_SPEC_GET
macro. There’s one more thing within GPIO_DT_SPEC_GET
that is related to this discussion, can you see it?
#define GPIO_DT_SPEC_GET(node_id, prop) \
{ \
.port = DEVICE_DT_GET(/* --snip-- */), \
.pin = DT_PHA_BY_IDX(node_id, prop, /*idx*/0, pin), \
.dt_flags = DT_PHA_BY_IDX_OR(node_id, prop, /*idx*/0, flags, 0), \
}
Looking at dt_flags
, we see that GPIO_DT_SPEC_GET
uses the _OR
variant of the phandle macro DT_PHA_BY_IDX
to read the flags
from the Devicetree:
- For nodes with a compatible binding that have both,
pin
andflags
specifiers, the Devicetree compiler ensures that values are provided for both,pin
andflags
whenever the node is references in aphandle-array
; providing a value forflags
is not optional for such phandles. - For nodes with a compatible binding that do not have the
flags
specifier, the value 0 is used in the specification. The compatible driver can also completely ignore the flags field in any API call.
This adds another level of flexibility to the generic binding gpio-leds
for LEDs, supporting any kind of GPIO node LEDs, regardless of whether or not they support using flags
, e.g., as follows:
/ {
leds {
compatible = "gpio-leds";
led0: led_0 { gpios = <&gpio0 13>; };
};
};
Device objects and driver compatibility
We now know how the .pin
and .dt_flags
fields of the structure are populated, but what about the .port
? Let’s try to find out what the macro expands to.
#define GPIO_DT_SPEC_GET(node_id, prop) \
{ \
.port = DEVICE_DT_GET(DT_GPIO_CTLR_BY_IDX(node_id, prop, /*idx*/0)), \
.pin = DT_PHA_BY_IDX(node_id, prop, /*idx*/0, pin), \
.dt_flags = DT_PHA_BY_IDX_OR(node_id, prop, /*idx*/0, flags, 0), \
}
The macro parameters are given in our application and expand as described in the following snippet:
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(DT_CHOSEN(app_led), gpios);
// Knowing that `DT_CHOSEN(app_led)` expands to `DT_CHOSEN_app_led`,
// we can look up `DT_CHOSEN_app_led` in `devicetree_generated.h`,
// which is defined as `DT_N_S_leds_S_led_0`.
// Thus:
// - node_id = DT_N_S_leds_S_led_0
// - prop = gpios
//
// .port = DEVICE_DT_GET(DT_GPIO_CTLR_BY_IDX(DT_N_S_leds_S_led_0, gpios, /*idx*/0)),
Macrobatics: Resolving device objects with DEVICE_DT_GET
Before resolving macros manually, it is always worth looking into the documentation. We have two nested macros, so it makes sense to check the inner DT_GPIO_CTLR_BY_IDX
first. The API documentation claims that it can be used to “get the node identifier for the controller phandle from a gpio phandle-array property”. For the assignment gpios = <&gpio0 13 GPIO_ACTIVE_LOW>;
in our LED node, we’d thus expect to get the node identifier for the phandle &gpio0
.
Let’s have a look at the macro and its expansion:
// We want to know the expansion of the following macro:
#define DT_GPIO_CTLR_BY_IDX(node_id, gpio_pha, idx) \
DT_PHANDLE_BY_IDX(node_id, gpio_pha, idx)
// Knowing that DT_PHANDLE_BY_IDX is defined as follows:
#define DT_PHANDLE_BY_IDX(node_id, prop, idx) \
DT_CAT6(node_id, _P_, prop, _IDX_, idx, _PH)
// Given:
// node_id = DT_N_S_leds_S_led_0
// prop = gpios
// idx = 0
// DT_GPIO_CTLR_BY_IDX
// = DT_N_S_leds_S_led_0 ## _P_ ## gpios ## _IDX_ ## 0 ## _PH
// = DT_N_S_leds_S_led_0_P_gpios_IDX_0_PH
Thus, DT_GPIO_CTLR_BY_IDX
resolves to DT_N_S_leds_S_led_0_P_gpios_IDX_0_PH
. We can find a macro for that token in devicetree_generated.h
, which indeed resolves to the GPIO node’s identifier DT_N_S_soc_S_gpio_50000000
:
$ grep DT_N_S_leds_S_led_0_P_gpios_IDX_0_PH ../build/zephyr/include/generated/devicetree_generated.h
#define DT_N_S_leds_S_led_0_P_gpios_IDX_0_PH DT_N_S_soc_S_gpio_50000000
On we go! DEVICE_DT_GET
takes this node identifier as its parameter node_id
.
// Original macro from zephyr/include/zephyr/device.h
#define DEVICE_DT_GET(node_id) (&DEVICE_DT_NAME_GET(node_id))
What can the documentation tell us about this macro?
Returns a pointer to a device object created from a Devicetree node, if any device was allocated by a driver. If no such device was allocated, this will fail at linker time [with an error like
undefined reference to __device_dts_ord_<N>
]
Since we don’t know yet what “device objects” are, this is a bit cryptic. There is, however, one good hint in the linker error message: It seems like this macro is trying to provide a reference to a symbol called __device_dts_ord_<N>
, where N
is a number.
Without the need for macrobatics on your side, your editor of choice should be able to resolve this macro. The following is a screenshot from vscode
, where the last line indeed shows that - for my application - .port
is assigned a reference to the symbol __device_dts_ord_11
:
We could be satisfied with this resolution, but we’re not, are we? Aren’t you curious how we somehow get from a node identifier to this weird symbol with the ordinal 11? Well, I am so it’s time for some more macrobatics!
In the following snippet we replace referenced macros step by step without showing their definition, and thus - to some extent - perform the same expansion as the preprocessor. Since the steps are not exactly the same, however, we’re calling them “replacements” and not “expansions”:
// Original macro from zephyr/include/zephyr/device.h
#define DEVICE_DT_GET(node_id) (&DEVICE_DT_NAME_GET(node_id))
// 1st replacement: Given DEVICE_DT_NAME_GET = DEVICE_NAME_GET(Z_DEVICE_DT_DEV_ID(node_id))
#define DEVICE_DT_GET_1st(node_id) (&DEVICE_NAME_GET(Z_DEVICE_DT_DEV_ID(node_id)))
// 2nd replacement: Given DEVICE_NAME_GET = _CONCAT(__device_, dev_id)
#define DEVICE_DT_GET_2nd(node_id) (&_CONCAT(__device_, ,Z_DEVICE_DT_DEV_ID(node_id)))
// 3rd replacement: Z_DEVICE_DT_DEV_ID = _CONCAT(dts_ord_, DT_DEP_ORD(node_id))
#define DEVICE_DT_GET_3rd(node_id) (&_CONCAT(__device_, _CONCAT(dts_ord_, DT_DEP_ORD(node_id))))
// 4th replacement: DT_DEP_ORD = DT_CAT(node_id, _ORD)
#define DEVICE_DT_GET_4th(node_id) (&_CONCAT(__device_, _CONCAT(dts_ord_, DT_CAT(node_id, _ORD))))
Note: In case you’re wondering what the difference between
_CONCAT
andDT_CAT
is, experiment with token pasting. Hint: One of the macros simply pastes tokens, whereas the other one also expands the pasted token.
It’s easy enough to see how we end up with the prefix __device_dts_ord_
, but it is not entirely clear yet how we get to the ordinal 11. For this, we still need to resolve DT_CAT(node_id, _ORD)
. Remembering that we already resolved our input parameter node_id
to DT_N_S_soc_S_gpio_50000000
, DT_CAT
simply pastes the two tokens, and we end up with DT_N_S_soc_S_gpio_50000000_ORD
. But what’s that? This is a macro that is again provided by Zephyr’s Devicetree generator:
$ grep DT_N_S_soc_S_gpio_50000000_ORD ../build/zephyr/include/generated/devicetree_generated.h
#define DT_N_S_soc_S_gpio_50000000_ORD 11
In simple words, Zephyr’s Devicetree generator assigns ordinals to device instances. This is a fundamental concept of Zephyr’s device API zephyr/include/zephyr/device.h
, which is used to represent devices and their instances. Since this is a rather elaborate concept that goes beyond the scope of this article series, I’ll leave you with a link to Zephyr’s official documentation about the device driver model and instance-based APIs.
Note: You might ask yourself why we even need ordinals if Devicetree nodes are unique. One simple reason is that nodes can support multiple drivers, e.g., an IC might support SPI as well as I2C, and therefore two instances exist for a single node.
For now, it is enough to know that Zephyr creates symbols for each device instance in the Devicetree. In fact, in devicetree_generated.h
we can find a list of the node ordering and thus ordinals of each instance at the very beginning of the file. Here, we find the ordinal 11 for the device of the GPIO node /soc/gpio@50000000
, and also the ordinal 104 of the device for our second GPIO node /soc/gpio@50000300
:
build/zephyr/include/generated/devicetree_generated.h
/*
* Generated by gen_defines.py
*
* DTS input file:
* /path/to/build/zephyr/zephyr.dts.pre
*
* Directories with bindings:
* /opt/nordic/ncs/v2.4.0/nrf/dts/bindings, /path/to/dts/bindings, $ZEPHYR_BASE/dts/bindings
*
* Node dependency ordering (ordinal and path):
* 0 /
* 1 /aliases
* 2 /analog-connector
* --snip--
* 11 /soc/gpio@50000000
* --snip--
* 104 /soc/gpio@50000300
* --snip--
*/
// --snip--
#define DT_N_S_soc_S_gpio_50000300_ORD 104
// --snip--
#define DT_N_S_soc_S_gpio_50000000_ORD 11
Now we can finalize the macro expansion of DEVICE_DT_GET
:
// given `DT_N_S_soc_S_gpio_50000000` for `node_id`:
// DT_CAT(node_id, _ORD)) resolves to `DT_N_S_soc_S_gpio_50000000_ORD`
#define DEVICE_DT_GET_4th(node_id) (&_CONCAT(__device_, _CONCAT(dts_ord_, DT_CAT(node_id, _ORD))))
#define DEVICE_DT_GET_x_1st() (&_CONCAT(__device_, _CONCAT(dts_ord_, DT_N_S_soc_S_gpio_50000000_ORD)))
#define DEVICE_DT_GET_x_2nd() (&_CONCAT(__device_, dts_ord_11))
#define DEVICE_DT_GET_x_3rd() (&__device_dts_ord_11)
With this, we finally pieced together the complete assignment for our LED’s GPIO specification:
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED_NODE, gpios);
// = {
// .port = (&__device_dts_ord_11),
// .pin = 13 /* DT_N_S_leds_S_led_0_P_gpios_IDX_0_VAL_pin */,
// .dt_flags = 1 /* DT_N_S_leds_S_led_0_P_gpios_IDX_0_VAL_flags */,
// }
That’s all good and well, but where is this symbol __device_dts_ord_11
coming from? With all this macro magic, we can imagine that it is very unlikely that a search for __device
within Zephyr’s codebase provides any valuable hint about the symbol’s location. Instead, we can use a much more reliable method: Let’s look for the symbol in the .map
file:
$ grep -sw __device_dts_ord_11 ../build/zephyr/zephyr.map -A 1 -B 3
.z_device_PRE_KERNEL_140_
0x0000000000006550 0x30 zephyr/drivers/gpio/libdrivers__gpio.a(gpio_nrfx.c.obj)
0x0000000000006550 __device_dts_ord_104
0x0000000000006568 __device_dts_ord_11
.z_device_PRE_KERNEL_155_
Note: Your
grep
mileage may vary, I’ve just added-A 1
and-B 3
since I know that, in my application, the corresponding object and all the remaining instances are visible if I include one line after (-A 1
) and three lines before (-B 3
) the occurrence.
We found it! It seems to be declared in Nordic’s GPIO driver zephyr/drivers/gpio/gpio_nrfx.c
.
Macrobatics: Declaring compatilble drivers and device object
I promised that we wouldn’t go into detail about Zephyr’s device driver model - and we won’t. In this section, we’ll only look at how the device instances are defined and how the connection with the nodes in the Devicetree is established.
The two responsible parts within gpio_nrfx.c
for defining the instances are the following:
#define DT_DRV_COMPAT nordic_nrf_gpio
/* --snip-- the entire driver implementation */
#define GPIO_NRF_DEVICE(id) \
/* --snip-- */ \
DEVICE_DT_INST_DEFINE(id, gpio_nrfx_init, \
/* --snip-- */ \
&gpio_nrfx_drv_api_funcs);
DT_INST_FOREACH_STATUS_OKAY(GPIO_NRF_DEVICE)
The macro definition DT_DRV_COMPAT
is placed at the beginning of the file. It is the device driver’s equivalent to the compatible
property of Devicetree nodes and the same-named key in the Devicetree binding: It is defined to the “lowercase-and-underscore” form nordic_nrf_gpio
of nordic,nrf-gpio
.
At the end of the file, the device driver creates instances for all GPIO devices with the status
property set to “okay” using the macro DT_INST_FOREACH_STATUS_OKAY
. And that’s where somehow the “device objects” __device_dts_ord_104
and __device_dts_ord_11
of the corresponding GPIO nodes are created, pretty likely through DEVICE_DT_INST_DEFINE
.
Let’s dissect these macros one by one, starting with DT_INST_FOREACH_STATUS_OKAY
, which is more concise but also more complex:
#define DT_INST_FOREACH_STATUS_OKAY(fn) \
COND_CODE_1(DT_HAS_COMPAT_STATUS_OKAY(DT_DRV_COMPAT), \
(UTIL_CAT(DT_FOREACH_OKAY_INST_, DT_DRV_COMPAT)(fn)), \
() \
)
For the expansion of COND_CODE_1
we rely on the docs, which state that this macro “insert[s] code depending on whether _flag expands to 1 or not.”. The first parameter is the _flag, the second parameter is the code in case _flag expands to 1, and finally, the third parameter is the code that is used in case the _flag is not 1 or undefined.
To get to the flag, we need to check DT_HAS_COMPAT_STATUS_OKAY(DT_DRV_COMPAT)
:
#define DT_HAS_COMPAT_STATUS_OKAY(compat) \
IS_ENABLED(DT_CAT(DT_COMPAT_HAS_OKAY_, compat))
We’ve seen the IS_ENABLED
macro already: It is a more intricate macro that allows evaluating tokens even if they are not even defined. Since we know that in our case compat
is nordic_nrf_gpio
, we’re looking for a macro DT_COMPAT_HAS_OKAY_nordic_nrf_gpio
, which, as usual, we can find in devicetree_generated.h
:
grep -w DT_COMPAT_HAS_OKAY_nordic_nrf_gpio ../build/zephyr/include/generated/devicetree_generated.h
#define DT_COMPAT_HAS_OKAY_nordic_nrf_gpio 1
Zephyr’s Devicetree generator creates a lot more information than just macros for the property values of our Devicetree nodes! Here, we see that the generator also provides macros indicating whether it encountered some nodes that claim compatibility with “nordic,nrf-gpio
”. If it doesn’t encounter any such node, it won’t create the corresponding macros and we’d know that the content of any driver compatible with “nordic,nrf-gpio
” is unused and can thus be discarded. Pretty neat!
The Devicetree generator does this for each and every driver and node. This allows heavy optimizations in the codebase as we’ve seen above.
Now we found out that our driver is used, and thus the second parameter of COND_CODE_1
is expanded. We’ve already seen that UTIL_CAT
concatenates and expands the provided macros, which are DT_FOREACH_OKAY_INST_
and our DT_DRV_COMPAT
macro, which expands to nordic_nrf_gpio
. Thus, we’re now looking for a macro DT_FOREACH_OKAY_INST_nordic_nrf_gpio
:
grep -w DT_FOREACH_OKAY_INST_nordic_nrf_gpio ../build/zephyr/include/generated/devicetree_generated.h
#define DT_FOREACH_OKAY_INST_nordic_nrf_gpio(fn) fn(0) fn(1)
Also here Zephyr’s Devicetree generator creates another useful macro based on the number of devices in the Devicetree claiming compatibility with the nordic,nrf-gpio
device driver. For each device it encounters it adds a fn(n)
to the macro, where n
is the instance number of the device, one for each of our GPIO nodes. Given this macro, we now know the complete expansion of the DT_INST_FOREACH_STATUS_OKAY
macro:
// `DT_INST_FOREACH_STATUS_OKAY(GPIO_NRF_DEVICE)`
// given DT_DRV_COMPAT = nordic_nrf_gpio
// expands to:
GPIO_NRF_DEVICE(0) GPIO_NRF_DEVICE(1)
The above macros are expanded for the corresponding device instance number, but we still don’t know how to associate the instance number with the ordinals, which are 11 and 104 for our GPIO devices. So far, we can only establish a connection to the nodes /soc/gpio@50000000
and /soc/gpio@50000300
via the ordinals.
This leads us to the expansion of DEVICE_DT_INST_DEFINE
used by GPIO_NRF_DEVICE
:
#define GPIO_NRF_DEVICE(id) \
/* --snip-- */ \
DEVICE_DT_INST_DEFINE(id, gpio_nrfx_init, \
/* --snip-- */ \
&gpio_nrfx_drv_api_funcs);
// 1st replacement:
// DEVICE_DT_INST_DEFINE(inst, ...) =
// DEVICE_DT_DEFINE(DT_DRV_INST(inst), __VA_ARGS__)
#define GPIO_NRF_DEVICE_1st(id) \
/* --snip-- */ \
DEVICE_DT_DEFINE(DT_DRV_INST(id), gpio_nrfx_init, \
/* --snip-- */ \
&gpio_nrfx_drv_api_funcs);
The documentation of DT_DRV_INST
states that it is used to get the “node identifier for an instance of a DT_DRV_COMPAT
compatible”. That’s exactly what we’d expect, but we want to know how this is done: How do we match a node (identifier) to a device instance number? Looks like we’ll have to do a quick macro expansion for DT_DRV_INST
as well:
// Expansion of DT_DRV_INST, given
// DT_INST(inst, compat) = UTIL_CAT(DT_N_INST, DT_DASH(inst, compat))
// and DT_DASH pastes underscores before and between the arguments.
#define DT_DRV_INST_1st(inst) DT_INST(inst, DT_DRV_COMPAT)
#define DT_DRV_INST_2nd(inst) UTIL_CAT(DT_N_INST, DT_DASH(inst, DT_DRV_COMPAT))
#define DT_DRV_INST_3rd(inst) UTIL_CAT(DT_N_INST, _ ## inst ## DT_DRV_COMPAT)
// Given:
// - DT_DRV_COMPAT = nordic_nrf_gpio
// - inst = 0 (and analog for 1, from the expansion of DT_INST_FOREACH_STATUS_OKAY)
#define DT_DRV_INST_0_1st UTIL_CAT(DT_N_INST, DT_DASH(0, nordic_nrf_gpio))
#define DT_DRV_INST_0_2nd UTIL_CAT(DT_N_INST, _0_nordic_nrf_gpio)
#define DT_DRV_INST_0_3rd DT_N_INST_0_nordic_nrf_gpio
// and DT_DRV_INST_1_3rd DT_N_INST_1_nordic_nrf_gpio ... for inst = 1
$ grep -w DT_N_INST_0_nordic_nrf_gpio ../build/zephyr/include/generated/devicetree_generated.h
#define DT_N_INST_0_nordic_nrf_gpio DT_N_S_soc_S_gpio_50000000
$ grep -w DT_N_INST_1_nordic_nrf_gpio ../build/zephyr/include/generated/devicetree_generated.h
#define DT_N_INST_1_nordic_nrf_gpio DT_N_S_soc_S_gpio_50000300
Yet again Zephyr’s Devicetree generator provides the necessary macros to map the instance number inst
to the matching node that claims compatibility with a given device driver:
-
/soc/gpio@50000000
is instance 0, and -
/soc/gpio@50000300
is instance 1 of the drivernordic,nrf-gpio
.
This is it! We now have the connection from our instance number to node identifier and thus the Devicetree node. We’ve already seen how we can get from the node identifier to the “device object’s” name, and can therefore apply that knowledge to declare the symbol.
Through several expansions, the device macro DEVICE_DT_DEFINE
effectively does exactly that: It declares a global constant using the name __device_dts_ord_<N>
, where N
is the ordinal it obtains using the node identifier of the corresponding instance. The following is an oversimplified definition for the macro DEVICE_DT_DEFINE
:
// Simplified definition of DEVICE_DT_DEFINE for the device object:
#define DEVICE_DT_DEFINE_simplified(node_id, ...) \
const struct device DEVICE_NAME_GET(Z_DEVICE_DT_DEV_ID(node_id)) = \
Z_DEVICE_INIT(/* --snip-- */)
// Remember the expansion of DEVICE_NAME_GET(Z_DEVICE_DT_DEV_ID(node_id)
// in the expansion for DEVICE_DT_GET that we've seen before.
// For node_id = DT_N_S_soc_S_gpio_50000000 ->
// const struct device __device_dts_ord_11 = ...
// For node_id = DT_N_S_soc_S_gpio_50000300 ->
// const struct device __device_dts_ord_104 = ...
In practice, Zephyr also creates other symbols that are used to populate the device object. This device object contains, e.g., a function table for the entire GPIO API, such that a call to a gpio
function leads to the correct call of the vendor’s function. E.g., a call to gpio_pin_toggle_dt
for a GPIO that is compatible with nordic,nrf-gpio
eventually leads to a call to gpio_nrfx_port_toggle_bits
.
For details, however, you’ll need to refer to Zephyr’s official documentation about the device driver model and instance-based APIs, or watch the tutorial “Mastering Zephyr Driver Development” by Gerard Marull Paretas from the Zephyr Development Summit 2022.
There’s also a blog post on this Interrupt blog about building drivers on Zephyr.
Summary
We now not only know how the GPIO subsystem uses the macros from devicetree.h
to get the specifiers for gpio
properties of type phandle-array
, but we also know what the device objects are and how they’re resolved:
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED_NODE, gpios);
// = {
// .port = (&__device_dts_ord_11),
// .pin = 13 /* DT_N_S_leds_S_led_0_P_gpios_IDX_0_VAL_pin */,
// .dt_flags = 1 /* DT_N_S_leds_S_led_0_P_gpios_IDX_0_VAL_flags */,
// }
Zephyr’s Devicetree generator provides much more than just macros for accessing property values in the Devicetree. It also creates the necessary macros to associate driver instances with nodes, and to create the corresponding instances in the device driver itself. And then some …
The status
property
One important property that we’ve touched already is the status
property. While our LED node has no such property, the referenced GPIO node in its gpios
property does. The nodes are first defined in the MCU’s DTS file with the status
property set to "disabled"
:
zephyr/dts/arm/nordic/nrf52840.dtsi
/ {
soc {
gpio0: gpio@50000000 { /* --snip-- */ status = "disabled"; port = <0>; };
gpio1: gpio@50000300 { /* --snip-- */ status = "disabled"; port = <1>; };
};
};
In the nRF52840 development kit’s DTS file, the status
property is overwritten with "okay"
:
zephyr/boards/arm/nrf52840dk_nrf52840/nrf52840dk_nrf52840.dts
&gpio0 { status = "okay"; };
&gpio1 { status = "okay"; };
Zephyr’s introduction to Devicetree explains the status
property in a dedicated section for important Devicetree properties as follows:
“A node is considered enabled if its
status
property is either"okay"
or not defined (i.e., does not exist in the Devicetree source). Nodes withstatus
"disabled"
are explicitly disabled. […] Devicetree nodes which correspond to physical devices must be enabled for the correspondingstruct
device in the Zephyr driver model to be allocated and initialized.”
This matches with what we’ve seen for the DT_INST_FOREACH_STATUS_OKAY
macro in gpio_nrfx.c
: Instances are only created for each node with the status
set to "okay"
. We can disable the node by setting the status
to "disabled"
in our application’s board overlay file:
&gpio0 {
status = "disabled";
};
Trying to recompile the project leads to the linker error that we’ve seen in the documentation of DEVICE_NAME_GET
:
/opt/nordic/ncs/v2.4.0/zephyr/include/zephyr/device.h:84:41: error:
'__device_dts_ord_11' undeclared here (not in a function);
did you mean '__device_dts_ord_15'?
84 | #define DEVICE_NAME_GET(dev_id) _CONCAT(__device_, dev_id)
| ^~~~~~~~~
The matching device object has not been allocated in gpio_nrfx.c
and thus the linker can’t resolve the symbol __device_dts_ord_11
. For demonstration purposes (this is not typically done in an application) we could transform this into a compiler error by using the devicetree.h
API as follows:
#if DT_NODE_HAS_STATUS(DT_PHANDLE(LED_NODE, gpios), okay)
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED_NODE, gpios);
#else
#error "The status of the GPIO referenced by the LED node is not 'okay'"
#endif
Discard the changes to the board’s overlay and remove the conditional compilation in the sources. The documentation also mentions, that the status
property is implicitly added with the value "okay"
for nodes that do not define the property in the Devicetree. As we can see in the merged zephyr.dts
file in the build folder, our /leds/led_0
node doesn’t have the status
property:
build/zephyr/zephyr.dts
/ {
leds {
compatible = "gpio-leds";
led0: led_0 {
gpios = < &gpio0 0xd 0x1 >;
label = "Green LED 0";
};
};
};
Instead of looking into devicetree_generated.h
, we can also check that the node is indeed implicitly assigned the status
with the value "okay"
using the following compile time switch and macros from devicetree.h
:
#if !DT_NODE_HAS_STATUS(LED_NODE, okay)
#error "LED node status is not okay." /* `status` is added implicitly. */
#endif
Rebuilding the application works without any warnings or errors and the LED continues blinking happily. You’ll find such conditional compilation switches in many of Zephyr’s source files. How the status
property is used varies between the corresponding subsystems.
What’s the use of the status
, then? You can and should use status
to disable all nodes that you don’t need. This typically leads to a (slight) reduction of code size but is especially important for low-power applications to reduce power consumption by disabling unused peripherals.
Note: In case you’re wondering what the difference between the
status
property and theis_ready_dt
function call is - you’re not alone, so let’s clarify this briefly. Thestatus
property is used to remove instances altogether, whereas theis_ready_dt
ensures that the driver is ready to be used. You can’t callis_ready_dt
with a specification for a disabled node - as we’ve seen, the compilation or linking fails entirely for disabled nodes.
Intermezzo: Power profiling
Let’s apply what we’ve just learned and observe the MCU’s current consumption with and without disabling unused nodes. To do that, I’ll be using Nordic’s Power Profiler Kit. You can, of course, use any clamp meter or other measurement hardware to verify the current consumption.
Without disabling unused nodes, I’m observing a current consumption in the application’s sleep cycles of around 550 uA. The screenshot below shows the measurement for an interval of 3 seconds, where the y-axis measures the current consumption truncated to a range of 0 .. 5 mA. You should still be able to make out the three peaks in the current consumption where the MCU wakes up to toggle the LED.
How can we find out which nodes to disable?
Zephyr’s board DTS files typically enable plenty of nodes, mostly for you to be able to run the samples without having to use an overlay to enable the required nodes. For a custom board, this might not be the case, meaning nodes could be disabled by default and you might have to enable nodes before using them. In the end, you should be well aware of the peripherals and thus nodes that you need, and should thus simply know which nodes can be disabled.
In case of doubt another good location to look for a node’s status
is the zephyr.dts
in the build directory. I’ve picked the following nodes from there and disabled them in the board’s overlay:
boards/nrf52840dk_nrf52840.overlay
&adc {status = "disabled"; };
&i2c0 {status = "disabled"; };
&i2c1 {status = "disabled"; };
&pwm0 {status = "disabled"; };
&spi0 {status = "disabled"; };
&spi1 {status = "disabled"; };
&spi3 {status = "disabled"; };
&usbd {status = "disabled"; };
&nfct {status = "disabled"; };
&temp {status = "disabled"; };
&radio {status = "disabled"; };
&uart0 {status = "disabled"; };
&uart1 {status = "disabled"; };
&gpiote {status = "disabled"; };
Note: We’re also disabling
uart0
and therefore the/chosen
node for our console output - the boot banner*** Booting Zephyr OS build v3.3.99-ncs1 ***
will no longer be output.
After rebuilding and flashing the application, I can perform the same measurement again and I indeed got a very different result, as you can see in the below screenshot:
Notice that the scale of the y-axis changed from 0 .. 5 mA to 0 .. 20 uA, that’s a factor 250 smaller! This is also visible in the MCU’s current consumption in the sleep phase, which dropped from 550 uA to an average of 7 uA.
It is quite convenient and easy enough to disable unused peripherals. If you think that power management is Zephyr is as simple as that, however, I’ll have to disappoint you: This is really just a way to disable peripherals that are not used at all. If we’d, e.g., need the UART while the device is not in sleep mode, we’re looking at an entirely different mechanism. We’ll only scratch the surface of power management in Zephyr when we have a look at pin control, but for details, you’ll need to dive into the official documentation.
Comparing the memory usage of the build with and without optimized node status, we can also see a slight gain in the flash memory consumption:
# west build with all nodes "okay"
Memory region Used Size Region Size %age Used
FLASH: 27808 B 1 MB 2.65%
RAM: 7552 B 256 KB 2.88%
IDT_LIST: 0 GB 2 KB 0.00%
# west build with optimized node status
Memory region Used Size Region Size %age Used
FLASH: 25352 B 1 MB 2.42%
RAM: 7552 B 256 KB 2.88%
IDT_LIST: 0 GB 2 KB 0.00%
Device driver function calls
Even though we still won’t write an instance-based device driver in this article series it is worth quickly (and this time I mean really quickly) reviewing how Zephyr’s API function calls map to the function tables provided by the device drivers.
Let’s first have a look at an overly simplified Devicetree driver function call tree for gpio_pin_configure_dt
, which looks approximately as follows:
gpio_pin_configure_dt(
│ spec, extra_flags)
└── gpio_pin_configure(
│ spec->port, spec->pin, spec->dt_flags | extra_flags)
└── z_impl_gpio_pin_configure(
│ spec->port, spec->pin, spec->dt_flags | extra_flags)
└── spec->port->api->pin_configure(
spec->port, spec->pin, flags)
A call to gpio_pin_configure_dt
thus maps to the function call spec->port->api->pin_configure
. When we dissected the GPIO pin information type, we’ve already seen that spec->port
is assigned the device object __device_dts_ord_<N>
. Knowing already that our device object is created in zephyr/drivers/gpio/gpio_nrfx.c
, we can immediately see that the API function table is the last parameter passed to the macro DEVICE_DT_INST_DEFINE
:
zephyr/drivers/gpio/gpio_nrfx.c
static const struct gpio_driver_api gpio_nrfx_drv_api_funcs = {
.pin_configure = gpio_nrfx_pin_configure,
// --snip--
};
// --snip--
#define GPIO_NRF_DEVICE(id) \
/* --snip-- */ \
DEVICE_DT_INST_DEFINE(id, gpio_nrfx_init, \
/* --snip-- */ \
&gpio_nrfx_drv_api_funcs);
DT_INST_FOREACH_STATUS_OKAY(GPIO_NRF_DEVICE)
That’s it, as promised I was quick about that one. That’s the magic behind the device driver abstractions in Zephyr. With that, you’ll have an easy time diving into the device driver model and instance-based APIs.
Dissecting UART peripheral pin multiplexing
With &led0
we’ve seen that it’s quite straightforward to use GPIOs in Zephyr. Changing the GPIO pin, e.g., of an LED node, can be as easy as changing the corresponding phandle’s pin specifier. We’ll now look at how pin assignments work for peripherals. For that, we’ll use our old friend UART.
We’ve learned that UART is used for the console output and thus for printk
, so let’s use some printk
calls to make use of it. We’ll output "tick"
each time the LED is turned on, and "tock"
each time it is turned off:
void main(void)
{
int err = 0;
bool tick = true;
if (!gpio_is_ready_dt(&led))
{
printk("Error: LED pin is not available.\n");
return;
}
err = gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
if (err != 0)
{
printk("Error %d: failed to configure LED pin.\n", err);
return;
}
while (1)
{
(void) gpio_pin_toggle_dt(&led);
k_msleep(SLEEP_TIME_MS);
if (tick != false) { printk("Tick\n"); }
else { printk("Tock\n"); }
tick = !tick;
}
}
Note: If you’ve been following along, make sure to set the UART node’s status back to
"okay"
in case you’ve disabled it in the last section.
Peripheral pin control in Zephyr
To see how pins are assigned to our UART peripheral, we need to look into our board’s DTS file. There, we find pinctrl-<x>
properties for the &uart0
node:
zephyr/boards/arm/nrf52840dk_nrf52840/nrf52840dk_nrf52840.dts
&uart0 {
compatible = "nordic,nrf-uarte";
status = "okay";
current-speed = <115200>;
pinctrl-0 = <&uart0_default>;
pinctrl-1 = <&uart0_sleep>;
pinctrl-names = "default", "sleep";
};
Before we look at how pinctrl
works, you might ask yourself why we need another mechanism when we have gpios
, and what the difference between the two is. Couldn’t we just use gpios
and, e.g., pass phandles for all the pins we need for our UART interface?
For some MCUs, like Nordic’s nRF series, this might work since pin multiplexing for such MCUs is not restricted: On Nordic’s MCUs it is possible to assign any functionality to any pin. Other MCUs (e.g., STM32) typically restrict this and clearly define possible alternate pin functions for all pins.
MCU manufacturers can be quite creative when it comes to pin multiplexing, and before Zephyr 3.0, pin multiplexing was entirely vendor-specific. Zephyr 3.0 adopted the Linux pinctl
concept as a standardized way of assigning peripheral functions to pins in Devicetree - and called it pinctrl
.
There is, of course, still an overlap between gpios
and pinctrl
since both associate nodes with pins and their parameters (e.g., pull resistors). As a rule of thumb, pinctrl
is generally used if the pin goes to a peripheral in the /soc
node, whereas gpios
are used if the pin is used directly by the application.
Pin control concepts
Zephyr’s official documentation distinguishes between two general concepts: Distributed and centralized pin control. The difference between the two concepts is essentially defined by which device decides about possible pin assignments, as we’ve seen before:
Distributed pin control
Some MCUs, such as the nRF series from Nordic, do not restrict pin assignments, and the pin assignment is defined by each peripheral. E.g., the UART node selects whichever pins it uses for RXD, TXD, CTS, and RTS. The pin control is thus distributed across all peripherals; there is no centralized pin multiplexer.
Centralized pin control
Other MCUs, such as the STM32, do not allow an arbitrary pin assignment. Instead, pins support alternative functions, managed by a centralized pin multiplexer which assigns the pin to a certain peripheral.
Devicetree approach
Whether an MCU uses centralized or distributed pin control does not necessarily have an impact on how the pin control is reflected in the Devicetree. Zephyr supports two approaches in Devicetree for pin multiplexing: Grouped and node approach.
-
In the node approach the vendor provides a DTS file containing dedicated nodes for all pins and the supported alternative functions. The provided nodes are referenced unmodified by the
pinctrl
properties of the peripheral. This is mostly used for MCUs with centralized pin control and fixed alternative functions and we’ll see a practical example when browsing the STM32 Devicetree source files. -
In the grouped approach the vendor’s Devicetree sources do not provide nodes for all possible combinations. Instead, nodes containing the pin configuration are created either for the board or by the application, and pins are grouped by their configuration (e.g., pull resistors), thus the name “grouped”. The pin multiplexing may or may not be restricted and thus this approach is for both, distributed and centralized pin multiplexing. We’ll see this when we browse the Nordic Devicetree source files in the next section.
Neither concept or approach is “better or worse”, it is just what’s implemented by hardware. As mentioned, MCU manufacturers can be very, very creative, and thus pin control will probably always remain vendor-specific.
Basics and grouped pin control with the nRF52840
Let’s bring up the &uart0
node in the nRF52840 development kit’s DTS file to save you some scrolling:
Pin control basics
zephyr/boards/arm/nrf52840dk_nrf52840/nrf52840dk_nrf52840.dts
&uart0 {
compatible = "nordic,nrf-uarte";
status = "okay";
current-speed = <115200>;
pinctrl-0 = <&uart0_default>;
pinctrl-1 = <&uart0_sleep>;
pinctrl-names = "default", "sleep";
};
The first thing we notice are the two properties pinctrl-0
and pinctrl-1
. The property names don’t really give a hint about their semantics. You could, of course, look at the matching binding zephyr/dts/bindings/pinctrl/pinctrl-device.yaml
, but in this case, the meaning of these properties is better described in Zephyr’s official documentation about pin control:
Each device can have multiple pin controller states. The names of the supported states are listed in the pinctrl-names
property, in this case "default"
and "sleep"
. Both states are standard states defined by Zephyr for when the device is operational and in its sleep mode (if supported).
Whichever states are supported by a device is defined by its driver. Therefore, you can’t, e.g., simply add another pinctrl-2
and add a new name to pinctrl-names
in an overlay and expect your application to compile or work. The device driver must support multiple states for you to be able to use them.
Note: We won’t go into detail about how a device driver switches modes or dynamic pin control in this article. Also, the
"sleep"
mode is described in more detail in Zephyr’s official documentation about the power management OS service. E.g., the"sleep"
mode can be disabled using the Kconfig symbolPM_DEVICE
. For now, it is enough to know that such states exist and how we can configure them.
As the names of the phandles in the pinctrl-<n>
suggest, pinctrl-<n>
is the pin control configuration for device state with index n
in the property pinctrl-names
: pinctrl-0
is used for the operational state "default"
, and pinctrl-1
is used for the sleep state "sleep"
.
Except for a list of pre-defined properties that we’ll see in just a bit, this is it for the standardized part of pin control! The structure of the referenced nodes is entirely vendor-specific, though vendors typically at least either follow the grouping or node approaches that we’ve mentioned before.
Grouped pin control
We can find the referenced nodes &uart0_default
and &uart0_sleep
in the matching -pinctrl.dtsi
DTS include file of the board:
zephyr/boards/arm/nrf52840dk_nrf52840/nrf52840dk_nrf52840-pinctrl.dtsi
&pinctrl {
uart0_default: uart0_default {
group1 {
psels = <NRF_PSEL(UART_TX, 0, 6)>, <NRF_PSEL(UART_RTS, 0, 5)>;
};
group2 {
psels = <NRF_PSEL(UART_RX, 0, 8)>, <NRF_PSEL(UART_CTS, 0, 7)>;
bias-pull-up;
};
};
uart0_sleep: uart0_sleep {
group1 {
psels =
<NRF_PSEL(UART_TX, 0, 6)>, <NRF_PSEL(UART_RX, 0, 8)>,
<NRF_PSEL(UART_RTS, 0, 5)>, <NRF_PSEL(UART_CTS, 0, 7)>;
low-power-enable;
};
};
};
The above is an example of the grouping approach for pin control:
Pins with common properties are grouped into child nodes with the name group<m>
. E.g., the pins configured in /pinctrl/uart0_default/group2
all use the bias-pull-up
property to indicate that pull-up resistors are enabled for all pins in psels
. Notice that the index m
has nothing to do with the corresponding pinctrl-<n>
properties of the peripheral node; it is simply used as group index. All groups combined contain the complete pin configuration for the state where they’re referenced, e.g., “default” or “sleep”.
Note: Naming child nodes of
pinctrl
using the formatgroup<m>
is only a convention for the grouped approach. In fact, for most drivers that follow this grouped approach the actual node names within thepinctrl
node is irrelevant: The drivers simply iterate over all child nodes regardless of their names.
Within each group, Nordic uses a vendor-specific format and thus also vendor-specific binding. We can find the referenced node &pinctrl
in the included nrf_common.dtsi
file, where the compatible binding is defined:
zephyr/dts/arm/nordic/nrf_common.dtsi
/ {
pinctrl: pin-controller {
compatible = "nordic,nrf-pinctrl";
};
};
Within the nordic,nrf-pinctrl.yaml
binding, we find a child-binding
property to define the child nodes of nordic,nrf-pinctrl
:
zephyr/dts/bindings/pinctrl/nordic,nrf-pinctrl.yaml
compatible: "nordic,nrf-pinctrl"
include: base.yaml
child-binding:
child-binding:
include:
- name: pincfg-node.yaml
property-allowlist:
- bias-disable
- bias-pull-down
- bias-pull-up
- low-power-enable
properties:
psels:
required: true
type: array
description: |
An array of pins sharing the same group properties. The pins should
be defined using the NRF_PSEL utility macro that encodes the port,
pin and function. NRF_PSEL_DISCONNECTED is also available to explicitly
disconnect a pin.
# --snip-- more nordic-specific properties
The property psels
is specific to Nordic MCUs and - as documented - you’re supposed to use the NRF_PSEL
macro to create an entry in the psels
array for all pins that you’re using. This macro (defined in zephyr/include/zephyr/dt-bindings/pinctrl/nrf-pinctrl.h
) takes the pin function, port, and pin as a parameter.
Other vendors use an entirely different set of properties and macros for their Devicetree nodes and values. E.g., Espressif uses the property pinmux
instead of psels
for the ESP32, as specified by espressif,esp32-pinctrl.yaml
:
zephyr/boards/xtensa/esp_wrover_kit/esp_wrover_kit-pinctrl.dtsi
&pinctrl {
uart0_default: uart0_default {
group1 {
pinmux = <UART0_TX_GPIO1>;
};
group2 {
pinmux = <UART0_RX_GPIO3>;
bias-pull-up;
};
};
};
ESP32 provides a fixed set of macros for their bindings as thoroughly documented in their pinctrl
index page: On the ESP32 a particular I/O pin does not necessarily have a certain pin function. Instead of providing a parameterized macro, the fixed macros UART0_TX_GPIO1
and UART0_RX_GPIO3
are used to configure the GPIO and function for the corresponding pins.
But let’s get back to our &uart0
node on the nRF52840 development kit. We’ve now seen psels
, but what is this pincfg-node.yaml
include?
Standard pin control properties
The binding pincfg-node.yaml
contains standardized pin properties that should, in turn, be used by a vendor’s pinctrl
. E.g., properties for the pull resistor configuration, low-power modes, slew rates, etc.
zephyr/dts/bindings/pinctrl/pincfg-node.yaml
properties:
bias-disable:
type: boolean
description: disable any pin bias
# --snip--
bias-pull-up:
type: boolean
description: enable pull-up resistor
bias-pull-down:
type: boolean
description: enable pull-down resistor
# --snip--
low-power-enable:
type: boolean
description: enable - mode
# --snip--
Each vendor can, in turn, restrict which properties are supported by MCU pins using the property-allowlist
. The following is the matching snippet from the nRF52840 DTS file that we’ve seen before:
child-binding:
child-binding:
include:
- name: pincfg-node.yaml
property-allowlist:
- bias-disable
- bias-pull-down
- bias-pull-up
- low-power-enable
Node approach with the STM32
Having seen the grouped approach, let’s see how the node approach is applied for the STM32 by looking at the STM32 Nucleo-64 development board’s DTS file:
zephyr/boards/arm/nucleo_c031c6/nucleo_c031c6.dts
&usart2 {
pinctrl-0 = <&usart2_tx_pa2 &usart2_rx_pa3>;
pinctrl-names = "default";
current-speed = <115200>;
status = "okay";
};
Since the pinctrl-<x>
properties are predefined by Zephyr, the format of the above snippet looks very familiar: The pinctrl-names
only contains the operational mode "default"
- it thus seems like the "sleep"
mode is not supported (yet) by the STM32 drivers. Therefore, only pinctrl-0
is provided, which now contains two references instead of one.
Aside from the fact that we’re using two phandles, the node names already indicate that this is a node-based approach with fixed pin multiplexing. This is especially clear when comparing the node names to the alternate function mapping table in the datasheet:
- Pin PA2 has the alternative function
AF1=USART2_TX
, matchingusart2_tx_pa2
- Pin PA3 has the alternative function
AF1=USART2_RX
, matchingusart2_rx_pa3
The content in the matching DTS file in the STM32 HAL specifies just that:
hal_stm32/dts/st/c0/stm32c031c(4-6)tx-pinctrl.dtsi
/ {
soc {
pinctrl: pin-controller@50000000 {
/omit-if-no-ref/ usart2_tx_pa2: usart2_tx_pa2 {
pinmux = <STM32_PINMUX('A', 2, AF1)>;
bias-pull-up;
};
/omit-if-no-ref/ usart2_rx_pa3: usart2_rx_pa3 {
pinmux = <STM32_PINMUX('A', 3, AF1)>;
};
};
};
};
Note: If you’re also using the Nordic toolchain installer to install Zephyr, you won’t find the
stm32c031c(4-6)tx-pinctrl.dtsi
locally, sincehal_stm32
is not included in the installation. Check the STM32 HAL on GitHub until we solve this problem in the next article.
Note:
/omit-if-no-ref/
is used to tell the Devicetree generator that no code should be generated for the node in case it is not referenced in the Devicetree. This avoids adding unnecessary content to the Devicetree sincepinctrl
nodes are only used when referenced.
On the STM32, the RX and TX functionality for USART2
can only be selected via a pin’s alternative function and is therefore not available on all pins. Instead of providing macros for all combinations, in the node approach, you’ll find predefined nodes for all pins and functions, e.g., for USART2_TX
the following nodes exist in stm32c031c(4-6)tx-pinctrl.dtsi
.
usart2_tx_pa2
usart2_tx_pa4
usart2_tx_pa8
usart2_tx_pa14
The USART2_TX
functionality is therefore only available on the pins PA2, PA4, PA8 and PA14, as specified in the datasheet. The order in which the pins are assigned to pinctrl-<n>
is again irrelevant since the pin’s configuration determines its function. E.g., selecting AF1
for the pin PA2 means that this pin is used for UART-TX of the USART2 peripheral.
As stated in Zephyr’s official documentation about pin control, the node approach method is discouraged if autogeneration is not an option. Therefore, you should really only encounter this pattern for MCUs where predefined nodes exist for all possible pin/function combinations.
This also means, that for MCUs using the node approach you should never attempt to change the properties of the pinctrl
nodes, e.g., in your overlay files. When mapping a peripheral to a different set of pins, you should instead change the node references in the pinctrl-<n>
property.
Summary
Zephyr streamlines pin multiplexing by requiring the use of pinctrl
properties for SoC peripherals and by providing a standardized set of pin configuration properties.
The nodes referenced in the pinctrl-<n>
phandle arrays, however, are entirely vendor-specific and will probably remain so in the foreseeable future. Two patterns for dealing with pinctrl
nodes exist: The grouping and the node approach.
-
Pin control with the grouping approach is the recommended approach and uses macros for the pin assignment. MCUs with restricted pin multiplexing typically provide macros for supported pins and functions, whereas MCUs with unrestricted multiplexing such as Nordics nRF series use parameterized macros.
-
Pin control with the node approach is only used for MCUs with restricted pin multiplexing: For each supported combination of pins and functions, the vendor provides a DTS file with generated nodes. Pins are assigned to a peripheral by referring to different (generated) nodes.
You can explore the pin control for your vendor of choice by looking it up in Zephyr’s bindings index.
Devicetree and vscode
If you like vscode
, you can also have a look at Nordic’s nRF DeviceTree extension. It includes Devicetree language support and even a visual Devicetree editor in case you’re using Nordic’s MCUs.
While I haven’t used the visual editor, it is also able to visualize multiple assignments for the same GPIO within the Devicetree. The extension is very useful when browsing Devicetree source files.
Rerouting UART
With what we’ve learned about grouped pin control with the nRF52840, let’s reroute our &uart0
instance to pins that are available on the development kit’s pin rows instead of using the pins that lead to the USB interface.
E.g., let’s use the pins P1.6 and P1.8 instead. There are two ways to do this: Either we define our own nodes and assign them to the pinctrl-<n>
properties of the &uart0
node, or we overwrite the psels
of the existing &uart0_default
and &uart0_default
nodes. For MCUs using the grouping approach, there’s really no difference at all, and since it is easier, we’ll just overwrite the existing nodes in our overlay as follows:
04_practice/boards/nrf52840dk_nrf52840.overlay
&uart0_default {
group1 {
psels = <NRF_PSEL(UART_TX, 1, 6)>, <NRF_PSEL_DISCONNECTED(UART_RTS)>;
};
group2 {
psels = <NRF_PSEL(UART_RX, 1, 8)>, <NRF_PSEL_DISCONNECTED(UART_CTS)>;
bias-pull-up;
};
};
&uart0_sleep {
group1 {
psels =
<NRF_PSEL(UART_TX, 1, 6)>, <NRF_PSEL(UART_RX, 1, 8)>,
<NRF_PSEL_DISCONNECTED(UART_RTS)>, <NRF_PSEL_DISCONNECTED(UART_CTS)>;
low-power-enable;
};
};
Notice that I’ve also disabled the RTS and CTS since we’re not really using it. Since I don’t have a working FDTI cable, I’ll be using my completely overqualified but trusted Saleae and connect it to the pins P1.6 and P1.8.
And it works!
Also, the output for the J-Link device remains empty, meaning that we really rerouted our UART node. A bit more intricate than reassigning GPIOs, but just as flexible.
Switching to the STM32 Nucleo-64 development board
Since I have a spare STM32 Nucleo-64 development board at hand, it’d be nice to repeat the same steps and reroute the UART output for an MCU that uses the pin approach. Before we try to reroute anything, let’s try to build the project for the matching board nucleo_c031c6
:
$ rm -rf ../build
$ west build --board nucleo_c031c6 --build-dir ../build
In file included from <command-line>:
/opt/nordic/ncs/v2.4.0/zephyr/boards/arm/nucleo_c031c6/nucleo_c031c6.dts:9:10: fatal error: st/c0/stm32c031c(4-6)tx-pinctrl.dtsi: No such file or directory
9 | #include <st/c0/stm32c031c(4-6)tx-pinctrl.dtsi>
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
compilation terminated.
Well, that’s embarrassing. Something’s wrong with our setup, we don’t seem to have all the files we need! At least this is what happens if you’ve followed my advice to use Nordic’s toolchain installer in the very first article of this series. What’s the reason for this error?
The reason is quite simple: Obviously, Nordic’s toolchain installer assumes that you’re only using their MCUs and the nRF Connect SDK, and not “vanilla Zephyr”. It therefore doesn’t include HALs and files from other vendors that are not required.
When using multiple MCUs it is therefore much better to stop using installed sources and use a west workspace application instead. But that’s a topic for the next and final article of this series. See? Embedded articles can have cliffhangers too!
Conclusion
In this article, we made use of what we’ve learned about the Devicetree and its bindings and had a detailed look at advanced Devicetree concepts in Zephyr. I really hope that this article made up for all the theory that I’ve put you through!
Some of you might have expected a bit more of a “practice” article, but looking at APIs and drivers in Zephyr, I see no point in writing an article when there are so many samples and demos that do a much better job - and are maintained by the awesome Zephyr contributors.
Instead, we had a look at some of Zephyr’s APIs and device drivers to learn that Devicetree is much more than just simple macro pasting for obtaining property values. The section on pin control showed us a more advanced concept in Devicetree that you’ll for sure need for even simple applications. At the same time, it also showed us that Devicetree concepts in Zephyr are still evolving, since the pinctrl
feature was not available prior to Zephyr 3.0 - and that’s very good news!
Note: The full example application including all the Devicetree files that we’ve seen throughout this article is available in the
04_practice
folder of the accompanying GitHub repository.
Further reading
The following are great resources when it comes to Zephyr and are worth a read or watch:
- When trying to find out how a driver works, definitely have a look at Zephyr’s samples and demos. They’re usually the best resource.
- If you want to know more about pin control, have a look at Zephyr’s official documentation and definitely watch the presentation Deep Dive into Pin Control in Zephyr by Gerard Marull Paretas from the Zephyr Development Summit 2022.
- A more concise presentation about How to use Zephyr Pin Control (pinctrl) for pin multiplexing and configuration is available on the Zephyr blog.
- If you want to know more about driver development, watch the Tutorial: Mastering Zephyr Driver Development by Gerard Marull Paretas from the Zephyr Development Summit 2022.
- There’s also a blog post on this Interrupt blog about building drivers on Zephyr.
Finally, have a look at the files in the accompanying GitHub repository and I hope I’ll see you again in the next article!