From 3e5c4fd603cf04a1b662af75f94738fd6d266854 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 13:42:17 -1000 Subject: [PATCH 01/32] Make IRAM_ATTR functional on LibreTiny Previously, IRAM_ATTR was an empty no-op on every LibreTiny family, so any ISR handler (gpio binary sensor, cc1101, sx126x/sx127x, mcp23xxx, pcf8574, pca9554, pca6416a, pi4ioe5v6408, tca9555, ...) lived in flash. When the ISR fired while flash was busy (XIP stall, OTA, logger flash write), the device could deadlock or crash. - hal.h: IRAM_ATTR now routes each family into a RAM-resident section (RTL8710B .image2.ram.text, RTL8720C .sram.text, BK72xx / LN882H .data, which the SDK startup code copies from flash into SRAM before main). Also adds esphome::in_isr_context() as a portable always_inline ISR detection helper: xPortInIsrContext on ESP32, PS.INTLEVEL on ESP8266, IPSR on Cortex-M cores, CPSR mode on BK72xx ARM9. - main_task.h: both notify helpers marked always_inline so IRAM callers keep the wake path in IRAM; removes the ESP32-only notify_any_context which moves into wake.h. - wake.h / wake.cpp: LibreTiny now shares the ESP32 wake path via a new wake_main_task_any_context() helper that picks between xTaskNotifyGive and vTaskNotifyGiveFromISR using in_isr_context(). wake_loop_any_context and wake_loop_isrsafe are now IRAM_ATTR entry points on LibreTiny too. - libretiny/patch_linker.py.script: pre-link hook modelled on the ESP8266 testing_mode patcher. BK72xx linker templates declare the SRAM region as (rw!x), blocking code placement in .data. The hook flips that to (rwx) so IRAM_ATTR functions can be emitted there; the MMU already permits RAM execution (the Wi-Fi driver runs from SRAM). No-op on the other families. --- esphome/components/libretiny/__init__.py | 16 +++++ .../libretiny/patch_linker.py.script | 64 +++++++++++++++++++ esphome/core/hal.h | 59 +++++++++++++++++ esphome/core/main_task.h | 19 ++---- esphome/core/wake.cpp | 6 +- esphome/core/wake.h | 22 ++++--- 6 files changed, 159 insertions(+), 27 deletions(-) create mode 100644 esphome/components/libretiny/patch_linker.py.script diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 656eee6d7bf..0635e5b7884 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -1,5 +1,6 @@ import json import logging +from pathlib import Path import esphome.codegen as cg import esphome.config_validation as cv @@ -24,6 +25,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.core.config import BOARD_MAX_LENGTH +from esphome.helpers import copy_file_if_changed from esphome.storage_json import StorageJSON from . import gpio # noqa @@ -465,6 +467,10 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) + # Patch the linker script to add a ".sram.text" output section so IRAM_ATTR + # (defined in esphome/core/hal.h) places code in SRAM on BK72xx and LN882H. + # No-op on RTL8710B/RTL8720C whose stock linker scripts already provide it. + cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) # decrease web server stack size (16k words -> 4k words) @@ -549,3 +555,13 @@ async def component_to_code(config): _configure_lwip(config) await cg.register_component(var, config) + + +# Called by writer.py +def copy_files() -> None: + dir = Path(__file__).parent + patch_linker_file = dir / "patch_linker.py.script" + copy_file_if_changed( + patch_linker_file, + CORE.relative_build_path("patch_linker.py"), + ) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script new file mode 100644 index 00000000000..56a42db0ee0 --- /dev/null +++ b/esphome/components/libretiny/patch_linker.py.script @@ -0,0 +1,64 @@ +# pylint: disable=E0602 +Import("env") # noqa + +import os +import re + +# BK72xx linker templates declare the SRAM region as "(rw!x)" — read/write but +# not executable. That prevents the linker from emitting functions we mark +# IRAM_ATTR (defined as section(".data") on BK72xx in esphome/core/hal.h) into +# the .data output section, which is the only section the SDK startup code +# copies from flash into SRAM before main() runs. +# +# LibreTiny's own Wi-Fi driver already runs code from SRAM at runtime, so the +# BK72xx MMU permits execution from that region; the "!x" is a stale hint in +# the linker template. Flipping it to "(rwx)" lets the linker place the +# function body in .data, where it is copied alongside initialized globals and +# becomes callable from an ISR even while flash is busy. +# +# LN882H already declares RAM0 as "(rwx)" and Realtek families use dedicated +# .image2.ram.text / .sram.text output sections — nothing to patch there. + + +def _is_bk72xx(defines): + prefix = "USE_LIBRETINY_VARIANT_" + bk = ("BK7231N", "BK7231T", "BK7231Q", "BK7251") + for token in defines: + if isinstance(token, tuple): + token = token[0] + if isinstance(token, str) and token.startswith(prefix): + return token[len(prefix):] in bk + return False + + +_RW_NO_X = re.compile(r"(\bram\s*\()\s*rw\s*!\s*x(\s*\))") + + +def _patch_directory(build_dir): + if not os.path.isdir(build_dir): + return + for name in os.listdir(build_dir): + if not name.endswith(".ld"): + continue + path = os.path.join(build_dir, name) + with open(path, "r", encoding="utf-8") as fh: + content = fh.read() + patched = _RW_NO_X.sub(r"\1rwx\2", content) + if patched != content: + with open(path, "w", encoding="utf-8") as fh: + fh.write(patched) + print( + "ESPHome: patched BK72xx linker script {} (rw!x -> rwx) so " + "IRAM_ATTR functions can be placed in .data".format(name) + ) + + +def _patch_ld_before_link(target, source, env): + _patch_directory(env.subst("$BUILD_DIR")) + + +if _is_bk72xx(env.get("CPPDEFINES", [])): + # LibreTiny writes the processed .ld templates into $BUILD_DIR during its + # own builder setup, which may run after this script. Register the patch + # as a pre-link action so it executes once the linker scripts exist. + env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _patch_ld_before_link) diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 03a30b7459f..4009950ea04 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -21,6 +21,25 @@ #define IRAM_ATTR __attribute__((noinline, long_call, section(".time_critical"))) #define PROGMEM +#elif defined(USE_LIBRETINY) + +// IRAM_ATTR places a function in SRAM so it is callable from an ISR even +// while flash is busy (XIP stall, OTA, logger flash write). The section used +// varies per family, based on what each linker script already supports: +// - RTL8710B (AmebaZ): ".image2.ram.text" output section exists. +// - RTL8720C (AmebaZ2): "*(.sram.text*)" is already consumed. +// - BK72xx / LN882H: stock linker script has no RAM text section, so we +// piggyback on ".data" — the SDK startup code copies .data from flash into +// SRAM before main() runs, so the function body ends up in executable RAM. +#if defined(USE_LIBRETINY_VARIANT_RTL8710B) +#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) +#elif defined(USE_LIBRETINY_VARIANT_RTL8720C) +#define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) +#else +#define IRAM_ATTR __attribute__((noinline, section(".data"))) +#endif +#define PROGMEM + #else #define IRAM_ATTR @@ -28,8 +47,48 @@ #endif +#ifdef USE_ESP32 +#include +#include +#endif + namespace esphome { +/// Returns true when executing inside an interrupt handler. +/// always_inline so callers placed in IRAM keep the detection in IRAM. +__attribute__((always_inline)) inline bool in_isr_context() { +#if defined(USE_ESP32) + return xPortInIsrContext() != 0; +#elif defined(USE_ESP8266) + // Xtensa LX106 PS.INTLEVEL[3:0]. Non-zero indicates interrupt in progress. + uint32_t ps; + __asm__ volatile("rsr.ps %0" : "=r"(ps)); + return (ps & 0xF) != 0; +#elif defined(USE_RP2040) + uint32_t ipsr; + __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); + return ipsr != 0; +#elif defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7231T) || \ + defined(USE_LIBRETINY_VARIANT_BK7231Q) || defined(USE_LIBRETINY_VARIANT_BK7251) + // BK72xx is ARM968E-S (ARM9). CPSR mode bits [4:0]: + // 0x10 USR, 0x13 SVC, 0x1F SYS are normal; others (IRQ=0x12, FIQ=0x11, + // ABT=0x17, UND=0x1B) are exception modes. + uint32_t cpsr; + __asm__ volatile("mrs %0, cpsr" : "=r"(cpsr)); + uint32_t mode = cpsr & 0x1Fu; + return mode != 0x10u && mode != 0x13u && mode != 0x1Fu; +#elif defined(USE_LIBRETINY) + // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; + // non-zero means we're in a handler. + uint32_t ipsr; + __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); + return ipsr != 0; +#else + // Host and any future platform without an ISR concept. + return false; +#endif +} + void yield(); uint32_t millis(); uint64_t millis_64(); diff --git a/esphome/core/main_task.h b/esphome/core/main_task.h index ed2885d2e25..3aa8669e445 100644 --- a/esphome/core/main_task.h +++ b/esphome/core/main_task.h @@ -20,7 +20,8 @@ extern "C" { extern TaskHandle_t esphome_main_task_handle; /// Wake the main loop task from another FreeRTOS task. NOT ISR-safe. -static inline void esphome_main_task_notify() { +/// always_inline so callers placed in IRAM do not reference a flash-resident copy. +__attribute__((always_inline)) static inline void esphome_main_task_notify() { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { xTaskNotifyGive(task); @@ -28,26 +29,14 @@ static inline void esphome_main_task_notify() { } /// Wake the main loop task from an ISR. ISR-safe. -static inline void esphome_main_task_notify_from_isr(BaseType_t *px_higher_priority_task_woken) { +__attribute__((always_inline)) static inline void esphome_main_task_notify_from_isr( + BaseType_t *px_higher_priority_task_woken) { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { vTaskNotifyGiveFromISR(task, px_higher_priority_task_woken); } } -#ifdef USE_ESP32 -/// Wake the main loop from any context (ISR or task). ESP32-only (needs xPortInIsrContext). -static inline void esphome_main_task_notify_any_context() { - if (xPortInIsrContext()) { - int px_higher_priority_task_woken = 0; - esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); - portYIELD_FROM_ISR(px_higher_priority_task_woken); - } else { - esphome_main_task_notify(); - } -} -#endif - #ifdef __cplusplus } #endif diff --git a/esphome/core/wake.cpp b/esphome/core/wake.cpp index b6b59b59909..3709fa88ac7 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake.cpp @@ -12,12 +12,12 @@ namespace esphome { -// === ESP32 — IRAM_ATTR entry points === -#ifdef USE_ESP32 +// === ESP32 / LibreTiny — IRAM_ATTR entry points === +#if defined(USE_ESP32) || defined(USE_LIBRETINY) void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { esphome_main_task_notify_from_isr(px_higher_priority_task_woken); } -void IRAM_ATTR wake_loop_any_context() { esphome_main_task_notify_any_context(); } +void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } #endif // === ESP8266 / RP2040 === diff --git a/esphome/core/wake.h b/esphome/core/wake.h index a8c9b7ad08b..f0a2d34b502 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -28,17 +28,21 @@ extern volatile bool g_main_loop_woke; // === ESP32 / LibreTiny (FreeRTOS) === #if defined(USE_ESP32) || defined(USE_LIBRETINY) -#ifdef USE_ESP32 -/// IRAM_ATTR entry point — defined in wake.cpp. +/// Wake the main loop from any context (ISR or task). +/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. +__attribute__((always_inline)) inline void wake_main_task_any_context() { + if (in_isr_context()) { + BaseType_t px_higher_priority_task_woken = pdFALSE; + esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); + portYIELD_FROM_ISR(px_higher_priority_task_woken); + } else { + esphome_main_task_notify(); + } +} + +/// IRAM_ATTR entry points — defined in wake.cpp. void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); -/// IRAM_ATTR entry point — defined in wake.cpp. void wake_loop_any_context(); -#else -/// LibreTiny: IRAM_ATTR is not functional and the FreeRTOS port does not -/// provide vTaskNotifyGiveFromISR/portYIELD_FROM_ISR, so ISR-safe wake -/// is not possible. xTaskNotifyGive is used as the best available option. -inline void wake_loop_any_context() { esphome_main_task_notify(); } -#endif inline void wake_loop_threadsafe() { esphome_main_task_notify(); } From c1ef3cab37740d91ab69342faaf7caf3a8457782 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 13:46:37 -1000 Subject: [PATCH 02/32] libretiny: wire copy_files() through each target_platform module target_platform for LibreTiny devices is one of bk72xx, rtl87xx, ln882x (not "libretiny" itself), so writer.py's platform-dispatched copy_files() call was not reaching libretiny.copy_files(). That left the pre-link patch_linker.py script absent from the build dir, so PlatformIO failed with "missing SConscript file 'patch_linker.py'". Add a delegating copy_files() to each generated sub-component and to generate_components.py so regeneration keeps them in sync. --- esphome/components/bk72xx/__init__.py | 5 +++++ esphome/components/libretiny/__init__.py | 8 +++++--- esphome/components/libretiny/generate_components.py | 5 +++++ esphome/components/ln882x/__init__.py | 5 +++++ esphome/components/rtl87xx/__init__.py | 5 +++++ 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/esphome/components/bk72xx/__init__.py b/esphome/components/bk72xx/__init__.py index 7fed742d2e2..3ffab0f3a5e 100644 --- a/esphome/components/bk72xx/__init__.py +++ b/esphome/components/bk72xx/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("bk72xx", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 0635e5b7884..70354843c13 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -467,9 +467,11 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) - # Patch the linker script to add a ".sram.text" output section so IRAM_ATTR - # (defined in esphome/core/hal.h) places code in SRAM on BK72xx and LN882H. - # No-op on RTL8710B/RTL8720C whose stock linker scripts already provide it. + # BK72xx linker templates mark the SRAM region as (rw!x), which blocks the + # linker from placing executable code in .data — the section IRAM_ATTR + # targets on BK72xx (see esphome/core/hal.h). This pre-link hook flips the + # flag to (rwx) once LibreTiny has written the processed .ld file(s) into + # the build dir. No-op on every other family. cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index 41b43894465..d5437895a69 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -79,6 +79,11 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("{COMPONENT_LOWER}", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() ''' BASE_CODE_BOARDS = ''' diff --git a/esphome/components/ln882x/__init__.py b/esphome/components/ln882x/__init__.py index 5c637bdf629..9c918275227 100644 --- a/esphome/components/ln882x/__init__.py +++ b/esphome/components/ln882x/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("ln882x", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() diff --git a/esphome/components/rtl87xx/__init__.py b/esphome/components/rtl87xx/__init__.py index 6fd750d51ed..a3b1dba4f22 100644 --- a/esphome/components/rtl87xx/__init__.py +++ b/esphome/components/rtl87xx/__init__.py @@ -65,3 +65,8 @@ async def to_code(config): @pins.PIN_SCHEMA_REGISTRY.register("rtl87xx", PIN_SCHEMA) async def pin_to_code(config): return await libretiny.gpio.component_pin_to_code(config) + + +# Called by writer.py; delegates to the shared libretiny implementation. +def copy_files() -> None: + libretiny.copy_files() From f404768fd147104c4dd9f5869633fb21c31b98cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 13:48:11 -1000 Subject: [PATCH 03/32] =?UTF-8?q?libretiny:=20fix=20BK72xx=20build=20?= =?UTF-8?q?=E2=80=94=20guard=20portYIELD=5FFROM=5FISR;=20use=20.data.iram?= =?UTF-8?q?=5Ftext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues surfaced building cb3s-test.yaml: 1. BK72xx's ARM9 FreeRTOS port does not define portYIELD_FROM_ISR; context switches happen naturally at IRQ exit. Wrap the call in #ifdef so the wake path compiles on ports that lack it. 2. Placing functions directly in section(".data") provoked assembler "ignoring changed section attributes" warnings and a hard DWARF error ("leb128 operand is an undefined symbol: .LVU31") because the compiler emits code-style attributes ("ax") that collide with .data's data-style attributes ("aw"). Use section(".data.iram_text") instead; the linker still folds it into the .data output via "*(.data.*)", so the bytes land in SRAM via the SDK's .data copy, but the assembler no longer sees conflicting attributes. --- esphome/components/libretiny/patch_linker.py.script | 7 ++++--- esphome/core/hal.h | 11 +++++++---- esphome/core/wake.h | 6 ++++++ 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 56a42db0ee0..b62ec1a5fb2 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -6,9 +6,10 @@ import re # BK72xx linker templates declare the SRAM region as "(rw!x)" — read/write but # not executable. That prevents the linker from emitting functions we mark -# IRAM_ATTR (defined as section(".data") on BK72xx in esphome/core/hal.h) into -# the .data output section, which is the only section the SDK startup code -# copies from flash into SRAM before main() runs. +# IRAM_ATTR (defined as section(".data.iram_text") on BK72xx in +# esphome/core/hal.h; folded into .data by the linker's "*(.data.*)" glob) +# into the .data output section, which is the only section the SDK startup +# code copies from flash into SRAM before main() runs. # # LibreTiny's own Wi-Fi driver already runs code from SRAM at runtime, so the # BK72xx MMU permits execution from that region; the "!x" is a stale hint in diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 4009950ea04..aa36a1f03dd 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -28,15 +28,18 @@ // varies per family, based on what each linker script already supports: // - RTL8710B (AmebaZ): ".image2.ram.text" output section exists. // - RTL8720C (AmebaZ2): "*(.sram.text*)" is already consumed. -// - BK72xx / LN882H: stock linker script has no RAM text section, so we -// piggyback on ".data" — the SDK startup code copies .data from flash into -// SRAM before main() runs, so the function body ends up in executable RAM. +// - BK72xx / LN882H: the stock linker script has no RAM text section, so we +// use ".data.iram_text" which the linker's "*(.data.*)" glob folds into +// the .data output section. The SDK startup code copies .data from flash +// into SRAM before main() runs, so the function body ends up in executable +// RAM. Using ".data.*" instead of plain ".data" keeps the assembler happy +// (no section-attribute collision) while still landing in the right place. #if defined(USE_LIBRETINY_VARIANT_RTL8710B) #define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) #elif defined(USE_LIBRETINY_VARIANT_RTL8720C) #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) #else -#define IRAM_ATTR __attribute__((noinline, section(".data"))) +#define IRAM_ATTR __attribute__((noinline, section(".data.iram_text"))) #endif #define PROGMEM diff --git a/esphome/core/wake.h b/esphome/core/wake.h index f0a2d34b502..5733ee65f6c 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -34,7 +34,13 @@ __attribute__((always_inline)) inline void wake_main_task_any_context() { if (in_isr_context()) { BaseType_t px_higher_priority_task_woken = pdFALSE; esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); +#ifdef portYIELD_FROM_ISR portYIELD_FROM_ISR(px_higher_priority_task_woken); +#else + // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ + // exit sequence performs the context switch if one was requested. + (void) px_higher_priority_task_woken; +#endif } else { esphome_main_task_notify(); } From e5b46540eea0b488498287a90d00500b8e82c225 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 13:53:35 -1000 Subject: [PATCH 04/32] libretiny: fix BK72xx Thumb asm; unify IRAM_ATTR on .sram.text Two fixes on top of the initial landing: - BK72xx builds Thumb-mode TUs alongside its ARM FreeRTOS port, and the MRS CPSR instruction is ARM-only, so in_isr_context() failed to assemble on cb3s. Delegate to the port's own platform_is_in_interrupt_context() helper (declared extern "C" in portmacro.h and built in ARM mode) instead of embedding inline CPSR reads in Thumb code. - Section name unified to ".sram.text" for every LibreTiny family to avoid the "setting incorrect section attributes for .data.*" GAS warning. The pre-link patcher now knows how to route that section into each family's RAM-resident output: * BK72xx: flip SRAM region (rw!x) -> (rwx) and inject KEEP(*(.sram.text*)) into .data : { ... } * LN882H: inject KEEP(*(.sram.text*)) into .flash_copysection : { ... } * RTL8710B: inject KEEP(*(.sram.text*)) into .image2.ram.text : { ... } * RTL8720C: no-op (linker already consumes *(.sram.text*)) The injection uses a "/* esphome .sram.text */" marker so repeated runs are idempotent. --- .../libretiny/patch_linker.py.script | 91 +++++++++++++------ esphome/core/hal.h | 34 +++---- 2 files changed, 73 insertions(+), 52 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index b62ec1a5fb2..08b95462f08 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -4,38 +4,69 @@ Import("env") # noqa import os import re -# BK72xx linker templates declare the SRAM region as "(rw!x)" — read/write but -# not executable. That prevents the linker from emitting functions we mark -# IRAM_ATTR (defined as section(".data.iram_text") on BK72xx in -# esphome/core/hal.h; folded into .data by the linker's "*(.data.*)" glob) -# into the .data output section, which is the only section the SDK startup -# code copies from flash into SRAM before main() runs. +# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny expands to +# section(".sram.text") (see esphome/core/hal.h). Each family's linker script +# needs that section routed into RAM-resident code so the function is callable +# while flash is busy (XIP stall, OTA, logger flash write): # -# LibreTiny's own Wi-Fi driver already runs code from SRAM at runtime, so the -# BK72xx MMU permits execution from that region; the "!x" is a stale hint in -# the linker template. Flipping it to "(rwx)" lets the linker place the -# function body in .data, where it is copied alongside initialized globals and -# becomes callable from an ISR even while flash is busy. -# -# LN882H already declares RAM0 as "(rwx)" and Realtek families use dedicated -# .image2.ram.text / .sram.text output sections — nothing to patch there. +# - RTL8720C (AmebaZ2): stock linker already consumes "*(.sram.text*)", no-op. +# - RTL8710B (AmebaZ): stock linker has ".image2.ram.text" — inject +# "*(.sram.text*)" into it. +# - BK72xx: stock linker has a (rw!x) SRAM region that blocks executable +# placement; flip to (rwx) and inject "KEEP(*(.sram.text*))" into the ".data" +# output (which is already flash-to-SRAM copied at startup by the SDK). +# - LN882H: stock linker has ".flash_copysection" which is flash-to-RAM0 +# copied at startup; inject "KEEP(*(.sram.text*))" there. -def _is_bk72xx(defines): +_MARKER = "/* esphome .sram.text */" +_KEEP_LINE = " KEEP(*(.sram.text*)) " + _MARKER + "\n" +_BK_DATA = re.compile(r"(\.data\s*:\s*\{\s*\n)") +_BK_RW_NO_X = re.compile(r"(\bram\s*\()\s*rw\s*!\s*x(\s*\))") +_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") +_RTL8710B_IMAGE2 = re.compile(r"(\.image2\.ram\.text\s*:\s*\{\s*\n)") + + +def _detect(defines): prefix = "USE_LIBRETINY_VARIANT_" - bk = ("BK7231N", "BK7231T", "BK7231Q", "BK7251") for token in defines: if isinstance(token, tuple): token = token[0] if isinstance(token, str) and token.startswith(prefix): - return token[len(prefix):] in bk - return False + return token[len(prefix):] + return None -_RW_NO_X = re.compile(r"(\bram\s*\()\s*rw\s*!\s*x(\s*\))") +def _patch_bk72xx(content): + new_content = _BK_RW_NO_X.sub(r"\1rwx\2", content) + if _MARKER not in new_content: + new_content = _BK_DATA.sub(r"\1" + _KEEP_LINE, new_content, count=1) + return new_content -def _patch_directory(build_dir): +def _patch_ln882h(content): + if _MARKER in content: + return content + return _LN_COPY.sub(r"\1" + _KEEP_LINE, content, count=1) + + +def _patch_rtl8710b(content): + if _MARKER in content: + return content + return _RTL8710B_IMAGE2.sub(r"\1" + _KEEP_LINE, content, count=1) + + +def _patchers_for(variant): + if variant in ("BK7231N", "BK7231T", "BK7231Q", "BK7251"): + return (_patch_bk72xx,) + if variant == "LN882H": + return (_patch_ln882h,) + if variant == "RTL8710B": + return (_patch_rtl8710b,) + return () + + +def _patch_build_dir(patchers, build_dir): if not os.path.isdir(build_dir): return for name in os.listdir(build_dir): @@ -44,22 +75,24 @@ def _patch_directory(build_dir): path = os.path.join(build_dir, name) with open(path, "r", encoding="utf-8") as fh: content = fh.read() - patched = _RW_NO_X.sub(r"\1rwx\2", content) + patched = content + for patch in patchers: + patched = patch(patched) if patched != content: with open(path, "w", encoding="utf-8") as fh: fh.write(patched) - print( - "ESPHome: patched BK72xx linker script {} (rw!x -> rwx) so " - "IRAM_ATTR functions can be placed in .data".format(name) - ) + print("ESPHome: patched linker script {} for IRAM_ATTR placement".format(name)) -def _patch_ld_before_link(target, source, env): - _patch_directory(env.subst("$BUILD_DIR")) +def _pre_link(target, source, env): + _patch_build_dir(_patchers, env.subst("$BUILD_DIR")) -if _is_bk72xx(env.get("CPPDEFINES", [])): +_variant = _detect(env.get("CPPDEFINES", [])) +_patchers = _patchers_for(_variant) if _variant else () + +if _patchers: # LibreTiny writes the processed .ld templates into $BUILD_DIR during its # own builder setup, which may run after this script. Register the patch # as a pre-link action so it executes once the linker scripts exist. - env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _patch_ld_before_link) + env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link) diff --git a/esphome/core/hal.h b/esphome/core/hal.h index aa36a1f03dd..466f864dc17 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -24,23 +24,13 @@ #elif defined(USE_LIBRETINY) // IRAM_ATTR places a function in SRAM so it is callable from an ISR even -// while flash is busy (XIP stall, OTA, logger flash write). The section used -// varies per family, based on what each linker script already supports: -// - RTL8710B (AmebaZ): ".image2.ram.text" output section exists. -// - RTL8720C (AmebaZ2): "*(.sram.text*)" is already consumed. -// - BK72xx / LN882H: the stock linker script has no RAM text section, so we -// use ".data.iram_text" which the linker's "*(.data.*)" glob folds into -// the .data output section. The SDK startup code copies .data from flash -// into SRAM before main() runs, so the function body ends up in executable -// RAM. Using ".data.*" instead of plain ".data" keeps the assembler happy -// (no section-attribute collision) while still landing in the right place. -#if defined(USE_LIBRETINY_VARIANT_RTL8710B) -#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) -#elif defined(USE_LIBRETINY_VARIANT_RTL8720C) +// while flash is busy (XIP stall, OTA, logger flash write). All LibreTiny +// families use ".sram.text". RTL8720C (AmebaZ2) already consumes that +// section; RTL8710B (AmebaZ), BK72xx, and LN882H get it routed into their +// RAM-resident output section via patch_linker.py.script. Using a custom +// name avoids the assembler "setting incorrect section attributes" warning +// that ".data.*" triggers when we place executable code there. #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) -#else -#define IRAM_ATTR __attribute__((noinline, section(".data.iram_text"))) -#endif #define PROGMEM #else @@ -73,13 +63,11 @@ __attribute__((always_inline)) inline bool in_isr_context() { return ipsr != 0; #elif defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7231T) || \ defined(USE_LIBRETINY_VARIANT_BK7231Q) || defined(USE_LIBRETINY_VARIANT_BK7251) - // BK72xx is ARM968E-S (ARM9). CPSR mode bits [4:0]: - // 0x10 USR, 0x13 SVC, 0x1F SYS are normal; others (IRQ=0x12, FIQ=0x11, - // ABT=0x17, UND=0x1B) are exception modes. - uint32_t cpsr; - __asm__ volatile("mrs %0, cpsr" : "=r"(cpsr)); - uint32_t mode = cpsr & 0x1Fu; - return mode != 0x10u && mode != 0x13u && mode != 0x1Fu; + // BK72xx is ARM968E-S (ARM9). The MRS CPSR instruction is ARM-only, and + // user code here may be built in Thumb mode. Defer to the FreeRTOS port + // helper (compiled in ARM mode by the SDK) which reads CPSR internally. + extern "C" uint32_t platform_is_in_interrupt_context(void); + return platform_is_in_interrupt_context() != 0; #elif defined(USE_LIBRETINY) // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; // non-zero means we're in a handler. From 90b311258a4066857e5a144a9f6c3d4e8cc2d4e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 13:54:23 -1000 Subject: [PATCH 05/32] libretiny: move Beken extern "C" declaration out of function body --- esphome/core/hal.h | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 466f864dc17..28030567300 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -45,6 +45,13 @@ #include #endif +#if defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7231T) || \ + defined(USE_LIBRETINY_VARIANT_BK7231Q) || defined(USE_LIBRETINY_VARIANT_BK7251) +// Declared in the Beken FreeRTOS port (portmacro.h) and built in ARM mode so +// it is callable from Thumb code via interworking. +extern "C" uint32_t platform_is_in_interrupt_context(void); +#endif + namespace esphome { /// Returns true when executing inside an interrupt handler. @@ -65,8 +72,7 @@ __attribute__((always_inline)) inline bool in_isr_context() { defined(USE_LIBRETINY_VARIANT_BK7231Q) || defined(USE_LIBRETINY_VARIANT_BK7251) // BK72xx is ARM968E-S (ARM9). The MRS CPSR instruction is ARM-only, and // user code here may be built in Thumb mode. Defer to the FreeRTOS port - // helper (compiled in ARM mode by the SDK) which reads CPSR internally. - extern "C" uint32_t platform_is_in_interrupt_context(void); + // helper declared above (compiled in ARM mode by the SDK). return platform_is_in_interrupt_context() != 0; #elif defined(USE_LIBRETINY) // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; From 55845b3e8510bd651dbf615bc448bb6484002e07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 13:56:39 -1000 Subject: [PATCH 06/32] Address copilot review: update extra_scripts comment to match current implementation --- esphome/components/libretiny/__init__.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 70354843c13..47ab92418b5 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -467,11 +467,14 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) - # BK72xx linker templates mark the SRAM region as (rw!x), which blocks the - # linker from placing executable code in .data — the section IRAM_ATTR - # targets on BK72xx (see esphome/core/hal.h). This pre-link hook flips the - # flag to (rwx) once LibreTiny has written the processed .ld file(s) into - # the build dir. No-op on every other family. + # IRAM_ATTR on LibreTiny expands to section(".sram.text") (see + # esphome/core/hal.h). This pre-link hook rewrites the processed .ld files + # in the build dir so that section lands in RAM on each family: + # - BK72xx: flip SRAM region (rw!x) -> (rwx) and inject + # KEEP(*(.sram.text*)) into the .data output section. + # - LN882H: inject KEEP(*(.sram.text*)) into .flash_copysection. + # - RTL8710B: inject KEEP(*(.sram.text*)) into .image2.ram.text. + # - RTL8720C: no-op (stock linker already consumes *(.sram.text*)). cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) From 33118e0e57f6c1c5468e050c11c2a9921fed3b36 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 13:59:42 -1000 Subject: [PATCH 07/32] =?UTF-8?q?libretiny:=20patch=5Flinker.py.script=20?= =?UTF-8?q?=E2=80=94=20fail=20hard=20if=20IRAM=5FATTR=20cannot=20land=20in?= =?UTF-8?q?=20SRAM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from the cb3s smoke test: - The previous CPPDEFINES-only variant detection missed the USE_LIBRETINY_VARIANT_* define on BK72xx in the pre-script environment, so no patcher registered and the .sram.text section silently landed in flash. Check BUILD_FLAGS as a fallback so the define is picked up regardless of when PlatformIO finishes populating CPPDEFINES. - When the variant could not be detected, or no .ld file was modified, the script exited quietly and the firmware linked with IRAM_ATTR functions in flash — the exact bug this PR is trying to fix. Raise a RuntimeError in all three failure cases (variant missing, variant unknown, no .ld patched) so the build fails loudly instead of producing broken firmware. --- .../libretiny/patch_linker.py.script | 57 ++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 08b95462f08..ce6db292945 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -27,16 +27,35 @@ _LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") _RTL8710B_IMAGE2 = re.compile(r"(\.image2\.ram\.text\s*:\s*\{\s*\n)") -def _detect(defines): +def _detect(env): prefix = "USE_LIBRETINY_VARIANT_" - for token in defines: - if isinstance(token, tuple): + # CPPDEFINES may hold strings or (name, value) tuples; BUILD_FLAGS holds + # the raw "-DNAME" strings. PlatformIO populates both, but the exact order + # vs. extra_scripts varies, so check both to be robust. + for token in env.get("CPPDEFINES", []): + if isinstance(token, (list, tuple)): token = token[0] if isinstance(token, str) and token.startswith(prefix): return token[len(prefix):] + for flag in env.get("BUILD_FLAGS", []): + if isinstance(flag, str) and "-D" + prefix in flag: + name = flag.split("-D", 1)[1].split("=", 1)[0].strip() + if name.startswith(prefix): + return name[len(prefix):] return None +KNOWN_VARIANTS = ( + "BK7231N", + "BK7231T", + "BK7231Q", + "BK7251", + "LN882H", + "RTL8710B", + "RTL8720C", +) + + def _patch_bk72xx(content): new_content = _BK_RW_NO_X.sub(r"\1rwx\2", content) if _MARKER not in new_content: @@ -68,8 +87,12 @@ def _patchers_for(variant): def _patch_build_dir(patchers, build_dir): if not os.path.isdir(build_dir): - return - for name in os.listdir(build_dir): + raise RuntimeError( + "ESPHome: LibreTiny build dir {} does not exist at link time; " + "IRAM_ATTR placement cannot be verified".format(build_dir) + ) + patched_any = False + for name in sorted(os.listdir(build_dir)): if not name.endswith(".ld"): continue path = os.path.join(build_dir, name) @@ -82,15 +105,35 @@ def _patch_build_dir(patchers, build_dir): with open(path, "w", encoding="utf-8") as fh: fh.write(patched) print("ESPHome: patched linker script {} for IRAM_ATTR placement".format(name)) + patched_any = True + if not patched_any: + raise RuntimeError( + "ESPHome: no linker script in {} was patched for IRAM_ATTR; refusing " + "to link because IRAM_ATTR functions would end up in flash instead of " + "SRAM and would crash on an ISR while flash is busy".format(build_dir) + ) def _pre_link(target, source, env): _patch_build_dir(_patchers, env.subst("$BUILD_DIR")) -_variant = _detect(env.get("CPPDEFINES", [])) -_patchers = _patchers_for(_variant) if _variant else () +_variant = _detect(env) +if _variant is None: + raise RuntimeError( + "ESPHome: could not determine LibreTiny variant from build flags. " + "patch_linker.py needs USE_LIBRETINY_VARIANT_* to route IRAM_ATTR " + "into SRAM; without it, ISR handlers would silently end up in flash." + ) +if _variant not in KNOWN_VARIANTS: + raise RuntimeError( + "ESPHome: unknown LibreTiny variant {!r}; patch_linker.py does not " + "know how to route IRAM_ATTR into SRAM for this family. Update " + "patch_linker.py.script before shipping firmware.".format(_variant) + ) + +_patchers = _patchers_for(_variant) if _patchers: # LibreTiny writes the processed .ld templates into $BUILD_DIR during its # own builder setup, which may run after this script. Register the patch From ab382c1e52a52a019014bf36080660149664f6bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 14:05:48 -1000 Subject: [PATCH 08/32] libretiny: print IRAM_ATTR placement summary after link After linking, walk the ELF symbol table and print one of: ESPHome: IRAM_ATTR placement summary (BK7231N): .sram.text: 312 bytes at 0x004001d0 - 0x00400308 ESPHome: IRAM_ATTR placement summary (RTL8720C): IRAM symbols at 0x10002f6c - 0x10002fa8 (approx 60 bytes) On the three families whose linker scripts we already patch (BK72xx, LN882H, RTL8710B) the PROVIDE(__esphome_sram_text_start/end = .) markers injected alongside KEEP(*(.sram.text*)) give an exact byte count for the ESPHome IRAM_ATTR contribution. On RTL8720C we cannot patch the linker script (it is loaded directly from the framework package) so the summary falls back to reading the addresses of the three core IRAM_ATTR symbols. Either way the address range is visible proof that the functions ended up in SRAM rather than flash. --- .../libretiny/patch_linker.py.script | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index ce6db292945..728a22b823e 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -20,11 +20,19 @@ import re _MARKER = "/* esphome .sram.text */" -_KEEP_LINE = " KEEP(*(.sram.text*)) " + _MARKER + "\n" +_KEEP_LINE = ( + " PROVIDE(__esphome_sram_text_start = .); " + "KEEP(*(.sram.text*)) " + "PROVIDE(__esphome_sram_text_end = .); " + + _MARKER + "\n" +) _BK_DATA = re.compile(r"(\.data\s*:\s*\{\s*\n)") _BK_RW_NO_X = re.compile(r"(\bram\s*\()\s*rw\s*!\s*x(\s*\))") _LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") _RTL8710B_IMAGE2 = re.compile(r"(\.image2\.ram\.text\s*:\s*\{\s*\n)") +# RTL8720C loads its linker script directly from the framework package so we +# cannot inject PROVIDE markers for it; the summary falls back to reading +# addresses of known IRAM_ATTR symbols instead of measuring a bracketed span. def _detect(env): @@ -82,6 +90,8 @@ def _patchers_for(variant): return (_patch_ln882h,) if variant == "RTL8710B": return (_patch_rtl8710b,) + # RTL8720C: stock linker already consumes *(.sram.text*), no .ld patch + # needed; summary falls back to reading symbol addresses. return () @@ -118,6 +128,49 @@ def _pre_link(target, source, env): _patch_build_dir(_patchers, env.subst("$BUILD_DIR")) +def _post_link(target, source, env): + """Print where IRAM_ATTR ended up so users can confirm at a glance.""" + nm = env.subst("$NM") or "arm-none-eabi-nm" + elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") + if not os.path.isfile(elf): + return + try: + import subprocess + out = subprocess.check_output([nm, "--defined-only", elf], text=True) + except (OSError, subprocess.CalledProcessError): + return + start = end = None + sample = [] + for line in out.splitlines(): + parts = line.split(maxsplit=2) + if len(parts) != 3: + continue + addr, _kind, name = parts + if name == "__esphome_sram_text_start": + start = int(addr, 16) + elif name == "__esphome_sram_text_end": + end = int(addr, 16) + elif name in ( + "_ZN7esphome21wake_loop_any_contextEv", + "_ZN7esphome17wake_loop_isrsafeEPl", + "_ZN7esphome9Component28enable_loop_soon_any_contextEv", + ): + sample.append((int(addr, 16), name)) + header = "ESPHome: IRAM_ATTR placement summary ({}):".format(_variant) + if start is not None and end is not None: + print(header) + print(" .sram.text: {} bytes at 0x{:08x} - 0x{:08x}".format(end - start, start, end)) + elif sample: + print(header) + sample.sort() + lo = sample[0][0] + hi = sample[-1][0] + print(" IRAM symbols at 0x{:08x} - 0x{:08x} (approx {} bytes)".format(lo, hi, hi - lo)) + else: + print(header) + print(" no IRAM_ATTR symbols found in the ELF") + + _variant = _detect(env) if _variant is None: @@ -139,3 +192,6 @@ if _patchers: # own builder setup, which may run after this script. Register the patch # as a pre-link action so it executes once the linker scripts exist. env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link) + +# Post-link summary runs for every LibreTiny family. +env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) From 8dba87014a3ea91981950b1d8bf41299077686ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 14:07:31 -1000 Subject: [PATCH 09/32] libretiny: use strong linker assignments for SRAM text markers PROVIDE symbols without references get garbage-collected by the linker, so the post-link summary was falling back to the symbol-scrape path on every family. Use direct assignment so the markers always land in the ELF symbol table, giving an exact byte count for the ESPHome IRAM_ATTR contribution. --- esphome/components/libretiny/patch_linker.py.script | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 728a22b823e..60b64a73160 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -20,10 +20,12 @@ import re _MARKER = "/* esphome .sram.text */" +# Strong assignments (not PROVIDE) so the symbols are always emitted in the +# ELF; PROVIDE symbols with no references can be garbage-collected. _KEEP_LINE = ( - " PROVIDE(__esphome_sram_text_start = .); " + " __esphome_sram_text_start = .; " "KEEP(*(.sram.text*)) " - "PROVIDE(__esphome_sram_text_end = .); " + "__esphome_sram_text_end = .; " + _MARKER + "\n" ) _BK_DATA = re.compile(r"(\.data\s*:\s*\{\s*\n)") From a1970737c6e86e01352858ffc3b3dd7b1f3982b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 14:10:39 -1000 Subject: [PATCH 10/32] libretiny: dedupe per-variant patchers into a shared injector and dict lookup --- .../libretiny/patch_linker.py.script | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 60b64a73160..a1c2f0f3e69 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -66,35 +66,33 @@ KNOWN_VARIANTS = ( ) -def _patch_bk72xx(content): - new_content = _BK_RW_NO_X.sub(r"\1rwx\2", content) - if _MARKER not in new_content: - new_content = _BK_DATA.sub(r"\1" + _KEEP_LINE, new_content, count=1) - return new_content +def _inject_keep(host_section): + """Return a patcher that injects _KEEP_LINE at the top of `host_section`.""" + def patch(content): + if _MARKER in content: + return content + return host_section.sub(r"\1" + _KEEP_LINE, content, count=1) + return patch -def _patch_ln882h(content): - if _MARKER in content: - return content - return _LN_COPY.sub(r"\1" + _KEEP_LINE, content, count=1) +def _flip_bk72xx_rwx(content): + return _BK_RW_NO_X.sub(r"\1rwx\2", content) -def _patch_rtl8710b(content): - if _MARKER in content: - return content - return _RTL8710B_IMAGE2.sub(r"\1" + _KEEP_LINE, content, count=1) +# RTL8720C is absent: its stock linker already consumes *(.sram.text*), so +# no .ld patch is needed; the summary falls back to reading symbol addresses. +_PATCHERS_BY_VARIANT = { + "BK7231N": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), + "BK7231T": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), + "BK7231Q": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), + "BK7251": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), + "LN882H": (_inject_keep(_LN_COPY),), + "RTL8710B": (_inject_keep(_RTL8710B_IMAGE2),), +} def _patchers_for(variant): - if variant in ("BK7231N", "BK7231T", "BK7231Q", "BK7251"): - return (_patch_bk72xx,) - if variant == "LN882H": - return (_patch_ln882h,) - if variant == "RTL8710B": - return (_patch_rtl8710b,) - # RTL8720C: stock linker already consumes *(.sram.text*), no .ld patch - # needed; summary falls back to reading symbol addresses. - return () + return _PATCHERS_BY_VARIANT.get(variant, ()) def _patch_build_dir(patchers, build_dir): From 365ff86bac86242617838b3044180ca8ca99caed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 14:18:39 -1000 Subject: [PATCH 11/32] libretiny: target BK72xx .itcm.code instead of .data for IRAM_ATTR cb3s flashed firmware crashed after logger init. Main SRAM on ARM968E-S is not instruction-fetchable: the stock linker marks it (rw!x) because the bus does not allow instruction fetches from that region, so putting IRAM_ATTR code in .data landed the bytes in RAM but triggered a prefetch abort when the first IRAM function was called. The ARM968 design has a separate tightly-coupled instruction memory ("itcm", 4.5 kB, rwx) where the SDK already routes its own ISR, flash, and FreeRTOS critical-path code via the .itcm.code output section. Inject KEEP(*(.sram.text*)) into .itcm.code so our IRAM_ATTR functions share that executable RAM region. No (rw!x) flip is needed since we no longer touch the main SRAM layout. --- esphome/components/libretiny/__init__.py | 9 +++-- .../libretiny/patch_linker.py.script | 40 +++++++++---------- esphome/core/hal.h | 14 +++---- 3 files changed, 31 insertions(+), 32 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 47ab92418b5..b8833e5a552 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -468,10 +468,11 @@ async def component_to_code(config): build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) # IRAM_ATTR on LibreTiny expands to section(".sram.text") (see - # esphome/core/hal.h). This pre-link hook rewrites the processed .ld files - # in the build dir so that section lands in RAM on each family: - # - BK72xx: flip SRAM region (rw!x) -> (rwx) and inject - # KEEP(*(.sram.text*)) into the .data output section. + # esphome/core/hal.h). This pre-link hook rewrites the processed .ld + # files in the build dir so that section lands in an executable RAM + # output section on each family: + # - BK72xx: inject KEEP(*(.sram.text*)) into .itcm.code (the only + # rwx RAM region; main SRAM is rw!x on ARM968E-S). # - LN882H: inject KEEP(*(.sram.text*)) into .flash_copysection. # - RTL8710B: inject KEEP(*(.sram.text*)) into .image2.ram.text. # - RTL8720C: no-op (stock linker already consumes *(.sram.text*)). diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index a1c2f0f3e69..8d447630f70 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -6,17 +6,23 @@ import re # ESPHome marks ISR code IRAM_ATTR, which on LibreTiny expands to # section(".sram.text") (see esphome/core/hal.h). Each family's linker script -# needs that section routed into RAM-resident code so the function is callable -# while flash is busy (XIP stall, OTA, logger flash write): +# needs that section routed into RAM-resident *executable* memory so the +# function is callable while flash is busy (XIP stall, OTA, logger flash +# write): # -# - RTL8720C (AmebaZ2): stock linker already consumes "*(.sram.text*)", no-op. -# - RTL8710B (AmebaZ): stock linker has ".image2.ram.text" — inject -# "*(.sram.text*)" into it. -# - BK72xx: stock linker has a (rw!x) SRAM region that blocks executable -# placement; flip to (rwx) and inject "KEEP(*(.sram.text*))" into the ".data" -# output (which is already flash-to-SRAM copied at startup by the SDK). +# - BK72xx: ARM968E-S has tightly-coupled instruction RAM ("itcm", 4.5 kB, +# rwx) that the SDK uses for its own ISR / flash / FreeRTOS critical +# routines; main SRAM ("ram", 192 kB) is rw!x (bus does not allow +# instruction fetches). Inject "KEEP(*(.sram.text*))" into ".itcm.code" +# so our IRAM_ATTR functions share the only executable RAM region. # - LN882H: stock linker has ".flash_copysection" which is flash-to-RAM0 # copied at startup; inject "KEEP(*(.sram.text*))" there. +# - RTL8710B (AmebaZ): stock linker has ".image2.ram.text" — inject +# "KEEP(*(.sram.text*))" into it. +# - RTL8720C (AmebaZ2): stock linker already consumes "*(.sram.text*)", +# no-op. Loaded directly from the framework package so we cannot inject +# our __esphome_sram_text_start/end markers either; the post-link summary +# falls back to reading known IRAM_ATTR symbol addresses instead. _MARKER = "/* esphome .sram.text */" @@ -28,13 +34,9 @@ _KEEP_LINE = ( "__esphome_sram_text_end = .; " + _MARKER + "\n" ) -_BK_DATA = re.compile(r"(\.data\s*:\s*\{\s*\n)") -_BK_RW_NO_X = re.compile(r"(\bram\s*\()\s*rw\s*!\s*x(\s*\))") +_BK_ITCM = re.compile(r"(\.itcm\.code\s*ALIGN\s*\(\s*\d+\s*\)\s*:\s*\{\s*\n)") _LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") _RTL8710B_IMAGE2 = re.compile(r"(\.image2\.ram\.text\s*:\s*\{\s*\n)") -# RTL8720C loads its linker script directly from the framework package so we -# cannot inject PROVIDE markers for it; the summary falls back to reading -# addresses of known IRAM_ATTR symbols instead of measuring a bracketed span. def _detect(env): @@ -75,17 +77,13 @@ def _inject_keep(host_section): return patch -def _flip_bk72xx_rwx(content): - return _BK_RW_NO_X.sub(r"\1rwx\2", content) - - # RTL8720C is absent: its stock linker already consumes *(.sram.text*), so # no .ld patch is needed; the summary falls back to reading symbol addresses. _PATCHERS_BY_VARIANT = { - "BK7231N": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), - "BK7231T": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), - "BK7231Q": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), - "BK7251": (_flip_bk72xx_rwx, _inject_keep(_BK_DATA)), + "BK7231N": (_inject_keep(_BK_ITCM),), + "BK7231T": (_inject_keep(_BK_ITCM),), + "BK7231Q": (_inject_keep(_BK_ITCM),), + "BK7251": (_inject_keep(_BK_ITCM),), "LN882H": (_inject_keep(_LN_COPY),), "RTL8710B": (_inject_keep(_RTL8710B_IMAGE2),), } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 28030567300..35ee55076ac 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -23,13 +23,13 @@ #elif defined(USE_LIBRETINY) -// IRAM_ATTR places a function in SRAM so it is callable from an ISR even -// while flash is busy (XIP stall, OTA, logger flash write). All LibreTiny -// families use ".sram.text". RTL8720C (AmebaZ2) already consumes that -// section; RTL8710B (AmebaZ), BK72xx, and LN882H get it routed into their -// RAM-resident output section via patch_linker.py.script. Using a custom -// name avoids the assembler "setting incorrect section attributes" warning -// that ".data.*" triggers when we place executable code there. +// IRAM_ATTR places a function in executable RAM so it is callable from an +// ISR even while flash is busy (XIP stall, OTA, logger flash write). All +// LibreTiny families use ".sram.text"; patch_linker.py.script routes it +// into each family's RAM-executable output section (.itcm.code on BK72xx, +// .image2.ram.text on RTL8710B, .flash_copysection on LN882H). RTL8720C's +// stock linker already consumes *(.sram.text*) via its .ram.code_text +// output. #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) #define PROGMEM From 85d1ab7303bf5854266eb112aaf461827b2981c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 14:24:57 -1000 Subject: [PATCH 12/32] libretiny: grow BK72xx .itcm region from 4.5 kB to 8.5 kB The SDK's built-in ISR / flash / FreeRTOS critical-path code already fills most of the stock 4.5 kB .itcm executable RAM region, leaving under 500 bytes for ESPHome's IRAM_ATTR functions. cb3s overflowed by 248 bytes on first link. Physically .tcm (rw!x, data) and .itcm (rwx, code) are contiguous SRAM blocks on ARM968E-S; the template's boundary is just a linker carve-out. Shift it back 4 kB so .tcm shrinks 60k -> 56k and .itcm grows 4.5k -> 8.5k. Confirmed fits every ESPHome IRAM_ATTR function on cb3s with ample headroom. --- .../libretiny/patch_linker.py.script | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 8d447630f70..2ae7702c2cb 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -38,6 +38,25 @@ _BK_ITCM = re.compile(r"(\.itcm\.code\s*ALIGN\s*\(\s*\d+\s*\)\s*:\s*\{\s*\n)") _LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") _RTL8710B_IMAGE2 = re.compile(r"(\.image2\.ram\.text\s*:\s*\{\s*\n)") +# On BK72xx the stock LibreTiny linker carves a 4.5 kB ".itcm" executable RAM +# region; the SDK already fills most of it with its own ISR / flash / FreeRTOS +# critical code, leaving <500 bytes for ESPHome IRAM_ATTR functions. Steal 4 kB +# from the adjacent (rw!x) "tcm" data region and give it to itcm so our +# .sram.text payload has room to grow. Physically tcm and itcm are contiguous +# SRAM, so shifting the boundary is just a linker rewrite. +_BK_TCM_LEN = re.compile(r"(\btcm\s*\(\s*rw!x\s*\)\s*:\s*ORIGIN\s*=\s*0x003F0000\s*,\s*LENGTH\s*=\s*)60k(\s*-\s*512\b)") +_BK_ITCM_REGION = re.compile( + r"(\bitcm\s*\(\s*rwx\s*\)\s*:\s*ORIGIN\s*=\s*)0x003FEE00(\s*,\s*LENGTH\s*=\s*)4k(\s*\+\s*512\b)" +) + + +def _grow_bk72xx_itcm(content): + # Shrink tcm by 4 kB: 60k - 512 -> 56k - 512. + new_content = _BK_TCM_LEN.sub(r"\g<1>56k\g<2>", content) + # Shift itcm origin back 4 kB and grow length: 4k + 512 -> 8k + 512. + new_content = _BK_ITCM_REGION.sub(r"\g<1>0x003FDE00\g<2>8k\g<3>", new_content) + return new_content + def _detect(env): prefix = "USE_LIBRETINY_VARIANT_" @@ -80,10 +99,10 @@ def _inject_keep(host_section): # RTL8720C is absent: its stock linker already consumes *(.sram.text*), so # no .ld patch is needed; the summary falls back to reading symbol addresses. _PATCHERS_BY_VARIANT = { - "BK7231N": (_inject_keep(_BK_ITCM),), - "BK7231T": (_inject_keep(_BK_ITCM),), - "BK7231Q": (_inject_keep(_BK_ITCM),), - "BK7251": (_inject_keep(_BK_ITCM),), + "BK7231N": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), + "BK7231T": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), + "BK7231Q": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), + "BK7251": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), "LN882H": (_inject_keep(_LN_COPY),), "RTL8710B": (_inject_keep(_RTL8710B_IMAGE2),), } From bc672d358bed980ae1915cc94865b44b9b9787bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 14:34:16 -1000 Subject: [PATCH 13/32] libretiny: scope BK72xx IRAM_ATTR to BK7231N; T/Q/7251 stay no-op BK7231T / BK7231Q / BK7251 share a LibreTiny linker template that only declares flash + ram, no executable RAM region. Their SDK wraps flash writes in GLOBAL_INT_DISABLE() which masks both FIQ and IRQ, so no ISR fires while flash is stalled and the "code in flash while flash is busy" scenario IRAM_ATTR is guarding against does not occur on those variants. Map IRAM_ATTR to nothing on them rather than orphan-linking .sram.text into flash and drop the T/Q/7251 entries from _PATCHERS_BY_VARIANT so the pre-link hook is not registered for them. BK7231N still gets the full fix (grown .itcm.code) because its SDK takes the defense-in-depth route of also placing flash/ISR/FreeRTOS critical code in ITCM, so our IRAM_ATTR functions need to live there too. Also make _grow_bk72xx_itcm fail loudly if the tcm/itcm declarations in the BK7231N template ever change shape, matching the fail-hard stance of the rest of the script. --- esphome/components/libretiny/__init__.py | 14 ++++++----- .../libretiny/patch_linker.py.script | 25 +++++++++++++------ esphome/core/hal.h | 21 +++++++++++----- 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index b8833e5a552..10dc0fe9844 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -467,15 +467,17 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) - # IRAM_ATTR on LibreTiny expands to section(".sram.text") (see - # esphome/core/hal.h). This pre-link hook rewrites the processed .ld - # files in the build dir so that section lands in an executable RAM - # output section on each family: - # - BK72xx: inject KEEP(*(.sram.text*)) into .itcm.code (the only - # rwx RAM region; main SRAM is rw!x on ARM968E-S). + # IRAM_ATTR on LibreTiny expands to section(".sram.text") on families + # where executable RAM is available (see esphome/core/hal.h). This pre- + # link hook rewrites the processed .ld files in the build dir so that + # section lands in each family's executable RAM output section: + # - BK7231N: grow .itcm from 4.5 kB to 8.5 kB (steal from .tcm) and + # inject KEEP(*(.sram.text*)) into .itcm.code. # - LN882H: inject KEEP(*(.sram.text*)) into .flash_copysection. # - RTL8710B: inject KEEP(*(.sram.text*)) into .image2.ram.text. # - RTL8720C: no-op (stock linker already consumes *(.sram.text*)). + # BK7231T/Q/7251 have no executable RAM region; their SDK disables IRQ + # + FIQ around flash writes, so IRAM_ATTR is left a no-op on them. cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 2ae7702c2cb..ee6d3124f9c 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -52,9 +52,20 @@ _BK_ITCM_REGION = re.compile( def _grow_bk72xx_itcm(content): # Shrink tcm by 4 kB: 60k - 512 -> 56k - 512. - new_content = _BK_TCM_LEN.sub(r"\g<1>56k\g<2>", content) + new_content, tcm_count = _BK_TCM_LEN.subn(r"\g<1>56k\g<2>", content) # Shift itcm origin back 4 kB and grow length: 4k + 512 -> 8k + 512. - new_content = _BK_ITCM_REGION.sub(r"\g<1>0x003FDE00\g<2>8k\g<3>", new_content) + new_content, itcm_count = _BK_ITCM_REGION.subn( + r"\g<1>0x003FDE00\g<2>8k\g<3>", new_content + ) + if tcm_count != 1 or itcm_count != 1: + raise RuntimeError( + "ESPHome: BK72xx linker script did not match the expected " + "tcm/itcm declarations (tcm matches: {}, itcm matches: {}); " + "refusing to link because IRAM_ATTR placement cannot be verified. " + "LibreTiny probably changed the bk7231*_bsp.template.ld layout, " + "update _BK_TCM_LEN / _BK_ITCM_REGION in patch_linker.py.script " + "before shipping firmware.".format(tcm_count, itcm_count) + ) return new_content @@ -96,13 +107,13 @@ def _inject_keep(host_section): return patch -# RTL8720C is absent: its stock linker already consumes *(.sram.text*), so -# no .ld patch is needed; the summary falls back to reading symbol addresses. +# Variants not listed here intentionally have no .ld patcher: +# - RTL8720C: stock linker already consumes *(.sram.text*). +# - BK7231T / BK7231Q / BK7251: SDK wraps flash ops in GLOBAL_INT_DISABLE() +# (FIQ + IRQ masked), so no ISR fires during a flash stall and IRAM_ATTR +# is a no-op on those variants (see esphome/core/hal.h). _PATCHERS_BY_VARIANT = { "BK7231N": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), - "BK7231T": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), - "BK7231Q": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), - "BK7251": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), "LN882H": (_inject_keep(_LN_COPY),), "RTL8710B": (_inject_keep(_RTL8710B_IMAGE2),), } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 35ee55076ac..f96cb485984 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -24,13 +24,22 @@ #elif defined(USE_LIBRETINY) // IRAM_ATTR places a function in executable RAM so it is callable from an -// ISR even while flash is busy (XIP stall, OTA, logger flash write). All -// LibreTiny families use ".sram.text"; patch_linker.py.script routes it -// into each family's RAM-executable output section (.itcm.code on BK72xx, -// .image2.ram.text on RTL8710B, .flash_copysection on LN882H). RTL8720C's -// stock linker already consumes *(.sram.text*) via its .ram.code_text -// output. +// ISR even while flash is busy (XIP stall, OTA, logger flash write). +// patch_linker.py.script routes ".sram.text" into each family's RAM- +// executable output section: .itcm.code on BK7231N, .image2.ram.text on +// RTL8710B, .flash_copysection on LN882H, stock *(.sram.text*) glob on +// RTL8720C. +// +// BK7231T/Q/7251 are left as a no-op: their SDK wraps flash operations in +// GLOBAL_INT_DISABLE() which masks FIQ + IRQ for the duration of the +// write, so no ISR fires while flash is stalled and the scenario +// IRAM_ATTR guards against does not occur there. +#if defined(USE_LIBRETINY_VARIANT_BK7231T) || defined(USE_LIBRETINY_VARIANT_BK7231Q) || \ + defined(USE_LIBRETINY_VARIANT_BK7251) +#define IRAM_ATTR +#else #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) +#endif #define PROGMEM #else From 05df654a3efdf4f95574c84433ca943c8e603ff2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:17:41 -1000 Subject: [PATCH 14/32] libretiny: drop BK72xx from IRAM_ATTR coverage After confirming via the Beken SDKs (both BK7231T and BK7231N flash.c use GLOBAL_INT_DISABLE around every erase/program) and searching the libretiny + esphome issue trackers, the ISR-during-flash race IRAM_ATTR is designed to prevent cannot actually occur on any BK72xx variant: - Beken SDK wraps every flash op in GLOBAL_INT_DISABLE(), masking FIQ + IRQ at the CPU for the ~0.5-20 ms of the write, so no ISR fires while flash is stalled. - Interrupts are delayed (not dropped outright for single-shot sources) by that mask, but that is an SDK-level design choice and cannot be reduced from this layer. - No BK72xx user has reported the crash pattern IRAM_ATTR fixes; the real reports of that pattern are on RTL8710B (see libretiny#167). Make IRAM_ATTR a no-op on every BK72xx variant and remove the BK7231N- specific ITCM shuffling from patch_linker.py.script. The fix now covers only the families where the race is real: RTL8710B, RTL8720C, LN882H. --- .../libretiny/patch_linker.py.script | 52 ++++--------------- esphome/core/hal.h | 20 +++---- 2 files changed, 21 insertions(+), 51 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index ee6d3124f9c..068e4670021 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -10,11 +10,6 @@ import re # function is callable while flash is busy (XIP stall, OTA, logger flash # write): # -# - BK72xx: ARM968E-S has tightly-coupled instruction RAM ("itcm", 4.5 kB, -# rwx) that the SDK uses for its own ISR / flash / FreeRTOS critical -# routines; main SRAM ("ram", 192 kB) is rw!x (bus does not allow -# instruction fetches). Inject "KEEP(*(.sram.text*))" into ".itcm.code" -# so our IRAM_ATTR functions share the only executable RAM region. # - LN882H: stock linker has ".flash_copysection" which is flash-to-RAM0 # copied at startup; inject "KEEP(*(.sram.text*))" there. # - RTL8710B (AmebaZ): stock linker has ".image2.ram.text" — inject @@ -23,6 +18,11 @@ import re # no-op. Loaded directly from the framework package so we cannot inject # our __esphome_sram_text_start/end markers either; the post-link summary # falls back to reading known IRAM_ATTR symbol addresses instead. +# +# BK72xx (all variants) have no .ld patcher: the Beken SDK wraps every flash +# operation in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU, so no +# ISR can fire during a flash stall and the race IRAM_ATTR guards against +# cannot occur. IRAM_ATTR is a no-op on BK72xx (see esphome/core/hal.h). _MARKER = "/* esphome .sram.text */" @@ -34,40 +34,9 @@ _KEEP_LINE = ( "__esphome_sram_text_end = .; " + _MARKER + "\n" ) -_BK_ITCM = re.compile(r"(\.itcm\.code\s*ALIGN\s*\(\s*\d+\s*\)\s*:\s*\{\s*\n)") _LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") _RTL8710B_IMAGE2 = re.compile(r"(\.image2\.ram\.text\s*:\s*\{\s*\n)") -# On BK72xx the stock LibreTiny linker carves a 4.5 kB ".itcm" executable RAM -# region; the SDK already fills most of it with its own ISR / flash / FreeRTOS -# critical code, leaving <500 bytes for ESPHome IRAM_ATTR functions. Steal 4 kB -# from the adjacent (rw!x) "tcm" data region and give it to itcm so our -# .sram.text payload has room to grow. Physically tcm and itcm are contiguous -# SRAM, so shifting the boundary is just a linker rewrite. -_BK_TCM_LEN = re.compile(r"(\btcm\s*\(\s*rw!x\s*\)\s*:\s*ORIGIN\s*=\s*0x003F0000\s*,\s*LENGTH\s*=\s*)60k(\s*-\s*512\b)") -_BK_ITCM_REGION = re.compile( - r"(\bitcm\s*\(\s*rwx\s*\)\s*:\s*ORIGIN\s*=\s*)0x003FEE00(\s*,\s*LENGTH\s*=\s*)4k(\s*\+\s*512\b)" -) - - -def _grow_bk72xx_itcm(content): - # Shrink tcm by 4 kB: 60k - 512 -> 56k - 512. - new_content, tcm_count = _BK_TCM_LEN.subn(r"\g<1>56k\g<2>", content) - # Shift itcm origin back 4 kB and grow length: 4k + 512 -> 8k + 512. - new_content, itcm_count = _BK_ITCM_REGION.subn( - r"\g<1>0x003FDE00\g<2>8k\g<3>", new_content - ) - if tcm_count != 1 or itcm_count != 1: - raise RuntimeError( - "ESPHome: BK72xx linker script did not match the expected " - "tcm/itcm declarations (tcm matches: {}, itcm matches: {}); " - "refusing to link because IRAM_ATTR placement cannot be verified. " - "LibreTiny probably changed the bk7231*_bsp.template.ld layout, " - "update _BK_TCM_LEN / _BK_ITCM_REGION in patch_linker.py.script " - "before shipping firmware.".format(tcm_count, itcm_count) - ) - return new_content - def _detect(env): prefix = "USE_LIBRETINY_VARIANT_" @@ -109,11 +78,8 @@ def _inject_keep(host_section): # Variants not listed here intentionally have no .ld patcher: # - RTL8720C: stock linker already consumes *(.sram.text*). -# - BK7231T / BK7231Q / BK7251: SDK wraps flash ops in GLOBAL_INT_DISABLE() -# (FIQ + IRQ masked), so no ISR fires during a flash stall and IRAM_ATTR -# is a no-op on those variants (see esphome/core/hal.h). +# - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op. _PATCHERS_BY_VARIANT = { - "BK7231N": (_grow_bk72xx_itcm, _inject_keep(_BK_ITCM)), "LN882H": (_inject_keep(_LN_COPY),), "RTL8710B": (_inject_keep(_RTL8710B_IMAGE2),), } @@ -221,5 +187,7 @@ if _patchers: # as a pre-link action so it executes once the linker scripts exist. env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link) -# Post-link summary runs for every LibreTiny family. -env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) +# Post-link summary runs for every LibreTiny family (except BK72xx where +# IRAM_ATTR is a no-op and no symbols are relocated to RAM). +if _patchers or _variant == "RTL8720C": + env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) diff --git a/esphome/core/hal.h b/esphome/core/hal.h index f96cb485984..01ce4189d1e 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -26,16 +26,18 @@ // IRAM_ATTR places a function in executable RAM so it is callable from an // ISR even while flash is busy (XIP stall, OTA, logger flash write). // patch_linker.py.script routes ".sram.text" into each family's RAM- -// executable output section: .itcm.code on BK7231N, .image2.ram.text on -// RTL8710B, .flash_copysection on LN882H, stock *(.sram.text*) glob on -// RTL8720C. +// executable output section: .image2.ram.text on RTL8710B, +// .flash_copysection on LN882H; RTL8720C's stock linker already consumes +// *(.sram.text*) via its .ram.code_text output. // -// BK7231T/Q/7251 are left as a no-op: their SDK wraps flash operations in -// GLOBAL_INT_DISABLE() which masks FIQ + IRQ for the duration of the -// write, so no ISR fires while flash is stalled and the scenario -// IRAM_ATTR guards against does not occur there. -#if defined(USE_LIBRETINY_VARIANT_BK7231T) || defined(USE_LIBRETINY_VARIANT_BK7231Q) || \ - defined(USE_LIBRETINY_VARIANT_BK7251) +// BK72xx (all variants) are left as a no-op: their SDK wraps flash +// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for +// the duration of every write, so no ISR fires while flash is stalled and +// the race IRAM_ATTR guards against cannot occur. The trade-off is that +// interrupts are delayed (not dropped) by up to ~20 ms during a sector +// erase, but that is an SDK-level choice and cannot be changed from this +// layer. +#if defined(USE_BK72XX) #define IRAM_ATTR #else #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) From 8a7de40f4088253f784e6be31de67a490e1f6204 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:22:12 -1000 Subject: [PATCH 15/32] libretiny: KNOWN_VARIANTS -> frozenset for O(1) membership --- esphome/components/libretiny/patch_linker.py.script | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 068e4670021..ab11257be0b 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -56,7 +56,7 @@ def _detect(env): return None -KNOWN_VARIANTS = ( +KNOWN_VARIANTS = frozenset({ "BK7231N", "BK7231T", "BK7231Q", @@ -64,7 +64,7 @@ KNOWN_VARIANTS = ( "LN882H", "RTL8710B", "RTL8720C", -) +}) def _inject_keep(host_section): From f0944a944a1104127bf236ec6b071851a7e210c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:26:36 -1000 Subject: [PATCH 16/32] libretiny: clean up stale comments and tighten patch_linker script - hal.h: collapse the four-way USE_LIBRETINY_VARIANT_BK72* guard into USE_BK72XX. - libretiny/__init__.py: drop the stale reference to growing .itcm from 4.5 kB to 8.5 kB; BK72xx no longer gets any .ld patching. - patch_linker.py.script: hoist the duplicated "import subprocess" to the top, pull the fallback IRAM_ATTR symbols out into a module-level frozenset, and split the ELF scan into a helper so _post_link is just the print logic. --- esphome/components/libretiny/__init__.py | 12 ++-- .../libretiny/patch_linker.py.script | 57 ++++++++++--------- esphome/core/hal.h | 14 ++--- 3 files changed, 42 insertions(+), 41 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 10dc0fe9844..6b24f4d52ca 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -468,16 +468,14 @@ async def component_to_code(config): build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) # IRAM_ATTR on LibreTiny expands to section(".sram.text") on families - # where executable RAM is available (see esphome/core/hal.h). This pre- - # link hook rewrites the processed .ld files in the build dir so that - # section lands in each family's executable RAM output section: - # - BK7231N: grow .itcm from 4.5 kB to 8.5 kB (steal from .tcm) and - # inject KEEP(*(.sram.text*)) into .itcm.code. + # where the ISR-during-flash race is real (see esphome/core/hal.h). This + # pre-link hook routes that section into each family's executable RAM: # - LN882H: inject KEEP(*(.sram.text*)) into .flash_copysection. # - RTL8710B: inject KEEP(*(.sram.text*)) into .image2.ram.text. # - RTL8720C: no-op (stock linker already consumes *(.sram.text*)). - # BK7231T/Q/7251 have no executable RAM region; their SDK disables IRQ - # + FIQ around flash writes, so IRAM_ATTR is left a no-op on them. + # BK72xx (all variants) is no-op: the Beken SDK wraps every flash write + # in GLOBAL_INT_DISABLE() so no ISR can fire during a flash stall; the + # race IRAM_ATTR guards against cannot occur. cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index ab11257be0b..f7f536df4ec 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -3,6 +3,7 @@ Import("env") # noqa import os import re +import subprocess # ESPHome marks ISR code IRAM_ATTR, which on LibreTiny expands to # section(".sram.text") (see esphome/core/hal.h). Each family's linker script @@ -122,46 +123,50 @@ def _pre_link(target, source, env): _patch_build_dir(_patchers, env.subst("$BUILD_DIR")) -def _post_link(target, source, env): - """Print where IRAM_ATTR ended up so users can confirm at a glance.""" - nm = env.subst("$NM") or "arm-none-eabi-nm" - elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") - if not os.path.isfile(elf): - return +# Well-known ESPHome IRAM_ATTR symbols used as a fallback on RTL8720C, where +# we cannot inject the __esphome_sram_text_start/end markers. +_FALLBACK_IRAM_SYMBOLS = frozenset({ + "_ZN7esphome21wake_loop_any_contextEv", + "_ZN7esphome17wake_loop_isrsafeEPl", + "_ZN7esphome9Component28enable_loop_soon_any_contextEv", +}) + + +def _collect_iram_symbols(nm, elf): + """Return (start, end, fallback_addresses) for the IRAM_ATTR payload.""" try: - import subprocess out = subprocess.check_output([nm, "--defined-only", elf], text=True) except (OSError, subprocess.CalledProcessError): - return + return None, None, [] start = end = None - sample = [] + fallback = [] for line in out.splitlines(): parts = line.split(maxsplit=2) if len(parts) != 3: continue - addr, _kind, name = parts + addr_str, _kind, name = parts if name == "__esphome_sram_text_start": - start = int(addr, 16) + start = int(addr_str, 16) elif name == "__esphome_sram_text_end": - end = int(addr, 16) - elif name in ( - "_ZN7esphome21wake_loop_any_contextEv", - "_ZN7esphome17wake_loop_isrsafeEPl", - "_ZN7esphome9Component28enable_loop_soon_any_contextEv", - ): - sample.append((int(addr, 16), name)) - header = "ESPHome: IRAM_ATTR placement summary ({}):".format(_variant) + end = int(addr_str, 16) + elif name in _FALLBACK_IRAM_SYMBOLS: + fallback.append(int(addr_str, 16)) + return start, end, fallback + + +def _post_link(target, source, env): + """Print where IRAM_ATTR ended up so users can confirm at a glance.""" + elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") + if not os.path.isfile(elf): + return + start, end, fallback = _collect_iram_symbols(env.subst("$NM"), elf) + print("ESPHome: IRAM_ATTR placement summary ({}):".format(_variant)) if start is not None and end is not None: - print(header) print(" .sram.text: {} bytes at 0x{:08x} - 0x{:08x}".format(end - start, start, end)) - elif sample: - print(header) - sample.sort() - lo = sample[0][0] - hi = sample[-1][0] + elif fallback: + lo, hi = min(fallback), max(fallback) print(" IRAM symbols at 0x{:08x} - 0x{:08x} (approx {} bytes)".format(lo, hi, hi - lo)) else: - print(header) print(" no IRAM_ATTR symbols found in the ELF") diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 01ce4189d1e..79eca8d5c34 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -56,10 +56,11 @@ #include #endif -#if defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7231T) || \ - defined(USE_LIBRETINY_VARIANT_BK7231Q) || defined(USE_LIBRETINY_VARIANT_BK7251) +#ifdef USE_BK72XX // Declared in the Beken FreeRTOS port (portmacro.h) and built in ARM mode so -// it is callable from Thumb code via interworking. +// it is callable from Thumb code via interworking. The MRS CPSR instruction +// is ARM-only and user code here may be built in Thumb, so in_isr_context() +// defers to this port helper on BK72xx instead of reading CPSR inline. extern "C" uint32_t platform_is_in_interrupt_context(void); #endif @@ -79,11 +80,8 @@ __attribute__((always_inline)) inline bool in_isr_context() { uint32_t ipsr; __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); return ipsr != 0; -#elif defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7231T) || \ - defined(USE_LIBRETINY_VARIANT_BK7231Q) || defined(USE_LIBRETINY_VARIANT_BK7251) - // BK72xx is ARM968E-S (ARM9). The MRS CPSR instruction is ARM-only, and - // user code here may be built in Thumb mode. Defer to the FreeRTOS port - // helper declared above (compiled in ARM mode by the SDK). +#elif defined(USE_BK72XX) + // BK72xx is ARM968E-S (ARM9); see extern declaration above. return platform_is_in_interrupt_context() != 0; #elif defined(USE_LIBRETINY) // Cortex-M (AmebaZ, AmebaZ2, LN882H). IPSR is the active exception number; From eae6e6d32ba306ee65b13f41a0b0c8825e6b3dc2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:28:46 -1000 Subject: [PATCH 17/32] =?UTF-8?q?libretiny:=20fix=20RTL8710B=20linker=20re?= =?UTF-8?q?gex=20=E2=80=94=20output=20is=20.ram=5Fimage2.text=20not=20.ima?= =?UTF-8?q?ge2.ram.text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confused the output section name with the input glob. RTL8710B's linker template has: .ram_image2.text : { KEEP(*(.image2.ram.text*)) } > BD_RAM so we need to match ".ram_image2.text :" and inject our extra KEEP(*(.sram.text*)) alongside the existing input glob. CI caught this; the test_build_components rtl87xx-ard smoke test now exercises the patcher. --- esphome/components/libretiny/patch_linker.py.script | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index f7f536df4ec..63d95000b61 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -36,7 +36,11 @@ _KEEP_LINE = ( + _MARKER + "\n" ) _LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") -_RTL8710B_IMAGE2 = re.compile(r"(\.image2\.ram\.text\s*:\s*\{\s*\n)") +# RTL8710B's output section is ".ram_image2.text"; its stock linker consumes +# "*(.image2.ram.text*)" as an input glob inside that output, but we need to +# add a second input glob "*(.sram.text*)" so the ESPHome-marked functions +# land in the same RAM-resident output. +_RTL8710B_IMAGE2 = re.compile(r"(\.ram_image2\.text\s*:\s*\{\s*\n)") def _detect(env): From 65f10feee4610399cb8b427ee00dadb0bd7206a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:36:13 -1000 Subject: [PATCH 18/32] libretiny: fix stale .image2.ram.text comments; rename dir -> script_dir; link regex from error Review feedback: - three comments still referenced the input glob '.image2.ram.text' after the output section was fixed to '.ram_image2.text' in eae6e6d32b; update comments in hal.h, libretiny/__init__.py, and patch_linker.py.script so they match the regex. - rename the 'dir' local in libretiny.copy_files() to 'script_dir' to avoid shadowing the Python builtin. - the 'no linker script patched' RuntimeError now names the per-family regex constants a maintainer should update when a LibreTiny template changes shape. --- esphome/components/libretiny/__init__.py | 6 +++--- esphome/components/libretiny/patch_linker.py.script | 10 +++++++--- esphome/core/hal.h | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 6b24f4d52ca..21b176d5f01 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -471,7 +471,7 @@ async def component_to_code(config): # where the ISR-during-flash race is real (see esphome/core/hal.h). This # pre-link hook routes that section into each family's executable RAM: # - LN882H: inject KEEP(*(.sram.text*)) into .flash_copysection. - # - RTL8710B: inject KEEP(*(.sram.text*)) into .image2.ram.text. + # - RTL8710B: inject KEEP(*(.sram.text*)) into .ram_image2.text. # - RTL8720C: no-op (stock linker already consumes *(.sram.text*)). # BK72xx (all variants) is no-op: the Beken SDK wraps every flash write # in GLOBAL_INT_DISABLE() so no ISR can fire during a flash stall; the @@ -565,8 +565,8 @@ async def component_to_code(config): # Called by writer.py def copy_files() -> None: - dir = Path(__file__).parent - patch_linker_file = dir / "patch_linker.py.script" + script_dir = Path(__file__).parent + patch_linker_file = script_dir / "patch_linker.py.script" copy_file_if_changed( patch_linker_file, CORE.relative_build_path("patch_linker.py"), diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 63d95000b61..4e96064f931 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -13,8 +13,9 @@ import subprocess # # - LN882H: stock linker has ".flash_copysection" which is flash-to-RAM0 # copied at startup; inject "KEEP(*(.sram.text*))" there. -# - RTL8710B (AmebaZ): stock linker has ".image2.ram.text" — inject -# "KEEP(*(.sram.text*))" into it. +# - RTL8710B (AmebaZ): stock linker has ".ram_image2.text" output section +# (which already consumes *(.image2.ram.text*)) — inject +# "KEEP(*(.sram.text*))" into it as a second input glob. # - RTL8720C (AmebaZ2): stock linker already consumes "*(.sram.text*)", # no-op. Loaded directly from the framework package so we cannot inject # our __esphome_sram_text_start/end markers either; the post-link summary @@ -119,7 +120,10 @@ def _patch_build_dir(patchers, build_dir): raise RuntimeError( "ESPHome: no linker script in {} was patched for IRAM_ATTR; refusing " "to link because IRAM_ATTR functions would end up in flash instead of " - "SRAM and would crash on an ISR while flash is busy".format(build_dir) + "SRAM and would crash on an ISR while flash is busy. LibreTiny probably " + "reformatted the target .ld template; update the per-family regex in " + "esphome/components/libretiny/patch_linker.py.script " + "(_LN_COPY, _RTL8710B_IMAGE2).".format(build_dir) ) diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 79eca8d5c34..c7d78589b4e 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -26,7 +26,7 @@ // IRAM_ATTR places a function in executable RAM so it is callable from an // ISR even while flash is busy (XIP stall, OTA, logger flash write). // patch_linker.py.script routes ".sram.text" into each family's RAM- -// executable output section: .image2.ram.text on RTL8710B, +// executable output section: .ram_image2.text on RTL8710B, // .flash_copysection on LN882H; RTL8720C's stock linker already consumes // *(.sram.text*) via its .ram.code_text output. // From 663c1784b79c415a9cbc4f28634c8ec6aa467b43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:44:29 -1000 Subject: [PATCH 19/32] libretiny: use walrus operator for variant/patchers assignment --- esphome/components/libretiny/patch_linker.py.script | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 4e96064f931..ec168284502 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -178,9 +178,7 @@ def _post_link(target, source, env): print(" no IRAM_ATTR symbols found in the ELF") -_variant = _detect(env) - -if _variant is None: +if (_variant := _detect(env)) is None: raise RuntimeError( "ESPHome: could not determine LibreTiny variant from build flags. " "patch_linker.py needs USE_LIBRETINY_VARIANT_* to route IRAM_ATTR " @@ -193,8 +191,7 @@ if _variant not in KNOWN_VARIANTS: "patch_linker.py.script before shipping firmware.".format(_variant) ) -_patchers = _patchers_for(_variant) -if _patchers: +if _patchers := _patchers_for(_variant): # LibreTiny writes the processed .ld templates into $BUILD_DIR during its # own builder setup, which may run after this script. Register the patch # as a pre-link action so it executes once the linker scripts exist. From f9c4ed65ffdd8d18ba04bc49671753c4a2fd2bfa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:47:06 -1000 Subject: [PATCH 20/32] libretiny: fix ESP8266 in_isr_context; use demangled names for fallback - ESP8266 in_isr_context() was checking PS.INTLEVEL which gives false positives when user code masks interrupts. Return false unconditionally since the ESP8266 wake path is context-agnostic and never calls in_isr_context(). - Replace brittle mangled C++ symbol names in the post-link fallback with substring matches on demangled names via nm --demangle. Survives namespace/signature changes. --- .../components/libretiny/patch_linker.py.script | 17 ++++++++--------- esphome/core/hal.h | 9 +++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index ec168284502..96bad69971f 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -131,19 +131,18 @@ def _pre_link(target, source, env): _patch_build_dir(_patchers, env.subst("$BUILD_DIR")) -# Well-known ESPHome IRAM_ATTR symbols used as a fallback on RTL8720C, where -# we cannot inject the __esphome_sram_text_start/end markers. -_FALLBACK_IRAM_SYMBOLS = frozenset({ - "_ZN7esphome21wake_loop_any_contextEv", - "_ZN7esphome17wake_loop_isrsafeEPl", - "_ZN7esphome9Component28enable_loop_soon_any_contextEv", -}) +# Substrings matched against demangled symbol names as a fallback on +# RTL8720C, where we cannot inject __esphome_sram_text_start/end markers. +_FALLBACK_SUBSTRINGS = ("wake_loop_any_context", "wake_loop_isrsafe", + "enable_loop_soon_any_context") def _collect_iram_symbols(nm, elf): """Return (start, end, fallback_addresses) for the IRAM_ATTR payload.""" try: - out = subprocess.check_output([nm, "--defined-only", elf], text=True) + out = subprocess.check_output( + [nm, "--defined-only", "--demangle", elf], text=True + ) except (OSError, subprocess.CalledProcessError): return None, None, [] start = end = None @@ -157,7 +156,7 @@ def _collect_iram_symbols(nm, elf): start = int(addr_str, 16) elif name == "__esphome_sram_text_end": end = int(addr_str, 16) - elif name in _FALLBACK_IRAM_SYMBOLS: + elif any(sub in name for sub in _FALLBACK_SUBSTRINGS): fallback.append(int(addr_str, 16)) return start, end, fallback diff --git a/esphome/core/hal.h b/esphome/core/hal.h index c7d78589b4e..d3bc97d9c39 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -72,10 +72,11 @@ __attribute__((always_inline)) inline bool in_isr_context() { #if defined(USE_ESP32) return xPortInIsrContext() != 0; #elif defined(USE_ESP8266) - // Xtensa LX106 PS.INTLEVEL[3:0]. Non-zero indicates interrupt in progress. - uint32_t ps; - __asm__ volatile("rsr.ps %0" : "=r"(ps)); - return (ps & 0xF) != 0; + // ESP8266 has no reliable single-register ISR detection: PS.INTLEVEL is + // non-zero both in a real ISR and when user code masks interrupts. The + // ESP8266 wake path is context-agnostic (wake_loop_impl uses esp_schedule + // which is ISR-safe) so this helper is unused on this platform. + return false; #elif defined(USE_RP2040) uint32_t ipsr; __asm__ volatile("mrs %0, ipsr" : "=r"(ipsr)); From 22781aff74d2874117b1743a9b0de02911a88ea0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:47:45 -1000 Subject: [PATCH 21/32] libretiny: reference _PATCHERS_BY_VARIANT instead of individual regex names in error --- esphome/components/libretiny/patch_linker.py.script | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 96bad69971f..0118a277054 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -120,10 +120,11 @@ def _patch_build_dir(patchers, build_dir): raise RuntimeError( "ESPHome: no linker script in {} was patched for IRAM_ATTR; refusing " "to link because IRAM_ATTR functions would end up in flash instead of " - "SRAM and would crash on an ISR while flash is busy. LibreTiny probably " - "reformatted the target .ld template; update the per-family regex in " - "esphome/components/libretiny/patch_linker.py.script " - "(_LN_COPY, _RTL8710B_IMAGE2).".format(build_dir) + "SRAM and would crash on an ISR while flash is busy. LibreTiny " + "probably reformatted the target .ld template; update the per-family " + "regex in patch_linker.py.script (_PATCHERS_BY_VARIANT).".format( + build_dir + ) ) From 3b0ac8ebedb762dd9c6aecfe6129e583dce6616c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:50:34 -1000 Subject: [PATCH 22/32] libretiny: simplify _pre_link, inline _patch_build_dir --- .../libretiny/patch_linker.py.script | 42 +++++++------------ 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 0118a277054..189f59e9e38 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -95,43 +95,31 @@ def _patchers_for(variant): return _PATCHERS_BY_VARIANT.get(variant, ()) -def _patch_build_dir(patchers, build_dir): - if not os.path.isdir(build_dir): - raise RuntimeError( - "ESPHome: LibreTiny build dir {} does not exist at link time; " - "IRAM_ATTR placement cannot be verified".format(build_dir) - ) - patched_any = False - for name in sorted(os.listdir(build_dir)): - if not name.endswith(".ld"): - continue +def _pre_link(target, source, env): + build_dir = env.subst("$BUILD_DIR") + ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")] + patched = 0 + for name in ld_files: path = os.path.join(build_dir, name) with open(path, "r", encoding="utf-8") as fh: - content = fh.read() - patched = content - for patch in patchers: - patched = patch(patched) - if patched != content: + original = fh.read() + content = original + for fn in _patchers: + content = fn(content) + if content != original: with open(path, "w", encoding="utf-8") as fh: - fh.write(patched) - print("ESPHome: patched linker script {} for IRAM_ATTR placement".format(name)) - patched_any = True - if not patched_any: + fh.write(content) + print("ESPHome: patched {} for IRAM_ATTR placement".format(name)) + patched += 1 + if not patched: raise RuntimeError( - "ESPHome: no linker script in {} was patched for IRAM_ATTR; refusing " - "to link because IRAM_ATTR functions would end up in flash instead of " - "SRAM and would crash on an ISR while flash is busy. LibreTiny " - "probably reformatted the target .ld template; update the per-family " + "ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the " "regex in patch_linker.py.script (_PATCHERS_BY_VARIANT).".format( build_dir ) ) -def _pre_link(target, source, env): - _patch_build_dir(_patchers, env.subst("$BUILD_DIR")) - - # Substrings matched against demangled symbol names as a fallback on # RTL8720C, where we cannot inject __esphome_sram_text_start/end markers. _FALLBACK_SUBSTRINGS = ("wake_loop_any_context", "wake_loop_isrsafe", From 35cafd52a5d4d6342a9b4f76e59d464d6326c363 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:54:02 -1000 Subject: [PATCH 23/32] libretiny: inline _collect_iram_symbols into _post_link --- .../libretiny/patch_linker.py.script | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 189f59e9e38..1fcad920d97 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -120,20 +120,23 @@ def _pre_link(target, source, env): ) -# Substrings matched against demangled symbol names as a fallback on -# RTL8720C, where we cannot inject __esphome_sram_text_start/end markers. +# Substrings matched against demangled names as a fallback on RTL8720C, +# where we cannot inject __esphome_sram_text_start/end markers. _FALLBACK_SUBSTRINGS = ("wake_loop_any_context", "wake_loop_isrsafe", "enable_loop_soon_any_context") -def _collect_iram_symbols(nm, elf): - """Return (start, end, fallback_addresses) for the IRAM_ATTR payload.""" +def _post_link(target, source, env): + """Print where IRAM_ATTR ended up so users can confirm at a glance.""" + elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") + if not os.path.isfile(elf): + return try: out = subprocess.check_output( - [nm, "--defined-only", "--demangle", elf], text=True + [env.subst("$NM"), "--defined-only", "--demangle", elf], text=True ) except (OSError, subprocess.CalledProcessError): - return None, None, [] + return start = end = None fallback = [] for line in out.splitlines(): @@ -145,17 +148,8 @@ def _collect_iram_symbols(nm, elf): start = int(addr_str, 16) elif name == "__esphome_sram_text_end": end = int(addr_str, 16) - elif any(sub in name for sub in _FALLBACK_SUBSTRINGS): + elif any(s in name for s in _FALLBACK_SUBSTRINGS): fallback.append(int(addr_str, 16)) - return start, end, fallback - - -def _post_link(target, source, env): - """Print where IRAM_ATTR ended up so users can confirm at a glance.""" - elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") - if not os.path.isfile(elf): - return - start, end, fallback = _collect_iram_symbols(env.subst("$NM"), elf) print("ESPHome: IRAM_ATTR placement summary ({}):".format(_variant)) if start is not None and end is not None: print(" .sram.text: {} bytes at 0x{:08x} - 0x{:08x}".format(end - start, start, end)) @@ -163,7 +157,7 @@ def _post_link(target, source, env): lo, hi = min(fallback), max(fallback) print(" IRAM symbols at 0x{:08x} - 0x{:08x} (approx {} bytes)".format(lo, hi, hi - lo)) else: - print(" no IRAM_ATTR symbols found in the ELF") + print(" no IRAM_ATTR symbols found") if (_variant := _detect(env)) is None: From 853782b2512a1cee4b2a17f4d599e9cbbe2f0380 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 15:59:26 -1000 Subject: [PATCH 24/32] libretiny: exclude veneer symbols from post-link IRAM fallback Linker-generated interworking veneers (e.g. ___ZN...enable_loop_soon_ any_context_veneer at 0x9b062588) contain the same function name substrings but live at unrelated addresses, producing a bogus multi-GB range in the summary. Filter them out. --- esphome/components/libretiny/patch_linker.py.script | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 1fcad920d97..45b04e8f961 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -148,7 +148,7 @@ def _post_link(target, source, env): start = int(addr_str, 16) elif name == "__esphome_sram_text_end": end = int(addr_str, 16) - elif any(s in name for s in _FALLBACK_SUBSTRINGS): + elif "veneer" not in name and any(s in name for s in _FALLBACK_SUBSTRINGS): fallback.append(int(addr_str, 16)) print("ESPHome: IRAM_ATTR placement summary ({}):".format(_variant)) if start is not None and end is not None: From 9fc54d30e5773822794028ddfb1f3373f4bd04fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:05:12 -1000 Subject: [PATCH 25/32] libretiny: use native section names for RTL8710B; patcher now LN882H-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RTL8710B's stock linker already consumes *(.image2.ram.text*) into its .ram_image2.text output (> BD_RAM), so hal.h can place IRAM_ATTR functions directly into section(".image2.ram.text") without any linker patching. RTL8720C already worked this way with section(".sram.text"). The patcher is now only needed for LN882H, whose stock linker has no glob that catches ".sram.text" — we inject KEEP(*(.sram.text*)) into .flash_copysection (> RAM0 AT> FLASH). This removes the _RTL8710B_IMAGE2 regex, the RTL8710B entry from _PATCHERS_BY_VARIANT, and simplifies the header comments. --- esphome/components/libretiny/__init__.py | 14 +++---- .../libretiny/patch_linker.py.script | 37 +++++++------------ esphome/core/hal.h | 6 +++ 3 files changed, 24 insertions(+), 33 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 21b176d5f01..e9449662a7f 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -467,15 +467,11 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) - # IRAM_ATTR on LibreTiny expands to section(".sram.text") on families - # where the ISR-during-flash race is real (see esphome/core/hal.h). This - # pre-link hook routes that section into each family's executable RAM: - # - LN882H: inject KEEP(*(.sram.text*)) into .flash_copysection. - # - RTL8710B: inject KEEP(*(.sram.text*)) into .ram_image2.text. - # - RTL8720C: no-op (stock linker already consumes *(.sram.text*)). - # BK72xx (all variants) is no-op: the Beken SDK wraps every flash write - # in GLOBAL_INT_DISABLE() so no ISR can fire during a flash stall; the - # race IRAM_ATTR guards against cannot occur. + # IRAM_ATTR routes ISR code into RAM-executable sections (see + # esphome/core/hal.h). Most families need no linker help; LN882H is the + # exception — its stock linker has no glob for ".sram.text", so this + # pre-link hook injects KEEP(*(.sram.text*)) into .flash_copysection. + # The script also prints a post-link summary on all non-BK72xx families. cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 45b04e8f961..cb1d9602017 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -5,26 +5,19 @@ import os import re import subprocess -# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny expands to -# section(".sram.text") (see esphome/core/hal.h). Each family's linker script -# needs that section routed into RAM-resident *executable* memory so the -# function is callable while flash is busy (XIP stall, OTA, logger flash -# write): +# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a section +# that each family's linker routes into RAM-executable memory so the function +# is callable while flash is busy (see esphome/core/hal.h for the per-family +# section names). # -# - LN882H: stock linker has ".flash_copysection" which is flash-to-RAM0 -# copied at startup; inject "KEEP(*(.sram.text*))" there. -# - RTL8710B (AmebaZ): stock linker has ".ram_image2.text" output section -# (which already consumes *(.image2.ram.text*)) — inject -# "KEEP(*(.sram.text*))" into it as a second input glob. -# - RTL8720C (AmebaZ2): stock linker already consumes "*(.sram.text*)", -# no-op. Loaded directly from the framework package so we cannot inject -# our __esphome_sram_text_start/end markers either; the post-link summary -# falls back to reading known IRAM_ATTR symbol addresses instead. +# Most families need no linker patching: +# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. +# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. +# - BK72xx: IRAM_ATTR is a no-op (SDK masks FIQ+IRQ around flash writes). # -# BK72xx (all variants) have no .ld patcher: the Beken SDK wraps every flash -# operation in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU, so no -# ISR can fire during a flash stall and the race IRAM_ATTR guards against -# cannot occur. IRAM_ATTR is a no-op on BK72xx (see esphome/core/hal.h). +# LN882H is the only family that needs patching: its stock linker has no glob +# that catches ".sram.text", so we inject KEEP(*(.sram.text*)) into the +# ".flash_copysection" output (which is flash-to-RAM0 copied at startup). _MARKER = "/* esphome .sram.text */" @@ -37,11 +30,6 @@ _KEEP_LINE = ( + _MARKER + "\n" ) _LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") -# RTL8710B's output section is ".ram_image2.text"; its stock linker consumes -# "*(.image2.ram.text*)" as an input glob inside that output, but we need to -# add a second input glob "*(.sram.text*)" so the ESPHome-marked functions -# land in the same RAM-resident output. -_RTL8710B_IMAGE2 = re.compile(r"(\.ram_image2\.text\s*:\s*\{\s*\n)") def _detect(env): @@ -83,11 +71,12 @@ def _inject_keep(host_section): # Variants not listed here intentionally have no .ld patcher: +# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker +# already routes into .ram_image2.text (> BD_RAM). # - RTL8720C: stock linker already consumes *(.sram.text*). # - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op. _PATCHERS_BY_VARIANT = { "LN882H": (_inject_keep(_LN_COPY),), - "RTL8710B": (_inject_keep(_RTL8710B_IMAGE2),), } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index d3bc97d9c39..a0e13ef3c78 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -39,7 +39,13 @@ // layer. #if defined(USE_BK72XX) #define IRAM_ATTR +#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) +// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). +#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) #else +// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +// LN882H: patch_linker.py.script injects *(.sram.text*) into +// .flash_copysection (> RAM0 AT> FLASH). #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) #endif #define PROGMEM From f961f9bfb3c156ab17822fadb35a221e95fb366f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:06:48 -1000 Subject: [PATCH 26/32] libretiny: fix post-link summary for RTL8710B + LN882H Post-link was not registered for RTL8710B (condition was too narrow after removing it from _PATCHERS_BY_VARIANT). Use a BK72xx exclusion set instead so all IRAM-active variants get the summary. Also fall back to TOOLCHAIN_PREFIX-nm when $NM is empty, which fixes the empty summary on LN882H. --- esphome/components/libretiny/patch_linker.py.script | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index cb1d9602017..c9b04ee901f 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -120,9 +120,10 @@ def _post_link(target, source, env): elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") if not os.path.isfile(elf): return + nm = env.subst("$NM") or env.subst("${TOOLCHAIN_PREFIX}nm") try: out = subprocess.check_output( - [env.subst("$NM"), "--defined-only", "--demangle", elf], text=True + [nm, "--defined-only", "--demangle", elf], text=True ) except (OSError, subprocess.CalledProcessError): return @@ -168,7 +169,8 @@ if _patchers := _patchers_for(_variant): # as a pre-link action so it executes once the linker scripts exist. env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link) -# Post-link summary runs for every LibreTiny family (except BK72xx where -# IRAM_ATTR is a no-op and no symbols are relocated to RAM). -if _patchers or _variant == "RTL8720C": +_BK72XX_VARIANTS = frozenset({"BK7231N", "BK7231T", "BK7231Q", "BK7251"}) + +# Post-link summary for every family where IRAM_ATTR places code in RAM. +if _variant not in _BK72XX_VARIANTS: env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) From 1cc0ae54435964eefc541b86815ca81bdc52b891 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:07:43 -1000 Subject: [PATCH 27/32] libretiny: skip patch_linker.py entirely on BK72xx IRAM_ATTR is a no-op on BK72xx so there is nothing for the script to patch or summarize. Guard the extra_scripts registration with a COMPONENT_BK72XX check and drop the BK72xx variants from KNOWN_VARIANTS. --- esphome/components/libretiny/__init__.py | 11 ++++---- .../libretiny/patch_linker.py.script | 27 +++++++------------ 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index e9449662a7f..4f42f40478c 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -467,12 +467,11 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) - # IRAM_ATTR routes ISR code into RAM-executable sections (see - # esphome/core/hal.h). Most families need no linker help; LN882H is the - # exception — its stock linker has no glob for ".sram.text", so this - # pre-link hook injects KEEP(*(.sram.text*)) into .flash_copysection. - # The script also prints a post-link summary on all non-BK72xx families. - cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) + # IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops). + # On other families, patch_linker.py routes .sram.text into the right + # RAM-executable output section and prints a post-link placement summary. + if FAMILY_COMPONENT[config[CONF_FAMILY]] != COMPONENT_BK72XX: + cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) # decrease web server stack size (16k words -> 4k words) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index c9b04ee901f..0be4df0a33c 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -5,19 +5,17 @@ import os import re import subprocess -# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a section -# that each family's linker routes into RAM-executable memory so the function -# is callable while flash is busy (see esphome/core/hal.h for the per-family -# section names). +# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family +# section routed into RAM-executable memory (see esphome/core/hal.h). # -# Most families need no linker patching: +# This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK +# masks FIQ+IRQ around flash writes). On the remaining families: # - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. # - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. -# - BK72xx: IRAM_ATTR is a no-op (SDK masks FIQ+IRQ around flash writes). +# - LN882H: stock linker has no glob for ".sram.text", so we inject +# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH). # -# LN882H is the only family that needs patching: its stock linker has no glob -# that catches ".sram.text", so we inject KEEP(*(.sram.text*)) into the -# ".flash_copysection" output (which is flash-to-RAM0 copied at startup). +# All families also get a post-link summary showing where IRAM_ATTR landed. _MARKER = "/* esphome .sram.text */" @@ -51,10 +49,6 @@ def _detect(env): KNOWN_VARIANTS = frozenset({ - "BK7231N", - "BK7231T", - "BK7231Q", - "BK7251", "LN882H", "RTL8710B", "RTL8720C", @@ -169,8 +163,5 @@ if _patchers := _patchers_for(_variant): # as a pre-link action so it executes once the linker scripts exist. env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link) -_BK72XX_VARIANTS = frozenset({"BK7231N", "BK7231T", "BK7231Q", "BK7251"}) - -# Post-link summary for every family where IRAM_ATTR places code in RAM. -if _variant not in _BK72XX_VARIANTS: - env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) +# Post-link summary for every family that reaches this script. +env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) From 384f8f687002f9f02231e54befe9245f209818cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:08:58 -1000 Subject: [PATCH 28/32] libretiny: derive nm from CC path when $NM is unset --- esphome/components/libretiny/patch_linker.py.script | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 0be4df0a33c..183759559a7 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -114,7 +114,11 @@ def _post_link(target, source, env): elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") if not os.path.isfile(elf): return - nm = env.subst("$NM") or env.subst("${TOOLCHAIN_PREFIX}nm") + # $NM may be unset; derive from $CC (e.g. arm-none-eabi-gcc -> arm-none-eabi-nm). + nm = env.subst("$NM") + if not nm: + cc = env.subst("$CC") + nm = cc.replace("-gcc", "-nm") if cc.endswith("-gcc") else "nm" try: out = subprocess.check_output( [nm, "--defined-only", "--demangle", elf], text=True From 1aeb04b4dc51775e297e84acfdf4ba26c37e1b3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:09:55 -1000 Subject: [PATCH 29/32] libretiny: log nm failure reason instead of silently swallowing --- esphome/components/libretiny/patch_linker.py.script | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 183759559a7..f2da6c9161a 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -123,7 +123,8 @@ def _post_link(target, source, env): out = subprocess.check_output( [nm, "--defined-only", "--demangle", elf], text=True ) - except (OSError, subprocess.CalledProcessError): + except (OSError, subprocess.CalledProcessError) as exc: + print("ESPHome: IRAM_ATTR summary unavailable (nm failed: {})".format(exc)) return start = end = None fallback = [] From f296a86675ed675400a7046f7da11f9ac314e89e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:10:29 -1000 Subject: [PATCH 30/32] libretiny: drop unnecessary CC-derived nm fallback --- esphome/components/libretiny/patch_linker.py.script | 4 ---- 1 file changed, 4 deletions(-) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index f2da6c9161a..c490b10e9bb 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -114,11 +114,7 @@ def _post_link(target, source, env): elf = env.subst("$BUILD_DIR/${PROGNAME}.elf") if not os.path.isfile(elf): return - # $NM may be unset; derive from $CC (e.g. arm-none-eabi-gcc -> arm-none-eabi-nm). nm = env.subst("$NM") - if not nm: - cc = env.subst("$CC") - nm = cc.replace("-gcc", "-nm") if cc.endswith("-gcc") else "nm" try: out = subprocess.check_output( [nm, "--defined-only", "--demangle", elf], text=True From 21c32966ee0230a020a96cbf72630451e0161d4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:11:50 -1000 Subject: [PATCH 31/32] =?UTF-8?q?libretiny:=20fix=20hal.h=20intro=20commen?= =?UTF-8?q?t=20=E2=80=94=20only=20LN882H=20uses=20the=20patcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- esphome/core/hal.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/core/hal.h b/esphome/core/hal.h index a0e13ef3c78..e4083622b98 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -25,10 +25,10 @@ // IRAM_ATTR places a function in executable RAM so it is callable from an // ISR even while flash is busy (XIP stall, OTA, logger flash write). -// patch_linker.py.script routes ".sram.text" into each family's RAM- -// executable output section: .ram_image2.text on RTL8710B, -// .flash_copysection on LN882H; RTL8720C's stock linker already consumes -// *(.sram.text*) via its .ram.code_text output. +// Each family uses a section its stock linker already routes to RAM: +// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the +// exception: its stock linker has no matching glob, so patch_linker.py +// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. // // BK72xx (all variants) are left as a no-op: their SDK wraps flash // operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for From 849463b8a5c20c15f99b54d3bbb484367c1afd0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 16:47:44 -1000 Subject: [PATCH 32/32] libretiny: treat already-patched .ld as success on incremental rebuilds The idempotency check in _inject_keep returns content unchanged when the marker is already present, so patched stayed 0 and the fail-hard RuntimeError fired on every incremental rebuild after the first. Check for the marker before attempting to patch and count it as success. --- esphome/components/libretiny/patch_linker.py.script | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index c490b10e9bb..282a31d3f2f 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -86,6 +86,9 @@ def _pre_link(target, source, env): path = os.path.join(build_dir, name) with open(path, "r", encoding="utf-8") as fh: original = fh.read() + if _MARKER in original: + patched += 1 + continue content = original for fn in _patchers: content = fn(content)