LILYGO T-Display-K230 · hardware note · observed

The command line the bootloader threw away

The device tree carried a correct kernel command line. U‑Boot replaced it with a vendor string that had no init= in it, NixOS stage 1 gave up, and the board stopped in emergency mode with the root account locked. One if in k230_img.c explains all of it.

Symptom
emergency mode
root locked, no shell offered
Failing unit
initrd-find-nixos-closure
.service, exit 1
Cause
board/canaan/common
/k230_img.c:110
Fix
bootargs.txt on the boot
partition + env import
Confirmed
2026-09-20, /proc/cmdline
on the booted board

the symptom

Three lines, and two of them are good news

This is everything the console said. It looks like a catastrophe and it is very nearly a success — which is the useful thing about reading it carefully rather than reaching for the card reader.

[  OK  ] Finished Create Volatile Files and Directories in the Real Root.
You are in emergency mode. After logging in, type "journalctl -xb" to view
system logs, "systemctl reboot" to reboot, or "exit" to continue bootup.

Cannot open access to console, the root account is locked.
See sulogin(8) man page for more details.
1

The root filesystem is mounted

“in the Real Root” is the /sysroot variant of the tmpfiles unit. It cannot possibly run before sysroot.mount has succeeded. So the card, the partition table, the ext4 filesystem and the MMC driver are all fine.

2

The failure is the very next unit

Something ordered immediately after that tmpfiles unit failed, and failed fatally to initrd.target. That is a short list.

3

The locked root account is why it looks worse than it is

Emergency mode normally drops you into a shell. NixOS leaves root locked, so sulogin has nothing to offer and the boot simply stops. That is a NixOS default meeting a bad day, not a second fault.

the unit

initrd-find-nixos-closure.service, and its one demand

From nixos/modules/system/boot/systemd/initrd.nix. It reads /proc/cmdline, looks for init=, and if it does not find one it refuses to continue — because without it, stage 1 does not know which of the closures on the disk it is supposed to be booting.

# initrd-find-nixos-closure.service
# RequiresMountsFor = "/sysroot/nix/store"
# requiredBy        = [ "initrd.target" ]

closure=
for o in $(< /proc/cmdline); do
    case $o in
        init=*) … closure="${initParam[1]}" ;;
    esac
done

if [ -z "${closure:-}" ]; then
  echo 'No init= parameter on the kernel command line' >&2
  exit 1
fi

RequiresMountsFor = "/sysroot/nix/store" is what orders it immediately after the sysroot tmpfiles unit, and requiredBy = [ "initrd.target" ] is what turns its exit 1 into emergency mode. The entire failure therefore reduces to one sentence: there was no init= on the kernel command line.

the cause

U-Boot honours /chosen/bootargs only when it feels like it

The SD image derivation writes the command line into the device tree with fdtput -t s … /chosen bootargs, and that step is genuinely required — an earlier boot with no /chosen/bootargs at all was silent on both UARTs. It is also, on its own, useless. Vendored U-Boot rewrites the property on its way past.

// u-boot/board/canaan/common/k230_img.c:110
char *board_fdt_chosen_bootargs(void){
    char *bootargs = env_get("bootargs");
    if(NULL == bootargs) {
        …
        bootargs = "root=/dev/mmcblk1p2 loglevel=8 rw rootdelay=4 \
                    rootfstype=ext4 console=ttyS0,115200  earlycon=sbi";
    }
    printf("g_bootmod = %d, bootargs=%s\n", g_bootmod, bootargs);
    return bootargs;
}

The vendor environment never defines bootargs, so every boot took the second branch. The g_bootmod = 3 line printed on every one of those boots is line 126 of this same function — it had been sitting in the log the whole time, naming the function that was doing it.

our DTB: /chosen/bootargs written by fdtput in nix/sd-image.nix bootm → board_fdt_chosen_bootargs() board/canaan/common/k230_img.c:110 env_get("bootargs") == NULL ? yes every boot until 2026-09-20 no after the env fix HARDCODED VENDOR STRING root=/dev/mmcblk1p2 loglevel=8 rw rootdelay=4 rootfstype=ext4 console=ttyS0,115200 earlycon=sbi overwrites /chosen/bootargs kernel boots · no init= present root mounts anyway — see the coincidence initrd-find-nixos-closure: exit 1 initrd.target fails EMERGENCY MODE · root locked OUR STRING, FROM THE ENV console=ttyS0,115200n8 root=fstab loglevel=4 lsm=landlock,yama,bpf init=/nix/store/2h0x…/init written into /chosen/bootargs kernel boots · init= present root=fstab resolves via NIXOS_SD closure found · stage 2 starts initrd.target reached [root@nixos:~]#
The decision, and the two boots either side of it. Note what the left branch costs: whatever fdtput wrote into /chosen/bootargs is discarded before the kernel ever sees the tree. The device tree is not where the kernel command line lives on this board.

