5.5.11 · D3Embedded Systems & Real-Time Software

Worked examples — FreeRTOS IPC — queues, semaphores, mutexes, event groups

3,647 words17 min readBack to topic

Prerequisites we lean on: Producer–Consumer Pattern, RTOS Scheduling — preemption & priorities, Priority Inversion & the Mars Pathfinder bug, Interrupt Service Routines & Deferred Processing, Critical Sections & taskENTER_CRITICAL, Memory Management in FreeRTOS — heap_1..5.


The scenario matrix

Every FreeRTOS IPC bug or exam question lives in one of these cells. Think of it like quadrants: two axes are "which object" and "which edge/degenerate condition."

# Object Case class (the "sign / quadrant / degenerate") Covered by
C1 Queue Normal FIFO, buffer has room Example 1
C2 Queue Full queue → sender blocks / times out (edge) Example 2
C3 Queue Empty queue → receiver blocks forever (edge) Example 1
C4 Queue Zero-timeout poll (degenerate wait = 0) Example 2
C5 Counting semaphore Count walks , bursts (limiting value) Example 3
C6 Counting semaphore Over-give past max (saturation edge) Example 3
C7 Binary semaphore ISR → task wake-up, latency (real-world) Example 4
C8 Binary sem Give twice, Take once (lost-count degenerate) Example 4
C9 Mutex Priority inheritance timing (the "signs" of L, M, H) Example 5
C10 Mutex Mutex used from ISR (illegal / degenerate) Example 6
C11 Event group Wait ALL (AND) vs ANY (OR), auto-clear Example 7
C12 Event group One set wakes many + stale-bit re-trigger (edge) Example 7
C13 Pointer queue Dangling-pointer ownership (exam twist) Example 8

Each example below names its cells like [C2, C4].


Example 1 — Queue: normal flow and the empty edge [C1, C3]

Forecast: guess the receive order and the CPU's behaviour during the empty gap before reading on.

  1. The consumer hits an empty queue. Why this step? We must first know the queue's state. Zero items are stored, so xQueueReceive cannot copy anything out.
  2. The kernel blocks the consumer instead of spinning. Why this step? portMAX_DELAY means "wait forever" (defined above). The kernel moves the consumer to the waiting-to-receive list and removes it from the ready list — so the CPU spends none of its cycles re-checking an empty queue in a tight loop; it runs other ready tasks instead. See the timeline figure.
  3. Producer sends 10. Why this step? A Send into a queue that has a blocked receiver does two things atomically: copy the byte pattern of 10 into a kernel slot, then unblock the highest-priority waiting receiver.
  4. FIFO ordering. Why this step? A queue is FIFO (defined above). Since sends happen in the order 10, 20, 30, the front of the buffer is always the oldest. So receives come out 10 → 20 → 30.

Figure — FreeRTOS IPC — queues, semaphores, mutexes, event groups
Figure — the top band (plum) shows the consumer parked in the BLOCKED state with the CPU free; the three orange boxes are the producer's sends 10, 20, 30 in time order; the teal arrow shows the first send waking the receiver. Read left-to-right: the consumer sleeps, then drains items oldest-first.

Verify: The buffer never held more than 1 item at a time here (consumer drains instantly), so capacity 3 was never stressed. Order out = order in = 10,20,30. Units: three uint32_t copies, 4 bytes each. ✓


Example 2 — Queue: full buffer + zero-timeout poll [C2, C4]

Forecast: how many of the four sends return success?

  1. Sends 1, 2, 3 fill slots 0, 1, 2. Why this step? Each Send copies into the next free slot; 3 free slots exist. All three return pdTRUE.
  2. Send 4 finds the queue full. Why this step? Capacity is 3, and nobody has received, so 0 slots are free.
  3. Timeout is 0 → no blocking, immediate return. Why this step? A zero xTicksToWait is the degenerate wait: "try, but never sleep." With no room right now, the call returns pdFALSE at once.

Figure — FreeRTOS IPC — queues, semaphores, mutexes, event groups
Figure — three filled teal slots labelled 1, 2, 3 show the queue at full capacity; the dashed orange box labelled 4 is the fourth send being rejected, with the arrow bouncing back and the caption "returns pdFALSE (no room, no wait)." The bottom line states the rule: succeeded = min(4, 3) = 3.

Verify: 3 successes + 1 failure = 4 attempts. Buffer holds exactly capacity = 3 items. If instead the timeout were portMAX_DELAY, send 4 would block (not fail) until the consumer freed a slot — this is why cell C2 and cell C4 are different. ✓


Example 3 — Counting semaphore: burst arrivals + saturation [C5, C6]

Forecast: guess the count after 5 gives, and how many takes the task can do.

  1. Gives 1, 2, 3 raise the count 0 → 1 → 2 → 3. Why this step? Each Give is . The count is literally "how many events I still owe a wake-up for."
  2. Gives 4 and 5 hit the ceiling. Why this step? Count cannot exceed . xSemaphoreGive returns pdFALSE and the count saturates — those two events are silently lost.
  3. Task takes: 3 → 2 → 1 → 0, three successes. Why this step? Each Take is while . Three units are available.
  4. Fourth take finds → blocks. Why this step? Take with is the blocking edge; the task sleeps until the next give.

