Tue 09 June 2026

Deep Dive into Upstream RISC-V Boot Chain

This deep dive into the upstream RISC-V boot chain was presented as a poster at the RISC-V Summit Europe 2026 in Bologna, Italy.

While porting Freedesktop SDK to the EBC7700 for my FOSDEM'26 talk, I encountered some UEFI boot issues, which motivated me to dig deeper and uncover all the mysteries about the RISC-V boot chain. We start by introducing a few important concepts/tools.

RISC-V Toolchain

First, you may need a toolchain to actually be able to compile any of the binaries involved in the boot chain. And if you are, just like me, still waiting for your long pre-ordered RISC-V laptop/workstation, and therefore still using an x86_64 machine as your daily driver, the easiest is to download a pre-built cross-toolchain. Throughout this tutorial, I used the nightly 2026.05.19 releases:

  • riscv64-glibc-ubuntu-24.04-gcc.tar.xz
  • riscv64-glibc-ubuntu-24.04-llvm.tar.xz

The setup for gcc:

1
2
3
4
5
 [zim@toolbx ~]$ cat envsetup-gcc-riscv64-16.1.sh
export ARCH=riscv
export CROSS_COMPILE=~/Toolchains/riscv64-glibc-ubuntu-24.04-gcc-2026.05.19-nightly/bin/riscv64-unknown-linux-gnu-
export DTC_FLAGS="-@"
export LANG=en_US.UTF-8

Or alternatively for llvm, which is my preferred option nowadays:

1
2
3
4
5
6
7
8
 [zim@toolbx ~]$ cat envsetup-llvm-riscv64-21.1.1.sh
export ARCH=riscv
export CC=clang
export CROSS_COMPILE=riscv64-unknown-linux-gnu-
export DTC_FLAGS="-@"
export LANG=en_US.UTF-8
export LLVM=1
export PATH=~/Toolchains/riscv64-glibc-ubuntu-24.04-llvm-2026.05.19-nightly/bin/:$PATH

Note: The -@ flag instructs the device tree compiler to generate symbol tables, which are mainly used should you ever play with device tree overlays.

Privilege Levels

Second, I want to briefly mention an important low-level RISC-V hardware concept called privilege levels, which define the processor's access rights to memory, I/O devices, and instructions. Understanding the basic concept of privilege levels is important for the overall understanding of this article. RISC-V knows 3 privilege levels:

  • M (machine) mode:
    • highest privilege
    • low-level firmware (e.g. OpenSBI), system boot, and security (e.g. secure execution environments)
    • handles critical system traps
    • return to lower-privileged levels using mret instruction
  • S (supervisor) mode:
    • intermediate privilege
    • used by operating system kernels to manage memory and hardware resources
    • restrict direct access to machine-level configuration registers and physical memory protection (PMP)
    • uses Memory Management Unit (MMU) and virtual memory
    • ecall instruction to request kernel services
    • return to U-mode using sret instruction
  • U (user) mode:
    • lowest privilege
    • cannot execute privileged instructions, access physical memory directly without virtual memory translations, or directly alter core control registers
    • user space executing standard applications
    • prevents incorrect or malicious application code from interfering with other running programs or the operating system itself

QEMU

Third, I want to introduce the Quick Emulator (QEMU), which is a free and open source machine emulator and virtualiser. The QEMU RISC-V emulation with its (riscv64) virt machine is the perfect playground for us to gain a deeper understanding of the RISC-V boot flow. It uses a hard-coded reset vector, and by default, loads the built-in OpenSBI binary into system memory space, unless a custom firmware binary is specified using the -bios argument. OpenSBI executes hardware-specific initialisation (in M-mode) and passes control (in S-mode) to the -kernel or bootloader payload at 0x80000000. The virt machine automatically generates a device tree blob (DTB) which it passes to the guest, if there is no -dtb option specified.

For QEMU I relied on the latest version shipped with my distro, rather than also compiling it from scratch:

1
2
 [zim@toolbx ~]$ rpm -q qemu-system-riscv
qemu-system-riscv-10.2.2-1.fc44.x86_64

Boot Flow Overview

With those three things introduced, let us now look at the RISC-V boot flow:

Boot flow overview

Boot ROM aka ZSBL (Zero Stage Bootloader)

