📙

「作って理解する仮想化技術」 読書ログ in Zig

に公開

イントロ

作って理解する仮想化技術 ─⁠─ハイパーバイザを実装しながら仕組みを学ぶ (著: 森真誠) という本を読み終えたので、その読書感想文。自作本の自分なりの読み方について。
念の為書いておくと、本記事はアフィリエイトではない。自分は筆者の方とは特に面識は無い。(完全に無いわけではなく、2023年のセキュリティ・キャンプ全国大会の講師をした時に同じく講師をしていた著者の方に挨拶程度に会話をしたくらいはある。恐らく向こうは覚えていないが。)

モチベーション

昔は自作なんちゃらの本を読むのが好きだった。中1 で 30日でできる!OS自作入門 (著: 川合秀実) を読んだことを契機とし、CPUの創り方 (著: 渡波郁)、Go言語でつくるインタプリタ (著: Thorsten Ball) など色々な自作本を読んできた。自作本は、面白い。なぜ動いているか分からないものの仕組みを理解し、ましてやそれらを自分で作って動かせてしまう。理解する楽しみと作る楽しみの両方を味わうことのできる本だ。
とりわけ OS 自作本はそれなりの数が出揃っており、先程の30日本に加え 12ステップで作る 組込みOS自作入門 (著: 坂井弘亮)、作って理解するOS x86系コンピュータを動かす理論と実装 (著: 林高勲)、ゼロからのOS自作入門 (著: 内田公太)、[作って学ぶ]OSの仕組み1 (著: hikalium) などが存在する。自分はその殆どをとりあえず読んで実装または移植してきたが、食傷気味になってきた。未知の領域を知ることができるというのが自作本の良さだが、流石に未知と言うには知りすぎてしまった感がある (まだ全然分からないけど)。

話は変わり、2024年の年末に Writing Hypervisor in Zig というブログシリーズを書いた。x64 で Type-1 Hypervisor を作り Linux をブートさせようというブログだ。全30章から成り CC0 で公開している。社会人になり研究をしなくて良くなったので暇な時間に書いた Ymir という hypervisor を作った事をきっかけに書いたものだ。本と呼べるほど大したものではないが、これを書くのは面白かった。人に説明するためには自分の中で曖昧な知識を潰していく必要がある。また、最小構成から積み上げていくために各要素が何に依存しているのかをはっきりさせていく必要もあった。ただ自分のためにコードを書く時には蔑ろにしがちな部分をより深く考える中で新たな発見を得ることができた経験だった。書いた時には誰も読まないだろうと思っていたが、完走した人 もいるようで嬉しい限りだ。

そんな中、ある日 Amazon で [作って学ぶ]OSの仕組み1 の続きが出ていないか探していると、ふと本書が予約販売されているのが目に入った。数多ある自作本の中でも hypervisor を自作するものは見たことがなく、すぐに購入した。監修が自分の研究室時代の教授っちだったというのも目を引いた理由の一つだったが、とりわけ、aarch64 の勉強をしたいと最近思っていたのが大きな理由だった。

読み進め方

自作本は薄目で読むのが良い。言い換えると、自作本は短編クイズ集のようなものだと思って読んでいる。
各章ごとにまず導入があり、実現したい事とそのモチベーションが語られる。次いでその実現方法の概要が説明され、そこから実際のコードスニペットが並び、それぞれにコードの意図が説明される。最後にそれらを組み合わせて動く (もしくは動かない) ものが完成するというのが1つの章の流れだ。自作本を読み進める場合、実際のコード片パートに入ったらそこで一旦本を読むのを止め、手を動かしてみるのが面白い。自分ならばどうやって実装するかを考えて書いてみる。当然そのままでは知識が足りないので参考文献 (CPU やデバイスの仕様書) と睨めっこする。うまいやり方が分からない場合も当然多いので、そこで初めて本の説明や公開されているソースコードを見てみる。答え合わせだ。本のうまいやり方に納得することもあれば、自分のやり方の方が良いこともある。いずれにせよ、ただ本を写経するよりもクイズの答えを自分で考えて読み進めていったほうが面白い。一気に本に書いてある内容を正視してしまわないように読むという意味で、薄目で読むと呼んでいる。
その観点で、実装する言語は本で使われているものとは違うものを使うのが望ましい。同じ言語を使ってしまうと、"正解" に囚われすぎてしまう。自分が書いたコードが少しでも本の実装と違う場合には書き直さなければいけないという強迫観念が生じてしまう。また、同じ言語だと本の実装のコピペができてしまい、コピペの誘惑と常に闘う面倒が生じてしまう。他の言語であればコピペはできないし、実装の仕方が多少違っていても言語が違うのだから当然だという言い訳ができる。幸いにも現代にはシステムプログラムが書ける言語がいくつもあり、大抵の人は C / Rust / Zig は読めるだろうから、本で使われている言語と異なる言語を使うのは容易だ (稀に C++ という言語が使われている場合もある)。

