Rust
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Ownership and Borrowing
-
Section 4: Structuring Data
-
Section 5: Collections and Error Handling
-
Section 6: Traits and Generics
-
Section 7: Concurrency
-
Section 8: Building for the Web
-
Section 9: Memory and Performance
-
Section 10: More Standard Library and Ecosystem
-
Section 11: Advanced Rust
-
Section 12: Rust for Systems and WebAssembly
-
Section 13: Tooling and Best Practices
-
Section 14: Data Structures and Algorithms in Rust
-
Section 15: Practical Projects
-
Section 16: Interview Practice
-
Section 17: std::collections In Depth
-
Section 18: std::io and std::fs In Depth
-
Section 19: std::net
-
Section 20: std::option and std::result In Depth
-
Section 21: std::iter In Depth
-
Section 22: std::sync In Depth
-
Section 23: std::string and std::str
-
Section 24: Cargo and Crates.io Ecosystem
-
Section 25: Popular Crates Ecosystem
-
Section 26: Rust Memory Model Deep Dive
-
Section 27: More Practice Exercises
-
Section 28: More Interview Practice
-
Section 29: Async Rust Deep Dive
-
Section 30: Tokio Ecosystem In Depth
-
Section 31: Error Handling Ecosystem Deep Dive
-
Section 32: Serde In Depth
-
Section 33: Web Development with Rust Deep Dive
-
Section 34: Database Access Ecosystem
-
Section 35: Rust for Embedded Systems Deep Dive
-
Section 36: Rust Macros In Depth
-
Section 37: Advanced Trait System
-
Section 38: Unsafe Rust In Depth
-
Section 39: Rust CLI Development
-
Section 40: Testing Ecosystem Deep Dive
-
Section 41: WebAssembly Deep Dive
-
Section 42: Rust Design Patterns
-
Section 43: More Data Structures in Rust
-
Section 44: Final Practice Projects
-
Section 45: Rust Performance Optimization
-
Section 46: Rust Ecosystem Tooling
-
Section 47: More Interview and Review
179: The embedded-hal Traits
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
StatusLedto be generic over a typeP. - Add a trait bound to
Pusingembedded_hal::digital::OutputPin. - Update the
blinkmethod to handle the fact thatset_high()andset_low()inembedded-halreturn aResult(you may use.ok()or.unwrap()for the sake of this exercise). - Ensure the driver no longer references
fictional_chip_hal.
There are no comments for now.