02 - Bootstrapping Linux

Objectives

  • Distinguish a Linux OS's various components (kernel, rootfs, initramfs)
  • Use Buildroot to make a Linux root filesystem from scratch
  • Configure & manually compile a Linux Kernel
  • Build everything as a single disk image

Contents

Lecture

Before beginning the tasks, please check out the lecture slides & notes here.

Tasks

01. Preparing the Linux μImage

There are three vital components that are required to successfully boot Linux on ARM:

  • Kernel image: This should be self-evident :P
  • Flattened Device Tree: A blob (.dtb) of compiled hierarchical data that the kernel parses to identify hardware elements;
  • RootFS: A file system that can be mounted at / in order to load the init executable. Here, we will use a ramdisk image (which will be **surprise pause** stored in RAM!).

Although these components can be manually loaded and configured for boot from U-Boot, it's preferable to package them together in a Flattened μImage Tree (FIT). Similarly to how bl1 and bl2 know how to load the binaries that comprise the FIP we generated in the previous lab, so does bl33 know how to boot the system from a FIT.

In the following tasks we will create each individual component and package them together. Finally, we will create a partition table (with one FAT partition) on the board's eMMC and store the FIT only to then load it and boot it from the bl33 shell.

Task 1.1. Compile the Linux kernel

Clone the kernel from the official GitHub repo, using the v6.12 tag. We recommend you to use this git cloning technique to avoid fetching the entire git history (requiring several GBs of disk space!).

Since you've built U-Boot previously, you should be somewhat familiar with the Kbuild system. Start by generating the build configuration from its default configuration. Optionally, run a menuconfig to inspect the Linux kernel options available.

You must set the ARCH argument to the appropriate architecture AT ALL TIMES when invoking linux's make!
Check out the subdirectories in <your-linux-src-dir>/arch/ for possible values.
Also, do not forget about the CROSS_COMPILE environment variable (export it inside your terminal / Makefile).
Our recommendation is use your own Makefile to invoke make commands on the kernel source tree with the required arguments!

In order to build the kernel image, simply make with the required variables (setting ARCH=? – see the warning above!).