The ZSBL is what a RISC-V chip, or its emulated derivative, runs first at power-up out of reset. It initialises a few registers and jumps directly to some hard-coded address (in case of QEMU's riscv64 virt machine, that is 0x80000000), where the first user-provided code runs (by default, QEMU will load OpenSBI there).

Debugging

Let us use QEMU's inbuilt gdb server (debugger) to have a look at what exactly ZSBL does in the QEMU case.

In a first terminal, we launch QEMU with the -S argument, which instructs it to just wait for a gdb debugger session to connect to its gdb server and further specify what to do next:

1
 [zim@toolbx ~]$ qemu-system-riscv64 -S -gdb tcp::1234 -machine virt -nographic

And in a second terminal, we launch gdb-multiarch (again, just using the distro-provided one), instruct it to disassemble the next line and connect to the above-launched QEMU. Note that it will automatically detect the riscv:rv64 architecture. We can use si a few times to step through ZSBL's instructions one by one, until we observe the final jump to OpenSBI at 0x80000000:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
 [zim@toolbx ~]$ gdb -ex 'set disassemble-next-line on' -ex 'target remote localhost:1234'
GNU gdb (Fedora Linux) 17.1-6.fc44
Copyright (C) 2025 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word".
Remote debugging using localhost:1234
⚠️ warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.
0x0000000000001000 in ?? ()
=> 0x0000000000001000:  00000297            auipc   t0,0x0
(gdb) si
0x0000000000001004 in ?? ()
=> 0x0000000000001004:  02828613            addi    a2,t0,40
(gdb) si
0x0000000000001008 in ?? ()
=> 0x0000000000001008:  f1402573            csrr    a0,mhartid
(gdb) si
0x000000000000100c in ?? ()
=> 0x000000000000100c:  0202b583            ld  a1,32(t0)
(gdb) si
0x0000000000001010 in ?? ()
=> 0x0000000000001010:  0182b283            ld  t0,24(t0)
(gdb) si
0x0000000000001014 in ?? ()
=> 0x0000000000001014:  00028067            jr  t0
(gdb) si
0x0000000080000000 in ?? ()
=> 0x0000000080000000:  00050433            add s0,a0,zero
(gdb) exit
A debugging session is active.

    Inferior 1 [process 1] will be detached.

Quit anyway? (y or n) y
Detaching from pid process 1
Ending remote debugging.
[Inferior 1 (process 1) detached]

So QEMU's ZSBL execution starts at address 0x1000, and there are only 6 instructions executed, but what exactly are they doing? Turns out in the case of QEMU's virt machine, this is the riscv_setup_rom_reset_vec() function in hw/riscv/boot.c which is populated as part of its own bring up before booting the guest:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void riscv_setup_rom_reset_vec(MachineState *machine, RISCVHartArrayState *harts,
                               hwaddr start_addr,
                               hwaddr rom_base, hwaddr rom_size,
                               uint64_t kernel_entry,
                               uint64_t fdt_load_addr)
{
    int i;
    uint32_t start_addr_hi32 = 0x00000000;
    uint32_t fdt_load_addr_hi32 = 0x00000000;

    if (!riscv_is_32bit(harts)) {
        start_addr_hi32 = start_addr >> 32;
        fdt_load_addr_hi32 = fdt_load_addr >> 32;
    }
    /* reset vector */
    uint32_t reset_vec[10] = {
        0x00000297,                  /* 1:  auipc  t0, %pcrel_hi(fw_dyn) */
        0x02828613,                  /*     addi   a2, t0, %pcrel_lo(1b) */
        0xf1402573,                  /*     csrr   a0, mhartid  */
        0,
        0,
        0x00028067,                  /*     jr     t0 */
        start_addr,                  /* start: .dword */
        start_addr_hi32,
        fdt_load_addr,               /* fdt_laddr: .dword */
        fdt_load_addr_hi32,
                                     /* fw_dyn: */
    };
    if (riscv_is_32bit(harts)) {
        reset_vec[3] = 0x0202a583;   /*     lw     a1, 32(t0) */
        reset_vec[4] = 0x0182a283;   /*     lw     t0, 24(t0) */
    } else {
        reset_vec[3] = 0x0202b583;   /*     ld     a1, 32(t0) */
        reset_vec[4] = 0x0182b283;   /*     ld     t0, 24(t0) */
    }
...

So the 6 instructions are literally hard-coded right there in QEMU's source code!

QEMU populates a struct fw_dynamic_info with the information required to boot the next stage and passes its address in the a2 register of the RISC-V CPU. The address must be aligned to 8 bytes on RV64 and 4 bytes on RV32. The struct fw_dynamic_info is defined in include/hw/riscv/boot_opensbi.h:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/** Representation dynamic info passed by previous booting stage */
struct fw_dynamic_info64 {
    /** Info magic */
    int64_t magic;
    /** Info version */
    int64_t version;
    /** Next booting stage address */
    int64_t next_addr;
    /** Next booting stage mode */
    int64_t next_mode;
    /** Options for OpenSBI library */
    int64_t options;
...

The next_mode is set to S to select a CPU that supports S-mode.

QEMU also passes a unique per-hart ID in the a0 register. We skipped the discussion of harts here as it is not relevant for the discussion of the boot chain.

The fdt_load_addr, pointing to the flattened device tree, is passed via the a1 register.

Then it jumps to address 0x80000000 to execute the next element within the boot chain.

OpenSBI

SBI stands for Supervisor Binary Interface, which is the system call style calling convention between a Supervisor (e.g. S-mode OS) and Supervisor Execution Environment (SEE, e.g. the M-mode runtime firmware). SBI calls help to reduce duplicate platform code across OSes, provide common drivers for an OS which can be shared by multiple platforms and an interface for direct access to hardware resources (M-mode only resources). The specification is maintained by the Platform Runtime Services (PRS) Task Group (TG), which ratified the SBI specification v1.0 in 2022.

OpenSBI (Open Source Supervisor Binary Interface) is an open source implementation of that RISC-V SBI specification.

In OpenSBI, the FW_DYNAMIC approach dynamically obtains the runtime configuration and information about the next boot stage (e.g. ⁠U-Boot proper) from any previous boot stage via passing a pointer to a struct fw_dynamic_info structure in a CPU register, rather than using hard-coded compile-time values.

The assembly bootstrapping phase does early reset and sanity checks by validating its environment and setting up basic mtvec trap vectors. Subsequently, the dynamic information from fw_dynamic_info gets parsed, stack allocated, scratch space initialised (mscratch), and the C-runtime jumped into.

The core initialisation and platform setup phase (sbi_init) does early console setup, configuring the primitive serial ⁠UART block for debug log output. The platform hardware init (platform_init) invokes platform-specific functions, initialising critical hardware blocks. The physical memory protection (PMP) registers are provisioned, shielding M-mode memory from unauthorised S-mode accesses. The interrupt controllers, like core timers, inter-processor interrupts (IPIs), and peripheral controllers like the PLIC/APLIC get initialised.

The payload transition phase (sbi_expected_trap to S-mode) extracts the jump target from struct fw_dynamic_info, modifies the mstatus/misa registers, configuring the previous privilege level to S-mode, programs the target next_addr value directly into the machine exception program counter (mepc), restores the rest of the original dynamic information (e.g. FDT address) passed via CPU registers, and executes the mret instruction, which drops the processor privilege down to S-mode and jumps directly to the address staged inside mepc, passing system execution to U-Boot proper.

Rather than using QEMU's built-in OpenSBI, we can build our own for later debugging:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
 [zim@toolbx ~]$ git clone https://github.com/riscv/opensbi.git
⬢ [zim@toolbx ~]$ cd opensbi
⬢ [zim@toolbx opensbi]$ git checkout -b v1.8.1-test v1.8.1
⬢ [zim@toolbx opensbi]$ make BUILD_INFO=y PLATFORM=generic
...
 AR        platform/generic/lib/libplatsbi.a
 AS        platform/generic/firmware/payloads/test_head.o
 CC        platform/generic/firmware/payloads/test_main.o
 MERGE     platform/generic/firmware/payloads/test.o
 ELF       platform/generic/firmware/payloads/test.elf
 OBJCOPY   platform/generic/firmware/payloads/test.bin
 AS        platform/generic/firmware/fw_dynamic.o
 ELF       platform/generic/firmware/fw_dynamic.elf
 OBJCOPY   platform/generic/firmware/fw_dynamic.bin
 AS        platform/generic/firmware/fw_jump.o
 ELF       platform/generic/firmware/fw_jump.elf
 OBJCOPY   platform/generic/firmware/fw_jump.bin
 AS        platform/generic/firmware/fw_payload.o
 ELF       platform/generic/firmware/fw_payload.elf
 OBJCOPY   platform/generic/firmware/fw_payload.bin

Note: The latest master branch did not build for me with llvm, which is why I picked released v1.8.1 instead, which seems to work fine.

The resulting binary may be launched by QEMU (remember, without the -bios argument, it would just launch the built-in/default OpenSBI):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
 [zim@toolbx opensbi]$ qemu-system-riscv64 -machine virt -nographic \
-bios build/platform/generic/firmware/fw_payload.bin

OpenSBI v1.8.1-87-g547a5bbda7c3
Build time: 2026-05-29 13:51:26 +0200
Build compiler: gcc version 16.1.0 (g6afcc4f6d)
   ____                    _____ ____ _____
  / __ \                  / ____|  _ \_   _|
 | |  | |_ __   ___ _ __ | (___ | |_) || |
 | |  | | '_ \ / _ \ '_ \ \___ \|  _ < | |
 | |__| | |_) |  __/ | | |____) | |_) || |_
  \____/| .__/ \___|_| |_|_____/|____/_____|
        | |
        |_|

