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 13: Memory Primitives

Sooner or later freestanding code works on memory directly: a page has to be zeroed before it is mapped, a descriptor ring has to sit at an address the device can find, a register block is a run of words at a fixed physical address. This chapter covers the primitives Loup 1.25 gives you for that:

  • buffer[T, N] — fixed-size blocks, either allocated by the compiler or laid over an address you supply;
  • onstack — per-invocation storage for aggregate locals;
  • the raw intrinsics makeBufferU64, bufGetU64/bufSetU64 and friends, and how to audit them;
  • addressOf, size_of and align_of;
  • PartialInit[T] — building a hardware structure field by field with compile-time tracking;

and what the Language Reference specifies but 1.25 does not implement yet: MaybeUninit[T], reinterpret[T, U], and the native.mem/native.ffi modules.

buffer[T, N] — fixed-size blocks

buffer[T, N] is N elements of type T with no header, no length field and no reference count — the counterpart of a C T[N] array (Language Reference §3-3.17b). N is any compile-time integer: a literal, a const, or arithmetic over them and size_of.

Declared without an initializer, a buffer is allocated: the compiler reserves the storage and zero-fills it.

@mode(freestanding)

const PAGES = 256
const WORDS = PAGES / 64

# One bit per physical page: 1 = free.
var freeMap: buffer[uint64, WORDS];

func markFree(page: int): void {
    let word = page / 64
    let bit = (page % 64) as uint64
    freeMap[word] = freeMap[word] | (1u64 << bit)
}

func isFree(page: int): bool {
    return (freeMap[page / 64] & (1u64 << ((page % 64) as uint64))) != 0u64
}

Listing 13-1: A page bitmap in a module-level buffer

