Articles
Resizing an Embedded Linux Filesystem on First Boot
Automatically expanding a Yocto root filesystem on first boot, from repairing the GPT through to resizing the ext4 filesystem.

Resizing an Embedded Linux Filesystem on First Boot
When creating a WIC system image for a Yocto project, you don’t always know the final size of your storage - especially when working with SD Cards. It is safer to create an image that is smaller than the target disk, because if it is bigger you will not be able to flash it.
This leads to a few ‘issues’. I class them as issues not problems because there is nothing wrong with the system - it could just be better. The root filesystem is not taking advantage of the extra space available.
At first glance this looks like a simple filesystem resizing problem: find the root filesystem and run resize2fs. In practice, there are several layers between the physical storage and the filesystem, and each of them reflect the size of the original image and so should be adjusted.
The outline process is:
- determine which partition contained the running root filesystem;
- repair the GPT to account for the actual size of the storage device;
- extend the root partition into the additional space;
- expand the ext4 filesystem to use the enlarged partition; and
- make sure the operation wasn’t repeated on every boot.
Writing a disk image to a larger device doesn’t automatically resize everything contained within that image.
There are several separate layers involved:
The GPT, partition boundary and ext4 filesystem all come from the original image, and so will all need updating to make use of the full disk space.
In this case there was an additional GPT complication. The backup GPT metadata had been created for the original image size and was therefore had the wrong metadata. This needs correcting before extending the partition to the real end of the device.
That meant there were three distinct operations required after identifying the root partition:
Running resize2fs on its own does not solve the problem. Until the partition is enlarged correctly, the block device presented to ext4 will still be the original size.
Finding the root partition
My initial version identified the partition using a fixed PARTUUID:
PARTUUID="79315341-f057-4541-803b-3faf9b0629ee"
MARKER=/var/lib/gpt-expanded
if [ -e "$MARKER" ]; then
exit 0
fi
PARTDEV="$(blkid -t "PARTUUID=$PARTUUID" -o device)"
if [ -z "$PARTDEV" ]; then
echo "Could not find partition with PARTUUID=$PARTUUID" >&2
exit 1
fi
This was OK, as I knew the UUID because I had put it into my wic file. Where is fell over though, as during a test I used the same wic file to flash to both eMMC and an SD Card meaning that the UUID was no longer unique and “Almost Unique IDs” doesn’t quite work.
Given that the script didn’t really care about the PARTUUID. What it actually needed to know was:
Which block device contains the root filesystem I’m currently running from?
Linux already knows that, so the final version asks it directly:
PARTDEV="$(findmnt -n -o SOURCE /)"
if [ -z "$PARTDEV" ]; then
echo "Could not determine root filesystem device" >&2
exit 1
fi
findmnt reports the source backing /, removing the dependency on a particular PARTUUID.
Note: The reported source may be something such as /dev/root rather than the underlying block device. It therefore needs to be resolved first:
PARTDEV="$(readlink -f "$PARTDEV")"
This makes the implementation less dependent on how the image was instantiated, but it doesn’t make it generic. There are still assumptions about the storage topology later in the script.
Getting the disk and partition number
parted needs the whole disk as well as the partition number, while findmnt gives me the root partition.
On this hardware the root device follows the MMC naming convention:
/dev/mmcblk0p9
From that I need:
Partition device: /dev/mmcblk0p9
Parent disk: /dev/mmcblk0
Partition number: 9
The script extracts those values with:
case "$PARTDEV" in
/dev/mmcblk*p*)
DISK="${PARTDEV%p*}"
PART="${PARTDEV##*p}"
;;
*)
echo "Unsupported root partition device: $PARTDEV" >&2
exit 1
;;
esac
For /dev/mmcblk0p9:
DISK="${PARTDEV%p*}"
removes the p9, producing /dev/mmcblk0, while:
PART="${PARTDEV##*p}"
produces 9.
I deliberately reject anything that doesn’t match this layout rather than attempting to manipulate an unexpected storage device. I may get around to updating it to detect and cope with /dev/sdX formats, but that will likely wait until I actually need to do it.
Repairing the GPT
Before enlarging the partition, I needed the GPT to reflect the real size of the storage device.
The command used for this looks slightly odd because the requested parted operation is only print:
parted -s -f "$DISK" print >/dev/null
The important option is -f. The parted command can detect that the GPT metadata doesn’t match the actual size of the device. With -f, it automatically fixes problems it encounters rather than prompting interactively.
The -s option is also important here because this is running unattended during boot.
After this operation the GPT correctly describes the actual storage device, so the next thing is to expand the partition to make use of the available space.
Expanding the partition
parted -s "$DISK" resizepart "$PART" 100%
100% tells parted to extend the selected partition to the end of the available device.
The block device containing the root filesystem is now larger, but ext4 doesn’t automatically start using any of that additional space, so the filesystem needs expanding to the full partition size.
Expanding ext4
With a modern version of resize2fs you can expand an active filesystem (but not shrink - that still needs to be done with the filesystem offline).
resize2fs "$PARTDEV"
No explicit size is required. resize2fs grows the filesystem to use the available space provided by its block device.
The complete sequence is therefore:
This ordering matters. resize2fs can only use storage exposed by the partition, and the partition can only be safely extended once the GPT correctly describes the physical device.
Running it once on first boot
The storage size isn’t something the image build can always know in advance. The build knows how large the generated disk image is, it doesn’t know whether that image will eventually be written to storage of exactly that size or to a significantly larger device.
Given that the real size of the storage is not known (by the system at least) until the system has booted, it needs to be run on the target at first boot.
I installed the resize script as /usr/sbin/resize-sd and ran it from a systemd oneshot service during the first boot:
[Unit]
Description=Expand rootfs GPT and partition
DefaultDependencies=no
Requires=dev-mmcblk0.device
After=dev-mmcblk0.device
Before=local-fs-pre.target
ConditionPathExists=!/var/lib/gpt-expanded
[Service]
Type=oneshot
ExecStart=/usr/sbin/resize-sd
[Install]
WantedBy=local-fs-pre.target
The service is deliberately ordered early:
DefaultDependencies=no
Before=local-fs-pre.target
and waits for the expected MMC device:
Requires=dev-mmcblk0.device
After=dev-mmcblk0.device
There is another assumption hiding here.
Although the script discovers the root partition using findmnt, the systemd unit still explicitly depends on dev-mmcblk0.device. The final implementation therefore doesn’t assume a particular root partition or PARTUUID, but the service still assumes that mmcblk0 is the relevant storage device.
Not doing it again
Once the resize has completed, there is no reason to manipulate the partition table again on subsequent boots. While it is not a problem if you run the script again, it is just a waste of time.
I use a completion marker:
/var/lib/gpt-expanded
and then systemd checks it before starting the service:
ConditionPathExists=!/var/lib/gpt-expanded
and the script checks it as well:
MARKER=/var/lib/gpt-expanded
if [ -e "$MARKER" ]; then
exit 0
fi
The second check means the protection still exists if the script is invoked manually rather than through this particular systemd unit.
More importantly, the marker isn’t created until the storage operations have completed.
The final script starts with:
set -eu
so an unsuccessful command terminates the script. Only after parted and resize2fs have returned successfully does it run:
mkdir -p "$(dirname "$MARKER")"
touch "$MARKER"
A failed resize therefore doesn’t mark the operation as complete.
That isn’t the same thing as making the operation transactional. If repairing the GPT and extending the partition succeed but resize2fs fails, those first two changes have already happened. The next invocation will run through the sequence again.
For this implementation the marker means “the complete sequence returned successfully”, rather than recording the state of each individual operation.
Putting it into the Yocto image
I packaged the script and systemd service in a small recipe:
SUMMARY = "First-boot SD card expansion"
DESCRIPTION = "Repairs the GPT backup header, resizes rootfs to fill the SD card, and grows the ext4 filesystem."
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
SRC_URI = " \
file://resize-sd \
file://resize-sd.service \
"
S = "${WORKDIR}"
inherit systemd
SYSTEMD_SERVICE:${PN} = "resize-sd.service"
SYSTEMD_AUTO_ENABLE:${PN} = "enable"
RDEPENDS:${PN} += " \
e2fsprogs-resize2fs \
parted \
util-linux-findmnt \
"
do_install() {
install -d ${D}${sbindir}
install -m 0755 ${WORKDIR}/resize-sd \
${D}${sbindir}/resize-sd
install -d ${D}${systemd_system_unitdir}
install -m 0644 ${WORKDIR}/resize-sd.service \
${D}${systemd_system_unitdir}/resize-sd.service
}
FILES:${PN} += " \
${sbindir}/resize-sd \
${systemd_system_unitdir}/resize-sd.service \
"
parted is a runtime dependency because the script needs it to repair the GPT and change the partition boundary.
Likewise e2fsprogs-resize2fs and util-linux-findmnt for resize2fs and findmnt
The final script
Putting the pieces together, this was the script I ended up with:
#!/bin/sh
set -eu
MARKER=/var/lib/gpt-expanded
if [ -e "$MARKER" ]; then
exit 0
fi
PARTDEV="$(findmnt -n -o SOURCE /)"
if [ -z "$PARTDEV" ]; then
echo "Could not determine root filesystem device" >&2
exit 1
fi
# Resolve things such as /dev/root to the actual block device.
PARTDEV="$(readlink -f "$PARTDEV")"
case "$PARTDEV" in
/dev/mmcblk*p*)
DISK="${PARTDEV%p*}"
PART="${PARTDEV##*p}"
;;
*)
echo "Unsupported root partition device: $PARTDEV" >&2
exit 1
;;
esac
echo "Root filesystem is on $PARTDEV"
echo "Fixing GPT on $DISK"
parted -s -f "$DISK" print >/dev/null
echo "Expanding partition $PART to end of disk"
parted -s "$DISK" resizepart "$PART" 100%
echo "Expanding ext4 filesystem on $PARTDEV"
resize2fs "$PARTDEV"
mkdir -p "$(dirname "$MARKER")"
touch "$MARKER"
echo "GPT repaired and partition $PART expanded"
What this implementation assumes
Using findmnt removed the fixed PARTUUID, but there are still quite a few assumptions in this code.
In particular, it assumes:
- the root filesystem is backed directly by a block partition;
- the block device follows
/dev/mmcblkXpYnaming; - the disk uses GPT;
partedis available and can repair that GPT;- the root partition has free space immediately following it;
- extending that partition to
100%is safe; - the filesystem is ext4;
- ext4 can be grown in the state in which the service runs;
- the required tools are available early enough during boot;
/dev/mmcblk0is the device systemd should wait for.
The free-space assumption is particularly important. resizepart "$PART" 100% is appropriate for the layout I was working with because the partition could consume the remaining storage. It isn’t something I’d run blindly on an arbitrary partition table.
This is a first-boot resize mechanism for a known embedded system layout, not a general-purpose disk management utility.
What I would improve
It would also be useful to record the resulting storage topology after the operation. Checking the final partition and filesystem sizes would make failures easier to diagnose and provide better evidence that each layer had reached the expected state.
I wouldn’t necessarily make the partition handling accept every possible Linux block-device naming scheme. There is value in rejecting storage layouts the script wasn’t designed to modify. The important thing is to make those restrictions deliberate rather than accidentally encoding them as unexplained constants.
There are still platform-specific assumptions in the resulting code, and that’s fine for a script intended for a known embedded system. But they’re now assumptions about the storage topology the code supports rather than an identifier copied from one particular disk image.
This is fine for the rootfs, but would need some tweaking to do the same for an arbitrary partition such as a data partition. It solved my immediate needs, and I have a base to extend it if I need to.

