3.5 KiB
name: heap-discipline description: Memory allocation discipline for the ESP32-C3 (~380KB RAM, no PSRAM, single 48KB framebuffer). Use whenever writing or reviewing code that allocates: new / malloc / std::vector / std::string, buffers, caches, or anything held across a loop or an activity lifecycle. Covers makeUniqueNoThrow vs raw new/malloc, fragmentation avoidance, reserve-before-push_back, alloc-once-reuse, stack vs heap sizing, and the chunked grayscale buffer pattern.
Heap Discipline (ESP32-C3)
CLAUDE.md states the allocation rules. This is the procedure you run while writing the code and the gate you run before handing it back.
The constraint that makes every call matter: ~380KB RAM, no PSRAM, one 48KB framebuffer. Fragmentation, not total usage, is what kills this device. Free-heap can read fine while the largest free block is too small for the next allocation. Optimize for not leaving holes, not just for using fewer bytes.
Allocation decision procedure
Ask in order; stop at the first yes.
- Stack? Local, bounded, under ~256 bytes total: plain array/struct. No heap, no fragmentation. Keep frames lean; the task stack is small.
- Compile-time constant?
static constexprlives in flash, costs zero DRAM. - Allocated once and reused for an activity's lifetime? Allocate in
onEnter, hold in a member, release inonExit. Never per-frame, never per-iteration. - Dynamic and fallible?
makeUniqueNoThrow<T>(...)/makeUniqueNoThrow<T[]>(n)fromlib/Memory/Memory.h. Null-check,LOG_ERRwith the size, return false. It frees on every exit path. - A C/SDK API takes ownership and frees it itself? Only then raw
new (std::nothrow)/malloc, with a comment naming who frees it.
Bare new / new[] is never correct here: under -fno-exceptions it calls
abort() on OOM instead of returning null.
Fragmentation rules
std::vector:reserve(n)before anypush_backloop. Each growth is alloc-copy-free (three heap ops) and leaves a hole. Unknown n: estimate high.- No repeated
new/deleteor growing containers inside a loop or render path. Hoist the allocation out of the loop. - Large contiguous blocks fragment worst. Full-screen-class buffers use the
chunked
storeBwBuffer/restoreBwBufferpath inGfxRendererso they never demand one contiguous 48KB block. Reuse that path. Do not malloc a second full-screen buffer. std::string/ ArduinoString: acceptable on cold paths (file I/O, one-shot setup). Banned on hot/render paths. Build text with a stackchar[]+snprintf; if astd::stringis unavoidable,reserveit first.
Justify every allocation
Per CLAUDE.md's evidence rule: when you add a heap allocation, state in one line why stack/static/reuse was rejected and the worst-case size. If you cannot name the size, you cannot budget it, and you should not allocate it.
Self-review before handoff
- No bare
new/new[]. Every fallible alloc ismakeUniqueNoThrow, or a raw alloc with an explicit owner comment. - Every allocation is null-checked with
LOG_ERRbefore the error return. - No allocation inside a loop or render path that could be hoisted.
- Every
push_backloop has a precedingreserve. - Anything allocated in
onEnteris released inonExit; memberHalFileclosed there too. - No second full-screen buffer; grayscale uses store/restoreBwBuffer.
- Each new allocation carries a one-line size + why-not-stack/static note.