测试闭环补固件身份戳、烧录回读校验与启动锚点计数,并修cmd引号解析与flash-cmd预检缺陷

This commit is contained in:
2026-10-10 02:54:33 +08:00
parent b37fdea21b
commit fccf99bc90
8 changed files with 261 additions and 23 deletions
+140 -8
View File
@@ -4,10 +4,16 @@
调用仓库内置串口助手(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时间戳> " 前缀)
5. verify_log.py 标记校验
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 可对第一个锚点做原因白名单)
6. (仅当 --require-selftest-pass)自检记录联合判定(S7):
--selftest-log 提供本地副本则直接用它,否则从设备 FTP 抓取 /log/selftest.log
(lftpd :21 匿名);判定:文件缺失 / 0 字节 = 无故障 → PASS,有 64B 记录 = FAIL
@@ -32,19 +38,23 @@
[--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']
--port 缺省取 .opencode/serial_config.json 的 portName(单一配置源)
退出码(与旧版一致):
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
1 = FAIL(标记缺失/命中)
2 = 采集或烧录错误
2 = 采集或烧录错误(含烧录回读不一致 = 芯片内容与刚编的镜像不同,本次运行作废)
3 = 用法/预检错误
"""
import argparse
import ftplib
import json
import shlex
import subprocess
import sys
import tempfile
@@ -59,10 +69,70 @@ REPO_ROOT = SCRIPT_DIR.parent.parent
SERIAL_TOOL = REPO_ROOT / "tools" / "serial_tool" / "serial_tool.py"
CFG_FILE = REPO_ROOT / ".opencode" / "serial_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)。
@@ -173,6 +243,21 @@ def main(argv=None):
help="device IP for fetching /log/selftest.log via FTP (lftpd anonymous)")
ap.add_argument("--out-dir", default=str(Path(tempfile.gettempdir()) / "opencode"))
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")
args = ap.parse_args(argv)
# 串口助手 CLI 用当前解释器(AGENTS.md 统一入口 = 仓库 .venv python)
@@ -191,7 +276,10 @@ def main(argv=None):
print("RUN_TEST_ERROR: no --port given and no portName in {}".format(CFG_FILE))
return 3
flash_cmd = Path(args.flash_cmd) if args.flash_cmd else DEFAULT_FLASH
# --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):
@@ -226,10 +314,10 @@ def main(argv=None):
# 3. flash(下载 + Reset & Run)。烧录失败不中断:板子可能已复位,
# 由采集 + 标记判定结果(与旧版一致)
print("FLASH_START: {}".format(flash_cmd))
print("FLASH_START: {}".format(" ".join(flash_argv)))
t0 = time.monotonic()
try:
proc = subprocess.run(["cmd", "/c", str(flash_cmd)],
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"")
@@ -243,6 +331,25 @@ def main(argv=None):
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 永不触发,只能靠后面"日志里没有
@@ -280,17 +387,42 @@ def main(argv=None):
boot_txt.write_bytes(("\r\n".join(rx) + "\r\n").encode("utf-8"))
print("Boot log: {} (lines={} gen={})".format(boot_txt, len(rx), data.get("gen")))
# 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. 标记校验(含可选的自检记录联合判定)
# 6. 标记校验(含固件身份戳、启动锚点计数、可选的自检记录联合判定)
from verify_log import verify
rc = verify(str(boot_txt), args.required, args.forbidden, args.show_tail_lines,
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)
selftest_log=selftest_local,
max_boot_count=args.max_boot_count,
expect_reset_cause=args.expect_reset_cause)
print("RUN_TEST_RC={}".format(rc))
return rc