実機起動で見つかったメモリ管理の不具合と、シリアルポートなし環境でのデバッグ

フレームバッファへのテキスト描画とpanic表示の記事からグラフィックスAPIの記事まで、これまでの実装はすべてQEMU + OVMF上でのみ動作確認していました。この記事では、対象実機(MousePro CR-I1U01)でこれらの機能を実際に起動し、起動直後に発生したpanicの原因調査と修正、および実機での見え方に合わせた調整を扱います。

シリアルポートなし環境での手がかり

対象実機にはシリアルポートがなく、デバッグ出力の手段はフレームバッファテキストコンソールしかありません。実機での初回起動時、画面は赤一色のpanic画面に切り替わり、次のメッセージが表示されました。

SAFETY PANIC
cast causes pointer to be null

これはZigの安全性チェックが、@ptrFromIntで非オプショナルなポインタ型にアドレス0を渡そうとした際に出すメッセージです。問題は、この情報だけではソースコード上のどこで発生したのか特定できないことでした。デバッガもアタッチできず、シリアル出力も存在しない状況では、次の2つの手がかりを追加する必要がありました。

チェックポイントの記録

kmain()の主要な初期化ステップの間に、直前に通過した地点の名前を記録する仕組みを追加しました。

// kernel/panic.zig
var last_checkpoint: []const u8 = "boot start";

pub fn checkpoint(name: []const u8) void {
    last_checkpoint = name;
}

panic画面がその時点のlast_checkpointを表示するようにし、kmain()側では各初期化呼び出しの前にチェックポイントを打つようにします。

// kernel/kernel.zig(抜粋)
panic.checkpoint("before pmm.init");
pmm.init(info.memory_map);
panic.checkpoint("before paging.init");
paging.init(info.memory_map, info.framebuffer);

画面自体はpanic発生時にクリアされてしまうため、それまでにconsole.printで出力した内容は失われます。チェックポイントの文字列をグローバル変数として保持しておくことで、画面クリア後もpanic画面上に「どこまで進んでいたか」を再表示できるようにしています。

panicコールサイトのアドレス

Zigのpanicハンドラはfirst_trace_addrとしてpanic発生箇所の呼び出し元アドレスを渡してきますが、これまでは_ = first_trace_addr;として捨てていました。これを表示するようにします。

// kernel/panic.zig
pub fn onSafetyPanic(msg: []const u8, first_trace_addr: ?usize) noreturn {
    if (initialized) {
        beginPanicScreen();
        console.print("SAFETY PANIC\n\n", .{});
        console.print("last checkpoint: {s}\n\n", .{last_checkpoint});
        console.print("{s}\n\n", .{msg});
        if (first_trace_addr) |addr| {
            console.print("call site: 0x{x}\n", .{addr});
        } else {
            console.print("call site: (none)\n", .{});
        }
    }
    arch.interrupts.halt();
}

明示的な@panic(...)呼び出しではこのアドレスがnullになる一方、コンパイラが自動挿入する安全性チェック(今回のようなポインタキャストや配列範囲外アクセスなど)では実際のアドレスが得られることをQEMU上で確認しています。実機でのpanicは後者に該当したため、有効なアドレスが得られました。

このアドレスだけでソースコード上の行を特定するには、実行時のロードベースアドレスとバイナリ内の静的アドレスの対応が必要で、シンボルテーブルを持たないPE形式の実行ファイルでは単体では完結しません。今回はチェックポイントの情報(before pmm.initで止まっている)と、ソースコード中で@ptrFromIntを使っている箇所を突き合わせることで、アドレスを厳密に解析せずに原因を特定できました。

現象・原因・対応:物理フレームアロケータのnullポインタキャスト

実機でのpanicは一貫してlast checkpoint: before pmm.initの状態で発生しました。これは物理フレームアロケータの記事で実装したpmm.init()の内部で止まっていることを意味します。

pmm.init()は、フリーページを管理するビットマップ自体の置き場所を、UEFIメモリマップ中の最初の「使用可能」な領域から確保します。

// kernel/pmm.zig(修正前)
const bitmap_base: u64 = blk: {
    i = 0;
    while (i < mmap.entry_count) : (i += 1) {
        const desc = descriptorAt(mmap, i);
        if (isUsable(desc.type) and desc.number_of_pages * page_size >= bitmap_bytes) {
            break :blk desc.physical_start;
        }
    }
    arch.interrupts.halt();
};
bitmap = @ptrFromInt(bitmap_base);

このコードは、ページングの記事で触れたとおり、物理アドレス0を「未割り当て」を示す特別な値として予約する設計になっています。ただしその予約処理はpmm.init()の最後、ビットマップを実際に書き込んだ後に実行される構成でした。

QEMU + OVMFでは、使用可能な領域の先頭が物理アドレス0になることは一度もありませんでした。ところが対象実機のUEFIファームウェアは、物理アドレス0から始まる領域を使用可能(EfiConventionalMemoryなど)として報告していました。その結果、bitmap_baseが0になり、@ptrFromInt(0)が非オプショナルなポインタ型への変換として即座に安全性チェックに引っかかり、ビットマップへ何かを書き込む前の時点でpanicしていました。物理アドレス0を後から予約する処理まで到達できていなかったことになります。

