FreeRTOS Memory Management: Which Heap Scheme for Constrained BLE Devices

Your BLE device works fine for twenty minutes, then crashes. You reboot, it runs for an hour, crashes again. The stack trace points somewhere into pvPortMalloc(), and FreeRTOS is returning NULL. You have 40 KB of RAM and the heap stats say 8 KB is free, so why can’t the system allocate a 200-byte connection context?
The answer is probably fragmentation. And the fix starts with a choice you may not have realized you were making: which of FreeRTOS’s five heap allocation schemes is actually compiled into your firmware.
Why FreeRTOS Doesn’t Just Use malloc()
The standard C library malloc() carries three problems that make it a poor fit for microcontrollers. Its execution time varies unpredictably depending on heap state. It’s not thread-safe, so two tasks calling malloc() simultaneously can corrupt the heap. And its implementation often pulls in more code than your flash budget allows.
FreeRTOS replaces it entirely. When your code (or the BLE stack) allocates memory, it calls pvPortMalloc() and vPortFree() instead. The twist: FreeRTOS ships five different implementations of these functions, heap_1.c through heap_5.c, and you pick one by including that source file in your build. Each makes different trade-offs around speed, fragmentation, and flexibility.
This matters disproportionately for BLE. A BLE stack is RAM-hungry relative to a small MCU’s budget: connection contexts, GATT attribute tables, advertisement buffers, L2CAP reassembly queues. On a device with 64–256 KB of SRAM, the allocation strategy you choose directly determines whether your device survives days of continuous connect/disconnect cycles or dies after a few hours.
Heap Fragmentation and Why It Kills Long-Running Devices
Think of your heap as a parking lot. Allocations are cars of different sizes pulling in. Frees are cars leaving. After enough arrivals and departures, you end up with gaps scattered throughout, technically enough total space for a new SUV, but no single contiguous stretch big enough.
RAM Heap (64 bytes shown)
After several alloc/free cycles:
|████|....|████████|....|████|................|
Used Free Used Free Used Large Free
████ = Allocated block
.... = Free fragment (too small for a new 12-byte request)
Total free: 22 bytes
Largest contiguous free: 16 bytes ← This is what actually mattersThat’s external fragmentation: free memory exists but is scattered into unusable pieces. Internal fragmentation is wasted space inside an allocation (e.g., you need 10 bytes but the allocator rounds up to 16).
Two other terms you’ll encounter: determinism means the allocation completes in a bounded, predictable time, which is important when a BLE callback needs memory within a tight timing window. Thread safety means multiple FreeRTOS tasks can allocate simultaneously without corrupting the heap.
Standard malloc() typically guarantees neither.
The Five FreeRTOS Heap Schemes Compared
You select a scheme by including one file, heap_1.c, heap_2.c, etc., in your project. Here’s the landscape:
| Scheme | free() | Coalescing | Deterministic | Thread-Safe | Best For |
|---------|--------|------------|---------------|-------------|-----------------------------|
| heap_1 | No | N/A | Yes | Yes | Static systems, boot-only |
| heap_2 | Yes | No | No | Yes | Legacy only |
| heap_3 | Yes | Depends* | No | Yes** | SDK-mandated malloc |
| heap_4 | Yes | Yes | No*** | Yes | Most BLE projects (default) |
| heap_5 | Yes | Yes | No*** | Yes | Non-contiguous RAM |
* Depends on compiler's stdlib implementation
** Thread safety via scheduler suspend
*** Deterministic for same-size blocks; variable for first-fit searchLet’s look at each one and what it means for your BLE project.
heap_1 — Allocate Once, Never Free
heap_1 is the simplest allocator possible: a bump pointer. Each call to pvPortMalloc() advances a pointer forward through the heap. vPortFree() does nothing.
This is fully deterministic (every allocation takes the same time) and uses zero bytes of overhead per block. It’s ideal for systems where you create all tasks, queues, and semaphores at boot and never delete them.
BLE relevance: Tempting for a simple peripheral that advertises and waits for a single connection. But the moment connections drop and reconnect, which they will, the BLE stack needs to free and reallocate connection contexts. heap_1 can’t do that. Unless your device truly never tears down any runtime object, skip this one.
heap_2 — Free Without Merging (Legacy)
heap_2 maintains a linked list of free blocks and uses a best-fit algorithm: it finds the smallest free block that satisfies the request. But when you free memory, adjacent free blocks are not merged back together (no coalescing, the process of combining neighboring free blocks into one larger block).
This means repeated alloc/free cycles of varying sizes will fragment the heap into ever-smaller pieces. It’s exactly the parking lot problem from earlier.
BLE relevance: BLE connections allocate and free buffers of varying sizes constantly. heap_2 will fragment under this workload. The FreeRTOS documentation itself considers heap_2 legacy. If you’re reaching for heap_2, use heap_4 instead.
heap_3 — A Thread-Safe Wrapper Around malloc()
heap_3 doesn’t manage its own memory at all. It wraps your compiler’s standard malloc() and free() with a scheduler suspend/resume to make them thread-safe. The heap size is determined by your linker script, not configTOTAL_HEAP_SIZE.
The trade-off: you inherit whatever behavior your toolchain’s allocator provides, possibly non-deterministic, possibly large code footprint, possibly good, possibly terrible.
BLE relevance: Some vendor BSPs (especially those with proprietary BLE stacks) already manage their own heap and expect malloc() to work normally. heap_3 keeps the peace. But you sacrifice visibility: FreeRTOS heap monitoring APIs (xPortGetFreeHeapSize(), etc.) won’t work, because FreeRTOS isn’t managing the heap.
heap_4 — First-Fit with Coalescing ⭐
heap_4 is the scheme you probably want. It manages a single contiguous block of memory (sized by configTOTAL_HEAP_SIZE) using a first-fit algorithm. Critically, when you free a block, heap_4 checks whether the neighboring blocks are also free and coalesces them, merging adjacent free blocks into a single larger one.
This directly combats the fragmentation problem that kills long-running BLE devices. A connection context allocated during pairing and freed on disconnect gets merged back into usable space for the next connection.
// In FreeRTOSConfig.h
#define configTOTAL_HEAP_SIZE ( ( size_t ) ( 40 * 1024 ) ) // 40 KB
heap_4 is not perfectly deterministic. The first-fit search time depends on the free list length. But in practice, on constrained BLE devices with modest heap sizes, this is rarely a problem. It’s the recommended default in the FreeRTOS documentation for good reason.
Limitation: heap_4 works with a single contiguous memory region. If your MCU splits SRAM across multiple address ranges, you need heap_5.
heap_4 vs heap_5: When to Upgrade
heap_5 uses the exact same algorithm as heap_4: first-fit with coalescing. The difference is that it can span multiple non-contiguous memory regions.
You initialize it by passing an array of HeapRegion_t structs to vPortDefineHeapRegions() before any allocation occurs, including before you create any tasks or queues.
const HeapRegion_t xHeapRegions[] = {
{ ( uint8_t * ) 0x20000000, 0x8000 }, // 32 KB SRAM bank 1
{ ( uint8_t * ) 0x20010000, 0x4000 }, // 16 KB SRAM bank 2
{ NULL, 0 } // Terminator
};
vPortDefineHeapRegions( xHeapRegions );This is common on MCUs like the ESP32-C6, which have SRAM at different address ranges, or devices with external PSRAM. When the BLE stack claims a large chunk of one memory bank, heap_5 lets your application use the remaining bank without waste.
Rule of thumb: Start with heap_4. Move to heap_5 only when your linker map shows usable RAM in multiple regions that heap_4 can’t reach.
Picking the Right Scheme — A Decision Flowchart
Start
│
├─ Do you ever free memory? ─── No ──► heap_1
│
├─ Must you use the compiler's malloc? ─── Yes ──► heap_3
│
├─ Do you have non-contiguous RAM regions? ─── Yes ──► heap_5
│
└─ Default for everything else ──► heap_4Notice heap_2 doesn’t appear. In any scenario where you’d consider heap_2, heap_4 is strictly better: same use pattern, but with coalescing. heap_2 exists for backward compatibility with legacy projects that depend on its specific best-fit behavior.
On a BLE device, heap_4 is your default. Graduate to heap_5 when your memory map demands it.
Monitoring Heap Health at Runtime
Choosing the right scheme is step one. Step two is proving it works under real-world conditions. FreeRTOS gives you three tools:
xPortGetFreeHeapSize()returns current free bytes. Useful, but misleading if fragmented.xPortGetMinimumEverFreeHeapSize()returns the lowest free-heap value since boot. This is your high-water mark. If it’s close to zero, you’re one bad connection cycle away from a crash.vApplicationMallocFailedHook()is a callback FreeRTOS invokes whenpvPortMalloc()returnsNULL. Enable it withconfigUSE_MALLOC_FAILED_HOOKset to1.
Here’s a diagnostic task you can drop into your project during development:
void vHeapMonitorTask( void *pvParameters ) {
for( ;; ) {
size_t free_now = xPortGetFreeHeapSize();
size_t free_min = xPortGetMinimumEverFreeHeapSize();
printf( "Heap - Free: %u bytes | Min-ever: %u bytes\n",
( unsigned ) free_now, ( unsigned ) free_min );
vTaskDelay( pdMS_TO_TICKS( 5000 ) );
}
}During development, log these values over your BLE debug characteristic or UART. Run your device through 50+ connect/disconnect cycles and watch free_min. If it keeps dropping and never recovers, you have a leak or fragmentation is winning.
Practical Sizing and Allocation Tips for BLE Projects
Size by measurement, not guessing. Set configTOTAL_HEAP_SIZE generously at first, run your full application scenario, read xPortGetMinimumEverFreeHeapSize(), then reduce the heap to leave a 20–30% margin. On a 48 KB heap, if min-ever-free never drops below 18 KB, you can safely reduce to 36 KB and reclaim 12 KB for stack space.
Use static allocation for permanent objects. Tasks, queues, and semaphores that exist for the entire device lifetime don’t need to come from the heap. Use xTaskCreateStatic() and xQueueCreateStatic() instead. They use memory you provide at compile time, reducing heap pressure for the dynamic allocations the BLE stack needs. (See our guide on static vs. dynamic allocation in FreeRTOS for details.)
ESP-IDF users, take note. If you’re building on ESP-IDF for the ESP32-C6 or similar, the framework uses its own multi-region heap (heap_caps_malloc()) that supersedes the FreeRTOS port layer. FreeRTOS’s pvPortMalloc() calls map into ESP-IDF’s allocator, and configTOTAL_HEAP_SIZE is effectively ignored. Use heap_caps_get_free_size() and heap_caps_get_minimum_free_size() instead of the FreeRTOS equivalents.
Building This Into Your Next BLE Project
Here’s the short version: heap_4 is your default for constrained BLE devices. It handles the alloc/free churn of connection lifecycles, coalesces freed blocks to fight fragmentation, and works out of the box with a single configTOTAL_HEAP_SIZE setting. Move to heap_5 when your MCU has split RAM regions. Reserve heap_1 for truly static systems, heap_3 for SDK-mandated malloc() wrappers, and leave heap_2 in the past.
But choosing a scheme is only the beginning. Instrument your heap from day one, not after the first field crash. Monitor xPortGetMinimumEverFreeHeapSize() across stress tests that simulate real-world BLE connection patterns. That single number will tell you more about your device’s long-term stability than any code review.
For your next step, dig into how to monitor FreeRTOS heap fragmentation in production, because what happens on your desk and what happens in the field are rarely the same thing.
Hubble Network connects your constrained BLE devices directly to satellite, so the memory decisions you make at the firmware level matter at global scale. Learn more →