Files
STM32F4-Base/scripts/check_mem_numbers.py
T
wandering1 a8de47ca3f fix(门禁脚本): 缺 .map 时说明"本树从未构建",并写进工作树文档
票面:task-20 交付后的回报——全新工作树没有 .map,依赖它的门禁必然红,而现场只看到一句
"找不到 .map 事实源",容易误判成代码问题。(Lead 定的两头修法都做,本笔独立于 UI 改动。)

- scripts/check_mem_numbers.py:FAIL 后补三行原因与动作——.map 是**每棵工作树各自的**构建产物、
  不入版本库,新开的工作树(task-worktree new)从未构建过时必然走到这里,先 @build 一次即可,
  再按需要 ram_baseline.py --update 回填台账。保持 FAIL 语义不变,只把提示词写对。
- scripts/ram_baseline.py:两处(--update / --write 路径)同样的补充说明。
- docs/环境与部署/并行任务工作树方案.md:生命周期表加一行「新空间首次:@build 一次生成 .map」,
  表下补一段说明点名 mem-numbers / mem-budget / L0c 三个消费者。
- .dsh/skills/task-worktree/SKILL.md:陷阱区加同一条,并与既有的「done 的门禁跑在 @build 之前
  → .map 是旧的」明确区分——一条是"有但旧",一条是"根本没有",处置动作不同。

只改提示文案与文档,不改判定逻辑与阈值;两个脚本 py_compile 通过并实测打印。
跳过的门禁(三条同族):mem-numbers / mem-budget / size-gate(L0c)——本笔恰恰是在解释其中两条
为什么会红,数值本身仍等 Lead 回填。
2026-10-25 15:33:45 +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 数字一致性门禁(D-7 第三档:体积)。
问题
----
文档(尤其 `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 数字一致性门禁(D-7 体积档)")
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))