size gate: emit flash_bytes from ELF for targets without a packaged .bin (#10940)

* size gate: emit flash_bytes from ELF for targets without a packaged .bin

nRF52 builds package hex/uf2/DFU zip but no raw .bin, so collect_sizes.py
dropped budgeted envs like rak4631 entirely and the size-budget-gate failed
closed. Emit ELF text+data as flash_bytes in the manifest and use it as the
fallback flash measurement.

* Factor shared size-tool invocation into run_size_tool helper
This commit is contained in:
Thomas Göttgens
2026-07-08 07:22:26 -05:00
committed by GitHub
co-authored by GitHub
parent 8d20606203
commit ba473bf529
2 changed files with 66 additions and 22 deletions
+19 -8
View File
@@ -5,10 +5,13 @@
Output schema (consumed by bin/size_report.py):
{"<env>": {"flash_bytes": <int>, "ram_bytes": <int>}}
flash_bytes is the size of the main firmware image (.bin). ram_bytes is the
static RAM footprint (.data + .bss) emitted into the manifest by
bin/platformio-custom.py; it is omitted for manifests that predate it, and
size_report.py renders those as "n/a".
flash_bytes is the size of the main firmware image (.bin); for targets whose
packaged artifacts include no raw .bin (e.g. nRF52) it falls back to the
flash_bytes value (ELF text + data) emitted into the manifest by
bin/platformio-custom.py. ram_bytes is the static RAM footprint (.data + .bss)
emitted into the manifest by bin/platformio-custom.py; either metric is
omitted for manifests that predate it, and size_report.py renders those as
"n/a".
"""
import json
@@ -42,11 +45,19 @@ def collect_sizes(manifest_dir):
):
bin_size = entry["bytes"]
break
# Fallback: flash footprint emitted into the manifest (ELF text + data),
# for targets that package no raw .bin (e.g. nRF52 hex/uf2/DFU zip)
if bin_size is None:
flash_bytes = data.get("flash_bytes")
if isinstance(flash_bytes, int) and not isinstance(flash_bytes, bool):
bin_size = flash_bytes
entry = {}
if bin_size is not None:
entry = {"flash_bytes": bin_size}
ram_bytes = data.get("ram_bytes")
if isinstance(ram_bytes, int) and not isinstance(ram_bytes, bool):
entry["ram_bytes"] = ram_bytes
entry["flash_bytes"] = bin_size
ram_bytes = data.get("ram_bytes")
if isinstance(ram_bytes, int) and not isinstance(ram_bytes, bool):
entry["ram_bytes"] = ram_bytes
if entry:
sizes[board] = entry
return sizes
+47 -14
View File
@@ -45,6 +45,27 @@ def infer_architecture(board_cfg):
return "stm32"
return None
def run_size_tool(env, flag, purpose):
"""Run the toolchain size tool against the built ELF and return its output.
Shared plumbing for compute_ram_bytes / compute_flash_bytes. Returns None
when the ELF is missing or the tool fails; the manifest then simply omits
the metric and downstream size reports show "n/a".
"""
elf = env.File(env.subst("$BUILD_DIR/${PROGNAME}.elf"))
if not elf.exists():
return None
size_tool = env.subst("$SIZETOOL") or "size"
try:
return subprocess.check_output(
[size_tool, flag, elf.get_abspath()],
env=env["ENV"],
universal_newlines=True,
)
except Exception as exc:
print(f"mtjson: skipping {purpose} ({size_tool} failed: {exc})")
return None
def compute_ram_bytes(env):
"""Static RAM usage (.data + .bss) of the ELF, via the toolchain size tool.
@@ -54,18 +75,8 @@ def compute_ram_bytes(env):
Returns None when the value cannot be determined; the manifest then simply
omits ram_bytes and downstream size reports show "n/a".
"""
elf = env.File(env.subst("$BUILD_DIR/${PROGNAME}.elf"))
if not elf.exists():
return None
size_tool = env.subst("$SIZETOOL") or "size"
try:
output = subprocess.check_output(
[size_tool, "-A", elf.get_abspath()],
env=env["ENV"],
universal_newlines=True,
)
except Exception as exc:
print(f"mtjson: skipping ram_bytes ({size_tool} failed: {exc})")
output = run_size_tool(env, "-A", "ram_bytes")
if output is None:
return None
ram = 0
found = False
@@ -88,6 +99,24 @@ def compute_ram_bytes(env):
continue
return ram if found else None
def compute_flash_bytes(env):
"""Flash footprint (text + data) of the ELF via the toolchain size tool.
Approximates the flashed app image for targets whose packaged artifacts do
not include a raw .bin (e.g. nRF52: hex/uf2/DFU zip only); consumed by
bin/collect_sizes.py as a fallback flash measurement for the size report
and the bin/ram_budgets.json budget gate. Returns None when the value
cannot be determined; the manifest then simply omits flash_bytes.
"""
output = run_size_tool(env, "-B", "flash_bytes")
if output is None:
return None
for line in output.splitlines():
parts = line.split()
if len(parts) >= 3 and parts[0].isdigit() and parts[1].isdigit():
return int(parts[0]) + int(parts[1])
return None
def manifest_gather(source, target, env):
global manifest_ran
if manifest_ran:
@@ -141,9 +170,9 @@ def manifest_gather(source, target, env):
d["part_name"] = partition_map[p]
out.append(d)
print(d)
manifest_write(out, env, compute_ram_bytes(env))
manifest_write(out, env, compute_ram_bytes(env), compute_flash_bytes(env))
def manifest_write(files, env, ram_bytes=None):
def manifest_write(files, env, ram_bytes=None, flash_bytes=None):
# Defensive: also skip manifest writing if we cannot determine architecture
def get_project_option(name):
try:
@@ -184,6 +213,10 @@ def manifest_write(files, env, ram_bytes=None):
# the CI size report and the bin/ram_budgets.json budget gate.
if ram_bytes is not None:
manifest["ram_bytes"] = ram_bytes
# Flash footprint (text + data); fallback flash measurement for targets
# whose packaged artifacts include no raw .bin (e.g. nRF52).
if flash_bytes is not None:
manifest["flash_bytes"] = flash_bytes
# Get partition table (generated in esp32_pre.py) if it exists
if env.get("custom_mtjson_part"):
# custom_mtjson_part is a JSON string, convert it back to a dict