Rust for RISC-V: Standalone executable.
In the set of articles I am planning to demonstrate how to write a standalone rust executable for RISC-V64.
Since I started to learn Rust, Rust programming language had improved much. Rust substituted C++ in the areas where previously I used C++ for a projects. But OOP C++ nightmare has ended. At the moment, Rust demonstrates its ability to substitute C programming language on bare metal level and OS level. At the moment, I have in development FreeBSD Kernel Programming Inrerface (KPI) for Rust and Kermel Module Interface (KMI) for Rust crates. There are a lot of things left to complete in both crates including testing, but at the moment I have something better to do, so the development was temporarily paused, but I will return to this soon.
So, sometime ago I got my MangoPi MQ1PH v1.4 with RISC-V AllwinnerD1 CPU for prototyping. It is a sort of Raspberry Pi Zero. The only problem with MPi is that the provided SDK is very low quality. But anyway. I was able to boot GNU/Linux and FreeBSD on this board. I made an attempt to port Sel4 Microkernel to this device, however I found out that more effort is required in order to boot it on MPi. For unknwon reason, provided `elfloader` which loads kernel from CPIO archive fails with “Unhandled exception: Load access fault”. I don’t have time and JTAG to find out what is wrong, so I postponed this for some time later.
The purpose of this articles is to develop on a Rust a simple standalone ELF executable (which U-boot is able to boot) and finally run it on the MPi board (nativly). For the experiments, a `Qemu` will be used. Lets begin from simple standalone application.
Firstly, it is required to prepare the environment. Adding the target using rustup.
rustup target add riscv64gc-unknown-none-elf
It is important that the system contains all necessary `binutils` for the RISC-V cross compilation.
Next, creating a project:
cargo new rel4 --bin –edition 2018
It was called `rel4`, you can call it anyway you like.
Now, we have an empty project. On Rust crates storage, there are available crates which helps to speed up the project setup and development. In cargo.toml:
[dependencies]
riscv-rt = { version = "0.11.0", features=["s-mode"] }
panic-halt = "0.2.0"
#`features=["s-mode"]` tells the riscv-rt crate that the project is generated to run in s-mode.
There are 3 modes: M, S, U.
Referring to the `riscv-rt` crate documentation, it requires to be provided with the memory layout. Normally, a LD script is provided which describes a memory layout. But, in this case, the task is a bit easier. For the qemu, I recommend to follow the provided instruction on the documentation page.
In file `memory.x` created in the root directory of the project the following should be placed:
MEMORY
{
RAM : ORIGIN = 0x80200000, LENGTH = 0x8000000
FLASH : ORIGIN = 0x20000000, LENGTH = 16M
}
REGION_ALIAS("REGION_TEXT", FLASH);
REGION_ALIAS("REGION_RODATA", FLASH);
REGION_ALIAS("REGION_DATA", RAM);
REGION_ALIAS("REGION_BSS", RAM);
REGION_ALIAS("REGION_HEAP", RAM);
REGION_ALIAS("REGION_STACK", RAM);
The above will be modified as soon as the task become more complex and when the stage will be hit to run the code on the real hardware. At the moment, all the constant sections are stored to the ‘flash’ memory region and the rest is stored at the memory region which starts at 0x80200000.
Also, in a build script, it is required to place the following:
use std::env;
use std::fs;
use std::path::PathBuf;
fn main()
{
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
// Put the linker script somewhere the linker can find it.
fs::write(out_dir.join("memory.x"), include_bytes!("memory.x")).unwrap();
println!("cargo:rustc-link-search={}", out_dir.display());
println!("cargo:rerun-if-changed=memory.x");
println!("cargo:rerun-if-changed=build.rs");
}
The script above copies a copy of the memory.x file to the specific location.
Cargo (Rust build system) is also required to be setup.Creating a directory in the root of the project directory: `.cargo`.In this directory, creating a file `config.toml` and copying the following.
[target.riscv64gc-unknown-none-elf]
rustflags = [
"-C", "link-arg=-Tmemory.x",
"-C", "link-arg=-Tlink.x",
]
# "-C", "target-cpu=generic-rv64",
[build]
target = "riscv64gc-unknown-none-elf"
AllwinnerD1 is not available as "target-cpu", so ` target-cpu` is useless to specify there. And at the moment the project is planned to run on qemu, so a generic will be picked automatically. Our build target is ‘ riscv64gc-unknown-none-elf’.
So, at that point the project template is configured and prepared to be built, as soon as the payload will be completed.
In the directory ‘src’ there is file: ‘main.rs’. There the payload should be placed.
Rust’s `std` crate (library) is not available because this is standalone executable which runs on bare metal. There is no OS. Only Rust's `core` crate is available. It means that only static memory allocations (on stack) with capacities known at compilation time are available. In order to allocate memory dynamically, we need to develop a Virtual Memory subsystem and allocator. But this will be done in next parts.
At the moment, our task is to build a binary which will be loaded and output hardcoded message on the console screen. In order to achieve our goal, there is no need to write any drivers. For the RISC-V an OpenSBI “RISC-V Open Source Supervisor Binary Interface” was developed. It contains all necessary code which will allow to use basic hardware like serial console, timers without developing a driver but just making a call to the function with arguments. This is achieved by executing ‘ecall’ assembly command.
Register ‘a0’ – ecall argument 0, exit result
Register ‘a1’ – ecall argument 1
Register ‘a2’ - ecall argument 2
Register ‘a3’ – ecall argument 3
...
Register ‘a7’ – ecall a call number/vector
In case of "ecall"-command called from the S-Mode (the CPU is in S-mode currently), a privilege level will be raised to the M-mode and execution will be jumped to the SBI handler of selected ecall.
In order to print to the serial console via UART0, a ‘ecall’ type 1 shuold be called. And, of cause, implement a fmt::Write for our output interface (for convenience).
In order to make a SPI call the following code can be used:
fn call_sbi(which: usize, arg0: usize, arg1: usize, arg2: usize) -> usize
{
let mut ret;
unsafe
{
core::arch::asm!(
"li x16, 0",
"ecall",
inlateout("x10") arg0 => ret,
in("x11") arg1,
in("x12") arg2,
in("x17") which,
);
}
return ret;
}
To put char on the serial console the following arguments should be passed to ecall:
The instruction “li x16, 0” is same as “li a6, 0” loads 0 to register ‘a6’ by default. This code will not work, for example, when executing a 0x48534D (HSM) (stopping a Hart)command which requires ‘a6’ to be loaded with "1".
The last thing to implement is an implementation of 'fmt::Write' on our structure.
pub fn tty_putchar(c: usize)
{
call_sbi(SBI_CONSOLE_PUTCHAR, c, 0, 0);
}
struct KernelConsole{}
impl KernelConsole {}
impl fmt::Write for KernelConsole
{
fn write_char(&mut self, c: char) -> fmt::Result
{
tty_putchar(c as usize);
return Ok(());
}
fn write_str(&mut self, s: &str) -> fmt::Result
{
s.chars().for_each(|c| tty_putchar(c as usize));
return Ok(());
}
}
The code above just puts every character to the console which are written to the instance of the struct KernelConsole. A “ tty_putchar” function is implemented separately, because it is not planned to be part of the structure and later moved somewhere else.
And the last important thing. It is required to specify that this code is not including a Rust's standart library and has no main method, because it is standalone. Normally, without crates which were included in the project, it is required to specify the entry point manually which performs all necessary initialization and then calls the main method. The symbol is ‘_start()’ like below:
#[no_mangle]
pub extern "C" fn _start() -> !
{
main();
loop {}
}
However in our case, the crate does everything. It is required just to use `#[entry]` macros on main function.
#[entry]
fn main() -> !
{
// do something here
let mut kc = KernelConsole{ };
let _ = writeln!(kc, "output: '{}'", 4);
loop { }
}
The code above initializes the instance of struct KernelConsole on a stack and prints to the serial console ‘output: ‘4’’.
Because there is nowhere to return, main function is no-return and for this reason the infinite loop is required.
Below is a full listing of main.rs:
#![no_std]
#![no_main]
use core::fmt::{self, Write};
extern crate panic_halt;
use riscv_rt::entry;
fn call_sbi(which: usize, arg0: usize, arg1: usize, arg2: usize) -> usize
{
let mut ret;
unsafe
{
core::arch::asm!(
"li x16, 0",
"ecall",
inlateout("x10") arg0 => ret,
in("x11") arg1,
in("x12") arg2,
in("x17") which,
);
}
return ret;
}
const SBI_CONSOLE_PUTCHAR: usize = 1;
pub fn tty_putchar(c: usize)
{
call_sbi(SBI_CONSOLE_PUTCHAR, c, 0, 0);
}
struct KernelConsole{}
impl KernelConsole {}
impl fmt::Write for KernelConsole
{
fn write_char(&mut self, c: char) -> fmt::Result
{
tty_putchar(c as usize);
return Ok(());
}
fn write_str(&mut self, s: &str) -> fmt::Result
{
s.chars().for_each(|c| tty_putchar(c as usize));
return Ok(());
}
}
// use `main` as the entry point of this application
// `main` is not allowed to return
#[entry]fn main() -> !
{
// do something here
let mut kc = KernelConsole{ };
let _ = writeln!(kc, "output: '{}'", 4);
loop { }
}
To compile the projects, a command: ‘cargo build’ is executed in the console. And run the binary in qemu.
sudo qemu-system-riscv64 -m 2G -nographic -machine virt -kernel ./rel4
The printout of the qemu output:
The hardcoded string ‘output ‘4’’ is there.
As a next stage, it is important to implement a Virtual Memory initialization in order to allocate memory dynamically.