06 · Storage & Filesystems (UBI, overlayfs)¶
Storage is where embedded Linux products die. Not from bugs — from power cuts. A desktop is shut down cleanly; a field device has its power yanked mid-write, thousands of times over a ten-year life. This module is about picking a storage stack that survives that, and understanding why the flash under your filesystem behaves nothing like a disk.
What is actually under the filesystem¶
raw NAND / NOR eMMC / SD / UFS
│ │
MTD layer (/dev/mtd0, mtdblock) block layer (/dev/mmcblk0)
│ │
UBI (wear levelling, bad blocks) FTL inside the chip's controller
│ │
UBIFS / squashfs ext4 / f2fs / squashfs
The critical difference: eMMC hides flash management in firmware you cannot see or audit (a Flash Translation Layer doing wear levelling, garbage collection and bad-block handling). Raw NAND exposes all of it to Linux, which is why raw NAND needs MTD + UBI and eMMC does not.
Three flash facts drive every design decision:
- You can only erase in blocks (128 KB is typical), never per byte.
- Each block tolerates a limited number of erases — roughly 100k for SLC, as few as 1k–3k for consumer TLC/QLC. Writing a 1 KB log line every second will destroy a cheap part in months.
- A write interrupted by power loss can leave a page in an indeterminate state — not old data, not new data.
root@target:~# cat /proc/mtd
dev: size erasesize name
mtd0: 00100000 00020000 "u-boot"
mtd1: 00040000 00020000 "u-boot-env"
mtd2: 00800000 00020000 "kernel"
mtd3: 0f000000 00020000 "rootfs"
root@target:~# lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
mmcblk0 179:0 0 14.6G 0 disk
├─mmcblk0p1 179:1 0 64M 0 part /boot
├─mmcblk0p2 179:2 0 1G 0 part /
└─mmcblk0p3 179:3 0 13.5G 0 part /data
mmcblk0boot0 179:32 0 4M 1 disk
Note mmcblk0boot0 — eMMC has separate hardware boot partitions that the
SoC ROM reads before anything else. That is where your bootloader lives on
a real i.MX design, and writing to it is how boards get bricked.
Choosing a filesystem¶
| Filesystem | Writable | Power-safe | Use it for |
|---|---|---|---|
| squashfs | no | inherently | read-only rootfs, compressed; the safe default |
| ext4 | yes | with journal | data partitions on eMMC/SD |
| f2fs | yes | yes | large write-heavy eMMC data partitions |
| UBIFS | yes | yes (designed for it) | raw NAND, on top of UBI |
| overlayfs | yes | inherits from upper | writable layer over a read-only base |
| tmpfs | yes | RAM — lost on reboot | /tmp, /run, volatile state |
The single most valuable habit: make the root filesystem read-only and put every writable thing somewhere you chose deliberately. Module 8 is entirely about that; this module builds the pieces.
ext4 tuned for flash¶
root@target:~# mkfs.ext4 -L data -O ^has_journal -E stride=2,stripe-width=1024 \
-m 0 /dev/mmcblk0p3
Creating filesystem with 3538944 4k blocks and 884736 inodes
Filesystem UUID: 4c3a9d1e-7b2f-4c88-9a01-5e7d3f1b2c60
Writing superblocks and filesystem accounting information: done
-m 0 drops the 5 % root reserve (pointless on a data partition, and 5 %
of 13 GB is real money). -O ^has_journal removes the journal — do not
do this unless the partition is genuinely disposable; it roughly halves
write amplification and completely removes your crash consistency.
Mount options matter more than format options:
# /etc/fstab
# <device> <mount> <type> <options> <dump> <pass>
PARTLABEL=rootfs / squashfs ro 0 0
PARTLABEL=data /data ext4 defaults,noatime,errors=remount-ro 0 2
tmpfs /tmp tmpfs defaults,noatime,nosuid,size=32M 0 0
tmpfs /var/log tmpfs defaults,noatime,nosuid,size=16M 0 0
noatimestops a read from causing a write. On flash this is free lifetime.errors=remount-roturns silent corruption into a loud, diagnosable failure instead of a filesystem that keeps writing over damage.PARTLABEL=instead of/dev/mmcblk0p3— device enumeration order is not guaranteed (module 4).
commit= is the tuning knob people reach for:
That batches journal commits every 30 seconds instead of 5. Fewer writes, better flash life — and up to 30 seconds of data lost on power cut. This is a deliberate trade, not an optimisation.
UBI and UBIFS on raw NAND¶
UBI is a volume manager that sits on MTD and handles wear levelling and bad blocks across the whole partition, so UBIFS above it does not have to. Build it on the host:
$ mkfs.ubifs -m 2048 -e 126976 -c 2000 -r rootfs/ -o rootfs.ubifs
$ ubinize -o ubi.img -m 2048 -p 128KiB ubinize.cfg
# ubinize.cfg
[rootfs-volume]
mode=ubi
image=rootfs.ubifs
vol_id=0
vol_type=dynamic
vol_name=rootfs
vol_flags=autoresize
The three geometry numbers are not optional and not guessable: -m is the
page (min I/O) size, -e the logical erase block size (physical erase
block minus UBI overhead — 131072 − 4096 = 126976 for a 128 KB block with
2 KB pages), and -p the physical erase block size. Get them from
/sys/class/mtd/mtd3/ on the real part. Wrong values produce an image that
attaches and then corrupts.
On target:
root@target:~# ubiattach -m 3
UBI device number 0, total 1920 LEBs
root@target:~# ubimkvol /dev/ubi0 -N data -s 64MiB
root@target:~# mount -t ubifs ubi0:data /data
root@target:~# ubinfo -a
Volume ID: 0 (on ubi0)
Name: rootfs
Type: dynamic
overlayfs¶
overlayfs unions a read-only lower directory with a writable upper one, so a read-only rootfs looks writable:
root@target:~# mkdir -p /data/overlay/upper /data/overlay/work /merged
root@target:~# mount -t overlay overlay \
-o lowerdir=/etc,upperdir=/data/overlay/upper,workdir=/data/overlay/work \
/merged
root@target:~# echo "changed" >> /merged/hostname
root@target:~# ls /data/overlay/upper
hostname
Two rules with no exceptions: workdir and upperdir must be on the
same filesystem, and workdir must be an empty directory that nothing
else touches. Violating either gives mount: wrong fs type, bad option
with no useful detail.
Traps¶
Storage traps — the ones that lose customer data
- Power loss during a write. The only real defence is a design where
the thing being written is not the thing you need to boot: read-only
rootfs, journalled or log-structured data partition, and atomic
write-to-temp-then-
rename()in your application. Awrite()that returned is not on the media untilfsync()— andfsync()on the file is not enough if you also created it, you mustfsync()the containing directory. - Logging to flash. Unbounded
/var/logon eMMC is the most common cause of dead units at scale. Put it on tmpfs, or cap journald (module 9). - Writing to
mmcblk0boot0by mistake.ddto the wrong device node overwrites the bootloader in the eMMC hardware boot partition, and the ROM then finds nothing. Recovery requires the SoC's serial-download mode (uuuon i.MX) — or a rework station. Double-check everyddtarget, and prefer writing images by PARTLABEL. - Assuming an SD card is a disk. Consumer cards lie about flush, remap silently, and fail without warning. They are fine for development and unacceptable in a product.
- Filling the filesystem to 100 %. Flash filesystems need free blocks to garbage-collect; a full UBIFS or f2fs volume degrades badly and can fail writes that "should" fit. Reserve headroom and monitor it.
mkfsgeometry guessed rather than read from sysfs — silent corruption weeks later, not an error at format time.
Cheat sheet¶
| Command / item | Purpose |
|---|---|
cat /proc/mtd / lsblk |
Raw flash partitions / block devices |
mkfs.ext4 -m 0 -L data |
Format a data partition, no root reserve |
noatime |
Stop reads from writing — free flash lifetime |
errors=remount-ro |
Fail loudly instead of writing over corruption |
commit=30 |
Batch journal commits: fewer writes, more data at risk |
PARTLABEL= / UUID= in fstab |
Stable identity, immune to enumeration order |
mksquashfs dir img.sqfs -comp xz |
Build a compressed read-only rootfs |
mkfs.ubifs -m -e -c |
Build a UBIFS image (page / LEB / max-LEB count) |
ubinize -o ubi.img -m -p cfg |
Wrap UBIFS volumes into a flashable UBI image |
ubiattach -m N / ubimkvol / ubinfo -a |
Attach MTD to UBI, make volume, inspect |
mount -t overlay -o lowerdir=,upperdir=,workdir= |
Writable layer over read-only |
tmpfs ... size=32M |
Volatile, RAM-backed, zero flash wear |
fsync() file and parent dir |
What actually makes data durable |
sync; echo 3 > /proc/sys/vm/drop_caches |
Flush and drop caches when testing |
df -h / df -i |
Free space / free inodes (a separate way to fill up) |
On verification
Command syntax and mount options here follow the documented mkfs.ubifs,
ubinize, overlayfs and ext4 interfaces. No flash hardware was available
while writing this page, so the UBI geometry numbers are worked examples
— always read /sys/class/mtd/mtdN/{writesize,erasesize} on your own
part rather than copying them.
How It Actually Works¶
Flash has no "overwrite," and every filesystem choice here is a strategy for coping with that one fact. NAND/eMMC cells must be erased in large blocks (128 KB–4 MB) before any bit can be flipped back to 1, but writes happen in much smaller pages — so any filesystem that does in-place updates (ext4 on raw NAND, for instance) either needs a Flash Translation Layer underneath doing that erase/rewrite dance invisibly (eMMC, SD, most SSDs) or it must be a log-structured filesystem that only ever appends and reclaims space later via garbage collection (UBIFS, JFFS2). This single hardware constraint is the reason "just use ext4" only works when there's an FTL between Linux and the physical cells, and doesn't work on raw NAND without one.
UBI is the Linux-side FTL equivalent, and UBIFS is layered strictly on
top of it, never on raw MTD. UBI's ubiattach scans the MTD device,
builds a table mapping logical eraseblocks (LEBs) to physical ones
(PEBs), and maintains a wear-leveling counter per PEB so hot LEBs get
remapped to less-worn PEBs over time — transparently to anything above
it. UBIFS then only ever issues LEB-level reads/writes/erases through
that table; it never sees a raw flash offset. This is exactly why you
attach ubiattach to an MTD partition and then mount UBIFS on the
resulting /dev/ubiX_Y — two independent layers, wear-leveling below,
journaling filesystem above, each with no knowledge of the other's
internals.
overlayfs is a stackable union at the VFS layer, not a copy or a
bind-mount trick. Mounting lowerdir=/ro,upperdir=/rw,workdir=/work
creates one struct super_block whose directory lookups check upperdir
first, falling back to lowerdir on miss, and merges directory listings
from both. The first write to any file triggers copy-up: the whole
file (not just the changed bytes) is copied from lower to upper before
the write proceeds, and a deletion of a lower-only file creates a
character-device "whiteout" (0/0 device node) in upperdir that tells
future lookups to hide the lower entry — which is why workdir must be
on the same filesystem as upperdir (atomic rename is what makes
copy-up crash-safe) and why a large file getting one byte appended can
cause a surprising burst of I/O the first time.
Exercise¶
(1) In QEMU, add a second virtual disk, partition it, format one partition
ext4 with -m 0 and mount it with noatime,errors=remount-ro; confirm the
options took with findmnt and /proc/mounts. (2) Build a squashfs image
of a directory tree, mount it, prove it is read-only, then union it with an
overlayfs upper on your ext4 partition and show that a modified file appears
in upperdir while the squashfs is untouched. (3) Simulate power loss:
write a loop that appends to a file, kill QEMU with SIGKILL mid-write, and
compare the result with and without fsync() in the writer — report how much
data each variant lost. (4) One paragraph: your product logs 2 KB per second
to /var/log on a 4 GB eMMC rated at 3,000 program/erase cycles. Estimate
the write amplification and rough lifetime, then describe the storage layout
you would ship instead, naming the filesystem for each mount point.