RISC-V Bare Metal Programming Chapter 1: The Setup

Submitted by MarcAdmin on Fri, 11/01/2019 - 21:27

This tutorial, will walk through the process of building and running a RISC-V program on bare metal hardware. The reader is assumed to be familiar with the GNU toolchain and basic C programming. Assembly experience is useful but should not be required to follow along.

There are many tutorials available to get started with bare metal programming1, 2. However, most of them are for more common architectures such as x86 and ARM-32. Moreover, many of the existing tutorials are targetted at aspiring OS developers and often assume that the reader is already familiar with embedded programming. The objective of this tutorial is to learn how to walk before being asked to run.

This tutorial is loosely modelled after a similar one for the ARM-32 architecture: Embedded Programming with the GNU Toolchain. However, the RISC-V ISA will be used rather than ARM-V5TE. QEMU will be used to emulate the hardware platform, thus allowing the learner to proceed without having to obtain an actual board.


RISC-V is a modern and open instruction set architecture (ISA). As opposed to X86 and ARM, which have existed for decades and have been updated incrementally, RISC-V was developed from scratch as a clean-slate, minimalist and open ISA informed by the mistakes of the past. As an open ISA, RISC-V is ideal for educational purposes as it is not subject to the whims or fates of a single corporation. More importantly, I have not found may resources for bare metal programming using this architecture3, therefore this tutorial aims to that knowledge void.

Setting Up the Host

Most programmers start off in a self-hosted envrionment. This means that they write programs for an environment using the same environment (e.g. writing GNU/Linux programs using a GNU/Linux machine). However, in embedded development, the machine on which a program is run (the target) will generally have a different architecture from the one on which it was created (the host). This section will outline the steps for setting up a GNU/Linux workstation as a host development environment (in my case Debian Buster) to build programs for a RISC-V 64-bit architecture. The steps oulined herein will be specific to Debian based GNU/Linux distributions (i.e. Ubuntu, PureOS, Mint)4.

The first step of any kind of programming is setting up the toolchain. Since we're building for the RISC-V architecture, we will need a suitable toolchain, and the RISC-V GNU Compiler Toolchain fits the bill.

Some dependencies are required in order to build the toolchain. These can be installed using the following command:

$ sudo apt-get install autoconf automake autotools-dev curl \
libmpc-dev libmpfr-dev libgmp-dev gawk build-essential bison \
flex texinfo gperf libtool patchutils bc zlib1g-dev libexpat-dev

Next retrieve the source for the RISC-V GNU toolchain and all the required sub-modules:

$ git clone --recursive https://github.com/riscv/riscv-gnu-toolchain

Since we are dealing with a bare metal system, the toolchain must be built for the Newlib library. The Newlib library is an implementation of the standard C library for embedded systems.

Choose a destination folder on your host system where the toolchain will be installed (this tutorial will assume /opt/riscv/), ensuring that its location is in the PATH. Then build the toolchain with the following commands:

./configure --prefix=/opt/riscv

Do As RISC Does

Once the compiler toolchain is setup, the next requirement is an environment in which to run the programs thereby generated. QEMU will be used for this purpose. Use apt-get on Debian systems to install QEMU:

$ apt-get install qemu-system-misc

This will install several QEMU binaries supporting various architectures including:

  • qemu-system-riscv64
  • qemu-system-riscv32

We're interested in the 64-bit RISC-V version.

RISC-V is Alive

This section describes the process of writing a simple RISC-V program in assembly, and running it on a bare metal virtual board emulated by QEMU. The programming examples are modelled after those in the Embedded Programming with the GNU Toolchain tutorial, but adapted from ARM-32 to the RISC-V 64-bit architecture.

Each line of the assembly program is composed of three optional elements: a label, an instruction, and a comment:

A label is a convenience to allow the program to refer to a particular memory location using a symbolic name. A label is composed of a sequence of alphanumeric characters, in addition to underscores (_) or dollar signs ($). A label will always be terminated by a colon (:).
Instructions consist of either RISC-V assembly instructions or assembler directives. Assembler directives are prefixed with a period (.)
Comments are preceded by an "#" charachter. Anything that follows that character will be ignored until the first newline character.

The first example will simply calculate the sum of two literal numerical values:

        .global _start
        li      a2, 5           # a2 = 5
        li      a3, 4           # a3 = 4
        add     a0, a2, a3      # a0 = a2 + a3
stop:   j       stop

The first line is an assembler directive which indicates that the program is meant to go in the "text" section of the binary file. The next line uses the .global directive to define the _start symbol. This will ensure that this symbol is visible to the loader. Next the _start label is defined to indicate the start offset of the program.

The first two instructions will load integer values 5 and 4 in to registers a2 and a3 respectively. The instruction mnemonic stands for "Load Immediate". This is a pseudo instruction since it does not actually correspond with a RISC-V opcode; internally this maps to a RISC-V addi instruction:

addi a2,zero,5
addi a3,zero,4

The last line defines the label "stop" and a jump instruction that will return the program counter to the memory address at that label; thus will loop indefinitely.

Save the program to a file called add.s then compile it using the following command:

