Compare commits

...
Author SHA1 Message Date
J. Nick Koston 34e0e1be71 Merge branch 'dev' into libretiny-bk72xx-crash-handler 2026-10-17 15:05:25 -10:00
J. Nick Koston 7a7e4695e2 [libretiny] Emit the bk72xx decoder import from the component generator too
Regenerating bk72xx/__init__.py keeps the process_stacktrace hook the
CLI looks for on the platform package.
2026-10-17 14:46:35 -10:00
J. Nick Koston 106fa763ab [libretiny] Derive objdump from the toolchain's nm, guard CRASH_HANDLER patterns, trim comments
The post-link check takes objdump from $NM (arm-none-eabi-gcc-nm here)
instead of editing the $CC string, the undeclared-pattern test guard
now also sees CRASH_HANDLER_* names in the shared decoder, and the
comments are cut to the repo's terse style with their facts kept.
2026-10-17 14:45:39 -10:00
J. Nick Koston 30a568b804 [libretiny] Check the linked ELF for the ITCM record and warn once per undecodable report
A post-link step reads the ELF's section table and fails the build when
.noinit is missing or outside ITCM, so a stale marker or an unmatched
linker script cannot leave the crash record in SRAM unnoticed. The
shared decoder warns once per report when addr2line or the ELF is
missing and leaves the dump region, instead of once per address line.
2026-10-17 14:40:04 -10:00
J. Nick Koston 090f8a0caf [libretiny] Take the saved register addresses from the SDK header and build the handler in CI
The saved-register table comes from arm_mcu_pub.h instead of literal
addresses, the wrapper no longer falls back to the exception mode's sp
and lr for a corrupt spsr, and only the backtrace terminator is written
at fault time. The rp2 and bk72xx packages import just the shared hook;
the decoder tests resolve the patterns from the hook's module. A
bk7238-ard build-test target links the handler and runs the linker
patch in CI, which the BK7252 board of the bk72xx target never did.
2026-10-17 14:35:09 -10:00
J. Nick Koston 9aeac52ba0 [core] Cover every branch of the shared crash report decoder
Unit tests for the marker, the decoded address, the two reasons an
address cannot be decoded, the closing hint line and the addr2line
failure paths. The bk72xx package keeps its hand-written decoder import
like its other hand-written lines, so generate_components.py is back to
dev.
2026-10-17 14:17:31 -10:00
J. Nick Koston 5ceafcee97 [libretiny] Read the saved register banks instead of switching modes
The SDK's exception entry (PUSH_ALL_ARM_REG) stores every mode's
cpsr, spsr and r8-r14 at fixed SRAM addresses before branching to
bk_trap_*, the same table bk_show_register() prints. The wrapper takes
the faulting mode's sp and lr from there, so the hand-written ARM
assembly that switched modes is gone.

