Zero-CPU LED Matrix: Driving HUB75 Panels with the Raspberry Pi's GPU
How I abused a display interface designed for LCD screens to get flicker-free LED panels — and how Claude Code helped me design, build, and debug the whole driver.
The Flicker Problem
My friend David and I have a project called pifi — a Raspberry Pi that drives a 64x32 RGB LED matrix panel. It plays YouTube videos, runs screensavers, and hosts multiplayer Snake. The whole thing is controlled through a web interface on your phone.
For years, I’ve been using the excellent hzeller/rpi-rgb-led-matrix library to drive the panel. It works by bit-banging GPIO pins from a high-priority CPU thread — toggling clock, data, and control lines millions of times per second to keep the LEDs refreshing. It works well, but it has a fundamental problem: it eats 30-40% of a CPU core, and any hiccup — a background process spiking, the OS scheduling something — causes visible flicker.
I wanted to find something better.
The Idea: Let the GPU Do It
I knew the Pi had a GPU that could output data on GPIO pins — I’d seen it mentioned in the context of LCD screens. My thought was simple: if the GPU can push parallel data to a display, and LED panels take parallel data, maybe we can skip the CPU entirely. But I had no idea how DPI actually worked, what the framebuffer format looked like, or how to map HUB75 signals to GPIO pins.
I brought the seed idea to Claude Code, and we worked out the approach together. Claude knew about DPI — Display Parallel Interface — and explained that the GPU reads a framebuffer and outputs 24 bits of data on GPIO pins 4-27, clocked by a pixel clock on GPIO 0. The key thing is: once you set up the framebuffer, the GPU scans it out continuously with zero CPU involvement. It just runs.
HUB75 LED panels also use parallel data lines — R1, G1, B1, R2, G2, B2, plus clock, latch, output-enable, and row address lines. And on the Adafruit RGB Matrix HAT, all of those signals happen to be wired to GPIOs in the 4-27 range.
Claude helped me work out the core insight: craft a framebuffer where each “pixel” isn’t a color, but a set of GPIO states encoding the HUB75 protocol. The GPU thinks it’s driving an LCD. The LED panel thinks a microcontroller is bit-banging its protocol. Neither knows about the other. CPU usage for driving the panel: zero.
Claude also figured out the exact pin mapping between DPI’s XRGB8888 byte layout and the Adafruit HAT’s GPIO wiring, designed the framebuffer row layout (data rows interleaved with control rows), implemented BCM timing for color depth, and wrote the initial driver code. I tested on hardware, reported what I saw, and we iterated.
A handful of people have explored this general idea before — FBMatrix, a proof-of-concept by B-C-Mike — but none of the projects gained traction or were maintained past 2020. The technique is barely known.
How It Actually Works
An HUB75 panel with 32 rows and 1/16 scan works like this: there are 16 row addresses, each driving two rows simultaneously (top half and bottom half of the panel). For each address, you clock in 64 pixels of data, latch it, then enable the output for a carefully timed duration. To get more than on/off brightness, you repeat this with different bit weights — Binary Coded Modulation (BCM).
In the DPI framebuffer, each scan address gets two DPI rows per bitplane:
- Data row: 64 panel pixels encoded as 128 DPI pixels (CLK high, CLK low for each)
- Control row: A latch pulse, then output-enable held active for a duration proportional to the bit weight
With 4-bit color (16 brightness levels per channel), that’s 16 addresses × 4 bits × 2 rows = 128 DPI rows. The GPU scans this out at whatever pixel clock you set and the panel just works.
The Pi’s DPI hardware operates on XRGB8888 pixels — 4 bytes each, where the blue byte controls GPIO 4-11, green controls GPIO 12-19, and red controls GPIO 20-27. So when I write 0x01 to the blue byte, that sets GPIO 4 (which is the output-enable line on the Adafruit HAT). Every DPI “pixel” is really just a GPIO command.
The Ghost Problem
Everything worked almost immediately — solid colors, gradients, animations, all flicker-free. But there was a persistent artifact: the bottom row and middle row of the panel (both driven by address 0) always showed a faint ghost of whatever was on the other rows.
This turned out to be an inherent incompatibility between DPI and HUB75. DPI was designed for LCD screens, which have a Data Enable (DE) signal that tells the display to ignore data during blanking periods. HUB75 has no such concept. And during every horizontal blanking period (hblank), the DPI hardware forces all 24 GPIO data lines to 0.
All zeros means: output-enable goes active (it’s active-low), and address lines go to 0. So for a brief moment after every single DPI row, the panel enables output on address 0 while the output register still holds some other address’s data. The result: a faint ghost of every other row, composited onto row 0.
Where Claude Code Made the Difference
Claude Code was involved from the beginning — from working out the DPI-to-HUB75 mapping, to writing the driver, to vectorizing the frame encoding with NumPy. But the ghost problem is where the collaboration really proved its value. I could see the artifact, I had a vague theory about blanking, but I didn’t have the low-level DPI knowledge to confirm it or know what levers to pull.
Over a long debugging session, we:
-
Built a systematic diagnostic suite — Claude suggested a series of test patterns to isolate whether the ghost came from adjacent rows, all rows, or specific addresses. The tests confirmed it was proportional to brightness across all addresses — ruling out a wiring issue or shift register problem.
-
Eliminated vblank ghost — Claude suggested adding “blanking rows” at the end of the framebuffer that clock zeros into address 0’s shift registers right before vblank. This cut the ghost in half.
-
Identified hblank as the remaining source — By testing with
hfp=0, hbp=0in config.txt (minimizing blanking to just the mandatory 1-pixel hsync), we confirmed the ghost scaled with blanking duration. Claude explained the exact mechanism: the output register retains the last-latched data, and hblank enables OE on address 0 with stale data. -
Designed the fix — Claude proposed using
hactive=256(double width) so that each control row has 128 extra DPI pixels to clock zeros through the shift register and latch them before hblank arrives. When blanking forces OE active on address 0, the output register is empty. Ghost eliminated.
The whole project — from “the Pi has a GPU, why can’t we use it” to a working, ghost-free, 7-bit color driver — took a few sessions. The problem required simultaneous knowledge of DPI timing, HUB75 protocol details, KMS/DRM framebuffer setup, GPIO multiplexing, and BCM modulation. I brought the hardware, the seed idea, and the ability to test on a real panel. Claude brought the ability to reason across all of those domains at once, write the code, suggest experiments, and iterate on results in real time. I genuinely don’t think I would have gotten here on my own — at least not without weeks of reading datasheets and writing code I’d have to throw away.
Results
The driver currently runs at 7-bit color depth (128 brightness levels per channel) with zero CPU cost for panel refresh. The framebuffer is encoded with vectorized NumPy operations, so even frame updates are fast.
The code is in the pifi repo on the dpi-matrix branch if you want to try it. You’ll need:
- A Raspberry Pi 3 or 4
- An Adafruit RGB Matrix HAT/Bonnet
- A 64x32 HUB75 LED panel (1/16 scan)
- The
python3-kms++(pykms) package
The config.txt setup:
dtoverlay=vc4-kms-v3d
dtoverlay=vc4-kms-dpi-generic,rgb888
dtparam=hactive=256,hfp=0,hsync=1,hbp=0
dtparam=vactive=256,vfp=1,vsync=1,vbp=1
dtparam=clock-frequency=8000000
If you’re running LED panels on a Pi and tired of flicker, give it a shot. And if you’re skeptical about using AI for real engineering work — this project is a good example of where it shines. Not writing boilerplate, but as a collaborator on a genuinely hard problem where the knowledge is scattered across multiple domains and nothing is on Stack Overflow.