Embedded-HAL: Cross-Platform Ecosystem

Embedded-HAL (Hardware Abstraction Layer) is the foundation of Rust's embedded ecosystem. It defines a series of standard traits for abstracting common embedded peripheral operation interfaces. Its core goals are:

  • Provide hardware-agnostic peripheral abstraction interfaces
  • Allow writing generic drivers that can run on different chips and platforms
  • Establish a unified embedded and system software development ecosystem to promote code reuse

Differences from Traditional C/C++ Embedded Libraries

In traditional embedded development, HAL libraries from different vendors and platforms are usually incompatible:

// STM32 HAL library example
HAL_StatusTypeDef HAL_GPIO_WritePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState);

//MSP432 library example
void GPIO_setOutputHighOnPin(uint_fast8_t selectedPort,uint_fast16_t selectedPins);

These differences cause:

  • Drivers must be rewritten for each platform
  • Code cannot be easily migrated to different hardware
  • Ecosystem fragmentation and duplicate work

Embedded-HAL solves this problem by defining unified trait interfaces, much like Arduino libraries such as digitalWrite etc., but Rust's trait system is more powerful and can implement more complex abstractions.

#![allow(unused)]
fn main() {
// Unified interface defined by Embedded-HAL
pub trait OutputPin {
    type Error;
    fn set_high(&mut self) -> Result<(), Self::Error>;
    fn set_low(&mut self) -> Result<(), Self::Error>;
}

// Different platforms implement the same interface
// Some platform implementation
impl OutputPin for PA0 {
    type Error = Infallible;
    fn set_high(&mut self) -> Result<(), Self::Error> { ... }
    fn set_low(&mut self) -> Result<(), Self::Error> { ... }
}

// Another platform implementation
impl OutputPin for GpioPin<Output, IO0> {
    type Error = Infallible;
    fn set_high(&mut self) -> Result<(), Self::Error> { ... }
    fn set_low(&mut self) -> Result<(), Self::Error> { ... }
}
}

As you can see, different platforms implement the same interface, but the interface implementation methods are different, yet the interface usage is the same.

What is a Trait

Trait (Rust language official documentation) is Rust's mechanism for defining shared behavior, similar to interfaces or abstract classes in other languages, but with more powerful features:

  1. Static Dispatch: The compiler determines the specific implementation at compile time, avoiding runtime overhead of dynamic lookup
  2. Associated Types: Allows defining dependent types within traits, enhancing type safety
  3. Generic Constraints: Traits can be used as generic constraints, providing compile-time guarantees
  4. Composition Capability: Traits can be combined to form more complex abstractions

If you have limited Rust experience, this concept might seem abstract. Here we just need to know that Traits are a mechanism for defining shared behavior.

C language can also implement similar interfaces, for example:

// C language simulating interfaces
typedef struct {
    void (*set_high)(void* ctx);
    void (*set_low)(void* ctx);
    void* ctx;
} gpio_ops_t;

// Usage example
void toggle_led(gpio_ops_t* gpio) {
    gpio->set_high(gpio->ctx);
    // Delay
    gpio->set_low(gpio->ctx);
}

This approach has the following drawbacks:

  • Runtime overhead (function pointer calls)
  • No compile-time type checking
  • Interface implementation errors can only be discovered at runtime
  • Need to manually manage context pointers

Rust's Traits combined with the type system mentioned above provide compile-time safety guarantees while avoiding runtime overhead of dynamic lookup.

Embedded-HAL Ecosystem

The Embedded-HAL ecosystem consists of multiple interconnected crates:

  1. embedded-hal: Defines basic blocking interfaces
  2. embedded-hal-async: Async versions of the interfaces
  3. embedded-hal-nb: Interfaces based on nb (non-blocking) mode
  4. embedded-can: CAN bus specific interfaces
  5. embedded-io/embedded-io-async: Generic I/O interfaces

For specific implementations, refer to the Embedded-HAL official repository

Drivers written using embedded-hal can run on any hardware that implements the corresponding traits: For example, the SSD1306 driver we'll use in the upcoming example:

#![allow(unused)]
fn main() {
// Generic SSD1306 OLED display driver
pub struct Ssd1306<I2C> {
    i2c: I2C,
    // ...other fields
}

impl<I2C, E> Ssd1306<I2C>
where
    I2C: embedded_hal::i2c::I2c<Error = E>,
{
    // Any device that implements the I2c trait can use this driver
    pub fn new(i2c: I2C) -> Self {
        // ...initialization code
    }
    
    pub fn display(&mut self, buffer: &[u8]) -> Result<(), E> {
        // ...use I2C to display data
    }
}
}

With I2C as a generic parameter, this driver can be used simultaneously with:

  • STM32 series
  • ESP32
  • nRF52 series
  • RP2040 (Raspberry Pi Pico)
  • And many other hardware that implement the I2c trait

In actual projects, we can use the same driver on different hardware, which is very helpful for product development iteration.

Elegant Error Handling

In embedded-hal code, you can see common error handling methods in Rust, such as:

#![allow(unused)]
fn main() {
// Using Result type to return errors
pub fn write(&mut self, address: u8, data: &[u8]) -> Result<(), Error> {
    // ...
}
}

Rust's error handling has significant advantages over C language. Let's compare through a pseudo-code example - suppose we need to read data from multiple sensors, process them, and then send the results:

// C language error handling - using return codes
typedef enum {
    ERR_OK = 0,
    ERR_I2C_BUS,
    ERR_SENSOR_TIMEOUT,
    ERR_INVALID_DATA,
    ERR_UART_BUSY,
    // ...more error types
} error_t;

// Read sensor
error_t read_temperature_sensor(float* result) {
    // Initialize I2C bus
    if (i2c_init() != ERR_OK) {
        return ERR_I2C_BUS;
    }
    // Read sensor
    uint8_t raw_data[4];
    error_t err = i2c_read(SENSOR_ADDR, raw_data, sizeof(raw_data));
    if (err != ERR_OK) {
        return err; // Propagate error
    }
    // Check data validity
    if (raw_data[3] != calculate_checksum(raw_data, 3)) {
        return ERR_INVALID_DATA;
    }
    // Convert data
    *result = convert_to_temperature(raw_data);
    return ERR_OK;
}

// Usage example
void process_sensor_data(void) {
    float temperature;
    error_t err = read_temperature_sensor(&temperature);
    // Error handling - common if-else branches
    if (err == ERR_OK) {
        printf("Temperature: %.2f C\n", temperature);
    } else if (err == ERR_I2C_BUS) {
        printf("I2C bus error\n");
        // May forget to handle certain errors
    } else if (err == ERR_SENSOR_TIMEOUT) {
        printf("Sensor timeout\n");
    } else if (err == ERR_INVALID_DATA) {
        printf("Invalid data received\n");
    } else {
        printf("Unknown error: %d\n", err);
    }
    // Easy to forget checking errors
    error_t another_err = send_data(&temperature);
    // Missing check for another_err here
}

Now let's see the equivalent Rust implementation:

// Rust error handling - using enums and Result types
#[derive(Debug)]
enum SensorError {
    I2cBus(I2cError),      // Contains underlying error
    SensorTimeout,
    InvalidData,
    UartBusy(UartError),   // Contains underlying error
}

// Automatic conversion from I2C error, avoiding manual error mapping
impl From<I2cError> for SensorError {
    fn from(err: I2cError) -> Self {
        SensorError::I2cBus(err)
    }
}

// Read sensor
fn read_temperature_sensor() -> Result<f32, SensorError> {
    // Initialize I2C bus - ? operator automatically propagates errors
    let i2c = i2c_init()?;
    // Read sensor - ? operator automatically handles and converts errors
    let raw_data = i2c.read(SENSOR_ADDR, 4)?;
    // Check data validity
    if raw_data[3] != calculate_checksum(&raw_data[0..3]) {
        return Err(SensorError::InvalidData);
    }
    // Convert data
    Ok(convert_to_temperature(&raw_data))
}

// Usage example
fn process_sensor_data() -> Result<(), SensorError> {
    // Read sensor - ? automatically propagates errors
    let temperature = read_temperature_sensor()?;
    // Can immediately use result because error has been handled
    println!("Temperature: {:.2} C", temperature);
    // Send data - errors will be automatically converted
    send_data(&temperature)?;
    // Return success when everything is OK
    Ok(())
}

// Use pattern matching in main function to handle errors
fn main() {
    match process_sensor_data() {
        Ok(()) => println!("Processing completed"),
        // Comprehensive error matching, compiler ensures all cases are handled
        Err(err) => match err {
            SensorError::I2cBus(bus_err) => {
                println!("I2C bus error: {:?}", bus_err);
                // Can further handle specific I2C errors
            },
            SensorError::SensorTimeout => println!("Sensor timeout"),
            SensorError::InvalidData => println!("Invalid data received"),
            SensorError::UartBusy(uart_err) => {
                println!("UART busy: {:?}", uart_err);
            },
        },
    }
}

For the above code, we can see that Rust's implementation has the following advantages:

  1. ? operator simplifies error propagation: Compared to C language where you need to check return values after every call, Rust's ? operator makes error propagation concise.

  2. Typed and composable errors: Rust's errors are typed and can contain additional information, not just integer codes. This makes error handling more precise and expressive.

  3. Compiler-enforced error handling: If you don't handle Result, the compiler will issue a warning, preventing missed error checks.

  4. Pattern matching ensures completeness: When using match to handle errors, the compiler ensures all possible error types are handled.

  5. No performance loss: Rust's error handling model is resolved at compile time, with almost no runtime overhead, equivalent to C code that manually checks error codes.

  6. Automated error conversion: Through the From trait implementation, automatic conversion between different error types can be achieved, reducing manual mapping code.

In embedded systems, reliable error handling is particularly important because errors often mean hardware interaction problems, which can lead to system instability. Rust's error handling mechanism maintains low-level control capability while providing higher-level abstractions, making code safer and more maintainable without introducing additional runtime overhead.