Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion .github/workflows/main_matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ jobs:
if: github.event_name == 'pull_request'
id: report
run: |
ARGS="./current-sizes.json"
ARGS="./current-sizes.json --budgets bin/ram_budgets.json"
if [ -f ./develop-sizes.json ]; then
ARGS="$ARGS --baseline develop:./develop-sizes.json"
fi
Expand Down Expand Up @@ -375,6 +375,34 @@ jobs:
./pr-number.txt
retention-days: 5

# RAM/flash guardrails: fails CI when an env listed in bin/ram_budgets.json
# exceeds its static RAM (.data+.bss) or flash budget. Kept separate from
# firmware-size-report, which is informational and continue-on-error.
size-budget-gate:
permissions:
contents: read
actions: read
runs-on: ubuntu-latest
needs: [build]
steps:
- uses: actions/checkout@v7

# No continue-on-error / empty-dir fallback: the gate must fail closed when
# the data it enforces on cannot be fetched (size_report.py additionally
# fails on missing budgeted envs under --enforce-budgets).
- name: Download current manifests
uses: actions/download-artifact@v8
with:
path: ./manifests/
pattern: manifest-*
merge-multiple: true

- name: Collect current firmware sizes
run: python3 bin/collect_sizes.py ./manifests/ ./current-sizes.json

- name: Enforce RAM/flash budgets
run: python3 bin/size_report.py ./current-sizes.json --budgets bin/ram_budgets.json --enforce-budgets
Comment thread
coderabbitai[bot] marked this conversation as resolved.

release-artifacts:
permissions: # Needed for 'gh release upload'.
contents: write
Expand Down
19 changes: 16 additions & 3 deletions bin/collect_sizes.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
#!/usr/bin/env python3

"""Collect firmware binary sizes from manifest (.mt.json) files into a single report."""
"""Collect firmware binary sizes from manifest (.mt.json) files into a single report.

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".
"""

import json
import os
import sys


def collect_sizes(manifest_dir):
"""Scan manifest_dir for .mt.json files and return {board: size_bytes} dict."""
"""Scan manifest_dir for .mt.json files and return {board: sizes_dict}."""
sizes = {}
for fname in sorted(os.listdir(manifest_dir)):
if not fname.endswith(".mt.json"):
Expand All @@ -34,7 +43,11 @@ def collect_sizes(manifest_dir):
bin_size = entry["bytes"]
break
if bin_size is not None:
sizes[board] = bin_size
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
sizes[board] = entry
return sizes


Expand Down
51 changes: 49 additions & 2 deletions bin/platformio-custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,49 @@ def infer_architecture(board_cfg):
return "stm32"
return None

def compute_ram_bytes(env):
"""Static RAM usage (.data + .bss) of the ELF, via the toolchain size tool.

Deliberately excludes heap/stack placeholder sections (e.g. the nRF52 .heap
section): on nRF52840 the heap arena is the linker gap after .bss, so static
RAM growth shrinks the usable heap 1:1 - which is exactly why we track it.
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})")
return None
ram = 0
found = False
for line in output.splitlines():
parts = line.split()
if len(parts) < 2:
continue
name = parts[0]
# Main-SRAM static sections: .data/.bss, platform-prefixed variants (e.g.
# ESP32 .dram0.data/.dram0.bss), and RISC-V small-data .sdata/.sbss.
# ESP-IDF .rtc.* sections live outside the heap-competing SRAM; .heap and
# .tdata never match.
if name.startswith(".rtc"):
continue
if name in (".data", ".bss", ".sdata", ".sbss") or name.endswith(".data") or name.endswith(".bss"):
try:
ram += int(parts[1])
found = True
except ValueError:
continue
return ram if found else None

Comment thread
coderabbitai[bot] marked this conversation as resolved.
def manifest_gather(source, target, env):
global manifest_ran
if manifest_ran:
Expand Down Expand Up @@ -98,9 +141,9 @@ def manifest_gather(source, target, env):
d["part_name"] = partition_map[p]
out.append(d)
print(d)
manifest_write(out, env)
manifest_write(out, env, compute_ram_bytes(env))

def manifest_write(files, env):
def manifest_write(files, env, ram_bytes=None):
# Defensive: also skip manifest writing if we cannot determine architecture
def get_project_option(name):
try:
Expand Down Expand Up @@ -137,6 +180,10 @@ def as_list(val):
"has_mui": False,
"has_inkhud": False,
}
# Static RAM footprint (.data + .bss); consumed by bin/collect_sizes.py for
# the CI size report and the bin/ram_budgets.json budget gate.
if ram_bytes is not None:
manifest["ram_bytes"] = ram_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
Expand Down
24 changes: 24 additions & 0 deletions bin/ram_budgets.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"_comment": [
"Per-environment static-size budgets, enforced by the size-budget-gate CI job",
"via: bin/size_report.py <sizes.json> --budgets bin/ram_budgets.json --enforce-budgets",
"Only environments listed here are gated, and only for the metrics they list.",
"",
"ram_bytes = static RAM (.data + .bss from the ELF, emitted into the .mt.json",
"manifest by bin/platformio-custom.py). On nRF52840 the heap arena is the linker",
"gap after .bss, so every byte of static RAM growth shrinks the usable heap 1:1;",
"that is how the 2.8.0 heap regression shipped without CI noticing.",
"",
"flash_bytes = size of the main firmware image (.bin). The rak4631 app region is",
"0x27000..0xEA000 = 798,720 bytes, and the image must also stay clear of the",
"warm-store record-ring guard (extra_scripts/nrf52_warm_region.py).",
"",
"Budgets are raised DELIBERATELY, never automatically: if your change needs more",
"headroom, bump the limit here in the same PR and justify the increase in the PR",
"description."
],
"rak4631": {
"ram_bytes": 113000,
"flash_bytes": 786000
}
}
Loading
Loading