hachem.wtf
aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--boot/boot.zig34
-rw-r--r--boot/gdt.zig77
-rw-r--r--boot/vmm.zig110
3 files changed, 213 insertions, 8 deletions
diff --git a/boot/boot.zig b/boot/boot.zig
index e6342ce..77e7bb6 100644
--- a/boot/boot.zig
+++ b/boot/boot.zig
@@ -6,6 +6,7 @@ const gdt = @import("gdt.zig");
const idt = @import("idt.zig");
const pmm = @import("pmm.zig");
const heap = @import("heap.zig");
+const vmm = @import("vmm.zig");
const keyboard = @import("keyboard.zig");
const bridge = @import("bridge");
@@ -85,6 +86,39 @@ export fn _start() callconv(.c) noreturn {
pmm.free(b);
pmm.free(c);
+ // this is just a VMM smoke test
+ // we map a fresh frame at an unused higher half address in the
+ // active address space, write a pattern, read it back through the mapping
+ {
+ const pml4 = vmm.activePml4();
+ const frame = pmm.alloc().?;
+ const test_virt: u64 = 0xffff_c000_0000_0000;
+ _ = vmm.map(pml4, test_virt, frame, vmm.WRITE);
+ const cell: *volatile u64 = @ptrFromInt(test_virt);
+ cell.* = 0xDEADBEEFCAFEBABE;
+ serial.write("vmm test: map+rw ");
+ serial.write(if (cell.* == 0xDEADBEEFCAFEBABE) "OK" else "FAIL");
+ serial.write(", translate ");
+ const back = vmm.translate(pml4, test_virt) orelse 0;
+ serial.write(if (back == frame) "OK\r\n" else "FAIL\r\n");
+
+ // create a separate address space, switch into it (the scary part -- a
+ // bad CR3 triple-faults), map a lower-half user page, read/write it,
+ // then switch back. if the kernel keeps running, address spaces work.
+ const as = vmm.createAddressSpace().?;
+ const user_frame = pmm.alloc().?;
+ const user_virt: u64 = 0x0000_0000_4000_0000; // 1 GiB, lower half (user)
+ _ = vmm.map(as, user_virt, user_frame, vmm.WRITE | vmm.USER);
+ vmm.loadPml4(as);
+ serial.write("vmm test: switched CR3 OK\r\n"); // reached => kernel still mapped
+ const ucell: *volatile u64 = @ptrFromInt(user_virt);
+ ucell.* = 0x1234_5678_9ABC_DEF0;
+ serial.write("vmm test: user page rw ");
+ serial.write(if (ucell.* == 0x123456789ABCDEF0) "OK\r\n" else "FAIL\r\n");
+ vmm.loadPml4(pml4); // back to the original address space
+ serial.write("vmm test: switched back OK\r\n");
+ }
+
// hand the kernel heap + raw framebuffer to revo and bring up the VM
const fb: ?bridge.Fb = if (framebuffer.get()) |s| .{
.ptr = s.address,
diff --git a/boot/gdt.zig b/boot/gdt.zig
index c428e2c..3f7807d 100644
--- a/boot/gdt.zig
+++ b/boot/gdt.zig
@@ -1,18 +1,54 @@
-// we have to make our own gdt even though limine gives us cause the
-// spec says that the one they provide is only a temp one.
-// in long mode, segment base/limits are ignrored so all we really encode
-// is the access byte and the long mode bit.
+// GDT + TSS
+// long mode ignores segment base/limit, so the segment descriptors
+// only really carry the access byte + long-mode bit.
+// we add ring-3 (user) code and data segments, and a TSS whose rsp0 is
+// the kernel stack the CPU switches to when an interrupt fires while
+// we're in user mode
+//
// see: https://wiki.osdev.org/Global_Descriptor_Table
+// https://wiki.osdev.org/Task_State_Segment
pub const KERNEL_CODE: u16 = 0x08;
pub const KERNEL_DATA: u16 = 0x10;
+pub const USER_CODE: u16 = 0x18; // OR with 3 (RPL) when loading into CS
+pub const USER_DATA: u16 = 0x20; // OR with 3 (RPL) for SS
+pub const TSS_SEL: u16 = 0x28;
+// slots 5+6 (the 16-byte TSS descriptor) are filled at load() time
var gdt = [_]u64{
- 0x0000000000000000, // null
- 0x00209A0000000000, // 0x08 kernel code: present, ring 0, executable, L=1
+ 0x0000000000000000, // 0x00 null
+ 0x00209A0000000000, // 0x08 kernel code: present, ring 0, exec, L=1
0x0000920000000000, // 0x10 kernel data: present, ring 0, writable
+ 0x0020FA0000000000, // 0x18 user code: present, ring 3, exec, L=1
+ 0x0000F20000000000, // 0x20 user data: present, ring 3, writable
+ 0x0000000000000000, // 0x28 TSS low
+ 0x0000000000000000, // TSS high
};
+// x64 TSS
+const Tss = packed struct {
+ reserved0: u32 = 0,
+ rsp0: u64 = 0,
+ rsp1: u64 = 0,
+ rsp2: u64 = 0,
+ reserved1: u64 = 0,
+ ist1: u64 = 0,
+ ist2: u64 = 0,
+ ist3: u64 = 0,
+ ist4: u64 = 0,
+ ist5: u64 = 0,
+ ist6: u64 = 0,
+ ist7: u64 = 0,
+ reserved2: u64 = 0,
+ reserved3: u16 = 0,
+ iomap_base: u16 = 0,
+};
+
+var tss: Tss = .{};
+
+// the stack the CPU lands on for a ring0 entry from ring3 (16 KiB)
+var kernel_stack: [16 * 1024]u8 align(16) = undefined;
+
const Gdtr = packed struct {
limit: u16,
base: u64,
@@ -20,9 +56,31 @@ const Gdtr = packed struct {
var gdtr: Gdtr = undefined;
-// load the GDT, then reload CS (via a far return) and the data segments so the
-// CPU actually uses our descriptors instead of Limine's shit one.
+// set the ring-0 stack used on the next interrupt-from-user (per process later)
+pub fn setKernelStack(rsp0: u64) void {
+ tss.rsp0 = rsp0;
+}
+
+// build the 16 byte TSS system descriptor from the TSS address + limit
+fn tssDescriptor(base: u64, limit: u32) struct { low: u64, high: u64 } {
+ var low: u64 = 0;
+ low |= @as(u64, limit & 0xFFFF); // limit 0..15
+ low |= @as(u64, base & 0xFFFF) << 16; // base 0..15
+ low |= @as(u64, (base >> 16) & 0xFF) << 32; // base 16..23
+ low |= @as(u64, 0x89) << 40; // access: present, type=9 (available 64-bit TSS)
+ low |= @as(u64, (limit >> 16) & 0xF) << 48; // limit 16..19
+ low |= @as(u64, (base >> 24) & 0xFF) << 56; // base 24..31
+ return .{ .low = low, .high = (base >> 32) & 0xFFFFFFFF }; // base 32..63
+}
+
+// load the GDT, reload CS + data segments, then load the task register
pub fn load() void {
+ tss.rsp0 = @intFromPtr(&kernel_stack) + kernel_stack.len; // stack grows down
+ tss.iomap_base = @sizeOf(Tss); // no I/O bitmap
+ const d = tssDescriptor(@intFromPtr(&tss), @sizeOf(Tss) - 1);
+ gdt[5] = d.low;
+ gdt[6] = d.high;
+
gdtr = .{ .limit = @sizeOf(@TypeOf(gdt)) - 1, .base = @intFromPtr(&gdt) };
asm volatile (
\\ lgdt (%[gdtr])
@@ -37,7 +95,10 @@ pub fn load() void {
\\ movw %ax, %fs
\\ movw %ax, %gs
\\ movw %ax, %ss
+ \\ movw %[tss], %ax
+ \\ ltr %ax
:
: [gdtr] "r" (&gdtr),
+ [tss] "i" (@as(u16, TSS_SEL)),
: .{ .rax = true, .memory = true });
}
diff --git a/boot/vmm.zig b/boot/vmm.zig
new file mode 100644
index 0000000..6187545
--- /dev/null
+++ b/boot/vmm.zig
@@ -0,0 +1,110 @@
+// our own 4-level (x86_64) page tables, so we can build
+// per-process address spaces and mark pages user-accessible
+//
+// physical frames come from the PMM, so we reach any frame through the HHDM
+// (pmm.physToVirt) to read/write table entries. the privileged bits (reading
+// CR3, invlpg) are the only asm here
+//
+// see: https://wiki.osdev.org/Paging
+
+const pmm = @import("pmm.zig");
+
+// page-table entry flag bits
+pub const PRESENT: u64 = 1 << 0;
+pub const WRITE: u64 = 1 << 1;
+pub const USER: u64 = 1 << 2; // 1 = ring-3 accessible
+pub const NX: u64 = 1 << 63; // WARNING: needs EFER.NXE, don't set unless enabled
+
+// bits 12..51 of an entry hold the physical frame address
+const ADDR_MASK: u64 = 0x000F_FFFF_FFFF_F000;
+
+// physical base of the currently active PML4 (top of CR3)
+pub fn activePml4() u64 {
+ const cr3 = asm volatile ("mov %%cr3, %[out]"
+ : [out] "=r" (-> u64),
+ );
+ return cr3 & ADDR_MASK;
+}
+
+// switch the active address space
+pub fn loadPml4(pml4_phys: u64) void {
+ asm volatile ("mov %[v], %%cr3"
+ :
+ : [v] "r" (pml4_phys),
+ : .{ .memory = true });
+}
+
+pub fn invlpg(virt: u64) void {
+ asm volatile ("invlpg (%[v])"
+ :
+ : [v] "r" (virt),
+ : .{ .memory = true });
+}
+
+fn table(phys: u64) [*]volatile u64 {
+ return @ptrFromInt(pmm.physToVirt(phys));
+}
+
+fn zeroFrame(phys: u64) void {
+ const p: [*]u8 = @ptrFromInt(pmm.physToVirt(phys));
+ @memset(p[0..pmm.PAGE_SIZE], 0);
+}
+
+// which 9 bit slice of the virtual address indexes level (3=PML4 .. 0=PT)
+fn index(virt: u64, level: u6) usize {
+ return @intCast((virt >> (12 + 9 * level)) & 0x1FF);
+}
+
+// return the physical base of the next-level table under entry i
+// if missing, create one
+fn nextTable(parent_phys: u64, i: usize, create: bool) ?u64 {
+ const t = table(parent_phys);
+ const entry = t[i];
+ if (entry & PRESENT != 0) return entry & ADDR_MASK;
+ if (!create) return null;
+ const frame = pmm.alloc() orelse return null;
+ zeroFrame(frame);
+ t[i] = frame | PRESENT | WRITE | USER;
+ return frame;
+}
+
+// map one 4 KiB page: virt -> phys with the given leaf flags (PRESENT is added
+// returns false only if we ran out of frames for intermediate tables
+pub fn map(pml4_phys: u64, virt: u64, phys: u64, flags: u64) bool {
+ const pdpt = nextTable(pml4_phys, index(virt, 3), true) orelse return false;
+ const pd = nextTable(pdpt, index(virt, 2), true) orelse return false;
+ const pt = nextTable(pd, index(virt, 1), true) orelse return false;
+ table(pt)[index(virt, 0)] = (phys & ADDR_MASK) | flags | PRESENT;
+ invlpg(virt);
+ return true;
+}
+
+// create a fresh address space
+// a new PML4 that shares the kernel's higher half, so indices 256..511
+// (kernel image, HHDM, stack, VM heap) but has an empty lower half for a
+// process's own user memory. returns the new PML4 phys
+//
+// sharing is by copying the top-level entries, so every address space points at
+// the SAME kernel page tables, the kernel stays mapped no matter which process
+// is active, which is what lets us keep running after a CR3 switch
+pub fn createAddressSpace() ?u64 {
+ const pml4 = pmm.alloc() orelse return null;
+ zeroFrame(pml4);
+ const src = table(activePml4());
+ const dst = table(pml4);
+ var i: usize = 256;
+ while (i < 512) : (i += 1) dst[i] = src[i];
+ return pml4;
+}
+
+// walk the tables and return the physical address a virtual address maps to
+// (page base | offset), or null if unmapped
+// NOTE: mostly for debugging
+pub fn translate(pml4_phys: u64, virt: u64) ?u64 {
+ const pdpt = nextTable(pml4_phys, index(virt, 3), false) orelse return null;
+ const pd = nextTable(pdpt, index(virt, 2), false) orelse return null;
+ const pt = nextTable(pd, index(virt, 1), false) orelse return null;
+ const entry = table(pt)[index(virt, 0)];
+ if (entry & PRESENT == 0) return null;
+ return (entry & ADDR_MASK) | (virt & 0xFFF);
+}