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 文字列として扱えるようになります。
zig における配列
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;
}
使わない変数
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 のポインタを渡してあげればログが出力されるようになります。 試しに以下のようなログ出力をしてみましょう:
cosnt log = std.log;
blog.init(con_out);
log.info("Initialized bootloader log.", .{});
QEMU を動かしてログが出力されるかどうかを確認してください。
unreachable
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 optoin = @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()
によってアプリが対象の変数を定義していない場合にのみライブラリ側で定義した値を使うような実装になっています。