Skip to content

10 · Capstone — Tiny Linux Appliance

Every module so far taught one piece of the stack in isolation. This capstone assembles all of them into one thing: a small, working "embedded Linux appliance" — a Buildroot-built QEMU image running a cross-compiled C sensor-simulator daemon, wired into init two different ways, with a boot-time status banner and a shell health-check script. It is, in miniature, exactly the deliverable a bring-up engineer hands off on a real board. The stretch section maps every single piece onto an actual i.MX95 product bring-up, so you leave this level able to say precisely what changes when the hardware becomes real.

What you're building

┌────────────────────────────────────────────────────┐
│ Buildroot rootfs.ext4  (module 9)                   │
│  ├─ /usr/sbin/sensord      ← cross-compiled C daemon│
│  ├─ /usr/bin/healthcheck.sh                         │
│  ├─ init wiring: BusyBox inittab respawn line        │
│  │   (variant B: systemd unit, same image family)   │
│  └─ boot banner: "Tiny Appliance vX — sensord: OK"  │
└────────────────────────────────────────────────────┘
       booted with: qemu-system-aarch64 -M virt ...

Step 1 — Start from your module-9 Buildroot tree

$ cd buildroot
$ make qemu_aarch64_virt_defconfig
$ make menuconfig    # keep dropbear from module 9; leave everything else default

Step 2 — Write the sensor-simulator daemon (C, module-6 skills)

/* sensord.c — cross-compiled sensor simulator daemon */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>
#include <signal.h>
#include <sys/stat.h>

static volatile sig_atomic_t running = 1;
static void on_term(int sig) { (void)sig; running = 0; }

int main(void) {
    signal(SIGTERM, on_term);
    signal(SIGINT, on_term);

    const char *pidfile = "/var/run/sensord.pid";
    FILE *pf = fopen(pidfile, "w");
    if (pf) { fprintf(pf, "%d\n", getpid()); fclose(pf); }

    const char *logfile = "/var/log/sensord.log";
    int reading = 20;
    while (running) {
        FILE *f = fopen(logfile, "a");
        time_t now = time(NULL);
        reading = 20 + (int)(now % 5);          /* fake temp, 20-24 C */
        if (f) {
            fprintf(f, "[%ld] temp=%dC status=OK\n", (long)now, reading);
            fclose(f);
        }
        sleep(2);
    }

    remove(pidfile);
    return 0;
}

Cross-compile it with Buildroot's own toolchain — the module-6 lesson about matching sysroots, now automatic:

$ output/host/bin/aarch64-buildroot-linux-gnu-gcc -O2 -o sensord sensord.c
$ output/host/bin/aarch64-buildroot-linux-gnu-readelf -d sensord | grep NEEDED
 0x0000000000000001 (NEEDED)  Shared library: [libc.so.6]

Step 3 — Write the health-check shell script (module-5 skills, POSIX sh)

#!/bin/sh
# /usr/bin/healthcheck.sh — is sensord alive and logging?
PIDFILE=/var/run/sensord.pid
LOGFILE=/var/log/sensord.log

if [ ! -f "$PIDFILE" ]; then
    echo "FAIL: no pidfile"; exit 1
fi
PID=$(cat "$PIDFILE")
if ! kill -0 "$PID" 2>/dev/null; then
    echo "FAIL: sensord pid $PID not running"; exit 1
fi
LAST=$(tail -1 "$LOGFILE" 2>/dev/null)
if [ -z "$LAST" ]; then
    echo "FAIL: no log entries yet"; exit 1
fi
echo "OK: sensord pid=$PID last='$LAST'"
exit 0

Step 4 — Get both files into the image with a rootfs overlay

Buildroot's BR2_ROOTFS_OVERLAY copies a directory tree straight onto the built rootfs — the cleanest way to inject your own files without writing a full package:

$ mkdir -p board/myappliance/overlay/usr/sbin
$ mkdir -p board/myappliance/overlay/usr/bin
$ cp sensord board/myappliance/overlay/usr/sbin/sensord
$ cp healthcheck.sh board/myappliance/overlay/usr/bin/healthcheck.sh
$ chmod +x board/myappliance/overlay/usr/sbin/sensord \
           board/myappliance/overlay/usr/bin/healthcheck.sh

