Platform Agnostic Drivers in Rust: MAX7219 Naive Code Refactoring

Search for a command to run...

Hi Omar, great write-up! Just a hint for your enum to integer value conversion you can do this with simply type-casting using the repr attribute. This would shorten your code where you pass an enum variant to the MAX7219, because there is no need to match against all the enum variants. Illustrative example:
// C style represenation of enum in memory, here u8
#[repr(u8)]
enum DigitRowAddress {
Digit0 = 0x01,
Digit1 = 0x02,
Digit2 = 0x03,
Digit3 = 0x04,
Digit4 = 0x05,
Digit5 = 0x06,
Digit6 = 0x07,
Digit7 = 0x08,
}
fn draw_row_or_digit(
per: MaxSpi,
cs: &mut Pin<'A', 6_u8, Output>,
digit_addr: DigitRowAddress,
led_data: u8,
) -> Result<(), AddressError> {
let send_array: [u8; 2] = [digit_addr as u8, led_data];
transmit_raw_data(&send_array, per, cs).unwrap();
Ok(())
}
Another thing I'd improve, is in your matches against enumerations that are exhaustive, e.g. DigitRowAddress, most of the time it's better to not match against _ for any variant, as first of all it cannot happen because you covered all cases already (and the chip wont add new pins during its lifetime) - and second: if somebody added Digit8 to the DigitRowAddress enum, your code keeps happily compiling even though your match block is now broken. At runtime it will error out, but it could already error at compile time which is preferred, especially on embedded systems without visual feedback. The type system of Rust allows many of those compile time sanity verifications and makes embedded programming a joy. Aiming to only allow valid states at compile time basically made me stop using debuggers :)
Thanks, Michael! It did actually feel like it wasn't the best way to do it but wasn't sure of the best alternative. I did see the #[repr (u8)] attribute used in various other drivers' source code but it wasn't clear to me how it was used until your explanation, so thank you! 😀 Regarding the _ in the match, as I was updating my code for the next post, I noticed that the compiler was trying to tell me about it in one of the warnings, but I apparently ignored it 😂
Omar Hiari You might want to look into num_enum:: TryFromPrimitive derive macro too, this takes away the manual implementation of the TryFrom trait for going from integer to enum. I am looking forward to the published MAX7219 crate :-)
Michael Kefeder Thanks again! I looked into it and from what it seems num_enum:: TryFromPrimitive requires std. Is there an alternative for no_std?
Omar Hiari you're welcome! No alternative is needed, simply change your Cargo.toml to not use the default features for this crate, it is no_std compatible, see their documentation. Two options:
cargo-edit and run cargo add num_enum --no-default-features num_enum = { version = "0.5.7", default-features = false }A community giveaway program by The Embedded Rustacean × Seeed Studio to grow the embedded Rust ecosystem

Introduction Another year comes to an end; as always, it is an opportunity to reflect on the achievements of the past year and look forward to the next. It's been over 3.5 years since I embarked on my embedded Rust journey, and it only gets more exci...

Introduction Another year comes to an end, as such, this is an opportunity to reflect on the achievements of 2024 and look forward to 2025. It's been over 2.5 years since I embarked on my embedded Rust journey, and it remains as exciting as ever. If ...

Introduction 🚀 On Friday, May 17th, 2024, the release of Simplified Embedded Rust was announced marking a new kind of book in the embedded Rust learning space. I'm happy to say that this book, design

This post is the sixth of a multi-part series where I'm exploring the use of Bluetooth Low Energy along embedded Rust on the ESP32. Information in this post might rely on knowledge presented in past p

