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 1: What Is Freestanding Mode?

What you'll learn. What @mode(freestanding) is, what it keeps from the rest of Loup, what it leaves out, what the 1.25 toolchain can do with it today, and what a first freestanding program looks like.

Loup has six execution modes. Five of them run on top of something — an operating-system process or a browser — and the toolchain supplies a runtime underneath your code. The sixth, freestanding, runs where nothing is underneath: a kernel, a boot loader, firmware on a microcontroller, a hypervisor payload.

The six modes at a glance

Mode Runs on How it runs Typical file
@mode(host.scripted) an OS process interpreted; top-level statements run in order tool.lpscr
@mode(host.dual) an OS process interpreted, or compiled ahead of time; main() app.loup
@mode(host.native) an OS process compiled to a native binary; main() server.lphc
@mode(web.scripted) / @mode(web.compiled) a browser planned browser runtime page.lpweb
@mode(freestanding) bare metal compiled to a native image with no Loup runtime; an @entry function kernel.lpfree

The other books cover the hosted modes: The Loup Scripting Book, The Loup Native Book and The Loup Web Book. Freestanding code is written in .lpfree files, and every file starts with @mode(freestanding).

A freestanding image contains only the code you wrote, the code it reaches, and a few compiler helpers (memcpy, memset and the like) that the toolchain links automatically. There is no garbage collector or reference counter, no exception unwinder, no async executor, no libc, and no runtime object to ask about the environment. When the first instruction of your image runs, the CPU's state is whatever the firmware or boot loader left it in — often with no stack set up at all.

The boot story

Something hands your image control: GRUB or another Multiboot loader, UEFI firmware, a hypervisor, or the reset vector of a microcontroller. The hand-off happens at a known address, with a known register state, under a calling convention someone else chose.

From there, your code does everything. It sets the stack pointer. It sets up interrupt vectors. It finds out what memory exists, builds page tables, talks to the UART, the timer and the interrupt controller, and drops into a loop that never returns — there is nothing to return to.

Writing that code in Loup is what this book is about.

Why use Loup here rather than C, Rust or Zig?

Consider a network driver that fills a transmit buffer and hands it to the device. In C, nothing records that the CPU must not touch the buffer afterwards:

struct tx_buf { uint8_t bytes[1514]; uint32_t len; };

void transmit(struct tx_buf *buf) {
    nic_post_descriptor(buf);   /* the device now owns the bytes */
    buf->len = 0;               /* compiles; races with the device's DMA */
}

Rust expresses the hand-off with ownership, and so does Loup — with ownership verbs written where the reader looks for them. A takes method consumes its receiver, so after the hand-off the old binding cannot be used:

@mode(freestanding)

unique class TxBuffer {
    pub var frame: uint64;          # physical address of the bytes
    pub var length: uint32;

    new(frame: uint64, length: uint32) {
        this.frame = frame
        this.length = length
    }

    # Gives the buffer to the device and returns the descriptor value to post.
    takes func handToDevice(): uint64 {
        return this.frame | (this.length as uint64)
    }
}

func transmit(): uint64 {
    let tx = TxBuffer(0x20_0000u64, 1514u32)
    let descriptor = tx.handToDevice()
    # tx.length = 0     -- would be E_USE_AFTER_TAKE: `tx` was consumed
    return descriptor
}

Listing 1-1: Ownership hand-off expressed as a takes method

The check happens at compile time and costs nothing at run time. The same machinery — reads/edits/takes verbs, unique values, lifetime-labelled borrows — works all the way down to device registers, and freestanding adds hardware features on top: inline assembly, volatile MMIO, interrupt handlers, naked functions, layout control and linker sections. Chapters 10–12 cover ownership; chapters 13–18 cover the hardware features.

What you keep

  • The type system: fixed-width integers, bool, string (as an immutable slice), structs, enums, union types, tuples, generics, interfaces with compile-time dispatch, exhaustive match.
  • Ownership and borrowing: the verbs reads/edits/takes, modifier, safe contexts, unique classes and bindings, borrow/excl borrow, lifetimes ([lifetime ~a], borrow[~a]), noescape fn, unowned[~a].
  • Error handling with values: Result[T, E], ?, From conversions, and try { … attempt … } catch Err(e) { … }.
  • Compile-time evaluation: const, const func, if const, size_of[T](), align_of[T]().

And you gain the freestanding features: @entry, @naked, @callconv, asm { }, Volatile[T] and volatile borrow, @repr, @align, @section, buffer[T, N] with overlays, onstack, @interrupt, @interruptSafe and InterruptGuard, @panic_handler with a panic policy, @allocator/@deallocator, PartialInit[T], and a set of bare intrinsics for atomics, raw memory and interrupt masking.

What you do not have

These features need a runtime that a freestanding image does not contain. The Language Reference assigns each an error code (§3-3.3); the second column says whether the 1.25 checker reports it today.

Not available Reported by loup check in 1.25
throw and catch (T as e) — use Result yes: E_FREESTANDING_EXCEPTIONS
weak references yes: E_FREESTANDING_WEAK
dynamic class yes: E_FREESTANDING_DYNAMIC
defer import yes: E_DEFERRED_NOT_FREESTANDING
consume (unique-to-ARC downgrade) yes: E_CONSUME_NOT_AVAILABLE
hosted standard-library modules (core.math, system.*, …) yes: the import is rejected
async/await, Task[T], spawn partly — await outside an async func is rejected, but an async func declaration is not
bigint, decimal, the runtime object no — don't use them
template fences, reflection, ui.* no — don't use them
growable collections (list, dict) no — nothing enforces it; use buffer[T, N] (chapter 13)
class construction without an @allocator no — and see chapter 7 for where objects actually live

