I have a drawer full of dead silicon. There’s a Raspberry Pi I shorted out in 2019, a BeagleBone Black that mysteriously stopped booting after a power surge, and an STM32 board that I’m pretty sure I physically stepped on. Hardware is fragile. It smells like ozone when you break it. And honestly? Waiting for shipping is the worst part of embedded development.
That’s why I stopped trying to do everything on physical hardware from day one. If you’re trying to learn embedded Linux, or just want to test a kernel module without risking your $80 dev board, you need to get comfortable with QEMU. Specifically, pairing it with Buildroot.
I spent the last weekend fighting with this setup again, and while it’s not exactly “plug-and-play” (nothing in Linux ever is, let’s be real), it’s a heck of a lot faster than flashing SD cards over and over. Here is how I actually set up a virtual ARM Cortex-A environment, build a custom Linux image from scratch, and debug apps remotely. No burnt silicon required.
Why Buildroot? (And Why Not Yocto?)
Look, I respect Yocto. It’s the industry standard for a reason. But using Yocto for a quick prototype or a learning exercise is like using a sledgehammer to crack a walnut. It’s heavy, the learning curve is a vertical wall, and the build times can be soul-crushing on a laptop.
Buildroot is different. It’s basically a giant pile of Makefiles that says, “Hey, I’ll build you a cross-compiler, a root filesystem, and a kernel. Go get a coffee.” It’s simpler. It uses kconfig (the same menu system the Linux kernel uses), which feels familiar if you’ve ever compiled a kernel. For this walkthrough, we want speed and simplicity, so Buildroot wins.
The Setup: Don’t Mess This Part Up
I’m running this on a standard Ubuntu LTS machine. If you’re on Windows, use WSL2. If you’re on a Mac… good luck, use a VM. You need a few dependencies before you start, or the build will fail three hours in, and you will want to throw your computer out the window. Trust me.
sudo apt-get update
sudo apt-get install -y sed make binutils build-essential gcc g++ bash patch gzip bzip2 perl tar cpio unzip rsync file bc wget python3 libncurses5-dev libssl-dev
Once that boring stuff is out of the way, grab the latest stable Buildroot. I usually stick to the LTS releases because I hate surprises.
git clone https://git.buildroot.net/buildroot
cd buildroot
git checkout 2024.02.x # Or whatever the latest stable tag is when you read this
Configuring the “Board”
We aren’t targeting a physical BeagleBone right now, though the concepts are identical (AM335x processor). We are targeting the QEMU ARM Versatile Express board (Cortex-A9). It’s well-supported and stable.
Buildroot comes with pre-made configs, which saves us hours of tweaking. I usually start here:
make qemu_arm_vexpress_defconfig
Now, we customize. Run make menuconfig. This is where the magic happens. You’ll see the retro blue-and-gray interface.
Here is what I always change immediately:
- Toolchain: Ensure “Enable WCHAR support” is on. Python and other tools freak out without it.
- System configuration: Set a root password. By default, it’s empty, which is fine for dev, but I like setting it to “root” just to stop the console from complaining.
- Target packages: This is the candy store. Under “Interpreter languages and scripting”, I usually grab Python 3. Under “Networking applications”, I might grab
dropbear(SSH server) if I want to SSH in later. - Build options: Check “build packages with debugging symbols”. We are doing this to learn debugging, right? Don’t skip this.
Save and exit.
The Long Wait
Type make.
Now, go do something else. Depending on your CPU, this takes anywhere from 20 minutes to “leave it running overnight.” Buildroot is downloading the Linux kernel source, uClibc (or glibc), BusyBox, and every package you selected, and compiling them from scratch. It’s compiling a cross-compiler first, then using that to compile the rest. It’s impressive, but slow.
If it errors out (and it might), read the error log. 99% of the time, it’s a missing host dependency I forgot to install. Google the error, install the missing package, and run make again. It picks up where it left off.
Booting the Ghost Machine
Once the terminal finally says “Images successfully generated,” check the output/images directory. You should see zImage (the kernel) and rootfs.ext2 (the filesystem).
Here is the command to boot it. I always put this in a shell script called run_qemu.sh because I am physically incapable of remembering QEMU flags.
#!/bin/bash
qemu-system-arm \
-M vexpress-a9 \
-m 256 \
-kernel output/images/zImage \
-dtb output/images/vexpress-v2p-ca9.dtb \
-drive file=output/images/rootfs.ext2,if=sd,format=raw \
-append "root=/dev/mmcblk0 console=ttyAMA0" \
-nographic \
-net nic -net user,hostfwd=tcp::2222-:22
Run that script. If the gods are smiling, you’ll see the kernel boot logs scrolling by. It’s fast—way faster than a real board. Eventually, you’ll hit the login prompt. Type root and you’re in.
Side note: To kill QEMU when you’re stuck in the console, press Ctrl+A then x. I unplugged my computer the first time because I couldn’t figure out how to exit. Don’t be me.
Remote Debugging: The Killer Feature
Okay, booting Linux is cool, but developing on it is the goal. We want to write code on our host machine (where we have VS Code, decent fonts, and Spotify) and run it on the target.
You need to ensure gdbserver is installed on your target image. Back in make menuconfig, go to Toolchain and select “Build cross gdb for the host” and ensure “gdbserver” is selected for the target.
Rebuild (it’ll be fast this time).
Let’s say you have a simple C program hello.c:
#include <stdio.h>
int main() {
int i = 0;
for(i = 0; i < 5; i++) {
printf("Hello Embedded World %d\n", i);
}
return 0;
}
Compile this using the cross-compiler Buildroot made for you. It lives in output/host/bin.
./output/host/bin/arm-linux-gcc -g hello.c -o hello
Now, how do we get the file onto the QEMU instance? Since we configured networking in the QEMU command (-net user), we can use SCP if we added Dropbear, or we can just rebuild the filesystem image with the file included using a RootFS overlay. For speed, I usually just mount the image locally or use Python’s simple HTTP server to download it from within the QEMU guest.
Once the binary is on the target, start gdbserver on the QEMU instance:
# On the QEMU guest
gdbserver :1234 hello
Now, on your host machine, you connect to it. Note: You need to forward the port in your QEMU command (add -net user,hostfwd=tcp::1234-:1234 to the script above).
# On your Host machine
./output/host/bin/arm-linux-gdb hello
Inside GDB:
(gdb) target remote :1234
(gdb) break main
(gdb) continue
Boom. You are stepping through code running on a simulated ARM processor from your x86 workstation. You can inspect registers, watch variables, and crash the kernel without ever touching a piece of hardware.
Why This Matters
This workflow—Buildroot + QEMU + GDB—is the bread and butter of embedded Linux. It separates “hardware problems” from “software problems.” If your code crashes here, it’s your code. If it works here but crashes on the physical board later, it’s a driver or power issue.
I still fry boards occasionally. I’m clumsy. But at least now I know my software works before I let the magic smoke out.




