Most people boot a Raspberry Pi with a pre-built image.

You download it, flash it, done.

But what if you want to understand what is actually running on your hardware?

What if you need to control every single component of your system?

This tutorial walks through building a minimal Linux system for the Raspberry Pi 1B Rev 2 from scratch using Buildroot 2024.02 LTS.

By the end, you will have a working Linux Kernel 6.6 image ready to flash on an SD card for your embedded work.

What we need

Buildroot

Buildroot is an open source tool that automates the construction of a complete embedded Linux system.

Given a configuration file, it downloads, cross-compiles and assembles all the components of the system: the cross-compilation toolchain, the Linux kernel, the root filesystem and the bootloader.

The key word here is cross-compilation.

Your development machine runs on x86_64.

The Raspberry Pi 1B runs on ARMv6.

Cross-compilation is the process of compiling code on one architecture (x86_64) that will run on a different architecture (ARMv6).

SoC

The Raspberry Pi 1B is built with the BCM2835, a SoC (System on Chip) made by Broadcom (BCM stands for Broadcom).

A SoC integrates on a single chip what would otherwise require multiple separate components: the CPU, the GPU, the RAM controller, the USB controller, the GPIO controller, the UART, the SPI bus, the I2C bus, and more.

This is different from a MCU (Microcontroller Unit) like the STM32.

A MCU is also a SoC, but designed for low-power real-time control with limited resources.

The BCM2835 is an application processor SoC, powerful enough to run a full Linux kernel.

The four layers of an embedded Linux system

Before touching a single file, it helps to understand what you are building.

An embedded Linux system has four layers, loaded in sequence at boot time:

Bootloader

The bootloader is the first code executed by the hardware after power-on.

It initializes the hardware at a low level, loads the kernel into RAM and hands control over to it.

On the Raspberry Pi, the bootloader consists of closed-source firmware files provided by the Raspberry Pi Foundation: bootcode.bin, start.elf and fixup.dat.

Kernel

The kernel is the core of the Linux operating system.

It manages hardware resources (CPU, memory, peripherals) and provides services to user-space programs.

It is distributed as a compressed binary called zImage on ARM 32-bit systems.

Device tree

The Device tree is a data structure that describes the hardware layout to the kernel.

The kernel cannot auto-detect what peripherals are present on an embedded board the way a PC BIOS does.

The Device Tree Source (.dts) is a human-readable description of the hardware that gets compiled into a binary Device Tree Blob (.dtb).

At boot time, the bootloader loads both the kernel and the DTB into RAM.

The kernel reads the DTB to know what hardware it has to drive.

Root filesystem (rootfs)

The rootfs is the filesystem mounted at / that contains everything the system needs to run after the kernel has booted:

  • init process
  • libraries
  • configuration files
  • user-space utilities

Without a rootfs, the kernel panics immediately after boot with the message Kernel panic: not syncing: No working init found.

BusyBox

On a standard desktop Linux distribution like Ubuntu, each command-line utility is a separate binary maintained by the GNU project: ls comes from coreutils, mount comes from util-linux, init is systemd.

These are complete, feature-rich implementations that can weigh hundreds of megabytes in total.

BusyBox is a completely independent reimplementation of around 300 Unix utilities, written from scratch in C with embedded systems in mind.

The entire collection is compiled into a single binary of a few hundred kilobytes.

Each utility is a simplified version of its GNU counterpart, covering the essential options but nothing exotic.

The trick is elegant: BusyBox creates symbolic links for each utility pointing back to the single busybox binary.

With BusyBox, when you type ls, the shell executes /bin/ls, which is a symbolic link to /bin/busybox.

BusyBox reads argv[0] (the name it was called with), sees ls, and runs the corresponding code.

/bin/busybox       <- the real binary
/bin/ls -> busybox
/bin/cp -> busybox
/sbin/mount -> busybox
/sbin/init -> busybox

The Buildroot build process

What Buildroot compiles and in what order

The build follows a strict sequence.

Buildroot tracks each completed step with stamp files (empty files) created in output/build/<package>/.

A stamp file named .stamp_built means the package has been compiled.

If you delete a package directory, its stamps disappear and Buildroot recompiles it.

If you only delete the compiled files but leave the stamps, Buildroot considers the package done and skips it.

The full sequence for our configuration:

1. Host tools: utilities that run on your x86_64 host and are needed to build everything else.

Examples include m4, autoconf, automake, bison, flex, pkgconf.

These are installed in output/host/bin/.

2. Cross-compilation toolchain: this is the most time-consuming step.

