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 nofn name(...) -> Tform;fnappears only in function types such asfn(int): void. pub funcexports a function from its module. Withoutpubit 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// commentis 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:
@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.
@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:
@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:
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:
@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 themE_FREESTANDING_ASYNCerrors. The 1.25 checker accepts a plainasync funcdeclaration 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 withE_FREESTANDING_EXCEPTIONS; chapter 9 showsResult,?, and thetry … catch Err(e)form that is allowed.portalstatements outside--debugbuilds (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 { … };pubexports; default parameters and named arguments work as in every mode.#comments, not//.- Conditions are always parenthesized (
E_CONDITION_PARENS);ifis an expression and there is no ternary. while, C-stylefor,for-inover ranges, strings and generators; abufferis indexed, not iterated. Noloopkeyword — usewhile (true).matchis exhaustive and supports guards.deferis accepted but its block does not run in 1.25 — release resources explicitly on each exit path;defer importis rejected in freestanding.- A lambda that assigns a captured variable declares it with
nonlocalorglobal(E_GLOBAL_MISSING).noescape fnparameters take visitors that must not escape. gen funcneeds an@allocatorin freestanding by rule, though the checker does not enforce it;asyncand exceptions are not available.
Next: structs and layout control.