From 269729f2f23195e44c702e0a26cff16c44692b31 Mon Sep 17 00:00:00 2001 From: Daniel Samson Date: Fri, 3 Jul 2026 13:41:55 +0100 Subject: [PATCH] Built heap allocation --- docs/README.md | 13 ++-- docs/heap.md | 75 ++++++++++++++++++++ docs/testing.md | 1 + src/heap.zig | 174 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 7 +- src/tests.zig | 66 ++++++++++++++++++ test/qemu_test.py | 3 + 7 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 docs/heap.md create mode 100644 src/heap.zig diff --git a/docs/README.md b/docs/README.md index e1bb39c..2b3861c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,7 +18,7 @@ rather than restate it. Roughly in the order things happen at runtime: rather than leaking UEFI's memory descriptors across the boundary. 5. **[frame-allocator.md](frame-allocator.md) — the physical frame allocator.** The bitmap allocator that hands out and reclaims 4 KiB physical frames from that - map — the primitive page tables and the heap will be built on. + map — the primitive page tables and the heap are built on. 6. **[interrupts.md](interrupts.md) — interrupts and exceptions.** The GDT, IDT and TSS, the exception stubs, and the handler that reports a CPU fault in red instead of letting it triple-fault into a silent reset. @@ -28,8 +28,11 @@ rather than restate it. Roughly in the order things happen at runtime: 8. **[device-interrupts.md](device-interrupts.md) — device interrupts.** The Local APIC and its timer — the kernel's first interrupt that is *handled and returned from*, giving it a heartbeat. -9. **[halting.md](halting.md) — halting.** Why a kernel can't just "exit", and - how `while (true) hlt` parks the CPU safely once there's nothing left to do. +9. **[heap.md](heap.md) — the kernel heap.** A growable free-list allocator built on + the VMM, exposed as a `std.mem.Allocator` so std containers work — dynamic + allocation for the kernel. +10. **[halting.md](halting.md) — halting.** Why a kernel can't just "exit", and + how `while (true) hlt` parks the CPU safely once there's nothing left to do. Cutting across all of these: @@ -51,7 +54,8 @@ map** of physical RAM ([memory-map.md](memory-map.md)); the kernel turns that ma into a **frame allocator** ([frame-allocator.md](frame-allocator.md)), installs its **descriptor tables** so CPU faults are caught ([interrupts.md](interrupts.md)), builds its own **page tables** and switches onto them ([paging.md](paging.md)), -starts the **timer** so it has a heartbeat ([device-interrupts.md](device-interrupts.md)), +brings up the **heap** for dynamic allocation ([heap.md](heap.md)), starts the +**timer** so it has a heartbeat ([device-interrupts.md](device-interrupts.md)), runs — its CPU-specific bits behind the [arch](arch.md) boundary — and when it has finished, or panics, it **halts** ([halting.md](halting.md)). @@ -63,6 +67,7 @@ finished, or panics, it **halts** ([halting.md](halting.md)). | Kernel entry, panic, bring-up | `src/main.zig` | | Shared loader↔kernel contract (`BootInfo`, `Framebuffer`, `MemoryMap`, ABI) | `src/root.zig` | | Physical frame allocator | `src/pmm.zig` | +| Kernel heap (`std.mem.Allocator`) | `src/heap.zig` | | Framebuffer text console (mirrors to serial) | `src/console.zig` | | In-kernel test cases | `src/tests.zig` | | Arch-specific kernel code (`halt`, GDT/IDT/TSS, exception + interrupt stubs, page tables, APIC/timer, serial, linker script) | `src/arch/x86_64/` | diff --git a/docs/heap.md b/docs/heap.md new file mode 100644 index 0000000..b72cf0e --- /dev/null +++ b/docs/heap.md @@ -0,0 +1,75 @@ +# The kernel heap + +The [frame allocator](frame-allocator.md) hands out fixed 4 KiB physical frames; +the [VMM](paging.md) maps pages into virtual addresses. The **kernel heap** sits on +top of both to provide what the rest of the kernel actually wants: `alloc(n)` / +`free(p)` for arbitrary byte sizes. It's the first real consumer of `map()`, and +the thing that unlocks dynamic data structures — lists, hash maps, driver state, +eventually a process table. + +It's generic kernel code (`src/heap.zig`): the allocator logic is +architecture-neutral, using `arch.mapPage` and the frame allocator underneath. + +## A growable free-list allocator + +The algorithm is a classic **first-fit free list**: + +- The heap owns a virtual region. Free space is tracked as an **address-ordered + singly linked list** of free blocks; each block begins with a 16-byte header + (`size`, and a `next` link used while free). +- **alloc(n)** walks the list for the first block big enough. If the block is much + larger it's **split** — the front becomes the allocation, the remainder stays + free. If nothing fits, the heap **grows** (below) and the search retries. +- **free(p)** finds the block header just before `p` and inserts it back into the + list, **coalescing** with the physically adjacent free blocks on either side so + the space can be reused as one region rather than fragmenting away. + +Allocations are 16-byte aligned; larger alignments aren't supported yet (the +`std.mem.Allocator` `alloc` returns `null` for them). + +## Growing on demand + +The heap lives in the **higher half** of the address space (virtual base +`0xFFFF_8000_0000_0000`) — unmapped, well clear of the identity-mapped low half, +and leaving the low half free for a future user address space. (That base is +x86_64-canonical; another architecture would pick its own.) + +When the free list can't satisfy a request, `grow` extends the mapped region: it +pulls fresh frames from the [frame allocator](frame-allocator.md) and `map`s each +onto the end of the heap, then adds the new span as a free block (coalescing with +the current tail). So the heap starts at one page and expands page-by-page as +demand requires, up to a cap. This is exactly what the VMM's on-demand `map` was +built for. + +## A std.mem.Allocator + +The heap is exposed as a **`std.mem.Allocator`** (`heap.allocator()`), Zig's +standard allocator interface. That's a deliberate multiplier: it means the whole of +Zig's standard library — `ArrayList`, `AutoHashMap`, `std.fmt.allocPrint`, and the +rest — works directly on the kernel heap, no bespoke containers required. + +## Verifying it + +The `heap` test (see [testing.md](testing.md)) exercises the allocator end to end: + +``` +[PASS] alloc 4096 bytes +[PASS] heap memory is writable and reads back +[PASS] freed block is reused <- free list + coalescing works +[PASS] many allocations (heap growth) stay valid <- grow() maps fresh frames +[PASS] std.ArrayList on the kernel heap <- std containers work on it +``` + +The "freed block is reused" check (free then re-alloc returns the same address) is +the proof that free and the free list actually work, not just alloc; "heap growth" +forces allocation past the initial page so `grow`/`map` runs; and the `ArrayList` +check is the std-integration payoff. + +## What's next (not done here) + +- **Thread/interrupt safety.** The heap assumes a single caller — no lock yet. + It's safe now (nothing allocates from interrupt handlers), but threads or an + allocating IRQ handler will need a lock (or `cli` around the critical section). +- **Larger alignments** than 16 (for page-aligned buffers, DMA regions). +- **`resize`/`remap` in place**, so growing an `ArrayList` needn't always copy. +- **Reclaiming empty tail pages** back to the frame allocator when the heap shrinks. diff --git a/docs/testing.md b/docs/testing.md index 86d1d1e..d957df0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -48,6 +48,7 @@ Current cases: | `smoke` | memory map has usable RAM; frame alloc/free; paging active | `DANOS-TEST-RESULT: PASS` | | `timer` | device interrupts fire and return (tick count advances) | `DANOS-TEST-RESULT: PASS` | | `vmm` | on-demand `map` works: a mapped page is writable and reads back | `DANOS-TEST-RESULT: PASS` | +| `heap` | kernel heap: alloc/free, block reuse, growth, and a std container on it | `DANOS-TEST-RESULT: PASS` | | `fault-ud` | invalid-opcode exception is caught | serial shows `invalid opcode (vector 6)` | | `fault-pf` | page fault caught with CR2 | `page fault (vector 14)` | | `fault-df` | double fault caught on IST1 (not a triple-fault reset) | `double fault (vector 8)` | diff --git a/src/heap.zig b/src/heap.zig new file mode 100644 index 0000000..910f4e5 --- /dev/null +++ b/src/heap.zig @@ -0,0 +1,174 @@ +//! The kernel heap: dynamic allocation for the kernel. +//! +//! Where the frame allocator ([pmm]) hands out fixed 4 KiB physical frames, the +//! heap hands out arbitrary byte-sized blocks from a virtual region, growing on +//! demand by mapping fresh frames into it (arch.mapPage) — the first real user of +//! the VMM (see docs/paging.md). +//! +//! The algorithm is a first-fit free list: an address-ordered singly linked list +//! of free blocks, split on allocation and coalesced with neighbours on free. It +//! is exposed as a std.mem.Allocator, so the kernel can use std containers. +//! +//! Not yet concurrency-safe: it assumes a single caller and no allocation from +//! interrupt handlers (ours don't). A lock comes with threads/SMP. + +const std = @import("std"); +const danos = @import("danos"); +const arch = @import("arch"); +const pmm = @import("pmm.zig"); + +const page_size = danos.page_size; + +/// Virtual base of the heap: the start of the higher half, which is unmapped and +/// well clear of the identity-mapped low half. (Canonical on x86_64; an arch that +/// splits the address space differently would choose its own.) +const heap_base: usize = 0xFFFF_8000_0000_0000; +/// Cap on heap growth for now. +const heap_max: usize = 64 * 1024 * 1024; + +/// A block header, placed at the start of every block. While the block is free it +/// also links into the free list via `next`. +const Block = extern struct { + size: usize, // total block size in bytes, including this header; a multiple of 16 + next: ?*Block, // free-list link (only meaningful while free) +}; + +const header_size = @sizeOf(Block); // 16 +const min_block = header_size + 16; // smallest block worth splitting off + +var free_list: ?*Block = null; +var heap_end: usize = heap_base; // [heap_base, heap_end) is currently mapped + +fn alignUp(value: usize, alignment: usize) usize { + return (value + alignment - 1) & ~(alignment - 1); +} + +fn payloadOf(block: *Block) [*]u8 { + return @ptrFromInt(@intFromPtr(block) + header_size); +} + +/// Bring the heap up with an initial mapped region. +pub fn init() void { + free_list = null; + heap_end = heap_base; + _ = grow(page_size); +} + +/// Map more pages onto the end of the heap and add them as a free block. Returns +/// false if out of heap virtual space or out of physical frames. +fn grow(min_bytes: usize) bool { + const start = heap_end; + const bytes = alignUp(min_bytes, page_size); + if (start + bytes > heap_base + heap_max) return false; + + var virt = start; + while (virt < start + bytes) : (virt += page_size) { + const frame = pmm.alloc() orelse return false; + arch.mapPage(virt, frame, true); + } + heap_end = start + bytes; + + const block: *Block = @ptrFromInt(start); + block.size = bytes; + insertFree(block); // coalesces with the previous tail block if adjacent + return true; +} + +/// Insert a block into the address-ordered free list, coalescing with the +/// physically adjacent free blocks on either side. +fn insertFree(block: *Block) void { + var prev: ?*Block = null; + var cur = free_list; + while (cur) |c| : (cur = c.next) { + if (@intFromPtr(c) > @intFromPtr(block)) break; + prev = c; + } + + block.next = cur; + if (prev) |p| p.next = block else free_list = block; + + // Merge forward into `cur` if they're contiguous. + if (cur) |c| { + if (@intFromPtr(block) + block.size == @intFromPtr(c)) { + block.size += c.size; + block.next = c.next; + } + } + // Merge `prev` forward into `block` if they're contiguous. + if (prev) |p| { + if (@intFromPtr(p) + p.size == @intFromPtr(block)) { + p.size += block.size; + p.next = block.next; + } + } +} + +/// Allocate `len` bytes (16-byte aligned), or null if out of memory. +fn rawAlloc(len: usize) ?[*]u8 { + const need = alignUp(header_size + len, 16); + + var attempts: u32 = 0; + while (attempts < 2) : (attempts += 1) { + var prev: ?*Block = null; + var cur = free_list; + while (cur) |block| : ({ + prev = block; + cur = block.next; + }) { + if (block.size < need) continue; + + if (block.size >= need + min_block) { + // Split: carve `need` off the front, leave the rest free. + const rest: *Block = @ptrFromInt(@intFromPtr(block) + need); + rest.size = block.size - need; + rest.next = block.next; + if (prev) |p| p.next = rest else free_list = rest; + block.size = need; + } else { + // Take the whole block. + if (prev) |p| p.next = block.next else free_list = block.next; + } + return payloadOf(block); + } + + // Nothing fit: grow and try once more. + if (!grow(need)) return null; + } + return null; +} + +fn rawFree(ptr: [*]u8) void { + const block: *Block = @ptrFromInt(@intFromPtr(ptr) - header_size); + insertFree(block); +} + +// --- std.mem.Allocator interface ----------------------------------------- + +pub fn allocator() std.mem.Allocator { + return .{ .ptr = undefined, .vtable = &vtable }; +} + +const vtable = std.mem.Allocator.VTable{ + .alloc = allocImpl, + .resize = resizeImpl, + .remap = remapImpl, + .free = freeImpl, +}; + +fn allocImpl(_: *anyopaque, len: usize, alignment: std.mem.Alignment, _: usize) ?[*]u8 { + // Blocks are 16-byte aligned; larger alignments aren't supported yet. + if (alignment.toByteUnits() > 16) return null; + return rawAlloc(len); +} + +fn resizeImpl(_: *anyopaque, _: []u8, _: std.mem.Alignment, _: usize, _: usize) bool { + return false; // no in-place resize; the caller reallocates +} + +fn remapImpl(_: *anyopaque, _: []u8, _: std.mem.Alignment, _: usize, _: usize) ?[*]u8 { + return null; +} + +fn freeImpl(_: *anyopaque, memory: []u8, _: std.mem.Alignment, _: usize) void { + rawFree(memory.ptr); +} diff --git a/src/main.zig b/src/main.zig index f2b5c94..de5eb72 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,6 +3,7 @@ const danos = @import("danos"); const arch = @import("arch"); const console = @import("console.zig"); const pmm = @import("pmm.zig"); +const heap = @import("heap.zig"); const tests = @import("tests.zig"); const build_options = @import("build_options"); const BootInfo = danos.BootInfo; @@ -92,10 +93,14 @@ fn kmain(boot_info: *const BootInfo) noreturn { con.print(" page tables: CR3 = 0x{x:0>16}\n", .{arch.readCr3()}); con.print(" kernel segs: {d} (mapped with W^X permissions)\n", .{boot_info.kernel_segment_count}); + // Bring up the kernel heap (dynamic allocation), built on the VMM. + heap.init(); + con.write("\ndanos: kernel heap online\n"); + // Start the timer and unmask interrupts — the kernel now has a heartbeat. arch.startTimer(); arch.enableInterrupts(); - con.write("\ndanos: timer interrupts enabled\n"); + con.write("danos: timer interrupts enabled\n"); // In a test build (`zig build -Dtest-case=`), run that case and stop. // Normal builds fall through to the idle halt. diff --git a/src/tests.zig b/src/tests.zig index 685855f..f2b8bb1 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -13,6 +13,7 @@ const std = @import("std"); const danos = @import("danos"); const arch = @import("arch"); const pmm = @import("pmm.zig"); +const heap = @import("heap.zig"); /// Formatted write straight to serial, independent of the framebuffer console. fn log(comptime fmt: []const u8, args: anytype) void { @@ -50,6 +51,8 @@ pub fn run(case: []const u8, boot_info: *const BootInfo) void { timer(); } else if (eql(case, "vmm")) { vmm(); + } else if (eql(case, "heap")) { + heapTest(); } else if (eql(case, "fault-ud")) { faultInvalidOpcode(); } else if (eql(case, "fault-pf")) { @@ -138,6 +141,69 @@ fn vmm() void { result(); } +/// Exercise the kernel heap: basic alloc/write/free, reuse, growth beyond the +/// initial region, and a std container backed by it. +fn heapTest() void { + log("DANOS-TEST-BEGIN: heap\n", .{}); + const a = heap.allocator(); + + // Allocate, write a pattern, read it back, free. + const buf = a.alloc(u8, 4096) catch null; + check("alloc 4096 bytes", buf != null); + if (buf) |b| { + @memset(b, 0xAB); + check("heap memory is writable and reads back", b[0] == 0xAB and b[4095] == 0xAB); + a.free(b); + } + + // Freeing then re-allocating the same size should reuse the block. + const p1 = a.alloc(u64, 8) catch null; + const addr1 = if (p1) |p| @intFromPtr(p.ptr) else 0; + if (p1) |p| a.free(p); + const p2 = a.alloc(u64, 8) catch null; + const addr2 = if (p2) |p| @intFromPtr(p.ptr) else 0; + check("freed block is reused", addr1 != 0 and addr1 == addr2); + if (p2) |p| a.free(p); + + // Force growth past the initial page and check every block is usable. + var blocks: [64]?[]u8 = .{null} ** 64; + var ok = true; + for (&blocks, 0..) |*slot, i| { + const b = a.alloc(u8, 4096) catch null; + slot.* = b; + if (b) |bb| @memset(bb, @intCast(i & 0xff)) else { + ok = false; + } + } + for (blocks, 0..) |slot, i| { + if (slot) |bb| { + if (bb[0] != @as(u8, @intCast(i & 0xff)) or bb[4095] != @as(u8, @intCast(i & 0xff))) ok = false; + } + } + check("many allocations (heap growth) stay valid", ok); + for (blocks) |slot| { + if (slot) |bb| a.free(bb); + } + + // A std container backed by the kernel heap. + var list: std.ArrayList(u32) = .empty; + var sum: u64 = 0; + var expected: u64 = 0; + var i: u32 = 0; + var list_ok = true; + while (i < 1000) : (i += 1) { + list.append(a, i) catch { + list_ok = false; + }; + expected += i; + } + for (list.items) |v| sum += v; + list.deinit(a); + check("std.ArrayList on the kernel heap", list_ok and sum == expected); + + result(); +} + fn faultInvalidOpcode() void { log("DANOS-TEST-BEGIN: fault-ud\n", .{}); asm volatile ("ud2"); diff --git a/test/qemu_test.py b/test/qemu_test.py index d7c54c7..79a4ea2 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -69,6 +69,9 @@ CASES = [ {"name": "vmm", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, + {"name": "heap", + "expect": r"DANOS-TEST-RESULT: PASS", + "fail": r"DANOS-TEST-RESULT: FAIL"}, {"name": "fault-ud", "expect": r"invalid opcode \(vector 6\)"}, {"name": "fault-pf", "expect": r"page fault \(vector 14\)"}, {"name": "fault-df", "expect": r"double fault \(vector 8\)"},