Skip to main content
C · lesson 12 of 25
C intermediate 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, or realloc
  • Free each allocation exactly once — double-free is undefined behavior
  • free(NULL) is safe and does nothing
  • Set pointers to NULL after freeing, so a stray second free becomes a harmless free(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

  1. An identity matrix for free. Change the calloc example 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.

  1. Watch the vector grow. Add a printf inside vec_push that reports each time the capacity changes. How many times does realloc run 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.

  1. Give the spare memory back. Write vec_shrink_to_fit so it uses realloc to 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.

  1. Find the leak with Valgrind. Change the leak program so main calls leaky_function three 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.

  1. Break free_list on purpose. Replace the loop body with free(head); head = head->next; (one statement per line), so it frees head before reading head->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.

Frequently Asked Questions

What happens if I forget to call free()?
The memory remains allocated until the process exits. In short-lived programs this is often harmless. In long-running servers or loops, it causes a memory leak that gradually exhausts available RAM.
Is it safe to call free(NULL)?
Yes. The C standard guarantees that free(NULL) is a no-op. It is good practice to set a pointer to NULL after freeing it: a second free then does nothing, and a later dereference crashes predictably instead of silently reading freed memory.
What is the difference between stack and heap allocation?
Stack memory is automatically managed — it is allocated when a function is called and freed when it returns, and it is small, typically 1-8 MB per thread. Heap memory from malloc persists until you explicitly free it and survives function returns. It is limited only by available memory, and malloc returns NULL when a request can't be met.
Should I use Valgrind or AddressSanitizer to find memory leaks in C?
Both report a leaked block with the file and line that allocated it. Valgrind runs any existing binary unchanged but makes it many times slower. AddressSanitizer needs the program rebuilt with -fsanitize=address, runs much faster, and also stops at the exact line of a stack buffer overflow. Memcheck doesn't flag an out-of-bounds write to a stack array, only the crash it may cause later.