Files
STM32F4-Base/scripts/check_registration.py
T
wandering1 484d068250 feat(存储): 写回缓存空闲 flush 兜底——卷空闲周期 CTRL_SYNC(登记簿 N-22 收口)
道F 批 2 / task-16。FTL 单页写回缓存(nand_ftl.c s_cache_dirty)与 dhara
journal 只在换页/整页写/f_close 时刷出,漏网脏页的掉电驻留窗无上限。本
提交以最小面收口:不碰 GD5F/dhara 驱动,全部走既有 FatFs CTRL_SYNC 路径。

实现 App/fs/vol_idle_sync.[ch](新增,仅 MCU 端,uvprojx App 组注册):
- 四级门:STORAGE_READY 就绪门 → 5 s 节流(无符号差回绕安全判据)→
  vol_lock_wait(0) try(锁被持有=文件序列在做,跳过,不改 STOR 持有期
  语义)→ disk_ioctl(0, CTRL_SYNC);
- clean 态脏标志+journal 双短路、零 NAND 触点;脏态最坏 = 一次 f_close
  的同步段同型成本,不引入新磨损来源;
- 失败按边沿 latch 一条 DBG_ERROR、成功重新武装;失败路径也成对放锁;
- TEST_SUITE_STORAGE 构建整体 no-op(卷由 testTask 独占)。
接线:defaultTask 主循环 syslog_drain 之后(default_task.c v1.3)。

文档:docs/架构设计/STM32F4-Base存储架构说明.md §5.5 补空闲兜底与边界;
本板无掉电检测硬件、不做掉电前强制 flush、勿当掉电保护——口径同步落
注释与文档(sys_log_mcu.c 两处旧「暂不做」注释随实现改口)。

测试:新主机单测 test/host/vol_idle_sync/(TC-VOLSYNC-001..012,19 断言
全过);scripts/id_registry.json 建 TC-HOST-VOLSYNC 族(仿 ADCCAL 先例);
.pre-commit-config.yaml 加 vol-idle-sync-unit-test hook(只加自己条目)。

越界申报(①②③ 均 lead 批准,④ 为门禁连带必要):
① App/task/default_task.c 单点接线(include + 1 调用 + 头修改记录);
② docs/架构设计/STM32F4-Base存储架构说明.md §5.5;
③ .pre-commit-config.yaml 单 hook + scripts/id_registry.json 单族;
④ scripts/check_registration.py SIM_EXEMPT 加一行(vol_idle_sync.c 模拟器
   侧豁免,registration-check GAP 所需;与批准③同类别但未列名,特此申报)。

体积增量(vs scripts/ram_baseline.tsv 2026-10-25 行):
Flash(Code+RO) 237140→237328(+188 B)、RW 1224→1232(+8 B)、ZI 0;
增量 = 3 个文件级 static + 一处 DBG_ERROR 调用点;栈/堆无变化。

gate: only mem-budget/mem-numbers pending lead ledger backfill
全量 pre-commit --all-files:40 项 Passed,仅 mem-numbers(D-7 体积档)与
mem-budget 两条红 = 台账数字待回填(增量如上,属 lead 单写者禁区);
其余含 doc-links/id-check/registration/test-coverage/全部主机单测皆绿。
@build 0 错误 0 警告(Code=215932 RO=21396 RW=1232 ZI=131984)。

台架:空闲 sync 实机节奏(clean 零触点、脏态时长、无心跳扰动)属实际
测量采样类——本批跳过,留「待台架实测」。
2026-10-25 09:23:20 +08:00

