Optimizing Task Scheduling with FreeRTOS on the Raspberry Pi Pico

Configuring FreeRTOS scheduling can be quite challenging, as choosing the right setup often involves a lot of decision-making. I wanted to explore the various options to understand how to optimize it.

 

The widely-used real-time operating system offers settings such as `configUSE_TIME_SLICING` and `configUSE_PREEMPTION` that can be configured in your FreeRTOSConfig.h file. You can assign priority levels to tasks, and there are API functions available that let tasks sleep, yield the CPU, and be suspended and later resumed.


To explore the options, I started with my WuKong2040 FreeRTOS template and modified it to include an HT16K33-driven four-digit, seven-segment display and an MCP9808 temperature sensor. The code, which previously alternated between toggling two LEDs—one on the board and one connected to a GPIO pin—now drives the display, switching between a counter and the current temperature reading. The temperature is obtained by a separate FreeRTOS task that continuously reads from the sensor. You can find the updated code in the App-Scheduling folder. After cloning or updating the repository and building the project, look for the file named build/App-Scheduling/SCHEDULING_DEMO.uf2.

 

1.Scheduling: Exploring the Options

 

To conduct the testing, both configUSE_TIME_SLICING and configUSE_PREEMPTION are automatically enabled unless explicitly disabled in FreeRTOSConfig.h. For example, setting #define configUSE_PREEMPTION 0 would disable preemption. In the provided template, these settings are initially enabled with a value of 1. For this test, I set both to 0.

With these configurations, tasks must manually yield their runtime using FreeRTOS functions such as vTaskYield(), vTaskDelayUntil(), or vTaskDelay(). The vTaskDelay() function takes the number of OS ticks for which the task will sleep; passing 0 will yield immediately, similar to vTaskYield(). Uncomment the appropriate lines in main.cpp to activate these functions.

Ticks are not always equivalent to milliseconds, but you can convert milliseconds to ticks using the following formula:

 

In this example, 500 represents the delay in milliseconds. The portTICK_PERIOD_MS constant defines the duration of a tick in milliseconds, so dividing the delay by portTICK_PERIOD_MS converts it into the appropriate number of ticks.
 
The assumption here is that all tasks have the same priority. If this is not the case, FreeRTOS will always choose the highest priority task to run when a task yields.

To observe this behavior, build and run the demo application. Currently, the code sets all three tasks to the same priority level, 1:

 

In this setup, led_task_pico, led_task_gpio, and sensor_read_task are all assigned priority 1. Consequently, task scheduling behavior will depend on how these tasks yield or block, and all tasks will be treated equally in terms of scheduling unless priorities are changed.

Set the priority of the third task to 2, recompile the code, and run the build. You'll notice that the first two tasks are never executed—the LEDs won't flash. This occurs because when task three yields, FreeRTOS searches for the next task to run and selects the highest priority task, which is task three itself. This behavior is intentional: real-time operating systems (RTOS) are event-driven, meaning tasks typically wait for events, such as data arriving on a bus, before executing.

To avoid manual yielding, you can reset the third task’s priority to 1, enable time slicing by setting configUSE_TIME_SLICING to 1, and comment out the vTaskDelay() calls. You might expect that, similar to desktop operating systems, time slicing would automatically rotate through the tasks, giving each an equal chance at the CPU.

However, you'll find that while the on-board LED flashes and the display updates, the temperature reading is static, and the external LED does not flash. This happens because tasks two and three are never executed; only the first registered task runs. This occurs because FreeRTOS cannot preempt the task due to configUSE_PREEMPTION being set to 0. To address this, set configUSE_PREEMPTION to 1, rebuild the code, and rerun the application.

With preemption enabled, you should see the two LEDs toggle and the display switch between the count and the temperature reading. However, there is still an issue: the temperature reading does not change. This happens because task switching is not properly synchronized with the sensor data flow—it's not functioning in an event-driven manner.

To resolve this, re-enable yielding in the third task:

 

This modification forces FreeRTOS to switch tasks only after the third task has completed its reading, ensuring that tasks remain synchronized. When you touch the sensor on the breakout board, you should see the temperature reading on the display update accordingly.

 

2.In Conclusion

 

Firstly, it’s important to recognize that FreeRTOS does not operate like desktop operating systems. Enabling preemption in FreeRTOS allows tasks to be preempted, but it doesn't guarantee they will be. Tasks will only be preempted if a dynamic priority change occurs, the scheduler decides to switch tasks, or if a task yields, blocks, or time slicing is enabled.
 
Setting configUSE_TIME_SLICING to 1 will only allow time slicing between tasks with the same priority. For example, if you have ten tasks all with priority 1 and one task with a higher priority, only the higher-priority task will run, even if it yields.
 
When configUSE_TIME_SLICING is set to 1 and all tasks have the same priority, ensure that any time-sensitive tasks are properly yielding or blocking. Other tasks will be managed through time slicing. However, be aware that some code, like the Pico SDK’s i2c_read_blocking() function, does not block in the FreeRTOS sense. Manual yielding or blocking is still required.
 
In FreeRTOS, blocking refers to halting a task for a specific duration using vTaskDelay() or vTaskDelayUntil(), with the latter allowing you to synchronize task awakening to a specific time.
 
Higher-priority tasks will always preempt lower-priority ones. Therefore, if you need to ensure a task is scheduled appropriately, don’t rely solely on yielding. Instead, actively block the task for a period using vTaskDelay(20); to allow lower-priority tasks to run during the delay. This is especially important if configUSE_TIME_SLICING is enabled, as tasks will be time-sliced, otherwise they will only run when they manually yield.
 
So why not handle everything manually? While manually managing tasks can work for simple applications, more complex embedded systems—such as those involving multiple sensors, user input, graphical displays, or network communications—benefit from the automated scheduling features of FreeRTOS.
 
For example, in an IoT sensor application, you might prioritize taking and uploading readings over updating the display. This ensures that data transmission to a server takes precedence over display updates, which are less critical.
 
You can find the code used for these demonstrations in my WuKong2040 FreeRTOS repository.

Leave a comment