vmalloc, vfree and vsize
Now we move onto the virtual allocators. These sit on top of the paging infrastructure. Unlike kmalloc which uses physical frames, vmalloc maps arbitrary virtual ranges using the page directory/tables.
Think of it like this:
kmalloc → physical frames, identity-mapped, fast, contiguous in physical memory
vmalloc → virtual address space, backed by physical frames mapped via page tables,
not necessarily contiguous physically
Vmalloc solves a big problem. Say we want to allocate 12MB of memory but our physical memory looks like this:
- 4MB free here
- 2MB free there
- 6MB free somewhere else
kmalloc won’t be any help here because it needs a contiguous physical region. But vmalloc can, because it only needs contiguous virtual addresses. The physical frames backing it can be scattered anywhere in physical memory — the page tables stitch them together into one seamless virtual region.
The Virtual Address space
Before writing any code, we need to decide where vmalloc allocations live in virtual memory. We can’t just pick a random address — it needs to be a dedicated region that won’t clash with the kernel or the heap.
Our memory layout looks like this:
0x00100000 kernel start (1MB)
...kernel binary...
0x00800000 end of kernel reserved space (8MB)
heap (1MB, managed by kmalloc)
0x00900000 VMALLOC_START ← vmalloc region begins here
...vmalloc virtual space...
0xD0000000 VMALLOC_END ← plenty of room
We track the next free virtual address with a simple cursor:
static mut VMALLOC_NEXT_VADDR: u32 = VMALLOC_START;
Every allocation advances this cursor by pages * PAGE_SIZE. Simple and effective.
Tracking Allocations — VMallocRegion
We need to track each allocation so that vfree and vsize can find them later. For this we use a linked list of VMallocRegion structs, each allocated via kmalloc:
struct VMallocRegion {
start: u32, // virtual address this allocation begins at
pages: u32, // how many pages were allocated
free: bool, // is this region free?
next: *mut VMallocRegion // next region in the linked list
}
Notice that the structs themselves live in the kmalloc heap — we use kmalloc(size_of::<VMallocRegion>()) to create each one. This is a nice separation: kmalloc manages small metadata, vmalloc manages large virtual regions.
vmalloc
The way vmalloc works is quite different to kmalloc. It goes something like this:
- Calculate how many pages are needed
- For each page, allocate a physical frame and map it to the next virtual address
- Create a
VMallocRegionstruct to track the allocation - Add it to the linked list and return the start address
Page Calculation
Since one page holds 4096 bytes, every allocation is rounded up to the nearest page boundary:
pages = (size + 4095) / 4096
This ensures any value under 4096 allocates at least one page. Note that integer division truncates, so without the + 4095 trick, vmalloc(1) would calculate 1 / 4096 = 0 and allocate nothing!
| Bytes | Allocated | Pages |
|---|---|---|
| 1 | 4096 | 1 |
| 5000 | 8182 | 2 |
| 12000 | 12276 | 3 |
Mapping the Pages
for i in 0..page_num {
if VMALLOC_NEXT_VADDR >= VMALLOC_END {
if i > 0 { vfree(old_head as *mut u8); }
return 0 as *mut u8;
}
let frame = allocate_frame();
match frame {
Some(f) => map_page(f, VMALLOC_NEXT_VADDR, 0),
None => {
if i > 0 { vfree(old_head as *mut u8); }
return 0 as *mut u8;
}
}
VMALLOC_NEXT_VADDR += PAGE_SIZE;
}
A couple of things worth noting here. First, we check VMALLOC_END before each page to make sure we don’t walk off the end of our reserved region. Second, if allocate_frame fails midway through, we call vfree on what we already mapped to clean up — no memory leaks.
Tracking the Allocation
After the mapping loop, we kmalloc a VMallocRegion to describe what we just did, and prepend it to the linked list:
let block = kmalloc(size_of::<VMallocRegion>());
match block {
Some(b) => {
let new_block = b as *mut VMallocRegion;
(*new_block).start = old_head; // where this allocation starts
(*new_block).pages = page_num as u32;
(*new_block).free = false;
(*new_block).next = VMALLOC_HEAD; // prepend to list
VMALLOC_HEAD = new_block;
return old_head as *mut u8; // return the virtual address
}
None => return 0 as *mut u8,
}
vfree
vfree needs to reverse everything vmalloc did. It walks the linked list to find the matching region, then for each page it retrieves the physical address, frees the frame, and unmaps the page:
let mut current = VMALLOC_HEAD;
while !current.is_null() {
if (*current).start == ptr as u32 {
for i in 0..(*current).pages {
let vaddr = ((*current).start + (i * PAGE_SIZE)) as *const u8;
let paddr = get_physaddr(vaddr) as u32; // find physical frame
free_frame(paddr); // return frame to allocator
unmap_page(vaddr as u32); // clear page table entry
}
(*current).free = true;
break;
}
current = (*current).next;
}
The order matters here — we call get_physaddr before unmap_page, because once the page table entry is cleared, we can no longer look up the physical address.
vsize
vsize follows the same linked list walk pattern, and simply returns pages * PAGE_SIZE:
while !current.is_null() {
if (*current).start == ptr as u32 {
return ((*current).pages * PAGE_SIZE) as usize;
}
current = (*current).next;
}
return 0;
The Frame Allocator Bug
During testing I hit an interesting bug — kmalloc was returning None immediately after map_page was called, even though the heap was working fine beforehand.
The culprit: The frame allocator starts handing out frames from right after the kernel, but the heap lives in that same region. When map_page internally called allocate_frame to create a new page table, it was handing out a frame that overlapped with the heap — silently corrupting it.
The fix was to advance NEXT_FREE_FRAME past the heap at the end of init_heap:
pub unsafe fn advance_frame_past_heap(heap_start: u32, heap_size: u32) {
NEXT_FREE_FRAME = align_up(heap_start + heap_size);
}
A good reminder that in bare metal programming, nothing enforces memory boundaries for you — you have to reason about every region explicitly.