Expand description
Framebuffer implementation for HUB75 LED matrix displays.
§How HUB75 LED Displays Work
HUB75 RGB LED matrix panels are scanned, time-multiplexed displays that behave like a long daisy-chained shift register rather than a random-access framebuffer.
§Signal names
- R1 G1 B1 / R2 G2 B2 – Serial colour data for the upper and lower halves of the active scan line
- CLK – Shift-register clock; every rising edge pushes the six colour bits one pixel to the right
- LAT / STB – Latch; copies the shift-register contents to the LED drivers for the row currently selected by the address lines
- OE – Output-Enable (active LOW): LEDs are lit while OE is LOW and blanked when it is HIGH
- A B C D (E) – Row-address select lines (choose which pair of rows is lit)
- VCC & GND – 5 V power for panel logic and LED drivers
§Row-pair scanning workflow (e.g., 1/16-scan panel)
- While the panel is still displaying row pair N − 1, the controller shifts the six-bit colour data for row pair N into the chain (OE remains LOW so row N − 1 stays visible).
- After the last pixel is clocked in, the controller raises OE HIGH to blank the LEDs.
- With the panel blanked, it first changes the address lines to select row pair N, lets them settle for a few nanoseconds, and then pulses LAT to latch the freshly shifted data into the output drivers for that newly selected row.
- OE is immediately driven LOW again, lighting row pair N.
- Steps 1–4 repeat for every row pair fast enough (hundreds of Hz) that the human eye sees a steady image.
- If the first row pair is being shifted, the panel continues showing the last row pair of the previous frame until the first blank-address-latch sequence occurs.
§Brightness and colour depth (Binary Code Modulation)
- Full colour is typically achieved using Binary Code Modulation (BCM), also known as Bit-Angle Modulation (BAM). Each bit-plane is displayed for a duration proportional to its binary weight (1, 2, 4, 8 …), yielding 2ⁿ intensity levels per channel. See Batsocks – LED dimming using Binary Code Modulation for a deeper explanation.
- Because each LED is on for only a fraction of the total frame time, the driver can use relatively high peak currents without overheating while average brightness is preserved.
§Implications for software / hardware drivers
- You don’t simply “write a pixel” once; you must continuously stream the complete refresh data at MHz-range clock rates.
- Precise timing of CLK, OE, address lines, and LAT is critical—especially the order: blank (OE HIGH) → set address → latch → un-blank (OE LOW).
- Microcontrollers typically employ DMA, PIO, or parallel GPIO tricks, and FPGAs use dedicated logic, to sustain the data throughput while leaving processing resources free.
In short: a HUB75 panel is a high-speed shift-register chain that relies on rapid row-pair scanning and Binary Code Modulation (BCM) to create a bright, full-colour image. Keeping OE LOW almost all the time—blanking only long enough to change the address and pulse LAT—maximises brightness without visible artefacts.
§Framebuffer Implementations
Four framebuffer layouts are provided, covering two hardware variants and two BCM strategies:
| Module | Word size | External latch? | BCM strategy |
|---|---|---|---|
bitplane::plain | 16-bit | No | True bitplane |
bitplane::latched | 8-bit | Yes | True bitplane |
plain (deprecated) | 16-bit | No | Threshold frames |
latched (deprecated) | 8-bit | Yes | Threshold frames |
§Plain vs. Latched
- Plain packs all HUB75 signals (address, latch, OE, colour) into each 16-bit word. No extra hardware beyond a parallel output peripheral.
- Latched uses 8-bit words with a separate external latch circuit to hold the row address and gate the pixel clock, halving per-entry memory.
§Threshold Frames vs. True Bitplane
The two BCM strategies differ in how they store colour data and how the DMA stream must be driven to render it. The bitplane layouts are recommended for new designs; the threshold layouts are deprecated.
True bitplane (bitplane::plain, bitplane::latched) – each of
PLANES planes (typically 8) stores one bit of every colour channel
directly. To render, stream each plane’s data a number of times equal to
its bit-weight (the MSB plane 128 times, the LSB plane once), either with
a DMA descriptor chain or by reprogramming the DMA registers per segment.
Memory scales linearly with the number of planes. Each module offers a
plane-major (frame) and a row-major (row) layout — both LSB-first
with suffix-coalesced BCM segments; see their documentation for the exact
scan order.
Threshold frames (plain, latched) – deprecated. The driver
compares each channel’s 8-bit value against per-frame thresholds and
stores the resulting on/off bits. For a colour depth of BITS, this
produces 2^BITS - 1 frames. Frame n is displayed for a duration
proportional to 2^n. Memory grows exponentially with colour depth, but
the whole refresh is one contiguous buffer that even interrupt-less
circular DMA can stream.
All four variants have configurable row and column dimensions, support
embedded-graphics via the DrawTarget trait, and expose their BCM
scan sequence through the FrameBuffer trait.
§Memory placement (⚠️ large buffers)
Every framebuffer is a large inline array sized from its const generic parameters. Rough sizes:
bitplane::plain:PLANES × NROWS × (COLS + gap) × 2bytes.bitplane::latched:PLANES × NROWS × (COLS + gap)bytes.plain:(2^BITS - 1) × NROWS × COLS × 2bytes.latched:(2^BITS - 1) × NROWS × (COLS + 4)bytes.
For example, a 64×64 panel at 8-bit colour depth is already tens of
kilobytes. Never bind a framebuffer to a task-local let — the whole
buffer would be placed on that task’s stack and overflow it.
Framebuffers are mutable (drawing takes &mut self), so they need a
'static home that hands out &'static mut without unsafe.
static mut is not an option. Use static_cell::StaticCell, which
guarantees the value is taken at most once and lives in .bss:
use embedded_graphics::pixelcolor::RgbColor;
use embedded_graphics::prelude::Point;
use hub75_framebuffer::bitplane::plain::frame::DmaFrameBuffer;
use hub75_framebuffer::Color;
use static_cell::StaticCell;
const COLS: usize = 64;
const NROWS: usize = 32;
const PLANES: usize = 8;
type Fb = DmaFrameBuffer<NROWS, COLS, PLANES>;
// `StaticCell` hands out `&'static mut` without `unsafe`; the value is
// taken at most once and lives in .bss, not on the stack.
static FB: StaticCell<Fb> = StaticCell::new();
let fb = FB.uninit().write(Fb::new());
fb.set_pixel(Point::new(0, 0), Color::RED);§Driving the Panel (DMA Integration)
This crate is buffer-only: it builds the memory image and describes
exactly what to stream via the FrameBuffer trait — writing the
peripheral driver (the refresh loop) is up to you. A driver only needs
two things from a framebuffer:
FrameBuffer::Word— the stream element type:u16for theplainvariants,u8for thelatchedones. The peripheral must output this many bits in parallel and generates the pixel clock (CLK) itself.FrameBuffer::BCM_SEQUENCEandFrameBuffer::bcm_segment— the ordered(ptr, len, reps)segments that make up one complete panel refresh.
The framebuffer must live in DMA-capable memory and outlive the transfer:
place it in a static (or a DMA RAM section on chips that require one)
and apply whatever alignment and cache maintenance your MCU needs.
§DMA with descriptor chains (least CPU)
The bitplane stream is non-uniform: each segment must be output reps
times (the MSB plane up to 2^(PLANES-2) times). This maps naturally
onto DMA descriptor chains (e.g. ESP32 GPDMA linked-list mode) — use
FrameBuffer::BCM_SEQUENCE / bcm_rep_count to size the descriptor
table at compile time and FrameBuffer::bcm_segment to populate it; a
segment whose len exceeds the DMA’s maximum transfer size is simply
split across several descriptors. Link the chain into a loop and the
panel refreshes forever with zero CPU involvement. For free-running
loops, consider the tail-closes-latch feature so LAT is not left
asserted when the chain wraps.
§Simple DMA (registers only, no descriptors)
If your DMA is just source address + length registers with a
transfer-complete interrupt, walk the segments in the ISR: on each
interrupt, program the address/length registers from
FrameBuffer::bcm_segment, re-arm the transfer, and re-issue the same
segment until its reps count is exhausted before advancing. The cadence
is modest — the plane-major (frame) layouts raise only PLANES
interrupts per panel refresh (8 at full color depth); the row-major
(row) layouts raise one per segment of every row
(FrameBuffer::BCM_SEGMENT_COUNT per refresh).
If the DMA cannot interrupt at all — truly circular-only hardware — the
deprecated threshold framebuffers (plain, latched) remain an
option: their entire refresh is one contiguous buffer
(FrameBuffer::BCM_SEQUENCE_LEN is 1, reps == 1) with all BCM
timing baked into the memory image, at an exponential memory cost.
§Multiple Panels / Scan-Pattern Remapping
Use tiling::RemappedFrameBuffer to drive several HUB75 panels as one
large virtual display, or to remap pixels for non-standard scan patterns
(e.g. 1/16-scan on 64×64 panels). It works with all four framebuffer types
and only requires two generic parameters (F and M).
Combine it with a tiling::PixelRemapper implementation such as
tiling::ChainTopRightDown (tiling) or tiling::QuarterScan (1/16-scan).
The wrapper exposes a single embedded-graphics canvas, so for example a
3 × 3 stack of 64 × 32 panels simply looks like a 192 × 96 screen while
all coordinate translation happens transparently.
The older tiling::TiledFrameBuffer is still available but deprecated.
§Available Feature Flags
§skip-black-pixels Feature (disabled by default)
When enabled, calls to set_pixel() with Color::BLACK return early without
writing to the framebuffer. This provides a significant performance boost for
UI applications that frequently draw black pixels (backgrounds, clearing, etc.)
by assuming the framebuffer was already cleared.
Important: This optimization assumes that black pixels represent “no change” rather than “explicitly set to black”. By default, black pixels are written normally to ensure correct overwrite behavior. To enable the optimization:
[dependencies]
hub75-framebuffer = { version = "0.13.0", features = ["skip-black-pixels"] }§esp32-ordering Feature (required for original ESP32 only)
Required when targeting the original ESP32 chip (not ESP32-S3 or other variants). This feature adjusts byte ordering to accommodate the quirky requirements of the ESP32’s I²S peripheral in 8-bit and 16-bit modes. The original ESP32 has different byte ordering requirements compared to other ESP32 variants (S2, S3, C3, etc.), which do not need this feature.
[dependencies]
hub75-framebuffer = { version = "0.13.0", features = ["esp32-ordering"] }§interstate75 Feature (Pimoroni Interstate 75 / 75 W only)
Selects the 16-bit pin layout of the Pimoroni Interstate 75 and
Interstate 75 W (RP2040 / RP2350). The DMA word is pre-aligned to the
board’s contiguous GPIO mapping (GPIO0..GPIO13) so a PIO out pins, 16
stream can clock out the panel with no run-time bit shuffling:
bit 13 12 11 10..6 5..3 2..0
OE LAT (spare) E D C B A B1 G1 R1 B0 G0 R0Bit 11 is unused by this crate and always left at 0.
Applies to the 16-bit plain and bitplane::plain framebuffers only;
the 8-bit latched framebuffers are unaffected. Mutually exclusive with
esp32-ordering.
[dependencies]
hub75-framebuffer = { version = "0.13.0", features = ["interstate75"] }§tail-closes-latch Feature (plain framebuffers only)
Appends a single extra “tail” word at the end of the DMA buffer that drives the LATCH signal LOW (de-asserted) on the final clock edge. Without this feature the last word in each row asserts LATCH HIGH to latch shifted data into the LED drivers, and the GPIO pins remain in that state after the DMA transfer completes. Some hardware configurations (e.g. free-running DMA loops or peripherals that continue clocking after the descriptor chain ends) can re-latch stale data or glitch if LATCH is left asserted.
Enabling tail-closes-latch adds one 16-bit Entry (for plain) or one entry
per bit-plane (for bitplane::plain) that parks the bus with LATCH=0 and
OE=BLANK, cleanly terminating the transfer. The cost is a single extra word per
DMA chunk, which is negligible compared to the frame data.
[dependencies]
hub75-framebuffer = { version = "0.13.0", features = ["tail-closes-latch"] }§reverse-row-order Feature (disabled by default)
Stores the rows of every framebuffer layout in reverse scan order so that the DMA stream renders the last panel row first and row 0 last. The rows are physically reversed in the buffer (row addresses are written back-to-front while keeping the deferred address-change timing intact) and the pixel-setting paths map logical rows to the reversed slots, so logical coordinates are unchanged — only the scan order changes.
[dependencies]
hub75-framebuffer = { version = "0.13.0", features = ["reverse-row-order"] }§Blanking delay features (lead-blank-* / trail-blank-*)
Control the number of pixel-clock cycles of blanking (OE HIGH) inserted
around row-address changes. The lead blank controls how many cycles the
output is blanked before the row address is changed, and the trail blank
controls blanking after the row address is changed. Together they give
the address lines time to settle and prevent ghosting or “bleeding”
artifacts caused by the panel briefly displaying data on the wrong row
during the transition.
| Feature | Blanking cycles | Position |
|---|---|---|
| (none) | 1 (default) | before & after change |
lead-blank-1 | 1 | before address change |
lead-blank-2 | 2 | before address change |
lead-blank-4 | 4 | before address change |
lead-blank-8 | 8 | before address change |
lead-blank-16 | 16 | before address change |
trail-blank-1 | 1 | after address change |
trail-blank-2 | 2 | after address change |
trail-blank-4 | 4 | after address change |
trail-blank-8 | 8 | after address change |
trail-blank-16 | 16 | after address change |
Higher values reduce ghosting at the cost of slightly less brightness (the LEDs are on for less time per scan line). Start with the default and increase only if you observe row-transition artifacts on your particular panel hardware.
[dependencies]
hub75-framebuffer = { version = "0.13.0", features = ["lead-blank-4", "trail-blank-2"] }Note: At most one lead-blank-* and one trail-blank-* feature may be
enabled at a time. If multiple are enabled for the same edge, compile-time cfg
conflicts will result.
§Inter-row blanking features (inter-row-blank-*)
Insert additional dead clock cycles at the row transition, between the
latch and the address-line change. In plain framebuffers the gap entries
hold the previous row address with OE HIGH (blank), deferring the
address change to the first pixel after the gap and giving slow panels
more time to finish blanking before the address lines move. In latched
framebuffers the latch and address change are inseparable in hardware,
so the gap simply adds extra blanked cycles after the address change.
Row-major bitplane framebuffers stream the gap between plane 0 (shifted
out before the address change) and plane 1 (shifted out at/after it);
all other framebuffers place it at the end of each row, between that
row’s latch and the next row’s first pixel. The gap entries are
invisible to all drawing primitives — they only appear in the DMA
stream.
| Feature | Gap cycles | RAM cost per row |
|---|---|---|
| (none) | 0 | 0 bytes |
inter-row-blank-4 | 4 | 8 bytes (16-bit) / 4 bytes (8-bit) |
inter-row-blank-8 | 8 | 16 bytes / 8 bytes |
inter-row-blank-16 | 16 | 32 bytes / 16 bytes |
inter-row-blank-32 | 32 | 64 bytes / 32 bytes |
[dependencies]
hub75-framebuffer = { version = "0.13.0", features = ["inter-row-blank-8"] }Note: At most one inter-row-blank-* feature may be enabled at a time.
The inter-row-blank feature (no number) is an internal umbrella that is
enabled automatically by any inter-row-blank-* feature — do not enable it
directly.
These are independent of the lead-blank-* / trail-blank-* features and
can be combined with them.
§defmt Feature
Implements defmt::Format for framebuffer types so they can be emitted with
the defmt logging framework. No functional changes; purely adds a trait impl.
§doc-images Feature
Embeds documentation images when building docs on docs.rs. Not needed for normal usage.
Modules§
- bitplane
- Bitplane-oriented framebuffer implementations for HUB75 LED panels.
- latched
- DMA-friendly framebuffer implementation for HUB75 LED panels with external latch circuit support.
- pinmap
- Compile-time HUB75-signal → DMA-word bit mapping.
- plain
- DMA-friendly framebuffer implementation for HUB75 LED panels.
- tiling
- Coordinate remapping for tiled and scan-pattern LED panel arrangements
Structs§
- BcmLen
Reps - Static
(len, reps)pair describing one BCM segment without a pointer. - BcmSegment
- A single segment of the BCM scan sequence.
Enums§
- Word
Size - Word size configuration for the framebuffer
Constants§
- BCM_
SEQUENCE_ CAPACITY - Capacity of the
FrameBuffer::BCM_SEQUENCEarray.
Traits§
- Frame
Buffer - Framebuffer that exposes its BCM scan sequence as an ordered series of segments.
- Frame
Buffer Operations - Trait for all operations a user may want to call on a framebuffer.
- Mutable
Frame Buffer - Trait for mutable framebuffers that support
embedded_graphicsdrawing.
Functions§
- bcm_
rep_ count - Sum of
repsacross one complete panel refresh. - compute_
frame_ count - Computes the number of frames needed for a given bit depth
- compute_
rows - Computes the NROWS value from ROWS for
DmaFrameBuffer
Type Aliases§
- Color
- Color type used in the framebuffer