Skip to content

Firmware memory checks

Every firmware environment runs scripts/check_firmware_ram.py against its linked ELF before producing or uploading an image. build.sh, including option 3, also requires a passing report before collecting release files. Native host tests do not use a microcontroller RAM budget.

The check reserves room for enabled runtime allocations as well as static data. A firmware image fitting its board's reported RAM total is insufficient: the display, packet pool, USB, Bluetooth workers and WiFi can allocate after startup. The T096 Full 1.17.1.5 report exposed this distinction. MQTT uses an explicit startup minimum plus runtime connection admission. A passing build report does not qualify its maximum TLS/WSS/outbox workload.

What is counted

Platform Source of available runtime RAM
nRF52 Actual __HeapBase and __HeapLimit; excludes SoftDevice, retained state, ISR stack and the dedicated 64 KiB mOTA arena where present
ESP32, S3, C3, C6 Linked ESP-IDF memory-region, capability and reservation tables; only internal, byte-addressable heap counts
RP2040/RP2350 __end__ to __HeapLimit, according to the selected linker
STM32 _end to _estack, minus _Min_Stack_Size

ESP32 PSRAM, instruction-only RAM and RTC RAM never increase the internal budget. On chips other than classic ESP32, the late-reclaimed ROM stack region is excluded because its silicon-specific reservations are only known at boot. Classic ESP32 additionally retains its existing 8 KiB static DRAM check.

The policy adds allowances for task stacks, radio packet pools, screen objects and pixel buffers, filesystem/sensor allocations, enabled wireless stacks, MQTT connections, OTA scratch and transient allocations. ESP32 images with MQTT reserve the same 49,152-byte WiFi allowance even without WIFI_SSID or WIFI_OTA_SEEDER: saved runtime credentials can activate WiFi. Multiple WiFi features count that allowance once; PSRAM-backed TLS does not remove it. ESP32 images with RS-232 reserve another 6,656 bytes even when UART defaults off: 2,560 bytes for the source-bounded bridge object and allocation metadata, plus an unchanged 4 KiB for the UART driver and buffers. This keeps runtime UART enablement in the budget beside MQTT and ESP-NOW. T-LoRa V2.1-1.6 cannot fit all three transports: the qualified linked triple image had 190,488 available internal bytes against 196,984 required bytes under the earlier allocation estimates. The corrected equivalent requirement was 193,400 bytes with the earlier flat MQTT allowance, still 2,912 bytes above capacity. Its configured 16 KiB MQTT worker stack adds another 8 KiB under the current startup policy; these figures describe historical linked checks. Its ordinary release retains separate UART/ESP-NOW and MQTT/ESP-NOW Full images; routing capacities remain unchanged. A 160x80 ST7735 framebuffer needs 25,602 bytes. ESP32 SSD1306 uses a source-bounded 2 KiB allowance for its fixed 1,024-byte framebuffer, driver and metadata; other OLED and ordinary non-ESP32 allowances remain unchanged. nRF52 Full with that color framebuffer must have at least 72 KiB available before startup allocations. Headless and OLED devices use their own smaller totals. The JSON lists each component and checks the largest available region against the largest planned single allocation.

MQTT startup minimum and runtime qualification

The mqtt_startup_allowance component keeps a 24,576-byte internal startup minimum for the default 8,192-byte MQTTBridge worker stack and two active clients. Each SDK client task is explicitly configured with a 6,144-byte stack and priority 5 on IDF 4 and 5; its maximum configured receive/transmit buffers total 1,792 bytes. These stacks and buffers total 24,064 bytes for two clients, leaving 512 bytes within the existing allowance. This is a minimum allowance, not a complete allocation bound for the clients, transport objects, TLS records, certificate processing, WebSockets or outboxes. The independent 16 KiB transient margin remains in the policy.

MQTT_TASK_STACK_SIZE above 8,192 bytes raises the startup requirement by its actual increase and raises the required contiguous allocation when needed. Smaller stacks and fewer runtime slots do not lower the existing allowance. The default runtime slot array has six entries on PSRAM builds and three on internal-only builds. The bridge can expose five active connections when PSRAM is present and two otherwise; MQTT_RUNTIME_SLOT_COUNT can restrict that capacity. A PSRAM board with unavailable PSRAM uses the two-connection cap.

