Why would we want to do this?

I personally love Raspberry Pis: they are super versatile and I’m always thinking of new ways to use them.

Instead of hooking up a keyboard and mouse to them I’m used to deploying them headlessly: I just plug them in and have them connect my home network so that I can interact with them over SSH. This however poses some difficulties:

  • How can I configure a static IP address beforehand? (I could use mDNS, but I’m not really a fan 😅)
  • How can I enable SSH on the first boot?
  • How can I configure the hostname before the first boot to avoid collisions with other RPis?
  • How can I have the Pi connect to my WiFi network if I’m not using Ethernet?

These are all points we should take care of if we really seek a ‘plug-n-play’ workflow.

Now, when you ‘burn’ your operating system image (we’ll assume it’s Raspberry Pi OS Lite) to an SD card you’ll notice it gets mounted as two separate partitions on your machine provided you’re not using Windows. If you dig a bit around the rootfs partition you’ll probably find some very familiar files and directories such as /home/pi, /etc and so on. This partition is going to be the Pi’s main disk! Actually, rootfs gets mounted on / as seen on /etc/fstab:

proc                  /proc           proc    defaults          0       0
PARTUUID=7d5a2870-01  /boot           vfat    defaults,flush    0       2
PARTUUID=7d5a2870-02  /               ext4    defaults,noatime  0       1

Well, it’s actually a bit hard to ‘see’ it given we should take a look at the partition UUIDs and such but you get the point 😅

The bottom line is we can modify these files and we’ll in turn be modifying the contents of the RPi’s HDD. If we know what we need to modify we can ‘just do it’ (pun intended) before booting up the Pi for the first time!

Now, imagine we need to headlessly boot not one but 10 machines: do we have to burn 10 SD cards with the stock image and then modify each of them manually? It’s true parameters such as IP addresses and hostnames need to be provided to each machine individually, but the same’s not true for WiFi network information for instance. The good thing is that, of course, we can avoid having to repeat the process over and over again. Isn’t that what tech is for? 😉

Please bear in mind that the following discussion only applies to Linux-based systems. We have chosen to use Fedora35, but you should be good to go with Ubuntu and Debian too!

Mounting the image as a loopback device

In order to modify the operating system image we are to burn into SDs we’ll mount it as a loop device. A loop(4) device allows to expose a file as if it were a regular block device (such as an HDD). Wording can get a bit messy on manual pages, so you are good to go with the idea that a loop device let’s you ‘mount’ an image. Thanks to this facility offered by Linux we can work with the image’s contents without burning it to an SD card and then mounting it: we can skip the first step altogether!

Inspecting the image

Before mounting the image as a loop device we need to know what it’s contents really are: in other words, we need some information on the image’s structure (in terms of partitions) so that we know what to mount and where to find it. We can take a look at that with the fdisk(8) utility.

We have obtained the image we’re to work with from here. Given it’s been compressed with xz(1) we need to decompress it first:

# We have to decompress the image. Note `xz` decompresses the image in-place (i.e. it overwrites the compressed image).
[collado@hoth ~]$ xz -d 2022-04-04-raspios-bullseye-armhf-lite.img.xz

# We can now inspect the image with `fdisk`. Option `-l` lists the partition tables.
[collado@hoth ~]$ fdisk -l 2022-04-04-raspios-bullseye-armhf-lite.img
Disk 2022-04-04-raspios-bullseye-armhf-lite.img: 1.88 GiB, 2017460224 bytes, 3940352 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0x7d5a2870

Device                                      Boot  Start     End Sectors  Size Id Type
2022-04-04-raspios-bullseye-armhf-lite.img1        8192  532479  524288  256M  c W95 FAT32 (LBA)
2022-04-04-raspios-bullseye-armhf-lite.img2      532480 3940351 3407872  1.6G 83 Linux

We can see how the size of each sector is 512 B and where each of the partitions begins in terms of sectors. The *.img1 partition corresponds to the boot partition we mentioned above and (you guessed it), the *.img2 partition corresponds to rootfs.

Now that we know the offset (i.e. the starting point) and size of the partitions we can use mount(8) to mount them right away!

Creating the mountpoint

We’ll first of all make a temporary directory to mount stuff on. We’ll call it tmp_mnt (now, that’s original):

# Let's create the directory
mkdir tmp_mount

A note on math with bash

We should also take a brief look into arithmetic evaluation on the shell. That terms is just fancy for computing stuff, that is, adding and multiplying numbers and so on within the shell. On bash(1) this can be done with double parenthesis:

# This will fail horribly: the shell will try to execute a command named `3`
[collado@hoth ~]$ 3 + 4
-bash: 3: command not found

# Let's use arithmetic expressions
    # As you'll notice, this shows nothing! This will just emit
    # a return code of `0` if the result is not 0 and `1` otherwise.
    # You can check that with `echo $?` right after execution!
[collado@hoth ~]$ (( 3 + 4 ))

# What we need are arithmetic expansions which do substitute the value.
    # This will try to execute `7` though
