Write a Tiny OS on RISC-V in Rust
Rust で簡単な OS を書きたい。
参考このあたり?主に1つめ( OS in 1000 lines )に沿ってやる
環境構築
- WSL 上の Ubuntu 20.04.6
- Rust はインストール済み
$ sudo apt install qemu-system-riscv32
$ rustup target add riscv32i-unknown-none-elf
$ cargo new tinyos && cd tinyos
$ curl -LO https://github.com/qemu/qemu/raw/v8.0.4/pc-bios/opensbi-riscv32-generic-fw_dynamic.bin
^^^ tinyos ディレクトリ内(か path の通る場所?)に置く必要がある
i
, imac
, imc
が使える [参考] ようだが、とりあえず i
にした
デバッグ用:
$ cargo install cargo-binutils
$ rustup component add llvm-tools-preview
boot
- ここ の kernel.ld をそのまま使う
- main.rs はとりあえずこうした
#![no_std]
#![no_main]
#[no_mangle]
#[link_section = ".text.boot"]
pub extern "C" fn boot() -> ! {
loop {}
}
use core::panic::PanicInfo;
#[panic_handler]
#[no_mangle]
pub fn panic(_info: &PanicInfo) -> ! {
loop {}
}
- .cargo/config.toml はこうした
[target.riscv32i-unknown-none-elf]
runner = "qemu-system-riscv32 -machine virt -bios default -nographic -serial mon:stdio --no-reboot -kernel"
rustflags = [
"-C", "link-arg=-Tlinker.ld",
]
[build]
target = "riscv32i-unknown-none-elf"
結果
-
cargo build
は OK -
cargo run
は以下のエラー
qemu-system-riscv32: Unable to load the RISC-V firmware "opensbi-riscv32-virt-fw_jump.bin"
-bios default
で指定されるのが 参考元 と変わってる?なお私の環境は以下
$ qemu-system-riscv32 --version
QEMU emulator version 4.2.1 (Debian 1:4.2-3ubuntu6.27)
対処
-bios
オプションはファイル名を指定すればよいらしいので、 default
でなく -bios opensbi-riscv32-generic-fw_dynamic.bin
とした。とりあえずエラーは無くなった。これでいいのかな?よくわかってない
-bios none
でも一応エラーは消える
VSCode の話
(前提: rust-analyzer 拡張機能を入れている)
「panic handler が重複しています」みたいなエラーが出てて、デフォルトの x86-64 の target を見ているみたいだったから、 settings.json に
"rust-analyzer.cargo.target": "riscv32i-unknown-none-elf"
と追記してみた。すると上記のエラーは消えたものの、代わりに can't find crate for test
というエラーが出るようになった。こっちは
"rust-analyzer.check.allTargets": false
とすることで解消された。
参考: https://github.com/rust-lang/rust-analyzer/issues/3297
※ unstable feature を使えば #[no_std]
でも test が使えるようになるらしい [see here]
cargo はリンカスクリプトの変更を検知してくれないので、リンカスクリプトだけ変えた場合は
touch src/main.rs && cargo build
する( cargo にリンカスクリプトの変更を検知するよう設定できるのかな? -> build script を使う方法はあるらしい [参考] )
main.rs の boot
関数を変更。
use core::arch::asm;
extern "C" {
static __stack_top: *const u8;
}
#[no_mangle]
#[link_section = ".text.boot"]
pub unsafe extern "C" fn boot() -> ! {
asm!(
"mv sp, {stack_top}\n
j {kernel_main}\n",
stack_top = in(reg) __stack_top,
kernel_main = sym kernel_main,
);
loop {}
}
#[no_mangle]
pub fn kernel_main() -> ! {
loop {}
}
boot がうまくいかない
cargo run
で qemu を動かして info registers
すると、 pc が 0 になってる。 sp も設定されていない。
bios は 0x80000000 以降に置かれているよう。
-bios none
にして、リンカスクリプトのベースアドレスを 0x80000000 にしてみると、無事 pc が kernel_main
の loop
まで達した。しかし、 sp は相変わらず設定されていない。
Rust が boot
関数の冒頭に
lui a0, 524320
lw a0, 32(a0)
を追加するのだが、なぜか lw
をしたあと a0 が 0 になる。これのせいで sp が設定できていない。
解決
stack_top = in(reg) &__stack_top,
の &
が抜けていたのと、 lw
をレジスタ-レジスタのデータ移動と勘違いしていたせいだった。 &
を入れたら lw a0, 32(a0)
でなく addi a0, a0, 32
になり、無事に sp が設定された。
なんとなく
static __stack_top: *const u8;
と宣言したせいで、 __stack_top
がアドレス値 0x800XXXXX
を持っていると思い込んでいた。しかし実際には「( 0x800XXXX
にある) *const u8
型の値」を持っているので、アドレス値 0x800XXXXX
が欲しい場合は &__stack_top
とする必要がある。また、 0x800XXXX
にある値は別に使わないので、型は適当に
static __stack_top: u8;
とかでよい。
QEMU のバージョンが低い。最新は 8.1.0 らしいが、今 4.2.1 がインストールされている。 Ubuntu 自体が1個前の LTS なので、 apt install
で降ってくるのが新しいやつじゃない。
ソースからビルドしてみる。概ね これ に従う。ただし、全部のターゲットをビルドするとでかそうなので、 必要なターゲットを指定。
wget https://download.qemu.org/qemu-8.1.0.tar.xz
tar xvJf qemu-8.1.0.tar.xz
cd qemu-8.1.0
mkdir build && cd build
../configure --target-list=riscv32-softmmu
make
これで build
ディレクトリ内に qemu-system-riscv32
ができた。
$ qemu-system-riscv32 --version
QEMU emulator version 8.1.0
Copyright (c) 2003-2023 Fabrice Bellard and the QEMU Project developers
ちゃんと最新になった。以降これを使う。
OpenSBI のほうも 新しいやつ があった。こっちを使う。
↑これをやったあと bios ありで実行したら、ちゃんと
- OpenSBI が起動されて
- sp が正しい値に設定されて
- pc が
kernel_main
の loop まで達した。
解決!
toml は """
で挟むことで文字列を改行できるようなので、 .cargo/config.toml が見やすくなった
[target.riscv32i-unknown-none-elf]
runner = """
qemu-system-riscv32
-machine virt
-bios opensbi-riscv32-generic-fw_dynamic.bin
-nographic
-serial mon:stdio
--no-reboot
-kernel
"""
...
QEMU monitor で print $sp
としても unknown register
と言われる。 print $x2
でもだめ。 RISC-V では print
でレジスタ名を使えないのだろうか。仕方ないから info registers
で見てる
asm!
に options(noreturn)
を指定することで、この asm!
ブロックを普通に出ることはない(途中でジャンプする)とコンパイラに伝えることができるので、 asm!
の型が !
になり後ろの loop が要らなくなる
#[link_section = ".text.boot"]
#[no_mangle]
extern "C" fn boot() -> ! {
unsafe {
asm!(
"mv sp, {stack_top}",
"j {kernel_main}",
stack_top = in(reg) &__stack_top,
kernel_main = sym kernel_main,
options(noreturn),
);
}
}
とりあえず bss の0クリアまでの現状。一区切り
main.rs
#![no_std]
#![no_main]
use core::arch::asm;
use core::panic::PanicInfo;
use core::ptr;
extern "C" {
static __stack_top: u8;
static mut __bss: u8;
static __bss_end: u8;
}
#[link_section = ".text.boot"]
#[no_mangle]
extern "C" fn boot() -> ! {
unsafe {
asm!(
"mv sp, {stack_top}",
"j {kernel_main}",
stack_top = in(reg) &__stack_top,
kernel_main = sym kernel_main,
options(noreturn),
);
}
}
#[no_mangle]
fn kernel_main() -> ! {
unsafe {
let addr_bss_start = &mut __bss as *mut u8;
let addr_bss_end = &__bss_end as *const u8;
let length = addr_bss_end as usize - addr_bss_start as usize;
ptr::write_bytes(addr_bss_start, 0, length);
}
loop {}
}
#[panic_handler]
#[no_mangle]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
IO
文字入出力は、一旦 SBI を使わずにやってみる。 UART とやらのレジスタに読み書きすればよいらしい。今回使っている virt マシンのそれは 0x10000000 のメモリにマッピングされている。
write
use core::ptr;
const UART0_ADDR: usize = 0x10000000;
pub fn write_byte(c: u8) {
let uart0 = UART0_ADDR as *mut u8;
unsafe {
ptr::write_volatile(uart0, c);
}
}
本当は初期化とか状態の確認とかしなきゃいけないようだが、 virt はやらなくてもどうにかしてくれるらしい。
コンパイラが最適化をしても毎回 write してくれるようにするために ptr::write_volatile
を使う。( --release
無しの普通のコンパイルなら、最適化はされないらしいので一応 *uart0 = c;
でも動作はした)
read
fn read_byte_immediately() -> u8 {
let uart0 = UART0_ADDR as *mut u8;
unsafe { ptr::read_volatile(uart0) }
}
pub fn read_byte() -> u8 {
let mut byte = 0;
while byte == 0 {
byte = read_byte_immediately();
}
byte
}
read も似た感じ。
割り込み・例外やコンテキストスイッチを実装する段階になったら、
- UART の排他的制御はどうするか
- non-preemptive な場合に
read_byte
が制御を保持したままでいいか
とかを考える必要があるか
println!
こちら を参考に print!
および println!
を実装。
概ね同じだが、 println!
は
#[macro_export]
macro_rules! println {
() => {{
$crate::print!("\n");
}};
( $($arg:tt)* ) => {{
$crate::print!($($arg)*);
$crate::println!();
}};
}
のようにした。
- 引数無しの呼び出しに対応
- 改行文字を
concat!
するのではなく、print!
したあと改めて改行する形に変更
後者は、
let s = "world";
println!("Hello, {s}!");
のように変数を直接キャプチャしてフォーマット文字列リテラル内に埋め込む場合に concat!
だと問題が生じるため。 format_args!
に渡されるフォーマット文字列リテラルがマクロ(今回の場合 concat!
)で生成されたものだと、変数のキャプチャは許されないよう。
トラップ
こちら に沿って実装。
use core::arch::asm;
#[derive(Debug)]
#[repr(C, packed)]
struct TrapFrame {
ra: u32,
gp: u32,
...
s11: u32,
sp: u32,
}
pub extern "C" fn trap_entry() {
unsafe {
asm!(
".balign 4",
"csrw sscratch, sp",
"addi sp, sp, -4 * 31",
"sw ra, 4 * 0(sp)",
"sw gp, 4 * 1(sp)",
...
"sw s11, 4 * 29(sp)",
"/* ----- */",
"csrr a0, sscratch",
"sw a0, 4 * 30(sp)",
"mv a0, sp",
"call {handle_trap}",
"/* ----- */",
"lw ra, 4 * 0(sp)",
"lw gp, 4 * 1(sp)",
...
"lw s11, 4 * 29(sp)",
"lw sp, 4 * 30(sp)",
"sret",
handle_trap = sym handle_trap,
options(noreturn),
)
}
}
extern "C" fn handle_trap(frame: TrapFrame) {
let mut scause: u32;
let mut stval: u32;
let mut sepc: u32;
unsafe {
asm!("csrr {}, scause", out(reg) scause);
asm!("csrr {}, stval", out(reg) stval);
asm!("csrr {}, sepc", out(reg) sepc);
}
panic!(
"unexpected trap:\n\
scause: {scause:#010x}, stval: {stval:#010x}, sepc: {sepc:#010x}\n\
{frame:#010x?}"
)
}
fn kernel_main() -> ! {
...
let addr_trap_entry = trap::trap_entry as usize;
unsafe { asm!("csrw stvec, {addr_trap_entry}", addr_trap_entry = in(reg) addr_trap_entry ) };
...
}
追記:
handle_trap
の引数はポインタ型で受け取るつもりのものだったので、 *const TrapFrame
型で受け取るように修正。ただ、 RISC-V の呼び出し規約 では 2 words より大きいデータは結局ポインタで渡されることになっているようなので、 TrapFrame
型で受け取っても一応意図した動きにはなっていたっぽい。
CSR の読み書きをマクロにした。
#[macro_export]
macro_rules! read_csr {
($csr:literal) => {{
let mut val: u32;
unsafe {
::core::arch::asm!(concat!("csrr {}, ", $csr), out(reg) val);
}
val
}};
}
#[macro_export]
macro_rules! write_csr {
($csr:literal, $val:expr) => {{
::core::arch::asm!(concat!("csrw ", $csr, ", {}"), in(reg) $val);
}};
}
- CSR の名前をリテラルで受け取って
concat!
する。任意の名前を受け取れてしまうが、不正な名前を渡すとコンパイル時にちゃんとエラーになってくれるのでまあいいか。 - マクロ内の識別子が曖昧だと名前衝突してしまうのでフルパスで書く [参考]
メモリ割り当て
リンカスクリプトでのヒープ領域の定義までは こちら に沿ってやり、メモリ割り当てのプログラムは こちら を参考にした。 GlobalAlloc
トレイトを実装した型を用意して、
pub struct BumpPointerAlloc {
pub head: UnsafeCell<*const u8>,
pub end: *const u8,
}
unsafe impl GlobalAlloc for BumpPointerAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let size = layout.size();
let align = layout.align();
let head = self.head.get();
let rem = *head as usize % align;
let start_addr = if rem == 0 {
*head
} else {
(*head).add(align - rem)
};
let next_head = start_addr.add(size);
if next_head > self.end {
ptr::null_mut()
} else {
*head = next_head;
start_addr as *mut u8
}
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
// never deallocate
}
}
それを #[global_allocator]
に指定することで、 Box
, String
, Vec
などの alloc
クレートに入っているものが使えるようになる。
extern "C" {
...
static __heap: u8;
static __heap_end: u8;
}
#[global_allocator]
static HEAP: BumpPointerAlloc = BumpPointerAlloc {
head: UnsafeCell::new(unsafe { &__heap } as *const u8),
end: unsafe { &__heap_end } as *const u8,
};
だいたい参考元と同じだが、( Layout
の size
を使うようにしたのと、) BumpPointerAlloc
がアドレスを usze
でなく *const u8
として持つようにした。何故かというと、ヒープの開始・終了アドレスは参考元と異なり extern "C"
を経由して *const u8
型で得ているが、 static
内ではポインタ型を整数型にキャストできなかったため。( global_allocator は static
変数で持つ必要がある。) lazy_static か once_cell を使えば usize
でもいけるか?
なお、 #[alloc_error_handler]
は用意しなくても動いた。実際にメモリが確保できなかった時は #[panic_handler]
でのエラーが出た。そういうふうに変わった っぽい?