IDTを構築してCPU例外を捕捉する

自前のGDTとカーネルスタックへの切り替えの記事で、自前のGDTとカーネルスタックへの切り替えを行いました。IDT(Interrupt Descriptor Table)を構築し、ゼロ除算やページフォルトといったCPU例外が発生したときにカーネル側でハンドリングできるようにします。あわせて、テキスト描画がまだ無い現時点での代替として、フレームバッファの塗りつぶしによるpanic表示も実装します。

IDTとは

IDTは、CPUが割り込みや例外に遭遇したときに「どこへジャンプすればよいか」を引くための表です。レガシーBIOS時代のリアルモードには、メモリアドレス0x0000から256エントリ、各エントリが単純な「segment:offset」の4バイトポインタだけというIVT(Interrupt Vector Table)がありましたが、IDTはその後継にあたります。ロングモードのIDTも同じく256エントリですが、1エントリは16バイトに拡張されており、単なるジャンプ先アドレスだけでなく、どのコードセグメントセレクタを使うか、どの特権レベルで実行するか、といった情報まで含む構造体になっています。

CPUは何らかの割り込み・例外が起きるたびに「割り込み/例外番号(vector)」を1つ決定し、IDTの該当エントリを引いてジャンプします。vector 0〜31はIntelがCPU例外用に予約している範囲で、たとえばゼロ除算は0、ページフォルトは14、General Protection Faultは13です。vector 32以降はハードウェア割り込み(IRQ)やソフトウェア割り込み用に空いていますが、ここではvector 0〜31のCPU例外だけを埋めます。

ゲートディスクリプタの構造

IDTの各エントリ(ゲートディスクリプタ)は、自前のGDTとカーネルスタックへの切り替えの記事のGDTのセグメントディスクリプタと同様にpacked structで定義しました。

// arch/idt.zig
const GateDescriptor = packed struct(u128) {
    offset_low: u16 = 0,
    selector: u16 = 0,
    ist: u3 = 0,
    reserved0: u5 = 0,
    gate_type: u4 = 0,
    zero: u1 = 0,
    dpl: u2 = 0,
    present: bool = false,
    offset_mid: u16 = 0,
    offset_high: u32 = 0,
    reserved1: u32 = 0,
};

ハンドラのアドレス(offset)がoffset_low / offset_mid / offset_highの3つに分割されているのは、32ビット時代からの拡張の歴史的経緯によるもので、意味自体は単純です。1つの64ビットアドレスを分割して詰め直しているだけです。

selectorには、自前のGDTとカーネルスタックへの切り替えの記事で定義したコードセグメントセレクタ(arch.gdt.code_selector0x08)を指定します。dpl(Descriptor Privilege Level、特権レベル)は、この割り込みゲートをint命令で明示的に呼び出せる最低の特権レベルを表します。CPU自身が発生させる例外はこの制限を受けないため、全エントリで0のままにしています。

gate_typeには割り込みゲート(0xE)とトラップゲート(0xF)の2種類があり、違いはハンドラに入る瞬間にIFフラグ(割り込み許可フラグ)を自動的にクリアするかどうかです。割り込みゲートはクリアし、トラップゲートはクリアしません。ここで実装しているのは致命的な例外を捕まえてそのまま停止させる処理で、ハンドラの実行中にさらに別の割り込みが割り込んでくる事態を避けたいため、割り込みゲートを選びました。

エラーコードの有無を吸収して統一フレームを作る

CPUは例外の種類によって、ハンドラへジャンプする直前にエラーコードをスタックへ積む場合と積まない場合があります。エラーコードが積まれるのはPage Fault(14)やGeneral Protection Fault(13)など一部の例外だけです。

// arch/idt.zig
fn hasErrorCode(comptime vector: u8) bool {
    return switch (vector) {
        8, 10, 11, 12, 13, 14, 17 => true,
        else => false,
    };
}

このままではvectorごとにスタックの並びが変わってしまうため、エラーコードが積まれない場合はハンドラ側で0をダミーとして積み、常に同じ並びになるようにしています。

