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:
- Create simple code to configure and test the MAX7219 with a simple application. Link to post.
- 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.
- Refactor code in the second step to incorporate embedded-hal traits and create a platform-agnostic driver.
- Register the driver in crates.io and publish its documentation.
- 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:
- The Driver Code was Verbose: We had to include the SPI peripheral and CS pin as parameters in every function call.
- 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.
- 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
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,
}
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
}
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)
}
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
}
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
}
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>,
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,
}
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"}
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 👇.
Top comments (0)