Paralellize the build process using make … -j <num_CPUs> (use 6-8 on the VM, do not use all 16 as you're not alone in there!), unless you want to waste an hour for Linux to finish building.

While waiting for the build, explore the Linux source using your favorite code editor (especially the device trees)!

Note that the kernel image you will be including in the FIP is called Image. Unlike vmlinux which is an ELF file (it contains some useless-for-now sections like .debuginfo if you want to debug the kernel), Image is a boot executable image, made specifically for the CPU to jump straight in and start executing code from RAM. After the build process finishes, find the Image file within the <linux-src-dir>/arch/arm64/boot/ output directory.

Useful tip for kernel development (if you wish to browse / modify the kernel code):
After you finish an initial build of the kernel, consider running the following script:

./scripts/clang-tools/gen_compile_commands.py

This will generate a compile_commands.json file that contains the gcc invocation cmdline. Any half decent LSP-based IDE (VSCode, AstroNVim – which is installed on the armbuilder VM) will be able to (automatically) parse this file and deduce the include paths and definitions used. This will enable features like go-to-definition between different source files and much more.

Note that this script only works on the current project. For a more generic tool, try bear. Be warned though that it's LD_PRELOAD hooking of exec() calls (needed to extract the cmdargs) interferes with the Automake configuration stage.

Task 1.2. Find/save the Device Tree

Luckily, the Flattened Device Tree (FDT – .dtb file) for our platform is also included in Linux. It's name should be imx93-11x11-evk.dtb (generated from its source counterpart, imx93-11x11-evk.dts).

If for some reason it wasn't built alongside the kernel Image, you can always use the make … dtbs command to [re]compile them.

Task 1.3. Build a Root FileSystem

The kernel alone does NOT make a useable Operating System. It needs some userspace applications to at least supply a command-line interface for the human users (we won't be building a graphical UI, as it was to be expected). This means we have to find a way to build a root filesystem containing such programs and their required dependencies (shared libraries, configuration files etc.).

Usually there are two approaches to generating the root filesystem: bootstrapping from a source of pre-compiled binaries (e.g.: debootstrap, pacstrap, etc.) or building them yourself from source (e.g.: Yocto, Buildroot.) The former is usually preferred when working on desktop environments. The latter allows you to fine tune everything that is installed on your system and obtain much less disk space consumption!

Between Yocto and Buildroot, we generally opt for Buildroot. While Yocto has many advantages (e.g.: wide industry adoption, extensive device support, etc.), it also has a very steep learning curve and consumes significant amounts of resources (i.e.: ~50-100GB storage space for a build). Although it has a layer-based build customization feature (think Dockerfiles extending base images), we argue that this makes it more difficult to comprehend the contents of what is being built.

Buildroot on the other hand is geared towards simplicity and ease of use. Being based on Kbuild and Makefile (same as Linux and U-Boot) makes it instantly familiar to most developers. And even if you are new to this, while Yocto requires you to read entire books in order to utilize it properly, Buildroot can be summed up in a 1k LoC Makefile.

Download Buildroot

Clone the official Buildroot repo. We used the latest release, 2025.05.x, so make sure to fetch that branch (master should also work, but it is in continuous development and may break at any time! use the versioned branch if you want a hassle-free experience).

Create the configuration

You can find all KConfig files inside <buildroot-src>/configs/ directory. There is even one for our imx93 platform, but we won't use it!

Buildroot also integrates the bootloader & kernel into its build system. However, such configurations won't work for our specific board and keeping the components separate makes it easier to appply patches and debug problems.

What we want from Buildroot is a minimal root filesystem and nothing more. So we'll start from a basic defconfig (that's it!), then proceed with menuconfig and make the following changes:

  • Target Options → Aarch64 Little Endian;
    • To optimize: Model → Corted-A55;
  • Enter System Configuration menu.
    • You can change the System Banner to your own liking;
    • Make sure you have Busybox as the Init System;
    • Enable root login with password, then set your desired password on the config below…
    • Choose bash as the default shell instead of sh;.
  • Include a text editor PACKAGE (e.g.: search for PACKAGE_<PAKCKAGE_NAME>, VIM or nano)!
  • You can optionally choose the python3 package!
  • Generate an uncompressed CPIO image from the output file system (enable the TARGET_ROOTFS_CPIO option, see the Note below).

Note how the the configuration variables are prefixed with BR2_, packages with BR2_PACKAGE_ and so on!

The CPIO archive is the preferred method of packaging the initramfs. We could also do it manually using the cpio tool (see example below) but since the option is there… why not?

cd /path/to/rootfs
find . | cpio -o > /path/to/ramdisk.cpio

Feel free to add / remove anything else you want. Anything goes as long as you end up with a functioning system. After completing Step 3, maybe return and see if there's anything left to trim down to speed up the build process.

Build & inspect the rootfs

Not much to it, really, simply invoke make -j <NUM.CPUS> (no other parameters are required for Buildroot, no ARCH like the Linux Kernel). Once everything's done, check out the output/ directory. What does it contain? Where is your CPIO archive?

Chances are that you're going to screw something up while playing around with the config file. Check the error message and if it's not immediately obvious try to find the makefile / script where things go awry. If a grep -rn ${ERROR_MESSAGE} doesn't help, try to run make with the V=1 argument for verbose output, but without -j!

Task 1.4. Compile the final FIT Image

Like the flash.bin firmware package, we need to pack the kernel, FDT and rootfs together in a single file for convenience in booting.

Create a staging/ directory somewhere in your work dir and copy everything that we've obtained from the previous three tasks (see below for the files to be included inside the FIT). Then, create an Image Tree Source (ITS) file. We'll be referring to it as linux.its but the name doesn't really matter. What matters is the content:

linux.its

linux.its

/dts-v1/;

/ {
    description = "Linux FIT image for FRDM iMX93";
    #address-cells = <1>;

    images {
        kernel {
            description = "Linux kernel";
            data = /incbin/("Image");
            type = "kernel";
            arch = "arm64";
            os = "linux";
            compression = "none";
            /* load + entrypoint address (e.g., <0x12340000>), MUST BE VALID DRAM REGION!
             * also, do NOT ommit the '<>' characters!) */
            load = <XXX>;
            entry = <XXX>;
        };
        fdt {
            description = "Device tree";
            data = /incbin/("imx93-11x11-evk.dtb");
            type = "flat_dt";
            arch = "arm64";
            compression = "none";
            /* compute the next available DRAM address (e.g., add 64MB to the previous one) */
            load = <YYY>;
        };
        initrd {
            description = "Ramdisk";
            data = /incbin/("rootfs.cpio");
            type = "ramdisk";
            arch = "arm64";
            os = "linux";
            compression = "none";
            /* same, compute a valid next address (FDT is pretty small, couple of kilos) */
            load = <ZZZ>;
        };
    };

    configurations {
        default = "normal-boot";

        normal-boot {
            description = "Normal boot config";
            kernel = "kernel";
            fdt = "fdt";
            ramdisk = "initrd";
        };
    };
};


Let's analyze this:

  • The file starts with /dts-v1/;, identifying it as a Device Tree Source.
  • Next, we have a root / node with two child nodes: images and configurations.
    • images contains the description of each binary that will need to be loaded by U-Boot when processing the FIT.
      • Each image entry contains an incbin directive that tells mkimage to copy paste the contents of the specified file into the output DTB.
      • Each image also contains a load property, specifying the address where the data will be placed.
      • In addition to load, the kernel image also has an entry property specifying where U-Boot will jump when relinquishing control to the Linux kernel.
    • configurations contains sets of image configurations that can be chained.
      • The only configuration we have is also the default: normal-boot.
      • Notice how it has pre-defined attributes for kernel, fdt, ramdisk. These are not simple binary blobs; instead, they each have a role to play in the boot sequence. E.g., U-Boot will know to take the load address of the fdt image and pass it via a certain register (decided by convention) to the kernel, informing it where in memory to look for the FDT.

Note how we replaced the load and entry addresses with placeholders such as XXX. Replace these with whatever addresses you want such as the binaries do not overlap once U-Boot starts loading them.

Before deciding the address, find out:

  • at what physical address does the RAM start (check out the iMX93 Memory Map);
  • how much RAM do you have;
  • how large is each included file!

One thing to note: U-Boot is also located somewhere in RAM. During its initialization stage, no matter where bl2 placed it, it will relocate itself towards the end of RAM. Make sure not to overwrite the FIT unpacking code while unpacking the FIT :)

