Introduction
boot2deb turns a laptop, SBC, tablet, or other device into a Debian device. It is a
Rust-native, typed, testable builder that resolves a build from layered TOML config
— an arch ← soc ← boot-method ← device hardware stack plus an orthogonal kernel
axis — and drives the whole pipeline: kernel, u-boot, media-accel userspace, ffmpeg,
the Debian rootfs, and a bootable disk image, all from a single committed lockfile.
The image assembly is pure Rust: GPT partitioning, ext4 formatting, and .xz
compression with no C dependencies and no sudo. Cross-architecture package builds
run in a rootless sandbox (mmdebstrap --mode=unshare + bwrap + qemu-user), so
an x86_64 host builds an arm64 image without root.
Not every board needs every stage. A build compiles a kernel only if the board needs one of its own, and builds a bootloader only if the board’s firmware is ours to make. The Turing RK1 does both — a patched mainline kernel, and u-boot written into the disk’s raw gap. The ASUS C201 Chromebook does neither: Debian’s own kernel runs it, its firmware lives in an SPI chip, and what boot2deb produces for it is a signed kernel in a ChromeOS partition. Its lock, correspondingly, pins nothing from git. The model states what is true of each board rather than making them look alike.
Where to start
- Getting started — install the prerequisites and build your first image.
- Turing RK1 — the shipped RK3588 configuration, and how to flash it.
- ASUS Chromebook C201 — the shipped RK3288 Chromebook: a ChromeOS-firmware board, and a kernel that comes from Debian. Its two siblings, the C100P and the Chromebit CS10, are each a device file and nothing else.
- Config model — how a build is described across its axes, and how the layers resolve.
- CLI — the command reference.
- Overlays — keep your own boards and retunings out-of-tree.
- Adding a board — bring up a new device.
- Adding a patch — get a patch into a build.
Getting started
This gets you from a clone of the repo to a built image. It uses the shipped
turing-rk1-forky recipe as the running example; other boards build the same way
with their own recipe name. Flashing and board-specific notes live on each board’s
page — for the RK1, see Turing RK1.
Which track are you on? This is the shipped-recipe track —
doctorthenbuild, for a recipe that already ships a committed lock (liketuring-rk1-forky). Bringing up a new board, or authoring a patch, is the longer bring-up track: see Adding a board and Adding a patch.
The build is rootless: it uses no sudo and no loop devices. You only need root
to install host packages and (on some hosts) to enable unprivileged user namespaces
once.
What you need
- A Linux host, x86_64 or arm64. An x86_64 desktop building the arm64 image is
the common case and fully supported — it cross-builds under
qemu-user. Debian and Ubuntu are the primary targets; Fedora and Arch work too (doctorknows their package names). macOS can run the read-only commands but cannot build. - A recent stable Rust toolchain, installed via rustup.
- Disk and time. A cold build bootstraps a Debian rootfs, and — for a board that needs one — compiles a kernel and a bootloader. Budget a few GB of scratch space and tens of minutes the first time; later builds reuse cached trees. A board that compiles nothing (the C201) is much cheaper: it is a rootfs bootstrap and an image assembly.
Let doctor find what’s missing
Rather than hand-installing a package list, run doctor. It probes for every tool
the build needs and, for anything absent, prints the exact install command for
your distro — so you never guess a package name. doctor itself needs nothing but
Rust, so it is the first thing to run after cloning:
cd boot2deb
cargo run -p boot2deb-cli -- doctor turing-rk1-forky
It reports your host arch, whether the build is cross-arch, and one line per requirement:
host arch : x86_64
target : turing-rk1-forky (arch arm64)
cross : yes — needs qemu-user binfmt for arm64 maintainer scripts/compiles
ok git /usr/bin/git
MISSING mmdebstrap rootfs bootstrap — sudo apt install mmdebstrap
MISSING qemu-aarch64-static run target binaries under binfmt — sudo apt install qemu-user-static
...
result : all required host tools present
Run the install lines it reports, then re-run doctor until it prints
all required host tools present. Because the list is generated from the build’s own
requirements, it is always current — this page does not repeat the package names, so
there is nothing here to drift out of date.
For orientation, the checks fall into a few groups:
| Group | What it covers | When |
|---|---|---|
| Rootfs bootstrap | mmdebstrap + unprivileged user namespaces | always |
| Packaging / apt repo | dpkg-deb, dpkg-scanpackages, apt-ftparchive, sha256sum | always |
| Image assembly | mke2fs + e2fsck (format the rootfs ext4 and verify it clean) | always |
| Compile toolchain | git, make, bc, flex, bison, libssl, and a C compiler (native, or the <triple>gcc cross compiler) | only if the recipe compiles a kernel or a bootloader |
| Emulation | qemu-<arch>-static + a registered binfmt handler, so the target’s maintainer scripts run | cross only |
| Sandbox | bwrap, to enter the rootless target-arch build sandbox | the recipe builds target-arch packages (the media-accel stack) — on any host |
doctor asks only for what your recipe will actually invoke, so the table above
is a superset. doctor turing-rk1-media-accel-forky wants the whole list; the base
doctor turing-rk1-forky drops the sandbox row (it builds no target-arch userspace);
doctor asus-c201-forky wants no compiler at all, because that board installs Debian’s
kernel and boots its own firmware. That is deliberate: a requirement you do not need is
somewhere a requirement you do need can hide.
The “cross” row applies when your host arch differs from the target — i.e. any x86_64 host building an arm64 or armhf image. An arm64 host runs the target’s binaries directly and needs no emulation.
The sandbox row is not a cross-only requirement. Packages like ffmpeg-rk and
librga2 are built inside a userland bootstrapped for the target suite, never on your
host, even when your host arch already matches the target. Their runtime Depends are
derived from the libraries present at build time, so building them against your host’s
libraries would stamp your host’s package names and versions into a .deb bound for a
Debian forky image. An arm64 host building an arm64 image still needs bwrap.
The user-namespace check (common blocker on Ubuntu 24.04)
The rootless rootfs bootstrap, the sandbox, and the ext4 image staging all need
unprivileged user namespaces with a subuid/subgid range for your user, which some
hosts disable by default. doctor tests this by actually creating one (with the
subuid mapping), and if it fails it prints the fix for your host. The usual cases:
- Ubuntu 24.04+ ships an AppArmor restriction on by default:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - Debian with namespaces disabled:
sudo sysctl -w kernel.unprivileged_userns_clone=1 - Either way,
kernel.max_user_namespaces(oruser.max_user_namespaces) must be greater than 0. - Your user needs a subuid/subgid range (usually present by default):
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER
sysctl -w lasts until reboot; drop the same line in /etc/sysctl.d/ to make it
persist.
On a cross build doctor also checks that the qemu-<arch> binfmt handler is
registered and enabled with the F (fix-binary) flag — the sandbox relies on it.
Installing qemu-user-static (with binfmt-support / systemd’s binfmt) normally
registers this; doctor warns if the flag is missing.
Build
With doctor green:
cargo run -p boot2deb-cli -- build turing-rk1-forky
This resolves the recipe’s committed lockfile and runs the pipeline end to end. For the
RK1 that is: compile the kernel and u-boot, build the media-accel userspace and ffmpeg,
bootstrap the Debian rootfs, and assemble a bootable disk image. A recipe runs only the
stages it has — build asus-c201-forky compiles nothing at all, so it is a rootfs
bootstrap and an image assembly and nothing else.
The build reads only the lock, so it consults no network for its pins and is reproducible
from what is committed. The patch series, where a recipe has one, is fetched automatically
at its pinned commit if a ../patches checkout is not already present — you do not need
to clone it separately.
The rootfs bootstrap is content-cached, so a rebuild whose solved package set is
unchanged skips the multi-minute bootstrap. To force a clean rootfs, add
--refresh-rootfs. To build a single stage, pass --stage
(kernel, dtb, uboot, userspace, ffmpeg, rootfs, image) — see the
CLI reference.
What you get
Artifacts land under the recipe’s work dir, build/turing-rk1-forky/artifacts/:
turing-rk1.img.xz— the compressed bootable image (the file is named after the device, not the recipe).turing-rk1-forky.provenance.toml— exactly what went into the image: the resolved pins, package count, toolchain identity, and the first-boot credential.
The build prints the exact paths on its final lines, including the credential:
compressed : .../build/turing-rk1-forky/artifacts/turing-rk1.img.xz
first-boot pw : <generated> (user debian, expired — change at first login)
provenance : .../build/turing-rk1-forky/artifacts/turing-rk1-forky.provenance.toml
Note the first-boot password down. It is unique per image, shown once here, and stored only in the provenance file — it exists nowhere on the running system in recoverable form.
Next: flash the image. That step is board-specific — for the RK1, see Turing RK1.
Upgrading the kernel
On a ChromeOS-firmware board — the C201, the
C100P, the Chromebit, and every
other board using the depthcharge boot method — upgrading the kernel is apt upgrade,
and it is atomic and reversible. If a new kernel does not boot, the firmware puts the
old one back on its own. You do not have to do anything, and you do not need a USB stick.
That is worth stating plainly, because on these boards it is not obvious. The kernel is
not a file in /boot that a bootloader reads. It is a vboot-signed blob written raw
into a partition, and changing it means re-signing it and rewriting that partition —
which is exactly the operation you cannot afford to get wrong, because it is the only
thing standing between the board and a firmware screen.
How it works
The image ships two ChromeOS kernel slots, KERN-A and KERN-B. The kernel lives in
one of them; the other is empty. Both are ordinary GPT partitions, and the firmware picks
between them using three fields in each partition’s GPT attribute bits:
| field | meaning |
|---|---|
priority | boot order among candidates; 0 means never boot |
tries | attempts remaining, decremented before each attempt |
successful | known-good; stops the firmware spending tries |
A slot is a boot candidate while priority > 0 and (successful or tries > 0).
When a kernel package is installed — by apt, or by dpkg -i on a .deb you built
yourself — Debian’s depthcharge-tools package runs its /etc/kernel/postinst.d hook,
which:
- rebuilds the kernel FIT (kernel + device tree + initramfs) and signs it,
- writes it into the slot the board is not currently booted from,
- marks that slot highest-priority with
tries = 1, successful = 0.
On the next boot the firmware spends that single try on the new slot. If the system comes
up, depthcharge-tools.service runs depthchargectl bless, which sets successful and
commits the upgrade. If the system never comes up, the try is already spent, the slot
stops being a candidate, and the firmware falls back to the other slot — which still holds
the kernel that worked, still marked successful.
So a failed kernel upgrade costs you one reboot. Nothing else.
The spare slot is the whole mechanism. An image with only one kernel slot cannot do any of this: the only slot is the one you are running from, so an upgrade has to overwrite the running kernel in place, and a kernel that does not come up leaves the board with nothing to boot and no way in but external media.
Doing it
Nothing special:
sudo apt update && sudo apt upgrade
If the upgrade includes a kernel you will see depthchargectl run in the output. Reboot
when it finishes. That is the entire procedure.
Checking which slot you are on
sudo depthchargectl list
That prints every ChromeOS kernel partition on the disk with its size and its S/P/T
(successful / priority / tries) attributes. A healthy board after a committed upgrade
looks like two slots, both S=1, the running one at the higher priority.
The board also tells the running system which slot it booted from: the firmware
substitutes that partition’s PARTUUID into kern_guid= on the kernel command line.
grep -o 'kern_guid=[^ ]*' /proc/cmdline
That value is what depthchargectl uses to know which slot it must not overwrite.
Rolling back on purpose
If a kernel boots but is bad in some way you only notice later, mark it unbootable and reboot — the firmware falls back to the other slot:
sudo depthchargectl bless --bad # zero the running slot's attributes
sudo reboot
Then pin or downgrade the kernel package so the next apt upgrade does not simply put it
back.
When the write fails: the payload ceiling
The signed blob must fit its kernel slot — 16 MiB on stock firmware (a board page may
list roomier firmware profiles). depthchargectl builds the new image first and writes
second, so when nothing it tries fits under the ceiling, it fails without touching the
slot:
Couldn't build a small enough image for this board
Nothing is broken when this prints. The slot still holds the payload the board booted from and the board keeps booting — but the change that triggered the rebuild has not reached the slot, and nothing will until the payload fits again.
The payload is kernel + device tree + initramfs, and the part that grows is the
initramfs. The image ships it deliberately small — an explicit module list
(MODULES=list) and xz compression — which leaves about 2 MB of headroom under the
stock ceiling.
What spends that headroom is initramfs-tools hooks, and the one that spends it all at
once is plymouth. Desktop metapackages (cinnamon-desktop-environment,
task-gnome-desktop, and the rest) pull plymouth in through Recommends, desktop-base
registers a graphical boot theme, and plymouth’s hook then copies the splash daemon, its
renderers, the theme, and the text plugin with its whole font stack into every initramfs
built afterwards. That is several MB, and the next slot write — a kernel upgrade, or any
package that triggers update-initramfs — fails as above.
The cost buys nothing on these boards. The initramfs carries no DRM modules, so plymouth cannot draw before the root pivot regardless — it says so itself during the rebuild:
W: plymouth: not including drm modules since MODULES=list
Remove it:
sudo apt purge 'plymouth*'
The purge re-triggers the initramfs rebuild and the slot write; watch the
depthchargectl run in the output succeed. Nothing depends on plymouth — desktops only
recommend it — and no configuration keeps an installed plymouth out of the initramfs
(its initramfs-tools fragment overrides any admin setting), so removing the package is
the supported answer. If the original failure aborted an apt run partway, finish it
first with sudo dpkg --configure -a.
If the write still fails, something else grew the initramfs. List its contents by size and look for what does not belong:
lsinitramfs -l /boot/initrd.img-$(uname -r) | sort -k5 -rn | head -20
A healthy initramfs for these boards is 7–8 MB compressed (ls -lh /boot/initrd.img-*).
Does it differ with a compiled kernel?
No — the mechanism is identical. The hook that re-signs and writes a slot is triggered
by the kernel package’s own maintainer script, not by apt, so it fires for any
linux-image .deb that gets configured, however it arrived. A boot2deb-compiled kernel
and Debian’s stock linux-image-armmp take exactly the same path through the same tool,
and both get the same A/B safety.
What differs is delivery, and only that:
| where the kernel comes from | how you upgrade | |
|---|---|---|
distro kernel (debian-armmp) | the Debian mirror | apt upgrade |
| compiled kernel | boot2deb’s --stage kernel output | copy the .deb to the board, dpkg -i |
A compiled kernel is not on any mirror, so nothing will ever offer it to you — you deliver
the .deb yourself. Once dpkg configures it, the slot is written and the reboot is as
safe as any other.
The upgrade unit is the
.deb, not the signed blob. It is tempting to think of the signed kernel partition as the thing you ship, and it is not. The signed blob contains the kernel, the device tree, and the initramfs — but not the kernel modules, which live on the root filesystem in/lib/modules/<version>and are where Wi-Fi, graphics and sound actually come from. A kernel written without its modules boots into a system with no drivers. The.debcarries both, which is why it is what you move around.The signed blob is also specific to the machine it was built on: first boot gives each board its own root filesystem PARTUUID, and that value is baked into the signature. A blob signed for one board’s PARTUUID cannot find root on another.
The other boards
On a rockchip-rkbin board — the Turing RK1, the H96 — none of
this applies. There the kernel is an ordinary file in /boot, the bootloader is u-boot
reading extlinux.conf, and a kernel upgrade rewrites that config file. It is simpler,
and it has no rollback: the boot configuration is a file, so a bad kernel is fixed by
editing it back, which needs a keyboard and a screen or a serial console.
Locale, timezone, and keyboard
There are two ways to set these, and both are supported on purpose:
- Before a build — declare them in the layered config. They are resolved, recorded in the image’s provenance, and baked in. Nothing asks a question at boot.
- On a running image —
dpkg-reconfigurethe relevant package, exactly as on any Debian system, with no network. This works because the image already shipslocales,keyboard-configuration, andconsole-setup, and because the locales are already compiled onto the disk.
The second is the reason the first is not enough. A pre-built image is something you hand to someone else; they should not have to rebuild it — or get it onto a network — to type on a German keyboard.
The knobs
| field | layer | default | what it sets |
|---|---|---|---|
locale | base.toml | C.UTF-8 | LANG in /etc/locale.conf |
locales_generate | base.toml | ["en_US.UTF-8"] | extra locales compiled into the image |
timezone | base.toml | UTC | the /etc/localtime symlink |
keymap | devices/<board>.toml | none | /etc/default/keyboard (the XKB variables) |
Each is overridable in a recipe, and on the command line with --locale,
--locale-gen (repeatable), --timezone, and --keymap:
cargo run -p boot2deb-cli -- build asus-c201-forky \
--locale de_DE.UTF-8 --timezone Europe/Berlin --keymap de
resolve shows what a build will bake in:
locale : C.UTF-8 (generated: C.UTF-8, en_US.UTF-8)
timezone : UTC
keymap : us [pc105]
Why the locale and the keymap live on different layers
The locale and the timezone are distro policy: no board has an opinion about them,
so they sit in base.toml.
A keymap is different — whether a console keymap configures anything at all is a
property of the hardware. The C201 and the C100P are laptops with keyboards under the
user’s hands and a US layout; the Turing RK1 and the H96 are headless, and a layout
declared for a console nobody types at is a claim the config cannot back. So keymap
sits on the device, and a headless board simply omits it: boot2deb then writes no
/etc/default/keyboard and Debian’s own default (pc105 / us) stands.
The Chromebit CS10 shows what the field is really asking. It has no keyboard at all, and
it declares keymap = "us" anyway — because it is not headless: it drives an HDMI
console, and a USB keyboard is the only way to type at it. The question is “does a
console layout configure anything here?”, not “does the board ship keys”. It does, so it
answers.
You can still pass --keymap to a headless board. console-setup ships on every
image, so a keymap is always actionable — plugging a USB keyboard into the RK1’s HDMI
console is a real thing to do. A headless board just has no reason to default one.
Why the default locale is C.UTF-8 and not en_US.UTF-8
C.UTF-8 is a complete UTF-8 locale built into glibc. It is also neutral: this project
targets no one country, and a US locale is not a better default than any other.
en_US.UTF-8 is nevertheless generated into every image, and that is not a
contradiction — see the next section.
The Setting locale failed warning
SSH into a fresh board and you may see:
perl: warning: Setting locale failed.
perl: warning: Please check that your locale settings:
LANGUAGE = (unset),
LC_ALL = (unset),
LANG = "en_US.UTF-8"
are supported and installed on your system.
Nothing on the image is broken. Debian’s stock openssh-server ships
AcceptEnv LANG LC_*, so your client forwards its own LANG into the session. If
that locale was never generated on the target, setlocale() fails and every Perl-based
tool says so.
That is why en_US.UTF-8 is in locales_generate: it makes the common client’s
forwarded locale resolve. It costs about 3 MiB.
It is not a general fix — a de_DE client forwarding de_DE.UTF-8 still warns, and
chasing that by pre-generating more locales is whack-a-mole. The actual fix is that the
locale is changeable, which is what the rest of this page is about. To silence it
for one session without changing anything:
LANG=C.UTF-8 ssh board
Do not “fix” this by removing AcceptEnv LANG LC_* from sshd_config. It is
standard Debian behaviour, and silently dropping it surprises anyone who relies on it.
Changing them on a running image, offline
All three are the ordinary Debian commands. None of them needs the network, because the packages and the locale data are already on the disk.
Locale. dpkg-reconfigure is the authoritative path — it generates the locale and
sets the default:
sudo dpkg-reconfigure locales # tick the locales to generate, then pick the default
localectl also works on a boot2deb image, and it is worth knowing why: Debian builds
systemd-localed with locale-gen support, so localectl set-locale will add the
locale to /etc/locale.gen and run locale-gen itself — but only if
/usr/sbin/locale-gen exists, i.e. only if the locales package is installed. On an
image without it, localectl would set a LANG naming a locale that was never
generated. boot2deb ships locales, so:
sudo localectl set-locale LANG=de_DE.UTF-8
is safe here. Reconnect for it to take effect on your session.
Timezone. Either command works; both write the /etc/localtime symlink, which is
the only thing that reads as the system timezone (forky’s tzdata no longer keeps an
/etc/timezone file at all):
sudo timedatectl set-timezone America/New_York
sudo dpkg-reconfigure tzdata # the menu-driven equivalent
Console keymap.
sudo dpkg-reconfigure keyboard-configuration # then: sudo setupcon
setupcon applies the new layout to the current console without a reboot.
Why dpkg-reconfigure opens on the right values
boot2deb writes /etc/locale.gen, /etc/locale.conf, /etc/default/keyboard, and the
/etc/localtime symlink before the packages that own them are configured, not
after. Debian’s locales, keyboard-configuration, and tzdata each seed their
debconf answers from those exact files when they install, so the shipped files, the
debconf database, and the console-setup cached keymap all agree.
The practical consequence: dpkg-reconfigure locales on the running board opens with
your locales already ticked and your default already selected — not Debian’s. Had
the files been written after the packages, they would still be correct on disk, and
debconf would still be holding Debian’s defaults underneath them.
Notes for the curious
/etc/locale.conf, not/etc/default/locale. Debian makes the latter a symlink to the former, andsystemd-tmpfilesre-asserts that link with a forcing rule (L+) — so a regular file written at/etc/default/localeis deleted and replaced by the symlink on the next boot. Writing the symlink’s target satisfies every reader:pam_envthrough the link,systemd/localectldirectly, and thelocalespackage, whose config script reads that path to learn the current default.- The system locale is always generated, even
C.UTF-8, which glibc would provide ungenerated. Thelocalespackage builds the choice list thatdpkg-reconfigure localesoffers for the default locale out of/etc/locale.gen— so a system locale missing from that file is one the user cannot see or re-select on the board. - Not
locales-all. It carries every locale Debian has, at hundreds of MiB. The three packages boot2deb ships cost about 44 MiB installed (measured on forky/arm64), plus ~3 MiB for the generated locale archive — call it ~47 MiB on the image. On a 2 GiB rootfs that is a little over 2%, and it compresses well into the shipped.img.xz.
Turing RK1
The Turing RK1 is an RK3588 compute
module that seats in a Turing Pi 2 cluster board. boot2deb ships it as a small family
of recipes over one validated hardware base — kernel v7.1.1 (linux-stable), u-boot
v2026.04, and the RGA / VEPU / VDPU (and NPU) drivers carried in-kernel via the
rk3588-accel patch profile. It is a supported configuration in its own right and a
good starting point for any RK3588 board.
The variants differ along two independent axes — the Debian suite, and whether the Rockchip media userspace is built in:
| Recipe | Suite | Media userspace |
|---|---|---|
turing-rk1-forky | forky | — (base) |
turing-rk1-trixie | trixie | — (base) |
turing-rk1-media-accel-forky | forky | ffmpeg-rk + MPP + RGA |
turing-rk1-media-accel-trixie | trixie | ffmpeg-rk + MPP + RGA |
Every variant carries the same accel kernel: the VEPU / VDPU / RGA and NPU drivers
are present in all of them, because the patches and kconfig live on the kernel axis. A
base image simply omits the Rockchip media userspace — the hardware blocks are
there but dark. A media-accel image adds the media-accel-rockchip feature, which
builds and installs ffmpeg-rk, librockchip-mpp1, and librga2 on top. The split is
deliberate: because the kernel already carries the capability, those debs can equally be
installed onto a running base image later. forky is the RK1’s validated suite.
Build the base image as in Getting started:
cargo run -p boot2deb-cli -- build turing-rk1-forky
or, for a ready hardware-transcode host, the media-accel variant:
cargo run -p boot2deb-cli -- build turing-rk1-media-accel-forky
Either produces build/<recipe>/artifacts/turing-rk1.img.xz — a whole-disk image (GPT,
u-boot in the reserved gap ahead of the first partition, then the ext4 rootfs), so a
single write lays down everything, bootloader included. The flashing and boot notes
below use turing-rk1-forky; they are identical for any variant (the bootloader and
disk layout do not change), so substitute your recipe name in the artifact path.
Flash
The RK1 is a compute module, not a board you plug a card reader into, so the usual path is the Turing Pi’s BMC, which writes the module’s eMMC:
tpi flash -n <node> -l -i /absolute/path/to/turing-rk1.img— copy the image to the BMC first (e.g. onto its SD card, mounted at/mnt/sdcard) and use an absolute path, or- the BMC web UI’s flash upload.
Both write eMMC only. For a removable or NVMe/USB medium you write on another machine,
decompress and dd it — the same image boots from any medium the board scans, since
u-boot discovers its root device at runtime:
xzcat build/turing-rk1-forky/artifacts/turing-rk1.img.xz \
| sudo dd of=/dev/sdX bs=4M status=progress conv=fsync # confirm /dev/sdX with lsblk
The tpi CLI and web UI evolve; see Turing Pi’s
flashing docs for the current
specifics.
u-boot on eMMC, OS on a separate disk
A common RK1 setup keeps only u-boot on the eMMC and runs the OS from an NVMe or USB disk. The builder produces the two pieces for this directly.
The whole split at once — build the split layout, which emits two images instead
of one:
cargo run -p boot2deb-cli -- build turing-rk1-forky --layout split
turing-rk1-boot.img— u-boot only (idbloader +u-boot.itbat their offsets, no GPT), for the eMMC.turing-rk1-rootfs.img— GPT + rootfs, for the NVMe/USB disk.
Just the bootloader — if you only need the eMMC u-boot image (e.g. to re-flash the bootloader across nodes) without building a whole OS, the u-boot stage emits it on its own:
cargo run -p boot2deb-cli -- build turing-rk1-forky --stage uboot
This writes turing-rk1-boot.img (a few MiB, gap-sized) alongside the raw idbloader.img
and u-boot.itb. Flash turing-rk1-boot.img to the eMMC with tpi/web UI; write the
rootfs image to the target disk.
Because tpi/web UI flash the eMMC only, the rootfs image goes onto the NVMe/USB disk
by another route — typically dd from a running system on the node, or written on
another machine.
Serial console
To watch u-boot and the kernel come up, open the node’s UART from the BMC:
tpi uart --node <n> get
# or, on the BMC directly:
picocom /dev/ttyS<n> -b 115200
On BMC firmware 2.1.0 and newer the node number maps 1:1 to the ttyS number
(node 1 → ttyS1, node 2 → ttyS2, …). On 2.0.5 and older the mapping was offset
(node 1 → ttyS2, node 2 → ttyS1, …), so check your firmware version. The baud rate
is 115200. See Turing Pi’s UART docs.
First boot
Power the node on. On first boot the image:
- regenerates its SSH host keys, and
- grows the rootfs to fill the whole medium (the 2 GB image expands to the disk’s capacity). This reboots the node once to pick up the resized partition, so the first power-on comes up, reboots itself, then settles.
Log in as user debian with the password the build printed. It is expired, so you
are required to set a new one immediately. The debian account has passwordless
sudo, and the hostname is turing-rk1.
That is a booted Debian system. The kernel’s transcode devices come up on every
variant — check for /dev/dri and /dev/rga. A media-accel image also installs the
ffmpeg-rk userspace, so you can exercise the rkmpp / rkrga paths directly; on a base
image the blocks are present but idle until you install the media-accel debs (or build a
turing-rk1-media-accel-* image).
ASUS Chromebook C201
The asus-c201-forky recipe builds a bootable Debian forky image for the ASUS
Chromebook C201/C201PA (google,veyron-speedy) — an RK3288 Veyron Chromebook, and the
first 32-bit Arm board and first ChromeOS-firmware board boot2deb supports.
asus-c201-trixie is the same board on the stable suite.
cargo run -p boot2deb-cli -- build asus-c201-forky
That produces build/asus-c201-forky/artifacts/asus-c201.img.xz — a whole-disk image
carrying a signed kernel partition and the ext4 rootfs, so one write lays down
everything the firmware needs.
What is unusual about this board
Almost nothing is built. The RK3288 and all ten Veyron boards are upstream, Debian’s own armhf kernel runs them, and the bootloader is not ours to make — so this recipe compiles neither a kernel nor a bootloader, and its lock pins nothing from git:
[rootfs]
suite = "forky"
manifest = "asus-c201-forky.pkgs.lock"
That is the whole lock. Every package in the image — the kernel included — is pinned by name, version, and sha256 in the manifest beside it.
The boot payload is the kernel. ChromeOS firmware (coreboot + depthcharge, in the
board’s SPI flash) does not read a bootloader from a disk offset. It scans every boot
medium’s GPT for a partition of the ChromeOS kernel type, orders the candidates by
attribute bits in the partition entry, and loads a vboot-signed FIT out of the winner.
The signature is built by depthchargectl inside the rootfs, deliberately: that is
the same packaged tool, reading the same /etc/fstab, that re-signs and rewrites a kernel
partition when apt upgrades the kernel on the running board.
So the image carries three partitions: two ChromeOS kernel slots and the rootfs.
| # | partition | contents |
|---|---|---|
| 1 | KERN-A, 16 MiB @ 12 MiB | the signed kernel |
| 2 | KERN-B, 16 MiB @ 28 MiB | empty, priority 0 — the upgrade spare |
| 3 | rootfs, ext4 @ 44 MiB | grown to fill the medium on first boot |
The empty second slot is not waste. It is what makes a kernel upgrade on this board
atomic and reversible: apt writes the slot the board is not running from, and if the
new kernel fails to boot, the firmware falls back to the old one by itself. See
Upgrading the kernel.
Board profiles
A depthcharge board profile describes the firmware a unit runs, not the board model. The C201 supports two:
| profile | payload ceiling | for |
|---|---|---|
speedy (default) | 16 MiB | stock ChromeOS firmware — and libreboot |
speedy-libreboot | 32 MiB | a unit running libreboot, when the headroom is wanted |
The stock profile is the default deliberately: a stock-profile image boots on stock
firmware and on a libreboot unit, while the reverse is not true. Both are confirmed
on the hardware. Select the other with --board speedy-libreboot; its extra headroom is
useful for a debug initramfs carrying the display stack, which makes the boot visible on
the panel a few seconds after Ctrl+U instead of after the rootfs mounts.
Flash and boot
Write the image to a microSD card or a USB stick:
xzcat build/asus-c201-forky/artifacts/asus-c201.img.xz \
| sudo dd of=/dev/sdX bs=4M status=progress conv=fsync # confirm /dev/sdX with lsblk
The unit must be in developer mode. Then, from a full power-off, boot the medium with Ctrl+U at the developer-mode screen.
- On libreboot, Ctrl+U works as-is.
- On stock firmware, external boot must first be enabled once, from a ChromeOS
shell:
crossystem dev_boot_usb=1.
If a boot fails, the board tells you by rebooting: the signed command line carries
panic=30, so a kernel panic or an initramfs that gives up on root returns to the
firmware splash about 30 seconds later. A board that never reboots therefore means the
kernel never reached the initramfs at all — which on a machine with no serial console is
the single most useful thing a failed boot can say. A panic also writes a full dmesg to
BOOT2DEB-PANIC.txt on every ext4 partition it can reach.
Expect 8-10 seconds of white screen on a healthy boot before the display comes up: the standard image leaves the DRM stack out of the initramfs to keep the signed payload comfortably under its ceiling, so the console appears only once the real root is mounted.
Keyboard
A laptop, so it declares a console keymap — keymap = "us", the layout the C201PA ships.
The RK1 and the H96 are headless and declare none.
Note that a USB keyboard is not an option at the firmware screens on this board:
CONFIG_LP_USB_HID is not set in its libpayload, so depthcharge reads Ctrl+U from the EC
keyboard and nothing else. (The Chromebit, which has no EC, is
the one board in the family built the other way.)
For a unit with another layout, either override at build time or change it on the running board (offline, like any Debian system):
cargo run -p boot2deb-cli -- build asus-c201-forky --keymap gb
sudo dpkg-reconfigure keyboard-configuration && sudo setupcon # on the board
See Locale, timezone, and keyboard.
Getting online
There is no ethernet port, so Wi-Fi is the only way onto the network and joining one is the first thing to do after logging in:
sudo nmtui # pick "Activate a connection", choose the network, enter the key
NetworkManager owns the interfaces (the base layer’s dhcpcd is excluded here, so the
two do not fight over the NIC), and it remembers the network, so this is a one-time
step. nmcli device wifi list and nmcli device wifi connect <ssid> --ask do the same
job without the interface.
Wi-Fi needs two Broadcom blobs Debian does not ship; they are vendored in the device layer and are already in the image. Scanning shows randomized, locally-administered MAC addresses — that is NetworkManager, not a fault.
Audio
The image comes up with working speakers. That takes a little doing, because the
max98090 codec starts in a state where two separate things are in the way: its
amplifiers are muted, and the DAPM mixers that feed them have their DAC input
switches open, so there is no route from the DAC to the speakers to unmute in the first
place. Clearing only the mutes — which is what reaching for the obvious Speaker
control does — still leaves the board silent.
The device’s first-boot.d/20-audio hook closes the routing switches, unmutes both
amplifiers, sets sane volumes, and runs alsactl store. alsa-utils replays the
result on every later boot, so this happens once and then it is simply the board’s
mixer state. Adjust it like any other Debian system:
alsamixer && sudo alsactl store
Bluetooth
The Wi-Fi and Bluetooth halves of the BCM4354 arrive on different buses: Wi-Fi over
SDIO, Bluetooth over uart0, which the device tree wires as brcm,bcm43540-bt. The
kernel loads the Bluetooth patchram this device vendors alongside the Wi-Fi NVRAM, and
the image ships bluez so there is a host stack to use it.
btsdio is blacklisted. The BCM4354’s SDIO side also advertises a Bluetooth function,
and if btsdio claims it, Wi-Fi does not survive suspend and resume.
Display
An eDP panel and a real HDMI port, both driven by mainline rockchip-drm.
HDMI does 4K30 (3840x2160 at a 297 MHz pixel clock) and cannot do 4K60. That is the hardware: the RK3288 caps TMDS at 340 MHz, its HDMI PHY has no scrambling above that, and the VOP cannot emit YUV420, so there is no reduced-rate path to 4K60 either. Nothing in the image configures any of this — the ceilings are constants in the driver, and the kernel Debian ships already supports everything the SoC can do.
One quirk is worth knowing if a 4K display comes up showing only part of the picture.
The RK3288 has two display controllers, and the smaller one (VOPL) tops out at 2560x1600
while advertising the same maximum as the larger one. Which controller the HDMI encoder
lands on is decided at runtime by DRM, not by configuration. dmesg | grep -i vop says
which one it got.
Status
A boot2deb-built image boots this board. Confirmed end to end on a libreboot unit,
from USB via Ctrl+U: forky comes up, runs first boot, reboots itself, and comes back to a
login prompt. The per-image password works and is changed at first login; nmtui joins
Wi-Fi.
What that reboot proves is worth spelling out, because it is the part of the flow with no
second chance. First boot gives the rootfs a new partition UUID and a new filesystem
UUID, which invalidates the root= baked into the signed kernel — so the board is only
bootable again because the first-boot.d/10-depthcharge hook re-signed the kernel against
the new UUID and wrote it into both kernel slots before rebooting. A board that comes
back to a login prompt has exercised that whole path, and comes out of it with two
known-good kernels rather than one, which is the state every later upgrade relies on.
Expect a white screen for roughly 10 seconds after Ctrl+U before the boot messages appear. That is normal and not a fault: there is no display driver in the initramfs, so the panel holds the firmware’s last frame until the kernel’s DRM stack takes over.
Audio is confirmed on hardware: the internal speakers and volume control work out of the box. Bluetooth ships configured — the kernel log shows the radio initialize and load its patchram — but has not yet been exercised against a device.
Stock-firmware hardware is untested. The stock speedy profile is what the image
ships by default and there is good reason to expect it to work — the profile is
depthcharge-tools’ own stock definition, the same one postmarketOS and Arch Linux ARM
use on these boards, and a libreboot unit boots it — but no one has yet booted a
boot2deb image on a C201 running its factory firmware. Treat it as high-confidence,
not proven, and note the extra crossystem dev_boot_usb=1 step above.
Wi-Fi needs two Broadcom blobs Debian does not ship (a board NVRAM file and a Bluetooth
patchram); they are vendored in the SoC layer’s overlay, since it is the same radio module
on every Broadcom board in the family. See socs/rk3288/README.md for their provenance
and why Debian’s and ChromiumOS’s copies are the wrong module.
The family
The depthcharge boot method is not C201-specific, and that is the point of it. Two siblings ship already — the C100P and the Chromebit CS10 — and each is a device file and nothing else: no overlay, no engine change, no kernel. Their device trees are upstream and everything that makes a Veyron boot lives on the shared layers.
The same method reaches the seven remaining Veyron boards and the RK3399 gru
Chromebooks — which are easier than this one: arm64, a 32 MiB budget, and firmware that
loads a FIT ramdisk without the DTB patching. Doing the hard 32-bit case first is what
makes those nearly free.
ASUS Chromebook Flip C100P
The asus-c100p-forky recipe builds a bootable Debian forky image for the ASUS
Chromebook Flip C100P/C100PA (google,veyron-minnie) — the 10.1“ convertible of the
RK3288 Veyron family. asus-c100p-trixie is the same board on the stable suite.
cargo run -p boot2deb-cli -- build asus-c100p-forky
That produces build/asus-c100p-forky/artifacts/asus-c100p.img.xz — a whole-disk image
carrying two ChromeOS kernel slots and the ext4 rootfs, so one write lays down everything
the firmware needs. The kernel is in the first slot; the second ships empty, and is what
lets a later kernel upgrade roll itself back if the new kernel does not boot. See
Upgrading the kernel.
A C201 that folds
Structurally this board is the C201. It includes the same
rk3288-veyron-chromebook.dtsi, so the EC keyboard, the trackpad, the microSD slot, the
max98090 codec and the eDP panel are all identical, and all of them — along with the
Broadcom radio, the initramfs and the network stack — are inherited from the shared layers.
Its device file states a boot method, a board profile, a DTB and a few defaults, and ships
no overlay.
Its own hardware deltas are four, and only the last two matter to you:
| C201 | C100P | |
|---|---|---|
| panel | 1366x768 innolux,n116bge | 1280x800 auo,b101ean01 |
| extra input | — | volume buttons, and a touchscreen |
| touchscreen | none | Elan ekth3500 on i2c3 |
| battery gauge | sbs-battery | ti,bq27500 |
Board profiles
One: minnie, which depthcharge-tools carries as a first-class board. There is no
libreboot port for it — the family’s only libreboot profile is the C201’s — so this board
runs stock ChromeOS firmware and the crossystem step below is required.
Flash and boot
Write the image to a microSD card or a USB stick (the C100P has both, and two USB ports, so unlike the Chromebit nothing here needs a hub):
xzcat build/asus-c100p-forky/artifacts/asus-c100p.img.xz \
| sudo dd of=/dev/sdX bs=4M status=progress conv=fsync # confirm /dev/sdX with lsblk
The unit must be in developer mode: power off, hold Esc + Refresh and briefly press Power, keep Esc+Refresh held until the recovery screen appears, then Ctrl+D and Enter to confirm. It wipes and transitions; allow 15 to 20 minutes.
Then, once, from a ChromeOS shell (Ctrl+Alt+T, then shell):
sudo crossystem dev_boot_usb=1 dev_boot_signed_only=0
Reboot and press Ctrl+U at the “OS verification is OFF” screen. Ctrl+U covers the SD card as well as USB.
The convertible’s side buttons do nothing here. The recovery combo is read by the EC from the built-in keyboard, and the volume-button sequence Google documents belongs to detachables and tablets, which this firmware is not built as. Use Esc+Refresh+Power.
A USB keyboard will not help you at these screens either: CONFIG_LP_USB_HID is not set in
this board’s libpayload, so depthcharge reads the EC keyboard and nothing else. (The
Chromebit, which has no EC, is the one board in the family built the other way.)
If a boot fails, the board tells you by rebooting: the signed command line carries
panic=30, so a kernel panic or an initramfs that gives up on root returns to the firmware
splash about 30 seconds later. A board that never reboots means the kernel never reached
the initramfs at all. A panic also writes a full dmesg to BOOT2DEB-PANIC.txt on every
ext4 partition it can reach.
Expect 8-10 seconds of white screen on a healthy boot before the display comes up: the standard image leaves the DRM stack out of the initramfs to keep the signed payload comfortably under its 16 MiB ceiling, so the console appears only once the real root is mounted.
Installing to the eMMC
The board has 16 GB of internal eMMC, and the image is a whole-disk image, so putting the OS there is one command from a booted card:
lsblk # the eMMC is mmcblk0 — the one with mmcblk0boot0 beside it
xzcat asus-c100p-forky.img.xz | sudo dd of=/dev/mmcblk0 bs=4M status=progress conv=fsync
sudo reboot # Ctrl+D boots the eMMC, Ctrl+U the card
This needs no kernel patch, contrary to the usual advice. The Veyron eMMC ships with its
primary GPT deliberately corrupted — ChromeOS marks it IGNOREME and uses the secondary,
and a stock kernel cannot read a table like that. That only bites if you keep the factory
GPT. Writing a whole-disk image lays down a fresh, valid one over the top, which a stock
kernel reads like any other.
The touchscreen and the battery gauge do not work
Both are gaps in Debian’s kernel configuration, not in the hardware, the boot path, or this board’s device tree. The C100P needs two drivers that Debian’s armhf kernel does not build, in forky (7.1.3) and trixie (6.12.94) alike:
| what | driver | Debian armhf |
|---|---|---|
Elan ekth3500 touchscreen | elants_i2c | # CONFIG_TOUCHSCREEN_ELAN is not set |
ti,bq27500 fuel gauge | bq27xxx_battery_i2c | # CONFIG_BATTERY_BQ27XXX_I2C is not set |
So a C100P image comes up with a working keyboard, trackpad, panel, HDMI, Wi-Fi, Bluetooth and audio — and no touch input and no battery percentage.
Neither is a near miss you can work around in config. The modules are simply absent from
the kernel Debian ships. Note the trackpad is unaffected and does work: it is a
different Elan driver (elan_i2c, CONFIG_MOUSE_ELAN_I2C=m), and the similar names are
the only thing the two have in common. The C201 is unaffected by both gaps — its battery is
an SBS one, which Debian does build, and it has no touchscreen.
Keyboard
A laptop, so it declares a console keymap — keymap = "us", the layout the C100PA ships.
cargo run -p boot2deb-cli -- build asus-c100p-forky --keymap gb
sudo dpkg-reconfigure keyboard-configuration && sudo setupcon # or, on the board
See Locale, timezone, and keyboard.
Getting online
There is no ethernet port, so Wi-Fi is the only way onto the network:
sudo nmtui # pick "Activate a connection", choose the network, enter the key
The radio is the family’s Broadcom BCM4354 and needs two blobs Debian does not ship; they
are vendored on the SoC layer and are already in the image. Bluetooth works as it does on
the C201 — the BCM4354’s Bluetooth half is on uart0, the kernel loads the vendored
patchram, and bluez is installed to use it. btsdio is blacklisted, because if it claims
the BCM4354’s SDIO Bluetooth function, Wi-Fi does not survive suspend and resume.
Audio
The same max98090 as the C201, so the same first-boot fixup applies unchanged. The codec
comes up with its amplifiers muted and the DAPM mixers that feed them holding their DAC
input switches open — so there is no route from the DAC to the speakers to unmute in the
first place, and clearing only the obvious Speaker control leaves the board silent.
The SoC layer’s first-boot.d/20-audio hook closes the routing switches, unmutes both
amplifiers, sets sane volumes, and runs alsactl store; alsa-utils replays the result on
every later boot. Adjust it like any other Debian system:
alsamixer && sudo alsactl store
Display
A 1280x800 eDP panel and a micro-HDMI port, both driven by mainline rockchip-drm.
The panel’s backlight has one quirk worth knowing if you write to it directly: its PWM duty must be at least 1%, so the device tree starts its brightness scale at 3, not 0. A userspace policy that writes 0 to turn the backlight down is doing something this panel does not accept.
HDMI does 4K30 and cannot do 4K60 — the RK3288 caps TMDS at 340 MHz, its PHY has no scrambling above that, and the VOP cannot emit YUV420, so there is no reduced-rate path. Nothing in the image configures any of this.
Like the C201, this board lights two display controllers, and the smaller one (VOPL) tops
out at 2560x1600 while advertising the same maximum as the larger. Which one the HDMI
encoder lands on is decided at runtime by DRM, not by configuration; dmesg | grep -i vop
says which it got. That is the thing to check if a 4K display comes up showing only part of
the picture.
Status
Not yet booted on hardware. The image builds, and everything it is made of is shared with a board that does boot: the C100P resolves to the same rootfs, the same boot method, the same signed-payload flow, the same initramfs and the same Debian kernel as the C201, which is confirmed booting to a login prompt. It differs from it in a DTB, a depthcharge profile and a hostname.
Of the family’s three boards this is the one most likely to boot first time — it is the C201 in a different case, and unlike the Chromebit it has a card slot, a keyboard and two USB ports, so there is nothing unusual about getting an image into it. The known gaps are the touchscreen and the battery gauge, above; audio and Bluetooth ship configured and are unverified here.
The family
The depthcharge boot method is not board-specific, and this is what that buys: the C100P is a device file and nothing else. No overlay, no kernel, no engine change — its device tree is upstream and everything that makes a Veyron boot lives on the shared layers. The same holds for the Chromebit CS10, and for the seven Veyron boards not yet written.
ASUS Chromebit CS10
The asus-chromebit-cs10-forky recipe builds a bootable Debian forky image for the
ASUS Chromebit CS10 (google,veyron-mickey) — an RK3288 Veyron, like the
C201 and the C100P, but in an HDMI stick rather than a
laptop. asus-chromebit-cs10-trixie is the same board on the stable suite.
cargo run -p boot2deb-cli -- build asus-chromebit-cs10-forky
That produces build/asus-chromebit-cs10-forky/artifacts/asus-chromebit-cs10.img.xz — a
whole-disk image carrying two ChromeOS kernel slots and the ext4 rootfs, so one write lays
down everything the firmware needs. The kernel is in the first slot; the second ships
empty, and is what lets a later kernel upgrade roll itself back if the new kernel does not
boot. See Upgrading the kernel.
What is unusual about this board
Its device tree tells you the whole story in one line. rk3288-veyron-mickey.dts
includes rk3288-veyron.dtsi directly and never picks up
rk3288-veyron-chromebook.dtsi — the file that gives every other board in the family its
SD slot, its ChromeOS EC, its keyboard, its trackpad, its eDP panel and its max98090
codec. The Chromebit has none of those, and that single omission is what the board is:
| Chromebit | the Veyron laptops | |
|---|---|---|
| removable storage | none — no SD slot | microSD |
| internal storage | 16 GB eMMC (mmcblk0) | 16 GB eMMC |
| keyboard | none — USB only | EC keyboard on spi0 |
| USB ports | one | two, plus the card slot |
| display | HDMI only | eDP panel + HDMI |
| audio | HDMI only (ROCKCHIP-HDMI) | max98090 (ROCKCHIP-MAX98090-HDMI) |
| battery / lid | none — mains only | both |
Everything else is the family’s and is inherited unchanged: the Broadcom radio, the initramfs, the network stack, the boot method. So for all that it is the odd one out, the board’s device file states a boot method, a board profile, a DTB and a handful of defaults — and ships no overlay at all.
Board profiles
One: mickey, which depthcharge-tools carries as a first-class board. There is no
libreboot port for the Chromebit — the family’s only libreboot profile is the C201’s —
so this board always runs stock ChromeOS firmware, and the crossystem step below
is not optional the way it is on a libreboot C201.
You will need a USB hub
The Chromebit has one USB 2.0 port, and installing needs two things in it at once: a keyboard, to press Ctrl+U, and the stick you are booting. So a hub is not a convenience here, it is a prerequisite.
It does work, and not by luck. The Chromebit is the one board in the Veyron family
whose firmware is built with USB HID — CONFIG_DRIVER_INPUT_USB=y in depthcharge, and
CONFIG_LP_USB_HID=y plus CONFIG_LP_USB_HUB=y in its libpayload — because it is the
one board with no keyboard to read instead. A keyboard behind a hub is precisely the
topology this firmware was compiled for. (The inverse is also true and worth knowing:
CONFIG_LP_USB_HID is not set on the C201 or the C100P, so on those boards a USB
keyboard does nothing at the firmware screens.)
Use a self-powered hub, with its own wall supply. ASUS’s own documentation warns that anything drawing more than 500 mA should not hang off this port directly, and a keyboard plus a flash drive on a bus-powered hub is a real brown-out risk.
Flash and boot
Write the image to a USB stick:
xzcat build/asus-chromebit-cs10-forky/artifacts/asus-chromebit-cs10.img.xz \
| sudo dd of=/dev/sdX bs=4M status=progress conv=fsync # confirm /dev/sdX with lsblk
Entering developer mode. The Chromebit has no power button — it boots the instant DC is applied — so the usual “hold keys and tap power” does not exist here. The sequence is:
- Connect HDMI, then the powered hub, then a wired USB keyboard into the hub. Leave the barrel jack unplugged.
- Press and hold the recovery button — a pinhole on the underside, opposite the HDMI connector; use a paperclip — and, still holding it, plug in the DC barrel jack. Release when the screen changes.
- The recovery screen appears. Press Ctrl+D on the USB keyboard. There is no on-screen prompt for it.
- Confirm by pressing the recovery button again with the paperclip — on a device with no keyboard the firmware treats that button as Enter — then Ctrl+D to reboot.
- It wipes and transitions to developer mode. Allow 10 to 15 minutes.
Enabling external boot. From a ChromeOS shell (Ctrl+Alt+T, then shell), once:
sudo crossystem dev_boot_usb=1 dev_boot_signed_only=0
Then reboot and press Ctrl+U at the “OS verification is OFF” screen. A 2.4 GHz keyboard with its own USB receiver works as well as a wired one; a Bluetooth keyboard does not, because the firmware has no Bluetooth stack.
If a boot fails, the board tells you by rebooting: the signed command line carries
panic=30, so a kernel panic or an initramfs that gives up on root returns to the
firmware splash about 30 seconds later. A board that never reboots means the kernel
never reached the initramfs at all — which on a machine with no serial console is the
single most useful thing a failed boot can say. A panic also writes a full dmesg to
BOOT2DEB-PANIC.txt on every ext4 partition it can reach.
Expect several seconds of blank HDMI on a healthy boot before the console appears: the standard image leaves the DRM stack out of the initramfs to keep the signed payload under its 16 MiB ceiling, so the display comes up only once the real root is mounted.
Installing to the eMMC
Running from a USB 2.0 stick works but is slow, and it means the hub stays plugged in forever. The board has 16 GB of eMMC, and once the OS is on it the Chromebit boots with no keyboard, no stick and no hub — Ctrl+D at the developer screen, or just wait out the 30-second timeout.
The image is a whole-disk image, so installing it is one command. Boot the USB stick, join
Wi-Fi, get the same .img.xz onto the running system (scp it from the build host, or
keep a copy on the stick — first boot grows the rootfs, so there is room), and write it to
the internal eMMC:
lsblk # the eMMC is mmcblk0 — it is the one with mmcblk0boot0 beside it
xzcat asus-chromebit-cs10-forky.img.xz | sudo dd of=/dev/mmcblk0 bs=4M status=progress conv=fsync
sudo reboot # then Ctrl+D to boot the eMMC, or wait out the timeout
First boot on the eMMC then does what it did on the stick: it grows the rootfs to fill the device, gives it fresh UUIDs, and re-signs the kernel against them into both of that medium’s kernel slots. The stick can stay plugged in — the two installs do not collide, because first boot already gave the stick its own UUIDs, and Ctrl+D and Ctrl+U pick between the two media explicitly, each of which carries its own pair of slots.
Why this needs no kernel patch, contrary to the usual advice. The Veyron eMMC ships
with its primary GPT deliberately corrupted — ChromeOS marks it IGNOREME and uses the
secondary — and a stock Linux kernel cannot read a partition table like that. Every guide
therefore tells you an eMMC install needs a patched kernel. That is true only if you
keep the factory GPT. Writing a whole-disk image does not: it lays down a fresh, valid
GPT over the top, and a stock kernel reads that one like any other. (postmarketOS does
exactly this on the C201’s eMMC and boots from it.)
Keyboard
A board with no keyboard still declares a console keymap, and it is not a contradiction.
The question keymap answers is “does a console layout configure anything here?” — not
“does the board have keys”. The Chromebit drives an HDMI console that a USB keyboard is
the only way to type at, so a layout means exactly what it means on a laptop; it just
describes a keyboard you bring. The default is us.
cargo run -p boot2deb-cli -- build asus-chromebit-cs10-forky --keymap gb
sudo dpkg-reconfigure keyboard-configuration && sudo setupcon # or, on the board
This has no bearing on the firmware screens. Depthcharge reads Ctrl+U with its own USB HID driver and its own fixed layout, long before Linux exists.
See Locale, timezone, and keyboard.
Getting online
There is no ethernet port, so Wi-Fi is the only way onto the network:
sudo nmtui # pick "Activate a connection", choose the network, enter the key
The radio is the family’s Broadcom BCM4354 and needs two blobs Debian does not ship; they
are vendored on the SoC layer and are already in the image. Bluetooth works the same way
as on the laptops — the BCM4354’s Bluetooth half is on uart0, the kernel loads the
vendored patchram, and bluez is installed to use it.
Audio and display
Both come out of the HDMI connector and nothing else does.
Audio is HDMI only. The Chromebit’s sound node wires straight to the HDMI codec with
no audio-codec phandle, so the machine driver builds a different card entirely: ALSA
shows ROCKCHIP-HDMI, not the ROCKCHIP-MAX98090-HDMI the laptops get. There is no
max98090, no headset codec, and nothing to unmute — the family’s 20-audio first-boot
hook probes for the codec’s mixer controls, does not find them, and exits without
touching anything. That is the correct outcome, not a failure.
Display is HDMI only, and simpler than on the laptops. The Chromebit lights one
display controller (vopb); there is no eDP, no panel and no backlight. That also means
it is free of the trap the C201 has, where two controllers advertise the same maximum and
DRM picks between them at runtime.
HDMI does 4K30 and cannot do 4K60. That is the silicon: the RK3288 caps TMDS at 340 MHz, its PHY has no scrambling above that, and the VOP cannot emit YUV420, so there is no reduced-rate path either. Nothing in the image configures any of this.
Status
Not yet booted on hardware. The image builds, and everything it is made of is shared with a board that does boot: the same boot method, the same signed-payload flow, the same initramfs, the same radio and the same Debian kernel as the C201, which is confirmed booting to a login prompt. What is untested is this board’s own firmware, and its DTB.
Two things to know before you try it, because they are the reported failure modes:
- USB boot on the Chromebit has been reported to fail — a 2015 review could not boot
a stick at all, and there is an unanswered forum thread where Ctrl+U flashes black on a
postmarketOS image. Both are most consistent with malformed boot media (a kernel
partition that is not in ChromeOS format, or one whose GPT attribute bits are unset)
rather than a firmware limit; boot2deb’s payload is built by
depthchargectlagainst the board’s own profile and its attribute bits are asserted at build time. Expect it to work, but this is the board’s open question. - No one has confirmed an eMMC install on a Chromebit. The one public attempt fails with “Primary GPT header is being ignored”, which is the factory GPT being preserved — the thing writing a whole-disk image does not do. The reasoning in Installing to the eMMC above is sound and the same flow works on the C201, but it has not been run on this board.
Audio, Bluetooth and HDMI ship configured and are unverified here.
The family
The Chromebit is the awkward member of the family and it still costs exactly one file. It ships no overlay and needed no change to the engine — everything that makes a Veyron boot lives on the SoC and boot-method layers, and a board that is a stick rather than a laptop inherits all of it unchanged. The same holds for the C100P, and for the seven Veyron boards not yet written.
Config model
A build is a single point across the axes a user selects:
device × kernel × suite × layout, plus composable features
- device — the target hardware. It resolves through a layered hardware stack (see below).
- kernel — an orthogonal axis that owns everything version-coupled: its source
refs,
.configfragments, and patch profile. A device declares which kernels it supports and a default; override with--kernel(values fromlist-kernels). Some kernels are not built at all. - suite — the Debian suite (e.g.
forky,sid); override with--suite. - layout — how the disk image is packaged:
combined(one whole-disk image, boot payload and rootfs on a single medium) orsplit(separate bootloader and rootfs images for a two-medium install); override with--layout. Only a boot method that has a bootloader can split it off; the combination is rejected at resolve for one that does not. - features — a list of composable rootfs add-ins stacked onto the base image:
a capability feature that provides a hardware stack (
media-accel-rockchip, the RK35xx HW-transcode userspace) or an application feature that installs an app (jellyfin). Features are the knob the RK1 recipes differ by over one shared device and kernel:turing-rk1-forky(base) selects none,turing-rk1-media-accel-forkyadds the capability, andturing-rk1-jellyfinadds the app on top of it. Override with--feature(repeatable; values fromlist-features).
Three more knobs round out a build without being headline axes: --boot-method (a
device property, rarely overridden), --board (the depthcharge board profile — see
Boot methods describe different things), and
--image-size.
The system locale, timezone, and console keymap are resolved the same way, and are split across two layers for a reason: see Locale, timezone, and keyboard.
Kernels are compiled, or installed
A kernel definition’s flavor decides what shape it has, because the two kinds of
kernel have almost nothing in common:
-
mainline/vendor— compiled from source. The definition owns a source ref, a base defconfig, a fragment list, and a patch profile, and the build clones the tree, applies the series, merges the config, and runsmake bindeb-pkg. The lock pins the exact commit. -
distro-package— installed from the Debian mirror. The definition owns nothing but a package name (linux-image-armmp); Debian owns the source, the config, and the patches. There is no compile node, no fragment merge, no patch series, and no[kernel]table in the lock — the exact version and hash are pinned in the solved package manifest, alongside every other package in the image.
This is not a shortcut. For a board whose SoC and device tree are fully upstream —
every Veyron Chromebook, for instance — compiling a kernel would add a cross-build and
a maintenance burden to arrive at a worse version of what apt already ships: one
that stops receiving Debian’s security updates on the running board. Where Debian’s
kernel runs the hardware, using it is the right answer, and the model says so rather
than pretending otherwise.
One definition then serves every suite, because the suite picks the version:
asus-c201-forky and asus-c201-trixie name the same debian-armmp kernel and resolve
7.1.x and 6.12.x respectively.
A distro kernel rejects the two device fields it could never act on — device_dts and
device_config_fragments are compile inputs, and a board that declared them with a
kernel that compiles nothing would read as configured and boot as broken.
Boot methods describe different things
A boot method is not a set of options on a common shape — the shapes genuinely differ —
so boot-methods/<method>.toml is a variant per method, and the file’s own name
selects it. A field belonging to another method is an unknown field: a parse error
naming the file, not a value quietly carried into a build with nowhere to put it.
-
rockchip-rkbin— we compile the bootloader. The layer carries the u-boot source and ref and the raw-gap offsets (idbloader_offset,uboot_itb_offset,rootfs_offset); the device carriesuboot_defconfigand inherits an rkbin blob set (ATF + DDR TPL) from its SoC. The payloads land outside any partition, in the gap ahead of the rootfs. -
depthcharge— we compile no bootloader at all. The firmware is the board’s own (coreboot in an SPI chip), and what it loads is the kernel itself, vboot-signed and wrapped in a FIT, from a ChromeOS kernel partition it finds by scanning each medium’s GPT for a type GUID. The layer carries those partitions’ geometry and the GPT attribute bits that make the firmware boot one of them (priority/tries/successful), plus the command line to bake into the signature. The device carries a board profile.kpart_slotsis the field worth understanding. It is how many kernel partitions the image lays down, back to back, and it is 2: the first carries the signed kernel, the second ships empty at priority 0. That spare is what makes an on-device kernel upgrade atomic — the upgrade writes the slot the board is not booted from, so a kernel that fails to come up leaves the previous one intact for the firmware to fall back to. At one slot there is no fallback and a bad upgrade needs external media to recover. See Upgrading the kernel.
Because the requirements are method-scoped, a board is only ever asked for fields its
own boot method reads: the C201 declares no uboot_defconfig and no rkbin blobs, and
omitting them is not an error — omitting its [depthcharge] block is.
Board profiles
A depthcharge board profile is depthcharge-tools’ codename for a firmware
behaviour set — its payload ceiling, and whether it loads a FIT ramdisk or needs the
initramfs address patched into every DTB. It describes the firmware a unit runs, not
the board model: the same C201 takes one profile on stock firmware and another with
libreboot installed. So the device declares a default and the profiles it supports, and
--board selects among them.
The default is deliberately the stock profile: a stock-profile image boots on stock firmware and on a unit running libreboot, while the reverse is not true.
Patch profiles belong to the kernel
A patch profile (e.g. rk3588-accel) is the ordered patch series applied to the
source trees before they compile. It is a property of the kernel definition, not a
user-selected axis: a kernel names its profile via patch_profile in
kernels/<id>.toml, and there is deliberately no --profile flag, because a series
that applies to one kernel version does not apply to another — so the profile is
version-coupled to the kernel that owns it. Profiles live in a separate patches repo,
not in this one; the resolved profile name and its exact patches-repo commit are
recorded together in the lock’s [patches] block. Authoring workflow:
Adding a patch.
A kernel may apply no series at all — a stock mainline kernel whose SoC is fully
upstream, or a vendor tree that already ships its patches. It writes
patch_profile = "none", and then the build never reads the patches repo: nothing is
fetched, nothing is applied, verify-patches reports there is nothing to verify, and
the lock omits its [patches] block entirely rather than pinning a commit the build
never consumes. Such a board builds on a machine with no patches checkout.
The hardware stack
The device’s hardware properties resolve by merging four TOML layers, lowest to highest precedence:
arches ← socs ← boot-methods ← devices
Each layer states only its deltas. A value lives at the lowest layer that fully determines it — for example, the DDR TPL blob is board-memory-specific, so it lives at the device layer, not the soc layer. The kernel axis is resolved separately and merged in, since a kernel’s refs and fragments are coupled to its version rather than to the hardware.
The config layers are the top-level directories:
arches/ socs/ boot-methods/ devices/ kernels/ recipes/
with vendored bootloader blobs under blobs/<soc>/, kernel .config fragments under
fragments/, and the resolved exact pins in recipes/<recipe>.lock.
Media-accel sources ride the feature, not the SoC
The [userspace] (MPP/RGA/Mali) and [ffmpeg] source stanzas at the soc layer are
optional. They provide the trees the media-accel-rockchip feature compiles, and
they are copied into a build only when a selected feature declares
requires_media_accel. A recipe that builds no transcode stack carries no such sources
and skips the userspace/ffmpeg compile nodes entirely; a SoC that never transcodes omits
the stanzas. Selecting a requires_media_accel feature on a SoC that lacks them is a
resolve-time error, so the coupling is checked, not assumed.
A board device tree that is not yet upstream
A device normally names an in-tree DTB with kernel_dtb, and the kernel’s own tree
builds it. A freshly-supported SoC often has every driver upstream but none of its
boards, so a device may instead carry its device-tree sources in device_dts — the
board .dts plus any board-specific .dtsi, as config-root-relative paths resolved
along the overlay search path like a fragment or blob:
kernel_dtb = "rockchip/rk3576-h96-max-m9.dtb"
device_dts = ["devices/h96-max-m9/dts/rk3576-h96-max-m9.dts"]
The kernel stage copies them into arch/<arch>/boot/dts/<dt_dir>/ after the clone and
git am, then teaches that directory’s Makefile to build the DTB, so bindeb-pkg
ships it in the linux-image deb like any in-tree board — and a forked board .dts’s
#include "<soc>.dtsi" resolves for free. Each source is content-hashed into the kernel
tree’s signature, so editing the .dts rebuilds. Resolution checks that kernel_dtb is
actually built by one of the listed sources, and that each entry is a contained relative
.dts/.dtsi path.
device_dts adds a new board device tree. Editing an existing upstream .dts is a
patch’s job, and a source that would overwrite an in-tree file is refused. For the
edit → reflash loop, build <recipe> --stage dtb rebuilds just that DTB in seconds.
Explicit over derived
Several device values are redundant with a value the resolver could derive:
default_kernel must also appear in supported_kernels; boot_method in
supported_boot_methods; kernel_dtb repeats the SoC’s dt_dir prefix; default_suite
appears on both the device and any recipe that pins it. These are kept explicit on
purpose: every value a board contributes is visible in its own file and greppable
across the tree, which matters more in a small hand-authored config repo than saving a
few lines. The redundancy is not unchecked — resolution rejects a default_kernel outside
supported_kernels, a boot_method outside supported_boot_methods, and so on — so a
drifted duplicate fails fast rather than silently. boot2deb new-device emits these
values for you, so the boilerplate is paid by the generator, not the author.
Recipes and the lock
A recipe (recipes/<recipe>.toml) pins one buildable point: it names the device
and, optionally, the kernel, suite, features, layout, and image size (each omitted axis
falls back to the device default). Its lock (recipes/<recipe>.lock) holds the
exact resolved pins: for every git source, the repo URL it was pinned from plus the
ref and commit, blob content hashes, and the solved rootfs manifest digest.
A lock records what the build depends on, and nothing else. Each table is present
only when the build actually has that dependency: [kernel] when a kernel is compiled,
[uboot] and [blobs] when a bootloader is, [patches] when a series is applied,
[userspace]/[ffmpeg] when the media-accel stack is. Pinning a commit nothing
consumes would record provenance for a dependency that does not exist — and would make
update demand a checkout the build never reads. Taken to its limit, a board that
installs Debian’s kernel and boots its own firmware has a lock with exactly one table:
[rootfs]
suite = "forky"
manifest = "asus-c201-forky.pkgs.lock"
That is the whole truth about what it depends on, and the package manifest beside it pins every one of those packages by name, version, and sha256.
The split between the two is what makes a build reproducible:
updateis the only command that consults upstream. It resolves refs to commits, hashes blobs, and writes the lock.buildreads only the lock. It touches no network for its pins, so the same lock always produces the same inputs. Before building it checks the lock against a fresh resolution on every axis the lock records from config — the source repos, blob file names, kernel id, suite, patch profile, extra debs — and refuses on drift, so a config edit afterupdate(say a boot-method flip to a different u-boot repo) is a named error rather than a build against stale pins.
See the CLI reference for the commands that operate on these.
Crates
The builder is a Rust workspace of three crates:
crates/core typed model, layer resolution + validation, patch-profile / lock /
kconfig formats (pure, deterministic, unit-tested — no Linux host)
crates/engine Linux side effects: git shell-outs, the lock resolver, the patch
verify gate, kernel-config generation, the compile stages (kernel /
u-boot / userspace / ffmpeg), the rootfs + image nodes, and the host
preflight behind `doctor`
crates/cli the boot2deb binary
core is pure and testable without a Linux host; all side effects (the filesystem,
subprocesses, the network) live in engine.
CLI
The binary is boot2deb; during development run it with cargo run -p boot2deb-cli --.
It defaults --root ., so run it from inside boot2deb/ (or pass --root).
Three global flags apply to every command: --root <dir> (the config root),
--overlay <dir> (an out-of-tree config overlay, repeatable — see
Overlays), and --json (machine-readable output).
Under --json, the list-* commands print one JSON array (unreadable entries
become {"name", "error"} objects), resolve prints the fully resolved build as
one JSON document, and build streams NDJSON — one JSON object per line, tagged
by its event field (step_started, progress, log, artifact,
step_finished, error), with every produced artifact’s path carried by an
artifact event. Errors are still plain text on stderr, and the exit code is the
result either way. Other commands print their human form regardless.
The two commands that split reproducibility from upstream are update (the only one
that consults the network) and build (reads only the lock). See
Config model for that split.
Inspection
cargo run -p boot2deb-cli -- list-devices
cargo run -p boot2deb-cli -- list-recipes
cargo run -p boot2deb-cli -- list-kernels
cargo run -p boot2deb-cli -- list-features
cargo run -p boot2deb-cli -- resolve turing-rk1-forky
cargo run -p boot2deb-cli -- resolve turing-rk1 --suite sid --layout split
cargo run -p boot2deb-cli -- doctor turing-rk1-forky
list-devices/list-recipesenumerate the buildable targets;list-recipesflags any recipe with no committed lock as not-yet-buildable (runupdate).list-kernels/list-featuresenumerate the valid values for the--kerneland--featureoverrides — name, version/compatibility, and (for kernels) the patch profile — so the override knobs are discoverable without reading the TOML tree.resolveprints the fully merged build point without building, and runs the same localpreflight_configcoherence check the build does (geometry, fragment-file existence, feature compatibility, apt keyrings). Selectable axes (--kernel,--suite,--feature,--layout,--boot-method,--board,--image-size,--locale,--locale-gen,--timezone,--keymap) can be overridden on the command line.doctorreports the host’s tool-presence preflight for a target and, for anything missing, the exact per-distro install command. It asks only for what that build will invoke: a board that installs Debian’s kernel and boots its own firmware compiles nothing, so it is not told to install a cross compiler — which keeps a genuinely missing tool from getting lost among requirements that do not apply. See Getting started.
Scaffolding
# Interactive on a terminal: menus over the valid SoC / boot-method / kernel / feature
# choices, then writes devices/<name>.toml + recipes/<name>.toml.
cargo run -p boot2deb-cli -- new-device my-board
# Scriptable: take every value from flags (required: --soc), no prompts.
cargo run -p boot2deb-cli -- new-device my-board --soc rk3588 \
--feature media-accel-rockchip --non-interactive
# Scaffold into your own overlay tree instead of the shipped root:
cargo run -p boot2deb-cli -- --overlay ~/my-boards new-device my-board --soc rk3588
new-device generates a device (and, unless --no-recipe, a matching recipe) from
the typed model. It offers only valid choices — the closed Soc/BootMethod/Layout
enums, the kernels whose supported_socs include the chosen SoC, and the features
compatible with the SoC/arch — fills every derivable value, and leaves the four
values it cannot validate (uboot_defconfig, kernel_dtb, and the [rkbin]
atf/tpl blobs) as best-effort suggestions marked # TODO:. It writes into the
highest-precedence --overlay when one is given (the third-party path), else the
primary root, then resolve-checks the result and prints exactly which values you still
have to research. It refuses to overwrite an existing file without --force.
The generated files resolve immediately (proving the layer composition); the # TODO:
values are the ones that fail late — at the u-boot or kernel build — if left wrong,
so verify them before update/build. See Adding a board.
update
cargo run -p boot2deb-cli -- update turing-rk1-forky --kernel-ref v7.1.1
Resolves upstream refs to commits and hashes the vendored blobs, writing
recipes/<recipe>.lock. This is the only command that consults upstream; build
reads only the lock, so a build is reproducible from its committed pins.
build
cargo run -p boot2deb-cli -- build turing-rk1-forky
Builds the recipe from its lock: compiles the kernel, u-boot, userspace, and ffmpeg, bootstraps the rootfs, and writes the bootable disk image. Notable flags:
--stage <node>runs a single node —kernel,dtb,uboot,userspace,ffmpeg,rootfs, orimage; the default builds everything. A--stage ubootrun also emits a standalone, directly-flashable<device>-boot.img(see below). Asking for a node this recipe does not have —--stage kernelon a board that installs Debian’s kernel — is an error naming why, not a silent no-op.--layout combined|splitoverrides the image packaging.combinedis one whole-disk image;splitemits a bootloader-only image and a separate rootfs image for a two-medium install. This is lock-independent — it changes only how the image is packaged, not any pinned source. Only a boot method that has a bootloader can split it off.--board <profile>selects the depthcharge board profile — which firmware the signed kernel is built for, not which board. The default is the device’s, which is the stock profile;--board speedy-libreboottargets a C201 running libreboot. Ignored by boot methods with no board profile.--locale,--locale-gen,--timezone,--keymapoverride the localization axes: the image’sLANG, any extra locales compiled into it, the/etc/localtimezone, and the console keyboard layout. Lock-independent — they change only generated rootfs config, not any pinned source. The system locale is always generated, so--locale de_DE.UTF-8needs no matching--locale-gen. See Locale, timezone, and keyboard.--refresh-rootfsforces a clean rootfs bootstrap instead of restoring the content cache.
The rootfs stage is content-cached: a cheap mmdebstrap --simulate solve keys a store,
so a rebuild whose solved package set is unchanged restores the bootstrapped tree
instead of re-running the multi-minute bootstrap. Because the key is the solved set, a
moved mirror resolves new versions and rebuilds automatically — a cache hit is never
stale. The unique per-image first-boot password is applied on restore, not cached, so
every image still gets its own credential.
Rebuilding only the board DTB
build <recipe> --stage dtb compiles just the board’s device tree in the
already-cloned, already-patched kernel tree and stages the .dtb — seconds rather than
a full kernel build. It is the bring-up loop for a board carrying its own device_dts
source: edit the .dts, rebuild the DTB, reflash. The result is byte-identical to the
DTB a full --stage kernel ships inside the linux-image deb.
Standalone bootloader image
build <recipe> --stage uboot writes <device>-boot.img next to the raw idbloader.img
and u-boot.itb: a small, GPT-less image holding just the bootloader at its offsets. It
needs no rootfs, so you can produce a flashable eMMC/SPI bootloader image without building
a whole OS. The split layout emits the same image as part of a full build. See
Turing RK1 for the eMMC-plus-NVMe workflow this serves.
Verification
Three read-only commands catch config mistakes before any compile — each exits non-zero on failure, so they gate CI as well as an interactive bring-up. They share the reproducibility split: every one reads the recipe’s lock for its pins, and any that needs a source tree auto-fetches it at the locked commit into a durable cache, so all three work on a fresh clone with no hand-cloned trees.
Which verify when
| What changed / what you want to be sure of | Command |
|---|---|
| Imported or edited a patch — does the series still apply to the pinned kernel (and ffmpeg/userspace)? | verify-patches |
Edited a .config fragment or the base defconfig — does the kernel .config still generate cleanly (and match a reference)? | verify-config |
| A lock is old — are its pinned commits still fetchable upstream, or has a branch moved out from under them? | verify-sources |
The first verify-patches or verify-config on a cold cache clones the kernel, and
linux-stable is large. If you already have a local checkout, point --kernel-src at it
(a git URL or path holding the locked commit) to make the fetch near-instant;
--ffmpeg-base-src and --mpp-src do the same for the other trees. verify-sources
never clones — it only queries the remotes.
verify-patches
# Dry-run every locked patch series against its source tree with `git am --3way`,
# hard-erroring on the first patch that does not apply. Omit the checkouts and each
# tree is auto-fetched at its pin.
cargo run -p boot2deb-cli -- verify-patches turing-rk1-forky
# Fast path when you already have a local kernel checkout:
cargo run -p boot2deb-cli -- verify-patches turing-rk1-forky --kernel-src ../linux
--kernel-path / --ffmpeg-path / --userspace-path are all optional: an omitted
tree is auto-fetched at its locked commit (ffmpeg and userspace only when the profile
carries patches for that scope). The --kernel-src / --ffmpeg-base-src / --mpp-src
flags (same names and meaning as build’s) override the fetch source — a git URL or
local path used in place of the configured upstream — while the tree still lands at
exactly the locked commit; they are consulted only on the first materialization and
ignored when the matching --*-path is given. The patches checkout is resolved the way
build does: an explicit --patches-path, else ../patches if present, else an
auto-fetch at the lock’s patches.commit.
verify-config
# Generate the kernel .config (base defconfig + fragments, via merge_config.sh) on the
# patched kernel tree and report the merge. Omit --kernel-path and the tree is fetched
# and the kernel patch series applied for you.
cargo run -p boot2deb-cli -- verify-config turing-rk1-forky
# Assert byte-identical CONFIG_* parity against a reference config as well:
cargo run -p boot2deb-cli -- verify-config turing-rk1-forky --reference-config /path/to/.config
--kernel-path is optional; omitted, the kernel is auto-fetched at its pin and the kernel
patch series applied before the config run. --kernel-src supplies a local fetch source
the same way as verify-patches. With --reference-config, the run additionally fails on
any CONFIG_* difference from the reference.
verify-sources
# Survey the durability of every source pin in the lock: for each, probe its configured
# upstream and report whether the commit is a durable tag, an ephemeral branch, or
# ORPHANED (no longer re-fetchable). Read-only: `git ls-remote` plus a bounded ancestry
# check -- no build, no checkout, no hardware.
cargo run -p boot2deb-cli -- verify-sources turing-rk1-forky
verify-sources answers “will this lock still build a year from now?” An orphaned pin
(a branch force-pushed, a tag deleted upstream) exits non-zero, so a periodic run catches
a lock rotting before a build needs it. Capture a snapshot (build --save-snapshot) to
make the rootfs solve durable the same way.
patch import
patch import fetches a patch, normalizes it to canonical git am-ready mbox, and slots
it into a profile — the first step of the patch-authoring loop. It is documented with its
full workflow (commit, re-pin, verify) on
Adding a patch:
cargo run -p boot2deb-cli -- patch import https://patchwork.kernel.org/project/linux-rockchip/patch/NNNN/mbox/ \
--profile rk3588-accel --scope kernel
Rebuild planning and cleanup
# Explain, offline, whether the next build reuses or rebuilds each compile node's source
# tree -- and which pinned input changed if it will rebuild.
cargo run -p boot2deb-cli -- why-rebuild turing-rk1-forky
# Remove a recipe's build scratch to reclaim disk or force a clean rebuild. --dry-run
# previews; --cache / --sandbox clean only that subtree.
cargo run -p boot2deb-cli -- clean turing-rk1-forky --dry-run
clean removes only directories build created: every work dir is stamped with a
.boot2deb-work marker, and an unmarked target is refused — so a mistyped
--work-dir cannot recursively delete an arbitrary tree. --force overrides the
check for a directory you are sure about.
why-rebuild reads the lock and each compile node’s signature stamp and reports, per node,
whether the next build reuses or rebuilds the cloned-and-patched tree, naming the pinned
input that moved when it will rebuild. It runs no build and touches no network.
Overlays
An overlay is an out-of-tree directory of config layers that boot2deb merges on top
of the shipped tree. It is how you keep your devices, recipes, and retunings in your
own repo — versioned, private, and never a fork of the vendored config. Pass one (or
several) with the global --overlay <dir> flag, on any command:
cargo run -p boot2deb-cli -- --overlay ~/my-boards build my-tablet-forky
Each --overlay must name an existing directory. An empty or mistyped path is a
resolve-time error rather than a silent no-op: an empty one would resolve every asset
against the current directory, and a typo would shadow nothing at all — either way the
build would quietly use a config tree you did not intend, which is exactly what an
overlay exists to make explicit.
An overlay has the same directory layout as the shipped root — any subset of
devices/, socs/, arches/, boot-methods/, kernels/, features/, recipes/,
plus fragments/, blobs/, and per-layer overlay/ trees. You ship only the files you
add or change; everything else resolves from the shipped tree underneath. Because an
overlay is just a second config search root, everything the CLI does — resolve,
doctor, verify-*, build, and the list-* commands — sees the merged tree.
What an overlay can do
- Retune one value. An overlay
devices/turing-rk1.tomlholding onlyimage_size = "8G"changes that one field — every other key merges from the shipped file (see merge semantics). - Add to a list. Add a
supported_kernel, an extra rootfs package, another[[apt_sources]]— by restating the array with your addition (arrays are replaced wholesale, not concatenated). - Add a whole target. Drop in a new
devices/my-tablet.toml,socs/…,kernels/…,features/…, orrecipes/…; it lists and builds alongside the shipped ones, sincelist-devices,list-recipes, and friends union the overlay’s targets in.
How overlays merge
The search path is the shipped root first, then each --overlay in the order given;
later wins, and any overlay wins over the shipped root. When the same layer file
(e.g. devices/turing-rk1.toml) exists in more than one root, the copies are
deep-merged:
- Tables merge key-by-key, recursing into nested tables — so setting one field leaves its siblings intact.
- Scalars and arrays are replaced wholesale — an overlay array sets the value, it does not append. To add one entry to a shipped list, restate the list with your entry included.
A layer file present only in an overlay simply adds a new target (nothing to merge).
Fragments, blobs, and per-feature/-layer rootfs trees (both overlay/ and
overlay-pre/) resolve along the same path: a same-named asset in an overlay shadows the
shipped one, while rootfs trees present in both roots stack (shipped first, overlay
last).
Locks land in the owning overlay
update writes a recipe’s lock, and build --save-manifest writes its solved manifest,
into the root that owns the recipe — so an overlay recipe’s lock and manifest land in
that overlay, beside the recipe, not in the shipped tree. An out-of-tree recipe stays
fully self-contained: recipe, lock, and manifest are all versioned together in your repo.
The keyring is a fixed trust anchor
One asset an overlay may not silently replace: the Debian archive keyring
(blobs/keyrings/debian-archive-keyring.gpg). It is the trust root for the rootfs
bootstrap, so an overlay that ships its own copy is refused with a fail-closed error
rather than trusted — an overlay must not be able to swap the bootstrap’s trust anchor.
If you genuinely intend to use the overlay’s keyring, opt in explicitly with
build --unsafe-overlay-keyring. Every other asset follows the normal
highest-precedence-wins rule; only this trust anchor is pinned to the shipped root.
Overlay or in-tree edit?
Two paths, chosen by intent:
- Overlay — you are bringing up your own board, or tuning a build for yourself. Keep
it out-of-tree with
--overlay; there is nothing to upstream and nothing to fork. - In-tree edit — you are contributing a board back to boot2deb. Edit the vendored tree directly and open a pull request.
Adding a board walks through the layers to write and applies to both paths — the only difference is whether the files land in your overlay or in the vendored tree.
Image identity
Every image boot2deb builds carries /etc/boot2deb/image.toml — a small TOML
document in which the image says what it is.
It exists for readers that are not the running system. A tool repairing a board that will not boot is looking at the disk from somewhere else: from a USB stick, or from a laptop with the eMMC dumped to a file, quite possibly without mounting the filesystem at all. Such a reader can work out a great deal from the disk itself — the partition table, the boot scheme, the kernel in the signed slot. This file is for the part it cannot.
What it looks like
A depthcharge board:
version = 1
[image]
device = "asus-c201"
description = "ASUS Chromebook C201 (RK3288, google,veyron-speedy)"
arch = "armv7"
soc = "rk3288"
boot_method = "depthcharge"
board = "speedy"
suite = "forky"
features = []
layout = "combined"
hostname = "asus-c201"
[kernel]
id = "debian-armmp"
flavor = "distro-package"
package = "linux-image-armmp"
A rockchip-rkbin board with a compiled kernel — no board, and the kernel is a git pin
rather than a package:
[image]
device = "turing-rk1"
boot_method = "rockchip-rkbin"
...
[kernel]
id = "rk3588-mainline-7.1"
flavor = "mainline"
reference = "v7.1.1"
commit = "c9acdc466e9aa96352f658b9276aa8a45b8e817d"
patch_profile = "rk3588-accel"
board is the reason the file exists
Everything else here is a cross-check: a reader can already infer the device, the boot method, and the architecture from the disk, and comparing what it inferred against what the image claims is worth doing — a disagreement is itself a finding.
board is different. It is the depthcharge board profile the kernel partition was signed
for, and it is not recoverable from the image. depthchargectl normally works it out
by reading the running board’s hardware ID and device-tree compatibles, which is exactly
what a tool running somewhere else cannot do. Re-signing a C201’s kernel from a laptop
means passing --board speedy, and this file is how that laptop knows to.
It also distinguishes firmware, not just hardware: a stock C201 and a libreboot’d one are the same board and take different profiles.
The field is absent under a boot method that has no board profile, rather than being an empty string a reader would have to special-case.
kernel.flavor decides how a kernel upgrade arrives
distro-package means the kernel comes from the Debian mirror and an upgrade is
apt upgrade. mainline or vendor means boot2deb compiled it, nothing will ever offer
it to the board, and a new one is a .deb somebody has to hand it. A tool that intends to
put a kernel on this system needs to know which.
layout matters on a split image
Under layout = "split" the boot payload and the root filesystem live on different
media — u-boot on the eMMC, the OS on NVMe. A reader that finds this rootfs with no
bootloader beside it is looking at an expected state, not a fault.
It carries no secrets
boot2deb also emits a provenance manifest (<recipe>.provenance.toml) beside the
image, which records every source pin, the toolchain, the solved package manifest’s
digest, and the image’s initial first-boot password. That document stays with the build.
It never ships inside an image, and image.toml is a deliberately chosen subset of it
with the credential — and everything else an image has no business carrying — left out.
Two values that are in the manifest cannot be in image.toml even in principle: the
solved-manifest digest and the package count are produced by the rootfs bootstrap, so
they are not yet known at the moment the file is written into the rootfs they would
describe.
Compatibility
version is the schema version, and this is a wire format: it is parsed by programs
versioned independently of boot2deb. A reader must check it, and must tolerate fields it
does not recognise. Adding an optional field does not bump the version; changing what a
field means, or removing one, does.
The file is written as part of the generated config, alongside /etc/boot2deb/board.conf,
so it folds into the rootfs cache key like every other generated file — a cached rootfs
can never be reused under an identity that disagrees with it.
Reproducibility
Reproducibility here is a property of a lock, not a promise of the tool. boot2deb does not guarantee that any clone rebuilds any image forever — that would over-promise, and during active development it is not even true. What it guarantees is narrower and honest: the machinery to make a given build point reproducible to whatever strength you choose, and a documented way to rebuild the images the project publishes.
A build is a point across axes (device, kernel, suite, features, layout). The recipe
.toml records the constraints; the sibling .lock records the exact resolution —
every pinned commit, blob hash, and package manifest. build reads only the lock. That
separation is what lets one recipe serve two intents without choosing between them:
- Rolling — “give me a current working image.” Fresh clone,
updateto re-pin at today’s upstream,build. Best day-to-day UX; the resulting image’s provenance records exactly what went into it, so it is reproducible as of now. - Frozen — “reproduce exactly what shipped.” The lock is pinned and left alone; the image ships with a provenance manifest, and rebuilding it is a mechanical replay. Reproducible across time.
You opt into a strength per lock. Rolling and frozen are the same tool at two dial settings.
The three layers
An image rests on three independent inputs, each with its own durability and its own way to pin. Reproducibility is only as strong as the weakest one you froze.
1. Upstream sources (git commits, blobs)
Every compiled input — kernel, u-boot, the MPP/RGA/ffmpeg trees — is pinned to an exact
commit in the lock; rkbin blobs are pinned by sha256. A commit is only re-fetchable if
its remote still advertises it, so pins fall into durability classes: a release tag
is immutable and fetchable forever; a branch tip is ephemeral (a force-push orphans
it); a bare local commit is unfetchable by construction. boot2deb keeps shipped
recipes on durable tags, makes a non-durable pin loud at update time, and never
substitutes a different commit for an orphaned one — a different SHA is different bytes.
boot2deb verify-sources <recipe> is the check: a read-only probe that reports each pin
as durable | ephemeral | ORPHANED | skipped and exits non-zero on any orphan, so CI can
gate on it. It touches only the git remotes.
Custom kernels. A custom kernel is pinned the same way — a source commit plus a patch profile commit. Its one failure mode is rebasing or force-pushing the patch repo, which orphans the pinned commit. Keep it in the durable class by tagging the patch repo at each release; the pinned commit then lives under an immutable ref and stays fetchable across future rebases.
2. The Debian archive (rootfs)
The rootfs is the fast-moving layer: a testing suite like forky changes daily, and the
exact package versions a build installs rotate off the live mirror as it advances. Two
mechanisms pin it:
- The lock’s solved manifest fixes which bytes install — every package name, version, and sha256. This is always present.
- A captured
snapshot.debian.orgtimestamp fixes availability of those bytes after they leave the live mirror. This is opt-in and dormant by default (mode = off), so day-to-day builds go straight to the live mirror.
Snapshot has three modes: off (live mirror only), fallback (live first, the snapshot
backfills anything that 404s), and pin (the snapshot only — a fully deterministic
userland). Capture a timestamp with --save-snapshot; activate a mode with --snapshot fallback|pin. A fallback/pin with no captured timestamp is refused rather than
silently downgraded.
This is why forky’s churn is not at odds with the model: the tool to freeze against it exists; a frozen build turns it on.
3. The builder (boot2deb itself)
The same lock built by a different boot2deb can produce a different image, or fail to read
an old lock — during active development, breaking changes are expected, and the project
does not carry compatibility shims to read old locks forever. So the builder is an input
like any other, and the provenance manifest records it: a [built_with] section with the
boot2deb version, the git commit of the checkout that built the image, and whether that
checkout was dirty.
That stamp is an as-built record, not a requirement. The stamped commit is a floor: it, and later commits up to the next change that alters the output for this lock, will reproduce the image — and a later one may carry fixes you want. A commit past that change will not. And the floor is all that can ever be recorded, because the breaking change is in the future and unknowable at build time — even a bugfix can be output-affecting. So the stamp says when the build worked, never when it will break. A reproduce flow reads it to advise — “built with X; you are on Y, newer, likely fine; here is how to get X” — never to enforce.
Two audiences
Because reproducibility is a property of a lock, the story splits by who owns the lock.
The project, publishing a release. We own every axis — recipe, lock, snapshot timestamp, patch-repo tag, builder commit — so we offer a closed guarantee for a shipped image: check out boot2deb at the stamped commit, build this lock, get that image. The consumer mostly flashes; rebuilding is the frozen path. This is the release ritual below.
Someone who clones and authors their own recipe. Their subject is their build point,
not ours, and their reproducibility is forward-looking — “make my current build re-buildable
later” — rather than “rebuild what the project shipped.” They own their lock: when to
update, whether to --save-snapshot, which builder they are on. The project does not
guarantee their build; it hands them the same machinery and lets them set the strength.
The release ritual
To publish an image that stays reproducible across time, freeze all three layers and commit the result:
- Freeze the userland: capture a
snapshot.debian.orgtimestamp into the lock withboot2deb build <recipe> --save-snapshotand set its mode topin, so the rootfs is deterministic even after the suite advances. Commit the snapshot-pinned lock — it is part of the release. - Keep sources durable: tag the patch repo at its pinned commit, and confirm
boot2deb verify-sources <recipe>reports noORPHANEDpins. - Build from that clean, committed checkout, so the image’s
[built_with]records a real commit withdirty = false. - Publish the image together with its
<recipe>.provenance.toml. The manifest names the builder that produced it; the committed lock — recoverable at that commit — carries the snapshot timestamp and every source pin.
Reproducing a frozen image
- Read the published
.provenance.tomlfor the[built_with]commit that produced it. git checkout <built_with.commit>in a boot2deb clone — this recovers the recipe and the snapshot-pinned lock exactly as they were at build time.boot2deb build <recipe>— the lock’s snapshot pin makes the userland deterministic, and the pinned commits and blobs reproduce the compiled inputs.
The stamp is a floor, not a ceiling: a newer builder usually reproduces the image too and may
carry fixes, so a current clone is the normal first attempt — step back toward the stamped
commit only if it diverges. The builder stamp lives in the build’s .provenance.toml, not on
the image; the on-image /etc/boot2deb/image.toml (see Image identity)
records the image and kernel identity, which a rescue tool reads without the provenance file.
What is deliberately outside the claim
The per-image first-boot password is unique per build by design, so /etc/shadow is
intentionally not byte-reproducible. Everything else in the rootfs is, given the same three
layers frozen.
Adding a board
Bringing up a new device is mostly writing config layers — a build resolves from TOML across the config model’s axes, so a new board is a set of small TOML files plus any vendored blobs and kernel fragments. The one exception is a genuinely new chip family, which also needs a small Rust change; see What needs code.
Which track are you on? This is the bring-up track (resolve → update → verify → build) — for a board or patch that has no lock yet. If you only want to build one of the shipped recipes, take the shorter Getting started track (doctor → build) instead. To add a patch rather than a board, see Adding a patch.
Start with the generator.
boot2deb new-device <name>scaffolds the device (and a matching recipe) for you — it offers the valid SoC/boot-method/kernel/feature choices, fills every derivable value, and leaves the researched ones marked# TODO:. Run it (--soc <soc>non-interactively, or answer the prompts on a terminal; add--overlay <dir>to scaffold into your own tree), then edit the TODO values below. The rest of this page explains what those files mean and which values you must research. Seenew-device.
Where your files go: overlay or in-tree
Two ways to add a board, chosen by intent:
- Out-of-tree overlay — you are bringing up a board for yourself. Put the files in
your own directory and pass
--overlay <dir>; there is nothing to fork, and the board’s lock is written back beside it. This is the third-party path — see Overlays. - In-tree — you are contributing a board back to boot2deb. Add the files to the vendored tree and open a pull request.
The files are identical either way; only their location differs. The rest of this page describes those files.
What needs code
Most of a board is data, but three axes are closed Rust enums — Arch, Soc, and
BootMethod in crates/core/src/model.rs — chosen for type safety and exhaustiveness
checking. A board built on a chip family that already exists (any RK35xx SoC, the
rockchip-rkbin boot method) needs no code: the variant is already there. A genuinely
new family does:
- New SoC (e.g. a non-Rockchip chip) — add a variant to the
Socenum near the top ofmodel.rsand to itskebab_enum!invocation grouped just below the enum definitions, then rebuild. The compiler flags everymatchthat must now handle it. - New architecture or new boot method — the same, on
Arch/BootMethod. A new boot method also needs the engine taught how to write its payloads.
This is a deliberate boundary: closed enums give the compiler a single source of truth and catch a half-added target at compile time, at the cost of a recompile for a new family. Within an existing family it is pure config.
The layers to write
Work from the bottom of the hardware stack up, adding only what is new:
First, check whether the board joins a family that is already here. If its SoC and
boot method are both supported, a new board can be a device file and nothing else — no
overlay, no kernel, no engine change. That is not an aspiration: the ASUS
C100P and Chromebit CS10
each ship as a single TOML, and the Chromebit is a stick PC with no SD slot, no keyboard,
no EC and no analog audio. The rule that makes it work is that anything true of the whole
family belongs on the SoC layer, not on the board that happened to need it first —
socs/rk3288/ carries the family’s radio blobs, initramfs module list and network stack
for exactly that reason. When you find yourself copying a file from one board to another,
move it up instead.
-
arch (
arches/<arch>.toml) — only for a CPU architecture not already present. Arch-wide kbuild facts: the cross triple, theARCH=values for kbuild and u-boot, the kernel image path. -
soc (
socs/<soc>.toml, plussocs/<soc>/overlay/for files baked into the rootfs) — the SoC’s shared properties: device-tree directory, force-loaded modules, arch, and any SoC-wide firmware packages.- Media-accel sources are optional and ride the feature. Supply the
[userspace.mpp],[userspace.librga],[userspace.libmali],[ffmpeg.base], and[ffmpeg.rockchip]stanzas here only if a board of this SoC will enable amedia-accel-*feature (the feature compiles them into.debs); copy the block fromsocs/rk3588.toml. A headless SoC that never transcodes omits them entirely. Selecting arequires_media_accelfeature on a SoC that lacks them is a resolve-time error, so the coupling is checked, not assumed.
- Media-accel sources are optional and ride the feature. Supply the
-
boot-method (
boot-methods/<method>.toml) — how this family boots. The file’s shape depends on the method, because the methods differ in kind:rockchip-rkbincompiles a bootloader: the u-boot source + ref and the raw-gap offsets (whereidbloaderandu-boot.itbsit outside any partition, and where the rootfs partition starts).depthchargecompiles nothing — the firmware is the board’s own and what it loads is the signed kernel — so the file carries the ChromeOS kernel partition’s geometry, its GPT attribute bits, and the command line to sign into the kernel.
A field from the other method is an unknown field and fails to parse, which is the point: an image cannot half-belong to two boot chains.
boot-methods/<method>/overlay/ships any boot-time files (e.g. the extlinux generator), andoverlay-pre/ships config a package’s own maintainer scripts must see while they run (see Two overlay stages). -
device (
devices/<device>.toml) — the board itself, stating only its deltas: itssoc,boot_method,supported_boot_methods,kernel_dtb,image_size,hostname,supported_kernels/default_kernel,default_suite,default_layout, plus whatever its boot method requires:- under
rockchip-rkbin: auboot_defconfig, and — only if the board departs from the SoC’s defaults — an[rkbin]block. The bootloader blobs are inherited from the soc layer and merged per field, so a board on the SoC’s usual memory omits the block entirely; a board with different DRAM overrides justtpl. - under
depthcharge: a[depthcharge]block naming the board profile and the profiles the unit supports. Nouboot_defconfig, no blobs — this board compiles no bootloader, and resolution does not ask it to. device_config_fragmentsgotcha: naming a fragment here makes its file mandatory.device_config_fragments = ["device/my-board"]requiresfragments/device/my-board.configto exist — a missing file failsresolve. A board with no board-specific kconfig deltas usesdevice_config_fragments = []to add none. Do not name a fragment you have not written.
- under
-
kernel (
kernels/<kernel>.toml) — the orthogonal kernel axis. Ask first whether the board needs a kernel of yours at all.- If Debian’s own kernel already runs the hardware — which it does for any SoC and
board that are fully upstream — write a
flavor = "distro-package"definition naming the package (linux-image-armmp) and you are done. No source ref, no defconfig, no fragments, no patches, and one definition serves every suite. This is the better answer where it applies:aptkeeps the board’s kernel patched, which a kernel you compiled does not. - Otherwise write a
mainlineorvendordefinition with its source refs,.configfragments, and patch profile. Version-coupled, so a new kernel version is a new file. A compiled kernel that applies no series writespatch_profile = "none"and then never reads thepatchesrepo.
Note that a distro kernel and the two compile-only device fields (
device_dts,device_config_fragments) are mutually exclusive, and resolution says so: nothing would ever build the DTB or merge the fragments. - If Debian’s own kernel already runs the hardware — which it does for any SoC and
board that are fully upstream — write a
Two overlay stages
Each layer may ship two trees of files that are copied into the rootfs:
overlay/— laid in after every package. It therefore wins over whatever the packages shipped, which is what nearly all config wants.overlay-pre/— laid in before any package is installed. This is for config a package’s own maintainer scripts must see while they run, where winning afterwards is too late because the package already acted. Two examples, both from the Veyron Chromebooks:- initramfs settings (
MODULES=list) must precede the kernel package, or the first initramfs is built atMODULES=most— far over the signed payload’s size budget — and then thrown away and rebuilt. depthcharge-tools’ kernel hook re-signs and re-flashes a ChromeOS kernel partition. Installed with no config present, it runs at its defaults and goes looking for that partition on the build host’s disks. Its config has to be there first.
- initramfs settings (
Use overlay/ unless the package acts before your file would arrive.
Supporting assets:
- blobs (
blobs/<soc>/) — vendored bootloader binaries the device/boot-method references. - fragments (
fragments/<name>.config) — kernel.configfragments merged onto the base defconfig, referenced by name from a kernel or device. - patch profile — lives in the separate
patchesrepo, referenced by the kernel; see Adding a patch. Omitted entirely by apatch_profile = "none"kernel. - board device tree — only when the board’s
.dtsis not yet upstream; seedevice_dts.
Finally, a recipe (recipes/<recipe>.toml) pins one point across the axes — device,
kernel, suite, features, layout.
Values you must research
Most fields fail loudly at resolve: bad image geometry, a missing fragment, or an
unvendored keyring are all caught up front. Two fields are not validated until the
stage that consumes them compiles, so a typo produces a late, confusing failure:
| Value | Layer | Fails at |
|---|---|---|
kernel_dtb | device | the kernel build — the DTB is not produced (unless the board carries device_dts, below, which makes this a resolve-time check) |
uboot_defconfig | device | the u-boot build — unknown defconfig |
Take both from the board’s upstream support: kernel_dtb is the device tree the mainline
kernel builds for the board (under arch/<arch>/boot/dts/<dt_dir>/), and
uboot_defconfig is the board’s u-boot defconfig. Confirm each exists in the exact
kernel/u-boot versions you pin before you trust a green resolve.
When the board’s device tree is not upstream
A freshly-supported SoC often has every driver in mainline but none of its boards. Fork
the nearest in-tree board .dts, put it in your overlay, and list it in device_dts:
kernel_dtb = "rockchip/rk3576-my-box.dtb"
device_dts = ["devices/my-box/dts/rk3576-my-box.dts"]
The kernel stage copies it into the tree and registers the DTB with kbuild, so it ships
in the linux-image deb like any in-tree board. kernel_dtb is then validated at
resolve — it must be the DTB one of those sources builds — so the table above no longer
applies to it. Iterate with build <recipe> --stage dtb, which rebuilds only the DTB.
Keep device_dts for the new board file. An edit to an existing upstream .dts is a
patch in the kernel’s patch profile; a source that would overwrite an in-tree file is
refused rather than silently shadowing it.
Bring it up
With the layers written, use the CLI’s checks as guardrails, in order:
# 1. Does it resolve to a coherent build point? Also runs the geometry / fragment /
# keyring preflight, so this is a real coherence gate, not just a merge print.
cargo run -p boot2deb-cli -- resolve <recipe>
# 2. Resolve upstream refs + hash blobs into the lock.
cargo run -p boot2deb-cli -- update <recipe> --kernel-ref <ref>
# 3. Is the host equipped to build it?
cargo run -p boot2deb-cli -- doctor <recipe>
# 4. Does the patch series apply cleanly to the pinned kernel? Auto-fetches the locked
# kernel — no hand-cloned tree — or add --kernel-src ../linux if you have a checkout.
cargo run -p boot2deb-cli -- verify-patches <recipe>
# 5. Does the .config generate (and, with --reference-config, match a reference)?
cargo run -p boot2deb-cli -- verify-config <recipe>
# 6. Build.
cargo run -p boot2deb-cli -- build <recipe>
resolve, update, and the two verify-* commands fail with a typed error before any
compile starts, so most config mistakes surface in seconds rather than partway through a
build. The verify-* commands auto-fetch the pinned source trees, so this whole sequence
works on a fresh clone with no hand-cloned kernel — see
Verification.
A worked example: a second RK3588 board
A board on an existing SoC needs only a device file and a recipe — arch, soc, and
boot-method all reuse the shipped layers. Suppose my-board is another RK3588 module.
devices/my-board.toml:
description = "My RK3588 board"
soc = "rk3588" # reuse the shipped SoC layer
boot_method = "rockchip-rkbin"
supported_boot_methods = ["rockchip-rkbin"]
uboot_defconfig = "my-board-rk3588_defconfig" # research: must exist in u-boot
kernel_dtb = "rockchip/rk3588-my-board.dtb" # research: must exist in the kernel
device_config_fragments = [] # no board-specific kconfig deltas
supported_kernels = ["rk3588-mainline-7.1"]
default_kernel = "rk3588-mainline-7.1"
default_suite = "forky"
default_layout = "combined"
hostname = "my-board"
image_size = "2G"
[rkbin] # board-memory-specific DDR init
atf = "rk3588_bl31_v1.51.elf"
tpl = "rk3588_ddr_lp4_2112MHz_lp5_2400MHz_v1.19.bin"
recipes/my-board-forky.toml:
device = "my-board"
kernel = "rk3588-mainline-7.1"
suite = "forky"
features = ["media-accel-rockchip"] # or [] for a plain image
layout = "combined"
Then run the bring-it-up sequence against my-board-forky. A new SoC
would additionally need socs/<soc>.toml (with the required userspace/ffmpeg stanzas) and
its fragments; a new family would need the code change from
What needs code.
Document your board
Give each board a page under Boards, the way the Turing RK1 page does, since flashing is inherently per-board — a Turing Pi module flashes through the BMC, a standalone SBC takes an SD card or a maskrom loader, a laptop boots UEFI. A useful skeleton:
# <Board name>
The `<recipe>` recipe builds a bootable Debian <suite> image for the <board> (<SoC>).
It pins kernel `<ver>`, u-boot `<ver>`, and <features>.
Build it as in [Getting started](../getting-started.md):
cargo run -p boot2deb-cli -- build <recipe>
## Flash
<how this board takes an image: card reader, BMC, maskrom, UEFI…>
## Serial console
<UART pins / adapter / baud>
## First boot
<credentials, resize-on-first-boot, hostname>
Adding a patch
boot2deb applies an ordered patch series to each source tree (kernel, ffmpeg,
userspace, u-boot) before it compiles. The series is declared by a
patch profile that
lives in the separate patches repo. Adding a patch means getting it into that series
and then into a build. This page walks the loop end to end.
It applies to a kernel that names a profile. A kernel with patch_profile = "none"
applies no series and never reads the patches repo; giving such a board a patch means
first authoring a profile for its kernel.
The loop
patch import -> commit in ../patches -> boot2deb update -> boot2deb build
(verify-patches at any point along the way)
The linchpin is the middle two steps: update re-pins the patches repo’s current
commit into the lock, and build reads the series at exactly that pinned commit. So a
patch sitting on disk does nothing until it is committed in the patches repo and the
lock is re-pinned to include that commit. patch import prints these follow-ups for
you; the rest of this page is the same steps, with their failure modes.
The running example imports a kernel patch into the rk3588-accel profile and builds the
turing-rk1-forky recipe.
1. Import the patch
patch import fetches a patch (a patchwork/mbox URL, a local file, or - for stdin),
normalizes it to canonical git am-ready mbox, writes it into the profile’s tree, and
slots it into the profile manifest at the right position:
cargo run -p boot2deb-cli -- patch import \
https://patchwork.kernel.org/project/linux-rockchip/patch/NNNN/mbox/ \
--profile rk3588-accel --scope kernel
--scopeselects which tree’s series to insert into:kernel,ffmpeg,userspace, oruboot.- The filename prefix is chosen to sort the patch at its list position; pass
--positionto insert at a specific index (default: append). If the neighbours leave no numeric gap (e.g.070/071), the import falls back to a lettered sub-prefix (070a) automatically. - Add
--verify-tree <kernel-checkout>to dry-rungit amthe resulting series during the import (it rolls back the write on failure). Without it, the patch is written unverified — see verify.
On success it prints exactly what to do next:
patch import: wrote media-accel/kernel/045-fix-foo.patch (3812 bytes)
!! patch written but NOT verified — it has not been dry-run against a kernel tree.
verify it now: boot2deb verify-patches turing-rk1-forky
(auto-fetches the locked kernel at its pin — no checkout needed)
next time: add --verify-tree <kernel-checkout> to verify during import.
patch import: rk3588-accel/kernel now lists the patch at position 5 of 11
next steps — no build reads the patch until the series is committed and re-pinned:
1. commit it: git -C ../patches add -A && git -C ../patches commit
2. re-pin locks: boot2deb update turing-rk1-forky
The re-pin line names each recipe whose kernel uses the profile you imported into.
2. Commit in the patches repo
The new patch file and the profile edit both live in the patches repo. Commit them
there:
git -C ../patches add -A
git -C ../patches commit -m "kernel: fix foo"
This matters because update pins the patches repo’s HEAD commit. An uncommitted
patch is invisible to that pin — which is exactly why update refuses to run against a
dirty patches checkout (see failure modes).
3. Verify
Confirm the series still applies cleanly to the pinned kernel:
cargo run -p boot2deb-cli -- verify-patches turing-rk1-forky
With no --kernel-path, verify-patches auto-fetches the locked kernel at its pin —
no hand-cloned tree needed. The first run on a cold cache clones linux-stable (large); if
you already have a checkout, point --kernel-src at it to skip the clone:
cargo run -p boot2deb-cli -- verify-patches turing-rk1-forky --kernel-src ../linux
You can verify before or after committing — the series on disk is what is checked.
(Passing --verify-tree to patch import runs the same check inline.) See
Verification for verify-config and the full flag
set.
4. Re-pin the lock, then build
update re-pins the patches commit (and re-resolves the other refs) into the lock:
cargo run -p boot2deb-cli -- update turing-rk1-forky
You do not need --kernel-ref for a patch-only re-pin: with a lock already present,
update inherits the previous kernel ref and re-pins only what changed. Commit the
updated recipes/<recipe>.lock, then build:
cargo run -p boot2deb-cli -- build turing-rk1-forky
The build reads the series at the pinned commit and applies it with git am --3way.
Failure modes
Dirty patches checkout. update refuses a patches repo with uncommitted changes
(PatchesDirty): a dirty pin would be wrong in every case, so commit first. This guard
turns “I imported but forgot to commit” into an instant, offline error instead of a
confusing build-time one.
Stale / mismatched pin. If the checkout build reads is at a different commit than
the lock pins, you get PatchesPinMismatch. Its remedy text distinguishes the cases: if
your local HEAD is ahead of the pin (you committed but did not re-pin), run update;
if the checkout is behind the pin (stale), git checkout the pinned commit. Re-pinning
after a commit is the usual fix.
Auto-fetch can’t find the commit. A zero-clone build (no local ../patches) fetches
the series at the pinned commit from the profile’s patches_url. That only works if the
commit has been pushed — an unpushed local commit resolves fine against a local
checkout but not on another machine. Push the patches repo before relying on the
auto-fetch.
Co-developing the series
While iterating on a patch you may not want to commit-and-re-pin on every change. Point
build (and verify-patches) at your working checkout instead:
cargo run -p boot2deb-cli -- build turing-rk1-forky --patches-path ../patches
An explicit --patches-path downgrades a pin mismatch from an error to a loud
warning, so you can build from an uncommitted, un-re-pinned series. The trade-off is
reproducibility: that build is no longer pinned by the lock, so it is a development
convenience, not a committed result. When the patch is settled, commit it, update, and
drop the flag.