evidence · 27.3 KiB

rtsmart-panel-bringup.md

Committed at docs/evidence/rtsmart-panel-bringup.md. This is what grounds the requirements that cite it.

Open raw source

How LilyGO’s RT-Smart firmware brings up the RM69A10, end to end

Read against our Linux path. Written 2026-09-21 from the vendor source in repo/canmv_k230/src/rtsmart/mpp/ plus disassembly of the prebuilt kernel/lib/libvo.a, compared with the pristine kernel tree at /nix/store/pn7bm6ck5a3x6pnk1ng30hlf24qn85jx-source/.

Everything below is either quoted source or disassembly I read. Where I am inferring, it says so.


The headline: we are sending the wrong panel’s init sequence

docs/evidence/rm69a10-init-sequence.md records the sequence as coming from “rm69a10_568x1232_dsi_send()”. There is no function by that name. What was actually transcribed is rm69a10_OLD_init(), and that function has no callers.

$ grep -rn "rm69a10_OLD_init\|rm69a10_568x1232_init" repo/canmv_k230/src/rtsmart/mpp
kernel/connector/src/rm69a10.c:39:static void rm69a10_OLD_init(k_u8 test_mode_en)
kernel/connector/src/rm69a10.c:96:static void rm69a10_568x1232_init(k_u8 test_mode_en)
kernel/connector/src/rm69a10.c:100:    rt_kprintf("rm69a10_568x1232_init \n");
kernel/connector/src/rm69a10.c:345:			   rm69a10_568x1232_init(1);
kernel/connector/src/rm69a10.c:347:			   rm69a10_568x1232_init(0);

One definition, zero call sites. The live dispatcher is rm69a10.c:342-348:

	if(info->type == RM69A10_MIPI_2LAN_568X1232_60FPS)
	   {
		   if(info->screen_test_mode)
			   rm69a10_568x1232_init(1);
		   else
			   rm69a10_568x1232_init(0);
	   }

and our panel is exactly that type — mpi_connector.c:43 sets RM69A10_MIPI_2LAN_568X1232_60FPS for the "rm69a10" entry.

It is not merely unused, it is a different panel’s sequence

rm69a10_OLD_init() (rm69a10.c:41-61) is a byte-for-byte copy of hx8399_v2_init() (hx8399.c:41-61) — the Himax HX8399 1080x1920 panel. Same twenty k_u8 param* arrays, same values, same order, same pag20[50] blue test-pattern array, same 0xB9 0xFF 0x83 0x99 opener (which is Himax’s SETEXTC “password”). It is dead copy-paste left in the file when the driver was forked from hx8399.c.

Our nix/dts/display-rm69a10-568x1232.dtsi:54-75 ships that HX8399 sequence. Twenty commands, 297 payload bytes, verified byte-identical — to the wrong panel.

Confirmed on the vendor’s own boot log

docs/rtsmart-boot-log.txt lines 71-82, captured from this board running LilyGO’s firmware:

rm69a10_power_on
rm69a10_power_reset
rm69a10 rst_gpio is 22
rm69a10_power_reset
rm69a10 rst_gpio is 22
rm69a10_power_reset
rm69a10 rst_gpio is 22
rm69a10_set_backlight
rm69a10_init
rm69a10_set_phy_freq
rm69a10_dsi_resolution_init
rm69a10_568x1232_init
rm69a10_vo_resolution_init

rm69a10_568x1232_init prints its own name (rm69a10.c:100). It is what runs on the shipped firmware.

Caveat on provenance: the source in repo/ also has rt_kprintf("connecter_dsi_send_pkg: %d \n", ret) at rm69a10.c:140 and two read printfs at :160/:163, and those lines do not appear in the boot log. So the flashed binary is probably an older build of the same function, before those debug printfs were added. That does not affect the conclusion — the function name printed is the dispatcher’s, and the dispatcher has only ever had one branch — but the body might differ in detail from the snapshot we hold. I could not check: there is no RT-Smart image in firmware/, only U-Boot/SPL/OpenSBI blobs.

The sequence that actually runs

rm69a10.c:114-163, in order. Each connecter_dsi_send_pkg() is followed by an unconditional 3 ms delay inside the transport (see below).

