Why dynamic memory

Local variables live on the stack: their size is fixed when you compile, and they disappear when the function returns. That is not enough when the size is only known at run time (the number of readings in a file), when data must outlive the function that creates it (a parsed telegram that is queued for sending), or when it is too large for the stack, which is typically a few megabytes on Linux and only a few kilobytes per task on a microcontroller.

Dynamic memory allocation is requesting memory while the program runs, from a region called the heap, and releasing it explicitly when it is no longer needed. In C you do both by hand, with four functions from <stdlib.h>. The general picture of stack and heap, and how garbage-collected languages avoid all of this, is on Memory Management; this page is about doing it right in C.

malloc and free

malloc() (memory allocate) takes a number of bytes, reserves a block of at least that size on the heap and returns a void * pointer to its first byte. When it cannot, it returns NULL. The contents of the block are indeterminate: whatever bytes were there before. free() takes a pointer that malloc, calloc or realloc returned and gives the block back to the allocator, so it can be reused. After that call the pointer still holds the old address, but the memory is no longer yours.

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

int main(void)
{
    int count = 4;                  /* imagine: read from a file header */
    int *readings = malloc(count * sizeof *readings);
    if (readings == NULL) {
        fprintf(stderr, "out of memory\n");
        return EXIT_FAILURE;
    }

    int values[] = {120, 95, 130, 110};
    int total = 0;
    for (int i = 0; i < count; i++) {
        readings[i] = values[i];
        total += readings[i];
    }
    printf("%d readings, total %d kWh\n", count, total);

    free(readings);                 /* give the block back */
    readings = NULL;                /* and forget its address */
    free(readings);                 /* free(NULL) does nothing: safe */
    return 0;
}
4 readings, total 455 kWh

Three habits in this short example are worth keeping:

  • count * sizeof *readings takes the element size from the pointer itself. If the type of readings changes later, the size changes with it; writing sizeof(int) would silently become wrong.
  • The result is checked for NULL before use. On a Linux server malloc rarely fails for small sizes, but on a microcontroller with 400 KB of RAM it does, and using a null pointer crashes the program (or, without memory protection, writes to address 0).
  • The pointer is set to NULL after free. A second free through the same variable is then harmless, and a use of it crashes clearly instead of reading freed memory. It does not help for other copies of the address.

malloc returns void *, which converts to any object pointer type without a cast in C. Writing (int *)malloc(…) is needed in C++, not in C.

The life of a heap block in three steps. After malloc, the pointer readings on the stack points to a block of 16 bytes on the heap. After free, the block is returned to the allocator but readings still holds its address: a dangling pointer. After readings = NULL, the pointer no longer refers to the block. 1. malloc readings 120 95 130 110 yours 2. free(readings) readings allocator's again dangling 3. readings = NULL NULL may be reused
free changes who owns the block, not the pointer. Between steps 2 and 3, any use of readings is a use-after-free.

calloc: zeroed memory

calloc(count, size) allocates room for count elements of size bytes and sets every byte to zero. It has a second, less known advantage: it checks whether count * size overflows. With malloc(count * size), a huge count from untrusted input can wrap the multiplication around to a small number; the allocation succeeds, and the loop that fills count elements writes far past its end.

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

int main(void)
{
    int *counters = calloc(96, sizeof *counters);    /* 96 quarter-hours */
    if (counters == NULL) {
        return EXIT_FAILURE;
    }
    printf("first %d, last %d\n", counters[0], counters[95]);
    free(counters);

    size_t count = SIZE_MAX / 2;    /* a corrupted count from a file */
    size_t bytes = count * sizeof(int);
    printf("count * 4 wraps around to %zu bytes\n", bytes);

    int *big = calloc(count, sizeof(int));
    printf("calloc: %s\n", big == NULL ? "NULL (overflow detected)" : "allocated");
    free(big);
    return 0;
}
first 0, last 0
count * 4 wraps around to 18446744073709551612 bytes
calloc: NULL (overflow detected)

