4 Simple Steps for Creating a Platform Agnostic Driver in Rust

4 Simple Steps for Creating a Platform Agnostic Driver in Rust

ยท

4 min read

For those following my blog posts, I have recently written several separate posts working through creating platform-agnostic drivers in Rust. Those posts worked through the details for particular device implementations, documentation, and crates.io publication. Although it took several posts before, it was more to show how powerful the idea is and how it's enabled by Rust. However, I wouldn't want to establish the notion that creating platform-agnostic drivers in Rust is complicated. This post is sort of the TL;DR version as I figured it would be only appropriate if one is interested in getting the gist of it.

Essentially all that needs to be done can be broken down into the following 4 steps:

1๏ธโƒฃ Create a Library Binary Package that Imports the Embedded HAL ๐Ÿ“š

Instead of creating a regular binary package with a main.rs here we need a library package that will be uploaded to crates.io. The library package can be created with cargo in the command line as follows:

$ cargo new [driver name] --lib

Next, the embedded-hal needs to be imported in the lib.rs along with any modules required for the driver. Here's an example of imports we would need if we are to use GPIO Output and SPI:

use embedded_hal as hal;

use hal::blocking::spi::Write;
use hal::digital::v2::OutputPin;
use hal::spi::{Mode, Phase, Polarity};

To identify the modules one would refer to the embedded-hal documentation.

2๏ธโƒฃ Create the Driver Struct ๐Ÿ—

This is the main part of the driver. We'd have to define the struct that through it, all the function implementations will be provided. This looks something like this:

pub struct DriverName<T, U> {
    peripheral1: T,
    peripheral2: U,
    // Any other peripherals
}

Both T and U here are generics that will be bound to particular traits in the implementation of the struct.

3๏ธโƒฃ Implement your Driver ๐Ÿš˜

Create an implementation block that defines the generic types and includes at a minimum a function to allow for instantiating the driver. The driver implmentation block would look like this:

impl<T, U> DriverName<T, U>
where
    T: Write<u8>,
    U: OutputPin,
{
// Implmemented Fucntions
}

Here the generic type T is being bound to implement the Write<u8> trait in hal::blocking::spi::Write from the embedded-hal crate. Additionally, U bound to the OutputPin trait in hal::digital::v2::OutputPin from the embedded-hal crate. Keep in mind here that depending on what you want to do the trait bound would be different. One can find the appropriate bound from the embedded-hal documentation. For example, here the Write<u8> trait supports only writing in a single direction using SPI. However, if one would need to both write and read (full-duplex transaction) instead would use the Transfer trait. Or, alternatively, as another example, if one would want to implement an SPI slave, then the InputPin trait would be more appropriate instead.

Now inside the impl block, an instantiating function needs to be created and can be called new. Here's how it would look like:

pub fn new(peripheral1: T, peripheral2: U) -> Result<Self, DriverError> {
     let driver = DriverName { peripheral1: T, peripheral2: U };
     Ok(driver)
}

In new a Result is being returned to confirm that the driver has been instantiated successfully. Optionally the error can be ignored but that would be poor design. Also, here DriverError is an enum that is defined in the same library file and enumerates the errors that can occur. The DriverError enum looks as follows:

pub enum DriverError {
    SpiError,
    PinError,
}

Here also two error types are defined. SpiError indicates that an error in the SPI peripheral occurred, and PinError indicates that an error in the GPIO peripheral occurred. These could have all been bundled in one type of error but it's up to the designer. At least this way some differentiation is helpful for debugging the source of the error.

4๏ธโƒฃ Implement the Driver Functions ๐Ÿงฐ

This is the final step, in the impl block, in addition to the new function, we would add any other driver-associated function. These would be functions that one would have planned ahead of time and define what things the driver can do. Overall, the impl block would resemble something like this:

impl<T, U> DriverName<T, U>
where
    T: Write<u8>,
    U: OutputPin,
{

 pub fn new(peripheral1: T, peripheral2: U) -> Result<Self, DriverError> {
        let driver = DriverName { peripheral1: T, peripheral2: U };
        Ok(driver)
 }

pub fn driverFunc1(&self, optionalParam: [paramType]) -> () {
        // Function 1 Implementation
 }

pub fn driverFunc2(&self, optionalParam: [paramType]) -> Result<Self, DriverError> {
        // Function 2 Implementation
 }

// Remaining function implementations

}

If one would has the need to use enums in their driver, one would define any enums outside of the impl block though still inside the impl block.

Conclusion

The implementation of a platform-agnostic driver in Rust might sound intimidating at first. However, going through the various steps it turns out that it's less challenging than one might think. Probably the most challenging part is dealing with the aspect of using generics and traits. Nevertheless, in this post, I attempt to simplify the process of creating platform gnostic drivers in Rust by breaking it down into 4 steps. This should be all that one needs to create a simple driver.

Have any questions or thoughts? Please post them in the comments below ๐Ÿ‘‡.

Did you find this article valuable?

Support Omar Hiari by becoming a sponsor. Any amount is appreciated!

ย