Skip to content

Commit 35ce05a

Browse files
authored
Merge pull request #5502 from willmmiles/dynarray-fixes
Dynarray fixes - fixes usermods on C3s
2 parents ba377d7 + 6d7c1d0 commit 35ce05a

4 files changed

Lines changed: 185 additions & 88 deletions

File tree

pio-scripts/dynarray.py

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,85 @@
22
# This is implemented as a pio post-script to ensure that we can
33
# place our linker script at the correct point in the command arguments.
44
Import("env")
5+
import shutil
56
from pathlib import Path
67

7-
platform = env.get("PIOPLATFORM")
8-
script_file = Path(f"tools/dynarray_{platform}.ld")
9-
if script_file.is_file():
10-
linker_script = f"-T{script_file}"
11-
if platform == "espressif32":
12-
# For ESP32, the script must be added at the right point in the list
13-
linkflags = env.get("LINKFLAGS", [])
14-
idx = linkflags.index("memory.ld")
15-
linkflags.insert(idx+1, linker_script)
16-
env.Replace(LINKFLAGS=linkflags)
8+
# Linker script fragment injected into the rodata output section of whichever
9+
# platform we're building for. Placed just before the end-of-rodata marker so
10+
# that the dynarray entries land in flash rodata and are correctly sorted.
11+
DYNARRAY_INJECTION = (
12+
"\n /* dynarray: WLED dynamic module arrays */\n"
13+
" . = ALIGN(0x10);\n"
14+
" KEEP(*(SORT_BY_INIT_PRIORITY(.dynarray.*)))\n"
15+
" "
16+
)
17+
18+
19+
def inject_before_marker(path, marker):
20+
"""Patch a linker script file in-place, inserting DYNARRAY_INJECTION before marker."""
21+
original = path.read_text()
22+
marker_pos = original.find(marker)
23+
if marker_pos < 0:
24+
raise RuntimeError(
25+
f"DYNARRAY injection marker not found in linker script: path={path}, marker={marker!r}"
26+
)
27+
patched = original[:marker_pos] + DYNARRAY_INJECTION + original[marker_pos:]
28+
path.write_text(patched)
29+
30+
31+
if env.get("PIOPLATFORM") == "espressif32":
32+
# Find sections.ld on the linker search path (LIBPATH).
33+
sections_ld_path = None
34+
for ld_dir in env.get("LIBPATH", []):
35+
candidate = Path(str(ld_dir)) / "sections.ld"
36+
if candidate.exists():
37+
sections_ld_path = candidate
38+
break
39+
40+
if sections_ld_path is not None:
41+
# Inject inside the existing .flash.rodata output section, just before
42+
# _rodata_end. IDF v5 enforces zero gaps between adjacent output
43+
# sections via ASSERT statements, so INSERT AFTER .flash.rodata would
44+
# fail. Injecting inside the section creates no new output section and
45+
# leaves the ASSERTs satisfied.
46+
build_dir = Path(env.subst("$BUILD_DIR"))
47+
patched_path = build_dir / "dynarray_sections.ld"
48+
shutil.copy(sections_ld_path, patched_path)
49+
inject_before_marker(patched_path, "_rodata_end = ABSOLUTE(.);")
50+
51+
# Replace "sections.ld" in LINKFLAGS with an absolute path to our
52+
# patched copy. The flag may appear as a bare token, combined as
53+
# "-Tsections.ld", or split across two tokens ("-T", "sections.ld").
54+
patched_str = str(patched_path)
55+
new_flags = []
56+
skip_next = False
57+
for flag in env.get("LINKFLAGS", []):
58+
if skip_next:
59+
new_flags.append(patched_str if flag == "sections.ld" else flag)
60+
skip_next = False
61+
elif flag == "-T":
62+
new_flags.append(flag)
63+
skip_next = True
64+
else:
65+
new_flags.append(flag.replace("sections.ld", patched_str))
66+
env.Replace(LINKFLAGS=new_flags)
1767
else:
18-
# For other platforms, put it in last
19-
env.Append(LINKFLAGS=[linker_script])
68+
# Assume sections.ld will be built (ESP-IDF format); add a post-action to patch it
69+
# TODO: consider using ESP-IDF linker fragment (https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-guides/linker-script-generation.html)
70+
# For now, patch after building
71+
sections_ld = Path(env.subst("$BUILD_DIR")) / "sections.ld"
72+
def patch_sections_ld(target, source, env):
73+
inject_before_marker(sections_ld, "_rodata_end = ABSOLUTE(.);")
74+
env.AddPostAction(str(sections_ld), patch_sections_ld)
75+
76+
elif env.get("PIOPLATFORM") == "espressif8266":
77+
# The ESP8266 framework preprocesses eagle.app.v6.common.ld.h into
78+
# local.eagle.app.v6.common.ld in $BUILD_DIR/ld/ at build time. Register
79+
# a post-action on that generated file so the injection happens after
80+
# C-preprocessing but before linking.
81+
build_ld = Path(env.subst("$BUILD_DIR")) / "ld" / "local.eagle.app.v6.common.ld"
82+
83+
def patch_esp8266_ld(target, source, env):
84+
inject_before_marker(build_ld, "_irom0_text_end = ABSOLUTE(.);")
85+
86+
env.AddPostAction(str(build_ld), patch_esp8266_ld)

pio-scripts/validate_modules.py

Lines changed: 106 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,117 +1,169 @@
1-
import os
21
import re
32
import subprocess
4-
from pathlib import Path # For OS-agnostic path manipulation
3+
from pathlib import Path
54
from click import secho
65
from SCons.Script import Action, Exit
76
Import("env")
87

8+
_ATTR = re.compile(r'\bDW_AT_(name|comp_dir)\b')
9+
910

1011
def 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

2624
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.
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-
86103
def 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

92136
def 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

125176
env.Append(LINKFLAGS=[env.subst("-Wl,--Map=${BUILD_DIR}/${PROGNAME}.map")])
126177
env.AddPostAction("$BUILD_DIR/${PROGNAME}.elf", Action(validate_map_file, cmdstr='Checking linked optional modules (usermods) in map file'))

tools/dynarray_espressif32.ld

Lines changed: 0 additions & 10 deletions
This file was deleted.

wled00/dynarray.h

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,4 @@ Macros for generating a "dynamic array", a static array of objects declared in d
2020
#define DYNARRAY_END(array_name) array_name##_end
2121
#define DYNARRAY_LENGTH(array_name) (&DYNARRAY_END(array_name)[0] - &DYNARRAY_BEGIN(array_name)[0])
2222

23-
#ifdef ESP8266
24-
// ESP8266 linker script cannot be extended with a unique section for dynamic arrays.
25-
// We instead pack them in the ".dtors" section, as it's sorted and uploaded to the flash
26-
// (but will never be used in the embedded system)
27-
#define DYNARRAY_SECTION ".dtors"
28-
29-
#else /* ESP8266 */
30-
31-
// Use a unique named section; the linker script must be extended to ensure it's correctly placed.
3223
#define DYNARRAY_SECTION ".dynarray"
33-
34-
#endif

0 commit comments

Comments
 (0)