commit 64e19c213a868a8d9d62bf073f72606860dad894 Author: vhaudiquet Date: Sat Aug 29 19:21:28 2026 +0200 krane-fb-stub: arm64 framebuffer test payload for depthcharge Minimal freestanding arm64 binary that depthcharge boots as a kernel: parses depthcharge's /firmware/coreboot DTB node, walks the coreboot table, finds LB_TAG_FRAMEBUFFER, and paints red/yellow/green/blue checkpoints (~2s each) into the live boot-splash framebuffer. Verified against coreboot tables header, Linux arm64 booting.rst, and depthcharge's fit.c/boot64.c. Host parser tests pass against the live /sys/firmware/fdt; full red->yellow->green->blue sequence verified end-to-end under qemu-system-aarch64; packed image verifies with the ChromeOS devkeys. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c77c00 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# build intermediates (regenerated by build.sh) +*.o +*.elf +host_test + +# qemu test artifacts (regenerated by qemu_test.py) +test.dtb +test.dts +lbio.bin + +# reference source clones (coreboot, linux, depthcharge, depthcharge-tools) +src/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd01f6c --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# krane-fb-stub + +Minimal arm64 "hello framebuffer" payload for the Lenovo IdeaPad Duet +Chromebook (MT8183, `google,krane` sku176). It validates the +depthcharge → custom-payload pipeline before any U-Boot bring-up work: +it locates the boot-splash framebuffer handed over by coreboot/depthcharge +and paints solid colors at checkpoints. + +## Checkpoint convention + +Parsed first (instantaneous), then drawn — see Deviations: + +| stage | success signal | failure signal | +|-------|---------------------------------------|-------------------------------------| +| 1 | screen RED ~2s | screen keeps depthcharge output — DTB parse failed | +| 2 | YELLOW ~2s | (yellow implies DTB parsed) | +| 3 | GREEN ~2s | stuck on yellow — LBIO/fb record bad| +| 4 | BLUE, held forever | stuck on green — fb fill faulted | + +If the stub never runs at all (header rejected, load failed), depthcharge +falls back to its own screen — no color ever appears. + +## Files + +- `stub.S` — 64-byte arm64 Image header + entry stub (own stack, no PLT). +- `main.c` — FDT walker, coreboot-table walker, framebuffer painter. +- `linker.ld` — flat layout, `.bss` asserted empty (image_size must cover + all state because depthcharge does not zero-fill beyond the file). +- `build.sh` — build + relocation/bss checks + `image_size` patch. +- `host_test.c` — host-compiled test of both parsers against the live + `/sys/firmware/fdt` and a synthetic LBIO table. +- `qemu_test.py` — end-to-end smoke test under `qemu-system-aarch64` + (synthetic coreboot table + DTB; asserts red→yellow→green→blue timeline). +- `build-payload.sh` — packs `krane-fb-stub.bin` + `krane-sku176.dtb` into a + signed depthcharge image via mkdepthcharge. +- `krane-fb-stub.bin` — raw arm64 Image (18 KB). +- `krane-fb-stub-payload.bin` — packed, devkey-signed depthcharge image, + verified with `futility vbutil_kernel --verify`. +- `krane-sku176.dtb` — the exact krane sku176 DTB from the currently-booting + FIT on this unit (extracted with `dumpimage` from the p1 FIT, config + conf-27 `mt8183-kukui-krane-sku176`). + +## Verification performed (per the prompt's "verify against source" list) + +### 1. coreboot table layout — CHECKED, matches prompt +Cloned coreboot `main`; read +`src/commonlib/include/commonlib/coreboot_tables.h`: +- `physical_address` is `lb_uint64_t` = `__aligned(4) uint64_t` + (`LB_ENTRY_ALIGN == 4`). On little-endian AArch64 this is a plain + 8-byte LE u64 at offset 8 of `struct lb_framebuffer`; the "hi/lo split" + is only an alignment trick, not separate halves in memory. +- Full struct is 40 bytes: `tag@0, size@4, physical_address@8, + x_resolution@16, y_resolution@20, bytes_per_line@24, bits_per_pixel@28, + red 29/30, green 31/32, blue 33/34, reserved 35/36, orientation@37, + flags@38, pad@39`. Verified by compiling a `sizeof`/`offsetof` probe + against the real header on the host (`sizeof == 40`). +- `lb_header` matches the prompt: `signature[4] "LBIO"`, then + `header_bytes, header_checksum, table_bytes, table_checksum, + table_entries` (LE u32 each). Records start after `header_bytes`. +- `LB_TAG_FRAMEBUFFER = 0x12` confirmed. +- Cross-check on this live unit: parsing the live `/sys/firmware/fdt` + (which contains depthcharge's injected `/firmware/coreboot` node) + yields LBIO at `0xffed9000`, size `0x380` — matches the prompt. + +### 2. arm64 Image header — CHECKED against Linux `booting.rst` +- 64-byte header, magic `0x644d5241` ("ARM\x64") at `0x38`, LE fields. +- `code0` must branch past the header; entry is at the image start with + `x0`=DTB phys addr, `x1..x3`=0, MMU off, DAIF masked. +- `image_size` must be exact (non-zero), little-endian. +- Cross-checked against depthcharge's consumer: `src/arch/arm/boot64.c` + reads the same header (magic check, `image_size`, `text_offset` as + `load_offset` for KASLR slot math). + +### 3. depthcharge behavior — CHECKED against chromiumos depthcharge source +- `src/arch/arm/boot64.c`: image is decompressed/relocated to a random + 2 MiB-aligned slot; the **Image header magic is verified**; entry is the + first byte of the (relocated) image; x0 = flat DTB, x1..x3 = 0 + (`boot64_asm.S` zeroes x1-x3, disables MMU, `br x25`). +- => the stub must be position-independent (built `-fPIC`, checked for + zero dynamic relocations) and carry its own stack (done, 16 KiB inside + the image). +- **Prompt assumption contradicted by source:** a FIT config *without* an + FDT subimage is skipped (`fit.c`: "config %s has no FDT, skipping"), and + with no match depthcharge gives up. So the packed FIT **does** include a + DTB — the real krane-sku176 DTB taken from the working FIT — while + depthcharge still injects its own `/firmware/coreboot` node at boot via + `src/boot/coreboot.c` `install_coreboot_data` (it adds `#address-cells`, + `#size-cells`, `ranges`, `compatible`, and the `reg` pairs to + `/firmware/coreboot`). Our DTB is only a config-selection key; the + runtime DTB the stub receives at x0 is depthcharge's fixed-up tree. +- depthcharge injects `/firmware/coreboot` reg = (table addr, CBMEM addr) + in `src/boot/coreboot.c`; the krane root DTS uses + `#address-cells=2/#size-cells=2`, so the first reg pair decodes to + LBIO @0xffed9000 size 0x380 — matching the prompt. +- Existing kernel p1 body confirmed to be a FIT (`d00dfeed` at body + offset 0), body load address 0x100000, so FIT (`--format fit`, the arm + default in mkdepthcharge) is the right container. + +### 4. mkdepthcharge input format — CHECKED, answers the prompt's question +- `mkdepthcharge` builds the FIT itself (`mkimage -f auto -T kernel + -O linux -C none -d -b `, then patches the kernel + subimage type to `kernel_noload`), then signs with `vbutil_kernel`. +- A raw arm64 Image binary can be passed **directly** as `-d`/VMLINUZ — + no manual uImage wrapping. mkdepthcharge auto-detects arm64 from the + `ARM\x64` magic at file offset 0x38 and defaults to FIT format for arm. +- Packed command (see `build-payload.sh`): + `mkdepthcharge -A arm64 -o -n -d krane-fb-stub.bin -b krane-sku176.dtb` +- The packed image verifies with the devkeys and carries one config + (`conf-1`, description "krane-sku176") with `kernel_noload` + `flat_dt` + subimages, compression `none` — the same shape as the working kernel. + +## Deviations from the prompt (all deliberate, explained) + +1. **Red-on-entry is physically impossible.** The framebuffer address is + only discoverable by parsing the DTB (depthcharge injects it) and the + coreboot table. Parsing is sub-millisecond, so the sequence is: + parse → red → yellow → green → blue. A pre-parse failure leaves + depthcharge's dev-mode screen visible, which is a strictly better + "stuck at stage N" indicator than a stuck color (a stuck color would + hide *which* parse failed; an unpainted screen + depthcharge's own + on-screen error identifies the stage). +2. **A DTB is included in the FIT** (contrary to "no DTB"): this unit's + depthcharge requires an fdt subimage in the chosen config. We embed the + genuine krane sku176 DTB; depthcharge's fixup adds the coreboot node at + runtime, exactly as the prompt's hardware facts describe. +3. Colors are derived from the mask_pos/mask_size fields of the framebuffer + record rather than hard-coded xRGB — works for any format the panel + reports (krane reports 32bpp xRGB: red@16/8, green@8/8, blue@0/8). + +## Testing done (host) + +- `host_test` (host C): FDT parser against the LIVE `/sys/firmware/fdt` + (finds LBIO @0xffed9000 size 0x380 — exact match to the prompt's + manually-confirmed values), synthetic coreboot table layout test, + color composition for all four checkpoints, and full-screen fill bounds + (first/last pixel written, no overflow past `yres*bpl`). +- qemu end-to-end: real stub binary + real krane DTB + synthetic coreboot + table under `qemu-system-aarch64 -M virt`; guest memory snapshots via + the QEMU monitor confirm red → yellow → green → blue in 2s steps and + blue held forever. All 4 checks pass (`qemu_test.py`). +- Packed payload verified with `futility vbutil_kernel --verify` + (body verification succeeded, devkeys). + +## Firmware state confirmed on this unit + +- `dev_boot_usb = 1` — read directly from the device's VBNV flash region + (RW_NVRAM @ 0x80000 in the 8 MB SPI, latest 16-byte vbnv blob at + 0x81bb0; CRC8-vbnv valid; `DEV_FLAGS_OFFSET` bit0 set, signed-only off). + crossystem could not run because distro flashrom lacks the `host` + programmer; `flashrom -p internal` works and the blob was decoded from + the read-back image. +- Boot chain evidence: p1 body is a FIT with 27 configs including + `mt8183-kukui-krane-sku176`; depthcharge FIT config matching is by + `compatible` from the config nodes, and our packed FIT's conf-1 carries + that description (`mt8183-kukui-krane-sku176`) as its fdt subimage, so + this unit's depthcharge (with kukui compat strings) selects it. + +## Build & pack + +``` +./build.sh # -> krane-fb-stub.bin (+ relocation/bss checks) +sh build-payload.sh # -> krane-fb-stub-payload.bin (signed, verifiable) +``` + +## Flashing (USB only — safety rules honored) + +Nothing has been written to internal storage. The next step is to write +`krane-fb-stub-payload.bin` to the **USB stick's** kernel partition +(`/dev/sda1`, current pmOS kernel partition on the stick — back that up +first), then boot the ChromeOS dev-mode menu → USB. The on-disk write step +is deliberately left for explicit human confirmation, per the task's +safety rules. + +## Known limitations / notes + +- The stub never returns; it spins on WFE holding blue (or halts unpainted + on a parse failure). +- No timer driver: delays use the architected generic timer registers + (CNTFRQ/CNTPCT) — mandatory in the boot protocol anyway. +- No cache maintenance is done for the framebuffer writes: we enter with + the D-cache off, and writes to the (DMA'd) display controller read + physical memory; on this platform the splash buffer is already coherent. +- The `0x380`/`0xffed9000` constants from the prompt are used ONLY in the + host tests as expected values, never in the stub logic. diff --git a/build-payload.sh b/build-payload.sh new file mode 100755 index 0000000..37ac13a --- /dev/null +++ b/build-payload.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# build-payload.sh — pack krane-fb-stub.bin + krane DTB into a signed +# depthcharge kernel image using devkeys (fits USB-boot testing with +# dev_boot_usb=1; not for internal storage without explicit confirmation). +set -e +cd "$(dirname "$0")" + +PYTHONPATH=src/depthcharge-tools python3 -m depthcharge_tools.mkdepthcharge \ + -A arm64 \ + -o krane-fb-stub-payload.bin \ + -n "krane-fb-stub framebuf test" \ + -d krane-fb-stub.bin \ + -b krane-sku176.dtb + +echo "---- verify ----" +futility vbutil_kernel --verify krane-fb-stub-payload.bin diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..9bf559a --- /dev/null +++ b/build.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# build.sh — build krane-fb-stub raw arm64 binary. +# Output: krane-fb-stub.bin (arm64 Linux Image with 64-byte header). +set -e +cd "$(dirname "$0")" +CC=aarch64-linux-gnu-gcc +OBJCOPY=aarch64-linux-gnu-objcopy +READELF=aarch64-linux-gnu-readelf +OBJDUMP=aarch64-linux-gnu-objdump + +CFLAGS="-O2 -ffreestanding -fPIC -fno-builtin -fno-stack-protector \ + -fno-unwind-tables -fno-asynchronous-unwind-tables -mstrict-align \ + -mgeneral-regs-only -Wall -Wextra -Werror" + +${CC} -c stub.S -o stub_head.o +${CC} -c main.c -o main.o ${CFLAGS} + +${CC} -nostdlib -nostartfiles -static -no-pie -Wl,-T,linker.ld,-n,-z,max-page-size=4096 \ + -o krane-fb-stub.elf stub_head.o main.o + +# The payload is copied to an arbitrary 2MiB-aligned slot with no relocation +# processing: any dynamic relocation in the linked image is a build failure. +if ${READELF} -r krane-fb-stub.elf | grep -q R_AARCH64; then + echo "FATAL: dynamic relocations present:" >&2 + ${READELF} -r krane-fb-stub.elf + exit 1 +fi + +# .bss is not copied by depthcharge; everything we use must be in the file. +BSS=$(${OBJDUMP} -h krane-fb-stub.elf | awk '$3==".bss" {print $3}') +if [ -n "$BSS" ]; then + echo "FATAL: .bss is non-empty; image_size must cover all state" >&2 + exit 1 +fi + +${OBJCOPY} -O binary krane-fb-stub.elf krane-fb-stub.bin + +# Patch image_size (offset 0x10, LE u64) to the final file size. +python3 - "$PWD/krane-fb-stub.bin" <<'EOF' +import os, struct, sys +path = sys.argv[1] +size = os.path.getsize(path) +with open(path, "r+b") as f: + f.seek(0x10) + f.write(struct.pack(" +#include +#include + +#include "main.c" + +static int fails; + +static void check(const char *what, unsigned long long got, unsigned long long want) +{ + printf("%-40s %s (got 0x%llx, want 0x%llx)\n", + what, got == want ? "PASS" : "FAIL", got, want); + if (got != want) + fails++; +} + +static void put32(u8 *p, u32 v) { memcpy(p, &v, 4); } +static void put64(u8 *p, u64 v) { memcpy(p, &v, 8); } + +int main(void) +{ + /* --- test 1: live DTB ------------------------------------------------ */ + FILE *f = fopen("/sys/firmware/fdt", "rb"); + if (!f) { + printf("no live /sys/firmware/fdt, skipping test 1\n"); + } else { + static u8 dtb[1 << 20]; + size_t n = fread(dtb, 1, sizeof(dtb), f); + fclose(f); + printf("loaded live fdt: %zu bytes\n", n); + + u64 addr; u32 size; + int rc = find_coreboot_reg(dtb, &addr, &size); + check("find_coreboot_reg found", (unsigned long long)(rc == 0), 1); + check("LBIO address", addr, 0xffed9000ull); + check("LBIO size", size, 0x380); + } + + /* --- test 2: synthetic coreboot table -------------------------------- */ + u8 table[512]; + memset(table, 0, sizeof table); + memcpy(table, "LBIO", 4); + put32(table + 4, 24); /* header_bytes */ + put32(table + 20, 3); /* table_entries */ + + u8 *rec = table + 24; + /* record 0: an unrelated tag we must skip (size 16) */ + put32(rec, 1); put32(rec + 4, 16); + rec += 16; + /* record 1: LB_TAG_FRAMEBUFFER */ + put32(rec, 0x12); + put32(rec + 4, 40); /* record size */ + put32(rec + 8, 0x80000000); /* physical_address lo */ + put32(rec + 12, 0); /* physical_address hi */ + put32(rec + 16, 1920); /* x_resolution */ + put32(rec + 20, 1200); /* y_resolution */ + put32(rec + 24, 7680); /* bytes_per_line */ + rec[28] = 32; /* bits_per_pixel */ + rec[29] = 16; rec[30] = 8; /* red */ + rec[31] = 8; rec[32] = 8; /* green */ + rec[33] = 0; rec[34] = 8; /* blue */ + + struct fbinfo fb; + int rc = find_framebuffer((u64)(unsigned long)table, &fb); + check("find_framebuffer rc", (unsigned long long)(rc == 0), 1); + check("fb pa", fb.pa, 0x80000000ull); + check("fb xres", fb.xres, 1920); + check("fb yres", fb.yres, 1200); + check("fb bpl", fb.bpl, 7680); + check("fb bpp", fb.bpp, 32); + check("mkcolor white", mkcolor(&fb, 1, 1, 1), 0x00ffffffull); + check("mkcolor red", mkcolor(&fb, 1, 0, 0), 0x00ff0000ull); + check("mkcolor yellow", mkcolor(&fb, 1, 1, 0), 0x00ffff00ull); + check("mkcolor green", mkcolor(&fb, 0, 1, 0), 0x0000ff00ull); + check("mkcolor blue", mkcolor(&fb, 0, 0, 1), 0x000000ffull); + + /* fill_screen: first and last visible pixel written, nothing past end */ + { + static u8 fbmem[1200 * 7680 + 64]; + memset(fbmem, 0xAA, sizeof fbmem); + struct fbinfo f2 = { .pa = (u64)(unsigned long)fbmem, + .xres = 1920, .yres = 1200, + .bpl = 7680, .bpp = 32, + .rpos = 16, .rsize = 8, + .gpos = 8, .gsize = 8, + .bpos = 0, .bsize = 8 }; + fill_screen(&f2, 0x00ffffff); + u32 last = *(u32 *)(fbmem + 1199 * 7680 + 1919 * 4); + u32 first = *(u32 *)fbmem; + check("fill first px", first, 0x00ffffff); + check("fill last px", last, 0x00ffffff); + check("fill no overflow (past-end untouched)", + (unsigned long)fbmem[1200 * 7680 + 32], 0xAA); + } + + printf(fails ? "\nFAILED: %d\n" : "\nALL PASS\n", fails); + return fails != 0; +} diff --git a/krane-fb-stub-agent-prompt.md b/krane-fb-stub-agent-prompt.md new file mode 100644 index 0000000..427cac0 --- /dev/null +++ b/krane-fb-stub-agent-prompt.md @@ -0,0 +1,199 @@ +# Task: Build and test a minimal ARM64 "hello framebuffer" stub for a Lenovo IdeaPad Duet (MT8183 / Krane) + +## Goal + +Produce the smallest possible standalone ARM64 binary that depthcharge (the +ChromeOS firmware bootloader) can load as if it were a Linux kernel, which +does nothing but locate the already-initialized boot-splash framebuffer and +fill it with solid colors at distinct checkpoints. This validates the whole +depthcharge → custom-payload pipeline before any real U-Boot bring-up work +begins. **No U-Boot involved yet** — this is a from-scratch freestanding +binary, as small and dependency-free as possible. + +## Hardware / firmware facts already confirmed on this exact device — do not re-derive these + +- Device: Lenovo IdeaPad Duet Chromebook. SoC: MediaTek MT8183. + Board: Google "krane", specifically **krane sku176**. +- Confirmed from the live devicetree `/firmware/coreboot` node on this exact + unit (dumped via `dtc -I dtb -O dts` on `/sys/firmware/fdt` under the + currently-running kernel): + ``` + firmware { + ranges; + coreboot { + ram-code = <0x06>; + sku-id = <0xb0>; /* = 176 decimal, confirms sku176 */ + board-id = <0x06>; + reg = <0x00 0xffed9000 0x00 0x380 0x00 0xffed9000 0x00 0x127000>; + compatible = "coreboot"; + }; + }; + ``` + With `#address-cells = 2, #size-cells = 2`, this `reg` decodes as two + (address, size) pairs: + - **coreboot table (LBIO)**: address `0xffed9000`, size `0x380` (896 bytes) + - **CBMEM area**: address `0xffed9000`, size `0x127000` (~1.15 MB) +- Firmware boot chain on this device: Boot ROM → coreboot → TF-A BL31 → + depthcharge → "kernel" (whatever is packed into the ChromeOS kernel + partition). Reference: https://trustedfirmware-a.readthedocs.io/en/latest/plat/mt8183.html +- Partition layout on `/dev/mmcblk0`: + - `p1` = type "ChromeOS kernel" (GUID `FE3A2A5D-4F32-41A7-B725-ACCC3285A309`), + small. This is what depthcharge actually boots — verified via + `futility vbutil_kernel --verify /dev/mmcblk0p1 --verbose`. + - `p2` = ext4, mounted `/boot` in the Ubuntu rootfs — **not read by firmware + at boot time**, just staging space. + - `p3` = ext4, Ubuntu root filesystem. +- The device currently shows the ChromeOS dev-mode boot menu (USB/internal + boot choice) before handoff, confirming coreboot's `panel_krane.c` + (https://github.com/coreboot/coreboot/blob/main/src/mainboard/google/kukui/panel_krane.c) + has already brought up the MIPI-DSI panel and a linear framebuffer is live + and DMA'd by the time any payload we supply would run. +- `dev_boot_usb` should be confirmed as `1` (`crossystem dev_boot_usb`) before + any USB-boot testing. + +## Coreboot table format — verify against source before trusting field order + +Reference header: `src/commonlib/include/commonlib/coreboot_tables.h` in +https://github.com/coreboot/coreboot (clone this repo locally and grep it — +do not trust field order from this prompt without checking). + +Known so far: +```c +struct lb_header { + uint8_t signature[4]; /* "LBIO" */ + uint32_t header_bytes; + uint32_t header_checksum; + uint32_t table_bytes; + uint32_t table_checksum; + uint32_t table_entries; +}; + +struct lb_record { + uint32_t tag; + uint32_t size; +}; + +#define LB_TAG_FRAMEBUFFER 0x12 + +struct lb_framebuffer { + uint32_t tag; + uint32_t size; + uint64_t physical_address; /* verify: coreboot uses lb_uint64_t, a + 32-bit-aligned split hi/lo struct on + some versions — confirm packing */ + uint32_t x_resolution; + uint32_t y_resolution; + uint32_t bytes_per_line; + uint8_t bits_per_pixel; + uint8_t red_mask_pos; + uint8_t red_mask_size; + uint8_t green_mask_pos; + uint8_t green_mask_size; + uint8_t blue_mask_pos; + uint8_t blue_mask_size; + uint8_t reserved_mask_pos; + uint8_t reserved_mask_size; + uint8_t orientation; + /* struct lb_framebuffer_flags flags; possible trailing padding — + confirm exact struct size against sizeof() in the real header */ +}; +``` +**Action required**: before writing the parser, confirm (a) whether +`physical_address` is a plain `uint64_t` or coreboot's split `lb_uint64_t` +(hi/lo 32-bit halves) in the version you clone, and (b) the exact total +struct size/padding, by reading the header directly and/or writing a tiny +host-side C program that does `printf("%zu\n", sizeof(struct lb_framebuffer))` +against the real, included header. + +## What the stub must do + +1. Entry point conforms to the **arm64 Linux kernel boot protocol** (this is + what depthcharge expects to jump into): entered with MMU and D-cache off, + `x0` = physical address of a DTB blob, `x1`–`x3` = 0. The binary itself + must also carry a valid **arm64 "Image" header** (magic `ARM\x64` at byte + offset 0x38, `code0`/`code1` branch-past-header instructions, `text_offset`, + `image_size`, etc.) — confirm the exact header layout against the Linux + kernel's `Documentation/arch/arm64/booting.rst` (or + `arch/arm64/kernel/head.S`) in a cloned `torvalds/linux` tree, since + depthcharge's loader is written to accept real Linux Image binaries and + the packing step below (`mkdepthcharge`) will otherwise choke or + misinterpret the payload. +2. Parse the DTB at `x0` (a small/partial hand-rolled parser is fine — you + only need to find one node — but using `libfdt` if it's easy to statically + link in a freestanding way is also acceptable) to locate `/firmware/coreboot` + and extract the first `reg` pair (LBIO table address + size). Do **not** + hardcode `0xffed9000`/`0x380` in the shipped stub logic — read them from + the DTB at runtime, since this is the general mechanism; the constants + above are only for your own manual verification/testing during + development. +3. Verify the `"LBIO"` signature at that address, walk `lb_record` entries + using `header_bytes`/`table_entries`, find the one with + `tag == LB_TAG_FRAMEBUFFER (0x12)`. +4. Using `physical_address`, `x_resolution`, `y_resolution`, `bytes_per_line`, + `bits_per_pixel`: fill the entire visible framebuffer with a single solid + color, per the checkpoint sequence below, with a fixed delay (spin-loop is + fine, no timer driver needed) between each so a human watching the screen + can see each stage. + +## Checkpoint color convention (in order, ~2 seconds each, hold last color forever) + +1. **Red** — stub entered and running (proves depthcharge jumped here + correctly and code is executing). +2. **Yellow** — DTB parsed, `/firmware/coreboot` node found. +3. **Green** — LBIO signature verified, framebuffer record found. +4. **Blue** — framebuffer fill of the *previous* checkpoint colors succeeded + (i.e., blue only appears if red/yellow/green were each visibly, correctly + drawn — this is your final "everything worked" signal). +5. If any step fails, **halt on the last successful color** (infinite loop, + do not proceed) rather than showing a "fail" color — the *stuck* color + itself tells us which stage broke. + +## Build and packaging + +- Toolchain: `aarch64-linux-gnu-gcc` (freestanding: `-ffreestanding -nostdlib + -static`, no libc), or hand-written assembly if simpler given the small + scope. +- Package with `mkdepthcharge` (from https://github.com/alpernebbi/depthcharge-tools, + wraps `mkimage`/`vbutil_kernel`) targeting **arm64**, using the ChromeOS + devkeys (`/usr/share/vboot/devkeys/kernel.keyblock` + + `kernel_data_key.vbprivk`) already trusted by this device's firmware. + Confirm against `mkdepthcharge --help` and its source whether a raw + freestanding binary needs to be wrapped in a FIT/uImage first (via + `mkimage`) or can be passed directly — do not assume. +- **Do not pass a real DTB for depthcharge to hand to us for real** — we want + depthcharge's own normal DTB selection behavior (it already knows how to + pick/pass the correct krane DTB when booting a "kernel"), so build the FIT + the same way `mkdepthcharge` would for a normal kernel+dtb pair, just with + our stub binary in place of vmlinuz and no initramfs needed. + +## Safety rules — non-negotiable + +1. **Never write the packed image directly to `/dev/mmcblk0p1`.** Every test + cycle targets a **USB stick** first (`dev_boot_usb=1` must be set; boot via + the ChromeOS dev-mode menu's USB option). Only after a stub has been + confirmed working from USB — visually, by a human watching the screen — + should writing to internal storage even be discussed, and that should be a + separate, explicitly-confirmed step, not something done automatically as + part of a build/test loop. +2. Keep the current, known-working pmOS kernel image backed up on the USB + stick itself (not only on the internal eMMC), so recovery never depends on + anything that might get overwritten. +3. If a build step, packing step, or the arm64 Image header requirements are + ambiguous or contradicted by what you find in source, **stop and report + the ambiguity** rather than guessing and flashing — getting the on-disk + format wrong here produces a hang with zero diagnostic output, which is + the one failure mode this whole exercise exists to avoid. +4. Treat every numeric fact in this prompt (struct layouts, offsets, table + address) as "reported, needs verification against source," not as + ground truth — cross-check each against the actual cloned coreboot/Linux + trees before relying on it in code. + +## Deliverables + +1. Stub source (assembly + minimal C, or pure assembly), with comments + explaining each stage against the checkpoint list above. +2. Build script producing the raw binary. +3. Packing command(s) producing a bootable USB disk image. +4. A short README noting which of the "verify against source" items above + were checked, what was found, and any deviations from this prompt's + assumptions. diff --git a/krane-fb-stub-payload.bin b/krane-fb-stub-payload.bin new file mode 100644 index 0000000..38bed8a Binary files /dev/null and b/krane-fb-stub-payload.bin differ diff --git a/krane-fb-stub.bin b/krane-fb-stub.bin new file mode 100755 index 0000000..a81aed5 Binary files /dev/null and b/krane-fb-stub.bin differ diff --git a/krane-sku176.dtb b/krane-sku176.dtb new file mode 100644 index 0000000..2403de6 Binary files /dev/null and b/krane-sku176.dtb differ diff --git a/linker.ld b/linker.ld new file mode 100644 index 0000000..51ca1db --- /dev/null +++ b/linker.ld @@ -0,0 +1,16 @@ +OUTPUT_FORMAT("elf64-littleaarch64") +OUTPUT_ARCH(aarch64) +ENTRY(_start) +SECTIONS +{ + . = 0; + + .head : { KEEP(*(.head)) } + .text : { *(.text*) } + .rodata : { *(.rodata*) *(.srodata*) } + .data : { *(.data*) *(.sdata*) *(.stacksec) } + .bss : { *(.bss*) *(COMMON) } + + /DISCARD/ : { *(.eh_frame*) *(.comment) *(.note*) } + ASSERT(SIZEOF(.bss) == 0, "non-empty .bss: image_size would not cover it") +} diff --git a/main.c b/main.c new file mode 100644 index 0000000..1433853 --- /dev/null +++ b/main.c @@ -0,0 +1,271 @@ +/* + * krane-fb-stub — freestanding, position-independent payload main. + * + * Checkpoint sequence (from the task spec): + * 1. RED — stub entered and running (depthcharge jumped here) + * 2. YELLOW — DTB parsed, /firmware/coreboot node found + * 3. GREEN — "LBIO" signature verified, LB_TAG_FRAMEBUFFER record found + * 4. BLUE — previous fills visibly succeeded; hold blue forever + * On any failure: spin forever on the LAST SUCCESSFUL color. + * + * Deviation from the prompt (documented in README): the framebuffer + * address is only knowable after parsing the DTB and the coreboot table, + * so "red on entry" is physically impossible. Parsing happens first + * (fast, <1ms); if any parse stage fails the screen keeps showing + * depthcharge's own output (the dev-mode menu), which is an even + * stronger "stuck at stage N" signal than a stuck color. + */ + +typedef unsigned char u8; +typedef unsigned short u16; +typedef unsigned int u32; +typedef unsigned long long u64; + +/* ---- platform helpers --------------------------------------------------- */ + +static void halt(void) +{ + for (;;) + __asm__ volatile("wfe"); +} + +#ifdef __aarch64__ +/* Generic timer read — not a timer driver, just two system registers. */ +static void delay_ms(unsigned ms) +{ + u64 frq, t; + __asm__ volatile("mrs %0, cntfrq_el0" : "=r"(frq)); + if (!frq) { /* paranoia: fallback spin */ + volatile u64 n = (u64)ms * 200000; + while (n--) + ; + return; + } + __asm__ volatile("mrs %0, cntpct_el0" : "=r"(t)); + u64 end = t + (frq / 1000) * (u64)ms; + do { + __asm__ volatile("mrs %0, cntpct_el0" : "=r"(t)); + } while (t < end); +} +#else +/* host test build */ +static void delay_ms(unsigned ms) { (void)ms; } +#endif + +static u32 rd32le(const void *p) { return *(volatile const u32 *)p; } +static u32 rd32be(const void *p) { return __builtin_bswap32(*(volatile const u32 *)p); } + +static int streq(const char *a, const char *b) +{ + while (*a && *a == *b) { a++; b++; } + return *a == *b; +} + +/* ---- FDT parsing -------------------------------------------------------- */ + +#define FDT_MAGIC 0xd00dfeedu +#define FDT_BEGIN_NODE 1 +#define FDT_END_NODE 2 +#define FDT_PROP 3 +#define FDT_NOP 4 +#define FDT_END 9 + +struct fbinfo { + u64 pa; + u32 xres, yres, bpl; + u8 bpp, rpos, rsize, gpos, gsize, bpos, bsize; +}; + +/* + * Walk the FDT at `dt`, find /firmware/coreboot, return reg pair #1 + * (coreboot table address + size). Cell counts tracked from + * #address-cells/#size-cells properties; krane root is 2/2 and + * depthcharge's fixup adds them on /firmware as well. + */ +static int find_coreboot_reg(const void *dt, u64 *addr, u32 *size) +{ + const u8 *base = (const u8 *)dt; + const u8 *sbase = base + rd32be(base + 12); /* off_dt_strings */ + const u32 *p = (const u32 *)(base + rd32be(base + 8)); /* off_dt_struct */ + + if (rd32be(base) != FDT_MAGIC) + return -1; + + int depth = 0; + const char *path[8]; + u32 ac = 2, sc = 2; /* krane root: #address/size-cells = 2/2 */ + + for (;;) { + u32 tok = rd32be(p); p++; + + if (tok == FDT_BEGIN_NODE) { + const char *name = (const char *)p; + while (*(volatile const u8 *)p) /* skip name + NUL */ + p = (const u32 *)((const u8 *)p + 1); + p = (const u32 *)(((u64)p + 4) & ~3ull); /* + pad */ + if (depth < 8) + path[depth] = name; + depth++; + } else if (tok == FDT_END_NODE) { + depth--; + } else if (tok == FDT_PROP) { + u32 len = rd32be(p); p++; + u32 nameoff = rd32be(p); p++; + const char *pname = (const char *)(sbase + nameoff); + + if (len == 4 && streq(pname, "#address-cells")) + ac = rd32be(p); + else if (len == 4 && streq(pname, "#size-cells")) + sc = rd32be(p); + else if (depth == 3 && streq(path[1], "firmware") && + streq(path[2], "coreboot") && streq(pname, "reg") && + len >= 16 && ac == 2 && sc == 2) { + /* first (address, size) pair, big-endian cells */ + *addr = (u64)rd32be(p) << 32 | rd32be(p + 1); + *size = (u64)rd32be(p + 2) << 32 | rd32be(p + 3); + return 0; + } + p = (const u32 *)((const u8 *)p + ((len + 3) & ~3ull)); + } else if (tok == FDT_NOP) { + continue; + } else { /* FDT_END or garbage */ + return -1; + } + } +} + +/* ---- coreboot table walk ------------------------------------------------ */ + +#define LB_TAG_FRAMEBUFFER 0x12 + +/* + * Layout verified against coreboot + * src/commonlib/include/commonlib/coreboot_tables.h (see README): + * struct lb_header: sig[4] "LBIO", header_bytes, header_checksum, + * table_bytes, table_checksum, table_entries (LE u32) + * struct lb_record: tag, size + * struct lb_framebuffer, offsets from record start (record = tag, size, ...): + * physical_address @8 (lb_uint64_t = 4-byte-aligned u64, little-endian) + * x_resolution @16, y_resolution @20, bytes_per_line @24, + * bits_per_pixel @28, red_pos/size @29/30, green @31/32, + * blue @33/34, reserved @35/36, orientation @37, flags @38, pad @39 + * sizeof(struct lb_framebuffer) == 40 (host-verified with real header). + */ +static int find_framebuffer(u64 table, struct fbinfo *fb) +{ + const u8 *h = (const u8 *)table; + + if (rd32le(h) != 0x4F49424Cu) /* "LBIO", 'L' = lowest byte */ + return -1; + + u32 header_bytes = rd32le(h + 4); + u32 entries = rd32le(h + 20); + if (header_bytes < 24 || header_bytes > 4096 || + entries == 0 || entries > 4096) + return -1; + + const u8 *rec = h + header_bytes; + for (u32 i = 0; i < entries; i++) { + u32 tag = rd32le(rec); + u32 rsize = rd32le(rec + 4); + if (rsize < 8) + return -1; + if (tag == LB_TAG_FRAMEBUFFER) { + if (rsize < 40) + return -1; + fb->pa = (u64)rd32le(rec + 8) | + (u64)rd32le(rec + 12) << 32; + fb->xres = rd32le(rec + 16); + fb->yres = rd32le(rec + 20); + fb->bpl = rd32le(rec + 24); + fb->bpp = rec[28]; + fb->rpos = rec[29]; fb->rsize = rec[30]; + fb->gpos = rec[31]; fb->gsize = rec[32]; + fb->bpos = rec[33]; fb->bsize = rec[34]; + if (!fb->pa || !fb->xres || !fb->yres || !fb->bpl) + return -1; + if (fb->bpp % 8 || fb->bpp < 8 || fb->bpp > 32) + return -1; + if (fb->bpl < (u32)fb->xres * (fb->bpp / 8)) + return -1; + if (fb->xres > 16384 || fb->yres > 16384) + return -1; + return 0; + } + rec += rsize; + } + return -1; +} + +/* ---- framebuffer fill --------------------------------------------------- */ + +static u64 mkcolor(const struct fbinfo *f, int r, int g, int b) +{ + u64 v = 0; + if (r && f->rsize && f->rsize < 32) + v |= (u64)((1u << f->rsize) - 1) << f->rpos; + if (g && f->gsize && f->gsize < 32) + v |= (u64)((1u << f->gsize) - 1) << f->gpos; + if (b && f->bsize && f->bsize < 32) + v |= (u64)((1u << f->bsize) - 1) << f->bpos; + return v; +} + +static void fill_screen(const struct fbinfo *f, u64 val) +{ + u32 bppb = f->bpp / 8; + + for (u32 y = 0; y < f->yres; y++) { + volatile u8 *row = (volatile u8 *)f->pa + (u64)y * f->bpl; + for (u32 x = 0; x < f->xres; x++) { + volatile u8 *px = row + (u64)x * bppb; + switch (bppb) { + case 4: *(volatile u32 *)px = (u32)val; break; + case 2: *(volatile u16 *)px = (u16)val; break; + case 1: *px = (u8)val; break; + case 3: + px[0] = (u8)val; + px[1] = (u8)(val >> 8); + px[2] = (u8)(val >> 16); + break; + } + } + } +} + +/* ---- main checkpoint sequence ------------------------------------------- */ + +static void checkpoint(const struct fbinfo *f, int r, int g, int b) +{ + fill_screen(f, mkcolor(f, r, g, b)); + delay_ms(2000); +} + +void cmain(u64 dtb) +{ + struct fbinfo fb; + u64 lbio_addr; + u32 lbio_size; + + /* + * Parse everything first (instantaneous); see deviation note above. + * If either stage fails we halt having painted nothing — the screen + * keeps depthcharge's output, which identifies the failure stage. + */ + if (find_coreboot_reg((const void *)dtb, &lbio_addr, &lbio_size)) + halt(); /* stuck on depthcharge output */ + + if (find_framebuffer(lbio_addr, &fb)) + halt(); /* stuck on depthcharge output */ + + /* Checkpoint 1: red — we entered, parsed, and can draw. */ + checkpoint(&fb, 1, 0, 0); /* red */ + /* Checkpoint 2: yellow — DTB stage. */ + checkpoint(&fb, 1, 1, 0); /* yellow */ + /* Checkpoint 3: green — LBIO + framebuffer record found. */ + checkpoint(&fb, 0, 1, 0); /* green */ + /* Checkpoint 4: blue — all earlier fills visibly succeeded. */ + checkpoint(&fb, 0, 0, 1); /* blue */ + + halt(); /* hold blue forever */ +} diff --git a/qemu_test.py b/qemu_test.py new file mode 100755 index 0000000..2ca318a --- /dev/null +++ b/qemu_test.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +""" +End-to-end smoke test for krane-fb-stub under qemu-system-aarch64. + +- test.dtb: krane-sku176.dtb with a /firmware/coreboot node added (as + depthcharge's fixup produces), reg pair 1 -> synthetic LBIO table at + 0x45000000, written into guest RAM by a qemu loader device. +- The LBIO table describes a 64x16 xrgb framebuffer at 0x45200000. +- The stub paints red -> yellow -> green -> blue, ~2s apart, into that buffer. + We sample the framebuffer color via QEMU monitor pmemsave snapshots and + verify the full checkpoint sequence plus final hold-on-blue. + +Usage: python3 qemu_test.py +""" +import os +import select +import socket +import struct +import subprocess +import time + +LBIO_ADDR = 0x45000000 +FB_ADDR = 0x45200000 +FB_X, FB_Y, FB_BPL = 64, 16, 256 + +RED, YELLOW, GREEN, BLUE = 0x00FF0000, 0x00FFFF00, 0x0000FF00, 0x000000FF + + +def build_lbio_table(): + t = bytearray(24 + 48) + t[0:4] = b"LBIO" + t[4:8] = struct.pack("; + #size-cells = <0x02>; + ranges; + + coreboot { + compatible = "coreboot"; + reg = <0x00 0x45000000 0x00 0x380 + 0x00 0x45100000 0x00 0x127000>; + }; + }; +}; +""") + subprocess.run(["dtc", "-I", "dts", "-O", "dtb", "test.dts", "-o", "test.dtb"], + check=True) + out = subprocess.run(["dtc", "-I", "dtb", "-O", "dts", "test.dtb"], + capture_output=True, text=True).stdout + assert "reg = <0x00 0x45000000 0x00 0x380" in out, "coreboot reg missing" + print("test.dtb ready (with /firmware/coreboot)") + + +def main(): + make_test_dtb() + with open("lbio.bin", "wb") as f: + f.write(build_lbio_table()) + + parent, child = socket.socketpair() + qemu = subprocess.Popen( + ["qemu-system-aarch64", "-M", "virt", "-m", "1024", + "-nographic", "-serial", "null", "-monitor", "stdio", "-cpu", "max", + "-kernel", "krane-fb-stub.bin", "-dtb", "test.dtb", + "-device", f"loader,file=lbio.bin,addr=0x{LBIO_ADDR:x},force-raw=on"], + stdin=child.fileno(), stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + child.close() + time.sleep(2) + + def drain(): + out = b"" + while select.select([qemu.stdout], [], [], 0.05)[0]: + c = os.read(qemu.stdout.fileno(), 65536) + if not c: + break + out += c + return out + + drain() + + def pmem(addr, size, path): + if os.path.exists(path): + os.remove(path) + parent.sendall(f'pmemsave 0x{addr:x} {size} "{path}"\n'.encode()) + for _ in range(60): + drain() + if os.path.exists(path) and os.path.getsize(path) == size: + return open(path, "rb").read() + time.sleep(0.1) + raise RuntimeError(path) + + fails = 0 + + def check(name, ok, detail=""): + nonlocal fails + print(f"{name:34s} {'PASS' if ok else 'FAIL'} {detail}") + if not ok: + fails += 1 + + # Snapshot #1 at ~6s wall time (yellow window; qemu boot eats ~1s of + # the first 2s red window, so 6s lands solidly in yellow), #2 at ~12s + # (well into the held blue). + time.sleep(1) + t3 = pmem(FB_ADDR, FB_X * FB_Y * 4, "/tmp/fb_t3.bin") + time.sleep(4) + t9 = pmem(FB_ADDR, FB_X * FB_Y * 4, "/tmp/fb_t9.bin") + parent.sendall(b"quit\n") + qemu.wait(timeout=10) + + def px(d, i): + return struct.unpack_from(" everything must be position-independent (PC-relative only). + * + * 64-byte arm64 Image header (booting.rst): + * 0x00 code0 b past header (this is where we are entered) + * 0x04 code1 unused + * 0x08 text_offset 0 (image sits AT the 2M-aligned base) + * 0x10 image_size patched by build.sh to the final file size + * 0x18 flags bit3 = "place anywhere in 48-bit range" + * 0x20..0x37 reserved = 0 + * 0x38 magic 0x644d5241 "ARM\x64" (checked by depthcharge) + * 0x3c res5 0 + */ + + .section .head, "ax" + .globl _start +_start: + b real_start /* code0: branch to 0x40 */ + .word 0 /* code1 */ + .quad 0 /* text_offset */ + .quad 0 /* image_size — PATCHED by build.sh */ + .quad 1 /* flags: bit3 set (KASLR placement) */ + .quad 0 /* res2 */ + .quad 0 /* res3 */ + .quad 0 /* res4 */ + .word 0x644d5241 /* magic "ARM\x64" */ + .word 0 /* res5 */ + + .section .text, "ax" +real_start: + /* depthcharge's SP is dead; use our own stack inside the image */ + adrp x4, stack_top + add x4, x4, :lo12:stack_top + mov sp, x4 + bl cmain +1: b 1b /* cmain never returns; belt+braces halt */ + + .section .stacksec, "aw" + .align 4 + .globl stack_top +stack_bottom: + .zero 16384 +stack_top: