Files
STM32F4-Base/docs/测试说明/单元测试/主机单测覆盖率口径.md
T
super_agent_bot a4b8bee209
CI / 静态门禁 (pre-commit) (push) Has been cancelled
CI / 主机侧单测 (MSVC) (push) Has been cancelled
CI / 静态门禁 (pre-commit) (pull_request) Has been cancelled
CI / 主机侧单测 (MSVC) (pull_request) Has been cancelled
feat(net/udp): UDP 数据面并入 netMsgQueue 串行执行(#73)
前置来自 #57 SNTP 调研:旧 net_sendto / net_recvfrom 由调用方直调
SPI 且无限自旋收包,既违约「netTask 是芯片唯一访问者」,又把 IWDG
预算吃在自旋里。

改造面
- 新增 Drivers/BSP/NET/net_udp.c/.h(真机专属,模拟器不编译):
  bind/set_peer/sendto/recvfrom 四条执行体 + 挂起收包表 + tick;
  net_msg_type_t 加四型,公开 API 一律「入队 → 睡在通知上」。
- 源地址出参不搭 net_msg_t(按值拷贝,AGENTS 禁加回传字段):改住
  net_udp.c 的 per-fd 槽,netTask 就地写回调用方内存,通知值只带
  结果码;每条出口都清槽,泄漏 = 该 fd 永久 BUSY。
- 发送复用 net_send_locked,不写第二份分块逻辑;「会话是否已死」
  收成一条判据 net_send_state_down()(TCP 看 ESTABLISHED、UDP 看
  UDP_OPEN),原先 4 处各写一遍。
- 顺带修掉真缺陷(新条 TRAP-NET-22):旧 sendto 把 DesIP 写成具体
  IP,芯片静默切 UDP Client,而旧 recvfrom 无条件剥 8 字节 → SNTP
  这类 48 B 应答丢载荷前 8 字节并交出垃圾源地址。加 udp_mode(塞进
  原有 padding,sizeof 不变)、bind 恒 SERVER、显式对端 sendto 切
  CLIENT、set_peer(fd, NULL) 轻量回 SERVER,仅 SERVER 且 avail >= 8
  才剥头。
- 截断定案:一次调用交付一个数据报,buf 装不下的尾巴用 64 B 栈上
  scratch 排空(不用 clear_recv_buf——它连后面已到达的包一起丢)。
- 取消点接齐:net_init 重建 cancel_all、net_socket 分配 fd clear、
  net_close settle_close + clear、重 bind 前先结算等包者,
  net_process_messages 第 5 个 tick 位挂 net_udp_tick。
- 收紧两处契约(原实现无生产调用者):UDP 未 bind 一律 -5;
  net_recv / _nb / _to 拒 DGRAM(绕过剥头就是静默吃 8 字节);
  NET_ERR_NOT_IN_NETTASK(-12) 退役为历史常量并写明原因。

验证
- 主机单测 test/host/net_socket 全套 616/616 绿(改造前 470):新
  分片 ns_s7_udp.c 20 组,改写 440 / 555 / 560 组;覆盖率
  net_udp.c 234/236(99.2%)、net_socket.c 956/969(98.7%),
  scripts/test_coverage_baseline.json 随本提交登记新文件。
- 测试抓到 4 个被测码真 bug:自等守卫误用调用侧谓词、芯片长度被读
  两次、槽指针取在 fd 合法性检查之前、重 bind 静默作废等待者。
- Keil ARMCC 0 Error(s) 0 Warning(s);net_udp.o Code 1,358 / ZI 224
  (8 fd × 28 B,其余模块零增长),全仓 RW_IRAM1 72.2%;内存台账
  受管区按门禁提示用 --write / --update 机器回填,未手改数字。
- pre-commit 全量钩子(含 doc-links、coding-standard、mem-budget、
  generated-fresh、bsp-err、registration、nested-comment)全绿。

限度
- 本轮无板子:真机回归 5 条(两端互通、set_peer(NULL) 能否恢复收
  广播、截断排空后下一包完整性、挂起 recv 期间 IWDG 余量、bind 写
  DesIP 与 open 先后的真芯片效果)已写进覆盖率口径「台架必验」。
- scripts/stack_report.py 退出码 1 是 10-26 起挂在账上的 iec104Task
  栈债(margin 1,016 < 1,024,见 docs/代码规范/pre-commit使用说明.md:
  122);本轮只让 net_bind 的 UDP 分支多一层静态帧(1,544 → 1,600),
  netTask margin 1,888 与 timeSyncTask 1,152 不受影响。抬栈要过
  mem-budget 台账,不在本分支自行处理。
- 登记簿销项按仓库约定留给主工作树,本分支不动 docs/任务规划/**。
2026-11-03 18:08:27 +08:00

763 lines
92 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 主机单测覆盖率口径(覆盖率脚本 / 基线 / 盲区)
> **状态**:✅ 已落地,并**已挂 pre-commit**(`test-coverage` 总闸,2026-10-24 接线,缺组件按裁定 FAIL;
> 该钩子带 `stages: [manual]`——`git commit` 阶段不跑,由远端 CI「手动阶段门禁」步与 `scripts/ci_local.py` 的 manual 段跑);
> **分支覆盖**:本机可实测拿到(§2.1 的 gcc/WSL 路),但**用户裁定 2026-10-23 不引入第二条工具链**
> → 本文把它记为**已接受缺口**,不是待办
> **日期**:2026-10-23
> **脚本**:[`scripts/check_test_coverage.py`](../../../scripts/check_test_coverage.py)(纯标准库)
> **基线台账**:[`scripts/test_coverage_baseline.json`](../../../scripts/test_coverage_baseline.json)
> **关联文档**:
> [`测试开发标准流程.md`](../流程与规范/测试开发标准流程.md)(静态门禁~人工目检分层与门禁决策表)、
> [`../../代码规范/pre-commit使用说明.md`](../../代码规范/pre-commit使用说明.md)(钩子通用机制)
---
## 1. 一句话口径
**行覆盖 = 至少执行过一次的可执行行 / 被插桩的可执行行**,统计面只算自研生产源码
(`App/**`、`Drivers/BSP/**`),多套单测对同一文件的命中取**并集**;函数覆盖同理按
"至少有一行命中"计。**没有分支覆盖**——本机拿不到(§2),这一点决定了"绿"能说明什么、
不能说明什么(§7)。
## 2. 工具链现实:为什么是 mscoverage,不是 gcov
2026-10-23 本机(VS 2026 / MSVC 14.51)逐项实测,**换机请按同一顺序重探一遍**:
| 候选 | 实测结果 | 结论 |
|---|---|---|
| gcc / gcov / clang / llvm-cov / lcov / gcovr(**Windows 原生侧**) | PATH 上没有;再全盘扫 `Program Files*`、`C:\msys64`、`scoop`、choco、`.platformio`、`%LOCALAPPDATA%\Programs` → **0 处**(OpenCppCoverage 也没有) | Windows 侧走不了 gcov 这条最主流的路(但 WSL 里有,见 §2.1) |
| `cl /coverage`、`cl /fsanitize=coverage` | **MSVC 14.51 已不认**:只报 `warning D9002: 忽略未知选项`,**然后照常编译成功** | ⚠ 最阴的一种:不报错、静默没有插桩 → 覆盖率恒为 0 或假数据,绝不可用 |
| `Team Tools\Dynamic Code Coverage Tools\CodeCoverage.exe`(17.0) | 文件在,但 `collect` / `analyze` / `instrument` **一律只打印"用法: CodeCoverage <command>"** 并返回 -1 | 已 deprecated 且子命令不可用 |
| **`Microsoft.CodeCoverage.Console.exe`**(mscoverage 18.x,随 VS 装在 `Common7\IDE\Extensions\Microsoft\CodeCoverage.Console`) | `collect -f cobertura` 直接吃 MSVC 原生 exe + PDB,产出标准 Cobertura XML;**子进程退出码原样透传**(实测子进程 `return 3` → `collect` 也返回 3) | ✅ **本脚本用它**("加载期动态插桩",不是编译期插桩) |
### 2.1 GCC 到底行不行:能编、能跑、**能出分支覆盖**——结论是**不用**(换编译器就是换分母)
本机 **WSL2 Ubuntu 里有 gcc / gcov 15.2.0**(`wsl -d Ubuntu`,仓库经 `/mnt/d` 可见)。2026-10-23 实测:
| 测项 | 实测结果 |
|---|---|
| 16 套单测用 `gcc -std=c11 -O0 -g --coverage` 编译 | **全部编过、全部 PASS**,用例数与 MSVC 逐个一致(45/103/151/46/36/97/149/57/47/90/13/32/100/82/33/39)——**可移植性不是障碍** |
| `gcov -b -c` | ✅ 行覆盖之外**还给出分支**:`nand_ftl.c` 行 45.49% / 分支至少走过一次 **41.40%(157 支)**;`cfg_file.c` 行 86.72% / 分支 79.69%(512 支);`sha256.c` 行 97.14% / 分支 82.00%(50 支) |
| 代价一:**分母变了** | 同一文件的"可执行行"两个编译器不同:`App/util/sha256.c` gcc 105 行 vs MSVC 114 行;`nand_ftl.c` 244 vs 253;`adc_input.c` 101 vs 120 → **两套数字两套台账,绝不能混在一张表里比涨跌** |
| 代价二:链接也必须带 `--coverage` | 只给编译加、链接不加 → `undefined reference to __gcov_init`(实测踩过,报错还不指向原因) |
| 代价三:环境不归仓库管 | WSL 是「这台机器装了」,不是「这个仓库保证有」;CI runner 虽是同一台机器,还得先让 WSL 起来,且 `gcov` 在 `/mnt/d`(drvfs)上读写 `.gcda/.gcno` 很慢 → 工作目录要放 WSL 本地 |
**决策记录——默认通道仍是 MSVC + mscoverage,三条理由**:① 与门禁**同一份编译开关**(原样复用
`unit_test_harness._BASE_FLAGS`),台账涨跌直接对应「门禁那份改动」;② 不依赖 WSL 起没起;
③ 零下载、零新依赖、纯标准库解析 Cobertura。**分支覆盖不做第二条通道(用户裁定 2026-10-23,本仓只留 MSVC 一条)**:
多一套工具链就多一套分母、多一本台账、多一个"这台机器到底有没有 WSL/gcov"的未决项,而换来的只有
分支这一维——不值当。代价如实记在 §7 第 1 条:本口径的绿灯**只证明行与函数被测到**。
探测顺序:环境变量 `DTU_MS_COVERAGE`(设了就必须有效,**不静默回退**,与 `DTU_MSVC_CL` 同口径)
→ 各 VS 安装根的 `Common7\IDE\Extensions\Microsoft\CodeCoverage.Console` → PATH。
找不到即以退出码 2 失败并打印上述现实,**不会**假装通过。
## 3. 怎么跑
```powershell
# 全量:47 套单测逐套"编译(带 PDB)→ 插桩运行 → 解析 Cobertura",最后并集 + 比基线
& '.venv\Scripts\python.exe' scripts/check_test_coverage.py
# 只跑一套(调试用;子集会跳过"在测文件消失"类回退判定,见 §5)
& '.venv\Scripts\python.exe' scripts/check_test_coverage.py --module sha256
# 刷新基线(改完测试或改完被测源码、确认过数字之后)
& '.venv\Scripts\python.exe' scripts/check_test_coverage.py --update-baseline
# 出报告(默认只打控制台;报告是一次性快照,放临时目录,不入库)
& '.venv\Scripts\python.exe' scripts/check_test_coverage.py --md $env:TEMP/cov.md --json $env:TEMP/cov.json
```
**与 主机侧纯逻辑单测同一套发现规则**(`test/host/**/run_unit_test.py`,与 `scripts/ci_local.py` 一致),
并且**不复制任何编译配置**:脚本 import 各模块运行器、直接复用其声明式 `BUILD`(`TestBuild`),
编译开关取 `unit_test_harness._BASE_FLAGS`,只追加 `/Zi /Od` 与 `/link /INCREMENTAL:NO`。
所以"include 目录 / 桩顺序 / 测试单元路径"只有一处定义,覆盖率脚本不会漂成第二套口径。
退出码:`0` 全过且无回退 / `1` 有单测失败或覆盖率回退 / `2` 环境错误(无 cl、无 mscoverage、
编译失败、XML 缺失)/ `3` 用法错误。全量实测约 **67 s**(33 套 × 编译 + 插桩运行;
2026-10-25 实测;16 套时约 25 s、28 套时约 45 s,套件数涨了耗时就涨,别拿旧数当预算)。最新一次实测:
2026-10-28 = 41 套 **~105 s**(该钩子同日移入 `stages: [manual]`,由远端 CI 与 `ci_local.py` 的 manual 段强制);
套件数现值 **47 套**(2026-10-30 实测,台账见 `docs/门禁说明/host-unit-tests.md` §6 第 4 条),**47 套这一轮的总闸耗时未重测**,
上面那三个耗时都是当时值,别当现值预算。
套件数的现值与逐个来历见 `docs/门禁说明/host-unit-tests.md` §6 第 4 条。
中间产物(exe / PDB / Cobertura XML)一律落 `%TEMP%\dtu_host\<工作树哈希>\coverage\<模块>`
(与主机单测同根、按树分家,2026-10-25 起;改根用 `--work-dir` 或 `DTU_HOST_BUILD`),**不进仓库**,
所以本脚本不需要新增任何 `.gitignore` 规则。
## 4. 统计面:进什么、不进什么
| | 处理 | 依据 |
|---|---|---|
| `App/**`、`Drivers/BSP/**` 的 `.c`/`.h` | **计入** | 自研生产源码,是唯一有意义的覆盖率对象 |
| `test/host/**`(测试单元自身与 `stub/`) | 不计 | 它们不是被测面;测桩的"覆盖率"是自我安慰 |
| `Lib/`(LVGL / FatFs / dhara)、`Middlewares/`、HAL / CMSIS | 不计 | 第三方;与 `cppcheck` / `coding-standard` 的排除面同口径 |
| Windows SDK / UCRT / MSVC 头 | 不计 | 同上(脚本按"仓库内 + 前缀"双重过滤) |
| **生成物**(文件头 15 行内含"自动生成 / 请勿手改 / DO NOT EDIT") | **不进分母**,单列打印 | 现有 5 个:`ui_font_cn_12.c`、`ui_menu_tab.c/.h`、`ui_page_registry.c/.h`。给生成表算覆盖率只会稀释数字、照不出问题 |
分母一律取"**插桩报告里出现的行**",**不做任何静态行数猜测**;`--md` 报告里"有效代码行"
那一列只用于未触及面的体量提示,不参与任何百分比。
## 5. 门禁姿态:只拦回退,不拦进步
台账 = [`scripts/test_coverage_baseline.json`](../../../scripts/test_coverage_baseline.json)
(`--update-baseline` 生成,含 `totals` / `modules` / `files` / 未触及清单)。
| 情况 | 判定 | 说明 |
|---|---|---|
| 某文件 `lines_covered` 或 `funcs_covered` 比基线**少** | ❌ **REGRESS**(退出码 1) | 删了用例,或改代码让原来到达的行到不了——这才是要拦的 |
| 总计 `lines_covered` 比基线少 | ❌ REGRESS | 兜住"搬家式下降" |
| 基线里在测的文件本次整个消失(仅全量跑时判) | ❌ REGRESS | 通常是 include 链被删,或那套单测被跳过 |
| 基线里某套单测本次没跑到 / 没通过 | ❌ REGRESS | 覆盖率只在**同一套测试全绿**的前提下可比 |
| 新文件被纳入统计 | 🟡 NOTE | 进步不拦,但确认无误后要 `--update-baseline` 登记 |
| 某文件可执行行数暴涨(>1.3× 基线且 +120 行) | 🟡 NOTE | 百分比会被摊薄,提醒刷基线,免得把"没退"当成退 |
| `--module` 子集运行 | 🟡 NOTE | **跳过全部覆盖率回退判定**(并集必然变小,不是回退):只保留「单测必须通过」与「新文件登记提示」两项;判回退必须跑全量 |
与 `layer-deps` / `bsp-err` / `id-check` 同属**增量拦截**:**绿灯只等于"没有比上次更差"**。
## 6. 首次实测(2026-10-23,基线首登值)
```
[SUMMARY] coverage: line=2552/2893 (88.2%) func=193/207 (93.2%) suites=16/16 tested_files=19 untested_files=125
```
| 事实 | 数字 |
|---|---|
| 16 套主机单测 | 全部通过(插桩运行下仍 PASS) |
| 被测到的自研生产源文件 | **19 个** / 2,893 可执行行 |
| 行覆盖最低的两个 | `Drivers/BSP/GD5F2GQ5UE/nand_ftl.c` 48.6%(123/253,函数 14/22)、`App/protocol/iec104/iec104_pts.c` 67.2%(117/174,函数 13/17) |
| 行覆盖 95%+ 的文件 | 12 个(多为纯逻辑层:`adc_health` / `sample_ring` / `vol_lock` / `time_sync` / `task_watchdog` …) |
| **从未进过任何主机单测的生产源文件** | **125 个 / 约 11,839 有效代码行**——拆开是 **47 个 `.c`(9,657 行)
| + 78 个 `.h`(2,182 行,多数只有声明与宏)**;前五:`net_socket.c` 1,251、`ch395f.c` 801、`lftpd.c` 694、
| `gd5f2gq5ue.c` 689、`sys_log_mcu.c` 458。待办池是那 47 个 `.c`,且其中 29 个属 §9 P3「刻意不做」 |
⚠ 读法:88.2% 是"**被测的那 19 个文件内部**"的比率,**不是全仓覆盖率**。全仓的真相写在
`untested_files` 这一栏(125 个),它比 88% 这个数字有用得多——也是 §7 第三条的由来。
## 7. 限制与盲区(读这篇文档第一眼看这节)
1. **本文这条通道没有分支覆盖。** mscoverage 导出的 Cobertura 里 `branch` 恒为 `False`(实测),
MSVC 又已砍掉 `/coverage`。含义:`if (a && b)` 只走过一侧,这一行也算 100% 覆盖。
→ 报告 §5.5 想要的"覆盖到哪些分支"**没有被本文满足**,被满足的是"哪些行根本没进过"。
分支覆盖本机**能拿到**(§2.1 的 gcc + `gcov -b -c`),但**已裁定不引入第二条工具链** →
它就是本口径的**已知缺口**:绿灯只代表行与函数被测到,`if (a && b)` 的另一半要自己读用例。
补偿判据:TC 双向追溯 + 用例侧显式列边界值(`docs/测试说明/流程与规范/测试开发标准流程.md` §5);
真到要分支证据那天,先看 §2.1 的三条代价再决定,别顺手装个 `dotnet-coverage` 当第二套口径。
2. **0 分母 ≠ 0%。** 125 个文件从没被任何主机单测编译过,它们**不在**覆盖率里;把"88%"读成
"仓库八成代码被测"是最常见的误读。
3. **桩会重塑被测面。** `nand_port` / `adc_input` / `cfg_file` 等用 `stub/` 顶掉真实 HAL 与日志头,
统计的是"生产 `.c` 在桩环境下可达的行"。真机才走的分支(寄存器超时、DMA 回调、SPI 错误)
在主机侧**永远 0 命中**——它们会拉低百分比,但**不是缺陷**,别为凑覆盖率造假桩。
4. **`/Od` ≠ 真机 `/O1`。** 覆盖率构建只为行映射服务,优化级别与 Keil 产物不同,
不能由它推断真机的可达性或时序。
5. **插桩会改变时序。** 动态插桩让被测程序变慢,对真实 tick/延时敏感的逻辑理论上可能被拖出
不同路径。脚本的处置是**硬要求"插桩运行仍必须 PASS"**(汇总行 + 退出码双校验)——
宁可红,也不给可疑的数字。
6. **只覆盖跑到的进程树。** 一次 `collect` 只看一次运行;没跑到的套件不进分母
(全量跑时以"模块未跑到"判 REGRESS),子集运行明确降级(§5)。
7. **头文件里的代码归 `.h`。** 统计面含 `.h`(静态内联与被展开的行落在头文件上);当前 47 套
几乎没触及 `.h`,表里看不到它们——不要以为头文件"天生 100%"。
8. **依赖 `Microsoft.CodeCoverage.Console.exe`。** 探测顺序:`DTU_MS_COVERAGE`(设了就必须
有效、**不静默回退**)→ 各 VS 安装根的 `Common7\IDE\Extensions\Microsoft\CodeCoverage.Console` →
PATH;找不到即退出码 2(**不是 0**)。2026-10-24 起该结果直接红掉 `test-coverage` 钩子(用户裁定
FAIL 语义),并已合成自证:把 `DTU_MS_COVERAGE` 指到一个不存在的文件 → 钩子 Failed、脚本退出码 2、
打印可执行的 `[ENV]` 提示。
⚠ **更正本文旧说法**(原写"只有 BuildTools 且没勾代码覆盖率工具的机器会退出码 2"):实测本机
Community 18.10 与 BuildTools 18.9 **两份安装都自带**该 exe(mtime 2026-09-15 02:30 / 2026-09-05
16:17,都是各自安装当日,不是后来补勾)→ VS 18 的 BuildTools 默认就带它。真实风险面只剩:没有
VS 只有裸 MSVC 的机器、`DTU_MS_COVERAGE` 指错、以及靠 PATH 兜底的环境。CI runner 与本机同一台
(`docs/CI/README.md`);**换机仍先跑 `--list` 再跑全量**。
9. **并集口径下"单套百分比"没有意义。** 同一文件被多套触及很常见(`sha256.c` 被 `sha256` 与
`ftp_auth` 都编译),所以对外只报**并集**,单套只报"触及几个文件 / 多少行"。
## 8. 接线状态与下一步
**已落地**:脚本、基线台账、口径(本文);`test-coverage` 已并进 `scripts/ci_local.py` 的 manual 段(`--coverage` 旗标保留只为兼容旧命令)。
**接线状态:已挂**(2026-10-24,`test-coverage` 落在 `.pre-commit-config.yaml` 第 8 节,用户裁定 **FAIL** 语义)。
它带 `stages: [manual]`——**`git commit` 阶段不跑**(一次带代码的提交只跑 49 根快钩子),由远端 CI 的
「手动阶段门禁(覆盖率总闸 + 全量 cppcheck)」步与 `scripts/ci_local.py` 的 manual 段承担;手工复跑
`& '.venv\Scripts\python.exe' -m pre_commit run --all-files --hook-stage manual test-coverage`。
接线前的三条理由连同它们的现状一并留档,免得后人重问同一件事:
1. 39 个钩子里已有 16 个主机单测钩子(每套各编译各跑),再加一份"全量插桩"在**当时**确实是同一
件事做两遍,还要多约 25 s;
⚠ **这条理由的事实基础已变(2026-10-24,P1 落地后)**:本仓现有
`test/host/**/run_unit_test.py` **33 套**,而钩子只挂 **16 套**——另外 17 套(P1/P2 新增的
全部模块)目前只被 `ci_local.py`(主机侧纯逻辑单测自动发现)与 `check_test_coverage.py` 跑到,
**不在提交期跑的那些钩子里**(`test-coverage` 本身带 `stages: [manual]`,提交期同样不跑)。
所以现状不是"重复两遍",而是**总闸一遍 + 提交期一遍快归因**的分工。
当时唯一没解的是第 2 条:必须先定"缺 VS 组件时 skip 还是 FAIL"——那是要用户裁定的事(选 skip
等于造一根会静默失效的门禁,与 `always: true 已失效` 同类)。**2026-10-24 用户选 FAIL,本闸已挂**,
缺口关闭。接线时的两个实测数:全闸跑完 **67 s**(33 套;本文别处写的"约 25 s"是只有 16 套、
"45 s"是 28 套时的值,都已过时);缺组件时钩子 **Failed**、脚本退出码 2 并打印 `[ENV]` 提示(合成自证)。
2. 它依赖 VS 可选组件(§7 第 8 条)。挂之前必须先定"缺组件时 skip 还是 FAIL"——
选 skip 就是一个会静默失效的门禁(与 `AGENTS.md` 里那条 `always: true 已失效、必须靠 files: 覆盖`
是同一类坑);
3. 基线首登后需要**真实回退事件**才算被验证过。已做的是**合成自证**(人为抬高 `test_coverage_baseline.json`
的三处数字后全量跑):行 / 函数 / 总计三条回退路径都报 `[REGRESS]` 且退出码 1;`--module` 子集运行
不会假报回退(首版会,已修:并集口径下子集必然变小,所以 `--module` 子集跑跳过全部回退判定)。
真实事件(某次改动真把覆盖行弄少了)仍待观察。
落库的就是下面这份(`.pre-commit-config.yaml` 第 8 节);`docs/门禁说明/README.md` 里只挂一行索引指向
本文,同一事实不在两处维护:
```yaml
- repo: local
hooks:
- id: test-coverage
name: 主机单测覆盖率(增量拦截:已覆盖行只增不减)
stages: [manual] # 不随 git commit 跑;CI「手动阶段门禁」步与 ci_local.py manual 段跑
entry: python scripts/check_test_coverage.py
language: system
files: '^(App/|Drivers/BSP/|test/host/|scripts/check_test_coverage\.py|scripts/test_coverage_baseline\.json|\.pre-commit-config\.yaml$)'
pass_filenames: false
```
**下一步(按性价比排序)**:
1. **扩大覆盖面**:按 **§9 的建桩优先级清单**吃 —— **清单已吃穿**:P1 13 个 `.c`(`files_tested` 19 → 32)
与 P2 5 个 `.c`(`files_tested` 32 → **37**,实测见 §9「P2 落地」)都已进分母,**剩下的 29 个 `.c`
全部是 P3「刻意不做」**、每条都能在 P3 表里找到归属(本节终点判据就此达成)。于是这一条**没有下一批可排**,
只剩两类事:① 把 §9 登记为本批「现状」的条目按用户裁定落成 fix(体量最大的一块是
`App/log/sys_log_mcu.c` 卷忙时整批丢弃日志);② 真想把百分比再往上推,就只能回头补**已进分母文件**的
未测分支(缺口最大的三块:`lftpd_io.c` 53.5%、`net_pending.c` 87.1%、`lftpd.c` 64.3%)——
按 §5 那仍是自分数、不是 KPI,性价比低于 ①。
2. **不做**:第二条覆盖率通道(gcc/WSL、`dotnet-coverage`、OpenCppCoverage 三条路都不走,用户裁定
2026-10-23)。要重开这个裁定,前提是能同时给出"两份分母怎么对齐"的答案,否则只是多一本对不上的账。
## 9. 未触及面怎么吃:建桩优先级清单(2026-10-23 画像)
**先把读数纠正一次**:所谓"125 个未触及"= **47 个 `.c`(9,657 有效代码行)+ 78 个 `.h`(2,182 行,多数只有声明与宏)**,
生成物 5 个已排除。**待办池不是 125,而是这 47 个 `.c`**——而且其中约一半在下面 P3 里被明确判为"不做"。
排序口径(把数据摊开,欢迎反驳):**价值 = 有效代码行 × 逻辑度 × 缺陷历史**(该文件的 fix/refactor 提交数),
**成本 = 要桩掉的外部头数 − 能复用的现有桩**。逻辑度 = `1 − 硬件符号密度`(`HAL_*(`、FatFs `f_*(`、
`CH395F_*`、SPI/I2C、OSAL 调用数 ÷ 行数),逐文件实测。
现有桩共 13 个头(`test/host/*`/stub/):`dbg_log.h`(×6)、`FreeRTOS.h`、`task.h`、`main.h`、`cmsis_os.h`、
`cmsis_os2.h`、`sys_clock.h`、`sys_events.h`、`iwdg.h`、`adc.h`、`tpafe5160.h`、`gd5f2gq5ue.h`、`net_socket.h`。
⚠ **没有 `ch395f.h`**——NET 域那批必须新建它。FatFs 侧不建假头,沿用"真实 `ff.h` + 测试单元内假 diskio"
(`test/host/nand_port/` 已趟通:接口契约原汁,桩与真实 API 一漂移就编译期报错)。
### P1|先吃:逻辑密、桩薄、有近期缺陷垫背(13 个 `.c` = 2,617 行)
| 批次 | 文件(有效代码行) | 逻辑度 | 要桩什么 | 判据出处(直接抄成用例) | 为什么值得 |
|---|---|---|---|---|---|
| **FTP 一套** | `lftpd.c`(694)、`lftpd_io.c`(207)、`lftpd_inet.c`(171)、`lftpd_string.c`(11) = 1,083 | 0.99/0.90/0.93 | 真实 `ff.h` + 假 diskio;复用 `net_socket.h` | `docs/测试说明/板级测试/FTP_Test_Guide.md`、`docs/子系统设计/网络配置说明.md` §3.4(`pwd_hash`=SHA-256(user‖password)、连续 5 次失败锁 60 s、默认口令启动告警) | **20 次提交里 6 次是 fix**,最近两条都是"静默型":LIST/NLST 满块被截断回 550、对端行为误打 `[ERR]`(2026-10-23)——覆盖率最擅长照的就是这类"日志看不出根因" |
| **NET 拆出来的四块** | `net_pending.c`(287)、`net_events.c`(263)、`net_listener.c`(191)、`net_select.c`(162) = 903 | 0.90/0.78/–/0.93 | **新建 `stub/ch395f.h`**(芯片寄存器只到函数边界)+ 假任务通知;`net_socket.h` 已有 | `docs/网络/网络自愈架构设计.md` §4.10(socket 读清点审计 + 新增一处读清的三条准入判据)、`docs/网络/Net层阻塞模型架构设计.md` | 挂起 recv、监听按代次重建、退避这些**语义在真机上极难复现**;这一族的 网络自愈("顺手全量清一遍把别的 socket 门焊死")与 N34 E14 都是 fix 出来的真事 |
| **配置与工具(最便宜,建议第一个落地)** | `net_cfg.c`(167)、`task_prof.c`(160)、`ftp_cfg.c`(59)、`prot_set_store.c`(43) = 429 | 1.00/0.97/–/– | 真实头即可(`cfg_file.h` 已被现有单测证明能在 PC 上编),最多补 `dbg_log.h` | `docs/子系统设计/网络配置说明.md`(工厂默认 192.168.31.100/24、MAC 全零=芯片内置、保存立即生效)、`docs/算法/采集与算法层设计说明.md` | 硬件符号 0~2 处,纯解析/编解码/格式化。**一天内能出第一份绿**,用来把"新增 `files_tested`"这条路先跑通 |
| **104 服务层** | `iec104_serv.c`(202) | 0.96 | 复用 `time_sync`/stub 的 `cmsis_os2.h` + 已有 `net_socket.h` | 与 `test/host/iec104/`(97 用例)同一套脚手架 | 同族的 `iec104_pts.c` 现在只有 **67.2%**(全仓最低之一);服务层进来才能把 YK/YX 编排的分支照全 |
**落地进度(2026-10-23,同日推进)**:
| P1 批次 | 状态 | 新增模块(断言数) | 进分母的实测覆盖 |
|---|---|---|---|
| 配置与工具 4 块 | ✅ 完成 | `test/host/{net_cfg,ftp_cfg,prot_set_store,task_prof}`(104 条) |
`net_cfg.c` 108/112、`task_prof.c` 88/93、`ftp_cfg.c` 35/36、`prot_set_store.c` 27/29 |
| FTP 一套(4 块全进分母) | ✅ 主体完成 | `test/host/lftpd_io`(33 条)、`test/host/lftpd_inet`(28 条)、
`test/host/lftpd`(35 条) | `lftpd_inet.c` **127/127 = 100%**、`lftpd_io.c` 85/159、
`lftpd.c` **304/473 = 64.3%**(控制通道闸门 + 17 条命令 + 被动模式 + 传输收尾都进来了;
还剩 `lftpd_start` 主循环与 `send_list`/`send_file` 的分批边界分支) |
| NET 拆出的四块 ①:`net_select.c` | ✅ 完成 | `test/host/net_select`(21 条) | `net_select.c` 97/114 = **85.1%** |
| NET 拆出的四块 ②:`net_listener.c` | ✅ 完成(**125/126 = 99.2%**) |
`test/host/net_listener`(34 条)+ 共享假芯片 `test/host/common/ch395f_fake.{h,c}` |
四道时间闸门全钉住(30ms 退避 / 400 与 100ms 两级沉降 / 800ms 上限只留痕 / 2000ms
预算交对账器),另有接收区 256B 钳位与 16 轮上限、"清接收→关断→读即清→读状态"的
调用顺序、五种静默放弃条件 |
| NET 拆出的四块 ③:`net_pending.c` | ✅ 完成(**169/194 = 87.1%**) | `test/host/net_pending`(43 条) |
跨上下文契约(**错误码走任务通知值**,靠可观测的 `xTaskNotify` 假件才测得到)、
收尾四条分支(不丢尾包 / 干净报 0 / 异常释放报 CONNRESET / 槽位没了报 CONNRESET)、
发送分块钳在 `NET_SEND_CHUNK_MAX=1024`(真值,非假件自造)、`deadline` 每轮续期
(长文件不被 1000ms 掐死)、停滞超时、取消与 `clear` 不发通知的纪律 |
| NET 拆出的四块 ④:`net_events.c` | ✅ 完成 | `test/host/net_events`(34 条) | 陈旧中断防误判 |
(芯片说还在连接就不许拆活会话)、疑似误报延后、超时标异常释放(正是 `net_pending`
收尾报 CONNRESET 的那条输入,**跨文件因果两头都能离线跑才测得到**)、丢中断自愈,
以及对账器的 100ms 周期闸门 / 稳定计数 4·2·1 三档 / 1000ms 冷却 / U11 重同步 |
| 104 服务层:`iec104_serv.c` | ✅ 完成(**117/139 = 84.2%**) | `test/host/iec104_serv`(28 条) |
服务生命周期:代次一变就重建监听、accept 门控与表项失效自愈、单连接断线只关数据口
(与监听同号时绝不关)、连续短写判死(成功即复位)、32 分频探活、协议掉回 IDLE 丢弃
重生、时钟桥接两个溢出边界(>4G 秒拒绝、未对时回哨兵) |
本批照出的两处**现状**(只登记、**未改语义**;全库唯一调用点 `test/board/net_test_task.c`
传的是 NULL 与非零 tv,今天没有受害者):
1. `net_select` 的 `timeout = {0, 0}` 换算出 `timeout_ms = 0`,而退出条件写的是
`if ((timeout_ms > 0) && (已用时 >= timeout_ms))` → **超时 0 永不触发退出**,退化成
无限等(POSIX 语义是立刻返回 0)。`net_poll_events` 的 `timeout >= 0` 写对了,
两条并排放着正好是对照。风险面:谁拿它当"非阻塞查一遍",netTask 就再也回不到
主循环,而 netTask 在 IWDG `required_mask` 里。
2. `exceptfds` 是**恒为空的死参数**:循环体开头 `p_sock == NULL → continue` 先跳过,
`check_socket_error` 的 NULL 分支从 select 里到不了。按 POSIX 直觉用它收异常的人
会永远什么都收不到。修法:continue 之前先判异常,或在 `net_select.h` 写明不支持异常集。
数字:套件 16 → **21**、`files_tested` 19 → **25**、未触及 125 → 119(`.c` 47 → **41**,
恒等式 `41 = 7(P1 剩) + 5(P2) + 29(P3)` 仍然咬合)、分母 2,893 → **3,332** 插桩行。
⚠ 总体 `line_rate` 88.2% → **87.2%**:**这正是 §5 说的摊薄,不是回退**——`lftpd_io.c` 只测了
一半就进分母,分母涨得比分子快;要盯的是 `files_tested`/`files_untested` 两个数。
**(同日已处置,见下方"三处死路径的处置")**原照出处:`lftpd_string_trim` 的尾部循环写成
`for (i = strlen(p); i >= 0 && isspace(p[i]); i--)`,i 起步那格是 `'\0'`、`isspace(0)` 为假 →
循环一次都不执行,**尾部空白/CRLF 从未被裁掉**。今天不发作是因为 CRLF 在
`lftpd_inet_read_line` 里已用 `strstr("\r\n")` 截断、唯一调用点 `lftpd.c` 只用返回指针去行首;
风险在"以后有人拿它去尾部空白"。修它一行即可,但会改变 FTP 参数(含空格的文件名/
口令)的可见行为,需与 FTP 域一起过 → 现在只用 TC-LFTSTR-005 把现状钉成"改了会响"。
**P1 收官(2026-10-23 同日,13 个文件全部进分母)**:新增 12 个主机单测模块
(`net_cfg` / `ftp_cfg` / `prot_set_store` / `task_prof` / `lftpd_io` /
`net_select` / `net_listener` / `net_pending` / `net_events` / `lftpd_inet` /
`lftpd` / `iec104_serv`),共 **308 条断言**;套件 16 → **28** 全绿,
`files_tested` 19 → **32**,未触及 `.c` 47 → **34**(恒等式 `34 = 0(P1) + 5(P2) + 29(P3)`),
分母 2,893 → **4,684** 插桩行,`line=4020/4684(85.8%)`、`func=300/333(90.1%)`。
百分比从 88.9% 一路"降"到 85.8% —— **这仍然是摊薄不是回退**:`lftpd_io.c`(53.5%) 与
`lftpd.c`(64.3%) 这类只测了主体的文件也进了分母;按 §5 的口径,这一路该盯的是
`files_tested`(19→32)与 `files_untested`(125→112),而不是 `line_rate`。
其中 `lftpd_inet.c` 达到 **100%**(127/127),`net_listener.c` 99.2%、
`net_events.c` 97.8%。
### 三处死路径的处置(2026-10-23,用户逐条裁定后实施)
三处都不是改动引入的回归,而是**从没被执行到的分支**:生产路径上要么没有调用者、
要么调用者没走边界,所以编译器(0 错 0 警)、`cppcheck`、真机日志对它都无话可说。
覆盖率把"进没进过"变成可见数字之后,我为了写用例逐行读了它们的判定条件才撞见。
| 缺陷 | 裁定 | 改法与影响 | 现在钉住它的用例 |
|---|---|---|---|
| `lftpd_string_trim` 尾部循环起点落在 NUL,`isspace(0)` 为假 → **尾部从不被裁**
(名字却承诺两头都去) | **选 (a) 改名** | 改名 `lftpd_string_ltrim` + 删掉那条死循环 +
在 `.h`/`.c` 写明"只去前导"。**行为零变化**(那条循环本来就不执行);不去尾才是对的:
RFC 959 以 CRLF 界定命令,参数(`PASS` 口令、含空格文件名)的尾随空格属于参数本体,
去掉等于静默改数据。`lftpd.c` 调用点同步改名 | TC-LFTSTR-001..005(005 现在是正向契约:
尾随空格必须保留) |
| `net_select` 的 `timeout = {0,0}` 换算出 `timeout_ms = 0`,而退出条件写着
`if ((timeout_ms > 0) && (已用时 >= timeout_ms)))` → **超时永不生效**,退化成死等
(POSIX 是"只查一轮立即返回") | **按推荐修** | 去掉 `timeout_ms > 0` 这个错误前置,
变成 `if (已用时 >= timeout_ms) break;`。就绪检查在它之前,故有就绪时仍立刻带计数返回;
同文件 `net_poll_events` 的 `timeout >= 0` 本来就是对的,现在两条对齐。风险面:
拿它当"非阻塞查一遍"会让 netTask 回不到主循环,而 netTask 在 IWDG `required_mask` 里 |
TC-NETSEL-008(已翻成正向断言:`rc==0` 且一轮都不睡) |
| `exceptfds` 是恒为空的死参数:循环开头 `p_sock == NULL → continue` 先跳过,而
`check_socket_error` 只有"表项为空/越界"才为真,越界的 `i` 又不在循环范围内 |
**选 ①**(按 POSIX 置位) | 把异常判定提到 `continue` **之前**:表项不存在的 fd 计入
`exceptfds` 并计入返回计数;同时更新函数注释。选 ① 会改变返回计数语义(缺失 fd 现在
算就绪),因此补一条"未请求异常集时不计数"的用例,确保只查可读的老调用方不被误伤 |
TC-NETSEL-012 / 012b / 012c(新增) |
**处置后的账**:`@build` 0 错误 0 警告,`Code` **−36 B**(删掉的死循环),
`RW-data`/`ZI-data` 不变 → **RAM 零变化**。`lftpd_string.c` 的已覆盖行 8 → 7
被门禁判了一次 `REGRESS`——那是台账在如实反映"代码少了一行",不是覆盖退化,
已按 `--update-baseline` 重登(这条恰好反证门禁有效:删代码想悄悄过去,它会拦)。
两处"现状桩"翻成正向契约,另新增 `TC-NETSEL-012c`;全量 22 套件仍然全绿。
### 假 CH395F 芯片:实施规格(2026-10-23 立项,交给下一会话执行)
**为什么要它**:`NET` 域剩下的三块(`net_pending.c` 287、`net_events.c` 263、`net_listener.c` 191 = 741 行)
全都把芯片当真理读,真机上这些语义只在总线上一闪而过、台架不可复现。做完它,P2 的
`net_socket.c`(1,251 行,全仓最大逻辑块)也一起解锁。
**真实原型(`Drivers/BSP/CH395F/ch395f.h` 逐条核出,假件必须一字不改地照抄签名)**:
```cpp
uint8_t ch395f_sock_state_cached(uint8_t sn); /* 返回 ch395f_sock_state_t 枚举值 */
void ch395f_get_socket_status(uint8_t sock, uint8_t *status); /* 出参形态 */
uint8_t ch395f_get_sock_int_status(uint8_t sock); /* ⚠ 整体读即清,见下 */
uint16_t ch395f_get_recv_len(uint8_t sock);
void ch395f_read_recv_buf(uint8_t sock, uint8_t *p_data, uint16_t len);
void ch395f_write_send_buf(uint8_t sock, uint8_t *p_data, uint16_t len);
uint8_t ch395f_open_socket(uint8_t sock);
uint8_t ch395f_close_socket(uint8_t sock);
uint8_t ch395f_tcp_listen(uint8_t sock);
void ch395f_set_proto_type(uint8_t sock, uint8_t proto);
void ch395f_set_sour_port(uint8_t sock, uint16_t port);
void ch395f_get_remot_ipp(uint8_t sock, uint8_t *p_buf); /* 出参形态 */
void ch395f_set_keepalive_enable(uint8_t sock, uint8_t enable);
uint8_t ch395f_link_up_cached(void);
uint16_t ch395f_link_change_count(void);
```
**假芯片必须复刻的两条芯片怪癖**(不复刻就等于把被测代码的坑假件掉):
1. `get_sock_int_status` 是**整体读即清**、芯片没有按位清除手段。假件要按"每次调用
返回该 socket 的挂起位图并**同时清空**"实现,并允许逐 socket 独立预置——否则
照不出 AGENTS「模块约定」里 网络自愈 那条实证坑:*"顺手全量清一遍"等于把别的在用
Socket 的门焊死*。
2. 状态是**缓存值**而非现场值(`sock_state_cached`/`link_up_cached`):假件要把
"缓存更新发生在哪一次调用之后"做成可脚本化,才能测出对账循环读到陈旧状态的路径。
**脚本化模型**(沿用本仓既有假件风格:`nand_port` 的真头+假 diskio、`lftpd_io` 的可注入假堆):
每个读函数一条**按调用序号取的返回队列**(耗尽后回落到最后一值),每个写函数一条
**调用记录**(次数 + 最近一次入参),另给 `g_chip_call_count[函数名]` 让"这一轮不该碰芯片"
的契约可断言;`reset` 一次全清。所有假件写在测试单元 `.c` 里,**不建共享 `.so`/库**。
**桩放哪里(一个需要拍板的点,先给推荐)**:三个模块都要用同一份假芯片,`test/host/*/stub/`
每模块一份会漂移(三处签名各改一半 = 假件与被测代码同时错还不报错)。推荐
`test/host/common/ch395f_fake.h` 与同名 `test/host/common/ch395f_fake.c`(与 `unit_test_harness.py` 同级,三模块的
`include_dirs` 各加这一条),代价是它不进 MCU 构建、要靠 `test-cppcheck` 覆盖。
若沿用"每模块自带桩"的现有惯例,则必须让三份桩都 `#include` 同一份头以锁死签名。
**三个文件的真实依赖面(逐个核出,别按猜的排工作量)**:
- `net_listener.c`(191 行)—— **最便宜,先做它**:`net_drain_chip_rx`/`net_relisten_locked`/
`net_relisten_done` 全是**本文件内的 static**(2026-10-23 实测确认),所以它只需要
芯片假件 + `net_get_sock` + `net_pending_active` + `HAL_GetTick` + `dbg_log`,
外加 `test_config.h`/`net_latency_test.h` 两个可空壳的头。**不需要假造 net_socket.c 内部函数**
(我上一轮把它当成前置成本,是错的,特此更正)。判据现成:单连接 socket 复用、
按网络代次重建监听、`net_listener_armed` 门控(`docs/网络/网络自愈架构设计.md` §4.10)。
- `net_pending.c`(287 行)—— 要 `net_listener_request`(可用一个只记录调用的假件),
契约是挂起 recv 的注册/超时/取消与"写失败回置"那族;`tick` 收 `net_sock_t *` 表参数
(`net_get_sock` 对未用槽位返 NULL,tick 需读 `in_use == 0` 兜底 —— AGENTS 已记)。
- `net_events.c`(263 行)—— 依赖最多(`net_sock_raw`/`net_listener_settle`/
`net_pending_cancel`/`net_send_pending_cancel`/`xTaskGetTickCount`),**放最后**。
注意 `net_sock_raw` 是刻意不给应用层用的现场指针(头注释写明"仅供 net_events.c 的
对账循环使用"),假件要保留"对未在用槽位也能拿到项"的行为,否则对账兜底路径测不到。
**验收顺序**:`net_listener` 绿 → 立刻登记基线(哪怕只有它一个)→ `net_pending` → `net_events`;
每步都要 /keil-build 0/0 + 全部套件绿 + --update-baseline + 补建 `TC-HOST-*` 族(本规格落地时族数
16 → 22、套件 16 → 28,实测见上方落地进度),并且**先补族再往文档里引这些号**,否则 `id-check` 会按台账外新账卡提交
(本会话已被 `TC-NETCFG-028` 实测拦过一次)。
### P2|值得但工程量大(5 个 `.c` = 2,226 行)
1. **`net_socket.c`(1,251 行 / 51 个函数,全仓最大的逻辑块)** —— 必须**分片**,别一次吞。
先测状态迁移与语义判据:EOF 时**不得** `net_close` 该 fd(会连监听一起毁掉)、`auto_relisten`、
`settle_close(sockfd, p_sock)` 双参显式传、挂起 recv 超时、`net_*` 错误码经**任务通知值**回传
(`net_msg_t` 是按值拷入队列的,改副本调用方看不到)。桩 = 新建假 CH395F。
该文件 80 次提交、15 次 fix/refactor,值;但**一次做完必翻车**(先求把状态机进分母,别求百分比)。
2. **`sys_log_mcu.c`(458) + `cfg_file_store_mcu.c`(129) + `soe_store.c`(123) = 710 行** —— 一套假 FatFs
吃三块,性价比最高的第二批。判据现成:`docs/子系统设计/运行与自检记录设计说明.md` §4.4(写侧批量合并 + **掉电语义不变** +
失败回投不丢条目 + 容量轮转只在慢路径)与 `docs/子系统设计/运行与自检记录设计说明.md` §4.5
(读侧 8 条窗口 + 写者代数失效 + API 不变)。
这块的历史教训就是"日志读写放大"与"`mkdir` 失败静默不再重试"。
3. **`console_log.c`(265)** —— 单写者契约 K1/K2 已经写成表,照抄成用例最省力:
`docs/子系统设计/控制台单写者日志设计说明.md`。
### P2 落地(2026-10-24,5 个文件全部进分母,套件 28 → 33 全绿)
三个模块码并行落地(`net_socket` 分片吞、存储三件套共用新建假 FatFs、`console_log` 独立),
**新增 5 套 659 条断言**:`net_socket` 420、`console_log` 104、`sys_log_mcu` 75、`cfg_file_store_mcu` 34、`soe_store` 26。
| 新纳入分母的 `.c` | 插桩行 / 函数 | 断言 | 套件目录 |
|---|---|---|---|
| `net_socket.c` | **881/883 = 99.8%**、52/52 | 420 | `test/host/net_socket/` |
| `sys_log_mcu.c` | **310/313 = 99.0%**、12/12 | 75 | `test/host/sys_log_mcu/` |
| `console_log.c` | **188/189 = 99.5%**、15/15 | 104 | `test/host/console_log/` |
| `cfg_file_store_mcu.c` | **97/97 = 100%**、10/10 | 34 | `test/host/cfg_file_store_mcu/` |
| `soe_store.c` | **78/79 = 98.7%**、4/4 | 26 | `test/host/soe_store/` |
| **P2 合计** | **1,554/1,561 = 99.6%**、93/93 | 659 | — |
**账**:套件 28 → **33** 全绿、`files_tested` 32 → **37**、未触及 **112 → 107**(`.c` **34 → 29**、`.h` 仍 78)、
分母 4,684 → **6,245** 插桩行,`line=4020/4684(85.8%)` → **`5574/6245(89.3%)`**、
`func=300/333(90.1%)` → **`393/426(92.2%)`**。恒等式跟着更新且仍然咬合:`29 = 0(P1 剩) + 0(P2) + 29(P3)`,
并且**本节终点判据(下面「顺序建议与记账」第 3 条)就此达成**:剩下的 29 条 `.c` 逐条都能在 P3 那张表里找到归属
(芯片驱动 5 + UI 17 + 任务编排 6 + 内核前装配 1 = 29),没有一条是"既没进分母、又说不清为什么不测"的真窟窿。
⚠ **这批罕见地没有摊薄、反而 +3.5pp** —— 不是因为更勤奋,而是 5 个文件**全都以 ≥98.7% 进分母**
(P1 那批是 `lftpd_io.c` 53.5%、`lftpd.c` 64.3% 这种"半份进分母")。**别把它读成"百分比才是 KPI"**:
按 §5 口径这批该盯的仍是 `files_tested` 32→37 与未触及 `.c` 34→29 这两个数,89.3% 只是自分数。
**两套分母别再混用**(写给下次估工作量的人):本节清单里的"有效代码行"是**去注释/去空行**的静态计数,
插桩分母只数 mscoverage 认为可执行的行——`net_socket.c` 清单 1,251 / 插桩 **883**、`sys_log_mcu.c` 458 / **313**、
`console_log.c` 265 / **189**、`cfg_file_store_mcu.c` 129 / **97**、`soe_store.c` 123 / **79**。
按 1,251 估进度会把 99.8% 读成 70.4%。另:`for (;;)` 的收尾大括号会被算进可执行行
(`soe_store.c:202` 就是这种非真语句),别当成漏测去"补"。
**画像期两处口径被落地推翻**(就地更正,不回改上文原话):① "⚠ **没有 `ch395f.h`**——NET 域那批必须新建它"
已由 P1 建好(`test/host/net_listener/stub/ch395f.h` 转发到共享假芯片);② "FatFs 侧**不建假头**,沿用真实
`ff.h` + 测试单元内假 diskio"——P2 走的是**另一条并存的路**:`test/host/common/fatfs_fake.{h,c}` 以真实
`Lib/FatFs/ff.h` 为**声明源**、在 `fatfs_fake.c` 里**定义** `f_*`(签名一漂移就编译期报错),
提供内存卷 + 可注入返回码/短写/`FR_DENIED`。两条路的共同点是**都不自造假头**。假件**刻意只提供三个生产文件
实际调用的那 9 个 `f_*`**(`f_close f_lseek f_mkdir f_open f_read f_rename f_stat f_unlink f_write`),
不给 `f_mount/f_mkfs/f_opendir/f_readdir/f_sync` —— 于是"自愈逻辑把名字/参数类错误码升级成卷操作"这类违规
会在**链接期**就响,而不是靠评审眼神(`docs/架构设计/STM32F4-Base存储架构说明.md` §10.1 陷阱二的机器化兜底)。
**编号**:`TC-HOST-*` 族 22 → **27**(本批补 `NETSOCK`/`SYSLOG`/`CFGFST`/`SOESTORE`/`CONLOG`,
游标分别 `TC-NETSOCK-545`/`TC-SYSLOG-100`/`TC-CFGFST-053`/`TC-SOESTORE-024`/`TC-CONLOG-135`,
属主登记处 `test/host/<目录>/*`,`cursor_owner_only`/`allow_orphans`/`subitem_ok` 照 B 层口径)。
**先补族再引号**这条链本批又走了一遍,而且踩在收口前:`net_socket` 起初自取两字母码 `TC-NS-*`(465 处),
那会整批落进通用 `TC` 族(属主 `docs/测试说明/板级测试/*_Test_Guide.md`、**无** `allow_orphans`)
= 458 条台账外新账直接拦提交;正名为 `TC-NETSOCK-` 是 bytes 级替换(465 处 / 0 残留 / 兄弟目录 `git diff` 全空 /
复跑 420 全绿)。**写死成纪律**:队友起手就按 Lead 给的模块码写号,两字母不可检索的码(`NS`)不要取。
本批**未**给新套件单挂 `*-unit-test` 钩子(与 P1/P2 那 17 套同口径):聚合闸 `test-coverage` 的触发面
`^(App/|Drivers/BSP/|test/host/|…)` 已自动发现新模块并按基线守回退。
**假件常量必须与真头逐字对齐(本批最值钱的机制结论)**:共享假芯片里 `CH395F_SOCKET_OPEN` 曾是 `0x01U`
(真头 `Drivers/BSP/CH395F/ch395f.h`:220 = **`0x05U`**、`docs/芯片手册/CH395F.md`:527)、
`CH395F_TCP_ESTABLISHED` 曾是 `0x05U`(真头:229 = **`0x04U`**、手册:539)。
**三套存量单测一条都没红**——host 编译里生产码看到的也是假宏,宏对宏自洽 → 错在"值层",
表现正是"把断言写在假件规则上而门禁毫无反应"。修法不是改断言而是**加机械闸**:
`test/host/net_socket/run_unit_test.py` 在编译之前做真头/假件同名 `CH395F_*` 常量的文本核对
(真头 106 项 / 假件 32 项 / 同名 22 项,只比纯字面量取值),并做过负例验证:把值改回 `0x01U` →
输出 `[DRIFT] CH395F_SOCKET_OPEN 真=0x05 假=0x01` 且 rc=1 拒绝编译。跨头 `_Static_assert` 做不到,
文本核对是目前唯一能常驻的拦法。**推论**:任何"照抄真头常量"的共享假件都该配这样一根闸。
**两条 FatFs 实证陷阱都做了正反两面**:`FA_OPEN_APPEND` 不含 `FA_WRITE`(缺写位 → `f_write` 恒
`FR_DENIED` 的对照保真断言 3 处)与"`FR_OK` + 短写"判失败必须校验写回字节数;短名违规**分档**
(体 > 8 / 扩展 > 3 / 非法字符 / 以点开头 → `FR_INVALID_NAME`;含 `:` → `FR_INVALID_DRIVE`),
`FF_USE_LFN=0` 下 `.fschk` 走的正是"扩展域 5 字符超限"这条真实路径;自愈只动自己的文件
(反复失败 20 轮后 `/log` 目录与邻居文件未被触碰)。
**本批照出的 11 条现状(缺陷原文保留在此,处置结论见紧随其后的「第 1 轮修复处置」)**:
| # | 位置 | 现象与影响 | 钉住它的用例 |
|---|---|---|---|
| 1 | `App/log/sys_log_mcu.c:509~537` | **疑似数据丢失(建议按 P1 定级)**:批量路径 `done = n` 只有 `rc==-3`/`rc==-1` 两个分支会改它,**`rc==-2`(卷忙)没有分支** → 紧随其后的 `for (i = done; i < n; …)` 回投循环一次都不执行 → 最多 8 条日志既不落盘也不回投、不计故障、不打日志。同段 `:531` 注释与 `docs/子系统设计/运行与自检记录设计说明.md` §4.4 都写"条目不丢(旧实现 -2 丢那一条)"→ **现状比旧实现更糟**(旧丢 1 条、现一次丢 8 条)。对照:**逐条慢路径遇 `-2` 会正确回投尾部**,同一错误码在两条路径行为相反。触发面常见:卷锁 v1.2 之后 ftpTask 传文件期间 defaultTask 每 2 s 排空一次 | TC-SYSLOG-035 / 035b / 035c |
| 2 | `App/log/sys_log_mcu.c:527~529` | `rc==-1` 打 `DBG_ERROR("drop %lu entries …")`,但该分支 `done=0` → 整批其实**全部回投**、一条没丢 → 文案与行为相反,且持续写故障时每轮 drain 都重投重试一遍 | TC-SYSLOG-036 |
| 3 | `App/log/sys_log_mcu.c:616` vs `627~628` | 守卫写成 `desc_len <= SYSLOG_DESC_MAX` 就 `-1`,而 `dl` 恒 ≤ 28 → 截断分支**永不可达**。无害(宁可拒绝不越界),但"传小缓冲能拿到截断内容"的直觉是错的 | TC-SYSLOG-098 / 057 |
| 4 | `Drivers/BSP/NET/net_socket.c:2825` / `:2869` / `:3080` | **同一文件两套 IP 字节序约定,`inet_addr` 与 `ntoa`/`getsockname` 不互逆**:`net_inet_addr` 小端组装后又 `NET_HTONL` → `0xC0A80164`;`net_inet_ntoa` 交换后高段在前 → 只能正确还原 `getsockname` 那族(`0x6401A8C0`);`net_connect_locked:1571` 用 `>>24` 取首段 → 只配 `inet_addr` 那族。**为什么长期不被看见**:`ntoa` 自己 `:2865~2867` 那段 E13 修复注释里的例子「192.168.1.100 → 0xC0A80164 → "192.168.1.100"」取的是**另一族**的值(交换宏下实算 `"100.1.168.192"`)。前提已核清:`Drivers/BSP/NET/net_types.h`:56 是 `#ifdef __BIG_ENDIAN` 才恒等,STM32 与 x86/MSVC 都走交换分支 → **不存在"主机语义≠目标语义"**。当前无调用者跨族(`lftpd.c`:864 走 `getpeername→ntoa` 同族恰好正确),属埋在库 API 面上的地雷;统一必须跨 `inet_addr`/`ntoa`/`getsockname`/`connect` 解析/`lftpd.c`:548 一起过 | TC-NETSOCK-100b / 100c / 100d、107f、430h(钉的是**具体数值**,统一时会五条一起红并互相指认,不会被误读成单点回归) |
| 5 | `Drivers/BSP/NET/net_socket.c:2408` / `:2537` | **UDP 两个公开 API 绕过 netTask 串行化**:`net_sendto`/`net_recvfrom` 直接 `net_poll()`(`:2482`/`:2501`/`:2563`)+ `ch395f_write_send_buf`(`:2487`)/`ch395f_read_recv_buf`(`:2575`/`:2604`),**全程不经 `netMsgQueue`** → 与 AGENTS「所有 `net_*` 经 netTask 串行执行」及 `docs/问题记录/CH395F_Trap_Records.md` TRAP-NET-14 正面冲突。全仓无生产调用者(只有 `test/board` 与模拟器)所以未爆。建议:改走消息队列,或在 `net_socket.h` 显式标注"未串行化,仅限 netTask"。**处置(2026-11-03):已按前者落地**——UDP 全面走 `netMsgQueue`,出参经 per-fd 槽回传,见本文「第 4 轮:UDP 数据面队列化(2026-11-03)」 | TC-NETSOCK-440 |
| 6 | `App/log/soe_store.c` 函数头 ④ | 注释写"打开/写入结构性错误 → `unlink` 重试一次(同 run.log)",实现里 `f_open` 失败**直接 return -1、不重试**(只有 `f_write` 失败才重试);且 `run.log` 侧 `s_append_rec` 的重试码集合还含 `FR_INVALID_NAME`/`FR_NO_PATH` → 两套自愈集合不一致。属"注释夸大实现"还是"实现少一半"待裁 | TC-SOESTORE-022 |
| 7 | `App/cfg/cfg_file_store_mcu.c:50` | 函数头写"未挂载时 `f_open` 返回 `FR_NO_SYSTEM`"——**`FRESULT` 里没有这个码**(`Lib/FatFs/ff.h` 共 20 个码,未挂载实际是 `FR_NOT_READY`/`FR_NO_FILESYSTEM`)。只在注释里不参与编译,危害是后人照它写判据会写出永不成立的分支 | —(注释面) |
| 8 | `App/log/console_log.c:317` vs `:285` | 冷却起点用 `s_con_cool_until = (uint32_t)HAL_GetTick()`,而判据拿 `!= 0U` 当哨兵 → 故障恰好落在 `HAL_GetTick()==0` 那一拍(开机头 1 ms,或 49.7 天回绕到 0)时**冷却等于没记**,下一轮 drain 立刻再耗一次 100 ms 块超时。撞的正是控制台域那条已登记的"背靠背块超时拖长主循环 → 喂狗节奏饥饿"。建议:另用一个"在冷却"标志,别拿 tick 值当哨兵 | TC-CONLOG-081 / 082(按现状钉) |
| 9 | `App/log/console_log.c:394~412` | `con_append` 的**空位判据在 `taskENTER_CRITICAL` 之外**(check-then-act)。生产者按 `docs/子系统设计/控制台单写者日志设计说明.md` §3.1 是"全部任务 + 内核前主线程 + ISR",而同文档 §4.1 的 head/tail 行写作"单生产者/单写者"——**两处口径互斥,后者需要更正**。多生产者可在环 >95% 满时同时过判据 → `head` 各自推进后合计越过容量 → `con_used()` 回绕变小 → 写者少发一段,被覆盖的行**既不打 `[ERR]` 也不进 `dropped`**(静默损坏;索引恒取模,不会越界)。**这条没有用例、只报推断**:单线程宿主构造不出真抢占点(把注入点挂在 `taskENTER_CRITICAL` 上已在区内,不算抢占点) | —(无用例,仅登记) |
| 10 | `App/log/console_log.c:181~183` + `:306~331` | `huart1.Instance == NULL`(HAL 早期 / 断言-故障上报路径的专用短路,返回 `HAL_ERROR`)在 drain 里被当成**僵尸通道**走 `con_uart_reinit()` → `MX_USART1_UART_Init()`(含 MspInit 的 RCC/GPIO 重配)。当前接线顺序(`MX_USART1_UART_Init` 早于 `dbg_console_writer_attach`)挡住了,但"未初始化"与"通道卡死"共用一条自愈支路,attach 时机一旦被提前就会在 HAL 未就绪时重配外设 | TC-CONLOG-103(钉现状) |
### 第 1 轮修复处置(2026-10-24,同日;生产码改动 + 用例翻正向)
用户裁定「全部修」。三名队友按域并行改生产码(写范围互斥、零越界),Lead 统一 `@build` 与跑闸。
**`@build` 0 错误 0 警告**(本工作树首次全量构建):`Code=213720 RO-data=18860 RW-data=1204 ZI-data=131500`。
全量闸:**33/33 套件全绿**、`line=5605/6275(89.3%)`、`func=396/429(92.3%)`、未触及仍 29 个 `.c`;
与修复前逐文件比对**下降文件数 = 0**(改过生产码的三个文件覆盖随代码同步增长:
`console_log.c` 188→199、`sys_log_mcu.c` 310→324、`net_socket.c` 881→887)。
| 原表条目 | 处置 | 钉住它的正向断言 |
|---|---|---|
| 1 卷忙整批丢日志 | **已修**:`syslog_drain` 批量路径补 `rc == -2` 分支把 `done` 置 0 → 整批回投队列;三条纪律逐条有断言(不计故障 / 不打日志 / 保序),慢路径(参照物)一字未动 | TC-SYSLOG-035、035a、035c、035d、104 |
| 2 文案与行为相反 | **已修**:文案改 `write fail, requeue %lu entries`,并加「正常→故障」节流(`s_requeue_reported[]`,落盘成功一轮复位);真丢仍由 `hold-over drop` 兜底 | TC-SYSLOG-039、039b |
| 3 截断死分支 | **已修**:删掉永不可达的截断支,容量契约写死在 `sys_log.h`(`ts_len > SYSLOG_TS_LEN`、`desc_len > SYSLOG_DESC_MAX`,不足**一律 -1 且不写调用方一个字节**);API 签名与返回码集合不变 | TC-SYSLOG-057、057b(0x7A 哨兵逐字节回比)、098 |
| 6 soe_store 注释夸大实现 | **已修(改注释,实现不动)**:打开失败**任何错误码都不 unlink** 是有意的——soe.log 是事件序列,「打开失败就删文件」等于销毁事件证据;两侧资料块各写一份理由并**互指对方文件与测试号**,要求同步修改 | TC-SOESTORE-022 |
| 7 FR_NO_SYSTEM 不存在 | **已修**:改成实际会返回的 `FR_NOT_READY(3)` / `FR_NO_FILESYSTEM(13)`,并写明「本层对任何非 FR_OK 一律转 -1,别去枚举错误码集合」,唯一例外指向自愈触发集 | 注释面(无行为改动) |
| 4 IP 两套字节序 | **已修(统一到 POSIX 网络序)**:改 `net_inet_addr`(`&s_addr[0] == 第一段`,小端上 192.168.1.100 = `0x6401A8C0`)+ `net_connect_locked`/`net_sendto` 的 IP 数组改按内存字节顺位拆装(**进与出同时换族 → 芯片侧数组净效果不变**,由 550h 直接钉住)+ 模拟器 `net_socket_sim.c` 逐字同语义;`net_inet_ntoa` **函数体一字未动**,只把它注释里那个取了废弃族值的例子改对(就是那条隐蔽原因)。lftpd 两处经算术核对**确认无需改**(PASV 227 的双重反转仍成立),只加「勿顺手改对」的注释 | TC-NETSOCK-100 组(新增往返不变量 100g/100h、边界 100i/100j、截断 100k)、550h |
| 5 UDP 绕过 netTask | **2026-10-24 那轮只关掉了危险面,串行化改造本身已于 2026-11-03 落地**:两个 UDP 入口不再"非 netTask 就拒",而是与 TCP 同构走 `netMsgQueue`(执行体拆到新文件 `Drivers/BSP/NET/net_udp.c`);`NET_ERR_NOT_IN_NETTASK = -12` 保留为**历史码**——全仓已无运行时返回点,只剩注释说明它为什么不再可能发生。**顺带修掉一条当年看不见的问题**:`net_sendto` 为发一个包把 DesIP 从 `0xFFFFFFFF` 改成具体 IP,芯片就此静默切到 UDP Client 形态(收包**不再带 8 字节信息头**),而 `net_recvfrom` 仍按服务器形态无条件剥 8 字节 → 请求-响应式协议(SNTP 48 字节应答)会**丢掉载荷前 8 字节**并交出垃圾源地址。现已按形态分路。当年守卫顺带关掉的"自旋期间 netTask 把同一 sockfd 分给新会话 → 旧自旋者消费新会话数据报",现由"只有 netTask 读芯片 + 每 fd 一个排他接收槽"结构性排除 | `ns_s7_udp.c` UDP-1~14 组 + 改写后的 TC-NETSOCK-440 / 555 / 560 组("非 netTask 调用零芯片事务"仍逐条钉住) |
| 8 冷却哨兵与 tick 撞值 | **已修**:新增独立 `s_con_cooling` 判定「是否在冷却」,`s_con_cool_until` 只存截止时刻(无符号差跨回绕的写法保留) | TC-CONLOG-080~084、107、108 |
| 9 环空位判据在临界区外 | **已修**:「复核空位 + 整行拷贝 + 推进 head」收进同一个临界区(最长 192 B、168 MHz 下亚微秒;`vTaskDelay` 与 DWT/tick 判据一律留区外,区内绝不睡眠)→ 多生产者结构性安全,写者仍唯一。**原「这条主机测不出来」的结论本轮作废**:桩里记临界区嵌套深度并把第二生产者精确塞进第一个生产者的临界区,再把环形缓冲的宏名成函数调用使每一次碰环都可断言 | TC-CONLOG-160~167(**167 是正控**,证明 160~166 不是空转) |
| 10 未初始化被误当僵尸通道 | **已修**:drain 的失败处理先分诊——`Instance == NULL` 视为通道不可用(不 reinit、不计 tx_fault、不进冷却、只记 dropped);只有确认超时/僵尸通道才走软复位自愈 + 冷却 | TC-CONLOG-103~106 |
| (撤销)非阻塞 recv 看不见 EOF | **误报,未改任何码**:`net_recv_locked:1885` 的非阻塞分支里 `CLOSED || session_dead` 已判 EOF(0),只有会话存活才 WOULDBLOCK。顺带核实 `net_recvfrom` **不存在** EOF 误判面(UDP 无连接,无数据只有 WOULDBLOCK 或继续自旋两条出口;2026-11-03 队列化后"继续自旋"改为"登记挂起等 tick 结算",本条结论不变) | —(记一次「描述得很合理」的误报:修之前先读码,误报比漏报更贵) |
**修复过程中顺带照出的两条(原表没有,一并修掉)**:
- `syslog_post` 的 `queue full, drop` / `preboot full, drop` **无节流**——1 号修好之后「整批回投 → 队列常驻满」成为必然场景,不修等于**用一个新刷屏换掉一个静默丢**。已按同一「正常→故障」纪律加两个独立抑制位(**丢弃语义一字未改**,满则丢照旧)。TC-SYSLOG-041b/041d/041e/041f、105、106、107、107b、107c。
- 自愈触发集里混着 `FR_INVALID_NAME` / `FR_NO_PATH` / `FR_NO_FILE` / `FR_INVALID_OBJECT`——**比原报告的 6 号严重**:这类码下 unlink 自己的文件必然同样失败(同名同因),后果是**每轮排空删一次自己的历史日志且永远修不好**,正是 `docs/架构设计/STM32F4-Base存储架构说明.md` §10.1 第 10 条严禁的归集。已新增代码侧唯一入口 `s_is_selfheal_code()`:触发集收敛为 `{FR_DISK_ERR, FR_INT_ERR, FR_NO_FILESYSTEM, FR_DENIED}`,逐码依据(含 `Lib/FatFs/ff.c` 行号)与显式排除清单写在资料块里;`soe_store.c` 侧同源但**取更保守子集**。TC-SYSLOG-101/101b/102/103/103b、TC-SOESTORE-022b。
**本轮刻意没做(后续项,含理由)**:① UDP 改走 `netMsgQueue`(当年列的前置约束——`net_msg_t` 按值拷入队列、错误码只搭任务通知值回传、AGENTS 明文禁止给 `net_msg_t` 加回传字段,**至今成立**)→ **已于 2026-11-03 落地**:出参槽放进新模块 `net_udp.c` 内部,`net_msg_t` 一字未加;**真机回归仍待做**,见本文「第 4 轮:UDP 数据面队列化(2026-11-03)」的台架清单;
② `simulator/sim/sim_syslog.c:171` 那条与 3 号**同源的不可达截断支**(本轮编译验证不到它——模拟器走 CMake+VS,不在 `/keil-build` 覆盖内,不交没编译过的改动);③ `syslog_post` 的**丢弃计数**(属新增对外可见信息,本轮没有实测频率支撑阈值);
④ `dbg_console_tx_timeout()` 的**读端接线**(现无生产调用者;候选位置 `task_watchdog.c` 的 `STALL snap=… req=… h=… mg=… cf=… seg=…` 是**被解析的证据行**,加字段要连解析端与台架判据一起改,本批不碰台架);⑤ 给 104 加来源地址日志(现网 `iec104_serv.c` 根本不打印来源 IP、无 `getpeername/ntoa`,**没有输出点可看**)。
**必须上台架复验(本轮按指示未碰台架;主机测覆盖不到的部分逐条列出)**:
1. TCP 出向连接(唯一真正穿过被改的 `des_ip` 拆装路径):板级 `net_test_task` Phase 7(TC-NET-701/703/704,PC 侧起 server),判据 connect 能建立、PC 侧看到的源端口正确;Phase 2 的 `test_parse_ip` 期望 `[192,168,1,2]` 不变。
2. FTP 被动模式取一个文件并抓 227 响应:括号内前 4 元组 == 设备实际 IP、后 2 元组解出的端口 == 实际拨的端口(验 lftpd 那处双重反转没被改坏)。
3. FTP 对端日志 `connection received from <ip>:<port>`:ip 必须是 PC 地址且正序(验 `getpeername` → `ntoa` 同族)。
4. UI 网络配置页改 IP 保存 → 回读一致、按新 IP 通、104/FTP 仍可连(代次重建链路;UI 显示取自配置字符串、不经 `s_addr`,列进来是兜住「配置字符串→芯片下发→回读」这条主机测不到的路)。
5. FTP 传输期间持续投日志:传输结束后 `run.log`/`selftest.log` 条数 == 投递条数、顺序不乱、全程无 `write fail, requeue`;同窗口 `queue full, drop` 只应出现**一行**。
6. 制造持续写故障(坏块/写保护):串口只在**转入故障那一刻**出一条,恢复后再故障要再出一条。
7. 控制台灌高频日志把环逼到将满:不再出现半行/串行,且 `[ERR] CONSOLE dropped N lines` 与实际缺失数一致(**单位口径已变**:N 是「行或块」的计数,不恒等于行数);拔/短接 TX 造一次僵尸通道,核对 `dropped` / `tx_fault` / `tx_timeout` 各 +1。
**第 1 轮新踩到的坑(都有复现路径)**:
1. **`edit` 替换块会吞掉相邻代码**:在 `net_sendto` 插守卫时 `new_string` 漏写了紧随其后的 `buf == NULL → INVAL` 检查,那块被整段删掉,现场只表现为「sendto 空指针不报 INVAL」——**是上一节刚落的存量断言 440d 当场抓到的**。教训:`old_string` 覆盖多个语义块时 `new_string` 必须逐块回读,别信「我只加了一段」。
2. **`read` 全文回写会截断大文件**:`console_log_test.c` 到约 54 KB 后用「read 全文 → 替换 → write」连续两次把尾部(含 `main()`)吃掉,报 `C1075` 才发现。**超过约 40 KB 的文件只能定点改**(`edit` 或直接 fs 读写),不要整文件回写。
3. **块注释里替换把结尾的注释闭合符提前引出**:给 `console_log.h` 的 API 清单加说明时,替换文本自带了注释闭合符 → 注释中途关闭、后面中文裸露成代码,报 `C3872` / `C4138` 且**错误落在远处中文字符上**,第一眼完全看不出根因。改注释类替换宁可让 old/new 都不含行尾的注释闭合符。
4. **假时钟不要只挂在单一计数点**:假内核的时基挂在 `net_poll` 必读的 GPIO 上,而 `net_init` 未跑时 `net_poll` 直接短路 → tick 永不前进 → 停滞超时永不命中,**连 40 万次跑飞保险也挂在同一计数点、同样失效**(进程真挂死)。凡「可能短路返回」的路径都要能推进时间或独立触发保险;那条用例最后改用 `recvfrom(DONTWAIT)` 测同一分支。
5. **被 kill 的测试进程会占住 `tempfile\u003csuite\u003e.exe`**:下一个套件表现为 `LNK1169` / `LNK1104 无法打开文件`,看着像构建配置坏了,其实先 `taskkill` 那个 exe——这是「逐套 runner 固定共享路径」的又一处连带代价(与上面第 12 条同源)。
6. **cppcheck 会把「仅置位时才清」的抑制位写法判成 `duplicateConditionalAssign`(style,拦提交闸)**:为省一次 volatile 写而写成「判非零再清零」会被判等价赋值。处置:**回退成无条件写 + 注释写明别再「优化」回去**,不要为一条 style 告警加 inline 抑制。
7. **新增断言要用「逐条退回生产码」验一次牙**(变异自证):把四条修复逐条退回旧实现,失败集合单调增长 6 → 11 → 18 → 22 条、每步都是上一步的超集,改回即 127/127 全绿——**没有这一步,一条空转断言就要被交上去**。
8. **任务书里给的临时缺陷标签会被编号门禁当自造编号拦**——本批 Lead 在任务书里用的两个「单字母+连字符+序号」临时标签被原样抄进生产注释,一次冒出 8 类台账外错误(连本文档举这个反例时把字面量写出来,都会被同一根闸再拦一次,故此处只能描述形态、不能给样例)。纪律:**Lead 给的内部标签不得写进代码**,注释只写行为(如「两套 IP 字节序统一为 POSIX 网络序」),要指用例就指已登记的 `TC-*` 号。
### 第 2 轮:测试字面量统一纯 ASCII(2026-10-24,单独一次提交)
用户裁定「统一」。口径来自 AGENTS 铁律「**字符串字面量必须纯 ASCII**」(无 BOM 文件里奇数个汉字会吞掉结束引号,
偶数才侥幸通过)——**中文只写注释**。四名工人按目录分四组并行、写范围互斥,25 套 / 30 个 `.c` 全部改完。
**两套口径必须分清(本轮最大的方法论收获)**:
| 口径 | 做法 | 结果 | 用途 |
|---|---|---|---|
| 粗口径(普查) | 按行切引号,跳过以 `*` / `//` / `/*` 开头的行 | 34 文件 / **1,552** 行 | 判断"谁还没动" |
| 严格口径(交付判据) | 状态机把块/行注释替换成**等宽空格**(保住行列)后再取字符串字面量判 `ord>127` | 30 文件 / **1,535** 行 | 交付与验收 |
差值 = **4 个文件的命中全在注释里**(`net_socket/ns_peers.c`、`net_socket/stub/ns_rtos.h`、
`common/ch395f_fake.h`、`common/fatfs_fake.c`:行尾或块注释续行里的中文 + ASCII 引号)——**一处都不该改**,
改了等于把中文从注释里搬走、正好违背口径。教训写死成纪律:**粗口径绝不能当交付判据**,否则要么白改注释、
要么留下"这行为什么没改"的悬案(本批两名工人各自独立撞见并如实上报,没有一人硬凑数字)。
**Lead 独立终检(不采信自报)五条硬判据 → 30 文件漂移 0**:
1. 每个文件的 **TC 号集合**与 HEAD 逐字相同(防"改标签顺手改号"与"批量替换串位");
2. **断言宏调用点数**(`CHECK`/`ASSERT` 等)与 HEAD 相同;
3. **行数与 HEAD 完全相等**——这条专抓 `read` 全文回写把尾部(含 `main()`)吃掉的截断,第 1 轮真发生过两次;
4. **剥注释后代码区非 ASCII 字符 = 0**(含字符字面量),残留必须归零;
5. 字节卫生:BOM 与 HEAD 一致、无 CR、无 U+FFFD、末尾恰好一个换行。
另用一条更硬的机械证明替代"眼看 diff":**逐行把两侧字面量整体替换成空串后,工作版与 HEAD 必须逐行相等**
(`only string literals changed`)——任何落到引号外的差异当场失败。
**"没手滑动逻辑"的证据 = 覆盖率复量逐字不变**:各套 `--module` 与基线一致(`net_socket` 887/889·53/53、
`console_log` 199/200·16/16、`sys_log_mcu` 324/326·13/13、`cfg_file_store_mcu` 97/97·10/10、
`net_listener` 125/126、`lftpd` 304/473、`iec104_link` 367/405 …);每套 runner 的断言数改前后相同
(如 `iec104` 97/97、`sys_clock` 100/100、`adc_input` 103/103、`net_cfg` 45/45)。**基线本轮不需要重登**(生产码零改动)。
⚠ **`/keil-build` 对本轮没有覆盖力**:`test/host/**` 不进 Keil 编译面也不进模拟器 CMake,字面量改动只能靠
16 个 `*-unit-test` 钩子 + `test-cppcheck` + `test-coverage` 这三根闸验证——**别把"编译过了"当测试面改动的证据**。
**本轮新手法(值得抄进任何"批量改标签"作业)**:
1. **TC 号逐字相等守卫**:从文件字节取原文(不手敲),提取原文与译文各自的 TC 号要求**逐字相等**,不等即拒绝写入。
它拦下的是「测试标签与被测事实错位」——断言照样绿,却永久指向错误的号位(本轮真的拦下一次 092 被写成 093)。
2. **批量替换的键必须是内容,不能是顺序/位置**:按位置对齐 HEAD 与工作版会在字面量数量不等时**串位污染**
(本轮真实发生过:`net_inet_addr("255.255.255.255")` 被换成一条断言名)。正确做法是 HEAD 字节 + 按字面量文本精确匹配,
**凡未命中映射且含非 ASCII 的字面量直接报错终止**,其余字节一字节不动。
3. **自查至少要有一维能发现「自己多改了东西」**:残留计数 + 改动形状(不含引号的改动行 = 0)+ 行数与 HEAD 对比,三样缺一就漏。
本轮暴露事故的正是"残留本该 0 却报 11"——只看 rc 与断言条数会一路放行到提交。
4. **新串必须「不引入任何编号形态」**(本批真实翻车点):一句 `CHECK` 的英文标签里被写回了一条**已登记族之外**的「单字母+连字符+序号」编号,
而 HEAD 那行早就在上一轮整改中把它换成了平实描述——**翻译等于把债重新引进来**。批量翻译的守卫除「原文与译文 TC 号逐字相等」之外,
还必须断言「新串不得出现任何 已登记前缀族之外 的编号形态」(字母+连字符+数字一律拦)。
⚠ 同时暴露一条验收口径的教训:Lead 的自动验收器只比对了 `TC-*` 形的 id 集合,**没覆盖其它已登记族**(L 系 / E 系 / N 系 / S 系 / TRAP 系编号),
这一条是**编号门禁闸**拦下来的,不是我的验收器——「结构不变式」只覆盖自己想到的维度时,绝不能替代真闸。
⚠ 写这条手法时,Lead 又把该编号的字面量当样例写进本文档、被同一根闸再拦一次(第 1 轮举临时标签反例时已中过一次)。**结论:门禁不看语境,文档举例只能描述形态、不能贴字面量。**
5. **替换说明符不变量(比「禁止裸 `%`」更强)**:新旧字面量的**转换说明符序列必须逐位相同**(个数/顺序/宽度一致)。
「禁止新增 `%`」只挡一半——丢了 `%` 是 UB(实参没被消费)、多了也是 UB(多消费栈垃圾),而逼人把 `%` 写成 `%%`
等于把说明符偷偷变成字面文本,编译器最多给个 C4473 就放过。另注:断言名是当**实参**打印的,本身不会被二次解释,
真正要防的是「标签被当格式串」的实现——批量改标签前顺手扫一眼宏定义。
6. **生成脚本的脚本,转义层数会翻倍**:经模板字符串生成 Python 源码时正则里的 `\\s` 被吃成字面量反斜杠+s,正则永不匹配,
于是 418 条断言名的 TC 前缀**全部静默丢失**。要么显式 `chr(92)`,要么像本 Lead 会话一样改用「JS 组装 JSON 规格 +
Python 按规格带断言应用」,把转义面收敛到一处。
**协作口径(并行作业的板子纪律)**:
- **先改板再派工**:拆别人在做的范围时,必须先 `team_task_update` 收缩原任务写范围,再建新任务并派工。
本轮 Lead(我)建 G3a 时板上已回"write scopes overlap with task-9"却照样派工,属于用队友的规避动作补自己的疏漏——
工人-side 的补救是查 `mtime` 确认文件未被动过再让出,比外部检查及时,但这不该依赖个人自觉。
- **多名 agent 共用 `%TEMP%`,通用文件名本身就是竞态**:本轮一名工人写 `r2_verify.py` 撞上队友同名脚本,
是「文件未读不许改」的守卫阻止了覆盖;若当时改用直接 fs 写入就会**静默毁掉别人的工件**。纪律:临时文件一律带组前缀。
- 任务书里的条数要写清是**字面量条数**还是**断言总数**(本轮 `adc_input` 89 条字面量 vs 103 条断言),
否则工人会拿两套数互相印证出假结论。
- **三个计数单位别混用**(本批全踩过):① **行** = 含非 ASCII 字面量的行数(Lead 终检口径,本轮 1,535);
② **条** = 字面量出现次数(HEAD 全量 1,538;个别行含 2 条);③ **numstat 的 ± 行数** ≠ 替换条数——
git 会在相邻改动块之间重排空行(实测 `adc_input` 出现 90 行对 / 89 条替换,多出的那对两侧皆空、净零)。
同一批工作因此出现过 1,552 / 1,538 / 1,535 三个「都对」的数字——**报数前先声明用的是哪一个**。
- **分母只算 `.c`/`.h`**:`test/host` 下的 runner 与桩脚本是 `.py`,HEAD 上有 44 条中文 docstring,
属另一套口径(不经 ARMCC 代码页,翻它没有工程收益);混进分母会直接虚高普查数。
- 取改前原始字节一律 `git show HEAD:<路径>`:**不要**用 `git checkout` 取版本(会丢弃未提交工作,是铁律),
也别拿工作区文件当「改前基线」——队友可能正在改它。
**后续项(本轮不做)**:① 把「剥注释词法扫描」收进 `scripts/` 当门禁(需用户点头,并先定 `test/` 是否纳入
`coding-standard` 的口径——该钩子现在 `exclude` 了 `test/`);② `test/host/iec104/` 里「`TC-` 直接接三位序号、不带模块码」这类**简写形态**
归族(属编号登记与可追溯性改动,牵 `id_registry.json`,不能顺手做);③ `runner .py` 的中文 docstring **不动**
(口径是 `.c/​.h`,且 Python 源不经 ARMCC 代码页,翻它无工程收益)。
**给下一批的可复用手法与坑**(完整清单写在各套件 `.c` 文件头,这里只留跨模块通用的):
1. **测试 TU 里 `#define fputc <改名符号>` 再纳入生产源**:`App/log/console_log.c`:492 的
`int fputc(int, FILE*)` 与宿主 CRT 撞符号(`libucrt.lib(fputc.obj) error LNK2005` + `LNK1169`)。
改名纳入后函数体与行覆盖一字不动;顺带实测确认**宿主 printf 不走用户 `fputc`**(测试输出不会被吞进环形缓冲)。
2. **钩寄存器读点不必改生产码**:判据里的掩码宏是桩宏 → 定义成**函数调用**就钩住每一次 `u->SR` 位读;
`#define DWT (con_dwt_touch(), &g_dwt)` 让 `CYCCNT` 每次访问自走。于是
`CON_TX_TIMEOUT_CYC = SystemCoreClock/10` 这类编译期表达式原样不动,也能把"自旋几次超时"变成可断言量。
⚠ 结构体成员访问本身(`u->SR`/`u->DR`)钩不到,别绕进去。
3. **跑飞保险要挂在被测模块的天然计数点上**:`net_send_locked`/`net_sendto`/`net_recvfrom` 的等待环里
**没有 `osDelay`**,只给 `osDelay` 加保险挡不住这些死循环(实测第一次跑就真卡死)。挂在 `net_poll()`
每轮必读的 `HAL_GPIO_ReadPin`(INT#)上,附带让 `NET_SEND_SPIN_MS` 这类有界自旋预算**第一次可量测**(2026-11-03 更新:UDP 收包已不再在调用方自旋,改为登记挂起、由 `net_udp_tick` 结算——同段那条"泵 50 轮仍无通知就 FATAL"的纪律因此成了主力把关:漏一次 `xTaskNotify` 当场红,而不是慢慢卡死)。
同一纪律的另一半:假内核对 `xTaskNotifyWait(portMAX_DELAY)` 泵 50 轮仍无通知就 `exit(2)` 打 `FATAL` ——
它当场抓出一处把 `net_close` 返回语义判错的用例,而不是让门禁卡死。
4. **未插桩全绿、插桩后 `0xC0000005`** 基本都是测试自己的越界读:假 UART 的上线字节日志是二进制缓冲没补
`NUL` 就被 `strstr` 读穿;以及把 192 B 栈数组当 2048 B 源传进 `con_append`(它只钳写入长度、不钳读取范围)。
守则:凡是把缓冲当 C 字符串读的地方先补 `NUL` 或改 `memcmp` 定长比。
5. **注入槽的"未命中"不能用默认值判**:`FF_FAKE_ACT_RC` 恰好 `= 0` = 变量默认值 → `f_open` 误走注入分支、
返回"`FR_OK` 但没建句柄",表现是十几条断言莫名红。要显式 `hit` 标志 + `act` 双条件。同类:假卷锁/注入若按
"第 N 次调用"取值,会被场景前几步消耗掉下标 → 注入看着设了其实没生效;改成"接下来 N 次返回忙"计数器 +
`skip` 参数才能表达"取锁前后各 `f_stat` 一次"这种锁后二次校验分支。
6. **假卷每文件簇池必须大于生产单文件容量上限**(`run.log`/`soe.log` = 1024×64 = 65,536 B)。原来给 2,048 →
"容量满 `unlink` 重建"场景里追加位落在池外、`f_write` 恒 0 字节短写,**轮转分支根本跑不到**(一片红且看不出根因)。
7. **共享调用日志会被长轮询挤掉**:假芯片 `g_chip.log` 只有 96 格,一次 `sock_reset` 的几十轮把更早的顺序信息
挤出可查范围 → 顺序断言假绿/假红。做顺序断言前先 `clear_log`、用 `log_find` 查。
8. **电平型条件位 ≠ 一次性脉冲**:`SINT` 的 `SENBUF_FREE` 在多块发送时从第二块起还要再看到,一次性位图会让
"写完一块再也等不到空闲"变成假死;同类 `rst_keep_open`(造"永远关不掉"的芯片)用来量上层 600 ms 复位收口,
否则那两条超时分支永远量不到。**新旋钮必须默认关**,存量套件行为才逐字不变。
9. **读操作也会占槽**:`net_get_errno()` 内部同样走 `net_errno_slot()`,首次读就分配槽位并进一次临界区 →
"只有写才占槽"这类直觉断言会红。这是真实语义,不是缺陷。
10. **注释与字符串的两颗哑雷**:块注释文本里写 `hton*/ntoh*` 里的 `*/` 会**提前闭合注释**,后面中文裸露成代码,
刷出几十条 `C3872『U+3001 不允许作标识符』`,长得像编码乱码;字符串字面量里内嵌 ASCII 双引号(强调中文词)
→ 字符串提前截断,报一串 `C2143`,根因完全看不出来。要引用一律全角「」。另:用 heredoc /
`python - <<PY` 写 C 文件会吃掉 `\n`、`\xef` 这类转义(落盘成真换行)→ 一律直写文件再编译验证。
11. **共享假件扩展的纪律**(本批验证有效):**只加不改**,且**不往既有状态结构体里加字段** —— 另开
`g_fx`/`g_fxchip` 一份扩展世界,新增 `ch395f_fake_ext_reset()` 由既有 `ch395f_fake_reset()` 末尾调用,
既有三套的复位面与断言面一字不动、新状态也不会场景间残留。改完必跑存量回归(本批 net_listener 34/34、
net_events 34/34、net_pending 43/43、net_select 22/22;nand_port 149/149、lftpd_io 33/33、vol_lock 33/33、
soe 32/32、cfg_file 151/151)。
12. **产物目录已按工作树分家(2026-10-25,adabb6a)**:主机单测与覆盖率的中间产物统一落
`%TEMP%/dtu_host/<仓库根 sha1 前 10 位>/`,且**每次运行独占 `runs/<pid>-<纳秒>/` 子目录**
(obj+exe 在内,finally 删除;`DTU_HOST_BUILD` 改根、`DTU_HOST_BUILD_KEEP=1` 保留现场)。
并行期**不再需要**"每人只跑 `--module` 自己模块",同树并发跑同名套件也不再撞 obj/exe;
旧固定共享名 `%TEMP%/dtu_test_cov/` 与固定 `<suite>_obj`/`<suite>.exe` 一并作废,`--clean`
改为只清本工作树根。**判据正文** = `test/host/common/unit_test_harness.py` 顶部「产物目录归属」。
⚠ 加锁**不是**解法:这里没有真独占资源,只是名字撞——加锁等于把 主机侧纯逻辑单测全局串行化。
**两处口径问答(免得下次重新推导)**:① 子集数不能和并集基线直接比 —— `soe_store` 单套对
`App/log/soe_rec.c` 只到 78/87(89.7%),而台账是全部 33 套的**并集** 81/87(93.1%);本批
`--update-baseline` 前后逐文件比对**下降文件数 = 0**,所以那不是回退。判据只有一条:全量闸的并集 ≥ 基线。
② `console_log.c` 与 `soe_store.c` 那 1~2 行未覆盖都不是漏测:`console_log.c:352` 是 `snprintf` 溢出后的
钳位(`note[64]`,最长 42 B → 数学上到不了)、`soe_store.c:202` 是 `for (;;)` 收尾大括号。
### 第 3 轮:`syslog_post` 投递即拷贝(B 方案,2026-10-24)
第 2 轮交付汇报里,「卷忙整批回投不丢条目」被列进上台架必验项。本轮把它跑成,并在跑的过程中改掉一个真隐患。
- **正/负双向实证(台架,全程非破坏性)**:临时钩子从 testTask 以 2.5 s 间隔投 12 条带序号记录(远低于 8 槽队列容量,避开投递侧「满则丢」),窗口中段起一次 8 MB FTP STOR 抢卷,回读 `LOG/SELFTEST.LOG` 按 64 B 定长解析——**修复在位 12/12 全落、序号严格递增**;把 `-2` 支路临时改成 `rc == -2 && 0u` 之后**只落 5/12、6~12 全丢**(两轮独立复现完全一致,且这一轮没有任何 `queue full` 留痕,说明丢的路径确实是「卷忙整批丢」而不是投递侧溢出)。**负向对照是必要的**:若卷其实没被占住,「零丢失」就是空结论。
- **顺手照出的隐患**:待发条目只存 `const char *`,复用栈缓冲投递时整批别名到最后一次写入(实测落盘「1~5 + 12 七份」)。已改为条目内嵌 `desc[SYSLOG_DESC_MAX + 1u]` 并在投递时截断拷贝,头文件参数契约同步改写;代价 `+352 B ZI / +60 B Code`。理由与实测细节 → `docs/子系统设计/运行与自检记录设计说明.md` §4.4。
- **两次测法自误(教训,别重复)**:① 先按 700 ms 快投递测,测到的 34 条丢失其实是投递侧队列满(设计如此),根本没走到被整改的支路——**测错了对象**;② 钩子投完后 `return` 使 testTask 退出,约 10 s 后被看门狗复位,把还压在 RAM 队列里的条目一起带走——**污染了自己的计数**。两者都不是固件问题,根因都是没有先验证测量手段本身有效。
- **假件护栏的一次正向记录**:条目由 8 B 变 32 B 后,`test/host/sys_log_mcu` 的假消息队列因 `MQ_MAXMSG`(原 24)拒收一切 `osMessageQueuePut`,从既有用例起整片失败——这种「尺寸变了就响」的断言比任何评审都可靠;该宏已抬到 40 并把现象写进注释。
- 本轮改动面:`App/log/sys_log_mcu.c`(条目结构 + 投递拷贝)、`App/log/sys_log.h`(参数契约)、`test/host/sys_log_mcu/sys_log_mcu_test.c`(新增 2 条断言 + `MQ_MAXMSG`)、`scripts/archive/id_gate/id_registry.json`(游标 `TC-SYSLOG-108`)。
- **台架与临时改动已全部还原**:钩子、负向对照、套件宏逐一反向删除(`git status` 归零),设备上 `BENCH.STS` / `BENCH2.STS` / `B5FILL.STS` 与含钩子记录的 `SELFTEST.LOG` 已删除,产品固件重建后重烧并回读校验通过。
### 第 4 轮:UDP 数据面队列化(2026-11-03)
前置背景:`#57` SNTP 调研把"UDP 先串行化"列为 SNTP 落地的第一步——`net_sendto` / `net_recvfrom` 直调 SPI 且自旋收包,既违反"netTask 唯一芯片访问者",又把 IWDG 预算吃在自旋上。
- **改造面**:新增 `Drivers/BSP/NET/net_udp.c/.h`(真机专属,模拟器不编译)承接 4 个执行体与挂起 recv;`net_msg_type_t` 加 `NET_MSG_UDP_BIND / UDP_SENDTO / UDP_RECVFROM / UDP_PEER` 四型;公开 API 一律"入队 → 等通知"。`net_init` 重建、`net_socket` 分配 fd、`net_close`、掉线与对账复位四处接上 `net_udp_cancel_all / clear / settle_close`,`net_process_messages` 的第 5 个 tick 位挂 `net_udp_tick`。
- **源地址怎么回来(AGENTS 硬约束)**:`net_msg_t` 按值拷入队列、明文禁止加"回传用"字段 → 出参指针**不进消息**,改住 `net_udp.c` 的 per-fd 槽:调用方 `arm`(临界区只包"查占位 + 占位 + 记 owner"三步)、入队失败立刻 `disarm`、netTask 执行体经该指针就地写回调用方自己的内存,通知值只带结果码。互斥 + 泄漏 = 该 fd 永久 `BUSY`,所以执行体**每条出口**都清(UDP-7d / UDP-9e 钉住)。
- **不写第二份分块逻辑**:UDP 发送复用 `net_send_locked`(1KB 分块 + 挂起 send 都在里面)。为此把"会话是否已死"收成一条判据 `net_send_state_down()`(`net_pending.c`):TCP 看 `ESTABLISHED`、UDP 看 `UDP_OPEN`;原先 `net_send_locked` 两处 + `net_send_pending_tick` + `net_pending_tick` 各写一遍,共 4 处。
- **顺带修掉的形态缺陷**(真隐患):`net_sendto` 为发一个包把 DesIP 从 `0xFFFFFFFF` 写成具体 IP,芯片静默切到 UDP Client 形态(收包**不带** 8 字节信息头),而旧 `recvfrom` 无条件剥 8 字节 → 请求-响应式协议(SNTP 48 B 应答)会丢载荷前 8 字节并交出垃圾源地址。**修法**:`net_sock_t` 加 `udp_mode`(塞进原有 2 字节 padding,`sizeof(net_sock_t)` 不变)、`bind` 恒 SERVER、显式对端 `sendto` 切 CLIENT、`net_udp_set_peer(fd, NULL)` 轻量回 SERVER(不重开 Socket 因此不清芯片缓冲)、只在 SERVER 且 `avail >= 8` 时剥头(残包不猜头,TC-NETSOCK-440o 契约不变)。
- **截断语义定案**:一次调用交付一个数据报,`buf` 装不下的尾巴**必须排空**——`ch395f_read_recv_buf` 是按长度推进读指针的消费式读,不排空则下一次 `recvfrom` 交出上一个包的残料。排空用 64 B 栈上 scratch 循环读,**不用** `ch395f_clear_recv_buf`:它清整个接收缓冲,会把后面已经到达的包一起丢。
- **用例面**:新分片 `test/host/net_socket/ns_s7_udp.c` 共 20 组 UDP-1~20(bind 的 5 条芯片命令**顺序**、未 bind 一律 -5、服务器形态剥头逐字节、客户端形态不剥头、`sendto` 切形态、挂起收包在后续 poll 轮到达、预算超时按调用方 deadline 计、close 与重建结算不许静默丢通知、出参槽互斥与泄漏、截断排空、netTask 自等守卫、`net_init` 重建取消、流式 API 拒 `DGRAM`、非 netTask 调用零芯片事务、执行体与公开入口的参数守卫(越界 fd / `STREAM` / 未 bind / 零长 / 出参容量不足——拒时必须零芯片事务)、"队列未绑定"与"队列满"两条失败出口不许漏槽、`net_udp_set_peer` 轻/重两种回服务器模式(重开丢缓冲 vs 不重开)、挂起 send 由 `net_send_pending` 结算且登记时不许抢发第二份回执、`net_udp_tick` 的会话作废与 stale-readable 兜底、重 bind 必须把还在等包的调用方按 -8 结算);改写 TC-NETSOCK-440(补 bind 前置 + 新增 440p~440u)、555(表头逐字节按形态分路)、560 组("拒在 netTask 外"→"经队列后非 netTask 仍零 SPI")、500h-i / 506 / 545。全套 **616/616 绿**(改造前 470)。
- **假件新增(一律可加不可改)**:`g_fx.wlen[]` + `g_fx.last_write[]` 捕获 `ch395f_write_send_buf` 的**真实字节**(此前假件忽略 `p_data`,UDP 截断与排空无从断言);`ch395f_fake_set_write_delay(fd, n)` 让 SENBUF_FREE 第 n 次才到(钉"排空必须在下一次写之前完成");`g_chip.read_calls` 供"读了几个 scratch 块"断言。`g_fake` / `g_chip` 既有字段一字未动。
- **两条教训(都是被测码真 bug,不是测法自误)**:① **执行侧自等守卫不能复用调用侧谓词**——`net_udp_recvfrom_exec` 用 `net_ctx_is_owner_task()` 判自等,而它在 netTask 内恒真 → 每一条经队列投进来的正常 recvfrom 都被拒;正确判据是 `caller == xTaskGetCurrentTaskHandle()`(对齐 `net_pending.c` 的 `caller == s_owner_task`)。抓它的是 UDP-8a。② **芯片长度只能读一次并传参**——`net_udp_datagram_read` 内部又调一次 `ch395f_get_recv_len`,于是"剥几字节"与"读几字节"不同源;假件的 recv_len 队列**每次调用弹一个值**,这条设计当场把双读暴露出来。现由调用方读一次 `avail` 传入三个调用点。
- **补测阶段又抓到两条(同一批缺口分析顺手挖的)**:③ **槽指针必须在 fd 合法性检查之后取**——`net_udp_recvfrom_exec` 原先先算 `&s_net_udp[sockfd]` 再判 `p_sock == NULL`,越界 fd 立刻得到一个越界槽指针(哪怕当下没解引用,后面登记就是野写)。④ **作废不等于可以不吭声**——`net_udp_bind_exec` 重开芯片 Socket 会丢掉缓冲里的包,却只 `net_udp_clear()`:A 任务正阻塞在 `net_recvfrom`、B 任务重 bind 同一个 fd,A 就永远睡死。现在重开之前先按 `NET_ERR_CONNRESET` 结算并通知(UDP-20 钉住)。教训同 `net_pending`:**任何"取消挂起"的动作都必须走 complete 那条路,clear 只是归零**。
- **本轮覆盖率**:`net_udp.c` 234/236 行(99.2%)、`net_socket.c` 956/969(98.7%)、`net_pending.c` 173/199,全仓 line 8359/9287(90.0%)、47/47 套绿;`net_udp.c` 作为新文件随本轮 `--update-baseline` 登记,未覆盖的两处是 `net_udp.c` 的槽越界兜底(调用方已挡)与 `net_socket.c` 错误码槽重查竞态(真并发才走得到,改造前后同为那 2 行)。
- **台架必验(本轮无板子,主机测替代不了)**:① 两端真机互通,服务器形态收发 + 客户端形态往返,重点看 48 B 这类小应答**载荷完整**且源地址正确;② `sendto` 切形态后 `net_udp_set_peer(fd, NULL)` 能否真恢复收广播——手册只写 DesIP 语义,没写切换时机;③ 截断包排空后下一个包是否完整(scratch 循环在真芯片读指针上的行为);④ 挂起 recv 等待期间的 IWDG 余量;⑤ bind 在 open **之前**写 DesIP 的顺序在真芯片上的实际效果(TRAP-NET-14 的 UDP 侧对照)。
- 本轮改动面:`Drivers/BSP/NET/net_udp.c` 与 `Drivers/BSP/NET/net_udp.h`(新)、`net_socket.c` / `net_socket.h`、`net_pending.c` / `net_pending.h`、`net_types.h`、`MDK-ARM/STM32F407-Demo.uvprojx`、`scripts/check_registration.py`、`test/host/common/ch395f_fake.c` 与 `test/host/common/ch395f_fake.h`、`test/host/net_socket/`(含新分片);文档 → `docs/网络/BSD_Socket_API_使用指南.md` §6.0 / §6.1 / §6.2 / §6.3 / §9.0、`docs/网络/BSD_Socket_API_函数调用关系.md` §4.11、`docs/问题记录/CH395F_Trap_Records.md`(TRAP-NET-14 UDP 段 + 新条)。
### P3|**明确不做**(把理由写死,免得下次又被列回待办:29 个 `.c` = 4,814 行)
| 类别 | 体量 | 为什么不做 | 那它靠什么把关 |
|---|---|---|---|
| 芯片驱动(寄存器/时序) | `ch395f.c`(801)、`gd5f2gq5ue.c`(689)、`sd2506.c`(264)、`tpafe5160.c`(157)、`rs485.c`(79) = **1,990 行** | 把 HAL 与寄存器假到底 ≈ **重写一遍驱动**,测的是自己的桩而不是硬件行为(SPI 分频必须 8、DMA 流、I2C 时序在桩里怎么编都"对") | 板级套件(`board-test-suite` skill)+ `/serial-boot-test` 真机日志 + 4 个 Fault 现场;陷阱逐条登记在 `docs/问题记录/CH395F_Trap_Records.md`、`docs/问题记录/GD5F2GQ5UE_Trap_Records.md`、`docs/问题记录/SD2506_Trap_Records.md` |
| UI 侧 | 17 个 `.c` = **1,942 行** | 与 PC 模拟器是**同一份源码**,行为回归已由 `ui-golden`(菜单 8 状态 + 内容页 66 张像素黄金参考)覆盖;再写单测是第三份重复投资 | `ui-verify` skill 五步 + `docs/UI设计/UI_自动化测试流程.md` |
| 任务编排 | `default_task.c`(281)、`adc_task.c`(171)、`net_task.c`(123)、`protect_task.c`(90)、`ui_task.c`(71)、`rs485_task.c`(11) = **747 行** | 每个只有 4~5 个函数,主体是"按顺序调别人";真逻辑在被调模块里(那些已测或在 P1/P2)。给编排层写单测最容易写成"断言我调了几次"的自证 | 真机启动锚点计数 + IWDG `required_mask`(`/serial-boot-test`)+ `docs/架构设计/项目启动顺序管理.md` |
| 内核前装配 | `app_main.c`(135) | 被测对象本身就是"`osKernelStart` 之前不许调内核 API"的时序,主机侧无法忠实复现(PC 上没有那个约束) | 同上 + `cubemx-survival` 审计 |
**四个数字对得上**:`47 = 13(P1) + 5(P2) + 29(P3)`、`9,657 = 2,617 + 2,226 + 4,814`。
**落地进度(同日,P1 的第 ① 批已吃掉)**:新增 4 个主机单测模块 —— `test/host/net_cfg/`(45 断言)、
`test/host/ftp_cfg/`(16)、`test/host/prot_set_store/`(16)、`test/host/task_prof/`(27),
`files_tested` 19 → **23**、`files_untested` 125 → **121**(未触及 `.c` 47 → **43**、行数 9,657 → **9,228**)、
分母 2,893 → **3,163** 插桩行(新纳入 `net_cfg.c` 108/112、`task_prof.c` 88/93、`ftp_cfg.c` 35/36、
`prot_set_store.c` 27/29),套件 16 → **20** 全绿。恒等式跟着更新且仍然咬合:
`43 = 9(P1 剩) + 5(P2) + 29(P3)`、`9,228 = 2,188 + 2,226 + 4,814`——**剩下 43 个里 29 个是 P3 刻意不做**。
顺带由新单测照出并修掉两处(都在 `net_cfg`,`@build` 0/0 实测 ER_IROM1 +12 B / RW_IRAM1 +8 B / CCM 0):
① `net_cfg_save_run` 先清 `pending` 再写盘,导致写盘窗口里 UI 保存行退回上一轮的 OK/IDLE,
违反 `net_cfg.h` 自己写的"写盘中显示 WAIT"→ 补 `s_save_busy` 标志(TC-NETCFG-028/026/030 钉死);
② "定长 11 字符"是错的(`%03u`×4 + 3 个点 = **15**),`net_cfg.h`/`net_cfg.c`/本文所引设计文档
共四处已更正(缓冲要求 `len >= 16` 本来是对的,UI 侧 `UI_BUF_STRLEN`=32 未受影响)。
也就是说:**"覆盖率不该 100%"这件事在这份清单里是算出来的,不是嘴上说的**——P3 那 4,814 行永远不进分母,
而 P1+P2 做完约 `4.8k` 行进分母(现在的分母只有 2,893),`files_tested` 从 19 → 37。
### 顺序建议与记账
1. 落地顺序:**配置与工具 4 块**(先跑通"新增文件进台账 + NOTE 不误报回退"这条链)→ **FTP 一套** →
**NET 四块**(含新建 `stub/ch395f.h`)→ **104 服务层** → P2 的 FatFs 三块与 `console_log.c` →
最后啃 `net_socket.c` 的状态机分片。
2. 每吃掉一批:`--update-baseline` 重登;看 `files_tested`(19→…)与 `files_untested`(125→…)这两个数,
**不要盯着 88.2% 看**——那只是"已进分母部分"的自分数,新增大块未测代码时会**被摊薄而下降,那不是回退**(§5 已注明)。
3. 终点判据不是百分比,而是:**`untested_sources` 里剩下的每一条都能在 P3 那张表里找到归属**。
找不到归属的(既没进分母、也不属于"刻意不做")才是真窟窿。