From 89ce3ab98c72a5aac199040562420a8770f94236 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Sep 2026 05:05:26 +0100 Subject: [PATCH] [nrf52] Run analyze-memory on the sdk-nrf toolchain (#19730) --- esphome/__main__.py | 14 +++--- esphome/build_helpers/native.py | 18 ++++++++ esphome/components/nrf52/framework.py | 11 +++++ esphome/components/nrf52/toolchain.py | 28 ++++++++++++ tests/unit_tests/test_main.py | 9 +++- tests/unit_tests/test_nrf52_toolchain.py | 54 ++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 esphome/components/nrf52/toolchain.py create mode 100644 tests/unit_tests/test_nrf52_toolchain.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 707ad03609..c3502db4ba 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Protocol # cause them to be loaded before external components are processed, resulting # in the built-in version being used instead of the external component one. from esphome import const, platform_hooks -from esphome.build_helpers.native import native_backend +from esphome.build_helpers.native import analysis_backend, native_backend from esphome.const import ( ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, @@ -2015,8 +2015,8 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: from esphome.analyze_memory.ram_strings import RamStringsAnalyzer # Refuse an unsupported toolchain before paying for a full compile - native_toolchain = native_backend() - if native_toolchain is None and not CORE.using_toolchain_platformio: + analysis_toolchain = analysis_backend() + if analysis_toolchain is None and not CORE.using_toolchain_platformio: _LOGGER.error( "analyze-memory is not supported with the '%s' toolchain on %s; " "re-run with --toolchain platformio", @@ -2036,9 +2036,9 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: # Get idedata for analysis idedata = None - if native_toolchain is not None: - objdump = native_toolchain.get_objdump_path() - readelf = native_toolchain.get_readelf_path() + if analysis_toolchain is not None: + objdump = analysis_toolchain.get_objdump_path() + readelf = analysis_toolchain.get_readelf_path() for tool in (objdump, readelf): if not tool.is_file(): # The analyzer would silently fall back to host @@ -2052,7 +2052,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: objdump_path = str(objdump) readelf_path = str(readelf) - firmware_elf = native_toolchain.get_elf_path() + firmware_elf = analysis_toolchain.get_elf_path() if not firmware_elf.is_file(): # The analyzer swallows tool failures, so a missing ELF would # produce an exit-0 zeroed report diff --git a/esphome/build_helpers/native.py b/esphome/build_helpers/native.py index cde526b803..24499c3ccd 100644 --- a/esphome/build_helpers/native.py +++ b/esphome/build_helpers/native.py @@ -32,3 +32,21 @@ def native_backend() -> ModuleType | None: f"module for platform {CORE.target_platform}" ) return importlib.import_module(module_path) + + +# Binutils and the linked image for memory analysis, for toolchains that build +# without PlatformIO but have no native build backend (which supplies them) +ANALYSIS_TOOLCHAIN_MODULES = { + ("nrf52", Toolchain.SDK_NRF): "esphome.components.nrf52.toolchain", +} + + +def analysis_backend() -> ModuleType | None: + """The module giving objdump, readelf and the ELF of a non-PlatformIO build. + + None means PlatformIO's idedata supplies them (or nothing can). + """ + if (native := native_backend()) is not None: + return native + module_path = ANALYSIS_TOOLCHAIN_MODULES.get((CORE.target_platform, CORE.toolchain)) + return importlib.import_module(module_path) if module_path else None diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index ebfbbe41b2..6e57c46c7e 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -80,6 +80,17 @@ def _get_toolchain_path(version: str) -> Path: return get_sdk_nrf_tools_path() / "toolchains" / version +def toolchain_tool(name: str) -> Path: + """Path to one of the pinned Zephyr SDK's tools (objdump, readelf, ...). + + The single owner of the ``arm-zephyr-eabi/bin/arm-zephyr-eabi-`` + layout and the Windows suffix. + """ + suffix = ".exe" if os.name == "nt" else "" + bin_path = _get_toolchain_path(TOOLCHAIN_VERSION) / "arm-zephyr-eabi" / "bin" + return bin_path / f"arm-zephyr-eabi-{name}{suffix}" + + _SITECUSTOMIZE = """\ import os, stat, shutil _orig = shutil.rmtree diff --git a/esphome/components/nrf52/toolchain.py b/esphome/components/nrf52/toolchain.py new file mode 100644 index 0000000000..aaa317aa5f --- /dev/null +++ b/esphome/components/nrf52/toolchain.py @@ -0,0 +1,28 @@ +"""Binutils and the linked image of an nRF52 sdk-nrf build, for memory analysis.""" + +from pathlib import Path + +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE + +from .framework import toolchain_tool + + +def get_objdump_path() -> Path: + return toolchain_tool("objdump") + + +def get_readelf_path() -> Path: + return toolchain_tool("readelf") + + +def get_elf_path() -> Path: + """The linked Zephyr image, at the layout the configured SDK version writes. + + Chosen by version rather than by probing so a leftover image from another + SDK layout is never analyzed in place of the current build. + """ + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] < cv.Version(2, 9, 2): + return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf") + return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf") diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index b5e7181269..4f11be0cb8 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -7489,6 +7489,8 @@ def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None: [ (PLATFORM_ESP8266, Toolchain.ARDUINO, "esphome.arduino8266.toolchain"), (PLATFORM_ESP32, Toolchain.ESP_IDF, "esphome.espidf.toolchain"), + # No native build backend, but its binutils and ELF are known + (PLATFORM_NRF52, Toolchain.SDK_NRF, "esphome.components.nrf52.toolchain"), ], ) def test_command_analyze_memory_native_toolchains( @@ -7708,7 +7710,12 @@ def test_command_analyze_memory_unsupported_toolchain( mock_write_cpp.return_value = 0 mock_compile_program.return_value = 0 - result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}}) + # Every toolchain has analysis hooks today; drop sdk-nrf's to stand in for + # one that does not + with patch.dict( + "esphome.build_helpers.native.ANALYSIS_TOOLCHAIN_MODULES", clear=True + ): + result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}}) assert result == 1 assert "analyze-memory is not supported" in caplog.text diff --git a/tests/unit_tests/test_nrf52_toolchain.py b/tests/unit_tests/test_nrf52_toolchain.py new file mode 100644 index 0000000000..ee8f6eaf49 --- /dev/null +++ b/tests/unit_tests/test_nrf52_toolchain.py @@ -0,0 +1,54 @@ +"""Tests for the nRF52 sdk-nrf memory analysis hooks.""" + +import os +from pathlib import Path + +import pytest + +from esphome.components.nrf52 import toolchain +from esphome.components.nrf52.framework import TOOLCHAIN_VERSION, get_sdk_nrf_tools_path +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE + + +@pytest.fixture +def nrf52_build(tmp_path: Path) -> Path: + CORE.name = "test_device" + CORE.build_path = tmp_path / "build" / "test_device" + return CORE.build_path / ".pioenvs" / "test_device" / "zephyr" + + +def _set_sdk_version(version: cv.Version) -> None: + CORE.data.setdefault(KEY_CORE, {})[KEY_FRAMEWORK_VERSION] = version + + +def test_binutils_come_from_the_pinned_zephyr_sdk() -> None: + bin_path = ( + get_sdk_nrf_tools_path() + / "toolchains" + / TOOLCHAIN_VERSION + / "arm-zephyr-eabi" + / "bin" + ) + # Windows hosts get the .exe binaries (CI covers both) + suffix = ".exe" if os.name == "nt" else "" + assert toolchain.get_objdump_path() == bin_path / f"arm-zephyr-eabi-objdump{suffix}" + assert toolchain.get_readelf_path() == bin_path / f"arm-zephyr-eabi-readelf{suffix}" + + +def test_elf_uses_the_nested_layout_from_sdk_2_9_2(nrf52_build: Path) -> None: + _set_sdk_version(cv.Version(2, 9, 2)) + # A flat image left by an older SDK must not be picked over the current + # layout, so the choice follows the version rather than what exists + (nrf52_build / "zephyr.elf").parent.mkdir(parents=True) + (nrf52_build / "zephyr.elf").write_text("") + assert toolchain.get_elf_path() == nrf52_build / "zephyr" / "zephyr.elf" + + +def test_elf_uses_the_flat_layout_before_sdk_2_9_2(nrf52_build: Path) -> None: + _set_sdk_version(cv.Version(2, 6, 1)) + nested = nrf52_build / "zephyr" / "zephyr.elf" + nested.parent.mkdir(parents=True) + nested.write_text("") + assert toolchain.get_elf_path() == nrf52_build / "zephyr.elf"