If you build Arm products you already run resident firmware: TF-A’s BL31 sits at EL3 for the life of the system and your kernel calls it through SMC. RISC-V has the same arrangement under a different name — OpenSBI in M-mode, reached by ecall. What is genuinely different is what travels through it: on RISC-V, timers, interprocessor interrupts and remote TLB shootdown can all be firmware calls, and whether they are depends on which extensions your silicon implements. This part builds a RISC-V Linux kernel, boots it on OpenSBI under QEMU, and reads the boot log to show exactly where that line falls.
This is the first part of a series building RISC-V Linux from the ground up — firmware, bootloader, kernel and root filesystem as separate pieces, rather than a build system that produces an image and hides the sequence. Everything here runs on QEMU, so it is reproducible on any machine; real hardware comes once the boot chain is understood. We start with that chain rather than the kernel build because cross-compiling is the same work on any architecture. The privilege model underneath is what is new, and the boot log will not make sense until it does.
What you need, and what it will cost you
A Linux host with about 20 GB free. Budget roughly 400 MB of downloads and, on a four-core laptop, about half an hour of compiling. The kernel build is the long pole; it is slow, not hung.
raghu@techveda.org:~$ sudo apt-get install gcc-riscv64-linux-gnu qemu-system-misc \
flex bison libelf-dev libssl-dev bc cpio curl file device-tree-compilerThat is the distribution’s cross-compiler rather than one built from source, to keep setup to a single command. Worth knowing what it decided for you: on Debian and Ubuntu it targets rv64gc with the lp64d ABI. On RISC-V that is not a default you can leave alone forever — once you have silicon, the -march string becomes a specification you agree with your vendor and freeze in your BSP.
Everything below happens in one directory. Create it now and stay in it, because later commands refer to paths inside it:
raghu@techveda.org:~$ mkdir -p ~/rv && cd ~/rvEvery command and log below was run with GCC 13.3.0, QEMU 8.2.2 and Linux 6.12.111.
The RISC-V Linux boot chain, and where firmware sits
RISC-V defines three privilege levels: machine mode (M-mode), supervisor mode (S-mode) and user mode (U-mode). The hardware starts in M-mode, the most privileged. RISC-V Linux runs in S-mode. Applications run in U-mode.
If you write application C you know the shape of this. When your program calls write() it does not touch the disk; it traps into something more privileged that does, and control returns. An SBI call is to the kernel what a syscall is to your application.
Why it must: in the base architecture the timer compare register, the IPI trigger and system reset are M-mode-only, so an S-mode kernel has to ask. It asks through the Supervisor Binary Interface, or SBI. OpenSBI is its common implementation, and it does not hand over and exit the way a bootloader does — it stays resident as the thing behind every ecall.
If your background is Arm, the mapping is worth stating outright:
| RISC-V | Arm |
|---|---|
| SBI (the specification) | SMCCC |
| OpenSBI (the implementation) | TF-A BL31 |
ecall from S-mode | smc from EL1 |
| M-mode | EL3 |
| SBI SRST / HSM extensions | PSCI |
| PMP | TZASC, RDC |
So resident firmware is not the difference — Arm has BL31, x86 has SMM. Scope is. On Arm, EL1 programs the generic timer and raises IPIs in its own system registers, and firmware handles power and vendor calls. On RISC-V those facilities live in M-mode, so SBI carries timers, IPIs and remote fences too. Extensions claw some back, and the boot log shows which this machine has.
The chain on a real board: mask ROM loads a first-stage loader — U-Boot SPL on most boards, a vendor FSBL on SiFive-lineage parts. That stage, still M-mode, initialises DRAM and loads OpenSBI and U-Boot proper. OpenSBI drops to S-mode at the address given and U-Boot loads RISC-V Linux. QEMU shortens this by loading OpenSBI itself, which is why the DRAM-init stage that dominates real bring-up is absent here.
Build the kernel
raghu@techveda.org:~/rv$ curl -LO https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.12.111.tar.xz
raghu@techveda.org:~/rv$ sha256sum linux-6.12.111.tar.xz
9e59dc67624188fa12a6601f9598499cd6662a9066be572b59f935e3d7849810 linux-6.12.111.tar.xz
raghu@techveda.org:~/rv$ tar xf linux-6.12.111.tar.xzMatching a hash fetched over the same connection as the tarball proves little. The version that resists an attacker who controls the channel is gpg --verify linux-6.12.111.tar.sign against the uncompressed tarball.
Now build RISC-V Linux. Both variables matter: ARCH selects arch/riscv, and CROSS_COMPILE is the prefix prepended to every tool name, so it must end in a hyphen. defconfig is the maintainers’ default configuration for this architecture — a starting point you will later edit.
raghu@techveda.org:~/rv$ cd linux-6.12.111
raghu@techveda.org:~/rv/linux-6.12.111$ make ARCH=riscv CROSS_COMPILE=riscv64-linux-gnu- defconfig
raghu@techveda.org:~/rv/linux-6.12.111$ make ARCH=riscv CROSS_COMPILE=riscv64-linux-gnu- -j$(nproc) Image
raghu@techveda.org:~/rv/linux-6.12.111$ file arch/riscv/boot/Image
arch/riscv/boot/Image: PE32+ executable (EFI application) RISC-V 64-bit (stripped to external PDB), for MS Windows, 2 sectionsThe kernel image is a Windows PE executable, and that is not a mistake. RISC-V, like arm64, adds a PE/COFF header under CONFIG_EFI so UEFI can load it as an EFI application — the “MZ” signature is also a valid compressed instruction, so the file stays a bootable flat image for firmware that ignores EFI.
Build a minimal root filesystem
RISC-V Linux needs something to run as PID 1. The smallest useful option is a statically linked BusyBox in an initramfs — an archive the kernel unpacks into RAM and runs from, with no shared libraries to get right yet. The mechanics are architecture-neutral and covered in more depth in the initramfs article linked at the end; what follows is the RISC-V-specific part.
raghu@techveda.org:~/rv/linux-6.12.111$ cd ~/rv
raghu@techveda.org:~/rv$ curl -LO https://busybox.net/downloads/busybox-1.38.0.tar.bz2
raghu@techveda.org:~/rv$ tar xf busybox-1.38.0.tar.bz2 && cd busybox-1.38.0
raghu@techveda.org:~/rv/busybox-1.38.0$ make CROSS_COMPILE=riscv64-linux-gnu- defconfig
raghu@techveda.org:~/rv/busybox-1.38.0$ sed -i 's/^# CONFIG_STATIC is not set/CONFIG_STATIC=y/' .config
raghu@techveda.org:~/rv/busybox-1.38.0$ make CROSS_COMPILE=riscv64-linux-gnu- -j$(nproc)Editing .config with sed is fine here: it is a plain key/value file and these symbols have no dependencies menuconfig would resolve. That build then fails:
networking/tc.c:236:27: error: 'TCA_CBQ_MAX' undeclared (first use in this function); did you mean 'TCA_CBS_MAX'?
networking/tc.c:256:61: error: invalid application of 'sizeof' to incomplete type 'struct tc_cbq_lssopt'The tc applet still references the CBQ queueing discipline, removed from the kernel’s UAPI headers. Not a stale-release problem — it happens on 1.38.0, the current release, as on 1.36.1 — and not RISC-V specific either; it breaks identically on arm64. Turn the applet off and rebuild.
raghu@techveda.org:~/rv/busybox-1.38.0$ sed -i 's/^CONFIG_TC=y/# CONFIG_TC is not set/' .config
raghu@techveda.org:~/rv/busybox-1.38.0$ make CROSS_COMPILE=riscv64-linux-gnu- -j$(nproc)
raghu@techveda.org:~/rv/busybox-1.38.0$ file busybox
busybox: ELF 64-bit LSB executable, UCB RISC-V, RVC, double-float ABI, version 1 (SYSV), statically linked, BuildID[sha1]=19085d26cac5865d486a0be86d5fee2895efe018, for GNU/Linux 4.15.0, strippedUCB RISC-V confirms the architecture, RVC means compressed instructions are in use, double-float ABI is the lp64d the toolchain chose, and statically linked is what lets this run with no root filesystem behind it. Assemble the initramfs:
raghu@techveda.org:~/rv/busybox-1.38.0$ mkdir -p ~/rv/initramfs/{bin,proc,sys,dev} && cd ~/rv/initramfs
raghu@techveda.org:~/rv/initramfs$ cp ~/rv/busybox-1.38.0/busybox bin/
raghu@techveda.org:~/rv/initramfs$ for a in sh mount ls cat uname poweroff dmesg; do ln -sf busybox bin/$a; doneNext the init script. This block is a file to create, not commands to run — paste it whole, including the closing EOF:
cat > init <<'EOF'
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev
echo
echo "=== userspace is up on $(uname -m), kernel $(uname -r) ==="
echo
exec /bin/sh
EOFraghu@techveda.org:~/rv/initramfs$ chmod +x init
raghu@techveda.org:~/rv/initramfs$ find . | cpio -o -H newc | gzip -9 > ../initramfs.cpio.gz
3878 blocksMounting devtmpfs populates /dev with the kernel’s device nodes, which you will want shortly. It does not silence the can't access tty; job control turned off line you are about to see — that comes from BusyBox being unable to set up job control while running as PID 1, and it is harmless. The unpacker accepts newc (070701) and crc (070702) but not the older odc, so -H newc is the safe choice.
Boot it, and read the OpenSBI banner
raghu@techveda.org:~/rv/initramfs$ cd ~/rv
raghu@techveda.org:~/rv$ qemu-system-riscv64 -machine virt -cpu rv64 -smp 2 -m 512M -nographic -kernel linux-6.12.111/arch/riscv/boot/Image -initrd initramfs.cpio.gz -append "console=ttyS0 rdinit=/init" | tee boot.logThat is one long line rather than a backslash-continued block on purpose: a lost continuation turns it into qemu-system-riscv64 with no arguments, which prints a complete, correct firmware banner and then hangs — the most misleading failure available here. tee keeps the log for the checks at the end.
No -bios argument appears: QEMU supplies its bundled OpenSBI by default, which is why firmware rather than the kernel is first on the console. The banner is long, so these are the lines that tell you something:
OpenSBI v1.3
Platform Name : riscv-virtio,qemu
Platform HART Count : 2
Firmware Base : 0x80000000
Firmware Size : 332 KB
Runtime SBI Version : 1.0
Domain0 Region01 : 0x0000000080040000-0x000000008005ffff M: (R,W) S/U: ()
Domain0 Region02 : 0x0000000080000000-0x000000008003ffff M: (R,X) S/U: ()
Domain0 Next Address : 0x0000000080200000
Domain0 Next Mode : S-mode
Boot HART ID : 0
Boot HART Base ISA : rv64imafdch
Boot HART ISA Extensions : time,sstc
Boot HART PMP Count : 16
Boot HART MEDELEG : 0x0000000000f0b509A hart is a hardware thread — RISC-V’s word for a core. This machine has two.
Four things there are the whole handoff. Firmware Base 0x80000000 is where OpenSBI sits, the bottom of RAM here. Next Address 0x80200000 is where it jumps, 2 MB higher — architectural rather than arbitrary, because the kernel must start on a PMD boundary and its image header advertises that offset. The absolute value is QEMU’s, the firmware end rounded up; a board whose DRAM starts elsewhere keeps the offset, not the address. Next Mode S-mode is the privilege drop. The two Domain0 regions are firmware hiding from the kernel — Region02 is OpenSBI’s code, Region01 its writable data, both invisible below M-mode, together the 332 KB above. PMP is the mechanism: physical memory protection, roughly TZASC’s opposite number, with the caveat that it constrains the hart and not DMA masters.
MEDELEG is machine exception delegation: each bit decides whether an exception taken in S or U mode is handled by firmware or passed to the kernel. Without it every page fault would trap into firmware first. Decode the value rather than admire it, because what is not delegated matters — here, misaligned load and store faults are not, so firmware emulates them in software. That is a performance cliff invisible to any S-mode profile.
What the kernel log says about the handoff
The kernel starts, and its first lines are about the firmware it came from. These are consecutive lines from the log, in order:
[ 0.000000] SBI specification v1.0 detected
[ 0.000000] SBI implementation ID=0x1 Version=0x10003
[ 0.000000] SBI TIME extension detected
[ 0.000000] SBI IPI extension detected
[ 0.000000] SBI RFENCE extension detected
[ 0.000000] SBI SRST extension detected
[ 0.000000] efi: UEFI not found.
[ 0.000000] OF: reserved mem: 0x0000000080000000..0x000000008003ffff (256 KiB) nomap non-reusable mmode_resv1@80000000
[ 0.000000] OF: reserved mem: 0x0000000080040000..0x000000008005ffff (128 KiB) nomap non-reusable mmode_resv0@80040000RISC-V Linux is discovering what it may ask firmware to do. SBI implementation ID=0x1 is OpenSBI, from a numbered list in the specification. The extensions are capabilities: TIME for timers, IPI for interrupting harts already running, RFENCE for remote TLB shootdown, SRST for reset and poweroff. A fifth, SBI HSM extension detected, appears later — hart state management, which is what starts the second hart.
The two reserved mem lines are the banner’s firmware regions arriving through the device tree as memory the kernel must not touch, marked nomap. efi: UEFI not found is unrelated to the PE header: the kernel found no UEFI system-table pointer in the device tree, so nothing booted it through UEFI.
Further down, two lines are worth contrasting, and the order is the real one:
[ 0.000000] riscv: providing IPIs using SBI IPI extension
[ 0.000253] riscv-timer: Timer interrupt in S-mode is available via sstc extensionIPIs go through firmware. Timers do not: sstc gives S-mode its own timer compare register, so RISC-V Linux programs it directly with a CSR write — a control and status register — instead of an ecall. The kernel uses hardware where it can and firmware where it must, decided at boot. This is where QEMU flatters reality: its default CPU has sstc and several shipping parts do not, so on real silicon every timer program may be a firmware call.
Then userspace:
[ 0.834042] Freeing unused kernel image (initmem) memory: 2288K
[ 0.835308] Run /init as init process
=== userspace is up on riscv64, kernel 6.12.111 ===
/bin/sh: can't access tty; job control turned off
~ #That is a complete RISC-V Linux system: firmware in M-mode, kernel in S-mode, shell in U-mode. Exit with Ctrl-a then x.
Common wrong turns
Assuming console=ttyS0 is what gives you output. Drop it and the boot still prints everything. That surprised me, so I checked why rather than assuming QEMU was being helpful:
raghu@techveda.org:~/rv$ qemu-system-riscv64 -machine virt -nographic -machine dumpdtb=virt.dtb
raghu@techveda.org:~/rv$ dtc -I dtb -O dts virt.dtb | grep -A1 chosen
chosen {
stdout-path = "/soc/serial@10000000";The kernel takes its console from chosen/stdout-path when no console= is given. The habit is still right on hardware, where plenty of vendor device trees omit stdout-path and the omission does produce a silent boot.
Forgetting ARCH=riscv on a later make. It is a make variable, not stored in the tree, so it belongs on every invocation. The tree does remember the architecture in .config, so dropping ARCH will not quietly build an x86 kernel — kbuild re-syncs against arch/x86/Kconfig, the RISC-V symbols stop resolving, and you land in an interactive Restart config... prompt. Loud, but baffling. Export both once per shell.
Booting with no root filesystem. Leave out -initrd and the kernel gets to the end and stops:
[ 0.699852] VFS: Cannot open root device "" or unknown-block(0,0): error -6
[ 0.701558] Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0,0)This is the most common result of a first bring-up, and it is good news: firmware ran, the kernel ran, hardware was found, and only userspace is missing. Read it as a checkpoint passed. Note the same panic appears if a long command line loses an argument, so check the command before blaming the initramfs.
Key takeaways
- RISC-V Linux runs on resident M-mode firmware, but so does Arm Linux on BL31. The difference is scope: timers, IPIs and remote fences can be firmware calls here, and which ones are depends on the silicon.
- An SBI call is to the kernel what a syscall is to an application: a trap into something more privileged, and a return.
- The handoff is four items in the OpenSBI banner: firmware base, next address, next mode, and the PMP-backed regions that hide firmware from the kernel.
- RISC-V Linux discovers firmware capabilities at boot, so a missing extension shows up as a broken feature rather than a firmware error.
- Build RISC-V Linux with both
ARCH=riscvandCROSS_COMPILE=riscv64-linux-gnu-on every make; neither is remembered. - The console can come from
console=orchosen/stdout-path. Know which feeds yours before debugging a silent board.
Verify your build
file arch/riscv/boot/ImagereportsRISC-V 64-bit. If it names your host architecture,ARCH=riscvwas missing.file busyboxreportsUCB RISC-Vandstatically linked. Dynamically linked here means a shell that cannot start.- An OpenSBI banner appears before any kernel output. The version will differ with your QEMU; only its absence is a problem.
- The banner shows
Next Mode : S-modeand aNext Addressabove the firmware base. grep 'SBI.*extension detected' boot.loglists at least TIME, IPI, RFENCE, SRST and HSM.- The shell prompt appears and
uname -mprintsriscv64. poweroff -fshuts QEMU down cleanly. The-fmatters: plainpoweroffsignals PID 1 expecting BusyBox init, and PID 1 here is the shell, so nothing happens. That is your init script, not your firmware.
Engineers who want this material as a structured programme rather than a series of posts can find it in TECH VEDA’s embedded Linux training.
Related reading on TECH VEDA
- RISC-V Embedded Linux: What’s Real, What’s Not — where the ecosystem stands, and which silicon is worth planning a product around.
- Cross-Compile the Linux Kernel for ARM64 and Boot It in QEMU — the same exercise on Arm, useful for seeing which parts here are RISC-V specific and which are not.
- Boot a Custom Linux Kernel in QEMU with a Minimal Initramfs — more on initramfs construction and cpio packing than this part covers.
Frequently asked questions
What is OpenSBI and why does RISC-V need it?
OpenSBI implements the Supervisor Binary Interface, the firmware layer running in RISC-V machine mode. Linux runs in supervisor mode and cannot reach machine-mode facilities such as the timer compare register, the IPI trigger or system reset, so it calls firmware. OpenSBI stays resident rather than exiting after boot, much as TF-A’s BL31 does at EL3 on Arm.
Do I need a RISC-V board to follow this tutorial?
No. Every command runs on an ordinary x86 Linux host using a cross-compiler and QEMU’s virt machine. Real hardware comes later in the series, once the boot chain is understood.
Why is the RISC-V Linux kernel Image a PE executable?
RISC-V, like arm64, adds a PE/COFF header under CONFIG_EFI so UEFI can load it as an EFI application. The signature bytes are also a valid instruction, so the file stays a bootable flat image without UEFI. “efi: UEFI not found” means the kernel found no UEFI system table in the device tree, not that the header is wrong.
Why does the kernel still print output without console=ttyS0?
QEMU’s generated device tree sets chosen/stdout-path to the serial node, and the kernel uses that when no console= argument is given. On hardware whose device tree does not set stdout-path, omitting console= does produce a silent boot.
Why does BusyBox fail to build with a tc.c error?
The tc applet references the CBQ queueing discipline, removed from the kernel’s userspace headers. It affects current BusyBox releases, not just old ones, and it is not RISC-V specific. Set CONFIG_TC to off and rebuild.
Further reading
- RISC-V Supervisor Binary Interface specification — the extensions the kernel probes at boot, and the implementation-ID table that makes 0x1 OpenSBI.
- OpenSBI — the reference M-mode firmware, its platform ports and its build instructions, which Part 2 uses.
- QEMU RISC-V virt machine — the memory map, the default firmware behaviour and the generated device tree.
- Documentation/arch/riscv/boot.rst — what the kernel expects from firmware at entry, including the PMD-boundary requirement and reserved memory for resident firmware.
- TF-A firmware design — for the Arm comparison: BL31 as EL3 runtime firmware, and the services it keeps resident.



