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:
@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:
@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(withhwFenceandMemoryOrder),native.ffi,native.dma(DmaBuffer[T]) andnative.mpu(installStackGuard). None of these can be imported by freestanding code in Loup 1.25: thecore.*modules that ship are hosted,native.atomicis ahost.nativemodule, and the othernative.*modules do not exist. (The@interchangemethods above, whichcore.interchangewould 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 nativelibraries 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 thatld.lldaccepts), but the Loup test suites do not exercise this; prefer writing the routine in Loup or in anasmblock.
Porting hosted code
When you bring a hosted Loup module into a kernel, the work is usually:
- Exceptions become
Result[T, E]with?, ortry { … attempt f() … } catch Err(e) { … }(chapter 9).throwandcatch (T as e)areE_FREESTANDING_EXCEPTIONS. - Growable collections become
buffer[T, N]with an explicit count. The 1.25 checker does not stoplistoperations such aspushin freestanding code, and the LLVM lanes silently drop them, so remove them yourself (chapter 23). - String building becomes formatting into caller-provided buffers, as in Listing 20-3.
- Imports of hosted modules go: replace
core.mathwith integer helpers,system.textwith your own byte routines,io.*with your drivers. - Floating point usually becomes fixed point (chapter 3).
asyncbecomes interrupt handlers and polling loops (chapter 16).deferbecomes 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@interchangepack/unpack. - Four modules can be imported:
debug.assert_fs,debug.freestanding(debug builds),debug.recording, andnative.css(s390x, not yet usable). - Hosted modules cannot be imported (
… cannot import @mode(host.scripted) module);native.memand the other plannednative.*modules do not exist. Do not call hosted prelude functions such asprintln, 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 nativedeclarations check, but linking C objects into a freestanding image is not managed by the 1.25 toolchain.
Next: a complete x86_64 kernel stub.