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 20: The Available Standard Library Subset

Almost all of Loup's standard library is hosted code: it expects the Loup runtime, an operating system, exceptions or a heap. A freestanding image has none of those, so what it can use is small and precise. This chapter lists it:

  • what the compiler provides with no import at all;
  • the four standard-library modules a freestanding file can import;
  • why every other module is unavailable, and what the Language Reference plans;
  • the routines you write yourself, and the raw-memory intrinsics they rest on;
  • what happens to C code declared with extern native.

Built into the compiler

These need no import in a @mode(freestanding) file:

Facility What it is Chapter
fixed-width integers, bool, string literals and iteration the core language 3, 4
Option[T], Result[T, E], Ok/Err/Some/None, ?, try … attempt … catch Err(e) error handling without exceptions 9
buffer[T, N] fixed-size arrays, owned or overlaid on an address 13, 23
size_of[T](), align_of[T]() compile-time layout queries 5
Volatile[T], Volatile[T, E], Volatile.view[I] MMIO access 15
InterruptGuard, irqSaveDisable, irqRestore interrupt masking 16
addressOf, the atomic*U64/atomic*U32 family, atomicFence atomics 19
bufGetU8/U32/U64, bufSetU8/U32/U64, bufGetU8Vol, bufSetU8Vol, makeBufferU64/U32 raw memory access by address 13
@interchange structs with unpackFrom, packInto, byteLength byte-exact (de)serialization with a declared byte order this chapter

The last row deserves an example. An @interchange struct declares the exact byte layout and byte order of a record, and the compiler generates heap-free methods that read it from, and write it to, an address. That is what you want for network headers and on-disk structures, which are big-endian on the wire whatever the CPU is:

@mode(freestanding)

# The first bytes of an Ethernet payload carrying IPv4 (big-endian on the wire).
@interchange(endianness: Big)
struct FrameStart {
    @field(encoding: Binary, length: 2) pub var etherType: uint16 = 0;
    @field(encoding: Binary, length: 1) pub var versionIhl: uint8 = 0;
    @field(encoding: Binary, length: 1) pub var tos: uint8 = 0;
    @field(encoding: Binary, length: 2) pub var totalLength: uint16 = 0;
}

const ETHERTYPE_IPV4: uint16 = 0x0800u16

func isIpv4(frame: uint): bool {
    let h = FrameStart.unpackFrom(frame)
    return h.etherType == ETHERTYPE_IPV4 and (h.versionIhl >> 4) == 4u8
}

func writeFrameStart(frame: uint, length: uint16): void {
    var h: FrameStart;
    h.etherType = ETHERTYPE_IPV4
    h.versionIhl = 0x45u8                 # IPv4, 20-byte header
    h.totalLength = length
    h.packInto(frame)
}

Listing 20-1: Reading and writing a big-endian header with @interchange

unpackFrom and packInto read and write memory at a caller-supplied address, so they count as raw memory access (see "Raw memory access" below). For a device register block, Volatile.view[I] gives the same unpacking with volatile accesses (chapter 15).

The freestanding modules

Four modules in the standard library are written in @mode(freestanding) and can be imported:

Module What it provides
debug.assert_fs expectFs(condition, message) and expectEqFs(actual, expected, message): assertions that set a failure flag instead of throwing, for loup test --freestanding (chapter 22)
debug.freestanding the freestanding debug service: start(config), poll(), flushTrace(), and the FreestandingDebugConfig, FreestandingTraceChannel and FreestandingControlChannel types (Language Reference §3-3.25)
debug.recording a fixed-size ring for recording nondeterministic events (system call results, input, timer reads) for later replay: RecordStream, rs_init, rs_record, rs_replay, rs_rotate
native.css the s390x channel subsystem (§3-3.26); specified, but the s390x target itself is not implemented yet

The debug service exists only in loup build --debug images. A release build rejects the import (E_FREESTANDING_MODULE: "debug.freestanding is available only in --debug freestanding builds"), so that a release image cannot silently contain calls that do nothing; the Language Reference names this case E_FREESTANDING_DEBUG_START_RELEASE. A kernel starts the service once and polls it from its main loop:

from debug.freestanding import (poll, start, FreestandingDebugConfig, ControlPollMode);

