Files
STM32F4-Base/scripts/check_cubemx_survival.py
T

521 lines
22 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「平台约束」。
注意
----
`MDK-ARM/*.uvprojx` **不参与漂移检测**(`no_baseline=True`):该文件每次
`@build` 后被 Keil UV4 自动改写(XML 格式归一化 + `PackID` 降级到本机 DFP
版本),内容每次构建都不同。若参与检测,则**每次构建后提交都会被拦**。
本项只保留 `must_contain` 断言("源文件仍挂载")。
"""
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:开机死循环防线 ----------------
# ⚠ 这一项是**生成区内的手工改动**(无 USER CODE 区可用):
# 需要改的是 `hiwdg.Init.*` 赋值与 `HAL_IWDG_Init()` 调用之间的位置,
# 而 CubeMX 只在该调用**之前**(IWDG_Init 0/1)和**之后**(IWDG_Init 2)
# 生成标记区——正中间没有。塞进 IWDG_Init 1 区也不能阻止区外那行被覆盖
# (另见 `usr init 1` 区的注释)。
# 故本项按"文件整体 + 特征串"检查,并靠一个稳定锚点注释定位。
dict(
id="iwdg-defer-start",
kind="wholefile",
path="Src/iwdg.c",
must_contain=["IWDG-DEFER-START-ANCHOR"], # 手工锚点注释,重生成后会消失
must_not_contain=["HAL_IWDG_Init(&hiwdg)"], # 真实调用不得存在
severity="FAIL",
hint="MX_IWDG_Init() 不得调 HAL_IWDG_Init()(内部立即启动,"
"而它跑在 app_main_init 之前 → 开机必复位)。启动移交 task_wd_start()。"
"恢复后补回 `IWDG-DEFER-START-ANCHOR` 锚点注释。",
),
# ---------------- P1:崩溃可观测性 ----------------
# ⚠ 2026-09-29 重要变更:CubeMX 把本文件的中断函数模板**换了一代**——
# 旧模板在函数前后给自造区(HardFault_Handler / HardFault_Report),
# 新模板改成函数体**内部**的 `HardFault_IRQn 0` / `W1_HardFault_IRQn 0`。
# 实测"生成区之外的自造标记区"**不会被保留**。故现在的落点是:
# Handler 实体 → 生成区内整函数替换(无 USER CODE 位可用),
# 靠 `HARD-FAULT-ANCHOR` 锚点注释检出丢失;
# 生成实体用 `#define HardFault_Handler HardFault_Handler_Gen`
# 改名保留,链接器自动移除(map 已验证 2 bytes removed)
# 报告实现 → `USER CODE BEGIN 0`(生成区内的标准保护位,重生成不丢)
dict(
id="hardfault-handler",
kind="wholefile",
path="Src/stm32f4xx_it.c",
must_contain=[
"HARD-FAULT-ANCHOR", # 手工锚点注释,重生成后会消失
"__asm void HardFault_Handler", # 手工汇编 Handler
"fault_entry_hard", # 跳转目标(C 侧带故障名)
],
must_not_contain=["HardFault_Handler(void)\r\n{\r\n while (1)"],
hint="HardFault_Handler 须为手工汇编版(取 MSP/PSP 交 fault_entry_hard),"
"生成实体改名 HardFault_Handler_Gen。被打回空 while(1) 后死机将完全静默。"
"恢复后补回 `HARD-FAULT-ANCHOR` 锚点注释。",
),
# ⚠ 2026-09-29:MemManage / BusFault / UsageFault 三个也升级为崩溃采集。
# 模板形态与 HardFault 相同(整函数重写、函数体外无标记区)→ 同一套方案:
# 生成区整函数替换 + 独立锚点 + `#define X X_Gen` 改名保留生成实体。
# 由一项统一检查(同文件、同 kind),避免 4 条重复项。
dict(
id="fault-handlers-3",
kind="wholefile",
path="Src/stm32f4xx_it.c",
must_contain=[
"MEMFAULT-ANCHOR",
"BUSFAULT-ANCHOR",
"USAGEFAULT-ANCHOR",
"__asm void MemManage_Handler",
"__asm void BusFault_Handler",
"__asm void UsageFault_Handler",
"fault_entry_mem",
"fault_entry_bus",
"fault_entry_usage",
],
hint="MemManage/BusFault/UsageFault 须为手工汇编版(各跳 fault_entry_* 打故障名)。"
"被打回空 while(1) 后这三类故障会被升级成 HardFault 且看不出根因。",
),
dict(
id="fault-handler-enable",
kind="protected",
path="Src/stm32f4xx_it.c",
region="1",
must_contain=["fault_handler_enable", "SCB_SHCSR_MEMFAULTENA_Msk"],
hint="MemManage/BusFault/UsageFault 默认**禁用**,须在 SHCSR 置使能位才会独立触发;"
"否则它们被升级成 HardFault、日志只显示 HARDFAULT。"
"⚠ 这三位**无法在 CubeMX 配置**(2026-10-01 查模板源码证实:System Exception "
"只生成优先级、从不生成使能代码;.ioc 里的 NVIC.xxx=true 只是'向量可见',"
"不代表已使能)→ 只能手工写,故必须在本审计清单里兜底。"
),
dict(
id="hardfault-report-impl",
kind="protected",
path="Src/stm32f4xx_it.c",
region="0",
must_contain=["hard_fault_report", "CFSR"],
hint="崩溃现场报告实现(打印 PC/LR/xPSR/CFSR/HFSR/BFAR/MMFAR)"
"须在 USER CODE BEGIN 0 区内(生成区内的标准保护位)。",
),
# ---------------- P1:FreeRTOS 加固 ----------------
# ⚠ 2026-10-09 升级:这两项原先靠 FreeRTOSConfig.h 里**自造**的
# `USER CODE BEGIN FREE_RTOS_CONFIG_HARDENING` 区——模板不认这个区名,
# 重生成连标记带内容一起消失(实测两次)。现已改为 **L1 写进 .ioc**:
# GUI = FreeRTOS 页 → "Config parameters" 标签 → "Hook function related
# definitions" 组 → CHECK_FOR_STACK_OVERFLOW=2 / USE_MALLOC_FAILED_HOOK=1,
# 由 CubeMX 模板自己生成到 FreeRTOSConfig.h,永不被打回。
dict(
id="stack-overflow-check",
kind="ioc",
key="FREERTOS.configCHECK_FOR_STACK_OVERFLOW",
want="2",
hint="栈溢出机械检测(模式 2)必须在 .ioc 里(GUI: FreeRTOS → Config parameters → "
"Hook function related definitions)。关闭后溢出静默腐蚀堆相邻块。",
),
dict(
id="malloc-failed-hook",
kind="ioc",
key="FREERTOS.configUSE_MALLOC_FAILED_HOOK",
want="1",
hint="堆分配失败钩子必须在 .ioc 里(同上 GUI 位置)。",
),
# ⚠ 2026-10-09 连带坑与最终落点:在 GUI 里启用这两个钩子后,CubeMX 会在 freertos.c
# 生成 `USER CODE BEGIN 4 / 5` 区(默认内容是空桩)。若把我们的实现留在
# `Application` 区、又留着生成桩 → **同名函数两处定义 = 编译错**。
# 最终落点(比"每次删桩"更稳):**整个钩子函数写进 4 / 5 区**——区名由 CubeMX
# 生成、且其存在由 `.ioc` 启用钩子保证,重生成时 CubeMX 只保留区内容、不再补桩。
# 配套 helper `hook_ptr_ok()` 必须在钩子之前定义,故放 `USER CODE BEGIN PD` 区。
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="4",
must_contain=["vApplicationStackOverflowHook", "STACK OVERFLOW", "NVIC_SystemReset"],
hint="栈溢出钩子实现(裸 UART 打任务名后复位)须**整体**写在 `USER CODE BEGIN 4` 区内。",
),
dict(
id="malloc-failed-hook-impl",
kind="protected",
path="Src/freertos.c",
region="5",
must_contain=["vApplicationMallocFailedHook", "MALLOC FAILED"],
hint="堆分配失败钩子实现(只告警不复位)须**整体**写在 `USER CODE BEGIN 5` 区内。",
),
dict(
id="stack-overflow-hook-helper",
kind="protected",
path="Src/freertos.c",
region="PD",
must_contain=["hook_ptr_ok"],
hint="任务名指针范围校验 helper 须在钩子之前定义(现放 `USER CODE BEGIN PD` 区);"
"缺了它,钩子会因不可信 TCB 指针二次崩溃(2026-09-24 实测 HardFault)。",
),
# ---------------- 看门狗模块 ----------------
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"],
no_baseline=True, # 见下方说明:本文件每次 @build 后被 Keil 改写
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,是最彻底的防丢失手段。",
),
# ---------------- ADC1(2026-10-09 首次启用) ----------------
dict(
id="adc1-hal-enabled",
kind="generated",
path="Inc/stm32f4xx_hal_conf.h",
must_contain=["#define HAL_ADC_MODULE_ENABLED"],
hint="HAL_ADC_MODULE_ENABLED 须为**生效**状态(非注释),否则 adc.c 不参与编译。",
),
# 注:ADC 时钟分频**不设防护**(2026-10-09 决定)。CubeMX 面板里 DIV2 已被约束禁用
# (FadcConstraint Max=36000000;APB2=84 MHz → DIV2=42 MHz 超限,选不到),
# .ioc 虽不保存 ADC1.ClockPrescaler 键,重生成也只会回落到约束允许的值。
]
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
# ---- 取待检内容 ----
# `probe_neg`:用于 must_not_contain(禁项)——**总是剥注释**,
# 避免"注释里提到历史代码"被误判为真实调用。
# `probe` :用于 must_contain(正项)——`wholefile` 模式下不剥注释,
# 因为锚点本身可能就是注释(如 IWDG-DEFER-START-ANCHOR)。
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)
probe_neg = probe
elif c["kind"] == "wholefile":
body = text
probe = text # 正项:整文件原文(锚点可能是注释)
probe_neg = strip_comments(text) # 禁项:仍剥注释
else:
body = text
probe = text
probe_neg = text
# ---- 基线快照 ----
# `no_baseline=True` 的项不参与漂移检测。理由:`MDK-ARM/*.uvprojx`
# 每次 `@build` 后被 Keil UV4 自动改写(XML 格式归一化 + PackID 降级到
# 本机 DFP 版本),内容每次构建都不同 → 若参与漂移检测,则**每次构建后
# 提交都会被拦**。本项只保留 must_contain 断言("源文件仍挂载")。
if not c.get("no_baseline"):
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_neg]
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",
[
("timeSyncTask 手工定义(不在 .ioc FREERTOS.Tasks01 里)", r"timeSyncTask_attributes"),
],
),
]
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())