Rustで自作OSに挑戦した話(敗走)
この記事について
Writing an OS in 1000 LinesをRustで途中まで頑張った記録を残したものです。もと記事はとても素晴らしいので、みなさんやりましょう!
RustでOS書きたいと思った時、小さな踏み台になれればと思います。もし間違っている部分があれば、教えていただけると嬉しいです。
また、Scrapboxに日記があります。人に見せるようなものではないかもですが、詳しめに書いているので置いておきます。動かない原因を調査するため、もがいている姿が見れます。
つくったものはこちら
参考
以下の記事&コードを沢山参考にしました。ありがとうございました!
環境
- Ubuntu 20.04
- rustc version
rustc 1.73.0 (cc66ad468 2023-10-03)
- プロセスを実装するところからnightlyに変わります(後述)
- qemu version
QEMU emulator version 8.1.2
ブート
もろもろセットアップ
- QEMU
- もと記事のとおり、
apt get qemu
すると、動かなかった - Ubuntu 20.04を使っていたため、古いqemuが入ったことが原因だった
- そのため、ソースからビルドし、
~/tools
の下に置いた - 詳しい話はこちらから
- もと記事のとおり、
- OpenSBI
- qemuのバージョン合わせたものを入れる
- 今回は8.1.2なので、こちら
- Rust
- 今回は、
riscv32i-unknown-none-elf
に向けてコンパイルするため、これを入れる -
$ rustup target list
すると、ターゲットの一覧が出る - 無かったら、
$ rustup target add riscv32i-unknown-none-elf
する
- 今回は、
実装
基本的にはこれを見ながら整えていきます。
- リンカスクリプト
kernel.ld
を指定 - コンパイルのターゲットを指定
[build]
target = "riscv32i-unknown-none-elf"
[target.riscv32i-unknown-none-elf]
rustflags = [
"-C", "link-arg=-Tkernel.ld",
]
スタックアンワインドの無効化(詳細)します。こうすると、error: language item required, but not found: eh_personality
がでなくなります。
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
リンカスクリプトで定義したシンボルにアクセスできるように変数を定義します。extern "C"
すると何が起きるがちゃんと考えてみたりもしました。
extern "C" {
static __stack_top: u8;
static mut __bss: u8;
static __bss_end: u8;
}
asm!(
"mv sp, {stack_top}",
"j {kernel_main}",
stack_top = in(reg) &__stack_top,
kernel_main = sym kernel_main,
);
-
bss_size
を計算するとき、u8-> *const u8-> usize
のキャストはこれを参考にしました- (実は、このキャストで何が起きているかちゃんと理解してない...)
let addr_bss_start = &__bss as *const u8;
let addr_bss_end = &__bss_end as *const u8;
let bss_size = addr_bss_end as usize - addr_bss_start as usize;
実行するときはrun.sh
を走らせます。cargo run
でできるようにしたかったのですが、qemuのバイナリの場所を直接指定する方法が見つかりませんでした。build scriptなるもの書けば良さそうですが、書き方が分からなかったため今回は見送りました...
Hello World!
まずはputcharからですが、このコミットの通りです。mainにベタ書きしていますが、あとで分けます。
そのあとにprintを書きました。実装はまんまこれです。これだけでいい感じのprintができるってすごいですね。ついでに、ファイルを分けたりもしました。
qemuモニタ(ctrl+a -> cで起動するやつ)で、info registers
して、a0~6レジスタが0、a7レジスタが1になっていることを確認しましょう。
C標準ライブラリ&カーネルパニック
- C標準ライブラリの章で実装したものたちは、必要になった時に実装or探すことにしたので、スルー
- カーネルパニックについてはこちら
例外処理
ありがたいことに、先駆者様がいらしたので、参考にしました。
割り込みに関するレジスタが沢山あるため、整理します。
レジスタ | 本名 | 用途 |
---|---|---|
sstatus | supervisor status | いろんなステータス(RISC-V原典p107) |
stvec | supervisor trap vector base address | 割り込みハンドラのアドレス |
scause | supervisor cause | 割り込み原因 |
sepc | supervisor exception program counter | 割り込み時のプログラムカウンタ |
sscratch | supervisor scratch | 割り込みハンドラが好きに使える一時領域 |
stval | supervisor trap value | フォルトを起こしたアドレスや不正な命令などのトラップ情報 |
handle_trap
関数は、例外をいい感じに処理するのが目的で、今回はパニックするだけ。scause
などがmutになっているのは、asm!
で変更が加えられるためです。
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); // READ_CSR
asm!("csrr {}, stval", out(reg) stval); // READ_CSR
asm!("csrr {}, sepc", out(reg) sepc); // READ_CSR
}
panic!("unexpected trap scause={:x}, stval={:x}, sepc={:x}\n frame: {:x?}", scause, stval, sepc, frame);
}
上のhandle_trap
を呼んでいるのがkernel_entry
関数で、例外処理前後にいろいろするのが目的となっている。何をしているかをコメントとして書いてみました。
また、spを引数としてhandle_trap
を呼んでいるのは、その関数内でスタックに積んだレジスタを参照するためでしょう。TrapFrame
構造体のフィールドの順番と、スタックに積んだ順番を見比べると見えてくるかもれません。
pub extern "C" fn kernel_entry() {
unsafe {
asm!(
".balign 4", // 4byte境界に並べる
"csrw sscratch, sp", // sscratchに例外発生時のspを一時保管
"addi sp, sp, -4 * 31", // レジスタをスタックに保管するため、spをずらす
"sw ra, 4 * 0(sp)", // レジスタをスタックに保管
~~~~~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~
"sw s11, 4 * 29(sp)",
// ================
"csrr a0, sscratch", // a0にsscratch(例外発生時のsp)を入れる
"sw a0, 4 * 30(sp)", // a0(例外発生時のsp)もスタックに積んで保管
"mv a0, sp", // a0は関数の引数なので、今のspを引数として
"call {handle_trap}", // 関数を呼んでいる
// ================
"lw ra, 4 * 0(sp)", // 帰ってきたので、積んだレジスタを戻す
~~~~~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~
"lw sp, 4 * 30(sp)",
"sret",
handle_trap = sym handle_trap,
);
}
}
メモリ割り当て
もとの記事では、memset
で動的にメモリを初期化しており、Rustでも生ポインタを使えば同じようなことができます。が、せっかくなのでBoxとかでやりたいなーと思ったので、Allocatorを実装して みました。
まずは、もと記事にあるとおりリンカスクリプトを変更します。また、cargoはリンカスクリプトの変更を検知しないらしいので、touch src/main.rs
しておきます。
. = ALIGN(4096);
__free_ram = .;
. += 64 * 1024 * 1024; /* 64MB */
__free_ram_end = .;
Allocatorの実装はこれをそのまま書きました。
正しく設定できたか見てみます。main内で動的にメモリを確保して、そのアドレスを表示してみます。
let mut v = alloc::vec::Vec::new();
v.push(42);
println!("{:p}", v.as_ptr());
上で表示された値と、__free_ram
が一致していることが確認できればOKです。
$ llvm-nm target/riscv32i-unknown-none-elf/debug/os-rust-1000
~~~~~~~~~~~~~~
80225000 B __free_ram
~~~~~~~~~~~~~~
これで動的にメモリを確保するすべを手にできました。当時は、「これでページをBoxとかで確保しようかなー」とか思ってましたが....
プロセス
ここの実装はoctoxを参考にしました。
また、ここからコンパイラがnightlyに変わりました(rust-toolchain
参照)。変えた理由は、#[naked]
を使うためで、詳しくはコンテキストスイッチのところで説明します。
まずは、プロセスの構造体を定義します(Context
構造体については後述します)。この構造体は、static mut
な変数として管理されています。これはいろいろとよくありません。
- そもそも危険な書き方である。
- 構造体にアクセスする際、unsafeが必要になる。実際、
process.rs
はunsafeだらけになってしまった。
本当はProcessManager
的な物を導入して、必要に応じてCellなどで守る必要があると思います。borrow checkerと仲良くなれず、こうなってしまいました。
static mut PROCESS_POOL: [Process; PROCESS_MAX] = [Process::new(0); PROCESS_MAX];
static mut IDLE_PROCESS: Process = Process::new(0);
static mut CURRENT_PROCESS : *mut Process = ptr::null_mut();
#[derive(Debug, Clone, Copy)]
pub struct Process {
pid: u32,
state: ProcessState,
context: Context,
stack: [u8; 8192],
}
impl Process {
pub const fn new(pid: u32) -> Self {
Self {
pid: pid,
state: ProcessState::Unused,
context: Context::new(),
stack: [0; 8192],
}
}
}
create_process
は、割ともとの記事のとおりです。main.rs
から呼ばれます。余談ですが、process.context.sp
を設定する所で間違えて、ひどい目にあいました。
pub unsafe fn create_process(pc :u32) -> Result<(), ProcessError> {
let process = find_unused_process();
if process.is_none() { return Err(ProcessError::FailedToCreateProcess); }
let process = process.unwrap();
process.context.ra = pc;
let sp = ptr::addr_of!(process.stack[process.stack.len() - 1]);
process.context.sp = sp as u32;
process.state = ProcessState::Runnable;
println!("create pid {}", process.pid);
Ok(())
}
unsafe fn find_unused_process() -> Option<&'static mut Process> {
for process in &mut PROCESS_POOL {
if process.state == ProcessState::Unused {
return Some(process);
}
}
None
}
Context
について。もとの記事との違いとして、コンテキストスイッチをスタックで行っていないというのがあり、今回はContext
構造体を使いました。これは、octoxのコードから得たものです、ありがとうございました。
#[derive(Debug,Clone, Copy)]
#[repr(C, packed)]
pub struct Context {
pub ra: u32,
pub sp: u32,
pub s0: u32,
pub s1: u32,
pub s2: u32,
pub s3: u32,
pub s4: u32,
pub s5: u32,
pub s6: u32,
pub s7: u32,
pub s8: u32,
pub s9: u32,
pub s10: u32,
pub s11: u32,
}
コンテキストスイッチは以下の通り。a0,a1はそれぞれ第1,2引数を表しており、swでa0(前のコンテキスト)に今のレジスタ情報を保存し、lwでa1(次のコンテキスト)にある情報をレジスタに入れています。
また、#[naked]
をつけることで関数内のasm!
の内容だけを実行できるようになります。つけないと、今回は不要なアセンブリ(プロローグ,エピローグと言われているもの?)が登場し、上手く動きませんでした。
#[no_mangle]
#[naked]
#[allow(unused_variables)]
pub unsafe extern "C" fn switch_context(prev: &mut Context, next: &Context) {
asm!(
"sw ra, 0 * 4(a0)",
~~~~~~~~~~~~~~~~~~~~~~~
"sw s11, 13 * 4(a0)",
"lw ra, 0 * 4(a1)",
~~~~~~~~~~~~~~~~~~~~~~~
"lw s11, 13 * 4(a1)",
"ret",
options(noreturn),
);
ここから先
残念なことに、メモリ管理がうまく実装できませんでした...
Box
を使ってページ確保をやろうと頑張ってましたが、自分のデバッグ力の限界を迎えてしまい、断念しました。悔しいかぎりです。
Box
で強引にページ管理するより、Page
構造体にAllocatorを実装したりなどすればよかったのかなという反省があります。
感想
CのコードをRustに落とすというのは、言うのは簡単だけど実際にやってみると自分には難しかったです。CにはCの書き方、RustにはRustの書き方があると分かったので、もっと力がついたらリベンジします。
OSという題を冠してはいますが、OSらしいことができるまで作れなかったのは悔しいので、もっと精進します。
RustでOS書きたいという人の架け橋になれたら幸いです。
Discussion
一応Rustで完走(ファイルシステムまで)しましたので、参考までにコードを共有します。コードは汚いですが少しでもお役に立てれば。。。
うぉぉぉ、ありがとうございます!
今度再挑戦してみます!