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
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:
| Exception | Priority | Role |
|---|---|---|
| SysTick | usually 15 | 1 kHz tick, drives xTaskIncrementTick() |
| PendSV (Pending Supervisor call) | always lowest (15) | Deferred context switch — the actual task swap |
| SVCall (Supervisor Call) | configurable | Used 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
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:
- PendSV runs last, by design. Setting its pending bit is a request, not a call.
portEND_SWITCHING_ISR(x)only acts whenx == pdTRUE. It is safe to call unconditionally at the end of a handler.- The hardware frame and the software context are two halves of one switch. Neither is optional; Cortex-M7 just splits the work.
- QP/C is transparent to all of it.
QACTIVE_POST_FROM_ISRis a thin wrapper overxTaskNotifyFromISR, and dispatch happens in task context after the switch. - The cost of forgetting is four orders of magnitude, and it will not show up as a failure — only as jitter.
Related
- Interrupts on the i.MX RT1062: auditing your NVIC table — which handlers are even allowed to post
- A microSD active object with QP/C — the ISR-to-worker-task hand-off pattern
💬 Questions, corrections, or your own war story with PendSV? Leave a comment below — I read and answer all of them.
💬 Discussion
Questions, corrections, or war stories from your own board? Leave a comment below — I read and answer all of them. (Sign-in with GitHub.)