Getting the panel off the critical path
The panel worked. It was just dragging the entire console down with it.
Here is the shape of the problem. The ST7735 mirrors the cart's 128x128 framebuffer. To put a frame on it you set the address window, then stream 32 KB of RGB565 out the SPI bus. The first working version did that the obvious way: convert the frame, then push it row by row with a blocking write.
self.set_window(0, Y0, 127, Y0 + 127);
self.dc.set_high();
for _ in 0..128 {
let _ = self.spi.blocking_write(&row); // CPU babysits every byte
}
blocking_write means exactly what it says. For the roughly 13ms it takes to
shift a frame out at 25 MHz, the CPU sits in that loop feeding the SPI peripheral
and does nothing else. On a single core that runs the compositor, the Lua VM, and
the audio refill, 13ms of the CPU staring at a shift register is 13ms nobody else
gets. The whole present loop ran at about 14fps, and a big slice of every frame
was just the panel being couriered out by hand.
The panel was on the critical path. It should not have been on the path at all.
DMA: hand the bytes to the hardware
The fix is the one the H7 is built for. The SPI peripheral has a DMA channel (SPI1_TX on DMA1_CH1). You point it at a buffer, tell it how many bytes, and it shifts them out on its own while the CPU goes and does literally anything else. The row-by-row loop collapses into one call:
// One whole-frame DMA write. The .await yields the executor for the full
// ~13ms blit, so the panel is OFF the critical path.
let _ = p.spi.write(&dma.0[buf_idx][..]).await;
The .await is the whole point. While the DMA engine spends 13ms pushing the
frame, that await hands the thread executor back to the present loop and the next
Lua frame. The panel blit now overlaps the cart's compute instead of blocking it.
Same 25 MHz bus, same 32 KB, but the CPU is no longer holding its hand.
That one change took the panel from about 14fps to about 33fps.
The catch: the data cache lies to the DMA
DMA on a Cortex-M7 with the caches on is a trap I had already fallen into once, with audio. The data cache is on, because the Lua interpreter and the compositor live and die by it. But DMA reads from actual RAM. The CPU writes through the cache. So when the CPU fills the frame buffer and then kicks off a DMA read, the DMA can pull stale bytes that are still sitting in a dirty cache line, and you get a frame of garbage or a frame from two frames ago.
The textbook answer is to clean the cache over the buffer before every transfer. That is real work on every single frame, and it is fiddly to get the address ranges right. There is a better move, and I had already built it for the audio DMA ring: carve the buffer out of the cache entirely with the MPU.
The nice part here is where the buffer lives. The H7 has a small SRAM bank called SRAM4 in the D3 domain, 64 KB, that nothing else in this firmware uses. So the frame buffer gets the whole bank to itself, and the MPU region becomes trivial: one region covering exactly 64 KB, aligned to the bank base, marked Normal Non-cacheable.
// Region 1: all 64KB of SRAM4, the panel DMA double-buffer, Non-cacheable.
let rasr1 = (1 << 0) // ENABLE
| (15 << 1) // SIZE=15 -> 2^16 = 64KB
| (0 << 17) // C = 0 -> Non-cacheable
| ...;
cp.MPU.rnr.write(1); // region 1
cp.MPU.rbar.write(panel_base | (1 << 4) | 1); // base | VALID | REGION 1
cp.MPU.rasr.write(rasr1);
The buffer is declared align(65536) so its base is the bank base and the region
lands on it exactly. Region 0 is the 2 KB audio ring from the sound work; region 1
is this. Everything else in RAM keeps its default write-back caching, so the Lua
VM and the compositor lose nothing. Only these two little islands are
non-cacheable, and the DMA over them stays coherent with zero per-frame cache
maintenance. The same trick that kept the audio click-free now keeps the panel
correct.
Three banks, three jobs
What I like about where this landed is that the memory map ended up telling the whole story. The panel pipeline touches three different RAM banks, and each one is doing the thing it is actually best at:
- DTCM holds a private snapshot of the cart's framebuffer. DTCM is the fastest
bank for the CPU and DMA cannot reach it, which is perfect, because this copy
exists precisely so the SPI read never races the cart's live
cls()and redraw. That race was an earlier flicker bug. The snapshot is CPU-only by design. - SRAM4 holds the RGB565 DMA buffer, the one place that has to be both DMA-reachable and non-cacheable.
- AXI holds everything else, fully cached, untouched.
A snapshot the DMA must not see, a buffer the DMA must see, and the cached working set. Three banks, and the constraints sort themselves into the right one.
The SRAM4 buffer is also two halves, not one. The frame I am about to show
converts into one half while the DMA reads the other, then they swap (buf_idx ^= 1).
Double-buffering means the next conversion can never scribble on the bytes the DMA
is mid-read on, so there is no tearing.
The frame I was packing for nobody
After DMA, the profiler still showed a fat chunk of every frame, about 9ms, going
into something called prep. That turned out to be the most embarrassing kind of
slow: work being done perfectly, for no reason.
The console can run two ways. With the Mac viewer attached, the present loop RLE-packs each frame and encodes it for the USB stream. In standalone mode the SPI panel IS the display and the USB stream is off, so the panel reads the framebuffer directly. But the present loop did not know that. It was still faithfully RLE-packing every frame and encoding it for a host that had hung up. Nine milliseconds a frame, compressing pictures and sending them into a closed pipe.
The fix is a const the optimiser can see through:
const STREAM_FRAME: bool = !cfg!(feature = "standalone-panel");
In the standalone build that is false, the whole pack-and-encode branch is dead
code, and the release optimiser deletes it. The prep cost dropped from about 9ms
to about 2.5ms, and the panel went from about 33fps to about 41fps. Two wins in an
afternoon: DMA took it off the critical path, and deleting the framing it was
doing for nobody bought the rest.
What is left
The panel is no longer the bottleneck, and that is the actual result here, more than any single fps number. It is off the critical path, the DMA is coherent for free, and there is no tearing.
The remaining ceiling is the Lua VM. A heavy cart like Solais renders at roughly 23fps on the on-target interpreter, and the panel is vsync'd to the cart, so it only ever pushes a frame the VM has actually finished. The display is no longer what limits the number. Getting to a locked 60 needs faster Lua or a lighter cart, not a faster screen. I will take "the screen is no longer the problem" as a good place to stop for the night.