import os import re import subprocess from pathlib import Path # For OS-agnostic path manipulation from click import secho from SCons.Script import Action, Exit Import("env") def read_lines(p: Path): """ Read in the contents of a file for analysis """ with p.open("r", encoding="utf-8", errors="ignore") as f: return f.readlines() def _get_nm_path(env) -> str: """ Derive the nm tool path from the build environment """ if "NM" in env: return env.subst("$NM") # Derive from the C compiler: xtensa-esp32-elf-gcc → xtensa-esp32-elf-nm cc = env.subst("$CC") nm = re.sub(r'(gcc|g\+\+)$', 'nm', os.path.basename(cc)) return os.path.join(os.path.dirname(cc), nm) def check_elf_modules(elf_path: Path, env, module_lib_builders) -> set[str]: """ Check which modules have at least one defined symbol placed in the ELF. The map file is not a reliable source for this: with LTO, original object file paths are replaced by temporary ltrans.o partitions in all output sections, making per-module attribution impossible from the map alone. Instead we invoke nm --defined-only -l on the ELF, which uses DWARF debug info to attribute each placed symbol to its original source file. Requires usermod libraries to be compiled with -g so that DWARF sections are present in the ELF. load_usermods.py injects -g for all WLED modules via dep.env.AppendUnique(CCFLAGS=["-g"]). Returns the set of build_dir basenames for confirmed modules. """ nm_path = _get_nm_path(env) try: result = subprocess.run( [nm_path, "--defined-only", "-l", str(elf_path)], capture_output=True, text=True, errors="ignore", timeout=120, ) nm_output = result.stdout except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e: secho(f"WARNING: nm failed ({e}); skipping per-module validation", fg="yellow", err=True) return {Path(b.build_dir).name for b in module_lib_builders} # conservative pass # Match placed symbols against builders as we parse nm output, exiting early # once all builders are accounted for. # nm --defined-only still includes debugging symbols (type 'N') such as the # per-CU markers GCC emits in .debug_info (e.g. "usermod_example_cpp_6734d48d"). # These live at address 0x00000000 in their debug section — not in any load # segment — so filtering them out leaves only genuinely placed symbols. # nm -l appends a tab-separated "file:lineno" location to each symbol line. remaining = {Path(str(b.src_dir)): Path(b.build_dir).name for b in module_lib_builders} found = set() for line in nm_output.splitlines(): if not remaining: break # all builders matched addr, _, _ = line.partition(' ') if not addr.lstrip('0'): continue # zero address — skip debug-section marker if '\t' not in line: continue loc = line.rsplit('\t', 1)[1] # Strip trailing :lineno (e.g. "/path/to/foo.cpp:42" → "/path/to/foo.cpp") src_path = Path(loc.rsplit(':', 1)[0]) # Path.is_relative_to() handles OS-specific separators correctly without # any regex, avoiding Windows path escaping issues. for src_dir in list(remaining): if src_path.is_relative_to(src_dir): found.add(remaining.pop(src_dir)) break return found DYNARRAY_SECTION = ".dtors" if env.get("PIOPLATFORM") == "espressif8266" else ".dynarray" USERMODS_SECTION = f"{DYNARRAY_SECTION}.usermods.1" def count_usermod_objects(map_file: list[str]) -> int: """ Returns the number of usermod objects in the usermod list """ # Count the number of entries in the usermods table section return len([x for x in map_file if USERMODS_SECTION in x]) def validate_map_file(source, target, env): """ Validate that all modules appear in the output build """ build_dir = Path(env.subst("$BUILD_DIR")) map_file_path = build_dir / env.subst("${PROGNAME}.map") if not map_file_path.exists(): secho(f"ERROR: Map file not found: {map_file_path}", fg="red", err=True) Exit(1) # Identify the WLED module builders, set by load_usermods.py module_lib_builders = env['WLED_MODULES'] # Extract the values we care about modules = {Path(builder.build_dir).name: builder.name for builder in module_lib_builders} secho(f"INFO: {len(modules)} libraries linked as WLED optional/user modules") # Now parse the map file map_file_contents = read_lines(map_file_path) usermod_object_count = count_usermod_objects(map_file_contents) secho(f"INFO: {usermod_object_count} usermod object entries") elf_path = build_dir / env.subst("${PROGNAME}.elf") confirmed_modules = check_elf_modules(elf_path, env, module_lib_builders) missing_modules = [modname for mdir, modname in modules.items() if mdir not in confirmed_modules] if missing_modules: secho( f"ERROR: No symbols from {missing_modules} found in linked output!", fg="red", err=True) Exit(1) return None env.Append(LINKFLAGS=[env.subst("-Wl,--Map=${BUILD_DIR}/${PROGNAME}.map")]) env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", Action(validate_map_file, cmdstr='Checking linked optional modules (usermods) in map file'))