F(r)oggy reflections

A personal page of Jan Tušil


Building ad-hoc bootable images with Nix

Embedded Linux is everywhere. When it comes to building bootable images of Linux-based operating system, there exist popular toolchains such as Yocto and Buildroot. In this post I explore an alternative approach, using a tool known as Nix. The approach is mechanically reproducible and keeps me in the control.

Prelude

I have been using various Linux distributions since my high-school years. At some point, some fifteen years ago, when I was using Gentoo Linux as my primary system, I stumbled across Linux From Scratch: a Linux distribution which existed only in the form of documentation. I played with LFS a bit and the desire to understand how operating systems work under the hood sticked.

Moving forward to 2025: my primary system was NixOS and a backup phone of mine was experimentally running PostmarketOS. The system run fine and was nicely documented. However, coming from Nix background, I was a bit disappointed with its the build system: it kept changing files in its working directory, and it was hard for me to reproduce images of the system. So, a question emerged: could one use Nix, with its advantages of reproducibility and caching, for building such system images?

Of course, the idea is not new. Unsurprisingly, NixOS itself uses Nix for reproducibility. However, what if one does not want exactly NixOS?

An RPi SD-card image, from scratch

I have one unused Raspberry Pi. Let’s build a bootable SD-card image for that device, using Nix. The SD-card will contain:

  • (proprietary) RPi firmware blobs;
  • U-boot botloader;
  • Linux kernel;
  • device-specific device-tree blob (obtained from the linux kernel build);
  • some userspace packages (see below);
  • and some hand-written configuration files. From these, the first four will be stored in a FAT filesystem in the first partition of the MBR/DOS-labeled image, and the other in an ext4 filesystem in the second partition. The second partition will also contain some userspace packages; among others:
  • sysvinit (instead of systemd, which is used by NixOS)
  • sbase - a “suckless” collection of standard Unix tools like cat, md5sum, etc;
  • libxcrypt (a cryptographic library on which depends both sysvinit and sbase);
  • ubase - a collection of standard tools such as mount;
  • busybox - a well-known collection of utility tools, from which I use only an implementation of /bin/sh.

The only tool one needs to have installed in the system used for building the image is Nix; every other tool will be provied from nixpkgs. Among others, I use dd, rsync, pkexec (from polkit).

An SD-image skeleton

Partitioning the disk is easy:

emptyButPartitionedDisk =
  pkgs.runCommandLocal "partition-disk"
    {
      nativeBuildInputs = with pkgs; [
        parted
      ];
    }
    ''
    mkdir $out/
    truncate -s 250m $out/disk.img
    parted $out/disk.img mklabel msdos
    parted $out/disk.img mkpart primary fat32 1MiB 99MiB
    parted $out/disk.img mkpart primary ext4 100MiB 249MiB
    '';

However, for creating the filesystems, one needs to associate a loopback device with the disk image file. That is done easily when one is root; hence, let us run losetup in a virtual machine. After creating the filesystems, we also need to mount them in order to copy some files inside; let us do this in the same run of the same virtual machine. Luckily, Nixpkgs have nice support for executing commands inside virtual machines.

buildMainImage = { boot-dir, root-dir }: pkgs.vmTools.runInLinuxVM (
  pkgs.stdenvNoCC.mkDerivation {
    name = "main-image";
    dontUnpack = true;
    nativeBuildInputs = with pkgs; [
      util-linux # mkfs
      dosfstools # mkfs.fat32
      kmod # modprobe
      e2fsprogs # mkfs.ext4
      rsync
   ];
   buildPhase = ''
     cp ${emptyButPartitionedDisk}/disk.img ./disk.img
     modprobe loop
     losetup --find disk.img --partscan
     mkfs -t fat /dev/loop0p1
     mkfs -t ext4 /dev/loop0p2

     mkdir ./boot
     boot=$(realpath ./boot)
     mount /dev/loop0p1 $boot
     pushd ${boot-dir}
       rsync -rv --ignore-existing . $boot
     popd
     umount ./boot

     mkdir ./root
     root=$(realpath ./root)
     mount /dev/loop0p2 $root
     pushd ${root-dir}
       rsync -av --ignore-existing . $root
     popd
     umount ./root

     losetup --detach-all
  '';
  installPhase = ''
    cp disk.img $out
  '';
  }
);

