In this exercise, you will learn through hands-on exercises how to differentiate between thread context and interrupt context, and to understand the allowed operations in each.
The exercise covers the use of the Kernel Timer API. It is used to periodically trigger a function, called an interrupt service routine (ISR), that runs in the System timer interrupt context. The Kernel uses the System timer, which is the RTC1 peripheral on the nRF91, nRF53, and nRF52 Series hardware. On the nRF54 Series, it’s the global real-time counter peripheral (GRTC).
You will practice collecting information about all the threads running in an nRF Connect SDK application: their stack allocation and run-time usage, as well as the priority and state (Running, Ready, Unready). You will also learn how to see the ready queue content at any given time. For both of these, you will use the nRF Debug view in nRF Connect for VS Code.
In addition, you will learn how to use a message queue to pass data safely between threads.
This exercise assumes you have already finished Lesson 1 of the nRF Connect SDK Fundamentals course and have the nRF Connect SDK and nRF Connect for VS Code already set up.
You only need to do steps A to D once throughout the course.
Get the source code of this course as a third-party repository inside the SDK itself. This gives you better integration with the nRF Connect for VS Code extension.
A. Open VS Code, then open the nRF Connect terminal as shown below.
It is important to use the nRF Connect terminal as this is the terminal where the build toolchain, such as west
, are sourced and available for use. If you use the other terminals, you will get an error saying that the command is not recognized.
B. Type the command west manifest --path
in nRF Connect Terminal.
Hover over the printed file path, hold Ctrl, and left-click to open the file.
C. Include the repo of the course.
Include the repository by adding the following lines inside the west.yml
file of your selected SDK version. Add the following information at the end of the # Other third-party repositories
section. Make sure to save the new changes in the file.
- name: devacademy-ncsinter
path: nrf/samples/devacademy/ncs-inter
revision: main
url: https://github.com/NordicDeveloperAcademy/ncs-inter
YAMLMake sure that the copied code’s indentation is as shown in the screenshot below. Otherwise, west will throw an error.
D. Type west update -x devacademy-ncsinter
to get the course repository.
With this, the course’s exercises (base code and solutions) are stored locally on your machine in <install_path>\nrf\samples\devacademy\ncs-inter
and nRF Connect for VS Code can detect them since they have the right metadata files (sample.yaml
).
We added the course repository to the nRF Connect SDK itself purely for educational purposes. The goal is to expose you to west.yaml
and some west
commands that can pull in third-party repositories. You can also get the course repository using GitHub directly, as done in the nRF Connect SDK Fundamentals course.
You will need to do this step for every exercise in this course.
A. In nRF Connect for VS Code, create a new application.
Navigate to the nRF Connect for VS Code extension, select Create a new application and then select the Copy a sample option as shown below.
Choose the SDK version you plan to use if you have multiple versions installed.
B. Type in the name of the exercise code base.
In the Create New Application from Sample window, type in the name of the exercise. For this exercise, the name is Lesson 1 – Exercise 1. Make sure you select the code base (not the solution), as shown below.
The course repository contains two version directories depending on which nRF Connect SDK version you are using: v2.8.x-v2.7.0
and v2.6.2-v2.5.2
. Select the directory that matches the nRF Connect SDK version you are using. Some of the exercises will also have varying exercise texts depending on which version you are using, this will be reflected by tabs at the beginning of the exercise text.
Please note that not all exercises are supported in v2.8.x-v2.7.0 yet. Support is ongoing.
C. Select a folder where you want the code to be stored.
Type in the path to the folder where you want the code to be stored, followed by the name of the code base. The examples in this exercise will store the code in C:\myfw\ncsinter
and name it l1_e1
.
D. Press Enter to make a copy of the base code and store it in the specified directory.
Now, you are ready to work on the code.
In this exercise, you will develop an application with five threads (producer, consumer, main, logging, idle) and a function that runs periodically every 500 milliseconds (timer) in an interrupt context. The timer will do a simple task of toggling LED0 and LED1 in sequence.
The main thread is a system thread created by the kernel itself, and calls the main()
function defined by the user code if one exists. In this exercise, you will define a main()
function that will setup the GPIO pins for LED0 and LED1, start a timer, and then terminate normally by returning 0
. You will examine the functions by using nRF Debug view in nRF Connect for VS Code.
The logging thread is created by default when enabling the logger module, which uses the deferred mode by default.
The idle thread is a system thread that gets created by the kernel itself, and is responsible for putting the system in a power-saving mode.
The main thread priority is set with CONFIG_MAIN_THREAD_PRIORITY
, which has the default value of 0
(the highest priority for a preemptable thread). The stack for the main thread is set to the default value of CONFIG_MAIN_STACK_SIZE
, which is 1024 bytes.
You will create a producer thread with a priority of 6. In the producer thread, you will generate emulated sensor values (x,y,z accelerometer emulated data) and put them in a message queue every 2.2 seconds. You will set the stack size for the producer thread to 2048 bytes.
The consumer thread will be created with a priority of 7 and a stack size of 2048 bytes. It will simply sleep until there is some data in the message queue. Once there is data in the message queue, the thread state is changed to Ready and the scheduler will schedule for execution. The consumer thread consumes the data in the message queue and sends it to the logger module. The logger module, once it’s scheduled, sends the data to the logger backend, which is UART0 by default. The scheduler will then have no Ready threads in the ready queue except the idle thread. The idle thread is called, and the system enters a power-saving mode which is automatically managed by the idle thread. This cycle is repeated once the producer thread wakes up from sleep.
1. Enable debugging.
Add the following lines in the application configuration file (prj.conf
):
CONFIG_DEBUG_THREAD_INFO=y
CONFIG_DEBUG_OPTIMIZATIONS=y
KconfigIt’s also possible to enable debugging by selecting the option Optimize for debugging (-Og) in the Optimization level in the Add Build Configuration GUI within nRF Connect for VS Code. Checking this option will pass -DCONFIG_DEBUG_OPTIMIZATIONS:STRING="y" -DCONFIG_DEBUG_THREAD_INFO:STRING="y"
to the Cmake build system. Enabling debugging has the same effect, whether enabled in VS Code, prj.conf
or both.
2 . Create a timer with a handler function that executes in an interrupt context.
2.1. Define a Timer.
The timer is defined by using the K_TIMER_DEFINE() macro. It takes three parameters:
NULL
function can be specified. Since this function executes in an interrupt context, it will interrupt anything running. This means that you need to be careful not to call kernel APIs that are intended for the thread context, and absolutely no blocking operation is allowed in this function.NULL
function can be specified.Add the following code in main.c
:
K_TIMER_DEFINE(timer0, timer0_handler, NULL);
CThe code above creates a timer (timer0
) and assigns it the expiry function timer0_handler
which we will define in Step 2.3. Since the plan is not to stop the timers prematurely, the stop function is set to NULL
.
2.2. Start the timer.
The timer will be started with the k_timer_start() function. Use the function to set the timer as a periodic timer or a one-shot timer. The function takes three parameters:
k_timeout_t
value that may be initialized using different units, such as K_MSEC()
or K_SECONDS()
.k_timeout_t
value that must be non-negative. Setting a period of K_NO_WAIT
(equal to zero) or K_FOREVER
means that the timer is a one-shot timer that stops after a single expiration.For example, if a timer is started with a duration of 200 ms and a period of 75 ms, it will first expire after 200 ms and then every 75 ms afterward.
Create a periodic timer that fires up every 0.5 seconds. Add the following code in main()
:
/* start periodic timer that expires once every 0.5 second */
k_timer_start(&timer0, K_MSEC(500), K_MSEC(500));
C2.3. Create the expiry function for the timer.
The expiry function is the function that will execute when the timer fires. Since this function runs in an interrupt context (the system timer – ISR), extra care needs to be taken with the code put inside it. In this exercise, you will keep the code inside the expiry functions very short, and simply toggle either LED0 or LED1 every time the timer fires (every 0.5 seconds), making each LED toggle once every second.
static void timer0_handler(struct k_timer *dummy)
{
/*Interrupt Context - System Timer ISR */
static bool flip= true;
if (flip)
gpio_pin_toggle_dt(&led0);
else
gpio_pin_toggle_dt(&led1);
flip = !flip;
}
CYou have now set up the timer.
3. Pass data between threads using a message queue.
3.1. Define the data type of the message.
Define the message that will be exchanged between the producer and consumer threads as a struct SensorReading
of three uint32_t
members, as shown below
Add the following code to main.c
:
typedef struct {
uint32_t x_reading;
uint32_t y_reading;
uint32_t z_reading;
} SensorReading;
C3.2. Define the message queue.
When defining the message queue, the data type of the message, the number of messages, and the alignment must be defined.
Add the following code to main.c
:
K_MSGQ_DEFINE(device_message_queue, sizeof(SensorReading), 16, 4);
C3.3. Write messages to the message queue.
As covered in the previous topic, use the function k_msgq_put() to write messages to the message queue from the producer thread.
Add the following code inside the producer_func
function:
ret = k_msgq_put(&device_message_queue,&acc_val,K_FOREVER);
if (ret){
LOG_ERR("Return value from k_msgq_put = %d",ret);
}
C3.4. Read messages from the message queue.
Use the k_msgq_get() function to read messages from the message queue.
Add the following code inside the consumer_func
function:
ret = k_msgq_get(&device_message_queue,&temp,K_FOREVER);
if (ret){
LOG_ERR("Return value from k_msgq_get = %d",ret);
}
CNote how the timeout option K_FOREVER
is used to make the thread wait (change the thread’s state to Unready) until there is data in the message queue.
4. Build your application.
This is done by adding a build configuration from the Applications view.
This section is two sets of instructions: one for threads and another for ISRs. To complete either set, you must first complete the following steps:
1. Start a debugging session.
Press the Debug button in the Actions view.
This will flash your board with the firmware and start a debugging session. Execution will break by default at main()
.
2. Open the Run and Debug View.
Click on the Run and Debug icon in the primary sidebar.
3. Open the Thread Viewer.
Click on nRF Debug in the Panel View area to view the Thread viewer.
Using the Thread Viewer, you can find crucial information about all the threads in the application, including:
Complete documentation of the Thread Viewer can be found here.
As you can see in the screenshot above, the application has five threads (main, producer, consumer, logging, idle) with priorities 0, 6, 7, 14, and 15. The currently running thread is the main thread, indicated by the yellow arrow symbol. The stack allocated to each thread is shown in the Thread Viewer, and you can also click the Enable Tracking button to show the stack usage of the stack pointer in real-time as well as the maximum stack usage.
All the entries in the Thread Viewer are clickable. For example, clicking on a thread entry will take you to the code where the function is defined. The same applies for a thread control block.
Complete the following steps using nRF Debug View in nRF Connect for VS Code to find information about the threads in an nRF Connect SDK application: their stack allocation and run-time usage, as well as the priority and state (Running, Ready, Unready). You can also see the ready queue content.
1. Set two breakpoints in your code.
Set one breakpoint in the producer thread, right after the k_msgq_put()
call, and the other in the consumer thread, right after the k_msgq_get()
call. Set the breakpoints by clicking on the column next to the line numbers, as shown in the screenshot below.
2. Continue the execution.
Click on the Start/Continue button, or press F5.
3. Watch the message queue and enable stack usage.
The breakpoint in the producer thread will be hit first, because the producer thread will run right after the main thread. This is because it is the thread with the highest priority in the ready queue by the time the main thread terminates.
The first thing to notice in the Thread View is that the main thread no longer exists since the thread has terminated normally by exiting. The producer thread is now the running thread, as indicated by the yellow arrow.
Right-click on the device_message_queue
variable, and select Add to Watch. This will add the variable device_message_queue
to the Watch window so you can examine what is happening inside the message queue. As you can see in the screenshot below, one message is pushed in the message queue as reflected by the used_msgs: 1 entry.
Also, for the sake of demonstration, enable stack real-time monitoring by pressing on the brush symbol next to the Stack Usage column name.
4. Continue the execution.
Click on the Start/Continue button or press F5, just like in Step 2.
The producer thread will call the k_msleep()
kernel function, which will put the thread to sleep and change its state to Unready (Suspended) until the PRODUCER_SLEEP_TIME_MS
elapses. The scheduler will pick up the highest-priority thread in the ready queue, which at this moment is the consumer thread. This is shown clearly in the Thread Viewer.
5. Watch the message queue.
Notice the consumer thread has safely consumed the message that the producer thread put in the message queue. You can see that the used_msgs entry is now zero.
Also, note the small up-facing arrow in the stack usage for each thread. This shows you the max stack usage that the stack pointer of each thread has reached in real-time. At the same time, the number shows you the current stack consumption.
6. Continue the execution.
The thread will try to get another item from the message queue (since the k_msgq_get()
call is placed inside an infinite loop), but there will be none since the producer thread is already asleep. Therefore, the consumer thread will also be in an Unready state. This leaves only the logging and idle threads in the ready queue.
The scheduler will pick the logging thread, which will send the logs to its backend (UART0 by default). Then the logging thread will no longer have any logs to send out, meaning it will be in the Unready state as well.
The scheduler will then schedule the only thread left in the ready queue, which is the idle thread. The idle thread will be running most of the time, ensuring the system is in power-saving mode.
The logger thread also has a timeout option which makes it possible go to sleep for some time, defined in CONFIG_LOG_PROCESS_THREAD_SLEEP_MS
before it becomes ready.
7. Remove the breakpoints from the producer and consumer threads.
Remove the breakpoints by clicking on them in the column next to the line numbers, similar to how you added them in Step 1.
8. Continue the execution.
9. View the serial output of your development kit.
Switch from the Debug view to the nRF Connect extension, and connect to the serial output of your development kit from the Connected Devices window, as shown below.
You should see an output similar to the one shown below:
[00:02:01.255,950] <inf> Less1_Exer1: Values got from the queue: 155.155.155
[00:02:03.456,054] <inf> Less1_Exer1: Values got from the queue: 156.156.156
[00:02:05.656,158] <inf> Less1_Exer1: Values got from the queue: 157.157.157
[00:02:07.856,262] <inf> Less1_Exer1: Values got from the queue: 158.158.158
[00:02:10.056,365] <inf> Less1_Exer1: Values got from the queue: 159.159.159
[00:02:12.256,469] <inf> Less1_Exer1: Values got from the queue: 160.160.160
[00:02:14.456,573] <inf> Less1_Exer1: Values got from the queue: 161.161.161
Complete the following steps using nRF Debug View in nRF Connect for VS Code to examine the ISR in an nRF Connect SDK application.
1. Add a breakpoint in the timer0_handler
interrupt context function.
This function will run in the System Timer ISR context. Therefore, it will be triggered periodically and interrupt any thread running when they fire. Each time the function is triggered, the breakpoint of the timer will be hit.
Set the breakpoint by clicking on the column next to the line number, as shown in the screenshot below.
2. Continue the execution.
Click on the Start/Continue button or press F5.
3. Examine the debugging call stack.
Notice is that timer0_handler()
runs in an interrupt context. In the screenshot above, this can be seen in the Call stack → Exception handler: timer0_handler()
has interrupted the idle thread.
4. Remove the breakpoint set in the timer0_handler()
interrupt context function.
Remove the breakpoint by clicking on it in the column next to the line numbers, similar to how you added it in Step 1.
For the sake of demonstration, let’s explore what happens if you call a kernel API that is intended for the thread context inside the timer0_handler()
interrupt context function?
1. Add a kernel API call inside the timer0_handler()
interrupt context function.
Add the k_msleep(2000);
call to the function.
2. Build the application.
The application will build, but it will contain a fatal runtime error.
3. Flash the application to your development kit.
You should observe that the system will keep crashing and resetting as you are never supposed to block inside interrupt context.
[00:00:02.450,408] <err> os: ***** MPU FAULT *****
[00:00:02.450,439] <err> os: Data Access Violation
[00:00:02.450,439] <err> os: MMFAR Address: 0x0
[00:00:02.450,469] <err> os: r0/a1: 0x00000000 r1/a2: 0x00000000 r2/a3: 0x00000000
[00:00:02.450,469] <err> os: r3/a4: 0x00000000 r12/ip: 0x00000000 r14/lr: 0x000095d3
[00:00:02.450,500] <err> os: xpsr: 0x21000021
[00:00:02.450,500] <err> os: Faulting instruction address (r15/pc): 0x000095ec
[00:00:02.450,561] <err> os: >>> ZEPHYR FATAL ERROR 19: Unknown error on CPU 0
[00:00:02.450,561] <err> os: Fault during interrupt handling
TerminalIf you are using a build target with TF-M (such as nrf9161dk_nrf9161_ns
), the error message will be on to the TF-M log output, which is not routed to VCOM by default. So on the application log output, you will see the output:
*** Booting nRF Connect SDK v2.5.0 ***
[00:00:00.250,854] Less1_Exer1: Values got from the queue: 100.100.100
The board resets, and then the cycle repeats itself.
4. Restore the application.
Remove the k_msleep(2000);
call from inside the timer0_handler()
function, then rebuild and flash the application to your development kit.
If you want to add a lengthy functionality inside the timer expiry function, you must use a work queue to submit the work.