UEFI ELFローダーを作る 前編(Zigの紹介)

これは東京高専プロコンゼミ① Advent Calendar 2020 22日目の記事です。

導入

自作OSを始めるためには、はじめに一山超えなければなりません。 その山とはブートローダーです。legacy BIOSであっても、UEFIであってもまずはじめに書くのは自分のOSをロードするためのブートローダーなのです。

このブートローダー、OSを作りたいと意気込んでいる人にとってはちょっと厄介です。なぜなら、ブートローダーはOSほど魅力的なものではなく*1、その割にAPIについて調べたり思いの外手間がかかるからです。 そしてUEFIでのブートローダーの書き方について、UEFIアプリを書いたことがない人でも分かるように説明されている記事があまりないようでした。

そこでこの記事ではUEFIでELFファイルをロードするブートローダーの書き方について見ていこうと思います。時間がなかったので、記事を前後編に分けて前編である今回は、Zigの紹介パートとします。

今回、ブートローダーを作るにあたっては、C言語ではなくZigという言語を用いました。この言語は、C言語を置き換えることを目的とした言語であり、C言語と比べて安全なプログラムが書けるようにデザインされています。システムプログラミングや組み込みプログラミングで嬉しい機能も標準で組み込まれています。

ziglangについて

ziglangは、シンプルで安全なプログラミング言語で現在活発に開発されています。システム開発や組み込み開発向けのライブラリや機能も充実しており、UEFI APIにも対応しています*2

ziglang.org

なぜziglangを使うのか

ziglangは、C言語の置き換えとして使うことを推奨しています。このことは、zigコンパイラがCコンパイラを含んでいることからもわかります。C言語は、とても危険な言語です。例えば、以下のコードには潜在的なバグが幾つか有りますが気づけますでしょうか。

gist.github.com

yuki/Akagi :) ./a.out
please input number: -9
zsh: segmentation fault (core dumped)  ./a.out

ひとつは、よくあるバグなので見つけやすいでしょう。そうです、scanfの引数に変数のポインタではなく、変数そのものを渡してしまっているため、不正なアドレス参照が起きてセグメンテーション違反となりました。直したコードが以下です。しかし、まだバグがあります。確認してみましょう。

gist.github.com

yuki/Akagi :) ./a.out
please input number: -9
v: -9, data: 65535

dataの値が変わってしまいました。理由はわかりますでしょうか。フォーマット指定子がdなのにshortのポインタを渡してしまっていますね。結果、buffer over runが起きてスタック上の隣の変数まで書き込んでしまうことになります。このバグは、gccでwarningを表示するようにすれば検出することができますし、考えればわかることですが、そもそもこんなコードを意図して書いていたら相当ヤバイと思うので、どうせならコンパイルエラーにしてほしいと思うのです。

yuki/Akagi :) gcc -Wall test.c
test.c: In function ‘main’:
test.c:10:13: warning: format ‘%d’ expects argument of type ‘int *’, but argument 2 has type ‘short int *’ [-Wformat=]
   10 |     scanf("%d", &v);
      |            ~^   ~~
      |             |   |
      |             |   short int *
      |             int *
      |            %hd

以上のコードと同じ入出力を行うプログラムをzigで書くと以下のようになります。

gist.github.com

いくつか特徴をピックアップしてみましょう。

pub fn main() anyerror!void {
    const stdin = std.io.getStdIn();
    var v: u16 = undefined;
    const data: usize = 80;

zigでは、変数の宣言時には初期値を設定することが強制されています。これにより、初期値の設定し忘れによるバグを埋め込む余地がありません。また、次に詳しく述べますが標準入出力関数として特別なものは用意されておらず、標準入出力は単なるファイルとして扱います。std.io.getStdIn()で標準入力ファイルを取得できます。特に初期値がない場合には、undefinedで初期化することができます。undefinedで初期化した変数に何も代入しないまま使用するとそれはバグとなります。zigでは、debugモードにおいてundefinedで初期化すると0xaaが書き込まれるようになっています。これにより、早期にバグを検出することができます。

    std.debug.warn("please input number: ", .{});
    v = read_num(u16, &stdin);
    std.debug.warn("v: {}, data: {}\n", .{ v, data });
}

