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 11: Borrow and Lifetimes

Chapter 10 introduced the verbs that say what a function does with an argument. This chapter covers the borrow forms underneath them and the lifetime labels that let the compiler check that a borrowed value does not outlive what it borrows from. All of it is compile-time only: lifetime labels are erased before code generation and cost nothing at run time (Language Reference §3-2.4.2).

The chapter covers borrow and excl borrow, lifetime parameters ([lifetime ~name], borrow[~name]), inferred struct lifetimes, closures and noescape fn, unowned[~name], volatile borrow — and, just as important for kernel code, which escapes the 1.25 checker detects and which it does not yet.

borrow and excl borrow

borrow x: T is a shared, read-only borrow — the same thing as reads. excl borrow x: T is an exclusive, mutable borrow of a struct or unique value — the same thing as edits for those types. The verbs are the preferred spelling in new code; the borrow forms remain valid in host.native and freestanding (§3-2.4.9), and they are the forms that take a lifetime label.

@mode(freestanding)

@repr(C)
struct SavedRegs {
    var rip: uint64;
    var rsp: uint64;
    var rflags: uint64;
}

func interruptsWereOn(borrow regs: SavedRegs): bool {
    return (regs.rflags & 0x200u64) != 0u64
}

func skipInstruction(excl borrow regs: SavedRegs, length: uint64): void {
    regs.rip = regs.rip + length
}

func handleBreakpoint(): bool {
    var regs = SavedRegs(rip = 0x1000u64, rsp = 0x8000u64, rflags = 0x202u64)
    skipInstruction(regs, 1u64)         # an excl borrow parameter is called plainly
    return interruptsWereOn(regs)
}

Listing 11-1: Shared and exclusive borrows of a struct

Writing through a plain borrow is the same E_READS_MUTATION you saw in chapter 10. excl borrow is limited to structs and unique values, because a plain class reference can be aliased:

This code does not compile
@mode(freestanding)

class IrqStats {
    pub var count: uint64 = 0;
}

func recordIrq(excl borrow stats: IrqStats): void {
    stats.count = stats.count + 1u64
}
error[E_EXCL_BORROW_NOT_STRUCT]: E_EXCL_BORROW_NOT_STRUCT: `excl borrow` parameter 'stats' has type `IrqStats` — only struct types and `unique` types may be exclusively borrowed; use `edits` to exclusively borrow a class instance
 --> src/main.lpfree:7:16
  |