[collado@hoth ~]$ $(( 3 + 4 ))
-bash: 7: command not found

# We just need to explicitly print the value and we're good
[collado@hoth ~]$ echo $(( 3 + 4 ))
7

So why all this? Well, we can just use this nifty feature when we call mount. Notice we need to express the offset in bytes, so we’ll need to multiply the sector size for the image (i.e. 512 B) times the sector offset to locate each partition’s beginning. Now, instead of using pen and paper we can just explicitly pass everything to the command, which also makes it much easier to understand.

Time to mount it!

So, with all that we find the next generic mount command for our purposes. Note we need to use sudo so be allowed to mount stuff!

# The options  and arguments are:
    # -t: The filesystem type we're mounting. Each partition has a different one.
    # -o: These are the options passed to mount:
        # loop: We'll mount the image as a loop device.
        # offset: The offset (in bytes) where a partition starts.
    # img_file: The operating system image we are to mount.
    # target_directory: The directory on which to mount it.
sudo mount -t <fs_type> -o loop,offset=$((<sector_size * <start_sector>)) <img_file> <target_directory>

The key option of the above is the offset option that tells mount where to start looking for the filesystem to mount. Notice the first partition’s offset is not 0 but 8192 sectors! The file system type is determined by in the Type column in fdisk -l’s output and the target directory would be the tmp_mount/ we just created. With the above in mind we can then run:

# Mount the boot partition.
    # The FAT32 filesystem corresponds to the vfat type as seen in mount's manpage (i.e. man mount).
sudo mount -t vfat -o loop,offset=$((512 * 8192)) 2022-04-04-raspios-bullseye-armhf-lite.img tmp_mount

# Mount the rootfs partition.
    # The Linux filesystem corresponds to the ext4 type as seen in mount's manpage (i.e. man mount).
sudo mount -t ext4 -o loop,offset=$((512 * 532480)) 2022-04-04-raspios-bullseye-armhf-lite.img tmp_mount

We can then check that ls tmp_mount does indeed show the contents of those partitions!

[collado@hoth ~]$ ls tmp_mount/ bin boot dev etc home lib lost+found media mnt opt proc root run sbin srv sys tmp usr var

Now we are free to alter this filesystem at will to suit or needs. We might write another article explaining the changes we would need to do to achieve a truly headless RPi setup.

Do bear in mind that both partitions cannot be mounted at the same time! We should either unmount one first or just create another directory.

All good things come to an end: unmounting the image

Unmounting a partition is a matter of calling umount(8). We can always call sync(1) to flush the caches beforehand:

# Flush cached writes and unmount the image.
    # Note the argument to `umount` is the directory on which we mounted the image.
sync && umount tmp_mount

We got there!

We did it! If you want to save some modifications you just have to make them effective and then unmount the image. After that, your *.img file will contain those changes no matter where you go. Isn’t that cool?

A script automating it all!

The following script will automatically mount the boot partition and enable SSH on a Raspberry Pi the first time it boots. Please bear in mind this script is by no means ‘sturdy’, so it might break especially when parsing fdisk’s output. We’re just providing it as an example. However, the important and useful information is the one you’ll find above! ☝️

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#!/bin/bash

# Check we are running as root!
if [ $EUID -ne 0 ]
then
    echo "Run me as root please :P"
    exit -1
fi

# Check we only received one argument!
if [ $# -ne 2 ]
then
    printf "Usage: %s <image_file> <mountpoint>\n" $0
    exit -1
fi

# Check if the file exists
if [ ! -e $1 ]
then
    echo "Couldn't find the image at $1. Quitting..."
    exit -1
fi

# Check the mountpoint exists
if [ ! -d $2 ]
then
    echo "Couldn't find the mountpoint at $2. Quitting..."
    exit -1
fi

printf "Analyzing image on file %s\n" $1

# Get the sector size and partition offsets
sector_size=$(fdisk -l $1 | head -n 2 | tail -n 1 | awk '{print $8}')
boot_offset=$(fdisk -l $1 | tail -n 2 | head -n 1 | awk '{print $2}')
rootfs_offset=$(fdisk -l $1 | tail -n 1 | awk '{print $2}')

printf "Detected sector data:\n\tSector Size   -> %8d bytes\n\tBoot Offset   -> %8d sectors\n\tRootfs Offset -> %8d sectors\n"\
        $sector_size $boot_offset $rootfs_offset

# Mount the boot partition
printf "Mounting the boot partition...\n"
mount -t vfat -o loop,offset=$(($sector_size * $boot_offset)) $1 $2

# Enable SSH headlessly
printf "Touching the ssh file to headlessly enable SSH...\n"
touch $2/ssh

# Sync the changes and unmount the boot partition
printf "Syncing changes and unmounting the boot partition...\n"
sleep 2
sync && sudo umount $2

printf "Finished modifying the image! Ready to burn :P\n"

exit 0

If you have any comments, questions or suggestions, feel free to drop me an email!

Thanks for your time! Hope you found this useful 😸