Dynamic Memory Allocation (Heap)
Every serious C program — from web servers handling thousands of concurrent connections to embedded firmware running on microcontrollers with mere kilobytes of RAM — must wrestle with a fundamental question: how much memory do we need, and when do we need it? Static and automatic (stack) allocation answer that question at compile time. Dynamic memory allocation — the heap — answers it at runtime, and with that power comes profound responsibility.
1. The
Memory Landscape: Understanding Where Your Data LivesBefore touching a single heap function, you must internalize the layout of a C program's virtual address space. Every running process has distinct memory segments, and confusing them is the root cause of the most insidious bugs in C.
HIGH ADDRESS
+-----------------------+
| Command-line |
| args & env vars |
+-----------------------+
| Stack | grows downward
| (automatic vars, | LIFO, fast, limited (~8 MB default on Linux)
| return addresses, |
| saved frame ptrs) |
| | |
| v |
+-----------------------+
| |
| UNMAPPED GAP | room for stack and heap to grow
| |
+-----------------------+
| ^ |
| | |
| Heap | grows upward
| (malloc, calloc, | manual, larger, slower
| realloc, free) |
+-----------------------+
| BSS (uninitialized |
| global/static data)| zeroed at startup
+-----------------------+
| DATA (initialized |
| global/static data) | read-write
+-----------------------+
| TEXT (machine code, |
| read-only strings) | read-only, shared
+-----------------------+
LOW ADDRESS
Stack allocation is lightning-fast: the compiler simply adjusts the stack pointer register by a fixed offset. When a function returns, the stack pointer snaps back, and the memory is reclaimed. But three constraints limit stack usage: (1) the size is fixed and relatively small, (2) the lifetime is tied to the function scope — you cannot return a pointer to a local variable, and (3) the size must be known at compile time. Try allocating an array whose size depends on user input on the stack (int arr[n] as a VLA), and you risk stack overflow — or worse, silent corruption of adjacent memory.
Heap allocation solves all three: you decide the size at runtime, the memory lives until you explicitly free it, and the total available heap is limited only by system RAM plus swap. The price you pay: manual lifetime management, slower allocation (the heap manager must search for free blocks), and fragmentation risk.
2. Th
e Four Horsemen of Heap Management2.1 malloc — Raw, Uninitialized Memory
void* malloc(size_t size) carves out size bytes from the heap and returns a pointer to the first byte. The memory contents are indeterminate — whatever garbage was left by the previous occupant. It is your job to initialize every byte before reading.
/* malloc returns void*, so we cast to the target pointer type.
The cast is required in C++ but optional (though recommended for clarity) in C.
ALWAYS use sizeof with the dereferenced pointer, NOT a hardcoded type:
This way, if the type of *arr changes, the allocation automatically adjusts. */
int *arr = (int*)malloc(100 * sizeof(*arr));
/* The memory above contains random garbage. Never read from it
without writing first. This is undefined behavior: */
/* printf("%d\n", arr[0]); -- DANGER: uninitialized read */
/* Initialize before use: */
for (int i = 0; i < 100; i++) arr[i] = 0;
2.2 calloc — Zeroed Array Allocation
void* calloc(size_t nmemb, size_t size) allocates space for nmemb elements of size bytes each, and zeros out every byte. This makes it safer but slightly slower than malloc. It also guards against integer overflow: calloc> checks that nmemb * size doesn't overflow, which raw malloc(nmemb * size) does not.
/* Equivalent to malloc + memset, but with overflow protection: */
int *scores = (int*)calloc(50, sizeof(int));
/* Now scores[0] through scores[49] are all guaranteed to be 0.
No manual initialization loop needed. */
2
.3realloc — Resize Without Losing Data
void* realloc(void *ptr, size_t new_size) is the trickiest of the four. It attempts to resize the block pointed to by ptr to new_size bytes. Three outcomes are possible:
- Expansion in-place: If there is free space immediately after the block,
realloc> extends it and returns the same pointer. - Relocation: If the block cannot be extended,
reallocallocates a new block, copies old data over, frees the old block, and returns the new pointer. The old pointer is now invalid. - Failure: If no memory is available,
reallocreturnsNULL. The original block is NOT freed in this case.
/* THE CORRECT realloc PATTERN — never overwrite the original pointer */
int *old = (int*)malloc(10 * sizeof(int));
if (!old) { /* handle error */ }
/* ... fill old[0..9] with data ... */
int *temp = (int*)realloc(old, 20 * sizeof(int));
if (temp == NULL) {
/* Realloc failed, but old is still valid and must be freed! */
free(old);
return 1;
}
/* Now temp points to the (possibly moved) resized block */
old = temp; /* only now update old */
/* THE WRONG WAY — this leaks memory if realloc fails: */
/* old = realloc(old, 20 * sizeof(int)); -- NEVER DO THIS */
2.4 free — The Liberator
void free(void *ptr) returns the block to the heap's free list. After calling free(ptr), ptr becomes a dangling pointer — it still holds the old address, but that address is no longer yours. Any access through it is undefined behavior. The defensive pattern is to set the pointer to NULL immediately after freeing:
free(ptr);
ptr = NULL; /* now any accidental use of ptr will at least segfault,
making the bug detectable, rather than silently
corrupting heap metadata */
3. Comp
lete Worked Example: A Dynamically-Growing ArrayStandard
C arrays are fixed-size. Let's build a vector-like structure that grows on demand — the pattern used internally by countless real C projects when they can't use C++'sstd::vector.
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int *data; /* pointer to heap-allocated buffer */
size_t length; /* number of elements currently stored */
size_t capacity; /* total slots available before reallocation */
} IntVector;
/* Initialize: allocate a small starting buffer */
int vec_init(IntVector *vec, size_t initial_cap) {
vec->data = (int*)malloc(initial_cap * sizeof(int));
if (!vec->data) return 0;
vec->length = 0;
vec->capacity = initial_cap;
return 1;
}
/* Push: append an element, doubling capacity if full */
int vec_push(IntVector *vec, int value) {
if (vec->length == vec->capacity) {
size_t new_cap = vec->capacity * 2; /* geometric growth */
int *temp = (int*)realloc(vec->data, new_cap * sizeof(int));
if (!temp) return 0; /* original data still intact */
vec->data = temp;
vec->capacity = new_cap;
}
vec->data[vec->length++] = value;
return 1;
}
/* Free all resources */
void vec_destroy(IntVector *vec) {
free(vec->data);
vec->data = NULL; /* safety */
vec->length = vec->capacity = 0;
}
int main() {
IntVector nums;
if (!vec_init(&nums, 4)) {
fprintf(stderr, "Init failed\n");
return 1;
}
for (int i = 1; i <= 20; i++) {
if (!vec_push(&nums, i * 10)) {
fprintf(stderr, "Push %d failed\n", i);
vec_destroy(&nums);
return 1;
}
printf("Pushed %3d | length=%2zu capacity=%2zu\n",
i * 10, nums.length, nums.capacity);
}
vec_destroy(&nums);
return 0;
}
Run thi
s and observe the output: capacity doubles from 4 to 8 to 16 to 32 as needed. This geometric growth (multiplying by a factor) gives amortized O(1) push time — a classic systems programming tradeoff between memory and speed.4. Common
Bugs: A Rogues' Gallery4.1 Memory Leak
The silent killer of long-running programs. Every malloc/calloc/realloc must have a matching free. In server processes or embedded systems that run for weeks or months, even a single leaked allocation per request will eventually exhaust all memory.
/* LEAK: allocated memory is never freed */
void process() {
char *buf = (char*)malloc(1024);
/* ... use buf ... */
/* OOPS: no free(buf) — every call to process() leaks 1 KB */
}
4.2
Use-After-Free (UAF)Accessing memory after it has been freed. The heap manager may reuse that memory for another allocation, leading to bizarre data corruption where two logically unrelated parts of your program overwrite each other.
int *p = (int*)malloc(sizeof(int));
*p = 42;
free(p);
/* p is now dangling */
int *q = (int*)malloc(sizeof(int)); /* q might reuse the same address */
*q = 99;
printf("%d\n", *p); /* UNDEFINED: might print 99, 42, or crash */
4
.3 Double FreeCalling free twice on the same pointer corrupts the heap's internal bookkeeping structures. The second free may crash immediately, or it may silently corrupt the free list and cause a crash much later in an unrelated malloc call — a debugging nightmare.
int *p = (int*)malloc(sizeof(int));
free(p);
free(p); /* BOOM: double free — undefined behavior */
4.4 Buffer Overflow on the Heap
Writing past the end of a heap-allocated buffer corrupts heap metadata stored between blocks. The crash may not happen at the write site but at the next malloc or free, making it fiendishly hard to track down.
int *arr = (int*)malloc(5 * sizeof(int));
arr[5] = 999; /* writes past the end — corrupts heap metadata */
free(arr); /* may crash here, far from the actual bug */
5
. Key Takeaways- The stack is fast and automatic but limited in size and scope; the heap is large and flexible but demands manual lifetime management.
- Always check the return value of
malloc,calloc, andreallocagainstNULLbefore using the pointer. - When using
realloc, assign the result to a temporary pointer first — overwriting the original pointer on failure causes a leak. - Set pointers to
- Memory leaks in long-running programs are cumulative; one leaked byte per iteration eventually exhausts all RAM. Design your allocation and deallocation in matched pairs.
NULL after free to catch use-after-free bugs early. Use tools like Valgrind or AddressSanitizer (-fsanitize=address) during development.
6. Practice
ExercisesExercise 1: Dynamic String Builder
Implement a StringBuilder struct that can efficiently concatenate strings. It should start with a small buffer (say, 16 bytes), double capacity when full, and provide append and destroy functions.
/* Starter code */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char *buffer;
size_t length;
size_t capacity;
} StringBuilder;
/* TODO: implement these three functions */
int sb_init(StringBuilder *sb, size_t initial_cap);
int sb_append(StringBuilder *sb, const char *str);
void sb_destroy(StringBuilder *sb);
Hint: Use strlen to check needed space. Use realloc (with the temp-pointer pattern!) to grow. Use memcpy or strcpy to copy the string data into the buffer at the correct offset.
Exercise
2: Two-Dimensional Dynamic MatrixWrite
a function that allocates anint** matrix on the heap where the user specifies both rows and columns at runtime. Write a corresponding free_matrix function that deallocates every row and then the row pointer array. Populate it with row*col values and print it.
/* Starter: allocate and return rows x cols matrix */
int** allocate_matrix(int rows, int cols);
void free_matrix(int **matrix, int rows);
Hint: First malloc an array of int* for the rows. Then loop and malloc each row. Free in reverse order.
Exercise 3: Find
the Memory LeakThe following function has a subtle leak. Identify it and fix the code:
char* read_file_contents(const char *filename) {
FILE *f = fopen(filename, "r");
if (!f) return NULL;
fseek(f, 0, SEEK_END);
long size = ftell(f);
rewind(f);
char *buffer = (char*)malloc(size + 1);
if (!buffer) return NULL;
fread(buffer, 1, size, f);
buffer[size] = '\0';
fclose(f);
return buffer;
}
Hint: What happens if malloc returns NULL? What resource is left open?
Exercise 4: Implement
my_realloc
Write your own simplified version of realloc using only malloc, memcpy, and free. Your function should take an old pointer, an old size (since you can't query the size from a raw pointer), and a new size. Handle the edge case where ptr is NULL (just call malloc) and where new_size is 0 (just call free and return NULL).