Rolling your own minimal embedded Linux for the Raspberry Pi -- part one: booting to a root shell

Unicode logo

This first article in my series on building a custom Linux image for a Raspberry Pi-based appliance describes the most minimal of minimal installations -- one that boots a kernel and runs a root shell.

Structure of a Pi SD card; how it boots

If you're working within the official guidelines, a Raspberry Pi bootable SD card must contain at least two partitions. It can contain more, but you'll need at least two.

It's not common to provide a swap partition, nor a separate partition for /home or /usr, although it's possible in principle.

Step 0: preparation

You'll need...

I would recommend not copying files directly on the SD card you plan to boot the Pi from, even if the card is properly formatted. You're probably going to break things repeatedly whilst experimenting, so you need a way to recreate a working card from scratch. Instead, I suggest creating a staging directory to hold the two sets of files you need: the boot firmware to go into partition 1, and the root filesystem to go into partition 2.

$ mkdir -p staging/boot
$ mkdir -p staging/rootfs

In addition, when I say things like "copy this file to..." you should read that as "create a shell script to copy this file to..." Building a custom Linux distribution is not straightforward, and you'll probably end up repeating the set-up steps over and over again. Any amount of automation will help to preserve your sanity.

Step 1: Getting the boot firmware for partition 1

The 'official' boot firmware contains bootloaders and kernels to support all the current Pi models. If you're trying to be very minimalist, you could delete the bits that don't apply to your model -- but good luck figuring out which bits you don't need. Unless you're trying to save every byte, it's hardly worth worrying, since the complete bundle is only about 40Mb.

So far as I know, the only supported source of boot firmware is from the official Raspian boot images. The procedure for unpacking the firmware, without burning an actual SD card, is as follows.

Mounting the specific partition can be a little fiddly, because you'll need to skip the partition table. To figure out how, first examine the partition table like this:

$ fdisk -l /path/to/raspian.img

