66 lines
3.5 KiB
Markdown
66 lines
3.5 KiB
Markdown
---
|
|
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.
|
|
|
|
1. **Stack?** Local, bounded, under ~256 bytes total: plain array/struct. No
|
|
heap, no fragmentation. Keep frames lean; the task stack is small.
|
|
2. **Compile-time constant?** `static constexpr` lives in flash, costs zero DRAM.
|
|
3. **Allocated once and reused for an activity's lifetime?** Allocate in
|
|
`onEnter`, hold in a member, release in `onExit`. Never per-frame, never
|
|
per-iteration.
|
|
4. **Dynamic and fallible?** `makeUniqueNoThrow<T>(...)` /
|
|
`makeUniqueNoThrow<T[]>(n)` from `lib/Memory/Memory.h`. Null-check, `LOG_ERR`
|
|
with the size, return false. It frees on every exit path.
|
|
5. **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 any `push_back` loop. Each growth is
|
|
alloc-copy-free (three heap ops) and leaves a hole. Unknown n: estimate high.
|
|
- No repeated `new`/`delete` or 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` / `restoreBwBuffer` path in `GfxRenderer` so they
|
|
never demand one contiguous 48KB block. Reuse that path. Do not malloc a
|
|
second full-screen buffer.
|
|
- `std::string` / Arduino `String`: acceptable on cold paths (file I/O, one-shot
|
|
setup). Banned on hot/render paths. Build text with a stack `char[]` +
|
|
`snprintf`; if a `std::string` is unavoidable, `reserve` it 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 is `makeUniqueNoThrow`, or a
|
|
raw alloc with an explicit owner comment.
|
|
- [ ] Every allocation is null-checked with `LOG_ERR` before the error return.
|
|
- [ ] No allocation inside a loop or render path that could be hoisted.
|
|
- [ ] Every `push_back` loop has a preceding `reserve`.
|
|
- [ ] Anything allocated in `onEnter` is released in `onExit`; member `HalFile`
|
|
closed there too.
|
|
- [ ] No second full-screen buffer; grayscale uses store/restoreBwBuffer.
|
|
- [ ] Each new allocation carries a one-line size + why-not-stack/static note.
|