feat: overhaul userspace heap and virtual memory

This commit is contained in:
2026-08-10 12:25:27 +02:00
parent 4ecec32c5c
commit 9b23d99082
21 changed files with 905 additions and 451 deletions
+4
View File
@@ -229,6 +229,10 @@ namespace montauk::abi {
static constexpr uint64_t SYS_WIFI_CONNECT_ASYNC = 164; // (ssid, password) -> 0 accepted, <0 on error
static constexpr uint64_t SYS_NETIFS = 165; // (NetIfInfo*, maxCount) -> count
static constexpr uint64_t SYS_GETCHAR_NB = 166; // () -> ascii, 0 if nothing pending; never blocks
static constexpr uint64_t SYS_UTIME = 167;
static constexpr uint64_t SYS_MMAP_ANON = 168;
static constexpr uint64_t SYS_MUNMAP = 169;
static constexpr uint64_t SYS_MPROTECT = 170;
// Tunable parameters (for SYS_SDR_SETPARAM / SYS_SDR_GETPARAM).
static constexpr int SDR_PARAM_FREQ = 0; // center frequency, Hz
+4
View File
@@ -190,6 +190,10 @@ extern "C" {
#define MTK_SYS_WIFI_CONNECT_ASYNC 164
#define MTK_SYS_NETIFS 165
#define MTK_SYS_GETCHAR_NB 166
#define MTK_SYS_UTIME 167
#define MTK_SYS_MMAP_ANON 168
#define MTK_SYS_MUNMAP 169
#define MTK_SYS_MPROTECT 170
/* @SYSCALLS-END */
#define MTK_SOCK_TCP 1
+2 -4
View File
@@ -10,10 +10,8 @@ extern "C" {
#endif
/*
* Anonymous-memory mmap over SYS_ALLOC. SYS_ALLOC returns
* page-aligned process memory, which is exactly what callers like
* GCC's page allocator need. File-backed mappings are not
* supported and fail with ENODEV.
* Demand-paged anonymous memory backed by the kernel's VMA subsystem.
* File-backed and shared mappings are not supported and fail cleanly.
*/
#define PROT_NONE 0
+20 -271
View File
@@ -1,290 +1,39 @@
/*
* heap.h
* Userspace heap allocator for MontaukOS programs
* Copyright (c) 2025 Daniel Hammer
* Unified userspace heap API for MontaukOS programs
* Copyright (c) 2025-2026 Daniel Hammer
*/
#pragma once
#include <montauk/syscall.h>
#include <montauk/string.h>
#include <cstddef>
#include <cstdint>
// The allocator lives in libc. Keeping these declarations here lets
// freestanding C++ programs use the Montauk API without pulling in all of
// <stdlib.h>, while ensuring C, C++, and libraries share one heap.
extern "C" {
void* malloc(std::size_t size);
void free(void* ptr);
void* realloc(void* ptr, std::size_t size);
void* calloc(std::size_t count, std::size_t size);
}
namespace montauk {
namespace heap_detail {
static constexpr uint64_t HEADER_MAGIC = 0x5A484541; // "ZHEA"
static constexpr uint64_t FREED_MAGIC = 0xDEADFEEE;
struct Header {
uint64_t magic;
uint64_t size; // user-requested size
} __attribute__((packed));
struct FreeNode {
uint64_t size; // total size of this free block (including node)
FreeNode* next;
};
// Segregated free lists: power-of-2 size classes for blocks <= 4096 bytes.
// Blocks larger than 4096 go to the overflow list.
static constexpr int NUM_BUCKETS = 8;
static constexpr uint64_t BUCKET_SIZES[NUM_BUCKETS] = {
32, 64, 128, 256, 512, 1024, 2048, 4096
};
// Per-process heap state — must be `inline` (not `static`) so that all
// translation units in a multi-TU program share a single heap.
inline FreeNode* g_buckets[NUM_BUCKETS] = {};
inline FreeNode g_overflow{0, nullptr};
inline bool g_initialized = false;
// Process-wide heap lock. Userspace threads share the heap, so the
// public malloc/mfree/realloc entry points must serialize access to
// g_buckets/g_overflow. Kept inline here (not in thread.h) because
// thread.h depends on heap.h, and the internal helpers below are not
// reentrant into the public API, so a plain spinlock suffices.
inline volatile uint32_t g_heap_lock = 0;
static inline void heap_lock_acquire() {
while (__atomic_exchange_n(&g_heap_lock, 1, __ATOMIC_ACQUIRE) != 0) {
syscall0(montauk::abi::SYS_YIELD);
}
}
static inline void heap_lock_release() {
__atomic_store_n(&g_heap_lock, 0, __ATOMIC_RELEASE);
}
static inline Header* get_header(void* block) {
return (Header*)((uint8_t*)block - sizeof(Header));
}
// Determine which bucket a block size belongs to, or -1 for overflow
static inline int bucket_index(uint64_t blockSize) {
if (blockSize <= 32) return 0;
if (blockSize <= 64) return 1;
if (blockSize <= 128) return 2;
if (blockSize <= 256) return 3;
if (blockSize <= 512) return 4;
if (blockSize <= 1024) return 5;
if (blockSize <= 2048) return 6;
if (blockSize <= 4096) return 7;
return -1;
}
// Insert into overflow list (sorted by address, with adjacent-block coalescing)
static inline void insert_overflow(void* ptr, uint64_t size) {
auto* node = (FreeNode*)ptr;
node->size = size;
FreeNode* prev = &g_overflow;
FreeNode* cur = g_overflow.next;
while (cur != nullptr && cur < node) {
prev = cur;
cur = cur->next;
}
bool merged_prev = false;
if (prev != &g_overflow &&
(uint8_t*)prev + prev->size == (uint8_t*)node) {
prev->size += size;
node = prev;
merged_prev = true;
}
if (cur != nullptr &&
(uint8_t*)node + node->size == (uint8_t*)cur) {
node->size += cur->size;
node->next = cur->next;
if (!merged_prev) prev->next = node;
} else if (!merged_prev) {
node->next = cur;
prev->next = node;
}
}
// Take a block of at least `needed` bytes from the overflow list.
// Splits remainder back into overflow if worthwhile.
static inline void* take_from_overflow(uint64_t needed) {
FreeNode* prev = &g_overflow;
FreeNode* cur = g_overflow.next;
while (cur != nullptr) {
if (cur->size >= needed) {
uint64_t blockSize = cur->size;
prev->next = cur->next;
if (blockSize > needed + sizeof(FreeNode) + 16) {
insert_overflow((uint8_t*)cur + needed, blockSize - needed);
}
return (void*)cur;
}
prev = cur;
cur = cur->next;
}
return nullptr;
}
// Next slab size for heap growth. The kernel tracks a finite number
// of SYS_ALLOC records per process (MaxHeapAllocs), so growing once
// per large allocation exhausts them under allocation-heavy loads
// (the native ld ran out mid-link). Doubling slabs keep the syscall
// count logarithmic in total heap size.
inline uint64_t g_grow_slab = 16 * 0x1000;
static inline bool grow(uint64_t bytes) {
uint64_t want = (bytes + 0xFFF) & ~0xFFFULL;
if (want < 0x4000) want = 0x4000;
uint64_t slab = (want > g_grow_slab) ? want : g_grow_slab;
if (g_grow_slab < 4 * 1024 * 1024) g_grow_slab *= 2;
void* mem = montauk::alloc(slab);
if (mem == nullptr && slab > want) {
// Big slab refused (low memory): retry with the exact need.
slab = want;
mem = montauk::alloc(slab);
}
if (mem == nullptr) return false;
insert_overflow(mem, slab);
return true;
}
// Refill a small-block bucket by carving a page-sized chunk from overflow
static inline bool refill_bucket(int idx) {
uint64_t bsize = BUCKET_SIZES[idx];
uint64_t chunk = (bsize < 4096) ? 4096 : bsize;
void* block = take_from_overflow(chunk);
if (block == nullptr) {
if (!grow(chunk)) return false;
block = take_from_overflow(chunk);
if (block == nullptr) return false;
}
uint64_t count = chunk / bsize;
for (uint64_t i = 0; i < count; i++) {
auto* node = (FreeNode*)((uint8_t*)block + i * bsize);
node->size = bsize;
node->next = g_buckets[idx];
g_buckets[idx] = node;
}
return true;
}
} // namespace heap_detail
// ---- Public API ----
inline void* malloc(uint64_t size) {
using namespace heap_detail;
// Guard against overflow: size + Header must not wrap
if (size > UINT64_MAX - sizeof(Header) - 15)
return nullptr;
heap_lock_acquire();
if (!g_initialized) {
grow(16 * 0x1000); // seed with 64 KiB
g_initialized = true;
}
uint64_t needed = size + sizeof(Header);
needed = (needed + 15) & ~15ULL;
int idx = bucket_index(needed);
if (idx >= 0) {
// Small allocation — use segregated bucket (O(1))
if (g_buckets[idx] == nullptr && !refill_bucket(idx)) {
heap_lock_release();
return nullptr;
}
FreeNode* node = g_buckets[idx];
g_buckets[idx] = node->next;
Header* header = (Header*)node;
header->magic = HEADER_MAGIC;
header->size = size;
heap_lock_release();
return (void*)((uint8_t*)header + sizeof(Header));
}
// Large allocation — search overflow list
void* block = take_from_overflow(needed);
if (block == nullptr) {
if (!grow(needed)) { heap_lock_release(); return nullptr; }
block = take_from_overflow(needed);
if (block == nullptr) { heap_lock_release(); return nullptr; }
}
Header* header = (Header*)block;
header->magic = HEADER_MAGIC;
header->size = size;
heap_lock_release();
return (void*)((uint8_t*)header + sizeof(Header));
return ::malloc((std::size_t)size);
}
inline void mfree(void* ptr) {
using namespace heap_detail;
if (ptr == nullptr) return;
Header* header = get_header(ptr);
heap_lock_acquire();
if (header->magic == FREED_MAGIC) { heap_lock_release(); return; } // double-free
if (header->magic != HEADER_MAGIC) { heap_lock_release(); return; } // corrupt
header->magic = FREED_MAGIC;
uint64_t blockSize = header->size + sizeof(Header);
blockSize = (blockSize + 15) & ~15ULL;
int idx = bucket_index(blockSize);
if (idx >= 0) {
// Small block — push onto bucket (O(1))
auto* node = (FreeNode*)header;
node->size = BUCKET_SIZES[idx];
node->next = g_buckets[idx];
g_buckets[idx] = node;
} else {
// Large block — sorted insert with coalescing
insert_overflow((void*)header, blockSize);
}
heap_lock_release();
::free(ptr);
}
inline void* realloc(void* ptr, uint64_t size) {
if (ptr == nullptr) return malloc(size);
return ::realloc(ptr, (std::size_t)size);
}
// Read old size under the lock to avoid racing with another
// thread that might be freeing/recycling this header.
heap_detail::heap_lock_acquire();
auto* header = heap_detail::get_header(ptr);
uint64_t old = header->size;
uint64_t oldBlock = (old + sizeof(heap_detail::Header) + 15) & ~15ULL;
int idx = heap_detail::bucket_index(oldBlock);
if (idx >= 0) oldBlock = heap_detail::BUCKET_SIZES[idx];
uint64_t newNeed = (size + sizeof(heap_detail::Header) + 15) & ~15ULL;
if (newNeed <= oldBlock) {
header->size = size;
heap_detail::heap_lock_release();
return ptr;
}
heap_detail::heap_lock_release();
void* newBlock = malloc(size);
if (newBlock == nullptr) return nullptr;
uint64_t copySize = (old < size) ? old : size;
memcpy(newBlock, ptr, copySize);
mfree(ptr);
return newBlock;
inline void* calloc(uint64_t count, uint64_t size) {
return ::calloc((std::size_t)count, (std::size_t)size);
}
} // namespace montauk
+47 -9
View File
@@ -44,8 +44,22 @@ namespace montauk {
ThreadEntry user_entry;
void* user_arg;
void* stack_base;
int tid;
ThreadCtx* next;
};
inline ThreadCtx* g_thread_records = nullptr;
inline volatile uint32_t g_thread_records_lock = 0;
inline void records_lock() {
while (__atomic_exchange_n(&g_thread_records_lock, 1, __ATOMIC_ACQUIRE) != 0)
montauk::yield();
}
inline void records_unlock() {
__atomic_store_n(&g_thread_records_lock, 0, __ATOMIC_RELEASE);
}
// Userspace trampoline: bridges from the raw entry the kernel jumps
// to into the typed entry, then funnels into SYS_THREAD_EXIT. We
// route the exit through libc rather than relying on a kernel-side
@@ -53,21 +67,24 @@ namespace montauk {
// memory on this path.
//
// The thread's stack itself is intentionally not freed here: we are
// still running on it. It is reclaimed when the process exits, or
// the joiner may free it explicitly after thread_join.
// still running on it. It is reclaimed by a successful thread_join,
// or as part of whole-process teardown if the thread is never joined.
[[noreturn]] inline void thread_trampoline(detail::ThreadCtx* ctx) {
// A sibling CPU can start the thread before thread_spawn has
// returned its TID. Wait until the parent has published the record
// needed by thread_join to reclaim this stack.
while (__atomic_load_n(&ctx->tid, __ATOMIC_ACQUIRE) == 0)
montauk::yield();
int code = ctx->user_entry(ctx->user_arg);
montauk::mfree(ctx);
thread_exit(code);
}
}
// Spawn a new thread that begins executing `entry(arg)`. Returns the
// new TID on success, or -1 on failure. The thread's stack is
// allocated from the user heap; it is leaked on thread exit (the
// thread itself cannot free the stack it is running on). The kernel
// reclaims it on process exit. Callers that need to spawn many short-
// lived threads should pool stacks themselves.
// allocated from the user heap. The exiting thread cannot free the stack
// it is running on, so thread_join reclaims both it and the trampoline
// context after the kernel has reaped the sibling.
inline int thread_spawn(ThreadEntry entry, void* arg,
uint64_t stack_bytes = 0) {
if (entry == nullptr) return -1;
@@ -84,6 +101,8 @@ namespace montauk {
ctx->user_entry = entry;
ctx->user_arg = arg;
ctx->stack_base = stack;
ctx->tid = 0;
ctx->next = nullptr;
uint64_t stack_top = ((uint64_t)stack + stack_bytes) & ~0xFULL;
int tid = (int)syscall3(montauk::abi::SYS_THREAD_SPAWN,
@@ -94,6 +113,11 @@ namespace montauk {
montauk::mfree(stack);
return -1;
}
detail::records_lock();
ctx->next = detail::g_thread_records;
detail::g_thread_records = ctx;
__atomic_store_n(&ctx->tid, tid, __ATOMIC_RELEASE);
detail::records_unlock();
return tid;
}
@@ -101,8 +125,22 @@ namespace montauk {
// success (with the thread's exit code in *out_code if non-null) or
// -1 if `tid` is not a joinable sibling.
inline int thread_join(int tid, int* out_code = nullptr) {
return (int)syscall2(montauk::abi::SYS_THREAD_JOIN,
(uint64_t)tid, (uint64_t)out_code);
int result = (int)syscall2(montauk::abi::SYS_THREAD_JOIN,
(uint64_t)tid, (uint64_t)out_code);
if (result == 0) {
detail::records_lock();
detail::ThreadCtx** link = &detail::g_thread_records;
while (*link != nullptr && (*link)->tid != tid)
link = &(*link)->next;
detail::ThreadCtx* ctx = *link;
if (ctx != nullptr) *link = ctx->next;
detail::records_unlock();
if (ctx != nullptr) {
montauk::mfree(ctx->stack_base);
montauk::mfree(ctx);
}
}
return result;
}
// Return the calling thread's TID (== getpid() for the main thread).
+189 -65
View File
@@ -89,6 +89,7 @@ static inline long _zos_syscall4(long nr, long a1, long a2, long a3, long a4) {
/* Syscall numbers */
#define SYS_EXIT 0
#define SYS_YIELD 1
#define SYS_SLEEP_MS 2
#define SYS_PRINT 4
#define SYS_PUTCHAR 5
@@ -119,6 +120,9 @@ static inline long _zos_syscall4(long nr, long a1, long a2, long a3, long a4) {
#define SYS_KILL 62
#define SYS_STAT 152
#define SYS_UTIME 167
#define SYS_MMAP_ANON 168
#define SYS_MUNMAP 169
#define SYS_MPROTECT 170
/* ========================================================================
errno
@@ -626,44 +630,74 @@ int tolower(int c) { return (c >= 'A' && c <= 'Z') ? c + 32 : c; }
Heap allocator (free-list, backed by SYS_ALLOC)
======================================================================== */
#define HEAP_MAGIC 0x5A484541ULL /* "ZHEA" */
#define FREED_MAGIC 0xDEADFEEEULL
#define HEAP_MAGIC 0x4D544B4845415041ULL /* "MTKHEAPA" */
#define DIRECT_MAGIC 0x4D544B4449524543ULL /* "MTKDIREC" */
#define FREED_MAGIC 0x4D544B4652454544ULL /* "MTKFREED" */
#define HEAP_ALIGN 16ULL
#define DIRECT_THRESHOLD (256ULL * 1024ULL)
struct HeapHeader {
uint64_t magic;
uint64_t size;
} __attribute__((packed));
uint64_t requested_size;
uint64_t block_size;
uint64_t cookie;
};
struct FreeNode {
uint64_t magic;
uint64_t size;
struct FreeNode *next;
};
/* Segregated free lists: power-of-2 size classes for blocks <= 4096 bytes */
#define NUM_BUCKETS 8
/* Segregated free lists for small and medium blocks. The minimum class must
hold both an allocated header and a free-list node. */
#define NUM_BUCKETS 7
static const uint64_t BUCKET_SIZES[NUM_BUCKETS] = {
32, 64, 128, 256, 512, 1024, 2048, 4096
64, 128, 256, 512, 1024, 2048, 4096
};
static struct FreeNode *g_buckets[NUM_BUCKETS] = {};
static struct FreeNode g_overflow = { 0, NULL };
static struct FreeNode g_overflow = { FREED_MAGIC, 0, NULL };
static int g_heapInit = 0;
static volatile uint32_t g_heapLock = 0;
static void heap_lock(void) {
while (__atomic_exchange_n(&g_heapLock, 1, __ATOMIC_ACQUIRE) != 0)
_zos_syscall0(SYS_YIELD);
}
static void heap_unlock(void) {
__atomic_store_n(&g_heapLock, 0, __ATOMIC_RELEASE);
}
static int heap_total_size(size_t requested, uint64_t *out) {
uint64_t value = (uint64_t)requested;
if (value > UINT64_MAX - sizeof(struct HeapHeader) - (HEAP_ALIGN - 1))
return 0;
*out = (value + sizeof(struct HeapHeader) + (HEAP_ALIGN - 1))
& ~(HEAP_ALIGN - 1);
return 1;
}
static uint64_t heap_cookie(const struct HeapHeader *hdr, uint64_t block_size) {
return ((uint64_t)(uintptr_t)hdr >> 4) ^ block_size ^ hdr->magic;
}
static int heap_bucket_index(uint64_t blockSize) {
if (blockSize <= 32) return 0;
if (blockSize <= 64) return 1;
if (blockSize <= 128) return 2;
if (blockSize <= 256) return 3;
if (blockSize <= 512) return 4;
if (blockSize <= 1024) return 5;
if (blockSize <= 2048) return 6;
if (blockSize <= 4096) return 7;
if (blockSize <= 64) return 0;
if (blockSize <= 128) return 1;
if (blockSize <= 256) return 2;
if (blockSize <= 512) return 3;
if (blockSize <= 1024) return 4;
if (blockSize <= 2048) return 5;
if (blockSize <= 4096) return 6;
return -1;
}
/* Insert into overflow list (sorted by address, with coalescing) */
static void heap_insert_overflow(void *ptr, uint64_t size) {
struct FreeNode *node = (struct FreeNode *)ptr;
node->magic = FREED_MAGIC;
node->size = size;
struct FreeNode *prev = &g_overflow;
@@ -693,7 +727,7 @@ static void heap_insert_overflow(void *ptr, uint64_t size) {
}
/* Take a block >= needed from overflow. Splits remainder back. */
static void *heap_take_overflow(uint64_t needed) {
static void *heap_take_overflow(uint64_t needed, uint64_t *actual_size) {
struct FreeNode *prev = &g_overflow;
struct FreeNode *cur = g_overflow.next;
@@ -702,9 +736,13 @@ static void *heap_take_overflow(uint64_t needed) {
uint64_t blockSize = cur->size;
prev->next = cur->next;
if (blockSize > needed + sizeof(struct FreeNode) + 16) {
uint64_t min_free = (sizeof(struct FreeNode) + HEAP_ALIGN - 1)
& ~(HEAP_ALIGN - 1);
if (blockSize >= needed + min_free) {
heap_insert_overflow((uint8_t *)cur + needed, blockSize - needed);
blockSize = needed;
}
*actual_size = blockSize;
return (void *)cur;
}
prev = cur;
@@ -713,11 +751,8 @@ static void *heap_take_overflow(uint64_t needed) {
return NULL;
}
/* Next slab size for heap growth. The kernel tracks a finite number of
SYS_ALLOC records per process (MaxHeapAllocs), so growing once per
large allocation exhausts them: ld ran out mid-link and BFD reported
the resulting NULL mallocs as "file format not recognized". Doubling
slabs keep the syscall count logarithmic in total heap size. */
/* Next slab size for heap growth. Doubling slabs keeps syscall and VMA
metadata traffic logarithmic in total heap size. */
static uint64_t g_heap_slab = 16 * 0x1000;
static void heap_grow(uint64_t bytes) {
@@ -742,16 +777,24 @@ static int heap_refill_bucket(int idx) {
uint64_t bsize = BUCKET_SIZES[idx];
uint64_t chunk = (bsize < 4096) ? 4096 : bsize;
void *block = heap_take_overflow(chunk);
uint64_t actual = 0;
void *block = heap_take_overflow(chunk, &actual);
if (block == NULL) {
heap_grow(chunk);
block = heap_take_overflow(chunk);
block = heap_take_overflow(chunk, &actual);
if (block == NULL) return 0;
}
/* A small unsplittable tail belongs to the overflow list, rather than
disappearing when the main chunk is carved into bucket blocks. */
if (actual - chunk >= ((sizeof(struct FreeNode) + HEAP_ALIGN - 1)
& ~(HEAP_ALIGN - 1)))
heap_insert_overflow((uint8_t *)block + chunk, actual - chunk);
uint64_t count = chunk / bsize;
for (uint64_t i = 0; i < count; i++) {
struct FreeNode *node = (struct FreeNode *)((uint8_t *)block + i * bsize);
node->magic = FREED_MAGIC;
node->size = bsize;
node->next = g_buckets[idx];
g_buckets[idx] = node;
@@ -759,19 +802,31 @@ static int heap_refill_bucket(int idx) {
return 1;
}
void *malloc(size_t size) {
static void *heap_malloc_locked(size_t size) {
uint64_t needed;
if (!heap_total_size(size, &needed))
return NULL;
/* Large objects get their own page mapping, so free can promptly return
both physical memory and virtual space to the kernel. */
if (needed >= DIRECT_THRESHOLD) {
if (needed > UINT64_MAX - 0xFFFULL) return NULL;
uint64_t mapping_size = (needed + 0xFFFULL) & ~0xFFFULL;
struct HeapHeader *hdr = (struct HeapHeader *)
_zos_syscall1(SYS_ALLOC, (long)mapping_size);
if (hdr == NULL) return NULL;
hdr->magic = DIRECT_MAGIC;
hdr->requested_size = size;
hdr->block_size = mapping_size;
hdr->cookie = heap_cookie(hdr, mapping_size);
return (uint8_t *)hdr + sizeof(*hdr);
}
if (!g_heapInit) {
heap_grow(16 * 0x1000);
g_heapInit = 1;
}
/* Guard against overflow: size + Header must not wrap */
if (size > (uint64_t)-1 - sizeof(struct HeapHeader) - 15)
return NULL;
uint64_t needed = size + sizeof(struct HeapHeader);
needed = (needed + 15) & ~15ULL;
int idx = heap_bucket_index(needed);
if (idx >= 0) {
@@ -781,44 +836,63 @@ void *malloc(size_t size) {
struct FreeNode *node = g_buckets[idx];
g_buckets[idx] = node->next;
uint64_t block_size = node->size;
struct HeapHeader *hdr = (struct HeapHeader *)node;
hdr->magic = HEAP_MAGIC;
hdr->size = size;
hdr->requested_size = size;
hdr->block_size = block_size;
hdr->cookie = heap_cookie(hdr, hdr->block_size);
return (void *)((uint8_t *)hdr + sizeof(struct HeapHeader));
}
/* Large allocation — search overflow list */
void *block = heap_take_overflow(needed);
uint64_t actual = 0;
void *block = heap_take_overflow(needed, &actual);
if (block == NULL) {
heap_grow(needed);
block = heap_take_overflow(needed);
block = heap_take_overflow(needed, &actual);
if (block == NULL) return NULL;
}
struct HeapHeader *hdr = (struct HeapHeader *)block;
hdr->magic = HEAP_MAGIC;
hdr->size = size;
hdr->requested_size = size;
hdr->block_size = actual;
hdr->cookie = heap_cookie(hdr, actual);
return (void *)((uint8_t *)hdr + sizeof(struct HeapHeader));
}
void free(void *ptr) {
static int heap_header_valid(const struct HeapHeader *hdr) {
return (hdr->magic == HEAP_MAGIC || hdr->magic == DIRECT_MAGIC) &&
hdr->block_size >= sizeof(struct HeapHeader) &&
(hdr->block_size & (HEAP_ALIGN - 1)) == 0 &&
hdr->cookie == heap_cookie(hdr, hdr->block_size) &&
hdr->requested_size <= hdr->block_size - sizeof(struct HeapHeader);
}
static void heap_free_locked(void *ptr) {
if (ptr == NULL) return;
struct HeapHeader *hdr = (struct HeapHeader *)((uint8_t *)ptr - sizeof(struct HeapHeader));
if (hdr->magic == FREED_MAGIC) return; /* double-free */
if (hdr->magic != HEAP_MAGIC) return; /* corrupt */
if (!heap_header_valid(hdr)) return;
uint64_t blockSize = hdr->block_size;
int direct = hdr->magic == DIRECT_MAGIC;
hdr->magic = FREED_MAGIC;
uint64_t blockSize = hdr->size + sizeof(struct HeapHeader);
blockSize = (blockSize + 15) & ~15ULL;
if (direct) {
_zos_syscall1(SYS_FREE, (long)hdr);
return;
}
int idx = heap_bucket_index(blockSize);
if (idx >= 0) {
/* Small block — push onto bucket (O(1)) */
struct FreeNode *node = (struct FreeNode *)hdr;
node->magic = FREED_MAGIC;
node->size = BUCKET_SIZES[idx];
node->next = g_buckets[idx];
g_buckets[idx] = node;
@@ -828,6 +902,21 @@ void free(void *ptr) {
}
}
void *malloc(size_t size) {
void *result;
heap_lock();
result = heap_malloc_locked(size);
heap_unlock();
return result;
}
void free(void *ptr) {
if (ptr == NULL) return;
heap_lock();
heap_free_locked(ptr);
heap_unlock();
}
void *calloc(size_t nmemb, size_t size) {
/* Check for multiplication overflow */
if (nmemb != 0 && size > (size_t)-1 / nmemb)
@@ -842,26 +931,39 @@ void *realloc(void *ptr, size_t size) {
if (ptr == NULL) return malloc(size);
if (size == 0) { free(ptr); return NULL; }
uint64_t newNeed;
if (!heap_total_size(size, &newNeed))
return NULL;
heap_lock();
struct HeapHeader *hdr = (struct HeapHeader *)((uint8_t *)ptr - sizeof(struct HeapHeader));
uint64_t old = hdr->size;
if (!heap_header_valid(hdr)) {
heap_unlock();
return NULL;
}
uint64_t old = hdr->requested_size;
uint64_t oldBlock = hdr->block_size;
/* Compute actual block size (accounting for bucket rounding) */
uint64_t oldBlock = (old + sizeof(struct HeapHeader) + 15) & ~15ULL;
int idx = heap_bucket_index(oldBlock);
if (idx >= 0) oldBlock = BUCKET_SIZES[idx];
uint64_t newNeed = (size + sizeof(struct HeapHeader) + 15) & ~15ULL;
if (newNeed <= oldBlock) {
hdr->size = size;
/* Retain the actual extent. Losing it here makes the tail impossible
to recover when this block is later freed. */
hdr->requested_size = size;
hdr->cookie = heap_cookie(hdr, oldBlock);
heap_unlock();
return ptr;
}
void *newp = malloc(size);
if (newp == NULL) return NULL;
void *newp = heap_malloc_locked(size);
if (newp == NULL) {
heap_unlock();
return NULL;
}
size_t copySize = old < size ? old : size;
memcpy(newp, ptr, copySize);
free(ptr);
heap_free_locked(ptr);
heap_unlock();
return newp;
}
@@ -3874,17 +3976,26 @@ long sysconf(int name) {
}
}
/* Anonymous mappings only: SYS_ALLOC hands back zeroed page-aligned
memory and SYS_FREE releases it. Length is remembered by the kernel
per allocation, so munmap ignores its length argument. */
/* Anonymous private mappings with kernel-enforced page permissions. */
void *mmap(void *addr, size_t length, int prot, int flags, int fd,
long offset) {
(void)addr; (void)prot; (void)offset;
if (length == 0 || fd != -1 || !(flags & MAP_ANONYMOUS)) {
errno = ENODEV;
int supported_flags = MAP_PRIVATE | MAP_ANONYMOUS;
if (length == 0 || fd != -1 || offset != 0 ||
(flags & supported_flags) != supported_flags ||
(flags & ~supported_flags) != 0) {
errno = EINVAL;
return MAP_FAILED;
}
void *p = (void *)_zos_syscall1(SYS_ALLOC, (long)length);
/* Address hints may be ignored; fixed placement is intentionally rejected
by the flag validation above. */
(void)addr;
if ((prot & ~(PROT_READ | PROT_WRITE | PROT_EXEC)) != 0 ||
!(prot & PROT_READ) ||
((prot & PROT_WRITE) && (prot & PROT_EXEC))) {
errno = EINVAL;
return MAP_FAILED;
}
void *p = (void *)_zos_syscall2(SYS_MMAP_ANON, (long)length, (long)prot);
if (p == NULL) {
errno = ENOMEM;
return MAP_FAILED;
@@ -3893,18 +4004,31 @@ void *mmap(void *addr, size_t length, int prot, int flags, int fd,
}
int munmap(void *addr, size_t length) {
(void)length;
if (addr == NULL || addr == MAP_FAILED) {
if (addr == NULL || addr == MAP_FAILED || length == 0 ||
((uintptr_t)addr & 0xFFFULL) != 0) {
errno = EINVAL;
return -1;
}
if (_zos_syscall2(SYS_MUNMAP, (long)addr, (long)length) < 0) {
errno = EINVAL;
return -1;
}
_zos_syscall1(SYS_FREE, (long)addr);
return 0;
}
int mprotect(void *addr, size_t length, int prot) {
(void)addr; (void)length; (void)prot;
return 0; /* page protections are not adjustable from userspace */
if (addr == NULL || length == 0 || ((uintptr_t)addr & 0xFFFULL) != 0 ||
(prot & ~(PROT_READ | PROT_WRITE | PROT_EXEC)) != 0 ||
!(prot & PROT_READ) ||
((prot & PROT_WRITE) && (prot & PROT_EXEC))) {
errno = EINVAL;
return -1;
}
if (_zos_syscall3(SYS_MPROTECT, (long)addr, (long)length, (long)prot) < 0) {
errno = EINVAL;
return -1;
}
return 0;
}
long pathconf(const char *path, int name) {
Binary file not shown.
+20 -18
View File
@@ -9,49 +9,51 @@
.SH DESCRIPTION
The userspace heap provides dynamic memory allocation on top of
the kernel's page-mapping syscall (SYS_ALLOC). Include the
header <montauk/heap.h> to use these functions.
anonymous virtual memory. The Montauk C++ API and libc's
malloc/free API use the same process-wide allocator.
.SS malloc
Allocates 'size' bytes from the free list. Returns a 16-byte
aligned pointer, or nullptr on failure. When the free list is
empty, it requests more pages from the kernel via SYS_ALLOC
(minimum 16 KiB growth, initial seed of 64 KiB).
empty, it reserves more pages from the kernel. Physical pages
are committed as they are first touched.
char* buf = (char*)montauk::malloc(1024);
.SS mfree
Returns the block to the userspace free list. No syscall is
made -- the memory stays mapped and is immediately reusable.
Returns the block to the userspace allocator. Arena blocks are
immediately reusable; large direct mappings are returned to the
kernel, including their virtual address range.
Passing nullptr is a safe no-op.
montauk::mfree(buf);
.SS realloc
Resizes the allocation to 'size' bytes. Allocates a new block,
copies the smaller of old/new sizes, and frees the old block.
Resizes the allocation to 'size' bytes. A block with sufficient
capacity is retained; otherwise a new block is allocated, the
smaller of old/new requested sizes is copied, and the old block
is freed. Integer overflow fails without changing the old block.
If ptr is nullptr, behaves like malloc.
buf = (char*)montauk::realloc(buf, 2048);
.SH IMPLEMENTATION
The allocator uses a linked free-list with first-fit search.
Blocks larger than needed are split. The allocation header is
16 bytes (magic + size). All allocations are 16-byte aligned.
The allocator uses segregated size-class bins and a coalescing
address-ordered overflow list. Headers retain both requested size
and actual block extent. A process-wide lock serializes C and C++
allocation calls. All returned pointers are 16-byte aligned.
The heap grows by requesting pages from the kernel via
SYS_ALLOC. These pages are never returned to the kernel (since
SYS_FREE is currently a no-op), but mfree makes them available
for future malloc calls within the process.
Allocations of 256 KiB or more use direct page mappings so they
can be released promptly. Smaller allocations use growing arenas.
.SH LOW-LEVEL PAGE API
For large allocations or when direct page control is needed:
void* montauk::alloc(uint64_t size); // SYS_ALLOC
void montauk::free(void* ptr); // SYS_FREE (no-op)
void montauk::free(void* ptr); // SYS_FREE
alloc() maps zeroed pages starting at 0x40000000 and growing
upward. Size is rounded up to 4 KiB page boundaries.
alloc() reserves zero-filled, read/write, non-executable pages.
Size is rounded up to 4 KiB. Freed ranges are reusable.
.SH SEE ALSO
syscalls(2), file(2)
+16 -1
View File
@@ -185,9 +185,24 @@
void* montauk::alloc(uint64_t size);
.B SYS_FREE (12)
Reserved (currently a no-op).
Release a complete mapping previously returned by SYS_ALLOC.
void montauk::free(void* ptr);
.B SYS_MMAP_ANON (168)
Reserve a zero-filled anonymous mapping with read/write/execute
protection flags. Pages are committed on first access. Writable
executable mappings are rejected.
void* mmap(void*, size_t, int, int, int, long);
.B SYS_MUNMAP (169)
Release a page-aligned range. Partial unmap splits the VM area and
makes the virtual range reusable.
int munmap(void* addr, size_t length);
.B SYS_MPROTECT (170)
Change read/write/execute permissions on an anonymous mapping.
int mprotect(void* addr, size_t length, int prot);
.B SYS_MEMSTATS (67)
Get kernel-wide physical memory usage (total/free/used bytes,
page size).
+171
View File
@@ -0,0 +1,171 @@
/*
* memtest - userspace heap and virtual-memory regression tests
* Copyright (c) 2026 Daniel Hammer
*/
#include <montauk/heap.h>
#include <montauk/syscall.h>
#include <montauk/thread.h>
#include <sys/mman.h>
#include <stdint.h>
#include <stddef.h>
namespace {
int failures = 0;
void check(bool condition, const char* name) {
montauk::print(condition ? "PASS " : "FAIL ");
montauk::print(name);
montauk::putchar('\n');
if (!condition) failures++;
}
bool bytes_are(const uint8_t* p, size_t n, uint8_t value) {
for (size_t i = 0; i < n; i++)
if (p[i] != value) return false;
return true;
}
struct WorkerArgs {
volatile uint32_t* failures;
uint8_t seed;
};
int heap_worker(void* opaque) {
auto* args = (WorkerArgs*)opaque;
for (int round = 0; round < 256; round++) {
size_t size = (size_t)((round * 37 + args->seed) % 8192 + 1);
auto* p = (uint8_t*)montauk::malloc(size);
if (p == nullptr) {
__atomic_fetch_add(args->failures, 1, __ATOMIC_RELAXED);
continue;
}
uint8_t value = (uint8_t)(args->seed + round);
for (size_t i = 0; i < size; i++) p[i] = value;
size_t grown = size + (size_t)(round % 97);
auto* q = (uint8_t*)montauk::realloc(p, grown);
if (q == nullptr || !bytes_are(q, size, value)) {
__atomic_fetch_add(args->failures, 1, __ATOMIC_RELAXED);
if (q != nullptr) montauk::mfree(q);
else montauk::mfree(p);
continue;
}
montauk::mfree(q);
}
return 0;
}
}
extern "C" void _start() {
// Alignment, reuse, and data integrity across all current size classes.
void* blocks[512] = {};
bool basic_ok = true;
int block_count = 0;
for (int i = 0; i < 512; i++) {
size_t size = (size_t)((i * 53) % 12000);
blocks[i] = montauk::malloc(size);
if (blocks[i] == nullptr || ((uintptr_t)blocks[i] & 15) != 0) {
basic_ok = false;
break;
}
block_count++;
uint8_t value = (uint8_t)i;
for (size_t j = 0; j < size; j++) ((uint8_t*)blocks[i])[j] = value;
}
for (int i = 0; i < block_count; i += 2) {
montauk::mfree(blocks[i]);
blocks[i] = nullptr;
}
for (int i = 1; i < block_count; i += 2) {
size_t size = (size_t)((i * 53) % 12000);
if (!bytes_are((uint8_t*)blocks[i], size, (uint8_t)i)) basic_ok = false;
montauk::mfree(blocks[i]);
}
check(basic_ok, "size classes preserve data and 16-byte alignment");
auto* overflow = (uint8_t*)montauk::malloc(64);
for (int i = 0; i < 64; i++) overflow[i] = 0xA5;
void* rejected = montauk::realloc(overflow, UINT64_MAX);
check(rejected == nullptr && bytes_are(overflow, 64, 0xA5),
"realloc overflow fails without altering the old allocation");
montauk::mfree(overflow);
auto* shrink = (uint8_t*)montauk::malloc(32000);
for (int i = 0; i < 32000; i++) shrink[i] = (uint8_t)i;
auto* shrunk = (uint8_t*)montauk::realloc(shrink, 4097);
check(shrunk == shrink, "large in-arena realloc shrink retains its extent");
// The first byte sequence is not uniformly zero; validate explicitly.
bool shrink_data_ok = true;
for (int i = 0; i < 4097; i++)
if (shrunk[i] != (uint8_t)i) { shrink_data_ok = false; break; }
check(shrink_data_ok, "realloc shrink preserves payload");
montauk::mfree(shrunk);
auto* direct1 = (uint8_t*)montauk::malloc(512 * 1024);
uintptr_t direct_addr = (uintptr_t)direct1;
if (direct1) direct1[511 * 1024] = 0x6D;
montauk::mfree(direct1);
auto* direct2 = (uint8_t*)montauk::malloc(512 * 1024);
check(direct2 != nullptr && (uintptr_t)direct2 == direct_addr,
"large allocation releases and reuses its VM range");
auto* raw1 = (uint8_t*)montauk::alloc(3 * 4096);
uintptr_t raw_addr = (uintptr_t)raw1;
bool raw_split = raw1 != nullptr &&
mprotect(raw1, 4096, PROT_READ) == 0;
montauk::free(raw1);
auto* raw2 = (uint8_t*)montauk::alloc(3 * 4096);
check(raw_split && raw2 != nullptr && (uintptr_t)raw2 == raw_addr,
"SYS_FREE releases every fragment after mprotect splitting");
auto* map = (uint8_t*)mmap(nullptr, 3 * 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
bool map_ok = map != MAP_FAILED && ((uintptr_t)map & 0xFFF) == 0 &&
bytes_are(map, 3 * 4096, 0);
check(map_ok, "anonymous mappings are aligned and zero-filled");
if (map != MAP_FAILED) {
map[0] = 1;
map[8192] = 2;
check(mprotect(map, 4096, PROT_READ) == 0 &&
mprotect(map, 4096, PROT_READ | PROT_WRITE) == 0,
"mprotect changes mapped page permissions");
check(mprotect(map, 3 * 4096, PROT_READ) == 0 &&
mprotect(map, 3 * 4096, PROT_READ | PROT_WRITE) == 0,
"mprotect spans adjacent VMA fragments");
check(mprotect(map, 4096, PROT_READ | PROT_WRITE | PROT_EXEC) < 0,
"mprotect rejects writable executable memory");
void* middle = map + 4096;
check(munmap(middle, 4096) == 0, "munmap supports VMA splitting");
void* replacement = mmap(nullptr, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
check(replacement == middle, "munmap makes virtual ranges reusable");
if (replacement != MAP_FAILED) munmap(replacement, 4096);
munmap(map, 4096);
munmap(map + 8192, 4096);
}
// Keep this extent occupied until the partial-unmap test above has
// verified that its own hole is the first reusable range.
montauk::free(raw2);
montauk::mfree(direct2);
volatile uint32_t worker_failures = 0;
WorkerArgs args[4] = {};
int tids[4] = {};
bool threads_ok = true;
for (int i = 0; i < 4; i++) {
args[i] = { &worker_failures, (uint8_t)(17 + i * 31) };
tids[i] = montauk::thread_spawn(heap_worker, &args[i]);
if (tids[i] < 0) threads_ok = false;
}
for (int i = 0; i < 4; i++) {
int code = -1;
if (tids[i] >= 0 && (montauk::thread_join(tids[i], &code) < 0 || code != 0))
threads_ok = false;
}
check(threads_ok && worker_failures == 0,
"concurrent malloc/realloc/free stress");
montauk::print(failures == 0 ? "memtest: all tests passed\n"
: "memtest: failures detected\n");
montauk::exit(failures == 0 ? 0 : 1);
}