asmdiff 0.1.2__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {asmdiff-0.1.2 → asmdiff-0.1.3}/PKG-INFO +27 -10
- {asmdiff-0.1.2 → asmdiff-0.1.3}/README.md +26 -9
- {asmdiff-0.1.2 → asmdiff-0.1.3}/asmdiff.example.toml +3 -2
- {asmdiff-0.1.2 → asmdiff-0.1.3}/asmdiff.py +162 -32
- {asmdiff-0.1.2 → asmdiff-0.1.3}/pyproject.toml +1 -1
- {asmdiff-0.1.2 → asmdiff-0.1.3}/test_asmdiff.py +195 -4
- {asmdiff-0.1.2 → asmdiff-0.1.3}/.claude/settings.local.json +0 -0
- {asmdiff-0.1.2 → asmdiff-0.1.3}/.gitignore +0 -0
- {asmdiff-0.1.2 → asmdiff-0.1.3}/CLAUDE.md +0 -0
- {asmdiff-0.1.2 → asmdiff-0.1.3}/LICENSE +0 -0
- {asmdiff-0.1.2 → asmdiff-0.1.3}/asmdiff_example.c +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: asmdiff
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Compare per-function assembly between paired C implementations
|
|
5
5
|
Project-URL: Homepage, https://github.com/rt-rtos/asmdiff
|
|
6
6
|
Project-URL: Repository, https://github.com/rt-rtos/asmdiff
|
|
@@ -94,7 +94,7 @@ $ asmdiff.py asmdiff_example.c
|
|
|
94
94
|
```
|
|
95
95
|
asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
|
|
96
96
|
[--cc 'CC FLAGS']... [--target NAME]... [--config PATH]
|
|
97
|
-
[--compile-commands [PATH]] [-- EXTRA_FLAGS...]
|
|
97
|
+
[--compile-commands [PATH]] [-v] [-- EXTRA_FLAGS...]
|
|
98
98
|
```
|
|
99
99
|
|
|
100
100
|
| Option | Meaning |
|
|
@@ -105,7 +105,8 @@ asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
|
|
|
105
105
|
| `--cc 'CC FLAGS'` | One compiler invocation, command and flags in a single quoted string. Repeatable to build a matrix. |
|
|
106
106
|
| `--target NAME` | A named target from the config file, resolved to a `--cc` entry. Repeatable; appended to the matrix after `--cc` entries. |
|
|
107
107
|
| `--config PATH` | Config file to use. Default search: `asmdiff.toml` next to `SOURCE.c`, then in the current directory, then `~/.config/`. First hit wins. |
|
|
108
|
-
| `--compile-commands [PATH]` | Borrow each source's include/define flags from a `compile_commands.json`; with no `PATH`,
|
|
108
|
+
| `--compile-commands [PATH]` | Borrow each source's include/define flags from a `compile_commands.json`; with no `PATH`, walk up from the CWD checking each directory and its `build/` until the repository root. See [below](#borrowing-includes-from-compile_commandsjson). |
|
|
109
|
+
| `-v`, `--verbose` | On compile failure, print the full compiler command and complete error output. Default shows only the compiler, the source, and the first error lines. |
|
|
109
110
|
| `-- FLAGS...` | Everything after a bare `--` is appended to *every* compiler invocation. |
|
|
110
111
|
|
|
111
112
|
With no `--cc` and no `--target`, the config file's top-level
|
|
@@ -207,7 +208,8 @@ compile_commands = "$HOME/myproject/build/compile_commands.json"
|
|
|
207
208
|
Now `asmdiff.py $HOME/myproject/components/dsp/biquad.c --target esp32s3-idf`
|
|
208
209
|
finds that file's entry in the database and adds the include/define flags
|
|
209
210
|
it recorded — `-I`, `-isystem`, `-iquote`, `-idirafter`, `-include`,
|
|
210
|
-
`-imacros`, `-D`, `-U
|
|
211
|
+
`-imacros`, `-D`, `-U`, plus the header-environment driver flags `-specs`
|
|
212
|
+
and `--sysroot` — to this target's command. Everything else the
|
|
211
213
|
database records (its own compiler, `-O`/`-std`/`-W` flags, `-c`, `-o`, the
|
|
212
214
|
source) is ignored: **you** own the compiler and optimisation flags via
|
|
213
215
|
`cc`/`flags`; only the header environment is borrowed. That split is the
|
|
@@ -219,6 +221,12 @@ Details that make it robust:
|
|
|
219
221
|
- **Paths are made absolute** against each entry's `directory`, so a
|
|
220
222
|
database full of build-relative `-I../include` flags still resolves when
|
|
221
223
|
asmdiff runs from anywhere.
|
|
224
|
+
- **`@file` response files are expanded.** Build systems park flags in
|
|
225
|
+
them — ESP-IDF v6 hides `-specs=picolibc.specs` in
|
|
226
|
+
`build/toolchain/cflags`, and without it every libc header breaks —
|
|
227
|
+
so the flag scan reads them (nested ones too) instead of skipping the
|
|
228
|
+
token. A bare specs name is left for the compiler's own search
|
|
229
|
+
directories; a specs path resolves like any other recorded path.
|
|
222
230
|
- **Per source file.** The lookup keys on the source you pass (matched by
|
|
223
231
|
resolved absolute path), so two files in an `--across`/summary run each
|
|
224
232
|
get their own recorded flags.
|
|
@@ -246,12 +254,15 @@ asmdiff.py biquad.c --compile-commands # same, for any matrix
|
|
|
246
254
|
asmdiff.py biquad.c --compile-commands path/to/compile_commands.json
|
|
247
255
|
```
|
|
248
256
|
|
|
249
|
-
Both
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
257
|
+
Both walk up from the current directory, checking each level for
|
|
258
|
+
`compile_commands.json` and then `build/compile_commands.json` (where
|
|
259
|
+
CMake and idf.py leave it) — first hit wins. The walk stops at the
|
|
260
|
+
repository root (the first directory with a `.git`), so running from any
|
|
261
|
+
depth of component directory finds the project database, but an unrelated
|
|
262
|
+
one further up the filesystem is never picked up. Finding nothing is an
|
|
263
|
+
error: discovery is opt-in, so if you asked for it, silence would be a
|
|
264
|
+
lie. It is never on by default — a target without `compile_commands` and
|
|
265
|
+
no `--compile-commands` flag borrows nothing.
|
|
255
266
|
|
|
256
267
|
The precedence is what you'd hope: a target that names its own
|
|
257
268
|
`compile_commands` path always keeps it; `--compile-commands` (with or
|
|
@@ -292,6 +303,12 @@ weight, did a call appear that shouldn't have? It sums parsed function
|
|
|
292
303
|
bodies only (no literal pools, data, or alignment), so it is not a size
|
|
293
304
|
measurement, and per-function rows are where the real information is.
|
|
294
305
|
|
|
306
|
+
Only labels the assembler types as functions are listed — global data
|
|
307
|
+
(string constants, state structs, lookup tables) gets column-0 labels too
|
|
308
|
+
but is not code. A `calls` list longer than 8 symbols is truncated to
|
|
309
|
+
`..., +N more`; real firmware dispatch functions call dozens of distinct
|
|
310
|
+
symbols and would otherwise make rows thousands of characters wide.
|
|
311
|
+
|
|
295
312
|
## Comparing the same function across two builds (`--across`)
|
|
296
313
|
|
|
297
314
|
`--pair` needs both variants to coexist in one compilation. Real changes
|
|
@@ -76,7 +76,7 @@ $ asmdiff.py asmdiff_example.c
|
|
|
76
76
|
```
|
|
77
77
|
asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
|
|
78
78
|
[--cc 'CC FLAGS']... [--target NAME]... [--config PATH]
|
|
79
|
-
[--compile-commands [PATH]] [-- EXTRA_FLAGS...]
|
|
79
|
+
[--compile-commands [PATH]] [-v] [-- EXTRA_FLAGS...]
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
| Option | Meaning |
|
|
@@ -87,7 +87,8 @@ asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
|
|
|
87
87
|
| `--cc 'CC FLAGS'` | One compiler invocation, command and flags in a single quoted string. Repeatable to build a matrix. |
|
|
88
88
|
| `--target NAME` | A named target from the config file, resolved to a `--cc` entry. Repeatable; appended to the matrix after `--cc` entries. |
|
|
89
89
|
| `--config PATH` | Config file to use. Default search: `asmdiff.toml` next to `SOURCE.c`, then in the current directory, then `~/.config/`. First hit wins. |
|
|
90
|
-
| `--compile-commands [PATH]` | Borrow each source's include/define flags from a `compile_commands.json`; with no `PATH`,
|
|
90
|
+
| `--compile-commands [PATH]` | Borrow each source's include/define flags from a `compile_commands.json`; with no `PATH`, walk up from the CWD checking each directory and its `build/` until the repository root. See [below](#borrowing-includes-from-compile_commandsjson). |
|
|
91
|
+
| `-v`, `--verbose` | On compile failure, print the full compiler command and complete error output. Default shows only the compiler, the source, and the first error lines. |
|
|
91
92
|
| `-- FLAGS...` | Everything after a bare `--` is appended to *every* compiler invocation. |
|
|
92
93
|
|
|
93
94
|
With no `--cc` and no `--target`, the config file's top-level
|
|
@@ -189,7 +190,8 @@ compile_commands = "$HOME/myproject/build/compile_commands.json"
|
|
|
189
190
|
Now `asmdiff.py $HOME/myproject/components/dsp/biquad.c --target esp32s3-idf`
|
|
190
191
|
finds that file's entry in the database and adds the include/define flags
|
|
191
192
|
it recorded — `-I`, `-isystem`, `-iquote`, `-idirafter`, `-include`,
|
|
192
|
-
`-imacros`, `-D`, `-U
|
|
193
|
+
`-imacros`, `-D`, `-U`, plus the header-environment driver flags `-specs`
|
|
194
|
+
and `--sysroot` — to this target's command. Everything else the
|
|
193
195
|
database records (its own compiler, `-O`/`-std`/`-W` flags, `-c`, `-o`, the
|
|
194
196
|
source) is ignored: **you** own the compiler and optimisation flags via
|
|
195
197
|
`cc`/`flags`; only the header environment is borrowed. That split is the
|
|
@@ -201,6 +203,12 @@ Details that make it robust:
|
|
|
201
203
|
- **Paths are made absolute** against each entry's `directory`, so a
|
|
202
204
|
database full of build-relative `-I../include` flags still resolves when
|
|
203
205
|
asmdiff runs from anywhere.
|
|
206
|
+
- **`@file` response files are expanded.** Build systems park flags in
|
|
207
|
+
them — ESP-IDF v6 hides `-specs=picolibc.specs` in
|
|
208
|
+
`build/toolchain/cflags`, and without it every libc header breaks —
|
|
209
|
+
so the flag scan reads them (nested ones too) instead of skipping the
|
|
210
|
+
token. A bare specs name is left for the compiler's own search
|
|
211
|
+
directories; a specs path resolves like any other recorded path.
|
|
204
212
|
- **Per source file.** The lookup keys on the source you pass (matched by
|
|
205
213
|
resolved absolute path), so two files in an `--across`/summary run each
|
|
206
214
|
get their own recorded flags.
|
|
@@ -228,12 +236,15 @@ asmdiff.py biquad.c --compile-commands # same, for any matrix
|
|
|
228
236
|
asmdiff.py biquad.c --compile-commands path/to/compile_commands.json
|
|
229
237
|
```
|
|
230
238
|
|
|
231
|
-
Both
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
239
|
+
Both walk up from the current directory, checking each level for
|
|
240
|
+
`compile_commands.json` and then `build/compile_commands.json` (where
|
|
241
|
+
CMake and idf.py leave it) — first hit wins. The walk stops at the
|
|
242
|
+
repository root (the first directory with a `.git`), so running from any
|
|
243
|
+
depth of component directory finds the project database, but an unrelated
|
|
244
|
+
one further up the filesystem is never picked up. Finding nothing is an
|
|
245
|
+
error: discovery is opt-in, so if you asked for it, silence would be a
|
|
246
|
+
lie. It is never on by default — a target without `compile_commands` and
|
|
247
|
+
no `--compile-commands` flag borrows nothing.
|
|
237
248
|
|
|
238
249
|
The precedence is what you'd hope: a target that names its own
|
|
239
250
|
`compile_commands` path always keeps it; `--compile-commands` (with or
|
|
@@ -274,6 +285,12 @@ weight, did a call appear that shouldn't have? It sums parsed function
|
|
|
274
285
|
bodies only (no literal pools, data, or alignment), so it is not a size
|
|
275
286
|
measurement, and per-function rows are where the real information is.
|
|
276
287
|
|
|
288
|
+
Only labels the assembler types as functions are listed — global data
|
|
289
|
+
(string constants, state structs, lookup tables) gets column-0 labels too
|
|
290
|
+
but is not code. A `calls` list longer than 8 symbols is truncated to
|
|
291
|
+
`..., +N more`; real firmware dispatch functions call dozens of distinct
|
|
292
|
+
symbols and would otherwise make rows thousands of characters wide.
|
|
293
|
+
|
|
277
294
|
## Comparing the same function across two builds (`--across`)
|
|
278
295
|
|
|
279
296
|
`--pair` needs both variants to coexist in one compilation. Real changes
|
|
@@ -42,8 +42,9 @@ flags = ["-O2", "-mlongcalls", "-DNDEBUG"]
|
|
|
42
42
|
# never transcribe include paths by hand. The compiler and -O/-m flags
|
|
43
43
|
# still come from cc/flags; only the header environment is borrowed.
|
|
44
44
|
# A path expands ~ and $VARS; `compile_commands = true` instead searches
|
|
45
|
-
#
|
|
46
|
-
#
|
|
45
|
+
# at run time: each directory from the CWD up to the repository root,
|
|
46
|
+
# and its build/ subdirectory (a source missing from a database found
|
|
47
|
+
# that way is compiled without borrowed flags, with a note).
|
|
47
48
|
[esp32s3-idf]
|
|
48
49
|
cc = "$HOME/.espressif/tools/xtensa-esp-elf/esp-*/xtensa-esp-elf/bin/xtensa-esp32s3-elf-gcc"
|
|
49
50
|
flags = ["-O2", "-mlongcalls"]
|
|
@@ -12,7 +12,7 @@ Usage:
|
|
|
12
12
|
tools/asmdiff/asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]...
|
|
13
13
|
[--across FUNC]... [--cc 'CC FLAGS']...
|
|
14
14
|
[--target NAME]... [--config PATH]
|
|
15
|
-
[--compile-commands [PATH]]
|
|
15
|
+
[--compile-commands [PATH]] [-v]
|
|
16
16
|
[-- EXTRA_FLAGS...]
|
|
17
17
|
|
|
18
18
|
Three modes:
|
|
@@ -35,9 +35,10 @@ the include/define flags recorded there for the source being compiled are
|
|
|
35
35
|
added to that target's command, so a real project source resolves its
|
|
36
36
|
headers the way its own build system does (e.g. an ESP-IDF component).
|
|
37
37
|
compile_commands = true in a target — or a bare --compile-commands on the
|
|
38
|
-
command line — instead
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
command line — instead finds the database by checking each directory from
|
|
39
|
+
the CWD up to the repository root, and its build/ subdirectory; a source
|
|
40
|
+
absent from a database found that way is compiled without borrowed flags
|
|
41
|
+
(with a note) rather than being an error.
|
|
41
42
|
"""
|
|
42
43
|
import argparse
|
|
43
44
|
import glob
|
|
@@ -73,6 +74,11 @@ CONFIG_NAME = "asmdiff.toml"
|
|
|
73
74
|
_CC_DB_PATH_FLAGS = ("-I", "-iquote", "-isystem", "-idirafter",
|
|
74
75
|
"-include", "-imacros", "-isysroot")
|
|
75
76
|
_CC_DB_PLAIN_FLAGS = ("-D", "-U")
|
|
77
|
+
# Driver-level flags that also shape the header environment: a specs file
|
|
78
|
+
# can swap the entire libc header set (ESP-IDF v6 selects picolibc via
|
|
79
|
+
# -specs=picolibc.specs), and --sysroot moves every system include. Both
|
|
80
|
+
# accept =-glued and split spellings; both are re-emitted =-glued.
|
|
81
|
+
_CC_DB_EQ_FLAGS = ("-specs", "--specs", "--sysroot")
|
|
76
82
|
_DB_CACHE = {}
|
|
77
83
|
_MISS_NOTED = set()
|
|
78
84
|
|
|
@@ -105,6 +111,43 @@ def _abs_against(directory, value):
|
|
|
105
111
|
return str(p)
|
|
106
112
|
|
|
107
113
|
|
|
114
|
+
def _expand_response_files(tokens, directory, depth=0):
|
|
115
|
+
"""Inline GCC @file response files: each @FILE token is replaced by the
|
|
116
|
+
shlex-split contents of FILE, resolved against the entry's ``directory``
|
|
117
|
+
(the CWD the driver ran from). Build systems park header-environment
|
|
118
|
+
flags there — ESP-IDF v6 hides -specs=picolibc.specs in one — so
|
|
119
|
+
skipping them silently loses flags this feature exists to borrow.
|
|
120
|
+
An unreadable file is warned about and dropped; nesting is bounded as
|
|
121
|
+
a cycle guard.
|
|
122
|
+
"""
|
|
123
|
+
out = []
|
|
124
|
+
for tok in tokens:
|
|
125
|
+
if not tok.startswith("@") or len(tok) == 1:
|
|
126
|
+
out.append(tok)
|
|
127
|
+
continue
|
|
128
|
+
path = _abs_against(directory, tok[1:])
|
|
129
|
+
try:
|
|
130
|
+
content = Path(path).read_text()
|
|
131
|
+
except OSError:
|
|
132
|
+
print(f"warning: response file {path} not readable; "
|
|
133
|
+
"flags inside it are not borrowed", file=sys.stderr)
|
|
134
|
+
continue
|
|
135
|
+
inner = shlex.split(content)
|
|
136
|
+
if depth < 8:
|
|
137
|
+
inner = _expand_response_files(inner, directory, depth + 1)
|
|
138
|
+
out += inner
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _specs_value(directory, value):
|
|
143
|
+
"""A specs argument with a path separator is a file path (resolved like
|
|
144
|
+
any other); a bare name (picolibc.specs) is looked up in the compiler's
|
|
145
|
+
own search directories and must pass through untouched."""
|
|
146
|
+
if "/" in value or os.sep in value:
|
|
147
|
+
return _abs_against(directory, value)
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
108
151
|
def include_flags(tokens, directory):
|
|
109
152
|
"""Pick header-search, forced-include, and define flags out of one
|
|
110
153
|
recorded compile command; make relative paths absolute against
|
|
@@ -124,6 +167,19 @@ def include_flags(tokens, directory):
|
|
|
124
167
|
flags += [f, _abs_against(directory, tok[len(f):])]
|
|
125
168
|
matched = True
|
|
126
169
|
break
|
|
170
|
+
if not matched:
|
|
171
|
+
for f in _CC_DB_EQ_FLAGS:
|
|
172
|
+
value = None
|
|
173
|
+
if tok == f and i + 1 < n: # -specs file
|
|
174
|
+
value, extra = tokens[i + 1], 1
|
|
175
|
+
elif tok.startswith(f + "="): # -specs=file
|
|
176
|
+
value = tok[len(f) + 1:]
|
|
177
|
+
if value is not None:
|
|
178
|
+
resolve = (_specs_value if f.endswith("specs")
|
|
179
|
+
else _abs_against)
|
|
180
|
+
flags.append(f + "=" + resolve(directory, value))
|
|
181
|
+
matched = True
|
|
182
|
+
break
|
|
127
183
|
if not matched:
|
|
128
184
|
for f in _CC_DB_PLAIN_FLAGS:
|
|
129
185
|
if tok == f and i + 1 < n: # -D FOO
|
|
@@ -138,18 +194,26 @@ def include_flags(tokens, directory):
|
|
|
138
194
|
|
|
139
195
|
|
|
140
196
|
def find_compile_commands():
|
|
141
|
-
"""Locate a compile_commands.json
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
197
|
+
"""Locate a compile_commands.json by walking up from the current
|
|
198
|
+
directory.
|
|
199
|
+
|
|
200
|
+
Each directory from the CWD upward is checked for the database itself,
|
|
201
|
+
then for build/compile_commands.json (where CMake and idf.py leave it),
|
|
202
|
+
so the search works from a project root or from any depth of component
|
|
203
|
+
directory. The walk stops at the first directory containing .git (the
|
|
204
|
+
repository root — checked after that directory's own candidates) or
|
|
205
|
+
after a bounded number of levels, so an unrelated database further up
|
|
206
|
+
the filesystem is never picked up. Returns None when nothing is found.
|
|
147
207
|
"""
|
|
148
|
-
|
|
149
|
-
for
|
|
150
|
-
candidate
|
|
151
|
-
|
|
152
|
-
|
|
208
|
+
d = Path.cwd()
|
|
209
|
+
for _ in range(10):
|
|
210
|
+
for candidate in (d / "compile_commands.json",
|
|
211
|
+
d / "build" / "compile_commands.json"):
|
|
212
|
+
if candidate.is_file():
|
|
213
|
+
return str(candidate)
|
|
214
|
+
if (d / ".git").exists() or d.parent == d:
|
|
215
|
+
return None
|
|
216
|
+
d = d.parent
|
|
153
217
|
return None
|
|
154
218
|
|
|
155
219
|
|
|
@@ -159,8 +223,9 @@ def _discovered_db(who):
|
|
|
159
223
|
not a silent no-op."""
|
|
160
224
|
found = find_compile_commands()
|
|
161
225
|
if found is None:
|
|
162
|
-
sys.exit(f"error: {who}: no compile_commands.json found in "
|
|
163
|
-
"
|
|
226
|
+
sys.exit(f"error: {who}: no compile_commands.json found in the "
|
|
227
|
+
"current directory, its build/, or any parent up to the "
|
|
228
|
+
"repository root")
|
|
164
229
|
return found
|
|
165
230
|
|
|
166
231
|
|
|
@@ -212,6 +277,7 @@ def compile_commands_flags(db_path, source, missing_ok=False):
|
|
|
212
277
|
args = entry.get("arguments")
|
|
213
278
|
tokens = (list(args) if isinstance(args, list)
|
|
214
279
|
else shlex.split(entry.get("command", "")))
|
|
280
|
+
tokens = _expand_response_files(tokens, directory)
|
|
215
281
|
return include_flags(tokens, directory)
|
|
216
282
|
if Path(f).name == want.name:
|
|
217
283
|
name_seen = True
|
|
@@ -229,10 +295,18 @@ def compile_commands_flags(db_path, source, missing_ok=False):
|
|
|
229
295
|
|
|
230
296
|
# A label at column 0 that is not a local (.L*) label starts a function.
|
|
231
297
|
FUNC_LABEL = re.compile(r"^([A-Za-z_][\w$.]*):")
|
|
232
|
-
#
|
|
298
|
+
# .type NAME, @object|@function — gcc/clang emit this before the label on
|
|
299
|
+
# ELF. Objects (string constants like __func__, global state, LUTs) get
|
|
300
|
+
# column-0 labels too and must not be reported as functions. The type
|
|
301
|
+
# marker is @ on x86/Xtensa/RISC-V, % on ARM, # on some targets.
|
|
302
|
+
TYPE_DIRECTIVE = re.compile(r'^\s*\.type\s+([^,\s]+)\s*,\s*[@%#]?(\w+)')
|
|
303
|
+
# Assembler directives that carry no information worth reading. .local/
|
|
304
|
+
# .comm/.lcomm declare zero-initialised data, .literal/.literal_position
|
|
305
|
+
# are Xtensa literal-pool bookkeeping — none of them is an instruction.
|
|
233
306
|
NOISE = re.compile(
|
|
234
307
|
r"^\s*\.(cfi_|p2align|align\b|loc\b|file\b|text\b|globl\b|global\b|"
|
|
235
|
-
r"type\b|section\b|ident\b|weak\b|hidden\b|addrsig|build_version
|
|
308
|
+
r"type\b|section\b|ident\b|weak\b|hidden\b|addrsig|build_version|"
|
|
309
|
+
r"local\b|comm\b|lcomm\b|literal_position|literal\b)"
|
|
236
310
|
)
|
|
237
311
|
# Compiler-generated bracketing labels that add nothing (.LFB0:, .Lfunc_end0:).
|
|
238
312
|
NOISE_LABEL = re.compile(r"^\.(LFB|LFE|Lfunc_begin|Lfunc_end)\d*:")
|
|
@@ -252,18 +326,28 @@ def extract_functions(asm_text):
|
|
|
252
326
|
|
|
253
327
|
A function body runs from its column-0 label to the matching .size
|
|
254
328
|
directive (gcc and clang both emit one on ELF) or the next function
|
|
255
|
-
label.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
329
|
+
label. Labels typed ``@object`` (string constants, global state,
|
|
330
|
+
lookup tables) are data, not functions, and are skipped entirely;
|
|
331
|
+
an untyped label still counts as a function so hand-written asm
|
|
332
|
+
keeps working. Comment lines, CFI/section/alignment directives,
|
|
333
|
+
compiler bracketing labels, and inline data (switch jump tables,
|
|
334
|
+
constants) are dropped; instructions and meaningful local labels
|
|
335
|
+
(loop targets) are kept, whitespace-stripped.
|
|
259
336
|
"""
|
|
260
337
|
funcs = {}
|
|
261
338
|
current = None
|
|
339
|
+
data_labels = set()
|
|
262
340
|
for raw in asm_text.splitlines():
|
|
341
|
+
t = TYPE_DIRECTIVE.match(raw)
|
|
342
|
+
if t and t.group(2) != "function":
|
|
343
|
+
data_labels.add(t.group(1))
|
|
263
344
|
m = FUNC_LABEL.match(raw)
|
|
264
345
|
if m:
|
|
265
|
-
|
|
266
|
-
|
|
346
|
+
if m.group(1) in data_labels:
|
|
347
|
+
current = None
|
|
348
|
+
else:
|
|
349
|
+
current = m.group(1)
|
|
350
|
+
funcs[current] = []
|
|
267
351
|
continue
|
|
268
352
|
if current is None:
|
|
269
353
|
continue
|
|
@@ -453,7 +537,7 @@ def target_command(config, name, config_path):
|
|
|
453
537
|
if not isinstance(db, str):
|
|
454
538
|
sys.exit(f"error: target [{name}]: compile_commands must be a "
|
|
455
539
|
"path to a compile_commands.json, or true to search "
|
|
456
|
-
"
|
|
540
|
+
"upward from the current directory")
|
|
457
541
|
db = os.path.expandvars(os.path.expanduser(db))
|
|
458
542
|
return Target(shlex.join([resolve_cc(cc, name), *flags]), db, discovered)
|
|
459
543
|
|
|
@@ -491,6 +575,31 @@ def build_matrix(cc_args, target_args, config, config_path, db_arg=None):
|
|
|
491
575
|
return entries
|
|
492
576
|
|
|
493
577
|
|
|
578
|
+
# --verbose: print full compiler command lines and untrimmed stderr.
|
|
579
|
+
VERBOSE = False
|
|
580
|
+
# Without --verbose, a failed compile shows this many stderr lines — enough
|
|
581
|
+
# for the include chain plus the first error, which is the actionable part.
|
|
582
|
+
MAX_STDERR_LINES = 20
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _compile_failure(cmd, stderr):
|
|
586
|
+
"""Exit for a failed compile.
|
|
587
|
+
|
|
588
|
+
A borrowed-flags command runs to hundreds of tokens and a broken
|
|
589
|
+
header environment produces pages of stderr; dumping both buries the
|
|
590
|
+
actual error. Default: compiler + source + the first stderr lines.
|
|
591
|
+
--verbose restores the complete command and output.
|
|
592
|
+
"""
|
|
593
|
+
if VERBOSE:
|
|
594
|
+
sys.exit(f"error: compile failed: {shlex.join(cmd)}\n{stderr}")
|
|
595
|
+
lines = stderr.splitlines()
|
|
596
|
+
shown = "\n".join(lines[:MAX_STDERR_LINES])
|
|
597
|
+
dropped = len(lines) - MAX_STDERR_LINES
|
|
598
|
+
more = f"\n... {dropped} more stderr lines" if dropped > 0 else ""
|
|
599
|
+
sys.exit(f"error: {cmd[0]} failed on {cmd[-1]}\n{shown}{more}\n"
|
|
600
|
+
"(re-run with --verbose for the full command and output)")
|
|
601
|
+
|
|
602
|
+
|
|
494
603
|
def asm_output_name(cc_cmd, harness):
|
|
495
604
|
"""Filesystem-safe .s name for one (compiler, source) compilation.
|
|
496
605
|
|
|
@@ -527,7 +636,7 @@ def compile_to_asm(cc_cmd, extra_flags, harness, out_dir):
|
|
|
527
636
|
+ ["-S", "-o", str(out_s), str(harness)])
|
|
528
637
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
529
638
|
if proc.returncode != 0:
|
|
530
|
-
|
|
639
|
+
_compile_failure(cmd, proc.stderr)
|
|
531
640
|
return out_s.read_text()
|
|
532
641
|
|
|
533
642
|
|
|
@@ -554,14 +663,28 @@ def format_spans(spans):
|
|
|
554
663
|
return " ".join(f"{label}:{n}" for label, n in spans) or "-"
|
|
555
664
|
|
|
556
665
|
|
|
666
|
+
# Real firmware dispatch functions call dozens of distinct symbols; an
|
|
667
|
+
# uncapped list makes summary rows thousands of characters wide.
|
|
668
|
+
MAX_CALLS_SHOWN = 8
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def format_calls(calls):
|
|
672
|
+
"""Comma-joined callee list, capped at MAX_CALLS_SHOWN for readability."""
|
|
673
|
+
if not calls:
|
|
674
|
+
return "-"
|
|
675
|
+
if len(calls) > MAX_CALLS_SHOWN:
|
|
676
|
+
return (", ".join(calls[:MAX_CALLS_SHOWN])
|
|
677
|
+
+ f", +{len(calls) - MAX_CALLS_SHOWN} more")
|
|
678
|
+
return ", ".join(calls)
|
|
679
|
+
|
|
680
|
+
|
|
557
681
|
def summary_table(pairs, funcs):
|
|
558
682
|
"""Instruction counts, outbound calls, and loop spans per pair member."""
|
|
559
683
|
rows = [("function", "role", "insns", "calls", "loop spans")]
|
|
560
684
|
for old, new in pairs:
|
|
561
685
|
for name, role in ((old, "baseline"), (new, "candidate")):
|
|
562
686
|
insns, calls = analyze(funcs[name])
|
|
563
|
-
rows.append((name, role, str(insns),
|
|
564
|
-
", ".join(calls) or "-",
|
|
687
|
+
rows.append((name, role, str(insns), format_calls(calls),
|
|
565
688
|
format_spans(loop_spans(funcs[name]))))
|
|
566
689
|
return render_table(rows)
|
|
567
690
|
|
|
@@ -582,10 +705,10 @@ def file_summary_table(funcs):
|
|
|
582
705
|
for sym in calls:
|
|
583
706
|
if sym not in all_calls:
|
|
584
707
|
all_calls.append(sym)
|
|
585
|
-
rows.append((name, str(insns),
|
|
708
|
+
rows.append((name, str(insns), format_calls(calls),
|
|
586
709
|
format_spans(loop_spans(lines))))
|
|
587
710
|
rows.append((f"TOTAL ({len(funcs)} functions)", str(total_insns),
|
|
588
|
-
|
|
711
|
+
format_calls(all_calls), "-"))
|
|
589
712
|
return render_table(rows)
|
|
590
713
|
|
|
591
714
|
|
|
@@ -763,13 +886,20 @@ def main(argv=None):
|
|
|
763
886
|
default=None, metavar="PATH",
|
|
764
887
|
help="borrow each source's include/define flags from "
|
|
765
888
|
"a compile_commands.json; with no PATH, search "
|
|
766
|
-
"
|
|
889
|
+
"each directory (and its build/) from the CWD "
|
|
890
|
+
"up to the repository root. A target whose "
|
|
767
891
|
"config names its own compile_commands keeps it")
|
|
768
892
|
parser.add_argument("--config", metavar="PATH",
|
|
769
893
|
help=f"config file; default search: {CONFIG_NAME} "
|
|
770
894
|
"next to SOURCE.c, in the current directory, "
|
|
771
895
|
"then in ~/.config/")
|
|
896
|
+
parser.add_argument("-v", "--verbose", action="store_true",
|
|
897
|
+
help="on compile failure, print the full compiler "
|
|
898
|
+
"command and complete error output instead of "
|
|
899
|
+
"the first lines")
|
|
772
900
|
args = parser.parse_args(argv)
|
|
901
|
+
global VERBOSE
|
|
902
|
+
VERBOSE = args.verbose
|
|
773
903
|
|
|
774
904
|
if len(args.sources) > 2:
|
|
775
905
|
parser.error("at most two source files may be given")
|
|
@@ -388,6 +388,77 @@ class TestIncludeFlags(unittest.TestCase):
|
|
|
388
388
|
["-isystem", "/s"])
|
|
389
389
|
|
|
390
390
|
|
|
391
|
+
class TestSpecsAndSysroot(unittest.TestCase):
|
|
392
|
+
"""Driver flags that change the header environment (-specs, --sysroot)."""
|
|
393
|
+
|
|
394
|
+
def test_specs_glued_bare_name_not_resolved(self):
|
|
395
|
+
# A bare specs name (no path separator) is found in the compiler's
|
|
396
|
+
# own search dirs; gluing a directory onto it would break it.
|
|
397
|
+
self.assertEqual(
|
|
398
|
+
asmdiff.include_flags(["-specs=picolibc.specs"], "/build"),
|
|
399
|
+
["-specs=picolibc.specs"])
|
|
400
|
+
|
|
401
|
+
def test_specs_with_path_resolved_against_directory(self):
|
|
402
|
+
self.assertEqual(
|
|
403
|
+
asmdiff.include_flags(["-specs=./custom/my.specs"], "/build"),
|
|
404
|
+
["-specs=/build/custom/my.specs"])
|
|
405
|
+
|
|
406
|
+
def test_specs_split_and_double_dash(self):
|
|
407
|
+
self.assertEqual(
|
|
408
|
+
asmdiff.include_flags(["-specs", "nano.specs",
|
|
409
|
+
"--specs=nosys.specs"], "/b"),
|
|
410
|
+
["-specs=nano.specs", "--specs=nosys.specs"])
|
|
411
|
+
|
|
412
|
+
def test_sysroot_glued_and_split(self):
|
|
413
|
+
self.assertEqual(
|
|
414
|
+
asmdiff.include_flags(["--sysroot=sr", "--sysroot", "/abs"],
|
|
415
|
+
"/b"),
|
|
416
|
+
["--sysroot=/b/sr", "--sysroot=/abs"])
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class TestResponseFiles(unittest.TestCase):
|
|
420
|
+
"""GCC @file response files inside compile_commands entries."""
|
|
421
|
+
|
|
422
|
+
def test_flags_inside_response_file_are_borrowed(self):
|
|
423
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
424
|
+
rsp = Path(tmp) / "toolchain" / "cflags"
|
|
425
|
+
rsp.parent.mkdir()
|
|
426
|
+
rsp.write_text("-mlongcalls\n-specs=picolibc.specs\n-Irspinc\n")
|
|
427
|
+
src = Path(tmp) / "a.c"
|
|
428
|
+
src.touch()
|
|
429
|
+
db = Path(tmp) / "compile_commands.json"
|
|
430
|
+
db.write_text(json.dumps([{
|
|
431
|
+
"directory": tmp, "file": str(src),
|
|
432
|
+
"command": f"cc -Iinc @{rsp} -c a.c"}]))
|
|
433
|
+
asmdiff._DB_CACHE.clear()
|
|
434
|
+
self.assertEqual(
|
|
435
|
+
asmdiff.compile_commands_flags(str(db), str(src)),
|
|
436
|
+
["-I", f"{tmp}/inc", "-specs=picolibc.specs",
|
|
437
|
+
"-I", f"{tmp}/rspinc"])
|
|
438
|
+
|
|
439
|
+
def test_relative_response_file_resolves_against_directory(self):
|
|
440
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
441
|
+
(Path(tmp) / "cflags").write_text("-DFROMRSP")
|
|
442
|
+
self.assertEqual(
|
|
443
|
+
asmdiff._expand_response_files(["@cflags"], tmp),
|
|
444
|
+
["-DFROMRSP"])
|
|
445
|
+
|
|
446
|
+
def test_nested_response_files(self):
|
|
447
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
448
|
+
(Path(tmp) / "outer").write_text("@inner -DOUTER")
|
|
449
|
+
(Path(tmp) / "inner").write_text("-DINNER")
|
|
450
|
+
self.assertEqual(
|
|
451
|
+
asmdiff._expand_response_files(["@outer"], tmp),
|
|
452
|
+
["-DINNER", "-DOUTER"])
|
|
453
|
+
|
|
454
|
+
def test_missing_response_file_warns_and_continues(self):
|
|
455
|
+
err = io.StringIO()
|
|
456
|
+
with contextlib.redirect_stderr(err):
|
|
457
|
+
out = asmdiff._expand_response_files(["-DKEEP", "@/nope/x"], "/b")
|
|
458
|
+
self.assertEqual(out, ["-DKEEP"])
|
|
459
|
+
self.assertIn("/nope/x", err.getvalue())
|
|
460
|
+
|
|
461
|
+
|
|
391
462
|
class TestCompileCommandsFlags(unittest.TestCase):
|
|
392
463
|
def _db(self, tmp, entries):
|
|
393
464
|
path = Path(tmp) / "compile_commands.json"
|
|
@@ -477,15 +548,22 @@ def _inside(directory):
|
|
|
477
548
|
|
|
478
549
|
|
|
479
550
|
class TestFindCompileCommands(unittest.TestCase):
|
|
480
|
-
"""Auto-discovery of compile_commands.json
|
|
551
|
+
"""Auto-discovery of compile_commands.json by walking up from the CWD."""
|
|
481
552
|
|
|
482
553
|
def _touch_db(self, directory):
|
|
483
554
|
directory.mkdir(parents=True, exist_ok=True)
|
|
484
555
|
(directory / "compile_commands.json").write_text("[]")
|
|
485
556
|
|
|
486
|
-
def
|
|
557
|
+
def _repo(self, tmp):
|
|
558
|
+
"""A fake repo root: .git bounds the walk so tests never escape
|
|
559
|
+
the tempdir and pick up a stray database further up."""
|
|
560
|
+
root = Path(tmp)
|
|
561
|
+
(root / ".git").mkdir()
|
|
562
|
+
return root
|
|
563
|
+
|
|
564
|
+
def test_nearer_hits_win(self):
|
|
487
565
|
with tempfile.TemporaryDirectory() as tmp:
|
|
488
|
-
root =
|
|
566
|
+
root = self._repo(tmp)
|
|
489
567
|
cwd = root / "sub"
|
|
490
568
|
cwd.mkdir()
|
|
491
569
|
for where, expect in [(root / "build", root / "build"),
|
|
@@ -497,14 +575,127 @@ class TestFindCompileCommands(unittest.TestCase):
|
|
|
497
575
|
self.assertEqual(asmdiff.find_compile_commands(),
|
|
498
576
|
str(expect / "compile_commands.json"))
|
|
499
577
|
|
|
578
|
+
def test_walks_up_from_nested_component_dir(self):
|
|
579
|
+
# components/amy/src is three levels below the project root where
|
|
580
|
+
# idf.py leaves build/compile_commands.json.
|
|
581
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
582
|
+
root = self._repo(tmp)
|
|
583
|
+
self._touch_db(root / "build")
|
|
584
|
+
cwd = root / "components" / "amy" / "src"
|
|
585
|
+
cwd.mkdir(parents=True)
|
|
586
|
+
with _inside(cwd):
|
|
587
|
+
self.assertEqual(asmdiff.find_compile_commands(),
|
|
588
|
+
str(root / "build" / "compile_commands.json"))
|
|
589
|
+
|
|
590
|
+
def test_stops_at_repository_root(self):
|
|
591
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
592
|
+
self._touch_db(Path(tmp)) # db ABOVE the repo root
|
|
593
|
+
repo = Path(tmp) / "repo"
|
|
594
|
+
repo.mkdir()
|
|
595
|
+
(repo / ".git").write_text("gitdir: elsewhere") # worktree form
|
|
596
|
+
cwd = repo / "src"
|
|
597
|
+
cwd.mkdir()
|
|
598
|
+
with _inside(cwd):
|
|
599
|
+
self.assertIsNone(asmdiff.find_compile_commands())
|
|
600
|
+
|
|
500
601
|
def test_nothing_found_returns_none(self):
|
|
501
602
|
with tempfile.TemporaryDirectory() as tmp:
|
|
502
|
-
|
|
603
|
+
root = self._repo(tmp)
|
|
604
|
+
cwd = root / "a" / "b"
|
|
503
605
|
cwd.mkdir(parents=True)
|
|
504
606
|
with _inside(cwd):
|
|
505
607
|
self.assertIsNone(asmdiff.find_compile_commands())
|
|
506
608
|
|
|
507
609
|
|
|
610
|
+
class TestObjectLabels(unittest.TestCase):
|
|
611
|
+
"""Data labels must not be reported as functions."""
|
|
612
|
+
|
|
613
|
+
ASM = """\
|
|
614
|
+
\t.type\tscale, @function
|
|
615
|
+
scale:
|
|
616
|
+
\tentry\tsp, 32
|
|
617
|
+
\tretw.n
|
|
618
|
+
\t.size\tscale, .-scale
|
|
619
|
+
\t.type\t__func__$1, @object
|
|
620
|
+
\t.size\t__func__$1, 9
|
|
621
|
+
__func__$1:
|
|
622
|
+
\t.string\t"app_main"
|
|
623
|
+
\t.local\ts_queue
|
|
624
|
+
\t.comm\ts_queue,4,4
|
|
625
|
+
\t.lcomm\ts_tmp,8
|
|
626
|
+
"""
|
|
627
|
+
|
|
628
|
+
def test_object_labels_skipped(self):
|
|
629
|
+
funcs = asmdiff.extract_functions(self.ASM)
|
|
630
|
+
self.assertEqual(list(funcs), ["scale"])
|
|
631
|
+
|
|
632
|
+
def test_arm_percent_function_type_still_reported(self):
|
|
633
|
+
asm = "\t.type\tf, %function\nf:\n\tbx\tlr\n\t.size\tf, .-f\n"
|
|
634
|
+
self.assertEqual(list(asmdiff.extract_functions(asm)), ["f"])
|
|
635
|
+
|
|
636
|
+
def test_untyped_label_still_treated_as_function(self):
|
|
637
|
+
# Hand-written asm often has no .type at all.
|
|
638
|
+
asm = "myfunc:\n\tret\n"
|
|
639
|
+
self.assertEqual(list(asmdiff.extract_functions(asm)), ["myfunc"])
|
|
640
|
+
|
|
641
|
+
def test_local_comm_literal_lines_not_counted(self):
|
|
642
|
+
asm = ("f:\n\tmov.n\ta2, a3\n\t.literal_position\n"
|
|
643
|
+
"\t.literal .LC1, 4096\n\t.local\tx\n\t.comm\tx,4,4\n"
|
|
644
|
+
"\tretw.n\n")
|
|
645
|
+
funcs = asmdiff.extract_functions(asm)
|
|
646
|
+
insns, _ = asmdiff.analyze(funcs["f"])
|
|
647
|
+
self.assertEqual(insns, 2)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
class TestCompileFailureOutput(unittest.TestCase):
|
|
651
|
+
CMD = ["xtensa-gcc", "-O2"] + [f"-I/inc{i}" for i in range(50)] + ["a.c"]
|
|
652
|
+
STDERR = "\n".join(f"err line {i}" for i in range(60))
|
|
653
|
+
|
|
654
|
+
def test_default_trims_flags_and_stderr(self):
|
|
655
|
+
asmdiff.VERBOSE = False
|
|
656
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
657
|
+
asmdiff._compile_failure(self.CMD, self.STDERR)
|
|
658
|
+
msg = str(ctx.exception)
|
|
659
|
+
self.assertIn("xtensa-gcc failed on a.c", msg)
|
|
660
|
+
self.assertNotIn("-I/inc0", msg) # no flag dump
|
|
661
|
+
self.assertIn("err line 0", msg)
|
|
662
|
+
self.assertNotIn("err line 30", msg) # stderr trimmed
|
|
663
|
+
self.assertIn("40 more stderr lines", msg)
|
|
664
|
+
self.assertIn("--verbose", msg)
|
|
665
|
+
|
|
666
|
+
def test_verbose_shows_everything(self):
|
|
667
|
+
asmdiff.VERBOSE = True
|
|
668
|
+
try:
|
|
669
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
670
|
+
asmdiff._compile_failure(self.CMD, self.STDERR)
|
|
671
|
+
finally:
|
|
672
|
+
asmdiff.VERBOSE = False
|
|
673
|
+
msg = str(ctx.exception)
|
|
674
|
+
self.assertIn("-I/inc0", msg)
|
|
675
|
+
self.assertIn("err line 59", msg)
|
|
676
|
+
|
|
677
|
+
def test_short_stderr_not_annotated_with_more(self):
|
|
678
|
+
asmdiff.VERBOSE = False
|
|
679
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
680
|
+
asmdiff._compile_failure(["gcc", "x.c"], "one error\n")
|
|
681
|
+
self.assertNotIn("more stderr", str(ctx.exception))
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
class TestFormatCalls(unittest.TestCase):
|
|
685
|
+
def test_short_list_unchanged(self):
|
|
686
|
+
self.assertEqual(asmdiff.format_calls(["a", "b"]), "a, b")
|
|
687
|
+
|
|
688
|
+
def test_empty_is_dash(self):
|
|
689
|
+
self.assertEqual(asmdiff.format_calls([]), "-")
|
|
690
|
+
|
|
691
|
+
def test_long_list_capped(self):
|
|
692
|
+
calls = [f"fn{i}" for i in range(12)]
|
|
693
|
+
out = asmdiff.format_calls(calls)
|
|
694
|
+
self.assertTrue(out.endswith("+4 more"))
|
|
695
|
+
self.assertIn("fn7", out)
|
|
696
|
+
self.assertNotIn("fn8,", out)
|
|
697
|
+
|
|
698
|
+
|
|
508
699
|
class TestDiscoveredCompileCommands(unittest.TestCase):
|
|
509
700
|
"""compile_commands = true in a target / bare --compile-commands."""
|
|
510
701
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|