nRF Connect SDK Tutorial - Part 2

This tutorial is a continuation of the nRF Connect SDK Tutorial, and will go through some topics that weren't covered there. It is not mandatory to have completed that tutorial in order to start on this one, but it may be beneficial, as it gives a basic understanding of the nRF Connect SDK (NCS). After completing this tutorial you will be more familiar with the sensor drivers that comes with the Zephyr Project and how use them in your project. You will also learn how to use a custom board with NCS, how to include header and source files to your project and some basics about error handling.

Before starting on this tutorial you should complete the two first stepsfo of the guide Getting Started with nRF9160 DK, specifically "Installing nRF Connect for Desktop, Getting....." and "Setting Up nRF Connect SDK Environment".  It will instruct you how to set up NCS v1.0.0. You don't need to install the LTE Link Monitor. 

In this tutorial, the nRF9160 DK will be used and you wihell need this board if are going to copy and paste the code provided. However, it is not required to have this board, as I try to explain the concepts in a general manner, and you can modify the code to fit your particular board.


1. Use the sensor drivers

The zephyr project includes a set of drivers for different sensors, everything from MPU-6050 for measuring acceleration to MAX30101 for measuring heart rate. The complete list of the supported sensors can be seen here. Using these drivers simplifies the communication with you sensor, and you don't need to have in-depth knowledge of your specific sensor and all the different interfaces, such as I2C and SPI. You will not use the sensor drivers directly, instead you will use a simplified layer on top of that. Specifically, the Sensor Subsystem

Zephyr has a set of almost complete samples (Some modifications/additions for your specific board might be needed), that demonstrates how to use the sensor drivers. Take a look at the documentation for the samples and the actual code.

1.1 How it works

The sensor API

The Zephyr projects sensor Subsystem offers a generic sensor API  that is compatible with a wide range of different sensors. The API includes a limited set of functions that makes it fairly easy to communicate with a sensor. The API includes the following functions (in addition to some functions used for conversion of sensor data):

  • sensor_attr_set(..): This function is used to set an attribute for a sensor, and can be used for writing to a sensors internal registers.
  • sensor_trigger_set(..): If an interrupt based sensor is used, this function needs to be called to activate the sensor's trigger and set the trigger handle
  • sensor_sample_fetch(..): In order to read the sensor data, this function has to be called. It will fetch the data from all the sensors active channels* and store it in the internal buffer of the sensor driver (E.g. the adxl362 sensor will store it in a struct of the following type).
  • sensor_sample_fetch_chan(..): This functions does the same as sensor_sample_fetch(..), except that it will only read an individual channel instead of all.
  • sensor_channel_get(..) :This will get the sensor data for a specified channel from the sensor drivers internal storage. It will get the data previously fetched by the sample_fetch() function. Then the measurements can be used in the main application and read by the user.

*A channel specifies the type of sensor data, like pressure, temperature or humidity. Check out the different options here.

The call stack

The image below gives an overview of the different abstraction layers when using the sensor API with the sensor ADXL363 and nRF9160. The call stack starts at the top with the function sensor_sample_fetch(..), from zephyr's sensor API and ends at the bottom with spim_xfer(), which uses nRF9160 registers directly. The figure is far from complete and misses many steps in between. However, it should give a simple overview of the different layers included when using Zephyr's sensor API.

Some important files

Overlay file: By looking at the samples for the sensors provided above, you can see that many of them includes an overlay file. The sensor is incorporated into the device tree by setting it as a child node of peripheral that is able to communicate with the sensor. E.g. for the ADXL362 sensor, the sensor node can be set as a child node for the SPI3 peripheral, since the sensor uses SPI to communicate. The device tree will then generate DT definitions, which the sensor driver will use when initializing and configuring the sensor.

prj.conf file: Some configurations needs to be set in order to use the driver for a particular sensor. This includes configurations (configs) for enabling particular features and modes for a sensor, configs for making the specific sensor available and some configs for enabling SPI or I2C peripherals.

Device binding

Check out Device Driver Model in the Zephyr Documentation, which explains device drivers in an intuitive manner and provides nice illustrations.

Each sensor driver includes the macro DEVICE_AND_API_INIT(..). This macro will create a device structure, which includes some configuration information and a pointer to the driver API for the particular sensor. The device structure will be associated with a label.

After the device structure is created, the function device_get_binding(..) can be called in the main application in order to retrieve the device structure for a driver by label. This device structure will be passed along when using the sensor API functions in sensor.h, and will bind the generic sensor functions to the particular sensor driver functions (E.g. sensor_sample_fetch(..) is associated with adxl362_sample_fetch(..)).

Enable the peripheral

In order to for a sensor to communicate with the chip, a communication protocol needs to be used, for example SPI or I2C, and these peripherals need to get configured and included correctly. In the same way generic sensor functions are associated with specific sensor functions (e.g. adxl362.c), like explained in "Device binding" in section 1.1, the specific sensor functions (e.g. adxl362.c) has to get linked with lower level bus protocol drivers, like SPI or I2C. As you can see in the figure of the call stack in section 1.1 How it works, the ADXL362 sensor functions (adxl362.c) will be linked with the Nordic Zephyr functions for the SPI peripheral (spi_nrfx_spim.c).

For this association to take place, for lets say the adxl362 sensor with SPI interface, two things need to happen:

Configure the peripheral correctly

Include the peripheral into the device tree

The configurations will take care of stuff related to the software, like including the proper drivers and running the correct macros. However, this is not enough, a hardware description of the peripheral is also needed. If you take a look at the macro SPI_NRFX_SPIM_DEVICE(..), you can see that it uses DT defines to configure the SPI. This is where the overlay file comes into the picture. As explained above, by putting the sensor node inside the node of the appropriate peripheral, the proper DT defintions will be generated. Take a look at the bindings inside <..>/ncs/zephyrdts/bindings/sensor, for guidance how to implement your specific sensor. The walkthrough below shows an example of such an overlay file, where the BME280 sensor is used with an I2C interface.

1.2 Walkthrough for BME280

This section will show you, step by step, how to use the BME280 sensor with the nRF9160 DK.

  • Start by copying the folder <sourcecode_root>\ncs\zephyr\samples\sensor\bme280 into your working folder where your projects are located (E.g. <..>/ncs/nrf/samples/nrf9160)
  • Create a file named nrf9160_pca10090ns.overlay to the bme280 folder and add the following contents inside it:

&i2c3 {
	status = "ok";
	sda-pin = < 12 >;
	scl-pin = < 11 >;
    clock-frequency = <I2C_BITRATE_STANDARD>;  
	/* The I2C address could be one of two, here 0x76 is assumed */
	bme280@76 {
		compatible = "bosch,bme280";
		reg = <0x76>;
		label = "BME280";

In the overlay file above, you can see a reg field inside the bme280 node. It is set equal to 0x76, which is the I2C address of the BME280 sensor. The address will generate a define in a header file, which will be used by drivers to communicate with the sensor. Be aware that a BME280 sensor of this kind was used in this tutorial, where the address always will be equal to 0x76. If you are using a different kind, your address will depend on how SDO is connected. Check out the datasheet for BME280 for more information.

  • Add the following to the prj.conf:


  • Connect the BME280 sensor to your nRF9160 DK in the following manner:
BME280 nRF9160 DK
SCL P0.11 
SDA  P0.12 

As mentioned earlier this sensor was used in this tutorial, which has a limited set of pins. If you're sensor comes with a different board, please study the datasheet of how to connect it properly.

  • Build and flash the example by running:

west build -b nrf9160_pca10090ns && west flash

  • Verify that it's working by opening a terminal and check if you are able to see the serial output (temperature, pressure and humidity). It should look similar to this:

SPM: NS image at 0xc000
SPM: NS MSP at 0x200209b8
SPM: NS reset vector at 0xe08d
SPM: prepare to jump to Non-Secure image.
***** Booting Zephyr OS v1.14.99-ncs2 *****
dev 0x20021870 name BME280
temp: 24.600000; press: 99.504062; humidity: 60.510742
temp: 24.600000; press: 99.503496; humidity: 60.510742
temp: 24.430000; press: 99.483300; humidity: 60.610351
temp: 24.430000; press: 99.487820; humidity: 60.766601
temp: 24.430000; press: 99.491605; humidity: 60.932617
temp: 24.440000; press: 99.494375; humidity: 61.055664

1.3 Pitfalls

Sensor init function

In order to initialize the kernel, the function z_cstart(..) needs to run. It will do some basic hardware initialization that includes invoking the initialization routine for each object created by DEVICE_(AND_API_)INIT. The initialization routine for many sensors include testing the sensor API and checking if it works properly. E.g. the init function for the ADXL362 driver will read the PARTID register and check if the value is correct according to the datasheet. If this register check, or any other check fails, the init function will return a non-zero value and the driver API of the particular sensor object will set to NULL. This is done in <..>/ncs/kernel/device.c, check it out here.

If the init function for your sensor fails, the call to device_get_binding() for that particular sensor will fail as well, due to the absence of a driver API. To avoid this, it is important to connect your sensor correctly to the board. You should study the datasheet for your particular board carefully on how to connect the sensor. If you've made sure all the wring is done correctly and it still fails, you can get to the bottom of it by enabling logging, checking return values and debugging your program. I will go through this quickly later on in this tutorial, in section 4. Error handling.

GPIO pins are used by other pieces of hardware

When choosing pins for your sensor, you have to make sure those pins aren't used by other onboard functionality, such as LEDs, Buttons or any of the interface MCUs. Check out the different options in nRF9160 DK board control on the infocenter. If you would like to change the GPIO routing, you should check out this Devzone ticket, where Martin will guide you through the process.

2. Use a custom board with NCS

This section will give you some guidance of how to use your custom board with NCS. If you want to build and use your board with NCS, you have to add a folder to zephyr/boards/ and include a set of different files into it, in order to be able to build and flash it and make it compatible with the Zephyr API. I didn't see any reason to create a step-by-step guide of how to create such a folder, as the end result would just be a complete board folder, which the Zephyr Project already contains a lot of already. A better approach is to simply copy an already present board folder, most similar to your custom board, and modify it according to your board.

The first section of this chapter will go through the different board files that may be included into the board folder, and can be useful when modifying the files of an already present board folder to be compatible with your board. The second section will show you the necessary changes that must be done to the NCS in order to build with a non-secure board. The first part of the second section is more general, and explains how to change the naming of a board folder to suit your custom board.

I would recommend you to take a look at the Device Tree section in the Zephyr documentation as well, which gives some guidance of how to adding device tree support for a custom board. Electronut Labs also has a nice tutorial that is worth checking out, it shows how to add support for a custom board with Zephyr.

2.1 The board files

DTS files

The board folder must contain a dts file, which will describe the board and make it a part of the Device Tree. Read more about the Devie Tree in the NCS tutorial. The dts file will describe how LEDs and buttons are connected to GPIO pins, what peripheral will be used as the standard console and memory placement of various images. Check out the dts file for the nRF52_pca10040 board to get an idea of how it may look like. I will quickly explain the different elements that may be included in this file.

The compatible property is required for any node in the Device Tree, and helps the system to decide what driver to bind to the device. The compatible field describes what device the node is representing. The nodes are mapped to the bindings via the compatible field. E.g. the compatible field of the leds node in the board file for nRF52 pca10040 is set equal to "gpio-leds", which will map it to the binding gpio-leds.yaml. Read more about bindings in the Zephyr documentation.

The chosen property makes it possible to select a particular piece of hardware to serve a specific purpose. E.g. in one of the board files for nRF9160 pca10090 UART0 is chosen as the console for the system. If this peripheral instance connected to the onboard debugger (which it should by default), you can see the output from printk() on the computer

Looking at the bottom of the nRF52 pca10040 board file, you will notice the partitions property inside &flash0. In the partitions node, it is possible to specify the size and location of different images in flash. The main application for the pca10040 wil be placed according to the field slot0_partition, since it is assigned to zephyr,code-partition in the chosen field, take a look at it here. Partitions are also convenient when using mcuboot, as you can specify the location of the bootloader, firmware upgrade images and temporary storage. If you look at the board files for the nRF9160 pca10090 you can see that it has four different dts files, which is because the placement of non-secure and secure applications differs. Study these files as well to get an understanding of how SRAM allocation is done. Read more about flash partitions in the Zephyr documentation.

If your board has leds and buttons connected to the chips GPIOs, and you want access it using the Zephyr API, you should include it into the Device Tree. This can be done by incorporating the nodes described in respectively ../bindings/gpio/gpio-leds.yaml and ../bindings/gpio/gpio-keys.yaml into your dts files. You simply select the pins the buttons and leds are connected to and choose a label. If you create aliases for the nodes, the generated names will be simplified and easier accessible in your samples. E.g. LED1_GPIO_PIN can be used instead of DT_GPIO_LEDS_LED_1_GPIO_PIN.

Configuration files

The configuration files makes it possible to enable/disable features in the software, read section 1.5 in the NCS tutorial for a more in-depth explanation. The board folder may contain a set of different configurations, such as Kconfig, Kconfig.board, Kconfig.defconfig and <your_board>_defconfig. The difference between these files, is mainly that the Kconfig files defines configuration symbols for the first time, and the defconfig files makes it possible to change already defined configurations.

In the Kconfig file, you can define configuration symbols needed for your particular board. E.g. for the nRF52840 pca10090 board, configurations related to the routing of pins on to the nRF91 DK is created in the Kconfig file. The Kconfig.board file defines the board config, e.g. if you build the board nRF9160 pca10090, the configuration CONFIG_BOARD_PCA10090 will be defined and visible in menuconfig.

In the file Kconfig.defconfig, the default values of various features and hardware interfaces are set. The file is used for enabling a specific instance of a peripheral, if the peripheral is enabled. E.g. if ADC is enabled, you can enable the ADC0 instance, by setting its default value to Y. The file <your_board>_defconfig is used to set visible configuration symbols. Configurations for the SoC, architecure and the board are set in this file. In this file, you can enable the console and choose a particular peripheral as the systems console.  Check out the file nrf9160_pca10090_defconfig to get a better understanding of the file.

yaml file

In <your_board>.yaml, you specify the properties for your board, such as toolchain, supported features and ram and flash size.


This file simply instucts how to flash and debug the board.

Fixup file

The board folder may contain a fixup file, which can be used to redefince definitions (from configurations and the device tree) to be compatible with drivers.

Header and source files

Header and source files may be present in the board folder, such as board.c/h, pinmux.c and nrf52840_reset.c. These files can then be used by drivers.

2.2 Walkthrough

In this section, I will show you what needs to be changed in NCS in order to build a non-secure board. When creating a board folder, you can use the approach described in this section, by copying an already present board folder, and changing the names according to your board. If you want to run non-secure applications on your board, you have to complete the last part of this section.

  • Copy the folder <..>\ncs\zephyr\boards\arm\nrf9160_pca10090 into <..>\ncs\zephyr\boards\arm and give it the name nrf9160_test (or nrf9160_<your-board>)
  • Perform a “Find and replace” in the folder <..>\ncs\zephyr\boards\arm\nrf9160_test and replace “nrf9160_pca10090” with “nrf9160_test” (This can be done by using e.g. Notepad++ or M. Visual Studio)
  • You also have to change the name of the folders to match you new board name, e.g. “nrf9160_pca10090_common.dts” should be changed to “nrf9160_test_common.dts”. It should look like this:
    • Let's test the new board. Open ncs\zephyr\samples\hello_world in the command line and run:
      • nrfjprog --eraseall && west build -b nrf9160_test && west flash

      • Verify that it's working by checking the serial output. The output should be "Hello World! nrf9160_test"
        • Let's try to run the non-secure version of the board, type in:
      • nrfjprog west build -b nrf9160_testns -d build_ns

      • You will get an error. In order to fix this, do the following:
        • In <..>\ncs\nrf\samples\nrf9160\spm copy the file nrf9160_pca10090.overlay into the same folder and name it nrf9160_test.overlay 
        • In <..>\ncs\zephyr\cmake\app\boilerplate.cmake go to line 267 and add the following: 


        # TODO: Allow multiple non-secure images by using Kconfig to set the
        # secure/non-secure property rather than using a separate board definition.
        if(${BOARD} STREQUAL nrf9160_pca10090ns)
          set(BOARD nrf9160_pca10090)
          message("Changed board to secure nrf9160_pca10090 (NOT NS)")
        if(${BOARD} STREQUAL nrf9160_pca20035ns)
          set(BOARD nrf9160_pca20035)
          message("Changed board to secure nrf9160_pca20035 (NOT NS)")
        if(${BOARD} STREQUAL nrf9160_testns)                       #Added this
          set(BOARD nrf9160_test)                                  #Added this
          message("Changed board to secure nrf9160_test (NOT NS)") #Added this
        endif()                                                    #Added this
        if(EXISTS              ${APPLICATION_SOURCE_DIR}/${BOARD}.overlay)
      # The SHIELD can be set by 3 sources. Through environment variables,
      # through the cmake CLI, and through CMakeLists.txt.

      • Eventually, build and flash it:

        nrfjprog --eraseall && west build -b nrf9160_test -d build_ns && west flash -d build_ns

        • Verify that it is working by checking the serial output
          • You have now created a new set of board definitions and made the necessary changes to run a non-secure application. You can now modify the files in <..>\ncs\zephyr\boards\arm\nrf9160_test according to your custom board.

          3. Include header and source files to your project

          If your project is getting large, it may be convenient to put some of the functions in different source files. Here I will demonstrate how this can be achieved

          • Start by creating a sample containing the elementary files. You can follow the steps under 2.1.1 Set it up in the NCS tutorial, and create a hello world sample
          • In the src folder, create a file named multiply.c, with the following content:

          #include "multiply.h"
          int multiply(int x, int y){
          	return x*y;

          • Next, in your project folder (E.g. in <sourcecode_root>\ncs\nrf\samples\nrf9160\hello_world ) create a folder named includes. Then, create the header file multiply.h add the following content to it:

          int multiply(int x, int y);

          • In main.c you need to include the header file and use the multiply function. Use the code snippet below:

          #include <zephyr.h>
          #include <misc/printk.h>
          #include "multiply.h"
          void main(void)
          	int x = 5;
          	int y = 2;
          	int my_number = multiply(x, y);
          	printk("%d times %d equals %d\n",x,y, my_number);

          • The last step involves specifying the target source and including the header file. This can be done by opening the CMakeLists.txt file and addding the lines as shown in the code snippet below

          # Copyright (c) 2018 Nordic Semiconductor
          # SPDX-License-Identifier: LicenseRef-BSD-5-Clause-Nordic
          cmake_minimum_required(VERSION 3.8.2)
          include($ENV{ZEPHYR_BASE}/cmake/app/boilerplate.cmake NO_POLICY_SCOPE)
          zephyr_include_directories(includes)        #Add this line
          target_sources(app PRIVATE src/multiply.c)  #Add this line
          target_sources(app PRIVATE src/main.c)

          • Build and flash the example by running:

          west build -b nrf9160_pca10090 && west flash

          • Verify that it is working by checking the serial output

          4. Error handling

          4.1 Errno

          The C standard library includes the header file errno.h, which contains a set of error codes that can be used by functions for reporting errors. Take a look in the errno.h file in the C minimal library in Zephyr for an example. All the APIs in Zephyr uses these errors. At the top of errno.h, errno is defined as a function. This macro function will return the last error of the current thread, and there will be one such macro for each thread. Some functions will use the errors in errno.h directly (E.g. the function configure() in spi_nrfx_spim.c) and some functions will use the function macro (E.g. the function mqtt_client_tcp_write() in mqtt_transport_socket_tcp.c).

          If you study the image in section 1.1, you can see that the top layers only include functions from the Zephyr API, and further down Nordic specific functions are used. The functions that are part of the Zephyr API will only return generic errors defined in errno.h, while the functions deeper down in the call stack will return Nordic specific error codes, which are more descriptive. Let's take a look at a specific example. If the Nordic specific function nrfx_spim_xfer(..) fails, it may return NRFX_ERROR_BUSY or NRFX_ERROR_INVALID_ADDR. This function is used in the Zephyr driver spi_nrfx_spim.c, and the Nordic specific error types, just mentioned, will be translated into -EIO (I/O error):

          if (!error) {
          			result = nrfx_spim_xfer(&dev_config->spim, &xfer, 0);
          			if (result == NRFX_SUCCESS) {
          			error = -EIO;

          Take a look at section 4.2 Debugging, where I will guide you how to debug your code, and you will be able to figure out what is happening in the Nordic specific functions.

          4.2 Debugging

          Run a debug session

          When your application stops working and you want to get to the bottom of it, then you may want to debug your code. Here I will quickly show you how to do it in NCS and SEGGER Embedded Studio V4.18.

          • Start by creating a sample containing the elementary files. You can follow the steps under 2.1.1 Set it up in the NCS tutorial, and create a hello world sample
          • Add the following config to prj.conf


          • Then copy this content into src/main.c

          #include <zephyr.h>
          #include <device.h>
          #include <gpio.h>
          /* 1000 msec = 1 sec */
          #define SLEEP_TIME 	1000
          void main(void)
          	int cnt = 0;
          	struct device *dev;
          	dev = device_get_binding("GPIO_0");
          	/* Set LED pin as output */
          	gpio_pin_configure(dev, 3, GPIO_DIR_OUT); //p0.03 == LED2
          	while (1) {
          		/* Set pin to HIGH/LOW every 1 second */
          		gpio_pin_write(dev, 3, cnt % 2);	//p0.03 == LED2

          • Open SEGGER Embedded studio Nordic Edition V4.18, press File→Open nRF Connect SDK Project and choose the following options:
            • CMakeLists.txt: <sourcecode_root>/ncs/nrf/samples/nrf9160/hello_world/CMakeLists.txt
            • Board Directory: <sourcecode_root>/ncs/zephyr/boards/arm/nrf9160_pca10090
            • Board Name: nRF9160_pca10090ns
            • Build Directory: <sourcecode_root>/ncs/nrf/samples/nrf9160/hello_world/build_nrf9160_pca10090ns
          • Before doing anything else, connect the nRF9160DK to your computer, with SW5 set to nRF91. Then open a command line and run nrfjprog --eraseall
          • Keep the board connected and press Build→Build zephyr/merged.hex in SES 
          • Press Debug→Go. This will start a debug session
          • Open the file gpio_nrfx.c in the Project Explorer window

          • Create a breakpoint in the function gpio_nrfx_write(..) as shown in the image below

          • Press Debug→Go until you hit the breakpoint in gpio_nrfx_write()
          • To get more information about your program and the execution you can open a set of different Debug windows. E.g. to see the call stack, you click on View→Call Stack

          Other debugging tools

          • Check out this guide, which shows you how to debug a Zephyr application using SEGGER OZone 
          • Zephyr provides an extension command for debbuging an application, take a look at Building, Flashing and Debugging in the Zephyr documentation for more information

          4.3 Logging

          You may have have encountered two different log functions in the Zephyr examples, printf() and printk(). The former function is a C library function that simply takes in string and arguments, converts it into a string and sends it out to the console. The latter is a kernel function that has the ability to print out various log levels, and has to be used if running in kernel mode, since C library functions are not available in this mode.

          Another option is to use Zephyr's highly flexible logger API, which makes it possible to filter messages in a set of different ways. Messages can be filtered based on particular modules or log level, during compile and runtime. Read more about the Logger API in the Zephyr documentation and check out this comprehensive guide on foundries, which covers various aspect of this API. By setting UART0 as the systems console in the board folder, you can see the log output from this particular peripheral (If using nRF9160 pca10090 you can see the output on the computer, as UART0 can be connected to the interface MCU).