Skip to main content
C intermediate Lesson 11 of 23

Memory Management in C

Learn malloc, calloc, realloc, and free, understand heap vs stack, detect memory leaks with Valgrind, and avoid buffer overflows.

Stack vs Heap

C gives you two places to store data: the stack and the heap. Understanding the difference is fundamental to writing correct C programs. The stack is fast and automatic but limited in size; the heap is flexible and large but requires manual management. Choosing the wrong one leads to either stack overflows (too much stack) or memory leaks (forgetting to free heap memory).

#include <stdio.h>
#include <stdlib.h>

void stack_example(void) {
    int arr[1000];    /* ~4KB on the stack — fast, zero overhead, dies with the function */
    arr[0] = 42;
    /* arr is freed automatically when this function returns */
}

int *heap_example(int n) {
    int *arr = malloc(n * sizeof(int));  /* on the heap — survives beyond the function */
    if (!arr) return NULL;

    for (int i = 0; i < n; i++) arr[i] = i;
    return arr;   /* safe: heap memory outlives the function that allocated it */
}

int main(void) {
    stack_example();

    int *data = heap_example(10);
    if (data) {
        printf("data[5] = %d\n", data[5]);   /* 5 */
        free(data);   /* must free heap memory — it does not go away on its own */
        data = NULL;  /* set to NULL to prevent accidental use-after-free */
    }
    return 0;
}

Stack: Fast, limited size (typically 1–8 MB), automatically managed. Good for small, short-lived data.

Heap: Slower, virtually unlimited (bounded by available RAM), 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. The contents are uninitialized — they contain whatever bytes happened to be in that memory. Always check the return value; malloc returns NULL if allocation fails.

#include <stdlib.h>
#include <stdio.h>

int main(void) {
    int n = 100;

    /* Allocate n ints — contents are uninitialized (garbage until you write them) */
    int *arr = malloc(n * sizeof(int));
    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]);   /* 100 */
    free(arr);
    arr = NULL;

    /* malloc returns NULL on failure — always check */
    size_t huge = (size_t)-1;   /* impossibly large */
    int *fail = malloc(huge);
    if (!fail) {
        printf("Allocation failed (expected)\n");
    }

    return 0;
}

calloc — Allocate Zero-Initialized Memory

calloc allocates memory and zeroes it out before returning. This is slightly slower than malloc but eliminates uninitialized-read bugs for data structures that rely on zero as a default value — hash tables, sparse arrays, and counters.

#include <stdlib.h>
#include <stdio.h>

int main(void) {
    int rows = 4, cols = 4;

    /* calloc(count, size) — allocates count*size bytes, all set to zero */
    int *matrix = calloc(rows * cols, sizeof(int));
    if (!matrix) return 1;

    /* All elements start at 0 — no need to initialize explicitly */
    matrix[1 * cols + 2] = 42;  /* matrix[1][2] = 42 */

    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;
}

Use calloc when you need zeroed memory. It is often slightly slower than malloc but avoids uninitialized-read bugs.

realloc — Resize an Allocation

realloc changes the size of an existing allocation. It may move the data to a new location if the current block cannot be expanded in place. The classic use case is a dynamic array (like C++‘s std::vector) that doubles its capacity whenever it fills up.

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

/* Dynamic array that grows automatically as elements are added */
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;

        /* CRITICAL: use a temp pointer — if realloc fails, the original is still valid */
        int *tmp = realloc(v->data, new_cap * sizeof(int));
        if (!tmp) return -1;  /* v->data is still valid — caller can recover */

        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) {
            fprintf(stderr, "Push failed\n");
            break;
        }
    }

    for (size_t i = 0; i < v.size; i++) {
        printf("%d ", v.data[i]);
    }
    printf("\n");
    printf("size=%zu, capacity=%zu\n", v.size, v.capacity);

    free(v.data);
    return 0;
}

Never do ptr = realloc(ptr, new_size) — if realloc returns NULL, you’ve lost the original pointer and leaked the memory. Always use a temporary variable.

free — Release Memory

free returns memory to the heap. Every malloc/calloc/realloc must eventually be paired with a free. Forgetting to free causes memory leaks; freeing the same pointer twice (double-free) corrupts the allocator’s internal state and can be exploited as a security vulnerability.

#include <stdlib.h>
#include <string.h>

typedef struct Node {
    int          value;
    struct Node *next;
} Node;

/* Free every node in a linked list — save next BEFORE freeing the current node */
void free_list(Node *head) {
    while (head) {
        Node *next = head->next;  /* save before free — after free, head is invalid */
        free(head);
        head = next;
    }
}

int main(void) {
    /* Build a small list */
    Node *head = NULL;
    for (int i = 5; i >= 1; i--) {
        Node *n = malloc(sizeof(Node));
        n->value = i;
        n->next  = head;
        head = n;
    }

    free_list(head);
    head = NULL;
    return 0;
}

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 to detect use-after-free bugs

Memory Leaks and Valgrind

A memory leak occurs when allocated memory is never freed. In a long-running process, leaks grow the process’s memory consumption until the OS kills it. Valgrind and AddressSanitizer make leaks visible immediately, pointing to the exact line where the leaked memory was allocated.

/* Intentional leak for demonstration */
void leaky_function(void) {
    int *buf = malloc(1024);
    /* forgot to free(buf) before returning — 1024 bytes leaked */
}

Compile with debug symbols and run under Valgrind:

gcc -Wall -std=c11 -g -o prog program.c
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./prog

Valgrind output for a leak:

==12345== 1,024 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345==    at 0x4C2FB0F: malloc (vg_replace_malloc.c:309)
==12345==    by 0x10868B: leaky_function (program.c:4)
==12345==    by 0x1086A1: main (program.c:9)

On Linux/macOS with GCC or Clang, AddressSanitizer catches leaks and corruptions at runtime:

gcc -Wall -std=c11 -g -fsanitize=address,undefined -o prog program.c
./prog

Buffer Overflow Example

A buffer overflow writes past the end of an allocated buffer, corrupting adjacent memory. This is the root cause of countless security vulnerabilities. The fix is always the same: use length-limited functions and verify that your buffers are large enough.

#include <string.h>
#include <stdio.h>

/* DANGEROUS — do not write code like this */
void vulnerable(const char *input) {
    char buf[8];
    strcpy(buf, input);   /* no bounds check — overflows if input > 7 chars */
    printf("buf: %s\n", buf);
}

/* SAFE version — snprintf always null-terminates and never overflows */
void safe_version(const char *input) {
    char buf[8];
    snprintf(buf, sizeof(buf), "%s", input);   /* truncates gracefully */
    printf("buf: %s\n", buf);
}

int main(void) {
    safe_version("hello");        /* OK */
    safe_version("hello world");  /* truncated to "hello w" but safe */
    return 0;
}

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

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 to prevent use-after-free bugs.
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. Heap memory (malloc/free) persists until you explicitly free it, survives function returns, and can be arbitrarily large.