Visual walkthrough — FreeRTOS IPC — queues, semaphores, mutexes, event groups
Before we touch a queue we must agree on three words: task, ready list, and block. We will not use any of them until they are drawn.
Step 1 — What "many tasks, one CPU" actually looks like
WHAT. A task is just a function that runs forever in a loop, plus a little saved state (its stack, its registers). The CPU can run only one task at a time. A part of the kernel called the scheduler decides which one, and it can preempt — freeze the current task mid-line and switch — as covered in RTOS Scheduling — preemption & priorities.
WHY start here. Everything the queue does is move a task between two piles. So we must first see the piles. There is a Ready list (tasks that want the CPU and are just waiting their turn) and, for each kernel object, a Blocked list (tasks that are asleep, waiting for something specific).
PICTURE. Look at the two coloured boxes. The lavender Ready box holds tasks that could run right now; the CPU (the little chip) picks the highest-priority one. The Blocked box is empty for now — nobody is waiting on anything yet.

Step 2 — The queue is a box with three parts
WHAT. We create a queue with xQueueCreate(length, itemSize). Concretely the kernel carves out three things:
Reading it term by term: length is how many items fit; itemSize is how many bytes each item is; their product is the total mailbox storage. The two lists start empty — they only fill when a task has to wait.
WHY three parts. Storage alone would force senders to spin ("is there room yet? is there room yet?"). By keeping two lists of sleeping tasks right next to the data, the kernel can put a task to sleep and later wake exactly the right one — no spinning.
PICTURE. Five mint slots (this queue has length = 5), all empty. Below them, two empty trays labelled senders waiting and receivers waiting.

Step 3 — Receive on an empty queue → the task goes to sleep
WHAT. The Consumer runs xQueueReceive(q, &got, portMAX_DELAY). The queue is empty. There is nothing to copy.
WHY it blocks instead of returning. We asked to wait up to portMAX_DELAY (forever). Since the storage is empty, the kernel does the useful thing: it moves the Consumer out of Ready and into the queue's receivers-waiting list. Now the scheduler ignores it. The CPU is free to run somebody else.
PICTURE. Watch the arrow: the Consumer task travels from the lavender Ready box into the queue's receivers waiting tray. It is now grey (asleep). The storage slots are still empty.

Step 4 — Send arrives → copy the bytes into a slot
WHAT. Now the Producer runs xQueueSend(q, &v, ...). Here v is the Producer's local value and &v is where to copy from. The kernel copies itemSize bytes from v into the first free storage slot.
WHY copy the whole value (not a pointer). The instant the Producer is preempted, its local v could be overwritten or go out of scope. Copying the raw bytes into kernel-owned storage makes the message self-contained — its lifetime no longer depends on the sender. (This is the copy-by-value guarantee the parent note stressed.)
PICTURE. A coral arrow copies the bytes of v from the Producer's stack into slot 0 of the mint storage. Slot 0 turns coral (now occupied); slots 1–4 stay empty.

Step 5 — The wake-up: kernel unblocks the highest-priority receiver
WHAT. Immediately after copying, the kernel checks the receivers-waiting list. It is not empty — the Consumer is sitting there. So the kernel:
- copies the bytes from the slot into the address (
&got) the Consumer left behind, - removes the Consumer from receivers-waiting,
- puts it back into the Ready list.
WHY highest-priority. If several tasks were waiting, the kernel wakes the highest-priority one first, honouring the priority rules. This keeps the RTOS's "most urgent work first" promise.
PICTURE. Two arrows: a butter arrow copies slot 0 → got; a lavender arrow carries the Consumer back from the sleeping tray into Ready. The slot empties again.

