Room to grow
Milestone 8 of my AI-assisted OS build: the heap allocator. The kernel gains dynamic memory — Vec, Box, and String start working with no operating system underneath.
Contents
Milestone 8 — the heap allocator. The kernel gains dynamic memory: Vec,
Box, and String start working with no operating system underneath.
Everything the kernel has allocated so far was fixed-size and known at compile
time — the stack, the static page tables, the frame bitmap. This milestone adds
the thing that lets data structures grow at runtime: a heap, and with it
Rust’s alloc crate. It sits directly on the last two milestones — a virtual
region whose pages Milestone 7 maps onto
Milestone 6 frames — completing the stack
the memory work has been building: frames → pages → heap.
The goal
The one thing I wanted to understand: how Vec, Box, and String actually
get their memory on bare metal — that they all bottom out in one GlobalAlloc
trait the kernel implements, backed by a region of virtual memory the kernel
mapped for itself. This is the milestone where alloc — dynamic memory —
becomes available with no OS underneath.
“Done” as observable behavior, over serial: push to a Vec until it
reallocates (grows past its initial capacity) and print its contents; allocate
a Box<T> and a String and print them; and — the honest one — free and
re-allocate and show the memory come back. A marker M8: heap online, then
[ok] Vec grew to N; Box and String work; freed memory was reused.
The smallest version that still teaches it: a fixed-size heap region (a megabyte
or so) mapped eagerly at init via M7’s map_page onto M6 frames, plus a
#[global_allocator]. The real teaching content is the plumbing —
extern crate alloc, the GlobalAlloc impl, the alloc-error handler, and the
heap-on-mapped-pages wiring — not a fancy algorithm. The open question going in:
a bump allocator (trivial, but leaks on every free) vs. a simple
free-list (reclaims). M6 rejected a bump allocator precisely because we want
reclaim; the heap deserves the same honesty.
Working vs. accidentally working
A bump allocator makes Vec::push “work” — it grows — while leaking every
reallocation, so “Vec works” is not proof of a real allocator. Guards:
- Allocate,
drop, allocate again, and show the second allocation reuses the freed address. That’s what separates an allocator from a bump pointer. - Force an allocation whose
Layoutdemands over-alignment (e.g. 64 bytes) and confirm the returned pointer is actually aligned — aGlobalAllocthat ignoresalignlooks fine until something needs it. - Allocate a buffer that spans several pages and read it all back, proving the whole heap region is mapped, not just its first page.
What could stall this for days? My own plan’s words: “getting Vec to work
felt bigger than it should.” The no_std + alloc ceremony on stable Rust:
extern crate alloc, #[global_allocator], and especially the alloc-error
handler (historically nightly-only via #[alloc_error_handler]; modern stable
provides a default — but if the toolchain wants a feature or a handler, that’s
the classic time-sink). And GlobalAlloc is unsafe returning a raw
*mut u8: a misalignment, off-by-one, or a pointer into unmapped memory
corrupts the heap and crashes far from the cause — the same silent-and-delayed
class as paging. Tools: serial-print every alloc address, make debug / GDB.
Scope
Deliberately minimal. A linked-list free-list, not a buddy or slab allocator
with coalescing — those solve performance we can’t measure yet. No bump
allocator (it leaks every reallocation; M6 rejected one for the same reason).
Demand-paging the heap (lazy) is deferred — eager mapping is simpler and M7’s
#PF handler halts. Per-process heaps and a userspace malloc are
Milestone 13. One kernel heap, fixed region, one honest allocator, no new crate
dependency — hand-rolled in the spin-only, no-x86_64 house style.
The design
A new src/heap.rs plus the alloc-crate wiring in lib.rs:
heap::init()reserves a 1 MiB virtual region at0x4000_0000(1 GiB — the first byte past the identity map, M7’smap_pageas its first real customer) and eagerly maps every page onto an M6 frame.- The allocator is a linked-list free-list: each free region stores a
ListNode { size, next }inside its own bytes — the intrusive list M6 wanted for frames but couldn’t use until M7 gave us mapped memory to hold the links. First-fit with splitting and alignment;deallocpushes the block back, so it reclaims. - Behind
spin::Mutexin aLockedHeapnewtype, becauseGlobalAlloctakes&selfand the orphan rule forbidsimpl GlobalAlloc for Mutex<…>.
The build order isolated the historical time-sink (the linker/alloc ceremony)
from the algorithm: a throwaway bump allocator first, to prove it links and
Box/Vec work, then the free-list.
What got built
src/heap.rs: the free-list allocator,heap::init(map the region + hand it to the allocator), andheap::self_test.src/lib.rs:extern crate alloc;,mod heap;, and — that’s the whole ceremony on stable.src/paging.rs:WRITABLEmade public so the heap can map writable pages.- The
Makefileheadless boot test now assertsM8: kernel heap online.
What I verified — and what I didn’t
Boot-tested live under QEMU. The serial log:
[ok] heap: 1024 KiB mapped at 0x000040000000 (256 pages)
M8: kernel heap online
[ok] heap: Box, Vec (grown), String, and a 4-page Vec all allocate
[ok] heap: freed 0x000040000ff0 and got the same address back (reclaim works)
The Vec is pushed past its capacity (forcing a real reallocation), the 4-page
Vec proves the whole region is mapped, and the reclaim line is the honest one:
free an allocation, allocate again, get the same address back. A bump
allocator would return a fresh, higher address and fail that assert — which is
exactly why it was rejected.
What broke — and what didn’t
Plan vs. reality. The plan was accurate and the build-order de-risking
worked: the ceremony linked on the first try, isolated from the allocator by a
throwaway bump. The one naive spot the review caught: the plan’s frame
accounting was off by two — it reasoned about M8 in isolation and forgot that
M7’s self-test had already leaked exactly the PD+PT for 0x4000_0000 that the
heap then reuses, so init costs 256 frames, not 258.
What broke, and for how long. Nothing. The ceremony linked, the bump worked, the free-list worked, reclaim passed. Near-zero debugging — and that’s the milestone’s real lesson.
The assumption worth re-examining (the lede). This milestone’s reputation —
“getting Vec to work felt bigger than it should” — is a stale artifact of
the nightly era. Before Rust 1.68 a no_std build that used alloc failed to
link without a hand-written #[alloc_error_handler], gated behind a nightly
feature — that was the pain. On the pinned stable toolchain the research
fan-out found the whole ceremony is now two lines: extern crate alloc; and
#[global_allocator]. An OOM just panics through the panic handler we already
have. A milestone’s fearsome reputation can be a version-old ghost; checking
beats budgeting for it.
What earned its keep. The research fan-out, again — one agent’s cited answer
(the default alloc-error handler, stable since 1.68) meant I never wrote the
doomed nightly ceremony. And the build-order split: proving the linker/alloc
plumbing with a trivial bump allocator meant that when I added ~150 lines of
free-list, any new failure could only be the algorithm, not the plumbing.
One thing to do differently. When a milestone builds directly on the previous one’s memory, trace the previous one’s actual end-state, not its intended one — M7’s deliberate two-table leak changed M8’s frame math, and the review shouldn’t be where that coupling first surfaces.
Takeaway for the next person
A heap is just a pool of memory you mapped, plus a bookkeeper that hands out and
takes back pieces of it. Vec, Box, and String aren’t magic — they all call
one trait, GlobalAlloc, and on bare metal you are the implementation, over
memory you mapped onto frames you found. The honest test that you built a
real allocator and not a leak is a single line: free something, allocate again,
and get the same address back. Next: the memory stack is complete (frames →
pages → heap), and Milestone 9 — a timer
and the first taste of scheduling — starts building on top of it.
Newsletter
Liked this? Get the next one.
One essay or short note every other week — privacy-first software, AI, security, and the occasional dispatch from the trail. No filler.
More writing
The illusion of memory
Milestone 7 of my AI-assisted OS build: paging and virtual memory. The kernel stops using the map the bootloader handed it and starts drawing its own — and survives the CR3 switch on the first try.
ReadIt knows what it has now
Milestone 6 of my AI-assisted OS build: physical memory management. The kernel finally learns how much RAM the machine has, and hands it out one 4 KiB frame at a time.
ReadIt Listens Now
Milestone 5 of my from-scratch Rust kernel: PS/2 keyboard input — the first time the machine reacts to the outside world instead of just talking at it. Remapping the PIC, enabling interrupts for the very first time, and learning why forgetting one EOI makes the keyboard die after exactly one keypress.
Read