ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
app_sizes.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Per-app size visualizer for ra8-firmware.
5
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:
9
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).
13
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.
19
20Usage:
21
22 python3 scripts/report/app_sizes.py
23 python3 scripts/report/app_sizes.py --write
24 python3 scripts/report/app_sizes.py --verbose
25
26Copyright (c) 2026 Brighton Sikarskie
27SPDX-License-Identifier: MIT
28"""
29
30from __future__ import annotations
31
32import argparse
33import shutil
34import subprocess
35import sys
36import tempfile
37from dataclasses import dataclass, field
38from pathlib import Path
39
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"
44
45# Section -> bucket. Anything matching .text* lands in "text", and
46# so on. .debug_* / .ARM.attributes / .comment are dropped from the
47# headline numbers because they do not consume on-target memory.
48TEXT_PREFIXES = (
49 ".text",
50 ".vectors",
51 ".rodata",
52 ".init",
53 ".fini",
54 ".gnu.sgstubs",
55 ".ARM.exidx",
56 ".ARM.extab",
57 ".init_array",
58 ".fini_array",
59 ".preinit_array",
60)
61DATA_PREFIXES = (".data", ".sdata", ".tdata")
62BSS_PREFIXES = (".bss", ".sbss", ".tbss", ".stack_canary", ".heap", ".stack", ".noinit")
63DROP_PREFIXES = (
64 ".debug_",
65 ".ARM.attributes",
66 ".comment",
67 ".symtab",
68 ".strtab",
69 ".shstrtab",
70 ".option_setting_",
71)
72
73
74@dataclass
75class AppSizes:
76 """Aggregated size buckets for one application ELF."""
77
78 name: str
79 elf: Path
80 text: int = 0
81 data: int = 0
82 bss: int = 0
83 sections: list[tuple] = field(default_factory=list)
84
85 @property
86 def total(self) -> int:
87 """Sum of text + data + bss in bytes (on-target footprint)."""
88 return self.text + self.data + self.bss
89
90
91def find_size_tool() -> str | None:
92 """Locate arm-none-eabi-size on PATH."""
93 return shutil.which(SIZE_TOOL)
94
95
96def collect_elfs(apps_dir: Path = APPS_DIR) -> list[Path]:
97 """Every built app ELF currently on disk.
98
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.
101 """
102 out: list[Path] = []
103 if not apps_dir.is_dir():
104 return out
105 for build_dir in sorted(apps_dir.rglob("build")):
106 if not build_dir.is_dir():
107 continue
108 app_dir = build_dir.parent
109 if app_dir == apps_dir:
110 continue
111 elf = build_dir / f"{app_dir.name}.elf"
112 if elf.is_file():
113 out.append(elf)
114 return out
115
116
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"
121 if not elfs:
122 return (
123 "no examples/ek_ra8d2/<tier>/.../<app>/build/<app>.elf found; "
124 "build the desired applications first"
125 )
126 return None
127
128
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"
133 expected = (
134 root / "hw_pending" / "alpha" / "build" / "alpha.elf",
135 root / "hw_validated" / "hil" / "beta" / "build" / "beta.elf",
136 )
137 for elf in expected:
138 elf.parent.mkdir(parents=True, exist_ok=True)
139 elf.touch()
140
141 unrelated = root / "hw_validated" / "hil" / "beta" / "build" / "helper.elf"
142 unrelated.touch()
143 shallow = root / "build" / "ek_ra8d2.elf"
144 shallow.parent.mkdir(parents=True, exist_ok=True)
145 shallow.touch()
146
147 found = collect_elfs(root)
148 want = list(expected)
149 if found != want:
150 print(
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]}",
154 file=sys.stderr,
155 )
156 return 1
157
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)
160 return 1
161 if input_error("/bin/size", []) is None:
162 print("app_sizes.py --selftest: FAILED: empty ELF set was accepted", file=sys.stderr)
163 return 1
164 if input_error("/bin/size", found) is not None:
165 print("app_sizes.py --selftest: FAILED: valid inputs were rejected", file=sys.stderr)
166 return 1
167
168 print("app_sizes.py --selftest: PASS (discovery and prerequisites fail closed)")
169 return 0
170
171
172_MIN_SYSV_PARTS = 2 # arm-none-eabi-size sysv output has at least "name size" columns
173_BYTES_PER_KIB = 1024 # binary kilobyte
174
175
176def run_size(size_tool: str, elf: Path) -> AppSizes:
177 """Bucket one ELF's sections into text / data / bss totals.
178
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.
182 """
183 result = subprocess.run( # noqa: S603 # trusted: size_tool comes from shutil.which
184 [size_tool, "--format=sysv", str(elf)],
185 check=True,
186 capture_output=True,
187 text=True,
188 )
189 app_dir = elf.parent.parent
190 try:
191 name = app_dir.relative_to(APPS_DIR).as_posix()
192 except ValueError:
193 name = app_dir.name
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)):
198 continue
199 if line.startswith("Total"):
200 continue
201 parts = line.split()
202 if len(parts) < _MIN_SYSV_PARTS:
203 continue
204 section = parts[0]
205 try:
206 size = int(parts[1])
207 except ValueError:
208 continue
209 if any(section.startswith(p) for p in DROP_PREFIXES):
210 continue
211 sizes.sections.append((section, size))
212 if any(section.startswith(p) for p in TEXT_PREFIXES):
213 sizes.text += size
214 elif any(section.startswith(p) for p in DATA_PREFIXES):
215 sizes.data += size
216 elif any(section.startswith(p) for p in BSS_PREFIXES):
217 sizes.bss += size
218 # Otherwise: silently ignored (option_setting_*, etc.).
219 return sizes
220
221
222def fmt_bytes(n: int) -> str:
223 """Return a humanised byte count: `1234` -> `1234 (1.2 KiB)`."""
224 if n < _BYTES_PER_KIB:
225 return f"{n}"
226 return f"{n} ({n / _BYTES_PER_KIB:.1f} KiB)"
227
228
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)
232 lines = [
233 "## Combined summary (sorted by total)",
234 "",
235 "| App | text | data | bss | total |",
236 "|-----|-----:|-----:|----:|------:|",
237 ]
238 lines.extend(
239 f"| `{r.name}` | {fmt_bytes(r.text)} | {fmt_bytes(r.data)} "
240 f"| {fmt_bytes(r.bss)} | {fmt_bytes(r.total)} |"
241 for r in rows
242 )
243 if rows:
244 n = len(rows)
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)
248 lines += [
249 "",
250 "### Stats",
251 "",
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)",
256 ]
257 return "\n".join(lines)
258
259
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):
264 lines += [
265 f"### `{r.name}`",
266 "",
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)} |",
273 "",
274 f"_ELF: `{r.elf.relative_to(REPO_ROOT)}`_",
275 "",
276 ]
277 return "\n".join(lines)
278
279
280def render_full(rows: list[AppSizes]) -> str:
281 """Render the full markdown document."""
282 head = (
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"
295 )
296 return head + render_summary(rows) + "\n\n" + render_per_app(rows) + "\n"
297
298
299def main(argv: list[str] | None = None) -> int:
300 """Entry point: collect ELFs, run size, render markdown."""
301 parser = argparse.ArgumentParser(description=__doc__)
302 parser.add_argument(
303 "--write", action="store_true", help=f"Also write the output to {DEFAULT_OUT}."
304 )
305 parser.add_argument(
306 "--verbose", action="store_true", help="Print the per-app section breakdown too."
307 )
308 parser.add_argument(
309 "--selftest", action="store_true", help="exercise nested app discovery and exit"
310 )
311 args = parser.parse_args(argv)
312
313 if args.selftest:
314 return run_selftest()
315
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)
320 return 1
321 # input_error() establishes this; keep the type boundary explicit.
322 if size_tool is None:
323 return 1
324
325 rows: list[AppSizes] = []
326 for elf in elfs:
327 try:
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)
331 return 1
332
333 rendered = render_full(rows)
334 print(rendered)
335
336 if args.write:
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)
340 return 0
341
342
343if __name__ == "__main__":
344 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157