というわけで、自分は以下のような楽しみ方で読み進めた:

  • Zig に移植する。本書では Rust が使われているため、Zig に移植する中で Rust/Zig それぞれの良いところを再発見する面白さがある。
  • Aarch64 の勉強をする。とりわけ x64 の仮想化機構 (VT-x) との違いを見比べながら楽しむ。
  • 「Hypervisor の本」 という観点で読む。Writing Hypervisor in Zig を書く時に考えていたことや、その構成と比較してその背景や意図を想像しながら楽しむ。

Zig への移植

Rust から Zig への移植は特に苦労したことはなく、寧ろ Zig の良さや知らなかった機能を再発見できた良い機会だった。完成したコード Hugin はこちら

Tagged Union

Zig では union 型の各要素にタグをつけて Tagged Union にすることができる。これにより、active なフィールドごとに switch で分岐することが可能になる:

const U = union(enum) {
    a: i32,
    b: bool,
};

switch (u) {
    .a => |v| std.debug.print("i32: {d}\n", .{v}),
    .b => |v| std.debug.print("bool: {d}\n", .{@intFromBool(v)}),
}

また、enum なので当然これに enum value を持たせることも可能だ。これにより、以下のように PSCI の関数呼び出しを実装することができる:

/// SMC64 function IDs.
const Func = union(enum(u64)) {
    /// Return the version of PSCI implemented.
    psci_version: struct {
        /// Not used.
        x1: u64 = 0,
        /// Not used.
        x2: u64 = 0,
        /// Not used.
        x3: u64 = 0,
    } = 0x8400_0000,

    /// Power up a core.
    cpu_on: struct {
        /// Target CPU ID.
        ///
        /// Contains a copy of the affinity fields of the MPIDR register.
        x1: u64,
        /// Entry point address.
        x2: u64,
        /// Argument to pass to the entry point.
        x3: u64,
    } = 0xC400_0003,
};

fn psci(func: Func) Error!u64 {
    const ret = switch (func) {
        inline else => |v| am.smc(@intFromEnum(func), v.x1, v.x2, v.x3),
    };
    ...
}

PSCI の各種機能は SMC 経由で SMCCC と呼ばれる呼び出し規約に沿って呼び出す。この時、X0 には PSCI function number を、X1, X2, X3 には引数を入れるようになっている。他の言語であれば関数番号とその引数を別々に定義することになると思うが、Zig では Tagged Union を使って関数番号とその引数をまとめて定義することができる。また、上の例では enum の各要素は中身が同じ型として定義されているが、必要であれば u64 ではなく u32 でも *const fn (void) にすることもできる (実際に SMC をする psci() 側での型変換は必要になるが) 。関数番号とそれに紐付く引数型をまとめて管理できるのは嬉しい。

System Registers

