フレームバッファへのテキスト描画とpanic表示の刷新

このプロジェクトの対象実機にはシリアルポートがなく、デバッグ出力はGOPフレームバッファへの直接描画に頼るしかありません。にもかかわらず、CPU例外をIDTで捕捉する記事で実装したpanic表示は、画面を単色で塗り潰すだけの簡易的なものでした。安全性チェックの失敗(unreachableや範囲外アクセスなど)に至っては、メッセージ文字列の先頭3バイトをRGB値として直接ピクセルに書き込み、スクリーンダンプの色をあとから目で読み取って推測する、という運用でしのいでいました。今回はビットマップフォントによる文字描画を実装し、panic表示を実際に読めるメッセージやレジスタ情報に置き換えます。

ビットマップフォントの選定

文字をピクセルとして描画するには、各文字がどのピクセルパターンで構成されるかを定義したフォントデータが必要です。TrueTypeのようなアウトラインフォントはグリフの輪郭を数式的に定義しており、レンダリングには曲線の描画処理が必要になるため、今回の用途には過剰です。代わりに、1文字を固定サイズのビットマップ(ドットの並び)としてそのまま持つビットマップフォントを採用しました。

具体的には、Daniel Hepper氏によるパブリックドメインのビットマップフォントデータfont8x8_basic(原型はMarcel Sondaar氏によるIBM系VGAフォント)を使っています。ASCII印字可能文字(U+0020〜U+007E)の範囲で、1文字につき8バイト(8x8ピクセル、1行1バイト)というシンプルな構造です。

// kernel/font.zig
const glyphs = [_][glyph_height]u8{
    .{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x20 ' '
    .{ 0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00 }, // 0x21 '!'
    // ...
};

このデータをそのままソースコードに埋め込むにあたって、1点確認が必要な事項がありました。1バイト=1行のうち、どちらの端が左端のピクセルに対応するかというビット順序です。ドキュメント上の明記がなかったため、A(0x41)のグリフデータ0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00を手計算でデコードして確認しました。ビット0(最下位ビット)を左端のピクセルとして展開すると、上から順に「先端が細い三角形→左右の脚→横棒」という、見慣れた大文字のAの形が浮かび上がります。逆にビット7を左端として展開すると形が破綻するため、ビット0=左端という対応であることが分かります。

// kernel/font.zig
// Row is one byte, bit 0 = leftmost pixel, bit `glyph_width - 1` = rightmost.
pub fn pixelSet(row: u8, col: u3) bool {
    return (row >> col) & 1 == 1;
}

フレームバッファテキストコンソールの実装

フォントデータを実際に画面へ反映する部分はkernel/console.zigにまとめました。カーソルの行・列位置を保持し、1文字描画するたびに列を進め、行の右端に達したら次の行へ、画面の下端に達したらフレームバッファの内容を1行分上へシフトしてスクロールする、という一般的なテキストコンソールの構造です。スクロールバックの保持(画面外に流れた行を後から遡って見る機能)は今回のスコープには含めず、直接入力を編集するTUIシェルとあわせて今後の課題とします。

// kernel/console.zig
fn scroll() void {
    const line_height = font.glyph_height;
    const scrollable_height = (rows - 1) * line_height;
    var y: usize = 0;
    while (y < scrollable_height) : (y += 1) {
        var x: usize = 0;
        while (x < fb_width) : (x += 1) {
            pixels[y * stride + x] = pixels[(y + line_height) * stride + x];
        }
    }
    // 最終行を背景色でクリアする処理が続く
}

色の指定には、GOPフレームバッファの情報取得を扱った記事で触れたピクセルフォーマット(RGB系かBGR系か)をそのまま踏まえています。GOPのRGB系フォーマットとBGR系フォーマットは、赤と青のバイト位置が入れ替わっているだけで緑は共通の位置にある、という関係になっており、フォーマットに応じて赤・緑・青の各バイトを詰める位置を切り替える関数を1つ用意することで両対応させています。

文字列の組み立てには、Zig標準ライブラリのstd.fmt.bufPrintを使い、スタック上の固定長バッファにフォーマット済みの文字列を書き出してからコンソールに渡す形にしました。整数のフォーマット(10進・16進の指定など)を自前で実装せずに済み、以後のPhaseでも{d}{x}といった通常のZigのフォーマット指定子がそのまま使えます。

panic表示の刷新

テキスト描画ができるようになったことで、kernel/panic.zigの2つのpanicハンドラを実表示に置き換えました。

CPU例外側は、IDTのハンドラに渡ってくるフレーム構造体の内容(vector番号・エラーコード・発生時のRIP/CS/RFLAGS/RSP/SS)をそのままテキストとして描画します。

// kernel/panic.zig
fn onException(frame: *const arch.idt.InterruptFrame) noreturn {
    if (initialized) {
        beginPanicScreen();
        console.print("CPU EXCEPTION\n\n", .{});
        console.print("vector:     {d}\n", .{frame.vector});
        console.print("error_code: 0x{x}\n", .{frame.error_code});
        console.print("rip:        0x{x}\n", .{frame.rip});
        // cs/rflags/rsp/ssも同様に出力
    }
    arch.interrupts.halt();
}

Zig側の安全性チェック(unreachableや範囲外アクセスなど)が失敗した際に呼ばれるハンドラも同様に、これまで先頭3バイトだけを色として表示していたメッセージを、全文そのまま表示するように変更しています。

// kernel/panic.zig
pub fn onSafetyPanic(msg: []const u8, first_trace_addr: ?usize) noreturn {
    _ = first_trace_addr;
    if (initialized) {
        beginPanicScreen();
        console.print("SAFETY PANIC\n\n", .{});
        console.print("{s}\n", .{msg});
    }
    arch.interrupts.halt();
}

背景色を通常表示と異なる色(赤)に切り替えることで、panic画面であることが一目で分かるようにもしています。

動作確認

QEMU + OVMF上で、通常起動時の文字表示に加えて、2種類のpanic経路を意図的に発生させて確認しました。1つは@panic()を直接呼び出すsafety panic経路、もう1つはページング未マップ領域への意図的なアクセスによる実際の#PF(Page Fault、vector 14)経路です。

後者の確認では1つ発見がありました。ページテーブルはページングを実装した記事で触れた恒等マッピング方針に従い、UEFIのメモリマップに含まれる全領域(使用可能かどうかを問わない)を対象に構築しています。UEFIのメモリマップにはPCI holeなどのMMIO/予約領域も含まれ、実装によっては数GiB近くまで及ぶため、最初にテスト用として選んだ低いアドレス(0xdead0000付近)はこの範囲に収まってしまい、フォールトが発生しませんでした。実際にマップされていないことが確実な、テラバイト単位の高いアドレスに変更したことで、想定通り#PFが発生し、エラーコード0x0(該当ページが存在しない状態)としてレジスタ情報が表示されることを確認できています。

いずれの画面もスクリーンダンプ上で文字が正しい向きで表示され、複数行にまたがる出力も崩れずに描画されることを確認しました。実機での確認は、他のPhaseの内容とあわせて今後行います。