How the Heap Works in C and Why Embedded Code Avoids It

The C heap is a region of memory that your program borrows from at runtime with malloc(), calloc() or realloc() and hands back with free(). It gives you memory whose size and lifetime you decide while the program runs. On a microcontroller with 20 KB of RAM and no memory protection, that flexibility becomes a liability, so most embedded code bans or tightly limits it.

Managed bymalloc, free
Headerstdlib.h
Lifetimeuntil free() is called
STM32CubeIDE default512 bytes
MISRA C2012: Rule 21.3 bans it

Where the Heap Sits in Memory

A compiled C program is split into sections. On a typical bare-metal ARM Cortex-M part, the linker lays them out like this:

Flash:  .text (code)   .rodata (constants)   .data initial values
RAM:    .data | .bss | heap -->        <-- stack
        low address                    high address (_estack)

.data holds initialised globals. .bss holds zeroed globals. The heap starts right after .bss and grows upward. The stack starts at the top of RAM and grows down.

Nothing physically separates them. If the heap grows into the stack, or the stack grows into the heap, you get silent corruption. On a desktop OS, virtual memory and guard pages catch most of this. On a Cortex-M0 with no MPU, nothing does.

The Four Functions That Manage It

The standard library gives you four core functions, all declared in <stdlib.h>:

  • malloc(size) returns a pointer to size bytes of uninitialised memory, or NULL on failure.
  • calloc(n, size) allocates n * size bytes and zeroes them.
  • realloc(ptr, size) resizes a block, possibly moving it to a new address.
  • free(ptr) returns the block to the allocator.

C11 added aligned_alloc(), and C23 added free_sized() and free_aligned_sized(). Here is the basic pattern, with the checks people skip:

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

int *make_buffer(size_t count)
{
    int *buf = malloc(count * sizeof *buf);
    if (buf == NULL) {
        return NULL;            /* allocation failed: handle it */
    }
    memset(buf, 0, count * sizeof *buf);
    return buf;
}

void use_it(void)
{
    int *data = make_buffer(256);
    if (!data) { /* recover or fail safe */ return; }

    data[0] = 42;
    free(data);
    data = NULL;                /* avoid a dangling pointer */
}

Every malloc needs exactly one matching free. Miss one and you leak. Call it twice and you corrupt the allocator’s internal data.

What malloc Actually Does Under the Hood

malloc is a library function, not a hardware feature. It manages a pool of memory and keeps bookkeeping data next to every block you get.

On Linux, glibc uses ptmalloc2, derived from Doug Lea’s dlmalloc. Each allocation is a chunk with a size header in front of your data. On 64-bit x86, the minimum chunk is 32 bytes and alignment is 16 bytes. So malloc(1) actually consumes 32 bytes, of which 24 are usable.

Freed chunks go into bins: per-thread tcache bins (added in glibc 2.26), fastbins, small bins, large bins and an unsorted bin. The next malloc searches these lists for a fit. When the pool runs dry, glibc asks the kernel for more with brk/sbrk. Requests above M_MMAP_THRESHOLD, which defaults to 128 KiB, get their own mmap mapping instead.

On a microcontroller the story is simpler. Most GCC Arm toolchains ship newlib or newlib-nano (--specs=nano.specs). Its malloc calls a function named _sbrk() to extend the heap. In STM32CubeIDE projects, _sbrk() lives in sysmem.c. It hands out memory from the end of .bss and returns ENOMEM once it would cross into the space reserved for the stack, _estack - _Min_Stack_Size.

Engineer's note

_Min_Heap_Size in an STM32 linker script is a link-time check. It is not a cap. The linker only fails if heap plus stack minimums do not fit in RAM. At runtime, _sbrk() decides how far the heap can grow.

Stack vs Heap in C

Property Stack Heap
Allocated by Compiler, on function entry You, via malloc/calloc/realloc
Freed by Automatically on return You, via free
Allocation time Constant, one pointer adjust Variable, depends on free list search
Size known At compile time (except VLAs) At runtime
Failure mode Stack overflow, often silent on MCUs NULL return, or fragmentation
Typical bugs Returning pointers to locals Leaks, double free, use-after-free
Embedded verdict Preferred, but size it carefully Avoided or restricted to init

Why Embedded Code Avoids the Heap

Five problems stack up on small targets.

Fragmentation. Picture a 4 KB heap. You allocate 32 blocks of 64 bytes, then free every second one. Roughly half the heap is free, yet the largest contiguous block is still about 64 bytes. A request for 200 bytes fails. On a desktop that program restarts in a second. A motor controller running for six months has no such luxury.

Non-deterministic timing. A free-list search takes a different amount of time depending on the heap’s history. A 1 kHz control loop has a 1 ms budget. If malloc occasionally takes 80 ยตs instead of 2 ยตs, you have jitter you cannot bound on paper.

No safety net. Without an MMU there is no swap, no guard page and no OS to kill a misbehaving task. Heap-stack collisions overwrite live data instead of crashing cleanly.

Thread safety. newlib’s malloc needs __malloc_lock() and __malloc_unlock() hooks to be safe under an RTOS. Many projects never implement them, so two tasks calling malloc at once can corrupt the free list.

Certification. Safety standards forbid it outright:

Standard Rule What it says
MISRA C:2012 Rule 21.3 (Required) Memory functions of <stdlib.h> shall not be used
MISRA C:2012 Directive 4.12 (Required) Dynamic memory allocation shall not be used
NASA/JPL Power of Ten Rule 3 No dynamic memory allocation after initialization
JPL C Coding Standard LOC-3 rules Mirrors the Power of Ten restriction