Figure — FreeRTOS IPC — queues, semaphores, mutexes, event groups
Figure — the teal staircase climbs 0→1→2→3 for the first three gives then flattens (gives 4 and 5 are lost against the plum dotted ceiling at N=3); the orange staircase then steps 3→2→1→0 for the three takes, and the label at the far right marks where count=0 forces the take to block.

Verify: gives requested 5, ceiling 3, so 2 lost. Takes drain 3, leaving 0. This is the exact reason you size the semaphore's max count to your worst-case burst — undersize it and events vanish. ✓


Example 4 — Binary semaphore: ISR wake + the lost-count trap [C7, C8]

Forecast: two gives — does the task process two bytes or one?

  1. First give: count 0 → 1. Why this step? Binary semaphore is a counting semaphore with . The first give sets the single flag.
  2. Second give: count stays 1 (saturated). Why this step? Max is 1, so the second give cannot increment — it returns pdFALSE. The "two bytes arrived" fact is now invisible.
  3. Task takes once: count 1 → 0, then blocks. Why this step? Only one unit exists, so the task wakes exactly once and processes one byte. The second byte's signal was lost.
  4. Fix — count how many, not whether. Why this step? To handle bursts you either drain a queue of the actual bytes, or use a counting semaphore sized to the FIFO depth (see Example 3). Binary answers "did it happen?", not "how many?".

Verify: gives = 2, but unit stored, so wakes = 1, lost = 1. Consistent with the counting formula at . ✓


Example 5 — Mutex: priority inheritance timeline [C9]

Forecast: guess L's effective priority during the wait, and who runs.

  1. L holds the mutex, runs the critical section. Why this step? Establish the baseline: the low-priority task owns the lock.
  2. H tries to take, finds it owned, blocks. Why this step? Mutex is held, so H (priority 3) cannot proceed and is parked on the mutex's wait list.
  3. Without inheritance: M (priority 2) preempts L (priority 1). Why this step? Scheduler always runs the highest-priority ready task. M > L, so M runs — potentially forever. H (priority 3) is blocked by M indirectly = priority inversion. H's wait = "however long M runs." Unbounded.
  4. With inheritance: kernel boosts L to H's priority (3). Why this step? While H waits on a mutex owned by L, FreeRTOS raises L's effective priority to . Now M (2) < L-boosted (3), so M cannot preempt L. L finishes the critical section quickly, gives the mutex, and drops back to priority 1. H then runs.

Figure — FreeRTOS IPC — queues, semaphores, mutexes, event groups
Figure — three horizontal lanes labelled H(3), M(2), L(1). The teal bar in L's lane shows L holding the mutex while boosted to priority 3; the dashed orange box in M's lane shows M "blocked out (2<3)" — it cannot preempt the boosted L. The plum band shows H's wait is short and bounded, ending with "H runs" once L gives the mutex.

Verify: After inheritance, running order is L(boosted) → H → M, and H's wait is bounded by L's remaining critical-section time, not by M. This is precisely the Priority Inversion & the Mars Pathfinder bug fix. Priorities restore: L back to 1. ✓


Example 6 — Mutex from an ISR: the illegal case [C10]

Forecast: does this compile-and-work, silently corrupt, or fail by design?

  1. A mutex has an owner; an ISR has no task identity. Why this step? Ownership is stored as "which task holds this." An ISR is not a task, so there is nobody to record as owner.
  2. Priority inheritance is undefined for an ISR. Why this step? Inheritance boosts the owner's task priority. With no owner task, there is nothing to boost — the whole mechanism is meaningless. FreeRTOS therefore provides no xSemaphoreTakeFromISR for mutexes.
  3. Correct pattern: signal a task with a binary/counting semaphore. Why this step? The ISR does xSemaphoreGiveFromISR(binarySem, &woke) then portYIELD_FROM_ISR(woke); a task takes the mutex and does the bus work. This respects Interrupt Service Routines & Deferred Processing.

Verify: No valid FreeRTOS API exists for taking a mutex from an ISR — the degenerate case is rejected at the API level, not merely discouraged. Correct path uses semaphore-give + yield. ✓


Example 7 — Event group: AND vs OR, broadcast to many, stale-bit re-trigger [C11, C12]