388 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 -*-
"""check_registration.py - 双端注册反向校验门禁(纯标准库)。
背景(登记簿 N-23,出处审查报告 N-06 残余):
本工程 Keil 工程与 PC 模拟器都用**显式文件列表**(无通配扫描)。新 `.c`
必须手动注册两处才会被编译:
① `MDK-ARM/STM32F407-Demo.uvprojx` 的 `<FilePath>` 条目(MCU 固件构建)
② `simulator/CMakeLists.txt` 的显式源列表(PC 模拟器构建)
历史上 `App/task/rs485_task.c`、`Drivers/BSP/NET/lftpd/lftpd_log.c` **两处都没注册**
= 写了却从不参与任何一端编译的**死代码**,误导读者以为功能已生效。
本脚本做什么(四类缺口):
扫描 `App/`、`Drivers/BSP/`、`Src/` 下全部 `.c`(排除第三方),对照两份注册表:
① 存在但未进 uvprojx -> 固件不编译(死代码 / 漏注册)
② 存在但未进 CMakeLists -> 模拟器不编译(**仅当该文件本应进模拟器时**才是缺口;
MCU-only 文件不进模拟器是**正确**的,见下方豁免)
③ 注册了但文件不存在(反向)-> 幽灵注册(改名/删除后残留的注册条目)
另对 UI 页面模块(U4 门禁补强,2026-10-15,报告 §6.5):
④ `Drivers/BSP/UI/ui_page_*.c` 定义了 `ui_page_<name>_desc`,但该符号在 UI 目录内
除自身 .c/.h 外无任何引用 -> 页面未接入
`ui_content_page.c` 的 `s_defs[]` 描述符表:两端**编译
链接全过**(desc 无人引用,链接器整段剥离),菜单永远
打不开这一页 = 又一形态的"写了却从不生效"死代码。
①②③ 对此完全照不出(2026-10-15 实测),故补本类;
即"新增 UI 页面必须过 registration-check"的机械口径。
豁免机制(关键):
模拟器只建模 **UI + 网络配置共享核心**;其余模块(MCU 驱动 / 固件任务 / 纯算法 /
CubeMX 生成层)**不进模拟器是设计事实**,模拟器另有 `_sim` 替身或根本无对应。
豁免用**显式清单 + 每行理由注释**(命名模式 `*_mcu.c` + 个别点名),
**不做整目录一刀切**(N-23 教训正是"目录级盲区")。唯一例外是 `Src/`:
CubeMX 生成的外设初始化/中断/main,按构造即 MCU 专属、不可能有模拟器替身,
故整目录豁免(任务口径明确认可"Src/ 全部")。
已知缺口过渡(存量不拦、只对新缺口 FAIL):
`KNOWN_GAPS` 登记存量缺口(N-23 存量,Lead 处置中):命中 -> 打印 `[WARN]` 不拦;
新出现的缺口 -> 打印 `[GAP]` 并 FAIL。`--strict` 关闭白名单(存量也 FAIL),供审计。
退出码:
0 = 一致(无新缺口;存量缺口已白名单 WARN)
1 = 有缺口(存在未白名单的新缺口或幽灵注册)
2 = 脚本环境错误(注册表文件缺失 / 无法读取)
用法(仓库根目录):
& '.venv\\Scripts\\python.exe' scripts/check_registration.py
& '.venv\\Scripts\\python.exe' scripts/check_registration.py --strict
设计依据:docs/任务规划/登记簿.md N-23;.dsh/skills/new-source-file
创建日期:2026-10-12
"""
import argparse
import fnmatch
import os
import re
import sys
# --- 仓库根(scripts/ 的上一级)---
REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
UVPROJX = os.path.join(REPO_ROOT, "MDK-ARM", "STM32F407-Demo.uvprojx")
CMAKELISTS = os.path.join(REPO_ROOT, "simulator", "CMakeLists.txt")
SIM_DIR = os.path.join(REPO_ROOT, "simulator")
# --- 扫描范围与第三方排除 ---
SCAN_ROOTS = ["App", "Drivers/BSP", "Src"]
# 第三方目录(即便落在扫描根下也跳过)
THIRD_PARTY = [
"Drivers/STM32F4xx_HAL_Driver",
"Drivers/CMSIS",
"Middlewares",
"Lib",
]
# --- 模拟器豁免清单(category ②:不进模拟器是**正确**的)---
# 每项 = (fnmatch 模式(对仓库相对 posix 路径匹配), 理由)。
# 原则:命名模式 + 个别点名,不整目录一刀切;唯一目录级豁免 Src/(CubeMX 生成,MCU 专属)。
SIM_EXEMPT = [
# CubeMX 生成的外设初始化 / 中断向量 / main / freertos:按构造即 MCU 专属,
# 模拟器无对应替身(任务口径明确认可"Src/ 全部")。唯一目录级豁免。
("Src/*", "CubeMX 生成的外设初始化/中断/main,MCU 专属,模拟器无替身"),
# MCU 平台后端:命名约定 *_mcu.c,模拟器另有同名 *_sim.c 替身(在 simulator/sim/)。
("*_mcu.c", "MCU 平台后端,模拟器另有 *_sim.c 替身"),
# --- App/:固件任务 / MCU 依赖 / 纯算法(模拟器无消费路径)---
("App/algo/adc_health.c", "L1 有效性校验纯算法,模拟器无消费路径"),
("App/algo/adc_input.c", "依赖 HAL/TPAFE 取数,MCU 专属"),
("App/algo/phasor.c", "L2 相量纯算法,模拟器无消费路径"),
("App/algo/prot_elm.c", "L3 保护元件纯算法,模拟器无消费路径"),
("App/algo/prot_set.c", "L5 保护定值纯算法,模拟器无消费路径"),
("App/algo/freq.c", "L2 派生测频纯算法(输入=phasor 使用区),模拟器无消费路径"),
("App/cfg/prot_set_store.c", "定值持久化 MCU 胶水(依赖 prot_set 纯算法),模拟器无消费路径"),
("App/log/soe_rec.c", "SOE 记录编解码纯逻辑(B9),L0.5 主机单测覆盖,模拟器无消费路径"),
("App/log/soe_store.c", "SOE 落盘 MCU 胶水(队列+FatFs 单写者,B9),模拟器无消费路径"),
("App/algo/sample_ring.c", "L2 环形缓冲纯算法,模拟器无消费路径"),
("App/algo/adc_cal.c", "L1.5 标定折算纯算法 + cfg 段注册(消费者 = MCU 侧 adcTask 每拍;模拟器不跑采样任务),模拟器无消费路径"),
("App/app/app_main.c", "固件启动/就绪屏障,MCU 专属"),
("App/fs/vol_lock.c", "FreeRTOS 互斥量卷锁,MCU 专属"),
("App/fs/vol_idle_sync.c", "MCU 存储属主后台空闲同步(FatFs disk_ioctl + 卷锁 + sys_events),L0.5 覆盖于 test/host/vol_idle_sync/,模拟器无消费路径"),
("App/log/console_log.c", "MCU 控制台输出,模拟器无消费路径"),
("App/sync/time_sync.c", "对时任务(net+RTOS),MCU 专属"),
("App/task/adc_task.c", "固件 FreeRTOS 任务,模拟器不跑固件任务"),
("App/task/default_task.c", "固件 FreeRTOS 任务,模拟器不跑固件任务"),
("App/task/iec104_serv.c", "固件 FreeRTOS 任务(104 上行,B4),模拟器不跑固件任务"),
("App/task/protect_task.c", "固件 FreeRTOS 任务(保护判据节拍,B5),模拟器不跑固件任务"),
("App/task/net_task.c", "固件 FreeRTOS 任务,模拟器不跑固件任务"),
("App/task/rs485_task.c", "固件 RS-485 任务,MCU 专属(另:uvprojx 缺口见 KNOWN_GAPS)"),
("App/task/ui_task.c", "固件 UI 任务,模拟器另有 main.c 驱动 UI"),
("App/protocol/iec104/iec104_asdu.c", "104 ASDU 编解码纯逻辑,L0.5 主机单测覆盖,模拟器无消费路径"),
("App/protocol/iec104/iec104_link.c", "104 链路状态机纯逻辑(I/O 注入),L0.5 覆盖,模拟器无消费路径"),
("App/protocol/iec104/iec104_pts.c", "104 点表/数据源适配(phasor/prot 依赖),模拟器无消费路径"),
("App/util/sha256.c", "纯工具(FTP 鉴权在 MCU 端),模拟器无消费路径"),
("App/util/sys_clock.c", "RTC/SD2506 依赖墙钟,MCU 专属"),
("App/util/task_prof.c", "任务性能探针,MCU 专属"),
("App/util/task_watchdog.c", "看门狗判定(RTOS 依赖),MCU 专属"),
# --- Drivers/BSP/:MCU 芯片/网络驱动与 FTP 服务(模拟器无对应)---
("Drivers/BSP/CH395F/ch395f.c", "MCU SPI 网卡驱动"),
("Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c", "MCU SPI NAND 驱动"),
("Drivers/BSP/GD5F2GQ5UE/nand_ftl.c", "dhara FTL 适配,MCU 存储栈"),
("Drivers/BSP/NET/lftpd/lftpd.c", "MCU FTP 服务器"),
("Drivers/BSP/NET/lftpd/lftpd_auth.c", "MCU FTP 鉴权"),
("Drivers/BSP/NET/lftpd/lftpd_inet.c", "MCU FTP 网络层"),
("Drivers/BSP/NET/lftpd/lftpd_io.c", "MCU FTP 文件 IO"),
("Drivers/BSP/NET/lftpd/lftpd_string.c", "MCU FTP 字符串工具"),
("Drivers/BSP/NET/net_select.c", "MCU socket 选择层"),
("Drivers/BSP/NET/net_socket.c", "MCU socket 后端,模拟器另有 net_socket_sim.c"),
("Drivers/BSP/NET/net_listener.c", "MCU 监听器生命周期(依赖 ch395f_* SPI 驱动与 net_socket.c 静态状态),模拟器无对应(Winsock 后端无孤儿态/重监听概念)"),
("Drivers/BSP/NET/net_events.c", "MCU 芯片事件处理 + 100ms 周期对账(依赖 ch395f_* SPI 驱动与驱动状态缓存),模拟器无对应(Winsock 后端无\"事件未收到/静默回收\"概念,无失同步可收敛)"),
("Drivers/BSP/NET/net_pending.c", "MCU 挂起 recv 模型(依赖 ch395f_* SPI 读出与 FreeRTOS 通知),模拟器另有 net_socket_sim.c 按外部语义模拟"),
("Drivers/BSP/RS485/rs485.c", "MCU RS-485 驱动"),
("Drivers/BSP/SD2506/sd2506.c", "MCU RTC 驱动"),
("Drivers/BSP/TPAFE5160/tpafe5160.c", "MCU 计量芯片驱动"),
("Drivers/BSP/UI/ui_port.c", "MCU UI 端口层(显示/触摸硬件),模拟器另有 sim/sim_port.c"),
]
# --- 已知缺口白名单(存量,N-23,Lead 已处置)---
# 命中 -> WARN 不拦;新缺口 -> GAP FAIL。仅针对"存在但未注册"方向。
KNOWN_GAPS = {
"App/task/rs485_task.c": "E-59 门禁刻意未接线(B2 消费者就绪时随任务立项注册)",
}
def to_repo_rel(path):
"""把绝对/相对路径归一为仓库相对 posix 路径。"""
p = os.path.normpath(path)
rel = os.path.relpath(p, REPO_ROOT)
return rel.replace("\\", "/")
def is_scan_scope(repo_rel):
"""是否在扫描范围内(App/、Drivers/BSP/、Src/ 下的 .c,排除第三方)。"""
if not repo_rel.endswith(".c"):
return False
for tp in THIRD_PARTY:
if repo_rel == tp or repo_rel.startswith(tp + "/"):
return False
for root in SCAN_ROOTS:
if repo_rel.startswith(root + "/"):
return True
return False
def scan_sources():
"""扫描扫描根下全部 .c,返回仓库相对 posix 路径集合。"""
found = set()
for root in SCAN_ROOTS:
abs_root = os.path.join(REPO_ROOT, root.replace("/", os.sep))
if not os.path.isdir(abs_root):
continue
for dirpath, _dirnames, filenames in os.walk(abs_root):
for fn in filenames:
if fn.endswith(".c"):
abs_p = os.path.join(dirpath, fn)
rel = to_repo_rel(abs_p)
if is_scan_scope(rel):
found.add(rel)
return found
def parse_uvprojx():
"""解析 uvprojx 的 <FilePath>,返回仓库相对 posix 路径集合(含 .c/.s)。"""
with open(UVPROJX, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
mdk_dir = os.path.dirname(UVPROJX)
entries = set()
for m in re.finditer(r"<FilePath>(.*?)</FilePath>", text):
raw = m.group(1).strip()
if not raw:
continue
# uvprojx 路径相对 MDK-ARM/ 目录,反斜杠或正斜杠混用
joined = os.path.join(mdk_dir, raw.replace("\\", os.sep))
entries.add(to_repo_rel(joined))
return entries
def _expand_var(s, varmap, depth=0):
"""递归展开 ${VAR}。"""
if depth > 12:
return s
def repl(m):
name = m.group(1)
val = varmap.get(name, m.group(0))
return _expand_var(val, varmap, depth + 1)
return re.sub(r"\$\{(\w+)\}", repl, s)
def parse_cmake():
"""解析 CMakeLists 的 *_SOURCES 列表(先展开变量),返回仓库相对 posix 路径集合。"""
with open(CMAKELISTS, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
# 1) 单值 set(NAME value):目录变量(不含 *_SOURCES)
varmap = {"CMAKE_CURRENT_SOURCE_DIR": SIM_DIR}
for m in re.finditer(r'set\s*\(\s*([A-Za-z_]\w*)\s+("?)([^"\r\n]*?)\2\s*\)', text):
name = m.group(1)
val = m.group(3).strip()
if name.endswith("_SOURCES"):
continue
varmap[name] = val
# 2) 多值 set(NAME ... ) 中名字以 _SOURCES 结尾的块
sources = set()
for m in re.finditer(r"set\s*\(\s*([A-Za-z_]\w*_SOURCES)\b(.*?)\)", text, re.DOTALL):
body = m.group(2)
# 逐行剥注释
lines = []
for line in body.splitlines():
idx = line.find("#")
if idx >= 0:
line = line[:idx]
lines.append(line)
body = " ".join(lines)
for tok in body.split():
expanded = _expand_var(tok, varmap)
if not expanded:
continue
# 展开后若仍是绝对/带盘符路径直接用,否则相对 simulator/ 目录
if os.path.isabs(expanded) or re.match(r"^[A-Za-z]:[\\/]", expanded):
cand = expanded
else:
cand = os.path.join(SIM_DIR, expanded)
sources.add(to_repo_rel(cand))
return sources
# --- UI 页面模块 → 描述符表接线(④,U4 门禁补强)---
UI_DIR_REL = "Drivers/BSP/UI"
def check_ui_page_wiring():
"""每个定义 `ui_page_<name>_desc` 的 `ui_page_*.c`,其 desc 必须被 UI 目录内其他文件引用。
UI 页面的"注册"有**两层**:构建注册(uvprojx + CMakeLists,①② 类)与框架注册
(`ui_content_page.h` 枚举 + `ui_content_page.c` 的 `s_defs[]` 表)。漏了后者的页面
两端照常编译链接(desc 无引用 → 链接器剥离),①②③ 全绿但菜单打不开这一页。
判定:页面模块 = `ui_page_*.c` 中定义 `<stem>_desc =` 的文件(不定义 desc 的
`ui_page_*.c` 不是页面模块——未来 U3 生成的 `ui_page_registry.c` 承载表引用,
本规则依旧成立且不会把它误判为页面);引用面 = `Drivers/BSP/UI/` 下全部 .c/.h,
排除该页自身 .c/.h(自身头文件里的 extern 声明不算接线)。
"""
ui_dir = os.path.join(REPO_ROOT, UI_DIR_REL.replace("/", os.sep))
if not os.path.isdir(ui_dir):
return [], 0
# 1) 页面模块清单:ui_page_*.c 中定义 `<stem>_desc =` 的
pages = {} # sym -> repo-rel .c
for fn in sorted(os.listdir(ui_dir)):
if not (fn.startswith("ui_page_") and fn.endswith(".c")):
continue
try:
text = open(os.path.join(ui_dir, fn), encoding="utf-8-sig", errors="replace").read()
except OSError:
continue
sym = fn[:-2] + "_desc"
if re.search(r"\b%s\b\s*=" % re.escape(sym), text):
pages[sym] = "%s/%s" % (UI_DIR_REL, fn)
if not pages:
return [], 0
# 2) 引用面扫描:UI 目录全部 .c/.h,排除各页自身 .c/.h
referenced = {sym: False for sym in pages}
for dirpath, _dirnames, filenames in os.walk(ui_dir):
for fname in filenames:
if not fname.endswith((".c", ".h")):
continue
rel_p = to_repo_rel(os.path.join(dirpath, fname))
try:
text = open(os.path.join(dirpath, fname), encoding="utf-8-sig", errors="replace").read()
except OSError:
continue
for sym, own_c in pages.items():
if rel_p in (own_c, own_c[:-2] + ".h"):
continue
if re.search(r"\b%s\b" % re.escape(sym), text):
referenced[sym] = True
gaps = []
for sym, rel_c in sorted(pages.items()):
if not referenced[sym]:
gaps.append("[GAP] %s | 页面未接入描述符表(%s 在 %s/ 内除自身外无引用 -> "
"编译得过但菜单打不开,死代码;新增 UI 页面必须过本项)"
% (rel_c, sym, UI_DIR_REL))
return gaps, len(pages)
def main():
ap = argparse.ArgumentParser(description="双端注册反向校验(uvprojx + CMakeLists)")
ap.add_argument("--strict", action="store_true",
help="关闭 KNOWN_GAPS 白名单:存量缺口也按 FAIL 处理(供审计)")
args = ap.parse_args()
# 环境校验
for path, label in ((UVPROJX, "uvprojx"), (CMAKELISTS, "CMakeLists.txt")):
if not os.path.isfile(path):
print("[ENV-ERROR] 注册表缺失: %s (%s)" % (label, path))
return 2
try:
scanned = scan_sources()
uv = parse_uvprojx()
cmake = parse_cmake()
except OSError as e:
print("[ENV-ERROR] 读取失败: %s" % e)
return 2
uv_c = {p for p in uv if is_scan_scope(p)}
cmake_c = {p for p in cmake if is_scan_scope(p)}
gaps = [] # 新缺口 -> FAIL
warns = [] # 存量白名单 -> WARN
# ① 存在但未进 uvprojx(无豁免:扫描范围内每个 .c 都应编入固件)
for f in sorted(scanned):
if f not in uv:
if (not args.strict) and f in KNOWN_GAPS:
warns.append("[WARN] %s | 缺 uvprojx(存量:%s)" % (f, KNOWN_GAPS[f]))
else:
gaps.append("[GAP] %s | 缺 uvprojx" % f)
# ② 存在但未进 CMakeLists(MCU-only 豁免)
for f in sorted(scanned):
if f in cmake_c:
continue
if any(fnmatch.fnmatch(f, pat) for pat, _ in SIM_EXEMPT):
continue
if (not args.strict) and f in KNOWN_GAPS:
warns.append("[WARN] %s | 缺 CMakeLists(存量:%s)" % (f, KNOWN_GAPS[f]))
else:
gaps.append("[GAP] %s | 缺 CMakeLists" % f)
# ③ 幽灵注册:注册了但文件不存在(反向)
for f in sorted(uv_c):
if not os.path.isfile(os.path.join(REPO_ROOT, f.replace("/", os.sep))):
gaps.append("[GAP] %s | 幽灵注册(uvprojx)" % f)
for f in sorted(cmake):
if not os.path.isfile(os.path.join(REPO_ROOT, f.replace("/", os.sep))):
gaps.append("[GAP] %s | 幽灵注册(CMakeLists)" % f)
# ④ UI 页面模块未接入描述符表(框架注册,U4;①②③ 照不出的盲区)
ui_gaps, ui_pages = check_ui_page_wiring()
gaps.extend(ui_gaps)
# 输出
print("== 双端注册反向校验 ==")
print("扫描 .c: %d | uvprojx(扫描范围): %d | CMakeLists(扫描范围): %d | UI 页面模块: %d"
% (len(scanned), len(uv_c), len(cmake_c), ui_pages))
for w in warns:
print(w)
for g in gaps:
print(g)
if gaps:
print("结果: FAIL(%d 个新缺口/幽灵注册/页面未接线,%d 个存量 WARN)" % (len(gaps), len(warns)))
return 1
if warns:
print("结果: PASS(无新缺口;%d 个存量缺口已白名单 WARN,N-23 Lead 处置中)" % len(warns))
return 0
print("结果: PASS(双端注册一致)")
return 0
if __name__ == "__main__":
sys.exit(main())