xv6-riscv を Rust に移植する

xv6 for RISC-V の公式レポジトリ mit-pdos/xv6-riscv を fork して Rust に移植していく。
方針
- 動作する状態を保ちながら徐々に C を Rustに置き換えていく
- 1コミット1コメントで解説していく
- (後で追加するかも)
進捗
GitHub のレポジトリの Language Graph 参照。
C が Rust に 100% 置き換われば完了。

xv6-riscv の Makefile のコンパイラツールチェーン自動検知用処理が Arch Linux では正しく動作しなかったのを修正。 make
時に TOOLPREFIX=...
を指定する必要がなくなった。
riscv-elf-objdump
が存在する場合は、 TOOLPREFIX
に riscv-elf-
を設定するよう分岐を追加した。
余談
openSUSE や RHEL といったメジャーなディストリビューションも同じプレフィックスらしく、対応するための Pull Request が出されているが、長年放置されてしまっている。

Rust のコードを追加して xv6 のカーネルから呼び出せるようにした。
動作確認用に rust_hello()
関数を追加して、メッセージ出力した。
コード編集時に行末の空白が削除されるなどして、余分な diff が出ているのはご愛敬。
コンパイラフラグの指定
以下の通り設定した。
設定内容は Makefile
の CFLAGS
の指定を参考に決定した。
CFLAGS オプションの取捨選択・移植について
オプション | 対応内容 |
---|---|
-Wall -Werror -Wno-main |
コンパイル警告に関わるオプション。 C コンパイラ固有なので Rust 移植において考慮不要 |
-O |
最適化を有効化するオプション。 Rust コードはリリースビルドすることにした (debug ビルド + opt-level=1 にしても良いかも) |
-fno-omit-frame-pointer |
最適化の frame pointer elimination を無効化するオプション。 rustc の -C force-frame-pointers=yes に対応すると思われる |
-ggdb , -gdwarf-2
|
デバッグ情報の生成に関わるオプション。 Cargo.toml のリリースプロファイルの設定に debug = "full" を追加。 |
-MD |
コンパイル時にヘッダファイル等の依存ファイル情報を出力するオプション。Makefile での依存関係定義のために使用されている。 Rust コードのビルドには Cargo を使うので対応不要 |
-mcmodel=medany |
Risc-V 向けコードビルド時のメモリモデルを指定するオプション。 riscv64gc-unknown-none-elf ターゲットでは medany と同等の medium が指定されているようだ (後述の target spec JSON 参照) |
-ffreestanding , -nostdlib
|
フリースタンディング環境向けのビルドを指定する。 Rust では #![no_std] が相当すると思われる |
-fno-common |
未初期変数のリンクに関わるオプションらしい。Rust の場合、全てのグローバル変数は初期化されるため、関係ないと思われる |
-fno-builtin-* |
ビルトイン関数の利用に関わるオプション。 C 標準ライブラリに関わるもので、 Rust には関係ないと思われる |
-I |
C プリプロセッサの include パスに関わるオプション。Rust には関係ない |
-fno-pie , -no-pie
|
PIE (Position Independent Executable) 無効なコード生成するためのオプション。 rustc の -C relocation-model=static に対応すると思われる |
-fno-stack-protector |
スタック保護の仕組みを無効化するオプション。 riscv64gc-unknown-none-elf ターゲットの場合は勝手に無効になっているものと思われる |
riscv64gc-unknown-none-elf の target spec JSON
-
出力コマンド
$ rustc -Z unstable-options --target=riscv64gc-unknown-none-elf --print target-spec-json
-
出力結果
{ "arch": "riscv64", "code-model": "medium", "cpu": "generic-rv64", "crt-objects-fallback": "false", "data-layout": "e-m:e-p:64:64-i64:64-i128:128-n32:64-S128", "eh-frame-header": false, "emit-debug-gdb-scripts": false, "features": "+m,+a,+f,+d,+c", "linker": "rust-lld", "linker-flavor": "gnu-lld", "llvm-abiname": "lp64d", "llvm-target": "riscv64", "max-atomic-width": 64, "metadata": { "description": "Bare RISC-V (RV64IMAFDC ISA)", "host_tools": false, "std": false, "tier": 2 }, "panic-strategy": "abort", "relocation-model": "static", "supported-sanitizers": [ "shadow-call-stack", "kernel-address" ], "target-pointer-width": "64" }
.cargo/config.toml
での設定は避けたかったが、他に良い方法が見つけられなかった。
`.cargo/config.toml` での設定を避けたかった理由
.cargo/config.toml
は cargo
コマンドを実行したディレクトリ直下のものが読み込まれる ため、同一 workspace 配下に複数の crate がある場合、 crate ごとに異なる設定ができないという問題がある。
また、コマンドの実行ディレクトリに依存して読み込まれる設定が異なる可能性があるというのも、気持ち悪いポイント。
うまくいかなかった他の手段
build.rs
から println!("cargo::rustc-flags=-C ...");
を呼び出して設定したかった。
build.rs
の指定では -l
と -L
以外の指定は許可されていない ため、 build.rs
の実行時エラーが出てしまった。
Makefile
の更新
$R/libkernel.a
を $K/kernel
の依存関係とリンク対象のファイルに追加。
cargo build --release
を実行して $R/libkernel.a
をビルドするルールを追加。当該ルールは FORCE
ルールに依存させることによって、 make
実行時に必ず cargo build --release
が実行されるようにした。
Cargo.toml
の設定
kernel
crate は、 unstable feature の forced-target
指定により常に riscv64gc-unknown-none-elf
ターゲットでビルドされるようにした。

