From 6b67224286d16bb2ee55b6fca475faa445dfdea9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 13:41:39 -0500 Subject: [PATCH] [core] Chunk setup() into per-component noinline IIFEs Generated setup() is a single monolithic function whose stack frame scales super-linearly with config size. On a 5,943-line apollo build the frame reached 1,264 B at -Os; extrapolation onto larger configs (e.g. the 16k-line LVGL config in #15796) plausibly overflows the 8 KB loop task stack before safe_mode can increment its boot counter. Emit a ComponentMarker sentinel at the start of each component's to_code output, then have cpp_main_section wrap each component's block (and sub-splits of up to 50 statements within each block) in a noinline IIFE lambda. Each lambda's ENTRY frame is released on return, bounding peak stack to setup() frame + max chunk frame. Measured on apollo-r-pro-1-eth (esp32-s3, -Os): setup() frame 1264 B -> 160 B max chunk frame n/a -> 144 B peak setup stack 1264 B -> 304 B (-76%) total flash 792,471 B -> 791,995 B (-476 B) The brace-depth guard in _wrap_in_noinline_iifes ensures we never split between the RawStatement("{") / RawStatement("}") pair emitted by cg.with_local_variable() (currently only wifi), so scoped locals stay intact. --- esphome/__main__.py | 1 + esphome/codegen.py | 1 + esphome/core/__init__.py | 63 +++++++++++++++++++-- esphome/cpp_generator.py | 15 +++++ tests/unit_tests/test_core.py | 100 ++++++++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 6 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 7879cdad0c..5d6cf2972a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -569,6 +569,7 @@ def wrap_to_code(name, comp): @functools.wraps(comp.to_code) async def wrapped(conf): + cg.add(cg.ComponentMarker(name)) cg.add(cg.LineComment(f"{name}:")) if comp.config_schema is not None: conf_str = yaml_util.dump(conf) diff --git a/esphome/codegen.py b/esphome/codegen.py index a5b5abe447..ffaf368bd4 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -10,6 +10,7 @@ # pylint: disable=unused-import from esphome.cpp_generator import ( # noqa: F401 ArrayInitializer, + ComponentMarker, Expression, FlashStringLiteral, LineComment, diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 009fef2f86..43c1ffaed0 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -531,6 +531,39 @@ class Library: return self +def _wrap_in_noinline_iifes(lines: list[str], max_statements: int) -> list[str]: + """Wrap ``lines`` in one or more ``[]() [[gnu::noinline]] { ... }();`` IIFEs. + + Splits into IIFEs of up to ``max_statements`` entries each. Never splits + inside a brace-balanced block (e.g. the ``{`` / ``}`` pair that + ``cg.with_local_variable()`` emits around a scoped local), so an IIFE + may exceed ``max_statements`` when a block straddles the boundary. + """ + out: list[str] = [] + chunk: list[str] = [] + depth = 0 + + def flush() -> None: + if not chunk: + return + out.append("[]() [[gnu::noinline]] {") + out.extend(chunk) + out.append("}();") + chunk.clear() + + for line in lines: + chunk.append(line) + stripped = line.strip() + if stripped == "{": + depth += 1 + elif stripped == "}": + depth -= 1 + if depth == 0 and len(chunk) >= max_statements: + flush() + flush() + return out + + # pylint: disable=too-many-public-methods class EsphomeCore: def __init__(self): @@ -1003,14 +1036,32 @@ class EsphomeCore: @property def cpp_main_section(self): - from esphome.cpp_generator import statement + from esphome.cpp_generator import ComponentMarker, statement - main_code = [] + # Split main_statements at ComponentMarker sentinels into a prefix + # (statements emitted before any component) plus per-component groups. + prefix: list[str] = [] + components: list[list[str]] = [] + current = prefix for exp in self.main_statements: - text = str(statement(exp)) - text = text.rstrip() - main_code.append(text) - return "\n".join(main_code) + "\n\n" + if isinstance(exp, ComponentMarker): + current = [str(exp).rstrip()] + components.append(current) + continue + current.append(str(statement(exp)).rstrip()) + + # No components → flat output (host build, tests). + if not components: + return "\n".join(prefix) + "\n\n" + + # Each component's block is wrapped in a noinline IIFE lambda so its + # stack frame is released on return, bounding peak stack during + # setup(). Large blocks are sub-split to cap single heavy components + # (e.g. sensor platforms with many filter registrations). + pieces = list(prefix) + for block in components: + pieces.extend(_wrap_in_noinline_iifes(block, max_statements=50)) + return "\n".join(pieces) + "\n\n" @property def cpp_global_section(self): diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index cf90b878e1..d7205d1d8f 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -434,6 +434,21 @@ class LineComment(Statement): return "\n".join(parts) +class ComponentMarker(Statement): + """Sentinel marker recorded in main_statements when a component's + to_code begins emitting code. Used by cpp_main_section to split + setup() output into per-component chunks, so each component's + stack frame is released on return.""" + + __slots__ = ("name",) + + def __init__(self, name: str): + self.name = name + + def __str__(self): + return f"// === {self.name} ===" + + class ProgmemAssignmentExpression(AssignmentExpression): __slots__ = () diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 22be59653a..1c31f24066 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -7,6 +7,7 @@ import pytest from strategies import mac_addr_strings from esphome import const, core +from esphome.cpp_generator import ComponentMarker, RawStatement class TestHexInt: @@ -867,3 +868,102 @@ class TestEsphomeCore: mock_enable.assert_called_once_with("Wire") assert "Wire" in target.platformio_libraries + + +def test_wrap_in_noinline_iifes_empty_input() -> None: + assert not core._wrap_in_noinline_iifes([], max_statements=10) + + +def test_wrap_in_noinline_iifes_fewer_lines_than_limit() -> None: + lines = ["a();", "b();", "c();"] + assert core._wrap_in_noinline_iifes(lines, max_statements=10) == [ + "[]() [[gnu::noinline]] {", + "a();", + "b();", + "c();", + "}();", + ] + + +def test_wrap_in_noinline_iifes_splits_at_max_statements() -> None: + lines = [f"s{i}();" for i in range(5)] + result = core._wrap_in_noinline_iifes(lines, max_statements=2) + # With max=2 and 5 lines: chunks of 2, 2, 1 → 3 IIFEs. + assert sum(1 for line in result if line.startswith("[]()")) == 3 + + +def test_wrap_in_noinline_iifes_never_splits_inside_braces() -> None: + # max=2 would naively split after "{" but brace guard keeps block whole. + lines = ["a();", "{", "inner();", "}", "b();"] + assert core._wrap_in_noinline_iifes(lines, max_statements=2) == [ + "[]() [[gnu::noinline]] {", + "a();", + "{", + "inner();", + "}", + "}();", + "[]() [[gnu::noinline]] {", + "b();", + "}();", + ] + + +def test_wrap_in_noinline_iifes_nested_braces() -> None: + lines = ["{", "{", "deep();", "}", "}", "after();"] + assert core._wrap_in_noinline_iifes(lines, max_statements=1) == [ + "[]() [[gnu::noinline]] {", + "{", + "{", + "deep();", + "}", + "}", + "}();", + "[]() [[gnu::noinline]] {", + "after();", + "}();", + ] + + +def test_wrap_in_noinline_iifes_unbalanced_braces_fall_through() -> None: + # Pathological input where "}" appears before "{": don't crash; emit + # a single IIFE with all lines rather than splitting mid-flight. + lines = ["a();", "}", "b();"] + result = core._wrap_in_noinline_iifes(lines, max_statements=1) + assert result[0] == "[]() [[gnu::noinline]] {" + assert result[-1] == "}();" + assert [line for line in result if line in lines] == lines + + +def test_cpp_main_section_no_components_emits_flat() -> None: + target = core.EsphomeCore() + target.main_statements = [RawStatement("a();"), RawStatement("b();")] + out = target.cpp_main_section + assert "[[gnu::noinline]]" not in out + assert "a();" in out + assert "b();" in out + + +def test_cpp_main_section_component_marker_wraps_in_iife() -> None: + target = core.EsphomeCore() + target.main_statements = [ + ComponentMarker("logger"), + RawStatement("new_logger();"), + ComponentMarker("wifi"), + RawStatement("new_wifi();"), + ] + out = target.cpp_main_section + assert out.count("[]() [[gnu::noinline]] {") == 2 + assert out.count("}();") == 2 + assert "// === logger ===" in out + assert "// === wifi ===" in out + + +def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None: + target = core.EsphomeCore() + target.main_statements = [ + RawStatement("prefix();"), + ComponentMarker("c"), + RawStatement("body();"), + ] + out = target.cpp_main_section + assert out.index("prefix();") < out.index("[]() [[gnu::noinline]] {")