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:
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:
extern "C" void TIMER0_IRQHandler() { /* … */ }2. What happens on an interrupt
bx lr.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.
lrholds a magic EXC_RETURN value;bx lrto 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:
#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");
}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:
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 it1 | 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.#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):
#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);
}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
- 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.
- Never block. Waiting in a handler for something that another handler (or main) must do deadlocks.
- Shared data is
volatileor 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 plainboolset by a handler can be optimised into nothing at all:
wait_ready: @ while (!ready) {} with `bool ready`
bx lr @ the whole loop deleted: nothing in it can change `ready`- Clear the source, first thing.
- 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.