Luckfox Pico — build & development
SeedSigner OS supports the Luckfox Pico board family (Rockchip RV1103 / RV1106) alongside the Raspberry Pi / La Frite targets. Unlike the Pi builds (mainline Buildroot via the opt/buildroot submodule), the Luckfox build drives the Rockchip Luckfox Pico vendor SDK (cloned at build time) and injects the SeedSigner packages, defconfig, rootfs files and patches into it. Everything Luckfox-specific lives under opt/luckfox/.
Some deeper docs in this folder (e.g.
OS-build-instructions.md,TOOLCHAIN_ANALYSIS.md) were carried over from the original standaloneseedsigner-luckfox-picorepo and may still describe the oldbuildroot/layout. The commands below are the current ones for this repo.
Hardware targets
| Model | SoC | Boot media |
|---|---|---|
| Luckfox Pico Mini | RV1103 | SD_CARD, SPI_NAND |
| Luckfox Pico Pro Max | RV1106 | SD_CARD, SPI_NAND |
| Luckfox Pico Pi | RV1106 | EMMC |
Building
GitHub Actions (recommended)
.github/workflows/build-luckfox.yml — “Build SeedSigner OS (Luckfox Pico)”. Run it from the Actions tab (or gh workflow run) choosing hardware_type, boot_medium, and seedsigner_branch (the app branch to build). Specific inputs build a single combo; a push builds the full matrix. It is also a reusable workflow — the seedsigner app repo’s “Build Luckfox” workflow calls it to build an image from an app branch.
Local — Docker
From a checkout, use the repo-root dispatcher:
./build.sh --luckfox build --microsd --model mini # or --nand ; --model max|both
(equivalently opt/luckfox/build.sh build --microsd). Artifacts land in opt/luckfox/build-output/.
Local — no Docker (Ubuntu 22.04)
opt/luckfox/build-local.sh --check-deps # first time: install deps
opt/luckfox/build-local.sh --hardware mini --boot nand
How the build works (dev process)
- Clone the Rockchip Luckfox Pico SDK (
3rdIteration/luckfox-pico) — brings U-Boot, the kernel, and its own Buildroot. - Apply SDK patches (partition tables, UART2 console, HWRNG, Rust-on-uClibc) — mostly in-line
sed/append fragments against the SDK’s board configs, DTS and kernel defconfig. - Inject SeedSigner packages: copy
opt/external-packages/*into the SDK Buildroot’spackage/and append amenu "SeedSigner"block thatsources each custom package’sConfig.in. Two-place rule: a custom package needs both the menusourceline and aBR2_PACKAGE_*=yinopt/luckfox/configs/luckfox_pico_defconfig. See the canonical package set inAGENTS.md. - Copy the SeedSigner app source + rootfs files (
opt/luckfox/files/*), set the hostname toseedsigner-os, and generate the identity/provenance marker/etc/seedsigner-os-release(repo/branch/commit/date for both seedsigner-os and the app — surfaced in the app’s System Info screen). - Build and package the SD / NAND / eMMC images.
Three build implementations duplicate this logic and must be kept in sync: .github/workflows/build-luckfox.yml (authoritative), opt/luckfox/os-build.sh (in the Docker build), and opt/luckfox/build-local.sh (no-Docker). The toolchain is uClibc (arm-rockchip830-linux-uclibcgnueabihf, ARMv7); note python-numpy is not buildable there (the app tolerates its absence).
Build variants — dev vs non-dev
The build_variant workflow input (choice; non-dev or dev) selects a hardened production image vs a debuggable one. Automatic push/PR CI always builds dev (it validates the full debuggable stack); a manual “Run workflow” defaults to non-dev (pick dev to override). Local builds set the same thing via SEEDSIGNER_BUILD_VARIANT=dev|non-dev (default non-dev).
For the serial console specifically, the disable_uart2_console_debug input defaults to auto, which strips the console + FIQ debugger on every variant, dev included; only an explicit false/0 keeps them. Local Docker builds take the same lever as --disable-uart2-console-debug auto|true|false on build.sh; build-local.sh defaults to stripped with an opt-in --enable-uart2-console.
The console and the SEC1210 smartcard HAT share a UART. A console-on image will not initialise the reader: kernel log output is injected into the ccid driver’s AT-command stream, and with only the active reader on the line early boot wedges instead. That is why the console is now stripped by default on every variant — dev images keep their other two bench paths (adb via USB gadget, and telnet via debug_network=on), so no image loses the card reader unless it was explicitly built with disable_uart2_console_debug=false for boards with no reader attached.
The Luckfox implementation differs from the Pi / La Frite profiles (which have parallel -dev/non-dev profile directories). Luckfox is the Rockchip SDK with a single defconfig + an SDK-provided rootfs, so non-dev is a set of build-time hardening steps gated on the flag, not a second profile tree. It maps to the AGENTS.md non-dev leak-vector table like so (Luckfox has no HDMI; its extra vectors are the USB gadget and Ethernet on Pro Max / Pico Pi).
Threat model: root-level code execution. The SeedSigner Python app runs as root, so userspace hardening is not a control by itself — root can simply ifconfig eth0 up; udhcpc, or insmod a WiFi driver from /oem/usr/ko. Anything that must genuinely hold is therefore removed from the kernel, with the userspace steps kept as defence in depth.
The oem partition used to be a hole and is gone. It was a separate, unsigned volume the board mounted and executed as root — a 2026-09-23 PoC ran a tamper spliced into its RkLunch.sh as uid 0 on a fully fused board (see secure-boot.md §7.10). It is now removed and folded into the signed rootfs: apply-partition-layout.sh sets RK_BUILD_APP_TO_OEM_PARTITION=n, so /oem/usr/ko, the camera iqfiles and RkLunch.sh live inside the rootfs squashfs and are covered by the rootfs signature.
| Vector | non-dev closes it via | Where |
|---|---|---|
| Kernel serial console | strip console=ttyFIQ0/earlycon/user_debug bootargs (default on for every variant; disable_uart2_console_debug=false is the explicit bench override) | “Configure UART2 console debug” (all three builds) |
| Serial login (getty) | # BR2_TARGET_GENERIC_GETTY is not set in the defconfig and comment console/tty getty/login/shell respawn lines in the rootfs /etc/inittab | defconfig sed + harden-nondev.sh |
| Networking — kernel | INET/PACKET/IPV6/NETDEVICES off plus the Ethernet MAC+PHY (STMMAC_ETH/RK630_PHY) and USB_CONFIGFS_RNDIS: root cannot create an interface or open an AF_INET socket because the stack isn’t compiled in. Gated on debug_network=off | strip-kernel-network.sh (Group A) |
| WiFi — kernel | WL_ROCKCHIP (umbrella that selects CFG80211+MAC80211 and sources every vendor WiFi Kconfig) + RTL8723BS off, so the 802.11 stack and all 8 vendor drivers are never built and can’t reach /oem/usr/ko. Always stripped on non-dev | strip-kernel-network.sh (Group B) |
| Networking — userspace (defence in depth) | no interface bring-up: S*network* stubbed to loopback-only, DHCP neutered, /etc/network/interfaces = lo, telnet/ssh/dropbear init scripts removed | harden-nondev.sh (HARDEN_DISABLE_NETWORK) |
| USB gadget (ADB + RNDIS) | DTS dr_mode = "host": dwc3 registers host-only so /sys/class/udc/ is empty and a configfs gadget has nothing to bind to — root cannot re-enable adb at runtime. Plus adb userspace strip: stub adbd/usbdevice, blank the gadget function config, comment gadget lines in RkLunch.sh. S*usb* init scripts are kept — S50usbdevice mounts configfs (the SPI display depends on it) and is patched host-aware instead | configure-usb-mode.sh + harden-nondev.sh (HARDEN_DISABLE_ADB=1) + patch-s50usbdevice.sh |
| Logging daemons | remove syslogd/klogd autostart | harden-nondev.sh |
| Dev / network CLI tools | drop python-pip, wget, libcurl/curl from the target | defconfig sed |
| Untrusted storage mounts | /userdata (the only untrusted writable partition) and removable FAT cards mount noexec,nosuid,nodev — all variants. The SDK does not use fstab for these partitions: it generates /etc/init.d/S20linkmount, whose template lives in project/build.sh and is re-copied into the rootfs during build.sh firmware (after harden-nondev.sh), so only a generator patch survives | patch-linkmount-hardening.sh (via apply_sdk_patches, all three builds), files/fat-fsck-hotplug, files/S02fsck |
Kernel symbols that must stay enabled — each would break the device: CONFIG_NET/CONFIG_UNIX (pcscd uses an AF_UNIX socket; dropping NET kills smartcards), CONFIG_MODULES (camera drivers are =m), and CONFIG_USB_GADGET — the sole provider of configfs here (USB_CONFIGFS → USB_LIBCOMPOSITE → select CONFIGFS_FS); without configfs luckfox-config cannot create the device-tree overlays that enable SPI0 and the display dies. That is why ADB is blocked via dr_mode=host rather than by disabling the gadget stack. strip-kernel-network.sh refuses to run if any of these is off, and assert-kernel-network.sh re-checks them after the build.
Always verify against the generated .config, never the defconfig — Kconfig silently drops defconfig lines whose symbol doesn’t exist or whose dependencies are unmet (exactly how the first U-Boot bootcount attempt shipped green with no bootcount code). assert-kernel-network.sh runs after build.sh kernel and again after build.sh firmware, checking the built config, a CONFIGFS_FS=y display canary, and that no wireless .ko reached the oem payload.
The hardening lives in shared opt/luckfox/*.sh scripts (harden-nondev.sh, configure-usb-mode.sh, patch-s50usbdevice.sh, uboot-recovery-config.sh, strip-kernel-network.sh, assert-kernel-network.sh, optimize-nondev.sh, patch-oem-pre-hook.sh, prune-oem-iqfiles.sh) called identically by all three build implementations — the GitHub Actions workflow and both local Docker builds (os-build.sh, build-local.sh) — so CI and local images get the same hardening; change the script, never one caller. Because the SDK rootfs layout varies by version, every userspace step is guarded/no-op if the target is absent and logs each file it did/didn’t touch.
A green build does not prove the vectors were closed — review the [harden] / [kstrip] / [kassert] log lines, then verify on hardware. As root on the device: ip link shows no eth0 and ifconfig eth0 up fails; no wifi .ko exists under /oem/usr/ko to insmod; ls /sys/class/udc/ is empty and adb devices finds nothing; nothing listens on :22/:23 and no DHCP requests appear on the LAN; no console output or login prompt on the serial header; no syslogd/klogd; which pip curl wget empty; mount | grep -E 'userdata|microsd' shows noexec,nosuid,nodev on both. Also confirm the things the strip must not have broken: display (the configfs canary), camera (modules still load from /oem/usr/ko) and smartcards (the AF_UNIX/pcscd path). The reboot-to-Loader Power option / rk-reboot is retained in both variants.
Non-dev size / boot optimizations
Non-dev images also get size and boot tweaks (dev keeps SDK defaults). Companion script opt/luckfox/optimize-nondev.sh <ROOTFS_DIR> runs right after harden-nondev.sh:
| Tweak | What / where | Notes |
|---|---|---|
| Optimize for size | defconfig BR2_OPTIMIZE_3=y → BR2_OPTIMIZE_S=y | smaller binaries, marginally slower |
| Prune test/metadata | remove tests/, *.dist-info/*.egg-info under site-packages + /opt/src, and /opt/tools | optimize-nondev.sh |
| Prune camera iqfiles | keep only the board’s sensor (IQFILES_KEEP), remove the rest from the staged oem tree before it is folded into the rootfs at /oem | prune-oem-iqfiles.sh, run from the SDK’s pre-build-OEM hook (see below); verify camera |
| Quiet boot | append quiet loglevel=3 to the DTS bootargs | marginal once console is stripped |
| U-Boot bootdelay | zero any non-zero CONFIG_BOOTDELAY/bootdelay= in the SDK U-Boot | best-effort, guarded |
| UI-first camera | optimize-nondev.sh drops /etc/seedsigner-nondev; start-seedsigner.sh then backgrounds the ~4s camera-graph bootstrap so the UI comes up first | experimental — verify camera on hardware |
Same discipline as hardening: everything keys off build_variant=non-dev, is guarded/no-op when the SDK target isn’t present, and logs under [optimize] / [iqprune]. iqfiles pruning and the UI-first camera reorder must be verified on real hardware (scan a QR) — a green build proves nothing about the camera. Both are trivially revertible (widen IQFILES_KEEP; the reorder only triggers when the /etc/seedsigner-nondev marker exists).
Why iqfiles pruning and the /oem prep use an SDK hook. Anything that edits the oem tree cannot run from optimize-nondev.sh: oem is assembled by the SDK’s __PACKAGE_OEM, which is called only from build_firmware() (i.e. during build.sh firmware), long after the rootfs/app install step. The prune lived there originally and therefore silently did nothing in every build until 2026-08-06. It now runs from prune-oem-iqfiles.sh, invoked via the SDK’s __RUN_PRE_BUILD_OEM_SCRIPT hook — which fires after __PACKAGE_OEM and before the tree is folded into the rootfs and packed, the one window where the staged oem tree exists and is still editable. patch-oem-pre-hook.sh installs the calls by appending to whatever script RK_PRE_BUILD_OEM_SCRIPT names (every Luckfox board config points at the vendor’s luckfox-buildroot-oem-pre.sh, which prunes unused libs there for the same reason — replacing it would drop those prunes). The same hook also runs prepare-oem-for-rootfs.sh, which fixes what assumed a writable /oem (notably repointing RkLunch.sh’s core_pattern to /tmp). The prune decides what to delete before deleting anything and aborts without touching a file if IQFILES_KEEP matches nothing, so a bad keep-list can’t silently ship a camera with no tuning data. The fold itself is done by the SDK (RK_BUILD_APP_TO_OEM_PARTITION=n, set in apply-partition-layout.sh), not by this hook.
Keeping the three build implementations in sync
There are three ways to build: .github/workflows/build-luckfox.yml (CI), opt/luckfox/os-build.sh (Docker), and opt/luckfox/build-local.sh (no Docker). Shared logic lives in opt/luckfox/*.sh; change the script, never one caller. Every one of these is invoked by all three:
| Script | Does |
|---|---|
apply-partition-layout.sh | flash layout incl. the userdata partition; removes the oem partition and folds it into the rootfs (RK_BUILD_APP_TO_OEM_PARTITION=n) |
pin-spidev-bufsiz.sh | spidev.bufsiz=8192 on the kernel command line |
readonly-rootfs.sh / assert-readonly-rootfs.sh | squashfs root + overlay, and its verification |
install-gnupg-home.sh | stages the GnuPG agent/scdaemon config seeded into GNUPGHOME |
install-build-time.sh | bakes /etc/seedsigner-build-time from the pinned app commit; the boot clock’s default |
strip-whitespace-filenames.sh | drops whitespace-named entries anywhere in the rootfs before ext4 packing (upstream test fixtures like setuptools’ vendored Lorem ipsum.txt); debugfs’s line-oriented command file cannot address them, so they break every ext4 target. Every removal is logged |
strip-kernel-network.sh / assert-kernel-network.sh | network/WiFi/coredump strip, and its verification |
configure-usb-mode.sh, harden-nondev.sh, optimize-nondev.sh, patch-s50usbdevice.sh, patch-oem-pre-hook.sh, prune-oem-iqfiles.sh, prepare-oem-for-rootfs.sh, uboot-recovery-config.sh, compile-translations.sh | as named |
Why this is a hard rule, not a style preference. Two of these were inlined and duplicated instead, and both copies drifted into shipping a different device:
- Partition layout. The local builds deleted the
userdatapartition (20M(oem),99M(rootfs), plus a sed strippinguserdata@/userdata@ubifs) while CI kept it. Same repo, same board, silently different images — and the local one had nowhere to persist settings or write a boot log. With a read-only rootfs it had nowhere writable at all. spidev.bufsiz. Present only in CI. A locally built Mini kept the vendor default, hit the order-6 allocation failure inspidev_open(), and came up with no display — while its splash drew fine on the same boot, which makes it look like a display bug rather than a build difference.
Neither had a build-time signal. Both are now single scripts that hard-fail if their result is wrong.
opt/luckfox/build.sh (the Docker wrapper) mirrors the CI workflow inputs: --variant, --readonly-rootfs, --usb-mode, --debug-network, --seedsigner-ref, and --model mini|max|pi|both. Previously only BUILD_MODEL/BUILD_JOBS crossed the container boundary, so a Docker build silently took the defaults no matter what was asked for.
Pinning the SeedSigner app
The app is the only component this repo does not pin — the SDK is fetched at a fixed revision, but the app is cloned from a ref. --seedsigner-ref accepts a branch, a release tag, or a commit (anything git clone -b takes), and a tag is the only one of those that is a fixed target:
./build.sh --luckfox build --model max --microsd --seedsigner-ref SeSi-0.8.7+ShSi-B11
The same value goes in the workflow’s seedsigner_branch input. Whatever is used, the resolved ref and commit are stamped into /etc/seedsigner-os-release (SEEDSIGNER_APP_BRANCH / SEEDSIGNER_APP_COMMIT), so a built image always records what it contains — including the tag name, which gen-os-release.sh now prefers over the branch, since a tag build is a detached checkout that would otherwise record nothing useful.
build-local.sh reuses an existing seedsigner/ checkout rather than re-cloning, so it reports the ref and commit it found and warns when they do not match what was requested. Delete the checkout to switch refs.
System clock
The RV1106 has no battery-backed RTC, these images carry no NTP and no network, and there is no tzdata anywhere in the tree — everything runs in UTC. Nothing set the clock at all until this existed, so the device came up at whatever the SoC left in its timer, in practice a date well in the future.
That broke GPG outright. The SeedSigner app validates a new key’s expiry against datetime.now(timezone.utc) and rejects any expiry that is not after it, so with the clock past the offered default (2029-12-31 for RSA-2048, 2035-12-31 for everything else) key generation failed at the prompt with “Invalid expiration date”, before it could gpg --batch --import anything. It matters past the prompt too: a key’s creation time is an input to its fingerprint, so a key generated under a wrong clock is permanently wrong and cannot be corrected afterwards.
start-seedsigner.sh now sets the clock in init_system_clock(), called before anything else so the boot log’s own timestamps are real dates. Sources, last wins:
CLOCK_FALLBACK, the hard-coded2025-02-28 12:00. Deliberately the same constant the Pi uses, so the string is a recognisable signature meaning the build-time bake did not happen — not a plausible date./etc/seedsigner-build-time, baked byinstall-build-time.shfrom the pinned app commit’s committer date. This is the normal path, and it is reproducible: same OS commit + same--seedsigner-ref→ same byte./mnt/microsd/time.txt, the user escape hatch — same filename andYYYY-MM-DD HH:MMformat as the Pi. Best-effort here: the card is mounted by thefat-fsck-hotplugmdev rule, whichS10mdevalso triggers for partitions that already existed at power-on (a coldplug pass re-emitting their uevents) — but the mount happens asynchronously after S10mdev runs, so a power-on card may still not be mounted when the early clock init ran. The override is therefore re-checked before each app launch attempt, guarded on the file existing so a normal boot never rewinds its clock.
The set is unconditional by design. Do not add a “only if the clock looks wrong” or “only move forward” guard: the fault this fixes is a clock in the future, which such a guard would never repair. And never source the value from SOURCE_DATE_EPOCH — it is 0, and a 1970 clock is the invisible version of this bug (it passes the app’s expiry check while stamping every key with a 1970 creation date). install-build-time.sh enforces a year floor of 2020 to fail the build on exactly that edit.
MicroSD card
Cards are mounted by mdev, not at boot: the fat-fsck-hotplug rule fscks and mounts each FAT partition as it appears, and S10mdev re-emits the uevents of partitions that already existed when the board came up (a coldplug pass), so a power-on card is handled like a hot-inserted one.
Hot-swapping is not supported. The SDK’s device tree gives the MicroSD controller (&sdio) no card-detect GPIO and nothing polls for insertion, so a card inserted or removed after boot is never seen. This was verified on the Pico Pi against both our image and the vendor stock Buildroot — identical behaviour, i.e. an SDK/hardware limitation rather than something these images introduced. A removed card leaves a stale mmc object behind (accessing it produces tried to HW reset card, got error -2 / pre recovery failed! I/O errors), and a freshly inserted one does not appear until the next boot.
The rule is therefore: insert the card before power-on — which is also the natural flow for the air-gapped signing work (the card moves between PC and device while both are off). To swap cards, reboot with the new one in place; there is no userspace rescan that works around this.
Read-only root filesystem
Non-dev images mount / as squashfs with tmpfs overlays on /etc, /var, /root and /home. A dirty unmount cannot corrupt the rootfs (squashfs has no journal, no allocator and no write path) and no runtime change to / survives a reboot, because none can be committed in the first place.
This replaces a writable UBIFS root that was self-damaging in normal operation, via two confirmed writers:
luckfox-configdoessed -ion/etc/luckfox.cfgon every boot. A power cut mid-write truncates or loses the file;luckfox_load_cfgthen silentlytouches an empty one, zero device-tree overlays are created, there is no/dev/spidev0.0— and the board comes up with a black screen and no way to report why. That failure is indistinguishable on the bench from a display bug.- the shipped Python bytecode sits on the same filesystem. A truncated
.pycsurfaced asEOFError: marshal data is too shortand needed a reflash.
Neither is fixable in application code: the damage happens during UBIFS journal replay, to files nobody was deliberately writing.
| Path | State | Notes |
|---|---|---|
/ | squashfs, read-only | immutable after flash |
/etc /var /root /home | overlayfs, tmpfs upper | writable; discarded at reboot |
/opt | read-only | app + bytecode; deliberately not overlaid |
/userdata | read-write, persistent | the only persistent store — settings live here |
/oem | read-only, inside / | folded into the signed rootfs squashfs; the separate partition was removed |
Moving parts. readonly-rootfs.sh (build time) sets rootfs@IGNORE@squashfs in the board config plus RK_SQUASHFS_COMP=xz, and enables CONFIG_OVERLAY_FS in the kernel defconfig. Bootargs are not patched: the SDK derives root=/rootfstype= from the filesystem type (ubi.block=0,rootfs root=/dev/ubiblock0_0 on NAND, root=/dev/mmcblk0p<n> on eMMC). files/S01overlay mounts the overlays at runtime, and is a no-op on a writable root, so dev images are unaffected. Controlled by the readonly_rootfs build input (auto/on/off; auto = on for non-dev).
Bytecode is precompiled at build time. A read-only /opt can never cache __pycache__ at runtime, so without this every import would re-compile .py source off xz squashfs, on a single-core A7, on every boot. precompile-bytecode.sh runs after the app is staged (and after the non-dev prune) in all three build paths, using the SDK buildroot’s host python + the target python’s compileall.py (discovered, version-matched — wrong-magic .pyc would be ignored) with deterministic flags and hash-based invalidation, over /opt/src and site-packages. Same approach as the Pi profiles’ post-build scripts; .py sources stay in the image (checked-hash validation reads them).
Why the assertion matters. assert-readonly-rootfs.sh checks the generated kernel .config, never the defconfig written — Kconfig silently drops lines for symbols whose dependencies are unmet. A squashfs root on a kernel without overlayfs boots fine and then fails the first write to /etc, which presents as exactly the black screen described above. That has to fail the build, not the board.
GnuPG is on tmpfs, deliberately. The app never sets GNUPGHOME and never passes gpg --homedir, so gpg resolves its home from $HOME — which under BusyBox init is /, i.e. /.gnupg on the read-only root. Every write gpg needs then fails with “read-only file system”, which is what broke GPG key generation and key import. start-seedsigner.sh sets GNUPGHOME=/tmp/.gnupg and seeds gpg-agent.conf + scdaemon.conf there from /usr/share/seedsigner/gnupg.
/tmp rather than an overlaid path: it is a plain tmpfs available before S01overlay runs (which itself uses /tmp), so gpg cannot inherit an overlay failure — and GPG keys are wiped at reboot by construction, never written to flash. scdaemon.conf’s disable-ccid matters here: until the home was writable, gpg-agent/scdaemon could not create their sockets and never ran at all; giving them a working home means they start, and disable-ccid routes scdaemon through pcscd instead of letting it grab the SEC1210 reader directly.
The oem partition is gone (2026-09-23). It used to be a separate, unsigned, read-write volume the board mounted at /oem and ran /oem/usr/bin/RkLunch.sh from as root — outside the boot chain secure boot verifies. A tamper spliced there ran as uid 0 on a fully fused board. It is now removed and its content (camera iqfiles, .ko modules, RkLunch.sh) is folded into the signed read-only rootfs squashfs, so it is immutable and signature-covered. prepare-oem-for-rootfs.sh repoints RkLunch.sh’s core_pattern at /tmp because /oem is no longer writable. See secure-boot.md §7.10.
Boot recovery & auto-failover to Loader
So a bad image self-heals into a flashable state without the BOOT button:
- KEY3 very-long-press on Home (both variants, Luckfox only): hold KEY3 ~5 s on the home screen to reboot into rockusb Loader mode — same action as the Power menu’s “Reboot to flash mode”. A short KEY3 tap still selects. Implemented in the app (
MainMenuScreen/RebootToLoaderView). - Startup watchdog (
start-seedsigner.sh, both variants): the app writes/tmp/seedsigner-readywhen it reaches Home; if that never appears within ~120 s, or the launch retry loop is exhausted, the device reboots into Loader mode. Covers the app-crash and app-hang cases. The app ref must carry the signal — it was added in app commit689483af(2026-07-28), sodevand all release tags predate it. A signal-less app builds green, boots looking completely healthy, and reboots into Loader 120 s later, on every boot;assert-app-watchdog-signal.shtherefore fails the build after the app clone when the signal is absent (escape hatch:SEEDSIGNER_ALLOW_NO_WATCHDOG_SIGNAL=1). - Persistent boot log (
start-seedsigner.sh, both variants, off by default): when enabled, every boot is recorded to/userdata/seedsigner-boot.log(rotated once, capped at 128 KiB, deleted as soon as the app signals ready) so a boot failure that leaves a “bricked-looking” device still explains itself —/tmpis a tmpfs and is gone on the next reboot. It is off unless the build bakes/etc/seedsigner-boot-log(boot_logdispatch input /--boot-log on/SEEDSIGNER_BOOT_LOG=on): a production device must write nothing to flash, and/userdatasurvives a reflash, so a persisted app traceback would be app output left in NVS on a device meant to be air-gapped. Even when disabled, a successful boot still sweeps any stale log/.prevand core dumps from writable storage. panic=5(non-dev bootargs): a kernel panic reboots instead of hanging, giving the failover another shot. Dev keeps panics visible for debugging.- USB role (
usb_mode) — this is the ADB switch. The RV1106 USB port is either a device gadget (gadget— adb + RNDIS, for debugging) or a host (host— drives external USB peripherals like a camera or smartcard reader; no gadget, so no adb/RNDIS = air-gapped on the USB axis). Set via theusb_modedispatch input (auto/gadget/host/otg);autofollows the variant: non-dev = host, dev = gadget. Implemented as a device-tree override (&usbdrd_dwc3 { dr_mode = "host"; }) via the sharedopt/luckfox/configure-usb-mode.sh(used by CI and both local builds). On non-dev the harden step additionally strips the adb userspace (HARDEN_DISABLE_ADB=1: no-op stubs foradbd/usbdevice, blanked gadget function config) as defence in depth — but never theS*usb*init scripts:S50usbdevicemust survive (it is what mounts configfs, whichluckfox-configneeds to enable SPI0 for the display); it is instead patched host-aware byopt/luckfox/patch-s50usbdevice.sh. To debug a non-dev image, build it withusb_mode=gadget. Switching to host does not weaken recovery: rockusb Loader mode is a U-Boot/maskrom USB mode, independent of the Linux gadget, so KEY3→Loader and the U-Boot bootcount failover still enumerate the device for re-flashing. (Host mode needs the board to supply VBUS to power bus-powered peripherals; a self-powered device or powered hub always works.) - Ethernet debug channel (
debug_network) — the network switch. The Luckfox SDK kernel keepsCONFIG_INET(unlike the Pi/La Frite non-dev kernels), and the Pro Max / Pico Pi have Ethernet — so networking is closed in userspace byharden-nondev.sh(HARDEN_DISABLE_NETWORK=1, the non-dev default): no interface bring-up (S*network*stubbed to loopback-only), DHCP neutered (no broadcast of MAC/hostname on any LAN it’s plugged into),/etc/network/interfacesreduced tolo, and every telnet/ssh/dropbear init script removed. Set thedebug_networkdispatch input toonto build a non-dev image that keeps Ethernet + telnet (root shell on:23, loginroot/luckfox) as a USB-independent debug/recovery channel — invaluable for debugging host-mode images, but never ship it.
U-Boot boot-counter → loader (non-dev). This is the deepest layer — for failures the userspace watchdog can’t catch because they reboot before start-seedsigner.sh even runs (kernel panic, rootfs-mount or init failure). The non-dev build enables a U-Boot boot counter that auto-enters rockusb Loader after bootlimit consecutive such boots — no BOOT button, ADB, or working app required.
- Why a memory-backed counter (not the usual env one): this Rockchip U-Boot 2017.09 fork’s
drivers/bootcount/Kconfigonly definesCONFIG_BOOTCOUNT/CONFIG_BOOTCOUNT_EXT— there is noCONFIG_BOOTCOUNT_LIMIT/CONFIG_BOOTCOUNT_ENVsymbol, so putting those in a defconfig is silently dropped by Kconfig (verified on-device: the compiled U-Boot had zero bootcount code). The always-built generic backenddrivers/bootcount/bootcount.cis used instead, pointed at a hardware register. - U-Boot (
build-luckfox.ymlrecovery step): patches the U-Boot board headerinclude/configs/rv1106_common.hwith#define CONFIG_BOOTCOUNT_LIMIT,#define CONFIG_SYS_BOOTCOUNT_SINGLEWORD, and#define CONFIG_SYS_BOOTCOUNT_ADDR 0xFF020218.autoboot.c’sbootdelay_process()— which runs on every boot to pickbootcmd— then increments the counter and, oncebootcount > bootlimit, runsaltbootcmdinstead of the normal boot. (No Kconfig/Makefile change and no new source files:bootcount.cisobj-yand reads the#defines;BOOTCOUNT_MAGIC 0xB001C041comes frominclude/common.h.) - Register semantics (hardware-verified on a live Pico Pro Max): the counter lives in a free GRF
OS_REGscratch register0xFF020218(0xFF020210/214/218/21Call read 0 = unused) — not flash, so there is no per-boot NAND wear. The register survives a warm reset, so a kernel-panic reboot loop keeps counting, and is cleared by a cold power-cycle, so simply unplugging the device resets the counter. Singleword encoding stores(0xB0010000 | count). Proven on hardware: forcing the register to0xB0010006and rebooting, U-Boot read count 6 and stored0xB0010007— the increment path runs. The adjacent0xFF020200is the reboot-mode register (0x5242C301= Loader). bootlimit+altbootcmdare baked into the COMPILED DEFAULT ENV — not the mtd0 env. This U-Boot is builtENV_IS_NOWHERE(noCONFIG_ENV_IS_*in any Luckfox defconfig): it uses only its compiled-in default environment and never reads the mtd0 env thatfw_setenvwrites. Proven on hardware: with the counter forced to 7 and mtd0bootlimit=5, U-Boot used its built-in default (10) and booted normally. So the recovery step also injects intoCONFIG_EXTRA_ENV_SETTINGS:bootlimit=5andaltbootcmd='mw.l 0xFF020218 0; mw.l 0xff020200 0x5242c301; reset'— zero the counter register, then enter Loader via the proven reboot-mode magic (0x5242C301, same asrk-reboot loader;mw/resetboth exist in this U-Boot). Nofw_setenv,/etc/fw_env.config, oru-boot-toolsis needed for the failover (they remain installed only for env inspection/debugging).- Cleared on a healthy boot:
start-seedsigner.shrunsdevmem 0xFF020218 32 0as soon as userspace is up, and the app (MainMenuView) clears it again on reaching Home. So only boots that never reach userspace accumulate toward the limit; a device that boots normally never approaches it.
Hardware-verify on a non-dev image via ADB:
- Clear works: shortly after a normal boot,
devmem 0xFF020218reads0x00000000(userspace cleared it). - Loader path: force the counter over the limit and reboot once —
devmem 0xFF020218 32 0xB0010006 && reboot -f(0xB0010006 = magic | count 6, and 6 > bootlimit 5). U-Boot should skip the normal boot and the device should enumerate as a Rockchip Loader device (rkdeveloptool ld). A cold power-cycle clears the register and returns to normal boot. - Healthy device never triggers: repeatedly reaching Home must always keep the counter at/near 0.
Note: because bootlimit/altbootcmd are compiled in (not env), disabling the policy requires a rebuild — it cannot be turned off with fw_setenv. The failover is inert on any boot that reaches userspace (the counter is cleared), so a healthy device never enters Loader on its own.
Flashing & recovery — Loader / Maskrom mode
To re-flash with the Rockchip SocToolKit or rkdeveloptool you normally hold the BOOT button while powering on to enter a download mode. You can instead trigger it from the running device over the shell.
Loader (rockusb) mode — sufficient to re-flash a device whose U-Boot still boots:
rk-reboot loader
rk-reboot (in /usr/bin) issues the real reboot(2) RESTART2 syscall. Busybox’s own reboot loader is a no-op because it ignores the mode argument. After this the device reboots and enumerates on the host as a Rockchip Loader device, ready for flashing.
On an older image that predates rk-reboot, run the same syscall inline (python3 is always present):
python3 -c 'import ctypes; ctypes.CDLL(None).syscall(88, 0xfee1dead, 0x28121969, 0xa1b2c3d4, b"loader")'
Pure-shell fallback (write the loader magic to the GRF OS_REG, then warm-reset):
busybox devmem 0xFF020200 32 0x5242C301 && reboot -f
Notes:
- A cold power-cycle clears the flag, so entering Loader mode is safe to try — just unplug/replug to boot normally if you change your mind.
- Maskrom (BootROM USB download — needed only for a bricked/blank device or to rewrite the loader itself) is not enabled yet: it requires adding a device-tree
mode-maskrom = <0xef08a53c>entry to the board DTS plus a firmware rebuild. The physical BOOT button remains the guaranteed fallback for maskrom.
Other reference docs in this folder
- secure-boot.md — start here for secure boot: what it protects, how to sign a release (build-time, re-sign on a PC, re-sign on the device, or air-gapped), burning the fuse and recovery, and the technical notes and bench history. Every build is signed by default (
signing: on) with the committed public dev keys; the OTP fuse burn is always opt-in. It works on NAND and on MicroSD-only boards alike — a card-booting board is armed by writing an armed card, with no NAND and no USB involved. - airgapped-signing.md — reference for the signature formats and the pure-Python signers: digests out, signatures in, BIP85-derived keys, and what a re-key rewrites.
- secure-boot-bench-procedure.md — runnable staged procedure for signing, flashing and (last, irreversibly) burning the fuse on sacrificial hardware.
- verifying-a-release.md — how anyone can check a distributed image for authenticity and reproducibility, with no keys and no vendor tools.
- soctoolkit-cli.md — driving SocToolkit’s
upgrade_tooldirectly: flashing, reading back, reading SocToolkit’s log, and recovering a fused board stuck in maskrom. - ../hwrng.md — how hardware entropy reaches the app on this and the other boards.
- OS-build-instructions.md — detailed manual SDK build steps (original standalone layout).
- LUCKFOX_STARTUP_WORKFLOW.md — on-device startup / camera sequencing.
- BUILD_REFERENCE.md, TOOLCHAIN_ANALYSIS.md, SMARTCARD_PACKAGES.md, CAMERA_SERVICE_PLANNING.md.