mirror of
https://github.com/esphome/esphome.git
synced 2026-11-15 09:36:04 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ba019395b | ||
|
|
d3dd10da12 | ||
|
|
da34aa9f7a | ||
|
|
2b27ba016b | ||
|
|
e5e85609a9 | ||
|
|
180f952fd0 | ||
|
|
c607199b23 | ||
|
|
058a5be777 |
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from functools import cached_property
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
@@ -55,6 +56,7 @@ _COMPONENT_PREFIX_EXTERNAL = "[external]"
|
||||
_COMPONENT_PREFIX_LIB = "[lib]"
|
||||
_COMPONENT_CORE = f"{_COMPONENT_PREFIX_ESPHOME}core"
|
||||
_COMPONENT_API = f"{_COMPONENT_PREFIX_ESPHOME}api"
|
||||
_COMPONENT_ARDUINO_CORE = "arduino_core"
|
||||
|
||||
# Placement new storage suffix (generated by codegen Pvariable)
|
||||
_PSTORAGE_SUFFIX = "__pstorage"
|
||||
@@ -83,6 +85,24 @@ _NAMESPACE_STD = "std::"
|
||||
# Type alias for symbol information: (symbol_name, size, component)
|
||||
SymbolInfoType = tuple[str, int, str]
|
||||
|
||||
# Only ESP8266 has this section; its .rodata is copied to RAM at boot like .data
|
||||
_ESP8266_FLASH_CODE_SECTION = ".irom0.text"
|
||||
|
||||
# readelf -SW row: [Nr] Name Type Address Off Size ES Flg
|
||||
_OBJ_SECTION_PATTERN = re.compile(
|
||||
r"^\s*\[\s*(\d+)\]\s+(\S+)\s+(\S+)\s+[\da-f]+\s+[\da-f]+\s+([\da-f]+)\s+[\da-f]+\s+(\S*)"
|
||||
)
|
||||
_RELOC_SECTION_PATTERN = re.compile(r"^Relocation section '\.rela(\S+)'")
|
||||
# Code section; group 1 is the function for -ffunction-sections names, after any
|
||||
# placement prefix (.text.startup.<fn>, .iram.text.<fn> for IRAM_ATTR)
|
||||
_CODE_SECTION_PATTERN = re.compile(
|
||||
r"^\.(?:literal|text|irom0\.text|iram\.text|iram0\.text)"
|
||||
r"(?:\.(?:startup|unlikely|hot|exit)(?=\.|$))?(?:\.(.+))?$"
|
||||
)
|
||||
# readelf -x row: " 0x00000000 0000803f 0000803f 0000803f 0000803f ...?...?...?...?"
|
||||
_HEX_DUMP_PREFIX = " 0x"
|
||||
_HEX_DUMP_COLUMNS = slice(13, 48)
|
||||
|
||||
# RAM sections - symbols in these sections consume RAM
|
||||
RAM_SECTIONS = frozenset([".data", ".bss"])
|
||||
|
||||
@@ -119,6 +139,19 @@ class SDKSymbol:
|
||||
demangled: str = "" # Demangled name (populated after analysis)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnnamedConstant:
|
||||
"""Constant without a symbol in an object's .rodata (RAM on ESP8266)."""
|
||||
|
||||
size: int
|
||||
source_file: str
|
||||
component: str
|
||||
functions: list[str] # Functions that reference it, mangled
|
||||
jump_table: bool # Holds code addresses (a switch table), not a value
|
||||
data: bytes # Leading bytes, to recognize the value
|
||||
strings: list[str] = field(default_factory=list) # String literals it points to
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComponentMemory:
|
||||
"""Tracks memory usage for a component."""
|
||||
@@ -152,6 +185,146 @@ class ComponentMemory:
|
||||
return self.data_size + self.bss_size
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RodataSection:
|
||||
"""An object's .rodata section and the byte ranges its symbols cover."""
|
||||
|
||||
name: str
|
||||
size: int
|
||||
covered: list[tuple[int, int]] = field(default_factory=list) # (offset, size)
|
||||
|
||||
@cached_property
|
||||
def _named(self) -> frozenset[int]:
|
||||
return frozenset(
|
||||
offset
|
||||
for start, size in self.covered
|
||||
for offset in range(start, min(start + size, self.size))
|
||||
)
|
||||
|
||||
def is_unnamed(self, offset: int) -> bool:
|
||||
return 0 <= offset < self.size and offset not in self._named
|
||||
|
||||
@property
|
||||
def unnamed_size(self) -> int:
|
||||
return self.size - len(self._named)
|
||||
|
||||
|
||||
def _parse_hex_dumps(output: str) -> dict[str, bytearray]:
|
||||
"""Collect the bytes of each ``readelf -x`` section dump by section name."""
|
||||
contents: dict[str, bytearray] = defaultdict(bytearray)
|
||||
section: str | None = None
|
||||
for line in output.splitlines():
|
||||
if line.startswith("Hex dump of section"):
|
||||
section = line.split("'")[1]
|
||||
elif section is not None and line.startswith(_HEX_DUMP_PREFIX):
|
||||
contents[section] += bytes.fromhex(line[_HEX_DUMP_COLUMNS].replace(" ", ""))
|
||||
elif line and not line.startswith(" "):
|
||||
section = None
|
||||
return contents
|
||||
|
||||
|
||||
def _parse_relocations(output: str) -> list[tuple[str, int, str, int]]:
|
||||
"""List (relocated section, offset, target, addend) from ``readelf -rW`` output."""
|
||||
relocations: list[tuple[str, int, str, int]] = []
|
||||
section: str | None = None
|
||||
for line in output.splitlines():
|
||||
if line.startswith("Relocation section"):
|
||||
match = _RELOC_SECTION_PATTERN.match(line)
|
||||
section = match.group(1) if match else None
|
||||
elif line[:1].isdigit() and section is not None:
|
||||
# Offset Info Type Sym.Value Sym.Name + Addend
|
||||
fields = line.split()
|
||||
if len(fields) < 5:
|
||||
continue
|
||||
addend = int(fields[6], 16) if len(fields) > 6 else 0
|
||||
if len(fields) > 6 and fields[5] == "-":
|
||||
addend = -addend
|
||||
relocations.append((section, int(fields[0], 16), fields[4], addend))
|
||||
elif line and line[0] != " ":
|
||||
section = None
|
||||
return relocations
|
||||
|
||||
|
||||
def _pointed_literal(
|
||||
referrer: bytes, offset: int, addend: int, strings: bytes
|
||||
) -> bytes:
|
||||
"""Read the string literal a relocated pointer at ``offset`` refers to."""
|
||||
# Xtensa keeps part of the addend in the relocated word
|
||||
start = addend + int.from_bytes(referrer[offset : offset + 4], "little")
|
||||
end = strings.find(b"\0", start)
|
||||
return bytes(strings[start : end if end != -1 else len(strings)])
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FunctionDefinitions:
|
||||
"""Where each function is defined across the scanned objects."""
|
||||
|
||||
weak: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set))
|
||||
strong: set[str] = field(default_factory=set) # Global, non-weak
|
||||
count: Counter[str] = field(default_factory=Counter) # Objects defining it
|
||||
|
||||
def linked_copy_unknown(self, file_path: str, functions: list[str]) -> bool:
|
||||
"""True if another object's copy of these functions may be the linked one."""
|
||||
if self.weak[file_path].issuperset(functions):
|
||||
# Inline copies: a strong definition elsewhere replaces them all
|
||||
return not self.strong.isdisjoint(functions)
|
||||
# Same-named local functions in several objects; the ELF can't tell them apart
|
||||
return all(f not in self.strong and self.count[f] > 1 for f in functions)
|
||||
|
||||
|
||||
def _parse_unnamed_rodata(
|
||||
output: str,
|
||||
) -> tuple[dict[str, dict[str, _RodataSection]], _FunctionDefinitions]:
|
||||
"""Find .rodata sections with bytes no symbol covers in ``readelf -SW -sW`` output.
|
||||
|
||||
Returns:
|
||||
Object path -> section index -> section, and where functions are defined
|
||||
"""
|
||||
sections: dict[str, dict[str, _RodataSection]] = defaultdict(dict)
|
||||
definitions = _FunctionDefinitions()
|
||||
file_path = ""
|
||||
for line in output.splitlines():
|
||||
if line.startswith("File: "):
|
||||
file_path = line[6:]
|
||||
elif match := _OBJ_SECTION_PATTERN.match(line):
|
||||
index, name, section_type, size_hex, flags = match.groups()
|
||||
# Merged sections (string literals) are shared between objects
|
||||
if (
|
||||
section_type == "PROGBITS"
|
||||
and name.startswith(".rodata")
|
||||
and "A" in flags
|
||||
and "M" not in flags
|
||||
):
|
||||
sections[file_path][index] = _RodataSection(name, int(size_hex, 16))
|
||||
else:
|
||||
# Num: Value Size Type Bind Vis Ndx Name
|
||||
fields = line.split()
|
||||
if len(fields) < 8:
|
||||
continue
|
||||
if fields[3] == "OBJECT" and (
|
||||
section := sections[file_path].get(fields[6])
|
||||
):
|
||||
section.covered.append((int(fields[1], 16), int(fields[2], 0)))
|
||||
elif fields[3] == "FUNC" and fields[6] != "UND":
|
||||
definitions.count[fields[7]] += 1
|
||||
if fields[4] == "WEAK":
|
||||
definitions.weak[file_path].add(fields[7])
|
||||
elif fields[4] == "GLOBAL":
|
||||
definitions.strong.add(fields[7])
|
||||
unnamed = {
|
||||
path: kept
|
||||
for path, file_sections in sections.items()
|
||||
if (
|
||||
kept := {
|
||||
index: section
|
||||
for index, section in file_sections.items()
|
||||
if section.unnamed_size
|
||||
}
|
||||
)
|
||||
}
|
||||
return unnamed, definitions
|
||||
|
||||
|
||||
class MemoryAnalyzer:
|
||||
"""Analyzes memory usage from ELF files."""
|
||||
|
||||
@@ -231,6 +404,9 @@ class MemoryAnalyzer:
|
||||
self._heuristic_to_lib: dict[str, str] = {}
|
||||
# Function call counts: mangled_name -> call_count
|
||||
self._function_call_counts: Counter[str] = Counter()
|
||||
# True on ESP8266, where .rodata is RAM; it is then counted as .data
|
||||
self.rodata_in_ram = False
|
||||
self._unnamed_constants: list[UnnamedConstant] = []
|
||||
|
||||
def analyze(self) -> dict[str, ComponentMemory]:
|
||||
"""Analyze the ELF file and return component memory usage."""
|
||||
@@ -240,6 +416,7 @@ class MemoryAnalyzer:
|
||||
self._scan_source_symbols()
|
||||
self._categorize_symbols()
|
||||
self._analyze_cswtch_symbols()
|
||||
self._analyze_object_rodata()
|
||||
self._analyze_sdk_libraries()
|
||||
self._analyze_function_calls()
|
||||
return dict(self.components)
|
||||
@@ -253,18 +430,17 @@ class MemoryAnalyzer:
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Parse section headers
|
||||
for line in result.stdout.splitlines():
|
||||
# Look for section entries
|
||||
if not (match := _READELF_SECTION_PATTERN.match(line)):
|
||||
continue
|
||||
raw_sections = [
|
||||
(match.group(1), int(match.group(2), 16))
|
||||
for line in result.stdout.splitlines()
|
||||
if (match := _READELF_SECTION_PATTERN.match(line))
|
||||
]
|
||||
self.rodata_in_ram = any(
|
||||
name == _ESP8266_FLASH_CODE_SECTION for name, _ in raw_sections
|
||||
)
|
||||
|
||||
section_name = match.group(1)
|
||||
size_hex = match.group(2)
|
||||
size = int(size_hex, 16)
|
||||
|
||||
# Map to standard section name
|
||||
mapped_section = map_section_name(section_name)
|
||||
for section_name, size in raw_sections:
|
||||
mapped_section = self._standard_section(map_section_name(section_name))
|
||||
if not mapped_section:
|
||||
continue
|
||||
|
||||
@@ -272,6 +448,12 @@ class MemoryAnalyzer:
|
||||
self.sections[mapped_section] = MemorySection(mapped_section)
|
||||
self.sections[mapped_section].total_size += size
|
||||
|
||||
def _standard_section(self, section: str | None) -> str | None:
|
||||
"""Count .rodata as .data where it is RAM (ESP8266)."""
|
||||
if section == ".rodata" and self.rodata_in_ram:
|
||||
return ".data"
|
||||
return section
|
||||
|
||||
def _parse_symbols(self) -> None:
|
||||
"""Parse symbols from ELF file."""
|
||||
result = subprocess.run(
|
||||
@@ -289,14 +471,18 @@ class MemoryAnalyzer:
|
||||
continue
|
||||
|
||||
section, name, size, address = symbol_info
|
||||
section = self._standard_section(section)
|
||||
|
||||
if section not in self.sections:
|
||||
continue
|
||||
# Every alias is in the ELF, even when its size is counted once
|
||||
self._elf_symbol_names.add(name)
|
||||
# Skip duplicate symbols at the same address (e.g., C1/C2 constructors)
|
||||
if address in seen_addresses or section not in self.sections:
|
||||
if address in seen_addresses:
|
||||
continue
|
||||
|
||||
self.sections[section].symbols.append((name, size, ""))
|
||||
self.sections[section].symbol_size += size
|
||||
self._elf_symbol_names.add(name)
|
||||
seen_addresses.add(address)
|
||||
|
||||
def _categorize_symbols(self) -> None:
|
||||
@@ -925,10 +1111,12 @@ class MemoryAnalyzer:
|
||||
Path to the directory containing .o files, or None if not found.
|
||||
"""
|
||||
# The ELF is typically at .pioenvs/<env>/firmware.elf
|
||||
# Object files are in .pioenvs/<env>/src/ and .pioenvs/<env>/lib*/
|
||||
# PlatformIO puts object files in .pioenvs/<env>/src/ and .pioenvs/<env>/lib*/,
|
||||
# the native ESP8266 build in .pioenvs/<env>/obj/src/ and obj/lib/
|
||||
pioenvs_dir = self.elf_path.parent
|
||||
if pioenvs_dir.exists() and any(pioenvs_dir.glob("src/*.o")):
|
||||
return pioenvs_dir
|
||||
for obj_dir in (pioenvs_dir, pioenvs_dir / "obj"):
|
||||
if any(obj_dir.glob("src/*.o")):
|
||||
return obj_dir
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@@ -1110,6 +1298,12 @@ class MemoryAnalyzer:
|
||||
):
|
||||
return f"{_COMPONENT_PREFIX_LIB}{self._lib_hash_to_name[parts[1]]}"
|
||||
|
||||
# Native ESP8266 build: lib/<owner>__<Name>/... and the Arduino core in core/
|
||||
if len(parts) >= 3 and parts[0] == "lib":
|
||||
return f"{_COMPONENT_PREFIX_LIB}{parts[1].rsplit('__', 1)[-1].lower()}"
|
||||
if len(parts) >= 2 and parts[0] == "core":
|
||||
return _COMPONENT_ARDUINO_CORE
|
||||
|
||||
# Other framework/library files - return the first path component
|
||||
# e.g., FrameworkArduino/... -> FrameworkArduino
|
||||
return parts[0] if parts else source_file
|
||||
@@ -1191,9 +1385,7 @@ class MemoryAnalyzer:
|
||||
and other_mem is not None
|
||||
):
|
||||
other_mem.add_section_size(section_name, -size)
|
||||
if component not in self.components:
|
||||
self.components[component] = ComponentMemory(component)
|
||||
self.components[component].add_section_size(section_name, size)
|
||||
self._add_component_size(component, section_name, size)
|
||||
|
||||
# Sort by size descending
|
||||
self._cswtch_symbols.sort(key=lambda x: x[1], reverse=True)
|
||||
@@ -1205,6 +1397,146 @@ class MemoryAnalyzer:
|
||||
total_size,
|
||||
)
|
||||
|
||||
def _analyze_object_rodata(self) -> None:
|
||||
"""Attribute .rodata without a symbol, which is RAM on ESP8266, to components."""
|
||||
if not self.rodata_in_ram or (obj_dir := self._find_object_files_dir()) is None:
|
||||
return
|
||||
result = run_tool(
|
||||
[self.readelf_path, "-SW", "-sW"]
|
||||
+ [str(f) for f in sorted(obj_dir.rglob("*.o"))]
|
||||
)
|
||||
if result is None or result.returncode != 0:
|
||||
_LOGGER.debug("readelf failed scanning object files for constants")
|
||||
return
|
||||
self._attribute_unnamed_constants(result.stdout, obj_dir)
|
||||
names = {n for c in self._unnamed_constants for n in c.functions}
|
||||
self._batch_demangle_symbols(sorted(names - self._demangle_cache.keys()))
|
||||
|
||||
def _attribute_unnamed_constants(self, output: str, obj_dir: Path) -> None:
|
||||
"""Attribute constants GCC copies into objects built from constant initializers."""
|
||||
unnamed, definitions = _parse_unnamed_rodata(output)
|
||||
# Inline (weak) functions are emitted, with their constants, in every
|
||||
# object that uses them; the linker keeps one copy
|
||||
inline_copies: dict[tuple[tuple[str, ...], int], str] = {}
|
||||
for file_path, sections in unnamed.items():
|
||||
rel_path = Path(file_path).relative_to(obj_dir).as_posix()
|
||||
component = self._source_file_to_component(rel_path)
|
||||
for constant in self._scan_object_constants(
|
||||
file_path, sections, rel_path, component
|
||||
):
|
||||
if definitions.linked_copy_unknown(file_path, constant.functions):
|
||||
continue
|
||||
if definitions.weak[file_path].issuperset(constant.functions):
|
||||
key = (tuple(constant.functions), constant.size)
|
||||
if inline_copies.setdefault(key, file_path) != file_path:
|
||||
continue
|
||||
self._unnamed_constants.append(constant)
|
||||
self._charge_ram(component, constant.size)
|
||||
|
||||
self._unnamed_constants.sort(key=lambda c: c.size, reverse=True)
|
||||
|
||||
def _scan_object_constants(
|
||||
self,
|
||||
file_path: str,
|
||||
sections: dict[str, _RodataSection],
|
||||
rel_path: str,
|
||||
component: str,
|
||||
) -> list[UnnamedConstant]:
|
||||
"""Return the unnamed constants of one object that the link kept.
|
||||
|
||||
Args:
|
||||
file_path: Object file to read
|
||||
sections: Candidate sections by index
|
||||
rel_path: Object path shown in the report
|
||||
component: Component the object belongs to
|
||||
"""
|
||||
result = run_tool(
|
||||
[self.readelf_path, "-rW"]
|
||||
+ [arg for index in sections for arg in ("-x", index)]
|
||||
+ [file_path]
|
||||
)
|
||||
if result is None or result.returncode != 0:
|
||||
_LOGGER.debug("readelf failed reading constants of %s", file_path)
|
||||
return []
|
||||
|
||||
by_name = {section.name: section for section in sections.values()}
|
||||
contents = _parse_hex_dumps(result.stdout)
|
||||
functions: dict[str, set[str]] = defaultdict(set)
|
||||
jump_tables: set[str] = set()
|
||||
# Section -> (offset, string section, addend) of each pointer to a literal
|
||||
pointers: dict[str, list[tuple[int, str, int]]] = defaultdict(list)
|
||||
for section, offset, target, addend in _parse_relocations(result.stdout):
|
||||
if target in by_name:
|
||||
code = _CODE_SECTION_PATTERN.match(section)
|
||||
if code and code.group(1):
|
||||
functions[target].add(code.group(1))
|
||||
elif section in by_name and by_name[section].is_unnamed(offset):
|
||||
if _CODE_SECTION_PATTERN.match(target):
|
||||
jump_tables.add(section)
|
||||
elif ".str" in target:
|
||||
pointers[section].append((offset, target, addend))
|
||||
|
||||
strings = self._read_pointed_strings(file_path, pointers, contents)
|
||||
return [
|
||||
UnnamedConstant(
|
||||
size=section.unnamed_size,
|
||||
source_file=rel_path,
|
||||
component=component,
|
||||
functions=sorted(functions[name]),
|
||||
jump_table=name in jump_tables,
|
||||
data=bytes(
|
||||
byte
|
||||
for offset, byte in enumerate(contents[name])
|
||||
if section.is_unnamed(offset)
|
||||
)[:16],
|
||||
strings=strings.get(name, []),
|
||||
)
|
||||
for name, section in by_name.items()
|
||||
# Dropped by --gc-sections unless a function that uses it was kept
|
||||
if not self._elf_symbol_names.isdisjoint(functions[name])
|
||||
]
|
||||
|
||||
def _read_pointed_strings(
|
||||
self,
|
||||
file_path: str,
|
||||
pointers: dict[str, list[tuple[int, str, int]]],
|
||||
contents: dict[str, bytearray],
|
||||
) -> dict[str, list[str]]:
|
||||
"""Resolve each constant's pointers to string literals, in offset order."""
|
||||
if not pointers:
|
||||
return {}
|
||||
targets = sorted({t for refs in pointers.values() for _, t, _ in refs})
|
||||
result = run_tool(
|
||||
[self.readelf_path]
|
||||
+ [arg for target in targets for arg in ("-x", target)]
|
||||
+ [file_path]
|
||||
)
|
||||
if result is None or result.returncode != 0:
|
||||
_LOGGER.debug("readelf failed reading strings of %s", file_path)
|
||||
return {}
|
||||
literals = _parse_hex_dumps(result.stdout)
|
||||
return {
|
||||
name: [
|
||||
_pointed_literal(
|
||||
contents[name], offset, addend, literals[target]
|
||||
).decode("utf-8", "backslashreplace")
|
||||
for offset, target, addend in sorted(refs)
|
||||
]
|
||||
for name, refs in pointers.items()
|
||||
}
|
||||
|
||||
def _charge_ram(self, component: str, size: int) -> None:
|
||||
"""Charge RAM bytes that have no ELF symbol to a component."""
|
||||
self._add_component_size(component, ".data", size)
|
||||
if (data_section := self.sections.get(".data")) is not None:
|
||||
data_section.symbol_size += size
|
||||
|
||||
def _add_component_size(self, component: str, section: str, size: int) -> None:
|
||||
"""Add size to a component, creating it under its own name."""
|
||||
if component not in self.components:
|
||||
self.components[component] = ComponentMemory(component)
|
||||
self.components[component].add_section_size(section, size)
|
||||
|
||||
def _analyze_function_calls(self) -> None:
|
||||
"""Count function call sites by parsing disassembly output.
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
import heapq
|
||||
import json
|
||||
from operator import itemgetter
|
||||
from pathlib import Path
|
||||
import sys
|
||||
@@ -204,6 +205,36 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
f"{i + 1:>2}. {size:>7,} B {section_label:<8} {demangled_display:<{self.COL_TOP_SYMBOL_NAME}} {component}"
|
||||
)
|
||||
|
||||
def _add_unnamed_constants_analysis(self, lines: list[str]) -> None:
|
||||
"""Add the unnamed constants (ESP8266 .rodata in RAM) section."""
|
||||
self._add_section_header(lines, "Unnamed Constants in RAM (.rodata)")
|
||||
total_size = sum(c.size for c in self._unnamed_constants)
|
||||
lines.append(
|
||||
f"Total: {len(self._unnamed_constants)} constant(s), {total_size:,} B. "
|
||||
"GCC copies these into objects built from constant initializers;"
|
||||
)
|
||||
lines.append(
|
||||
"PROGMEM tables, assigned fields or a user-provided constructor avoid the copy."
|
||||
)
|
||||
lines.append("")
|
||||
for constant in self._unnamed_constants:
|
||||
kind = "switch table" if constant.jump_table else "constant"
|
||||
lines.append(
|
||||
f"{constant.size:>6,} B {kind:<12} {constant.component} ({constant.source_file})"
|
||||
)
|
||||
lines.extend(
|
||||
f"{'':>9} used by {self._demangle_symbol(function)}"
|
||||
for function in constant.functions
|
||||
)
|
||||
if constant.strings:
|
||||
shown = ", ".join(f'"{text[:40]}"' for text in constant.strings[:4])
|
||||
more = ", ..." if len(constant.strings) > 4 else ""
|
||||
lines.append(f"{'':>9} points to {shown}{more}")
|
||||
elif constant.data and not constant.jump_table:
|
||||
more = " ..." if constant.size > len(constant.data) else ""
|
||||
lines.append(f"{'':>9} bytes: {constant.data.hex(' ')}{more}")
|
||||
lines.append("")
|
||||
|
||||
def _add_cswtch_analysis(self, lines: list[str]) -> None:
|
||||
"""Add CSWTCH (GCC switch table lookup) analysis section."""
|
||||
self._add_section_header(lines, "CSWTCH Analysis (GCC Switch Table Lookups)")
|
||||
@@ -394,6 +425,12 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
f"{total_flash:>{self.COL_TOTAL_FLASH - 2},} B | {total_ram:>{self.COL_TOTAL_RAM - 2},} B"
|
||||
)
|
||||
|
||||
if self.rodata_in_ram:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"ESP8266 keeps .rodata in RAM, so constants are counted in RAM (data)."
|
||||
)
|
||||
|
||||
# Show unattributed RAM (SDK/framework overhead)
|
||||
unattributed_bss, unattributed_data, unattributed_total = (
|
||||
self.get_unattributed_ram()
|
||||
@@ -657,6 +694,9 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
lines.append(f" ... and {len(large_ram_syms) - 10} more")
|
||||
lines.append("")
|
||||
|
||||
if self._unnamed_constants:
|
||||
self._add_unnamed_constants_analysis(lines)
|
||||
|
||||
# CSWTCH (GCC switch table) analysis
|
||||
if self._cswtch_symbols:
|
||||
self._add_cswtch_analysis(lines)
|
||||
@@ -738,7 +778,6 @@ def main():
|
||||
build_dir = sys.argv[1]
|
||||
|
||||
# Load build directory
|
||||
import json
|
||||
|
||||
from esphome.platformio.toolchain import IDEData
|
||||
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
"""Tests for the memory analyzer."""
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
from esphome.analyze_memory import MemoryAnalyzer
|
||||
|
||||
|
||||
def make_analyzer(external_components: set[str] | None = None) -> MemoryAnalyzer:
|
||||
"""Create a MemoryAnalyzer through its real constructor, without an ELF or a toolchain."""
|
||||
def make_analyzer[T: MemoryAnalyzer](
|
||||
external_components: set[str] | None = None, cls: type[T] = MemoryAnalyzer
|
||||
) -> T:
|
||||
"""Create an analyzer through its real constructor, without an ELF or a toolchain."""
|
||||
with (
|
||||
patch("esphome.analyze_memory.resolve_tool_path", return_value=None),
|
||||
patch("esphome.analyze_memory.find_tool", return_value="nm"),
|
||||
):
|
||||
# Any existing file passes the ELF check; nothing reads it
|
||||
return MemoryAnalyzer(__file__, external_components=external_components)
|
||||
return cls(__file__, external_components=external_components)
|
||||
|
||||
|
||||
def completed(stdout: str) -> subprocess.CompletedProcess[str]:
|
||||
"""A successful tool run printing ``stdout``."""
|
||||
return subprocess.CompletedProcess([], 0, stdout=stdout)
|
||||
|
||||
|
||||
def hex_dump(section: str, data: bytes, address: int = 0) -> str:
|
||||
"""Format bytes the way ``readelf -x`` does."""
|
||||
lines = [f"Hex dump of section '{section}':"]
|
||||
for start in range(0, len(data), 16):
|
||||
chunk = data[start : start + 16]
|
||||
words = " ".join(chunk[i : i + 4].hex() for i in range(0, len(chunk), 4))
|
||||
text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
|
||||
lines.append(f" 0x{address + start:08x} {words:<35} {text}")
|
||||
return "\n".join(lines) + "\n\n"
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for source-file-to-component attribution in memory analyzer."""
|
||||
|
||||
import pytest
|
||||
|
||||
from . import make_analyzer
|
||||
|
||||
|
||||
@@ -30,3 +32,19 @@ def test_source_file_to_component_known_component() -> None:
|
||||
"src/esphome/components/wifi/wifi_component.cpp.o"
|
||||
)
|
||||
assert result == "[esphome]wifi"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rel_path", "component"),
|
||||
[
|
||||
(
|
||||
"lib/esp32async__ESPAsyncWebServer/WebHandlers.cpp.o",
|
||||
"[lib]espasyncwebserver",
|
||||
),
|
||||
("lib/ESP8266WiFi/ESP8266WiFi.cpp.o", "[lib]esp8266wifi"),
|
||||
("core/core_esp8266_main.cpp.o", "arduino_core"),
|
||||
],
|
||||
)
|
||||
def test_native_library_objects(rel_path: str, component: str) -> None:
|
||||
"""Objects of the native ESP8266 build's libraries and core get a component."""
|
||||
assert make_analyzer()._source_file_to_component(rel_path) == component
|
||||
@@ -0,0 +1,475 @@
|
||||
"""Tests for ESP8266 .rodata accounting and unnamed constant attribution."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.analyze_memory import (
|
||||
_CODE_SECTION_PATTERN,
|
||||
MemoryAnalyzer,
|
||||
MemorySection,
|
||||
UnnamedConstant,
|
||||
_FunctionDefinitions,
|
||||
_parse_unnamed_rodata,
|
||||
_RodataSection,
|
||||
)
|
||||
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
|
||||
|
||||
from . import completed, hex_dump, make_analyzer
|
||||
|
||||
LIGHT_OBJ = "src/esphome/components/light/light_state.cpp.o"
|
||||
WEB_OBJ = "src/esphome/components/web_server/web_server.cpp.o"
|
||||
SETUP = "_ZN7esphome5light10LightState5setupEv"
|
||||
SAVE = "_ZN7esphome5light10LightState19save_remote_values_Ev"
|
||||
MATCH_URL = "_ZN7esphome10web_serverL9match_urlEPKcjbb"
|
||||
|
||||
# readelf -SW -sW over all objects; the web section index and size vary per test
|
||||
HEADERS_AND_SYMBOLS = """\
|
||||
File: {light}
|
||||
|
||||
Section Headers:
|
||||
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
|
||||
[124] .rodata._ZN7esphome5light10LightStateC2EPNS0_11LightOutputE.str1.1 PROGBITS 00000000 00067d 000001 01 AMS 0 0 1
|
||||
[174] .rodata PROGBITS 00000000 00139c 00002c 00 A 0 0 4
|
||||
[221] .rodata.str1.1 PROGBITS 00000000 001f39 000005 01 AMS 0 0 1
|
||||
[225] .rodata.BUILTIN_LED PROGBITS 00000000 001f54 000004 00 A 0 0 4
|
||||
|
||||
Symbol table '.symtab' contains 348 entries:
|
||||
Num: Value Size Type Bind Vis Ndx Name
|
||||
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
|
||||
300: 00000000 64 FUNC {bind:<6} DEFAULT 12 {setup}
|
||||
301: 00000000 64 FUNC {bind:<6} DEFAULT 13 {save}
|
||||
347: 00000000 4 OBJECT WEAK DEFAULT 225 BUILTIN_LED
|
||||
|
||||
File: {web}
|
||||
|
||||
Section Headers:
|
||||
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
|
||||
[{web_index:>3}] .rodata PROGBITS 00000000 000400 {web_size:06x} 00 A 0 0 4
|
||||
|
||||
Symbol table '.symtab' contains 1 entries:
|
||||
Num: Value Size Type Bind Vis Ndx Name
|
||||
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
|
||||
{web_symbols}"""
|
||||
|
||||
LIGHT_OBJECT_DUMP = f"""\
|
||||
Relocation section '.rela.text.{SETUP}' at offset 0x7928 contains 2 entries:
|
||||
Offset Info Type Sym. Value Symbol's Name + Addend
|
||||
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 0
|
||||
00000094 00003b14 R_XTENSA_SLOT0_OP 00000000 .text.{SETUP} + 82
|
||||
|
||||
Relocation section '.rela.text.{SAVE}' at offset 0x7d28 contains 1 entry:
|
||||
Offset Info Type Sym. Value Symbol's Name + Addend
|
||||
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 0
|
||||
|
||||
Relocation section '.rela.xt.prop' at offset 0x8000 contains 1 entry:
|
||||
Offset Info Type Sym. Value Symbol's Name + Addend
|
||||
000007d4 00003a01 R_XTENSA_32 00000000 .rodata + 0
|
||||
|
||||
Hex dump of section '.rodata':
|
||||
0x00000000 0000803f 0000803f 0000803f 0000803f ...?...?...?...?
|
||||
0x00000010 0000803f 0000803f 0000803f 0000803f ...?...?...?...?
|
||||
0x00000020 0000803f 00000000 00000000 ...?........
|
||||
"""
|
||||
|
||||
WEB_OBJECT_DUMP = f"""\
|
||||
Relocation section '.rela.text.{MATCH_URL}' at offset 0x100 contains 1 entry:
|
||||
Offset Info Type Sym. Value Symbol's Name + Addend
|
||||
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 0
|
||||
|
||||
Hex dump of section '.rodata':
|
||||
0x00000000 00000000 00000000 00000000 00000000 ................
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def obj_dir(tmp_path: Path) -> Path:
|
||||
"""Native ESP8266 build layout: objects under obj/, ELF next to it."""
|
||||
for rel in (LIGHT_OBJ, WEB_OBJ, "src/main.cpp.o"):
|
||||
path = tmp_path / "obj" / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.touch()
|
||||
return tmp_path / "obj"
|
||||
|
||||
|
||||
def _esp8266_analyzer(
|
||||
obj_dir: Path,
|
||||
kept: set[str],
|
||||
web_index: int = 9,
|
||||
web_size: int = 0x1C,
|
||||
web_dump: str = WEB_OBJECT_DUMP,
|
||||
bind: str = "GLOBAL",
|
||||
web_symbols: str = "",
|
||||
) -> tuple[MemoryAnalyzer, Callable[..., subprocess.CompletedProcess[str]]]:
|
||||
analyzer = make_analyzer()
|
||||
analyzer.elf_path = obj_dir.parent / "firmware.elf"
|
||||
analyzer.rodata_in_ram = True
|
||||
analyzer.sections[".data"] = MemorySection(".data", total_size=1000)
|
||||
analyzer._elf_symbol_names = kept
|
||||
light = str(obj_dir / LIGHT_OBJ)
|
||||
web = str(obj_dir / WEB_OBJ)
|
||||
|
||||
def run_tool(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
if "-SW" in cmd:
|
||||
return completed(
|
||||
HEADERS_AND_SYMBOLS.format(
|
||||
light=light,
|
||||
web=web,
|
||||
web_index=web_index,
|
||||
web_size=web_size,
|
||||
bind=bind,
|
||||
setup=SETUP,
|
||||
save=SAVE,
|
||||
web_symbols=web_symbols,
|
||||
)
|
||||
)
|
||||
# Only sections with uncovered bytes are dumped
|
||||
if cmd[-1] == light:
|
||||
assert cmd[1:-1] == ["-rW", "-x", "174"]
|
||||
return completed(LIGHT_OBJECT_DUMP)
|
||||
return completed(web_dump)
|
||||
|
||||
return analyzer, run_tool
|
||||
|
||||
|
||||
def test_find_object_files_dir_native_layout(obj_dir: Path) -> None:
|
||||
"""The native ESP8266 build keeps objects under obj/."""
|
||||
analyzer = make_analyzer()
|
||||
analyzer.elf_path = obj_dir.parent / "firmware.elf"
|
||||
assert analyzer._find_object_files_dir() == obj_dir
|
||||
|
||||
|
||||
def test_find_object_files_dir_platformio_layout(tmp_path: Path) -> None:
|
||||
"""PlatformIO keeps objects next to the ELF."""
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "main.cpp.o").touch()
|
||||
analyzer = make_analyzer()
|
||||
analyzer.elf_path = tmp_path / "firmware.elf"
|
||||
assert analyzer._find_object_files_dir() == tmp_path
|
||||
|
||||
|
||||
def _parse_sections(code_section: str) -> MemoryAnalyzer:
|
||||
readelf = f"""\
|
||||
[ 1] .data PROGBITS 3ffe8000 001000 000668 00 WAX 0 0 16
|
||||
[ 4] {code_section} PROGBITS 40201010 00b010 06612c 00 WAX 0 0 16
|
||||
[ 6] .rodata PROGBITS 3ffe86b0 0016b0 000b90 00 A 0 0 16
|
||||
"""
|
||||
analyzer = make_analyzer()
|
||||
with patch(
|
||||
"esphome.analyze_memory.subprocess.run", return_value=completed(readelf)
|
||||
):
|
||||
analyzer._parse_sections()
|
||||
return analyzer
|
||||
|
||||
|
||||
def test_parse_sections_esp8266_counts_rodata_as_data() -> None:
|
||||
"""ESP8266 (.irom0.text) copies .rodata to RAM, so it is counted as .data."""
|
||||
analyzer = _parse_sections(".irom0.text")
|
||||
assert analyzer.rodata_in_ram
|
||||
assert ".rodata" not in analyzer.sections
|
||||
assert analyzer.sections[".data"].total_size == 0x668 + 0xB90
|
||||
|
||||
|
||||
def test_parse_sections_other_platforms_keep_rodata() -> None:
|
||||
"""Other platforms keep .rodata in flash."""
|
||||
analyzer = _parse_sections(".flash.text")
|
||||
assert not analyzer.rodata_in_ram
|
||||
assert analyzer.sections[".rodata"].total_size == 0xB90
|
||||
assert analyzer.sections[".data"].total_size == 0x668
|
||||
|
||||
|
||||
def test_unnamed_constants_attributed_to_component(obj_dir: Path) -> None:
|
||||
"""Unnamed .rodata in a kept function is listed and counted as the component's RAM."""
|
||||
analyzer, run_tool = _esp8266_analyzer(obj_dir, {SETUP, MATCH_URL})
|
||||
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool):
|
||||
analyzer._analyze_object_rodata()
|
||||
|
||||
light, web = analyzer._unnamed_constants
|
||||
assert light == UnnamedConstant(
|
||||
size=44,
|
||||
source_file=LIGHT_OBJ,
|
||||
component="[esphome]light",
|
||||
functions=[SAVE, SETUP],
|
||||
jump_table=False,
|
||||
data=bytes.fromhex("0000803f") * 4,
|
||||
)
|
||||
assert (web.size, web.component, web.functions) == (
|
||||
28,
|
||||
"[esphome]web_server",
|
||||
[MATCH_URL],
|
||||
)
|
||||
assert analyzer.components["[esphome]light"].data_size == 44
|
||||
assert analyzer.components["[esphome]web_server"].ram_total == 28
|
||||
# Attributed bytes no longer count as unattributed RAM
|
||||
assert analyzer.sections[".data"].symbol_size == 72
|
||||
|
||||
|
||||
def test_unnamed_constants_skip_garbage_collected(obj_dir: Path) -> None:
|
||||
"""A constant whose functions the linker dropped is not in the firmware."""
|
||||
analyzer, run_tool = _esp8266_analyzer(obj_dir, {MATCH_URL})
|
||||
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool):
|
||||
analyzer._analyze_object_rodata()
|
||||
assert [c.component for c in analyzer._unnamed_constants] == ["[esphome]web_server"]
|
||||
|
||||
|
||||
def test_unnamed_constants_skipped_when_rodata_in_flash(obj_dir: Path) -> None:
|
||||
"""Other platforms keep .rodata in flash, so there is nothing to scan."""
|
||||
analyzer, run_tool = _esp8266_analyzer(obj_dir, {SETUP, MATCH_URL})
|
||||
analyzer.rodata_in_ram = False
|
||||
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool) as mock:
|
||||
analyzer._analyze_object_rodata()
|
||||
mock.assert_not_called()
|
||||
assert analyzer._unnamed_constants == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("bind", "expected"), [("WEAK", 1), ("GLOBAL", 2)])
|
||||
def test_unnamed_constants_inline_copies_counted_once(
|
||||
obj_dir: Path, bind: str, expected: int
|
||||
) -> None:
|
||||
"""Only weak (inline) functions bring the same constant from several objects."""
|
||||
# The second object emits the same functions, with the same binding
|
||||
analyzer, run_tool = _esp8266_analyzer(
|
||||
obj_dir,
|
||||
{SETUP},
|
||||
web_index=174,
|
||||
web_size=0x2C,
|
||||
web_dump=LIGHT_OBJECT_DUMP,
|
||||
bind=bind,
|
||||
web_symbols=(
|
||||
f" 300: 00000000 64 FUNC {bind:<6} DEFAULT 12 {SETUP}\n"
|
||||
f" 301: 00000000 64 FUNC {bind:<6} DEFAULT 13 {SAVE}\n"
|
||||
),
|
||||
)
|
||||
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool):
|
||||
analyzer._analyze_object_rodata()
|
||||
assert len(analyzer._unnamed_constants) == expected
|
||||
assert analyzer.sections[".data"].symbol_size == 44 * expected
|
||||
|
||||
|
||||
def test_parse_symbols_keeps_alias_names() -> None:
|
||||
"""C1/C2 constructor aliases share an address; both names are in the ELF."""
|
||||
objdump = """4020b558 g F .irom0.text\t0000007e _ZN7esphome3FooC1Ev
|
||||
4020b558 g F .irom0.text\t0000007e _ZN7esphome3FooC2Ev
|
||||
"""
|
||||
analyzer = make_analyzer()
|
||||
analyzer.sections[".text"] = MemorySection(".text")
|
||||
with patch(
|
||||
"esphome.analyze_memory.subprocess.run", return_value=completed(objdump)
|
||||
):
|
||||
analyzer._parse_symbols()
|
||||
assert {"_ZN7esphome3FooC1Ev", "_ZN7esphome3FooC2Ev"} <= analyzer._elf_symbol_names
|
||||
assert analyzer.sections[".text"].symbol_size == 0x7E
|
||||
|
||||
|
||||
def test_unnamed_constants_skip_named_bytes() -> None:
|
||||
"""In a section shared with a named object, only the unnamed bytes are shown."""
|
||||
analyzer = make_analyzer()
|
||||
analyzer._elf_symbol_names = {"_Z3fooi"}
|
||||
dump = """Relocation section '.rela.text._Z3fooi' at offset 0x100 contains 1 entry:
|
||||
Offset Info Type Sym. Value Symbol's Name + Addend
|
||||
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 4
|
||||
|
||||
Hex dump of section '.rodata':
|
||||
0x00000000 aaaaaaaa 01020304 ........
|
||||
"""
|
||||
section = _RodataSection(".rodata", 8, covered=[(0, 4)])
|
||||
with patch("esphome.analyze_memory.run_tool", return_value=completed(dump)):
|
||||
(constant,) = analyzer._scan_object_constants(
|
||||
"foo.o", {"5": section}, "src/foo.o", "[esphome]foo"
|
||||
)
|
||||
assert constant.size == 4
|
||||
assert constant.data == bytes([1, 2, 3, 4])
|
||||
|
||||
|
||||
def test_unnamed_constants_jump_table() -> None:
|
||||
"""A .rodata section that points into code is a switch jump table."""
|
||||
analyzer = make_analyzer()
|
||||
analyzer._elf_symbol_names = {"_Z3fooi"}
|
||||
dump = """\
|
||||
Relocation section '.rela.text._Z3fooi' at offset 0x100 contains 1 entry:
|
||||
Offset Info Type Sym. Value Symbol's Name + Addend
|
||||
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 0
|
||||
|
||||
Relocation section '.rela.rodata' at offset 0x200 contains 2 entries:
|
||||
Offset Info Type Sym. Value Symbol's Name + Addend
|
||||
00000000 00003b01 R_XTENSA_32 00000000 .text._Z3fooi + 12
|
||||
00000004 00003b01 R_XTENSA_32 00000000 .text._Z3fooi + 20
|
||||
"""
|
||||
with patch("esphome.analyze_memory.run_tool", return_value=completed(dump)):
|
||||
(constant,) = analyzer._scan_object_constants(
|
||||
"foo.o", {"5": _RodataSection(".rodata", 8)}, "src/foo.o", "[esphome]foo"
|
||||
)
|
||||
assert constant.jump_table
|
||||
assert constant.functions == ["_Z3fooi"]
|
||||
|
||||
|
||||
def test_report_lists_unnamed_constants() -> None:
|
||||
"""The report names the component, the object, the functions and the bytes."""
|
||||
analyzer = make_analyzer(cls=MemoryAnalyzerCLI)
|
||||
analyzer.rodata_in_ram = True
|
||||
analyzer._demangle_cache = {SETUP: "esphome::light::LightState::setup()"}
|
||||
analyzer._unnamed_constants = [
|
||||
UnnamedConstant(
|
||||
size=44,
|
||||
source_file=LIGHT_OBJ,
|
||||
component="[esphome]light",
|
||||
functions=[SETUP],
|
||||
jump_table=False,
|
||||
data=bytes.fromhex("0000803f") * 4,
|
||||
)
|
||||
]
|
||||
report = analyzer.generate_report()
|
||||
assert "ESP8266 keeps .rodata in RAM" in report
|
||||
assert "Unnamed Constants in RAM (.rodata)" in report
|
||||
assert f"44 B constant [esphome]light ({LIGHT_OBJ})" in report
|
||||
assert "used by esphome::light::LightState::setup()" in report
|
||||
assert "bytes: 00 00 80 3f 00 00 80 3f 00 00 80 3f 00 00 80 3f ..." in report
|
||||
|
||||
|
||||
def test_unnamed_constants_resolve_string_pointers() -> None:
|
||||
"""A constant of pointers to literals lists the strings, so it can be recognized."""
|
||||
analyzer = make_analyzer()
|
||||
analyzer._elf_symbol_names = {"setup"}
|
||||
relocations = """\
|
||||
Relocation section '.rela.text.setup' at offset 0x100 contains 1 entry:
|
||||
Offset Info Type Sym. Value Symbol's Name + Addend
|
||||
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 0
|
||||
|
||||
Relocation section '.rela.rodata' at offset 0xe6fc contains 3 entries:
|
||||
Offset Info Type Sym. Value Symbol's Name + Addend
|
||||
00000000 00000501 R_XTENSA_32 00000000 .rodata.str1.1 + 0
|
||||
00000004 00000501 R_XTENSA_32 00000000 .rodata.str1.1 + 0
|
||||
00000008 00000501 R_XTENSA_32 00000000 .rodata.str1.1 + 0
|
||||
|
||||
Hex dump of section '.rodata':
|
||||
0x00000000 00000000 0f000000 1e000000 ............
|
||||
"""
|
||||
literals = hex_dump(
|
||||
".rodata.str1.1", b"0.pool.ntp.org\0001.pool.ntp.org\0002.pool.ntp.org\0"
|
||||
)
|
||||
|
||||
def run_tool(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
if "-rW" not in cmd:
|
||||
assert cmd[1:-1] == ["-x", ".rodata.str1.1"]
|
||||
return completed(literals)
|
||||
return completed(relocations)
|
||||
|
||||
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool):
|
||||
(constant,) = analyzer._scan_object_constants(
|
||||
"main.cpp.o",
|
||||
{"5": _RodataSection(".rodata", 12)},
|
||||
"src/main.cpp.o",
|
||||
"[esphome]core",
|
||||
)
|
||||
assert constant.strings == ["0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org"]
|
||||
|
||||
cli = make_analyzer(cls=MemoryAnalyzerCLI)
|
||||
cli._unnamed_constants = [constant]
|
||||
assert (
|
||||
'points to "0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org"'
|
||||
in cli.generate_report()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("section", "function"),
|
||||
[
|
||||
(".text._Z3foov", "_Z3foov"),
|
||||
(".literal._Z3foov", "_Z3foov"),
|
||||
(".irom0.text._Z3foov", "_Z3foov"),
|
||||
(".text.startup._GLOBAL__sub_I_setup", "_GLOBAL__sub_I_setup"),
|
||||
(".text.unlikely._Z3foov", "_Z3foov"),
|
||||
(".iram.text._Z3foov", "_Z3foov"),
|
||||
(".text.hot_path", "hot_path"),
|
||||
(".text", None),
|
||||
],
|
||||
)
|
||||
def test_code_section_function(section: str, function: str | None) -> None:
|
||||
"""Placement prefixes and IRAM code still resolve to the function."""
|
||||
match = _CODE_SECTION_PATTERN.match(section)
|
||||
assert match is not None
|
||||
assert match.group(1) == function
|
||||
|
||||
|
||||
def test_unnamed_constants_same_object_not_merged(obj_dir: Path) -> None:
|
||||
"""Two equal constants of one inline function in one object are both counted."""
|
||||
analyzer, run_tool = _esp8266_analyzer(obj_dir, {SETUP}, bind="WEAK")
|
||||
constant = UnnamedConstant(
|
||||
size=8,
|
||||
source_file=LIGHT_OBJ,
|
||||
component="[esphome]light",
|
||||
functions=[SAVE, SETUP],
|
||||
jump_table=False,
|
||||
data=b"",
|
||||
)
|
||||
light = str(obj_dir / LIGHT_OBJ)
|
||||
with (
|
||||
patch("esphome.analyze_memory.run_tool", side_effect=run_tool),
|
||||
patch.object(
|
||||
analyzer,
|
||||
"_scan_object_constants",
|
||||
side_effect=lambda path, *_: [constant, constant] if path == light else [],
|
||||
),
|
||||
):
|
||||
analyzer._analyze_object_rodata()
|
||||
assert len(analyzer._unnamed_constants) == 2
|
||||
|
||||
|
||||
def test_unnamed_constants_jump_table_only_from_unnamed_bytes() -> None:
|
||||
"""Code pointers in a named table at the start do not make the rest a jump table."""
|
||||
analyzer = make_analyzer()
|
||||
analyzer._elf_symbol_names = {"_Z3fooi"}
|
||||
dump = """\
|
||||
Relocation section '.rela.text._Z3fooi' at offset 0x100 contains 1 entry:
|
||||
Offset Info Type Sym. Value Symbol's Name + Addend
|
||||
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 4
|
||||
|
||||
Relocation section '.rela.rodata' at offset 0x200 contains 1 entry:
|
||||
Offset Info Type Sym. Value Symbol's Name + Addend
|
||||
00000000 00003b01 R_XTENSA_32 00000000 .text._Z3fooi + 12
|
||||
|
||||
"""
|
||||
section = _RodataSection(".rodata", 8, covered=[(0, 4)])
|
||||
with patch("esphome.analyze_memory.run_tool", return_value=completed(dump)):
|
||||
(constant,) = analyzer._scan_object_constants(
|
||||
"foo.o", {"5": section}, "src/foo.o", "[esphome]foo"
|
||||
)
|
||||
assert not constant.jump_table
|
||||
|
||||
|
||||
def _definitions(rows: dict[str, list[tuple[str, str]]]) -> _FunctionDefinitions:
|
||||
"""Parse symbol rows (binding, function) per object, as readelf -sW prints them."""
|
||||
output = "".join(
|
||||
f"File: {path}\n"
|
||||
+ "".join(
|
||||
f" {i}: 00000000 64 FUNC {bind:<6} DEFAULT 12 {name}\n"
|
||||
for i, (bind, name) in enumerate(symbols)
|
||||
)
|
||||
for path, symbols in rows.items()
|
||||
)
|
||||
return _parse_unnamed_rodata(output)[1]
|
||||
|
||||
|
||||
def test_linked_copy_unknown_weak_overridden_by_strong() -> None:
|
||||
"""A strong definition replaces every weak copy, so their constants are not linked."""
|
||||
definitions = _definitions(
|
||||
{"a.o": [("WEAK", "_Z3foov")], "b.o": [("GLOBAL", "_Z3foov")]}
|
||||
)
|
||||
assert definitions.linked_copy_unknown("a.o", ["_Z3foov"])
|
||||
assert not definitions.linked_copy_unknown("b.o", ["_Z3foov"])
|
||||
|
||||
|
||||
def test_linked_copy_unknown_same_named_locals() -> None:
|
||||
"""Local functions with one name in two objects can't be told apart in the ELF."""
|
||||
definitions = _definitions(
|
||||
{
|
||||
"a.o": [("LOCAL", "_ZL3barv"), ("LOCAL", "_ZL3bazv")],
|
||||
"b.o": [("LOCAL", "_ZL3barv")],
|
||||
}
|
||||
)
|
||||
assert definitions.linked_copy_unknown("a.o", ["_ZL3barv"])
|
||||
# Another, unique user decides it
|
||||
assert not definitions.linked_copy_unknown("a.o", ["_ZL3barv", "_ZL3bazv"])
|
||||
Reference in new issue
Block a user