From dc9d0df59a3bc5311c6a9784aad9cf1d8b06e02d Mon Sep 17 00:00:00 2001 From: srpatcha Date: Sat, 29 Aug 2026 04:29:27 -0700 Subject: [PATCH] feat(build): report flash and RAM footprint after a build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MLP developer walk in the platform design document ends with a build that says how much of the board it used: Flash: 384 KB RAM: 72 KB Ready to flash. Nothing produced those numbers. A developer had to run `size` themselves and remember which columns to add, which is not being told — it is being left to find out. $ ebuild build [ok] Build completed successfully. Flash: 1.9 KB of 1.00 MB (0.2%) RAM : 300.6 KB of 192.0 KB (156.6%) [warn] RAM usage 300.6 KB exceeds the board's 192.0 KB -- this image will not fit. The accounting matches scripts/measure_footprint.py in the eos repo so the two tools cannot disagree about what a number means: flash = text + data ram = data + bss `data` is charged to both because it is stored in flash and copied to RAM at startup; reading `size`'s "dec" column instead understates RAM. Capacity comes from the project's own board.yaml `memory.flash_size` / `ram_size` when it ships one — the convention the descriptions under hardware/board/ already use — and otherwise from a table of the reference part for each board family. Boards that boot from removable storage, and Linux-class parts with no fixed budget, are deliberately absent: a percentage against a guessed ceiling reads as authoritative, so those report absolute sizes only. An over-budget image is a warning, not a build failure. It linked; it will not fit. The developer needs to hear that now rather than from a board that will not boot. A cross build is measured with its own `size` and never falls back to the host one — host `size` on an ARM ELF reports numbers for a different target and nothing in the output would say so. Where no suitable tool exists the report is skipped rather than guessed, and never fails the build. Also fixes .gitignore, found while committing this: line 8 was a bare `build/` from the Python-packaging block, which also matched `ebuild/build/` — the source package holding dispatch.py, ninja_backend.py and toolchain.py. Those four files predate the rule and stayed tracked, but every new module added there was silently ignored, including this one. Anchored to `/build/`, which keeps the setuptools artifact directory ignored and stops the pattern reaching nested source. 235 tests pass, up from 202; 32 are new here. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 +- ebuild/build/footprint.py | 228 +++++++++++++++++++++++++++++++++++ ebuild/cli/commands.py | 89 ++++++++++++++ tests/unit/test_footprint.py | 198 ++++++++++++++++++++++++++++++ 4 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 ebuild/build/footprint.py create mode 100644 tests/unit/test_footprint.py diff --git a/.gitignore b/.gitignore index 64115a9d..135341d4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,9 @@ __pycache__/ *.egg-info/ *.egg dist/ -build/ +# Anchored: the bare pattern also matched ebuild/build/, the source package, +# so any new module added there was silently untracked. +/build/ .eggs/ *.whl diff --git a/ebuild/build/footprint.py b/ebuild/build/footprint.py new file mode 100644 index 00000000..0414f81f --- /dev/null +++ b/ebuild/build/footprint.py @@ -0,0 +1,228 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Flash and RAM footprint of a built artifact. + +The MLP developer walk ends with a build that says how much of the board it +just used: + + Flash: 384 KB + RAM: 72 KB + Ready to flash. + +A developer who has to run `arm-none-eabi-size` themselves and remember which +columns to add is not being told; they are being left to find out. + +The accounting matches `scripts/measure_footprint.py` in the eos repo, so the +two tools cannot disagree about what a number means: + + text code + read-only data -> flash + data initialised writable data -> flash AND RAM + bss zero-initialised data -> RAM only + + flash = text + data + ram = data + bss + +`data` is charged to both because it is stored in flash and copied to RAM at +startup. Reading `size`'s "dec" column instead understates RAM. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional, Tuple + +#: Capacity of the reference part for each board family, in bytes. +#: +#: These are the parts `ebuild new --board` scaffolds against. A family spans +#: several densities -- an STM32F407 has 1 MB of flash and an STM32F401 has +#: 256 KB -- so a project that cares states its own numbers under `memory:` in +#: its board YAML, which takes precedence over anything here. +#: +#: Boards that boot from removable or external storage, and Linux-class parts +#: with no fixed budget, are absent on purpose: a made-up ceiling is worse +#: than no ceiling, because a percentage reads as authoritative. +_REFERENCE_CAPACITY: Dict[str, Tuple[int, int]] = { + # board flash ram + "nrf52": (512 * 1024, 64 * 1024), # nRF52832 + "nrf52840": (1024 * 1024, 256 * 1024), # nRF52840 + "stm32f4": (1024 * 1024, 192 * 1024), # STM32F407 + "stm32h7": (2048 * 1024, 1024 * 1024), # STM32H743 + "rp2040": (2048 * 1024, 264 * 1024), # RP2040 + 2 MB QSPI + "esp32": (4096 * 1024, 520 * 1024), # ESP32-WROOM-32, 4 MB module + "tms570": (3072 * 1024, 256 * 1024), # TMS570LS3137 +} + +_SIZE_LINE = re.compile( + r"^\s*(?P\d+)\s+(?P\d+)\s+(?P\d+)\s+\d+\s+[0-9a-fA-F]+\s" +) + + +class FootprintError(RuntimeError): + """Raised when a footprint cannot be measured.""" + + +@dataclass(frozen=True) +class Footprint: + """Section sizes of one artifact, in bytes.""" + + text: int + data: int + bss: int + + @property + def flash(self) -> int: + """Bytes occupied in flash: code, read-only data, and the stored + image of initialised writable data.""" + return self.text + self.data + + @property + def ram(self) -> int: + """Bytes occupied in RAM once started: initialised data copied out of + flash, plus the zero-initialised region.""" + return self.data + self.bss + + +def find_size_tool(toolchain_prefix: Optional[str] = None) -> Optional[str]: + """Locate the `size` binary for a toolchain. + + A cross build must be measured with its own `size`; the host one reads an + ARM ELF's headers but is not guaranteed to across every binutils version, + and silently reporting host numbers for a firmware image is worse than + reporting none. Returns None when nothing suitable is installed. + """ + if toolchain_prefix and toolchain_prefix != "host": + cross = shutil.which(f"{toolchain_prefix}-size") + if cross: + return cross + # No fallback to the host tool: the numbers would be for a different + # target and nothing in the output would say so. + return None + return shutil.which("size") + + +def measure(artifact: Path, size_tool: Optional[str] = None) -> Footprint: + """Section sizes of ``artifact``. + + Raises FootprintError rather than returning zeros, so a build cannot + report "Flash: 0 KB" when the truth is that nothing was measured. + """ + artifact = Path(artifact) + if not artifact.is_file(): + raise FootprintError(f"no artifact to measure at {artifact}") + + tool = size_tool or shutil.which("size") + if not tool: + raise FootprintError( + "no 'size' tool on PATH, so the footprint cannot be measured " + "(install binutils, or the toolchain's binutils for a cross build)" + ) + + try: + proc = subprocess.run([tool, str(artifact)], capture_output=True, + text=True, timeout=60) + except (OSError, subprocess.TimeoutExpired) as exc: + raise FootprintError(f"{tool} failed on {artifact}: {exc}") from exc + + if proc.returncode != 0: + raise FootprintError( + f"{tool} exited {proc.returncode} on {artifact}: " + f"{(proc.stderr or proc.stdout).strip()[:200]}" + ) + + for line in proc.stdout.splitlines(): + m = _SIZE_LINE.match(line) + if m: + return Footprint(text=int(m.group("text")), + data=int(m.group("data")), + bss=int(m.group("bss"))) + + raise FootprintError( + f"could not read a size line from {tool} output for {artifact}" + ) + + +def board_capacity(board: Optional[str], + board_config: Optional[dict] = None + ) -> Tuple[Optional[int], Optional[int]]: + """Flash and RAM capacity for a board, or (None, None) when unknown. + + A project's own board YAML wins over the reference table: `memory.flash_size` + and `memory.ram_size` are what the hardware descriptions in `hardware/board/` + already use. + """ + if board_config: + memory = board_config.get("memory") or {} + flash = _as_bytes(memory.get("flash_size")) + ram = _as_bytes(memory.get("ram_size")) + if flash or ram: + return flash, ram + if board: + found = _REFERENCE_CAPACITY.get(board.lower()) + if found: + return found + return None, None + + +def _as_bytes(value) -> Optional[int]: + """Board YAMLs write sizes as ints or as hex strings like ``0x200000``.""" + if value is None: + return None + if isinstance(value, int): + return value if value > 0 else None + try: + parsed = int(str(value), 0) + except ValueError: + return None + return parsed if parsed > 0 else None + + +def format_size(n: int) -> str: + """Human-readable size, at the granularity a developer reasons in.""" + if n < 1024: + return f"{n} B" + if n < 1024 * 1024: + return f"{n / 1024:.1f} KB" + return f"{n / (1024 * 1024):.2f} MB" + + +def format_report(fp: Footprint, + flash_capacity: Optional[int] = None, + ram_capacity: Optional[int] = None) -> str: + """The build's closing footprint block. + + A percentage is shown only where the capacity is actually known. Printing + one against a guessed ceiling would be the most misleading number in the + whole build. + """ + lines = [] + for label, used, cap in (("Flash", fp.flash, flash_capacity), + ("RAM ", fp.ram, ram_capacity)): + if cap: + pct = 100.0 * used / cap + lines.append(f" {label}: {format_size(used):>10}" + f" of {format_size(cap):>10} ({pct:.1f}%)") + else: + lines.append(f" {label}: {format_size(used):>10}") + return "\n".join(lines) + + +def over_budget(fp: Footprint, + flash_capacity: Optional[int] = None, + ram_capacity: Optional[int] = None) -> Optional[str]: + """A message naming the region that does not fit, or None. + + Being told at the end of a build beats being told by a linker script, and + beats being told by a board that will not boot. + """ + if flash_capacity and fp.flash > flash_capacity: + return (f"flash usage {format_size(fp.flash)} exceeds the board's " + f"{format_size(flash_capacity)}") + if ram_capacity and fp.ram > ram_capacity: + return (f"RAM usage {format_size(fp.ram)} exceeds the board's " + f"{format_size(ram_capacity)}") + return None diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index b7b95228..2dd646ff 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -226,6 +226,94 @@ def _resolve_backend_request( return resolved_backend, backend_config +def _selected_board(default: str = "generic") -> str: + """The board this project targets, from its eos.yaml. + + Read rather than passed in: `ebuild build` takes no --board of its own in + the documented walk, so the value has to survive from `ebuild new` or + `ebuild configure`. + """ + path = Path("eos.yaml") + if not path.is_file(): + return default + try: + import yaml + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception: + return default + return (data.get("system") or {}).get("board") or default + + +def _report_footprint(cfg: "ProjectConfig", build_path: Path, log: Logger) -> None: + """Print how much of the board the build just used. + + The MLP walk ends with a build that says `Flash: 384 KB / RAM: 72 KB`. A + developer who has to run `size` themselves and remember which columns to + add is not being told; they are being left to find out. + + Never fatal. A footprint that cannot be measured -- no binutils, a cross + toolchain whose `size` is not installed -- is a missing convenience, and + failing a successful build over it would be worse than the silence it + replaces. + """ + from ebuild.build.footprint import ( + FootprintError, board_capacity, find_size_tool, format_report, + measure, over_budget, + ) + + binaries = [t for t in cfg.targets if t.target_type == "executable"] + if not binaries: + return + + artifact = build_path / binaries[0].name + if not artifact.is_file(): + return + + prefix = getattr(cfg.toolchain, "target", None) or "host" + tool = find_size_tool(prefix) + if tool is None: + log.debug(f"no size tool for toolchain {prefix!r}; skipping footprint") + return + + try: + fp = measure(artifact, tool) + except FootprintError as exc: + log.debug(f"footprint unavailable: {exc}") + return + + board = _selected_board(default="") + flash_cap, ram_cap = board_capacity(board or None, _board_config()) + log.info("") + for line in format_report(fp, flash_cap, ram_cap).splitlines(): + log.info(line) + + exceeded = over_budget(fp, flash_cap, ram_cap) + if exceeded: + # Not a build failure: the image linked. It will not fit on the board, + # which the developer needs to hear now rather than from a device that + # will not boot. + log.warning(f"{exceeded} -- this image will not fit.") + else: + log.info("Ready to flash.") + + +def _board_config() -> Optional[Dict[str, Any]]: + """The project's own board description, if it ships one. + + A project that states its part's real capacity should not be measured + against the reference part for its family. + """ + path = Path("board.yaml") + if not path.is_file(): + return None + try: + import yaml + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except Exception: + return None + return data if isinstance(data, dict) else None + + def _configure_ninja_backend( cfg: ProjectConfig, build_path: Path, @@ -708,6 +796,7 @@ def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], raise SystemExit(1) log.success("Build completed successfully.") + _report_footprint(cfg, build_path, log) except FileNotFoundError as e: log.error(_format_missing_tool(e)) diff --git a/tests/unit/test_footprint.py b/tests/unit/test_footprint.py new file mode 100644 index 00000000..3ee44ed4 --- /dev/null +++ b/tests/unit/test_footprint.py @@ -0,0 +1,198 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Flash and RAM footprint reporting. + +The MLP developer walk ends with a build that says how much of the board it +used. Nothing produced those numbers, so a developer had to run `size` by hand +and know which columns to add. + +The accounting has to match `scripts/measure_footprint.py` in the eos repo, or +the two tools disagree about what "flash" means: + + flash = text + data + ram = data + bss +""" + +import subprocess +from pathlib import Path + +import pytest + +from ebuild.build.footprint import ( + Footprint, + FootprintError, + board_capacity, + find_size_tool, + format_report, + format_size, + measure, + over_budget, +) + + +class TestAccounting: + """`data` is charged to both regions: it is stored in flash and copied to + RAM at startup. Reading `size`'s "dec" column instead understates RAM.""" + + def test_flash_is_text_plus_data(self): + assert Footprint(text=1000, data=200, bss=500).flash == 1200 + + def test_ram_is_data_plus_bss(self): + assert Footprint(text=1000, data=200, bss=500).ram == 700 + + def test_data_is_counted_in_both(self): + fp = Footprint(text=0, data=64, bss=0) + assert fp.flash == 64 + assert fp.ram == 64 + + def test_a_pure_bss_buffer_costs_ram_but_not_flash(self): + """A zero-initialised array is not stored in the image.""" + fp = Footprint(text=100, data=0, bss=8192) + assert fp.flash == 100 + assert fp.ram == 8192 + + +class TestMeasure: + def test_measures_a_real_binary(self, tmp_path): + src = tmp_path / "m.c" + src.write_text("static char buf[4096];\nint main(void){return buf[0];}\n") + exe = tmp_path / "m" + subprocess.run(["gcc", str(src), "-o", str(exe)], check=True) + + fp = measure(exe) + assert fp.text > 0 + # The 4 KB buffer is zero-initialised, so it lands in bss and shows up + # in RAM without inflating flash. + assert fp.bss >= 4096 + assert fp.ram >= 4096 + + def test_a_missing_artifact_raises_rather_than_reporting_zero(self, tmp_path): + """Reporting "Flash: 0 KB" when nothing was measured is worse than + saying nothing.""" + with pytest.raises(FootprintError, match="no artifact"): + measure(tmp_path / "nope") + + def test_a_missing_size_tool_raises(self, tmp_path, monkeypatch): + exe = tmp_path / "x" + exe.write_bytes(b"\x7fELF") + monkeypatch.setattr("ebuild.build.footprint.shutil.which", lambda _n: None) + with pytest.raises(FootprintError, match="no 'size' tool"): + measure(exe) + + def test_a_file_that_is_not_an_object_raises(self, tmp_path): + junk = tmp_path / "notelf.txt" + junk.write_text("this is not an object file") + with pytest.raises(FootprintError): + measure(junk) + + +class TestSizeToolSelection: + def test_host_build_uses_the_host_tool(self): + assert find_size_tool("host") == find_size_tool(None) + + def test_cross_build_wants_the_toolchain_tool(self, monkeypatch): + seen = {} + + def fake_which(name): + seen["name"] = name + return "/opt/arm/bin/arm-none-eabi-size" + + monkeypatch.setattr("ebuild.build.footprint.shutil.which", fake_which) + assert find_size_tool("arm-none-eabi").endswith("arm-none-eabi-size") + assert seen["name"] == "arm-none-eabi-size" + + def test_cross_build_does_not_fall_back_to_the_host_tool(self, monkeypatch): + """Host `size` on an ARM ELF would report numbers for a different + target, and nothing in the output would say so.""" + monkeypatch.setattr( + "ebuild.build.footprint.shutil.which", + lambda name: None if name.startswith("arm-") else "/usr/bin/size", + ) + assert find_size_tool("arm-none-eabi") is None + + +class TestBoardCapacity: + def test_a_known_family_has_a_reference_part(self): + flash, ram = board_capacity("stm32f4") + assert flash == 1024 * 1024 + assert ram == 192 * 1024 + + def test_lookup_is_case_insensitive(self): + assert board_capacity("STM32F4") == board_capacity("stm32f4") + + def test_a_linux_class_board_has_no_fixed_budget(self): + """A made-up ceiling is worse than none: a percentage reads as + authoritative.""" + assert board_capacity("rpi4") == (None, None) + + def test_an_unknown_board_is_unknown(self): + assert board_capacity("some-new-board") == (None, None) + + def test_project_board_yaml_overrides_the_reference_table(self): + """An STM32F401 has 256 KB of flash where the F407 has 1 MB; a project + that says so should be measured against its own part.""" + cfg = {"memory": {"flash_size": "0x40000", "ram_size": 65536}} + assert board_capacity("stm32f4", cfg) == (262144, 65536) + + def test_hex_and_int_sizes_both_parse(self): + assert board_capacity(None, {"memory": {"flash_size": "0x100"}})[0] == 256 + assert board_capacity(None, {"memory": {"flash_size": 256}})[0] == 256 + + def test_a_zero_or_unparseable_size_is_not_a_capacity(self): + """`flash: 0 (boots from SD card)` appears in the shipped board + descriptions; zero is not a ceiling.""" + assert board_capacity(None, {"memory": {"flash_size": 0}}) == (None, None) + assert board_capacity(None, {"memory": {"flash_size": "lots"}}) == (None, None) + + def test_a_board_config_without_memory_falls_back(self): + assert board_capacity("stm32f4", {"board_name": "x"})[0] == 1024 * 1024 + + +class TestReport: + def test_percentages_appear_only_with_a_known_capacity(self): + fp = Footprint(text=1000, data=100, bss=200) + assert "%" in format_report(fp, 4096, 4096) + assert "%" not in format_report(fp) + + def test_report_names_both_regions(self): + body = format_report(Footprint(text=1, data=1, bss=1)) + assert "Flash" in body and "RAM" in body + + def test_a_known_flash_and_unknown_ram_shows_one_percentage(self): + body = format_report(Footprint(text=1000, data=0, bss=99), 4096, None) + flash_line, ram_line = body.splitlines() + assert "%" in flash_line + assert "%" not in ram_line + + +class TestBudget: + def test_within_budget_is_silent(self): + assert over_budget(Footprint(1000, 100, 200), 1 << 20, 1 << 20) is None + + def test_flash_overflow_is_named(self): + msg = over_budget(Footprint(2 << 20, 0, 0), 1 << 20, 1 << 20) + assert msg and "flash" in msg + + def test_ram_overflow_is_named(self): + msg = over_budget(Footprint(0, 0, 2 << 20), 1 << 20, 1 << 20) + assert msg and "RAM" in msg + + def test_no_capacity_means_no_verdict(self): + """Without a real ceiling there is nothing to be over.""" + assert over_budget(Footprint(1 << 30, 0, 1 << 30)) is None + + def test_exactly_full_is_not_over(self): + assert over_budget(Footprint(1024, 0, 0), 1024, 4096) is None + + +class TestFormatSize: + @pytest.mark.parametrize("n,expected", [ + (0, "0 B"), + (512, "512 B"), + (1024, "1.0 KB"), + (1536, "1.5 KB"), + (1024 * 1024, "1.00 MB"), + ]) + def test_units(self, n, expected): + assert format_size(n) == expected