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 9: Error Handling with Result

The hosted modes have exceptions. Freestanding does not: throw and exception catch arms are rejected with E_FREESTANDING_EXCEPTIONS. This chapter explains why, then shows what replaces them — Result[T, E], the ? operator, error conversion with From, and the try … attempt … catch Err(e) block — with the mistakes the compiler catches and two it does not.

Why there are no exceptions

An exception needs unwind tables (.eh_frame), an unwinder that walks them and restores registers, and something sensible to do when nothing catches the exception. A freestanding image has none of these: linking an unwinder would bring back the runtime freestanding exists to avoid, and an interrupt handler on a per-CPU stack has no caller to unwind into.

So freestanding splits errors in two:

  • a recoverable error is a value — the function returns Result[T, E] and the caller decides;
  • an unrecoverable condition is a panic — the package's panic policy halts the core, faults, or calls your @panic_handler (chapter 17).

Error types

Result[T, E] is a built-in enum with the variants Ok(value) and Err(error). The error type is yours to choose; usually it is an enum per subsystem:

@mode(freestanding)

enum UartError {
    Timeout,
    Framing,
    Parity,
    Overrun,
}

enum PciError {
    NoDevice(bus: uint8),
    BadVendor(vendor: uint16),
}

func readByte(lsr: uint8, data: uint8): Result[uint8, UartError] {
    if ((lsr & 0x01u8) == 0u8) {
        return Err(UartError.Timeout)        # nothing received
    }
    if ((lsr & 0x08u8) != 0u8) {
        return Err(UartError.Framing)
    }
    return Ok(data)
}

func describe(r: Result[uint8, UartError]): uint8 {
    return match r {
        Ok(b) => b,
        Err(UartError.Timeout) => 0x00u8,
        Err(e) => 0xFFu8,
    }
}

Listing 9-1: Subsystem error enums and a function that returns Result

Propagating errors with ?

expression? on a Result:

  • evaluates to v when the result is Ok(v);
  • returns Err(e) from the enclosing function when it is Err(e).
func readWord(lsr: uint8, hi: uint8, lo: uint8): Result[uint16, UartError] {
    let high = readByte(lsr, hi)?
    let low = readByte(lsr, lo)?
    return Ok(((high as uint16) << 8) | (low as uint16))
}

Listing 9-2: Two fallible reads composed with ?

? only works where it can return an error. In a function that returns a plain value it is rejected:

This code does not compile
@mode(freestanding)

enum UartError { Timeout, Framing }

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

func pollKeyboard(): uint8 {
    let scancode = readByte()?
    return scancode
}
error[E_PROPAGATE_WRONG_RETURN]: E_PROPAGATE_WRONG_RETURN: the '?' operator can only be used inside a function returning Result (§1-5.7)
  --> src/main.lpfree:10:20
   |
10 |     let scancode = readByte()?
   |                    ^^^^^^^^^^^
   |

pollKeyboard has to decide what an error means for its caller. Either return Result too, or handle the error where it happens:

# 0 means "no key" to the caller.
func pollKeyboard(): uint8 {
    return match readByte() {
        Ok(scancode) => scancode,
        Err(e) => 0u8,
    }
}

Listing 9-3: Handling the error at the boundary with match

Converting errors with From

Higher layers usually want one error type that covers several subsystems. ? converts automatically when the function's error type implements From[E] for the error being propagated. Without the conversion:

This code does not compile
@mode(freestanding)

enum UartError { Timeout, Framing }
enum KernelError { Uart, OutOfMemory }

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

func readConsoleId(): Result[uint8, KernelError] {
    let id = readByte()?
    return Ok(id)
}
error[E_PROPAGATE_MISSING_FROM]: E_PROPAGATE_MISSING_FROM: '?' propagates an error of type 'UartError' out of a function whose error type is 'KernelError', which does not implement `From[UartError]` (§1-5.7)
  --> src/main.lpfree:11:14
   |
11 |     let id = readByte()?
   |              ^^^^^^^^^^^
   |

From[E] is an interface with one static method, from(err: E). In 1.25 an enum declaration cannot carry implements, so make the kernel-level error a struct that records which subsystem failed and how, and give it one from per subsystem error:

enum UartError { Timeout, Framing }
enum PciError { NoDevice(bus: uint8), BadVendor(vendor: uint16) }

enum Subsystem { Uart, Pci }

