Logging in UEFI Application

Now that we have a template for Surtr, the next thing we want to do is to output logs. Log output is very important for debugging, so let's do it first. This time, we will use the UEFI Simple Text Output protocol to output logs.

important

Source code for this chapter is in whiz-surtr-hello_uefi branch.

Table of Contents

System Table and Simple Text Output Protocol

In UEFI, EFI System Table contains various Boot Services and Runtime Services. Among them, the field ConOut contains a pointer to EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL interface. This protocol allows for easy text output in UEFI application.

A pointer to the System Table is contained in std.os.uefi.system_table. You can get the pointer to the Simpl Output Protocl by:

surtr/boot.zig
var status: uefi.Status = undefined;

const con_out = uefi.system_table.con_out orelse return .Aborted;
status = con_out.clearScreen();

After retrieving the Simple Output Protocol, call clearScreen() to clear the screen. By doing this, the output like BdsDxe: loading Boot0001.... that was displayed in the previous chapter should disappear and you should see a blank screen.

To output a string to the screen, use outputString(). Note that the string here uses the UCS-2 character set. UCS-2 represents a character as two bytes. I'll leave the details to other literature, but here we only use the fact that a character is represented by two bytes, <8bit ASCII code> <0x00>, if it is within the range of ASCII characters. Thus, the string "Hello, world!" can be output as follows:

zig
for ("Hello, world!\n") |b| {
    con_out.outputString(&[_:0]u16{ b }).err() catch unreachable;
}

In Zig, string literals are of type [N:0]const u8, Sentinel-Terminated Arrays. In other words, it is a null-terminated array. Thus, the variable b used in the for loop is of type const u8. The type of the argument to outputString() is [*:0]const u16, so the u8 type must be converted to a u16 type. The &[_:0]u16{ b } inside the for loop converts b of type u8 to type u16. By specifying [_:0], 0x00 is added at the end automatically and can be treated as a UCS-2 string.

info

In Zig, arrays are initialized as follows:

zig
const array = [3]u8 { 0, 1, 2 };

However, if the size of the array is known, the size can be omitted by writing _:

zig
const array = [_]u8 { 0, 1, 2 };

Also, a null-terminated array can be initialized as follows:

zig
const array = [_:0]u8 { 0, 1, 2 };

In this case, array.len == 4 but array[4] (the fifth element) is accessible and its value is 0.

Overriding Implementation

Zig can output logs with functions such as std.log.info(). The entity of these functions, std.log.log(), call std_options.logFn. This defaults to defaultLog(). The implementation of this function is OS-specific, but there're no impl for UEFI platform. Therefore, you need to override std_options.logFn and implement your own logging function that uses Simple Text Output.

Create surtr/log.zig and implement the logging functions. Define the function as per the signature of logFn:

surtr/log.zig
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 will emits a compile error for iunused variables. If there are unused function arguments, you must explicitly declare that they are intentionally not used by using _. If you are using VSCode + ZLS, it's convenient that it automatically converts unused variables to _ when you save a file. The same effect can also be achieved by mansally etting the argument names to _.

std.fmt.format() is a function that generates a string from a format string and arguments and writes it to the first argument Writer. The Writer type is defined as follows:

surtr/log.zig
const Writer = std.io.Writer(
    void,
    LogError,
    writerFunction,
);
const LogError = error{};

std.io.Writer() is a function that returns a Writer type based on the information you give. Zig allows functions to return types. The first argument is the context that will be available when Writer is called. In this case, void is specified since no context is needed. The second argument is the error type returned by this Writer. Since no errors are returned, an empty error type LogError should be defined and specified. The third argument, the most important one, specifies the function that actually outputs the data:

surtr/log.zig
fn writerFunction(_: void, bytes: []const u8) LogError!usize {
    for (bytes) |b| {
        con_out.outputString(&[_:0]u16{b}).err() catch unreachable;
    }
    return bytes.len;
}

The first argumect is the type specified when defining the Writer type. Since the void type is specified and we will not use it, it's explicitly ignored with _. The bytes is the string to output. As in the case of outputting “Hello, world!”, it's converted to UCS-2 and passed to outputString().

Now you have implemented your own logging function. All that remains is to set this function in std_options.logFn and override it:

surtr/log.zig
pub const default_log_options = std.Options{
    .logFn = log,
};

It is not documented, but it seems that the std_options variable can be overridden only in root_source_file defined n build.zig. Therefore, the default_log_options variable is pub so that boot.zig can read it. In boot.zig, we reference this variable and override the std_options variable:

surtr/boot.zig
const blog = @import("log.zig");
pub const std_options = blog.default_log_options;

Now, your own logging implementation will be called by std.log.info().

Initialization of Log System

Overriding the log function alone will not cause the log to be output to the screen. You must pass the con_out variable used in writerFunction() to log.zig, and set it as a global variable. Prepare a function to output the log:

surtr/log.zig
const Sto = uefi.protocol.SimpleTextOutput;

var con_out: *Sto = undefined;

/// Initialize bootloader log.
pub fn init(out: *Sto) void {
    con_out = out;
}

Then, the log will be output by passing a pointer to the Simple Text Output Protocol that was obtained earlier. Let's try the following:

surtr/boot.zig
const log = std.log;

blog.init(con_out);
log.info("Initialized bootloader log.", .{});

Run QEMU to see if logs are printed:

info

In Zig, the function returns Error Union Type. This type has the form like LogError!u32, which combines both the error type and the success type. It can also be written as !u32 if anyerror is acceptable. Conversely, if you do not want to return any errors, you can write u32.

If you call a function that may return an error, you can catch it with catch to handle the error:

zig
const value = SomeFunction() catch |err| {
    log.error("SomeFunction failed: {?}", .{err});
    @panic();
}

If no error is returned, the catch block is not executed and value is assigned the return value. In the previous writerFunction(), outputString() may return an error, so catch unreachable is used to handle the error.

The meaning of unreachable depends on the optimization level of the build. For the Debug and ReleaseSafe levels, unreachable causes @panic(). Otherwise, unreachable acts just as a “never reachable” annotation. The behavior when the execution reached there is undefined. Be careful not to put unreachable where it could be executed.

Scope

Now we are ready to output logs. This would be enough, but since we are here, let's take advantage of Zig's logging system a little more.

Zig allows for scoping of logs:

zig
const log = std.log.scoped(.hoge);
log.info("Hello, from hoge scope", .{});

scoped(.hoge) generates a new set of log functions given a scope of hoge. The second argument of the log() function we just implemented accepts this scope. Let's modify it so that it outputs the scope as well:

surtr/log.zig
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;
}

If the received scope is not .default, a string (<SCOPE>) is included in the output. Zig allows array concatnation using the ++ operator. Functions with comptime arguments are evaluated at compile time, so the string generation for scope has no runtime overhead.

In boot.zig, we use the scope .surtr to make it easy to see that the output is from Surtr:

surtr/boot.zig
const log = std.log.scoped(.surtr);
log.info("Hello, world!", .{});

The output should look something like this:

txt
(surtr): Hello, world!

Log Level

The last element of the log is the log level. Zig's log levels are defined as std.log.Level enum, with four levels: err/warn/info/debug. The default log level is determined by the optimization level. Logs lower than the program's log level are not output and are removed at compile time.

For clarity, let's also output the log level:

surtr/log.zig
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 is a switch statement that is evaluated at compile time. It generates a string corresponding to level, concatenates it with fmt like a scope string, and outputs it. The log output in this state should look like this:

txt
[INFO ] (surtr): Hello, world!

Change Log Level

The log level can be changed by setting std_options.log_level in the code. However, it is not convenient to rewrite the code just to change the log level. Let's modify the build script to change the log level at build time:

build.zig
// 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);

A new command line argument is defined by b.option. The string received as an argument is converted to four enum values and added as a new option named log_level with addOption().

The options added here can be referenced in the code as follows:

surtr/log.zig
const option = @import("option"); // build.zig で指定したオプション名
const log_level = option.log_level;

The log_level can be used as a value that's determined at compile time. Set this value to std_options.log_level:

surtr/log.zig
pub const default_log_options = std.Options{
    .log_level = switch (option.log_level) {
        .debug => .debug,
        .info => .info,
        .warn => .warn,
        .err => .err,
    },
    .logFn = log,
};

Then, specify this option at build time to change the log level. For example, you can use log.info() to specify log output, and then specify .warn as the log level at compile time:

bash
zig build -Dlog_level=warn -Doptimize=Debug

The log output should disappear from the QEMU output.

Summary

本チャプターでは UEFI の Simple Text Output Protocol を利用した出力を実装しました。 この出力を Zig のログシステムに組み込むことで、ログのスコープやレベルを自由に変更できるようになりました。 ログ関数は、もちろんフォーマット文字列も利用できます。 今後の開発が捗ること間違いなしですね。

References

1

Strictly speaking, Zig does not have the concept of override. It is implemented in the standard library such that it uses the functin defined by std only if the app hasn't provided the target variable by @hasDecl() .