7 | func recordIrq(excl borrow stats: IrqStats): void {
  |                ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |

The message names the fix: func recordIrq(edits stats: IrqStats), called as recordIrq(edits: stats). edits extends exclusive borrowing to class instances, which is one reason the verbs are the preferred spelling.

Lifetime parameters

A lifetime is a compile-time name for how long a borrowed value is valid. A function or type introduces one in its bracket list as lifetime ~name, and a borrow tied to it is written borrow[~name]. Labels use ~, not Rust's 'a.

# One line of a boot command line, borrowed from the text it came from.
struct Line[lifetime ~buf] {
    borrow[~buf] text: string;
    var number: uint32;
}

func firstLine[lifetime ~buf](borrow[~buf] cmdline: string): Line[~buf] {
    return Line(text = cmdline, number = 1u32)
}

func lineNumber(cmdline: string): uint32 {
    let line = firstLine(cmdline)      # Line[~buf] for this cmdline
    return line.number
}

Listing 11-2: A lifetime-parameterized struct and a function that returns it

The signature of firstLine states the contract: the returned Line borrows from cmdline, so it is valid only as long as cmdline is. The caller does not write labels; the compiler matches them up at each call (§3-2.4.2, "reborrowing"). ~static is the built-in region that lasts for the whole program.

Inferred struct lifetimes

A struct whose non-trivial fields are all borrow gets an implicit lifetime, so short-lived views need no labels (§3-2.4.4). The inference covers local use; the struct still may not escape the scope of what it borrows, and returning one from a function needs the explicit form:

This code does not compile
@mode(freestanding)

# A view of one received frame: every field borrows from the frame.
struct FrameView {
    borrow header: string;
    borrow payload: string;
}

func split(borrow frame: string): FrameView {
    return FrameView(header = frame, payload = frame)
}
error[E_INFERRED_LIFETIME_RETURN]: E_INFERRED_LIFETIME_RETURN: function 'split' returns 'FrameView', a struct with an inferred lifetime, without a lifetime annotation on its return type — give the struct a `[lifetime ~name]` parameter and return 'FrameView[~name]' (§3-2.4.4)
 --> src/main.lpfree:9:1
  |
9 | func split(borrow frame: string): FrameView {
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |

Without a label, a caller could not tell what the returned view borrows from. Name the lifetime and tie the parameter to it:

struct FrameView[lifetime ~f] {
    borrow[~f] header: string;
    borrow[~f] payload: string;
}

func split[lifetime ~f](borrow[~f] frame: string): FrameView[~f] {
    return FrameView(header = frame, payload = frame)
}

func headerLength(frame: string): int {
    let view = split(frame)
    return view.header.length()
}

Listing 11-3: Returning a view with an explicit lifetime

Storing a view into a variable that lives longer than its source is caught too:

This code does not compile
@mode(freestanding)

struct FrameView {
    borrow header: string;
}

func latestHeader(fresh: bool): uint32 {
    var last = FrameView(header = "none")
    if (fresh) {
        let received = "eth0 frame 1"
        let v = FrameView(header = received)
        last = v
    }
    return 0u32
}
error[E_INFERRED_LIFETIME_ESCAPE]: E_INFERRED_LIFETIME_ESCAPE: 'v' has an inferred lifetime (it borrows from 'received') and is stored in 'last', declared in an enclosing block, which outlives it (§3-2.4.4)
  --> src/main.lpfree:12:9
   |
12 |         last = v
   |         ^^^^^^^^
   |

received ends with the if block, so last would outlive what it points into. Either use the view inside the block, or declare the source in the outer scope.

Closures that capture borrows

A closure that captures a borrow[~name] parameter may only be handed to a noescape fn parameter — a plain fn value could be stored and called after the borrow is gone (§3-2.4.2 rule 3):

This code does not compile
@mode(freestanding)

func checksumLater[lifetime ~pkt](borrow[~pkt] payload: string): uint32 {
    let job: fn(): uint32 = func(): uint32 {
        return payload.length() as uint32
    }
    return job()
}
error[E_BORROW_CLOSURE_ESCAPE]: E_BORROW_CLOSURE_ESCAPE: a closure capturing 'payload: borrow[~pkt]' is bound to 'job', a plain `fn` binding — a lifetime-capturing closure may only be passed to a `noescape fn` parameter (§3-2.4.2 rule 3)
 --> src/main.lpfree:4:5
  |
4 |     let job: fn(): uint32 = func(): uint32 {
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |

Pass the closure straight to a function whose parameter is noescape fn:

func sumOver(n: int, visit: noescape fn(int): uint32): uint32 {
    var total: uint32 = 0
    var i = 0
    while (i < n) {
        total = total + visit(i)
        i = i + 1
    }
    return total
}

func checksumNow[lifetime ~pkt](borrow[~pkt] payload: string): uint32 {
    return sumOver(1, func(i: int): uint32 {
        return payload.length() as uint32
    })
}

Listing 11-4: A borrow-capturing closure passed to a noescape fn parameter

The noescape promise is checked on the callee's side as well: a noescape fn parameter cannot be stored in a longer-lived fn variable.

This code does not compile
@mode(freestanding)

var onTick: fn(uint64): void = func(t: uint64): void { }

func everyTick(handler: noescape fn(uint64): void): void {
    onTick = handler
}
error[E0001]: cannot assign 'noescape fn(uint64): void' to 'fn(uint64): void'
 --> src/main.lpfree:6:5
  |
6 |     onTick = handler
  |     ^^^^^^^^^^^^^^^^
  |

A callback that is meant to be stored — a timer hook, an interrupt dispatch entry — takes a plain fn parameter, and is usually a named module-level function rather than a capturing closure:

var onTick: fn(uint64): void = func(t: uint64): void { }

func setTickHandler(handler: fn(uint64): void): void {
    onTick = handler
}

var ticks: uint64 = 0

func countTick(now: uint64): void {
    ticks = now
}

func initTimer(): void {
    setTickHandler(countTick)
}

Listing 11-5: Storing a plain function as a callback

unowned[~name] back-references

unowned[~name] declares a non-owning field whose referent the compiler proves outlives the holder, using scope nesting (§3-2.4.3). The class must declare the lifetime it names:

class TreeNode[lifetime ~owner] {
    pub let value: int;
    pub unowned[~owner] var parent: TreeNode[~owner]?;

    new(value: int, parent: TreeNode[~owner]?) {
        this.value = value
        this.parent = parent
    }
}

safe func build[lifetime ~tree](): int {
    let root = TreeNode[~tree](value = 0, parent = None)
    let child = TreeNode[~tree](value = 1, parent = Some(root))
    return child.value
}

Listing 11-6: A child that points back to its parent

Assigning a referent declared in a narrower scope — a node created inside an if block, stored as the parent of one created outside it — is E_UNOWNED_LIFETIME_MISMATCH; leaving [lifetime ~owner] off the class is E_UNOWNED_CLASS_NO_PARAM. In freestanding, where class objects are staged as chapter 7 describes, keep such trees small and prefer index handles into a fixed table for anything long-lived.

What the 1.25 checker does not catch yet

The region checks above are a conservative, syntactic subset (§3-2.4.2, "What is statically detected"). Two gaps matter for kernel code:

  • Storing a borrowed parameter into a module variable. The Language Reference makes returning or storing a reads/borrow parameter beyond the call E_BORROW_ESCAPE. That check is not implemented: saved = name inside func stash[lifetime ~user](borrow[~user] name: string) compiles. Do not cache pointers from a system call's arguments in kernel state; copy the data you need.
  • Outlives constraints. There is no where ~a: ~b syntax; the parser rejects it. Express "this result lives no longer than that input" by giving both the same label.

Treat lifetime labels as checked documentation of these contracts, and keep the pointers that cross a boundary — a system call, an interrupt, a DMA completion — in values whose lifetime the checker does follow: parameters, locals and the structs built from them.

volatile borrow — device registers

For memory-mapped registers, the borrow form takes one more keyword: volatile borrow tells the compiler that every read and write through the reference is an observable device access that must not be merged, reordered or removed (§3-3.8). Chapter 15 covers it in depth. A frequent mistake is to write it as an annotation, the way some older material did:

This code does not compile
@mode(freestanding)

@repr(C)
struct UartRegs {
    var data: uint8;
    var lsr: uint8;
}

func uartWrite(@volatile borrow uart: UartRegs, b: uint8): void {
    uart.data = b
}
error[E0001]: expected identifier
 --> src/main.lpfree:9:16
  |
9 | func uartWrite(@volatile borrow uart: UartRegs, b: uint8): void {
  |                ^
  |

error[E0002]: expected `{`
 --> src/main.lpfree:9:16
  |
9 | func uartWrite(@volatile borrow uart: UartRegs, b: uint8): void {
  |                ^
  |

error[E0001]: expected identifier
 --> src/main.lpfree:9:17
  |
9 | func uartWrite(@volatile borrow uart: UartRegs, b: uint8): void {
  |                 ^^^^^^^^
  |

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

There is no @volatile annotation; annotations cannot appear inside a parameter list at all, which is why the parser stops at the @. volatile is a keyword in front of borrow:

@repr(C)
struct UartRegs {
    var data: uint8;
    var lsr: uint8;
}

func uartWrite(volatile borrow uart: UartRegs, b: uint8): void {
    while ((uart.lsr & 0x20u8) == 0u8) { }    # every read of lsr reaches the device
    uart.data = b                            # a volatile store
}

Listing 11-7: A volatile borrow parameter

A volatile borrow is still a borrow: the lifetime rules apply to it like to any other.

Summary

  • borrow = reads and excl borrow = edits for structs and unique values; excl borrow on a class is E_EXCL_BORROW_NOT_STRUCT — use edits.
  • [lifetime ~name] introduces a lifetime; borrow[~name] T ties a borrow to it; labels are checked, then erased.
  • All-borrow structs get an inferred lifetime for local use; returning one needs an explicit label (E_INFERRED_LIFETIME_RETURN), and storing one where it outlives its source is E_INFERRED_LIFETIME_ESCAPE.
  • A closure capturing a lifetime-labelled borrow may only go to a noescape fn parameter (E_BORROW_CLOSURE_ESCAPE), and a noescape fn cannot be stored; stored callbacks are plain fn values.
  • unowned[~name] back-references are checked by scope nesting (E_UNOWNED_LIFETIME_MISMATCH).
  • Not yet checked: storing a borrowed parameter in a module variable (E_BORROW_ESCAPE); there are no where ~a: ~b constraints.
  • volatile borrow marks device-register parameters; @volatile does not exist.

Next: unique ownership.