5.5.11 · D4Embedded Systems & Real-Time Software

Exercises — FreeRTOS IPC — queues, semaphores, mutexes, event groups

3,918 words18 min readBack to topic

This page tests everything from queues, semaphores, mutexes, and event groups. Prerequisite ideas — RTOS Scheduling — preemption & priorities, Priority Inversion & the Mars Pathfinder bug, Interrupt Service Routines & Deferred Processing, Producer–Consumer Pattern, and Memory Management in FreeRTOS — heap_1..5 — are used but re-explained in each solution.

Below, one small picture (the map) shows which object each rung exercises before you dive in.

Figure — FreeRTOS IPC — queues, semaphores, mutexes, event groups

L1 · Recognition — "name the right tool"

Here you only decide which kernel object fits. No code yet. Remember the four objects:

  • Queue = a copy-by-value mailbox (FIFO, first-in-first-out).
  • Semaphore = a counter you can Give (add 1) and Take (subtract 1, blocking at 0).
  • Mutex = a binary semaphore with an owner + priority inheritance (a lockable "key").
  • Event group = a set of bits you wait on with AND / OR logic.

Exercise L1.1

A UART interrupt fires each time one byte arrives. The interrupt handler must be tiny; a normal task should do the actual parsing. Which IPC object wakes the task, and why not a mutex?

Recall Solution

Object: binary semaphore (given from the ISR with xSemaphoreGiveFromISR). Why: the ISR only needs to signal "something happened" — a single yes/no wake-up. A binary semaphore is exactly a 0/1 flag. Why not a mutex: a mutex has an owner — only the task that took it may give it. An ISR has no task identity, so there is nobody to own or inherit priority. Ownership rules forbid an ISR releasing a mutex, so priority inheritance would be undefined. Signalling is not locking.

Exercise L1.2

Task A produces uint16_t temperature readings; task B logs them, possibly slower. You must never lose a reading unless the buffer overflows, and the two tasks must not corrupt each other's data. Which object?

Recall Solution

Object: queue of uint16_t items. Why: you need to transfer data (the reading itself), keep order (FIFO), and buffer a few while B is slow. A queue copies the bytes into kernel-owned storage, so A can overwrite its local variable the instant it returns without harming the message already in flight. This is the Producer–Consumer Pattern.

Exercise L1.3

A firmware update task must run only after both "network connected" and "flash unlocked" become true, in any order. Which object?

Recall Solution

Object: event group with two bits, waited on with the AND rule (xWaitForAllBits = pdTRUE). Why: you're waiting on a logical combination of independent conditions. One semaphore answers one yes/no; you'd need awkward juggling of two of them. An event group lets a single task block until the chosen mask (both bits) is satisfied — regardless of the order they arrive.


L2 · Application — "use the API correctly"

Exercise L2.1

Create a queue holding 8 items, each a struct sensor_sample of 12 bytes. How many bytes of storage does the queue reserve for messages (ignore the control block), and what is the exact xQueueCreate call?

Recall Solution

Call: xQueueCreate(8, sizeof(struct sensor_sample));xQueueCreate(8, 12); Storage for messages: length × itemSize = 8 × 12 = 96 bytes. Why this size: a queue copies each item by value, so it must own length × itemSize bytes to hold up to length full copies at once. (There is also a small control block and two lists of blocked tasks, but the message buffer itself is 96 bytes.)

Exercise L2.2

A sender does xQueueSend(q, &v, pdMS_TO_TICKS(50)). The kernel tick runs at 1000 Hz (1 ms per tick). How many ticks will the sender block if the queue stays full the whole time? Also explain why the argument is &v and not v, and derive why the rounding formula uses a +999 bias at a 300 Hz tick rate.

Recall Solution

The real macro rounds up. In FreeRTOS the definition is essentially computed in integer arithmetic — and the common implementation adds a rounding bias so partial ticks are rounded up, e.g. ((ms * configTICK_RATE_HZ) + 999) / 1000 on some ports. This matters whenever the tick rate does not divide 1000 evenly.

Why +999, step by step (the integer-arithmetic "why"). Integer division a / b in C throws away the remainder — it floors, i.e. rounds down. But a timeout must never be shorter than requested, so we need to round up. The universal integer trick for "divide and round up" is: Why exactly and not some other number? Write with remainder .

  • If (divides evenly): adding gives , and — unchanged, correct.
  • If (there is a leftover partial tick): , so the floor becomes — bumped up by exactly one, correct. Adding less than would fail to bump some cases; adding more could over-bump when . So is the unique smallest bias that rounds up only when a remainder exists. Here (the ms→s denominator), so the bias is .

At 1000 Hz: exactly, no rounding needed. So the sender blocks up to 50 ticks (= 50 ms). At 300 Hz (does not divide 1000): take : exact is . Plain truncation gives ticks (too short!); the round-up form ticks. The rounded-up answer guarantees you wait at least the requested time, never less. Why &v: the API takes a pointer to the item to copy. FreeRTOS reads itemSize bytes starting at that address and copies them into the queue. Passing v (the value) would be a type error — the function does not know your item's type, only its byte count and a source address.

Exercise L2.3

An ISR wants to wake a high-priority task through a binary semaphore. Fill in the two things the classic pattern needs beyond xSemaphoreGiveFromISR.

Recall Solution
BaseType_t woke = pdFALSE;                 // (1) a flag the API sets
xSemaphoreGiveFromISR(sem, &woke);         //     if a higher-prio task woke
portYIELD_FROM_ISR(woke);                  // (2) switch to it on ISR exit

Why the woke flag: giving the semaphore may unblock a task higher priority than whatever was interrupted. xSemaphoreGiveFromISR sets woke = pdTRUE to tell you so. Why portYIELD_FROM_ISR: if a higher-priority task became ready, we context-switch to it immediately on ISR return, instead of waiting for the next scheduler tick — this minimises wake-up latency. This is Interrupt Service Routines & Deferred Processing in action.

Exercise L2.4 — event-group API

An OTA (firmware update) task must proceed only when both WIFI_BIT (bit 0) and CFG_BIT (bit 1) are set, and it should clear both on exit so the next cycle starts clean. Write the create call, the two set calls from other tasks, and the wait call — and state what mask value "both bits" is numerically.

Recall Solution
#define WIFI_BIT (1 << 0)   // = 0b01 = 1
#define CFG_BIT  (1 << 1)   // = 0b10 = 2
 
EventGroupHandle_t eg = xEventGroupCreate();      // one word of bits, all 0
 
// from the WiFi task, when the link comes up:
xEventGroupSetBits(eg, WIFI_BIT);                 // sets bit 0
// from the config task, when config is loaded:
xEventGroupSetBits(eg, CFG_BIT);                  // sets bit 1
 
// the OTA task blocks until BOTH are set:
EventBits_t b = xEventGroupWaitBits(
    eg,
    WIFI_BIT | CFG_BIT,   // mask to wait on = 0b11 = 3
    pdTRUE,               // xClearOnExit: clear those bits when we unblock
    pdTRUE,               // xWaitForAllBits: pdTRUE = AND (both), pdFALSE = OR (any)
    portMAX_DELAY);       // block forever until satisfied

The "both bits" mask numerically: WIFI_BIT | CFG_BIT = 1 | 2 = 3 (binary 0b11). The bitwise-OR combines two single-bit flags into one mask; waiting on that mask with xWaitForAllBits = pdTRUE means "wake only when all set bits in the mask are set in the group." Why pdTRUE for clear-on-exit: without it the bits stay set, and the next loop iteration would fire immediately on stale bits instead of waiting for fresh events. Why portMAX_DELAY: the OTA task has nothing to do until both conditions hold — let the kernel sleep it forever (no busy-waiting) rather than time out. (portMAX_DELAY = block forever, defined at the top of this page.)


L3 · Analysis — "reason about counts and timing"

Exercise L3.1

A counting semaphore is created with max count 3, initial count 0. While a worker task is busy (not calling Take), an ISR calls Give 5 times. What is the semaphore's count afterward, and how many times will the worker's Take succeed without blocking once it starts looping?

Recall Solution

Count: each Give tries c += 1 but saturates at the max, 3. Starting at 0: gives 1→1, 2→2, 3→3, 4→still 3, 5→still 3. Final count = 3. Takes that succeed immediately: the count is 3, so the worker can Take 3 times without blocking (3→2→1→0). The 4th and 5th events were lost because the semaphore saturated. Lesson: a counting semaphore "remembers how many, up to its max." Choose the max ≥ the worst-case burst you must not lose.

Exercise L3.2 — Priority inversion timing

Three tasks: L (low), M (medium), H (high). L takes a mutex at and needs 30 ms of work inside the lock. At ms, H wakes and tries to take the same mutex. At ms, M wakes and would run for 100 ms. Assume single core, priority-preemptive scheduling. (a) Without priority inheritance, when (earliest) can H acquire the mutex? (b) With priority inheritance, when can H acquire it?

Recall Solution

A full self-contained timeline in words — and the figure below draws exactly these two timelines side by side so you can see the difference.

How to read the figure: time runs left-to-right in milliseconds; each row is one task (L bottom, M middle, H top). A green bar = L running while holding the lock; a yellow bar = M running; a blue bar = L running at boosted priority; a red bar = H blocked, waiting. The arrow marks the instant H finally acquires the mutex. The left panel is without inheritance (H waits to 130 ms); the right panel is with inheritance (H waits only to 30 ms). Watch how the yellow M bar disappears from H's critical path on the right — that is the whole fix.

(a) Without inheritance — trace the CPU owner over time:

  • ms: only L is awake. L runs, holding the lock. It has now done 5 ms of its 30 ms of locked work.
  • ms: H wakes and tries to take the mutex. L still holds it, so H blocks. L is still the only ready task, so L keeps running.
  • ms: L runs another 5 ms → 10 ms of locked work done, 20 ms remaining.
  • ms: M wakes. M's priority > L's, and M does not need the lock, so the scheduler preempts L and runs M.
  • ms: M runs its full 100 ms. L is starved the entire time, still holding the lock, so H stays blocked.
  • ms: M finishes. L resumes and runs its remaining 20 ms of locked work.
  • ms: L releases the mutex. H finally acquires it at ms. This blocking is unbounded: add more medium tasks and H waits even longer.

(b) With inheritance — the same trace, one rule changed:

  • ms: L runs holding the lock (5 ms done, 25 ms left).
  • ms: H blocks on the mutex. The kernel now boosts L's priority to H's (priority inheritance) because L holds the lock H wants.
  • ms: L, now at H's priority, runs its remaining 25 ms uninterrupted.
  • ms: M wakes — but M's medium priority is below L's boosted priority, so M cannot preempt L. M waits.
  • ms: L finishes the locked work and releases the mutex; its priority drops back to low. H acquires the mutex at ms, bounded only by L's critical-section length. This is the fix behind the Priority Inversion & the Mars Pathfinder bug.
Figure — FreeRTOS IPC — queues, semaphores, mutexes, event groups

Exercise L3.3

A queue of length 2 holds uint32_t. Three producers each xQueueSend one value (10, 20, 30) with xTicksToWait = 0 (no blocking), back-to-back, before any receiver runs. What happens to each send, and what will a receiver read first?

Recall Solution
  • Send 10 → queue [10], returns pdTRUE.
  • Send 20 → queue [10, 20] (full), returns pdTRUE.
  • Send 30 → queue is full and timeout is 0, so it does not block; returns pdFALSE. 30 is dropped.
  • Receiver reads FIFO → 10 first, then 20. Lesson: with xTicksToWait = 0, a full queue makes xQueueSend fail immediately (return value pdFALSE) rather than block — you must check the return value.

L4 · Synthesis — "design a small system"

Exercise L4.1

