Tasks and the scheduler
Work is split into tasks, each with its own endless loop, and the scheduler shares out CPU time. Task states and the idea of a tick.
Tasks
A task is an endless-loop function with its own stack.
void imu_task(void *arg) {
for (;;) {
read_imu();
vTaskDelay(pdMS_TO_TICKS(1)); // give up the CPU for 1 ms
}
}
xTaskCreate(imu_task, "imu", 256, NULL, 3, NULL);
Task states
| State | Meaning |
|---|---|
| Running | Using the CPU right now (one per core) |
| Ready | Able to run but waiting its turn |
| Blocked | Asleep, waiting for time, a queue or a semaphore |
| Suspended | Explicitly stopped |
A task should be Blocked most of the time. A task that holds the CPU and spins when there is nothing to do starves lower-priority tasks.
Ticks and context switches
SysTick interrupts every 1 ms (the tick) and the scheduler picks the task to run next. To switch tasks, the current registers are saved on that task's stack and the next task's registers are restored. On Cortex-M the PendSV exception does this.
Splitting into tasks
Split by work with different periods and deadlines: sensor reading, control, communication, display, logging. Split too finely and the communication between tasks becomes the complicated part.
The four task states
| State | Meaning |
|---|---|
| Running | Using the CPU now. With one core, one at a time |
| Ready | Able to run, but waiting behind a more urgent task |
| Blocked | Waiting for time or an event (queue, semaphore). Uses no CPU |
| Suspended | Explicitly put on hold |
The scheduler runs the highest-priority task among those that are ready. In a well-built system most tasks spend most of their time blocked.
In a picture: preemption
A task is an endless loop
void sensor_task(void *arg) {
TickType_t last = xTaskGetTickCount();
for (;;) {
read_sensor();
vTaskDelayUntil(&last, pdMS_TO_TICKS(10)); // exactly a 10 ms period
}
}
xTaskCreate(sensor_task, "sensor", 256, NULL, 3, NULL); // 256-word stack, priority 3
vTaskDelay(10) means "10 ms from now"; vTaskDelayUntil means "10 ms from the last wake-up time". Use the latter for work that needs an exact period. With the former, the period slips by the processing time.
When a task never lets go of the CPU
void bad_task(void *arg) {
for (;;) { if (flag) do_work(); } // keeps checking without waiting
}
This task is always ready, so lower-priority tasks never run. Every task's loop needs a point where it gives up the CPU, such as a delay or a wait on a queue or semaphore.
Criteria for setting priorities
- Shorter period, higher priority: a 1 ms control loop must be above a 100 ms log output.
- Stricter deadline, higher priority: separate work that must not be late from work that can be a little late.
- Long-running work goes low: file writes, display updates.
- Do not use too many levels: three or four are enough in most cases.
Stack size
Each task has its own stack, and its size is fixed at creation. How much is needed varies a lot with local variables, call depth and whether printf is used.
UBaseType_t left = uxTaskGetStackHighWaterMark(NULL); // the smallest margin seen so far
After a good long run, increase the stack if this value is below 20% of the total. Keep stack-overflow detection (configCHECK_FOR_STACK_OVERFLOW) on throughout development.
Ticks and time resolution
The scheduler wakes on each periodic tick interrupt (usually 1 ms) and decides the next task. Delays can only be given in ticks, so timing finer than 1 ms is handled with a hardware timer.
Problems that come up in practice
- A task function ends with
return: a task must not end. To finish, it has to delete itself. - Calling ordinary APIs from an interrupt: use only functions with the
FromISRsuffix. - A high-priority task doing a long calculation: every task below stops meanwhile. Split the work or lower the priority.
- The idle task never gets to run: memory of deleted tasks is cleaned up in the idle task.