Rust on Metal Chapter 2 · Ownership, Borrowing, Lifetimes
Part I — The Language · Chapter 2

Ownership,
Borrowing, and
Lifetimes

The borrow checker is not a constraint imposed on you. It is a proof assistant arguing on your behalf that your program is correct. Understanding it is not about learning rules — it is about learning to think the way the compiler thinks about time, scope, and the lifecycle of data.
§ 2.1
Stack and Heap — The Physical Reality

Ownership is a solution to a physical problem. Before we can understand ownership, we need to understand the two places where data lives in a running program — the stack and the heap — because they have fundamentally different properties and different rules for allocation and release.

The Stack

The stack is a region of memory that operates like its name suggests: a last-in, first-out structure. When your program calls a function, a stack frame is pushed — a contiguous block of memory is reserved for that function's local variables, its return address, and its arguments. When the function returns, the frame is popped — that memory is instantly available for the next function call. No allocator involved. No bookkeeping. Just a single register (the stack pointer) moving up or down.

Stack allocation is fast — essentially free. The constraint is that everything on the stack must have a known, fixed size at compile time. You cannot push a variable-length collection onto the stack because the compiler would not know how much space to reserve for the function frame. Every local variable you declare in a Rust function — every integer, every fixed-size array, every struct whose size is known at compile time — lives on the stack.

STACK GROWTH (grows downward in memory)
─────────────────────────────────────────────────────
High address

  ┌──────────────────────────────────────┐
  │  main() frame                        │
  │  let x: u32 = 42;    [4 bytes]       │
  │  let arr: [u8; 8];   [8 bytes]       │
  └──────────────────────────────────────┘
  ┌──────────────────────────────────────┐
  │  process() frame     ← pushed on call│
  │  let result: u32;    [4 bytes]       │
  │  return address      [8 bytes]       │
  └──────────────────────────────────────┘
  ▲ stack pointer
  (moves here when process() is called)

Low address

When process() returns:
  stack pointer moves back up to main()'s frame
  process()'s memory is immediately reusable
  No allocator. No free(). One register increment.
Figure 2.1 — Stack frames grow downward and are released immediately on function return. The stack pointer is the only bookkeeping required.
The Heap

The heap is a pool of memory managed by an allocator. When you need memory whose size is not known at compile time — a string that the user types, a collection whose length grows — you ask the allocator for a block of heap memory. The allocator finds a suitable free region, marks it as used, and gives you a pointer to it. When you are done, you must tell the allocator to release it.

Heap allocation is slower than stack allocation — the allocator must find a suitable free block, potentially fragmented among other allocations. It also requires bookkeeping: the allocator maintains a data structure tracking which regions are free and which are in use. On a server with gigabytes of RAM, this overhead is negligible. On an RP2350 with 520KB of RAM and hard timing requirements, heap allocation adds latency and fragmentation risk.

This is why Embassy operates without a heap allocator by default. All Embassy task state, all driver state, all synchronisation primitive state — it all lives in statically allocated memory whose size is determined at compile time. No heap, no allocator, no fragmentation, no allocation latency. This is only possible because Rust's ownership system gives you memory safety without a garbage collector.

What Rust Knows

When you declare a variable in Rust, the compiler knows exactly where it lives — stack or heap — and exactly how long it lives. For stack variables, the compiler knows the variable lives until the end of the enclosing scope (the closing brace }). For heap allocations (like String or Vec), the compiler knows the allocation is tied to an owner — a stack variable that holds a pointer to the heap data — and the heap allocation is freed when the owner goes out of scope.

This is the foundation of everything in this chapter. The compiler's ability to reason about where data lives and how long it lives is what makes ownership possible, what makes the borrow checker possible, and what makes Rust's memory safety guarantees possible without a garbage collector.

§ 2.2
The Three Ownership Rules

