hachem.wtf
aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhachem <im@hachem.wtf>2026-09-12 04:08:33 +0200
committerhachem <im@hachem.wtf>2026-09-12 04:09:21 +0200
commit4f540af34d7a88547d60bed7bd28850e6d0e18ab (patch)
tree2b98bc0010a00822169459ef0d37f2ddda6f3b99
parentc34524767c9eac650f16dbe9059c24af2acd1397 (diff)
feat: BAREEE METTALLLLLL REVOOOOO
-rw-r--r--.gitignore2
-rw-r--r--boot/boot.zig19
-rw-r--r--boot/heap.zig4
-rw-r--r--boot/idt.zig7
-rw-r--r--boot/limine.zig14
-rw-r--r--bridge/bridge.zig232
-rw-r--r--build.zig39
-rw-r--r--build.zig.zon15
m---------revo0
9 files changed, 328 insertions, 4 deletions
diff --git a/.gitignore b/.gitignore
index f770585..e26294a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,8 @@
# zig build output
/zig-out/
/.zig-cache/
+# fetched package cache
+/zig-pkg/
# ISO staging + image
/iso_root/
diff --git a/boot/boot.zig b/boot/boot.zig
index 933065c..1f50d83 100644
--- a/boot/boot.zig
+++ b/boot/boot.zig
@@ -6,21 +6,32 @@ const gdt = @import("gdt.zig");
const idt = @import("idt.zig");
const pmm = @import("pmm.zig");
const heap = @import("heap.zig");
+const bridge = @import("bridge");
// We need a panic handler otherwise zig won't be happy,
// std usually formats a message through std.Io.Writer which
// emits SSE instructions which I haven't enabled. Ours
// dumps the message to serial, then halts.
pub const panic = std.debug.FullPanic(struct {
- fn halt(msg: []const u8, _: ?usize) noreturn {
+ fn halt(msg: []const u8, ret_addr: ?usize) noreturn {
serial.init();
serial.write("\r\n!!! uhhhh engine kaput : ");
serial.write(msg);
+ if (ret_addr) |ra| {
+ serial.write("\r\n at ");
+ serial.writeHex(ra);
+ }
serial.write("\r\n");
while (true) asm volatile ("hlt");
}
}.halt);
+// std.debug.print (revo hits it on a few error paths) defaults to a threaded
+// stderr IO that doesn't exist on freestanding and won't even compile. This
+// just points it to the custom one we wrote in bridge tthat uses the serial
+// console and shit
+pub const std_options_debug_io: std.Io = bridge.debug_io;
+
// Check `linker.ld`
export var base_revision: limine.BaseRevision linksection(".limine_requests") = limine.BaseRevision.init(3);
export var requests_start: limine.RequestsStartMarker linksection(".limine_requests_start") = .{};
@@ -31,6 +42,9 @@ export var requests_end: limine.RequestsEndMarker linksection(".limine_requests_
export var hhdm_request: limine.HhdmRequest linksection(".limine_requests") = .{};
export var memmap_request: limine.MemoryMapRequest linksection(".limine_requests") = .{};
+// 4 MiB stack
+export var stack_size_request: limine.StackSizeRequest linksection(".limine_requests") = .{ .stack_size = 4 * 1024 * 1024 };
+
// ENTRY(_start)
export fn _start() callconv(.c) noreturn {
serial.init();
@@ -70,6 +84,9 @@ export fn _start() callconv(.c) noreturn {
pmm.free(b);
pmm.free(c);
+ // hand the kernel heap to revo and we're gucci
+ bridge.run(heap.allocator(), serial.write);
+
if (framebuffer.get()) |screen| {
// XOR texture
var y: usize = 0;
diff --git a/boot/heap.zig b/boot/heap.zig
index 2f4e74b..3fd9f8c 100644
--- a/boot/heap.zig
+++ b/boot/heap.zig
@@ -10,9 +10,9 @@ const std = @import("std");
const pmm = @import("pmm.zig");
const serial = @import("serial.zig");
-// 4 MiB.
+// 32 MiB.
// NOTE: bump if the VM needs more
-const HEAP_PAGES: usize = 1024;
+const HEAP_PAGES: usize = 8192;
var fba: std.heap.FixedBufferAllocator = undefined;
diff --git a/boot/idt.zig b/boot/idt.zig
index adff4b3..127fd8e 100644
--- a/boot/idt.zig
+++ b/boot/idt.zig
@@ -140,6 +140,13 @@ export fn exceptionHandler(frame: *Frame) callconv(.c) noreturn {
serial.write(" cs="); writeHex(frame.cs);
serial.write("\r\n rflags="); writeHex(frame.rflags);
serial.write(" rsp="); writeHex(frame.rsp);
+ // page faults stash the offending address in CR2
+ if (frame.vector == 14) {
+ const cr2 = asm volatile ("mov %%cr2, %[out]"
+ : [out] "=r" (-> u64),
+ );
+ serial.write("\r\n cr2="); writeHex(cr2);
+ }
serial.write("\r\n");
while (true) asm volatile ("hlt");
}
diff --git a/boot/limine.zig b/boot/limine.zig
index 914aca8..f1ed158 100644
--- a/boot/limine.zig
+++ b/boot/limine.zig
@@ -66,6 +66,20 @@ pub const FramebufferRequest = extern struct {
response: ?*FramebufferResponse = null,
};
+// ask limine for a bigger bootstrap stack than its ~64 KiB default cause
+// revo's VM init builds a large struct on the stack and runs a fat recursive
+// descent parser to register its stdlib, which page faults the machine
+pub const StackSizeResponse = extern struct {
+ revision: u64,
+};
+
+pub const StackSizeRequest = extern struct {
+ id: [4]u64 = requestId(0x224ef0460a8e8926, 0xe1cb0fc25f46ea3d),
+ revision: u64 = 0,
+ response: ?*StackSizeResponse = null,
+ stack_size: u64,
+};
+
// HHDM = higher-half direct map, limine linearly maps ALL of physical memory
// starting at `offset`, so any physical address p is reachable at p + offset
// This is how we touch physical frames without setting up our own page tables
diff --git a/bridge/bridge.zig b/bridge/bridge.zig
new file mode 100644
index 0000000..136cfa9
--- /dev/null
+++ b/bridge/bridge.zig
@@ -0,0 +1,232 @@
+// so the thing is right, revo isn't really that low level and we need to have a
+// thin bridge to translate shit over. Its just to setup the VM, serial ports,
+// heap shit like that.
+
+const std = @import("std");
+const revo = @import("revo");
+
+// all the VM's output goes here, boot injects this at runtime so the
+// bridge doesn't need to pull a serial port from its ass
+pub const Sink = *const fn ([]const u8) void;
+fn noopSink(_: []const u8) void {}
+var sink: Sink = noopSink;
+
+// fan a multi-slice write payload out to the sink. mirrors the shape of std.Io's
+// file_write_streaming request: an optional header, then `data`, whose LAST slice
+// is repeated `splat` times. returns the total byte count written.
+fn writeToSink(
+ header: []const u8,
+ data: []const []const u8,
+ splat: usize,
+) std.Io.Operation.FileWriteStreaming.Result {
+ if (header.len > 0) sink(header);
+ for (data[0 .. data.len -| 1]) |slice| {
+ if (slice.len > 0) sink(slice);
+ }
+ const last = data[data.len -| 1];
+ var i: usize = 0;
+ while (i < splat) : (i += 1) {
+ if (last.len > 0) sink(last);
+ }
+
+ var total: usize = header.len;
+ for (data[0 .. data.len -| 1]) |slice| total += slice.len;
+ total += last.len * splat;
+ return total;
+}
+
+// every file write (stdout, stderr, whatever) routes to the serial port; we
+// don't have a filesystem or multiple sinks yet, so there's nowhere else to go.
+fn ioOperate(_: ?*anyopaque, operation: std.Io.Operation) std.Io.Cancelable!std.Io.Operation.Result {
+ return switch (operation) {
+ .file_write_streaming => |req| .{
+ .file_write_streaming = writeToSink(req.header, req.data, req.splat),
+ },
+ .file_read_streaming => .{ .file_read_streaming = error.InputOutput },
+ .device_io_control => .{ .device_io_control = -1 },
+ .net_receive => .{ .net_receive = .{ error.NetworkDown, 0 } },
+ };
+}
+
+// no timer wired up yet, so time stands still. fine until we have the PIT/TSC.
+fn ioNow(_: ?*anyopaque, _: std.Io.Clock) std.Io.Timestamp {
+ return .{ .nanoseconds = 0 };
+}
+
+fn ioClockResolution(_: ?*anyopaque, _: std.Io.Clock) std.Io.Clock.ResolutionError!std.Io.Duration {
+ return .{ .nanoseconds = 1_000_000 };
+}
+
+// the scheduler parks fibers itself, it never needs us to actually block.
+fn ioSleep(_: ?*anyopaque, _: std.Io.Timeout) std.Io.Cancelable!void {}
+
+fn ioCrashHandler(_: ?*anyopaque) void {}
+
+// eval captures its own errors into a buffer, so this is never reached; return
+// Canceled rather than recursing into a stderr lock we can't provide.
+fn ioLockStderr(_: ?*anyopaque, _: ?std.Io.Terminal.Mode) std.Io.Cancelable!std.Io.LockedStderr {
+ return error.Canceled;
+}
+
+fn ioUnlockStderr(_: ?*anyopaque) void {}
+
+// start from std's all-failing vtable and override only what the VM touches.
+const io_vtable: std.Io.VTable = blk: {
+ var v = std.Io.failing.vtable.*;
+ v.operate = ioOperate;
+ v.now = ioNow;
+ v.clockResolution = ioClockResolution;
+ v.sleep = ioSleep;
+ v.crashHandler = ioCrashHandler;
+ v.lockStderr = ioLockStderr;
+ v.unlockStderr = ioUnlockStderr;
+ break :blk v;
+};
+
+const serial_io: std.Io = .{ .userdata = null, .vtable = &io_vtable };
+
+// A second IO used only as std's debug IO (for std.debug.print, which revo hits
+// on a few error paths). std's default debug IO is a threaded stderr writer that
+// doesn't exist on freestanding, and std.Io.failing's stderr path is literally
+// `unreachable` -- so we give std a real one that drains to the same sink. Unlike
+// serial_io above, its lockStderr must return an actual File.Writer (std turns a
+// Canceled lock into a panic), and swapCancelProtection must not be unreachable.
+var dbg_fw: std.Io.File.Writer = undefined;
+var dbg_fw_buf: [256]u8 = undefined;
+
+fn dbgLockStderr(_: ?*anyopaque, _: ?std.Io.Terminal.Mode) std.Io.Cancelable!std.Io.LockedStderr {
+ // File.stderr() reads posix.STDERR_FILENO, which doesn't exist on
+ // freestanding. Build the File by hand instead; ioOperate ignores the handle
+ // and routes every write to the sink regardless.
+ const stderr_file: std.Io.File = .{ .handle = undefined, .flags = .{ .nonblocking = false } };
+ dbg_fw = stderr_file.writerStreaming(debug_io, &dbg_fw_buf);
+ return .{ .file_writer = &dbg_fw, .terminal_mode = .no_color };
+}
+fn dbgUnlockStderr(_: ?*anyopaque) void {
+ dbg_fw.interface.flush() catch {};
+}
+fn dbgSwapCancel(_: ?*anyopaque, new: std.Io.CancelProtection) std.Io.CancelProtection {
+ return new;
+}
+
+const debug_io_vtable: std.Io.VTable = blk: {
+ var v = std.Io.failing.vtable.*;
+ v.operate = ioOperate;
+ v.now = ioNow;
+ v.clockResolution = ioClockResolution;
+ v.sleep = ioSleep;
+ v.crashHandler = ioCrashHandler;
+ v.lockStderr = dbgLockStderr;
+ v.unlockStderr = dbgUnlockStderr;
+ v.swapCancelProtection = dbgSwapCancel;
+ break :blk v;
+};
+
+pub const debug_io: std.Io = .{ .userdata = null, .vtable = &debug_io_vtable };
+
+// an unbuffered std.Io.Writer that drains straight to the sink, so we can render
+// revo's own error reports (which write to a std.Io.Writer) onto the console.
+const SinkWriter = struct {
+ interface: std.Io.Writer,
+
+ fn init() SinkWriter {
+ return .{ .interface = .{ .buffer = &.{}, .vtable = &.{ .drain = drain, .flush = flush }, .end = 0 } };
+ }
+
+ fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
+ _ = w;
+ var total: usize = 0;
+ for (data[0 .. data.len -| 1]) |s| {
+ if (s.len > 0) sink(s);
+ total += s.len;
+ }
+ const last = data[data.len -| 1];
+ var i: usize = 0;
+ while (i < splat) : (i += 1) {
+ if (last.len > 0) sink(last);
+ }
+ total += last.len * splat;
+ return total;
+ }
+
+ fn flush(_: *std.Io.Writer) std.Io.Writer.Error!void {}
+};
+
+// bring up the VM on the kernel heap and run a revo program. this is the "hello
+// world" that proves the whole stack: revo compiled for bare metal, our
+// allocator feeding it, its stdout coming out our serial port.
+pub fn run(alloc: std.mem.Allocator, out: Sink) void {
+ sink = out;
+
+ // NOTE: we drive the VM directly (compile -> run) instead of the high-level
+ // Runtime.eval wrapper. that wrapper has a latent type error in revo (it
+ // names module.EvalResult, which doesn't exist) that only trips when it's
+ // actually analyzed -- we're the first caller. this is the same low-level
+ // path revo's own wasm entry uses, so it's the well-trodden one anyway.
+ const VM = revo.VM;
+ const vm = alloc.create(VM) catch {
+ out("bridge: OOM creating VM\r\n");
+ return;
+ };
+ defer alloc.destroy(vm);
+ vm.* = VM.init(.{
+ .alloc = alloc,
+ .io = serial_io,
+ .argv = &.{},
+ .diag_alloc = alloc,
+ }) catch {
+ out("bridge: VM.init failed\r\n");
+ return;
+ };
+ defer vm.deinit();
+ out("bridge: revo VM up\r\n");
+
+ // a proper workout, not just a print: recursion (call frames + soft-float
+ // arithmetic), string methods (real stdlib), tables, tuples, destructuring.
+ const src =
+ \\fn fib(n) do
+ \\ if n < 2 n
+ \\ else fib(n - 1) + fib(n - 2)
+ \\end
+ \\print("hello from revo")
+ \\print("fib(10) =", fib(10))
+ \\print("arith =", 1 + 2 * 3, 10 / 4)
+ \\print("string =", "revo":upper(), "KERNEL":lower())
+ \\const arr = {10, 20, 30}
+ \\print("table =", arr, arr[0])
+ \\const (a, b) = (3, 4)
+ \\print("tuple =", a + b)
+ ;
+ const build_result = revo.lang.build(vm, .{ .name = "kernel", .text = src }, .{}) catch {
+ out("bridge: compile threw\r\n");
+ return;
+ };
+ const artifact = switch (build_result) {
+ .ok => |art| art,
+ .err => {
+ out("bridge: compile error\r\n");
+ return;
+ },
+ };
+ defer alloc.free(artifact.instructions);
+ defer alloc.free(artifact.spans);
+
+ vm.setProgramDebugInfo(artifact.spans, "", "kernel") catch {
+ out("bridge: setProgramDebugInfo failed\r\n");
+ return;
+ };
+
+ const eval_result = revo.module.runCompiledModuleReport(vm, "kernel", artifact.instructions) catch {
+ out("bridge: eval threw\r\n");
+ return;
+ };
+ switch (eval_result) {
+ .ok => out("\r\nbridge: eval ok\r\n"),
+ .err => |failure| {
+ out("\r\nbridge: runtime error: ");
+ var sw = SinkWriter.init();
+ failure.render(alloc, &sw.interface, src) catch {};
+ out("\r\n");
+ },
+ }
+}
diff --git a/build.zig b/build.zig
index f49eb58..ea919b7 100644
--- a/build.zig
+++ b/build.zig
@@ -26,11 +26,13 @@ pub fn build(b: *std.Build) void {
query.cpu_features_sub.addFeature(@intFromEnum(Feature.avx2));
query.cpu_features_add.addFeature(@intFromEnum(Feature.soft_float));
+ const target = b.resolveTargetQuery(query);
+
const kernel = b.addExecutable(.{
.name = "revel",
.root_module = b.createModule(.{
.root_source_file = b.path("boot/boot.zig"),
- .target = b.resolveTargetQuery(query),
+ .target = target,
.optimize = optimize,
.code_model = .kernel,
.red_zone = false,
@@ -42,5 +44,40 @@ pub fn build(b: *std.Build) void {
kernel.pie = false;
kernel.setLinkerScript(b.path("boot/linker.ld"));
+ // the big guns
+ const revo_dep = b.dependency("revo", .{ .target = target, .optimize = optimize });
+ const revo_mod = revo_dep.module("revo");
+
+ var visited: std.AutoHashMap(*std.Build.Module, void) = .init(b.allocator);
+ defer visited.deinit();
+ kernelize(revo_mod, &visited);
+
+ // the thin guns
+ const bridge_mod = b.createModule(.{
+ .root_source_file = b.path("bridge/bridge.zig"),
+ .target = target,
+ .optimize = optimize,
+ .code_model = .kernel,
+ .red_zone = false,
+ .single_threaded = true,
+ .link_libc = false,
+ });
+ bridge_mod.addImport("revo", revo_mod);
+
+ kernel.root_module.addImport("bridge", bridge_mod);
+
b.installArtifact(kernel);
}
+
+// force a module and everything it imports to the kernel's codegen settings
+fn kernelize(mod: *std.Build.Module, visited: *std.AutoHashMap(*std.Build.Module, void)) void {
+ if (visited.contains(mod)) return;
+ visited.put(mod, {}) catch @panic("OOM building module graph");
+
+ mod.code_model = .kernel;
+ mod.red_zone = false;
+ mod.single_threaded = true;
+
+ var it = mod.import_table.iterator();
+ while (it.next()) |entry| kernelize(entry.value_ptr.*, visited);
+}
diff --git a/build.zig.zon b/build.zig.zon
new file mode 100644
index 0000000..0a70ccb
--- /dev/null
+++ b/build.zig.zon
@@ -0,0 +1,15 @@
+.{
+ .name = .revel,
+ .version = "0.1.0",
+ .fingerprint = 0x453ae5f0bb5a7c9f,
+ .minimum_zig_version = "0.16.0",
+ .dependencies = .{
+ .revo = .{ .path = "revo" },
+ },
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "boot",
+ "bridge",
+ },
+}
diff --git a/revo b/revo
-Subproject de1a5c8bdca6c0cc7bb60378dfd7c9e64f12dcf
+Subproject 48c6dcc7ff03d5ea631c8d913eaad22f75d1007