Kendryte K230 · hardware note

One SoC file, one board file, and what happens where they meet

k230.dtsi describes the silicon and switches almost all of it off. A board .dts reaches back into it by label and turns things on. That seam is the whole of a board port, so it is worth knowing exactly what it lets you do.

SoC file
k230.dtsi
857 lines · 56 nodes
Clock tree
k230_clock_provider.dtsi
3,354 lines · 150 nodes
Our board file
k230-tdisplay.dts
129 lines
Flattened
234 nodes
184 okay · 11 disabled
Compiled size
55,156 bytes
16 more than the reference

composition

Who includes whom

Four source files, one flattened tree, and a second device tree entirely — U-Boot’s — that runs first and leaves the pin multiplexer in a state the kernel simply inherits. Our board file is a third sibling of the two reference boards, not a modification of either.

LINUX DEVICE TREE — arch/riscv/boot/dts/canaan/ k230.dtsi 857 lines · 56 nodes 19 disabled · 25 okay · 12 unset no pinctrl · no regulators k230_clock_provider.dtsi 3,354 lines · 150 clock nodes included on k230.dtsi's last line display-st7701-480x800.dtsi 97 lines · 44 init commands grafts panel@0 onto the DSI host display-rm69a10-568x1232.dtsi ours · 20 init commands same shape, different numbers k230-canmv-v3-lcd.dts 96 lines · the reference board 8 × status = "okay" ST7701 panel · FT5306 touch memory@0 · aliases · chosen k230-tdisplay.dts 129 lines · ours, in nix/dts/ the same 8 × status = "okay" RM69A10 panel · GT9895 touch model = LILYGO T-Display-K230 every divergence marked in-file reference .dtb 55,140 bytes 234 nodes, one flat tree 184 okay · 11 still disabled last assignment to a property wins our .dtb 55,156 bytes 234 nodes · 184 okay · 11 disabled every node verified present, not assumed, by re-reading the output 16 bytes apart from the reference dtc dtc SEPARATE FIRMWARE, RUNS FIRST — u-boot/arch/riscv/dts/ k230_canmv_v3p0.dts pinctrl-single,pins — all 64 pads sets the FPIOA registers; Linux inherits them pad muxing at kernel entry inherited, never described
Node counts are measured, not estimated: both board files were run through cpp and dtc at the pinned commit and compile clean. The two flattened trees differ by sixteen bytes. Structurally they are the same device tree; what differs is a model string, two compatible strings, a panel and a touch controller. That is what a board port of this kind actually is. Neither v3 file is listed in the canaan Makefile, so all three are built by name rather than by the dtb-y list.

census

What the SoC file declares, by status

Fifty-six hand-written nodes: forty-seven at the top level plus the nine children of sysctl and security. The interesting split is by status — nineteen sit disabled waiting for a board to claim them, twenty-five are switched on unconditionally, and twelve carry no status at all, which in device-tree semantics means enabled.

After a board file has had its say the flattened tree is 234 nodes: 184 okay, 11 still disabled. Those eleven are uart1, uart2, uart4, i2c0–i2c2, all three SPI controllers and mipi1/mipi2: everything nobody claimed. Anything this board needs from that list is a node we would have to enable ourselves, and the charger, the fuel gauge and the LoRa radio are all in it.

Core — always live

no board involvement possible 12 nodes
cpu@0thead,c908 · sv39 · the only CPU in the tree
l2-cache256 KiB unified
plicinterrupt-controller@f00000000
canaan,k230-plic / thead,c900-plic
clinttimer@f04000000
gpio0_portsgpio@9140b000 · pins 0–31
gpio- and interrupt-controller
gpio1_portsgpio@9140c000 · pins 32–63
watchdog1wdt1@91106800 · snps,dw-wdt
adcadc@9140d000
pwm0 · pwm19140a000 · 9140a040
rtcrtc@0x91000c00 — note the literal "0x"
sysctlclock@91100000 · reset@91101000
boot@91102000 · power@91103000
sysctl_clock children150 nodes from k230_clock_provider.dtsi
PLLs, dividers, gates, muxes
apb_clk · dummy_sd · clk_dummyfixed-clock · 50 / 100 / 0 MHz
the three that bypass the clock tree