x64 では MSR (Model-Specific Registers) と呼ばれるレジスタ群は、aarch64 では System Registers と呼ばれる。PE ごとに存在するこのレジスタ群は各種機能の設定や状態の読み取りを可能にしている。x64 では MSR への読み書きは RDMSR / WRMSR という命令で行う。これらの命令では ECX に MSR に対応するアドレスを入れて指定する。これらのレジスタアドレスは Intel SDM の Volume 4 に列挙されている。aarch64 では MRS / MSR という命令が存在するが、ここではレジスタアドレスではなくレジスタ名を指定する。TPIDR_EL0 というレジスタを読む場合には mrs tpidr_el0, x0 のようになる。
レジスタ名を指定するため、これを Zig で実現するには以下の2つの方法が思いつく。1つ目が、ちゃんとシステムレジスタをエンコードする関数を作ってやること。MRS では 15bit でシステムレジスタを指定している。これらの エンコーディング一覧 をもとに目的のレジスタのエンコード値を定義し、さらに MRS/MSR 命令をバイナリで書いてあげるようにすれば最もプリミティブに目的を達成できる。もちろん、めんどくさい。Hugin では2つめの方法を取った。まず以下のようにシステムレジスタに対応する enum を定義する:

pub const SystemReg = enum {
    currentel,
    tpidr_el0,
    tpidr_el1,
    ...
};

このとき、タグ名はアセンブリにおけるシステムレジスタ名と一致するようにする。こうすることで、レジスタの文字列表現は以下のようなラッパ関数で取得できる:

pub fn str(comptime self: SystemReg) []const u8 {
    return @tagName(self);
}

また、enum からそのレジスタに対応する型を取得するための関数も用意する:

pub fn Type(comptime self: SystemReg) type {
    return switch (self) {
        .currentel => CurrentEl,
        .tpidr_el0, .tpidr_el1, .tpidr_el2, .tpidr_el3 => Tpidr,
    };
}

これらを使って、MRS 命令は以下のように書ける:

pub fn mrs(comptime reg: SystemReg) reg.Type() {
    return @bitCast(asm volatile (std.fmt.comptimePrint(
            \\mrs %[ret], {s}
        , .{reg.str()})
        : [ret] "=r" (-> switch (@sizeOf(reg.Type())) {
            4 => u32,
            8 => u64,
            else => @compileError("Unsupported system register size."),
          }),
    ));
}

Zig では引数が comptime である場合、その引数を引数に取る comptime 関数を使って返り値の型を記述することができる。今回は先程定義して Type() 関数を使って MRS の返り値に方をもたせている。また、コンパイル時に文字列フォーマットをするためのstd.fmt.comptimePrint() と先程の str() 関数を組み合わせることで、アセンブリ命令にシステムレジスタのシンボル名を埋め込んでいる。これにより、以下のような直感的でかつ型を持ったレジスタ値取得が可能になる:

var tpidr_el0 = mrs(.tpidr_el0);
tpidr_el0.tid = 0xDEAD_BEEF;
msr(.tpidr_el0, tpidr_el0);

なお、MSR の場合も同様に書ける。comptime な引数は別の引数の型としても使うことができる:

pub fn msr(comptime reg: SystemReg, value: reg.Type()) void {...}

なお、それなりに関数呼び出しが多くて複雑に見える mrs() 関数だが、やっていることは MRS 命令を除いて comptime なので Release ビルドでは1命令に置き換わる。Debug ビルドの場合はインライン展開されないため、stack 操作 + ret を合わせて計5命令になる (comptime 引数を持つ関数はテンプレートと同様に引数の種類だけ関数が生成されるため、その分 text サイズも増える)。

ビルドスクリプト

本書ではビルドにおいて以下のステップがある:

  • Hypervisor のビルド
  • Devicetree のビルド
  • BIOS (u-boot) などの artifact のコピー
  • u-boot スクリプトのビルド
  • ゲスト用ファイルシステムイメージのビルド
  • QEMU の実行

Zig ではこれらのステップを全て build.zig の中に記述することができる。Zig のビルドスクリプト は DAG を生成するために Zig を利用する。例えば Devicetree ファイル .dts から DTB ファイルを生成するステップは以下のように書ける:

const compile_dtb = blk: {
    const command = b.addSystemCommand(&[_][]const u8{
        "dtc",
        "-I", "dts",
        "-O", "dtb",
        "-o", b.fmt("{s}/{s}/{s}", .{ b.install_path, vfatdir_name, "DTB" }),
        "assets/virt.dts",
    });

    break :blk command;
};