Platform Name               : riscv-virtio,qemu
Platform Features           : medeleg
Platform HART Count         : 1
Platform HART Protection    : pmp
Platform IPI Device         : aclint-mswi
Platform Timer Device       : aclint-mtimer @ 10000000Hz
Platform Console Device     : uart8250
Platform HSM Device         : ---
Platform PMU Device         : ---
Platform Reboot Device      : syscon-reboot
Platform Shutdown Device    : syscon-poweroff
Platform Suspend Device     : ---
Platform CPPC Device        : ---
Firmware Base               : 0x80000000
Firmware Size               : 321 KB
Firmware RW Offset          : 0x40000
Firmware RW Size            : 65 KB
Firmware Heap Offset        : 0x47000
Firmware Heap Size          : 37 KB (total), 0 KB (reserved), 14 KB (used), 22 KB (free)
Firmware Scratch Size       : 4096 B (total), 1600 B (used), 2496 B (free)
Runtime SBI Version         : 3.0
Standard SBI Extensions     : time,rfnc,ipi,base,hsm,srst,pmu,dbcn,fwft,legacy,dbtr,sse
Experimental SBI Extensions : none

Domain0 Name                : root
Domain0 Boot HART           : 0
Domain0 HARTs               : 0x0*
Domain0 Region00            : 0x0000000080040000-0x000000008005ffff M: (F,R,W) S/U: ()
Domain0 Region01            : 0x0000000080000000-0x000000008003ffff M: (F,R,X) S/U: ()
Domain0 Region02            : 0x0000000000100000-0x0000000000100fff M: (I,R,W) S/U: (R,W)
Domain0 Region03            : 0x0000000010000000-0x0000000010000fff M: (I,R,W) S/U: (R,W)
Domain0 Region04            : 0x0000000002000000-0x000000000200ffff M: (I,R,W) S/U: ()
Domain0 Region05            : 0x000000000c400000-0x000000000c5fffff M: (I,R,W) S/U: (R,W)
Domain0 Region06            : 0x000000000c000000-0x000000000c3fffff M: (I,R,W) S/U: (R,W)
Domain0 Region07            : 0x0000000000000000-0xffffffffffffffff M: () S/U: (R,W,X)
Domain0 Next Address        : 0x0000000080200000
Domain0 Next Arg1           : 0x0000000082200000
Domain0 Next Mode           : S-mode
Domain0 SysReset            : yes
Domain0 SysSuspend          : yes

Boot HART ID                : 0
Boot HART Domain            : root
Boot HART Priv Version      : v1.12
Boot HART Base ISA          : rv64imafdch
Boot HART ISA Extensions    : sstc,zicntr,zihpm,zicboz,zicbom,sdtrig,svadu,f,d
Boot HART PMP Count         : 16
Boot HART PMP Granularity   : 2 bits
Boot HART PMP Address Bits  : 54
Boot HART MHPM Info         : 16 (0x0007fff8)
Boot HART Debug Triggers    : 2 triggers
Boot HART MIDELEG           : 0x0000000000001666
Boot HART MEDELEG           : 0x0000000000f4b509

Test payload running

Debugging

We can launch QEMU in the first terminal, but this time, in addition to instructing it with the -bios argument to load our self-built OpenSBI, again with the -S argument, which instructs it to just wait for the gdb debugger to connect to it and further specify what to do next:

1
2
 [zim@toolbx opensbi]$ qemu-system-riscv64 -S -bios build/platform/generic/firmware/fw_payload.bin \
-gdb tcp::1234 -machine virt -nographic

In the second terminal, we start gdb, giving it the OpenSBI symbol file and instructing it to break at sbi_boot_print_banner. Then we can let it print the banner line by line:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
 [zim@toolbx opensbi]$ gdb -ex 'add-symbol-file build/platform/generic/firmware/fw_payload.elf \
0x80000000' -ex 'target remote localhost:1234'
GNU gdb (Fedora Linux) 17.1-6.fc44
Copyright (C) 2025 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word".
add symbol table from file "build/platform/generic/firmware/fw_payload.elf" at
    .text_addr = 0x80000000
(y or n) y
Reading symbols from build/platform/generic/firmware/fw_payload.elf...
Remote debugging using localhost:1234
⚠️ warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.
0x0000000000001000 in ?? ()
(gdb) b sbi_boot_print_banner
Breakpoint 1 at 0x8000b14c: file /var/home/zim/Sources/opensbi/lib/sbi/sbi_init.c, line 51.
(gdb) c
Continuing.

Breakpoint 1, sbi_boot_print_banner (scratch=0x80046000) at /var/home/zim/Sources/opensbi/lib/sbi/sbi_init.c:51
51      if (scratch->options & SBI_SCRATCH_NO_BOOT_PRINTS)
(gdb) n
55      sbi_printf("\nOpenSBI %s\n", OPENSBI_VERSION_GIT);
(gdb) n
62      sbi_printf("Build time: %s\n", OPENSBI_BUILD_TIME_STAMP);
(gdb) n
66      sbi_printf("Build compiler: %s\n", OPENSBI_BUILD_COMPILER_VERSION);
(gdb) n
69      sbi_printf(BANNER);
(gdb) c
Continuing.
[Inferior 1 (process 1) exited normally]
(gdb) exit

U-Boot

Das U-Boot (Universal Boot Loader) is the de facto open source bootloader used in embedded devices. Its main job is to initialise hardware, load the operating system (usually Linux) into memory, and boot. It supports multi-stage booting, often split into an SPL (Secondary Program Loader) that sets up basic RAM, and full U-Boot proper that takes over to load the next stage. It features an interactive command line interface (CLI) usually accessible via serial port UART used to interrupt the boot sequence, poke/test hardware, and configure various aspects of the system boot. As part of its hardware management, it allows loading files from file systems on various (boot) devices like NAND/NOR flashes, (e)MMC/SD cards/chips, NVMe drives, SD cards, UFS, or USB mass storage. But it also features a built-in network stack for downloading images over Ethernet using e.g. TFTP, which is very convenient for debugging/development. Last but not least, it does the OS hand-off, including preparing a device tree and loading the Linux kernel image into RAM before transferring execution. Alternatively, as we will see later on, it may also hand off to a next-stage UEFI boot manager.