#linebytesDCS meaning
1116,139fe fdmanufacturer command page select (Raydium)
2117,14180 fcpage-0xFD register write
3118,142fe 00back to user command set
4119,1432a 00 00 02 37set_column_address 0..567
5120,1442b 00 00 04 cfset_page_address 0..1231
6121,14531 00 03 02 34set_partial_columns 3..564
7122,14630 00 00 04 cfset_partial_rows 0..1231
8123,14712 00enter_partial_mode
9124,14835 00set_tear_on
10125,14951 fewrite_display_brightness = 0xFE
150connector_delay_us(2000)
11126,15111exit_sleep_mode
152connector_delay_us(120000) — 120 ms
12127,15329set_display_on
13130,1563a 77set_pixel_format = 24 bpp
159,162connecter_dsi_read_pkg(0x05), (0x0C)

Thirteen commands, 36 payload bytes. Not one of them appears in what we send. Three differences stand out as sufficient on their own to keep an AMOLED dark:

Also note 0x3A 0x77 (pixel format) is sent after 0x29, and the manufacturer page dance 0xFE 0xFD / 0x80 0xFC / 0xFE 0x00 opens the sequence. Neither is expressible as “just append the missing commands” — the order matters.


1. The ordered bring-up, power-on to lit panel

1.0 Power domain — on device open

connector_dev.c:53, in connector_dev_open():

    sysctl_pwr_up(SYSCTL_PD_DISP);

Same thing we now do with pm_runtime_get_sync() pinned at probe. No divergence.

1.1 kd_mpi_connector_power_set(fd, 1) → rm69a10_power_on()

rm69a10.c:282-307:

    if (on) {
        // rst vo;
        k230_display_rst();
        // rst rm69a10
        rm69a10_power_reset(1);
        rt_thread_mdelay(g_blacklight_delay_ms);
        rm69a10_power_reset(0);
        rt_thread_mdelay(10);
        rm69a10_power_reset(1);
        rt_thread_mdelay(20);
        g_blacklight_delay_ms = DELAY_MS_BACKLIGHT_DEFAULT;

        //enable backlight
        rm69a10_set_backlight(1);
    }

g_blacklight_delay_ms is DELAY_MS_BACKLIGHT_FIRST = 1 on the first call (rm69a10.c:33,37) and 200 thereafter. So on a cold boot:

display reset (below)
GPIO22 = 1
   1 ms
GPIO22 = 0
  10 ms
GPIO22 = 1
  20 ms
GPIO25 = 1   (backlight)

rm69a10_power_reset() (rm69a10.c:248-263) does kd_pin_mode(rst_gpio, GPIO_DM_OUTPUT) then writes GPIO_PV_HIGH/GPIO_PV_LOW; active-low reset, GPIO22. rm69a10_set_backlight() (rm69a10.c:265-279) drives GPIO25 high.

Pin numbers from repo/canmv_k230/include/generated/autoconf.h:37,57: CONFIG_MPP_DSI_LCD_BACKLIGHT_PIN 25, CONFIG_MPP_DSI_LCD_RESET_PIN 22.

1.2 k230_display_rst() — SoC display-subsystem reset

Not in the RT-Smart source tree; it lives in kernel/lib/libvo.a (kd_vo_reg.o, symbol k230_display_rst). Disassembled:

writel(0x00000000, 0x91101090)     ; lui 0x9110 + 0x109, slli 4 -> 0x91101090
rt_thread_mdelay(1)
writel(0xffffffff, 0x91101090)
rt_thread_mdelay(1)
writel(0x0, dsi_base + 0x04)       ; PWR_UP = 0  (DSI host held in reset)
kd_vo_software_reset()

and kd_vo_software_reset() (same object):

writel(0xf, vo_base + 0x00)        ; VO_SOFT_RST_CTL
writel(0x0, vo_base + 0x08)
writel(0x0, vo_base + 0x04)        ; VO_REG_LOAD_CTL

This is the first thing the vendor does, on the power-on path, before the panel reset pulse.

1.3 kd_mpi_connector_init(fd, info) → rm69a10_init()