Buses and controllers — off until a board says otherwise

status = "disabled" 19 nodes · 8 claimed
uart0serial@91400000 · alias serial0
the console on this board
uart1serial@91401000
uart2serial@91402000
uart3serial@91403000 · alias serial3
uart4serial@91404000
i2c3i2c@91408000 · alias i2c1
the touch bus
i2c4i2c@91409000 · alias i2c0
enabled, no children
i2c0 · i2c1 · i2c291405000 · 91406000 · 91407000
mmc_sd0sdhci0@91580000 · 8-bit
Wi-Fi SDIO on this board
mmc_sd1sdhci1@91581000 · 4-bit
the card, and the boot device
usb0 · usb191500000 · 91540000 · canaan,k230-otg
spi0 · spi1 · spi291584000 · 91582000 · 91583000
the LoRa radio presumably wants one
mipi1 · mipi2verisilicon,mipi — extra camera inputs

Media and accelerators — already on

status = "okay" in the SoC file 16 nodes
displaydisplay-subsystem · ports = <&vo_out>
vovo@90840000 · canaan,k230-vo
vth_line = 10 by default
dsidsi@90850000 · canaan,k230-mipi-dsi
port@0 wired to vo; port@1 free
mipi0verisilicon,mipi
re-pointed to CSI-2 by the board
ispisp.0 · verisilicon,isp
gpugpu@90800000 · verisilicon,gc8000ul
no Mesa or Vulkan driver exists
vpuvpu@0x90400000 · canaan,vpu
gnnegnne@80400000 — the KPU
ai2dai2d@80400c00
pdmapdma@0x80804000
i2s · audioi2s@0x9140f000 · audio@0x9140f400
inno_codecinno_codec@0x9140e000
tsensortsensor@91107000
securitya simple-bus holding five blocks
aes · hash · rsa · trng · otp91210200 · 91210800 · 91211000
91213000 · 91213500

mechanism

How an override actually resolves

A board file does not copy a node. It writes &label { … }, which the compiler resolves to whichever node carries that label anywhere in the included tree, then merges the properties in: later assignment wins, everything unmentioned survives.

DECLARED — k230.dtsi uart0: serial@91400000 { compatible = "snps,dw-apb-uart"; reg = <0x0 0x91400000 …>; interrupts = <16 LEVEL_HIGH>; clocks = <&apb_clk>; reg-shift = <2>; reg-io-width = <4>; status = "disabled"; }; OVERRIDDEN — the board .dts &uart0 { status = "okay"; }; no reg, no compatible, no re-declaration — the label is a pointer into the SoC tree. merge EFFECTIVE — what lands in the .dtb serial@91400000 { compatible = "snps,dw-apb-uart"; reg = <0x0 0x91400000 …>; interrupts = <16 LEVEL_HIGH>; clocks = <&apb_clk>; reg-shift = <2>; reg-io-width = <4>; status = "okay"; }; THE THREE RULES THAT FOLLOW FROM THIS 1 · A label must already exist. You cannot reference a node the SoC file never named. 2 · Re-assigning a property replaces it whole. There is no partial edit of a list. 3 · Include order decides. The last file to touch a property is the one that wins.
Rule 2 is the one that surprises people. The board file re-points &mipi0 by re-assigning reg, interrupts and resets — a node that was already okay in the SoC file quietly moves to a different address and a different interrupt. Nothing flags it, and our board file inherits that edit unchanged because the reference does it.

taxonomy

Seven things a board file does

Read against the two reference boards and our own, every edit falls into one of these seven. There is no eighth kind, which is a useful thing to know before writing a board file.

