#!/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` 是唯一事实源**,文档里放**受管生成区**: | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| | RW_IRAM1 | 87,040 B (85.00 KB) | 131,072 B (128 KB) | 66.4% | ... 脚本解析 `.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 # 指定 .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 = "" 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))