🥧

Interface2023年7月号のTry KernelをZigで実装してみた

2023/07/06に公開
2

雑誌Interface2023年7月号の「ゼロから作るOS」で特集されているTry KernelをZigで実装してみました。特集は5部構成になっていますがセンサなどの部品を持っていなかったので、4部の内容まで実装しています。4部までの内容でもマルチタスクやセマフォをつかった同期処理を実装するので結構ボリュームがありました。
基本的にはC言語で書かれたサンプルコードをZigでそのまま置き換えているのでコードの詳細な内容はssstoyama/interface_trykernel_zigのリポジトリを確認してください。サンプルコードと対応するようにpart2_sect3のようなタグがふってあります。

開発環境のホストPCはMacOS(Appleシリコン)、エディタはVSCodeを使いました。そして、VSCode Dev ContainerをつかってDebianコンテナの中で開発しています。また、UARTのメッセージを確認するためにVSCode拡張機能Serial Monitorをローカルにインストールしました。

以下ではZigっぽく実装できたところをいくつか紹介したいと思います。

カーネルのビルド

カーネルはbuild.zigに書かれたZigのビルドスクリプトでビルドします。zig build elf2uf2を実行するとbuildディレクトリにkernel.uf2ファイルを出力するようにしました。
ビルドスクリプトでは、まずkernel.elfファイルを出力したあとelf2uf2スクリプトでelfファイルをuf2ファイルに変換しています。openocdで直接ラズパイPicoにカーネルファイルを書き込みたかったのですが、コンテナの中からラズパイPicoにうまく通信できなかったのでuf2ファイルを出力するにとどめました。

build.zig
const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = std.zig.CrossTarget{
        .cpu_arch = .thumb,
        .cpu_model = .{ .explicit = &std.Target.arm.cpu.cortex_m0plus },
        .os_tag = .freestanding,
        .abi = .eabi,
        .ofmt = .elf,
    };
    const optimize = b.standardOptimizeOption(.{});

    const boot2 = b.addObject(.{
        .name = "boot2",
        .root_source_file = .{ .path = "src/boot/boot2.zig" },
        .target = target,
        .optimize = optimize,
    });
    const entry = b.addExecutable(.{
        .name = "kernel.elf",
        .root_source_file = .{ .path = "src/entry.zig" },
        .target = target,
        .optimize = optimize,
    });
    entry.addObject(boot2);
    entry.addAssemblyFile("./src/dispatch.S");
    entry.setLinkerScriptPath(.{ .path = "src/linker/pico_memmap.ld" });
    b.installArtifact(entry);

    const elf2uf2_cmd = b.addSystemCommand(&.{
        "tools/elf2uf2",
        "zig-out/bin/kernel.elf",
        "build/kernel.uf2",
    });
    elf2uf2_cmd.step.dependOn(b.getInstallStep());
    const elf2uf2_step = b.step("elf2uf2", "Convert elf to uf2");
    elf2uf2_step.dependOn(&elf2uf2_cmd.step);
}

カーネルファイルを書き込むには、リセットボタンを押しながらUSBをPCに接続するとラズパイPicoがPRI-PR2という名前で認識されるので、buildディレクトリにあるkernel.uf2をドラッグアンドドロップで移動します。
毎回USBを抜き差しをするのは面倒なのでYouTube動画を参考にリセットボタンを追加しました。開発が楽になるのでおすすめです。
Lチカできるか確認するにはgit checkout part2_sect3を実行したあとカーネルをビルドしてラズパイPicoに書き込んでみてください。

エラーの返却

関数からエラーを返すときはZigのエラーハンドリングの仕組みを使いました。
カーネル用のエラー型KernelErrorを定義して関数の返り値の型をKernelError!voidのように指定します。

src/error.zig
pub const KernelError = error{
    SYS, // システムエラー
    ...
src/semaphore.zig(KernelErrorまたはセマフォIDを返す関数の例)
// セマフォの生成API
pub fn tk_cre_sem(pk_csem: apidef.T_CSEM) KernelError!typedef.ID {
        ...
    if (semid >= config.CNF_MAX_SEM_ID) return KernelError.LIMIT;
    ...
    return semid;
}

エラー型を返す関数を呼び出すときは、catchでエラーを処理するか、tryでエラーをさらに上流に返すことができます。

var semid = tk_cre_sem(arg) catch |err| {
    // エラー処理
    ...
};

var semid = try tk_cre_sem(arg);

defer の使用

あとから実行したい処理にはdeferを使いました。
関数の先頭で割り込みを禁止して関数を抜ける時に割り込みを許可する処理がいくつかあるので、deferを使うと(個人的には)分かりやすくなると思います。

src/semaphore.zig
// セマフォの生成API
pub fn tk_cre_sem(pk_csem: apidef.T_CSEM) KernelError!typedef.ID {
    // 関数の先頭で割り込みを禁止する
    const intsts = syslib.DI();
    // 関数から抜けるときに割り込みを許可する。
    defer syslib.EI(intsts);
    ...

ただし、無限ループがあるような関数(noreturnがついた関数など)ではdeferは実行されないので気をつけてください。
(2023-7-24追記)Zig言語ではブロックを抜けたときにdeferが実行されるので、無限ループのある関数でも適切にブロックを使えばdeferが利用できます。
Zig Documentation - defer

boot/reset_handler.zig
// リセットハンドラ
pub fn reset_handler() callconv(.C) void {
    var intsts = syslib.DI();
    // defer を使うと割り込みが許可されない
    // defer syslib.EI(intsts);

    ...

    // deferなしで普通に実行する
    syslib.EI(intsts);

    main();

    // 無限ループ
    while (true) {}
}

まとめ

マイコンはArduinoでLチカしたことがあるくらいの経験しかなかったのでOSを完成させられるか不安でしたが、1ステップずつ実装を進められるよう構成されているので分かりやすかったです。マルチタスクや同期処理などOSに代表的な機能も解説されているので満足できる内容でした。
Interface2023年7月号

Discussion

tetsu_kobatetsu_koba

Go言語のdeferは関数から抜けたときに実行されますが、Zig言語の場合はブロックを抜けた時です。なので、無限ループがあるような関数(noreturnがついた関数など)でも適切にブロックを使うことでdeferを効かすことができます。

ssstoyamassstoyama

コメントありがとうございます。Go言語と同じ感覚でdeferを使っていました。。。
記事の方も修正させていただきます。