In make menuconfig → System configuration → Root filesystem overlay directories, set:

board/myappliance/overlay

Step 5 — Init integration, variant A: BusyBox inittab (respawn)

Still in System configuration, edit the inittab the build will generate, or append after the build via the overlay (board/myappliance/overlay/etc/inittab.append merged manually, or simplest: add the line directly to board/myappliance/overlay/etc/inittab if you supply a full custom inittab). The one line that matters:

::respawn:/usr/sbin/sensord

This is the module-8 pattern exactly: crash sensord, init restarts it, your product stays alive.

Step 6 — Init integration, variant B: systemd unit

If you rebuild the same image with systemd enabled (Target packages → select systemd as the init system, in place of BusyBox init), ship this unit instead via the overlay at etc/systemd/system/sensord.service:

[Unit]
Description=Sensor simulator daemon
After=local-fs.target

[Service]
ExecStart=/usr/sbin/sensord
Restart=always
RestartSec=1

[Install]
WantedBy=multi-user.target

And enable it at build time by symlinking it into etc/systemd/system/multi-user.target.wants/ inside the overlay (the same effect systemctl enable has at runtime).

Step 7 — Boot-time status banner

A one-line motd-style banner, set via the overlay file etc/issue (BusyBox getty prints this before every login prompt):

Tiny Appliance v1.0 — sensord: check with `healthcheck.sh`

Step 8 — Build and boot

$ make -j$(nproc)
$ qemu-system-aarch64 \
    -M virt -cpu cortex-a53 -smp 2 -m 512M \
    -kernel output/images/Image \
    -append "root=/dev/vda console=ttyAMA0" \
    -drive file=output/images/rootfs.ext4,if=none,format=raw,id=hd0 \
    -device virtio-blk-device,drive=hd0 \
    -netdev user,id=net0 -device virtio-net-device,netdev=net0 \
    -nographic

Expected output, in order:

Booting Linux on physical CPU 0x0000000000 ...
[    2.xx] Run /sbin/init as init process
Tiny Appliance v1.0 — sensord: check with `healthcheck.sh`
buildroot login: root
# healthcheck.sh
OK: sensord pid=187 last='[1737500001] temp=22C status=OK'
# kill $(cat /var/run/sensord.pid)
# sleep 3
# healthcheck.sh
OK: sensord pid=203 last='[1737500009] temp=24C status=OK'   # new PID = respawned

That last block — kill it, watch a new PID come back, health-check passes again — is the whole capstone working correctly.

Stretch — mapping this onto a real i.MX95 bring-up

Everything above targeted QEMU's generic virt machine. Here is exactly what changes to run the same appliance on a real i.MX95 board — nothing about sensord.c or healthcheck.sh changes at all:

