自前のページテーブルへの切り替えと、そこで踏んだ地雷

物理フレームアロケータの実装の記事で、物理メモリをページ単位で貸し出す仕組みができました。今回はページングそのものを自前で構築し、UEFIが用意したページテーブルから切り替えます。

なぜ置き換える必要があるのか

自前のGDTとカーネルスタックへの切り替えの記事で説明したのと同じ理由です。UEFIが用意したページテーブルはloader_databoot_services_dataといったBoot Services由来のメモリ領域に置かれています。pmmはこれらの領域をすでに「割り当て可能」として扱っており、何かを確保すればそこに書き込まれます。ページテーブルが今もそこに残ったままだと、確保したメモリに新しいデータを書き込んだ結果、CPUが今まさに参照しているページテーブルの中身を意図せず壊す、という事態になりかねません。GDTのときと同じ地ならしを、ページテーブルに対しても行う必要があります。

4KBページ・PML4/PDPT/PD/PTの構造

x86-64のロングモードは4段階のテーブルでアドレス変換を行います。仮想アドレスの上位ビットを4つに区切り、各9ビットをそれぞれの段のインデックスとして使います。

// kernel/paging.zig
fn tableIndices(virt: usize) [4]usize {
    return .{
        (virt >> 39) & 0x1FF, // PML4
        (virt >> 30) & 0x1FF, // PDPT
        (virt >> 21) & 0x1FF, // PD
        (virt >> 12) & 0x1FF, // PT
    };
}

各テーブルは512エントリ×8バイトで、ちょうど4KB=1ページに収まります。テーブル自体もpmmから確保した1ページとして扱えるため、pmmとの相性は良好です。

エントリの構造は、GDTのセグメントディスクリプタと同じくpacked structで表現しています。

// kernel/paging.zig
const PageTableEntry = packed struct(u64) {
    present: bool = false,
    writable: bool = false,
    user: bool = false,
    write_through: bool = false,
    cache_disable: bool = false,
    accessed: bool = false,
    dirty: bool = false,
    page_size: bool = false,
    global: bool = false,
    _available1: u3 = 0,
    address: u40 = 0,
    _available2: u11 = 0,
    no_execute: bool = false,
};

addressフィールドは物理アドレスのbit 12〜51に対応します。フレームは常に4KBアラインされているため、下位12bitは常に0であることが保証されており、実際に格納する際は物理アドレス >> 12として詰め込みます。

恒等マッピングを維持する設計

新しいページテーブルでも、仮想アドレスと物理アドレスを一致させる恒等マッピングをそのまま踏襲しています。フレームバッファへの直接書き込みも、pmmのビットマップへのアクセスも、これまで一貫して物理アドレスをそのままポインタとして扱ってきたコードだからです。CR3を切り替えた瞬間にこれらのアドレスの意味が変わってしまうと、既存のコードを全て書き換える必要が出てきます。仮想アドレス空間を本格的に設計し直す(カーネルを higher-half に配置するなど)のは、まだ先の課題として残しています。

もう1点、フレームバッファ用に個別のマッピングが必要でした。GOPフレームバッファの物理アドレスは、UEFIメモリマップが記述する通常のRAM領域の外(PCIのBARに割り当てられたMMIO領域)にあるため、RAM分の恒等マッピングだけでは範囲に含まれません。これを見落とすと、CR3切り替え直後の最初のフレームバッファ書き込みで、あとで説明する#PFが発生します。

// kernel/paging.zig(抜粋)
identityMapRange(pml4, 0, @intCast(highest_address));
identityMapRange(pml4, fb.base, fb.size);

asm volatile ("mov %[pml4], %%cr3"
    :
    : [pml4] "r" (@intFromPtr(pml4)),
    : .{ .memory = true });

検証中に発生した予期しないクラッシュ

