1. Introduction
When you write a simple C program on Linux and allocate memory with malloc(), it might seem like a straightforward operation.
But behind the scenes, Linux and your hardware are doing quite a lot of work to make that memory available.
This article dives into the layers of abstraction that make it possible: from virtual memory and memory pages to the inner workings of malloc(), and via the brk() and mmap() syscalls.
If you’ve ever wondered how a process sees memory or how Linux manages it under the hood, read on.
2. Introduction to virtual memory
As you may know, every program you run on a modern OS resides on the physical memory (RAM) of your computer. But, what is less known is that each process (a program that is running) is not aware of the actual size of your RAM or the presence of other processes. Instead, it sees a virtual address space: a clean, private illusion managed by the kernel.
Physical vs virtual memory
When you compile a C program, the resulting binary contains hardcoded and absolute memory addresses for functions, variables, instructions, etc…
If two programs were loaded into memory using those exact addresses at the same time, they could interfere with one another.
One process might accidentally call a function from another or read its data.
That would be a security and maintainability nightmare.
To avoid this, mainstream operating systems like Linux use virtual memory. Instead of sharing a single, unified memory space, each process is given the illusion of having the entire memory to itself. This abstraction ensures a really good isolation.
But this means that the memory addresses a program sees are not the actual physical locations in RAM.
Let’s look at an example:
|
|
Here, the program receives a pointer to a memory address, say 0x55555575e2a0.
This looks like an address in RAM, but it’s not.
It’s a logical address.
Behind the scenes, the kernel and the hardware work together to translate this into a physical address, like 0x1a3f2a0, where the data is actually stored.
This translation is handled by a hardware component called the Memory Management Unit (MMU). Every time a program accesses memory, the MMU maps the logical address to the corresponding physical one, using data structures maintained by the kernel (like page tables). This process is what separate physical to virtual memory:
- Physical memory is your RAM installed on your machine. A physical address refers to actual data on your RAM.
- Virtual memory is what each process sees: a clean, isolated address space. A logical address refers to data inside this address space.
Address space and memory pages
As we’ve seen above, the address space of a process is the full range of logical addresses it can access. On a 64-bit Linux system, this space can be up to 264 bytes (although operating systems impose practical limits).
This address space is divided into regions (called segments), each serving a different purpose:
- The text segment contains the program’s compiled instructions (the actual program code).
- The data segment holds global and static variables.
- The heap is used for dynamic memory allocation. The example above will show you a logical address that is located inside the heap of your process.
- The stack grows downward as functions are called and local variables are stored.
- Memory-mapped files and shared libraries are mapped elsewhere.
This entire address space is seen as continuous by your process. But, in reality, it is managed by the kernel in fixed-size blocks called memory pages.
A memory page is the smallest unit of memory your RAM has access to. A memory page is to the RAM what the cell is to animals.
There are two kinds of pages to understand:
- A virtual page is a page-sized chunk of a process’s address space. The segments described above are made of those virtual pages.
- A page frame is the actual page-sized block of RAM in physical memory. When you request memory to your MMU, it won’t return you a single byte, but a whole page frame.
The MMU, with help from the kernel’s page tables, maps each virtual page to a page frame in physical memory.
Question: But why the need for virtual pages?
Not all virtual pages are always backed by physical memory. Some may be:
- Not yet allocated, i.e. reserved but unused (this examples in part 5.).
- Shared with other processes (shared libraries).
- Swapped out to disk (with swap).
With the example above with malloc(), the system may reserve several virtual pages in your address space, but delay assigning physical page frames until you actually access them. This is called demand paging.
This layered abstraction (virtual address space -> vritual pages -> page frames -> physical memory) is what allows Linux to provide powerful features like isolation, lazy allocation, copy-on-write, and memory protection.
3. mmap() and brk()
Now that we’ve talked about pages and how a process’s address space is made up of virtual pages mapped to physical ones, a question arise:
Question: How does a process actually get those pages?
The answer is, as always, syscalls !
To get pages, a process can ask the kernel to add virtual pages into its virtual address space using syscalls.
The two main ones are brk() and mmap().
brk(): extending the heap
Historically, the primary way for a process to request memory was through the brk() syscall.
This call controls the end of the heap.
When a program starts, the kernel sets a point in its virtual address space called the program break, which marks the end of the heap.
Calling brk() increases or decreases this boundary:
This method is simple and efficient for small or frequent allocations. However, it has limitations:
- The heap is a contiguous region:
brk()can only grow or shrink one end. - Fragmentation can become a problem for large or complex allocations.
- There’s only one program break, so multiple threads or allocators can’t use it independently.
mmap(): flexible memory mapping
To overcome the limitations of brk(), modern programs often rely on mmap(), a more powerful and flexible syscall.
mmap() allows a process to add any number of virtual pages anywhere in its address space, so not just at the end of the heap.
Here’s how to use it:
|
|
This requests length bytes of memory (page-aligned), with specified permissions.
The flags MAP_PRIVATE | MAP_ANONYMOUS indicate that the memory should be private to the process and not backed by a file.
The resulting logical address will point to the start of the virtual page(s) returned by mmap.
One small point about page-aligned length: if your page size is 4kB and you request only 250 bytes, mmap() will still return a whole virtual page.
As said above, a page is the smallest unit of memory, so you cannot request a smaller amount of memory than physically possible.
What makes mmap() powerful is that it can be used to:
- Map large memory regions for dynamic allocations.
- Map files directly into memory (memory-mapped I/O).
- Create shared memory between processes, for them to communicate.
- Manage multiple, non-contiguous memory regions safely.
4. How malloc() Works
As we’ve seen so far, brk() is not really well suited for big allocations and mmap(), because of page alignment, is not efficient in terms of used space.
Those problems are what malloc() tries to fix.
To do that, calling malloc(size) does three main things:
- It decides how to get memory from the system with either
brk()ormmap(). - It manages memory itself using internal structures (to avoid constant system calls).
- It tries to reuse memory that was already allocated but is no longer used.
1. Choosing between brk() and mmap()
The first decision malloc() makes is how to get new memory from the operating system:
- For small allocations (typically under 128 KB), it uses the heap with the
brk()syscall. - For large allocations (128 KB or more), it uses
mmap().
This choice is the foundation of how glibc’s allocator (ptmalloc) works.
But now that malloc() knows where to get memory, it needs to manage it efficiently.
2. Chunks and bins
Once memory has been acquired, malloc() doesn’t hand it all right away.
Instead, it breaks memory into chunks, and organizes these chunks in bins to make reuse fast.
Each chunk of memory is a block that includes a small header. These chunks are grouped by size into bins, which are basically buckets of reusable memory blocks.
There are several types of bins:
- Fast bins, for very small chunks (typically < 64 bytes). These are reused quickly but not immediately merged with neighbors when freed.
- Small and large bins, for medium and large chunks. They are sorted by size and can be merged to form bigger chunks.
- Unsorted bin, when a chunk is freed, it often goes here first before being placed in its correct bin.
So when you call malloc(size):
- The allocator first looks in the appropriate bin for a free chunk.
- If it finds one, it returns it immediately: no syscall needed.
- If not, it tries to create new chunks from already acquired memory.
- And only if that fails, it makes a new syscall to Linux using
brk()ormmap().
This explains why small allocations are often very fast as they never go to the kernel.
Finally, when malloc() has found the right chunk, it simply returns it.
3. Recycling memory and free()
Question: So how does
malloc()looks for a free chunk?
It uses free lists (or more generally, the bin structures) to keep track of freed chunks.
When you call free(), you are telling malloc to mark a chunk in a bin as free.
This allows malloc() to quickly grab memory that was previously used, instead of calling brk() or mmap() again.
Here’s a simple example to demonstrate this reuse:
|
|
will output:
x = 0x562de42ae2a0
y = 0x562de42ae2c0
x = 0x562de42ae2a0
As you can see, the third call to malloc() returns the same address as the first one.
It reused a freed chunk!
Behind the scenes, the allocator may split large chunks into smaller ones or merge adjacent free chunks to reduce fragmentation.
Multithreading and arenas
As we’ve seen above, malloc() depends on data structures.
Question: So how does it work with multithreading?
As you may know, in multithreaded programs, accessing and modifying data structures is difficult. On top of that, managing memory safely and efficiently is more complex.
To avoid locking on a single global bin structure, malloc() uses multiple arenas:
- Each thread is assigned an arena, which contains its own bins.
- Threads can allocate and free memory independently, reducing contention.
By default, single-threaded programs use the main arena, usually backed by the heap.
New arenas are created with mmap() to ensure they don’t interfere with the heap.
This design allows malloc() to scale better with the number of threads in your program.
5. Exploring /proc/[pid]/maps and smaps
Throughout this article, we’ve built an abstraction ladder:
- We started with the idea of virtual memory and the needed illusion of a private, continuous address space.
- We then went on with memory pages and the fact that getting a single byte of memory requires access to a whole page.
- We then dug into the
brk()andmmap()syscalls. - And finally we explored how
malloc()ties it all together with chunks, bins, arenas and how it reuses memory.
But what if we want to peek behind the curtain?
What if we want to see how our process memory is actually laid out in real time?
What if we want to confirm that a malloc() used brk(), or spot how many pages were actually allocated by our code?
That’s exactly what the Linux /proc filesystem allows us to do. It gives us a live, human-readable view into the kernel’s internal bookkeeping for each process.
Two of the most valuable files in this regard are, with
- /proc/
/maps: shows which memory regions are mapped in your process, and how they are used. - /proc/
/smaps: adds detailed statistics about each region: how much memory is actually used, shared, dirty, and more.
Let’s explore how these files work.
/proc/[pid]/maps: the memory layout blueprint
The maps file shows the memory layout of a process: what regions exist, where they start and end, their permissions, and what they’re used for.
Here’s a real example:
this code:
|
|
will print its pid (process id) and then increase the heap size by 1000 bytes every second.
Each time the heap size increases, it also writes some text in it, so that the kernel mark the virtual pages as used and actually allocates them.
With the process pid, you can read its related maps file with the command: cat /proc/<pid>/maps by replacing <pid> with the process pid.
Here is what you may have:
5a4fd289000-55a4fd28a000 r--p 00000000 00:21 1655925 .../proc_mmap/main
55a4fd28a000-55a4fd28b000 r-xp 00001000 00:21 1655925 .../proc_mmap/main
55a4fd28b000-55a4fd28c000 r--p 00002000 00:21 1655925 .../proc_mmap/main
55a4fd28c000-55a4fd28d000 r--p 00002000 00:21 1655925 .../proc_mmap/main
55a4fd28d000-55a4fd28e000 rw-p 00003000 00:21 1655925 .../proc_mmap/main
55a503001000-55a503022000 rw-p 00000000 00:00 0 [heap]
7f22229ee000-7f22229f1000 rw-p 00000000 00:00 0
7f22229f1000-7f2222a15000 r--p 00000000 00:21 4095 /usr/lib/libc.so.6
7f2222a15000-7f2222b85000 r-xp 00024000 00:21 4095 /usr/lib/libc.so.6
7f2222b85000-7f2222bd3000 r--p 00194000 00:21 4095 /usr/lib/libc.so.6
7f2222bd3000-7f2222bd7000 r--p 001e1000 00:21 4095 /usr/lib/libc.so.6
7f2222bd7000-7f2222bd9000 rw-p 001e5000 00:21 4095 /usr/lib/libc.so.6
7f2222bd9000-7f2222be3000 rw-p 00000000 00:00 0
7f2222c27000-7f2222c2b000 r--p 00000000 00:00 0 [vvar]
7f2222c2b000-7f2222c2d000 r--p 00000000 00:00 0 [vvar_vclock]
7f2222c2d000-7f2222c2f000 r-xp 00000000 00:00 0 [vdso]
7f2222c2f000-7f2222c30000 r--p 00000000 00:21 4086 /usr/lib/ld-linux-x86-64.so.2
7f2222c30000-7f2222c59000 r-xp 00001000 00:21 4086 /usr/lib/ld-linux-x86-64.so.2
7f2222c59000-7f2222c64000 r--p 0002a000 00:21 4086 /usr/lib/ld-linux-x86-64.so.2
7f2222c64000-7f2222c66000 r--p 00034000 00:21 4086 /usr/lib/ld-linux-x86-64.so.2
7f2222c66000-7f2222c67000 rw-p 00036000 00:21 4086 /usr/lib/ld-linux-x86-64.so.2
7f2222c67000-7f2222c68000 rw-p 00000000 00:00 0
7ffe26ce9000-7ffe26d0a000 rw-p 00000000 00:00 0 [stack]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
Each line shows:
- The address range (start-end)
- The permissions:
- read
- write
- execute
- private (copy-on-write) or shared
- The offset into the file/device
- The device and inode
- The mapped file or anonymous mapping
- [heap]: managed via
brk() - [stack]: the program’s stack
- [vdso], [vvar], [vsyscall]: special kernel-provided regions
- memory-mapped libraries (like libc or libpthread)
- [heap]: managed via
By using this command watch -n 1 cat /proc/<pid>/maps, you’ll se the same output, but this time updated every seconds.
With the program above, you’ll see that the line finishing with ‘[heap]’ has its address range increasing slowly, thus showing the effectiveness of brk().
/proc/[pid]/smaps: memory usage in detail
If maps shows where and what, then smaps shows how much and why. It expands each mapping into dozens of lines, reporting usage statistics like:
- Size: total size of the mapping
- Rss: resident set size (physical memory actually used)
- Pss: proportional set size (shared memory split among processes)
- Shared_Clean, Private_Dirty, etc.
- Referenced, Anonymous, and more
With the same code as above, and with the command cat /proc/<pid>/smaps, you’ll have something like this:
561e6c999000-561e6c9ba000 rw-p 00000000 00:00 0 [heap]
Size: 132 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Rss: 4 kB
Pss: 4 kB
Pss_Dirty: 4 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 4 kB
Referenced: 4 kB
Anonymous: 4 kB
KSM: 0 kB
LazyFree: 0 kB
AnonHugePages: 0 kB
ShmemPmdMapped: 0 kB
FilePmdMapped: 0 kB
Shared_Hugetlb: 0 kB
Private_Hugetlb: 0 kB
Swap: 0 kB
SwapPss: 0 kB
Locked: 0 kB
THPeligible: 0
ProtectionKey: 0
VmFlags: rd wr mr mw me ac sd
As you can see here, the total reserved size of the heap is 132 kB, but only 4 kB are actually used. This shows the difference between virtual pages and page frames.
6. Conclusion
Memory management in Linux is built on powerful abstractions: virtual memory, paging, the brk() and mmap() syscalls and the malloc() function familly.
These mechanisms provide isolation, security, and flexibility to every process.
With malloc() adding layers of efficiency, most devs rarely need to think about these inner workings.
But understanding them can give you powerful insight into performance, debugging, and system behavior.
And thanks to tools like /proc/[pid]/maps, the whole picture is just a terminal away.