スピンロックを提供する spinlock.c
を Rust に移植した。
また、 Rust 風の保護対象データを内部に持つロック実装の Mutex<T>
も実装した。
最初はコンソール出力周りから手を付けようとしたのですが、スピンロック実装が必要だったので最初に実装することにした。
実装にあたっては、 Rust Atomics and Locks の Chapter 4. Building Our Own Spin Lock を参考にした。
RISC-V のレジスタ操作等には riscv
crate を利用している。
オリジナルの xv6-riscv のコードではロックの獲得後および解放前に __sync_synchronize()
を呼び出して fence
命令を実行している。
アトミック変数へのアクセス時のメモリオーダリング指定を適切に行えば fence
命令は不要だと思うので、 Rust 移植版では削除している。

コンソールへの入出力を担う console.c
を Rust に移植した。
C 実装の consoleputc()
関数は char
+ backspace などの特殊文字を意味する整数値 (0x100
) を受け付けるようになっている。
特殊文字は u8
の値域外の値なので、 consoleputc()
関数の引数は int
型になっている。
Rust 実装では両者を console::put_char()
と console::put_backspace()
の二つの関数に分割している。また、引数の型は char
として、内部で u8
に変換している。
当面はASCII範囲の文字さえ考えれば良さそうなので文字の扱いが雑でも良いが、そのうちちゃんとしたい。

現在の実装では &str
を str::chars()
で char
にバラしているが、 str::as_bytes()
で u8
の配列として取り扱った方が始末が良いかもしれない。
getc()
をどうすべきかは要検討 (invalid UTF-8 sequenceになり得るので、適宜バッファリングしつつ読み込みする必要がある)。

文字列のコンソール出力を担う print.c
を Rust に移植した。
core::fmt
のありがたさを噛み締めつつ printf
も Rust へと移植した。
unsatable feature の c_variadic
を有効化して、 va_list
を Rust 関数から取り扱っている。
printf()
のフォーマット指定子のパースは Rust の文字列操作関数を使うことで、 C 版の実装より読みやすくできたと思う。

main
関数を含む main.c
を Rust に移植した。
実装にあたり多数の unsafe extern "C"
な外部関数の宣言を追加した。移植を続けて徐々に消していきたい。

ページ割り当てを担う kalloc.c
を Rust に移植した。
Rust はフリースタンディング環境でも core::ptr::write_bytes()
などのメモリ操作関数が使えるのが便利ですね。
リンカスクリプトで定義されるシンボル end
については、長さ0の配列型の外部変数として定義した。
zero-sized type にしておいた方が、誤って参照・操作するリスクが減りそうだと思ったため。
なお、 変数の型を ()
にすると ()
は not FFI-safe だという警告が出る (improper_ctypes
)。
end
のようなすごくジェネリックな名前をグローバルなシンボルとしてリンカスクリプトで埋め込むのは、いかがな物かと思う。

END
の型は c_void
でも良いのかもしれない。
リンカスクリプトで定義されたシンボルへの宣言があちこちに散らばるのはよろしくないと思うので、 memlayout.rs
あたりにまとめるべきかもしれない。
一方で、 end
は kalloc.rs
でしか参照しないなら kalloc.rs
で宣言すべきではないかという気持ちもある。
悩ましい。

ビルド時に生成されるシンボル一覧ファイル (*.sym
) と、逆アセンブリファイル (*.asm
) に登場するシンボルをデマングルするように変更。
*.sym
ファイルについは objdump -t
の出力結果を sed
で加工しているため、 objdump
の -C
(--demangle
) オプションを指定すると出力が壊れる (デマングルされたシンボルにスペースが含まれるため)。
sed
のコマンドを修正してもよかったが、面倒くさかったため、 sed
の出力結果を c++filt
に通すようにして対応した。
sed
で加工せず、 objdump
の出力を加工せず保存しても良いじゃんとも思う。