Design the IPC for this system: a temperature ISR fires every sample and must hand a float to a filter task; the filter writes results to a shared I²C display that a second clock task also writes to. List each object you'd create and one sentence why.

Recall Solution
  • Queue q_samples (float, length ≈ 8): ISR → filter task data transfer. Copy-by-value keeps each sample lifetime-safe; the length absorbs jitter. Use xQueueSendFromISR in the ISR.
  • Mutex m_display: both the filter task and the clock task write the shared I²C display; a mutex gives exclusive turns and priority inheritance so a low-priority writer can't indefinitely block a high-priority one.
  • (Optional) binary semaphore could replace the queue only if the ISR passed no data — but here it passes a float, so a queue is required. Why not a mutex ISR→filter? ISRs can't own mutexes; use the queue (which has ...FromISR variants) for the hand-off.

Exercise L4.2

You must guarantee a "deferred interrupt processing" pipeline never loses events even if 4 interrupts fire while the worker is busy for one long stretch. What object, what parameters, and what's the trade-off?

Recall Solution
  • Counting semaphore, xSemaphoreCreateCounting(maxCount, 0) with maxCount ≥ 4 (pick the worst-case burst, e.g. 8 for margin).
  • ISR does xSemaphoreGiveFromISR each event; the worker loops xSemaphoreTake(sem, portMAX_DELAY); handle_one();.
  • Why counting, not binary: a binary semaphore saturates at 1 — 4 events during one busy stretch collapse to a single wake-up, losing 3. A counting semaphore remembers the backlog up to its max.
  • Trade-off: if the real burst can exceed maxCount, events beyond it are still lost — you must size maxCount to the true worst case, at the cost of a slightly larger control block.

L5 · Mastery — "defend a subtle choice"

Exercise L5.1

A colleague protects a shared counter with taskENTER_CRITICAL()/taskEXIT_CRITICAL() for a 3-instruction increment, and elsewhere uses a mutex to guard a 5 ms I²C transaction. Defend or refute each choice.

Recall Solution
  • Critical section for the 3-instruction increment: correct. taskENTER_CRITICAL disables the scheduler/interrupts for a handful of cycles — cheap, and no blocking object is needed for something this short. See Critical Sections & taskENTER_CRITICAL.
  • Mutex for the 5 ms I²C transaction: correct. Disabling interrupts for 5 ms would wreck real-time latency (no ISR could run). A mutex blocks the waiting task instead — the CPU keeps doing other work — and priority inheritance bounds the wait.
  • The rule: guard very short, deterministic sequences with a critical section; guard long or blocking operations with a mutex. Wrong swap (critical section around 5 ms) would freeze the whole system's interrupts for 5 ms.

Exercise L5.2

Prove — by counting — that using a binary semaphore instead of a counting one for a burst-of- deferred-work scenario loses exactly events in the worst case, when all gives land before any take. Then state what a counting semaphore with achieves instead.

Recall Solution

Model the binary semaphore as a queue of length 1, itemSize 0. Its count is , and every Give does the saturating update .

  • Start . Give #1: .
  • Gives #2 … #N: each computes — the count is already saturated, so all of these are no-ops. That is exactly lost events.
  • When the worker finally Takes: one successful wake-up, then . So out of events, exactly 1 produces work and are lost — the claim.

With a counting semaphore, : the update never saturates during the burst, so after gives . The worker then Takes times, doing all units of work with 0 losses. This is the concrete justification for L4.2's sizing rule: set to the worst-case burst.


Recall Self-test cloze

A semaphore is implemented as a queue of itemSize ::: 0 (zero bytes). Only the task that took a mutex may give it back — this property is called ::: ownership. While a high-priority task waits on a mutex, the kernel boosts the holder's priority — this is ::: priority inheritance. pdMS_TO_TICKS(50) at a 100 Hz tick rate equals ::: 5 ticks. A counting semaphore's count saturates at its ::: maximum count. The event-group mask WIFI_BIT | CFG_BIT with bits 0 and 1 equals numerically ::: 3.