SIZE_MAX / 2 elements of 4 bytes would need more bytes than a size_t can count, and the unsigned multiplication wrapped around to an almost-full but wrong value. calloc noticed and returned NULL. When you use malloc with a computed size, check the multiplication yourself first, for example with if (count > SIZE_MAX / sizeof *p).

realloc: growing a block

realloc() changes the size of an allocated block. When the block cannot grow where it is, realloc allocates a new block, copies the old contents and frees the old block, so the returned pointer may differ from the one you passed. When it fails, it returns NULL and leaves the old block untouched. That is why the result goes into a temporary variable first:

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

struct reading_list {
    int *items;
    size_t count;
    size_t capacity;
};

/* Appends one reading; returns 0 on success, -1 when out of memory. */
static int append(struct reading_list *list, int kwh)
{
    if (list->count == list->capacity) {
        size_t new_capacity = list->capacity == 0 ? 2 : list->capacity * 2;
        int *bigger = realloc(list->items, new_capacity * sizeof *bigger);
        if (bigger == NULL) {
            return -1;              /* list->items is still valid */
        }
        list->items = bigger;
        list->capacity = new_capacity;
        printf("grew to %zu\n", new_capacity);
    }
    list->items[list->count++] = kwh;
    return 0;
}

int main(void)
{
    struct reading_list list = {NULL, 0, 0};    /* realloc(NULL, n) acts like malloc(n) */
    int values[] = {120, 95, 130, 110, 1500};

    for (int i = 0; i < 5; i++) {
        if (append(&list, values[i]) != 0) {
            free(list.items);
            return EXIT_FAILURE;
        }
    }
    printf("%zu readings in room for %zu\n", list.count, list.capacity);
    free(list.items);
    return 0;
}
grew to 2
grew to 4
grew to 8
5 readings in room for 8

Doubling the capacity keeps the number of copies low: appending n items costs about n copies in total. This is how a dynamic array such as a Python list works inside. The classic mistake is list->items = realloc(list->items, …): if realloc fails, it returns NULL, which overwrites the only pointer to the old block, and that block leaks.

Who frees what: ownership

C has no rule about who frees a block; the language only says it must happen exactly once, and not before the last use. Memory ownership is the convention that settles this: at any moment one part of the program, the owner, is responsible for freeing each block, and ownership can be handed over explicitly. In C the convention lives in comments and names, so state it in every function that allocates or frees:

#define _POSIX_C_SOURCE 200809L     /* for strdup under -std=c17 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct dongle {
    char *serial;                   /* owned: freed by dongle_destroy */
    char *home_id;                  /* owned */
    int power_w;
};

/* Creates a dongle. The caller owns the result and must call dongle_destroy. */
static struct dongle *dongle_create(const char *serial, const char *home_id)
{
    struct dongle *d = malloc(sizeof *d);
    if (d == NULL) {
        return NULL;
    }
    d->serial = strdup(serial);     /* strdup = malloc + copy */
    d->home_id = strdup(home_id);
    d->power_w = 0;
    if (d->serial == NULL || d->home_id == NULL) {
        free(d->serial);            /* free(NULL) is fine */
        free(d->home_id);
        free(d);
        return NULL;
    }
    return d;
}

/* Frees the dongle and everything it owns. Accepts NULL. */
static void dongle_destroy(struct dongle *d)
{
    if (d == NULL) {
        return;
    }
    free(d->serial);                /* the members first ... */
    free(d->home_id);
    free(d);                        /* ... then the struct itself */
}

int main(void)
{
    struct dongle *d = dongle_create("KHP1-000231", "h0017");
    if (d == NULL) {
        return EXIT_FAILURE;
    }
    d->power_w = 1234;
    printf("%s in home %s: %d W\n", d->serial, d->home_id, d->power_w);
    dongle_destroy(d);
    return 0;
}
KHP1-000231 in home h0017: 1234 W

