CodeRigor

FREE LESSON

From interrupt to task (deferred processing)

Deferred interrupt processing under FreeRTOS on the Cortex-M4: waking tasks from ISRs with task notifications, queues and stream buffers, portYIELD_FROM_ISR and the latency it saves, ISR priorities versus configMAX_SYSCALL_INTERRUPT_PRIORITY (and the assert you get when it's wrong), what an ISR may call, counting overruns, and stopping an ISR-fed task cleanly.

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.

An interrupt handed off to a task
The ISR acknowledges, captures the reading and gives the handler task a notification. portYIELD_FROM_ISR makes the exception return land in the handler, which preempts the busy lower-priority task, processes, and blocks again.
An interrupt handed off to a task
The ISR acknowledges, captures the reading and gives the handler task a notification. 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:

C++
#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 …FromISR API 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:

NVIC priorities on this port (3 bits: 0 most urgent … 7 least)
Kernel critical sections set BASEPRI to priority 5: interrupts 5–7 are held off while the kernel updates its lists, so they may call …FromISR functions. Interrupts 0–4 are never masked by FreeRTOS — lowest latency — and must never call it. Every priority resets to 0.
NVIC priorities on this port (3 bits: 0 most urgent … 7 least)
Kernel critical sections set BASEPRI to priority 5: interrupts 5–7 are held off while the kernel updates its lists, so they may call …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:

C++
// 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:

Text
crucible: FreeRTOS configASSERT failed at FreeRTOS-Kernel/portable/GCC/ARM_CM4F/port.c:888

Two 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:

Text
crucible: FreeRTOS configASSERT failed at FreeRTOS-Kernel/portable/GCC/ARM_CM4F/port.c:487

The 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.

C++
#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();
}
The same interrupt, with and without the yield
With the yield the handler runs as the ISR returns (0.2 µs of emulated time). Without it, the busy priority-1 task resumes and the handler waits for the next 1 ms tick — 502.8 µs for an interrupt at 1.5 ms, 2.8 µs for one that happens just before a tick. Worst case: a whole tick.
The same interrupt, with and without the yield
With the yield the handler runs as the ISR returns (0.2 µs of emulated time). Without it, the busy priority-1 task resumes and the handler waits for the next 1 ms tick — 502.8 µs for an interrupt at 1.5 ms, 2.8 µs for one that happens just before a tick. Worst case: a whole tick.

Rules of thumb:

  • Every ISR that calls …FromISR starts with BaseType_t woken = pdFALSE;, passes &woken to every call (each may only set it, never clear it), and ends with portYIELD_FROM_ISR(woken).
  • woken is 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_ISR never 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:

C++
#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:

C++
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.

C++
#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.

Choosing the hand-off
Start from what the task needs to know. The build task uses a hand-written ring plus a notification: the cheapest pair, with the overrun policy in plain sight.
Choosing the hand-off
Start from what the task needs to know. The build task uses a hand-written ring plus a notification: the cheapest pair, with the overrun policy in plain sight.

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:

A 16-slot sample ring with the task starved
16 samples (1–16) wait for the task. Sample 17 arrives: storing it at head % 16 would overwrite sample 1, the oldest unread one. The ISR drops it and counts an overrun instead. Invariant: sampled = delivered + waiting + overruns.
A 16-slot sample ring with the task starved
16 samples (1–16) wait for the task. Sample 17 arrives: storing it at 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:

  1. 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.
  2. 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.
  3. The ISR itself — which is why it stays short.
  4. Other pending ISRs (PendSV waits for them all) and the context switch in PendSV.
  5. 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:

C++
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 …FromISR calls 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 keep configASSERT enabled in development builds — the port's priority check is one of the most useful asserts in FreeRTOS.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

A hardware timer ISR handing samples to FreeRTOS tasks: FromISR calls, never blocking in an ISR, volatile shared data. Shown on an ESP32 with Arduino, but the deferred-processing pattern is the one in this skill.