# 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`.

```loup
@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:

```text
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:

```loup
#~ @mode(freestanding)
#~
# 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`:

```loup
#~ @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
#~
#~ @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 }
#~ }
#~
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:

```loup,does_not_compile
@mode(freestanding)

@naked
func switchStacks(fromSp: uint, toSp: uint): void {
    asm {
        "mov [rdi], rsp"
        "mov rsp, [rsi]"
        "ret"
    }
}
```

```text
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:

```loup
#~ @mode(freestanding)
#~
# 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:

```loup,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"
    }
}
```

```text
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.

```loup,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)
}
```

```text
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.

```loup
#~ @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

@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:

```loup,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
}
```

```text
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):

```loup
#~ @mode(freestanding)
#~
@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:

```loup,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
    }
}
```

```text
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:

```loup
#~ @mode(freestanding)
#~
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:

```loup
#~ @mode(freestanding)
#~
var eventCount: uint64 = 0

func recordTwo(): void {
    with (outer = InterruptGuard.disable()) {
        eventCount = eventCount + 1u64;
        with (inner = InterruptGuard.disable()) {
            eventCount = eventCount + 1u64
        }
    }
}
```

```text
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:

```loup
#~ @mode(freestanding)
#~
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.