Kind 1 — flip a switch

Enable a controller, change nothing else

The commonest edit and the least interesting. uart0, uart3, usb0, usb1 and i2c4 get exactly this on our board.

&uart0 {
    status = "okay";
};

Kind 2 — flip and tune

Enable, then add board-electrical properties

Signal-integrity and voltage facts only the board knows. rx_delay_line and tx_delay_line are Canaan-specific delay-chain trims; no-1-8-v says the level shifters cannot do 1.8 V signalling.

&mmc_sd0 {
    status = "okay";
    no-1-8-v;
    rx_delay_line = <0x0d>;
    tx_delay_line = <0xc0>;
};

Kind 3 — attach a device

Enable a bus and declare what hangs off it

The child node’s unit address is the I²C address. GPIOs are referenced through the bank controller’s label and a per-bank index. This is the shape of our GT9895 node, shown as we actually wrote it.

&i2c3 {
    status = "okay";
    touchscreen@5d {
        compatible = "goodix,gt9895";
        reg = <0x5d>;
        reset-gpios = <&gpio0_ports 24 GPIO_ACTIVE_LOW>;
        interrupt-parent = <&gpio0_ports>;
        interrupts = <23 IRQ_TYPE_EDGE_FALLING>;
    };
};

Kind 4 — re-point

Rewrite reg, interrupts and resets on a live node

The board tells mipi0 it is a CSI-2 receiver and hands it a different register window and interrupt. Note there is no status here — the node was already okay.

An unconditional rewrite of a hardware address from a board file is a strong smell. It is also what upstream does, and we carry it over rather than diverge on something we have not tested.

&mipi0 {
    // set to csi2
    id = <2>;
    reg = <0x0 0x9000a800 0x0 0x800>;
    interrupts = <121 IRQ_TYPE_LEVEL_HIGH>;
    resets = <&sysctl_reset K230_RESET_CSI2_…>,
             <&sysctl_reset K230_RESET_M2_…>;
};

Kind 5 — graft an endpoint

Extend an existing port graph

The SoC file leaves &dsi with only port@0 filled. A board adds port@1 and the endpoint naming its downstream sink — a bridge chip on one board, a panel on ours. Endpoints are declared in pairs and both halves must name each other or the bridge chain never forms.

&dsi {
    ports {
        port@1 {
            reg = <1>;
            dsi_out_rm69a10: endpoint {
                remote-endpoint = <&rm69a10_in>;
            };
        };
    };
};

Kind 6 — new root nodes

Facts that have no SoC counterpart

How much DRAM, which UART is the console, what the board is called, the machine-level sound card. These are written into / directly because there is nothing upstream to reference.

aliases matters more than it looks: it fixes the ttySn and mmcblkn numbering the rest of the system will see.

/ {
    model = "LILYGO T-Display-K230";
    compatible = "lilygo,t-display-k230",
                 "canaan,canmv-k230",
                 "canaan,kendryte-k230";
    aliases { serial3 = &uart3; mmc0 = &mmc_sd0;
              mmc1 = &mmc_sd1; i2c0 = &i2c4; i2c1 = &i2c3; };
    chosen { stdout-path = "serial0:115200n8"; };
    ddr: memory@0 {
        device_type = "memory";
        reg = <0x0 0x0 0x0 0x40000000>;   // 1 GiB
    };
    sound { compatible = "canaan,k230-audio-inno"; … };
};

Kind 7 — include a fragment

Pull in a whole subsystem description

One #include is the entire difference between the plain CanMV v3 board and the LCD variant. The fragment is not a node — it is a bundle of &-overrides that happens to live in its own file, which is why it can be reused across boards.

This is the mechanism our port uses: write the panel fragment, include it instead, and attach the pins by label afterwards.

#include "k230.dtsi"
#include "display-rm69a10-568x1232.dtsi"