"No" in the second column means the code may type-check and then fail to build, or build into something that cannot work on bare metal. Treat the whole table as rules you keep, not as a safety net.

A natural first mistake is to reach for the standard library:

This code does not compile
@mode(freestanding)

from core.math import (sqrt);

func diagonal(w: float, h: float): float {
    return sqrt(w * w + h * h)
}
error[E0001]: `@mode(freestanding)` module cannot import `@mode(host.scripted)` module
 --> src/main.lpfree:3:1
  |
3 | from core.math import (sqrt);
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |

core.math is a hosted module (the message names the mode the checker resolved it under). In 1.25 the only standard-library modules a freestanding file can import are the debugging and test helpers debug.freestanding, debug.assert_fs and debug.recording, plus native.css for the planned s390x target; chapter 20 lists what is built in without an import. Kernel code computes with integers and writes the few routines it needs itself.

What the 1.25 toolchain does with freestanding code

  • Type-checking works everywhere the toolchain runs: loup check in a project directory (on Linux x86-64, where loup is the self-hosted driver, the command is loup-bootstrap check). Every listing in this book is checked that way.
  • Building an image — loup build --target freestanding --arch … — works on Linux x86-64, needs ld.lld, and uses the clang-free llbe back end by default (chapter 2). Freestanding builds are not available on macOS or Windows.
  • Targets: images for x86_64, aarch64, riscv64, armv7 and Cortex-M (thumbv8.1m.main and relatives) are boot-tested under QEMU. riscv32 boots through the LLVM back end. loongarch64, mips64el and powerpc64le are encoder-level only (no image has been booted), x86 (32-bit) goes through the C/LLVM path only, and s390x is specified but not implemented.
  • The project's own status table calls freestanding experimental. Expect gaps; this book marks the ones a reader would hit.

A first freestanding program

This program prints a line on the serial console of QEMU's AArch64 virt machine, whose PL011 UART is memory-mapped at 0x0900_0000:

# QEMU `virt` (AArch64): the PL011 UART's data register.
let UART0_DATA = Volatile[uint8].at(0x0900_0000)

func uartWrite(s: string): void {
    for (c in s) {
        UART0_DATA.write(c as uint8)
    }
}

func kernelMain(): void {
    uartWrite("Hello from Loup freestanding\n")
    while (true) {
        asm { "wfi" clobbers: [] }
    }
}

# The first code that runs: give the CPU a stack, then call into Loup.
@entry
@naked
@unsafe("naked: runs before any stack exists; sets sp from __stack_top")
@section(".text.boot")
func _start(): void {
    asm {
        "adrp x0, __stack_top"
        "add x0, x0, :lo12:__stack_top"
        "mov sp, x0"
        "bl kernelMain"
        "1: wfe"
        "b 1b"
        clobbers: ["memory"]
    }
}

Listing 1-2: src/main.lpfree — hello world over a memory-mapped UART

Things to notice:

  1. The file starts with @mode(freestanding). Every freestanding source file declares its mode on its first line (ruling MODE-2).
  2. Volatile[uint8].at(addr) wraps a device register; every write is a real store the optimizer will not remove or merge (chapter 15).
  3. @entry names _start as the image's entry point. There is no main.
  4. _start runs before any stack exists, so it must not have a compiler-generated prologue: @naked removes it, and a naked function's body may contain only asm (chapter 16). @section(".text.boot") places it where the linker script expects the first instruction (chapter 18).
  5. Nothing returns. kernelMain ends in a while (true) loop, and _start parks the core if it ever comes back.

@naked is the one feature in this program that demands an audit trail. Leave out the @unsafe line and the checker stops you:

src/main.lpfree:9:1: error: E_NAKED_NOT_UNSAFE: `@naked` function `_start` must also carry `@unsafe("reason")` — naked functions have no stack frame and are inherently unsafe (§3-3.6)

(That is the one-line form loup check --format short prints, for a variant of Listing 1-2 without the @unsafe line.) The reason string is free text, written for the next person who reads the code.

On x86 PCs the classic COM1 serial port is different: it lives in the separate I/O-port space at port 0x3F8 and is written with the out instruction, not through a memory address (chapter 14 shows outb). Writing Volatile[uint8].at(0x3F8) on x86 would store into RAM at address 0x3F8.

Chapter 2 adds the linker script and manifest, builds this program and boots it in QEMU.

How this book is organized

  • Part I (chapters 1–4): the mode, building and booting, integers, functions and control flow.
  • Part II (5–8): data layout, enums, classes and allocators, interfaces and generics.
  • Part III (9–13): error handling without exceptions, ownership verbs, borrows and lifetimes, unique, memory primitives.
  • Part IV (14–18): inline assembly, MMIO, interrupts and naked functions, entry points and panics, sections and linker scripts.
  • Part V (19–24): multi-core and atomics, the available library surface, a complete x86_64 kernel stub, testing and deployment, kernel safety patterns, and synchronized classes.

Summary

  • @mode(freestanding) compiles .lpfree files into a native image with no Loup runtime; the entry point is an @entry function, not main.
  • You keep the type system, ownership and borrowing, Result-based errors and compile-time evaluation, and gain inline assembly, volatile MMIO, interrupts, layout control and linker sections.
  • Exceptions, weak, dynamic class, defer import, consume and hosted library modules are rejected; async, bigint, collections and several others are not yet rejected but do not belong in freestanding code.
  • loup check works everywhere; image builds need Linux x86-64 and ld.lld; x86_64, aarch64, riscv64, armv7 and Cortex-M images boot under QEMU.

Next: building a bare-metal binary.