evidence · 4.5 KiB

dsi-burst-headroom.md

Committed at docs/evidence/dsi-burst-headroom.md. This is what grounds the requirements that cite it.

Open raw source

The 25% DSI burst headroom blanked the panel

2026-09-21. Commit e3a9c8a multiplied the DSI PHY bit clock by 5/4 in canaan_dsi_clk_cfg() to give a burst-mode link some slack. It was built into the image written at 17:12 and never visually confirmed. It blanks the panel completely.

How it was found

The panel had been lit and photographed (panel-lit.md). It went dark and stayed dark across reboots and a full power cycle. Ruled out, each by measurement on the board rather than by reasoning:

hypothesistestresult
framebuffer is all zeroeswrote 1.4 MB /dev/urandom, then all-0xFF, to /dev/fb0still black
the pushed touch DTB broke itdecompiled both DTBs and diffedonly interrupts type + touchscreen-size-* differ; zero display deltas
…still the DTBpushed the image’s original DTB back, rebootedstill black
console blanking (consoleblank, default 600 s)black 35 s after a cold bootnot blanking
panel power / reset GPIO/sys/kernel/debug/gpiodsi_reset hi, backlight_gpio hi
display power domain offpm_genpd_summarydisp_domain on, subsystem active
display clocks gatedclk_summaryall disp_* enabled
panel latched in a bad stateuser power-cycled the boardstill black

Every status bit the driver exposes said the display was on. DRM reported connected / enabled / dpms On. That is the same trap as panel-dark.md and dsi-phy-hang.md: a driver’s own opinion of itself is not evidence.

What broke the tie was the persistent journal. journalctl --list-boots held all four boots of this card, and the display lines are byte-identical in every one of them — including the first. So this card has never lit the panel, which dates the regression to the image, not to anything done at runtime. The only kernel-affecting commit between the last lit image and this one is e3a9c8a.

The confirming experiment

The headroom is a kernel patch, so it cannot be pushed over serial. But the PHY clock is derived from the DTB’s pixel clock:

phy_clk_freq = pclk * 3 * 8 / lanes / 2   = pclk * 6      (2 lanes, 24bpp)
phy_clk_freq = phy_clk_freq * 5 / 4                       (e3a9c8a)

so scaling clock-frequency down by 4/5 cancels the patch exactly. fdtput on the DTB, pushed with tools/push-file.py:

pclkPHY bit clockpanel
49 500 000 (shipped)371.25 MHzblack
31 680 000237.6 MHz — the rate that was litlights, console legible

Photograph: panel-photos/06-sheared-console-31.68mhz.jpg.

Why it fails

At 31.68 MHz the panel lights but every scanline is sheared progressively to the right, drawing diagonal bands across the frame, and the image ripples. That is a line-length mismatch: the panel is clocked for one number of bytes per line and fed another.

The VO’s pixel timing and the DSI byte clock are not independent in this driver. Nothing recomputes the video-mode timing registers when the PHY clock moves, so scaling the PHY alone desynchronises them. This also means no DTB value can work around the patch — any clock-frequency leaves the two clocks off by exactly the same 5/4.

The fix is to drop the patch, which restores pclk * 6 and the LILYGO 52 Hz timings from 6978dde — the last state seen working.

What is still open

The flicker that motivated the patch is real and unfixed: stripe jitter -9..+1 px with no monotonic drift over 30 s, and 19 irregular brightness dips in 30 s, one to 53% of mean. Whatever addresses it has to move the VO timing and the DSI byte clock together.

Postscript: it also moved the VCO bucket

Reading the source the corrected kernel was actually built from (30kiycz38bv840qdzdzzjyvniyh5camf-linux-xuantie-k230-src, canaan_dsi.c:366), phy_clk_freq is in kHz and immediately feeds a ladder of range comparisons that select voc:

phy_clk_freq = dsi->clk_freq * 3 * 8 / device->lanes / 2;
if (phy_clk_freq > 1250000 || phy_clk_freq < 40000) ...
else if (phy_clk_freq < 55000)  ...
...
else if (phy_clk_freq < 330000) ...
else if (phy_clk_freq < 440000) ...

297 000 kHz lands in the < 330000 bucket. 371 250 kHz lands in the < 440000 bucket. So the patch did not merely raise the bit clock, it silently reprogrammed the PHY’s VCO range as well. The original commit message called that out as “intended — the bucket is chosen from the PHY frequency, so it should follow it.” It follows the PHY frequency correctly and still breaks, because nothing moved the VO’s pixel timing to match.