Hint: bdinfo holds all the answers.

Once all the addresses are filled in, generate the Image Tree Blob (ITB) using mkimage. Best not to use the imx-mkimage; instead, install mkimage using your distro's package manager (or find it pre-compiled by U-Boot at <uboot-src-path>/tools/mkimage).

../<path-to-uboot>/tools/mkimage -f linux.its linux.itb

02. Placing the FIT on the board's eMMC

There are many ways to make the FIT image available to U-Boot. For this task we chose a method that may seem roundabout, but can serve as a starting point toward making our board's configuration persistent across resets (i.e.: no longer using SDP each time we restart it.)

The goal is to add a FAT32 partition on the eMMC storage device and on that partition, place the FIT image as a file (linux.itb) on its root. U-Boot has the drivers necessary to parse the partition table and interact with the FAT32 file system.

Later in this exercise we will see how uuu can be used to overwrite the eMMC. Unfortunately, it will overwrite it indiscriminately starting at the very beginning. This means that an attempt to just flash the FIT image onto the eMMC will delete the partition table, rendering it useless to bl33. To circumvent this issue, we will create a disk image as a big, continous file, containing the following:

  • A partition table: needed in order to identify different partitions on the eMMC;
  • ~10MB of empty space: this is where you can place the FIP image (i.e.: the one with the bootloaders and firmware) to be loaded automatically during an eMMC boot (refer to the Jumper configuration part of the previous lab).
  • A FAT32 formatted partition containing the FIT image. Other file system types such as ext4 may be a bit too exotic for some bootloaders out there, but everyone should know how to interpret FAT.

Task 2.1. Create the disk image

Start of by creating an empty file. If your FIT image is ~70MB, then 128MB should suffice. No need to be conservative.

$ truncate --size 128M disk.img

Next, use fdisk or parted (modern alternative) to create a partition on disk.img.

Figure 1: Disk image layout. The optional Firmware Image Package would be loaded during an MMC boot from a 32KB offset. Adding the FIP to the disk image is not part of the task (yet).

Before creating the partition, explicitly add a Master Boot Record (MBR) partition table. If you don't and there's no partition table at the start of disk.img, fdisk will create a MBR by default. However, you never know what garbage data was already on your hard drive when you truncated the new file.

When asked where the partition should start, remember that fdisk expresses the offsets in sectors. One sector is 512 bytes. So, how many sectors are there in 10MB?

Use the print (or p) command of your partitioning tool to inspect the end result. You should see your partition at the desired offset.

Now that we have a partition, we want to format it with a FAT32 filesystem. Normally, the OS would make this painfully easy for us, by creating /dev/ entries for each partition. For example, if you plugged in a USB stick identified as /dev/sda with two partitions, the kernel would also generate the sda1 and sda2 entries for you to target with mkfs or to mount. As of now, the kernel does not recognize a random file that we just created as a storage device. So let's change that:

# -a : add new disk
# -v : verbose output helps identify the new device
$ partx -a -v disk.img 

The partx tool made our disk.img file known to the kernel and asked for it to be scanned for partitions. The kernel obliged and created a loopback device (i.e.: a file-backed storage device).

You can run lsblk to confirm this. However, if you are using Ubuntu, you will most likely see a lot of loopback devices, one for each snap that you have installed. Look for a 128M disk with a ~110M partition.

We've finally arrived at the point where we can format the partition, mount it and copy the fit image onto it:

# Note: it is usually loop0, but the index may differ depending on your previous operations!
# use `lsblk` to confirm the name of the device!!!
lsblk
# create FAT32 file system 
mkfs.fat -F 32 /dev/loop0p1
 