$ riscv64-unknown-elf-as -o add.o add.s

This will create the add.o object file representing the assembled program. Before it can be used, the program must be linked into suitable executable file. We use the loader for this purpose. Moreover since we are targetting a bare metal machine, we have to ensure that our program is loaded to an address where it can be found by the processor.

We will be working with the virt machine emulated by QEMU. The reset vector for this machine is located at address 0x8000000. This means that the first instruction that will be executed when the processor is reset will be the one at memory location 0x80000000. Therefore we must ensure that the memory offset labelled "start" in our program is loaded at the reset vector location:

$ riscv64-unknown-elf-ld -Ttext=0x80000000 -o add.elf add.o

The -Ttext= options forces the text section (defined using the .text assembler directive) to be loaded at the given memory address. We can verify that the instruction with the label _start is at the correct location using the nm command. The output should be similar to the following:

$ riscv64-unknown-elf-nm add.elf 
0000000080001010 T __BSS_END__
0000000080001010 T __bss_start
0000000080001010 T __DATA_BEGIN__
0000000080001010 T _edata
0000000080001010 T _end
0000000080001810 A __global_pointer$
0000000080001010 T __SDATA_BEGIN__
0000000080000000 T _start
000000008000000c t stop

Notice that the _start label in the previous example is located at address 0000000080000000, which corresponds with the reset vector of the virt machine. We can now run the program in QEMU using the following command:

$ qemu-system-riscv64 -M virt -serial /dev/null -nographic -kernel add.elf
QEMU 3.1.0 monitor - type 'help' for more information

This executes the RISC-V 64-bit QEMU emulator with the following options:

-M virt
This sets the machine type to 'virt' which models a RISC-V VirtIO board using the priviledge RISC-V ISA version (1.10).
-serial /dev/null
Since there is no I/O in our the serial output is redirected to /dev/null.
Again, since there is no I/O, we don't need a graphical UI.
-kernel add.elf
Load the kernel in add.elf.

Although it doesn't seem that the program has done very much, remember that it is very basic and does not involve any kind if I/O. To ensure that it did what was expected, we have to inspect the state of the machine. The QEMU console allows us to do this. We can inspect the state of the registers using the info registers command:

(qemu) info registers
 pc       000000008000000c
 mhartid  0000000000000000
 mstatus  0000000000000000
 mip      0000000000000000
 mie      0000000000000000
 mideleg  0000000000000000
 medeleg  0000000000000000
 mtvec    0000000000000000
 mepc     0000000000000000
 mcause   0000000000000000
 zero 0000000000000000 ra   0000000000000000 sp   0000000000000000 gp   0000000000000000
 tp   0000000000000000 t0   0000000080000000 t1   0000000000000000 t2   0000000000000000
 s0   0000000000000000 s1   0000000000000000 a0   0000000000000009 a1   0000000000001020
 a2   0000000000000005 a3   0000000000000004 a4   0000000000000000 a5   0000000000000000
 a6   0000000000000000 a7   0000000000000000 s2   0000000000000000 s3   0000000000000000
 s4   0000000000000000 s5   0000000000000000 s6   0000000000000000 s7   0000000000000000
 s8   0000000000000000 s9   0000000000000000 s10  0000000000000000 s11  0000000000000000
 t3   0000000000000000 t4   0000000000000000 t5   0000000000000000 t6   0000000000000000
 ft0  0000000000000000 ft1  0000000000000000 ft2  0000000000000000 ft3  0000000000000000
 ft4  0000000000000000 ft5  0000000000000000 ft6  0000000000000000 ft7  0000000000000000
 fs0  0000000000000000 fs1  0000000000000000 fa0  0000000000000000 fa1  0000000000000000
 fa2  0000000000000000 fa3  0000000000000000 fa4  0000000000000000 fa5  0000000000000000
 fa6  0000000000000000 fa7  0000000000000000 fs2  0000000000000000 fs3  0000000000000000
 fs4  0000000000000000 fs5  0000000000000000 fs6  0000000000000000 fs7  0000000000000000
 fs8  0000000000000000 fs9  0000000000000000 fs10 0000000000000000 fs11 0000000000000000
 ft8  0000000000000000 ft9  0000000000000000 ft10 0000000000000000 ft11 0000000000000000

Remember that we had loaded the immediate value 5 in register a2, and the immediate value 4 in register a3. The values in the register dump reflect this. Moreover the program will sum the values in a2 and a3 and store the result in register a0. The regiseter dump shows that the value in register a0 is in fact 9, which is the sum of 4 and 5. Moreover we see that the program counter in register pc is at memory address 0x8000000c which corresponds with the label "stop" which is an infinite loop.


This tutorial went through the process of setting up a host environment for developing bare metal RISC-V programs, creating a simple program to add two numbers in RISC-V assembly, then running this program using QEMU. In future tutorials, the coding examples will get progressively more complex. However, the host environment will remain the same.



One good reference is Stepen Marz tutorial for building an OS in Rust: http://osblog.stephenmarz.com/index.html


Future tutorials may focus on setting up other host platforms.


Add new comment