From 8586ef5f51fd1846377c997231d75ae69331b18e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 4 Oct 2026 01:54:01 -0700 Subject: [PATCH] Fix nRF52 loop stack allocation under LTO --- platformio.ini | 1 + scripts/nrf52_loop_stack_fix.py | 56 ++++++++++++++++++ src/Nrf52LoopStack.cpp | 4 +- test/test_nrf52_loop_stack_fix.py | 97 +++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 scripts/nrf52_loop_stack_fix.py create mode 100644 test/test_nrf52_loop_stack_fix.py diff --git a/platformio.ini b/platformio.ini index c732c2c9f..62afcb7a7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -160,6 +160,7 @@ extra_scripts = create-uf2.py pre:scripts/nrf52_internal_flash_fix.py pre:scripts/nrf52_ble_cccd_fix.py pre:scripts/nrf52_ble_dfu_fix.py + pre:scripts/nrf52_loop_stack_fix.py pre:scripts/nrf52_internal_bootloader_link.py ; Nordic's builder otherwise overwrites the LTO-aware archive tools. pre:scripts/nrf52_lto_toolchain.py diff --git a/scripts/nrf52_loop_stack_fix.py b/scripts/nrf52_loop_stack_fix.py new file mode 100644 index 000000000..e77165127 --- /dev/null +++ b/scripts/nrf52_loop_stack_fix.py @@ -0,0 +1,56 @@ +"""Make the nRF52 core's loop task use the configured stack even under LTO. + +GNU --wrap only redirects undefined references. The pinned core and FreeRTOS +share an LTO graph, so its xTaskCreate call can bind directly to the real +function and silently retain the framework's 4 KiB stack. Explicitly route +only the core loop-task call through MeshCore's existing stack policy in a +private build copy. Never edit the shared framework package. +""" + +from pathlib import Path + + +OLD_LOOP_CREATE = ' xTaskCreate(loop_task, "loop", LOOP_STACK_SZ, NULL, TASK_PRIO_LOW, &_loopHandle);' +LOOP_CREATE = OLD_LOOP_CREATE.replace("xTaskCreate(", "__wrap_xTaskCreate(", 1) +DECLARATION = """extern "C" BaseType_t __wrap_xTaskCreate( + TaskFunction_t task_code, const char* const task_name, + const configSTACK_DEPTH_TYPE stack_depth, void* const parameters, + UBaseType_t priority, TaskHandle_t* const created_task); + +""" +LOOP_STACK = "#define LOOP_STACK_SZ (256*4)" + + +def patched_source(source): + source = source.replace("\r\n", "\n") + if (source.count(LOOP_CREATE) == 1 and source.count(DECLARATION) == 1 + and OLD_LOOP_CREATE not in source and source.count(LOOP_STACK) == 1): + return source + if (source.count(OLD_LOOP_CREATE) != 1 or source.count(LOOP_STACK) != 1 + or LOOP_CREATE in source or DECLARATION in source): + raise RuntimeError( + "nRF52 loop stack fix: unrecognized core task creation; review the " + "framework update before building (shared SDK not modified)" + ) + return source.replace(LOOP_STACK, DECLARATION + LOOP_STACK).replace( + OLD_LOOP_CREATE, LOOP_CREATE) + + +def replace_core(build_env, node): + source = Path(node.srcnode().get_abspath()) + patched = patched_source(source.read_text(encoding="utf-8")) + destination = Path(build_env.subst("$BUILD_DIR")) / "patched-nrf52-core" / "main.cpp" + destination.parent.mkdir(parents=True, exist_ok=True) + if not destination.exists() or destination.read_text(encoding="utf-8") != patched: + destination.write_text(patched, encoding="utf-8") + print("nRF52 loop stack: explicit LTO-safe core task policy enabled") + return build_env.File(str(destination)) + + +def install(build_env): + build_env.AddBuildMiddleware(replace_core, "*cores*nRF5*main.cpp") + + +if "Import" in globals(): + Import("env") + install(env) diff --git a/src/Nrf52LoopStack.cpp b/src/Nrf52LoopStack.cpp index 5eefe4b28..6135cb9bd 100644 --- a/src/Nrf52LoopStack.cpp +++ b/src/Nrf52LoopStack.cpp @@ -19,7 +19,9 @@ extern "C" BaseType_t __real_xTaskCreate( // The Adafruit nRF52 core hard-codes the Arduino loop task to 1024 32-bit // words (4 KiB). Mesh packet verification and authenticated remote commands // can legitimately enter Ed25519 routines whose stack peaks do not fit there. -// Wrap task creation so only the framework's "loop" task receives 8 KiB; all +// nrf52_loop_stack_fix.py explicitly directs that core call here: GNU --wrap +// alone is bypassed when the caller and FreeRTOS share the LTO graph. +// Only the framework's "loop" task receives the configured 8 KiB; all // BLE, timer, callback, and application-created tasks retain their requested // sizes. extern "C" BaseType_t __wrap_xTaskCreate( diff --git a/test/test_nrf52_loop_stack_fix.py b/test/test_nrf52_loop_stack_fix.py new file mode 100644 index 000000000..a2cd72b12 --- /dev/null +++ b/test/test_nrf52_loop_stack_fix.py @@ -0,0 +1,97 @@ +"""Reproduce the LTO wrapper bypass and execute the patched core task policy.""" + +import importlib.util +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "nrf52_loop_stack_fix", ROOT / "scripts/nrf52_loop_stack_fix.py") +PATCH = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PATCH) + +RTOS = """#pragma once +#include +using BaseType_t = int; +using UBaseType_t = unsigned; +using TaskHandle_t = void*; +using TaskFunction_t = void (*)(void*); +using configSTACK_DEPTH_TYPE = uint16_t; +constexpr BaseType_t pdPASS = 1; +extern "C" BaseType_t xTaskCreate(TaskFunction_t, const char*, + configSTACK_DEPTH_TYPE, void*, UBaseType_t, TaskHandle_t*); +""" +CORE = """#include "Arduino.h" +#include +#include +#define LOOP_STACK_SZ (256*4) +static TaskHandle_t _loopHandle; +static void loop_task(void*) {} +static unsigned last_depth; +constexpr unsigned TASK_PRIO_LOW = 1; +extern "C" BaseType_t xTaskCreate(TaskFunction_t, const char*, + configSTACK_DEPTH_TYPE depth, void*, UBaseType_t, TaskHandle_t*) { + last_depth = depth; + return pdPASS; +} +int main() { + xTaskCreate(loop_task, "loop", LOOP_STACK_SZ, NULL, TASK_PRIO_LOW, &_loopHandle); + assert(last_depth == EXPECTED_LOOP_WORDS); + xTaskCreate(nullptr, "usbd", 200, nullptr, 3, nullptr); + assert(last_depth == 200); + xTaskCreate(nullptr, "callback", 768, nullptr, 1, nullptr); + assert(last_depth == 768); +} +""" + + +class Nrf52LoopStackFixTest(unittest.TestCase): + def test_patch_is_idempotent_and_rejects_changed_or_partial_core(self): + patched = PATCH.patched_source(CORE.replace("\n", "\r\n")) + self.assertEqual(PATCH.patched_source(patched), patched) + for source in ( + CORE.replace("LOOP_STACK_SZ (256*4)", "LOOP_STACK_SZ 4096"), + CORE.replace(PATCH.OLD_LOOP_CREATE, PATCH.LOOP_CREATE), + CORE.replace(PATCH.LOOP_STACK, PATCH.DECLARATION + PATCH.LOOP_STACK), + CORE + "\n" + PATCH.OLD_LOOP_CREATE, + ): + with self.subTest(source=source), self.assertRaises(RuntimeError): + PATCH.patched_source(source) + + @unittest.skipUnless(shutil.which("c++"), "native C++ compiler unavailable") + def test_lto_bypass_is_reproduced_then_fixed_without_resizing_other_tasks(self): + with tempfile.TemporaryDirectory(prefix="meshcore-loop-stack-") as temp: + temp = Path(temp) + (temp / "Arduino.h").write_text(RTOS) + for name in ("FreeRTOS.h", "task.h"): + (temp / name).write_text('#include "Arduino.h"\n') + core, binary = temp / "main.cpp", temp / "core" + for patched, configured_words in ((False, 2048), (True, 2048), (True, 3072)): + with self.subTest(patched=patched, configured_words=configured_words): + core.write_text(PATCH.patched_source(CORE) if patched else CORE) + expected = configured_words if patched else 1024 + built = subprocess.run([ + "c++", "-std=c++17", "-O2", "-flto", "-flto-partition=one", + "-fno-semantic-interposition", "-DNRF52_PLATFORM=1", + f"-DMESH_NRF52_LOOP_STACK_WORDS={configured_words}", + f"-DEXPECTED_LOOP_WORDS={expected}", "-I" + str(temp), + str(core), str(ROOT / "src/Nrf52LoopStack.cpp"), + "-Wl,--wrap=xTaskCreate", "-o", str(binary), + ], text=True, capture_output=True) + self.assertEqual(built.returncode, 0, built.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_every_nrf52_build_uses_private_core_patch(self): + config = (ROOT / "platformio.ini").read_text() + nrf = config.split("[nrf52_base]", 1)[1].split("\n[", 1)[0] + self.assertIn("pre:scripts/nrf52_loop_stack_fix.py", nrf) + self.assertEqual(config.count("pre:scripts/nrf52_loop_stack_fix.py"), 1) + + +if __name__ == "__main__": + unittest.main()