diff options
| -rw-r--r-- | boot/boot.zig | 34 | ||||
| -rw-r--r-- | boot/gdt.zig | 77 | ||||
| -rw-r--r-- | boot/vmm.zig | 110 |
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); +} |
