How to Use GPIO, SPI, I2C, and UART in Rust

Use the `embedded-hal` traits as your abstraction layer and rely on board-specific crates (like `esp-hal` or `rpi-embedded-hal`) to implement them for your hardware.

The hardware doesn't care about your types

You wired a temperature sensor to your microcontroller. The datasheet says pin 3 goes to SDA and pin 4 goes to SCL. You write the Rust code, flash it, and get nothing. The C example from the vendor works perfectly. Your code compiles, runs, and the sensor stays silent.

The problem isn't your logic. It's the abstraction layer. Rust refuses to let you poke raw registers by accident. The compiler demands proof that you own the hardware resources you're using. If you try to configure the same pin twice, or use a pin that doesn't support the peripheral, the code won't compile. This friction is the price of safety. The reward is that your driver code can't accidentally corrupt memory or leave a peripheral in a broken state.

The contract: embedded-hal

Rust's embedded ecosystem revolves around embedded-hal. This crate defines a set of traits that describe hardware behavior without tying you to a specific chip. DigitalOutputPin tells the compiler a type can drive a voltage high or low. SpiDevice says a type can transfer bytes over a serial bus. I2c defines reading and writing to addresses.

Think of embedded-hal like a universal power adapter. Your code writes to the adapter. The adapter talks to the wall socket. The wall socket is different in every country. An ESP32 has different registers than an STM32 or an RP2040. You don't care about the socket shape. You care that the adapter gives you power. You code against the embedded-hal traits. Board-specific crates like esp-hal or rp-pico implement those traits for the actual silicon.

This split lets you write drivers once and run them on any microcontroller that supports the standard. It also lets the board crate handle the messy details: clock configuration, power domains, and pin multiplexing. You get a clean API. The board crate deals with the metal.

Minimal example: Blinking without burning

Start with GPIO. This is the foundation. Every peripheral maps back to pins. Here is how you initialize a pin and toggle it on an ESP32 using esp-hal.

use esp_hal::peripherals::Peripherals;
use esp_hal::gpio::{Io, Output, Level};
use esp_hal::delay::Delay;

/// Toggles GPIO2 at 1Hz.
/// This demonstrates resource ownership and type-state tracking.
fn main() -> ! {
    // Peripherals::take() consumes the hardware singleton.
    // This ensures no other part of the code can grab the same resources.
    let peripherals = Peripherals::take();
    let system = peripherals.SYSTEM;
    let clocks = esp_hal::clock::Clocks::configure(system.clock_control).freeze();
    
    // Io::new sets up the pin multiplexer.
    // It moves GPIO and IO_MUX into the Io struct.
    let io = Io::new(peripherals.GPIO, peripherals.IO_MUX);
    
    // Output::new consumes the pin and sets its direction.
    // The return type is Output<GpioPin<2>>, not just a number.
    let mut led = Output::new(io.pins.gpio2, Level::Low);
    let delay = Delay::new(clocks);

    loop {
        // toggle() flips the level.
        // The type system guarantees this pin is configured as output.
        led.toggle();
        delay.delay_ms(500u32);
    }
}

What the compiler is actually doing

The magic happens in the types. When you call io.pins.gpio2, you get a value of type GpioPin<2>. This is a zero-sized type that represents the physical pin. It has no runtime cost. It exists only to let the compiler track state.

When you pass that pin to Output::new, the function consumes the GpioPin<2>. You can't use the original variable anymore. The function returns an Output<GpioPin<2>>. The type has changed. The compiler now knows this pin is an output. If you try to call a read method on it, the compiler rejects the code with a trait bound error. The pin's state is encoded in its type.

Peripherals::take() enforces exclusive access to the chip's resources. The Peripherals struct contains fields for every hardware block. take() moves this struct into your code. If you try to call take() a second time, the compiler stops you with E0382 (use of moved value). You can't initialize the SPI peripheral twice. You can't hand the same pin to both UART and I2C. The type system prevents double-initialization and resource conflicts before the code runs.

This is why embedded Rust feels different. You aren't just writing functions. You are assembling a machine where every wire and register is accounted for. The compiler acts as a circuit inspector. It checks your wiring diagram and flags shorts before you apply power.

Realistic setup: Sensor and serial

