Writing device drivers for UART peripherals

Objectives

  • Introduce the fundamentals of writing device drivers for UART peripherals.

Preface

Making different pieces of hardware work together can be a bit of a chore. Recently, while working on a project, I had to use an external sensor module which could only connect through UART. After playing around with it for a while to see what it could do I decided that writing a Zephyr device driver for it would be an elegant way of accessing the module from my application, but this would turn out to be trickier than expected. Any and all information I could dig up on driver development was for devices that either connected through SPI or I²C, or weren't connected to a peripheral bus at all. Because of this I thought I might as well compile what I've learned and make it available to the world.

This post will assume some prior knowledge about Zephyr device drivers. I would recommend the sections of the Zephyr docs on the device driver model and devicetree aware drivers, and this article by Jared Wolff if you want a refresher on the basics. I have also made a small example project that implements a "soft" UART peripheral driver that does not require any hardware besides an nrf52840 or nrf9160 development kit, and shows how the concepts discussed here can be implemented.

Information in this post should be correct for Zephyr v2.6.0 and has not been double checked for other versions.

Using devicetree

Defining devicetree bindings

Devicetree bindings define how devicetree nodes a interpreted. Any information that is specific to hardware or an instance of the device, and that can't be extracted from information that's already available is a candidate for being added to the devicetree bindings. Some common examples include GPIO pins, initial configuration data, and bus speeds. What bus a device can be on is also specified by it's devicetree bindings. 

To make your device a UART peripheral you need to include the file uart-device.yaml. Including this file does two things: First it sets the on-bus property to "uart" which ensures that only devices put on a UART bus will be matched with these bindings. Secondly it adds the label property and marks it as required. To include this file add the line

include: uart-device.yaml

to your bindings YAML file. If you want to make these bindings available to your application they need to be placed in one of the locations specified by the Zephyr docs.

Adding peripheral to devicetree

When your bindings are complete the next step is to add a device to your devicetree. The simplest way to do that for an existing board is by using a devicetree overlay. The general way to add a peripheral to a bus is by adding it as a node to the bus node. This works for UART as well.

&uart_node {
    status="okay";

    /* Bus driver specifc fields */
    
    my_uart_peripheral {
        status="okay";
        
        /* Peripheral fields*/
        
    };
};

For the nRF9160 DK the overlay will end up looking something like this:

&uart1 {
    status="okay";      // Enable bus
    rx-pin = <10>;      // Pin 0.10
    tx-pin = <11>;      // Pin 0.11
    
    my_device: my_uart_peripheral0 {
        compatible = "riphiphip,my_uart_peripheral";                // Point to previously defined bindings
        status="okay";                                              // Mark peripheral as enabled
        label = "my_uart_peripheral0";
        button-gpios = <&gpio0 6 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>; // Button 1 on dk
        initial-string = "Hello, World!";
    };
};

There are many ways to include an overlay file. The simplest is to name the file board.overlay, where board is replaced by the name of the board you are building for, and save it to {Application root}/boards/. For instance, the overlay above will be saved as {Application root}/boards/nrf9160dk_nrf9160ns.overlay.

The device should now be accessible from your application using standard devicetree access functions and macros. I would recommend playing around a bit with this to ensure that things are working as expected before proceeding.

Implementing your driver

Implementing a UART peripheral driver is not too different from implementing any other driver. The main challenges will be related to device specific logic, which is unfortunately beyond the scope of this post. There are however a couple of things that could be worth pointing out. At the time of writing Zephyr supports three different APIs for interacting with UART bus devices: one polling based, one interrupt based, and the experimental callback based async API. These API's don't always play nice with each other, so it might be a good idea to take some time to figure out which API best suits your particular use case and stick with it.

There is also the matter of actually accessing the bus device. Zephyr offers the convenience macro DT_BUS to get get a pointer to the device struct of the bus hosting the peripheral. If you're writing a driver that creates devices using instance numbers there exists a variant of the macro, DT_INST_BUS that does the same thing, but takes an instance number instead of a devicetree node. Adding the pointer provided by one of these macros to the UART peripheral's config struct is a good way to keep it available for later use.

Another important thing to keep note of is initialization order. For a driver that relies on other devices to function properly it needs to be initialized after them. If your UART peripheral driver can't access the UART bus when initializing attempting to delay the initialization of the peripheral might give better results.

Conclusion

At the end of the day, writing a UART peripheral driver isn't too different from writing any other device driver, but figuring that out isn't necessarily easy, and there are a couple of steps that aren't explicitly documented. Hopefully this post has given you some pointers as to how you can get your own driver up and running.  

Useful links + Further reading

Anonymous