Introduction
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:
- 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.
- 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 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.
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
Additionally, all the code presented in this post in addition to instructions for the environment and toolchain setup are available on the apollolabsdev Nucleo-F401RE 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.
Driver Functions
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.
Encoding Configuration Options
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 apollolabsdev Nucleo-F401RE 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.
Encoding Commands & LED Data
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.
Function Implementation
The transmit_raw_data
function
I 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.
The config
functions
There 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
.
The draw_row_or_digit
function
This 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.
The clear_display
function
This function is meant to clear the display, the function simply iterates 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();
}
}
The init_display
function
This 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);
}
}
Refactoring Application Code
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.
Conclusion
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 👇.
Top comments (0)