07 · Interrupts and Privilege Levels¶
Chapter header
| Target | bare — the same machine under QEMU with no operating system on it |
| Prerequisites | ch06 |
| What it measures | A timer interrupt taken with no kernel present, and an access refused because the program had dropped a privilege level. |
The question¶
What arrives without being asked for, and what does a privilege level actually restrict?
ch06’s trap was caused. An instruction executed, and the trap was that
instruction’s consequence — remove the ecall and nothing happens. This chapter is about the other
kind, an interrupt, which no instruction causes and which would arrive if the program were doing
nothing at all. It is also about privilege levels: what “allowed” means in hardware is a two-bit
field and a refusal.
The material¶
Something that arrives on its own¶
The timer is a device that counts. mtime goes up whether or not anybody reads it; when it reaches
mtimecmp, the processor takes an interrupt. Both are addresses that are wires, in
ch04’s sense — fixed places on this machine, in a block called the CLINT —
and there is nothing else to it:
#define CLINT_BASE 0x02000000UL
#define CLINT_MTIMECMP (CLINT_BASE + 0x4000)
#define CLINT_MTIME (CLINT_BASE + 0xBFF8)
Arming it takes three writes — one to say when, one to enable this particular interrupt, and one to enable interrupts at all. The two enable bits do different jobs: the per-source bit says which interrupts a program is interested in, and the global bit is what a critical section — a stretch of code that must not be interrupted — turns off.
static void wait_for_the_timer(void) {
volatile uint64 *mtime = (volatile uint64 *)CLINT_MTIME;
volatile uint64 *mtimecmp = (volatile uint64 *)CLINT_MTIMECMP;
bare_csr_write(mtvec, (uint64)handler);
*mtimecmp = *mtime + 100000;
bare_csr_set(mie, MIE_MTIE);
bare_csr_set(mstatus, MSTATUS_MIE);
/* This loop asks for nothing. It reads no device, executes no `ecall`, and would run for
* ever left alone. The interrupt is what stops it. */
while (interrupt_cause == 0) {
spins_before_interrupt++;
}The loop asks for nothing. It reads no device, executes no ecall, touches no memory it does not
own, and left alone it would run until the machine was switched off. The interrupt stops it anyway,
and unlike the previous chapter’s trap, nothing in the loop asked for it.
Two kinds of cause, one register¶
mcause reports both kinds, and the top bit is which. That bit is also the reason the handler can
be one function:
if (cause & CAUSE_INTERRUPT) {
/* An interrupt's mepc is wherever the program happened to be. There is no instruction
* that "caused" it, so unlike the bare-metal trap chapter's trap there is nothing to advance past. */
interrupt_cause = cause;
interrupt_epc = bare_csr_read(mepc);
bare_csr_clear(mie, MIE_MTIE); /* once is the demonstration; twice is a loop */
return;
}
The mepc + 4 from ch06 is gone. An interrupt’s mepc is wherever
the program happened to be — no instruction caused it, so there is nothing to advance past, and
advancing would skip an instruction that had not run yet. The same register, read two different
ways, depending on one bit.
What a privilege level refuses¶
Machine mode may do anything. Supervisor mode — the level below it, and the one a kernel runs in — may not, and this is the smallest demonstration of what “may not” means in hardware:
/* Supervisor mode tries to read a machine-mode register. Naked because it is not a function the
* C world calls or returns from — it is entered by `mret` and left by trapping. */
__attribute__((naked, aligned(4))) static void supervisor_probe(void) {
asm volatile("csrr t0, mhartid\n" /* 0xF14: machine mode only. This is the refusal. */
"j .\n"); /* never reached, and says so if it ever is */
}
One instruction, reading one register. In machine mode it succeeds; in supervisor mode the processor refuses, and the refusal is itself a trap — cause 2, illegal instruction. There is no error code, no signal and no return value. The instruction simply does not happen and control leaves for the handler.
Down a level on purpose, and back only by trapping. There is no instruction for gaining privilege, which is why the way back has to be arranged before leaving.
Getting into supervisor mode is the same instruction that returns from a trap. mret goes to
mepc at the privilege level named in mstatus.MPP — the field that records which level the
last trap came from, and so which level mret goes back to — so setting that field to
supervisor and executing mret is a deliberate demotion:
static void be_refused(void) {
bare_open_memory();
phase = PHASE_SUPERVISOR;
bare_csr_write(mepc, (uint64)supervisor_probe);
bare_csr_clear(mstatus, MSTATUS_MPP);
bare_csr_set(mstatus, MSTATUS_MPP_S);
/* `la` before `mret`, in one asm block, so the label is the instruction the handler sends us
* back to. Split across two statements the compiler is entitled to put anything in between. */
bare_enter_supervisor();
}
bare_open_memory() opens the physical memory protection regions — a small table in the
processor saying which addresses the lower modes may touch — and leaving it out will cost you an
afternoon. With no firmware in front of the program every one of those regions starts closed, and
closed means closed to supervisor and user mode — machine mode is exempt. Without it,
the supervisor program faults on its first instruction, with a cause that has nothing to do with
what it was trying to do.
And getting back¶
Only through a trap. The handler notices which phase the program is in, puts machine in MPP,
and points mepc at the address the program recorded before it left:
if (phase == PHASE_SUPERVISOR) {
refusal_cause = cause;
refused = 1;
/* Go back to machine mode, at the instruction after the `mret` that left it. Changing
* MPP is how a handler decides which mode it is returning *to* — the same field that
* dropped us into supervisor mode brings us back out. */
bare_csr_clear(mstatus, MSTATUS_MPP);
bare_csr_set(mstatus, MSTATUS_MPP_M);
bare_csr_write(mepc, bare_resume_at);
phase = PHASE_BACK;
return;
}
mret restores no registers at all, so anything the program wants back after the round trip has to
be saved and reloaded by hand. bare_enter_supervisor() does that in one place, and
ch11 is where it stops being an inconvenience and becomes the
subject.
What we measured¶
Run it yourself before reading the table — the rows below are what you should see, and a figure you have reproduced is worth more than one you have been shown:
./run privilege| An interrupt nobody asked for, and a refusal | Observed |
|---|---|
| The timer interrupted a loop that asked for nothing | yes |
mcause’s top bit is set: asynchronous | yes |
| Cause code (machine timer) | 7 |
| Supervisor mode reading a machine register | yes |
| …refused with cause | 2 |
| …which is illegal instruction | yes |
| The handler returned us to machine mode | yes |
Conditions: target bare · qemu virt (-machine virt -bios none) · QEMU emulator version 8.2.2 (Debian 1:8.2.2+ds-0ubuntu1.18) · no firmware, no kernel · riscv64-linux-gnu-gcc · -march=rv64g -mabi=lp64 -mcmodel=medany -ffreestanding -nostdlib -fno-pic -fno-stack-protector -fno-builtin -O1 -g -Wall -Werror · 2026-09-17 · Source: bench/results/privilege-bare.json, code hash 832b1b25cf466972.
What this cannot tell you¶
What an interrupt costs, or how long it takes to arrive. Not measurable here and not measurable under any emulator: interrupt latency is a property of a pipeline, and QEMU has none. ch29 asks that question on hardware.
How real interrupt sources behave. One timer is the simplest possible case: one source, no routing, no priority, no sharing. A real machine has an interrupt controller deciding which device may interrupt which core. That controller is exactly the part this chapter leaves out, and ch19 is where it arrives.
What the other privilege level does. There is a user mode below supervisor, and this chapter never enters it. Nothing here needed it, and a demonstration with a level that made no difference would have taught that levels make no difference.
Problems¶
Three, in tests/interrupts_and_privilege/.
7.1 — Interrupt an interrupt.
Arrange for the timer to fire while the handler is still running, and say what happens and why.
Then make it happen. The test checks both your prediction and a run. The default answer is
nothing, for a reason in one bit of mstatus.
python3 -m pytest tests/interrupts_and_privilege/test_problem_1_nested.py7.2 — Advance mepc on an interrupt.
Make the handler treat the interrupt as ch06 treated the trap, and add four. Say exactly what goes
wrong and produce a run where the damage is visible in the output rather than inferred.
python3 -m pytest tests/interrupts_and_privilege/test_problem_2_wrong_epc.py7.3 — Find another refusal.
csrr t0, mhartid is one instruction supervisor mode may not execute. Find a second, of a
different kind — not another machine-mode register — and demonstrate it, reporting its cause. The
test checks the cause differs from 2 and that your program really did run in supervisor mode.
python3 -m pytest tests/interrupts_and_privilege/test_problem_3_another_refusal.pyWhere to go next¶
The privileged specification RISC-V International (n.d.) defines the cause codes, mstatus.MPP and the interrupt
enable bits. Its table of causes is one page and worth reading in full once: most of the entries
are things you can arrange to see from a program this size.
ch08 adds the other thing that arrives without being asked for, which is a second processor.
- RISC-V International. (n.d.). The RISC-V Instruction Set Manual, Volume II: Privileged Architecture. https://riscv.org/technical/specifications/