Step 6 — Preemption: does the waker run right now?
WHAT. The Consumer is now Ready. Suppose it has higher priority than the Producer. A preemptive scheduler must switch to it immediately, before the xQueueSend call even returns to the Producer.
WHY. The whole reason we run an RTOS is bounded latency: the moment a more urgent task becomes runnable, it should run. So xQueueSend triggers a context switch on the spot if it just unblocked a higher-priority task.
PICTURE. A timeline. Producer's xQueueSend line is where the arrow flips control (coral → lavender): the Consumer runs inside the send call, then control returns to the Producer afterwards.

Step 7 — The mirror case: Send to a full queue
WHAT. Reverse the roles. All 5 slots are occupied and the Producer calls xQueueSend(q, &v, xTicksToWait). There is no room.
WHY it blocks now. Same machine, other list: the Producer is moved into the senders-waiting list with its timeout. When a receiver later takes an item and frees a slot, the kernel copies the sleeping sender's value in and wakes it — perfectly symmetric to Steps 3–5.
PICTURE. All five slots coral (full). The Producer arrow travels into the senders waiting tray. A later Receive frees slot 0 and pulls the Producer back to Ready.

This is the Producer–Consumer Pattern in its complete form: both ends can sleep, so a fast producer and a slow consumer (or vice versa) automatically throttle each other with no busy-waiting.
Step 8 — Degenerate & edge cases (never leave a gap)
WHAT / WHY / PICTURE, four situations, each drawn as a mini-panel:
- Timeout = 0 (
xTicksToWait = 0): the kernel does not move the task to a waiting list at all. Empty-on-receive → returnpdFALSEinstantly. This is the polite non-blocking poll. - Timeout expires: a blocked task whose ticks run out is moved back to Ready by the kernel's tick handler and the call returns
pdFALSE(nothing was transferred). - From an ISR: an ISR has no task identity, so it must not block. Use
xQueueSendFromISR; if the queue is full it just returnspdFALSE— it can never sleep. It reports whether a higher-priority task woke, so you canportYIELD_FROM_ISR. itemSize = 0: the storage carries zero bytes per item — the queue degenerates into a pure counter of how many "empty messages" are present. That counter is a semaphore. Same code, no data. This is why the parent could say "a semaphore is a degenerate queue."

Here the left side is the number of zero-byte items present; the right side is the semaphore's counter — they are literally the same integer.
The one-picture summary
Everything above is a task walking between three places — Ready, the storage, and a waiting list — with the kernel copying bytes and choosing who wakes. This single diagram compresses Steps 1–8: send fills a slot and wakes a sleeping receiver; a full queue sleeps the sender; the highest-priority waiter always wins; and shrinking itemSize to zero turns the whole thing into a semaphore.

Recall Feynman: tell the whole walkthrough in plain words
There's one teacher (the CPU) and a shelf with five cubbies (the queue). A kid who wants a note but finds the cubbies empty doesn't stand there tapping — the teacher sends them to nap in the "waiting for a note" corner and helps someone else. When another kid drops a note in a cubby, the teacher immediately copies it into the napping kid's hand, wakes them, and — if that kid is more urgent than everyone else — lets them go right now, even interrupting the drop-off. If all cubbies are full, the dropper naps in the "waiting for space" corner until someone takes a note out. And if the notes have no writing on them at all, the whole thing becomes just a tally of how many blank notes are on the shelf — which is exactly a semaphore. One little machine, two sleeping corners, and a rule that the most urgent kid wakes first: that's a FreeRTOS queue.
Recall Quick self-check
Why does receive on an empty queue not waste CPU? ::: The task is removed from the Ready list into the receivers-waiting list, so the scheduler never runs it until data arrives — zero CPU used.
Why are the bytes copied twice on a successful transfer? ::: Sender → kernel slot (survives sender preemption), then slot → receiver's address; each copy makes the message independent of the other task's timing.
What single change turns a queue into a semaphore? ::: Setting itemSize = 0, so the "item count" becomes a pure counter with no data.
When must you use xQueueSendFromISR instead of xQueueSend? ::: In an ISR, which has no task context and therefore may never block; the FromISR variant returns immediately if it can't act.