カーネルのエントリーポイントを定義する entry.S
を Rust の crate 内に移動した。
entry.S
をリネームした entry.s
を global_asm!(include_str!(".."))
マクロで Rust コードに取り込んでいる。
アセンブリファイルの拡張子には 以下のような違いあるらしい。
-
.S
: プリプロセッサを通した後、アセンブラに入力する -
.s
: アセンブラに直接入力する
今回はプリプロセッサは使えないので、拡張子は .s
が適切だと思われる。
_entry
シンボルを .text
セクションの先頭に配置する
QEMU で ELF を実行する場合、0x8000_0000
アドレスのコードがエントリーポイントとなる。
このため、エントリーポイントの _entry
シンボルは .text
セクションの先頭 (0x8000_0000
) に配置する必要がある。
C 版のコードでは、リンカに渡すオブジェクトファイルの先頭を entry.o
にすることで、 _entry
シンボルが .text
の先頭に配置されることを保証している。
Rust 版ではこのような対応はできないこと、また、リンカに渡す引数の順序に依存するのは危うく思えたため、以下のようにして _entry
が .text
の先頭に配置されることを保証した。
-
_entry
を.text.init
セクションに配置するようentry.s
を書き換え
- リンカスクリプト
kernel/kernel.ld
で.text.init
セクションを.text
セクションの先頭に配置するよう指定
この手法は The Adventures of OS: Making a RISC-V Operating System using Rust でも採用されている。
LLVM の謎のバグに対処する
コンパイル時に以下のエラーが出力された。
error: <inline asm>:21:9: instruction requires the following: 'Zmmul' (Integer Multiplication)
mul a0, a0, a1
^
mul
命令を使用するためには Zmmul
という target feature が必要というエラーだが、今回ターゲットとしている riscv64gc
は当該命令をサポートしているはずなので、このようなエラーが出るのはおかしい。
GitHub で Issue が発行されているが、 LLVM のバグで現在も修正されていないようだ。
以下の Pull Request の workaround を取り込んで対処した。
なお、この問題はリリースビルドでのみ発生するようだ。

global_asm!
追加時の RFC (RFC1548) によると、
Assembly files can also be preprocessed or generated by build.rs (for example using the C preprocessor), which will produce output files in the Cargo output directory:
とのことで、プリプロセスが必要な場合は build.rs
内でプリプロセッサを呼べという方針っぽい。

ドキュメントコメントのコードブロックの言語指定が不足していたのを修正。
VSCode + rust-analyzer で開発をしているのだが、 lib.rs
の mod memlayout;
の行の上に Run Doctest
というコマンドが出てきたため、この問題に気づくことができた。

エントリポイントから呼び出される初期化処理を実装する start.c
を Rust に移植した。
アセンブリから参照されるシンボルについては、 #[unsafe(no_mangle)]
指定するのではなく、 global_asm!
の引数で関数シンボルを渡しアセンブリに含めるようにしている。
Rust コード中の #[unsafe(no_mangle)]
は、徐々に消していって、最終的には0にしたい。
riscv
crate では CSR の medeleg
, mideleg
, sie
の複数のフラグを同時に操作する手段が提供されていないようなので、 asm!()
でインラインアセンブリを書いたり、個々のフラグをそれぞれ個別に設定するようにしたりして対応した。

Rust の crate 内でアセンブリを読み込む際のモジュール構成を変更した。
新旧方式で優劣は特にないと思うが、アセンブリ読み込みの度に mod.rs
が増えるのも嫌な感じがしたため、 lib.rs
に global_asm!(include_str!(...))
を書くように変更した。
やり方は後でまた見直すかも。

シリアル経由のやりとりを担う uart.c
を Rust に移植した。
送信バッファ操作周りの処理を TxBuffer
に集約して多少抽象化した。
MMIO レジスタ操作関連はもう少し綺麗にできそうだが、こだわり始めるとキリがなさそうなので、 C のベタ移植で一旦ヨシとした。
TxBuffer
の初期化は #[derive(Default)]
を活用してシュッとやりたい気持ちはあるが、 const コンテキストで Default::default()
が呼び出せないので諦めた。
const_trait_impl
あたり使えばなんとかなるんですかね。

Rust 移植により不要になった FFI 用コードを削除した。
面白みのない変更ではないですが、やっていて気持ちいいですね。

if cond { panic!() }
を assert!(!cond)
に置き換えた。
変更の理由
- コード中に
assert
の文字が現れるため、 assertion のためのチェックという意図が明確になる - Rust の慣例として事前条件チェックは
assert!
でやるのが普通 - 異常時のエラーメッセージが分かり易くなる

README.md
を追加した。
ビルドに必要なツールや、ビルド方法についても説明した。
オリジナルの xv6 のリポジトリではこのあたりの説明が見つけられず戸惑ったので、親切心で追加した。