Also: say why an address was not decoded (no addr2line, no ELF, or the
tool's error), test the family gate and linker flags of the code
generation, and correct the cleared-SRAM figure to the measured 128 KB.
2026-10-17 14:10:54 -10:00
J. Nick Koston 2be625293b [libretiny] Shorten the crash handler comments 2026-10-17 14:02:51 -10:00
J. Nick Koston 9ee5aaf3ec [libretiny] Use the SDK's exception frame type and assert its layout
The wrapper now takes the SDK's struct arm_registers instead of a local
copy of it, and static asserts pin the frame size and the sp/lr/pc/spsr/cpsr
word offsets to what PUSH_SVC_REG in boot_handlers.S stores, plus that
LibreTiny still declares a hardware watchdog, so a drift in either
project fails the build instead of corrupting the report.
2026-10-17 13:58:15 -10:00
J. Nick Koston 730e05b0c1 [libretiny] Guard the crash log hooks on the handler define and bound the stack scan per chip
The logger's crash_log_pending/log_crash_to_client are defined only with
USE_LIBRETINY_CRASH_HANDLER: the static-analysis defines.h enables
USE_CRASH_HANDLER for every target, so the LN882H and RTL87xx tidy
builds called functions that are only declared on BK. The stack scan now
stops at 192 KB on the BK7231N, whose SRAM ends at 0x430000.
2026-10-17 13:42:15 -10:00
J. Nick Koston 9f4dd988dc [core] Import subprocess lazily in the shared crash report decoder
esphome.__main__ loads esphome/stacktrace.py on every CLI start and the
lazy-import test keeps subprocess out of that path.
2026-10-17 13:28:38 -10:00
J. Nick Koston 609df3f438 [libretiny] Reboot and report the previous boot's crash on BK7238 and BK7231N
Wrap the SDK's undefined-instruction, prefetch-abort and data-abort
handlers on BK7238 and BK7231N. The wrapper saves the fault PC, the
faulting context's LR and SP and a scanned stack backtrace into a
.noinit section in ITCM (the only memory the bootloader leaves alone
across a watchdog reset), arms the watchdog again and then hands over
to the SDK handler. Once Wi-Fi is up the watchdog armed at boot no
longer fires, so without this a crashed device hangs until power is
cycled.

The next boot logs the same CRASH DETECTED ON PREVIOUS BOOT report as
ESP32, ESP8266 and RP2, replays it to API and MQTT log clients, and the
CLI decodes it with addr2line through a decoder now shared with rp2 in
esphome/stacktrace.py.
2026-10-17 13:23:20 -10:00
20 changed files with 611 additions and 57 deletions
+1
View File
@@ -29,6 +29,7 @@ from esphome.components.libretiny.const import (
)
from esphome.core import CORE
from esphome.cpp_generator import MockObj
from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401
from esphome.types import ConfigType
from .boards import BK72XX_BOARD_PINS, BK72XX_BOARDS
+14
View File
@@ -55,6 +55,9 @@ from .const import (
)
_LOGGER = logging.getLogger(__name__)
# Fault handlers the crash handler wraps (BK7238, BK7231N)
BK_TRAP_HANDLERS = ("bk_trap_udef", "bk_trap_pabt", "bk_trap_dabt")
CODEOWNERS = ["@kuba2k2"]
AUTO_LOAD = ["preferences"]
DOMAIN = "libretiny"
@@ -530,6 +533,13 @@ async def component_to_code(config):
# 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"])
if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238):
# Crash handler (crash_handler.cpp, patch_noinit.py.script)
cg.add_define("USE_LIBRETINY_CRASH_HANDLER")
cg.add_define("USE_CRASH_HANDLER")
for trap in BK_TRAP_HANDLERS:
cg.add_build_flag(f"-Wl,--wrap={trap}")
cg.add_platformio_option("extra_scripts", ["pre:patch_noinit.py"])
# Match the 1-byte enums of the LN882H SDK's prebuilt WiFi library.
if FAMILY_COMPONENT[config[CONF_FAMILY]] == COMPONENT_LN882X:
cg.add_build_unflag("-fno-short-enums")
@@ -637,4 +647,8 @@ def copy_files() -> None:
script_dir / "scons_dont_inherit.py.script",
CORE.relative_build_path("scons_dont_inherit.py"),
)
copy_file_if_changed(
script_dir / "patch_noinit.py.script",
CORE.relative_build_path("patch_noinit.py"),
)
copy_ccache_script()
@@ -0,0 +1,217 @@
#ifdef USE_BK72XX
#include "esphome/core/defines.h"
#ifdef USE_LIBRETINY_CRASH_HANDLER
#include "crash_handler.h"
#include "core.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <cinttypes>
#include <cstddef>
#include <cstdint>
#include <cstdio>
extern "C" {
#include <arm_mcu_pub.h> // MCU_REG_BACKUP_SP_*
#include <intc.h> // struct arm_registers
}
// On a fault the SDK (boot_handlers.S, PUSH_SVC_REG) builds a struct arm_registers frame and branches
// to bk_trap_udef/pabt/dabt, which record the crash type, print a dump sdk_silent hides and spin for
// the watchdog. The wrappers (-Wl,--wrap) first save the fault location and a stack scan for the
// next boot. pc is the exception LR; PUSH_SVC_REG stores pc and spsr at these word offsets.
static_assert(sizeof(arm_registers) == 18 * sizeof(uint32_t), "SDK exception frame changed size");
static_assert(offsetof(arm_registers, pc) == 15 * 4 && offsetof(arm_registers, spsr) == 16 * 4,
"SDK exception frame layout changed");
static_assert(LT_HW_WATCHDOG, "the crash reboot relies on the hardware watchdog");
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" {
void __real_bk_trap_udef(arm_registers *regs);
void __real_bk_trap_pabt(arm_registers *regs);
void __real_bk_trap_dabt(arm_registers *regs);
// Bounds of the app's flash image and its ITCM code (linker symbols).
extern char _vector_start[];
extern char _data_flash_begin[];
extern char _itcmcode_ram_begin[];
extern char _itcmcode_ram_end[];
}
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
namespace esphome::libretiny {
ESPHOME_LOG_TAG(TAG, "libretiny.crash");
// 0xDEAD sentinel, then the record layout version.
static constexpr uint32_t CRASH_MAGIC_V1 = 0xDEAD0001;
static constexpr uint32_t CRASH_REASON_UNDEFINED = 1;
static constexpr uint32_t CRASH_REASON_PREFETCH_ABORT = 2;
static constexpr uint32_t CRASH_REASON_DATA_ABORT = 3;
PROGMEM_STRING_TABLE(CrashReasonStrings, "Unknown", "Undefined instruction", "Prefetch abort", "Data abort");
// With Wi-Fi up the boot-time watchdog no longer fires: any interrupts-off spin hangs until power is
// cycled (BK7238 and BK7231N; cause not in the open SDK). Re-armed here, long enough for the SDK's
// register dump when sdk_silent is off.
static constexpr uint32_t CRASH_REBOOT_WDT_MS = 500;
static constexpr size_t MAX_BACKTRACE = 8;
// Words scanned above sp for return addresses.
static constexpr size_t STACK_SCAN_WORDS = 64;
// SRAM: 192 KB on the BK7231N, 256 KB on the BK7238.
static constexpr uint32_t SRAM_START = 0x00400000;
#ifdef USE_LIBRETINY_VARIANT_BK7231N
static constexpr uint32_t SRAM_END = 0x00430000;
#else
static constexpr uint32_t SRAM_END = 0x00440000;
#endif
static constexpr uint32_t CPSR_MODE_MASK = 0x1F;
static constexpr uint32_t CPSR_MODE_USR = 0x10;
static constexpr uint32_t CPSR_MODE_FIQ = 0x11;
static constexpr uint32_t CPSR_MODE_IRQ = 0x12;
static constexpr uint32_t CPSR_MODE_SVC = 0x13;
static constexpr uint32_t CPSR_MODE_ABT = 0x17;
static constexpr uint32_t CPSR_MODE_UND = 0x1B;
static constexpr uint32_t CPSR_MODE_SYS = 0x1F;
static constexpr uint32_t CPSR_THUMB = 1u << 5;
struct CrashRecord {
uint32_t magic;
uint32_t reason;
uint32_t pc;
uint32_t lr;
uint32_t sp;
uint32_t backtrace[MAX_BACKTRACE];
};
// Kept in ITCM, see patch_noinit.py.script.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
__attribute__((section(".noinit"))) static CrashRecord s_record;
static bool s_record_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static bool s_record_valid = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool crash_handler_has_data() {
if (!s_record_read) {
s_record_read = true;
s_record_valid = s_record.magic == CRASH_MAGIC_V1;
// Clear the magic so the record is reported once.
s_record.magic = 0;
}
return s_record_valid;
}
// One ESP_LOGE per line: the CLI decodes per line and partial output still shows.
void crash_handler_log() {
if (!crash_handler_has_data())
return;
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
ESP_LOGE(TAG, " Reason: %s", LOG_STR_ARG(CrashReasonStrings::get_log_str(s_record.reason, 0)));
ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_record.pc);
ESP_LOGE(TAG, " LR: 0x%08" PRIX32 " (return address)", s_record.lr);
ESP_LOGE(TAG, " SP: 0x%08" PRIX32, s_record.sp);
uint8_t count = 0;
for (; count < MAX_BACKTRACE && s_record.backtrace[count] != 0; count++) {
ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (stack backtrace)", count, s_record.backtrace[count]);
}
// addr2line hint with every address
char hint[160];
int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e raw_firmware.elf 0x%08" PRIX32 " 0x%08" PRIX32,
s_record.pc, s_record.lr);
for (uint8_t i = 0; i < count && pos < (int) sizeof(hint) - 12; i++) {
pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_record.backtrace[i]);
}
ESP_LOGE(TAG, "%s", hint);
}
// --- Crash time: exception mode, interrupts masked, a few dozen bytes of stack: no printing, no
// large locals.
static inline bool is_code_addr(uint32_t val) {
uint32_t addr = val & ~1u; // clear the Thumb bit of a return address
return (addr >= (uint32_t) _vector_start && addr < (uint32_t) _data_flash_begin) ||
(addr >= (uint32_t) _itcmcode_ram_begin && addr < (uint32_t) _itcmcode_ram_end);
}
// PUSH_ALL_ARM_REG (boot_handlers.S) saves every mode's registers at the arm_mcu_pub.h addresses
// before bk_trap_*; lr (r14) follows sp (r13).
struct SavedBank {
uint32_t mode;
uint32_t sp_addr;
};
static constexpr SavedBank SAVED_BANKS[] = {
{CPSR_MODE_SYS, MCU_REG_BACKUP_SP_SYS}, {CPSR_MODE_IRQ, MCU_REG_BACKUP_SP_IRQ},
{CPSR_MODE_FIQ, MCU_REG_BACKUP_SP_FIQ}, {CPSR_MODE_ABT, MCU_REG_BACKUP_SP_ABT},
{CPSR_MODE_UND, MCU_REG_BACKUP_SP_UND}, {CPSR_MODE_SVC, MCU_REG_BACKUP_SP_SVC},
};
// sp and lr stay 0 for an unknown mode (corrupt spsr).
static void read_saved_sp_lr(uint32_t mode, uint32_t *sp, uint32_t *lr) {
for (const auto &bank : SAVED_BANKS) {
if (bank.mode != mode)
continue;
const auto *regs = (const volatile uint32_t *) bank.sp_addr; // NOLINT(performance-no-int-to-ptr)
*sp = regs[0];
*lr = regs[1];
return;
}
}
static void record_crash(arm_registers *regs, uint32_t reason, uint32_t pc_offset) {
uint32_t mode = regs->spsr & CPSR_MODE_MASK;
if (mode == CPSR_MODE_USR)
mode = CPSR_MODE_SYS; // same register bank
uint32_t sp = 0;
uint32_t lr = 0;
read_saved_sp_lr(mode, &sp, &lr);
s_record.reason = reason;
s_record.pc = regs->pc - pc_offset;
s_record.lr = lr;
s_record.sp = sp;
// Return addresses above sp give the backtrace.
uint32_t count = 0;
if (sp >= SRAM_START && sp < SRAM_END && (sp & 3) == 0) {
auto *scan = (const uint32_t *) sp; // NOLINT(performance-no-int-to-ptr)
auto *end = (const uint32_t *) SRAM_END; // NOLINT(performance-no-int-to-ptr)
for (size_t i = 0; i < STACK_SCAN_WORDS && scan < end && count < MAX_BACKTRACE; i++, scan++) {
uint32_t val = *scan;
if (is_code_addr(val) && val != lr && (val & ~1u) != s_record.pc)
s_record.backtrace[count++] = val;
}
}
if (count < MAX_BACKTRACE)
s_record.backtrace[count] = 0; // terminator, the reader stops at the first 0
s_record.magic = CRASH_MAGIC_V1;
lt_wdt_enable(CRASH_REBOOT_WDT_MS);
}
// The exception LR is past the fault: one instruction (undefined), 4 bytes (prefetch), 8 (data abort).
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" void __wrap_bk_trap_udef(arm_registers *regs) {
record_crash(regs, CRASH_REASON_UNDEFINED, (regs->spsr & CPSR_THUMB) ? 2 : 4);
__real_bk_trap_udef(regs);
}
extern "C" void __wrap_bk_trap_pabt(arm_registers *regs) {
record_crash(regs, CRASH_REASON_PREFETCH_ABORT, 4);
__real_bk_trap_pabt(regs);
}
extern "C" void __wrap_bk_trap_dabt(arm_registers *regs) {
record_crash(regs, CRASH_REASON_DATA_ABORT, 8);
__real_bk_trap_dabt(regs);
}
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
} // namespace esphome::libretiny
#endif // USE_LIBRETINY_CRASH_HANDLER
#endif // USE_BK72XX
@@ -0,0 +1,20 @@
#pragma once
#ifdef USE_BK72XX
#include "esphome/core/defines.h"
#ifdef USE_LIBRETINY_CRASH_HANDLER
namespace esphome::libretiny {
/// Log crash data if a crash was detected on previous boot.
void crash_handler_log();
/// Returns true if crash data was found this boot, reading it first if needed.
bool crash_handler_has_data();
} // namespace esphome::libretiny
#endif // USE_LIBRETINY_CRASH_HANDLER
#endif // USE_BK72XX
@@ -146,6 +146,11 @@ COMPONENT_CODEOWNERS = {
"ln882x": ["@lamauny"],
}
# Extra imports per component; the CLI looks up process_stacktrace on the platform package.
COMPONENT_EXTRA_IMPORTS = {
"bk72xx": "from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401",
}
def subst(code: str, key: str, value: str) -> str:
return code.replace(f"{{{key}}}", value)
@@ -222,6 +227,8 @@ def write_component_code(
import_lines = "\n".join(
f"from .{m} import {', '.join(sorted(v))}" for m, v in imports.items() if v
)
if extra := COMPONENT_EXTRA_IMPORTS.get(component):
import_lines = f"{extra}\n{import_lines}"
code = subst_many(
code,
("IMPORTS", import_lines),
@@ -0,0 +1,79 @@
# pylint: disable=E0602
Import("env") # noqa
import os
import re
import subprocess
# Adds a .noinit section for the crash record (crash_handler.cpp) to the
# BK7238 / BK7231N linker script, in ITCM after .itcm.code: ITCM survives
# the watchdog reset and the SDK startup only copies .itcm.code there, while
# the bootloader clears at least 128 KB of SRAM and the SDK's saved registers
# on every boot (measured on a BK7238). The LMA stays in ITCM. BK7231T/Q/7251
# have no ITCM region, so libretiny/__init__.py loads this for two families.
_MARKER = "/* esphome .noinit */"
_ITCM_CODE_END = re.compile(r"(\}\s*>\s*itcm\s+AT\s*>\s*flash[^\n]*\n)")
_NOINIT_BLOCK = (
"\t.noinit (NOLOAD) : " + _MARKER + "\n"
"\t{\n"
"\t\t. = ALIGN(8);\n"
"\t\tKEEP(*(.noinit .noinit.*))\n"
"\t} > itcm AT> itcm\n"
)
def _pre_link(target, source, env):
build_dir = env.subst("$BUILD_DIR")
found = False
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()
if _MARKER in content:
found = True
continue
new_content = _ITCM_CODE_END.sub(
lambda m: m.group(1) + _NOINIT_BLOCK, content, count=1
)
if new_content == content:
continue
with open(path, "w", encoding="utf-8") as fh:
fh.write(new_content)
print("ESPHome: patched {} (.noinit section)".format(name))
found = True
if not found:
raise RuntimeError(
"ESPHome: no .ld in {} got the .noinit section; the crash record "
"would not survive a reboot. Update the regex in "
"patch_noinit.py.script for this LibreTiny version.".format(build_dir)
)
ITCM_START = 0x003F0000
ITCM_END = 0x00400000
def _post_link(target, source, env):
"""Fail unless the linked ELF keeps .noinit in ITCM (stale marker, renamed script)."""
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
objdump = re.sub(r"(gcc-)?nm$", "objdump", env.subst("$NM")) # same toolchain prefix
headers = subprocess.check_output([objdump, "-h", elf], text=True)
for line in headers.splitlines():
fields = line.split()
if len(fields) > 3 and fields[1] == ".noinit":
vma = int(fields[3], 16)
if ITCM_START <= vma < ITCM_END:
return
raise RuntimeError(
"ESPHome: .noinit landed at 0x{:08x}, outside ITCM; the crash "
"record would not survive a reboot.".format(vma)
)
raise RuntimeError("ESPHome: the linked ELF has no .noinit section.")
# LibreTiny renders the .ld into $BUILD_DIR after this script loads; patch at link time.
env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", _pre_link)
env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", _post_link)
@@ -2,6 +2,9 @@
#include "logger.h"
#include "esphome/components/libretiny/core.h"
#ifdef USE_LIBRETINY_CRASH_HANDLER
#include "esphome/components/libretiny/crash_handler.h"
#endif
namespace esphome::logger {
@@ -52,6 +55,9 @@ void Logger::pre_setup() {
global_logger = this;
ESP_LOGI(TAG, "Log initialized");
#ifdef USE_LIBRETINY_CRASH_HANDLER
libretiny::crash_handler_log();
#endif
}
const LogString *Logger::get_uart_selection_() {
@@ -68,6 +74,11 @@ const LogString *Logger::get_uart_selection_() {
}
}
#ifdef USE_LIBRETINY_CRASH_HANDLER
bool crash_log_pending() { return libretiny::crash_handler_has_data(); }
void log_crash_to_client() { libretiny::crash_handler_log(); }
#endif
} // namespace esphome::logger
#endif // USE_LIBRETINY
+1 -47
View File
@@ -1,9 +1,7 @@
from collections.abc import Callable
import logging
from pathlib import Path
import re
from string import ascii_letters, digits
import subprocess
from typing import Any
import esphome.codegen as cg
@@ -34,6 +32,7 @@ from esphome.core import (
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
from esphome.platformio.toolchain import copy_ccache_script
from esphome.stacktrace import process_crash_handler_line as process_stacktrace # noqa: F401
from esphome.storage_json import StorageJSON
from esphome.types import ConfigType
@@ -654,48 +653,3 @@ def copy_files() -> None:
path = CORE.relative_src_path("esphome.h")
content = read_file(path).rstrip("\n")
write_file_if_changed(path, content + '\n#include "pio_includes.h"\n')
# RP2040 crash handler stacktrace decoding
# Matches output from esphome/components/rp2/crash_handler.cpp
_CRASH_RE = re.compile(r"CRASH DETECTED ON PREVIOUS BOOT")
_CRASH_ADDR_RE = re.compile(
r"(?:PC|LR|BT\d):\s+(0x[0-9a-fA-F]{8})\s+\((?:fault location|return address|stack backtrace)\)"
)
def _addr2line(tool: str, elf: Path, addr: str) -> str:
try:
result = subprocess.run(
[tool, "-pfiaC", "-e", str(elf), addr],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except (OSError, subprocess.CalledProcessError):
return f"{addr} (decode failed)"
def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool:
"""Decode RP2040 crash handler output using addr2line."""
if _CRASH_RE.search(line):
_LOGGER.error("RP2040 crash detected - decoding addresses")
return True
if backtrace_state:
if match := _CRASH_ADDR_RE.search(line):
from esphome.platformio.toolchain import get_idedata
idedata = get_idedata(config)
if idedata.addr2line_path:
elf = idedata.firmware_elf_path
if elf.exists():
decoded = _addr2line(idedata.addr2line_path, elf, match.group(1))
_LOGGER.error(" %s => %s", match.group(1), decoded)
# Stop backtrace state after addr2line hint (last line of crash dump)
if "addr2line" in line:
return False
return backtrace_state
+3
View File
@@ -704,6 +704,9 @@
#endif
#ifdef USE_LIBRETINY
#ifdef USE_BK72XX
#define USE_LIBRETINY_CRASH_HANDLER
#endif
#define USE_BK72XX_BLE
#define BK72XX_BLE_SCAN_LISTENER_COUNT 1
#define USE_LN882H_BLE
+6 -1
View File
@@ -23,6 +23,7 @@ import logging
from typing import Any, Final
from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_NRF52,
@@ -37,6 +38,9 @@ _LOGGER = logging.getLogger(__name__)
# (upload method, log transport) warns. A new hook is loud by default.
COSMETIC_HOOKS: Final = frozenset({"process_stacktrace"})
# Platforms whose firmware prints the crash handler report share one gate.
_CRASH_HANDLER_GATE = r"0x[0-9a-fA-F]{3,}\b|CRASH DETECTED ON PREVIOUS BOOT"
# Per-platform trigger languages for lazy stacktrace decoding: a
# matching line is what imports the platform package, so false triggers
# (8-digit uptime counters, ESP-IDF decimal timestamps) must stay out.
@@ -58,7 +62,8 @@ STACKTRACE_GATES: Final[dict[str, str]] = {
r"|>>>stack>>>"
r"|CRASH DETECTED ON PREVIOUS BOOT"
),
PLATFORM_RP2: r"0x[0-9a-fA-F]{3,}\b|CRASH DETECTED ON PREVIOUS BOOT",
PLATFORM_RP2: _CRASH_HANDLER_GATE,
PLATFORM_BK72XX: _CRASH_HANDLER_GATE,
PLATFORM_NRF52: r"0x[0-9a-fA-F]{3,}\b|Last crash:",
}
+63
View File
@@ -17,6 +17,7 @@ from esphome.types import ConfigType
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
# The contract every platform's process_stacktrace implements.
StacktraceHandler = Callable[[ConfigType, str, bool], bool]
@@ -122,3 +123,65 @@ class LogLineProcessor:
self._platform,
detail,
)
# Crash handler report (rp2 and libretiny crash_handler.cpp): one address per line.
CRASH_HANDLER_RE = re.compile(r"CRASH DETECTED ON PREVIOUS BOOT")
CRASH_HANDLER_ADDR_RE = re.compile(
r"(?:PC|LR|BT\d):\s+(0x[0-9a-fA-F]{8})\s+\((?:fault location|return address|stack backtrace)\)"
)
def _addr2line(tool: str, elf: Path, addr: str) -> str:
# Lazy: __main__ imports this module on every CLI start
import subprocess
try:
result = subprocess.run(
[tool, "-pfiaC", "-e", str(elf), addr],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except (OSError, subprocess.CalledProcessError) as err:
detail = getattr(err, "stderr", None) or str(err)
return f"{addr} (decode failed: {detail.strip()})"
def process_crash_handler_line(
config: ConfigType, line: str, backtrace_state: bool
) -> bool:
"""Decode a crash handler report with addr2line; the process_stacktrace hook of rp2 and bk72xx."""
if CRASH_HANDLER_RE.search(line):
_LOGGER.error("Crash detected - decoding addresses")
return True
if backtrace_state:
if match := CRASH_HANDLER_ADDR_RE.search(line):
from esphome.platformio.toolchain import get_idedata
addr = match.group(1)
idedata = get_idedata(config)
elf = idedata.firmware_elf_path
# Warn once per report and leave the dump region
if not idedata.addr2line_path:
_LOGGER.warning(
"Cannot decode the crash report: no addr2line in the toolchain"
)
return False
if not elf.exists():
_LOGGER.warning(
"Cannot decode the crash report: %s missing, compile locally first",
elf,
)
return False
_LOGGER.error(
" %s => %s", addr, _addr2line(idedata.addr2line_path, elf, addr)
)
# The addr2line hint is the last line of the report
if "addr2line" in line:
return False
return backtrace_state
+1
View File
@@ -59,6 +59,7 @@ PLATFORM_BUILD_SECONDS = {
"ln882x-ard": 55,
"esp32-s3-idf": 60,
"bk72xx-ard": 70,
"bk7238-ard": 70,
"esp32-c6-idf": 70,
}
NRF52_BUILD_SECONDS = 50
@@ -0,0 +1,5 @@
esphome:
name: test
bk72xx:
board: generic-bk7231t-qfn32-tuya
@@ -0,0 +1,5 @@
esphome:
name: test
bk72xx:
board: generic-bk7238
@@ -0,0 +1,37 @@
"""Only BK7238 and BK7231N, the BK chips with an ITCM linker region, get the crash handler."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.libretiny import BK_TRAP_HANDLERS
from esphome.core import CORE
@pytest.mark.parametrize(
("config", "enabled"),
[
("bk7238.yaml", True),
("bk72xx.yaml", True), # BK7231N
("bk7231t.yaml", False),
("ln882x.yaml", False),
],
)
def test_crash_handler_family_gate(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config: str,
enabled: bool,
) -> None:
generate_main(component_config_path(config))
defines = {define.name for define in CORE.defines}
assert ("USE_LIBRETINY_CRASH_HANDLER" in defines) is enabled
assert ("USE_CRASH_HANDLER" in defines) is enabled
for trap in BK_TRAP_HANDLERS:
assert (f"-Wl,--wrap={trap}" in CORE.build_flags) is enabled
assert (
"pre:patch_noinit.py" in CORE.platformio_options["extra_scripts"]
) is enabled
@@ -0,0 +1,13 @@
logger:
level: VERBOSE
esphome:
on_boot:
- lambda: |-
int x = 100;
x = clamp(x, 50, 90);
assert(x == 90);
x = clamp_at_least(x, 95);
assert(x == 95);
x = clamp_at_most(x, 40);
assert(x == 40);
@@ -0,0 +1,15 @@
esphome:
name: componenttestespbk7238
friendly_name: $component_name
bk72xx:
board: generic-bk7238
logger:
level: VERY_VERBOSE
packages:
component_under_test: !include
file: $component_test_file
vars:
component_test_file: $component_test_file
+2 -1
View File
@@ -117,6 +117,7 @@ from esphome.const import (
PLATFORM_HOST,
PLATFORM_NRF52,
PLATFORM_RP2,
PLATFORM_RTL87XX,
ErrorFormat,
Toolchain,
)
@@ -6143,7 +6144,7 @@ def test_run_miniterm_no_stacktrace_analyzer(
"""Platforms without a stacktrace analyzer log an info and stream anyway."""
mock_serial = MockSerial([b"[I][app:100]: Line 1\r\n", MOCK_SERIAL_END])
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_BK72XX}
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_RTL87XX}
config = {
CONF_LOGGER: {
CONF_BAUD_RATE: 115200,
+2 -2
View File
@@ -15,7 +15,7 @@ from unittest.mock import Mock
import pytest
from esphome import platform_hooks
from esphome.const import PLATFORM_BK72XX, PLATFORM_ESP32, Platform
from esphome.const import PLATFORM_ESP32, PLATFORM_RTL87XX, Platform
def test_no_unregistered_platform_exposes_a_hook() -> None:
@@ -165,7 +165,7 @@ def test_get_stacktrace_handler_reports_missing_analyzer(
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level("INFO", logger="esphome.platform_hooks")
assert platform_hooks.get_stacktrace_handler(PLATFORM_BK72XX) is None
assert platform_hooks.get_stacktrace_handler(PLATFORM_RTL87XX) is None
assert "no compatible analyzer" in caplog.text
# A capability gap is ordinary; it must not warn.
assert not any(r.levelno >= logging.WARNING for r in caplog.records)
+109 -6
View File
@@ -6,6 +6,7 @@ import importlib
import inspect
from pathlib import Path
import re
import subprocess
from unittest.mock import Mock, patch
from hypothesis import given, settings
@@ -19,6 +20,7 @@ from esphome.const import (
PLATFORM_ESP8266,
PLATFORM_NRF52,
PLATFORM_RP2,
PLATFORM_RTL87XX,
)
from esphome.core import EsphomeError
@@ -60,6 +62,10 @@ CRASH_SAMPLES: dict[str, dict[str, list[str]]] = {
"state_markers": ["CRASH DETECTED ON PREVIOUS BOOT"],
"addresses": ["PC: 0x10001234 (fault location)"],
},
PLATFORM_BK72XX: {
"state_markers": ["CRASH DETECTED ON PREVIOUS BOOT"],
"addresses": ["PC: 0x0001614A (fault location)"],
},
PLATFORM_NRF52: {
"state_markers": ["Last crash:"],
"addresses": [
@@ -168,6 +174,7 @@ FIRMWARE_MARKER_SOURCES = {
"esphome/components/esp32/crash_handler.cpp",
"esphome/components/esp8266/crash_handler.cpp",
"esphome/components/rp2/crash_handler.cpp",
"esphome/components/libretiny/crash_handler.cpp",
),
"Last crash:": ("esphome/components/logger/logger_zephyr.cpp",),
}
@@ -222,7 +229,8 @@ DECODER_PATTERNS: dict[str, list[str]] = {
"STACKTRACE_BAD_ALLOC_RE",
"STACKTRACE_ESP8266_BACKTRACE_PC_RE",
],
PLATFORM_RP2: ["_CRASH_RE", "_CRASH_ADDR_RE"],
PLATFORM_RP2: ["CRASH_HANDLER_RE", "CRASH_HANDLER_ADDR_RE"],
PLATFORM_BK72XX: ["CRASH_HANDLER_RE", "CRASH_HANDLER_ADDR_RE"],
PLATFORM_NRF52: ["STACKTRACE_NRF52_PC_LR_RE"],
}
@@ -236,6 +244,12 @@ GATE_EXEMPT_PATTERNS = {
}
def _decoder_module(platform: str):
"""Where a platform's decoder patterns live: its package, or its hook's module."""
module = importlib.import_module(f"esphome.components.{platform}")
return importlib.import_module(module.process_stacktrace.__module__)
@pytest.mark.parametrize("platform", sorted(CRASH_SAMPLES))
def test_platform_declarations_match_decoder(platform: str) -> None:
r"""Samples, declared patterns, and the decoder must agree.
@@ -253,9 +267,10 @@ def test_platform_declarations_match_decoder(platform: str) -> None:
pointer branch's ``\b`` are invisible to it.
"""
module = importlib.import_module(f"esphome.components.{platform}")
decoder = _decoder_module(platform)
patterns: dict[str, re.Pattern] = {}
for name in DECODER_PATTERNS[platform]:
pattern = getattr(module, name, None)
pattern = getattr(decoder, name, None)
if pattern is None:
pytest.fail(
f"{platform} no longer defines {name}; update DECODER_PATTERNS "
@@ -278,9 +293,9 @@ def test_platform_declarations_match_decoder(platform: str) -> None:
)
undeclared = [
name
for name, value in vars(module).items()
for name, value in vars(decoder).items()
if isinstance(value, re.Pattern)
and ("STACKTRACE" in name or name.startswith("_CRASH"))
and ("STACKTRACE" in name or name.startswith(("_CRASH", "CRASH_HANDLER")))
and name not in DECODER_PATTERNS[platform]
]
assert not undeclared, (
@@ -332,7 +347,7 @@ def test_address_gate_covers_decoder_pattern_languages(
"""Each platform's gate must be a superset of its decoder patterns;
generated inputs catch a widened decoder the finite samples miss.
"""
pattern = getattr(importlib.import_module(f"esphome.components.{platform}"), name)
pattern = getattr(_decoder_module(platform), name)
example = data.draw(from_regex(pattern, fullmatch=True))
gate = re.compile(stacktrace.platform_hooks.STACKTRACE_GATES[platform])
assert gate.search(example), (
@@ -533,7 +548,7 @@ def test_processor_registry_miss_disables_at_construction(
caplog.set_level("INFO", logger="esphome.platform_hooks")
with patch.object(stacktrace.platform_hooks, "import_module") as mock_import:
processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_BK72XX)
processor = stacktrace.LogLineProcessor(CONFIG, PLATFORM_RTL87XX)
processor.process_line("PC: 0x40104960")
mock_import.assert_not_called()
@@ -563,3 +578,91 @@ def test_external_platform_resolves_at_construction(
mock_import.assert_called_once()
assert processor.backtrace_state is False
# --- shared crash handler report decoder (rp2, bk72xx) ---
REPORT_PC_LINE = "[E][libretiny.crash:118]: PC: 0x0001D6DC (fault location)"
REPORT_HINT_LINE = "Use: addr2line -pfiaC -e raw_firmware.elf 0x0001D6DC"
def _idedata(
tmp_path: Path, *, addr2line: str | None = "addr2line", elf: bool = True
) -> Mock:
idedata = Mock()
idedata.addr2line_path = addr2line
idedata.firmware_elf_path = tmp_path / "raw_firmware.elf"
if elf:
idedata.firmware_elf_path.write_bytes(b"")
return idedata
def test_crash_report_marker_opens_the_dump_region() -> None:
assert stacktrace.process_crash_handler_line(
CONFIG, "*** CRASH DETECTED ON PREVIOUS BOOT ***", False
)
def test_crash_report_lines_outside_the_region_are_ignored(
mock_get_idedata: Mock,
) -> None:
assert not stacktrace.process_crash_handler_line(CONFIG, REPORT_PC_LINE, False)
mock_get_idedata.assert_not_called()
def test_crash_report_address_is_decoded(
tmp_path: Path, mock_get_idedata: Mock, caplog: pytest.LogCaptureFixture
) -> None:
mock_get_idedata.return_value = _idedata(tmp_path)
with patch.object(stacktrace, "_addr2line", return_value="main.cpp:12") as decode:
assert stacktrace.process_crash_handler_line(CONFIG, REPORT_PC_LINE, True)
decode.assert_called_once_with(
"addr2line", tmp_path / "raw_firmware.elf", "0x0001D6DC"
)
assert "0x0001D6DC => main.cpp:12" in caplog.text
def test_crash_report_hint_line_closes_the_region(mock_get_idedata: Mock) -> None:
assert not stacktrace.process_crash_handler_line(CONFIG, REPORT_HINT_LINE, True)
@pytest.mark.parametrize(
("addr2line", "elf", "reason"),
[(None, True, "no addr2line"), ("addr2line", False, "missing")],
)
def test_crash_report_warns_once_when_it_cannot_decode(
tmp_path: Path,
mock_get_idedata: Mock,
caplog: pytest.LogCaptureFixture,
addr2line: str | None,
elf: bool,
reason: str,
) -> None:
mock_get_idedata.return_value = _idedata(tmp_path, addr2line=addr2line, elf=elf)
# One warning per report
assert not stacktrace.process_crash_handler_line(CONFIG, REPORT_PC_LINE, True)
assert caplog.text.count(reason) == 1
def test_addr2line_returns_the_tool_output(tmp_path: Path) -> None:
elf = tmp_path / "fw.elf"
with patch("subprocess.run", return_value=Mock(stdout=" decoded \n")) as run:
assert stacktrace._addr2line("addr2line", elf, "0x1") == "decoded"
assert run.call_args.args[0] == ["addr2line", "-pfiaC", "-e", str(elf), "0x1"]
@pytest.mark.parametrize(
("error", "detail"),
[
(subprocess.CalledProcessError(1, "addr2line", stderr="bad ELF\n"), "bad ELF"),
(OSError("no such tool"), "no such tool"),
],
)
def test_addr2line_reports_the_failure(
tmp_path: Path, error: Exception, detail: str
) -> None:
with patch("subprocess.run", side_effect=error):
assert (
stacktrace._addr2line("addr2line", tmp_path / "fw.elf", "0x1")
== f"0x1 (decode failed: {detail})"
)