Zig で OS in 1,000 Lines をやる

https://operating-system-in-1000-lines.vercel.app/ja/ を Zig でやってみる試み、riscv さわったことほとんど無いし、ちょうど良さそうな題材だと思ったためやる
リポジトリ:https://github.com/Ryoga-exe/os-in-1000-lines-zig
Zig のバージョンは v0.14.1

Zig で書く際、以下のリンクが参考になる
まず build.zig を整えないとビルドができないがほとんど公式で情報が無い

build.zig
はこんな感じに書いた
const std = @import("std");
pub fn build(b: *std.Build) void {
const features = std.Target.riscv.Feature;
var disabled_features = std.Target.Cpu.Feature.Set.empty;
var enabled_features = std.Target.Cpu.Feature.Set.empty;
disabled_features.addFeature(@intFromEnum(features.a));
disabled_features.addFeature(@intFromEnum(features.d));
disabled_features.addFeature(@intFromEnum(features.e));
disabled_features.addFeature(@intFromEnum(features.f));
disabled_features.addFeature(@intFromEnum(features.c));
enabled_features.addFeature(@intFromEnum(features.m));
const target = b.resolveTargetQuery(.{
.cpu_arch = .riscv32,
.os_tag = .freestanding,
.abi = .none,
.ofmt = .elf,
.cpu_features_sub = disabled_features,
.cpu_features_add = enabled_features,
});
const optimize = b.standardOptimizeOption(.{});
const kernel = b.addExecutable(.{
.name = "kernel.elf",
.root_source_file = b.path("src/kernel.zig"),
.target = target,
.optimize = optimize,
});
kernel.entry = .{ .symbol_name = "boot" };
kernel.setLinkerScript(b.path("kernel.ld"));
b.installArtifact(kernel);
const kernel_step = b.step("kernel", "Build the kernel");
kernel_step.dependOn(&kernel.step);
const qemu_cmd = b.addSystemCommand(&.{
"qemu-system-riscv32",
"-machine",
"virt",
"-bios",
"default",
"-nographic",
"-serial",
"mon:stdio",
"--no-reboot",
"-kernel",
b.getInstallPath(.bin, kernel.name),
});
qemu_cmd.step.dependOn(b.getInstallStep());
const run_step = b.step("run", "Run the kernel on QEMU");
run_step.dependOn(&qemu_cmd.step);
}
target は resolveTargetQuery
で指定する。
デフォルトだと CPU の feature 周りでコケるのでそこらへんも記述する。このあたりの feature の on/off がかなりわかりやすく書けて良い。
ELF のエントリはデフォルトだと _start
となるが、これは
kernel.entry = .{ .symbol_name = "boot" };
のように書くと、エントリシンボル名を指定できるらしい。なので boot
にした。
zig build run
で QEMU が起動して実行してほしいが、addSystemCommand
とするとこれは実現できた。

最小限のカーネル
extern var __bss: u8;
extern var __bss_end: u8;
extern var __stack_top: u8;
pub export fn kernel_main() callconv(.C) noreturn {
const start = @intFromPtr(&__bss);
const end = @intFromPtr(&__bss_end);
const len: usize = end - start;
const bss_ptr: [*]u8 = @ptrFromInt(start);
@memset(bss_ptr[0..len], 0);
while (true) {}
}
pub export fn boot() linksection(".text.boot") callconv(.naked) noreturn {
asm volatile (
\\ mv sp, %[stack]
\\ j kernel_main
:
: [stack] "r" (&__stack_top),
: "memory"
);
}
memset
とかはビルトイン関数にあるので自作する必要はない。