func startDebugService(): void {
    start(FreestandingDebugConfig(
        controlPollMode = ControlPollMode.Polling,   # poll() handles one command per call
        traceFlushThreshold = 0,                     # send each event as it happens
        traceFlushIntervalUs = 0,
        timeTravelEnabled = false,
        timeTravelRingFrames = 0))
}

func mainLoopTick(): void {
    poll()
    # ... the rest of the loop's work ...
}

Listing 20-2: Starting and polling the freestanding debug service

On the host, loup debug attach --uart <device> or loup debug attach --tcp <host:port> connects to it. The transports themselves are functions you write and mark @debug_control_channel or @debug_trace_channel; the Language Reference describes the protocol (§3-3.25).

Hosted modules cannot be imported

Every other module — core.math, system.text, io.fs, debug.assert and the rest — is hosted code, and a freestanding file cannot import it. The natural first attempt fails at the import:

This code does not compile
@mode(freestanding)

from core.math import (sqrt);

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

The message names the module's mode, not the fact that it lives in the standard library: the rule is the general one that freestanding code imports only freestanding code. The same applies to the native.* modules the Language Reference lists for freestanding, which do not exist yet:

This code does not compile
@mode(freestanding)

import native.mem as mem;

func clearPage(page: uint): void {
    mem.zero(page, 4096u64)
}
error[E0001]: 'native.mem' is not a standard-library module (namespace 'native' is reserved for the standard library)
 --> src/main.lpfree:3:8
  |
3 | import native.mem as mem;
  |        ^^^^^^^^^^
  |

error[E0001]: undefined identifier `mem`
 --> src/main.lpfree:6:5
  |
6 |     mem.zero(page, 4096u64)
  |     ^^^
  |

⚠ Planned. The Language Reference's freestanding subset (§3-3.20) also lists core.math, core.text, core.complex, core.array, core.fpenv, core.interchange, native.mem (copy, zero, compare, alloc, free, tryAlloc), native.simd, native.atomic (with hwFence and MemoryOrder), native.ffi, native.dma (DmaBuffer[T]) and native.mpu (installStackGuard). None of these can be imported by freestanding code in Loup 1.25: the core.* modules that ship are hosted, native.atomic is a host.native module, and the other native.* modules do not exist. (The @interchange methods above, which core.interchange would package, are already generated by the compiler.)

The prelude's hosted functions are a trap of a different kind. println, print, exit, readText and the like are not rejected by the 1.25 checker in a freestanding file, but there is no console, process or file system behind them. Do not call them; write to your own UART (chapter 15) and stop with your own fatal (chapter 17).

Writing the pieces yourself

Kernels have always carried a small library of their own: integer helpers, formatting into fixed buffers, byte copying. In Loup these are ordinary functions over fixed-width integers and buffers:

# Integer square root: the largest r with r * r <= n (bit-by-bit method).
pub func isqrt(n: uint64): uint64 {
    var rem = n
    var root: uint64 = 0u64
    var bit: uint64 = 0x4000_0000_0000_0000u64     # the highest power of four
    while (bit > rem) {
        bit = bit >> 2
    }
    while (bit != 0u64) {
        if (rem >= root + bit) {
            rem = rem - (root + bit)
            root = (root >> 1) + bit
        } else {
            root = root >> 1
        }
        bit = bit >> 2
    }
    return root
}

pub func gcd(a: uint64, b: uint64): uint64 {
    var x = a
    var y = b
    while (y != 0u64) {
        let t = x % y
        x = y
        y = t
    }
    return x
}

# Write `value` as 16 lowercase hex digits into `out`; returns the byte count.
pub func formatHex(value: uint64, edits out: buffer[uint8, 16]): int {
    var i = 0
    while (i < 16) {
        let shift = ((15 - i) * 4) as uint64
        let nibble = ((value >> shift) & 0xFu64) as uint8
        if (nibble < 10u8) {
            out[i] = 0x30u8 + nibble          # '0'..'9'
        } else {
            out[i] = 0x57u8 + nibble          # 'a'..'f'
        }
        i = i + 1
    }
    return 16
}

Listing 20-3: A few kernel library routines

formatHex writes into a buffer the caller owns, which is the general shape of freestanding APIs: the caller provides the memory, so nothing allocates and the lifetime is obvious. A caller writes var text: buffer[uint8, 16]; and passes edits: text.

Raw memory access

