CodeRigor

FREE LESSON

Interrupts: SysTick, timers and the NVIC

The Cortex-M exception model in practice: vector table, NVIC enable/pending/priority, SysTick and timer interrupts, what the hardware stacks, why an ISR must clear its source, and sleeping with WFI — built into a tick clock with software timers.

A microcontroller spends most of its life waiting. Interrupts are how the world gets its attention: a timer expires, a byte arrives on the UART, a button is pressed — and the core drops what it's doing, runs a handler, and goes back. This lesson is the Cortex-M version of that story, run on the emulated MPS2 board (mps2-an386, a Cortex-M4F).

One property of this emulator makes interrupts teachable: time is virtual. Every instruction takes exactly 1 ns, and the peripherals (SysTick, the CMSDK timers, the FPGA counter) count at 25 MHz — one timer cycle per 40 instructions. So "how many times did the timer fire?" has one exact answer, the same on every run. (On real hardware the answer would wobble; the reasoning is the same.)

1. The vector table

The first words of memory are a table of addresses: the initial stack pointer, then one handler per exception. Our startup code (crucible/mcu/startup.cpp) defines it:

Exception numbers index the vector table
Entry n holds the handler for exception n; external IRQ n is exception 16 + n, so TIMER0 (IRQ 8) is entry 24. IPSR reports the same number while the handler runs.
Exception numbers index the vector table
Entry n holds the handler for exception n; external IRQ n is exception 16 + n, so TIMER0 (IRQ 8) is entry 24. IPSR reports the same number while the handler runs.

The handlers are weak aliases of Default_Handler (which spins forever). Defining a function with the same extern "C" name replaces it — that's how you "install" a handler:

C++
extern "C" void TIMER0_IRQHandler() { /* … */ }

2. What happens on an interrupt

An interrupt, from main's point of view
The hardware stacks and unstacks the caller-saved registers, so the handler in between is an ordinary C++ function that returns with bx lr.
An interrupt, from main's point of view
The hardware stacks and unstacks the caller-saved registers, so the handler in between is an ordinary C++ function that returns with bx lr.
The exception frame the core pushes
Eight words, highest address first. The stacked pc is where main resumes and the stacked lr is main's own; the lr register is then loaded with EXC_RETURN.
The exception frame the core pushes
Eight words, highest address first. The stacked pc is where main resumes and the stacked lr is main's own; the lr register is then loaded with EXC_RETURN.
  • The core pushes exactly the registers a C function is allowed to clobber, plus the return address and status. So a handler is an ordinary function — no special keyword, no special epilogue.
  • lr holds a magic EXC_RETURN value; bx lr to it means "return from exception". Its bits say which stack and which frame type (bit 4 clear = the frame includes FP registers, once main has used the FPU).
  • IPSR (in xPSR) holds the active exception number: 0 in main, 15 in SysTick, 24 in TIMER0's handler.

3. The NVIC: enable, pending, priority

The Nested Vectored Interrupt Controller has, per IRQ:

register address meaning
ISER / ICER 0xE000E100 / 0xE000E180 set / clear enable (write 1 to the bit)
ISPR / ICPR 0xE000E200 / 0xE000E280 set / clear pending
IPR 0xE000E400 + n (bytes) priority: lower value = more urgent

An interrupt is taken when it's pending, enabled, more urgent than whatever is running, and not masked by PRIMASK. Masking delays it — it stays pending — it never discards it:

C++
#include <crucible/mcu/mps2.hpp>
#include <cstdio>

extern "C" void TIMER0_IRQHandler() { std::puts("  [handler]"); }

int main() {
    mps2::nvic_enable(mps2::irq::timer0);
    mps2::irq_disable();
    mps2::nvic().ispr[0] = 1u << mps2::irq::timer0;   // pend it by software
    std::puts("masked: request recorded, handler not run yet");
    mps2::irq_enable();                               // taken right here
    std::puts("unmasked");
}
Masking delays an interrupt; it never loses it
Pending is recorded while PRIMASK is set; the handler runs the moment irq_enable() clears it — between the two puts.
Masking delays an interrupt; it never loses it
Pending is recorded while PRIMASK is set; the handler runs the moment irq_enable() clears it — between the two puts.

When several are pending, the most urgent goes first; when it returns and another is still pending, the core tail-chains straight into it without unstacking and restacking. A more urgent interrupt can also preempt a running handler (nesting).

4. SysTick: the core's own timer

Every Cortex-M has SysTick, a 24-bit down-counter that raises exception 15:

Text
CSR  0xE000E010   bit0 ENABLE  bit1 TICKINT (raise the exception)  bit2 CLKSOURCE (1 = core clock)  bit16 COUNTFLAG
RVR  0xE000E014   reload value: counts RVR, RVR-1, … 0, then reloads → period = RVR + 1 cycles
CVR  0xE000E018   current value; any write clears it
SysTick CSR = 7: enable, interrupt, core clock
The example writes 1 | 2 | 4 (low byte shown). COUNTFLAG, bit 16, is set by the hardware each time the counter reaches 0 and cleared when you read CSR.
SysTick CSR = 7: enable, interrupt, core clock
The example writes 1 | 2 | 4 (low byte shown). COUNTFLAG, bit 16, is set by the hardware each time the counter reaches 0 and cleared when you read CSR.
CVR counts down from RVR, then reloads
With RVR = 24 999 the counter takes 25 000 cycles per period — 1 ms at 25 MHz. Each reload raises SysTick: RVR = period − 1.
CVR counts down from RVR, then reloads
With RVR = 24 999 the counter takes 25 000 cycles per period — 1 ms at 25 MHz. Each reload raises SysTick: RVR = period − 1.
C++
#include <crucible/mcu/mps2.hpp>
#include <cstdio>

volatile unsigned ticks = 0;
extern "C" void SysTick_Handler() { ticks = ticks + 1; }

int main() {
    auto& st = mps2::systick();
    st.csr = 0;
    st.rvr = 25000 - 1;           // 1 ms at 25 MHz
    st.cvr = 0;
    st.csr = 1u | 2u | 4u;
    while (ticks < 5) mps2::wfi(); // sleep between ticks
    st.csr = 0;
    std::printf("%u ticks, FPGA counter says ~%lu cycles\n", ticks, (unsigned long)mps2::fpgaio().counter);
}

wfi ("wait for interrupt") stops the core until an interrupt is taken; the handler runs, then execution continues after the wfi. Always re-check your condition in a loop: other interrupts wake the core too.

5. Peripheral interrupts: clear the source

The CMSDK timers (mps2::timer0(), IRQ 8) count down from RELOAD at 25 MHz and set INTSTATUS when they reach zero. That status drives the interrupt line until you clear it (write 1 to INTCLEAR, the same offset):

C++
#include <crucible/mcu/mps2.hpp>
#include <cstdio>

volatile unsigned expiries = 0;
extern "C" void TIMER0_IRQHandler() {
    mps2::timer0().intstatus = 1;   // acknowledge first
    expiries = expiries + 1;
}

int main() {
    auto& t = mps2::timer0();
    t.reload = 1000 - 1;
    t.value = 1000 - 1;
    t.ctrl = 1 | 8;                 // enable, interrupt enable
    mps2::nvic_enable(mps2::irq::timer0);
    for (volatile int i = 0; i < 50000; i = i + 1) {}
    t.ctrl = 0;
    mps2::nvic_disable(mps2::irq::timer0);
    std::printf("timer expired %u times\n", expiries);
}
The interrupt line stays high until you clear the source
Writing 1 to INTCLEAR drops the line. Without it the interrupt is pending again the instant the handler returns, and the core tail-chains straight back in — forever.
The interrupt line stays high until you clear the source
Writing 1 to INTCLEAR drops the line. Without it the interrupt is pending again the instant the handler returns, and the core tail-chains straight back in — forever.

Forget the acknowledgement and the handler returns into itself forever: the line is still high, so the interrupt is pending again instantly and tail-chained. The device doesn't crash — it just never runs main again. (SysTick is different: its pending bit clears on entry.)

6. Rules for handlers

  1. Short. A handler delays everything at its priority and below. Do the minimum (read the data, clear the flag, set a flag or queue an item) and let the main loop do the rest.
  2. Never block. Waiting in a handler for something that another handler (or main) must do deadlocks.
  3. Shared data is volatile or atomic — and multi-word or read-modify-write data needs more (next skill: critical sections). The compiler doesn't know the handler exists: polling a plain bool set by a handler can be optimised into nothing at all:
Assembly
wait_ready:              @ while (!ready) {}   with `bool ready`
    bx    lr             @ the whole loop deleted: nothing in it can change `ready`
  1. Clear the source, first thing.
  2. Publish carefully. If main fills in a structure the handler reads, set the "valid" flag last; when removing, clear it first. The build task's software timers depend on this.

7. Software timers on one hardware timer

One periodic tick can drive any number of software timers: each has a remaining countdown; the tick handler decrements every active one and, at zero, reloads it and calls its callback. That's the build task — a tick clock (now(), delay(n) with wfi) plus eight periodic timers, tested with real SysTick interrupts, so off-by-ones in the reload (RVR = period − 1!) or the countdown show up as exact, reproducible wrong counts.

Three software timers on one tick
start / 4
Each tick decrements every active countdown; at zero the timer fires its callback and reloads its period. Step through three ticks.
Three software timers on one tick
Each tick decrements every active countdown; at zero the timer fires its callback and reloads its period. Step through three ticks.

Watch & read

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

What interrupts are and how hardware saves context and dispatches handlers.

Cortex-M specifics: NVIC, automatic stacking of r0–r3, r12, lr, pc, xPSR, and EXC_RETURN.

Hardware interrupts from first principles on a 6502 breadboard computer: IRQ vs NMI.

How hardware timers count and raise interrupts — the idea behind SysTick.