88601422c3
17 篇文档的变更记录(含修订记录、带编号与中文序号标题)节里只剩一句 「不再记流水账」的占位话,整节删掉,连带它前面的分隔线。 仍保留真台账的只有 5 篇(看门狗 / 配置文件设计 / RAM 预算台账 / 内容页架构 / 本地工具路径方案),外加 scripts/archive 归档区。 份数口径改成可复跑的普查:文档规范 §5.1 补自查命令, stale-doc-count-sweep 的速查表与陷阱各加一条——只 grep 裸标题会漏 带编号与中文序号的两种写法,实测 5 篇只数得出 3 篇,差点据此把 全仓正确的份数改错。 门禁:逐篇与全量 check_doc_links 除 simulator/SDL2 联接假红外 0 问题; skill-size rc=0;表格列数与字节体检相对 HEAD 零新增问题。
16 KiB
16 KiB
自动化测试流程(AI 迭代闭环)
目标:AI 改完代码后,可自动完成「构建 → 烧录 → 抓取串口调试信息 → 校验 → 迭代」,无需人工开终端/串口助手。
总体流程
@test 命令触发
│
├─ Step 0 预检 .venv + serial_tool(selftest/ports 确认 COM)
├─ Step 1 构建验证 keil-build skill(keil_build.py) (0 错误 0 警告才继续)
├─ Step 2 烧录 + 采集(一条命令编排:开串口 + 清日志 → 烧录 → 固件身份戳 → wait-idle → 提取 → 回读校验)
│ .dsh/skills/serial-boot-test/scripts/run_boot_test.py (内置 serial_tool + stlink-flash skill 的 flash/cmp + verify_log.py)
├─ Step 3 判定:
│ PASS → 报告,结束
│ FAIL → 依据 [MISS]/[HIT] + 日志尾部,修改代码后回到 Step 1
命令与脚本清单
| 名称 | 位置 | 作用 |
|---|---|---|
@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 |
.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] 时基)→ 回读校验(stlink_flash.py cmp)→ verify;--keep-out 清旧产物;退出码 0=PASS/1=FAIL/2=采集·烧录·回读错误/3=用法错误 |
.dsh/skills/stlink-flash/scripts/stlink_flash.py |
stlink-flash skill |
ST-LINK CLI 封装:flash [hex](烧 + -Rst -Run)/ cmp [hex](回读比对,2026-10-10 加)/ reset(只复位)/ hard(nRST 脉冲,CLI 拒 RM= 自动降级)/ halt/run/list;原 MDK-ARM/reset.bat 已于 2026-10-10 删除并全并入本 skill |
.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 结构化输出、整行锚定 --exact / 跨复位配对 --pair / 崩溃腿 --allow-residual+--head-char / 判别自检 --selftest(22 条,含三计数分类器);退出码 0=PASS / 1=设备侧真有坏消息 / 2=测量没成立(读不到日志,或开了锚点计数却零锚点——采集断了不算设备故障)。子串 Required 照不出"行首被吃掉"那一类行级损坏 → 行级完整性一律用 --exact,口径与固定动作见 启动日志原子性复验流程.md |
判定标准
- 构建:
@build退出码 0(0 错误 0 警告),否则不进入硬件环节。 - 烧录:
@download退出码 0(run_boot_test.py内部调用)。 - 日志:
verify_log.py退出码0= PASS:所有-Required标记均出现,且无-Forbidden模式命中1= FAIL:存在缺失标记([MISS])或违规模式([HIT])2= 日志文件不可读
- 固件身份戳(2026-10-10 加):
App/app/app_main.c开机打印fw build: <__DATE__> <__TIME__>(编译期展开成一段连续 ASCII,.hex里可原样搜到)。run_boot_test.py从待烧的 hex 里抠出该串并追加成 Required 标记 → 芯片里跑的必须就是这份构建。反例实测:把 hex 里的戳改成Oct 9 2026 01:00:00再跑,其余标记全中但[MISS] fw build: Oct 9 2026 01:00:00→ FAIL——这正是过去"反复重下载、芯片一直是旧镜像"在闭环里完全隐形的场景。--no-fw-check关闭;hex 里没有该串(旧构建)只 WARN 跳过、不假判。 - 戳覆盖构建配置(2026-10-11 加):
__DATE__/__TIME__只在该 TU 被重编时才刷新——增量构建下只切test/board/test_config.h的套件宏不会重编app_main.c,于是产品镜像与测试镜像顶着同一个戳(实测:切宏后的测试构建与产品构建戳同为Oct 11 2026 14:44:11,只有回读校验才发现镜像不同)。两条防线:①app_main.c#include "test_config.h"建立头依赖 → 改宏必重编本 TU、戳必刷新;② 戳串追加构建变体suite=<TEST_SUITE_NAME> dstr=<on|off>(FW_BUILD_VARIANT)→ 产品 =suite=NONE dstr=off、CH395F 测试 =suite=CH395F dstr=on,即便戳时间相同也能一眼分辨。实测:切宏前后Oct 11 2026 20:36:11 suite=NONE dstr=off→Oct 11 2026 20:36:40 suite=CH395F dstr=on;@test闭环对新格式匹配通过(Required 行就是完整戳串,脚本按"标记后连续可打印 ASCII"抠取,无需改脚本)。 - 烧录回读(2026-10-10 加):采集结束后自动跑
stlink_flash.py cmp(=-CmpFile全片逐字节比对),判定按行首No difference found(进度条与帮助文本里也含该短语,故不能全文匹配);不一致 →RUN_TEST_ERROR: flash readback mismatch+ 退出码 2(本次运行作废:日志是"某个未知镜像"跑出来的)。⚠ 顺序不能颠倒——-CmpFile连接时会复位目标(实测板子当场重打一遍开机日志)。--no-verify-flash关闭。 - 启动锚点计数(2026-10-10 加):
reset cause:在一次采集里出现 0 次(采集没从复位开始,标记全中也可能只是上一轮残留)或 >1 次(板子中途自己复位:IWDG 咬狗 / Fault 后NVIC_SystemReset/ 掉电重启)→ FAIL;--max-boot-count N放宽(跑故意复位的套件时用),--expect-reset-cause 'software;power-on'对第一个锚点做原因白名单(SWD 软复位实测为software (NVIC_SystemReset),CSR=0x14000000)。 - 判定可读性 + 时序(2026-10-10 第三批):
- Forbidden 命中行:命中时逐行打印
L<行号>: <内容>(最多 6 行,剩余报数)。旧实现只说forbidden pattern: [ERR] (3 line(s)),还得人再 grep 一遍才知道是哪几行。 [+Nms]相对时基:run_boot_test.py给 boot_log 每行加行首[+Nms](daemon 毫秒时基,本次第一行 = +0ms;--raw-log出纯载荷)。MCU 侧时间戳只有秒级、开机头几行还没时钟(----/--/--),量不了耗时。- 开机耗时:
--done-marker 'net init: OK'(默认)+--max-boot-ms N。实测基线 +537 ms(2026-10-10 台架,stlink_flash.py flash后);默认--max-boot-ms 0= 只报告不判,避免网络重试抖动造成假 FAIL。 - 标记顺序:
--require-order 'App init OK>Network Init>net init: OK'按首次出现的行序校验;标记全中但顺序颠倒(屏障失序 / 重试补跑)此前完全不可见。 --json:同一套判定以 JSON 输出(required_missing/forbidden_hits[].lines/boot/timing/tail/verdict/exit),退出码语义不变,供 CI 与上层脚本消费。- 产物保留:
--keep-out N(默认 20)自动清理--out-dir里的旧boot_log_*.txt/selftest_log_*.log(本机曾堆到 31+7 份)。
- Forbidden 命中行:命中时逐行打印
自检结果判定(S7,--require-selftest-pass)
run_boot_test.py / verify_log.py 支持 --require-selftest-pass:除串口标记外,还判定设备侧自检结论
(v1 只记故障、不记通过)。判定对象(20 处自检描述串)见 实机测试实施指南.md §3.2。
判据默认只看「本次启动新增」(--selftest-scope this-run,2026-10-22 改)。原因:
/log/selftest.log 是 512 条环形累计历史,而旧判据是"文件非空即 FAIL"——用它判一次启动,
在任何做过故障注入或长跑的设备上永远不可能 PASS(本仓台架实测:抓到的 29 条记录时间戳
全是 2026-10-10 ~ 10-14 留下的,没有一条来自本次)。新流程 = 烧录前先经 FTP 取一次基线、
启动后再取一次,只把"基线最后一条之后"的记录交给判定:
| 情形 | 含义 | 判定 |
|---|---|---|
| 设备侧无此文件(450/550) | 台账干净("仅故障才写"语义下本就可能不存在) | 无新增 → PASS |
| 抓到文件、本次无新增 | 本次启动没产生故障 | PASS |
| 抓到文件、本次有新增 | 本次启动有故障 | FAIL,只打印新增条目(落 selftest_log_<时刻>_new.log) |
| 连不上 / 登录被拒 / FTP 故障 | 结果未知 | FAIL——旧行为是 WARN 后当"无记录"放过,那就是假绿 |
既没有 device_ip 也没有 --selftest-log |
要求判定却没有数据源 | FAIL(同上,不再 WARN 放行) |
| 基线最后一条在台账里找不到 | 512 条环已覆盖基线(更新量 ≥ 环容),无法证明 | FAIL(不拿"看不到"当"没有") |
- ⚠ 采集窗口内仍不要跑 FTP:每条 FTP 命令都会打到串口上(日志变长、开机耗时指标被污染——
实测抓基线排在采集武装之后时,
net init: OK从 612 ms 被拉到 7997 ms)。基线抓取因此固定在open/clear之前。(原先更要命的是客户端退出会打[ERR] [FTP] error reading next command→ 直接命中 Forbidden 里的[ERR];该措辞与级别已在 2026-10-22 归入"对端行为"级,见../架构设计/STM32F4-Base存储架构说明.md§10.3。注意:RST 硬拆仍会留一条[ERR][NET] sockNTIMEOUT——那是网络自愈架构要读的套接字超时锚点,不在降级范围内。 --selftest-scope all= 保留旧的累计口径(任一记录即 FAIL),用于审计或第一次建基线。- 给了
--selftest-log(本地副本)就没有基线可比 → 自动降级为累计口径并打印一行说明。 - 抓基线同样要 FTP 凭据:
--ftp-user/--ftp-pass(configftp_user/ftp_pass,默认出厂admin/admin);设备没有匿名通道 →FTP_Test_Guide.md§1。
默认开机标记(来自固件)
| 标记 | 来源 |
|---|---|
App init OK |
App/app/app_main.c |
RS485 init: OK |
App/app/app_main.c |
NAND init: |
App/app/app_main.c |
ADC init: |
App/app/app_main.c |
SD2506 ID: / time: / temp: / battery: |
App/app/app_main.c |
Network Init / net init: OK / MAC: / PHY: |
App/task/net_task.c |
phasor(first) / phasor(hb) |
App/task/adc_task.c(全周傅氏相量,2026-10-11 起常开;hb 每 10 s 一次,采集窗口需 ≥10 s——默认 max_wait 30 s 覆盖) |
默认 Forbidden 模式:FAIL;ERROR;HardFault;[ERR]
⚠ 固件错误级日志前缀是
[ERR](DBG_ERROR→[时间][ERR][TAG] 消息),与ERROR子串并不匹配。2026-09-21 起默认列表显式加入[ERR]——此前驱动/模块的错误行 不会被自动闭环拦截(历史日志中的[ERR]全是漏检)。产品构建正常开机应为零[ERR]。
失败模式应对
| 现象 | 处理 |
|---|---|
selftest FAIL / ModuleNotFoundError: pyserial |
venv 未建或依赖缺失:按 AGENTS.md「Python 环境」重建 .venv + pip install -r requirements.txt |
| 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 未重编译。处置:stlink_flash.py flash <hex> 重烧并看 READBACK: OK;确认 @build 已重跑(戳取自编译时刻)。⚠ 若戳命中但里面的 suite= 与本次要跑的套件不符(想跑 CH395F 却显示 suite=NONE dstr=off)→ 烧的是产品镜像,@build 不会替你切宏(→ board-test-suite skill) |
READBACK: FAIL / flash readback mismatch |
芯片内容与 hex 逐字节不一致(写入未落地、选项字节/RDP 异常、烧错目标)。处置:按 docs/测试说明/ST-LINK_CLI_复位使用说明.md §7 排查;stlink_flash.py flash 重烧后闭环会自动复核 |
boot banner x2 > allowed 1 |
采集窗口内板子自己复位过(IWDG 咬狗 / Fault 后 NVIC_SystemReset / 掉电重启)。处置:看判定里逐条打印的 reset cause: 定位;跑故意复位的套件用 --max-boot-count N 放宽 |
marker order violated |
标记全中但先后顺序颠倒 = 某步在另一步之后补做(就绪屏障失序 / 失败重试补跑)。处置:看判定打印的各标记首次行号定位;--require-order 只在需要强约束时启用 |
boot time N ms > limit |
能开机但慢(mount 重试 / PHY 重协商 / 复位重试)。实测基线:net init: OK 在 +537ms(2026-10-10 台架);默认只报告不判(--max-boot-ms 0),要设门禁再给预算 |
端口被占用(port busy) |
先 serial_tool.py close 或 restart;检查其他程序占用;Keil ST-Link 不占 COM |
| 配置里的 COM 号失效(换口/换机) | serial_tool.py ports 现回 configured_present + hint(系统里只有一个候选时直接给出口名),open 的报错里也带 [hint: ...];确认后只改 .serialtest/config.json |
| 日志中无 UI 相关输出 | 见下节「UI 自动化」 |
日志出现 [ERR] 行 |
固件错误级日志(DBG_ERROR),默认 Forbidden 会判 FAIL:按 TAG 查对应模块的 docs/问题记录/*_Trap_Records.md;若属测试套件的预期错误(守卫负路径、非法名校验等),用 --forbidden 放宽 |
UI 屏幕自动化
- 初始化回归:
App/task/ui_task.c已有UI port init: OK/UI menu init: OK/UI net sink: started打印(见@test命令示例);将其加入-Required即可经串口自动回归 UI 初始化。 - 屏幕内容回归:由 LVGL 模拟器像素回归覆盖(文本级断言 + 66 张黄金参考 + 池水位门禁,
见
docs/UI设计/UI_自动化测试流程.md);上机屏幕仍需人工目检(见测试开发标准流程.md§1)。
扩展用法
- 单模块回归:
@test后接-Required定制,例如网络回归用-Required "App init OK;Network Init;net init: OK;PHY: 100M FULL"。 - 测试套件构建:
test/board/test_config.h开启某个TEST_SUITE_*后,把套件打印的=== ... Test:标题作为 Required 标记,配合test/下各用例输出判断。 破坏性用例默认不跑:test_config.h的ENABLE_DESTRUCTIVE_TESTS(默认关闭)控制写/擦 NAND、 格式化、坏块注入与 FatFS 写类用例;关闭时套件为只读模式(开机打印mode: READ-ONLY), 可随时在在用设备上执行且不改动卷内容。首次上电/产线验证时才打开(打印mode: DESTRUCTIVE)。 注意:套件内预期的错误日志会打印[ERR](GD5F 的TC-GD5F-704/705DMA 守卫 负路径与 FTL 运行期坏块、FatFs 非法名校验等),与默认 Forbidden 冲突,须显式放宽:--forbidden "FAIL;HardFault"([FAIL]仍会拦截真实断言失败); 例:GD5F 只读--required "=== GD5F2GQ5UE Tests: 16/16 PASSED",破坏性68/68。 - 长时间采集:daemon 全量持续写入
state/serial.log(20MB 上限自动截头保尾 10MB),直接读文件即可;或用log --grep <标记>过滤(2026-10-10 起先过滤后截尾,不必再配--tail 0)。