C — Memory Management
C gives programmers direct control over memory through pointers, manual allocation, and an intimate understanding of the process memory layout. Unlike managed languages, there is no garbage collector — every byte you allocate must be explicitly freed. This power comes with responsibility: buffer overflows, dangling pointers, and memory leaks are among the most common and dangerous bugs in C programs.
Understanding the memory model is not optional for serious C programming. It determines performance characteristics, security properties, and correctness guarantees of your code. This page covers every layer — from the operating system's process layout down to individual allocation strategies and debugging tools.
When a C program is loaded into memory by the OS, the process address space is divided into distinct segments. Each segment serves a specific purpose and has different characteristics regarding mutability, lifetime, and access permissions.
| 1 | // Demonstration of memory layout segments |
| 2 | #include <stdio.h> |
| 3 | #include <stdlib.h> |
| 4 | |
| 5 | // Initialized global variable -> .data segment |
| 6 | int initialized_global = 42; |
| 7 | |
| 8 | // Uninitialized global variable -> .bss segment |
| 9 | int uninitialized_global; |
| 10 | |
| 11 | // Constant string literal -> .rodata (read-only data) |
| 12 | const char *constant_string = "Hello, Memory Layout"; |
| 13 | |
| 14 | // Function body -> .text segment (read-only machine code) |
| 15 | void function_example(void) { |
| 16 | // Local variable -> stack |
| 17 | int local_variable = 10; |
| 18 | printf("local_variable is at: %p\n", (void *)&local_variable); |
| 19 | } |
| 20 | |
| 21 | int main(void) { |
| 22 | // Local variable -> stack |
| 23 | int stack_var = 100; |
| 24 | |
| 25 | // Dynamically allocated -> heap |
| 26 | int *heap_var = malloc(sizeof(int)); |
| 27 | *heap_var = 200; |
| 28 | |
| 29 | printf("initialized_global: %p (data segment)\n", |
| 30 | (void *)&initialized_global); |
| 31 | printf("uninitialized_global: %p (bss segment)\n", |
| 32 | (void *)&uninitialized_global); |
| 33 | printf("constant_string: %p (rodata)\n", |
| 34 | (void *)constant_string); |
| 35 | printf("function_example: %p (text segment)\n", |
| 36 | (void *)function_example); |
| 37 | printf("stack_var: %p (stack)\n", |
| 38 | (void *)&stack_var); |
| 39 | printf("heap_var: %p (heap)\n", |
| 40 | (void *)heap_var); |
| 41 | |
| 42 | free(heap_var); |
| 43 | return 0; |
| 44 | } |
| Segment | Contains | Writable | Lifetime |
|---|---|---|---|
| .text | Compiled machine code | No | Program lifetime |
| .rodata | String literals, constants | No | Program lifetime |
| .data | Initialized globals and statics | Yes | Program lifetime |
| .bss | Uninitialized globals and statics | Yes | Program lifetime |
| Heap | malloc/calloc/realloc allocations | Yes | Until free() |
| Stack | Local variables, return addresses | Yes | Block scope |
The visual layout of a process address space from low to high addresses:
| 1 | Process Memory Layout (typical Linux x86-64) |
| 2 | +------------------------------- High Address (0x7FFF...) ---+ |
| 3 | | Stack | |
| 4 | | +---------------------------------------------------+ | |
| 5 | | | Function frames (local vars, args, ret) | | |
| 6 | | | Grows DOWN (toward lower addresses) | | |
| 7 | | +---------------------------------------------------+ | |
| 8 | | | | |
| 9 | | Stack Guard Page (unmapped) | |
| 10 | | | | |
| 11 | | +---------------------------------------------------+ | |
| 12 | | | Memory-mapped regions | | |
| 13 | | | (libraries, mmap'd files) | | |
| 14 | | +---------------------------------------------------+ | |
| 15 | | | | |
| 16 | | +---------------------------------------------------+ | |
| 17 | | | Heap | | |
| 18 | | | +---------------------------------------------+ | | |
| 19 | | | | malloc/calloc allocations | | | |
| 20 | | | | Grows UP (toward higher addresses) | | | |
| 21 | | | +---------------------------------------------+ | | |
| 22 | | +---------------------------------------------------+ | |
| 23 | | | | |
| 24 | | +------------------------------+ | |
| 25 | | | .bss (uninitialized data) | | |
| 26 | | +------------------------------+ | |
| 27 | | +------------------------------+ | |
| 28 | | | .data (initialized data) | | |
| 29 | | +------------------------------+ | |
| 30 | | +------------------------------+ | |
| 31 | | | .rodata (read-only data) | | |
| 32 | | +------------------------------+ | |
| 33 | | +------------------------------+ | |
| 34 | | | .text (code segment) | | |
| 35 | | +------------------------------+ | |
| 36 | +------------------------------- Low Address (0x400000) ----+ |
The stack is a region of memory that operates in a LIFO (Last In, First Out) manner. It is used for automatic storage — local variables, function parameters, and return addresses are all stored on the stack. Allocation is handled implicitly by adjusting the stack pointer, making it extremely fast (typically a single instruction).
Stack Frame Anatomy:
| 1 | Stack Frame for function call foo(42, 100): |
| 2 | |
| 3 | High Address |
| 4 | +-----------------------------------+ |
| 5 | | Previous Frame's Stack Pointer | <- Frame Pointer (EBP/RBP) |
| 6 | +-----------------------------------+ |
| 7 | | Return Address | <- 8 bytes on x86-64 |
| 8 | +-----------------------------------+ |
| 9 | | Saved Frame Pointer | <- Saved EBP/RBP from caller |
| 10 | +-----------------------------------+ |
| 11 | | Parameter: 100 (arg 2) | <- Passed on stack (or registers) |
| 12 | +-----------------------------------+ |
| 13 | | Parameter: 42 (arg 1) | |
| 14 | +-----------------------------------+ |
| 15 | | Local variable: int x | <- Compiler may use registers first |
| 16 | +-----------------------------------+ |
| 17 | | Local variable: char buf[64] | |
| 18 | +-----------------------------------+ |
| 19 | | Saved registers (callee-saved) | <- RBX, R12-R15 if used |
| 20 | +-----------------------------------+ |
| 21 | | (Padding for alignment) | <- Stack must be 16-byte aligned |
| 22 | +-----------------------------------+ |
| 23 | Low Address <- Stack Pointer (ESP/RSP) |
| 1 | #include <stdio.h> |
| 2 | |
| 3 | // Each function call creates a new stack frame |
| 4 | void inner_function(int value) { |
| 5 | // This local variable lives in inner_function's stack frame |
| 6 | int local_copy = value * 2; |
| 7 | printf("inner_function: local_copy = %d, address = %p\n", |
| 8 | local_copy, (void *)&local_copy); |
| 9 | // When this function returns, local_copy is destroyed |
| 10 | } |
| 11 | |
| 12 | void outer_function(int a, int b) { |
| 13 | // Parameters a and b are in outer_function's stack frame |
| 14 | int result = a + b; |
| 15 | printf("outer_function: result = %d, address = %p\n", |
| 16 | result, (void *)&result); |
| 17 | |
| 18 | // Calling inner_function pushes a new frame onto the stack |
| 19 | inner_function(result); |
| 20 | printf("outer_function resumes after inner_function returns\n"); |
| 21 | } |
| 22 | |
| 23 | int main(void) { |
| 24 | printf("main: stack of main at %p\n", (void *)&main); |
| 25 | outer_function(10, 20); |
| 26 | return 0; |
| 27 | } |
note
Stack Overflow: Recursion or deep call chains can exhaust the stack. The default stack size is typically 1-8 MB.
| 1 | #include <stdio.h> |
| 2 | |
| 3 | // WARNING: This will cause a stack overflow! |
| 4 | void infinite_recursion(int depth) { |
| 5 | // Each call allocates a new stack frame |
| 6 | // Eventually the stack pointer hits the guard page |
| 7 | char waste[1024]; // 1KB per call |
| 8 | printf("depth: %d, address: %p\n", depth, (void *)waste); |
| 9 | infinite_recursion(depth + 1); |
| 10 | } |
| 11 | |
| 12 | // Safe: Tail-call optimized version (with -O2) |
| 13 | int countdown_safe(int n) { |
| 14 | if (n <= 0) return 0; |
| 15 | printf("%d...\n", n); |
| 16 | // Compiler may reuse the current stack frame |
| 17 | return countdown_safe(n - 1); // tail call |
| 18 | } |
| 19 | |
| 20 | int main(void) { |
| 21 | // infinite_recursion(0); // DO NOT RUN - will crash |
| 22 | countdown_safe(100000); // Safe with optimization |
| 23 | return 0; |
| 24 | } |
warning
ulimit -s (default is often 8192 KB). Use recursion judiciously and convert to iteration when call depth is unbounded.The heap is the region of memory used for dynamic allocation. Unlike the stack, heap allocations persist until explicitly freed, and they can be of any size. The heap grows upward (toward higher addresses) on most systems, while the stack grows downward — they meet in the middle.
malloc / calloc / realloc / free:
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | #include <string.h> |
| 4 | |
| 5 | int main(void) { |
| 6 | // malloc: allocates uninitialized memory |
| 7 | // Contents are indeterminate (could be anything) |
| 8 | int *arr_malloc = malloc(5 * sizeof(int)); |
| 9 | if (!arr_malloc) { |
| 10 | perror("malloc failed"); |
| 11 | return 1; |
| 12 | } |
| 13 | printf("malloc uninitialized: %d %d %d\n", |
| 14 | arr_malloc[0], arr_malloc[1], arr_malloc[2]); // Garbage |
| 15 | |
| 16 | // calloc: allocates zero-initialized memory |
| 17 | // All bits set to zero -- safer for most use cases |
| 18 | int *arr_calloc = calloc(5, sizeof(int)); |
| 19 | if (!arr_calloc) { |
| 20 | perror("calloc failed"); |
| 21 | free(arr_malloc); |
| 22 | return 1; |
| 23 | } |
| 24 | printf("calloc zero-initialized: %d %d %d\n", |
| 25 | arr_calloc[0], arr_calloc[1], arr_calloc[2]); // All 0 |
| 26 | |
| 27 | // realloc: resize a previous allocation |
| 28 | // May move the block if growth is not possible in place |
| 29 | arr_malloc = realloc(arr_malloc, 10 * sizeof(int)); |
| 30 | if (!arr_malloc) { |
| 31 | perror("realloc failed"); |
| 32 | free(arr_calloc); |
| 33 | return 1; |
| 34 | } |
| 35 | // Original contents preserved, new area is indeterminate |
| 36 | printf("realloc preserved: %d, new slots indeterminate\n", |
| 37 | arr_malloc[0]); |
| 38 | |
| 39 | // free: release memory back to the heap |
| 40 | // After free(), the pointer is dangling -- set to NULL |
| 41 | free(arr_malloc); |
| 42 | arr_malloc = NULL; |
| 43 | free(arr_calloc); |
| 44 | arr_calloc = NULL; |
| 45 | |
| 46 | printf("Memory freed successfully\n"); |
| 47 | return 0; |
| 48 | } |
info
Memory alignment refers to the requirement that data types be stored at addresses that are multiples of their size. Most CPU architectures require aligned access for performance, and some (like ARM) will fault on misaligned access. Even on x86, which allows unaligned access, misaligned data causes performance penalties of 2-10x.
| 1 | #include <stdio.h> |
| 2 | #include <stdalign.h> |
| 3 | #include <stdint.h> |
| 4 | |
| 5 | int main(void) { |
| 6 | // Typical alignment requirements on 64-bit systems: |
| 7 | // char: 1 byte alignment |
| 8 | // short: 2 byte alignment |
| 9 | // int: 4 byte alignment |
| 10 | // long: 8 byte alignment |
| 11 | // pointer: 8 byte alignment |
| 12 | |
| 13 | printf("sizeof(char) = %zu, alignof(char) = %zu\n", |
| 14 | sizeof(char), alignof(char)); |
| 15 | printf("sizeof(short) = %zu, alignof(short) = %zu\n", |
| 16 | sizeof(short), alignof(short)); |
| 17 | printf("sizeof(int) = %zu, alignof(int) = %zu\n", |
| 18 | sizeof(int), alignof(int)); |
| 19 | printf("sizeof(long) = %zu, alignof(long) = %zu\n", |
| 20 | sizeof(long), alignof(long)); |
| 21 | printf("sizeof(void*) = %zu, alignof(void*) = %zu\n", |
| 22 | sizeof(void *), alignof(void *)); |
| 23 | |
| 24 | // Struct padding due to alignment |
| 25 | struct Bad { |
| 26 | char a; // 1 byte + 3 padding |
| 27 | int b; // 4 bytes |
| 28 | char c; // 1 byte + 7 padding |
| 29 | }; |
| 30 | |
| 31 | struct Good { |
| 32 | int b; // 4 bytes |
| 33 | char a; // 1 byte |
| 34 | char c; // 1 byte + 2 padding |
| 35 | }; |
| 36 | |
| 37 | printf("sizeof(Bad) = %zu (wasted %zu bytes)\n", |
| 38 | sizeof(struct Bad), |
| 39 | sizeof(struct Bad) - sizeof(char) - sizeof(int) - sizeof(char)); |
| 40 | printf("sizeof(Good) = %zu (wasted %zu bytes)\n", |
| 41 | sizeof(struct Good), |
| 42 | sizeof(struct Good) - sizeof(int) - sizeof(char) - sizeof(char)); |
| 43 | |
| 44 | // C11 alignas for explicit control |
| 45 | alignas(64) int cache_aligned; // Aligned to cache line boundary |
| 46 | printf("cache_aligned address: %p (%%64 == %zu)\n", |
| 47 | (void *)&cache_aligned, |
| 48 | (uintptr_t)&cache_aligned % 64); |
| 49 | |
| 50 | return 0; |
| 51 | } |
| Type | Size (64-bit) | Alignment | Packed Layout |
|---|---|---|---|
| char | 1 byte | 1 byte | 0 |
| short | 2 bytes | 2 bytes | 0 |
| int | 4 bytes | 4 bytes | 0 |
| long / pointer | 8 bytes | 8 bytes | 0 |
| { char; int; char; } | 12 bytes | 4 bytes | 6 wasted |
best practice
A buffer overflow occurs when a program writes data beyond the boundaries of a pre-allocated fixed-length buffer. This is one of the most exploited vulnerability classes in history — the Morris Worm (1988) used a buffer overflow in gets(). Modern compilers add mitigations (stack canaries, ASLR, NX bit), but the fundamental problem remains if programmers are careless.
| 1 | #include <stdio.h> |
| 2 | #include <string.h> |
| 3 | #include <stdlib.h> |
| 4 | |
| 5 | // VULNERABLE: no bounds checking |
| 6 | void vulnerable_copy(const char *input) { |
| 7 | char buffer[64]; |
| 8 | // strcpy does NOT check buffer size -- DANGEROUS |
| 9 | strcpy(buffer, input); |
| 10 | printf("Copied: %s\n", buffer); |
| 11 | } |
| 12 | |
| 13 | // SAFE: use strncpy or snprintf with explicit bounds |
| 14 | void safe_copy(const char *input) { |
| 15 | char buffer[64]; |
| 16 | // Always ensure null termination after strncpy |
| 17 | strncpy(buffer, input, sizeof(buffer) - 1); |
| 18 | buffer[sizeof(buffer) - 1] = '\0'; |
| 19 | printf("Copied: %s\n", buffer); |
| 20 | } |
| 21 | |
| 22 | // BETTER: use snprintf for automatic bounds checking |
| 23 | void better_copy(const char *input) { |
| 24 | char buffer[64]; |
| 25 | snprintf(buffer, sizeof(buffer), "%s", input); |
| 26 | printf("Copied: %s\n", buffer); |
| 27 | } |
| 28 | |
| 29 | // BEST: use a string library with dynamic sizing |
| 30 | void best_copy(const char *input) { |
| 31 | size_t len = strlen(input); |
| 32 | char *buffer = malloc(len + 1); |
| 33 | if (!buffer) return; |
| 34 | memcpy(buffer, input, len + 1); |
| 35 | printf("Copied: %s\n", buffer); |
| 36 | free(buffer); |
| 37 | } |
| 38 | |
| 39 | int main(void) { |
| 40 | char safe_input[] = "Hello, this is a normal string"; |
| 41 | char long_input[] = "This string is definitely longer than " |
| 42 | "sixty-four bytes and will overflow " |
| 43 | "a fixed-size buffer if not handled properly"; |
| 44 | |
| 45 | vulnerable_copy(safe_input); |
| 46 | vulnerable_copy(long_input); // Buffer overflow! |
| 47 | |
| 48 | safe_copy(safe_input); |
| 49 | safe_copy(long_input); // Truncated safely |
| 50 | |
| 51 | better_copy(safe_input); |
| 52 | better_copy(long_input); // Truncated safely |
| 53 | |
| 54 | return 0; |
| 55 | } |
warning
Use-after-free (UAF) occurs when a program continues to use a pointer after the memory it points to has been freed. The freed memory may be reallocated for different purposes, leading to data corruption, crashes, or exploitable security vulnerabilities. Double free — freeing the same pointer twice — corrupts the heap metadata and can also be exploited.
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | #include <string.h> |
| 4 | |
| 5 | // Use-after-free: the pointer is still used after free() |
| 6 | void use_after_free_example(void) { |
| 7 | int *data = malloc(sizeof(int)); |
| 8 | *data = 42; |
| 9 | printf("Before free: %d\n", *data); |
| 10 | |
| 11 | free(data); |
| 12 | // data is now a DANGLING POINTER |
| 13 | // The memory may be reused by another allocation |
| 14 | |
| 15 | // BUG: accessing freed memory -- undefined behavior! |
| 16 | // printf("After free: %d\n", *data); |
| 17 | |
| 18 | // The safe pattern: set pointer to NULL after freeing |
| 19 | data = NULL; |
| 20 | // if (data) printf("Safe: %d\n", *data); // This check works |
| 21 | } |
| 22 | |
| 23 | // Double free: freeing the same pointer twice |
| 24 | void double_free_example(void) { |
| 25 | char *str = malloc(32); |
| 26 | strcpy(str, "Hello"); |
| 27 | |
| 28 | free(str); |
| 29 | // BUG: double free corrupts heap metadata |
| 30 | // free(str); // DO NOT DO THIS |
| 31 | |
| 32 | // Safe pattern: set to NULL after free |
| 33 | str = NULL; |
| 34 | } |
| 35 | |
| 36 | // Correct linked list node removal (no UAF) |
| 37 | struct Node { |
| 38 | int value; |
| 39 | struct Node *next; |
| 40 | }; |
| 41 | |
| 42 | struct Node *remove_node(struct Node *head, int target) { |
| 43 | struct Node *curr = head; |
| 44 | struct Node *prev = NULL; |
| 45 | |
| 46 | while (curr != NULL) { |
| 47 | if (curr->value == target) { |
| 48 | if (prev == NULL) { |
| 49 | head = curr->next; |
| 50 | } else { |
| 51 | prev->next = curr->next; |
| 52 | } |
| 53 | free(curr); |
| 54 | // curr is now dangling, but we return immediately |
| 55 | // and don't use it again |
| 56 | return head; |
| 57 | } |
| 58 | prev = curr; |
| 59 | curr = curr->next; |
| 60 | } |
| 61 | return head; |
| 62 | } |
| 63 | |
| 64 | int main(void) { |
| 65 | use_after_free_example(); |
| 66 | double_free_example(); |
| 67 | printf("Examples completed (UB was avoided)\n"); |
| 68 | return 0; |
| 69 | } |
pro tip
-fsanitize=address to catch use-after-free and double-free bugs at runtime. AddressSanitizer instruments every memory access and maintains shadow memory to detect invalid operations. It adds ~2x overhead but catches bugs that valgrind might miss.A memory leak occurs when allocated memory is no longer reachable (no pointers reference it) but has not been freed. Over time, leaks consume increasing amounts of memory, eventually exhausting available resources. In long-running programs (servers, daemons), even small leaks can cause catastrophic failures.
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | #include <string.h> |
| 4 | |
| 5 | // LEAK: memory allocated but never freed |
| 6 | void leak_example(void) { |
| 7 | char *buffer = malloc(1024); |
| 8 | strcpy(buffer, "This memory is leaked"); |
| 9 | printf("%s\n", buffer); |
| 10 | // buffer goes out of scope -- pointer is lost |
| 11 | // Memory is still allocated but unreachable |
| 12 | } |
| 13 | |
| 14 | // LEAK: losing pointer before freeing |
| 15 | void losing_pointer(void) { |
| 16 | int *data = malloc(sizeof(int) * 100); |
| 17 | data = malloc(sizeof(int) * 200); // Lost the first allocation! |
| 18 | free(data); |
| 19 | } |
| 20 | |
| 21 | // CORRECT: proper allocation and deallocation |
| 22 | void no_leak(void) { |
| 23 | int *data = malloc(sizeof(int) * 100); |
| 24 | if (!data) return; |
| 25 | |
| 26 | // ... use data ... |
| 27 | |
| 28 | free(data); |
| 29 | data = NULL; |
| 30 | } |
| 31 | |
| 32 | // CORRECT: error path cleanup |
| 33 | int process_data(const char *filename) { |
| 34 | FILE *fp = NULL; |
| 35 | char *buffer = NULL; |
| 36 | int *results = NULL; |
| 37 | int status = -1; |
| 38 | |
| 39 | fp = fopen(filename, "r"); |
| 40 | if (!fp) goto cleanup; |
| 41 | |
| 42 | buffer = malloc(4096); |
| 43 | if (!buffer) goto cleanup; |
| 44 | |
| 45 | results = malloc(sizeof(int) * 1000); |
| 46 | if (!results) goto cleanup; |
| 47 | |
| 48 | // ... processing ... |
| 49 | status = 0; |
| 50 | |
| 51 | cleanup: |
| 52 | free(results); // free(NULL) is safe (no-op) |
| 53 | free(buffer); |
| 54 | if (fp) fclose(fp); |
| 55 | return status; |
| 56 | } |
| 57 | |
| 58 | int main(void) { |
| 59 | leak_example(); |
| 60 | losing_pointer(); |
| 61 | printf("Memory leaks occurred -- use valgrind to detect\n"); |
| 62 | return 0; |
| 63 | } |
| Leak Type | Description | Severity |
|---|---|---|
| Lost pointer | Pointer overwritten without freeing | High |
| Missing free | Forgot to free at end of scope | Medium |
| Error path leak | Early return skips cleanup code | High |
| Growing data structure | Nodes added but never removed/freed | High |
Two essential tools for finding memory bugs are Valgrind (a dynamic analysis framework) and AddressSanitizer (a compiler-based sanitizer). They detect different classes of bugs and are complementary — use both in your development workflow.
Valgrind Memcheck: The most widely used tool for memory debugging. Runs your program in a virtual machine and instruments every memory access.
| 1 | # Compile with debug symbols (no optimization) |
| 2 | gcc -g -O0 -o my_program my_program.c |
| 3 | |
| 4 | # Run with Valgrind memcheck |
| 5 | valgrind --leak-check=full \ |
| 6 | --show-leak-kinds=all \ |
| 7 | --track-origins=yes \ |
| 8 | --verbose \ |
| 9 | ./my_program |
| 10 | |
| 11 | # Common Valgrind output patterns: |
| 12 | # ==12345== Invalid read of size 4 -> out-of-bounds or use-after-free |
| 13 | # ==12345== Invalid write of size 4 -> buffer overflow |
| 14 | # ==12345== Conditional jump depends on -> use of uninitialized value |
| 15 | # ==12345== 48 bytes in 1 blocks are -> memory leak (definitely lost) |
| 16 | # ==12345== Mismatched free() / delete -> heap corruption |
| 17 | # ==12345== Heap summary: -> detailed heap statistics |
AddressSanitizer (ASan): A fast compiler-based tool that detects out-of-bounds accesses, use-after-free, and stack/heap buffer overflows. Typically adds only 2x slowdown.
| 1 | # Compile with AddressSanitizer |
| 2 | gcc -g -fsanitize=address -fno-omit-frame-pointer \ |
| 3 | -o my_program_asan my_program.c |
| 4 | |
| 5 | # Run -- ASan intercepts every memory operation |
| 6 | ./my_program_asan |
| 7 | |
| 8 | # ASan output example: |
| 9 | # ==4567==ERROR: AddressSanitizer: heap-buffer-overflow |
| 10 | # on address 0x60200000eff8 at pc 0x000000401234 |
| 11 | # WRITE of size 4 at 0x60200000eff8 thread T0 |
| 12 | # #0 0x401233 in overflow_func |
| 13 | # #1 0x401299 in main |
| 14 | |
| 15 | # Also available: ThreadSanitizer (TSan), UndefinedBehaviorSanitizer (UBSan) |
| 16 | gcc -g -fsanitize=address,undefined -o my_program_ubsan my_program.c |
best practice
Many forms of undefined behavior (UB) in C relate to memory access. The compiler assumes UB never happens, so it may optimize aggressively, making UB bugs particularly hard to diagnose. A program that accesses memory out of bounds, uses a dangling pointer, or dereferences NULL may appear to "work" in debug builds but crash or produce wrong results in release builds.
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | |
| 4 | int main(void) { |
| 5 | // 1. Out-of-bounds array access (UB) |
| 6 | int arr[5] = {10, 20, 30, 40, 50}; |
| 7 | // int bad = arr[10]; // UB: reading past array bounds |
| 8 | |
| 9 | // 2. Dangling pointer (UB) |
| 10 | int *ptr; |
| 11 | { |
| 12 | int local = 42; |
| 13 | ptr = &local; |
| 14 | } |
| 15 | // *ptr; // UB: local is out of scope, ptr is dangling |
| 16 | |
| 17 | // 3. NULL pointer dereference (UB) |
| 18 | int *null_ptr = NULL; |
| 19 | // *null_ptr = 10; // UB: null pointer dereference |
| 20 | |
| 21 | // 4. Signed integer overflow (UB) |
| 22 | int max = __INT_MAX__; |
| 23 | // int overflow = max + 1; // UB: signed overflow |
| 24 | |
| 25 | // 5. Using freed memory (UB) |
| 26 | int *dynamic = malloc(sizeof(int)); |
| 27 | *dynamic = 100; |
| 28 | free(dynamic); |
| 29 | // int val = *dynamic; // UB: use-after-free |
| 30 | |
| 31 | printf("All UB examples are commented out for safety\n"); |
| 32 | return 0; |
| 33 | } |
| UB Type | Consequence | Detection |
|---|---|---|
| Buffer overflow | Data corruption, code execution | ASan, Valgrind |
| Use-after-free | Crash, exploitable vulnerability | ASan, Valgrind |
| Dangling pointer | Unpredictable reads/writes | ASan, static analysis |
| NULL dereference | Segfault | UBSan, Valgrind |
| Uninitialized read | Non-deterministic behavior | Valgrind, MSAN |
Beyond Valgrind and ASan, several practical techniques help prevent and detect memory bugs at the source level. Guard pages, canary values, and red zones are techniques used by both programmers and runtime environments to catch buffer overflows.
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | #include <string.h> |
| 4 | |
| 5 | // Stack canary: a value placed before the return address |
| 6 | // If a buffer overflow overwrites the canary, the program |
| 7 | // detects corruption before returning from the function |
| 8 | |
| 9 | // On GCC/Clang, this is automatic with -fstack-protector-strong |
| 10 | // But you can implement a manual version for heap allocations: |
| 11 | |
| 12 | #define CANARY_VALUE 0xDEADBEEF |
| 13 | |
| 14 | typedef struct { |
| 15 | unsigned int canary_head; |
| 16 | size_t size; |
| 17 | void *data; |
| 18 | unsigned int canary_tail; |
| 19 | } ProtectedBlock; |
| 20 | |
| 21 | ProtectedBlock *protected_alloc(size_t size) { |
| 22 | ProtectedBlock *block = malloc(sizeof(ProtectedBlock)); |
| 23 | if (!block) return NULL; |
| 24 | |
| 25 | block->canary_head = CANARY_VALUE; |
| 26 | block->canary_tail = CANARY_VALUE; |
| 27 | block->size = size; |
| 28 | block->data = malloc(size); |
| 29 | if (!block->data) { |
| 30 | free(block); |
| 31 | return NULL; |
| 32 | } |
| 33 | return block; |
| 34 | } |
| 35 | |
| 36 | int protected_check(ProtectedBlock *block) { |
| 37 | if (block->canary_head != CANARY_VALUE) { |
| 38 | fprintf(stderr, "CORRUPTION: head canary mismatch!\n"); |
| 39 | return -1; |
| 40 | } |
| 41 | if (block->canary_tail != CANARY_VALUE) { |
| 42 | fprintf(stderr, "CORRUPTION: tail canary mismatch!\n"); |
| 43 | return -1; |
| 44 | } |
| 45 | return 0; |
| 46 | } |
| 47 | |
| 48 | void protected_free(ProtectedBlock *block) { |
| 49 | if (!block) return; |
| 50 | if (protected_check(block) != 0) { |
| 51 | fprintf(stderr, "Freeing corrupted block anyway\n"); |
| 52 | } |
| 53 | free(block->data); |
| 54 | free(block); |
| 55 | } |
| 56 | |
| 57 | // Red zone: extra padding around allocations to detect |
| 58 | // out-of-bounds writes that go slightly beyond the buffer |
| 59 | #define RED_ZONE_SIZE 16 |
| 60 | |
| 61 | void *redzone_alloc(size_t size) { |
| 62 | size_t total = RED_ZONE_SIZE + size + RED_ZONE_SIZE; |
| 63 | char *raw = malloc(total); |
| 64 | if (!raw) return NULL; |
| 65 | |
| 66 | // Fill red zones with known pattern |
| 67 | memset(raw, 0xAA, RED_ZONE_SIZE); // Head red zone |
| 68 | memset(raw + RED_ZONE_SIZE + size, 0xAA, RED_ZONE_SIZE); // Tail red zone |
| 69 | |
| 70 | return raw + RED_ZONE_SIZE; // Return pointer past head red zone |
| 71 | } |
| 72 | |
| 73 | int redzone_check(void *ptr, size_t size) { |
| 74 | char *raw = (char *)ptr - RED_ZONE_SIZE; |
| 75 | for (size_t i = 0; i < RED_ZONE_SIZE; i++) { |
| 76 | if ((unsigned char)raw[i] != 0xAA) return -1; |
| 77 | } |
| 78 | for (size_t i = 0; i < RED_ZONE_SIZE; i++) { |
| 79 | if ((unsigned char)raw[RED_ZONE_SIZE + size + i] != 0xAA) return -1; |
| 80 | } |
| 81 | return 0; |
| 82 | } |
| 83 | |
| 84 | int main(void) { |
| 85 | ProtectedBlock *block = protected_alloc(256); |
| 86 | if (block) { |
| 87 | printf("Block allocated, head canary: 0x%X\n", |
| 88 | block->canary_head); |
| 89 | protected_check(block); |
| 90 | protected_free(block); |
| 91 | } |
| 92 | |
| 93 | void *rz = redzone_alloc(128); |
| 94 | if (rz) { |
| 95 | printf("Redzone allocation OK, check: %d\n", |
| 96 | redzone_check(rz, 128)); |
| 97 | free((char *)rz - RED_ZONE_SIZE); |
| 98 | } |
| 99 | |
| 100 | return 0; |
| 101 | } |
info
-fstack-protector-strong (GCC/Clang) to enable automatic stack canaries. This detects stack buffer overflows by placing a random value between local arrays and the saved return address. The program aborts if the canary is corrupted.| Property | Stack | Heap |
|---|---|---|
| Allocation speed | ~1 instruction (sub rsp) | Slower (OS allocator + search) |
| Maximum size | 1-8 MB (system limit) | Available RAM + swap |
| Lifetime | Scope (automatic) | Until explicit free() |
| Management | Automatic (compiler) | Manual (programmer) |
| Fragmentation | None (LIFO order) | Can fragment over time |
| Variable size | Fixed at compile time (VLAs aside) | Resizable with realloc() |
| Thread safety | Per-thread (no sharing needed) | Shared (requires synchronization) |
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | |
| 4 | // Stack allocation: fast, automatic, limited |
| 5 | void stack_heavy(int n) { |
| 6 | // VLA (Variable Length Array) -- stack allocated |
| 7 | // C99 feature, optional in C11, removed in C23 |
| 8 | // WARNING: can cause stack overflow for large n |
| 9 | int local_arr[n]; |
| 10 | for (int i = 0; i < n; i++) { |
| 11 | local_arr[i] = i; |
| 12 | } |
| 13 | printf("Stack array sum: %d\n", local_arr[n - 1]); |
| 14 | } |
| 15 | |
| 16 | // Heap allocation: slower, manual, flexible |
| 17 | void heap_heavy(int n) { |
| 18 | int *heap_arr = malloc(n * sizeof(int)); |
| 19 | if (!heap_arr) { |
| 20 | perror("malloc failed"); |
| 21 | return; |
| 22 | } |
| 23 | for (int i = 0; i < n; i++) { |
| 24 | heap_arr[i] = i; |
| 25 | } |
| 26 | printf("Heap array sum: %d\n", heap_arr[n - 1]); |
| 27 | free(heap_arr); |
| 28 | } |
| 29 | |
| 30 | int main(void) { |
| 31 | // For small, fixed-size data: prefer stack |
| 32 | int small[100]; // Fast, automatic cleanup |
| 33 | |
| 34 | // For large or dynamic-size data: use heap |
| 35 | int *large = malloc(1000000 * sizeof(int)); // Can be huge |
| 36 | if (large) { |
| 37 | heap_heavy(1000000); |
| 38 | free(large); |
| 39 | } |
| 40 | |
| 41 | return 0; |
| 42 | } |
The mmap() system call maps a file (or anonymous memory) into the process address space. This provides zero-copy file I/O and can be significantly faster than read()/write() for large files. Memory-mapped files appear as if they were loaded into memory, and the OS handles paging data in and out of disk transparently.
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | #include <string.h> |
| 4 | #include <sys/mman.h> |
| 5 | #include <sys/stat.h> |
| 6 | #include <fcntl.h> |
| 7 | #include <unistd.h> |
| 8 | |
| 9 | // Memory-map a file for reading (POSIX) |
| 10 | int mmap_file(const char *filename, void **addr, size_t *length) { |
| 11 | int fd = open(filename, O_RDONLY); |
| 12 | if (fd == -1) { |
| 13 | perror("open"); |
| 14 | return -1; |
| 15 | } |
| 16 | |
| 17 | struct stat st; |
| 18 | if (fstat(fd, &st) == -1) { |
| 19 | perror("fstat"); |
| 20 | close(fd); |
| 21 | return -1; |
| 22 | } |
| 23 | |
| 24 | *length = st.st_size; |
| 25 | *addr = mmap(NULL, *length, PROT_READ, MAP_PRIVATE, fd, 0); |
| 26 | close(fd); |
| 27 | |
| 28 | if (*addr == MAP_FAILED) { |
| 29 | perror("mmap"); |
| 30 | return -1; |
| 31 | } |
| 32 | |
| 33 | return 0; |
| 34 | } |
| 35 | |
| 36 | // Anonymous mmap: allocate memory without backing file |
| 37 | // More flexible than malloc for large allocations |
| 38 | void *mmap_alloc(size_t size) { |
| 39 | void *ptr = mmap(NULL, size, |
| 40 | PROT_READ | PROT_WRITE, |
| 41 | MAP_PRIVATE | MAP_ANONYMOUS, |
| 42 | -1, 0); |
| 43 | if (ptr == MAP_FAILED) return NULL; |
| 44 | return ptr; |
| 45 | } |
| 46 | |
| 47 | void mmap_free(void *ptr, size_t size) { |
| 48 | munmap(ptr, size); |
| 49 | } |
| 50 | |
| 51 | int main(int argc, char *argv[]) { |
| 52 | // Example: mmap a file |
| 53 | void *file_data; |
| 54 | size_t file_size; |
| 55 | if (mmap_file("/etc/hostname", &file_data, &file_size) == 0) { |
| 56 | printf("Mapped %zu bytes: %.*s\n", |
| 57 | file_size, (int)file_size, (char *)file_data); |
| 58 | munmap(file_data, file_size); |
| 59 | } |
| 60 | |
| 61 | // Example: anonymous mmap allocation |
| 62 | size_t alloc_size = 1024 * 1024; // 1 MB |
| 63 | void *mem = mmap_alloc(alloc_size); |
| 64 | if (mem) { |
| 65 | memset(mem, 0, alloc_size); |
| 66 | printf("mmap allocated %zu bytes\n", alloc_size); |
| 67 | mmap_free(mem, alloc_size); |
| 68 | } |
| 69 | |
| 70 | return 0; |
| 71 | } |
note
A memory pool pre-allocates a large block of memory and sub-allocates from it, avoiding the overhead of individual malloc/free calls. Pools are ideal for scenarios where many objects of the same size are allocated and freed frequently (e.g., network packets, AST nodes, game objects). They eliminate fragmentation and provide O(1) allocation.
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | #include <string.h> |
| 4 | |
| 5 | #define POOL_BLOCK_SIZE 4096 |
| 6 | |
| 7 | typedef struct PoolBlock { |
| 8 | struct PoolBlock *next; |
| 9 | char data[POOL_BLOCK_SIZE]; |
| 10 | } PoolBlock; |
| 11 | |
| 12 | typedef struct { |
| 13 | PoolBlock *blocks; |
| 14 | size_t offset; // Current offset within top block |
| 15 | size_t total_allocated; |
| 16 | size_t total_used; |
| 17 | } MemoryPool; |
| 18 | |
| 19 | MemoryPool *pool_create(void) { |
| 20 | MemoryPool *pool = malloc(sizeof(MemoryPool)); |
| 21 | if (!pool) return NULL; |
| 22 | pool->blocks = NULL; |
| 23 | pool->offset = POOL_BLOCK_SIZE; // Force first allocation |
| 24 | pool->total_allocated = 0; |
| 25 | pool->total_used = 0; |
| 26 | return pool; |
| 27 | } |
| 28 | |
| 29 | static PoolBlock *pool_new_block(MemoryPool *pool) { |
| 30 | PoolBlock *block = malloc(sizeof(PoolBlock)); |
| 31 | if (!block) return NULL; |
| 32 | block->next = pool->blocks; |
| 33 | pool->blocks = block; |
| 34 | pool->offset = 0; |
| 35 | pool->total_allocated += POOL_BLOCK_SIZE; |
| 36 | return block; |
| 37 | } |
| 38 | |
| 39 | void *pool_alloc(MemoryPool *pool, size_t size) { |
| 40 | // Align to 8-byte boundary |
| 41 | size = (size + 7) & ~(size_t)7; |
| 42 | |
| 43 | // Does current block have enough space? |
| 44 | if (pool->offset + size > POOL_BLOCK_SIZE) { |
| 45 | if (size > POOL_BLOCK_SIZE) return NULL; // Too large |
| 46 | if (!pool_new_block(pool)) return NULL; |
| 47 | } |
| 48 | |
| 49 | void *ptr = pool->blocks->data + pool->offset; |
| 50 | pool->offset += size; |
| 51 | pool->total_used += size; |
| 52 | return ptr; |
| 53 | } |
| 54 | |
| 55 | void pool_destroy(MemoryPool *pool) { |
| 56 | if (!pool) return; |
| 57 | PoolBlock *block = pool->blocks; |
| 58 | while (block) { |
| 59 | PoolBlock *next = block->next; |
| 60 | free(block); |
| 61 | block = next; |
| 62 | } |
| 63 | free(pool); |
| 64 | } |
| 65 | |
| 66 | // Example: allocate many small objects efficiently |
| 67 | int main(void) { |
| 68 | MemoryPool *pool = pool_create(); |
| 69 | if (!pool) { |
| 70 | perror("pool_create"); |
| 71 | return 1; |
| 72 | } |
| 73 | |
| 74 | // Allocate 10000 small structures |
| 75 | for (int i = 0; i < 10000; i++) { |
| 76 | int *obj = pool_alloc(pool, sizeof(int)); |
| 77 | if (!obj) { |
| 78 | fprintf(stderr, "Pool allocation failed at i=%d\n", i); |
| 79 | break; |
| 80 | } |
| 81 | *obj = i; |
| 82 | } |
| 83 | |
| 84 | printf("Pool stats: allocated=%zu bytes, used=%zu bytes\n", |
| 85 | pool->total_allocated, pool->total_used); |
| 86 | printf("Efficiency: %.1f%%\n", |
| 87 | 100.0 * pool->total_used / pool->total_allocated); |
| 88 | |
| 89 | // Single free releases everything -- no individual free needed |
| 90 | pool_destroy(pool); |
| 91 | return 0; |
| 92 | } |
pro tip
These practices will save you from the most common memory bugs in C. Follow them consistently and your code will be dramatically more reliable.
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | #include <string.h> |
| 4 | |
| 5 | // Practice 1: Always initialize pointers to NULL |
| 6 | void practice_init(void) { |
| 7 | int *ptr = NULL; // Safe: free(NULL) is a no-op |
| 8 | // ... conditional allocation ... |
| 9 | ptr = malloc(sizeof(int)); |
| 10 | if (ptr) { |
| 11 | *ptr = 42; |
| 12 | } |
| 13 | free(ptr); |
| 14 | ptr = NULL; // Safe: prevents double-free |
| 15 | } |
| 16 | |
| 17 | // Practice 2: Always check return values |
| 18 | void practice_check(void) { |
| 19 | int *arr = calloc(1000, sizeof(int)); |
| 20 | if (!arr) { |
| 21 | fprintf(stderr, "Out of memory\n"); |
| 22 | return; // Don't continue with NULL pointer |
| 23 | } |
| 24 | // ... use arr ... |
| 25 | free(arr); |
| 26 | } |
| 27 | |
| 28 | // Practice 3: Match allocators -- malloc/free, not malloc/delete |
| 29 | // Practice 4: Use sizeof(*ptr) not sizeof(type) for portability |
| 30 | void practice_sizeof(void) { |
| 31 | int *arr = malloc(100 * sizeof(*arr)); // Not sizeof(int) |
| 32 | if (!arr) return; |
| 33 | // ... use arr ... |
| 34 | free(arr); |
| 35 | } |
| 36 | |
| 37 | // Practice 5: Write a cleanup macro for goto-based cleanup |
| 38 | #define CLEANUP_GUARD char *_cleanup_ptr __attribute__((cleanup(_cleanup_free))) |
| 39 | static void _cleanup_free(void *p) { |
| 40 | free(*(void **)p); |
| 41 | } |
| 42 | |
| 43 | void practice_cleanup_pattern(void) { |
| 44 | CLEANUP_GUARD = malloc(1024); |
| 45 | CLEANUP_GUARD = malloc(2048); |
| 46 | // Both are automatically freed when function returns |
| 47 | // (even on early return) |
| 48 | } |
| 49 | |
| 50 | // Practice 6: Never use the return value of realloc if the |
| 51 | // original pointer might leak |
| 52 | void practice_realloc(void) { |
| 53 | int *data = malloc(100 * sizeof(int)); |
| 54 | if (!data) return; |
| 55 | |
| 56 | int *tmp = realloc(data, 200 * sizeof(int)); |
| 57 | if (!tmp) { |
| 58 | // data is still valid -- don't lose it! |
| 59 | fprintf(stderr, "realloc failed, original data preserved\n"); |
| 60 | free(data); |
| 61 | return; |
| 62 | } |
| 63 | data = tmp; // Only update pointer on success |
| 64 | free(data); |
| 65 | } |
| Rule | Why |
|---|---|
| Always initialize pointers to NULL | Prevents use-after-free and double-free |
| Check every allocation return | malloc/calloc can return NULL |
| Use sizeof(*ptr) instead of sizeof(type) | Automatically matches pointer target type |
| Don't store realloc result directly | Original pointer leaks on failure |
| Use goto cleanup for error paths | Avoids resource leaks on early return |
| Run Valgrind/ASan regularly | Catch bugs before they reach production |