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 7: Classes and Allocators

In the hosted modes a class instance lives on a heap managed by ARC. A freestanding image has neither a heap nor ARC, so classes need more care. This chapter covers:

  • declaring and constructing classes in freestanding, and where the objects actually live in Loup 1.25;
  • why unique class is the class kind to use;
  • registering an @allocator/@deallocator pair, and the signature mistakes the compiler catches;
  • del, finalize() and consume;
  • patterns that avoid dynamic allocation altogether.

Declaring and constructing a class

Class syntax is the same as everywhere else: fields are declared let or var, the constructor is new(...), and methods can carry receiver verbs (chapter 10).

@mode(freestanding)

unique class Task {
    pub var id: uint32;
    pub var stackTop: uint64;
    pub var runnable: bool = false;

    new(id: uint32, stackTop: uint64) {
        this.id = id
        this.stackTop = stackTop
    }

    reads func describe(): uint32 {
        return this.id
    }

    modifier func wake(): void {
        this.runnable = true
    }
}

func startIdleTask(): uint32 {
    var idle = Task(0u32, 0x9_0000u64)
    idle.wake()
    return idle.describe()
}

Listing 7-1: A unique class with a constructor and methods

Task(…) and new Task(…) are the same construction. A field without pub is private to the class.

Where a class object lives in 1.25

The Language Reference says that in freestanding a class can only be constructed once the program registers an @allocator, that construction allocates through it, and that constructing a class without one is E_FREESTANDING_CLASS_NO_ALLOC (§3-3.3, §3-3.16).

⚠ Planned. The 1.25 compilers do not implement that integration. loup check accepts class construction with or without an allocator, and the freestanding back ends never call the @allocator function: they keep it in the image and check its signature, but place each object the way they place a struct value — in the constructing function's stack frame when the analysis can prove that is safe, otherwise in a static block reserved for that construction site (Language Reference §3-3.17c, §3-3.24.1).

The static-block case is the one to watch. A static block belongs to the site, so two executions of the same function — a recursive call, an interrupt that lands while the function runs, a second CPU — share one object. onstack turns that into a guarantee of per-invocation storage, or a compile error naming why it cannot be honoured:

unique class Port {
    pub var base: uint16;
    new(base: uint16) { this.base = base; }
}

# Called from the main loop and from the serial interrupt handler: each
# activation needs its own Port object.
func statusPort(base: uint16): uint16 {
    onstack var port: Port = Port(base)
    return port.base + 5u16
}

Listing 7-2: onstack gives a class local per-invocation storage

onstack needs a declared struct or class type on the binding, as here; chapter 13 covers its rules.

unique class versus class

A plain class value is a reference. In the hosted modes ARC counts those references; in freestanding nothing does, and the checker lets you copy one freely:

class Counter {
    pub var hits: uint32 = 0;
}

func twoNames(): uint32 {
    let a = Counter()
    let b = a              # a second reference to the same object
    return a.hits + b.hits
}

Listing 7-3: A plain class reference can be aliased

With no reference count, nobody knows when the last reference is gone. A unique class has exactly one owner at a time: assigning it moves it, and using the old binding afterwards is E_USE_AFTER_MOVE (chapter 12). Single ownership is what lets del and takes say precisely when an object's life ends, so unique class is the kind to use for anything that owns a resource — a device, a DMA region, a lease on a pool slot.

Registering @allocator and @deallocator

An allocator is a pair of module-level functions with fixed signatures (§3-3.16):

  • @allocator func name(size: uint64, align: uint64): uint? — Some(address) on success, None when out of memory;
  • @deallocator func name(ptr: uint, size: uint64, align: uint64): void — receives the size and alignment of the original request.

Here is a bump allocator over a 64 KiB module buffer:

# 64 KiB of heap in .bss, as 8-byte words.
var heapWords: buffer[uint64, 8192];
var heapNext: uint64 = 0
const HEAP_BYTES: uint64 = 65536u64