The first phase of U-Boot's SPL consists of M-mode assembly entry. The hardware reset vector gets directly set to the entry point _start in arch/riscv/cpu/start.S. The hardware setup includes configuring fundamental control and status registers (CSRs), like disabling interrupts (mstatus), clearing delegation registers (medeleg/mideleg), and setting the trap vector base address (mtvec) to catch early faults. The hart ID is extracted by querying via the csrr a0, mhartid instruction. The primary executing core (boot "hart") continues initialisation, while auxiliary harts are placed in a secondary wait loop if symmetric multiprocessing (CONFIG_SPL_SMP) is enabled, as is the case on QEMU. An early runtime C stack is prepared in internal SRAM or cache-as-RAM (L2 cache). The gp (Global Pointer) register is set up to reserve space for the early global data structure pointer (gd).

The second phase consists of the pre-RAM initialisation (board_init_f), which now runs as a C runtime within arch/riscv/lib/spl.c. The spl_early_init() sets up a basic driver model (DM) tree structure to find essential SoC components configured via device tree, either passed by QEMU or baked into U-Boot SPL. The riscv_cpu_setup() issues early CPU core tweaks and configures the physical memory protection (PMP) boundaries. The preloader_console_init() prepares the system console (typically a standard 16550 UART or similar device), allowing debug log prints. And spl_board_init_f() is the board/SoC-specific implementation called to power up, configure clock phase-locked loops (PLLs), and explicitly initialise the external RAM.

The third phase consists of the post-RAM initialisation (board_init_r), which now runs within generic U-Boot SPL common/spl/spl.c. The operational stack and active global data structures get transferred out of early internal SRAM into RAM if configured to do so (CONFIG_SPL_STACK_R), however, on QEMU this is not the case. Storage media drivers and devices, such as MMC/SD cards or NAND/NOR flashes, get initialised as preparation for loading the next stage payloads, the exact source of which may be dynamically discovered.

The fourth and final phase of the SPL consists of image loading and executing the jump into OpenSBI, which does the transition from M-mode, where the SPL runs, to S-mode for U-Boot proper to accommodate an operating system like Linux. The FIT image gets parsed and may consist of U-Boot proper, OpenSBI and optionally a device tree blob (DTB). However, QEMU usually passes in its own DTB via FW_DYNAMIC mechanism. U-Boot proper gets loaded into its target execution RAM address, while OpenSBI is loaded into its respective baseline location. The instruction pipeline is flushed in invalidate_icache_all(), clearing caches. Last but not least, the jump gets initiated by loading the target execution entry address to point directly to OpenSBI's entry point. The information gets passed via regular RISC-V registers: a0 receives the boot hart ID (gd->arch.boot_hart), a1 the memory pointer referencing the DTB, and a2 the memory pointer referencing the struct fw_dynamic_info.

Once OpenSBI completes its M-mode initialisation, it transitions to S-mode, and ultimately branches to the pre-loaded U-Boot proper entry address in system RAM to continue the full boot sequence.

The first phase of U-Boot proper consists of S-mode assembly entry (start.S), where the incoming information from OpenSBI is parsed, the S-mode trap vector base address register (stvec) is set up to catch early faults, the S-mode interrupt enable flags (sie) are cleared to ensure sequential, deterministic initialisation, and the global pointer registers, thread pointer (tp) and global pointer (gp), get loaded.

The second phase consists of pre-relocation C initialisation (board_init_f), where a sequential array of early setup functions get executed, the driver model (DM) gets activated in dm_init_f from device tree information for early core devices (clocks, reset controllers, and pin multiplexers), the memory layout is calculated reserving space at the top of memory to store the relocated U-Boot binary, the master page tables, the permanent stack, and the new heap.

The third phase consists of the binary relocation relocate.S, where U-Boot migrates itself out of the way towards the top of memory leaving low memory addresses available for further boot stages, the .rela.dyn relocation tables are iterated updating all address offsets to point to the newly relocated ones, the RISC-V fence instruction fence.i gets called to flush any stale instructions out of the CPU pipeline execution cache.

The fourth phase consists of the post-relocation C initialisation (board_init_r), where the full suite of drivers as declared in the device tree gets bound to, the environment gets loaded from non-volatile storage with configuration scripts and parameters (e.g. bootargs, bootcmd), and further subsystems like Ethernet, PCIe/NVMe, and USB get dynamically probed.

The fifth and final phase consists of the main loop and OS handover, where user interaction may be established or the default boot initiated after a certain countdown time. The interactive shell features command parsing in a cli_loop, where user debugging commands are evaluated. The OS handover gets initiated by a booting call like booti, bootm or bootefi, where the system gets prepared to transfer control to the next S-mode stage, first disabling all active hardware peripheral timers, flushing caches, and triggering the next entry point. Just like in previous stages, information gets passed using the FW_DYNAMIC mechanism via CPU registers.

We get the U-Boot source code:

1
2
 [zim@toolbx ~]$ git clone https://github.com/u-boot/u-boot.git
⬢ [zim@toolbx ~]$ cd u-boot

However, if we do like the OpenSBI banner to be printed, we need to change its configuration as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
 [zim@toolbx u-boot]$ git diff
diff --git a/configs/qemu-riscv64_spl_defconfig b/configs/qemu-riscv64_spl_defconfig
index 04dec72ceac7..4212699c3dd3 100644
--- a/configs/qemu-riscv64_spl_defconfig
+++ b/configs/qemu-riscv64_spl_defconfig
@@ -18,6 +18,7 @@ CONFIG_DISPLAY_BOARDINFO=y
 # CONFIG_BOARD_INIT is not set
 CONFIG_SPL_MAX_SIZE=0x100000
 CONFIG_SPL_SYS_MALLOC=y
+CONFIG_SPL_OPENSBI_SCRATCH_OPTIONS=0x0
 # CONFIG_CMD_MII is not set
 CONFIG_ENV_RELOC_GD_ENV_ADDR=y
 CONFIG_DM_MTD=y

Guess how/where I found out about this OpenSBI scratch-options handling? Yes, the attentive reader noticed it during our previous debugging session (the terminal box just above the U-Boot section) at line 32, source code line 51!

Only now we configure/build U-Boot for SPL:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
 [zim@toolbx u-boot]$ make qemu-riscv64_spl_defconfig
⬢ [zim@toolbx u-boot]$ make OPENSBI=../opensbi/build/platform/generic/firmware/fw_dynamic.bin
...
  LDS     spl/u-boot-spl.lds
  LD      spl/u-boot-spl
  OBJCOPY spl/u-boot-spl-nodtb.bin
