How to Build Drivers for Zephyr
If you’ve heard the news about the nRF9160 then you know it’s a game-changer. ARM Cortex M33 + LTE Cat-M + NB-IoT and GPS all included under one System in Package. Drool-worthy, right?
Well, if you’ve been using the nRF SDK to build nRF5x projects, you likely feel like you’re hanging from your toes. That’s because Nordic has chosen to adopt Zephyr as their RTOS of choice for the nRF9160.
Although there is plenty of documentation about Zephyr, making the transition can still be tough. Many nRF SDK enthusiasts will find themselves lost in a sea of CMake files and Kconfigs. I’m here to help with that!
This is the guide I wish existed while I was developing some drivers for the nRF9160 Feather. I spent weeks getting accustomed to how Zephyr worked, figuring out what files needed to be where, and how the heck to include a header file. (Fun times.)
In this post, I’ll go over some of the nuances related to creating drivers for your peripherals on Zephyr. We’ll talk about Device Tree organization, Kconfig
, and CMakeLists.txt
files. By the end, you should have an idea on how to tackle your own Zephyr driver aspirations!
Table of Contents
Example Walkthrough
In this example, you’ll learn about what it takes to create a driver for the PCF85063A real-time clock and counter. I’ve chosen to use the counter API for reasons we’ll discuss in a bit. There are some prerequisites to getting started though. Let’s begin with SDK Setup.
SDK Setup
I’ll be referencing Nordic’s nRF Connect SDK a bunch throughout this post. I’ve been building and testing nRF9160 Feather on v1.3.0. I won’t be going into detail on how to set up the SDK though. Nordic has great documentation you can check out to get started. I’ve also discussed my experience here.
What west
can do for you
The Zephyr project uses a CLI tool, west
, for project management, compilation, and flashing. west
replaces the idea of a top-level Makefile and project dependency management like Git submodules. For the most part, it accomplishes that goal well.
You’ll need to install west
if you haven’t already. Follow the SDK setup links above for more information.
Building
To build a project, navigate to the directory for the application in question and run west build
. For example:
$ cd ncs/nrf/samples/nrf9160/nrf_cloud_agps
$ west build -b circuitdojo_feather_nrf9160ns -p
This builds in the current folder for the circuitdojo_feather_nrf9160ns
board. You can find all the possible arm
based boards in ncs/zephyr/boards/arm/
The -p
flag tells west
to clean before building.
A ton of things happen in the background to build a project. We’ll discuss more how things compile in the next section.
Flashing
Flashing code is as simple as running west flash
from your project directory. You can use the -r
parameter to choose which programming method you’d like to use. Common options are jlink
, nrfjprog
(for Nordic projects), pyocd
, openocd
, dfu-util
, etc.
These “runners” are set in the board configuration. Here’s the board.cmake
for the nRF9160 Feather located in zephyr/boards/arm/circuitdojo_feather_nrf9160/board.cmake
:
board_runner_args(nrfjprog "--nrf-family=NRF91")
board_runner_args(jlink "--device=cortex-m33" "--speed=4000")
include(${ZEPHYR_BASE}/boards/common/nrfjprog.board.cmake)
include(${ZEPHYR_BASE}/boards/common/jlink.board.cmake)
As you can see, the nRF9160 Feather supports the nrfjprog
and jlink
options.
Dependency Management
There are a few different ways of handling dependencies in firmware land. I’ve used Git submodules as my dependency “manager” of choice. Zephyr though, is very opinionated on how you should work with your dependencies. Let’s look into how we do that with west
and a west.yml
file.
west.yml
is a manifest file much like a Cargo.toml
in rust or package.json
for Node. You initialize a Zephyr repository using west init
command. This does a shallow git
clone for each dependency and puts them into place.
Here’s what a west.yml
file may look like. This was for a personal project that uses NanoPB and also the nRF Connect SDK.
manifest:
remotes:
- name: nrfconnect
url-base: https://github.com/nrfconnect
- name: nanopb
url-base: https://github.com/nanopb
projects:
- name: nrf
remote: nrfconnect
revision: v1.3.0
import: true
- name: nanopb
repo-path: nanopb
revision: 0.3.9
path: pyrinas/ext/nanopb
remote: nanopb
self:
# This repository should be cloned to ncs/nrf.
path: pyrinas
The west.yml
file doesn’t affect driver operation. If you’re going to include your drivers as a separate repo though, this is where you would add it! You can learn more though by going to the Zephyr documentation on West Manifests. (complete with crazy dependency diagrams and more!)
How Things Compile
One of the important things to know about driver development is how things compile. It may bend your mind a bit but we’ll get through this together.
CMakeLists.txt
If you spend enough time in Zephyr, you may see some patterns emerge. Many folders and sub-folders have this dynamic duo: CMakeLists.txt
and Kconfig
. These files are where all the magic happens.
You may already have an idea of what CMakeLists.txt
does by its name. It’s a Makefile of sorts that you use to add source files and include folders to a project. The neat thing about cmake
is that it’s recursive (for better or for worse). This means a higher-level CMakeLists.txt
file spells out what, if anything, can be found in subdirectories. There are no more ginormous Makefiles. You make the changes you need closest to the code that’s affected.
For example, you can add subdirectories in CMakeLists.txt
so they’re part of the cmake
path:
# Point to NCS root directory.
set(NRF_DIR ${CMAKE_CURRENT_LIST_DIR} CACHE PATH "NCS root directory")
include(cmake/extensions.cmake)
include(cmake/multi_image.cmake)
include(cmake/reports.cmake)
zephyr_include_directories(include)
add_subdirectory(ext)
add_subdirectory(lib)
add_subdirectory(samples)
add_subdirectory(subsys)
add_subdirectory(drivers)
add_subdirectory(tests)
Sometimes they’re used to set the directory as a project. This particular one is from the https_client
example in nRF Connect SDK.
cmake_minimum_required(VERSION 3.8.2)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(https_client)
target_sources(app PRIVATE src/main.c)
In my case, I’ll show you how I used several CMakeLists.txt
so the compiler knows about my device drivers!
Kconfig
Kconfig
allows you to create project-based configuration parameters. If you’ve ever used Nordic’s Original SDK, you know the pain of their sdk_config.h
file. It’s a nightmare.
Like CMakeLists.txt
, Kconfig
files allow you to keep the configuration as close to your code. There’s no massive top-level configuration manifest to be afraid of. You can add your code without breaking the repository. Good stuff, right?
Here’s an example of a Kconfig
from the ncs/nrf/applications/asset_tracker
application
menu "Asset tracker"
rsource "src/ui/Kconfig"
config APPLICATION_WORKQUEUE_STACK_SIZE
int "Application workqueue stack size"
default 4096
config APPLICATION_WORKQUEUE_PRIORITY
int "Application workqueue priority"
default SYSTEM_WORKQUEUE_PRIORITY
menu "GPS"
...
You can see that it references separate Kconfig
files as a dependency. You can also see that some environment variables are set with default values. You can either add them to your project’s prj.conf
or edit them using west build -t menuconfig
. If you’re looking to make permanent changes, you should use the prj.conf
method.
The most important part about Kconfig
files in regards to drivers is that you can use these variables as flags for conditional compilation.
As an example, here’s what the ncs/nrf/drivers/sensor/CMakeLists.txt
looks like:
add_subdirectory_ifdef(CONFIG_BH1749 bh1749)
add_subdirectory_ifdef(CONFIG_SENSOR_SIM sensor_sim)
add_subdirectory_ifdef(CONFIG_PMW3360 pmw3360)
add_subdirectory_ifdef(CONFIG_PAW3212 paw3212)
You may notice that all the variables begin with CONFIG_
. All variables produced by Kconfig
have this prefix. So, for example, APPLICATION_WORKQUEUE_STACK_SIZE
becomes CONFIG_APPLICATION_WORKQUEUE_STACK_SIZE.
In the CMakeLists.txt
above, you can see that a subdirectory is only added if the driver is “enabled”. CONFIG_BH1749
happens to be the top-level environment variable to make that happen. You can also use this type of conditional compilation for your project code.
zephyr/module.yml
Kconfig
and CMakeList.txt
are great, but where do we start? How does west
know where to start looking for them?
The answer is a very unsuspecting file called module.yml
. module.yml
is usually found in the /zephyr
subdirectory of a dependency. For example, here’s the one for nRF Connect SDK located in ncs/nrf/zephyr/
Example
build:
cmake: .
kconfig: Kconfig.nrf
The file tells west
where to find the appropriate base Kconfig
and CMakeLists.txt
. Those are the starting point for further repository traversal. Without these, there’s no way to use your driver code in your application!
Adding a drivers folder
You now should understand Kconfig
, CMakeLists.txt
, and module.yml
. We’re ready for driver development!
In my case, since my driver work is ongoing, I opted to use my own drivers
folder in my main project repository. You can always opt to start developing your drivers in zephyr/drivers
. This is likely a better location especially if you want to release the driver for others to use. The downside is that it requires more work!
In my case, here’s what the directory structure looks like from my project repo:
. # My Project Root located in /ncs/pyrinas/
├── CMakeLists.txt
├── Kconfig.pyrinas
├── drivers
│ ├── CMakeLists.txt
│ ├── Kconfig
│ └── rtc
│ ├── CMakeLists.txt
│ ├── Kconfig
│ └── pcf85063a
│ ├── CMakeLists.txt
│ ├── Kconfig
│ ├── pcf85063a.c
│ └── pcf85063a.h
├── west.yml
└── zephyr
└── module.yml
You can see there’s a pair of CMakeLists.txt
and Kconfig
at each level. You don’t have to make a CMakeLists.txt
and Kconfig
at every level though. For example, if I wasn’t planning on adding any more RTC drivers, I could stop at the rtc
folder. You can see an example of this below:
zephyr_library()
zephyr_library_sources_ifdef(CONFIG_PCF85063A pcf85063a/pcf85063a.c)
In my case though, I opted to define CMakeLists.txt
and Kconfig
at every level.
Full Example
Below are all of the KConfig
and CMakeLists.txt
files that are part of my project, along with their relative project path.
ncs/pyrinas/CMakeLists.txt
#
# Copyright (c) 2020 Circuit Dojo
#
# SPDX-License-Identifier: Apache-2.0
#
# Point to Pyrinas root directory.
set(PYRINAS_DIR ${CMAKE_CURRENT_LIST_DIR} CACHE PATH "Pyrinas root directory")
zephyr_include_directories(include)
add_subdirectory(lib)
add_subdirectory(ext)
add_subdirectory(drivers)
ncs/pyrinas/drivers/CMakeLists.txt
#
# Copyright (c) 2020 Circuit Dojo LLC
#
# SPDX-License-Identifier: Apache-2.0
#
add_subdirectory(rtc)
ncs/pyrinas/drivers/rtc/CMakeLists.txt
add_subdirectory_ifdef(CONFIG_PCF85063A pcf85063a)
ncs/pyrinas/drivers/rtc/pcf86063a/CMakeLists.txt
zephyr_library()
zephyr_library_sources_ifdef(CONFIG_PCF85063A pcf85063a.c)
The Kconfig
files follow a very similar pattern. You can use the same pattern for your projects as well.
ncs/pyrinas/Kconfig.pyrinas
menu "Pyrinas"
rsource "lib/Kconfig"
rsource "drivers/Kconfig"
endmenu
Side note: The Kconfig
in a module’s root doesn’t need a suffix. I chose to add .pyrinas
to be consistent with the nRF Connect Studio and Zephyr repo. Make sure whatever you use is reflected in zephyr/module.yml
.
ncs/pyrinas/drivers/Kconfig
comment "Device Drivers"
rsource "rtc/Kconfig"
ncs/pyrinas/drivers/rtc/Kconfig
comment "RTC Drivers"
rsource "pcf85063a/Kconfig"
ncs/pyrinas/drivers/rtc/pcf85063a/Kconfig
# PCF85063A Nano Power Real time Clock configuration options
menuconfig PCF85063A
bool "PCF85063A Three Axis Accelerometer"
depends on (I2C && HAS_DTS_I2C)
help
Enable SPI/I2C-based driver for PCF85063A based RTC
As you can imagine, it’s more complicated when your drivers are many levels deep. In any case, the above example should give you an idea of how to organize your Kconfig
and CMakeLists.txt
.
Don’t forget the Device Tree
In Zephyr, the Device Tree dictates hardware access. Gone are the days of initializing the driver in main
. Instead, you must use references to the Device Tree to access peripherals.
In my case, I needed to use I2C to get the PCF85063A working. Here’s what the nRF9160 Feather’s Device Tree looks like before I added the PCF85063A:
&i2c1 {
compatible = "nordic,nrf-twim";
status = "okay";
sda-pin = <26>;
scl-pin = <27>;
};
Here’s what it looked like after:
&i2c1 {
compatible = "nordic,nrf-twim";
status = "okay";
sda-pin = <26>;
scl-pin = <27>;
pcf85063a@51 {
compatible = "nxp,pcf85063a";
label = "PCF85063A";
reg = <0x51>;
};
};
I got my inspiration from the drivers for the bh1749
driver in the nRF Connect SDK. You can see how Nordic implemented it in thingy91_nrf910_common.dts
. (Github link)
You can see that the address of the PCF85063A address of 0x51
is in two places. As the reg
value is what the driver uses whereas the @51
is a naming convention. This allows you to have two or more of the same device without interference. (Granted they’re operating at different hardware addresses!)
The compatible
entry references the code in pcf85063a.c
. This value must be unique. We’ll discuss this a bit more in a second.
The label
allows us to use the device tree macros to reference the hardware.
As with any device, your driver may look different. The Zephyr project is full of example driver implementations. You can check out more in the nrf/drivers/
folder and also the zephyr/drivers
folder.
Driver Smarts
Now that you’re configured and your Device Tree is set, it’s time to write some code! In this section, we’ll review the important macros and nuances of driver development.
Helpers
Who doesn’t like a handy macro that makes life easier? I found that the macros like BIT
and BIT_MASK
were extra useful. They’re located in zephyr/include/sys/util.h
Here’s what it looks like for the CTRL1 register of the PCF85063A
#ifndef ZEPHYR_DRIVERS_RTC_PCF85063A_PCF85063A_H_
#define ZEPHYR_DRIVERS_RTC_PCF85063A_PCF85063A_H_
#define PCF85063A_CTRL1 0x00
#define PCF85063A_CTRL1_EXT_TEST BIT(7)
#define PCF85063A_CTRL1_STOP BIT(5)
#define PCF85063A_CTRL1_SR BIT(4)
#define PCF85063A_CTRL1_CIE BIT(2)
#define PCF85063A_CTRL1_12_24 BIT(1)
#define PCF85063A_CTRL1_CAP_SEL BIT(0)
Though these macros don’t do too much, they keep your code clean and easy to read.
DEVICE_AND_API_INIT
The DEVICE_AND_API_INIT
macro adds the initialization of your driver before any user code. Here’s what the setup looks like for the PCF85063A:
DEVICE_AND_API_INIT(pcf85063a, DT_INST_LABEL(0), pcf85063a_init, &pcf85063a_data,
&pcf85063_cfg_info, POST_KERNEL, CONFIG_I2C_INIT_PRIORITY,
&pcf85063a_driver_api);
If we look closer at the definition, we can see what each entry does:
#define DEVICE_AND_API_INIT(dev_name, drv_name, init_fn, data, cfg_info, \
level, prio, api) \
This allows the bulk of this private code to remain private. It’s only accessible through a shared API struct. In my case, it was the pcf85063a_driver_api
. We’ll talk more about device APIs more shortly.
You can also change when the driver gets initialized by changing the level argument. Here are the levels as shown in linker-defs.h
:
#define INIT_SECTIONS() \
__init_start = .; \
CREATE_OBJ_LEVEL(init, PRE_KERNEL_1) \
CREATE_OBJ_LEVEL(init, PRE_KERNEL_2) \
CREATE_OBJ_LEVEL(init, POST_KERNEL) \
CREATE_OBJ_LEVEL(init, APPLICATION) \
CREATE_OBJ_LEVEL(init, SMP) \
__init_end = .; \
In most cases, POST_KERNEL
is good enough. If you need immediate initialization, then one of the PRE_KERNEL
options is better.
DT_DRV_COMPAT
This is a particularly important macro. It ties the compatible line in your Device Tree to the driver itself. Without this, we wouldn’t be able to use macros like DT_INST_LABEL(0)
to get the label of the device.
In my case, I defined DT_DRV_COMPAT
as
#define DT_DRV_COMPAT nxp_pcf85063a
This aligns with the compatible = "nxp,pcf85063a";
set in the Device Tree. (Replacing a ,
with _
)
Find an API that suits your needs
Initialization is great, but how the heck do we use the driver. This is where a driver API comes into play.
In the zephyr/include/drivers
directory, you can find all the API level code for every conceivable driver in Zephyr. In some cases, the API for your device may be there. In other cases, it may not be. The counter_driver_api
seemed to be the best fit for the PCF85063A. Unfortunately, this API doesn’t have any hooks for the clock functionality.
When you use a higher-level API, you write static functions that match the API you’re working with. You pass the functions into an API struct and then on to the DEVICE_AND_API_INIT
macro.
That was a mouthful. Let’s see what it actually looks like:
static const struct counter_driver_api pcf85063a_driver_api = {
.start = pcf85063a_start,
.stop = pcf85063a_stop,
.get_value = pcf85063a_get_value,
.set_alarm = pcf85063a_set_alarm,
.cancel_alarm = pcf85063a_cancel_alarm,
.set_top_value = pcf85063a_set_top_value,
.get_pending_int = pcf85063a_get_pending_int,
.get_top_value = pcf85063a_get_top_value,
.get_max_relative_alarm = pcf85063a_get_max_relative_alarm,
};
Implementing a function looked something like this:
static int pcf85063a_start(struct device *dev)
{
// Get the data pointer
struct pcf85063a_data *data = pcf85063a_dev->driver_data;
// Turn it back on (active low)
uint8_t reg = 0;
uint8_t mask = PCF85063A_CTRL1_STOP;
// Write back the updated register value
int ret = i2c_reg_update_byte(data->i2c, DT_REG_ADDR(DT_DRV_INST(0)),
PCF85063A_CTRL1, mask, reg);
if (ret)
{
LOG_ERR("Unable to stop RTC. (err %i)", ret);
return ret;
}
return 0;
}
If you check out counter.h
you can see how you can implement your static functions:
typedef int (*counter_api_start)(struct device *dev);
typedef int (*counter_api_stop)(struct device *dev);
typedef int (*counter_api_get_value)(struct device *dev, u32_t *ticks);
typedef int (*counter_api_set_alarm)(struct device *dev, u8_t chan_id,
const struct counter_alarm_cfg *alarm_cfg);
typedef int (*counter_api_cancel_alarm)(struct device *dev, u8_t chan_id);
typedef int (*counter_api_set_top_value)(struct device *dev,
const struct counter_top_cfg *cfg);
typedef u32_t (*counter_api_get_pending_int)(struct device *dev);
typedef u32_t (*counter_api_get_top_value)(struct device *dev);
typedef u32_t (*counter_api_get_max_relative_alarm)(struct device *dev);
typedef u32_t (*counter_api_get_guard_period)(struct device *dev, u32_t flags);
typedef int (*counter_api_set_guard_period)(struct device *dev, u32_t ticks,
u32_t flags);
__subsystem struct counter_driver_api {
counter_api_start start;
counter_api_stop stop;
counter_api_get_value get_value;
counter_api_set_alarm set_alarm;
counter_api_cancel_alarm cancel_alarm;
counter_api_set_top_value set_top_value;
counter_api_get_pending_int get_pending_int;
counter_api_get_top_value get_top_value;
counter_api_get_max_relative_alarm get_max_relative_alarm;
counter_api_get_guard_period get_guard_period;
counter_api_set_guard_period set_guard_period;
};
Repeat the process for all the API related functions from your __subsystem struct
and you’ll be good to go!
Using your driver in code
You’ve created a Device Tree entry and you’ve picked an API. You’ve sprinkled CMakeLists.txt
and Kconfig
throughout your code. It’s time to use your driver!
First, you’ll likely want to include the high-level API to your app. Here was my include to the counter API:
#include <drivers/counter.h>
During the early stages of your app, you can fetch your device. Using the label defined in your Device Tree, use the device_get_binding
function call.
// Get the device
rtc = device_get_binding("PCF85063A");
if (rtc == NULL)
{
LOG_ERR("Failed to get RTC device binding\n");
return;
}
LOG_INF("device is %p, name is %s", rtc, log_strdup(rtc->name));
This allows you to fetch the context and API access to do all the things! Instead of accessing the PCF85063A functions, you’ll use the public API calls.
In my case, my code uses the counter_get_pending_int
and counter_cancel_channel_alarm
functions.
if (!timer_flag)
{
int ret = counter_get_pending_int(rtc);
LOG_INF("Interrupt? %d", ret);
if (ret == 1)
{
timer_flag = true;
int ret = counter_cancel_channel_alarm(rtc, 0);
if (ret)
{
LOG_ERR("Unable to cancel channel alarm!");
}
}
}
This abstracts your hardware. That way you can use this code with any other counter_api
capable device. If you want to switch it up, change the name passed to device_get_binding
and you’ll be golden.
Only the tip of the iceberg
In this article, we talked about what makes a driver in Zephyr, a driver. You’ve learned how to add entries to your Device Tree. You’ve learned how to organize your repository. You’ve learned how to take advantage of some of Zephyr’s pre-existing APIs. You’re now ready to tackle your drivers!
Curious about the nRF9160 Feather? It’s open-source and is available for pre-order this week!
You can check it out here.
See anything you'd like to change? Submit a pull request or open an issue on our GitHub