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 4: Functions and Control Flow

Most of Loup's everyday syntax is the same in @mode(freestanding) as in any other mode: func, if/else, while, for, match, return, break, continue and lambdas. This chapter is a quick tour of that syntax, with the mistakes C and Rust programmers make most often, and then the parts that need care in freestanding: closures, generators and async.

Function declarations

@mode(freestanding)

# Parameters and the return type are always written out.
func add(a: uint32, b: uint32): uint32 {
    return a + b
}

func clamp(x: int32, lo: int32, hi: int32): int32 {
    if (x < lo) { return lo }
    if (x > hi) { return hi }
    return x
}

# A default value is evaluated on every call that omits the argument.
func scale(x: uint32, factor: uint32 = 2u32): uint32 {
    return x * factor
}

func nop(): void { }

pub func demo(): uint32 {
    return add(1u32, 2u32) + scale(3u32) + scale(3u32, factor = 4u32)
}

Listing 4-1: Function declarations, a default parameter and a named argument

A few rules carry over from the rest of the language:

  • The keyword is func, and the return type follows a colon. There is no fn name(...) -> T form; fn appears only in function types such as fn(int): void.
  • pub func exports a function from its module. Without pub it is private to the module.
  • Arguments with defaults can be passed by name: scale(3u32, factor = 4u32).
  • # starts a comment. // is the floor-division operator, so a C-style // comment is a syntax error.

The default calling convention in freestanding is the platform C ABI. @callconv(...) overrides it for functions that are called from firmware or assembly — for example win64 for a UEFI entry point (chapter 16):

@callconv(win64)
func efiMain(imageHandle: uint64, systemTable: uint64): uint64 {
    return 0u64      # EFI_SUCCESS
}

Listing 4-2: Overriding the calling convention

Conditions need parentheses

Every condition — if, while, and the header of a for — is written in parentheses, and every body in braces. A Rust or Go habit trips this rule:

This code does not compile
@mode(freestanding)

func classify(irq: uint32): uint8 {
    if irq < 32u32 {
        return 0u8          # CPU exception
    }
    return 1u8              # device interrupt
}
error[E0001]: E_CONDITION_PARENS: the condition of `if` must be in parentheses; add them: `if (...) {`
 --> src/main.lpfree:4:8
  |
