#!/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。" ), 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 加固 ---------------- 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"], 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,是最彻底的防丢失手段。", ), ] 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 */` 与 `/* USER CODE END */` 之间的内容。 实测本项目生成文件的标记形态是**单行注释**: /* 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())