Copying bytes between arbitrary addresses — a DMA buffer into a packet, a boot module into its final place — needs access by address rather than through a typed buffer. Chapter 13 introduced the raw intrinsics for this: bufGetU8(base, i) is the byte at base + i, bufGetU64(base, i) the 8-byte word at base + 8 * i, and the bufSet* forms write. Nothing checks those addresses, so mark the function that uses them:

# Copy `n` bytes from `src` to `dst`; the regions must not overlap.
@unsafe("rawpoke: callers pass mapped, non-overlapping regions of at least n bytes")
func copyBytes(dst: uint64, src: uint64, n: uint64): void {
    var i: uint64 = 0u64
    while (i < n) {
        bufSetU8(dst, i, bufGetU8(src, i))
        i = i + 1u64
    }
}

Listing 20-4: A byte copy over raw addresses

With [freestanding] rawpoke = "unsafe-only" in the manifest, the compiler insists on that annotation: a raw access outside an @unsafe function or an unsafe("reason") { … } region is E_RAWPOKE_UNSAFE_ONLY (chapter 13 shows the message). The @interchange methods packInto and unpackFrom count as raw accesses too. With the key set, grep -E '@?unsafe\(' lists every place a package touches memory by address, which is what makes the audit in chapter 23 complete.

memset and friends

An optimizing backend may turn a byte loop into a call to memset or memcpy, and a freestanding link has no C library to provide them. The toolchain supplies them, written in Loup on top of the same raw-memory intrinsics. With --backend llvm, [linker] mem_runtime chooses where they come from: "loup" (the default), "c" (an older C implementation), or "none" when your kernel defines memset, memcpy, memmove and memcmp itself. The llbe backend expands these operations inline and needs none of them.

C code: extern native

A freestanding file can declare a function implemented in C, one function per declaration (chapter 5 shows the form and the @repr(C) rule for struct arguments):

@repr(C)
struct Timespec {
    var seconds: int64;
    var nanos: int64;
}

extern native "libfwclock" func fw_clock_read(ts: Timespec): int32;

Listing 20-5: Declaring a function from a C library

The declaration type-checks, but getting the C object into the image is not handled by the toolchain yet:

⚠ Planned. The Language Reference says extern native libraries are linked directly into a freestanding image (§3-3.2). In 1.25 the manifest has no key that adds a C object or library to a freestanding link. A linker script can name extra inputs itself (INPUT(libfwclock.a) is a standard linker-script command that ld.lld accepts), but the Loup test suites do not exercise this; prefer writing the routine in Loup or in an asm block.

Porting hosted code

When you bring a hosted Loup module into a kernel, the work is usually:

  1. Exceptions become Result[T, E] with ?, or try { … attempt f() … } catch Err(e) { … } (chapter 9). throw and catch (T as e) are E_FREESTANDING_EXCEPTIONS.
  2. Growable collections become buffer[T, N] with an explicit count. The 1.25 checker does not stop list operations such as push in freestanding code, and the LLVM lanes silently drop them, so remove them yourself (chapter 23).
  3. String building becomes formatting into caller-provided buffers, as in Listing 20-3.
  4. Imports of hosted modules go: replace core.math with integer helpers, system.text with your own byte routines, io.* with your drivers.
  5. Floating point usually becomes fixed point (chapter 3).
  6. async becomes interrupt handlers and polling loops (chapter 16).
  7. defer becomes explicit cleanup on each exit path; it is accepted but never runs in 1.25 (chapter 4).

Pure logic — parsers, checksums, data structures over buffers — often needs no change beyond the mode line, and can then also be tested on the host (chapter 22).

Summary

  • Without any import, freestanding code has the core language, Option/Result, buffer, layout queries, Volatile, InterruptGuard, the atomic and raw-memory intrinsics, and @interchange pack/unpack.
  • Four modules can be imported: debug.assert_fs, debug.freestanding (debug builds), debug.recording, and native.css (s390x, not yet usable).
  • Hosted modules cannot be imported (… cannot import @mode(host.scripted) module); native.mem and the other planned native.* modules do not exist. Do not call hosted prelude functions such as println, even though the checker allows them.
  • Write small library routines yourself over fixed-width integers and caller-provided buffers. Mark raw-memory code @unsafe, and set [freestanding] rawpoke = "unsafe-only" to make the compiler insist.
  • extern native declarations check, but linking C objects into a freestanding image is not managed by the 1.25 toolchain.

Next: a complete x86_64 kernel stub.