mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-16 00:10:11 -04:00
* fix(nrf52): LTO was dropping the board variant's weak hook overrides Whole-image LTO (enabled arch-wide for nrf52840 in #10655) inlines the empty weak body of earlyInitVariant()/lateInitVariant()/variant_shutdown()/ variant_nrf52LoopHook()/variantDefault*Config() at the call site, because the weak default and the call site live in the SAME translation unit. The strong override in variants/<arch>/<board>/variant.cpp is then never linked, and the board's hardware setup silently does not run. nrf52_lto.py's -fno-lto variant recompile does not help here: the caller is the problem, not the variant object. Needs both ingredients, so this only affects 2.8: the earlyInitVariant() indirection landed in #9438 and is present in v2.7.26 too, but v2.7.26 has no -flto, so the override linked normally. Found on the muzi R1 Neo, whose earlyInitVariant() drives DCDC_EN_HOLD (P0.13, the DC-DC hold after the user button) and NRF_ON (P0.29, "tells IO controller device is on"). Both were dropped from the image, so the companion MCU never saw the nRF application come up and stayed in its DFU indication (purple LED). Verified in the ELF: pre-fix setup() runs straight from waitUntilPowerLevelSafe() to the LED_NOTIFICATION block with no earlyInitVariant symbol in the binary and no pinMode/digitalWrite on P0.13 or P0.29 anywhere; post-fix it calls the real override. HW-confirmed on an R1 Neo. Also affected on nrf52840: earlyInitVariant() on 10 variants (incl. t-echo-card, which sequences its RT9080 3V3 rail there), variant_shutdown() on 18 variants (t114, t-echo, ThinkNode M1-M8, meshlink, wio-tracker-L1 ... - sleep pin parking, so deep-sleep leakage), variant_nrf52LoopHook() on 3 RAK variants. Confirmed dropped on heltec-mesh-node-t114 by build, not just by inspection. Fix is __attribute__((noinline)) on both the weak declaration and definition - the same guard already carried by loopCanSleep(), preFSBegin(), PowerHAL and variant_enableBatteryLpcompWake(), whose comment in main-nrf52.cpp already documents this exact failure mode. Also extend _VARIANT_OVERRIDES in extra_scripts/nrf52_lto.py from just _Z11initVariantv to all eight hooks. That post-link guard already had the right logic and would have caught this on every PR - it simply was not listing Meshtastic's own weak variant hooks, only the core's. With the list extended it goes red on both r1-neo and heltec-mesh-node-t114 when the noinline is reverted, and green with it. Its failure message now names both possible causes. * review: trim the noinline rationale comments to two lines Per AGENTS.md ("keep code comments minimal - one or two lines, max"), the incident detail and extended background belong in the PR description, not the source. Keeps the LTO/noinline rationale and the pointer to the guard. --------- Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
334 lines
16 KiB
Python
334 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
# trunk-ignore-all(ruff/F821)
|
|
# trunk-ignore-all(flake8/F821)
|
|
#
|
|
# Whole-image LTO for nrf52840 (~-60KB; ~-23KB beyond src-only LTO), EXCEPT the objects
|
|
# that own interrupt/exception handlers.
|
|
#
|
|
# Every ISR is referenced only from the assembly vector table (gcc_startup_nrf52840.S),
|
|
# which LTO cannot see -> whole-program LTO judges the handlers dead, removes them, and
|
|
# the weak `b .` Default_Handler stubs prevail -> the IRQ lands in an infinite loop and the
|
|
# chip hangs (or the peripheral silently stalls). Compiling the handler-bearing objects
|
|
# WITHOUT LTO lets ordinary linking keep the strong handlers; everything else stays LTO'd:
|
|
# - framework core (/FrameworkArduino/, /cores/nRF5/): every nrfx ISR + the FreeRTOS
|
|
# SVC/PendSV port.
|
|
# - TinyUSB nrf port (Adafruit_TinyUSB_nrf.cpp): USBD_IRQHandler (USB data path).
|
|
# - library .cpp files that own a vector ISR (would otherwise be silently dropped):
|
|
# bluefruit.cpp -> SD_EVT/SWI2_EGU2 (SoftDevice BLE-event delivery -- advertising
|
|
# hangs without it)
|
|
# Wire_nRF52.cpp -> SPIM0/TWIM0 + SPIM1/TWIM1 (interrupt-driven I2C/SPI)
|
|
# PDM.cpp -> PDM_IRQHandler (PDM microphone)
|
|
# RotaryEncoder.cpp -> QDEC_IRQHandler (hardware quadrature/rotary encoder)
|
|
#
|
|
# A post-link guard (bottom of this file) fails the build if a critical handler was dropped
|
|
# anyway -- so a future deps bump or a new ISR-owning library becomes a red build, not a field
|
|
# hang. To hunt a dropped ISR by hand: nm the .elf for `_IRQHandler$` symbols marked `W`, then
|
|
# grep the libs/framework for who defines them.
|
|
#
|
|
# HW-validated: RAK4631 (SX1262) + muzi-base (LR1121).
|
|
import glob
|
|
import os
|
|
|
|
Import("env")
|
|
|
|
env.Append(LINKFLAGS=["-flto", "-flto-partition=1to1"])
|
|
|
|
# The ISR -fno-lto re-compiles below run with the global env, which lacks the framework's
|
|
# bundled-library include dirs -- and those libs cross-include each other (Wire pulls in
|
|
# Adafruit_TinyUSB.h, which pulls in SPI.h, ...). Add every bundled-lib dir (+ its src/) so
|
|
# the re-compiles resolve without chasing headers one at a time.
|
|
_fw = env.PioPlatform().get_package_dir("framework-arduinoadafruitnrf52") or ""
|
|
_extra_inc = []
|
|
for _d in sorted(glob.glob(os.path.join(_fw, "libraries", "*"))):
|
|
if os.path.isdir(_d):
|
|
_extra_inc.append(_d)
|
|
if os.path.isdir(os.path.join(_d, "src")):
|
|
_extra_inc.append(os.path.join(_d, "src"))
|
|
|
|
FRAMEWORK = ("/FrameworkArduino/", "/cores/nRF5/")
|
|
USB_ISR = "Adafruit_TinyUSB_nrf" # USBD_IRQHandler
|
|
# Library .cpp files that define vector-table ISRs (the rest of their lib stays LTO'd):
|
|
LIB_ISR = ("/bluefruit.cpp", "/Wire_nRF52.cpp", "/PDM.cpp", "/RotaryEncoder.cpp")
|
|
|
|
|
|
# The active board variant file owns strong overrides of the core's weak initVariant()/
|
|
# earlyInitVariant()/lateInitVariant() stubs (cores/nRF5/main.cpp). Same failure mode as the ISRs
|
|
# above: whole-image LTO mis-resolves the call (made from the -fno-lto framework core) to the empty
|
|
# WEAK stub, so the board's early hardware setup -- e.g. driving PIN_3V3_EN to power the LoRa/sensor
|
|
# rail -- silently never runs and the radio probe finds no chip. Compiling the variant without LTO
|
|
# lets ordinary linking pick the strong override. HW-proven on nrf52_promicro_diy_tcxo (boot-trace
|
|
# 2026-06-30). NOTE: only the real board variant (built from /variants/...) -- NOT the guarded
|
|
# src/platform/extra_variants/*/variant.cpp no-op stubs, which don't tolerate the -fno-lto recompile.
|
|
#
|
|
# Anchor to THIS repo's variants/ tree: a board variant lives at
|
|
# <PROJECT_DIR>/variants/<arch>/<board>/variant.cpp. Anchoring deliberately excludes
|
|
# - PlatformIO's framework-owned .../framework-arduinoadafruitnrf52/variants/*/variant.cpp
|
|
# - the src/platform/extra_variants/*/variant.cpp stubs (they live under src/, not variants/)
|
|
# both of which also end in "/variant.cpp" but must NOT be recompiled here.
|
|
#
|
|
# CAUTION: middleware nodes come from the SCons variant-dir mirror, so node.get_abspath() is
|
|
# $BUILD_DIR/variants/.../variant.cpp -- NOT the repo path. Matching the anchor against the
|
|
# mirrored path never hits, which silently re-enables LTO on the variant (the post-link guard
|
|
# below turns that into a red build). srcnode() undoes the mirror and yields the real source
|
|
# path -- the same trick piobuild.py itself uses for middleware pattern matching.
|
|
_PROJECT_VARIANTS = (
|
|
env.subst("$PROJECT_DIR").replace("\\", "/").rstrip("/") + "/variants/"
|
|
)
|
|
|
|
|
|
def _is_board_variant(node, path):
|
|
try:
|
|
src = node.srcnode().get_abspath().replace("\\", "/")
|
|
except Exception:
|
|
src = path
|
|
return (
|
|
src.endswith("/variant.cpp")
|
|
and src.startswith(_PROJECT_VARIANTS)
|
|
and "extra_variants" not in src
|
|
)
|
|
|
|
|
|
# projenv is the construction env PlatformIO uses to compile project sources (src/ + the board
|
|
# variant). Unlike the bare framework env captured above, it carries the -DAPP_VERSION... flags
|
|
# and the generated-protobuf include path (pb.h) that bin/platformio-custom.py appends *after*
|
|
# this pre-script's eval. It isn't exported yet at eval time, but it is by the time the build
|
|
# middleware fires -- so fetch it lazily from SCons' export registry, falling back to env.
|
|
_projenv_cache = []
|
|
|
|
|
|
def _get_projenv():
|
|
if not _projenv_cache:
|
|
try:
|
|
from SCons.Script.SConscript import global_exports
|
|
|
|
_projenv_cache.append(global_exports.get("projenv", env))
|
|
except Exception:
|
|
_projenv_cache.append(env)
|
|
return _projenv_cache[0]
|
|
|
|
|
|
def _variant_ccflags(target, source, env, for_signature):
|
|
# Deferred CCFLAGS for the board variant recompile. SCons calls this while substituting the
|
|
# compile command (build phase), so projenv's CCFLAGS now include the -DAPP_VERSION... flags
|
|
# that bin/platformio-custom.py appends as a POST extra_script. -fno-lto goes last to override
|
|
# the inherited -flto and keep the variant's strong initVariant() out of whole-image LTO.
|
|
return list(_get_projenv()["CCFLAGS"]) + ["-fno-lto"]
|
|
|
|
|
|
def _no_lto(node):
|
|
try:
|
|
path = node.get_abspath()
|
|
except Exception:
|
|
path = str(node)
|
|
path = path.replace(
|
|
"\\", "/"
|
|
) # normalize Windows backslashes so matches work cross-platform
|
|
if (
|
|
USB_ISR in path
|
|
or any(s in path for s in FRAMEWORK)
|
|
or any(s in path for s in LIB_ISR)
|
|
):
|
|
return env.Object(
|
|
node,
|
|
CCFLAGS=env["CCFLAGS"] + ["-fno-lto"],
|
|
CPPPATH=env["CPPPATH"] + _extra_inc,
|
|
)
|
|
if _is_board_variant(node, path):
|
|
# The board variant is a project source, not a framework object: it can #include
|
|
# configuration.h/sleep.h, which need the -DAPP_VERSION... define and the generated-
|
|
# protobuf include path (pb.h). Recompile it with projenv, which carries both.
|
|
#
|
|
# TIMING: those -DAPP_VERSION... flags are appended to projenv by bin/platformio-custom.py,
|
|
# which is an unprefixed extra_script -> PlatformIO runs it as a POST script, i.e. AFTER
|
|
# $BUILD_SCRIPT, where this build middleware already fired. So projenv["CCFLAGS"] read HERE
|
|
# is a pre-append snapshot with no -DAPP_VERSION -> the recompile dies with
|
|
# "APP_VERSION must be set". Defer the read to a callable construction variable: SCons
|
|
# invokes it during command substitution (after every SConscript, post-scripts included),
|
|
# by which point projenv carries the version flags. -fno-lto is appended last so it wins.
|
|
build_env = _get_projenv()
|
|
return build_env.Object(
|
|
node,
|
|
CCFLAGS=_variant_ccflags,
|
|
CPPPATH=build_env["CPPPATH"] + _extra_inc,
|
|
)
|
|
return node
|
|
|
|
|
|
env.AddBuildMiddleware(_no_lto)
|
|
|
|
|
|
# --- post-link guard: catch a dropped ISR handler at build time (CI footgun protection) ----
|
|
# After every link, fail the build if one of these critical vector-table handlers resolved to
|
|
# the weak `b .` Default_Handler stub -- i.e. LTO (or a deps bump, or a new ISR-owning library
|
|
# that nobody added to LIB_ISR) silently dropped it. A dropped handler hangs the chip the
|
|
# instant that IRQ fires; this turns a field hang into a red build. CI builds every nrf52840
|
|
# target, so this runs on every PR automatically. If a board deliberately stops using one of
|
|
# these, edit the tuples on purpose.
|
|
_REQUIRED_STRONG = (
|
|
"SWI2_EGU2_IRQHandler", # SoftDevice BLE event (SD_EVT) -- advertising & connections
|
|
"GPIOTE_IRQHandler", # GPIO interrupts: radio DIO + buttons
|
|
"RTC1_IRQHandler", # FreeRTOS scheduler tick
|
|
)
|
|
# Owned by the TinyUSB stack, so only required when the board builds with USB at all.
|
|
# Boards without native USB wiring (e.g. wio-sdk-wm1110's CH340 UART) strip TinyUSB via
|
|
# disable_adafruit_usb.py / unflagging USE_TINYUSB, leaving these legitimately weak.
|
|
_REQUIRED_STRONG_USB = (
|
|
"USBD_IRQHandler", # USB CDC (serial console + 1200bps DFU trigger)
|
|
"POWER_CLOCK_IRQHandler", # USB power events (VBUS detect/ready) via TinyUSB hal
|
|
)
|
|
|
|
_tc = env.PioPlatform().get_package_dir("toolchain-gccarmnoneeabi") or ""
|
|
_NM = os.path.join(_tc, "bin", "arm-none-eabi-nm")
|
|
if not os.path.isfile(_NM):
|
|
_NM = "arm-none-eabi-nm" # fall back to PATH
|
|
|
|
|
|
def _assert_isr_handlers_survived(source, target, env):
|
|
import subprocess
|
|
import sys
|
|
|
|
try:
|
|
# Resolve the ELF at build time; target[0] is the buildprog alias, not the file.
|
|
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
|
|
out = subprocess.check_output([_NM, elf], universal_newlines=True)
|
|
except Exception as exc: # tooling hiccup: warn loudly, don't wedge the build
|
|
print("nrf52_lto: WARNING - ISR-handler guard skipped (nm failed: %s)" % exc)
|
|
return
|
|
# nm line: "<addr> <type> <symbol>". type 'T'/'t' = strong (good); 'W'/'w' = weak stub.
|
|
kind = {}
|
|
for line in out.split("\n"):
|
|
f = line.split()
|
|
if len(f) >= 3 and f[-1].endswith("_IRQHandler"):
|
|
kind[f[-1]] = f[-2]
|
|
required = list(_REQUIRED_STRONG)
|
|
defines = [
|
|
str(d[0] if isinstance(d, tuple) else d) for d in env.get("CPPDEFINES", [])
|
|
]
|
|
if "USE_TINYUSB" in defines:
|
|
required += _REQUIRED_STRONG_USB
|
|
dropped = [h for h in required if kind.get(h, "W").upper() != "T"]
|
|
if dropped:
|
|
sys.stderr.write(
|
|
"\n*** nrf52 LTO guard: interrupt handler(s) DROPPED: %s ***\n"
|
|
"Each resolved to the weak Default_Handler stub, so the chip hangs when that IRQ\n"
|
|
"fires. Compile the .cpp that defines the handler with -fno-lto by adding it to\n"
|
|
"LIB_ISR in extra_scripts/nrf52_lto.py. Find the owner of FOO_IRQHandler with:\n"
|
|
" grep -rl FOO_IRQHandler <framework-arduinoadafruitnrf52>/{libraries,cores}\n\n"
|
|
% ", ".join(dropped)
|
|
)
|
|
from SCons.Script import Exit
|
|
|
|
Exit(1) # canonical SCons build-abort -> red build
|
|
print(
|
|
"nrf52_lto: ISR-handler guard OK -- %d critical handlers strong" % len(required)
|
|
)
|
|
|
|
|
|
# --- post-link guard #2: the board variant's strong overrides must reach the ELF -----------
|
|
# The active variant.cpp overrides the core's weak initVariant() (cores/nRF5/main.cpp). If the
|
|
# -fno-lto middleware above stops matching it -- e.g. a path-shape change; the middleware sees
|
|
# $BUILD_DIR-mirrored nodes, and anchoring against the repo path already broke the match once
|
|
# (2026-07) -- LTO resolves the core's call to the empty weak stub, the board's early hardware
|
|
# setup silently vanishes (promicro DIY: PIN_3V3_EN rail never driven -> SX1262 CHIP_NOT_FOUND),
|
|
# and the build stays green. Turn that into a red build:
|
|
# 1. the linked variant.cpp.o must not be an LTO object (proves the -fno-lto recompile fired);
|
|
# 2. any override the object defines strong must resolve strong in the ELF.
|
|
#
|
|
# The list must also cover Meshtastic's OWN weak variant hooks, not just the core's. Those have
|
|
# a second, independent way to vanish: their weak default AND their call site sit in the same
|
|
# LTO'd translation unit (src/main.cpp, src/platform/nrf52/main-nrf52.cpp), so GCC inlines the
|
|
# empty body at the call site and never reaches for the variant's strong override -- the
|
|
# -fno-lto middleware above cannot help, the caller is the problem. The definitions carry
|
|
# __attribute__((noinline)) to prevent it; this guard is what catches a future one that forgets.
|
|
# Regression that motivated the extension: 2.8 dropped earlyInitVariant() on the muzi R1 Neo, so
|
|
# DCDC_EN_HOLD/NRF_ON were never driven and the IO controller read the nRF as stuck in DFU
|
|
# (purple LED). The build stayed green because only _Z11initVariantv was listed here.
|
|
_VARIANT_OVERRIDES = (
|
|
"_Z11initVariantv", # core hook (cores/nRF5/main.cpp)
|
|
"_Z16earlyInitVariantv", # src/main.cpp -- pre-peripheral board bring-up
|
|
"_Z15lateInitVariantv", # src/main.cpp -- post-radio board bring-up
|
|
"_Z16variant_shutdownv", # main-nrf52.cpp -- pin parking before System OFF
|
|
"_Z21variant_nrf52LoopHookv", # main-nrf52.cpp -- per-loop variant hook
|
|
"_Z31variant_enableBatteryLpcompWakev", # main-nrf52.cpp -- LPCOMP wake opt-out
|
|
"_Z20variantDefaultConfigv", # NodeDB.cpp -- per-board config defaults
|
|
"_Z26variantDefaultModuleConfigv", # NodeDB.cpp -- per-board module defaults
|
|
) # extend if the core (or Meshtastic) grows more weak variant hooks
|
|
|
|
|
|
def _assert_variant_survived(source, target, env):
|
|
import subprocess
|
|
import sys
|
|
|
|
objs = glob.glob(
|
|
os.path.join(env.subst("$BUILD_DIR"), "variants", "**", "variant.cpp.o"),
|
|
recursive=True,
|
|
)
|
|
if not objs:
|
|
return # env links no in-repo board variant
|
|
problems = []
|
|
elf_kind = {}
|
|
obj_kind = {}
|
|
try:
|
|
for obj in objs:
|
|
# An LTO object carries .gnu.lto_* sections; their names live in the shstrtab,
|
|
# so a byte scan is a sufficient (and toolchain-free) detector.
|
|
with open(obj, "rb") as f:
|
|
if b".gnu.lto" in f.read():
|
|
problems.append(
|
|
"%s was compiled WITH LTO -- the -fno-lto middleware did not match it"
|
|
% obj
|
|
)
|
|
|
|
def _kinds(nm_out):
|
|
kinds = {}
|
|
for line in nm_out.split("\n"):
|
|
f = line.split()
|
|
if len(f) >= 3:
|
|
kinds[f[-1]] = f[-2]
|
|
return kinds
|
|
|
|
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
|
|
elf_kind = _kinds(subprocess.check_output([_NM, elf], universal_newlines=True))
|
|
for obj in objs:
|
|
obj_kind.update(
|
|
_kinds(subprocess.check_output([_NM, obj], universal_newlines=True))
|
|
)
|
|
except Exception as exc: # tooling hiccup (e.g. an nm that can't read slim LTO
|
|
# objects): warn and skip the symbol comparison, but never let it mask a
|
|
# violation the .gnu.lto byte-scan above already recorded in `problems`.
|
|
print("nrf52_lto: WARNING - variant guard incomplete (%s)" % exc)
|
|
if not problems:
|
|
return
|
|
for sym in _VARIANT_OVERRIDES:
|
|
if (
|
|
obj_kind.get(sym, "").upper() == "T"
|
|
and elf_kind.get(sym, "W").upper() != "T"
|
|
):
|
|
problems.append(
|
|
"%s is strong in variant.cpp.o but weak/absent in the ELF "
|
|
"(LTO resolved the call to the empty weak stub)" % sym
|
|
)
|
|
if problems:
|
|
sys.stderr.write(
|
|
"\n*** nrf52 LTO guard: board variant override DROPPED from the image ***\n%s\n"
|
|
"That board hardware setup silently will not run. Two possible causes:\n"
|
|
" 1. The weak default and its CALL SITE share one LTO'd translation unit\n"
|
|
" (src/main.cpp, src/platform/nrf52/main-nrf52.cpp, src/mesh/NodeDB.cpp), so GCC\n"
|
|
" inlined the empty body and never reached the override. Fix: mark BOTH the weak\n"
|
|
" declaration and definition __attribute__((noinline)) -- see earlyInitVariant().\n"
|
|
" 2. The -fno-lto middleware stopped matching the variant. Check _is_board_variant()\n"
|
|
" below -- middleware nodes are $BUILD_DIR-mirrored, so match srcnode() paths,\n"
|
|
" not node.get_abspath().\n\n" % "\n".join(" - " + p for p in problems)
|
|
)
|
|
from SCons.Script import Exit
|
|
|
|
Exit(1)
|
|
print("nrf52_lto: variant guard OK -- board variant kept out of LTO")
|
|
|
|
|
|
# Attach to the phony "buildprog" alias, NOT the .elf file node: SCons can skip a post-action
|
|
# on a file target during an incremental relink (observed), but the buildprog alias runs every
|
|
# build -- so the guard fires on local incremental rebuilds and clean CI builds alike.
|
|
env.AddPostAction("buildprog", _assert_isr_handlers_survived)
|
|
env.AddPostAction("buildprog", _assert_variant_survived)
|