Chapter 2: Building a Bare-Metal Binary
This chapter is the workflow you repeat for every freestanding project: lay out the files, write the manifest and linker script, type-check, build an image for a target architecture, and boot it in QEMU. It builds the hello-world program from chapter 1 for QEMU's AArch64 virt machine.
The @mode(freestanding) directive
Every freestanding source file declares its mode as its first token; only comments may come before it (Language Reference §1-2.5.1, ruling MODE-2):
# Serial console driver.
@mode(freestanding)
const UART0_BASE: uint64 = 0x0900_0000u64
Listing 2-1: The directive after a leading comment
Anything else before the directive is an error:
# Serial console driver.
const COM1: uint16 = 0x3F8u16
@mode(freestanding)
func port(): uint16 { return COM1 }
error[E_MODE_POSITION]: E_MODE_POSITION: `@mode` must be the first token of the file — only comments and `@compiled` may precede it (§1-2.5.1)
--> src/main.lpfree:3:1
|
3 | @mode(freestanding)
| ^^^^^^^^^^^^^^^^^^^
|
A second directive in the same file is E_MODE_DUPLICATE. Two silent mistakes are worth knowing about, because the compiler does not report them: a file with no directive falls back to its extension and the project's [build] mode, and a misspelled or retired mode name — @mode(native), @mode(bare) — is treated as host.scripted without any diagnostic. Name the file .lpfree and spell the directive exactly @mode(freestanding).
A freestanding program is homogeneous. A freestanding file may import other freestanding files of the program and the few freestanding standard-library modules (chapter 20), and may call C code declared with extern native "library" func …. It may not import host.* or web.* modules — the import is rejected, as chapter 1 showed for core.math — and hosted code may not import freestanding modules either ("@mode(host.native) module cannot import @mode(freestanding) module").
Project layout
hello/
├── hello.lppkg # the manifest
├── kernel.ld # the linker script
└── src/
└── main.lpfree # Listing 1-2
The manifest is TOML, named <package>.lppkg (or package.lppkg). loup-bootstrap init scaffolds a hosted project; for a freestanding one, write the manifest by hand.
The manifest
name = "hello"
version = "0.1.0"
[build]
entry = "src/main.lpfree"
target = "freestanding" # also spelled `delivery`
mode = "freestanding"
arch = "aarch64"
panic = "halt" # halt (the default) | abort | custom — chapter 17
[linker]
script = "kernel.ld"
entry = "_start" # matches the @entry function
[freestanding]
stack_size = 65536 # bytes; the linker script reserves the stack
Listing 2-2: hello.lppkg
The keys the 1.25 toolchain acts on:
| Section | Key | Meaning |
|---|---|---|
[build] |
entry, mode, target/delivery, arch |
the root file, its mode, "freestanding", the CPU architecture |
[build] |
panic |
what a panic does: "halt" (default), "abort", "custom" (calls your @panic_handler) — chapter 17 |
[build] |
separate |
one cached object per package instead of one per program (LLVM lanes only) |
[linker] |
script, entry |
the linker script and the entry symbol |
[linker] |
map, size_opt, max_image_size |
write a link map; shrink zero-initialization prologues; fail the build if the image is larger than this many bytes (E-BUILD-IMAGE-SIZE) |
[freestanding] |
stack_size, stack_check, interrupt_nesting |
the stack budget and the static worst-case stack check ("off", "report", "enforce"), which proves a bound on stack use from the call graph |
[freestanding] |
bounds |
"trap" adds run-time index checks to buffers — chapter 13 |
[freestanding] |
overlays, rawpoke |
how strictly unacknowledged raw-memory views are reported — chapter 13 |
[freestanding] |
relocatable |
build a position-independent user program a kernel can load anywhere |
Relative paths are resolved against the directory that holds the manifest.
⚠ Planned. The Language Reference also lists
[freestanding] no_float,overflow,no_simd,double_faultandendianness(§3-3.21). The 1.25 toolchain accepts these keys and ignores them:no_float = truedoes not reject floating-point code, andoverflow = "trap"does not make arithmetic trap (chapter 3).
An invalid value for a key the checker reads is reported against the project. With panic = "explode" in [build]:
error[E0001]: invalid [build].panic 'explode'; must be one of: halt, abort, custom
--> src/main.lpfree:4:1
|
4 |
| ^^^^^^^^^^^^^^^^^
|
The source location underneath is not meaningful — the mistake is in the manifest. The build driver checks the other enumerated keys (bounds, stack_check, overlays, rawpoke) the same way: a misspelled safety switch fails the build instead of silently meaning "off".
The linker script
QEMU's AArch64 virt machine has RAM at 0x4000_0000 and loads a -kernel ELF at its link address. This script links the image at 0x4008_0000, puts .text.boot (where _start lives) first, and reserves a 64 KiB stack after .bss, ending at __stack_top:
ENTRY(_start)
PHDRS {
text PT_LOAD FLAGS(7);
}
SECTIONS {
. = 0x40080000;
.text.boot : { KEEP(*(.text.boot)) } :text
.text : { *(.text .text.*) } :text
.rodata : { *(.rodata .rodata.*) } :text
.data : { *(.data .data.*) } :text
.bss : {
*(.bss .bss.*)
. = ALIGN(16);
. = . + 65536;
__stack_top = .;
} :text
}
Listing 2-3: kernel.ld for QEMU virt (AArch64)
Chapter 18 explains sections and linker scripts in detail. One rule matters already: the @entry function is the root of the image. The build keeps _start, every @section function, and whatever they reach, and drops everything else.
Type-checking
loup check type-checks the whole project without building it, on any platform the toolchain runs on:
cd hello
loup check
It prints nothing and exits 0 when the program is well-typed. On Linux x86-64, where the installed loup is the self-hosted driver, run loup-bootstrap check instead. --format short prints one line per diagnostic; --format json prints machine-readable records.
Building
loup build # target, arch and options from the manifest
loup build --target freestanding --arch aarch64 # or name them on the command line
The image is written to ./hello — the package name — unless you pass -o PATH. Other useful flags: --release (an optimized build), --debug, --backend llbe|llvm, --stack-report/--stack-enforce (print, or enforce, the static stack budget for one build), --no-cache.
Freestanding builds run on Linux x86-64 and need ld.lld on the PATH (the toolchain installer warns if it is missing). They are not available on macOS or Windows; on those platforms you can type-check freestanding code but not link an image.
There are two back ends:
llbe— the default: Loup's own clang-free chain.loup-llvmemits LLVM IR text,loup-llbeturns it into machine code and an ELF object itself, andld.lldlinks the image. No C compiler is involved.llvm(--backend llvm) — the same IR compiled byclang. Use it for a unit thatllbe's tier for your architecture cannot lower yet;llberefuses such code by name rather than miscompiling it.
One exception to the default today: for an x86_64 package with a [linker] script (the "kernel route", which is how every x86_64 kernel is built) the self-hosted driver uses the llvm lane unless you pass --backend llbe. A build always runs the lane you name; it never silently substitutes another (E_BACKEND_UNAVAILABLE).
Booting in QEMU
qemu-system-aarch64 -M virt -cpu cortex-a53 -m 256M -nographic -kernel ./hello
Hello from Loup freestanding
-nographic connects the UART to your terminal; press Ctrl-A, then x, to quit. -s -S makes QEMU wait for GDB on port 1234 (chapter 22).
Other architectures
The same source tree builds for another CPU by changing arch — plus whatever is architecture-specific: the boot code in _start, the device addresses, the asm blocks, and the linker script.
--arch |
Status in 1.25 |
|---|---|
x86_64 |
Boot-verified under QEMU; the reference target |
aarch64 |
Boot-verified under QEMU |
riscv64 |
Boot-verified under QEMU |
armv7 (also arm) |
Boot-verified under QEMU on both back ends |
thumbv8.1m.main, thumbv8m.main, thumbv7em, thumbv6m |
Cortex-M; boot-verified (mps3-an547) |
riscv32 |
Boot-verified through the llvm back end; llbe cannot yet link cross-function calls |
x86 (32-bit) |
C/LLVM path only; not boot-verified |
loongarch64, mips64el, powerpc64le |
Encoder-level only; no image has been booted |
s390x |
Specified, not implemented |
An architecture outside this list is rejected rather than encoded for a different CPU. The QEMU machines the toolchain's own boot tests use:
# x86_64 — the image needs a Multiboot2 header and a PVH note (chapter 21)
qemu-system-x86_64 -kernel ./kernel -serial stdio -display none
# riscv64 — no firmware; link the image at 0x8000_0000
qemu-system-riscv64 -M virt -bios none -m 128M -nographic -kernel ./kernel
# armv7 (A-profile)
qemu-system-arm -M virt -cpu cortex-a15 -m 256M -nographic -kernel ./kernel
# Cortex-M55
qemu-system-arm -M mps3-an547 -nographic -kernel ./kernel
On x86_64, -kernel loads an ELF only if it carries a Multiboot header or a Xen PVH note, and the CPU starts in 32-bit protected mode — so an x86_64 kernel starts with a 32-bit trampoline that enables long mode. Chapter 21 builds one.
Image formats
The build produces an ELF image. For targets that need something else, convert it with standard tools:
llvm-objcopy -O binary ./hello hello.bin # a flat binary, e.g. for flashing
llvm-strip ./hello # drop the symbol table
⚠ Planned. The
--output-format elf|raw|peand--marchflags described in the Language Reference (§3-3.2) are not in the 1.25 command line, and there is no PE/COFF output for UEFI applications yet.
Keep symbols in development builds; for a release image, keep the link map ([linker] map = "kernel.map") alongside a stripped binary so crash addresses can still be resolved.
A small Makefile
KERNEL = hello
run: build
qemu-system-aarch64 -M virt -cpu cortex-a53 -m 256M -nographic -kernel ./$(KERNEL)
build:
loup build
check:
loup-bootstrap check
clean:
rm -rf $(KERNEL) .loup-cache
Summary
- Every
.lpfreefile starts with@mode(freestanding); a misplaced directive isE_MODE_POSITION, but a misspelled one is silent. - The manifest (
<name>.lppkg) sets[build] target/mode/arch/panic,[linker] script/entry, and the[freestanding]keys;no_float,overflowand a few others are accepted but not yet acted on. loup check(orloup-bootstrap checkon Linux x86-64) type-checks anywhere;loup build --target freestanding --arch …builds images on Linux x86-64 withld.lld, through the clang-freellbeback end by default.- x86_64, aarch64, riscv64, armv7 and Cortex-M images boot under QEMU; convert ELF to other formats with
llvm-objcopy.
Next: integer types and wrapping arithmetic.