Skip to content
Back to skills

Esp32 Heap Discipline

ASecurity

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...

  • 256 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 23, 2026
developmentrustgoc++api

Works with

  • api

Security analysis

A100/100

Scanned September 23, 2026

npx -y skills add puritysb/AgentDeck --skill esp32-heap-discipline --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Esp32 Heap Discipline?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Esp32 Heap Discipline
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/puritysb-esp32-heap-discipline/badge)](https://www.skillsdirectory.com/skills/puritysb-esp32-heap-discipline)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: esp32-heap-discipline
description: 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).
---

# 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_BOX_86`, `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.

1. **Stack?** Local, bounded, under ~256 bytes: plain array/struct. The task
   stacks are sized per board in `config.h` (`STACK_UI`) — keep frames lean.
2. **Compile-time constant?** `static constexpr` lives in flash, costs zero
   DRAM. Lookup tables and string literals belong here.
3. **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.
4. **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 — `led8x32` builds at
   gnu++11 — so construct the guard via the factory, not C++17 CTAD).
5. **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

- [ ] Identified the board regime (PSRAM vs no-PSRAM) and allocated accordingly.
- [ ] No bare `new`/`new[]` without a null-check + log; fallible allocs use
      `makeUniqueNoThrow`, raw allocs carry an owner comment.
- [ ] No allocation inside a loop / render / flush path that could be hoisted.
- [ ] Every `push_back` loop has a preceding `reserve`.
- [ ] Per-pixel/LVGL draw buffers stay `MALLOC_CAP_INTERNAL` on PSRAM boards.
- [ ] Any new growable parse/ingest path is bounded (cf. `PROTOCOL_MAX_MSG_BYTES`).
- [ ] Added a `logHeap` call near any new large allocation; checked freeblk, not
      just free heap.
- [ ] Each new allocation carries a one-line size + why-not-stack/static note.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.