So, you have a classic Raspberry Pi 1B (Rev 2) laying around, and baking a standard Raspberry Pi OS image feels too bloated?
Welcome to the ultimate minimalist kitchen.
Today, we are compiling a custom, lightweight embedded Linux system from scratch using Buildroot.
We will configure the essentials: the Toolchain, Bootloader, Linux Kernel (6.6), a custom Device Tree, and the Root Filesystem (Rootfs).
Then we will pack it all into a flashable sdcard.img.
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
Setting up the workshop
Running raw make commands from the wrong directory can mess up your workspace, whatever system you are on.
To keep things pristine, we use the -C flag to point to the Buildroot core source code directory.
We also use O= to redirect all generated files into our local output folder.
First of all, we assume you are in the right directory, that’s:
/workspaces/embedded-linux
Then:
$ make -C /opt/buildroot O=$(pwd)/output menuconfig
Here $(pwd) is equal to /workspaces/embedded-linux but it’s easier to use in a command line.
menuconfig
menuconfig is Buildroot’s interactive text-based configuration tool.
It is a navigable menu where every feature, package and target option can be turned on or off, without ever editing a configuration file by hand.
Every choice you make in this menu gets written directly into output/.config, the single file that drives everything Buildroot will compile next.
Once the blue-and-grey menu pops up, it’s time to toggle the switches.
Here is the exact blueprint to build a rock-solid system for our ARMv6 hardware.
Target Options for the ARMv6 Blueprint
The Pi 1B runs on an old-school Broadcom ARM11 processor.
Getting this wrong means instant death at boot.
Target Architecture:ARM (little endian)Target Architecture Variant:arm1176jzf-sTarget ABI:EABIhf
The toolchain orientation: internal vs external
Buildroot offers two strategies here.
menuconfig can silently switch you from one to the other if you are not paying close attention while clicking through unrelated menus.
An internal toolchain (Buildroot toolchain) means Buildroot downloads gcc, binutils and glibc sources and compiles them itself.
This guarantees the compiler is built with exactly the right ARMv6 flags.
This is the slow but fully controlled path, and the one used for this build:
Toolchain type:Buildroot toolchain (Internal)C library:glibc[*] Enable C++ support(Essential if you plan to run C++ binaries or middleware like ROS 2 later)
An external toolchain (for example Bootlin) downloads a pre-built cross-compiler instead of building one locally.
This saves a significant amount of compile time.
We tested this path and the resulting zImage, the Device Tree Blob and config.txt all checked out as individually valid (correct file types, correct sizes, correct GPU memory split, correct kernel options).
Despite all these checks passing, the board displayed the GPU diagnostic rainbow screen and never reached the kernel boot log.
We were not able to conclusively isolate the toolchain as the root cause without a side-by-side rebuild.
If you switch back to the internal toolchain after having used an external one, expect Buildroot to rebuild host-gcc-initial, glibc, host-gcc-final, the kernel and BusyBox from scratch.
Changing the toolchain invalidates everything linked against it, so this is effectively as long as a first build.
While you are in Build options, enable the compiler cache to speed up future rebuilds that touch the toolchain:
Enable compiler cache: yes
Compiler cache location: a writable directory on your host, for example /workspaces/common/ccache if you want it shared across multiple devcontainers and projects rather than rebuilt from scratch every time.
To keep this path generic across machines, point your devcontainer.json mounts at an environment variable instead of a hardcoded host path, for example ${localEnv:BUILDROOT_SHARED_DIR}/ccache.
Set BUILDROOT_SHARED_DIR once in your own ~/.bashrc, for example:
export BUILDROOT_SHARED_DIR="$HOME/my-path/buildroot-shared"
Then create the folders it points to:
$ mkdir -p $BUILDROOT_SHARED_DIR/dl $BUILDROOT_SHARED_DIR/ccache
This way, anyone cloning this repo only has to set that one variable on their own machine, without ever touching devcontainer.json itself.
System Configuration & post-image scripts
We don’t want to partition our SD card manually with fdisk.
We want Buildroot to automatically build a finished .img file.
Custom scripts to run after creating filesystem images:board/raspberrypi/post-image.sh
While you are in System configuration, two more fields are worth customizing here directly:
-
System hostname: set this to whatever you like, for examplebadprog-rpiinstead of the defaultbuildroot. -
/etc/issue message: this is your login banner, for exampleWelcome to BadproG BusyBox/Linux.
Note the wording here on purpose: this system runs BusyBox, not the GNU coreutils, so calling it “GNU/Linux” would be misleading.
“BusyBox/Linux” is the technically accurate term for what you are actually building.
Root filesystem overlay directories: leave this field empty.
We cover why below.
Linux Kernel & the Critical Device Tree (Kernel 6.6+ trap)
Modern Linux kernels organize Device Tree Source (.dts) files into subdirectories by manufacturer, for example broadcom/ for Raspberry Pi boards.
Missing this manufacturer prefix means Buildroot won’t find the hardware layout.
[*] Build a Device Tree Blob (DTB)In-tree Device Tree Source file names:broadcom/bcm2835-rpi-b-rev2
(Note the broadcom/ prefix and the -rev2 suffix, which handles the updated GPIO/I2C pin mapping of the Rev 2 board).
Target Packages -> Hardware Handling -> Firmware
The Raspberry Pi is weird: its GPU actually boots before the CPU.
It needs proprietary Broadcom firmware files on the SD card to launch.
[*] rpi-firmwareVariant:rpi 0/1/2/3 (default)Path to a file stored as boot/config.txt:board/raspberrypi/config_default.txt
Host Utilities
To assemble the final image, Buildroot triggers tools to format a FAT partition and copy files.
If your host PC doesn’t have them installed, the build crashes.
Instead of installing random packages on your host OS, make Buildroot compile its own autonomous host tools:
[*] host dosfstools(Providesmkdosfsto create the FAT boot partition)[*] host e2fsprogs(Provides the tools needed to build the ext2/3/4 rootfs image)[*] host mtools(Providesmcopyto inject files into the FAT partition without root privileges)[*] host genimage(The orchestrator that glues the partitions into the final.imgfile)[*] host kmod(Providesdepmod, needed to finalize the kernel module tree even on a minimal system)
Filesystem Images
Buildroot’s default rootfs format is a plain tar archive, not a mountable filesystem.
If you only configure the kernel, the firmware and the host tools above without touching this section, the build will complete without a single error.
But output/images/ will only contain rootfs.tar: no rootfs.ext2, no boot.vfat, no sdcard.img.
post-image.sh and genimage simply have nothing to package without an actual filesystem image to embed.
So under Filesystem images, select:
[*] ext2/3/4 root filesystem
Despite the name, this single option covers all three generations: ext2, ext3 (which adds journaling on top of ext2) and ext4 (which adds extents and larger size limits on top of ext3).
Press Enter on it to open its sub-menu:
Filesystem revision:4(this is what actually gives you ext4, not just ext2)exact size:120M(or whatever your card and rootfs content need. For example the60Msize is Buildroot’s tiny default and will not leave much room to spare)
If you ever find yourself with a clean build log but a missing sdcard.img, this is the first thing to check:
$ grep "BR2_TARGET_ROOTFS" output/.config
If BR2_TARGET_ROOTFS_EXT2 shows up as is not set, that is your answer.
Rootfs Overlay
Here is a trap nobody warns you about.
Once your board boots, you might stare at a perfectly healthy kernel log on your HDMI screen, and then nothing.
No login prompt, ever.
The system is actually alive and waiting, but BusyBox’s default inittab only spawns a getty on the serial console, not on tty1 (the HDMI framebuffer console).
The fix is a rootfs overlay: a directory whose contents get copied on top of the generated rootfs during the final target-finalize step, after the toolchain, kernel and BusyBox are already built.
Overlays only touch files inside the final Linux system (things like /etc/inittab).
This is the opposite of a defconfig, which only steers what Buildroot compiles and never ends up on the SD card itself.
To add an overlay let’s do the following:
- create a new file:
buildroot-config/overlay/etc/inittab - copy BusyBox’s default template from
/opt/buildroot/package/busybox/inittabinto your newinittabfile - and add this line to your
inittab:
tty1::respawn:/sbin/getty -L tty1 0 vt100
This file alone does nothing yet: it is just a plain file sitting in your project folder.
Buildroot only copies it into the rootfs if BR2_ROOTFS_OVERLAY points to the folder that contains it.
The path you give to that variable matters a lot.
Writing it directly inside the defconfig using $(TOPDIR) is tempting but broken:
So doing this is not what we want:
BR2_ROOTFS_OVERLAY="$(TOPDIR)/../buildroot-config/overlay"
Indeed, $(TOPDIR) resolves to /opt/buildroot, the Buildroot source tree itself, not your project workspace.
The path above silently resolves to a non-existent location and the build fails at target-finalize with an rsync change_dir error.
The clean fix is to pass BR2_ROOTFS_OVERLAY as a make argument from your build script instead, computed dynamically from the script’s own location.
This is what we do in the script/build.sh file:
WORKSPACE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
OVERLAY_DIR="${WORKSPACE_DIR}/buildroot-config/overlay"
make -C /opt/buildroot O="${WORKSPACE_DIR}/output" BR2_ROOTFS_OVERLAY="${OVERLAY_DIR}"
This keeps the defconfig itself fully portable: it works no matter where the project is cloned, since the path is resolved at build time rather than hardcoded.
One subtlety worth remembering: the overlay is applied at the very last step of the build, after everything else is already compiled.
If you rebuild without the overlay argument by mistake, every package will still show up as already built (stamps are untouched), but your inittab change will be silently absent from the final image.
Re-running with the correct BR2_ROOTFS_OVERLAY argument is fast in that case: Buildroot skips straight to target-finalize since nothing else changed.
Compilation optimization
Save your configuration and exit menuconfig.
Before kicking off the build, let’s optimize compile times using parallel execution.
$ make -C /opt/buildroot O=$(pwd)/output -j$(nproc)
Tips for incremental updates
Buildroot tracks every completed build step with stamp files inside output/build/<package>/.
A file named .stamp_built means that package is considered done, and make will skip it entirely on the next run, even if you change something that should invalidate it.
Deleting a package’s whole directory under output/build/ removes its stamps and forces a clean rebuild of that package alone, without touching anything else already compiled.
This is the mechanism behind every trick below.
This same logic applies to the final image files, not just package directories.
If you edit a file directly inside output/target/ (for example /etc/issue), Buildroot has no way of knowing that rootfs.ext2 needs to be regenerated.
This is because rootfs.ext2 is the actual filesystem image that gets flashed onto the SD card, built from a snapshot of output/target/ at generation time.
If you edit a file in output/target/ after that image was last generated, the change exists on disk but is not yet included inside rootfs.ext2.
Compare the timestamps to check whether this is the case:
$ ls -la output/images/rootfs.ext2
$ ls -la output/target/etc/issue
If the image is older than your edit, delete the generated images so the next build step is forced to rebuild them from the current output/target/:
$ rm output/images/rootfs.ext2
$ rm output/images/rootfs.ext4
$ rm output/images/sdcard.img
This regeneration step is fast (seconds to about a minute), since it only reassembles the rootfs image and re-runs genimage, without recompiling the toolchain, kernel or BusyBox.
Modified a Kernel/DTB sub-option?
Buildroot uses stamp files and won’t notice.
Force it to rebuild the kernel and regenerate the DTB layout without rebuilding the whole OS:
$ make -C /opt/buildroot O=$(pwd)/output linux-rebuild
Firmware file gone wrong?
Force-reinstall it with:
$ make -C /opt/buildroot O=$(pwd)/output rpi-firmware-reinstall
Need to load a defconfig that lives outside Buildroot’s own configs/ folder?
Pointing make directly at a defconfig name without the defconfig target does absolutely nothing, silently.
The correct syntax requires the explicit target:
$ make -C /opt/buildroot O=$(pwd)/output BR2_DEFCONFIG="/absolute/path/to/your_defconfig" defconfig
Without the trailing defconfig target, BR2_DEFCONFIG is accepted as a variable but never actually triggers a load.
Your build silently keeps using whatever output/.config already had.
Loading a partial defconfig that only contains one or two lines may be a problem.
defconfig does not merge with what is already in output/.config, it resets every option not explicitly listed in that file back to its default.
This is exactly how the missing ext2/3/4 filesystem trap happens in practice: a minimal defconfig that only sets BR2_TARGET_GENERIC_ISSUE silently drops BR2_TARGET_ROOTFS_EXT2 back to disabled, while BR2_TARGET_ROOTFS_TAR (the Buildroot default) stays enabled.
Always prefer loading the full, validated defconfig over small partial ones to avoid this kind of silent option loss.
Flashing
Once the compilation finishes, your golden artifact is waiting for you at output/images/sdcard.img.
Here is how to flash it directly from a terminal.
Identify the block device
Switch over to your Ubuntu terminal (not your Dev Container) and locate the disk identity:
$ lsblk
Identify your SD card by size (e.g., sdb for a 4/16/32GB card).
CRITICAL WARNING: Always target the raw block device (e.g., /dev/sdb).
Never target a partition number (e.g., /dev/sdb1), or you will corrupt the partition table.
Burn it down with dd
From your native Linux distribution (not the Dev Container), navigate to your image directory and stream the raw bits onto the card:
$ cd ~/your-project-path-on-your-real-system/output/images/
Then flash it:
$ sudo dd if=sdcard.img of=/dev/sdb bs=4M status=progress oflag=sync
(Note on oflag=sync: This forces dd to write data synchronously block-by-block.
The progress bar reflects reality in real-time, meaning that when it hits 100%, your card is safe to pull immediately!)
If a partition refuses to mount afterwards
If you ever mount a partition for inspection and later get a mount: Structure needs cleaning error, the filesystem got left in an inconsistent state, usually from an interrupted write or an improper unmount on a previous flash.
A good option in this case is to flash again sdcard.img from scratch rather than trusting the repaired partition.
The moment of truth
Eject the card, slide it into the Raspberry Pi 1B slot, plug a screen with HDMI cable and power with the micro-USB cable.
With the internal toolchain and the overlay correctly wired in, the system boots past the GPU initialization, loads your custom compiled Linux kernel, mounts your custom rootfs.
It presents a clean, ultra-fast, lightweight login prompt directly on your HDMI screen: no serial cable required.
Saving your configuration for good
Once your menuconfig session produces a working sdcard.img, save it as a compact, versionable defconfig before doing anything else.
output/.config itself is not meant to be committed: it is a huge, fully expanded file with thousands of lines, including every option left at its Buildroot default.
A defconfig only keeps the options that actually differ from those defaults, which makes it short, readable, and easy to review in a Git diff.
Generate one with:
$ make -C /opt/buildroot O=$(pwd)/output savedefconfig BR2_DEFCONFIG=$(pwd)/buildroot-config/badprog_rpi1b_defconfig
This is what makes a non-interactive CI build possible: a GitHub Actions runner has no terminal to click through menuconfig with, so it needs this exact file to reproduce your configuration automatically with BR2_DEFCONFIG=... defconfig, the same syntax covered above.
Conclusion
You now have a fully custom Linux system running on a fifteen-year-old board, built piece by piece rather than downloaded as a finished image.
Every choice along the way is visible and traceable: the exact toolchain, the exact kernel defconfig, the exact filesystem, the exact files in the rootfs.
None of it required hand-editing a single configuration file: every option was set through menuconfig, one screen at a time.
This is the real trade-off of embedded Linux work: more upfront configuration, in exchange for full control over what actually ends up on the hardware.
Good job, you did it!