Embedded Linux From Scratch

A complete Linux system built by hand for a BeagleY-AI board: cross-compiled mainline kernel, custom device tree, hand-assembled BusyBox rootfs, a custom /init as PID 1 and a real kernel driver bound through the device tree.

No vendor BSP. Every layer above the TI bootloaders was built by hand.

Kernel · Linux 7.1.5 mainline Board · TI AM67A (Cortex-A53) Arch · aarch64 Rootfs · BusyBox 1.38 (static) ● boot OK

1- Why this project

Most embedded work uses a Linux image someone else built. This project goes the other way: it builds the system itself, layer by layer, to understand how an embedded Linux platform actually comes to life — from the boot ROM handoff to a shell running as PID 1, and down into kernel space with a custom driver.

2-Boot stack

The TI AM67A has a multi-stage, vendor-specific boot chain. The three TI binaries are reused as-is; everything in green was built in this project.

BootROMimmutable, in-SoC — reads the SD card
▼
tiboot3.binstage 1 · R5 firmware + TI-FS (security)
▼
tispl.binstage 2 · U-Boot SPL + Device Manager
▼
u-boot.imgstage 3 · the "real" U-Boot
▼
Image + .dtbself-built kernel + device tree
▼
/inithand-written PID 1 (BusyBox rootfs)
▼
/bin/shinteractive shell

3- Cross-compiling the mainline kernel

Compiled on x86 (WSL2) for the ARM64 target, with board support verified in the config before building.

# verify board support (all must be =y)
grep CONFIG_ARCH_K3 .config          # TI K3 SoC
grep CONFIG_SERIAL_8250_OMAP .config # serial console
grep CONFIG_MMC_SDHCI_AM654 .config  # SD card

# build kernel + device trees + modules
make -j8 ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- Image dtbs modules
Image
51 MB
arch/arm64/boot/Image
Device tree
65 KB
k3-am67a-beagley-ai.dtb
Result
uname -r
7.1.5 (not vendor 7.0.9)
Design choice
Pure mainline kernel instead of the TI vendor fork, accepting a known trade-off (some recent hardware unsupported) in exchange for a clean, upstream base.

4- Root-cause debugging: the WiFi that couldn't work

At boot, the WiFi service crash-looped. A funnel investigation (systemctl → ip link → dmesg → .config → device tree) peeled back three layers:

Layer 1 — modules not deployed

The driver existed as a module (CONFIG_WL18XX=m) but /lib/modules/7.1.5/ was missing. Deploying the modules loaded the driver but still no wlan0.

Layer 2 — device tree silent

The mainline device tree never declares the chip (wlcore@2 missing under mmc@fa20000). A driver with no matching DT node stays inert.

Layer 3 — the real cause

The board's actual WiFi chip is a TI CC33xx (recent). Its driver exists only in the vendor kernel (6.x-ti) and is absent from mainline 7.1.5 entirely. Mainline only ships the older wl12xx/wl18xx family.

The Decision
Forward-porting an actively-developed 6.x vendor driver to mainline 7.1.5 would mean reconciling large kernel-API differences for a very low chance of success. The right call was not to and to document the limitation clearly.
The central lesson
Deploying a kernel means three things, not one: Image (built-in drivers) + .dtb (hardware description) + /lib/modules/ (module drivers). Forget the modules and the system boots but everything built as a module silently disappears.

5-A rootfs from scratch → PID 1

A minimal root filesystem assembled by hand: a static BusyBox binary (~200 Unix commands in one file) plus a hand-written /init, the very first program the kernel launches.

#!/bin/sh — PID 1, first program launched by the kernel
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev
export PATH=/bin:/sbin:/usr/bin:/usr/sbin
exec setsid cttyhack /bin/sh   # shell attached to serial console
~ # ps
PID   USER     TIME  COMMAND
  1   0        0:00  /bin/sh      ← our shell is PID 1
~ # free
Mem: 3872428 total  51444 used  3813520 free   ← 51 MB for the whole system

6- Into kernel space: a real driver

Step 5 controlled hardware from userspace (a C app writing to /sys/class/leds/). This step crosses into kernel space: a platform driver bound through the device tree, the core pattern behind every real device. It registers a virtual LED in /sys/class/leds/ (no GPIO yet): the goal is to prove the binding mechanism, not to drive the physical LED, which uses the kernel's standard leds-gpio driver.

/* the driver declares what it handles */
static const struct of_device_id myled_of_match[] = {
    { .compatible = "steve,myled", },   // must match the device tree
    { },
};

static int myled_probe(struct platform_device *pdev) {
    const char *label;
    of_property_read_string(pdev->dev.of_node, "label", &label); // read DT property
    led_classdev_register(&pdev->dev, &myled_cdev);            // create /sys interface
    return 0;
}

The kernel calls probe() only when a device-tree node's compatible matches the driver's of_match_table, the same mechanism that activates the standard LED driver and leaves the unsupported WiFi inert.

myled-dt: probe() called - matched by device tree!
myled-dt: label from DT = 'hello-from-devicetree'
myled-dt: registered /sys/class/leds/myled-dt/

7- Bug log : the real content

Every bug taught something. A selection of the thirteen documented in the repo:

SymptomRoot causeFix
Services FAILED at boot=m modules never deployedmodules_install + deploy
wl18xx loaded, no wlan0chip is a CC33xx, driver not in mainlineinvestigated → documented
Wrong Ramdisk Image Formatbooti expects size for raw cpio.gzadd :${filesize}
RD image overlaps OS imageinitramfs inside the 51 MB kernel regionrelocate load address
Silent shell, ls does nothingPATH not set in /initexport PATH
probe() never calledstale .dtb — make skipped rebuildforce DT rebuild

8-Key lessons

  1. Deploying a kernel = Image + device tree + modules. Forgetting modules is the #1 trap.
  2. The device tree decides which drivers activate, a loaded driver with no matching DT node stays inert.
  3. Funnel debugging: descend from symptom to root cause, verify each hypothesis before concluding.
  4. Boot-time memory layout matters, kernel, DT and initramfs must not overlap in RAM.
  5. A platform driver is bound by the device tree, not by loading, insmod alone does nothing.
  6. Vendor vs mainline is a real trade-off; knowing when not to forward-port is an engineering decision.