Files
STM32F4-Base/scripts/check_mem_numbers.py
T
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名,
不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体
run_boot_test.py 的真实调用。

- AGENTS.md:别名说明重写为「技能调用名不是 shell 命令」,两条目改
  「编译(/keil-build)」「串口回归(/serial-boot-test)」
- keil-build / serial-boot-test 两 skill 调用名与示例同步;task-worktree
  方案文档去掉 @build/@test 惯例从句;@task 保留(不在点名范围)
- 123 文件现状陈述全改;台架捕获、变更记录/时间线、rename 映射输入
  等 41 行历史证据保留旧名
- gen_memory_svg.py 6 处措辞随改,3 张 SVG 产物同批重跑
- 顺手补并行方案同行漏网计数 51→52 钩子

验证:提交期快钩子对 123 文件实跑全 Passed;check_doc_links --all
rc=0(3038 处、无法判定 0);check_mermaid --all rc=0;YAML 解析 52
钩子不变;skill-size rc=0;py_compile 全过;字节卫生零漂移。
2026-10-30 13:03:12 +08:00

427 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
内存/Flash 数字一致性门禁(体积档)。
问题
----
文档(尤其 `docs/内存管理/*.md`)大量引用 `RW_IRAM1 82,048 B / 62.6%`、
`合计 ROM 249,432 B / 23.8%` 这类**具体数字**。这些数字来自 `.map`,每次重建都可能变化,
而手抄必然滞后——实测 2026-09-26 复核:文档写 `82,048 B / 62.6%`,实际 `.map` 已是
`87,040 B / 66.4%`;Flash 写 `249,432 / 23.8%`,实际 `261,932 / 25.0%`。
读者照文档推算内存余量会得到错误结论。
做法
----
与 `gen_task_table.py` 同构:**`.map` 是唯一事实源**,文档里放**受管生成区**:
<!-- MEM_SNAPSHOT:BEGIN generated by scripts/check_mem_numbers.py -->
| 区域 | 用量 | 容量 | 占比 |
|---|---|---|---|
| RW_IRAM1 | 87,040 B (85.00 KB) | 131,072 B (128 KB) | 66.4% |
...
<!-- MEM_SNAPSHOT:END -->
脚本解析 `.map`,重新渲染该区并校验一致;`--write` 自动同步。
**行文中的散句数字**(如"现 RW_IRAM1 62.6%")不自动改写——它们由作者从受管区抄写,
`--check` 会额外做**散句数字扫描**:发现文中出现与受管区不符的"旧值"即告警(见 §扫描)。
这样既避免全文自动改写的高风险,又能抓出漏改的散句。
用法
----
python scripts/check_mem_numbers.py # 检查(默认)
python scripts/check_mem_numbers.py --write # 同步受管区
python scripts/check_mem_numbers.py --diff # 只打印期望快照
python scripts/check_mem_numbers.py --scan # 额外扫描散句数字不一致
python scripts/check_mem_numbers.py --map <path> # 指定 .map(默认 MDK-ARM/.../*.map)
退出码:0 通过;1 不一致 / 找不到事实源 / 解析失败。
"""
import argparse
import pathlib
import re
import sys
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
MAP_DEFAULT = REPO_ROOT / "MDK-ARM" / "STM32F407-Demo" / "STM32F407-Demo.map"
# 受管快照区锚点
BEGIN = "<!-- MEM_SNAPSHOT:BEGIN generated by scripts/check_mem_numbers.py -->"
END = "<!-- MEM_SNAPSHOT:END -->"
# 需要保持同步的文档
TARGETS = [
REPO_ROOT / "docs" / "内存管理" / "RAM使用分析与优化方案.md",
]
# 散句扫描范围(只扫这些文档里的"裸数字",避免误伤历史快照 data/*.md)
SCAN_TARGETS = [
REPO_ROOT / "docs" / "内存管理" / "RAM使用分析与优化方案.md",
REPO_ROOT / "docs" / "内存管理" / "内存内容分配策略.md",
REPO_ROOT / "docs" / "内存管理" / "存储栈RAM优化调研.md",
]
# --- .map 解析(与 scripts/ram_report.py 同口径,保持数据一致)---------------
def parse_map(path: pathlib.Path):
"""解析 Keil .map,返回 {'regions': [...], 'grand_totals': {...}}。
与 ram_report.py 使用**相同**的正则与口径,避免两处数字打架。
"""
text = path.read_text(encoding="utf-8", errors="replace")
regions, grand = [], None
for ln in text.splitlines():
if "Execution Region" in ln and "Exec base:" in ln:
m = re.search(r"Execution Region (\w+) \((.*?)\)", ln)
if not m:
continue
params = {}
for part in m.group(2).split(","):
part = part.strip()
if ":" in part:
k, v = part.split(":", 1)
params[k.strip()] = v.strip()
regions.append({
"name": m.group(1),
"size": int(params.get("Size", "0"), 16),
"max": int(params.get("Max", "0"), 16),
})
if "Grand Totals" in ln:
nums = []
for p in ln.split():
try:
nums.append(int(p))
except ValueError:
if "Grand" in p:
break
if len(nums) >= 6:
grand = {"code": nums[0], "ro_data": nums[2],
"rw_data": nums[3], "zi_data": nums[4]}
return {"regions": regions, "grand_totals": grand}
def compute_facts(data):
"""从解析结果算出受管快照要展示的指标(字节 → 展示口径)。"""
facts = {}
for r in data["regions"]:
util = (r["size"] / r["max"] * 100) if r["max"] else 0.0
facts[r["name"]] = {
"size": r["size"], "max": r["max"], "util": util,
}
gt = data["grand_totals"]
if gt:
rom = gt["code"] + gt["ro_data"] + gt["rw_data"]
rw_total = gt["rw_data"] + gt["zi_data"]
facts["_flash"] = {"rom": rom, "code": gt["code"], "ro": gt["ro_data"],
"flash_max": 1048576}
facts["_rw_total"] = rw_total
return facts
def fmt_kb(n, digits=2):
return f"{n/1024:.{digits}f} KB"
def render_snapshot(facts):
"""渲染受管快照 Markdown。顺序/口径固定,保证多次运行结果稳定。"""
order = ["ER_IROM1", "RW_IRAM1", "RW_CCM"]
lines = [
"| 区域 | 用量 | 容量 | 占比 |",
"|---|---|---|---|",
]
for name in order:
f = facts.get(name)
if not f:
continue
lines.append(
f"| {name} | {f['size']:,} B ({fmt_kb(f['size'])}) | "
f"{f['max']:,} B ({fmt_kb(f['max'], 0)}) | {f['util']:.1f}% |"
)
fl = facts.get("_flash")
if fl:
util = fl["rom"] / fl["flash_max"] * 100
lines.append(
f"| **Flash (ROM 合计)** | **{fl['rom']:,} B ({fmt_kb(fl['rom'], 1)})** | "
f"{fl['flash_max']:,} B (1024 KB) | **{util:.1f}%** |"
)
return "\n".join(lines)
def snapshot_metrics(facts):
"""返回用于散句扫描的「期望值集合」:(数字, 允许的等价写法列表)。"""
out = []
def add(n):
out.append(n)
for name in ("ER_IROM1", "RW_IRAM1", "RW_CCM"):
f = facts.get(name)
if f:
add(f["size"]); add(f["max"])
add(round(f["util"], 1))
fl = facts.get("_flash")
if fl:
add(fl["rom"]); add(fl["code"]); add(fl["ro"])
add(round(fl["rom"] / fl["flash_max"] * 100, 1))
if "_rw_total" in facts:
add(facts["_rw_total"])
return out
# --- 文档读写(保真换行 / BOM,同 gen_task_table.py)------------------------
def read_doc(path: pathlib.Path):
raw = path.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
body = raw[3:] if bom else raw
crlf = body.count(b"\r\n")
lf = body.count(b"\n") - crlf
newline = "\r\n" if crlf > lf else "\n"
text = body.decode("utf-8", errors="replace")
return text.replace("\r\n", "\n").replace("\r", "\n"), newline, bom
def write_doc(path: pathlib.Path, text: str, newline: str, bom: bool):
out = text if newline == "\n" else text.replace("\n", newline)
data = out.encode("utf-8")
path.write_bytes((b"\xef\xbb\xbf" if bom else b"") + data)
def replace_managed(text: str, snapshot: str):
if BEGIN not in text or END not in text:
return text, False
s = text.index(BEGIN) + len(BEGIN)
e = text.index(END)
new = "\n" + snapshot + "\n"
if text[s:e] == new:
return text, False
return text[:s] + new + text[e:], True
def extract_managed(text: str):
if BEGIN not in text or END not in text:
return None
s = text.index(BEGIN) + len(BEGIN)
e = text.index(END)
return text[s:e].strip()
# --- 散句扫描 ---------------------------------------------------------------
#
# 朴素做法(扫所有 4~7 位数字)会产生大量误报——任务栈 `4096`、堆配置 `49,152`、
# 峰值水位 `1184` 都是合法数字(实测一次报 45 处,全是噪音,门禁会被无视)。
#
# 正确判据:**「上一个快照里是区域用量、但当前已不是」的数字**才是陈旧数字。
# 例如历史 `RW_IRAM1 = 82,048 B`、当前 `87,040 B` → 正文若仍写 `82,048` 即为旧值。
# 故扫描只针对:从 `data/ram_report_*.md` 历史快照提取的**区域用量/占比**值。
SNAPSHOT_DIR = REPO_ROOT / "docs" / "内存管理" / "data"
# 历史快照里 "| RW_IRAM1 | 0x... | 82,048 B (...)" 这类行
SNAP_REGION_RE = re.compile(
r"\|\s*(ER_IROM1|RW_IRAM1|RW_CCM)\s*\|\s*0x[0-9A-Fa-f]+\s*\|\s*([\d,]+)\s*B"
r"\s*\([^)]*\)\s*\|\s*([\d,]+)\s*B[^|]*\|\s*([\d.]+)%"
)
# 行内出现这些词说明是在讲**别的**数字(栈配置/峰值/池大小),不是区域用量。
# ⚠️ 只在**紧邻数字**(前后 12 字符内)时才算命中——否则会误豁免整句
# (实测:「堆迁 CCM 后…(现 RW_IRAM1 62.6%)」因含「堆」被整句跳过,漏掉现状数字)。
NON_REGION_HINTS = ("栈", "STACK", "stack", "堆", "heap", "pool", "池",
"峰值", "水位", "high water", "min_ever", "free=",
"LV_MEM_SIZE", "config", "任务", "TCB", "缓冲", "buffer")
HINT_WINDOW = 12
def _is_non_region_metric(sent: str, start: int, end: int) -> bool:
"""数字 [start,end) 是否处于「栈/池/峰值」语境(紧邻 ≤12 字符内有提示词)。"""
lo = max(0, start - HINT_WINDOW)
hi = min(len(sent), end + HINT_WINDOW)
window = sent[lo:hi]
return any(h in window for h in NON_REGION_HINTS)
def collect_stale_numbers(current_facts):
"""从历史快照提取「曾经是区域用量、现已变化」的数字 → 陈旧值集合。
只收**当前值不含**的历史区域用量/占比;这样扫正文时命中的就是真正的陈旧数字。
"""
cur = set()
for name in ("ER_IROM1", "RW_IRAM1", "RW_CCM"):
f = current_facts.get(name)
if f:
cur.add(f["size"])
cur.add(f["max"])
cur.add(round(f["util"], 1))
fl = current_facts.get("_flash")
if fl:
cur.add(fl["rom"])
cur.add(fl["code"])
cur.add(fl["ro"])
cur.add(round(fl["rom"] / fl["flash_max"] * 100, 1))
stale = {} # 数字 -> 来源文件名
if not SNAPSHOT_DIR.is_dir():
return stale
for snap in sorted(SNAPSHOT_DIR.glob("ram_report_*.md")):
text = snap.read_text(encoding="utf-8", errors="replace")
for m in SNAP_REGION_RE.finditer(text):
size = int(m.group(2).replace(",", ""))
util = float(m.group(4))
for v in (size, util):
if v not in cur:
stale.setdefault(v, snap.name)
# 旧快照的 Grand Totals 行(Total ROM)
for m in re.finditer(r"\*\*Total ROM[^|]*\|\s*\*\*([\d,]+)\s*B", text):
v = int(m.group(1).replace(",", ""))
if v not in cur:
stale.setdefault(v, snap.name)
return stale
def scan_prose(text: str, stale, errors, path: pathlib.Path):
"""正文里出现「历史快照的区域用量、但当前已变化」的数字 → 报为陈旧引用。
只报这一类(实测 0 误报),不报任务栈/池配置等其它数字。
⚠️ **按句子而非按行判定**:同一行常混有「当时 X」与「现 Y」两种时态,
按行判会把整行豁免掉、漏掉后半句的现状数字(实测踩中)。
"""
if not stale:
return
in_managed = False
# 句读切分:中文句号/分号/感叹号/问号 + Markdown 分隔(`|`、`;`)
SENT_SPLIT = re.compile(r"(?<=[。;;!!??])|(?<=\|)")
for lineno, line in enumerate(text.splitlines(), 1):
if BEGIN in line:
in_managed = True
continue
if END in line:
in_managed = False
continue
if in_managed:
continue
for sent in SENT_SPLIT.split(line):
if not sent or not sent.strip():
continue
# 明确标注「历史/曾/旧/快照」或引用 data/ 的**句子** → 允许保留旧数字
if any(h in sent for h in ("历史", "旧", "曾", "此前", "快照", "data/",
"修复前", "优化前", "迁入前", "实施时",
"实施前", "当时", "原值", "改动前", "改造前")):
continue
# 讲栈/池/峰值的**数字**(提示词紧邻该数字)语义不同,跳过;
# 注意不能按整句跳过——同一句可能既讲堆又报区域现状。
for m in re.finditer(r"\b(\d{1,3}(?:,\d{3})+|\d{4,7}|\d{1,3}\.\d)%?", sent):
if _is_non_region_metric(sent, m.start(), m.end()):
continue
raw = m.group(1)
try:
v = float(raw.replace(",", ""))
except ValueError:
continue
key = int(v) if v.is_integer() else v
if key in stale:
errors.append((path, lineno,
f"陈旧区域数字 {raw}(历史快照 {stale[key]} 的值,"
f"当前已变化)", "陈旧数字"))
def main(argv):
ap = argparse.ArgumentParser(description="内存/Flash 数字一致性门禁(体积档)")
g = ap.add_mutually_exclusive_group()
g.add_argument("--check", action="store_true", help="检查模式(默认)")
g.add_argument("--write", action="store_true", help="同步受管快照区")
g.add_argument("--diff", action="store_true", help="只打印期望快照")
ap.add_argument("--scan", action="store_true", help="额外扫描散句数字")
ap.add_argument("--map", default=str(MAP_DEFAULT), help=".map 文件路径")
args = ap.parse_args(argv[1:])
map_path = pathlib.Path(args.map)
if not map_path.exists():
print(f"[FAIL] 找不到 .map 事实源:{map_path}", file=sys.stderr)
print(" 先跑一次 /keil-build(keil-build skill)生成 .map,再跑本门禁。", file=sys.stderr)
print(" 这不是代码缺陷:.map 是**每棵工作树各自的构建产物**(不入版本库),",
file=sys.stderr)
print(" 新开的工作树(task-worktree new)从未构建过时必然走到这里——先 /keil-build",
file=sys.stderr)
print(" 一次即可;随后按需要跑 scripts/ram_baseline.py --update 回填台账。",
file=sys.stderr)
return 1
data = parse_map(map_path)
if not data["regions"]:
print(f"[FAIL] 未能从 {map_path.name} 解析出执行区,请检查文件格式。",
file=sys.stderr)
return 1
facts = compute_facts(data)
snapshot = render_snapshot(facts)
stale = collect_stale_numbers(facts)
if args.diff:
print(snapshot)
return 0
changed, missing, problems = [], [], []
errors = []
for t in TARGETS:
if not t.exists():
problems.append(f"{t.relative_to(REPO_ROOT)} 不存在")
continue
text, newline, bom = read_doc(t)
rel = t.relative_to(REPO_ROOT).as_posix()
if args.write:
new_text, did = replace_managed(text, snapshot)
if did:
write_doc(t, new_text, newline, bom)
changed.append(rel)
else:
got = extract_managed(text)
if got is None:
missing.append(rel)
elif got.replace("\r\n", "\n") != snapshot.strip():
changed.append(rel)
if args.scan:
scan_prose(text, stale, errors, t)
if args.write:
if missing:
print("[WARN] 以下文档缺受管锚点,未写入:" + ", ".join(missing))
print(f" 请在快照表上下各加一行:\n {BEGIN}\n {END}")
if changed:
print("已更新内存快照:" + ", ".join(changed))
if not changed and not missing:
print("内存快照已是最新,无需改动。")
return 0
if problems:
for p in problems:
print(f"[FAIL] {p}", file=sys.stderr)
return 1
if args.scan:
for path, lineno, msg, kind in errors:
rel = path.relative_to(REPO_ROOT)
print(f" {rel}:{lineno} [{kind}] {msg}")
if missing or changed or errors:
if missing:
print("[FAIL] 以下文档缺少受管锚点:")
for n in missing:
print(f" - {n}")
if changed:
print("[FAIL] 以下文档的内存快照与 .map 不一致:")
for n in changed:
print(f" - {n}")
print(f"\n事实源:{map_path.relative_to(REPO_ROOT)}")
print("\n期望快照:")
print(snapshot)
print("\n修复:运行 `python scripts/check_mem_numbers.py --write`")
if errors:
print("(散句数字告警需人工复核,--write 不会自动改写正文)")
return 1
print(f"内存数字一致(事实源 {map_path.name})。")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))