Add MemAudit: per-subsystem heap accounting in the boot log (#10900)

* Add MemAudit: per-subsystem heap accounting in the boot log

The 2.8.0 nRF52840 heap-exhaustion field reports had to be diagnosed by
hand, reconstructing each subsystem's heap footprint from source and
build flags one report at a time. This makes every future report
self-diagnosing from the serial log: a tiny fixed-size registry
(src/memory/MemAudit.*) that big long-lived allocations report into,
printed as one line at the end of setup() and alongside the periodic
"Heap free:" log:

  MemAudit[boot]: tmm=2500 warm=4000 pkthist=5824 nodedb=13440
  msgstore=2200 pktpool(live)=3270 total=31234

Instrumented: NodeDB hot vector (nodedb) + satellite maps (satmaps,
rb-tree overhead estimated), WarmNodeStore (warm), PacketHistory records
and hash index (pkthist), TrafficManagement caches (tmm/tmm_ni),
MessageStore text pool (msgstore), TFT line/repaint buffers (display),
and live in-flight packets (pktpool(live)) via an optional audit tag on
the packet pool allocator - the one hot path, counted with a relaxed
32-bit atomic add (single instructions on Cortex-M, no locks).

Cost: 128 B RAM for the 16-tag table, well under 1 KB flash on rak4631.
MESHTASTIC_MEM_AUDIT=0 compiles it out to inline no-op stubs (call
sites need no ifdefs); STM32WL, the tightest flash target, defaults off.

New native suite test_mem_audit covers add/set/snapshot arithmetic, tag
reuse (pointer and cross-TU strcmp fallback), null/unknown tags, and
table-full behavior; test/native-suite-count bumped to 28.

* native-wasm: add src/memory/ to the curated source filter

The wasm env denies all sources and adds an explicit file list; MemAudit
callers (main, MeshService, NodeDB, PacketHistory) are in that list but
src/memory/MemAudit.cpp was not, so wasm-ld failed on undefined
memaudit:: symbols.
This commit is contained in:
Ben Meadors
2026-07-06 13:18:49 -05:00
committed by GitHub
co-authored by GitHub
parent ed03a69555
commit 6c7ee8afc7
15 changed files with 440 additions and 7 deletions
+19 -2
View File
@@ -7,12 +7,15 @@
#include "PointerQueue.h"
#include "configuration.h" // For LOG_WARN, LOG_DEBUG, LOG_HEAP
#include "memory/MemAudit.h"
template <class T> class Allocator
{
public:
Allocator() : deleter([this](T *p) { this->release(p); }) {}
/// Optional memaudit tag: when set, live objects from this allocator are
/// reported under it (+/- sizeof(T) per alloc/release).
explicit Allocator(const char *auditTag = nullptr) : deleter([this](T *p) { this->release(p); }), auditTag(auditTag) {}
virtual ~Allocator() {}
/// Return a queable object which has been prefilled with zeros. Return nullptr if no buffer is available
@@ -73,9 +76,17 @@ template <class T> class Allocator
// Alloc some storage
virtual T *alloc(TickType_t maxWait) = 0;
// Report a live-object delta to memaudit (no-op when untagged)
void auditAdd(int32_t delta)
{
if (auditTag)
memaudit::add(auditTag, delta);
}
private:
// std::unique_ptr Deleter function; calls release().
const std::function<void(T *)> deleter;
const char *auditTag; // memaudit tag, or nullptr for untracked pools
};
/**
@@ -84,6 +95,8 @@ template <class T> class Allocator
template <class T> class MemoryDynamic : public Allocator<T>
{
public:
explicit MemoryDynamic(const char *auditTag = nullptr) : Allocator<T>(auditTag) {}
/// Return a buffer for use by others
virtual void release(T *p) override
{
@@ -92,6 +105,7 @@ template <class T> class MemoryDynamic : public Allocator<T>
LOG_HEAP("Freeing 0x%x", p);
this->auditAdd(-(int32_t)sizeof(T));
free(p);
}
@@ -101,6 +115,7 @@ template <class T> class MemoryDynamic : public Allocator<T>
{
T *p = (T *)malloc(sizeof(T));
assert(p);
this->auditAdd((int32_t)sizeof(T));
return p;
}
};
@@ -115,7 +130,7 @@ template <class T, int MaxSize> class MemoryPool : public Allocator<T>
bool used[MaxSize];
public:
MemoryPool() : pool{}, used{}
explicit MemoryPool(const char *auditTag = nullptr) : Allocator<T>(auditTag), pool{}, used{}
{
// Arrays are now zero-initialized by member initializer list
// pool array: all elements are default-constructed (zero for POD types)
@@ -135,6 +150,7 @@ template <class T, int MaxSize> class MemoryPool : public Allocator<T>
if (index >= 0 && index < MaxSize) {
assert(used[index]); // Should be marked as used
used[index] = false;
this->auditAdd(-(int32_t)sizeof(T));
LOG_HEAP("Released static pool item %d at 0x%x", index, p);
} else {
LOG_WARN("Pointer 0x%x not from our pool!", p);
@@ -149,6 +165,7 @@ template <class T, int MaxSize> class MemoryPool : public Allocator<T>
for (int i = 0; i < MaxSize; i++) {
if (!used[i]) {
used[i] = true;
this->auditAdd((int32_t)sizeof(T));
LOG_HEAP("Allocated static pool item %d at 0x%x", i, &pool[i]);
return &pool[i];
}
+21
View File
@@ -22,6 +22,7 @@
#include "TypeConversions.h"
#include "error.h"
#include "main.h"
#include "memory/MemAudit.h"
#include "mesh-pb-constants.h"
#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h"
#include "meshUtils.h"
@@ -1796,6 +1797,25 @@ bool NodeDB::enforceSatelliteCaps()
#endif
(void)trim; // all four maps may be compiled out
// Approximate satellite heap usage: each std::map entry is one rb-tree node,
// value_type plus ~44 B of node overhead (parent/left/right pointers, color,
// allocator rounding on 32-bit targets - an estimate, not exact bookkeeping).
size_t satBytes = 0;
#if !MESHTASTIC_EXCLUDE_POSITIONDB
satBytes += nodePositions.size() * (sizeof(decltype(nodePositions)::value_type) + 44);
#endif
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
satBytes += nodeTelemetry.size() * (sizeof(decltype(nodeTelemetry)::value_type) + 44);
#endif
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
satBytes += nodeEnvironment.size() * (sizeof(decltype(nodeEnvironment)::value_type) + 44);
#endif
#if !MESHTASTIC_EXCLUDE_STATUSDB
satBytes += nodeStatus.size() * (sizeof(decltype(nodeStatus)::value_type) + 44);
#endif
memaudit::set("satmaps", satBytes);
return trimmedAny;
}
@@ -2080,6 +2100,7 @@ void NodeDB::nodeDBSelfCare()
// Normalise the backing store to the hot cap so getOrCreateMeshNode always
// has spare slots to append into (it indexes meshNodes->at(numMeshNodes++)).
meshNodes->resize(MAX_NUM_NODES);
memaudit::set("nodedb", MAX_NUM_NODES * sizeof(meshtastic_NodeInfoLite));
const bool satsTrimmed = enforceSatelliteCaps();
+3
View File
@@ -1,5 +1,6 @@
#include "PacketHistory.h"
#include "configuration.h"
#include "memory/MemAudit.h"
#include "mesh-pb-constants.h"
#include "meshUtils.h"
@@ -44,6 +45,7 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0) // Initia
// Initialize the recent packets array to zero
memset(recentPackets.get(), 0, sizeof(PacketRecord) * recentPacketsCapacity);
memaudit::set("pkthist", sizeof(PacketRecord) * recentPacketsCapacity);
#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
// Allocate hash index with load factor <= 0.5 for short probe chains
@@ -57,6 +59,7 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0) // Initia
return;
}
memset(hashIndex.get(), 0xFF, sizeof(uint16_t) * hashCapacity); // Fill with HASH_EMPTY (0xFFFF)
memaudit::set("pkthist", sizeof(PacketRecord) * recentPacketsCapacity + sizeof(uint16_t) * hashCapacity);
#endif
}
+6 -3
View File
@@ -41,7 +41,8 @@
(MAX_RX_TOPHONE + MAX_RX_FROMRADIO + 2 * MAX_TX_QUEUE + \
2) // max number of packets which can be in flight (either queued from reception or queued for sending)
static MemoryDynamic<meshtastic_MeshPacket> dynamicPool;
// Live in-flight packet bytes are tracked under "pktpool(live)" in the MemAudit breakdown
static MemoryDynamic<meshtastic_MeshPacket> dynamicPool("pktpool(live)");
Allocator<meshtastic_MeshPacket> &packetPool = dynamicPool;
#elif defined(ARCH_STM32WL) || defined(BOARD_HAS_PSRAM)
// On STM32 and boards with PSRAM, there isn't enough heap left over for the rest of the firmware if we allocate this statically.
@@ -50,7 +51,8 @@ Allocator<meshtastic_MeshPacket> &packetPool = dynamicPool;
(MAX_RX_TOPHONE + MAX_RX_FROMRADIO + 2 * MAX_TX_QUEUE + \
2) // max number of packets which can be in flight (either queued from reception or queued for sending)
static MemoryDynamic<meshtastic_MeshPacket> dynamicPool;
// Live in-flight packet bytes are tracked under "pktpool(live)" in the MemAudit breakdown
static MemoryDynamic<meshtastic_MeshPacket> dynamicPool("pktpool(live)");
Allocator<meshtastic_MeshPacket> &packetPool = dynamicPool;
#else
// Embedded targets use static memory pools with compile-time constants
@@ -58,7 +60,8 @@ Allocator<meshtastic_MeshPacket> &packetPool = dynamicPool;
(MAX_RX_TOPHONE + MAX_RX_FROMRADIO + 2 * MAX_TX_QUEUE + \
2) // max number of packets which can be in flight (either queued from reception or queued for sending)
static MemoryPool<meshtastic_MeshPacket, MAX_PACKETS_STATIC> staticPool;
// Static pool RAM is BSS, not heap; "pktpool(live)" still shows in-flight packet bytes
static MemoryPool<meshtastic_MeshPacket, MAX_PACKETS_STATIC> staticPool("pktpool(live)");
Allocator<meshtastic_MeshPacket> &packetPool = staticPool;
#endif
+3
View File
@@ -6,6 +6,7 @@
#include "SPILock.h"
#include "SafeFile.h"
#include "configuration.h"
#include "memory/MemAudit.h"
#include "power/PowerHAL.h"
#include <ErriezCRC32.h>
#include <vector>
@@ -62,6 +63,7 @@ WarmNodeStore::WarmNodeStore()
#else
entries = static_cast<WarmNodeEntry *>(calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry)));
#endif
memaudit::set("warm", entries ? WARM_NODE_COUNT * sizeof(WarmNodeEntry) : 0);
#if defined(NRF52840_XXAA)
memset(pageOf, kNoPage, sizeof(pageOf));
#endif
@@ -71,6 +73,7 @@ WarmNodeStore::~WarmNodeStore()
{
free(entries); // always malloc-family (calloc / ps_calloc)
entries = nullptr;
memaudit::set("warm", 0);
}
WarmNodeEntry *WarmNodeStore::find(NodeNum num) const