In C, a GPIO pin is just a number. You pass it to gpio_init(), gpio_set_dir(), and gpio_put(), and the compiler has no idea whether the pin is configured, what direction it is set to, or whether another part of your code is also modifying it. Bugs from misconfigured pins, double initialization, and unsynchronized access to shared peripherals are common and hard to trace. Rust solves all of these at compile time through ownership, borrowing, and the typestate pattern. In this lesson, you will see exactly how these mechanisms work by building a debounced button that toggles an LED, with proper error handling and safe interrupt-shared state. #Rust #Ownership #EmbeddedSystems
What We Are Building
Button-Toggled LED with Software Debounce
A push button on GP14 toggles an external LED on GP15. Each press flips the LED state, with a 50 ms software debounce to ignore contact bounce. The program uses polling (not interrupts, which come in Lesson 3) and demonstrates ownership transfer, borrowing, Result-based error handling, and the typestate GPIO pattern. A second button on GP16 controls a second LED on GP17, demonstrating how to pass peripheral references between functions.
Project specifications:
Parameter
Value
Board
Raspberry Pi Pico (RP2040)
Button 1
GP14, active low with internal pull-up
LED 1
GP15, push-pull output
Button 2
GP16, active low with internal pull-up
LED 2
GP17, push-pull output
Debounce time
50 ms
Logging
defmt over RTT
Bill of Materials
Component
Quantity
Notes
Raspberry Pi Pico
1
From Lesson 1
Debug probe
1
From Lesson 1
Push buttons
2
Tactile momentary switches
LEDs (any color)
2
3mm or 5mm
220 ohm resistors
2
Current limiting for LEDs
Breadboard + jumper wires
1 set
From Lesson 1
Wiring Table
Pico Pin
Connection
Notes
GP14
Button 1 (one leg)
Other leg to GND; internal pull-up enabled
GP15
LED 1 anode (long leg)
Through 220 ohm resistor to GND
GP16
Button 2 (one leg)
Other leg to GND; internal pull-up enabled
GP17
LED 2 anode (long leg)
Through 220 ohm resistor to GND
GND
Button GND, LED cathodes
Common ground
Ownership in Rust: The Hardware Connection
Ownership is Rust’s central concept. Every value has exactly one owner. When the owner goes out of scope, the value is dropped (cleaned up). When you assign a value to a new variable or pass it to a function, ownership moves (the original variable becomes unusable). This is not garbage collection; it is a compile-time analysis with zero runtime cost.
For embedded systems, ownership maps perfectly to hardware: a peripheral should have exactly one owner. If two parts of your code both “own” the UART, they might send data simultaneously and corrupt each other’s output. If two functions both configure the same GPIO pin, one might overwrite the other’s settings. In C, preventing this requires discipline and documentation. In Rust, the compiler enforces it.
Move Semantics
letpins= rp_pico::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mutpac.RESETS,
);
// `pins.gpio15` is a pin in its unconfigured state
letled=pins.gpio15.into_push_pull_output();
// `pins.gpio15` is MOVED into `led` and no longer exists
// This would not compile:
// let another_led = pins.gpio15.into_push_pull_output();
// ^^^^^^^^^^^ value used after move
The .into_push_pull_output() method consumes pins.gpio15 by taking ownership of it. The original pins.gpio15 field is now empty. If you try to use it again, the compiler rejects the code with a “value used after move” error. In C, nothing prevents you from calling gpio_init(15) twice.
Why This Matters for Hardware
Consider this C bug:
// Module A: configures GP15 as a status LED output
gpio_init(15);
gpio_set_dir(15, GPIO_OUT);
// Module B (written by someone else): uses GP15 as a sensor input
gpio_init(15); // silently reconfigures the pin!
gpio_set_dir(15, GPIO_IN);
// Module A tries to set the LED, but the pin is now an input
gpio_put(15, 1); // does nothing, no error, no warning
In Rust, this is structurally impossible. Module A takes ownership of the pin. Module B cannot access it.
Typestate GPIO
The typestate pattern encodes the state of a resource in its type. The rp2040-hal uses this extensively for GPIO pins. A pin that has not been configured has a different type than a pin configured as an output, and both have different types from a pin configured as an input. The compiler uses these types to determine which operations are valid.
letstate=led.is_high(); // COMPILE ERROR: is_high() not implemented
// for SioOutput pins
// Attempt 2: write to an input pin
letbutton=pins.gpio14.into_pull_up_input();
button.set_high(); // COMPILE ERROR: set_high() not implemented
// for SioInput pins
// Attempt 3: use a pin before configuring it
letraw_pin=pins.gpio15; // still FunctionNull type
raw_pin.set_high(); // COMPILE ERROR: set_high() not defined
// for FunctionNull pins
Every one of these mistakes compiles and runs in C (usually doing nothing visible, or worse, doing something subtly wrong). In Rust, every one is caught before the code reaches the chip.
Reconfiguring Pins
Sometimes you need to change a pin’s configuration at runtime (for example, switching between input and output for a bidirectional bus). Rust handles this through type state transitions:
// Start as output
letmutled=pins.gpio15.into_push_pull_output();
led.set_high().unwrap();
// Reconfigure as input (consumes the output pin, returns an input pin)
letsensor=led.into_pull_up_input();
letvalue=sensor.is_high().unwrap();
// Reconfigure back to output
letmutled=sensor.into_push_pull_output();
led.set_low().unwrap();
Each transition consumes the old type and returns a new one. You cannot accidentally hold references to both the input and output versions of the same pin.
Button Debouncing with Ownership
Mechanical push buttons do not produce clean transitions. When you press a button, the contacts bounce several times over 1 to 20 ms, generating multiple rapid edges. Without debouncing, a single press might register as 5 or 10 presses. The debounce logic owns its state (last stable reading, timestamp of last change) and borrows the button pin to read it.
Debouncer Struct
use embedded_hal::digital::InputPin;
use rp2040_hal::fugit::MicrosDurationU64;
/// Software debouncer that owns its internal state.
/// The button pin is borrowed (not owned) so the caller
/// retains ownership of the hardware.
pubstruct Debouncer {
last_raw: bool,
stable: bool,
last_change_us: u64,
debounce_us: u64,
}
impl Debouncer {
/// Create a new debouncer with the given debounce period.
pubfnnew(debounce_us: u64) ->Self {
Debouncer {
last_raw:false,
stable:false,
last_change_us:0,
debounce_us,
}
}
/// Update the debouncer with the current pin state and timestamp.
/// Returns true if a falling edge (button press) was detected.
// Return true on falling edge (button press with pull-up)
returnold_stable&&!self.stable;
}
false
}
/// Get the current debounced state.
pubfnis_pressed(&self) -> bool {
!self.stable // active low with pull-up
}
}
Notice the ownership patterns:
Debouncerowns its internal state (last_raw, stable, timestamps). Nobody else can modify these.
The update() method borrows the pin (&P) rather than owning it. This means the caller keeps ownership of the pin and can use it elsewhere if needed.
The generic P: InputPin means this debouncer works with any pin type that implements InputPin, not just RP2040 pins.
Error Handling: Result vs C Error Codes
In C embedded code, error handling is inconsistent. Some functions return error codes, some set global error flags, some return sentinel values, and some simply ignore errors. The compiler does not enforce checking any of these.
// C: errors are easily ignored
int status =i2c_read(I2C0, addr, buf, len);
// What if status < 0? Nothing forces you to check.
gpio_put(15, 1); // What if pin 15 is not initialized? No indication.
In Rust, operations that can fail return Result<T, E>. You must handle the Result before you can access the value inside. The compiler will warn (and can be configured to error) if you ignore a Result.
// Rust: the compiler tracks error handling
matchled.set_high() {
Ok(()) => defmt::info!("LED on"),
Err(e) => defmt::error!("Failed to set LED: {:?}", e),
}
// Or, for infallible operations where you know it cannot fail:
led.set_high().unwrap(); // panics if it somehow fails (documents your assumption)
led.set_high()?; // the ? operator returns early on error
Ok(())
}
The ? Operator
The ? operator is Rust’s way of propagating errors. If the expression before ? is Err(e), the function returns early with that error. If it is Ok(v), the value v is extracted. This replaces the C pattern of checking every return value:
Functions often need access to peripherals they do not own. Rust handles this through references (borrowing). You can lend a peripheral to a function without giving up ownership.
Immutable References (&T)
Multiple parts of the code can read a peripheral simultaneously through immutable references:
fnprint_button_state(button:&impl InputPin) {
ifbutton.is_high().unwrap() {
defmt::info!("Button released");
} else {
defmt::info!("Button pressed");
}
}
// Multiple reads are fine
letbutton=pins.gpio14.into_pull_up_input();
print_button_state(&button); // borrows button
print_button_state(&button); // borrows again, no problem
// button is still owned here
Mutable References (&mut T)
Only one mutable reference can exist at a time. This prevents data races at compile time:
toggle_led(&mutled, false); // exclusive mutable borrow (previous one ended)
The compiler guarantees that while toggle_led has a &mut reference to the LED, no other code can access it. This eliminates an entire class of concurrency bugs.
Static Variables and the Mutex Pattern
In C, sharing data between the main loop and an interrupt handler is simple (and dangerous):
// C: global mutable state (common source of bugs)
volatileint counter =0;
volatilebool flag =false;
voidTIM2_IRQHandler(void) {
counter++;
flag =true;
}
intmain(void) {
while (1) {
if (flag) {
flag =false;
printf("Counter: %d\n", counter);
// BUG: counter might change between the flag check and printf
}
}
}
This has a data race: the interrupt can fire between checking flag and reading counter, giving an inconsistent view. The volatile keyword only prevents the compiler from optimizing away the read; it does not prevent torn reads or inconsistent state.
Rust makes global mutable state intentionally difficult. You cannot have a static mut variable without unsafe, and the compiler is right to warn you: mutable statics are inherently unsafe because any code can access them without synchronization.
The Safe Pattern: Mutex with Critical Section
use core::cell::RefCell;
use critical_section::Mutex;
// Shared state protected by a Mutex
// The Mutex ensures access only happens inside a critical section
drop(flag); // release the immutable borrow before mutating
letcounter=SHARED_COUNTER.borrow_ref(cs);
defmt::info!("Counter: {}", *counter);
letmutflag=SHARED_FLAG.borrow_ref_mut(cs);
*flag=false;
}
});
}
The critical_section::with() function disables interrupts, executes the closure, and re-enables interrupts. The CriticalSection token (cs) passed to the closure is proof that interrupts are disabled, and the Mutex requires this token to grant access. This means you cannot access the shared data without first disabling interrupts, which eliminates the data race by construction.
Press Button 1 once. LED 1 should turn on. Press again. LED 1 should turn off.
Press Button 2 once. LED 2 should turn on independently of LED 1.
Press both buttons rapidly. Each LED should toggle independently. The debounce should prevent double-counting.
Hold a button down. Only one toggle should register (on the initial press, not while held).
Check the RTT output. Each press should show exactly one “Button N pressed” message with an incrementing total count.
Ownership Patterns Summary
Pattern
When to Use
Example in This Lesson
Move (ownership transfer)
When a function needs permanent control of a resource
pins.gpio15.into_push_pull_output() consumes the pin
Immutable borrow (&T)
When a function needs to read but not modify
debounce.update(&button, now) reads the button
Mutable borrow (&mut T)
When a function needs to modify
toggle_led(&mut led, &mut state) sets the LED
Owned state in struct
When state must be encapsulated
Debouncer owns its timing variables
Generics with traits
When code should work with any compatible type
fn toggle_led<P: OutputPin>(led: &mut P, ...)
Rules the Compiler Enforces
One owner at a time. A value can have exactly one owner. Moving transfers ownership.
Many immutable borrows OR one mutable borrow. Never both simultaneously.
Borrows must not outlive the owner. The compiler tracks lifetimes.
No use after move. Once a value is moved, the old name is invalid.
These rules, applied to hardware peripherals, eliminate:
Double initialization of the same peripheral
Reading a pin configured as output (or writing to an input)
Using an unconfigured peripheral
Two modules modifying the same peripheral without synchronization
Use-after-free of DMA buffers (covered in Lesson 5)
Production Notes
Production Considerations
Typestate overhead: The typestate pattern is entirely a compile-time construct. At runtime, pin.into_push_pull_output() generates the same register writes as the C equivalent. There is no extra RAM, flash, or CPU overhead. The safety is free.
Debounce timing: The 50 ms debounce period works well for most tactile switches. For capacitive touch buttons or reed switches, you may need different values. The debouncer struct makes this easy to tune per-button.
Pulling pins low vs high: The RP2040 has configurable internal pull-ups and pull-downs. Using into_pull_up_input() and wiring buttons to GND is the most common pattern because it reduces external components. Make sure your buttons connect between the pin and GND, not between the pin and 3.3V.
Critical section cost: On the single-core RP2040, critical_section::with() disables interrupts globally. The critical section should be as short as possible to minimize interrupt latency. Copy data out of the Mutex inside the closure, then process it outside. On the dual-core RP2040, the critical section uses spinlocks, which are more expensive.
RefCell panics:RefCell performs runtime borrow checking. If you borrow mutably while an immutable borrow is still active, it panics. In no_std, this triggers the panic handler. During development, use panic-probe to see the panic location over defmt.
What You Have Learned
Lesson 2 Complete
Ownership concepts:
Move semantics: .into_*() methods consume the original value
Comments