The report's separate mqtt.maximum_slot_stack_buffer_allowance_bytes records 48,384 bytes for five exposed slots or 24,576 bytes for two, with larger worker stacks added to either. The five-slot figure extends the minimum by 7,936 bytes for each additional client task and buffer pair. It is not added to the startup gate: extra clients allocate lazily, and every connection attempt must pass runtime admission. It still excludes the maximum TLS/WSS/outbox workload. maximum_runtime_load_qualified and physical_validation_performed are false in these build reports. A passed startup check must not be presented as qualification of five simultaneous PSRAM connections or two internal TLS connections.

The mqtt report also names the production runtime admission thresholds. A cold SDK start requires at least 32 KiB of DMA-capable internal free heap and an 8 KiB contiguous DMA block. This includes a separate 16 KiB allowance for its SDK task stack/TCB, receive/transmit/reassembly buffers and transport or configuration objects, alongside the 16 KiB AES reserve. Those ordinary SDK allocations occur after admission and do not use the TLS allocation guard. A retained initialized SDK handle with a stopped task requires 24 KiB free and an 8 KiB contiguous DMA block. Its separate 8 KiB restart allowance covers the pinned 6 KiB task stack plus TCB and allocator overhead. SDK stop retains its receive/transmit buffers, configuration, transport objects and wrapper reassembly buffer, so restarting that handle does not need to reserve their first-initialization allocation again. A wrapper whose SDK initialization has not completed still receives the full cold allowance. This distinction also applies after WiFi loss stops the SDK tasks but keeps their handles.

All ESP32 clients use 896-byte receive, transmit and wrapper reassembly buffers from first initialization. Applying configuration cannot grow the retained SDK buffers or wrapper reassembly capacity, so uniform sizing permits later JWT reconfiguration without replacing the client. This adds 1,152 bytes to a non-JWT client's three buffers on an internal-only board compared with 512-byte buffers. The startup allowance already uses 896-byte receive/transmit buffers, and cold admission covers the ordinary allocations. WiFi recovery and explicit reconfiguration continue to retain clients.

A warm reconnect keeps its running task and buffers; it requires at least 16 KiB free and a 4 KiB contiguous DMA block. The per-client SDK stack is independent of MQTT_TASK_STACK_SIZE, which sizes the existing bridge worker. A TLS attempt without PSRAM requires at least 60 KiB free and a 17,408-byte contiguous block. PSRAM allocation fallback must leave the 16 KiB DMA reserve available. These thresholds describe free heap at the time of admission; they are not additional linked capacity and the TLS contiguous threshold is not a startup allocation requirement. Available linked internal heap cannot establish how much DMA-capable heap remains or how it is fragmented after boot. Only runtime observations can establish that.

Maximum-load qualification still needs physical boot and sustained reconnect, publish and configuration-change tests with the selected number of brokers, transport schemes, certificates and enabled UART/browser/OTA features. Record DMA free heap, largest DMA block and allocation failures throughout that workload. Host admission tests and passing ELF checks provide engineering evidence without claiming that hardware validation has been performed.

The W12 Full MQTT profile remains unqualified: the current linked image has 182,768 internal bytes available against 198,432 required, including the unchanged 16 KiB transient margin. Its standard repeater compiles and passes the RAM check, but the 1,322,304-byte application exceeds the 1,310,720-byte portable LoRa-OTA slot. Both limits also occur in the pre-stability baseline (Full: 182,800 available against 200,480 required under the earlier OLED estimate; standard application: 1,321,088 bytes). LR2021 compile and fault-test coverage does not qualify either package for release.

The experimental RAK4631 combined Ethernet/UART2/adaptive-storage config adds an explicit 2,560-byte allowance for the UART bridge and its TX semaphore. Its Ethernet worker reserves 1,024 stack words (4,096 bytes) plus 256 bytes for task metadata and allocator overhead. Its bounded TCP transmit queue allocates 512 bytes only while Ethernet is enabled; another 64 bytes cover allocator overhead. The DHCP object, socket state, queue indices and SPI objects are static and already reduce the linked heap capacity.

Ethernet and external OTA share an exclusive ownership contract. Enabling Ethernet rejects a live or pinned transfer, releases idle external OTA buffers, and blocks encoder, self-source, manual-staging and folder-source allocation while Ethernet owns SPI. Disabling Ethernet parks the worker after socket/SPI cleanup; the main loop deletes that parked task synchronously and frees its TX queue before releasing ownership. The worker stack therefore cannot remain queued for idle-task reclamation while OTA allocates again. The budget counts the larger of the 4,928-byte Ethernet worker/queue or 11,344-byte external OTA workspaces, with the UART allowance alongside either mode. It keeps the 8 KiB loop stack, 63 flood rules, and reserved 64 KiB OTA handoff arena.