mkdir -p spl/dts/
  FDTGREP spl/dts/dt-spl.dtb
  COPY    spl/u-boot-spl.dtb
  CAT     spl/u-boot-spl-dtb.bin
  COPY    spl/u-boot-spl.bin
  SYM     spl/u-boot-spl.sym
  MKIMAGE u-boot.img
  COPY    u-boot.dtb
  MKIMAGE u-boot-dtb.img
  BINMAN  .binman_stamp
  OFCHK   .config

This actually builds the SPL binary spl/u-boot-spl.bin, as well as the U-Boot proper binary u-boot.bin packaged together with the OpenSBI binary fw_dynamic.bin, into a single flattened image tree (FIT) itb.fit.fit blob. Its former name was u-boot.itb, where ITB meant image tree blob. As I am more familiar with that former name, I will stick to u-boot.itb throughout this article. Such .fit or .itb files may be inspected using U-Boot's mkimage tool:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
 [zim@toolbx u-boot]$ mkimage -l u-boot.itb
FIT description: Configuration to load OpenSBI before U-Boot
Created:         Sat May 30 18:23:39 2026
 Image 0 (uboot)
  Description:  U-Boot
  Created:      Sat May 30 18:23:39 2026
  Type:         Standalone Program
  Compression:  uncompressed
  Data Size:    895160 Bytes = 874.18 KiB = 0.85 MiB
  Architecture: RISC-V
  Load Address: 0x81200000
  Entry Point:  unavailable
 Image 1 (opensbi)
  Description:  OpenSBI fw_dynamic Firmware
  Created:      Sat May 30 18:23:39 2026
  Type:         Firmware
  Compression:  uncompressed
  Data Size:    275768 Bytes = 269.30 KiB = 0.26 MiB
  Architecture: RISC-V
  OS:           RISC-V OpenSBI
  Load Address: 0x80100000
 Default Configuration: 'conf-1'
 Configuration 0 (conf-1)
  Description:  NAME
  Kernel:       unavailable
  Firmware:     opensbi
  Loadables:    uboot

The interested reader may find much more information in the Flattened Image Tree Specification.

But, now, let us actually launch this by QEMU, which is slightly more complex, as not only a -bios argument, but also a loader device with the additional parts needs to be specified:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
 [zim@toolbx u-boot]$ qemu-system-riscv64 -bios spl/u-boot-spl.bin \
-device loader,file=u-boot.itb,addr=0x80200000 -machine virt -nographic

U-Boot SPL 2026.07-rc3-00008-g987907ae4bcc-dirty (Jun 01 2026 - 04:26:09 +0200)
Trying to boot from RAM

OpenSBI v1.8.1
Build time: 2026-05-29 17:28:04 +0200
Build compiler: clang version 21.1.1 (https://github.com/llvm/llvm-project.git
 5a86dc996c26299de63effc927075dcbfb924167)
   ____                    _____ ____ _____
  / __ \                  / ____|  _ \_   _|
 | |  | |_ __   ___ _ __ | (___ | |_) || |
 | |  | | '_ \ / _ \ '_ \ \___ \|  _ < | |
 | |__| | |_) |  __/ | | |____) | |_) || |_
  \____/| .__/ \___|_| |_|_____/|____/_____|
        | |
        |_|

Platform Name               : riscv-virtio,qemu
Platform Features           : medeleg
Platform HART Count         : 1
Platform HART Protection    : pmp
Platform IPI Device         : aclint-mswi
Platform Timer Device       : aclint-mtimer @ 10000000Hz
Platform Console Device     : uart8250
Platform HSM Device         : ---
Platform PMU Device         : ---
Platform Reboot Device      : syscon-reboot
Platform Shutdown Device    : syscon-poweroff
Platform Suspend Device     : ---
Platform CPPC Device        : ---
Firmware Base               : 0x80100000
Firmware Size               : 321 KB
Firmware RW Offset          : 0x40000
Firmware RW Size            : 65 KB
Firmware Heap Offset        : 0x47000
Firmware Heap Size          : 37 KB (total), 0 KB (reserved), 12 KB (used), 23 KB (free)
Firmware Scratch Size       : 4096 B (total), 1464 B (used), 2632 B (free)
Runtime SBI Version         : 3.0
Standard SBI Extensions     : time,rfnc,ipi,base,hsm,srst,pmu,dbcn,fwft,legacy,dbtr,sse
Experimental SBI Extensions : none

Domain0 Name                : root
Domain0 Boot HART           : 0
Domain0 HARTs               : 0*
Domain0 Region00            : 0x0000000080140000-0x000000008015ffff M: (F,R,W) S/U: ()
Domain0 Region01            : 0x0000000080100000-0x000000008013ffff M: (F,R,X) S/U: ()
Domain0 Region02            : 0x0000000000100000-0x0000000000100fff M: (I,R,W) S/U: (R,W)
Domain0 Region03            : 0x0000000010000000-0x0000000010000fff M: (I,R,W) S/U: (R,W)
Domain0 Region04            : 0x0000000002000000-0x000000000200ffff M: (I,R,W) S/U: ()
Domain0 Region05            : 0x000000000c400000-0x000000000c5fffff M: (I,R,W) S/U: (R,W)
Domain0 Region06            : 0x000000000c000000-0x000000000c3fffff M: (I,R,W) S/U: (R,W)
Domain0 Region07            : 0x0000000000000000-0xffffffffffffffff M: () S/U: (R,W,X)
Domain0 Next Address        : 0x0000000081200000
Domain0 Next Arg1           : 0x00000000812da8c8
Domain0 Next Mode           : S-mode
Domain0 SysReset            : yes
Domain0 SysSuspend          : yes

Boot HART ID                : 0
Boot HART Domain            : root
Boot HART Priv Version      : v1.12
Boot HART Base ISA          : rv64imafdch
Boot HART ISA Extensions    : sstc,zicntr,zihpm,zicboz,zicbom,sdtrig,svadu
Boot HART PMP Count         : 16
Boot HART PMP Granularity   : 2 bits
Boot HART PMP Address Bits  : 54
Boot HART MHPM Info         : 16 (0x0007fff8)
Boot HART Debug Triggers    : 2 triggers
Boot HART MIDELEG           : 0x0000000000001666
Boot HART MEDELEG           : 0x0000000000f4b509


U-Boot 2026.07-rc3-00008-g987907ae4bcc-dirty (Jun 01 2026 - 04:26:09 +0200)

CPU:   riscv
Model: riscv-virtio,qemu
DRAM:  128 MiB
using memory 0x86ef5000-0x87715000 for malloc()
Core:  27 devices, 14 uclasses, devicetree: board
Flash: 32 MiB
Loading Environment from nowhere... OK
In:    serial,usbkbd
Out:   serial,vidconsole
Err:   serial,vidconsole
No USB controllers found
Net:   No ethernet found.