4 |     if irq < 32u32 {
  |        ^^^^^^^^^^^
  |

The message says exactly what to write: if (irq < 32u32) {.

if, while, for

func irqClass(irq: uint32): uint8 {
    if (irq < 32u32) {
        return 0u8                    # CPU exception
    } else if (irq < 48u32) {
        return 1u8                    # legacy PIC line
    } else {
        return 2u8                    # MSI / APIC vector
    }
}

# Busy-wait until bit 5 of a status value is set, giving up after `limit` polls.
func pollReady(readStatus: fn(): uint8, limit: uint32): bool {
    var tries: uint32 = 0
    while (tries < limit) {
        if ((readStatus() & 0x20u8) != 0u8) {
            return true
        }
        tries = tries + 1u32
    }
    return false
}

func sumFirst(n: int): uint64 {
    var total: uint64 = 0
    for (var i = 0; i < n; i = i + 1) {        # C-style three-part header
        total = total + (i as uint64)
    }
    for (j in 0..4) {                          # a range as the loop source
        total = total + (j as uint64)
    }
    return total
}

func firstDivisibleBy7(n: int): int {
    var i = 0
    while (i < n) {
        if (i > 0 and i % 7 == 0) {
            return i
        }
        i = i + 1
    }
    return 0 - 1                               # not found
}

Listing 4-3: Conditionals and loops

There is no loop { … } keyword; write while (true) { … }, which is also how a kernel's idle loop and a noreturn function end (chapter 17). break and continue work in every loop. A range such as 0..4 is allowed only as a loop source.

if is also an expression, and it is the only conditional expression: Loup has no ?: ternary.

This code does not compile
@mode(freestanding)

func pageKind(size: uint64): uint8 {
    let kind = size > 4096u64 ? 2u8 : 1u8
    return kind
}
error[E0003]: expected `;`
 --> src/main.lpfree:4:33
  |
4 |     let kind = size > 4096u64 ? 2u8 : 1u8
  |                                 ^^^
  |

The parser reads size > 4096u64 as a complete expression, sees ? (the error-propagation operator) and then cannot continue. Write the if form:

func pageKind(size: uint64): uint8 {
    let kind = if (size > 4096u64) { 2u8 } else { 1u8 }
    return kind
}

Listing 4-4: if as an expression instead of a ternary

Iterating a buffer

for (x in …) works over ranges and strings. A fixed-size buffer[T, N] (chapter 13) is a plain block of memory with no iterator, so a for-in loop over one is rejected:

This code does not compile
@mode(freestanding)

func checksum(): uint64 {
    var block: buffer[uint64, 8];
    var sum: uint64 = 0
    for (word in block) {
        sum = sum ^ word
    }
    return sum
}
error[E0001]: type 'buffer[uint64, 8]' has no method 'iter'
 --> src/main.lpfree:6:5
  |
6 |     for (word in block) {
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |

Index it instead. The element count is part of the type, so the bound is known:

func checksum(): uint64 {
    var block: buffer[uint64, 8];
    var sum: uint64 = 0
    for (var i = 0; i < 8; i = i + 1) {
        sum = sum ^ block[i]
    }
    return sum
}

func serialChecksum(message: string): uint8 {
    var sum: uint8 = 0
    for (c in message) {                 # strings do iterate
        sum = sum ^ (c as uint8)
    }
    return sum
}

Listing 4-5: Indexing a buffer; iterating a string

match

match takes the scrutinee without parentheses, and every arm is pattern => expression or pattern => { block }. It must be exhaustive.

enum Irq {
    Timer,
    Keyboard,
    Serial(port: uint16),
    Unknown(vector: uint32),
}

func irqName(irq: Irq): string {
    return match irq {
        Irq.Timer => "timer",
        Irq.Keyboard => "keyboard",
        Irq.Serial(port) => "serial",
        Irq.Unknown(vector) => "unknown",
    }
}

func isLegacy(irq: Irq): bool {
    match irq {
        Irq.Unknown(vector) if (vector >= 48u32) => { return false }
        _ => { return true }
    }
}

Listing 4-6: match as an expression and as a statement, with a guard

Chapter 6 covers patterns, guards and the exhaustiveness rule in detail. Exhaustiveness matters more in freestanding than elsewhere: there is no exception to catch an unexpected value at run time.

defer

The Language Reference defines defer { … } to run a block when the enclosing scope exits, on every path — a normal return, an early return, or an early exit through ? (§1-6.11). That would make it the natural place to release a lock. In Loup 1.25 it does not work that way, and the failure is silent: the checker accepts the statement, but the deferred block is never executed — the interpreters skip it, and the freestanding back ends treat the defer bookkeeping as a no-op. This listing compiles and leaves the lock held on both paths:

This code does not do what it should
var listLock: uint64 = 0

func lock(): void {
    # Spin until the lock word goes 0 -> 1 (the intrinsic returns 1 on success).
    while (atomicCompareExchangeU64(addressOf(listLock), 0u64, 1u64, 2) == 0u64) { }
}

func unlock(): void {
    atomicStoreU64(addressOf(listLock), 0u64, 3)
}

func removeEntry(index: uint32): bool {
    lock()
    defer { unlock() }        # accepted, but never runs in 1.25

    if (index >= 64u32) {
        return false
    }
    # ... unlink the entry ...
    return true
}

Listing 4-7: A defer that is accepted but does not run

⚠ Planned. Running deferred blocks at scope exit is specified but not implemented in Loup 1.25. Until it is, write the cleanup on each exit path.

The working version releases the lock explicitly before each return:

func removeEntry(index: uint32): bool {
    lock()
    if (index >= 64u32) {
        unlock()
        return false
    }
    # ... unlink the entry ...
    unlock()
    return true
}

Listing 4-8: Releasing a spin lock on every exit path explicitly

The atomic intrinsics used here are covered in chapter 19. Keeping the exits few — one early return for the error case, one at the end — keeps explicit cleanup manageable.

defer import is a different feature that shares the keyword: a lazily resolved import, which needs a runtime module loader. It is rejected in freestanding (E_DEFERRED_NOT_FREESTANDING).

Lambdas and closures

A lambda is written func(params): ReturnType { … }, or func(params) => expression for a one-line body. It can read the variables around it. To assign to one, the lambda must say so at the top of its body: nonlocal name; for a variable of an enclosing function, global name; for a module-level variable (Language Reference §1-7.3). Forgetting it is an error:

This code does not compile
@mode(freestanding)

func countEven(limit: uint32): uint32 {
    var evens: uint32 = 0
    let visit = func(n: uint32): void {
        if (n % 2u32 == 0u32) {
            evens = evens + 1u32
        }
    }
    var i: uint32 = 0
    while (i < limit) {
        visit(i)
        i = i + 1u32
    }
    return evens
}
error[E_GLOBAL_MISSING]: E_GLOBAL_MISSING: assignment to outer-scope `var` `evens` inside a closure needs a `nonlocal evens` (an enclosing function's variable) or `global evens` (a module variable) declaration at the top of the closure's body (§1-7.3)
 --> src/main.lpfree:7:13
  |
7 |             evens = evens + 1u32
  |             ^^^^^^^^^^^^^^^^^^^^
  |

The fix is one line:

func countEven(limit: uint32): uint32 {
    var evens: uint32 = 0
    let visit = func(n: uint32): void {
        nonlocal evens;
        if (n % 2u32 == 0u32) {
            evens = evens + 1u32
        }
    }
    var i: uint32 = 0
    while (i < limit) {
        visit(i)
        i = i + 1u32
    }
    return evens
}

Listing 4-9: Declaring the captured variable a lambda assigns

A parameter typed noescape fn(...) promises that the callee only calls the function during the call and never stores it. That is the right shape for a visitor: the lambda's captures can live in the caller's frame, and the borrow checker knows the lambda cannot outlive them (chapter 11).

# Visit every slot index of a fixed table.
func forEachSlot(count: int, visit: noescape fn(int): void): void {
    var i = 0
    while (i < count) {
        visit(i)
        i = i + 1
    }
}

var dirtySlots: int = 0

func markAll(): int {
    forEachSlot(16, func(slot: int): void {
        global dirtySlots;
        dirtySlots = dirtySlots + 1
    })
    return dirtySlots
}

Listing 4-10: A noescape fn visitor

A closure that captures a unique value moves it into the closure (ruling CAPMV-1); chapter 12 shows what that means for ownership.

Generators

gen func declares a generator that yields values, and for-in consumes it:

gen func countdown(n: uint32): Iterable[uint32] {
    var i = n
    while (i > 0u32) {
        yield i
        i = i - 1u32
    }
}

func total(): uint32 {
    var sum: uint32 = 0
    for (v in countdown(3u32)) {
        sum = sum + v
    }
    return sum
}

Listing 4-11: A synchronous generator

A generator keeps its state between yields, and in freestanding that state needs storage that outlives a single call. The Language Reference therefore allows gen func in freestanding only when the program registers an @allocator (the mode feature matrix, §1-2.6). The 1.25 checker does not enforce that, so the rule is yours to keep: register an allocator (chapter 7) before you use generators, or write the plain loop, which is what most kernel code does. async gen func is not available in freestanding at all.

What is missing

These features need a runtime that a freestanding image does not have:

  • async, await, Task[T], spawn. There is no executor. The Language Reference makes them E_FREESTANDING_ASYNC errors. The 1.25 checker accepts a plain async func declaration without complaint, so do not take its silence as permission: use interrupt handlers, a polling main loop, or per-CPU work loops instead (chapters 16 and 19).
  • throw, catch (T as e). There is no unwinder. Both are rejected with E_FREESTANDING_EXCEPTIONS; chapter 9 shows Result, ?, and the try … catch Err(e) form that is allowed.
  • portal statements outside --debug builds (E_FREESTANDING_PORTAL); chapter 23 covers the freestanding debug service.

Early return with ?

A preview of chapter 9: a function that returns Result[T, E] can propagate an error from a call with ?. On Err, ? returns that error from the enclosing function; on Ok(v) it evaluates to v.

enum UartError {
    Timeout,
    Framing,
}

func readByte(): Result[uint8, UartError] {
    if (uartReady()) {
        return Ok(uartRead())
    }
    return Err(UartError.Timeout)
}

func readWord(): Result[uint16, UartError] {
    let high = readByte()?
    let low = readByte()?
    return Ok(((high as uint16) << 8) | (low as uint16))
}

Listing 4-12: Propagating an error with ?

Summary

  • func name(params): Type { … }; pub exports; default parameters and named arguments work as in every mode. # comments, not //.
  • Conditions are always parenthesized (E_CONDITION_PARENS); if is an expression and there is no ternary.
  • while, C-style for, for-in over ranges, strings and generators; a buffer is indexed, not iterated. No loop keyword — use while (true).
  • match is exhaustive and supports guards.
  • defer is accepted but its block does not run in 1.25 — release resources explicitly on each exit path; defer import is rejected in freestanding.
  • A lambda that assigns a captured variable declares it with nonlocal or global (E_GLOBAL_MISSING). noescape fn parameters take visitors that must not escape.
  • gen func needs an @allocator in freestanding by rule, though the checker does not enforce it; async and exceptions are not available.

Next: structs and layout control.