< up >

let’s boot with riscv

Let’s boot some C code1 with riscv64 using qemu: riscy2

After about half a year of os development abstinence. I fell into the riscv-os-dev-rabbit-hole. Those risc, arm, riscv buzzwords are in my news bubble for a while. Now it’s time to get my hands dirty.


RISC-V vs. ARM vs. ARM64 vs. AArch64

ARM64 and Aarch64 are both the same3: an implementation of the proprietary RISC architecture family ARM. RISC-V on the other side is an open-source architecture.

Those RISC’s are fairly modern4 and faster than CISC due to their nature of being reduced instead of complex. I’m not sure what this “faster” actually means while a CISC operation may be slower than a single RISC operation, but with RISC more operations are needed.

Getting started - barebone tutorial

I initialized my riscy bootloader using the os.dev risc-v bare bones tutorial containing everything needed for a hello world:


In order to compile and link riscv code on a x86 machine, a cross-compiler version of gcc is needed. For Ubuntu and Fedora I could make out the package gcc-riscv64-linux-gnu containing all needed binaries.

Despite the different binaries names and the dropped -lgcc flag5, the commands look the same as in the Tutorial:

$ riscv64-linux-gnu-gcc -Wall -Wextra -c -mcmodel=medany kernel.c -o kernel.o -ffreestanding
$ riscv64-linux-gnu-gcc -c entry.S -o entry.o
$ riscv64-linux-gnu-ld -T linker.ld -nostdlib kernel.o entry.o -o kernel.elf

Let’s boot it via qemu-system-riscv64 -machine virt -bios none -kernel kernel.elf -serial mon:stdio.


Interact with the outside world

Devices and their drivers are needed to interact with the outside world. Usually devices are defined by the underlying hardware. In our case Qemu and the used machine type virt defines what devices can be used. A driver is defined by the operating system and provides an abstraction of such devices. The Tutorial makes use of the UART device for providing a simple console. Let’s discover devices and drivers separately.


Qemu provides a list of supported devices for our machine type virt. We also can make out our UART device.

But how can we make use of them?


Looking at the Tutorial, the driver consists of a magic address and… well actually nothing more than read and write from it:

unsigned char * uart = (unsigned char *)0x10000000; 
void putchar(char c) {
    *uart = c;

int main(){

How can this make any sense?

Magic address

The address is actually well-defined by Qemu’s machine type for the UART device. As far as I understood it, the address-space of the devices are mapped into our address space and directly usable. Qemu can dump all those devices and addresses which can be made readable using the device-tree-compiler, as I found out via this riscv-from-scratch blog post.

And again, we can can make out our UART device with some additional information:

chosen {
  bootargs = [00];
  stdout-path = "/soc/uart@10000000";
uart@10000000 {
  interrupts = <0x0a>;
  interrupt-parent = <0x03>;
  clock-frequency = "\08@";
  reg = <0x00 0x10000000 0x00 0x100>;
  compatible = "ns16550a";


The Universal Asynchronous Receiver-Transmitter is used for serial console. Looking at the tutorial, they seem to just read/write to a pointer:

// UART address
unsigned char * uart = (unsigned char *)0x10000000; 
// print char
*uart = c;
// read char

Coming from traditional application development, this look very odd assigning a value to a variable that was read from the same variable before. It makes sense because the uart variable is not pointing to ordinary memory but the address space of the UART device, starting with our magic address. The device actualy reacts to bytes we write and itself writes something back.

Currently our keyboard is connected (via qemu) to read buffer of the device and the stdout of our terminal (again via qemu) is connected to the write buffer.

Since this barely works with stdin and it isn’t clear how the driver is set up, I rather set it up explicitly. There is a pretty short but descriptive riscv uart driver to gain inspiration.

First all needed addresses and flags get defined in the header:

#define UART0 0x10000000
#define uart_addr(offset) ((unsigned char *)(UART0 + offset))
#define uart_write(offset, value) (*(uart_addr(offset)) = (value))
#define uart_read(offset) (*(uart_addr(offset)))

/* The Interrupt Enable Register (IER) masks the incoming interrupts from receiver ready,
 * transmitter empty, line status and modem status registers to the INT output pin.

/* The line Control Register is used to specify the asynchronous data communication format. 
 * The number of the word length, stop bits, and parity can be selected by writing appro-
 * priate bits in this register.
/* The internal baud rate counter latch enable (DLAB).*/

/* bit 0+1: word length = 0b11 => 8 bits
 * bit 2:   number of stop bits = 0 => 1 bit
 * bit 3:   parity = 0 => no parity
 * bit 4:   even/odd parity => doesn't matter since parity is off
 * bit 5:   force parity => -*-
 * bit 6:   break control = 0 => no break control
 * bit 7:   set baud rate = 0 => don't set baud rate

/* This register is used to enable the FIFOs, clear the FIFOs, set the receiver FIFO trigger 
 * level, and select the type of DMA signaling.


Then use them within the module’s initializer:

void uart_init() {
  // disable any interrupt, so we can set up some uart settings
  uart_write(INTERRUPT_ENABLE_REGISTER, 0x00); 

  // enter baud rate mode

  // set baud rate to 38.4K
  uart_write(0, 0x01);
  uart_write(1, 0x00);

  // leave baud rate mode

  // enable and clear FIFO

  // enable interrupts again


After uart is working good enough, I wondered which other devices Qemu’s virt machinetype offers. Right in the beginning of the device-tree-map, a goldfish got my attention:

rtc@101000 {
  interrupts = <0x0b>;
  interrupt-parent = <0x03>;
  reg = <0x00 0x101000 0x00 0x1000>;
  compatible = "google,goldfish-rtc";

This is the google goldfish rtc device mapped at 0x101000. Qemu has a documentation about the rtc device and the linux kernel has a pretty small implementation.

I came up with something like this:

uint64_t time() {
  volatile uint32_t *rtc = (uint32_t *) 0x101000;
  uint64_t time_low = rtc[0];
  uint64_t time_high = rtc[1];
  return (time_high << 32 | time_low);

Which lead to the first ever nanosecond unix timestamp printed to the riscy console: 17074102021870520006.

Inline assembler

After having some convenience with basic drivers and an essential standard lib, it’s time to checkout riscv assembler itself using inline assembler.

Incrementing a number should be a simple start, right? Well, unlike x86’s inc, there is no pedant here. But addi gotcha covered: addi result, a, number where result and a are variables and number is a so-called imediate (literal) value. The inline syntax is a bit odd since it breaks the usual format we know from C/printf. This SO Post helped me to find the right syntax.

int inc(int n) {
  volatile int result;
  asm volatile(
      "addi %0, %1, 1;"
      : "=r" (result)
      : "r" (n)
  return result;

The keyword volatile is recommended to prevent compiler optimizations.


Although I learned a bit about RISC and the hardware qemu provides for it, I barely touched the differences between x86 and RISC. I wouldn’t wonder if the C “core” lib (despite the machine type specialities) is architecture-agnostic. But I really enjoyed starting with nothing and introduce concepts like strings or time. The bootloader is still a bit mysterious for me and I couldn’t figure out how to support VGA, but the UART driver was pretty straight forward.

Developing my first driver from scratch with my own findings, going through kernel code and tinkering around was awesome. It’s just a small goldfish but it meant a lot.

  1. Assembler won’t matter much as I use the bootloader from the bare-bones tutorial and start development with C for convenience.
  2. Together with Tch1b0
  3. Based on SO: Apple named it arm64 and all the others aarch64.
  4. RISC-V is from 2014 and not unlike the famous x86/i386 from 1978.
  5. riscv64-linux-gnu-ld: cannot find -lgcc: no error ¯\(ツ)
  6. $ date -d @1707410202.187052000 +"%Y-%m-%d %H:%M:%S.%N": 2024-02-08 17:36:42.187052000