07 · display

Display from black

Bringing up an entire MIPI DSI stack — VOP, DPHY PLL, DW MIPI DSI bridge, HX8394 panel — line by line.

◐ partial

This isn’t a single bug arc. It’s a construction story. The PinePhone Pro’s display is a Hannstar HSD060BHW4 panel — 720x1440 at 60 Hz — driven by a Himax HX8394 controller IC that takes commands over MIPI DSI. The DSI bridge inside the RK3399S is a Synopsys DesignWare core wrapped in a Rockchip platform glue. The pixels come from the RK3399 VOP (Visual Output Processor), which has its own scanout PLL and a register-shadow latching scheme that requires explicit “commit” writes. None of this had a FreeBSD driver. Each layer had to be ported, and each layer has its own contract that nothing in the kernel enforces.

The journey was: U-Boot brings up display, then FreeBSD’s kernel resets the SoC’s clocks at attach, blanking the display before any DRM code runs. So we got smart and tried to “preserve” U-Boot’s display state. That mostly didn’t work. Then we ported the entire bridge from Linux. Then we ported the panel. Then the display turned on, and it was glitchy and green for a week.

Figure · DSI pipeline

framebuffer
     │
     ▼
┌────────────┐                      ┌──────────────┐
│    DRM     │  ── atomic commit ──▶│  rk_drm/vop  │
│  scheduler │                      │  (CRTC + plane)
└────────────┘                      └──────┬───────┘
                                           │
                            REG_CFG_DONE = 0x01 (latch shadow regs)
                                           │
                                           ▼
                                   ┌────────────────┐
                                   │   VOP scanout  │ ──────── pixel clock
                                   │   (RK3399 VOP) │           ↓
                                   └───────┬────────┘     ┌──────────┐
                                           │              │  DPHY PLL│
                                           ▼              │  (RKisp) │
                                   ┌────────────────┐     └────┬─────┘
                                   │ DW MIPI DSI    │◀─────────┘
                                   │ bridge core    │  HS clock + 4 lanes
                                   │ (Synopsys)     │
                                   └───────┬────────┘
                                           │
                            MIPI DSI command + pixel stream
                                           │
                                           ▼
                                   ┌────────────────┐
                                   │ HX8394 panel   │
                                   │ (Himax IC +    │
                                   │  HSD060BHW4)   │
                                   └────────────────┘

Five drivers had to come up in order, each holding the next one’s prerequisites. The wrong-guess hypotheses were strategy-level — we tried to skip layers, and the layers wouldn’t be skipped.

[WAR STORY]

