Files
STM32F4-Base/scripts/check_cubemx_survival.py
T
wandering1 8a18e388c7 四个 Fault 全部采集现场:MemManage/BusFault/UsageFault 由空死循环升级
原状:HardFault 已有崩溃采集,但 MemManage / BusFault / UsageFault 仍是空
while(1) —— 这三类故障发生时日志完全静默,且因 SHCSR 默认未使能,实际会
被升级成 HardFault,报告出笼统的 "HARDFAULT"、看不出根因。

改动:

- Src/stm32f4xx_it.c:三个 Handler 升级为手工 __asm 版(取 MSP/PSP 帧指针)
  -> B 到 per-fault C 入口 fault_entry_{mem,bus,usage} -> hard_fault_report()。
  hard_fault_report 增加 tag 参数,日志统一为
  "[ERR] <TAG> PC=.. LR=.. xPSR=.. R0-R3=.. CFSR=.. HFSR=..[/BFAR][/MMFAR]"。
  生成实体经 #define X X_Gen 改名保留,链接器各移除 2 字节(.map 已验)。
  三个各带独立锚点 MEMFAULT/BUSFAULT/USAGEFAULT-ANCHOR。
- 新增 fault_handler_enable()(USER CODE BEGIN 1 区):使能 SHCSR 的
  MEMFAULTENA/BUSFAULTENA/USGFAULTENA。声明入 Inc/stm32f4xx_it.h,调用在
  app_main_init() 第一行(纯寄存器写,不依赖日志/时钟)。
- 刻意不启用 CCR.UNALIGN_TRP / CCR.DIV_0_TRP:会把普通 bug 升级成死机,
  对本项目弊大于利(说明写进函数注释)。

实现约束(本轮踩坑):ARMCC 的 __asm 块取不到 C 的数据符号(ADR r1, xxx_tag
报 A1516E: Bad symbol,去 static 也无效),也无法给 C 函数传字符串参数,
故故障名必须由 C 侧 per-fault 入口带上。

配套:

- check_cubemx_survival.py:新增 fault-handlers-3 / fault-handler-enable 两项,
  UNPROTECTED_LEGACY 清掉三个 Fault(WARN 2->1),清单 16 -> 18 项。
- AGENTS.md:新增"4 个 Fault 全部采集"条目;ARMCC 汇编条目补 A1516E 第 3 坑;
  校验标记补 [ERR](四个 Fault 日志都以它开头 -> 一律判 FAIL,刻意)。
- 看门狗设计说明.md:§6.1 补 4 条 Fault 标记与 @test 禁项关系;
  §10.2 L3 清单与四个 Fault 采集小节;§11 结论表与变更记录。
- 待处理内容.md:新增 N-03d(已完成),N-03c 补第二次重生成结论。

验证:@build 0 错误 0 警告(Flash +156 B,RAM 无变化);pre-commit 13 钩子
全通过;审计 18 通过 / 1 警告 / 0 失败。
2026-09-29 09:16:19 +08:00

479 lines
19 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。"
),
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 <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())