The shared RAM changes now reserve the lazy folder-source buffers as well: up to 4,096 bytes of source leaves and one 2,048-byte encoded output block, plus allocation overhead, for the RAK4631. These can coexist with own-image metadata and the encoder. The combined budget therefore reserves 11,344 bytes for external OTA, or 10,304 with the device encoder disabled, against the 4,928-byte Ethernet worker/queue. Ethernet and external OTA remain exclusive. Freeing static buffers increases available heap; their active allocation cost still appears in the memory check.

This combined config uses a source-bounded 2 KiB SSD1306 allowance. Its named 128x64 geometry needs one 1,024-byte framebuffer, reused on later starts; the driver itself is embedded in a linked global, measured at 156 bytes in the ARM test ELF. Including that already-counted object again plus 32 bytes for allocation overhead still leaves 836 bytes within the allowance. A compile-time assertion covers framebuffer, complete driver object and allocator overhead within that bound. This corrects a padded allocation estimate; it does not free actual RAM. Other images retain their existing display budgets. These are local test configs, not a qualification of a published or physically tested image.

Shared RAM reductions

These changes apply to every board that compiles the corresponding feature. They retain stack sizes, radio packet pools, USB buffering, table capacities, stored settings, OTA block sizes and source-image limits.

Change Scope RAM reduction
Repeater ACL session state Pure repeater builds on all platforms 36 bytes per client on 32-bit targets; 1,152 bytes for 32 clients
Compact recent-repeater records Repeaters with recent history enabled 12 to 9 bytes per record; 1,536 bytes for 512 records
Compact flood-rule flags and aligned counters Generalized repeater and room rule engines 200 to 192 bytes per rule; 504 bytes for 63 repeater rules or 248 bytes for 31 room rules
Streaming Merkle proof generation All OTA roles and platforms Removes full-leaf-table proof scratch; compressed output needs only one logical block
Source buffers allocated on demand All OTA roles and platforms Leaves allocate only the selected image's digest count; unused leaves/output return to the heap

The ACL source-role pre-script contributes one common compiler definition for the entire build. Room/sensor and unknown roles keep the complete session layout. The persisted four-byte room synchronization field stays in repeater records, so existing ACL files and their checksums remain compatible.

Recent-history timestamps retain all 32 bits using explicit byte operations, avoiding unaligned multi-byte accesses on ARM. The table belongs to the mesh loop; network command handlers marshal their work to that loop. These byte operations do not provide synchronization for hypothetical concurrent users. Flood flags remain internal; FPF files serialize each field explicitly and never expose compiler bitfield layout. Multibyte counters remain aligned. Rule deduplication compares configured fields explicitly, so neither live rate-window counters nor struct padding can make different rules share a slot.

OTA leaf capacity remains controlled by OTA_PROOFGEN_SCRATCH, independently of output-buffer size. The maximum source buffers are 4 KiB plus one block on ordinary nRF52, 8 KiB plus one block with SD storage, and 16 KiB plus one block on ESP32. Generic folder sources need no output allocation for raw DATA or proofs; own-image raw serving retains its caller-owned compatibility buffer. Its nRF52 allowance remains in the budget even when the device encoder is off. Queued DATA, PROOF and MANIFEST responses pin their buffers through radio backpressure. Explicit cancellation or source detach releases the reloadable cache; an unpinned cache also expires after 30 seconds. Attached source descriptors remain advertised and reload on the next request. Allocation failure refuses source loading safely, or falls back to raw DATA if only compression output is unavailable. Receive reassembly and serving/inflate buffers remain separate.

On RAK4631, the OTA context's permanent allocation falls from 28,816 to 20,640 bytes, an 8,176-byte reduction before table savings. This is idle heap capacity, not an additional 8 KiB saving on top of the active-operation savings. The RAM policy reserves lazy buffers when active and separately checks the largest contiguous allocation. ESP32 history uses its actual default capacities: 256 records on classic ESP32 and 2,048 on S2/S3/C-series, unless overridden.

The matched RAK4631 Full combined trial on 8 October 2026 retained all sensor drivers, 32 ACL clients, 63 flood rules, 512 recent repeater records, the 8 KiB loop stack and the reserved 64 KiB OTA arena:

Linked measurement Before shared RAM changes After all five changes
Available heap capacity before startup 73,580 bytes 83,300 bytes
Reserved startup/active allocations 72,584 bytes 77,104 bytes
Capacity beyond those reservations 996 bytes 6,196 bytes
Application flash 585,700 bytes 586,260 bytes

