Chapter 17: Entry Points and Panic
A freestanding program has two special functions. The entry point is the first Loup code the CPU runs: there is no C runtime and no main, only an address the boot loader or the reset vector jumps to. The panic handler is the last: it runs when the program reaches a state it cannot continue from. This chapter covers:
@entryand the rules for the entry function;- getting from a bare entry stub to an ordinary
kernelMain; - what counts as a panic in Loup 1.25, and what to write instead of
panic(...); - the panic policy in the manifest (
[build] panic); - writing a
@panic_handler.
@entry
@entry marks the function the linker uses as the image's entry symbol (Language Reference §3-3.5). It is the root of the image: the compiler keeps the entry, every @section function, every @interrupt handler, and whatever they reach, and drops everything else.
@entry does not imply @naked. If the code that jumps to your entry has already set up a stack, a plain function works. If it has not — the usual case for a kernel's first instruction — the entry must be naked, and its assembly sets the stack pointer before calling into Loup code. Here is the entry of a kernel for QEMU's AArch64 virt machine, which starts the image at EL1 with no stack:
@mode(freestanding)
# QEMU `virt` (aarch64): the image is linked at 0x40080000 and entered at EL1
# with the MMU off and no stack.
@entry
@naked
@unsafe("naked: runs before any stack exists; sets sp from the linker script, then calls kernelMain")
@section(".text.boot")
func _start(): void {
asm {
"adrp x0, __stack_top"
"add x0, x0, :lo12:__stack_top"
"mov sp, x0"
"bl kernelMain"
"1: wfe"
"b 1b"
}
}
func kernelMain(): void {
# ... bring up the console, the exception vectors, the timer ...
while (true) {
asm { "wfi" }
}
}
Listing 17-1: A naked entry stub that sets up the stack and calls kernelMain
__stack_top is a symbol the linker script defines (chapter 18). @section(".text.boot") lets the linker script put this code first, at the address the loader jumps to. The loop after bl kernelMain is a safety net; kernelMain should never return.
The manifest names the same symbol for the linker:
[linker]
script = "scripts/linker.ld"
entry = "_start"
If the linked image has no entry symbol, the build fails (E-BUILD-NO-ENTRY) instead of producing an image the CPU would jump into at an arbitrary address.
On x86_64, qemu-system-x86_64 -kernel starts a Multiboot2/PVH image in 32-bit protected mode, so the entry stub there also has to build page tables and switch to 64-bit long mode before it can call Loup code. Chapter 21 shows that stub in full.
Rules for the entry
There is exactly one @entry per program:
@mode(freestanding)
@entry
func bootPrimary(): void {
}
@entry
func bootSecondary(): void {
}
error[E0001]: E_ENTRY_DUPLICATE: at most one `@entry` function per compilation unit; `bootSecondary` is the second `@entry` (§3-3.6)
--> src/main.lpfree:7:1
|
7 | @entry
| ^^^^^^
|
Secondary CPUs on a multi-core system do not get a second @entry. They start at an address the primary CPU gives the firmware or the interrupt controller (an AP trampoline on x86_64, PSCI CPU_ON on AArch64), which is an ordinary @section function or naked stub.
The entry must also be a module-level function; on a method @entry is E_ANNOTATION_TARGET. The Language Reference further requires it to take no parameters and return void (E_ENTRY_PARAMS, E_ENTRY_NONVOID); the 1.25 checker does not report those two, so keep them yourself. Boot information that a loader passes in registers (a Multiboot info pointer in ebx, a device tree address in x0) is saved by the naked stub into a module global before it calls Loup code.
The Language Reference also lets you name the exported symbol differently from the function. That form is not implemented:
@mode(freestanding)
@entry(symbol: "Reset_Handler")
func reset(): void {
}
error[E_ANNOTATION_ARGS]: E_ANNOTATION_ARGS: @entry does not take arguments
--> src/main.lpfree:3:1
|
3 | @entry(symbol: "Reset_Handler")
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
⚠ Planned.
@entry(symbol: "…")(Language Reference §3-3.5) is rejected by the 1.25 checker. Name the function after the symbol your loader or vector table expects (_start,Reset_Handler), and give the same name to[linker] entry.
From the entry to kernelMain
Keep the naked stub as small as possible and do everything else in ordinary Loup code. kernelMain can call functions, use locals and structs, and loop; it cannot return, because there is nothing to return to.
Module-level variables with initializers that are computed at run time (anything beyond a constant) are set up by a module initializer. The compiler runs it once, at the start of the function your entry stub calls, before that function's own code. So by the time kernelMain's first statement runs, every module global holds its declared value. Code that runs before that point — the naked stub itself, or anything it calls other than kernelMain — must not rely on module globals with computed initializers.
Panics in Loup 1.25
A panic is a stop the compiler inserts for a condition the program cannot continue from. In 1.25 two constructs produce one:
- reaching an
unreachableexpression; - an out-of-range runtime index into a
buffer[T, N]when the manifest has[freestanding] bounds = "trap"(chapter 23).
The Language Reference adds more panic sources — an explicit panic("message") call, Option.unwrap() on None, and integer overflow under overflow = "trap" — but they are not available in freestanding code in 1.25. In particular, panic itself is not defined:
@mode(freestanding)
func pageFrame(index: uint64, frames: uint64): uint64 {
if (index >= frames) {
panic("page frame index out of range")
}
return index * 4096u64
}
error[E0001]: undefined identifier `panic`
--> src/main.lpfree:5:9
|
5 | panic("page frame index out of range")
| ^^^^^
|
⚠ Planned.
panic(msg): noreturn(Language Reference §3-3.6, stdlib reference §21.13) is not resolved by Stage0 in any mode. Overflow trapping (overflow = "trap") is accepted in the manifest but has no effect (chapter 3).
What to write instead is a small noreturn function of your own that reports the message and then reaches unreachable, so the stop goes through the package's panic policy like any other panic:
# Print a fatal message, then stop through the package's panic policy.
func fatal(msg: string): noreturn {
uartPuts("fatal: ")
uartPuts(msg)
uartPuts("\n")
unreachable
}
func pageFrame(index: uint64, frames: uint64): uint64 {
if (index >= frames) {
fatal("page frame index out of range")
}
return index * 4096u64
}
Listing 17-2: A fatal helper that reports and then panics
A noreturn function must not have a path that completes normally (E_NORETURN_FALLTHROUGH); ending it with unreachable satisfies that. Exceptions are not an alternative: throw and catch (T as e) are E_FREESTANDING_EXCEPTIONS (chapter 9).
The panic policy
What happens at a panic is decided per package by one manifest key:
[build]
panic = "halt" # "halt" (the default) | "abort" | "custom"
| Policy | What a panic does | Handler called? |
|---|---|---|
halt |
masks interrupts and parks the core in a wait-for-interrupt loop it never leaves | no |
abort |
executes the architecture's trap instruction (ud2 on x86_64, brk on AArch64, udf on Arm, unimp on RISC-V) |
no |
custom |
calls your @panic_handler; if the handler returned, the trap of abort follows |
yes |
The halt loop is cli + 1: hlt; jmp 1b on x86_64, msr daifset, #0xf + 1: wfi; b 1b on AArch64, csrci mstatus, 8 + 1: wfi; j 1b on RISC-V, and cpsid i + 1: wfi; b 1b on Cortex-M (Language Reference §3-3.6, ruling PANIC-1).
Three points that are easy to get wrong:
- An absent key means
halt(since 1.25.0). Before that, a program with a handler gotcustomimplicitly. Now onlypanic = "custom"calls the handler: a program that declares a@panic_handlerand leaves the key out halts at a panic and never runs the handler, and nothing warns you. - There is no
--panic=flag. The policy lives in the manifest only. - The value is checked. A misspelled policy is an error, not a silent default:
panic = "explode"givesinvalid [build].panic 'explode'; must be one of: halt, abort, custom(chapter 2 shows the full report).
halt is a good choice for production images and early board bring-up: the machine stops cleanly and does no further damage. abort suits debugging with an attached debugger, which stops on the trap with the faulting address and registers intact. custom is what you want once you have a console to report on.
Writing a @panic_handler
With panic = "custom", every panic calls the function marked @panic_handler with a message, the source file and the line of the construct that panicked. The file is relative to the directory of the entry file (main.lpfree, drv/table.lpfree). The message is fixed by the kind of panic: reached an `unreachable` expression for unreachable, buffer index out of range for a bounds guard.
The handler's signature is fixed, and its return type is noreturn, not void: returning from a panic handler would resume the code that just failed. A C or Rust habit gives void:
@mode(freestanding)
@panic_handler
func kernelPanic(msg: string, file: string, line: int): void {
while (true) {
asm { "cli" "hlt" }
}
}
src/main.lpfree:1:1: error: E_PANIC_HANDLER_SIG: `@panic_handler` `kernelPanic` must have one of the two normative signatures — `(msg: string, file: string, line: int): noreturn` or `(msg: string, file: string, line: int, source: PanicSource): noreturn` — it returns `void`; a panic handler must be `noreturn`, because returning from it resumes into the fault it was called to report (§3-3.6)
(The one-line --format short form again: as with E_NAKED_NOT_UNSAFE, the 1.25 checker anchors this diagnostic at the file's first line.) Changing void to noreturn is not quite enough if the loop that never exits is written in assembly:
@mode(freestanding)
@panic_handler
func kernelPanic(msg: string, file: string, line: int): noreturn {
asm { "cli" "1: hlt" "jmp 1b" }
}
src/main.lpfree:1:1: error: E_NORETURN_FALLTHROUGH: `kernelPanic` is declared `noreturn` but has a path that completes normally — every path must diverge (`throw`, a call to another `noreturn` function, or a loop with no reachable exit)
The compiler does not read assembly to decide whether a function returns, so the loop must be a Loup loop with the assembly inside it. Here is a complete handler for the RISC-V virt machine, which masks interrupts, reports the message, file and line on the UART, and parks the core:
# Decimal output without a buffer: find the largest power of ten first.
func uartPutDec(n: uint64): void {
var divisor: uint64 = 1u64
while (n / divisor >= 10u64) {
divisor = divisor * 10u64
}
var rest = n
while (divisor > 0u64) {
uartPutc((0x30u64 + rest / divisor) as uint8)
rest = rest % divisor
divisor = divisor / 10u64
}
}
@panic_handler
func kernelPanic(msg: string, file: string, line: int, source: int): noreturn {
asm { "csrci mstatus, 8" } # RISC-V machine mode: mask interrupts
uartPuts("\nPANIC: ")
uartPuts(msg)
uartPuts(" at ")
uartPuts(file)
uartPutc(0x3Au8) # ':'
uartPutDec(line as uint64)
uartPuts("\n")
while (true) {
asm { "wfi" }
}
}
Listing 17-3: A serial panic handler
The fourth parameter is optional. When present it receives a number saying what kind of panic this is: 1 for a bounds guard, 6 for unreachable. The Language Reference declares these values as an enum, PanicSource, which does not exist yet:
⚠ Planned.
enum PanicSource { Assertion, BoundsCheck, OverflowTrap, UnwrapNone, StackOverflow, Unknown, Unreachable }(Language Reference §3-3.6). Writingsource: PanicSourcein 1.25 givesundefined type 'PanicSource'; writesource: intand compare against the variant numbers (0 to 6, in that order).
Guidelines for the handler itself:
- Mask interrupts first. A handler that is interrupted may never finish its report.
- Use little stack. The panic may have been a stack problem. No recursion, no large locals, no deep call chains — which is why
uartPutDecabove uses a loop instead of a buffer or recursion. - Call only code you trust not to panic. A panic inside the handler is undefined behaviour (the Language Reference specifies a
double_faultpolicy for it; the manifest key is accepted in 1.25 but has no effect). Do not index buffers with computed indexes here. - Write directly to the hardware. Do not take the console lock the panicking code may be holding; go straight to the UART registers, as Listing 17-3 does.
Under halt and abort a declared handler is never called, and the image does not contain it. Under custom without a handler, unreachable stops with the trap and bounds = "trap" guards are not emitted at all — declare the handler whenever you set custom.
Summary
@entrymarks the single entry function; it is the image's reachability root. One per program (E_ENTRY_DUPLICATE), module level only (E_ANNOTATION_TARGET), no parameters andvoid(by rule; not checked in 1.25).- An entry that runs without a stack is
@naked+@unsafeand sets the stack pointer before callingkernelMain;[linker] entrynames the same symbol.@entry(symbol: …)is planned. - Module initializers run once at the start of the function the entry calls.
- In 1.25, panics come from
unreachableandbounds = "trap"guards.panic()is undefined; write anoreturnhelper that reports and reachesunreachable. [build] panic = "halt" | "abort" | "custom"; absent meanshalt; onlycustomcalls the handler; there is no--panic=flag.@panic_handler func h(msg: string, file: string, line: int): noreturn, optionally withsource: int.voidisE_PANIC_HANDLER_SIG; the body must diverge with a Loup loop (E_NORETURN_FALLTHROUGH).PanicSourceis planned.
Next: sections and the linker.