UEFI でログ出力
Surtr の雛形ができたので、次にやりたいことはログ出力です。 ログ出力はデバッグをする上でも非常に重要なので先にやってしまいましょう。 今回は UEFI の Simple Text Output というプロトコルを利用してログを出力していきます。
important
本チャプターの最終コードは whiz-surtr-uefi_log ブランチにあります。
Table of Contents
- System Table と Simple Text Output Protocol
- ログ実装のオーバーライド
- ログの初期化
- ログのスコープ
- ログレベル
- ログレベルの変更
- まとめ
- References
System Table と Simple Text Output Protocol
UEFI では EFI System Table というテーブルに各種 ブートサービス・ランタイムサービス へのポインタが格納されています。
その中でも、ConOut というフィールドには
EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL インタフェースへのポインタが格納されています。
このプロトコルを利用することで、テキスト出力を容易に行えます。
System Table へのポインタは std.os.uefi.system_table に入っています。
ここから Simple Output Protocol へのポインタを取得します:
var status: uefi.Status = undefined;
const con_out = uefi.system_table.con_out orelse return .Aborted;
status = con_out.clearScreen();
Simple Output Protocol を取得後、clearScreen()を呼んで画面をクリアします。
これによって、前チャプターで表示されていた BdsDxe: loading Boot0001... のような出力が消えてまっさらな画面が表示されるはずです。
画面への文字列出力には outputString() を使います。
ただし、ここで渡す文字列は UCS-2 という文字列集合を使います。
UCS-2 は1文字を2バイトで表現します。
詳しいことは他の文献に譲るとして、ASCII文字の範囲内であれば <8bit ASCII code> <0x00> という2バイトで1文字が表されるという事実だけをここでは利用します。
よって、"Hello, world!"という文字列は以下のようにして出力できます:
for ("Hello, world!\n") |b| {
    con_out.outputString(&[_:0]u16{ b }).err() catch unreachable;
}
Zig において、文字列リテラルは [N:0]const u8 という Sentinel-Terminated Arrays 型になります。
言い換えれば NULL 終端された配列です。
よって、forループで使う変数の b は const u8 型になります。
outputString()の引数の型は [*:0]const u16 であるため、u8型をu16型に変換する必要があります。
forループの中身の &[_:0]u16{ b } では u8 型の b を u16 型に変換しています。
[_:0] と指定することで、最後に勝手に 0x00 が追加され、UCS-2 文字列として扱えるようになります。
info
Zig において、配列の初期化は以下のように行います:
const array = [3]u8 { 0, 1, 2 };
ただし、配列のサイズが明らかである場合にはサイズを _ で省略できます:
const array = [_]u8 { 0, 1, 2 };
また、NULL終端された配列は以下のように初期化できます:
const array = [_:0]u8 { 0, 1, 2 };
この場合、array.len == 4 ではありますが array[4] (5番目の要素)にアクセスでき、その値は 0 になります。
ログ実装のオーバーライド
Zig では std.log.info() のような関数でログを出力できます。
これらの関数の実体である std.log.log() は std_options.logFn を呼び出します。
これはデフォルトでは defaultLog() になっています。
この関数は内部で OS ごとの分岐をするのですが、os.uefi においてはコンパイルできないような分岐になっています。
よって std_options.logFn をオーバーライド1し、Simple Text Output を利用するような独自のログ関数を実装してあげる必要があります。
surtr/log.zig を作成し、ログ関数を実装します。
logFn のシグネチャのとおりに関数を定義してあげます:
fn log(
    comptime level: stdlog.Level,
    scope: @Type(.EnumLiteral),
    comptime fmt: []const u8,
    args: anytype,
) void {
    _ = level;
    _ = scope;
    std.fmt.format(
        Writer{ .context = {} },
        fmt ++ "\r\n",
        args,
    ) catch unreachable;
}
info
Zig では使われない変数がある状態ではコンパイルエラーになります。
関数の引数などで使わない変数がある場合には _ で明示的に使わないことを宣言する必要があります。
VSCode + ZLS を使っている場合、利用しない変数がある状態でセーブをすると自動的に _ に変換してくれるので便利です。
また、最初から引数の名前を _ にすることでも同様の効果が得られます。
std.fmt.format() はフォーマット文字列と引数から文字列を生成し、それを第1引数のWriterに書き込む関数です。
Writer型は、以下のように定義します:
const Writer = std.io.Writer(
    void,
    LogError,
    writerFunction,
);
const LogError = error{};
std.io.Writer() は与えた情報をもとに Writer 型を返してくれる関数です。
Zig では関数が型を返すことができます。
第1引数はWriterが呼び出された時に利用できるコンテキストです。今回はコンテキストが必要ないためvoidを指定します。
第2引数はこのWriterが返すエラー型です。エラーは返さないため、空のエラー型LogErrorを定義し、それを指定しておきます。
最も重要な第3引数では実際に出力をする関数を指定します:
fn writerFunction(_: void, bytes: []const u8) LogError!usize {
    for (bytes) |b| {
        con_out.outputString(&[_:0]u16{b}).err() catch unreachable;
    }
    return bytes.len;
}
第1引数のコンテキストはWriter型の定義時に指定した型です。今回はvoid型を指定しており使わないため、最初から_で無視しています。
bytesは出力する文字列です。
先程 "Hello, world!" を出力したときと同様に、UCS-2 に変換して outputString() に渡してあげます。
これで独自のログ関数を実装できました。
あとは std_options.logFn にこの関数をセットしてオーバーライドしてあげるだけです:
pub const default_log_options = std.Options{
    .logFn = log,
};
ドキュメントされていませんが、std_options 変数は build.zig の root_source_file で指定したファイル以外オーバーライドできないようです。
そのため、default_log_options 変数を pub 指定して boot.zig から触れるようにしています。
boot.zig においてこの変数を参照し、 std_options 変数をオーバーライドします:
const blog = @import("log.zig");
pub const std_options = blog.default_log_options;
これで std.log.info() を呼び出すと、独自に実装したログ関数が呼び出されるようになりました。
ログの初期化
ログ関数をオーバーライドしただけでは、ログが出力されるようにはなりません。
writerFunction() で利用している con_out 変数を log.zig に渡してグローバル変数としてセットしてあげる必要があります。
ログを出力する関数を用意します:
const Sto = uefi.protocol.SimpleTextOutput;
var con_out: *Sto = undefined;
/// Initialize bootloader log.
pub fn init(out: *Sto) void {
    con_out = out;
}
あとは先程取得した Simple Text Output Protocol のポインタを渡してあげればログが出力されるようになります。 試しに以下のようなログ出力をしてみましょう:
const log = std.log;
blog.init(con_out);
log.info("Initialized bootloader log.", .{});
QEMU を動かしてログが出力されるかどうかを確認してください。
info
Zig において関数は Error Union Type を返します。
この型は、エラー型と成功時の型の両方を合わせた LogError!u32 のようなかたちをしています。
エラーとして任意の型を許容する場合には !u32 のように書くこともできます。
逆にエラーを一切返さない場合には u32 と書けます。
関数を呼び出したとき、その関数がエラーを返す可能性がある場合は catch で受けることでエラーを処理できます:
const value = SomeFunction() catch |err| {
    log.error("SomeFunction failed: {?}", .{err});
    @panic();
}
エラーが返されなかった場合、catch ブロックは実行されず、value には関数の返り値が代入されます。
先程の writerFunction() では outputString() がエラーを返す可能性があるため、catch unreachable でエラーを処理しています。
ここで、unreachableの意味は ビルドの最適化レベルによって変化します。
Debug と ReleaseSafe レベルの場合、unreachable は @panic() を引き起こします。
それ以外の場合には、unreachableは「到達することがない」というアノテーションとして働くため、
実際にその箇所に到達してしまった場合の挙動は未定義です。
実行される可能性がある箇所に unreachable を置かないように気をつけましょう。
ログのスコープ
ここまででログの出力ができるようになりました。 これで終わっても十分なのですが、せっかくなのでもう少し Zig のログシステムの良さを活かしてみましょう。
Zig ではログにスコープをもたせることができます:
const log = std.log.scoped(.hoge);
log.info("Hello, from hoge scope", .{});
scoped(.hoge) は、hoge というスコープが与えられた新しいログ関数たちを生成してくれます。
先程実装した log() 関数の第2引数ではこのスコープを受け取ることができます。
スコープも一緒に出力してあげるように修正しましょう:
fn log(
    comptime level: stdlog.Level,
    scope: @Type(.EnumLiteral),
    comptime fmt: []const u8,
    args: anytype,
) void {
    _ = level;
    const scope_str = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): ";
    std.fmt.format(
        Writer{ .context = {} },
        scope_str ++ fmt ++ "\r\n",
        args,
    ) catch unreachable;
}
受け取ったscopeが.default以外の場合には、(<SCOPE>) という文字列を作成し、それを出力するようにしています。
Zig では配列を ++ 演算子で結合できるため、これを利用しています。
引数に comptime を含む関数はコンパイル時に評価されるため、スコープ用の文字列生成部分には実行時のオーバーヘッドはありません。
boot.zig では、スコープを .surtr として Surtr からの出力であることがわかりやすいようにします:
const log = std.log.scoped(.surtr);
log.info("Hello, world!", .{});
以下のような出力になるはずです:
(surtr): Hello, world!
ログレベル
ログの最後の要素は ログレベル です。
Zig のログレベルは std.log.Level enum として定義されており、err/warn/info/debug の4つがあります。
デフォルトのログレベルは 最適化レベルによって決まります。
プログラムのログレベルより低いログは出力されず、コンパイル時に削除されます。
ここでは、分かりやすいようにログレベルも出力してみましょう:
fn log(
    comptime level: stdlog.Level,
    scope: @Type(.EnumLiteral),
    comptime fmt: []const u8,
    args: anytype,
) void {
    const level_str = comptime switch (level) {
        .debug => "[DEBUG]",
        .info => "[INFO ]",
        .warn => "[WARN ]",
        .err => "[ERROR]",
    };
    const scope_str = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): ";
    std.fmt.format(
        Writer{ .context = {} },
        level_str ++ " " ++ scope_str ++ fmt ++ "\r\n",
        args,
    ) catch unreachable;
}
comptime switch はコンパイル時に評価される switch 文です。
level に対応する文字列を生成し、スコープ文字列のように fmt と結合して出力しています。
この状態でログを出力すると以下のようになるはずです:
[INFO ] (surtr): Hello, world!
ログレベルの変更
ログレベルはコード中で std_options.log_level にセットすることで変更できます。
しかし、わざわざログレベルを変更するためにコードを書き換えるのはめんどうですね。
ビルドスクリプトを変更し、ビルド時にログレベルを変更できるようにしましょう:
// Options
const s_log_level = b.option(
    []const u8,
    "log_level",
    "log_level",
) orelse "info";
const log_level: std.log.Level = b: {
    const eql = std.mem.eql;
    break :b if (eql(u8, s_log_level, "debug"))
        .debug
    else if (eql(u8, s_log_level, "info"))
        .info
    else if (eql(u8, s_log_level, "warn"))
        .warn
    else if (eql(u8, s_log_level, "error"))
        .err
    else
        @panic("Invalid log level");
};
// 新たなオプションの作成
const options = b.addOptions();
options.addOption(std.log.Level, "log_level", log_level);
// Surtr にオプションの追加
surtr.root_module.addOptions("option", options);
b.option によって、新たなコマンドライン引数を定義しています。
引数で受け取った文字列を4つの enum 値に変換し、addOption() で新たに log_level という名前のオプションとして追加します。
ここで追加したオプションは、コード中で以下のように参照できます:
const option = @import("option"); // build.zig で指定したオプション名
const log_level = option.log_level;
log_level はコンパイル時に決定する値として利用できます。
この値を std_options.log_level にセットしてあげましょう:
pub const default_log_options = std.Options{
    .log_level = switch (option.log_level) {
        .debug => .debug,
        .info => .info,
        .warn => .warn,
        .err => .err,
    },
    .logFn = log,
};
あとはビルド時にこのオプションを指定してあげれば、ログレベルが変更されます。
試しに log.info() でログ出力するように指定してあげた上で、コンパイル時にログレベルとして .warn を指定してみましょう:
zig build -Dlog_level=warn -Doptimize=Debug
QEMUの出力からログ出力が消えるはずです。
まとめ
本チャプターでは UEFI の Simple Text Output Protocol を利用した出力を実装しました。 この出力を Zig のログシステムに組み込むことで、ログのスコープやレベルを自由に変更できるようになりました。 ログ関数は、もちろんフォーマット文字列も利用できます。 今後の開発が捗ること間違いなしですね。
References
厳密には Zig にはオーバーライドという概念はありません。
標準ライブラリの中で @hasDecl() によってアプリが対象の変数を定義していない場合にのみライブラリ側で定義した値を使うような実装になっています。