1- import os
21import re
32import subprocess
4- from pathlib import Path # For OS-agnostic path manipulation
3+ from pathlib import Path
54from click import secho
65from SCons .Script import Action , Exit
76Import ("env" )
87
8+ _ATTR = re .compile (r'\bDW_AT_(name|comp_dir)\b' )
9+
910
1011def read_lines (p : Path ):
1112 """ Read in the contents of a file for analysis """
1213 with p .open ("r" , encoding = "utf-8" , errors = "ignore" ) as f :
1314 return f .readlines ()
1415
1516
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 )
17+ def _get_readelf_path (env ) -> str :
18+ """ Derive the readelf tool path from the build environment """
19+ # Derive from the C compiler: xtensa-esp32-elf-gcc → xtensa-esp32-elf-readelf
20+ cc = Path (env .subst ("$CC" ))
21+ return str (cc .with_name (re .sub (r'(gcc|g\+\+)$' , 'readelf' , cc .name )))
2422
2523
2624def 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.
25+ """ Check which modules have at least one compilation unit in the ELF.
2826
2927 The map file is not a reliable source for this: with LTO, original object
3028 file paths are replaced by temporary ltrans.o partitions in all output
3129 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"]) .
30+ Instead we invoke readelf --debug-dump=info --dwarf-depth=1 on the ELF,
31+ which reads only the top-level compilation-unit DIEs from .debug_info .
32+ Each CU corresponds to one source file; matching DW_AT_comp_dir +
33+ DW_AT_name against the module src_dirs is sufficient to confirm a module
34+ was compiled into the ELF. The output volume is proportional to the
35+ number of source files, not the number of symbols .
3836
3937 Returns the set of build_dir basenames for confirmed modules.
4038 """
41- nm_path = _get_nm_path (env )
39+ readelf_path = _get_readelf_path (env )
4240 try :
4341 result = subprocess .run (
44- [nm_path , "--defined-only " , "-l " , str (elf_path )],
42+ [readelf_path , "--debug-dump=info " , "--dwarf-depth=1 " , str (elf_path )],
4543 capture_output = True , text = True , errors = "ignore" , timeout = 120 ,
4644 )
47- nm_output = result .stdout
45+ output = result .stdout
46+ if result .returncode != 0 or result .stderr .strip ():
47+ secho (f"WARNING: readelf exited { result .returncode } : { result .stderr .strip ()} " , fg = "yellow" , err = True )
4848 except (subprocess .TimeoutExpired , FileNotFoundError , OSError ) as e :
49- secho (f"WARNING: nm failed ({ e } ); skipping per-module validation" , fg = "yellow" , err = True )
49+ secho (f"WARNING: readelf failed ({ e } ); skipping per-module validation" , fg = "yellow" , err = True )
5050 return {Path (b .build_dir ).name for b in module_lib_builders } # conservative pass
5151
52- # Match placed symbols against builders as we parse nm output, exiting early
53- # once all builders are accounted for.
54- # nm --defined-only still includes debugging symbols (type 'N') such as the
55- # per-CU markers GCC emits in .debug_info (e.g. "usermod_example_cpp_6734d48d").
56- # These live at address 0x00000000 in their debug section — not in any load
57- # segment — so filtering them out leaves only genuinely placed symbols.
58- # nm -l appends a tab-separated "file:lineno" location to each symbol line.
5952 remaining = {Path (str (b .src_dir )): Path (b .build_dir ).name for b in module_lib_builders }
6053 found = set ()
61-
62- for line in nm_output . splitlines ():
63- if not remaining :
64- break # all builders matched
65- addr , _ , _ = line . partition ( ' ' )
66- if not addr . lstrip ( '0' ):
67- continue # zero address — skip debug-section marker
68- if ' \t ' not in line :
69- continue
70- loc = line . rsplit ( ' \t ' , 1 )[ 1 ]
71- # Strip trailing :lineno (e.g. "/path/to/foo.cpp:42" → "/path/to/foo.cpp")
72- src_path = Path ( loc . rsplit ( ':' , 1 )[ 0 ])
73- # Path.is_relative_to () handles OS-specific separators correctly without
74- # any regex, avoiding Windows path escaping issues.
54+ project_dir = Path ( env . subst ( "$PROJECT_DIR" ))
55+
56+ def _flush_cu ( comp_dir : str | None , name : str | None ) -> None :
57+ """Match one completed CU against remaining builders."""
58+ if not name or not remaining :
59+ return
60+ p = Path ( name )
61+ src_path = ( Path ( comp_dir ) / p ) if ( comp_dir and not p . is_absolute ()) else p
62+ # In arduino+espidf dual-framework builds the IDF toolchain sets DW_AT_comp_dir
63+ # to the virtual path "/IDF_PROJECT" rather than the real project root, so
64+ # src_path won't match. Pre-compute a fallback using $PROJECT_DIR and check
65+ # both candidates in a single pass.
66+ use_fallback = not p . is_absolute () and comp_dir and Path ( comp_dir ) != project_dir
67+ src_path_real = project_dir / p if use_fallback else None
7568 for src_dir in list (remaining ):
76- if src_path .is_relative_to (src_dir ):
69+ if src_path .is_relative_to (src_dir ) or ( src_path_real and src_path_real . is_relative_to ( src_dir )) :
7770 found .add (remaining .pop (src_dir ))
78- break
71+ return
72+
73+ # readelf emits one DW_TAG_compile_unit DIE per source file. Attributes
74+ # of interest:
75+ # DW_AT_name — source file (absolute, or relative to comp_dir)
76+ # DW_AT_comp_dir — compile working directory
77+ # Both appear as either a direct string or an indirect string:
78+ # DW_AT_name : foo.cpp
79+ # DW_AT_name : (indirect string, offset: 0x…): foo.cpp
80+ # Taking the portion after the *last* ": " on the line handles both forms.
81+
82+ comp_dir = name = None
83+ for line in output .splitlines ():
84+ if 'Compilation Unit @' in line :
85+ _flush_cu (comp_dir , name )
86+ comp_dir = name = None
87+ continue
88+ if not remaining :
89+ break # all builders matched
90+ m = _ATTR .search (line )
91+ if m :
92+ _ , _ , val = line .rpartition (': ' )
93+ val = val .strip ()
94+ if m .group (1 ) == 'name' :
95+ name = val
96+ else :
97+ comp_dir = val
98+ _flush_cu (comp_dir , name ) # flush the last CU
7999
80100 return found
81101
82102
83- DYNARRAY_SECTION = ".dtors" if env .get ("PIOPLATFORM" ) == "espressif8266" else ".dynarray"
84- USERMODS_SECTION = f"{ DYNARRAY_SECTION } .usermods.1"
85-
86103def count_usermod_objects (map_file : list [str ]) -> int :
87- """ Returns the number of usermod objects in the usermod list """
88- # Count the number of entries in the usermods table section
89- return len ([x for x in map_file if USERMODS_SECTION in x ])
104+ """ Returns the number of usermod objects in the usermod list.
105+
106+ Computes the count from the address span between the .dynarray.usermods.0
107+ and .dynarray.usermods.99999 sentinel sections. This mirrors the
108+ DYNARRAY_LENGTH macro and is reliable under LTO, where all entries are
109+ merged into a single ltrans partition so counting section occurrences
110+ always yields 1 regardless of the true count.
111+ """
112+ ENTRY_SIZE = 4 # sizeof(Usermod*) on 32-bit targets
113+ addr_begin = None
114+ addr_end = None
115+
116+ for i , line in enumerate (map_file ):
117+ stripped = line .strip ()
118+ if stripped == '.dynarray.usermods.0' :
119+ if i + 1 < len (map_file ):
120+ m = re .search (r'0x([0-9a-fA-F]+)' , map_file [i + 1 ])
121+ if m :
122+ addr_begin = int (m .group (1 ), 16 )
123+ elif stripped == '.dynarray.usermods.99999' :
124+ if i + 1 < len (map_file ):
125+ m = re .search (r'0x([0-9a-fA-F]+)' , map_file [i + 1 ])
126+ if m :
127+ addr_end = int (m .group (1 ), 16 )
128+ if addr_begin is not None and addr_end is not None :
129+ break
130+
131+ if addr_begin is None or addr_end is None :
132+ return 0
133+ return (addr_end - addr_begin ) // ENTRY_SIZE
90134
91135
92136def validate_map_file (source , target , env ):
93137 """ Validate that all modules appear in the output build """
94138 build_dir = Path (env .subst ("$BUILD_DIR" ))
95- map_file_path = build_dir / env .subst ("${PROGNAME}.map" )
139+ map_file_path = build_dir / env .subst ("${PROGNAME}.map" )
96140
97141 if not map_file_path .exists ():
98142 secho (f"ERROR: Map file not found: { map_file_path } " , fg = "red" , err = True )
99143 Exit (1 )
100144
101145 # Identify the WLED module builders, set by load_usermods.py
102- module_lib_builders = env ['WLED_MODULES' ]
146+ module_lib_builders = env .get ('WLED_MODULES' )
147+ if module_lib_builders is None :
148+ secho ("ERROR: WLED_MODULES not set — ensure load_usermods.py is run as a pre: script" , fg = "red" , err = True )
149+ Exit (1 )
103150
104151 # Extract the values we care about
105152 modules = {Path (builder .build_dir ).name : builder .name for builder in module_lib_builders }
106- secho (f"INFO: { len (modules )} libraries linked as WLED optional/user modules" )
153+ secho (f"INFO: { len (modules )} libraries included as WLED optional/user modules" )
107154
108155 # Now parse the map file
109156 map_file_contents = read_lines (map_file_path )
110157 usermod_object_count = count_usermod_objects (map_file_contents )
111- secho (f"INFO: { usermod_object_count } usermod object entries" )
158+ secho (f"INFO: { usermod_object_count } usermod object entries found " )
112159
113160 elf_path = build_dir / env .subst ("${PROGNAME}.elf" )
161+
114162 confirmed_modules = check_elf_modules (elf_path , env , module_lib_builders )
163+ if confirmed_modules :
164+ secho (f"INFO: Code from usermod libraries found in binary: { ', ' .join (confirmed_modules )} " )
165+ # else - if there's no usermods found, don't generate a message. If we're legitimately missing all entries, the error report on the
166+ # next line will trip; and if the usermod set is expected to be empty, then there's no need for yet another null message.
115167
116168 missing_modules = [modname for mdir , modname in modules .items () if mdir not in confirmed_modules ]
117169 if missing_modules :
@@ -120,7 +172,6 @@ def validate_map_file(source, target, env):
120172 fg = "red" ,
121173 err = True )
122174 Exit (1 )
123- return None
124175
125176env .Append (LINKFLAGS = [env .subst ("-Wl,--Map=${BUILD_DIR}/${PROGNAME}.map" )])
126177env .AddPostAction ("$BUILD_DIR/${PROGNAME}.elf" , Action (validate_map_file , cmdstr = 'Checking linked optional modules (usermods) in map file' ))
0 commit comments