How to Use the embedded-hal Traits in Rust

The embedded-hal traits provide a standardized interface for microcontroller peripherals, enabling hardware-agnostic Rust code.

When your code outgrows one microcontroller

You spend three days wiring up a temperature sensor to an ESP32. The code works. Then your team decides to switch to an STM32 for better power efficiency. You copy the logic over. The register names change. The initialization sequence changes. The I2C timing constants change. You spend another three days rewriting the exact same sensor logic. This happens constantly in embedded development. The embedded-hal crate exists to stop that cycle.

The abstraction layer, explained

Hardware Abstraction Layers sit between your application logic and the silicon. They define a common vocabulary for talking to peripherals. Think of a power outlet standard. Your laptop charger does not need to know which power plant generated the electricity or what transformer stepped the voltage down. It just plugs into a standardized socket and expects a standardized voltage. embedded-hal does the same thing for microcontrollers. It defines a set of Rust traits for common peripherals: GPIO pins, I2C buses, SPI buses, timers, and delays. You implement those traits for your specific chip. Your application code talks to the traits. The chip details disappear behind the abstraction.

Embedded Rust almost always runs in a no_std environment. The standard library does not exist on a bare-metal microcontroller. There is no operating system to manage memory or handle threads. embedded-hal is designed for this constraint. It relies entirely on compile-time guarantees and zero-cost abstractions. You get the safety of trait bounds without the runtime overhead of dynamic dispatch.

Stop rewriting the same logic for every new chip.

A minimal toggle

Start with a single pin. The embedded_hal::digital::OutputPin trait defines what it means to control a digital output. Any type that implements it must provide set_high, set_low, and is_set_high. It also defines an associated type Error for hardware failures.

use embedded_hal::digital::OutputPin;

/// Toggles a pin high then low, returning early on hardware error.
fn toggle_pin<P: OutputPin>(mut pin: P) -> Result<(), P::Error> {
    // Drive the pin high. The ? operator propagates hardware faults.
    pin.set_high()?;
    
    // Drive the pin low. Fails fast if the bus is stuck.
    pin.set_low()?;
    
    Ok(())
}

The function does not care what P is. It could be a raw memory-mapped register, a struct from a vendor SDK, or a software-emulated bit-bang implementation. As long as the type satisfies OutputPin, the compiler generates the correct machine code. The mut keyword is required because set_high and set_low modify the hardware state, which the trait expresses as &mut self.

The compiler verifies the contract before the silicon ever runs.

What the compiler actually does

When you compile this, Rust performs monomorphization. It looks at every call site of toggle_pin. If you pass an esp32_hal::gpio::Pin, it generates a version of the function specialized for that type. If you pass an stm32_hal::gpio::Pin, it generates a second version. There is no virtual dispatch table. There is no runtime overhead. The trait acts as a compile-time contract. The compiler verifies that the hardware type provides the required methods and the correct error type. If a method is missing, compilation fails before the code ever runs on the device.

This pattern is the foundation of the entire embedded-hal ecosystem. Sensor drivers, display controllers, and communication stacks are written as generic functions or structs that accept trait bounds. You drop in your hardware implementation, and the driver just works. The abstraction is free at runtime. The compiler inlines the trait calls directly into your binary. You pay for the hardware operation, not for the interface.

Zero-cost abstraction means the interface is free, but the implementation still pays the hardware price.

Reading a real sensor

Move from a single pin to a communication bus. I2C is common for environmental sensors. The embedded_hal::i2c::I2c trait defines read and write methods that take a device address and a byte buffer. Here is how a realistic driver wrapper looks.

use embedded_hal::i2c::{I2c, ErrorType};

/// Reads a 16-bit temperature value from a sensor at the given address.
fn read_temperature<I: I2c>(mut bus: I, address: u8) -> Result<u16, I::Error> {
    let mut buffer = [0u8; 2];
    
    // Send the register address to read from, then read two bytes.
    // The trait guarantees the bus handles start/stop conditions.
    bus.write_read(address, &[0x00], &mut buffer)?;
    
    // Combine the two bytes into a big-endian u16.
    Ok(u16::from_be_bytes(buffer))
}

Notice the I::Error return type. Hardware operations can fail. A bus collision, a missing pull-up resistor, or a timeout will produce an error. The trait forces you to handle it with Result. You cannot accidentally ignore a hardware fault. The ? operator propagates the specific error type from the underlying HAL up to the caller. The ErrorType trait is a companion trait that exposes the associated error type, making it easier to write generic error handling code across different buses.

Convention aside: the embedded-hal community strongly prefers explicit trait bounds over impl Trait in function parameters for hardware drivers. impl I2c works for simple cases, but explicit <I: I2c> makes the generic type visible in error messages and allows you to add additional bounds later, like I: I2c + Send for multi-threaded executor contexts. Stick to the explicit generic syntax when publishing drivers.

Handle the error or the hardware will fail silently in production.

Where things break

The abstraction is powerful, but it introduces specific failure modes. The most common is a trait bound mismatch. If you write a function expecting I2c but pass a type that only implements I2c from an older version of the crate, the compiler rejects it with E0277 (trait bound not satisfied). The embedded-hal ecosystem split into version 0.2 and version 1.0. Version 1.0 stabilized the API and removed blocking assumptions. Mixing versions in Cargo.toml causes silent trait incompatibilities. Always check your dependencies.

Another trap is blocking behavior. The standard embedded-hal traits are synchronous. They block the CPU until the hardware operation finishes. If you are running a real-time operating system or an async executor, blocking the main thread will stall your entire application. The ecosystem provides embedded-hal-async for non-blocking operations. The trait names are identical, but the methods return Future types instead of Result. Using the wrong version in an async context will cause your task to hang indefinitely.

Hardware registers also have strict alignment and volatility requirements. When implementing a trait for a raw peripheral, you will often dereference a raw pointer to a memory-mapped address. The compiler will reject this with E0133 (dereference of raw pointer requires unsafe). You must wrap the access in an unsafe block and document the hardware guarantees.

// SAFETY: The address 0x4002_1000 is the documented base address for the I2C1 peripheral.
// The hardware manual guarantees the register is aligned and volatile.
// No other core or interrupt modifies this address concurrently.
unsafe {
    let reg = 0x4002_1000usize as *const u32;
    *reg
}

Treat the SAFETY comment as a proof. If you cannot cite the datasheet section that guarantees the address and alignment, you do not have a proof. Do not write the block.

Check your crate versions before you blame the silicon.

Picking your hardware strategy

You have choices when writing embedded Rust. Pick the right tool for the layer you are working on.

Use embedded-hal traits when you are writing reusable drivers or application logic that should run on multiple microcontrollers. Use embedded-hal when you want to leverage the existing ecosystem of sensor and display drivers without rewriting them for each chip. Use embedded-hal-async when your application runs on an async executor and cannot afford blocking delays.

Reach for vendor HALs or PACs (Peripheral Access Crates) when you need direct register access for timing-critical interrupts or when the embedded-hal trait does not expose a specific hardware feature. Reach for raw register manipulation when you are implementing the embedded-hal trait itself and need to translate trait calls into silicon commands.

Pick a synchronous blocking approach when you are writing simple bare-metal loops or when your hardware lacks an interrupt controller. Pick an async approach when you are managing multiple concurrent peripherals and want the executor to schedule other tasks during hardware waits.

Match the abstraction level to the problem, not to the trend.

Where to go next