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
@sectionfunction is always kept. Like the@entryfunction and@interrupthandlers, 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
@sectionvariable 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:
@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")
| ^^^^^^^^^^^^^^^^^^^^^^^^^
|
@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 = 8388608fails 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 = trueshrinks 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@entryfunction..text.bootcomes first, at the load address, so the CPU starts in the naked stub..vectorscollects@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-sectionsfrom discarding a section nothing refers to..bssholds zero-initialized data, including@section(".bss.pagetables"), between two symbols the boot code uses.- The stack follows the image;
__stack_topis 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:
@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-levelvar/letin an ELF section; such functions and variables are always kept. On a local or aconstit isE_ANNOTATION_TARGET.[linker] script,entryandmapconnect the build to the linker; paths are relative to the manifest.max_image_sizefails an oversized image;size_optshrinks frame zeroing.- The linker script decides addresses and defines symbols (
__stack_top,__bss_start,__bss_end) that assembly can name; Loup code reads them throughasm. - Zero
.bssin 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
@sectionfunction.
Next: multi-core code and atomics.