Rust's ownership system is built on three rules. These rules are not arbitrary — each one is necessary to prevent a specific class of memory error. Understand the rule, understand the error it prevents, and the rule stops feeling like a constraint and starts feeling like a safety system.

1
Every value has exactly one owner.
A value is owned by the variable that holds it. There is always exactly one owner — not zero, not two. This prevents the question "who is responsible for freeing this?" from ever being ambiguous.
2
There can only be one owner at a time.
Ownership can be transferred (moved) from one variable to another, but after the transfer the original variable no longer owns the value. This prevents double-free: only the current owner can free the value.
3
When the owner goes out of scope, the value is dropped.
When a variable's scope ends (the enclosing block closes), the value it owns is automatically released. No explicit free() required. No risk of forgetting. No risk of freeing too early.
Memory is always freed exactly once.
From these three rules, it follows that every allocation is freed exactly when the last reference to it goes out of scope. Never earlier. Never later. Never twice. Never not at all.

Let us see these rules in code, starting from the simplest possible example and building up to the patterns you will encounter in embedded drivers.

ownership_basics.rs — the rules in action rust
fn main() {
    // Rule 1: x owns the String "Sprint Group"
    // The String lives on the heap; x is a stack variable holding a pointer to it.
    let x = String::from("Sprint Group");

    // Rule 2: ownership moves from x to y.
    // x is no longer valid after this line.
    // Only one owner at a time.
    let y = x;

    // This would be a compile error:
    // println!("{}", x);  // error[E0382]: borrow of moved value: `x`

    // Rule 3: when main() returns, y goes out of scope.
    // The String's heap memory is automatically freed.
    // No free(). No delete. No garbage collector needed.
    println!("{}", y);  // This works — y owns the String
}  // ← "Sprint Group" heap memory freed here, automatically
§ 2.3
Move Semantics — What Happens When You Pass a Value

In most languages, when you pass a variable to a function, the function receives a copy of the value (for primitives) or a reference to the same object (for heap-allocated objects). In Rust, the default behaviour is a move — ownership transfers from the caller to the function. After the call, the caller no longer owns the value. This feels restrictive at first. Understanding why it is the right default takes a few examples.

move_semantics.rs — ownership transfer into a function rust
fn process_name(name: String) {
    // name is owned here — this function is responsible for it
    println!("Processing: {}", name);
}  // name goes out of scope here — String is freed

fn main() {
    let company = String::from("Sprint Group");

    // Ownership moves into process_name.
    // company is no longer valid in main() after this call.
    process_name(company);

    // This would not compile:
    // println!("{}", company);  // error[E0382]: value borrowed here after move
}

Why does Rust do this instead of implicitly copying? Because copying is not always cheap or even meaningful. A String has heap-allocated content that would need to be duplicated — a heap allocation and a memcopy. A network socket cannot be meaningfully copied at all — there is only one socket, one file descriptor, one connection. A GPIO pin configuration struct cannot be copied — there is only one physical pin. Rust makes the safe choice the default: move. If you want to copy, you opt in explicitly with the Clone trait.

Copy Types — The Exception

Simple types that live entirely on the stack — integers, booleans, floating point numbers, fixed-size arrays of Copy types, tuples of Copy types — implement the Copy trait. For these types, the assignment let y = x; copies the value because a stack copy is cheap and the original is not invalidated. You can use x after assigning it to y because both are valid independent copies.

copy_types.rs — integers are Copy, Strings are not rust
// u32 implements Copy — cheap stack copy
let speed: u32 = 75;
let duty_cycle = speed;  // copy — speed is still valid
println!("Speed: {}, Duty: {}", speed, duty_cycle);  // works fine

// String does NOT implement Copy — heap allocation, non-trivial copy cost
let site = String::from("DSM Bagamoyo Core");
let site2 = site;  // MOVE — site is no longer valid
// println!("{}", site);  // compile error — site was moved