Hit any key to stop autoboot: 0

Device 0: unknown device

Device 0: unknown device

Device 1: unknown device
scanning bus for devices...

Device 0: unknown device
No ethernet found.
No ethernet found.
=> poweroff
poweroff ...

Debugging

Now, it starts to get interesting. We can launch QEMU again in the first terminal like before, but again with the -S argument, which instructs it to just wait for the gdb debugger to connect to it and further specify what to do next:

1
2
 [zim@toolbx u-boot]$ qemu-system-riscv64 -S -bios spl/u-boot-spl.bin \
-device loader,file=u-boot.itb,addr=0x80200000 -gdb tcp::1234 -machine virt -nographic

In the second terminal, we start gdb, giving it not only the OpenSBI symbol file as before, but also U-Boot's SPL and U-Boot proper symbol files as well. We instruct it to break at board_init_f and spl_invoke_opensbi in the U-Boot SPL, sbi_boot_print_banner and sbi_hart_switch_mode in OpenSBI and display_options in U-Boot proper. Then we can let it print the various banners line by line:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
 [zim@toolbx u-boot]$ gdb -ex 'add-symbol-file spl/u-boot-spl 0x80000000' \
-ex 'add-symbol-file ../opensbi/build/platform/generic/firmware/fw_payload.elf 0x80100000' \
-ex 'add-symbol-file u-boot 0x81200000' \
-ex 'add-symbol-file u-boot 0x87715000' -ex 'target remote localhost:1234'
GNU gdb (Fedora Linux) 17.1-6.fc44
Copyright (C) 2025 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word".
add symbol table from file "spl/u-boot-spl" at
    .text_addr = 0x80000000
(y or n) y
Reading symbols from spl/u-boot-spl...
add symbol table from file "../opensbi/build/platform/generic/firmware/fw_payload.elf" at
    .text_addr = 0x80100000
(y or n) y
Reading symbols from ../opensbi/build/platform/generic/firmware/fw_payload.elf...
add symbol table from file "u-boot" at
    .text_addr = 0x81200000
(y or n) y
Reading symbols from u-boot...
add symbol table from file "u-boot" at
    .text_addr = 0x87715000
(y or n) y
Reading symbols from u-boot...
Remote debugging using localhost:1234
⚠️ warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.
0x0000000000001000 in ?? ()
(gdb) b board_init_f
Breakpoint 1 at 0x80000a3a: board_init_f. (3 locations)
(gdb) b spl_invoke_opensbi
Breakpoint 2 at 0x8000167a: file common/spl/spl_opensbi.c, line 55.
(gdb) b sbi_boot_print_banner
Breakpoint 3 at 0x8010b14c: file /var/home/zim/Sources/opensbi/lib/sbi/sbi_init.c, line 51.
(gdb) b sbi_hart_switch_mode
Breakpoint 4 at 0x80104b68: file /var/home/zim/Sources/opensbi/lib/sbi/sbi_hart.c, line 779.
(gdb) b display_options
Breakpoint 5 at 0x8125bec0: display_options. (2 locations)
(gdb) c
Continuing.

Breakpoint 1.1, board_init_f (dummy=0) at arch/riscv/lib/spl.c:26
26      ret = spl_early_init();
(gdb) n
27      if (ret)
(gdb) n
30      riscv_cpu_setup();
(gdb) n
32      preloader_console_init();
(gdb) n
34      ret = spl_board_init_f();
(gdb) c
Continuing.