The modprobe loop command loads the Linux kernel module for loop devices - for some reason, the machine starts without such module loaded.

An alternative approach might be possible: create the filesystems outside any virtual machine, as ordinary files (using truncate and mkfs), in the virtual machine only associate the main image file with a loopback device (losetup) and block-copy the filesystem images to the corresponding /dev/loop0pX using dd. I can imagine it would result in faster builds, as dd surely has to be faster than rsync; also, re-builds would take greater advantage of Nix’s caching mechanism.

Filesystem contents

How do we get the ${boot-dir} and ${root-dir} to supply to the buildMainImagefunction? We combine them from simple "packages" - filesystem trees. For${boot-dir}`, there are two such trees: one corresponding to Raspberry Pi firmware, and one corresponding to a configuration of the U-boot bootloader. The former needs to be configured such that it loads the latter; also, the firmware for RPi requires a device tree blob to be present in the root of the filesystem:

let
  config =
    { pathToKernel }:
    pkgs.writeText "config.txt" ''
      enable_uart=1
      kernel=${pathToKernel}
      # Other configuration. One can be inspired by images of Raspberry Pi OS .
    '';
   ...
in
  pkgs.runCommandLocal "firmware" { } ''
     mkdir -p $out/
     cp ${firmwareSrc}/boot/bootcode.bin $out/bootcode.bin
     cp ${firmwareSrc}/boot/start.elf $out/start.elf
     cp ${firmwareSrc}/boot/fixup.dat $out/fixup.dat
     cp ${config_txt} $out/config.txt
     cp ${board_dtb} $out/
   ''

That was the firmware. Now, let me turn the attention to U-Boot. When given the control, U-boot looks for a binary environment file uboot.env, which needs to point to a kernel image; uboot.env can be created from a plaintext description using the mkenvimage tool from the package ubootTools. For storing kernel images, U-boot uses a format known as “Flattened Image Tree”. The environment file has to point to a .fit file, which can be created from an image tree source file (.its) using the tool mkimage (from the same package).

Main filesystem

Besides the compiled sysvinit and other packages, the main filesystem needs to also contain some configuration files. For example, sysvinit reads /etc/inittab:

id:3:initdefault:

# We need to mount proc before mounting everything else.
# This is becuase [mount -a] is checking for existing mounts
# assuming proc is already mounted in /proc
mp:3:wait:/bin/mount -t proc proc /proc
mr:3:wait:/bin/mount -t ext4 -o remount,rw /dev/mmcblk0p2  /
ma:3:wait:/bin/mount -a

tS:3:respawn:/bin/getty /dev/ttyS1
t1:3:respawn:/bin/getty /dev/tty1
t2:3:respawn:/bin/getty /dev/tty2
t3:3:respawn:/bin/getty /dev/tty3
t4:3:respawn:/bin/getty /dev/tty4
t5:3:respawn:/bin/getty /dev/tty5

Also, getty (which also uses login) need /etc/passwd:

root:x:0:0:System administrator:/root:/bin/sh
u:x:1000:999:Random user:/home/u:/bin/sh

and /etc/shadow:

u:$6$vTiRv8QRUjduv8uY$Zm/Z/UjAdjxm1U0mXdqyOnOI30rs570W771WGQovpE3Mkb9v9uoe1ELh0uB.X6wL849J7r9NB7KoPVJaZuTwA0:0:0:99999:7:::

The hashed password (random-password) was created using mkpasswd --method=SHA-512.

Copying the image to a SD-card

Creating a script for copying to SD-card is easy. Of course, one can hardly run such a script when building a Nix package, but we can run it as a Flake app (using nix run).

copyToSDCard = { pkgs, image }:
{
  type = "app";
  program = pkgs.writeShellApplication {
    name = "copy-filesystem";
    runtimeInputs = [ pkgs.coreutils ];
    text = ''
      target="$1"
      if [[ -e "$target" ]]; then
        pkexec dd if=${image} of="$target" bs=4096 status=progress
        sync
      else
        echo "Target file $target does not exist."
      fi
    '';
  };
}

Conclusion

A companion repository for this post is here.