Zig OS Dev 001

Zigに最近興味を持っている。
と言ってもZigのファンになって3年ほどになるが、なかなか見る時間がなかった。
それとWASMだ。僕はWASMバイナリで動くOSをRiscVでテスト的に作ろうと思っている。
理由はring0で動くWASMバイナリなOSを実装することで昨今のクラウドコンピューティングやコンテナ技術の
無駄なオーバーヘッドを無くすことができると考えている。

それとZigを選んだ理由はいくつかある。

  • zigの強力なビルドシステム
  • zigでcをコンパイルできる
  • メモリ管理システムが魅力的
  • 今後のエコシステムを作っていく上でZigは良さそう

それはさておき、まずは boot.s を見ていこう

// boot.s

.option norvc
.section .data

.section .text.init
.global _start
_start:
  csrr t0, mhartid
  bnez  t0, 3f
  csrw satp, zero
.option push
.option norelax
  la gp, _global_pointer
.option pop
  la a0, _bss_start
  la a1, _bss_end
  bgeu a0, a1, 2f

1:
#if __riscv_xlen == 64
  sd zero, (a0)
#elif __riscv_xlen == 32
  sw zero, (a0)
#endif
  addi a0, a0, 8
  bltu a0, a1, 1b

2:
  la sp, _stack
  li t0, (0b11 << 11) | (1 << 7) | (1 << 3)
  csrw mstatus, t0
  la t1, kmain
  csrw mepc, t1
  la t2, trap
  csrw mtvec, t2
  li t3, (1 << 3) | (1 << 11)
  csrw mie, t3
  la ra, 4f
  mret

3:
4:
  wfi
  j 4b

OSを自作する時によく見るアセンブリだと思う。

アセンブリはあまり書きたくない言語である。
しかしzigの強力なビルドシステムのおかげで
linkerスクリプトやbootの処理やmemoryレイアウトなどをbuild.zigで指定して実行することで
比較的シンプルでわかりやすくzigのプログラムをビルドすることができる。
build.zigはこんな感じ。

const kernel = b.addExecutable(.{
    .root_source_file = .{ .path = "src/kernel.zig" },
    .optimize = optimize,
    .target = target,
    .name = "kernel",
});

kernel.code_model = .medium;
kernel.setLinkerScriptPath(.{ .path = "src/linker.lds" });
// Some of the boot-code changes depending on if we're targeting 32-bit
// or 64-bit, which is why we need the pre-processor to run first.
kernel.addCSourceFiles(&.{"src/boot.S"}, &.{
    "-x", "assembler-with-cpp",
});
kernel.addCSourceFiles(&.{"src/mem.S"}, &.{
    "-x", "assembler-with-cpp",
});
b.installArtifact(kernel);

linkerスクリプトは基本的なOSを作る時と同じ。
各セクションのメモリ位置をリンカスクリプトで用意。
linker.ldsはこんな感じ。

OUTPUT_ARCH( "riscv" )
ENTRY( _start )

MEMORY
{
  ram  (wxa) : ORIGIN = 0x80000000, LENGTH = 128M
}

PHDRS
{
  text PT_LOAD;
  data PT_LOAD;
  bss PT_LOAD;
}

SECTIONS
{
  .text : {
    PROVIDE(_text_start = .);
    *(.text.init) *(.text .text.*)
    PROVIDE(_text_end = .);
  } >ram AT>ram :text

  PROVIDE(_global_pointer = .);

  .rodata : {
    PROVIDE(_rodata_start = .);
    *(.rodata .rodata.*)
    PROVIDE(_rodata_end = .);
  } >ram AT>ram :text

  .data : {
    . = ALIGN(4096);
    PROVIDE(_data_start = .);
    *(.sdata .sdata.*) *(.data .data.*)
    PROVIDE(_data_end = .);
  } >ram AT>ram :data

  .bss : {
    PROVIDE(_bss_start = .);
    *(.sbss .sbss.*) *(.bss .bss.*)
    PROVIDE(_bss_end = .);
  } >ram AT>ram :bss

  PROVIDE(_memory_start = ORIGIN(ram));
  PROVIDE(_stack = _bss_end + 0x80000);
  PROVIDE(_memory_end = ORIGIN(ram) + LENGTH(ram));
  PROVIDE(_heap_start = _stack);
  PROVIDE(_heap_size = _memory_end - _heap_start);
}

