Files
STM32F4-Base/scripts/check_cubemx_survival.py
T
wandering1 7a18d2ce28 加 CubeMX 重生成存活审计:防标记区外定制被静默覆盖
背景:.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 全通过。
2026-09-29 07:30:00 +08:00

409 lines
15 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 -*-
"""
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())