Forecast: guess who wakes on the WIFI-only set (one waiter or several?), and whether B and C fire again on stale bits.

  1. Compute masks numerically. Why this step? WIFI = 1, CFG = 2. A group value is the OR of its set bits, so we test each waiter's condition against that value.
  2. Worker sets WIFI → group = 01 = 1. Test all three waiters. Why this step? One bit-set is broadcast to every blocked task at once — this is the event-group superpower a single semaphore cannot do.
    • Task B (OR on {WIFI,CFG}): 1 & 3 = 1 ≠ 0B wakes.
    • Task C (OR on {WIFI}): 1 & 1 = 1 ≠ 0C wakes.
    • Task A (AND on {WIFI,CFG}): 1 & 3 = 1 ≠ 3A stays blocked. So one set of WIFI woke two tasks (B and C) simultaneously — cell C12's "one set wakes many."
  3. Worker sets CFG → group = 11 = 3. Task A's AND is now satisfied. Why this step? 3 & 3 = 3 = maskA wakes. A had clearOnExit = pdTRUE, so on exit it clears bits 0 and 1 → group returns to 00.
  4. Stale-bit re-trigger — the C12 edge B and C hit. Why this step? Suppose A had not run yet, so WIFI is still set, and B or C loops back to wait again. Because B and C set clearOnExit = pdFALSE, the bit was never consumed. Their OR condition group & mask ≠ 0 is still true, so they wake again instantly on the stale bit — a spurious re-trigger. Fix: the task that must react once per event should set clearOnExit = pdTRUE (as A does), so the bit is consumed and the next cycle starts fresh.

Figure — FreeRTOS IPC — queues, semaphores, mutexes, event groups
Figure — the teal boxes across the top are the group value over time: 00 (start) → 01 (set WIFI) → 11 (set CFG). At group=01 two plum/orange arrows fan out to both the OR waiters (B and C), showing one set waking many. At group=11 a single arrow wakes the AND waiter A, which then auto-clears the group back to 00. The dashed loop-back arrow marks where a non-clearing waiter would re-fire on the stale WIFI bit.

Verify: mask for A/B = 3, for C = 1. On group=1: B 1&3=1≠0 wakes, C 1&1=1≠0 wakes, A 1&3=1≠3 waits → 2 wake, 1 blocks. On group=3: A 3&3=3=mask wakes, then clears 3 & ~3 = 0. Stale re-trigger: with the bit still set and clearOnExit=pdFALSE, 1 & 1 = 1 ≠ 0 is still true → C re-fires. ✓


Example 8 — Pointer through a queue: the dangling-pointer twist [C13]

Forecast: the send returned pdTRUE — is the 512-byte packet safe when the consumer finally reads it? Guess before reading on.

  1. The queue copies the pointer value, not the 512 bytes. Why this step? The queue's item size here is sizeof(char*) — on a typical 32-bit MCU that is 4 bytes. So only the address of buf is copied into the queue slot; the 512 payload bytes stay where they were allocated on the heap. This is why "send a pointer to avoid copying" works mechanically.
  2. Producer frees buf before the consumer reads. Why this step? free(buf) returns that 512-byte heap block to the allocator. The 4-byte pointer sitting in the queue now names memory the allocator considers free and reusable — a dangling pointer (points at deallocated memory). See Memory Management in FreeRTOS — heap_1..5 for which heap schemes reuse freed blocks (heap_2/4/5 do; heap_1 never frees).
  3. Consumer dereferences → reads garbage or corrupted data. Why this step? Between the free and the consumer's read, another allocation elsewhere may have grabbed that exact block and overwritten it. The consumer then reads someone else's data — a use-after-free bug that is intermittent and brutal to debug.
  4. Fix: transfer ownership — the producer must NOT free. Why this step? Ownership of the buffer travels through the queue with the pointer. The rule: producer allocates and sends, then never touches buf again; the consumer frees buf after using it. Exactly one owner at a time, hand-off at the send. For small items, sidestep the whole problem by copying by value (send the struct itself, not a pointer).

Figure — FreeRTOS IPC — queues, semaphores, mutexes, event groups
Figure — left: a 512-byte teal heap block with a 4-byte orange pointer inside the queue slot pointing at it (only the pointer is copied). Middle: the producer's free() greys out the heap block and the pointer now dangles (dashed red arrow into freed memory). Right: the corrected hand-off — the pointer crosses the queue, the producer lets go, and the consumer frees after use (single owner).

Verify: The queue moved 4 bytes; the 512-byte payload was never copied, so its lifetime is entirely the programmer's responsibility. Freeing in the producer = use-after-free; freeing in the consumer = correct single-owner handoff. For small items, copy-by-value avoids the whole problem. ✓


Recall Self-test (reveal after guessing)

Full queue (cap 3), send with timeout 0, 4 sends — how many succeed? ::: 3 succeed, 1 returns pdFALSE. Counting semaphore max 3, given 5 times from 0 — final count and lost events? ::: count = 3, 2 events lost. Binary semaphore given twice before one take — bytes processed? ::: 1 (second give saturates and is lost). Mutex, L=1 holds it, H=3 waits — L's effective priority during the wait? ::: 3 (boosted to H). Event group, mask=3, group=1, one OR waiter on {WIFI,CFG} and one on {WIFI} — who wakes? ::: both OR waiters wake (broadcast); the AND waiter stays blocked. Send a pointer then free it in the producer — the bug? ::: dangling pointer / use-after-free; consumer must own the free.


Back to the parent topic · related: Producer–Consumer Pattern, Critical Sections & taskENTER_CRITICAL.