Zenn
Open29

xv6-riscv を Rust に移植する

ピン留めされたアイテム
gifnksmgifnksm

xv6 for RISC-V の公式レポジトリ mit-pdos/xv6-riscv を fork して Rust に移植していく。

https://github.com/gifnksm/xv6-riscv

方針

  • 動作する状態を保ちながら徐々に C を Rustに置き換えていく
  • 1コミット1コメントで解説していく
  • (後で追加するかも)

進捗

GitHub のレポジトリの Language Graph 参照。
C が Rust に 100% 置き換われば完了。

gifnksmgifnksm

xv6-riscv の Makefile のコンパイラツールチェーン自動検知用処理が Arch Linux では正しく動作しなかったのを修正。 make 時に TOOLPREFIX=... を指定する必要がなくなった。

https://github.com/gifnksm/xv6-riscv/commit/f0e39451527ba6af9f19dfb674009c32a9d6cc82

riscv-elf-objdump が存在する場合は、 TOOLPREFIXriscv-elf- を設定するよう分岐を追加した。

https://github.com/gifnksm/xv6-riscv/blob/f0e39451527ba6af9f19dfb674009c32a9d6cc82/Makefile#L38-L51

余談

openSUSE や RHEL といったメジャーなディストリビューションも同じプレフィックスらしく、対応するための Pull Request が出されているが、長年放置されてしまっている。

https://github.com/mit-pdos/xv6-riscv/pull/35

gifnksmgifnksm

Rust のコードを追加して xv6 のカーネルから呼び出せるようにした。
動作確認用に rust_hello() 関数を追加して、メッセージ出力した。

https://github.com/gifnksm/xv6-riscv/commit/c14555547ef0fcba957a91cf67e4f04b1aba1b63

コード編集時に行末の空白が削除されるなどして、余分な diff が出ているのはご愛敬。

コンパイラフラグの指定

以下の通り設定した。

https://github.com/gifnksm/xv6-riscv/blob/c14555547ef0fcba957a91cf67e4f04b1aba1b63/.cargo/config.toml#L1-L5

https://github.com/gifnksm/xv6-riscv/blob/c14555547ef0fcba957a91cf67e4f04b1aba1b63/Cargo.toml#L9-L10

設定内容は MakefileCFLAGS の指定を参考に決定した。

https://github.com/gifnksm/xv6-riscv/blob/c14555547ef0fcba957a91cf67e4f04b1aba1b63/Makefile#L62-L82

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.tomlcargo コマンドを実行したディレクトリ直下のものが読み込まれる ため、同一 workspace 配下に複数の crate がある場合、 crate ごとに異なる設定ができないという問題がある。
また、コマンドの実行ディレクトリに依存して読み込まれる設定が異なる可能性があるというのも、気持ち悪いポイント。

うまくいかなかった他の手段

build.rs から println!("cargo::rustc-flags=-C ..."); を呼び出して設定したかった。
build.rs の指定では -l-L 以外の指定は許可されていない ため、 build.rs の実行時エラーが出てしまった。

Makefile の更新

$R/libkernel.a$K/kernel の依存関係とリンク対象のファイルに追加。

https://github.com/gifnksm/xv6-riscv/blob/c14555547ef0fcba957a91cf67e4f04b1aba1b63/Makefile#L86-L89

cargo build --release を実行して $R/libkernel.a をビルドするルールを追加。当該ルールは FORCE ルールに依存させることによって、 make 実行時に必ず cargo build --release が実行されるようにした。

https://github.com/gifnksm/xv6-riscv/blob/c14555547ef0fcba957a91cf67e4f04b1aba1b63/Makefile#L119-L120

Cargo.toml の設定

kernel crate は、 unstable feature の forced-target 指定により常に riscv64gc-unknown-none-elf ターゲットでビルドされるようにした。

https://github.com/gifnksm/xv6-riscv/blob/c14555547ef0fcba957a91cf67e4f04b1aba1b63/kernel/Cargo.toml#L7

gifnksmgifnksm

