Skip to Content
Course content

179: The embedded-hal Traits

Click on the "Edit" button in the top corner of the screen to edit your slide content.

If you've spent any time in the embedded world, you've likely felt the pain of "vendor lock-in." I remember working on a sensor project a few years ago where we wrote a great driver for a specific temperature sensor. The problem was that we wrote it specifically for the STM32 series. Six months later, a supply chain hiccup forced us to move to an RP2040. We spent a week rewriting the same driver because, while the sensor hadn't changed, the way we talked to the I2C bus had.

The Trap of Hardware Coupling

The naive way to write a driver is to depend directly on the Peripheral Access Crate (PAC) or the specific HAL provided by the chip manufacturer. In your code, it usually looks something like this:

struct TemperatureSensor {
    i2c: stm32f4xx_hal::i2c::I2c, 
}

impl TemperatureSensor {
    fn read_temp(&mut self) -> f32 {
        // Use STM32-specific methods to write/read from the bus
    }
}

At first glance, this is the easiest path. You have autocomplete, you know exactly which chip you're using, and it compiles. But you've just tied your driver's fate to a specific piece of silicon. If you want to share this driver as a crate for others to use, they can only use it if they also happen to be using that exact STM32 family. You've created a "siloed" driver.

Decoupling with Generic Traits

This is where embedded-hal comes in. Instead of depending on a concrete implementation of a peripheral, we depend on a trait. The embedded-hal crate defines what it means to be an I2C bus or a digital output pin, regardless of whether that pin is on an AVR, an ARM, or a RISC-V chip.

I prefer to think of this as creating a contract. The driver says, "I don't care who you are, as long as you can write and read bytes," and the chip-specific HAL says, "I know how to do that on this specific hardware." Here is how we evolve that driver to be portable:

use embedded_hal::i2c::I2c;

struct TemperatureSensor<I2C> {
    i2c: I2C,
}

impl<I2C> TemperatureSensor<I2C> 
where 
    I2C: I2c, 
{
    fn read_temp(&mut self) -> Result<f32, I2C::Error> {
        let mut buffer = [0u8; 2];
        self.i2c.write_read(0x48, &[0x00], &mut buffer)?;
        // Convert bytes to temperature...
        Ok(25.0)
    }
}

Now, the TemperatureSensor is completely agnostic. It doesn't know about STM32, ESP32, or anything else. It just knows it has an object that implements the I2c trait. When you actually instantiate the sensor in your main.rs, you pass in the specific I2C instance provided by your chip's HAL, and Rust's monomorphization handles the rest.

The Cost of Flexibility

I'll be honest with you: this approach adds some mental overhead. Your type signatures become more complex. You'll see generics everywhere, and you'll have to deal with associated types like I2C::Error because every chip has a different way of reporting an I2C failure.

You might wonder if this is overkill for a small project. If you are 100% certain you will never change your MCU, you could get away with the naive approach. But in my experience, "never" is a dangerous word in hardware. Even if you don't change the chip, writing your drivers against embedded-hal traits makes them exponentially easier to unit test using "mock" peripherals on your development PC, rather than flashing a board every time you want to check a logic branch.




📋 Practical Task

Refactor a Rigid LED Driver to use embedded-hal

You have been given a driver for a "Status LED" that is currently hard-coded to a specific (fictional) chip HAL. Your task is to refactor it so that it can work on any microcontroller that implements the embedded-hal digital output traits.

Current Rigid Code:

struct StatusLed {
    pin: fictional_chip_hal::gpio::Pin12,
}

impl StatusLed {
    fn blink(&mut self) {
        self.pin.set_high();
        // delay...
        self.pin.set_low();
    }
}

Your Requirements:

  • Change StatusLed to be generic over a type P.
  • Add a trait bound to P using embedded_hal::digital::OutputPin.
  • Update the blink method to handle the fact that set_high() and set_low() in embedded-hal return a Result (you may use .ok() or .unwrap() for the sake of this exercise).
  • Ensure the driver no longer references fictional_chip_hal.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.