ここまでは実装した通りに動くはずでしたが、実際にQEMUで起動すると、意図していない例外(後述する調査で#UDと判明)が発生しました。以下は、原因を突き止めるまでの記録です。

現象:フレームバッファ色エンコードが「起きるはずのないタイミング」で反応する

IDTを構築してCPU例外を捕捉するの記事で使った、例外のvectorをフレームバッファの色に変換して読み取る手法で確認したところ、vector 6(#UD:不正命令)が、何もテストコードを仕込んでいない通常の起動シーケンスの中で発生していました。

調査:フォールト位置の特定

InterruptFrameが持つrip(フォールト発生時の命令アドレス)を同じ手法で読み出し、QEMUで実際のメモリ内容を逆アセンブルして確認したところ、該当アドレスにはud2命令がそのまま置かれていました。ud2は「絶対に実行されてはいけない」ことを示すためにコンパイラが明示的に埋め込む命令で、Zigの言語レベルの安全性チェック(unreachableや範囲外アクセスなど)が失敗した際のトラップとして使われます。

つまり、CPU自体の例外ではなく、Zig自身のランタイムチェックが何かに引っかかってpanicを起こしていることが分かりました。

さらなる原因:panicハンドラ自体が機能していなかった

Zigの標準ライブラリには、UEFIターゲット向けのデフォルトpanicハンドラ(std.debug.defaultPanic)が用意されています。

// std/debug.zig(抜粋、Zig 0.16.0)
.uefi => {
    const uefi = std.os.uefi;
    ...
    inline for ([_]?*uefi.protocol.SimpleTextOutput{ uefi.system_table.std_err, uefi.system_table.con_out }) |o| {
        ...
    }

このデフォルト実装は、uefi.system_table.con_outを経由してエラーメッセージを出力しようとします。しかしkmain()が動いている時点はExitBootServicesをとうに過ぎており、System Table経由のプロトコル呼び出しは無効です。それどころか、System Tableが置かれていたメモリ領域自体、pmmがすでに「割り当て可能」とみなしている可能性があります。壊れている(かもしれない)ポインタ経由の呼び出しが、さらに別の異常終了を引き起こし、本来の原因が何であったかを覆い隠していました。

対応:カーネル自身のpanicハンドラを用意する

Zigは、実行ファイルのルートモジュールにpub const panicを宣言することで、このデフォルト実装を上書きできます。フレームバッファへの色表示という、このプロジェクトですでに確立している手段を使い、パニックメッセージの先頭3バイトをそのままRGB各チャンネルに埋め込む方式にしました。

// kernel/panic.zig
pub fn onSafetyPanic(msg: []const u8, first_trace_addr: ?usize) noreturn {
    _ = first_trace_addr;
    const b0: u32 = if (msg.len > 0) msg[0] else 0;
    const b1: u32 = if (msg.len > 1) msg[1] else 0;
    const b2: u32 = if (msg.len > 2) msg[2] else 0;
    fill((b0 << 16) | (b1 << 8) | b2);
    arch.interrupts.halt();
}
// boot/main.zig
pub const panic = std.debug.FullPanic(kernel.panic.onSafetyPanic);

これを組み込んでQEMUで再現したところ、色は(99, 97, 115)でした。ASCIIコードに変換すると"cas"となり、Zigの組み込みメッセージ"cast causes pointer to be null"(非オプショナルなポインタ型に対して、変換結果がnullになるキャストを行った)の先頭に一致します。

根本原因:物理アドレス0を有効なフレームとして貸し出していた

kernel/paging.zigのテーブル参照処理は、エントリに記録された物理アドレスを@ptrFromIntでそのままポインタに変換します。

// kernel/paging.zig
fn tableAt(entry: PageTableEntry) *Table {
    return @ptrFromInt(@as(usize, entry.address) << 12);
}

pmm.alloc()は、空いている中で最も若い物理アドレスから順に貸し出します。物理アドレス0のページがたまたま使用可能な領域として報告されていた場合、最初のpmm.alloc()呼び出しでアドレス0がそのまま返ってきます。ページテーブルの1エントリがこのアドレス0を指すことになり、tableAtがそれを*Table(非オプショナルなポインタ型)へ変換しようとした瞬間、Zigのランタイムチェックが「アドレス0はnullとして扱われるべきで、非オプショナルなポインタ型には代入できない」と判断してpanicを起こしていました。

対応:物理アドレス0を予約する

pmm側で、アドレス0を意図的に割り当て対象から外すよう修正しました。

// kernel/pmm.zig
// Physical page 0 is often reported as conventional (usable) memory,
// but handing it out is dangerous: any consumer that stores a physical
// address and later reads it back through a non-optional pointer (as
// kernel/paging.zig does for page-table entries) can't distinguish
// "points at frame 0" from "was never set" -- @ptrFromInt(0) panics for
// non-optional pointer types precisely because of that ambiguity.
if (isFree(0)) {
    setUsed(0);
    free_page_count -= 1;
}

物理アドレス0を「未設定」を表す特別な値として扱いたい場面は、ページテーブル以外でも今後出てくる可能性が高く、アロケータ側で最初から除外しておくのが妥当と判断しました。

QEMUでの最終確認

修正後、通常起動では例外が発生しないこと(フレームバッファが正常な色で塗りつぶされること)をまず確認しました。そのうえで、意図的にマッピングしていないアドレスへ書き込むコードを一時的に追加し、#PF(ページフォルト、vector 14)が発生してpanic表示(緑)になることを確認しています。

// 検証用に一時的に追加したコード(確認後に削除)
const bogus: *volatile u8 = @ptrFromInt(0x7fff_ffff_f000);
bogus.* = 1;

これにより、単に「クラッシュしなくなった」だけでなく、自前のページテーブルが実際にCPUの参照先として使われ、マッピングしていない領域を正しく拒否していることまで確認できています。

今後

今後はヒープアロケータを実装し、pmmが貸し出す4KiB単位のページを、もっと小さい任意サイズの確保に切り分けられるようにします。

参考リンク