ディスクイメージを作るには、Hypervisor 自体のビルドに加え、この DTB のビルド等も必要になる。この依存関係は以下のように記述する:

    {
        const create_fat32 = blk: {
            const command = b.addSystemCommand(&[_][]const u8{
                "scripts/create_disk.bash",
                b.fmt("{s}/{s}", .{ b.install_path, vfatdir_name }), // copy source
                b.fmt("{s}/{s}", .{ b.install_prefix, diskimg_name }), // output image
            });
            command.step.dependOn(&compile_scr.step);
            command.step.dependOn(&compile_dtb.step);
            command.step.dependOn(&install_hugin.step);
            command.step.dependOn(&install_guest.step);
            command.step.dependOn(&install_guestdisk.step);

            break :blk command;
        };
        b.getInstallStep().dependOn(&create_fat32.step);
    }

command.step.dependOn() で、FAT32 ディスクイメージの生成 (create_fat32) に必要な依存を指定している。ここには Hypervisor のビルド (install_hugin) もあれば、先程の DTB のビルド (compile_dtb) もある。その後、Zig のビルドスクリプトのトップレベルのステップは install ステップであり、最後に install ステップに FAT32 ディスクイメージを指定してやっている。これにより、install コマンドを実行すると create_fat32 ステップが実行されるようになり、それが依存する諸々のステップも実行される:

Build Summary: 12/12 steps succeeded
run success
└─ run /home/wataru/qemu-aarch64/bin/qemu-system-aarch64 success 4s MaxRSS:158M
   └─ install success
      ├─ install hugin success
      │  └─ compile exe hugin Debug aarch64-freestanding-none cached 8ms MaxRSS:39M
      │     ├─ options cached
      │     └─ options (reused)
      └─ run scripts/create_disk.bash success 315ms MaxRSS:3M
         ├─ run scripts/build_scr.sh success 5ms MaxRSS:3M
         │  └─ install generated to disk/hugin success
         │     └─ compile exe hugin Debug aarch64-freestanding-none (+2 more reused dependencies)
         ├─ run dtc success 629us MaxRSS:2M
         │  └─ install generated to disk/hugin (+1 more reused dependencies)
         ├─ install generated to disk/hugin (+1 more reused dependencies)
         ├─ install Image to disk/Image success
         └─ install DISK0 to disk/DISK0 success

なお、独立したステップはちゃんと並列に実行してくれるようになっている。ここでは Hypervisor のビルドや DTB のビルドや SCR のビルドは依存がないため並列に行われる。
最初はやや癖のある書き方のような気もするが、オプションの値等に応じて柔軟にビルドスクリプトを書くことができるのが良いところだ。自分は実行時に Git SHA の値を読み取ってそれをビルド対象の実行ファイルにオプションとして渡すということもよくやる。

x64 と aarch64 の比較

ここでは、Type-1 Hypervisor を自作するにあたって感じた x64 と aarch64 の違いを比較する。前提として、x64 では仮想化を実現するために Ring Level とは別の VMX Root/Non-root Operation という権限軸がある。VMX Root & Ring-0 が hypervisor、VMX Non-root & Ring-0 がゲストカーネル、VMX Non-root & Ring-3 がゲストアプリといった感じだ。必要であれば VMX Root & Ring-3 を実装することもできる。対して、aarch64 では最初から Exception Level に hypervisor 用の EL2 が存在する。形骸化した x64 の Ring Level とは異なり、EL3-0 にはそれぞれ secure monitor / hypervisor / kernel / application という役割が存在する。1つの権限軸で hypervisor を実現することができるようになっている。(なお、その他には TrustZone による Security State という軸も存在し、これを考慮すると EL3 / EL2 / EL1N / EL1S / EL0N / EL1S が存在する。自分は知らなかったが、 オプションで EL2S もあるらしい。)
また、そもそも aarch64 初心者として初めて知った hypervisor に関係のない違いも書いていく。

ゲストへの遷移がとても簡単