Real applications mix peripherals. You might read a sensor over I2C and log the data over UART. Here is a setup that combines both. The code reads a byte from a sensor and sends it to the serial console.

use esp_hal::peripherals::Peripherals;
use esp_hal::gpio::Io;
use esp_hal::i2c::I2c;
use esp_hal::uart::{Uart, config::Config};
use esp_hal::delay::Delay;
use esp_hal::prelude::*;

/// Reads from I2C sensor and logs to UART.
/// Demonstrates pin consumption and peripheral initialization.
fn main() -> ! {
    let peripherals = Peripherals::take();
    let system = peripherals.SYSTEM;
    let clocks = esp_hal::clock::Clocks::configure(system.clock_control).freeze();
    
    let io = Io::new(peripherals.GPIO, peripherals.IO_MUX);
    let delay = Delay::new(clocks);

    // I2C: SDA on GPIO 21, SCL on GPIO 22.
    // The pins are moved into the I2c struct.
    // You can no longer use gpio21 or gpio22 as GPIO.
    let i2c = I2c::new(
        peripherals.I2C0,
        io.pins.gpio21,
        io.pins.gpio22,
        100.kHz(),
        &clocks,
    );

    // UART: TX on GPIO 1, RX on GPIO 3.
    // Standard USB-serial pins on many ESP32 dev boards.
    let mut uart = Uart::new(
        peripherals.UART0,
        io.pins.gpio1,
        io.pins.gpio3,
        Config::default().baudrate(115_200.bps()),
    ).unwrap();

    loop {
        // Read a byte from sensor address 0x48.
        let mut buf = [0u8];
        
        // ok() discards the error.
        // In embedded, panicking on sensor noise is usually a bad idea.
        if i2c.read(0x48, &mut buf).is_ok() {
            // Send the value to the serial console.
            uart.write_bytes(&buf).ok();
        }
        
        delay.delay_ms(1000u32);
    }
}

Notice the pin usage. gpio21 and gpio22 disappear after I2c::new. The compiler guarantees they are dedicated to I2C. You can't accidentally toggle them as GPIO later. The same applies to the UART pins. The hardware is partitioned by type.

Pitfalls and compiler signals

Embedded Rust has specific failure modes. The compiler catches most of them, but you need to know what the errors mean.

If you try to use a pin after handing it to a peripheral, the compiler rejects the code with E0382 (use of moved value). The pin is gone. It lives inside the peripheral struct now. You need to restructure your code to share the peripheral, not the pin.

If you pass a pin that doesn't support the peripheral function, you get E0277 (trait bound not satisfied). Not every GPIO pin can do I2C or SPI. The board crate implements traits only for valid pins. Check the datasheet or the board crate documentation for pin multiplexing constraints.

Asynchronous code introduces a different set of issues. If you use .await on a blocking driver, the compiler complains about Send or Sync bounds. You need an async-compatible driver and an executor. embedded-hal splits traits into blocking and async versions. Mixing them causes type mismatches. Stick to one paradigm per driver.

Convention aside: embedded developers often use .ok() to discard errors from non-critical peripherals. A sensor glitch shouldn't crash the whole system. Using .ok() signals that you considered the error and chose to ignore it. It's cleaner than let _ = ... and avoids the panic risk of unwrap().

Decision: Choosing your stack

Use embedded-hal traits when you want your driver code to run on any microcontroller that implements the standard. This maximizes code reuse across projects.

Use board-specific crates like esp-hal or rp-pico when you need to initialize clocks, power management, and map physical pins to peripherals. These crates provide the concrete implementations of the traits.

Use unsafe blocks only when wrapping a C library that lacks a Rust binding, and isolate the raw pointer access in a single helper function. Keep the unsafe surface minimal.

Use embedded-hal-async when your application runs an executor and you need non-blocking I/O to keep the CPU free for other tasks. This requires an async runtime like embassy or cortex-m-rtic.

Reach for cortex-m-rt when you are targeting bare-metal ARM Cortex-M chips without an RTOS. This crate provides the reset handler and exception vectors needed to start execution.

Trust the type system. If the compiler complains about a pin, you are trying to use a wire twice. Fix the wiring in your code, not in your head.

Where to go next