rm69a10.c:379-393:

    if(info->pixclk_div != 0)
        connector_set_pixclk(info->pixclk_div);

    ret |= rm69a10_set_phy_freq(&info->phy_attr);
    ret |= rm69a10_dsi_resolution_init(info);
    ret |= rm69a10_vo_resolution_init(&info->resolution, info->bg_color, info->intr_line);

The table entry driving all of this is mpi_connector.c:32-44:

     {
        "rm69a10",
        0,                                     /* screen_test_mode */
        0,                                     /* dsi_test_mode */
        BACKGROUND_BLACK_COLOR,
        9,                                     /* intr_line */
        14,                                    /* pixclk_div */
        K_DSI_2LAN,
        K_BURST_MODE,
        K_VO_LP_MODE,
      	{ 4, 97, 0x19, 0x96 },                 /* n, m, voc, hs_freq */
        { 39600, 475200, 788, 568, 40, 140, 40, 1268, 1232, 4, 16, 16  },
        RM69A10_MIPI_2LAN_568X1232_60FPS,
    },

field order from k_connector_comm.h:80-93; k_connectori_phy_attr is {n, m, voc, hs_freq} (k_connector_comm.h:71-77).

1.4 Pixel clock — k230_set_pixclk(14)

connector_comm.c:200-203 → k230_set_pixclk() in libvo.a:

reg  = readl(clk_base + 0x78)      ; clk_base = 0x91100000
reg &= ~0x7F8                      ; GENMASK(10,3)
reg |= div << 3                    ; div = 14
reg |= 0x80000000
writel(reg, clk_base + 0x78)

Identical to canaan_dsi.c:376-381, which writes (div - 1) << 3 with div = round(594000/39600) = 15, i.e. also 14. No divergence — except in when it happens (see §2.7).

1.5 PHY — rm69a10_set_phy_freq() → connector_set_phy_freq() → dwc_mipi_phy_config()

rm69a10.c:310-325:

    mipi_phy_attr.m = phy_attr->m;
    mipi_phy_attr.n = phy_attr->n;
    mipi_phy_attr.hs_freq = phy_attr->hs_freq;
    mipi_phy_attr.voc = phy_attr->voc;
    mipi_phy_attr.phy_lan_num = K_DSI_2LAN;
    connector_set_phy_freq(&mipi_phy_attr);

connector_comm.c:155-158 forwards to dwc_mipi_phy_config(), which is in libvo.a. Disassembled in full (it is 0x62 bytes):

writel(0x0, phy_base + 0x0c)                 ; phy_base = dsi_base + 0x400
a0 = attr[0x04]   (m)
a1 = attr[0x00]   (n)
a2 = attr[0x08] & 0xff   (voc)
a3 = attr[0x10] & 0xff   (hs_freq)
call k230_dsi_config_4lan_phy(m, n, voc, hs_freq)
return 0

phy_lan_num is at offset 0x0c of k_vo_mipi_phy_attr (k_vo_comm.h:256-262) and is never loaded. The only reference to offset 0x0c in the function is the writel(0x0, phy_base + 0x0c) at entry, which is a register write to dsi_base + 0x400 + 0x0c, not a struct read.

See §3 for what this means.

k230_dsi_config_4lan_phy, k230_dsi_phy0_config, k230_dsi_phy1_config, k230_dsi_phy_pll_config and k230_dsi_write_phy_reg all exist in libvo.a under exactly those names, and the disassembly matches canaan_phy.c instruction for instruction in structure. Diffs are in §2.

1.6 DSI host — rm69a10_dsi_resolution_init()

rm69a10.c:328-355:

    attr.lan_num = info->lan_num;          /* K_DSI_2LAN == 1 */
    attr.cmd_mode = info->cmd_mode;        /* K_VO_LP_MODE == 0 */
    attr.lp_div = 8;
    attr.work_mode = info->work_mode;      /* K_BURST_MODE == 0 */
    memcpy(&attr.resolution, &resolution, ...);
    connector_set_dsi_attr(&attr);
       ... init sequence ...
    connector_set_dsi_enable(1);

connector_set_dsi_attr → kd_dsi_dsi_attr() (kd_vo.o):

