I inherited an ISR that posted an event to a QP/C active object and then just returned. It worked. The event arrived, the state machine ran, nothing crashed.

It was also adding up to a full millisecond of latency to a 100 Hz measurement, and nobody had noticed because “the event arrives” and “the event arrives promptly” look identical in a debugger. The missing line was portEND_SWITCHING_ISR(xHigherPriorityTaskWoken).

The reason one macro makes that much difference is worth understanding properly, because it is the same mechanism behind every “my RTOS is slow” question on Cortex-M.

In this article I trace the whole path — from the interrupt firing to the active object’s state handler running — and show exactly where the millisecond comes from when the macro is missing.

sequenceDiagram
    autonumber
    participant HW as Cortex-M7 hardware
    participant ISR as PWM ISR (prio 10)
    participant PSV as PendSV (prio 15)
    participant AO as Io active object
    Note over AO: BLOCKED on its event queue
    HW->>ISR: IRQ fires, hardware pushes R0-R3,R12,LR,PC,xPSR
    ISR->>ISR: measure freq/duty
    ISR->>AO: QACTIVE_POST_FROM_ISR() -> xTaskNotifyFromISR()
    Note over AO: now READY, xHigherPriorityTaskWoken = pdTRUE
    ISR->>PSV: portEND_SWITCHING_ISR sets SCB->ICSR PENDSVSET
    ISR-->>HW: exception return
    PSV->>PSV: save R4-R11 of outgoing task, load R4-R11 of Io
    PSV-->>AO: exception return into QActive_run()
    AO->>AO: dequeue event, QHSM_DISPATCH()

The whole path. Step 5 is the one line people leave out.

TL;DR

PendSV is deliberately the lowest-priority exception, so it runs only once every ISR has finished. portEND_SWITCHING_ISR(pdTRUE) sets its pending bit; the switch then happens on ISR exit. Leave it out and the task stays READY until the next SysTick — up to 1 ms instead of roughly a tenth of a microsecond. QP/C changes none of this: QACTIVE_POST_FROM_ISR is a thin wrapper over xTaskNotifyFromISR.

What a context switch actually is

On Cortex-M7 with FreeRTOS, “context” means the 16 general-purpose registers, the PSR, and the FPU state (S0–S31 plus FPSCR) for a task. Saving and restoring all of that is what lets each task believe it owns the CPU.

The work is split between hardware and software, and knowing which half does what is the whole trick:

ExceptionPriorityRole
SysTickusually 151 kHz tick, drives xTaskIncrementTick()
PendSV (Pending Supervisor call)always lowest (15)Deferred context switch — the actual task swap
SVCall (Supervisor Call)configurableUsed by vPortStartFirstTask() at boot only

PendSV is the key. It is designed to be preempted by every other exception. Setting its pending bit does not run it immediately — it runs only when no other ISR is active. That is precisely what portYIELD_FROM_ISR() exploits: request a switch now, let it happen once the interrupt storm has cleared.

Where QP/C sits

QP/C does not replace FreeRTOS scheduling. It sits on top of it:

flowchart TD
    A["Your AO state machine<br>(written in the .qmp model)"] --> B["QActive_run() — event dispatch loop<br>QHSM_DISPATCH() / QEvt dequeue"]
    B --> C["xTaskNotifyWait() — FreeRTOS task blocking"]
    C --> D["PendSV / SysTick / NVIC — Cortex-M7 hardware"]

Each active object maps to one FreeRTOS task. That task blocks inside QActive_run() waiting on its event queue. When an event is posted from an ISR, QP unblocks the task, FreeRTOS marks it READY, and PendSV switches to it.

QACTIVE_POST_FROM_ISR() calls into QP, which calls xTaskNotifyFromISR(). That is the only FreeRTOS call QP makes from ISR context. The dispatch into your state machine happens in task context, after the switch completes — never inside the ISR.

The full path, step by step

Scenario: the Io AO is BLOCKED waiting for its next event. A lower-priority Hmi AO is RUNNING. A PWM falling edge fires.

t1 — hardware does the first half

When the IRQ fires, the hardware pushes 8 words onto the current task’s stack with no software involvement:

Hmi task stack (PSP)          ISR stack (MSP)
+----------------+            +----------------+
|   xPSR         | <- pushed  |                |
|   PC (ret addr)|   by CPU   |  Your ISR code |
|   LR           |   hardware |  runs here     |
|   R12          |            |                |
|   R3           |            +----------------+
|   R2           |
|   R1           |
|   R0           |
+----------------+
|  (prev data)   |
+----------------+

CPU switches SP from PSP -> MSP, sets IPSR to the PWM2_3 exception number

With lazy FPU stacking enabled — the default — another 18 words are reserved if the FPU was active, but only written if the ISR actually touches the FPU.

t1 to t2 — the ISR runs

The handler measures frequency and duty. On the falling edge it posts an event, which internally calls xTaskNotifyFromISR(). FreeRTOS marks the Io AO task READY and sets xHigherPriorityTaskWoken = pdTRUE, because Io has higher priority than the running Hmi.

t2 — the one line

portEND_SWITCHING_ISR(pdTRUE) writes 1 to bit 28 (PENDSVSET) of SCB->ICSR. PendSV is now pending. The ISR returns via EXC_RETURN.

t2 to t3 — PendSV does the second half

No other ISR is pending, so PendSV fires immediately. xPortPendSVHandler in port.c handles the callee-saved registers R4–R11, which the hardware frame does not include:

; Save the outgoing task's remaining context onto its PSP
STMDB   SP!, {R4-R11}          ; push R4-R11 onto Hmi task PSP
STR     SP, [R1]               ; save Hmi PSP into its TCB

