WS2812 vs. Rust
Posted on
Many people are using WS2812/Neopixel LEDs for their convenience, for how cheap they are or just because they're lying around. But controlling them from an MCU is a tricky thing, since they use a weird, timing-sensitive protocol.
The Protocol
There is only one pin over which the data is sent for each led in order and then a latch sequence, to tell them to display the new color.
The latch signal tells the LEDs, that they can now display the new data and if afterwards we start transmitting again, it's starting from the beginning. This is done by keeping the line low for at least 300μs and not particularly hard.
Each LED has 24 bits of data, 8 bits per color, where each bit is transmitted as a high pulse and a low pulse. The length of the high pulse determines if a given pulse is a '1' or a '0'.
Here you can see how '0' & '1' bytes look and some of the timing constraints. These numbers aren't totally accurate, but way better than specified in the data sheet. There are many blog posts on the internet regarding the "real" WS2812 timings, for example the one by Josh Levine.
If we can reliably hit these numbers, the job of driving the WS2812 LEDs becomes pretty easy.
Unfortunately that's not all that easy and it becomes pretty much impossible to do in a portable way. We tried anyway, which is what the next section is about.
Controlling WS2812 LEDs in Rust
NOP
An (almost) trivial way to time actions is to just do nothing for long enough, which can be done with nop
instructions or empty loops. To work well, this has to be done in assembler (which often needs nightly rust), changes from chip to chip and is even dependent on the clock frequency. We tried doing it in a half-assed way, but the resulting code broke very often and wasn't worth the effort keeping it up-to-date.
Timer
When thinking about this problem, a timer based solution seems obvious. Isn't that exactly what timers are for? But it's not that easy. Using a timer just for keeping the intervals between the changing outputs means we need a very fast MCUs and it doesn't work that well. To improve compatibility, we introduced a hack that ignores the timer for T0H
. This means more MCUs can be used, but it's hard to say reliably when it works well and when the hack makes things worse.
Another solution would be to control the pin directly via the timer, but embedded-hal
has no interfaces for that, so it would need to be chip-specific.
SPI
Last, but not least: a true hack. One can abuse the MOSI
line of most SPI peripherals to bitbang a certain byte sequence at a specified frequency, ignoring the SCK
and MISO
pins. Since the timing of the bits is now handled by hardware, it makes this solution more resistant against software timing differences.
Now we can define two bit sequences, one for '1' and another for '0'. We're currently using two nibbles, 1110
and 1000
, contained in a lookup table to send 2 WS2812 bits at once. This seems to work very well across a pretty wide range of SPI frequencies.
Since the state of the MOSI
line when idle is not typically a concern for SPI
devices it depends on the MCU. That means we have to work around MCUs that leave MOSI
high on idle (like the ATSAMD MCUs) since otherwise the first LED might
interpret it as a color signal which is ... not good.
To work around that, use the mosi_idle_high
feature, which inserts a "latch" before transmitting any data.
If somehow your MCU is too slow for that (maybe, perhaps, when using an AVR), you can also use the prerendered
variant, where the data is first written into a buffer and then sent, which minimizes timing issues.
Other Options
The "best" option will be something specific for your platform, either assembler based or using the integrated peripherals in better ways, e.g. using timers (on the nrf52) & compare outputs, PIOs on the RP2040 or some other novel process. There's no end to unusual ways to generate the right waveforms for this protocol.
TLDR: Weird, convoluted ways to blink an LED. Hooray!