# 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 --freestanding`** builds 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:

```loup
@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:

```loup,ignore
@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)*

```sh
loup-bootstrap test
```

```text
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 `uint8` arithmetic wraps (chapter 3); in the interpreter it raises `NumericOverflowError`.
- **`buffer` is not supported.** A function that declares a `buffer` fails in the interpreter with `internal 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:

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

```loup,throws
@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))
```

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

```loup
@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*

```text
42
0
```

So: mark every function that relies on wrapping with `@overflowWrapping`, keep `buffer`s 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:

```loup
@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:

```loup,does_not_compile
@mode(freestanding)

from debug.assert import (expectEq);

func test_sum(): void {
    expectEq(2 + 2, 4, "sum")
}
```

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

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

```text
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 test` is the self-hosted runner, which has no `--freestanding`; call `loup-bootstrap` explicitly.
- It targets x86_64 only, and needs what any freestanding build needs (Linux x86-64, `ld.lld`) plus `qemu-system-x86_64` on the `PATH`.
- The runner supplies `_start`, `kernelMain` and a `@panic_handler`, so the test file must not declare `@entry`, `@panic_handler` or `kernelMain` itself.
- 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`.

```loup
#~ @mode(freestanding)
#~
#~ func outb(port: uint16, value: uint8): void {
#~     asm { "out dx, al" in: "dx" = port, "al" = value clobbers: [] }
#~ }
#~
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*

```sh
#!/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):

```toml
[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:

```sh
qemu-system-x86_64 -kernel ./mykernel -serial stdio -display none -s -S
```

In another terminal:

```sh
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 `.efi` file, 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.lpscr` beside the module, `debug.assert`, `loup test`) run freestanding logic in the interpreter. Hosted rules apply there: mark wrapping arithmetic `@overflowWrapping`, and keep `buffer`s and hardware access out of host-tested modules.
- `loup-bootstrap test --freestanding --file <file>` builds `test_*` functions into an x86_64 kernel and boots it under QEMU; assertions come from `debug.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`/`rawpoke` and `bounds = "trap"` catch problems at build time or stop cleanly at run time.
- Debug with QEMU's `-s -S` and GDB; images are ELF, converted with `llvm-objcopy` for flashing.

Next: memory safety and kernel patterns.