スピンロックを提供する spinlock.c を Rust に移植した。
また、 Rust 風の保護対象データを内部に持つロック実装の Mutex<T> も実装した。

https://github.com/gifnksm/xv6-riscv/commit/39e8ebe05bfbe7d5d2b4dae38c4aedc84b8f6d1b

最初はコンソール出力周りから手を付けようとしたのですが、スピンロック実装が必要だったので最初に実装することにした。

実装にあたっては、 Rust Atomics and LocksChapter 4. Building Our Own Spin Lock を参考にした。

RISC-V のレジスタ操作等には riscv crate を利用している。

オリジナルの xv6-riscv のコードではロックの獲得後および解放前に __sync_synchronize() を呼び出して fence 命令を実行している。
アトミック変数へのアクセス時のメモリオーダリング指定を適切に行えば fence 命令は不要だと思うので、 Rust 移植版では削除している。

https://github.com/mit-pdos/xv6-riscv/blob/de247db5e6384b138f270e0a7c745989b5a9c23b/kernel/spinlock.c#L54-L60

gifnksmgifnksm

コンソールへの入出力を担う console.c を Rust に移植した。

https://github.com/gifnksm/xv6-riscv/commit/76f825aa1900b4ff99c902cedd38ef240d82cf77

C 実装の consoleputc() 関数は char + backspace などの特殊文字を意味する整数値 (0x100) を受け付けるようになっている。
特殊文字は u8 の値域外の値なので、 consoleputc() 関数の引数は int 型になっている。

Rust 実装では両者を console::put_char()console::put_backspace() の二つの関数に分割している。また、引数の型は char として、内部で u8 に変換している。

当面はASCII範囲の文字さえ考えれば良さそうなので文字の扱いが雑でも良いが、そのうちちゃんとしたい。

gifnksmgifnksm

現在の実装では &strstr::chars()char にバラしているが、 str::as_bytes()u8 の配列として取り扱った方が始末が良いかもしれない。

getc() をどうすべきかは要検討 (invalid UTF-8 sequenceになり得るので、適宜バッファリングしつつ読み込みする必要がある)。

gifnksmgifnksm

文字列のコンソール出力を担う print.c を Rust に移植した。

https://github.com/gifnksm/xv6-riscv/commit/0d4787409415051e0fcfa3948d6e33516499fdc4

core::fmt のありがたさを噛み締めつつ printf も Rust へと移植した。
unsatable feature の c_variadic を有効化して、 va_list を Rust 関数から取り扱っている。

printf() のフォーマット指定子のパースは Rust の文字列操作関数を使うことで、 C 版の実装より読みやすくできたと思う。

gifnksmgifnksm

ページ割り当てを担う kalloc.c を Rust に移植した。

https://github.com/gifnksm/xv6-riscv/commit/38ab886820dafcf531ec1fea7c18fb69aefa9231

Rust はフリースタンディング環境でも core::ptr::write_bytes() などのメモリ操作関数が使えるのが便利ですね。

リンカスクリプトで定義されるシンボル end については、長さ0の配列型の外部変数として定義した。
zero-sized type にしておいた方が、誤って参照・操作するリスクが減りそうだと思ったため。
なお、 変数の型を () にすると () は not FFI-safe だという警告が出る (improper_ctypes)。

https://github.com/gifnksm/xv6-riscv/blob/38ab886820dafcf531ec1fea7c18fb69aefa9231/kernel/src/kalloc.rs#L31-L41

end のようなすごくジェネリックな名前をグローバルなシンボルとしてリンカスクリプトで埋め込むのは、いかがな物かと思う。

gifnksmgifnksm

END の型は c_void でも良いのかもしれない。

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

gifnksmgifnksm

ビルド時に生成されるシンボル一覧ファイル (*.sym) と、逆アセンブリファイル (*.asm) に登場するシンボルをデマングルするように変更。

https://github.com/gifnksm/xv6-riscv/commit/0175c0f47a86600e327cd6c049e515a468d05af9

*.sym ファイルについは objdump -t の出力結果を sed で加工しているため、 objdump-C (--demangle) オプションを指定すると出力が壊れる (デマングルされたシンボルにスペースが含まれるため)。