EFI framebuffer is enough (it isn't)

display / boot

▸ symptom

Boot the phone. EFI framebuffer mode comes up via U-Boot’s simplefb. Console renders via vt(4). X11 even runs against scfb software framebuffer. Screen is sharp, refresh works, you can read kernel messages. Why bother with DRM?

▸ hypothesis 1

We don’t need DRM. EFI framebuffer is preserved across kernel handoff; vt(4) paints into it; X11’s scfb driver scans it out. This is the path most arm64 board ports take for the first six months. Just live with it.

This works for about two boots. Then you load rk_gpio (or anything else that calls clk_set_rate on a parent of the display PLL chain), and the next register write to the VOP shadow scribbles uninitialized garbage into a scanned-out plane. The screen turns green. Or grey. Or strobing. The EFI framebuffer is just a chunk of pinned RAM; once anything in the SoC clock graph below it changes, the pixels stop being valid even if the framebuffer is still mapped.

The kernel’s CRU driver (rk_cru.c) doesn’t know that U-Boot configured dclk_vopb and pll_vpll to specific values — it sees clocks at attach, runs through its initialization, and on RK3399 specifically calls clk_set_parent on the VPLL to its default state. Which is “off”. The display pixel clock dies. No driver claims responsibility.

▸ hypothesis 2

“Just preserve U-Boot state.” Walk the device tree, find the display nodes, mark their clocks/regulators/PHYs as assigned-state, tell the CRU to leave them alone. Linux has assigned-clocks and assigned-clock-parents properties that do exactly this. Implement that handler in rk_cru.c.

Partially worked. The clock chain stopped being stomped at boot. But the VOP itself is full of shadow registers that latch on a REG_CFG_DONE = 0x01 write — if you don’t issue that latch, the VOP keeps scanning the previous timing values regardless of what you’ve programmed. And U-Boot left the VOP in a configured-but-not-currently-issued state, where its visible registers reflected mode A and its shadow registers held mode B, and our first programming attempt latched mode B with our partial-update mode A bits, producing a hybrid that the panel rejected.

▸ breakthrough

You cannot half-own the display pipeline. Either U-Boot owns it and the kernel never touches CRU/VOP/DSI registers, or the kernel owns it and brings up the entire stack from a known reset state. There is no working middle ground. The implementation strategy split: the CRU patches stay (so we don’t blank the display before DRM is ready), but the VOP/DSI/panel get reset to a clean state and brought up by the kernel as if U-Boot had done nothing.

So we stopped trying to skip layers and ported each one.

▸ fix

This is what shipped in 3462965 MIPI DSI pipeline: full driver stack, VOP outputting frames as a single 2,135-line stack:

  1. src/sys/dev/drm/core/drm_mipi_dsi.c — 525 lines of MIPI DSI host framework. Generic packet framing, command queue, peripheral binding. Almost a direct port of Linux’s drivers/gpu/drm/drm_mipi_dsi.c with FreeBSD device model glue substituted for Linux’s device machinery.

  2. src/sys/dev/drm/bridges/dw_mipi_dsi/dw_mipi_dsi.c — 762 lines of Synopsys DW MIPI DSI bridge core. Register programming for DPI input config, packet-handler config, lane-management, and the LP/HS state machine. Port from drivers/gpu/drm/bridge/synopsys/dw-mipi-dsi.c line-by-line; the Synopsys IP is identical between Rockchip’s RK3399 instance and the i.MX8 instance Linux primarily develops against.

  3. src/sys/dev/drm/bridges/dw_mipi_dsi/rk_dw_mipi_dsi.c — 781 lines of Rockchip platform glue. The interesting part is the DPHY PLL tuner. The RK3399 has a PLL inside the DSI block (separate from VPLL!) that generates the DSI bit-clock from a reference. The Linux driver computes PLL coefficients by table lookup; we ported the table. For 720x1440@60Hz the math is pixel_clock = 74.25 MHz, bits_per_pixel = 24, lanes = 4, so the per-lane bit rate is 74.25 * 24 / 4 = 445.5 Mbps, and the DPHY PLL gets configured with M=37, N=1, S=1 to produce a 446 MHz HS clock. Get that wrong and the panel sees gibberish.

  4. src/sys/dev/drm/panels/panel_hx8394.c — 273 lines of HX8394 init sequence. The HX8394 needs ~120 bytes of vendor-specific DCS commands sent over DSI in command mode before it will accept pixel data. Lifted directly from Linux’s drivers/gpu/drm/panel/panel-himax-hx8394.c (hsd060bhw4 variant). The sequence is undocumented outside that file; the Linux driver attributes it to “vendor BSP”, which is industry shorthand for “we don’t know what these bytes do, but the panel won’t initialize without them.”

  5. src/sys/dev/drm/rockchip/rk_vop.c — VOP MIPI output enable (SYS_CTRL_MIPI_OUT_EN bit), polarity, and crucially the REG_CFG_DONE = 0x01 shadow-latch write at the end of every atomic commit. Without that final write, you can program the entire VOP register file and it will be ignored.

There were also CRU patches in patches/ that mark the VPLL as preserved-from-U-Boot, so the early-boot blanking doesn’t happen. Those stay.

When the display first lit up — green, glitchy, drawing about 20 frames before stalling — I sent the message: “it’s green i worry it might be breaking it. i rebooted the phone to be sure the panel still works and it does.” It was a relief. Two weeks of black screens followed by a recognizably alive panel, even one painting wrong colors, told us the pipeline was fundamentally connected end to end.

▸ lesson

A modern display pipeline is a five-layer protocol stack. You cannot bring it up incrementally from the top, because every layer has prerequisites the layer above it can’t observe. You also can’t share it with the bootloader — the contract for “your shadow register state” cannot be expressed across the kernel handoff. Pick: the kernel owns the display, or it doesn’t. If it does, port all five drivers. If it doesn’t, never touch the CRU.

The first version painted ~20 frames and stopped. The next bugs were in DRM commit/vblank timing. c11b579 vop: fix atomic commit completion — move event to atomic_flush only — both atomic_begin and atomic_flush were consuming crtc->state->event; atomic_begin took it first, leaving atomic_flush with NULL and never signaling flip_done. Move event consumption to atomic_flush only, after REG_CFG_DONE is written.

24af106 vop: enable vblank interrupt in U-Boot preserve path — the U-Boot-preserve path skipped enabling INTR_EN0_FS_INTR along with skipping the full enable sequence. drm_crtc_vblank_on was called but no vblank IRQ ever fired, so the first commit after boot timed out in wait_for_vblanks. One register write fixed it.

736d191 vop: send commit event immediately instead of arming for vblank — during initial modeset, before vblank counting stabilized, arming the event could time out and panic. Send immediately. Trades vsync precision for crash-free boot. Tech debt, accepted.

The status is ◐ partial : console renders, X11 runs, sway runs, hyprland mostly runs (with some explicit-sync issues that are GPU-side, not display-side — see essay 8). Visible artifacts remain: occasional first-frame tearing on resume, an intermittent green flash when the VOP reconfigures from one mode to another. Those are tracked but unprioritized.