Files
STM32F4-Base/scripts/check_cubemx_survival.py
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名,
不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体
run_boot_test.py 的真实调用。

- AGENTS.md:别名说明重写为「技能调用名不是 shell 命令」,两条目改
  「编译(/keil-build)」「串口回归(/serial-boot-test)」
- keil-build / serial-boot-test 两 skill 调用名与示例同步;task-worktree
  方案文档去掉 @build/@test 惯例从句;@task 保留(不在点名范围)
- 123 文件现状陈述全改;台架捕获、变更记录/时间线、rename 映射输入
  等 41 行历史证据保留旧名
- gen_memory_svg.py 6 处措辞随改,3 张 SVG 产物同批重跑
- 顺手补并行方案同行漏网计数 51→52 钩子

验证:提交期快钩子对 123 文件实跑全 Passed;check_doc_links --all
rc=0(3038 处、无法判定 0);check_mermaid --all rc=0;YAML 解析 52
钩子不变;skill-size rc=0;py_compile 全过;字节卫生零漂移。
2026-10-30 13:03:12 +08:00

614 lines
28 KiB
Python
Raw Permalink 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.2/§10.3;AGENTS.md「平台约束」。
注意
----
`MDK-ARM/*.uvprojx` **不参与漂移检测**(`no_baseline=True`):该文件每次
`/keil-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:开机死循环防线 ----------------
# ✅ 2026-10-09 由「审计兜底」升级为「搬进 USER CODE 区」:不再删生成区那句 `HAL_IWDG_Init(&hiwdg)`,
# 而是在 `USER CODE BEGIN 0` 里放一个空实现 + `#define HAL_IWDG_Init(a_h)
# iwdg_deferred_start(a_h)`,把本编译单元里那句生成调用改名到空实现。
# → 生成区保持原样、重生成**零 diff**,不必再手工删那一句。
# ⚠ "反初始化"不可行:IWDG 启动后无法停止(唯一手段是复位),故只能拦截启动本身。
# 判据用区内的代码符号(protected 项会剥掉注释再比对,锚点注释不能当断言)。
dict(
id="iwdg-defer-start",
kind="protected",
path="Src/iwdg.c",
region="0",
must_contain=["iwdg_deferred_start", "#define HAL_IWDG_Init"],
severity="FAIL",
hint="MX_IWDG_Init() 不得真正启动 IWDG(HAL_IWDG_Init 内部立即启动,而它跑在 "
"app_main_init 之前 → 开机必复位)。现由 `USER CODE BEGIN 0` 的空实现 + 宏改名"
"拦截,启动移交 task_wd_start();丢了宏 = 启动段看门狗就跑起来 = 开机死循环。",
),
# ---------------- P1:崩溃可观测性 ----------------
# ⚠ 2026-09-29 重要变更:CubeMX 把本文件的中断函数模板**换了一代**——
# 旧模板在函数前后给自造区(HardFault_Handler / HardFault_Report),
# 新模板改成函数体**内部**的 `HardFault_IRQn 0` / `W1_HardFault_IRQn 0`。
# 实测"生成区之外的自造标记区"**不会被保留**。
# ✅ 2026-10-09 落点升级为「搬进 USER CODE 区」(与 iwdg 同一招):4 个 `__asm` Handler 与报告实现
# 全部放 `USER CODE BEGIN 0`(标准保护位),生成区恢复 CubeMX 原样;
# 生成实体靠 `#define X X_Gen` 改名让位(未被引用 → 链接器自动移除)。
# ⚠ 顺序硬约束:我们的定义必须在 4 个 `#define` **之前**,否则被自己的宏改名。
# 判据用区内的代码符号(protected 项比对前会剥掉注释,锚点注释不能当断言)。
dict(
id="hardfault-handler",
kind="protected",
path="Src/stm32f4xx_it.c",
region="0",
must_contain=[
"__asm void HardFault_Handler", # 手工汇编 Handler(取 MSP/PSP)
"fault_entry_hard", # 跳转目标(C 侧带故障名)
"#define HardFault_Handler HardFault_Handler_Gen", # 给生成实体让位
],
hint="HardFault_Handler 须为手工汇编版(取 MSP/PSP 交 fault_entry_hard),"
"生成实体改名 HardFault_Handler_Gen。被打回空 while(1) 后死机将完全静默。"
"恢复位置 = `USER CODE BEGIN 0`,且必须在 4 个 #define 之前。",
),
# MemManage / BusFault / UsageFault 三个同样升级为崩溃采集(同文件、同方案,
# 由一项统一检查,避免 3 条重复项)。
dict(
id="fault-handlers-3",
kind="protected",
path="Src/stm32f4xx_it.c",
region="0",
must_contain=[
"__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 区内(生成区内的标准保护位)。",
),
# Error_Handler 统一降级(2026-10-12;详述见看门狗设计说明.md §4.4)。函数体本身在 CubeMX 的
# `USER CODE BEGIN/END Error_Handler_Debug` 区内(USER CODE 区受保护),重生成不丢;
# 本项是审计兜底:被打回模板默认(__disable_irq+while(1) 静默挂死)**不报编译错**,
# 只在真出外设初始化故障时暴露——整机只剩 IWDG 兜底且无自检日志。
dict(
id="error-handler-degrade",
kind="protected",
path="Src/main.c",
region="Error_Handler_Debug",
must_contain=["DBG_ERROR", "NVIC_SystemReset"],
must_not_contain=["__disable_irq", "__builtin_return_address"],
hint="Error_Handler 必须打 1 条自检日志后 NVIC_SystemReset(),不得回到 "
"__disable_irq()+while(1) 静默挂死(无日志、只剩 IWDG 兜底)。"
"⚠ 勿用 __builtin_return_address:AC5 编成库调用,MicroLIB 无符号 = L6218E(2026-10-12 实证)。"
"恢复位置 = `USER CODE BEGIN Error_Handler_Debug` 区。",
),
# ---------------- P1:FreeRTOS 加固 ----------------
# ⚠ 2026-10-09 升级:这两项原先靠 FreeRTOSConfig.h 里**自造**的
# `USER CODE BEGIN FREE_RTOS_CONFIG_HARDENING` 区——模板不认这个区名,
# 重生成连标记带内容一起消失(实测两次)。现已改为 **写进 .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, # 见下方说明:本文件每次 /keil-build 后被 Keil 改写
hint="task_watchdog.c 须挂在 MDK 工程 App 组;CubeMX 重生成可能重建 .uvprojx。",
),
dict(
id="net-msgq-adopt",
kind="protected",
path="Src/freertos.c",
region="RTOS_QUEUES",
must_contain=["net_msg_queue_adopt"],
hint="消息队列句柄的唯一使用者是 net_socket,创建后必须在 USER CODE BEGIN RTOS_QUEUES 区"
"绑定给它(替代此前 BSP 对该全局变量的裸 extern)。丢了这一行,net_socket 的句柄"
"恒为 NULL → 所有 net_* 请求被判空丢弃,网络全线静默不通(无崩溃、无报错日志)。",
),
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 键,重生成也只会回落到约束允许的值。
# ADC1 上电自校准(2026-10-10 补):RM0090 §13.3.6 的**必需**步骤,CubeMX **不生成**,
# 且本仓库裁剪版 HAL **没有** HAL_ADCEx_Calibration_Start → 只能手写(搬进 USER CODE 区),
# 故必须在此兜底。丢了它不报编译错,只让 ADC 带失调误差(难从日志看出)。
dict(
id="adc1-calibration-call",
kind="protected",
path="Src/adc.c",
region="ADC1_Init 2",
must_contain=["adc1_calibrate("],
hint="MX_ADC1_Init() 末尾必须调用 adc1_calibrate()(F4 ADC 上电自校准,CubeMX 不生成)。"
"丢了它 = ADC 未校准 → 存在失调误差,且不会报任何编译/运行错误。",
),
dict(
id="adc1-calibration-impl",
kind="protected",
path="Src/adc.c",
region="1",
must_contain=["int adc1_calibrate(void)", "ADC1_CAL_BIT", "ADC_CR2_ADON"],
hint="自校准实现(清 ADON→等生效→置 ADON→置 CAL→等 CAL 由硬件清零)须在 "
"`USER CODE BEGIN 1` 区。本仓库 HAL 无 HAL_ADCEx_Calibration_Start、"
"CMSIS 头无 ADC_CR2_CAL,两者都是裁剪造成的,勿以为『标准 HAL 里有』。",
),
# ---------------- ADC 采样节拍 + DMA(TIM3 + DMA2_Stream4) ----------------
# 节拍源 TIM3:同一个更新事件 ① ISR 脉冲 TPAFE CONVST ② 经 TRGO 触发 ADC1
# ③ 置位 ADC_WAKE_FLAG。丢了 ② 不报编译错,只是两片转换器不再同源(相位差自由漂移)。
dict(
id="adc-tick-tim3-branch",
kind="protected",
path="Src/main.c",
region="Callback 1",
must_contain=["TIM3", "tpafe5160_start_conv_irq()", "adc_tick_irq_notify()"],
hint="HAL_TIM_PeriodElapsedCallback 的 TIM3 分支必须同时做三件事:判 TIM3、脉冲 CONVST、"
"通知消费者。⚠ 节拍源**不能**换回 TIM6/TIM7——F4 上 ADC1 的外部触发清单里没有 "
"TIM6/TIM7 TRGO(只接 DAC),TIM3 的 TRGO 才是 ADC1 能收到的那个。",
),
dict(
id="ioc-adc-trig-t3trgo",
kind="ioc",
key="ADC1.ExternalTrigConv",
want="ADC_EXTERNALTRIGCONV_T3_TRGO",
hint="ADC1 外部触发必须是 T3_TRGO(与节拍源 TIM3 配对)。改回软件触发 = 每拍由任务启动"
"转换,DMA 与 TPAFE 不再同源对齐,且重新引入忙等。",
severity="FAIL",
),
dict(
id="ioc-adc-dma-continuous",
kind="ioc",
key="ADC1.DMAContinuousRequests",
want="ENABLE",
hint="DMAContinuousRequests(DDS 位)必须 ENABLE:关掉后一轮 9 通道只发**一次** DMA 请求"
"→ 静默丢 8 路,不报任何编译/运行错误。",
severity="FAIL",
),
dict(
id="adc1-dma-link",
kind="generated",
path="Src/adc.c",
must_contain=["hdma_adc1", "__HAL_LINKDMA", "DMA2_Stream4", "DMA_CIRCULAR"],
hint="ADC1 的 DMA 挂载(Stream4 + 循环模式 + __HAL_LINKDMA)须在位。"
"ADC1 在 DMA2 上只挂 Stream0 或 Stream4;选 Stream0 会连锁搬走 SPI1_RX/USART1_RX。",
),
dict(
id="adc1-dma-irq-priority",
kind="generated",
path="Src/dma.c",
must_contain=["HAL_NVIC_SetPriority(DMA2_Stream4_IRQn, 6, 0)"],
hint="⚠ CubeMX 在**换 DMA 流**时会把该流的 NVIC 优先级静默重置为 **0**(实测两轮)。"
"优先级 0 高于 configMAX_SYSCALL_INTERRUPT_PRIORITY(5) → 会抢占 FreeRTOS 临界区,"
"违反本仓『外设中断 ≥5』。每次改 DMA 流后必须回 NVIC 页核对。",
severity="FAIL",
),
]
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`
# 每次 `/keil-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.3。",
))
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", newline="\n") 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())