R
RUSTY YELLOW PAGES

The borrow checkerConcept


Explanation

The borrow checker is the part of the compiler that statically verifies every ownership and borrowing rule holds for every possible execution path, before the program ever runs: no value is used after it's moved, no reference outlives the data it points to, and no mutable reference coexists with any other reference to the same data.

This is what most people mean when they talk about "fighting the borrow checker" as a newcomer — it rejects programs that would be memory-unsafe (dangling pointers, data races, use-after-free) at compile time, with no runtime cost and no possibility of the bug reaching production, in exchange for sometimes requiring the code to be restructured in ways that feel unfamiliar coming from a language without this check.

The mental model that helps most: the borrow checker isn't an arbitrary obstacle, it's checking a real property your program needs to hold regardless of language — other languages either enforce a version of it at runtime (garbage collection sidesteps use-after-free by never freeing early; a mutex enforces exclusivity at runtime with a lock) or don't enforce it at all (raw pointers in C, where violating it is undefined behavior). Rust's distinguishing choice is doing the check entirely at compile time, for zero runtime cost, which is only possible because the rules are conservative — some genuinely safe programs are rejected simply because the checker can't prove they're safe, which is why escape hatches like interior mutability, reference counting, and (in the rare, unavoidable case) unsafe exist.

Basic usage example

let mut v = vec![1, 2, 3];
let first = &v[0];       // <- shared borrow of v starts here
v.push(4);                // borrow checker error: can't mutate v...
println!("{first}");      // ...while `first` still borrows it

Restriction: this is a compile-time rejection, not a runtime panic — the fix is to shorten first's scope (or restructure the code) so it no longer overlaps with the mutable use.

Best practices & deeper information

Scenario

Sharing data with multiple references

Reading a shared config in several places goes smoothly as long as each borrow's scope is kept as small as the code that actually needs it, rather than held open for the rest of a function "just in case."

struct Config {
    max_retries: u32,
    timeout_ms: u32,
}

fn summarize(cfg: &Config) -> String {
    format!("retries={} timeout={}", cfg.max_retries, cfg.timeout_ms)
}

let cfg = Config { max_retries: 3, timeout_ms: 500 };

{
    let view = &cfg; // <- borrow scoped tightly to this block
    println!("{}", summarize(view)); // <- last use: the borrow ends here (NLL)
} // well before any later mutation of cfg would need to happen

Why this way: letting a shared borrow end at its last use, rather than at the end of its enclosing scope, is what keeps later mutations of the same value from ever colliding with it — the Rust Book covers this scoping behavior as the key to avoiding borrow-checker friction, not a workaround for it.

Scenario

Mutating through a reference

Updating one field of a struct while still needing to read another works in a single scope because the borrow checker tracks borrows of individual fields separately — a mutable borrow of one field can coexist with a shared borrow of a different one.

struct Sensor {
    readings: Vec<f64>,
    status: String,
}

let mut sensor = Sensor { readings: Vec::new(), status: "ok".into() };

let readings = &mut sensor.readings; // <- mutable borrow of ONE field
let status = &sensor.status;         // <- simultaneous shared borrow of a DIFFERENT field
readings.push(21.5);
println!("{status}");

Why this way: the borrow checker tracks field-level (disjoint) borrows within a function body, so structuring mutation around individual fields rather than whole-struct &mut self methods avoids artificial conflicts; the Rustonomicon covers splitting borrows across separate fields as the standard way to keep the checker satisfied without restructuring the data itself.

Explanation

Embedded support: Full

The borrow checker's proof happens entirely at compile time and leaves no trace in the compiled output, which is exactly the property that makes it uniquely valuable in code shared between fn main's loop and one or more #[interrupt] handlers. An interrupt can preempt main at essentially any instruction boundary, completely independent of program logic — from the compiler's point of view, an interrupt handler is a second concurrent context that can run "at the same time" as main, with none of the sequencing guarantees an ordinary function call provides. In C, sharing a global between a handler and the main loop is just sharing a global: nothing stops the same multi-byte struct from being half-written by the handler and half-read by main, and the resulting corruption is a runtime heisenbug that only reproduces under the right timing — found, if you're lucky, in testing, and if not, months later in the field. Rust's borrow checker turns that entire bug class into a compile error: safe Rust has no way to alias a &mut across the main-loop/interrupt boundary without going through something the checker can verify, so a static item touched from both contexts must be wrapped in a synchronization primitive (typically critical_section::Mutex<Cell<T>>/Mutex<RefCell<T>>, see Interior mutability) before either side can safely reach it — and the compiler simply refuses to build anything that tries to skip that step. None of this costs a single cycle at runtime: the check runs once, on the host machine, during compilation, and the firmware that ships contains no borrow-tracking code whatsoever. It's the identical static analysis described above, just applied to a domain — interrupt- driven, no-OS firmware — where the class of bug it prevents is unusually common and unusually expensive to debug once it's running on real hardware.

Basic usage example

struct SensorConfig { gain: u8 }

let mut config = SensorConfig { gain: 4 };
let cfg_ref = &config;     // <- shared borrow of config starts here
config.gain = 8;            // borrow checker error: can't mutate while borrowed
println!("{}", cfg_ref.gain);

Best practices & deeper information

Scenario

Sharing state across threads

A timer interrupt updates the latest sample while the main loop reads it; the borrow checker won't let either side reach the shared value except through a synchronization wrapper it can verify.

use core::cell::Cell;
use critical_section::Mutex;

// AVOID: a bare mutable static — nothing stops `main` and the interrupt
// handler from touching it at the same instant, and the aliasing bug is
// only caught (if ever) as a corrupted reading discovered on hardware
// static mut LATEST_SAMPLE: u16 = 0;

// PREFER: the borrow checker enforces exclusive access at compile time
static LATEST_SAMPLE: Mutex<Cell<u16>> = Mutex::new(Cell::new(0));

#[interrupt]
fn ADC1() {
    critical_section::with(|cs| {
        LATEST_SAMPLE.borrow(cs).set(read_adc_register()); // <- only reachable inside a checked critical section
    });
}

fn main_loop() -> ! {
    loop {
        let sample = critical_section::with(|cs| LATEST_SAMPLE.borrow(cs).get());
        process(sample);
    }
}

Why this way: wrapping the shared sample in critical-section's Mutex<Cell<_>> is what lets the borrow checker prove main and the interrupt handler never alias a &mut to the same value — removing the wrapper doesn't just risk a bug, it fails to compile, since Rust's 2024 edition denies-by-default creating a reference to a mutable static via static_mut_refs specifically because that pattern can't be checked; C's shared-global idiom offers no equivalent compile-time backstop.

Scenario

Mutating through a reference

A HAL's split() call turns one peripheral register block into disjoint, individually-owned fields specifically so the borrow checker can reason about each pin's borrows independently of the others.

let gpiob = dp.GPIOB.split(); // <- returns disjoint per-pin fields, not one shared handle

let led = &mut gpiob.pb0;   // <- exclusive borrow of ONE pin
let button = &gpiob.pb1;    // <- simultaneous shared borrow of a DIFFERENT pin
led.set_high();
if button.is_high() {
    // ...
}

Why this way: the borrow checker tracks field-level (disjoint) borrows within a function body, so embedded-hal-style crates deliberately split a peripheral into one field per pin/function — the same field-splitting mechanism the Rustonomicon documents for ordinary structs, applied here to real driver design so unrelated pins never contend over one shared handle.