Maker.io main logo

Intro to Embedded Rust Part 4: Ownership and Borrowing

90

2026-02-12 | By ShawnHymel

Raspberry Pi MCU

Understanding Rust's ownership model is often considered one of the biggest hurdles for newcomers to the language, but it's also one of Rust's most powerful features. Ownership is the mechanism that allows Rust to guarantee memory safety without needing a garbage collector, eliminating entire classes of bugs that plague languages like C and C++ (e.g., use-after-free errors, double frees, dangling pointers, data races). While frameworks like Arduino and high-level languages abstract away memory management details, Rust forces you to think about how data is stored, accessed, and cleaned up. This might seem daunting at first, but once you understand the rules, you'll find that the compiler becomes a helpful guide rather than an obstacle, catching potential bugs before your code ever runs on hardware.

Note that all code for this series can be found in this GitHub repository.

We'll step back from embedded hardware to focus entirely on the fundamental rules of ownership and borrowing in Rust. You won't need a Raspberry Pi Pico 2 or any external electronics: just your computer and the Rust compiler. We'll explore seven core rules that govern how Rust manages memory, using simple code examples that demonstrate each concept.

Unlike the examples in the Rust Book, which often rely on heap-allocated types like String and Vec, we'll work primarily with stack-based data structures similar to what you'll encounter in embedded systems without the standard library. By the end of this tutorial, you'll have a solid understanding of how ownership, borrowing, references, and scope work together to create safe, efficient code that works just as well on a microcontroller as it does on a desktop computer.

Example Struct

Before we dive into the ownership rules, let's establish our example data structure and understand where it lives in memory. We'll be working with a simple SensorReading struct that contains two fields: value (a 16-bit unsigned integer) and timestamp_ms (a 32-bit unsigned integer).

Copy Code
struct SensorReading {
    value: u16,
    timestamp_ms: u32,
}

This struct is typical of what you might use in embedded systems to store data from a sensor along with a timestamp. When you create an instance of this struct and store it in a variable like let reading = SensorReading {value: 1, timestamp_ms: 100}, the entire struct (all 6 bytes of data) lives directly on the stack. This is different from types like String in the standard library, where the struct itself lives on the stack but contains a pointer to data stored on the heap.

If you would like a refresher on stack vs. heap, check out this helpful article.

Let’s say we create a reference to our reading variable using something like:

Copy Code
let my_ref = &reading;

With this, we're creating a new variable my_ref, that stores the memory address of reading. Both my_ref and reading live on the stack. reading contains the actual sensor data, while my_ref contains just a pointer (memory address) pointing to where reading is located.

In Rust, references are like safety wrappers around raw pointers from C: they store memory addresses just like pointers do, but the compiler enforces strict rules at compile-time to prevent common memory bugs. Importantly, references have zero runtime overhead, as they compile down to simple pointers under the hood. This stack-only approach makes our examples perfect for understanding ownership and borrowing without the added complexity of heap allocation, which is especially relevant for embedded systems where we often avoid using the heap altogether.

Rule 1: Each value in Rust has an owner

Copy Code
fn main() {
    let reading = SensorReading {value: 1, timestamp_ms: 100};

    println!("{}, {}", reading.value, reading.timestamp_ms);
} 

The first and most fundamental rule of Rust's ownership system is that every value must have exactly one owner. In our example, we create a SensorReading struct and store it in the variable reading. At this moment, reading becomes the owner of that SensorReading value. It's responsible for that data and controls its lifetime. This is straightforward: when you assign a value to a variable, that variable owns the value.

Ownership determines when memory gets cleaned up: when the owner goes out of scope (in this case, at the end of the function), Rust automatically deallocates the memory used by that value. This automatic cleanup (called "dropping") happens deterministically at compile-time, giving you the predictability of manual memory management without the risk of forgetting to free memory or accidentally freeing it twice.

Rule 2: There can only be one owner at a time

Copy Code
fn main() {
    let reading = SensorReading {value: 2, timestamp_ms: 100};

    // Transfer (move) ownership
    let new_owner = reading;

    // Error: borrow of moved value: `reading`
    // println!("{}", reading.value);
    // println!("{}", reading.timestamp_ms);

    // This works
    println!("{}, {}", new_owner.value, new_owner.timestamp_ms);
}

The second rule enforces that a value can have only one owner at any given time. In this example, when we assign reading to new_owner, ownership of the SensorReading is transferred (or "moved") from reading to new_owner. After this move, reading is no longer valid.

If you try to access reading.value or any other field, the compiler will give you an error: "borrow of moved value." This prevents use-after-move bugs that could lead to accessing invalid memory. Only new_owner can now access the data because it's the sole owner.

Copy Code
fn main() {
    let my_array = [1, 1, 2, 3, 5, 8];

    // Primitives and arrays implement the Copy trait (if elements implement Copy)
    let my_copy = my_array;

    // Both of these work
    println!("{:?}", my_array);
    println!("{:?}", my_copy);
}

However, there's an important exception to this rule: types that implement the Copy trait, such as primitive integers, floating-point numbers, booleans, and arrays of Copy types. As shown in the example, when you assign my_array to my_copy, the data is actually copied bit-for-bit rather than moved, so both variables remain valid and own their own independent copies of the data. Our SensorReading struct does not implement Copy by default, so it follows the move semantics: ownership transfers rather than copying. This distinction is crucial: simple stack-only types can be copied cheaply, while more complex types are moved to avoid expensive implicit copies and to maintain clear ownership semantics.

Rule 3: When the owner goes out of scope, the value will be dropped

Copy Code
// fn print_reading(reading: SensorReading) {
//     // Ownership of reading is "consumed" by this function
//     println!("{}", reading.value);
//     println!("{}", reading.timestamp_ms);
//     // reading goes out of scope, so the value is dropped here
// }

fn print_reading(reading: SensorReading) -> SensorReading {
    // Ownership of reading is "consumed" by this function
    println!("{}, {}", reading.value, reading.timestamp_ms);
    
    // Fix: return reading (shorthand for "return reading;")
    reading
}

fn main() {
    // New scope
    {
        let mut reading = SensorReading {value: 3, timestamp_ms: 100};

        // Error: borrow of moved value: `reading`
        // print_reading(reading);

        // Fix: return reading ownership
        reading = print_reading(reading);

        // Use `reading` after a move
        println!("{}, {}", reading.value, reading.timestamp_ms);
    }

    // Error: cannot find value `reading` in this scope
    // println!("{}, {}", reading.value, reading.timestamp_ms);
}

The third rule addresses what happens when ownership ends: when the owner goes out of scope, Rust automatically deallocates the value by "dropping" it. In the example, we create a new scope using curly braces, and reading only exists within that scope. When the closing brace is reached, reading goes out of scope, and its memory is automatically freed. Trying to access it after that point results in a compiler error.

This also applies to function calls: when you pass reading as a parameter to print_reading(), ownership is transferred (moved) into the function. If the function does not return ownership back, the value is dropped at the end of the function, making the original variable invalid.

The commented-out version of print_reading() demonstrates this problem: after calling it, reading would be invalid because ownership was consumed and the value was dropped inside the function. The solution is to return ownership: by having print_reading() return the SensorReading value, we transfer ownership back to the caller, allowing us to reassign it to reading and continue using it. This pattern of consuming and returning ownership can feel cumbersome, which is why Rust provides references (borrowing) as a more ergonomic solution. We'll explore borrowing in the next set of rules.

Rule 4: You can have either one mutable reference or any number of immutable references

Copy Code
fn print_borrowed_reading(reading: &SensorReading) {
    // We borrow reading instead of consuming ownership (pass by reference)
    println!("{}, {}", reading.value, reading.timestamp_ms);
}

fn main() {
    let mut reading = SensorReading {value: 4, timestamp_ms: 100};

    // References are easier than consuming and returing ownership
    print_borrowed_reading(&reading);

    // Can have any number of immutable references
    let immut_ref_1 = &reading;
    let immut_ref_2 = &reading;
    let immut_ref_3 = &reading;
    println!("{}", (*immut_ref_1).timestamp_ms);    // Explicit dereference
    println!("{}", immut_ref_2.timestamp_ms);       // Automatic dereference
    println!("{}", immut_ref_3.timestamp_ms);
    // immut_refs are no longer used, so they go out of scope (example of "non lexical lifetimes")

    // Only one mutable reference at a time (exclusive)
    let mut_ref_1 = &mut reading;

    // Error: cannot borrow `reading` as mutable more than once at a time
    // let mut_ref_2 = &mut reading;
    // println!("{}", mut_ref_2.timestamp_ms);

    // Error: cannot borrow `reading` as immutable because it is also borrowed as mutable
    // let immut_ref_4 = &reading;
    // println!("{}", immut_ref_4.timestamp_ms);

    // Change value in struct through the mutable reference
    mut_ref_1.timestamp_ms = 1000;
    println!("{}", reading.timestamp_ms);
    // mut_ref_1 is no longer used, so it goes out of scope
    
    // Now we can borrow again!
    let mut_ref_3 = &mut reading;
    mut_ref_3.timestamp_ms = 2000;
    println!("{}", reading.timestamp_ms);
}

The fourth rule introduces borrowing: a way to temporarily access data without taking ownership. Rust allows you to create references to values using the & operator, as seen in print_borrowed_reading(&reading), which borrows reading without consuming it. This is far more ergonomic than the consume-and-return pattern from Rule 3. However, Rust enforces strict borrowing rules to prevent data races and concurrent modification issues: you can have either multiple immutable references (&T) or exactly one mutable reference (&mut T) to a value at any given time, but never both simultaneously.

In the example, we create three immutable references (immut_ref_1, immut_ref_2, immut_ref_3) that can all coexist because they only read data. None of them can modify the underlying value. You can access fields through references either with explicit dereferencing using (*immut_ref_1).timestamp_ms or let Rust automatically dereference with immut_ref_2.timestamp_ms, which is more idiomatic.

When we create a mutable reference with:

Copy Code
let mut_ref_1 = &mut reading;

It gains exclusive access to modify the data, which is why attempting to create another mutable reference or any immutable reference while mut_ref_1 exists would result in compiler errors. This exclusivity prevents situations where one part of your code modifies data while another part is reading it, eliminating a whole class of concurrency bugs.

Modern Rust uses "non-lexical lifetimes," meaning references don't need to last until the end of their scope: they end when they're last used. This is why, after we use mut_ref_1 and it's no longer referenced in the code, we can create a new mutable reference mut_ref_3 to modify the data again. This borrowing system is what allows Rust to guarantee memory safety without runtime overhead: all these checks happen at compile-time, and references compile down to simple pointers with zero additional cost.

Rule 5: References must always be valid

Copy Code
// Fix: return value with full ownership
fn return_reading() -> SensorReading {
    let some_reading = SensorReading {value: 5, timestamp_ms: 100};
    some_reading
    // More idiomatic to just return `SensorReading {value: 5, timestamp_ms: 100}`
}

fn main() {
    let reading = return_reading();
    println!("{}, {}", reading.value, reading.timestamp_ms);
}

The fifth rule ensures that references never point to invalid or freed memory (known as a "dangling pointer" in languages like C and C++). The commented-out version of return_reading() attempts to create a SensorReading inside the function, then returns a reference to it. This would be catastrophic: some_reading is a local variable that gets dropped when the function ends, so returning a reference to it would create a dangling pointer: a reference pointing to memory that's been deallocated.

In C or C++, this might compile but lead to undefined behavior, crashes, or security vulnerabilities when you try to access that memory. Rust's compiler catches this at compile-time with the error "cannot return reference to local variable," preventing the bug before it can cause harm. The fix is simple: return the value itself with full ownership transfer rather than a reference. By returning some_reading directly, ownership moves to the caller, and the data remains valid because it's now owned by reading in the calling function. Rust's borrow checker analyzes the lifetimes of all references throughout your program to ensure they never outlive the data they point to, giving you the safety of garbage-collected languages with the performance of manual memory management.

Rule 6: If you move out part of a value, you cannot use the whole value anymore

Copy Code
fn main() {
    let my_tuple = (
        SensorReading {value: 6, timestamp_ms: 100},
        SensorReading {value: 6, timestamp_ms: 101},
    );

    // Partially move ownership
    let first_reading = my_tuple.0;

    // Error: borrow of moved value: `my_tuple.0`
    // println!("{}", my_tuple.0.value);
    
    // Error: use of partially moved value: `my_tuple`
    // let new_owner = my_tuple;
    // println!("{}", new_owner.1.value);
    
    // Can print new owner
    println!("{}, {}", first_reading.value, first_reading.timestamp_ms);

    // Can move and borrow other parts
    println!("{}, {}", my_tuple.1.value, first_reading.timestamp_ms);
} 

The sixth rule addresses what happens with compound values like tuples, structs, and arrays when you move ownership of only part of the value. In the example, we create a tuple containing two SensorReading structs. When we move the first element out with let first_reading = my_tuple.0, ownership of that specific SensorReading transfers to first_reading, but the tuple itself enters a partially moved state. From this point on, you can no longer access my_tuple.0 (it's been moved), and you can no longer use my_tuple as a whole value, as trying to move the entire tuple to another variable fails with "use of partially moved value."

However, you can still access and move the parts that haven't been moved yet: my_tuple.1 remains valid and can be accessed or borrowed. This rule prevents you from accidentally using data that's been moved elsewhere while still allowing you to work with the remaining valid parts. In practice, this situation often comes up with pattern matching and destructuring, where you might want to extract specific fields from a struct or elements from a collection. Understanding partial moves helps you reason about which parts of your data structure remain valid after ownership transfers, maintaining Rust's guarantee that you never access moved or invalid data.

Rule 7: Slices are references to the whole value and follow the same borrowing rules

Copy Code
fn main() {
    let my_array = [
        SensorReading {value: 7, timestamp_ms: 100},
        SensorReading {value: 7, timestamp_ms: 101},
        SensorReading {value: 7, timestamp_ms: 102},
    ];

    // Create a slice (section of the array), borrows all of my_array immutably
    let slice_1 = &my_array[0..1];

    // Error: cannot borrow `my_array` as mutable because it is also borrowed as immutable
    // let slice_2 = &mut my_array[1..3];

    // Fix: we can have multiple immutable references
    let slice_2 = &my_array[1..3];

    // Print out some of our slices
    println!("{}, {}", slice_1[0].value, slice_1[0].timestamp_ms);
    println!("{}, {}", slice_2[0].value, slice_2[0].timestamp_ms);
    println!("{}, {}", slice_2[1].value, slice_2[1].timestamp_ms);
}

The seventh rule introduces slices, which are references to contiguous sequences of elements in arrays, vectors, or other collections. In this example, we create an array of three SensorReading structs and then create slices using the range syntax &my_array[0..1] and &my_array[1..3].

Note that a slice doesn't own the data. It is a reference (or "view") into a portion of the original array. Importantly, when you create a slice, it borrows the entire underlying array, not just the portion you're viewing. This is why when slice_1 creates an immutable borrow with &my_array[0..1], we cannot create a mutable slice like &mut my_array[1..3]. Even though the ranges don't overlap, the borrow checker sees that my_array is already borrowed immutably and won't allow a mutable borrow at the same time.

However, we can create multiple immutable slices like slice_2 = &my_array[1..3] because they follow Rule 4: you can have any number of immutable references simultaneously. Slices are incredibly useful in embedded systems for working with buffers and arrays without copying data, allowing you to efficiently pass portions of arrays to functions while maintaining Rust's safety guarantees.

Copy Code
fn main() {
    let mut my_array = [
        SensorReading {value: 7, timestamp_ms: 100},
        SensorReading {value: 7, timestamp_ms: 101},
        SensorReading {value: 7, timestamp_ms: 102},
    ];

    // Split at index 1 to borrow two mutable slices
    let (slice_1, slice_2) = my_array.split_at_mut(1);

    // Error: cannot assign to `my_array[_].timestamp_ms` because it is borrowed
    // my_array[0].timestamp_ms = 1234;

    // We can modify each slice
    slice_1[0].timestamp_ms = 1000;
    slice_2[0].timestamp_ms = 1001;
    slice_2[1].timestamp_ms = 1002;
    // slice_1 and slice_2 go out of scope here

    // We can access my_array again
    my_array[0].timestamp_ms = 1234;

    // Show that the original array changed
    println!("{}, {}", my_array[0].value, my_array[0].timestamp_ms);
    println!("{}, {}", my_array[1].value, my_array[1].timestamp_ms);
    println!("{}, {}", my_array[2].value, my_array[2].timestamp_ms);
}

This example demonstrates a more advanced technique: splitting an array into multiple mutable slices that can coexist. Normally, you can only have one mutable reference to an array at a time, but what if you need to modify different parts simultaneously? The split_at_mut() method solves this by dividing the array at a specified index and returning two separate mutable slices: slice_1 covers elements before the split point, and slice_2 covers elements after it.

Because these slices reference non-overlapping regions of memory, Rust's borrow checker allows both to exist simultaneously without violating the "one mutable reference" rule. While the slices are active, you cannot access the original my_array directly because it's borrowed mutably through the slices. Once the slices go out of scope (after their last use), you regain full access to my_array and can modify it directly again.

This pattern is particularly valuable in embedded systems when working with DMA buffers, ring buffers, or any situation where you need to process different sections of an array independently, demonstrating how Rust's ownership system can express safe concurrent access patterns that would be error-prone in other languages.

Recommended Reading

These seven rules form the foundation of Rust's ownership and borrowing system, providing memory safety without garbage collection or runtime overhead. While they might seem restrictive at first, they prevent entire categories of bugs that plague C and C++ programs (e.g., use-after-free, double frees, dangling pointers, data races) all caught at compile-time before your code ever runs.

Don’t worry if these rules seem daunting at first. With time and practice, you will become more familiar with them. We will also continue to use them throughout the rest of the series.

In the next tutorial, we will communicate with an I2C temperature sensor connected to the Raspberry Pi Pico 2. I recommend reading chapters 5-9 in the Rust Book and tackling structs, enums, modules, and error_handling exercises in rustlings. We will be relying on these common primitives and patterns over the course of the next few episodes.

Find the full Intro to Embedded Rust series here.

Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.