In this post, I continue the work I started in the previous post where I created a simple SPI application driving an LED dot matrix through the MAX7219 LED Driver IC. The goal ultimately was to create a platform agnostic driver for the MAX7219. To reach that goal, as a reminder, here are the steps I had laid out for the series:
Step 2 in bold is where we stand now in the series. So this means that right now the goal is to replace repetitive code with functions that can be utilized to drive the MAX7219 instead. Note that here in what I am going to be doing here, I'm taking sort of the typical approach one would maybe in other languages like C. Though due to certain patterns in embedded Rust, like the singleton pattern, it will show that the code is still not going to be optimal. As a matter of fact, the code here will also be less flexible (not platform agnostic) as we'll see. So why am I doing this? hopefully to make a point and better explain the proper way of how doing things in Rust looks like. I figure that rather than presenting a solution right away, showing the problem would help explain why the solution is the way it is.
All the code presented in this post in addition to instructions for the environment and toolchain setup are available on the The Embedded Rustacean STM32F4 git repo. Note that if the code on the git repo is slightly different then it means that it was modified to enhance the code quality or accommodate any HAL/Rust updates.
In creating my initial swipe at the driver code I want to list out the basic functions that the driver is going to provide. The functions would be ones that would enable one to initialize and control all possible device functionality and be able to light up any LED. This is all information provided by the datasheet. Any additional special features/functions like drawing specific characters or supporting larger displays I reserve for step 5 in the series. Those would be functions that require more algorithmic involvement. As such, here's the list of functions that will be created for now:
1️⃣ A transmit_raw_data function that will serve to transmit data to the device whenever needed. It's called raw since the function will not have any knowledge of the kind of data that is being sent.
2️⃣ A config_power_mode function to configure the MAX7219 power mode.
3️⃣ A config_decode_mode function to configure the MAX7219 decode mode.
4️⃣ A config_intensity function to configure the LED intensity.
5️⃣ A config_scan_limit function to configure the MAX7219 scan limit.
6️⃣ A display_test function to test the display connected to the MAX7219.
7️⃣ A draw_row_or_digit function that activates LEDs to draw a row if the MAX7219 is driving an 8x8 dot matrix, or a digit if the MAX7219 is driving seven segment displays. Although I am connecting an 8x8 dot matrix, I named the function in a generic way since the driver is not restricted only to dot matrix operation.
8️⃣ A init_display function to initialize the display connected to the MAX7219.
9️⃣ A clear_display function to clear the display connected to the MAX7219.
In configuring the MAX7219 device, or any other device for that matter, typically several configuration options would exist. These configuration options present themselves in special values or codes that would be hard to memorize or remember without constantly referring to the datasheet. Consequently, it would be often convenient to encode the special values with names instead. This can be done using enums. Enumerations are such a powerful construct that when you get used to them, they're hard to let go of!
For the sake of brevity, I'll cite one example and then list the rest of the enums I created. The reader can refer to the code for the implementation details on the The Embedded Rustacean STM32F4 git repo. For example, table 4 in the datasheet lists the different decode modes available in the MAX7219.

This table corresponds to the DecodeMode enum in the code that looks as follows:
enum DecodeMode {
NoDecode = 0x00,
CodeB0 = 0x01,
CodeB30 = 0x0F,
CodeB70 = 0xFF,
}
Other enums I created in the code include Shutdown, SevenSegCharacter, Intensity, ScanLimit, and DisplayTest each corresponding to a particular configuration table in the MAX7219 datasheet.
In driving the MAX7219 functionality, as explained in the device block diagram, a 16-bit value is sent. The 16-bit value is divided into two parts, data, and addresses, or commands, if more appropriate to call them that way. The full list of addresses is shown in Table 2 in the MAX7219 datasheet.

