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
56 changes: 56 additions & 0 deletions .github/workflows/baremetal.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 17 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<name>.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)
Expand Down Expand Up @@ -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".
Expand Down
25 changes: 25 additions & 0 deletions scripts/ci/baremetal-budget.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Size budgets for the bare-metal smoke firmware, checked by
# scripts/ci/baremetal_size_gate.py (`make baremetal-size-gate`).
# <firmware> <flash|static_ram|heap|stack> <max bytes>
#
# 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
124 changes: 124 additions & 0 deletions scripts/ci/baremetal_size_gate.py
Original file line number Diff line number Diff line change
@@ -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 (<name>.log)
stack stack high-water mark the firmware reported (<name>.log)

and fails when any value exceeds its limit in the budget file. The budget
file holds one `<firmware> <metric> <max bytes>` 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 '<firmware> <{'|'.join(METRICS)}> <bytes>'")
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:]))
55 changes: 55 additions & 0 deletions scripts/ci/test_baremetal_size_gate.py
Original file line number Diff line number Diff line change
@@ -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()
Loading