進捗 (2025-02-25)
12.6% = (12.0%) / (12.0% + 83.0%)
$ tokei
===============================================================================
Language Files Lines Code Comments Blanks
===============================================================================
GNU Style Assembly 5 311 275 0 36
C 35 9128 7157 892 1079
C Header 17 1101 788 170 143
Makefile 1 181 138 14 29
Perl 1 38 31 2 5
TOML 3 32 26 0 6
-------------------------------------------------------------------------------
Markdown 1 81 0 45 36
|- BASH 1 9 9 0 0
(Total) 90 9 45 36
-------------------------------------------------------------------------------
Rust 11 1164 888 77 199
|- Markdown 10 137 0 114 23
(Total) 1301 888 191 222
===============================================================================
Total 74 12036 9303 1200 1533
===============================================================================

ページテーブル関連処理を担う vm.c
を Rust に移植した。
ボリュームが多く、結構大変だった。
NonNull
の活用
*mut T
ではなく、 NonNull<T>
or Option<NonNull<T>>
を利用するようにしてみた。
NULL
ポインタチェックの漏れがなくなると良いなと思い、こうしている。
kalloc()
関数も同様に Option<NonNull<T>>
を返すようにすると良さそう。
各種データ構造の定義
以下を表現するデータ型を用意した。
- 仮想アドレス
VirtAddr
- 物理アドレス
PhysAddr
- 物理ページ番号
PhysPageNum
- ページテーブルエントリ
PtEntry
- ページテーブルエントリのフラグ
PtEntryFlag
- ページテーブル
PageTable
ページテーブル操作については、 &mut PageTable
や &PageTable
を取り回して処理できるようになっていて、 C のコードより読みやすくなっているのではないかと思う。
ページテーブルの解放については、 &mut T
が dangling pointer になるUBを回避するため、生ポインタで扱っている。
あまり綺麗ではないのでなんとかしたい。
(PageTable
への owning な参照型 (Box<T>
みたいな) を定義すべきか?)
Vec::drain()
風の解放処理
PageTable::unmap_pages()
は解放したページの物理アドレスを列挙するイテレータを返すインタフェースとした。
物理アドレスの解放が必要な場合、呼び出し元でよしなに処理できるようになっている。
goto err;
の移植
goto err;
でのエラー処理は closure + return Err(...)
で再現。
くるしい...
ちゃんとやるならRAIIを活用すべきですね。

進捗 (2025-02-06)
22.7% = (21.6%) / (21.6% + 73.7%)
vm.c
を移植したら一気に10%くらい進んだ (Rus tのコード量が多いというのもある)。
$ tokei
===============================================================================
Language Files Lines Code Comments Blanks
===============================================================================
GNU Style Assembly 5 311 275 0 36
C 34 8677 6841 814 1022
C Header 17 1098 785 170 143
Makefile 1 180 137 14 29
Perl 1 38 31 2 5
TOML 3 33 27 0 6
-------------------------------------------------------------------------------
Markdown 1 81 0 45 36
|- BASH 1 9 9 0 0
(Total) 90 9 45 36
-------------------------------------------------------------------------------
Rust 12 2061 1597 120 344
|- Markdown 11 282 0 228 54
(Total) 2343 1597 348 398
===============================================================================
Total 74 12479 9693 1165 1621
===============================================================================

いくつかの *mut T
を NonNull<T>
or Option<NonNull<T>>
に置き換えた。
nullptr
が返る可能性がある箇所を明示できること、また、 nullptr
の対処を強制できるのが良い感じ。
0番アドレスへのアクセスで例外発生するよりも、 unwrap()
契機でパニックした方が原因箇所が分かり易いと思う。

RISC-V の page table entry の RSV 領域を flags に統合した。
ユーザ定義の flag を指定する領域だろうということで、 flags と異なる扱いをする必要もなさそうなので。

仮想メモリ関連処理をリファクタリングして、ページテーブルエントリへの mutable アクセスが PageTable
型の impl
から漏れ出さないようにした。
ページテーブルエントリは変な操作をすると分かりにくいバグを引き起こしそうだったので、ページテーブルエントリを編集できる箇所を限定して多少の安心感を得られるようにした。

ページ単位のアドレスを丸める操作を提供する PageRound
トレイトを定義し、各種構造体に実装した。
VirtAddr
や NonZero<T>
の値を丸める際、 VirtAddr(page_roundup(va.0.addr()))
などと書くのがダルかったので、 va.page_roundup()
と書けるようにした。
今思えば、 map_addr
的な関数を用意してもよかったかもしれない。

生ポインタでデータコピーなどしている箇所で、スライスを利用するようにした。
境界チェックがあるのでロジックバグへの耐性が高まった気がする。