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 classis the class kind to use; - registering an
@allocator/@deallocatorpair, and the signature mistakes the compiler catches; del,finalize()andconsume;- 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 checkaccepts class construction with or without an allocator, and the freestanding back ends never call the@allocatorfunction: 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,Nonewhen 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:
@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:
@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:
@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:
@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 classgives 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 localsonstack. @allocatoris(size: uint64, align: uint64): uint?; a plainuintreturn isE_ALLOCATOR_SIG_V16.@deallocatoris(ptr: uint, size: uint64, align: uint64): void(E_DEALLOCATOR_SIG). Call the allocator directly for dynamic memory today.delrunsfinalize(); using the binding afterwards isE_USE_AFTER_DEL.consumeis 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.