自前のGDTとカーネルスタックへの切り替え

ExitBootServicesと自作カーネルへのハンドオフの記事で、ExitBootServicesを呼んで自作カーネルへ処理を引き継ぐところまで進みました。ただし、その時点ではまだ2つのものをUEFIファームウェアから借りたままになっていました。GDT(Global Descriptor Table)と、実行中のスタックです。今回はこの2つを、カーネル自身が所有するものに置き換えます。

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

ExitBootServicesは「Boot Servicesの関数が呼べなくなる」だけで、それまでファームウェアが使っていたメモリ領域そのものが消えるわけではありません。UEFIが用意したGDTも、main()が動いていたスタックも、物理メモリ上にはそのまま残り続けます。

問題は、これから実装していく物理フレームアロケータ(Phase 6)が、こうしたloader_databoot_services_dataといった種別のメモリ領域を「空いている」とみなして再利用し始めることです。カーネルが今もGDTやスタックとしてそのメモリを使い続けていると、そこに新しいデータが割り当てられて上書きされ、原因の分かりにくいクラッシュにつながります。今回の作業は、この後の作業のための地ならしという位置づけです。

GDT:ビットフィールドとして定義する

GDTの各エントリ(ディスクリプタ)は8バイトの中に、セグメントの開始アドレス(base)・サイズ(limit)・権限を表す複数のビットフィールドが詰め込まれた構造をしています。生の16進数定数として書くこともできますが、それぞれのビットが何を意味するか読み取れなくなるため、Zigのpacked structでフィールドごとに名前を付けています。

// arch/gdt.zig
const SegmentDescriptor = packed struct(u64) {
    limit_low: u16 = 0,
    base_low: u16 = 0,
    base_mid: u8 = 0,
    accessed: bool = false,
    read_write: bool = false,
    conforming_expand_down: bool = false,
    executable: bool = false,
    descriptor_type: bool = false, // S bit: true = code/data, false = system
    privilege_level: u2 = 0,
    present: bool = false,
    limit_high: u4 = 0,
    available: bool = false,
    long_mode: bool = false,
    size_32bit: bool = false, // D/B bit; must stay false when long_mode is set
    granularity: bool = false,
    base_high: u8 = 0,
};

ロングモードでは、コード・データセグメントのbase/limitはCPUに無視されるため、両方とも0のままにしています。実際に効くのは権限まわりのビットだけです。null・コード・データの3エントリを用意しました。

const null_descriptor = SegmentDescriptor{};

const code_descriptor = SegmentDescriptor{
    .read_write = true,
    .executable = true,
    .descriptor_type = true,
    .present = true,
    .long_mode = true,
};

const data_descriptor = SegmentDescriptor{
    .read_write = true,
    .descriptor_type = true,
    .present = true,
};

null_descriptorはすべてのフィールドがデフォルト値(false/0)のままなので、結果として全ビット0のnullディスクリプタになります。

読み込みはlgdt命令で行い、続けてCSセグメントレジスタを新しいコードセグメントに切り替えます。CSは通常のmovでは書き換えられないレジスタで、ファージャンプかファーリターンを使う必要があります。

asm volatile (
    \\ pushq $0x08
    \\ lea 1f(%%rip), %%rax
    \\ pushq %%rax
    \\ lretq
    \\ 1:
    \\ mov $0x10, %%ax
    \\ mov %%ax, %%ds
    \\ mov %%ax, %%es
    \\ mov %%ax, %%ss
    \\ mov %%ax, %%fs
    \\ mov %%ax, %%gs
    :
    :
    : .{ .rax = true, .memory = true });

新しいコードセグメントセレクタ(0x08)と、次に実行すべき命令のアドレス(1fというラベル、%rip相対で取得)をスタックに積み、lretq(ファーリターン)でその両方を一度に読み込ませています。retqが「戻り先アドレスだけ」をスタックから取り出すのに対し、lretqは「セグメントセレクタとアドレスの組」を取り出す点が違いです。ジャンプ先に何も呼び出し元がないのにリターン命令を使うのは奇妙に見えますが、CSを書き換える唯一の手段としてOSDevでは定番の手法です。

カーネルスタック:静的に確保して切り替える

スタックの切り替えは、カーネルバイナリ自身の中に固定サイズの領域を確保し、rspをそこに向けるだけです。

// arch/stack.zig
var kernel_stack: [64 * 1024]u8 align(16) = undefined;

pub fn top() usize {
    return @intFromPtr(&kernel_stack) + kernel_stack.len;
}

この切り替えはboot/main.zig側、kernel.kmain()を呼び出す直前で行っています。

const new_rsp = arch.stack.top();
asm volatile ("mov %[new_rsp], %%rsp"
    :
    : [new_rsp] "r" (new_rsp),
    : .{ .memory = true });

kernel.kmain(info_ptr);

切り替えのタイミングは慎重に選んでいます。関数の途中でrspを書き換えると、その関数がそれ以降に参照するローカル変数(それまで古いスタック上に置かれていたもの)が正しく扱えなくなる可能性があります。そこで、rsp書き換えの直後にはkernel.kmain()の呼び出し以外何も行わない構成にしました。渡すinfo_ptrBootInfoへのポインタ)自体は、書き換えより前に計算済みの値であり、指している先も単なるメモリ上のデータなので、スタック切り替え後にそのポインタを経由して読むこと自体には問題がありません。

Zigのモジュール分割の壁

この実装をarch/kernel/boot/にまたがる形で書こうとしたところ、Zig 0.16ではモジュールのルートディレクトリの外へ相対パス(../)で@importできないという制約に当たりました。boot/main.zigから../arch/stack.zigのような形で直接読み込むことができません。

対応として、build.zig側でarchを独立したZigモジュールとして定義し、kernelモジュールとbootモジュールの両方から依存として追加しています。

// build.zig(抜粋)
const arch_module = b.createModule(.{
    .root_source_file = b.path("arch/arch.zig"),
    .target = uefi_target,
    .optimize = optimize,
});

kernel_module.addImport("arch", arch_module);
boot_module.addImport("arch", arch_module);

arch/arch.ziggdtstackをまとめて再エクスポートするだけの薄いモジュールルートです。ExitBootServicesと自作カーネルへのハンドオフの記事kernel/kernel.zigにてboot_infoを再エクスポートしたのと同じパターンです。

QEMUでの確認:画面ではなくレジスタで見る

今回の変更は、画面上はExitBootServicesと自作カーネルへのハンドオフの記事と同じ「赤一色」にしかならず、目視では区別がつきません。そこでQEMU上でCPUレジスタの状態を直接確認したところ、CS0x08(今回定義したコードセグメント)、SS/DS/ES/FS/GS0x10(データセグメント)になっており、CS64のフラグも付いていました。GDTの limit は0x17(10進で23)で、これは「3エントリ × 8バイト − 1」に正確に一致します。OVMF自身が最初から持っているGDTはこれより多くのエントリを持つのが通常なので、この値がぴったり3エントリ分であることは、ファームウェアのGDTではなくこちらが用意したGDTに実際に切り替わっている根拠になります。

今後の予定

この後はIDT(Interrupt Descriptor Table)を用意し、CPU例外(ゼロ除算やページフォルトなど)をハンドリングできるようにします。

参考リンク