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:

malloc_basics.c
#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;
}
What it actually prints
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.

πŸ“ On casting the return value of malloc
The example above writes 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:

safe_realloc.c
#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) but ptr still points at the old address. Reading or writing through *ptr afterwards is a serious bug, because the OS may have handed that memory to something else. Always assign ptr = NULL; immediately after free.
  • Double free: calling free() twice on the same address without setting it to NULL in 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:

Terminal
# 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:

Terminal
# 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:

leak_example.c
#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:

Terminal
# 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:

overflow_example.c
#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=leak flag).

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

Terminal
# Turn on every warning to catch latent bugs at compile time
gcc -Wall -Wextra -g program.c -o program

Step 2: run under AddressSanitizer

Terminal
# 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

Terminal
# 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/calloc has a matching free() on every execution path, including the error paths.
  • Every realloc uses a temporary pointer, so the old address is not lost on failure.
  • Every result from malloc/calloc/realloc is checked against NULL before use.
  • Pointers are set to NULL immediately after free(), to avoid dangling pointers.

Static analysis tools, as a complement:

Terminal
# 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.

πŸ“ Check your understanding β€” Lesson 9
Which statement most accurately describes the heap compared with the stack in C?

Related lessons in this series

Lesson 10: Building data structures in C Lesson 8: Mastering pointers in C, from the basics upwards Back to the C series roadmap (Vietnamese)