Introduction
Before learning anything else, I believe we have to understand memory management. As I kept allocating dynamic memory in my programs, I started to think about what that actually means. I know it’s virtual and it’s available, but who gives it? Who is in charge? How does it happen?
I am writing this article to answer those exact questions. This is a two-part series. I will start with what memory actually looks like and how the OS does its magic, and later move on to building a small memory allocator. I am going to start with sbrk() and move to mmap(), as it is the new standard.
Ask Heap for memory?
In a Linux environment, a program’s virtual memory heap has a defined upper boundary known as the “program break.” Everything below this line—sitting just above the Data and BSS segments, is accessible heap memory. Everything immediately above this line is unmapped, and trying to access it will cause a crash (segmentation fault).
Traditionally, we use the sbrk() system call to drag this program break up or down. If you ask sbrk() to expand the heap by 40 bytes, it pushes the boundary up and returns a void * pointer to the start of that new space.
However, on modern architectures like RISC-V and ARM64, relying on sbrk() and brk() is deprecated. Modern custom allocators instead rely on mmap() (Memory Mapper) to request memory directly from the OS in chunks called “pages.” A page is simply a fixed-size contiguous block of virtual memory (typically 4KB) that the operating system hands over to the program to manage.
Need space? OS: “Hold my page”
The memory we use is not a real, contiguous block of physical RAM. It is just an illusion we use to make programs work. In reality, physical memory is broken up into blocks called “frames,” and the hardware stores our data wherever it finds free space, rarely in a straight line. This chaos is managed through a partnership between the OS and the CPU’s Memory Management Unit (MMU).
When a program asks the OS for memory, the OS provides a “page” for that process to work, play, or exist in. A page is simply the smallest chunk of memory that the OS will hand out to a program. Each virtual page is then linked to a raw physical memory frame, mapped together by a lookup table managed by the MMU. This is exactly how the illusion of continuous virtual memory is created.
Benefits:
Security: Because each process is assigned its own page, its own playground, or dare I say, kingdom. They cannot cross over into another program’s memory area. If a program tries to invade without permission, it is essentially declaring war, and the OS will instantly strike it down (Segmentation Fault).
Efficiency: If the system is running low on physical RAM, the OS can look for pages belonging to different processes that aren’t actively being used. It can simply copy or move those pages to the hard drive (swapping) and bring them back later if needed. The MMU keeps track of this as well, seamlessly mapping the memory addresses behind the scenes.
Why Shifting the Program Break (sbrk) is Dead
Modern architectures don’t use sbrk() anymore; it has been deprecated. Moving a single program break line had a few major problems.
Waste of memory: As we keep moving the break line up to add more chunks of memory for the program, we run into a trap. Suppose chunks A, B, and C are allocated in that order. The program finishes with chunk B and wants to free it. It tells the allocator B is free, but the OS cannot use that space again. Why? Because we cannot move the program break back down to the start of B without losing chunk C, which is sitting right on top of it. Layout and chronology dictate everything. We might say it is just 4 bytes trapped in the middle, but do it enough times, and it will eat a huge chunk of RAM through a compounding effect (this is known as fragmentation).
Multi-threading: Because sbrk() plays with a single, global program break for the whole process, what happens if two threads call malloc() at the exact same time? They both try to move the same boundary line simultaneously. This causes a race condition leading to memory corruption and unpredictable outcomes.
The Page-Based Solution
With the help of tools like mmap(), we can request discrete, independent pages from the OS instead of moving a single global line. If we allocate independent pages for different data, we can give a specific page back to the OS the moment we are done with it, without affecting the others. This is much more efficient, and modern allocators can even assign entirely different pages to different threads, completely removing the race condition.
#include <sys/mman.h>
#include <stdio.h>
int main() {
// Asking the OS for 4096 bytes of space (a typical page size)
void *raw_memory = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (raw_memory == MAP_FAILED) {
printf("Allocation failed!\n");
return 1;
}
printf("Successfully allocated a page at: %p\n", raw_memory);
// Always return the page to the OS when done
munmap(raw_memory, 4096);
return 0;
}
Enter fullscreen mode Exit fullscreen mode
Why did I say “Page-Based” Solution? (Conclusion)
Understanding virtual memory pages and physical memory frames paints a much clearer picture of what is happening under the hood when dealing with dynamic memory allocation. The OS has successfully handed us a 4KB block of space using mmap().
But this creates a new problem: what if our program only needs to store an 8-byte integer? If we give it the entire 4KB page, we waste a massive amount of space.
In Part 2, I will solve this by building a custom memory allocator in C that takes this raw OS page and efficiently slices it up for the program to use. Thank you for reading, and have a profound day!