インラインアセンブリの構文は https://ziglang.org/documentation/master/#Assembly に説明がある。
Dissecting the syntax の部分を ChatGPT に和訳してもらった。
pub fn syscall1(number: usize, arg1: usize) usize {
// インラインアセンブリは「値を返す式」です。
// `asm` キーワードで式が始まります。
return asm
// `volatile` は任意指定で、「このインラインアセンブリ式には副作用がある」
// ことを Zig に伝えます。`volatile` がない場合、式の結果が使われなければ
// Zig はインラインアセンブリを削除してよいことになります。
volatile (
// 次はコンパイル時文字列で、これがアセンブリコードです。
// この文字列の中では、レジスタが期待される場所で `%[ret]`、`%[number]`、
// `%[arg1]` を使って、引数や戻り値に Zig が用いるレジスタを指定できます
// (レジスタ制約文字列を使う場合)。ただし、この下のコードでは使っていません。
// リテラルの `%` が欲しい場合は `%%` と二重にしてエスケープします。
// ここでは複数行文字列リテラルが便利なことが多いです。
\\syscall
// 次は出力です。将来的に Zig が複数出力に対応する可能性があります
// (https://github.com/ziglang/zig/issues/215 の解決次第)。
// 出力が 1 つもないことも認められており、その場合このコロンの直後に
// 入力のためのコロンが来ます。
:
// これは上のアセンブリ文字列内で `%[ret]` 構文に使われる名前を指定します。
// この例では使っていませんが、構文としては必須です。
[ret]
// 次は出力制約文字列です。この機能は Zig ではまだ不安定扱いなので、
// その意味づけは LLVM/GCC のドキュメントを参照してください。
// http://releases.llvm.org/10.0.0/docs/LangRef.html#inline-asm-constraint-string
// https://gcc.gnu.org/onlinedocs/gcc/Extended-Asm.html
// この例では制約文字列は「このインラインアセンブリの結果は $rax にある」
// ことを意味します。
"={rax}"
// 次は値バインディング、または `->` に続く型のどちらかです。
// 型を置く場合は、その型がインラインアセンブリ式の結果型です。
// 値バインディングの場合は `%[ret]` 構文で、その値に結びつけられた
// レジスタを参照できます。
(-> usize),
// 次は入力のリストです。
// これらの入力に対する制約は「アセンブリが実行される時点で
// $rax には `number` の値、$rdi には `arg1` の値が入っていること」
// を意味します。入力パラメータの数は任意(ゼロも可)です。
: [number] "{rax}" (number),
[arg1] "{rdi}" (arg1),
// 次はクロバー(clobbers)のリストです。これは、このアセンブリの実行で
// 値が保持されないレジスタの集合を宣言します。
// 出力や入力のレジスタは含みません。特別なクロバー値 "memory" は、
// このアセンブリが宣言されていない任意のメモリ位置に書き込む可能性がある
// ことを意味します(宣言済みの間接出力の指すメモリに限らない、ということ)。
// この例では、カーネルの syscall が $rcx と $r11 を保存しないことが
// 知られているため、それらを列挙しています。
: .{ .rcx = true, .r11 = true });
}
x86 / x86_64 ターゲットでは、構文は一般的な Intel 構文ではなく AT&T 構文です。これは技術的な制約によるもので、アセンブリのパースは LLVM が提供しており、Intel 構文のサポートにはバグがあり十分にテストされていないためです。
将来的に Zig 独自のアセンブラを持つ可能性があります。そうなれば言語との統合がよりスムーズになり、一般的な NASM 構文にも対応できるでしょう。このドキュメントのセクションは 1.0.0 リリース前に更新され、AT&T vs Intel/NASM 構文の最終的な扱いが明確に示される予定です。
これまじ?アツい
出力制約(Output Constraints)
出力制約は Zig ではまだ不安定扱いです。意味づけを理解するには LLVM と GCC のドキュメントを参照してください。
Issue #215 に伴って、出力制約には将来的に破壊的変更が入る予定です。
入力制約(Input Constraints)
入力制約も Zig では不安定扱いです。意味づけは LLVM/GCC のドキュメントを参照してください。
Issue #215 に伴って、入力制約にも将来的に破壊的変更が入る予定です。
クロバー(Clobbers)
クロバーは、そのインラインアセンブリの実行によって値が保持されないレジスタの集合です。出力や入力レジスタは含みません。特別なクロバー "memory" は、そのアセンブリが宣言されていない任意のメモリ位置に書き込みを行う可能性があることを意味します(宣言済みの間接出力が指すメモリに限りません)。
与えたインラインアセンブリ式に必要なクロバーを完全に宣言しないことは、チェックされない Illegal Behavior(不正動作) です(訳注:Zig の用語で、安全チェック外の未定義動作に類する扱い)。
グローバルアセンブリ(Global Assembly)
コンテナレベルの comptime ブロック内にアセンブリ式が現れる場合、それはグローバルアセンブリです。
この種のアセンブリはインラインアセンブリとルールが異なります。まず、volatile は無効です。グローバルアセンブリは無条件に常に取り込まれるためです。次に、入力・出力・クロバーはありません。すべてのグローバルアセンブリは 1 つの長い文字列に逐次連結され、そのまままとめてアセンブルされます。インラインアセンブリでの % に関するテンプレート置換ルールもありません。
グローバルアセンブリってやつ、別途 start.S
みたいなアセンブリファイルを用意しないといけない場面とかで使えるやつなのかな?
こういうとき、build.zig
で addAssemblyFile
を書いて指定してたけどそれいらなくなるのかな