Sector size (logical/physical): 512 bytes / 512 bytes
...
Device                       Boot  Start     End Sectors   Size Id Type
Raspbian_RPi-ARMv6-Buster.img1        8192  532479  524288   256M  c W95 FAT32 (LB
Raspbian_RPi-ARMv6-Buster.img2      532480 2085648 1553169 758.4M 83 Linux

The sector size is 512 bytes, and partition 1 starts at sector 8192. 512 x 8192 is 4194304, so that's the offset to use when mounting the image.

$ sudo mount -o loop,offset=4194304 /path/to/raspian.img /mnt/tmp
$ cp /mnt/tmp/* staging/boot
$ sudo umount /mnt/tmp

When you copy the boot firmware from the SD card image, you needn't worry about file attributes, because they are going to a VFAT partition. You'll need to be a little more careful when you build the root filesystem, as described next.

At the time of writing, a simpler way of getting the boot firmware is from GitHub. The problem with this approach is that it's difficult to be sure you're getting a supportable version -- that is, a version that corresponds to one of the Raspbian releases. Depending on what you're building, that may or may not be a problem.

Step 2: building a minimal root filesystem

Let's first consider the minimal requirements to have the kernel execute a root shell. The shell need not be particularly functional for this exercise -- in fact, it certainly won't be -- it just needs to prove that the boot is complete.

Since we're only booting to a root shell, you won't need anything that is normally found in the /etc directory. Nor will you need, at this stage, any kernel modules.

So how will we build the minimal root filesystem?

Option 2a: building a root filesystem by picking files from an existing distribution

It would be nice if there were, in the standard Raspian distribution, a .deb package whose description was "Files needed to boot to a shell". Unfortunately there isn't. Well, that's not quite true -- see the alternative option using busybox later in this article.

I've already explained how to mount partition 1 of a standard Raspian SD card image by finding the offset of the partition. The same procedure can be used to mount partition 2, and get the root filesystem. You can then pick out the bits you need for a minimal root filesystem.

Needless to say, that's not straightforward, and a lot of trial and error will be required, unless you're very lucky, or very methodical. To save a bit of time, the following are the files you need from Raspbian Buster to boot to the bash shell, and be able to run ls, and nothing else. So these files need to be copied to staging/rootfs, bearing in mind that some of them are symbolic links, not regular files (so use cp -a or similar).

bin/ls
bin/bash
lib/arm-linux-gnueabihf/libpcre.so.3.13.3
lib/arm-linux-gnueabihf/libpcre.so.3
lib/arm-linux-gnueabihf/libdl.so.2
lib/arm-linux-gnueabihf/libdl-2.28.so
lib/arm-linux-gnueabihf/libtinfo.so.6.1
lib/arm-linux-gnueabihf/libtinfo.so.6
lib/arm-linux-gnueabihf/libtinfo.so.5.9
lib/arm-linux-gnueabihf/libtinfo.so.5
lib/arm-linux-gnueabihf/libpthread.so.0
lib/arm-linux-gnueabihf/libselinux.so.1
lib/arm-linux-gnueabihf/libc.so.6
lib/ld-linux-armhf.so.3

You'll probably want to add more basic infrastructure than this in due course. You can add whole packages from the Raspian repository, and sometimes it will be appropriate to do this; or you can just add individual binaries. If you have a working desktop Raspberry Pi, you can use ldd to work out what libraries the binaries need. You'll still need some trial-and-error, though.

Option 2b: building a root filesystem using busybox-static

busybox is an interesting project that attempts to provide minimal versions of the all the common command-line utilities -- ls, cp, etc., all in a single executable. In particular, busybox provides a shell which is broadly compatible with the traditional sh Bourne shell.

To avoid the need for installing many additional libraries, we can use a statically-linked version of busybox.

To use a single executable to provide many utilities, busybox expects to be invoked using the name of the relevant utility. Since busybox is only one file, to get different names we have to create symbolic links in the /bin directory with different names, all pointing to the busybox binary. In this current example all we need is an instance of /bin/sh, so we create this symlink in /bin as follows.

First, download busybox-static from the main Raspbian repository. At the time of writing, the latest release for Raspberry Pi is 1.30, for Raspbian Buster. In case you didn't know, Debian .deb files are just ar archives with a particular format. So unpack the .deb and build the root filesystem as follows.

$ ar x busybox-static_1.30.1-4_armhf.deb
# This gets you data.tar.xz, among other things
$ cd staging/rootfs
$ tar xf /path/to/data.tar.xz
$ cd bin
$ ln -sf busybox sh
$ rm -rf usr # Don't need any of the docs or examples

At the end of this process, your entire root filesystem looks like this:

$ ls -lR staging/rootfs

total 4
drwxr-xr-x 2 kevin kevin 4096 Jan 21 12:51 bin

staging2/rootfs/bin:
total 1568
-rwxr-xr-x 1 kevin kevin 1603144 Apr  1  2019 busybox
lrwxrwxrwx 1 kevin kevin       7 Jan 21 12:51 sh -> busybox

Although minimal, this will be enough to get a complete boot to a command prompt.

For the purposes of the current exercise, it doesn't matter how you build the minimal root filesytem -- it's only a basis for customization. As soon as you can boot to a prompt, you're ready to move onto the next stage of development.

Step 3: editing kernel boot parameters

At this stage, the most important change you'll need to make is to indicate to the kernel what to run when the system starts. The kernel command line is in the file cmdline.txt, which should by this time be in your staging/boot directory.

The relevant parameter here is init=, which will almost certainly have the value /bin/init. In fact, in Raspian /bin/init is a link to systemd, but that's not important here. We need to ensure that init references /bin/sh or /bin/bash or whatever shell you installed above. If you aren't going to use ext4 as the filesystem type of your root filesystem, you'll also need to add rootfstype=something as well.

cmdline.txt is just a plain text file -- you can edit it using vi, or whatever you like.

Step 4: formatting the SD card

The usual way to distribute Raspberry Pi Linux distributions is as .img files that can be written to an SD card. For experimental purposes, however, it's more convenient to set up the disk partitions manually, and just copy the files into the appropriate partitions. In practice, you're going to be making small changes and rebooting repeatedly, and there's no point in copying a large disk image just to change one or two files. The structure of the SD card was described at the start of this article. It's easy enough to replicate this just using fdisk. In this example, I'm using a 32Gb card, and it appears to Linux as /dev/sdb.

# fdisk /dev/sdb

Command (m for help): n
Partition type
   p   primary (0 primary, 0 extended, 4 free)
   e   extended (container for logical partitions)
Select (default p): p
Partition number (1-4, default 1):  
First sector (2048-62530623, default 2048):
Last sector, +sectors or +size{K,M,G,T,P} (2048-62530623, default 62530623): +40M

Created a new partition 1 of type 'Linux' and of size 40 MiB.

Command (m for help): t
Selected partition 1
Hex code (type L to list all codes): b
Changed type of partition 'Linux' to 'W95 FAT32'.

Command (m for help): n
Partition type
   p   primary (1 primary, 0 extended, 3 free)
   e   extended (container for logical partitions)
Select (default p): p
Partition number (2-4, default 2): 
First sector (83968-62530623, default 83968): 83968
Last sector, +sectors or +size{K,M,G,T,P} (83968-62530623, default 62530623): 

Created a new partition 2 of type 'Linux' and of size 29.8 GiB.

Command (m for help): w
The partition table has been altered.
Calling ioctl() to re-read partition table.
Syncing disks.


# mkfs.vfat -F 32 /dev/sdb1
mkfs.fat 4.1 (2017-01-24)

# mkfs.ext4 /dev/sdb2
mke2fs 1.44.2 (14-May-2018)
Creating filesystem with 7805832 4k blocks and 1954064 inodes
...

Step 5: copying the files

If you've followed the steps above, you should have a directory staging/boot that needs to be copied to partition 1, and a directory staging/rootfs for partition 2. There will be no file attributes in (DOS) partition 1. Looking to the future, the files in partition 2 ideally need to be owned by root. Depending on how you do the copy, this might happen automatically, or you might have set the ownership explicitly. In any event, the copy to partition 2 needs to preserve file attributes other than ownership, and take account of the fact that not all entries will be regular files.

# mount /dev/sdb1 /mnt/tmp
# cp -ar staging/boot/* /mnt/tmp
# umount /dev/sdb1

# mount /dev/sdb2 /mnt/tmp
# cp -ar staging/rootfs/* /mnt/tmp
# chown -R root:root /mnt/tmp/*
# umount /dev/sdb1
# sync

Now you should have a bootable Raspberry Pi SD card ready to test.

Closing remarks

This is a lot of work to go to, just to get a Raspberry Pi to boot to a prompt. However, this is all groundwork that is needed to get to a point where you can start working on a real custom Linux.

Incidentally, the procedure described above will get you to a system that will operate the official Raspberry Pi 7" touchscreen. If you're planning an appliance with a built-in screen, it's good to know that you can test with this minimal procedure. You won't be able to set the brightness -- you need a kernel module for that -- but you'll be able to see a text console.

If the procedure above work for you, might like to look at the next article in this series, on early initialization, or go to the series index.