Skip to content

esp32_rmt

RMT: the remote-control peripheral, used here as a precise pulse generator.

Eight channels that emit a programmed sequence of high/low durations, clocked off APB through a per-channel divider. It exists for infrared remote protocols, and it is also the only sane way to drive an addressable LED — WS2812 and friends need sub-microsecond pulse widths held to a few hundred nanoseconds, which is not something a task competing with a 1 ms tick can do by toggling a pin.

Two modes.

[Rmt::transmit] is one shot: write the entries, start, done. It is bounded by the channel’s 64-entry block — 63 usable, and a WS2812 bit is one entry, so two LEDs. Simple, and enough for a board with a single onboard LED.

[Rmt::start_stream] is for anything longer. The channel wraps around its block while an interrupt refills the half just played, so frame length is bounded by nothing but the caller’s buffer. A 5×5 panel is 600 entries and needs this: MEM_SIZE maxed gives one channel all eight blocks, 512 entries, at the cost of every other channel, and 512 < 600.

Streaming buys that at the price of a deadline. A refill has one half-block — about 40 µs at WS2812 rates — before the transmitter reaches what it is writing, and there is no underrun flag, so being late is silent and looks like corrupt pixels. [Refill] holds the bookkeeping and is pure, so the ping-pong can be tested on a host rather than by watching an LED.

Rejecting an over-long sequence is deliberate. Silently truncating it would light some LEDs and leave others at whatever they held before, which looks like a wiring fault rather than a programming error.

Register facts, all checked against esp-idf headers

Section titled “Register facts, all checked against esp-idf headers”

Every constant here was read out of soc/rmt_reg.h, soc/soc.h, soc/dport_reg.h and soc/gpio_sig_map.h rather than recalled. Four of them would have been wrong from memory, which is why:

  • the channel FIFO register is at offset 0x0000, not 0x800
  • IDLE_OUT_EN and IDLE_OUT_LV live in CONF1, not CONF0
  • ETS_RMT_INTR_SOURCE is 47, not 46
  • RMT_SIG_OUT0_IDX is 87

Two ways in, and only one of them works twice

Section titled “Two ways in, and only one of them works twice”

There are two paths to a channel’s entries, and the first version of this module took the wrong one. Writing RMT_CHnDATA_REG pushes through an APB FIFO; setting RMT_APB_FIFO_MASK instead maps the 64-entry block into memory at RMT_BASE + 0x800 and you write it by index.

The FIFO path transmits correctly once. Its write pointer is rewound by APB_MEM_RST, which is a different bit from the MEM_RD_RST that rewinds the read pointer, so a driver that resets only the read pointer replays its first frame forever – an LED that lights the right colour and then ignores every later one. That is what happened, and the symptom is quiet enough to be mistaken for the encoder.

This uses the memory path, because esp-idf does: rmt_ll_tx_reset_pointer touches mem_rd_rst alone, which is only sufficient when the writes went to RMTMEM rather than through the FIFO. mem_wr_rst, despite the name, rewinds the receiver’s pointer and has nothing to do with transmitting.

RMTMEM = 0x3ff56800 comes from esp-idf’s esp32.peripherals.ld, and the rest from rmt_reg.h read literally rather than summarised.

struct Chunk {
pub dest: usize,
pub src: usize,
pub len: usize,
pub terminator: bool,
}

One refill: copy len entries from src in the source into the block at dest, and append a terminator if this is the last of them.

Implements: Clone, Copy, Debug, Eq, PartialEq, StructuralPartialEq

struct Entry(u32);

One RMT entry: two consecutive pulses, each a level and a 15-bit duration in divided clock ticks.

A duration of zero is the end marker, which is why [Rmt::transmit] appends one rather than trusting the caller to.

Implements: Clone, Copy, Debug, Eq, PartialEq, StructuralPartialEq

struct Refill { /* private fields */ }

Bookkeeping for a frame longer than the block it is played from.

Deliberately pure: no registers, no channel, nothing target-specific. The half-block ping-pong is the part with the off-by-ones in it, and keeping it separate is what lets it be tested on a host instead of by watching an LED.

Implements: Clone, Copy, Debug, Eq, PartialEq, StructuralPartialEq

struct Rmt { /* private fields */ }

One RMT channel, configured for transmit.

const fn divider_for_ns(ns: u32) -> (u8, u32)

Divider giving approximately ns per tick, clamped to the 8-bit field.

Returns the divider and the resulting nanoseconds-per-tick, because the caller needs the latter to convert its own pulse widths and recomputing it from the divider is how the two drift apart.

const fn frame_ns(total_ticks: u32, ns_per_tick: u32) -> u32

How long a frame of entries takes, in nanoseconds, at ns_per_tick.

Each entry holds two pulses; total_ticks is their summed durations. The caller has that number already from building the entries, and recomputing it from the packed words here would only be a second chance to get it wrong.

const APB_HZ: u32;

APB clock feeding the RMT divider.

const ENTRIES_PER_BLOCK: usize;

Entries in one channel’s memory block.

Each entry encodes two pulses, so a block is 128 pulses — and a WS2812 bit is one entry, giving 64 bits, or two LEDs and change.

const HALF_BLOCK: usize;

Entries in half a block. The transmitter plays one half while the other is being refilled, which is what bounds the memory a stream needs to 64 entries however long the frame is.

const IRQ_SOURCE: u8;

The chip’s RMT peripheral interrupt source, for interrupt::connect.

One source serves all eight channels (ETS_RMT_INTR_SOURCE); the handler reads the per-channel status to tell them apart. Re-exported so a caller can wire the channel to a CPU interrupt without naming the SoC’s address map.