Zig OS Dev 001

Lately I've been into Zig.
Well, I've been a fan of Zig for about three years, but I never really had the time to dig into it.
And then there's WASM. I want to build an experimental OS on RISC-V that runs WASM binaries.
The reason is that I think an OS running WASM binaries in ring 0 could get rid of
the wasted overhead in today's cloud computing and container technology.

There are a few reasons I chose Zig:

  • Zig's powerful build system
  • Zig can compile C
  • Its approach to memory management is attractive
  • Zig looks like a good foundation for building the ecosystem going forward

Anyway, let's start by looking at 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

This is the kind of assembly you usually see when writing your own OS.

Assembly isn't a language I enjoy writing.
But thanks to Zig's powerful build system,
you can specify the linker script, the boot code, the memory layout and so on in build.zig,
and build the Zig program in a relatively simple and readable way.
Here's what build.zig looks like.

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);

The linker script is the same as for any basic OS.
It defines the memory location of each section.
Here's 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);
}

Now that the memory layout and boot code are in place, let's implement the UART.
Since this targets 32-bit RISC-V, I'm writing a driver that follows OpenSBI's NS16550 driver.

// 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 {};
}

All that's left is to initialize the UART in the entry point, kernel.zig.

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", .{}),
            }
        }
    }
}

Run it, and you can see it working.

Checking the heap memory addresses with the RISC-V objdump confirms they're correct too.

That's it for today.

The project continues here:
xv6z