文档体系重组与 D-7 一致性门禁落地,清理芯片手册孤儿图片

- D-6 目录规范化:docs 根目录散落文档归入 UI设计/子系统设计/架构设计/审查记录/
  环境与部署/网络 等子目录,去掉目录名空格,重命名 嵌入式C语言代码规范_V1.0.md
- D-7 三档一致性门禁:新增 scripts/check_doc_links.py(链接存在性)、
  scripts/gen_task_table.py(FreeRTOS 任务表数值)、scripts/check_mem_numbers.py
  (内存/Flash 体积),均支持 --check/--write/--diff,pre-commit 钩子同步注册
- D-5 文档索引:新建 docs/README.md,新建 docs/文档规范.md 约束目录命名与链接写法
- D-1 芯片手册瘦身:删除 1069 张无引用孤儿图片(77.06 MB),
  docs/芯片手册 120 MB → 41 MB,已引用图片零断链
- 同步 C 源码注释中的文档路径引用(D-6 重组后),构建 0 错误 0 警告
This commit is contained in:
2026-09-26 00:51:53 +08:00
parent c76aa035b2
commit a6cdebde62
1154 changed files with 1810 additions and 3409 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
C 语言代码规范检查(《嵌入式C语言代码规范(V1.0)》可机械化校验的规则)
C 语言代码规范检查(《嵌入式C语言代码规范_V1.0》可机械化校验的规则)
由 pre-commit 调用:python scripts/check_coding_standard.py <file...>,发现违规退出码 1。
检查项(括号内为规范条款号):
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
文档路径一致性 + 命名规范检查(D-3/D-6/D-7)。
由 pre-commit 调用:python scripts/check_doc_links.py <file...>,发现问题退出码 1。
检查三类:
A. Markdown 链接 [文本](相对路径) —— 相对当前文档所在目录解析
B. 反引号路径引用 `docs/xxx/yyy.md` —— 相对仓库根解析(本工程约定:文档间引用
一律用「仓库根相对路径」)
C. 文件名规范 目录名 / 文件名不得含空格与路径敏感字符(见 docs/文档规范.md §1)
豁免(不算失效,不报错):
* 以 `[计划]` 前缀标记的路径(尚未创建的约定产物,见《项目审查与优化建议报告》D-3 建议)
* 含 `[示例]` 标记的行——引用报错样例 / 日志片段等含路径的引文
* 引用附近(±40 字符)出现「未创建/尚未创建/已删除/待排期」等状态词
* 含通配符 `*`、尖括号 `<模块>` 等占位符
* 模板变量样式(NN_ / XX_ / 界面名 / 模块名 / 日期 / 示例 / ${...} / %s 等)
* 行内代码 span(`...`)里的链接写法——那是「怎么写」的示例,不是真实引用
* 外部 URL(http/https/mailto)与纯锚点(#...)
* 第三方目录下的文档(由 pre-commit exclude 控制,脚本内亦再兜底)
* `docs/芯片手册/` 为外部资料:链接形态与文件名均不受控(整体豁免)
用法:
python scripts/check_doc_links.py docs/a.md docs/b.md ... # 检查指定文档
python scripts/check_doc_links.py --all # 检查全库文档
退出码:0 通过;1 有问题。
"""
import re
import sys
import pathlib
import urllib.parse
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
# 不参与检查的路径片段(第三方/生成物/外部资料)
SKIP_PARTS = ("node_modules", ".venv", ".git", "build", "obj", "__pycache__",
"Lib/lvgl", "simulator/SDL2", ".opencode/node_modules",
".workbuddy-ai", # 本地记忆/会话数据,非项目文档
"docs/芯片手册") # 外部数据手册转录,非项目自有文档,链接形态不受控
# Markdown 链接:[text](target)。目标须以「非空白、非右括号」构成,
# 且排除纯数字(字体尺寸等畸形写法)。
MD_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
# 行内代码 span:`...`(含 ``` 围栏行整行)。先剥离再扫链接——
# 反引号里的内容按文档约定是「字面量/示例」,不应被当作真实引用。
INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
# 反引号路径:`docs/xxx/yyy.md`(仓库根相对);终止字符含中英文标点,防同行粘连
BACKTICK_PATH_RE = re.compile(r"`((?:docs|scripts|tools|test|simulator)/[^`\n]*?\.(?:md|c|h|py|json|txt|bat|svg|ini|tsv|yml|yaml))`")
# 行内状态词:出现这些说明该引用已失效/尚未创建,属正常叙述。
# ⚠️ 必须与引用「就近」匹配(见 has_status_hint),不能整行匹配——
# 否则文件名本身含「不存在」「删除」等字样时会误豁免同行的所有引用。
STATUS_HINTS = ("计划", "未创建", "尚未创建", "已删除", "删除", "不存在", "缺失",
"断链", "待建", "待创建")
# 显式「这是举例」标记:整行豁免。用于引用报错样例、日志片段、期望输出等
# 含路径的引文(如「典型报错:`docs/a.md:1 [markdown 链接] x.svg`」)。
EXAMPLE_MARKER = "[示例]"
# 状态词与引用之间的最大字符距离(就近判定窗口)
STATUS_PROXIMITY = 40
# 占位符 token
PLACEHOLDER_TOKENS = ("NN_", "XX_", "界面名", "模块名", "日期", "示例", "{{", "}}",
"${", "%s", "%d", "<", ">")
def is_placeholder(text: str) -> bool:
if "*" in text or "<" in text or ">" in text or "{" in text or "}" in text:
return True
if "xxx" in text or "yyy" in text:
return True
return any(tok in text for tok in PLACEHOLDER_TOKENS)
def is_valid_link_target(target: str) -> bool:
"""过滤畸形写法:纯数字(字体尺寸等)、空目标、非路径形态。"""
if not target:
return False
if target.isdigit():
return False
# 必须含「/」或「.」才像路径(避免把普通文本/锚点当路径)
if "." not in target and "/" not in target:
return False
return True
def has_status_hint(text: str, pos: int = -1, span: int = 0) -> bool:
"""是否靠近某状态词。
pos<0(默认)→ 退化为整行匹配,兼容无位置信息的调用。
pos>=0 → 只在引用的 span 两侧 STATUS_PROXIMITY 字符内找状态词,
避免「文件名里含状态词」豁免掉同行的其它真实引用。
"""
if pos < 0:
return any(h in text for h in STATUS_HINTS)
lo = max(0, pos - STATUS_PROXIMITY)
hi = min(len(text), pos + max(span, 0) + STATUS_PROXIMITY)
window = text[lo:hi]
return any(h in window for h in STATUS_HINTS)
def strip_inline_code(line: str) -> str:
"""剥离行内代码 span(`...`),但保持字符数不变(同长空白替换),
以便后续报错时的列号/上下文仍可对应原文。
目的:文档常把「链接写法示例」放在反引号里(如 ![标题](相对路径.svg)),
这类字面量不是真实引用,不应参与存在性校验。
注意:反引号包裹的仓库根相对路径由 check_backtick_paths 单独处理,
故这里用逐段替换、只挖掉成对反引号内部内容。
"""
return INLINE_CODE_RE.sub(lambda m: " " * len(m.group(0)), line)
def check_md_links(path: pathlib.Path, lines, errors):
"""A 类:markdown 链接 —— 相对当前文档目录解析。
扫描前剥离行内代码 span:反引号里的链接写法是「示例」,非真实引用。
"""
for lineno, line in enumerate(lines, 1):
scan = strip_inline_code(line)
for m in MD_LINK_RE.finditer(scan):
target = m.group(2).strip()
if not target or target.startswith(("http://", "https://", "mailto:", "#")):
continue
if not is_valid_link_target(target):
continue
# 去掉锚点后判断
raw_path = urllib.parse.unquote(target.split("#")[0])
if not raw_path:
continue
if is_placeholder(target) or "[计划]" in line or EXAMPLE_MARKER in line:
continue
if has_status_hint(line, m.start(), len(m.group(0))):
continue
resolved = (path.parent / raw_path).resolve()
if not resolved.exists():
errors.append((path, lineno, target, "markdown 链接"))
def check_backtick_paths(path: pathlib.Path, lines, errors):
"""B 类:反引号内的仓库根相对路径。
与 A 类相反:这里反引号**就是**引用容器(本工程约定 `docs/xxx.md` 表引用),
故不剥离代码 span,直接扫原文。
"""
for lineno, line in enumerate(lines, 1):
for m in BACKTICK_PATH_RE.finditer(line):
target = m.group(1).strip()
if is_placeholder(target) or "[计划]" in line or EXAMPLE_MARKER in line:
continue
if has_status_hint(line, m.start(), len(m.group(0))):
continue
if not (REPO_ROOT / target).exists():
errors.append((path, lineno, target, "路径引用"))
def _rel(p: pathlib.Path) -> str:
"""仓库相对路径(正斜杠),用于与 SKIP_PARTS 前缀比较。"""
try:
return p.resolve().relative_to(REPO_ROOT).as_posix()
except ValueError:
return p.as_posix()
def is_skipped(p: pathlib.Path) -> bool:
rel = _rel(p)
parts = rel.split("/")
for s in SKIP_PARTS:
if "/" in s:
if rel == s or rel.startswith(s + "/"):
return True
elif s in parts:
return True
return False
# --- 文件名规范(docs/文档规范.md §1) -----------------------------------
# 路径敏感 / 全角字符:禁止出现在文档目录名与文件名中
BAD_NAME_CHARS = set("()【】〔〕<>:|*?\"\\&")
# 外部资料目录:沿用上游原名,不受命名规范约束
NAME_EXEMPT_DIRS = ("docs/芯片手册",)
def check_name(path: pathlib.Path, errors):
"""校验文档「目录名 + 文件名」不含空格与路径敏感字符(规范 §1)。"""
rel = _rel(path)
for exempt in NAME_EXEMPT_DIRS:
if rel == exempt or rel.startswith(exempt + "/"):
return
parts = rel.split("/")[1:] # 去掉仓库根
for part in parts:
stem = part[:-3] if part.endswith(".md") else part
if " " in stem:
errors.append((path, 0, f"名称含空格: {stem}", "文件名规范"))
bad = sorted(set(stem) & BAD_NAME_CHARS)
if bad:
errors.append((path, 0,
f"名称含禁用字符 {' '.join(bad)}: {stem}", "文件名规范"))
def iter_docs(argv):
if "--all" in argv:
for p in REPO_ROOT.rglob("*.md"):
if not is_skipped(p):
yield p
return
for a in argv:
if a.startswith("--"):
continue
p = pathlib.Path(a)
if not p.is_absolute():
p = pathlib.Path.cwd() / p
if p.is_file() and p.suffix.lower() == ".md":
yield p.resolve()
def main(argv):
errors = []
n_files = 0
for doc in iter_docs(argv[1:] if len(argv) > 1 else []):
if is_skipped(doc):
continue
try:
text = doc.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
n_files += 1
lines = text.splitlines()
check_md_links(doc, lines, errors)
check_backtick_paths(doc, lines, errors)
check_name(doc, errors)
if errors:
print(f"发现的失效文档引用 / 命名问题({len(errors)} 处):")
for path, lineno, target, kind in errors:
try:
rel = path.relative_to(REPO_ROOT)
except ValueError:
rel = path
loc = f"{rel}:{lineno}" if lineno else f"{rel}"
print(f" {loc} [{kind}] {target}")
print(f"\n检查 {n_files} 个文档,{len(errors)} 处问题。")
print("提示:引用计划中的文件请用 `[计划] <路径>` 前缀标记;"
"已删除的文件请写明「已删除」状态词;"
"引用报错样例/日志片段请在该行加 `[示例]` 标记;"
"命名规范见 docs/文档规范.md §1。")
return 1
print(f"文档路径检查通过({n_files} 个文档)。")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+420
View File
@@ -0,0 +1,420 @@
#!/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(或 MDK-ARM/build.bat)生成 .map。", 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))
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
FreeRTOS 任务表一致性工具(D-2 / D-7)。
背景
----
`README.md` 与 `AGENTS.md` 各有一张 FreeRTOS 任务表(任务名 / 栈大小 / 优先级)。
历史上这两张表全靠手写,`Src/freertos.c` 改栈后表格忘记同步——2026-09 就出现过
两张表都比源码落后一大截(写 2048/4096/4096/4096/16384/2048,实际 1024...512)。
本脚本以 `Src/freertos.c` 为**唯一事实源**,生成 / 校验这两张表。
事实源
------
解析 `Src/freertos.c` 里形如:
const osThreadAttr_t netTask_attributes = {
.name = "netTask",
.stack_size = 1024 * 4, /* 注释 */
.priority = (osPriority_t) osPriorityNormal,
};
的块,抽出 name / stack_words / priority。`stack_size` 一律按 `N * 4`
(CMSIS-RTOS2 的 stack_size 单位是字节,工程约定写成 `words * 4`)换算为 words。
用法
----
python scripts/gen_task_table.py # 检查模式(默认):不一致即退出码 1
python scripts/gen_task_table.py --check # 同上
python scripts/gen_task_table.py --write # 就地重写 README/AGENTS 中的任务表
python scripts/gen_task_table.py --diff # 只打印期望表格,不写文件
pre-commit 用法:`python scripts/gen_task_table.py --check`,
任一被跟踪文档里的任务表与 `Src/freertos.c` 不一致即拦截。
退出码:0 通过;1 不一致 / 解析失败。
"""
import argparse
import pathlib
import re
import sys
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
FREERTOS_C = REPO_ROOT / "Src" / "freertos.c"
# 需要保持同步的文档及其任务表锚点
# anchor_begin / anchor_end 之间即为「受管表格区」,脚本只替换这一段,
# 表格外的说明文字(含各文档自己的职责描述/口径注记)原样保留。
TARGETS = [
{
"path": REPO_ROOT / "README.md",
"begin": "<!-- TASK_TABLE:BEGIN generated by scripts/gen_task_table.py -->",
"end": "<!-- TASK_TABLE:END -->",
"header": "| 任务 | 栈大小 | 优先级 | 职责 |\n"
"|------|--------|--------|------|",
},
{
"path": REPO_ROOT / "AGENTS.md",
"begin": "<!-- TASK_TABLE:BEGIN generated by scripts/gen_task_table.py -->",
"end": "<!-- TASK_TABLE:END -->",
"header": "| Task | 栈 (words) | 优先级 |\n|---|---|---|",
},
]
# 文档中的展示顺序(与 README/AGENTS 既有一致;不在表内的任务按源码顺序追加)。
# 不用源码顺序:freertos.c 里 timeSyncTask 定义在最前,但文档按「主任务→辅助任务」排列。
DISPLAY_ORDER = ["defaultTask", "netTask", "ftpTask", "testTask", "uiTask",
"timeSyncTask"]
# 各任务在文档里的「职责」描述(README 需要,AGENTS 不需要)。
# 只影响展示文案,不参与一致性判定;未知任务回落为空。
ROLE = {
"defaultTask": "FTL + FatFS 挂载、配置引擎属主、syslog 唯一写者",
"netTask": "网络初始化 + CH395F 轮询",
"ftpTask": "FTP 服务(等待网络+存储就绪)",
"testTask": "硬件测试入口",
"uiTask": "LVGL UI 界面",
"timeSyncTask": "墙钟时间同步",
}
def sort_tasks(tasks):
"""按 DISPLAY_ORDER 排序;未列出的任务保持源码相对顺序排在最后。"""
idx = {n: i for i, n in enumerate(DISPLAY_ORDER)}
return sorted(tasks, key=lambda t: (idx.get(t[0], len(idx)),))
# 任务块:const osThreadAttr_t <id>_attributes = { ... };
BLOCK_RE = re.compile(
r"const\s+osThreadAttr_t\s+(?P<id>\w+)_attributes\s*=\s*\{(?P<body>.*?)\};",
re.S,
)
STACK_RE = re.compile(r"\.stack_size\s*=\s*(?P<expr>[^,]+),")
PRIO_RE = re.compile(r"\.priority\s*=\s*\(osPriority_t\)\s*(?P<prio>\w+)")
# 优先级枚举 → 文档里使用的短名(去掉 osPriority 前缀)
PRIO_SHORT = lambda p: p[len("osPriority"):] if p.startswith("osPriority") else p
def parse_tasks(text: str):
"""从 freertos.c 文本解析任务列表,按出现顺序返回。
stack_size 允许以下写法(工程约定):
1024 * 4 -> 1024 words
(1024 * 4) -> 1024 words
4096 -> 1024 words(按 bytes/4 折算,并给出提示)
返回 [(name, words, prio, raw_expr), ...]
"""
tasks = []
for m in BLOCK_RE.finditer(text):
body = m.group("body")
sm = STACK_RE.search(body)
pm = PRIO_RE.search(body)
if not (sm and pm):
continue
expr = sm.group("expr").strip()
name_lit = re.search(r'\.name\s*=\s*"([^"]+)"', body)
name = name_lit.group(1) if name_lit else m.group("id")
# 去掉注释后求值:`1024 * 4` -> 4096 (bytes)
clean = re.sub(r"/\*.*?\*/", "", expr, flags=re.S).strip()
clean = clean.strip("()").strip()
try:
num = eval(clean, {"__builtins__": {}}, {}) # noqa: S307 (受控字面量)
except Exception:
print(f"[WARN] 无法解析 {name} 的 stack_size 表达式:{expr!r}",
file=sys.stderr)
continue
# 工程约定 = words*4,故 words = 字节/4
words = num // 4 if num % 4 == 0 else num
tasks.append((name, words, PRIO_SHORT(pm.group("prio")), expr))
return tasks
def render_table(tasks, header: str, with_role: bool) -> str:
lines = [header]
for name, words, prio, _expr in tasks:
if with_role:
lines.append(f"| {name} | {words} words | {prio} | {ROLE.get(name, '')} |")
else:
lines.append(f"| {name} | {words} | {prio} |")
return "\n".join(lines)
def replace_managed(text: str, target: dict, table: str):
"""替换 <!-- TASK_TABLE:BEGIN --> ... <!-- TASK_TABLE:END --> 之间的内容。
返回 (新文本, 是否变化)。若锚点不存在则原样返回(由调用方决定是否补锚点)。
"""
b, e = target["begin"], target["end"]
if b not in text or e not in text:
return text, False
start = text.index(b) + len(b)
end = text.index(e)
old = text[start:end]
new = "\n" + table + "\n"
if old == new:
return text, False
return text[:start] + new + text[end:], True
def extract_managed(text: str, target: dict):
b, e = target["begin"], target["end"]
if b not in text or e not in text:
return None
start = text.index(b) + len(b)
end = text.index(e)
return text[start:end].strip()
def read_doc(path: pathlib.Path):
"""读文档,返回 (文本, 原换行符, 是否有 BOM)。
本仓库 core.autocrlf=true 且无 .gitattributes,工作区换行混杂
(README=LF、AGENTS=CRLF);带 BOM 文件(App/cfg_file.c 等)另有铁律。
--write 必须**按原样回写**,否则整文件行尾/BOM 漂移 → 巨型无意义 diff。
"""
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_only = body.count(b"\n") - crlf
newline = "\r\n" if crlf > lf_only else "\n"
text = body.decode("utf-8", errors="replace")
# 统一成 \n 供内部处理
text = text.replace("\r\n", "\n").replace("\r", "\n")
return text, newline, bom
def write_doc(path: pathlib.Path, text: str, newline: str, bom: bool):
"""按原换行符 / BOM 状态回写(text 内部为 \n)。"""
out = text if newline == "\n" else text.replace("\n", newline)
data = out.encode("utf-8")
if bom:
data = b"\xef\xbb\xbf" + data
path.write_bytes(data)
def main(argv):
ap = argparse.ArgumentParser(description="FreeRTOS 任务表生成/校验")
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="只打印期望表格")
args = ap.parse_args(argv[1:])
if not FREERTOS_C.exists():
print(f"[FAIL] 找不到事实源:{FREERTOS_C}", file=sys.stderr)
return 1
c_text, _nl, _bom = read_doc(FREERTOS_C)
tasks = parse_tasks(c_text)
if not tasks:
print("[FAIL] 未能从 Src/freertos.c 解析出任何任务,请检查正则与源码格式。",
file=sys.stderr)
return 1
tasks = sort_tasks(tasks)
if args.diff:
for t in TARGETS:
with_role = t["path"].name == "README.md"
print(f"=== {t['path'].name} ===")
print(render_table(tasks, t["header"], with_role))
print()
return 0
changed, missing, problems = [], [], []
for t in TARGETS:
if not t["path"].exists():
problems.append(f"{t['path'].name} 不存在")
continue
text, newline, bom = read_doc(t["path"])
with_role = t["path"].name == "README.md"
table = render_table(tasks, t["header"], with_role)
if args.write:
new_text, did = replace_managed(text, t, table)
if did:
write_doc(t["path"], new_text, newline, bom)
changed.append(t["path"].name)
else:
got = extract_managed(text, t)
if got is None:
missing.append(t["path"].name)
else:
# 提取区已归一为 \n;与期望表格比较时同样归一
if got.replace("\r\n", "\n") != table.strip():
changed.append(t["path"].name)
if args.write:
if missing:
print("[WARN] 以下文档缺少受管锚点,未写入:"
+ ", ".join(missing))
print(f" 请在任务表上下各加一行锚点:\n"
f" {TARGETS[0]['begin']}\n {TARGETS[0]['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 missing or changed:
if missing:
print(f"[FAIL] 以下文档缺少受管锚点 "
f"({TARGETS[0]['begin']} / {TARGETS[0]['end']}):")
for n in missing:
print(f" - {n}")
if changed:
print(f"[FAIL] 以下文档的任务表与 Src/freertos.c 不一致:")
for n in changed:
print(f" - {n}")
print("\n事实源(Src/freertos.c):")
for name, words, prio, _ in tasks:
print(f" {name}: {words} words / {prio}")
print("\n期望表格(README 口径):")
print(render_table(tasks, TARGETS[0]["header"], True))
print("\n修复:运行 `python scripts/gen_task_table.py --write`")
return 1
print(f"任务表一致({len(tasks)} 个任务,"
f"{len(TARGETS)} 个文档)。")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))