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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user