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 $ 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
$ apt-get install qemu-system-misc
This will install several QEMU binaries supporting various architectures including:
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
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 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.oobject 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
-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
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
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.
- @ 2019-11-01 21:27