Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

07 · Interrupts and Privilege Levels

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:

privilege.c
#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.

privilege.c
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:

privilege.c
    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:

privilege.c
/* 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.

Machine mode drops to supervisor with mret, and a refused instruction traps it back.

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:

privilege.c
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:

privilege.c
    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 refusalObserved
The timer interrupted a loop that asked for nothingyes
mcause’s top bit is set: asynchronousyes
Cause code (machine timer)7
Supervisor mode reading a machine registeryes
…refused with cause2
…which is illegal instructionyes
The handler returned us to machine modeyes

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.py

7.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.py

7.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.py

Where 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.

References
  1. RISC-V International. (n.d.). The RISC-V Instruction Set Manual, Volume II: Privileged Architecture. https://riscv.org/technical/specifications/