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 6: Enums and Pattern Matching

Enums carry a lot of the weight in freestanding code. Device states are enums, error types are enums, and so are the legal values of most register fields. Because match must be exhaustive, the code that handles an enum cannot silently drop a case — which matters when there is no exception to catch the case you forgot.

This chapter covers plain and data-carrying enums, Option[T] and Result[T, E], the patterns match understands, union types, and why errors in freestanding are values rather than exceptions.

Plain enums

A plain enum is a set of named variants, separated by commas. Variants are written with the enum's name, LinkState.Up, both when you construct one and in a match pattern:

@mode(freestanding)

enum LinkState { Down, Training, Up }

func ledPattern(s: LinkState): uint8 {
    return match s {
        LinkState.Down => 0x00u8,
        LinkState.Training => 0x55u8,      # blink
        LinkState.Up => 0xFFu8,
    }
}

func isUsable(s: LinkState): bool {
    return s == LinkState.Up               # enum equality is structural
}

Listing 6-1: A plain enum and an exhaustive match

The variants need the commas. Writing them one per line without commas, as in a C or Swift enum, fails at the second variant:

This code does not compile
@mode(freestanding)

enum LinkState {
    Down
    Training
    Up
}
error[E0002]: expected `}`
 --> src/main.lpfree:5:5
  |
5 |     Training
  |     ^^^^^^^^
  |

error[E0001]: expected expression
 --> src/main.lpfree:7:1
  |
7 | }
  | ^
  |

One variant per line is fine — with a comma after each (a trailing comma after the last one is allowed). An enum must have at least one variant; an empty enum E {} is E_ENUM_NO_VARIANTS, and the uninhabited type is spelled impossible.

A fieldless enum is stored as its tag, one pointer-sized word (Language Reference §3-3.4.1). It does not have a guaranteed numeric encoding, so when a hardware register field has defined values, keep the register as an integer and convert explicitly:

enum Parity { None, Odd, Even }

# 16550 line control register: bits 3..5 select parity.
func parityBits(p: Parity): uint8 {
    return match p {
        Parity.None => 0x00u8,
        Parity.Odd => 0x08u8,
        Parity.Even => 0x18u8,
    }
}

func parityFromBits(lcr: uint8): Option[Parity] {
    return match lcr & 0x38u8 {
        0x00u8 => Some(Parity.None),
        0x08u8 => Some(Parity.Odd),
        0x18u8 => Some(Parity.Even),
        _ => None,                         # a reserved encoding
    }
}

Listing 6-2: Converting between an enum and a register encoding

Data-carrying enums

A variant can carry named fields. Construct it with named arguments; in a pattern, bind the fields by position:

enum DiskError {
    Timeout,
    BadSector(lba: uint64),
    Mismatch(expected: uint32, actual: uint32),
}

func errorCode(e: DiskError): uint32 {
    return match e {
        DiskError.Timeout => 1u32,
        DiskError.BadSector(lba) => 2u32,
        DiskError.Mismatch(expected, actual) => 3u32,
    }
}

func checkCrc(expected: uint32, actual: uint32): Option[DiskError] {
    if (expected != actual) {
        return Some(DiskError.Mismatch(expected = expected, actual = actual))
    }
    return None
}

Listing 6-3: Variants with payloads

Exhaustiveness

Every variant must be handled, either by its own arm or by a _ wildcard arm. Add a variant to an enum and the compiler finds every match that has to change:

This code does not compile
@mode(freestanding)

enum LinkState { Down, Training, Up, Error(code: uint8) }

func ledPattern(s: LinkState): uint8 {
    return match s {
        LinkState.Down => 0x00u8,
        LinkState.Training => 0x55u8,
        LinkState.Up => 0xFFu8,
    }
}
error[E0001]: Non-exhaustive match: missing variants Error
 --> src/main.lpfree:6:12
  |
