A hands-on learning repo progressing from raw kernel builds to full Yocto Project based embedded Linux images, all running on Apple Silicon via Docker + QEMU.
| Phase | Topic | Status |
|---|---|---|
| 1 | Build Linux kernel (ARM64) from source | Done |
| 2 | BusyBox initramfs + custom init | Done |
| 3 | Kernel modules | Done |
| 4 | Yocto Project β first image (core-image-minimal) | Next |
| 5 | Yocto β custom layer + recipe | Planned |
| 6 | Yocto β BSP layer for QEMU AArch64 | Planned |
This guide documents a reproducible workflow to:
- Build the Linux kernel (ARM64) using Docker
- Build a BusyBox-based initramfs
- Boot the kernel using QEMU (AArch64) on macOS (Apple Silicon / M1βM3)
- macOS on Apple Silicon (M1 / M2 / M3)
- Docker Desktop (Compose v2)
- Homebrew
- QEMU (AArch64)
Install QEMU:
brew install qemu.
βββ Dockerfile
βββ docker-compose.yml
βββ hello.c # Phase 2: minimal static-C init (no shell)
βββ init.c # Phase 2: pure-C init with ls/cd/cat shell
βββ kernel-modules-practice/
β βββ kernel-param-module/ # Phase 3: module parameters + callbacks
β βββ kernel-sample-module/ # Phase 3: simple hello-world module
βββ minimal_shell_init/
β βββ etc/ # Phase 2: motd + resolv.conf for shell init
βββ initramfs/
β βββ init # Phase 1: BusyBox init script
βββ out/ # build output (gitignored)
docker compose -f docker-compose.yml builddocker compose -f docker-compose.yml run --rm kernel-builder
Or one-shot build:
git clean -fdx
make ARCH=arm64 mrproper
make ARCH=arm64 defconfig
make O=/work/out ARCH=arm64 -j$(nproc)
docker compose run --rm kernel-builder make -C /work/linux O=/work/out ARCH=arm64 -j$(nproc)Or one-shot background build:
docker compose run -d --rm kernel-builder /bin/bash -c 'make -C /work/linux ARCH=arm64 defconfig && make -C /work/linux O=/work/out ARCH=arm64 -j$(nproc)'
docker compose run -d --rm kernel-builder /bin/bash -c 'make -C /work/linux O=/work/out ARCH=arm64 -j$(nproc)'
docker exec -d kernel-builder /bin/bash -c 'make -C /work/linux O=/work/out ARCH=arm64 -j$(nproc)'
docker exec kernel-builder /bin/bash -c 'make -C /linux-kernel-source O=/work/out ARCH=arm64 -j$(nproc)'
# View logs
docker compose logs -f kernel-buildercd /work
git clone --depth 1 --branch v6.12 https://github.com/torvalds/linux.git /linux-kernel-source
# or git clone --depth 1 https://github.com/torvalds/linux.git
cd linux
# if you have older linux cloned folder to cleanup
# or git clean -idx for intractive clean
git clean -fdx
make ARCH=arm64 O=/work/out defconfig
make ARCH=arm64 O=/work/out -j$(nproc)Kernel image produced:
/work/out/arch/arm64/boot/Image
clone BusyBox β /work/busybox
cd /work
git clone --depth 1 --branch 1_36_1 https://github.com/mirror/busybox.git /busybox
# or git clone https://github.com/mirror/busybox.git
cd /busybox
make distclean
make defconfig ARCH=arm64
make menuconfigDisable:
- Settings β SHA1/SHA256 hardware acceleration
- Networking Utilities β tc
Enable static build:
sed -i.bak 's/# CONFIG_STATIC is not set/CONFIG_STATIC=y/' .configBuild and install:
make ARCH=arm64 -j$(nproc)
make ARCH=arm64 CONFIG_PREFIX=/work/initramfs installThis installs BusyBox into:
/work/initramfs/{bin,sbin,usr/bin,...}
cat > /work/initramfs/init <<'EOF'
#!/bin/sh
# 1. Mount basic filesystems
mkdir -p /proc /sys /tmp
mount -t proc none /proc
mount -t sysfs none /sys
mount -t tmpfs none /tmp
# 2. Mount devtmpfs FIRST
# This populates /dev with device nodes like console, null, etc.
mount -t devtmpfs none /dev
# 3. NOW create pts inside the newly mounted /dev and mount devpts
mkdir -p /dev/pts
mount -t devpts none /dev/pts
echo "Booted into initramfs!"
echo "Kernel: $(uname -a)"
# drop into a shell
# exec /bin/sh
# cttyhack detects the console and runs the shell with a controlling terminal
exec setsid cttyhack /bin/sh
EOFchmod +x /work/initramfs/initcd /work/initramfs
# mkdir -p proc sys dev dev/pts tmp # Not required as init script will do it
find . -print0 | cpio --null -ov --format=newc | gzip -9 > /work/initramfs.cpio.gz
# exit from container
exitNow you have:
- /work/out/arch/arm64/boot/Image
- /work/initramfs.cpio.gz
qemu-system-aarch64 \
-machine virt \
-cpu cortex-a72 \
-m 2048 \
-nographic \
-kernel ./out/arch/arm64/boot/Image \
-initrd ./initramfs.cpio.gz \
-append "console=ttyAMA0 loglevel=8 rdinit=/init"Exit QEMU:
Ctrl + A, then X
You are now running a custom ARM64 Linux kernel in QEMU on macOS.
To test a Static C Binary as your init process, you will replace the entire BusyBox userland with a single compiled executable. Since you are on a Mac (Apple Silicon), you can use your existing Docker container to cross-compile the C code for the Linux ARM64 target.
Create a file named hello.c in your workspace:
#include <stdio.h>
#include <unistd.h>
int main() {
printf("\n--- Starting Minimal Custom Init ---\n");
printf("Hello from a pure C static binary!\n");
printf("No BusyBox, no shell, just code.\n");
// The init process must never exit.
// If it does, the kernel will panic.
while(1) {
sleep(10);
}
return 0;
}You must compile this as a static binary so that it contains all its own library code. If you use a dynamic binary, the kernel will fail to boot because there is no linker (ld-linux) or libc.so in your empty filesystem.
Inside your Docker container, run:
gcc -static hello.c -o initNote: Ensure you are using the ARM64 cross-compiler inside the container.
You don't need any folders (/bin, /sbin, etc.) for this testβjust the init file.
# Move the compiled binary to a fresh directory
mkdir -p /work/minimal_init
cp /work/init /work/minimal_init/init
chmod +x /work/minimal_init/init# Create the cpio archive
cd /work/minimal_init
find . | cpio -ov --format=newc | gzip -9 > /work/minimal_init.cpio.gzBoot with QEMU Run the QEMU command from your Mac host, pointing to the new minimal_init.cpio.gz.
qemu-system-aarch64 \
-machine virt \
-cpu cortex-a72 \
-m 512 \
-nographic \
-kernel ./out/arch/arm64/boot/Image \
-initrd ./minimal_init.cpio.gz \
-append "console=ttyAMA0 rdinit=/init"Yocto lets you build a complete, reproducible embedded Linux distro from source β kernel, rootfs, packages β using BitBake recipes and layers.
Build core-image-minimal for qemuarm64 and boot it in QEMU on macOS.
- Docker Desktop (existing setup works)
- ~100 GB free disk (Yocto build caches are large)
- Poky reference distro (the starting point for all Yocto builds)
# Clone Poky (Yocto reference distro)
git clone --branch scarthgap https://git.yoctoproject.org/poky
cd poky
source oe-init-build-env build
# Set machine to QEMU AArch64
echo 'MACHINE = "qemuarm64"' >> conf/local.conf
# Build minimal image (~1-2 hrs first time)
bitbake core-image-minimal
# Boot in QEMU
runqemu qemuarm64 nographic| Concept | What it is |
|---|---|
| BitBake | The task execution engine (like make, but for recipes) |
Recipe (.bb) |
Describes how to fetch, patch, compile, and install one package |
Layer (meta-*) |
A collection of recipes/configs β stacked to build an image |
local.conf |
Your per-build settings (machine, distro, parallelism) |
bblayers.conf |
Which layers are active in this build |
core-image-minimal |
Smallest bootable image β shell + basic tools |
devshell |
Drop into a recipe's build env for debugging |
- Build
core-image-minimalforqemuarm64 - Add a custom
meta-mylayerwith a hello-world recipe - Add a custom kernel config fragment via a
.bbappend - Build
core-image-full-cmdlineand explore the rootfs - Port one of the kernel modules in
kernel-modules-practice/to a Yocto recipe