// arch/idt.zig
fn Stub(comptime vector: u8) type {
    return struct {
        fn handler() callconv(.naked) noreturn {
            if (comptime !hasErrorCode(vector)) {
                asm volatile ("pushq $0");
            }
            asm volatile (std.fmt.comptimePrint("pushq ${d}", .{vector}));
            asm volatile ("jmp isrCommonStub");
        }
    };
}

vectorごとに別々のスタブ関数が必要なのは、CPUがハンドラのアドレスへ直接ジャンプするだけで、vector番号自体をどこにも渡してくれないためです。ハンドラ側で「今どのvectorの例外なのか」を知る手段は、このスタブ自身がその値を知っている(=comptimeでvectorごとに専用のスタブを生成する)以外にありません。Stub(vector)はZigのジェネリック関数で、comptime引数ごとに別々の関数実体が生成される性質を利用して、32個のスタブをforループから生成しています。

// arch/idt.zig
pub fn init() void {
    inline for (0..32) |v| {
        setGate(v, @intFromPtr(&Stub(@as(u8, v)).handler), gdt.code_selector, interrupt_gate);
    }
    // ...
    asm volatile ("lidt (%[idtr])" : : [idtr] "r" (&idtr) : .{ .memory = true });
}

GDTをlgdtで読み込ませたのと同様に、IDTはlidt命令でCPUに読み込ませます。

共通ディスパッチとMicrosoft x64 ABI

32個のスタブは、それぞれ「エラーコード(またはダミーの0)」と「vector番号」をスタックに積んだ後、共通のトランポリンへジャンプします。

// arch/idt.zig
export fn isrCommonStub() callconv(.naked) noreturn {
    asm volatile (
        \\ mov %%rsp, %%rcx
        \\ and $-16, %%rsp
        \\ sub $0x20, %%rsp
        \\ call exceptionDispatch
    );
}

export fn exceptionDispatch(frame: *const InterruptFrame) callconv(.c) noreturn {
    exception_handler(frame);
}

ここで引数をrdiではなくrcxに積んでいるのは誤りではありません。UEFIのファームウェア関数を呼び出す区間は、Microsoft x64 ABIに従うことがUEFI仕様そのもので定められています。これはこの先どのOSを起動するかに関係のない制約で、Windowsを起動する場合でもLinuxを起動する場合でも変わりません。

一方、ファームウェアを呼び終えたあと(ExitBootServicesより後)のコードがどのABIに従うかは、UEFI仕様の関与しない領域で、OSごとに個別に決まります。Windowsはもともと自身の内部でもMicrosoft x64 ABIを使っているため、この境界をまたいでも実質的に同じABIが続きます。Linuxは、ExitBootServicesを呼んでカーネル本体に処理が渡った時点で、Linux/Unix系の標準であるSysV ABIへ切り替えます。

exceptionDispatchisrCommonStubkmain()の中、つまりExitBootServicesより後で動くコードです。ここではもうファームウェアを呼んでいないため、実はABIをSysVに切り替えても技術的には成立します。それでもMicrosoft x64 ABIのまま統一しているのは、UEFI側の制約というより、プロジェクト全体でABIを1つに揃えておいた方が単純だという判断によるものです。callconv(.c)で書いた関数は、ビルドターゲットがuefiである限り自動的にMicrosoft x64 ABIとしてコンパイルされるため、あわせてcallの直前でスタックを16バイト境界に揃え、Microsoft x64 ABIが要求する32バイトのシャドウスペース(呼び出し先がレジスタ渡しの引数を退避するための領域)を確保しています。

捕まえる例外はいずれも致命的なものとして扱い、割り込まれた側の処理へ戻ることはしません。そのため、汎用レジスタの退避・復帰やiretqによるリターンは実装していません。InterruptFrameは、スタブが積んだ2ワード(vector、エラーコード)と、CPUが例外発生時に自動的に積む5ワード(RIP、CS、RFLAGS、RSP、SS)をそのまま構造体に対応させたものです。

// arch/idt.zig
pub const InterruptFrame = extern struct {
    vector: u64,
    error_code: u64,
    rip: u64,
    cs: u64,
    rflags: u64,
    rsp: u64,
    ss: u64,
};

panic表示:テキスト描画がまだ無い制約への対応

