From 42491569c87f8be4ff7a4dd813256b2e7d9893b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Jan 2026 17:53:53 -1000 Subject: [PATCH] [analyze_memory] Add nRF52/Zephyr platform support for memory analysis (#13249) --- esphome/analyze_memory/__init__.py | 8 ++- esphome/analyze_memory/const.py | 18 ++++++- esphome/analyze_memory/helpers.py | 8 +-- esphome/analyze_memory/toolchain.py | 84 ++++++++++++++++++++++++++++- script/determine-jobs.py | 9 +++- tests/script/test_determine_jobs.py | 30 +++++++++++ 6 files changed, 148 insertions(+), 9 deletions(-) diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index 9c935c78fa..63ef0e74ed 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -22,7 +22,7 @@ from .helpers import ( map_section_name, parse_symbol_line, ) -from .toolchain import find_tool, run_tool +from .toolchain import find_tool, resolve_tool_path, run_tool if TYPE_CHECKING: from esphome.platformio_api import IDEData @@ -132,6 +132,12 @@ class MemoryAnalyzer: readelf_path = readelf_path or idedata.readelf_path _LOGGER.debug("Using toolchain paths from PlatformIO idedata") + # Validate paths exist, fall back to find_tool if they don't + # This handles cases like Zephyr where cc_path doesn't include full path + # and the toolchain prefix may differ (e.g., arm-zephyr-eabi- vs arm-none-eabi-) + objdump_path = resolve_tool_path("objdump", objdump_path, objdump_path) + readelf_path = resolve_tool_path("readelf", readelf_path, objdump_path) + self.objdump_path = objdump_path or "objdump" self.readelf_path = readelf_path or "readelf" self.external_components = external_components or set() diff --git a/esphome/analyze_memory/const.py b/esphome/analyze_memory/const.py index aadc6a231c..83547b1eb5 100644 --- a/esphome/analyze_memory/const.py +++ b/esphome/analyze_memory/const.py @@ -15,6 +15,7 @@ ESPHOME_COMPONENT_PATTERN = re.compile(r"esphome::([a-zA-Z0-9_]+)::") # - LibreTiny RTL87xx: .xip.code_* (flash), .ram.code_* (RAM) # - LibreTiny BK7231: .itcm.code (fast RAM), .vectors (interrupt vectors) # - LibreTiny LN882X: .flash_text, .flash_copy* (flash code) +# - Zephyr/nRF52: text, rodata, datas, bss (no leading dots) SECTION_MAPPING = { ".text": frozenset( [ @@ -30,6 +31,9 @@ SECTION_MAPPING = { # LibreTiny LN882X flash code ".flash_text", ".flash_copy", + # Zephyr/nRF52 sections (no leading dots) + "text", + "rom_start", ] ), ".rodata": frozenset( @@ -37,6 +41,8 @@ SECTION_MAPPING = { ".rodata", # LibreTiny RTL87xx read-only data in RAM ".ram.code_rodata", + # Zephyr/nRF52 sections (no leading dots) + "rodata", ] ), # .bss patterns - must be before .data to catch ".dram0.bss" @@ -45,9 +51,19 @@ SECTION_MAPPING = { ".bss", # LibreTiny LN882X BSS ".bss_ram", + # Zephyr/nRF52 sections (no leading dots) + "bss", + "noinit", + ] + ), + ".data": frozenset( + [ + ".data", + ".dram", + # Zephyr/nRF52 sections (no leading dots) + "datas", ] ), - ".data": frozenset([".data", ".dram"]), } # Section to ComponentMemory attribute mapping diff --git a/esphome/analyze_memory/helpers.py b/esphome/analyze_memory/helpers.py index cb503b37c5..a6ca7e7f0d 100644 --- a/esphome/analyze_memory/helpers.py +++ b/esphome/analyze_memory/helpers.py @@ -94,13 +94,13 @@ def parse_symbol_line(line: str) -> tuple[str, str, int, str] | None: return None # Find section, size, and name + # Try each part as a potential section name for i, part in enumerate(parts): - if not part.startswith("."): - continue - + # Skip parts that are clearly flags, addresses, or other metadata + # Sections start with '.' (standard ELF) or are known section names (Zephyr) section = map_section_name(part) if not section: - break + continue # Need at least size field after section if i + 1 >= len(parts): diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py index 23d85e9700..3a8a5f7be4 100644 --- a/esphome/analyze_memory/toolchain.py +++ b/esphome/analyze_memory/toolchain.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import os from pathlib import Path import subprocess from typing import TYPE_CHECKING @@ -17,10 +18,82 @@ TOOLCHAIN_PREFIXES = [ "xtensa-lx106-elf-", # ESP8266 "xtensa-esp32-elf-", # ESP32 "xtensa-esp-elf-", # ESP32 (newer IDF) + "arm-zephyr-eabi-", # nRF52/Zephyr SDK + "arm-none-eabi-", # Generic ARM (RP2040, etc.) "", # System default (no prefix) ] +def _find_in_platformio_packages(tool_name: str) -> str | None: + """Search for a tool in PlatformIO package directories. + + This handles cases like Zephyr SDK where tools are installed in nested + directories that aren't in PATH. + + Args: + tool_name: Name of the tool (e.g., "readelf", "objdump") + + Returns: + Full path to the tool or None if not found + """ + # Get PlatformIO packages directory + platformio_home = Path(os.path.expanduser("~/.platformio/packages")) + if not platformio_home.exists(): + return None + + # Search patterns for toolchains that might contain the tool + # Order matters - more specific patterns first + search_patterns = [ + # Zephyr SDK deeply nested structure (4 levels) + # e.g., toolchain-gccarmnoneeabi/zephyr-sdk-0.17.4/arm-zephyr-eabi/bin/arm-zephyr-eabi-objdump + f"toolchain-*/*/*/bin/*-{tool_name}", + # Zephyr SDK nested structure (3 levels) + f"toolchain-*/*/bin/*-{tool_name}", + f"toolchain-*/bin/*-{tool_name}", + # Standard PlatformIO toolchain structure + f"toolchain-*/bin/*{tool_name}", + ] + + for pattern in search_patterns: + matches = list(platformio_home.glob(pattern)) + if matches: + # Sort to get consistent results, prefer arm-zephyr-eabi over arm-none-eabi + matches.sort(key=lambda p: ("zephyr" not in str(p), str(p))) + tool_path = str(matches[0]) + _LOGGER.debug("Found %s in PlatformIO packages: %s", tool_name, tool_path) + return tool_path + + return None + + +def resolve_tool_path( + tool_name: str, + derived_path: str | None, + objdump_path: str | None = None, +) -> str | None: + """Resolve a tool path, falling back to find_tool if derived path doesn't exist. + + Args: + tool_name: Name of the tool (e.g., "objdump", "readelf") + derived_path: Path derived from idedata (may not exist for some platforms) + objdump_path: Path to objdump binary to derive other tool paths from + + Returns: + Resolved path to the tool, or the original derived_path if it exists + """ + if derived_path and not Path(derived_path).exists(): + found = find_tool(tool_name, objdump_path) + if found: + _LOGGER.debug( + "Derived %s path %s not found, using %s", + tool_name, + derived_path, + found, + ) + return found + return derived_path + + def find_tool( tool_name: str, objdump_path: str | None = None, @@ -28,7 +101,8 @@ def find_tool( """Find a toolchain tool by name. First tries to derive the tool path from objdump_path (if provided), - then falls back to searching for platform-specific tools. + then searches PlatformIO package directories (for cross-compile toolchains), + and finally falls back to searching for platform-specific tools in PATH. Args: tool_name: Name of the tool (e.g., "objdump", "nm", "c++filt") @@ -47,7 +121,13 @@ def find_tool( _LOGGER.debug("Found %s at: %s", tool_name, potential_path) return potential_path - # Try platform-specific tools + # Search in PlatformIO packages directory first (handles Zephyr SDK, etc.) + # This must come before PATH search because system tools (e.g., /usr/bin/objdump) + # are for the host architecture, not the target (ARM, Xtensa, etc.) + if found := _find_in_platformio_packages(tool_name): + return found + + # Try platform-specific tools in PATH (fallback for when tools are installed globally) for prefix in TOOLCHAIN_PREFIXES: cmd = f"{prefix}{tool_name}" try: diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 7ecbfb225e..318ac04a7d 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -93,6 +93,7 @@ class Platform(StrEnum): RTL87XX_ARD = "rtl87xx-ard" # LibreTiny RTL8720x LN882X_ARD = "ln882x-ard" # LibreTiny LN882x RP2040_ARD = "rp2040-ard" # Raspberry Pi Pico + NRF52_ZEPHYR = "nrf52-adafruit" # Nordic nRF52 (Zephyr) # Memory impact analysis constants @@ -112,7 +113,7 @@ PLATFORM_SPECIFIC_COMPONENTS = frozenset( "rtl87xx", # Realtek RTL87xx platform implementation (uses LibreTiny) "ln882x", # Winner Micro LN882x platform implementation (uses LibreTiny) "host", # Host platform (for testing on development machine) - "nrf52", # Nordic nRF52 platform implementation + "nrf52", # Nordic nRF52 platform implementation (uses Zephyr) } ) @@ -126,6 +127,7 @@ PLATFORM_SPECIFIC_COMPONENTS = frozenset( # 4-6. Other ESP32 variants - Less commonly used but still supported # 7-9. LibreTiny platforms (BK72XX, RTL87XX, LN882X) - good for detecting LibreTiny-specific changes # 10. RP2040 - Raspberry Pi Pico platform +# 11. nRF52 - Nordic nRF52 with Zephyr (good for detecting Zephyr-specific changes) MEMORY_IMPACT_PLATFORM_PREFERENCE = [ Platform.ESP32_C6_IDF, # ESP32-C6 IDF (newest, supports Thread/Zigbee) Platform.ESP8266_ARD, # ESP8266 Arduino (most memory constrained, fastest builds) @@ -137,6 +139,7 @@ MEMORY_IMPACT_PLATFORM_PREFERENCE = [ Platform.RTL87XX_ARD, # LibreTiny RTL8720x Platform.LN882X_ARD, # LibreTiny LN882x Platform.RP2040_ARD, # Raspberry Pi Pico + Platform.NRF52_ZEPHYR, # Nordic nRF52 (Zephyr) ] @@ -463,6 +466,10 @@ def _detect_platform_hint_from_filename(filename: str) -> Platform | None: if "pico" in filename_lower or "rp2040" in filename_lower: return Platform.RP2040_ARD + # nRF52 / Zephyr + if "nrf52" in filename_lower or "zephyr" in filename_lower: + return Platform.NRF52_ZEPHYR + return None diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 52025513a8..61ef8985df 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1499,6 +1499,23 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) -> "tests/components/rp2040/test.rp2040-ard.yaml", determine_jobs.Platform.RP2040_ARD, ), + # nRF52 / Zephyr detection + ( + "tests/components/logger/test.nrf52-adafruit.yaml", + determine_jobs.Platform.NRF52_ZEPHYR, + ), + ( + "esphome/components/nrf52/gpio.cpp", + determine_jobs.Platform.NRF52_ZEPHYR, + ), + ( + "esphome/components/zephyr/core.cpp", + determine_jobs.Platform.NRF52_ZEPHYR, + ), + ( + "esphome/components/zephyr_ble_server/ble_server.cpp", + determine_jobs.Platform.NRF52_ZEPHYR, + ), # No platform hint (generic files) ("esphome/components/wifi/wifi.cpp", None), ("esphome/components/sensor/sensor.h", None), @@ -1528,6 +1545,10 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) -> "pico_i2c", "pico_spi", "rp2040_test_yaml", + "nrf52_test_yaml", + "nrf52_gpio", + "zephyr_core", + "zephyr_ble_server", "generic_wifi_no_hint", "generic_sensor_no_hint", "core_helpers_no_hint", @@ -1554,6 +1575,11 @@ def test_detect_platform_hint_from_filename( ("file_ESP8266.cpp", determine_jobs.Platform.ESP8266_ARD), # ESP32 with different cases ("file_ESP32.cpp", determine_jobs.Platform.ESP32_IDF), + # nRF52/Zephyr with different cases + ("file_NRF52.cpp", determine_jobs.Platform.NRF52_ZEPHYR), + ("file_Nrf52.cpp", determine_jobs.Platform.NRF52_ZEPHYR), + ("file_ZEPHYR.cpp", determine_jobs.Platform.NRF52_ZEPHYR), + ("file_Zephyr.cpp", determine_jobs.Platform.NRF52_ZEPHYR), ], ids=[ "rp2040_uppercase", @@ -1562,6 +1588,10 @@ def test_detect_platform_hint_from_filename( "pico_titlecase", "esp8266_uppercase", "esp32_uppercase", + "nrf52_uppercase", + "nrf52_mixedcase", + "zephyr_uppercase", + "zephyr_titlecase", ], ) def test_detect_platform_hint_from_filename_case_insensitive(