// If you want both to be valid, clone explicitly:
let site3 = String::from("DSM Bagamoyo Core");
let site4 = site3.clone();  // explicit heap allocation and copy
println!("{} and {}", site3, site4);  // both valid — two independent Strings

// In embedded code, your sensor reading structs are often Copy:
#[derive(Copy, Clone)]
struct TemperatureReading {
    celsius: f32,
    timestamp_ms: u64,
}
// Both fields are Copy, so TemperatureReading derives Copy.
// You can pass it to functions and still use the original — no heap involved.
§ 2.4
Borrowing — Access Without Ownership

Moving ownership into every function that needs to read a value is impractical. You would have to return ownership at the end of every function — a clumsy, verbose pattern. Borrowing solves this: you can give a function temporary access to a value without transferring ownership. Borrowing is done with references, denoted by the & symbol.

borrowing.rs — immutable references rust
// This function borrows the String — it does not own it.
// The &str parameter means "a reference to string data I don't own".
fn print_site(name: &str) {
    println!("Site: {}", name);
    // The String is NOT freed here — we never owned it
}

fn main() {
    let site = String::from("Kariakoo Edge");

    // Pass a reference — lending access without transferring ownership.
    // The & creates a reference to site's data.
    print_site(&site);  // site is borrowed, not moved
    print_site(&site);  // can borrow again — still own it
    print_site(&site);  // and again

    // site is still valid here — we never gave it away
    println!("Still have: {}", site);
}
Mutable References

An immutable reference (&T) gives read-only access. If you need to modify the value, you need a mutable reference (&mut T). The owner must also be declared mutable. Here is the critical rule for mutable references — a rule that directly prevents data races:

The Mutable Reference Rule

At any given moment, you can have EITHER one mutable reference OR any number of immutable references — never both simultaneously.

This is not an arbitrary restriction. It is precisely the condition needed to prevent data races. A data race occurs when: (1) two or more pointers access the same data at the same time, and (2) at least one of them is writing. The borrow checker's rule makes condition (2) impossible when condition (1) is true: if you have a mutable reference, no other references can exist. If multiple references exist, none of them can be mutable.

This guarantee holds not just within a single thread but across threads too. The Send and Sync marker traits extend these guarantees to multi-threaded code. Types that are not safe to share between threads (like a raw pointer with no synchronisation) cannot implement Sync, and the compiler refuses to let you share them. Thread safety is a compile-time property.

mutable_refs.rs — the rules around &mut rust
fn set_speed(speed: &mut u8, value: u8) {
    *speed = value.min(100);  // * dereferences — we're modifying through the reference
}

fn main() {
    let mut motor_speed: u8 = 0;  // must be mut to create a &mut reference

    // One mutable reference at a time — this is fine
    set_speed(&mut motor_speed, 75);

    // This would not compile — cannot have &mut and & simultaneously:
    // let r1 = &mut motor_speed;
    // let r2 = &motor_speed;     // error: cannot borrow as immutable because
    // println!("{}", r2);        //        it is also borrowed as mutable

    // But this is fine — sequential, not simultaneous:
    let r1 = &mut motor_speed;
    *r1 = 50;
    // r1 is no longer used after this point — borrow ends here
    let r2 = &motor_speed;  // fine — r1's borrow has ended
    println!("Speed: {}", r2);
}
§ 2.5
The Borrow Checker — How the Compiler Thinks

The borrow checker is the component of the Rust compiler that enforces ownership and borrowing rules. Understanding how it thinks — what it is checking, and why it rejects certain code — is the key to writing idiomatic Rust without fighting the compiler.

The borrow checker performs a form of static analysis called liveness analysis. For every variable in your code, it computes the region of code where that variable is "live" — where it holds a value that is or might be used later. A borrow is valid if the lifetime of the borrow does not exceed the lifetime of the value being borrowed. In modern Rust (editions 2018+), this analysis uses Non-Lexical Lifetimes (NLL) — it tracks liveness at the granularity of individual use points, not just scope blocks.

The Dangling Reference Problem