例外を捕まえたあと、何らかの形で「異常が起きた」ことを示す必要があります。本来ならエラーメッセージを表示したいところですが、フレームバッファへのテキスト描画は後の作業で扱う予定のため、現時点ではフレームバッファ全体を単色で塗りつぶすだけの簡易的な表示にとどめています。

// kernel/panic.zig
fn onException(frame: *const arch.idt.InterruptFrame) noreturn {
    _ = frame;
    if (framebuffer) |fb| {
        const pixels: [*]volatile u32 = @ptrFromInt(fb.base);
        const total: usize = @as(usize, fb.pixels_per_scan_line) * fb.height;
        var i: usize = 0;
        while (i < total) : (i += 1) pixels[i] = panic_color;
    }
    arch.interrupts.halt();
}

halt()cliでIFフラグをクリアしたあとhltをループするだけの実装です。IF=0の状態でのhltはNMI(ノンマスカブル割り込み)以外では起きないため、事実上CPUをそこで停止させたのと同じ状態になります。

archkernelはビルド上、archが下位のモジュールとして依存される一方向の関係になっています。例外ハンドラの実体(フレームバッファへの書き込み)はkernel側の知識であるため、arch/idt.zig側は関数ポインタを保持するだけの薄いフックとしてsetExceptionHandlerを用意し、kernel/panic.zigがそこへ自分自身を登録する形にしました。

// arch/idt.zig
pub const ExceptionHandler = *const fn (frame: *const InterruptFrame) noreturn;
var exception_handler: ExceptionHandler = defaultHandler;

pub fn setExceptionHandler(handler: ExceptionHandler) void {
    exception_handler = handler;
}

割り込みを止めたままにしておく理由

kmain()の冒頭では、GDT・IDTのセットアップより前にcli相当のarch.interrupts.disable()を呼んでいます。

// kernel/kernel.zig
pub fn kmain(info: *const boot_info.BootInfo) noreturn {
    arch.interrupts.disable();
    arch.gdt.init();
    panic.init(info.framebuffer);
    arch.idt.init();
    // ...
}

ExitBootServicesを抜けた直後のIFフラグの状態はUEFI仕様上保証されておらず、不用意にハードウェア割り込みを受け付けてしまう可能性があります。加えて、レガシーPIC(Programmable Interrupt Controller)は初期状態でIRQ0〜7をvector 8〜15にマッピングしており、これはIDTに割り当てたCPU例外の範囲(0〜31)とちょうど重なります。この重複はPICの再マッピングによって解消しますが、それは後の作業で扱う予定です。それまでの間は、IRQ用のvector(32以降)を実装しないだけでなく、IFフラグ自体を明示的にクリアしたままにしておく必要があります。

QEMUでの検証と、その落とし穴

意図的にゼロ除算を発生させるコードをkmain()に一時的に追加し、IDT経由でハンドラに到達することを確認しました。

// 検証用に一時的に追加したコード(確認後に削除)
asm volatile (
    \\ xor %%edx, %%edx
    \\ mov $10, %%eax
    \\ xor %%ecx, %%ecx
    \\ div %%ecx
    :
    :
    : .{ .rax = true, .rcx = true, .rdx = true });

Zig自身のゼロ除算に対する言語レベルの安全性チェックを経由すると、実際のCPU例外(#DE)ではなくZig側のパニック処理に入ってしまうため、生のアセンブラで直接div命令を発行しています。

検証の過程で1つ、色選びに関する落とし穴がありました。panic表示の色として最初は赤を選んでいましたが、この環境のGOPフレームバッファはBGR形式(blue_green_red_reserved_8_bit_per_color)で、通常時の画面塗りつぶし処理も同じ理由で赤になる実装になっていたため、画面が赤いというだけでは「panicが起きているのか、正常に起動して赤く塗りつぶしただけなのか」を区別できませんでした。panic用の色は、通常の起動シーケンスが取り得るどの色とも一致しない値(緑)を選ぶことで、この曖昧さを解消しています。

今後

今後は物理フレームアロケータを実装し、UEFIから受け取ったメモリマップの使用可能領域をカーネル側で管理できるようにします。

参考リンク