sed のコマンドを修正してもよかったが、面倒くさかったため、 sed の出力結果を c++filt に通すようにして対応した。

https://github.com/gifnksm/xv6-riscv/blob/0175c0f47a86600e327cd6c049e515a468d05af9/Makefile#L81-L84

sed で加工せず、 objdump の出力を加工せず保存しても良いじゃんとも思う。

gifnksmgifnksm

カーネルのエントリーポイントを定義する entry.S を Rust の crate 内に移動した。

https://github.com/gifnksm/xv6-riscv/commit/fc627fa4994336dbd34bcdbdc8b6ba0710f644a8

entry.S をリネームした entry.sglobal_asm!(include_str!("..")) マクロで Rust コードに取り込んでいる。

https://github.com/gifnksm/xv6-riscv/blob/fc627fa4994336dbd34bcdbdc8b6ba0710f644a8/kernel/src/entry/mod.rs#L1-L3

アセンブリファイルの拡張子には 以下のような違いあるらしい

  • .S : プリプロセッサを通した後、アセンブラに入力する
  • .s : アセンブラに直接入力する

今回はプリプロセッサは使えないので、拡張子は .s が適切だと思われる。

_entry シンボルを .text セクションの先頭に配置する

QEMU で ELF を実行する場合、0x8000_0000 アドレスのコードがエントリーポイントとなる。
このため、エントリーポイントの _entry シンボルは .text セクションの先頭 (0x8000_0000) に配置する必要がある。

C 版のコードでは、リンカに渡すオブジェクトファイルの先頭を entry.o にすることで、 _entry シンボルが .text の先頭に配置されることを保証している。

Rust 版ではこのような対応はできないこと、また、リンカに渡す引数の順序に依存するのは危うく思えたため、以下のようにして _entry.text の先頭に配置されることを保証した。

  1. _entry.text.init セクションに配置するよう entry.s を書き換え

https://github.com/gifnksm/xv6-riscv/blob/fc627fa4994336dbd34bcdbdc8b6ba0710f644a8/kernel/src/entry/entry.s#L10-L12

  1. リンカスクリプト kernel/kernel.ld.text.init セクションを .text セクションの先頭に配置するよう指定

https://github.com/gifnksm/xv6-riscv/blob/fc627fa4994336dbd34bcdbdc8b6ba0710f644a8/kernel/kernel.ld#L12-L14

この手法は 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 のバグで現在も修正されていないようだ。

https://github.com/rust-embedded/riscv/issues/175

以下の Pull Request の workaround を取り込んで対処した。

https://github.com/rust-embedded/riscv/pull/176

https://github.com/gifnksm/xv6-riscv/blob/fc627fa4994336dbd34bcdbdc8b6ba0710f644a8/kernel/src/entry/entry.s#L1-L5

なお、この問題はリリースビルドでのみ発生するようだ。

gifnksmgifnksm

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 内でプリプロセッサを呼べという方針っぽい。

gifnksmgifnksm

エントリポイントから呼び出される初期化処理を実装する start.c を Rust に移植した。

https://github.com/gifnksm/xv6-riscv/commit/050658c389c914fcc71ade96e619265d1a15e42f

アセンブリから参照されるシンボルについては、 #[unsafe(no_mangle)] 指定するのではなく、 global_asm! の引数で関数シンボルを渡しアセンブリに含めるようにしている。
Rust コード中の #[unsafe(no_mangle)] は、徐々に消していって、最終的には0にしたい。

riscv crate では CSR の medeleg, mideleg, sie の複数のフラグを同時に操作する手段が提供されていないようなので、 asm!() でインラインアセンブリを書いたり、個々のフラグをそれぞれ個別に設定するようにしたりして対応した。

https://github.com/gifnksm/xv6-riscv/blob/050658c389c914fcc71ade96e619265d1a15e42f/kernel/src/start.rs#L26-L32

gifnksmgifnksm

シリアル経由のやりとりを担う uart.c を Rust に移植した。

https://github.com/gifnksm/xv6-riscv/commit/0d354a42ace59a4f38ed4881a03eac03fd00c600

