Further FreeRTOS fun with the Pi Pico: sizing up scheduling

FreeRTOS scheduling is hard in as much at can be difficult to decide how to configure it. I wanted to try and figure out the options.

The popular real-time operating system provides the configUSE_TIME_SLICING and configUSE_PREEMPTION as settings values. You can add them to your FreeRTOSConfig.h file Tasks themselves can be assigned priority values, and there are API calls to allows tasks to sleep, to yield up the CPU, and be suspended and subsequently resumed.

To explore the options, I took my RP2040 FreeRTOS template and adapted it to add an HT16K33-driven four-digit, seven-segment display and an MCP9808 temperature sensor. The code works a before: to alternately flip two LEDs — one on the board, one connected to a GPIO pin — on and off, but this time the code’s driving the display, and switching between a count and the current temperature. The latter is generated by a separate FreeRTOS task that does nothing but repeatedly read the temperature from the sensor. The code is in the App-Scheduling folder. If you clone or update the repo then do a build, the file you want is build/App-Scheduling/SCHEDULING_DEMO.uf2.

Scheduling: the options

To the testing. Both configUSE_TIME_SLICING and configUSE_PREEMPTION will be automatically enabled if they are not explicitly disabled in FreeRTOSConfig.h: for example, #define configUSE_PREEMPTION 0. They’re already in place in the template with the value 1, so for this test I set them both to zero.

With these settings, each task must manually yield its runtime using either FreeRTOS’ functions vTaskYield(), vTaskDelayUntil() or vTaskDelay(). The latter takes the number of OS ticks for which the task will sleep; pass in 0 for an immediate yield equivalent to vTaskYield(). Uncomment the relevant lines in main.cpp to enable them.

Ticks are not necessarily the same as milliseconds, but you can convert, say, 500ms to ticks with:

TickType_t ms_delay = 500 / portTICK_PERIOD_MS;

The assumption is that all the tasks have the same priority. If not, FreeRTOS will choose the highest priority task when any task yields.

Build and run the demo app to see this. The code currently sets all three tasks to have the same priority, 1:

xTaskCreate(led_task_pico, "PICO_LED_TASK", 128, NULL, 1, NULL);
xTaskCreate(led_task_gpio, "GPIO_LED_TASK", 128, NULL, 1, NULL);
xTaskCreate(sensor_read_task, "SENSOR_TASK", 64, NULL, 1, NULL);

Set the third task’s priority to 2, recompile the code and run the build, and the first two tasks will never be called: the LEDs will not flash. This is because when task three yields, FreeRTOS looks for the next task to run and picks the highest priority one… which is task three again. This is intentional: RTOSes are event-driven, so tasks should expect to wait for events, such as data coming on on a bus, to occur.

To avoid having to yield manually, you can reset the third task’s priority to 1, set configUSE_TIME_SLICING to 1 and comment out the vTaskDelay() lines. You’d think from using a desktop OS that this would automatically flip between the three tasks as each gets time an even shot at the CPU, which is what time slicing is all about.

On the contrary, while the on-board LED flashes and the display updates, there’s no temperature reading and the external LED doesn’t flash. Why not? Because tasks two and three never get to run. Only the first registered task does. This is because FreeRTOS is unable to pre-empt it because configUSE_PREEMPTION is set to 0. Set this variable to 1, rebuild and rerun the app, and see what happens.

Now we have the two LEDs flip-flopping and the display switching between a count and a temperature reading. Success!

Well no, not really. Look at the temperature reading: it doesn’t change. Why? Because the task swapping is getting out of sync with the inflow of data from the sensor — it’s not allowing for events, RTOS style. We need to give the third task time to send and receive the temperature reading. Bumping its priority up doesn’t help: it just shuts off the other tasks as we saw before.

To fix this, we re-enable the yield in the third task:

void sensor_read_task(void* unused_arg) {
  while (true) {
    // Just read the sensor and yield
    read_temp = sensor.read_temp();
    vTaskDelay(0);
  }
}

This has the effect of forcing FreeRTOS to switch tasks when the third task has taken a reading, not before. It keeps the tasks in sync, in other words. Touch the sensor on the breakout board and you’ll see the temperature reading on the display change.

Summing up

What does all this tell us?

Firstly, don’t assume that FreeRTOS’ operates the same way desktop operating systems do. Enabling pre-emption allows tasks to be pre-empted, but it doesn’t mean that they will be. They’ll only be pre-empted on a code-controlled dynamic priority change or if the scheduler switches tasks. Switching only occurs when a task yields its runtime, it ‘blocks’, or time-slicing is enabled.

However, setting configUSE_TIME_SLICING to 1 will only time-slice between tasks of equal priority. If you have ten priority 1 tasks and a single higher priority task, for example, only the latter will be run, even if it yields.

With configUSE_TIME_SLICING set to 1 and all tasks the same priority, make sure any time-sensitive tasks yield or block, but other tasks need not because they’ll be time-sliced anyway. You should note that underlying code that you might expect to block — a call to the Pico SDK’s i2c_read_blocking() function, for example — does not block in the way FreeRTOS means. You still need to yield or block manually.

This is because blocking in FreeRTOS actually means halting a task for a specified time using vTaskDelay() or vTaskDelayUntil() — the latter lets you peg the task’s awakening to a specific moment.

Higher priority tasks always win, so if you need to mark a task as higher priority, make sure you don’t merely yield for the reasons outlined above, but you actively block. For instance, rather than call vTaskDelay(0); to immediately yield, call vTaskDelay(20); to allow 20 ticks during which the lower priority tasks can be scheduled — on a time-sliced basis if configUSE_TIME_SLICING is 1, otherwise as and when they manually yield.

Which of course begs the question, why not do it all manually? For a simple demo app, that’s a valid approach. But for more complex embedded applications — reading multiple sensors and possibly user input too, managing a graphical display, managing a cellular modem or WiFi radio, and issuing requests to Internet-connected resources — that’s a different matter.

If you were building an IoT sensor, say, you might prioritise the taking and uploading of regular readings, but view updating the display as a low priority task because showing the temperature on a tight time-cycle is less important that getting data to the server and from there to an app on the sensor owner’s phone.

You can find the code I used in my RP2040 FreeRTOS repo.

More FreeRTOS on RP2040