I’m trying to run an LED matrix display (with a Max7219 controller) from a raspberry pi pico using rust. There is a max7219-crate that I used. But i am unsure about how to prepare the pins I want to use. Can I Use any of the pins? Do I have to set them to push-pull-output?
Edit: OK, so Lemmy keeps stripping all the angle brackets out of my comments which makes posting any code that uses generics really hard/impossible. To work around that I’m just going to link to a gist of this post the way it’s supposed to look.
I’m going to guess you’re not super familiar with Rust yet, in which case good job making it this far with embedded Rust, that’s kind of the deep end of the pool. The embedded-hal crate that’s at the core of all these crates is a really amazing piece of engineering, it walks a fine line between defining a set of primitives that can be used across all embedded devices while also not being so generic as to be useless or so specific as to exclude certain embedded devices from being supported. A big part of how it accomplishes that is by very carefully using traits and generics. Traits are easiest to work with but they have the downside of potentially introducing dynamic dispatch which has runtime overhead, so static dispatch is preferred. A big part of how you avoid dynamic dispatch is using generics.
For a concrete example, we can look at the Pin struct declared by the rp2040-hal crate. The
Pin
struct is generic and includes two parameters, an Id that’s an instance of PinId which is itself simply a marker trait that can be applied to each GPIO address, and a Mode that’s an instance of the PinMode trait which is a marker trait for the various modes eachPin
can be toggled into. Using these you could for instance have an instance of thePin
struct declared like soPin
which would indicate the GPIO0 pin that has been configured intoPushPull
mode. The PushPull struct is an instance of the marker trait OutputConfig. Going back to thePin
struct for a moment, we can see that it provides a generic implementation forOutputPin
which is defined for anyPin
whose mode is an instance ofOutputConfig
. Using thatOutputPin
marker trait then allows writers of drivers, such as the one for the MAX7219 to write a generic implementation that will work for literally anyPin
that’s an instance ofOutputPin
.Now an important point in all of that, is that generics are made concrete at compile time. While you see a declaration like this:
pub struct PinConnectorwhere DATA: OutputPin, CS: OutputPin, SCK: OutputPin,
at compile time that actually ends up looking more like
PinConnector,Pin,Pin>
which is declaring that you’re using the GPIO pin 3 for MOSI, pin 5 for CS, and pin 2 as clock as well as statically asserting at compile time that they’ve all been properly configured into output mode. You would for instance get a compile error if you attempted to pass a pin instance likePin
becausePullUp
is an instance ofInputConfig
and therefore thatPin
instance is an instance ofInputPin
not an instance ofOutputPin
as declared by the bounds on thePinConnector
generics.Now, that does make reading the docs for all this a little tricky, and requires some getting used to, but it’s incredibly powerful once you do understand it. One skill you’re going to want to get in the habit of to make the most out of the embedded-hal ecosystem is reading blanket and auto trait implementation, they’re really the core of what makes the entire thing function.
To make all of these even more complicated, embedded rust docs are only half the picture, the other half is the docs for the specific hardware devices in question. For instance here is the datasheet for the Max7219. Looking at that I can already see I made a mistake in one of my previous comments. I said the max supported speed was 1mHz, but the datasheet actually indicates it’s 10mHz, and indeed when I double check the driver docs I linked previously they do in fact say 10mHz, not 1mHz. Based on the datasheet for the Max7219, I would expect that the
DS
parameter on theSpi
device should actually be16
as that’s the size of each serialized packet sent over the SPI bus that it’s expecting, however I see that the Max7219 driver crate specifies that the Spi instance should be aWrite
which is only defined forSpi
with aDS
value of 8 or lower. I’m guessing maybe there’s some quirk of the Max7219 command set that the driver is working around? Not really sure what’s going on there honestly.Hey! Thank you very much! This is an incredibly well made, probably labor-intensive and (nice!) comment! (and yeah a few code-pieces seem to disappear, but i think i understand the original meaning.
that cleared a lot up to be honest. I have been using rust for a while now, but i think all the more advanced features that i didn’t really have to deep-dive into before are now used all at once in the embedded context. it’s all very dense to read when only looking into the source code (or the docs). But your explanations helped tremendously (i will read them again tomorrow though.
It’s really fascinating what rust makes possible here. I haven’t really programmed too much in c++ in the embedded context, but i guess i would have to basically rewrite a lot of software if i want to use it on a different device, right?
Regarding the 8 or 16 values of the
DS
-values, i am not quite sure myself. I’ve found two examples where a Max7219-chip is used together with a raspberry Pi pico with Rust. One implemented the max7219-struct itself and didn’t use the max7219-crate and used the value 16 for DS… This example works on my setup.The other example is using the max7219 and it needs DS=8 otherwise it doesn’t compile. It kinda works, but there seems to be some errors when i use it: if I use write_raw to set all the pixels on the display certain values seem to change the display’s state. at a certain point it changes its intensity and changes into all-pixels-on-mode suddenly. This shouldn’t happen if i only use wrote_raw.
But with your explanations i might understand a little more of the stuff that i used in the code. Thank you very much!
Honestly I’m suspecting that the driver crate is just broken, and that it is supposed to be using a value of 16 for the DS parameter. The trait constraint the
from_spi
function should have applied should be Write with au16
generic, notu8
which would then allow you to use16
as the DS parameter when initializing the Spi instance. If I had a Max7219 chip at hand I would try modifying the driver crate to verify if that’s the case, but I don’t unfortunately. Maybe open an issue on the driver repo describing the behavior you’re seeing (and maybe link him back to this thread) to see what he thinks?As for the case with C++ code it is often more device specific, but it can also cheat a certain amount. Rust is all about safety, it doesn’t let you make a bunch of mistakes that are possible in C++. The upshot of that is that when you get a piece of Rust code to compile, it’s more often than not correct. That’s somewhat on the skill of the person writing the libraries though, you can certainly write code that can be used wrong, but a good author can often define their APIs in such a way that it’s impossible to use it incorrectly. As in the example above, the Spi instance is being constrained to a DS of 8 due to the way the Max7219 crate is defined, it’s impossible to accidentally use a DS of 16 with it, it just happens that it seems like that constraint is wrong in this case.
C++ in contrast lets you take shortcuts. For instance you can define a bunch of constants and use ifdefs to conditionally set them at compile time. For example you can see this random driver I found using a google search that it defines the
Max7219
class as taking aPinName
class/struct/enum (not sure which honestly) which I’m sure is defined elsewhere as the raw pin identifier constant exposed by the underlying hardware. That driver for instance does not enforce that the pin has been configured into the properPushPull
mode prior to it being passed to the driver, it’s on you as the user of the library to make sure everything has been properly setup before hand. It’s “easier” in that everything is basic, but it’s also error prone as it doesn’t double check your work, you’ll just get a crash at runtime.C/C++ is very low level, barely higher than assembly. If you’re armed with the datasheets for everything you can probably make it work, but you need to be very sure you’re getting all the details right. Rust on the other hand tries to force you to use things correctly. Ideally you should have just been able to grab the Max7219 crate, and just use it and everything would work. The fact it isn’t suggests there’s a possible bug in the crate, rather than that you’re just using it wrong, as it really should be impossible to use it wrong.
Hey thank you! it might actually be that the driver has an error. For me somebody pointing that out is actually very helpful, as I always suspect that I’m doing something wrong. But playing around with the other example that kind of implemented the max7219 interface from scratch (using the u16 for the data send) was pretty fun!
I guess I will try changing the original max719-crate from u8 to u16 tomorrow and see what happens. I also posted an issue about that on the GitHub.
I decided to crack open the source of the Max7219 crate to get a better idea of what’s going on.
Reading the chips datasheet it looks like it’s expecting 16 bit packets sent in little endian format on the wire. The high byte consists of a 4 bit segment address (or command) and then 4 bits of padding. The low byte is interpreted depending on the address or command in the high byte as well as what the currently set decoding mode is.
Looking at the code for the crate, I see in the Spi struct it declares a buffer like so
buffer: [u8; MAX_DISPLAYS * 2],
. I believe a more correct version of that declaration would bebuffer: [u16; MAX_DISPLAYS],
. Then looking at the actual implementation of the write_raw method I see this:fn write_raw(&mut self, addr: usize, header: u8, data: u8) -> Result { let offset = addr * 2; let max_bytes = self.devices * 2; self.buffer = [0; MAX_DISPLAYS * 2]; self.buffer[offset] = header; self.buffer[offset + 1] = data; self.spi .write(&self.buffer[0..max_bytes]) .map_err(|_| DataError::Spi)?; Ok(()) }
where once again a bunch of double counting of u8s is being done. I think a more accurate version of that would be:
fn write_raw(&mut self, addr: usize, header: u8, data: u8) -> Result { self.buffer = [0; MAX_DISPLAYS]; self.buffer[addr] = u16::from_ne_bytes([header, data]); self.spi .write(&self.buffer[0..self.devices]) .map_err(|_| DataError::Spi)?; Ok(()) }
This skips messing around with packing the u8 bytes into pairs via address calculations and instead uses the
from_ne_bytes
function to directly pack the address/header byte and the data byte into a little endian u16 suitable for serialization across the SPI bus. I’m not 100% sure thatfrom_ne_bytes
is correct in this case, as I’m not entirely clear how that would interact with the native endianness of the CPU and the SPI controller, but I’m hoping that by explicitly putting the header in the high byte that it would respect that. Some experimentation would be necessary there I think to make sure it was actually portable.Hi, Thank you. It took me a while, but I experimented around a little bit. I have not yet tried to fix the max7219-library though. I think it is
from_be_bytes
(the other one didn’t work).But one thing that I am not understanding (I think this is a “can’t tell the forest from the trees”-situation) is how exactly multiple 8x8-matrices are connected i.e. how the data-stream looks exactly.
In your example (from the max7219-library) it seems like if I use 4 devices I send 4 times a u16 out and the 4 connected Max7219’s figure out themselves which one is meant?
So it took me a little while to figure out between reading the datasheet for the Max7219 and looking at the source code. Basically it’s taking advantage of a feature of the Max7219 that allows daisy chaining multiple chips off the same SPI connection. In order to take advantage of this feature you would take N Max7219 chips and wire all their CS and CLK pins together with your controller, and then run the connection from the controller to the first chips DIN port, and then the DOUT port from the first chip to the DIN port of the next chip. Keep chaining DOUT to DIN to daisy chain all the chips together.
In the datasheet for the Max7219 there’s this section:
Essentially what that all boils down to, is that each Max7219 maintains a 16 bit internal shift register, so as each bit is received on DIN it’s pushed onto the register, and the highest bit of the register gets pushed out to DOUT. When you daisy chain multiple chips together it’s effectively like concatenating all their shift registers together. So if you have 4 chips, that’s 64 bits of register. If you write 64 bits out to MOSI the first 16 bits will end up on the farthest out chip, the next 16 in the next closest, etc. Switching the CS pin from low to high is the trigger for the Max7219 to actually lock in and read the contents of those shift registers. The way the driver crates code is structured that’s the purpose of the buffer field in the various Connector structs. So if you have say 4 chips, you need 4 x u16 storage, and each write cycle you write all 4 u16 values out, one to each daisy chained device. Technically the driver is less efficient than it could be, in that it takes advantage of the fact that writing 0 to a chip is a no-op, so in practice while it does write to every device each time, when you call write_raw it actually 0s the buffer for all but the selected chip.
If you think about a sequence of chips, lets say once again 4 of them labeled A to D. They would be connected like so:
RP-Pico-MOSI----DIN-A-DOUT----DIN-B-DOUT----DIN-C-DOUT----DIN-D-DOUT -CS----------CS------------CS------------CS------------CS -CLK---------CLK-----------CLK-----------CLK-----------CLK
Then you write to all four chips like so:
Thank you.
Ive actually read the section you quoted a few times and my brain just couldn’t parse it. But i finally understand how the max7219 makes this. I’ve thought about it completely wrong. It just shifting through all the bits from chip to chip so obvious now.
I think i will go a step back and not use spi for a while and just do the bit-banging -thingy first to get more familiar first.
I’ve read somewhere that Is faster and I guess it’s cheaper for the cpu to use as the cpu doesn’t have to set the pin outs high or low with each cycle. Instead (i guess) the cpu can simply call a spi-out-funtctio one time and the spi does its thing for a while while the cpu can do other things.
But right now I don’t do much yet on the rest of the CPU, so i can afford to do it manually.
Just one other question regarding multiple displays: as e.g. 4 displays requires 4x16bits does this mean that there would have to be a Write-trait implemented somewhere (or Write<[u16;4]>)?
could it be that the max7219-crate is incomplete here? The write-funtion you corrected seems like it was copied 1to1 from the cpp-lib (LedControl).
Nope. The Write trait is indicating the size of the “packet” that’s written on the SPI bus, it’s the equivalent of the DS generic off the Spi struct. The way SPI works is, when you toggle CS low, the device is notified that it needs to start listening on MOSI, at which point you’re free to start sending it packets. There’s no requirement that you only send a single packet, you can send as many as you want, however many devices will have special rules about processing with respect to the state of the CS pin. E.G. just like with the Max7219 it’s common for devices to buffer commands and not actually process them until CS is sent high.
The only reason why the Write and the Spi generic are important is because it defines the minimum number of bits that will be written to the bus (or more concretely it’s the stride size the SPI controller uses when reading and writing from its buffers). That’s why using u8/8 as the parameter mostly works except for occasionally demonstrating strange behavior. Using u16 guarantees that it always writes a number of bits that’s a multiple of 16, while using u8 can allow for essentially a half packet to be written.
As for bit banging vs. SPI controller, it’s essentially the same thing as DMA if you’re familiar with that concept. Using bit banging the CPU is spending time toggling the various pins off and on, which although fast, is still relatively slow by communication standards and puts an upper limit on the speed data is transmitted on the SPI bus that’s directly tied to the frequency of the CPU and the number of cycles it takes to toggle a pin (minimum two pin toggles, maybe one for MOSI, two for CLK). Using the SPI controller on the other hand, the CPU writes bytes into memory and then passes essentially a couple of pointers to the SPI controller then flips some bits in a register. The CPU does need to pause occasionally to refill the buffers, but that’s a relatively fast operation and is mostly decoupled from the actual bus speed of SPI.
Manually implementing SPI with bit banging is probably a good learning exercise, but understanding how to properly use the SPI controller is also good to know. For an extra challenge you can usually also setup the SPI buffer to be managed using DMA for the most optimal way to handle things. I would suggest configuring a u16 buffer sized based on the number of devices and then using DMA to write its contents out using the SPI buffer would be a very educational exercise.