とにかくこれに尽きる。x64 VT-x では hypervisor は VMCS と呼ばれる専用の領域に仮想化に必要な情報を詰め込む必要がある。この VMCS は6つのカテゴリからなるレジスタ群であり、VMENTRY / VMLAUNCH 時にその正当性がチェックされる。もしも不正な値が入っていた場合にはゲストが意図したとおりに動かない以前に、そもそもゲストを実行する (VMX Non-root Operation に入る) ことができない。このチェック項目は SDM Vol.3C CHAPTER 27. VMENTRIES によると 100 項目以上存在する。リセット値そのままでは駄目なため、これら一つ一つの面倒を見てやる必要がある。おまけに、ゲストに入る以前に失敗した場合には失敗した理由は一切明かされず、ただエラー番号7番 VM entry with invalid control fields が返されるだけである。自分はこのエラーの原因を突き止めるためにこれらのチェック項目を確認する地獄コード を書いた。それくらい、x64 ではゲストで1命令実行するまでが難しい。
対して、aarch64 では hypervisor がそもそも EL の中に入っている。よって、通常の EL1→EL0 遷移のように ERET するだけでゲストに遷移することができる。最小限に必要なのは、HCR_EL2 で仮想化を有効にすることだけだ。地獄のように複雑な VMCS も存在しない。

例外テーブルは関数テーブルではなくコード

x64 の割り込みテーブルは関数テーブルだ。IDT レジスタにこのテーブルのアドレスを入れておくと、例外の発生時にベクタ番号に対応する関数が呼ばれるようになっている。対して、aarch64 では 割り込みテーブルにコードがそのまま入っている 。割り込みのタイプ (4種類: 例外・IRQ・FIQ・SError) と割り込みが発生したときの EL とスタック (4種類: 同EL & SP_EL0・同 EL & SPL_ELx・低EL & Aarch64・低EL & Aarch32) によって異なる16ブロックのコードが存在する。各コードは 0x80 bytes であり、1命令4bytes だから最大32命令までしか入らない。
当然32命令では収まらないので service routine だけ書いてあとは専用のハンドラにジャンプするというのはどちらも同じになるだろう。x64 では例外発生時にスタックにエラーコードが勝手に載せられる (ことがある)。ベクタ番号は例外発生以降は取得する手段は存在しない (そのため、ベクタ番号ごとに異なる ISR を生成して自分の番号をスタックに積むようにしてあげる必要がある)。一方、aarch64 では例外に関する情報は ESR_ELx に入っていて自由に取り出すことができる。例外の場合にはここからベクタを取り出すことができるし、割り込みの場合には ICC_IAR1_ELx からベクタを取り出すことができるようになっている。

ページングと Unaligned Access

ページングに関してはそこまで大きな差異はなかったものの、用語が若干ややこしかった。x64 では level 1 から最大 level 5 までの変換が行われるが、aarch64 では level -1 から level 3 までの変換が行われる。開始レベルをどこにするかは物理アドレスや仮想アドレスのビット幅から決定することになり、4段階なら level 0 から始めることになる。この辺の仮想アドレスのビット幅の決定等はやや複雑だった。
なお、本書では EL2 のページテーブルは u-boot が用意してくれたものをそのまま使っている。また、BSP 以外のコアについてはそもそもページングを利用していない。おそらく Rust 実装ではそのままでも問題ないが、Zig で書いた場合には困ることがあった。Aarch64 ではメモリアクセスする際にアドレスがレジスタ幅にアラインされている必要が必ずしも無い。例えば mov x0, <addr> をする場合に、addr が 8bytes align されていなくても良い場合がある。これは、SCTLR_ELx の A bit がアンセットされている場合に限る。逆に言うと、このビットがセットされていれば unaligned cache 時に Data Abort (Alignment Exception) が発生する。そして、この A bit は MMU が有効化されている場合にしか無効化できない。今回は ARM v8-a としてビルドしていたのだが、Zig のコンパイラは unaligned access ができることを前提としたコードを生成するようで、BSP 以外で unaligned access した場合に例外が発生してしまっていた。そのため、BSP のページテーブルの設定をコピーして AP に設定することで MMU を有効化し、A bit をアンセットすることにした。

例外は4種類