先程少し書きましたが、zigでは標準入出力を特別に抽象化していません。標準入出力は、zigの標準ライブラリにあるAPIを使ってユーザーの責任において行う必要があります。今回はお試しコードなので出力はstd.debug.warn関数を用いました。これは、debugモードにおいてのみ有効なものです。入力はデバッグモードだけで使えるようなものもないのでお試しであっても自分で書かなくてはなりません。

fn read_num(comptime T: type, stdin: *const std.fs.File) T {
    var buf: [64]u8 = undefined;
    const len = stdin.read(&buf) catch |err| {
        std.debug.warn("Error while reading number: {}\n", .{err});
        std.os.exit(1);
    };
    if (len == buf.len) {
        std.debug.warn("Input is too big!\n", .{});
        std.os.exit(1);
    }
    const line = std.mem.trimRight(u8, buf[0..len], "\r\n");
    const val = std.fmt.parseInt(T, line, 10) catch |err| {
        std.debug.warn("Error while parsing number: {}\n", .{err});
        std.os.exit(1);
    };
    return val;
}
fn read_num(comptime T: type, stdin: *const std.fs.File) T {

関数宣言です。comptimeを使ってジェネリックプログラミングができます。

var buf: [64]u8 = undefined;

zigの配列型は、要素数ごとに固有の型になっています。つまり、[64]u8のbufと[128]u8のbufは別物です。もし、配列外参照をコンパイル時に検出した場合にはコンパイルエラーとしてくれます。さらに、コンパイル時に検出できなかった配列外参照についてもデバッグモードの強力なランタイムにより検出することができます。このランタイムは、@panic関数を自分で実装しさえすればベアメタルにおいても使用することができます。

./main.zig:5:8: error: index 65 outside array of size 64
    buf[65] = 'a';
index out of bounds
/home/yuki/workspace/tmp/zig-test/src/main.zig:33:9: 0x22cdea in main (zig-test)
    hoge[v] = 'a';
pub fn panic(msg: []const u8, error_return_trace: ?*builtin.StackTrace) noreturn {
    @setCold(true);
    EFI.puts("PANIC: ");
    EFI.puts(msg);
    EFI.puts("\r\n");
    while (true) {}
}
    const len = stdin.read(&buf) catch |err| {
        std.debug.warn("Error while reading number: {}\n", .{err});
        std.os.exit(1);
    };
    if (len == buf.len) {
        std.debug.warn("Input is too big!\n", .{});
        std.os.exit(1);
    }

read関数では、bufferの大きさを超える入力を受けないようになっています(先述のように、配列型はその要素数を持つので、buf.lenによってbufのサイズを得ることができます)。

    const line = std.mem.trimRight(u8, buf[0..len], "\r\n");
    const val = std.fmt.parseInt(T, line, 10) catch |err| {
        std.debug.warn("Error while parsing number: {}\n", .{err});
        std.os.exit(1);
    };

あとは、入力を整数にパースします。これは標準ライブラリで行うことができます。

さて、長くなってしまいましたが一通りZigの紹介をしてきました。Zigはシンプルでありながら、とても強力にプログラマーのサポートをしてくれる言語です。ドキュメントが不十分なので、今の所自分で標準ライブラリを見に行って仕様を調べたりする必要がありますが、discordで質問をすると開発者のみなさんがすぐに回答してくれます。今後の推移に期待しましょう。

では、次回は実際にローダーについて解説します。

*1:諸説あります...

*2:全部実装されているわけではありませんが、ブートローダーに必要な部分は一通り私が書いておきました