Designing an Embedded USB Flasher: Initramfs, Partition Layouts, and Zero-Touch Provisioning
Every embedded Linux device eventually needs a way to get its operating system onto internal storage. The answer is often a USB flasher: a self-contained image that boots from a USB drive, writes the OS to internal eMMC or NVMe storage, and shuts down. No network, no interactive installer, no monitor required.
This article walks through what I built and what I learned along the way — from the initramfs design through zero-touch provisioning. I have since used the same architecture on eMMC, NVMe, and SATA devices; as long as Linux can enumerate the block device, the approach holds. If you are trying to build a similar USB installer for an embedded Linux device, I hope these notes save you some time 1.
Why initramfs, not systemd
A USB flasher has exactly one job: detect the target storage device, write the production image, and shut down. It does not need networking, logging, D-Bus, or a multi-user target.
The cleanest approach is an initramfs with a custom /init script. The
kernel boots, extracts the initramfs into a tmpfs, and executes /init as
PID 1. The script runs to completion, then calls poweroff. No systemd,
no service manager, no unnecessary processes.
The initramfs approach has three advantages over a full systemd-based installer 2:
-
Faster boot. The initramfs is a few megabytes; a full rootfs is hundreds. Booting to the installer prompt takes seconds instead of tens of seconds.
-
Fewer dependencies. The initramfs only needs busybox,
parted,bmaptool, and a few utilities. There is no risk of a systemd service failing and blocking the installer. -
No competing processes. In a systemd-based installer,
systemd-udevdmight touch the target block device,systemd-logindmight spawn a getty on the serial console, andsystemd-tmpfilesmight mount filesystems. All of these interfere with the installer's exclusive control of the target device.
The tradeoff: an initramfs is a constrained environment. You must include every tool the installer needs in the initramfs image, and error handling must work without journald or syslog.
The WIC partition layout
Yocto's WIC (WIC Image Creator) tool defines disk layouts in a declarative format. A flasher USB image needs two partitions 3:
# Partition 1: EFI boot (50MB) — kernel, initramfs, systemd-boot
part --source bootimg-efi --sourceparams="loader=systemd-boot,initrd=core-image-minimal-initramfs-genericx86-64.cpio.gz" \
--ondisk sda --label FLASH_BOOT --size 50 --align 1024 --active --part-type EF00
# Partition 2: Storage (remaining space, ext4) — production WIC image + bmap
part --source bootimg-partition --ondisk sda --label FLASH_IMG \
--fstype=ext4 --extra-space 2000 --align 1024
bootloader --ptable gpt --timeout=0 \
--append="rdinit=/init console=ttyS0,115200"Key design decisions:
-
Two partitions only. Partition 1 carries the boot artifacts (kernel, initramfs, systemd-boot loader). Partition 2 carries the payload (the production WIC image and its bmap file). No swap, no recovery partition. The flasher does not need them.
-
rdinit=/initon the kernel command line tells the kernel to execute the custom/initscript from the initramfs, bypassing the normal init process 4. -
timeout=0makes systemd-boot boot immediately. There is no boot menu because there is no choice — the flasher always boots into the installer. -
extra-space 2000on partition 2 reserves 2000 MB (~2 GB) of padding so the partition is large enough to hold the production WIC image (which might be 6-8 GB for a full image).
The production image (target) uses a simpler layout — boot + rootfs, no storage partition:
part --source bootimg-efi --sourceparams="loader=systemd-boot" \
--ondisk sda --label boot --size 256 --align 1024 --active
part --ondisk sda --label rootfs --size 4096 --align 1024 --fstype=ext4
part --ondisk sda --label data --size 512 --align 1024 --fstype=ext4
bootloader --ptable gpt --timeout=3Storage detection
The installer's first task is to identify which block device is the USB boot drive and which is the internal storage target. Getting this wrong destroys the boot media or writes to the wrong device.
The detection algorithm uses sysfs attributes 5:
#!/bin/sh
# init-install.sh — initramfs installer script
# Find boot device: removable media or virtio disk 1 (QEMU)
BOOT_DEVICE=""
for dev in /sys/block/*; do
devname=$(basename "$dev")
case $devname in
loop*|ram*|sr*) continue ;;
esac
# Removable = USB on real hardware
if [ -f "$dev/removable" ] && [ "$(cat "$dev/removable")" = "1" ]; then
BOOT_DEVICE="$devname"
break
fi
# For QEMU: vda is the first virtio disk (boot drive)
if [ "$devname" = "vda" ]; then
BOOT_DEVICE="$devname"
break
fi
done
# Find target device: exclude boot device, prefer eMMC or second virtio disk
TARGET_DEVICE=""
for dev in /sys/block/*; do
devname=$(basename "$dev")
case $devname in
loop*|ram*|sr*|"$BOOT_DEVICE"*) continue ;;
esac
# eMMC on real hardware
if [ "$devname" = "mmcblk0" ]; then
TARGET_DEVICE="$devname"
break
fi
# Second virtio disk in QEMU
if [ "$devname" = "vdb" ]; then
TARGET_DEVICE="$devname"
break
fi
# Fallback: first available non-boot block device
if [ -z "$TARGET_DEVICE" ] && [ -b "/dev/$devname" ]; then
TARGET_DEVICE="$devname"
fi
done
if [ -z "$TARGET_DEVICE" ]; then
echo "ERROR: No target device found"
/bin/sh # Drop to shell for debugging
exit 1
fiThe sysfs removable attribute is the most reliable USB indicator on real
hardware. In QEMU, virtio disks do not set it, so the script uses naming
conventions (vda = boot, vdb = target) as a fallback.
The most important thing here is excluding the boot device. If the script selects the USB drive as the target, it overwrites the installer mid-operation and bricks itself.
Writing the image: bmaptool vs dd
Once the target device is identified, the installer writes the production
image. The naive approach is dd:
dd if=/mnt/boot/production.wic of=/dev/mmcblk0 bs=4M status=progressThis works but is slow and writes every block, including empty ones. A 6 GB WIC image with 4 GB of actual data takes 6 GB of write time.
bmaptool is the better choice 6. It reads a block map (.bmap) file that
describes which blocks in the image are non-empty, and only writes those.
The result is 30-50% faster writes and less wear on the target eMMC:
bmaptool copy /mnt/boot/production.wic /dev/mmcblk0The bmap file is generated at build time alongside the WIC image. Yocto
produces both automatically when wic wic.bmap is in IMAGE_FSTYPES.
The installer should fall back to dd if bmaptool is not available. On
minimal initramfs images, bmaptool must be explicitly included in the
package list:
PACKAGE_INSTALL += "bmap-tools"Post-flash partition expansion
The production WIC image typically has a fixed root partition size (e.g., 4 GB) to keep the image small for distribution. The target device (e.g., 128 GB eMMC) has far more space. After writing the image, the installer should expand the root partition to use the remaining capacity.
The expansion uses parted and resize2fs in sequence 7:
expand_partition() {
local target="/dev/$TARGET_DEVICE"
# Verify the target has at least the minimum expected size
# Note: partition suffix depends on device type.
# eMMC: /dev/mmcblk0p2 (uses 'p' prefix)
# NVMe: /dev/nvme0n1p2 (uses 'p' prefix)
# SATA: /dev/sda2 (no 'p' prefix)
# Adapt ${target}pN or ${target}N to match your target hardware.
local disk_size=$(parted -s "$target" unit MB print | grep "Disk /dev" | awk '{print $3}')
local min_size=4096 # 4 GB minimum
if [ "${disk_size%.*}" -lt "$min_size" ]; then
echo "ERROR: Target disk too small ($disk_size MB < $min_size MB)"
return 1
fi
# Expand root partition (partition 2) to fill remaining space
# Leave room for data partition if present.
# parted print output uses the same naming as the device:
# eMMC/NVMe: 3 ... p3 ...
# SATA/virtio: 3 ... 3 ...
# Matching on "^ *3 " (whitespace + number + whitespace) works
# regardless of device naming.
if parted -s "$target" unit MB print | grep -qE "^ *3 "; then
# Data partition exists, leave it at the end
local data_start=$(parted -s "$target" unit MB print | grep -E "^ *3 " | awk '{print $2}')
parted -s "$target" resizepart 2 "${data_start}MB"
else
# No data partition, use all available space
parted -s "$target" resizepart 2 "100%"
fi
# Build partition suffix based on device type.
# eMMC and NVMe insert a 'p' before the partition number;
# SATA and virtio devices do not.
case "$TARGET_DEVICE" in
mmcblk*|nvme*) PART_SEP="p" ;;
*) PART_SEP="" ;;
esac
# Check and expand the ext4 filesystem
e2fsck -fy "${target}${PART_SEP}2"
resize2fs "${target}${PART_SEP}2"
}The e2fsck before resize2fs is important — I learned that resizing
a filesystem with errors can corrupt data. The -f flag forces a check
even if the filesystem appears clean; the -y flag auto-answers "yes" to
all prompts so the installer does not hang.
A note on partition naming: eMMC and NVMe both use a p prefix before
the partition number (mmcblk0p2, nvme0n1p2), but SATA and virtio
devices do not (sda2, vda2). If your target device type might vary,
build the partition path dynamically from the device name rather than
hardcoding the p prefix.
Safety features
A flasher that bricks a device is not a flasher; it is a weapon. Production flashers need at least these safety checks:
1. Pre-flash validation
Before writing anything, verify:
- The target device exists and is a block device
- The target device has enough capacity for the image
- The target device is not mounted
- The production image file exists and is readable
- The image checksum matches (if a
.sha256file is present)
2. Idempotency
If the installer runs again on a device that is already flashed, it should either skip the flash or require a force flag. A sentinel file on the target filesystem prevents accidental re-flashing:
if [ -f /mnt/target/etc/installer-completed ]; then
echo "Device already flashed. Use FORCE=1 to re-flash."
if [ "${FORCE:-0}" != "1" ]; then
poweroff
fi
fi3. Error handling
Every operation that can fail must check its exit status and handle failure gracefully:
write_image() {
if ! bmaptool copy /mnt/boot/production.wic "/dev/$TARGET_DEVICE" 2>&1; then
echo "ERROR: Image write failed"
echo "Falling back to dd..."
if ! dd if=/mnt/boot/production.wic of="/dev/$TARGET_DEVICE" bs=4M status=progress; then
echo "FATAL: Both bmaptool and dd failed"
echo "Device may be in an inconsistent state"
/bin/sh # Drop to shell
return 1
fi
fi
}4. Protective sync
After writing, call sync and wait. Pulling the USB drive before the kernel
has flushed its write cache produces a corrupted target filesystem:
sync
# Allow kernel write cache to flush before poweroff.
# Pulling the USB drive too early produces a corrupted target filesystem.
sleep 2
echo "Flash complete. Powering off in 5 seconds..."
sleep 5
poweroff -fThe -f flag on poweroff forces immediate shutdown without running
userspace shutdown scripts, which is appropriate for a completed initramfs
installer.
The Yocto recipe structure
The flasher image is a Yocto image recipe that 8:
- Depends on the production image (
core-image-full-cmdline) being built first - Embeds the production WIC and bmap files into partition 2
- Uses a custom initramfs with the installer script as
/init - Generates a
.wicand.wic.bmapfile for the USB drive
The key recipe components:
# core-image-flasher.bb
DESCRIPTION = "USB flasher image with embedded production image"
# Use custom initramfs with installer script
INITRD_IMAGE_LIVE = "core-image-minimal-initramfs"
# Use the flasher disk layout
WKS_FILE = "flasher-genericx86.wks"
# Embed production image into partition 2.
# PRODUCTION_IMAGE_LINK is a symlink created by the production image recipe
# in DEPLOY_DIR_IMAGE (e.g., core-image-full-cmdline-genericx86-64 -> ...).
# Define it in your image recipe or local.conf.
IMAGE_BOOT_FILES:append = " \
${DEPLOY_DIR_IMAGE}/${PRODUCTION_IMAGE_LINK}.rootfs.wic;production.wic \
${DEPLOY_DIR_IMAGE}/${PRODUCTION_IMAGE_LINK}.rootfs.wic.bmap;production.wic.bmap \
"
# Must build production image first
do_image_wic[depends] += "core-image-full-cmdline:do_image_complete"
# Output formats
IMAGE_FSTYPES = "wic wic.bmap"The initramfs recipe installs the installer script as /init 9:
# core-image-minimal-initramfs.bbappend
PACKAGE_INSTALL += "bmap-tools parted e2fsprogs e2fsprogs-resize2fs \
e2fsprogs-e2fsck dosfstools util-linux-blkid"
do_install:append() {
install -m 0755 ${WORKDIR}/init-install.sh ${IMAGE_ROOTFS}/init
}No systemd service definition is needed. The kernel executes /init directly.
Zero-touch provisioning
The flasher described so far writes an image and expands the partition. In production, devices also need configuration: hostname, timezone, SSH keys, user accounts, service enablement, VPN or tunnel setup.
Zero-touch provisioning adds a post-flash configuration step. A
provisioning.yaml file placed on the USB boot partition is read by a
Python script (using the standard PyYAML library 10) that runs after the
image is written but before shutdown:
# provisioning.yaml
hostname: device-east-03
timezone: America/New_York
users:
- name: admin
groups: [wheel, docker]
ssh_authorized_keys:
- "ssh-ed25519 AAAAC3NzaC1..."
services:
enable: [tailscaled, docker]
disable: [sshd]
tunnels:
ngrok:
# In production, this should come from a secrets store
# (Vault, TPM-sealed blob, or env-specific provisioning),
# not be committed in plaintext alongside the image.
auth_token: "ngrok-auth-token-here"
tunnels:
ssh:
proto: tcp
addr: 22The provisioning script mounts the target root filesystem after flashing, applies the configuration, and unmounts:
# After writing the image, mount target rootfs
mkdir -p /mnt/target
mount "/dev/${TARGET_DEVICE}p2" /mnt/target
# Run provisioning if config exists
if [ -f /mnt/boot/provisioning.yaml ]; then
echo "Provisioning device..."
python3 /usr/bin/provision-device.py \
--config /mnt/boot/provisioning.yaml \
--target-root /mnt/target
fi
umount /mnt/targetThe provisioning step is optional — if no provisioning.yaml is present,
the flasher writes the image and shuts down. The same USB drive can be
configured with or without provisioning.
Testing with QEMU
The flasher must be tested before it ever touches real hardware. QEMU with UEFI firmware (OVMF) provides a virtual test environment 11:
# Create a virtual target disk
qemu-img create -f raw /tmp/target-disk.raw 16G
# Copy OVMF variables (QEMU needs writable vars)
cp /usr/share/OVMF/OVMF_VARS.fd /tmp/ovmf_vars_test.fd
# Boot the flasher with a target disk
qemu-system-x86_64 \
-drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE.fd \
-drive if=pflash,format=raw,file=/tmp/ovmf_vars_test.fd \
-drive file=core-image-flasher-genericx86-64.wic,format=raw,if=virtio \
-drive file=/tmp/target-disk.raw,format=raw,if=virtio \
-m 2048 -smp 2 -nographic -no-rebootIn QEMU, the flasher detects vda as the boot device and vdb as the
target. It writes the image, expands the partition, and powers off. After
the test, boot from the target disk to verify:
qemu-system-x86_64 \
-drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE.fd \
-drive if=pflash,format=raw,file=/tmp/ovmf_vars_test.fd \
-drive file=/tmp/target-disk.raw,format=raw,if=virtio \
-m 2048 -nographicYou should see the production image boot to a login prompt.
The QEMU test can be scripted — boot the flasher, poll the serial output for the "poweroff" message, then boot the target and poll for "login:" 12. This catches bootloader misconfiguration, kernel panics, and installer failures without a physical device.
Summary
The USB flasher I ended up with is small and does exactly one thing. The initramfs approach kept it simple and fast compared to a full systemd installer. The two-partition WIC layout cleanly separates boot artifacts from the payload. Storage detection via sysfs handled my real hardware and QEMU tests identically. bmaptool cut write times by 30-50% compared to plain dd. Post-flash expansion let me ship a small image while making full use of the target storage. Zero-touch provisioning meant I could configure a device without ever touching it. QEMU testing caught failures before I risked real hardware. The same approach has since held across eMMC, NVMe, and SATA targets.
References
[1] Yocto Project, "Creating Partitioned Images Using WIC," https://docs.yoctoproject.org/dev/dev-manual/wic.html, accessed June 2026.
[2] Linux Kernel Documentation, "Ramfs, rootfs, and initramfs," https://docs.kernel.org/filesystems/ramfs-rootfs-initramfs.html, accessed June 2026.
[3] Yocto Project, "WIC Kickstart Reference," https://docs.yoctoproject.org/ref-manual/kickstart.html, accessed June 2026.
[4] Linux Kernel Documentation, "The kernel's command-line parameters," https://docs.kernel.org/admin-guide/kernel-parameters.html, accessed June 2026.
[5] Linux Kernel Documentation, "sysfs block," https://docs.kernel.org/block/queue-sysfs.html, accessed June 2026.
[6] Intel, "bmap-tools — Efficient Block Map Copy," https://github.com/intel/bmap-tools, accessed June 2026.
[7] GNU Parted, "Parted User Manual," https://www.gnu.org/software/parted/manual/, accessed June 2026.
[8] Yocto Project, "Writing a New Image Recipe," https://docs.yoctoproject.org/dev/dev-manual/new-recipe.html, accessed June 2026.
[9] Yocto Project, "Customizing Images Using Custom Package Feeds," https://docs.yoctoproject.org/dev/dev-manual/customizing-images.html, accessed June 2026.
[10] PyYAML, "PyYAML Documentation," https://pyyaml.org/, accessed June 2026.
[11] QEMU, "QEMU System Emulation User's Guide," https://www.qemu.org/docs/master/system/index.html, accessed June 2026.
[12] Yocto Project, "Performing Automated Runtime Testing," https://docs.yoctoproject.org/test-manual/intro.html, accessed June 2026.