nRF Connect SDK Tutorial - Part 3 | NCS v1.2.0

→ Check out the preceding part of this tutorial series before starting on this: nRF Connect SDK Tutorial - Part 2


This is the third and last part of this tutorial series and will go through some important aspects that haven't been covered yet. After completing this tutorial you will be more familiar with the sensor drivers that come with the Zephyr Project and how to use them in your project. You will also learn how to use a custom board with NCS and some basics about error handling.

It is recommended to follow this tutorial series chronologically, but it's not a requirement. Before starting on this part you should at least have done the following:

  • Read and executed the instructions in Part 0 of this tutorial series.
  • Gone through the first section (Your first "Hello World") of part 1 of this tutorial series

This will help you with setting up the nRF Connect SDK and give you a basic understanding of how to set up a simple example in NCS.

As mentioned in Part 0, you should choose a board and set <board> and <board_variant> accordingly. This applies to this part as well, and when you see one of these variables use your chosen value instead.

In this tutorial, the west tool will, for the most part, be used to build and flash the projects. If you would like to use SES instead, check out 1.2 Build and flash - using SEGGER Embedded Studio.

Contents

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 your 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 make 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 function 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 include an overlay file. The sensor is incorporated into the device tree by setting it as a child node of the 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 need 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 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 be configured and included correctly. In the same way generic sensor functions are associated with specific sensor functions, like explained in "Device binding" in section 1.1, the specific sensor functions (e.g. adxl362.c) have 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 let's say the adxl362 sensor with SPI interface, two things need to happen:

1. Configure the peripheral correctly

→ CONFIG_SPI and CONFIG_SPI_2 needs to be enabled (=y) in prj.conf.

→ This will include the folder <..>/ncs/zephyr/drivers/spi, through some cmake logic in <..>/ncs/zephyr/drivers/CMakeLists.txt

→ CMake will then find the correct low-level driver based on the board being used, e.g. if the nRF9160 DK is used,  spi_nrfx_spim.c will be included to the project 

→ Eventually, the macro SPI_NRFX_SPIM_DEVICE(2) and consequently DEVICE_DEFINE() will run. This will create a device structure for the peripheral, and make it possible to call device_get_binding(..) and use the functions inside spi_nrfx_spim.c.

2. 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 definitions will be generated. Take a look at the bindings inside <..>/ncs/zephyr/dts/bindings/sensor, for guidance on 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 your board in NCS

  • Start by copying the folder C:\Users\<user_name>\ncs\v1.2.0\zephyr\samples\sensor\bme280 into your working folder where your projects are located (e.g. C:\my_projects)
  • Add the following to the prj.conf:

CONFIG_I2C_1=y

  • Create a file named <board_variant>.overlay to the bme280 folder and add the following contents inside it:

&i2c1 {
    compatible = "nordic,nrf-twim";
	status = "okay";
	sda-pin = < 30 >;
	scl-pin = < 31 >;
    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.

  • Connect the BME280 to your board according to the table below (these pins works for all the tested boards):
BME280 Board
SCL P0.31 
SDA  P0.30 
GND GND
VIN VDD

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

west build -b <board_variant> && 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
.
.

If you would like to build and flash this sample with SES, check out 1.2 Build and flash - using SEGGER Embedded Studio for instructions.

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 includes 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 wiring 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.

If you are using an nRF9160, 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. Using a custom board with NCS

This section will give you some guidance on 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 C:/Users/<user_name>/<ncs_version>/v1.2.0/zephyr/boards/<arch> and include a set of different files in 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 that uses your chip and is 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 in the board folder and the section 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 contains a subsection that gives some guidance on how to add device tree support for a custom board. 

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 Device Tree in section 1.4 The Device Tree in part 2 of this tutorial series. 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 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 is 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 will 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 differ. 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 chip's GPIOs, and you want to access it using the Zephyr API, you should include it into the Device Tree. This can be done by incorporating the nodes described in ../bindings/gpio/gpio-leds.yaml and ../bindings/gpio/gpio-keys.yaml into your dts files respectively. 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 make it possible to enable/disable features in the software, read section 1.5 Configurations in NCS tutorial - Part 1, 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 define configuration symbols for the first time, and the defconfig files make 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 are created in the Kconfig file. The Kconfig.board file defines the board configs, 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, architecture 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 <board_variant>.yaml, you specify the properties for your board, such as toolchain, supported features and ram and flash size.

board.cmake

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

Fixup file

The board folder may contain a fixup file, which can be used to redefine 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 order to keep things more simple and less abstract, this section is not written in a generic manner like the rest of this tutorial series. Instead, it is written specifically for a custom board with the nRF9160 SiP. However, the approach is the same for any board, e.g. if your custom boards is based on the nRF5340 SoC copy the folder C:/Users/<user_name>/ncs/<ncs_version>/zephyr/boards/arm/nrf5340_dk_nrf5340 instead

In this section, I will show you what needs to be changed in NCS in order to build an application for a custom 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 C:/Users/<user_name>/ncs/<ncs_version>/zephyr/boards/arm/nrf9160_pca10090 into C:/Users/<user_name>/ncs/<ncs_version>/zephyr/boards/arm and give it the name nrf9160_test (or nrf9160_<your-board>)
  • Perform a “Find and replace” in the folder C:/Users/<user_name>/ncs/<ncs_version>/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 your 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 C:/Users/<user_name>/ncs/<ncs_version>/zephyr/samples/hello_world in the TM Bash shell (check out 1.3.2 Build and flash):
      • 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:
      • west build -b nrf9160_testns -d build_ns

      • You will get an error. In order to fix this, do the following:
        • Open C:/Users/<user_name>/ncs/<ncs_version>/zephyr/samples/hello_world/CMakeLists.txt in notepad and add following code snippet under cmake_minimum_required(..):

      include($ENV{ZEPHYR_BASE}/../nrf/cmake/boilerplate.cmake)

        • In <..>/nrf/cmake/multi_image.cmake go to line 38 and add the following:

           

      set(nonsecure_boards_with_ns_suffix
          nrf9160_pca10090ns
          nrf9160_pca20035ns
          nrf9160_testns                                #Added this
          nrf5340_dk_nrf5340_cpuappns
      )

      • Eventually, build and flash it:

        nrfjprog --eraseall && west build -b nrf9160_testns -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 C:/Users/<user_name>/ncs/<ncs_version>/zephyr/boards/arm/nrf9160_test according to your custom board.

          3. Error handling

          3.1 Errno

          This text was initially written for the manual installation of NCS

          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. Depending on whether CONFIG_NEWLIB_LIBC is enabled or not, the location of errno.h will vary. If CONFIG_NEWLIB_LIBC is set to "y", the file <..>\gnuarmemb\arm-none-eabi\include\sys\errno.h will be used (is included through <..>\ncs\<ncs_version>\zephyr\lib\libc\newlib\CMakeLists.txt). If CONFIG_NEWLIB_LIBC is set to "n" the errno.h file in the C minimal library in Zephyr will be used. All the APIs in Zephyr use these errors. At the top of errno.h, errno is defined as a macro function. This 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 might be 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) {
          				return;
          			}
          			error = -EIO;
          		}
          .
          .

          Take a look at section 4.2 Debugging, where I will guide you on how to debug your code, and you will be able to figure out what is happening in the Nordic specific functions. Usually, you should be able to debug and solve your problems at the top Zephyr layer, but sometimes it is useful to dig a little deeper.

          3.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.52 (the version used in the TM with NCS v1.2.0)

          • Copy the sample <..>\ncs\<ncs_version>\zephyr\samples\basic\blinky into your project folder (e.g. C:\my_projects)
          • Add the following config to <..>/blinky/prj.conf

          CONFIG_DEBUG_OPTIMIZATIONS=y

          • Open SEGGER Embedded studio Nordic Edition as explained in 1.2 Build and flash - using SEGGER Embedded Studio, press File→Open nRF Connect SDK Project and choose the following options:
            • CMakeLists.txt<project folder>/blinky/CMakeLists.txt
            • Board Directory: C:/Users/<user_name>/ncs/<ncs_version>/zephyr/boards/arm/<board>
            • Board Name: <board_variant>
            • Build Directory: <project folder>/blinky/build_<board_variant>
          • Before doing anything else, connect your board to the computer and turn it on (if you are using an nRF9160 DK, set SW5 to nRF91). Then open a command line and run nrfjprog --eraseall
          • Keep the board connected and press Build→Build Solution 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 debugging an application, take a look at Building, Flashing and Debugging in the Zephyr documentation for more information

          3.3 Logging

          You may 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 aspects 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 the nRF9160 DK you can see the output on the computer, as UART0 can be connected to the interface MCU).

          If you have any questions regarding this tutorial, please create a ticket in DevZone.