An O(1) constant-time memory allocator for real-time and embedded systems, derived from the BSD-licensed implementation by Matthew Conte and based on the TLSF specification.
Allocation and deallocation are O(1) regardless of allocation pattern or heap
state, with low fragmentation: a bound required by hard real-time systems, where
unbounded latency is unacceptable. Only the realloc paths that move payload
bytes cost more, and they cost exactly the copy.
This implementation was written from the published specification rather than derived from the GPL-licensed reference implementation that accompanies the paper, so no GPL restrictions apply.
- O(1) cost for
malloc,free,aligned_alloc, and forreallocwhen it grows forward or shrinks in place; the backward and relocatingreallocpaths add one payload copy - One word overhead per allocation
- 32 second-level subdivisions per first-level class (~3.125% max internal fragmentation for large allocations)
- Immediate coalescing on free (no deferred work)
- Two pool modes: dynamic (auto-growing via
tlsf_resize) and static (fixed-size viatlsf_pool_init) - Pool extension via
tlsf_append_pool(coalesces adjacent memory) - Realloc with in-place expansion, forward into a free successor, or backward into a free predecessor while absorbing a free successor in the same step
- Branch-free size-to-bin mapping
- Optional thread-safe wrapper (
tlsf_thread.h) with per-arena fine-grained locking; pthreads, C11 threads and Win32 backends, and configurable lock primitives for RTOS portability - Public headers compile as both C and C++
- ACSL contracts on the core helpers, checked by Frama-C WP (
make verify) - Minimal libc as shipped: only
stddef.h,stdbool.h,stdint.h,string.h. Debug builds addassert.h, andTLSF_ENABLE_CHECKaddsstdio.handstdlib.hfor the abort path
This file is the tour. The three pages under docs/ are the reference.
| Page | Covers |
|---|---|
| Design | Two-level segregated fit, the bitmaps, size-to-bin mapping, block and pool layout |
| Operations | malloc, free, realloc, aligned alloc, pool modes, complexity, the threading wrapper |
| Configuration and Verification | Compile flags, derived constants, the ABI guard, heap checks, tests, fuzzing, Frama-C, WCET |
make all # Build test, bench, wcet, fuzz, test_thread, test_cpp
make check # Run the full suite with heap debugging
make bench # Full throughput benchmark (50 iterations)
make bench-quick # Quick benchmark for development
make wcet # WCET measurement (10000 iterations)
make wcet-quick # Quick WCET check
make wcet-plot # WCET with raw samples plus plots (needs python3)
make verify # Frama-C WP proof run over the annotated helpers
make fuzz # libFuzzer campaign over the allocator (needs clang)
make clean # Remove build artifactsCompile flags used by default:
-std=gnu11 -g -O2 -Wall -Wextra -Wshadow -Wpointer-arith -Wcast-qual
-Wconversion -Wc++-compat -DTLSF_ENABLE_ASSERT -DTLSF_ENABLE_CHECK
The two -D flags come from TLSF_DEBUG_FLAGS. Clear it to build and test the
library the way it ships, which is the only way the off-branches of
TLSF_ENABLE_ASSERT and TLSF_ENABLE_CHECK get compiled:
make check TLSF_DEBUG_FLAGS=| Flag | Effect |
|---|---|
TLSF_ENABLE_ASSERT |
Enable runtime assertions in allocator internals |
TLSF_ENABLE_CHECK |
Enable tlsf_check() heap consistency validation |
TLSF_MAX_POOL_BITS |
Lower the first-level exponent ceiling, shrinking tlsf_t: 8,376 bytes by default, 2,920 at =18 |
TLSF_SPLIT_THRESHOLD |
Minimum remainder worth splitting off when trimming. Default BLOCK_SIZE_MIN, 24 on 64-bit |
TLSF_ENABLE_POISON, TLSF_NO_INTRINSICS and the thread wrapper's knobs are in
Configuration, along
with what each one costs and which of them move a structure layout.
Pass all of them through CPPFLAGS, never CFLAGS or CXXFLAGS; the Makefile
rejects the latter, because a macro that reaches only one compiler leaves the C
and C++ objects disagreeing about sizeof(tlsf_t).
make verify proves the annotated leaf helpers consistent with their own
contracts. It is not a proof of the allocator: the public entry points other
than tlsf_pool_reset are unproved, and several listed helpers carry no
postcondition, so "proved" there means "cannot fault", not "returns the right
answer". The WP_FUNCTIONS list in the Makefile states the exact scope.
#include "tlsf.h"
/* Dynamic pool (auto-growing): user must define tlsf_resize() */
tlsf_t t = TLSF_INIT;
void *p = tlsf_malloc(&t, 256);
void *z = tlsf_calloc(&t, 4, 64);
void *q = tlsf_aalloc(&t, 64, 256); /* 64-byte aligned */
p = tlsf_realloc(&t, p, 512);
tlsf_free(&t, p);
tlsf_free(&t, z);
tlsf_free(&t, q);
/* Static pool (fixed-size): no tlsf_resize() needed */
char pool[1 << 20];
tlsf_t s;
size_t usable = tlsf_pool_init(&s, pool, sizeof(pool));
void *r = tlsf_malloc(&s, 100);
tlsf_free(&s, r);| Function | Description |
|---|---|
tlsf_malloc(t, size) |
Allocate size bytes. Zero size returns a unique minimum-sized block. |
tlsf_calloc(t, nmemb, size) |
Allocate an array and zero its requested bytes. Multiplication overflow returns NULL. |
tlsf_free(t, ptr) |
Free a previously allocated block. NULL is a no-op. |
tlsf_realloc(t, ptr, size) |
Resize allocation. Tries in-place expansion before relocating. |
tlsf_aalloc(t, align, size) |
Allocate with alignment. align must be a power of two. |
tlsf_pool_init(t, mem, bytes) |
Initialize a fixed-size pool. Returns usable bytes, 0 on failure. |
tlsf_append_pool(t, mem, size) |
Extend pool with adjacent memory. Returns bytes used, 0 on failure. |
tlsf_resize(t, size) |
Platform callback for dynamic pool growth (weak symbol). |
tlsf_usable_size(ptr) |
Return the usable size of an allocated block. |
tlsf_check(t) |
Validate heap consistency (requires TLSF_ENABLE_CHECK). |
tlsf_get_stats(t, stats) |
Collect heap statistics (free/used bytes, block counts, overhead). |
tlsf_pool_reset(t) |
Reset a static pool to its initial empty state (bounded time). |
For concurrent use, include the optional per-arena wrapper:
#include "tlsf_thread.h"
static char pool[4 * 1024 * 1024];
tlsf_thread_t ts;
size_t usable = tlsf_thread_init(&ts, pool, sizeof(pool));
void *p = tlsf_thread_malloc(&ts, 256);
void *q = tlsf_thread_aalloc(&ts, 64, 256);
p = tlsf_thread_realloc(&ts, p, 512);
tlsf_thread_free(&ts, p);
tlsf_thread_free(&ts, q);
tlsf_thread_destroy(&ts);| Function | Description |
|---|---|
tlsf_thread_init(ts, mem, bytes) |
Split memory into per-arena sub-pools. Returns total usable bytes. |
tlsf_thread_destroy(ts) |
Release lock resources. Does not free the memory region. |
tlsf_thread_malloc(ts, size) |
Thread-safe malloc with per-arena locking. |
tlsf_thread_aalloc(ts, align, size) |
Thread-safe aligned allocation. |
tlsf_thread_realloc(ts, ptr, size) |
Thread-safe realloc. In-place first, cross-arena fallback. |
tlsf_thread_free(ts, ptr) |
Thread-safe free. Finds owning arena automatically. |
tlsf_thread_check(ts) |
Heap consistency check across all arenas. |
tlsf_thread_stats(ts, stats) |
Aggregate statistics across all arenas. |
tlsf_thread_reset(ts) |
Reset all arenas to initial state (bounded time). |
TLSF_ARENA_COUNT is the arena count, four by default, and a ceiling rather
than a promise: tlsf_thread_init() halves it while the per-arena share would
fall below 256 bytes. The lock backend is chosen by the header: C11 mtx_t
when TLSF_C11_THREADS asks for it and <threads.h> is available, Win32
SRWLOCK or CRITICAL_SECTION on Windows otherwise, pthread_mutex_t
everywhere else; see Lock backends for the
migration note. For a platform-specific lock (FreeRTOS semaphore, Zephyr
k_mutex, bare-metal spinlock), define TLSF_LOCK_T and the five lock macros
before including tlsf_thread.h; that bypasses the selection, so matching the
backend across translation units becomes the caller's obligation.
The full knob list is in Thread wrapper flags, and the design is in Concurrency.
include/tlsf_pmr.hpp is an optional, header-only C++17 wrapper for consumers
that accept a std::pmr::memory_resource, such as ROS 2 nodes. Including it
requires C++17; the library and its public headers stay C11 and C++11.
#include <vector>
#include "tlsf_pmr.hpp"
alignas(64) static unsigned char memory[64 * 1024];
tlsf_t pool = TLSF_INIT;
if (!tlsf_pool_init(&pool, memory, sizeof(memory)))
return -1;
tlsf::pmr_resource res(pool);
std::pmr::vector<int> v(&res);
v.push_back(1);tlsf::pmr_thread_resource, in the separate include/tlsf_thread_pmr.hpp,
is the same wrapper over tlsf_thread_t, so a concurrent PMR user gets the
per-arena locks instead of one global mutex. It is a separate header because
tlsf_thread.h pulls in the platform's threading header and needs a lock
backend, which a single-threaded consumer of pmr_resource should not have to
supply:
#include "tlsf_thread_pmr.hpp"
alignas(TLSF_CACHELINE_SIZE) static unsigned char thread_memory[256 * 1024];
tlsf_thread_t ts;
if (!tlsf_thread_init(&ts, thread_memory, sizeof(thread_memory)))
return -1;
tlsf::pmr_thread_resource res(ts);Both are non-owning views: the pool and the resource must outlive every
allocation, since a std::pmr container calls back into the resource when it
is destroyed. Every allocation routes through tlsf_aalloc(), so a container
of over-aligned elements gets the alignment it asked for. Exhaustion throws
std::bad_alloc; with exceptions disabled it calls std::terminate() instead,
since memory_resource may not return a null allocation. That kills the
process, and std::set_terminate() is the only hook, so a caller for which
exhaustion is a recoverable condition should use tlsf_aalloc() directly
rather than reach the allocator through PMR. Neither adds locking of its own.
pmr_resource is unsynchronized exactly like the C allocator, and
pmr_thread_resource inherits the contracts in tlsf_thread.h.
Be precise about what the O(1) claim covers, because reaching TLSF through PMR does not widen it. Bounded is the allocator's own work on one call: finding a fit is two bitmap scans, and splitting and coalescing are constant-time. Four costs sit outside that bound, and PMR changes none of them.
| Cost | Why it is still there |
|---|---|
| Container growth | A std::pmr::vector that outgrows its capacity allocates once and then moves every element it already held, so push_back() is O(n) in the worst case however fast the allocation was. |
| External fragmentation | The size classes bound internal fragmentation to about 3.125%. Whether a request finds a large enough free block still depends on the order the caller allocated and freed; when none exists, do_allocate() throws std::bad_alloc, or terminates where exceptions are disabled. |
| Virtual dispatch | Every allocation through a memory_resource is an indirect call that a direct tlsf_malloc() is not. |
| Lock waits | For pmr_thread_resource only. Per-arena locking makes contention less likely; it does not bound how long a thread waits once contended. |
A latency budget has to account for all four on top of the allocator's own bound.
TLSF keeps free blocks pre-sorted into size classes, and keeps one bit per class to say whether that class is occupied. Finding a fit is then two bitmap scans and an array dereference, not a search: the bin lookup is the allocation. Design has the full account.
- First level (FL): a power-of-two class. Class
i > 0covers[2^(i+7), 2^(i+8))on 64-bit, so 32 classes span every allocatable size. - Second level (SL): each class subdivided into 32 equal linear bins, which
bounds the round-up waste at
1/32 = 3.125%. - Below 256 bytes that subdivision would be finer than the 8-byte alignment, so class 0 is binned linearly instead: one bin per aligned size, and no internal fragmentation at all.
A set bit in t->fl means the class holds a free block; a set bit in t->sl[i]
means that bin does. One ffs on the second-level bitmap finds the first bin at
or above the request. If the class is empty, one more ffs on the first-level
bitmap finds the next larger class. Each ffs is one or two hardware
instructions, tzcnt or bsf on x86 and rbit plus clz on ARM, so the
lookup is worst-case constant time, not amortized and not expected-case.
flowchart LR
REQ["request,<br/>rounded up to a<br/>bin boundary"] --> MAP["mapping():<br/>fl, sl"]
MAP --> SLB["t->sl[fl]<br/>second-level bitmap"]
MAP -.->|"class empty"| FLB["t->fl<br/>first-level bitmap"]
FLB -->|"ffs: next larger class"| SLB
SLB -->|"ffs: first bin at or above sl"| BIN["t->block[fl][sl]<br/>bin head"]
BIN --> B1["free block"]
B1 <--> B2["free block"]
B2 <--> NUL["t->block_null<br/>list sentinel"]
The full structure draws the same thing with a worked set of bits, bin ranges and block sizes.
One word of overhead per allocation, and no footer. A block's other fields
overlap its neighbours' payloads: the free-list links live in the payload while
the block is free, and the successor's prev pointer is the last word of this
block's payload, which is the boundary tag.
Block sizes are multiples of ALIGN_SIZE, so the low bits of the size word are
always zero and hold two flags instead: F for "this block is free" and P for
"the previous block is free". P plus the prev boundary tag are what make
backward coalescing O(1).
Each pool ends in a zero-size sentinel block. It terminates the chain, is never free, and never enters a bin. Blocks in memory draws the layout byte by byte and explains why the minimum block is 24 bytes.
| Operation | Summary |
|---|---|
| Allocate | Round the request up to a bin boundary, take the head of the first non-empty bin at or above it, split off the tail if the remainder is worth keeping. Sizes under 256 bytes skip the mapping arithmetic when class 0 already holds a block at or above the request. |
| Free | Set the free bit, merge with a free predecessor and a free successor, insert the result. Coalescing is immediate: no deferred pass, no compaction, no latency spikes from batch reclamation. |
| Realloc | Grow forward into a free successor with no copy, else backward into a free predecessor with a memmove that also absorbs a free successor, else allocate, copy and free. Shrinking trims in place. |
| Aligned alloc | Over-allocate, split the front gap back onto a free list, trim the tail. |
Worst cases stay O(1): the most expensive allocation is a full bitmap fallback plus a split, and the most expensive free is a block sandwiched between two free neighbours. Operations walks each path.
Dynamic pools grow on demand through a user-supplied tlsf_resize() callback
and shrink when the tail block becomes free. Fixed pools (tlsf_pool_init) use
a caller-owned region and never call tlsf_resize(). Either can be extended
with tlsf_append_pool() when adjacent memory is available.
tlsf_resize() is a weak symbol defaulting to NULL, so fixed-pool users need
not define it and dynamic-pool users must. Its full contract, and what happens
when it is missing, is in Pool modes. Multiple
independent allocators are just multiple tlsf_t instances with their own
memory.
The core allocator is single-threaded by design: no lock, no atomic, no barrier.
The optional wrapper in tlsf_thread.h splits the pool into TLSF_ARENA_COUNT
independent sub-pools, each with its own lock and its own cache line, and maps
threads to arenas by a hash of the thread identifier. Allocation tries the
preferred arena, then the others by non-blocking trylock, then blocking.
Free finds the owning arena by pointer range and locks only that one.
Concurrency covers the arena-count trade-offs, the fallback path when an arena is full, and how to port the lock backend to an RTOS.
| Constant | 64-bit | 32-bit | Notes |
|---|---|---|---|
TLSF_MAX_SIZE |
256 GiB - 8 | 1 GiB - 4 | Largest single allocation; reduced by TLSF_MAX_POOL_BITS |
TLSF_MAX_POOL_BYTES |
256 GiB + 16 | 1 GiB + 8 | Largest region tlsf_pool_init() accepts |
| FL classes | 32 | 25 | _TLSF_FL_MAX - _TLSF_FL_SHIFT + 1 |
| Alignment | 8 bytes | 4 bytes | |
| Min block | 24 bytes | 12 bytes | sizeof(tlsf_block) - sizeof(ptr) |
| Block overhead | 8 bytes | 4 bytes | One word, no footer |
| SL subdivisions | 32 | 32 |
tlsf_t is caller-allocated and holds the whole FL x SL bin array, so its
size is set entirely by the configuration: 8,376 bytes by default on 64-bit,
down to 2,920 with -DTLSF_MAX_POOL_BITS=18. A tlsf_thread_t is roughly
TLSF_ARENA_COUNT times that.
The layout knobs change that structure, and callers allocate it, so every translation unit in a build must see the same set. A mismatch is a link error rather than silent corruption: those knobs are folded into the public symbol names, so disagreeing objects fail to resolve. Flags that do not move a layout are not encoded and not diagnosed. See Configuration for the size tables, the guard, and what it asks of callers.
The tests/wcet.c tool measures per-operation latency under pathological
scenarios, to bound the constant hiding behind the O(1):
make wcet # 10000 iterations across four scenarios
make wcet-plot # the same, plus raw samples and plots under build/Four scenarios run: allocation from a single huge free block, an exact bin hit, a free sandwiched between two free neighbours, and a free with no merge. The tool reports the whole distribution rather than one number, because on a general-purpose OS the tail is scheduler preemption and not the allocator.
WCET measurement lists the timing sources, the raw-sample options, and a sample run.
M. Masmano, I. Ripoll, A. Crespo, and J. Real. TLSF: a new dynamic memory allocator for real-time systems. In Proc. ECRTS (2004), IEEE Computer Society, pp. 79-86.
TLSF-BSD is freely redistributable under the 3-clause BSD License. Use of this source code is governed by a BSD-style license that can be found in the LICENSE file.