the fix

Define the variable, and win the test outright

env_get("bootargs") is checked first, so anything that defines it wins. The interesting part is where the value is allowed to live: the command line names the NixOS closure and therefore changes on every rebuild, while the stage 1 environment image must not, because it is vendored firmware data with a recorded hash.

blinux = k230_set_dtb &&
         ext4load mmc ${mmc_boot_dev_num}:1 0x7000000 /bootargs.txt &&
         env import -t 0x7000000 ${filesize} &&
         ext4load mmc ${mmc_boot_dev_num}:1 0x8000000 /fw_jump_add_uboot_head.bin &&
         ext4load mmc ${mmc_boot_dev_num}:1 0x200000  /${k} &&
         ext4load mmc ${mmc_boot_dev_num}:1 0x8400000 /${dtb} &&
         ext4load mmc ${mmc_boot_dev_num}:1 0x9000000 /initrd.uimg &&
         bootm 0x8000000 0x9000000 0x8400000

mmc_boot_dev_num = 1     k = Image     dtb = force.dtb
1

The value lives on the boot partition, not in the image

nix/sd-image.nix writes bootargs.txt from config.boot.kernelParams plus init= — the same string it puts in the DTB, so the two cannot drift apart. env.env stays closure-independent.

2

env import is available because the build enables it

CONFIG_CMD_IMPORTENV=y in this U‑Boot build. ext4load sets the filesize variable as a side effect (fs/fs.c:743), which the very next command consumes — the two lines only work as a pair, and in that order.

3

0x7000000 is chosen, not arbitrary

Above the ~60 MB kernel that lands at 0x200000 and below fw_jump at 0x8000000. The highlighted addresses in the listing are the same problem solved once before: the vendor layout assumes a kernel under 32 MiB, ours is not, and the device tree and OpenSBI payload had to move above it.

corrections

Three things this project believed that the board disproved

Written from the QEMU side before the board had ever run our image, repeated in this repository, and wrong. Each one cost a boot cycle, which is why they are named here rather than quietly edited out.

was believed is actually why it mattered
U-Boot reads extlinux
nothing on this board parses extlinux.conf
Vendored U-Boot runs blinux, an environment variable holding a chain of ext4load commands that fetch each file by literal filename, ending in bootm. There is no generation menu and no APPEND line to put init= on, which is why nix/hardware.nix sets boot.loader.generic-extlinux-compatible.enable = false.
OpenSBI is v0.9
v1.4, on the path that boots Linux
v0.9 is what the shipped RT‑Smart firmware prints, and that log is real. It is not our chain. We boot through opensbi-1.4-overlay — OpenSBI 1.4 plus Canaan’s T‑Head overlay, FW_TEXT_START=0 — and the version matters because the overlay carries THEAD_QUIRK_DISABLE_MAEE.
the DTB carries the kernel command line
the U-Boot environment does
This is the sharpest difference from QEMU, where -append is simply honoured. On the board the command line is a property of the environment image and the boot partition. Writing it into the device tree is still necessary, and is not sufficient.

Analysis: docs/evidence/stage1-emergency-mode.md. Console evidence: docs/evidence/hardware-boot.txt for the failure, docs/evidence/hardware-userspace.md for /proc/cmdline after the fix. The vendor environment as shipped is in docs/evidence/uboot-env.txt; the differences from QEMU are tabulated in docs/evidence/boot-path-differences.md.

Vendor source: u-boot/board/canaan/common/k230_img.c line 110 (the function) and line 126 (the g_bootmod printf). NixOS source: nixos/modules/system/boot/systemd/initrd.nix. Our side: nix/sd-image.nix, nix/hardware.nix, tools/gen-stage1.sh and firmware/stage1/PROVENANCE.txt, which records every byte of stage 1 that this project generates rather than vendors.

The U-Boot and SPL binaries are untouched. Only the environment image — plain data produced with mkenvimage from a text file — is ours, which is what image/boot-chain requires.