This is chapter 7 of a multi-part series on writing a RISC-V OS in Rust.

Table of Contents → Chapter 6 → (Chapter 7) → Chapter 8

System Calls

Video

https://www.youtube.com/watch?v=6GW_jgkdGPw

Overview

System calls are a way for unprivileged, user applications to request services from the kernel. In the RISC-V architecture, we invoke the call using the ecall instruction. This will cause the CPU to halt what it's doing, elevate privilege modes, and then jump to whatever function handler is stored in the mtvec (machine trap vector) register. Remember, this is the "funnel" where all traps are handled, including our system calls.

We have to set up our convention for handling system calls. We can use a convention that already exists, so we can interface with a library, such as newlib. But, let's make this ours! We get to say what the system call numbers are, and where they will be when we execute a system call.

System Call Procedure

We usually only need to execute a system call if we're in a lower privilege mode. If we're in the kernel, we already have access to most of the privileged systems, which precludes us from actually needing to go into a system call.

Our system call comes to us through synchronous trap #8, which is the cause for a user-mode ecall. So, in our #8 handler, we forward the data over to our syscall handler. We'll be completely in Rust. We also need to be able to manipulate the program counter. Think of it, our exit system call must be able to move on to another process, so we manipulate that through the mepc (machine exception program counter) register.

Rust System Calls

As always, make sure you import your system call code with the following.

pub mod syscall;

This will go in your lib.rs file.

Ordering and Numbering

Several libraries already have a certain order they expect your system calls to be in. However, we will be creating our own "C library" for our simple applications. Therefore, we're good to go as long as we're consistent.

Think about how we can do this. Whenever we execute the ecall instruction, the CPU elevates privileges and jumps to a trap vector. How do we send data along with it? The ARM architecture allows you to encode a number into their svc (supervisor call) instruction. However, many operating systems forego that implementation. So, what do we do instead?

The answer is: registers. We have a ton of registers in the RISC-V architecture, so we aren't limited nearly as much as we were in the x86 days. For our system call convention, we will put the number of the system call into the first argument register, a0 . Subsequent parameters will go in a1, a2, a3, ..., a7. Then, we will return anything back using the same a0 register.

This is the same calling convention for regular functions, so it will interface well with what we already know. In RISC-V, we can use the pseudoinstruction call to make a normal function call, or ecall to make a supervisor call. Consistency is cey, or Konsistency is Key. Hmm.. Consistency is key isn't consistently sounding the 'k' :(.

Implementing System Calls

We redirect synchronous cause #8 to our system call Rust code.

8 => { // Environment (system) call from User mode println!("E-call from User mode! CPU#{} -> 0x{:08x}", hart, epc); return_pc = do_syscall(return_pc, frame); },

Most operating systems build a table with function pointers, but I'm using Rust's match statement here. I haven't done any performance calculations, but I don't think one has a distinct advantage over another. Again, don't quote me, I haven't actually tested it.

As you can see, we receive a new program counter, which is the address of the instruction we want to execute when we return. The system call function must at least move this by 4 because the ecall instruction is what's actually causing the synchronous interrupt. If we didn't move the program counter, we'd keep executing the ecall instruction over and over again. Fortunately, unlike x86, all instructions are 32-bits except for the 16-bit compressed instructions, but ecall is always 32-bit because it doesn't have a compressed form.

Each mode has a different ecall cause. If we make an ecall in machine mode (the most privileged mode), we get cause #11. Otherwise, if we make an ecall in supervisor mode (typically the kernel mode), we get cause #9. So, we can split off what system calls mean what given the privilege mode. I'm going to keep it easy and konsistent (consistent?) by linking all three to the same system call function.

Let There Be System Calls!

Let's take a look at the code in Rust.

pub fn do_syscall(mepc: usize, frame: *mut TrapFrame) -> usize { let syscall_number; unsafe { // A0 is X10, so it's register number 10. syscall_number = (*frame).regs[10]; } match syscall_number { 0 => { // Exit println!("You called the exit system call!"); mepc + 4 }, _ => { print!("Unknown syscall number {}", syscall_number); mepc + 4 } }

The very first thing we need is the value of A0, which is the system call number. Since this is stored in the context during the trap handler phase, we can just retrieve it directly from memory. We have to put this in an unsafe context because we're dereferencing a raw pointer, which may or may not be an accurate memory address. Since Rust cannot guarantee that it is, we're required to put this in an unsafe block.

This might be interesting to non-Rustaceans.

let syscall_number; unsafe { // A0 is X10, so it's register number 10. syscall_number = (*frame).regs[10]; }

I'm creating a variable called syscall_number , but since I cannot get its value until I'm inside of an unsafe block, it is just a placeholder. In fact, Rust doesn't put a type to it until we give it a value. You'll notice that I haven't put any constraints on the variable type, so I'm letting Rust decide.

Why did I do this? The unsafe block creates a new block, and thus, it creates a new scope. However, I want syscall_number to contain an immutable value outside of the unsafe context. This is why I decided to do it this way. Technically, I can constrain the data type by using let syscall_number: u64; , but it's not required since Rust will evaluate the data type whenever we get around to setting the variable equal to something.

Where do we go from here?

We will write the scheduler so that our system calls can actually do stuff--yes, stuff in the most technically accurate way possible! For example, we may need to postpone a process until after a certain amount of time (sort of like how sleep() works), or we need to close the process (much like how exit() ) works. What about writing to the console--yep, we need that one too.

So, next, we will add processes and the system calls necessary as we reach them. We aren't making any future predictions, which might be dangerous, but we're implementing our OS as we find an unworkable solution. Hopefully, this will give you an appreciation for how operating systems are implemented to be all things to all applications!

Table of Contents → Chapter 6 → (Chapter 7) → Chapter 8