一通りOSのメモリレイアウトとブートの用意ができたところで、uartを実装しよう。
RiscV-32bitで実装するのでOpenSBIのNS16550に沿ったドライバーの実装をする。

// based on the OpenSBI NS16550 driver.

const std = @import("std");

// the default UART serial device is at 0x10000000 on the QEMU RISC-V virt platform.
const uart_base: usize = 0x10000000;

const UART_RBR_OFFSET = 0; // In:  Recieve Buffer Register
const UART_DLL_OFFSET = 0; // Out: Divisor Latch Low
const UART_IER_OFFSET = 1; // I/O: Interrupt Enable Register
const UART_DLM_OFFSET = 1; // Out: Divisor Latch High
const UART_FCR_OFFSET = 2; // Out: FIFO Control Register
const UART_LCR_OFFSET = 3; // Out: Line Control Register
const UART_LSR_OFFSET = 5; // In:  Line Status Register
const UART_MDR1_OFFSET = 8; // I/O:  Mode Register

const UART_LSR_DR = 0x01; // Receiver data ready
const UART_LSR_THRE = 0x20; // Transmit-hold-register empty

fn write_reg(offset: usize, value: u8) void {
    const ptr: *volatile u8 = @ptrFromInt(uart_base + offset);
    ptr.* = value;
}

fn read_reg(offset: usize) u8 {
    const ptr: *volatile u8 = @ptrFromInt(uart_base + offset);
    return ptr.*;
}

pub fn put_char(ch: u8) void {
    // Wait for transmission bit to be empty before enqueuing more characters
    // to be outputted.
    while ((read_reg(UART_LSR_OFFSET) & UART_LSR_THRE) == 0) {}

    write_reg(0, ch);
}

pub fn get_char() ?u8 {
    // Check that we actually have a character to read, if so then we read it
    // and return it.
    if (read_reg(UART_LSR_OFFSET) & UART_LSR_DR == 1) {
        return read_reg(UART_RBR_OFFSET);
    } else {
        return null;
    }
}

pub fn init() void {
    // 1 << 0 = 0000 0001
    // 1 << 1 = 0000 0010
    // lcr = 0000 0011 #1 if either one is 1
    // 1 << 7 = 1000 0000
    // lcr | 1 << 7 = 1000 0011
    const lcr = (1 << 0) | (1 << 1);
    write_reg(UART_LCR_OFFSET, lcr);
    write_reg(UART_FCR_OFFSET, (1 << 0));
    write_reg(UART_IER_OFFSET, (1 << 0));
    write_reg(UART_LCR_OFFSET, lcr | (1 << 7));

    const divisor: u16 = 592;
    const divisor_least: u8 = divisor & 0xff;
    const divisor_most: u8 = divisor >> 8;
    write_reg(UART_DLL_OFFSET, divisor_least);
    write_reg(UART_DLM_OFFSET, divisor_most);

    write_reg(UART_LCR_OFFSET, lcr);
}

const Writer = std.io.Writer(u32, error{}, uart_put_str);
const uart_writer = Writer{ .context = 0 };

fn uart_put_str(_: u32, str: []const u8) !usize {
    for (str) |ch| {
        put_char(ch);
    }
    return str.len;
}

pub fn println(comptime fmt: []const u8, args: anytype) void {
    uart_writer.print(fmt ++ "\n", args) catch {};
}

あとはエントリポイントである、kernel.zigでuartを初期化すれば良い。

const uart = @import("uart.zig");
const page = @import("page.zig");

// This the trap/exception entrypoint, this will be invoked any time
// we get an exception (e.g if something in the kernel goes wrong) or
// an interrupt gets delivered.
// from boot.S
export fn trap() align(4) callconv(.C) noreturn {
    while (true) {}
}

// This is the kernel's entrypoint which will be invoked by the booting
// CPU (aka hart) after the boot code has executed.
export fn kmain() callconv(.C) void {
    page.init();
    uart.init();
    uart.println("Zig is running on barebones RISC-V (rv{})!", .{@bitSizeOf(usize)});

    while (true) {
        if (uart.get_char()) |value| {
            switch (value) {
                10, 13 => uart.println("", .{}),
                else => uart.println("not implemented", .{}),
            }
        }
    }
}

実行すると、確認できる。

ヒープのメモリアドレスをriscvのobjdumpで確認すると正しいこともわかる。

ひとまず今日は終わりだ。

プロジェクトはここで進めていく。
xv6z