The interrupt skills taught the rules for handlers: short, never block, acknowledge the source, share data carefully. With an RTOS there is a better place for everything that isn't urgent — a task — and a kernel that can wake it the moment the interrupt finishes. The pattern is called deferred interrupt processing: the ISR does what must happen now (acknowledge, grab the data before it's overwritten) and hands the rest to a task that runs under the scheduler, can block, can take as long as it needs, and is preempted by more important work.
portYIELD_FROM_ISR makes the exception return land in the handler, which preempts the busy lower-priority task, processes, and blocks again.Everything here runs on the emulated MPS2 board under FreeRTOS V11. Time
is emulated: qemu runs with -icount (one instruction = 1 ns), the tick
is 1 ms, and every tick count and microsecond figure below is exactly
reproducible — good for reasoning and for tests. It is not a hardware
measurement: there are no caches, flash wait states or real interrupt-entry
latencies, so these numbers do not establish physical deadlines, and none
of this is hardware-in-the-loop testing.
1. The split
The interrupt source in this skill is the CMSDK timer you met in the
interrupt skills: mps2::timer0(), IRQ 8, handler TIMER0_IRQHandler,
counting at 25 MHz (25 counts per µs). Here the ISR plays a sensor driver:
it reads a value and wakes the task that processes it:
#include "FreeRTOS.h"
#include "task.h"
#include <crucible/mcu/mps2.hpp>
#include <cstdio>
#include <cstdlib>
static TaskHandle_t worker;
static volatile std::uint32_t reading;
extern "C" void TIMER0_IRQHandler() {
mps2::timer0().intstatus = 1; // acknowledge the timer
reading = reading + 7; // "read the sensor": the urgent part
BaseType_t woken = pdFALSE;
vTaskNotifyGiveFromISR(worker, &woken); // wake the task …
portYIELD_FROM_ISR(woken); // … and run it as soon as we return
}
static void process(void*) {
for (int i = 0; i < 4; ++i) {
std::uint32_t n = ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100));
std::printf("tick %lu: woken for %lu interrupt(s), reading %lu\n",
(unsigned long)xTaskGetTickCount(), (unsigned long)n, (unsigned long)reading);
}
mps2::timer0().ctrl = 0;
mps2::nvic_disable(mps2::irq::timer0);
std::exit(0);
}
int main() {
xTaskCreate(process, "process", 512, nullptr, 3, &worker);
auto& t = mps2::timer0();
t.reload = 25000 * 5 / 2 - 1; // every 2.5 ms (25 counts per µs)
t.value = t.reload;
t.ctrl = 1 | 8; // enable, interrupt enable
mps2::nvic_set_priority(mps2::irq::timer0, 6);
mps2::nvic_enable(mps2::irq::timer0);
vTaskStartScheduler(); // never returns
}Three FreeRTOS-specific lines make this work, and each gets a section below:
mps2::nvic_set_priority(…, 6)— an ISR that calls FreeRTOS must sit below a line in the NVIC priorities (§2);vTaskNotifyGiveFromISR— only the…FromISRAPI may be called from an interrupt (§3), and a task notification is the lightest way to wake a task (§5);portYIELD_FROM_ISR(woken)— without it the woken task waits for the next tick (§4).
The task blocks in ulTaskNotifyTake, using no CPU at all between
interrupts: in this program the CPU spends almost all of each 2.5 ms in the
idle task.
2. The line in the NVIC
The FreeRTOS Cortex-M port does not disable interrupts with PRIMASK for
its own critical sections. It raises BASEPRI to
configMAX_SYSCALL_INTERRUPT_PRIORITY, which masks only the interrupts at
that priority and below (numerically at or above it). In this
configuration (FreeRTOSConfig.h) there are 3 priority bits and the
limit is priority 5:
…FromISR functions. Interrupts 0–4 are never masked by FreeRTOS — lowest latency — and must never call it. Every priority resets to 0.Why the rule exists: an interrupt above the line can arrive while the kernel is half-way through moving a task between its lists. If that ISR called the kernel, it would see — and modify — a broken list. The kernel can't protect itself against interrupts it doesn't mask, so they must leave it alone. The benefit is that those interrupts are never delayed by FreeRTOS: put the ones with hard microsecond deadlines (motor commutation, a fast ADC) above the line, and have them hand off through plain shared memory or to a lower-priority ISR.
The port checks the rule on every …FromISR call. With
configASSERT defined, vPortValidateInterruptPriority reads the active
interrupt's priority and asserts:
// FreeRTOS-Kernel/portable/GCC/ARM_CM4F/port.c (V11.3), abridged
void vPortValidateInterruptPriority( void )
{
__asm volatile ( "mrs %0, ipsr" : "=r" ( ulCurrentInterrupt )::"memory" );
if( ulCurrentInterrupt >= portFIRST_USER_INTERRUPT_NUMBER )
{
ucCurrentPriority = pcInterruptPriorityRegisters[ ulCurrentInterrupt ];
/* ... Interrupts that use the FreeRTOS API must not be left at their
* default priority of zero as that is the highest possible priority ... */
configASSERT( ucCurrentPriority >= ucMaxSysCallPriority );
}
...
}Forget nvic_set_priority (every IRQ resets to 0) and the first interrupt
ends the program with:
crucible: FreeRTOS configASSERT failed at FreeRTOS-Kernel/portable/GCC/ARM_CM4F/port.c:888Two details that bite: the NVIC stores priorities in the top bits of each
byte (mps2::nvic_set_priority shifts for you: priority 6 is the byte
0xC0; FreeRTOS's configMAX_SYSCALL_INTERRUPT_PRIORITY is the shifted
value 0xA0, configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY the plain 5);
and vendor HALs may set priorities in their own init code — check the
values in the NVIC, not in your intentions.
3. What an ISR may call
FreeRTOS keeps two APIs. The task-level one may block, may switch tasks
directly, and protects itself with taskENTER_CRITICAL(); the
interrupt-safe one never blocks, reports "a higher-priority task is now
ready" through a BaseType_t* instead of switching, and uses the
ISR-flavoured masking:
| from an ISR (priority ≥ 5) | never from an ISR |
|---|---|
vTaskNotifyGiveFromISR, xTaskNotifyFromISR |
xTaskNotifyGive, xTaskNotify |
xQueueSendFromISR, xQueueReceiveFromISR, xQueueOverwriteFromISR (length-1 queues) |
xQueueSend(q, &v, 0) — even with a timeout of 0 |
xSemaphoreGiveFromISR |
xSemaphoreTake, any mutex operation (mutexes have owners: tasks) |
xStreamBufferSendFromISR, xMessageBufferSendFromISR |
vTaskDelay, anything with a timeout |
xTaskGetTickCountFromISR |
pvPortMalloc, new, malloc |
xTimerPendFunctionCallFromISR |
printf and other newlib stdio |
taskENTER_CRITICAL_FROM_ISR / taskEXIT_CRITICAL_FROM_ISR |
taskENTER_CRITICAL |
Calling a task-level function from an ISR is caught too — on this port
vPortEnterCritical asserts that the CPU isn't in an exception handler:
crucible: FreeRTOS configASSERT failed at FreeRTOS-Kernel/portable/GCC/ARM_CM4F/port.c:487The heap deserves a note: here (heap_3) malloc is made task-safe by
suspending the scheduler (crucible_rtos.c — read it), which keeps other
tasks out but not interrupts. Never allocate in an ISR; preallocate the
buffers it fills. And printf from an ISR is wrong twice: newlib's stdio
locks and per-task reentrancy aren't interrupt-safe, and semihosting output
takes far longer than any interrupt should.
4. portYIELD_FROM_ISR and the latency it saves
vTaskNotifyGiveFromISR(task, &woken) moves the task to the ready list and
sets woken = pdTRUE if that task outranks the one that was interrupted.
It doesn't switch: an interrupt handler can't be switched away from mid-way.
portYIELD_FROM_ISR(woken) — on this port, a write of PENDSVSET into the
ICSR — pends the PendSV exception. PendSV has the lowest priority, so
it runs after the timer ISR (and any other pending interrupts) return, and
its handler switches to the woken task. Without the yield, the ISR returns
into the task it interrupted and the switch waits for the next tick.
A stopwatch shows the difference: timer1 free-runs at 25 MHz, the ISR records it, the woken task reads it again.
#include "FreeRTOS.h"
#include "task.h"
#include <crucible/mcu/mps2.hpp>
#include <cstdio>
#include <cstdlib>
static TaskHandle_t worker;
static volatile bool yield_from_isr = true;
static volatile std::uint32_t at_irq; // the stopwatch when the ISR ran
extern "C" void TIMER0_IRQHandler() {
mps2::timer0().intstatus = 1;
at_irq = mps2::timer1().value;
BaseType_t woken = pdFALSE;
vTaskNotifyGiveFromISR(worker, &woken);
if (yield_from_isr) portYIELD_FROM_ISR(woken);
}
static void busy(void*) { for (;;) {} } // lower priority, never blocks
static void handler(void*) {
for (int phase = 0; phase < 2; ++phase) {
yield_from_isr = phase == 0;
std::printf("%s\n", phase == 0 ? "with portYIELD_FROM_ISR:" : "without:");
for (int i = 0; i < 4; ++i) {
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100));
std::uint32_t counts = at_irq - mps2::timer1().value; // 25 counts per µs
std::printf(" IRQ -> task %6lu.%lu us\n", (unsigned long)(counts / 25),
(unsigned long)(counts % 25 * 10 / 25));
}
}
std::exit(0);
}
int main() {
auto& sw = mps2::timer1(); // free-running stopwatch, counts down
sw.reload = 0xFFFFFFFF;
sw.value = 0xFFFFFFFF;
sw.ctrl = 1;
xTaskCreate(handler, "handler", 512, nullptr, 3, &worker);
xTaskCreate(busy, "busy", 256, nullptr, 1, nullptr);
auto& t = mps2::timer0();
t.reload = 25000 * 3 / 2 - 1; // every 1.5 ms: out of step with the 1 ms tick
t.value = t.reload;
t.ctrl = 1 | 8;
mps2::nvic_set_priority(mps2::irq::timer0, 6);
mps2::nvic_enable(mps2::irq::timer0);
vTaskStartScheduler();
}portYIELD_FROM_ISRRules of thumb:
- Every ISR that calls
…FromISRstarts withBaseType_t woken = pdFALSE;, passes&wokento every call (each may only set it, never clear it), and ends withportYIELD_FROM_ISR(woken). wokenis set only for a higher-priority task. An equal-priority handler waits for the running task's time slice to end — give deferred handlers a priority above the work they must preempt.- The kernel remembers a missed yield and switches at the next tick, so a
forgotten
portYIELD_FROM_ISRnever shows up as a functional failure in a quiet test — only as up to one tick of latency, which under load becomes overruns. That is exactly how the build task's hidden tests catch it.
5. Task notifications from an ISR
Each task has configTASK_NOTIFICATION_ARRAY_ENTRIES (2 here) notification
slots, each a 32-bit value plus a pending flag, stored in the task's own
TCB. No object to create, no handle to share, and the fastest wake-up path
in the kernel. The ISR side chooses what the value means:
| ISR call | value becomes | task side | use |
|---|---|---|---|
vTaskNotifyGiveFromISR(t, &w) |
value + 1 | ulTaskNotifyTake(pdTRUE, timeout) returns the count and clears it |
counting semaphore: "N events" |
xTaskNotifyFromISR(t, bits, eSetBits, &w) |
value | bits | xTaskNotifyWait(0, ~0u, &v, timeout) |
event flags from several sources |
xTaskNotifyFromISR(t, x, eSetValueWithOverwrite, &w) |
x | xTaskNotifyWait |
latest-value mailbox |
xTaskNotifyFromISR(t, x, eSetValueWithoutOverwrite, &w) |
x, unless one is pending (returns pdFAIL) | xTaskNotifyWait |
"don't lose the first" mailbox |
What a notification does not do is count the different things that happened. Two sources setting bits, a task that is busy for 2 ms between waits:
#include "FreeRTOS.h"
#include "task.h"
#include <climits>
#include <crucible/mcu/mps2.hpp>
#include <cstdio>
#include <cstdlib>
static TaskHandle_t events_task;
constexpr std::uint32_t EV_FAST = 1u << 0, EV_SLOW = 1u << 1;
extern "C" void TIMER0_IRQHandler() {
mps2::timer0().intstatus = 1;
BaseType_t woken = pdFALSE;
xTaskNotifyFromISR(events_task, EV_FAST, eSetBits, &woken);
portYIELD_FROM_ISR(woken);
}
extern "C" void TIMER1_IRQHandler() {
mps2::timer1().intstatus = 1;
BaseType_t woken = pdFALSE;
xTaskNotifyFromISR(events_task, EV_SLOW, eSetBits, &woken);
portYIELD_FROM_ISR(woken);
}
static void start_timer(mps2::Timer& t, unsigned irq, std::uint32_t counts) {
t.reload = counts - 1;
t.value = counts - 1;
t.ctrl = 1 | 8;
mps2::nvic_set_priority(irq, 6);
mps2::nvic_enable(irq);
}
static void events(void*) {
start_timer(mps2::timer0(), mps2::irq::timer0, 25000); // 1 ms
start_timer(mps2::timer1(), mps2::irq::timer1, 62500); // 2.5 ms
for (int i = 0; i < 4; ++i) {
std::uint32_t bits = 0;
// Clear nothing on entry, everything on exit; bits OR together while we're away.
xTaskNotifyWait(0, ULONG_MAX, &bits, pdMS_TO_TICKS(100));
std::printf("tick %lu:%s%s\n", (unsigned long)xTaskGetTickCount(),
bits & EV_FAST ? " fast" : "", bits & EV_SLOW ? " slow" : "");
TickType_t t0 = xTaskGetTickCount();
while (xTaskGetTickCount() - t0 < 2) {} // 2 ms of work: events pile up
}
mps2::timer0().ctrl = 0;
mps2::timer1().ctrl = 0;
std::exit(0);
}
int main() {
xTaskCreate(events, "events", 512, nullptr, 3, &events_task);
vTaskStartScheduler();
}"fast" fired twice during each 2 ms of work, and the task saw it once: bits OR together, they don't count. Choose by the question the task must answer — whether (bits), how many (a count), what is the latest (overwrite), or every value (a queue or a ring, §6).
The same distinction separates a binary semaphore from a count. An ISR
that calls xSemaphoreGiveFromISR on a binary semaphore five times while
its task is busy leaves one give to take: giving an already-given binary
semaphore changes nothing. That's fine when the task re-checks the hardware
(or drains a buffer) after every wake — it's lost information when the
semaphore is the event count. A notification given with
vTaskNotifyGiveFromISR, or a counting semaphore, keeps the count. (A
check item runs exactly this experiment.)
Two cautions. Notifications are one-to-one: an ISR can't wake "whichever
worker is free" with one (a queue can). And stream and message buffers use
the reader task's notification slot 0 internally to block it, so a task
that waits on a stream buffer shouldn't also use slot 0 for its own
signalling — use the …Indexed variants (ulTaskNotifyTakeIndexed(1, …))
for a second channel, as the build task does for its stop handshake.
6. Data, not just a wake-up
When the ISR produces values, they need a home until the task reads them.
A queue copies each item in (xQueueSendFromISR copies sizeof(item)
bytes) and lets any number of ISRs and tasks send and receive. An ISR can't
wait for room, so a full queue makes the send fail at once — the ISR must
count it:
extern "C" void TIMER0_IRQHandler() {
mps2::timer0().intstatus = 1;
std::uint16_t v = read_adc(); // the urgent part
BaseType_t woken = pdFALSE;
if (xQueueSendFromISR(samples, &v, &woken) != pdPASS) // copies 2 bytes; never blocks
dropped = dropped + 1; // full: count it and move on
portYIELD_FROM_ISR(woken);
}A full queue keeps its oldest items; the newest are refused. (Overwriting
needs xQueueOverwriteFromISR, which exists only for length-1 queues — a
mailbox.) Queues are the general tool, but each FromISR call is a full
kernel operation with its own critical section — at tens of kHz that cost
adds up.
A stream buffer is a byte FIFO with a trigger level: the blocked reader is woken when at least that many bytes are waiting, or when its timeout expires. It's built for exactly one writer and one reader (here: the ISR and a task) and is the natural shape for a UART receiver. A message buffer is the same with length-prefixed messages of varying size.
#include "FreeRTOS.h"
#include "stream_buffer.h"
#include "task.h"
#include <crucible/mcu/mps2.hpp>
#include <cstdio>
#include <cstdlib>
static StreamBufferHandle_t rx;
static const char line[] = "$GPS,52.1,4.3*";
static volatile unsigned pos = 0, lost = 0;
extern "C" void TIMER0_IRQHandler() { // plays a UART: one byte received per interrupt
mps2::timer0().intstatus = 1;
if (pos < sizeof line - 1) {
BaseType_t woken = pdFALSE;
if (xStreamBufferSendFromISR(rx, &line[pos], 1, &woken) != 1) lost = lost + 1;
pos = pos + 1;
portYIELD_FROM_ISR(woken);
}
}
static void parser(void*) {
char buf[16];
for (;;) {
// Returns when >= 4 bytes are waiting (the trigger level), or after 10 ms with what's there.
std::size_t n = xStreamBufferReceive(rx, buf, sizeof buf, pdMS_TO_TICKS(10));
if (n == 0) break;
std::printf("tick %2lu: %u bytes \"%.*s\"\n", (unsigned long)xTaskGetTickCount(),
static_cast<unsigned>(n), static_cast<int>(n), buf);
}
std::printf("lost %u\n", lost);
mps2::timer0().ctrl = 0;
std::exit(0);
}
int main() {
rx = xStreamBufferCreate(32, 4); // 32 bytes of storage, trigger level 4
xTaskCreate(parser, "parser", 512, nullptr, 3, nullptr);
auto& t = mps2::timer0();
t.reload = 25000 - 1; // a byte every 1 ms
t.value = t.reload;
t.ctrl = 1 | 8;
mps2::nvic_set_priority(mps2::irq::timer0, 6);
mps2::nvic_enable(mps2::irq::timer0);
vTaskStartScheduler();
}Four bytes per wake instead of one; the last two arrive below the trigger level and come out at the 10 ms timeout. The trigger level trades latency for fewer context switches.
xTimerPendFunctionCallFromISR deserves a mention: it queues a function
and two arguments for the timer daemon task (priority 7 here) to run —
deferred processing without writing a task, at the cost of sharing that
daemon with every software timer.
7. Counting what you lose
Every buffer between an ISR and a task can fill. The ISR can't wait, so it has three choices — and the only wrong one is silence:
head % 16 would overwrite sample 1, the oldest unread one. The ISR drops it and counts an overrun instead. Invariant: sampled = delivered + waiting + overruns.- Drop the newest and count — what a FromISR queue send does, and what the build task's ring does. Older data is preserved; a counter makes the loss visible.
- Overwrite the oldest — right for latest-value data, but then the reader must be able to tell (a sequence number), and the overwrite must be designed so the ISR and the task never touch the same slot at once.
- Block — not available in an ISR.
Size the buffer from the arithmetic, not by feel: interrupt rate × the longest time the consumer can be kept from running (higher-priority tasks, critical sections, its own processing of the previous batch), plus margin. Then test it: starve the consumer on purpose and check the counter, as the hidden tests do.
One more trap: a wake is not an item. Ten interrupts while the task
couldn't run produce one return from ulTaskNotifyTake(pdTRUE, …) (with a
count of 10). A task that takes one sample per wake leaves the rest in the
ring. After every wake, drain everything that's waiting.
8. Latency from IRQ to task
The time from the interrupt request to the first instruction of the woken task is the sum of:
- Waiting to be taken: while any task or the kernel is inside
taskENTER_CRITICAL()(BASEPRI = 5), and while more urgent ISRs run. Long critical sections in task code are the classic hidden latency. - Interrupt entry: the core stacks eight registers (more with lazy FPU state) and fetches the vector — on real hardware tens of cycles, plus flash wait states.
- The ISR itself — which is why it stays short.
- Other pending ISRs (PendSV waits for them all) and the context switch in PendSV.
- Without
portYIELD_FROM_ISR: up to one tick.
Interrupts above configMAX_SYSCALL_INTERRUPT_PRIORITY skip item 1
entirely — the reason they exist. Measure with a free-running counter as
in §4 under emulation; on a board, toggle a GPIO in the ISR and in the task
and look at both on a logic analyser.
9. Stopping cleanly
The shutdown order matters as much as the start-up order:
bool stop(TickType_t timeout) {
auto& t = mps2::timer0();
t.ctrl = 0; // 1. no more interrupts …
mps2::nvic_disable(mps2::irq::timer0);
t.intstatus = 1; // … not even a pending one
mps2::nvic().icpr[0] = 1u << mps2::irq::timer0;
stopper_ = xTaskGetCurrentTaskHandle(); // 2. tell the task, and wake it
stopping_ = true;
xTaskNotifyGive(task_);
return ulTaskNotifyTakeIndexed(1, pdTRUE, timeout) != 0; // 3. wait for its reply
}Get it backwards — delete the task or free the buffer while the timer can
still fire — and the next interrupt notifies a deleted TCB or writes into
freed memory. It happens once in thousands of runs, on the board, in the
field. Deleting the task from outside (vTaskDelete(task_)) is also wrong
here: it throws away whatever is still in the ring and the partial batch.
Checklist
- The ISR's NVIC priority is set, before enabling, to a number ≥
configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY— or the ISR makes no FreeRTOS calls at all. - Only
…FromISRcalls in the ISR; no blocking, no allocation, no stdio. -
BaseType_t woken = pdFALSE;…portYIELD_FROM_ISR(woken)in every ISR that can wake a task. - The deferred task has a priority above the work it must preempt.
- The hand-off matches the question: count, bits, latest value, or every item.
- Full buffers drop and count; the counter is tested with a starved consumer.
- After each wake the task drains everything waiting.
- Stop: source off → wake → drain → acknowledge → reuse; every wait has a timeout.
On real hardware
The code is the same on a physical Cortex-M4; what you learn about it changes.
- A real tick and real interrupts. SysTick runs from the core clock and peripherals interrupt on real events, so timings jitter: an emulated "0.2 µs" becomes whatever interrupt entry, flash wait states, the FPU's lazy stacking and your own critical sections add up to. Budget from measurements, not from this lesson's numbers.
- Measure with a GPIO and a logic analyser (or oscilloscope): set a pin on ISR entry, clear it on exit, toggle another in the task when it wakes. That shows ISR length, IRQ-to-task latency and jitter under real load.
- Trace the kernel: SEGGER SystemView and Percepio Tracealyzer hook FreeRTOS's trace macros and show every ISR, notification and context switch on a timeline — the diagrams above, recorded from the device.
- A hardware watchdog catches the failure modes this skill makes visible in tests — an interrupt storm, a handler that never runs — when they happen in the field: it resets the chip if the healthy path stops kicking it.
- Vendor code sets priorities too. HAL init functions and middleware
configure NVIC priorities and their own ISRs; check the final register
values against
configMAX_SYSCALL_INTERRUPT_PRIORITY, and keepconfigASSERTenabled in development builds — the port's priority check is one of the most useful asserts in FreeRTOS.