Open25

Write a Tiny OS on RISC-V in Rust

SphendamiSphendami

環境構築

  • 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
SphendamiSphendami

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 {}
}
  • .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 とした。とりあえずエラーは無くなった。これでいいのかな?よくわかってない

SphendamiSphendami

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]

SphendamiSphendami

cargo はリンカスクリプトの変更を検知してくれないので、リンカスクリプトだけ変えた場合は

touch src/main.rs && cargo build

する( cargo にリンカスクリプトの変更を検知するよう設定できるのかな? -> build script を使う方法はあるらしい [参考]

SphendamiSphendami

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 に持ってくるのは こちら を参考に。

SphendamiSphendami

boot がうまくいかない

cargo run で qemu を動かして info registers すると、 pc が 0 になってる。 sp も設定されていない。

bios は 0x80000000 以降に置かれているよう。

-bios none にして、リンカスクリプトのベースアドレスを 0x80000000 にしてみると、無事 pc が kernel_mainloop まで達した。しかし、 sp は相変わらず設定されていない。

Rust が boot 関数の冒頭に

lui     a0, 524320
lw      a0, 32(a0)

を追加するのだが、なぜか lw をしたあと a0 が 0 になる。これのせいで sp が設定できていない。

SphendamiSphendami

解決

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;

とかでよい。

SphendamiSphendami

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 のほうも 新しいやつ があった。こっちを使う。

SphendamiSphendami

↑これをやったあと bios ありで実行したら、ちゃんと

  • OpenSBI が起動されて
  • sp が正しい値に設定されて
  • pc が kernel_main の loop まで達した。

解決!

SphendamiSphendami

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
"""
...
SphendamiSphendami

QEMU monitor で print $sp としても unknown register と言われる。 print $x2 でもだめ。 RISC-V では print でレジスタ名を使えないのだろうか。仕方ないから info registers で見てる

SphendamiSphendami

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),
        );
    }
}
SphendamiSphendami

とりあえず 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 {}
}
SphendamiSphendami

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 が制御を保持したままでいいか

とかを考える必要があるか

SphendamiSphendami

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!)で生成されたものだと、変数のキャプチャは許されないよう。

SphendamiSphendami

トラップ

こちら に沿って実装。

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 ) };

    ...
}
  • repr(C) で C 言語と同じメモリ配置の構造体になる。
  • 関数アドレスの alignment を指定する attribute は(少なくとも今の stable release には)無いようなので、代わりに関数の冒頭で .balign するとうまくいった。
    • ここ を見て初めは .align 4 としたのだが、これではだめだった。 .align n の解釈には2通り( n バイトアラインと 2^n バイトアライン)あり、ものによって異なるらしい。それぞれ .balign.p2align を使うことで明示的にどちらを意図しているかを指定できる。 [参考1] [参考2]
SphendamiSphendami

追記:
handle_trap の引数はポインタ型で受け取るつもりのものだったので、 *const TrapFrame 型で受け取るように修正。ただ、 RISC-V の呼び出し規約 では 2 words より大きいデータは結局ポインタで渡されることになっているようなので、 TrapFrame 型で受け取っても一応意図した動きにはなっていたっぽい。

SphendamiSphendami

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! する。任意の名前を受け取れてしまうが、不正な名前を渡すとコンパイル時にちゃんとエラーになってくれるのでまあいいか。
  • マクロ内の識別子が曖昧だと名前衝突してしまうのでフルパスで書く [参考]
SphendamiSphendami

メモリ割り当て

リンカスクリプトでのヒープ領域の定義までは こちら に沿ってやり、メモリ割り当てのプログラムは こちら を参考にした。 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,
};

だいたい参考元と同じだが、( Layoutsize を使うようにしたのと、) BumpPointerAlloc がアドレスを usze でなく *const u8 として持つようにした。何故かというと、ヒープの開始・終了アドレスは参考元と異なり extern "C" を経由して *const u8 型で得ているが、 static 内ではポインタ型を整数型にキャストできなかったため。( global_allocator は static 変数で持つ必要がある。) lazy_static か once_cell を使えば usize でもいけるか?

なお、 #[alloc_error_handler] は用意しなくても動いた。実際にメモリが確保できなかった時は #[panic_handler] でのエラーが出た。そういうふうに変わった っぽい?

SphendamiSphendami

asm! には volatile のオプションが無く、最適化で壊れないか心配だったのだが、どうやらデフォルトで volatile になっており、逆にオプションでどの程度最適化してよいかをコンパイラに伝えるようになっているみたい [参考]

SphendamiSphendami

プロセス

概ね こちら に沿って実装。ただし、プロセスの配列をどう保持するかで迷った。

  • 参照を持ち回す
  • 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::cellstd::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_controlsched の可変参照を取るときに 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 を呼ぶようにした。

SphendamiSphendami

例外ハンドラの使うspの修正も一応やったが、このOSを自分だけで使う分にはユーザプログラムのspの扱いを誤らなければ関係ない話なので、別にやらなくても良さそう。
例外が発生したときは常にカーネルスタックの一番下から使うようになったが、つまりはカーネルランドは例外経由でしか動かないということ?

SphendamiSphendami

変数から mutable な生ポインタを生成する

&mut hoge as *mut _

という操作について、 hoge が static 変数の場合に警告が出るようになっていた。以下に修正した

core::ptr::addr_of_mut!(hoge)