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

This code does not compile
# 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_fault and endianness (§3-3.21). The 1.25 toolchain accepts these keys and ignores them: no_float = true does not reject floating-point code, and overflow = "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-llvm emits LLVM IR text, loup-llbe turns it into machine code and an ELF object itself, and ld.lld links the image. No C compiler is involved.
  • llvm (--backend llvm) — the same IR compiled by clang. Use it for a unit that llbe's tier for your architecture cannot lower yet; llbe refuses 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|pe and --march flags 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 .lpfree file starts with @mode(freestanding); a misplaced directive is E_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, overflow and a few others are accepted but not yet acted on.
  • loup check (or loup-bootstrap check on Linux x86-64) type-checks anywhere; loup build --target freestanding --arch … builds images on Linux x86-64 with ld.lld, through the clang-free llbe back 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.