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.
Contents
- RISC-V vs. ARM vs. ARM64 vs. AArch64
- Getting started - barebone tutorial
- Interact with the outside world
- Summary
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:
entry.S
: Entry point and initial bootloader preparing everything before calling the main function in the C kernel code.kernel.c
: C kernel printing hello world and reading from the keyboard by UART.linker.ld
: Defines the memory layout of the resulting binary image that can be booted by qemu.
Build
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
.
\o/
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.
Devices
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?
Drivers
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(){
putchar(*uart);
}
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";
};
- uart device emulates the ns16550A IC
- address spaces starts at magic address
0x10000000
- qemu uses this device for serial stdin/stdout
UART
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
putchar(*uart);
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.
*/
#define INTERRUPT_ENABLE_REGISTER 0x1
#define INTERRUPT_ENABLE_REGISTER_RX_ENABLE 0x1
#define INTERRUPT_ENABLE_REGISTER_TX_ENABLE 0x2
/* 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.
*/
#define LINE_CONTROL_REGISTER 0x3
/* The internal baud rate counter latch enable (DLAB).*/
#define LINE_CONTROL_REGISTER_BAUD_LATCH (0x1<<7)
/* 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
*/
#define LINE_CONTROL_REGISTER_EIGHT_BITS_NO_PARITY 0x3
/* This register is used to enable the FIFOs, clear the FIFOs, set the receiver FIFO trigger
* level, and select the type of DMA signaling.
*/
#define FIFO_CONTROL_REGISTER 0x2
#define FIFO_CONTROL_REGISTER_ENABLE 0x1
#define FIFO_CONTROL_REGISTER_CLEAR_RX 0x2
#define FIFO_CONTROL_REGISTER_CLEAR_TX 0x4
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
uart_write(LINE_CONTROL_REGISTER, LINE_CONTROL_REGISTER_BAUD_LATCH);
// set baud rate to 38.4K
uart_write(0, 0x01);
uart_write(1, 0x00);
// leave baud rate mode
uart_write(LINE_CONTROL_REGISTER, LINE_CONTROL_REGISTER_EIGHT_BITS_NO_PARITY);
// enable and clear FIFO
uart_write(
FIFO_CONTROL_REGISTER,
FIFO_CONTROL_REGISTER_ENABLE |
FIFO_CONTROL_REGISTER_CLEAR_TX |
FIFO_CONTROL_REGISTER_CLEAR_RX
);
// enable interrupts again
uart_write(INTERRUPT_ENABLE_REGISTER,
INTERRUPT_ENABLE_REGISTER_RX_ENABLE |
INTERRUPT_ENABLE_REGISTER_TX_ENABLE
);
}
RTC
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: 1707410202187052000
6.
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.
Summary
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.
- Assembler won’t matter much as I use the bootloader from the bare-bones tutorial and start development with C for convenience.
- Together with Tch1b0
- Based on SO: Apple named it
arm64
and all the othersaarch64
. - RISC-V is from 2014 and not unlike the famous x86/i386 from 1978.
riscv64-linux-gnu-ld: cannot find -lgcc: no error
¯\(ツ)/¯$ date -d @1707410202.187052000 +"%Y-%m-%d %H:%M:%S.%N"
: 2024-02-08 17:36:42.187052000