Worked examples — FreeRTOS IPC — queues, semaphores, mutexes, event groups
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.
- The consumer hits an empty queue.
Why this step? We must first know the queue's state. Zero items are stored, so
xQueueReceivecannot copy anything out. - The kernel blocks the consumer instead of spinning.
Why this step?
portMAX_DELAYmeans "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. - Producer sends
10. Why this step? ASendinto a queue that has a blocked receiver does two things atomically: copy the byte pattern of10into a kernel slot, then unblock the highest-priority waiting receiver. - 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 out10 → 20 → 30.

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?
- Sends 1, 2, 3 fill slots 0, 1, 2.
Why this step? Each
Sendcopies into the next free slot; 3 free slots exist. All three returnpdTRUE. - Send 4 finds the queue full. Why this step? Capacity is 3, and nobody has received, so 0 slots are free.
- Timeout is 0 → no blocking, immediate return.
Why this step? A zero
xTicksToWaitis the degenerate wait: "try, but never sleep." With no room right now, the call returnspdFALSEat once.

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

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?
- First give: count
0 → 1. Why this step? Binary semaphore is a counting semaphore with . The first give sets the single flag. - Second give: count stays
1(saturated). Why this step? Max is 1, so the second give cannot increment — it returnspdFALSE. The "two bytes arrived" fact is now invisible. - 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. - 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.
- L holds the mutex, runs the critical section. Why this step? Establish the baseline: the low-priority task owns the lock.
- 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.
- 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.
- 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.

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?
- 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.
- 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
xSemaphoreTakeFromISRfor mutexes. - Correct pattern: signal a task with a binary/counting semaphore.
Why this step? The ISR does
xSemaphoreGiveFromISR(binarySem, &woke)thenportYIELD_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.
- 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. - 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 ≠ 0→ B wakes. - Task C (OR on {WIFI}):
1 & 1 = 1 ≠ 0→ C wakes. - Task A (AND on {WIFI,CFG}):
1 & 3 = 1 ≠ 3→ A stays blocked. So one set ofWIFIwoke two tasks (B and C) simultaneously — cell C12's "one set wakes many."
- Task B (OR on {WIFI,CFG}):
- Worker sets
CFG→ group =11= 3. Task A's AND is now satisfied. Why this step?3 & 3 = 3 = mask→ A wakes. A hadclearOnExit = pdTRUE, so on exit it clears bits 0 and 1 → group returns to00. - Stale-bit re-trigger — the C12 edge B and C hit.
Why this step? Suppose A had not run yet, so
WIFIis still set, and B or C loops back to wait again. Because B and C setclearOnExit = pdFALSE, the bit was never consumed. Their OR conditiongroup & mask ≠ 0is 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 setclearOnExit = pdTRUE(as A does), so the bit is consumed and the next cycle starts fresh.

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.
- 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 ofbufis 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. - Producer frees
bufbefore 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/5do;heap_1never frees). - Consumer dereferences → reads garbage or corrupted data.
Why this step? Between the
freeand 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. - 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
bufagain; the consumer freesbufafter 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).

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.