Keep the spec half honest: fingerprint-stamped ld cache, MMU table, emission helpers deferred

This commit is contained in:
J. Nick Koston
2026-08-20 14:19:53 -05:00
parent 9d54e87c70
commit cd0c3bb069
2 changed files with 61 additions and 126 deletions
+53 -81
View File
@@ -1,11 +1,11 @@
"""Native ninja build generator for the ESP8266 Arduino core.
"""Build specification for the native ESP8266 Arduino toolchain.
Transliterates the PlatformIO build spec for the Arduino ESP8266 framework
(``framework-arduinoespressif8266/tools/platformio-build.py`` plus
``platform-espressif8266/builder/main.py``) into a ``build.ninja`` under
``.pioenvs/<name>/``. The flag sets, defines, link line, linker-script
generation, and ``elf2bin`` invocation deliberately match what PlatformIO
produces so the binaries stay near-identical between the two toolchains.
``platform-espressif8266/builder/main.py``): the flag sets, defines, and
linker-script generation deliberately match what PlatformIO produces so the
binaries stay near-identical between the two toolchains. The ninja emission
(``write_project``) builds on these pieces.
The ``PIO_FRAMEWORK_ARDUINO_*`` knob defines (lwIP variant, NONOS SDK
version, MMU layout, exceptions, waveform phase) keep working: they are read
@@ -21,9 +21,6 @@ import re
import subprocess
from esphome.components.esp8266 import build_surgery
from esphome.components.esp8266.boards import BOARDS, ESP8266_LD_SCRIPTS
from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_FLASH_SIZE
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE, EsphomeError
from esphome.helpers import mkdir_p, write_file_if_changed
from esphome.platformio.library import join_flag_args, split_flag_entry
@@ -59,6 +56,36 @@ _LWIP_VARIANTS = (
)
_LWIP_DEFAULT = (536, 1, 0, "lwip2-536-feat")
# knob define -> MMU_* defines, first match wins (as in platformio-build.py)
_MMU_VARIANTS = (
(
"PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48",
["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000"],
),
(
"PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED",
["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000", "MMU_IRAM_HEAP"],
),
(
"PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM32_SECHEAP_NOTSHARED",
[
"MMU_IRAM_SIZE=0x8000",
"MMU_ICACHE_SIZE=0x4000",
"MMU_SEC_HEAP_SIZE=0x4000",
"MMU_SEC_HEAP=0x40108000",
],
),
(
"PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_128K",
["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000", "MMU_EXTERNAL_HEAP=128"],
),
(
"PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_1024K",
["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000", "MMU_EXTERNAL_HEAP=256"],
),
)
_MMU_DEFAULT = ("MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000")
_ASFLAGS = ["-mlongcalls", "-mtext-section-literals"]
_CFLAGS = [
"-std=gnu17",
@@ -179,40 +206,22 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig:
"VTABLES_IN_FLASH",
)
if "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48" in defines:
mmu = ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000"]
elif "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED" in defines:
mmu = ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000", "MMU_IRAM_HEAP"]
elif "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM32_SECHEAP_NOTSHARED" in defines:
mmu = [
"MMU_IRAM_SIZE=0x8000",
"MMU_ICACHE_SIZE=0x4000",
"MMU_SEC_HEAP_SIZE=0x4000",
"MMU_SEC_HEAP=0x40108000",
]
elif "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_128K" in defines:
mmu = [
"MMU_IRAM_SIZE=0x8000",
"MMU_ICACHE_SIZE=0x8000",
"MMU_EXTERNAL_HEAP=128",
]
elif "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_1024K" in defines:
mmu = [
"MMU_IRAM_SIZE=0x8000",
"MMU_ICACHE_SIZE=0x8000",
"MMU_EXTERNAL_HEAP=256",
]
elif "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" in defines:
if "MMU_IRAM_SIZE" not in defines or "MMU_ICACHE_SIZE" not in defines:
raise EsphomeError(
"PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM requires MMU_IRAM_SIZE and "
"MMU_ICACHE_SIZE build flags"
mmu = next((variant for knob, variant in _MMU_VARIANTS if knob in defines), None)
if mmu is None:
if "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" in defines:
if "MMU_IRAM_SIZE" not in defines or "MMU_ICACHE_SIZE" not in defines:
raise EsphomeError(
"PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM requires MMU_IRAM_SIZE and "
"MMU_ICACHE_SIZE build flags"
)
# Sorted so build.ninja and the linker-script stamp stay
# byte-stable across runs (the flag set has no deterministic
# iteration order).
mmu = sorted(
body for name, body in defines.items() if name.startswith("MMU_")
)
# Sorted so build.ninja and the linker-script stamp stay byte-stable
# across runs (the flag set has no deterministic iteration order).
mmu = sorted(body for name, body in defines.items() if name.startswith("MMU_"))
else:
mmu = ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000"]
else:
mmu = list(_MMU_DEFAULT)
return _BuildConfig(
nonosdk=nonosdk,
@@ -225,15 +234,6 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig:
)
def _flash_ld_name(board: str) -> str:
return ESP8266_LD_SCRIPTS[BOARDS[board][KEY_FLASH_SIZE]][1]
def _e(value) -> str:
"""Escape a path or token for a ninja file."""
return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
def _quote_arg(tok: str) -> str:
"""Wrap a token in double quotes with the Windows argv rule.
@@ -247,11 +247,6 @@ def _quote_arg(tok: str) -> str:
return f'"{quoted}"'
def _q(value) -> str:
"""Force-quote a path for the ninja command line (shell/CreateProcess)."""
return _quote_arg(str(value).replace("$", "$$"))
_NEEDS_QUOTE = re.compile(r'[\s"\']')
@@ -370,10 +365,9 @@ def generate_ld_scripts(
stamp_content = (
" ".join(cmd)
+ f" testing={CORE.testing_mode}"
+ f" {build_surgery.RATETABLE_RULE}"
+ f" {build_surgery.TESTING_IRAM_SIZE}"
+ f" {build_surgery.TESTING_DRAM_SIZE}"
+ f" {build_surgery.TESTING_FLASH_SIZE}"
# One fingerprint instead of enumerating surgery internals here, so
# any behavioral edit in build_surgery self-invalidates the cache
+ f" {build_surgery.surgery_fingerprint()}"
)
if not (
output.is_file()
@@ -404,25 +398,3 @@ def generate_ld_scripts(
("dram0_0_seg", "irom0_0_seg"),
),
)
def get_flash_ld_path(build_dir: Path) -> Path:
"""The flash linker script the link actually uses (for size reporting)."""
from esphome.arduino8266.framework import (
framework_package_version,
get_framework_path,
)
name = _flash_ld_name(CORE.data[KEY_ESP8266][KEY_BOARD])
if CORE.testing_mode:
return build_dir / "ld" / f"testing_{name}"
version = framework_package_version(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
return get_framework_path(version) / "tools" / "sdk" / "ld" / name
def _flash_size_str(flash_ld_name: str) -> str:
"""Flash size for elf2bin, derived from the ld script name (PIO logic)."""
match = re.search(r"\.flash\.(\d+)([mk])", flash_ld_name)
if not match:
raise EsphomeError(f"Cannot parse flash size from {flash_ld_name}")
return f"{match.group(1)}{match.group(2).upper()}"
+8 -45
View File
@@ -1,11 +1,11 @@
"""Drift tests for the native ESP8266 Arduino build generator.
"""Drift tests for the native ESP8266 Arduino build spec.
These pin the build spec transliterated from the PlatformIO builder
(framework-arduinoespressif8266/tools/platformio-build.py and
platform-espressif8266/builder/main.py) so a change on either side of the
toolchain seam is caught: the knob-define precedence, the define/flag sets,
the link line, and the core source exclusions must keep matching what the
PlatformIO toolchain produces for the same configuration.
and the linker-script generation must keep matching what the PlatformIO
toolchain produces for the same configuration.
"""
from __future__ import annotations
@@ -20,9 +20,7 @@ from esphome.build_gen import arduino8266
from esphome.build_gen.arduino8266 import (
_defines_flags,
_flag_defines,
_flash_size_str,
_resolve_build_config,
get_flash_ld_path,
)
from esphome.components.esp8266.boards import ESP8266_BOARD_BUILD
from esphome.components.esp8266.build_surgery import RATETABLE_RULE
@@ -289,10 +287,12 @@ def test_generate_ld_scripts(tmp_path: Path) -> None:
_run_generate_ld_scripts(paths)
mock_run.assert_not_called()
# An edit to the surgery constants invalidates the stamp (a stale linker
# script would otherwise persist until an esphome clean)
# A surgery edit invalidates the stamp via the fingerprint (a stale
# linker script would otherwise persist until an esphome clean)
with (
patch.object(arduino8266.build_surgery, "TESTING_FLASH_SIZE", "0x3000000"),
patch.object(
arduino8266.build_surgery, "surgery_fingerprint", return_value="changed"
),
patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run,
):
_run_generate_ld_scripts(paths)
@@ -328,37 +328,6 @@ def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None:
assert "len = 0x2000000" in patched
def test_get_flash_ld_path(tmp_path: Path) -> None:
CORE.testing_mode = True
assert get_flash_ld_path(tmp_path) == (
tmp_path / "ld" / "testing_eagle.flash.4m.ld"
)
CORE.testing_mode = False
with (
patch(
"esphome.arduino8266.framework.get_framework_path",
return_value=tmp_path / "framework",
),
patch(
"esphome.arduino8266.framework.framework_package_version",
return_value="3.30102.0",
),
):
assert get_flash_ld_path(tmp_path) == (
tmp_path / "framework" / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld"
)
def test_flash_size_str() -> None:
assert _flash_size_str("eagle.flash.4m.ld") == "4M"
assert _flash_size_str("eagle.flash.512k.ld") == "512K"
with pytest.raises(EsphomeError, match="Cannot parse flash size"):
_flash_size_str("bogus.ld")
def test_build_config_nonosdk_precedence() -> None:
"""With two SDK knobs set (a pathological config), ties break
deterministically by table order."""
@@ -447,9 +416,3 @@ def test_flag_defines_joins_spaced_define() -> None:
defines = _flag_defines()
assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines
assert "" not in defines
def test_ninja_path_escaping() -> None:
"""Build-statement paths and command-line paths escape differently."""
assert arduino8266._e("a b:$c") == "a$ b$:$$c"
assert arduino8266._q("/a b/$x") == '"/a b/$$x"'