Feedback
Feedback

If you are having issues with the exercises, please create a ticket on DevZone: devzone.nordicsemi.com
Click or drag files to this area to upload. You can upload up to 2 files.

Exercise 1 – Exploring threads and ISRs

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.

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.

Exercise steps

Getting the course’s repository

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.

Important

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, left-click, and hold Ctrl, to open the file.

C. Include the repo of the course 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.

Make sure that the indentation of the copied code is as shown in the below screenshot. Otherwise, west will through 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 <nRF Connect SDK >\nrf\samples\devacademy\ncs-inter and nRF Connect for VS Code can detect them since they have the right metadata files (sample.yaml).

Getting the code base

You will need to do this step for every exercise in this course.

Open the exercise code base. This is done by navigating to the nRF Connect for VS Code extension, clicking on Create a new application and then selecting the Copy a sample option as shown below.

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.

Select a folder where you want the code to be stored and the name of the copied code. As we discussed in the nRF Connect SDK Fundamentals course, always avoid long paths and paths that contain spaces. The examples in this exercise will store the code in C:\nordic\inter and name it inter_less1_exer1 .

Press Enter. This will make a copy of the base code and store it in the specified directory.

Now, you are ready to work on the code.

Building the application

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.

Modifying the code

1. Enable debugging.

Add the following lines in the application configuration file (prj.conf):

Note

It’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:

  • Name of the timer to be created.
  • An expiry function that is executed each time the timer expires (fires). This function is executed in the interrupt context by the system timer interrupt handler. If no expiry function is required, a 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.
  • A stop function is executed if the timer is stopped prematurely while running. The function is executed in thread context by the thread that stops the timer. If no stop function is required, a NULL function can be specified.

Add the following code in main.c:

K_TIMER_DEFINE(timer0, timer0_handler, NULL);

The 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:

  • The timer variable, which you defined in Step 2.1.
  • The duration, which is the time interval for the timer to expire for the first time. This is a k_timeout_t value that may be initialized using different units, such as K_MSEC() or K_SECONDS().
  • The period, which is the time interval between all timer expirations after the first one, also a 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));

2.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;
}

You 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;

3.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);

3.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);
        }

3.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);
        }

Note 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.

Debugging and analytics

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:

  • Number of threads in an nRF Connect SDK application.
  • Priority of each thread.
  • State of each thread at any given time.
  • Allocated stack and real-time stack usage for each thread. This is very useful for optimizing the stack usage and pinpointing possible overflow issues.
  • Threads control block.
  • Entry function associated with each thread.
  • Thread options for each thread.

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.

Threads in an nRF Connect SDK application

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.

Side note

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:

ISR in an nRF Connect SDK application

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.

Experimenting with the dos and don’ts of the interrupt context

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.

A note on build targets with TF-M

If 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.

Important

If you want to add a lengthy functionality inside the timer expiry function, you must use a work queue to submit the work.

Register an account
Already have an account? Log in
(All fields are required unless specified optional)

  • 8 or more characters
  • Upper and lower case letters
  • At least one number or special character

Forgot your password?
Enter the email associated with your account, and we will send you a link to reset your password.