Writing a Custom Global Allocator in Rust to Track Memory
A global allocator is a type implementing GlobalAlloc, registered with one attribute. Wrapping the system allocator with atomic counters gives you live memory numbers.

Every Vec::push, String::from and Box::new in a Rust program ends in a call to the same place: the global allocator. Normally you never see it, because the standard library supplies one that forwards to the operating system's malloc. But Rust lets you replace it with a single attribute, and that one hook turns out to be one of the most useful debugging and performance tools in the language. With a few dozen lines you can count every allocation your program makes, assert that a hot path makes none, cap memory in a test, or swap in a completely different allocation strategy.
This guide builds three allocators, each small enough to read in one sitting: one that tracks allocations, one that allocates from a fixed arena and never frees, and a per-thread counter you can use in tests. Everything ran on Rust 1.99 with edition 2024 and only the standard library, in release mode on an Apple silicon Mac. The failures, including a crash with no message, are real and are explained, because writing an allocator is the one place in Rust where a perfectly ordinary line of code, a println!, can bring the whole process down.
What the hook is
The GlobalAlloc trait has two required methods and two optional ones:
pub unsafe trait GlobalAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8;
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);
// Provided, with default implementations built on the two above:
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { /* ... */ }
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { /* ... */ }
}A Layout carries a size and an alignment. Returning a null pointer from alloc means "out of memory". The trait is unsafe because the rest of the program relies on you honoring its contract: the pointer you return must point to at least layout.size() bytes, aligned to layout.align(), not overlapping any live allocation, and valid until dealloc is called with the same layout. Violate it and the symptoms appear anywhere, much later, in code that has nothing to do with your allocator.
You install one with the #[global_allocator] attribute on a static. There can be only one per program, and it applies to everything, including the standard library and every dependency.
An allocator that counts
The simplest useful allocator wraps the system one and keeps counters. It forwards every call to System and updates four atomics on the way. The measure function runs a closure and prints what it allocated.
use std::alloc::{GlobalAlloc, Layout, System};
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
/// Wraps the system allocator and keeps four counters. It never allocates itself.
struct Tracking {
live: AtomicUsize,
peak: AtomicUsize,
allocs: AtomicUsize,
frees: AtomicUsize,
reallocs: AtomicUsize,
}
impl Tracking {
const fn new() -> Self {
Self { live: AtomicUsize::new(0), peak: AtomicUsize::new(0), allocs: AtomicUsize::new(0), frees: AtomicUsize::new(0), reallocs: AtomicUsize::new(0) }
}
fn snapshot(&self) -> Snapshot {
Snapshot { live: self.live.load(Relaxed), peak: self.peak.load(Relaxed), allocs: self.allocs.load(Relaxed), frees: self.frees.load(Relaxed), reallocs: self.reallocs.load(Relaxed) }
}
}
#[derive(Debug, Clone, Copy)]
struct Snapshot {
live: usize,
peak: usize,
allocs: usize,
frees: usize,
reallocs: usize,
}
unsafe impl GlobalAlloc for Tracking {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let ptr = unsafe { System.alloc(layout) };
if !ptr.is_null() {
self.allocs.fetch_add(1, Relaxed);
let now = self.live.fetch_add(layout.size(), Relaxed) + layout.size();
self.peak.fetch_max(now, Relaxed);
}
ptr
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) };
self.frees.fetch_add(1, Relaxed);
self.live.fetch_sub(layout.size(), Relaxed);
}
// Forward realloc so the system allocator can grow in place; the default
// implementation would allocate, copy and free, which counts as three events.
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
let new_ptr = unsafe { System.realloc(ptr, layout, new_size) };
if !new_ptr.is_null() {
self.reallocs.fetch_add(1, Relaxed);
if new_size >= layout.size() {
let now = self.live.fetch_add(new_size - layout.size(), Relaxed) + (new_size - layout.size());
self.peak.fetch_max(now, Relaxed);
} else {
self.live.fetch_sub(layout.size() - new_size, Relaxed);
}
}
new_ptr
}
}
#[global_allocator]
static ALLOC: Tracking = Tracking::new();
/// Run `f` and report what it allocated.
fn measure<R>(label: &str, f: impl FnOnce() -> R) -> R {
let before = ALLOC.snapshot();
ALLOC.peak.store(before.live, Relaxed); // peak is measured from this call's start
let out = f();
let after = ALLOC.snapshot();
println!(
"{label:<26} allocs {:>5} reallocs {:>3} frees {:>5} net {:>+7} B peak +{:>6} B",
after.allocs - before.allocs,
after.reallocs - before.reallocs,
after.frees - before.frees,
after.live as isize - before.live as isize,
after.peak.saturating_sub(before.live),
);
out
}
fn main() {
measure("Vec::new + 1000 pushes", || {
let mut v = Vec::new();
for i in 0..1000u32 {
v.push(i);
}
v.len()
});
measure("Vec::with_capacity(1000)", || {
let mut v = Vec::with_capacity(1000);
for i in 0..1000u32 {
v.push(i);
}
v.len()
});
measure("1000 format! strings", || (0..1000).map(|i| format!("item-{i}")).count());
let kept = measure("HashMap of 100 Strings", || {
let mut m = HashMap::new();
for i in 0..100 {
m.insert(i, "x".repeat(32));
}
m
});
println!("kept {} entries alive, live bytes now {}", kept.len(), ALLOC.snapshot().live);
// A leak shows up as live bytes that never come back.
measure("leaking 10 boxes", || {
for _ in 0..10 {
std::mem::forget(Box::new([0u8; 1024]));
}
});
}Running the program produced this:
Vec::new + 1000 pushes allocs 1 reallocs 8 frees 1 net +0 B peak + 4096 B
Vec::with_capacity(1000) allocs 1 reallocs 0 frees 1 net +0 B peak + 4000 B
1000 format! strings allocs 1000 reallocs 0 frees 1000 net +0 B peak + 10 B
HashMap of 100 Strings allocs 106 reallocs 0 frees 5 net +7432 B peak + 8176 B
kept 100 entries alive, live bytes now 9132
leaking 10 boxes allocs 10 reallocs 0 frees 0 net +10240 B peak + 10240 BThis table teaches more about Rust's memory behavior than a chapter of explanation, so read it row by row.
The first two rows run the same loop. Pushing 1,000 u32 values into a Vec::new() made one allocation and then eight reallocations, as the capacity doubled from 4 to 8, 16, 32, 64, 128, 256, 512 and 1,024. Vec::with_capacity(1000) made one allocation and zero reallocations, and peaked at 4,000 bytes against 4,096. That is the whole argument for with_capacity when you know the size: the same work with a ninth of the allocator traffic.
The third row creates a thousand short-lived strings. There were 1,000 allocations and 1,000 frees, and the peak was a mere 10 bytes, because each string was dropped before the next was built. Allocation count and memory use are different costs. A program that makes a million small allocations is slow even if it never holds more than a kilobyte.
The fourth row builds a HashMap and keeps it. 106 allocations, 5 frees, and a net 7,432 bytes still live: 100 strings of 32 bytes plus the table itself, which also reallocated as it grew (the 5 frees are the old tables). The line below it confirms the map is still alive and counts total live bytes at 9,132.
The last row is a deliberate leak. std::mem::forget skips the destructor, so ten 1 KiB boxes were allocated and never freed, and net reads +10,240 bytes. A leak is any row where net is positive and nothing holds the data. The tracking allocator will not tell you who owns what, but it will tell you, for any block of code, whether memory came back. Wrap a request handler in measure in a test and a leak announces itself.
Why realloc is forwarded
The trait provides a default realloc that allocates a new block, copies, and frees the old one. That is correct and wasteful: the system allocator can often grow a block in place without copying. If your wrapper does not forward realloc to System.realloc, you change the program's performance by observing it. The listing forwards it and keeps its own reallocs counter, because without that the Vec row would show a single allocation and hide eight events.
Measuring a peak that means something
The peak counter is global, so a naive reading includes the peak of everything that ran before. The fix is the line ALLOC.peak.store(before.live, Relaxed) at the start of measure, which resets the high-water mark to the current level. Without it, the third row would have reported the 4,096-byte peak from the first row instead of 10 bytes. This is a common bug in home-made profilers: a counter that never resets reports history, not the thing you measured.
The counters use Ordering::Relaxed, which is correct here because they are independent statistics and nothing else synchronizes through them. Under threads, the numbers are exact in total but the snapshot you read is not a consistent cut across all four; two counters read a few nanoseconds apart may disagree by an in-flight allocation. For profiling that is fine. Do not use these values to make correctness decisions.
The mistake that crashes with no message
Here is the allocator everyone writes first, to see allocations happen:
unsafe impl GlobalAlloc for Noisy {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
eprintln!("alloc {} bytes", layout.size());
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
}It compiles without a warning. Running a program that uses it printed nothing at all, and the process exited with status 139, a segmentation fault. The cause is recursion. Formatting a message and writing it to a stream needs memory, so eprintln! allocates, which calls alloc, which calls eprintln!, which allocates, until the stack overflows. The stack overflow happens inside the allocator, before any output reaches the screen, so there is no message to read.
The rule is that code inside alloc and dealloc must never allocate and must never take a lock the allocating code might already hold. Writing a number to a file descriptor with the write system call directly is fine. Anything that formats, logs, builds a String or touches a thread_local with lazy initialization is not. Keep the allocator to atomics and plain arithmetic, record events, and do the printing later from outside, as measure does.
An allocator that never frees: the bump arena
At the other end of the design space is the allocator that does almost nothing. A bump allocator owns a block of memory and a cursor. Allocating rounds the cursor up to the requested alignment, moves it forward by the size and returns the old position. Freeing does nothing at all.
use std::alloc::{GlobalAlloc, Layout};
use std::cell::UnsafeCell;
use std::sync::atomic::{AtomicUsize, Ordering};
const ARENA_SIZE: usize = 1 << 20; // 1 MiB
/// A bump allocator: allocation moves a pointer forward, freeing does nothing.
/// Fast and tiny, and the right tool for a short-lived process or a request arena.
struct Bump {
arena: UnsafeCell<[u8; ARENA_SIZE]>,
next: AtomicUsize,
}
// SAFETY: the only mutation of `arena` is through pointers handed out once each by `alloc`;
// `next` is advanced atomically, so two threads never receive overlapping ranges.
unsafe impl Sync for Bump {}
unsafe impl GlobalAlloc for Bump {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let base = self.arena.get() as usize;
let mut current = self.next.load(Ordering::Relaxed);
loop {
// Round the next free address up to the alignment the caller asked for.
let start = (base + current).next_multiple_of(layout.align()) - base;
let end = match start.checked_add(layout.size()) {
Some(end) if end <= ARENA_SIZE => end,
_ => return std::ptr::null_mut(), // out of arena
};
match self.next.compare_exchange_weak(current, end, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => return (base + start) as *mut u8,
Err(actual) => current = actual,
}
}
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {}
}
#[global_allocator]
static ALLOC: Bump = Bump { arena: UnsafeCell::new([0; ARENA_SIZE]), next: AtomicUsize::new(0) };
fn used() -> usize {
ALLOC.next.load(Ordering::Relaxed)
}
fn main() {
let start = used();
let mut names: Vec<String> = Vec::new();
for i in 0..1000 {
names.push(format!("user-{i:04}"));
}
println!("1000 strings: arena used {} bytes", used() - start);
let before = used();
drop(names);
println!("after drop: arena used {} bytes (nothing came back)", used() - before);
let before = used();
for _ in 0..3 {
let scratch: Vec<u8> = vec![0; 100_000];
std::hint::black_box(&scratch);
}
println!("3 x 100 kB scratch buffers: arena used {} bytes", used() - before);
println!("arena size {ARENA_SIZE}, used {} so far", used());
let big: Vec<u8> = vec![0; 800_000];
println!("an 800 kB allocation succeeded: {}", big.len());
}1000 strings: arena used 59062 bytes
after drop: arena used 0 bytes (nothing came back)
3 x 100 kB scratch buffers: arena used 300000 bytes
arena size 1048576, used 360768 so far
memory allocation of 800000 bytes failed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceThe program exited with status 134, an abort. Read the output as the allocator's contract. A thousand strings used 59,062 bytes. Dropping them gave back nothing, as the second line says. Three 100,000-byte scratch buffers used 300,000 bytes, because each one was allocated fresh and none was reclaimed. After that the cursor stood at 360,768 of the 1,048,576 bytes, and an 800,000-byte request did not fit, so alloc returned null and the runtime aborted with "memory allocation of 800000 bytes failed".
That behavior is a feature when it is intended. A bump allocator is extremely fast, a few instructions per allocation with no bookkeeping, has zero fragmentation, and is the right tool when everything allocated lives until a known point and then dies together: a compiler pass, a command that runs for a second and exits, one request handled in an arena that is reset afterward. It is the wrong tool for a long-lived server, where a leak-by-design fills memory. For request-scoped arenas inside a normal program, use a local arena type such as bumpalo rather than replacing the global allocator.
Three details in the code are worth studying. The alignment line, (base + current).next_multiple_of(layout.align()) - base, rounds the actual address, not the offset, because the static array itself is only aligned as strictly as its type requires. The checked_add and the comparison guard against both overflow and overrun, returning null instead of handing out memory past the end. And the compare_exchange_weak loop makes the cursor advance atomic, so two threads never receive overlapping ranges. The unsafe impl Sync carries a comment stating why it is sound, which is the convention to follow for every unsafe block you write.
A counter that is safe to use in tests
The global counters are awkward in tests, because Rust runs tests on several threads at once, and one test's allocations leak into another's counts. The cure is to count per thread. A thread_local! with a const initializer does not allocate on first use, so it is safe inside the allocator, and try_with avoids a panic during thread teardown.
use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;
thread_local! {
// `const` initialization: touching this never allocates, which matters inside an allocator.
static ALLOCS_ON_THIS_THREAD: Cell<u64> = const { Cell::new(0) };
}
struct PerThread;
unsafe impl GlobalAlloc for PerThread {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let _ = ALLOCS_ON_THIS_THREAD.try_with(|c| c.set(c.get() + 1));
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
}
#[global_allocator]
static A: PerThread = PerThread;
/// Run `f` and return how many allocations *this thread* made.
fn count_allocs<R>(f: impl FnOnce() -> R) -> (R, u64) {
let before = ALLOCS_ON_THIS_THREAD.with(Cell::get);
let out = f();
(out, ALLOCS_ON_THIS_THREAD.with(Cell::get) - before)
}
fn checksum(bytes: &[u8]) -> u32 {
bytes.iter().fold(0u32, |acc, &b| acc.rotate_left(5) ^ u32::from(b))
}
fn checksum_hex(bytes: &[u8]) -> String {
format!("{:08x}", checksum(bytes))
}
fn main() {
let data = vec![7u8; 4096];
let (_, n) = count_allocs(|| checksum(&data));
println!("checksum: {n} allocations");
assert_eq!(n, 0, "the hot path must not allocate");
let (_, n) = count_allocs(|| checksum_hex(&data));
println!("checksum_hex: {n} allocation(s)");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checksum_does_not_allocate() {
let data = [1u8; 64];
let (_, n) = count_allocs(|| checksum(&data));
assert_eq!(n, 0);
}
}checksum: 0 allocations
checksum_hex: 1 allocation(s)The pair of lines is the point. checksum is a pure computation over a slice and must never allocate, and the program asserts it. checksum_hex wraps the same call in a format!, which costs one allocation. The unit test at the bottom of the file is the pattern to copy: assert that a function on a latency-critical path makes zero allocations, and the build fails the day someone adds a to_string() or a collect::<Vec<_>>() to it. Allocation regressions are among the most common performance bugs and among the least visible in review, because the diff looks innocent.
One caveat applies to this design: the counter sees only allocations made by the thread running the closure. If the function spawns a thread or hands work to a pool, those allocations are counted elsewhere. For code that stays on one thread, which includes most hot-path functions, it is exact.
What the tracking costs
An obvious worry is that wrapping every allocation in atomic operations slows the program. The cost is easy to measure. The program below makes two million Box allocations and frees, with the counting turned off and on in alternation, and times each pass.
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
use std::time::Instant;
struct Counting;
static COUNT: AtomicUsize = AtomicUsize::new(0);
static ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
if ENABLED.load(Relaxed) {
COUNT.fetch_add(1, Relaxed);
}
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
}
#[global_allocator]
static A: Counting = Counting;
fn churn() -> usize {
let mut total = 0;
for i in 0..2_000_000u32 {
let b = Box::new(i);
total += *std::hint::black_box(b) as usize & 1;
}
total
}
fn main() {
churn(); // warm up
for enabled in [false, true, false, true] {
ENABLED.store(enabled, Relaxed);
let start = Instant::now();
churn();
println!("counter {:<8} {:>8.2?} per 2M alloc+free pairs", if enabled { "enabled" } else { "disabled" }, start.elapsed());
}
}counter disabled 51.99ms per 2M alloc+free pairs
counter enabled 52.68ms per 2M alloc+free pairs
counter disabled 51.75ms per 2M alloc+free pairs
counter enabled 51.78ms per 2M alloc+free pairsThe difference is within noise: about 26 nanoseconds per pair either way, which is the cost of the system allocator itself. An uncontended atomic add is a handful of cycles next to a malloc. That holds for a single thread. With many threads hammering one counter, the cache line holding it bounces between cores and the cost rises sharply; a service that allocates millions of times a second across all cores should shard the counters per thread or per CPU and add them up when read. The per-thread version earlier in this guide has exactly that property.
An allocator that says no
A custom allocator can enforce a rule rather than just observe. The next one refuses any allocation that would push live bytes past a limit, by returning a null pointer, which is the documented way to signal failure. Normal code then aborts with "memory allocation failed", but code that uses the fallible APIs, try_reserve on collections, gets an Err it can handle. That is the right way to treat a length that comes from the network:
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
/// Refuses any allocation that would push live bytes past a limit.
struct Capped {
live: AtomicUsize,
limit: AtomicUsize,
}
unsafe impl GlobalAlloc for Capped {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let after = self.live.fetch_add(layout.size(), Relaxed) + layout.size();
if after > self.limit.load(Relaxed) {
self.live.fetch_sub(layout.size(), Relaxed);
return std::ptr::null_mut();
}
let ptr = unsafe { System.alloc(layout) };
if ptr.is_null() {
self.live.fetch_sub(layout.size(), Relaxed);
}
ptr
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) };
self.live.fetch_sub(layout.size(), Relaxed);
}
}
#[global_allocator]
static A: Capped = Capped { live: AtomicUsize::new(0), limit: AtomicUsize::new(usize::MAX) };
/// Parse a length-prefixed message, refusing to trust the length.
fn read_message(declared_len: usize) -> Result<Vec<u8>, String> {
let mut buf = Vec::new();
buf.try_reserve_exact(declared_len)
.map_err(|_| format!("refusing to allocate {declared_len} bytes for one message"))?;
buf.resize(declared_len.min(16), 0xAB);
Ok(buf)
}
fn main() {
// Allow 1 MiB above whatever the runtime already holds.
A.limit.store(A.live.load(Relaxed) + (1 << 20), Relaxed);
println!("small message: {:?}", read_message(64).map(|m| m.len()));
println!("hostile length: {:?}", read_message(usize::MAX / 2).map(|m| m.len()));
println!("just over the cap: {:?}", read_message(2 << 20).map(|m| m.len()));
}small message: Ok(16)
hostile length: Err("refusing to allocate 9223372036854775807 bytes for one message")
just over the cap: Err("refusing to allocate 2097152 bytes for one message")The first message is fine. The second declares a length near the maximum a 64-bit address space allows, the kind of value a malicious or corrupted frame carries. try_reserve_exact reports failure instead of aborting the process, so the parser can reject the frame and move on. The third is a plausible length that exceeds the 1 MiB budget the program set for itself. Without try_reserve, Vec::with_capacity(declared_len) on the hostile value would abort the whole server. This is a security property worth testing: a test that installs a capped allocator and feeds the parser oversized lengths proves that no input can make it allocate more than the cap before validating.
One honest limitation: only code that calls the fallible APIs benefits. Ordinary Vec::push and String growth still abort on failure, because Rust's infallible collection APIs treat out-of-memory as fatal. A cap in the allocator therefore works best as a test tool and as a guard around parsing code written with try_reserve, not as a general mechanism to survive running out of memory.
Alignment and zeroed memory
Two details of the contract trip people who write their own. The alignment in a Layout is a requirement, not a hint, and Layout guarantees it is a power of two. Types such as SIMD vectors or page-sized buffers can need 16, 64 or 4096 bytes, which is why the bump allocator rounds the address and not just the offset. And alloc_zeroed, which backs vec![0; n], has a default implementation that calls alloc and writes zeros. The system allocator can often get zeroed pages straight from the operating system for large requests, with no write at all, so a wrapper that does not forward alloc_zeroed to System.alloc_zeroed turns a lazy, nearly free vec![0u8; 1 << 30] into a gigabyte of real writes. Forward all four methods unless you have a reason not to.
The trait you cannot use yet
GlobalAlloc is the stable interface, and it has limits: one allocator for the whole program, no way to pass an allocator to a single Vec, and no way to ask for the size actually allocated. The Allocator trait and the Vec::new_in family fix that and are still unstable, so using them requires a nightly toolchain. If you want a per-collection arena on stable today, use a crate that provides its own arena types, such as bumpalo, which hands out references tied to the arena's lifetime.
Replacing the allocator for performance
Most programs never need to write an allocator, but many benefit from swapping one in. The system allocator on some platforms is slow under multithreaded contention or fragments badly in long-running services. Third-party allocators such as mimalloc and tikv-jemallocator install in three lines:
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;Whether it helps is an empirical question with a measurable answer. Build once with each, run the same load test at the same request rate, and compare tail latency and resident memory. Allocator changes often move the 99th percentile more than the median, and they trade memory for speed in different ways, so the numbers you read must come from your workload. The tracking wrapper from this guide composes with any of them: wrap MiMalloc instead of System and keep the counters.
When you should not write one
Do not replace the global allocator to fix a problem you have not measured. For a leak, a heap profiler such as dhat or the platform's tooling points at the allocating line directly, with no custom unsafe code. For a per-request arena, use bumpalo. For a memory cap in a test, the tracking allocator above is enough, and it takes minutes. On embedded targets with no operating system, the situation inverts: there is no System, and you supply the allocator, usually a small crate such as embedded-alloc or linked-list-allocator, initialized with a region of RAM at startup. Whatever the case, keep to the rules that make allocators safe: no allocation, no locks that can deadlock, no panics, and a comment justifying every unsafe. For the language-level story of what you are bypassing, see Box, Rc and Arc in Rust, and for memory problems in services, memory usage and finding leaks.


