Using FreeRTOS with the Raspberry Pi Pico: Part 3
November 14, 2022
Blog
This is the third blog in the series where we explore writing embedded applications for the Raspberry Pi Pico using FreeRTOS.
As mentioned in the previous blog, this blog will cover event-driven design and its benefits. We will also look at Semaphores, another built-in feature of FreeRTOS. To demonstrate both of these concepts, we have a concise code example to share that you can run on your own Raspberry Pi Pico hardware.
First, what do we mean by “event” when we say “event-driven design” for embedded applications? Generally, an event represents an action that takes place at a particular point in time that can then be captured and acted upon programmatically. An event could be something like input from a button press or data arriving from a connected peripheral. Events happen asynchronously to the running application, and are usually triggered by what are known as interrupts on embedded systems. An interrupt is a signal produced by an embedded system that indicates a change in state. The specific source of an interrupt can be identified by an Interrupt Request or IRQ. The RP2040 microcontroller on the Raspberry Pi Pico has 26 IRQs built-in with a range of sources. See the RP2040 Datasheet (Section 2.3.2) for more details on the specific IRQs supported.
Knowing that events are based on interrupts in event-driven design, how do we actually utilize this in an embedded application? Interrupts can happen at any time, so they will not always align perfectly with the timing of a running application. To properly capture an interrupt, we usually have to implement an interrupt handler. This is also known as an Interrupt Service Routine or ISR. From a coding perspective, this can be represented with a callback function. A callback function is called from outside the application by the system. The application will define a callback function and pass the name of that function to an API so that the system can call that function when an interrupt occurs. With the Raspberry Pi Pico C/C++ SDK, two such examples are: gpio_set_irq_callback (gpio_irq_callback_t callback) and irq_set_exclusive_handler (uint num, irq_handler_t handler). We will use yet another API call from this SDK in our example code later.
Now that we understand how event-driven design works, why is this approach preferable? For one, an event-driven design does not waste execution cycles by continuously looping. With FreeRTOS, tasks can be stopped from executing to wait for an event, then re-started when an event occurs. This is more efficient for an embedded system in terms of processor usage because tasks that are not executing do not consume processor cycles. Another reason is that we can reduce the amount of code we write and maintain to constantly poll for state changes that may have occurred. Next, we can eliminate certain types of sleeps, delays, and other less elegant timing techniques used as an attempt to align to when we think events might occur in the application. This allows the system to respond to events more quickly since tasks can automatically restart when an event occurs. Finally, with event-driven design, we can simplify our task priorities and timings with FreeRTOS to avoid potential task starvation or race conditions.
Before we demonstrate a simple event-driven design with FreeRTOS and the Raspberry Pi Pico, we must first take a short detour to cover an important topic related to multitasking. When we have multiple tasks executing in quick succession, what happens when we want to share a particular resource that only one task can access at a time? In order to avoid possible collisions between tasks, another built-in feature of FreeRTOS available to us is the Semaphore API. Semaphores allow a developer to manage serial access to a shared resource within an application. There are 3 main types of Semaphores in FreeRTOS – binary, counting, and mutex. We will use a mutex in the example below, which is shorthand for “mutual exclusion.” Once a mutex is defined, a task can “take” it to indicate that a shared resource is being used, then “give” it back to indicate the resource is no longer being used. If a mutex is already taken by one task, another task must wait until the mutex is given back before it can be taken again. We use a mutex in the example below to enable multiple tasks to print to STDOUT, which is a shared resource that only one task can access at a time.
As mentioned previously, there are different types of events that can be captured in an embedded application. An embedded system may have sensors or actuators attached to GPIO pins, there may be communication or networking attached to a UART, or there could be IRQs triggered from a programmable I/O (PIO) state machine. Each of these will have different implementations, but can all benefit from event-driven design. One other type of interrupt that can be captured is a hardware timer. There are 4 hardware timer slots on the Raspberry Pi Pico according to the datasheet referenced above, and we will use hardware timers to demonstrate event-driven design in the upcoming example.
In the example below, direct-to-task notification as a lightweight mailbox is used to communicate between an ISR and a task. The application creates and runs 3 tasks, all defined by the same vNotifyTask() function. Each task begins by getting its task number and handle, then creating a hardware timer with add_repeating_timer_ms(). A callback function defined in the application, vTimerCallback(), is passed to the timer, which will be called when the interrupt occurs. Also, the handle of the task is passed to the timer as data so that the callback can identify the task to notify. Each task then enters its ‘for’ loop and calls xTaskNotifyWait(), which causes each task to wait until a notification is received. The task is no longer executing at this point, so no processor cycles are consumed. The timer interrupt will trigger execution of the callback function, which then gets the current time in milliseconds since boot and notifies the task by calling xTaskNotifyFromISR(). At that point, the vNotifyTask() calls vPrintTime() to format the output and print the value using the vSafePrint() function, which utilizes a mutex. Then, the task waits for the next notification from the ISR.
#include <stdio.h>
#include "pico/stdlib.h"
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"
SemaphoreHandle_t mutex;
void vSafePrint(char *out) {
xSemaphoreTake(mutex, portMAX_DELAY);
puts(out);
xSemaphoreGive(mutex);
}
void vPrintTime(int task, uint32_t time) {
char out[32];
sprintf(out, "Task: %d, Timestamp: %d", task, time);
vSafePrint(out);
}
bool vTimerCallback(struct repeating_timer *timer) {
uint32_t time = time_us_32();
TaskHandle_t handle = timer->user_data;
xTaskNotifyFromISR(handle, time, eSetValueWithOverwrite, NULL);
return true;
}
void vNotifyTask(void *pvParameters) {
int task = (int)pvParameters;
TaskHandle_t handle = xTaskGetCurrentTaskHandle();
struct repeating_timer timer;
add_repeating_timer_ms(1000, vTimerCallback, (void *)handle, &timer);
uint32_t time;
for (;;) {
xTaskNotifyWait(0, 0, &time, portMAX_DELAY);
vPrintTime(task, time);
}
}
void main() {
stdio_init_all();
mutex = xSemaphoreCreateMutex();
xTaskCreate(vNotifyTask, "Notify Task 1", 256, (void *)1, 1, NULL);
xTaskCreate(vNotifyTask, "Notify Task 2", 256, (void *)2, 1, NULL);
xTaskCreate(vNotifyTask, "Notify Task 3", 256, (void *)3, 1, NULL);
vTaskStartScheduler();
}
To run this application, you can reuse the same project from the second blog in this series. Simply rename the existing ‘main.c’ file and create a new ‘main.c’ file inserting the code above. Then, follow the build and flash instructions from the first blog in this series. The output will be written to the USB serial as shown in the previous blog. You should see the task number and timestamp from each task printed out, with the hardware timer repeating every second. This is a sample of the expected output:
Task: 1, Timestamp: 23002971
Task: 2, Timestamp: 23002983
Task: 3, Timestamp: 23002991
Task: 1, Timestamp: 24002984
Task: 2, Timestamp: 24002997
Task: 3, Timestamp: 24003005
Notice how there are no continuously executing loops or artificial delays set in our application code. Instead, we use event-driven design to capture interrupts from the hardware to continue task execution, saving processor cycles. This example is less than 50 lines of code, but these basic concepts can be applied to much larger applications.
The next blog will continue our exploration of FreeRTOS with the Raspberry Pi Pico by looking into the multicore capabilities of the hardware. We will compare Asymmetric Multiprocessing (AMP) and Symmetric Multiprocessing (SMP), which are both options available with FreeRTOS.