diff --git a/esphome/components/bk72xx/__init__.py b/esphome/components/bk72xx/__init__.py index 7fed742d2e..3ffab0f3a5 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 656eee6d7b..4f42f40478 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,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 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) @@ -549,3 +556,13 @@ async def component_to_code(config): _configure_lwip(config) await cg.register_component(var, config) + + +# Called by writer.py +def copy_files() -> None: + 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/generate_components.py b/esphome/components/libretiny/generate_components.py index 41b4389446..d5437895a6 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/libretiny/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script new file mode 100644 index 0000000000..282a31d3f2 --- /dev/null +++ b/esphome/components/libretiny/patch_linker.py.script @@ -0,0 +1,171 @@ +# pylint: disable=E0602 +Import("env") # noqa + +import os +import re +import subprocess + +# 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). +# +# 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. +# - LN882H: stock linker has no glob for ".sram.text", so we inject +# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH). +# +# All families also get a post-link summary showing where IRAM_ATTR landed. + + +_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 = ( + " __esphome_sram_text_start = .; " + "KEEP(*(.sram.text*)) " + "__esphome_sram_text_end = .; " + + _MARKER + "\n" +) +_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") + + +def _detect(env): + prefix = "USE_LIBRETINY_VARIANT_" + # 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 = frozenset({ + "LN882H", + "RTL8710B", + "RTL8720C", +}) + + +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 + + +# 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),), +} + + +def _patchers_for(variant): + return _PATCHERS_BY_VARIANT.get(variant, ()) + + +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: + original = fh.read() + if _MARKER in original: + patched += 1 + continue + content = original + for fn in _patchers: + content = fn(content) + if content != original: + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + print("ESPHome: patched {} for IRAM_ATTR placement".format(name)) + patched += 1 + if not patched: + raise RuntimeError( + "ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the " + "regex in patch_linker.py.script (_PATCHERS_BY_VARIANT).".format( + build_dir + ) + ) + + +# 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 _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 + nm = env.subst("$NM") + try: + out = subprocess.check_output( + [nm, "--defined-only", "--demangle", elf], text=True + ) + except (OSError, subprocess.CalledProcessError) as exc: + print("ESPHome: IRAM_ATTR summary unavailable (nm failed: {})".format(exc)) + return + start = end = None + fallback = [] + for line in out.splitlines(): + parts = line.split(maxsplit=2) + if len(parts) != 3: + continue + addr_str, _kind, name = parts + if name == "__esphome_sram_text_start": + start = int(addr_str, 16) + elif name == "__esphome_sram_text_end": + end = int(addr_str, 16) + 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: + print(" .sram.text: {} bytes at 0x{:08x} - 0x{:08x}".format(end - start, start, end)) + 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(" no IRAM_ATTR symbols found") + + +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 " + "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) + ) + +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. + env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link) + +# Post-link summary for every family that reaches this script. +env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link) diff --git a/esphome/components/ln882x/__init__.py b/esphome/components/ln882x/__init__.py index 5c637bdf62..9c91827522 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 6fd750d51e..a3b1dba4f2 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() diff --git a/esphome/core/application.h b/esphome/core/application.h index b4bb8a1eec..7356263c55 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -337,8 +337,8 @@ class Application { /// @see esphome::wake_loop_threadsafe() in wake.h for platform details. void wake_loop_threadsafe() { esphome::wake_loop_threadsafe(); } -#ifdef USE_ESP32 - /// Wake from ISR (ESP32 only). +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + /// Wake from ISR (ESP32 and LibreTiny). static void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px) { esphome::wake_loop_isrsafe(px); } #endif diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 03a30b7459..e4083622b9 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -21,6 +21,35 @@ #define IRAM_ATTR __attribute__((noinline, long_call, section(".time_critical"))) #define PROGMEM +#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). +// 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 +// 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 +#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 + #else #define IRAM_ATTR @@ -28,8 +57,51 @@ #endif +#ifdef USE_ESP32 +#include +#include +#endif + +#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. 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 + 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) + // 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)); + return ipsr != 0; +#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; + // 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 ed2885d2e2..3aa8669e44 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 b6b59b5990..3709fa88ac 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 a8c9b7ad08..5733ee65f6 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -28,17 +28,27 @@ 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. -void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); -/// IRAM_ATTR entry point — defined in wake.cpp. -void wake_loop_any_context(); +/// 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); +#ifdef portYIELD_FROM_ISR + portYIELD_FROM_ISR(px_higher_priority_task_woken); #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(); } + // 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(); + } +} + +/// IRAM_ATTR entry points — defined in wake.cpp. +void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); +void wake_loop_any_context(); inline void wake_loop_threadsafe() { esphome_main_task_notify(); }