Every embedded Linux board eventually needs its root filesystem sitting somewhere the bootloader can hand off to — an SD card partition, an eMMC partition, or a raw NAND/NOR region. This lecture is part of our free embedded Linux course and walks through turning a staging directory into a real root filesystem image, then getting that image onto a target board and booting it. If you’ve been following this free linux kernel development course, you already have a populated staging directory from earlier lectures — now we make it bootable.
What You Will Learn
Lecture Outcomes
Prerequisites
You should already have a staging directory built from the earlier lectures in this root filesystem image series — BusyBox or ToyBox installed, shared libraries copied in, device nodes planned, and an init system chosen. You’ll also need a Linux host with the standard build-essential toolchain, and a target board (real hardware or QEMU) whose bootloader can load a kernel and mount a block-device root.
Why You Can’t Just Copy Files In as Root
A staging directory built by an ordinary user has one structural problem: device nodes need root privileges to create with mknod, and many files in a real root filesystem — /dev/console, /dev/null, setuid binaries — need ownership and permission bits that an unprivileged build process cannot set directly. Rather than running your entire build as root, the standard approach is a device table: a plain text file that describes the extra nodes, ownership, and permission overrides to apply while the image is generated, without ever needing root on the build host.
Each line in a device table describes one path and the metadata to stamp onto it:
path type mode uid gid major minor start inc count
type—f(regular file),d(directory),c(character device),b(block device), orp(pipe)mode— octal permission bitsuid/gid— numeric owner and group to stamp on the nodemajor/minor— device numbers, only meaningful forc/bentriesstart,inc,count— only meaningful for device nodes; they let one line generate a numbered group of nodes, starting at minor numberstartand incrementing byinc,counttimes
Here’s an original device table for a small target with a UART console and a single I2C bus exposed to user space:
#<path> <type> <mode> <uid> <gid> <major> <minor> <start> <inc> <count>
/dev d 755 0 0 - - - - -
/dev/console c 600 0 0 5 1 - - -
/dev/null c 666 0 0 1 3 - - -
/dev/ttyS0 c 600 0 0 4 64 - - -
/dev/i2c-0 c 660 0 20 89 0 - - -
Fields left as - aren’t used for that entry. The last row shows the group id set to 20 so the i2c group (not root) can access the bus — a pattern worth reusing anywhere you want a device node accessible to a non-root daemon.
Generating the Image with genext2fs
genext2fs reads your staging directory plus the device table and writes out a complete ext2 image in one pass, applying every override from the table without touching the real filesystem permissions on your build host. You do not list every file individually the way you would with gen_init_cpio for an initramfs — you point it at the staging directory and it walks the tree itself.
Size the image with headroom: a bare BusyBox-based rootfs is usually a few megabytes, but leave room for logs, temporary files, and future package installs. This example builds an 8 MiB image (8,192 blocks of the default 1,024-byte block size):
$ genext2fs -b 8192 -d staging -D device-table.txt -U rootfs.ext2
$ ls -lh rootfs.ext2
-rw-r--r-- 1 dev dev 8.0M Aug 14 09:12 rootfs.ext2
| Flag | Meaning |
|---|---|
-b | Total blocks in the image (block size × blocks = final image size) |
-d | Source directory to populate the image from |
-D | Device table file — ownership, permissions, and device nodes to stamp in |
-U | Assign sequential inode UIDs/GIDs matching the host, useful for reproducible builds |
The result, rootfs.ext2, is a self-contained image file you can mount, inspect with debugfs, or write straight to a block device.
Writing the Image to an SD Card
Identify which partition holds your root filesystem before you write anything — an accidental dd to the wrong device node will happily overwrite your host’s disk. Check with lsblk first, then write to the correct partition, not the whole-disk device:
$ lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
mmcblk0 179:0 0 14.9G 0 disk
├─mmcblk0p1 179:1 0 100M 0 part /media/dev/boot
└─mmcblk0p2 179:2 0 14.8G 0 part
$ sudo dd if=rootfs.ext2 of=/dev/mmcblk0p2 bs=4M status=progress
$ sync
bs=4M speeds up the write considerably over the default 512-byte block size, and status=progress gives you visible feedback on a large card. The trailing sync matters — without it, part of the write can still be sitting in the host’s page cache when you pull the card.
Telling the Kernel Where to Find It
With the image on the card, the kernel needs a matching root= argument on its command line so it knows which block device to mount as /. Set this through your bootloader’s environment before loading the kernel:
=> setenv bootargs console=ttyS0,115200 root=/dev/mmcblk0p2 rootwait
=> fatload mmc 0:1 0x82000000 zImage
=> fatload mmc 0:1 0x83000000 board.dtb
=> bootz 0x82000000 - 0x83000000
rootwait is easy to forget and causes intermittent boot failures on real hardware: it tells the kernel to wait for the SD/eMMC controller to finish enumerating the card before it tries to mount root, which matters because that enumeration can finish after the rest of early boot on some controllers.
Choosing an Image Format for the Job
| Format | Best for | Trade-off |
|---|---|---|
| ext2 (genext2fs) | SD card / eMMC production images | Writable, journalless — fine for read-mostly rootfs |
| squashfs | Read-only, compressed production images | Smaller flash footprint, needs an overlay for writable paths |
| cpio initramfs | Early boot, recovery images, diskless boards | Lives fully in RAM — no persistent storage needed |
| UBIFS | Raw NAND flash | Handles wear-leveling and bad blocks that ext2 cannot |
Common Mistakes and Troubleshooting
- Writing to the whole-disk node instead of the partition —
/dev/mmcblk0instead of/dev/mmcblk0p2destroys the partition table. Always confirm withlsblkfirst. - Image too small —
genext2fsfails outright, or worse, silently truncates content, if-bis undersized. Size generously and check the exit status. - Forgetting
rootwait— boot works from a fast desktop card reader in testing, then fails intermittently on the real board once the controller timing differs. - Device table typos — a wrong major/minor number produces a device node that exists but refers to the wrong driver;
ls -l /devafter mounting the image will show the numbers so you can cross-check againstDocumentation/admin-guide/devices.txt.
Best Practices
- Keep the device table under version control alongside your staging build scripts — it’s as much a build artifact as the kernel config.
- Generate a checksum (
sha256sum rootfs.ext2) alongside every image you ship, so field updates can be verified before flashing. - Prefer read-only root plus a small writable overlay in production; a read-only ext2/squashfs image is far more resilient to power loss during writes than a fully writable one.
Security Considerations
An unsigned image written straight to a card can be swapped for a malicious one by anyone with physical access. For production devices, pair this workflow with your bootloader’s image-verification support (signed FIT images or equivalent) rather than trusting an unauthenticated block device blindly.
Summary
Device tables let you stamp root-owned metadata onto a staging directory without ever running the build as root. genext2fs turns that staging directory plus the table into a real ext2 root filesystem image, sized generously and written to the correct partition with dd. Matching root= and rootwait bootargs complete the handoff so the kernel mounts exactly the image you built. This same device-table technique carries over directly to squashfs and UBIFS images later in this free linux device drivers course.
FAQ
Why do I need a device table instead of just running mknod as root during the build?
You can run as root, but it complicates CI pipelines, container builds, and reproducibility — the device table achieves the same result from an unprivileged build step.
What happens if my rootfs image is too small for genext2fs?
The build fails with a “no space” style error. Increase the block count passed to -b and rebuild; always leave headroom beyond the staging directory’s current size.
Can I resize an ext2 image after it’s built?
Yes, with resize2fs, but it’s simpler to rebuild from the device table with a larger -b value during development.
Do I need rootwait on every board?
It’s needed whenever the storage controller can finish probing after the kernel would otherwise try to mount root — which is common enough on SD/eMMC that it’s safe to always include.
Is ext2 a good choice for a production embedded root filesystem?
It’s fine for a writable root on eMMC/SD, but squashfs or UBIFS are usually better for flash-constrained, read-mostly production images.
How is this different from the initramfs images covered earlier in this course?
An initramfs lives entirely in RAM and is unpacked at boot; this ext2 image is a persistent, block-device-backed root that survives a reboot without being rebuilt.
Continue the Free Embedded Linux Course
Next up: mounting your root filesystem over NFS for a much faster development loop.

2 Comments