Four wires, five villains
Last post ended with the Feather RP2040 DVI drawing colour bars from its own smoke test. The next step was the real thing: the Nucleo H7 streaming live console frames over a four-wire SPI link, the Feather scanning them out as HDMI. Four wires. Clock, data, chip select, ground. How hard could four wires be?
Two days. Five separate root causes. Every single one real, every single one masking the next one down. This post is the war diary.
Villain 1: the encoder that starved the console
The first link protocol was the obvious one: reuse the console's existing wire codec (the same COBS-framed, CRC-checked protocol the USB viewer speaks), point it at SPI instead. It worked - the console appeared on the TV - and everything else fell apart. Games stopped loading. Sound effects went silent. The whole machine felt drunk.
The console runs its Lua VM, audio synth, SPI panel and network stack cooperatively on one core. The panel path stays out of the way by being nearly free: convert the framebuffer, fire one DMA, yield. The new link path COBS-encoded 16KB per frame on the CPU - roughly twenty times the work - and the Lua VM and synth paid for it.
The fix set the design rule for everything after: the sender must be as cheap
as the panel. The protocol became raw RGB332 - one lookup table converts the
framebuffer's palette indices to the RP2040's native 8-bit colour format
(PicoDVI's dvi_scanbuf_main_8bpp wants exactly this, three bits red, three
green, two blue), then one DMA pushes the bytes. No framing codec, no CRC
computation, no palette negotiation. The RP2040 receives pixels it can scan out
without touching them.
Along the way the RP2040 taught me its SPI slave folklore: the PL022 block is unreliable as a slave with CPHA=0. Every frame corrupted at 25MHz and at 2MHz - which ruled out signal integrity, because slowing down changed nothing. Both ends moved to mode 1 (CPHA=1) and the bytes came good. If an RP2040 slave ever hands you garbage that does not care about clock speed, reach for the mode bits first.
Villain 2: the chip select that lied by 16 bytes
With the raw protocol in, frames arrived... short. Every single one. The receiver counted bytes between chip-select edges and always came up a few bytes light, so no frame ever completed.
The H7's SPI driver resolves its DMA-complete future when the DMA finishes - but the SPI peripheral's TX FIFO still holds the tail of the frame, still shifting it out the wire. My code raised chip select the instant the future resolved, which told the Feather "frame over" while the last bytes were still in flight. A 40 microsecond wait before raising CS fixed it.
Then the receiver needed to stop trusting edges entirely. Its polling loop could notice a chip-select transition late, mark the frame start at the wrong byte offset, and deliver an image shifted by whatever the delay was - the picture waved, drifting a few pixels per frame like it was underwater. I filmed it, because nobody would believe a bug this pretty:
That is the cart library, technically. The whole frame is rolled by a wrong byte offset - the pink header bar belongs at the top - and every edge fringes in colour as the offset drifts. The stream format grew a self-locating header: eight preamble zeros (the PL022 slave mis-samples the first byte or two after CS asserts, so let it eat padding), then a four-byte magic, then the pixels. The receiver hunts the magic byte-by-byte and alignment stops depending on anyone's timing.
The last protocol lesson was about blast radius. A whole-frame checksum sounds prudent and is actually useless over a marginal wire: one flipped bit anywhere in 16KB kills the entire frame, and at the error rates I was seeing, zero frames survived. The wire does not need perfection demanded of it - it needs errors contained. So the checksum went per-line: each 128-byte row carries its own trailer byte, and a corrupt row costs that row, for one frame (the receiver keeps the previous frame's copy of it). A bad bit became a one-frame flicker on one line instead of a dead display.
// A whole frame is in the ring: verify each line by its checksum. Commit
// clean lines; a corrupt line keeps its previous-frame pixels - a bad bit
// costs one row for one frame, not the whole 16KB.
for (uint32_t row = 0; row < FRAME_H; row++) {
uint32_t sum = 0;
for (uint32_t col = 0; col < FRAME_W; col++) {
line[col] = ring[p++ & (RING_SIZE - 1)];
sum += line[col];
}
if ((sum & 0xFF) == ring[p++ & (RING_SIZE - 1)]) {
memcpy(&frame_buf[row * FRAME_W], line, FRAME_W);
} else {
bad++; // dropped line, previous frame's row shows for 1/60s
}
}
All of that was correct and none of it was the problem.
Villain 3: the missing ground return
Because after all the protocol hardening, the console displayed... and froze. Every few seconds it would stop dead, then lurch back. The per-line checksums were doing their job - which is exactly how I could see the real problem: the "bad lines" counter was climbing constantly. The wire was flipping bits at a rate no protocol could paper over, and the rate did not care whether I clocked at 8MHz or 2MHz. Clock-independent errors are not timing errors. They are a reference problem.
The SPI signals ran from one header to the Feather while the only ground between the boards wandered off through the power wiring. Every signal's return current has to flow somewhere, and that somewhere was a giant loop - the whole link was one big antenna, transmitting its own clock into everything nearby, including itself.
The fix was jumper wires: a ground beside the clock, a ground beside the data, routed with the signals. The freeze vanished instantly. Days of protocol suspicion, resolved by three inches of wire.

Rule learned, now written in the project docs: for any SPI-over-jumpers link, fix the ground return before touching the firmware.
Villain 4: the clock divider that lied to me
With the wire clean I went hunting framerate. Request 8MHz: 45fps. Request 12MHz: broken. Request 16MHz: broken identically. Suspiciously identically.
The H7's SPI divides its kernel clock by powers of two. The kernel clock was 100MHz, so the achievable rates were 6.25MHz and 12.5MHz - and nothing in between. My "12MHz" and "16MHz" requests had both silently become 12.5MHz. I had been A/B testing a knob that was not connected to anything. Every framerate I had ever observed mapped exactly onto the divider ladder: request 2MHz, get 1.5625 (12fps); request 4, get 3.125 (24fps); request 8, get 6.25 (45fps).
The way out was to stop accepting the ladder. The SAI audio subsystem already runs a PLL at 192MHz for its own clocking, and that PLL has a spare output with its own divider - and PLL dividers are not restricted to powers of two. Tune the spare output to 17.45MHz, route the SPI kernel mux to it, and the SPI's /2 lands on 8.727MHz: a rate that does not exist on the default ladder, fast enough for a full 60fps of frames, comfortably below the 12.5MHz cliff where the jumpers give up.
// The picodvi link's SPI kernel: PLL2_Q = 192MHz VCO / 11 = 17.45MHz, so the
// SPI's /2 divider lands on 8.727MHz - a rate the default power-of-two
// ladder (100MHz -> 6.25 / 12.5) simply cannot reach.
if let Some(pll) = config.rcc.pll2.as_mut() {
pll.divq = Some(PllDiv::DIV11);
}
config.rcc.mux.spi45sel = mux::Spi45sel::PLL2_Q;
Sixty-one frames per second out the H7, dead steady.
Villain 5: the GPIO port that mutes my DAC
And then the audio died. Again. This one had been stalking the project for days, coming and going with no visible pattern - and the pattern, once it finally surfaced, was the strangest fact of the whole saga.
The link had moved between two SPI peripherals during all this: SPI5, whose pins live on GPIO port F, and SPI4, whose pins live on port E. The audio subsystem's I2S pins - the bit clock and word clock feeding a PCM5102A DAC - also live on port E.
On SPI5/port F, audio played happily with the link at full speed. On SPI4/port E, the same clock, same protocol, same everything silenced it. Slowest link speed: audio fine. Anything faster: mute. I twisted wires, spread wires, re-routed wires. Nothing. Then the decisive test: pull the SPI wires out entirely and let the H7 drive open pins. Still mute. The interference was never on the wires. It was inside the chip, or at least inside that port.
The DAC side of the mechanism is documented and unforgiving. The PCM5102A runs its internal PLL from the bit clock and watches the clock relationship continuously; if the ratio looks invalid for more than four frames - about 91 microseconds - it hard-mutes to zero, and it un-mutes by itself once the clocks look clean. Which is exactly the behaviour I kept observing and kept failing to explain: silence, not crackle, arriving with the fast link and evaporating the moment I slowed it. A DAC that mutes on 91 microseconds of jitter is a very sensitive seismograph for whatever your other pins are doing.
The MCU side of the mechanism, honestly, remains open. The tidy theory - "same port means shared noise" - took damage when I checked the package pinout: the audio pins sit at one physical corner of the chip and the SPI4 pins on the opposite side, while the port F pins that work are physically closer to the audio corner than the port E ones that fail. ST's application notes acknowledge simultaneous-switching noise in one generic sentence and offer nothing about port-to-port victims. So I have an empirical law and no blessed mechanism, and I have made my peace with that, because the law is iron: this link lives on port F. Forever.

Moving the link back to SPI5 took one evening and both discoveries came along: the port F pins for audio's sake, the PLL rung for speed's sake. And for one glorious stretch the whole machine ran at once - console on the TV at 59fps, music playing, gamepad steering it - the first time every subsystem coexisted over HDMI.
Where it stands
Not finished. At the top two clock rungs the picture shows thin stripes on moving objects - dropped lines, the per-line checksums earning their keep, which on a static screen are invisible (a dropped line re-shows last frame's identical row) and on motion are not. The link wants one more step of physical dignity: proper twisted ground-return pairs, and eventually the protoboard layout that has been waiting in a drawer since before the Feather arrived - soldered joints, short runs, a ground beside every signal. The same layout would almost certainly hold the 12.5MHz rung stripe-free, which is more framerate than the console can even produce.
It is worth saying why this fight happened on this link and not the other one. The console's little SPI TFT panel runs at 25MHz and has never dropped a pixel - because its receiver is a purpose-built display ASIC that shifts bits into pixel RAM and shrugs off errors as one briefly-wrong pixel. The Feather's receiver is a general-purpose microcontroller's notoriously-weak SPI slave block, fed over the most re-plugged jumpers on the bench, running the only protocol in the system honest enough to verify every line it receives. Purpose- built silicon versus pretending: the panel gets to lie about its errors, and the HDMI link is not allowed to.
Scoreboard for the two days: one starved VM, one FIFO race, one waving image, one useless checksum design replaced by a useful one, one missing ground return, one power-of-two ladder bypassed through an audio PLL, one GPIO port convicted by an empty socket, and one DAC datasheet that explained a week of ghosts in a single paragraph. Four wires.