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.
This commit is contained in:
J. Nick Koston
2026-04-15 13:42:17 -10:00
parent 403a9f7b7e
commit 3e5c4fd603
6 changed files with 159 additions and 27 deletions
+16
View File
@@ -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"),
)
@@ -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)
+59
View File
@@ -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 <freertos/FreeRTOS.h>
#include <freertos/task.h>
#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();
+4 -15
View File
@@ -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
+3 -3
View File
@@ -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 ===
+13 -9
View File
@@ -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(); }