Part 3 · Firmware Systems28 min read
RTOS Synchronization and Communication
Once tasks can pre-empt each other, every piece of shared state becomes a question: who may touch it, when, and what happens if two of them try at once? An RTOS answers with a small toolbox: critical sections, mutexes, semaphores, queues, task notifications, event groups and stream buffers. Each tool solves one problem well and causes subtle bugs when used for another. This lesson explains what each one does inside the kernel, which to choose, the rules for using them from interrupts, the classic failures (priority inversion, deadlock, starvation), and the patterns that keep a multi-task design sane. Examples use FreeRTOS, the most widely deployed kernel on Cortex-M; the concepts map directly to Zephyr and others.
Critical sections in an RTOS
Section titled “Critical sections in an RTOS”The bluntest tool is a critical section: a stretch of code during which nothing else can run. In FreeRTOS:
#include "FreeRTOS.h"#include "task.h"
static uint32_t packets_rx;static uint32_t bytes_rx;
void stats_add(uint32_t len){ taskENTER_CRITICAL(); packets_rx++; bytes_rx += len; /* both fields change together, or not at all */ taskEXIT_CRITICAL();}On the Cortex-M3/M4 ports, taskENTER_CRITICAL() does not disable all interrupts. It writes configMAX_SYSCALL_INTERRUPT_PRIORITY into the BASEPRI register, masking every interrupt at that priority or lower urgency, and increments a nesting counter. taskEXIT_CRITICAL() decrements the counter and writes BASEPRI = 0 only when it reaches zero, so critical sections nest correctly. Because the tick and PendSV are masked too, no context switch can happen inside.
configMAX_SYSCALL_INTERRUPT_PRIORITY splits the NVIC priority range in two:
NVIC priority: 0 1 2 3 4 | 5 6 7 ... 14 15 | never masked by the kernel | masked by taskENTER_CRITICAL zero added latency from the RTOS | may call ...FromISR() APIs must NOT call any FreeRTOS API | PendSV and SysTick live at 15 | configMAX_SYSCALL_INTERRUPT_PRIORITY = (5 << 4) = 0x50A motor-control ISR that must never be delayed goes at priority 0 to 4; it may not call any FreeRTOS function. An ISR that posts to a queue goes at 5 to 15. After reset every NVIC priority is 0, the most urgent, so an ISR you forgot to configure is in the forbidden zone. If it calls a FromISR API, it can interrupt the kernel in the middle of updating a list and corrupt it. With configASSERT defined, the port checks the priority of the running interrupt on every FromISR call and stops at the offending line. This is one of the most common FreeRTOS support questions, and it is why configASSERT belongs in every build. On STM32 also make sure all priority bits are pre-emption bits (NVIC_SetPriorityGrouping(0), or NVIC_PRIORITYGROUP_4 in ST’s HAL), which the FreeRTOS port expects.
Two more variants exist. Inside an ISR use taskENTER_CRITICAL_FROM_ISR(), which returns the previous BASEPRI value for taskEXIT_CRITICAL_FROM_ISR(saved) to restore. And vTaskSuspendAll() / xTaskResumeAll() lock the scheduler without masking interrupts: no other task runs, but ISRs do. Use it for longer operations that only race with other tasks.
Critical sections are for a handful of instructions. Everything masked by one waits for it, so a critical section around a 2 ms flash write adds 2 ms to the latency of every interrupt at priority 5 and below. For anything longer, use a mutex.
Mutexes and binary semaphores
Section titled “Mutexes and binary semaphores”A mutex (mutual exclusion lock) protects a resource that tasks use for a while: a UART, an SPI bus with several devices, a shared data structure.
#include <stdbool.h>#include "FreeRTOS.h"#include "semphr.h"#include "uart.h"
static SemaphoreHandle_t uart_mutex;
void console_init(void){ uart_mutex = xSemaphoreCreateMutex(); configASSERT(uart_mutex != NULL);}
bool console_write(const char *s){ if (xSemaphoreTake(uart_mutex, pdMS_TO_TICKS(50)) != pdTRUE) { return false; /* could not get the UART in 50 ms: report, never hang */ } uart_puts(s); xSemaphoreGive(uart_mutex); return true;}A task that calls xSemaphoreTake on a mutex someone else holds goes to the blocked state, costing no CPU, until the holder gives it or the timeout expires. Always pass a finite timeout in production code and handle failure; portMAX_DELAY turns a bug elsewhere into a silent hang here.
A binary semaphore looks similar (xSemaphoreCreateBinary, take, give) but means something different. It is a signal: one party gives, another takes. A mutex is a lock: the task that takes it must be the one that gives it back. FreeRTOS builds both from the same queue machinery, but the mutex adds two things:
| Mutex | Binary semaphore | |
|---|---|---|
| Purpose | protect a resource | signal an event |
| Owner | the task that took it | none |
| Who gives | only the owner | anyone, including an ISR (xSemaphoreGiveFromISR) |
| Priority inheritance | yes | no |
| Usable from ISR | no | give only |
| Initial state | available | empty (created taken) |
There is also a recursive mutex (xSemaphoreCreateRecursiveMutex, xSemaphoreTakeRecursive) that the owner may take repeatedly, needing as many gives. Code that needs it usually has a layering problem, but it exists for library code that calls itself through callbacks.
The classic misuse runs both ways. Using a binary semaphore as a lock works in testing and then suffers unbounded priority inversion in the field, because binary semaphores have no inheritance. Using a mutex to signal from an ISR fails outright: an ISR cannot own anything, and FreeRTOS asserts if you try.
Priority inversion
Section titled “Priority inversion”Priority inversion is when a high-priority task waits for a low-priority one. Some inversion is unavoidable: if Low holds the mutex High needs, High must wait for Low to finish its critical section. That wait is bounded and short. The disaster is when a third, medium-priority task gets involved:
priority High .............[wakes, takes M: BLOCKED.............................][runs] Med .............[wakes...... runs ......... runs ......... runs .....] Low [take M][run] (pre-empted by Med, still holding M) [give M] t0 t1 t2 t3At t2, High blocks on the mutex Low holds. Med, which needs no mutex at all, becomes ready and pre-empts Low because it outranks it. Now High waits for Low, Low waits for Med, and Med can run as long as it likes. High’s waiting time is no longer bounded by Low’s short critical section; it is bounded by nothing.
This is not hypothetical. In 1997 the Mars Pathfinder lander began resetting itself on Mars. A low-priority meteorological task held a mutex protecting a shared information bus; a high-priority bus management task blocked on it; a medium-priority communications task ran in between. A watchdog noticed the bus task had not completed its cycle and reset the system. The VxWorks mutex had a priority inheritance option that had been left off. JPL engineers reproduced the fault on a ground replica and fixed it by turning the option on remotely.
The two standard fixes:
- Priority inheritance. When High blocks on a mutex held by Low, the kernel temporarily raises Low to High’s priority. Med can no longer pre-empt it, Low finishes its critical section quickly, gives the mutex, drops back to its own priority, and High runs. FreeRTOS mutexes do this automatically. Its implementation is deliberately simple and assumes a task holds one mutex at a time; a task holding several may keep the raised priority until it releases all of them.
- Priority ceiling. Each mutex is given a ceiling equal to the highest priority of any task that uses it, and taking the mutex immediately raises the taker to the ceiling. Inversion cannot start, and on a single core it also prevents deadlock among those mutexes. FreeRTOS does not provide ceiling mutexes; you can emulate one with
vTaskPrioritySetaround the critical section, or avoid the shared lock entirely with a gatekeeper task (below).
Inheritance bounds the inversion; it does not remove it. Keep critical sections short, and design so that high-priority tasks share as few locks as possible.
Counting semaphores
Section titled “Counting semaphores”A counting semaphore holds a count from 0 to a maximum. Give increments it; take decrements it, blocking at zero. Two uses:
/* 1. Resource pool: four DMA buffers. Take one before use, give it back after. */SemaphoreHandle_t dma_bufs = xSemaphoreCreateCounting(4, 4); /* max 4, initially 4 free */
/* 2. Event counting: an ISR gives once per event, a task takes once per event. */SemaphoreHandle_t edges = xSemaphoreCreateCounting(16, 0); /* up to 16 unprocessed edges */For a pool, the semaphore counts free items but does not say which one is free; pair it with a small free list protected by a critical section. For event counting, the semaphore remembers how many events happened while the task was busy, which a binary semaphore cannot (two gives before a take collapse into one).
Queues
Section titled “Queues”A queue carries data between tasks, or from ISRs to tasks. It is the workhorse of RTOS design.
#include "FreeRTOS.h"#include "queue.h"#include "stm32f4xx.h"
typedef struct { TickType_t tick; uint16_t raw;} adc_sample_t;
static QueueHandle_t adc_q;static volatile uint32_t adc_dropped;
void adc_queue_init(void){ adc_q = xQueueCreate(16, sizeof(adc_sample_t)); /* 16 items of 8 bytes, storage from heap */ configASSERT(adc_q != NULL); NVIC_SetPriority(ADC_IRQn, 6); /* below the syscall threshold: may use FromISR */}
void ADC_IRQHandler(void){ BaseType_t woken = pdFALSE; const adc_sample_t s = { .tick = xTaskGetTickCountFromISR(), .raw = (uint16_t)ADC1->DR, /* reading DR clears EOC */ }; if (xQueueSendFromISR(adc_q, &s, &woken) != pdPASS) { adc_dropped++; /* queue full: count it */ } portYIELD_FROM_ISR(woken);}
void adc_task(void *arg){ (void)arg; adc_sample_t s; for (;;) { if (xQueueReceive(adc_q, &s, pdMS_TO_TICKS(1000)) == pdPASS) { process_sample(&s); } else { report_adc_stalled(); /* no sample for a second: hardware problem */ } }}Key properties:
- Copy semantics.
xQueueSendcopiessizeof(adc_sample_t)bytes into the queue’s own storage, andxQueueReceivecopies them out. The sender’s local variable can be reused immediately. For large items this costs time and RAM; send a pointer to a buffer instead, and treat sending it as handing over ownership: the sender must not touch that buffer again. - Blocking with timeouts on both ends. A receiver blocks while the queue is empty; a sender (from a task) can block while it is full. Both take a timeout in ticks. A receive timeout is a free liveness check, as
adc_taskshows. - FromISR variants never block.
xQueueSendFromISRreturnserrQUEUE_FULLimmediately if there is no room. portYIELD_FROM_ISR(woken). If sending woke a task with higher priority than the one that was interrupted, the API setswokentopdTRUE. Passing it toportYIELD_FROM_ISRpends PendSV, so the woken task runs the instant the ISR returns. Forget it and the task still runs, but only at the next tick or the next context switch, up to a full tick later. Nothing crashes; latency silently grows.- Mailbox. A queue of length 1 written with
xQueueOverwriteand read withxQueuePeekalways holds the latest value. Good for “current setpoint” style data where only the newest matters.
Task notifications
Section titled “Task notifications”Every FreeRTOS task has a built-in 32-bit notification value and a notification state. Notifying a task is cheaper than any queue or semaphore: no separate kernel object, no extra RAM, and noticeably faster to give and take. For the very common case of “an ISR tells one specific task that something happened”, it is the best tool.
#include "FreeRTOS.h"#include "task.h"#include "stm32f4xx.h"
static TaskHandle_t button_task_handle; /* set by xTaskCreate's last argument */
void EXTI0_IRQHandler(void){ BaseType_t woken = pdFALSE; EXTI->PR = EXTI_PR_PR0; /* clear the pending flag by writing 1 */ vTaskNotifyGiveFromISR(button_task_handle, &woken); portYIELD_FROM_ISR(woken);}
void button_task(void *arg){ (void)arg; for (;;) { /* Block until notified. pdTRUE: return the count and reset it to zero. */ const uint32_t presses = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); handle_presses(presses); }}vTaskNotifyGiveFromISR increments the target’s notification value, and ulTaskNotifyTake blocks until it is non-zero, so it acts as a fast counting semaphore owned by one task. xTaskNotify with eSetBits turns the value into event flags, and eSetValueWithOverwrite into a one-word mailbox.
The limits are what keep it cheap. A notification has exactly one receiver: the task it is sent to. It carries one 32-bit value, not a stream of items. An ISR cannot wait for one, and a sender cannot block waiting for the receiver to catch up. Since FreeRTOS 10.4 each task has an array of notification values (configTASK_NOTIFICATION_ARRAY_ENTRIES, used with the ...Indexed functions) so several independent sources do not collide.
Event groups
Section titled “Event groups”An event group is a set of flag bits that several tasks can wait on, each for its own combination:
#include "FreeRTOS.h"#include "event_groups.h"
#define EV_LINK_UP (1u << 0)#define EV_TIME_SYNC (1u << 1)#define EV_CONFIG_OK (1u << 2)#define EV_READY_ALL (EV_LINK_UP | EV_TIME_SYNC | EV_CONFIG_OK)
static EventGroupHandle_t sys_events; /* created with xEventGroupCreate() at init */
void upload_task(void *arg){ (void)arg; for (;;) { const EventBits_t bits = xEventGroupWaitBits(sys_events, EV_READY_ALL, pdFALSE, /* do not clear on exit */ pdTRUE, /* wait for ALL bits */ pdMS_TO_TICKS(30000)); if ((bits & EV_READY_ALL) == EV_READY_ALL) { upload_pending_data(); } else { report_not_ready(bits); /* which prerequisite is missing */ } }}
/* elsewhere: xEventGroupSetBits(sys_events, EV_LINK_UP); */Unlike a notification, setting bits can release any number of waiting tasks at once: it is a broadcast. That makes xEventGroupSetBits non-deterministic in time (the kernel does not know in advance how many tasks it will wake), so xEventGroupSetBitsFromISR does not do the work in the ISR. It sends a command to the timer service task, which needs configUSE_TIMERS enabled and can fail if that task’s command queue is full. With the default 32-bit tick type an event group has 24 usable bits; the top 8 are reserved for the kernel.
Stream and message buffers
Section titled “Stream and message buffers”Stream buffers carry a stream of bytes, like a UART receive path: xStreamBufferSendFromISR(rx, &byte, 1, &woken) in the ISR, xStreamBufferReceive(rx, buf, sizeof buf, timeout) in the task. A trigger level set at creation says how many bytes must be waiting before a blocked reader wakes. Message buffers carry discrete variable-length messages; each is stored with a length prefix (a size_t, 4 bytes on Cortex-M), and a receive returns exactly one whole message.
Both are lightweight because they assume one writer and one reader. Two tasks writing to the same stream buffer, or a task and an ISR both writing, need an external lock or a different tool. For many writers, use a queue.
The ISR rule
Section titled “The ISR rule”From an interrupt handler you may only call FreeRTOS functions whose names end in FromISR (or the _FROM_ISR macros), and none of them block. The reason is structural: blocking means “put the current task to sleep and run another”. An ISR is not a task; it has no TCB to put to sleep. Calling xQueueSend, xSemaphoreTake, vTaskDelay or any mutex function from an ISR either asserts (with configASSERT) or corrupts the scheduler’s state.
The same applies to anything that might block inside: a printf whose retargeted _write takes a mutex, a logging call that waits for queue space, a driver call that waits on a semaphore. An ISR’s job is to capture what happened, hand it to a task with one FromISR call, request a yield, and return.
Deadlock, livelock and starvation
Section titled “Deadlock, livelock and starvation”A deadlock is a set of tasks each waiting for a resource another one holds, forever:
/* Task A */ /* Task B */xSemaphoreTake(uart_mutex, portMAX_DELAY); xSemaphoreTake(spi_mutex, portMAX_DELAY);xSemaphoreTake(spi_mutex, portMAX_DELAY); xSemaphoreTake(uart_mutex, portMAX_DELAY);/* never gets here if B ran in between */ /* never gets here either */Deadlock requires four conditions at once: mutual exclusion (a resource can be held by one task), hold and wait (a task holds one resource while waiting for another), no pre-emption (resources cannot be taken away), and circular wait (a cycle of tasks each waiting for the next). Break any one and deadlock is impossible. In firmware the practical lever is circular wait: define a global lock order (for example, always spi_mutex before uart_mutex) and never take locks out of order. Better still, never hold two locks at once. Finite timeouts on every take do not prevent deadlock, but they turn it from a silent freeze into an error you can log and recover from.
Livelock is tasks that keep running but make no progress: two tasks that both time out, back off and retry in lockstep, colliding every time. Randomized back-off or a fixed ordering fixes it. Starvation is a task that never gets the CPU or the resource it needs: typically a high-priority task that polls instead of blocking, so nothing below it ever runs. If the idle task never runs, everything is starving. A task should spend most of its life blocked.
Patterns that work
Section titled “Patterns that work”Most well-structured RTOS applications are built from four patterns.
Producer and consumer. One task (or ISR) produces items into a queue; another consumes them. The queue absorbs bursts and decouples their timing. Size the queue for the worst burst and count overflows.
Deferred interrupt handling. The ISR does the minimum (clear the source, capture the data) and notifies a task that does the real work at task priority, where it can block, take mutexes and call libraries. The EXTI0_IRQHandler and button_task above are this pattern. Give the handler task a high priority if the work is urgent.
Worker task with a command queue. One task owns a slow resource such as flash, and other tasks send it commands:
typedef enum { CMD_FLASH_WRITE, CMD_FLASH_ERASE } cmd_type_t;
typedef struct { cmd_type_t type; uint32_t addr; const uint8_t *data; /* must stay valid until the reply arrives */ size_t len; TaskHandle_t reply_to; /* notified with the result, or NULL for fire-and-forget */} flash_cmd_t;
void flash_worker(void *arg){ (void)arg; flash_cmd_t cmd; for (;;) { xQueueReceive(flash_q, &cmd, portMAX_DELAY); const int32_t err = flash_execute(&cmd); /* the only code touching the flash */ if (cmd.reply_to != NULL) { xTaskNotify(cmd.reply_to, (uint32_t)err, eSetValueWithOverwrite); } }}A requester sends the command and then waits for the result with xTaskNotifyWait(0, 0, &result, timeout). Flash operations are serialized without any mutex, and requesters choose whether to wait.
Gatekeeper task. A special case of the worker: one task is the only code that ever touches a shared resource, such as the console UART. Other tasks send it messages through a queue. There is no lock, so there is no inversion and no deadlock; the cost is a copy per message and a queue that must be sized. Compare it with the mutex approach in the worked example below; both are legitimate.
Timing APIs and software timers
Section titled “Timing APIs and software timers”FreeRTOS counts time in ticks. Convert with pdMS_TO_TICKS(ms), which computes ms * configTICK_RATE_HZ / 1000 in integer arithmetic and truncates. At a 100 Hz tick, pdMS_TO_TICKS(5) is 0, and vTaskDelay(0) merely yields. Check conversions whenever the tick rate is below 1 kHz.
Software timers run a callback after a delay or periodically, without a task of their own:
#include "FreeRTOS.h"#include "timers.h"
static void heartbeat_cb(TimerHandle_t t){ (void)t; led_toggle(LED_ORANGE); /* runs in the timer service task: must not block */}
void heartbeat_start(void){ TimerHandle_t t = xTimerCreate("hb", pdMS_TO_TICKS(500), pdTRUE /* auto-reload */, NULL, heartbeat_cb); configASSERT(t != NULL); const BaseType_t started = xTimerStart(t, 0); /* never put the call inside configASSERT */ configASSERT(started == pdPASS); (void)started;}All timer callbacks run in one kernel task, the timer service (or daemon) task, configured by configTIMER_TASK_PRIORITY, configTIMER_TASK_STACK_DEPTH and configTIMER_QUEUE_LENGTH. Timer API calls like xTimerStart do not act directly; they post a command to the daemon’s queue, which is why they take a block time and can fail. Because callbacks share one task, one callback that blocks or runs long delays every other timer. Treat callbacks like ISRs: short, non-blocking, and if real work is needed, notify a task.
Worked example: sensor, queue, logger
Section titled “Worked example: sensor, queue, logger”Here is a small but complete FreeRTOS application in the shape of real products: a sensor task samples every 100 ms and sends samples through a queue to a logger task, and both the logger and a status task print through a mutex-protected UART.
#include <inttypes.h>#include <stdbool.h>#include <stdint.h>#include <stdio.h>#include "FreeRTOS.h"#include "task.h"#include "queue.h"#include "semphr.h"#include "uart.h" /* void uart_puts(const char *s): polled TX on USART2 */#include "sensor.h" /* bool sensor_read_mdeg(int32_t *out) */
typedef struct { TickType_t tick; int32_t mdeg;} sample_t;
static QueueHandle_t sample_q;static SemaphoreHandle_t uart_mutex;static volatile uint32_t samples_dropped;
static void console_write(const char *s){ if (xSemaphoreTake(uart_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { uart_puts(s); xSemaphoreGive(uart_mutex); }}
static void sensor_task(void *arg){ (void)arg; TickType_t last = xTaskGetTickCount(); for (;;) { sample_t s = { .tick = xTaskGetTickCount(), .mdeg = 0 }; if (sensor_read_mdeg(&s.mdeg)) { if (xQueueSend(sample_q, &s, 0) != pdPASS) { samples_dropped++; /* logger behind: count, never block the sampler */ } } vTaskDelayUntil(&last, pdMS_TO_TICKS(100)); }}
static void logger_task(void *arg){ (void)arg; char line[64]; sample_t s; for (;;) { if (xQueueReceive(sample_q, &s, portMAX_DELAY) == pdPASS) { snprintf(line, sizeof line, "%" PRIu32 " T=%" PRId32 " mC\r\n", (uint32_t)s.tick, s.mdeg); console_write(line); } }}
static void status_task(void *arg){ (void)arg; char line[64]; for (;;) { vTaskDelay(pdMS_TO_TICKS(5000)); snprintf(line, sizeof line, "status: dropped=%" PRIu32 " heap=%u\r\n", samples_dropped, (unsigned)xPortGetFreeHeapSize()); console_write(line); }}
void app_start(void){ sample_q = xQueueCreate(8, sizeof(sample_t)); uart_mutex = xSemaphoreCreateMutex(); configASSERT(sample_q != NULL); configASSERT(uart_mutex != NULL);
BaseType_t ok; ok = xTaskCreate(sensor_task, "sensor", 256, NULL, tskIDLE_PRIORITY + 3, NULL); configASSERT(ok == pdPASS); ok = xTaskCreate(logger_task, "logger", 512, NULL, tskIDLE_PRIORITY + 2, NULL); configASSERT(ok == pdPASS); ok = xTaskCreate(status_task, "status", 512, NULL, tskIDLE_PRIORITY + 1, NULL); configASSERT(ok == pdPASS); (void)ok; /* configASSERT may compile to nothing */
vTaskStartScheduler(); for (;;) { }}0 T=23125 mC100 T=23140 mC200 T=23118 mC...status: dropped=0 heap=17384The design decisions, in order of importance:
- The highest-priority task touches no lock. The sensor task only sends to a queue with a zero timeout. Nothing the logger or status task does can delay sampling, and priority inversion on the UART mutex can only ever involve the two lower tasks.
- The queue decouples timing. The UART at 115200 baud needs about 1.7 ms per 20-character line. If the console is slow or busy, up to 8 samples wait in the queue; beyond that, samples are dropped and counted, never blocking the sampler.
vTaskDelayUntilkeeps the sample period exact regardless of how long the read takes.- The mutex has a timeout. If something holds the UART for more than 100 ms, the message is dropped instead of the task hanging.
- Formatting happens before taking the mutex.
snprintfruns outside the critical region, so the lock is held only for the transmit. - No work inside
configASSERT. The task creation results are stored first and then checked, becauseconfigASSERTmay be defined to nothing in a release build, and the call would vanish with it. - Stacks are sized for
snprintf. The two tasks that format text get 512 words;snprintfalone can use several hundred bytes. The sensor task does no formatting and gets 256.
The status line uses the heap reading from xPortGetFreeHeapSize() to watch for leaks. In a real product you would also print each task’s uxTaskGetStackHighWaterMark(NULL) here during development.
Common mistakes
Section titled “Common mistakes”- Giving or taking a mutex from an ISR. ISRs cannot own a mutex; use a notification or a binary semaphore given with
xSemaphoreGiveFromISR. - Calling a FromISR API from an ISR at NVIC priority 0 to 4. The kernel can be interrupted mid-update and corrupt its lists; set every such ISR’s priority numerically at or above
configMAX_SYSCALL_INTERRUPT_PRIORITYand defineconfigASSERT. - Forgetting
portYIELD_FROM_ISR(woken). The woken task waits up to a tick; always passwokenthrough at the end of the ISR. - A full queue dropped silently. Data disappears with no trace; check every send’s return value and count failures.
- A high-priority task that polls or blocks for long. Lower tasks starve; high-priority tasks should block on an event, do brief work, and block again.
- A binary semaphore used as a lock. No priority inheritance, so inversion is unbounded; use a mutex for mutual exclusion.
- Stack overflow in the task that calls
printf. Formatting functions use hundreds of bytes; give text-formatting tasks generous stacks and enableconfigCHECK_FOR_STACK_OVERFLOWwith avApplicationStackOverflowHook(TaskHandle_t task, char *name). portMAX_DELAYeverywhere. A bug elsewhere becomes a silent hang; use finite timeouts and handle failure.
Exercises
Section titled “Exercises”- Build the worked example on the Discovery board (or in Renode) with a fake
sensor_read_mdegthat returns a slowly changing value. After a minute, read every task’s stack high-water mark withuxTaskGetStackHighWaterMarkand report the margins. Which task uses the most stack, and why? - Reproduce priority inversion. Create Low (priority 1), Med (2) and High (3). Low takes a binary semaphore used as a lock and busy-waits 50 ms; Med wakes 5 ms later and busy-waits 200 ms; High wakes 10 ms after Low and takes the same semaphore. Toggle a pin when High finally gets it and measure the delay. Replace the semaphore with a mutex and measure again. Expect roughly 240 ms versus roughly 45 ms, and explain both numbers from the timeline.
- Replace the mutex-protected
console_writewith a gatekeeper task that owns the UART and receives fixed-size 64-byte messages from a queue. Compare RAM use (queue storage plus task stack) with the mutex version, and decide which you would ship. - Wire the user button (PA0, EXTI0) to the
button_tasknotification example. Using the DWT cycle counter, timestamp the ISR entry and the momentulTaskNotifyTakereturns. Compare the latency with and withoutportYIELD_FROM_ISR; without it you should see values spread up to a full tick period. - Create two tasks that take
uart_mutexandspi_mutexin opposite orders with 1 ms of work in between, and run until it deadlocks. Halt in the debugger and use your IDE’s FreeRTOS-aware view (oruxTaskGetSystemState) to show both tasks blocked. Fix it with a lock order, then fix it again by restructuring so no task holds two locks.
Check yourself
Section titled “Check yourself”Question 1:A UART ISR left at the default NVIC priority calls xQueueSendFromISR. configMAX_SYSCALL_INTERRUPT_PRIORITY is (5 << 4). What is wrong?
After reset every NVIC priority is 0, the most urgent. Only ISRs at priority values 5 to 15 here are masked by kernel critical sections and may call FromISR APIs.
Question 2:A low-priority task protects a shared SPI bus with a binary semaphore. Occasionally a high-priority task misses deadlines by hundreds of milliseconds while a medium-priority task runs. What is the fix?
This is unbounded priority inversion. A binary semaphore has no owner and no inheritance; a FreeRTOS mutex raises the holder's priority while a higher-priority task waits.
Question 3:An ISR calls xQueueSendFromISR(q, &item, &woken), which wakes a task of higher priority than the interrupted one, but the ISR never calls portYIELD_FROM_ISR(woken). What happens?
The send succeeds and the task becomes ready, but no PendSV is requested. The switch waits for the next scheduling point, adding up to a tick of latency.
Question 4:A task fills a local sample_t s, calls xQueueSend(q, &s, 0), then immediately overwrites s with the next reading. What does the receiver get?
FreeRTOS queues have copy semantics: the item's bytes are copied into queue storage at send time, so the sender may reuse its variable at once.
Question 5:Task A takes uart_mutex then spi_mutex. Task B takes spi_mutex then uart_mutex. Which change makes deadlock between them impossible?
Opposite orders allow a circular wait. A single global lock order removes the cycle, which breaks one of the four required conditions.
Question 6:Three tasks must all wait until the network is up, time is synced and configuration is loaded, whichever order those happen in. Which primitive fits best?
Event groups let any number of tasks wait for a combination of bits, and setting bits releases all of them. A notification has exactly one receiver.
Key takeaways
Section titled “Key takeaways”taskENTER_CRITICALraises BASEPRI toconfigMAX_SYSCALL_INTERRUPT_PRIORITYand nests; ISRs above that threshold are never delayed but may not call the kernel.- Mutexes are owned locks with priority inheritance; binary semaphores are ownerless signals. Never swap their roles.
- Priority inversion becomes unbounded when a medium-priority task runs while low holds what high needs; inheritance or ceilings bound it, and short critical sections keep it small.
- Queues copy data and block with timeouts; ISRs use the FromISR variants and finish with
portYIELD_FROM_ISR. - Task notifications are the fastest one-to-one signal; event groups broadcast combinations of flags; stream and message buffers assume one writer and one reader.
- Deadlock needs four conditions; a global lock order, or never holding two locks, removes it.
- Build systems from producer and consumer, deferred interrupt handling, worker tasks with command queues, and gatekeepers.
Next: Concurrency, atomics, and memory ordering, which looks beneath the RTOS at what is and is not atomic on Cortex-M and how the compiler and CPU can reorder your memory accesses.