dwc_dsi_set_lan_num(attr->lan_num)     ; attr+0x30
dwc_dsi_set_lpdt_div(attr->lp_div)     ; attr+0x3c
dwc_dsi_set_vcid()
dwc_dsi_set_timing(attr)
if (attr->cmd_mode == 0) dwc_dsi_set_lpdt_mode(); else dwc_dsi_set_hs_mode();
if (attr->work_mode != 0) dwc_set_non_brust_mode(attr->work_mode);

For our panel: LP command mode, and burst mode means dwc_set_non_brust_mode() is not called.

The individual writes, all to dsi_base (DWC MIPI-DSI-Host v1.31 offsets; the names are my mapping, the offsets and values are read out of the binary):

functionregistervalue
dwc_dsi_set_lan_num0xa4 PHY_IF_CFG0x2800 + lan_num → 0x2801
dwc_dsi_set_lpdt_div0x08 CLKMGR_CFG0x100 + lp_div → 0x108
dwc_dsi_set_vcid0x30 GEN_VCID0x303
dwc_dsi_set_timing0x3c VID_PKT_SIZEhdisplay
0x40 VID_NUM_CHUNKS0
0x44 VID_NULL_SIZE0
0x48 VID_HSA_TIMElbcc(hsync_len)
0x4c VID_HBP_TIMElbcc(hback_porch)
0x50 VID_HLINE_TIMElbcc(htotal)
0x54 VID_VSA_LINESvsync_len
0x58 VID_VBP_LINESvback_porch
0x5c VID_VFP_LINESvfront_porch
0x60 VID_VACTIVE_LINESvdisplay
dwc_dsi_set_lpdt_mode0x34 MODE_CFG0x1 (command mode)
0x38 VID_MODE_CFG0xbf02
0x68 CMD_MODE_CFG0x10f7f01
0x2c PCKHDL_CFG0x1c
0x04 PWR_UP0x1

lbcc is dw_mipi_dsi_get_hcomponent_lbcc(hcomp, phyclk, pclk) = hcomp * phyclk / 8 / pclk, rounded up — same formula as canaan_dsi_get_hcomponent_lbcc() (canaan_dsi.c:201-215).

This block is identical to Linux canaan_dsi_lpdt_init() (canaan_dsi.c:258-284) plus canaan_dsi_set_lan_num() (canaan_dsi.c:183-199). No divergence at all.

dwc_dsi_set_hs_mode() exists and differs (PCKHDL_CFG = 0x38, plus LPCLK_CTRL = 3) but is not taken for this panel, and Linux has no equivalent. Noted for completeness only.

1.7 The init sequence — transport

connecter_dsi_send_pkg() (connector_comm.c:145-148) → dwc_lpdt_send_pkg() in libvo.a. Disassembled:

if (cmd_len <= 1):
    hdr = 0x05 | (buf[0] << 8)                  ; DCS short write, vcid 0
    writel(hdr, dsi_base + 0x6c)                ; GEN_HDR
else:
    for each 4-byte chunk of buf:
        writel(chunk, dsi_base + 0x70)          ; GEN_PLD_DATA
    hdr = 0x39 | (cmd_len << 8)                 ; DCS long write, vcid 0
    writel(hdr, dsi_base + 0x6c)
rt_thread_mdelay(3)                             ; <-- unconditional, every packet

Two things:

There is no FIFO-full polling in the vendor path at all — the 3 ms is the flow control.

1.8 connector_set_dsi_enable(1) → dwc_dsi_enable()

connector_comm.c:165-168 → dwc_dsi_enable() in libvo.a:

call dwc_dsi_init()
writel(0x1, dsi_base + 0x94)       ; LPCLK_CTRL

and dwc_dsi_init():

call dwc_dsi_set_color_coding()    ; writel(0x105, dsi_base + 0x10)  DPI_COLOR_CODING
writel(0x320068, dsi_base + 0x9c)  ; PHY_TMR_CFG
writel(0x2e0080, dsi_base + 0x98)  ; PHY_TMR_LPCLK_CFG
writel(0xffffffff, dsi_base + 0xc4); INT_MSK0
writel(0xffffffff, dsi_base + 0xc8); INT_MSK1
writel(0x0, dsi_base + 0x34)       ; MODE_CFG  -> video mode
writel(0x0, dsi_base + 0x68)       ; CMD_MODE_CFG
writel(0x3, dsi_base + 0x94)       ; LPCLK_CTRL