Elements are size_of[T]() bytes apart — a buffer[uint8, 16] is 16 bytes. (The Language Reference's "storage model" paragraph in §3-3.17b still describes word-wide slots; the implementation indexes at the element size, which is what overlays of byte and 32-bit registers need.) A buffer has no methods: it is indexed with b[i], an int, and that is all — no .length, no .push, and no for-in iteration (chapter 4).

Index checks

A constant index outside 0..N is a compile error:

This code does not compile
@mode(freestanding)

# Free-page bitmap: 4 x 64 bits = 256 pages.
func freePageCount(): uint64 {
    var bitmap: buffer[uint64, 4];
    bitmap[0] = 0xFFFF_FFFF_FFFF_FFFFu64
    bitmap[4] = 0x0Fu64
    return bitmap[0] & 0xFFu64
}
error[E_BUFFER_INDEX_RANGE]: buffer index 4 is out of range for buffer[T, 4] (E_BUFFER_INDEX_RANGE; valid 0..4)
 --> src/main.lpfree:7:5
  |
7 |     bitmap[4] = 0x0Fu64
  |     ^^^^^^^^^
  |

"valid 0..4" is a half-open range: indexes 0 through 3. A run-time index is not checked by default — an out-of-range one reads or writes the neighbouring memory, exactly like C. Turn on run-time checks per package:

[freestanding]
bounds = "trap"     # "off" (the default) emits no check

With bounds = "trap", every non-constant subscript of a buffer whose size is visible in its declared type is guarded, and a bad index is a panic under the package's [build] panic policy — halt by default, or your @panic_handler with the message buffer index out of range under panic = "custom" (chapter 17).

Buffer parameters

A buffer parameter has an exact size, and an argument must match it — a buffer[uint64, 16] is not a buffer[uint64, 8]:

This code does not compile
@mode(freestanding)

func checksum(block: buffer[uint64, 8]): uint64 {
    var sum: uint64 = 0
    for (var i = 0; i < 8; i = i + 1) {
        sum = sum ^ block[i]
    }
    return sum
}

func checkLargeBlock(): uint64 {
    var block: buffer[uint64, 16];
    return checksum(block)
}
error[E0001]: argument 1 of 'checksum': expected 'buffer[uint64, 8]', found 'buffer[uint64, 16]'
  --> src/main.lpfree:13:21
   |
13 |     return checksum(block)
   |                     ^^^^^
   |

This is what makes bounds = "trap" sound for parameters: the declared N is the bound. Write the function for the size it is used with, or pass the size as a separate argument and use the raw intrinsics below.

Overlays — a buffer laid over an address

Initialized with an integer, a buffer does not allocate: it views the memory at that address. This is how freestanding code treats a register page, a DMA area or a scratch page as an array. Because the compiler cannot check that the address is valid, an overlay needs an explicit acknowledgment. Without one, 1.25 compiles the code but warns:

This code does not do what it should
@mode(freestanding)

# The local APIC registers, identity-mapped at their reset address.
var lapic: buffer[uint32, 1024] = 0xFEE0_0000

func lapicId(): uint32 {
    return lapic[8] >> 24          # ID register at byte offset 0x20
}
warning[W_OVERLAY_NOT_UNSAFE]: W_OVERLAY_NOT_UNSAFE: `lapic` is an overlay — it views the memory at an integer address as a buffer, which the compiler cannot check (§3-3.17b). Fix: write `@unsafe("overlay: <why this address is valid>")` on the declaration (a local may instead sit inside an `unsafe("<reason>") { … }` region)
 --> src/main.lpfree:4:5
  |
4 | var lapic: buffer[uint32, 1024] = 0xFEE0_0000
  |     ^^^^^
  |

The warning becomes the error E_OVERLAY_NOT_UNSAFE when the package sets [freestanding] overlays = "error" (or the compiler runs with LOUP_OVERLAYS=error); the Language Reference makes errors the default in a later release, so write the acknowledgment now. On a declaration it is @unsafe("overlay: …"); for a local overlay it can also be an enclosing unsafe("…") { … } region:

@unsafe("overlay: the local APIC register page, identity-mapped at its reset address")
var lapic: buffer[uint32, 1024] = 0xFEE0_0000

func lapicId(): uint32 {
    return lapic[8] >> 24
}

func zeroPage(page: uint): void {
    unsafe("overlay: `page` is a free, mapped 4 KiB frame owned by the caller") {
        var words: buffer[uint64, 512] = page
        for (var i = 0; i < 512; i = i + 1) {
            words[i] = 0u64
        }
    }
}

func copyPage(dst: uint, src: uint): void {
    unsafe("overlay: both frames are mapped, 4 KiB, and do not overlap") {
        var target: buffer[uint64, 512] = dst
        var source: buffer[uint64, 512] = src
        for (var i = 0; i < 512; i = i + 1) {
            target[i] = source[i]
        }
    }
}

Listing 13-2: Acknowledged overlays, and page helpers built from them

grep -E '@?unsafe\(' lists every overlay in a code base, with its reason.

The declaration is the only way an integer becomes a buffer. Assigning an address to an existing buffer variable is a type error, not a silent re-point:

This code does not compile
@mode(freestanding)

func clearPage(page: uint64): void {
    var words: buffer[uint64, 512];
    words = page
    for (var i = 0; i < 512; i = i + 1) {
        words[i] = 0u64
    }
}
error[E0001]: cannot assign 'uint64' to 'buffer[uint64, 512]'
 --> src/main.lpfree:5:5
  |
5 |     words = page
  |     ^^^^^^^^^^^^
  |

Write it as an overlay declaration inside an unsafe region, as zeroPage in Listing 13-2 does. The same holds for passing an integer for a buffer parameter, storing one into a buffer field, or returning one from a function declared to return a buffer.

A struct field can be an overlay too (@unsafe("overlay: …") var regs: buffer[uint32, 16] = UART0_BASE;). Every value of such a struct views the same memory, so the struct is move-only: using it after let b = a copies the view, which is the warning W_OVERLAY_STRUCT_COPY (an error under overlays = "error").

Where an allocated buffer lives — onstack

An allocated buffer at module level is a static in .bss. An allocated buffer local is, by default, also a static block — one per function, shared by every call (§3-3.17c). That is fine for a function only ever running once at a time, and wrong for one that can be re-entered: a recursive call, an interrupt that lands while the function runs, or a second CPU would all use the same block.

onstack makes the local a real per-invocation frame allocation, or fails to compile and says why:

# Walks a 4-level page table; called from the page-fault handler, which can
# nest inside a walk the kernel was already doing.
func walkDepth(root: uint64): int {
    onstack var path: buffer[uint64, 4];
    var depth = 0
    var table = root
    while (depth < 4 and table != 0u64) {
        path[depth] = table
        table = 0u64                 # ... read the next level from `table` ...
        depth = depth + 1
    }
    return depth
}

Listing 13-3: A per-invocation buffer local with onstack

onstack is a guarantee, not a hint. If the address of the block could outlive the frame — stored in a global, passed to a function whose parameter the compiler cannot analyse — it is E_ONSTACK_ESCAPES, naming the escape. It applies to the allocate form of buffer and to struct- or class-typed locals; on a scalar it is E_ONSTACK_NOT_AGGREGATE (a scalar is already in the frame). Inside @interrupt and @interruptSafe functions an aggregate local must be onstack (E_INTERRUPT_SAFE_STAGING, chapter 16). The frame block is zeroed once per call, in the prologue; @no_zero_locals suppresses that.

Raw intrinsics

Below buffer sit freestanding intrinsics that work on raw uint addresses (§3-3.4.2). They need no import:

Intrinsic Does
makeBufferU64(N), makeBufferU32(N) reserve a static, zeroed block of N 8- or 4-byte elements; evaluate to its address (N must be a literal)
bufGetU64(base, i), bufSetU64(base, i, v) and the U32/U8 forms read/write element i — an element index, not a byte offset; gets return uint64
bufGetU8Vol(base, i), bufSetU8Vol(base, i, v) one volatile byte access each, for byte-wide device registers
addressOf(g) the address of a module-level variable g
# A 16-word scratch table whose address is handed to firmware.
var scratchTable: uint = 0

func initScratch(): uint {
    scratchTable = makeBufferU64(16)
    bufSetU64(scratchTable, 0, 0x4C4F5550u64)     # element 0: a tag
    bufSetU64(scratchTable, 1, 16u64)             # element 1: the length
    return scratchTable
}

# The second 32-bit word of a record is at element index 1, byte offset 4.
func secondWord(record: uint): uint32 {
    return bufGetU32(record, 1) as uint32
}

Listing 13-4: The raw buffer intrinsics

Note the as uint32: the getters return uint64, and widths never mix (chapter 3). Indexes are unchecked, and these calls can reach any address from any function. To make them auditable, a package can require an acknowledgment for each one:

[freestanding]
rawpoke = "unsafe-only"     # or "warn"; the default is "allow"

With that key, a raw access outside an @unsafe function or unsafe("…") { } region is an error. This function compiles under the default but not under rawpoke = "unsafe-only":

# Read the boot loader's handoff word from the scratch page.
func handoffMagic(scratch: uint): uint64 {
    return bufGetU64(scratch, 0)
}

Listing 13-5: A raw read that rawpoke = "unsafe-only" rejects

error[E_RAWPOKE_UNSAFE_ONLY]: E_RAWPOKE_UNSAFE_ONLY: 'bufGetU64' reads/writes an arbitrary address and this package sets [freestanding] rawpoke — move the call into a function annotated @unsafe("reason"), or into an unsafe("reason") { … } region
 --> src/main.lpfree:5:12
  |
5 |     return bufGetU64(scratch, 0)
  |            ^^^^^^^^^^^^^^^^^^^^^
  |

Prefer a typed buffer where the size is known; keep the raw intrinsics for addresses computed at run time.

Layout queries

size_of[T]() and align_of[T]() fold to uint64 constants for primitives, @repr(C)/@repr(packed) structs, enums and buffers (chapter 5). Use them to size and place memory:

@repr(C)
@align(16)
struct RxDescriptor {
    var bufferAddr: uint64;
    var length: uint16;
    var flags: uint16;
    var status: uint32;
}

const DESC_BYTES = size_of[RxDescriptor]()       # 16
const RING_SLOTS: uint64 = 32u64
const RING_BYTES = DESC_BYTES * RING_SLOTS        # 512

Listing 13-6: Sizing a descriptor ring at compile time

PartialInit[T] — field-by-field construction

Hardware structures are often filled in one field at a time, from different sources. PartialInit[T] tracks, at compile time, which fields of a @repr(C) struct have been set, and complete() only compiles when all of them have (§3-3.17a):

@repr(C)
struct UdpHeader {
    var srcPort: uint16;
    var dstPort: uint16;
    var length: uint16;
    var checksum: uint16;
}

func buildHeader(src: uint16, dst: uint16, payload: uint16): UdpHeader {
    var h = PartialInit[UdpHeader].begin()
    h.set(.srcPort, src)
    h.set(.dstPort, dst)
    h.set(.length, payload + 8u16)
    h.set(.checksum, 0u16)          # 0 = no checksum (IPv4)
    return h.complete()
}

Listing 13-7: Building a header with PartialInit

Forget a field, and complete() says which:

This code does not compile
@mode(freestanding)

@repr(C)
struct UdpHeader {
    var srcPort: uint16;
    var dstPort: uint16;
    var length: uint16;
    var checksum: uint16;
}

func buildHeader(src: uint16, dst: uint16, payload: uint16): UdpHeader {
    var h = PartialInit[UdpHeader].begin()
    h.set(.srcPort, src)
    h.set(.dstPort, dst)
    h.set(.length, payload + 8u16)
    return h.complete()
}
error[E_PARTIAL_INIT_INCOMPLETE]: E_PARTIAL_INIT_INCOMPLETE: `.complete()` called with 1 unset field(s): checksum
  --> src/main.lpfree:16:12
   |
16 |     return h.complete()
   |            ^^^^^^^^^^^^
   |

Reading a field before it is set is E_PARTIAL_INIT_READ. Tracking follows statement order: a .set counts for every statement after it, including one inside a branch, so set every field on the straight-line path before completing.

Planned primitives

⚠ Planned. These are specified in the Language Reference but not implemented in Loup 1.25:

  • MaybeUninit[T] (§3-3.17) — a generic slot for not-yet-initialized memory with uninit(), zeroed(), rawPtr() and an @unsafe assumeInit(). The prelude contains an older, non-generic placeholder class of the same name; MaybeUninit[T] is rejected (type 'MaybeUninit' expects 0 type argument(s), got 1). Use an allocated buffer (zeroed) or PartialInit[T] instead.
  • reinterpret[T, U] and reinterpretChecked[T, U] (§3-3.18) — bit-pattern reinterpretation with compile-time size and alignment checks. The 1.25 checker parses reinterpret[T, U](x) but treats the result as having the source type and checks nothing, so do not use it. reinterpretChecked and its AlignmentError are not declared.
  • native.mem (copy, zero, compare, alloc, free, tryAlloc) and native.ffi (§3-3.20) — these modules do not exist for freestanding code; importing them is an error (chapter 20). Write the loops with overlays as in Listing 13-2, and call your @allocator directly (chapter 7).

The compiler itself still needs memcpy/memset/memcmp for struct copies and zero-fills; the toolchain links its own freestanding implementation of those automatically, so you never write or import them.

Putting it together — a receive descriptor ring

A network card reads its receive ring from memory: a contiguous, 16-byte-aligned array of descriptors whose base address the driver writes to a device register. A module-level buffer of @repr(C) structs is exactly that:

@repr(C)
@align(16)
struct RxDescriptor {
    var bufferAddr: uint64;
    var length: uint16;
    var flags: uint16;
    var status: uint32;
}

const RING_SLOTS = 32
const DESC_DONE: uint32 = 0x1u32

@align(16)
var rxRing: buffer[RxDescriptor, RING_SLOTS];
var rxHead: int = 0

# The value the driver writes to the card's ring-base register.
func ringBase(): uint {
    return addressOf(rxRing)
}

# Give slot `i` a fresh 2 KiB packet buffer.
func armSlot(i: int, packetBuffer: uint64): void {
    rxRing[i].bufferAddr = packetBuffer
    rxRing[i].length = 2048u16
    rxRing[i].status = 0u32
}

# Returns the length of the next completed packet, or 0 if none is ready.
func pollReceive(): uint16 {
    if ((rxRing[rxHead].status & DESC_DONE) == 0u32) {
        return 0u16
    }
    let length = rxRing[rxHead].length
    rxHead = (rxHead + 1) % RING_SLOTS
    return length
}

Listing 13-8: A receive ring as an aligned module buffer

Two cautions before this goes near real hardware. The device writes status behind the compiler's back, so the poll must be a volatile read — use a Volatile view or a volatile borrow of the descriptor (chapter 15). And on weakly ordered CPUs a barrier is needed between filling a descriptor and telling the device about it (chapter 19).

Summary

  • buffer[T, N] is a fixed block of N elements at size_of[T]() stride; constant indexes are checked (E_BUFFER_INDEX_RANGE), run-time indexes only with [freestanding] bounds = "trap"; buffer parameters must match N exactly.
  • An integer initializer makes an overlay; acknowledge it with @unsafe("overlay: …") or an unsafe("…") { } region (W_OVERLAY_NOT_UNSAFE, an error under overlays = "error"). Only the declaration turns an address into a buffer.
  • Allocated buffer locals are shared static blocks unless declared onstack.
  • makeBufferU64, bufGet*/bufSet* (element indexes, uint64 results) and addressOf work on raw addresses; rawpoke = "unsafe-only" makes each use auditable.
  • PartialInit[T] checks field-by-field construction at compile time.
  • MaybeUninit[T], reinterpret, reinterpretChecked, native.mem and native.ffi are planned, not available.

Next: inline assembly.