Buildroot compiles a complete GCC toolchain capable of producing ARMv6 code from your x86_64 host.

This is called bootstrapping the toolchain: using an existing compiler to build a new compiler capable of targeting a different architecture.

The process runs in two passes:

  • host-gcc-initial: a minimal cross-compiler, just enough to compile the C library
  • glibc: the GNU C library compiled for ARMv6
  • host-gcc-final: the full cross-compiler, now built against the ARMv6 glibc

The result is arm-buildroot-linux-gnueabi-gcc installed in output/host/bin/.

This is the compiler used for everything else.

3. Linux kernel: Buildroot downloads the kernel sources, applies the bcm2835_defconfig configuration and compiles:

  • zImage: the compressed kernel binary
  • bcm2835-rpi-b-rev2.dtb: the Device Tree Blob for the Raspberry Pi 1B Rev 2

4. BusyBox: compiled with the ARMv6 cross-compiler and installed into output/target/.

5. RPi firmware: the closed-source bootloader files (bootcode.bin, start.elf, fixup.dat, config.txt) from the Raspberry Pi Foundation, installed into output/images/rpi-firmware/.

6. Root filesystem image: Buildroot assembles output/target/ into rootfs.ext2.

This file is not downloaded: it is generated locally by formatting everything in output/target/ into an ext4 image.

At boot time, the kernel reads this file and mounts it as / exposing its contents as the familiar Linux directory tree: /bin, /etc, /lib, /sbin and so on.

7. SD card image: the post-image.sh script uses genimage to produce sdcard.img with two partitions:

  • a FAT32 boot partition containing the firmware and the kernel
  • an ext4 rootfs partition.

The build-time log

Every step is recorded in output/build/build-time.log with a Unix timestamp, a start/end marker, the step name and the package name:

1781696339.105:start:download  : host-binutils
1781696339.290:end  :download  : host-binutils
1781696339.327:start:extract   : host-binutils
1781696350.137:end  :extract   : host-binutils
1781696350.169:start:patch     : host-binutils
1781696350.663:end  :patch     : host-binutils
1781696350.715:start:configure : host-binutils
1781696356.054:end  :configure : host-binutils
1781696356.099:start:build     : host-binutils
1781696483.524:end  :build     : host-binutils
1781696483.563:start:install-host: host-binutils
1781696485.279:end  :install-host: host-binutils

Each package goes through the same six phases:

  • download: fetch the source archive into dl/<package>/
  • extract: decompress the archive into output/build/<package>/
  • patch: apply any Buildroot patches to the sources
  • configure: run ./configure to detect the build environment
  • build: compile the sources
  • install-host or install-target: copy the result to output/host/ or output/target/

Build time reference

A complete first build processes 46 packages across 283 steps.

Of those 46 packages, 32 are host packages (tools that run on x86_64 to build the system) and 14 are target packages (what actually ends up on the Raspberry Pi).

In other words, 70% of the compilation effort produces tools that never touch the SD card.

The time distribution across the main components:

  • Toolchain (gcc-initial + glibc + gcc-final): 54% of total build time
  • host-gcc-final alone: 30%
  • Linux kernel: 17%
  • host-gcc-initial: 16%
  • glibc: 9%
  • BusyBox: 1%
  • Everything else: 3%

These percentages are machine-independent.

The absolute duration varies widely depending on available CPU cores, RAM, and disk speed, but the proportions stay consistent.

The first build is the longest.

On subsequent builds, Buildroot uses the source cache in dl/ and the stamp files to skip everything that has already been compiled.

Project structure

badprog@d24cde653cba:/workspaces/embedded-linux$
├── .devcontainer/
│   ├── devcontainer.json
│   └── Dockerfile
├── buildroot-config/
│   └── rpi1b_minimal_defconfig
├── scripts/
│   └── build.sh
├── .github/
│   └── workflows/
│       └── ci.yml
└── .gitignore

The devcontainer

Dockerfile

The Dockerfile builds the build environment on top of debian:bookworm-slim.

Debian Bookworm is the recommended host distribution for Buildroot.

The -slim variant removes unnecessary packages to keep the image lean.

Buildroot 2024.02.9 LTS is downloaded and extracted into /opt/buildroot/ during the image build. Buildroot does not need to be installed: it is just a collection of Makefiles and scripts.

The user badprog (UID 1000) owns /opt/buildroot/ so the build runs entirely without root privileges.

Buildroot never requires root to compile.

devcontainer.json

The workspaceMount entry explicitly defines how VSCode mounts the project into the container.