struct KernelError implements From[UartError], From[PciError] {
    var subsystem: Subsystem;
    var code: uint32;

    static func from(err: UartError): KernelError {
        let code = match err {
            UartError.Timeout => 1u32,
            UartError.Framing => 2u32,
        }
        return KernelError(subsystem = Subsystem.Uart, code = code)
    }

    static func from(err: PciError): KernelError {
        let code = match err {
            PciError.NoDevice(bus) => bus as uint32,
            PciError.BadVendor(vendor) => vendor as uint32,
        }
        return KernelError(subsystem = Subsystem.Pci, code = code)
    }
}

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

func findNic(): Result[uint16, PciError] {
    return Ok(0x8139u16)
}

func initDevices(): Result[uint16, KernelError] {
    let consoleId = readByte()?        # UartError -> KernelError.from(...)
    let nic = findNic()?               # PciError  -> KernelError.from(...)
    return Ok(nic + (consoleId as uint16))
}

Listing 9-4: A kernel error type with From conversions

The compiler inserts KernelError.from(e) at each ? whose error type differs. Low-level drivers keep precise error enums; the glue layer above them speaks one type.

try … attempt … catch Err(e)

? sends every error out of the function. When a block of steps should share one local handler instead, use a try block whose only handler is a catch Err(e) arm. Inside it, attempt expression unwraps an Ok or jumps to the arm with the error (Language Reference §1-5.10). This form needs no unwinder — it is a match and a jump — so it is allowed in freestanding, with an optional finally:

enum ProbeError { Absent, Timeout }

func probePort(port: uint16): Result[uint8, ProbeError] {
    if (port == 0u16) {
        return Err(ProbeError.Absent)
    }
    return Ok(0x5Au8)
}

var probesRun: uint32 = 0

# Probe two ports; any failure falls back to "no device" (0).
func detect(primary: uint16, secondary: uint16): uint8 {
    var found: uint8 = 0
    try {
        let a = attempt probePort(primary)
        let b = attempt probePort(secondary)
        found = a | b
    } catch Err(e) {
        found = 0u8
    } finally {
        probesRun = probesRun + 1u32
    }
    return found
}

Listing 9-5: A local handler for a sequence of fallible steps

What is not allowed is the exception form, catch (Type as e):

This code does not compile
@mode(freestanding)

func decode(scancode: uint8): uint8 {
    return scancode & 0x7Fu8
}

func pollKeyboard(scancode: uint8): uint8 {
    try {
        return decode(scancode)
    } catch (ValueError as e) {
        return 0u8
    }
}
error[E_FREESTANDING_EXCEPTIONS]: E_FREESTANDING_EXCEPTIONS: an exception `catch` arm 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:10:7
   |
