Skip to content

Commit 8d9680f

Browse files
willmmilesclaude
andcommitted
validate_modules: Support LTO
When LTO is enabled, the map file no longer provides a positive indication of whether a given object file has contributed to the final binary. Instead use nm to parse the debug data in the .elf file. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 05498f2 commit 8d9680f

2 files changed

Lines changed: 66 additions & 15 deletions

File tree

pio-scripts/load_usermods.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,14 @@ def wrapped_ConfigureProjectLibBuilder(xenv):
180180
# Add WLED's own dependencies
181181
for dir in extra_include_dirs:
182182
dep.env.PrependUnique(CPPPATH=str(dir))
183+
# Ensure debug info is emitted for this module's source files.
184+
# validate_modules.py uses `nm --defined-only -l` on the final ELF to check
185+
# that each module has at least one symbol placed in the binary. The -l flag
186+
# reads DWARF debug sections to map placed symbols back to their original source
187+
# files; without -g those sections are absent and the check cannot attribute any
188+
# symbol to a specific module. We scope this to usermods only — the main WLED
189+
# build and other libraries are unaffected.
190+
dep.env.AppendUnique(CCFLAGS=["-g"])
183191
# Enforce that libArchive is not set; we must link them directly to the executable
184192
if dep.lib_archive:
185193
broken_usermods.append(dep)

pio-scripts/validate_modules.py

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import os
12
import re
3+
import subprocess
24
from pathlib import Path # For OS-agnostic path manipulation
3-
from typing import Iterable
45
from click import secho
56
from SCons.Script import Action, Exit
67
Import("env")
@@ -12,24 +13,64 @@ def read_lines(p: Path):
1213
return f.readlines()
1314

1415

15-
def check_map_file_objects(map_file: list[str], dirs: Iterable[str]) -> set[str]:
16-
""" Identify which dirs contributed to the final build
16+
def _get_nm_path(env) -> str:
17+
""" Derive the nm tool path from the build environment """
18+
if "NM" in env:
19+
return env.subst("$NM")
20+
# Derive from the C compiler: xtensa-esp32-elf-gcc → xtensa-esp32-elf-nm
21+
cc = env.subst("$CC")
22+
nm = re.sub(r'(gcc|g\+\+)$', 'nm', os.path.basename(cc))
23+
return os.path.join(os.path.dirname(cc), nm)
1724

18-
Returns the (sub)set of dirs that are found in the output ELF
25+
26+
def check_elf_modules(elf_path: Path, env, module_lib_builders) -> set[str]:
27+
""" Check which modules have at least one defined symbol placed in the ELF.
28+
29+
The map file is not a reliable source for this: with LTO, original object
30+
file paths are replaced by temporary ltrans.o partitions in all output
31+
sections, making per-module attribution impossible from the map alone.
32+
Instead we invoke nm --defined-only -l on the ELF, which uses DWARF debug
33+
info to attribute each placed symbol to its original source file.
34+
35+
Requires usermod libraries to be compiled with -g so that DWARF sections
36+
are present in the ELF. load_usermods.py injects -g for all WLED modules
37+
via dep.env.AppendUnique(CCFLAGS=["-g"]).
38+
39+
Returns the set of build_dir basenames for confirmed modules.
1940
"""
20-
# Pattern to match symbols in object directories
21-
# Join directories into alternation
22-
usermod_dir_regex = "|".join([re.escape(dir) for dir in dirs])
23-
# Matches nonzero address, any size, and any path in a matching directory
24-
object_path_regex = re.compile(r"0x0*[1-9a-f][0-9a-f]*\s+0x[0-9a-f]+\s+\S+[/\\](" + usermod_dir_regex + r")[/\\]\S+\.o")
41+
nm_path = _get_nm_path(env)
42+
try:
43+
result = subprocess.run(
44+
[nm_path, "--defined-only", "-l", str(elf_path)],
45+
capture_output=True, text=True, errors="ignore", timeout=120,
46+
)
47+
nm_output = result.stdout
48+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
49+
secho(f"WARNING: nm failed ({e}); skipping per-module validation", fg="yellow", err=True)
50+
return {Path(b.build_dir).name for b in module_lib_builders} # conservative pass
51+
52+
# Build a filtered set of lines that have a nonzero address.
53+
# nm --defined-only still includes debugging symbols (type 'N') such as the
54+
# per-CU markers GCC emits in .debug_info (e.g. "usermod_example_cpp_6734d48d").
55+
# These live at address 0x00000000 in their debug section — not in any load
56+
# segment — so filtering them out leaves only genuinely placed symbols.
57+
placed_lines = [
58+
line for line in nm_output.splitlines()
59+
if (parts := line.split(None, 1)) and parts[0].lstrip('0')
60+
]
61+
placed_output = "\n".join(placed_lines)
2562

2663
found = set()
27-
for line in map_file:
28-
matches = object_path_regex.findall(line)
29-
for m in matches:
30-
found.add(m)
64+
for builder in module_lib_builders:
65+
# builder.src_dir is the library source directory (used by is_wled_module() too)
66+
src_dir = str(builder.src_dir).rstrip("/\\")
67+
# Guard against prefix collisions (e.g. /path/to/mod vs /path/to/mod-extra)
68+
# by requiring a path separator immediately after the directory name.
69+
if re.search(re.escape(src_dir) + r'[/\\]', placed_output):
70+
found.add(Path(builder.build_dir).name)
3171
return found
3272

73+
3374
DYNARRAY_SECTION = ".dtors" if env.get("PIOPLATFORM") == "espressif8266" else ".dynarray"
3475
USERMODS_SECTION = f"{DYNARRAY_SECTION}.usermods.1"
3576

@@ -60,11 +101,13 @@ def validate_map_file(source, target, env):
60101
usermod_object_count = count_usermod_objects(map_file_contents)
61102
secho(f"INFO: {usermod_object_count} usermod object entries")
62103

63-
confirmed_modules = check_map_file_objects(map_file_contents, modules.keys())
104+
elf_path = build_dir / env.subst("${PROGNAME}.elf")
105+
confirmed_modules = check_elf_modules(elf_path, env, module_lib_builders)
106+
64107
missing_modules = [modname for mdir, modname in modules.items() if mdir not in confirmed_modules]
65108
if missing_modules:
66109
secho(
67-
f"ERROR: No object files from {missing_modules} found in linked output!",
110+
f"ERROR: No symbols from {missing_modules} found in linked output!",
68111
fg="red",
69112
err=True)
70113
Exit(1)

0 commit comments

Comments
 (0)