The permanent allocations shrink by 9,720 bytes. The smaller ACL and flood tables reduce startup allowances by another 1,656 bytes, while the new 6,176-byte folder-source allowance counts buffers when active. The resulting capacity beyond reservations improves by 5,200 bytes; flash grows by 560 bytes. The reports are bound to ELF hashes a16c02e43c12d7dadd94b92b9b80cdd22eced7f67e085b1fffb62ea39f09fcce and f78110d1609334621098cb6db2039227c2f8fc9ac7f9e6ef7dc3cc63eb8d502f, respectively. These are linked capacity checks, not measured free heap after boot or physical Ethernet/OTA soak results.

The five changes pass representative repeater builds for RAK4631 combined Full, Heltec V4 (ESP32-S3), Heltec V2 (classic ESP32), and Seeed XIAO RP2040. RAK4631 room-server and Heltec V4 sensor builds also pass while retaining their complete ACL session layouts. Wio E5 Mini repeater compilation passes, but linking exceeds flash capacity: the baseline already overflowed by 27,200 bytes, and these changes add 224 bytes for a 27,424-byte overflow. That STM32 image is unqualified and is not release-ready.

Regression coverage includes unchanged ACL/FPF file bytes, full capacities, timestamp rollover, flag combinations, ARM access widths/alignment, streamed proofs against the existing reference algorithm, allocation failures, source reload, queued-response lifetimes, and concurrent compressed serving/receiving. Synthetic and representative build results are distinct from physical soak qualification of every board.

Generate matched Full/Reduced baseline and combined configs outside the repository without starting PlatformIO:

python3 scripts/generate_rak4631_combined_test.py

Run its printed commands one at a time and preserve each ELF and memory report before the next build reuses the environment directory.

The combined prototype starts with Ethernet off after every restart. Use the USB CLI commands eth on, eth.status, and eth off; get eth reports the requested mode. UART2 remains independently controlled by set bridge.uart 2 and set rs232.enabled on|off. Full retains every sensor driver; absent sensors do not require a different image.

With Ethernet on, OTA uses eligible internal-flash deltas. External full-image staging, self-serving, manual serving buffers and OTA folders are unavailable until Ethernet is off. Active or staged OTA work prevents a mode change. RAK15001 and Ethernet share chip-select 26 and cannot be used together; the prototype refuses that combination. External storage also needs a matching bootloader that permits internal OTA while Ethernet is enabled.

TCP sends and receives use bounded register operations; a stalled peer cannot hold the mesh loop waiting for an acknowledgement. DHCP and listener management run in the Ethernet worker. This is synthetic coverage and a build experiment, not a physical Ethernet/PoE soak qualification or a replacement release image.

The small-screen message layout retains complete 160-byte messages. Its expanded preview records add 2,816 bytes to the startup allowance and increase the contiguous history allocation budget. The V4 can allocate that history in PSRAM; the guard conservatively reserves internal capacity so that PSRAM availability cannot hide a RAM shortage. A target may replace the generic UI allowance only with MESH_COMPANION_SCREEN_STARTUP_BYTES; a source-side assertion then covers its actual startup screens plus ESP32 allocator overhead.

These are engineering allowances for supported configurations, not measured free heap after boot or a guarantee against every future allocation failure. Unknown platforms, unknown display drivers and missing linker metadata fail closed. MESH_MIN_RUNTIME_HEAP can raise a profile's requirement; it cannot lower the calculated requirement. Add an allocation allowance when adding a display, transport or other substantial feature.

Wireless Paper Full keeps 350 contacts and 256 offline frames by lending the upper 128 queue slots to its mOTA workspace during a session. An idle WiFi mOTA listener leaves all 256 slots available. More than 128 unread frames refuses the loan; sync messages with an app first. USB/TCP source detach or disconnect returns all 256 slots and releases the ESP32 proof/leaf scratch buffers. The display and simultaneous USB, Bluetooth and WiFi remain enabled.

nRF52 Companion flash savings

The common nRF52 Companion policy now retains a 960-byte Ed25519 fixed-base table in place of the original 30,720-byte table. It evaluates the same signed radix-16 digits with constant-time table selection, using more doublings instead of storing 32 shifted rows. Compact SHA-512 and scalar arithmetic keep the same outputs and wire format. The larger RAM-built table used by infrastructure profiles is not enabled for Companions.