Identical, in the same order, to Linux canaan_mipi_dsi_set_dsi_enable() (canaan_dsi.c:286-297). No divergence.

1.9 VO — rm69a10_vo_resolution_init()

rm69a10.c:358-376:

    attr.bg_color = bg_color;
    attr.intf_sync = K_VO_OUT_1080P30;
    attr.intf_type = K_VO_INTF_MIPI;
    attr.sync_info = resolution;

    connector_set_vo_init();
    connector_set_vtth_intr(1, intr_line);
    connector_set_vo_param(&attr);
    connector_set_vo_enable();

From libvo.a:

Note the vendor brings the VO up last, after the DSI link is already in video mode.


2. Diff list: what RT-Smart does that our Linux path does not

Ordered by how likely each is to be the reason the panel is dark.

2.1 The init sequence is a different panel’s (fatal, near-certain)

2.2 No SoC display reset on the enable path

2.3 Panel reset is stale and mistimed

2.4 3 ms between every DCS command

2.5 vco_cntrl differs

2.6 intr_line / vth_line

2.7 Bring-up order is inverted: VO before DSI vs DSI before VO

2.8 Smaller, probably harmless

vendorours
DSI host register blockidentical writes, identical order (§1.6, §1.8)—
PHY register blockidentical (§3)—
PHY_TST_CTRL0 settle in phy1_configrt_thread_mdelay(1)msleep(20) (canaan_phy.c:174)
post-PHY_STATUS settle in config_4lanrt_thread_mdelay(1)msleep(20) (canaan_phy.c:252)
PHY_TST_CTRL1 != 0x580 loopunboundedbounded at 1000 (canaan_phy.c:230-231)
PHY_STATUS != 0x1fbd loopsunboundedunbounded upstream; bounded in our patched tree
VO backgroundbg_color from the tablehard-coded 0xffffff (canaan_vo.c:660)
layer0_test() in vo_initcalledno equivalent
DCS read busy pollreads 0xb0 bit 6 — looks like a vendor bug, should be 0x74we poll 0x74 correctly

3. The 2-lane question, answered: it is a non-issue

The leading hypothesis in docs/evidence/dsi-phy-hang.md — that the Linux driver programs the D-PHY for four lanes because canaan_phy.c exports only k230_dsi_config_4lan_phy() — is wrong, and the vendor binary proves it.

dwc_mipi_phy_config() is the only consumer of phy_lan_num, and it does not consume it. Full disassembly (libvo.a, kd_vo_reg.o, .text.dwc_mipi_phy_config, 0x62 bytes):

   8: sd   a0, -0x18(s0)               ; save attr pointer
  10: auipc a5, display_remap          ; phy base = remap[1] (dsi_base + 0x400)
  1c: addi a5, a5, 0xc
  20: li   a0, 0x0
  22: call __raw_writel                ; writel(0, phy_base + 0x0c)
  2a: ld   a5, -0x18(s0)
  2e: lw   a4, 0x4(a5)                 ; attr->m
  30: ld   a5, -0x18(s0)
  34: lw   a1, 0x0(a5)                 ; attr->n
  36: ld   a5, -0x18(s0)
  3a: lw   a5, 0x8(a5)
  3c: zext.b a2, a5                    ; attr->voc
  40: ld   a5, -0x18(s0)
  44: lw   a5, 0x10(a5)
  46: zext.b a5, a5
  4a: mv   a3, a5                      ; attr->hs_freq
  4c: mv   a0, a4
  4e: call k230_dsi_config_4lan_phy    ; (m, n, voc, hs_freq)
  56: li   a5, 0x0

Struct offsets from k_vo_comm.h:255-262:

typedef struct
{
    k_u32 n;            /* +0x00 */
    k_u32 m;            /* +0x04 */
    k_u32 voc;          /* +0x08 */
    k_u32 phy_lan_num;  /* +0x0c */
    k_u32 hs_freq;      /* +0x10 */
} k_vo_mipi_phy_attr;

Offset 0x00, 0x04, 0x08 and 0x10 are loaded. 0x0c is not. phy_lan_num is dead in the vendor firmware too — every panel, 2-lane and 4-lane alike, gets the identical k230_dsi_config_4lan_phy() call. Setting mipi_phy_attr.phy_lan_num = K_DSI_2LAN at rm69a10.c:321 has no effect whatsoever.

