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/bufSetU64and friends, and how to audit them; addressOf,size_ofandalign_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:
@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]:
@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:
@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:
@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:
@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 withuninit(),zeroed(),rawPtr()and an@unsafeassumeInit(). 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) orPartialInit[T]instead.reinterpret[T, U]andreinterpretChecked[T, U](§3-3.18) — bit-pattern reinterpretation with compile-time size and alignment checks. The 1.25 checker parsesreinterpret[T, U](x)but treats the result as having the source type and checks nothing, so do not use it.reinterpretCheckedand itsAlignmentErrorare not declared.native.mem(copy,zero,compare,alloc,free,tryAlloc) andnative.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@allocatordirectly (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 ofNelements atsize_of[T]()stride; constant indexes are checked (E_BUFFER_INDEX_RANGE), run-time indexes only with[freestanding] bounds = "trap"; buffer parameters must matchNexactly.- An integer initializer makes an overlay; acknowledge it with
@unsafe("overlay: …")or anunsafe("…") { }region (W_OVERLAY_NOT_UNSAFE, an error underoverlays = "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,uint64results) andaddressOfwork 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.memandnative.ffiare planned, not available.
Next: inline assembly.