From ca3c3d131c32588b8ffe75997976177135b4fc42 Mon Sep 17 00:00:00 2001 From: Daniel Samson Date: Wed, 1 Jul 2026 19:51:06 +0100 Subject: [PATCH] booting into the kernel --- build.zig | 53 ++++++++++++--- src/efi.zig | 182 +++++++++++++++++++++++++++++++++++++++++++++++--- src/linker.ld | 47 +++++++++++++ src/main.zig | 48 +++++++++++-- src/root.zig | 40 ++++++++++- 5 files changed, 345 insertions(+), 25 deletions(-) create mode 100644 src/linker.ld diff --git a/build.zig b/build.zig index 2317c38..01288a4 100644 --- a/build.zig +++ b/build.zig @@ -4,22 +4,46 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + // Shared handoff definitions (BootInfo, Framebuffer, ...). No target is set, + // so the module inherits the target of whichever binary imports it — the + // freestanding kernel or the UEFI bootloader. const mod = b.addModule("danos", .{ .root_source_file = b.path("src/root.zig"), - .target = target, + }); + + // --- Kernel: freestanding x86_64 ELF, jumped to by the bootloader --- + // SSE2 is part of the x86_64 baseline and UEFI leaves it enabled at handoff, + // so we keep it: disabling it forces soft-float and makes the compiler unable + // to encode the vector ops that std's formatting/runtime still emit. + const kernel_target = b.resolveTargetQuery(.{ + .cpu_arch = .x86_64, + .os_tag = .freestanding, + .abi = .none, }); const exe = b.addExecutable(.{ .name = "danos", .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), - .target = target, + .target = kernel_target, .optimize = optimize, + .code_model = .small, // kernel is linked in the low 2 GiB (see image_base) + .red_zone = false, // interrupts would corrupt the SysV red zone + .single_threaded = true, // no scheduler yet; avoids pulling in TLS/atomics + .sanitize_c = .off, // the UBSan runtime needs f128/SSE support we don't provide + .stack_check = false, // stack-probe calls have no runtime to land in + .stack_protector = false, .imports = &.{ .{ .name = "danos", .module = mod }, }, }), }); + exe.setLinkerScript(b.path("src/linker.ld")); + exe.entry = .{ .symbol_name = "_start" }; + // Physical address the bootloader loads the kernel to (identity-mapped under + // UEFI). Overrides Zig's default image base so the linker script's layout is + // honoured; adjust here if it collides with firmware-reserved memory. + exe.image_base = 0x100000; // 1 MiB b.installArtifact(exe); @@ -32,6 +56,9 @@ pub fn build(b: *std.Build) void { .os_tag = .uefi, }), .optimize = optimize, + .imports = &.{ + .{ .name = "danos", .module = mod }, + }, }), }); @@ -55,6 +82,11 @@ pub fn build(b: *std.Build) void { const efi_install = b.addInstallArtifact(efiexe, .{ .dest_dir = .{ .override = .{ .custom = "esp/EFI/BOOT" } }, }); + // The bootloader loads the kernel by name from the volume root, so drop the + // kernel ELF at esp/danos. + const kernel_install = b.addInstallArtifact(exe, .{ + .dest_dir = .{ .override = .{ .custom = "esp" } }, + }); // The firmware needs to write NVRAM, so give it a writable copy of the vars. const vars_copy = b.addSystemCommand(&.{ "cp", "-f", ovmf_vars }); @@ -79,6 +111,7 @@ pub fn build(b: *std.Build) void { "none", }); run_efi.step.dependOn(&efi_install.step); + run_efi.step.dependOn(&kernel_install.step); const run_efi_step = b.step("run-efi", "Boot the EFI app in QEMU (OVMF)"); run_efi_step.dependOn(&run_efi.step); @@ -92,19 +125,19 @@ pub fn build(b: *std.Build) void { // run_cmd.addArgs(args); // } + // Tests run on the host. The kernel and bootloader target freestanding/UEFI + // and can't be executed natively, so only the shared module is unit-tested + // here (compiled for the host rather than inheriting a freestanding target). const mod_tests = b.addTest(.{ - .root_module = mod, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = optimize, + }), }); const run_mod_tests = b.addRunArtifact(mod_tests); - const exe_tests = b.addTest(.{ - .root_module = exe.root_module, - }); - - const run_exe_tests = b.addRunArtifact(exe_tests); - const test_step = b.step("test", "Run tests"); test_step.dependOn(&run_mod_tests.step); - test_step.dependOn(&run_exe_tests.step); } diff --git a/src/efi.zig b/src/efi.zig index 5844324..e795cff 100644 --- a/src/efi.zig +++ b/src/efi.zig @@ -1,15 +1,177 @@ const std = @import("std"); const uefi = std.os.uefi; +const elf = std.elf; +const danos = @import("danos"); +const BootInfo = danos.BootInfo; + +/// Name of the kernel ELF on the boot volume (installed to the ESP root by +/// build.zig). UEFI wants a UTF-16, null-terminated path. +const kernel_file_name = std.unicode.utf8ToUtf16LeStringLiteral("danos"); + +/// Physical page size, and the sentinel UEFI uses to seek to end-of-file. +const page_size = 4096; +const seek_end = 0xffff_ffff_ffff_ffff; pub fn main() uefi.Status { - // std's start code populates these globals before calling main. - const con_out = uefi.system_table.con_out orelse return .device_error; - - // UEFI console output expects a null-terminated UTF-16 string. - const message = std.unicode.utf8ToUtf16LeStringLiteral("Hello from Zig UEFI!\r\n"); - _ = con_out.outputString(message) catch {}; - - while (true) {} - - return .success; + // `boot` never returns on success — it jumps into the kernel. If it fails, + // report the reason (boot services are still up) and park the machine so the + // message stays on screen. + boot() catch |err| { + log("\r\ndanos: boot failed: "); + logBytes(@errorName(err)); + log("\r\n"); + while (true) asm volatile ("hlt"); + }; + unreachable; +} + +fn boot() !noreturn { + const bs = uefi.system_table.boot_services orelse return error.NoBootServices; + + // Everything the kernel needs must be gathered *before* we exit boot + // services, since afterwards none of these calls are usable. + var boot_info: BootInfo = .{ + .framebuffer = try queryFramebuffer(bs), + }; + + const entry = try loadKernel(bs); + + log("danos: kernel loaded, exiting boot services\r\n"); + try exitBootServices(bs); + + // Hand control to the kernel. `danos.kernel_abi` is SysV, so the pointer is + // passed in RDI as the kernel expects — not RCX, which this UEFI binary's + // default `.c` convention (Microsoft x64) would use. + const kernel: *const fn (*const BootInfo) callconv(danos.kernel_abi) noreturn = @ptrFromInt(entry); + kernel(&boot_info); +} + +/// Read the current graphics mode into our own framebuffer description. +fn queryFramebuffer(bs: *uefi.tables.BootServices) !danos.Framebuffer { + const gop = (try bs.locateProtocol(uefi.protocol.GraphicsOutput, null)) orelse + return error.NoGraphicsOutput; + const info = gop.mode.info; + return .{ + .base = @intCast(gop.mode.frame_buffer_base), + .width = info.horizontal_resolution, + .height = info.vertical_resolution, + // Each pixel is 32 bits, so the byte pitch is 4 * pixels-per-row. + .pitch = info.pixels_per_scan_line * 4, + .format = switch (info.pixel_format) { + .red_green_blue_reserved_8_bit_per_color => .rgbx, + .blue_green_red_reserved_8_bit_per_color => .bgrx, + // bit_mask / blt_only have no linear 32bpp layout we can paint into. + else => return error.UnsupportedPixelFormat, + }, + }; +} + +/// Open the kernel on the volume we booted from, read it into a pool buffer, +/// load its segments, and return the physical entry-point address. +fn loadKernel(bs: *uefi.tables.BootServices) !usize { + const loaded = (try bs.handleProtocol(uefi.protocol.LoadedImage, uefi.handle)) orelse + return error.NoLoadedImage; + const device = loaded.device_handle orelse return error.NoBootDevice; + const fs = (try bs.handleProtocol(uefi.protocol.SimpleFileSystem, device)) orelse + return error.NoFileSystem; + + const root = try fs.openVolume(); + defer _ = root.close() catch {}; + + const file = try root.open(kernel_file_name, .read, .{}); + defer _ = file.close() catch {}; + + // Seek to the end to learn the size, then rewind. + try file.setPosition(seek_end); + const size: usize = @intCast(try file.getPosition()); + try file.setPosition(0); + + const image = try bs.allocatePool(.loader_data, size); + defer _ = bs.freePool(image.ptr) catch {}; + + // `read` may return short; loop until the whole file is in memory. + var read_total: usize = 0; + while (read_total < size) { + const n = try file.read(image[read_total..]); + if (n == 0) return error.UnexpectedEof; + read_total += n; + } + + return loadElf(bs, image); +} + +/// Validate the ELF and copy every PT_LOAD segment to its physical address. +fn loadElf(bs: *uefi.tables.BootServices, image: []u8) !usize { + if (image.len < @sizeOf(elf.Elf64_Ehdr)) return error.NotElf; + const ehdr: *const elf.Elf64_Ehdr = @ptrCast(@alignCast(image.ptr)); + + if (ehdr.e_ident[0] != 0x7f or ehdr.e_ident[1] != 'E' or + ehdr.e_ident[2] != 'L' or ehdr.e_ident[3] != 'F') return error.NotElf; + if (ehdr.e_machine != .X86_64) return error.WrongArchitecture; + + var i: usize = 0; + while (i < ehdr.e_phnum) : (i += 1) { + const phdr: *const elf.Elf64_Phdr = @ptrCast(@alignCast( + image.ptr + ehdr.e_phoff + i * ehdr.e_phentsize, + )); + if (phdr.p_type != elf.PT_LOAD) continue; + + // Reserve the exact physical pages this segment is linked at. This + // requires the segment's p_paddr to be free in the firmware memory map; + // if it collides, adjust `image_base` in build.zig. + const mem_sz: usize = @intCast(phdr.p_memsz); + const pages = (mem_sz + page_size - 1) / page_size; + const dest: [*]align(page_size) uefi.Page = @ptrFromInt(phdr.p_paddr); + _ = try bs.allocatePages(.{ .address = dest }, .loader_data, pages); + + // Copy the file-backed part, then zero the .bss tail (memsz > filesz). + const bytes: [*]u8 = @ptrFromInt(phdr.p_paddr); + const file_sz: usize = @intCast(phdr.p_filesz); + const off: usize = @intCast(phdr.p_offset); + @memcpy(bytes[0..file_sz], image[off..][0..file_sz]); + @memset(bytes[file_sz..mem_sz], 0); + } + + return @intCast(ehdr.e_entry); +} + +/// Fetch the memory map and exit boot services. Allocating the map buffer can +/// itself change the map (invalidating the key), so retry until it takes. +fn exitBootServices(bs: *uefi.tables.BootServices) !void { + var attempts: usize = 0; + while (attempts < 8) : (attempts += 1) { + const info = try bs.getMemoryMapInfo(); + // Spare descriptors to absorb the growth from the allocatePool below. + const buf = try bs.allocatePool(.loader_data, (info.len + 8) * info.descriptor_size); + const map = bs.getMemoryMap(buf) catch { + _ = bs.freePool(buf.ptr) catch {}; + continue; + }; + bs.exitBootServices(uefi.handle, map.info.key) catch { + _ = bs.freePool(buf.ptr) catch {}; + continue; + }; + return; // Boot services are gone; do not touch `bs` again. + } + return error.ExitBootServicesFailed; +} + +/// Write a compile-time string to the console (best effort). +fn log(comptime msg: []const u8) void { + const out = uefi.system_table.con_out orelse return; + _ = out.outputString(std.unicode.utf8ToUtf16LeStringLiteral(msg)) catch {}; +} + +/// Write a runtime ASCII byte string (e.g. an @errorName) by widening to UTF-16. +fn logBytes(bytes: []const u8) void { + const out = uefi.system_table.con_out orelse return; + var buf: [128]u16 = undefined; + var i: usize = 0; + for (bytes) |b| { + if (i + 1 >= buf.len) break; + buf[i] = b; + i += 1; + } + buf[i] = 0; + _ = out.outputString(buf[0..i :0].ptr) catch {}; } diff --git a/src/linker.ld b/src/linker.ld new file mode 100644 index 0000000..50bf499 --- /dev/null +++ b/src/linker.ld @@ -0,0 +1,47 @@ +/* Kernel link layout. + * + * The kernel is linked at a fixed low physical address (set by `image_base` in + * build.zig). UEFI runs with memory identity-mapped, so the bootloader can load + * each PT_LOAD segment to the physical address matching its virtual address and + * jump straight to _start — no page tables to build yet. (Moving to a + * higher-half virtual base is a later step, once the bootloader sets up paging.) + */ + +ENTRY(_start) + +/* One loadable segment per permission set, so the loader can map .text as R+X, + * .rodata as R, and .data/.bss as R+W. FLAGS bits: 1=X, 2=W, 4=R. */ +PHDRS { + text PT_LOAD FLAGS(5); /* R + X */ + rodata PT_LOAD FLAGS(4); /* R */ + data PT_LOAD FLAGS(6); /* R + W */ +} + +SECTIONS { + .text ALIGN(4K) : { + *(.text .text.*) + } :text + + .rodata ALIGN(4K) : { + *(.rodata .rodata.*) + } :rodata + + .data ALIGN(4K) : { + *(.data .data.*) + } :data + + /* .bss occupies memory but not file space. The loader zeroes it via the + * gap between each PT_LOAD segment's file size and memory size, so no + * boundary symbols are needed here. (Zig's self-hosted linker also does not + * yet honour linker-script symbol assignments.) */ + .bss ALIGN(4K) : { + *(.bss .bss.*) + *(COMMON) + } :data + + /DISCARD/ : { + *(.comment) + *(.note .note.*) + *(.eh_frame .eh_frame_hdr) + } +} diff --git a/src/main.zig b/src/main.zig index abddad8..0ade511 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,8 +1,48 @@ const std = @import("std"); -const Io = std.Io; - const danos = @import("danos"); +const BootInfo = danos.BootInfo; -pub fn main() !void { +/// The calling convention used to enter the kernel. Pinned to SysV explicitly: +/// the bootloader is built for the UEFI target, whose C convention is Microsoft +/// x64 (first argument in RCX), while the kernel is SysV (first argument in +/// RDI). Both sides reference this so the `boot_info` pointer lands in the +/// register the other expects. `danos.kernel_abi` re-exports it to the loader. +pub const kernel_abi = danos.kernel_abi; -} \ No newline at end of file +/// Kernel entry point. The bootloader jumps here after `ExitBootServices` with a +/// pointer to the handoff data. There is no runtime, no stack unwinding, and no +/// caller to return to, so this never returns. +export fn _start(boot_info: *const BootInfo) callconv(kernel_abi) noreturn { + kmain(boot_info); +} + +fn kmain(boot_info: *const BootInfo) noreturn { + // Paint the whole screen so it is visually obvious the kernel took over. + // We honour `pitch` because it can exceed `width * 4`. + const fb = boot_info.framebuffer; + const base: [*]volatile u8 = @ptrFromInt(fb.base); + var y: u32 = 0; + while (y < fb.height) : (y += 1) { + const row: [*]volatile u32 = @ptrCast(@alignCast(base + y * fb.pitch)); + var x: u32 = 0; + while (x < fb.width) : (x += 1) row[x] = 0x0033_66cc; + } + + hang(); +} + +/// Stop the CPU. `hlt` in a loop parks the core at near-zero power until the +/// next interrupt; we loop because `hlt` returns when one arrives. +fn hang() noreturn { + while (true) asm volatile ("hlt"); +} + +/// Freestanding has no OS to receive a panic, so override std's default panic +/// handler to simply halt. (Later this can print to the framebuffer/serial.) +pub const panic = std.debug.FullPanic(struct { + fn panic(msg: []const u8, first_trace_addr: ?usize) noreturn { + _ = msg; + _ = first_trace_addr; + hang(); + } +}.panic); diff --git a/src/root.zig b/src/root.zig index fa354a8..1f3137d 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1 +1,39 @@ -//! By convention, root.zig is the root source file when making a package. +//! Shared definitions that form the contract between the bootloader +//! (src/efi.zig, built as BOOTX64.efi) and the kernel (src/main.zig). +//! +//! Both binaries import this as the "danos" module, so the handoff layout is +//! defined in exactly one place. + +const std = @import("std"); + +/// Calling convention for the bootloader→kernel jump. Pinned to SysV so it does +/// not depend on each binary's target default: the UEFI bootloader's C +/// convention is Microsoft x64 (first arg in RCX), the freestanding kernel's is +/// SysV (first arg in RDI). Both reference this to agree on where `*BootInfo` +/// is passed. +pub const kernel_abi: std.builtin.CallingConvention = .{ .x86_64_sysv = .{} }; + +/// Pixel byte order of the linear framebuffer the firmware handed us. +pub const PixelFormat = enum(u32) { + /// Byte 0 = Red, 1 = Green, 2 = Blue, 3 = reserved. + rgbx, + /// Byte 0 = Blue, 1 = Green, 2 = Red, 3 = reserved. + bgrx, +}; + +/// A linear framebuffer: `width`x`height` pixels, each a 32-bit value, with +/// `pitch` bytes between the start of one row and the next (which may be larger +/// than `width * 4` due to hardware padding). +pub const Framebuffer = extern struct { + base: usize, + width: u32, + height: u32, + pitch: u32, + format: PixelFormat, +}; + +/// Handoff structure the bootloader fills in and passes to the kernel's +/// `_start` in RDI (the first argument under the SysV AMD64 C ABI). +pub const BootInfo = extern struct { + framebuffer: Framebuffer, +};