Porting Linux to RISC-V: Building a 64-bit System from Scratch on QEMU
Cross-compiling a RISC-V 64-bit Linux kernel with BusyBox rootfs, running on QEMU — from toolchain setup to shell prompt.
TL;DR
From bare metal to a login shell — cross-compiling a RISC-V 64-bit Linux kernel with BusyBox rootfs, booting it on QEMU. No vendor SDK, no pre-built binaries. Every component is built from source, starting with the cross-compiler toolchain itself.
1. Why RISC-V?
RISC-V is the first major instruction set architecture (ISA) released under an open-source license. No royalties, no NDAs, no negotiation with a IP holder. That alone changed the game.
The modular design is the real innovation. RV64I provides the base integer operations. Then you bolt on extensions: M for multiplication/division, A for atomics, F/D for floating point, C for compressed instructions. A “rv64gc” target means: base 64-bit integer + M + A + F + D + C. You get a full general-purpose processor with no dead silicon.
For China’s semiconductor industry, RISC-V is a strategic escape valve from ARM and x86 licensing dependency. For software developers, it’s something more fundamental: the ISA is the contract between hardware and software. Understanding it means understanding what your code actually does at the machine level.
2. System Architecture
The complete system runs inside QEMU’s virt machine — a virtual platform designed specifically for software development. Here’s the full stack:
QEMU’s virt machine provides a clean abstraction: a PLIC (interrupt controller), UART for console I/O, VirtIO for block devices and networking, and a CLINT timer. No legacy cruft. The kernel doesn’t need to know it’s virtualized — the hardware interface is standard RISC-V.
3. Toolchain Setup
Three components make up the cross-compilation toolchain. Each has a distinct role:
-
binutils — the assembler (
riscv64-unknown-linux-gnu-as) and linker (riscv64-unknown-linux-gnu-ld). Converts assembly to object files and links them into executables. No compilation happens here, just translation. -
GCC — the C compiler. Takes
.csource files and targetsrv64gc. The--with-arch=rv64gcflag tells GCC which instruction extensions are available, so it can emit optimal code. -
glibc/musl — the C standard library. Provides
printf,malloc,pthread, and everything else your program assumes exists. For an embedded rootfs, musl is the better choice: static linking produces a ~1MB binary instead of 5MB+ with glibc.
Why cross-compile at all? The host machine is x86_64. The target is RISC-V. A compiler is just a program — it runs on the host, but its output must be RISC-V machine code. The compiler itself doesn’t care about the target architecture; it’s the code generator backend that translates abstract operations into target-specific instructions. Cross-compilation is the same compilation pipeline, just pointed at a different backend.
Build the toolchain from source (not a pre-built package) to control every flag and understand every dependency. It takes 30 minutes and teaches you more than any tutorial.
4. Kernel Compilation
The kernel build follows a deterministic path: configuration → compilation → binary extraction.
Three environment variables control the entire build:
ARCH=riscv— tells the kernel build system to use RISC-V Kconfig defaultsCROSS_COMPILE=riscv64-unknown-linux-gnu-— prefixes every compiler/linker invocationmake defconfig— generates.configwith sensible defaults (enables VirtIO, disables unnecessary drivers)
After make -j$(nproc), you get vmlinux — a raw ELF binary with debug symbols. QEMU needs a flat binary image, so objcopy strips the ELF headers and produces a raw Image file that the bootloader can load directly into memory.
The defconfig for RISC-V includes everything needed for the virt platform: VirtIO drivers, UART console, CLINT timer, and SMP support. No manual configuration needed for a first boot.
5. BusyBox Rootfs
BusyBox is the Swiss Army knife of embedded Linux. One static binary replaces hundreds of individual utilities: ash, ls, cp, mv, rm, mkdir, cat, grep, sed, awk, mount, init, getty, and 300+ more.
Static compilation (-static) is critical here. A dynamically linked BusyBox needs glibc, ld-linux, and locale files — adding 5MB+ of dependencies. A statically linked version is self-contained: ~1MB, zero runtime dependencies. For a minimal rootfs, static is the only option that makes sense.
The rootfs structure mirrors a real Linux system, stripped to its skeleton:
/rootfs/
├── bin/ # BusyBox symlinks
├── etc/
│ ├── inittab # Boot script
│ └── init.d/ # System services
├── proc/ # procfs mount point
├── sys/ # sysfs mount point
├── dev/ # Device nodes
└── lib/ # Shared libraries (optional)
The inittab file is the first script executed by BusyBox init. It mounts /proc and /sys, creates device nodes under /dev, and spawns getty on the UART — which is what finally gives you a login prompt.
Pack it into a cpio archive (find . | cpio -o --format=newc | gzip > rootfs.cpio.gz), and QEMU loads it as an initramfs — a ramdisk-based filesystem that the kernel unpacks before handing off to init.
6. QEMU Boot
The final command:
qemu-system-riscv64 \
-machine virt \
-m 256M \
-nographic \
-kernel [path]/Image \
-initrd [path]/rootfs.cpio.gz
Every flag has a purpose:
-machine virt— RISC-V virtual platform with PLIC, CLINT, UART, VirtIO-m 256M— RAM allocation (enough for kernel + rootfs + userspace)-nographic— redirect serial console to terminal (no GUI window)-kernel— load the kernel image directly (skips bootloader)-initrd— load the rootfs as initramfs
Boot sequence:
- QEMU initializes the emulated RV64GC hardware
- OpenSBI (Open Source Supervisor Binary Interface) takes over — this is the firmware layer that initializes the PLIC and CLINT
- Linux kernel decompresses from the
Imagefile - Kernel mounts the initramfs (
rootfs.cpio.gz) - BusyBox init runs
/init→ mounts/proc,/sys, creates/devnodes gettyspawns on/dev/ttyS0→ ash shell prompt appears
From power-on to shell prompt: ~2 seconds on modern hardware.
7. What I Learned
ISA as the critical abstraction layer. The instruction set architecture is where hardware and software meet. Everything above it (compilers, operating systems, applications) is abstracted by the ISA. Everything below it (microarchitecture, cache hierarchy, branch prediction) is hidden by it. Understanding the ISA means understanding what your code actually becomes.
Operating systems aren’t black boxes. From the first instruction fetched by the CPU to the shell prompt, every step is deterministic and traceable. There’s no magic — just layers of well-defined abstractions, each doing one thing.
Cross-compilation is compilation with a twist. The compiler is a host program. It runs on x86_64. But its code generator backend targets RISC-V. The compilation pipeline (preprocessing → compilation → assembly → linking) is identical; only the target backend changes.
Embedded thinking. When you have 256MB of RAM and a 1MB rootfs, every byte matters. Static linking, stripped binaries, minimal configuration — these aren’t hacks, they’re design principles. Resource constraints force clarity.
8. Connection to Hardware Security
RISC-V’s openness enables something proprietary ISAs can’t: hardware-level security audit. When the ISA specification is public, anyone can verify that the silicon implements it correctly. No hidden instructions, no undocumented backdoors, no trust-me semantics.
This connects directly to SM4 side-channel defense work (Docker Container Security). Hardware transparency is the foundation of software security. If you can’t verify the hardware, you can’t trust the software running on it.
From porting an operating system to verifying its security properties — the path runs through the same understanding: know your hardware, know your software, know where they meet.
Related Articles:
- Docker Container Security: Beyond the Basics — Applying container security principles.
- Infrastructure for EDA Automation — Securing a Linux server for EDA workloads.
Project: This post is part of the systems engineering portfolio.