0f7b5a87fb
登记簿、文档与代码注释里的存量字母加数字编号(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%。
427 lines
17 KiB
Python
427 lines
17 KiB
Python
#!/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))
|