β±οΈ FreeRTOSο
FreeRTOS is a lightweight, open-source real-time operating system kernel for microcontrollers and small processors. It provides task scheduling, synchronisation primitives, and inter-task communication while remaining small enough to run on devices with kilobytes of RAM.
Task Statesο
FreeRTOS tracks four task states: Running, Ready, Blocked, and Suspended. The scheduler only considers tasks in the Ready state when deciding what to run next.
Runningο
The task currently executing on the CPU. Only one task can be in this state at a time.
If an interrupt fires, the interrupted task remains in the Running state from the schedulerβs perspective. The CPU saves the taskβs context, executes the ISR, then restores the context. FreeRTOS has no βinterruptedβ state.
The only exception is if the ISR unblocks a higher-priority task and calls
portYIELD_FROM_ISR(), which causes a context switch and moves the interrupted
task to the Ready state.
Readyο
Tasks that are able to run but are waiting for the CPU. FreeRTOS maintains one ready list per priority level. The scheduler picks the highest-priority non-empty ready list and runs the task at its head.
Blockedο
A task waiting for a specific condition to be met. It unblocks automatically when that condition is satisfied β no manual intervention required.
A task blocks itself by calling a FreeRTOS API function:
vTaskDelay(pdMS_TO_TICKS(1000)); // wait for time delay
xQueueReceive(xQueue, &data, portMAX_DELAY); // wait for queue data
xSemaphoreTake(xSemaphore, portMAX_DELAY); // wait for semaphore
The second argument to most blocking calls is a timeout. portMAX_DELAY means
wait indefinitely β but only truly infinite when INCLUDE_vTaskSuspend is set
to 1 in FreeRTOSConfig.h. Without it, portMAX_DELAY resolves to the
largest possible tick count, which is a very long but finite timeout.
Unblocking happens in two ways:
Time delay expired β the tick interrupt fires on every tick and checks the delay list. When a taskβs timeout has expired it is moved back to the ready list automatically.
Resource became available β when another task or ISR calls
xSemaphoreGive(),xQueueSend(), etc., FreeRTOS immediately checks the resourceβs waiting list and moves the waiting task to the ready list. This happens synchronously inside the give/send call itself, not on the next tick.
A blocked task consumes zero CPU time while waiting.
Suspendedο
A task that has been indefinitely paused with no condition to wake it up. It remains suspended until explicitly resumed by another task or ISR.
vTaskSuspend(NULL); // suspend yourself
vTaskSuspend(xTaskHandle); // suspend another task
vTaskResume(xTaskHandle); // resume from a task
xTaskResumeFromISR(xTaskHandle); // resume from an ISR
Any task can suspend any other task regardless of priority β there is no priority checking. This makes suspend a blunt instrument that must be used carefully.
Internal Data Structuresο
FreeRTOS uses separate lists to track tasks in each state:
Ready Lists (one per priority level)
βββ priority 5: [taskA]
βββ priority 3: [taskB β taskC]
βββ priority 1: [taskD]
Delay Lists (time-blocked tasks, sorted by expiry time)
βββ pxDelayedTaskList: [taskE(t=150) β taskF(t=300)]
βββ pxOverflowDelayedTaskList: [taskG]
Per-Resource Lists (embedded inside each queue/semaphore/mutex)
βββ Queue1.waitingToReceive: [taskH]
βββ Semaphore1.waiting: [taskI]
Suspended List
βββ [taskJ, taskK]
Every task is on exactly one of these lists at any given moment. The scheduler only ever looks at the ready lists.
Synchronisation Primitivesο
Queuesο
A queue is a thread-safe FIFO buffer for passing data between tasks.
Created with a fixed capacity and fixed item size
Sender blocks if full; receiver blocks if empty (with configurable timeout)
Safe to use from ISRs via
xQueueSendFromISR/xQueueReceiveFromISR
QueueHandle_t xQueue = xQueueCreate(10, sizeof(int));
xQueueSend(xQueue, &value, portMAX_DELAY);
xQueueReceive(xQueue, &received, portMAX_DELAY);
Multiple tasks on one queue: Only one task unblocks per item received β the highest priority waiter, then FIFO among equals. Give each consumer its own queue if they need different data.
Broadcast to all tasks: Use an Event Group instead.
Semaphoresο
A semaphore carries no data β it is purely a signalling mechanism.
Binary semaphoreο
Acts as a flag (0 or 1). Ideal for ISR-to-task signalling, where one entity gives and a different entity takes.
SemaphoreHandle_t xSem = xSemaphoreCreateBinary();
xSemaphoreGiveFromISR(xSem, &xHigherPriorityTaskWoken); // from ISR
xSemaphoreTake(xSem, portMAX_DELAY); // in task
Counting semaphoreο
Tracks N available resources (e.g. 3 DMA buffers). Take to claim one, Give to release it.
SemaphoreHandle_t xSem = xSemaphoreCreateCounting(3, 3);
Mutexesο
A mutex is a binary semaphore with ownership: the task that takes it must be the one to give it back. Use for protecting shared resources, not for signalling.
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();
xSemaphoreTake(xMutex, portMAX_DELAY);
// access shared resource
xSemaphoreGive(xMutex); // same task gives it back
Priority Inheritanceο
FreeRTOS mutexes implement priority inheritance to prevent priority inversion.
The problem (priority inversion):
Low-priority task takes the mutex
High-priority task blocks waiting for the mutex
Medium-priority task pre-empts the low-priority task
High-priority task is now starved by a medium-priority task
The fix (priority inheritance):
When the high-priority task blocks, FreeRTOS temporarily boosts the low-priority taskβs priority to match, preventing the medium-priority task from pre-empting it. Once the mutex is released the priority is restored.
Binary semaphores do not have priority inheritance β use a mutex whenever protecting a shared resource.
Event Groupsο
An event group is a set of binary flags (bits) that tasks can set, clear, and wait on. Each bit represents a condition. Tasks block until their condition is satisfied, then the scheduler moves them to the ready state.
xEventGroupCreate()β create a group, returns a handlexEventGroupSetBits()β set one or more bitsxEventGroupClearBits()β clear one or more bitsxEventGroupWaitBits()β block until bits are satisfiedxEventGroupSync()β barrier synchronisation (see below)
xEventGroupWaitBits() supports two modes:
AND β task unblocks only when all specified bits are set
OR β task unblocks when any specified bit is set
When a task calls xEventGroupWaitBits() and the condition is not met:
The task is removed from the ready list and placed on the event groupβs waiting list.
When
xEventGroupSetBits()is called, FreeRTOS walks the waiting list once, evaluating each waiter against the new bit state in priority order.Satisfied tasks are moved to the ready list. The scheduler then runs.
Clear-on-exitο
If xClearOnExit is set, the bits are cleared inline during the list walk,
before any unblocked task actually runs:
The bit is cleared before the unblocked task executes a single instruction.
Lower priority tasks waiting on the same bit are evaluated against the already-cleared state and remain blocked.
No task ever needs to manually clear the bit.
If the bit is set again before the unblocked task runs, the task does not re-wait β
its xEventGroupWaitBits() call has already returned. The task has no visibility of
the second set event. For this reason, event groups represent current state, not
occurrences.
Event groups vs queuesο
Use a queue when the value matters and every occurrence must be processed (e.g. passing a sensor reading to a logger β each value must be recorded).
Use an event group when you only need to know the current state, and missed intermediate transitions are acceptable (e.g. is WiFi connected right now?).
Event groups vs semaphoresο
Binary semaphore β one task signals one other task. Simple ping, no conditions.
Counting semaphore β tracks how many times something has occurred.
Event group β wait for combinations of conditions simultaneously (AND/OR logic).
If you find yourself taking multiple semaphores in sequence to combine conditions, switch to an event group.
Barrier synchronisationο
xEventGroupSync() is used when a group of tasks must all reach a point before any
of them continue. Each task sets its own bit and waits for all other tasksβ bits:
xEventGroupSync(
barrierGroup,
BIT_MY_TASK, /* bit this task sets */
BIT_TASK_A | BIT_TASK_B | BIT_TASK_C, /* bits to wait for */
portMAX_DELAY
);
/* all tasks proceed from here together */
When the last task calls xEventGroupSync(), FreeRTOS clears all barrier bits
atomically as part of unblocking everyone β no manual clearing required, no race
conditions.
Task Notificationsο
A task notification is a direct signal sent to a specific task. Every task has two fields baked into its TCB:
A 32-bit notification value β an integer the sender can set, OR, or increment.
A notification state β either pending (notification waiting to be taken) or not-pending.
Because these fields live inside the TCB there is no kernel object to create and no additional RAM beyond the TCB itself. Task notifications are roughly 45% faster and use significantly less memory than an equivalent binary semaphore.
Signalling from an ISRο
Two ISR-safe functions cover the common cases:
``vTaskNotifyGiveFromISR()`` β the lightweight option. Increments the notification
value and sets the state to pending. The receiving task calls ulTaskNotifyTake()
to block until notified.
/* In the ISR */
void UART_IRQHandler(void) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
vTaskNotifyGiveFromISR(xUartTaskHandle, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
/* In the task */
void vUartTask(void *pvParameters) {
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY); /* block until notified */
processUartData();
}
}
pdTRUE as the first argument clears the notification value to zero on exit
(binary semaphore behaviour). pdFALSE decrements it by one instead (counting
semaphore behaviour β useful when the ISR fires multiple times before the task runs).
``xTaskNotifyFromISR()`` β the general-purpose version. Takes an eNotifyAction
parameter that controls what happens to the notification value:
Action |
Effect on the 32-bit value |
|---|---|
|
Sets state to pending; value unchanged. |
|
ORs the value with |
|
Increments value ( |
|
Sets value unconditionally. |
|
Sets value only if state is not-pending.
Returns |
/* Signal which DMA channel completed β pack status bits into the value */
xTaskNotifyFromISR(
xDmaTaskHandle,
DMA_CHANNEL_2_DONE, /* ulValue */
eSetBits, /* OR into existing value */
&xHigherPriorityTaskWoken
);
Receiving Notificationsο
``ulTaskNotifyTake()`` β pairs with vTaskNotifyGiveFromISR/eIncrement.
Blocks until the value is non-zero, then either clears it (pdTRUE) or decrements
it (pdFALSE). Returns the value before the clear/decrement.
``xTaskNotifyWait()`` β the general receiver. Accepts masks for clearing bits on entry (flushes stale flags) and on exit (acknowledgement), and writes the current notification value to an output pointer.
uint32_t ulNotifiedValue;
xTaskNotifyWait(
0x00, /* clear no bits on entry */
ULONG_MAX, /* clear all bits on exit */
&ulNotifiedValue, /* notification value is written here */
portMAX_DELAY
);
if (ulNotifiedValue & DMA_CHANNEL_2_DONE) { /* handle channel 2 */ }
if (ulNotifiedValue & DMA_CHANNEL_3_DONE) { /* handle channel 3 */ }
Limitationsο
Each task has one notification slot. A second notification arriving while one is already pending either overwrites it or is dropped, depending on the action.
The sender must hold the task handle β notifications are point-to-point, not broadcast. For one-to-many signalling use an event group.
ISRs above
configMAX_SYSCALL_INTERRUPT_PRIORITYcannot call any FreeRTOS API, includingvTaskNotifyGiveFromISR.
When to use task notifications vs semaphoresο
Task notification β ISR-to-task or task-to-task, one sender, one receiver, lowest overhead. The first choice for simple interrupt deferred processing.
Binary semaphore β multiple potential senders or multiple potential receivers; when the sender does not know which task should unblock.
Counting semaphore β same as binary but the count matters (e.g. N items available).
Stream Buffers and Message Buffersο
Stream buffers and message buffers are lightweight, lock-free primitives for passing data between exactly one writer and one reader. Because they are lock-free by design they are safe to use directly from ISRs without any additional protection.
Stream Buffersο
A stream buffer is a continuous byte-stream FIFO with no message framing. Data is written and read in arbitrary chunk sizes β the receiver reassembles meaning from the raw stream.
Key properties:
Single-reader / single-writer only (enforced by the lock-free design)
ISR-safe via
xStreamBufferSendFromISR()No message boundaries
Trigger level controls when the receiver task unblocks
Trigger Levelο
The trigger level is set at creation time (or via
xStreamBufferSetTriggerLevel()), not per receive call. The receiver task
blocks until at least this many bytes are available β it is a property of the
buffer, not of individual calls.
/* 256-byte buffer, unblock receiver when >= 16 bytes are ready */
xSB = xStreamBufferCreate(256, 16);
/* unblocks at trigger level, reads up to 64 bytes */
received = xStreamBufferReceive(xSB, rxBuf, 64, portMAX_DELAY);
The receive call can return fewer bytes than requested β always check the return value.
Sending from an ISRο
xStreamBufferSendFromISR() never blocks. If the buffer is full it writes as
many bytes as it can and returns immediately; the return value indicates how
many bytes were actually written, which may be zero.
void UART_IRQHandler(void) {
uint8_t byte = UART->DR;
xStreamBufferSendFromISR(xSB, &byte, 1, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
Blocking is forbidden in ISRs because the scheduler cannot context-switch inside
one. All FromISR FreeRTOS functions follow this contract.
Message Buffersο
A message buffer is built on top of a stream buffer. It prepends a 4-byte
length header to each write, preserving message boundaries so that each
xMessageBufferReceive() call dequeues exactly one complete message.
Underlying storage:
[ 0x05 0x00 0x00 0x00 | H e l l o | 0x03 0x00 0x00 0x00 | A C K ]
|ββ 4B length ββββ>| |ββ msg β>| |ββ 4B length ββββ>| |<msg>|
Key properties:
Each
xMessageBufferReceive()returns exactly one messageReceiver unblocks as soon as one complete message is available
Receive buffer must be large enough for the largest possible message
4-byte overhead per message
If several messages have accumulated while a task was busy processing, subsequent receive calls return immediately as long as the buffer is non-empty. A common drain pattern:
while (1) {
/* block until at least one message arrives */
received = xMessageBufferReceive(xMB, rxBuf, sizeof(rxBuf), portMAX_DELAY);
processMessage(rxBuf, received);
/* drain any further messages without blocking */
while ((received = xMessageBufferReceive(xMB, rxBuf, sizeof(rxBuf), 0)) > 0) {
processMessage(rxBuf, received);
}
}
A Typical Real-World Architectureο
A common pattern for UART-based protocols layers both primitives:
UART hardware (1 byte at a time)
β
[ISR] xStreamBufferSendFromISR()
β
Stream buffer β raw bytes, no framing
β
[Parser task] β accumulates bytes, hunts for delimiters,
β validates checksums, assembles complete frames
xMessageBufferSend()
β
Message buffer β clean, validated, complete frames
β
[Application task] xMessageBufferReceive()
β one call = one complete command, no parsing needed
The parser task acts as the boundary between the raw-bytes world and the structured-messages world, shielding application logic from hardware-level concerns and making each layer independently testable.
When to use eachο
Use a stream buffer when data comes from hardware and message boundaries are your problem to figure out.
Use a message buffer when data comes from another task and message boundaries are already known.
Software Timersο
Software timers execute a callback function at a future point in time, or periodically, without consuming a hardware timer peripheral. They are managed entirely by the FreeRTOS daemon task (also called the Timer Service task).
Two modes:
One-shot β fires callback once, then goes dormant.
Auto-reload β automatically restarts after each expiry, firing periodically.
Architectureο
Timer API calls (xTimerStart, xTimerStop, etc.) never act directly on
a timer. Instead they post a command to the timer command queue. The daemon
task wakes up, reads the command, and acts on it.
App task β [command queue] β Daemon task β callback()
Key implications:
API calls are non-blocking (return immediately).
Callbacks run inside the daemon task context β never in the calling task.
Callbacks must not block or call blocking APIs.
All callbacks run sequentially β a slow callback delays all others.
How Timers Are Decrementedο
FreeRTOS does not count down timers in a loop. The mechanism is tick-based:
A hardware timer (e.g. SysTick on ARM Cortex-M) fires a periodic ISR.
The ISR increments the global
xTickCountcounter.If SW timers are enabled (
configUSE_TIMERS = 1), the ISR peeks at the head of a sorted expiry list.If the head timer has expired, the ISR unblocks the daemon task via the command queue. The ISR then returns immediately β O(1) cost.
The daemon task wakes up and walks the list, firing all expired callbacks and reloading any auto-reload timers.
Timers are stored by absolute expiry tick (xTickCount + period at
start time), not by a remaining countdown. The list is sorted ascending, so
the ISR only ever needs to check the head.
Multiple timers expiring on the same tickο
The ISR still only checks the head and unblocks the daemon once. The daemon then handles all expired timers in sequence. Callback order among same-tick timers is an implementation detail and must not be relied upon.
Accuracy and Jitterο
SW timers are not cycle-accurate. Two sources of jitter:
Tick granularity β resolution is one tick. At
configTICK_RATE_HZ = 1000this is 1 ms. Jitter is 0β1 tick depending on whenxTimerStartwas called within the current tick period.Daemon task scheduling delay β even after the ISR flags an expiry, the daemon must be scheduled before the callback runs. A higher-priority running task delays the callback by however long it holds the CPU.
When to use whatο
Requirement |
Approach |
|---|---|
Β±1β2 ms accuracy (timeouts, debounce, LED blink) |
SW timers β fine |
Sub-millisecond / hard real-time |
Hardware timer peripheral + ISR directly |
Periodic task at exact rate |
|
Key Configuration (FreeRTOSConfig.h)ο
#define configUSE_TIMERS 1
#define configTIMER_TASK_PRIORITY (configMAX_PRIORITIES - 1)
#define configTIMER_QUEUE_LENGTH 10
#define configTIMER_TASK_STACK_DEPTH 256
How an Interrupt Is Handledο
1. Hardware responds immediately
The CPU finishes its current instruction (not its current task β just the single instruction), saves its registers, and jumps to the Interrupt Service Routine (ISR). This happens in hardware, in a handful of CPU cycles. FreeRTOS has nothing to do with this part β itβs purely the CPU.
2. The ISR runs
The ISR should do as little as possible β read the hardware register, clear the interrupt flag, and signal a task to do the real work. In FreeRTOS you use the FromISR API variants here:
void UART_IRQHandler(void) {
char received = UART->DATA;
xQueueSendFromISR(xQueue, &received, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
3. portYIELD_FROM_ISR() triggers a context switch
If xHigherPriorityTaskWoken was set to pdTRUE by the queue send (meaning a higher-priority task is now ready), this macro tells the scheduler to switch to that task the moment the ISR exits β not at the next tick, immediately.
On ARM Cortex-M, the macro does this by writing to the ICSR (Interrupt Control and State Register) to pend the PendSV exception:
/* What portYIELD_FROM_ISR expands to on Cortex-M */
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
PendSV is intentionally configured at the lowest possible interrupt priority. This means it wonβt fire immediately β it waits until all higher-priority ISRs have finished. Once your UART ISR returns, the CPU sees the pending PendSV and jumps straight to it (using Cortex-M tail-chaining, so no return to the interrupted task happens in between).
The PendSV handler is the FreeRTOS context switcher. It:
Saves the remaining registers of the currently interrupted task onto that taskβs stack
Calls the scheduler to determine the new highest-priority ready task
Restores the registers of the new task from its stack
Returns β which resumes the new task, not the originally interrupted task
The interrupted task is not lost β its full CPU state is saved on its own stack and it remains in the ready list. It will resume normally when it is next scheduled.
4. The high-priority task runs
The task that was waiting on the queue unblocks and runs right away, preempting whatever was running before the interrupt.
The pattern of deferring real work from the ISR to a task is called Interrupt Deferred Processing.
The Ready-List Bitmapο
FreeRTOS maintains one linked list of tasks per priority level. To find the next task to run it needs to find the highest priority that has at least one ready task.
Instead of scanning from the top priority downward (O(n)), FreeRTOS maintains a single integer where each bit represents one priority level:
Priority: 7 6 5 4 3 2 1 0
Bitmap: 0 0 1 0 1 1 0 0
^ ^
| Tasks ready at priority 2 and 3
Task ready at priority 5 β this one wins
To find the highest ready priority, FreeRTOS uses the CLZ (Count Leading Zeros) CPU instruction β a single hardware instruction on ARM Cortex-M that returns the position of the highest set bit in one cycle. Finding the next task to run is literally one instruction, regardless of how many priorities or tasks exist.
Selecting the Task Within a Priorityο
Once the winning priority is known, FreeRTOS indexes into pxReadyTasksLists[] β an array of circular linked lists, one per priority. Each list has a pxIndex pointer that remembers where it left off:
pxReadyTasksLists[5]: TaskA β TaskB β TaskC β (back to TaskA)
^
pxIndex β this task runs next
The scheduler advances pxIndex to the next entry and reads the TCB (Task Control Block) pointer stored there. That TCB becomes pxCurrentTCB β the running task. On the next scheduler invocation at the same priority, pxIndex advances again, giving each task at that priority equal time β this is the round-robin within a priority level.
If a task is blocked, suspended, or deleted it is removed from the ready list entirely (and its priorityβs bit is cleared in the bitmap if the list becomes empty), so pxIndex only ever lands on tasks that are actually runnable.
The Tick Interruptο
FreeRTOS uses a hardware timer configured to fire at a fixed rate β configTICK_RATE_HZ in FreeRTOSConfig.h. Commonly 1000 Hz (every 1 ms). On ARM Cortex-M this hooks into the SysTick peripheral, a dedicated timer built into every Cortex-M core.
Every time the tick fires, the ISR does two things:
Increments the tick count β this is how FreeRTOS tracks time.
vTaskDelay(100)means βunblock me after 100 ticksβ.Calls the scheduler β checks if any blocked tasks have expired their delay, unblocks them, and decides if a context switch is needed.
Every 1 ms (at 1000 Hz):
Tick ISR fires
β increment xTickCount
β check delayed task list β unblock any that have expired
β if higher priority task is now ready β context switch
β resume highest priority ready task
The tick drives time-based scheduling, but context switches also happen immediately in response to events:
A task calls
vTaskDelay()β yields immediately, doesnβt wait for the next tickAn ISR sends to a queue and calls
portYIELD_FROM_ISR()β switch happens at ISR exitA task blocks on a semaphore that isnβt available β switch happens immediately
Tickless Idle Modeο
FreeRTOS has a tickless idle mode (configUSE_TICKLESS_IDLE) for low-power devices. When no tasks need to run for a known period, the scheduler suppresses the tick interrupt entirely, lets the CPU sleep deeply, and then wakes on a real event or when the next task is due β correcting the tick count for the time that passed. This is how battery-powered devices achieve sleep currents in the microamp range.
SysTick and PendSV: Division of Responsibilityο
The two interrupts have distinct roles:
SysTick runs on every tick and owns timekeeping and readiness decisions:
Increments
xTickCountWalks the delayed-task list β any task whose timeout has expired is moved from the delayed list back to the ready list
Checks whether the newly-readied tasks (or round-robin within the same priority) require a context switch
If a switch is needed, SysTick doesnβt do it directly. It pends PendSV β a cheap register write β and returns.
PendSV is configured at the lowest interrupt priority so it fires only after all other pending ISRs have exited. It owns the actual context switch:
Saves the remaining CPU registers of the current task onto its stack (hardware already saved a subset on interrupt entry)
Updates the current-task pointer to the new highest-priority ready task
Restores the new taskβs registers from its stack
Returns β resuming the new task
This split keeps SysTick fast (timekeeping only) and defers the expensive register save/restore to PendSV, which runs at the safest possible moment.
Pausing the Schedulerο
FreeRTOS provides two distinct mechanisms depending on what you need to protect against.
Suspend the Schedulerο
vTaskSuspendAll();
/* multi-step time-critical work */
xTaskResumeAll();
This stops task switching but β critically β interrupts still fire normally. The tick ISR still runs, time is still tracked, but no context switch will happen mid-way through your code. This is the lighter-weight option, preferred when your concern is another task preempting you rather than an ISR corrupting shared data.
During vTaskSuspendAll(), the tick ISR will still:
Increment the tick count
Walk the delayed task list and unblock any tasks whose delay has expired
Track that a context switch is pending (via the
xYieldPendingflag)
It will not actually perform the context switch. When xTaskResumeAll() is called, it checks xYieldPending and if a higher-priority task became ready while the scheduler was suspended, it performs the context switch at that point. Time still advances correctly and no delayed task misses its wakeup.
Disable Interrupts (Critical Section)ο
taskENTER_CRITICAL();
/* very short time-critical work */
taskEXIT_CRITICAL();
This disables interrupts up to a configurable priority threshold (configMAX_SYSCALL_INTERRUPT_PRIORITY). Nothing can preempt you β no task switch, no ISR. The tradeoff is that your interrupt latency directly suffers for the duration, so this must be kept very short β think microseconds, not milliseconds.
Since the scheduler is driven by the tick interrupt, disabling interrupts also stops the scheduler for the duration: no tick fires, no scheduler runs, no context switch.
Both mechanisms are nestable β you can call them multiple times and they only actually release when the nesting count returns to zero.
The Cortex-M Interrupt Priority Detailο
On ARM Cortex-M, FreeRTOS uses the BASEPRI register to mask only interrupts at or below configMAX_SYSCALL_INTERRUPT_PRIORITY β not all interrupts. Interrupts above that threshold still fire unmasked:
Priority 0 β never masked (hard fault, NMI)
Priority 1 β never masked
Priority 2 β never masked
----- configMAX_SYSCALL_INTERRUPT_PRIORITY -----
Priority 3 β masked during critical section
Priority 4 β masked (tick interrupt lives here)
Priority 5 β masked
This lets you have extremely high priority interrupts that can never be blocked by anything FreeRTOS does. The tradeoff: those ISRs cannot call any FreeRTOS API at all β not even the FromISR variants.
Memory Allocationο
FreeRTOS allocates task stacks and kernel objects (queues, semaphores, mutexes, etc.) from the
heap by default. Objects can also be allocated statically using the ...Static API variants
(e.g. xTaskCreateStatic()), passing pre-allocated buffers at creation time β FreeRTOS never
calls the heap allocator in this case.
Heap Management Schemesο
FreeRTOS ships five heap implementations in Source/portable/MemMang/. Choose one based on
your allocation pattern:
Scheme |
Behaviour |
|---|---|
heap_1 |
Allocate-only, no free. Fully deterministic. Suited to safety-critical systems where fragmentation is unacceptable. |
heap_2 |
Adds |
heap_3 |
Wraps the compilerβs |
heap_4 |
Best-fit allocator with free-block coalescing. The most common choice for general embedded use. |
heap_5 |
Extends heap_4 across multiple non-contiguous memory regions (e.g. internal SRAM + external SDRAM). |
Stack Size Analysisο
Static stack analysis determines the maximum stack usage of a program without running it.
In general, this is not always possible β VLAs, alloca(), function pointers, and indirect
recursion all defeat static analysis. Plain C with none of those constructs is fully analysable.
The process has two steps:
Per-function frame sizes β GCCβs
-fstack-usageflag emits.sufiles at compile time, labelling each functionβs frame asstatic,dynamic, ordynamic,bounded.Call graph analysis β a separate tool walks the call graph and sums worst-case depth. Open-source options include
cflowandegypt; commercial tools such as AbsInt StackAnalyzer and IAR Embedded Workbench provide certified analysis for safety-critical work (DO-178C, ISO 26262).
FreeRTOS requires the developer to specify each taskβs stack size manually in xTaskCreate().
It does not perform static analysis. The practical workflow is to monitor stack and heap headroom
at runtime, and to enable overflow detection during development.
Runtime Memory Checksο
Two APIs expose memory headroom at runtime:
uxTaskGetStackHighWaterMark(task)β returns the minimum free stack space (in words) recorded since the task started. A value close to zero means the stack is nearly exhausted and the allocation inxTaskCreate()should be increased.xPortGetFreeHeapSize()/xPortGetMinimumEverFreeHeapSize()β return the current and historically lowest free heap bytes, useful for confirming the heap is not dangerously tight.
Stack Overflow Detectionο
configCHECK_FOR_STACK_OVERFLOW enables checking on every context switch:
Mode 1 β checks that the taskβs stack pointer is within its allocated region at the moment of the switch. Cheap, but only catches overflows still present at context switch time.
Mode 2 (stack canary) β fills the last few words of each stack with a known pattern at task creation and verifies the pattern on every context switch. If it has been overwritten,
vApplicationStackOverflowHook()is called. More reliable than mode 1 as it catches overflows that occurred and partially recovered between switches.
Hooksο
FreeRTOS provides hook (callback) functions that the application can implement to respond to specific kernel events. The kernel calls these automatically when the corresponding event occurs, giving the application control over error handling and idle-time behaviour.
Common hook functions:
``vApplicationIdleHook()`` β called on every iteration of the idle task. Useful for putting the CPU into a low-power sleep state when no tasks are ready. Must never block or call a blocking API.
``vApplicationTickHook()`` β called from the tick ISR on every tick. Must be kept very short and must not call any FreeRTOS API that is unsafe from an ISR.
``vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)`` β called when stack overflow detection triggers. Typically used to log the offending task name and halt. Requires
configCHECK_FOR_STACK_OVERFLOWto be enabled.``vApplicationMallocFailedHook()`` β called when
pvPortMalloc()fails. Useful for logging heap exhaustion before the system crashes.
Each hook is enabled by a corresponding define in FreeRTOSConfig.h:
#define configUSE_IDLE_HOOK 1
#define configUSE_TICK_HOOK 1
#define configCHECK_FOR_STACK_OVERFLOW 2 /* mode 2 enables stack overflow hook */
#define configUSE_MALLOC_FAILED_HOOK 1