25. Memory management
A section that textbooks usually skim over, yet on a device with twenty kilobytes it decides everything. It is about where the kernel gets memory for tasks, queues and semaphores, and why the choice of one line in the project changes the behaviour of the device after a month of continuous operation.
Terms used in the documentation
Term Meaning heap the area for dynamic allocation heap scheme / implementation the algorithm that manages the heap fragmentation free space broken into small pieces coalescing merging adjacent free blocks block link the block header thread safe safe under simultaneous access high water mark the worst value over the whole run time linker heap the heap reserved by the linker region an area of memory
25.1 Why the kernel has its own heap
The first question: why can't you make do with the ordinary malloc from the C library?
Three reasons, each of them sufficient.
malloc knows nothing about tasks. Two tasks that call it at the same time
will corrupt the library's internal structures. Some implementations are protected
by locks, but those locks belong to the operating system of a computer,
not to your kernel.
malloc lives in the linker heap. Its size is set in the link
settings, not in the kernel configuration, and the kernel knows nothing about how
full it is: you cannot ask it for the worst free amount (25.4).
Alignment has nothing to do with it: malloc must return blocks suitable for
any type, and on ARM that is the same eight bytes the kernel needs.
malloc is unpredictable in time. How long a call takes depends
on the history of allocations. For a real-time system this is unacceptable.
That is why the kernel has its own pair of functions — pvPortMalloc and vPortFree —
and exactly one file that implements them. There are five files to choose from.
25.2 Five schemes
| Scheme | Freeing | Coalescing | Code | When to use |
|---|---|---|---|---|
heap_1 |
no | — | 160 bytes | everything is created once at startup |
heap_2 |
yes | no | 430 bytes | obsolete, do not use |
heap_3 |
yes | depends | 56 bytes | a wrapper over the library malloc |
heap_4 |
yes | yes | 726 bytes | the default choice |
heap_5 |
yes | yes | 884 bytes | memory is spread over several regions |
The sizes were measured by building for Cortex‑M3 with size optimization. You can see
that the difference between the schemes is hundreds of bytes, not kilobytes: choosing heap_1
to save firmware space makes no sense; it is chosen for predictability.
The modesty of heap_3 is deceptive — fifty-six bytes is only the wrapper;
the allocator itself comes from the C library and has its own weight.
heap_1 — allocation without returning
The simplest scheme: a pointer that moves forward. There is no freeing at all.
void vPortFree(void *pv)
{
(void)pv;
configASSERT(pv == NULL); /* вызывать эту функцию нельзя */
}
The function exists for compatibility, but calling it with a real pointer stops the program on the assertion. This is not an oversight but a deliberate decision: it is better to catch the error at once than to pretend the memory was returned.
The scheme looks primitive, but it has a property that none of the others
have: it cannot fragment and it runs
in constant time. For a device where all tasks and queues are created
at startup and live until power-down — and that is the vast majority of devices —
heap_1 is a perfect fit.
heap_2 — freeing without coalescing
It can free blocks but cannot join adjacent free blocks.
The consequence: a program that takes and returns blocks of different sizes ends up after a while with a heap made of many small free pieces. In total a lot is free, but you cannot allocate a hundred bytes — there is no contiguous piece of that size left.
The scheme is considered obsolete. It is kept for old projects;
in new ones heap_4 is used in its place.
heap_3 — a wrapper over the library malloc
Fifty bytes or so of its own code; all the work is done by the C library:
void *pvPortMalloc(size_t xWantedSize)
{
void *pvReturn;
vTaskSuspendAll(); /* остановить переключение задач */
pvReturn = malloc(xWantedSize);
xTaskResumeAll();
return pvReturn;
}
Suspending the scheduler solves the problem of simultaneous access — and creates two new ones.
The first: configTOTAL_HEAP_SIZE does not work here at all. The heap size
is set not in the kernel configuration but in the linker settings. Someone
who put 20 kilobytes in the configuration and got a failure on the fifth
allocation is looking for the error in the wrong place.
The second: the allocation time is unpredictable, because it depends on the library implementation, not on the kernel.
The scheme is chosen in one case: when the project already has code that uses
malloc directly and you do not want to maintain two heaps.
A side difference visible in the memory map: heap_1, heap_2 and heap_4
declare the heap as their own array and so immediately take
configTOTAL_HEAP_SIZE bytes of uninitialized data. heap_3
has no such array at all, and heap_5 has only a region descriptor
of a couple of dozen bytes: the program gives it the memory.
heap_4 — coalescing neighbours
The same as heap_2, plus the main thing: a freed block is merged
with adjacent free blocks. The heap does not crumble into fragments.
This is the default choice and the right answer when there is no reason to choose another.
In addition heap_4 can do something the first three cannot — report
on itself. More on this below.
heap_5 — several regions
The same heap_4, but the heap can consist of several non-contiguous pieces
of memory. You need it when external memory is connected to the microcontroller
(section 41) or when the internal memory is split into regions with different properties.
It requires explicit setup before the first allocation:
static uint8_t heap_int[8 * 1024]; /* внутренняя память — массивом */
static const HeapRegion_t regions[] = {
{ heap_int, sizeof heap_int }, /* место ему выберет компоновщик */
{ (uint8_t *)0x68000000, 512 * 1024 }, /* внешняя SRAM на FSMC */
{ NULL, 0 } /* признак конца списка */
};
vPortDefineHeapRegions(regions); /* до всего остального */
You cannot give away all of the internal memory from its start: .data,
.bss and the stack are already there, and the very first allocations will overwrite global variables.
The internal region is given as an array or by boundaries from the linker script.
⚠️ The regions must be in ascending address order. The order in the array is not arbitrary: the coalescing algorithm relies on regions that are adjacent in the list being adjacent in memory as well. A wrong order gives heap corruption that shows up far from the place of the error.
25.3 What one request really costs
A place where intuition lets you down.
Every block in the heap carries an eight-byte header: a pointer to the next free block and the size. It is logical to assume that a request for 512 bytes will take 520. In kernel 10.5.1, on which the handbook is built, it takes 528.
The reason is how the size formula is written. The header is added to the request, and then the result is padded to an eight-byte boundary — but padded unconditionally, by a full step. An already aligned request gets a whole eight extra bytes on top of the header:
| Requested | Taken in the heap | Overhead |
|---|---|---|
| 1 byte | 16 | 15 |
| 7 bytes | 16 | 9 |
| 8 bytes | 24 | 16 |
| 9 bytes | 24 | 15 |
| 16 bytes | 32 | 16 |
| 92 bytes | 104 | 12 |
| 512 bytes | 528 | 16 |
The overhead is from 9 to 16 bytes per block, and it is greatest precisely on the "round" sizes that programmers choose most often.
In kernel 11 the formula was fixed: padding is added only if the size
together with the header is not a multiple of eight. There 512 bytes take 520, 8 bytes take
16, 92 bytes take the same 104, and the overhead is from 8 to 15 bytes. The table above
and the task cost below are for 10.5.1; for another kernel version they are recalculated
from its heap_4.c.
Hence the cost of a task. Dynamic creation makes two separate requests — for the control block and for the stack:
| Bytes | |
|---|---|
| control block, 92 bytes | 104 |
| stack, 512 bytes | 528 |
| total from the heap | 632 |
| the same task created statically | 604 |
In the debugger's memory window the heap after one xTaskCreate looks like this:
Twenty-eight bytes of difference seem a trifle until there are ten tasks. And the main thing — a 10 KB heap holds only sixteen such tasks, after which it runs out.
⚠️ The heap always takes up space, however much of it is used. It is an ordinary array in the uninitialized data area. A
configTOTAL_HEAP_SIZEvalue of 10 KB on the F103C8 means that half of the RAM is taken from the very first clock cycle, whether you created one task or fifteen. The heap size is chosen to fit the device rather than left as it is.
25.4 Watching the heap
heap_4 and heap_5 can answer questions about their state.
size_t now = xPortGetFreeHeapSize();
size_t worst = xPortGetMinimumEverFreeHeapSize();
The first function tells you how much is free now. The second, how much remained free at the worst moment over the whole run time.
It is the second one you should look at. The first shows the state at the moment of the query and is easily misleading: six kilobytes are free, all is well — but half an hour ago two hundred bytes were left, and the next load spike the device will not survive.
A sensible technique: print the worst value during debugging and set a threshold
below which the device reports trouble — with an LED, to a log or
via SWO (section 21).
⚠️
xPortGetMinimumEverFreeHeapSizeis not in every scheme.heap_1,heap_2andheap_3do not have it, and a link error on this function means exactly one thing: the wrong heap file is included.
There is also a detailed report — vPortGetHeapStats fills a structure that,
besides the free amount, holds the size of the largest free block
and the number of allocations made. The largest free block is a direct measure
of fragmentation: if six kilobytes are free but the largest piece is
eight hundred bytes, the heap has crumbled.
25.5 The out-of-memory hook
One function that is written in every project:
void vApplicationMallocFailedHook(void)
{
for (;;) { } /* точка останова: сюда попадают, когда кончилась куча */
}
The kernel calls it when an allocation fails — provided that
the configuration has configUSE_MALLOC_FAILED_HOOK.
Without the hook, heap exhaustion looks like this: xTaskCreate returned a failure,
nobody checked the result, the task was not created, and part of the device simply
does not work. Sorting this out on a live device takes an evening.
With the hook the failure is visible instantly.
25.6 What to choose
The rule is simple, and it almost always gives the right answer.
If everything is created at startup and lives until power-down — static
allocation, no heap at all. configSUPPORT_DYNAMIC_ALLOCATION set to zero,
no heap file included, configTOTAL_HEAP_SIZE not needed. Memory use
is visible in the memory map right after the build, an allocation failure is impossible
in principle, and a whole class of errors disappears. For the vast majority of
devices this is the right choice.
If things are created at startup but you do not want to bother with static allocation —
heap_1. No fragmentation, constant time, 160 bytes of code.
If objects appear and disappear during operation — heap_4.
If memory is spread over regions — heap_5.
Never use heap_2; heap_3 only with existing code
built on the library malloc.
⚠️ Dynamic creation after the scheduler has started is a decision that needs its own justification. It means that the device may run out of memory at an arbitrary moment — for example, on the tenth day of operation under a rare combination of events. If such a scenario is unacceptable, all memory must be allocated before entering the working mode.
25.7 Pitfalls
A link error on pvPortMalloc. No heap file is included.
A link error on xPortGetMinimumEverFreeHeapSize. heap_1,
heap_2 or heap_3 is included, and that function is not there.
The program stops in vPortFree. heap_1 is used,
where freeing is not allowed.
configTOTAL_HEAP_SIZE has no effect. heap_3 is used,
where the heap size is set by the linker.
Tasks get created, and then stop being created. The heap ran out. Look at
xPortGetMinimumEverFreeHeapSize and check the result of xTaskCreate.
A lot is free, but allocation fails. Fragmentation: heap_2
or heap_4 with intensive creation and deletion of objects of different
sizes. Look at the largest free block via vPortGetHeapStats.
Memory runs short right after startup. The heap in the handbook's configuration is 10 KB, and the chip has only 20 in all. Reduce it or switch to static allocation.
Memory corruption when using heap_5. The regions in the array
are not listed in ascending address order.
The device crashes after a week. The classic sign of a leak: somewhere memory is taken and not returned. Watch the worst value of the free amount — it will slowly creep down.
25.8 Summary
- The kernel does not use
mallocfrom the C library: it knows nothing about tasks, lives in the linker heap, which the kernel knows nothing about, and is unpredictable in time; its alignment is the same — eight bytes. - There are five schemes, and exactly one is included:
heap_1— no freeing,heap_2— obsolete,heap_3— a wrapper overmalloc,heap_4— the default choice,heap_5— for several memory regions. - The block header is eight bytes, but the overhead in kernel 10.5.1 is from 9 to 16 bytes and is greatest on sizes that are multiples of eight; in kernel 11 it is from 8 to 15.
- A task created dynamically costs 632 bytes versus 604 statically; a 10 KB heap holds sixteen such tasks.
- The heap always takes up space: the 10 KB of the handbook's configuration is half of the memory of the F103C8, taken from the first clock cycle.
- Look at the worst value of the free amount, not at the current one.
- The largest free block is a direct measure of fragmentation.
- The
vApplicationMallocFailedHookhook turns a silent failure into an instantly visible one. - If everything is created at startup — static allocation and no heap at all: memory use is visible after the build, and failure is impossible.
Next: section 26 "Tasks" — priorities, states, the time slice and what happens when two tasks of the same priority want the CPU at the same time.