Chapter 16: Interrupts and Naked Functions
An interrupt handler runs at a moment you did not choose, on a stack you did not set up, in the middle of code that was doing something else. It must save whatever it touches, return with a special instruction, and never block. This chapter covers the tools @mode(freestanding) gives you for that:
@interrupt(arch: …)handlers and theInterruptFramethey receive;- installing a handler in the x86_64 interrupt descriptor table;
@nakedfunctions, whose bodies are pure assembly, and@callconv;@interruptSafe, which makes the compiler check everything a handler calls;InterruptGuardand theirqSaveDisable/irqRestorepair for code that shares data with a handler.
@interrupt handlers
@interrupt(arch: "…") turns a module-level function into an interrupt entry. The compiler emits a small assembly trampoline under the function's name: it saves the registers the body may clobber, passes the body a borrow of the InterruptFrame the CPU pushed, restores the registers and returns with the architecture's interrupt-return instruction (iretq on x86_64, eret on AArch64, mret on RISC-V) (Language Reference §3-3.13).
On x86_64, some exceptions push an error code. Declare those handlers with has_error_code: true and a second uint64 parameter; the trampoline passes the code and discards it before iretq.
@mode(freestanding)
# What an x86_64 CPU pushes on interrupt entry.
@repr(C)
struct InterruptFrame {
let rip: uint64;
let cs: uint64;
let rflags: uint64;
let rsp: uint64;
let ss: uint64;
}
var ticks: uint64 = 0
var lastFaultRip: uint64 = 0
var lastFaultCode: uint64 = 0
# IRQ 0 (the PIT), remapped to vector 0x20.
@interrupt(arch: "x86_64", has_error_code: false)
func timerIsr(frame: borrow InterruptFrame): void {
let _old = atomicFetchAddU64(addressOf(ticks), 1u64, 5)
asm { "out dx, al" in: "dx" = 0x20u16, "al" = 0x20u8 } # end of interrupt to the 8259
}
# Vector 14, page fault: the CPU pushes an error code.
@interrupt(arch: "x86_64", has_error_code: true)
func pageFaultIsr(frame: borrow InterruptFrame, error: uint64): void {
atomicStoreU64(addressOf(lastFaultRip), frame.rip, 5)
atomicStoreU64(addressOf(lastFaultCode), error, 5)
while (true) {
asm { "cli" "hlt" }
}
}
Listing 16-1: A timer handler and a page-fault handler on x86_64
loup check accepts this listing but prints one advisory per handler:
warning[E_INTERRUPT_SAFE_VIOLATION]: interrupt-context function `timerIsr` marshals a struct by value (parameter of type `InterruptFrame`) — freestanding stages by-value struct args/returns through a per-callsite GLOBAL `__obj_` block that an interrupt (or the other CPU) can clobber; not interrupt-reentrant / SMP-safe (§3-3.24). Prefer passing `borrow`/pointer, or a primitive handle.
--> src/main.lpfree:18:1
The parameter is a borrow, which is the signature the Language Reference prescribes; the 1.25 checker reports it anyway. Treat this particular advisory as noise.
Three things about the frame:
- Declare the frame for your target. The Language Reference makes
InterruptFramea built-in whose fields follow--arch. In 1.25 the built-in only has the x86_64 fields (typedint), so the boot-tested kernels declare a@repr(C) struct InterruptFramewith their architecture's layout, as Listing 16-1 does; your declaration shadows the built-in. - It is read-only. The fields are
let, and they describe hardware state the trampoline will restore. - No floating-point or vector state is saved. The x86_64 freestanding lanes compile without SSE; on AArch64, a handler that uses FP or SIMD registers must save them itself.
On AArch64 and RISC-V there is no error code; the fault information is part of the frame. Write @interrupt(arch: "aarch64") or @interrupt(arch: "riscv64") with a frame of that architecture's shape:
# AArch64: built by the trampoline from the exception registers.
@repr(C)
struct InterruptFrame {
let elr_el1: uint64; # return address
let spsr_el1: uint64; # saved processor state
let sp_el0: uint64; # the EL0 stack pointer
let esr_el1: uint64; # exception syndrome: class and details
}
const EC_SVC64: uint64 = 0x15u64 # exception class: SVC from AArch64
var syscalls: uint64 = 0
@interrupt(arch: "aarch64")
func syncIsr(frame: borrow InterruptFrame): void {
let exceptionClass = (frame.esr_el1 >> 26) & 0x3Fu64
if (exceptionClass == EC_SVC64) {
let _old = atomicFetchAddU64(addressOf(syscalls), 1u64, 5)
}
}
Listing 16-2: An AArch64 synchronous-exception handler reading ESR_EL1
The RISC-V 64 frame has mepc, mcause, mstatus and mtval, in that order, and the handler returns with mret (machine mode).
The Language Reference also requires handlers to return void (E_INTERRUPT_NONVOID) and forbids calling one from Loup code (E_INTERRUPT_DIRECT_CALL). The 1.25 checker reports neither, so keep both rules yourself: a handler returns nothing, and only the CPU calls it.
Installing a handler on x86_64
The CPU finds handlers through the interrupt descriptor table (IDT): 256 gates of 16 bytes each, whose address is loaded with lidt. A buffer[uint64, 512] module global holds the table, and addressOf gives its address. A function's address is not something addressOf returns (it takes module-level variables only), so the handler's address comes from a lea:
const KERNEL_CS: uint64 = 0x08u64
const GATE_INTERRUPT: uint64 = 0x8Eu64 # present, DPL 0, 64-bit interrupt gate
var idt: buffer[uint64, 512]; # 256 gates x 16 bytes
var idtr: buffer[uint64, 2]; # limit:16 | base:64, padded to 16 bytes
func timerIsrAddress(): uint64 {
var a: uint64 = 0
asm { "lea rax, [rip + timerIsr]" out: a = "=a" }
return a
}
func setGate(vector: int, handler: uint64): void {
let low = (handler & 0xFFFFu64) | (KERNEL_CS << 16) | (GATE_INTERRUPT << 40)
| (((handler >> 16) & 0xFFFFu64) << 48)
idt[vector * 2] = low
idt[vector * 2 + 1] = handler >> 32
}
func loadIdt(): void {
let base = addressOf(idt)
idtr[0] = 4095u64 | (base << 16) # limit = 256 * 16 - 1
idtr[1] = base >> 48
let p = addressOf(idtr)
asm { "lidt [rax]" in: "rax" = p clobbers: ["memory"] }
}
func installTimer(): void {
setGate(0x20, timerIsrAddress())
loadIdt()
}
Listing 16-3: Building an IDT gate and loading the table
Do not write addressOf(timerIsr): addressOf is defined for module-level variables only (§3-3.4.2). The 1.25 checker does not reject a function name there, but it is not a supported operand, so take a function's address with lea as above. The symbol timerIsr names the trampoline, not the body (the body is emitted as timerIsr__isr_body), so the gate points at code that saves registers and returns with iretq. An @interrupt function is always kept in the image, even though nothing in Loup calls it.
On AArch64 the equivalent is a vector table of sixteen 128-byte slots, written as assembly in a naked function placed in its own section, whose address is written to VBAR_EL1; each slot branches to an @interrupt function. On Cortex-M the vector table is a list of .long handler words in a @section(".vectors") naked function (Language Reference §3-3.2, "Vector table").
@naked functions
A naked function has no compiler-generated prologue or epilogue: no frame, no saved registers, no zeroed locals. Its body is assembly and nothing else. That is what you need for code that runs before a stack exists, for a context switch that replaces the stack pointer, and for hand-written trampolines.
Because the compiler can check nothing about such a function, the Language Reference requires an @unsafe("reason") next to @naked, stating the calling convention and what the assembly is responsible for (§3-3.14). Leaving it off is an error:
@mode(freestanding)
@naked
func switchStacks(fromSp: uint, toSp: uint): void {
asm {
"mov [rdi], rsp"
"mov rsp, [rsi]"
"ret"
}
}
src/main.lpfree:1:1: error: E_NAKED_NOT_UNSAFE: `@naked` function `switchStacks` must also carry `@unsafe("reason")` — naked functions have no stack frame and are inherently unsafe (§3-3.6)
(This is the one-line form loup check --format short prints. The 1.25 checker anchors this diagnostic at the first line of the file rather than at the function; the function name in the message tells you which one.) With the reason added, here is a complete context switch for the System V x86_64 ABI. It saves the callee-saved registers on the old stack, stores the old stack pointer, switches stacks and restores the new task's registers:
# switchContext(fromSpSlot, toSp): rdi = where to store the old rsp, rsi = the new rsp.
@naked
@unsafe("naked: SysV x86_64; saves rbx, rbp, r12-r15 on the old stack, returns on the new one")
@section(".text.switch")
func switchContext(fromSpSlot: uint, toSp: uint): void {
asm {
"push rbp"
"push rbx"
"push r12"
"push r13"
"push r14"
"push r15"
"mov [rdi], rsp"
"mov rsp, rsi"
"pop r15"
"pop r14"
"pop r13"
"pop r12"
"pop rbx"
"pop rbp"
"ret"
}
}
Listing 16-4: A naked context switch
Parameters still arrive where the calling convention puts them (rdi and rsi here), but the compiler does not spill or reload them, so the body reaches them through registers only. Naming a parameter, or writing any other Loup statement, is an error:
@mode(freestanding)
@naked
@unsafe("naked: SysV x86_64")
func switchStacks(fromSp: uint, toSp: uint): void {
let target = toSp + 8
asm {
"mov rsp, rsi"
"ret"
}
}
error[E_NAKED_NON_ASM]: E_NAKED_NON_ASM: @naked function body may only contain asm {} blocks
--> src/main.lpfree:6:5
|
6 | let target = toSp + 8
| ^^^^^^^^^^^^^^^^^^^^^
|
Rules for naked functions:
- They must be module-level functions; on a method
@nakedisE_ANNOTATION_TARGET. @callconv(naked)means the same thing as@naked. The Language Reference requires@unsafewith either spelling; the 1.25 checker only enforces it for@naked. Write the reason in both cases.- The function must end with its own return or jump; the compiler adds nothing after the assembly.
@callconv
The default calling convention in freestanding code is the platform C ABI. @callconv(name) overrides it for one function:
| Convention | Meaning |
|---|---|
C |
the platform C ABI (the default) |
sysv64 |
System V AMD64 |
win64 |
Microsoft x64, used by UEFI firmware |
aapcs64 |
Arm 64-bit procedure call standard |
naked |
the same as @naked |
interrupt |
the same as @interrupt |
The 1.25 checker accepts any name inside @callconv(...), so a misspelled convention is not caught; check the spelling against this table.
A UEFI application's entry point is called with the win64 convention (chapter 4 shows efiMain). Building the PE/COFF image that UEFI firmware loads is a separate problem:
⚠ Planned. The Language Reference lists
--output-format elf|raw|pe|macho(§3-3.2). The 1.25 toolchain produces ELF only and has no--output-formatflag, so it cannot produce a UEFI.efiimage by itself.
@interruptSafe: checking what a handler calls
A handler that calls an ordinary kernel function inherits everything that function does. If it takes a lock that the interrupted code already holds, the CPU deadlocks; if it allocates, it may need that same lock. @interruptSafe marks a function as safe to run in interrupt context, and the compiler enforces a colour rule: an @interrupt handler or an @interruptSafe function may only call other @interruptSafe functions (§3-3.24.1). Calls to intrinsics, such as the atomics, are always allowed.
@mode(freestanding)
@repr(C)
struct InterruptFrame {
let rip: uint64;
let cs: uint64;
let rflags: uint64;
let rsp: uint64;
let ss: uint64;
}
var ticks: uint64 = 0
# Writes to the serial console under the console lock.
func logTick(n: uint64): void {
}
@interrupt(arch: "x86_64", has_error_code: false)
func timerIsr(frame: borrow InterruptFrame): void {
let n = atomicFetchAddU64(addressOf(ticks), 1u64, 5)
logTick(n)
}
warning[E_INTERRUPT_SAFE_VIOLATION]: interrupt-context function `timerIsr` marshals a struct by value (parameter of type `InterruptFrame`) — freestanding stages by-value struct args/returns through a per-callsite GLOBAL `__obj_` block that an interrupt (or the other CPU) can clobber; not interrupt-reentrant / SMP-safe (§3-3.24). Prefer passing `borrow`/pointer, or a primitive handle.
--> src/main.lpfree:18:1
|
18 | @interrupt(arch: "x86_64", has_error_code: false)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
error[E_INTERRUPT_SAFE_VIOLATION]: E_INTERRUPT_SAFE_VIOLATION: interrupt-context function calls non-@interruptSafe function `logTick` — annotate `logTick` `@interruptSafe`, or do not call it from interrupt context
--> src/main.lpfree:21:5
|
21 | logTick(n)
| ^^^^^^^^^^
|
The two fixes are the ones the message names. If logTick really is safe in interrupt context — no locks, no allocation, short — audit it and mark it @interruptSafe; its own body is then checked by the same rule. If it is not (and a console logger that takes a lock is not), do not call it from the handler: record the event, and let the main loop do the logging.
var ticks: uint64 = 0
@interruptSafe
func ackTimer(): void {
asm { "out dx, al" in: "dx" = 0x20u16, "al" = 0x20u8 }
}
@interrupt(arch: "x86_64", has_error_code: false)
func timerIsr(frame: borrow InterruptFrame): void {
let _old = atomicFetchAddU64(addressOf(ticks), 1u64, 5)
ackTimer()
}
# Main loop: does the slow work the handler must not.
func logNewTicks(lastSeen: uint64): uint64 {
let now = atomicLoadU64(addressOf(ticks), 2)
# ... print `now - lastSeen` ticks to the console here ...
return now
}
Listing 16-5: A handler that only calls @interruptSafe code
Locals in interrupt context need onstack
Freestanding code places an aggregate local — a struct, a class instance or a buffer — in a block the compiler chooses, and unless that block is in the stack frame it is a per-function static shared by every call. An interrupt that lands while the same function is running would find that block half-written. Inside interrupt-context code this is an error:
@mode(freestanding)
var samples: uint64 = 0
@interruptSafe
func averageOfFour(base: uint64): uint64 {
var window: buffer[uint64, 4];
window[0] = base
window[1] = base + 1u64
window[2] = base + 2u64
window[3] = base + 3u64
return (window[0] + window[1] + window[2] + window[3]) / 4u64
}
error[E_INTERRUPT_SAFE_STAGING]: E_INTERRUPT_SAFE_STAGING: interrupt-context function `averageOfFour` declares the aggregate local `window` (a `buffer` local) without `onstack` — freestanding stages it in a per-function STATIC block that every invocation shares, so an interrupt landing inside this frame can find it half-written (§3-3.24.1). Declare it `onstack window` (§3-3.17c) to get a per-invocation frame block; the build then also proves the address cannot escape.
--> src/main.lpfree:7:5
|
7 | var window: buffer[uint64, 4];
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
Declaring the local onstack gives each call its own frame block, and the compiler then also proves that the block's address never escapes the function (E_ONSTACK_ESCAPES otherwise; Language Reference §3-3.17c):
@interruptSafe
func averageOfFour(base: uint64): uint64 {
onstack var window: buffer[uint64, 4];
window[0] = base
window[1] = base + 1u64
window[2] = base + 2u64
window[3] = base + 3u64
return (window[0] + window[1] + window[2] + window[3]) / 4u64
}
Listing 16-6: An onstack buffer in interrupt-context code
Shared variables
The Language Reference also forbids an @interruptSafe body from reading or writing a module-level variable except through atomic operations. The 1.25 checker does not enforce that part, so it is a rule you keep: a variable that both a handler and the rest of the kernel touch is accessed only with the atomic intrinsics (atomicLoadU64, atomicFetchAddU64 and the rest, chapter 19), as ticks is in Listing 16-5, or only while interrupts are masked, as in the next section.
InterruptGuard: masking interrupts around shared data
On a single core, the simplest way to keep a handler out of a data structure is to mask interrupts while you change it. InterruptGuard.disable() saves the current interrupt state and masks interrupts; used in a with block, it restores the saved state when the block ends (§3-3.24.2). The instructions are chosen per architecture: cli on x86_64, the DAIF bits on AArch64, mstatus.MIE on RISC-V, PRIMASK on Cortex-M.
Here is a natural first attempt at a small event log shared with a handler:
@mode(freestanding)
var eventCount: uint64 = 0
var events: buffer[uint64, 16];
func recordEvent(code: uint64): void {
let slot = eventCount % 16u64
with (g = InterruptGuard.disable()) {
events[slot as int] = code
eventCount = eventCount + 1u64
}
}
error[E0003]: expected `;`
--> src/main.lpfree:8:41
|
8 | with (g = InterruptGuard.disable()) {
| ^
|
error[E0001]: expected expression
--> src/main.lpfree:12:1
|
12 | }
| ^
|
The error is not about interrupts at all. with is also an infix operator that copies a struct with some fields changed (p with (x = 3)), and the line before ends without a ;. The parser therefore reads eventCount % 16u64 with (g = …) as one expression and then finds a { it cannot place. End the previous statement with ;, or make the with block the first statement. Reading eventCount inside the guarded block also closes a race the first attempt had:
var eventCount: uint64 = 0
var events: buffer[uint64, 16];
func recordEvent(code: uint64): void {
with (g = InterruptGuard.disable()) {
let slot = eventCount % 16u64
events[slot as int] = code
eventCount = eventCount + 1u64
}
}
Listing 16-7: Updating data shared with a handler under InterruptGuard
Nested critical sections
A guarded function that calls another guarded function nests two guards. Each guard restores what it saved, so the inner one restores "masked" and nothing breaks, but the Language Reference still flags lexically nested guard blocks:
var eventCount: uint64 = 0
func recordTwo(): void {
with (outer = InterruptGuard.disable()) {
eventCount = eventCount + 1u64;
with (inner = InterruptGuard.disable()) {
eventCount = eventCount + 1u64
}
}
}
warning[E_INTERRUPT_GUARD_NESTED]: E_INTERRUPT_GUARD_NESTED: an `InterruptGuard` scope nested in another — the inner guard's release restores interrupts before the outer guard exits; use `irqSaveDisable()` / `irqRestore()` for nested critical sections (§3-3.24.2)
--> src/main.lpfree:8:9
|
8 | with (inner = InterruptGuard.disable()) {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
For code that may be entered with interrupts already masked — a helper called both from guarded and unguarded paths — use the intrinsic pair the guard is built from. irqSaveDisable() masks interrupts and returns a token describing the previous state (bit 0x200 set means "they were enabled"); irqRestore(token) puts back exactly that state:
var eventCount: uint64 = 0
var events: buffer[uint64, 16];
# Safe to call with interrupts enabled or already masked.
func recordEvent(code: uint64): void {
let token = irqSaveDisable()
let slot = eventCount % 16u64
events[slot as int] = code
eventCount = eventCount + 1u64
irqRestore(token)
}
Listing 16-8: The save/restore pair for critical sections that may nest
Restore the token on every path out of the function, explicitly: a defer block is accepted but never runs in Loup 1.25 (chapter 4).
Masking interrupts protects against handlers on this CPU only. With more than one core, add a lock (chapters 19 and 24). On Cortex-M cores InterruptGuard is also the tool for multi-word data, because those cores have no 64-bit atomics and the compiler refuses them there.
Rules of thumb
- Keep handlers short. Acknowledge the device and the interrupt controller, record what happened, return. Do the rest in the main loop.
- Never allocate in a handler. The allocator may be what the interrupted code was running.
- Mind the stack. A handler runs on the interrupted stack unless you arrange otherwise (x86_64 IST entries, a separate exception stack).
[freestanding] stack_check(chapter 22) chargesinterrupt_nestinghandler activations to every entry's stack budget. - Do not print from a handler. Serial output is slow and the console code is usually not interrupt-safe.
Summary
@interrupt(arch: "x86_64", has_error_code: …)(orarch: "aarch64"/"riscv64") makes a function an interrupt entry: a trampoline saves registers, passesframe: borrow InterruptFrame(plus the error code on x86_64) and returns with the interrupt-return instruction.- Declare a
@repr(C) struct InterruptFramewith your architecture's layout. Ignore the 1.25 "marshals a struct by value" advisory on the frame parameter. - Install handlers yourself: on x86_64, build IDT gates in a
bufferglobal, get the handler address withlea, load the table withlidt. @nakedfunctions are assembly-only (E_NAKED_NON_ASM) and need@unsafe("naked: …")(E_NAKED_NOT_UNSAFE).@callconvselects a calling convention; misspellings are not caught.@interruptSafeand the colour rule (E_INTERRUPT_SAFE_VIOLATION) check a handler's call tree; aggregate locals in interrupt context must beonstack(E_INTERRUPT_SAFE_STAGING). Access shared variables atomically — the checker does not enforce that yet.InterruptGuard.disable()in awithblock masks interrupts and restores the saved state; put a;before awithstatement. For nesting, useirqSaveDisable()/irqRestore(token).- Not enforced in 1.25:
E_INTERRUPT_NONVOID,E_INTERRUPT_DIRECT_CALL, atomic-only access to shared variables,@unsafeon@callconv(naked).
Next: entry points and panics.