4"""Aggregate gcc ``-fstack-usage`` .su files into a project-wide stack report.
9NASA Power-of-10 Rule 3 forbids dynamic memory allocation after init.
10The silent equivalent is unbounded stack growth: a deep call chain or a
11single oversized frame can blow past the linker-reserved stack region
12and corrupt adjacent RAM. IEC 61508 SIL 3 and DO-178C Level B both
13require the project to *demonstrate* a worst-case stack bound, not
16When compiled with ``-fstack-usage``, gcc emits a ``<file>.su`` file
17next to each ``.o``. Each line has the form::
19 path/to/file.c:LINE:COL:function_name<TAB>FRAME_BYTES<TAB>QUALIFIER
21where QUALIFIER is one of ``static``, ``dynamic``, ``bounded``, or
22combinations like ``dynamic,bounded``.
271. Walks every ``examples/**/build*/**/*.su`` file.
282. For each function: records TU, function name, frame size, qualifier.
293. Flags any function with frame > ``--frame-limit`` (default 2048) or
30 with a ``dynamic`` qualifier.
314. Writes ``build/stack_usage.csv`` and a per-app
32 ``build/stack_usage_<app>.txt``.
335. Exits non-zero if a critical-path module
34 (ra8_isr, ra8_check, ra8_err, ra8_mpu, ra8_cgc, ra8_pfs) has any function
35 with frame > 256 bytes or any ``dynamic`` qualifier anywhere.
37This script is report-only by default for the rest of the codebase --
38the per-target ``-Wstack-usage=N`` warning (in cmake/ra8_warnings.cmake)
39is the build-time gate; this script is the project-wide aggregator.
42from __future__
import annotations
49from collections
import defaultdict
50from pathlib
import Path
52DEFAULT_FRAME_LIMIT = 2048
53CRITICAL_FRAME_LIMIT = 256
88DEFAULT_MIN_FUNCTIONS = 5000
95RC_ENUMERATION_BROKE = 2
118THIRD_PARTY_PATH_FRAGMENTS = (
119 "/libs/third_party/",
120 "/apps/shared_libs/third_party/",
125FIRST_PARTY_EXEMPTIONS = (
144def is_first_party(tu_path: str) -> bool:
145 """Whether a .su translation-unit path is ours rather than vendored SOUP.
147 Decided by path fragment against the vendored trees, because the TU path
148 inside a .su is whatever the compiler was given and is not reliably
149 repo-relative -- a prefix test would miss an absolute one.
151 return not any(frag
in tu_path
for frag
in THIRD_PARTY_PATH_FRAGMENTS)
154def exemption_for(tu_path: str, func: str) -> int:
155 """Return the exempt max-bytes for a first-party (tu, func) pair, or 0."""
156 for tu_frag, fn_name, max_bytes, _why
in FIRST_PARTY_EXEMPTIONS:
157 if tu_frag
in tu_path
and fn_name == func:
162_SU_LINE = re.compile(
163 r"^(?P<path>.+?):(?P<line>\d+):(?P<col>\d+):(?P<func>[^\s\t]+)"
164 r"[\t ]+(?P<bytes>\d+)[\t ]+(?P<qual>[A-Za-z,]+)\s*$"
169 """One function's stack frame, as reported by a single line of a .su file.
171 ``tu`` is the path the COMPILER recorded, not where the .su was found. The
172 two diverge for the shared-library archive, and the gates key off ``tu``
173 precisely so that a critical-path frame is caught wherever it was built.
176 __slots__ = (
"app",
"bytes_",
"func",
"qualifier",
"su_file",
"tu")
187 """Record one parsed .su line; every field is required and none is derived."""
192 self.qualifier = qualifier
193 self.su_file = su_file
196 def is_dynamic(self) -> bool:
197 """Whether gcc reported this frame as dynamically sized.
199 The qualifier is a comma-separated set, so it is split and matched as a
200 whole token rather than by substring -- a compound qualifier must be
201 decomposed, not pattern-matched.
203 ``dynamic,bounded`` counts as dynamic here even though gcc could bound
204 it. That is the conservative reading and it is deliberate: a bounded
205 dynamic frame still has no single compile-time size to put in a
206 worst-case stack budget, so it is surfaced for a human to judge.
208 return "dynamic" in self.qualifier.split(
",")
211 def is_critical(self) -> bool:
212 """Whether this frame belongs to a module held to the tighter 256-byte limit.
214 Matches the module name in the TU path OR in the function name, since a
215 critical-path function can be defined in a differently-named TU; either
216 hit is enough to pull the frame under the stricter budget.
218 return any(m
in self.tu
for m
in CRITICAL_MODULES)
or any(
219 m
in self.func
for m
in CRITICAL_MODULES
232SHARED_LIB_BUILD_SUBDIR =
"build/shared_libs"
233SHARED_LIB_APP_NAME =
"ra8_shared"
236def find_su_files(repo_root: Path) -> list:
237 """Collect every gcc ``.su`` file the cross-build emits.
241 * ``examples/`` -- each app's per-app CMake output lives in ``<app>/
242 build*/``, nested 2-4 levels below ``examples/`` (``examples/<tier>/
243 .../<app>/``). A ``.su`` is only ever emitted inside such a build tree,
244 so we recurse to any depth rather than assume a fixed layout. This also
245 covers the ineligible apps (TrustZone, non-ek board) that the fast path
246 still compiles from source per-app.
248 * ``build/shared_libs/`` -- the prebuilt universal-library archive the
249 fast path compiles once (see ``SHARED_LIB_BUILD_SUBDIR``). Absent on a
250 per-app-only build, so this is additive and never regresses the old
254 examples = repo_root /
"examples"
255 if examples.is_dir():
256 found.extend(p
for p
in examples.rglob(
"*.su")
if p.is_file())
257 shared = repo_root / SHARED_LIB_BUILD_SUBDIR
259 found.extend(p
for p
in shared.rglob(
"*.su")
if p.is_file())
263def app_name_for(su_file: Path, repo_root: Path) -> str:
264 """Attribute a .su file to the app whose build tree produced it.
266 Used only for GROUPING the per-app reports. The critical-path and
267 first-party gates deliberately key off the TU path recorded inside the .su
268 instead, so a shared-library frame is still judged correctly despite being
269 filed under a synthetic app name.
271 Returns "unknown" rather than raising for a .su outside both known roots:
272 an unattributable file should still appear in the aggregate report.
280 su_file.relative_to(repo_root / SHARED_LIB_BUILD_SUBDIR)
284 return SHARED_LIB_APP_NAME
286 rel = su_file.relative_to(repo_root /
"examples")
292 for i, part
in enumerate(parts):
293 if part.startswith(
"build"):
294 return parts[i - 1]
if i > 0
else "unknown"
295 return parts[0]
if parts
else "unknown"
298def parse_su(su_file: Path, app: str) -> list:
299 """Parse one .su file into StackEntry records, skipping anything unrecognised.
301 Every failure mode here is a silent skip -- an unreadable file, a line that
302 does not match the .su grammar, a byte count that is not an integer. That
303 tolerance suits a format gcc owns and may extend, but note the consequence:
304 if the .su grammar ever changed wholesale this would return no entries, and
305 ``main`` reports a parse of zero functions as success. The gate would go
306 quiet rather than fail. Nothing currently detects that.
310 text = su_file.read_text(encoding=
"utf-8", errors=
"replace")
313 for raw_line
in text.splitlines():
314 line = raw_line.strip()
317 match = _SU_LINE.match(line)
321 nbytes = int(match.group(
"bytes"))
337def write_csv(out_path: Path, entries: list) ->
None:
338 """Write the flat machine-readable report of every parsed frame.
340 Written with ``newline=""`` as the csv module requires, and in ASCII so a
341 non-ASCII symbol name fails here rather than producing a file the rest of
342 the ASCII-only toolchain cannot read.
344 out_path.parent.mkdir(parents=
True, exist_ok=
True)
345 with out_path.open(
"w", encoding=
"ascii", newline=
"")
as fh:
346 writer = csv.writer(fh)
347 writer.writerow([
"app",
"tu",
"function",
"frame_bytes",
"qualifier"])
349 writer.writerow([e.app, e.tu, e.func, e.bytes_, e.qualifier])
352def write_per_app(out_dir: Path, entries: list) ->
None:
353 """Write one human-readable report per app, biggest frame first.
355 Sorted descending because these files are read to answer "what should I
356 shrink?", so the answer belongs at the top rather than after a scroll.
358 by_app = defaultdict(list)
360 by_app[e.app].append(e)
361 out_dir.mkdir(parents=
True, exist_ok=
True)
362 for app, rows
in sorted(by_app.items()):
363 rows.sort(key=
lambda r: r.bytes_, reverse=
True)
364 path = out_dir / f
"stack_usage_{app}.txt"
365 with path.open(
"w", encoding=
"ascii")
as fh:
366 fh.write(f
"# stack usage report for {app}\n")
367 fh.write(f
"# {len(rows)} functions analysed\n")
368 fh.write(
"# columns: bytes qualifier function tu\n\n")
370 fh.write(f
"{r.bytes_:>8} {r.qualifier:<16} {r.func} {r.tu}\n")
373def find_violations(entries: list, frame_limit: int) -> tuple[list, list]:
374 """Partition entries into critical-path and soft breaches.
376 The two buckets are mutually exclusive by construction: a critical-path
377 frame is judged ONLY against the 256-byte limit and never also counted as
378 a soft breach, so one oversized function cannot be reported twice.
380 Returns ``(critical, soft)``.
386 if e.bytes_ > CRITICAL_FRAME_LIMIT
or e.is_dynamic:
388 elif e.bytes_ > frame_limit
or e.is_dynamic:
390 return critical, soft
393def print_top_n(entries: list, n: int) ->
None:
394 """Print the ``n`` largest frames across every app to stdout.
396 Ranks the whole corpus rather than each app separately, which is what makes
397 it useful on a CI log: the worst frame in the tree surfaces even when it
398 lives in an app nobody was looking at.
400 ranked = sorted(entries, key=
lambda r: r.bytes_, reverse=
True)[:n]
401 print(f
"\nTop {len(ranked)} stack-frame offenders across all apps:")
402 print(f
"{'bytes':>8} {'qualifier':<16} app/function (tu)")
404 print(f
"{r.bytes_:>8} {r.qualifier:<16} {r.app}/{r.func} ({r.tu})")
407def _add_enumeration_args(parser: argparse.ArgumentParser) ->
None:
408 """Add the #386 enumeration-floor and self-check options.
410 Kept out of ``_build_parser`` so that function stays within the 60-line
411 NASA Rule 4 budget the ``function-size`` gate enforces; these are the
412 controls that make an empty or collapsed sweep fail loudly.
417 default=DEFAULT_MIN_FUNCTIONS,
419 "Enumeration floor (issue #386). A sweep that parses fewer than "
420 "this many functions is treated as a collapsed enumeration and "
421 "FAILS, so a degraded run reporting fewer results cannot read as a "
422 "pass. Ignored under --allow-empty."
429 "Pre-commit-friendly: a freshly cloned tree has no .su files yet, "
430 "and forcing a full app build inside the hook would cost minutes "
431 "per commit. With this flag a sweep that finds NO .su files exits "
432 "0 and the function floor is not enforced. It does NOT excuse .su "
433 "files that decode to zero functions -- that is corruption, not a "
434 "fresh clone, and still fails. Omit this flag in CI so an empty or "
435 "collapsed sweep goes red."
442 "Assert the gate fires in BOTH directions on synthetic .su "
443 "fixtures -- an empty sweep and an over-budget frame go red, a "
444 "real clean run stays green -- then exit. Proves an enumeration "
445 "collapse cannot pass as clean."
450def _build_parser() -> argparse.ArgumentParser:
451 """Every command-line option this gate accepts."""
452 parser = argparse.ArgumentParser(
453 description=
"Aggregate gcc .su files into a project-wide report."
458 default=Path(__file__).resolve().parents[2],
463 default=DEFAULT_FRAME_LIMIT,
465 parser.add_argument(
"--top", type=int, default=10)
466 parser.add_argument(
"--quiet", action=
"store_true")
471 "Pre-commit-friendly mode: report soft violations and the "
472 "top-N table without failing on per-app frame breaches "
473 "or critical-module budget breaches. The only hard "
474 "failure left is a `dynamic` qualifier anywhere (NASA "
475 "Power-of-10 Rule 3 -- VLAs / alloca are forbidden). "
476 "Skips the report entirely if no .su files are present "
477 "yet (so a fresh clone never blocks a commit)."
484 "Promote the soft frame limit to a hard gate for "
485 "*first-party* TUs (everything outside both third-party roots). "
486 "Third-party SOUP remains exempt. First-party functions "
487 "with a justified large frame must be enrolled in "
488 "FIRST_PARTY_EXEMPTIONS at the top of this script."
491 _add_enumeration_args(parser)
495def _report_strict(soft: list, frame_limit: int) -> int:
496 """Promote a first-party soft violation into a hard failure.
498 Third-party SOUP stays exempt; a first-party function with a justified
499 large frame must be enrolled in FIRST_PARTY_EXEMPTIONS with a rationale.
503 if not is_first_party(e.tu):
505 allowed = exemption_for(e.tu, e.func)
506 if allowed > 0
and e.bytes_ <= allowed:
512 f
"\nSTRICT first-party violations: "
513 f
"{len(offenders)} function(s) exceed "
514 f
"{frame_limit} bytes and are not exempt:"
516 for e
in sorted(offenders, key=
lambda r: r.bytes_, reverse=
True):
517 print(f
" [{e.app}] {e.func} ({e.tu}): {e.bytes_} bytes [{e.qualifier}]")
519 "\nFix: either reduce the frame size (move scratch "
520 "buffers to module-static storage) or enroll the "
521 "function in FIRST_PARTY_EXEMPTIONS at the top of "
522 "scripts/checks/stack_usage_check.py with a written "
528def _report_critical(critical: list) ->
None:
529 """Print the critical-module frame breaches."""
531 f
"\nCRITICAL-PATH violations "
532 f
"(>{CRITICAL_FRAME_LIMIT} bytes or dynamic in "
533 f
"{','.join(CRITICAL_MODULES)}):"
535 for e
in sorted(critical, key=
lambda r: r.bytes_, reverse=
True):
536 print(f
" [{e.app}] {e.func} ({e.tu}): {e.bytes_} bytes [{e.qualifier}]")
539def _report_dynamic(all_entries: list) -> int:
540 """NASA P10 Rule 3: a `dynamic` qualifier is a VLA or alloca.
542 Forbidden in this firmware regardless of frame size, in any module, and
543 the gate applies even in --warn-only mode. No deviation procedure.
545 dyn = [e
for e
in all_entries
if e.is_dynamic]
549 f
"\nNASA P10 Rule 3 violation: "
550 f
"{len(dyn)} function(s) carry a `dynamic` qualifier "
551 f
"(VLA or alloca). Forbidden in this firmware -- "
552 f
"replace with a fixed-size buffer or an enum-bounded "
555 for e
in sorted(dyn, key=
lambda r: r.bytes_, reverse=
True):
556 print(f
" [{e.app}] {e.func} ({e.tu}): {e.bytes_} bytes [{e.qualifier}]")
560def _collect_entries(repo_root: Path, su_files: list) -> list:
561 """Parse every .su file into entries tagged with their owning app."""
564 app = app_name_for(su, repo_root)
565 all_entries.extend(parse_su(su, app))
569def _print_census(su_count: int, func_count: int) ->
None:
570 """Print the file/function census that tells a real pass from an empty one.
572 Emitted on EVERY path -- pass and fail alike, and regardless of --quiet --
573 because it is the one line that makes a green result meaningful (#386): a
574 reader can see whether the sweep actually measured anything.
576 print(f
"stack_usage_check: parsed {func_count} function(s) from {su_count} .su file(s).")
579def _check_enumeration(
580 su_count: int, func_count: int, min_functions: int, allow_empty: bool
582 """Decide whether the sweep measured enough to trust its verdict (#386).
584 A gate that examined nothing must FAIL, not pass. The three collapse modes
585 are graded distinctly:
587 * NO .su files -- an unbuilt or moved build tree. Under ``--allow-empty``
588 (the pre-commit path) this is a fresh clone and is tolerated; otherwise
589 it is a build that never ran and fails.
590 * .su files that decode to ZERO functions -- corruption or a grammar the
591 parser no longer understands. This is never a fresh clone, so it fails
592 even under ``--allow-empty``.
593 * FEWER functions than the floor -- a partially collapsed enumeration. A
594 run reporting far fewer results than a real build must not read as an
595 improvement, so it fails; ``--allow-empty`` skips the floor because a
596 single-app local build legitimately parses fewer.
598 Returns ``RC_OK`` when the sweep is trustworthy, ``RC_ENUMERATION_BROKE``
599 otherwise (after printing the reason to stderr).
604 "stack_usage_check: no .su files found; nothing built with "
605 "-fstack-usage yet -- allowed under --allow-empty.",
610 "stack_usage_check: FAIL -- no .su files found under examples/**/build*/ "
611 "or build/shared_libs/.\n"
612 " The build never ran or its output moved: there is no stack budget to "
613 "measure, so this is a failure, not a pass.\n"
614 " Run the build-cross gate first (it compiles every app with "
618 return RC_ENUMERATION_BROKE
622 f
"stack_usage_check: FAIL -- found {su_count} .su file(s) but parsed 0 "
624 " .su files present that decode to nothing is corruption or a changed "
625 ".su grammar, not a fresh clone; the stack budget went unmeasured.",
628 return RC_ENUMERATION_BROKE
630 if not allow_empty
and func_count < min_functions:
632 f
"stack_usage_check: FAIL -- parsed only {func_count} function(s) from "
633 f
"{su_count} .su file(s), below the floor of {min_functions}.\n"
634 " The enumeration collapsed (a partial build, a moved build dir, or a "
635 "compiler swap). A degraded sweep reporting fewer frames must not read as "
639 return RC_ENUMERATION_BROKE
644def main(argv: list) -> int:
645 """Aggregate every .su file, write the reports, and gate on the violations.
647 A green run is only evidence that stack budgets were checked when the sweep
648 actually parsed functions. Every no-input path is therefore graded by
649 ``_check_enumeration`` and prints the file/function census: an empty sweep
650 or one collapsed below ``--min-functions`` FAILS with
651 ``RC_ENUMERATION_BROKE`` rather than passing vacuously (#386). The only
652 tolerated empty case is ``--allow-empty`` with no .su files at all -- the
653 pre-commit fresh-clone path, where forcing a full build inside the hook
654 would cost minutes per commit.
656 Severity is tiered rather than uniform. Critical-path modules breach at 256
657 bytes, everything else at ``--frame-limit`` (2048), and a ``dynamic``
658 qualifier anywhere is a P10 Rule 3 violation regardless of size --
659 that last check runs on ALL entries and is the one thing ``--warn-only``
662 Returns ``RC_ENUMERATION_BROKE`` (2) when the sweep measured too little to
663 trust, ``RC_VIOLATION`` (1) on a strict-mode soft breach, an un-waived
664 critical breach, or any dynamic frame, and ``RC_OK`` (0) otherwise.
666 args = _build_parser().parse_args(argv)
671 repo_root = args.repo_root.resolve()
672 su_files = find_su_files(repo_root)
673 all_entries = _collect_entries(repo_root, su_files)
675 su_count = len(su_files)
676 func_count = len(all_entries)
677 _print_census(su_count, func_count)
679 verdict = _check_enumeration(su_count, func_count, args.min_functions, args.allow_empty)
686 out_dir = repo_root /
"build"
687 write_csv(out_dir /
"stack_usage.csv", all_entries)
688 write_per_app(out_dir, all_entries)
690 critical, soft = find_violations(all_entries, args.frame_limit)
693 print(f
" CSV: {out_dir / 'stack_usage.csv'}")
694 print(f
" per-app: {out_dir}/stack_usage_<app>.txt")
695 print_top_n(all_entries, args.top)
699 f
"\nSOFT violations: {len(soft)} function(s) over {args.frame_limit} bytes or dynamic:"
701 for e
in sorted(soft, key=
lambda r: r.bytes_, reverse=
True):
702 print(f
" [{e.app}] {e.func} ({e.tu}): {e.bytes_} bytes [{e.qualifier}]")
704 if args.strict
and _report_strict(soft, args.frame_limit):
708 _report_critical(critical)
709 if not args.warn_only:
712 return _report_dynamic(all_entries)
715def _write_su_fixture(build_dir: Path, name: str, lines: list) ->
None:
716 """Write a synthetic .su file under a fake app build tree for the selftest."""
717 build_dir.mkdir(parents=
True, exist_ok=
True)
718 (build_dir / name).write_text(
"".join(f
"{line}\n" for line
in lines), encoding=
"ascii")
721def selftest() -> int:
722 """Assert the gate fires in BOTH directions, so a collapse cannot pass clean.
724 Builds throwaway .su fixtures in a temp tree and drives ``main`` against
725 them. A vacuous selftest would defeat the whole point of #386, so each
726 direction is asserted against a real return code:
728 * an empty sweep (no .su files, no --allow-empty) FAILS;
729 * .su files that decode to zero functions FAIL even under --allow-empty;
730 * a sweep below the floor FAILS, but the same fixture PASSES once the floor
731 is lowered -- proving the floor, not something else, is what tripped;
732 * an over-budget first-party frame FAILS under --strict;
733 * a genuine clean run PASSES.
735 Returns ``RC_OK`` when every direction behaves, ``RC_VIOLATION`` otherwise.
739 def expect_red(got: int, label: str) ->
None:
741 failures.append(f
"{label}: expected non-zero, got {got}")
743 def expect_green(got: int, label: str) ->
None:
745 failures.append(f
"{label}: expected 0, got {got}")
747 with tempfile.TemporaryDirectory()
as tmp:
749 build = root /
"examples" /
"app" /
"build"
750 low = [
"--strict",
"--min-functions",
"1"]
751 clean_line =
"apps/shared_libs/epub/src/ok.c:10:1:small_fn\t128\tstatic"
754 expect_red(
main([
"--repo-root", str(root)]),
"empty sweep")
756 expect_green(
main([
"--repo-root", str(root),
"--allow-empty"]),
"empty sweep, allow-empty")
760 _write_su_fixture(build,
"junk.su", [
"not a stack-usage line at all",
""])
762 main([
"--repo-root", str(root),
"--allow-empty"]),
763 "zero functions, allow-empty",
767 _write_su_fixture(build,
"junk.su", [clean_line])
768 expect_red(
main([
"--repo-root", str(root),
"--strict"]),
"below floor")
770 expect_green(
main([
"--repo-root", str(root), *low]),
"clean run, floor=1")
777 [clean_line,
"apps/shared_libs/epub/src/big.c:20:1:priv_scratch\t9000\tstatic"],
779 expect_red(
main([
"--repo-root", str(root), *low]),
"over-budget frame")
782 print(
"SELFTEST FAILED:", file=sys.stderr)
783 for problem
in failures:
784 print(f
" - {problem}", file=sys.stderr)
786 print(
"selftest: stack_usage_check empty-sweep + floor + over-budget detection OK")
790if __name__ ==
"__main__":
791 sys.exit(
main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.