Chapter 22: Testing and Deploying Firmware
A freestanding program has no test runner underneath it and no exceptions to report a failed assertion. Testing it takes several layers, each catching what the others cannot:
- host tests run pure logic — parsers, checksums, state machines — in the interpreter, in milliseconds, on any platform;
loup test --freestandingbuilds test functions into a real kernel and boots it under QEMU;- boot tests run your actual image under QEMU and check what it prints;
- static checks at build time bound stack use and image size;
- hardware runs catch timing, firmware and electrical problems no emulator models.
The chapter ends with debugging under QEMU and getting the image onto a device.
Host tests for freestanding logic
A host.scripted test can import a freestanding module and run it in the interpreter. (The other direction is closed: host.native and host.dual code cannot import freestanding modules.) So code that touches no hardware can be tested with the ordinary loup test workflow, on macOS and Windows as well as Linux.
Keep such code in its own module:
@mode(freestanding)
# The 8-bit sequence number of a serial frame: 255 is followed by 0.
@overflowWrapping
pub func nextSeq(seq: uint8): uint8 {
return seq + 1u8
}
# One step of the Internet checksum: add with end-around carry.
pub func onesComplementAdd(a: uint16, b: uint16): uint16 {
let sum = (a as uint32) + (b as uint32)
let folded = (sum & 0xFFFFu32) + (sum >> 16)
return folded as uint16
}
Listing 22-1: src/frames.lpfree, logic with no hardware access
and put the test beside it, named *_test.lpscr so loup test finds it, importing the module by name:
@mode(host.scripted)
from debug.assert import (expectEq);
from frames import (nextSeq, onesComplementAdd);
func test_sequence_wraps(): void {
expectEq(nextSeq(255u8), 0u8, "255 is followed by 0")
}
func test_checksum_carry(): void {
expectEq(onesComplementAdd(0xFFFFu16, 0x0001u16), 0x0001u16, "end-around carry")
}
Listing 22-2: src/frames_test.lpscr (it imports Listing 22-1, so it runs as a pair)
loup-bootstrap test
found 2 tests in 1 files (0 compile failures)
✓ src/frames_test.lpscr::test_checksum_carry (17ms)
✓ src/frames_test.lpscr::test_sequence_wraps (17ms)
Tests: 2 total, 2 passed, 0 failed, 0 skipped (18ms)
(The test imports a sibling module, so the listing is shown with the output of loup-bootstrap test in a project holding both files rather than checked on its own.) The Scripting Book's testing chapter covers debug.assert and the runners in detail; on macOS and Windows loup test and loup-bootstrap test are the same program.
Where host behaviour differs
The interpreter runs the module under hosted rules, and two of them differ from what the same code does on the target:
- Fixed-width overflow throws. In freestanding code
uint8arithmetic wraps (chapter 3); in the interpreter it raisesNumericOverflowError. bufferis not supported. A function that declares abufferfails in the interpreter withinternal interpreter limitation: unsupported instruction: MakeBuffer.
The first one bites exactly the code that host tests are good at — counters, sequence numbers, checksums. Without the @overflowWrapping line in Listing 22-1, test_sequence_wraps fails on the host although the function is correct on the target:
found 2 tests in 1 files (0 compile failures)
✓ src/frames_test.lpscr::test_checksum_carry (18ms)
✗ src/frames_test.lpscr::test_sequence_wraps (18ms)
AssertionError: arithmetic overflow: 256 does not fit uint8
Tests: 2 total, 1 passed, 1 failed, 0 skipped (18ms)
Here is the same effect in a single script:
@mode(host.scripted)
# The 8-bit sequence number of a serial frame: 255 is followed by 0.
func nextSeq(seq: uint8): uint8 {
return seq + 1u8
}
println(nextSeq(41u8))
println(nextSeq(255u8))
42
Uncaught NumericOverflowError: arithmetic overflow: 256 does not fit uint8
at nextSeq (main.lpscr:5:12)
at <main> (main.lpscr:9:9)
@overflowWrapping states that the function's arithmetic is meant to wrap. In freestanding code that is already the behaviour, so the annotation documents intent; in the interpreter it switches the function to wrapping, so host and target agree:
@mode(host.scripted)
# The 8-bit sequence number of a serial frame: 255 is followed by 0.
@overflowWrapping
func nextSeq(seq: uint8): uint8 {
return seq + 1u8
}
println(nextSeq(41u8))
println(nextSeq(255u8))
Listing 22-3: @overflowWrapping makes the interpreter wrap too
42
0
So: mark every function that relies on wrapping with @overflowWrapping, keep buffers and hardware access out of host-tested modules, and test the rest on the target.
Freestanding tests under QEMU
loup test --freestanding runs test functions inside a real freestanding image (Testing Reference §23.7). For each test file it generates a complete kernel — your file, a runner that calls each test_* function, a boot trampoline and a panic handler — builds it, boots it with qemu-system-x86_64, and reads name: PASS or name: FAIL lines back from the serial port.
A freestanding test file is an ordinary .lpfree file with test_* functions that take no parameters and return void. Assertions cannot throw, so they come from debug.assert_fs: expectFs(condition, message) and expectEqFs(actual, expected, message) record a failure in a flag that the runner checks after each test. Here buffer and wrapping work exactly as they will in the kernel:
@mode(freestanding)
from debug.assert_fs import (expectFs, expectEqFs);
@overflowWrapping
func nextSeq(seq: uint8): uint8 {
return seq + 1u8
}
func test_next_seq_counts_up(): void {
expectFs(nextSeq(41u8) == 42u8, "41 -> 42")
}
func test_next_seq_wraps(): void {
expectFs(nextSeq(255u8) == 0u8, "255 -> 0")
}
func test_buffer_round_trip(): void {
var ring: buffer[uint64, 4];
ring[3] = 7u64
expectEqFs(ring[3] as int, 7, "buffer element")
}
Listing 22-4: tests/frames_fs_test.lpfree, a freestanding test file
The hosted assertion module is the natural thing to reach for, and it is a hosted module:
@mode(freestanding)
from debug.assert import (expectEq);
func test_sum(): void {
expectEq(2 + 2, 4, "sum")
}
error[E0001]: `@mode(freestanding)` module cannot import `@mode(host.scripted)` module
--> src/main.lpfree:3:1
|
3 | from debug.assert import (expectEq);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
Run a freestanding test file by naming it:
loup-bootstrap test --freestanding --file tests/frames_fs_test.lpfree
Name the file with --file. Discovery in the tests/ directory only collects .loup and .lpscr files, and a bare argument after the flags is a filter, not a file name. Without --file the run finds nothing and still exits 0:
freestanding: found 0 tests in 0 files — building + booting under QEMU
Tests: 0 total, 0 passed, 0 failed, 0 skipped (0ms)
What to know about this runner in 1.25:
- It exists only in Stage0 (
loup-bootstrap test). On Linux x86-64,loup testis the self-hosted runner, which has no--freestanding; callloup-bootstrapexplicitly. - It targets x86_64 only, and needs what any freestanding build needs (Linux x86-64,
ld.lld) plusqemu-system-x86_64on thePATH. - The runner supplies
_start,kernelMainand a@panic_handler, so the test file must not declare@entry,@panic_handlerorkernelMainitself. - All tests of a file run in one image, in sequence. A test that hangs or crashes the kernel stops the rest; they are reported as failed because the end-of-run marker never arrives.
- Only PASS or FAIL comes back; the assertion messages are not sent over the serial line yet.
Boot tests of your own image
The test suites in the Loup repository boot real kernels the same way: the kernel prints marker lines over serial, and a script runs QEMU with a time limit and looks for them. You can do the same with your image. On x86_64, QEMU's isa-debug-exit device also lets the kernel end the run with an exit status: a value written to its port makes QEMU exit with status (value << 1) | 1.
const DEBUG_EXIT_PORT: uint16 = 0xF4u16
# QEMU exits with status (code << 1) | 1: 0x10 -> 33 (pass), 0x11 -> 35 (fail).
func qemuExit(code: uint8): void {
outb(DEBUG_EXIT_PORT, code)
while (true) {
asm { "cli" "hlt" } # only reached without the device
}
}
Listing 22-5: Ending a QEMU run with an exit status
#!/bin/sh
# Boot the kernel; pass if it exits with status 33 and printed "ALL TESTS OK".
timeout 60 qemu-system-x86_64 -kernel ./mykernel -display none -no-reboot \
-serial file:serial.log \
-device isa-debug-exit,iobase=0xf4,iosize=0x04
status=$?
if [ "$status" -eq 33 ] && grep -q "ALL TESTS OK" serial.log; then
echo PASS
else
echo "FAIL (exit $status)"; cat serial.log; exit 1
fi
Always put a time limit around QEMU: a kernel that hangs never exits on its own. On AArch64 and RISC-V, where there is no isa-debug-exit, rely on the serial markers and the time limit alone.
Static checks at build time
Some properties are cheaper to prove when building than to test.
Stack use. A freestanding kernel usually has no guard page below its stack, so an overflow silently overwrites whatever lies below. With stack_check, the build computes the worst-case stack depth of every entry from the call graph and the backend's frame sizes, adds the cost of nested interrupt handlers, and compares the total with stack_size (Language Reference §3-3.21.3):
[freestanding]
stack_size = 65536
stack_check = "enforce" # "off" (default) | "report" | "enforce"
interrupt_nesting = 1 # handler activations that may stack up at once
"report" prints the budget and writes <image>.stack.json; "enforce" also fails the build when an entry is over budget (E_STACK_BUDGET_EXCEEDED) or has no provable bound (E_STACK_UNBOUNDED: recursion, an indirect call nothing resolves, a stack switch in asm). --stack-report and --stack-enforce override the key for one build. Recursion becomes boundable with @maxDepth(n) on each function of the cycle, and an asm block's stack use can be stated with @asmStack(n); both are trusted, and listed in the report. The check runs on the LLVM lanes (llvm and llbe), not on a separate build.
Image size. [linker] max_image_size fails a build whose image is larger than the given number of bytes, with a per-section breakdown (chapter 18).
Unsafe surface. [freestanding] overlays = "error" and rawpoke = "unsafe-only" turn unacknowledged overlays and raw memory accesses into errors (chapter 13), so grep -E '@?unsafe\(' is a complete list of the places to review.
Run-time checks. Build test images with [freestanding] bounds = "trap" and panic = "custom", so an out-of-range buffer index stops in your panic handler with its file and line instead of corrupting memory.
Debugging under QEMU
QEMU has a GDB server. -s listens on port 1234 and -S holds the CPU at the first instruction until a debugger connects:
qemu-system-x86_64 -kernel ./mykernel -serial stdio -display none -s -S
In another terminal:
gdb ./mykernel
(gdb) target remote localhost:1234
(gdb) break kernelMain
(gdb) continue
(gdb) info registers
(gdb) x/8gx $rsp
Function names are in the image's symbol table, so breakpoints by name work; an interrupt handler's body is named <handler>__isr_body (chapter 18). lldb works the same way (gdb-remote 1234). For a device on the bench, the same commands work against a JTAG or SWD probe's GDB server (OpenOCD, pyOCD, probe-rs).
For events that are hard to catch with breakpoints, the freestanding debug service (chapter 20) records portal events in --debug builds, and loup debug attach --uart <device> or --tcp <host:port> connects to it.
Where images run today
The toolchain's QEMU suites boot images for these architectures; there is no CI gate on those suites yet, so treat the status as "booted in the repository's tests", not as a support guarantee:
| Status | Architectures |
|---|---|
Boot-verified under QEMU, on both the llvm and llbe lanes |
x86_64, aarch64, riscv64, armv7, Cortex-M (thumbv8.1m.main, thumbv8m.main, thumbv7em, thumbv6m) |
Boot-verified through the llvm lane only |
riscv32 |
| C/LLVM path only, not boot-verified | x86 (32-bit) |
| Encoder-level only, never booted | loongarch64, mips64el, powerpc64le |
| Specified, not implemented | s390x |
| Not supported | xtensa |
Image builds run on Linux x86-64 with ld.lld; on macOS and Windows you can type-check (loup check) and run host tests, but not link an image (chapter 2).
Deploying
The build produces an ELF file. What happens next depends on the target:
- Emulators and hypervisors (QEMU, Firecracker, Xen) load the ELF directly.
- Boot loaders (GRUB, Limine, U-Boot) load an ELF kernel with the right headers — the Multiboot2 header of chapter 21, for example.
- Microcontrollers and raw flash usually want a flat binary:
llvm-objcopy -O binary ./firmware firmware.bin, then the vendor's tool or a debug probe (OpenOCD, pyOCD, probe-rs) writes it. - UEFI needs a PE/COFF
.efifile, which the 1.25 toolchain cannot produce (chapter 16).
For anything you ship:
- Record the version in the image (chapter 18's version record), so you can tell what a device is running.
- Keep the link map and an unstripped copy of every released image, so crash addresses can be resolved later; ship a stripped copy if size matters (
llvm-strip). - Sign the image if the platform supports secure boot, with the platform's tools; Loup does not sign images.
- Keep a recovery path — a boot loader that can accept a new image, a second firmware slot, or a debug header — before the first field update.
Summary
- Host tests (
*_test.lpscrbeside the module,debug.assert,loup test) run freestanding logic in the interpreter. Hosted rules apply there: mark wrapping arithmetic@overflowWrapping, and keepbuffers and hardware access out of host-tested modules. loup-bootstrap test --freestanding --file <file>buildstest_*functions into an x86_64 kernel and boots it under QEMU; assertions come fromdebug.assert_fs. Always pass--file.- Boot your own image under QEMU with a time limit, serial markers and, on x86_64,
isa-debug-exit. stack_check,max_image_size,overlays/rawpokeandbounds = "trap"catch problems at build time or stop cleanly at run time.- Debug with QEMU's
-s -Sand GDB; images are ELF, converted withllvm-objcopyfor flashing.
Next: memory safety and kernel patterns.