Without it, VSCode generates an automatic mount that can conflict with other mounts and cause a Duplicate mount point error.

The workspaceFolder entry tells VSCode which directory to open in the Explorer and use as the default terminal working directory (whatever is the real project name outside the container).

The Buildroot configuration

The file buildroot-config/rpi1b_minimal_defconfig is the heart of the project.

It tells Buildroot exactly what to build and for which target.

All BR2_* options are standard Buildroot configuration variables.

They correspond directly to options in make menuconfig.

The BR2 prefix stands for Buildroot 2, the current generation of the tool.

Architecture options

BR2_ARM_EABIHF=y selects the Hard Float ABI.

The hf suffix means function arguments of floating-point type are passed directly in VFP (Vector Floating Point) registers, rather than in general-purpose integer registers.

This requires the VFPv2 unit present in the ARM1176JZF-S and produces faster floating-point code.

Toolchain: internal vs external

Two options exist for the cross-compilation toolchain.

An internal toolchain (BR2_TOOLCHAIN_BUILDROOT=y) means Buildroot downloads the sources of gcc, binutils and glibc and compiles them itself.

This is the recommended approach when targeting a specific architecture like ARMv6 with VFPv2, because it guarantees the compiler is built with exactly the right flags.

An external toolchain means you provide a pre-compiled cross-compiler.

This is faster but requires finding one that matches your target precisely.

Generic ARM toolchains often target ARMv7 or use different VFP flags, which can produce binaries that crash on the Raspberry Pi 1B.

Kernel configuration

BR2_LINUX_KERNEL_DEFCONFIG="bcm2835" selects the kernel configuration.

Buildroot appends _defconfig to this value and looks for arch/arm/configs/bcm2835_defconfig inside the kernel sources.

This defconfig covers all BCM2835-based boards including the Raspberry Pi 1B.

The older bcmrpi_defconfig that appears in many tutorials was removed from the mainline kernel and does longer exist in Linux 6.6.

Using it causes the build to fail with Can't find default configuration "arch/arm/configs/bcmrpi_defconfig".

BR2_LINUX_KERNEL_INTREE_DTS_NAME="broadcom/bcm2835-rpi-b-rev2" selects the Device Tree for the Raspberry Pi 1B Rev 2.

In Linux 6.6, the Raspberry Pi Device Tree files were moved into a broadcom/ subdirectory.

The full path from the kernel DTS root is arch/arm/boot/dts/broadcom/bcm2835-rpi-b-rev2.dts.

The broadcom/ prefix is mandatory: omitting it causes the build to fail with No rule to make target.

To identify your Raspberry Pi revision, run on a booted system:

$ cat /proc/device-tree/model

RPi firmware

BR2_PACKAGE_RPI_FIRMWARE_CONFIG_FILE="board/raspberrypi/config_default.txt" copies Buildroot’s default config.txt into the boot partition.

This file tells the Raspberry Pi bootloader where to find the kernel:

start_file=start.elf
fixup_file=fixup.dat
kernel=zImage

Without this option, config.txt is not generated.

The post-image.sh script reads config.txt to determine which kernel file to include in the SD card image, and fails with sed: can't read config.txt: No such file or directory.

The build script

WORKSPACE_DIR is computed dynamically from the script location: the project works wherever it is cloned.

BR2_DL_DIR redirects the source download cache to dl/ inside the workspace.

Inside the Docker container, the default Buildroot download directory /opt/buildroot/dl/ is owned by root and the build user cannot write to it.

Using a directory inside the workspace solves the permission issue since the workspace bind mount is owned by badprog.

BR2_ROOTFS_OVERLAY points to the overlay directory.

Passing it on the make command line rather than hardcoding a path in the defconfig keeps the defconfig portable.

The dl/ directory is listed in .gitignore since it can contain several gigabytes of downloaded source archives.

Building

Open the project in VSCode, reopen in container, then:

$ bash scripts/build.sh

The first build takes between 60 and 90 minutes depending on your machine.

Subsequent builds are much faster thanks to the source cache in dl/ and Buildroot’s stamp system.

Output

At the end of a successful build, output/images/ contains:

  • bcm2835-rpi-b-rev2.dtb (19K): Device Tree Blob for the Raspberry Pi 1B Rev 2
  • boot.vfat (32M): FAT32 boot partition image
  • rootfs.ext2 (120M): ext4 root filesystem image
  • rootfs.ext4: symlink to rootfs.ext2
  • rootfs.tar (9.5M): root filesystem as tar archive
  • rpi-firmware/: bootloader files
  • sdcard.img (153M): complete SD card image
  • zImage (6.4M): compressed Linux kernel

