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
- GitHub for this tutorial:
- Raspberry Pi 1B Rev 2 (ARM1176JZF-S, ARMv6, 512 MB RAM)
- SD card (4 GB minimum)
- Docker
- VSCode with the Dev Containers extension
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 libraryglibc: the GNU C library compiled for ARMv6host-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 binarybcm2835-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 intodl/<package>/extract: decompress the archive intooutput/build/<package>/patch: apply any Buildroot patches to the sourcesconfigure: run./configureto detect the build environmentbuild: compile the sourcesinstall-hostorinstall-target: copy the result tooutput/host/oroutput/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-finalalone: 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 2boot.vfat(32M): FAT32 boot partition imagerootfs.ext2(120M): ext4 root filesystem imagerootfs.ext4: symlink to rootfs.ext2rootfs.tar(9.5M): root filesystem as tar archiverpi-firmware/: bootloader filessdcard.img(153M): complete SD card imagezImage(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 imageof=/dev/sdg: the output device. Writing to/dev/sdgdirectly (not/dev/sdg1) writes from the absolute start of the disk, partitions includedbs=4M: block size of 4 MB per read/write operation. The default is 512 bytes which is significantly slowerstatus=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.