The RAM budget makes all of this sharper. These are real on-chip SRAM figures for common parts:

Microcontroller Core On-chip SRAM
ATmega328P (Arduino Uno) AVR 8-bit 2 KB
STM32F103C8 (“Blue Pill”) Cortex-M3 20 KB
STM32F407VG Cortex-M4F 192 KB (incl. 64 KB CCM)
nRF52840 Cortex-M4F 256 KB
RP2040 (Raspberry Pi Pico) Dual Cortex-M0+ 264 KB
ESP32 Xtensa LX6 520 KB

On the ATmega328P, the 2 KB is shared by globals, stack and heap. One careless String concatenation in Arduino code can eat it.

What Firmware Does Instead

The fixes are boring and effective.

Static allocation. Size everything at compile time. Globals and static arrays live in .bss, and the linker tells you immediately if they don’t fit.

Fixed-block memory pools. Carve a static array into equal-sized blocks and hand them out from a free list. Allocation and release are O(1), and fragmentation cannot happen because every block is the same size. Zephyr’s k_mem_slab works this way.

Allocate once at boot. Follow the JPL rule: call malloc during initialization, never free, and never allocate again. You get runtime-sized buffers with no fragmentation risk.

Use the RTOS allocator deliberately. FreeRTOS replaces malloc with pvPortMalloc() and ships five schemes. You compile exactly one into your project:

Scheme Free supported Coalescing Best for
heap_1 No n/a Create objects at boot, never delete
heap_2 Yes No Legacy; superseded by heap_4
heap_3 Yes Depends on libc Wraps standard malloc/free with thread safety
heap_4 Yes Yes (first fit) General use; the usual choice
heap_5 Yes Yes heap_4 across non-contiguous RAM regions

heap_1 through heap_4 take their memory from a static array sized by configTOTAL_HEAP_SIZE. heap_5 needs vPortDefineHeapRegions() called before the first allocation. Better still, set configSUPPORT_STATIC_ALLOCATION to 1 and use xTaskCreateStatic() and xQueueCreateStatic(). You supply the memory, so the kernel never touches a heap.

The same discipline shows up higher in the stack. Real-time guidance for ROS 2 tells you to preallocate messages and avoid allocation inside the control loop, and the realtime_support repository includes a TLSF-based allocator for that purpose. The joint controller inside a dog robot follows the identical rule at the firmware level.

Not the Same Thing: the Heap Data Structure

Search results mix two unrelated ideas. A binary heap is a tree stored in an array, where each parent is larger (max-heap) or smaller (min-heap) than its children. It powers priority queues and heapsort. It has nothing to do with malloc.

The index math is simple. For a node at index i, the parent sits at (i - 1) / 2, the left child at 2i + 1 and the right child at 2i + 2.

Creating a fixed-size heap

A fixed-size version fits embedded rules perfectly, since it lives in a static array:

\\#define HEAP_CAP 32

static int heap[HEAP_CAP];
static int heap_len = 0;

int heap_push(int value)            /* max-heap insert */
{
    if (heap_len >= HEAP_CAP) return -1;
    int i = heap_len++;
    heap[i] = value;
    while (i > 0 && heap[(i - 1) / 2] < heap[i]) {
        int p = (i - 1) / 2;
        int tmp = heap[p]; heap[p] = heap[i]; heap[i] = tmp;
        i = p;
    }
    return 0;
}

Insert and delete both run in O(log n). A pop removes the largest value at index 0, moves the last element to the root and sifts it down.

Creating a dynamically-sized heap

The desktop version grows its backing array with realloc, usually doubling capacity when full. That works well on Linux. On firmware, prefer the fixed array and treat a full queue as a design error to catch in testing.

Debugging Heap Problems on Embedded Targets

When you must use a heap, measure it:

  • FreeRTOS: xPortGetFreeHeapSize() gives current free space. xPortGetMinimumEverFreeHeapSize() gives the low-water mark, the number you actually size against. Enable configUSE_MALLOC_FAILED_HOOK to trap failures.
  • newlib: mallinfo() reports arena size and bytes in use.
  • Stack painting: fill RAM between heap and stack with a pattern like 0xDEADBEEF at boot, then inspect it in the debugger to see how close the two came.
  • Desktop first: compile your logic for the host and run it under AddressSanitizer (-fsanitize=address) or Valgrind to catch leaks and use-after-free before flashing.

Reality check

A heap that survives a one-hour bench test proves nothing about month six. Fragmentation depends on allocation history, so only a bounded design, or a long soak test under realistic load, gives you confidence.

FAQ

Is the C heap slower than the stack?

Yes. A stack allocation is one pointer adjustment. malloc searches free lists and updates metadata, and its time varies with heap history. For most desktop code the difference doesn’t matter. In a hard real-time loop it does.

What happens if malloc fails?

It returns NULL. If you dereference that without checking, you get undefined behavior. On a Cortex-M that usually means a HardFault, or writes to address 0 that corrupt whatever lives there.

Can I use malloc on Arduino?

You can, and the String class does internally. On a 2 KB ATmega328P, fragmentation shows up quickly. Use fixed char buffers for anything that runs indefinitely.

Does freeing memory return it to the operating system?

Usually not right away. glibc keeps freed chunks in its bins for reuse. Large mmap-backed blocks are unmapped on free, and the top of the heap can be trimmed. On bare metal there is no OS, so the memory simply goes back to the allocator’s free list.

Is any dynamic allocation acceptable in safety-critical firmware?

Allocation during initialization only is the common compromise, and it’s what the JPL rules permit. MISRA C still flags it, so you would need a documented deviation. Fixed-block pools are the other accepted pattern, since their timing and failure modes are fully predictable.