Storage on the Raspberry Pi 1B

The Raspberry Pi 1B has no internal storage.

The SD card is both the boot medium and the only storage available to the system.

Once booted, the entire Linux system runs from the SD card.

The sdcard.img produced by this build creates two partitions:

  • a 32M FAT32 boot partition containing the firmware and the kernel
  • a 120M ext4 rootfs partition containing BusyBox, glibc and the system files

The rest of the SD card capacity remains unallocated and is not accessible from the running system.

On a 7.4G card, that means roughly 7G of unused space.

For this minimal system, 120M is more than enough.

If you need more space, for example to store data or add packages, adjust BR2_TARGET_ROOTFS_EXT2_SIZE in the defconfig before building:

BR2_TARGET_ROOTFS_EXT2_SIZE="512M"

Then rebuild and reflash.

Buildroot will produce a larger rootfs partition accordingly.

Flashing the SD card

Insert the SD card and identify its device with lsblk:

$ lsblk
NAME   MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
sda      8:0    0 356.9M  1 disk
sdb      8:16   0 159.5M  1 disk
sdc      8:32   0     2G  0 disk [SWAP]
sdd      8:48   0     1T  0 disk /
sde      8:64   1     0B  0 disk
sdg      8:96   1   7.4G  0 disk
├─sdg1   8:97   1   512M  0 part
└─sdg2   8:98   1   6.9G  0 part

Note that the partition sizes shown here (512M for boot and 6.9G for rootfs) reflect a previous image already on the card from another project.

So, after flashing, the new partitions will be 32M for boot and 120M for rootfs.

The SD card, in the list, is the removable disk with a size matching your card.

In this example it is sdg.

Then flash the image from your Linux host (not from the Dev Container):

$ sudo dd if=output/images/sdcard.img of=/dev/sdg bs=4M status=progress

Replace /dev/sdg with your actual SD card device.

Here is what each parameter does:

  • if=output/images/sdcard.img: the input file to read, our SD card image
  • of=/dev/sdg: the output device. Writing to /dev/sdg directly (not /dev/sdg1) writes from the absolute start of the disk, partitions included
  • bs=4M: block size of 4 MB per read/write operation. The default is 512 bytes which is significantly slower
  • status=progress: displays real-time progress, bytes written, speed and elapsed time

Once dd completes, run the following command:

$ sync

sync forces the kernel to flush all write buffers to the physical device.

Without it, data could still be in RAM cache when you unplug the card.

Rootfs overlay

Buildroot generates /etc/inittab from a default template that only configures a getty on the serial port.

A getty is the program responsible for opening a terminal, displaying the login prompt and handing control to the shell once the user is authenticated.

The boot sequence goes:

init -> getty -> login -> shell

Without a getty on tty1, the login prompt never appears on the HDMI screen.

tty stands for TeleTYpe, the historical name for text terminals under Unix.

Linux creates several numbered virtual terminals: tty1, tty2, tty3 and so on.

tty1 is the one displayed on the main screen (HDMI). tty0 is an alias that always points to the currently active terminal.

The serial port uses a different naming convention: ttyAMA0 on the Raspberry Pi (ARM AMBA UART).

To configure a getty on tty1 without modifying Buildroot’s internal files, use a rootfs overlay.

A rootfs overlay is a directory in the project whose contents are copied over the generated rootfs at the end of the build.

Any file in the overlay replaces its counterpart in output/target/.

The overlay is declared in the defconfig:

BR2_ROOTFS_OVERLAY="$(TOPDIR)/../buildroot-config/overlay"

The custom inittab lives at buildroot-config/overlay/etc/inittab.

It is identical to the Buildroot default template with one additional line:

tty1::respawn:/sbin/getty -L tty1 0 vt100

This tells BusyBox init to start a getty on tty1 at boot and restart it automatically if it exits.

Booting

Insert the SD card into the Raspberry Pi 1B, connect an HDMI screen and a USB keyboard, then power on.

The kernel boot log scrolls on screen.

After a few seconds the login prompt appears:

Welcome to BadproG GNU/Linux
badprog-rpi login:

Log in as root with no password.

The system is ready.

Conclusion

Building a complete Linux system from source gives a precise understanding of what runs on the hardware and why.

Every component has a clear role in the chain: the cross-compiler, the C library, the kernel, the Device Tree, the root filesystem.

Buildroot automates the entire build pipeline while leaving every configuration choice explicit and under control.

Good job, you did it.