Embedded HandbookApps
All sections › IV. FreeRTOS
STM32F103, STM32F407 · level advanced · 13 min read
Open in the interactive handbook

30. Synchronization and thread safety

This section is about bugs that did not exist before the operating system. While the program was one loop, a variable changed in only one place and only when the loop reached that place. Now two tasks can change it at the same time — and they do, more often than you think.

Terms used in the documentation

Term Meaning
race condition the result depends on the timing of tasks
atomic operation an indivisible operation
critical section a stretch of code run with interrupts masked
scheduler suspension task switching is paused
mutual exclusion / mutex only one task at a time / a lock for that
priority inversion a high-priority task waits for a lower one
priority inheritance the lock holder temporarily gets the waiter's priority
deadlock tasks wait for each other forever
recursive mutex a mutex the same task can take several times
gatekeeper task a task that alone owns a resource
thread safe safe to call at the same time from several tasks

30.1 A race: why counter++ does not work

The most innocent line in the world:

shared_counter++;

The compiler turns it into three instructions:

ldr   r1, [r0]      ; прочитать значение
adds  r1, #1        ; прибавить единицу
str   r1, [r0]      ; записать обратно

Now let two tasks execute this line, and let the scheduler switch them between ldr and str:

Step Task A Task B Value
1 read 5 5
2 preempted read 5 5
3 wrote 6 6
4 wrote 6 6

Two increments — the result is plus one. One is lost, and neither task will ever know. This is a race: the result depends on the moment at which the scheduler switched tasks.

A race on counter++ step by step: task A reads 5 and is preempted, task B reads 5, adds one and writes 6, then A writes its own 6 — the counter holds 6 instead of the expected 7, one increment is lost; the scheduler switch fell between ldr and str

Anything longer than one instruction is subject to races: a structure of two fields, a 64-bit number on a 32-bit processor, any "read — modify — write". The volatile keyword (section 3) does not help at all here: it makes the compiler read the variable from memory, but it does not turn three instructions into one.

Three ways to protect, from coarse to fine.


30.2 The critical section

taskENTER_CRITICAL();
shared_counter++;
taskEXIT_CRITICAL();

Inside the section there are no task switches and no interrupts at the threshold and below (section 28): the kernel raises BASEPRI to 0x50. The three instructions execute as one.

Sections nest. The kernel counts the depth and removes the mask only on exit from the outermost one. So a function with a section inside can safely be called from another section.

From an interrupt — a separate pair, taskENTER_CRITICAL_FROM_ISR and its partner with the saved value. The ordinary pair must not be called from a handler: with configASSERT enabled the kernel catches this.

⚠️ A section must be short. While it is open, all interrupts at the threshold and below wait. Ten microseconds is 720 cycles at 72 MHz during which UART reception, the tick and everything else are stalled. The rule: a handful of instructions inside a section. No loops, no calls, no waiting.

A single counter can do without a section. On Cortex-M3/M4 the increment is done with a C11 atomic operation (124.5):

#include <stdatomic.h>

static atomic_uint shared_counter;

void count_event(void)
{
    atomic_fetch_add_explicit(&shared_counter, 1u, memory_order_relaxed);
}

armclang builds this into an LDREX/STREX loop: if an interrupt or a task switch happened between the read and the write, STREX does not write, and the loop tries again — the increment is not lost. Interrupts are not disabled at all, so you can count this way even together with fast interrupts above the threshold, which a section does not protect against (28.6). Cortex-M0/M0+ have no such instructions: the same line becomes a call to __sync_fetch_and_add_4, which again contains a section.

The section is a tool for small things: an increment, copying a pair of fields, reading and clearing a flag. For anything long — the next method.


30.3 Suspending the scheduler

vTaskSuspendAll();
recalculate_table();            /* десятки микросекунд */
xTaskResumeAll();

Softer than a section: only task switching is forbidden; interrupts run as usual. Suitable for long operations on data that is touched only by tasks, not by interrupts.

From an interrupt that occurs inside the pause you may call FromISR — the kernel remembers that someone was woken and switches on exit from xTaskResumeAll.

⚠️ Inside the pause you must not call functions that may block. With the scheduler stopped there is no one to wait: vTaskDelay, xQueueReceive with a non-zero time are errors, and the kernel catches them with a check.


30.4 The mutex