; Load the incoming task's context from its PSP
LDR     SP, [R0]               ; load Io AO PSP from its TCB
LDMIA   SP!, {R4-R11}          ; restore Io AO R4-R11

; Return — hardware automatically pops {R0-R3,R12,LR,PC,xPSR}
; from the Io AO task stack (the frame saved when it last blocked)
BX      LR

The hardware pops the exception frame from the Io AO’s stack, so the CPU resumes exactly where QActive_run() left off when it called xTaskNotifyWait().

t3 — the active object runs

QActive_run() dequeues the event and calls QHSM_DISPATCH(). Your state handler processes the signal.

What it costs

Counting the instructions in the ARM_CM7 port rather than measuring on hardware:

portEND_SWITCHING_ISR sets PENDSVSET
  |
  v
ISR epilogue: hardware pops the exception frame        ~12 cycles
  |
  v
PendSV fires (no other ISR pending)
  |
  v
xPortPendSVHandler:
  STMDB  save R4-R11 for the outgoing task              ~8 cycles
  STR    save its PSP to its TCB                        ~1 cycle
  LDR    load the incoming PSP from its TCB             ~1 cycle
  LDMIA  restore R4-R11                                 ~8 cycles
  BX LR  hardware pops the incoming frame              ~12 cycles
  |
  v
Io AO task resumes in QActive_run() -> QHSM_DISPATCH()

Roughly 42–60 cycles, so 70–100 ns at 600 MHz. That is an instruction-count estimate from the port source, not a DWT measurement — treat it as the right order of magnitude rather than a number to quote in a datasheet.

What it costs to leave the line out

[ Hmi task running ][ PWM ISR ][ Hmi task continues .......... ][ SysTick ][ Io AO ]
                                <--------- up to 1 ms --------> ^
                                                                 |
                                Io AO is READY, but PendSV is    |
                                never set until SysTick ---------+

xTaskNotifyFromISR() made the AO READY. Without PENDSVSET, the scheduler only runs at the next SysTick, up to 1 ms later. On a 100 Hz PWM measurement that is 1% jitter. On a fast control loop it is a full tick of dead time every single cycle.

Compare: ~70–100 ns versus up to 1,000,000 ns. Four orders of magnitude, one macro.

Do you always need it?

No — and the rule is simple:

flowchart TD
    A["ISR fires"] --> B{"Does it call<br>QACTIVE_POST_FROM_ISR /<br>xTaskNotifyFromISR /<br>xQueueSendFromISR?"}
    B -->|yes| C["Always call<br>portEND_SWITCHING_ISR(xHigherPriorityTaskWoken)"]
    B -->|"no — only writes<br>volatile variables"| D["Not needed"]

A worked example from this project’s PWM input handler: the rising edge only writes a volatile frequency variable — no post, no yield needed. The falling edge posts an event, so a yield is needed. Placing portEND_SWITCHING_ISR once at the end covers both cases: if only the rising edge fired, xHigherPriorityTaskWoken is still pdFALSE and the macro compiles down to a no-op.

void PWM_IN1_INT_Handler(void)    /* runs on MSP (ISR stack), NVIC priority 10 */
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;

    uint16_t sts = PWM2->SM[3].STS & (PWM_STS_CFB0_MASK | PWM_STS_CFB1_MASK);

    if ((sts & PWM_STS_CFB0_MASK) != 0U)    /* rising edge */
    {
        /* writes a volatile frequency variable — no FreeRTOS call,
           xHigherPriorityTaskWoken stays pdFALSE */
    }

    if ((sts & PWM_STS_CFB1_MASK) != 0U)    /* falling edge — full cycle known */
    {
        if (s_ao != NULL)
        {
            QEvt *evt = Q_NEW_FROM_ISR(QEvt, s_pwm_in_sig);   /* ISR-safe pool alloc */

            QACTIVE_POST_FROM_ISR(s_ao, evt, &xHigherPriorityTaskWoken, (void *)0);
            /* QP calls xTaskNotifyFromISR internally and sets the flag to pdTRUE,
               because the Io AO outranks the currently running Hmi AO */
        }
    }

    portEND_SWITCHING_ISR(xHigherPriorityTaskWoken);
    /* pdTRUE  -> writes SCB->ICSR PENDSVSET; PendSV fires on ISR exit
       pdFALSE -> no-op */

    SDK_ISR_EXIT_BARRIER;    /* DSB+ISB: flush the store buffer before exception return */
}

Priority 10 is not an accident

This handler sits at NVIC priority 10, comfortably above configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY (5) numerically, so calling FreeRTOS APIs from it is legal. An ISR at priority 0–4 may not post at all, no matter how much you want it to — and the compiler will not stop you. Auditing which side of that line each handler falls on is the subject of the NVIC priority audit.

Summary

The switch is split by design: hardware saves the caller-saved registers on exception entry, PendSV saves the callee-saved ones, and PendSV is deliberately the lowest priority so it never preempts a real ISR. portEND_SWITCHING_ISR is how an ISR asks for that switch to happen on exit rather than at the next tick.

Five things worth keeping:

  1. PendSV runs last, by design. Setting its pending bit is a request, not a call.
  2. portEND_SWITCHING_ISR(x) only acts when x == pdTRUE. It is safe to call unconditionally at the end of a handler.
  3. The hardware frame and the software context are two halves of one switch. Neither is optional; Cortex-M7 just splits the work.
  4. QP/C is transparent to all of it. QACTIVE_POST_FROM_ISR is a thin wrapper over xTaskNotifyFromISR, and dispatch happens in task context after the switch.
  5. The cost of forgetting is four orders of magnitude, and it will not show up as a failure — only as jitter.

💬 Questions, corrections, or your own war story with PendSV? Leave a comment below — I read and answer all of them.