The core problem the borrow checker prevents is a dangling reference — a reference that outlives the value it points to. In C, this is trivially easy to create and impossible for the compiler to prevent. In Rust, it is a compile-time error.

⛔ Dangling Reference — Compile Error

This code attempts to return a reference to a local variable. The variable is destroyed when the function returns, leaving the reference pointing to invalid memory.

fn dangle() -> &String {
    let s = String::from("will be dropped");
    &s  // ← returning a reference to s
}   // ← s is dropped here — the reference is now dangling
// error[E0106]: missing lifetime specifier
// help: this function's return type contains a borrowed value,
//       but there is no value for it to be borrowed from

The compiler does not just refuse to compile this. It explains why, identifies the function, and suggests that you return an owned String instead of a reference — which is the correct fix.

✓ The Correct Fix

Return an owned String. The caller takes ownership and the String lives as long as the caller needs it. No dangling reference possible.

fn no_dangle() -> String {
    let s = String::from("now owned by caller");
    s  // transfer ownership to the caller
}   // s is NOT dropped — it was moved out of the function
Why the Compiler Knows — Lifetime Annotations Under the Hood

Every reference in Rust has a lifetime — a region of code for which it is valid. Most of the time, the compiler infers lifetimes automatically through lifetime elision rules — a set of heuristics that cover the vast majority of common patterns. You only need to write lifetime annotations explicitly when the compiler cannot determine them from context.

When you write a function that takes references and returns a reference, the compiler needs to know: how long is the returned reference valid? It cannot determine this without knowing the relationship between the input and output lifetimes. This is when you write an explicit lifetime annotation.

lifetimes.rs — when you need explicit annotations rust
// This function returns a reference. The compiler asks:
// "How long is the returned reference valid?"
// It needs to know that the returned &str lives as long as
// whichever of s1 or s2 is shorter-lived.

// Without lifetime annotation — compile error:
// fn longest(s1: &str, s2: &str) -> &str { ... }
// error: missing lifetime specifier

// With lifetime annotation — the 'a means:
// "the returned reference lives as long as the shorter of s1 and s2"
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() >= s2.len() { s1 } else { s2 }
}

fn main() {
    let s1 = String::from("Bugolobi Core");
    let result;
    {
        let s2 = String::from("Nakawa");
        result = longest(&s1, &s2);
        println!("Longest: {}", result);  // valid — both s1 and s2 live here
    }
    // result cannot be used here — s2 is gone, and result might point to s2
    // The compiler enforces this through the 'a lifetime constraint
}
The Lifetime Mental Model

A reference is a loan. The loan cannot outlive the asset.

Think of ownership as holding title to a property. A reference is a loan — someone else has temporary use of the property but you still hold the title. A mutable reference is an exclusive loan — while you have it, nobody else can use the property at all. An immutable reference is a shared access agreement — multiple people can view the property simultaneously, but nobody can modify it while the shared access is in effect.

A lifetime is the duration of the loan. The borrow checker's job is to verify that every loan expires before the property is sold or demolished. You cannot loan out a property that you are about to demolish — that is a dangling reference. You cannot have multiple exclusive loans simultaneously — that is a multiple mutable reference violation. These are the same rules a responsible property manager would apply, and the compiler applies them automatically on every compile.

In embedded systems, the "property" might be a GPIO pin, an I2C peripheral, or a DMA channel. The hardware reality that only one driver should control each peripheral at a time maps directly onto Rust's ownership model. This is not an accident. It is the same insight.

§ 2.6
Lifetimes in Embedded Rust — Why You See 'static Everywhere

When you read Embassy code, you will see 'static lifetimes frequently. In the motor control system from Chapter 10, every spawned task takes arguments with 'static lifetime bounds. Understanding why this is necessary requires understanding what 'static means.