6 |     return match s {
  |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |

Handle the new case explicitly rather than reaching for _ — the next variant someone adds should also be flagged:

enum LinkState { Down, Training, Up, Error(code: uint8) }

func ledPattern(s: LinkState): uint8 {
    return match s {
        LinkState.Down => 0x00u8,
        LinkState.Training => 0x55u8,
        LinkState.Up => 0xFFu8,
        LinkState.Error(code) => 0xF0u8 | (code & 0x0Fu8),
    }
}

Listing 6-4: Handling the new variant

Patterns and guards

match arms can use:

  • variant patterns, with bindings: DiskError.BadSector(lba);
  • literal patterns: 0u32, true, "on";
  • a binding pattern that names the whole value: n;
  • the wildcard _;
  • a guard after any pattern: n if (n < 100u32) => … (the guard condition is parenthesized like every other condition).

Arms are tried in order, so a guarded arm must come before a more general one.

# Bucket an interrupt latency in microseconds for a histogram.
func latencyBucket(us: uint32): uint8 {
    return match us {
        0u32 => 0u8,
        n if (n < 10u32) => 1u8,
        n if (n < 100u32) => 2u8,
        n if (n < 1000u32) => 3u8,
        _ => 4u8,
    }
}

Listing 6-5: Literal patterns, bindings and guards

Option[T] and Result[T, E]

Two generic enums are built in: Option[T] with the variants Some(value) and None, and Result[T, E] with Ok(value) and Err(error). The constructors are available everywhere without an import.

Use Option for "might be absent" — a table lookup, a free slot, a reserved encoding as in Listing 6-2. Use Result for "might have failed", with an enum as the error type:

enum RegError { BadOffset(offset: uint32), Busy }

var regs: buffer[uint32, 64];

func readRegister(offset: uint32): Result[uint32, RegError] {
    if (offset >= 64u32) {
        return Err(RegError.BadOffset(offset = offset))
    }
    return Ok(regs[offset as int])
}

func readAndIncrement(offset: uint32): Result[uint32, RegError] {
    let current = readRegister(offset)?      # an Err returns from this function
    return Ok(current + 1u32)
}

func firstFreeSlot(used: uint64): Option[int] {
    var i = 0
    while (i < 64) {
        if ((used & (1u64 << (i as uint64))) == 0u64) {
            return Some(i)
        }
        i = i + 1
    }
    return None
}

func slotOrDefault(used: uint64): int {
    let slot = firstFreeSlot(used)
    if (slot.is_some()) {
        return slot.unwrap()
    }
    return firstFreeSlot(0u64).unwrap_or(0)
}

Listing 6-6: Result with an error enum, ?, and Option helpers

The helper methods use snake_case names: is_some(), is_none(), is_ok(), is_err(), unwrap(), unwrap_or(default), unwrap_err(). (The Language Reference spells some of them in camelCase, such as unwrapOr; use the snake_case names the toolchain implements.) x ?? default is shorthand for unwrapping an Option with a fallback.

unwrap() on None is a panic. In freestanding a panic is a stop handled by the package's panic policy — halt the core by default, or call your @panic_handler under panic = "custom" (chapter 17). Use unwrap() only where None really is impossible, and prefer match, ?? or unwrap_or everywhere else.

Chapter 9 covers Result, ? and error conversion in depth.

Union types

A union type lists alternatives inline: uint32 | bool holds either a uint32 or a bool. T | None is the same type as Option[T]. You narrow a union with type patterns in a match, or with is in an if:

# A boot option is either a number or an on/off switch.
func parseOption(s: string): uint32 | bool {
    if (s == "on") { return true }
    if (s == "off") { return false }
    return 0u32
}

func optionWeight(v: uint32 | bool): uint32 {
    return match v {
        uint32(n) => n,
        bool(b) => if (b) { 1u32 } else { 0u32 },
    }
}

func isNumeric(v: uint32 | bool): bool {
    if (v is uint32) {
        return true
    }
    return false
}

Listing 6-7: A union type narrowed by type patterns and is

Type patterns are exhaustive too: leave out the bool(b) arm and the compiler reports Non-exhaustive match: missing types bool. In freestanding every member of a union must be a concrete type known at compile time; Any is not allowed, because naming its type at run time would need reflection metadata. Prefer an enum when the alternatives mean something — a named variant documents itself; a union is for genuinely ad-hoc "this or that type" values.

Errors are values in freestanding

In the hosted modes a function can throw and a caller further up can catch. A freestanding image has no unwinder and no .eh_frame, so exceptions do not exist there. A habit from hosted code is caught at once:

This code does not compile
@mode(freestanding)

enum UartError { Timeout, Framing, Overrun }

func readByte(ready: bool, data: uint8): uint8 {
    if (not ready) {
        throw UartError.Timeout
    }
    return data
}
error[E_FREESTANDING_EXCEPTIONS]: E_FREESTANDING_EXCEPTIONS: `throw` is not available in `@mode(freestanding)` — the mode has no unwinder and no `.eh_frame`. Use `Result[T, E]` with `?` or `attempt` (§1-5.10).
 --> src/main.lpfree:7:9
  |
7 |         throw UartError.Timeout
  |         ^^^^^^^^^^^^^^^^^^^^^^^
  |

error[E_THROW_NON_ERROR]: cannot throw value of type 'UartError': thrown values must extend Error
 --> src/main.lpfree:7:9
  |
7 |         throw UartError.Timeout
  |         ^^^^^^^^^^^^^^^^^^^^^^^
  |

The first error is the freestanding rule. The second would apply in any mode: only exception classes can be thrown, not enum values. The freestanding version returns the error as a value:

enum UartError { Timeout, Framing, Overrun }

func readByte(ready: bool, data: uint8): Result[uint8, UartError] {
    if (not ready) {
        return Err(UartError.Timeout)
    }
    return Ok(data)
}

func readPair(ready: bool, a: uint8, b: uint8): Result[uint16, UartError] {
    let high = readByte(ready, a)?
    let low = readByte(ready, b)?
    return Ok(((high as uint16) << 8) | (low as uint16))
}

Listing 6-8: Returning an error enum instead of throwing

Every enum you define for a subsystem is likely to end up as somebody's error type, and its variants are the list of things that can go wrong.

Summary

  • Enum variants are comma-separated and written Enum.Variant; variants can carry named fields.
  • match is exhaustive — missing variants (or union member types) are compile errors; guards are pattern if (condition).
  • A fieldless enum has no guaranteed encoding; map register values explicitly.
  • Option[T] and Result[T, E] are built in; their helpers are snake_case (is_some, unwrap_or); unwrap() on None is a panic under the package's panic policy.
  • Union types such as uint32 | bool are narrowed with type patterns or is; Any is not allowed in freestanding unions.
  • throw is E_FREESTANDING_EXCEPTIONS; return Err(...) and propagate with ?.

Next: classes and allocators.