Platform Agnostic Drivers in Rust: MAX7219 Naive Code Refactoring

Platform Agnostic Drivers in Rust: MAX7219 Naive Code Refactoring

·

12 min read

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:

  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.
  3. Refactor code in the second step to incorporate embedded-hal traits and create a platform-agnostic driver. Link to Post.
  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 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 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.

MAX7219 Decode Modes

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.

MAX7219 Address Map

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 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();
    }
}

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 👇.

Did you find this article valuable?

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