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
