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

  • @entry and 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:

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

This code does not compile
@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 unreachable expression;
  • 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:

This code does not compile
@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 got custom implicitly. Now only panic = "custom" calls the handler: a program that declares a @panic_handler and 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" gives invalid [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:

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

This code does not compile
@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). Writing source: PanicSource in 1.25 gives undefined type 'PanicSource'; write source: int and 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 uartPutDec above 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_fault policy 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

  • @entry marks 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 and void (by rule; not checked in 1.25).
  • An entry that runs without a stack is @naked + @unsafe and sets the stack pointer before calling kernelMain; [linker] entry names 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 unreachable and bounds = "trap" guards. panic() is undefined; write a noreturn helper that reports and reaches unreachable.
  • [build] panic = "halt" | "abort" | "custom"; absent means halt; only custom calls the handler; there is no --panic= flag.
  • @panic_handler func h(msg: string, file: string, line: int): noreturn, optionally with source: int. void is E_PANIC_HANDLER_SIG; the body must diverge with a Loup loop (E_NORETURN_FALLTHROUGH). PanicSource is planned.

Next: sections and the linker.