'static is the longest possible lifetime — it means "this data lives for the entire duration of the program." A 'static reference is one that is valid forever, not just for part of the program. The classic example is a string literal: "Sprint Group" is a &'static str because the string data is baked into the binary at compile time and exists for the entire program run.

static_lifetime.rs — why Embassy tasks need 'static rust
// A spawned Embassy task runs for the entire program lifetime.
// It might outlive the scope where it was created.
// Therefore, any data it holds must be valid for the entire program.
// Therefore, it must be 'static.
//
// This is why Embassy tasks take 'static arguments:
#[embassy_executor::task]
async fn encoder_task(clk: Input<'static>, dt: Input<'static>) {
    // These pins must be 'static because encoder_task might run
    // long after the scope where it was spawned.
    // Input<'static> means: this Input holds a reference to the
    // GPIO register bank that is valid for the entire program.
}

// The GPIO bank IS 'static — it is hardware that exists forever.
// embassy_rp::init() gives you pins that are 'static because the
// hardware they refer to is permanent.

// Compare with a reference to a stack variable — NOT 'static:
fn main() {
    let x = 42u32;
    let r: &'static u32 = &x;  // compile error: x does not live long enough
    // x will be destroyed when main() returns.
    // A 'static reference would outlive x. The compiler refuses.
}
§ 2.7
Drop and RAII — The Automatic Cleanup Pattern

When a value goes out of scope in Rust, the compiler inserts a call to its drop method — a destructor. The Drop trait defines what cleanup should happen when a value is released. For String, drop frees the heap memory. For File, drop closes the file descriptor. For a database connection pool, drop returns the connection to the pool. For a Mutex lock guard, drop releases the lock.

This pattern — binding resource cleanup to the scope of a value — is called RAII: Resource Acquisition Is Initialisation. It was invented for C++ but is more reliably enforced in Rust because Rust's ownership rules guarantee that drop is always called exactly once. You cannot forget to release a lock, close a file, or free memory — the compiler inserts the cleanup automatically.

raii_drop.rs — RAII in Embassy: MutexGuard and automatic unlock rust
// From the motor control chapter — Embassy Mutex in action
use embassy_sync::mutex::Mutex;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;

static SPEED: Mutex<CriticalSectionRawMutex, u8> = Mutex::new(0);

async fn encoder_task() {
    loop {
        // lock() returns a MutexGuard — a value that holds the lock
        let mut guard = SPEED.lock().await;

        // Modify through the guard (which derefs to &mut u8)
        *guard = guard.saturating_add(5).min(100);

        // guard goes out of scope here — its Drop impl releases the lock.
        // You cannot forget to unlock. The type system makes it impossible.
        // No unlock() call needed. No unlock() call forgotten.
    }
    // ↑ lock released here — end of the block where guard was declared
}

// Compare with explicit MutexGuard drop for fine-grained control:
async fn update_and_log() {
    {  // inner scope — lock held only within this block
        let mut guard = SPEED.lock().await;
        *guard = 80;
    }  // ← lock released here — guard dropped

    // Lock is released before this potentially-slow operation
    log_speed_externally().await;
}
§ 2.8
Reading Compiler Errors — The Borrow Checker as Teacher

Rust's compiler error messages are among the best in any programming language. They do not just say what went wrong — they say where, why, and what to do instead. Learning to read them productively takes practice but pays dividends immediately. Here are the most common ownership errors you will encounter and exactly what they mean.

error[E0382] — Borrow of Moved Value

You moved a value and then tried to use the original.

let s = String::from("Kariakoo Edge");
let t = s;           // s is moved into t
println!("{}", s);   // error: value borrowed here after move
                     //        ^ help: consider cloning the value

Fix: Either clone s before moving it, or restructure so you only need one owner. Ask: do you actually need two copies, or can you work with a reference?

error[E0505] — Cannot Move Out of Value Because It Is Borrowed

You have an active borrow of a value and are trying to move it simultaneously.

