booting into the kernel

This commit is contained in:
Daniel Samson 2026-07-01 19:51:06 +01:00
parent 3c8656fb25
commit ca3c3d131c
5 changed files with 345 additions and 25 deletions

View File

@ -4,22 +4,46 @@ pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{}); const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{}); 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", .{ const mod = b.addModule("danos", .{
.root_source_file = b.path("src/root.zig"), .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(.{ const exe = b.addExecutable(.{
.name = "danos", .name = "danos",
.root_module = b.createModule(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"), .root_source_file = b.path("src/main.zig"),
.target = target, .target = kernel_target,
.optimize = optimize, .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 = &.{ .imports = &.{
.{ .name = "danos", .module = mod }, .{ .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); b.installArtifact(exe);
@ -32,6 +56,9 @@ pub fn build(b: *std.Build) void {
.os_tag = .uefi, .os_tag = .uefi,
}), }),
.optimize = optimize, .optimize = optimize,
.imports = &.{
.{ .name = "danos", .module = mod },
},
}), }),
}); });
@ -55,6 +82,11 @@ pub fn build(b: *std.Build) void {
const efi_install = b.addInstallArtifact(efiexe, .{ const efi_install = b.addInstallArtifact(efiexe, .{
.dest_dir = .{ .override = .{ .custom = "esp/EFI/BOOT" } }, .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. // The firmware needs to write NVRAM, so give it a writable copy of the vars.
const vars_copy = b.addSystemCommand(&.{ "cp", "-f", ovmf_vars }); const vars_copy = b.addSystemCommand(&.{ "cp", "-f", ovmf_vars });
@ -79,6 +111,7 @@ pub fn build(b: *std.Build) void {
"none", "none",
}); });
run_efi.step.dependOn(&efi_install.step); 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)"); const run_efi_step = b.step("run-efi", "Boot the EFI app in QEMU (OVMF)");
run_efi_step.dependOn(&run_efi.step); run_efi_step.dependOn(&run_efi.step);
@ -92,19 +125,19 @@ pub fn build(b: *std.Build) void {
// run_cmd.addArgs(args); // 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(.{ 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 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"); const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_mod_tests.step); test_step.dependOn(&run_mod_tests.step);
test_step.dependOn(&run_exe_tests.step);
} }

View File

@ -1,15 +1,177 @@
const std = @import("std"); const std = @import("std");
const uefi = std.os.uefi; 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 { pub fn main() uefi.Status {
// std's start code populates these globals before calling main. // `boot` never returns on success it jumps into the kernel. If it fails,
const con_out = uefi.system_table.con_out orelse return .device_error; // report the reason (boot services are still up) and park the machine so the
// message stays on screen.
// UEFI console output expects a null-terminated UTF-16 string. boot() catch |err| {
const message = std.unicode.utf8ToUtf16LeStringLiteral("Hello from Zig UEFI!\r\n"); log("\r\ndanos: boot failed: ");
_ = con_out.outputString(message) catch {}; logBytes(@errorName(err));
log("\r\n");
while (true) {} while (true) asm volatile ("hlt");
};
return .success; 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 {};
} }

47
src/linker.ld Normal file
View File

@ -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)
}
}

View File

@ -1,8 +1,48 @@
const std = @import("std"); const std = @import("std");
const Io = std.Io;
const danos = @import("danos"); 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;
} /// 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);

View File

@ -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 bootloaderkernel 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,
};