Skip to main content

TECH VEDA

Linux kernel & Device drivers starts on 24th Oct 2026 enrollingCorporate on-site training - Submit proposal Pick your modulesSharpen your kernel skills: deep dives, drivers, Yocto, CVEs, careers — updated daily. Read the blog →Embedded Linux fast track starts 30th sept 2026 enrollingEmbedded Linux Mastery track starts 30th sept 2026 enrollingLinux systems engineering starts 30th sept 2026 enrolling
Tutorials

Building Linux for a RISC-V Board — Part 2: Building OpenSBI Firmware and U-Boot from Source

Build the OpenSBI firmware and U-Boot for RISC-V from source, then boot a kernel through the full chain in QEMU. The addresses explained.

Building Linux for a RISC-V Board — Part 2: Building OpenSBI Firmware and U-Boot from Source

Part 2 of this series replaces the firmware QEMU supplied in Part 1 with an OpenSBI firmware you build yourself, then puts U-Boot between it and the kernel. One build produces three firmware images that differ only in how each locates the next boot stage, and choosing between them is the decision this part is really about. By the end you will have booted Part 1’s kernel through a chain you own end to end, and seen why the address 0x80200000 is agreed on by three separate projects with nothing checking it at runtime.

Part 1 cross-compiled a kernel, packed a BusyBox initramfs, booted both under QEMU and read the boot log line by line. All of it ran on firmware we did not build: the OpenSBI copy QEMU carries inside itself. That is where this part starts.

What you need before you start

The ~/rv directory from Part 1, with linux-6.12.111 built and initramfs.cpio.gz beside it, and the same cross-compiler and QEMU. Part 1’s package list is not sufficient here: OpenSBI needs nothing new, but U-Boot builds host tools of its own and stops in the pylibfdt step or in tools/mkimage without these.

raghu@techveda.org:~$ sudo apt-get install git build-essential swig libpython3-dev python3-setuptools python3-pyelftools libgnutls28-dev uuid-dev libncurses-dev
raghu@techveda.org:~$ cd ~/rv

Compiling takes about 15 minutes, and each QEMU run below is exited with Ctrl-a then x as in Part 1. Every command, path, configuration symbol and address here was checked against the upstream file that defines it, but the log excerpts show only the fields you should compare: version strings, device counts and byte sizes differ by host.

Why build the OpenSBI firmware yourself

Part 1’s banner said OpenSBI v1.3. Nothing in the exercise chose that version: it is the copy vendored into the QEMU binary the distribution packaged, and changing it meant installing a different QEMU.

On a product the firmware is a deliverable you own. It holds the physical memory protection regions that hide firmware from the kernel, the platform overrides for your silicon, and the set of SBI extensions your kernel will find. Part 1 showed the kernel probing for those at boot:

[    0.000000] SBI TIME extension detected
[    0.000000] SBI IPI extension detected
[    0.000000] SBI RFENCE extension detected
[    0.000000] SBI SRST extension detected

Each of those lines records one extension the kernel queried and the firmware answered for. The kernel does not assume an extension exists; it asks. An extension the firmware does not implement is a kernel feature that fails with no error message: without the SRST extension, poweroff does not power the board off. The firmware you built decides which exist.

Build the OpenSBI firmware

raghu@techveda.org:~/rv$ git clone https://github.com/riscv-software-src/opensbi.git
raghu@techveda.org:~/rv$ cd opensbi
raghu@techveda.org:~/rv/opensbi$ git checkout v1.9
raghu@techveda.org:~/rv/opensbi$ make PLATFORM=generic CROSS_COMPILE=riscv64-linux-gnu-

OpenSBI v1.9 was released on 1 July 2026. Checking out a tag rather than the branch tip matters more here than elsewhere: this is the code running at the highest privilege level on every hart, for the life of the system. PLATFORM=generic is what to use unless your vendor says otherwise; it has no board-specific C file and configures itself from the device tree the previous stage passed.

