diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 19dbb68f29..1a4007b60d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -321,6 +321,7 @@ async def to_code(config: ConfigType) -> None: "pre:exclude_updater.py", "pre:exclude_waveform.py", "pre:relocate_ratetable.py", + "pre:relocate_sodium_sha256.py", ] if not enable_scanf_float: extra_scripts.append("pre:remove_float_scanf.py") @@ -463,6 +464,7 @@ def copy_files() -> None: "exclude_waveform", "remove_float_scanf", "relocate_ratetable", + "relocate_sodium_sha256", ): copy_file_if_changed( dir / f"{script}.py.script", diff --git a/esphome/components/esp8266/build_surgery.py b/esphome/components/esp8266/build_surgery.py index eb6ed1b91b..1f0f1351e5 100644 --- a/esphome/components/esp8266/build_surgery.py +++ b/esphome/components/esp8266/build_surgery.py @@ -24,6 +24,40 @@ _RATETABLE_COMMENT = ( # "_dport0_data_start" line in the earlier .dport0.data section _RATETABLE_ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE) +# Move libsodium's SHA-256 round constants from DRAM to flash. The Arduino +# core keeps .rodata in DRAM because flash only allows aligned 32-bit reads, +# but Krnd is a uint32_t[64] that the transform only ever reads word-wise, so +# it is safe in flash and frees 256 bytes of DRAM on every build that links +# libsodium (api or ota encryption). The rule goes inside .irom0.text, which +# the linker script places before the DRAM .rodata rules, so it wins. +SODIUM_SHA256_RULE = "*hash_sha256_cp.c.o(.rodata.Krnd)" +_SODIUM_SHA256_COMMENT = "/* ESPHome: libsodium SHA-256 round constants are read word-wise, keep them in flash */" +_SODIUM_SHA256_ANCHOR = re.compile( + r"^\s*_irom0_text_start = ABSOLUTE\(\.\);", re.MULTILINE +) + + +def relocate_sodium_sha256(content: str) -> str: + """Insert the libsodium round-constant flash rule into a generated common + linker script.""" + if SODIUM_SHA256_RULE in content: + return content + match = _SODIUM_SHA256_ANCHOR.search(content) + if match is None: + raise RuntimeError( + "'_irom0_text_start' anchor not found in the generated linker script; " + "cannot move the libsodium SHA-256 constants to flash " + "(has the Arduino core linker script changed?)" + ) + insert_pos = match.end() + return ( + content[:insert_pos] + + f"\n {_SODIUM_SHA256_COMMENT}" + + f"\n {SODIUM_SHA256_RULE}" + + content[insert_pos:] + ) + + # Memory sizes for testing mode (allow larger builds for CI component grouping) TESTING_IRAM_SIZE = "0x200000" # 2MB TESTING_DRAM_SIZE = "0x200000" # 2MB diff --git a/esphome/components/esp8266/relocate_sodium_sha256.py.script b/esphome/components/esp8266/relocate_sodium_sha256.py.script new file mode 100644 index 0000000000..ee10565acc --- /dev/null +++ b/esphome/components/esp8266/relocate_sodium_sha256.py.script @@ -0,0 +1,57 @@ +# pylint: disable=E0602 +Import("env") # noqa + +# Move libsodium's SHA-256 round constants from DRAM to flash +# +# The Arduino core linker script keeps every .rodata input section in DRAM, +# because flash-mapped memory only allows aligned 32-bit reads and most +# tables are read byte-wise. libsodium's Krnd (crypto_hash/sha256) is a +# uint32_t[64] that SHA256_Transform only reads word-wise, so it is safe in +# flash; every build that links libsodium (api or ota encryption) gets 256 +# bytes of DRAM back. The rule is placed inside the .irom0.text output +# section, which the linker script lists before the DRAM .rodata rules, so it +# claims the section first. Mirrored in build_surgery.py for the native +# toolchain; keep both in sync. + +import re +from os.path import join + +RULE = "*hash_sha256_cp.c.o(.rodata.Krnd)" +ANCHOR = re.compile(r"^\s*_irom0_text_start = ABSOLUTE\(\.\);", re.MULTILINE) + + +def relocate_sodium_sha256(source, target, env): + """Insert the flash rule into the generated linker script. + + Runs as a pre-action of the link step; the linker script is a declared + dependency of the elf, so it has already been generated at this point. + """ + ld_path = join(env.subst("$BUILD_DIR"), "ld", "local.eagle.app.v6.common.ld") + with open(ld_path, encoding="utf-8") as f: + contents = f.read() + + if RULE in contents: + return # Already patched (incremental build) + + match = ANCHOR.search(contents) + if match is None: + raise RuntimeError( + f"ESPHome: '_irom0_text_start' anchor not found in {ld_path}; " + "cannot move the libsodium SHA-256 constants to flash " + "(has the Arduino core linker script changed?)" + ) + + insert_pos = match.end() + patched = ( + contents[:insert_pos] + + "\n /* ESPHome: libsodium SHA-256 round constants are read word-wise, keep them in flash */" + + f"\n {RULE}" + + contents[insert_pos:] + ) + with open(ld_path, "w", encoding="utf-8") as f: + f.write(patched) + print("ESPHome: Moved libsodium SHA-256 constants to flash (256 bytes of DRAM)") + + +# Register the callback to run before the link step +env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", relocate_sodium_sha256) diff --git a/tests/unit_tests/components/esp8266/test_build_surgery.py b/tests/unit_tests/components/esp8266/test_build_surgery.py index 411a35eb96..768d85e93c 100644 --- a/tests/unit_tests/components/esp8266/test_build_surgery.py +++ b/tests/unit_tests/components/esp8266/test_build_surgery.py @@ -12,8 +12,10 @@ from esphome.components.esp8266 import build_surgery from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD from esphome.components.esp8266.build_surgery import ( RATETABLE_RULE, + SODIUM_SHA256_RULE, apply_testing_memory_patches, relocate_ratetable, + relocate_sodium_sha256, segment_length, ) @@ -27,6 +29,17 @@ _COMMON_LD_SNIPPET = """\ _data_start = ABSOLUTE(.); *(.data) } >dram0_0_seg :dram0_0_phdr + .irom0.text : ALIGN(4) + { + _irom0_text_start = ABSOLUTE(.); + *(.rodata._ZTV*) /* C++ vtables */ + } >irom0_0_seg :irom0_0_phdr + .rodata : ALIGN(4) + { + _rodata_start = ABSOLUTE(.); + *(.rodata) + *(.rodata.*) + } >dram0_0_seg :dram0_0_phdr """ # Shaped like the real SDK flash ld scripts: no iram1_0_seg (that lives in @@ -61,6 +74,21 @@ def test_relocate_ratetable_inserts_after_data_start() -> None: assert relocate_ratetable(patched) == patched +def test_relocate_sodium_sha256_inserts_in_irom0_text() -> None: + patched = relocate_sodium_sha256(_COMMON_LD_SNIPPET) + assert SODIUM_SHA256_RULE in patched + # Inside .irom0.text, ahead of the DRAM .rodata rules that would win otherwise + assert patched.index("_irom0_text_start") < patched.index(SODIUM_SHA256_RULE) + assert patched.index(SODIUM_SHA256_RULE) < patched.index("*(.rodata)") + # Idempotent on an already-patched script + assert relocate_sodium_sha256(patched) == patched + + +def test_relocate_sodium_sha256_requires_anchor() -> None: + with pytest.raises(RuntimeError, match="_irom0_text_start"): + relocate_sodium_sha256("SECTIONS { }") + + def test_relocate_ratetable_requires_anchor() -> None: with pytest.raises(RuntimeError, match="_data_start"): relocate_ratetable("SECTIONS { }")