diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 5b14af5a1d..44369136a0 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -54,6 +54,7 @@ from esphome.components.esp8266.const import ( KEY_ESP8266, KEY_FLASH_SIZE, KEY_SCANF_FLOAT, + THROW_STUBS_HEADER, ) from esphome.core import CORE, EsphomeError from esphome.framework_helpers import get_project_cxx_compile_flags @@ -1087,32 +1088,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines: core_exclude |= _CORE_EXCLUDE_WAVEFORM - archives = [] - # variant_dir existence was already enforced with the include dirs - variant_sources = collect_sources(variant_dir) - if variant_sources: - objs = compile_edges(lines, variant_sources, variant_dir, "variant") - lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") - archives.append("libFrameworkArduinoVariant.a") - - core_objs = compile_edges( - lines, collect_sources(core_dir, core_exclude), core_dir, "core" - ) - if not core_objs: - # An empty archive would link into a wall of undefined references - # (app_entry, the exception vectors) far from the cause - raise EsphomeError( - f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}" - ) - lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}") - archives.append("libFrameworkArduino.a") - - lib_archives, direct_objs = library_edges(lines, libraries) - archives += lib_archives - - # One source of truth with the PlatformIO path: esp8266/__init__ pins - # build_src_flags (the throw_stubs force-include); -include paths - # resolve against the source root + # esp8266/__init__ pins build_src_flags (the throw_stubs force-include), as on + # the PlatformIO path; -include paths resolve against the source root src_other: list[str] = [] src_includes: list[str] = [] src_it = iter( @@ -1131,7 +1108,45 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: src_includes.append(tok[len("-include") :]) else: src_other.append(_shell_token(tok)) + # The throw stubs abort: none anywhere with exceptions on, else the core and + # libraries get them too (src keeps them after its pch include) + if config.exceptions: + src_includes = [h for h in src_includes if h != THROW_STUBS_HEADER] include_flags = [f"-include {_q(src_dir / h)}" for h in src_includes] + framework_flags = "" + if THROW_STUBS_HEADER in src_includes: + stubs_flag = include_flags[src_includes.index(THROW_STUBS_HEADER)] + lines.append(f"frameworkflags = {stubs_flag}") + framework_flags = "$frameworkflags" + archives = [] + # variant_dir existence was already enforced with the include dirs + variant_sources = collect_sources(variant_dir) + if variant_sources: + objs = compile_edges( + lines, variant_sources, variant_dir, "variant", flags=framework_flags + ) + lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") + archives.append("libFrameworkArduinoVariant.a") + + core_objs = compile_edges( + lines, + collect_sources(core_dir, core_exclude), + core_dir, + "core", + flags=framework_flags, + ) + if not core_objs: + # An empty archive would link into a wall of undefined references + # (app_entry, the exception vectors) far from the cause + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}" + ) + lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}") + archives.append("libFrameworkArduino.a") + + lib_archives, direct_objs = library_edges(lines, libraries, framework_flags) + archives += lib_archives + # One shared variable instead of repeating the flags line on every src # edge (hundreds of edges in a real project) lines.append(f"srcflags = {' '.join(src_other + include_flags)}") diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py index 8e72b36b3f..a2ee244a29 100644 --- a/esphome/build_helpers/ninja_gen.py +++ b/esphome/build_helpers/ninja_gen.py @@ -207,10 +207,12 @@ def compile_edges( def library_edges( - lines: list[str], libraries: list[ArduinoLibrary] + lines: list[str], libraries: list[ArduinoLibrary], extra_flags: str = "" ) -> tuple[list[str], list[str]]: """Emit every library's compile and archive edges. + ``extra_flags`` (raw ninja text) precede each library's own flags. + Returns the archive names and the objects that link directly. A library's own include dirs lead its compile lines, as PlatformIO searched them first: the include path is one global list, so another library's @@ -232,7 +234,7 @@ def library_edges( lib.sources, common_parent(lib.sources), f"lib/{lib.name}", - flags=" ".join(_shell_token(f) for f in lib.flags), + flags=" ".join(filter(None, [extra_flags, *map(_shell_token, lib.flags)])), own_includes=" ".join(f"-I{_q(d)}" for d in lib.include_dirs), ) if not lib.lib_archive: diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index d0d870c289..57fac2dc98 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -53,6 +53,7 @@ from .const import ( KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, KEY_WAVEFORM_REQUIRED, + THROW_STUBS_HEADER, enable_serial, enable_serial1, esp8266_ns, @@ -477,11 +478,9 @@ async def to_code(config: ConfigType) -> None: # Force-include inline std::__throw_* overrides so GCC dead-strips the unused # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. - # See throw_stubs.h. Unconditional: the native build generator reads - # the same option, keeping one source of truth. - cg.add_platformio_option( - "build_src_flags", "-include esphome/components/esp8266/throw_stubs.h" - ) + # See throw_stubs.h. The native build generator reads this option, also + # passes it to the core and libraries, and drops it with exceptions on. + cg.add_platformio_option("build_src_flags", f"-include {THROW_STUBS_HEADER}") # In testing mode, fake larger memory to allow linking grouped component tests # Real ESP8266 hardware only has 32KB IRAM and ~80KB RAM, but for CI testing diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index bf4dd1a358..fb50b6a5ca 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -17,6 +17,8 @@ KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" # Set for the native (non-PlatformIO) toolchain's build generator KEY_SCANF_FLOAT = "scanf_float" +# Force-included std::__throw_* overrides, relative to the src dir +THROW_STUBS_HEADER = "esphome/components/esp8266/throw_stubs.h" # Per-board flash-layout override consumed by board_ld_script() KEY_LDSCRIPT = "ldscript" # Crash-decode tools, resolved once per run by __init__ diff --git a/esphome/components/esp8266/throw_stubs.h b/esphome/components/esp8266/throw_stubs.h index a650935a5e..c18814d4d8 100644 --- a/esphome/components/esp8266/throw_stubs.h +++ b/esphome/components/esp8266/throw_stubs.h @@ -15,7 +15,8 @@ * unused, dead-strip the load, and drop the string entirely -- no LTO needed. * Behavior is identical to today: a bare abort() (the message was never * printed). This header MUST be force-included before , so it is - * wired up via build_src_flags "-include ..." in this component's __init__.py. + * wired up via build_src_flags "-include ..." in this component's __init__.py; + * the native toolchain also force-includes it into the core and libraries. * * Note: this defines functions in namespace std (technically UB). It is safe * here because the definitions match the existing abort() behavior exactly. diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 066746d36d..2ea4ffabe6 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -34,7 +34,12 @@ from esphome.components.esp8266.boards import ( board_ld_script, ) from esphome.components.esp8266.build_surgery import RATETABLE_RULE -from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_SCANF_FLOAT +from esphome.components.esp8266.const import ( + KEY_BOARD, + KEY_ESP8266, + KEY_SCANF_FLOAT, + THROW_STUBS_HEADER, +) import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError @@ -360,18 +365,22 @@ def test_write_project_link_line_and_exclusions(tmp_path: Path) -> None: # Assembly and C sources compile through their own rules assert "cont.S.o: aspp" in content assert "abi.c.o: c" in content - # throw_stubs is force-included for ESPHome sources only, via one shared - # srcflags variable rather than a copy of the flags line per edge + # throw_stubs reaches src through srcflags (after the pch include) and the + # core through frameworkflags: one shared variable each, not a copy per edge src_lines = [line for line in content.splitlines() if "obj/src/" in line] assert any("main.cpp.o: cxx" in line for line in src_lines) - assert content.count("throw_stubs.h") == 1 + assert content.count("throw_stubs.h") == 2 assert "srcflags = -include" in content flags_lines = [ line for line in content.splitlines() if line.startswith(" flags = ") ] assert flags_lines # C++ src edges consume the precompiled header; C/assembly keep srcflags - assert set(flags_lines) == {" flags = $srcflags", " flags = $srccxxflags"} + assert set(flags_lines) == { + " flags = $srcflags", + " flags = $srccxxflags", + " flags = $frameworkflags", + } def test_write_project_pch(tmp_path: Path) -> None: @@ -715,6 +724,8 @@ def test_write_project_libraries_and_variant( assert "libHeadersOnly.a" not in content assert "Library HeadersOnly has no source files" in caplog.text assert " flags = -DMYLIB=1" in content + # With exceptions on, nothing gets the stubs + assert "throw_stubs.h" not in content # A library's own include dirs lead its compile lines assert " own_includes = -I" in content assert "$own_includes $cxxflags $flags" in content @@ -729,6 +740,30 @@ def test_write_project_libraries_and_variant( assert f"ccache = {_shq('/cc/ccache')}" in content +def test_write_project_throw_stubs_reach_core_and_libraries(tmp_path: Path) -> None: + """Without exceptions the core and libraries take the throw stubs too.""" + paths = _make_framework(tmp_path) + lib_dir = tmp_path / "libsrc" + lib_dir.mkdir() + (lib_dir / "lib.cpp").write_text("") + library = ArduinoLibrary( + name="MyLib", + sources=[lib_dir / "lib.cpp"], + include_dirs=[lib_dir], + flags=["-DMYLIB=1"], + ) + content = _write_ninja(paths, libraries=[library]) + stubs = CORE.relative_src_path() / THROW_STUBS_HEADER + assert f"frameworkflags = -include {_shq(str(stubs))}" in content + # Before the library's own flags; core edges take the variable alone + assert " flags = $frameworkflags -DMYLIB=1" in content + lines = content.splitlines() + core_edge = next( + i for i, line in enumerate(lines) if "core_esp8266_main.cpp.o: cxx" in line + ) + assert lines[core_edge + 1] == " flags = $frameworkflags" + + def test_get_flash_ld_path(tmp_path: Path) -> None: paths = InstalledPaths(