ESP32 Heap Discipline
Memory rules for esp32/ firmware. Adapted from crosspoint-reader's
heap-discipline skill to AgentDeck's multi-board reality. This is the
procedure you run while writing firmware and the gate before handing it back.
The board split — know which world you're in
AgentDeck firmware targets two memory regimes. Check the board macros first.
- PSRAM boards —
BOARD_RGB48, BOARD_IPS35, BOARD_AMOLED, BOARD_IPS10
(ESP32-S3 / -P4, 8–32MB PSRAM). Large canvases/caches go in PSRAM
(ps_malloc / MALLOC_CAP_SPIRAM). But per-pixel / LVGL draw buffers and
PPA rotation buffers must stay in internal SRAM (MALLOC_CAP_INTERNAL):
PSRAM writes are ~30× slower, so a PSRAM draw buffer makes every widget render
crawl (see the IPS10 rationale in esp32/src/ui/display.cpp). Plenty of total
RAM here; the constraint is write latency and internal-SRAM headroom, not
bytes.
- No-PSRAM boards —
BOARD_TTGO (classic ESP32, ~160KB heap),
BOARD_ESP32_C6_147 (single-core RISC-V), BOARD_LED8X32 (TC001). This is
crosspoint's world: every allocation matters and fragmentation, not total
usage, is what kills the device. Free heap can read fine while the largest
free block is too small for the next alloc. Optimize for not leaving holes.
The canvas/buffer code already encodes this split (renderer.cpp::init uses
static pre-allocated buffers on TTGO/C6 and ps_malloc+SRAM fallback
elsewhere). Match the existing pattern; don't invent a third path.
Allocation decision procedure
Ask in order; stop at the first yes.
- Stack? Local, bounded, under ~256 bytes: plain array/struct. The task
stacks are sized per board in
config.h (STACK_UI) — keep frames lean.
- Compile-time constant?
static constexpr lives in flash, costs zero
DRAM. Lookup tables and string literals belong here.
- Allocated once and reused for the screen/activity lifetime? Allocate at
init, hold in a static/member, reuse every frame. Never per-frame, never
per-iteration, never in the render/flush path.
- Dynamic and fallible?
makeUniqueNoThrow<T>(...) /
makeUniqueNoThrow<T[]>(n) from esp32/src/util/memory.h. Null-check, log,
return. It frees on every exit path. Use makeScopedCleanup([&]{ … }) for
non-owning teardown (the header is kept C++11-safe — BOARD_RGB48 builds at
gnu++11 — so construct the guard via the factory, not C++17 CTAD).
- A C/SDK API takes ownership / the object lives for the device lifetime?
Only then raw
new / heap_caps_alloc / ps_malloc, with a null-check +
Serial.printf error and a comment naming who owns it. The display driver
objects in display.cpp are this case (one-time, device-lifetime).
Bare new/new[] whose result you don't null-check is never acceptable: with
exceptions disabled it abort()s on OOM; even with them on, an unchecked deref
crashes. (We had a one-time new (uint8_t[]){…} leak in jd9365_lcd.cpp —
fixed to a stack local.)
Fragmentation rules (no-PSRAM boards especially)
std::vector: reserve(n) before any push_back loop. Each growth is
alloc-copy-free — three heap ops that leave a hole. Unknown n: estimate high.
- No repeated
new/delete or growing containers inside a loop or the render
path. Hoist the allocation out.
std::string / Arduino String: acceptable on cold paths (setup, file I/O).
Banned on the render path. Build text with a stack char[] + snprintf; the
state struct (agent_state.h) already uses fixed char[] fields — match it.
- Bound untrusted input. Inbound bridge frames are capped by
PROTOCOL_MAX_MSG_BYTES (config.h) and dropped in Protocol::parseMessage
before they reach the elastic ArduinoJson JsonDocument. Keep that guard; if
you add a new growable parse path, bound it the same way.
Diagnostics — measure the right thing
logHeap(const char* tag) (esp32/src/util/memory.h) prints free heap and
largest free block (plus PSRAM totals on PSRAM boards). The gap between the two
is the fragmentation signal. It's already called at boot, post-terrarium,
and on a 30s tick on no-PSRAM boards. Add a call after any new large
allocation rather than guessing.
- The IPS10
[PERF] profiler line (main.cpp, IPS10_PERF_PROFILE) now carries
a freeblk field — a shrinking freeblk while fps holds steady is the
fragmentation tell.
Justify every allocation
When you add a heap allocation, state in one line why stack/static/reuse was
rejected and the worst-case size. If you can't name the size, you can't budget
it, and shouldn't allocate it. Cite the board regime you're allocating for.
Self-review before handoff
Source: puritysb/AgentDeck — distributed by TomeVault.
1---2name: esp32-heap-discipline3description: Memory-allocation discipline for AgentDeck ESP32 firmware (esp32/). Use whenever writing or reviewing firmware code that allocates — new / malloc / ps_malloc / heap_caps_alloc / std::vector / std::string / String / a buffer / a cache / anything held across a render loop. Covers the PSRAM-vs-no-PSRAM board split, the allocation decision order, fragmentation avoidance, the makeUniqueNoThrow / ScopedCleanup helpers, the PROTOCOL_MAX_MSG_BYTES JSON guard, and the heap diagnostics (logHeap / [PERF] freeblk). Use when this capability is needed.4---56# ESP32 Heap Discipline78Memory rules for `esp32/` firmware. Adapted from crosspoint-reader's9`heap-discipline` skill to AgentDeck's multi-board reality. This is the10procedure you run while writing firmware and the gate before handing it back.1112## The board split — know which world you're in1314AgentDeck firmware targets two memory regimes. Check the board macros first.1516- **PSRAM boards** — `BOARD_RGB48`, `BOARD_IPS35`, `BOARD_AMOLED`, `BOARD_IPS10`17 (ESP32-S3 / -P4, 8–32MB PSRAM). Large canvases/caches go in PSRAM18 (`ps_malloc` / `MALLOC_CAP_SPIRAM`). **But** per-pixel / LVGL draw buffers and19 PPA rotation buffers must stay in **internal SRAM** (`MALLOC_CAP_INTERNAL`):20 PSRAM writes are ~30× slower, so a PSRAM draw buffer makes every widget render21 crawl (see the IPS10 rationale in `esp32/src/ui/display.cpp`). Plenty of total22 RAM here; the constraint is *write latency and internal-SRAM headroom*, not23 bytes.24- **No-PSRAM boards** — `BOARD_TTGO` (classic ESP32, ~160KB heap),25 `BOARD_ESP32_C6_147` (single-core RISC-V), `BOARD_LED8X32` (TC001). This is26 crosspoint's world: every allocation matters and **fragmentation, not total27 usage, is what kills the device.** Free heap can read fine while the largest28 free block is too small for the next alloc. Optimize for not leaving holes.2930The canvas/buffer code already encodes this split (`renderer.cpp::init` uses31static pre-allocated buffers on TTGO/C6 and `ps_malloc`+SRAM fallback32elsewhere). Match the existing pattern; don't invent a third path.3334## Allocation decision procedure3536Ask in order; stop at the first yes.37381. **Stack?** Local, bounded, under ~256 bytes: plain array/struct. The task39 stacks are sized per board in `config.h` (`STACK_UI`) — keep frames lean.402. **Compile-time constant?** `static constexpr` lives in flash, costs zero41 DRAM. Lookup tables and string literals belong here.423. **Allocated once and reused for the screen/activity lifetime?** Allocate at43 init, hold in a static/member, reuse every frame. Never per-frame, never44 per-iteration, never in the render/flush path.454. **Dynamic and fallible?** `makeUniqueNoThrow<T>(...)` /46 `makeUniqueNoThrow<T[]>(n)` from `esp32/src/util/memory.h`. Null-check, log,47 return. It frees on every exit path. Use `makeScopedCleanup([&]{ … })` for48 non-owning teardown (the header is kept C++11-safe — BOARD_RGB48 builds at49 gnu++11 — so construct the guard via the factory, not C++17 CTAD).505. **A C/SDK API takes ownership / the object lives for the device lifetime?**51 Only then raw `new` / `heap_caps_alloc` / `ps_malloc`, with a null-check +52 `Serial.printf` error and a comment naming who owns it. The display driver53 objects in `display.cpp` are this case (one-time, device-lifetime).5455Bare `new`/`new[]` whose result you don't null-check is never acceptable: with56exceptions disabled it `abort()`s on OOM; even with them on, an unchecked deref57crashes. (We had a one-time `new (uint8_t[]){…}` leak in `jd9365_lcd.cpp` —58fixed to a stack local.)5960## Fragmentation rules (no-PSRAM boards especially)6162- `std::vector`: `reserve(n)` before any `push_back` loop. Each growth is63 alloc-copy-free — three heap ops that leave a hole. Unknown n: estimate high.64- No repeated `new`/`delete` or growing containers inside a loop or the render65 path. Hoist the allocation out.66- `std::string` / Arduino `String`: acceptable on cold paths (setup, file I/O).67 Banned on the render path. Build text with a stack `char[]` + `snprintf`; the68 state struct (`agent_state.h`) already uses fixed `char[]` fields — match it.69- Bound untrusted input. Inbound bridge frames are capped by70 `PROTOCOL_MAX_MSG_BYTES` (`config.h`) and dropped in `Protocol::parseMessage`71 before they reach the elastic ArduinoJson `JsonDocument`. Keep that guard; if72 you add a new growable parse path, bound it the same way.7374## Diagnostics — measure the right thing7576- `logHeap(const char* tag)` (`esp32/src/util/memory.h`) prints free heap **and**77 largest free block (plus PSRAM totals on PSRAM boards). The gap between the two78 is the fragmentation signal. It's already called at `boot`, `post-terrarium`,79 and on a 30s `tick` on no-PSRAM boards. Add a call after any new large80 allocation rather than guessing.81- The IPS10 `[PERF]` profiler line (`main.cpp`, `IPS10_PERF_PROFILE`) now carries82 a `freeblk` field — a shrinking freeblk while fps holds steady is the83 fragmentation tell.8485## Justify every allocation8687When you add a heap allocation, state in one line why stack/static/reuse was88rejected and the worst-case size. If you can't name the size, you can't budget89it, and shouldn't allocate it. Cite the board regime you're allocating for.9091## Self-review before handoff9293- [ ] Identified the board regime (PSRAM vs no-PSRAM) and allocated accordingly.94- [ ] No bare `new`/`new[]` without a null-check + log; fallible allocs use95 `makeUniqueNoThrow`, raw allocs carry an owner comment.96- [ ] No allocation inside a loop / render / flush path that could be hoisted.97- [ ] Every `push_back` loop has a preceding `reserve`.98- [ ] Per-pixel/LVGL draw buffers stay `MALLOC_CAP_INTERNAL` on PSRAM boards.99- [ ] Any new growable parse/ingest path is bounded (cf. `PROTOCOL_MAX_MSG_BYTES`).100- [ ] Added a `logHeap` call near any new large allocation; checked freeblk, not101 just free heap.102- [ ] Each new allocation carries a one-line size + why-not-stack/static note.103104---105> Source: [puritysb/AgentDeck](https://github.com/puritysb/AgentDeck) — distributed by [TomeVault](https://tomevault.io).106<!-- tomevault:4.0:skill_md:2026-06-30 -->