A DIR that switches off the screen: hunting a shared lock
On the Neo6502, in Hercules mode, the first DIR command after a cold start switched the screen off. Twelve fixes, eleven refuted by measurement, four miscalibrated instruments — and at the end, an SDK lock shared between the two cores, delaying the video interrupt by a single scanline. One line was enough.
The Neo6502 pairs a 65C02 with an RP2040: the latter does everything else, including DVI video output, through Luke Wren's PicoDVI library. Our Trinity firmware adds a "Hercules" mode: 720 × 350 monochrome inside a 720 × 480 60 Hz signal — a native mode, one screen line per image line, whereas the 320 × 240 mode doubles every line and therefore has twice the time.
The fault was reproducible and absurd: MODE 1 worked, then the first DIR switched the screen off. The second one went through untouched. The keyboard still answered during the failure. Only a mode change brought the picture back.
1. What measurement refuted, one hypothesis after another
The difference between the two commands was the FatFs cache: cold, the first reads twenty-two sectors from the USB stick; warm, almost nothing. Disk load was the trigger. What remained was how it broke the display.
We first suspected memory starvation, then a shortage of buffers, then bus arbitration, then a monitor losing lock. Each explanation was refuted by the next measurement, and two fixes shipped on that basis made things worse before being withdrawn:
- Four line buffers instead of two: the black screen became a full lock-up of both cores. A neighbouring project, pico-pacPlus, documents the opposite of what we hoped: "deeper line pools widen the window".
- Giving the memory bus priority to the video core: the screen went black as soon as Hercules mode started, without a single disk access.
- Lowering the clock from 270 to 250 MHz, with a 720 × 540 50 Hz timing borrowed from the ikjordan/PicoDVI fork. No effect at all. That was the right clue: if slowing down changes nothing, it is not a throughput problem.
We also tried a 720 × 400 70 Hz VGA timing — the MDA text mode one, exactly 720 pixels wide. The monitor finally announced a mode, which it had never done at 720 × 480 (a timing meant for televisions). But the picture came out wrong, and the lead was shelved.
2. The instrument falsifies the measurement, four times
The firmware's serial debug port never worked: the wire was never found on the expansion header. All instrumentation therefore went through the SWD probe, which reads the RP2040's memory while it runs. We wrote a tool that types on the machine's keyboard by writing straight into the firmware's key queue, and reads the counters — usable with a black screen.
That instrument lied to us four times, and each lie cost a hypothesis:
- Attaching the second core to the probe drops the encoder to 15 frames per second and manufactures a fault that does not exist. A whole run had to be thrown away.
- A miscalibrated overrun counter reported 96,000 events per second in normal operation: it ignored that each DMA channel is triggered once per block of its own lane.
- The latency counter was measuring the vertical blanking — 1,462 µs, always the same value, idle or loaded. It had to filter on duration, not on line index, because the firmware deliberately offsets that counter by two lines at start-up.
- A
resetfrom the probe leaves the board in a state where Hercules mode locks both cores. Several readings were taken that way, without that factor being controlled.
Rule adopted: for any measurement, start from a power cycle, never from a software reset. And never compare two runs that do not start from the same state.
3. What the black box showed
Every reading was taken after the fact, once the failure had settled. So we fitted a black box: at the exact moment the anomaly is detected, and before any repair, the firmware copies the full state of the six DMA channels and of the video state machine.
The result was unambiguous. For each of the three TMDS lanes, PicoDVI uses a data channel and a control channel that reprograms it. During the failure, two data channels carried the configuration of another lane — wrong request signal, wrong FIFO. Two channels were writing into the same FIFO, a third was no longer fed at all. The block lists in memory, however, were intact: the fault was not in the data, but in how it was loaded.
A photograph of the screen completed the picture better than ten readings: after a DIR, the text appeared doubled into a blue copy and a yellow one, some forty pixels apart. Yellow = red + green: the two other channels stayed aligned with each other, only blue slipped. Blue is the sync lane, the only one carrying four control blocks where the others carry two. A one-rank shift therefore does not move it by the same amount: the three lanes fall out of phase.
4. Making the fault harmless, before understanding it
From this came a fix that does not fix the cause: give every lane the same number of blocks, by splitting the blanking of the two data lanes the way the sync lane's is split. An overrun then shifts them all by the same phase, and the picture stays in one piece.
On the board, under four chained DIR commands: the overrun still happened (fifteen occurrences), but the three lanes moved together — zero phase divergence — and the text was sharp. The fault had become invisible.
Useful, not satisfying. The real question remained: why were the control channels taking a lap ahead?
5. The cause: a lock the two cores shared unknowingly
The latency measurement, finally filtered correctly, gave a modest and decisive figure. Between two active lines the nominal gap is 32 µs. During the DIR, the worst reading was 63 µs — that is one single line of delay, never more. No long block, no critical section of hundreds of microseconds: one line.
And one line is enough. During that delay the data channels finish their block and chain into their control channels, which then walk the list on their own — with nobody asking them to. They take a lap ahead, the lanes shift, and everything else follows.
What held the interrupt up? The answer was in the lock numbers. PicoDVI protects its four queues with spinlocks obtained from next_striped_spin_lock_num(). That SDK function hands out locks 16 to 23 round-robin among all callers — queues, mutexes, alarm pools, and whatever libraries use. Read on the board: PicoDVI held numbers 18 and 19, which FatFs and the USB stack could take from the other core.
The decisive detail is in the SDK documentation: spin_lock_blocking begins by disabling interrupts on the waiting core. When the application core held the lock during a disk burst, the video core waited for it, blind. Its scanline interrupt arrived one line late.
The SDK documents the trade-off plainly: these locks are shared, and drawing numbers from a range "reduces the probability" that two users land on the same one. Probability, not guarantee.
6. The fix is one line
// before
dvi_init(&dvi0, next_striped_spin_lock_num(), next_striped_spin_lock_num());
// after
static int slTmds = -1, slColour = -1;
if (slTmds < 0) { slTmds = spin_lock_claim_unused(true); slColour = spin_lock_claim_unused(true); }
dvi_init(&dvi0, slTmds, slColour);
Claimed locks that nobody else can take (numbers 24 and 25, outside the shared range). Measured on the board, cold DIR of twenty-two sectors, two runs including one from a power cycle:
| before | after | |
|---|---|---|
| channel overruns | 9 to 15 | 0 |
| gap between two lines | 63 µs | 33 µs (nominal) |
| late lines during the DIR | +10 to +16 | +2 |
| screen | black, or doubled text | perfect |
The fault no longer occurs. This is no longer a workaround.
7. Why nobody else has seen it
We looked at how the other PicoDVI projects initialise the library: upstream, the ikjordan fork, the xep80 fork, pico-pacPlus. All follow the pattern from the original example, next_striped_spin_lock_num() — and it suits them. The SDK's trade-off only shows in a particular situation, which needs three conditions at once:
- sustained I/O on the application core — a burst of sectors, not an isolated read;
- a native video mode, with no line doubling, hence no catch-up margin;
- the draw that assigns the same lock to both subsystems.
Emulators that load programs from an SD card meet the first two. We do not know whether others have hit this case: it takes a probe and a DMA channel overrun counter to see, and we have not found it described anywhere. That is the reason for this article — if the signature rings a bell for someone, it will save them the two days it cost us.
8. What this hunt leaves behind
Twelve fixes, eleven refuted. Four false measurements that had to be recalibrated. And two things that actually unlocked the case, neither of them from a reading: a photograph of the screen, showing that a single lane was slipping, and reading the lock numbers, which gave the cause.
A fix judged by its own counters is a fix not judged. Twice in the same evening, every indicator was green while the screen showed nothing.
This fault's signature is worth remembering, because it is generic to the RP2040: a delay of exactly one time unit of the real-time system — one scanline, here — insensitive to clock frequency. It is not a throughput problem: it is somebody holding a lock.
Sources & links
- Wren6991/PicoDVI — the original library, bit-banged DVI on the RP2040.
- ikjordan/PicoDVI — a fork with 50 Hz modes and 720-pixel widths.
- pico-pacPlus — dropped buffers, both cores stalling, red lines: a neighbouring fault, documented.
- pico-sdk — hardware_sync — the “striped” locks and their trade-off, in the SDK's own comments.
- neo6502.com — the Olimex board, firmware by Paul Robson.