Piece This capstone (QEMU) Real i.MX95 board
Bootloader QEMU's built-in virt boot path (module 4) i.MX95 boot ROM (fuses select eMMC/SD) → SPL → U-Boot, signed if HAB/AHAB secure boot is enabled (module 3, Level 4)
Device tree QEMU generates a generic virt DTB (module 7) imx95-<board>.dtb, built from NXP's imx95.dtsi + your board file, enabling exactly the peripherals your board wires up
Kernel Buildroot's generic qemu_aarch64_virt kernel defconfig NXP's BSP kernel tree/config (or mainline + i.MX95 defconfig fragments), likely via Yocto's meta-imx layer rather than raw Buildroot (module 9's decision table)
Rootfs build Buildroot, one defconfig Very likely Yocto, for multi-layer BSP integration and long-term maintenance (Level 2)
This daemon (sensord) Reads time() for a fake reading Reads a real sensor over I2C/SPI, or receives readings from the Cortex-M7 core over RPMsg (Level 3) instead of faking them in C
Init system Your choice (inittab shown, systemd variant given) Production i.MX BSP images default to systemd (module 8) — ship the unit-file variant
Storage Single ext4 disk image Real eMMC partitioning: read-only squashfs rootfs + a writable data partition is typical (Level 2's storage module)
Networking to check on it dropbear SSH over QEMU user-mode NAT Same dropbear/OpenSSH over the board's real Ethernet or the i.MX95's wireless options
What's identical The C code, the shell script, the systemd unit, the inittab respawn line, the health-check logic Unchanged — this is the entire point of cross-compilation and portable init config

Cheat sheet

Artifact Role
sensord.c Cross-compiled C daemon — module 6's skill, product-shaped
healthcheck.sh POSIX /bin/sh health probe — module 5's shell literacy
BR2_ROOTFS_OVERLAY Buildroot mechanism for injecting your own files into the rootfs
::respawn:/usr/sbin/sensord BusyBox init variant — module 8
sensord.service (Restart=always) systemd variant — module 8
/etc/issue Boot-time banner shown before login
kill $(cat /var/run/sensord.pid) then re-check Proves the respawn contract works

How It Actually Works

Why BR2_ROOTFS_OVERLAY doesn't need a package. Buildroot builds the root filesystem in output/target/ by unpacking every selected package's .ipk/.tar-equivalent staging output into that tree, then running fakeroot-wrapped permission/device-node fixups, then handing the tree to the filesystem generator (genext2fs/mke2fs, mksquashfs, ...). The overlay step runs after every package is merged and before the image is generated: it is a plain recursive copy (cp -a) of your overlay directory on top of output/target/, so anything you put there — your sensord binary, healthcheck.sh, a custom /etc/issue — simply wins if paths collide with a package. This is why the overlay can inject files without you writing a Buildroot package/recipe at all; it is a filesystem merge, not a build step, and it has zero awareness of what any package installed.

Cross-compilation, concretely. Buildroot's toolchain wrapper resolves CC to something like output/host/bin/arm-buildroot-linux-gnueabihf-gcc, which is a real GCC built with --target=arm-buildroot-linux-gnueabihf and a sysroot pointing at output/host/arm-buildroot-linux-gnueabihf/sysroot. sensord.c never touches your host's headers or libc — every #include resolves inside that sysroot, and the linker embeds an ELF PT_INTERP of /lib/ld-musl-armhf.so.1 (or glibc/uClibc equivalent) so the target's dynamic linker, not the host's, is what resolves it at boot. file on the resulting binary shows ELF 32-bit LSB, ARM — that's the proof the sysroot substitution worked, and it's also why you can't just gcc -o sensord sensord.c on your host and drop the result in.

The two init paths hit different PID-1 code paths, not just different config syntax. BusyBox init's inittab parser reads /etc/inittab once at boot, and a respawn-tagged line goes into an in-memory action table that init's main loop waitpid()s against forever — when sensord exits, init gets SIGCHLD, looks up the dead PID in that table, sees respawn, and fork()+exec()s it again, uncoditionally, with no backoff. systemd's Restart=always is a unit property consumed by systemd's manager_dispatch_run_queue; each service unit is its own cgroup, restart goes through a START_LIMIT rate-check (StartLimitIntervalSec/StartLimitBurst, default 5 tries in 10s) before it will actually respawn, and journald captures stdout/stderr per-unit automatically — behavior BusyBox init has no equivalent for at all. Killing sensord and watching it come back on both images is really exercising two structurally different supervision engines, not one feature toggled two ways.

Exercise

Take this capstone one step further, in the QEMU image you already have: (1) make sensord write its reading to /sys-style location isn't possible from userspace, so instead have it also append one line to /tmp/sensord_history.csv (timestamp,temp — CSV, for later analysis); (2) modify healthcheck.sh to exit 2 (a distinct code) if the last log line is older than 10 seconds — simulating a hung daemon detector, and test it by pausing sensord with kill -STOP $(cat /var/run/sensord.pid) then kill -CONT to resume; (3) write a two-paragraph bring-up note, as if handing this off to a hardware team getting the first i.MX95 prototype boards: what stays exactly the same from this capstone, and what is the first thing you'd verify once real hardware powers on (hint: revisit module 3's boot relay — where would you attach a serial cable, and what's the very first line of output you'd expect to see before your own daemon ever runs)?