When the resource is not a variable but something long: the I²C bus, a display, a file on a card. A section is no good here — you cannot hold interrupts off for the duration of a bus transfer. You need a lock that one task takes while the others wait as tasks, without using the CPU.

static SemaphoreHandle_t m_bus;

/* в main, до vTaskStartScheduler */
m_bus = xSemaphoreCreateMutex();
configASSERT(m_bus != NULL);

void bus_write(uint8_t addr, uint8_t val)
{
    if (xSemaphoreTake(m_bus, pdMS_TO_TICKS(50)) == pdTRUE) {
        i2c_transfer(addr, val);
        xSemaphoreGive(m_bus);
    } else {
        /* 50 мс не дождались — шина висит, и это надо знать */
    }
}

Three rules of the mutex:

If you took it, give it back. Every Take has its own Give, on all exit paths from the function, including the error ones.

The one who took it gives it back. The kernel relies on this, but for an ordinary mutex it does not check: a foreign Give with configASSERT enabled halts the program, and without it goes through and corrupts the priority inheritance bookkeeping. A recursive mutex checks the owner and returns failure to anyone else.

Not from an interrupt. Neither take nor give. An interrupt is not a task, it cannot be an owner, and it has no priority to inherit. For interrupts — a binary semaphore or a queue.

⚠️ Always check the result of a Take with a limited wait. Failure means the resource has been busy for longer than is reasonable. Starting a bus transfer without getting the mutex means overlapping someone else's transfer, and that is worse than skipping your own.

Mutex and binary semaphore: what is the difference

The structure is the same, the cost is the same — 80 bytes. The behaviour differs:

Binary semaphore Mutex
knows its owner no yes
priority inheritance no yes
who can give it anyone, including an interrupt only the owner
purpose event signal resource protection

The semaphore is for the "interrupt → task" pairing (section 28). The mutex is for "one resource, many takers". You can mix the roles, but then you lose the only thing the mutex exists for.


30.5 ⚠️ Priority inversion

The situation that makes it worth understanding mutexes more deeply than "take — give".

Three tasks: L — low priority, M — medium, H — high. L has taken the mutex and is working with the bus. H wakes up, also wants the bus and blocks on the mutex — waiting for L to give it. So far everything is fair.

Now M wakes up. It does not need the bus; it is just computing. M has higher priority than L, so it preempts it. L does not run — does not give the mutex — H waits. A high-priority task waits for a medium-priority one that has nothing to do with the resource. How long does it wait? Until M is done working. That is, without bound.

Task Priority What it does
H 3 waits for the mutex, can do nothing
M 2 computes its own stuff, not involved
L 1 holds the mutex, preempted, not running

This is not a theoretical problem. This is exactly how the Mars Pathfinder lander hung in 1997: a low-priority weather data task held the shared bus mutex, a high-priority bus management task waited for it, and between them a medium-priority communications task ran for long stretches — and the watchdog timer rebooted the spacecraft. The cure was found remotely: priority inheritance was enabled on that mutex.

Priority inheritance is what a mutex does:

While a task holds a mutex that a higher-priority task is waiting for, it gets the priority of the waiting task.

For the duration of ownership L becomes level three. M with its two no longer preempts it. L finishes its work, gives the mutex, returns to one, H gets the resource. H's wait is bounded by the time L works with the resource — and only by that.

On a trace — the same three tasks with a binary semaphore and with a mutex:

Priority inversion trace: without inheritance H waits for all of M, with a mutex only for the rest of L

Only the mutex gives inheritance. A binary semaphore does not know its owner and has no one to raise — which is why it must not be used to protect a resource.

⚠️ Inheritance is not a cure but a painkiller. It limits inversion but does not remove it: H still waits until L is done with the bus. The real cure is to hold the mutex as briefly as possible: take it, do the minimum, give it back. Do not compute under a mutex, do not wait under a mutex, do not call functions under a mutex that themselves wait.


30.6 ⚠️ Deadlock

Two resources, two tasks, two mutexes.

Task A: take the bus, then the log. Task B: take the log, then the bus.

A took the bus. B took the log. A waits for the log — B has it. B waits for the bus — A has it. Forever. Both are blocked, neither uses the CPU, the device looks alive from the outside and does nothing.

There is one cure, and it is organizational: a single locking order across the whole program. If there are several mutexes, they are numbered, and every task takes them strictly in ascending order. It gives them back in reverse order.

