Memory Management
Physical memory allocator, virtual memory manager, kernel virtual allocator, and per-process VMA tracking
Memory Management
Aegis implements a three-layer memory management architecture: a physical page allocator (PMM), a 4-level virtual memory manager (VMM), and a kernel virtual address allocator (KVA). User processes additionally track their virtual memory areas (VMAs) for mmap/mprotect/munmap support.
v1 maturity notice. The memory management subsystem is written entirely in C and represents v1 quality – functional and tested, but not battle-hardened. The PMM uses a simple bitmap allocator (O(n) scan), the VMM has a single-slot mapped-window allocator protected by a global spinlock, and there is no fault recovery table (
extable) for user-memory access. These are deliberate v1 trade-offs, not permanent design decisions. As a from-scratch C kernel, there are likely exploitable vulnerabilities in the memory management code, as would be expected at this stage. A gradual Rust migration is planned for safety-critical kernel subsystems;kernel/cap/is already in Rust. Contributions are welcome – file issues or propose changes at exec/aegis.
Architecture Overview
+-------------------+
| User processes | VMA tracking (mmap, brk, stack)
+-------------------+
| VMM | 4-level page tables (PML4 -> PDPT -> PD -> PT)
+-------------------+
| KVA | Kernel virtual address bump allocator
+-------------------+
| PMM | Bitmap physical page allocator
+-------------------+
| Physical RAM | Reported by multiboot2, managed as 4KB frames
+-------------------+
Initialization order in kernel_main:
arch_mm_init(mb_info)– Parse multiboot2 memory mappmm_init()– Build bootstrap physical page bitmap (covers first 4 GB)vmm_init()– Build kernel page tables, activate pagingkva_init()– Initialize kernel VA bump allocatorpmm_init_late()– Replace bootstrap bitmap with full RAM-sized bitmap from KVA (no 4 GB cap)
Physical Memory Manager (PMM)
Source: kernel/mm/pmm.c, kernel/mm/pmm.h
Design
The PMM uses a bitmap allocator. Each bit represents one 4KB page. There is no hard ceiling on RAM – the bitmap scales dynamically to the machine’s actual memory.
Two-phase bootstrap (solving the “bitmap needs memory, memory needs bitmap” chicken-and-egg):
pmm_init()uses a static bootstrap bitmap in BSS covering the first 4 GB (128 KB of BSS). This is enough to bring up the VMM and KVA allocator.pmm_init_late()– called afterkva_init()– allocates a full RAM-sized bitmap from KVA, copies the bootstrap state into its low portion, and switches over. Machines with ≤ 4 GB see no change; larger machines gain access to high RAM.
| Parameter | Value |
|---|---|
| Page size | 4096 bytes (PAGE_SIZE) |
| Bootstrap bitmap | 128 KB in BSS; covers first 4 GB |
| Full bitmap | KVA-allocated; covers all physical RAM (no ceiling) |
| Bit semantics | 0 = free, 1 = allocated |
Initialization
pmm_init() follows a five-step process:
- Mark everything reserved – Fill bootstrap bitmap with
0xFF(safe default) - Free usable RAM – Walk
arch_mm_get_regions()(multiboot2 type=1 entries), clear bits for each usable page - Re-reserve platform ranges – Walk
arch_mm_get_reserved_regions():- First 1MB (BIOS data, VGA hole, ISA ROMs)
- Multiboot2 info structure (may be above 1MB)
- Boot modules (rootfs, ESP image)
- Reserve kernel image –
[ARCH_KERNEL_PHYS_BASE, _kernel_end - KERN_VMA), covering.textthrough.bss(including the bootstrap bitmap itself) - Report – Print total usable MB across N regions
After kva_init(), pmm_init_late() replaces the bootstrap bitmap with a full RAM-sized one.
After the ramdisk modules are copied into KVA, pmm_unreserve_region() releases their physical pages back to the PMM (~56 MB reclaimed; logged as [PMM] OK: module pages reclaimed).
Allocation API
uint64_t pmm_alloc_page(void); // Returns physical address, 0 on OOM (prefers low RAM)
uint64_t pmm_alloc_page_low(void); // Always allocates from first 4 GB (DMA-safe zone)
void pmm_free_page(uint64_t addr);
void pmm_ref_page(uint64_t addr); // Increment refcount (COW/memfd sharing)
void pmm_init_late(void); // Switch to full RAM-sized bitmap
uint64_t pmm_unreserve_region(uint64_t base, uint64_t len); // Reclaim boot-reserved pages
uint64_t pmm_total_pages(void);
uint64_t pmm_free_pages(void);
pmm_alloc_page() performs a linear scan with a next-fit hint for the first free bit. O(n) where n = bitmap size. Single-page allocation only – multi-page contiguous allocation is deferred to a future buddy-allocator upgrade.
pmm_alloc_page_low() restricts allocation to physical addresses below 4 GB – required for device DMA buffers whose 64-bit addressing the kernel does not yet trust.
All PMM operations are protected by pmm_lock (spinlock with IRQ save/restore).
Reference Counting
Per-page refcounts support copy-on-write (COW) fork and MAP_SHARED memfd. Rather than a flat 1-byte-per-page array (which would cost 1 MB per 4 GB of RAM), refcounts are stored in a sparse open-addressing hash table (PMM_REFHASH_SIZE = 65536 slots, 576 KB of BSS). Only pages with refcount ≥ 2 have hash-table entries; all other allocated pages implicitly have refcount 1.
| Operation | Refcount effect |
|---|---|
pmm_alloc_page() |
Implicit refcount 1 (no hash entry) |
pmm_ref_page() |
Insert/increment in hash table |
pmm_free_page() |
Decrement; remove entry when count falls to 1; free page when count falls to 0 |
Pages outside the PMM-managed range (e.g., MMIO addresses) are silently skipped by pmm_free_page().
Virtual Memory Manager (VMM)
Source: kernel/mm/vmm.c, kernel/mm/vmm.h
x86-64 Page Table Structure
Aegis uses the standard 4-level x86-64 page table hierarchy:
Virtual Address (48-bit canonical):
+--------+--------+--------+--------+----------+
| PML4 | PDPT | PD | PT | Offset |
| [47:39]| [38:30]| [29:21]| [20:12]| [11:0] |
| 9 bits | 9 bits | 9 bits | 9 bits | 12 bits |
+--------+--------+--------+--------+----------+
PML4 [512 entries]
|
+--[0]----> pdpt_lo [512 entries] (identity map, removed after boot)
| +--[0]----> pd_lo [512 entries]
| +--[0..511] -> 512 x 2MB huge pages
|
+--[511]--> pdpt_hi [512 entries] (higher-half kernel)
+--[510]--> pd_hi [512 entries]
+--[0] -> 2MB huge: PA 0x000000 (kernel .text)
+--[1] -> 2MB huge: PA 0x200000 (kernel cont.)
+--[2] -> 2MB huge: PA 0x400000 (kernel BSS low)
+--[3] -> 2MB huge: PA 0x600000 (kernel BSS high)
+--[4] -> PT: mapped-window allocator (VMM_WINDOW_VA = 0xFFFFFFFF80800000)
+--[5+] -> KVA 4KB pages (KVA_BASE = 0xFFFFFFFF80A00000)
Page Table Entry Flags
Abstract flags defined in vmm.h (translated to hardware PTE bits by arch_pte_from_flags()):
| Flag | Bit | Purpose |
|---|---|---|
VMM_FLAG_PRESENT |
0 | Page is present in memory |
VMM_FLAG_WRITABLE |
1 | Page is writable |
VMM_FLAG_USER |
2 | Page is accessible from ring 3 |
VMM_FLAG_WC |
3 | Write-Combining cache (PWT bit, PAT entry 1) |
VMM_FLAG_UCMINUS |
4 | Uncacheable-minus (PCD bit, PAT entry 2) |
VMM_FLAG_COW |
9 | Copy-on-write marker (OS-available PTE bit) |
VMM_FLAG_NX |
63 | No-execute (requires EFER.NXE) |
Initialization (vmm_init)
vmm_init() builds a fresh set of page tables using the identity map, then switches CR3:
- Allocate 5 page-table pages via
alloc_table_early()(uses identity map to zero pages) - Identity map – PML4[0] -> pdpt_lo[0] -> pd_lo: 512 x 2MB huge pages covering [0..1GB)
- Higher-half map – PML4[511] -> pdpt_hi[510] -> pd_hi: 4 x 2MB huge pages for the kernel (8 MB total, covering the kernel image and BSS)
- Install mapped-window PT into
pd_hi[4]– a 4KB page table backingVMM_WINDOW_VA(0xFFFFFFFF80800000) - Load CR3 with the new PML4 physical address
KVA starts at pd_hi[5] = 0xFFFFFFFF80A00000 (KERN_VMA + 0xA00000).
After this point, both identity and higher-half mappings are active. The identity map is torn down by vmm_teardown_identity() near the end of boot.
Mapped-Window Allocator
The VMM uses a “mapped window” to manipulate page tables without requiring an identity map. This is a fixed virtual address (VMM_WINDOW_VA) whose PTE can be pointed at any physical page:
void *vmm_window_map(uint64_t phys); // Map phys at VMM_WINDOW_VA
void vmm_window_unmap(void); // Clear the mapping
// Two window slots available:
// Slot 0: s_window_pt[0] -> VMM_WINDOW_VA
// Slot 1: s_window_pt[1] -> VMM_WINDOW_VA + 4096
The window PTE pointer (s_window_pte) is declared volatile to ensure writes reach memory before the subsequent invlpg instruction.
Lock ordering: vmm_window_lock > pmm_lock > kva_lock. Code holding vmm_window_lock may acquire pmm_lock, but never the reverse.
Page Mapping
void vmm_map_page(uint64_t virt, uint64_t phys, uint64_t flags);
Walks the 4-level page table using ensure_table_phys(), which:
- Maps the parent table via the window
- Checks if entry at
idxis present -
If absent: allocates a new page-table page, installs it with PRESENT WRITABLE (plus USER for user tables) - Returns the physical address of the child table
The leaf PTE is set to phys | arch_pte_from_flags(flags | PRESENT). Double-mapping (leaf already present) panics.
User Address Spaces
Each user process has its own PML4:
uint64_t vmm_create_user_pml4(void); // New PML4, copies kernel entries [256..511]
void vmm_map_user_page(pml4, virt, phys, flags); // Map in user PML4
void vmm_switch_to(uint64_t pml4_phys); // Load CR3
void vmm_free_user_pml4(uint64_t pml4_phys); // Free user half + PT pages
User page table entries require VMM_FLAG_USER at every level of the walk (PML4e, PDPTe, PDe, PTe). The x86-64 MMU checks the USER bit at each level – a leaf with USER but an ancestor without causes a ring-3 #PF.
Copy-on-Write (COW)
The VMM exposes COW infrastructure for fork(), but the active fork path currently uses eager copy (vmm_copy_user_pages). COW was measured to be a net regression on the current workload (Aegis’s primary fork caller immediately execves), so activation is deferred until a workload motivates re-enabling it:
int vmm_copy_user_pages(uint64_t src_pml4, uint64_t dst_pml4); /* active */
int vmm_cow_user_pages(uint64_t src_pml4, uint64_t dst_pml4); /* infrastructure */
int vmm_cow_fault_handle(uint64_t pml4_phys, uint64_t fault_va); /* infrastructure */
vmm_cow_user_pages() (available, not currently called from sys_fork):
- Clears the W bit and sets
VMM_FLAG_COW(PTE bit 9) on every writable user page in the parent - Installs the same RO+COW mapping in the child (read-only pages are shared as-is)
- Skips MMIO pages (any PTE with
VMM_FLAG_WCorVMM_FLAG_UCMINUS) - Increments per-page refcounts via
pmm_ref_page() - Invalidates the parent’s TLB for each modified page
vmm_cow_fault_handle() (available but not wired into the page fault path): walks the PML4 to the leaf PTE, verifies VMM_FLAG_COW is set, allocates a fresh frame, copies the old contents via the two-window-slot mechanism, and updates the PTE with W set and COW cleared. Returns 0 (handled), -1 (not COW -> SIGSEGV), or -2 (OOM -> SIGBUS).
Because vmm_cow_fault_handle is not currently invoked from isr_dispatch, a write to a COW-marked page would panic the kernel in v1.
Identity Map Teardown
vmm_teardown_identity() is called after all kernel objects are allocated via KVA:
void vmm_teardown_identity(void); // PML4[0] = 0, reload CR3
After this point, physical addresses below KERN_VMA are only accessible through the mapped-window allocator or KVA mappings.
Kernel Virtual Allocator (KVA)
Source: kernel/mm/kva.c, kernel/mm/kva.h
Design
KVA provides a bump allocator for kernel-mode virtual addresses starting at KVA_BASE (KERN_VMA + 0xA00000, i.e., 0xFFFFFFFF80A00000). Each allocation gets contiguous VA space backed by individually-allocated PMM pages. kva_alloc_pages_low() forces DMA-safe (sub-4 GB) physical pages for device buffers.
void *kva_alloc_pages(uint64_t n); // Allocate n 4KB pages
void *kva_map_phys_pages(uint64_t phys, uint32_t n); // Map existing physical pages
void kva_free_pages(void *va, uint64_t n); // Unmap and free
uint64_t kva_page_phys(void *va); // VA -> PA lookup
Freelist
KVA recycles freed VA ranges through a va_freelist_t (defined in kernel/lib/va_freelist.{c,h}), a coalescing best-fit address-range freelist. On allocation, the freelist is checked first (best-fit search); on miss, the bump cursor advances. On free, the range is inserted with coalescing of adjacent entries. The va_freelist_t type is also used by mmap VA management, keeping the two freelists on the same audited code path.
Allocation path:
1. Try freelist (best-fit) -> return VA if hit
2. Bump s_kva_next forward by n * PAGE_SIZE
3. For each page: pmm_alloc_page() + vmm_map_page()
Free path:
1. For each page: vmm_phys_of() -> vmm_unmap_page() -> pmm_free_page()
2. Insert VA range into freelist (coalesce with neighbors)
KVA pages are mapped without VMM_FLAG_USER, so the MMU denies ring-3 access to all kernel objects (TCBs, kernel stacks, driver buffers).
All KVA operations are protected by kva_lock (spinlock with IRQ save/restore).
SMP TLB coherence (cross-CPU shootdown)
KVA lives in the shared higher-half mapping that is identical across every process’s page tables (all PML4s share the higher-half PDPT/PD entries). A KVA virtual address therefore resolves the same on every CPU, and any CPU that touches it can cache the translation in its own TLB.
Because the freelist recycles VA ranges, freeing a range and later re-allocating the same VA to a different physical frame is routine. If the free only invalidated the freeing CPU’s TLB, another CPU that had cached the old VA → old-frame mapping (and did not perform the free) would keep using it after recycling — reading or writing the wrong physical frame. This is acute for kernel stacks and TCBs: a dying task’s stack is freed from the next task’s sched_exit, which may run on a different CPU than the one the dying task last ran on.
Therefore vmm_unmap_page (the kernel-half unmap behind kva_free_pages) performs an all-CPU TLB shootdown via tlb_shootdown_kernel() — it invalidates the range on every online CPU unconditionally (the TLB_TARGET_ALL sentinel; KVA is global, not bound to one address space, so the per-CR3 match used for user-range shootdowns would wrongly skip CPUs). The shootdown runs after vmm_window_lock is dropped (shootdown-deadlock rule) and falls back to a local invlpg when g_cpu_count <= 1. See kernel/arch/x86_64/tlb.c.
Missing this shootdown was the root cause of the SMP concurrent-process-startup image corruption (ld-musl
#GP/#PFundersmp_sched): a recycled KVA range read through a stale per-CPU TLB entry corrupted a freshly-loaded ELF image. Fixed 2026-06-26.
Per-Process VMA Tracking
Source: kernel/mm/vma.c, kernel/mm/vma.h
Design
Each process has a sorted array of vma_entry_t structures tracking its virtual memory regions. The table is allocated as a single KVA page (4096 bytes / 24 bytes per entry = 170 entries max).
typedef struct {
uint64_t base; // Region start VA
uint64_t len; // Region length in bytes
uint32_t prot; // PROT_READ | PROT_WRITE | PROT_EXEC
uint8_t type; // VMA type constant
uint8_t _pad[3];
} vma_entry_t; // 24 bytes
VMA Types
| Constant | Value | Description |
|---|---|---|
VMA_NONE |
0 | Untyped |
VMA_ELF_TEXT |
1 | ELF PT_LOAD with PROT_EXEC |
VMA_ELF_DATA |
2 | ELF PT_LOAD without PROT_EXEC |
VMA_HEAP |
3 | [brk_base..brk] |
VMA_STACK |
4 | User stack |
VMA_MMAP |
5 | Anonymous mmap |
VMA_THREAD_STACK |
6 | Thread stack via pthread_create |
VMA_GUARD |
7 | Guard page (PROT_NONE) |
VMA_SHARED |
8 | MAP_SHARED mapping (phys pages owned by memfd) |
Operations
void vma_init(struct aegis_process *proc); // Allocate table page
void vma_insert(proc, base, len, prot, type); // Insert with merge
void vma_remove(proc, base, len); // Remove with split
void vma_update_prot(proc, base, len, prot); // Change permissions with split
void vma_clear(struct aegis_process *proc); // Clear all entries (execve)
void vma_clone(dst, src); // Deep copy (fork)
void vma_share(child, parent); // Share table (CLONE_VM threads)
void vma_free(struct aegis_process *proc); // Decrement refcount, free if 0
Insert merges with adjacent entries if they have matching prot and type. Remove and update_prot split entries at region boundaries when partial overlap occurs.
The table supports reference counting for CLONE_VM threads: vma_share() increments the refcount and gives the child a pointer to the parent’s table; vma_free() only deallocates when the refcount reaches 0.
User-Kernel Memory Access
Source: kernel/mm/uaccess.h
Aegis uses SMAP (Supervisor Mode Access Prevention) to prevent the kernel from accidentally accessing user memory. Controlled access is gated by STAC/CLAC instructions:
static inline void copy_from_user(void *dst, const void *src, uint64_t len) {
arch_stac(); // Set RFLAGS.AC (permit user access)
__builtin_memcpy(dst, src, len);
arch_clac(); // Clear RFLAGS.AC (re-enable SMAP)
}
static inline void copy_to_user(void *dst, const void *src, uint64_t len) {
arch_stac();
__builtin_memcpy(dst, src, len);
arch_clac();
}
Callers validate user pointers with user_ptr_valid() (which page-walks the range) before calling these functions. Unmapped user pointers return -EFAULT; there is no fault recovery table (Linux extable). A pointer that passes validation but maps to an unmapped page would still panic the kernel – this is a known v1 limitation.
Kernel Library Primitives
kernel/lib/ houses reusable primitives. New subsystem code should use these rather than hand-rolling equivalent data structures.
refcount_t (kernel/lib/refcount.h)
The single reference-count primitive for object lifetime. Wraps an atomic uint32_t with saturating semantics.
void refcount_init(refcount_t *r, uint32_t n);
void refcount_inc(refcount_t *r);
bool refcount_dec_and_test(refcount_t *r); // returns true when count hits 0
uint32_t refcount_read(const refcount_t *r);
bool refcount_inc_not_zero(refcount_t *r); // for weak references
Used by AF_INET sockets, pipes, memfd, AF_UNIX sockets, and PTYs. Distinct from the PMM’s per-frame sparse refcount hash.
ringbuf_t / ringbuf_* (kernel/lib/ringbuf.h)
The single byte-ring primitive. Two shapes:
- Bare-index inlines (
ringbuf_count/space/push/pull) – caller owns the buffer array and head/tail indices; the macro enforces a power-of-two size constraint at compile time. ringbuf_tview – adds explicit count and size fields; used by pipes.
Used by keyboard ring, PTY byte rings, and pipe buffers.
va_freelist_t (kernel/lib/va_freelist.{c,h})
A coalescing best-fit address-range freelist. Used by both KVA (kva_free_pages) and per-process mmap VA management. va_freelist_insert returns false on overflow (fixed-size array) so the caller can log without panicking. va_freelist_alloc does best-fit and removes the winning entry.
Cache Control
The PAT (Page Attribute Table) MSR is programmed by arch_pat_init() during early boot:
| PAT Entry | Index | Type | PTE Encoding |
|---|---|---|---|
| PA0 | 0 | Write-Back (WB) | PWT=0, PCD=0 (default) |
| PA1 | 1 | Write-Combining (WC) | PWT=1, PCD=0 (VMM_FLAG_WC) |
| PA2 | 2 | UC- (weak uncacheable) | PWT=0, PCD=1 (VMM_FLAG_UCMINUS) |
| PA3 | 3 | Uncacheable (UC) | PWT=1, PCD=1 (PCIe ECAM) |
The framebuffer is mapped with VMM_FLAG_WC for write-combining performance. PCIe ECAM configuration space uses strong UC (PA3) with PWT|PCD set in the PTE.
See Also
- Boot Process – Memory subsystem initialization order
- Interrupts & Exceptions – Page fault handling, CR3 switching in ISR
- Processes & ELF – Process address space layout, execve loading