[analyze_memory] Report aliased RAM symbols once in the RAM strings report (#17397)

This commit is contained in:
J. Nick Koston
2026-07-06 19:54:53 -05:00
committed by GitHub
parent a10e005bb4
commit 902cf6a679
2 changed files with 157 additions and 10 deletions
+34 -10
View File
@@ -8,7 +8,7 @@ memory-constrained platforms like ESP8266.
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from dataclasses import dataclass, field
import logging
from pathlib import Path
import re
@@ -65,6 +65,7 @@ class RamSymbol:
size: int
section: str
demangled: str = "" # Demangled name, set after batch demangling
aliases: list[str] = field(default_factory=list) # Other names at same address
class RamStringsAnalyzer:
@@ -235,6 +236,11 @@ class RamStringsAnalyzer:
except (subprocess.CalledProcessError, FileNotFoundError):
return
# Track symbols by address so aliases (multiple names for the same
# object, e.g. the newlib __lock___* mutexes that all alias one
# StaticSemaphore_t) are reported once instead of once per name.
symbols_by_addr: dict[int, RamSymbol] = {}
for line in output.split("\n"):
parts = line.split()
if len(parts) < 4:
@@ -253,6 +259,18 @@ class RamStringsAnalyzer:
if sym_type not in DATA_SYMBOL_TYPES:
continue
if (existing := symbols_by_addr.get(addr)) is not None:
# Prefer a global (uppercase type) name as the primary so
# nm output order can't hide it behind a local alias.
if sym_type.isupper() and existing.sym_type.islower():
existing.aliases.append(existing.name)
existing.name = name
existing.sym_type = sym_type
else:
existing.aliases.append(name)
existing.size = max(existing.size, size)
continue
# Check if symbol is in a RAM section
for section_name in self.ram_sections:
if section_name not in self.sections:
@@ -260,15 +278,15 @@ class RamStringsAnalyzer:
section = self.sections[section_name]
if section.address <= addr < section.address + section.size:
self.ram_symbols.append(
RamSymbol(
name=name,
sym_type=sym_type,
address=addr,
size=size,
section=section_name,
)
symbol = RamSymbol(
name=name,
sym_type=sym_type,
address=addr,
size=size,
section=section_name,
)
symbols_by_addr[addr] = symbol
self.ram_symbols.append(symbol)
break
def _demangle_symbols(self) -> None:
@@ -436,7 +454,13 @@ class RamStringsAnalyzer:
for symbol in largest_symbols:
# Use demangled name if available, otherwise raw name
display_name = symbol.demangled or symbol.name
name_display = display_name[:49] if len(display_name) > 49 else display_name
# Truncate the name, not the alias note, so merged aliases stay
# visible even for long demangled C++ names.
alias_note = f" (+{len(symbol.aliases)} aliases)" if symbol.aliases else ""
max_name_len = 49 - len(alias_note)
if len(display_name) > max_name_len:
display_name = display_name[:max_name_len]
name_display = display_name + alias_note
lines.append(
f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}"
)
@@ -0,0 +1,123 @@
"""Tests for RAM symbol analysis in the RAM strings analyzer."""
from pathlib import Path
from unittest.mock import patch
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer, SectionInfo
# nm -S --size-sort output with the newlib lock mutexes: nine global
# symbols that are all aliases of two local StaticSemaphore_t objects.
NM_OUTPUT_WITH_ALIASES = """\
3ffb4400 00000010 B small_symbol
3ffb43c8 00000054 B __lock___atexit_recursive_mutex
3ffb43c8 00000054 B __lock___env_recursive_mutex
3ffb43c8 00000054 B __lock___malloc_recursive_mutex
3ffb43c8 00000054 B __lock___sfp_recursive_mutex
3ffb43c8 00000054 B __lock___sinit_recursive_mutex
3ffb43c8 00000054 b s_common_recursive_mutex
3ffb441c 00000054 B __lock___arc4random_mutex
3ffb441c 00000054 B __lock___at_quick_exit_mutex
3ffb441c 00000054 B __lock___dd_hash_mutex
3ffb441c 00000054 B __lock___tz_mutex
3ffb441c 00000054 b s_common_mutex
"""
def _make_analyzer(tmp_path) -> RamStringsAnalyzer:
"""Create an analyzer with a dummy ELF and a .dram0.bss section."""
elf = tmp_path / "firmware.elf"
elf.write_bytes(b"\x7fELF")
analyzer = RamStringsAnalyzer(str(elf), platform="esp32")
analyzer.sections[".dram0.bss"] = SectionInfo(".dram0.bss", 0x3FFB0000, 0x10000)
return analyzer
def _run_symbol_analysis(analyzer: RamStringsAnalyzer, nm_output: str) -> None:
"""Run _analyze_symbols with mocked nm output."""
with (
patch(
"esphome.analyze_memory.ram_strings.find_tool",
return_value="nm",
),
patch.object(analyzer, "_run_command", return_value=nm_output),
):
analyzer._analyze_symbols()
def test_aliased_symbols_counted_once(tmp_path: Path) -> None:
"""Symbols sharing an address are one object, not one per name."""
analyzer = _make_analyzer(tmp_path)
_run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES)
# Three distinct addresses, so three symbols
assert len(analyzer.ram_symbols) == 3
total = sum(s.size for s in analyzer.ram_symbols)
assert total == 0x10 + 0x54 + 0x54
def test_aliases_recorded_on_first_symbol(tmp_path: Path) -> None:
"""Extra names at the same address are kept as aliases."""
analyzer = _make_analyzer(tmp_path)
_run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES)
by_addr = {s.address: s for s in analyzer.ram_symbols}
assert len(by_addr[0x3FFB43C8].aliases) == 5
assert len(by_addr[0x3FFB441C].aliases) == 4
assert by_addr[0x3FFB4400].aliases == []
assert "s_common_mutex" in by_addr[0x3FFB441C].aliases
def test_alias_count_shown_in_report(tmp_path: Path) -> None:
"""The large symbols table notes how many aliases were merged."""
analyzer = _make_analyzer(tmp_path)
_run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES)
report = analyzer.generate_report()
assert "(+5 aliases)" in report
assert "(+4 aliases)" in report
# Each lock name appears at most once in the report
assert report.count("__lock___") == 2
def test_global_name_preferred_over_local_alias(tmp_path: Path) -> None:
"""A global name becomes the primary even when nm lists a local first."""
analyzer = _make_analyzer(tmp_path)
nm_output = """\
3ffb43c8 00000054 b s_common_recursive_mutex
3ffb43c8 00000054 B __lock___atexit_recursive_mutex
3ffb43c8 00000054 B __lock___malloc_recursive_mutex
"""
_run_symbol_analysis(analyzer, nm_output)
(symbol,) = analyzer.ram_symbols
assert symbol.name == "__lock___atexit_recursive_mutex"
assert symbol.sym_type == "B"
assert sorted(symbol.aliases) == [
"__lock___malloc_recursive_mutex",
"s_common_recursive_mutex",
]
def test_alias_note_survives_name_truncation(tmp_path: Path) -> None:
"""Long names are truncated but the alias note is kept intact."""
analyzer = _make_analyzer(tmp_path)
long_name = "a_very_long_symbol_name_that_exceeds_the_column_width_by_far"
nm_output = f"""\
3ffb43c8 00000054 B {long_name}
3ffb43c8 00000054 B other_name
"""
_run_symbol_analysis(analyzer, nm_output)
report = analyzer.generate_report()
row = next(line for line in report.splitlines() if "(+1 aliases)" in line)
name_column = row[:50].rstrip()
assert name_column.endswith("(+1 aliases)")
assert name_column.startswith("a_very_long_symbol_name")
def test_symbols_outside_ram_sections_skipped(tmp_path: Path) -> None:
"""Symbols outside known RAM sections are ignored entirely."""
analyzer = _make_analyzer(tmp_path)
nm_output = "40080000 00000100 B not_in_ram\n"
_run_symbol_analysis(analyzer, nm_output)
assert analyzer.ram_symbols == []