7a18d2ce28
背景:.ioc 的 KeepUserCode 只保护 USER CODE 标记区,标记之外的手工改动 重生成即被覆盖,且多数不报编译错(2026-09-29 实测一次覆盖 4 处,含 HardFault_Handler 崩溃采集被打回空 while(1)、两个 FreeRTOS 加固宏被删)。 新增 scripts/check_cubemx_survival.py: - 16 项易失清单(IWDG 延后启动 / 崩溃采集 / 栈溢出检测 / 堆失败钩子 / 断言改造 / 看门狗模块与工程挂载 / 复位原因锁存 / HAL 驱动与使能 / .ioc 关键项),断言前剥掉 C 注释防误报 - 哈希基线漂移检测(.cubemx_survival_baseline.json),--write 重建 - 标记区外存量以 WARN 提示(3 个 Fault 处理函数、栈注释) 已挂 pre-commit cubemx-survival 钩子(改动 .ioc / Src / Inc / task_watchdog / .uvprojx 时触发)。 文档:看门狗设计说明 §10.2 新增"重生成不丢的三层手段"方法论 (L1 写 .ioc > L2 搬进 USER CODE 区(可自造标记区)> L3 审计兜底) 及 .ioc / USER CODE 区可用位置 / 存量清单三张对照表;AGENTS.md 平台约束 新增该规则;pre-commit 使用说明 §2.12 新增钩子详解。 门禁:build 0 错误 0 警告;pre-commit 全通过。
409 lines
15 KiB
Python
409 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
CubeMX 重生成存活审计(check_cubemx_survival.py)
|
||
|
||
模块名称:CubeMX 重生成存活审计
|
||
作者:王建锋
|
||
创建日期:2026-09-29
|
||
|
||
背景
|
||
----
|
||
`.ioc` 的 `ProjectManager.KeepUserCode=true` **只保护** `USER CODE BEGIN/END`
|
||
标记之间的内容。标记之外的任何手工改动,在 CubeMX 重新生成时会被模板覆盖,
|
||
且**多数不会立刻报编译错误**(典型:崩溃采集函数被打回空 while(1)、
|
||
加固宏被整块删除)——只在真出故障时才暴露,排查代价极高。
|
||
2026-09-29 实测:一次重生成覆盖了 4 处定制(P0×1 + P1×2 + P2×1)。
|
||
|
||
本脚本做两件事
|
||
--------------
|
||
1. **存活审计**:检查"重生成易失清单"里的每一项是否仍在预期位置,
|
||
对标记区外的存量给出显式警告(存量无法消除,只能靠人记)。
|
||
2. **漂移检测**:把关键位置的哈希写入 `.cubemx_survival_baseline.json`,
|
||
`--check` 比对,任一项变化即 FAIL——用于"重生成后立刻发现被改了什么"。
|
||
|
||
用法
|
||
----
|
||
python scripts/check_cubemx_survival.py # 审计(人读报告)
|
||
python scripts/check_cubemx_survival.py --check # 门禁模式(默认,供 pre-commit)
|
||
python scripts/check_cubemx_survival.py --write # 重建基线(确认改动无误后)
|
||
|
||
退出码:0 = 通过;1 = 有 FAIL 项。
|
||
|
||
设计依据:docs/子系统设计/看门狗设计说明.md §10.1;AGENTS.md「平台约束」。
|
||
"""
|
||
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
BASELINE = os.path.join(REPO, ".cubemx_survival_baseline.json")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 重生成易失清单
|
||
#
|
||
# kind:
|
||
# "protected" — 位于 USER CODE 标记区内,CubeMX 会保留;审计只验"区还在"
|
||
# "unprotected" — 位于标记区外,CubeMX **会**覆盖;审计给 WARN(存量风险)
|
||
# "generated" — 必须由 CubeMX 生成;审计验"没被删"
|
||
# "ioc" — 必须写在 .ioc 里;否则重生成会丢失(这是根治手段)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
CHECKS = [
|
||
# ---------------- P0:开机死循环防线 ----------------
|
||
dict(
|
||
id="iwdg-defer-start",
|
||
kind="protected",
|
||
path="Src/iwdg.c",
|
||
region="IWDG_DEFER_START",
|
||
must_not_contain=["HAL_IWDG_Init(&hiwdg)"],
|
||
hint="MX_IWDG_Init() 不得调 HAL_IWDG_Init()(内部立即启动,"
|
||
"而它跑在 app_main_init 之前 → 开机必复位)。启动移交 task_wd_start()。",
|
||
),
|
||
# ---------------- P1:崩溃可观测性 ----------------
|
||
dict(
|
||
id="hardfault-report",
|
||
kind="protected",
|
||
path="Src/stm32f4xx_it.c",
|
||
region="HardFault_Handler",
|
||
must_contain=["hard_fault_report"],
|
||
hint="HardFault_Handler 须为手工汇编版(取 MSP/PSP 交 hard_fault_report)。"
|
||
"被打回空 while(1) 后死机将完全静默。",
|
||
),
|
||
dict(
|
||
id="hardfault-report-impl",
|
||
kind="protected",
|
||
path="Src/stm32f4xx_it.c",
|
||
region="HardFault_Report",
|
||
must_contain=["CFSR"],
|
||
hint="HardFault 现场报告实现(打印 PC/LR/xPSR/CFSR/HFSR/BFAR/MMFAR)。",
|
||
),
|
||
# ---------------- P1:FreeRTOS 加固 ----------------
|
||
dict(
|
||
id="stack-overflow-check",
|
||
kind="protected",
|
||
path="Inc/FreeRTOSConfig.h",
|
||
region="FREE_RTOS_CONFIG_HARDENING",
|
||
must_contain=["configCHECK_FOR_STACK_OVERFLOW"],
|
||
hint="栈溢出机械检测必须开启(模式 2)。关闭后溢出静默腐蚀堆相邻块。",
|
||
),
|
||
dict(
|
||
id="malloc-failed-hook",
|
||
kind="protected",
|
||
path="Inc/FreeRTOSConfig.h",
|
||
region="FREE_RTOS_CONFIG_HARDENING",
|
||
must_contain=["configUSE_MALLOC_FAILED_HOOK"],
|
||
hint="堆分配失败钩子必须开启(1)。",
|
||
),
|
||
dict(
|
||
id="assert-not-deadloop",
|
||
kind="protected",
|
||
path="Inc/FreeRTOSConfig.h",
|
||
region="1",
|
||
must_contain=["vApplicationAssertFailed"],
|
||
must_not_contain=["taskDISABLE_INTERRUPTS(); for( ;; );}"],
|
||
hint="configASSERT 须走 vApplicationAssertFailed(打印后复位),"
|
||
"不得回到关中断死循环。",
|
||
),
|
||
dict(
|
||
id="assert-impl",
|
||
kind="protected",
|
||
path="Src/freertos.c",
|
||
region="Application",
|
||
must_contain=["vApplicationAssertFailed"],
|
||
hint="vApplicationAssertFailed() 实现须在 Src/freertos.c 的 USER CODE Application 区。",
|
||
),
|
||
dict(
|
||
id="stack-overflow-hook-impl",
|
||
kind="protected",
|
||
path="Src/freertos.c",
|
||
region="Application",
|
||
must_contain=["vApplicationStackOverflowHook"],
|
||
hint="栈溢出钩子实现(打印任务名后复位)。",
|
||
),
|
||
# ---------------- 看门狗模块 ----------------
|
||
dict(
|
||
id="watchdog-module",
|
||
kind="generated",
|
||
path="App/util/task_watchdog.c",
|
||
must_contain=["task_wd_poll", "IWDG->KR"],
|
||
hint="看门狗模块(App/util/),非 CubeMX 管辖,但须确认未被误删。",
|
||
),
|
||
dict(
|
||
id="watchdog-in-project",
|
||
kind="generated",
|
||
path="MDK-ARM/STM32F407-Demo.uvprojx",
|
||
must_contain=["task_watchdog.c"],
|
||
hint="task_watchdog.c 须挂在 MDK 工程 App 组;CubeMX 重生成可能重建 .uvprojx。",
|
||
),
|
||
dict(
|
||
id="reset-cause-latch",
|
||
kind="protected",
|
||
path="Src/main.c",
|
||
region="Init",
|
||
must_contain=["g_rcc_csr_latched", "RCC_CSR_RMVF"],
|
||
hint="复位原因锁存:HAL_Init() 后立即读 RCC->CSR 并写 RMVF 清除。",
|
||
),
|
||
# ---------------- 必须由 CubeMX 生成(缺 = 配错或误删) ----------------
|
||
dict(
|
||
id="iwdg-hal-driver",
|
||
kind="generated",
|
||
path="Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_iwdg.c",
|
||
hint="HAL IWDG 驱动须由 CubeMX 勾选 IWDG 后生成(手工补入会被重生成删掉)。",
|
||
),
|
||
dict(
|
||
id="hal-iwdg-enabled",
|
||
kind="generated",
|
||
path="Inc/stm32f4xx_hal_conf.h",
|
||
must_contain=["#define HAL_IWDG_MODULE_ENABLED"],
|
||
hint="HAL_IWDG_MODULE_ENABLED 须为**生效**状态(非注释)。",
|
||
),
|
||
# ---------------- .ioc 必须持有(根治:写进 .ioc 才不怕重生成) ----------------
|
||
dict(
|
||
id="ioc-keepusercode",
|
||
kind="ioc",
|
||
key="ProjectManager.KeepUserCode",
|
||
want="true",
|
||
hint="KeepUserCode 必须为 true,否则 USER CODE 区不再受保护。",
|
||
severity="FAIL",
|
||
),
|
||
dict(
|
||
id="ioc-iwdg-present",
|
||
kind="ioc",
|
||
key="IWDG.IPParameters",
|
||
hint="IWDG 须在 .ioc 中激活(否则重生成会删掉 iwdg.c 与 HAL 驱动)。",
|
||
),
|
||
dict(
|
||
id="ioc-task-stacks",
|
||
kind="ioc",
|
||
key="FREERTOS.Tasks01",
|
||
must_contain=["1024", "2048", "4096"],
|
||
hint="任务栈大小必须写在 .ioc(FREERTOS.Tasks01)里——写入 .ioc 的栈值"
|
||
"会被 CubeMX 直接生成到 freertos.c,是最彻底的防丢失手段。",
|
||
),
|
||
]
|
||
|
||
|
||
def read(p):
|
||
try:
|
||
with open(os.path.join(REPO, p), encoding="utf-8", errors="replace") as f:
|
||
return f.read()
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
def region_of(text, name):
|
||
"""取 `/* USER CODE BEGIN <name> */` 与 `/* USER CODE END <name> */` 之间的内容。
|
||
|
||
实测本项目生成文件的标记形态是**单行注释**:
|
||
/* USER CODE BEGIN HardFault_Handler */
|
||
... 内容 ...
|
||
/* USER CODE END HardFault_Handler */
|
||
(不是 `/* USER CODE BEGIN x` 换行再 `*/`)。故按整行匹配。
|
||
"""
|
||
m = re.search(
|
||
r"/\* USER CODE BEGIN %s \*/\n(.*?)\n[^\n]*/\* USER CODE END %s \*/"
|
||
% (re.escape(name), re.escape(name)),
|
||
text,
|
||
re.S,
|
||
)
|
||
return m.group(1) if m else None
|
||
|
||
|
||
def sha(s):
|
||
return hashlib.sha256(s.encode("utf-8")).hexdigest()[:16]
|
||
|
||
|
||
def strip_comments(code):
|
||
"""去掉 C 注释,避免把注释里提到的禁用符号误判为真实调用。
|
||
|
||
本项目多处注释**刻意**引用"原为 XX 直呼"之类的历史信息(如 Src/iwdg.c
|
||
的 IWDG_DEFER_START 区),若不过滤注释,`must_not_contain` 会恒定误报。
|
||
"""
|
||
code = re.sub(r"/\*.*?\*/", " ", code, flags=re.S)
|
||
code = re.sub(r"//[^\n]*", " ", code)
|
||
return code
|
||
|
||
|
||
def evaluate():
|
||
"""返回 (results, snapshot)。results = [(level, id, msg, hint)]"""
|
||
results = []
|
||
snap = {}
|
||
ioc = read("STM32F407-Demo.ioc") or ""
|
||
|
||
for c in CHECKS:
|
||
cid = c["id"]
|
||
path = c.get("path")
|
||
level = c.get("severity", "FAIL")
|
||
|
||
# ---- .ioc 类 ----
|
||
if c["kind"] == "ioc":
|
||
key = c["key"]
|
||
m = re.search(r"^%s=(.*)$" % re.escape(key), ioc, re.M)
|
||
if not m:
|
||
results.append((level, cid, ".ioc 缺少 %s" % key, c["hint"]))
|
||
snap[cid] = None
|
||
continue
|
||
val = m.group(1)
|
||
snap[cid] = sha(val)
|
||
if "want" in c and val.strip().lower() != c["want"].lower():
|
||
results.append((level, cid, ".ioc %s = %r(期望 %r)" % (key, val, c["want"]), c["hint"]))
|
||
continue
|
||
for needle in c.get("must_contain", []):
|
||
if needle not in val:
|
||
results.append(("WARN", cid, ".ioc %s 中未见 %r" % (key, needle), c["hint"]))
|
||
break
|
||
else:
|
||
results.append(("OK", cid, ".ioc %s 就位" % key, ""))
|
||
continue
|
||
|
||
text = read(path)
|
||
if text is None:
|
||
results.append((level, cid, "文件不存在:%s" % path, c["hint"]))
|
||
snap[cid] = None
|
||
continue
|
||
|
||
# ---- 取待检内容 ----
|
||
if c["kind"] == "protected":
|
||
body = region_of(text, c["region"])
|
||
if body is None:
|
||
results.append(
|
||
(level, cid, "%s 的 USER CODE BEGIN/END %s 区不存在" % (path, c["region"]), c["hint"])
|
||
)
|
||
snap[cid] = None
|
||
continue
|
||
probe = strip_comments(body) # 只对保护区做注释过滤
|
||
else:
|
||
body = text
|
||
probe = text
|
||
|
||
snap[cid] = sha(probe)
|
||
|
||
# ---- 断言 ----
|
||
bad = [n for n in c.get("must_contain", []) if n not in probe]
|
||
bad += [n for n in c.get("must_not_contain", []) if n in probe]
|
||
if bad:
|
||
results.append(
|
||
(level, cid, "%s:%s" % (c["region"] if c["kind"] == "protected" else path,
|
||
"缺失/命中禁用项 " + ", ".join(repr(b) for b in bad)),
|
||
c["hint"])
|
||
)
|
||
else:
|
||
results.append(("OK", cid, "%s 就位" % (c["region"] if c["kind"] == "protected" else path), ""))
|
||
|
||
# ---- 标记区外的存量风险(无法自动修复,只提示) ----
|
||
for path, needles in UNPROTECTED_LEGACY:
|
||
text = read(path) or ""
|
||
hits = []
|
||
for name, pat in needles:
|
||
if re.search(pat, text):
|
||
hits.append(name)
|
||
if hits:
|
||
results.append((
|
||
"WARN",
|
||
"legacy:" + path,
|
||
"标记区外手工内容(重生成会覆盖):" + "、".join(hits),
|
||
"这些内容 CubeMX 不保护。核对清单见 docs/子系统设计/看门狗设计说明.md §10.1。",
|
||
))
|
||
|
||
return results, snap
|
||
|
||
|
||
# 已知的"标记区外"手工内容(存量,只能人工盯)
|
||
UNPROTECTED_LEGACY = [
|
||
(
|
||
"Src/freertos.c",
|
||
[
|
||
("任务栈注释(.stack_size 行内中文说明)", r"\.stack_size\s*=\s*\d+\s*\*\s*4"),
|
||
],
|
||
),
|
||
(
|
||
"Src/stm32f4xx_it.c",
|
||
[
|
||
("MemManage_Handler 空 while(1)", r"void MemManage_Handler\(void\)"),
|
||
("BusFault_Handler 空 while(1)", r"void BusFault_Handler\(void\)"),
|
||
("UsageFault_Handler 空 while(1)", r"void UsageFault_Handler\(void\)"),
|
||
],
|
||
),
|
||
]
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="CubeMX 重生成存活审计")
|
||
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("--report", action="store_true", help="只打印审计报告,不比对基线")
|
||
args = ap.parse_args()
|
||
|
||
results, snap = evaluate()
|
||
|
||
fails = [r for r in results if r[0] == "FAIL"]
|
||
warns = [r for r in results if r[0] == "WARN"]
|
||
oks = [r for r in results if r[0] == "OK"]
|
||
|
||
# ---------- 漂移检测 ----------
|
||
drift = []
|
||
if not args.write and not args.report:
|
||
if os.path.exists(BASELINE):
|
||
try:
|
||
with open(BASELINE, encoding="utf-8") as f:
|
||
base = json.load(f).get("snapshot", {})
|
||
except (OSError, ValueError):
|
||
base = {}
|
||
for k, v in snap.items():
|
||
if k in base and base[k] != v and base[k] is not None and v is not None:
|
||
drift.append(k)
|
||
# 基线缺失不算失败(换机/首次),但提示
|
||
|
||
# ---------- 输出 ----------
|
||
print("CubeMX 重生成存活审计")
|
||
print(" 事实源:.ioc (KeepUserCode) + 各生成文件的 USER CODE 标记区")
|
||
print(" 通过 %d / 警告 %d / 失败 %d" % (len(oks), len(warns), len(fails)))
|
||
print()
|
||
|
||
if fails:
|
||
print("[FAIL] 以下内容丢失或配置错误(重生成后必须立刻修):")
|
||
for _, cid, msg, hint in fails:
|
||
print(" - %-26s %s" % (cid, msg))
|
||
if hint:
|
||
print(" → %s" % hint)
|
||
print()
|
||
|
||
if warns:
|
||
print("[WARN] 提示(多为标记区外存量,无法自动消除):")
|
||
for _, cid, msg, hint in warns:
|
||
print(" - %-26s %s" % (cid, msg))
|
||
print()
|
||
|
||
if drift:
|
||
print("[FAIL] 基线漂移(这些位置的内容与上次确认的不同):")
|
||
for k in drift:
|
||
print(" - %s" % k)
|
||
print(" → 若是本次有意改动,确认无误后跑 `--write` 重建基线;")
|
||
|
||
if args.write:
|
||
with open(BASELINE, "w", encoding="utf-8") as f:
|
||
json.dump(
|
||
{"snapshot": snap, "_note": "CubeMX 存活审计基线;改动用 --write 重建"},
|
||
f,
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
)
|
||
f.write("\n")
|
||
print("已重建基线:%s" % os.path.relpath(BASELINE, REPO))
|
||
return 0
|
||
|
||
if fails or drift:
|
||
return 1
|
||
print("通过:重生成易失清单全部就位。")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|