@allocator
func bumpAlloc(size: uint64, align: uint64): uint? {
    let base = addressOf(heapWords) as uint64
    let mask = align - 1u64
    let start = (heapNext + mask) & bnot mask     # round up to `align`
    if (start + size > HEAP_BYTES) {
        return None                               # out of memory
    }
    heapNext = start + size
    return Some((base + start) as uint)
}

@deallocator
func bumpFree(ptr: uint, size: uint64, align: uint64): void {
    # A bump allocator never reuses memory; a free list would go here.
    return
}

Listing 7-4: A bump allocator registered as the image's allocator

addressOf(g) is a freestanding intrinsic that yields the address of a module-level variable (§3-3.4.2). The allocator is not thread-safe; chapter 19 shows how to make the bump step atomic.

The return type is the most common mistake. A C allocator returns 0 for "out of memory", and a C habit writes this:

This code does not compile
@mode(freestanding)

var heapWords: buffer[uint64, 8192];
var heapNext: uint64 = 0

# C habit: return 0 for "out of memory".
@allocator
func bumpAlloc(size: uint64, align: uint64): uint {
    if (heapNext + size > 65536u64) {
        return 0
    }
    let addr = (addressOf(heapWords) as uint64) + heapNext
    heapNext = heapNext + size
    return addr as uint
}

@deallocator
func bumpFree(ptr: uint, size: uint64, align: uint64): void {
    return
}
src/main.lpfree:7:1: error: E_ALLOCATOR_SIG_V16: `@allocator` function must return `uint?` (nullable unsigned integer) in freestanding mode

(This is the one-line form loup check --format short prints.) Address 0 can be a valid physical address on bare metal, so Loup makes failure a separate value: return uint?, with None for failure, as in Listing 7-4.

The deallocator's signature is checked the same way. A deallocator that takes only the pointer — the C free(void *) shape — is rejected:

This code does not compile
@mode(freestanding)

@allocator
func poolAlloc(size: uint64, align: uint64): uint? {
    return None
}

@deallocator
func poolFree(ptr: uint): void {
    return
}
error[E_DEALLOCATOR_SIG]: E_DEALLOCATOR_SIG: `@deallocator` `poolFree` must be `(ptr: uint, size: uint64, align: uint64): void`; it is `(uint): void` (§3-3.16)
 --> src/main.lpfree:8:1
  |
8 | @deallocator
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |

Passing the size back means a size-class allocator never has to store a header in front of each block. The Language Reference adds three more rules — at most one of each per program (E_ALLOCATOR_DUPLICATE, E_DEALLOCATOR_DUPLICATE), the two must come as a pair (E_ALLOCATOR_UNPAIRED), and both are freestanding-only (E_ALLOCATOR_NOT_FREESTANDING). The 1.25 checker does not report those yet; keep them by hand.

Calling the allocator yourself

An @allocator function is also an ordinary function. Until class construction routes through it, calling it directly is how you get dynamic memory — for a packet buffer, a page-table page, a per-task stack:

enum AllocError { OutOfMemory }

# A 16 KiB kernel stack for a new task; returns the address of its top.
func allocStack(): Result[uint, AllocError] {
    match bumpAlloc(16384u64, 16u64) {
        Some(base) => { return Ok(base + 16384) }
        None => { return Err(AllocError.OutOfMemory) }
    }
}

Listing 7-5: Turning allocation failure into an error value

The Language Reference's native.mem.alloc/free/tryAlloc wrappers over the registered allocator (§3-3.20) are not available in freestanding in 1.25 (chapter 20 lists what is).

del and finalize()

del x ends a binding's life early and runs the class's finalize() method, if it has one (Language Reference §1-6.11). After del, the binding is uninitialized, and the compiler rejects any later use:

This code does not compile
@mode(freestanding)

unique class Lease {
    pub var slot: uint32;
    new(slot: uint32) { this.slot = slot; }
    func finalize(): void {
        # return the slot to the pool here
    }
}

func releaseEarly(): uint32 {
    let lease = Lease(3u32)
    del lease
    return lease.slot
}
error[E_USE_AFTER_DEL]: E_USE_AFTER_DEL: use of binding 'lease' after `del` — the binding is uninitialized once it has been passed to `del` (§1-6.11)
  --> src/main.lpfree:14:12
   |