The pair dongle_create/dongle_destroy is the usual C pattern: one function allocates the whole object, another frees the whole object in the reverse order, and callers never free the members by hand. Libraries use the same pattern (fopen/fclose, SSL_new/SSL_free in OpenSSL). A function that only borrows a pointer, such as one that prints a dongle, takes const struct dongle * and frees nothing. strdup was a POSIX function until C23 added it to the standard library; with -std=c17 glibc only declares it when you ask for POSIX with the macro on the first line.

The classic heap bugs

The four bugs below have one thing in common: the program usually keeps running after them, so a normal test passes. The examples are built with AddressSanitizer (-g -fsanitize=address,undefined), which stops the program and reports the bug; the last line shown is its summary. How to read a full report is on Debugging C.

Memory leaks

A memory leak is a block that is never freed although nothing uses it any more. Most leaks come from an early return on an error path that skips the free:

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

/* Returns 0 if the line is a valid power line. */
static int check_line(const char *line)
{
    char *copy = malloc(strlen(line) + 1);
    if (copy == NULL) {
        return -1;
    }
    strcpy(copy, line);             /* fits: strlen + 1 bytes */
    if (strncmp(copy, "1-0:1.7.0(", 10) != 0) {
        return -1;                  /* BUG: copy is never freed */
    }
    free(copy);
    return 0;
}

int main(void)
{
    printf("%d\n", check_line("1-0:1.7.0(01.234*kW)"));
    printf("%d\n", check_line("0-0:96.1.1(4B384547)"));
    return 0;
}
0
-1
SUMMARY: AddressSanitizer: 21 byte(s) leaked in 1 allocation(s).

The leak check (LeakSanitizer, part of ASan on Linux) runs when the program ends, so both lines were printed first. 21 bytes is nothing; a P1 dongle that leaks 21 bytes per rejected telegram, at one telegram per second, runs out of heap within hours. In a long-running C program a leak shows up as memory use that only grows, until the out-of-memory killer ends the process or the device's watchdog restarts it.

Use-after-free

A use-after-free is a read or write through a pointer to a block that has already been freed: a dangling pointer. The allocator may already have given the memory to another part of the program, so the read returns someone else's data and a write corrupts it.

When the free and the use are in the same function, gcc 12 sees it: -Wall includes -Wuse-after-free, and with -Werror this does not build.

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

int main(void)
{
    int *kwh = malloc(4 * sizeof *kwh);
    if (kwh == NULL) {
        return EXIT_FAILURE;
    }
    kwh[0] = 350;
    free(kwh);
    printf("last reading: %d\n", kwh[0]);   /* BUG: freed one line ago */
    return 0;
}
example.c:12:5: error: pointer ‘kwh’ used after ‘free’ [-Werror=use-after-free]

Real use-after-free bugs are rarely this close together: a pointer is stored in a struct or a list, the block is freed elsewhere, and the stored pointer is used later. The compiler cannot follow that, but AddressSanitizer catches it when the code runs:

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

struct cache {
    int *last;                      /* borrowed: points into someone else's block */
};

static void remember(struct cache *c, int *reading)
{
    c->last = reading;
}

int main(void)
{
    struct cache cache = {NULL};
    int *kwh = malloc(4 * sizeof *kwh);
    if (kwh == NULL) {
        return EXIT_FAILURE;
    }
    kwh[0] = 350;
    remember(&cache, &kwh[0]);
    free(kwh);                      /* the owner frees the block ... */
    kwh = NULL;
    printf("cached reading: %d\n", *cache.last);   /* ... the cache still uses it */
    return 0;
}
SUMMARY: AddressSanitizer: heap-use-after-free example.c:24 in main

Setting kwh to NULL did not help, because the cache held its own copy of the address. Without the sanitizer such a program prints a meaningless number that can differ on every run, and exits with status 0. Use-after-free bugs are a large share of serious security bugs: Chromium's security team reports that around 70% of its high-severity security bugs are memory safety problems and that half of those are use-after-free bugs, and CWE-416 Use After Free is number 7 in the CWE Top 25 of 2025.

