The SPI panel saga: from a blank screen to 25 MHz
Up to this point the console's display was a window on my Mac, fed over USB. That
was always meant to be temporary. The whole Present trait exists so the same
desktop can come out of a USB stream, a real LCD, an LTDC panel, or HDMI without
the compositor knowing the difference. This is the first time it came out of an
actual screen: a cheap SPI TFT wired straight to the board. It did not go
smoothly, which is why it is worth writing up.
The dead end: SSD1283A
I started with a 130x130 panel built around an SSD1283A. Mistake, but an instructive one. Most small SPI TFTs use an ST77xx-style command stream. The SSD1283A is a different animal: you write a register by sending an 8-bit index then a 16-bit value, and the controller latches that register on the rising edge of chip select.
That last detail is the entire problem, and it cost me two sessions.
My first driver held CS low for the whole init sequence, the way you would with a streaming controller. Backlight came on, screen stayed black. The controller never saw a single CS rising edge, so it never latched a single register. The fix was to frame every register write in its own CS low-then-high pulse, so each one latches.
That got the init to take, and the screen went from black to grey. Progress, but still wrong. The pixels were not landing. Same root cause one level deeper: the window-setup registers that tell the controller where to draw (horizontal address, vertical address, GRAM start) were also being held across CS, so they never latched either, and the pixel stream was going to the wrong place, which is to say nowhere visible. Frame those in CS pulses too, then stream the pixels, and finally there was an image.
And then the panel showed its other problem. Its backlight pulls about 100 mA, and on a rail shared off the Nucleo's regulator that sag dragged the logic levels down with it. I could not push the SPI clock past roughly 1 MHz before the rail collapsed and the image fell apart. A 130x130 panel at 1 MHz is not a console display. I cut my losses.
The switch: ST7735
The replacement was a 128x160 panel on an ST7735, a boring, well-documented,
native-3.3V command controller. The init is the standard ST7735R sequence:
software reset, sleep out, frame-rate control, inversion control, the power and
VCOM registers, then mode and colour setup, then display on. The pixel path is
the usual three commands: column address (CASET), row address (RASET), then
memory write (RAMWR) followed by the pixel stream. Colour is RGB565, 16 bits
per pixel.
After the SSD1283A, this felt like cheating. It just worked.
Colour and orientation: the MADCTL dance
"Just worked" is generous. It lit up, but the cart was mirrored, sideways, and
the colours were wrong: reds were blue and blues were red. Every one of those is
a bit in a single register, MADCTL (memory access control, 0x36):
- The classic red-and-blue swap is the BGR bit. The panel was reading my RGB565 as BGR. Clear the bit, colours correct.
- The MX and MY bits flip the panel horizontally and vertically. The cart was coming out mirrored and upside down relative to how it sat in the case, so I walked those bits until it was upright and unmirrored.
The value that got everything right was 0x00: no flips, RGB order. It only
looks tidy in hindsight. Getting there was a sequence of flash, look, flip one
bit, repeat.
Climbing the clock
With a stable image I started pushing the SPI clock, because the first working version was deliberately slow.
- 1 MHz. Cart mirrors to the panel, stable. The cautious baseline.
- 4 MHz. I had earlier seen a blank screen up here and assumed it was a speed ceiling. It was not. It was a wiring fault on the breadboard. Fixed, 4 MHz was the first clean "first light" at a real speed.
- 2 MHz. I dropped back here to dial in the orientation and colour, because it is easier to debug when each frame is unambiguous.
- 25 MHz. The SPI kernel clock divided down by four. The ST7735 is rated somewhere around 15 to 32 MHz, so 25 is comfortably inside spec, and the earlier "blank at high speed" was, again, just bad wiring.
At 25 MHz the panel runs the cart mirror at roughly 33 to 48 fps, five to seven times what 2 MHz gave. It starts around 47 and settles near 33 once a cart is doing real work, which is the tell for the current bottleneck.
What is actually slow
It is not the SPI bus. The pixel writes are blocking, and they share the one CPU core with the USB stream and the Lua cart's VM. While the core is busy shifting a frame out over SPI, it is not running the cart or servicing USB. The bus could go faster; the CPU is the thing that is saturated.
The fix, which is the next thing on the list, is DMA. Hand the pixel transfer to the DMA engine and the core is free to run the VM and the USB stream while the frame goes out in the background. That is the path to a locked 60 fps. It also re-opens the data-cache coherency question from the audio work, because now there is a DMA master reading a framebuffer in cached RAM, so the same Non-cacheable MPU treatment will be needed.
The power lesson, kept
The SSD1283A's sagging-rail problem left a mark on the design even after the panel was gone. Audio and the LCD now sit on separate supplies: the PCM5102A on its own 5V feed, the LCD on 3.3V with a bulk capacitor across it. And the firmware still re-runs the panel init every cycle, a habit picked up while fighting the sagging rail, so that a brief brownout self-heals instead of leaving a frozen or discoloured screen. With the ST7735 on a clean supply it is belt-and-braces, but it costs nothing and it means the display recovers from a glitch on its own.
The headline from the commit that closed this out says it best: ST7735 at 25 MHz, 33 to 48 fps, five to seven times faster than before, and the audio is still crisp. That last clause is the one I cared about. Pushing the display hard without starving the sound is the whole game on a single core.