A large LED matrix should not force the rest of your firmware to stop. If display refresh is implemented as a giant loop that continuously shifts pixels and waits between rows, networking, sensors, buttons and control logic will eventually miss deadlines.
The correct architecture separates frame generation from scan refresh. Application code updates pixels at its own pace; a timer, DMA engine, PIO block or dedicated peripheral keeps the panel refreshed deterministically.
Why blocking refresh fails
A naive matrix driver often looks like this:
while (1) {
for (row = 0; row < ROWS; ++row) {
shift_row_data(row);
latch_row();
delay_us(display_time);
}
read_buttons();
update_network();
}
The problem is structural. Application work only runs after a complete refresh sequence. If the display driver adds more rows, more color depth or more chained panels, the time left for everything else changes.
Split the system into two rates
A robust design normally has at least two timing domains:
- Refresh rate: how often panel rows/PWM bit planes are driven. This must remain deterministic.
- Content update rate: how often the application changes the framebuffer. This can usually be much slower.
A temperature display might refresh electrically thousands of row operations per second while the visible number changes only once per second.
Use a framebuffer
Keep the desired image in RAM instead of calculating every pixel during the time-critical scan.
uint16_t frame[HEIGHT][WIDTH];
The application writes the next image into a framebuffer. The refresh engine reads from it independently.
For larger displays, choose the pixel format deliberately. A full 24-bit RGB framebuffer uses three bytes per pixel before any alignment or driver-specific conversion. On a memory-constrained MCU, that may be a major design decision.
Double buffering prevents tearing
If application code modifies the same framebuffer while the refresh engine is reading it, part of one visible frame can contain old data and another part new data.
Double buffering avoids this:
- Front buffer: currently being scanned to the display.
- Back buffer: application draws the next frame here.
Swap the buffers at a safe frame boundary. This consumes more RAM but makes animation and UI updates cleaner.
Timer-driven refresh
For moderate matrix sizes, a hardware timer interrupt can advance the row/bit-plane state machine:
void display_timer_isr(void)
{
outputs_disable();
load_next_row();
latch_data();
select_row();
outputs_enable();
advance_scan_state();
}
The ISR should do only the work necessary to maintain timing. Do not format text, allocate memory or run application logic inside it.
DMA reduces CPU load
DMA can move display data from memory to a peripheral without having the CPU write every byte or GPIO value manually.
Espressif’s RGB LCD driver, for example, exposes DMA burst configuration and framebuffer/bounce-buffer modes. Its documentation notes that DMA from internal DRAM bounce buffers can be faster than fetching directly from PSRAM.
The exact mechanism differs by MCU, but the architecture is similar: CPU prepares data, DMA performs repetitive transfers, interrupts occur only at meaningful boundaries.
PIO is useful on RP2040-class devices
Raspberry Pi’s RP2040 provides Programmable I/O state machines for deterministic GPIO protocols. For matrix refresh, PIO can handle clock/data/latch sequences while CPU code manages the framebuffer and application.
This is especially valuable when the display interface does not map neatly onto SPI or another standard peripheral.
Do not put full RGB conversion in the interrupt
Binary-coded PWM or bit-plane refresh often requires the framebuffer to be transformed into scan-ready data. Performing color conversion, gamma correction and row rearrangement inside every refresh interrupt wastes timing margin.
Precompute as much as practical when the frame changes, then let the refresh path consume a compact representation.
Binary coded modulation changes refresh timing
Many RGB matrix libraries represent brightness with bit planes. More significant bits remain enabled longer than less significant bits.
For example, a conceptual 4-bit channel uses relative display times:
1, 2, 4, 8
The total per-row PWM time is 15 units. Increasing color depth rapidly increases the refresh budget required to show every plane while maintaining the same full-frame rate.
Do not let Wi-Fi or logging own the refresh deadline
Serial logging, filesystem operations and network stacks can introduce long unpredictable execution times. If the display refresh runs in ordinary application code, these delays become visible flicker.
Give refresh a hardware-backed timing path. Then let lower-priority work take longer without corrupting the scan.
Use bounded work in callbacks
A refresh callback must finish before the next required deadline. If it occasionally takes longer than one row interval, the problem is not solved merely because an interrupt is used.
Measure:
- Worst-case interrupt execution time.
- Longest interval between refresh events.
- CPU percentage consumed by display service.
- DMA underruns or buffer starvation.
Power and timing remain coupled
A non-blocking software architecture does not solve weak power delivery. Large RGB panels draw pulsed current and can still produce brownouts, dim rows or ghosting.
Use our LED matrix flicker and ghosting guide for OE/latch/address timing and power troubleshooting.
Choose the display architecture before writing the scheduler
A MAX7219-based 8×8 matrix does not need the same refresh architecture as a large HUB75 RGB wall. If you are still selecting the hardware, read MAX7219 vs HUB75 vs direct multiplexing.
Example architecture
main loop / RTOS tasks
|
+-- sensors
+-- network
+-- UI logic
+-- draw into back buffer
|
v
frame-ready flag
|
v
timer/DMA/PIO refresh engine
|
+-- row address
+-- shift pixels
+-- latch
+-- output enable/PWM
Only the lower layer owns exact scan timing.
RTOS design
If the MCU uses an RTOS, do not assume a high-priority display task automatically gives deterministic microsecond timing. Task scheduling has overhead and jitter.
Use hardware timers/DMA/peripherals for the finest timing. An RTOS task can prepare frames and respond to frame-complete events.
Frame swapping safely
Do not swap a framebuffer pointer in the middle of a scan unless the driver explicitly supports it. Change buffers at a known frame boundary or vertical blank equivalent.
A simple sequence is:
- Application finishes drawing the back buffer.
- Set a
frame_readyflag. - Refresh engine reaches the end of the current complete frame.
- Atomically exchange front/back pointers.
- Clear the flag.
What to measure with a logic analyzer
Capture CLK, LAT, OE and one row-address signal. Then add a debug GPIO toggled by the application.
You should see:
- Regular display timing even when application load changes.
- No OE overlap during row/latch transitions.
- Stable clock periods.
- Main-loop/task activity continuing during long display operation.
See our logic-analyzer debugging workflow for measurement technique.
Stress test the architecture
- Display full-white and high-detail patterns.
- Generate maximum expected network traffic.
- Enable verbose logging temporarily.
- Run all sensors at maximum configured rate.
- Measure refresh timing under combined load.
- Watch for missed rows, DMA starvation or brightness modulation.
- Run continuously long enough to expose memory leaks.
Primary references
- Raspberry Pi Pico SDK: PIO, PWM, DMA and timer hardware APIs
- ESP-IDF RGB LCD driver: DMA, framebuffers and timing
- Adafruit Protomatter: timer-driven HUB75 matrix refresh architecture
Bottom line
Large LED matrices should be refreshed by hardware-assisted timing, not by blocking application loops. Keep graphics in a framebuffer, precompute scan data, use timers/DMA/PIO for deterministic output, and swap frames at safe boundaries. That leaves the CPU available for the actual product.