chore(工具,文档): 台架 rc 语义与导航硬闸用例、门禁环境诊断、护栏还债算法、mem-budget 跨树口径

四条道的收尾件同笔(全是 python 工具与文档,零固件源;各段归属按道署明)。

· **台架(bench-runner)**:verify_log.py 新增 classify_measurement() 与 measureme
nt.* 字段,零行/零锚点
  判 exit 2 并打 [MEASUREMENT-BROKEN],**但同帧有 Forbidden 命中或自检故障仍判 1**(专门造了「零锚点 +
  [ERR] MEMFAULT」的陷阱日志实测——崩溃不许被降级成「没数据」);--selftest 16 → 22 条。board_ui_nav.py
  把两条执行页安全闸做成 7 条常驻用例接进提交钩子跑的快检档,作者把自己用例**证伪三轮**(测试强制上限、
  清单清空时是崩不是判定、出厂默认被翻允许也照样绿)后三条危险改动逐条能红。台架卫生清单落 §7:每圈复位
  前 open + 断言 ready + 看 rx 增长(今晚 daemon 死了 status 自愈把它遮住)、**Forbidden 令牌不得落在
  英文单词内部**(裸 Fault 命中 default 两次假红)、改判定语义前先 grep 消费者。
· **环境(ui-param-writer)**:证伪卡片前提两处——identify 那个版本根本没有 tags 模块(RECORD 与磁盘逐项
  一致,pre-commit 自己也从 identify.identify 取 ALL_TAGS,全仓零引用),真问题是 44 条 system 钩子里
  42 条 entry 用裸 python → PATH 首位那个零第三方包解释器,今天没炸纯靠两件事同时成立。交付是**新增 entry
  硬规则 + 修复死顺序与回滚并标注等静默窗口**,没有动共享环境(此刻重装会让别人的钩子在半途换解释器)。
· **门禁(gate-docs-fixer 已入库的 51 号钩子)**:本篇带上 host-unit-tests 的裁定句落点说明。
· **内存(comm-config-writer)**:mem-budget skill 补跨树测量身份口径(脚本按 __file__ 定位产物、什么才算
  一次新测量、多链同涨同一数值的共享帧/幻影环归因法)+ 看门狗说明的指针行。

