💡
I wont explain the details of the hardware peripheral itself, that is up to you, instead of I'll be focusing on explaining the Zephyr driver, if you want (and you should) known about your microcontroller GPIO peripheral you will need to read its respective user manual/datasheet

Probably the first peripheral you will ever use on a microcontroller is the GPIO. Working with ports and pins in Zephyr is quite straightforward. First, you need to locate the SoC device description file for your part number. In my case, since I’m using the Nordic nRF54L15, the file I need is:

zephyr/dts/common/nordic/nrf54l_05_10_15.dtsi

This file contains all the information about the peripherals, including their names. From here, we can identify the GPIOs. The file shows that Zephyr defines three different GPIO ports, which matches the information in the datasheet. Even better, it also provides the node labels we need to use in our code

/ {
	...

	soc {
		...

		global_peripherals: peripheral@50000000 {
			#address-cells = <1>;
			#size-cells = <1>;
			ranges = <0x0 0x50000000 0x10000000>;
            ...
          
			gpio2: gpio@50400 {
				compatible = "nordic,nrf-gpio";
				gpio-controller;
				reg = <0x50400 0x300>;
				#gpio-cells = <2>;
				ngpios = <11>;
				status = "disabled";
				port = <2>;
			};
            ...

            gpio1: gpio@d8200 {
				compatible = "nordic,nrf-gpio";
				gpio-controller;
				reg = <0xd8200 0x300>;
				#gpio-cells = <2>;
				ngpios = <16>;
				status = "disabled";
				port = <1>;
				gpiote-instance = <&gpiote20>;
			};
            ...

            gpio0: gpio@10a000 {
				compatible = "nordic,nrf-gpio";
				gpio-controller;
				reg = <0x10a000 0x300>;
				#gpio-cells = <2>;
				ngpios = <5>;
				status = "disabled";
				port = <0>;
				gpiote-instance = <&gpiote30>;
			};

It’s important to understand the information each node provides. For example, the property ngpios indicates how many pins are available on each port, while reg specifies where the peripheral registers are located in the memory address space.

Keep in mind that all of these properties can be modified using an overlay file for your specific application. They can also serve as the foundation for developing a higher-level driver. You can find their detailed descriptions in the corresponding binding files.

zephyr/dts/bindings/gpio/nordic,nrf-gpio.yaml
zephyr/dts/bindings/gpio/gpio-controller.yaml
zephyr/dts/bindings/base/base.yaml 

Using the label names defined above, we can obtain a device descriptor, which is essentially a pointer to a structure containing the values needed to manipulate the peripheral registers. For example, if we want to control any pin in port number 2, we would use this descriptor.

/* Get the device descriptor for port 2 */
const struct device *port2 = DEVICE_DT_GET( DT_NODELABEL( gpio2 ) );

We can use the device pointer with any of the available GPIO functions—for example, to set a pin. It’s a straightforward process, like most operations involving GPIOs.

/* Configure pin 9 as output */
gpio_pin_configure( port2, 9, GPIO_OUTPUT);
/* Write a 1 on pin 9 */
gpio_pin_set( port2, (1<<9), 1 );

We can avoid the hassle of configuring a pin as an input or output directly in our C code by using the device tree. This is done with GPIO hogs, a property that is part of the Zephyr gpio-controller driver. To use it, we simply reference the port and declare a child node where we set the gpio-hog property, specify the pin (or multiple pins), and define its configuration.

With this approach, there’s no need to call the gpio_pin_configure() function in your code anymore.

/* phandle for gpio2 port */
&gpio2 {
    /* Declare an internal node name led-0 for pin 9 */
    myLed0: myLed0{
        gpio-hog;
        /* config pin 9 */
        gpios = <9 GPIO_ACTIVE_HIGH>;
        /* Set pin as output with  init state as high level */
        output-high; 
    };
};

But this is not the proper Zephyr way of handling GPIOs. The reason is simple: it does not provide an agnostic mechanism to separate hardware from software. Let’s imagine we have an LED connected to pin 9 on port 2. Instead of hardcoding its configuration, we can use an overlay to define the pins through the gpio-leds driver.

/ {
    /* Create a new node to manage leds */
    myLeds: myLeds {
        /* We indicate we are going to use gpio-leds driver */
        compatible = "gpio-leds";
        /* Sub-node for one led in gpio2 pin 9, with the led been turn on
        with a high logic level */
        myLed_0: myLed-0 {
            gpios= <&gpio2 9 GPIO_ACTIVE_HIGH>;
            label = "User LD0";
        };
    };
};

Now, in your code, you will never reference a port named gpio2 or a pin number 9. Instead, you will simply refer to an LED called myled_0, as defined in the device tree.

/* Get GPIO specification for led0 and led1 from devicetree */
const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(DT_NODELABEL(myled_0), gpios);

As before, you can still use the GPIO API, but this time with the functions that end with the _dt postfix. In these cases, you need to pass a GPIO device tree specification (gpio_dt_spec) as the argument, like this:

/* Configure led0 and led1 as output */
gpio_pin_configure_dt( &led0, GPIO_OUTPUT_ACTIVE );
/* Toggle LEDs from devicetree */
gpio_pin_toggle_dt( &led0 );

Notice that this time we use the macro GPIO_DT_SPEC_GET, which accepts a node and the property gpios as parameters. In this case, it does not extract the device itself; instead, it returns a structure containing three specific elements: port, pin, and certain flags to apply to the selected pin(s). This structure is effectively the equivalent of what the macro provides.

const struct gpio_dt_spec led0 =  {		
	.port = &gpio2,
	.pin = 9,	
	.dt_flags = GPIO_ACTIVE_HIGH	
};

A similar mechanism can be used for pins configured as inputs. This time, however, they are not LEDs but gpio-keys, which can represent a button or a simple switch, for example.

/ {
    /* Create a node called button */
    myBtns: myBtns {
        /* We indicate we are going to use gpio-keys driver */
        compatible = "gpio-keys";
        /* Sub-node for one button in gpio1 pin 13, connected to a internal pullup */
        myBtn_0: myBtn-0 {
            gpios = <&gpio1 13 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>;
            label = "User BTN0";
        };
    };
};

Okay, but what if you don’t have LEDs or buttons connected to your pins? Maybe there is another kind of signal you need to manipulate with 0 and 1. I’m not talking about serial protocols or PWM—just simple on/off devices.

In that case, you would need to create your own binding driver, but that is a topic for a different post. For now, have fun experimenting with buttons and LEDs, or take a look at the following example from the Zephyr official documentation: GPIO with custom Devicetree binding — Zephyr Project Documentation

Snippets

Exercises

💡
None of the exercises require to manipulate the device tree using an overlay, just use the the none device tree functions
  1. Blink 8 LEDs connected to a single port alternately (turn on LEDs p0, p2, p4, p6, and then p1, p3, p5, p7).
  2. Write a program that rotates a turned-off LED on port any port at a speed that is perceptible to the human eye.
  3. Write a program that turns on an LED when a button is pressed and turns it off when the button is released. (The LED will only turn on when the button is pressed.)
  4. Write a program that rotates an LED on port C, but this time with three speeds and three buttons. Each button will activate a different speed.
  5. Modify exercise 3 so that pressing the button once turns on the LED, and pressing it again turns it off.
  6. Repeat exercise 4, but this time using only one button and four speeds. Each time the button is pressed, the speed will increase, and when it reaches the last speed, it will start over.
  7. Modify the previous program using two buttons. Pressing one button will rotate the LEDs from left to right, and pressing the other button will rotate them from right to left.
  8. Rewrite the exercises that involve buttons and declare new nodes to manipulate the buttons using the gpio-keys compatible driver, and use hogs to configure the pins connected to LEDs, make use of alias to define more agnostic names for the port where the led are connected