例外って、4種類あんねん。アンミカはそう言った。x64 では割り込みと例外の2つ。意識する必要のある種類ごとに分けるならば Fault / Trap / Abort の3種類だろうか (復帰可能性・復帰後の開始アドレスが異なる)。Aarch64 では SError / IRQ / FIQ / Synchronous Exception が存在する。IRQ / FIQ が割り込みで、Synchronous Exception が例外に該当する。SError については全く知らない。x64 の Abort に該当するもの (#MC / #DF) なのかな?前述したように、これらの4つの例外は異なる ISR から開始する。本書では IRQ と Sync しか扱わなかったため、IRQ / FIQ の差分については今後調べてみたい。
割り込みのルーティングは GIC Distributor / Redistributor によって行われる。このへんは x64 の Local APIC / IO APIC と同じ感じだった。
仮想割り込みの注入は Virtual GIC というものを使ったが、これは x64 の VMCS に存在する VM-Entry Interruption-Information と大体同じだと感じた。

本としての感想

以下は、作った hypervisor 自体や aarch64 というよりも、hypervisor の本として楽しんだ時の感想。

アロケータはめんどくさい

自作本で基本的に避けるべきと思われるのが、「筆者が用意した ooo を使います」という3分クッキング方式だ。自作の醍醐味である全て理解して先に進むというコンセプトが崩れてしまう。とは言っても、それが必要な場面も存在する。ゼロからのOS自作入門 でいうところの xHCI ドライバのような場合だ (みかん本の筆者は別の書籍で xHCI ドライバの本も出しているため、自分はそれを眺めながら書いた)。本書では3分クッキング方式が使われたのはたった1回だけであり、それがメモリアロケータだった。アロケータは面倒くさい割にテストが面倒なので、省いて正解だった気がする。Zig に移植するにあたっては、筆者と同様に以前自分が書いた Ymir という hypervisor のアロケータをそのまま持ってくる ことにした。Buddy Allocator と Bin Allocator を別々に実装している。ただし、本書ではゲストに対してホスト上の連続した仮想アドレス領域をマップしており、Buddy Allocator の最大連続ページサイズは結構大きくする必要があった (256MiB の場合は、log(256MiB / 4KiB, 2) ≒ 16)。

良い感じに適当に実装してくれている

本書の目的は Linux を複数コアでブートすることであり、それ以外は"良い感じに"力を抜いて実装してくれている。例えば DTB のパースについてもちゃんとした実装からは遠いが、目的達成のために必要な部分だけはパースできるようになっている。Devicetree に触れたのは始めてだったが、このおかげで特に詰まること無く進めることができた。また、FAT32 の実装も必要な部分だけを最小限実装しており、わずか 500 行程度に済んでいる。これらは不必要な障壁を取り除いて全体としての目的達成を用意にしているだけでなく、読み終わった後の読者に手近な改良ポイントを与える効果もあると思っている。

仕様書のページを教えてくれる

上述の良い感じの手抜き実装とも関連するが、本書では「詳しいことは仕様書を見て」という場面が多々ある。その際、仕様書の何ページに書いてありますと都度書いてくれているのが助かった。概して CPU のマニュアルは分厚くて検索するだけでも一苦労なので、ページを教えてくれるのはありがたい。

薄目で読むのに適した構造になっている

最初の方に書いたように、自分は自作本を薄めで読む。薄目で読むためには、本が理想的な構造をしている必要がある。問題提起・実現するために必要な概念の説明・少し具体的な説明・コードとその解説・動かした結果。確証がこのような流れ、もしくはこの流れの繰り返しで構成されていなければ、途中まで自分で読んで・本を閉じて実装して・答え合わせをしてというやり方が取りにくい。本書はその観点で理想的な構造をしていたように思う。また、各章ごとに実行時のログを貼ってくれているのも地味にありがたかった。デバッグをする際に本書のログと比較して違う部分を探してその周辺を Linux のソースや自分のコードを見て重点的にデバッグすることができた。

アウトロが充実

実装が終わった段階で 540 ページ中 480 ページにしかなっていなかった。もう1機能くらい何か付け加えるのかと思ったら、残りの60ページは作った hypervisor の発展・改良案と hypervisor 一般のコラムとなっていた。コラムは普通に読み物として面白かった。

アウトロ

Aarch64 の勉強にもなって面白かったです。明日はファミチキを食べようと思います。


2025.10.13-

GitHubで編集を提案

Discussion