10 |     } catch (ValueError as e) {
   |       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |

Nothing in decode can fail, so the fix is to delete the try; when something can, make it return a Result and use attempt or ?. throw is rejected the same way (chapter 6).

Two mistakes the compiler does not catch

Ignoring a Result. Calling a fallible function as a statement discards its result, and 1.25 does not warn:

This code does not do what it should
enum DiskError { Timeout, BadSector(lba: uint64) }

func flushCache(): Result[void, DiskError] {
    return Err(DiskError.Timeout)
}

func shutdown(): void {
    flushCache()          # the error is silently dropped
    # ... power off ...
}

Listing 9-6: A dropped error — compiles without a warning

If the shutdown path cannot do anything about a failed flush, say so explicitly — match and ignore the arm with a comment, or count the failure — so the next reader knows it was a decision.

? on an Option. The Language Reference allows ? only on Result (E_PROPAGATE_NOT_RESULT). In 1.25, ? on an Option inside a Result-returning function is accepted, and a None is propagated as the error value itself — Err(None) in a hosted run — not as a value of your error type:

This code does not do what it should
enum LookupError { NoSuchIrq }

func slotOf(irq: uint8): Option[int] {
    if (irq < 16u8) {
        return Some(irq as int)
    }
    return None
}

func handlerSlot(irq: uint8): Result[int, LookupError] {
    let slot = slotOf(irq)?          # a None becomes Err(None), not NoSuchIrq
    return Ok(slot)
}

Listing 9-7: ? applied to an Option

Convert the absence into your error explicitly:

func handlerSlot(irq: uint8): Result[int, LookupError] {
    let slot = slotOf(irq)
    if (slot.is_none()) {
        return Err(LookupError.NoSuchIrq)
    }
    return Ok(slot.unwrap())
}

Listing 9-8: Turning None into a typed error

Patterns

Allocation failure. The @allocator returns uint?; wrap it once and the rest of the kernel sees a Result (chapter 7, Listing 7-5).

Parsing. Every step of parsing a header can fail, and the failures compose with ?:

enum ParseError {
    Truncated,
    BadMagic(magic: uint32),
    UnsupportedClass(value: uint8),
}

# The first bytes of an ELF image, as received from a boot module.
struct Image {
    var bytes: buffer[uint8, 64];
    var length: int;
}

func byteAt(img: Image, offset: int): Result[uint8, ParseError] {
    if (offset >= img.length) {
        return Err(ParseError.Truncated)
    }
    return Ok(img.bytes[offset])
}

func checkMagic(img: Image): Result[void, ParseError] {
    let m0 = byteAt(img, 0)?
    let m1 = byteAt(img, 1)?
    let m2 = byteAt(img, 2)?
    let m3 = byteAt(img, 3)?
    let magic = ((m0 as uint32) << 24) | ((m1 as uint32) << 16) | ((m2 as uint32) << 8) | (m3 as uint32)
    if (magic != 0x7F454C46u32) {
        return Err(ParseError.BadMagic(magic = magic))
    }
    return Ok(())
}

func elfClass(img: Image): Result[uint8, ParseError] {
    checkMagic(img)?
    let cls = byteAt(img, 4)?
    if (cls != 2u8) {
        return Err(ParseError.UnsupportedClass(value = cls))   # only ELF64
    }
    return Ok(cls)
}

Listing 9-9: A header parser built from small fallible steps

Result[void, E] is the type of a step that produces nothing but can fail; it returns Ok(()).

Panics: panic() and unreachable

The Language Reference defines panic(msg): noreturn in every mode. The 1.25 Stage0 compiler does not declare it in any mode:

This code does not compile
@mode(freestanding)

func checkPaging(cr3: uint64): void {
    if (cr3 == 0u64) {
        panic("CR3 is zero: no page table loaded")
    }
}
error[E0001]: undefined identifier `panic`
 --> src/main.lpfree:5:9
  |
5 |         panic("CR3 is zero: no page table loaded")
  |         ^^^^^
  |

⚠ Planned. panic(msg) is specified (Language Reference §3-3.6) but not available in Loup 1.25.

What does work is unreachable: reaching it is a panic stop under the package's [build] panic policy — the core halts by default, or your @panic_handler runs under panic = "custom", with the source location (chapter 17). Write the message where a person will see it first, then stop:

func serialWrite(s: string): void {
    for (c in s) {
        outb(0x3F8u16, c as uint8)
    }
}

func checkPaging(cr3: uint64): void {
    if (cr3 == 0u64) {
        serialWrite("fatal: CR3 is zero, no page table loaded\n")
        unreachable
    }
}

Listing 9-10: Reporting, then stopping with unreachable

When to stop and when to return Err:

  • Stop when an invariant the rest of the program depends on is broken — a corrupt page table, a vector that fired with no handler installed, a state the code has proven impossible.
  • Return Err when the environment failed in a way some caller can handle — a device timed out, a PCI device is missing, a user-supplied length is wrong, a large buffer is unavailable and a smaller one would do.

Low-level drivers return precise errors; the subsystem glue converts them; the top-level loop logs, retries, falls back, or — when nothing else is safe — stops.

Error return traces — the list of ? sites an error passed through — are captured only in builds made with --error-traces (on by default in --debug builds); [freestanding] error_trace_depth sizes the fixed store (§3-3.21). In other builds, calling errorTraceDepth() is E_ERROR_TRACES_DISABLED.

Summary

  • throw and catch (T as e) are E_FREESTANDING_EXCEPTIONS; recoverable errors are Result[T, E] values.
  • ? returns an Err from the enclosing function, which must return Result (E_PROPAGATE_WRONG_RETURN).
  • Implement From[E] (one static func from per source error) on a struct to let ? convert errors across layers (E_PROPAGATE_MISSING_FROM otherwise).
  • try { … attempt … } catch Err(e) { … } finally { … } handles a sequence of fallible steps locally and is allowed in freestanding.
  • 1.25 does not warn about an ignored Result, and ? on an Option propagates None as the error — convert explicitly.
  • panic() is not available yet; report the problem and use unreachable, which stops under the package's panic policy.

Next: ownership verbs.