RISC-V Bare Metal Programming Chapter 1: The Setup
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.
Why RISC-V?
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
$ make
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: label 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 (:). instruction Instructions consist of either RISC-V assembly instructions or assembler directives. Assembler directives are prefixed with a period (.) comment 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:
.text
.global _start
_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 targeting 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
(qemu)
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 privilege 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
.nographic
: 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.
Conclusion
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.
Footnotes
- http://www.bravegnu.org/gnu-eprog/index.html
- https://wiki.osdev.org/Bare_Bones
- 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.
Filed under: RISC V - @ 2019-11-01 21:27