Shrink NimBLE footprint to prevent stack overflow

Reduce BLE stack memory usage from ~68KB to ~52KB by disabling unused NimBLE roles (peripheral, broadcaster), limiting connections to 1, and reducing buffer counts for ACL, HCI events, and ATT entries. Remove unused cloud components (esp_insights, esp_rainmaker) that require unavailable server certificates. These changes prevent stack collision with the render shed, eliminating restart flaps on the reader device.
This commit is contained in:
Justin Mitchell
2026-07-06 08:48:44 -04:00
parent 1c13913713
commit 7b6df60a54
8 changed files with 97 additions and 10 deletions
+6
View File
@@ -23,3 +23,9 @@ lib/EpdFont/scripts/output/
# (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out.
.claude/*
!.claude/skills/
/managed_components
/.dummy
dependencies.lock
sdkconfig.default
sdkconfig.defaults
CMakeLists.txt
+42
View File
@@ -60,6 +60,48 @@ board_build.flash_mode = dio
board_build.flash_size = 16MB
board_build.partitions = partitions.csv
; Shrink the NimBLE footprint for a 1-connection HID host moving 3-6 byte reports.
; Field-measured: begin() costs ~52 KB with these trims vs ~68 KB with the prebuilt
; framework defaults — and that 15 KB is the difference between the stack landing
; above the reader's render shed floor (stable coexistence) and below it (a
; guaranteed shed/restart flap). Rebuilds the Arduino core libs on first build
; (slower once, cached after; needs the CMake pin in platformio.local.ini on macOS).
custom_sdkconfig =
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=n
CONFIG_BT_NIMBLE_ROLE_BROADCASTER=n
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
CONFIG_BT_NIMBLE_MAX_CCCDS=2
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=23
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=6
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=6 ; was 24 x 320 B
CONFIG_BT_NIMBLE_ACL_BUF_COUNT=6 ; was 24 x 255 B
CONFIG_BT_NIMBLE_HCI_EVT_HI_BUF_COUNT=12 ; was 30 x 70 B; only scan bursts need many
; IDF 5.5 sizes the HCI transport pools under TRANSPORT_* names; pin both spellings.
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=6
CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=12
CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=4 ; was 64; a HID host never does prepared writes
CONFIG_BT_CTRL_BLE_MAX_ACT=3 ; was 6; need conn + scan + initiate only
CONFIG_BT_CTRL_BLE_ADV_REPORT_FLOW_CTRL_NUM=50 ; was 100; pairing-time scan only
CONFIG_BT_CTRL_ADV_DUP_FILT_MAX=10 ; was 30
; Keep the Arduino wrappers for the removed cloud components (below) out of
; the core source list; all other bundled libraries default to enabled.
CONFIG_ARDUINO_SELECTIVE_COMPILATION=y
CONFIG_ARDUINO_SELECTIVE_RainMaker=n
CONFIG_ARDUINO_SELECTIVE_Insights=n
; Drop unused cloud components from the core rebuild. esp_insights/rainmaker
; require embedded server certs the lib builder can't generate
; ("https_server.crt.S not found"); this firmware uses none of them.
custom_component_remove =
espressif/esp_insights
espressif/esp_rainmaker
espressif/esp_diagnostics
espressif/esp_diag_data_store
espressif/esp_schedule
espressif/esp_rcp_update
espressif/esp_secure_cert_mgr
espressif/cbor
extra_scripts =
pre:scripts/build_html.py
pre:scripts/gen_i18n.py
+6
View File
@@ -48,6 +48,12 @@ class Activity {
// covered by isReaderActivity()). The Bluetooth settings screen overrides this so
// pairing/scanning works there. Everywhere else BLE is torn down to free heap.
virtual bool keepsBluetoothAlive() const { return false; }
// True while the activity is doing (or has pending) heap-heavy work that must
// finish before the BLE stack (~52 KB) may start. The reader overrides this while
// a section build has catch-up work inside its window: restarting BLE mid-build
// just re-enters the heap state that forced the shed (observed as a 163 ms
// shed -> restart -> shed flap in the field).
virtual bool deferBluetoothStart() const { return false; }
// Ask the activity to make its next render a full ghost-cleanup (HALF) refresh rather
// than a fast/partial one. Used after drawing a transient popup over grayscale content
// (e.g. the "BT Connecting..." popup over a reader page) so it clears without ghosting.
+4
View File
@@ -271,6 +271,10 @@ bool ActivityManager::bluetoothShouldBeActive() const {
return std::any_of(stackActivities.begin(), stackActivities.end(), wants) || wants(currentActivity);
}
bool ActivityManager::bluetoothStartDeferred() const {
return currentActivity && currentActivity->deferBluetoothStart();
}
bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); }
ScreenshotInfo ActivityManager::getScreenshotInfo() const {
+6
View File
@@ -106,6 +106,12 @@ class ActivityManager {
// True if BLE should be resident for the current context: any reader (page-turner
// input) or the Bluetooth settings screen (pairing) is on the stack.
bool bluetoothShouldBeActive() const;
// True while the CURRENT activity is mid heap-heavy work that must complete before
// NimBLE may start (see Activity::deferBluetoothStart). Current only, not the
// stack: a reader stacked under a menu has its loop() paused, so its build never
// advances — a stack-wide check would hold BLE off for as long as the menu stays
// open.
bool bluetoothStartDeferred() const;
bool skipLoopDelay() const;
ScreenshotInfo getScreenshotInfo() const;
+1 -2
View File
@@ -292,8 +292,7 @@ void EpubReaderActivity::loop() {
// Skip while the render mutex is busy so we never delay a pending render; re-check
// isBuilding() under the lock since render() may have just finished it.
if (section && section->isBuilding() && !RenderLock::peek() &&
static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD &&
buildTickHeapGate()) {
static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD && buildTickHeapGate()) {
RenderLock lock;
// Re-check under the lock: render() (which also holds the RenderLock) may have finalized the
// build between the outer isBuilding() check and acquiring the lock here, in which case
+19 -7
View File
@@ -89,13 +89,16 @@ class EpubReaderActivity final : public Activity {
// background tick with the BLE stack resident). The tick is deferrable work:
// page-turn transients free up between turns and the build resumes; the render
// path still builds the page it actually needs regardless of this floor.
static constexpr size_t BACKGROUND_BUILD_MIN_FREE_HEAP = 32 * 1024;
// Fragmentation floor for the same gate: a tick passed the free-heap floor at
// 34.7 KB free but the largest block was ~11 KB, and a parse allocation inside the
// tick aborted anyway. Free heap says how much memory exists; maxAlloc says whether
// any single allocation can actually have it. 16 KB also keeps the advance-table
// batch path (16 KB scratch) viable during builds.
static constexpr size_t BACKGROUND_BUILD_MIN_MAX_ALLOC = 16 * 1024;
// Calibrated BETWEEN the measured states: steady reading with BLE resident runs at
// ~29.4 KB free / ~16.4 KB largest block (ticks are safe there — a 2-page parse
// transient is a few KB), while the field crash happened at 34.7 KB free with an
// ~11 KB largest block. A first cut at 32 KB/16 KB sat just ABOVE the healthy
// steady state, guaranteeing a pointless BLE shed the moment any build work was
// pending (the maxAlloc floor fired on a 12-byte shortfall).
static constexpr size_t BACKGROUND_BUILD_MIN_FREE_HEAP = 26 * 1024;
// Fragmentation floor for the same gate: free heap says how much memory exists;
// maxAlloc says whether any single allocation can actually have it.
static constexpr size_t BACKGROUND_BUILD_MIN_MAX_ALLOC = 13 * 1024;
// Gate for a background build tick: true when the heap can take parse allocations.
// When BLE is what's squeezing the heap, sheds it (build-pending deferral in the
// lifecycle then holds restarts off until the window is caught up) instead of
@@ -149,6 +152,15 @@ class EpubReaderActivity final : public Activity {
void loop() override;
void render(RenderLock&& lock) override;
bool isReaderActivity() const override { return true; }
// Hold BLE off only while the background build has catch-up work pending inside
// its window (same condition loop() uses to tick it). Gating on isBuilding() alone
// would hold BLE off for the rest of the chapter — a windowed build stays
// "building" until the reader walks the whole spine. Unlocked read, same pattern
// as the background-build check in loop().
bool deferBluetoothStart() const override {
return section && section->isBuilding() &&
static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD;
}
void requestGhostCleanup() override { pagesUntilFullRefresh = 1; }
ScreenshotInfo getScreenshotInfo() const override;
CrossPointPosition getCurrentPosition() const;
+13 -1
View File
@@ -511,6 +511,18 @@ void updateBluetoothLifecycle() {
static uint32_t recoveryTeardownMs = 0;
if (bleinput::lifecyclePaused()) recoveryTeardownMs = millis();
if (wanted && !BleHid.isRunning() && bleinput::lifecyclePaused()) return;
// Never start while the reader has build catch-up work pending: restarting into a
// pending build just re-enters the heap state that forced the shed (field: a
// 163 ms shed -> restart -> shed flap at a chapter watermark). Builds tick fast;
// the deferral clears itself within seconds and no popup is warranted.
if (wanted && !BleHid.isRunning() && activityManager.bluetoothStartDeferred()) {
static uint32_t lastBuildDeferLogMs = 0;
if (millis() - lastBuildDeferLogMs > 10000) {
lastBuildDeferLogMs = millis();
LOG_INF("BLELC", "start deferred: section build in progress heap=%u", ESP.getFreeHeap());
}
return;
}
static constexpr uint32_t BLE_RESTART_COOLDOWN_MS = 30 * 1000;
const bool inCooldown = recoveryTeardownMs != 0 && millis() - recoveryTeardownMs < BLE_RESTART_COOLDOWN_MS;
// Heap gate: NimBLE needs ~57 KB, and the section-build pre-flight needs 40 KB free
@@ -560,7 +572,7 @@ void updateBluetoothLifecycle() {
// observed in the field as the gate passing at 116 KB free and begin() then
// landing on 44 KB, where the session ground down and aborted. Defer to the next
// tick; the gate re-evaluates against the settled heap.
if (ESP.getFreeHeap() < startFloor || bleinput::lifecyclePaused()) {
if (ESP.getFreeHeap() < startFloor || activityManager.bluetoothStartDeferred() || bleinput::lifecyclePaused()) {
LOG_INF("BLELC", "start aborted under render lock: heap %u floor %u", ESP.getFreeHeap(), (unsigned)startFloor);
return;
}