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 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:

This code does not compile
@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:

This code does not compile
@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:

This code does not compile
@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:

This code does not compile
@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:

This code does not compile
@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:

This code does not compile
@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:

Fragment — not a complete program
@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; var fields are mutable, keyword-less fields are fixed at construction. Methods live inside the struct; construct with Name(field = value).
  • Without @repr the layout is unspecified and size_of refuses the type (E_SIZE_OF_UNSIZED).
  • @repr(C): declaration order with natural alignment — required for extern native structs (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], not tuple[T; N].
  • @align(n) raises alignment for types and module variables.
  • size_of[T]()/align_of[T]() fold to constants; a const func that reaches unreachable turns a wrong size into a compile error.
  • Non-pub fields cannot be written from another module (E_FIELD_PRIVATE), but cross-module construction is not yet blocked.

Next: enums and pattern matching.