Chapter 5: Structs and Layout Control
Most hardware interfaces have a layout someone else chose. The CPU vendor decided what an interrupt frame and a task state segment look like; the network standard fixed the bytes of a UDP header; the SoC datasheet says which register sits at offset 0x14. In freestanding code, many of your struct declarations exist to match those layouts exactly.
Loup gives you three layout annotations — @repr(C), @repr(packed) and @repr(transparent) — plus @align(n), and two compile-time queries, size_of[T]() and align_of[T](). This chapter covers each, with the mistakes that come from C and Rust habits.
Struct basics
A struct is a value type: assigning or passing one copies it. Fields declared with var are mutable; a field written without a keyword is immutable after construction. A struct is constructed with named arguments, and methods are declared inside the struct body.
@mode(freestanding)
# An x86_64 IDT gate, before it is encoded into its 16-byte hardware form.
struct Gate {
selector: uint16; # fixed when the gate is built
var offset: uint64; # retargeted when a handler is installed
var present: bool;
func isInstalled(): bool {
return this.present and this.offset != 0u64
}
}
func installHandler(handler: uint64): Gate {
var g = Gate(selector = 0x08u16, offset = 0u64, present = false)
g.offset = handler
g.present = true
return g
}
Listing 5-1: A struct with immutable and mutable fields and a method
Trying to change the immutable field after construction is an error:
@mode(freestanding)
struct Gate {
selector: uint16;
var offset: uint64;
}
func retarget(handler: uint64): Gate {
var g = Gate(selector = 0x08u16, offset = 0u64)
g.offset = handler
g.selector = 0x10u16
return g
}
error[E0001]: cannot assign to immutable field 'selector' of struct 'Gate'
--> src/main.lpfree:11:5
|
11 | g.selector = 0x10u16
| ^^^^^^^^^^^^^^^^^^^^
|
Either build a new Gate with the other selector, or declare the field var selector: uint16; if changing it is part of the design.
Two Rust habits do not carry over: there is no separate impl block (methods go inside the struct), and there is no Name { field: value } literal (construction is a call with named arguments, Name(field = value)). Writing impl Pte { … } produces error[E0003]: expected ;`` at the type name, followed by a cascade.
Default layout, and why size_of refuses it
Without a @repr annotation a struct's layout is the compiler's business. That is fine for a struct that never leaves Loup code, but it means the struct has no size you can rely on, and size_of says so:
@mode(freestanding)
struct Descriptor {
var base: uint32;
var limit: uint16;
}
func descriptorBytes(): uint64 {
return size_of[Descriptor]()
}
error[E_SIZE_OF_UNSIZED]: E_SIZE_OF_UNSIZED: `size_of[Descriptor]()` — `Descriptor` has no statically-known finite size, so there is no single number to answer with.
--> src/main.lpfree:9:12
|
9 | return size_of[Descriptor]()
| ^^^^^^^^^^^^^^^^^^^^^
|
Anything that crosses a boundary — a register block, a network header, a DMA descriptor, a structure the CPU reads, a C function's argument — needs an explicit @repr. With @repr(C) the same query answers 8 (a uint32 at offset 0, a uint16 at offset 4, rounded up to 4-byte alignment).
@repr(C) — declaration order, natural alignment
@repr(C) lays fields out in declaration order, each at the next multiple of its own alignment, and rounds the struct up to its widest alignment (Language Reference §3-3.10 and the layout rules in §3-3.4.1). It is the layout a C compiler would choose.
@repr(C)
struct ElfHeader {
var ident: buffer[uint8, 16]; # magic, class, data, version, ABI
var fileType: uint16;
var machine: uint16;
var version: uint32;
var entry: uint64;
var phoff: uint64;
var shoff: uint64;
var flags: uint32;
var ehsize: uint16;
var phentsize: uint16;
var phnum: uint16;
var shentsize: uint16;
var shnum: uint16;
var shstrndx: uint16;
}
const ELF_HEADER_BYTES = size_of[ElfHeader]() # 64
Listing 5-2: The ELF64 file header as a @repr(C) struct
A fixed-length array field is a buffer[T, N] — N elements of T, laid out inline (chapter 13 covers buffers in full). The C-style tuple[uint8; 16] that older Loup material used is not valid syntax:
@mode(freestanding)
@repr(C)
struct ElfIdent {
var magic: tuple[uint8; 4];
var elfClass: uint8;
}
error[E0001]: expected RBracket, found Semicolon
--> src/main.lpfree:5:27
|
5 | var magic: tuple[uint8; 4];
| ^
|
error[E0001]: unexpected IntLiteral in struct body
--> src/main.lpfree:5:29
|
5 | var magic: tuple[uint8; 4];
| ^
|
Write var magic: buffer[uint8, 4];.
extern native structs must be @repr(C)
A struct passed to or returned from an extern native function crosses into code compiled by a C compiler, so its layout must be C's:
@mode(freestanding)
struct Timespec {
var seconds: int64;
var nanos: int64;
}
extern native "libfwclock" func fw_clock_read(ts: Timespec): int32;
error[E_EXTERN_STRUCT_NOT_REPR_C]: E_EXTERN_STRUCT_NOT_REPR_C: struct `Timespec` crosses the `extern native` boundary of `fw_clock_read` in freestanding mode without `@repr(C)` — a struct passed to or returned from an `extern native` function must carry `@repr(C)` so its layout matches the C ABI (§2-3.4)
--> src/main.lpfree:8:1
|
8 | extern native "libfwclock" func fw_clock_read(ts: Timespec): int32;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
Adding @repr(C) above struct Timespec fixes it. Each extern native "library" declaration names one C function the image links against.
Checking a layout at compile time
Getting a hardware structure's size wrong is a quiet bug: the code compiles, and the CPU reads garbage. You can make the compiler check the size. A const is evaluated during compilation, and a const func that reaches unreachable stops that evaluation with an error:
@mode(freestanding)
# Fails compilation when a hardware-defined layout has the wrong size.
const func expectSize(actual: uint64, expected: uint64): uint64 {
if (actual != expected) {
unreachable
}
return actual
}
# x86_64 Task State Segment (Intel SDM Vol. 3, "64-bit TSS format"): 104 bytes.
@repr(C)
struct TaskStateSegment {
var reserved0: uint32;
var rsp0: uint64;
var rsp1: uint64;
var rsp2: uint64;
var reserved1: uint64;
var ist: buffer[uint64, 7];
var reserved2: uint64;
var reserved3: uint16;
var ioMapBase: uint16;
}
const TSS_BYTES = expectSize(size_of[TaskStateSegment](), 104u64)
error[E_CONST_NOT_EVALUABLE]: `const TSS_BYTES`: the initialiser is a const-expression in form, but evaluating it reached this expression, which the const evaluator does not support — a `const` is decided at compile time or not at all; use `let` for a runtime value (E_CONST_NOT_EVALUABLE)
--> src/main.lpfree:25:19
|
25 | const TSS_BYTES = expectSize(size_of[TaskStateSegment](), 104u64)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
The message is generic — the evaluator "reached" the unreachable — but it points at the right line, and the check caught a real bug. Under @repr(C), rsp0 is aligned to offset 8, so the struct is 112 bytes. The hardware format puts rsp0 at offset 4: the TSS is a packed structure. The fix is the layout annotation, and the same check now passes:
@repr(packed)
struct TaskStateSegment {
var reserved0: uint32;
var rsp0: uint64;
var rsp1: uint64;
var rsp2: uint64;
var reserved1: uint64;
var ist: buffer[uint64, 7];
var reserved2: uint64;
var reserved3: uint16;
var ioMapBase: uint16;
}
const TSS_BYTES = expectSize(size_of[TaskStateSegment](), 104u64)
Listing 5-3: A compile-time size check on the x86_64 TSS
@repr(packed) — no padding
@repr(packed) keeps declaration order but drops all padding, inside the struct and at its end. Fields may then sit at addresses that are not aligned for their type; the compiler emits unaligned-safe loads and stores for them, which is defined behaviour in Loup (§3-3.10). Use it for wire formats and for hardware structures that are specified as packed, like the TSS above.
@repr(packed)
struct UdpHeader {
var srcPort: uint16;
var dstPort: uint16;
var length: uint16;
var checksum: uint16;
}
@repr(packed)
struct EthernetHeader {
var dst: buffer[uint8, 6];
var src: buffer[uint8, 6];
var etherType: uint16;
}
const UDP_HEADER_BYTES = size_of[UdpHeader]() # 8
const ETHERNET_HEADER_BYTES = size_of[EthernetHeader]() # 14, not rounded up to 16
func invertChecksum(edits h: UdpHeader): void {
h.checksum = h.checksum ^ 0xFFFFu16
}
Listing 5-4: Packed wire-format headers
Two cautions. Network headers are big-endian on the wire; a packed struct fixes the positions of the bytes, not their order, so convert multi-byte fields when you read or write them (@interchange in the Language Reference §4-2 does that for you on hosted targets). And an unaligned field is still unaligned when you hand its address to hardware or to asm: copy it into an aligned local first.
@repr(transparent) — zero-cost newtypes
@repr(transparent) is for a struct with exactly one field that must have the same representation and calling convention as that field. It turns a raw integer into a distinct type at no cost — a physical address cannot be passed where a virtual address is expected:
@repr(transparent)
struct PhysAddr {
var raw: uint64;
}
@repr(transparent)
struct VirtAddr {
var raw: uint64;
}
# The kernel maps all physical memory at this offset.
let PHYS_MAP_BASE: uint64 = 0xFFFF_8000_0000_0000u64
func physToVirt(p: PhysAddr): VirtAddr {
return VirtAddr(raw = p.raw + PHYS_MAP_BASE)
}
Listing 5-5: Newtypes for physical and virtual addresses
PHYS_MAP_BASE is a module let, not a const: in 1.25 the compile-time evaluator reads integer literals as signed 64-bit values, so a const initialized with a uint64 literal above 0x7FFF_FFFF_FFFF_FFFF is rejected as an overflow (E_CONST_NOT_EVALUABLE). A let holds the same value.
The Language Reference requires exactly one non-zero-sized field (E_REPR_TRANSPARENT_MULTI_FIELD) and gives the struct the size and ABI of that field. The 1.25 checker does not report a second field yet, so keep transparent structs to one field yourself. It also does not answer size_of for a transparent struct yet (it reports E_SIZE_OF_UNSIZED); query the inner type instead.
@align(n) — minimum alignment
@align(n) raises the alignment of a struct type, or of a module-level variable, to n bytes; n must be a power of two and at least the natural alignment.
# An x86_64 page table: 512 entries, and the hardware requires 4 KiB alignment.
@align(4096)
@repr(C)
struct PageTable {
var entries: buffer[uint64, 512];
}
const PAGE_TABLE_BYTES = size_of[PageTable]() # 4096
const PAGE_TABLE_ALIGN = align_of[PageTable]() # 4096
# One counter per CPU, each on its own 64-byte cache line (no false sharing).
@align(64)
@repr(C)
struct PerCpuCounter {
var count: uint64;
}
var cpuCounters: buffer[PerCpuCounter, 4];
func bump(cpu: int): void {
cpuCounters[cpu].count = cpuCounters[cpu].count + 1u64
}
Listing 5-6: Page-aligned and cache-line-aligned types
The annotation applies to every instance of the type: module globals, buffer elements, locals. The rules that n be a power of two (E_ALIGN_NOT_POWER_OF_TWO) and not smaller than the natural alignment (E_ALIGN_TOO_SMALL) are specified but not yet checked, so an @align(3) slips through loup check today.
size_of[T]() and align_of[T]()
Both are compile-time expressions that fold to a uint64 constant against the build target; no import is needed and they cost nothing at run time (§3-3.4.1). The type must have a known layout — a primitive, a @repr struct, an enum, a tuple or a buffer. A class does not:
@mode(freestanding)
class Task {
pub var id: uint32 = 0;
pub var stackTop: uint64 = 0;
}
func taskBytes(): uint64 {
return size_of[Task]()
}
error[E_SIZE_OF_CLASS]: E_SIZE_OF_CLASS: `size_of[Task]()` — `Task` is a managed class, so a binding of that type holds a REFERENCE to an ARC-headered heap object. Ask for the layout of a `@repr(C)` struct, a primitive, an enum or a tuple.
--> src/main.lpfree:9:12
|
9 | return size_of[Task]()
| ^^^^^^^^^^^^^^^
|
A variable of class type is a reference; if you need to know how big the object behind it is, describe the data as a @repr(C) struct.
A page-table entry type
Putting the pieces together — a transparent entry type with its flag constants and methods, and a page-aligned table of them:
const PTE_PRESENT: uint64 = 1u64 << 0
const PTE_WRITABLE: uint64 = 1u64 << 1
const PTE_USER: uint64 = 1u64 << 2
const PTE_NO_EXECUTE: uint64 = 1u64 << 63
const PTE_FRAME_MASK: uint64 = 0x000F_FFFF_FFFF_F000u64
@repr(transparent)
struct Pte {
var raw: uint64;
pub static func empty(): Pte {
return Pte(raw = 0u64)
}
pub static func map(frame: uint64, flags: uint64): Pte {
return Pte(raw = (frame & PTE_FRAME_MASK) | flags | PTE_PRESENT)
}
pub func isPresent(): bool {
return (this.raw & PTE_PRESENT) != 0u64
}
pub func frame(): uint64 {
return this.raw & PTE_FRAME_MASK
}
}
@align(4096)
@repr(C)
struct PageTable {
var entries: buffer[Pte, 512];
}
func mapIdentity(edits table: PageTable, index: int): void {
let frame = (index as uint64) * 4096u64
table.entries[index] = Pte.map(frame, PTE_WRITABLE | PTE_NO_EXECUTE)
}
Listing 5-7: A page-table entry newtype with methods
Pte.map(...) is a static method call; table.entries[index] writes one 8-byte element of the table in place. Chapter 23 builds a safe mapping API on top of this.
Field defaults
A field may declare a default. A struct variable declared without an initializer, var p: T;, gets every field's default — or zero when the field has none — on every path, so no stale stack bytes leak through:
@repr(C)
struct RxDescriptor {
var bufferAddr: uint64 = 0;
var length: uint16 = 0;
var status: uint8 = 0;
var errors: uint8 = 0;
}
func freshDescriptor(addr: uint64): RxDescriptor {
var d: RxDescriptor; # all fields start at their defaults
d.bufferAddr = addr
return d
}
Listing 5-8: A default-initialized descriptor
Field privacy across modules
A field declared without pub is private to the module that declares the struct. Code in that module reads and writes it; code in any other module that writes it is rejected. Here caps.lpfree declares a capability type whose identifier field is private:
pub struct Cap {
var idRaw: uint64 = 0; # private to this module
pub var rights: uint64 = 0;
}
pub func mint(id: uint64): Cap {
return Cap(idRaw = id, rights = 1u64)
}
pub func idOf(c: Cap): uint64 {
return c.idRaw
}
Listing 5-9: src/caps.lpfree — a struct with a private field
and another module tries to change a capability it was given:
@mode(freestanding)
from caps import (Cap, mint, idOf);
func upgrade(): uint64 {
var c = mint(7u64)
c.idRaw = 0u64 # try to turn it into capability 0
return idOf(c)
}
error[E_FIELD_PRIVATE]: member 'idRaw' of struct 'Cap' is private
--> src/main.lpfree:7:5
|
7 | c.idRaw = 0u64 # try to turn it into capability 0
| ^^^^^^^
|
(This listing imports a sibling module, so it is shown with the output loup check printed for the two-file project rather than checked on its own.)
There is one gap to know about: in 1.25, constructing the struct from another module with named arguments — Cap(idRaw = 0u64, rights = 7u64) — is not rejected. Until it is, do not rely on private fields alone to make a type unforgeable; keep the type's constructor-like functions as the only documented way to make one, and review imports of the type.
Summary
- Structs are values;
varfields are mutable, keyword-less fields are fixed at construction. Methods live inside the struct; construct withName(field = value). - Without
@reprthe layout is unspecified andsize_ofrefuses the type (E_SIZE_OF_UNSIZED). @repr(C): declaration order with natural alignment — required forextern nativestructs (E_EXTERN_STRUCT_NOT_REPR_C).@repr(packed): no padding; fields may be unaligned.@repr(transparent): one-field newtypes with the field's ABI.- Fixed-length array fields are
buffer[T, N], nottuple[T; N]. @align(n)raises alignment for types and module variables.size_of[T]()/align_of[T]()fold to constants; aconst functhat reachesunreachableturns a wrong size into a compile error.- Non-
pubfields cannot be written from another module (E_FIELD_PRIVATE), but cross-module construction is not yet blocked.
Next: enums and pattern matching.