Built heap allocation

This commit is contained in:
2026-07-03 13:41:55 +01:00
parent 20b4661ff3
commit 269729f2f2
7 changed files with 334 additions and 5 deletions
+9 -4
View File
@@ -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/` |
+75
View File
@@ -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.
+1
View File
@@ -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)` |