let mut s = String::from("DSM Core");
let r = &s;        // borrow begins here
drop(s);           // error: cannot move out of `s` because it is borrowed
println!("{}", r); // r would dangle if move was allowed

Fix: Ensure the borrow ends before the move. Either drop the reference first, or restructure the code so the borrow's scope does not overlap with the move.

error[E0499] — Cannot Borrow as Mutable More Than Once

You have two simultaneous mutable borrows of the same value.

let mut v = vec![1, 2, 3];
let r1 = &mut v;   // first mutable borrow
let r2 = &mut v;   // error: second mutable borrow of `v`
println!("{:?} {:?}", r1, r2);

Fix: Ensure one mutable borrow ends before the next begins. Often, restructuring code to update the value in sequence rather than holding two mutable references solves this cleanly.

error[E0502] — Cannot Borrow as Mutable Because It Is Already Borrowed as Immutable

You have an active immutable borrow and are trying to create a mutable borrow.

let mut s = String::from("Bagamoyo");
let r1 = &s;       // immutable borrow
let r2 = &mut s;   // error: cannot borrow `s` as mutable because
                   //        it is also borrowed as immutable
println!("{} {}", r1, r2);

Fix: Use r1 before creating r2, or restructure to not hold both simultaneously. With NLL (non-lexical lifetimes), if you use r1 before creating r2 and the compiler can determine r1 is no longer live, this may compile without restructuring.

§ 2.9
Exercises
Exercise 2.1 — Fix the Compiler Errors

Each snippet has one ownership/borrowing error. Identify and fix each one without cloning unless necessary.

Create a file src/main.rs with each snippet in a separate function. Compile. Read the error. Fix it. Then answer: why did the original code fail, and what does your fix say about the lifetime of the data?

Snippet Arust
fn snippet_a() {
    let site = String::from("Nakawa Edge");
    let r = &site;
    let moved = site;
    println!("{}", r);
}
Snippet Brust
fn snippet_b() {
    let mut speeds = vec![10u8, 20, 30];
    let first = &speeds[0];
    speeds.push(40);
    println!("first: {}", first);
}
// Hint: why might push() invalidate first?
Snippet Crust
fn get_name() -> &str {
    let name = String::from("Sprint Tanzania");
    &name
}
Exercise 2.2 — The Hardware Ownership Problem

Model a multi-peripheral embedded system with Rust ownership

Without using any Embassy code, model the following scenario using Rust's ownership system:

  • You have a struct GpioBus that contains two Pin structs (representing GPIO pins 2 and 3).
  • A Tm1637 driver struct takes ownership of two pins in its constructor.
  • Write code that constructs a GpioBus, extracts the two pins, passes them to Tm1637::new(), and verify at compile time that attempting to use the original bus pins after construction is a compiler error.

This models exactly what Embassy's peripheral singleton does — use Rust's ownership to prove, at compile time, that the TM1637 driver is the only code that can touch those GPIO pins.

Exercise 2.3 — Lifetime Annotation Practice

Write the lifetime annotations for three functions

Each of these function signatures is missing lifetime annotations. Add the minimum lifetime annotations needed to compile them, and explain in a comment why those specific constraints are necessary.

  • A function that takes two string slices and returns the one that starts with "Sprint".
  • A struct AlertRef that holds a reference to an alert title string and a reference to a site name string, both borrowed from elsewhere. The struct should only be valid while both strings are valid.
  • A function that takes a mutable reference to a Vec of strings and a string slice, and appends the slice to the Vec. Explain why this function's lifetime constraints are simpler than the previous ones.
Exercise 2.4 — RAII Resource Tracking

Implement a custom Drop to understand when resources are released

Create a struct TrackedResource with a name field (String) that prints "Acquired: [name]" on creation and "Released: [name]" when dropped. Create several of these in different scopes and predict, before running the code, the exact order in which the release messages will print. Verify by running. Then extend the exercise by holding a TrackedResource inside a function that panics midway through — observe that Drop is still called even during unwinding.