Dynamic Memory Management
All the programs we have written so far have had variable sizes determined at compile time. However, the real world doesn't work that way—we don't know how many characters a user will input beforehand, we don't know how many records will be collected before running, and data packets sent by a client might be different every time. The common point of these scenarios is: before the program runs, you cannot determine how much memory is needed.
C's solution to this problem is dynamic memory management—requesting a block of memory of a specified size from the system while the program is running, and returning it when finished. This set of APIs looks like it only has four functions: malloc, calloc, realloc, free, which takes ten minutes to learn. But using them correctly is one thing; keeping them from crashing is another—memory leaks, dangling pointers, double frees, out-of-bounds writes—each one can crash your program inexplicably.
Learning Objectives
After completing this chapter, you will be able to:
- [ ] Draw a program memory layout diagram and explain the responsibilities of the text/rodata/data/bss/heap/stack sections.
- [ ] Correctly use
malloc/calloc/realloc/freeand handle errors.- [ ] Identify and avoid five common memory errors.
- [ ] Use Valgrind and AddressSanitizer to detect memory issues.
- [ ] Understand how RAII and smart pointers solve the pain points of manual C management.
Environment Setup
We will conduct all subsequent experiments in this environment:
- Platform: Linux x86_64 (WSL2 is also acceptable)
- Compiler: GCC 13+ or Clang 17+
- Compiler flags:
-g -O0 -Wall -Wextra
Step 1 — Figure out what a program looks like in memory
When an executable file is loaded into memory by the loader to start running, the operating system allocates a virtual address space for it. This space is divided into several functionally distinct areas:
The Code Segment (.text) stores compiled machine instructions and is usually read-only. The Read-Only Data Segment (.rodata) stores const global variables and string literals. The Initialized Data Segment (.data) stores global and static variables that have non-zero initial values at definition. The BSS Segment (.bss) stores global and static variables that are uninitialized or initialized to zero—the key difference is that the BSS does not take up space in the executable file, only recording "need N bytes zeroed". The Heap is where dynamic memory allocation happens; memory requested by malloc comes from here. The Stack is used for function calls, storing local variables and return addresses.
Step 2 — Master malloc/calloc/realloc/free
Stack management is completely automatic—stack frames are allocated when a function is called and automatically reclaimed when it returns. It is extremely fast (moving one register), but has size limitations (8MB default on Linux), and the memory is only valid during the current function's execution.
Heap management is handed over to the programmer. It is flexible but must be managed manually—if you forget to free, it leaks; if you free twice, it crashes. In actual projects, the following scenarios require the heap: data size cannot be determined at compile time, data lifetime spans function calls, or data size is too large for the stack.
malloc — Give me a block of memory
// malloc prototype
void* malloc(size_t size);malloc accepts the number of bytes to allocate and returns a void* pointer. A basic example:
// Allocate memory for an integer
int* p = (int*)malloc(sizeof(int));
if (p == NULL) {
// Handle allocation failure
fprintf(stderr, "Memory allocation failed\n");
return 1;
}
*p = 42; // Use the memory
free(p); // Release the memoryKey points: Write malloc(sizeof(*p)) instead of malloc(sizeof(int)), so the allocation size changes automatically when the pointer type changes. Checking for NULL immediately after every malloc is an iron rule. Memory allocated by malloc is uninitialized—you read garbage values.
calloc — Allocate and zero out
// calloc prototype
void* calloc(size_t nmemb, size_t size);calloc allocates memory and zeros it out completely. Use it when you need zero-initialized structures or arrays—it's safer. calloc can also detect parameter multiplication overflow, providing an extra layer of protection compared to malloc.
realloc — Expand capacity (might move house)
// realloc prototype
void* realloc(void* ptr, size_t size);realloc is used to adjust the size of allocated memory. It expands in place or finds new space and moves.
⚠️ The Classic Pitfall: realloc can return NULL (out of memory), but the original pointer remains valid. If you write directly ptr = realloc(ptr, new_size), once it returns NULL, the original ptr is lost—memory leak. The correct way:
// Correct usage of realloc
int* new_ptr = (int*)realloc(ptr, new_size);
if (new_ptr == NULL) {
// Handle failure, original ptr is still valid
free(ptr); // Optional: clean up if expansion is critical
} else {
ptr = new_ptr; // Update pointer only on success
}free — Return what you borrow
// free prototype
void free(void* ptr);free has more caveats than it appears: you can only free pointers returned by allocation functions; after freeing, the pointer becomes a dangling pointer; setting to NULL after free is a good habit—subsequent misuse will cause an immediate segmentation fault, which is ten thousand times easier to debug than use-after-free.
free(ptr);
ptr = NULL; // Prevent dangling pointerStep 3 — Know the five common memory errors
1. Memory Leak
Allocating but forgetting to free. More insidious scenarios include not releasing old memory before reassigning a pointer ("overwrite leak"), or forgetting to free in error handling branches.
2. Dangling Pointer / Use After Free
A pointer pointing to freed memory is continued to be used. This error doesn't necessarily crash immediately—that block of memory might not have been allocated to someone else yet, the data "looks" valid, but it is completely unreliable.
3. Double Free
Calling free twice on the same block of memory. The heap manager's internal data structures are corrupted, which may cause an immediate crash or strike much later.
4. Buffer Overflow
Writing outside the allocated memory area, corrupting metadata of adjacent memory blocks or other data. Off-by-one errors are a typical cause.
5. Uninitialized Read
The content of memory allocated by malloc is uncertain. Reading without assigning values reads garbage values.
Debugging Tools
Valgrind
The most classic memory debugging tool on Linux, capable of detecting leaks, illegal reads/writes, uninitialized reads, and double frees. No need to recompile, just add valgrind before the program:
valgrind --leak-check=full ./your_programAddressSanitizer (ASan)
A compiler-intrinsic memory error detection tool with much lower performance overhead than Valgrind:
# Compile with ASan
gcc -g -O0 -fsanitize=address -fno-omit-frame-pointer your_program.c -o your_programIt is recommended to always enable ASan during development and testing phases.
C++ Transition — How RAII ends the nightmare of manual management
Core Idea of RAII
Bind the lifecycle of a resource to the lifecycle of an object. The constructor acquires the resource, and the destructor releases it. When the object leaves scope, the destructor is guaranteed to be called (even if exceptions occur), and the resource is guaranteed to be released correctly.
The Three Musketeers of Smart Pointers
std::unique_ptr — Exclusive ownership, non-copyable but movable. Automatically releases when leaving scope. Recommended to create with std::make_unique.
std::shared_ptr — Shared ownership + reference counting. Releases memory when the last shared_ptr is destroyed. Recommended to create with std::make_shared.
std::weak_ptr — Does not increase reference count, used to break circular references between shared_ptrs.
Standard Library Containers
std::vector replaces manual malloc dynamic arrays, std::string replaces manual malloc string buffers. In modern C++, you almost never need to use malloc/free directly, let alone new/delete.
Summary
We started with memory layout, clarified the roles of the stack and heap, dissected the semantics and traps of the four dynamic memory functions one by one, summarized the five most common memory errors, and finally compared C++'s RAII and smart pointers. Dynamic memory management is one of the most error-prone areas in C, but once you master the correct methodology and tools, most errors can be avoided.
Exercises
Exercise 1: A Growing Array with realloc
Difficulty: Basic · grow capacity with realloc, building on this chapter's realloc section
Implement a simple dynamic array of ints: initial capacity 4, and each push_back doubles the capacity with realloc when full.
#include <stddef.h>
typedef struct {
int* data;
size_t size;
size_t capacity;
} IntVec;
/// @brief initialize with capacity 4
void intvec_init(IntVec* v);
/// @brief append to the tail; doubles capacity when full, returns -1 on failure
int intvec_push(IntVec* v, int value);
/// @brief release
void intvec_free(IntVec* v);Hint: realloc(NULL, n) is equivalent to malloc(n), so even the first allocation can go through realloc. Note that realloc returns NULL on failure and does not free the old block — catch the return value in a temporary, confirm success, and only then overwrite the old pointer; otherwise you leak.
Reference answer
Expand (27 lines)Collapse
#include <stdlib.h>
void intvec_init(IntVec* v) {
v->capacity = 4;
v->size = 0;
v->data = malloc(v->capacity * sizeof(int));
}
int intvec_push(IntVec* v, int value) {
if (v->size >= v->capacity) {
size_t new_cap = v->capacity * 2;
int* new_data = realloc(v->data, new_cap * sizeof(int));
if (new_data == NULL) {
return -1; // grow failed; the old data is still valid, let the caller decide
}
v->data = new_data;
v->capacity = new_cap;
}
v->data[v->size++] = value;
return 0;
}
void intvec_free(IntVec* v) {
free(v->data);
v->data = NULL;
v->size = v->capacity = 0;
}The key is to store the realloc return value in new_data first and only assign it to v->data after success. If you wrote v->data = realloc(v->data, ...) directly, a failure would overwrite the original v->data with NULL, and that memory is lost forever — a leak.
Exercise 2: Memory Error Diagnosis
Difficulty: Intermediate · identify the memory errors from this chapter with ASan or Valgrind
The code below hides at least four memory errors. First read the code and guess what each issue is, then run it with gcc -fsanitize=address (or Valgrind) and record the error type and location reported by the tool:
#include <stdlib.h>
int main(void) {
int* p = malloc(4 * sizeof(int));
p[4] = 42; // (1) what is wrong here?
int* q = malloc(sizeof(int));
free(q);
*q = 100; // (2) and here?
int* r = malloc(1024);
/* forgot to free(r) */ // (3) which category is this?
free(p);
free(p); // (4) one more
return 0;
}Requirement: for each one, name which kind of error from this chapter it is (out-of-bounds write, use-after-free, leak, double free, uninitialized read), and describe how the tool reports it.
Reference answer
(1) p[4]: only 4 ints were allocated (indices 0–3), so p[4] is a heap-buffer-overflow write. ASan reports heap-buffer-overflow.
(2) *q = 100: q was freed and then written — use-after-free. ASan reports heap-use-after-free.
(3) r is never freed: a memory leak. Valgrind's LEAK SUMMARY lists it; ASan on most platforms checks for leaks by default (detect_leaks=1) and reports Detected memory leaks at exit.
(4) free(p) twice: double free. ASan reports attempting double-free.
These four cover exactly the typical memory errors from this chapter; the tool's error keyword lets you quickly tell which kind it is.
Exercise 3: Fixed-Size Memory Pool Allocator (Challenge, optional)
Difficulty: Challenge · Optional, needs the free-list idiom; self-study required
Implement a fixed-size memory pool: slice fixed-size blocks out of one large chunk and manage free blocks with a linked list — the first few bytes of each free block store a pointer to the next free block. Look up the "in-place linked list / free list" technique before writing it.
typedef struct MemoryPool MemoryPool;
MemoryPool* pool_create(size_t block_size, size_t block_count);
void* pool_alloc(MemoryPool* pool);
void pool_free(MemoryPool* pool, void* block);
void pool_destroy(MemoryPool* pool);Think about it: compared to calling malloc/free directly, what does a memory pool buy you in an embedded or real-time system? Why can it do O(1) allocate/free with no fragmentation?
Exercise 4: malloc/free Wrapper with Statistics (Challenge, optional)
Difficulty: Challenge · Optional, best done after Chapter 15 on the preprocessor
Wrap malloc/free to record the file and line of each allocation, and print the still-unfreed list when the program exits. You will need the __FILE__/__LINE__ macros (covered in Chapter 15) and atexit to register an exit hook.
#define TMALLOC(size) tracked_malloc((size), __FILE__, __LINE__)Hint: use an array or linked list to record each allocation's address, size, and location; on free, match by address and mark it freed; register mem_report with atexit to print the remaining unfreed entries at exit.