Files
STM32F4-Base/scripts/check_mem_numbers.py
T
wandering1 0f7b5a87fb chore(编号): 存量编号全量转语义短语,门禁识别面与语料同批重打
登记簿、文档与代码注释里的存量字母加数字编号(N-x / S-x / T-x / E-x / A-x / 卡号)
一律改写为语义短语,净删 2,028 处引用;删除基线与待确认、并行分道方案、排期详述、
采集保护收尾 4 篇(内容已并入 docs/任务规划/README 与登记簿)。

门禁机器与三张账同批重打:check_ids.py 新增变更记录行豁免与第二根护栏
undecidable_total_ceiling;check_test_traceability.py 只加 stdout/stderr UTF-8
reconfigure(TC_RE 未动);check_cubemx_survival.py 的 --write 固定 newline=LF;
id_registry / id_aliases(+19 条 retired-to-phrase)/ id_known_backlog 重打。

test/ 56 个文件同步 TC 标签语义化,其中 18 个 TU 改了 CHECK / printf 里的标签文字,
是本批唯一改变构建产物的文件集合。

为什么合成一笔、不拆四笔(实测撞闸,非偏好):pre-commit 跑钩子前会 stash 未 staged
部分,拆开后中间提交必然是「新规则 + 旧语料」——实跑可判定 85.15% < 基线 86.00%、
不可判定 1,453 > 上限 1,166,编号门禁直接拦下。编号识别面与它描述的语料只能同码移动。

实跑基线(工作区全量):754 文件 / 8,230 处编号字面量 / 可判定 7,079 = 86.01%
(基线 86.0%,容差 0.1pp)/ 不可判定绝对数 1,151(上限 1,166)/ 校验 1-6 错误级 0 /
告警 = 悬空 2(App/algo/freq.c 的 S2,属 §4.4 同号不同义)+ 孤儿 50。

基线从 84.0 抬到 86.0 的理由见 docs/门禁说明/id-check.md §7.6:本批真删了 2,028 处
引用,可判定面随真实内容上升,不是把分母做小。同批新增排除 docs/硬件平台/ 语料,使
识别面少 264 处——芯片手册位号与网表料号不属编号空间(registry 有 excluded_paths_note)。

⚠ 余量只剩 0.11pp(约 10 处)与绝对数 15 处:把「可判定的引用」改成散文会立刻撞第二
根护栏。另遗留一项:id_aliases 里 retired 的 N / S / T / A 别名没有 scope(U1~U5 有),
今后有人新写一个 A-4,校验 3 不会响,只能靠人,建议下批补 scope。

固件不变证据:115 个 .c/.h 剥注释并归一字符串后与 HEAD 逐字节全等,残留代码差异 0;
App / Src / Drivers 的字符串字面量零改动。@build 0 错误 0 警告;40/40 主机套件、2,889
断言全通过;覆盖率 line 89.2% / func 92.6%;pre-commit 48 钩子 --all-files 全绿;
check_doc_links --all 通过(176 文档,章节引用无法判定 0);逐字节卫生核过:14 个带
BOM 文件与 HEAD 一致、0 个 CR、0 个 U+FFFD,字符串字面量纯 ASCII。

.cubemx_survival_baseline.json 的 watchdog-module 哈希 56bf27 变 2bb051 属合法:
Src/freertos.c 的 USER CODE 区内只有注释文字随编号改写,代码未变。

同批带上体积同码凭证与 RAM 台账行(@build 之后盖戳):RW_IRAM1 79,008 B / 60.3%,
RW_CCM 57,376 B / 87.9%,阈值 92%。
2026-10-26 12:28:14 +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(" 先跑一次 @build(keil-build skill)生成 .map,再跑本门禁。", file=sys.stderr)
print(" 这不是代码缺陷:.map 是**每棵工作树各自的构建产物**(不入版本库),",
file=sys.stderr)
print(" 新开的工作树(task-worktree new)从未构建过时必然走到这里——先 @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))