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:

  1. arch_mm_init(mb_info) – Parse multiboot2 memory map
  2. pmm_init() – Build bootstrap physical page bitmap (covers first 4 GB)
  3. vmm_init() – Build kernel page tables, activate paging
  4. kva_init() – Initialize kernel VA bump allocator
  5. pmm_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):

  1. 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.
  2. pmm_init_late() – called after kva_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:

  1. Mark everything reserved – Fill bootstrap bitmap with 0xFF (safe default)
  2. Free usable RAM – Walk arch_mm_get_regions() (multiboot2 type=1 entries), clear bits for each usable page
  3. 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)
  4. Reserve kernel image[ARCH_KERNEL_PHYS_BASE, _kernel_end - KERN_VMA), covering .text through .bss (including the bootstrap bitmap itself)
  5. 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:

  1. Allocate 5 page-table pages via alloc_table_early() (uses identity map to zero pages)
  2. Identity map – PML4[0] -> pdpt_lo[0] -> pd_lo: 512 x 2MB huge pages covering [0..1GB)
  3. 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)
  4. Install mapped-window PT into pd_hi[4] – a 4KB page table backing VMM_WINDOW_VA (0xFFFFFFFF80800000)
  5. 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:

  1. Maps the parent table via the window
  2. Checks if entry at idx is present
  3. If absent: allocates a new page-table page, installs it with PRESENT WRITABLE (plus USER for user tables)
  4. 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_WC or VMM_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/#PF under smp_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_t view – 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