From 53835c0793011601a41fe85be19f6527fb8b4d7a Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 17 Oct 2026 11:57:05 -0700 Subject: [PATCH] Avoid flash load image for the empty contact-path cache --- docs/research/firmware_memory_budget.md | 34 +++++++++++++++++++++++++ scripts/check_nrf52_companion_crypto.py | 4 +++ src/helpers/ContactPathCache.h | 5 ++++ test/test_contact_cache.py | 30 ++++++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/docs/research/firmware_memory_budget.md b/docs/research/firmware_memory_budget.md index 904afd8cf..2d8144279 100644 --- a/docs/research/firmware_memory_budget.md +++ b/docs/research/firmware_memory_budget.md @@ -401,6 +401,40 @@ 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. +### Empty contact-path cache initialization + +The contact-path pool now has an explicit runtime constructor. Its existing +member defaults still initialize every empty handle and resident entry, and +`Slot()` still resets released handles. This moves the local static pool from +initialized `.data` to zero-initialized `.bss`: the vtable pointer and repeated +`NONE`/255 slot markers no longer require a 5,780-byte flash load image. The +pool is constructed on first use by the existing main-loop contact owner. +Its layout, 390 handle slots, 16 resident paths, reference/snapshot behavior +and persistent contact records are unchanged. + +The RAK4631 Full release recipe at `c0672b90`, with the full +`v1.17.1.10-halo-keymind-cascade-dev` version and commit suffix, measured: + +| Measurement | Before | Runtime pool construction | +| --- | ---: | ---: | +| Linked initialized flash bytes | 716,448 | 710,908 | +| Application image including EndF | 716,528 | 710,988 | +| Linked available heap bytes | 79,388 | 79,388 | +| Required runtime heap bytes | 74,576 | 74,576 | + +The net flash saving is 5,540 bytes after constructor/guard code. The image +passes the existing 712,704-byte application ceiling, capability checks, RAM +budget and DFU handoff checks. Its padded UF2 ends at `0xD3A00`, below the +unchanged `0xD4000` boundary. Sensors, BLE/USB/Ethernet, SD, logging, Meshtext, +OTA, Reader data and the 8 KiB loop stack remain enabled. + +The contact-cache test compiles a real static pool and checks its zero-initialized +storage, alongside the existing sanitized eviction, snapshot, migration and +transaction tests. The compiled ARM Companion check also rejects a pool in an +initialized ELF section, so compiler promotion cannot silently restore the +flash image. Its normal linked crypto vectors and Reader retention checks +remain required. + ## Release evidence and regression tests Full Companions without PSRAM also use diff --git a/scripts/check_nrf52_companion_crypto.py b/scripts/check_nrf52_companion_crypto.py index d83b4d53e..aa6fdff2c 100644 --- a/scripts/check_nrf52_companion_crypto.py +++ b/scripts/check_nrf52_companion_crypto.py @@ -45,6 +45,9 @@ def check(path): reader = "_ZN4mesh5bible9generatedL10readerDataE" if reader not in elf.symbols: raise ValueError("Companion Reader corpus is missing") + path_cache = elf.symbols.get("_ZZN4mesh18contactPathStorageEvE4pool") + if path_cache and elf.sections[path_cache[2]][1] != 8: # SHT_NOBITS + raise ValueError("Contact path cache has a flash load image; expected zero-initialized storage") uc = Uc(UC_ARCH_ARM, UC_MODE_THUMB | UC_MODE_MCLASS) uc.mem_map(0, 0x100000) uc.mem_map(0x20000000, 0x40000) @@ -100,6 +103,7 @@ def check(path): "passed": True, "elf_sha256": hashlib.sha256(elf.data).hexdigest(), "base_table_bytes": elf.symbols["base"][1], "reader_data_bytes": elf.symbols[reader][1], "vectors": rows, + "zero_initialized_path_cache_bytes": path_cache[1] if path_cache else 0, "stack_limit_bytes": 2048, "scope": "Crypto function frames only; excludes caller/ISR/task stacks and physical timing.", } diff --git a/src/helpers/ContactPathCache.h b/src/helpers/ContactPathCache.h index f9556a3fc..80d2729f2 100644 --- a/src/helpers/ContactPathCache.h +++ b/src/helpers/ContactPathCache.h @@ -170,6 +170,11 @@ class ContactPathPool : public ContactPathStorage { } public: + // Construct the empty cache on first use instead of storing a flash load + // image of every slot's NONE/255 sentinels and the vtable pointer. Keep the + // member defaults for both initial construction and Slot() resets. + ContactPathPool() {} + void attach(ContactPathBackend* backend) override { _backend = backend; } uint16_t bind(uint16_t location, const uint8_t path[64]) override { if (location == NONE) return NONE; diff --git a/test/test_contact_cache.py b/test/test_contact_cache.py index a89f39ee7..e6008dc1c 100644 --- a/test/test_contact_cache.py +++ b/test/test_contact_cache.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Exercise real contact handles, cache policy and persistence under sanitizers.""" from pathlib import Path +import re import subprocess import tempfile import unittest @@ -22,6 +23,35 @@ def crypto_objects(temp): class ContactCacheTest(unittest.TestCase): + def test_empty_path_pool_has_no_flash_load_image(self): + # The 390-handle pool must occupy zero-initialized storage, not a + # multi-KiB initialized-data image containing repeated empty markers. + # Compile a real static instance; source-shape checks cannot establish + # whether the compiler emits a flash initializer for it. + with tempfile.TemporaryDirectory(prefix="mesh-path-pool-image-") as directory: + source = Path(directory) / "pool.cpp" + obj = Path(directory) / "pool.o" + source.write_text('''#include +mesh::ContactPathStorage& testPathStorage() { + static mesh::ContactPathPool<390> pool; + return pool; +} +''', encoding="ascii") + compiled = subprocess.run([ + "c++", "-std=c++17", "-Os", "-fno-threadsafe-statics", + "-fno-rtti", "-fno-exceptions", "-I", str(ROOT / "src"), + "-c", str(source), "-o", str(obj), + ], capture_output=True, text=True) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + symbols = subprocess.run(["nm", "-S", "-C", "--defined-only", str(obj)], + capture_output=True, text=True) + self.assertEqual(symbols.returncode, 0, symbols.stdout + symbols.stderr) + match = re.search(r"^\S+\s+([0-9a-fA-F]+)\s+(\w) testPathStorage\(\)::pool$", + symbols.stdout, re.MULTILINE) + self.assertIsNotNone(match, symbols.stdout) + self.assertGreater(int(match[1], 16), 5000) + self.assertEqual(match[2].lower(), "b", symbols.stdout) + def test_default_policy_covers_high_contact_nrf52_without_full(self): cases = ( ({"NRF52_PLATFORM": 1, "MAX_CONTACTS": 350}, 1),