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 はとりあえず以下のようにした。Rust 特有の記述(
no_std
とかpanic_handler
とか)は、 ここ とか ここ とか ここ とか ここ を参考に。
#![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 {}
}
[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 {}
}
asm!
は こちら を参考に。
リンカのシンボル(ここでは __stack_top
)を Rust に持ってくるのは こちら を参考に。
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]
でのエラーが出た。そういうふうに変わった っぽい?
asm!
には volatile のオプションが無く、最適化で壊れないか心配だったのだが、どうやらデフォルトで volatile になっており、逆にオプションでどの程度最適化してよいかをコンパイラに伝えるようになっているみたい [参考]
プロセス
概ね こちら に沿って実装。ただし、プロセスの配列をどう保持するかで迷った。
- 参照を持ち回す
- static で持つ
前者の場合、コンテキストスイッチにプロセス配列の参照が必要となり、それはプロセスの thunk にキャプチャされる。しかし、キャプチャ有りのクロージャからは関数ポインタを得られないため新しいプロセスを作れない。
というわけで後者を採用。ただし mutability は必要なので、 unsafe な static mut
を許容するか、様々な回避策のうちの1つを使うかになる。せっかく Rust を使っているんだし、なるべく unsafe は回避したい。
そもそも static mut
が unsafe なのは、スレッドセーフが保証できないから。 static な変数はどこからでもアクセスできるので、それが mutable だと無秩序に書き換えられてしまう。一方 immutable であれば、値は固定でありマルチスレッドでも競合は起こらないため、safe である。しかし今回は mutability が欲しい。
結論から述べると、mutable な値を( mut
でない) static
変数で safe に持ちたかったら、 interior mutability を備えておりかつ Sync
トレイトを実装した型を使う必要がある。
- interior mutability (内部可変性)とは、
mut
を付けていなくても(動的なロジックで安全性を保証して)値を変更できる性質。標準ライブラリの中ではCell
,RefCell
,Mutex
,RwLock
などがこれを備えている。 -
Sync
トレイトとは、これを実装する型がスレッドセーフであることを示すマーカー。immutable で static な変数の型は、Sync
を実装していなければならない。「 mutable でないなら元からスレッドセーフなのでは?」と思うかもしれないが、上記の interior mutability によって immutable でも値を書き換えられる可能性があるため、別途トレイトでスレッドセーフであることを明示している。
ということで、
struct InnerScheduler {
procs: [Process; PROCS_MAX],
current_pid: Pid,
}
pub struct Scheduler(RefCell<InnerScheduler>);
unsafe impl Sync for Scheduler {}
pub static SCHEDULER: Scheduler = Scheduler::new();
というように、プロセスの配列を持たせた型を RefCell
で包み、それに Sync
を実装して static
で持つようにした。
interior mutability があるのに勝手に Sync
を実装してしまって大丈夫かと思うかもしれないが、今私はマルチスレッド機構そのものを実装しているので、その管理に用いる Scheduler
はそもそも複数スレッドから同時にアクセスされることはなく、 Scheduler
に対するデータ競合は生じないはず。なので大丈夫(実際の動作と Sync
トレイトマーカーの主張は互いに矛盾しない)。
プロセスに関わる各関数( switch_context
を除く)は Scheduler
のメソッドとして実装し、この各メソッドの最初で
let mut sched = self.0.borrow_mut();
して以降この sched
を使う。
補足
標準ライブラリで interior mutability を提供する主な型は次の通り。
-
std::cell
モジュールのCell
,RefCell
,OnceCell
-
std::sync
モジュールのMutex
,RwLock
,OnceLock
-
std::sync::atomic
モジュールのAtomicBool
,AtomicU8
など
-
std::cell
配下は Sync
でない一方 std::sync
配下は Sync
であるのが大きな違い。それぞれの型の詳細な説明はリファレンスなどに譲るとして、ここでは #![no_std]
環境すなわち core
での話を少し。
core
の場合、 core::cell
は std::cell
と同じものが用意されている一方、 core::sync
の下には atomic
モジュールしかなく Mutex
, RwLock
, OnceLock
は使えない。すなわち core
で interior mutability かつ Sync
な型を使おうと思ったら、今回みたいに interior mutability を持つが Sync
でない型に Sync
を自己責任で付与するか、 atomic
配下の型(ブール・整数・ポインタに対応するものしかないうえ、読み書きの都度 Ordering
なるものを指定する必要がある)を使うかすることになる。
switch_context
関数はこんな感じになった
#[inline(never)]
unsafe fn switch_context(ptr_prev_sp: *mut usize, next_sp: usize) {
asm!(
"addi sp, sp, -13 * 4",
"sw ra, 0 * 4(sp)",
...
"sw s11, 12 * 4(sp)",
);
asm!(
"sw sp, (a0)",
"mv sp, a1",
in("a0") ptr_prev_sp,
in("a1") next_sp,
);
asm!(
"lw ra, 0 * 4(sp)",
...
"lw s11, 12 * 4(sp)",
"addi sp, sp, 13 * 4",
)
}
-
ptr_prev_sp
はポインタ先を書き換えるのでポインタで受け取るが、next_sp
は値があれば十分なのでポインタではなくした。 - 最後の明示的な
ret
を無くして、switch_context
自体のret
で戻るようにした。これは、 Rust コンパイラが引数保存用スタック領域確保の処理をswitch_context
の先頭に加えてしまうために、その確保されたスタックを元に戻さないといけない(この処理は Rust コンパイラがswitch_context
の最後に(ret
と共に)加える)から。-
#[naked]
みたいなアトリビュートがあれば抑制できるんだろうが、少なくとも stable には無かった
-
- 上述のように
switch_context
自体のret
で戻るようにしたせいで、最適化によってswitch_context
がインライン化された場合ret
が無くなってしまいバグる。そのため#[inline(never)]
を付与してインライン化しないようにしている。
yield
(Rust では yield
が予約語で使えないので yield_control
に名前変更した)では一工夫が必要だった。 yield_control
内では switch_context
を呼び出すのだが、その段階で sched
の可変参照を持っているとその可変参照が解放されないままコンテキストスイッチしてしまい、次の yield_control
で sched
の可変参照を取るときに BorrowMutError
が発生してしまう。そのため switch_context
の呼び出し前までに sched
のスコープを抜ける必要がある(ただし sched
内の ptr_prev_sp
の部分だけは書き換えたい)。そのため、
pub fn yield_control(&self) {
let sps = {
let mut sched = self.0.borrow_mut();
let mut next_proc = None;
... // search for a runnable process
if let Some(next) = next_proc {
...
let prev = ...;
Some((&mut prev.sp as *mut _, next_sp))
} else {
None
}
};
if let Some((ptr_prev_sp, next_sp)) = sps {
unsafe { switch_context(ptr_prev_sp, next_sp) };
}
}
というように、 &mut prev.sp
を *mut
にして Rust の borrow checker の目を逃れ、その後 sched
を drop したのち、 switch_context
を呼ぶようにした。
例外ハンドラの使うspの修正も一応やったが、このOSを自分だけで使う分にはユーザプログラムのspの扱いを誤らなければ関係ない話なので、別にやらなくても良さそう。
例外が発生したときは常にカーネルスタックの一番下から使うようになったが、つまりはカーネルランドは例外経由でしか動かないということ?
変数から mutable な生ポインタを生成する
&mut hoge as *mut _
という操作について、 hoge
が static 変数の場合に警告が出るようになっていた。以下に修正した
core::ptr::addr_of_mut!(hoge)