diff --git a/.github/workflows/baremetal.yml b/.github/workflows/baremetal.yml new file mode 100644 index 000000000..8efeb4ce6 --- /dev/null +++ b/.github/workflows/baremetal.yml @@ -0,0 +1,56 @@ +name: "Bare metal (Cortex-M)" + +# `make baremetal-size-gate` (#447): builds libcwist_baremetal.a with +# arm-none-eabi-gcc for Cortex-M7, links the smoke firmware (in-memory +# dispatch with and without an RTC, and the lwIP adapter over lwIP's +# loopback netif), runs each under qemu-arm with semihosting output, then +# holds flash, static RAM and the reported heap/stack high-water marks to +# scripts/ci/baremetal-budget.txt. The runner is pinned to ubuntu-24.04 so +# the toolchain (gcc 13.2, newlib 4.4, qemu 8.2) and therefore the sizes do +# not move under the budget; changing it is a deliberate PR. + +on: + push: + branches: [main, master, dev] + pull_request: + branches: [main, master, dev] + workflow_dispatch: + +permissions: + contents: read + +jobs: + baremetal: + name: Cortex-M7 smoke + size gate + if: github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip ci]') + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + # Top-level only: the build uses headers from boringssl and lsquic + # (Makefile feature probes) and sources from cjson, libttak and lwip; + # nothing vendored is built. + submodules: true + + - name: Install arm-none-eabi toolchain and qemu-user + run: | + sudo apt-get update + sudo apt-get install -y gcc-arm-none-eabi libnewlib-arm-none-eabi qemu-user + arm-none-eabi-gcc --version | head -1 + qemu-arm --version | head -1 + + - name: Smoke firmware under qemu-arm and size gate + run: make -j"$(nproc)" baremetal-size-gate + + - name: Upload firmware maps and logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: baremetal-firmware + path: | + .baremetal-build/*.elf + .baremetal-build/*.map + .baremetal-build/*.log + if-no-files-found: ignore diff --git a/Makefile b/Makefile index 4d566c4c7..ddcc40a76 100644 --- a/Makefile +++ b/Makefile @@ -611,10 +611,23 @@ $(BAREMETAL_BUILD_DIR)/hello-lwip.elf: $(BAREMETAL_ARCHIVE) $(BAREMETAL_LWIP_SRC $(BAREMETAL_LWIP_SRCS) $(BAREMETAL_ARCHIVE) -lm $(BAREMETAL_CROSS)size $@ +# Each run's output is kept next to its ELF (.log): the size gate +# reads the heap/stack high-water marks from it. +BAREMETAL_FIRMWARE = hello hello-rtc hello-lwip baremetal-smoke: $(BAREMETAL_HELLO) $(BAREMETAL_BUILD_DIR)/hello-lwip.elf - $(QEMU_ARM) -cpu cortex-m7 $(BAREMETAL_BUILD_DIR)/hello.elf - $(QEMU_ARM) -cpu cortex-m7 $(BAREMETAL_BUILD_DIR)/hello-rtc.elf - $(QEMU_ARM) -cpu cortex-m7 $(BAREMETAL_BUILD_DIR)/hello-lwip.elf + @set -e; for fw in $(BAREMETAL_FIRMWARE); do \ + echo "$(QEMU_ARM) -cpu cortex-m7 $(BAREMETAL_BUILD_DIR)/$$fw.elf"; \ + $(QEMU_ARM) -cpu cortex-m7 $(BAREMETAL_BUILD_DIR)/$$fw.elf > $(BAREMETAL_BUILD_DIR)/$$fw.log 2>&1 \ + || { cat $(BAREMETAL_BUILD_DIR)/$$fw.log; exit 1; }; \ + cat $(BAREMETAL_BUILD_DIR)/$$fw.log; \ + done + +# Flash, static RAM and heap/stack high-water budgets for the smoke firmware +# (scripts/ci/baremetal-budget.txt). Run after baremetal-smoke. +baremetal-size-gate: baremetal-smoke + $(PYTHON) scripts/ci/baremetal_size_gate.py --size $(BAREMETAL_CROSS)size \ + --budget scripts/ci/baremetal-budget.txt \ + $(foreach fw,$(BAREMETAL_FIRMWARE),$(BAREMETAL_BUILD_DIR)/$(fw).elf) clean-baremetal: rm -rf $(BAREMETAL_BUILD_DIR) $(BAREMETAL_ARCHIVE) @@ -1414,7 +1427,7 @@ TEST_TARGETS = test_worker_affinity \ test_css_composer \ test_multipart -.PHONY: all test tsan-test $(TEST_TARGETS) fuzz_seq install uninstall dist clean rebuild examples clean-examples micro-examples examples-check wasm-abi-layout wasm-sw-example wasm-sw-smoke wasm-sw-browser-test wasm-dist wasip2-smoke clean-wasip2 baremetal baremetal-smoke clean-baremetal wit-check jco-transpile wit-bindings component-guest component-smoke clean-component api-baseline api-check env-check +.PHONY: all test tsan-test $(TEST_TARGETS) fuzz_seq install uninstall dist clean rebuild examples clean-examples micro-examples examples-check wasm-abi-layout wasm-sw-example wasm-sw-smoke wasm-sw-browser-test wasm-dist wasip2-smoke clean-wasip2 baremetal baremetal-smoke baremetal-size-gate clean-baremetal wit-check jco-transpile wit-bindings component-guest component-smoke clean-component api-baseline api-check env-check # Regenerate docs/api/v4.0-api-baseline.txt (the recorded v4.0 public API # symbol surface). See CONTRIBUTING.md "Releases and patch releases". diff --git a/scripts/ci/baremetal-budget.txt b/scripts/ci/baremetal-budget.txt new file mode 100644 index 000000000..608f6d06b --- /dev/null +++ b/scripts/ci/baremetal-budget.txt @@ -0,0 +1,25 @@ +# Size budgets for the bare-metal smoke firmware, checked by +# scripts/ci/baremetal_size_gate.py (`make baremetal-size-gate`). +# +# +# Set at roughly 10% above the measured values, rounded to KiB. Measured on +# Cortex-M7 -Os, 2026-10-27, with arm-none-eabi-gcc 13.2.1 / newlib 4.4 (the +# ubuntu-24.04 CI toolchain) and 14.2.1 / newlib 4.5; the two agree within +# 0.6%. Raise a limit only together with the change that needs it, and say +# why in the PR. +# +# measured (13.2 / 14.2) +hello flash 49152 # 44096 / 44360 +hello static_ram 2048 # 1792 / 1792 +hello heap 24576 # 22296 +hello stack 9216 # 8352 +hello-rtc flash 49152 # 44136 / 44400 +hello-rtc static_ram 2048 # 1804 / 1804 +hello-rtc heap 24576 # 22296 +hello-rtc stack 9728 # 8800 +# hello-lwip includes lwIP and the test client; static_ram is dominated by +# tests/lwip/lwipopts.h (24 KB MEM_SIZE + 16-entry pbuf pool), not CWIST. +hello-lwip flash 83968 # 74964 / 74808 +hello-lwip static_ram 69632 # 62384 / 62384 +hello-lwip heap 61440 # 55364 +hello-lwip stack 9216 # 8320 / 8316 diff --git a/scripts/ci/baremetal_size_gate.py b/scripts/ci/baremetal_size_gate.py new file mode 100644 index 000000000..728e05f4b --- /dev/null +++ b/scripts/ci/baremetal_size_gate.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Size gate for the bare-metal smoke firmware (issue #447 Phase 5). + +For each ELF built by `make baremetal-smoke`, measures + + flash bytes the image occupies in flash: code, read-only data, the + exception tables, init/fini arrays and the .data load image + static_ram .data + .bss (the linker's .heap region is not counted) + heap heap high-water mark the firmware reported (.log) + stack stack high-water mark the firmware reported (.log) + +and fails when any value exceeds its limit in the budget file. The budget +file holds one ` ` line per limit; `#` starts +a comment. A firmware or metric without a limit is reported, not gated. + +Usage: baremetal_size_gate.py [--size arm-none-eabi-size] --budget FILE ELF... +Exit 0 = within budget, 1 = over budget or a measurement is missing. +""" +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +FLASH_SECTIONS = {".isr_vector", ".text", ".rodata", ".ARM.exidx", ".ARM.extab", + ".preinit_array", ".init_array", ".fini_array", ".data"} +RAM_SECTIONS = {".data", ".bss"} +METRICS = ("flash", "static_ram", "heap", "stack") +HIGH_WATER = re.compile(r"heap high water: (\d+) bytes, stack used: (\d+) bytes") + + +def parse_size_a(text: str) -> dict[str, int]: + """Section name -> size from `size -A` output.""" + sections: dict[str, int] = {} + for line in text.splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[0].startswith(".") and parts[1].isdigit(): + sections[parts[0]] = int(parts[1]) + return sections + + +def measure_sections(sections: dict[str, int]) -> dict[str, int]: + return { + "flash": sum(v for k, v in sections.items() if k in FLASH_SECTIONS), + "static_ram": sum(v for k, v in sections.items() if k in RAM_SECTIONS), + } + + +def parse_log(text: str) -> dict[str, int]: + m = HIGH_WATER.search(text) + return {"heap": int(m.group(1)), "stack": int(m.group(2))} if m else {} + + +def parse_budget(text: str) -> dict[tuple[str, str], int]: + budget: dict[tuple[str, str], int] = {} + for n, raw in enumerate(text.splitlines(), 1): + line = raw.split("#", 1)[0].strip() + if not line: + continue + parts = line.split() + if len(parts) != 3 or parts[1] not in METRICS or not parts[2].isdigit(): + raise ValueError(f"budget line {n}: expected ' <{'|'.join(METRICS)}> '") + budget[(parts[0], parts[1])] = int(parts[2]) + return budget + + +def check(measured: dict[str, dict[str, int]], budget: dict[tuple[str, str], int]) -> list[str]: + """Return one failure message per exceeded or unmeasurable budget entry.""" + failures = [] + for (fw, metric), limit in sorted(budget.items()): + if fw not in measured: + continue + value = measured[fw].get(metric) + if value is None: + failures.append(f"{fw} {metric}: not measured (missing high-water line in {fw}.log?)") + elif value > limit: + failures.append(f"{fw} {metric}: {value} bytes > budget {limit} (+{value - limit})") + return failures + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--size", default="arm-none-eabi-size") + ap.add_argument("--budget", required=True, type=Path) + ap.add_argument("elfs", nargs="+", type=Path) + args = ap.parse_args(argv) + + budget = parse_budget(args.budget.read_text()) + measured: dict[str, dict[str, int]] = {} + for elf in args.elfs: + out = subprocess.run([args.size, "-A", str(elf)], check=True, capture_output=True, + text=True).stdout + values = measure_sections(parse_size_a(out)) + log = elf.with_suffix(".log") + if log.exists(): + values.update(parse_log(log.read_text(errors="replace"))) + measured[elf.stem] = values + + print(f"{'firmware':<12}" + "".join(f"{m:>22}" for m in METRICS)) + for fw, values in measured.items(): + cells = [] + for m in METRICS: + v, lim = values.get(m), budget.get((fw, m)) + cell = "-" if v is None else str(v) + if lim is not None: + cell += f" / {lim}" + cells.append(f"{cell:>22}") + print(f"{fw:<12}" + "".join(cells)) + + failures = check(measured, budget) + for f in failures: + print(f"FAIL {f}") + if failures: + print(f"baremetal_size_gate: {len(failures)} budget(s) exceeded. If the growth is intended, " + "raise the limit in the budget file in the same PR and say why.") + return 1 + print("baremetal_size_gate: all within budget") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/ci/test_baremetal_size_gate.py b/scripts/ci/test_baremetal_size_gate.py new file mode 100644 index 000000000..2573c3b2a --- /dev/null +++ b/scripts/ci/test_baremetal_size_gate.py @@ -0,0 +1,55 @@ +"""Regression tests for baremetal_size_gate.py on synthetic size/log output - +no cross toolchain or firmware build needed.""" +import unittest + +import baremetal_size_gate as gate + +SIZE_A = """hello.elf : +section size addr +.isr_vector 64 134217728 +.text 44372 134217792 +.ARM.exidx 8 134262164 +.init_array 16 134262172 +.data 216 603979776 +.bss 1576 603979992 +.heap 514392 603989672 +.comment 31 0 +.ARM.attributes 52 0 +Total 560727 +""" + + +class BaremetalSizeGateTests(unittest.TestCase): + def test_sections_split_into_flash_and_ram(self): + m = gate.measure_sections(gate.parse_size_a(SIZE_A)) + # flash: vectors, code, exidx, init array and the .data load image + self.assertEqual(m["flash"], 64 + 44372 + 8 + 16 + 216) + # static RAM: .data + .bss; the linker's .heap region is not counted + self.assertEqual(m["static_ram"], 216 + 1576) + + def test_high_water_line(self): + log = "PASS x\nheap high water: 22296 bytes, stack used: 8352 bytes\nbaremetal-smoke: PASS\n" + self.assertEqual(gate.parse_log(log), {"heap": 22296, "stack": 8352}) + self.assertEqual(gate.parse_log("no numbers here"), {}) + + def test_budget_parsing(self): + b = gate.parse_budget("# c\nhello flash 100 # note\n\nhello stack 9\n") + self.assertEqual(b, {("hello", "flash"): 100, ("hello", "stack"): 9}) + for bad in ("hello flash", "hello flsh 1", "hello flash 1k"): + with self.assertRaises(ValueError): + gate.parse_budget(bad) + + def test_check(self): + budget = {("hello", "flash"): 100, ("hello", "heap"): 50, ("other", "flash"): 1} + self.assertEqual(gate.check({"hello": {"flash": 100, "heap": 50}}, budget), []) + over = gate.check({"hello": {"flash": 101, "heap": 50}}, budget) + self.assertEqual(len(over), 1) + self.assertIn("hello flash: 101 bytes > budget 100 (+1)", over[0]) + # a budgeted metric the run did not report fails instead of passing silently + missing = gate.check({"hello": {"flash": 1}}, budget) + self.assertEqual(len(missing), 1) + self.assertIn("hello heap: not measured", missing[0]) + + +if __name__ == "__main__": + unittest.main()