raghu@techveda.org:~/rv/opensbi$ ls build/platform/generic/firmware/*.bin
build/platform/generic/firmware/fw_dynamic.bin
build/platform/generic/firmware/fw_jump.bin
build/platform/generic/firmware/fw_payload.bin
raghu@techveda.org:~/rv/opensbi$ cd ~/rv

The glob is deliberate: that directory also keeps object files, dependency files and linker scripts, so an unfiltered listing is noisy. Each image is there as an .elf as well. Three firmwares from one build, same SBI implementation in all, differing in one respect: how each obtains the address of the next stage.

The three OpenSBI firmware types, and how each locates the next stage

TypeHow it locates the next stageWhen you want it
FW_PAYLOADThe next stage is compiled into the firmware image.The stage before OpenSBI can load only one blob.
FW_JUMPJumps to an address fixed at build time; carries no payload.The previous stage loads both pieces where the firmware expects.
FW_DYNAMICReads the address at runtime from a structure the previous stage built.The previous stage loads both pieces and can say where it put them.

FW_PAYLOAD takes FW_PAYLOAD_PATH at build time and produces a single image. The cost is that every kernel rebuild is a firmware rebuild, which can mean re-signing firmware because an application-level change was merged.

FW_JUMP jumps to an address decided when it was compiled. FW_JUMP_ADDR sets it absolutely; FW_JUMP_OFFSET sets it as an offset from wherever OpenSBI was loaded, which keeps the firmware relocatable. The generic platform sets the offset, and one more:

# platform/generic/objects.mk, the 64-bit arm
FW_JUMP_OFFSET=0x200000
FW_JUMP_FDT_OFFSET=0x2200000

So a fw_jump image at 0x80000000 jumps to 0x80200000, and before jumping it copies the device tree to 0x82200000. That second number is easy to overlook: the device tree the next stage receives is not at the address the previous stage wrote it to. It also sets a budget. The next stage is entered at 0x80200000 and the tree lands 32 MiB above it, so a payload larger than 32 MiB has its tail overwritten by the firmware’s own copy of the tree. The order of events is what makes it that way round: the payload is already in RAM when OpenSBI starts, and the firmware copies the tree afterwards. Upstream states the constraint in the documentation for this variable and supplies a check for it.

FW_DYNAMIC is the type to choose on a new design, and the mechanism is the reason. The previous stage builds a struct fw_dynamic_info in memory and passes its address to the firmware in register a2, 8-byte aligned on RV64. The structure carries a magic value, a version, the privilege mode to enter next, and the address to enter it at. Nothing is fixed at build time.

QEMU does exactly this, which answers a question Part 1 left open: how did -kernel work when the firmware was QEMU’s own and knew nothing of our kernel? QEMU wrote the structure. Its RISC-V boot helper builds a fw_dynamic_info with the next mode set to supervisor and the next address set to the kernel entry, stores it in the mask ROM just past the reset vector, and the reset vector’s second instruction loads that address into a2. Real boards do the same with a first-stage loader: U-Boot’s SPL bundles OpenSBI’s fw_dynamic image and U-Boot proper into one FIT image and passes the firmware the address at which U-Boot was loaded.

Boot Part 1’s kernel on your own OpenSBI firmware

One argument changes:

raghu@techveda.org:~/rv$ qemu-system-riscv64 -machine virt -cpu rv64 -smp 2 -m 512M -nographic -bios opensbi/build/platform/generic/firmware/fw_dynamic.bin -kernel linux-6.12.111/arch/riscv/boot/Image -initrd initramfs.cpio.gz -append "console=ttyS0 rdinit=/init"

With -bios, QEMU loads your firmware and writes the structure for it. The banner confirms:

OpenSBI v1.9
Platform Name             : riscv-virtio,qemu
Firmware Base             : 0x80000000
Domain0 Next Address      : 0x0000000080200000
Domain0 Next Mode         : S-mode

Now substitute fw_jump.bin for fw_dynamic.bin. Check your kernel against the 32 MiB budget first, because this is the layout that budget applies to:

raghu@techveda.org:~/rv$ ls -l linux-6.12.111/arch/riscv/boot/Image

Under 33,554,432 bytes it boots. Over, and the firmware’s copy of the tree lands inside the kernel, producing no console output at all. If your build is close to the line, stay with fw_dynamic.bin, where QEMU places the tree out of the way instead.

Within that budget the kernel boots, because fw_jump ignores the structure QEMU prepared and jumps to a compile-time address that happens to be the same one. Three numbers, set in three projects, agree:

  • QEMU rounds the end of the firmware image up to a 2 MiB boundary to place a raw -kernel binary. With firmware at 0x80000000 that is 0x80200000.
  • OpenSBI’s generic platform sets FW_JUMP_OFFSET=0x200000, so the jump lands at the firmware base plus 2 MiB. Also 0x80200000.
  • U-Boot’s CONFIG_TEXT_BASE for this board defaults to 0x80200000 when built for supervisor mode on RV64.

Add U-Boot in supervisor mode

A bootloader provides what firmware deliberately does not: a shell, storage drivers, a scriptable boot sequence and an environment that survives a reset. Build it for supervisor mode, because OpenSBI already runs in machine mode.

raghu@techveda.org:~/rv$ git clone https://source.denx.de/u-boot/u-boot.git
raghu@techveda.org:~/rv$ cd u-boot
raghu@techveda.org:~/rv/u-boot$ git checkout v2026.07
raghu@techveda.org:~/rv/u-boot$ make CROSS_COMPILE=riscv64-linux-gnu- qemu-riscv64_smode_defconfig
raghu@techveda.org:~/rv/u-boot$ make CROSS_COMPILE=riscv64-linux-gnu- -j$(nproc)
raghu@techveda.org:~/rv/u-boot$ cd ~/rv

The _smode_ in the configuration name sets CONFIG_RISCV_SMODE, which moves CONFIG_TEXT_BASE from 0x80000000 to 0x80200000. The plain qemu-riscv64_defconfig is the machine-mode build, linked at 0x80000000 so that it replaces firmware rather than running under it.

raghu@techveda.org:~/rv$ qemu-system-riscv64 -machine virt -cpu rv64 -smp 2 -m 512M -nographic -bios opensbi/build/platform/generic/firmware/fw_jump.bin -kernel u-boot/u-boot.bin
U-Boot 2026.07 (Oct 04 2026 - 10:21:33 +0530)

Model: riscv-virtio,qemu
DRAM:  512 MiB
Core:  <n> devices, <m> uclasses, devicetree: board
Hit any key to stop autoboot:  2

Press a key while that countdown runs. This configuration carries the distro boot defaults, so letting it expire scans for NVMe, virtio and SCSI devices and then attempts DHCP, printing a not-found line for each and pausing on the network attempt. It reaches the prompt anyway, but some of those targets load files into addresses used later in this part. => is U-Boot’s prompt; commands shown with it below are typed there, not in the host shell.

devicetree: board is the line that matters: U-Boot is using the tree firmware handed it, which is the one QEMU generated and OpenSBI relocated. That is why one U-Boot binary can describe a machine it was not built for. The failure values are separate and embed, which mean U-Boot fell back to a tree of its own and warns about it.

U-Boot can also report which SBI implementation it is running on:

=> sbi
SBI <spec major>.<spec minor>
OpenSBI 1.9
Machine:
  Vendor ID 0
  Architecture ID 0
  Implementation ID 0
Extensions:
  SBI Base Functionality
  Timer Extension
  IPI Extension
  RFENCE Extension
  Hart State Management Extension
  System Reset Extension
  ...

That 1.9 is the firmware you built, read back through an SBI call. The extension list is the same set of queries the kernel makes, run earlier by U-Boot, so an extension missing here will be missing in Linux too.

Load the kernel from U-Boot and start it

U-Boot needs the kernel and the initramfs in memory. On a board they would come from storage, which is Part 3; for now QEMU’s generic loader places files directly in RAM. First, the initramfs size:

raghu@techveda.org:~/rv$ printf '0x%x\n' $(stat -c %s initramfs.cpio.gz)
0x<size in hex>

0x84000000 is what U-Boot itself calls kernel_addr_r, so the kernel goes where U-Boot would have put it. The initramfs needs more care: U-Boot reserves 0x8c000000 through 0x8c300000 for fdt_addr_r, scriptaddr, pxefile_addr_r and ramdisk_addr_r, so 0x8f000000 keeps it clear of all four.

raghu@techveda.org:~/rv$ qemu-system-riscv64 -machine virt -cpu rv64 -smp 2 -m 512M -nographic -bios opensbi/build/platform/generic/firmware/fw_jump.bin -kernel u-boot/u-boot.bin -device loader,file=linux-6.12.111/arch/riscv/boot/Image,addr=0x84000000 -device loader,file=initramfs.cpio.gz,addr=0x8f000000
=> setenv bootargs 'console=ttyS0 rdinit=/init'
=> booti 0x84000000 0x8f000000:0x<size in hex> ${fdtcontroladdr}

The first argument is the address at which the kernel image was loaded. The second is the initramfs address and its size after a colon, mandatory for a raw ramdisk: a cpio archive carries no length field, so without it the kernel cannot be told how much to unpack. The third is the device tree, and ${fdtcontroladdr} holds the address of the tree U-Boot is itself using, which describes memory size, hart count and console node correctly.

Moving Image from 0x84000000 to 0x80200000, end=0x<destination plus image_size>
## Flattened Device Tree blob at ...
   Booting using the fdt blob at 0x...
   Loading Device Tree to ..., end ... OK

Starting kernel ...

That first line is expected. U-Boot relocated the kernel to the address U-Boot itself was linked at: its RISC-V image handling checks the Linux image header for the RSC magic, refuses an image whose image_size is zero, and relocates to the base of RAM plus the header’s text_offset. On RV64 that offset is 2 MiB — the PMD boundary Part 1 described — so the destination is 0x80200000.

It is safe because U-Boot is no longer there: early in its own startup it relocates itself to the top of DRAM and runs from that copy. The device tree is copied to higher addresses for the same reason, which is why ${fdtcontroladdr} does not hold 0x82200000 by the time you use it. The ramdisk is not copied at all: this configuration sets initrd_high to all ones, which selects the zero-copy path, so the initramfs is passed to the kernel where it already sits and no Loading Ramdisk line appears.

[    0.000000] SBI implementation ID=0x1 Version=0x10009
...
=== userspace is up on riscv64, kernel 6.12.111 ===

Version=0x10009 is OpenSBI 1.9 in OpenSBI’s own encoding, major in the upper sixteen bits and minor in the lower. The specification leaves the implementation-version encoding to the implementation, which is why the SBI line and the OpenSBI line in the sbi output above are decoded differently by the same command. Part 1’s line read 0x10003, OpenSBI 1.3. The change confirms the kernel is running on the firmware built here.

Where this applies, and where it does not

Everything above is specific to the QEMU virt machine, OpenSBI’s generic platform, OpenSBI v1.9 and U-Boot v2026.07. Two boundaries matter enough to state plainly.

On RV32 the generic platform sets FW_JUMP_OFFSET and FW_PAYLOAD_ALIGN to 0x400000 rather than 0x200000, and QEMU rounds the kernel load address up to 4 MiB instead of 2 MiB. Both assignments sit inside a conditional on the register width, so every address in this part shifts on a 32-bit target.

The second boundary is the one this series exists to teach. Two of the three numbers that agree on 0x80200000 are offsets from the firmware base, so they follow the firmware when it moves. The third, U-Boot’s CONFIG_TEXT_BASE, is an absolute value in that board’s Kconfig and follows nothing. On a board whose DRAM does not start at 0x80000000, it is the number you set yourself, and getting it wrong under FW_JUMP produces a jump to an address that no longer holds the next stage. Nothing at runtime checks any of this, which is the practical argument for FW_DYNAMIC.

Common wrong turns

Building U-Boot with qemu-riscv64_defconfig. It is the unsuffixed name, and on most architectures the plain defconfig is the right one, so it is a reasonable thing to reach for. Here it is the machine-mode build: linked at 0x80000000 while QEMU loads -kernel at 0x80200000, and contending with OpenSBI for machine mode. The failure is early and produces little console output.

Leaving the size off the initramfs argument, or giving it in decimal. These fail very differently. booti 0x84000000 0x8f000000 ${fdtcontroladdr} is rejected outright: with no colon U-Boot cannot determine the format, prints Wrong Ramdisk Image Format and stops before starting the kernel. A decimal size is the quiet one, because U-Boot parses numeric arguments as hexadecimal, so a size pasted from stat is read as a far larger value and the command proceeds. That is why the printf step is there.

Rebuilding the kernel and forgetting the firmware. In every other configuration the kernel is a separate artifact, so the habit is correct everywhere except under FW_PAYLOAD, where it fails with no error message: the firmware still holds the previous kernel and boots it. Put the firmware rebuild in the same script.

Key takeaways

  • The OpenSBI firmware is a deliverable you own: it decides the PMP regions, the platform overrides and the SBI extensions the kernel will find.
  • One build produces three images differing only in how the next stage is located: embedded, fixed at build time, or passed at runtime.
  • FW_DYNAMIC reads a struct fw_dynamic_info whose address arrives in a2. QEMU builds that structure itself, which is why -kernel works with firmware that knows nothing of your kernel.
  • Under FW_JUMP the generic platform copies the device tree 32 MiB above the next stage, which caps the payload at 32 MiB.
  • booti relocates the kernel to the base of RAM plus the header’s text_offset, which is safe only because U-Boot has already moved itself to the top of DRAM.

Verify your boot chain

  1. The banner names the version you checked out. If it still shows Part 1’s, -bios is missing. A wrong path does not fall back silently: QEMU exits with Unable to load the RISC-V firmware.
  2. U-Boot’s banner reads devicetree: board. separate or embed mean it fell back to a tree of its own.
  3. sbi at the U-Boot prompt names OpenSBI and your version, and lists at least Base, Timer, IPI, RFENCE and System Reset.
  4. booti prints a Moving Image line whose destination is 0x80200000; the end= value is that address plus the kernel’s image_size. Bad Linux RISCV Image magic! means the address you gave does not hold an Image.
  5. The kernel line SBI implementation ID=0x1 Version=... shows your firmware version. This confirms the chain, because the kernel is reporting the version returned by its own SBI call.

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.

What’s next in this series

Part 3 replaces the hand-typed addresses with storage: a virtio block device, a real root filesystem on it, and U-Boot booting it from a script rather than from arguments you retype.

Was this worth your time?

Frequently asked questions

Do I have to build the OpenSBI firmware, or can I keep QEMU’s?
QEMU’s bundled firmware is fine for learning, and Part 1 used it. It stops being fine once the version matters, because the only way to change it is to install a different QEMU. On hardware there is no bundled copy at all.

Which OpenSBI firmware type should I use on a real board?
FW_DYNAMIC if the stage before OpenSBI can load two binaries and say where the second went, which is the usual arrangement with a first-stage loader such as U-Boot SPL. FW_PAYLOAD if that stage can load only one blob. FW_JUMP works but depends on a build-time address staying correct.

Why does U-Boot move the kernel to the address U-Boot was linked at?
Because U-Boot is not there any more: it relocates itself to the top of DRAM early in its own startup. The kernel’s destination is fixed by its image header, at the base of RAM plus a text_offset of 2 MiB on RV64.

Further reading

RB
Raghu Bharadwaj

Founder, TECH VEDA — 20+ years teaching the Linux kernel, device drivers and embedded systems.

Follow on LinkedIn

Get new posts by email

Kernel, embedded Linux and AI-era engineering — a few sharp reads a month. No spam.

We email occasionally and never share your address.