// …and later, supply the pins the fragment
// declared but deliberately left off the panel node:
&lcd {
    dsi_reset-gpios      = <&gpio0_ports 22 GPIO_ACTIVE_HIGH>;
    backlight_gpio-gpios = <&gpio0_ports 25 GPIO_ACTIVE_HIGH>;
};

the real thing

The board file we actually wrote

An earlier version of this note carried a sketch of what a T-Display board file might look like, with a Wi-Fi power sequence and a bus-width override in it, and a footnote saying it had never been compiled. The file below is the one in the repository. It compiles against the real k230.dtsi, every node was verified present in the output rather than assumed, and the two guessed-at Wi-Fi edits are not in it.

// nix/dts/k230-tdisplay.dts — compiles, and booted Linux on
// 2026-09-20. The panel it describes has still never lit.

/dts-v1/;

#include "k230.dtsi"
#include "display-rm69a10-568x1232.dtsi"          // ours

/ {
    model = "LILYGO T-Display-K230";
    compatible = "lilygo,t-display-k230", "canaan,canmv-k230",
                 "canaan,kendryte-k230";

    aliases {
        serial3 = &uart3;
        mmc0 = &mmc_sd0;   // the RTL8189FTV, over SDIO
        mmc1 = &mmc_sd1;   // the TF card, and the boot device
        i2c0 = &i2c4; i2c1 = &i2c3;
    };

    chosen { stdout-path = "serial0:115200n8"; };   // ttyS0, not ttyS1
    ddr: memory@0 { device_type = "memory"; reg = <0x0 0x0 0x0 0x40000000>; };
    sound { compatible = "canaan,k230-audio-inno"; … };
};

&uart0 { status = "okay"; };                        // CH342 ch0 — console
&uart3 { status = "okay"; };                        // CH342 ch1
&usb0  { status = "okay"; };
&usb1  { status = "okay"; };

&mmc_sd1 { status = "okay"; no-1-8-v; cap-sd-highspeed; mshc_ctrl_r = <0x00>; };

&mmc_sd0 {                                          // carried over verbatim
    status = "okay"; no-1-8-v;
    rx_delay_line = <0x0d>; tx_delay_line = <0xc0>;
};
// NOT WRITTEN — no mmc-pwrseq and no regulator, so nothing asserts IO45
//   and the radio stays unpowered under Linux exactly as it does under
//   RT-Smart. A known gap, left visible rather than guessed at.

&i2c3 {
    status = "okay";
    touchscreen@5d {                              // the reference has ft5306@38
        compatible = "goodix,gt9895";              // no driver matches this, anywhere
        reg = <0x5d>;
        reset-gpios = <&gpio0_ports 24 GPIO_ACTIVE_LOW>;
        interrupt-parent = <&gpio0_ports>;
        interrupts = <23 IRQ_TYPE_EDGE_FALLING>;
    };
};
&i2c4 { status = "okay"; };

&mipi0 { /* re-pointed to CSI-2, exactly as the reference does */ };

// NOT WRITTEN — no LT9611 node. The non-LCD reference gives it
//   gpio0_ports 24 and 23, which on this board are the touch controller's.
// NOT WRITTEN — BQ25896, BQ27220, SX1262/LR2021: bus and chip-select unread.

&lcd {                                              // the label on our panel node
    dsi_reset-gpios      = <&gpio0_ports 22 GPIO_ACTIVE_HIGH>;
    backlight_gpio-gpios = <&gpio0_ports 25 GPIO_ACTIVE_HIGH>;
};

Census taken by walking arch/riscv/boot/dts/canaan/k230.dtsi at ruyisdk/linux-xuantie-kernel 7d4e1f444f461dbe3833bd99a4640e7b6c2cd529. Byte counts and node counts are from running cpp and dtc over each board file at that commit and re-reading the output.

Board evidence: the vendored U-Boot device tree k230_canmv_v3p0.dts, the RT-Smart k230_canmv_v3p0_defconfig, and the V1.0_NEW schematic.