The Loup Freestanding Book — contents
  1. Chapter 1: What Is Freestanding Mode?
  2. Chapter 2: Building a Bare-Metal Binary
  3. Chapter 3: Integer Types and Wrapping Arithmetic
  4. Chapter 4: Functions and Control Flow
  5. Chapter 5: Structs and Layout Control
  6. Chapter 6: Enums and Pattern Matching
  7. Chapter 7: Classes and Allocators
  8. Chapter 8: Interfaces and Generics
  9. Chapter 9: Error Handling with Result
  10. Chapter 10: Ownership Verbs
  11. Chapter 11: Borrow and Lifetimes
  12. Chapter 12: Unique Ownership
  13. Chapter 13: Memory Primitives
  14. Chapter 14: Inline Assembly
  15. Chapter 15: MMIO with Volatile
  16. Chapter 16: Interrupts and Naked Functions
  17. Chapter 17: Entry Points and Panic
  18. Chapter 18: Sections and the Linker
  19. Chapter 19: Multicore and Atomics
  20. Chapter 20: The Available Standard Library Subset
  21. Chapter 21: Example — x86_64 Kernel Stub
  22. Chapter 22: Testing and Deploying Firmware
  23. Chapter 23: Memory Safety and Kernel Development Patterns
  24. Chapter 24: synchronized Classes and Lock Ordering

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 the InterruptFrame they receive;
  • installing a handler in the x86_64 interrupt descriptor table;
  • @naked functions, whose bodies are pure assembly, and @callconv;
  • @interruptSafe, which makes the compiler check everything a handler calls;
  • InterruptGuard and the irqSaveDisable/irqRestore pair 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 InterruptFrame a built-in whose fields follow --arch. In 1.25 the built-in only has the x86_64 fields (typed int), so the boot-tested kernels declare a @repr(C) struct InterruptFrame with 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:

This code does not compile
@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:

This code does not compile
@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 @naked is E_ANNOTATION_TARGET.
  • @callconv(naked) means the same thing as @naked. The Language Reference requires @unsafe with 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-format flag, so it cannot produce a UEFI .efi image 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.

This code does not compile
@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:

This code does not compile
@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:

This code does not compile
@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) charges interrupt_nesting handler 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: …) (or arch: "aarch64" / "riscv64") makes a function an interrupt entry: a trampoline saves registers, passes frame: borrow InterruptFrame (plus the error code on x86_64) and returns with the interrupt-return instruction.
  • Declare a @repr(C) struct InterruptFrame with 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 buffer global, get the handler address with lea, load the table with lidt.
  • @naked functions are assembly-only (E_NAKED_NON_ASM) and need @unsafe("naked: …") (E_NAKED_NOT_UNSAFE). @callconv selects a calling convention; misspellings are not caught.
  • @interruptSafe and the colour rule (E_INTERRUPT_SAFE_VIOLATION) check a handler's call tree; aggregate locals in interrupt context must be onstack (E_INTERRUPT_SAFE_STAGING). Access shared variables atomically — the checker does not enforce that yet.
  • InterruptGuard.disable() in a with block masks interrupts and restores the saved state; put a ; before a with statement. For nesting, use irqSaveDisable()/irqRestore(token).
  • Not enforced in 1.25: E_INTERRUPT_NONVOID, E_INTERRUPT_DIRECT_CALL, atomic-only access to shared variables, @unsafe on @callconv(naked).

Next: entry points and panics.