When you declare an ordinary variable or array (say int a[100];), the C compiler
allocates it on the stack. That region has a size fixed at compile time and is released automatically
when the function returns. But what if you do not know how many elements you need until run time? Or
you want the memory to outlive the function that created it? To solve this, C offers
dynamic memory allocation on the heap.
1. The memory layout of a process
To understand memory management properly, we first need the whole picture of how the operating system divides memory for a running C process. A process's virtual memory is split into segments, each with a specialised role:
+------------------------------------+ <--- High address (0xFFFFFFFF) | Environment & command arguments | +------------------------------------+ | Stack | (grows DOWN, towards low addresses) | | | | v | | | | ^ | | | | | Heap | (grows UP, towards high addresses) +------------------------------------+ | BSS segment (uninitialised) | (zero-filled when the program loads) +------------------------------------+ | Data segment (initialised) | (globals/statics with a value) +------------------------------------+ | Text segment (machine code) | (read-only, the binary instructions) +------------------------------------+ <--- Low address (0x00000000)
- Text segment: holds the program's machine code. It is read-only, protecting the instructions from being overwritten by accident.
-
Data segment: holds global and static variables initialised to a non-zero value
(for example
int g_var = 10;). - BSS segment: holds global and static variables that are uninitialised (or initialised to zero). At load time the operating system wipes this segment to zero.
- Stack segment: holds local variables, function arguments and return addresses. It works LIFO (last in, first out): calling a function pushes a stack frame, and returning destroys it entirely and automatically.
- Heap segment: the large region for dynamic allocation, managed directly by the programmer through library functions. Unlike the stack, which grows and shrinks by itself, the heap only changes size when you ask it to.
2. Stack versus heap in detail
| Property | Stack memory | Heap memory |
|---|---|---|
| Allocation | Automatic, managed by the CPU (via the stack pointer, SP). | Manual, managed by the programmer through malloc/calloc/free. |
| Size of the region | Very small (typically 1 MB on Windows, 8 MB on Linux). Easy to overflow with unbounded recursion. | Very large (bounded by the system's virtual memory or physical RAM). |
| Speed | Extremely fast (it only moves the stack pointer register). | Slower (it must run an algorithm to find a suitable free block). |
| Storage pattern | Contiguous and strictly ordered. | Scattered and discontinuous, which leads to fragmentation. |
3. How a heap allocator really works: syscalls and chunk headers
When you call malloc(10), how does the C library find that memory? Behind it sits a
heap allocator (ptmalloc in glibc, or jemalloc, dlmalloc) acting as an intermediary
between your program and the operating system.
Operating system syscalls: brk/sbrk and mmap
The heap allocator asks the kernel for RAM through system calls:
-
brk/sbrk: moves the program break pointer to extend the heap segment towards higher addresses. Typically used for small and medium requests. -
mmap: creates an anonymous virtual memory mapping entirely separate from the ordinary heap segment. Typically used for very large requests (by default over 128 KB on Linux) to avoid fragmenting the main heap.
The secret of free(): metadata and the chunk header
Why is free(ptr) enough β why don't you have to tell it how many bytes to release?
Because the heap allocator quietly writes a block of management
metadata (a chunk header) immediately before the address it hands back to you.
+-------------------+-----------------------------------+
| Chunk Header | The memory actually given to you|
| (holds the size) | (the address your pointer gets) |
+-------------------+-----------------------------------+
^ ^
| |
ptr - 8 bytes ptr (assigned to arr)
When malloc gives you ptr, the real allocation starts a few bytes to the
left. Those bytes hold the chunk size (32 bytes, say) and status flags (is this block in use or free).
When you pass ptr to free(ptr), the allocator simply looks at
ptr - sizeof(header) to read the size and return the block to its free list.
Memory fragmentation
- Internal fragmentation: happens when the allocated size is larger than the requested one, because of CPU alignment or the allocator's minimum chunk size.
- External fragmentation: happens when many small free blocks lie scattered between blocks still in use. The total free space may be large, yet no single contiguous block is big enough to satisfy a new request.
4. The dynamic allocation functions in C
To use these functions you must include <stdlib.h>.
malloc
Syntax: void* malloc(size_t size);
Allocates a region of size bytes. Note that the cells contain
random garbage.
calloc
Syntax: void* calloc(size_t num, size_t size);
Allocates room for num elements of size bytes each. The advantage of
calloc is that it zeroes every byte of the region it allocates. That
costs slightly more performance than malloc, since the CPU has to run a memory-clearing
instruction (memset).
All three explanations above are loose syntax. Assembled into a runnable program, the complete cycle β allocate β check β use β release β looks like this:
#include <stdio.h>
#include <stdlib.h>
int main() {
int n = 5;
// 1. Ask for room for n ints. malloc returns void*, and hands back
// uninitialised memory - whatever bytes happened to be there.
int *scores = malloc(n * sizeof(int));
// 2. ALWAYS check. If the system is out of memory malloc returns NULL,
// and dereferencing that is an immediate crash.
if (scores == NULL) {
printf("Out of memory\n");
return 1;
}
for (int i = 0; i < n; i++) {
scores[i] = (i + 1) * 10;
}
printf("malloc: ");
for (int i = 0; i < n; i++) printf("%d ", scores[i]);
printf("\n");
// 3. calloc does the same but zeroes every byte first.
int *counts = calloc(n, sizeof(int));
if (counts == NULL) { free(scores); return 1; }
printf("calloc: ");
for (int i = 0; i < n; i++) printf("%d ", counts[i]);
printf("\n");
// 4. Every malloc/calloc needs exactly one free. After freeing, set the
// pointer to NULL so a later accidental use fails loudly, not silently.
free(scores);
scores = NULL;
free(counts);
counts = NULL;
return 0;
}
malloc: 10 20 30 40 50
calloc: 0 0 0 0 0
The calloc line printing all zeroes is visible proof of the difference between the two
functions. Change that calloc to malloc and the printed values become random
garbage β and worse, sometimes they are coincidentally all zero, so the bug only shows up on
somebody else's machine.
int *scores = malloc(...) with no cast. In C,
void* converts to any other pointer type automatically, so the cast is not required β
and many people recommend leaving it out: if you happen to forget
#include <stdlib.h>, the cast hides the warning the compiler should have
given you. In C++ the opposite holds: the cast is mandatory. You will meet both
styles in real code, so understanding the reasoning on each side matters more than picking a side.
A defensive pattern: safe realloc
realloc resizes a region that has already been allocated:
void* realloc(void* ptr, size_t new_size);
Many programmers write arr = (int*)realloc(arr, new_size);. This is a
fatal mistake (a memory leak hazard). If the system runs out of memory,
realloc fails and returns NULL. That assignment then overwrites
arr with NULL, while the old region still exists on the heap and you have
completely lost the address needed to reach or release it. Always use a temporary pointer:
#include <stdio.h>
#include <stdlib.h>
int main() {
int *arr = (int*) malloc(2 * sizeof(int));
if (arr == NULL) return 1;
arr[0] = 10; arr[1] = 20;
// Yeu cau thay doi kich thuoc an toan qua con tro tam 'temp'
int *temp = (int*) realloc(arr, 1000 * sizeof(int));
if (temp == NULL) {
// realloc that bai! Nhung vung nho cu cua 'arr' van an toan.
printf("Khong the mo rong bo nho! Giai phong mang cu va thoat.\n");
free(arr);
return 1;
}
// Neu thanh cong, moi gan lai dia chi cho arr
arr = temp;
arr[999] = 9990;
printf("Mo rong mang thanh cong, arr[999] = %d\n", arr[999]);
free(arr);
arr = NULL;
return 0;
}
π₯ Download the sample source: safe_realloc.c
5. The dangerous memory bugs and undefined behaviour
Because C has no garbage collector, the programmer drives the hardware pointers directly. A small mistake produces undefined behaviour (UB), crashing the program immediately or quietly producing wrong results:
-
Memory leak: allocating dynamically but ending the function or program without
releasing it with
free(). The RAM stays occupied indefinitely. -
Dangling pointer: the memory has been released with
free(ptr)butptrstill points at the old address. Reading or writing through*ptrafterwards is a serious bug, because the OS may have handed that memory to something else. Always assignptr = NULL;immediately afterfree. -
Double free: calling
free()twice on the same address without setting it toNULLin between. This corrupts the allocator's own bookkeeping structures. -
Invalid free: calling
free()on an address that did not come from malloc/calloc/realloc β the address of a local variable on the stack, say, or an address in the middle of an allocated block.
6. Tools for finding and debugging memory bugs
Memory bugs in C are notoriously hard to spot by eye, because the program may run "normally" many times before crashing at some apparently random moment. To find and eliminate them, professional developers use automated memory analysis tools.
6.1. Finding memory bugs with Valgrind
Valgrind is an open-source dynamic analysis toolkit that detects memory leaks, invalid memory access and many other run-time errors. It works by running your program inside a virtual machine and watching every memory read and write.
Installing Valgrind:
# macOS (Homebrew)
brew install valgrind
# Ubuntu / Debian
sudo apt install valgrind
An important note: Valgrind does not yet fully support macOS on Apple Silicon (ARM). If you are on an M1/M2/M3/M4 Mac, consider running Valgrind inside a Linux VM or a Docker container.
For Valgrind to show detail (line numbers, function names), compile with debug symbols and optimisation disabled:
# Build with debug symbols (-g) and no optimisation (-O0)
gcc -g -O0 program.c -o program
# Run Valgrind with a detailed leak report
valgrind --leak-check=full --show-leak-kinds=all ./program
A worked example: the program below deliberately leaks memory, to show what Valgrind's output looks like:
#include <stdlib.h>
#include <stdio.h>
void create_leak() {
int *data = (int*) malloc(10 * sizeof(int));
data[0] = 42;
printf("data[0] = %d\n", data[0]);
// Loi: Khong goi free(data) truoc khi ham ket thuc!
}
int main() {
create_leak();
// Vung nho 40 bytes da bi ro ri vinh vien
return 0;
}
Running valgrind --leak-check=full ./leak_example produces:
==12345== Memcheck, a memory error detector ==12345== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al. ==12345== data[0] = 42 ==12345== ==12345== HEAP SUMMARY: ==12345== in use at exit: 40 bytes in 1 blocks ==12345== total heap usage: 2 allocs, 1 frees, 1,064 bytes allocated ==12345== ==12345== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1 ==12345== at 0x4C2BBAF: malloc (vg_replace_malloc.c:299) ==12345== by 0x40054E: create_leak (leak_example.c:5) ==12345== by 0x400568: main (leak_example.c:11) ==12345== ==12345== LEAK SUMMARY: ==12345== definitely lost: 40 bytes in 1 blocks ==12345== indirectly lost: 0 bytes in 0 blocks ==12345== possibly lost: 0 bytes in 0 blocks ==12345== still reachable: 0 bytes in 0 blocks ==12345== suppressed: 0 bytes in 0 blocks ==12345== ==12345== ERROR SUMMARY: 1 errors from 1 contexts
What Valgrind's key messages mean:
- definitely lost: memory that is certainly leaked β no pointer to it survives. This is the most serious category and should be fixed first.
- indirectly lost: memory lost indirectly β reachable only through a pointer that itself sits in a "definitely lost" block. Fixing the definite losses usually clears these automatically.
- possibly lost: Valgrind found a pointer into the middle of a block rather than to its start. This may be a bug, or a deliberate technique.
- still reachable: memory never released, but still pointed at when the program ended. Usually harmless, though worth cleaning up.
6.2. AddressSanitizer (ASan) β compile-time instrumentation
AddressSanitizer (ASan) is a memory error detector built into GCC and Clang. Rather than running your program in a virtual machine as Valgrind does, ASan injects checking code into the program at compile time, catching memory errors at a substantially lower performance cost.
How to use it:
# Build with AddressSanitizer
gcc -fsanitize=address -g -O1 program.c -o program
# Run it - ASan reports automatically as soon as it sees a violation
./program
A worked example: this program deliberately overflows a heap buffer:
#include <stdlib.h>
#include <stdio.h>
int main() {
int *arr = (int*) malloc(5 * sizeof(int));
if (arr == NULL) return 1;
// Loi: Ghi vao vi tri arr[5] β vuot qua gioi han mang (chi co index 0-4)
arr[5] = 999;
printf("arr[5] = %d\n", arr[5]);
free(arr);
return 0;
}
Compiled with -fsanitize=address and run, ASan reports in detail:
=================================================================
==54321==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000024
at pc 0x00010a3f1e8c bp 0x7ff7b3a01e60 sp 0x7ff7b3a01e58
WRITE of size 4 at 0x602000000024 thread T0
#0 0x10a3f1e8b in main overflow_example.c:9
#1 0x7fff20430620 in start (libdyld.dylib)
0x602000000024 is located 0 bytes after 20-byte region [0x602000000010, 0x602000000024)
allocated by thread T0 here:
#0 0x10a4a1a5d in wrap_malloc (libclang_rt.asan_osx_dynamic.dylib)
#1 0x10a3f1e4b in main overflow_example.c:5
SUMMARY: AddressSanitizer: heap-buffer-overflow overflow_example.c:9 in main
Where ASan wins over Valgrind:
- Far faster (roughly 2x slower than the original program, where Valgrind is 10β50x slower).
- Works well on macOS Apple Silicon (ARM) β no Linux VM needed.
- Can detect stack buffer overflows (stack-buffer-overflow), which Valgrind cannot.
The error classes ASan detects:
- heap-buffer-overflow: accessing beyond an allocated heap region.
- stack-buffer-overflow: accessing beyond an array or local variable on the stack.
- use-after-free: using memory after releasing it with
free(). - double-free: calling
free()twice on the same address. -
memory leaks: leak detection (compile with the additional
-fsanitize=leakflag).
6.3. A professional memory audit workflow
In real C projects, checking memory does not stop at running a single tool. Here is the audit workflow software engineers commonly follow:
Step 1: compile with strict warnings
# Turn on every warning to catch latent bugs at compile time
gcc -Wall -Wextra -g program.c -o program
Step 2: run under AddressSanitizer
# ASan catches buffer overflow, use-after-free and double-free
gcc -fsanitize=address -g -O1 program.c -o program_asan
./program_asan
Step 3: run under Valgrind
# Valgrind reports memory leaks in detail, with a stack trace
gcc -g -O0 program.c -o program_debug
valgrind --leak-check=full --show-leak-kinds=all ./program_debug
Step 4: a manual code review checklist
-
Every
malloc/callochas a matchingfree()on every execution path, including the error paths. -
Every
reallocuses a temporary pointer, so the old address is not lost on failure. -
Every result from
malloc/calloc/reallocis checked againstNULLbefore use. -
Pointers are set to
NULLimmediately afterfree(), to avoid dangling pointers.
Static analysis tools, as a complement:
# cppcheck - a static analyser for C/C++ source
cppcheck --enable=all program.c
# Clang Static Analyzer - walks execution paths to find logic bugs
clang --analyze program.c
Static analysis tools inspect the source without running the program, catching latent problems such as uninitialised variables, leaked resources and unreachable code.
The golden rule of C programming
Every time you write a dynamic allocation (malloc, calloc,
realloc), immediately write the NULL check alongside it and decide on the
matching free() + NULL assignment strategy. That habit is what keeps the
resource handling safe.
Comments