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 18: Sections and the Linker

A freestanding image is a set of named sections that the linker places at addresses a linker script chooses. The boot stub has to sit where the loader jumps, the exception vectors where the CPU looks for them, the page tables on page boundaries, and the stack somewhere the entry stub can find. This chapter covers:

  • @section("name") on functions and module-level variables;
  • the [linker] table of the manifest;
  • a linker script for a small AArch64 kernel, and the symbols it gives your code;
  • zeroing .bss, per-CPU data, and data blobs such as a version record;
  • checking the result: the link map and max_image_size.

@section("name")

@section("name") puts a function, or a module-level var or let, into the named ELF section (Language Reference §3-3.12, ruling SECVAR-1). A section name is written as it appears in the object file: .text.boot, .rodata.version, .bss.pagetables.

@mode(freestanding)

# Boot-time page tables: their own section, page aligned.
@section(".bss.pagetables")
@align(4096)
var bootTables: buffer[uint64, 2048];       # four 4 KiB tables

# Per-CPU tick counters, kept together so the boot code can find them.
@section(".data.percpu")
var cpuTicks: buffer[uint64, 8];

# The idle loop in a section the linker script can place on its own.
@section(".text.idle")
func idle(): void {
    while (true) {
        asm { "wfi" }
    }
}

Listing 18-1: Placing functions and module variables in named sections

Two properties make @section reliable:

  • A @section function is always kept. Like the @entry function and @interrupt handlers, it is a root of the image, so a handler that nothing in Loup calls — reached only through a vector table or from assembly — is not dropped.
  • A @section variable is kept even if nothing reads it. The compiler pins it against dead-global elimination, because the linker script or a debugger may be its only reader.

@section applies to declarations that have a single, permanent location. A local variable lives in a stack frame, and a const has no storage at all, so both are rejected:

This code does not compile
@mode(freestanding)

func handleRequest(): uint64 {
    @section(".data.scratch")
    var scratch: uint64 = 0
    scratch = scratch + 1u64
    return scratch
}
error[E0001]: E_ANNOTATION_TARGET: `@section` does not apply to a local binding — it applies to a function or a module-level `var` / `let` (§3-3.12)
 --> src/main.lpfree:4:5
  |
4 |     @section(".data.scratch")
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^
  |
This code does not compile
@mode(freestanding)

@section(".rodata.limits")
const MAX_CPUS: int = 8
error[E0001]: E_ANNOTATION_TARGET: `@section` does not apply to a `const` binding — it applies to a function or a module-level `var` / `let` (§3-3.12)
 --> src/main.lpfree:3:1
  |
3 | @section(".rodata.limits")
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^
  |

Move the variable to module level if it really must live in that section, or drop the annotation. A const needs no section: it is folded into the code that uses it.

The Language Reference reserves E_SECTION_INVALID_NAME for names that are not valid section names; the 1.25 checker does not check names, so keep to letters, digits, . and _.

The [linker] table

Three manifest keys connect your source to the linker:

[linker]
script = "scripts/linker.ld"   # the linker script, relative to the manifest's directory
entry  = "_start"              # the entry symbol; the name of your @entry function
map    = "kernel.map"          # optional: write a link map

All paths are relative to the directory that holds the manifest, not to the directory you run loup build from. The llbe backend refuses to build a freestanding image without a script, and a kernel needs one anyway to control where its code and stack go.

Two more keys are useful once the image works:

  • max_image_size = 8388608 fails the build (E-BUILD-IMAGE-SIZE, with a per-section breakdown) if the linked image is larger than that many bytes — for images that must fit a flash part or a loader's staging area.
  • size_opt = true shrinks code by turning each function's slot-by-slot zeroing of its stack frame into one loop (chapter 24 has more on frame zeroing).

A linker script

Here is a complete script for an AArch64 kernel on QEMU's virt machine, matching the entry stub of Listing 17-1. RAM starts at 0x4000_0000 and the kernel is linked at 0x4008_0000:

/* QEMU `virt` (AArch64): RAM at 0x40000000, the image at 0x40080000. */
ENTRY(_start)

SECTIONS {
    . = 0x40080000;

    .text.boot : { KEEP(*(.text.boot)) }        /* _start: first in the image */

    . = ALIGN(2048);                            /* VBAR_EL1 needs 2 KiB alignment */
    .vectors : { KEEP(*(.text.vectors)) }

    .text : { *(.text .text.*) }

    .rodata : {
        KEEP(*(.rodata.version))
        *(.rodata .rodata.*)
    }

    .data : { *(.data .data.*) }

    . = ALIGN(4096);
    .bss : {
        __bss_start = .;
        *(.bss .bss.*)
        *(COMMON)
        . = ALIGN(16);
        __bss_end = .;
    }

    . = ALIGN(16);
    . = . + 65536;                              /* 64 KiB boot stack */
    __stack_top = .;
}

Listing 18-2: scripts/linker.ld for an AArch64 kernel

Walking through it:

  • ENTRY(_start) and [linker] entry = "_start" both name the @entry function.
  • .text.boot comes first, at the load address, so the CPU starts in the naked stub.
  • .vectors collects @section(".text.vectors") code on a 2 KiB boundary for the exception vector table (chapter 16).
  • *(.text .text.*) collects ordinary functions and named text sections such as .text.idle.
  • KEEP(...) stops --gc-sections from discarding a section nothing refers to.
  • .bss holds zero-initialized data, including @section(".bss.pagetables"), between two symbols the boot code uses.
  • The stack follows the image; __stack_top is its high end, because stacks grow down.

When [freestanding] stack_size is set (it is used by the stack budget check in chapter 22), make the script reserve the same amount.

Symbols from the linker script

Symbols such as __stack_top, __bss_start and __bss_end exist only in the linker's symbol table. In C you would declare them extern char __bss_start[];, and the Loup spelling that looks closest is a mistake:

This code does not compile
@mode(freestanding)

extern native let __bss_start: uint
error[E0001]: expected library name string after `extern native`
 --> src/main.lpfree:3:15
  |
3 | extern native let __bss_start: uint
  |               ^^^
  |

extern native declares a function in a named C library (chapter 20); there is no declaration form for a bare linker symbol. Assembly can name such symbols directly, which is how Listing 17-1 loads the stack pointer, so a Loup function that needs one as a number reads it through an asm block:

# The address the linker gave `__bss_start` (AArch64).
func bssStart(): uint64 {
    var a: uint64 = 0
    asm {
        "adrp x0, __bss_start"
        "add x0, x0, :lo12:__bss_start"
        out: a = "=x0"
    }
    return a
}

Listing 18-3: Reading a linker-script symbol

Zeroing .bss

Loup code assumes that zero-initialized module globals read as zero. When QEMU loads an ELF file with -kernel, it clears .bss for you; a raw binary written to flash, or an image copied by your own boot loader, gets no such help. Zero .bss in the entry stub, before calling any Loup function:

@entry
@naked
@unsafe("naked: no stack yet; zeroes .bss 8 bytes at a time, sets sp, calls kernelMain")
@section(".text.boot")
func _start(): void {
    asm {
        "adrp x0, __bss_start"
        "add x0, x0, :lo12:__bss_start"
        "adrp x1, __bss_end"
        "add x1, x1, :lo12:__bss_end"
        "1: cmp x0, x1"
        "b.hs 2f"
        "str xzr, [x0], #8"
        "b 1b"
        "2: adrp x0, __stack_top"
        "add x0, x0, :lo12:__stack_top"
        "mov sp, x0"
        "bl kernelMain"
        "3: wfe"
        "b 3b"
    }
}

func kernelMain(): void {
    while (true) {
        asm { "wfi" }
    }
}

Listing 18-4: An entry stub that clears .bss before running Loup code

The order matters. Module globals with computed initializers are assigned at the start of kernelMain (chapter 17); zeroing .bss from inside kernelMain would wipe them again. The script aligns __bss_start and __bss_end to keep the 8-byte stores in bounds.

Per-CPU data

Each CPU of a multi-core kernel usually has its own copy of some state: its current task, its run queue, its counters. One common layout puts the template in a section of its own and lets the boot code copy it once per CPU:

    . = ALIGN(64);
    .data.percpu : {
        __percpu_start = .;
        KEEP(*(.data.percpu))
        __percpu_end = .;
    }

At boot, allocate CPU count × (__percpu_end − __percpu_start) bytes, copy the template into each slot, and give each CPU a register that points at its slot (TPIDR_EL1 on AArch64, the GS base on x86_64). Code then reaches its own CPU's data relative to that register.

A simpler layout, enough for many kernels, is a module-level array indexed by CPU number, like cpuTicks in Listing 18-1: each CPU writes only its own element. If several CPUs' elements share a cache line, every write bounces the line between them; give each element a 64-byte slot (chapter 5's @align(64) struct) when that matters.

Data blobs: a version record

Sometimes the linker or an external tool needs raw bytes at a known place: a Multiboot header, an ELF note, a version string a flashing tool reads from the image. A @section variable is not the right tool for a string: a string global is a pointer and a length, so its section would hold the descriptor, not the characters. Instead, emit the bytes with assembler data directives from a naked function that is never called:

# A version record a flashing tool can extract from the ELF file.
@section(".rodata.version")
@naked
@unsafe("naked: data only, never called; the bytes of the version record")
func versionRecord(): void {
    asm {
        ".ascii \"mykernel 0.3.1\""
        ".byte 0"
    }
}

Listing 18-5: Emitting raw bytes into a section

This is how the boot-tested x86_64 kernels emit their Multiboot2 header (@section(".multiboot")) and their PVH ELF note (@section(".note.Xen")); chapter 21 shows both. Because it is a @section function, the record is kept in the image, and KEEP(*(.rodata.version)) in the script keeps the linker from discarding it. Extract it with your toolchain's objcopy:

llvm-objcopy -O binary --only-section=.rodata.version mykernel version.bin

Checking the layout

When an address comes out wrong, look at what the linker did. With map = "kernel.map" in [linker], the build writes a link map listing every output section, its address and size, and the input sections and symbols in it. llvm-readelf -S mykernel (sections) and llvm-nm -n mykernel (symbols sorted by address) answer the same questions from the image itself.

Loup module variables are emitted under compiler-generated, file-local names (on the LLVM lanes a variable ring becomes global_ring), and an @interrupt handler's body is <name>__isr_body behind the trampoline <name>. When you search a map or symbol listing, look for those names rather than the bare Loup identifiers; the section a variable was placed in is the more reliable thing to check.

Summary

  • @section("name") places a function or a module-level var/let in an ELF section; such functions and variables are always kept. On a local or a const it is E_ANNOTATION_TARGET.
  • [linker] script, entry and map connect the build to the linker; paths are relative to the manifest. max_image_size fails an oversized image; size_opt shrinks frame zeroing.
  • The linker script decides addresses and defines symbols (__stack_top, __bss_start, __bss_end) that assembly can name; Loup code reads them through asm.
  • Zero .bss in the naked entry stub, before any Loup code runs.
  • Per-CPU data: a template section copied per CPU, or a module-level array indexed by CPU.
  • Raw data at a known place (headers, notes, version records) comes from assembler directives in a naked @section function.

Next: multi-core code and atomics.