The policy also omits unused USB device MSC/HID/MIDI/vendor/video classes, SparkFun's unused automatic NMEA cache and the invisible SSD1306 vendor splash. For the exact hash-qualified CC310 archive, the domain wrapper retains P-256 for BLE Secure Connections and removes unused legacy curves. CDC, DFU, USB host, GPS navigation, all Companion fonts, Meshtext, contacts, channels, the Reader corpus and the 8 KiB loop stack remain available. Explicit board/user overrides and MESH_NRF52_COMPANION_TRIM=0 retain their requested values. Other platforms do not receive this Companion policy.

The RAK4631 Full comparison uses the released 1.17.1.10 preview3 ELF at 9ce2a076 and the same target with these changes and Reader chapter/range support. Flash is initialized linked code/data; static RAM is .data plus ordinary .bss, excluding separately reserved arenas.

Measurement Preview3 With Companion trims and Reader ranges Difference
Linked flash bytes 710,404 652,908 57,496 fewer (56.1 KiB)
Static RAM bytes 157,028 153,876 3,152 fewer (3.1 KiB)
Linked heap capacity bytes 78,484 81,636 3,152 more
UF2 space below the application ceiling 2,048 59,648 57,600 more

Physical RAK signing averaged 14.4 ms with the original table and 35.9 ms with the smaller implementation. All 24 signatures per image, including SHA-512 block-boundary message lengths, matched byte for byte and verified independently. The linked ARM emulator measures 1,928 bytes in signing versus 1,528 before; these are crypto function frames only, excluding callers, interrupts and other tasks. The loop stack stays at 8 KiB. A full firmware RAM check still applies.

CI runs scripts/check_nrf52_companion_crypto.py on the actual Full RAK ELF. It checks the 960-byte table and retained Reader corpus, executes two RFC 8032 vectors, and rejects crypto function stack use above 2 KiB. Host tests compare 1,791 scalars and 128 keypair/signature/shared-secret cases against the original implementation, plus scalar boundary/random and sanitizer tests. Clean Full builds also cover T1000-E and T096. These builds are synthetic coverage for those boards; the physical Reader/signing tests use the RAK4631.

Reader commands support get reader 2 and get reader 2:2-12. Text terminals stream at most 96 bytes per mesh loop, preserving progress under zero/short writes. The TCP console uses one nonblocking socket-send attempt for the stream, so a peer that stops reading cannot hold the mesh loop in WiFiClient retries. App CLI replies remain bounded and supply a continuation command. The physical RAK passed all 21 chapters, ranges, app-style continuation replies, a paused USB reader exceeding the 4 KiB queue, and disconnect cancellation.

Release evidence and regression tests

Full Companions without PSRAM also use 16-entry path and shared-secret caches. The optional NimBLE capacity trials retain the same RAM guards at 350 contacts and 256 normal offline frames.

Each newly built firmware has a matching .memory.json report. It records the linked ELF SHA-256, available internal RAM, required RAM, largest region, and SHA-256 hashes for the actual firmware files and capability manifest. Packaging and resumed builds reject absent reports, failures, stale ELFs, missing files and changed firmware. Do not reuse a report for another build.

Run PlatformIO commands sequentially in this checkout:

python3 -B test/test_firmware_ram.py
python3 -B test/test_t096_full_memory.py
python3 -B test/test_nrf52_ble_startup.py
python3 -B test/test_shared_mota_queue.py
python3 -B test/test_cascade_release_package.py
pio test -e native -f test_ota

Tests cover all resolved firmware environments' hooks, real ELF parsing, allocator table formats, excluded memory, allocation failure, package/report binding and the published T096 failing budget. Shared mOTA tests exercise complete transfers, queue wraparound, unread-message order, source ownership, stop/disconnect and repeated reuse. Bluetooth tests inject task and service startup failures. The manual staging buffer also has allocation-failure and repeated release tests. ESP32 tests run the actual WiFi mOTA listener and source framing through complete transfers, idle polling, queue-full refusal, network loss, CLI detach and USB/TCP ownership changes under address/leak sanitizers.

For older releases without saved ELFs, an audit can compare their ESP allocator tables against a matching pinned SDK ELF and read reservations from the published application itself using scripts/audit_esp32_image_ram.py. Unrecognized layouts require another matching reference or a historical rebuild. An audit must identify original-log/linker calculations separately from new ELF checks and verify the published firmware hashes.

Physical validation remains necessary: boot with and without USB, pair and exchange Bluetooth messages, visit every screen, wake with the button, enable logging/MQTT, transfer mOTA, and monitor heap during a sustained workload. See memory monitoring for runtime diagnostics.