# mount file system (assuming /mnt is empty usually)
mount /dev/loop0p1 /mnt
 
# copy the FIT image
cp linux.itb /mnt
 
# finally, unmount the file system
umount /mnt

Although chances are you still need to make changes to the disk image, you can clean up the loopback device using the commands below:

# deregister our disk image as a storage device
partx -d /dev/loop0
 
# delete the loopback device that was allocated for us
losetup -D /dev/loop0

Task 2.2. Overwrite the eMMC

We may use U-Boot's Mass Storage Device emulation to present the board's eMMC flash memory as host device and write our image:

u-boot> ums mmc 0
# keep it running (i.e., do not ctrl+c)!

Then, simply use the dd tool or another image writer tool to burn the disk.bin image on the USB device presented by the board:

# first, run lsblk and determine the device's address:
lsblk
# look at the disk capacities and choose the 32G one (the eMMC flash device)
# PLEASE BE CAREFUL! TAKE NOTE TO IGNORE YOUR MAIN HOST PARTITIONS!
# OTHERWISE, YOU MAY LOSE PERSONAL DATA!
dd if=disk.bin of=/dev/sdX?? bs=4k
# note: the name of SCSI-emulated devices is /dev/sdX, where X is a lowercase letter from a-z.
# each partition, if any, follows with an index number starting from 1 (unlike C arrays :D)
# you will need to overwrite the whole drive, so don't use a partiion index!

If your host is Windows, you will lack the dd tool. You may install a third party disk imagine application such as Balena Etcher, though the easier way is through uuu.exe via command line:

# emmc_all has two arguments: a working firmware package + the disk image to write onto the eMMC
uuu -b emmc_all flash.bin disk.img

03. Booting Linux from the FIT image

Start up your board. Use uuu to supply it with a valid firmware image package:

uuu -b spl flash.bin

Connect to the serial cable (the DEBUG one), then open a terminal (recall: /dev/ttyACM0 on Linux – usually!), e.g. using picocom (don't forget to set baudrate to 115200!) and power on the board via the USB-C cable!

Task 3.1 List eMMC partition contents

First things first: let's check if the flashing process was succesful. Reload your FIP using uuu and get back into the U-Boot shell.

u-boot=> # list MMC devices (or rather, the slots)
u-boot=> mmc list
FSL_SDHC: 0 (eMMC)
FSL_SDHC: 1

u-boot=> # select MMC device 0
u-boot=> mmc dev 0
switch to partitions #0, OK
mmc0(part 0) is current device

u-boot=> # show partitions on currently selected MMC device
u-boot=> mmc part

Partition Map for MMC device 0  --   Partition Type: DOS

Part    Start Sector    Num Sectors     UUID            Type
  1     20480           184320          5a0f642d-01     83
  
u-boot=> # show contents of / directory on MMC device 0, partition 1
u-boot=> # assuming that the partition in question is FAT
u-boot=> fatls mmc 0:1
 72709475   linux.itb

1 file(s), 0 dir(s)

Task 3.2. Load the FIT image in RAM

In order for U-Boot to parse the FIT image and extract its components at their respective load addresses, we first need to bring the entire FIT image in main memory (use the load or fatload command for this). Choose a load address where the FIT image will not overlap with any of the three components when unpacked, or with the relocated bl33 code, e.g. >= 0x90000000.

Run help load for syntax.

Task 3.3. Investigate contents of FIT image

This step is optional, but gives us the chance to see how U-Boot can parse a DTB file and extract its information. In the following example we assume that you've loaded the FIT image at address 0x81000000 (i.e.: 64MB offset in DRAM):

u-boot=> # mark 0x80000000 as the starting address of a device tree
u-boot=> fdt addr 0x90000000

u-boot=> # show contents of specific nodes in the RAM-based DTB
u-boot=> fdt list /
/ {
        timestamp = <0x64b55028>;
        description = "Linux FIT image for FRDM iMX93";
        #address-cells = <0x00000001>;
        images {
        };
        configurations {
        };
};

u-boot=> fdt list /configurations
configurations {
        default = "normal-boot";
        normal-boot {
        };
};

u-boot=> fdt list /configurations/normal-boot
normal-boot {
        description = "Normal boot config";
        kernel = "kernel";
        fdt = "fdt";
        ramdisk = "initrd";
};

Be careful not to list the contents of specific image nodes, such as /images/kernel. The fdt list command will print out the entire data attribute, meaning it will dump the entire kernel image (several megabytes) to serial output, which would take a couple of hours!!

Task 3.4. Boot Linux from the FIT image

Use the bootm <loaded_fdt_address> command, passing it the starting address of the FIT image.

Linux should start displaying kernel messages!

ass/labs-2025/02.txt · Last modified: 2025/08/05 11:18 by florin.stancu
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0