4"""Per-app size visualizer for ra8-firmware.
6Walks every examples/ek_ra8d2/<tier>/.../<app>/build/<app>.elf, runs
7`arm-none-eabi-size --format=sysv` on it, parses the output, and
8emits two markdown tables to stdout:
10 1. A combined sortable summary table (one row per app) with
11 text / data / bss / total columns, sorted by total size.
12 2. A per-app section breakdown (only when --verbose is passed).
14Optionally writes the rendered output to the ignored local report
15``build/reports/APP_SIZES.md`` when ``--write`` is passed. This report
16describes only ELF files present in the current workspace, so it is not a
17version-controlled source of truth. Missing tools, missing ELFs, and an ELF
18that cannot be inspected all fail closed.
22 python3 scripts/report/app_sizes.py
23 python3 scripts/report/app_sizes.py --write
24 python3 scripts/report/app_sizes.py --verbose
26Copyright (c) 2026 Brighton Sikarskie
27SPDX-License-Identifier: MIT
30from __future__
import annotations
37from dataclasses
import dataclass, field
38from pathlib
import Path
40REPO_ROOT = Path(__file__).resolve().parents[2]
41APPS_DIR = REPO_ROOT /
"examples" /
"ek_ra8d2"
42DEFAULT_OUT = REPO_ROOT /
"build" /
"reports" /
"APP_SIZES.md"
43SIZE_TOOL =
"arm-none-eabi-size"
61DATA_PREFIXES = (
".data",
".sdata",
".tdata")
62BSS_PREFIXES = (
".bss",
".sbss",
".tbss",
".stack_canary",
".heap",
".stack",
".noinit")
76 """Aggregated size buckets for one application ELF."""
83 sections: list[tuple] = field(default_factory=list)
86 def total(self) -> int:
87 """Sum of text + data + bss in bytes (on-target footprint)."""
88 return self.text + self.data + self.bss
91def find_size_tool() -> str | None:
92 """Locate arm-none-eabi-size on PATH."""
93 return shutil.which(SIZE_TOOL)
96def collect_elfs(apps_dir: Path = APPS_DIR) -> list[Path]:
97 """Every built app ELF currently on disk.
99 Reports what HAS been built rather than what could be: an app that was
100 never compiled is simply absent from the size table, not zero-sized.
103 if not apps_dir.is_dir():
105 for build_dir
in sorted(apps_dir.rglob(
"build")):
106 if not build_dir.is_dir():
108 app_dir = build_dir.parent
109 if app_dir == apps_dir:
111 elf = build_dir / f
"{app_dir.name}.elf"
117def input_error(size_tool: str |
None, elfs: list[Path]) -> str |
None:
118 """Describe a missing report prerequisite, or return ``None``."""
119 if size_tool
is None:
120 return f
"{SIZE_TOOL} not found on PATH"
123 "no examples/ek_ra8d2/<tier>/.../<app>/build/<app>.elf found; "
124 "build the desired applications first"
129def run_selftest() -> int:
130 """Prove discovery and fail-closed report prerequisites."""
131 with tempfile.TemporaryDirectory()
as tmp:
132 root = Path(tmp) /
"examples" /
"ek_ra8d2"
134 root /
"hw_pending" /
"alpha" /
"build" /
"alpha.elf",
135 root /
"hw_validated" /
"hil" /
"beta" /
"build" /
"beta.elf",
138 elf.parent.mkdir(parents=
True, exist_ok=
True)
141 unrelated = root /
"hw_validated" /
"hil" /
"beta" /
"build" /
"helper.elf"
143 shallow = root /
"build" /
"ek_ra8d2.elf"
144 shallow.parent.mkdir(parents=
True, exist_ok=
True)
147 found = collect_elfs(root)
148 want = list(expected)
151 "app_sizes.py --selftest: FAILED\n"
152 f
" expected: {[str(path.relative_to(root)) for path in want]}\n"
153 f
" found: {[str(path.relative_to(root)) for path in found]}",
158 if input_error(
None, found) != f
"{SIZE_TOOL} not found on PATH":
159 print(
"app_sizes.py --selftest: FAILED: missing tool was accepted", file=sys.stderr)
161 if input_error(
"/bin/size", [])
is None:
162 print(
"app_sizes.py --selftest: FAILED: empty ELF set was accepted", file=sys.stderr)
164 if input_error(
"/bin/size", found)
is not None:
165 print(
"app_sizes.py --selftest: FAILED: valid inputs were rejected", file=sys.stderr)
168 print(
"app_sizes.py --selftest: PASS (discovery and prerequisites fail closed)")
176def run_size(size_tool: str, elf: Path) -> AppSizes:
177 """Bucket one ELF's sections into text / data / bss totals.
179 Uses the sysv format because the default Berkeley output collapses
180 sections into fixed columns; sysv lists each section by name, which is
181 what allows the buckets to be assigned by name rather than by position.
183 result = subprocess.run(
184 [size_tool,
"--format=sysv", str(elf)],
189 app_dir = elf.parent.parent
191 name = app_dir.relative_to(APPS_DIR).as_posix()
194 sizes = AppSizes(name=name, elf=elf)
195 for raw_line
in result.stdout.splitlines():
196 line = raw_line.strip()
197 if not line
or line.startswith((
"section", elf.name)):
199 if line.startswith(
"Total"):
202 if len(parts) < _MIN_SYSV_PARTS:
209 if any(section.startswith(p)
for p
in DROP_PREFIXES):
211 sizes.sections.append((section, size))
212 if any(section.startswith(p)
for p
in TEXT_PREFIXES):
214 elif any(section.startswith(p)
for p
in DATA_PREFIXES):
216 elif any(section.startswith(p)
for p
in BSS_PREFIXES):
222def fmt_bytes(n: int) -> str:
223 """Return a humanised byte count: `1234` -> `1234 (1.2 KiB)`."""
224 if n < _BYTES_PER_KIB:
226 return f
"{n} ({n / _BYTES_PER_KIB:.1f} KiB)"
229def render_summary(rows: list[AppSizes]) -> str:
230 """Render the sorted-by-total combined summary table."""
231 rows = sorted(rows, key=
lambda r: r.total, reverse=
True)
233 "## Combined summary (sorted by total)",
235 "| App | text | data | bss | total |",
236 "|-----|-----:|-----:|----:|------:|",
239 f
"| `{r.name}` | {fmt_bytes(r.text)} | {fmt_bytes(r.data)} "
240 f
"| {fmt_bytes(r.bss)} | {fmt_bytes(r.total)} |"
245 smallest =
min(rows, key=
lambda r: r.total)
246 largest = max(rows, key=
lambda r: r.total)
247 mean = sum(r.total
for r
in rows) / float(n)
252 f
"- Apps measured: **{n}**",
253 f
"- Smallest: `{smallest.name}` ({fmt_bytes(smallest.total)})",
254 f
"- Largest: `{largest.name}` ({fmt_bytes(largest.total)})",
255 f
"- Mean total: {mean:.0f} bytes ({mean / 1024.0:.1f} KiB)",
257 return "\n".join(lines)
260def render_per_app(rows: list[AppSizes]) -> str:
261 """Render per-app text/data/bss/total table block."""
262 lines = [
"## Per-app text/data/bss totals",
""]
263 for r
in sorted(rows, key=
lambda r: r.name):
267 "| section | size |",
268 "|---------|-----:|",
269 f
"| text | {fmt_bytes(r.text)} |",
270 f
"| data | {fmt_bytes(r.data)} |",
271 f
"| bss | {fmt_bytes(r.bss)} |",
272 f
"| total | {fmt_bytes(r.total)} |",
274 f
"_ELF: `{r.elf.relative_to(REPO_ROOT)}`_",
277 return "\n".join(lines)
280def render_full(rows: list[AppSizes]) -> str:
281 """Render the full markdown document."""
283 "# ra8-firmware -- per-application size report\n\n"
284 "Local, ignored output generated by `scripts/report/app_sizes.py`. Re-run via\n"
285 "`just docs::sizes`. Numbers come from "
286 "`arm-none-eabi-size --format=sysv` on each\n"
287 "`examples/ek_ra8d2/<tier>/.../<app>/build/<app>.elf`.\n\n"
288 "- `text` -- combined .vectors / .text / .rodata / .gnu.sgstubs /\n"
289 " .init_array / .ARM.exidx (everything that lives in MRAM at runtime).\n"
290 "- `data` -- combined .data / .sdata (initialized RAM).\n"
291 "- `bss` -- combined .bss / .stack_canary / .noinit (zero-init RAM).\n"
292 "- `total` -- text + data + bss (on-target footprint; `.debug_*`,\n"
293 " `.ARM.attributes`, and `.option_setting_*` are\n"
294 " excluded because they do not consume target memory).\n\n"
296 return head + render_summary(rows) +
"\n\n" + render_per_app(rows) +
"\n"
299def main(argv: list[str] |
None =
None) -> int:
300 """Entry point: collect ELFs, run size, render markdown."""
301 parser = argparse.ArgumentParser(description=__doc__)
303 "--write", action=
"store_true", help=f
"Also write the output to {DEFAULT_OUT}."
306 "--verbose", action=
"store_true", help=
"Print the per-app section breakdown too."
309 "--selftest", action=
"store_true", help=
"exercise nested app discovery and exit"
311 args = parser.parse_args(argv)
314 return run_selftest()
316 size_tool = find_size_tool()
317 elfs = collect_elfs()
318 if problem := input_error(size_tool, elfs):
319 print(f
"ERROR: {problem}", file=sys.stderr)
322 if size_tool
is None:
325 rows: list[AppSizes] = []
328 rows.append(run_size(size_tool, elf))
329 except subprocess.CalledProcessError
as exc:
330 print(f
"ERROR: {SIZE_TOOL} failed for {elf}: {exc}", file=sys.stderr)
333 rendered = render_full(rows)
337 DEFAULT_OUT.parent.mkdir(parents=
True, exist_ok=
True)
338 DEFAULT_OUT.write_text(rendered, encoding=
"ascii")
339 print(f
"wrote {DEFAULT_OUT.relative_to(REPO_ROOT)}", file=sys.stderr)
343if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.