Double free

A double free is calling free twice on the same block. The second call corrupts the allocator's own bookkeeping. Here the two calls are in different functions, out of the compiler's sight. glibc detects the simplest cases at run time and stops the program; this example is built without a sanitizer and shows the standard error output too:

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

struct telegram {
    char *text;                     /* owned */
};

static void telegram_clear(struct telegram *t)
{
    free(t->text);                  /* BUG: frees, but keeps the address */
}

int main(void)
{
    struct telegram t = {malloc(64)};
    if (t.text == NULL) {
        return EXIT_FAILURE;
    }
    telegram_clear(&t);
    printf("cleared once\n");
    telegram_clear(&t);             /* the same block again */
    printf("cleared twice\n");
    return 0;
}
cleared once
free(): double free detected in tcache 2
Aborted

The program was killed with the SIGABRT signal (a shell reports exit status 134), and the message text is glibc's (version 2.36 here); other C libraries and versions word it differently or do not notice at all. The fix is one line in telegram_clear: t->text = NULL; after the free, so that a second call frees NULL, which does nothing. With AddressSanitizer the report names both free calls and the malloc.

Heap buffer overflow

Writing past the end of a heap block is the heap version of the buffer overflow. The usual cause is an off-by-one loop condition or a size calculation that forgets an element:

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

int main(void)
{
    int count = 4;
    int *readings = malloc(count * sizeof *readings);
    if (readings == NULL) {
        return EXIT_FAILURE;
    }
    for (int i = 0; i <= count; i++) {      /* BUG: <= writes 5 elements */
        readings[i] = 100 + i;
    }
    printf("filled\n");
    free(readings);
    return 0;
}
SUMMARY: AddressSanitizer: heap-buffer-overflow example.c:12 in main

The summary names line 12, the assignment in the loop. The full report adds that the write was "0 bytes to the right of 16-byte region" and where that region was allocated.

Finding heap bugs before they ship

Sanitizers find bugs on the paths your tests actually run. gcc's static analyzer, -fanalyzer (since GCC 10), looks at every path through a function without running it, and reports some of the same bugs at compile time. Recorded with gcc 12 on small test files:

warning: double-‘free’ of ‘buf’ [CWE-415] [-Wanalyzer-double-free]
warning: use after ‘free’ of ‘kwh’ [CWE-416] [-Wanalyzer-use-after-free]
warning: leak of ‘copy’ [CWE-401] [-Wanalyzer-malloc-leak]
warning: dereference of possibly-NULL ‘p’ [CWE-690] [-Wanalyzer-possible-null-dereference]

The last one is a malloc result used without a NULL check. The analyzer misses things too (in our test it did not report a leak at the end of main), so it complements sanitizers and tests rather than replacing them. Valgrind's Memcheck finds the same classes of bugs without recompiling, at a much larger slowdown. More tools and how to combine them: Debugging C and Undefined Behavior and Safer C.

Heap fragmentation and small devices

Heap fragmentation is the situation where enough heap memory is free in total, but split into pieces between blocks that are still in use, so that no single piece is large enough for a new request. A program that keeps allocating and freeing blocks of different sizes can fail an allocation of 2 KB while 20 KB is free.

On a server with gigabytes of virtual memory this rarely matters. On a microcontroller such as the ESP32-C3 in the Kestrel Home P1 dongle, with a few hundred kilobytes of RAM and firmware that runs for months without a restart, it is a real cause of failures that appear only after days. Firmware therefore often follows stricter rules:

  • Allocate what the device needs at startup and never free it; after that, no malloc in the main loop.
  • Use fixed-size buffers and pools: an array of 8 telegram buffers that are reused, or a ring buffer.
  • Watch the free heap and the largest free block in the device's health data, so that a slow leak or growing fragmentation is visible before it causes a crash.

Coding standards for safety-critical C, such as MISRA C, go further and restrict dynamic memory allocation altogether. See Undefined Behavior and Safer C.