Breakpoint 2, spl_invoke_opensbi (spl_image=0x801fff60, spl_image@entry=0x801ffe90) at common/spl/spl_opensbi.c:55
55      if (!spl_image->fdt_addr) {
(gdb) n
60      if (!IS_ALIGNED((uintptr_t)spl_image->fdt_addr, 8)) {
(gdb) n
89      ret = spl_opensbi_find_os_node(spl_image->fdt_addr, &os_node, os_type);
(gdb) n
97      ret = fit_image_get_entry(spl_image->fdt_addr, os_node, &os_entry);
(gdb) n
98      if (ret)
(gdb) n
99          ret = fit_image_get_load(spl_image->fdt_addr, os_node, &os_entry);
(gdb) n
102     opensbi_info.magic = FW_DYNAMIC_INFO_MAGIC_VALUE;
(gdb) n
103     opensbi_info.version = FW_DYNAMIC_INFO_VERSION;
(gdb) n
105     opensbi_info.next_mode = FW_DYNAMIC_INFO_NEXT_MODE_S;
(gdb) n
106     opensbi_info.options = CONFIG_SPL_OPENSBI_SCRATCH_OPTIONS;
(gdb) n
107     opensbi_info.boot_hart = gd->arch.boot_hart;
(gdb) n
109     opensbi_entry = (opensbi_entry_t)spl_image->entry_point;
(gdb) n
110     invalidate_icache_all();
(gdb) n
123     ret = smp_call_function((ulong)spl_image->entry_point,
(gdb) n
126     if (ret)
(gdb) n
129     opensbi_entry(gd->arch.boot_hart, (ulong)spl_image->fdt_addr,
(gdb) c
Continuing.

Breakpoint 3, sbi_boot_print_banner (scratch=0x80146000) at /var/home/zim/Sources/opensbi/lib/sbi/sbi_init.c:51
51      if (scratch->options & SBI_SCRATCH_NO_BOOT_PRINTS)
(gdb) n
55      sbi_printf("\nOpenSBI %s\n", OPENSBI_VERSION_GIT);
(gdb) n
62      sbi_printf("Build time: %s\n", OPENSBI_BUILD_TIME_STAMP);
(gdb) n
66      sbi_printf("Build compiler: %s\n", OPENSBI_BUILD_COMPILER_VERSION);
(gdb) n
69      sbi_printf(BANNER);
(gdb) n
70  }
(gdb) c
Continuing.

Breakpoint 4, sbi_hart_switch_mode (arg0=0, arg1=2167253192, next_addr=2166358016, next_mode=1, next_virt=false) at /var/home/zim/Sources/opensbi/lib/sbi/sbi_hart.c:779
779     switch (next_mode) {
(gdb) n
783         if (!misa_extension('S'))
(gdb) n
794     val = csr_read(CSR_MSTATUS);
(gdb) n
795     val = INSERT_FIELD(val, MSTATUS_MPP, next_mode);
(gdb) n
804     if (misa_extension('H'))
(gdb) n
807     csr_write(CSR_MSTATUS, val);
(gdb) n
808     csr_write(CSR_MEPC, next_addr);
(gdb) n
810     if (next_mode == PRV_S) {
(gdb) n
811         if (next_virt) {
(gdb) n
817             csr_write(CSR_STVEC, next_addr);
(gdb) n
818             csr_write(CSR_SSCRATCH, 0);
(gdb) n
819             csr_write(CSR_SIE, 0);
(gdb) n
820             csr_write(CSR_SATP, 0);
(gdb) n
832     __asm__ __volatile__("mret" : : "r"(a0), "r"(a1));
(gdb) c
Continuing.

Breakpoint 1.2, board_init_f (boot_flags=0) at common/board_f.c:1036
1036        gd->flags &= ~GD_FLG_HAVE_CONSOLE;
(gdb) c
Continuing.

Breakpoint 5.1, display_options () at lib/display_options.c:50
50      display_options_get_banner(true, buf, sizeof(buf));
(gdb) n
51      printf("%s", buf);
(gdb) n
53      return 0;
(gdb) c
Continuing.
[Inferior 1 (process 1) exited normally]
(gdb) exit

The preloader_console_init in U-Boot SPL is what prints its banner and spl_invoke_opensbi is where it hands over execution to OpenSBI. The sbi_boot_print_banner as we learned already, prints OpenSBI's banner and sbi_hart_switch_mode does the mret instruction handing over execution to U-Boot proper. The display_options in U-Boot proper finally prints its banner.

The observant reader may have noticed that we instructed gdb to load U-Boot proper's symbols twice. This has to do with the fact that U-Boot proper actually re-locates itself during execution towards the end of RAM leaving as much RAM as possible free to load further artifacts to be booted. To find the relocation address one can use U-Boot's bdinfo command:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
=> bdinfo
boot_params = 0x0000000000000000
DRAM bank   = 0x0000000000000000
-> start    = 0x0000000080000000
-> size     = 0x0000000008000000
flashstart  = 0x0000000020000000
flashsize   = 0x0000000002000000
flashoffset = 0x0000000000000000
baudrate    = 115200 bps
relocaddr   = 0x0000000087715000
reloc off   = 0x0000000006515000
Build       = 64-bit
current eth = unknown
eth-1addr   = (not set)
IP addr     = <NULL>
fdt_blob    = 0x0000000086ef28e0
lmb_dump_all:
 memory.count = 0x1
 memory[0]  [0x80000000-0x87ffffff], 0x8000000 bytes, flags: none
 reserved.count = 0x3
 reserved[0]    [0x80100000-0x8015ffff], 0x60000 bytes, flags: no-map
 reserved[1]    [0x85eef000-0x85ef1fff], 0x3000 bytes, flags: no-notify, no-overwrite
 reserved[2]    [0x85ef28d0-0x87ffffff], 0x210d730 bytes, flags: no-overwrite
devicetree  = board
serial addr = 0x0000000010000000
 width      = 0x0000000000000001
 shift      = 0x0000000000000000
 offset     = 0x0000000000000000
 clock      = 0x0000000000384000
boot hart   = 0x0000000000000000
firmware fdt= 0x00000000812da8c8

UEFI Boot Manager

Systemd-boot (sd-boot for short, previously known as gummiboot) is a simple, easy-to-configure UEFI (Unified Extensible Firmware Interface) boot manager. It provides a textual menu to select the entry to boot and an editor for the kernel command line. Its source code is located primarily within src/boot/ (previously src/boot/efi/) within systemd.

The first phase consists of the entry point handover, where control gets transferred to the primary UEFI payload with efi_main() in src/boot/efi/boot.c being the initial code hook. The UEFI firmware, in our case U-Boot proper, passes standard arguments providing access to core console interfaces, system tables, and basic file system drivers.

The second phase consists of execution environment sanity checks, where the configuration is validated, a watchdog is initialised to safely reset the platform if a hard freeze occurs during image parsing, and optional secure boot checks are invoked, including random seed provisioning. Note that secure boot is outside the scope of this article.

The third phase consists of driver and file system probing, where file-access routines get initialised to locate available OS targets. The EFI system partition (ESP) where the binary was run from gets probed, and supplementary storage or file system drivers are loaded by iterating over /EFI/systemd/drivers/. It dynamically handles and loads any such compiled specifically with the architecture suffix riscv64.efi.

The fourth phase consists of config processing and menu presentation, where /loader/loader.conf gets parsed to handle general preferences, /loader/entries/*.conf gets scanned for target detection, searching inside /EFI/Linux/ looking for Unified Kernel Images (UKIs), and drawing the interactive boot menu, usually automatically booting the default target after a timeout occurred.

The fifth and final phase consists of executing the Kernel via EFI stub, where systemd-boot hands execution directly off to a kernel image after selection. It calls the EFI_BOOT_SERVICES.StartImage() execution standard, as the Linux kernel acts like a second UEFI application. Hardware information gets passed through via flattened device tree (FDT) or ACPI tables. Note that ACPI is outside the scope of this article.

To build systemd to include systemd-boot, make sure to have python3-pyelftools available and proceed as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
 [zim@toolbx ~]$ git clone https://github.com/systemd/systemd.git
⬢ [zim@toolbx ~]$ cd systemd
⬢ [zim@toolbx systemd]$ git checkout -b v260.2-test v260.2
⬢ [zim@toolbx systemd]$ cat riscv64.txt
[binaries]
c = '/var/home/zim/Toolchains/riscv64-glibc-ubuntu-24.04-gcc-2026.05.19-nightly/bin/riscv64-unknown-linux-gnu-gcc'
cpp = '/var/home/zim/Toolchains/riscv64-glibc-ubuntu-24.04-gcc-2026.05.19-nightly/bin/riscv64-unknown-linux-gnu-g++'
ar = '/var/home/zim/Toolchains/riscv64-glibc-ubuntu-24.04-gcc-2026.05.19-nightly/bin/riscv64-unknown-linux-gnu-ar'
strip = '/var/home/zim/Toolchains/riscv64-glibc-ubuntu-24.04-gcc-2026.05.19-nightly/bin/riscv64-unknown-linux-gnu-strip'
exe_wrapper = 'qemu-system-riscv64 -nographic'

[host_machine]
system = 'linux'
cpu_family = 'riscv64'
cpu = 'riscv64'
endian = 'little' [zim@toolbx systemd]$ meson setup --cross-file riscv64.txt build/ && ninja -C build/
⬢ [zim@toolbx systemd]$ file build/src/boot/systemd-bootriscv64.efi
build/src/boot/systemd-bootriscv64.efi: PE32+ executable for EFI (application),
 RISC-V 64-bit (stripped to external PDB), 6 sections

Note: The latest main branch did not work for me, which is why I picked released v260.2 instead, which seems to work fine.

Testing/debugging sd-boot is slightly more involved, as one can't just easily deploy it on its own, plus what would be the point, as we want it to subsequently boot a full Linux system. So we need to build one for RISC-V first. We can do this using Freedesktop SDK:

1
2
3
4
5
6
 [zim@toolbx ~]$ git clone https://gitlab.com/freedesktop-sdk/freedesktop-sdk.git
⬢ [zim@toolbx ~]$ cd freedesktop-sdk
⬢ [zim@toolbx freedesktop-sdk]$ bst --colors -o bootstrap_build_arch x86_64 \
-o target_arch riscv64 build vm/minimal/efi.bst
⬢ [zim@toolbx freedesktop-sdk]$ bst --colors -o bootstrap_build_arch x86_64 \
-o target_arch riscv64 checkout vm/minimal/efi.bst vm-minimal-efi/

We can now replace sd-boot inside the EFI partition of that disk.img with our previously built one:

1
2
3
4
5
zim@fedora:~/Sources/u-boot$ sudo losetup -P /dev/loop0 ../freedesktop-sdk/vm-minimal-efi/disk.img
zim@fedora:~/Sources/u-boot$ sudo mount /dev/loop0p1 /mnt
zim@fedora:~/Sources/u-boot$ sudo cp ../systemd/build/src/boot/systemd-bootriscv64.efi /mnt/EFI/BOOT/BOOTRISCV64.EFI
zim@fedora:~/Sources/u-boot$ sudo umount /mnt
zim@fedora:~/Sources/u-boot$ sudo losetup -D

With that in place, we can now launch it in QEMU:

1
2
3
4
 [zim@toolbx u-boot]$ qemu-system-riscv64 -bios spl/u-boot-spl.bin -device loader,file=u-boot.itb,addr=0x80200000 \
-device virtio-blk-device,drive=hd0 -device virtio-net-pci,netdev=n1 \
-drive file=../freedesktop-sdk/vm-minimal-efi/disk.img,format=raw,id=hd0,if=none \
-m 256M -machine virt -netdev user,id=n1 -nographic

Note: Without setting the -m 256M argument to QEMU, it will fail to load with a Unhandled exception: Load access fault.

Once we are on the U-Boot command line shell, we can poke it some more to find out about what it loads next:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
=> echo $bootcmd
run distro_bootcmd
=> echo $distro_bootcmd
scsi_need_init=; setenv nvme_need_init; virtio_need_init=; for target in ${boot_targets};
 do run bootcmd_${target}; done
=> echo $boot_targets
nvme0 virtio0 virtio1 scsi0 dhcp
=> echo $bootcmd_virtio0
devnum=0; run virtio_boot
=> echo $virtio_boot
run boot_pci_enum; run virtio_init; if virtio dev ${devnum}; then devtype=virtio;
 run scan_dev_for_boot_part; fi
=> echo $scan_dev_for_boot_part
if env exists distro_bootpart; then setenv devplist ${distro_bootpart};
 else part list ${devtype} ${devnum} -bootable devplist; env exists devplist || setenv devplist 1;
 fi; for distro_bootpart in ${devplist}; do if fstype ${devtype} ${devnum}:${distro_bootpart} bootfstype;
 then part uuid ${devtype} ${devnum}:${distro_bootpart} distro_bootpart_uuid ;
 run scan_dev_for_boot; fi; done; setenv devplist
=> echo $scan_dev_for_boot
echo Scanning ${devtype} ${devnum}:${distro_bootpart}...; for prefix in ${boot_prefixes};
 do run scan_dev_for_extlinux; run scan_dev_for_scripts; done;run scan_dev_for_efi;
=> echo $scan_dev_for_efi
setenv efi_fdtfile ${fdtfile}; for prefix in ${efi_dtb_prefixes};
 do if test -e ${devtype} ${devnum}:${distro_bootpart} ${prefix}${efi_fdtfile};
 then run load_efi_dtb; fi;done;run boot_efi_bootmgr;
 if test -e ${devtype} ${devnum}:${distro_bootpart} efi/boot/bootriscv64.efi;
 then echo Found EFI removable media binary efi/boot/bootriscv64.efi; run boot_efi_binary;
 echo EFI LOAD FAILED: continuing...; fi; setenv efi_fdtfile
=> echo $boot_efi_binary
load ${devtype} ${devnum}:${distro_bootpart} ${kernel_addr_r} efi/boot/bootriscv64.efi;
 if fdt addr -q ${fdt_addr_r}; then bootefi ${kernel_addr_r} ${fdt_addr_r};
 else bootefi ${kernel_addr_r} ${fdtcontroladdr};fi
=> load virtio 0:1 $loadaddr efi/boot/bootriscv64.efi
176128 bytes read in 1 ms (168 MiB/s)
=> bootefi $loadaddr
Missing RNG device for EFI_RNG_PROTOCOL
No RNG device
Booting /efi\boot\bootriscv64.efi
systemd-boot@0x8de83000 260.2

And once we are fully booted, we can look at the partition layout and where sd-boot's binary and configuration got deployed to:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
Freedesktop Platform 26.08beta localhost ttyS0
localhost login: root
Password:
[root@localhost ~]# lsblk
NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
vda    254:0    0  489M  0 disk
├─vda1 254:1    0   36M  0 part /efi
└─vda2 254:2    0  452M  0 part /
[root@localhost ~]# ls -l /efi/EFI/BOOT/BOOTRISCV64.EFI
-rwx------ 1 root root 101888 Nov 10  2011 /efi/EFI/BOOT/BOOTRISCV64.EFI
[root@localhost ~]# file /efi/EFI/BOOT/BOOTRISCV64.EFI
/efi/EFI/BOOT/BOOTRISCV64.EFI: PE32+ executable for EFI (application),
 RISC-V 64-bit (stripped to external PDB), 7 sections
[root@localhost ~]# cat /efi/loader/loader.conf
timeout 3
editor yes
console-mode keep

Linux Kernel

The Linux kernel needs to be built as an EFI executable (CONFIG_EFI_STUB) for the EFI boot manager to be able to execute it:

1
2
3
4
5
6
7
Freedesktop Platform 26.08beta localhost ttyS0
localhost login: root
Password:
[root@localhost ~]# ls /efi
config  dtbs  EFI  initramfs-6.19.6.img  loader  System.map  ubootefi.var  vmlinuz
[root@localhost ~]# file /efi/vmlinuz
/efi/vmlinuz: Linux kernel RISC-V64 EFI executable gzip compressed zboot Image

Note that the Linux kernel EFI stub exits the boot services by executing ExitBootServices(), terminating systemd-boot's operations and shifting control into the Linux S-mode kernel environment.

Conclusion

The RISC-V boot process is well understood, and we are ready to tackle applying our knowledge to any RISC-V system out there. Looking forward to getting our hands on the first RVA23 boards, making sure they have their upstream boot chains ready.

Other Content

Get in touch to find out how Codethink can help you

connect@codethink.co.uk +44 161 660 9930