Ownership,
Borrowing, and
Lifetimes
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 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.
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.
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.2Rust'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.
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.
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
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.
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.
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.
// 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.
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.
// 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); }
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:
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.
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); }
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 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.
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.
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
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.
// 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 }
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.
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.
// 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. }
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.
// 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; }
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.
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?
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.
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.
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.
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?
fn snippet_a() { let site = String::from("Nakawa Edge"); let r = &site; let moved = site; println!("{}", r); }
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?
fn get_name() -> &str { let name = String::from("Sprint Tanzania"); &name }
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
GpioBusthat contains twoPinstructs (representing GPIO pins 2 and 3). - A
Tm1637driver 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.
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
AlertRefthat 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.
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.