I decided to not encode the full address map in one enum for two reasons. One is for code readability and usability. Another is that there are certain implementations that I will do to part of the addresses that I won't do to the others (more detail will follow). As such, I created two enums, one is a Command enum that encodes only the "command" portion for configuring the device and looks as follows:
enum Command {
NoOp = 0x00,
DecodeMode = 0x09,
Intensity = 0x0A,
ScanLimit = 0x0B,
Shutdown = 0x0C,
DisplayTest = 0x0F,
}
The other enum is the DigitRowAddress enum that encodes the addresses alternating the digit/row data and looks as follows:
enum DigitRowAddress {
Digit0 = 0x01,
Digit1 = 0x02,
Digit2 = 0x03,
Digit3 = 0x04,
Digit4 = 0x05,
Digit5 = 0x06,
Digit6 = 0x07,
Digit7 = 0x08,
}
Now a challenge here is that from the code we are refactoring, we'll need to somehow extract from theDigitRowAddress enum an option that corresponds to a value to pass it to the draw_row_or_digit function. Iterating over the enum could be an approach though, by default, there isn't a way to iterate over enums. Another way around it is to implement the TryFrom trait for the DigitRowAddress enum as follows:
impl TryFrom<u8> for DigitRowAddress {
type Error = u8;
fn try_from(value: u8) -> Result<Self, Self::Error> {
use DigitRowAddress::*;
Ok(match value {
0x01 => Digit0,
0x02 => Digit1,
0x03 => Digit2,
0x04 => Digit3,
0x05 => Digit4,
0x06 => Digit5,
0x07 => Digit6,
0x08 => Digit7,
invalid => return Err(invalid),
})
}
}
Here what we're doing is that a u8 value is passed in for a possible match with one of the DigitRowAddress enum options. If a match is found then the value of that option is returned wrapped in a Result. If no match is found an error is returned.
transmit_raw_data functionI started with transmit_raw_data because its a function that all other subsequent functions rely on. transmit_raw_data is a function that performs a task that has been done repetitively in the past code after preparing the data. Its the core function performing data transmission, as the name obviously suggests. It was represented by the following lines:
cs.set_low();
spi.write(&send_array).unwrap();
cs.set_high();
As such, transmit_raw_data encapsulates these lines as follows:
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
}
We can see here that the function takes a slice of u8 in addition to two other parameters. The other two parameters are a mutable instance of SPI1 and a mutable instance of pin PA6. But why? it's because instances of peripherals are created, only one instance exists (singleton pattern) and these instances are not naturally globally accessible. This means as we would need to access a peripheral instance, it would need to be passed around (borrowed and returned) from function to function. This is obviously an issue, as it makes the code more verbose adding parameters equal to the number of peripherals needed for all functions that need to access the peripheral. Another issue that might be obvious, is that the parameters are specific to a certain instance, meaning specific to SPI1 and PA6. This restricts the driver code usage, what if one would want to use SPI2 instead or even a different pin for cs. Here they can't! Making the code very restrictive. To do that the driver source would need to be altered which is not realistic.
So, is there a solution? absolutely! with some interesting refactoring use of the embedded-hal which will be covered in the next post.
Finally note that I am returning transfer in the end of the function, this is to allow the error from the SPI instance to propagate.
config functionsThere are several functions that we create for device configuration. Essentially, one that corresponds to each configuration enum. As a result, configuration functions have a similar type of signature. I will cover config_scan_limit as an example here and list the others that have a similar type of signature/approach. config_scan_limit will need the instances of the SPI peripheral and CS pin as well. This is because config_scan_limit will call transmit_raw_data to transmit the configuration info. Additionally, as a third parameter, config_scan_limit will need the configuration mode which is in the ScanLimit enum. This results in the following code:
fn config_scan_limit(
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>,
mode: ScanLimit,
) -> () {
// match mode to option in ScanLimit
let data: u8 = match mode {
ScanLimit::Display0Only => 0x00,
ScanLimit::Display0And1 => 0x01,
ScanLimit::Display0To2 => 0x02,
ScanLimit::Display0To3 => 0x03,
ScanLimit::Display0To4 => 0x04,
ScanLimit::Display0To5 => 0x05,
ScanLimit::Display0To6 => 0x06,
ScanLimit::Display0To7 => 0x07,
};
// Package into array to pass to SPI write method
// Write method will grab array and send all data in it
let send_array: [u8; 2] = [Command::ScanLimit as u8, data];
// Transmit Data
transmit_raw_data(&send_array, per, cs).unwrap();
}
In the function itself you can see that the transmit_raw_data function created earlier is also used to send the data. transmit_raw_data takes in send_array as one of the arguments to transmit. In send_array the first element is the ScanLimit command from the Command enum. The second element is one of the display options from the ScanLimit enum.
If you examine the rest of the config functions, you'll see that they have a very similar footprint. The main difference being that the enum used corresponds to the feature that is being configured. This includes the functions config_power_mode, config_decode_mode, config_intensity, and display_test.
draw_row_or_digit functionThis function is meant to light up LEDs row by row, or alternatively if seven segment display are hooked up it would light up different segments per digit. Lets examine the function:
fn draw_row_or_digit(
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>,
digit_addr: DigitRowAddress,
led_data: u8,
) -> Result<(), AddressError> {
let addr: u8 = match digit_addr {
DigitRowAddress::Digit0 => 0x01,
DigitRowAddress::Digit1 => 0x02,
DigitRowAddress::Digit2 => 0x03,
DigitRowAddress::Digit3 => 0x04,
DigitRowAddress::Digit4 => 0x05,
DigitRowAddress::Digit5 => 0x06,
DigitRowAddress::Digit6 => 0x07,
DigitRowAddress::Digit7 => 0x08,
_ => return Err(AddressError::AddressNotValid),
};
let send_array: [u8; 2] = [addr, led_data];
transmit_raw_data(&send_array, per, cs).unwrap();
Ok(())
}
You can see that quite similar to the config functions with minor differences. Here there are the usual suspects of the per and cs parameters, in addition to another two digit_addr and led_data. digit_addr is the DigitRowAddress enum where the address of the digit or row is selected, and led_data is a u8 containing the information for which LEDs to light up.
clear_display functionThis function is meant to clear the display, the function simply itererates over all rows of the device sending a value of zero to each row. Again, per and cs had to be passed at least as parameters. For the sake of simplicity, I skipped using the DigitRowAddress enum and used the addresses directly.
fn clear_display(
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>,
) -> () {
for i in 1..9 {
transmit_raw_data(&[i, 0_u8], per, cs).unwrap();
}
}
init_display functionThis function was let for last since it depends on all other functions created earlier. init_display initializes the display for usage. The function steps through the configurations required by the datasheet so that the display can be used. These are the same steps as the previous post, only packaged in functions now. A clr_display bool type parameter was added as giving an option to clear the display after initializing it.
fn init_display(
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>,
clr_display: bool,
) -> () {
// 1.a) Power Up Device
config_power_mode(per, cs, Shutdown::NormalOperation);
// 1.b) Set up Decode Mode
config_decode_mode(per, cs, DecodeMode::NoDecode);
// 1.c) Configure Scan Limit
config_scan_limit(per, cs, ScanLimit::Display0To7);
// 1.d) Configure Intensity
config_intensity(per, cs, Intensity::Ratio15_32);
// 1.e) Optional Screen Clear on Init
if clr_display {
clear_display(per, cs);
}
}
In the application code from the past post, the code iterated over each row of the device activating LED diagonally. Instead of the code earlier, now we should be able to leverage the newly created draw_row_or_digit function resulting in the following code:
let mut data: u8 = 1;
for addr in 1..9 {
data = data << 1;
draw_row_or_digit(
&mut spi,
&mut cs,
DigitRowAddress::try_from(addr).unwrap(),
data,
)
.unwrap();
delay.delay_ms(500_u32);
}
Note here the usage of the try_from trait mentioned & implemented earlier in this post. Since draw_row_or_digit takes DigitRowAddress as a parameter, it wouldn't be possible to pass addr, an integer type. This is where try_from fills in the gap, it converts the u8 to a DigitRowAddress enum option.
In this post, the first step in creating a Rust platform agnostic driver for the MAX7219 device was taken. This was done by refactoring the code from the previous post and packaging it into functions. It turns out that based on the approach taken in this post, the newly created code was actually verbose and not as flexible as desired. In the following post, the code will be refactored again creating inching further toward a platform-agnostic driver. Have any questions or thoughts? Please post them in the comments below 👇.