串口采集/测试去重:删 tools/serial_tool 与 test/host run_boot_test/verify_log 副本,引用改指 .dsh/skills 串口 skill

This commit is contained in:
2026-10-10 15:13:23 +08:00
parent da66c500ea
commit f0300761ae
38 changed files with 314 additions and 2486 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ whenToUse: 新增任务/加大缓冲/改栈/接入日志之前之后、链接报
# scripts/ram_report.py (.map → RAM/Flash 分区与按文件占用表)
# scripts/stack_report.py (.htm 静态调用图 → 每任务最坏链 vs 配置栈)
# 叠加运行时水位(有真机日志时):
& '.venv\Scripts\python.exe' scripts/stack_report.py -s tools\serial_tool\state\serial.log
& '.venv\Scripts\python.exe' scripts/stack_report.py -s .dsh\skills\serial-assistant\scripts\state\serial.log
```
退出码:0=全部通过 / 1=有任务不达标 / 2=解析错误(.htm/.map 缺失先重跑构建)。
+1 -1
View File
@@ -32,7 +32,7 @@ whenToUse: 换新机器/新克隆要把环境搭起来、构建或烧录报"环
`Get-PnpDevice -Class Ports` 能看到 COMx、`reset.bat list` 能看到探针 SN。
4. **验收三连**(全绿才算环境就绪):
```powershell
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py selftest # 23/23 PASS
& '.venv\Scripts\python.exe' .dsh/skills/serial-assistant/scripts/serial_tool.py selftest # 23/23 PASS
& "MDK-ARM\build.bat" # exit 0
& "MDK-ARM\reset.bat" list # 列出探针
```
+1 -1
View File
@@ -36,5 +36,5 @@ cmake --build simulator/build --config Debug # 0 警告
- **禁止**把源文件复制进 `simulator/`——两端共用 `Drivers/BSP/` 原件(UI_界面制作流程.md C7)
- 只注册 `.c`(含 `.s`);`.h` 靠 include 路径找到,不加工程
- 漏注册的典型症状:一端编译失败(undefined reference)或一端行为悄悄停留在旧逻辑
- 注册完成后跑回归:UI 文件 → `ui-verify` skill;其他模块 → `@test`(`test/host/run_boot_test.py`)
- 注册完成后跑回归:UI 文件 → `ui-verify` skill;其他模块 → `@test`(`.dsh/skills/serial-boot-test/scripts/run_boot_test.py`)
- 改过 `CMakeLists.txt` 后需重新 `cmake -B build` 配置
@@ -5,7 +5,7 @@
输出结构化判定(格式与旧 PowerShell 版逐字一致,下游文档/习惯不受影响)。
用法:
& '.venv\\Scripts\\python.exe' test/host/verify_log.py --log-path <file>
& '.venv\\Scripts\\python.exe' .dsh/skills/serial-boot-test/scripts/verify_log.py --log-path <file>
[--required 'marker1;marker2']
[--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]']
[--require-suite-summary 'Storage Tests;GD5F2GQ5UE Tests']
+2 -2
View File
@@ -128,8 +128,8 @@ docs/images/*.png
# Python venv (unified env, see AGENTS.md "Python 环境"; rebuild via requirements.txt)
.venv/
# Serial assistant (tools/serial_tool) runtime state (daemon log/meta, local runtime artifacts)
tools/serial_tool/state/
# Serial assistant runtime state (daemon log/meta, local runtime artifacts)
.dsh/skills/serial-assistant/scripts/state/
# CI 运行时产物(.gitea/workflows/ci.yml 的 S3 步骤在 runner 工作区落盘的日志;
# 零依赖存档方案——落在工作区目录而非上传 artifact,故必须忽略以免误提交)
+6 -6
View File
@@ -16,7 +16,7 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v
## 构建与验证
- `@build` = `& "MDK-ARM\build.bat"`(仓库根目录用**相对路径**);退出码 0 = 0 错误 0 警告,1 = 有警告或错误(查 `MDK-ARM/build_log.txt`),2 = `DTU_KEIL_UV4` 未设/无效(换机必设,见 `docs/环境与部署/本地工具路径环境变量方案.md`);UV4 路径经 `DTU_KEIL_UV4` 环境变量解析(**不再硬编码**;显式设置但无效会立即报错、不静默回退)
- `@test` / `test/host/run_boot_test.py` 一键闭环(2026-09-20 起为纯 Python 标准库实现,替代 PowerShell 版;`& '.venv\Scripts\python.exe' test/host/run_boot_test.py`):开串口+清日志 → 烧录(`MDK-ARM/download.bat`,下载后 Reset&Run;快路径 `--flash-cmd "MDK-ARM\reset.bat flash"`)→ **固件身份戳**(从刚烧进去的 hex 里抠 `fw build: <DATE> <TIME>` 追加成 Required 标记)→ wait-idle(等 RX 静默 8s,超时 30s,首字节 12s 未到判 `no_data`)→ 提取日志 → **烧录回读校验**(`reset.bat cmp` = `-CmpFile`;不一致 = 本次运行作废 exit 2)→ `test/host/verify_log.py` 标记校验(含**启动锚点计数**:`reset cause:` 出现 0 次或 >1 次 = FAIL,`--max-boot-count` 调;Forbidden 命中**逐行打印行号+内容**;boot_log 每行带 `[+Nms]` daemon 毫秒时基 → 可查标记顺序 `--require-order` 与开机耗时 `--done-marker`/`--max-boot-ms`,实测 `net init: OK` = **+537ms**);退出码 0=PASS / 1=FAIL / 2=采集或烧录错误(含回读不一致)/ 3=用法/预检错误
- `@test` / `.dsh/skills/serial-boot-test/scripts/run_boot_test.py` 一键闭环(2026-09-20 起为纯 Python 标准库实现,替代 PowerShell 版;`& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py`):开串口+清日志 → 烧录(`MDK-ARM/download.bat`,下载后 Reset&Run;快路径 `--flash-cmd "MDK-ARM\reset.bat flash"`)→ **固件身份戳**(从刚烧进去的 hex 里抠 `fw build: <DATE> <TIME>` 追加成 Required 标记)→ wait-idle(等 RX 静默 8s,超时 30s,首字节 12s 未到判 `no_data`)→ 提取日志 → **烧录回读校验**(`reset.bat cmp` = `-CmpFile`;不一致 = 本次运行作废 exit 2)→ `.dsh/skills/serial-boot-test/scripts/verify_log.py` 标记校验(含**启动锚点计数**:`reset cause:` 出现 0 次或 >1 次 = FAIL,`--max-boot-count` 调;Forbidden 命中**逐行打印行号+内容**;boot_log 每行带 `[+Nms]` daemon 毫秒时基 → 可查标记顺序 `--require-order` 与开机耗时 `--done-marker`/`--max-boot-ms`,实测 `net init: OK` = **+537ms**);退出码 0=PASS / 1=FAIL / 2=采集或烧录错误(含回读不一致)/ 3=用法/预检错误
- 默认校验标记:Required `App init OK` / `Network Init` / `net init: OK` + **自动追加的固件身份戳 `fw build: <__DATE__> <__TIME__>`**(`App/app/app_main.c` 开机打印,`run_boot_test.py` 从待烧 hex 里抠出该串当 Required——此前所有标记都是固定串,**旧镜像/错镜像照样全中**,"反复重下载、芯片一直是旧镜像"在自动闭环里不可见,2026-10-10 补;`--no-fw-check` 关闭);Forbidden `FAIL` / `ERROR` / `HardFault` / `[ERR]`(**四个 Fault 的崩溃日志都以 `[ERR]` 开头 → 一律判 FAIL**,这是刻意的:出现 Fault 即本次运行不通过);按模块定制标记(网络/存储/RTC/ADC/RS485/UI)见 `docs/测试说明/自动化测试流程.md`
- ⚠️ 旧绝对路径 `D:\Code\DTU 程序\...` 出现即失效(仓库已搬迁),一律改用仓库相对路径
- **下载/烧录铁律(2026-09-20 实测)**:走 `& "MDK-ARM\reset.bat" flash [文件]`(= `ST-LINK_CLI -c SWD -P <文件> -Rst -Run`),烧后 `-Rst -Run` **不可省略**——裸 `-P` 会报 `Programming Complete`,但目标核处于死循环/异常状态时写入并不落地(芯片内容不变;这就是 09/19~09/20 "反复重下载、芯片一直是旧镜像、二分法失效" 的根因);`.hex`/`.bin` 均可直烧(1MB ≈ 6.5s,快于 UV4 路径);只重启不烧录用 `reset.bat` 无参(= `-c SWD -Rst -Run`,本机 3 线接线唯一复位手段:nRST 未接、`-HardRst`/`RM=` 不可用)
@@ -49,7 +49,7 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v
- **内核启动(`osKernelStart`)前禁止任何 FreeRTOS/CMSIS-RTOS API 调用**(2026-09-20 启动失败实证根因,修复 `82411cf`):`osMessageQueueNew`/`osMessageQueuePut`/`osMutexNew`/`osSemaphoreNew` 等在内核启动前调用会杀死启动(09/19~09/20 "每次必死" 的代码层根因 = `syslog_mcu_init` 在内核启动前调用 `osMessageQueueNew`)。内核启动前代码的标准模式:纯寄存器/HAL 轮询操作 + **无锁静态缓冲**(如 `s_preboot[8]` 暂存内核前日志)+ **内核启动后惰性创建真队列**(任务上下文首次使用时,见 `syslog_drain`);凡在内核启动前执行的初始化函数里不得创建任何 CMSIS 对象
- CH395F SPI:每次事务须 `ch395f_spi_begin()/end()` 包裹;分频系数**必须为 8**(PCB 约束,勿"优化")
- **就绪屏障位必须 `osFlagsNoClear`**(2026-09-21 实证):`osEventFlagsWait` **默认消费**所等位,而 `SYS_EVT_NET_READY`/`SYS_EVT_STORAGE_READY` 是**状态位**——被 netTask/uiTask/timeSync 先消费掉后,`ftpTask` 的 `WaitAll(两位)` 永不成立 → FTP 21 端口静默不监听(无日志、无报错,纯靠可观测性才揪出来)。就绪位一律 `osFlagsWaitAny|osFlagsNoClear` / `osFlagsWaitAll|osFlagsNoClear`;命令位(`SYS_EVT_CFG_SAVE`/`SYS_EVT_NET_RECONFIG`)保持消费型等待
- **内存预算紧张**:新增任何任务/缓冲/栈前必须先核算 RAM 余量(`docs/内存管理/`、`待处理内容.md`);**栈数据来源**:`& '.venv\Scripts\python.exe' scripts/stack_report.py -s tools\serial_tool\state\serial.log`(Keil 静态调用图 → 每任务最坏链,+ 串口 `[STACK]` 运行时水位)与 `scripts/ram_report.py`(.map 静态占用);改栈或加大局部变量前后各跑一次
- **内存预算紧张**:新增任何任务/缓冲/栈前必须先核算 RAM 余量(`docs/内存管理/`、`待处理内容.md`);**栈数据来源**:`& '.venv\Scripts\python.exe' scripts/stack_report.py -s .dsh\skills\serial-assistant\scripts\state\serial.log`(Keil 静态调用图 → 每任务最坏链,+ 串口 `[STACK]` 运行时水位)与 `scripts/ram_report.py`(.map 静态占用);改栈或加大局部变量前后各跑一次
- **自研代码禁用 C 库堆分配**(2026-09-18,P0-1):禁止 `malloc/calloc/realloc/strdup/free`——本机工具链 MicroLIB 的 C 堆由 `startup_stm32f407xx.s` 的 `Heap_Size EQU 0x200` 限定为 **512 B**,KB 级分配必然返回 NULL 且**模拟器(宿主 CRT 堆)完全正常、照不出来**。需要动态内存只用 `pvPortMalloc/vPortFree`(FreeRTOS 堆 `configTOTAL_HEAP_SIZE`),或像配置引擎那样把分配收进平台后端(`cfg_store_alloc/free/heap_free`:MCU=FreeRTOS 堆、模拟器=宿主堆,共享核心零分配符号);分配失败必须有 `FAIL` 日志 + 自检上报,暂态峰值要写编译期预算
- **FatFs 打开模式陷阱(2026-09-20 台架实测,`Lib/FatFs` 移植版)**:`FA_OPEN_APPEND`(0x30) = `FA_OPEN_ALWAYS|FA_SEEKEND`,**不含 `FA_WRITE`(0x02)**——不带写位打开的文件 `f_write` 恒返回 `FR_DENIED`(7)(/log 写失败真根因);追加写必须用 `FA_WRITE|FA_OPEN_APPEND`。该移植的 `f_write` 簇分配失败时返回 `FR_OK`+短写(无错误码),判失败必须校验写回字节数。标志位为旧式编号(`FA_READ`=0x01 / `FA_WRITE`=0x02 / `FA_CREATE_NEW`=0x04 / `FA_CREATE_ALWAYS`=0x08 / `FA_OPEN_ALWAYS`=0x10 / `FA_OPEN_APPEND`=0x30 / `FA_SEEKEND`=0x20(ff.c 内部定义)),与上游 FatFs R0.14 不同,勿照抄上游代码示例
- **FatFs 文件名约束(2026-09-20 台架实证,`.fschk` 无限重格式化事故根因)**:本移植 `Lib/FatFs/ffconf.h` 为 `FF_USE_LFN=0`,**只支持严格 8.3 短名**——主名≤8 字符、扩展≤3 字符、**不得以点开头**(`.fschk` 会被 `ff.c create_short` 把点后当扩展域解析超限 → `f_open` 恒返 `FR_INVALID_NAME`(6),纯 RAM 名字判定、不碰盘)。ff.c `create_short` 全部拒绝点已加 DEBUG 打印:`[ERR][FATFS] SFN reject (原因) seg='名字片段' bad_ch=坏字符`,非法名字调用看开机日志即可定位。自愈类逻辑**严禁**把名字/参数类错误码(`FR_INVALID_NAME` 等)归入卷损坏触发集(整卷重格式化不可逆)
@@ -112,16 +112,16 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v
## 串口助手 serial_tool
- 入口:`& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py <命令>`(daemon 自动拉起;全部命令 5s 超时、JSON 输出、exit 0/1/2/3)
- 全量历史 = 日志文件 `tools/serial_tool/state/serial.log`(RX/TX/SYS 时间戳行);agent 直接 Read/Grep 文件,**无采集窗口仪式**;20MB 上限自动截头保尾 10MB
- 入口:`& '.venv\Scripts\python.exe' .dsh/skills/serial-assistant/scripts/serial_tool.py <命令>`(daemon 自动拉起;全部命令 5s 超时、JSON 输出、exit 0/1/2/3)
- 全量历史 = 日志文件 `.dsh/skills/serial-assistant/scripts/state/serial.log`(RX/TX/SYS 时间戳行);agent 直接 Read/Grep 文件,**无采集窗口仪式**;20MB 上限自动截头保尾 10MB
- 常用:`open`(幂等)/ `send <文本>`(或 `--hex 0D0A`)/ `log --tail N --grep P [-i] [--regex]`(**先过滤后截尾**)/ `clear`(代际+1)/ `wait-idle --settle-ms 8000 --max-ms 30000 [--first-timeout-ms 12000]` / `status` / `close`
- 串口端口单一配置源:`.serialtest/config.json`(当前 COM6,2026-09-21 台架重插后由 COM7 变更;2026-10-10 由 `.opencode/serial_config.json` 迁入,仓库脚本与 `.dsh/skills/` 串口 skill 共用此文件);端口变化**只改这个文件**
- 串口端口单一配置源:`.serialtest/config.json`(当前 COM6,2026-09-21 台架重插后由 COM7 变更;2026-10-10 由 `.opencode/serial_config.json` 迁入;串口采集/测试实现已收敛到 `.dsh/skills/serial-assistant` + `serial-boot-test`,仓库内不再有 `tools/serial_tool`/`test/host/run_boot_test` 副本);端口变化**只改这个文件**
- 命令/工作流/故障排查 `docs/测试说明/串口助手使用指南.md`;契约/daemon 行为/陷阱 `docs/测试说明/串口助手设计说明.md`
- **抓开机日志铁律(2026-09-20 实测;②③ 已于 2026-10-10 修复)**:① 顺序 `open` → `clear` → 烧录/复位 → `wait-idle` → 读日志校验标记(**采集必须先于烧录**,否则错过开机头几行);② `wait-idle` 的 CLI 超时现按 `--max-ms` **自动推导**,不再需要手工带 `--timeout`(旧版固定 5s 会在 daemon 还在等时掐断连接、daemon.log 刷 traceback);③ `log --grep` 已改为**先过滤后截尾**——旧实现先 `tail`(默认 50)后 grep,开机 marker 必然查不到,这才是当年"`log --grep` 实测不可信"的真根因,现可直接作判定依据(Read/Grep 文件仍等效)
- **`wait-idle` 判据(2026-10-10 修)**:`no_data` = **本次等待期间零字节**(旧实现比的是 daemon 累计 `rx_bytes` 是否为 0,常驻 daemon 下永不触发 → 板子全程静默也误报 `idle:true`);**首字节到达前不启动静默计时**(`--first-timeout-ms` 兜底,`run_boot_test.py --first-wait-ms` 默认 12000);返回的 `rx_bytes` 是本次增量而非累计
- **采集卫生与查询面(2026-10-10 第三批)**:无换行残行 **64KB 封顶**(板子跑飞/烧录期的二进制垃圾旧实现无限攒内存,还会把后续每条正常行拼成一条读不出的长行);`open`/`close`/`clear` = **会话边界**,残行一并丢弃(旧实现里上一段被复位截断的半行会粘进新会话第一行,开机日志首行因此不可读);`cap_truncate` **按行边界**截头(旧实现按字节切 → 截出来的首行是半行,破坏"只含完整行"不变量);`open` 不带 `--dtr/--rts` = **保持当前线路状态**(旧实现把"没给"当 False,重开即静默拉低),显式给则现场应用并在 `note` 回显;`ports` 回 `configured_present` + `hint`(系统里只有一个候选才给,多候选不猜),`open` 的 `port not found` 也带 `[hint: ...]`;`status` 回 `rx_age_ms`
- ⚠️ **看到两个 `serial_tool.py --serve` 进程是正常的**(2026-10-10 实测):`.venv\Scripts\python.exe` 是 venv stub,会派生基解释器(本机 = `C:\ProgramData\anaconda3\python.exe`)子进程跑真 daemon,`status.daemon.pid` 报的是子进程;**别把这对父子当成"多 daemon 并存"去 taskkill**(HTTP 口独占绑定 `SO_EXCLUSIVEADDRUSE` 已生效,`status.daemon.exclusive_bind: true` 可验)。真要清的是 `status`/`meta.json` 里那个 pid 之外的**独立**残留实例
- **`open` 报"拒绝访问/端口被占用"时**:先查**残留旧 daemon**(上一会话遗留的 `serial_tool.py --serve` python 进程——口句柄已死但进程仍占着串口与 HTTP 口 18765)→ `taskkill /F /PID <旧PID>` → 重新 `open`(自动拉起新 daemon,系统自动释放口);改 `serial_config.json` 后同样必须先杀旧 daemon 再 `open`(daemon 启动时缓存配置)
- **`open` 报"拒绝访问/端口被占用"时**:先查**残留旧 daemon**(上一会话遗留的 `serial_tool.py --serve` python 进程——口句柄已死但进程仍占着串口与 HTTP 口 18765)→ `taskkill /F /PID <旧PID>` → 重新 `open`(自动拉起新 daemon,系统自动释放口);改 `.serialtest/config.json` 后同样必须先杀旧 daemon 再 `open`(daemon 启动时缓存配置)
## LVGL PC 模拟器
+5 -8
View File
@@ -158,9 +158,7 @@ STM32F4-Base/
│ │ ├── storage_test_task.c/h # 存储性能测试
│ │ ├── net_test_task.c/h # 网络层测试
│ │ └── net_latency_test.c/h # 链路时延探针
│ └── host/ # 主机端脚本(PC 侧)
│ ├── run_boot_test.py # 自动化启动测试(@test 入口)
│ ├── verify_log.py # 日志标记校验
│ └── host/ # 主机端脚本(PC 侧;@test 入口已迁至 .dsh/skills/serial-boot-test/)
│ ├── ch395f_socket_test.py # Socket 通信测试
│ ├── ftp_test.py / ftp_up_dl_test.py / ftp_speed_test.py / ftp_capture.py / ftp_regression.py
│ ├── e02_probe.py # E-02 专项探针
@@ -193,8 +191,7 @@ STM32F4-Base/
│ ├── tests/ # 无头回归脚本 + 黄金参考(菜单像素回归 / 内容页黄金参考)
│ └── SDL2/ # SDL2 2.32.10 预编译库
├── tools/ # 开发工具
│ ├── font_gen/ # 中文字体生成工具
│ └── serial_tool/ # Python 串口助手(常驻 daemon + CLI,见 docs/测试说明/串口助手设计说明.md)
│ └── font_gen/ # 中文字体生成工具
├── ui_cn_chars.txt # 中文字体字符集
├── requirements.txt # Python 依赖清单(统一 .venv 环境,见 AGENTS.md「Python 环境」)
├── STM32F407-Demo.ioc # CubeMX 工程文件
@@ -285,7 +282,7 @@ UI 改动回归门禁(菜单像素回归 + 内容页黄金参考 + 池水位
```powershell
# 一键测试:串口捕获 → 烧录 → 日志校验(2026-09-20 起为纯 Python 标准库实现)
& '.venv\Scripts\python.exe' test/host/run_boot_test.py
& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py
```
退出码:0 = PASS | 1 = FAIL | 2 = 错误
@@ -313,7 +310,7 @@ UI 改动回归门禁(菜单像素回归 + 内容页黄金参考 + 池水位
### 串口调试
通过仓库内置 Python 串口助手(`tools/serial_tool/`)自主读取 USART1 串口打印:
通过仓库内置 Python 串口助手(`.dsh/skills/serial-assistant/`)自主读取 USART1 串口打印:
- 端口配置:`.serialtest/config.json`(单一配置源)
- 用法详见 `docs/测试说明/串口助手使用指南.md`,设计见 `docs/测试说明/串口助手设计说明.md`
@@ -377,7 +374,7 @@ pre-commit run --all-files
| `Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c` | NAND Flash 驱动 |
| `Drivers/BSP/NET/net_socket.c` | BSD Socket API |
| `MDK-ARM/build.bat` | 编译脚本 |
| `test/host/run_boot_test.py` | 自动化测试脚本 |
| `.dsh/skills/serial-boot-test/scripts/run_boot_test.py` | 自动化测试脚本 |
## 已知问题
+2
View File
@@ -89,6 +89,7 @@ docs/
| [`控制台单写者日志设计说明.md`](子系统设计/控制台单写者日志设计说明.md) | 控制台日志单写者模型(避免多任务交错输出) |
| [`运行与自检记录设计说明.md`](子系统设计/运行与自检记录设计说明.md) | `/log/run.log`·`selftest.log` 定长记录与待机显示 |
| [`看门狗设计说明.md`](子系统设计/看门狗设计说明.md) | IWDG(CubeMX 配置)+ 任务心跳位图:选型与超时推导、掩码与埋点、取证机制、验证记录;已台架闭环(V2/V4/V5) |
| [`ADC采样节拍设计说明.md`](子系统设计/ADC采样节拍设计说明.md) | **ADC 采集链路采样节拍**:现状(任务周期软触发)→ 目标(TIM6 更新中断触发 CONVST)的方案选型、CubeMX 配置规格、代码改造清单、时序/优先级分析与验收判据;**规划中(未实现)** |
| [`网络/BSD_Socket_API_使用指南.md`](网络/BSD_Socket_API_使用指南.md) | **上层视图**:BSD Socket API 公开语义、示例、常见问题 |
| [`网络/BSD_Socket_API_函数调用关系.md`](网络/BSD_Socket_API_函数调用关系.md) | **内部视图**:netTask / 消息队列 / L3 挂起 / 重试对账的调用关系与设计意图 |
| [`网络/Net层阻塞模型架构设计.md`](网络/Net层阻塞模型架构设计.md) | **等待模型**:pending 请求架构、五路完成语义、设计边界与性能特征 |
@@ -261,6 +262,7 @@ docs/
| 日期 | 变更 | 说明 |
|---|---|---|
| 2026-10-10 | 新增子系统设计 | 新建 `docs/子系统设计/ADC采样节拍设计说明.md`(ADC 采集链路 1 kHz 硬件定时器触发的 CubeMX 配置规格与代码改造清单,B1 遗留项规划);本索引 §5.1 补登记 |
| 2026-10-10 | 新增算法专题 | 新建 `docs/算法/继电保护功能复现规划.md`(保护链路复现总规划);本索引 §1 目录树补 `算法/`、§5 拆为 5.1 功能子系统 / 5.2 算法专题并登记 `算法/` 两篇文档 |
| 2026-10-05 | 新增产品需求文档 | 新建 `docs/产品需求/产品需求设计文档.md`(面向产品的需求基线);本索引新增 §3 产品需求、§2 推荐阅读顺序置顶、§1 目录树补 `产品需求/`;后续章节号顺延(架构设计 §4 … 变更记录 §12) |
| 2026-09-25 | 目录重组 + 索引建立 | 顶层 16 篇散落 md 归入 5 个新目录(`架构设计/` `子系统设计/` `网络/` `审查记录/` `环境与部署/`);全仓库 90 处引用同步;新建本索引(D-5) |
+1 -1
View File
@@ -249,7 +249,7 @@ flowchart TD
## 六、P3:MCU 验证
1. `@build` → **0 错误 0 警告**(AGENTS.md 强制;有警告看 `MDK-ARM/build_log.txt`)
2. `@test`(一键 `test/host/run_boot_test.py`):
2. `@test`(一键 `.dsh/skills/serial-boot-test/scripts/run_boot_test.py`):
- 采集窗口**先于烧录武装**(脚本 `--flash-cmd` 已保证顺序)
- Required:`UI port init: OK;UI menu init: OK` + 本界面定制标记(按屏增补,如页头标题字样)
- Forbidden:`FAIL;ERROR;HardFault`
@@ -550,7 +550,7 @@ mac=00:00:00:00:00:00
| 端口 | USART1(PA9 TX / PA10 RX),115200 8N1 |
| 用途 | printf 日志 + `@test` 采集通道 |
| 配置源 | `.serialtest/config.json`(单一配置源) |
| 工具 | 仓库内置 Python 串口助手(`tools/serial_tool/`,常驻 daemon + CLI) |
| 工具 | 仓库内置 Python 串口助手(`.dsh/skills/serial-assistant/`,常驻 daemon + CLI) |
**启动标记(`@test` 判据)**:
- Required:`App init OK` / `Network Init` / `net init: OK` / `PHY: 100M FULL`
@@ -647,7 +647,7 @@ mac=00:00:00:00:00:00
# 构建(@build):0 错 0 警
& "MDK-ARM\build.bat" # 退出码 0=PASS / 1=构建失败 / 2=环境错误
# 自动化测试(@test):串口采集 → 烧录 → 日志校验
& '.venv\Scripts\python.exe' test/host/run_boot_test.py # 0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=用法错误
& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py # 0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=用法错误
# 全库门禁
pre-commit run --all-files
```
@@ -287,14 +287,14 @@ void vApplicationMallocFailedHook(void)
& '.venv\Scripts\python.exe' scripts/stack_report.py
# 推荐用法(静态 + 实测一起判,判定取两者更坏者;报告归档到 data/)
& '.venv\Scripts\python.exe' scripts/stack_report.py -s tools\serial_tool\state\serial.log `
& '.venv\Scripts\python.exe' scripts/stack_report.py -s .dsh\skills\serial-assistant\scripts\state\serial.log `
-o 'docs\内存管理\data\stack_report_<日期>.md'
```
| 参数 | 默认 | 说明 |
|---|---|---|
| `-i/--html` | `MDK-ARM/STM32F407-Demo/STM32F407-Demo.htm` | 静态调用图(Keil 构建产物) |
| `-s/--serial` | 无 | 串口日志,解析其中 `[STACK]` 行(daemon 全量历史:`tools/serial_tool/state/serial.log`) |
| `-s/--serial` | 无 | 串口日志,解析其中 `[STACK]` 行(daemon 全量历史:`.dsh/skills/serial-assistant/scripts/state/serial.log`) |
| `-o/--output` | 无(打印到 stdout) | 报告输出文件(Markdown) |
| `--isr-margin` | `1024` | ISR 嵌套预留(字节) |
| `--min-free-pct` | `30` | 要求的最小余量百分比 |
@@ -616,7 +616,7 @@ V4 逐项相加须与 V1 的 `Image component sizes` 逐 .o 吻合;不等则
```powershell
& '.venv\Scripts\python.exe' scripts/ram_report.py -o "docs\内存管理\data\ram_report_<日期>.md" # 静态台账
& '.venv\Scripts\python.exe' scripts/stack_report.py -s tools\serial_tool\state\serial.log `
& '.venv\Scripts\python.exe' scripts/stack_report.py -s .dsh\skills\serial-assistant\scripts\state\serial.log `
-o "docs\内存管理\data\stack_report_<日期>.md" # 栈预算(静态 × 实测)
& '.venv\Scripts\python.exe' tools/arch_diagram/gen_memory_svg.py # 内存布局/任务栈预算图
```
@@ -3,7 +3,7 @@
- Source: `MDK-ARM\STM32F407-Demo\STM32F407-Demo.htm` (Keil static call graph)
- Linker run: Mon Sep 21 04:28:54 2026
- Global maximum stack usage: **2776 B** (any call tree)
- Runtime watermark: 6 task(s) from `tools\serial_tool\state\serial.log`
- Runtime watermark: 6 task(s) from `.dsh\skills\serial-assistant\scripts\state\serial.log`
Rule: `margin = cfg - max(static_max_depth, runtime_peak)` must be >= max(1024 B ISR, 30% of cfg).
@@ -3,7 +3,7 @@
- Source: `MDK-ARM\STM32F407-Demo\STM32F407-Demo.htm` (Keil static call graph)
- Linker run: Sat Oct 10 10:13:26 2026
- Global maximum stack usage: **2776 B** (any call tree)
- Runtime watermark: 5 task(s) from `tools\serial_tool\state\serial.log`
- Runtime watermark: 5 task(s) from `.dsh\skills\serial-assistant\scripts\state\serial.log`
Rule: `margin = cfg - max(static_max_depth, runtime_peak)` must be >= max(1024 B ISR, 30% of cfg).
@@ -124,7 +124,7 @@ LVGL 集合明细(均在 SRAM):LVGL 池 40,960B(`lv_mem_core_builtin.o`
任何 scatter / CCM / 堆大小变更后,依次通过以下全部关卡方可认定生效:
1. `@build`(0 错误 0 警告)
2. 产品构建 `@test`(`test/host/run_boot_test.py`):标记校验 PASS,且**零 `[ERR]`**(默认 Forbidden 已含 `[ERR]`)
2. 产品构建 `@test`(`.dsh/skills/serial-boot-test/scripts/run_boot_test.py`):标记校验 PASS,且**零 `[ERR]`**(默认 Forbidden 已含 `[ERR]`)
3. 存储套件(`test_config.h` → `TEST_SUITE_STORAGE`):只读模式实测 **3/3 PASSED**(破坏性关,卷内容不变);开启 `ENABLE_DESTRUCTIVE_TESTS` 后为全量集成用例(2026-09-20 实测 **20/20 PASSED**,含 TC-STO-01/02/03/05/06)
4. GD5F 套件(`TEST_SUITE_GD5F`):只读模式实测 **16/16 PASSED**(含只读的守卫负路径 TC-GD5F-704);开启 `ENABLE_DESTRUCTIVE_TESTS` 后 **68/68 PASSED**,并确认日志出现 2 条守卫负路径 `[ERR]`(TC-GD5F-704/705)——破坏性模式运行须放宽 Forbidden(`--forbidden "FAIL;HardFault"`)
5. 恢复跑(`ENABLE_STORAGE_RECOVERY_TESTS`):期望 **3/3 PASSED**
@@ -140,7 +140,7 @@ LVGL 集合明细(均在 SRAM):LVGL 池 40,960B(`lv_mem_core_builtin.o`
| DMA 阈值 1024B | `Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h`(`GD5F_DMA_THRESHOLD`,公开供测试对齐上下界) |
| DMA 可达性运行时守卫 | `Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c`(`gd5f_dma_buf_ok`,`gd5f_read_from_cache`/`gd5f_program_load` 两处接入) |
| 守卫负路径用例(读/写) | `test/board/gd5f_test_task.c`(TC-GD5F-704/705) |
| 错误级日志纳入自动闭环 | `test/host/verify_log.py` + `test/host/run_boot_test.py`(默认 Forbidden 含 `[ERR]`,`[FAIL]` 仍拦截断言失败) |
| 错误级日志纳入自动闭环 | `.dsh/skills/serial-boot-test/scripts/verify_log.py` + `.dsh/skills/serial-boot-test/scripts/run_boot_test.py`(默认 Forbidden 含 `[ERR]`,`[FAIL]` 仍拦截断言失败) |
| LVGL 越界保护带 32B | `Drivers/BSP/UI/ui_port.c`(`UI_DRAW_BUF_GUARD`) |
| 栈溢出/分配失败钩子 | `Inc/FreeRTOSConfig.h` + `Src/freertos.c`(`vApplicationStackOverflowHook` / `vApplicationMallocFailedHook`) |
| HardFault 现场上报 | `Src/stm32f4xx_it.c`(`HardFault_Handler` → `hard_fault_report`) |
@@ -0,0 +1,202 @@
# ADC 采样节拍设计说明(1 kHz 硬件定时器触发)
> **状态**:**规划中(未实现)**——B1 遗留项。本文件给出 **CubeMX 配置规格**与**代码改造清单**,
> 供实施时按序执行;实现并台架验收后按 §7 判定,并把本行状态改为"已实现"。
> **上位需求**:`docs/算法/继电保护功能复现规划.md` §3.2 L1-1(周期 CONVST 重触发 1000 Hz,
> 与保护节拍同源)。**驱动契约**:`Drivers/BSP/TPAFE5160/README.md`。
> **背景**:B1(2026-10-10)已把 `adc_task` 由空桩改为真实消费者,但采样节拍是
> **任务周期软触发**(`osDelayUntil`)——CONVST 边沿 = 任务被调度到的时刻,抖动大、
> 且与 FreeRTOS tick 同源。本项把节拍源换成**硬件定时器**。
---
## 1. 目标与现状
| 项 | 现状(B1 软触发) | 目标(硬件定时器) |
|---|---|---|
| CONVST 边沿时刻 | 任务被调度时刻(抖动 = 调度延迟,可达数十 µs~ms) | TIM 更新事件 + ISR 入口延迟(亚 µs~数 µs) |
| 周期精度 | 依赖 FreeRTOS tick(TIM7) | 定时器硬件分频,**晶振级** |
| 丢拍 | 任务被长时抢占会漏拍 | 硬件自动重载,不漏拍 |
| 等周波调频 | 需改 tick 周期(不可行) | 运行期写 ARR 即可(§8) |
**判据(L1-1)**:周期 CONVST 重触发 1000 Hz;示波器见 U3 `BUSY` 连续脉冲;
`tpafe5160_frame_seq()` 持续递增(每秒 +1000)。
---
## 2. 方案选型
### 2.1 方案 A(**推荐**):TIM6 更新中断 → ISR 内脉冲 CONVST
- TIM6 配成 1 kHz 更新中断;ISR 内调 `tpafe5160_start_conv_irq()`(现成的 GPIO 脉冲)。
- **零引脚改动**:PC9 保持 `GPIO_Output`,驱动与 `tpafe5160_init()` 首帧自检**完全不动**。
- BUSY(PC7)EXTI 读帧路径**完全不动**。
- 周期精度 = 定时器硬件(晶振级);CONVST 边沿抖动 = TIM6 ISR 入口延迟(见 §5)。
- 运行期改 ARR 即改变采样率 → 天然支持等周波调频。
### 2.2 方案 B(备选,暂不采用):TIM3_CH4/TIM8_CH4 硬件 PWM 直驱 PC9
- PC9 复用为定时器通道,硬件直接产生 CONVST 方波,**零软件抖动**。
- **代价**:① 需把 PC9 由 `GPIO_Output` 改为 AF(`.ioc` 引脚改动);
② 驱动的 `TPAFE5160_CONVST_*` 宏(GPIO 写)全部失效 → `tpafe5160_init()` 的
"确保 CONVST 低"与首帧自检(E-44)都要重写;③ 上电初值/空闲电平语义变化。
- **结论**:收益(把抖动从"数 µs"降到"0")不足以抵消改动面与回归风险。
**仅当 §5 实测抖动超过算法容限时再考虑**。
> 下文只展开**方案 A**。
---
## 3. CubeMX 配置规格(照此配置)
**前提**:SYSCLK = 168 MHz / HCLK = 168 MHz / APB1 = 42 MHz(÷4)→ **APB1 定时器时钟 = 84 MHz**;
HAL 时基 = TIM7(已占用);**TIM1~TIM6、TIM8~TIM14 当前全部空闲**。
| 步骤 | 位置 | 值 | 说明 |
|---|---|---|---|
| 1 | Timers → **TIM6** → *Activated* | ✅ 勾选 | 基础定时器,**无输出引脚**(不占用任何 IO) |
| 2 | Prescaler (PSC) | **83** | 84 MHz ÷ (83+1) = **1 MHz** 计数时钟(1 µs 分辨率) |
| 3 | Counter Period (ARR) | **999** | 1 MHz ÷ (999+1) = **1.000 kHz**(= 1000 µs 周期) |
| 4 | Counter Mode | Up | 向上计数 |
| 5 | **auto-reload preload** | **Enable** | 运行期写 ARR 在下一个更新事件生效,不产生毛刺(§8 等周波调频要用) |
| 6 | Clock Division | No Division | 与采样无关 |
| 7 | Trigger Event Selection | Reset(默认) | 不用 TRGO |
| 8 | NVIC Settings → **TIM6 global interrupt** | ✅ 勾选;**Preemption Priority = 6** | 与 BUSY EXTI(`EXTI9_5_IRQn` = 6)**同级**:两者互不抢占,避免"读 RD 时序被 CONVST 脉冲打断"(§5);且 6 ≥ `configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY`(=5),将来 ISR 里发任务通知也安全 |
**生成后**:CubeMX 会新建 `Src/tim.c` + `Inc/tim.h`(含 `htim6` 与 `MX_TIM6_Init()`)、
在 `Src/stm32f4xx_it.c` 生成 `TIM6_IRQHandler()`、在 `Src/main.c` 的 `main()` 里插入 `MX_TIM6_Init()`。
⚠ **`MX_TIM6_Init()` 只做配置,不启动计数**——启动由用户代码负责(§4 第 4 条)。
> **为何不用 TIM7 那种"1 MHz 计数 + ARR=999"以外的心跳**:本配置与 HAL 时基 TIM7
> 的构造完全一致(同为 84 MHz / 1 MHz / 1000 计数),便于对照排查;PSC 取 83 使 ARR 与
> "微秒"一一对应,等周波调频时可直接 `ARR = 周期µs - 1`。
---
## 4. 代码改造清单
> 约定:**生成区不改**(CubeMX 重生成会覆盖);所有手工代码落 `USER CODE` 保护区(L2)。
### 4.1 `Src/main.c`(CubeMX 生成;改 `USER CODE` 区)
- `USER CODE BEGIN Includes` 增加 `#include "tpafe5160.h"`。
- `HAL_TIM_PeriodElapsedCallback()`(**注意:该回调被 TIM7 时基共用**)增加 TIM6 分支:
```c
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM7) { HAL_IncTick(); } /* 既有:HAL 时基 */
/* USER CODE BEGIN Callback 0 / 1 —— 加在既有 TIM7 分支之后 */
if (htim->Instance == TIM6)
{
tpafe5160_start_conv_irq(); /* 1 kHz 采样节拍:ISR 内脉冲 CONVST(仅 GPIO 写,亚 µs) */
}
}
```
⚠ **不得**破坏既有 `HAL_IncTick()` / `task_wd_tick_probe()` 两条 TIM7 分支。
### 4.2 `Src/tim.c` / `Inc/tim.h` / `Src/stm32f4xx_it.c`
- **CubeMX 自动生成,勿手改**(`TIM6_IRQHandler` → `HAL_TIM_IRQHandler(&htim6)`)。
### 4.3 `App/app/app_main.c`
- 增加 `#include "tim.h"`。
- 第 213 行的**单次** `tpafe5160_start_conv_irq();` 替换为**启动周期触发**:
```c
/* 6) 启动 1 kHz 采样节拍(TIM6 更新中断 → ISR 内触发 CONVST) */
(void)HAL_TIM_Base_Start_IT(&htim6);
```
- ⚠ **必须在 `tpafe5160_init()` 成功之后**再启动 TIM6,否则 ISR 会在驱动自检
(首帧有效性探针,E-44)期间乱发 CONVST。
### 4.4 `App/task/adc_task.c`
- **删除**主循环里的 `tpafe5160_start_conv_irq();`(触发已交给 TIM6)。
- 本任务退化为**纯消费者**:`tpafe5160_snapshot()` + 帧统计 + 10 s 心跳 + 看门狗心跳。
1 ms 节拍(`osDelayUntil`)保留,仅用于"消费节奏"。
- 更新文件头与函数注释(说明节拍由 TIM6 提供,本任务不再触发)。
- **看门狗不变**:`TASK_WD_ADC` 心跳仍在循环内每拍调用。
### 4.5 文档同步
- `docs/算法/继电保护功能复现规划.md` §3.2 L1-1:把"🔶 软触发;硬件定时器为后续"改为 ✅。
- `docs/待处理内容.md` §2.1:本项(B4)销项。
- `AGENTS.md` 架构要点 `adcTask` 条:删"本阶段为任务周期软触发"的注。
- `Drivers/BSP/TPAFE5160/README.md`:补一句"运行期周期触发由 TIM6 更新中断提供"。
---
## 5. 时序与优先级分析
**单周期时序(1 kHz,周期 1000 µs)**:
```
t=0 TIM6 更新事件 → ISR → CONVST 脉冲(LOW→HIGH 上升沿)
t≈0+ε TPAFE5160 开始转换(BUSY 拉高)
t≈23 µs BUSY 拉低 → PC7 EXTI(优先级 6) → 读 8 通道(≈0.5 µs)→ seq++
t=1000 µs 下一个 TIM6 更新事件
```
- **读窗口与下一次 CONVST 相隔 ~977 µs**(转换仅 ~23 µs),互不重叠。
- **TIM6 与 BUSY EXTI 同级(6)→ 互不抢占**:读 RD 时序(8 次 RD 翻转)不会被打断,
避免"读到一半被新转换刷新数据总线"。这是选 6 而非 5 的原因。
- **触发抖动**:TIM6 是最高优先级之一(5 为 USART,7 为 SPI/DMA);被更高优先级 ISR
(USART,~数 µs)挡住的概率与时长都有限 → 最坏抖动约数 µs,占 1000 µs 的 <0.5%。
- **ISR 时长**:`tpafe5160_start_conv_irq()` 只做两次 `HAL_GPIO_WritePin`(寄存器写)
→ 亚 µs,不拖累其它中断。
---
## 6. 风险与注意事项
1. **共用回调**:`HAL_TIM_PeriodElapsedCallback` 同时服务 TIM7(HAL 时基)与 TIM6 →
分支必须并列、不得互相干扰;改动后必须确认开机日志时间戳正常(时基未坏)。
2. **启动顺序**:TIM6 必须在 `tpafe5160_init()` 之后启动(§4.3)。
3. **不新增任务/栈**:本项**零新任务、零新栈、零动态分配** → 不动 `stack_report.py` 预算。
4. **CubeMX 生存**:新增生成文件 `Src/tim.c` / `Inc/tim.h`;改后跑一次
`python scripts/check_cubemx_survival.py`,若有新增生成文件被登记,按提示重建基线。
5. **Keil 工程**:从 CubeMX 重生成工程(含 MDK-ARM)以把 `tim.c` 纳入编译;随后
`@build` 必须 0 错 0 警。
6. **`mem-numbers`**:本项改 Flash/RAM 极小但非零 → 若 `check_mem_numbers.py` FAIL,
跑 `python scripts/check_mem_numbers.py --write` 刷新快照。
7. **消费端与生产端同频**:TIM6(84 MHz 派生)与 FreeRTOS tick TIM7(同源)都恰好 1 kHz,
相位固定 → 消费端每拍恰好取到 1 帧。**一旦进入等周波调频(采样率≠1 kHz),
1 ms 轮询会与生产端失配 → 消费端须改为"帧就绪事件驱动"**(见 §8)。
---
## 7. 验收判据
| # | 判据 | 手段 |
|---|---|---|
| 1 | `@build` 0 错 0 警,且镜像无源文件新于 hex | `MDK-ARM/build.bat` + `find -newermt` |
| 2 | PC9 `CONVST` 周期 = 1.000 kHz(±0.1%) | 示波器 / 逻辑分析仪 |
| 3 | PC7 `BUSY` 每周期一个脉冲,与 CONVST 相位固定 | 示波器双通道 |
| 4 | `frame_seq()` 每秒 +1000(每 10 s +10000),严格递增 | `@test` 串口 `[ADC_TASK] frames=... seq=...` |
| 5 | `frst_err=0`、`idle` 基本为 0 | 同上 |
| 6 | 开机日志时间戳正常(TIM7 时基未坏) | 串口日志 |
| 7 | 无 HardFault / `[ERR]` / 看门狗复位 | `@test` Forbidden 判定 |
---
## 8. 后续:等周波调频(不在本项范围)
参考系统按 **20 点/周波**采样:电网频率漂移时需让采样率跟随(50.0 Hz → 1000 Hz;
49.5 Hz → 990 Hz),以保持傅氏全波窗对齐一个工频周期。
- 本配置已为此预留:**1 µs 分辨率** + **ARR 预装载使能** → 运行期
`__HAL_TIM_SET_AUTORELOAD(&htim6, 周期µs - 1)` 即可无毛刺改频。
- 需要配套:① 测频源(过零/DFT 相位);② 把当前采样率同步给保护算法(相量/定值);
③ **消费端改事件驱动**(§6.7)——否则 1 ms 轮询在非 1 kHz 采样率下会丢帧。
---
## 9. 变更记录
| 日期 | 变更 | 说明 |
|---|---|---|
| 2026-10-10 | 建立本文档 | B1(ADC 采集链路)遗留项"精确 1 kHz 硬件定时器触发"的规划:方案选型(A/B)、CubeMX 配置规格、代码改造清单、时序与优先级分析、风险与验收判据、等周波调频预留 |
@@ -572,4 +572,4 @@ python scripts/check_cubemx_survival.py --write # 确认改动无误后重建
| 2026-10-09 | **4 个 Fault Handler 由 L3 升级为 L2:重生成完全零 diff** | 用与 iwdg 同一招。`Src/stm32f4xx_it.c` 的 4 个 `__asm void X_Handler` 从**生成区**搬进 `USER CODE BEGIN 0`,且必须写在文件头那 4 个 `#define X X_Gen` **之前**(写在之后会被自己的宏改名);配套的 `#undef X` 随之删除(不再需要)。生成区恢复 CubeMX 原样(`void HardFault_Handler(void)` + `HardFault_IRQn 0` / `W1_HardFault_IRQn 0` 标记区),4 个锚点注释(`HARD-FAULT-ANCHOR` / `MEMFAULT-ANCHOR` / `BUSFAULT-ANCHOR` / `USAGEFAULT-ANCHOR`)全部退役。`.map` 复核:`startup_stm32f407xx.o(RESET) refers to stm32f4xx_it.o(.emb_text) for HardFault_Handler`(向量表仍指向我们的 `__asm` 版)、`.emb_text → i.fault_entry_* → i.hard_fault_report`、4 个 `*_Gen` 各 `Removing ... (2 bytes)`。审计项 `hardfault-handler` / `fault-handlers-3` 由 `wholefile`+锚点改为 `protected`(区 `0`)+ 代码符号断言。**L3 清单清空**,重生成预期零 diff。`@build` 0 错误 0 警告;审计 21 通过 / 1 警告 / 0 失败 |
| 2026-10-10 | **正文按当前代码重写(清理立项前的描述)** | 正文原样保留了大量**立项前(2026-09-29)**的状态取证与已废弃的"路径 C 考古",与现状矛盾,故重写为现状设计说明(1144 → 571 行;**历史与逐次决策全部保留在下方变更记录,未删**)。主要更正:① §1.1"现状:无任何硬件看门狗"改为**历史背景**(IWDG 已在 `.ioc`/`hal_conf`/工程三处就位);② **ftpTask 早已纳入监视**——经 `lftpd_task_wd_beat()` 弱钩子(事件循环 / RETR·STOR 分片 / 连接与卷锁等待),旧正文"首期不埋点、由掩码剔除"及 R3/O1 已作废;③ 补齐正文缺失的三块现状实现:**FTL 心跳路由**(`task_wd_bind()` + `nand_ftl_wd_beat()`)、**FTL 直接喂狗**(`nand_ftl_wd_feed()`,含"设备存活但功能冻结"边界)、**round 7/8/8b 取证机制**(`/log/wdev.log`、`.noinit` 槽 + `UNINIT_CCM`、`PREVRUN`/`TICKRUN`/`MISS start`/`STALL`);④ 掩码改为按代码实况列表(产品 `0x37` / `ENABLE_HW_LAYER_TESTS` `0x35` / **testTask 任何构建都不入掩码**)并写明"精确相等"语义;⑤ 复位原因优先级按 `reset_cause_str()` 实际顺序改写(旧表把 POR 排第一,代码是 **IWDG 最优先**);⑥ §4.6 更正为**已集成**:看门狗复位写 `selftest.log`(旧正文"未改");⑦ §8 预算改用 `.map` 实测(`task_watchdog.o` 1098 B 代码 / 44+104 B RAM / `.noinit` 48 B;`iwdg.o` 仅 28 B,`HAL_IWDG_Init` 94 B 被移除);⑧ 附录 B 自相矛盾修正(旧表把"借助 CubeMX"列为否决,实际已选它);⑨ §7 由"验证计划"改为**验证记录**(V2/V4/V5 ✅、U1 39 断言 ✅,V3/V6/V7 标为未单独注入、V8 未做)。连带同步:`Src/iwdg.c`、`Src/freertos.c`、`App/task/default_task.c` 三处注释的章节引用(旧 §4.5/§4.6 → 新 §4.1/§4.4),`scripts/check_cubemx_survival.py` 提示语(§10.1 → §10.2/§10.3),`实机测试实施指南.md`(§10 → §7 ×3)、`看门狗单元测试说明.md`(§10 → §7)、`模拟器差距清单.md` H-1(§4.9 → §4.7,并更正"模拟器不编译本模块、无 IWDG 桩")、`docs/README.md`("实施中/待验证" → 已闭环)。⚠️ **下方历史行里的 §x.y 引用指当时编号**,不再回溯改写 |
| 2026-10-10 | **adcTask 纳入监视(B1):必需掩码 `0x37`→`0x77`** | B1(ADC 采集链路)落地时新增 `adcTask`(`App/task/adc_task.c`:1 ms 采样节拍触发 CONVST + 消费 `tpafe5160_snapshot()`)并纳入 IWDG 监视:`task_wd_id_t` 新增 `TASK_WD_ADC = 6`;`wd_required_mask()` 产品 `0x37`→`0x77`、`ENABLE_HW_LAYER_TESTS` `0x35`→`0x75`(adcTask **不依赖任何就绪屏障** → 任何构建都置位)。adcTask 主循环每 1 ms `task_wd_beat(TASK_WD_ADC)`;**不做 NAND I/O → 不登记 FTL 心跳路由**(无需 `task_wd_bind()`)。主机侧单测 `beat_all()` 与掩码断言(`tc_003`/`tc_005`/`tc_011` 的 `0x37`→`0x77`)同步,39 断言全绿。任务由 `Src/freertos.c` 的 `USER CODE` 区手工创建(同 timeSyncTask,不在 `.ioc` 里),栈 512 words、优先级 Normal,`stack_report.py` 实测最坏链 504 B(余 1544 B / 75%)。见 `docs/算法/继电保护功能复现规划.md` §7 |
| 2026-10-10 | **B1 `@test` 实机闭环:adcTask 掩码真机验证** | 台架闭环(`test/host/run_boot_test.py`)确认 `adcTask` 纳入掩码后**无 adc 相关误复位**:`[STACK] adc peak_used=496/2048 free_min=1552 (75%)`、`frames/seq` 每 10 s 严格 +10000(=1000 Hz)、`idle=0`/`frst_err=0`、全片回读 `No difference found`。启动早期两次 `MISS start snap=0x67 req=0x77` 仅缺 bit4(`timeSync` 未启动,属正常就绪窗;adc 的 bit6 自始置位),掩码随后收敛、全程无复位。同轮另修 `dbg_console_writer_ready()` 门控(adcTask 周期日志在写者注册前抑制,见 `控制台单写者日志设计说明.md`) |
| 2026-10-10 | **B1 `@test` 实机闭环:adcTask 掩码真机验证** | 台架闭环(`.dsh/skills/serial-boot-test/scripts/run_boot_test.py`)确认 `adcTask` 纳入掩码后**无 adc 相关误复位**:`[STACK] adc peak_used=496/2048 free_min=1552 (75%)`、`frames/seq` 每 10 s 严格 +10000(=1000 Hz)、`idle=0`/`frst_err=0`、全片回读 `No difference found`。启动早期两次 `MISS start snap=0x67 req=0x77` 仅缺 bit4(`timeSync` 未启动,属正常就绪窗;adc 的 bit6 自始置位),掩码随后收敛、全程无复位。同轮另修 `dbg_console_writer_ready()` 门控(adcTask 周期日志在写者注册前抑制,见 `控制台单写者日志设计说明.md`) |
@@ -166,19 +166,19 @@ ST-LINK_CLI -c SWD HOTPLUG -Dump 20000000 1000000 dump.bin :: 转储 SRAM
**A. 只重启不烧录**(固件没变,重新验证启动/串口行为;配合 serial_tool)
```powershell
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py open
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py clear
& '.venv\Scripts\python.exe' .dsh/skills/serial-assistant/scripts/serial_tool.py open
& '.venv\Scripts\python.exe' .dsh/skills/serial-assistant/scripts/serial_tool.py clear
& "MDK-ARM\reset.bat" # 等价于 ST-LINK_CLI -c SWD -Rst -Run -Q
# 等待开机完成后直接读日志文件校验标记:
Get-Content tools\serial_tool\state\serial.log -Tail 100
Get-Content .dsh\skills\serial-assistant\scripts\state\serial.log -Tail 100
```
顺序关键:**先开串口清日志,再复位**,否则错过开机头几行(与 `run_boot_test.py` 的"采集先于烧录"原则一致)。
ⓞ 实测补充三条:
1. **改过 `serial_config.json` 端口后必须重启 daemon**:daemon 在启动时缓存配置,改端口后 `close` + 杀旧进程再 `open`,否则一直按旧端口报 `port not found`;
1. **改过 `.serialtest/config.json` 端口后必须重启 daemon**:daemon 在启动时缓存配置,改端口后 `close` + 杀旧进程再 `open`,否则一直按旧端口报 `port not found`;
2. **(已修,2026-10-10)旧版 `wait-idle` 在复位突发期会报 `daemon unresponsive after auto-start: timed out`**:CLI 把**超时**误判成"daemon 不可达",顺手拉起一个绑不上 HTTP 口的实例再把命令撞一次超时(数据其实已落盘)。现 CLI 超时按 `--max-ms` 自动推导、超时只快速失败不再误拉起实例;
3. **日志校验**:`tools/serial_tool/state/serial.log` 用 Read/Grep 直接查最直观;`serial_tool.py log --grep <标记>` 现已可作判定依据(2026-10-10 修:旧实现先 `--tail`(默认 50)后 grep,开机标记必然查不到——这就是当年"实测 0 命中、语义存疑"的真根因)。
3. **日志校验**:`.dsh/skills/serial-assistant/scripts/state/serial.log` 用 Read/Grep 直接查最直观;`serial_tool.py log --grep <标记>` 现已可作判定依据(2026-10-10 修:旧实现先 `--tail`(默认 50)后 grep,开机标记必然查不到——这就是当年"实测 0 命中、语义存疑"的真根因)。
**B. 完整烧写 + 运行**:`MDK-ARM\reset.bat flash [xxx.hex]`(= `-c SWD -P <hex> -Rst -Run`;09/19 实测 1MB ≈ 6.5s,09/20 复测 6.47s + 全片 `-CmpFile` 逐字节一致)。正式流程仍以 `download.bat`(`UV4 -f`)为准,CLI 路径作为脚本化/快速路径。
+3 -3
View File
@@ -1,11 +1,11 @@
# 启动日志(boot_log)存档
本目录保存 `@test`(`test/host/run_boot_test.py`)产出的**开机串口日志快照**,
本目录保存 `@test`(`.dsh/skills/serial-boot-test/scripts/run_boot_test.py`)产出的**开机串口日志快照**,
用于**跨构建的栈 / 堆水位对照**(解析其中的 `[STACK]` 行)。
## 1. 为什么需要存档
- `@test` 默认把 boot_log 写到临时目录 `%TEMP%\opencode\boot_log_<时间戳>.txt`——**会被系统清理**;
- `@test` 默认把 boot_log 写到临时目录 `%TEMP%\serial-boot-test\boot_log_<时间戳>.txt`——**会被系统清理**;
- 台架长跑的 20MB 串口日志**截头会丢掉早期 `[STACK]` 水位行**(开机首次约 5s 后才报告);
- 两者叠加导致**无法事后回溯**"改代码前后"的栈水位变化。
@@ -15,7 +15,7 @@
| 项 | 规则 |
|---|---|
| 来源 | `& '.venv\Scripts\python.exe' test/host/run_boot_test.py`(默认 `--out-dir %TEMP%\opencode`,产出 `boot_log_<stamp>.txt` + `selftest_log_<stamp>.log`) |
| 来源 | `& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py`(默认 `--out-dir %TEMP%\serial-boot-test`,产出 `boot_log_<stamp>.txt` + `selftest_log_<stamp>.log`) |
| 命名 | `<日期>_<构建标识>_boot_log.txt`,例:`20261005_prod_none_boot_log.txt` |
| 构建标识 | 建议含:产品 / 测试套件(`prod_none` / `suite_ch395f` …)+ 负载(`idle` / `v5_30min` / `ftp_stress`) |
| 随文件登记 | 在下方 §3 清单登记日期、构建、负载、关键 `[STACK]` 水位(如 defaultTask 峰值) |
+3 -3
View File
@@ -20,13 +20,13 @@
```powershell
& "MDK-ARM\build.bat"
& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py clear
& '.venv\Scripts\python.exe' .dsh\skills\serial-assistant\scripts\serial_tool.py clear
& '.venv\Scripts\python.exe' test\latency_peer.py --host 192.168.1.100 --port 8080 `
--json "docs\测试说明\m2_data\m2_quiet_runNN.json" --wait 170 # 后台作业
& "MDK-ARM\download.bat"
& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py wait-idle `
& '.venv\Scripts\python.exe' .dsh\skills\serial-assistant\scripts\serial_tool.py wait-idle `
--timeout 120 --settle-ms 10000 --max-ms 110000
# 判定:Grep tools\serial_tool\state\serial.log
# 判定:Grep .dsh\skills\serial-assistant\scripts\state\serial.log
```
判定标记:Required `App init OK` / `Network Init` / `net init: OK` / `[LAT] report complete` /
+16 -16
View File
@@ -2,7 +2,7 @@
> **定位**:面向使用者的操作手册——命令怎么敲、输出怎么看、常见场景怎么组合、出问题怎么排查。
> **关联文档**:设计决策与实现细节见 `串口助手设计说明.md`;@test 自动化闭环见 `自动化测试流程.md`;`AGENTS.md`「串口调试」节有一页速查。
> **版本**:v1.1(与 `tools/serial_tool/serial_tool.py` 当前实现一致,2026-09-15)
> **版本**:v1.1(与 `.dsh/skills/serial-assistant/scripts/serial_tool.py` 当前实现一致,2026-09-15)
## 1. 它是什么(60 秒版)
@@ -12,12 +12,12 @@
|---|---|---|
| **常驻 daemon** | 后台 Python 进程,**串口唯一持有者**,把 RX/TX 持续写进日志文件 | 不用管它——任何命令发现它没在跑会**自动拉起** |
| **薄 CLI** | `serial_tool.py <命令>`,每条命令都是带 5s 硬超时的短操作 | 你唯一直接打交道的界面 |
| **日志文件** | `tools/serial_tool/state/serial.log`,**全量历史就在这里** | 想回看任何时刻的数据 → 直接读这个文件 |
| **日志文件** | `.dsh/skills/serial-assistant/scripts/state/serial.log`,**全量历史就在这里** | 想回看任何时刻的数据 → 直接读这个文件 |
**统一入口**(仓库根目录执行,任意 cwd 均可):
```powershell
& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' <命令> [参数]
& '.venv\Scripts\python.exe' '.dsh/skills/serial-assistant/scripts/serial_tool.py' <命令> [参数]
```
两条心法:
@@ -28,19 +28,19 @@
```powershell
# ① 看本机有哪些 COM 口(不依赖 daemon;configured = 配置目标口)
& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' ports
& '.venv\Scripts\python.exe' '.dsh/skills/serial-assistant/scripts/serial_tool.py' ports
# ② 开串口(自动拉起 daemon;端口取 .serialtest/config.json,也可显式指定)
& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' open
& '.venv\Scripts\python.exe' '.dsh/skills/serial-assistant/scripts/serial_tool.py' open
# ③ 发一条数据(文本;--hex 发字节;--nl 末尾加 CRLF)
& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' send "hello" --nl
& '.venv\Scripts\python.exe' '.dsh/skills/serial-assistant/scripts/serial_tool.py' send "hello" --nl
# ④ 看最近 20 行日志(含刚发的 TX 和收到的 RX)
& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' log --tail 20
& '.venv\Scripts\python.exe' '.dsh/skills/serial-assistant/scripts/serial_tool.py' log --tail 20
# ⑤ 不用了:关串口(daemon 继续常驻;要彻底退出用 stop)
& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' close
& '.venv\Scripts\python.exe' '.dsh/skills/serial-assistant/scripts/serial_tool.py' close
```
## 3. 命令速查
@@ -163,8 +163,8 @@
- 等价的手动读法(Agent 用 Read/Grep 工具、人用 PowerShell 均可):
```powershell
Get-Content 'tools\serial_tool\state\serial.log' -Tail 20
Select-String -Path 'tools\serial_tool\state\serial.log' -Pattern 'App init OK'
Get-Content '.dsh\skills\serial-assistant\scripts\state\serial.log' -Tail 20
Select-String -Path '.dsh\skills\serial-assistant\scripts\state\serial.log' -Pattern 'App init OK'
```
### 3.7 `clear` — 清空日志(代际 +1)
@@ -229,9 +229,9 @@ Select-String -Path 'tools\serial_tool\state\serial.log' -Pattern 'App init OK'
| 文件 | 内容 | 谁读 |
|---|---|---|
| `tools/serial_tool/state/serial.log` | RX/TX/SYS 全量日志 | 你(Read/Grep/`log` 命令) |
| `tools/serial_tool/state/meta.json`(运行时未创建) | daemon pid / 版本 / 当前 gen / 口配置快照 | 工具内部 |
| `tools/serial_tool/state/daemon.log` | daemon 自身 stderr(启动行、异常) | 排障时看 |
| `.dsh/skills/serial-assistant/scripts/state/serial.log` | RX/TX/SYS 全量日志 | 你(Read/Grep/`log` 命令) |
| `.dsh/skills/serial-assistant/scripts/state/meta.json`(运行时未创建) | daemon pid / 版本 / 当前 gen / 口配置快照 | 工具内部 |
| `.dsh/skills/serial-assistant/scripts/state/daemon.log` | daemon 自身 stderr(启动行、异常) | 排障时看 |
整个 `state/` 目录已 gitignore——纯本机运行时产物。
@@ -283,7 +283,7 @@ Select-String -Path 'tools\serial_tool\state\serial.log' -Pattern 'App init OK'
... serial_tool.py log --tail 0 # 取本轮全量 → 人眼/脚本检查标记
```
自动化版本 = `test/host/run_boot_test.py`(同一序列 + 烧录 + 标记校验,见 `自动化测试流程.md`)。
自动化版本 = `.dsh/skills/serial-boot-test/scripts/run_boot_test.py`(同一序列 + 烧录 + 标记校验,见 `自动化测试流程.md`)。
### 5.3 长时运行监控
@@ -298,7 +298,7 @@ daemon 常驻,日志持续累积(20MB 自动截头),几天不关也没
```powershell
... serial_tool.py status # ① state/error/counters 一眼看全
Get-Content 'tools\serial_tool\state\daemon.log' -Tail 30 # ② daemon 自身输出
Get-Content '.dsh\skills\serial-assistant\scripts\state\daemon.log' -Tail 30 # ② daemon 自身输出
... serial_tool.py restart # ③ 重建 daemon(顺带加载新代码)
```
@@ -337,7 +337,7 @@ Get-Content 'tools\serial_tool\state\daemon.log' -Tail 30 # ② daemon 自身
| `state: error` + `error: read: …` | 读串口出错/拔线 | daemon 仍活着;重插后 `open` 即恢复(自动 1.5s 退避) |
| 改了 `serial_tool.py` 行为没变 | 运行中的 daemon 还是旧代码 | `… serial_tool.py restart` |
| 开机日志"缺尾巴"(如缺 `Network Init`) | ① `--settle-ms` 太小,在 boot 自然静默间隙就收尾;② 首字节晚于 `--settle-ms` 到达,静默计时提前触发 | 用默认 8000(`run_boot_test.py` 已内置);2026-10-10 起首字节到达前不启动静默计时,必要时调 `--first-timeout-ms` |
| `open` 报 `no port specified … [config: …]` | `serial_config.json` 缺失 / JSON 语法错 / `baudRate`·`httpPort` 非整数(现按 `utf-8-sig` 读并**显式回显**错误,不再静默回退成"没配端口") | 按 `config_error` 文本修文件;`status` 与 `ports` 也回显 `config_error` |
| `open` 报 `no port specified … [config: …]` | `.serialtest/config.json` 缺失 / JSON 语法错 / `baudRate`·`httpPort` 非整数(现按 `utf-8-sig` 读并**显式回显**错误,不再静默回退成"没配端口") | 按 `config_error` 文本修文件;`status` 与 `ports` 也回显 `config_error` |
| 旧日志"丢了" | 20MB 截头丢的是**文件头** | 查旧内容要看尾部;重要轮次开始前先 `clear` 分代 |
## 8. 常见问题
@@ -63,8 +63,8 @@ flowchart LR
D --> L[("state/daemon.log<br/>daemon 自身 stderr")]
```
- **单文件实现**:`tools/serial_tool/serial_tool.py`(stdlib + pyserial,约 1000 行);解释器统一 `.venv\Scripts\python.exe`
- **Agent 调用式**:`& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' <命令> [参数]`(任意 cwd;state 目录按脚本位置解析,与 cwd 无关)
- **单文件实现**:`.dsh/skills/serial-assistant/scripts/serial_tool.py`(stdlib + pyserial,约 1000 行);解释器统一 `.venv\Scripts\python.exe`
- **Agent 调用式**:`& '.venv\Scripts\python.exe' '.dsh/skills/serial-assistant/scripts/serial_tool.py' <命令> [参数]`(任意 cwd;state 目录按脚本位置解析,与 cwd 无关)
- **历史 = 日志文件**:Agent 用 Read/Grep 直接看历史,无需专门 API(`log` 命令只是便捷封装)
- **配置沿用** `.serialtest/config.json`(`portName`/`baudRate` 为默认值;新增可选字段 `httpPort`,默认 18765)
@@ -128,9 +128,9 @@ flowchart LR
| 文件 | 用途 |
|---|---|
| `tools/serial_tool/state/serial.log` | RX/TX/SYS 全量日志(Agent 直接读) |
| `tools/serial_tool/state/meta.json`(运行时未创建) | daemon 元数据(pid / 版本 / 当前 gen) |
| `tools/serial_tool/state/daemon.log` | daemon 自身 stderr(排障用) |
| `.dsh/skills/serial-assistant/scripts/state/serial.log` | RX/TX/SYS 全量日志(Agent 直接读) |
| `.dsh/skills/serial-assistant/scripts/state/meta.json`(运行时未创建) | daemon 元数据(pid / 版本 / 当前 gen) |
| `.dsh/skills/serial-assistant/scripts/state/daemon.log` | daemon 自身 stderr(排障用) |
### 5.2 行格式
@@ -167,7 +167,7 @@ flowchart LR
| 项 | 规定 |
|---|---|
| 单实例 | HTTP 端口(默认 18765,`serial_config.json` 的 `httpPort` 可改)绑定即锁;端口被占 = 已有实例。Windows 上真独占靠 `SO_EXCLUSIVEADDRUSE`(**真名,值 -5**;2026-10-10 修:旧代码写成 `SO_EXCLUSIVEADDRPLACE` + 数值 `0x0040`,`setsockopt` 抛 `WinError 10042` 被静默吞掉,"独占绑定"从未生效),失败现打 WARN,`status.daemon.exclusive_bind` 可验。⚠️ 进程列表里出现**两个** `--serve` 是正常的:`.venv\Scripts\python.exe` 是 venv stub,会派生基解释器(本机 anaconda)子进程跑真 daemon(`status.daemon.pid` = 子进程),不是多实例 |
| 单实例 | HTTP 端口(默认 18765,`.serialtest/config.json` 的 `httpPort` 可改)绑定即锁;端口被占 = 已有实例。Windows 上真独占靠 `SO_EXCLUSIVEADDRUSE`(**真名,值 -5**;2026-10-10 修:旧代码写成 `SO_EXCLUSIVEADDRPLACE` + 数值 `0x0040`,`setsockopt` 抛 `WinError 10042` 被静默吞掉,"独占绑定"从未生效),失败现打 WARN,`status.daemon.exclusive_bind` 可验。⚠️ 进程列表里出现**两个** `--serve` 是正常的:`.venv\Scripts\python.exe` 是 venv stub,会派生基解释器(本机 anaconda)子进程跑真 daemon(`status.daemon.pid` = 子进程),不是多实例 |
| 拉起方式 | CLI detached 子进程(`CREATE_NO_WINDOW`),解释器 = `sys.executable`(保证同一 .venv) |
| `open` 语义 | 幂等(同端口已开 = 成功返回,**不重开**);端口不存在 → ≤1s 报错并附可用端口列表;被占用 → ≤1s 报错(Windows 共享冲突即时返回)并提示持有者 |
| DTR/RTS | 默认**不翻转**(`dtr=False, rts=False`,防 STM32 自动复位电路误复位目标板);`--dtr/--rts` 可显式置位 |
@@ -201,7 +201,7 @@ flowchart LR
| 对象 | 变更 |
|---|---|
| `requirements.txt` | 增 `pyserial`(**保持纯 ASCII** 注释) |
| `.gitignore` | 增 `tools/serial_tool/state/` |
| `.gitignore` | 增 `.dsh/skills/serial-assistant/scripts/state/` |
| `opencode.json`(仓库根) | **删除 `mcp.serial` 段**(指向缺失二进制,已失效) |
| `测试说明/serial-mcp-server_串口调试.md` | **已删除**(内容精华并入本文;工具历史见 §12) |
| `AGENTS.md` | "串口调试"节改写为 serial_tool(配置源仍 `serial_config.json`);"自动化测试"节同步新流程 |
@@ -238,7 +238,7 @@ flowchart LR
| # | 步骤 | 产物 | 验证 |
|---|---|---|---|
| S1 | `requirements.txt` 增 pyserial + `.venv` 安装 | requirements.txt | `pip show pyserial` 有版本 ✅ |
| S2 | daemon 核心:HTTP 服务 + 串口读线程 + 行日志写(含 clear/截头/DTR 策略) | `tools/serial_tool/serial_tool.py`(daemon 部分) | `start→status→send→stop` 手动走通 ✅ |
| S2 | daemon 核心:HTTP 服务 + 串口读线程 + 行日志写(含 clear/截头/DTR 策略) | `.dsh/skills/serial-assistant/scripts/serial_tool.py`(daemon 部分) | `start→status→send→stop` 手动走通 ✅ |
| S3 | CLI 全子命令 + 自动拉起 + 超时契约 + `selftest` | serial_tool.py(CLI 部分) | `selftest` 0=PASS + 命令走查 ✅ |
| S4 | 验收 A1–A6、A8 | 测试通过记录 | A1–A5/A8 PASS ✅(本机无 CH340,A6 待硬件) |
| S5 | @test 集成:`run_boot_test.py` 采集步改写 + 参数收敛 | run_boot_test.py | 负路径(端口缺失→exit 2)+ 假烧录全链路 + verify 正/负判定 ✅;**A7 正路径待 CH340 接入** |
@@ -253,7 +253,7 @@ flowchart LR
| O1 | 换机器缺 CH340 驱动 → `ports` 无目标口 | `ports` 返回带提示("未找到 CH340,请装驱动");文档列驱动要求 |
| O2 | 截头瞬间 Agent 恰在读文件 → 视图错位 | 已知可接受:Agent 读**尾部**;`log` 命令经 daemon 读更稳 |
| O3 | daemon 强杀瞬间丢 ≤1 行(内存残行) | 可接受(debug 日志场景);`close` 路径保证冲刷 |
| O4 | HTTP 端口 18765 与其他服务冲突 | `serial_config.json` `httpPort` 可改 |
| O4 | HTTP 端口 18765 与其他服务冲突 | `.serialtest/config.json` `httpPort` 可改 |
| O5 | TX 用途(本工程 USART1 单向输出) | 保留能力,面向未来交互/RS485 调试;v1 仅保证进日志 |
| O6 | daemon 常驻内存(Python 进程 ~20MB) | 可接受;`stop` 即释放 |
@@ -49,7 +49,7 @@
| A5 | 全量静态门禁 | `pre-commit run --all-files` | **17/17 Passed**(2026-10-03 实测;含新增 S5 `test-cppcheck`) |
| A6 | ST-LINK 探针可见 | `& "MDK-ARM\reset.bat" list`(CLI 经 `DTU_STLINK_CLI` / PATH 解析) | 列出探针 SN `0B250D12A218303030303032` |
| A7 | 串口端口正确 | 检查 `.serialtest/config.json` 的 `portName` | 端口单一配置源 = 此文件(当前 COM6,2026-09-21 台架重插后由 COM7 变更);变更后先杀旧 daemon 再 `open` |
| A8 | 串口助手 daemon 可用 | `& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py status` | 返回 JSON,`open: true/false` 可读 |
| A8 | 串口助手 daemon 可用 | `& '.venv\Scripts\python.exe' .dsh/skills/serial-assistant/scripts/serial_tool.py status` | 返回 JSON,`open: true/false` 可读 |
> ⚠️ **A4 的陷阱**(2026-10-02 实测):`@build` 是**增量**构建,`run_all_suites.py` 跑完
> 套件后 `.map` 会停在**测试套件体积**;仅还原 `test_config.h` 不够,**必须再编一次产品固件**
@@ -71,10 +71,10 @@
```bash
# 一键闭环(selftest.log 自动 FTP 抓取;无故障记录 = PASS)
& '.venv\Scripts\python.exe' test/host/run_boot_test.py --require-selftest-pass --device-ip 192.168.1.100
& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py --require-selftest-pass --device-ip 192.168.1.100
# 或手动联合判定(本轮串口日志 + selftest.log 本地副本)
& '.venv\Scripts\python.exe' test/host/verify_log.py \
& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/verify_log.py \
--log-path <本轮串口日志> \
--require-selftest-pass \
--selftest-log <下载的 selftest.log 本地副本>
@@ -122,7 +122,7 @@
```bash
# --- V2/V4(故障注入,需临时改码)---
# 1) 基线:跑一轮产品 @test 确认无复位
& '.venv\Scripts\python.exe' test/host/run_boot_test.py
& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py
# 2) 注入:按设计说明 §10 在 netTask 循环插 while(1);(或 taskDISABLE_INTERRUPTS(); while(1);)
# —— 改完 @build,然后烧录
@@ -382,7 +382,7 @@ CH395F / STORAGE / GD5F,未含 RTC;怀疑 RTC 构建下 `testTask` 不被创
`PASV→STOR` 间隔达 **11 s**(数据口 LISTEN 半开)。这是**与看门狗误复位不同的另一类
问题**(CH395F 数据通道);若修复后仍复现 425,转 §7.1 专项。
4. **串口采集铁律**:`open` → `clear` → 烧录/复位 → `wait-idle --timeout 90 --settle-ms 8000
--max-ms 45000` → **直接 Read/Grep `tools/serial_tool/state/serial.log`**
--max-ms 45000` → **直接 Read/Grep `.dsh/skills/serial-assistant/scripts/state/serial.log`**
(`log --grep` 子命令不可信);daemon 残留 → `taskkill /F /PID <旧>` 后重新 `open`。
5. **只用 `.venv\Scripts\python.exe`**(PATH 里的 `python` 是 msys64);每次烧录后**必须**
ST-LINK 全片回读 `No difference found`。
@@ -266,10 +266,10 @@ static void phaseN_run(void) {
| 工具 | 位置 | 作用 |
|---|---|---|
| Python 串口助手(serial_tool) | `tools/serial_tool/serial_tool.py`(.venv + pyserial) | 常驻 daemon + 薄 CLI:列端口 / 开关 / 收发 / 全量日志 / wait-idle |
| `test/host/run_boot_test.py` | `test/host/` | 一键闭环:采集 → 烧录 → 提取日志 → 校验 |
| Python 串口助手(serial_tool) | `.dsh/skills/serial-assistant/scripts/serial_tool.py`(.venv + pyserial) | 常驻 daemon + 薄 CLI:列端口 / 开关 / 收发 / 全量日志 / wait-idle |
| `.dsh/skills/serial-boot-test/scripts/run_boot_test.py` | `test/host/` | 一键闭环:采集 → 烧录 → 提取日志 → 校验 |
| **`test/run_all_suites.py`** | `test/` | **多套件回归编排**(T-5)+ **`--check-build` 只编译不烧录**(S6,纯离机,验证 4 套件可编译性);见 §3.6 |
| `test/host/verify_log.py` | `test/host/` | 日志标记校验(Required / Forbidden → PASS / FAIL)+ 可选套件汇总行校验(T-2) |
| `.dsh/skills/serial-boot-test/scripts/verify_log.py` | `test/host/` | 日志标记校验(Required / Forbidden → PASS / FAIL)+ 可选套件汇总行校验(T-2) |
| **`test/host/common/unit_test_harness.py`** | `test/host/common/` | **L0.5 通用单测 harness**:工具链探测 / `INCLUDE`-`LIB` 拼接 / 编译运行 / 退出码契约。各模块运行器只做声明式配置(T-3) |
| **`test/host/watchdog/run_unit_test.py`** | `test/host/watchdog/` | **L0.5 主机侧单测**:声明 `TestBuild` 后调 harness,穷举看门狗判定状态机(零硬件、秒级)。**需本机装有 VS(`cl.exe`)**,见 §4.2 环境要求 |
| **`test/host/sys_clock/run_unit_test.py`** | `test/host/sys_clock/` | **L0.5 主机侧单测**:穷举 `sys_clock` 时间换算 / 星期 / 惰性再基准化 / RTC 故障降级(100 断言,S1-a) |
@@ -420,7 +420,7 @@ flowchart TD
```powershell
# 测试套件构建回归(要求套件跑到汇总行,且断言数 > 0)
& '.venv\Scripts\python.exe' test/host/run_boot_test.py `
& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py `
--required "=== Storage Tests:" --forbidden "ERROR;HardFault;[ERR]" `
--require-suite-summary "Storage Tests"
```
@@ -476,7 +476,7 @@ flowchart TD
| 代码变更影响判定标准 | 立即同步更新模块 Test_Guide,并登记该文档变更记录 |
| 有意改视觉 | 刷黄金参考 + 重生成目检 PNG + `UI_自动化测试流程.md` 变更记录登记 |
| 新增测试工具 / 新标记 | `自动化测试流程.md` 命令清单登记(§4.2) |
| 栈 / 堆预算相关改动或基线重采 | 把 `@test` 的 boot_log 快照(`%TEMP%\opencode\boot_log_*.txt`)存入 `docs/测试说明/boot_log_data/` 并在其 §3 清单登记(防临时目录被清理 / 长跑日志截头丢 `[STACK]` 行) |
| 栈 / 堆预算相关改动或基线重采 | 把 `@test` 的 boot_log 快照(`%TEMP%\serial-boot-test\boot_log_*.txt`)存入 `docs/测试说明/boot_log_data/` 并在其 §3 清单登记(防临时目录被清理 / 长跑日志截头丢 `[STACK]` 行) |
> 文档与代码一体两面:**代码改了文档没同步 = 未完成**。
+5 -5
View File
@@ -10,7 +10,7 @@
├─ Step 0 预检 .venv + serial_tool(selftest/ports 确认 COM)
├─ Step 1 构建验证 MDK-ARM/build.bat (0 错误 0 警告才继续)
├─ Step 2 烧录 + 采集(一条命令编排:开串口 + 清日志 → 烧录 → 固件身份戳 → wait-idle → 提取 → 回读校验)
│ test/host/run_boot_test.py (内置 serial_tool + download.bat + reset.bat cmp + verify_log.py)
│ .dsh/skills/serial-boot-test/scripts/run_boot_test.py (内置 serial_tool + download.bat + reset.bat cmp + verify_log.py)
├─ Step 3 判定:
│ PASS → 报告,结束
│ FAIL → 依据 [MISS]/[HIT] + 日志尾部,修改代码后回到 Step 1
@@ -23,10 +23,10 @@
| `@build` | `keil-build` skill(`.dsh/skills/keil-build/`,自带 `keil_build.py`) | 仅编译验证(0 错误 0 警告) |
| `@download` | `stlink-flash` skill(`.dsh/skills/stlink-flash/`,自带 `stlink_flash.py`) | 编译并烧录(ST-Link) |
| `@test` | `serial-boot-test` skill(`.dsh/skills/serial-boot-test/`) | 完整闭环:build → capture+download → verify → iterate |
| `test/host/run_boot_test.py` | `test/host/run_boot_test.py` | **一键闭环**:open + clear → 烧录 → 固件身份戳(从 hex 抠 `fw build:`)→ wait-idle(静默 8s/超时 30s/首字节 12s)→ 提取 `[RX]`(每行加 `[+Nms]` 时基)→ 回读校验(`reset.bat cmp`)→ verify;`--keep-out` 清旧产物;退出码 0=PASS/1=FAIL/2=采集·烧录·回读错误/3=用法错误 |
| `.dsh/skills/serial-boot-test/scripts/run_boot_test.py` | `.dsh/skills/serial-boot-test/scripts/run_boot_test.py` | **一键闭环**:open + clear → 烧录 → 固件身份戳(从 hex 抠 `fw build:`)→ wait-idle(静默 8s/超时 30s/首字节 12s)→ 提取 `[RX]`(每行加 `[+Nms]` 时基)→ 回读校验(`reset.bat cmp`)→ verify;`--keep-out` 清旧产物;退出码 0=PASS/1=FAIL/2=采集·烧录·回读错误/3=用法错误 |
| `MDK-ARM/reset.bat` | `MDK-ARM/reset.bat` | ST-LINK CLI 封装:`flash [hex]`(烧 + `-Rst -Run`)/ **`cmp [hex]`(回读比对,2026-10-10 加)**/ 无参复位 / `halt`/`run`/`list`/`hard` |
| `tools/serial_tool/serial_tool.py` | `tools/serial_tool/` | Python 串口助手(常驻 daemon + 薄 CLI):ports/open/send/status/log/clear/wait-idle/selftest;全量日志 `state/serial.log`(20MB 自动截头) |
| `test/host/verify_log.py` | `test/host/verify_log.py` | 对日志做标记校验:Required/Forbidden(**命中逐行打印行号+内容**)、启动锚点计数 `--max-boot-count`、原因白名单 `--expect-reset-cause`、标记顺序 `--require-order`、开机耗时 `--done-marker`/`--max-boot-ms`、`--json` 结构化输出;退出码 0/1/2 |
| `.dsh/skills/serial-assistant/scripts/serial_tool.py` | `.dsh/skills/serial-assistant/` | Python 串口助手(常驻 daemon + 薄 CLI):ports/open/send/status/log/clear/wait-idle/selftest;全量日志 `state/serial.log`(20MB 自动截头) |
| `.dsh/skills/serial-boot-test/scripts/verify_log.py` | `.dsh/skills/serial-boot-test/scripts/verify_log.py` | 对日志做标记校验:Required/Forbidden(**命中逐行打印行号+内容**)、启动锚点计数 `--max-boot-count`、原因白名单 `--expect-reset-cause`、标记顺序 `--require-order`、开机耗时 `--done-marker`/`--max-boot-ms`、`--json` 结构化输出;退出码 0/1/2 |
## 判定标准
@@ -77,7 +77,7 @@
| 现象 | 处理 |
|---|---|
| `selftest` FAIL / `ModuleNotFoundError: pyserial` | venv 未建或依赖缺失:按 AGENTS.md「Python 环境」重建 `.venv` + `pip install -r requirements.txt` |
| daemon 无响应 / 自动拉起失败 | 查 `tools/serial_tool/state/daemon.log`;`serial_tool.py restart` 重建 daemon;检查 COM 是否被其他程序占用 |
| daemon 无响应 / 自动拉起失败 | 查 `.dsh/skills/serial-assistant/scripts/state/daemon.log`;`serial_tool.py restart` 重建 daemon;检查 COM 是否被其他程序占用 |
| 抓到日志缺 `Network Init` | `-SettleMs` 太小(boot 任务间有 ~3-4s 自然静默间隙),用默认 8000ms;2026-10-10 起首字节到达前不启动静默计时(`--first-wait-ms` 默认 12000),首行晚到不再被提前收尾 |
| 烧录后无数据(`no_data`) | 2026-10-10 起 `no_data` = **本次等待期间零字节**(旧实现比 daemon 累计字节,常驻 daemon 下永不触发,只能靠"日志里没有 [RX] 行"兜底);开串口 + clear 必须在烧录**前**完成(`run_boot_test.py` 已保证);核对 COM 号、板子供电,看 `state/serial.log` 有无 SYS 异常行 |
| `[MISS] fw build: <戳>`(其余标记全中) | 芯片里跑的不是这份构建:烧录没落地 / 烧错文件 / hex 未重编译。处置:`reset.bat flash <hex>` 重烧并看 `READBACK: OK`;确认 `@build` 已重跑(戳取自编译时刻) |
@@ -241,11 +241,11 @@ DTU-RemoteLCD 客户端;输出每帧 `req2first / total / recv 批次到达时
# 2) 一键闭环:开串口+清日志 → 烧录(reset.bat flash,Reset&Run)→ wait-idle → 标记校验
# 硬件层模式无 "Network Init" 标记(netTask 自挂起),且 TEST_REPORT 行含 "failed=0"
# → Required/Forbidden 均用下方定制集(见 §4.4)
& '.venv\Scripts\python.exe' test\run_boot_test.py `
& '.venv\Scripts\python.exe' .dsh\skills\serial-boot-test\scripts\run_boot_test.py `
--required "App init OK;net init: OK;PHY: 100M FULL;[LAT] report complete" `
--forbidden "HardFault;assert;[ERR];[FAIL];verdict=FAIL"
# 3) 提取 [LAT] 报告行(直接读日志文件最可靠)
& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py log --grep "\[LAT\]"
& '.venv\Scripts\python.exe' .dsh\skills\serial-assistant\scripts\serial_tool.py log --grep "\[LAT\]"
```
台架已跑通:7 段全部 OK、零尖峰、p95≈min(零抖动),§6.1 基线已按实测重锁。
@@ -257,13 +257,13 @@ DTU-RemoteLCD 客户端;输出每帧 `req2first / total / recv 批次到达时
# ENABLE_PHASE13_TESTS(Phase 13 必须独占 8080 socket,勿与其他 NET 层 Phase 同启)
& "MDK-ARM\build.bat" # 必须 0 错 0 警
# 1) 先 clear 再起 peer(顺序不可反:wait-idle 需要看到会话活跃期)
& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py clear
& '.venv\Scripts\python.exe' .dsh\skills\serial-assistant\scripts\serial_tool.py clear
& '.venv\Scripts\python.exe' test\latency_peer.py --host 192.168.1.100 --port 8080 `
--json "docs\测试说明\m2_data\m2_quiet.json" --wait 170 # 后台作业
# 2) 烧录(Reset&Run 不可省)
& "MDK-ARM\download.bat"
# 3) 等空闲:会话本身约 2s,但 peer 首连常因"芯片空闲超时 RST"失败一次并 2s 后重试
& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py wait-idle `
& '.venv\Scripts\python.exe' .dsh\skills\serial-assistant\scripts\serial_tool.py wait-idle `
--timeout 120 --settle-ms 10000 --max-ms 110000
# 4) 判定:**直接 Grep serial.log 文件**(勿用 log --grep,勿只看 wait-idle)
# Required: "App init OK" / "net init: OK" / "[LAT] report complete" / "=== Phase 13: 1/1 PASSED"
@@ -282,7 +282,7 @@ DTU-RemoteLCD 客户端;输出每帧 `req2first / total / recv 批次到达时
```powershell
# M3(生产路径,需要设备已烧 Phase 14 固件)
& "MDK-ARM\build.bat" # 0 错 0 警(Phase 14 构建)
& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py clear
& '.venv\Scripts\python.exe' .dsh\skills\serial-assistant\scripts\serial_tool.py clear
& "MDK-ARM\download.bat" # 先烧录,再起 peer(顺序不可反!)
& '.venv\Scripts\python.exe' test\latency_peer.py --mode remo --host 192.168.1.100 --port 7003 `
--samples 45 --stress-from 15 --ftp-stress --json "docs\测试说明\m3_data\m3_dev_stress.json"
@@ -518,14 +518,14 @@ Get-ChildItem 'C:\ProgramData\anaconda3\Lib\site-packages' -Filter 'cfgv*'
**根因**:CI 的 `actions/checkout@v4` 拉的是**干净克隆**,而钩子校验的是
**仓库根相对路径是否存在**(`check_backtick_paths`,见 `scripts/check_doc_links.py`)。
若文档引用了 **gitignore 的运行时产物**——
如 `tools/serial_tool/state/meta.json`(运行时未创建,`.gitignore:132` 排除整个 `state/`)——
如 `.dsh/skills/serial-assistant/scripts/state/meta.json`(运行时未创建,`.gitignore:132` 排除整个 `state/`)——
你本机因为跑过工具所以文件在,**克隆里则不存在** → 判为失效引用。
**修复**:按 `docs/文档规范.md` §3.3 给该行加**状态词**(脚本的 `STATUS_HINTS`:
`计划 / 未创建 / 尚未创建 / 已删除 / 删除 / 不存在 / 缺失 / 断链 / 待建 / 待创建`)。
```markdown
| `tools/serial_tool/state/meta.json`(运行时未创建) | daemon 元数据(pid / 版本 / gen) |
| `.dsh/skills/serial-assistant/scripts/state/meta.json`(运行时未创建) | daemon 元数据(pid / 版本 / gen) |
```
> ⚠️ **状态词必须与引用在【同一行】且 ≤40 字符**(`STATUS_PROXIMITY`)。
@@ -536,9 +536,9 @@ Get-ChildItem 'C:\ProgramData\anaconda3\Lib\site-packages' -Filter 'cfgv*'
```bash
# 把运行时目录临时移开,模拟干净克隆,跑完再移回
mv tools/serial_tool/state tools/serial_tool/state.bak_ci
mv .dsh/skills/serial-assistant/scripts/state .dsh/skills/serial-assistant/scripts/state.bak_ci
& '.venv\Scripts\python.exe' scripts/check_doc_links.py --all # 或全局 python -m pre_commit run doc-links --all-files
mv tools/serial_tool/state.bak_ci tools/serial_tool/state
mv .dsh/skills/serial-assistant/scripts/state.bak_ci .dsh/skills/serial-assistant/scripts/state
```
> 更一般的教训:**任何校验"文件是否存在/数字是否一致"的钩子,都要在
@@ -662,7 +662,7 @@ date +%s # 对比 last_online
| `l0-gates` 报 `TerminatorExpectedAtEndOfString` / 缺少右引号 | `run:` 块含**中文**,被 WinPS 5.1 按 GBK 解码撞坏引号 | `run:` 块改**纯 ASCII**(中文只留 `name:`/注释) |
| `l0-gates` 报 `Cannot find: pwsh in PATH` | `shell: pwsh` = PS7,本机只有 WinPS 5.1 | 改 `shell: powershell` |
| `l0-gates` 报 `runner missing: cppcheck` | cppcheck 仅在用户级 PATH(SYSTEM 不可见) | 设机器级 `DTU_CPPCHECK`(§5.1.3,**workflow 不再回落固定目录**),或把 cppcheck 目录加入系统 PATH 后重启任务 |
| 🔴 `doc-links` 钩子本地过、CI 挂 | 引用了 **gitignore 的运行时文件**(如 `tools/serial_tool/state/*.json`),本地存在、干净克隆里没有 | 该行加状态词「未创建/不存在」等(§5.1.2) |
| 🔴 `doc-links` 钩子本地过、CI 挂 | 引用了 **gitignore 的运行时文件**(如 `.dsh/skills/serial-assistant/scripts/state/*.json`),本地存在、干净克隆里没有 | 该行加状态词「未创建/不存在」等(§5.1.2) |
| `l05` 日志 `::warning::runner has no MSVC` 但仍 **PASS** | 缺 MSVC 时 L0.5 **降级跳过**(不硬失败) | 装 VS BuildTools(§5.2),否则该层无覆盖 |
| 任务跑满 72 小时被杀 | 计划任务默认 `ExecutionTimeLimit` | 设为 `0`(不限时长),见 §4.3 警示 |
| `config set log.file ...` 报 `unknown config key` | **v4 的 log 段无 `file` 键** | 用 `log.job.dir` 或输出重定向(§3) |
@@ -910,7 +910,7 @@ powershell -ExecutionPolicy Bypass -File "D:\Docker\act_runner\fix_cfgv_global.p
| 日期 | 变更 |
|---|---|
| 2026-10-02 | **workflow 接入 `DTU_*` 机器级变量**(`本地工具路径环境变量方案.md` 实施):`l0-gates` 的 Python 探测改为 `DTU_PRECOMMIT_PYTHON`(显式,无效即硬失败)→ PATH → `.venv`(删除 `C:\ProgramData\anaconda3` 固定候选);cppcheck 改为 `DTU_CPPCHECK` 优先 → PATH(**移除 `C:\Program Files\Cppcheck` 固定回退**);`l05` 的 MSVC 探测改为 `DTU_MSVC_CL` 优先 → `%ProgramFiles*` 推导的标准根(与 `unit_test_harness.py` 同步,不再写死盘符);新增 §5.1.3 机器级变量设置方法(`Machine` scope + 重启任务)、§5.2 与 check_env 样例、排障表 cppcheck 行、TL;DR 5b 步骤。§5.1.1 的报错原文标注为历史记录 |
| 2026-10-02 | ✅ **首次 CI 全绿(run#8:L0 + L0.5 双 job Success)**。定位并修复最后两个拦路虎:① **§5.1.1 用户态 site-packages 陷阱**——`pre-commit` 只装在 `%APPDATA%\Roaming\Python\...`(SYSTEM 不可见);且提权 `pip install --no-user` 会因 pip 扫**整个 `sys.path`**、见 user-site 已满足而**静默跳过**全局安装(9 依赖中唯 `cfgv` 因版本相同被跳过→`-s` 下 `ModuleNotFoundError`)。修法:提权 + `--force-reinstall --no-deps` 强制落全局,并以 `-s` 复验。② **§5.1.2 doc-links 假阳性**——文档引用了 gitignore 的运行时文件 `tools/serial_tool/state/meta.json`,本地存在、干净克隆无 → 按《文档规范》§3.3 加同状态词「运行时未创建」。排障表补 6 条(pre-commit 不可见 / GBK 中文 / pwsh / cppcheck / doc-links / MSVC 降级)。新增配套脚本 `install_precommit_global.ps1`、`install_precommit_global2.ps1`、`fix_cfgv_global.ps1` |
| 2026-10-02 | ✅ **首次 CI 全绿(run#8:L0 + L0.5 双 job Success)**。定位并修复最后两个拦路虎:① **§5.1.1 用户态 site-packages 陷阱**——`pre-commit` 只装在 `%APPDATA%\Roaming\Python\...`(SYSTEM 不可见);且提权 `pip install --no-user` 会因 pip 扫**整个 `sys.path`**、见 user-site 已满足而**静默跳过**全局安装(9 依赖中唯 `cfgv` 因版本相同被跳过→`-s` 下 `ModuleNotFoundError`)。修法:提权 + `--force-reinstall --no-deps` 强制落全局,并以 `-s` 复验。② **§5.1.2 doc-links 假阳性**——文档引用了 gitignore 的运行时文件 `.dsh/skills/serial-assistant/scripts/state/meta.json`,本地存在、干净克隆无 → 按《文档规范》§3.3 加同状态词「运行时未创建」。排障表补 6 条(pre-commit 不可见 / GBK 中文 / pwsh / cppcheck / doc-links / MSVC 降级)。新增配套脚本 `install_precommit_global.ps1`、`install_precommit_global2.ps1`、`fix_cfgv_global.ps1` |
| 2026-10-02 | 🔴 **常驻方式重大修正(实测推翻)**:`Start-Service gitea-runner` 必然失败。经二进制逆向确认 `gitea-runner v4.0.1` **无任何服务管理代码**(命令集仅 `bug-report/cache-server/config/daemon/exec/register`,无 `service.go`;`OpenSCManagerW` 等符号来自 Docker SDK,非服务安装代码),`daemon` 是纯控制台程序、不向 SCM 回报 `SERVICE_RUNNING`。**§4 全节重写为「任务计划程序」方案**(新增 §4.0 根因分析、§4.2 `deploy_runner_task.ps1`、§4.3 手动命令 + 72h 超时警示、§4.5 清理遗留服务、§4.6 提权);删除失效的 `deploy_runner.ps1`/`install_service.ps1`;排障表补「错误 1053」「LastTaskResult 非 0」「72 小时被杀」3 条;拓扑图/§5.1/§7 日志/§10 TL;DR 同步更新。另修 `config.yaml` 的 `fetch_timeout` 5m→1m(超出 RPC 1m 会被截断并告警) |
| 2026-10-02 | **修 `.ps1` 编码坑(实测)**:无 BOM 的 UTF-8 脚本被 WinPS 5.1 按 GBK 解读 → 中文乱码(`鐒跺悗鎵ц`)并撞坏引号配对,报 `源文本中包含无法识别的标记`/`字符串缺少终止符`。**新增 §7.1「给含中文的 `.ps1` 加 UTF-8 BOM」**(两种修法 + 解析器校验命令);排障表补该条目;配套脚本已统一加 BOM |
| 2026-10-02 | **按实测修正为 v4.0.1**:act_runner 已更名 **`gitea-runner`**,发布页迁至 `gitea.com/gitea/runner`。**v4 移除 `daemon install/start/stop/status`**;**v4 `log` 段无 `file` 键**(§3 改用 `log.job.dir`);补 §1.3 命令集对照、§6.1 MySQL 直查核验、排障表(`accepts at most 0 arg(s)` / `unknown config key log.file` 等) |
@@ -183,11 +183,11 @@ python tools/font_gen/extract_cn.py # 任意 python3:
| 项 | 要求 | 说明 |
|---|---|---|
| Python 串口助手 | `tools/serial_tool/serial_tool.py`(仓库内置,.venv + pyserial) | 常驻 daemon + 薄 CLI + 全量日志文件;无需单独安装,`pip install -r requirements.txt` 即就绪 |
| Python 串口助手 | `.dsh/skills/serial-assistant/scripts/serial_tool.py`(仓库内置,.venv + pyserial) | 常驻 daemon + 薄 CLI + 全量日志文件;无需单独安装,`pip install -r requirements.txt` 即就绪 |
| 调试串口 | USART1(PA9 TX / PA10 RX),115200 8N1 | **端口单一配置源 `.serialtest/config.json`**(本机 COM6);换端口只改这个文件 |
| 一键测试 | `test\run_boot_test.py`(即 `@test`,`& '.venv\Scripts\python.exe' test/host/run_boot_test.py`) | 开串口 + 清日志 → 烧录 → wait-idle(静默 8s/超时 30s)→ 提取 → 标记校验;默认端口取 `serial_config.json` / 115200,退出码 0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=用法错误 |
| 一键测试 | `.dsh/skills/serial-boot-test/scripts/run_boot_test.py`(即 `@test`,`& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py`) | 开串口 + 清日志 → 烧录 → wait-idle(静默 8s/超时 30s)→ 提取 → 标记校验;默认端口取 `.serialtest/config.json` / 115200,退出码 0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=用法错误 |
> 采集 = 端口常开 + daemon 全量日志(`tools/serial_tool/state/serial.log`),无采集窗口/first-byte/EOF 重试仪式。
> 采集 = 端口常开 + daemon 全量日志(`.dsh/skills/serial-assistant/scripts/state/serial.log`),无采集窗口/first-byte/EOF 重试仪式。
> 命令契约与陷阱见 `docs/测试说明/串口助手设计说明.md`、`docs/测试说明/自动化测试流程.md`。
### 2.7 代码审查钩子(pre-commit)
@@ -328,11 +328,11 @@ pre-commit run --all-files # 首次全库基线扫描
**第 7 步:Python 串口助手**(串口调试 / 自动测试,仓库内置)
无需单独安装:`tools/serial_tool/` 随仓库提供,依赖 `pyserial`(已入 `requirements.txt`,第 4 步建 `.venv` 时一并安装)。建成后验证:
无需单独安装:`.dsh/skills/serial-assistant/` 随仓库提供,依赖 `pyserial`(已入 `requirements.txt`,第 4 步建 `.venv` 时一并安装)。建成后验证:
```powershell
& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py selftest # → SELFTEST PASS
& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py ports # 枚举 COM 口
& '.venv\Scripts\python.exe' .dsh\skills\serial-assistant\scripts\serial_tool.py selftest # → SELFTEST PASS
& '.venv\Scripts\python.exe' .dsh\skills\serial-assistant\scripts\serial_tool.py ports # 枚举 COM 口
```
**第 8 步:下载器驱动**(ST-Link)
@@ -344,7 +344,7 @@ pre-commit run --all-files # 首次全库基线扫描
**第 9 步:串口配置**
```powershell
& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py ports # 查 COM 号
& '.venv\Scripts\python.exe' .dsh\skills\serial-assistant\scripts\serial_tool.py ports # 查 COM 号
```
把查到的端口写入 `.serialtest\config.json` 的 `portName`(唯一配置源,换口只改这里)。
@@ -358,7 +358,7 @@ pre-commit run --all-files # 首次全库基线扫描
& '.venv\Scripts\python.exe' simulator\tests\check_menu_rows.py # PASS
& '.venv\Scripts\python.exe' simulator\tests\check_content_pages.py --update # 首次生成参考图
& '.venv\Scripts\python.exe' simulator\tests\check_content_pages.py # 再跑 → PASS
& '.venv\Scripts\python.exe' tools\serial_tool\serial_tool.py selftest # → SELFTEST PASS
& '.venv\Scripts\python.exe' .dsh\skills\serial-assistant\scripts\serial_tool.py selftest # → SELFTEST PASS
& '.venv\Scripts\python.exe' test\host\watchdog\run_unit_test.py # 主机侧单测 → 39/39 PASSED(退出码 0)
pre-commit run --all-files
```
@@ -526,7 +526,7 @@ $env:DTU_KEIL_UV4 = 'D:\NotExist\UV4.exe' # 仅当前会话,不动用户级
|---|---|
| 下载方式 | `& "MDK-ARM\download.bat"`(Keil 命令行:烧写 + Reset & Run) |
| 下载器 | 工程已配 `UL2CM3` 调试驱动 + `STM32F4xx_1024` Flash 算法;**物理下载器按现场选择**(本机为 ST-Link——不占 COM 口,可与串口调试并存);新机器在 Keil `Options for Target → Debug → Settings` 选择与硬件一致的驱动 |
| 调试日志 | USART1 115200;抓开机日志必须**先武装采集再复位**(`test/host/run_boot_test.py --flash-cmd` 已保证顺序) |
| 调试日志 | USART1 115200;抓开机日志必须**先武装采集再复位**(`.dsh/skills/serial-boot-test/scripts/run_boot_test.py --flash-cmd` 已保证顺序) |
### 3.5 功能配置开关
@@ -367,7 +367,7 @@ D-2/D-3 的共同根因:**没有任何机械检查**保证"文档说的 = 代
> **实施记录(2026-09-18,按方案 A 落地,两处必要调整)**:① 分配接口**不写在 `cfg_file.c` 里,而是加到 `cfg_file_store` 后端**(`cfg_store_alloc` / `cfg_store_free` / `cfg_store_heap_free`)——`cfg_file.c` 是双端共享源码,直接写 `pvPortMalloc` 会让 PC 模拟器断链;MCU 后端 = `pvPortMalloc/vPortFree`,模拟器后端 = `malloc/free`。② 编译期峰值约束改用 **typedef 负数组**(`CFG_HEAP_BUDGET = 10,240 B`)——ARMCC 5 只到 C99,**没有 `_Static_assert`**(方案 A 原文此处需修正)。③ 另补 4 条失败路径的 `syslog_post(SYSLOG_SELFTEST, …)` 与 `load: OK …, heap free NB` 实测行。台账:`docs/待处理内容.md` **P0-1**;设计文档:`docs/子系统设计/配置文件设计说明.md` §6.2/§8 + v1.11。
> **诚实标注**:本结论由静态分析得出(映射表 + 启动文件 + 调用图三重证据),**尚未上机确认**。`test/host/verify_log.py`(PowerShell 版 `test/verify_log.ps1:34-35` 已删除,2026-09-20 起为纯 Python)的 Forbidden 标记含 `FAIL`,而失败路径会打印 `read: malloc 4097B FAIL` / `template: malloc 5120B FAIL`,因此**一次 `@test` 即可判定**(若近期 `@test` 曾 PASS,需先确认当时是否真的进入了文件读取分支,例如 `/config.ini` 不存在或大小为 0)。**(2026-09-18 已按上述实施;修复后的真机 `@test` 正向确认——`load: OK` 出现且 `FAIL` 零命中——已于 2026-10-03 执行 PASS,2026-10-04 独立复跑再次 PASS:`[CFG] load: OK (523B, heap free 8840B)`、Forbidden `FAIL`/`ERROR`/`HardFault`/`[ERR]` 零命中、`selftest: no fault record`、ST-LINK 全片回读 `No difference found`。)**
> **诚实标注**:本结论由静态分析得出(映射表 + 启动文件 + 调用图三重证据),**尚未上机确认**。`.dsh/skills/serial-boot-test/scripts/verify_log.py`(PowerShell 版 `test/verify_log.ps1:34-35` 已删除,2026-09-20 起为纯 Python)的 Forbidden 标记含 `FAIL`,而失败路径会打印 `read: malloc 4097B FAIL` / `template: malloc 5120B FAIL`,因此**一次 `@test` 即可判定**(若近期 `@test` 曾 PASS,需先确认当时是否真的进入了文件读取分支,例如 `/config.ini` 不存在或大小为 0)。**(2026-09-18 已按上述实施;修复后的真机 `@test` 正向确认——`load: OK` 出现且 `FAIL` 零命中——已于 2026-10-03 执行 PASS,2026-10-04 独立复跑再次 PASS:`[CFG] load: OK (523B, heap free 8840B)`、Forbidden `FAIL`/`ERROR`/`HardFault`/`[ERR]` 零命中、`selftest: no fault record`、ST-LINK 全片回读 `No difference found`。)**
### 4.5 日志子系统(`App/log/sys_log_mcu.c`)
+1 -1
View File
@@ -48,7 +48,7 @@
& '.venv\Scripts\python.exe' scripts/check_test_suite.py # 门禁判定
& '.venv\Scripts\python.exe' scripts/check_test_suite.py --allow-test-build # 放行测试构建
& '.venv\Scripts\python.exe' scripts/ram_report.py
& '.venv\Scripts\python.exe' scripts/stack_report.py -s tools\serial_tool\state\serial.log
& '.venv\Scripts\python.exe' scripts/stack_report.py -s .dsh\skills\serial-assistant\scripts\state\serial.log
```
## ⚠️ 脚本编写约定
+2 -2
View File
@@ -16,10 +16,10 @@
```bash
# @test 一键闭环(推荐)
& '.venv\Scripts\python.exe' test/host/run_boot_test.py
& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py
# 单独校验日志
& '.venv\Scripts\python.exe' test/host/verify_log.py <日志文件>
& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/verify_log.py <日志文件>
# 多套件回归编排(需真机;自动改开关 + 构建 + 烧录 + 采集,结束后还原)
& '.venv\Scripts\python.exe' test/run_all_suites.py
+1 -1
View File
@@ -36,7 +36,7 @@
1. 在 `test_config.h` 加开关(并入互斥 `#error` 检查)
2. 新建 `<模块>_test_task.c/h`
3. **注册进 Keil 工程**(`<FilePath>..\test\board\xxx.c</FilePath>`)
4. 跑回归:`@test`(`test/host/run_boot_test.py`)
4. 跑回归:`@test`(`.dsh/skills/serial-boot-test/scripts/run_boot_test.py`)
## 相关文档
+2 -2
View File
@@ -54,10 +54,10 @@
```bash
# @test 一键闭环(推荐,需真机)
& '.venv\Scripts\python.exe' test/host/run_boot_test.py
& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py
# 测试套件构建的回归:要求套件跑到汇总行且断言数 > 0(T-2)
& '.venv\Scripts\python.exe' test/host/run_boot_test.py `
& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py `
--required "=== Storage Tests:" --forbidden "ERROR;HardFault;[ERR]" `
--require-suite-summary "Storage Tests"
-501
View File
@@ -1,501 +0,0 @@
#!/usr/bin/env python3
"""run_boot_test.py - 一键开机日志抓取 + 校验(纯标准库,2026-09-20 替代 run_boot_test.ps1)。
调用仓库内置串口助手(tools/serial_tool,常驻 daemon,子进程 CLI + JSON):
1. open 串口 + clear 日志(本次运行独立代际)
2. 烧录(默认 MDK-ARM\\download.bat:下载 + Reset & Run)
2.5 固件身份戳:从刚烧进去的 .hex 里抠出 `fw build: <DATE> <TIME>`,追加成 Required
标记——芯片里跑的必须就是这份构建(--no-fw-check 关闭)
3. wait-idle:RX 静默 >= settle-ms 收尾;首字节超过 --first-wait-ms 未到 → no_data
(快速失败);整体 max-wait-ms 超时
4. 从日志提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀),并给每行加 `[+Nms]` 相对
偏移(daemon 毫秒时基,本次第一行 = +0ms;--raw-log 出纯载荷)。MCU 侧时间戳只有
秒级、开机头几行还没时钟,量不了耗时——这个前缀就是耗时口径
4.5 烧录回读校验:MDK-ARM\\reset.bat cmp(= ST-LINK_CLI -CmpFile 全片逐字节比对,
`No difference found` 才算真写进去;--no-verify-flash 关闭)。
**必须排在采集之后**:实测 -CmpFile 连接时会复位目标(板子当场重打一遍开机日志)
5. verify_log.py 标记校验(含启动锚点计数:`reset cause:` 出现 0 次或 >N 次 = FAIL,
N 由 --max-boot-count 给,默认 1;--expect-reset-cause 可对第一个锚点做原因白名单;
--require-order 查标记先后顺序;--done-marker + --max-boot-ms 量开机耗时)
6. (仅当 --require-selftest-pass)自检记录联合判定(S7):
--selftest-log 提供本地副本则直接用它,否则从设备 FTP 抓取 /log/selftest.log
(lftpd :21 匿名);判定:文件缺失 / 0 字节 = 无故障 → PASS,有 64B 记录 = FAIL
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
无采集窗口/首字节等待仪式(与旧 PowerShell 版行为一致)。
相对旧 PowerShell 版的加固(本机 Windows 环境实测教训,2026-09-20):
- 子进程输出按**字节**读取、显式 UTF-8 解码 —— 不受控制台代码页(GBK)影响
- serial_config.json 按 UTF-8(容忍 BOM)解析 —— 无 PS 5.1 按 GBK 读 JSON
导致 "Unterminated string" 崩溃的问题
- 不经 PowerShell —— 无执行策略(scripts disabled)拦截
- 烧录带超时(默认 180s;旧版无超时,坏 SWD 连接会永久挂起)
用法:
& '.venv\\Scripts\\python.exe' test/host/run_boot_test.py
[--port COM7] [--baud-rate 115200] [--settle-ms 8000] [--max-wait-ms 30000]
[--first-wait-ms 12000]
[--flash-cmd MDK-ARM\\download.bat] [--flash-timeout 180]
[--required "App init OK;Network Init;net init: OK"]
[--forbidden "FAIL;ERROR;HardFault;[ERR]"]
[--require-suite-summary "Storage Tests"]
[--require-selftest-pass] [--selftest-log <本地副本>]
[--device-ip 192.168.1.100] [--out-dir <目录>] [--show-tail-lines 15]
[--fw-hex <构建产物 hex>] [--no-fw-check]
[--no-verify-flash] [--readback-timeout 120]
[--max-boot-count 1] [--expect-reset-cause 'software;power-on']
[--require-order 'App init OK>Network Init>net init: OK']
[--done-marker 'net init: OK'] [--max-boot-ms 0]
[--raw-log] [--keep-out 20]
--port 缺省取 .serialtest/config.json 的 portName(单一配置源)
退出码(与旧版一致):
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
1 = FAIL(标记缺失/命中)
2 = 采集或烧录错误(含烧录回读不一致 = 芯片内容与刚编的镜像不同,本次运行作废)
3 = 用法/预检错误
"""
import argparse
import ftplib
import json
import shlex
import subprocess
import sys
import tempfile
import time
from datetime import datetime
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
# 本脚本位于 test/host/,仓库根需上溯两级(2026-10 实机窗口修:原 .parent 只到 test/,
# 导致 .serialtest/config.json 与 tools/serial_tool 路径解析错误)
REPO_ROOT = SCRIPT_DIR.parent.parent
SERIAL_TOOL = REPO_ROOT / "tools" / "serial_tool" / "serial_tool.py"
CFG_FILE = REPO_ROOT / ".serialtest" / "config.json"
DEFAULT_FLASH = REPO_ROOT / "MDK-ARM" / "download.bat"
RESET_BAT = REPO_ROOT / "MDK-ARM" / "reset.bat"
DEFAULT_HEX = REPO_ROOT / "MDK-ARM" / "STM32F407-Demo" / "STM32F407-Demo.hex"
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;[ERR]"
# 固件身份戳(App/app/app_main.c 的 `DBG_INFO("fw build: " __DATE__ " " __TIME__)`)。
# 编译期展开成一段连续 ASCII,故能直接从 .hex 里抠出来当 Required 标记用:
# 芯片里跑的镜像 == 刚编出来的镜像,这才叫"烧进去了"。此前所有标记都是固定串,
# 旧镜像照样全中——AGENTS.md 烧录铁律记的"反复重下载、芯片一直是旧镜像"在
# 自动闭环里完全不可见(2026-10-10 加)。
FW_MARKER = "fw build: "
def _split_cmd(cmd_str):
r"""把 `--flash-cmd` 这类命令串拆成 [exe, arg, ...](2026-10-10 实测坑)。
直接交给 subprocess、**不经 `cmd /c`**:CreateProcess 能直接跑 .bat,且 Python
会给含空格的路径正确加引号。旧写法 `['cmd','/c', 串]` 在本仓库(目录名
`DTU Code` 含空格)会被 cmd 的 /c 引号规则玩坏——实测报
`'D:\Code\DTU' is not recognized`,回读校验被假判成"芯片内容不一致"。
路径本身含空格时把该段用引号包住(`"D:\some dir\reset.bat" flash`)。
posix=False 是必须的:posix=True 会吃掉 Windows 路径里的反斜杠。
"""
return [p.strip('"') for p in shlex.split(cmd_str, posix=False)]
def _run_cmd(argv, timeout):
"""跑一条外部命令,按字节取回输出并显式 UTF-8 解码(不受控制台代码页影响)。"""
try:
proc = subprocess.run(argv, capture_output=True, timeout=timeout)
rc = proc.returncode
raw = (proc.stdout or b"") + (proc.stderr or b"")
except subprocess.TimeoutExpired as e:
rc = -1
raw = (e.stdout or b"") + (e.stderr or b"")
return rc, raw.decode("utf-8", errors="replace")
def extract_fw_stamp(hex_path):
"""从 Intel HEX 里抠固件身份戳(只扫数据记录,不建整片镜像,内存 O(镜像体积))。
返回戳串(如 'Oct 10 2026 02:38:12');找不到(旧固件/未重编译)返回 None。
"""
data = bytearray()
for line in Path(hex_path).read_text(errors="replace").splitlines():
line = line.strip()
if not line.startswith(":"):
continue
try:
rec = bytes.fromhex(line[1:])
except ValueError:
continue
if len(rec) > 4 and rec[3] == 0:
data += rec[4:4 + rec[0]]
i = data.find(FW_MARKER.encode("ascii"))
if i < 0:
return None
j = i + len(FW_MARKER)
end = j
while end < len(data) and 0x20 <= data[end] < 0x7F:
end += 1
return data[j:end].decode("ascii", errors="replace")
def _cli(cmd_args, timeout):
"""调用串口助手 CLI,返回 (rc, stdout_text, parsed_json_or_None)。
全程字节级处理(capture_output 取 bytes 后显式 UTF-8 解码),
任何 Windows 控制台代码页都不参与数据路径。
"""
try:
proc = subprocess.run(cmd_args, capture_output=True, timeout=timeout)
except subprocess.TimeoutExpired:
return 2, "client timeout ({}s)".format(timeout), None
text = proc.stdout.decode("utf-8", errors="replace")
try:
return proc.returncode, text, json.loads(text)
except ValueError:
return proc.returncode, text, None
def _tool_cmd(py, cmd, *rest, timeout=15):
rc, out, data = _cli([py, str(SERIAL_TOOL), cmd, *rest], timeout)
if rc != 0 or data is None:
return None, out
return data, out
def _ftp_quit(ftp):
"""尽力关闭 FTP 控制连接(QUIT 失败不影响判定)。"""
try:
ftp.quit()
except (OSError, ftplib.Error):
pass
def _prune_out_dir(out_dir, keep):
"""只保留最近 N 份产物(`boot_log_*.txt` / `selftest_log_*.log`)。
这些文件按 `%Y%m%d_%H%M%S` 命名、每次 @test 各留一份,旧实现从不清理——
本机实测 out 目录已堆到 31 份 boot_log + 7 份 selftest_log(2026-10-10)。
文件名含时间戳 → 按名字排序即按时间排序。
"""
if keep <= 0:
return 0
removed = 0
for pat in ("boot_log_*.txt", "selftest_log_*.log"):
for p in sorted(out_dir.glob(pat), key=lambda q: q.name)[:-keep]:
try:
p.unlink()
removed += 1
except OSError:
pass
return removed
def _fetch_selftest_log(device_ip, out_dir, stamp):
"""从设备 FTP 抓取 /log/selftest.log(lftpd :21 匿名,根目录 = 卷根)。
成功返回本地路径字符串;失败(网络不可达 / FTP 错误 / 设备侧文件缺失)返回 ""
(按 S7 语义视为无故障记录;此处打 WARN,调用方不判整体 FAIL)。
2026-10-02 台架修复:原实现只 `except OSError`,而 ftplib 的 error_temp /
error_perm 继承自 Exception 而非 OSError —— 台账"仅故障才写",干净设备上
/log/selftest.log 根本不存在,RETR 返回 450 直接抛出未捕获异常,令整个
`@test --require-selftest-pass` 在最后一步崩掉(而它本该是 PASS 路径)。
现把"设备侧无此文件"单列为正常路径(INFO),其余 FTP 错误保留 WARN。
"""
local = Path(out_dir) / "selftest_log_{}.log".format(stamp)
ftp = ftplib.FTP()
try:
ftp.connect(device_ip, 21, timeout=10)
ftp.login() # 匿名(lftpd 接受 USER anonymous / 空 PASS)
except (OSError, ftplib.Error) as e:
print("RUN_TEST_WARN: selftest fetch failed: {} "
"(按 S7 语义视为无记录;设备网络/FTP 需另行排查)".format(e))
return ""
buf = bytearray()
try:
ftp.retrbinary("RETR /log/selftest.log", buf.extend)
except ftplib.all_errors as e:
# 450/550 = 设备侧无此文件:干净台账,S7 判 PASS(正常路径,非故障)
print("SELFTEST_FETCH: {} -> (ledger absent: {}) 按 S7 视为无故障记录"
.format(device_ip, str(e).strip()))
_ftp_quit(ftp)
return ""
_ftp_quit(ftp)
local.write_bytes(bytes(buf))
print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf)))
return str(local)
def main(argv=None):
# 控制台输出固定 UTF-8:Windows 代码页只影响显示,不会让脚本崩溃
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="boot log capture + verify (pure stdlib, no PowerShell)")
ap.add_argument("--port", default="",
help="serial port (default: portName from .serialtest/config.json)")
ap.add_argument("--baud-rate", type=int, default=115200)
ap.add_argument("--settle-ms", type=int, default=8000,
help="RX silence that ends the capture")
ap.add_argument("--max-wait-ms", type=int, default=30000,
help="overall wait-idle timeout")
ap.add_argument("--first-wait-ms", type=int, default=12000,
help="give up as no_data if the board sent ZERO bytes this long "
"after flash started the wait (0 = same as --max-wait-ms)")
ap.add_argument("--flash-cmd", default="",
help="flash command (default: MDK-ARM/download.bat; pass reset.bat for reset-only)")
ap.add_argument("--flash-timeout", type=int, default=180,
help="flash subprocess timeout in seconds")
ap.add_argument("--required", default=REQUIRED_DEFAULT,
help="required markers, ';' separated")
ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT,
help="forbidden patterns, ';' separated")
ap.add_argument("--require-suite-summary", default="",
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
"with N>0, ';' separated (T-2). Empty = off")
ap.add_argument("--require-selftest-pass", action="store_true",
help="jointly judge /log/selftest.log (S7): missing/empty = PASS, "
"any 64B record = FAIL")
ap.add_argument("--selftest-log", default="",
help="local copy of /log/selftest.log (skips device-side fetch when given)")
ap.add_argument("--device-ip", default="192.168.1.100",
help="device IP for fetching /log/selftest.log via FTP (lftpd anonymous)")
ap.add_argument("--out-dir", default=str(Path(tempfile.gettempdir()) / "serial-boot-test"))
ap.add_argument("--show-tail-lines", type=int, default=15)
ap.add_argument("--fw-hex", default=str(DEFAULT_HEX),
help="built image for the firmware-identity stamp and the readback "
"compare (default: latest build hex)")
ap.add_argument("--no-fw-check", action="store_true",
help="do not require the 'fw build: <DATE> <TIME>' stamp in the boot log")
ap.add_argument("--no-verify-flash", action="store_true",
help="skip the ST-LINK -CmpFile readback (AGENTS.md makes it mandatory)")
ap.add_argument("--readback-timeout", type=int, default=120,
help="timeout for the readback compare, seconds")
ap.add_argument("--max-boot-count", type=int, default=1,
help="fail if the boot banner ('reset cause:') appears 0 times or more "
"than N times (0 = check off)")
ap.add_argument("--expect-reset-cause", default="",
help="';'-separated substrings the FIRST reset cause must match "
"(case-insensitive); empty = report only, no verdict")
ap.add_argument("--require-order", default="",
help="'>'-separated markers that must appear in that line order "
"(catches out-of-order boot steps); empty = off")
ap.add_argument("--done-marker", default="net init: OK",
help="marker whose line marks boot completion (for --max-boot-ms); "
"empty = don't measure")
ap.add_argument("--max-boot-ms", type=int, default=0,
help="fail if time from first line to --done-marker exceeds N ms "
"(0 = measure and report only)")
ap.add_argument("--raw-log", action="store_true",
help="write payload-only boot log (no '[+Nms]' relative-time prefix)")
ap.add_argument("--keep-out", type=int, default=20,
help="keep the newest N boot_log_*/selftest_log_* files in --out-dir "
"(0 = keep everything)")
args = ap.parse_args(argv)
# 串口助手 CLI 用当前解释器(AGENTS.md 统一入口 = 仓库 .venv python)
py = sys.executable
# 端口缺省:单一配置源 .serialtest/config.json(UTF-8,容忍 BOM)
port = args.port
if not port and CFG_FILE.is_file():
try:
cfg = json.loads(CFG_FILE.read_text(encoding="utf-8-sig"))
port = str(cfg.get("portName") or "")
except (OSError, ValueError) as e:
print("RUN_TEST_ERROR: cannot parse {}: {}".format(CFG_FILE, e))
return 3
if not port:
print("RUN_TEST_ERROR: no --port given and no portName in {}".format(CFG_FILE))
return 3
# --flash-cmd 允许带参数(如 "MDK-ARM\reset.bat flash"):拆成 argv,
# 存在性只查 exe 段(整串当文件名必然 missing,2026-10-10 实测)
flash_argv = _split_cmd(args.flash_cmd) if args.flash_cmd else [str(DEFAULT_FLASH)]
flash_cmd = Path(flash_argv[0])
# 预检(对应旧版 missing-file 检查)
for p in (SERIAL_TOOL, SCRIPT_DIR / "verify_log.py", flash_cmd):
if not p.is_file():
print("RUN_TEST_ERROR: missing {}".format(p))
return 3
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
pruned = _prune_out_dir(out_dir, args.keep_out)
if pruned:
print("OUT: pruned {} old artifact file(s) in {} (keep={})".format(
pruned, out_dir, args.keep_out))
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
boot_txt = out_dir / "boot_log_{}.txt".format(stamp)
def tool(cmd, *rest, timeout=15):
return _tool_cmd(py, cmd, *rest, timeout=timeout)
print("=== Boot test {} ===".format(stamp))
print("Port: {} @ {} settle={}ms max={}ms".format(
port, args.baud_rate, args.settle_ms, args.max_wait_ms))
# 1. open(幂等;未运行则自动拉起常驻 daemon)
data, out = tool("open", "--port", port, "--baud", str(args.baud_rate), "--timeout", "15")
if data is None:
print("RUN_TEST_ERROR: open failed: {}".format(out))
return 2
# 2. clear(新代际;此后数据才属于本次运行)
data, out = tool("clear", "--timeout", "15")
if data is None:
print("RUN_TEST_ERROR: clear failed: {}".format(out))
return 2
# 3. flash(下载 + Reset & Run)。烧录失败不中断:板子可能已复位,
# 由采集 + 标记判定结果(与旧版一致)
print("FLASH_START: {}".format(" ".join(flash_argv)))
t0 = time.monotonic()
try:
proc = subprocess.run(flash_argv,
capture_output=True, timeout=args.flash_timeout)
flash_rc = proc.returncode
flash_bytes = (proc.stdout or b"") + (proc.stderr or b"")
except subprocess.TimeoutExpired as e:
flash_rc = -1
flash_bytes = (e.stdout or b"") + (e.stderr or b"")
elapsed_ms = int((time.monotonic() - t0) * 1000)
for line in flash_bytes.decode("utf-8", errors="replace").strip().splitlines()[-12:]:
print(line)
print("FLASH_RC: {} elapsed_ms={}".format(flash_rc, elapsed_ms))
if flash_rc != 0:
print("RUN_TEST_WARN: flash rc={} (continuing: board may still have reset)".format(flash_rc))
# 3.5 固件身份戳:从**刚烧进去的那份 .hex** 里抠出 `fw build: <DATE> <TIME>`,
# 追加成 Required 标记 → 芯片里跑的必须就是这份构建。旧 hex 里没有该串
# (未重编译)时只 WARN 跳过,不假判 FAIL。
required = args.required
fw_hex = Path(args.fw_hex)
if args.no_fw_check:
print("FW ID: skipped (--no-fw-check)")
elif not fw_hex.is_file():
print("RUN_TEST_WARN: fw hex not found {} -> identity + readback skipped".format(fw_hex))
else:
fw_stamp = extract_fw_stamp(fw_hex)
if fw_stamp is None:
print("RUN_TEST_WARN: no '{}' stamp in {} -> identity check skipped "
"(old build: run @build first)".format(FW_MARKER, fw_hex.name))
else:
marker = FW_MARKER + fw_stamp
required = (args.required + ";" + marker) if args.required else marker
print("FW ID: expected stamp '{}' (from {})".format(marker, fw_hex.name))
# 4. wait-idle(RX 静默收尾或整体超时)
# --first-timeout-ms:首字节门——烧录后板子全程不吐字节时按 no_data 快速失败
# (旧版 daemon 常驻累计 rx_bytes>0,no_data 永不触发,只能靠后面"日志里没有
# [RX] 行"兜底,报错信息误导成 device silent?)
wait_timeout_s = args.max_wait_ms // 1000 + 20
wait_args = ["--settle-ms", str(args.settle_ms),
"--max-ms", str(args.max_wait_ms),
"--timeout", str(wait_timeout_s)]
if args.first_wait_ms:
wait_args += ["--first-timeout-ms", str(args.first_wait_ms)]
data, out = tool("wait-idle", *wait_args, timeout=wait_timeout_s + 15)
if data is None:
print("RUN_TEST_ERROR: wait-idle failed: {}".format(out))
return 2
print("WAIT: reason={} rx_bytes={} waited_ms={}".format(
data.get("reason"), data.get("rx_bytes"), data.get("waited_ms")))
if data.get("reason") == "no_data":
print("RUN_TEST_ERROR: no RX data at all (check port / board power / flash)")
return 2
# 5. 提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
# 默认给每行加 `[+Nms]` 相对偏移(daemon 毫秒时基,本次采集第一行 = +0ms)。
# 为什么加:MCU 侧时间戳只有**秒级**,且开机头几行时钟还没起(`----/--/--`),
# 根本量不了耗时;有了它 verify_log 才能查"能开机但慢了几秒"(--max-boot-ms)。
# --raw-log 恢复旧格式(纯载荷)。
data, out = tool("log", "--tail", "0", "--timeout", "15")
if data is None:
print("RUN_TEST_ERROR: log read failed: {}".format(out))
return 2
rx = []
t0 = None
for l in data.get("lines", []):
if not (isinstance(l, str) and l.startswith("[RX] ")):
continue
rest = l[5:]
sp = rest.find(" ")
ts_txt = rest[:sp] if sp >= 0 else ""
payload = rest[sp + 1:] if sp >= 0 else rest
if args.raw_log:
rx.append(payload)
continue
ts = None
if ts_txt:
try:
ts = datetime.fromisoformat(ts_txt)
except ValueError:
ts = None
if ts is None:
rx.append(payload) # 时间戳读不出来就不加前缀(不猜)
continue
if t0 is None:
t0 = ts
rx.append("[+{}ms] {}".format(int((ts - t0).total_seconds() * 1000), payload))
if not rx:
print("RUN_TEST_ERROR: no [RX] lines in log (device silent?)")
return 2
boot_txt.write_bytes(("\r\n".join(rx) + "\r\n").encode("utf-8"))
print("Boot log: {} (lines={} gen={} ts={})".format(
boot_txt, len(rx), data.get("gen"), "raw" if args.raw_log else "+ms"))
# 5.6 烧录回读校验(AGENTS.md 铁律:`No difference found` 才算真写进去)。
# 必须排在**采集之后**:实测 -CmpFile 连接时会复位目标(板子当场重打一遍开机
# 日志),排在采集前会截断开机日志并让启动锚点计数误判成"中途复位"。
if args.no_verify_flash:
print("READBACK: skipped (--no-verify-flash)")
elif not fw_hex.is_file():
print("READBACK: skipped (no fw hex: {})".format(fw_hex))
else:
rb_rc, rb_text = _run_cmd(_split_cmd('{} cmp "{}"'.format(RESET_BAT, fw_hex)),
args.readback_timeout)
# 判定按**行首**匹配:进度条和 reset.bat 的帮助文本里都含 "No difference found"
# 字样,只有 ST-LINK 自己那行才独占行首(实测 `...100%\r\nNo difference found.`)。
rb_ok = any(ln.strip().lower().startswith("no difference found")
for ln in rb_text.splitlines())
if rb_ok:
print("READBACK: OK (No difference found) rc={}".format(rb_rc))
else:
print("READBACK: FAIL -- device flash does NOT match {}".format(fw_hex.name))
for line in rb_text.strip().splitlines()[-10:]:
print(" | {}".format(line))
print("RUN_TEST_ERROR: flash readback mismatch (chip content != built image)")
return 2
# 5.5 自检记录抓取(S7):启用且未提供本地副本时才走 FTP
selftest_local = args.selftest_log
if args.require_selftest_pass and not selftest_local:
selftest_local = _fetch_selftest_log(args.device_ip, out_dir, stamp)
# 6. 标记校验(含固件身份戳、启动锚点计数、可选的自检记录联合判定)
from verify_log import verify
rc = verify(str(boot_txt), required, args.forbidden, args.show_tail_lines,
suite_summary=args.require_suite_summary,
selftest_pass=args.require_selftest_pass,
selftest_log=selftest_local,
max_boot_count=args.max_boot_count,
expect_reset_cause=args.expect_reset_cause,
require_order=args.require_order,
done_marker=args.done_marker,
max_boot_ms=args.max_boot_ms)
print("RUN_TEST_RC={}".format(rc))
return rc
if __name__ == "__main__":
sys.exit(main())
-448
View File
@@ -1,448 +0,0 @@
#!/usr/bin/env python3
"""verify_log.py - 对抓取的 MCU 调试日志做标记校验(纯标准库,2026-09-20 替代 verify_log.ps1)。
用于 @test 闭环:烧录后从串口助手日志提取的开机日志交给本脚本 grep 标记,
输出结构化判定(格式与旧 PowerShell 版逐字一致,下游文档/习惯不受影响)。
用法:
& '.venv\\Scripts\\python.exe' test/host/verify_log.py --log-path <file>
[--required 'marker1;marker2']
[--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]']
[--require-suite-summary 'Storage Tests;GD5F2GQ5UE Tests']
[--require-selftest-pass] [--selftest-log <selftest.log 本地副本>]
[--show-tail-lines 40]
[--max-boot-count 1] [--boot-marker 'reset cause:']
[--expect-reset-cause 'software;power-on']
[--require-order 'App init OK>Network Init>net init: OK']
[--done-marker 'net init: OK'] [--max-boot-ms 8000] [--json]
退出码(与旧版一致):
0 = PASS(所有 Required 标记找到,Forbidden 零命中,汇总行要求满足)
1 = FAIL(缺 Required / 命中 Forbidden / 汇总行要求不满足)
2 = ERROR(日志文件不存在/不可读)
默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app/app_main.c + App/task/net_task.c),
按测试用例覆盖,例如:
--required 'App init OK;net init: OK;PHY: 100M FULL'
--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]'
语义约定(与旧 PowerShell 版对齐):
- Required:大小写敏感子串匹配(全文)
- Forbidden:大小写**不敏感**子串匹配、逐行计数(旧版 -like 语义;
如 Forbidden 'FAIL' 会命中 "write /log/run.log fail (7)" 这类行)
- 固件错误级日志前缀是 `[ERR]`(`DBG_ERROR`,形如 `[时间][ERR][TAG] 消息`),
与 `ERROR` 子串**不匹配**——默认 Forbidden 因此显式包含 `[ERR]`,驱动/模块
错误行不再漏检(2026-09-21 修复)。测试套件中**预期**出现 `[ERR]` 的用例
(如 GD5F DMA 守卫负路径、FatFs 非法名校验)须用 --forbidden 覆盖默认值。
- 读取编码:先按 UTF-8,解码失败回退系统 locale 编码(旧版 Get-Content 行为)
- 控制台输出重配置为 UTF-8(errors=replace):任何 Windows 控制台代码页
都只影响终端显示,不会导致本脚本崩溃
- 汇总行(2026-10-01 加,T-2):纯 Required 子串匹配有个盲区——套件启动横幅
命中即判 PASS,**套件是否跑到汇总行、断言数是否为 0 完全不可见**(套件中途
挂死/复位、或 Phase 宏全关时会被误判为通过)。`--require-suite-summary`
要求日志中出现 `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
- 自检记录联合判定(S7,2026-10 实机窗口):`--require-selftest-pass` 对 /log/selftest.log
的本地副本(定长 64B 记录,格式见 App/log/sys_log.h)判定。自检仅故障写盘(v1 不记
通过项):**文件缺失 / 0 字节 = 无故障 → PASS**;存在任何一条记录 = 有故障 → FAIL
(退出码 1),并逐条打印时间戳 + 描述。抓取失败(设备不可达 / FTP 错误等)由调用方
(run_boot_test.py)先打 WARN,本地文件按缺失处理 → 按上述规则不判整体 FAIL。
- 启动锚点计数(2026-10-10):`--max-boot-count N`(run_boot_test.py 默认传 1)统计
`reset cause:` 锚点出现次数——**0 次** = 采集没从复位开始(标记全中也可能只是上一轮
的残留日志);**>N 次** = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后
`NVIC_SystemReset` / 掉电重启),此前这类运行只要不命中 Forbidden 就判 PASS。
`--expect-reset-cause` 对第一个锚点做原因白名单(大小写不敏感子串),空 = 只报告不判。
- Forbidden 命中行(2026-10-10):命中时逐行打印**行号 + 内容**(最多 6 行,超出报剩余数)。
旧实现只说 `forbidden pattern: [ERR] (3 line(s))`,人还得自己再 grep 一遍才知道是哪几行
——而"是哪几行"恰恰是定位要的东西。
- 标记顺序(`--require-order 'a>b>c'`):按**首次出现的行序**校验启动步骤先后。标记全中
但顺序颠倒 = 某步在另一步之后补做(屏障失序、重试后补跑),旧实现完全看不见。
- 开机耗时(`--done-marker` + `--max-boot-ms`):依赖行首 `[+Nms]` 前缀(run_boot_test.py
2026-10-10 起按 daemon 毫秒时基给每行加,MCU 侧时间戳只有秒级且开机头几行根本没有)。
量"第一行 → done-marker"的真实耗时,抓"能开机但慢了几秒"(mount 重试 / PHY 重协商)。
日志里没有该前缀 → 只 WARN 跳过,不假判 FAIL。
- `--json`:同一套判定以 JSON 输出(供 CI / 上层脚本消费;退出码语义不变)。
"""
import argparse
import json
import locale
import re
import sys
from pathlib import Path
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;assert;[ERR]"
# Forbidden 命中时最多打印几条命中行(够定位又不刷屏;剩余条数会报出来)
HIT_LINES_MAX = 6
# 行首 `[+1234ms] `:run_boot_test.py 按 daemon 毫秒时基给每行加的相对偏移
# (MCU 侧时间戳只有秒级,且开机头几行时钟还没起 → 量不了耗时)
_TS_OFF_RE = re.compile(r"^\[\+(\d+)ms\]\s")
# 每轮启动的锚点行(App/app/app_main.c:`reset cause: <原因> (CSR=0x…)`)。
# 一次采集里出现 >1 次 = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后
# NVIC_SystemReset / 掉电重启),此前只有 Forbidden 命中时才被发现,
# "跑完 PASS 但中途重启过"完全不可见(2026-10-10 加)。
BOOT_MARKER_DEFAULT = "reset cause:"
# 套件汇总行:`=== Storage Tests: 3/3 PASSED (failed=0) ===`
# 名称可含空格;N/M 为十进制;failed 允许前导 0。宽松容忍前后空白与
# 日志时间戳前缀(`[2026-10-01 12:00:00.000]`)——故用 search 而非 match。
_SUMMARY_RE = re.compile(
r"===\s*(?P<name>[^:=]+?)\s*:\s*(?P<passed>\d+)\s*/\s*(?P<total>\d+)\s+"
r"PASSED\s*\(\s*failed\s*=\s*(?P<failed>\d+)\s*\)\s*==="
)
def _split_list(spec):
"""';' 分隔列表 → 去空白、去空项。"""
return [p.strip() for p in spec.split(";") if p.strip()]
def _read_lines(path):
raw = Path(path).read_bytes()
for enc in ("utf-8", locale.getpreferredencoding(False)):
try:
return raw.decode(enc).splitlines()
except UnicodeDecodeError:
continue
return raw.decode("utf-8", errors="replace").splitlines()
def _reset_cause_of(line, marker):
"""从 `reset cause: software (NVIC_SystemReset) (CSR=0x14000000)` 取原因文本。"""
i = line.find(marker)
if i < 0:
return ""
tail = line[i + len(marker):].strip()
j = tail.find(" (CSR=")
return tail[:j] if j > 0 else tail
def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
show_tail=40, out=print, suite_summary="",
selftest_pass=False, selftest_log="",
boot_marker=BOOT_MARKER_DEFAULT, max_boot_count=0,
expect_reset_cause="", require_order="", done_marker="",
max_boot_ms=0, as_json=False):
"""标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。
suite_summary: ';' 分隔的套件名列表。非空时,要求日志中每个套件都出现
`=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
selftest_pass: True 时对 selftest_log(/log/selftest.log 本地副本)联合判定:
文件缺失 / 0 字节 = 无故障(PASS);存在任何 64B 记录 = FAIL(S7)。
selftest_log: 自检记录文件本地路径(空串 = 按文件缺失处理)。
boot_marker / max_boot_count(2026-10-10):统计启动锚点出现次数。
max_boot_count>0 时启用:0 次 = 采集没从复位开始;>N 次 = 板子
在采集窗口内自己复位过(IWDG / Fault 后 NVIC_SystemReset)。
expect_reset_cause: ';' 分隔的原因子串白名单,只对**第一个**锚点生效(大小写不敏感)。
空 = 只报告不判定。
require_order: '>' 分隔的标记序列(2026-10-10),按**首次出现的行序**校验先后;
顺序颠倒 = 某步在另一步之后补做(屏障失序 / 重试补跑)。空 = 不查。
done_marker / max_boot_ms(2026-10-10):量"第一行 → done_marker"的毫秒耗时
(靠行首 `[+Nms]` 前缀);超过 max_boot_ms 判 FAIL。
日志无该前缀 → WARN 跳过,不假判。
as_json: True = 不打印文本判定,最后输出一个 JSON 对象(退出码不变)。
"""
res = {"log_path": str(log_path), "lines": 0, "bytes": 0, "required_missing": [],
"forbidden_hits": [], "boot": None, "suites_bad": [], "selftest": None,
"order_bad": [], "timing": None, "tail": [], "verdict": None, "exit": None}
emit = out
def finish(code):
res["exit"] = code
res["verdict"] = {0: "PASS", 1: "FAIL", 2: "ERROR"}[code]
if as_json:
emit(json.dumps(res, ensure_ascii=False, indent=2))
return code
if as_json:
out = lambda *a, **k: None # 文本判定静默,结果走 JSON
p = Path(log_path)
if not p.is_file():
out("VERIFY_ERROR: log file not found: {}".format(log_path))
return finish(2)
try:
lines = _read_lines(log_path)
except OSError:
out("VERIFY_ERROR: cannot read log file: {}".format(log_path))
return finish(2)
full = "\n".join(lines)
req_list = _split_list(required)
forb_list = _split_list(forbidden)
summary_list = _split_list(suite_summary)
out("=== Log verdict: {} ===".format(log_path))
out("Lines: {}, bytes: {}".format(len(lines), p.stat().st_size))
res["lines"] = len(lines)
res["bytes"] = p.stat().st_size
missing = []
for m in req_list:
if m in full:
out("[OK] required marker found: {}".format(m))
else:
out("[MISS] required marker NOT found: {}".format(m))
missing.append(m)
res["required_missing"] = list(missing)
hits = []
for f in forb_list:
fl = f.lower()
matched = [(i + 1, ln) for i, ln in enumerate(lines) if fl in ln.lower()]
if matched:
out("[HIT] forbidden pattern: {} ({} line(s))".format(f, len(matched)))
# 命中行必须打出来(2026-10-10):旧实现只报 "(3 line(s))",人还得自己
# 再 grep 一遍才知道是哪几行——而"哪几行"正是定位所需的那一步
for ln_no, ln in matched[:HIT_LINES_MAX]:
out(" L{}: {}".format(ln_no, ln.strip()[:200]))
if len(matched) > HIT_LINES_MAX:
out(" ... {} more".format(len(matched) - HIT_LINES_MAX))
hits.append(f)
res["forbidden_hits"].append({
"pattern": f, "count": len(matched),
"lines": [{"no": n, "text": t.strip()[:200]}
for n, t in matched[:HIT_LINES_MAX]]})
else:
out("[OK] forbidden pattern absent: {}".format(f))
# 启动锚点计数(2026-10-10):抓"跑完但中途复位过"和"采集没从复位开始"
boot_issue = False
if max_boot_count > 0:
banner_lines = [ln for ln in lines if boot_marker in ln]
n_boot = len(banner_lines)
causes = [_reset_cause_of(ln, boot_marker) for ln in banner_lines]
if n_boot == 0:
out("[FAIL] no boot banner (marker '{}'): capture did not start at a reset".format(
boot_marker))
boot_issue = True
elif n_boot > max_boot_count:
out("[FAIL] boot banner x{} > allowed {} -> board reset itself during capture".format(
n_boot, max_boot_count))
for ln in banner_lines[:6]:
out(" {}".format(ln.strip()))
boot_issue = True
else:
out("[OK] boot banner x{} (marker '{}')".format(n_boot, boot_marker))
if causes:
out("[INFO] reset cause(s): {}".format("; ".join(causes)))
exp = _split_list(expect_reset_cause)
if exp:
got = causes[0].lower()
if any(e.lower() in got for e in exp):
out("[OK] reset cause '{}' matches expected {}".format(
causes[0], expect_reset_cause))
else:
out("[FAIL] reset cause '{}' not in expected: {}".format(
causes[0], expect_reset_cause))
boot_issue = True
res["boot"] = {"marker": boot_marker, "count": n_boot, "allowed": max_boot_count,
"causes": causes, "issue": boot_issue}
# 标记顺序(2026-10-10):标记全中但**顺序颠倒** = 某步在另一步之后补做
# (屏障失序 / 失败重试补跑)。旧实现只看子串存在性,这类失序完全看不见。
order_issue = False
if require_order:
seq = [s.strip() for s in require_order.split(">") if s.strip()]
pos = []
for m in seq:
idx = next((i for i, ln in enumerate(lines) if m in ln), -1)
pos.append((m, idx))
absent = [m for m, i in pos if i < 0]
if absent:
out("[FAIL] require-order: marker(s) not found: {}".format("; ".join(absent)))
order_issue = True
else:
bad = [(pos[k][0], pos[k + 1][0]) for k in range(len(pos) - 1)
if pos[k][1] > pos[k + 1][1]]
if bad:
out("[FAIL] marker order violated (expected {})".format(require_order))
for a, b in bad:
out(" '{}' first appeared AFTER '{}'".format(a, b))
for m, i in pos:
out(" L{}: {}".format(i + 1, m))
order_issue = True
else:
out("[OK] marker order ok: {}".format(require_order))
res["order_bad"] = [m for m, i in pos if i < 0] + [
"{}>{}".format(a, b) for a, b in
[(pos[k][0], pos[k + 1][0]) for k in range(len(pos) - 1) if pos[k][1] > pos[k + 1][1]]]
# 开机耗时(2026-10-10):行首 [+Nms] = daemon 毫秒时基的相对偏移
# (MCU 侧时间戳只有秒级,且开机头几行时钟还没起,量不出耗时)。
timing = None
timing_issue = False
offs = []
for i, ln in enumerate(lines):
m = _TS_OFF_RE.match(ln)
if m:
offs.append((i, int(m.group(1))))
if offs:
timing = {"ts_lines": len(offs), "first_ms": offs[0][1], "last_ms": offs[-1][1],
"span_ms": offs[-1][1] - offs[0][1]}
out("[INFO] capture span: +{}ms .. +{}ms ({} timestamped line(s))".format(
timing["first_ms"], timing["last_ms"], timing["ts_lines"]))
if done_marker:
di = next((i for i, ln in enumerate(lines) if done_marker in ln), -1)
if di < 0:
out("[WARN] done marker '{}' not found -> boot time not measured".format(
done_marker))
else:
doff = next((off for i, off in offs if i >= di), None)
if doff is None:
out("[WARN] done marker line has no [+Nms] prefix -> not measured")
else:
boot_ms = doff - offs[0][1]
timing["boot_ms"] = boot_ms
timing["done_marker"] = done_marker
out("[INFO] boot time to '{}': {} ms".format(done_marker, boot_ms))
if max_boot_ms > 0 and boot_ms > max_boot_ms:
out("[FAIL] boot time {} ms > limit {} ms".format(boot_ms, max_boot_ms))
timing_issue = True
elif done_marker or max_boot_ms > 0:
out("[WARN] no [+Nms] line prefix in this log -> timing checks skipped "
"(run_boot_test.py adds it; --raw-log turns it off)")
res["timing"] = timing
# 套件汇总行解析(T-2):抓"没跑到汇总行 / 断言数为 0"的盲区
bad_summary = []
if summary_list:
parsed = {}
for ln in lines:
m = _SUMMARY_RE.search(ln)
if m:
parsed.setdefault(m.group("name").strip(), []).append(m)
for name in summary_list:
cands = parsed.get(name)
if not cands:
out("[MISS] suite summary NOT found: '=== {}: N/M PASSED (failed=0) ==='".format(name))
bad_summary.append(name)
continue
ok = False
for m in cands:
passed = int(m.group("passed"))
total = int(m.group("total"))
failed = int(m.group("failed"))
if failed != 0:
out("[FAIL] suite summary reports failures: {} -> {}/{} PASSED (failed={})".format(
name, passed, total, failed))
continue
if total == 0:
out("[FAIL] suite summary total is 0 (no assertion ran): '{}'".format(name))
continue
if passed != total:
out("[FAIL] suite summary inconsistent: {} -> {}/{} PASSED".format(
name, passed, total))
continue
out("[OK] suite summary ok: {} -> {}/{} PASSED".format(name, passed, total))
ok = True
if not ok:
bad_summary.append(name)
res["suites_bad"] = list(bad_summary)
# 自检记录联合判定(S7):/log/selftest.log 定长 64B 记录(格式见 App/log/sys_log.h),
# 仅故障写盘:文件缺失 / 0 字节 = 无故障 → PASS;存在任何一条记录 → FAIL
selftest_fail = False
if selftest_pass:
sp = Path(selftest_log) if selftest_log else None
if sp is None or not sp.is_file() or sp.stat().st_size == 0:
out("[OK] selftest: no fault record (file missing or empty)")
res["selftest"] = {"path": str(sp) if sp else None, "faults": 0, "records": []}
else:
raw = sp.read_bytes()
n = len(raw) // 64
if len(raw) % 64:
out("[WARN] selftest log has {} trailing incomplete bytes (ignored)".format(len(raw) % 64))
recs = []
for i in range(n):
rec = raw[i * 64:(i + 1) * 64]
ts = rec[0:19].decode("ascii", errors="replace")
desc = rec[31:59].split(b"\0", 1)[0].decode("utf-8", errors="replace")
recs.append({"no": i, "ts": ts, "desc": desc})
res["selftest"] = {"path": str(sp), "faults": n, "records": recs}
if n > 0:
selftest_fail = True
out("[FAIL] selftest: {} fault record(s) in {}".format(n, selftest_log))
for r in recs:
out(" #{}: {} {}".format(r["no"], r["ts"], r["desc"]))
else:
out("[OK] selftest: no fault record (0 complete records)")
if show_tail > 0 and lines:
res["tail"] = lines[-show_tail:]
out("")
out("=== Last {} line(s) of log ===".format(show_tail))
for ln in lines[-show_tail:]:
out(ln)
out("")
if (missing or hits or bad_summary or selftest_fail or boot_issue
or order_issue or timing_issue):
out("VERDICT: FAIL")
return finish(1)
out("VERDICT: PASS")
return finish(0)
def main(argv=None):
# 控制台输出固定 UTF-8:Windows 代码页(GBK/UTF-8 终端)均不会让本脚本崩溃
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="MCU debug log marker check (pure stdlib)")
ap.add_argument("--log-path", required=True, help="boot log file to check")
ap.add_argument("--required", default=REQUIRED_DEFAULT,
help="required markers, ';' separated (case-sensitive)")
ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT,
help="forbidden patterns, ';' separated (case-insensitive)")
ap.add_argument("--require-suite-summary", default="",
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
"with N>0, ';' separated (T-2). Empty = off")
ap.add_argument("--show-tail-lines", type=int, default=40,
help="show last N log lines (0 = off)")
ap.add_argument("--require-selftest-pass", action="store_true",
help="judge /log/selftest.log local copy (S7): "
"missing/empty = PASS, any 64B record = FAIL")
ap.add_argument("--selftest-log", default="",
help="local copy path of /log/selftest.log (with --require-selftest-pass)")
ap.add_argument("--boot-marker", default=BOOT_MARKER_DEFAULT,
help="per-boot banner substring (default '{}')".format(BOOT_MARKER_DEFAULT))
ap.add_argument("--max-boot-count", type=int, default=0,
help="fail if the boot banner appears 0 times or more than N times "
"(0 = check off). run_boot_test.py passes 1 by default")
ap.add_argument("--expect-reset-cause", default="",
help="';'-separated substrings the FIRST reset cause must match "
"(case-insensitive). Empty = report only, no verdict")
ap.add_argument("--require-order", default="",
help="'>'-separated markers that must appear in that line order "
"(e.g. 'App init OK>Network Init>net init: OK'). Empty = off")
ap.add_argument("--done-marker", default="",
help="marker whose line marks boot completion, for --max-boot-ms")
ap.add_argument("--max-boot-ms", type=int, default=0,
help="fail if time from first line to --done-marker exceeds N ms "
"(needs '[+Nms]' line prefix; 0 = off)")
ap.add_argument("--json", action="store_true",
help="emit one JSON object instead of the text verdict (exit code unchanged)")
args = ap.parse_args(argv)
return verify(args.log_path, args.required, args.forbidden,
args.show_tail_lines, suite_summary=args.require_suite_summary,
selftest_pass=args.require_selftest_pass,
selftest_log=args.selftest_log,
boot_marker=args.boot_marker,
max_boot_count=args.max_boot_count,
expect_reset_cause=args.expect_reset_cause,
require_order=args.require_order,
done_marker=args.done_marker,
max_boot_ms=args.max_boot_ms,
as_json=args.json)
if __name__ == "__main__":
sys.exit(main())
+3 -3
View File
@@ -10,7 +10,7 @@
1. 改 test_config.h 启用该套件(其它全注释)
2. @build(0 错 0 警,否则跳过该套件)
3. 烧录(MDK-ARM/download.bat,Reset & Run)
4. @test 采集 + 判读(verify_log.py,含套件汇总行校验 T-2)
4. @test 采集 + 判读(.dsh/skills/serial-boot-test 的 verify_log.py,含套件汇总行校验 T-2)
结束后:
5. **无条件还原 test_config.h**(try/finally,含异常/KeyboardInterrupt 路径)
@@ -55,7 +55,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
CONFIG = REPO_ROOT / "test" / "board" / "test_config.h"
BUILD_BAT = REPO_ROOT / "MDK-ARM" / "build.bat"
DOWNLOAD_BAT = REPO_ROOT / "MDK-ARM" / "download.bat"
RUN_BOOT = REPO_ROOT / "test" / "host" / "run_boot_test.py"
RUN_BOOT = REPO_ROOT / ".dsh" / "skills" / "serial-boot-test" / "scripts" / "run_boot_test.py"
# 套件 -> (宏名, verify_log 需要的汇总行名称, 该套件的 Required 标记, --forbidden 覆盖)
# 汇总行名称与套件 `XXX_TEST_REPORT("...")` 里的字符串一致(见各模块 Test_Guide)。
@@ -207,7 +207,7 @@ def main(argv=None):
print("[WARN] 烧录 rc={}(继续:板子可能已复位)".format(frc))
# 3) 采集 + 判读(含套件汇总行校验 T-2)
cmd = [py, str(RUN_BOOT)]
cmd = [py, str(RUN_BOOT), "--flash-cmd", "MDK-ARM\\download.bat"]
if required:
cmd += ["--required", required]
if summary_name:
+1 -2
View File
@@ -12,7 +12,6 @@
| `menu_gen/` | 菜单树代码生成 | UI 菜单代码 | `docs/UI设计/菜单页面设计.md` |
| `netlist_semantics/` | 网表语义分析 | 引脚知识库 JSON + 网表报告 | `docs/硬件平台/` |
| `refgraph/` | 文件引用关系图 | `docs/images/引用关系图.*` | `docs/架构设计/引用关系图说明.md` |
| `serial_tool/` | Python 串口助手 | 日志状态(`state/`) | `docs/测试说明/串口助手使用指南.md` |
## 使用方式
@@ -27,7 +26,7 @@
& 'tools\refgraph\gen_refgraph.bat'
# 串口助手(常驻 daemon)
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py
& '.venv\Scripts\python.exe' .dsh/skills/serial-assistant/scripts/serial_tool.py
```
## ⚠️ 约定
-47
View File
@@ -1,47 +0,0 @@
# tools/serial_tool/ — Python 串口助手
> **常驻 daemon + CLI** 架构的串口工具:后台持续收串口日志,CLI 按需查询/发送,
> 避免反复开关串口导致日志丢失(台架与测试的主力观测工具)。
## 文件一览
| 文件/目录 | 作用 |
|---|---|
| `serial_tool.py` | 串口助手主程序(daemon 与 CLI 合一) |
| `state/` | **运行时状态目录** |
| `state/serial.log` | 串口原始日志(`stack_report.py -s` 的输入之一) |
| `state/daemon.log` | daemon 自身运行日志 |
| `state/meta.json` | daemon 元数据(端口、状态等) |
## 使用方式
```bash
# 打开串口(daemon 自动拉起;端口缺省取 .serialtest/config.json,不必带参数)
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py open
# 看有哪些口 / 配置口是否还在位(单一候选时回 hint)
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py ports
# 读日志(先过滤后截尾)/ 发送 / 等静默
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py log --tail 50 --grep "App init OK"
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py send "<命令>"
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py wait-idle --settle-ms 8000 --max-ms 30000
# 状态 / 关闭
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py status
& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py close
```
> 全部命令输出 JSON、退出码 0/1/2/3;具体子命令以 `--help` 为准。
> 抓开机日志的顺序是 `open` → `clear` → 烧录/复位 → `wait-idle` → 读日志(采集必须先于烧录)。
## ⚠️ 注意
- **单写者**:串口同一时刻只能被一个进程占用——别同时用本工具和其它串口程序
- `state/serial.log` 是 `[STACK]` 运行时水位的来源,`scripts/stack_report.py -s` 直接读它
- `state/` 是运行时数据,**不入库**
## 相关文档
- `docs/测试说明/串口助手使用指南.md` — 命令用法 / 工作流 / 排障
- `docs/测试说明/串口助手设计说明.md` — 契约 / daemon 行为 / 陷阱
File diff suppressed because it is too large Load Diff