フレームバッファコンソール上での簡易TUIシェルの実装

フレームバッファへのテキスト描画とpanic表示の刷新の記事でテキスト出力、xHCI経由のUSBキーボード入力とBoot Protocolの記事でキーボード入力がそれぞれ独立に扱えるようになりました。この2つを組み合わせ、キー入力を1行分編集してから確定し、コマンドとして解釈して実行する、簡易的なTUI(Text User Interface)シェルを実装します。

行編集の実装

シェルの入力は「1文字受け取っては即座に処理する」のではなく、Enterキーが押されるまで行バッファに文字を蓄積し、確定した行をまとめてコマンドとして解釈する構成にします。これは一般的なラインエディタの基本形です。

// kernel/shell.zig
fn readLine() []const u8 {
    var len: usize = 0;
    while (true) {
        const c = drivers.xhci.readKey() orelse continue;
        switch (c) {
            '\n' => {
                console.print("\n", .{});
                return line_buf[0..len];
            },
            0x08 => {
                if (len > 0) {
                    len -= 1;
                    console.backspace();
                }
            },
            else => {
                if (len < line_buf.len) {
                    line_buf[len] = c;
                    len += 1;
                    console.print("{c}", .{c});
                }
            },
        }
    }
}

印字可能な文字はその場でコンソールにエコーしつつ行バッファへ追記し、Backspace(0x08)が来たら行バッファの末尾を1文字減らすとともに、画面上でも直前の1文字を消す必要があります。後者のために、コンソール側に次のような関数を追加しました。

// kernel/console.zig
pub fn backspace() void {
    if (cursor_col == 0) return;
    cursor_col -= 1;
    drawGlyph(cursor_row, cursor_col, ' ');
}

カーソル位置を1つ手前に戻し、そこに空白のグリフを描き直すことで、直前の文字を視覚的に消去します。行の先頭(cursor_col == 0)でBackspaceが押された場合は何もしません。前の行への回り込みは、この行編集が想定する程度の行長では発生しないため、対応していません。

Backspaceキー自体は、USB HIDのUsage ID 0x2Aにあたります。これまでの実装ではアルファベット・数字・スペース・Enterのみを認識していたため、次の1行を追加してマッピングしました。

// drivers/xhci.zig
fn usageToAscii(usage: u8) ?u8 {
    return switch (usage) {
        // ...
        0x2A => 0x08, // Backspace
        // ...
    };
}

コマンドパーサ

確定した行は、最初の空白文字でコマンド名と引数部分に分割するだけの単純な構成にしています。クォートやエスケープのような複雑な構文解析は行いません。

// kernel/shell.zig
fn dispatch(line: []const u8) void {
    const trimmed = std.mem.trim(u8, line, " ");
    if (trimmed.len == 0) return;

    const space_idx = std.mem.indexOfScalar(u8, trimmed, ' ');
    const cmd = if (space_idx) |i| trimmed[0..i] else trimmed;
    const args = if (space_idx) |i| std.mem.trim(u8, trimmed[i + 1 ..], " ") else "";

    if (std.mem.eql(u8, cmd, "help")) {
        cmdHelp();
    } else if (std.mem.eql(u8, cmd, "echo")) {
        cmdEcho(args);
    } else if (std.mem.eql(u8, cmd, "meminfo")) {
        cmdMeminfo();
    } else {
        console.print("unknown command: {s}\n", .{cmd});
    }
}

組み込みコマンド

現時点では3つの組み込みコマンドを用意しています。

  • help:利用可能なコマンド一覧を表示する
  • echo:引数をそのまま表示する
  • meminfo:物理フレームアロケータ(kernel/pmm.zig)が把握している空きページ数を、ページ数とMiB換算の両方で表示する
// kernel/shell.zig
fn cmdMeminfo() void {
    const free = pmm.freePageCount();
    console.print("free pages: {d} ({d} MiB)\n", .{ free, free * pmm.page_size / 1024 / 1024 });
}

未知のコマンドが入力された場合は、unknown command: <名前>という形で明示的にエラーを表示し、黙って何もしない状態にはしないようにしています。

メインループは、プロンプトを表示し、行を読み取り、パースして実行するというシンプルな繰り返しです。

// kernel/shell.zig
pub fn run() noreturn {
    while (true) {
        console.print("> ", .{});
        const line = readLine();
        dispatch(line);
    }
}

スコープ外にしたもの

ROADMAP上ではスクロールバックと複数行編集を発展要素として挙げていましたが、今回は見送りました。現在のkernel/console.zigは画面外に出た行を単純に破棄するスクロール実装になっており、スクロールバックを実現するにはこの部分に新しいバッファリングの仕組みを追加する必要があります。カーソルの左右移動を伴う編集も同様に、現在の行バッファ実装を書き直す規模の変更になるため、必要になった時点で改めて取り組むこととし、現状は末尾への追記とBackspaceのみの単純な行編集にとどめています。

QEMUでの動作確認

xHCI経由のUSBキーボード入力とBoot Protocolの記事と同様、QEMUのqemu-xhciデバイスとusb-kbdデバイスを組み合わせた環境でQEMU+OVMF上の先行動作確認を行いました。

1文字ずつキー入力を送り、helpecho <文字列>meminfoのそれぞれが期待通りの出力を返すこと、存在しないコマンド名に対してunknown commandと表示されること、そしてhelppと入力してからBackspaceを1回押した行が正しくhelpとして実行されることを確認しました。