対応として、使用可能な領域を探す時点で物理アドレス0そのものを避けるようにしました。

// kernel/pmm.zig(修正後、抜粋)
const bitmap_base: u64 = blk: {
    i = 0;
    while (i < mmap.entry_count) : (i += 1) {
        const desc = descriptorAt(mmap, i);
        if (!isUsable(desc.type)) continue;
        const region_start = if (desc.physical_start == 0) page_size else desc.physical_start;
        const usable_bytes = desc.number_of_pages * page_size - (region_start - desc.physical_start);
        if (usable_bytes >= bitmap_bytes) {
            break :blk region_start;
        }
    }
    arch.interrupts.halt();
};

領域全体を見送るのではなく、その領域の先頭1ページ分だけをスキップして次のページからビットマップを配置します。これにより、実機でもpmm.init()を通過できるようになりました。

QEMUとファームウェアの挙動差でここまで振れ幅の大きい違いが出た点は印象的でした。UEFIの仕様上、物理アドレス0が使用可能領域として報告されること自体は禁止されておらず、それを前提にしないコードを書く必要があったということになります。

表示サイズの調整

pmm.init()の修正後、実機でも起動バナー・キーボード入力・TUIシェルの各コマンド・グラフィックス描画のテストパターンまで一通り動作することが確認できました。ただし8x8ビットマップフォントを等倍で描画すると、QEMUのテストウィンドウ(1024x768)では十分な大きさでも、実機の解像度ではかなり小さく表示されることが分かりました。

対応として、フォントの1ピクセルをそのままフレームバッファの1ピクセルに描画するのではなく、scale分のブロックとして描画するように変更しました。

// kernel/console.zig(抜粋)
var sy: usize = 0;
while (sy < scale) : (sy += 1) {
    var sx: usize = 0;
    while (sx < scale) : (sx += 1) {
        pixels[(base_y + gy * scale + sy) * stride + base_x + gx * scale + sx] = color;
    }
}

カーソル位置の計算やスクロール量もこの拡大率を反映したセルサイズに合わせて変更しています。等倍描画のままだと、解像度に対してフォントサイズが固定されているぶん、高解像度なディスプレイほど文字が小さくなるという問題がありました。

UEFI Runtime Servicesによるシャットダウン

ここまでの機能に加えて、シェルから電源を切るためのshutdownコマンドを実装しました。UEFIからのブート情報取得の記事で触れたBoot ServicesはExitBootServicesの呼び出しとともに使えなくなりますが、Runtime Servicesと呼ばれる別の関数群は、カーネルへのハンドオフ後も呼び出し続けることができます。UEFI変数の読み書きや時刻の取得に加え、システムのリセット(電源オフ・再起動)を行うResetSystemもここに含まれます。

ResetSystemの関数ポインタはExitBootServicesの前でなければ取得できないため、ブートローダー側(Boot Services呼び出しがまだ有効な段階)であらかじめ取得し、BootInfo経由でカーネルに引き渡す構成にしました。

// boot/main.zig(抜粋)
const info = boot_info.BootInfo{
    .memory_map = memory_map,
    .framebuffer = framebuffer,
    .reset_system = @ptrCast(uefi.system_table.runtime_services._resetSystem),
};

カーネル側でこの関数ポインタを呼び出す処理は、GDTの記事で独自のGDTに切り替え、IDT・割り込みの記事で独自の割り込み処理に切り替えた後に、初めてファームウェア側のコードを再び呼び出す箇所になります。呼び出し前に割り込みを無効化しているのは、ファームウェアのRuntime Servicesがこのカーネル独自のIDT/IRQハンドラの存在を前提にしていないためです。

// kernel/power.zig(抜粋)
pub fn shutdown() noreturn {
    if (reset_system) |reset_fn| {
        arch.interrupts.disable();
        reset_fn(.shutdown, 0, 0, null);
    }
    arch.interrupts.halt();
}

QEMU上では、shutdownコマンドの実行によりOVMFがEfiResetShutdownをQEMU自体のポリシーに転送し、画面表示が変わるだけでなくQEMUプロセス自体が終了することを確認しています。実機でも同様にシャットダウンコマンドから電源オフまで実行できることを確認しました。

実機で確認できたこと

今回の一連の修正を経て、対象実機で次の項目が動作することを確認しました。

  • 起動バナー(フレームバッファ解像度・空きメモリ量)の表示
  • USB HIDキーボードでの文字入力(xHCI経由のキーボード入力の記事
  • TUIシェルhelpechomeminfogfxtestpanictestshutdown各コマンド
  • グラフィックスAPIによるテストパターンの描画
  • 意図的なsafety panicによるpanic画面の表示
  • UEFI Runtime Services経由でのシャットダウン

USB HIDキーボードが実機で問題なく動作したことで、xHCI経由のキーボード入力の記事で追加したBIOS→OS所有権ハンドオフ(USB Legacy Support Capability経由)を含め、xHCIドライバ全体が実機のコントローラに対しても正しく機能していることが確認できたことになります。QEMU(qemu-xhci)はこのCapability自体を持たないため、このハンドオフ経路が実際に使われたかどうかは今回の確認だけでは分かりませんが、少なくともキーボード入力の初期化シーケンス全体が実機で破綻なく完走したことは確かです。