送信バッファ操作周りの処理を TxBuffer に集約して多少抽象化した。

https://github.com/gifnksm/xv6-riscv/blob/0d354a42ace59a4f38ed4881a03eac03fd00c600/kernel/src/uart.rs#L66-L102

MMIO レジスタ操作関連はもう少し綺麗にできそうだが、こだわり始めるとキリがなさそうなので、 C のベタ移植で一旦ヨシとした。

TxBuffer の初期化は #[derive(Default)] を活用してシュッとやりたい気持ちはあるが、 const コンテキストで Default::default() が呼び出せないので諦めた。
const_trait_impl あたり使えばなんとかなるんですかね。

gifnksmgifnksm

進捗 (2025-02-25)

12.6% = (12.0%) / (12.0% + 83.0%)

Language Graph

$ 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
===============================================================================
gifnksmgifnksm

ページテーブル関連処理を担う vm.c を Rust に移植した。
ボリュームが多く、結構大変だった。

https://github.com/gifnksm/xv6-riscv/commit/82622a8e81475b42ac1e7bd4863ba56b2b858d58

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> みたいな) を定義すべきか?)

https://github.com/gifnksm/xv6-riscv/blob/82622a8e81475b42ac1e7bd4863ba56b2b858d58/kernel/src/vm.rs#L834-L850

Vec::drain() 風の解放処理

PageTable::unmap_pages() は解放したページの物理アドレスを列挙するイテレータを返すインタフェースとした。

https://github.com/gifnksm/xv6-riscv/blob/82622a8e81475b42ac1e7bd4863ba56b2b858d58/kernel/src/vm.rs#L440-L452
https://github.com/gifnksm/xv6-riscv/blob/82622a8e81475b42ac1e7bd4863ba56b2b858d58/kernel/src/vm.rs#L612-L626

物理アドレスの解放が必要な場合、呼び出し元でよしなに処理できるようになっている。

goto err; の移植

goto err; でのエラー処理は closure + return Err(...) で再現。
くるしい...
ちゃんとやるならRAIIを活用すべきですね。

https://github.com/gifnksm/xv6-riscv/blob/82622a8e81475b42ac1e7bd4863ba56b2b858d58/kernel/src/vm.rs#L857-L886
https://github.com/gifnksm/xv6-riscv/blob/cd3e85c103fff140ede18fd1cdf767280e9b3743/kernel/vm.c#L312-L340

gifnksmgifnksm

進捗 (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
===============================================================================
gifnksmgifnksm

コード中の識別子やコメントのスペルチェックをしてくれる typos を導入して、スペルミスを修正。

https://github.com/gifnksm/xv6-riscv/commit/dba5090c0a4b42df1555252122beaab24685b545

false positive が少なくて、良い感じ。
C のコードの方でもスペルミスが検出されたが、見なかったことにして exclude した。

typos は clap のプロジェクトで使っているのを見て存在を知った。
このプロジェクトは他にもいろいろ便利ツールを導入しているので、折を見て試してみたい。

gifnksmgifnksm

仮想メモリ関連処理をリファクタリングして、ページテーブルエントリへの mutable アクセスが PageTable 型の impl から漏れ出さないようにした。

https://github.com/gifnksm/xv6-riscv/commit/2d1e8d024bf89a03bc82923ca5de0f3fc39df938

ページテーブルエントリは変な操作をすると分かりにくいバグを引き起こしそうだったので、ページテーブルエントリを編集できる箇所を限定して多少の安心感を得られるようにした。

gifnksmgifnksm

ページ単位のアドレスを丸める操作を提供する PageRound トレイトを定義し、各種構造体に実装した。

https://github.com/gifnksm/xv6-riscv/commit/115afe20705263bc14d2c514d3dfedac5ad41a5b

VirtAddrNonZero<T> の値を丸める際、 VirtAddr(page_roundup(va.0.addr())) などと書くのがダルかったので、 va.page_roundup() と書けるようにした。
今思えば、 map_addr 的な関数を用意してもよかったかもしれない。

ログインするとコメントできます