实测:verify_log --selftest 22/22、board_ui_nav --selftest-quick rc=0(硬闸 7/7)、
doc-links --all exit 0、
check_ids --strict exit 0 且绝对数 1,156 **未被本笔吃掉**;全部文件 LF / 无 BOM / U+FFFD=0
;本笔零固件源,
随上一批权威构建验证过(0 错 0 警),故不再单独 @build。
This commit is contained in:
2026-10-27 05:16:58 +08:00
parent f1d0b5c2d9
commit d286b69a2d
10 changed files with 499 additions and 30 deletions
+45 -7
View File
@@ -1,7 +1,7 @@
---
name: keil-build
description: Keil MDK-ARM 命令行编译闭环(自带独立脚本 keil_build.py,纯标准库,拷到任何 Keil 项目即用):UV4 -b 构建 → "0 错误 0 警告"判定 → build_log 定位报错。封装入口约定、退出码判读、UV4 改写工程文件的入库陷阱。
whenToUse: 需要编译 Keil MDK 工程、验证代码改动是否 0 错误 0 警告、或解读 UV4 构建日志时。
description: Keil MDK-ARM 命令行编译闭环(自带独立脚本 keil_build.py,纯标准库,拷到任何 Keil 项目即用):UV4 -b 构建 → "0 错误 0 警告"判定 → build_log 定位报错。封装入口约定(PowerShell 与 bash/ops_bash 两种调用形态及各自的 exit 2 归属)、退出码判读、UV4 改写工程文件的入库陷阱。
whenToUse: 需要编译 Keil MDK 工程、跑 `@build` 验证代码改动是否 0 错误 0 警告、解读 UV4 构建日志与退出码 2/4,或在 bash / `ops_bash` 里执行 `@build` 报语法错(syntax error near unexpected token)时。
---
# keil-build — Keil 命令行编译(独立可运行 skill)
@@ -10,16 +10,47 @@ whenToUse: 需要编译 Keil MDK 工程、验证代码改动是否 0 错误 0
(纯标准库,任意 python3;拷整个 skill 目录到别的 Keil 项目即可用,不依赖仓库脚本)。
```powershell
# cwd = 项目根目录
# cwd = 项目根目录;PowerShell 形态(AGENTS.md 里的 `@build` 写法)
& $PY ".dsh\skills\keil-build\scripts\keil_build.py"
# 覆盖:--project X.uvprojx --uv4 UV4.exe --target 名字 --log 路径 --timeout 600
```
## 调用形态:先认执行器是哪个 shell(2026-10-26 实测)
同一个 `@build`,**PowerShell 与 bash 是两种写法**;`ops_bash` / `run_code` 起的是
`/usr/bin/bash -c`,**不认 PowerShell 形态**,照抄仓库 `AGENTS.md` 会直接语法错退出。
| 执行器 | 正确写法(cwd = 仓库根) |
|---|---|
| PowerShell | `& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py` |
| bash(`ops_bash`) | `'.venv/Scripts/python.exe' .dsh/skills/keil-build/scripts/keil_build.py` |
- bash 侧**去掉前导 `&`**(bash 里 `&` 是"丢后台",不是调用操作符)、**路径改正斜杠**
(`\Scripts\python.exe` 的反斜杠在 bash 是转义,路径根本解析不出来)。
- 实测(step 41→42):把 PowerShell 形态丢进 `ops_bash` 后台任务 → 任务**秒退**、
`exit 2`,stderr 只有这一行:
```text
/usr/bin/bash: -c: line 1: syntax error near unexpected token `&'
```
- ⚠ **这个 2 不是脚本的环境错误 2**:脚本自己报环境错误会打出 `BuildEnvError` /
中文诊断(UV4 找不到、工程找不到、档案解析失败),而语法错**连解释器都没启动**,
`MDK-ARM/build_log.txt` 不会被改写。判据:stderr 含 `syntax error near unexpected token`
= 写法问题 → 换 bash 形态重跑,**别去查 `DTU_KEIL_UV4`、别当环境错误排障**。
- 后台跑(`run_in_background: true`)时要自己收码,且**别把脚本接进管道**:
`'.venv/Scripts/python.exe' .dsh/skills/keil-build/scripts/keil_build.py; rc=$?; echo RC=$rc`。
实测 `脚本 | tail -6; echo RC=$?` 里的 `$?` 是 `tail` 的退出码(恒 0),
拿它当 PASS/FAIL 会永远"绿";`set -o pipefail` 能透出非零码,但尾巴上挂的 `grep`
无匹配时同样报 1,也不可信。要看报错行就单独 grep 日志:
`grep -a -E 'Error|error:|Warning' MDK-ARM/build_log.txt`。
解析顺序(命令行 > 项目档案 `.serialtest/config.json` > 环境 > 探测):
工程 `uvprojx`(档案缺省时自动搜 cwd 下唯一 `*.uvprojx`);UV4 `uv4` >
`$DSH_KEIL_UV4` > `$DTU_KEIL_UV4` > PATH(**显式设了但路径无效 = 立即报错,不静默回退**)。
退出码:`0`=PASS(0错0警) / `1`=构建失败(打印报错行)/ **`2`=环境错误**(UV4 找不到或
路径无效、工程找不到、档案解析失败)/ `3`=用法错误 / `4`=build 锁被占用(并发 `-b` 不可行)。
**收到 2 先确认它是脚本打的还是 shell 语法错打的**(见上节),再走"环境错误"排障。
> 2026-10-12 修正:旧实现把环境错误写成 `raise SystemExit("...")` → 实际退出码是 **1**,
> 而用法错误由 argparse 抛 `SystemExit(2)` → 恰好占了"环境错误"的位置。故 `AGENTS.md` /
@@ -53,7 +84,9 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被
## 本仓库
**`@build` = 本 skill**(2026-10-10 起为仓库文档口径):在仓库根目录跑
`& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py`。
`& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py`——
**bash / `ops_bash` 下要写成 `'.venv/Scripts/python.exe' .dsh/skills/keil-build/scripts/keil_build.py`**
(原因见「调用形态」)。
判定口径(与原 `MDK-ARM/build.bat` 同款;该文件已于 2026-10-10 删除,本 skill 即 `@build`):
先删旧日志 → `-b -j0 -o` → 无 `error:` 且含 `0 Error(s), 0 Warning(s)`。
本 skill 另有超时保护、UV4 多级解析链(含 PATH 兜底)、失败只打报错行。
@@ -63,7 +96,7 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被
- **任何代码改动后必须先编译验证(0 错误 0 警告)再谈烧录/提交**——编译不过就进
硬件闭环只会把编译错误伪装成运行问题。
- **警告也算失败**:判 PASS 只认退出码 0,别只看"有没有 error:"。
- **警告也算失败**:判 PASS 只认退出码 0,别只看"有没有 error:"(取退出码的管道陷阱见「调用形态」)。
- **`MDK-ARM/*.uvprojx` 每次构建后被 UV4 自动改写,改动一律不入库**:UV4 会把工程
XML 归一化成本机风格(`<Tag />` → `<Tag></Tag>`、XML 声明加 `standalone="no"`)并把
`PackID` 降到**本机装的 DFP 版本**(属降级——提交后会把别人的 Pack 版本一起改掉)。
@@ -71,8 +104,10 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被
`git checkout -- <uvprojx>` 丢弃;只有真的增删了源文件(grep 文件名计数变化)才提交。
同目录的 `*.uvoptx` / `*.uvguix.*` 是 IDE 个人设置,已在 `.gitignore` 里,不会露出来。
- 报错定位读日志别只看终端回显:本仓库 = `MDK-ARM\build_log.txt`。
- **exit 2 先分清"真没设"还是"会话没继承"**(2026-10-10 实测):agent 的 shell 由
常驻服务派生,服务启动**之后**设置的用户级环境变量不会自动进来。查
- **exit 2 先分三种再动手**(2026-10-10 / 2026-10-26 实测):① **bash 语法错**——stderr 有
`syntax error near unexpected token`,是把 PowerShell 形态丢进了 `ops_bash`,换写法即可;
② **真没设** `DTU_KEIL_UV4`;③ **会话没继承**——agent 的 shell 由常驻服务派生,
服务启动**之后**设置的用户级环境变量不会自动进来。查
`[Environment]::GetEnvironmentVariable('DTU_KEIL_UV4','User')`,有值就
`$env:DTU_KEIL_UV4 = <该值>` 注入本会话再跑,别急着让用户去设。
- 编码类报错(`#870-D: invalid multibyte character sequence` 等)先怀疑源文件
@@ -82,3 +117,6 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被
- 烧录/回读 = `stlink-flash` skill;串口闭环 = `serial-boot-test` skill
(其档案 `flash_cmd`/`readback_cmd` 即 stlink-flash 的入口命令)。
- 收尾固定链(2026-10-26 实证):`@build` → `scripts/ram_baseline.py --update` →
体积门禁盖戳 → 内存数字回填与 `--scan` → 复跑 `ci_local` 静态段,
直到钩子全绿且 0 错误 0 警告(RAM/栈口径见 `mem-budget` skill)。
+114 -7
View File
@@ -1,7 +1,7 @@
---
name: mem-budget
description: 嵌入式 RAM/栈预算核算工作流:Keil .map/.htm 静态报告(ram_report/stack_report)+ 串口运行时水位叠加 + DBG 日志成本模型(每条约 90B Flash/216B 栈)。"改任务/缓冲/栈之前必须核算"的固定动作与判读口径。
whenToUse: 新增任务/加大缓冲/改栈/接入日志之前之后、链接报 L6406E 或 RAM 溢出、串口 [STACK] 水位余量低于底线、或需要回答"这个改动花多少 RAM/Flash"时。
description: 嵌入式 RAM/栈预算核算工作流:Keil .map/.htm 静态报告(ram_report/stack_report)+ 串口运行时水位叠加 + DBG 日志成本模型(每条约 90B Flash/216B 栈)+ 跨树(worktree)测量的身份口径——脚本按 __file__ 定位产物、什么才算一次新测量、多条链同涨同一数值的共享帧/幻影环归因法。"改任务/缓冲/栈之前必须核算"与"这个数属于哪棵树、哪次构建"的固定动作和判读口径。
whenToUse: 新增任务/加大缓冲/改栈/接入日志之前之后、链接报 L6406E 或 RAM 溢出、串口 [STACK] 水位余量低于底线、要回答"这个改动花多少 RAM/Flash";以及要复算或比对另一个 worktree 的栈数字、独立验证某个栈回归该记在哪一笔提交上、判断多次启动的栈峰值抖动算不算劣化时。
---
# mem-budget — RAM/栈预算核算
@@ -9,26 +9,123 @@ whenToUse: 新增任务/加大缓冲/改栈/接入日志之前之后、链接报
## 固定动作(改前、改后各跑一次)
```powershell
# cwd = 项目根;先 @build 保证 .htm/.map 是最新的
# cwd = 项目根;先 @build 保证 .htm/.map 是最新的(产物按脚本自身位置定位,见下节)
& '.venv\Scripts\python.exe' ".dsh\skills\mem-budget\scripts\mem_check.py"
# 等价于分别跑:
# scripts/ram_report.py (.map → RAM/Flash 分区与按文件占用表)
# scripts/stack_report.py (.htm 静态调用图 → 每任务最坏链 vs 配置栈)
# 报告落盘留档(改前/改后各一份,供逐帧差分):
& '.venv\Scripts\python.exe' scripts/stack_report.py -o ci-artifacts/sr_before.md
# 叠加运行时水位(有真机日志时):
& '.venv\Scripts\python.exe' scripts/stack_report.py -s .dsh\skills\serial-assistant\scripts\state\serial.log
```
退出码:0=全部通过 / 1=有任务不达标 / 2=解析错误(.htm/.map 缺失先重跑构建)。
别整篇读输出,抓关键行:`grep -aE 'iec104|margin|FAIL|ISR|Rule' ci-artifacts/sr_before.md`。
**「改前 / 改后」必须是两次不同的 Linker run**(两个不同的产物身份):同一份 .htm/.map 上跑两遍
只是把同一个数字复述两遍,不构成"改后已复验"。
## 测量身份口径(先钉树与时刻,再看数字)
### 1. 脚本按 `__file__` 定位产物,不按 cwd —— 跨树必须换脚本
```python
# scripts/stack_report.py:36-38
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
HTM_DEFAULT = REPO_ROOT / "MDK-ARM" / "STM32F407-Demo" / "STM32F407-Demo.htm"
FREERTOS_C = REPO_ROOT / "Src" / "freertos.c" # 配置栈来源,无命令行开关
# scripts/ram_report.py:16-18 同样用 dirname(dirname(abspath(__file__))) 定位 .map
```
⇒ `cd 进别的 worktree 再跑主仓的脚本` 量的仍是**主仓**那份 .htm/.map,数字不可比。
实测代价:这样一次"独立复算"量到主仓的 1,544 B / margin 1,016 B FAIL,据此写下的 +80 B 归因
被队友的逐树逐帧差分表推翻——目标 worktree 的真数是 1,464 B / margin 1,096 B / PASS。
正确做法:**量哪棵树就跑那棵树自己的脚本**(该树必须已在这棵树上构建过、有自己的 .htm/.map;
开树与合并的生命周期见 `task-worktree` skill):
```bash
'.venv/Scripts/python.exe' "<目标树>/scripts/stack_report.py"
'.venv/Scripts/python.exe' "<目标树>/scripts/ram_report.py"
```
⚠ 只给主仓脚本传 `-i <另一棵树的 .htm>`(或位置参数传另一棵树的 .map)**不够**:
`stack_report.py` 的配置栈仍从**脚本所在那棵树**的 `Src/freertos.c` 读(`FREERTOS_C` 写死自 `__file__`,
没有开关),两树栈配置一旦不同就是"半棵树的混合数字"。要整支脚本换树跑。
自检(每次复算都做):报告头两行 `- Source: ...` 与 `- Linker run: ...` 就是你实际量的树和时刻。
`Source` 若只打印一个裸文件名(不带 `MDK-ARM/...` 相对路径),说明 `-i` 指到了脚本 `REPO_ROOT` 之外的产物。
### 2. 重跑报告脚本**不算新测量**(复述 ≠ 复验)
`stack_report.py` / `ram_report.py` 只是解析盘上那一份 .htm/.map;产物没变,再跑一次数字必然不变。
那只能叫"复述",**不能写成"我复验过"**。要拿到新数字必须先有一次**新的 Linker run**(增量构建也算),
并把新时刻与产物来源一并写进记录。
⚠ `ram_report.py` 正文里的 `> Generated: …` 是**脚本运行时刻**,重跑就变——它不是链接时刻,
不能拿来当"这是新测量"的凭证。
### 3. 钉身份的取数法(.htm/.map 按约定不入库 → 把时刻写进正文/台账)
```bash
grep -aE 'Last Updated|Maximum Stack Usage' MDK-ARM/STM32F407-Demo/STM32F407-Demo.htm | head -2
# → "ARM Linker, 5060960: Last Updated: Mon Oct 26 22:52:12 2026"
```
- .htm 里唯一的链接时刻锚点是 **`Last Updated:`**;`Linker run` 只是 `stack_report.py` 自己输出里的标签,
`Date of Link`、`Image component` **不在 .htm 里**(实测按它们 grep 全空,白跑一轮)。
- 取不到该字段时退回产物 mtime;同一笔链接里 .htm 与 .map 的 mtime 只差 ~0.1 s,可据此确认两份产物同源。
再配 `git log -1 --format=%H` 与该树 `git status` 的未跟踪文件数。
- 记录最小格式:**`tree/commit + Linker run 时刻 + 产物来源(本次构建 / 盘上既有)`**。
本仓 .htm/.map 不入库,不钉时刻就等于把数字和树脱钩,下一个读数字的人无从判断它描述哪棵树。
- 归属还能机械证明时顺手做掉:`git log --name-only <那笔>..HEAD -- "*.c" "*.h"` 逐行筛(排除 `.uvprojx`)。
若那笔之后没有任何提交再碰固件源文件,产物描述的就是**在库那棵树**,不是提交前快照。
- 同族坑:`git show :path` / `git show HEAD:path` 读的是索引/HEAD,**不是工作区**。
## 静态最坏链回归的归因(同涨同一数值 = 共享帧,不是调用方)
- **多条任务链分毫不差同涨/同降同一个数值**(实测 default/net/iec104 三条 +80 B;断环后九条链各 −80 B)
⇒ 加深在这些任务**共用的被调帧 / 共享尾段**上,不在任何一个调用方。别逐个调用方翻,
也**别先猜"新增了大局部缓冲"**——实测猜错过一次(`con_ring_try` 只有 24 B 帧、没有数组)。
- 逐棵树逐帧比 frame size,不要只比总数:
- `stack_report.py` 输出的「Frame-size ranking (own frame)」表 + 每任务最坏链文本,锁定**链尾公共段**
(实测落在 `dbg_console_write_line → con_append → dbg_console_drain`,据此把账记到上一笔提交上);
- 直接在 .htm 里找环:`grep -ac 'In Cycle' MDK-ARM/STM32F407-Demo/STM32F407-Demo.htm`(实测 17 处),
形态为 `Max Depth = 280 + In Cycle` 与 `Call Chain = Error_Handler => dbg_console_write_line (Cycle)`。
`+ In Cycle` 表示 armlink 把**环上所有帧**计入含该环的每一条任务链——**链接器幻影环**能在无人改栈配置时让全仓链齐涨。
- 实测样本(无人改栈配置、全仓链却齐涨的那种):taskEXIT_CRITICAL ⇒ vPortExitCritical ⇒ configASSERT ⇒
vApplicationAssertFailed ⇒ dbg_console_write_raw ⇒ 回到 console_log 内部 ⇒ 再进临界区;
它使 `vApplicationAssertFailed` 的 Max Depth 由 ~40 B 变 128 B + In Cycle,差 88 B ≈ 三条链同涨 80 B 的来源。
断环方向:内核临界区改 PRIMASK 保存/恢复(保存量放局部变量)→ 零调用帧、不成环;
断环的**图证**(该帧从「136 B + In Cycle」变成「8 B、不再 In Cycle」)比数字更有说服力。
- `Maximum Stack Usage = N bytes + Unknown(Cycles, Untraceable Function Pointers)` 里的 `+ Unknown(...)`
说明静态图对环与函数指针不可完全解析,静态数是估计值,别当精确值上报。
- 不采信任何口头结论:用**目标树的脚本 + `.venv` 解释器**自己复算;复算同样先按上节钉身份。
- 处置后的下一步:抬栈(`Src/freertos.c` 的 `USER CODE` 区,属 Ask-first 动作)或收紧共享缓冲 / 断环,
**改前改后各一次** `stack_report.py` + `ram_report.py`,且"改后"那次必须落在一次新的 Linker run 上;
抬栈后跑 `gen_task_table.py` 同步任务表。
## 判读口径
- **静态**:`stack_report.py` 的 Max Depth(最坏调用链累计)vs `Src/freertos.c` 配置栈;
默认要求余量 ≥30%(`--min-free-pct`),ISR 另留 `--isr-margin`(默认 1024B)。
判定式 `margin = cfg - max(static_max_depth, runtime_peak)` 须 ≥ `max(isr_margin, 30% of cfg)`。
- **运行时**:开机日志 `[STACK] <task> peak_used=…/… free_min=…` 与静态值对照——
静态是上界、运行时是下界证据,**两者都要看**;余量 <1KB 底线要上报。
**静态最坏链是上界(硬线)、运行时峰值是下界证据(趋势)**,两者都要看;余量 <1KB 底线要上报。
- **RAM 总量**:`ram_report.py` 对 Grand Totals 与 `docs/内存管理/` 预算表核对;
链接错 `L6406E` = 已经溢出,用按文件表找大头。
- 任务表一致性由 pre-commit `task-table` 钩子保证(改栈后 `gen_task_table.py` 同步 AGENTS.md)。
- 任务表一致性由 pre-commit `task-table` 钩子保证;`mem-budget` 钩子拦 RW_IRAM1/RW_CCM >92%;
`mem-numbers` 钩子核文档里的内存数字——数字与树/时刻脱钩时就是这两条钩子会响。
## 运行时峰值抖动与"算不算劣化"
- 同一份镜像连续启动 5 次,`uiTask` 峰值实测在 4116~4188 之间摆 **72 B** ⇒ 跨启动本身就有几十 B 抖动,
"相对上一轮 +100 B"**不可归因**于被测改动。判据写成"栈水位不劣于上一轮"必然假红。
- 可执行修法(二选一,别都留着):
1. 同一份像**≥3 次启动取最大值**互比,并加容差(**±128 B 量级**);
2. 硬线改用 `stack_report.py` 的**静态最坏链**,运行时峰值降级为趋势参考。
- 判据一改要同步**所有落点**:判据正文、跑判据的脚本、记录判据的文档三处一起改,只改一处等于各说各话。
## 成本模型(实测,估算用)
@@ -42,8 +139,18 @@ whenToUse: 新增任务/加大缓冲/改栈/接入日志之前之后、链接报
- **自研代码禁 C 库堆**(MicroLIB 堆仅 512B):只用 `pvPortMalloc/vPortFree`(FreeRTOS 堆),
模拟器 CRT 堆照不出 MCU 堆分配失败。
## 验证(这轮测量算数了吗)
1. 报告头 `- Source:` 的相对路径落在**我要量的那棵树**下(不是裸文件名,更不是别的树);
2. `- Linker run:`(或 .htm 的 `Last Updated:` / mtime)与本轮那次构建对得上,且**不同于**上一轮那次的时刻
——相同就是复述,回去先做一次构建再比;
3. 树身份已按 `tree/commit + 时刻 + 产物来源` 写进提交正文/台账,而不是只留在终端输出里;
4. 结论里互比的数字必须来自**同一棵树同一次链接**;跨树比较要各自给出树与时刻;
5. 涉及劣化判定时注明依据的是静态最坏链(硬线)还是运行时峰值(趋势),并已计入跨启动抖动。
## 其它项目怎么套
模式通用:链接器 map + 调用图 htm 是**免硬件**的栈可观测源;固定"改前改后各跑一次
报告 + 运行时水位叠加 + 余量阈值"三件套。本仓库两个报告脚本按 Keil 输出格式解析,
换工具链(GCC newlib + `-fstack-usage`)需替换解析层,工作流不变。
报告 + 运行时水位叠加 + 余量阈值"三件套,**外加一条身份口径——脚本按自身位置定位产物,
跨树比的是"两棵树各自的脚本 + 各自的链接时刻",重跑同一份产物永远只是复述**。
本仓库两个报告脚本按 Keil 输出格式解析,换工具链(GCC newlib + `-fstack-usage`)需替换解析层,工作流不变。
@@ -16,10 +16,15 @@
[--require-order 'App init OK>Network Init>net init: OK']
[--done-marker 'net init: OK'] [--max-boot-ms 8000] [--json]
退出码(与旧版一致):
退出码(2026-10-27 task-32 起把 2 讲清楚;@test 那层的码不变):
0 = PASS(所有 Required 标记找到,Forbidden 零命中,汇总行要求满足)
1 = FAIL(缺 Required / 命中 Forbidden / 汇总行要求不满足)
2 = ERROR(日志文件不存在/不可读)
1 = FAIL(**设备侧真有坏消息**:缺 Required / 命中 Forbidden / 汇总行不满足 / 顺序或耗时超限)
2 = **测量没成立**(日志不存在或不可读;或开了 --max-boot-count 却零启动锚点 = 采集器没开、
日志被清、或采集没从复位开始)——这**不是**一次设备故障,按码聚合的脚本必须把它和 1
分开算,否则"采集断了"会被统计成"又崩了一次"。verdict 字段给 MEASUREMENT_BROKEN,
JSON 里 measurement.* 带三计数明细(payload_lines / boot_anchor_count / exact_hits /
loose_neighbours)。注意优先级:**零锚点但同时有 Forbidden 命中或自检故障记录时仍判 1**,
测量问题最多在旁边加一行 WARN——绝不能把真崩溃降级成"没数据"。
默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app/app_main.c + App/task/net_task.c),
按测试用例覆盖,例如:
@@ -224,6 +229,31 @@ def _reset_cause_of(line, marker):
return tail[:j] if j > 0 else tail
# --- measurement-vs-device classification (task-32; bench-runner night rule) ---
# Three counters decide which kind of "nothing found" we are looking at, and they must not
# be conflated: an all-zero read is a BROKEN MEASUREMENT, not a healthy device.
# hits=0, loose_neighbours=0, boot anchors=0 -> measurement broke (wrong file, closed
# serial port, dead collector). Nothing was observed -> no verdict is meaningful.
# hits=0, loose_neighbours>=1 -> the line exists but is damaged (head
# eaten / bytes spliced in) -> that is a real device-side defect, --exact's purpose.
# hits=0, loose_neighbours=0, anchors>=1 -> the boot DID happen; the marker is
# genuinely absent -> allowed to read as a functional miss.
# Reported as its own exit code (2, the existing "measurement/environment did not happen"
# family) so aggregation by exit code never counts a dead collector as a device failure.
def classify_measurement(n_lines, boot_count, boot_check_on, exact_checked, hits, neighbours):
"""(broken, reason). Only judges when a boot anchor check was actually requested."""
if not boot_check_on:
return False, "anchor check off (max_boot_count=0) -> cannot classify"
if n_lines == 0:
return True, "zero payload lines (collector closed / log truncated / wrong file)"
if boot_count == 0:
return True, ("log readable (%d line(s)) but ZERO boot anchor -> capture did not "
"start at a reset" % n_lines)
if exact_checked and hits == 0 and neighbours == 0:
return False, "anchors present: exact markers genuinely absent (functional miss, not measurement)"
return False, "measurement ok (hits=%d neighbours=%d anchors=%d)" % (hits, neighbours, boot_count)
def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
show_tail=40, out=print, suite_summary="",
selftest_pass=False, selftest_log="",
@@ -519,6 +549,39 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
for ln in lines[-show_tail:]:
out(ln)
# ---- measurement vs device (task-32) ----
n_exact_checked = len(exact or [])
n_exact_hits = sum(1 for e in (res.get("exact_ok") or []) if "hits=0" not in e["detail"])
n_neighbours = 0
for e in (res.get("exact_ok") or []) + (res.get("exact_bad") or []):
m = re.search(r"loose_neighbours=(\d+)", e.get("detail", ""))
if m:
n_neighbours += int(m.group(1))
b_on = max_boot_count > 0
b_cnt = (res.get("boot") or {}).get("count", 0)
broken, m_reason = classify_measurement(len(payloads), b_cnt, b_on,
n_exact_checked > 0, n_exact_hits, n_neighbours)
res["measurement"] = {"broken": broken, "reason": m_reason, "payload_lines": len(payloads),
"boot_anchor_count": b_cnt, "anchor_check_on": b_on,
"exact_checked": n_exact_checked, "exact_hits": n_exact_hits,
"loose_neighbours": n_neighbours}
# A broken capture must never mask real device-side bad news: if the log carries
# Forbidden hits or selftest fault records, that evidence stands and we report FAIL(1)
# with the measurement problem noted on top of it (worst outcome = a real crash quietly
# re-labelled as "no data").
if broken and (hits or selftest_fail):
out("")
out("[WARN] capture also looks incomplete: " + m_reason)
out(" but device-side bad news is present -> verdict stays FAIL, not MEASUREMENT-BROKEN")
broken = False
if broken:
out("")
out("[MEASUREMENT-BROKEN] " + m_reason)
out(" no verdict is meaningful here -- fix the measurement first (open the port,")
out(" assert the collector is alive, capture from a reset). Exit 2, NOT 1:")
out(" a dead collector must not be counted as one more device failure.")
return finish(2)
out("")
if (missing or hits or bad_summary or selftest_fail or boot_issue
or order_issue or timing_issue or exact_bad or pair_bad):
@@ -579,6 +642,19 @@ def selftest(out=print):
chk("blind spot proven: legacy substring PASSES where --exact FAILS",
legacy_present and not exact_ok_here, True)
# measurement-vs-device classification (task-32): the three-counter rule IS this function,
# so it is pinned here -- an all-zero read must not be reportable as a healthy device.
chk("measure: good capture -> not broken", classify_measurement(137, 1, True, True, 1, 0)[0], False)
chk("measure: zero payload lines -> broken", classify_measurement(0, 0, True, True, 0, 0)[0], True)
chk("measure: readable but no boot anchor -> broken",
classify_measurement(1, 0, True, True, 0, 0)[0], True)
chk("measure: anchor check off -> abstain (non-boot logs stay usable)",
classify_measurement(0, 0, False, True, 0, 0)[0], False)
chk("measure: damaged lines are device-side, not 'no data'",
classify_measurement(120, 1, True, True, 0, 3)[0], False)
chk("measure: anchors present + no neighbour = functional miss",
classify_measurement(120, 1, True, True, 0, 0)[0], False)
bad = 0
for name, ok, detail in cases:
out("{} {}".format("[OK] " if ok else "[FAIL]", name))
@@ -493,7 +493,7 @@ snapshot == required → miss_streak=0、证据门闩复位、喂狗
看门狗是本项目**第一次**把 CubeMX 生成流程引入可靠性关键路径,由此沉淀出与看门狗本身无关的通用方法论。`AGENTS.md`「平台约束」指向本节,改动 `.ioc`/`Src/`/`Inc/` 前请先读。
> **2026-10-26(道G task-26)一句话登记,机制不在本文复述**:四个 Fault 与栈溢出 / 断言钩子的**崩溃上报发送口已归一**进控制台单写者(`dbg_console_write_raw` + 一次整行独占,不再自行调用 `HAL_UART_Transmit`),机制正文与残余见 `docs/子系统设计/控制台单写者日志设计说明.md` §4.4.1。本节只关心一件事:这些实现仍须整体待在 `Src/freertos.c` 的 `USER CODE BEGIN 4/5` 区与 `Src/stm32f4xx_it.c` 的 `USER CODE BEGIN 0` 区(§10.2 的 L2),改完跑 `scripts/check_cubemx_survival.py`(§10.3)。
> **2026-10-26(道G task-26)一句话登记,机制不在本文复述**:四个 Fault 与栈溢出 / 断言钩子的**崩溃上报发送口已归一**进控制台单写者(`dbg_console_write_raw` + 一次整行独占,不再自行调用 `HAL_UART_Transmit`),机制正文与残余见 `docs/子系统设计/控制台单写者日志设计说明.md` §4.4.1。本节只关心一件事:这些实现仍须整体待在 `Src/freertos.c` 的 `USER CODE BEGIN 4/5` 区与 `Src/stm32f4xx_it.c` 的 `USER CODE BEGIN 0` 区(§10.2 的 L2),改完跑 `scripts/check_cubemx_survival.py`(§10.3)。同族另一笔只登记指针:断言钩子与临界区在控制台路径上互达会构成**幻影环**,armlink 的 `In Cycle` 记账随环形状波动(同一 port.o 三棵树实测每链差 0~80 B,休眠≠解耦),三树取证、量化表与「configASSERT 裁空宏 ≠ 优化收益」的钉死句见 `docs/内存管理/RAM使用分析与优化方案.md` §4.0.7(道G task-38)。
### 10.1 覆盖史(三次重生成)
@@ -36,7 +36,7 @@
| `--head-char C` | 命中行(去残留后)必须以 C 开头,如 `[` | 崩溃日志的行首形态本身就是判据 |
| `--pair FIRST THEN` | 恰好一条 FIRST,且其后**只有一条** THEN(跨复位配对) | 崩溃遗言必须与下一次启动的 `reset cause:` 成对;出现两条 = 有未记录/重复复位 |
| `--include-comments` | 不跳过 `#` 行 | 默认**跳过**:归档证据文件带 ASCII 溯源头,头里提到 `[ERR]` 不该触发 Forbidden(工具级假红防线) |
| `--selftest` | 跑内置 16 条判别用例后退出,无需日志与硬件 | "不可能失败的 PASS 就是空跑"——用例里含交错、缺行首、残留 1/2、配对错序,以及**"旧子串口径在同一条缺陷行上判 PASS"** 这一条盲区证明 |
| `--selftest` | 跑内置 **22 条**判别用例后退出,无需日志与硬件(含下表三计数分类器) | "不可能失败的 PASS 就是空跑"——用例里含交错、缺行首、残留 1/2、配对错序,以及**"旧子串口径在同一条缺陷行上判 PASS"** 这一条盲区证明 |
用法示例(一次仅复位启动的原子性判定)。四个行级开关**也已接进 `run_boot_test.py`**,
所以 @test 闭环里直接加 `--exact ... --pair ... --allow-residual 1 --head-char '['` 即可,无需单独跑本脚本:
@@ -50,6 +50,22 @@
--max-boot-count 1 --show-tail-lines 0
```
**退出码与三计数(task-32 定稿)——判据必须自己说清「没启动」与「没采到」**:
| 码 | 含义 | 触发条件 | 聚合口径 |
|---|---|---|---|
| 0 | PASS | 标记齐、Forbidden 零、锚点合法 | 一次好启动 |
| 1 | **设备侧真有坏消息** | 缺 Required / 命中 Forbidden / 顺序或耗时超限 / 整行锚定失配 | 记一次设备故障 |
| 2 | **测量没成立** | 日志读不到;或开了 `--max-boot-count` 却**零启动锚点**(采集器没开、日志被清、采集没从复位开始) | **不得**计入设备故障 |
三计数读法:`hits=0 + loose_neighbours=0 + 锚点=0` = 采集断了,先修测量,此时任何通过都无意义;
`hits=0 + neighbours≥1` = 行被啃(真缺陷,正是 `--exact` 要抓的);
`锚点≥1 + hits=0 + neighbours=0` 才允许当"功能缺失"看。
优先级铁律:**零锚点但同时有 Forbidden 命中或自检故障记录 → 仍判 1**,测量问题只在一边加 WARN
——绝不能把一次真崩溃降级成「没数据」(实测已按此口径验过:造一份"零锚点但含 `[ERR]` MEMFAULT"的日志,
工具仍判 FAIL 并另加一行 WARN,没有把崩溃改判成测量问题)。
`--selftest` 里钉了 6 条分类器用例;把分类器改成常量返回"不坏",自检立刻红(真跑过)。
## 3. 固定动作(顺序即判据,别调)
1. **环境**:继承完整环境,只补 `DTU_KEIL_UV4` / `DTU_STLINK_CLI`,并把 `TEMP`/`TMP` 指到真实目录。
@@ -70,10 +86,12 @@
7. **非空跑对照**:每换一版校验器,至少跑一次 `--selftest`,并把**缺陷发生时的原始捕获**再判一遍——必须 FAIL。
8. **收尾**:最后一次硬件动作是 `cmp`(排在全批日志之后);台账与证据按 `boot_log_data/README.md` 的口径归档。
## 4. 两条容易踩的纪律
## 4. 三条容易踩的纪律
1. **故意复位的腿不得并入"连续仅复位不重烧"的计数**。崩溃注入腿(MemManage/栈溢出钩子)本身就要复位设备,
并进常规序列会被下一个人数锚点数判成异常。它必须单独跑、单独进台账、标题写明"本腿含故意复位"。
**同一条口径覆盖"前面带烧录的圈"**:烧录启动之后紧跟的那一次启动不属于"连续仅复位不重烧",
报数写成两句并列——「烧录启动 ×1(不进序列)+ 仅复位 ×N」;若那一圈连字节都没采到,则**两条独立理由**都排除它(没数据谈不上计数)。
2. **崩溃腿的判据是原子性,不是 `[ERR]` 为零**。内核故障钩子的输出**本身就以 `[ERR]` 开头**
(`[ERR] MEMFAULT …` / `[ERR] STACK OVERFLOW task=…`),在同一条路径上要求 `[ERR]=0` 等于要求它别工作;
而**产品构建的正常启动根本不走 IPSR 路径**(多段裸发只在 `Src/freertos.c` 的两个内核钩子里),
@@ -97,7 +115,17 @@
- ⚠ **脚手架 `*.json` 一律不得放 `_wt/.state/`**:那是 task-worktree 的**台账目录**,脚本把每个 `*.json`
当台账读,实测 `task_worktree.py list` 直接崩在 `KeyError: 'id'`。放 `_wt/scratch/`。
## 7. 关联
## 7. 台架卫生清单(每一条都是实测踩过的,不是设想)
1. **每个台架窗口开头、以及每一圈复位之前**:`serial_tool.py open` + `status` 断言 `port.ready=true`,并**观察 `rx_lines` 在几秒内增长**。
实测:常驻 daemon 会死(今晚 pid 18020 跑了 156589 s 后没了),而 `status` 会**自动拉起一个新 daemon**,于是它一直"正常"地报 `ready=false / rx_lines=0` —— 端口状态**不会**跨小时存活,`@test` 开过也不算数。
2. **`serial_tool.py log / status / clear` 返回 JSON,不是纯文本**。按 `[RX]` 行首过滤纯文本会得到**恒 0 行**且看起来"跑成功了"。
3. **Forbidden 令牌不得落在英文单词内部**:裸 `Fault` 命中 `de`+`fault` → 打到 `[FTP] using factory default password` 与 `[STACK] default peak_used`(实测假红两次)。四个 Fault 的崩溃日志都以 `[ERR]` 开头,列 `[ERR]` 就够,要加就加 `MEMFAULT/BUSFAULT/USAGEFAULT/HardFault`。
4. **工具输出别混流**:非零退出时 stdout(结果)与 stderr(诊断)拼接后再 `JSON.parse` 必崩;解析只吃 stdout。
5. **给同事/lead 发消息时,正文里的引号用全角「」**:在 JS/TS 双引号字符串里放 ASCII `"` 会直接语法错,消息发不出去(本人一晚三次)。
6. **判据落点顺序**:先 grep 谁在消费这个码/字段(今晚的唯一消费者 `run_boot_test.py` 只对 `rc==0` 分支),再决定动不动语义。
## 8. 关联
- 修法与设计口径:`../子系统设计/控制台单写者日志设计说明.md`(O1 两实例与字节账、§4.4.1 崩溃路径验证设计)
- 闭环与标记口径:`./自动化测试流程.md`
@@ -437,6 +437,21 @@ CH395F / STORAGE / GD5F,未含 RTC;怀疑 RTC 构建下 `testTask` 不被创
(t≈0.6s / t≈2.8s,snap 仅缺 UI 位),NET_READY 解除后立即恢复——**未达 800ms 停喂
阈值、不触发复位**,属分片心跳与 100ms 抽样边界的固有交错,非回归;
处置:见到该行不必恐慌,**连续**出现 `task stall` 行/复位才是真停摆。
9. **导航类自动化脚本默认零 ENTER**(2026-10-26 台架实证,task-41 收口;口径也见
`启动日志原子性复验流程.md`):装置维护的 **强制复归 / 清除记录 是两段式确认**——
第一次 ENTER 只把该行标成「确认?」,**第二次 ENTER 才真执行**(复归保护位并写 SOE /
清 SOE·运行·操作记录)。因此任何"认不出屏就按 ENTER 试一下"的回收逻辑,落到这两页上
等于**替操作员按下执行键**;更隐蔽的是它只在画面异常时才发作(新字库缺字、白框、状态行
变化——恰恰是回归最想抓的那几帧),平时零流量、改动时无声。 `scripts/board_ui_nav.py`
的两条硬闸与 `--selftest-quick` 里的 **7 条常驻用例**(假设备 + 内存空白帧 + 按键只记账,
**不连 7003、不开串口、不碰台架**,提交钩子 `uinav-selftest` 就跑这档)把它钉成回归:
① UNKNOWN 退避的 ENTER 上限 `BACKOFF_MAX_E`(政策钉死 ≤1,需要第二次就当场停机并 dump 帧,
报错文案点名执行类风险);② `EXEC_PAGE_IDS`(clear / reset)**默认拒绝下钻**且拒绝发生在
**任何键投出去之前**(真机实测:连轨迹文件都不生成),显式 `--allow-exec-pages` 才放行并打警告。
用例还钉住**两个常量本身**:上限被调到 2 以上、清单被清空、或默认值被翻成"允许"——
都必须在 `--selftest-quick` 里报红(这三条都用"故意改坏再跑"逐条证过能红,不是空跑)。
通用要求:**导航 = 移光标 + 取帧;ENTER 只能出现在明确、非执行类的用例步骤里**;
要看执行类页面的显示正确性,去模拟器黄金参考(那里没有真机后果)。
---
@@ -481,6 +496,7 @@ CH395F / STORAGE / GD5F,未含 RTC;怀疑 RTC 构建下 `testTask` 不被创
| 日期 | 变更 |
|---|---|
| 2026-10-27 | **§9 新增第 9 条「导航类自动化脚本默认零 ENTER」**(task-41):装置维护两页是两段式确认、第二次 ENTER 即真执行,故导航器禁止用 ENTER 从未知状态脱身;`scripts/board_ui_nav.py` 两条硬闸(`BACKOFF_MAX_E` ≤1 停机、`EXEC_PAGE_IDS` 默认拒绝且投键前即拒)做成 `--selftest-quick` 7 条常驻用例(假设备零注键,提交钩子跑这档),并钉住常量本身(上限调高 / 清单清空 / 默认翻成允许 → 报红,三条均实测能红) |
| 2026-10-03 | **步骤 5/6/7 闭环**:§6 GD5F 错误通道回归 PASS(`=== GD5F2GQ5UE Tests: 16/16 PASSED (failed=0) ===`;E-10/42/53/54/56/57 全部销项;顺带发现并修复编排器判据缺口——GD5F 的 TC-GD5F-704 故意触发 `[ERR]` 回落轮询,`run_all_suites.py` 改为**按套件**覆盖 `--forbidden`,与 `verify_log.py` 说明一致)→ §1/§6 改「✅ 已闭环」。§7 网络稳定性:**7.1 正常路径 ✅**(V5 120/120 FTP、零复位;负向路径转 `N-16`,台架拓扑受限);**7.2 N-2 ✅判据②/未复现判据①**(丢弃可观测性代码已有:`console_log.c` `s_con_drop`+`[ERR] CONSOLE dropped`、`sys_log_mcu.c` 队列满 `DBG_ERROR`、TX 僵尸通道自愈;V5 30 min 日志间隔分析最大静默 41s 且在开机边界、运行期 24 处 >10s 静默**全为 ~28s 且均在 `e02_probe` 窗口内**=N-17 同源)→ N-2 降级低风险观察项;**7.3 N-3 ⬜ 待 PC peer** → §1/§7 改「◐ 部分闭环」。§8 工程卫生:S5 `test-cppcheck` 钩子 PASS(仅 warning,排除模拟器桩);S4 `scripts/check_test_traceability.py` 落地并跑通(**30 处差异**,默认 warn-only);**待核实项 `TEST_SUITE_SELECTED` 实机核实 = 死代码**(全仓无使用点;`start_test_task()` 直接按宏分支,RTC 套件实际正常执行;真缺口 = 缺 `=== RTC Tests: N/M PASSED ===` 汇总行,已补 `test/board/sd2506_test_task.c` 并修正 Phase 级汇总累计问题;`test/board/test_config.h` 补齐 RTC 并注释"当前无引用")→ §1/§8 改「✅ 已闭环」 |
| 2026-10-03 | **步骤 3/4 闭环**:V2/V4/V5 台架验收通过(V2 任务饿死声明 stall 后 ≤2.5s `reset cause: IWDG`;V4 关中断死循环每 ~4.9s 稳定 IWDG 复位;V5 30 min / **120×FTP 全 PASS、零 IWDG 复位**)→ §1 总览与 §4 状态改「✅ 已闭环」,实施细节与证据移入 `子系统设计/看门狗设计说明.md` 变更记录。S11 堆告警代码落地、V5 全程未触发(`min_ever=4728 B` > 3072 B)→ §5 改「✅ 已闭环」,基线入 `内存管理/RAM使用分析与优化方案.md`。新登记 `待处理内容.md` **N-17**(V5 期间 44 次 stall 与 `e02_probe` 的 time_sync 30s 掉线+监听重建重合;隔离实验「仅 FTP 120s = 0 stall」证实探针场景专属) |
| 2026-10-03 | **重构为纯执行流程**:确立单一组织逻辑——本文 = **按顺序执行**的上板测试流程(步骤 1~7 + 状态标记),参考内容(§9 执行注意 / §10 已完成事项去向 / §11 引用闭环)后置。已完成事项(S7、A1~A4)的实施细节与真机证据**移出本文**,§10 只留「事项 → 去向」指针;§3 只留复跑命令与判据;§1「事项总览」改「执行流程总览」(步骤/状态列) |
+1 -1
View File
@@ -26,7 +26,7 @@
| `.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`**;退出码 0/1/2。子串 Required 照不出"行首被吃掉"那一类行级损坏 → 行级完整性一律用 `--exact`,口径与固定动作见 `启动日志原子性复验流程.md` |
| `.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` |
## 判定标准
@@ -85,10 +85,73 @@ $env:DTU_STLINK_CLI = 'D:\Tools\ST-LINK Utility\ST-LINK_CLI.exe'
---
## 5. 三个解释器各能跑什么(2026-10-27 实测,道G task-33)
> 起因:有队友在 `.venv` 里 `import identify.tags` 失败,又用 `pip` 查不到 `pre-commit`,据此判定
> 「`.venv` 里的 pre-commit 安装是残缺的」。**复测结论:环境不残缺,两条观测都成立但推错了因**——
> 前者是版本本身没有那个模块(下表第三行),后者是查错了对象(下表第二行)。
> 取证方法固定:同一个探针脚本(`sys.executable` + 逐个 `import` + `importlib.metadata.version`)
> 分别喂给三个解释器;脚本放仓外临时目录,不入库、不改任何环境。
| 解释器 | 身份 | 门禁相关模块 | 第三方包 | 结论 |
|---|---|---|---|---|
| `.venv\Scripts\python.exe` | 3.12.4(**基底是 Anaconda**,非干净 CPython) | `identify` 可导入、`pre_commit` 可导入;`identify.tags` **无此模块** | fontTools 4.51.0 / pillow 10.3.0 / pyserial 3.5 / pyyaml 6.0.3 / cfgv 3.5.0 全在,`pre-commit` 与 `identify` 的 dist-info 齐全(`pip show` 可查) | **就是钩子的执行环境**:`.git/hooks/pre-commit` 里 `INSTALL_PYTHON` 写死指向它 |
| `C:\Python314\python.exe` | 3.14.7,PATH 首位 | 三个全缺 | **一个都没装**(`pip` 元数据全 `PackageNotFoundError`) | 「查不到 pre-commit」的现场就是它;`language: system` 的 42 条 entry 的裸 `python` 落在这里 |
| `C:\Users\...\Python311\python.exe` | 3.11.9,PATH 次位 | 同上全缺 | 同上全缺 | 与门禁无关,列出来只为排除「装了但找不到」的猜测 |
**为什么"缺 `identify.tags`"不是故障**:`identify-2.6.19.dist-info/RECORD` 一共 24 行,列出的模块只有
`__init__.py` / `cli.py` / `extensions.py` / `identify.py` / `interpreters.py` / `vendor/licenses.py`——
**根本没有 `identify/tags.py` 这一条**,而磁盘上的文件与 RECORD 逐项一致。即"元数据完好、文件齐全、
只是这个版本没有那个模块"。标签常量在这个版本里位于 `identify/identify.py`(`TEXT` / `BINARY` /
`EXECUTABLE` / `ALL_TAGS`),pre-commit 4.6.2 自己也是从那里取的(`clientlib.py` 里
`from identify.identify import ALL_TAGS`),**全仓没有任何脚本 import `identify.tags`**(实测 grep 零命中)。
⇒ 需要复算文件类型标签时,正确入口是 `identify.identify`;旧写法 `identify.tags` 在这个版本对上必然失败。
### 5.1 新增钩子 entry 的硬规则(今天没炸的真正原因)
`language: system` 的 entry 不继承 `.venv`:pre-commit 不改 `PATH`,所以裸 `python` 命中 PATH 首位
(本机 = 3.14.7,**零第三方包**)。实测本仓 44 条 system 钩子里 **42 条 entry 以裸 `python` 开头**,
今天能全绿只因为两件事同时成立:
1. 那 42 条脚本**只用标准库**(抽测 bom-drift / layer-deps / doc-links 与一套带 harness 的主机单测,
在 3.14.7 下 exit 0);
2. 唯一需要第三方包的那条(字体幂等性钩子 `scripts/check_generated_fresh.py`)**自带解释器解析**,
不依赖 entry 用的是谁。
⇒ 写新钩子时二选一,别开第三条路:要么脚本只用标准库,要么在脚本内部按
「显式变量 > 当前解释器 > 仓库 `.venv` > PATH」自行解析并**探针校验**(先例与理由见
`docs/门禁说明/generated-fresh.md` §4)。**不要**把 entry 写死成某台机器的绝对路径。
### 5.2 什么情况下才需要动环境,以及动之前要走死顺序
默认动作是**不动**(本节存在即结论:没有需要重装的东西)。只有出现下列**具体现象**才执行修复,
且必须先要到静默窗口——`pre-commit` 与 `.venv` 是全队共享运行面,中途换包会把别人的一次钩子跑打断:
1. 停表:确认没有人在跑提交/门禁/台架(build 与 bench 两类锁都空闲,见 `task-worktree`);
2. 备份指针:记下 `& '.venv\Scripts\python.exe' -m pip freeze > 仓外文件`(不入库);
3. 只补不重建:优先 `pip install --force-reinstall --no-deps <包>==<requirements.txt 里的版本>`,
**不要**顺手 `-U`(那会连带动别人的依赖);确实要整体重建才 `pip install -r requirements.txt`,
且该文件必须保持纯 ASCII(Windows 下 pip 按代码页解码);
4. 自证(缺一不算修完):`& '.venv\Scripts\python.exe' -m pip show pre-commit` 有版本 →
`-m pre_commit --version` 通 → `scripts/check_generated_fresh.py` 与字体字符集钩子在 `.venv`
与 PATH 两种解释器下都 exit 0 → 最后跑一次 `scripts/ci_local.py`;
5. 回滚:新装/升级出问题时按第 2 步的 freeze 反向 `pip install <包>==<原版本>`;**别删 `.venv` 重来**,
删了就把「基底是 Anaconda」这条不可复制性也一起删掉(换机复现不了,见本文 §3 与 `docs/环境与部署/项目部署环境要求.md`)。
### 5.3 仍开着的账(不在本次范围,登记给对应属主)
- `DTU_PRECOMMIT_PYTHON` 本机当前**未设**,靠 §1.1 的优先级自动落到 `.venv`;如果哪天 PATH 首位换了
解释器,静态门禁档就会跟着换——把它显式设成 `.venv` 是一条零成本的加固(改的是本机环境,不动仓库)。
- 42 条裸 `python` entry 与两个解释器的版本差(3.14 vs 3.12):今天没有可观测的行为差,但这是
「靠巧合不炸」。要不要把 entry 统一改成显式解释器解析,属门禁属主的决定(同一族的先例见 §5.1)。
- 本机的 `.venv` 基底是 Anaconda 发行版,`python -m venv` 在别的机器上给出的不是同一个东西;
这条差异目前无人记录在 `docs/环境与部署/项目部署环境要求.md` 里。
## 变更记录
| 日期 | 变更 |
|---|---|
| 2026-10-02 | **文档改造**:删除规划阶段内容(设计结论、路径耦合盘点、代码改造映射、分阶段推广与验收、风险与边界、后续任务拆分),保留并重组为现行机制说明——§1 变量清单(含 Python 优先级特别说明)、§2 解析与失败语义、§3 本机配置方法(会话/用户/runner 三级)、§4 验收判据、变更记录;章节重编号,外部引用同步(`gen_refgraph.py` §3→§1、`ci_local.py` §3.1→§1.1、`Gitea_Runner部署指南.md` §5.3→§3.3) |
| 2026-10-27 | **新增 §5 解释器实测矩阵(道G task-33,只读诊断,未动任何环境)**:三个解释器(仓库 `.venv` / PATH 首位 / PATH 次位)逐个探针实测,钉住三条事实——① `.venv` **不是残缺**:`pre-commit` 与 `identify` 的 dist-info 齐全(`pip show` 可查),`identify-2.6.19` 的 RECORD 里本就没有 `tags.py`,标签常量在该版本的 `identify.identify` 里,pre-commit 自己也是从那里取(旧写法 `identify.tags` 在这个版本对上必失败,全仓零引用);② 钩子执行环境被 `.git/hooks/pre-commit` 的 `INSTALL_PYTHON` 钉死为 `.venv`,而 `language: system` 的 42 条 entry 用裸 `python` → 落到 PATH 首位那个**零第三方包**的解释器,今天没炸是「42 条全只用标准库 + 唯一需要第三方包的那条自带解释器解析」两件事同时成立,据此写下新增 entry 的硬规则(§5.1);③ 「查不到 pre-commit」的现场是查了 PATH 上的解释器。另附 §5.2「默认不动环境;确需修复时的死顺序与回滚(只补不重建、freeze 备份放仓外、按 freeze 反向回滚、禁止删 `.venv` 重来)」与 §5.3 三条待属主的账。| 2026-10-02 | **文档改造**:删除规划阶段内容(设计结论、路径耦合盘点、代码改造映射、分阶段推广与验收、风险与边界、后续任务拆分),保留并重组为现行机制说明——§1 变量清单(含 Python 优先级特别说明)、§2 解析与失败语义、§3 本机配置方法(会话/用户/runner 三级)、§4 验收判据、变更记录;章节重编号,外部引用同步(`gen_refgraph.py` §3→§1、`ci_local.py` §3.1→§1.1、`Gitea_Runner部署指南.md` §5.3→§3.3) |
| 2026-10-02 | **实施完成**:阶段 A~D 落地。① 10 处调用点全部改为"`DTU_*` 变量优先 + 自动发现回退"(显式值无效 = 硬报错、不静默回退):`build/download/reset.bat`(goto 标签式解析,纯 ASCII)、`ci_local.py`(`DTU_PRECOMMIT_PYTHON` 严格校验)、`ci.yml`(`DTU_PRECOMMIT_PYTHON`/`DTU_CPPCHECK`/`DTU_MSVC_CL`/`DTU_WINDOWS_KITS_ROOT`,run 块纯 ASCII)、`gen_refgraph.py`(`DTU_GRAPHVIZ_DOT`→PATH,保留无渲染降级)、`unit_test_harness.py`(`DTU_MSVC_CL`/`DTU_WINDOWS_KITS_ROOT` + `%ProgramFiles*%` 推导取代静态盘符列表)、`build_sim.ps1`(`DTU_VSWHERE`→`%ProgramFiles(x86)%` 推导,去字面 C 盘回退)、`make_font.py`(`%WINDIR%` 推导);`_build.cmd` 确认零调用后删除。② 本机按 §3.2 配置用户级 `DTU_KEIL_UV4`/`DTU_STLINK_CLI`(其余工具本机均可自动发现,未设变量),验证:`@build` BUILD_OK 0/0、`ci_local.py` L0 15 钩子 + L0.5 278/278 断言全绿、refgraph 重生成(含修复 `引用关系图.json` 末尾换行缺失导致的 end-of-file-fixer 抖动,生成器补 `f.write("\n")`)、`build_sim.ps1` vswhere 推导命中 VS2026(当时模拟器 CMake 配置期暴露**既有**问题:`App/cfg_file.c` 已移入 `App/cfg/` 但 `simulator/CMakeLists.txt` 未同步——与环境变量改造无关;2026-10-02 已修复:CMakeLists 同步 `App/cfg/` + `test/board/` 引用,模拟器全量构建通过)、`reset.bat list` 识别探针。③ 文档收口:`AGENTS.md`/`MDK-ARM/README.md`/`项目部署环境要求.md`(新增 §2.1.1)/`Gitea_Runner部署指南.md`(新增 §5.1.3 机器级变量)/`CI配置指南.md`/`pre-commit使用说明.md`/字体/测试流程/引用图说明/ST-LINK 说明/实机规划/`scripts/README.md` 共 12 篇同步为变量口径。④ `requirements.txt` 增 `pre-commit==4.6.2`(重建 `.venv` 即可跑 L0)。⑤ 全量 `pre-commit --all-files` + `ci_local.py` 通过。待办:新 workflow 推送远程后跑一次 CI 两 job 闭环验证;第二台不同安装路径机器的交叉验证(操作清单已固化入 `项目部署环境要求.md` §2.12) |
| 2026-10-02 | 初版规划:盘点 BAT/Python/PowerShell/Gitea workflow 中机器相关工具路径;定义 `DTU_*` 变量清单、优先级、用户级与 SYSTEM runner 配置边界;规划 4 阶段迁移及离线/实机验收范围。未改工具调用代码 |
+143 -7
View File
@@ -841,6 +841,136 @@ BOARD_TRUTH = {
}
def selftest_safety_gates(clf):
"""两条执行风险硬闸的常驻回归用例(假设备、内存帧、零真机注键)。
为什么要常驻这两条:闸只在真出事的那一屏才起作用,平时没人碰,于是"改了个常量
就把闸拆掉"这种回退不会有任何人察觉。本用例不连 7003、不开串口、不碰台架——
设备是一帧内存里的空白位图,按键只记账、不投递。
覆盖的两条闸(2026-10-26 台架叠险 → ac9ca33f 收口,理由见文件头):
闸① UNKNOWN 退避里的 ENTER 次数上限(BACKOFF_MAX_E):需要第二次就当场停机;
以及显式改用无 ENTER 键序(--backoff-keys sl)时,一个 ENTER 都不许投出去;
闸② 执行类页面(EXEC_PAGE_IDS)默认拒绝下钻,且拒绝发生在**任何键投出去之前**;
显式放行(--allow-exec-pages)后不得再报"刻意跳过"——放行 = 接受后果。
返回 (用例数, 失败列表),与其它 selftest_* 同形,失败非 0 退出由调用方决定。
"""
g = globals()
saved = {"classify": g["classify"], "ALLOW_EXEC_PAGES": g["ALLOW_EXEC_PAGES"],
"BACKOFF_KEYS": g["BACKOFF_KEYS"], "BACKOFF_MAX_E": g["BACKOFF_MAX_E"]}
bad = []
n = 0
class FakeDevice(object):
"""假设备:grab 永远给同一帧内存位图,key 只记账不投递(不连板、不连模拟器)。"""
def __init__(self, fr):
self.fr = fr
self.keys = []
def grab(self):
return self.fr
def key(self, ch):
if ch not in KEYS:
raise ValueError("未知键 " + repr(ch))
self.keys.append(ch)
return self.fr
def stub(kind, extra=None):
def _f(fr, _clf):
st = {"kind": kind, "page": None, "cols": None, "evid": 0, "no": None}
if extra:
st.update(extra)
return st
return _f
def check(name, ok, detail):
print("%-5s %s %s" % ("[OK]" if ok else "[FAIL]", name, detail))
if not ok:
bad.append(name + ": " + detail)
blank = Frame(bytes(FRAME_BYTES), "fake_blank")
try:
# 政策钉:出厂默认必须是"拒绝下钻"。下面所有用例会临时改这两个常量,
# 所以默认值只能在进函数时抓一次(saved)——否则有人把默认翻成 True,
# 用例照样自己设 False 再测,全绿放行一个危险的默认(实测漏过一次)。
n += 1
check("闸② 出厂默认即拒绝(ALLOW_EXEC_PAGES 默认 False)",
saved["ALLOW_EXEC_PAGES"] is False,
"模块默认=%r(True 意味着任何人漏写 --allow-exec-pages 也会下钻执行类页面)"
% (saved["ALLOW_EXEC_PAGES"],))
# ---- 闸①:UNKNOWN 退避最多投 BACKOFF_MAX_E 次 ENTER,第二次当场停机 ----
g["classify"] = stub("UNKNOWN")
g["BACKOFF_KEYS"] = "esl"
# 故意**不**覆盖 BACKOFF_MAX_E:闸的强度本身要被钉住(有人把上限调到 2 就该红)
dev = FakeDevice(blank)
msg1 = ""
try:
Navigator(dev, clf).to_root(limit=12)
except RuntimeError as exc:
msg1 = str(exc)
n += 1
e_cnt = dev.keys.count("e")
check("闸① UNKNOWN 退避 ENTER 上限",
("BACKOFF_MAX_E" in msg1) and e_cnt == saved["BACKOFF_MAX_E"]
and saved["BACKOFF_MAX_E"] <= 1,
"停机消息=%s,投出 ENTER=%d 次(上限配置=%d,政策钉死 <=1),键迹=%s"
% (bool(msg1), e_cnt, saved["BACKOFF_MAX_E"], "".join(dev.keys)))
# ---- 闸①补:显式无 ENTER 键序时,一个 ENTER 都不许出现 ----
g["BACKOFF_KEYS"] = "sl"
dev2 = FakeDevice(blank)
try:
Navigator(dev2, clf).to_root(limit=6)
except RuntimeError:
pass
n += 1
check("闸①补 无 ENTER 键序(--backoff-keys sl)", "e" not in dev2.keys,
"键迹=%s(应不含 e)" % ("".join(dev2.keys) or "-"))
# ---- 闸②:执行类页面默认拒绝,且拒绝发生在投键之前 ----
g["BACKOFF_KEYS"] = "esl"
g["ALLOW_EXEC_PAGES"] = False
g["classify"] = stub("MENU", {"cols": [("根", 0)]})
# 清单被清空 = 闸被拆。这种情况要**报红**,不能"零条用例"悄悄过去,也不能崩
# (崩了只是噪声,红色才是判定;ng==0 只能兜住"一条都没跑",兜不住"清单空了")
n += 1
check("闸② 执行类清单完整(清空或缺项 = 拆闸)",
len(EXEC_PAGE_IDS) >= 2 and {"clear", "reset"} <= set(EXEC_PAGE_IDS),
"EXEC_PAGE_IDS=%s" % (sorted(EXEC_PAGE_IDS) or "空"))
for pid in sorted(set(EXEC_PAGE_IDS) | {"clear", "reset"}):
dev3 = FakeDevice(blank)
msg3 = ""
try:
Navigator(dev3, clf).go_page(pid)
except RuntimeError as exc:
msg3 = str(exc)
n += 1
check("闸② 执行类页面 %s 默认拒绝下钻" % pid,
("刻意跳过执行类页面" in msg3) and ("真执行" in msg3)
and ("e" not in dev3.keys),
"拒绝理由点名真执行=%s,投出的 ENTER=%d(消息:%s)"
% ("真执行" in msg3, dev3.keys.count("e"), msg3[:36] or "没抛错"))
# ---- 闸②反向:显式放行后不得再报"刻意跳过"(放行 = 接受后果)----
g["ALLOW_EXEC_PAGES"] = True
g["classify"] = stub("UNKNOWN") # 放行后走进导航,被闸①停住而不是被闸②拒绝
dev4 = FakeDevice(blank)
msg4 = ""
try:
Navigator(dev4, clf).go_page(sorted(set(EXEC_PAGE_IDS) | {"clear", "reset"})[0])
except RuntimeError as exc:
msg4 = str(exc)
n += 1
check("闸②反向 --allow-exec-pages 放行后不再拒绝",
("刻意跳过执行类页面" not in msg4) and bool(msg4),
"放行者=%s(消息:%s)" % ("刻意跳过执行类页面" not in msg4, msg4[:36] or "没抛错"))
return n, bad
finally:
for k, v in saved.items():
g[k] = v
def selftest_board_frames(clf):
"""真机留档帧上的分类自检:不需要台架也能证明认屏认得对。"""
bad = []
@@ -1032,13 +1162,17 @@ def main(argv):
return 0
if "--selftest-quick" in argv:
# 提交门禁用的快检档:只核"认屏认得对",不需要模拟器构建产物、不需要台架
print("[1/2] 仓库黄金参考分类(模拟器与板端同一份 UI 源码 = 语义基线)")
print("[1/3] 仓库黄金参考分类(模拟器与板端同一份 UI 源码 = 语义基线)")
nr, badr = selftest_refs(clf)
print("[2/2] 真机留档帧分类(目录缺则整段跳过,不算失败)")
print("[2/3] 真机留档帧分类(目录缺则整段跳过,不算失败)")
n, bad1 = selftest_board_frames(clf)
print("汇总(快检):黄金参考 %d 帧错 %d;留档帧 %d 帧错 %d" % (nr, len(badr), n, len(bad1)))
print("[3/3] 执行风险硬闸(假设备、零真机注键,task-41)")
ng, badg = selftest_safety_gates(clf)
print("汇总(快检):黄金参考 %d 帧错 %d;留档帧 %d 帧错 %d;硬闸用例 %d 条错 %d"
% (nr, len(badr), n, len(bad1), ng, len(badg)))
print("闭环导航未跑(需 lvgl_sim.exe):完整自检用 --selftest")
return 1 if (badr or bad1 or nr == 0) else 0
# 硬闸用例一条没跑也算失败:静默跳过等于把闸拆了还显示绿(同 nr==0 的处理口径)
return 1 if (badr or bad1 or badg or nr == 0 or ng == 0) else 0
if "--selftest" in argv:
print("[1/3] 仓库黄金参考分类(模拟器与板端同一份 UI 源码 = 语义基线)")
nr, badr = selftest_refs(clf)
@@ -1050,11 +1184,13 @@ def main(argv):
all_ids = [p for (_l, _n, p, _np) in clf.tree if p]
m, bad2, log = selftest_closed_loop(clf, all_ids)
_dump_trace(log)
print("汇总:黄金参考 %d 帧错 %d;留档帧 %d 帧错 %d;闭环 %d 页失败 %d"
% (nr, len(badr), n, len(bad1), m, len(bad2)))
print("[4/4] 执行风险硬闸(假设备、零真机注键,task-41)")
ng, badg = selftest_safety_gates(clf)
print("汇总:黄金参考 %d 帧错 %d;留档帧 %d 帧错 %d;闭环 %d 页失败 %d;硬闸用例 %d 条错 %d"
% (nr, len(badr), n, len(bad1), m, len(bad2), ng, len(badg)))
# 留档帧目录(ci-artifacts/uiwalk,不受版本控制)可以不存在 → 那一段不算失败;
# 但黄金参考与闭环必须真跑过:任一为 0 说明这次"自检"其实什么都没检查
return 1 if (badr or bad1 or bad2 or nr == 0 or m == 0) else 0
return 1 if (badr or bad1 or bad2 or badg or nr == 0 or m == 0 or ng == 0) else 0
if "--walk" in argv:
rc = bench_gate(argv, "--walk")
if rc:
+5
View File
@@ -71,6 +71,11 @@ def _global_python_candidates():
L0 全灭就是 workflow 把候选收窄成 PATH python + 仓库 .venv(runner 上两者
都是死路),而真正装了 pre-commit 的机器级解释器(如
C:\\ProgramData\\anaconda3\\python.exe)不再被探测。
本机实测矩阵(哪个解释器装了哪些包、钩子实际跑在谁身上)见
docs/环境与部署/本地工具路径环境变量方案.md §5:提交期钩子由 .git/hooks/pre-commit
里的 INSTALL_PYTHON 钉死在仓库 .venv,而 language: system 的 entry 用裸 python 时
**不继承**它——所以依赖第三方包的脚本必须像本函数这样自己解析解释器。
"""
out = []
pf = os.environ.get("ProgramFiles", r"C:\Program Files")