Open22

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 はとりあえずこうした
#![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 {}
}
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 になっており、逆にオプションでどの程度最適化してよいかをコンパイラに伝えるようになっているみたい [参考]