[analyze-memory] Attribute PlatformIO library symbols via nm archive scanning

This commit is contained in:
J. Nick Koston
2026-02-08 11:14:34 -06:00
parent 8c4a732eb7
commit 0f1ece6bdc
2 changed files with 162 additions and 4 deletions
+149 -3
View File
@@ -43,6 +43,7 @@ _READELF_SECTION_PATTERN = re.compile(
# Component category prefixes
_COMPONENT_PREFIX_ESPHOME = "[esphome]"
_COMPONENT_PREFIX_EXTERNAL = "[external]"
_COMPONENT_PREFIX_LIB = "[lib]"
_COMPONENT_CORE = f"{_COMPONENT_PREFIX_ESPHOME}core"
_COMPONENT_API = f"{_COMPONENT_PREFIX_ESPHOME}api"
@@ -56,6 +57,9 @@ SymbolInfoType = tuple[str, int, str]
# RAM sections - symbols in these sections consume RAM
RAM_SECTIONS = frozenset([".data", ".bss"])
# nm symbol types for global/weak defined symbols (used for library symbol mapping)
_NM_DEFINED_GLOBAL_TYPES = frozenset({"T", "D", "B", "R", "W", "V"})
@dataclass
class MemorySection:
@@ -179,11 +183,16 @@ class MemoryAnalyzer:
self._sdk_symbols: list[SDKSymbol] = []
# CSWTCH symbols: list of (name, size, source_file, component)
self._cswtch_symbols: list[tuple[str, int, str, str]] = []
# PlatformIO library symbol mapping: symbol_name -> library_name
self._lib_symbol_map: dict[str, str] = {}
# PlatformIO library hash to name mapping: "lib641" -> "espsoftwareserial"
self._lib_hash_to_name: dict[str, str] = {}
def analyze(self) -> dict[str, ComponentMemory]:
"""Analyze the ELF file and return component memory usage."""
self._parse_sections()
self._parse_symbols()
self._scan_pio_libraries()
self._categorize_symbols()
self._analyze_cswtch_symbols()
self._analyze_sdk_libraries()
@@ -328,6 +337,10 @@ class MemoryAnalyzer:
# If no component match found, it's core
return _COMPONENT_CORE
# Check PlatformIO library symbol map (more accurate than heuristic patterns)
if lib_name := self._lib_symbol_map.get(symbol_name):
return f"{_COMPONENT_PREFIX_LIB}{lib_name}"
# Check against symbol patterns
for component, patterns in SYMBOL_PATTERNS.items():
if any(pattern in symbol_name for pattern in patterns):
@@ -384,6 +397,135 @@ class MemoryAnalyzer:
return "Other Core"
def _discover_pio_libraries(self) -> dict[str, Path]:
"""Discover PlatformIO third-party libraries from the build directory.
Scans ``lib<hex>/`` directories under ``.pioenvs/<env>/`` to find
library names and their ``.a`` archive paths.
Returns:
Dictionary mapping lowercase library name to ``.a`` file path.
"""
# The ELF is typically at .pioenvs/<env>/firmware.elf
build_dir = self.elf_path.parent
libraries: dict[str, Path] = {}
for entry in build_dir.iterdir():
if not entry.is_dir() or not entry.name.startswith("lib"):
continue
# Validate that the suffix after "lib" is a hex hash
hex_part = entry.name[3:]
if not hex_part:
continue
try:
int(hex_part, 16)
except ValueError:
continue
# Each lib<hex>/ directory contains a subdirectory named after the library
# and a .a archive named lib<LibraryName>.a
for lib_subdir in entry.iterdir():
if not lib_subdir.is_dir():
continue
# The .a file is named lib<LibraryName>.a (case-insensitive match)
# e.g., lib72a/ESPAsyncTCP/... has lib72a/libESPAsyncTCP.a
archive = entry / f"lib{lib_subdir.name}.a"
if not archive.exists():
# Try case-insensitive: scan for any .a file
archives = list(entry.glob("*.a"))
archive = archives[0] if archives else None
if archive and archive.exists():
libraries[lib_subdir.name.lower()] = archive
_LOGGER.debug(
"Discovered PlatformIO library: %s -> %s",
lib_subdir.name,
archive,
)
return libraries
def _build_library_symbol_map(self, libraries: dict[str, Path]) -> dict[str, str]:
"""Build a symbol-to-library mapping from library archives.
Runs ``nm --defined-only`` on each ``.a`` file to collect global and
weak defined symbols.
Args:
libraries: Dictionary mapping library name to ``.a`` file path.
Returns:
Dictionary mapping symbol name to library name.
"""
symbol_map: dict[str, str] = {}
if not self.nm_path:
return symbol_map
for lib_name, archive_path in libraries.items():
result = run_tool(
[self.nm_path, "--defined-only", str(archive_path)],
timeout=10,
)
if result is None or result.returncode != 0:
continue
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) < 3:
continue
sym_type = parts[-2]
sym_name = parts[-1]
# Include global defined symbols (uppercase) and weak symbols (W/V)
if sym_type in _NM_DEFINED_GLOBAL_TYPES:
symbol_map[sym_name] = lib_name
return symbol_map
def _scan_pio_libraries(self) -> None:
"""Discover PlatformIO libraries and build symbol mapping.
Orchestrates library discovery, symbol map construction, and
hash-to-name mapping for CSWTCH attribution.
"""
libraries = self._discover_pio_libraries()
if not libraries:
_LOGGER.debug("No PlatformIO third-party libraries found")
return
_LOGGER.info(
"Scanning %d PlatformIO libraries: %s",
len(libraries),
", ".join(sorted(libraries)),
)
self._lib_symbol_map = self._build_library_symbol_map(libraries)
# Build hash-to-name mapping for CSWTCH attribution
# e.g., lib641 -> espsoftwareserial
build_dir = self.elf_path.parent
for entry in build_dir.iterdir():
if not entry.is_dir() or not entry.name.startswith("lib"):
continue
hex_part = entry.name[3:]
if not hex_part:
continue
try:
int(hex_part, 16)
except ValueError:
continue
for lib_subdir in entry.iterdir():
if lib_subdir.is_dir():
self._lib_hash_to_name[entry.name] = lib_subdir.name.lower()
break
_LOGGER.info(
"Built library symbol map: %d symbols from %d libraries",
len(self._lib_symbol_map),
len(libraries),
)
def _find_object_files_dir(self) -> Path | None:
"""Find the directory containing object files for this build.
@@ -559,9 +701,13 @@ class MemoryAnalyzer:
if "esphome" in parts and "components" not in parts:
return _COMPONENT_CORE
# Framework/library files - return the first path component
# e.g., lib65b/ESPAsyncTCP/... -> lib65b
# FrameworkArduino/... -> FrameworkArduino
# Framework/library files - check for PlatformIO library hash dirs
# e.g., lib65b/ESPAsyncTCP/... -> [lib]espasynctcp
if parts and parts[0] in self._lib_hash_to_name:
return f"{_COMPONENT_PREFIX_LIB}{self._lib_hash_to_name[parts[0]]}"
# Other framework/library files - return the first path component
# e.g., FrameworkArduino/... -> FrameworkArduino
return parts[0] if parts else source_file
def _analyze_cswtch_symbols(self) -> None:
+13 -1
View File
@@ -14,6 +14,7 @@ from . import (
_COMPONENT_CORE,
_COMPONENT_PREFIX_ESPHOME,
_COMPONENT_PREFIX_EXTERNAL,
_COMPONENT_PREFIX_LIB,
RAM_SECTIONS,
MemoryAnalyzer,
)
@@ -407,6 +408,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
for name, mem in components
if name.startswith(_COMPONENT_PREFIX_EXTERNAL)
]
library_components = [
(name, mem)
for name, mem in components
if name.startswith(_COMPONENT_PREFIX_LIB)
]
top_esphome_components = sorted(
esphome_components, key=lambda x: x[1].flash_total, reverse=True
@@ -417,6 +423,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
external_components, key=lambda x: x[1].flash_total, reverse=True
)
# Include all library components
top_library_components = sorted(
library_components, key=lambda x: x[1].flash_total, reverse=True
)
# Check if API component exists and ensure it's included
api_component = None
for name, mem in components:
@@ -435,10 +446,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
if name in system_components_to_include
]
# Combine all components to analyze: top ESPHome + all external + API if not already included + system components
# Combine all components to analyze: top ESPHome + all external + libraries + API if not already included + system components
components_to_analyze = (
list(top_esphome_components)
+ list(top_external_components)
+ list(top_library_components)
+ system_components
)
if api_component and api_component not in components_to_analyze: