C · lesson 12 of 25
Memory Management in C
Learn malloc, calloc, realloc, and free, understand heap vs stack, detect memory leaks with Valgrind, and avoid buffer overflows.
By SkillByExample · Updated
Stack vs Heap
C gives you two places to store data. The stack is fast and automatic but small and short-lived; the heap is large and outlives the function that allocated it, but you must free it yourself.
#include <stdio.h>
#include <stdlib.h>
int *make_squares(int n) {
int local[4] = {0}; /* stack: gone when this function returns */
int *arr = malloc(n * sizeof *arr); /* heap: lives until free() */
if (!arr) return NULL;
for (int i = 0; i < n; i++) arr[i] = i * i;
printf("local at %p, arr at %p\n", (void *)local, (void *)arr);
return arr; /* safe: heap memory outlives the call */
}
int main(void) {
int *data = make_squares(10);
if (!data) return 1;
printf("data[5] = %d\n", data[5]);
free(data);
data = NULL;
return 0;
}
$ gcc -Wall -std=c11 -o stack_heap stack_heap.c && ./stack_heap
local at 0x7fffd4d10320, arr at 0x215f2a0
data[5] = 25
The two addresses are in very different parts of the address space: local sits high up in the stack, arr
low down in the heap. Your exact numbers will differ from run to run, because Linux randomizes where the stack
and heap start. make_squares can return arr because heap memory stays valid after the function returns.
Returning local would hand back a pointer into a stack frame that no longer exists.
Stack: fast, limited size (typically 1–8 MB per thread), automatically managed. Good for small, short-lived data.
Heap: slower to allocate, bounded only by available memory, manually managed. Required for large data, data that outlives a function, or data whose size is not known at compile time.
malloc — Allocate Uninitialized Memory
malloc allocates a block of bytes on the heap and returns a pointer to it, or NULL if it can’t. The contents are uninitialized until you write them.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int n = 100;
int *arr = malloc(n * sizeof(int)); /* uninitialized until written */
if (arr == NULL) {
fprintf(stderr, "malloc failed\n");
return 1;
}
for (int i = 0; i < n; i++)
arr[i] = i * i;
printf("arr[10] = %d\n", arr[10]);
free(arr);
arr = NULL;
size_t huge = (size_t)-1 / 2; /* far more than any machine has */
int *fail = malloc(huge);
if (!fail)
printf("malloc(%zu) returned NULL\n", huge);
free(fail); /* free(NULL) is a no-op */
return 0;
}
$ gcc -Wall -std=c11 -o malloc_demo malloc_demo.c && ./malloc_demo
arr[10] = 100
malloc(9223372036854775807) returned NULL
Asking for 8 exabytes doesn’t crash the program: malloc simply returns NULL, and the check turns that
into a message. Without the check, the next write through fail would be a null-pointer dereference. The
final free(fail) is passed NULL, which the C standard defines as doing nothing.
calloc — Allocate Zero-Initialized Memory
calloc(count, size) allocates count * size bytes and zeroes them before returning. That suits data structures where zero is the natural starting value: counters, sparse arrays, hash-table buckets.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int rows = 4, cols = 4;
/* calloc(count, size): count*size bytes, all set to zero */
int *matrix = calloc(rows * cols, sizeof(int));
if (!matrix) return 1;
matrix[1 * cols + 2] = 42; /* matrix[1][2] */
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++)
printf("%3d", matrix[r * cols + c]);
printf("\n");
}
free(matrix);
return 0;
}
$ gcc -Wall -std=c11 -o calloc_demo calloc_demo.c && ./calloc_demo
0 0 0 0
0 0 42 0
0 0 0 0
0 0 0 0
Only one cell was ever written, yet the other fifteen print 0 rather than leftover bytes. That is the whole
difference from malloc. calloc also checks count * size for overflow and returns NULL instead of
allocating a wrapped-around, too-small block.
realloc — Resize an Allocation
realloc changes the size of an existing allocation, moving the data to a new block if it can’t grow in place. The classic use is a dynamic array that doubles its capacity whenever it fills up.
#include <stdio.h>
#include <stdlib.h>
typedef struct { int *data; size_t size, capacity; } IntVec;
int vec_push(IntVec *v, int value) {
if (v->size == v->capacity) {
size_t new_cap = v->capacity == 0 ? 4 : v->capacity * 2;
/* temp pointer: if realloc fails, v->data is still valid */
int *tmp = realloc(v->data, new_cap * sizeof(int));
if (!tmp) return -1;
v->data = tmp;
v->capacity = new_cap;
}
v->data[v->size++] = value;
return 0;
}
int main(void) {
IntVec v = {NULL, 0, 0};
for (int i = 0; i < 20; i++)
if (vec_push(&v, i * 10) != 0) return 1;
for (size_t i = 0; i < v.size; i++) printf("%d ", v.data[i]);
printf("\nsize=%zu, capacity=%zu\n", v.size, v.capacity);
free(v.data);
return 0;
}
$ gcc -Wall -std=c11 -o vec vec.c && ./vec
0 10 20 30 40 50 60 70 80 90 100 110 120 130 140 150 160 170 180 190
size=20, capacity=32
All twenty values survive the resizes, because realloc copies the old contents when it moves a block. The
capacity went 4, 8, 16, 32, so 12 slots are spare. Never write ptr = realloc(ptr, new_size): if
realloc returns NULL, you have overwritten your only pointer to the old block and leaked it. Assign to a
temporary first, as vec_push does.
free — Release Memory
free returns memory to the heap. Every malloc/calloc/realloc needs exactly one matching free, and once a block is freed you may not read it again, not even to find the next node of a list.
#include <stdio.h>
#include <stdlib.h>
typedef struct Node { int value; struct Node *next; } Node;
/* Save next BEFORE freeing: after free(head), head->next is off limits */
void free_list(Node *head) {
while (head) {
Node *next = head->next;
printf("freeing %d\n", head->value);
free(head);
head = next;
}
}
int main(void) {
Node *head = NULL;
for (int i = 3; i >= 1; i--) {
Node *n = malloc(sizeof *n);
if (!n) return 1;
n->value = i;
n->next = head;
head = n;
}
free_list(head);
head = NULL;
return 0;
}
$ gcc -Wall -std=c11 -o free_list free_list.c && ./free_list
freeing 1
freeing 2
freeing 3
The list was built by pushing 3, 2, 1 onto the front, so it frees in the order 1, 2, 3. Each iteration copies
head->next into a local before free(head). Reading it afterwards would be a use-after-free, which is
what Practice task 5 has you try.
Rules for free:
- Only free memory obtained from
malloc,calloc, orrealloc - Free each allocation exactly once — double-free is undefined behavior
free(NULL)is safe and does nothing- Set pointers to
NULLafter freeing, so a stray secondfreebecomes a harmlessfree(NULL)
Memory Leaks and Valgrind
A memory leak is allocated memory that is never freed. Nothing visibly goes wrong, which is the problem, so you need a tool to see it. Compile with -g so the tool can name source lines.
#include <stdio.h>
#include <stdlib.h>
void leaky_function(void) {
int *buf = malloc(1024);
if (!buf) return;
buf[0] = 1;
/* forgot to free(buf) before returning: 1024 bytes leaked */
}
int main(void) {
leaky_function();
puts("done");
return 0;
}
$ gcc -Wall -std=c11 -g -o leak leak.c
$ valgrind --leak-check=full ./leak
==48== Memcheck, a memory error detector
==48== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.
==48== Using Valgrind-3.24.0 and LibVEX; rerun with -h for copyright info
==48== Command: ./leak
==48==
done
==48==
==48== HEAP SUMMARY:
==48== in use at exit: 1,024 bytes in 1 blocks
==48== total heap usage: 2 allocs, 1 frees, 5,120 bytes allocated
==48==
==48== 1,024 bytes in 1 blocks are definitely lost in loss record 1 of 1
==48== at 0x4844818: malloc (vg_replace_malloc.c:446)
==48== by 0x401147: leaky_function (leak.c:5)
==48== by 0x40116A: main (leak.c:12)
==48==
==48== LEAK SUMMARY:
==48== definitely lost: 1,024 bytes in 1 blocks
==48== indirectly lost: 0 bytes in 0 blocks
==48== possibly lost: 0 bytes in 0 blocks
==48== still reachable: 0 bytes in 0 blocks
==48== suppressed: 0 bytes in 0 blocks
==48==
==48== For lists of detected and suppressed errors, rerun with: -s
==48== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
The program printed done and exited normally. Only Valgrind’s report shows the leak: 1,024 bytes
definitely lost, meaning no pointer to the block exists any more, allocated at leak.c:5 and reached from
main at leak.c:12. ==48== is the process ID. The heap summary counts 2 allocations, not 1: the other
4,096 bytes are the C library’s buffer for stdout, made by puts and freed at exit.
AddressSanitizer finds the same leak without Valgrind. Its leak checker runs when the program exits:
$ gcc -Wall -std=c11 -g -fsanitize=address -o leak leak.c && ./leak
done
=================================================================
==12==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 1024 byte(s) in 1 object(s) allocated from:
#0 0x7fa6676f7a97 in malloc (/usr/local/lib64/libasan.so.8+0xf4a97)
#1 0x401197 in leaky_function /src/leak.c:5
#2 0x4011f1 in main /src/leak.c:12
#3 0x7fa667438ca7 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
SUMMARY: AddressSanitizer: 1024 byte(s) leaked in 1 allocation(s).
The report points at the same two lines, and the program’s exit status becomes 1, so a leak fails a CI job. ASan needs the flag at compile time; Valgrind works on any binary but runs it many times slower.
Buffer Overflow Example
A buffer overflow writes past the end of a buffer and corrupts whatever is next to it. strcpy copies until it finds the terminating '\0', however big the destination is.
#include <stdio.h>
#include <string.h>
/* DANGEROUS: strcpy has no idea how big buf is */
void vulnerable(const char *input) {
char buf[8];
strcpy(buf, input);
printf("buf: %s\n", buf);
}
int main(int argc, char **argv) {
vulnerable(argc > 1 ? argv[1] : "hello");
return 0;
}
$ gcc -Wall -std=c11 -o overflow overflow.c && ./overflow && ./overflow "hello world, this is far too long"
buf: hello
buf: hello world, this is far too long
bash: line 1: 13 Segmentation fault (core dumped) ./overflow "hello world, this is far too long"
The long input prints correctly, and only then does the program crash. strcpy wrote 34 bytes into an
8-byte array, overwriting the saved return address on the stack. Nothing used that address until
vulnerable returned. An attacker who controls the input controls those bytes, which is why this pattern is
the textbook security hole. With ASan, the first byte out of bounds is caught:
$ gcc -Wall -std=c11 -g -fsanitize=address -o overflow overflow.c && ./overflow "hello world" 2>&1 | head -n 15
=================================================================
==19==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7f105d900028 at pc 0x7f105fcf34c1 bp 0x7ffd4a85fcf0 sp 0x7ffd4a85f4b0
WRITE of size 12 at 0x7f105d900028 thread T0
#0 0x7f105fcf34c0 (/usr/local/lib64/libasan.so.8+0xa24c0)
#1 0x40121a in vulnerable /src/overflow.c:7
#2 0x4012c2 in main /src/overflow.c:12
#3 0x7f105fa86ca7 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#4 0x7f105fa86d64 in __libc_start_main_impl ../csu/libc-start.c:360
#5 0x4010d0 in _start (/src/overflow+0x4010d0)
Address 0x7f105d900028 is located in stack of thread T0 at offset 40 in frame
#0 0x4011a5 in vulnerable /src/overflow.c:5
This frame has 1 object(s):
[32, 40) 'buf' (line 6) <== Memory access at offset 40 overflows this variable
"hello world" is only 12 bytes with its terminator, a 4-byte overrun. Without ASan it still crashes, but
later, after vulnerable has returned, so a crash report would point at the wrong place. ASan stops at the
12-byte write itself, at overflow.c:7, and names buf as the variable it overflowed. The full report
continues with a shadow-memory dump, cut here by head.
The fix is a function that is told the buffer’s size. snprintf writes at most sizeof buf bytes, always
adds the terminator, and returns how many characters it wanted to write:
#include <stdio.h>
/* snprintf never writes more than sizeof buf bytes, and always terminates */
void safe_version(const char *input) {
char buf[8];
int needed = snprintf(buf, sizeof buf, "%s", input);
printf("buf: \"%s\" (wanted %d chars, truncated: %s)\n",
buf, needed, needed >= (int)sizeof buf ? "yes" : "no");
}
int main(void) {
safe_version("hello");
safe_version("hello world");
return 0;
}
$ gcc -Wall -std=c11 -o safe safe.c && ./safe
buf: "hello" (wanted 5 chars, truncated: no)
buf: "hello w" (wanted 11 chars, truncated: yes)
"hello world" is cut to its first 7 characters, leaving the eighth byte for '\0'. The return value of 11
tells you truncation happened, so you can choose to report an error instead of silently using the short string.
Memory Layout Summary
Understanding where different variables live helps you reason about lifetime, size limits, and what can go wrong:
High address
┌──────────────────┐
│ Stack │ ← grows downward; local variables, function frames
│ ↓ │
├──────────────────┤
│ │
│ (free space) │
│ │
├──────────────────┤
│ ↑ │
│ Heap │ ← grows upward; malloc/calloc/realloc
├──────────────────┤
│ BSS segment │ ← uninitialized global/static variables (zero-filled)
├──────────────────┤
│ Data segment │ ← initialized global/static variables
├──────────────────┤
│ Text segment │ ← program code (read-only)
└──────────────────┘
Low address
You can check the diagram by printing one address from each region:
#include <stdio.h>
#include <stdlib.h>
int initialized = 1; /* data segment */
int zeroed; /* BSS segment */
int main(void) {
int local = 0; /* stack */
int *heap = malloc(sizeof *heap); /* heap */
if (!heap) return 1;
printf("stack %p\n", (void *)&local);
printf("heap %p\n", (void *)heap);
printf("bss %p\n", (void *)&zeroed);
printf("data %p\n", (void *)&initialized);
printf("text %p\n", (void *)main);
free(heap);
return 0;
}
$ gcc -Wall -std=c11 -o layout layout.c && ./layout
stack 0x7ffecf665e04
heap 0x20042a0
bss 0x404030
data 0x404028
text 0x401146
The addresses fall in the diagram’s order, highest first. The stack is near the top of the 47-bit user address space, far above everything else. The heap starts just above the program’s own segments, and BSS, data and text sit close together because they all come from the executable file. On a build that uses position-independent executables (the default on many distributions), all of these move on every run, but the order stays the same.
Practice
- An identity matrix for free. Change the
callocexample so it builds a 4×4 identity matrix, with 1s on the diagonal and 0s everywhere else. Write only the diagonal.
Solution
#include <stdlib.h>
#include <stdio.h>
int main(void) {
int n = 4;
int *matrix = calloc(n * n, sizeof(int));
if (!matrix) return 1;
for (int i = 0; i < n; i++)
matrix[i * n + i] = 1; /* only the diagonal; calloc zeroed the rest */
for (int r = 0; r < n; r++) {
for (int c = 0; c < n; c++)
printf("%3d", matrix[r * n + c]);
printf("\n");
}
free(matrix);
return 0;
}
$ gcc -Wall -std=c11 -o identity identity.c && ./identity
1 0 0 0
0 1 0 0
0 0 1 0
0 0 0 1
Twelve of the sixteen cells were never written, yet every one prints 0, because calloc zeroes the
block. With malloc in its place, those twelve cells would hold whatever bytes were already in that memory.
- Watch the vector grow. Add a
printfinsidevec_pushthat reports each time the capacity changes. How many times doesreallocrun for 20 pushes?
Solution
#include <stdlib.h>
#include <stdio.h>
typedef struct {
int *data;
size_t size;
size_t capacity;
} IntVec;
int vec_push(IntVec *v, int value) {
if (v->size == v->capacity) {
size_t new_cap = v->capacity == 0 ? 4 : v->capacity * 2;
int *tmp = realloc(v->data, new_cap * sizeof(int));
if (!tmp) return -1;
printf("grow: %zu -> %zu (at size %zu)\n", v->capacity, new_cap, v->size);
v->data = tmp;
v->capacity = new_cap;
}
v->data[v->size++] = value;
return 0;
}
int main(void) {
IntVec v = {NULL, 0, 0};
for (int i = 0; i < 20; i++)
if (vec_push(&v, i * 10) != 0) return 1;
printf("size=%zu, capacity=%zu\n", v.size, v.capacity);
free(v.data);
return 0;
}
$ gcc -Wall -std=c11 -o growth growth.c && ./growth
grow: 0 -> 4 (at size 0)
grow: 4 -> 8 (at size 4)
grow: 8 -> 16 (at size 8)
grow: 16 -> 32 (at size 16)
size=20, capacity=32
Twenty pushes need only four realloc calls, because doubling makes resizes rarer as the array grows. The
first call is realloc(NULL, ...), which behaves exactly like malloc. That is why IntVec can start
with data = NULL.
- Give the spare memory back. Write
vec_shrink_to_fitso it usesreallocto cut the capacity down to the size, following the temp-pointer rule. Print the size and capacity before and after.
Solution
#include <stdlib.h>
#include <stdio.h>
typedef struct {
int *data;
size_t size;
size_t capacity;
} IntVec;
int vec_push(IntVec *v, int value) {
if (v->size == v->capacity) {
size_t new_cap = v->capacity == 0 ? 4 : v->capacity * 2;
int *tmp = realloc(v->data, new_cap * sizeof(int));
if (!tmp) return -1;
v->data = tmp;
v->capacity = new_cap;
}
v->data[v->size++] = value;
return 0;
}
int vec_shrink_to_fit(IntVec *v) {
if (v->size == 0 || v->size == v->capacity) return 0;
int *tmp = realloc(v->data, v->size * sizeof(int));
if (!tmp) return -1; /* old block is still valid and still owned by v */
v->data = tmp;
v->capacity = v->size;
return 0;
}
int main(void) {
IntVec v = {NULL, 0, 0};
for (int i = 0; i < 20; i++)
if (vec_push(&v, i * 10) != 0) return 1;
printf("before: size=%zu, capacity=%zu\n", v.size, v.capacity);
if (vec_shrink_to_fit(&v) != 0) return 1;
printf("after: size=%zu, capacity=%zu, last=%d\n", v.size, v.capacity, v.data[v.size - 1]);
free(v.data);
return 0;
}
$ gcc -Wall -std=c11 -o shrink shrink.c && ./shrink
before: size=20, capacity=32
after: size=20, capacity=20, last=190
Shrinking keeps the first size elements, so last is still 190. The size == 0 guard matters because
realloc(ptr, 0) doesn’t reliably return a usable pointer, and C23 makes that call undefined behaviour.
- Find the leak with Valgrind. Change the leak program so
maincallsleaky_functionthree times in a loop. Predict what Valgrind will report, run it, and then fix the leak.
Solution
#include <stdlib.h>
#include <stdio.h>
void leaky_function(void) {
int *buf = malloc(1024);
if (!buf) return;
buf[0] = 1;
free(buf);
}
int main(void) {
for (int i = 0; i < 3; i++)
leaky_function();
puts("done");
return 0;
}
$ gcc -Wall -std=c11 -g -o prog leak.c
$ valgrind --leak-check=full ./prog
==30== Memcheck, a memory error detector
==30== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.
==30== Using Valgrind-3.24.0 and LibVEX; rerun with -h for copyright info
==30== Command: ./prog
==30==
done
==30==
==30== HEAP SUMMARY:
==30== in use at exit: 3,072 bytes in 3 blocks
==30== total heap usage: 4 allocs, 1 frees, 7,168 bytes allocated
==30==
==30== 3,072 bytes in 3 blocks are definitely lost in loss record 1 of 1
==30== at 0x4844818: malloc (vg_replace_malloc.c:446)
==30== by 0x401147: leaky_function (leak.c:5)
==30== by 0x401177: main (leak.c:13)
==30==
==30== LEAK SUMMARY:
==30== definitely lost: 3,072 bytes in 3 blocks
==30== indirectly lost: 0 bytes in 0 blocks
==30== possibly lost: 0 bytes in 0 blocks
==30== still reachable: 0 bytes in 0 blocks
==30== suppressed: 0 bytes in 0 blocks
==30==
==30== For lists of detected and suppressed errors, rerun with: -s
==30== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
$ gcc -Wall -std=c11 -g -o prog leak_fixed.c && valgrind --leak-check=full ./prog
==36== Memcheck, a memory error detector
==36== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.
==36== Using Valgrind-3.24.0 and LibVEX; rerun with -h for copyright info
==36== Command: ./prog
==36==
done
==36==
==36== HEAP SUMMARY:
==36== in use at exit: 0 bytes in 0 blocks
==36== total heap usage: 4 allocs, 4 frees, 7,168 bytes allocated
==36==
==36== All heap blocks were freed -- no leaks are possible
==36==
==36== For lists of detected and suppressed errors, rerun with: -s
==36== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
The code above is leak_fixed.c. leak.c is the same file with free(buf); replaced by the
lesson’s /* forgot to free */ comment line, which keeps the line numbers the same. Valgrind
merges the three identical leaks into one record of 3,072 bytes and gives the allocating line
(leak.c:5) along with its caller (leak.c:13). The fourth allocation, of 4,096 bytes, is the C library’s
buffer for stdout, made on the first puts. The library frees it at exit, which is why the fixed run
reports 4 allocs and 4 frees.
- Break
free_liston purpose. Replace the loop body withfree(head); head = head->next;(one statement per line), so it freesheadbefore readinghead->next. Build it with AddressSanitizer and see what you get, then put the lines back in the right order.
Solution
#include <stdlib.h>
#include <stdio.h>
typedef struct Node {
int value;
struct Node *next;
} Node;
void free_list(Node *head) {
while (head) {
Node *next = head->next; /* save before free */
free(head);
head = next;
}
}
int main(void) {
Node *head = NULL;
for (int i = 5; i >= 1; i--) {
Node *n = malloc(sizeof(Node));
if (!n) return 1;
n->value = i;
n->next = head;
head = n;
}
free_list(head);
puts("list freed");
return 0;
}
$ gcc -Wall -std=c11 -g -fsanitize=address,undefined -o uaf uaf.c && ./uaf 2>&1 | head -n 7
uaf.c: In function 'free_list':
uaf.c:12:14: warning: pointer 'head' used after 'free' [-Wuse-after-free]
12 | head = head->next; /* BUG: reads head after freeing it */
| ~~~~~^~~~~~~~~~~~
uaf.c:11:9: note: call to 'free' here
11 | free(head);
| ^~~~~~~~~~
=================================================================
==42==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000098 at pc 0x000000401267 bp 0x7ffdb0e811a0 sp 0x7ffdb0e81198
READ of size 8 at 0x502000000098 thread T0
#0 0x401266 in free_list /src/uaf.c:12
#1 0x4013cd in main /src/uaf.c:25
#2 0x7f26b45a3ca7 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#3 0x7f26b45a3d64 in __libc_start_main_impl ../csu/libc-start.c:360
$ gcc -Wall -std=c11 -g -fsanitize=address,undefined -o uaf uaf_fixed.c && ./uaf
list freed
The bug is caught twice. GCC 14’s -Wall includes -Wuse-after-free, which flags the pattern at
compile time. ASan then catches the actual 8-byte read of next from freed memory at line 12. Saving
head->next in a local before calling free, as the lesson’s version does, fixes both. In the transcript,
uaf.c is the broken swap and uaf_fixed.c is the program above.