summaryrefslogtreecommitdiff
path: root/build-aux/measurestack
diff options
context:
space:
mode:
Diffstat (limited to 'build-aux/measurestack')
-rw-r--r--build-aux/measurestack/__init__.py38
-rw-r--r--build-aux/measurestack/analyze.py429
-rw-r--r--build-aux/measurestack/app_main.py132
-rw-r--r--build-aux/measurestack/app_output.py139
-rw-r--r--build-aux/measurestack/app_plugins.py1006
-rw-r--r--build-aux/measurestack/test_analyze.py34
-rw-r--r--build-aux/measurestack/test_app_output.py52
-rw-r--r--build-aux/measurestack/util.py125
-rw-r--r--build-aux/measurestack/vcg.py97
9 files changed, 2052 insertions, 0 deletions
diff --git a/build-aux/measurestack/__init__.py b/build-aux/measurestack/__init__.py
new file mode 100644
index 0000000..c1b9d7f
--- /dev/null
+++ b/build-aux/measurestack/__init__.py
@@ -0,0 +1,38 @@
+# build-aux/measurestack/__init__.py - Analyze stack sizes for compiled objects
+#
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+import re
+import sys
+
+from . import app_main
+
+# pylint: disable=unused-variable
+__all__ = [
+ "main",
+]
+
+
+re_c_obj_suffix = re.compile(r"\.c\.(?:o|obj)$")
+
+
+def main() -> None:
+ pico_platform = sys.argv[1]
+ base_dir = sys.argv[2]
+ obj_fnames = set(sys.argv[3:])
+
+ c_fnames: set[str] = set()
+ ci_fnames: set[str] = set()
+ for obj_fname in obj_fnames:
+ if re_c_obj_suffix.search(obj_fname):
+ ci_fnames.add(re_c_obj_suffix.sub(".c.ci", obj_fname))
+ with open(obj_fname + ".d", "r", encoding="utf-8") as fh:
+ c_fnames.update(fh.read().replace("\\\n", " ").split(":")[-1].split())
+
+ app_main.main(
+ arg_pico_platform=pico_platform,
+ arg_base_dir=base_dir,
+ arg_ci_fnames=ci_fnames,
+ arg_c_fnames=c_fnames,
+ )
diff --git a/build-aux/measurestack/analyze.py b/build-aux/measurestack/analyze.py
new file mode 100644
index 0000000..a93874f
--- /dev/null
+++ b/build-aux/measurestack/analyze.py
@@ -0,0 +1,429 @@
+# build-aux/measurestack/analyze.py - Analyze stack sizes for compiled objects
+#
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+import re
+import sys
+import typing
+
+from . import vcg
+
+# pylint: disable=unused-variable
+__all__ = [
+ "BaseName",
+ "QName",
+ "UsageKind",
+ "Node",
+ "AnalyzeResultVal",
+ "AnalyzeResultGroup",
+ "AnalyzeResult",
+ "analyze",
+]
+
+# types ########################################################################
+
+
+class BaseName:
+ # class ##########################################################
+
+ _interned: dict[str, "BaseName"] = {}
+
+ def __new__(cls, content: str) -> "BaseName":
+ if ":" in content:
+ raise ValueError(f"invalid non-qualified name: {content!r}")
+ content = sys.intern(content)
+ if content not in cls._interned:
+ self = super().__new__(cls)
+ self._content = content
+ cls._interned[content] = self
+ return cls._interned[content]
+
+ # instance #######################################################
+
+ _content: str
+
+ def __str__(self) -> str:
+ return self._content
+
+ def __repr__(self) -> str:
+ return f"BaseName({self._content!r})"
+
+ def __format__(self, fmt_spec: str, /) -> str:
+ return repr(self)
+
+ def __eq__(self, other: typing.Any) -> bool:
+ assert isinstance(
+ other, BaseName
+ ), f"comparing BaseName with {other.__class__.__name__}"
+ return self._content == other._content
+
+ def __lt__(self, other: "BaseName") -> bool:
+ return self._content < other._content
+
+ def __hash__(self) -> int:
+ return self._content.__hash__()
+
+ def as_qname(self) -> "QName":
+ return QName(self._content)
+
+
+class QName:
+ # class ##########################################################
+
+ _interned: dict[str, "QName"] = {}
+
+ def __new__(cls, content: str) -> "QName":
+ content = sys.intern(content)
+ if content not in cls._interned:
+ self = super().__new__(cls)
+ self._content = content
+ self._base = None
+ cls._interned[content] = self
+ return cls._interned[content]
+
+ # instance #######################################################
+
+ _content: str
+ _base: BaseName | None
+
+ def __str__(self) -> str:
+ return self._content
+
+ def __repr__(self) -> str:
+ return f"QName({self._content!r})"
+
+ def __format__(self, fmt_spec: str, /) -> str:
+ return repr(self)
+
+ def __eq__(self, other: typing.Any) -> bool:
+ assert isinstance(
+ other, QName
+ ), f"comparing QName with {other.__class__.__name__}"
+ return self._content == other._content
+
+ def __lt__(self, other: "QName") -> bool:
+ return self._content < other._content
+
+ def __hash__(self) -> int:
+ return self._content.__hash__()
+
+ def base(self) -> BaseName:
+ if self._base is None:
+ self._base = BaseName(self._content.rsplit(":", 1)[-1].split(".", 1)[0])
+ return self._base
+
+
+UsageKind: typing.TypeAlias = typing.Literal["static", "dynamic", "dynamic,bounded"]
+
+
+class Node:
+ # from .title (`static` and `__weak` functions are prefixed with
+ # the compilation unit .c file. For static functions that's fine,
+ # but we'll have to handle it specially for __weak.).
+ funcname: QName
+ # .label is "{funcname}\n{location}\n{nstatic} bytes (static}\n{ndynamic} dynamic objects"
+ location: str
+ usage_kind: UsageKind
+ nstatic: int
+ ndynamic: int
+
+ # edges with .sourcename set to this node, val is if it's
+ # OK/expected that the function be missing.
+ calls: dict[QName, bool]
+
+
+class AnalyzeResultVal(typing.NamedTuple):
+ nstatic: int
+ cnt: int
+
+
+class AnalyzeResultGroup(typing.NamedTuple):
+ rows: dict[QName, AnalyzeResultVal]
+
+
+class AnalyzeResult(typing.NamedTuple):
+ groups: dict[str, AnalyzeResultGroup]
+ missing: set[QName]
+ dynamic: set[QName]
+
+ included_funcs: set[QName]
+
+
+class SkipModel(typing.NamedTuple):
+ """Running the skipmodel calls `.fn(chain, ...)` with the chain
+ consisting of the last `.nchain` items (if .nchain is an int), or
+ the chain starting with the *last* occurance of `.nchain` (if
+ .nchain is a collection). If the chain is not that long or does
+ not contain a member of the collection, then .fn is not called and
+ the call is *not* skipped.
+
+ """
+
+ nchain: int | typing.Collection[BaseName]
+ fn: typing.Callable[[typing.Sequence[QName], QName], bool]
+
+ def __call__(self, chain: typing.Sequence[QName], call: QName) -> tuple[bool, int]:
+ if isinstance(self.nchain, int):
+ if len(chain) >= self.nchain:
+ _chain = chain[-self.nchain :]
+ return self.fn(_chain, call), len(_chain)
+ else:
+ for i in reversed(range(len(chain))):
+ if chain[i].base() in self.nchain:
+ _chain = chain[i - 1 :]
+ return self.fn(_chain, call), len(_chain)
+ return False, 0
+
+
+class Application(typing.Protocol):
+ def extra_nodes(self) -> typing.Collection[Node]: ...
+ def indirect_callees(
+ self, elem: vcg.VCGElem
+ ) -> tuple[typing.Collection[QName], bool]: ...
+ def skipmodels(self) -> dict[BaseName, SkipModel]: ...
+
+
+# code #########################################################################
+
+re_node_label = re.compile(
+ r"(?P<funcname>[^\n]+)\n"
+ + r"(?P<location>[^\n]+:[0-9]+:[0-9]+)\n"
+ + r"(?P<nstatic>[0-9]+) bytes \((?P<usage_kind>static|dynamic|dynamic,bounded)\)\n"
+ + r"(?P<ndynamic>[0-9]+) dynamic objects"
+ + r"(?:\n.*)*",
+ flags=re.MULTILINE,
+)
+
+
+class _Graph:
+ graph: dict[QName, Node]
+ qualified: dict[BaseName, QName]
+
+ _resolve_cache: dict[QName, QName | None]
+
+ def __init__(self) -> None:
+ self._resolve_cache = {}
+
+ def _resolve_funcname(self, funcname: QName) -> QName | None:
+ s = str(funcname)
+ is_qualified = ":" in s
+
+ # Handle `ld --wrap` functions
+ if not is_qualified:
+ with_wrap = QName(f"__wrap_{s}")
+ if with_wrap in self.graph:
+ return with_wrap
+ if s.startswith("__real_"):
+ without_real = QName(s[len("__real_") :])
+ if without_real in self.graph:
+ funcname = without_real
+
+ # Usual case
+ if funcname in self.graph:
+ return funcname
+
+ # Handle `__weak`/`[[gnu::weak]]` functions
+ if not is_qualified:
+ return self.qualified.get(BaseName(s))
+
+ return None
+
+ def resolve_funcname(self, funcname: QName) -> QName | None:
+ if funcname not in self._resolve_cache:
+ self._resolve_cache[funcname] = self._resolve_funcname(funcname)
+ return self._resolve_cache[funcname]
+
+
+def _make_graph(
+ ci_fnames: typing.Collection[str],
+ app: Application,
+) -> _Graph:
+ graph: dict[QName, Node] = {}
+ qualified: dict[BaseName, set[QName]] = {}
+
+ def handle_elem(elem: vcg.VCGElem) -> None:
+ match elem.typ:
+ case "node":
+ node = Node()
+ node.calls = {}
+ skip = False
+ for k, v in elem.attrs.items():
+ match k:
+ case "title":
+ node.funcname = QName(v)
+ case "label":
+ if elem.attrs.get("shape", "") != "ellipse":
+ m = re_node_label.fullmatch(v)
+ if not m:
+ raise ValueError(f"unexpected label value {v!r}")
+ node.location = m.group("location")
+ node.usage_kind = typing.cast(
+ UsageKind, m.group("usage_kind")
+ )
+ node.nstatic = int(m.group("nstatic"))
+ node.ndynamic = int(m.group("ndynamic"))
+ case "shape":
+ if v != "ellipse":
+ raise ValueError(f"unexpected shape value {v!r}")
+ skip = True
+ case _:
+ raise ValueError(f"unknown edge key {k!r}")
+ if not skip:
+ if node.funcname in graph:
+ raise ValueError(f"duplicate node {node.funcname}")
+ graph[node.funcname] = node
+ if ":" in str(node.funcname):
+ basename = node.funcname.base()
+ if basename not in qualified:
+ qualified[basename] = set()
+ qualified[basename].add(node.funcname)
+ case "edge":
+ caller: QName | None = None
+ callee: QName | None = None
+ for k, v in elem.attrs.items():
+ match k:
+ case "sourcename":
+ caller = QName(v)
+ case "targetname":
+ callee = QName(v)
+ case "label":
+ pass
+ case _:
+ raise ValueError(f"unknown edge key {k!r}")
+ if caller is None or callee is None:
+ raise ValueError(f"incomplete edge: {elem.attrs!r}")
+ if caller not in graph:
+ raise ValueError(f"unknown caller: {caller}")
+ if callee == QName("__indirect_call"):
+ callees, missing_ok = app.indirect_callees(elem)
+ for callee in callees:
+ if callee not in graph[caller].calls:
+ graph[caller].calls[callee] = missing_ok
+ else:
+ graph[caller].calls[callee] = False
+ case _:
+ raise ValueError(f"unknown elem type {elem.typ!r}")
+
+ for ci_fname in ci_fnames:
+ with open(ci_fname, "r", encoding="utf-8") as fh:
+ for elem in vcg.parse_vcg(fh):
+ handle_elem(elem)
+
+ for node in app.extra_nodes():
+ if node.funcname in graph:
+ raise ValueError(f"duplicate node {node.funcname}")
+ graph[node.funcname] = node
+
+ ret = _Graph()
+ ret.graph = graph
+ ret.qualified = {}
+ for bname, qnames in qualified.items():
+ if len(qnames) == 1:
+ ret.qualified[bname] = next(name for name in qnames)
+ return ret
+
+
+def analyze(
+ *,
+ ci_fnames: typing.Collection[str],
+ app_func_filters: dict[str, typing.Callable[[QName], tuple[int, bool]]],
+ app: Application,
+ cfg_max_call_depth: int,
+) -> AnalyzeResult:
+ graphdata = _make_graph(ci_fnames, app)
+
+ missing: set[QName] = set()
+ dynamic: set[QName] = set()
+ included_funcs: set[QName] = set()
+
+ dbg = False
+
+ track_inclusion: bool = True
+
+ skipmodels = app.skipmodels()
+ for name, model in skipmodels.items():
+ if isinstance(model.nchain, int):
+ assert model.nchain > 1
+ else:
+ assert len(model.nchain) > 0
+
+ _nstatic_cache: dict[QName, int] = {}
+
+ def _nstatic(chain: list[QName], funcname: QName) -> tuple[int, int]:
+ nonlocal dbg
+ nonlocal track_inclusion
+
+ assert funcname in graphdata.graph
+
+ node = graphdata.graph[funcname]
+ if dbg:
+ print(f"//dbg: {'- '*len(chain)}{funcname}\t{node.nstatic}")
+ if node.usage_kind == "dynamic" or node.ndynamic > 0:
+ dynamic.add(funcname)
+ if track_inclusion:
+ included_funcs.add(funcname)
+
+ max_call_nstatic = 0
+ max_call_nchain = 0
+
+ if node.calls:
+ skipmodel = skipmodels.get(funcname.base())
+ chain.append(funcname)
+ if len(chain) == cfg_max_call_depth:
+ raise ValueError(f"max call depth exceeded: {chain}")
+ for call_orig_qname, call_missing_ok in node.calls.items():
+ skip_nchain = 0
+ # 1. Resolve
+ call_qname = graphdata.resolve_funcname(call_orig_qname)
+ if not call_qname:
+ if skipmodel:
+ skip, _ = skipmodel(chain, call_orig_qname)
+ if skip:
+ if dbg:
+ print(
+ f"//dbg: {'- '*len(chain)}{call_orig_qname}\tskip missing"
+ )
+ continue
+ if not call_missing_ok:
+ missing.add(call_orig_qname)
+ if dbg:
+ print(f"//dbg: {'- '*len(chain)}{call_orig_qname}\tmissing")
+ continue
+
+ # 2. Skip
+ if skipmodel:
+ skip, skip_nchain = skipmodel(chain, call_qname)
+ max_call_nchain = max(max_call_nchain, skip_nchain)
+ if skip:
+ if dbg:
+ print(f"//dbg: {'- '*len(chain)}{call_qname}\tskip")
+ continue
+
+ # 3. Call
+ if skip_nchain == 0 and call_qname in _nstatic_cache:
+ max_call_nstatic = max(max_call_nstatic, _nstatic_cache[call_qname])
+ else:
+ call_nstatic, call_nchain = _nstatic(chain, call_qname)
+ max_call_nstatic = max(max_call_nstatic, call_nstatic)
+ max_call_nchain = max(max_call_nchain, call_nchain)
+ if skip_nchain == 0 and call_nchain == 0:
+ _nstatic_cache[call_qname] = call_nstatic
+ chain.pop()
+ return node.nstatic + max_call_nstatic, max(0, max_call_nchain - 1)
+
+ def nstatic(funcname: QName) -> int:
+ return _nstatic([], funcname)[0]
+
+ groups: dict[str, AnalyzeResultGroup] = {}
+ for grp_name, grp_filter in app_func_filters.items():
+ rows: dict[QName, AnalyzeResultVal] = {}
+ for funcname in graphdata.graph:
+ cnt, track_inclusion = grp_filter(funcname)
+ if cnt:
+ rows[funcname] = AnalyzeResultVal(nstatic=nstatic(funcname), cnt=cnt)
+ groups[grp_name] = AnalyzeResultGroup(rows=rows)
+
+ return AnalyzeResult(
+ groups=groups, missing=missing, dynamic=dynamic, included_funcs=included_funcs
+ )
diff --git a/build-aux/measurestack/app_main.py b/build-aux/measurestack/app_main.py
new file mode 100644
index 0000000..7573146
--- /dev/null
+++ b/build-aux/measurestack/app_main.py
@@ -0,0 +1,132 @@
+# build-aux/measurestack/app_main.py - Application-specific wrapper around analyze.py
+#
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+import os.path
+import typing
+
+from . import analyze, app_output, app_plugins, util
+from .analyze import BaseName, QName
+
+# pylint: disable=unused-variable
+__all__ = [
+ "main",
+]
+
+
+def main(
+ *,
+ arg_pico_platform: str,
+ arg_base_dir: str,
+ arg_ci_fnames: typing.Collection[str],
+ arg_c_fnames: typing.Collection[str],
+) -> None:
+
+ plugins: list[util.Plugin] = []
+
+ # sbc-harness ####################################################
+
+ libobj_plugin = app_plugins.LibObjPlugin(arg_c_fnames)
+ lib9p_plugin = app_plugins.Lib9PPlugin(arg_base_dir, arg_c_fnames, libobj_plugin)
+
+ def sbc_is_thread(name: QName) -> int:
+ if str(name).endswith("_cr") and name.base() != BaseName("lib9p_srv_read_cr"):
+ if "9p" in str(name.base()) or "lib9p/tests/test_server/main.c:" in str(
+ name
+ ):
+ return lib9p_plugin.thread_count(name)
+ return 1
+ if name.base() == (
+ BaseName("_entry_point")
+ if arg_pico_platform == "rp2040"
+ else BaseName("main")
+ ):
+ return 1
+ return 0
+
+ plugins += [
+ app_plugins.CmdPlugin(),
+ libobj_plugin,
+ app_plugins.PicoFmtPlugin(arg_pico_platform),
+ app_plugins.LibHWPlugin(arg_pico_platform, libobj_plugin),
+ app_plugins.LibCRPlugin(),
+ app_plugins.LibCRIPCPlugin(),
+ lib9p_plugin,
+ app_plugins.LibMiscPlugin(),
+ ]
+
+ # pico-sdk #######################################################
+
+ if arg_pico_platform == "rp2040":
+
+ def get_init_array() -> typing.Collection[QName]:
+ ret: list[QName] = []
+ for plugin in plugins:
+ ret.extend(plugin.init_array())
+ return ret
+
+ plugins += [
+ app_plugins.PicoSDKPlugin(
+ get_init_array=get_init_array,
+ ),
+ app_plugins.TinyUSBDevicePlugin(arg_c_fnames),
+ app_plugins.NewlibPlugin(),
+ app_plugins.LibGCCPlugin(),
+ ]
+
+ # Tie it all together ############################################
+
+ def thread_filter(name: QName) -> tuple[int, bool]:
+ return sbc_is_thread(name), True
+
+ def intrhandler_filter(name: QName) -> tuple[int, bool]:
+ for plugin in plugins:
+ if plugin.is_intrhandler(name):
+ return 1, True
+ return 0, False
+
+ def misc_filter(name: QName) -> tuple[int, bool]:
+ if name in [
+ QName("__assert_msg_fail"),
+ QName("__lm_printf"),
+ QName("__lm_light_printf"),
+ QName("fmt_vfctprintf"),
+ QName("fmt_vsnprintf"),
+ ]:
+ return 1, False
+ return 0, False
+
+ extra_includes: list[BaseName] = []
+ for plugin in plugins:
+ extra_includes.extend(plugin.extra_includes())
+
+ def extra_filter(name: QName) -> tuple[int, bool]:
+ nonlocal extra_includes
+ if name.base() in extra_includes:
+ return 1, True
+ return 0, False
+
+ def _str_location_xform(loc: str) -> str:
+ if not loc.startswith("/"):
+ return loc
+ parts = loc.split(":", 1)
+ parts[0] = "./" + os.path.relpath(parts[0], arg_base_dir)
+ return ":".join(parts)
+
+ def location_xform(_loc: QName) -> str:
+ return _str_location_xform(str(_loc))
+
+ result = analyze.analyze(
+ ci_fnames=arg_ci_fnames,
+ app_func_filters={
+ "Threads": thread_filter,
+ "Interrupt handlers": intrhandler_filter,
+ "Misc": misc_filter,
+ "Extra": extra_filter,
+ },
+ app=util.PluginApplication(_str_location_xform, plugins),
+ cfg_max_call_depth=100,
+ )
+
+ app_output.print_c(result, location_xform)
diff --git a/build-aux/measurestack/app_output.py b/build-aux/measurestack/app_output.py
new file mode 100644
index 0000000..5336b85
--- /dev/null
+++ b/build-aux/measurestack/app_output.py
@@ -0,0 +1,139 @@
+# build-aux/measurestack/app_output.py - Generate `*_stack.c` files
+#
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+import typing
+
+from . import analyze
+from .analyze import QName
+
+# pylint: disable=unused-variable
+__all__ = [
+ "print_c",
+]
+
+
+def print_group(
+ result: analyze.AnalyzeResult,
+ location_xform: typing.Callable[[QName], str],
+ grp_name: str,
+) -> None:
+ grp = result.groups[grp_name]
+ if not grp.rows:
+ print(f"= {grp_name} (empty) =")
+ return
+
+ nsum = sum(v.nstatic * v.cnt for v in grp.rows.values())
+ nmax = max(v.nstatic for v in grp.rows.values())
+
+ # Figure sizes.
+ namelen = max(
+ [len(location_xform(k)) for k in grp.rows.keys()] + [len(grp_name) + 4]
+ )
+ numlen = len(str(nsum))
+ sep1 = ("=" * namelen) + " " + "=" * numlen
+ sep2 = ("-" * namelen) + " " + "-" * numlen
+
+ # Print.
+ print("= " + grp_name + " " + sep1[len(grp_name) + 3 :])
+ for qname, val in sorted(grp.rows.items()):
+ name = location_xform(qname)
+ if val.nstatic == 0:
+ continue
+ print(
+ f"{name:<{namelen}} {val.nstatic:>{numlen}}"
+ + (f" * {val.cnt}" if val.cnt != 1 else "")
+ )
+ print(sep2)
+ print(f"{'Total':<{namelen}} {nsum:>{numlen}}")
+ print(f"{'Maximum':<{namelen}} {nmax:>{numlen}}")
+ print(sep1)
+
+
+def next_power_of_2(x: int) -> int:
+ return 1 << (x.bit_length())
+
+
+def print_c(
+ result: analyze.AnalyzeResult, location_xform: typing.Callable[[QName], str]
+) -> None:
+ print("#include <stddef.h> /* for size_t */")
+ print()
+ print("/*")
+ print_group(result, location_xform, "Threads")
+ print_group(result, location_xform, "Interrupt handlers")
+ print("*/")
+ intrstack = max(
+ v.nstatic for v in result.groups["Interrupt handlers"].rows.values()
+ )
+ stack_guard_size = 16 * 2
+
+ class CrRow(typing.NamedTuple):
+ name: str
+ cnt: int
+ base: int
+ size: int
+
+ rows: list[CrRow] = []
+ mainrow: CrRow | None = None
+ for funcname, val in result.groups["Threads"].rows.items():
+ name = str(funcname.base())
+ base = val.nstatic
+ size = base + intrstack
+ if name in ["main", "_entry_point"]:
+ mainrow = CrRow(name=name, cnt=1, base=base, size=size)
+ else:
+ size = next_power_of_2(size + stack_guard_size) - stack_guard_size
+ rows.append(CrRow(name=name, cnt=val.cnt, base=base, size=size))
+ namelen = max(len(r.name) for r in rows)
+ baselen = max(len(str(r.base)) for r in rows)
+ sizesum = sum(r.cnt * (r.size + stack_guard_size) for r in rows)
+ sizelen = len(str(max(sizesum, mainrow.size if mainrow else 0)))
+
+ def print_row(comment: bool, name: str, size: int, eqn: str | None = None) -> None:
+ prefix = "const size_t CONFIG_COROUTINE_STACK_SIZE_"
+ if comment:
+ print(f"/* {name}".ljust(len(prefix) + namelen), end="")
+ else:
+ print(f"{prefix}{name:<{namelen}}", end="")
+ print(f" = {size:>{sizelen}};", end="")
+ if comment:
+ print(" */", end="")
+ elif eqn:
+ print(" ", end="")
+ if eqn:
+ print(f" /* {eqn} */", end="")
+ print()
+
+ for row in sorted(rows):
+ print_row(
+ False,
+ row.name,
+ row.size,
+ f"LM_NEXT_POWER_OF_2({row.base:>{baselen}}+{intrstack}+{stack_guard_size})-{stack_guard_size}",
+ )
+ print_row(True, "TOTAL (inc. stack guard)", sizesum)
+ if mainrow:
+ print_row(
+ True,
+ "MAIN/KERNEL",
+ mainrow.size,
+ f" {mainrow.base:>{baselen}}+{intrstack}",
+ )
+ print()
+ print("/*")
+ print_group(result, location_xform, "Misc")
+
+ for funcname in sorted(result.missing):
+ print(f"warning: missing: {location_xform(funcname)}")
+ for funcname in sorted(result.dynamic):
+ print(f"warning: dynamic-stack-usage: {location_xform(funcname)}")
+
+ print("*/")
+ print("")
+ print("/*")
+ print_group(result, location_xform, "Extra")
+ for funcname in sorted(result.included_funcs):
+ print(f"included: {location_xform(funcname)}")
+ print("*/")
diff --git a/build-aux/measurestack/app_plugins.py b/build-aux/measurestack/app_plugins.py
new file mode 100644
index 0000000..36e661b
--- /dev/null
+++ b/build-aux/measurestack/app_plugins.py
@@ -0,0 +1,1006 @@
+# build-aux/measurestack/app_plugins.py - Application-specific plugins for analyze.py
+#
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+import re
+import typing
+
+from . import analyze, util
+from .analyze import BaseName, Node, QName
+from .util import synthetic_node
+
+# pylint: disable=unused-variable
+__all__ = [
+ "CmdPlugin",
+ "LibObjPlugin",
+ "LibHWPlugin",
+ "LibCRPlugin",
+ "LibCRIPCPlugin",
+ "Lib9PPlugin",
+ "LibMiscPlugin",
+ "PicoFmtPlugin",
+ "PicoSDKPlugin",
+ "TinyUSBDevicePlugin",
+ "NewlibPlugin",
+ "LibGCCPlugin",
+]
+
+
+class CmdPlugin:
+ def is_intrhandler(self, name: QName) -> bool:
+ return False
+
+ def init_array(self) -> typing.Collection[QName]:
+ return []
+
+ def extra_includes(self) -> typing.Collection[BaseName]:
+ return []
+
+ def extra_nodes(self) -> typing.Collection[Node]:
+ return []
+
+ def indirect_callees(
+ self, loc: str, line: str
+ ) -> tuple[typing.Collection[QName], bool] | None:
+ if "/3rd-party/" in loc:
+ return None
+ if "srv->auth" in line:
+ return [], False
+ if "srv->rootdir" in line:
+ return [QName("get_root")], False
+ return None
+
+ def skipmodels(self) -> dict[BaseName, analyze.SkipModel]:
+ return {}
+
+
+re_comment = re.compile(r"/\*.*?\*/")
+re_ws = re.compile(r"\s+")
+re_lo_iface = re.compile(r"^\s*#\s*define\s+(?P<name>\S+)_LO_IFACE")
+re_lo_func = re.compile(r"LO_FUNC *\([^,]*, *(?P<name>[^,) ]+) *[,)]")
+re_lo_implementation = re.compile(
+ r"^LO_IMPLEMENTATION_[HC]\s*\(\s*(?P<iface>[^, ]+)\s*,\s*(?P<impl_typ>[^,]+)\s*,\s*(?P<impl_name>[^, ]+)\s*[,)].*"
+)
+re_call_objcall = re.compile(r"LO_CALL\((?P<obj>[^,]+), (?P<meth>[^,)]+)[,)].*")
+
+
+class LibObjPlugin:
+ objcalls: dict[str, set[QName]] # method_name => {method_impls}
+
+ def __init__(self, arg_c_fnames: typing.Collection[str]) -> None:
+ ifaces: dict[str, set[str]] = {} # iface_name => {method_names}
+ for fname in arg_c_fnames:
+ with open(fname, "r", encoding="utf-8") as fh:
+ while line := fh.readline():
+ if m := re_lo_iface.match(line):
+ iface_name = m.group("name")
+ if iface_name not in ifaces:
+ ifaces[iface_name] = set()
+ while line.endswith("\\\n"):
+ line += fh.readline()
+ line = line.replace("\\\n", " ")
+ line = re_comment.sub(" ", line)
+ line = re_ws.sub(" ", line)
+ for m2 in re_lo_func.finditer(line):
+ ifaces[iface_name].add(m2.group("name"))
+
+ implementations: dict[str, set[str]] = {} # iface_name => {impl_names}
+ for iface_name in ifaces:
+ implementations[iface_name] = set()
+ for fname in arg_c_fnames:
+ with open(fname, "r", encoding="utf-8") as fh:
+ for line in fh:
+ line = line.strip()
+ if m := re_lo_implementation.match(line):
+ implementations[m.group("iface")].add(m.group("impl_name"))
+
+ objcalls: dict[str, set[QName]] = {} # method_name => {method_impls}
+ for iface_name, iface in ifaces.items():
+ for method_name in iface:
+ if method_name not in objcalls:
+ objcalls[method_name] = set()
+ for impl_name in implementations[iface_name]:
+ objcalls[method_name].add(QName(impl_name + "_" + method_name))
+ self.objcalls = objcalls
+
+ def is_intrhandler(self, name: QName) -> bool:
+ return False
+
+ def init_array(self) -> typing.Collection[QName]:
+ return []
+
+ def extra_includes(self) -> typing.Collection[BaseName]:
+ return []
+
+ def extra_nodes(self) -> typing.Collection[Node]:
+ return []
+
+ def indirect_callees(
+ self, loc: str, line: str
+ ) -> tuple[typing.Collection[QName], bool] | None:
+
+ if "/3rd-party/" in loc:
+ return None
+ if m := re_call_objcall.fullmatch(line):
+ if m.group("meth") in self.objcalls:
+ return self.objcalls[m.group("meth")], False
+ return [
+ QName(f"__indirect_call:{m.group('obj')}.vtable->{m.group('meth')}")
+ ], False
+ return None
+
+ def skipmodels(self) -> dict[BaseName, analyze.SkipModel]:
+ return {}
+
+
+class LibHWPlugin:
+ pico_platform: str
+ libobj: LibObjPlugin
+
+ def __init__(self, arg_pico_platform: str, libobj: LibObjPlugin) -> None:
+ self.pico_platform = arg_pico_platform
+ self.libobj = libobj
+
+ def is_intrhandler(self, name: QName) -> bool:
+ return name.base() in [
+ BaseName("rp2040_hwtimer_intrhandler"),
+ BaseName("hostclock_handle_sig_alarm"),
+ BaseName("hostnet_handle_sig_io"),
+ BaseName("gpioirq_handler"),
+ BaseName("dmairq_handler"),
+ ]
+
+ def init_array(self) -> typing.Collection[QName]:
+ return []
+
+ def extra_includes(self) -> typing.Collection[BaseName]:
+ return []
+
+ def extra_nodes(self) -> typing.Collection[Node]:
+ return []
+
+ def indirect_callees(
+ self, loc: str, line: str
+ ) -> tuple[typing.Collection[QName], bool] | None:
+ if "/3rd-party/" in loc:
+ return None
+ for fn in [
+ "io_readv",
+ "io_writev",
+ "io_close",
+ "io_close_read",
+ "io_close_write",
+ "io_readwritev",
+ ]:
+ if f"{fn}(" in line:
+ return self.libobj.indirect_callees(loc, f"LO_CALL(x, {fn[3:]})")
+ if "io_read(" in line:
+ return self.libobj.indirect_callees(loc, "LO_CALL(x, readv)")
+ if "io_writev(" in line:
+ return self.libobj.indirect_callees(loc, "LO_CALL(x, writev)")
+ if "trigger->cb(trigger->cb_arg)" in line:
+ ret = [
+ QName("alarmclock_sleep_intrhandler"),
+ ]
+ if self.pico_platform == "rp2040":
+ ret += [
+ QName("w5500_tcp_alarm_handler"),
+ QName("w5500_udp_alarm_handler"),
+ ]
+ return ret, False
+ if "/rp2040_gpioirq.c:" in loc and "handler->fn" in line:
+ return [
+ QName("w5500_intrhandler"),
+ ], False
+ if "/rp2040_dma.c:" in loc and "handler->fn" in line:
+ return [
+ QName("rp2040_hwspi_intrhandler"),
+ ], False
+ return None
+
+ def skipmodels(self) -> dict[BaseName, analyze.SkipModel]:
+ return {}
+
+
+class LibCRPlugin:
+ def is_intrhandler(self, name: QName) -> bool:
+ return name.base() in [
+ BaseName("_cr_gdb_intrhandler"),
+ ]
+
+ def init_array(self) -> typing.Collection[QName]:
+ return []
+
+ def extra_includes(self) -> typing.Collection[BaseName]:
+ return []
+
+ def extra_nodes(self) -> typing.Collection[Node]:
+ return []
+
+ def indirect_callees(
+ self, loc: str, line: str
+ ) -> tuple[typing.Collection[QName], bool] | None:
+ return None
+
+ def skipmodels(self) -> dict[BaseName, analyze.SkipModel]:
+ return {}
+
+
+class LibCRIPCPlugin:
+ def is_intrhandler(self, name: QName) -> bool:
+ return False
+
+ def init_array(self) -> typing.Collection[QName]:
+ return []
+
+ def extra_includes(self) -> typing.Collection[BaseName]:
+ return []
+
+ def extra_nodes(self) -> typing.Collection[Node]:
+ return []
+
+ def indirect_callees(
+ self, loc: str, line: str
+ ) -> tuple[typing.Collection[QName], bool] | None:
+ if "/3rd-party/" in loc:
+ return None
+ if "/chan.c:" in loc and "front->dequeue(" in line:
+ return [
+ QName("_cr_chan_dequeue"),
+ QName("_cr_select_dequeue"),
+ ], False
+ return None
+
+ def skipmodels(self) -> dict[BaseName, analyze.SkipModel]:
+ return {}
+
+
+re_tmessage_handler = re.compile(
+ r"^\s*\[LIB9P_TYP_T[^]]+\]\s*=\s*\(tmessage_handler\)\s*(?P<handler>\S+),\s*$"
+)
+re_lib9p_msg_entry = re.compile(r"^\s*_MSG_(?:[A-Z]+)\((?P<typ>\S+)\),$")
+re_lib9p_caller = re.compile(
+ r"^lib9p_(?P<grp>[TR])msg_(?P<meth>validate|unmarshal|marshal)$"
+)
+re_lib9p_callee = re.compile(
+ r"^(?P<meth>validate|unmarshal|marshal)_(?P<msg>(?P<grp>[TR]).*)$"
+)
+
+
+class Lib9PPlugin:
+ tmessage_handlers: set[QName] | None
+ lib9p_msgs: set[str]
+ _CONFIG_9P_NUM_SOCKS: int | None
+ CONFIG_9P_SRV_MAX_REQS: int | None
+ CONFIG_9P_SRV_MAX_DEPTH: int | None
+ formatters: typing.Collection[BaseName]
+
+ def __init__(
+ self,
+ arg_base_dir: str,
+ arg_c_fnames: typing.Collection[str],
+ libobj_plugin: LibObjPlugin,
+ ) -> None:
+ self.formatters = {
+ x.base()
+ for x in libobj_plugin.objcalls["format"]
+ if str(x.base()).startswith("lib9p_")
+ }
+
+ # Find filenames #######################################################
+
+ def _is_config_h(fname: str) -> bool:
+ if not fname.startswith(arg_base_dir + "/"):
+ return False
+ suffix = fname[len(arg_base_dir) + 1 :]
+ if suffix.startswith("3rd-party/"):
+ return False
+ return suffix.endswith("/config.h")
+
+ config_h_fname = util.get_zero_or_one(_is_config_h, arg_c_fnames)
+
+ lib9p_srv_c_fname = util.get_zero_or_one(
+ lambda fname: fname.endswith("lib9p/srv.c"), arg_c_fnames
+ )
+
+ lib9p_generated_c_fname = util.get_zero_or_one(
+ lambda fname: fname.endswith("lib9p/9p.generated.c"), arg_c_fnames
+ )
+
+ # Read config ##########################################################
+
+ def config_h_get(varname: str) -> int | None:
+ if config_h_fname:
+ with open(config_h_fname, "r", encoding="utf-8") as fh:
+ for line in fh:
+ line = line.rstrip()
+ if line.startswith("#define"):
+ parts = line.split()
+ if parts[1] == varname:
+ return int(parts[2])
+ return None
+
+ self._CONFIG_9P_NUM_SOCKS = config_h_get("_CONFIG_9P_NUM_SOCKS")
+ self.CONFIG_9P_SRV_MAX_REQS = config_h_get("CONFIG_9P_SRV_MAX_REQS")
+ self.CONFIG_9P_SRV_MAX_DEPTH = config_h_get("CONFIG_9P_SRV_MAX_DEPTH")
+
+ # Read sources #########################################################
+
+ tmessage_handlers: set[QName] | None = None
+ if lib9p_srv_c_fname:
+ tmessage_handlers = set()
+ with open(lib9p_srv_c_fname, "r", encoding="utf-8") as fh:
+ for line in fh:
+ line = line.rstrip()
+ if m := re_tmessage_handler.fullmatch(line):
+ tmessage_handlers.add(QName(m.group("handler")))
+ self.tmessage_handlers = tmessage_handlers
+
+ lib9p_msgs: set[str] = set()
+ if lib9p_generated_c_fname:
+ with open(lib9p_generated_c_fname, "r", encoding="utf-8") as fh:
+ for line in fh:
+ line = line.rstrip()
+ if m := re_lib9p_msg_entry.fullmatch(line):
+ typ = m.group("typ")
+ lib9p_msgs.add(typ)
+ self.lib9p_msgs = lib9p_msgs
+
+ def thread_count(self, name: QName) -> int:
+ assert self._CONFIG_9P_NUM_SOCKS
+ assert self.CONFIG_9P_SRV_MAX_REQS
+ if "read" in str(name.base()):
+ return self._CONFIG_9P_NUM_SOCKS
+ if "write" in str(name.base()):
+ return self._CONFIG_9P_NUM_SOCKS * self.CONFIG_9P_SRV_MAX_REQS
+ return 1
+
+ def is_intrhandler(self, name: QName) -> bool:
+ return False
+
+ def init_array(self) -> typing.Collection[QName]:
+ return []
+
+ def extra_includes(self) -> typing.Collection[BaseName]:
+ return []
+
+ def extra_nodes(self) -> typing.Collection[Node]:
+ return []
+
+ def indirect_callees(
+ self, loc: str, line: str
+ ) -> tuple[typing.Collection[QName], bool] | None:
+ if "/3rd-party/" in loc:
+ return None
+ if (
+ self.tmessage_handlers
+ and "/srv.c:" in loc
+ and "tmessage_handlers[typ](" in line
+ ):
+ # Functions for disabled protocol extensions will be missing.
+ return self.tmessage_handlers, True
+ if self.lib9p_msgs and "/9p.c:" in loc:
+ for meth in ["validate", "unmarshal", "marshal"]:
+ if line.startswith(f"tentry.{meth}("):
+ # Functions for disabled protocol extensions will be missing.
+ return [QName(f"{meth}_{msg}") for msg in self.lib9p_msgs], True
+ return None
+
+ def skipmodels(self) -> dict[BaseName, analyze.SkipModel]:
+ ret: dict[BaseName, analyze.SkipModel] = {
+ BaseName("_lib9p_validate"): analyze.SkipModel(
+ 2,
+ self._skipmodel__lib9p_validate_unmarshal_marshal,
+ ),
+ BaseName("_lib9p_unmarshal"): analyze.SkipModel(
+ 2,
+ self._skipmodel__lib9p_validate_unmarshal_marshal,
+ ),
+ BaseName("_lib9p_marshal"): analyze.SkipModel(
+ 2,
+ self._skipmodel__lib9p_validate_unmarshal_marshal,
+ ),
+ BaseName("_vfctprintf"): analyze.SkipModel(
+ self.formatters, self._skipmodel__vfctprintf
+ ),
+ }
+ if isinstance(self.CONFIG_9P_SRV_MAX_DEPTH, int):
+ ret[BaseName("srv_util_pathfree")] = analyze.SkipModel(
+ self.CONFIG_9P_SRV_MAX_DEPTH,
+ self._skipmodel_srv_util_pathfree,
+ )
+ return ret
+
+ def _skipmodel__lib9p_validate_unmarshal_marshal(
+ self, chain: typing.Sequence[QName], call: QName
+ ) -> bool:
+ m_caller = re_lib9p_caller.fullmatch(str(chain[-2].base()))
+ assert m_caller
+
+ m_callee = re_lib9p_callee.fullmatch(str(call.base()))
+ if not m_callee:
+ return False
+ return m_caller.group("grp") != m_callee.group("grp")
+
+ def _skipmodel_srv_util_pathfree(
+ self, chain: typing.Sequence[QName], call: QName
+ ) -> bool:
+ assert isinstance(self.CONFIG_9P_SRV_MAX_DEPTH, int)
+ if call.base() == BaseName("srv_util_pathfree"):
+ return len(chain) >= self.CONFIG_9P_SRV_MAX_DEPTH and all(
+ c.base() == BaseName("srv_util_pathfree")
+ for c in chain[-self.CONFIG_9P_SRV_MAX_DEPTH :]
+ )
+ return False
+
+ def _skipmodel__vfctprintf(
+ self, chain: typing.Sequence[QName], call: QName
+ ) -> bool:
+ if call.base() == BaseName("libfmt_conv_formatter"):
+ return any(c.base() in self.formatters for c in chain)
+ return False
+
+
+class LibMiscPlugin:
+ def is_intrhandler(self, name: QName) -> bool:
+ return False
+
+ def init_array(self) -> typing.Collection[QName]:
+ return []
+
+ def extra_includes(self) -> typing.Collection[BaseName]:
+ return []
+
+ def extra_nodes(self) -> typing.Collection[Node]:
+ return []
+
+ def indirect_callees(
+ self, loc: str, line: str
+ ) -> tuple[typing.Collection[QName], bool] | None:
+ return None
+
+ def skipmodels(self) -> dict[BaseName, analyze.SkipModel]:
+ return {
+ BaseName("__assert_msg_fail"): analyze.SkipModel(
+ {BaseName("__assert_msg_fail")}, self._skipmodel___assert_msg_fail
+ ),
+ }
+
+ def _skipmodel___assert_msg_fail(
+ self, chain: typing.Sequence[QName], call: QName
+ ) -> bool:
+ if call.base() in [BaseName("__lm_printf"), BaseName("__lm_light_printf")]:
+ return any(
+ c.base() == BaseName("__assert_msg_fail") for c in reversed(chain[:-1])
+ )
+ return False
+
+
+class PicoFmtPlugin:
+ known_fct: dict[BaseName, BaseName]
+
+ def __init__(self, arg_pico_platform: str) -> None:
+ self.known_fct = {
+ # pico_fmt
+ BaseName("fmt_vsnprintf"): BaseName("_out_buffer"),
+ }
+ match arg_pico_platform:
+ case "rp2040":
+ self.known_fct.update(
+ {
+ # pico_stdio
+ BaseName("__wrap_vprintf"): BaseName("stdio_buffered_printer"),
+ BaseName("stdio_vprintf"): BaseName("stdio_buffered_printer"),
+ # libfmt
+ BaseName("__lm_light_printf"): BaseName("libfmt_light_fct"),
+ }
+ )
+ case "host":
+ self.known_fct.update(
+ {
+ # libfmt
+ BaseName("__lm_printf"): BaseName("libfmt_libc_fct"),
+ BaseName("__lm_light_printf"): BaseName("libfmt_libc_fct"),
+ }
+ )
+
+ def is_intrhandler(self, name: QName) -> bool:
+ return False
+
+ def init_array(self) -> typing.Collection[QName]:
+ return []
+
+ def extra_includes(self) -> typing.Collection[BaseName]:
+ return []
+
+ def extra_nodes(self) -> typing.Collection[Node]:
+ return []
+
+ def indirect_callees(
+ self, loc: str, line: str
+ ) -> tuple[typing.Collection[QName], bool] | None:
+ if "/3rd-party/pico-fmt/" not in loc:
+ return None
+ if "/printf.c:" in loc:
+ m = util.re_call_other.fullmatch(line)
+ call: str | None = m.group("func") if m else None
+ if "->fct" in line:
+ return [x.as_qname() for x in self.known_fct.values()], False
+ if "specifier_table" in line:
+ return [
+ # pico-fmt
+ QName("conv_sint"),
+ QName("conv_uint"),
+ # QName("conv_double"),
+ QName("conv_char"),
+ QName("conv_str"),
+ QName("conv_ptr"),
+ QName("conv_pct"),
+ # libfmt
+ QName("libfmt_conv_formatter"),
+ QName("libfmt_conv_quote"),
+ ], False
+ return None
+
+ def skipmodels(self) -> dict[BaseName, analyze.SkipModel]:
+ ret: dict[BaseName, analyze.SkipModel] = {
+ BaseName("fmt_state_putchar"): analyze.SkipModel(
+ self.known_fct.keys(), self._skipmodel_fmt_state_putchar
+ ),
+ }
+ return ret
+
+ def _skipmodel_fmt_state_putchar(
+ self, chain: typing.Sequence[QName], call: QName
+ ) -> bool:
+ if call.base() in self.known_fct.values():
+ fct: BaseName | None = None
+ for pcall in reversed(chain):
+ if pcall.base() in self.known_fct:
+ fct = self.known_fct[pcall.base()]
+ return call.base() != fct
+ return True
+ return False
+
+
+class PicoSDKPlugin:
+ get_init_array: typing.Callable[[], typing.Collection[QName]]
+ app_init_array: typing.Collection[QName] | None
+ app_preinit_array: typing.Collection[QName]
+
+ def __init__(
+ self,
+ *,
+ get_init_array: typing.Callable[[], typing.Collection[QName]],
+ ) -> None:
+ # grep for '__attribute__((constructor))' / '[[gnu::constructor]]'.
+ self.get_init_array = get_init_array
+ self.app_init_array = None
+
+ # git grep '^PICO_RUNTIME_INIT_FUNC\S*('
+ self.app_preinit_array = [
+ # QName("runtime_init_mutex"), # pico_mutex
+ # QName("runtime_init_default_alarm_pool"), # pico_time
+ # QName("runtime_init_boot_locks_reset"), # hardware_boot_lock
+ QName("runtime_init_per_core_irq_priorities"), # hardware_irq
+ # QName("spinlock_set_extexclall"), # hardware_sync_spin_lock
+ QName("__aeabi_bits_init"), # pico_bit_ops
+ # QName("runtime_init_bootrom_locking_enable"), # pico_bootrom, rp2350-only
+ # QName("runtime_init_pre_core_tls_setup"), # pico_clib_interface, picolibc-only
+ # QName("__aeabi_double_init"), # pico_double
+ # QName("__aeabi_float_init"), # pico_float
+ QName("__aeabi_mem_init"), # pico_mem_ops
+ QName("first_per_core_initializer"), # pico_runtime
+ # pico_runtime_init
+ # QName("runtime_init_bootrom_reset"), # rp2350-only
+ # QName("runtime_init_per_core_bootrom_reset"), # rp2350-only
+ # QName("runtime_init_per_core_h3_irq_registers"), # rp2350-only
+ QName("runtime_init_early_resets"),
+ QName("runtime_init_usb_power_down"),
+ # QName("runtime_init_per_core_enable_coprocessors"), # PICO_RUNTIME_SKIP_INIT_PER_CORE_ENABLE_COPROCESSORS
+ QName("runtime_init_clocks"),
+ QName("runtime_init_post_clock_resets"),
+ QName("runtime_init_rp2040_gpio_ie_disable"),
+ QName("runtime_init_spin_locks_reset"),
+ QName("runtime_init_install_ram_vector_table"),
+ ]
+
+ def is_intrhandler(self, name: QName) -> bool:
+ return name.base() in [
+ BaseName("isr_invalid"),
+ BaseName("isr_nmi"),
+ BaseName("isr_hardfault"),
+ BaseName("isr_svcall"),
+ BaseName("isr_pendsv"),
+ BaseName("isr_systick"),
+ *[BaseName(f"isr_irq{n}") for n in range(32)],
+ ]
+
+ def init_array(self) -> typing.Collection[QName]:
+ return []
+
+ def extra_includes(self) -> typing.Collection[BaseName]:
+ return []
+
+ def indirect_callees(
+ self, loc: str, line: str
+ ) -> tuple[typing.Collection[QName], bool] | None:
+ if "/3rd-party/pico-sdk/" not in loc or "/3rd-party/pico-sdk/lib/" in loc:
+ return None
+ m = util.re_call_other.fullmatch(line)
+ call: str | None = m.group("func") if m else None
+
+ match call:
+ case "connect_internal_flash_func":
+ return [
+ QName("rom_func_lookup(ROM_FUNC_CONNECT_INTERNAL_FLASH)")
+ ], False
+ case "flash_exit_xip_func":
+ return [QName("rom_func_lookup(ROM_FUNC_FLASH_EXIT_XIP)")], False
+ case "flash_range_erase_func":
+ return [QName("rom_func_lookup(ROM_FUNC_FLASH_RANGE_ERASE)")], False
+ case "flash_flush_cache_func":
+ return [QName("rom_func_lookup(ROM_FUNC_FLASH_FLUSH_CACHE)")], False
+ case "rom_table_lookup":
+ return [QName("rom_hword_as_ptr(BOOTROM_TABLE_LOOKUP_OFFSET)")], False
+ if "/flash.c:" in loc and "boot2_copyout" in line:
+ return [QName("_stage2_boot")], False
+ if "/stdio.c:" in loc:
+ if call == "out_func":
+ return [
+ QName("stdio_out_chars_crlf"),
+ QName("stdio_out_chars_no_crlf"),
+ ], False
+ if call and (call.startswith("d->") or call.startswith("driver->")):
+ _, meth = call.split("->", 1)
+ match meth:
+ case "out_chars":
+ return [QName("stdio_uart_out_chars")], False
+ case "out_flush":
+ return [QName("stdio_uart_out_flush")], False
+ case "in_chars":
+ return [QName("stdio_uart_in_chars")], False
+ if "/newlib_interface.c:" in loc:
+ if line == "*p)();":
+ if self.app_init_array is None:
+ self.app_init_array = self.get_init_array()
+ return self.app_init_array, False
+ if "/pico_runtime/runtime.c:" in loc:
+ return self.app_preinit_array, False
+ return None
+
+ def skipmodels(self) -> dict[BaseName, analyze.SkipModel]:
+ return {}
+
+ def extra_nodes(self) -> typing.Collection[Node]:
+ ret = []
+
+ # src/rp2_common/hardware_divider/include/hardware/divider_helper.S
+ save_div_state_and_lr = 5 * 4
+ # src/rp2_common/pico_divider/divider_hardware.S
+ save_div_state_and_lr_64 = 5 * 4
+
+ # src/src/rp2_common/pico_crt0/crt0.S
+ for n in range(32):
+ ret += [synthetic_node(f"isr_irq{n}", 0, {"__unhandled_user_irq"})]
+ ret += [
+ synthetic_node("isr_invalid", 0, {"__unhandled_user_irq"}),
+ synthetic_node("isr_nmi", 0, {"__unhandled_user_irq"}),
+ synthetic_node("isr_hardfault", 0, {"__unhandled_user_irq"}),
+ synthetic_node("isr_svcall", 0, {"__unhandled_user_irq"}),
+ synthetic_node("isr_pendsv", 0, {"__unhandled_user_irq"}),
+ synthetic_node("isr_systick", 0, {"__unhandled_user_irq"}),
+ synthetic_node("__unhandled_user_irq", 0),
+ synthetic_node("_entry_point", 0, {"_reset_handler"}),
+ synthetic_node("_reset_handler", 0, {"runtime_init", "main", "exit"}),
+ ]
+
+ ret += [
+ # src/rp2_common/pico_int64_ops/pico_int64_ops_aeabi.S
+ synthetic_node("__wrap___aeabi_lmul", 4),
+ # src/rp2_common/pico_divider/divider_hardware.S
+ # s32 aliases
+ synthetic_node("div_s32s32", 0, {"divmod_s32s32"}),
+ synthetic_node("__wrap___aeabi_idiv", 0, {"divmod_s32s32"}),
+ synthetic_node("__wrap___aeabi_idivmod", 0, {"divmod_s32s32"}),
+ # s32 impl
+ synthetic_node("divmod_s32s32", 0, {"divmod_s32s32_savestate"}),
+ synthetic_node(
+ "divmod_s32s32_savestate",
+ save_div_state_and_lr,
+ {"divmod_s32s32_unsafe"},
+ ),
+ synthetic_node("divmod_s32s32_unsafe", 2 * 4, {"__aeabi_idiv0"}),
+ # u32 aliases
+ synthetic_node("div_u32u32", 0, {"divmod_u32u32"}),
+ synthetic_node("__wrap___aeabi_uidiv", 0, {"divmod_u32u32"}),
+ synthetic_node("__wrap___aeabi_uidivmod", 0, {"divmod_u32u32"}),
+ # u32 impl
+ synthetic_node("divmod_u32u32", 0, {"divmod_u32u32_savestate"}),
+ synthetic_node(
+ "divmod_u32u32_savestate",
+ save_div_state_and_lr,
+ {"divmod_u32u32_unsafe"},
+ ),
+ synthetic_node("divmod_u32u32_unsafe", 2 * 4, {"__aeabi_idiv0"}),
+ # s64 aliases
+ synthetic_node("div_s64s64", 0, {"divmod_s64s64"}),
+ synthetic_node("__wrap___aeabi_ldiv", 0, {"divmod_s64s64"}),
+ synthetic_node("__wrap___aeabi_ldivmod", 0, {"divmod_s64s64"}),
+ # s64 impl
+ synthetic_node("divmod_s64s64", 0, {"divmod_s64s64_savestate"}),
+ synthetic_node(
+ "divmod_s64s64_savestate",
+ save_div_state_and_lr_64 + (2 * 4),
+ {"divmod_s64s64_unsafe"},
+ ),
+ synthetic_node(
+ "divmod_s64s64_unsafe", 4, {"divmod_u64u64_unsafe", "__aeabi_ldiv0"}
+ ),
+ # u64 aliases
+ synthetic_node("div_u64u64", 0, {"divmod_u64u64"}),
+ synthetic_node("__wrap___aeabi_uldivmod", 0, {"divmod_u64u64"}),
+ # u64 impl
+ synthetic_node("divmod_u64u64", 0, {"divmod_u64u64_savestate"}),
+ synthetic_node(
+ "divmod_u64u64_savestate",
+ save_div_state_and_lr_64 + (2 * 4),
+ {"divmod_u64u64_unsafe"},
+ ),
+ synthetic_node(
+ "divmod_u64u64_unsafe", (1 + 1 + 2 + 5 + 5 + 2) * 4, {"__aeabi_ldiv0"}
+ ),
+ # *_rem
+ synthetic_node("divod_s64s64_rem", 2 * 4, {"divmod_s64s64"}),
+ synthetic_node("divod_u64u64_rem", 2 * 4, {"divmod_u64u64"}),
+ # src/rp2_common/pico_mem_ops/mem_ops_aeabi.S
+ synthetic_node("__aeabi_mem_init", 0, {"rom_funcs_lookup"}),
+ synthetic_node(
+ "__wrap___aeabi_memset", 0, {"rom_func_lookup(ROM_FUNC_MEMSET)"}
+ ),
+ synthetic_node("__wrap___aeabi_memset4", 0, {"__wrap___aeabi_memset8"}),
+ synthetic_node(
+ "__wrap___aeabi_memset8", 0, {"rom_func_lookup(ROM_FUNC_MEMSET4)"}
+ ),
+ synthetic_node("__wrap___aeabi_memcpy4", 0, {"__wrap___aeabi_memcpy8"}),
+ synthetic_node(
+ "__wrap___aeabi_memcpy7", 0, {"rom_func_lookup(ROM_FUNC_MEMCPY4)"}
+ ),
+ synthetic_node("__wrap_memset", 0, {"rom_func_lookup(ROM_FUNC_MEMSET)"}),
+ synthetic_node("__wrap___aeabi_memcpy", 0, {"__wrap_memcpy"}),
+ synthetic_node("__wrap_memcpy", 0, {"rom_func_lookup(ROM_FUNC_MEMCPY)"}),
+ # src/rp2_common/pico_bit_ops/bit_ops_aeabi.S
+ synthetic_node("__aeabi_bits_init", 0, {"rom_funcs_lookup"}),
+ synthetic_node("__wrap___clz", 0, {"__wrap___clzsi2"}),
+ synthetic_node("__wrap___clzl", 0, {"__wrap___clzsi2"}),
+ synthetic_node("__wrap___clzsi2", 0, {"rom_func_lookup(ROM_FUNC_CLZ32)"}),
+ synthetic_node("__wrap___ctzsi2", 0, {"rom_func_lookup(ROM_FUNC_CTZ32)"}),
+ synthetic_node(
+ "__wrap___popcountsi2", 0, {"rom_func_lookup(ROM_FUNC_POPCOUNT32)"}
+ ),
+ synthetic_node("__wrap___clzll", 0, {"__wrap___clzdi2"}),
+ synthetic_node("__wrap___clzdi2", 4, {"rom_func_lookup(ROM_FUNC_CLZ32)"}),
+ synthetic_node("__wrap___ctzdi2", 4, {"rom_func_lookup(ROM_FUNC_CTZ32)"}),
+ synthetic_node(
+ "__wrap___popcountdi2", 3 * 4, {"rom_func_lookup(ROM_FUNC_POPCOUNT32)"}
+ ),
+ synthetic_node("__rev", 0, {"reverse32"}),
+ synthetic_node("__revl", 0, {"reverse32"}),
+ synthetic_node("reverse32", 0, {"rom_func_lookup(ROM_FUNC_REVERSE32)"}),
+ synthetic_node("__revll", 0, {"reverse64"}),
+ synthetic_node("reverse64", 3 * 4, {"rom_func_lookup(ROM_FUNC_REVERSE32)"}),
+ # src/rp2040/boot_stage2/boot2_${name,,}.S for name=W25Q080,
+ # controlled by `#define PICO_BOOT_STAGE2_{name} 1` in
+ # src/boards/include/boards/pico.h
+ # synthetic_node("_stage2_boot", 0), # TODO
+ # https://github.com/raspberrypi/pico-bootrom-rp2040
+ # synthetic_node("rom_func_lookup(ROM_FUNC_CONNECT_INTERNAL_FLASH)", 0), # TODO
+ # synthetic_node("rom_func_lookup(ROM_FUNC_FLASH_EXIT_XIP)", 0), # TODO
+ # synthetic_node("rom_func_lookup(ROM_FUNC_FLASH_FLUSH_CACHE)", 0), # TODO
+ # synthetic_node("rom_hword_as_ptr(BOOTROM_TABLE_LOOKUP_OFFSET)", 0), # TODO
+ ]
+ return ret
+
+
+re_tud_class = re.compile(
+ r"^\s*#\s*define\s+(?P<k>CFG_TUD_(?:\S{3}|AUDIO|VIDEO|MIDI|VENDOR|USBTMC|DFU_RUNTIME|ECM_RNDIS))\s+(?P<v>\S+).*"
+)
+re_tud_entry = re.compile(r"^\s+\.(?P<meth>\S+)\s*=\s*(?P<impl>[a-zA-Z0-9_]+)(?:,.*)?")
+re_tud_if1 = re.compile(r"^\s*#\s*if (\S+)\s*")
+re_tud_if2 = re.compile(r"^\s*#\s*if (\S+)\s*\|\|\s*(\S+)\s*")
+re_tud_endif = re.compile(r"^\s*#\s*endif\s*")
+
+
+class TinyUSBDevicePlugin:
+ tud_drivers: dict[str, set[QName]] # method_name => {method_impls}
+
+ def __init__(self, arg_c_fnames: typing.Collection[str]) -> None:
+ usbd_c_fname = util.get_zero_or_one(
+ lambda fname: fname.endswith("/tinyusb/src/device/usbd.c"), arg_c_fnames
+ )
+
+ tusb_config_h_fname = util.get_zero_or_one(
+ lambda fname: fname.endswith("/tusb_config.h"), arg_c_fnames
+ )
+
+ if not usbd_c_fname:
+ self.tud_drivers = {}
+ return
+
+ assert tusb_config_h_fname
+ tusb_config: dict[str, bool] = {}
+ with open(tusb_config_h_fname, "r", encoding="utf-8") as fh:
+ in_table = False
+ for line in fh:
+ line = line.rstrip()
+ if m := re_tud_class.fullmatch(line):
+ k = m.group("k")
+ v = m.group("v")
+ tusb_config[k] = bool(int(v))
+
+ tud_drivers: dict[str, set[QName]] = {}
+ with open(usbd_c_fname, "r", encoding="utf-8") as fh:
+ in_table = False
+ enabled = True
+ for line in fh:
+ line = line.rstrip()
+ if in_table:
+ if m := re_tud_if1.fullmatch(line):
+ enabled = tusb_config[m.group(1)]
+ elif m := re_tud_if2.fullmatch(line):
+ enabled = tusb_config[m.group(1)] or tusb_config[m.group(2)]
+ elif re_tud_endif.fullmatch(line):
+ enabled = True
+ if m := re_tud_entry.fullmatch(line):
+ meth = m.group("meth")
+ impl = m.group("impl")
+ if meth == "name" or not enabled:
+ continue
+ if meth not in tud_drivers:
+ tud_drivers[meth] = set()
+ if impl != "NULL":
+ tud_drivers[meth].add(QName(impl))
+ if line.startswith("}"):
+ in_table = False
+ elif " _usbd_driver[] = {" in line:
+ in_table = True
+ self.tud_drivers = tud_drivers
+
+ def is_intrhandler(self, name: QName) -> bool:
+ return False
+
+ def init_array(self) -> typing.Collection[QName]:
+ return []
+
+ def extra_includes(self) -> typing.Collection[BaseName]:
+ return []
+
+ def extra_nodes(self) -> typing.Collection[Node]:
+ return []
+
+ def indirect_callees(
+ self, loc: str, line: str
+ ) -> tuple[typing.Collection[QName], bool] | None:
+ if "/tinyusb/" not in loc or "/tinyusb/src/host/" in loc or "_host.c:" in loc:
+ return None
+ m = util.re_call_other.fullmatch(line)
+ assert m
+ call = m.group("func")
+ if call == "_ctrl_xfer.complete_cb":
+ ret = {
+ # QName("process_test_mode_cb"),
+ QName("tud_vendor_control_xfer_cb"),
+ }
+ ret.update(self.tud_drivers["control_xfer_cb"])
+ return ret, False
+ if call.startswith("driver->"):
+ return self.tud_drivers[call[len("driver->") :]], False
+ if call == "event.func_call.func":
+ # callback from usb_defer_func()
+ return [], False
+
+ return None
+
+ def skipmodels(self) -> dict[BaseName, analyze.SkipModel]:
+ return {}
+
+
+class NewlibPlugin:
+ def is_intrhandler(self, name: QName) -> bool:
+ return False
+
+ def init_array(self) -> typing.Collection[QName]:
+ return [QName("register_fini")]
+
+ def extra_includes(self) -> typing.Collection[BaseName]:
+ return [
+ # register_fini() calls atexit(__libc_fini_array)
+ BaseName("__libc_fini_array"),
+ ]
+
+ def extra_nodes(self) -> typing.Collection[Node]:
+ # This is accurate to
+ # /usr/arm-none-eabi/lib/thumb/v6-m/nofp/libg.a as of
+ # Parabola's arm-none-eabi-newlib 4.5.0.20241231-1.
+ return [
+ # malloc
+ synthetic_node("free", 8, {"_free_r"}),
+ synthetic_node("malloc", 8, {"_malloc_r"}),
+ synthetic_node("realloc", 8, {"_realloc_r"}),
+ synthetic_node("aligned_alloc", 8, {"_memalign_r"}),
+ synthetic_node("reallocarray", 24, {"realloc", "__errno"}),
+ # synthetic_node("_free_r", 0), # TODO
+ # synthetic_node("_malloc_r", 0), # TODO
+ # synthetic_node("_realloc_r", 0), # TODO
+ # synthetic_node("_memalign_r", 0), # TODO
+ # execution
+ synthetic_node("raise", 16, {"_getpid_r"}),
+ synthetic_node("abort", 8, {"raise", "_exit"}),
+ synthetic_node("longjmp", 0),
+ synthetic_node("setjmp", 0),
+ # <strings.h>
+ synthetic_node("memcmp", 12),
+ synthetic_node("memcpy", 28),
+ synthetic_node("memset", 20),
+ synthetic_node("strcmp", 16),
+ synthetic_node("strlen", 8),
+ synthetic_node("strncpy", 16),
+ synthetic_node("strnlen", 8),
+ # other
+ synthetic_node("__errno", 0),
+ synthetic_node("_getpid_r", 8, {"_getpid"}),
+ synthetic_node("random", 8),
+ synthetic_node("register_fini", 8, {"atexit"}),
+ synthetic_node("atexit", 8, {"__register_exitproc"}),
+ synthetic_node(
+ "__register_exitproc",
+ 32,
+ {
+ "__retarget_lock_acquire_recursive",
+ "__retarget_lock_release_recursive",
+ },
+ ),
+ synthetic_node("__libc_fini_array", 16, {"_fini"}),
+ ]
+
+ def indirect_callees(
+ self, loc: str, line: str
+ ) -> tuple[typing.Collection[QName], bool] | None:
+ return None
+
+ def skipmodels(self) -> dict[BaseName, analyze.SkipModel]:
+ return {}
+
+
+class LibGCCPlugin:
+ def is_intrhandler(self, name: QName) -> bool:
+ return False
+
+ def init_array(self) -> typing.Collection[QName]:
+ return [
+ QName("libfmt_install_formatter"),
+ QName("libfmt_install_quote"),
+ ]
+
+ def extra_includes(self) -> typing.Collection[BaseName]:
+ return []
+
+ def extra_nodes(self) -> typing.Collection[Node]:
+ # This is accurate to Parabola's arm-none-eabi-gcc 14.2.0-1.
+ return [
+ # /usr/lib/gcc/arm-none-eabi/14.2.0/thumb/v6-m/nofp/libgcc.a
+ synthetic_node("__aeabi_idiv0", 0),
+ synthetic_node("__aeabi_ldiv0", 0),
+ synthetic_node("__aeabi_llsr", 0),
+ # /usr/lib/gcc/arm-none-eabi/14.2.0/thumb/v6-m/nofp/crti.o
+ synthetic_node("_fini", 24),
+ ]
+
+ def indirect_callees(
+ self, loc: str, line: str
+ ) -> tuple[typing.Collection[QName], bool] | None:
+ return None
+
+ def skipmodels(self) -> dict[BaseName, analyze.SkipModel]:
+ return {}
diff --git a/build-aux/measurestack/test_analyze.py b/build-aux/measurestack/test_analyze.py
new file mode 100644
index 0000000..ff1732d
--- /dev/null
+++ b/build-aux/measurestack/test_analyze.py
@@ -0,0 +1,34 @@
+# build-aux/measurestack/test_analyze.py - Tests for analyze.py
+#
+# Copyright (C) 2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# pylint: disable=unused-variable
+
+import pytest
+
+from .analyze import BaseName, QName
+
+
+def test_name_base() -> None:
+ assert QName("foo.c:bar.1").base() == BaseName("bar")
+
+
+def test_name_pretty() -> None:
+ name = QName("foo.c:bar.1")
+ assert f"{name}" == "QName('foo.c:bar.1')"
+ assert f"{name.base()}" == "BaseName('bar')"
+ assert f"{[name]}" == "[QName('foo.c:bar.1')]"
+ assert f"{[name.base()]}" == "[BaseName('bar')]"
+
+
+def test_name_eq() -> None:
+ name = QName("foo.c:bar.1")
+ with pytest.raises(AssertionError) as e:
+ if name == "foo":
+ pass
+ assert "comparing QName with str" in str(e)
+ with pytest.raises(AssertionError) as e:
+ if name.base() == "foo":
+ pass
+ assert "comparing BaseName with str" in str(e)
diff --git a/build-aux/measurestack/test_app_output.py b/build-aux/measurestack/test_app_output.py
new file mode 100644
index 0000000..4653d4e
--- /dev/null
+++ b/build-aux/measurestack/test_app_output.py
@@ -0,0 +1,52 @@
+# build-aux/measurestack/test_app_output.py - Tests for app_output.py
+#
+# Copyright (C) 2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+# pylint: disable=unused-variable
+
+import contextlib
+import io
+
+from . import analyze, app_output
+
+
+def test_print_group() -> None:
+ result = analyze.AnalyzeResult(
+ groups={
+ "A": analyze.AnalyzeResultGroup(
+ rows={
+ analyze.QName("short"): analyze.AnalyzeResultVal(nstatic=8, cnt=1),
+ analyze.QName(
+ "anamethatisnttoolongbutisnttooshort"
+ ): analyze.AnalyzeResultVal(nstatic=9, cnt=2),
+ }
+ ),
+ "B": analyze.AnalyzeResultGroup(rows={}),
+ },
+ missing=set(),
+ dynamic=set(),
+ included_funcs=set(),
+ )
+
+ def location_xform(loc: analyze.QName) -> str:
+ return str(loc)
+
+ stdout = io.StringIO()
+ with contextlib.redirect_stdout(stdout):
+ print()
+ app_output.print_group(result, location_xform, "A")
+ app_output.print_group(result, location_xform, "B")
+ assert (
+ stdout.getvalue()
+ == """
+= A =============================== ==
+anamethatisnttoolongbutisnttooshort 9 * 2
+short 8
+----------------------------------- --
+Total 26
+Maximum 9
+=================================== ==
+= B (empty) =
+"""
+ )
diff --git a/build-aux/measurestack/util.py b/build-aux/measurestack/util.py
new file mode 100644
index 0000000..47b2617
--- /dev/null
+++ b/build-aux/measurestack/util.py
@@ -0,0 +1,125 @@
+# build-aux/measurestack/util.py - Analyze stack sizes for compiled objects
+#
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+import re
+import typing
+
+from . import analyze, vcg
+from .analyze import BaseName, Node, QName
+
+# pylint: disable=unused-variable
+__all__ = [
+ "synthetic_node",
+ "read_source",
+ "get_zero_or_one",
+ "re_call_other",
+ "Plugin",
+ "PluginApplication",
+]
+
+
+def synthetic_node(
+ name: str, nstatic: int, calls: typing.Collection[str] = frozenset()
+) -> Node:
+ n = Node()
+
+ n.funcname = QName(name)
+
+ n.location = "<synthetic>"
+ n.usage_kind = "static"
+ n.nstatic = nstatic
+ n.ndynamic = 0
+
+ n.calls = dict((QName(c), False) for c in calls)
+
+ return n
+
+
+re_location = re.compile(r"(?P<filename>.+):(?P<row>[0-9]+):(?P<col>[0-9]+)")
+
+
+def read_source(location: str) -> str:
+ m = re_location.fullmatch(location)
+ if not m:
+ raise ValueError(f"unexpected label value {location!r}")
+ filename = m.group("filename")
+ row = int(m.group("row")) - 1
+ col = int(m.group("col")) - 1
+ with open(filename, "r", encoding="utf-8") as fh:
+ return fh.readlines()[row][col:].rstrip()
+
+
+def get_zero_or_one(
+ pred: typing.Callable[[str], bool], fnames: typing.Collection[str]
+) -> str | None:
+ count = sum(1 for fname in fnames if pred(fname))
+ assert count < 2
+ if count:
+ return next(fname for fname in fnames if pred(fname))
+ return None
+
+
+re_call_other = re.compile(r"(?P<func>[^(]+)\(.*")
+
+
+class Plugin(typing.Protocol):
+ def is_intrhandler(self, name: QName) -> bool: ...
+
+ # init_array returns a list of functions that are placed in the
+ # `.init_array.*` section; AKA functions marked with
+ # `__attribute__((constructor))`.
+ def init_array(self) -> typing.Collection[QName]: ...
+
+ # extra_includes returns a list of functions that are never
+ # called, but are included in the binary anyway. This may because
+ # it is an unused method in a used vtable. This may be because it
+ # is an atexit() callback (we never exit).
+ def extra_includes(self) -> typing.Collection[BaseName]: ...
+
+ def extra_nodes(self) -> typing.Collection[Node]: ...
+ def indirect_callees(
+ self, loc: str, line: str
+ ) -> tuple[typing.Collection[QName], bool] | None: ...
+ def skipmodels(self) -> dict[BaseName, analyze.SkipModel]: ...
+
+
+class PluginApplication:
+ _location_xform: typing.Callable[[str], str]
+ _plugins: list[Plugin]
+
+ def __init__(
+ self, location_xform: typing.Callable[[str], str], plugins: list[Plugin]
+ ) -> None:
+ self._location_xform = location_xform
+ self._plugins = plugins
+
+ def extra_nodes(self) -> typing.Collection[Node]:
+ ret: list[Node] = []
+ for plugin in self._plugins:
+ ret.extend(plugin.extra_nodes())
+ return ret
+
+ def indirect_callees(
+ self, elem: vcg.VCGElem
+ ) -> tuple[typing.Collection[QName], bool]:
+ loc = elem.attrs.get("label", "")
+ line = read_source(loc)
+
+ for plugin in self._plugins:
+ ret = plugin.indirect_callees(loc, line)
+ if ret is not None:
+ return ret
+
+ placeholder = "__indirect_call"
+ if m := re_call_other.fullmatch(line):
+ placeholder += ":" + m.group("func")
+ placeholder += " at " + self._location_xform(elem.attrs.get("label", ""))
+ return [QName(placeholder)], False
+
+ def skipmodels(self) -> dict[BaseName, analyze.SkipModel]:
+ ret: dict[BaseName, analyze.SkipModel] = {}
+ for plugin in self._plugins:
+ ret.update(plugin.skipmodels())
+ return ret
diff --git a/build-aux/measurestack/vcg.py b/build-aux/measurestack/vcg.py
new file mode 100644
index 0000000..39755e9
--- /dev/null
+++ b/build-aux/measurestack/vcg.py
@@ -0,0 +1,97 @@
+# build-aux/measurestack/vcg.py - Parse the "VCG" language
+#
+# Copyright (C) 2024-2025 Luke T. Shumaker <lukeshu@lukeshu.com>
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+import re
+import typing
+
+# pylint: disable=unused-variable
+__all__ = [
+ "VCGElem",
+ "parse_vcg",
+]
+
+# Parse the "VCG" language
+#
+# https://www.rw.cdl.uni-saarland.de/people/sander/private/html/gsvcg1.html
+#
+# The formal syntax is found at
+# ftp://ftp.cs.uni-sb.de/pub/graphics/vcg/vcg.tgz `doc/grammar.txt`.
+
+
+class VCGElem:
+ typ: str
+ lineno: int
+ attrs: dict[str, str]
+
+
+re_beg = re.compile(r"(edge|node):\s*\{\s*")
+_re_tok = r"[a-zA-Z_][a-zA-Z0-9_]*"
+_re_str = r'"(?:[^\"]|\\.)*"'
+re_attr = re.compile("(" + _re_tok + r")\s*:\s*(" + _re_tok + "|" + _re_str + r")\s*")
+re_end = re.compile(r"\}\s*$")
+re_skip = re.compile(r"(graph:\s*\{\s*title\s*:\s*" + _re_str + r"\s*|\})\s*")
+re_esc = re.compile(r"\\.")
+
+
+def parse_vcg(reader: typing.TextIO) -> typing.Iterator[VCGElem]:
+
+ for lineno, line in enumerate(reader):
+ pos = 0
+
+ def _raise(msg: str) -> typing.NoReturn:
+ nonlocal lineno
+ nonlocal line
+ nonlocal pos
+ e = SyntaxError(msg)
+ e.lineno = lineno
+ e.offset = pos
+ e.text = line
+ raise e
+
+ if re_skip.fullmatch(line):
+ continue
+
+ elem = VCGElem()
+ elem.lineno = lineno
+
+ m = re_beg.match(line, pos=pos)
+ if not m:
+ _raise("does not look like a VCG line")
+ elem.typ = m.group(1)
+ pos = m.end()
+
+ elem.attrs = {}
+ while True:
+ if re_end.match(line, pos=pos):
+ break
+ m = re_attr.match(line, pos=pos)
+ if not m:
+ _raise("unexpected character")
+ k = m.group(1)
+ v = m.group(2)
+ if k in elem.attrs:
+ _raise(f"duplicate key: {k!r}")
+ if v.startswith('"'):
+
+ def unesc(esc: re.Match[str]) -> str:
+ match esc.group(0)[1:]:
+ case "n":
+ return "\n"
+ case '"':
+ return '"'
+ case "\\":
+ return "\\"
+ case _:
+ _raise(f"invalid escape code {esc.group(0)!r}")
+
+ v = re_esc.sub(unesc, v[1:-1])
+ elem.attrs[k] = v
+ pos = m.end()
+
+ del _raise
+ del pos
+ del line
+ del lineno
+ yield elem