DEV Community

Cover image for Platform Agnostic Drivers in Rust: The MAX7219 Driver
Omar Hiari
Omar Hiari

Posted on • Updated on

Platform Agnostic Drivers in Rust: The MAX7219 Driver

Introduction

In this post, I continue the work I started in an attempt to create a platform-agnostic driver for the MAX7219 LED Driver IC. To reach that goal, as a reminder, here are the steps I had laid out for the series:

  1. Create simple code to configure and test the MAX7219 with a simple application. Link to post.
  2. Refactor and optimize the code in the first step by adding functions. This step would also create a driver that isn't platform agnostic. Link to post.
  3. Refactor code in the second step to incorporate embedded-hal traits and create a platform-agnostic driver.
  4. Register the driver in crates.io and publish its documentation.
  5. Add advanced features to the driver and introduce a new version of the crate.

Step 3 in bold is where we stand now in the series. So this means that right now the goal is to refactor (again) the code in step 2 from the previous post with functions that can be utilized to drive the MAX7219 instead.

To recap quickly from the past post, there were three issues we faced:

  1. The Driver Code was Verbose: We had to include the SPI peripheral and CS pin as parameters in every function call.
  2. The Driver Code Restricted to One Peripheral Instance: We were stuck to a single instance of the SPI peripheral and the CS pin. Meaning, if we were to use a different SPI peripheral, the driver code itself needed to be changed.
  3. Driver Code Restricted to One Platform: Similar to the previous issue, the code was specific only to the stm32f4xx-hal and no other.

With that being said let's get started.

If you find this post useful, and if Embedded Rust interests you, stay in the know and skyrocket your learning curve by subscribing to The Embedded Rustacean newsletter:

Subscribe Now to The Embedded Rustacean

Creating a Library 📚

Before writing the driver there is something slightly different that needs to be done in creating the cargo package. All packages that we wrote code in before were binary packages that had a main.rs file. When creating a driver with the goal of publishing to crates.io we need to create a library package instead of a binary. This is done simply by navigating to your target folder, invoking cargo, and passing the file name with --lib as follows:

$ cargo new max7219 --lib
Enter fullscreen mode Exit fullscreen mode

Now you'll have a file structure in your target folder that encapsulates cargo.toml file and a src folder. Inside the src folder, there should be a lib.rs file where we will be inserting our code. You can refer to the git repo to see what the structure looks like.

If you recall, the way the functions were implemented in the past post was as standalone functions. Meaning that the functions weren't associated with a particular entity in the same code. However, recall from Rust that we could implement functions that are associated with a particular struct. Once we do, we can create instances (copies) of the same struct and each instance will have access to all the associated functions. This is somehow analogous to creating classes in OOP.

Let's take a step back and digest what was mentioned a little. Because from what we also know in Rust, we can use generics to remove type limitations. However, we also know from embedded Rust that peripherals exist as types, this means that it's possible to create a struct that removes the limitations experienced in the last post by using generics. Though if we use a generic, an issue remains. How would we restrict the generic type to the peripheral we want? Meaning, how do we make sure that when the struct is instantiated, the correct peripheral is instantiated as a member? This is where the embedded-hal traits come in. We can restrict the generic type to implement only the peripheral we want. As such, if the user tries to instantiate with a different peripheral, they'd get a compile error. Let's walk through some code to make this clearer.

Where it All Starts 🎬

Let's start out by creating the following struct in our newly created lib.rs file:

pub struct MAX7219<SPI, CS> {
    spi: SPI,
    cs: CS,
}
Enter fullscreen mode Exit fullscreen mode

As simple as this struct looks, believe it or not, this is the struct that the whole driver will revolve around. Note here what was done. A struct called MAX7219 has been created with SPI and CS as generic placeholders. Be aware that SPI and CS at this point are still nothing but names, I could have called them Bob and Amy for that matter and it wouldn't make a difference. There is nothing yet in the code that says that SPI and CS should be something in particular. They are named this way for convenience. So if that's the case, how do we make sure that SPI and CS are actually SPI and Pin peripherals? This is where the MAX7219 struct impl implementation block comes in.

Where it all Goes 🥅

The implementation block starts out like this:

impl<SPI, CS> MAX7219<SPI, CS>
where
    SPI: Write<u8>,
    CS: OutputPin,
{
// ... Struct-associated functions go here
}
Enter fullscreen mode Exit fullscreen mode

The impl block, as defined in Rust, is where we include all struct-associated functions. Note here that we brought up the SPI and CS generics again. However, we added the where keyword in which we associated each generic type with a trait. The line SPI: Write<u8> for example restricts the SPI generic type to the Write embedded-hal trait. Additionally, the line CS: OutputPin restricts the CS generic type to the OutputPin embedded-hal trait.

So where would one find these trait signatures anyway? there are actually two places, either the embedded-hal documentation or the documentation of whichever device hal you are using. Though you need to be careful to scroll down to the "trait implementations" section for the particular peripheral to see the implemented embedded-hal traits.

🚨 Important Note

One thing to be aware of is that for this to work, the hal for the device you are using should be compatible with the emebedded-hal to start with. Otherwise, the implementation for the behavior of the trait you are trying to restrict your driver to might not even exist.

The Grand Finale 🏁

Essentially all that is left right now is to copy over code we created last time. Be mindful that there are four things to take care of here:

1️⃣ Copy over all the enums:

These are the enums that we used for configuration. Note that these need to be added outside the implementation block!

2️⃣ Add a new function:

In order to use the driver, for every max7219 device we connect to a SPI interface, an instance of the MAX7219 struct needs to be created. Before that, we cant do anything with the driver or access the driver functions. Right now, we do not have a function that does that. This looks as follows:

    pub fn new(spi: SPI, cs: CS) -> Result<Self, DriverError> {
        let max7219 = MAX7219 { spi: spi, cs: cs };
        Ok(max7219)
    }
Enter fullscreen mode Exit fullscreen mode

what is happening here, is that when the new instance function is called, an instance of the MAX7219 is created with spi, and cs passed as types for its two members. Also spi and cs are already insured to be the correct types based on the implementation constraints applied earlier. This is similar to the idea of a class constructor in object-oriented languages.

3️⃣ Copy over and modify the existing functions:

Remember all the driver functions that were created in the last post? All you have to do is copy them over to the implementation block with some minor modifications. To see what the modifications look like, let's do a comparison of the transmit_raw_data before and after modification. The old implementation of transmit_raw_data looked like this:

fn transmit_raw_data(
    arr: &[u8],
    per: &mut Spi<
        SPI1,
        (
            Pin<'A', 5_u8, Alternate<5_u8>>,
            NoPin,
            Pin<'A', 7_u8, Alternate<5_u8>>,
        ),
        TransferModeNormal,
    >,
    cs: &mut Pin<'A', 6_u8, Output>,
) -> Result<(), stm32f4xx_hal::spi::Error> {
    cs.set_low();
    let transfer = per.write(&arr);
    cs.set_high();
    transfer
}
Enter fullscreen mode Exit fullscreen mode

The new implementation now looks like this:

    pub fn transmit_raw_data(&mut self, arr: &[u8]) -> Result<(), DriverError> {
        self.cs.set_low().map_err(|_| DriverError::PinError)?;
        let transfer = self.spi.write(&arr).map_err(|_| DriverError::SpiError);
        self.cs.set_high().map_err(|_| DriverError::PinError)?;
        transfer
    }
Enter fullscreen mode Exit fullscreen mode

If you notices, we got rid of the part we were complaining about that was limiting us! Which was the following portion of code:

    per: &mut Spi<
        SPI1,
        (
            Pin<'A', 5_u8, Alternate<5_u8>>,
            NoPin,
            Pin<'A', 7_u8, Alternate<5_u8>>,
        ),
        TransferModeNormal,
    >,
    cs: &mut Pin<'A', 6_u8, Output>,
Enter fullscreen mode Exit fullscreen mode

this all got reduced to &mut self! Why because now self refers to the MAX7219 driver struct instantiated that already has spi and cs as part of it! Two other differences to note is, first, the addition of the self keyword to access spi and cs. Second, is the use of map_err(|_| DriverError::PinError). What we're doing here is that now by using the embedded-hal traits, both Write<u8> and OutputPin return a Result. As such, the error of the Result needs to be propagated in case one happens. What I've done is add a DriverError enum in the code to differentiate between SPI errors and Pin errors. The driver error enum looks as such:

pub enum DriverError {
    /// An error occurred when working with SPI
    SpiError,
    /// An error occurred when working with a PIN
    PinError,
}
Enter fullscreen mode Exit fullscreen mode

One can choose to ignore propagating/handling the errors altogether, however that would be considered bad practice.

4️⃣ Make the structs, enums, and functions public:

Everything that needs to be utilized outside of the scope of the lib file needs to be made public. This is by adding the pub keyword ahead of all the structs, enums, and functions that need to be public. You can note the usage of pub in the earlier step.

This is it, we're done! There's much less to it than initially one would expect!

Some Additional Notes/Tips 💵

1️⃣ If you'd like to see how the driver is used. I refactored the application code from the last post to demonstrate the usage of the driver. You can check it out here. Of course, now the application would have to import the new driver crate. If the driver crate is still on your device (not published to crates.io) then in the toml file you need to do something like this:

[dependencies]
max7219 = {path = "/Path to Driver"}
Enter fullscreen mode Exit fullscreen mode

However, if the driver is up on crates.io all you need is the crate name and its version. Just like when importing any other crate. This is a step that we'll be doing in the next post. The next post will focus on showing how to document and publish the driver crate to crates.io.

2️⃣ There is one final thing I'd like to mention, that may make things easier. I found this embedded driver template by user ryankurte on github as all drivers follow the same footprint. One can start with the template, remove the parts not needed and then copy in all their function code and enums.

Conclusion

In this post, a Rust platform agnostic driver for the MAX7219 device was created. This was done by refactoring the code (again) from the previous post. In summary, although platform-agnostic drivers in Rust might sound like a scary thing to approach. In reality, it turns out that, once one feels comfortable with certain Rust concepts, there isn't much to it. I would say that the key is in understanding how generics and traits work and how everything ties to the embedded-hal. Also looking further into other embedded Rust code, understanding drivers could serve also as a good precursor to creating platform HALs. In the following post, the code will be documented properly and published to crates.io.

Have any questions or thoughts? Please post them in the comments below 👇.

If you found this post useful, and if Embedded Rust interests you, stay in the know and skyrocket your learning curve by subscribing to The Embedded Rustacean newsletter:

Subscribe Now to The Embedded Rustacean

Top comments (0)