Rust: Running a TLC5940 with an ESP32 using the RMT peripheral
The TLC5940 is a "16-channel LED driver w/EEprom dot correction & grayscale PWM control" IC, used for driving up to 16 constant current outputs (typically LEDs) using a number of control lines. It's more complex to drive than the TLC5947 which uses a simple I2C interface, but leaves more control to the application developer by letting them set the LED PWM frequency, among other things.
The TLC5940 uses SPI to transfer brightness data, but also requires an external PWM clock, as well as a periodic pulse on the BLANK pin to ensure the outputs remain enabled. This is annoying to do in software, but by abusing the RMT peripheral in an ESP32, we can let the microcontroller hardware do all of the heavy lifting for us.
Please note that I'm not going to detail the full TLC5940 setup in this post (e.g. SPI, XLAT, etc). That said, if you'd like a complete example using Embassy, I've left a bunch of code at the bottom of this post.
Behaviour from the datasheet
Let's take a quick look at the timing diagram in the TLC5940 datasheet page 14:
It's a little hard to make out, but paying attention to the BLANK
and GSCLK
signals, we see the
IC requires 4096 pulses on GSCLK
followed by a high pulse on BLANK
. GSCLK
is the PWM clock
source for the IC, so no outputs will be enabled if it's absent. For some reason, BLANK
needs a
pulse every 4096 cycles to get the IC to continue functioning. We'll pay attention to the timing
requirements later.
Hardware options
While we could of course use loop
s and counters to manually toggle the BLANK
and GSCLK
pins,
on ESP32 there's a really nice peripheral called the
RMT (Remote Control Receiver)
which:
The RMT (Remote Control Transceiver) peripheral was designed to act as an infrared transceiver. However, due to the flexibility of its data format, RMT can be extended to a versatile and general-purpose transceiver, transmitting or receiving many other types of signals.
We don't care about receiving anything, but we can use the two transmit channels of the RMT to generate the pulse trains we need. The RMT is configured by specifying high or low pulses of varying duration. The key reason to use the RMT is it can be set up to repeat its configured pulse train without any intervention in code, freeing the MCU up for other business logic tasks.
Channel configuration
We'll configure channel 1 for the GSCLK
waveform. The RMT only allows a few pulses to be provided,
but we can use the carrier frequency generation block of the RMT to generate a high frequency PWM
signal on the output while in an "on" pulse. All we need to do then is provide an "on" pulse with a
duration of 4096 cycles to generate the correct number of GSCLK
pulses.
The timing diagram from above shows that GSCLK
must be disabled while the BLANK
pin is high, so
we'll specify a second "off" pulse to disable the carrier while we perform the BLANK
pulse on
channel 2.
Channel 2 of the RMT will be for the BLANK
pulse, and behaves as the inverse of channel 1, however
does not require the carrier to be enabled as we don't want a high frequency clock on the BLANK
pin.
Synchronising the channels
The RMT can be told to synchronise both its channels. This is important for our use case to ensure
the BLANK
pulse lies in the idle period between GSCLK
pulse trains.
Some code
I'm using the ESP HAL for its nice high level API, but at the end of the day all we're doing is writing register values, so the following solution should work in any software stack. Also, I'm using an ESP32-C3 but I believe the RMT is available in most/all Espressif ICs.
Here's the RMT configuration in full:
// Choose your own adventure
let gsclk_pin = io.pins.gpio1;
let blank_pin = io.pins.gpio2;
// GSCLK frequency defined here
let rmt = new.unwrap;
// `GSCLK` config: when an "on" pulse is given,
// output a pulse train at half the configured RMT frequency
let gsclk_config = TxChannelConfig ;
// `BLANK` is simpler - we just make an "on" pulse after
// 4096 `GSLK` pulses
let blank_config = TxChannelConfig ;
// `GSCLK` pulses: 4096 cycles of carrier, followed by
// 64 cycles for `BLANK` pulse on channel 2
let gsclk_pulses = ;
// Spacing around `BLANK` pulse to meet timing
// requirements in datasheet
let blank_spacing = 16;
// BLANK pulse
let blank_pulses = ;
// Enable CH0/CH1 sync (manual 33.3.4.5).
let channel0 = rmt.channel0.configure.unwrap;
let channel1 = rmt.channel1.configure.unwrap;
channel0
.transmit_continuously
.expect;
channel1
.transmit_continuously
.expect;
This code is a bit of a handful and difficult to understand, but the visualisations in the next section should hopefully clarify what the configuration is doing.
Before that though, I'll draw attention to a couple of things:
- There isn't yet a high level API to enable channel sync, so I'm dropping down into the PAC (Peripheral Access Crate) to manually set some bits.
- We call
transmit_continuously
to repeat the configured pulse train indefinitely. - The
blank_spacing
variable adds some gaps around theBLANK
pulse. I'll discuss this in Tighter timing below.
Behaviour
Here's what we see in PulseView, a logic analyser GUI:
We can see a repeating pattern of high frequency PWM (the grey mess), interrupted by a BLANK
pulse
on the other line.
If we zoom in to the end of a cycle, just before the BLANK
pulse, Pulseview shows us that 4096
pulses have been sent:
It also confirms the desired 1MHz frequency:
Now let's turn out attention to the BLANK
pulse:
We can see it's nicely separated from the GSCLK
pulse train, and is suitably long for the IC to
register it.
Tighter timing
Let's take a closer look at the timing around the BLANK
pulse:
The code in this post is extremely conservative, meaning we're leaving performance on the table,
and potentially introducing undesirable flicker in any attached LEDs. We can probably do better,
starting by figuring out what the datasheet specifies as "better". Looking at the timing diagram
above, it makes reference to several excitingly named parameters like th4,
twh3, etc. The values for these times are defined in the datasheet section "6.3
Recommended Operating Conditions". I'll copy the relevant ones for BLANK
timing here:
Minimum time | ||
---|---|---|
th4 | Hold time GSCLK ↑ to BLANK ↑ | 10ns |
twh3 | BLANK pulse duration | 10ns |
tsu4 | Setup time BLANK ↓ to GSCLK ↑ | 10ns |
A minimum of 10ns. Nice. Are we near these minimums?
Lol. Not even close. Here's the minimum times against what we configured:
Minimum time | Ours lmao | ||
---|---|---|---|
th4 | Hold time GSCLK ↑ to BLANK ↑ | 10ns | 16500ns |
twh3 | BLANK pulse duration | 10ns | 32031ns |
tsu4 | Setup time BLANK ↓ to GSCLK ↑ | 10ns | 16500ns |
We're 3 orders of magnitude safe, so I think we can probably do better in our RMT configuration. The
maximum frequency on GSCLK
is 30MHz, although I don't think the RMT can reach even half that
frequency (remember, the GSCLK
pulse train is at half the RMT peripheral clock), but we could
simply bump the RMT peripheral clock as an easy step in the right direction.
The better option would be to reduce blank_spacing
in the example code from above, as well as
fiddling with the BLANK
pulse duration. This is RMT clock dependent however, so I'll leave it as
an exercise to the reader.
A full example
Here's an example using Embassy which just fades all outputs up and down in a loop:
use Spawner;
use ;
use esp_backtrace as _;
use ;
async !