😖

Rustで自作OSに挑戦した話(敗走)

2024/01/28に公開2

この記事について

Writing an OS in 1000 LinesをRustで途中まで頑張った記録を残したものです。もと記事はとても素晴らしいので、みなさんやりましょう!
RustでOS書きたいと思った時、小さな踏み台になれればと思います。もし間違っている部分があれば、教えていただけると嬉しいです。
また、Scrapboxに日記があります。人に見せるようなものではないかもですが、詳しめに書いているので置いておきます。動かない原因を調査するため、もがいている姿が見れます。

つくったものはこちら
https://github.com/yttnn/os-rust-1000

参考

以下の記事&コードを沢山参考にしました。ありがとうございました!
https://operating-system-in-1000-lines.vercel.app/ja/welcome
https://zenn.dev/sphendami/scraps/e3b9bdb82d0b7e
https://github.com/o8vm/octox
https://os.phil-opp.com/ja/

環境

  • 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を指定
  • コンパイルのターゲットを指定
config.toml
[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がでなくなります。

Cargo.toml
[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

リンカスクリプトで定義したシンボルにアクセスできるように変数を定義します。extern "C"すると何が起きるがちゃんと考えてみたりもしました。

main.rs
extern "C" {
  static __stack_top: u8;
  static mut __bss: u8;
  static __bss_end: u8;
}
  • kernel_main関数と、boot関数はわりとそのままなので省略...
  • Rustのインラインアセンブリは、ドキュメントを読むとなんとなくわかります
    • それだと味気ないので、独自調査を添えてみたり
main.rs
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のキャストはこれを参考にしました
    • (実は、このキャストで何が起きているかちゃんと理解してない...)
main.rs
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!で変更が加えられるためです。

trap.rs
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構造体のフィールドの順番と、スタックに積んだ順番を見比べると見えてくるかもれません。

trap.rs
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しておきます。

kernel.ld
  . = ALIGN(4096);
  __free_ram = .;
  . += 64 * 1024 * 1024; /* 64MB */
  __free_ram_end = .;

Allocatorの実装はこれをそのまま書きました。

https://github.com/yttnn/os-rust-1000/blob/10_memory/src/allocator.rs

正しく設定できたか見てみます。main内で動的にメモリを確保して、そのアドレスを表示してみます。

main.rs
  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と仲良くなれず、こうなってしまいました。

process.rs
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を設定する所で間違えて、ひどい目にあいました。

process.rs
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のコードから得たものです、ありがとうございました。

process.rs
#[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!の内容だけを実行できるようになります。つけないと、今回は不要なアセンブリ(プロローグ,エピローグと言われているもの?)が登場し、上手く動きませんでした。

switch.rs
#[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