Where lane count is consumed

Exactly one register, in both stacks:

Linux has one additional lane dependency the vendor lacks: phy_clk_freq = dsi->clk_freq * 3 * 8 / device->lanes / 2 (canaan_dsi.c:325). That is the vendor’s table-driven phyclk = 475200 recomputed, and it comes out right for 2 lanes.

Structural comparison of the PHY code

canaan_phy.c is a faithful transliteration of the libvo.a functions. I walked all four side by side:

RT-Smart (libvo.a)Linuxverdict
k230_dsi_write_phy_regcanaan_phy.c:42-56same
k230_dsi_phy_pll_configcanaan_phy.c:58-113same structure
k230_dsi_phy0_config(m,n,voc,hsfreq)canaan_phy.c:115-163same
k230_dsi_phy1_config(hsfreq)canaan_phy.c:165-234same except delays and the loop bound
k230_dsi_config_4lan_phy(m,n,voc,hsfreq)canaan_phy.c:236-266same except mdelay(1) vs msleep(20)

k230_dsi_config_4lan_phy disassembles to exactly:

k230_dsi_phy0_config(m, n, voc, hs_freq)
k230_dsi_phy1_config(hs_freq)
readl(phy_base + 0x04);  writel(0x0, phy_base + 0x04)
writel(0xd, dsi_base + 0xa0);  writel(0xf, dsi_base + 0xa0)
while (readl(dsi_base + 0xb0) != 0x1fbd) ;
rt_thread_mdelay(1)                              /* Linux: msleep(20) */
writel(0x1, dsi_base + 0x94)
readl(phy_base + 0x04);  writel(0x400000, phy_base + 0x04)
while (readl(dsi_base + 0xb0) != 0x1fbd) ;
readl(dsi_base + 0xb0)

0x1fbd is the vendor’s own expected value on a 2-lane panel — the same constant, in the same firmware, that lights this exact board. It was never a 4-lane encoding.

Conclusion. nix/patches/canaan-dsi-2lane-phy-path.patch, currently staged, is chasing a defect that does not exist. The vendor programs the D-PHY identically for 2 and 4 lanes, and our PHY already reaches 0x1fbd on hardware. That work should be dropped and the effort moved to §2.1.


4. Panel supply rails

There are none to model. The vendor’s entire panel power story is two GPIOs:

No regulator, no PMIC write, no I²C to a charge pump, no third GPIO. Grep for VCI / ELVDD / AVDD / regulator across repo/canmv_k230/src/rtsmart/mpp/kernel/connector/ returns nothing.

So the “missing VCI/ELVDD/AVDD” suspect from dsi-phy-hang.md is retired: if the vendor needs no such rail, neither do we. On an AMOLED whose ELVDD comes from an on-FPC charge pump, the equivalent of “turn on the supply” is 0x51 — the DCS brightness write we are not sending.

Our side already drives both GPIOs (panel-canaan-universal.c:318, 331, asserted at :324-328 and :339), so there is nothing to add; only the reset timing differs (§2.3).


What to do next, in order

  1. Replace the init sequence in nix/dts/display-rm69a10-568x1232.dtsi with the thirteen commands from rm69a10_568x1232_init(), with the 120 ms after 0x11, and 0x3A 0x77 after 0x29. This is the one change that is both high-probability and cheap. The DT delay byte is u8 milliseconds, so 120 fits; the 2000 µs before 0x11 becomes 2 ms.
  2. Set the DT delay byte to 3 on every command, matching the vendor transport.
  3. Drop nix/patches/canaan-dsi-2lane-phy-path.patch (§3).
  4. If still dark: move the reset pulse into canaan_panel_prepare() with the vendor’s 1/10/20 ms timing, and add k230_display_rst()’s 0x91101090 poke to the enable path (§2.2, §2.3).
  5. Then voc = 0x19 (§2.5) and the VO/DSI ordering (§2.7).

The DCS read (0x0A) that currently times out should start answering as soon as the panel is actually initialised — the vendor’s own code reads 0x05 and 0x0C right after 0x29 (rm69a10.c:159-163) and expects a reply, so a successful read is the pass/fail signal for step 1.