/* Во всех задачах: сначала bus, потом log. Без исключений. */
xSemaphoreTake(m_bus, portMAX_DELAY);
xSemaphoreTake(m_log, portMAX_DELAY);
/* ... */
xSemaphoreGive(m_log);
xSemaphoreGive(m_bus);

The second remedy is a limited wait instead of portMAX_DELAY. The deadlock does not disappear, but it turns into a timeout failure that you can notice and handle.


30.7 The recursive mutex

An ordinary mutex cannot be taken twice by the same task: the second Take blocks forever — the task is waiting for itself. This happens unnoticed: a function holding the mutex calls another function that also takes this mutex.

void outer(void)
{
    xSemaphoreTakeRecursive(m, portMAX_DELAY);
    inner();                            /* внутри — тот же мьютекс */
    xSemaphoreGiveRecursive(m);         /* освобождён только теперь */
}

A recursive mutex counts nesting: as many times as it was taken, that many times it must be given, and only then is it free. It has its own functions — TakeRecursive and GiveRecursive; the ordinary ones do not work with it.

Do not use it by default: it hides the structure of the program. If functions take the same mutex from each other, that is a reason to work out who is responsible for the resource, not a reason to turn on recursion.


30.8 🔧 The gatekeeper task

An alternative to the mutex that removes a whole class of problems.

The idea: one task owns the resource, alone and always. The others do not touch it at all — they put requests into this task's queue.

static QueueHandle_t q_log;

static void gatekeeper_task(void *arg)
{
    log_msg_t m;

    for (;;) {
        xQueueReceive(q_log, &m, portMAX_DELAY);
        uart_write(m.text, m.len);      /* единственное место, где трогают UART */
    }
}

/* любая задача, любое прерывание */
xQueueSend(q_log, &msg, 0);
xQueueSendFromISR(q_log, &msg, &woken);

What you get:

Mutex Gatekeeper
deadlock possible impossible — there is no mutex
priority inversion limited by inheritance none: no one owns the resource
from an interrupt not allowed allowed — it is just a queue
request delay wait until it is released put it in and move on
cost 80 bytes a task plus a queue

The sender does not wait for the resource — it waits for a slot in the queue, and usually does not wait at all. The order of requests is preserved. The gatekeeper's priority sets how urgently requests are served, and that is one decision instead of analysing every locking path.

The cost is a task with a stack and a queue: for a log of eight messages of 32 bytes this is 336 bytes of queue plus the task. For a UART, a display, a log on a card it is the typical and usually the best solution. The mutex is left for resources where you need an answer, not just a request.


30.9 What to choose

Situation Tool
an increment, a pair of fields, a flag critical section
a single counter or flags in a word, Cortex-M3/M4 atomic operation (30.2)
a long data recalculation, interrupts not involved scheduler suspension
a resource with long access, an answer is needed mutex, held briefly
a "write and forget" resource: log, display, UART gatekeeper
a signal from an interrupt to a task binary semaphore or notification
one variable, one word, one writer nothing: a word write is atomic

The last row matters: writing an aligned 32-bit word on Cortex‑M is atomic. If one task writes a variable and others read it, and it fits in a word, no protection is needed. A word in a packed structure is not written this way, and a reader that waits for a change in a loop declares the variable volatile — otherwise the compiler reads it once. A race begins where the read and the write are separated, or where the data is larger than a word.


30.10 Pitfalls

A counter counts less than it should. A race on the increment. A section or an atomic operation (30.2).

The device is alive but does nothing. A deadlock on two mutexes. Check the locking order in all tasks.

A task hung on xSemaphoreTake of its own mutex. It took it twice. Sort out the calls, or use a recursive mutex.

A high-priority task slows down for no reason. Inversion: it waits for a mutex held by a preempted low-priority task. Shorten the ownership; make sure it is a mutex, not a semaphore.

configASSERT in vPortEnterCritical. An ordinary section was called from an interrupt. You need the FROM_ISR pair.

xSemaphoreGive returns failure. The mutex was not taken. A foreign Give of an ordinary mutex does not fail — only configASSERT catches it; only xSemaphoreGiveRecursive returns failure to another task.

UART reception loses bytes while working with the display. The critical section is too long. Replace it with a mutex or a gatekeeper.

A mutex from an interrupt. Not allowed in any form. A semaphore, a queue, a notification.


30.11 Summary

Next: section 31 "Events and notifications" — how to pass a fact without data, wait for several conditions at once and do without a semaphore entirely.

Open in the interactive handbook