14 |     return lease.slot
   |            ^^^^^
   |

Read what you need first, then del:

func releaseEarly(): uint32 {
    let lease = Lease(3u32)
    let slot = lease.slot
    del lease                   # finalize() runs here
    return slot
}

Listing 7-6: Reading a field before del

The Language Reference also has del hand the object's memory to the @deallocator; like allocation, that half waits for the planned allocator integration.

consume does not exist here

In host.native, consume x turns a unique value into an ordinary ARC reference. There is no ARC in freestanding, so the downgrade is meaningless:

This code does not compile
@mode(freestanding)

unique class DmaRegion {
    pub var base: uint64;
    new(base: uint64) { this.base = base; }
}

func publish(): uint64 {
    unique let region = DmaRegion(0x8000_0000u64)
    let counted = consume region
    return counted.base
}
error[E_CONSUME_NOT_AVAILABLE]: E_CONSUME_NOT_AVAILABLE: `consume` is not available in @mode(freestanding) mode — the unique-to-ARC downgrade requires an ARC runtime, which exists only in `@mode(host.native)` or `@mode(host.dual)`
  --> src/main.lpfree:10:19
   |
10 |     let counted = consume region
   |                   ^^^^^^^^^^^^^^
   |

error[E_UNIQUE_CLASS_CONSUME]: E_UNIQUE_CLASS_CONSUME: `consume` on unique class binding 'DmaRegion' is not allowed — unique class instances cannot be downgraded to ARC
  --> src/main.lpfree:10:19
   |
10 |     let counted = consume region
   |                   ^^^^^^^^^^^^^^
   |

Keep the single owner and lend the object instead: pass it to a reads or borrow parameter, or transfer it with takes when the receiver should own it (chapters 10–12).

Designs that need no allocator

Many freestanding programs never allocate after boot. A boot shim runs once; a microcontroller has a fixed set of peripherals; a kernel subsystem can size its tables at build time. The patterns:

Module-level tables. A buffer at module scope is zero-initialized storage in .bss, with no allocator involved:

@repr(C)
struct TaskSlot {
    var used: bool;
    var stackTop: uint64;
    var entry: uint64;
}

# A handle is just an index into the table.
@repr(transparent)
struct TaskHandle {
    var index: uint16;
}

var tasks: buffer[TaskSlot, 64];

func allocTask(entry: uint64): Option[TaskHandle] {
    var i = 0
    while (i < 64) {
        if (not tasks[i].used) {
            tasks[i].used = true
            tasks[i].entry = entry
            return Some(TaskHandle(index = i as uint16))
        }
        i = i + 1
    }
    return None                       # table full
}

func freeTask(h: TaskHandle): void {
    tasks[h.index as int].used = false
}

Listing 7-7: A fixed task table handing out index handles

Frame storage. Struct locals and onstack buffers live in the function's frame and disappear with it (chapter 13).

Values instead of objects. Most freestanding data — descriptors, register snapshots, configuration — is better modelled as a @repr(C) struct than as a class: it has a known layout, size_of answers for it, and copying it is explicit.

Summary

  • Classes are declared and constructed as in other modes; unique class gives single ownership, which is what freestanding code needs. A plain class reference can be aliased and nothing counts the references.
  • The Language Reference routes class construction through the registered @allocator; in 1.25 that is not implemented — objects are staged in the frame or in a per-site static block. Declare re-entrant locals onstack.
  • @allocator is (size: uint64, align: uint64): uint?; a plain uint return is E_ALLOCATOR_SIG_V16. @deallocator is (ptr: uint, size: uint64, align: uint64): void (E_DEALLOCATOR_SIG). Call the allocator directly for dynamic memory today.
  • del runs finalize(); using the binding afterwards is E_USE_AFTER_DEL. consume is unavailable (E_CONSUME_NOT_AVAILABLE, E_UNIQUE_CLASS_CONSUME).
  • Fixed tables with index handles, frame storage and plain structs avoid dynamic allocation entirely.

Next: interfaces and generics.