merge: task/cov-host-line-coverage 合入 v2-on-ui

任务 cov(15 个提交)经 task-worktree 回收:
- test(APP/104): iec104_serv 模块(28 条,84.2%)——P1 十三块全部进分母
- test(NET/lftpd): lftpd 控制通道模块(35 条),FTP 一套四块全部进分母
- test(NET/lftpd): lftpd_inet 模块(28 条,127/127 = 100%)
- test(NET): net_events 模块(34 条,覆盖 97.8%),NET 三块收官
- test(NET): net_pending 模块(43 条,覆盖 87.1%),钉住通知值回传契约
- test(NET): 假 CH395F 芯片落地 + net_listener 模块(34 条,覆盖 99.2%)
- docs(覆盖率): §9 新增"假 CH395F 芯片"实施规格,并更正一项被我高估的前置成本
- docs(任务规划): 删除查无正文的 E-25 交叉引用,net_select 处置改用平实语言留档
- fix(NET,lftpd): 处置覆盖率照出的三处死路径(改名 + 修 timeout=0 + 激活异常集)
- test(NET): net_select 模块(21 条)与 NET 域首份 ch395f 桩,登记两处死路径
- test(NET/lftpd): P1 增补 lftpd_io 模块(33 条),登记 FTP 域一处潜伏缺陷
- test(NETCFG,CFG): P1①四个主机单测模块,并修 net_cfg 的 WAIT 契约与定长错记
- docs(覆盖率): 新增 §9 未触及面建桩优先级清单(P1/P2/P3 逐个画像)
- feat(门禁): coding-standard 加 CODE-RULE-20 禁 C 库堆分配(销 §4.8 空白 ②)
- feat(门禁): 主机单测覆盖率脚本与口径落地(销报告 §4.8 空白 ①)

备注:W5 验证常态:主机单测覆盖率脚本与口径(补 项目审查与优化建议报告 4.8 空白一)
This commit is contained in:
2026-10-23 23:46:57 +08:00
98 changed files with 10340 additions and 38 deletions
+1 -1
View File
@@ -110,7 +110,7 @@
- **`osKernelStart` 之前禁止任何 FreeRTOS/CMSIS-RTOS 创建类 API**(`osMessageQueueNew` 等会杀死启动,2026-09-20 实证根因)→ 内核前标准模式(纯寄存器/HAL 轮询 + **无锁静态缓冲** + 内核启动后**惰性创建**真队列)见 `docs/架构设计/项目启动顺序管理.md`
- **就绪屏障位必须 `osFlagsNoClear`**:`osEventFlagsWait` **默认消费**所等位,就绪位被先消费掉 → `ftpTask` 的 `WaitAll` 永不成立 → **FTP 21 端口静默不监听**(无日志无报错)。命令位(`SYS_EVT_CFG_SAVE`/`NET_RECONFIG`)保持消费型
- **自研代码禁用 C 库堆分配**(`malloc/calloc/realloc/strdup/free`):MicroLIB 的 C 堆由 `startup_stm32f407xx.s` 的 `Heap_Size EQU 0x200` 限定为 **512 B**,KB 级分配必然返回 NULL,而**模拟器(宿主 CRT 堆)完全正常、照不出来** → 只用 `pvPortMalloc/vPortFree`,或把分配收进平台后端 → `docs/内存管理/RAM使用分析与优化方案.md`
- **自研代码禁用 C 库堆分配**(`malloc/calloc/realloc/strdup/free`):**2026-10-23 起由提交门禁机器拦截**(`coding-standard` 的 CODE-RULE-20,唯一豁免 = 平台分配后端的宿主侧实现,见 `docs/门禁说明/coding-standard.md` §7):MicroLIB 的 C 堆由 `startup_stm32f407xx.s` 的 `Heap_Size EQU 0x200` 限定为 **512 B**,KB 级分配必然返回 NULL,而**模拟器(宿主 CRT 堆)完全正常、照不出来** → 只用 `pvPortMalloc/vPortFree`,或把分配收进平台后端 → `docs/内存管理/RAM使用分析与优化方案.md`
- **内存预算紧张**:新增任何任务/缓冲/栈前先核算 RAM 余量;改栈或加大局部变量**前后各跑一次** `scripts/stack_report.py`(静态最坏链 + 串口 `[STACK]` 水位)与 `scripts/ram_report.py`(.map 静态占用);**总量红线已机械化**:`mem-budget` 钩子拦 RW_IRAM1/RW_CCM >92%,子系统配额 / CCM 台账 / 趋势见 `docs/内存管理/RAM预算台账.md`(**每次 `@build` 后跑 `scripts/ram_baseline.py --update`**)→ `docs/内存管理/`
- FreeRTOS V10.3.1(CMSIS-RTOS V2);HAL 时基 = **TIM7**(非 SysTick),tick = 1ms;NVIC 4 位优先级分组,**外设中断优先级 ≥ 5**
+1 -1
View File
@@ -777,7 +777,7 @@ static int handle_control_channel(lftpd_client_t *client)
if (index < (int)strlen(read_buffer)) {
strncpy(arg_buf, read_buffer + index + 1, sizeof(arg_buf) - 1);
arg_buf[sizeof(arg_buf) - 1] = '\0';
arg = lftpd_string_trim(arg_buf);
arg = lftpd_string_ltrim(arg_buf);
}
lftpd_log_info("CMD %s %s", command_tmp, arg ? arg : "");
+6 -4
View File
@@ -3,14 +3,16 @@
#include "private/lftpd_string.h"
char *lftpd_string_trim(char *s)
char *lftpd_string_ltrim(char *s)
{
char *p = s;
/* 只去行首空白:返回跳过后的指针,不搬移缓冲。
* 原先此处的"去尾部空白"循环,起点是 strlen(p) 那一格 NUL,isspace('\0') 为假
* → 从未执行过;而 FTP 参数的尾随空格属于参数本体(见本文件头与
* private/lftpd_string.h 的说明),2026-10-23 删掉这条死代码并改名为 *_ltrim,
* 让函数名与实际行为一致(**行为零变化**)。 */
for (int i = 0, len = (int)strlen(s); i < len && isspace((int)s[i]); i++) {
p++;
}
for (int i = (int)strlen(p); i >= 0 && isspace((int)p[i]); i--) {
p[i] = '\0';
}
return p;
}
+13 -1
View File
@@ -2,4 +2,16 @@
#define CRLF "\r\n"
char* lftpd_string_trim(char* s);
/*
* 函数功能:去掉字符串**行首**空白(空格/制表/CR/LF),返回跳过空白后的指针
* 入口参数:s - 待处理串(不搬移内容,只挪返回指针) char*
* 返回值:首个非空白字符的指针(全空白则指向结尾 NUL) char*
* 限定条件:s 非空且以 NUL 结尾;调用方必须使用返回值,不能继续用原指针
* 函数说明:**只去前导、不去尾部**,2026-10-23 由 lftpd_string_trim 改名为
* lftpd_string_ltrim。原名让调用方以为两头都去,而实现里尾部那条循环
* 起点落在 NUL(isspace('\0') 为假)一次都没执行过 —— 潜伏陷阱。
* 不去尾才是对的:RFC 959 以命令终止符 CRLF 界定命令,参数(PASS 的
* 口令、含空格的文件名)的尾随空格属于参数本体,去掉等于静默改数据;
* CRLF 已由 lftpd_inet_read_line 截断,无需此处再处理。
*/
char* lftpd_string_ltrim(char* s);
+16 -4
View File
@@ -37,7 +37,8 @@ static int check_socket_error(int sockfd);
* 入口参数:nfds - 最大文件描述符数 +1 int > 0
* readfds - 读事件集合指针 net_fd_set_t *(可为 NULL)
* writefds - 写事件集合指针 net_fd_set_t *(可为 NULL)
* exceptfds- 异常事件集合指针 net_fd_set_t *(可为 NULL)
* exceptfds- 异常事件集合指针(表项不存在的 fd 计入异常并计入返回计数;
* timeout 传 {0,0} 表示"只查一轮立即返回") net_fd_set_t *(可为 NULL)
* timeout - 超时时间结构体指针 net_timeval_t *(NULL=无限等待)
* 出口参数:readfds/writefds/exceptfds - 仅保留就绪的 Socket 位 net_fd_set_t *
* 返回值:就绪的 Socket 数量(>= 0);-1 表示参数无效 int
@@ -81,6 +82,14 @@ int net_select(int nfds, net_fd_set_t *readfds, net_fd_set_t *writefds,
const net_sock_t *p_sock = net_get_sock(i);
if (p_sock == NULL) {
/* 表项不存在就是异常,必须在 continue **之前**判:原实现先跳过,而
* check_socket_error() 只有"表项为空 / fd 越界"才返回 1,越界的 i
* 又不在本循环范围内 → exceptfds 永远是空的死参数(调用方按 POSIX
* 直觉用它,就永远什么都收不到)。2026-10-23 修掉。 */
if ((exceptfds != NULL) && NET_FD_ISSET(i, exceptfds)) {
NET_FD_SET(i, &result_except);
ready_count++;
}
continue;
}
@@ -116,9 +125,12 @@ int net_select(int nfds, net_fd_set_t *readfds, net_fd_set_t *writefds,
/* 超时检测 */
if (timeout != NULL) {
if ((timeout_ms > 0) &&
((HAL_GetTick() - start_tick) >= timeout_ms)) {
break; /* 超时返回 0 */
/* 不能再加 (timeout_ms > 0) 前置:timeout = {0, 0} 换算出 0,那是
* POSIX 的"轮询一次立即返回";加上它 = 超时永不生效,调用方(netTask
* 在 IWDG required_mask 里)再也回不到主循环。就绪检查在本判断之前,
* 所以有就绪时依旧立刻带计数返回,2026-10-23 由主机单测暴露并修掉。 */
if ((HAL_GetTick() - start_tick) >= timeout_ms) {
break; /* 超时返回 0(timeout_ms == 0 即"只查一轮") */
}
}
+14 -4
View File
@@ -60,6 +60,7 @@ static uint8_t s_active_mac[6]; /* 芯片实际 MAC 影子(netTask 回写
static uint8_t s_active_valid; /* 影子有效标志(0 = 尚未回写) */
static net_cfg_apply_cb_t s_apply_cb; /* "立即应用"平台回调(未注册 = 空操作) */
static volatile uint8_t s_save_pending; /* 1 = 有待执行保存请求(UI 投递/属主执行) */
static volatile uint8_t s_save_busy; /* 1 = 属主任务正在写盘(维持 UI 的 WAIT 显示) */
static volatile uint8_t s_save_result; /* 最近一次保存结果:0 = 无;1 = 成功;2 = 失败 */
static net_cfg_save_req_cb_t s_save_req_cb; /* "保存请求"平台通知(未注册 = 空操作) */
@@ -234,7 +235,10 @@ int net_cfg_save_request(void) {
* 函数说明:见 net_cfg.h 同名函数说明
*/
int net_cfg_save_state(void) {
if (s_save_pending != 0u) {
/* "请求已投递未执行"与"正在写盘"都显示 WAIT:pending 在写盘前就被清掉
* (为了不让写盘期间的新请求丢失),单看它会在这几百毫秒里退回上一轮的
* OK/IDLE —— 用户按了 ENTER 却看不到反馈,只会再按一次。busy 补上这一段。 */
if ((s_save_pending != 0u) || (s_save_busy != 0u)) {
return (int)NET_CFG_SAVE_ST_WAIT;
}
if (s_save_result == 1u) {
@@ -252,8 +256,10 @@ int net_cfg_save_state(void) {
* 返回值:0 - 无待处理请求或保存成功;-1 - 写盘失败 int
* 限定条件:仅配置引擎属主任务上下文(MCU = defaultTask;模拟器 = 主循环)
* 函数说明:先清"待保存"标志(写盘期间的新请求重位置位,下轮再执行,
* 不丢失);写盘成功 → 置 OK 并触发"立即应用"(netTask 重建
* 网络,无需重启);失败 → 置 FAIL,不触发应用(网络保持)
* 不丢失),同时置"写盘中"标志让 UI 在这段窗口里仍显示 WAIT
* (否则退回上一轮结果,见 net_cfg_save_state);写盘成功 → 置 OK
* 并触发"立即应用"(netTask 重建网络,无需重启);失败 → 置 FAIL,
* 不触发应用(网络保持)。两条路径都会清"写盘中"标志
*/
int net_cfg_save_run(void) {
int r;
@@ -262,8 +268,11 @@ int net_cfg_save_run(void) {
return 0; /* 无待处理请求(空醒/周期轮询) */
}
s_save_pending = 0u;
s_save_busy = 1u; /* 写盘期间维持 WAIT(见 net_cfg_save_state 注释) */
r = net_cfg_save();
s_save_busy = 0u;
if (r == 0) {
s_save_result = 1u;
net_cfg_request_apply();
@@ -352,7 +361,8 @@ void net_cfg_get_mac(uint8_t *p_out) {
* len - 缓冲长度 size_t
* 返回值:无
* 限定条件:p_ip/p_buf 非空;len ≥ 16
* 函数说明:"192.168.001.100"(定长 11 字符,UI 编辑光标按固定偏移定位字节)
* 函数说明:"192.168.001.100"(定长 **15** 字符 = 4 段 × 3 位 + 3 个点,UI 编辑
* 光标按固定偏移定位字节:段 i 起点 4i、点号在 3/7/11)
*/
void net_cfg_fmt_ipv4(const uint8_t *p_ip, char *p_buf, size_t len) {
if (p_ip == NULL || p_buf == NULL || len == 0u) {
+1 -1
View File
@@ -204,7 +204,7 @@ void net_cfg_get_mac(uint8_t *p_out);
* p_buf - 输出缓冲(不可为 NULL) char *
* len - 缓冲长度 size_t
* 返回值:无
* 限定条件:p_ip/p_buf 非空;len ≥ 16(输出 11 字符 + '\0')
* 限定条件:p_ip/p_buf 非空;len ≥ 16(输出 15 字符 + '\0')
* 函数说明:"192.168.001.100"——每段 3 位补零使字符串定长,UI 编辑光标
* 可按固定偏移(4i/3i 起始)定位字节;net_init 的 parse_ip_string
* 对前导零兼容(十进制逐段解析)
+1
View File
@@ -134,6 +134,7 @@ docs/
| [`相量层单元测试说明.md`](测试说明/相量层单元测试说明.md) | phasor 全周傅氏系数表 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 双区交换,57 用例 |
| [`卷锁单元测试说明.md`](测试说明/卷锁单元测试说明.md) | vol_lock 创建 / 获取 / 释放契约(**fail-open 不变量**:创建失败仍返回 1 且零阻塞)/ 幂等 / 超时透传,33 断言点 |
| [`时间同步单元测试说明.md`](测试说明/时间同步单元测试说明.md) | time_sync 分片喂狗延时 / 监听退避 / 时间包重组 / 任务主循环(**N-17 的全部逻辑贡献者**),80 断言点 |
| [`主机单测覆盖率口径.md`](测试说明/主机单测覆盖率口径.md) | **跨全部 16 套 L0.5 的覆盖率**:`scripts/check_test_coverage.py` 用法 / 统计面与生成物例外 / 基线增量门禁(只拦回退)/ **为什么本机拿不到分支覆盖** / 九条限制与盲区 / 未挂 pre-commit 的三条理由与待粘 YAML |
**模块测试指南**(动手写测试前先读)
+2 -1
View File
@@ -245,7 +245,8 @@ $env:PATH = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Env
| `scripts/check_font_charset.py` | scripts/ | UI 字符集差集检查(复用 `tools/font_gen/` 提取与 cmap 解析规则,防缺字白框) |
| `scripts/check_layer_deps.py` | scripts/ | 分层方向门禁(BSP 反向 include App 的增量拦截,存量基线只减不增) |
| `scripts/check_bsp_err.py` | scripts/ | BSP 错误码与超时命名门禁(三条规则的增量拦截,存量 19 条基线只减不增;`--emit-baseline` 建基线) |
| `docs/代码规范/嵌入式C语言代码规范_V1.0.md` | docs/代码规范/ | 代码规范原文(CODE-RULE-01~CODE-RULE-19 规则来源) |
| `docs/代码规范/嵌入式C语言代码规范_V1.0.md` | docs/代码规范/ | 代码规范原文(CODE-RULE-01~CODE-RULE-20 规则来源;`CODE-RULE-20` 出处不在规范正文而在
AGENTS.md 平台约束,见 `docs/门禁说明/coding-standard.md`) |
| `docs/代码规范/cppcheck使用说明.md` | docs/代码规范/ | cppcheck 钩子详解(规则/配置/抑制) |
| `docs/文档规范.md` | docs/ | 文档体系规范(链接形态/命名/门禁要求) |
| `docs/代码规范/pre-commit使用说明.md` | 本文档 | 部署与使用说明 |
@@ -370,6 +370,15 @@ void function(void) {
p_data = NULL;
}
```
> ⚠ **本仓例外(2026-10-23 补,别照抄这段示例)**:示例里的 `malloc`/`free` 是**通用 C 写法**,本仓
> **禁止**——工具链是 MicroLIB,其 C 堆由 `startup_stm32f407xx.s` 的 `Heap_Size EQU 0x200` 只有 **512 B**,
> KB 级分配必然返回 NULL,而模拟器(宿主 CRT 堆)完全正常、**照不出来**(只有真机才炸)。这段示例只教
> 三件事:**声明即初始化、检查 NULL、释放后置 NULL**。实际分配一律 `pvPortMalloc`/`vPortFree`,或把
> 分配**收进平台后端**(MCU 端与宿主端各一份实现,例:`simulator/sim/cfg_file_store_sim.c` 的
> `cfg_store_alloc`)。机器拦截 = 提交门禁 `coding-standard` 的 **CODE-RULE-20**,细则见
> `docs/门禁说明/coding-standard.md`。
### 6.2 函数设计原则
1. 函数应遵循"单一职责原则",一个函数只做一件事
2. 函数长度不宜过长,一般不超过50行
+2 -2
View File
@@ -79,7 +79,7 @@
| **数据可信(W8)** | 1 簇 · **14 张卡** | 已知电压注入验证 · 端子变比确认 · 通道标定系数与折算 · 标定落盘 · 未标定即闭锁 · 下游三常数重定 · 量纲回归 · 遥测快照 200 ms · 运行参数段 · 频率点绑定(详表 `W8-数据可信.md`) |
> **网络侧 E-xx 存量项**(历史编号族,属主文档已于 2026-10-15 删除,编号冻结见
> `docs/编号规范.md` §4.3):E-25 / E-27 / E-28 挂 C1(net 层跨上下文收口,见 `排期详述.md` §1);
> `docs/编号规范.md` §4.3):E-27 / E-28 挂 C1(net 层跨上下文收口,见 `排期详述.md` §1);
> E-47 待 CH395F 驱动侧定案;E-12 为 `FF_FS_LOCK` / 单写者纪律记录项;E-31 后续与 E-62
> 为回归监督项(纪律已固化于 `AGENTS.md`,无需改动)。
> UI 标准化簇与 lftpd 主机测试缺口为整簇排期项,见 §2 W6(dhara 移植层主机单测已随登记簿
@@ -107,7 +107,7 @@
| **W1 采集+保护收尾** | **里程碑制 + 并行道**:M1 台架闭环 ✅ 达成(B6 ✅→N-33 ✅)∥ M2 功能面(代码道 B7 ✅→{B8∥B9})→ M3 送检(B10,Fixed Date)——任务卡/判据/写范围见 `W1-采集保护收尾.md` | **用户决定(2026-10-12):先把 ADC 采集、保护等相关功能完成**。B4/B5 代码已落地但未台架闭环、功能面不完整(定值不落盘 / 无频率保护 / 无动作记录 / 栈两处 FAIL)——本波收口到"可用 + 可验收";真约束只有**台架**与**送检窗口**两个稀缺资源,其余任务按写范围切分并行 |
| **W8 数据可信** | **通道标定 → 遥测链 → 运行参数**:M4 系数与量纲 / M5 遥测链 / M6 运行参数与点表;14 张卡与事实基线见 `W8-数据可信.md` | **用户决定(2026-10-23):下一步做通道标定,先于 UI 页面与结构债**。它是唯一"改一处、全链受益"的入口(`App/task/adc_task.c:335` 是码值→工程量的唯一位置),也是 W6 页面与 104 遥测的共同前置——先做页面则 `ui_data_feed` 组装的码值帧要返工。**编号是分配序,执行序以本表从上到下为准**:本波紧随 W1,排在 W3/W4 之前。⚠ 2026-10-23 补记:ADC 至今**从未采过已知电压**(连极性都没验过,见 `W8-数据可信.md` §2.6),故第一张卡是"已知电压注入验证"——只需一块万用表,不需要信号源/示波器,是开工的第一刀 |
| **W3 预算与内存** | ⏸ **整簇暂缓**(用户裁定 2026-10-15):~~缩堆 56→32 KB → R-2 后半(29.5 KB→CCM)→ R-3 / R-4 / R-6 逐项评估~~。本簇已落地的只有 **R-9 ✅(2026-10-15)**;R-5 已判定不可行 | **不再排期**:没有功能在等这块预算,而搬迁要动 scatter、要逐条过 DMA 可达性清单、缩堆还要上板复测。**W3 槽位保留、不重编号**(避免全库改 W4~W7 引用)。重开条件 → `../问题记录/项目审查与优化建议报告.md` §5.3 |
| **W4 结构债** | **C6(S-3)→ C3(N-2)→ C1 net 层跨上下文收口 → C5(含 N-23 残余)→ N-22/N-25/N-28**(详表 `排期详述.md` §1) | 无紧急,按性价比排:C6 灭 IWDG 误复位机制根源;C1 消化 E-25/E-27/E-28(原 ACCEPT+SEND pending 化已收口),体量大、验证重,放后(N-23 钩子已落地 2026-10-12,残余并入 C5)。⚠ 原挂 C3 的 **N-24(日志逐条开关)已于 2026-10-15 单独修复销项**,C3 只剩 N-2 观察项 |
| **W4 结构债** | **C6(S-3)→ C3(N-2)→ C1 net 层跨上下文收口 → C5(含 N-23 残余)→ N-22/N-25/N-28**(详表 `排期详述.md` §1) | 无紧急,按性价比排:C6 灭 IWDG 误复位机制根源;C1 消化 E-27/E-28(原 ACCEPT+SEND pending 化已收口),体量大、验证重,放后(N-23 钩子已落地 2026-10-12,残余并入 C5)。⚠ 原挂 C3 的 **N-24(日志逐条开关)已于 2026-10-15 单独修复销项**,C3 只剩 N-2 观察项 |
| **W5 验证常态** | P1-10 收尾 · T-7 CI · D2 N-3 · D5 N-16 · D6 N-17 · D7 串口助手 · S12 · S1-c · A-5(详表 `排期详述.md` §2) | 窗口性,逢机顺带,不单独排期 |
| **W6 UI 扩展** | U4 残余(golden + 字符集差集挂门禁)→ U1 令牌层 → U2/U3 → **27 页**落地 | 产品驱动、体量大,需单独立项(顺序按 `docs/问题记录/项目审查与优化建议报告.md` §6.5)。**2026-10-15 用户裁定启动**(覆盖 2026-10-11 的「本轮不排期」):已开智能体团队,第一波 **U1 令牌层 ∥ U4 门禁补强** 并行进行中;U3 为地基串行段(独占注册文件),U2/U5 排在 U3 之后 ⚠ **U5 暂缓(2026-10-20 用户裁定)**:剩余页面**已出规划(`docs/UI设计/页面补齐规划.md`,2026-10-23:27 个空叶子归并为 7 模块 + 2 项前置能力),但仍未排期**,本轮不补页 —— 现存 7 个 `ui_page_*.c` 即当前全部内容,报告"27 页"是目标数不可当差集清单。U1–U4 已全部落地,故 U5 未来开工时是纯增量活(实测新增一页 = 3 处人工 + 1 条生成器命令,且 `ui-golden` 已在提交门禁兜像素)。**2026-10-23 补充**:U5 的 `ui_data` / `ui_data_feed` 需要真数据才有意义,故 **U5 最早在 W8 的 M4 之后开工**(依据见 `W8-数据可信.md` §10) |
| **W7 仓库决策** | **N-29** `.git` **235 MB** + 图片 36.3 MB | **已决策(2026-10-11):暂不处理、登记观察**——推送/协作前重评(`git filter-repo` 历史重写影响未推送提交与协作者) |
+1 -1
View File
@@ -11,7 +11,7 @@
|---|---|---|---|
| **C6** | **USART1 TX-only 结构根治(S-3)** | 灭 ORE 僵尸机制根源(2026-10-02 台架 IWDG 误复位的机制根源):改 `.ioc` 令 USART1 不使能 RX / PA10 不复用为 `USART1_RX` → CubeMX 重生成 → 跑 `check_cubemx_survival.py`。结构性改动,需单开一轮 + 复跑台架闭环 | S-3 |
| **C3** | **存储侧日志管线(N-2)** | 丢弃可观测性**代码已具备**(`console_log.c` `s_con_drop`+`[ERR] CONSOLE dropped`、`sys_log_mcu.c` 队列满 `DBG_ERROR`、TX 僵尸通道自愈);V5 30 min 日志**未复现 49s 静默**(运行期 >10s 静默全为 ~28s、均在 `e02_probe` 窗口内=N-17)。剩余:与 S8(非阻塞日志)合并评估。⚠ 原挂本卡的 **N-24**(日志逐条 `f_open/f_write/f_close`)已于 2026-10-15 修复销项(写侧一次排空合并成每文件一次开关、读侧 8 条滑动窗口)→ 实施记录 `../子系统设计/运行与自检记录设计说明.md` §4.4 / §4.5 | N-2 |
| **C1** | **net 层跨上下文收口** | 消化 E-25(`net_select` 语义)、E-27/E-28(裸指针收口为状态查询 API)。原 ACCEPT + SEND pending 化已收口:SEND 2026-10-14 落地、ACCEPT 决定不实施(2026-10-15) | E-25 / E-27 / E-28 |
| **C1** | **net 层跨上下文收口** | 消化 E-27/E-28(裸指针收口为状态查询 API)。原 ACCEPT + SEND pending 化已收口:SEND 2026-10-14 落地、ACCEPT 决定不实施(2026-10-15)。**另:`net_select` 的两处死路径(`timeout={0,0}` 退化成死等、`exceptfds` 恒为空)已于 2026-10-23 由主机单测照出并修掉**,判据与用例见 `docs/测试说明/主机单测覆盖率口径.md` §9 | E-27 / E-28 |
| **C5** | 一档剩余 | **N-23 残余**(`KNOWN_GAPS` 白名单清理,见 `登记簿.md`);~~`app_cfg.h` 未接线开关~~ ✅ **已闭环**(2026-10-22 删除零消费点占位宏,报告 S-4)。**RS-485 DE 收尾已移入 `暂缓-RS485.md`** | 一档/二档 |
| **N-22/25/28** | 评估簇 | 写回 flush / 性能与语义一致性 / Flash 死代码——逐项见 `登记簿.md` 对应行,随窗口评估 | 报告各条 |
+2 -2
View File
@@ -228,7 +228,7 @@ sequenceDiagram
| 行 | 名称 | 值格式 |
|---|---|---|
| 0 | IP地址 | `192.168.001.100`(每段 `%03u` 补零,**定长 11 字符**) |
| 0 | IP地址 | `192.168.001.100`(每段 `%03u` 补零,**定长 15 字符** = 4 段 × 3 位 + 3 个点) |
| 1 | 子网掩码 | `255.255.255.000` |
| 2 | 默认网关 | `192.168.001.001` |
| 3 | MAC地址 | `A4:94:B6:12:34:56`(6 字节十六进制,冒号分隔) |
@@ -277,7 +277,7 @@ ENTER 进入选中地址行 → `↑/↓` 调活动字节(0..255 **环绕**)
| `net_cfg_set()` | 更新 RAM 副本(不写盘) |
| `net_cfg_set_active_mac()` | 回写芯片实际 MAC 影子(netTask 调) |
| `net_cfg_get_mac()` | 取显示用 MAC(存储非零 → 存储;否则影子;否则全零) |
| `net_cfg_fmt_ipv4()` | 4 字节 → `"192.168.001.100"` 定长字符串 |
| `net_cfg_fmt_ipv4()` | 4 字节 → `"192.168.001.100"` 定长 15 字符串(缓冲 len ≥ 16) |
---
@@ -0,0 +1,443 @@
# 主机单测覆盖率口径(覆盖率脚本 / 基线 / 盲区)
> **状态**:✅ 已落地(脚本 + 基线 + 口径),⏸ 未挂 pre-commit(理由见 §8);
> **分支覆盖**:本机可实测拿到(§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)(L0~L3 分层与门禁决策表)、
> [`../问题记录/项目审查与优化建议报告.md`](../问题记录/项目审查与优化建议报告.md) §4.8(本文关闭它的空白 ①)
> 与 [`../问题记录/项目审查与优化建议报告.md`](../问题记录/项目审查与优化建议报告.md) §5.5 第 5 条的前半、[`../代码规范/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
# 全量:16 套单测逐套"编译(带 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
```
**与 L0.5 同一套发现规则**(`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` 用法错误。全量实测约 **25 s**(16 套 × 编译 + 插桩运行)。
中间产物(exe / PDB / Cobertura XML)一律落 `%TEMP%\dtu_test_cov\<模块>\`,**不进仓库**,
所以本脚本不需要新增任何 `.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`(静态内联与被展开的行落在头文件上);当前 16 套
几乎没触及 `.h`,表里看不到它们——不要以为头文件"天生 100%"。
8. **依赖 VS 的可选组件。** 只有 BuildTools 且没勾"代码覆盖率工具"的机器会退出码 2;
CI runner 与本机同一台(`docs/CI/README.md`),但**换机先跑 `--list` 再跑全量**。
9. **并集口径下"单套百分比"没有意义。** 同一文件被多套触及很常见(`sha256.c` 被 `sha256` 与
`ftp_auth` 都编译),所以对外只报**并集**,单套只报"触及几个文件 / 多少行"。
## 8. 接线状态与下一步
**已落地**:脚本、基线台账、口径(本文)、`scripts/ci_local.py --coverage` 一条入口。
**未挂 pre-commit**,三条理由(不是忘了):
1. 39 个钩子里已有 16 个主机单测钩子(每套各编译各跑),再加一份"全量插桩"是**同一件事做两遍**,
还要多约 25 s;
2. 它依赖 VS 可选组件(§7 第 8 条)。挂之前必须先定"缺组件时 skip 还是 FAIL"——
选 skip 就是一个会静默失效的门禁(与 `AGENTS.md` 里那条 `always: true 已失效、必须靠 files: 覆盖`
是同一类坑);
3. 基线首登后需要**真实回退事件**才算被验证过。已做的是**合成自证**(人为抬高 `test_coverage_baseline.json`
的三处数字后全量跑):行 / 函数 / 总计三条回退路径都报 `[REGRESS]` 且退出码 1;`--module` 子集运行
不会假报回退(首版会,已修:并集口径下子集必然变小,所以 `--module` 子集跑跳过全部回退判定)。
真实事件(某次改动真把覆盖行弄少了)仍待观察。
真到接线那天,片段形状如下(这份 YAML 由接线者写进 `.pre-commit-config.yaml`,说明写进
`docs/门禁说明/` 对应那篇,不在本文维护):
```yaml
- repo: local
hooks:
- id: test-coverage
name: 主机单测覆盖率(增量拦截:已覆盖行只增不减)
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)'
pass_filenames: false
```
**下一步(按性价比排序)**:
1. **扩大覆盖面**:按 **§9 的建桩优先级清单**吃(P1 13 个 .c / 2,617 行 → P2 5 个 / 2,226 行;
P3 的 29 个 / 4,814 行**刻意不做**并已写明理由)。比把已有 19 个文件从 88% 推到 95% 值钱得多。
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、监听按代次重建、退避这些**语义在真机上极难复现**;这一族的 B3("顺手全量清一遍把别的 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.1.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「模块约定」里 B3 那条实证坑:*"顺手全量清一遍"等于把别的在用
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 + .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@@;
每步都要 ```build 0/0 + 21+ 套件全绿 + --update-baseline + 补建 @@TC-HOST-*@@ 族@@(族数现在 16,
三块做完应为 19),并且**先补族再往文档里引这些号**,否则 @@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`。
### 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)+ `@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 状态 + 内容页 11 页像素黄金参考)覆盖;再写单测是第三份重复投资 | `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`(`@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 那张表里找到归属**。
找不到归属的(既没进分母、也不属于"刻意不做")才是真窟窿。
## 变更记录
| 日期 | 说明 |
|---|---|
| 2026-10-23 | 新建。落地 `scripts/check_test_coverage.py` 与 `scripts/test_coverage_baseline.json`:首次实测 16 套全通过,并集 2552/2893 行(88.2%)、函数 193/207(93.2%),在测 19 文件 / 未触及 125 文件。同步销项 `docs/问题记录/项目审查与优化建议报告.md` §4.8 空白 ①(覆盖率口径)与 §5.5 第 5 条前半、§0.1 / §0.4 / §7 对应行;`malloc(` 正则那条**同日也落地**(`coding-standard` CODE-RULE-20,见 `docs/门禁说明/coding-standard.md`)。**同日补 §2.1**:用户问「为什么不用 GCC」,实测本机 WSL2 Ubuntu 有 gcc/gcov 15.2.0,16 套单测在 gcc 下**全部编过且全绿**、`gcov -b -c` 确实给出分支覆盖(`nand_ftl.c` 分支 41.40%)——但换编译器=换分母(`sha256.c` gcc 105 行 vs MSVC 114 行),故默认通道不变。 |
| 2026-10-23 | **用户裁定:本仓只留 MSVC 一条覆盖率通道**,不建 gcc/WSL 第二条("后面可能引入更多问题")。实测数据与三条代价保留在 §2.1 当决策依据,§7 第 1 条把分支覆盖改记为**已接受缺口**,§8 下一步删掉"建通道"并写明重开该裁定的前提(必须先答"两份分母怎么对齐")。脚本与基线**不受影响**,仍是 `mscoverage` 一条路 |
| 2026-10-23 | **P1 第①批落地**(net_cfg / ftp_cfg / prot_set_store / task_prof 四个模块,104 条断言):
套件 16→20、`files_tested` 19→23、分母 2,893→3,163;照出并修掉 `net_cfg` 的 WAIT 契约违背与
"定长 11 字符"错记(详见 §9 落地进度) |
| 2026-10-23 | 新增 **§9 建桩优先级清单**:对 47 个未触及 `.c`(9,657 行)逐个画像(有效代码行 / 函数数 /
逻辑度=1−硬件符号密度 / 要桩的外部头 / 可复用现有桩 / fix 提交数),分为 **P1 13 个 2,617 行、
P2 5 个 2,226 行、P3 29 个 4,814 行明确不做**(四条对得上:13+5+29=47、三段相加=9,657);
§8 下一步第 1 条改为指向本节 |
+2 -1
View File
@@ -417,7 +417,7 @@ git grep -n '<旧字面量>' # 零残留
| 改动 | 动机 | 对比例的影响 |
|---|---|---|
| 主机侧单测建 **9 族 `TC-HOST-<模块码>`**,属主登记处 = `test/host/<目录>/*`(代码即登记处) | 实测这些号在代码目录外**零消费者**(唯一"外部引用"是 `id_aliases.json` 自己),逐号补文档只会造出没人读的复述层 | 悬空错误 −508 中的 430(**口径修正,不是仓库变好**) |
| 主机侧单测建 **9 族 `TC-HOST-<模块码>`**,属主登记处 = `test/host/<目录>/*`(代码即登记处;**2026-10-23 起同一口径新增 4 族,现 13 族**,见 §8.4 变更记录) | 实测这些号在代码目录外**零消费者**(唯一"外部引用"是 `id_aliases.json` 自己),逐号补文档只会造出没人读的复述层 | 悬空错误 −508 中的 430(**口径修正,不是仓库变好**) |
| `subitem_ok`:小写子项后缀(`TC-TIMESYNC-003b`)母号在册即算已定义 | §4.1 明允许小写后缀,是合法形态;双向追溯仍按完整字面量报差异,可见性不丢 | 悬空 −74(同上,属口径) |
| **`snapshot_only`**:§4.3 冻结历史快照族(`N-RPT`/`D-RPT`/`S-RPT`/`R-RPT`/`F-RPT`/`P-AUDIT`/`A-AUDIT`/`B-AUDIT`)**不参与"定义位优先"** | 曾试过让定义位优先无差别生效:`N-21`(登记簿条目已销项)被审计报告里同号的另一码事"接管",门禁转而去报 ⑥"请写报告文档名"——**照做就制造 §1.2 的 `N-31` 撞号**;实测该做法使校验 6 从 266 暴涨到 1437、比例假崩到 68.7% | 校验 6 回到 269(与 B 层前持平) |
| **补属主**:`docs/任务规划/README.md` 加入 `N`/`S-LDG`/`T-LDG` 族的 `owner_docs` | 这是**我的配置漏项**:活登记处在 README(`N-21` 出现 2 次),我原先只登记了 `登记簿.md`(`N-21` = 0)→ 真条目被误判悬空 74 处 | 悬空 −74(**修 bug,不是口径**) |
@@ -556,6 +556,7 @@ git grep -n '<旧字面量>' # 零残留
| 日期 | 变更 | 说明 |
|---|---|---|
| 2026-10-23 | **主机侧单测族 +4(现共 13 族)** | 覆盖率口径 `docs/测试说明/主机单测覆盖率口径.md` §9 P1 第①批新建 `test/host/{net_cfg,ftp_cfg,prot_set_store,task_prof}` 四个模块,按 2026-10-21 批 3 的同一 B 层口径补建 `TC-HOST-{NETCFG,FTPCFG,PROTSTOR,TASKPROF}`(代码即属主登记处、`allow_orphans`、游标按实测最大号 056/014/016/020)。**验证了这条口径真的在拦东西**:建族前,文档里引用 `TC-NETCFG-028` 被判「台账之外的新账」而卡提交;建族后 `--strict` 通过 |
| 2026-10-14 | 新增 P-7 与引用可读性规则 | §2 加 **P-7「编号不语义,引用必语义」**(编号本体保持无语义并冻结,但正文引用必须带语义括注、禁裸 ID 独担信息)→ 六条原则变**七条**;§5.1 加前置步"先问能不能不加号";新增 **§5.6「编号密集文档的对照节(强制)」**(≥3 个编号族必须加「导读:编号与术语」);§4.1 补登记 **`E\d+`**(N34 自愈实验编号族,已封闭)并把 `N-\d{2}` 的在册范围订正为「曾用最大 `N-33`,现存至 `N-29`」;§1.2 悬空引用示例补 `N-32`;§7.1 同步订正。起因:`docs/网络/网络自愈架构设计.md` 合并与导读节实践暴露"裸编号引用不可读",用户裁定以后尽量用语义标识 |
| (规划稿) | 建立本文档 | 全库扫描 1,385 个不同编号 / 19,306 处出现后定稿的两层编号体系(全局编号 + 文档内编号)、六条原则、目标前缀总表、三批迁移映射与 `check_ids.py` 门禁设计;用户决策:`E-xx` 冻结保留、严重度档位词 `P0~P3` 不变、文档落 `docs/编号规范.md`、阶段 1 三批全做 |
| 2026-10-21 | **批 5:追溯器口径补齐(未改任何号)** | `check_test_traceability.py` 加入与 `check_ids.py`/`rename_ids.py`/`doc-links` 同源的「变更记录 / `[示例]` / `[计划]` 行不参与比对」豁免,并新增 `frozen_ids` 留痕(`--verbose` 可列);`DOC-ONLY` 38 → 29、总差异 45 → 36,**交集 260 → 260 不变**(证明没吞掉真对应)。批 5 原计划的 8 处文档侧改名**被证据否掉**(全在变更记录行、别名批 2 已登记),未落盘;经过与教训、以及剩 29 处的三类真问题都写进 §8.3 批 5 段 |
+28 -2
View File
@@ -1,8 +1,10 @@
# `coding-standard` — 编码规范 V1.0 的可机械化子集
> 一句话:它只查规范里**能用形状判定的 19 条**(命名、宏、注释块、括号、禁用函数、goto、
> 一句话:它只查规范里**能用形状判定的 20 条**(命名、宏、注释块、括号、禁用函数、goto、
> 递归、可变参数……)。规范里"名字要达意、函数要短、职责要清"这些它一条都管不了。
> 规范正文 = `docs/代码规范/嵌入式C语言代码规范_V1.0.md`。
> 规范正文 = `docs/代码规范/嵌入式C语言代码规范_V1.0.md`。**第 20 条(禁 C 库堆分配)出处不在规范正文**
> —— 规范 §6.1 的示例自己就写着 `malloc`/`free`(通用 C 模板遗留,已同日在示例下加批注),
> 它的出处是平台约束:MicroLIB 的 C 堆只有 512 B。
- 配置:`entry: python scripts/check_coding_standard.py`、`pass_filenames: true`、
排除 `test/`、CubeMX 生成的 `Src/`、`Inc/`、第三方与 `Lib/`。
@@ -29,6 +31,7 @@
| CODE-RULE-17 | 非 static 函数必须在配对 `.h` 声明(无配对头则跳过) | 2.2 |
| CODE-RULE-18 | 禁止 Yoda 条件(字面量必须在运算符右侧) | 6.3.1 |
| CODE-RULE-19 | 函数定义左大括号与右括号同行 | 4.4.2 |
| CODE-RULE-20 | **禁止 C 库堆分配** `malloc/calloc/realloc/strdup/strndup/free/aligned_alloc` | 平台约束(**非规范 V1.0 条款**) |
规则号里 **CODE-RULE-14 是缺的**:条款作废后编号不回收,留着是为了让历史文档里的"14"不指向别的东西。
@@ -37,6 +40,11 @@
- 这 19 条如果靠人看,评审成本极高且必然漏;机械化之后评审可以把注意力留给机器管不了的部分。
- 有硬约束背景的两条特别值:**禁止递归**与**禁止可变参数**——本仓 `DBG_*` 每个调用点内联约
90 B Flash + 216 B 栈,栈与 Flash 都是红线资源;`g_` 前缀和指针初始化则是最容易埋雷的静态状态。
- **CODE-RULE-20 有真事垫背**(2026-10-23 落地):MicroLIB 的 C 堆由 `startup_stm32f407xx.s` 的
`Heap_Size EQU 0x200` = **512 B**,而它的踩坑方式是**模拟器全对、真机必炸**:配置引擎 2026-09-18
那次堆分配纠偏(4 处 KB 级 `malloc` 落在 C 堆上、真机返 NULL,见
`docs/子系统设计/配置文件设计说明.md` v1.11 记录)与 lftpd 的 `malloc` 隐患(`docs/内存管理/RAM使用分析与优化方案.md` §4.2.1)都是
**上线后才发现**的那类。宿主照不出来的缺陷,靠评审等于没管。
## 3. 触发面 / 看不见什么
@@ -45,6 +53,10 @@
- **后果**:同一份规范对 `test/board/`(编进固件的 6300+ 行)不生效,对 CubeMX 生成区也不生效。
`test/` 的静态检查由 `test-cppcheck` 兜一半,形状规范没人管。
- 逐文件判定:跨文件的事实(声明在 A、定义在 B、调用在 C)它只看到眼前这一个文件。
- CODE-RULE-20 跑在**掩码后**的文本上(注释与字符串里写 `malloc` 不报);换来的代价是:
把分配藏进**宏展开**里它也看不见。
- CODE-RULE-20 有一处**点名豁免**(`simulator/sim/cfg_file_store_sim.c` 的 `malloc`/`free`):
那是平台分配后端的**宿主侧实现**(MCU 侧同名后端用 `pvPortMalloc`),不是漏网。
## 4. 报错怎么读
@@ -72,6 +84,10 @@ Drivers/BSP/X/x.c:42 [CODE-RULE-09] 函数 foo_bar 缺少函数注释(应含
5. `test/` 与生成区整目录不查(§3),这三类目录的规范只靠人。
6. 逐文件 + 正则形状:把违规写进字符串字面量、注释、宏展开里就看不见;反过来说,宏里真正的
违规代码也可能看不见。
7. CODE-RULE-20 **大小写敏感**:这是特性也是盲区——`pvPortMalloc`/`vPortFree` 因此不误报,
但写成 `Malloc(` 就绕过了(同形后缀也一样:`buf_free(` 因词边界不成立而不算违规)。
8. CODE-RULE-20 **只管堆分配函数名**:`alloca` 与变长数组(VLA)是**栈**上的坑,不归它;
分配不释放(泄漏)、释放后继续用(UAF)也不归它——本条只判"别碰 C 堆"这一件事。
补录(判据细节,迁移自原手册):
@@ -86,15 +102,25 @@ Drivers/BSP/X/x.c:42 [CODE-RULE-09] 函数 foo_bar 缺少函数注释(应含
- **没有基线**:硬拦截,新写的代码必须合规;因此**放宽任何一条都会立刻降低约束**,需谨慎。
- 豁免方式只有两种:脚本里的显式豁免名单(按名字形状,如 `HAL_` 回调)、或改代码。
- CODE-RULE-20 的豁免是脚本里的 `HEAP_ALLOC_ALLOWLIST`,按**文件路径 + 具体函数名**双条件放行,
现仅一条:`simulator/sim/cfg_file_store_sim.c` → `malloc`/`free`(平台分配后端的宿主侧实现,
设计依据见 `docs/子系统设计/配置文件设计说明.md` 的 MCU 端/宿主端两段式后端)。
⚠ 刻意**不做目录级豁免**:下次谁要 `malloc` 就得往这里加一行,评审看得见。
## 8. 改这个门禁本身
- 新增规则前先在**全库跑一次**统计违规数;有存量的规则要么先清存量,要么走"基线只减不增"
的模式(参考 `bsp-err`、`layer-deps`),不要直接挂上硬拦截卡住所有人。
- 规则作废**不回收编号**(见 CODE-RULE-14)。
- 新增规则前先全库普查违规数(本节上一条已写)。CODE-RULE-20 落地时的普查结果:钩子范围内
C/H 共 **151 个文件**,命中只有 `cfg_file_store_sim.c` 那两行(即 §7 那条豁免)→ 直接**硬拦截、
零基线**;`pre-commit run coding-standard --all-files` 实测 Passed。自证:临时样例里
`malloc/calloc/free` 各报一处,`pvPortMalloc/vPortFree/buf_alloc(`、注释、字符串 全部不误报。
## 9. 变更记录
| 日期 | 变更 |
|---|---|
| 2026-10-23 | 首篇逐项文档(19 条规则逐条列出,含 CODE-RULE-14 缺号说明) |
| 2026-10-23 | 新增 **CODE-RULE-20 禁 C 库堆分配**(硬拦截、零基线;唯一豁免 = 平台分配后端的
宿主侧实现文件)。出处是 AGENTS.md 平台约束而非规范 V1.0;规范 §6.1 示例已同日加"本仓禁止"批注 |
+1 -1
View File
@@ -34,7 +34,7 @@
## 3. 触发面 / 看不见什么
- 只看 `RW_IRAM1` 与 `RW_CCM` 两个**静态区域**;不看:栈运行时水位、FreeRTOS 堆运行时余量、
堆碎片、MicroLIB 的 512 B C 堆(那条纪律是"自研代码禁用 `malloc/free`",靠 `AGENTS.md` 铁律与评审)。
堆碎片、MicroLIB 的 512 B C 堆(那条纪律是"自研代码禁用 `malloc/free`",**2026-10-23 起由 `coding-standard` 的 CODE-RULE-20 机器拦截**(见 `coding-standard.md` §1/§7),不再只靠评审)。
- `.map` 缺失即跳过 → 无构建的机器(含远端 CI)这一门不产生信息。
## 4. 报错怎么读
@@ -19,7 +19,7 @@
| 文档与治理 | 🟢 良好 | 39 个提交钩子 + 16 套主机单测;工程 md 91 份;`doc-links` 全库 0 问题 | 新增 `编号门禁增量拦截` 与内存快照回填常态化 |
| 内存与 Flash | 🟡 可控但已换位置 | SRAM1 **57.4%**(余 55,808 B)、Flash **22.1%**、**CCM 87.9%(余 7,904 B)** | **红线从 SRAM1 迁到 CCM**(堆 2026-10-11 迁 CCM + 池 40→16 KB 后 SRAM1 已宽松) |
| 可靠性与自恢复 | 🟢 良好 | 4 个 Fault 全采集现场;IWDG 五面 `required_mask`;复位原因与 `[STACK]` 水位常开 | 台架复跑持续 PASS(今日真机 708 ms 起网、无 `[ERR]`/Fault) |
| 测试与验证 | 🟡 半办 | 16 套主机单测 + `ui-golden`/`font-charset` 入门禁;TC 双向追溯 593∶271 交集 265 | **仍无覆盖率**、`coding-standard` **仍无 `malloc` 正则** |
| 测试与验证 | 🟡 半办 | 16 套主机单测 + `ui-golden`/`font-charset` 入门禁;TC 双向追溯 593∶271 交集 265; **§5.5 两条补强已全部落地**(2026-10-23:覆盖率口径 `../测试说明/主机单测覆盖率口径.md` + `malloc(` 正则 `coding-standard` CODE-RULE-20) | 覆盖率**未挂 pre-commit**(口径文档 §8 三条理由);**分支覆盖已裁定不做** (§2.1 决策记录)→ 现在真正的半办是**覆盖面**:首登照出 **125 个生产源文件从未进过任何主机单测** |
| UI 标准化 | 🟢 基本落地 | `ui_tokens.h`(L0 令牌)+ `ui_page_registry.c`(描述符注册表)+ 7 个内容页 + 两枚 UI 钩子 | **U1/U3/U4 从"未做"变为已落地**;U2 只有 `ui_w_records` 一个组件 |
### 0.3 主线建议(2026-10-22)
@@ -30,8 +30,11 @@
92% 钩子会先报警。SRAM1 与 Flash 都已不是卡点。
3. **结构性内存优化整簇暂缓**(R-2 后半 / R-3 / R-4 / R-6,用户裁定 2026-10-15),治理面常开;
重开条件见 §5.3。今天又收口一项:**F-4 判定不做、F-6 已落地、F-5 降级观察**(§5.4)。
4. **验证的下一步不是加用例而是补面**:覆盖率口径 + `malloc(` 门禁正则(§5.5 第 5 条),把"读代码
才发现"的缺陷类型(N-12/N-13 那类)变成机器拦截。
4. **补面两条已全部落地(2026-10-23)**:覆盖率口径(`../测试说明/主机单测覆盖率口径.md`)+
堆禁令机器拦截(`coding-standard` **CODE-RULE-20**,§5.5 第 5 条)。**剩下的量在覆盖面**:覆盖率首登
照出 **47 个 `.c`(9,657 有效代码行)+ 78 个 `.h` 从未进过任何主机单测编译单元**(`net_socket.c` 1,251 行最大)。
建桩比把在测的 19 个文件从 88% 推到 95% 值钱得多,但**不是 125 个都该做**:优先级与"刻意不做"的清单已按逐文件
画像写好 → `../测试说明/主机单测覆盖率口径.md` §9(P1 13 个 / 2,617 行、P2 5 个 / 2,226 行、P3 29 个 / 4,814 行不做)。
5. **仓库体积重心在外部资料**(`docs/芯片手册` 717 文件 / 39.5 MB,占 docs 的 87%;`.git` 248 MB),
见 §3.3 D-1 与登记簿 N-29——已决策暂不处理,但**推送/对外协作前必须重评**。
@@ -67,7 +70,10 @@ B0h 验 `ECC_EN`**——原先那两行的注释自己写着"否则后续 I/O
| 存储/日志 | P2-10 ❌(N-25) | 卷锁现有 5 个使用者(配置原子写 / 日志 / SOE / lftpd_io / defaultTask),容量轮转删除与 UI 读并发仍未全覆盖 | 登记簿 S-5 面(卷锁覆盖面) |
| 仓库 | D-1 ❌(N-29) | jpg 707 个 / 37.3 MB;`docs` 45.3 MB 里芯片手册占 39.5 MB;`.git` **248 MB** | 降采样 / 拆 submodule / LFS 三选一 + 历史重写;已决策暂不做 |
| UI | U2 残余(U 簇) | 组件库只有 `ui_w_records.c/h` 一个;其余仍是页内自绘 | 按产品需求决定要不要继续抽组件 |
| 测试 | 覆盖率 ❌ / `malloc` 门禁 ❌ | 无覆盖率脚本;`check_coding_standard.py` 无 `malloc(` 正则 | 见 §5.5 与 §4.8 |
| ~~测试~~ ✅ **两条全部闭环 2026-10-23**(覆盖率口径 + 堆禁令 CODE-RULE-20) | 覆盖率:`scripts/check_test_coverage.py` + 基线 `scripts/test_coverage_baseline.json`(口径与**盲区**见 `../测试说明/主机单测覆盖率口径.md`)——首登 16 套并集 2552/2893 行(88.2%)、**未触及 125 个文件 = 47 个 `.c`(9,657 行)+ 78 个 `.h`(2,182 行,多为声明/宏)**,
| 待办池是那 47 个 `.c`;分批与「刻意不做」见 `../测试说明/主机单测覆盖率口径.md` §9。堆禁令:`check_coding_standard.py` **CODE-RULE-20**(硬拦截、零基线;唯一豁免 = `simulator/sim/cfg_file_store_sim.c`
| 的平台分配后端宿主侧实现,见 `../门禁说明/coding-standard.md` §7) | 无(残余三项**已归口或已裁定**:覆盖率挂
| pre-commit 的前置写在口径文档 §8;分支覆盖**不做**(§2.1 决策);扩大覆盖面按 §0.3 第 4 条) |
| CI | T-7 ❌ | 远程 CI 只跑本机一台 runner,且不跑 `@build` | 保持现状即可;扩机器前重读 `docs/CI/README.md` |
## 1. 审查方法与证据基线
@@ -226,8 +232,15 @@ defaultTask 单属主;原子写经卷锁;旧档迁移已删除。**仍列为
- 主机侧 **16 套**单测(提交门禁实跑);板级 `test/board/` 19 文件、套件开关互斥受 `test-suite-switch` 管
- TC 双向追溯:代码侧 593 号 / 文档侧 271 号 / 交集 265,差异 13 处(当前仅告警,未升门禁)
- **两处空白**:① 无覆盖率口径;② `check_coding_standard.py` 没有 `malloc(` 正则——"自研禁用 C 库堆"
这条铁律目前只靠人读 + 模拟器照不出来(MicroLIB C 堆仅 512 B)
- **两处空白已全部闭合(2026-10-23)**:① 覆盖率口径 → `scripts/check_test_coverage.py`(mscoverage 动态
插桩 + Cobertura 并集)+ 基线 `scripts/test_coverage_baseline.json`;口径 / 工具链实测 / **九条盲区**见
`../测试说明/主机单测覆盖率口径.md`(首登 2552/2893 行、19 文件在测、**未触及 125 文件**)。
② C 库堆禁令 → `check_coding_standard.py` **CODE-RULE-20**(拦 `malloc/calloc/realloc/strdup/strndup/
free/aligned_alloc(`;硬拦截、零基线;唯一豁免 = 平台分配后端的宿主侧实现
`simulator/sim/cfg_file_store_sim.c`,见 `../门禁说明/coding-standard.md` §7)。禁令本身的事实源仍是
AGENTS.md 平台约束与 `../内存管理/RAM使用分析与优化方案.md` §4.2.1——**为什么非得机器拦**:MicroLIB
的 C 堆只有 512 B,而**模拟器(宿主 CRT 堆)完全正常、照不出来**——配置引擎 2026-09-18 的堆分配纠偏
(4 处 KB 级 `malloc` 落错堆)与 lftpd 的 `malloc` 隐患都是**先上线才发现**的那类
- 破坏性套件(`ENABLE_DESTRUCTIVE_TESTS`)有**持久代价**(NAND 坏块记账不可逆),默认关闭是正确设计
## 5. 内存与 Flash(2026-10-22 实测收口)
@@ -285,8 +298,12 @@ defaultTask 单属主;原子写经卷锁;旧档迁移已删除。**仍列为
2. `check_mem_numbers.py --scan/--write`:文档里的内存数字一律由 `.map` 反查,禁止手填
3. 改栈 / 加大局部变量前后各跑一次 `stack_report.py`(静态 × 真机 `[STACK]`)
4. 子系统配额与 CCM 台账 → `../内存管理/RAM预算台账.md`;趋势 → `scripts/ram_baseline.tsv`
5. **仍未落地的两条补强**:`check_coding_standard.py` 加 `malloc(` 正则(把 C 库堆禁令变成机器拦截)、
覆盖率口径(现在只有"跑过哪些 TC",没有"覆盖到哪些分支")
5. **两条补强已全部落地(2026-10-23)**:`malloc(` 正则 = `check_coding_standard.py` **CODE-RULE-20**
(硬拦截、零基线,判据与豁免见 `../门禁说明/coding-standard.md` §1/§7)。
另一条「覆盖率口径」✅ **已落地 2026-10-23** → `../测试说明/主机单测覆盖率口径.md`(口径 / 基线 / 盲区
一处写完,此处不复述)。⚠ 它落地的是**行与函数**覆盖,**分支覆盖本机拿不到**(无 gcc,MSVC 14.51
已无 `/coverage`)——「覆盖到哪些分支」这一半**已裁定不补**(不为它引入第二套工具链,见
`../测试说明/主机单测覆盖率口径.md` §2.1),当前补偿 = TC 双向追溯 + 用例显式列边界值
## 6. UI 界面标准化:从方案到已落地
@@ -331,7 +348,7 @@ W6 簇按 U4 → U1 → U3 → U2 的顺序执行完毕(U2 停在"够用即止
|---|---|---|
| ~~W3 预算与内存~~ ⏸ | R-9 ✅ / R-2 后半、R-3、R-4、R-6 暂缓 | 暂缓期间无验收动作,观测照常 |
| W4 结构债 | N-14 ⏸、F-5 🟡、S-1/S-2/S-4/S-5、N-25 | `--strict` 已默认;S-5 契约/门禁已落地并收编三模块,`consume-result` 已把"丢弃返回值"纳入机械检查(基线 19→1→38;剩 GD5F 码集收编、C4 快照、37 条丢弃点、标坏策略) |
| W5 验证常态 | 覆盖率、`malloc` 正则、TC 差异 13 处升门禁 | 逢窗口顺手做 |
| W5 验证常态 | ~~覆盖率~~ ✅、~~`malloc` 正则~~ ✅(均 2026-10-23)、TC 差异 13 处升门禁 | 剩 TC 一条 |
| W6 UI 扩展 | U2 残余、U5 页量 | 随产品需求,不再单列专项 |
| W7 仓库决策 | D-1(N-29) | 已决策暂不处理;对外交付前重评 |
@@ -448,6 +465,25 @@ admin/admin(SHA-256 `pwd_hash`)→ 该脚本**必然登录失败**;它是
| | 数字联动照做:两份 `data/` 快照重跑、`ram_baseline --update/--write`、`check_mem_numbers --write`、FLASH 与
任务栈两张配图重算重生成(逐文本复查越界 = 0)、本文 §1.3/§5.1/§5.4 与部署文档 §3.3 同步;§0.4 的 S-2 行按
「完成即销项」改写、§9 拆出 S-2 行标 ✅。 |
| **2026-10-23** | **执行 §5.5 第 5 条后半:C 库堆禁令变成机器拦截**(§4.8 空白 ②,CODE-RULE-20) | 按本文件 §8 的既有纪律**先全库普查再动手**:`coding-standard` 钩子范围内 C/H 共 **151 个文件**,`malloc` 族只命中
| | | `simulator/sim/cfg_file_store_sim.c` 两行(`cfg_store_alloc`/`cfg_store_free` = 平台分配后端的**宿主侧实现**,
| | | 设计依据写在 `../子系统设计/配置文件设计说明.md` 的"MCU 端 = pvPortMalloc、模拟器端 = 宿主 CRT")——**不是漏网**,
| | | 所以零基线、直接硬拦截;豁免走脚本里的 `HEAP_ALLOC_ALLOWLIST`(**文件路径 + 函数名**双条件),刻意不做目录级豁免:
| | | 下次谁要碰 C 堆就得往那张表加一行,评审看得见。判据 = 掩码后文本上匹配 `\b(malloc|calloc|realloc|strdup|strndup|
| | | free|aligned_alloc)\s*(\(`:大小写敏感 ⇒ `pvPortMalloc`/`vPortFree` 不误报;词边界 ⇒ `dhara_nand_is_free(`、`buf_free(`
| | | 不误报;注释与字符串里写 `malloc` 不报。**自证**:临时样例三处(`malloc/calloc/free`)各报一次、五种陷阱形态全部不报;
| | | `pre-commit run coding-standard --all-files` 实测 **Passed**(= 全库零存量)。
| | | **顺带消掉一处自相矛盾**:规范 V1.0 §6.1 的示例自己写着 `malloc`/`free`(通用 C 模板遗留),已在其下方加
| | | 「本仓例外,别照抄」批注并把事实源指向 AGENTS.md 平台约束;`docs/门禁说明/mem-budget.md` 里"这条纪律只靠评审"
| | | 的旧说法同步更正。AGENTS.md 平台约束条加"已机器拦截"一句。**本轮零 `.c/.h` 改动**(只动门禁脚本与文档),
| | | 未跑 `@build`、未占 build/bench 锁;详解归 `../门禁说明/coding-standard.md`(本处不复述判据) |
| **2026-10-23** | **执行 §5.5 第 5 条前半:覆盖率口径落地并销项**(§4.8 空白 ①) | 新增 `scripts/check_test_coverage.py`(纯标准库)+ 基线台账 `scripts/test_coverage_baseline.json`。**工具链是实测出来的、不是查到的**:本机 PATH 上 gcc/gcov/clang/llvm-cov/lcov/gcovr **全无**;`cl /coverage`、`/fsanitize=coverage` 在 MSVC 14.51 已被砍——**只报 `warning D9002 忽略未知选项`,然后照常编译成功**(拿它做覆盖率=静默假数据,比报错更坏);`Team Tools\Dynamic Code Coverage Tools\CodeCoverage.exe`(17.0)的 `collect`/`analyze`/`instrument` 一律只打印「用法: CodeCoverage <command>」。最终走 **mscoverage(`Microsoft.CodeCoverage.Console.exe` 18.x)`collect -f cobertura`** 的**加载期动态插桩**,并实测它**原样透传子进程退出码**(子进程 `return 3` → `collect` 也返回 3)→ 于是「插桩运行下仍必须 PASS」可以当硬条件用。 |
| | | **口径**:只算 `App/**` 与 `Drivers/BSP/**` 的行/函数、多套单测取**并集**、生成物(文件头写「自动生成 / 请勿手改」)不进分母、分母一律来自插桩报告**不猜行数**;配置零复制——脚本 import 各运行器的 `TestBuild`、开关取 `unit_test_harness._BASE_FLAGS`,只追加 `/Zi /Od` 与 `/link /INCREMENTAL:NO`,发现规则与 L0.5 同一条 `test/host/**/run_unit_test.py`,**不会漂出第二套口径**。 |
| | | **首登数字(2026-10-23)**:16 套全通过,并集 2552/2893 行(88.2%)、193/207 函数(93.2%)、19 个生产文件在测,**未触及 125 个生产源文件 / 约 11,839 行**(`net_socket.c` 1,251 行最大)——最后一栏才是这套口径真正照出来的东西。**门禁自证**:人为抬高基线后,行 / 函数 / 总计三条回退路径都如期报 REGRESS 且退出码 1;`--module` 子集运行不会假报总计回退(首版就会,已修)。**刻意未挂 pre-commit**:三条理由与可直接粘的 YAML 片段写在 `../测试说明/主机单测覆盖率口径.md` §8,接线者照 §8 落地即可。 |
| | | **同日另一半也落地**(§4.8 空白 ②):`check_coding_standard.py` 加 **CODE-RULE-20** 禁 C 库堆分配,
| | | 硬拦截零基线(全库普查 151 个 C/H 只有 `cfg_file_store_sim.c` 两行命中 = 平台后端的宿主侧实现,
| | | 点名进 `HEAP_ALLOC_ALLOWLIST`,刻意不做目录级豁免);规范 §6.1 示例自己写着 `malloc/free` 的
| | | 自相矛盾也同日加了"本仓禁止"批注。**只剩分支覆盖**一条。分支覆盖**不是拿不到**:同日被用户问住后复测,本机 WSL2 Ubuntu 有 gcc/gcov 15.2.0,16 套单测在 gcc 下**全部编过且全绿**、`gcov -b -c` 出分支数据(`nand_ftl.c` 分支至少走过一次 41.40%);但换编译器就是换分母(`sha256.c` gcc 105 行 vs MSVC 114 行)→ **用户裁定只保留 MSVC 一条通道**(多一套工具链=多一套分母 +多一本台账 + 多一个"这台机器有没有 WSL"的未决项),分支覆盖**记为已接受缺口**、不是待办。实测表与决策依据见 `../测试说明/主机单测覆盖率口径.md` §2.1。本轮只加 `.py`/`.json`/`.md`,无 `.c/.h` 改动,不重跑 `@build`;文档面动 §0.1 / §0.4 / §4.8 / §5.5 / §7 与 `docs/README.md` 索引各一处。 |
| **2026-10-23** | **术语迁移**:`bsp-err` 的四项检查从 `R1/R2/R3/R4` 改成语义标签 | 用户一句「R4
| | 到底是什么」问出来的。这四个号**是我自己造的**,而 `R1~R8` 在本仓是"专项风险"的裸形编号
| | (`id_registry.json` 已登记那一族,保护规划 `R1~R8`、看门狗 `R1~R7` 的风险表都在用),`R-2`
@@ -657,3 +693,8 @@ USER CODE 区内只留「读 RCC_CSR → 写 RMVF 清除」两句、顺序约束
| **R-9(`LV_MEM_SIZE` 40→16 KB,登记簿 N-27 原 W3 的第一步,2026-10-15 已执行;该簇其余部分同日整簇暂缓)** | 取值依据与再降 8 KB 的前置写在 `Lib/lv_conf.h` 与 `simulator/lv_conf.h` 的池注释;实测水位 → `../内存管理/RAM使用分析与优化方案.md` §2.6.2 与 §阶段 1.1(已执行);解锁链第 ① 步 ✅(后续步骤暂缓)→ `../内存管理/RAM预算台账.md` §3;池容量口径 → `../UI设计/UI_设计架构.md` §1.2、`../UI设计/UI_界面制作流程.md` C4 |
| **N-12 / N-13**(dhara 移植层 `is_free` 精度 + HAL 越界守卫,登记簿 **N-21**,2026-10-15 已销项) | `../架构设计/STM32F4-Base存储架构说明.md` §5.3(移植层两条硬约束:整页精确的原因链 + 错误码取舍与"为什么不会静默清卷")+ `Drivers/BSP/GD5F2GQ5UE/nand_ftl.c` 的 `ftl_page_in_range` / `ftl_block_in_range` / `dhara_nand_is_free` 注释 + 主机侧单测 `test/host/nand_port/`(含"旧算法只看页首 64 B"的对照用例,防止将来被改回去) |
| **N-16**(日志读写放大,登记簿 **N-24**,2026-10-15 已销项) | `../子系统设计/运行与自检记录设计说明.md` §4.4(写侧批量合并 + **掉电语义不变**的论证 + 失败回投不丢条目 + 容量轮转只在慢路径)与 §4.5(读侧 8 条窗口 + 写者代数失效 + API 不变)+ `App/log/sys_log_mcu.c` 的 `s_format_rec` / `s_append_rec` / `s_append_many` / `s_read_raw` 注释 |
| **§4.8 两处空白(①无覆盖率口径 ②无 `malloc(` 正则)+ §5.5 第 5 条整条(2026-10-23 全部销项)** | `../测试说明/主机单测覆盖率口径.md`(口径 / 工具链实测 / 统计面 / 门禁姿态 / **九条限制与盲区** / 未挂钩子的三条理由与待粘 YAML)+ `scripts/check_test_coverage.py`(`--list` / `--module` / `--update-baseline` / `--md` / `--json`)+ 基线台账 `scripts/test_coverage_baseline.json`(首登 2552/2893 行、未触及 125 文件)。
空白 ② → `docs/门禁说明/coding-standard.md`(CODE-RULE-20 判据 / 唯一豁免 / 盲区 / 落地前普查数)+
`scripts/check_coding_standard.py`;禁令本身的事实源仍是 AGENTS.md 平台约束与
`../内存管理/RAM使用分析与优化方案.md` §4.2.1;另 `../代码规范/嵌入式C语言代码规范_V1.0.md` §6.1 的示例
自己写着 `malloc/free`(通用 C 模板遗留),已同日在示例下加"本仓禁止"批注 |
+1
View File
@@ -42,6 +42,7 @@
| 脚本 / 文件 | 作用 |
|---|---|
| `ci_local.py` | **本地跑与 CI 等价的门禁**(L0 pre-commit + L0b 提交信息 + L0.5 主机侧单测):自动选用装了 pre-commit 的解释器;无 `.map` 时自适应跳过 `mem-numbers`;退出码 0/1/2 与 CI 对齐。CI 定义见 `../.gitea/workflows/ci.yml`,覆盖边界见 `docs/测试说明/测试开发标准流程.md` §9 |
| `check_test_coverage.py` | 主机单测**覆盖率**(2026-10-23):mscoverage 动态插桩跑 `test/host/**` 全部 16 套,统计自研生产源码行/函数覆盖(并集)+ 与 `test_coverage_baseline.json` 比回退;`--module/--update-baseline/--md/--json/--list`;**不在 pre-commit 里**,入口是 `ci_local.py --coverage`(口径见 `docs/测试说明/主机单测覆盖率口径.md`) |
| `ci_gitlint_range.ps1` | CI 侧**提交信息门禁**:逐条校验本次 push/PR 引入的提交(`--all-files` 跑不到 `commit-msg` stage,故必须单独守) |
| `ci_find_python.ps1` | CI 侧 Python 探测(候选顺序须与 `ci_local.py` 同序同步,否则 CI 与本地判定会分叉) |
| `ci_runner_bootstrap.ps1` | **本机 runner 运维脚本**(体检 `-Status` / 干跑 `-DryRun` / 部署 `-SetEnv -StartNow`):装"等 Gitea 就绪 + 崩溃自拉起"的看门狗 + 开机计划任务 + 机器级 `DTU_*` |
+31 -3
View File
@@ -23,6 +23,7 @@ C 语言代码规范检查(《嵌入式C语言代码规范_V1.0》可机械化
CODE-RULE-17 (2.2) 非 static 函数必须在配对 .h 中声明(HAL_ 豁免;无配对头则跳过)
CODE-RULE-18 (6.3.1) 禁止 Yoda 条件(字面量/常量必须在运算符右侧)
CODE-RULE-19 (4.4.2) 函数定义左大括号必须与右括号同行
CODE-RULE-20 (平台约束) 禁止 C 库堆分配 malloc/calloc/realloc/strdup/free
语义类规则(函数长度、单一职责、错误处理等)无法机械可靠判定,不在此脚本范围。
"""
@@ -41,6 +42,13 @@ C_KEYWORDS = {'if', 'for', 'while', 'switch', 'do', 'else', 'return',
NAME_PAREN_RE = re.compile(r'([A-Za-z_][A-Za-z0-9_]*)\s*\(')
# CODE-RULE-20 的例外:平台分配后端的**宿主侧实现**(AGENTS.md「只用 pvPortMalloc/vPortFree,
# 或把分配收进平台后端」说的就是这种收口点)。只点名到文件 + 只放行这两个函数名,
# 不做目录级豁免——新增一个想 malloc 的文件必须显式加进这里,评审时看得见。
HEAP_ALLOC_ALLOWLIST = {
'simulator/sim/cfg_file_store_sim.c': frozenset({'malloc', 'free'}),
}
def _mask(text):
"""注释与字符串字面量替换为等长空白(保留换行,行号不变),防止误报。
@@ -281,9 +289,29 @@ def check_file(path):
'Yoda 条件(常量应在右): %s' % ' '.join(m.group(0).split())))
# CODE-RULE-19 (4.4.2) 函数定义左大括号必须与右括号同行
for name, ln, _body, _pre, bl, pl in defs:
if bl != pl:
v.append((ln, 'CODE-RULE-19 4.4.2', '函数定义左大括号须与右括号同行: %s' % name))
# CODE-RULE-20 (平台约束,2026-10-23) 禁止 C 库堆分配
# 出处**不在规范 V1.0**(其 §6.1 示例本身就写着 malloc/free,属通用 C 模板遗留,已同日在示例
# 下方加"本仓禁止"批注),而在 AGENTS.md「平台约束」+ docs/内存管理/RAM使用分析与优化方案.md:
# MicroLIB 的 C 堆由 startup_stm32f407xx.s 的 Heap_Size EQU 0x200 限定为 **512 B**,KB 级分配
# 必然返回 NULL,而**模拟器(宿主 CRT 堆)完全正常、照不出来** —— 典型"只有真机才炸",必须机器拦。
# 判据细节(三条都是踩过坑之后定的):
# - 大小写敏感:pvPortMalloc / vPortFree(唯一允许的配对)不会中招;
# - 词边界 \b:dhara_nand_is_free(、buf_alloc( 这类"同形后缀"的自有函数名不会误报;
# - 跑在 masked 上:注释与字符串字面量里提到 malloc 不报(规范示例就是这种命中)。
# 覆盖名比 AGENTS 列的五枚多带上 strndup / aligned_alloc —— 同族同类,漏一个就等于留个后门。
norm = path.replace('\\', '/')
allow = frozenset()
for suffix, names in HEAP_ALLOC_ALLOWLIST.items():
if norm == suffix or norm.endswith('/' + suffix):
allow = names
break
for m in re.finditer(r'\b(malloc|calloc|realloc|strdup|strndup|free|aligned_alloc)\s*\(', masked):
name = m.group(1)
if name in allow:
continue
v.append((_line(masked, m.start()), 'CODE-RULE-20 平台约束',
'禁止 C 库堆分配 %s()(MicroLIB C 堆仅 512 B;用 pvPortMalloc/vPortFree,'
'或把分配收进平台后端)' % name))
return v
+844
View File
@@ -0,0 +1,844 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
主机侧单元测试(L0.5)的**覆盖率**口径、报告与增量门禁(纯标准库)。
做什么:
1. 发现 test/host/**/run_unit_test.py(与 scripts/ci_local.py 的 L0.5 同一套发现规则),
直接 import 各模块运行器、复用其中声明的 TestBuild(**单一事实源**:include 目录、
桩顺序、测试单元路径都不在这里重复一遍);
2. 用**与门禁完全相同的编译开关**(unit_test_harness._BASE_FLAGS)另建一份带 /Zi 调试
信息的产物,交给 Microsoft.CodeCoverage.Console.exe 动态插桩运行;
3. 解析 Cobertura XML,只保留**自研生产源码**(App/**、Drivers/BSP/** 的 .c/.h)的行与
函数命中,对多套单测重复触及的同一文件取**并集**(命中一次即算覆盖);
4. 与 scripts/test_coverage_baseline.json 比对:**已覆盖行/函数只许增不许减**;并打印
自研生产源文件里有多少从未被任何主机单测编译过(未触及面 = 覆盖率的 0 分母盲区)。
为什么要它(2026-10-23,W5 验证常态):
docs/问题记录/项目审查与优化建议报告.md §4.8 记的两处空白之一:无覆盖率口径——
L0.5 只回答“跑过哪些 TC”,回答不了“生产源码里哪些行根本没进过”。用例数与 TC 追溯率
会随时间上升,但覆盖率照的是另一类真相:**新增分支一行没测**(N-12/N-13 那类“读代码
才发现”的缺陷,覆盖率是最便宜的机器照法)。
工具链现实(2026-10-23 本机实测,换机必读):
- **Windows 原生侧没有 gcc / clang / gcov / lcov / gcovr**(全盘扫 0 处)→ gcov 那条最主流的路在
Windows 侧走不通。**但 WSL2 Ubuntu 里有 gcc/gcov 15.2.0**,实测 16 套单测在 gcc 下全部编过、
gcov -b -c 还给出分支覆盖——不用它当默认是因为**换编译器=换分母**(sha256.c:gcc 105 行 vs
MSVC 114 行),详见 docs/测试说明/主机单测覆盖率口径.md §2.1 的决策记录。
- **MSVC 14.51(VS 2026/18)已不认 /coverage**:cl 只报 warning D9002「忽略未知选项」,
然后**照常编译成功** → 拿它做覆盖率等于静默出假数据,绝不可用(/fsanitize=coverage 同)。
- Team Tools\Dynamic Code Coverage Tools\CodeCoverage.exe(17.0)还在但已 deprecated,
且 collect / analyze / instrument 一律只打印「用法: CodeCoverage <command>」——不可用。
- 可用的是 **Microsoft.CodeCoverage.Console.exe**(mscoverage 18.x,随 VS 装在
Common7\IDE\Extensions\Microsoft\CodeCoverage.Console\):collect -f cobertura 直接吃
MSVC 原生 exe + PDB,且**子进程退出码原样透传**(实测子进程 return 3 → collect 也返回 3)。
- 结论:编译期插桩这条路在本机是断的,走**加载期动态插桩**(mscoverage collect)。
口径(一句话版,完整版见 docs/测试说明/主机单测覆盖率口径.md):
行覆盖 = 至少执行过一次的可执行行 / 被插桩的可执行行;
函数覆盖 = 至少有一行命中的函数 / 被插桩的函数;
统计面 = 自研生产源码(App/**、Drivers/BSP/** 的 .c/.h),**不含**测试单元自身、
test/host 下的桩、Lib/、Middlewares/、HAL/CMSIS、Windows SDK/UCRT/MSVC 头。
退出码:
0 = 全部单测通过,且覆盖率相对基线没有回退
1 = 有单测失败,或覆盖率相对基线回退(已覆盖行/函数减少、在测文件或模块消失)
2 = 环境错误(找不到 cl.exe / 找不到 mscoverage / 编译失败 / XML 缺失或解析失败)
3 = 用法错误
用法(仓库根目录):
& '.venv\Scripts\python.exe' scripts/check_test_coverage.py # 全量跑 + 比基线
& '.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 <路径> --json <路径>
& '.venv\Scripts\python.exe' scripts/check_test_coverage.py --list # 只列模块
实测坑(都在这条链上踩过,勿重复排查):
- **必须 /Zi 且链接 /INCREMENTAL:NO**:mscoverage 靠 PDB 把指令映射回源行;增量链接会出
.ilk、PDB 与代码不同步 → 行号漂移甚至整模块 0 覆盖。
- **PDB 必须与 exe 同目录**:/Fe: 决定 exe 落点,PDB 跟着 exe;换目录再跑就找不到符号。
- **只能 /Od**:/O2 会合并删除行,行覆盖率与源码对不上(本项目按 /Od 口径计)。
- **-- 分隔符不能省**:collect [options] -- <exe> [args];少了它被测程序的参数会被吃掉。
- **被测程序的 stdout 会被并进 collect 的 stdout**:判“测试真跑了”要在这份合并输出里找
汇总行(TestBuild.summary_marker),别指望单独一路流。
- **一次 collect 只覆盖这一次运行的进程树**:多套单测各跑各的,最后在本脚本里做并集。
- **覆盖率是"跑到的那几套的并集"**:`--module` 子集跑必然少覆盖行、少文件,**全部回退判定都
只在全量跑时生效**(首版只挡了"文件消失",结果 `--module sha256` 报"总计 2552→111"假回退)。
- **PDB 里的源路径大小写会漂**(盘符、VS 根),比对前先按仓库实际拼写归一,否则同一文件
在基线里裂成两份。
设计依据:docs/测试说明/主机单测覆盖率口径.md;docs/问题记录/项目审查与优化建议报告.md §4.8 §5.5
创建日期:2026-10-23
"""
import argparse
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
HARNESS_DIR = REPO_ROOT / "test" / "host" / "common"
HOST_TEST_DIR = REPO_ROOT / "test" / "host"
BASELINE_PATH = REPO_ROOT / "scripts" / "test_coverage_baseline.json"
# 统计面:只算自研生产源码(命中 INCLUDE 且不命中 EXCLUDE 才入选)
INCLUDE_PREFIXES = ("App/", "Drivers/BSP/")
EXCLUDE_PREFIXES = ()
SOURCE_SUFFIXES = (".c", ".h")
# 生成物标记:文件头注释里写着这些字样的源文件不进分母(覆盖率对它没有意义)
GENERATED_RE = re.compile(r"自动生成|请勿手改|DO NOT EDIT", re.I)
BASELINE_SCHEMA = 1
# 覆盖率专用追加开关:PDB(行映射)+ 不优化(行号不漂移)+ 非增量链接(PDB 与代码同步)
COV_FLAGS = ["/Zi", "/Od"]
COV_LINK_FLAGS = ["/link", "/INCREMENTAL:NO"]
# --------------------------------------------------------------------------
# harness 复用(工具链探测与编译开关的单一事实源)
# --------------------------------------------------------------------------
def load_harness():
"""导入 test/host/common/unit_test_harness.py;失败返回 None(调用方按环境错误处理)。"""
if not (HARNESS_DIR / "unit_test_harness.py").is_file():
return None
if str(HARNESS_DIR) not in sys.path:
sys.path.insert(0, str(HARNESS_DIR))
try:
import unit_test_harness
except Exception as exc:
print("[ENV] 导入 unit_test_harness 失败: {}".format(exc), file=sys.stderr)
return None
return unit_test_harness
def discover_modules():
"""{模块名: run_unit_test.py 绝对路径};发现规则与 ci_local.py 的 L0.5 一致。"""
out = {}
if not HOST_TEST_DIR.is_dir():
return out
for p in sorted(HOST_TEST_DIR.rglob("run_unit_test.py")):
out[p.parent.name] = str(p)
return out
def load_test_build(runner_path, harness):
"""从模块运行器取模块级 BUILD(TestBuild 实例),不执行它的 main。
各运行器都是声明式的:顶层只 import + 构造 TestBuild,真跑在
if __name__ == "__main__" 里,所以按独立模块名 import 是安全的。
"""
mod_name = "dtu_cov_" + re.sub(r"\W+", "_", Path(runner_path).parent.name)
spec = importlib.util.spec_from_file_location(mod_name, runner_path)
if spec is None or spec.loader is None:
return None, "无法为 {} 构造 import spec".format(runner_path)
mod = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(mod)
except SystemExit:
return None, "{} 在 import 阶段就调用了 sys.exit()".format(runner_path)
except Exception as exc:
return None, "import {} 失败: {}".format(runner_path, exc)
build = getattr(mod, "BUILD", None)
if build is None:
return None, "{} 未声明模块级 BUILD,覆盖率脚本无法复用其编译配置".format(runner_path)
if not isinstance(build, harness.TestBuild):
return None, "{} 的 BUILD 不是 TestBuild(与 harness 类型漂移)".format(runner_path)
return build, None
def _vs_roots(harness):
"""候选 VS 安装根:cl.exe 的祖先优先,再补 harness 的标准布局表(含 BuildTools)。"""
roots = []
cl, _, _ = harness.locate_toolchain()
if cl:
# <vs>\VC\Tools\MSVC\<ver>\bin\Hostx64\x64\cl.exe -> <vs>(上溯 8 级)
roots.append(Path(cl).parents[7])
for r in getattr(harness, "_default_vs_roots", list)():
p = Path(r)
if p not in roots:
roots.append(p)
return roots
def locate_coverage_console(harness, verbose=False):
r"""返回 mscoverage 绝对路径;找不到返回 None。
优先级:DTU_MS_COVERAGE(设了就必须有效,**不静默回退**)→ 各 VS 安装根下的
Common7\IDE\Extensions\Microsoft\CodeCoverage.Console\ → PATH。
"""
explicit = os.environ.get("DTU_MS_COVERAGE", "").strip()
if explicit:
if not os.path.isfile(explicit):
print("[ENV] DTU_MS_COVERAGE 已设置但文件不存在: {}(请修正该变量,不做自动探测回退)"
.format(explicit), file=sys.stderr)
return None
return explicit
rel = Path("Common7") / "IDE" / "Extensions" / "Microsoft" / "CodeCoverage.Console" \
/ "Microsoft.CodeCoverage.Console.exe"
for root in _vs_roots(harness):
cand = root / rel
if verbose:
print(" [probe] {}".format(cand))
if cand.is_file():
return str(cand)
return shutil.which("Microsoft.CodeCoverage.Console.exe")
# --------------------------------------------------------------------------
# 单模块:编译(带 PDB)-> mscoverage collect -> 解析 Cobertura
# --------------------------------------------------------------------------
def build_with_pdb(harness, build, workdir, verbose=False):
"""按门禁同一套开关 + COV_FLAGS 编译测试单元。返回 (退出码, 合并输出)。"""
cl, inc, lib = harness.locate_toolchain()
if not cl:
return 2, "找不到 MSVC cl.exe(设置 DTU_MSVC_CL,或安装 VS C++ 工具链)"
objdir = os.path.join(workdir, "obj")
os.makedirs(objdir, exist_ok=True)
exe = os.path.join(workdir, build.resolved_exe())
env = dict(os.environ)
env["INCLUDE"] = os.pathsep.join(inc)
env["LIB"] = os.pathsep.join(lib)
env["PATH"] = os.path.dirname(cl) + os.pathsep + env.get("PATH", "")
cmd = [cl] + list(harness._BASE_FLAGS) + list(COV_FLAGS)
for d in build.include_dirs:
cmd += ["/I", d]
cmd += list(build.extra_flags)
cmd += [build.src, "/Fe:" + exe, "/Fo:" + objdir + os.sep] + list(COV_LINK_FLAGS)
cp = subprocess.run(cmd, env=env, capture_output=True, cwd=workdir)
return cp.returncode, harness.decode(cp.stdout) + harness.decode(cp.stderr)
def collect_coverage(mscov, exe, workdir, xml_path, timeout_s, verbose=False):
"""动态插桩运行 exe 并导出 Cobertura XML。返回 (退出码, 合并输出, xml 路径或 None)。
退出码 = 被测程序退出码(mscoverage 透传):0 = 通过,非 0 = 失败或异常。
"""
cmd = [mscov, "collect", "-o", xml_path, "-f", "cobertura", "--nologo", "--", exe]
env = dict(os.environ)
env["PATH"] = os.path.dirname(mscov) + os.pathsep + env.get("PATH", "")
try:
cp = subprocess.run(cmd, env=env, capture_output=True, cwd=workdir, timeout=timeout_s)
except subprocess.TimeoutExpired:
return 2, "mscoverage collect 超时({} s)".format(timeout_s), None
except OSError as exc:
return 2, "mscoverage 无法执行: {}".format(exc), None
out = cp.stdout.decode("utf-8", "replace") + cp.stderr.decode("utf-8", "replace")
xml = xml_path if os.path.isfile(xml_path) and os.path.getsize(xml_path) > 0 else None
if verbose and xml is None:
print(out[-1500:])
return cp.returncode, out, xml
# --------------------------------------------------------------------------
# 路径与 Cobertura 解析
# --------------------------------------------------------------------------
def rel_to_repo(path_str):
"""PDB 里的源文件绝对路径 -> 仓库相对 posix 路径;仓库外返回 None。
Windows 下大小写不可信(盘符、VS 根都可能漂),故比较用 normcase、切片用原串,
最后再由 canonical 表统一成仓库里的实际拼写。
"""
if not path_str:
return None
p = path_str if os.path.isabs(path_str) else os.path.join(str(REPO_ROOT), path_str)
p = os.path.abspath(p)
root = os.path.abspath(str(REPO_ROOT))
if os.path.normcase(p).startswith(os.path.normcase(root) + os.sep):
return p[len(root) + 1:].replace("\\", "/")
return None
def in_scope(rel):
"""是否在覆盖率统计面内(自研生产源码);大小写不敏感。"""
if not rel:
return False
low = rel.lower()
if not low.endswith(SOURCE_SUFFIXES):
return False
# 前缀表本身带大小写(App/、Drivers/BSP/),比较前先整体小写
if not low.startswith(tuple(x.lower() for x in INCLUDE_PREFIXES)):
return False
return not low.startswith(tuple(x.lower() for x in EXCLUDE_PREFIXES))
def parse_cobertura(xml_path, canon):
"""Cobertura XML -> {仓库相对路径: {"lines": {行: 命中}, "funcs": {函数: 命中}}}。
同一 (文件, 行) 多次出现取最大值;文件名先经 canon 表归一,避免大小写裂成两份台账。
"""
tree = ET.parse(xml_path)
result = {}
for cls in tree.getroot().iter("class"):
rel = rel_to_repo(cls.get("filename", ""))
if not in_scope(rel):
continue
rel = canon.get(rel.lower(), rel)
entry = result.setdefault(rel, {"lines": {}, "funcs": {}})
for line in cls.iter("line"):
try:
num = int(line.get("number", "0"))
hits = int(line.get("hits", "0"))
except ValueError:
continue
if num <= 0:
continue
if hits >= entry["lines"].get(num, 0):
entry["lines"][num] = hits
for meth in cls.iter("method"):
name = meth.get("name") or "?"
hits = 0
for line in meth.iter("line"):
try:
hits = max(hits, int(line.get("hits", "0") or 0))
except ValueError:
pass
if hits >= entry["funcs"].get(name, 0):
entry["funcs"][name] = hits
return result
def merge_into(total, part):
"""单模块结果并入总表(同一 (文件, 行) 取并集:命中一次即算覆盖)。"""
for rel, entry in part.items():
dst = total.setdefault(rel, {"lines": {}, "funcs": {}})
for num, hits in entry["lines"].items():
if hits >= dst["lines"].get(num, 0):
dst["lines"][num] = hits
for name, hits in entry["funcs"].items():
if hits >= dst["funcs"].get(name, 0):
dst["funcs"][name] = hits
def summarize(total):
"""{文件: {lines, funcs}} -> 文件级指标(valid/covered + 覆盖率 + 未覆盖行清单)。"""
out = {}
for rel, entry in sorted(total.items()):
lines, funcs = entry["lines"], entry["funcs"]
valid = len(lines)
covered = sum(1 for h in lines.values() if h > 0)
f_valid = len(funcs)
f_covered = sum(1 for h in funcs.values() if h > 0)
out[rel] = {
"lines_valid": valid,
"lines_covered": covered,
"funcs_valid": f_valid,
"funcs_covered": f_covered,
"line_rate": round(covered / valid, 4) if valid else 0.0,
"func_rate": round(f_covered / f_valid, 4) if f_valid else 0.0,
"missing_lines": sorted(n for n, h in lines.items() if h == 0),
}
return out
def pct(x):
return "{:.1f}%".format(100.0 * x) if x is not None else "-"
# --------------------------------------------------------------------------
# 分母侧:生产源文件全集 / 生成物 / 未触及面
# --------------------------------------------------------------------------
def head_text(rel_path, n_lines=15):
try:
with open(REPO_ROOT / rel_path, "r", encoding="utf-8", errors="replace") as f:
return "".join(f.readlines()[:n_lines])
except OSError:
return ""
def is_generated(rel_path):
"""文件头 15 行内出现「自动生成 / 请勿手改 / DO NOT EDIT」即判为生成物。"""
return bool(GENERATED_RE.search(head_text(rel_path)))
def production_sources():
"""统计面内的全部生产源文件(仓库相对 posix 路径,按文件系统实际拼写)。"""
out = []
for prefix in INCLUDE_PREFIXES:
base = REPO_ROOT / prefix
if not base.is_dir():
continue
for p in sorted(base.rglob("*")):
if not p.is_file() or not p.name.lower().endswith(SOURCE_SUFFIXES):
continue
rel = str(p.relative_to(REPO_ROOT)).replace("\\", "/")
if rel.lower().startswith(EXCLUDE_PREFIXES):
continue
out.append(rel)
return out
def count_code_lines(rel_path):
"""粗粒度有效代码行(去空行、去整行注释、去 include)。
**只用于未触及面的体量提示**,不进覆盖率分母——分母一律来自插桩报告,不靠猜。
"""
try:
with open(REPO_ROOT / rel_path, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
except OSError:
return 0
n, in_block = 0, False
for raw in text.splitlines():
s = raw.strip()
if not s:
continue
if in_block:
if "*/" in s:
in_block = False
continue
if s.startswith("/*"):
in_block = "*/" not in s
continue
if s.startswith("*") or s.startswith("//") or s.startswith("#include"):
continue
n += 1
return n
# --------------------------------------------------------------------------
# 基线:读取 / 写出 / 比对(增量拦截:拦的是回退,不拦进步)
# --------------------------------------------------------------------------
def load_baseline(path):
if not Path(path).is_file():
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError) as exc:
print("[ENV] 基线读取失败 {}: {}".format(path, exc), file=sys.stderr)
return None
BASELINE_FILE_FIELDS = ("lines_valid", "lines_covered", "funcs_valid", "funcs_covered",
"line_rate", "func_rate")
def write_baseline(path, modules, files, totals, tested_sources, untested):
# 基线只留可比字段:missing_lines 每次跑都会漂,进了台账就是无意义的提交抖动
payload = {
"schema": BASELINE_SCHEMA,
"generated_by": "scripts/check_test_coverage.py --update-baseline",
"note": "主机单测覆盖率基线。口径与工具链限制见 docs/测试说明/主机单测覆盖率口径.md。"
"门禁判据:files[].lines_covered 与 funcs_covered 只增不减,下降即拦提交。",
"totals": totals,
"modules": modules,
"files": {rel: {k: v for k, v in rec.items() if k in BASELINE_FILE_FIELDS}
for rel, rec in files.items()},
"tested_sources": tested_sources,
"untested_sources": untested,
}
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(payload, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
def diff_baseline(baseline, modules, files, totals, full_run):
"""返回 (回退项, 提示项);回退项非空即门禁失败。"""
regress, notes = [], []
if baseline is None:
notes.append("无基线文件:先跑 scripts/check_test_coverage.py --update-baseline 生成 "
"scripts/test_coverage_baseline.json,否则本门禁只是又跑了一遍测试,"
"没有任何比较基准")
return regress, notes
if baseline.get("schema") != BASELINE_SCHEMA:
notes.append("基线 schema={} 与当前 {} 不一致:用 --update-baseline 重建"
.format(baseline.get("schema"), BASELINE_SCHEMA))
return regress, notes
base_files = baseline.get("files", {})
base_mods = baseline.get("modules", {})
for rel, b in sorted(base_files.items()):
cur = files.get(rel)
if cur is None:
if full_run:
regress.append("{}: 基线里在测,本次未出现在任何单测编译单元里"
"(include 链被删?还是那套单测被跳过了?)基线 {}/{} 行".format(
rel, b.get("lines_covered", 0), b.get("lines_valid", 0)))
continue
# 覆盖率是"跑到的套数的并集",子集必然比全量少覆盖行 → 不是回退,别假报警
if not full_run:
continue
if cur["lines_covered"] < b.get("lines_covered", 0):
regress.append("{}: 已覆盖行 {} -> **{}**(-{})".format(
rel, b.get("lines_covered", 0), cur["lines_covered"],
b.get("lines_covered", 0) - cur["lines_covered"]))
if cur["funcs_covered"] < b.get("funcs_covered", 0):
regress.append("{}: 已覆盖函数 {} -> **{}**".format(
rel, b.get("funcs_covered", 0), cur["funcs_covered"]))
if cur["lines_valid"] > int(b.get("lines_valid", 0) * 1.3) + 120:
notes.append("{}: 可执行行 {} -> {}(源码长大或被更多测试 include,百分比会被摊薄)"
":确认后 --update-baseline 登记".format(
rel, b.get("lines_valid", 0), cur["lines_valid"]))
for name, b in sorted(base_mods.items()):
cur = modules.get(name)
if cur is None:
if full_run:
regress.append("模块 {}: 基线里有,本次未跑到(运行器缺失 / import 失败?)".format(name))
elif not cur.get("ok"):
regress.append("模块 {}: 本次未通过({})".format(name, cur.get("reason", "未知")))
bt = baseline.get("totals", {})
# 子集运行的"总计"必然只包含被跑到的那几套,拿它比全量基线是假回退
if full_run and totals["lines_covered"] < bt.get("lines_covered", 0):
regress.append("总计: 已覆盖行 {} -> **{}**".format(bt.get("lines_covered", 0),
totals["lines_covered"]))
for rel in sorted(set(files) - set(base_files)):
notes.append("新纳入统计: {}({}/{} 行,{})——确认无误后 --update-baseline 登记".format(
rel, files[rel]["lines_covered"], files[rel]["lines_valid"],
pct(files[rel]["line_rate"])))
if not full_run:
notes.append("本次是 --module 子集运行:并集必然变小,跳过全部覆盖率回退判定"
"(文件消失 / 行减少 / 函数减少 / 总计下降);只保留"
"「单测必须通过」与「新文件登记提示」两项。要判回退请跑全量")
return regress, notes
# --------------------------------------------------------------------------
# 输出
# --------------------------------------------------------------------------
def print_report(modules, files, totals, untested, generated, notes, regress, verbose):
print("=== 主机单测覆盖率(口径:docs/测试说明/主机单测覆盖率口径.md)===")
print("")
print("--- 各套单测(插桩运行)---")
for name in sorted(modules):
m = modules[name]
if not m["ok"]:
print(" {:<12} FAIL {}".format(name, m.get("reason", "")))
continue
print(" {:<12} ok 触及 {:>2} 个生产文件 / {:>5} 行".format(
name, len(m["files"]), m["lines_total"]))
print("")
print("--- 自研生产源码(全部套件并集)---")
print(" {:<56} {:>9} {:>7} {:>8} {:>8}".format("文件", "覆盖行", "总行", "函数", "覆盖率"))
for rel in sorted(files):
s = files[rel]
print(" {:<56} {:>9} {:>7} {:>8} {:>8}".format(
rel[-56:], s["lines_covered"], s["lines_valid"],
"{}/{}".format(s["funcs_covered"], s["funcs_valid"]), pct(s["line_rate"])))
print("")
print("--- 未触及面:生产源码里从未进过任何主机单测的文件 ---")
print(" {} 个 / 有效代码约 {} 行(这些文件覆盖率是 0 分母,绿灯不代表它们被测)".format(
len(untested), sum(u["code_lines"] for u in untested)))
show = untested if verbose else untested[:12]
for u in show:
print(" {:<56} {:>6} 行".format(u["path"][-56:], u["code_lines"]))
if len(show) < len(untested):
print(" ...(另有 {} 个,--verbose 全列)".format(len(untested) - len(show)))
if generated:
print(" 生成物已排除(不进分母): {}".format(", ".join(generated)))
if notes:
print("")
print("--- 提示(不拦提交)---")
for n in notes:
print(" [NOTE] " + n)
if regress:
print("")
print("--- 回退(拦提交)---")
for r in regress:
print(" [REGRESS] " + r)
print("")
print("[SUMMARY] coverage: line={}/{} ({}) func={}/{} ({}) suites={}/{} "
"tested_files={} untested_files={}".format(
totals["lines_covered"], totals["lines_valid"], pct(totals["line_rate"]),
totals["funcs_covered"], totals["funcs_valid"], pct(totals["func_rate"]),
totals["modules_ok"], totals["modules_total"],
totals["files_tested"], totals["files_untested"]))
def write_markdown(path, modules, files, totals, untested, notes, regress):
"""一次性 Markdown 报告(建议放临时目录,不入库)。
⚠ 行拼装一律走 code() 显式拼接:`.format` 的优先级高于 `+`,写成
`a + "{}".format(x)` 只有最后一段会被格式化,占位符原样留在表里(实测踩过一次)。
"""
def code(s):
return BT_MARK + str(s) + BT_MARK
out = [
"# 主机单测覆盖率报告",
"",
"> 由 " + code("scripts/check_test_coverage.py") + " 生成;口径、工具链限制与盲区见 "
+ code("docs/测试说明/主机单测覆盖率口径.md") + "。本报告是一次性快照,重跑即覆盖。",
"",
"## 总计",
"",
"| 指标 | 覆盖 | 总数 | 覆盖率 |",
"|---|---|---|---|",
"| 行 | {} | {} | {} |".format(totals["lines_covered"], totals["lines_valid"],
pct(totals["line_rate"])),
"| 函数 | {} | {} | {} |".format(totals["funcs_covered"], totals["funcs_valid"],
pct(totals["func_rate"])),
"| 套件(通过/总数) | {} | {} | - |".format(totals["modules_ok"],
totals["modules_total"]),
"| 在测文件 / 未触及文件 | {} | {} | - |".format(totals["files_tested"],
totals["files_untested"]),
"",
"## 各套单测(插桩运行)",
"",
"| 套件 | 结果 | 触及生产文件 | 插桩行 |",
"|---|---|---|---|",
]
for name in sorted(modules):
m = modules[name]
out.append("| " + code(name) + " | "
+ ("ok" if m["ok"] else "**FAIL**") + " | "
+ str(len(m.get("files", {}))) + " | "
+ str(m.get("lines_total", 0)) + " |")
out += ["", "## 生产文件明细(多套取并集)", "",
"| 文件 | 覆盖行 | 总行 | 覆盖率 | 函数 | 未覆盖行 |",
"|---|---|---|---|---|---|"]
for rel in sorted(files):
s = files[rel]
miss = ", ".join(str(n) for n in s["missing_lines"][:40])
if len(s["missing_lines"]) > 40:
miss += " …(+" + str(len(s["missing_lines"]) - 40) + ")"
out.append("| " + code(rel) + " | " + str(s["lines_covered"]) + " | "
+ str(s["lines_valid"]) + " | " + pct(s["line_rate"]) + " | "
+ str(s["funcs_covered"]) + "/" + str(s["funcs_valid"]) + " | "
+ (miss or "—") + " |")
out += ["", "## 未触及的生产源文件", "",
"共 " + str(len(untested)) + " 个(有效代码约 "
+ str(sum(u["code_lines"] for u in untested))
+ " 行)。主机单测从未编译过它们——**对它们而言覆盖率是 0 分母,不是 0%**。",
"", "| 文件 | 有效代码行 |", "|---|---|"]
for u in untested:
out.append("| " + code(u["path"]) + " | " + str(u["code_lines"]) + " |")
if notes:
out += ["", "## 提示(不拦提交)", ""] + ["- [NOTE] " + n for n in notes]
if regress:
out += ["", "## 回退(拦提交)", ""] + ["- [REGRESS] " + r for r in regress]
out.append("")
Path(path).write_text("\n".join(out), encoding="utf-8", newline="\n")
# --------------------------------------------------------------------------
# 主流程
# --------------------------------------------------------------------------
def run(args):
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
harness = load_harness()
if harness is None:
print("[ENV] 取不到 test/host/common/unit_test_harness.py", file=sys.stderr)
return 2
all_runners = discover_modules()
if not all_runners:
print("[ENV] 未发现 test/host/**/run_unit_test.py", file=sys.stderr)
return 2
runners = all_runners
if args.module:
want = set(args.module)
unknown = want - set(all_runners)
if unknown:
print("[USAGE] 未知模块 {};可选:{}".format(
", ".join(sorted(unknown)), ", ".join(sorted(all_runners))), file=sys.stderr)
return 3
runners = {k: v for k, v in all_runners.items() if k in want}
full_run = len(runners) == len(all_runners)
if args.list:
for name in sorted(all_runners):
print(name)
return 0
mscov = None
if not args.build_only:
mscov = locate_coverage_console(harness, verbose=args.verbose)
if not mscov:
print("[ENV] 找不到 Microsoft.CodeCoverage.Console.exe(随 VS 装在 "
"Common7\\IDE\\Extensions\\Microsoft\\CodeCoverage.Console\\);"
"可用 DTU_MS_COVERAGE 指定绝对路径。本机实测:MSVC 14.51 已无 /coverage,"
"且 PATH 上没有 gcc/gcov。", file=sys.stderr)
return 2
prod = production_sources()
canon = {rel.lower(): rel for rel in prod}
work_root = args.work_dir or os.path.join(tempfile.gettempdir(), "dtu_test_cov")
if args.clean:
shutil.rmtree(work_root, ignore_errors=True)
os.makedirs(work_root, exist_ok=True)
total_files = {}
modules = {}
for name, runner in sorted(runners.items()):
workdir = os.path.join(work_root, name)
shutil.rmtree(workdir, ignore_errors=True)
os.makedirs(workdir, exist_ok=True)
build, err = load_test_build(runner, harness)
if err:
modules[name] = {"ok": False, "reason": err, "files": {}, "lines_total": 0}
print("[FAIL] {}".format(err), file=sys.stderr)
continue
rc, out = build_with_pdb(harness, build, workdir, verbose=args.verbose)
if rc != 0:
modules[name] = {"ok": False, "reason": "编译失败(cl 退出码 {})".format(rc),
"files": {}, "lines_total": 0}
print("[ENV] {} 编译失败:".format(name), file=sys.stderr)
print(out[-2500:], file=sys.stderr)
continue
if args.build_only:
modules[name] = {"ok": True, "files": {}, "lines_total": 0, "build_only": True}
print("[OK] {} 覆盖率形态编译通过".format(name))
continue
exe = os.path.join(workdir, build.resolved_exe())
xml_path = os.path.join(workdir, "coverage.cobertura.xml")
crc, cout, xml = collect_coverage(mscov, exe, workdir, xml_path, args.timeout,
verbose=args.verbose)
marker = build.summary_marker or ""
if marker and marker not in cout:
modules[name] = {"ok": False,
"reason": "插桩运行未产生测试汇总行(找不到 " + repr(marker) + ")",
"files": {}, "lines_total": 0}
print("[ENV] {} 测试汇总行缺失(rc={})".format(name, crc), file=sys.stderr)
continue
if crc != 0:
modules[name] = {"ok": False, "reason": "单测失败(退出码 {})".format(crc),
"files": {}, "lines_total": 0}
print("[FAIL] {} 在插桩运行下失败(rc={})".format(name, crc), file=sys.stderr)
continue
if xml is None:
modules[name] = {"ok": False, "reason": "未生成覆盖率 XML",
"files": {}, "lines_total": 0}
print("[ENV] {} 缺覆盖率 XML: {}".format(name, xml_path), file=sys.stderr)
continue
try:
part = parse_cobertura(xml, canon)
except ET.ParseError as exc:
modules[name] = {"ok": False, "reason": "覆盖率 XML 解析失败: {}".format(exc),
"files": {}, "lines_total": 0}
print("[ENV] {} XML 解析失败: {}".format(name, exc), file=sys.stderr)
continue
modules[name] = {
"ok": True,
"files": {rel: len(e["lines"]) for rel, e in part.items()},
"lines_total": sum(len(e["lines"]) for e in part.values()),
}
merge_into(total_files, part)
files = summarize(total_files)
tested = sorted(files)
untested, generated = [], []
for rel in prod:
if rel in files:
continue
if is_generated(rel):
generated.append(rel)
continue
untested.append({"path": rel, "code_lines": count_code_lines(rel)})
untested.sort(key=lambda u: (-u["code_lines"], u["path"]))
totals = {
"lines_valid": sum(s["lines_valid"] for s in files.values()),
"lines_covered": sum(s["lines_covered"] for s in files.values()),
"funcs_valid": sum(s["funcs_valid"] for s in files.values()),
"funcs_covered": sum(s["funcs_covered"] for s in files.values()),
"modules_total": len(modules),
"modules_ok": sum(1 for m in modules.values() if m["ok"]),
"files_tested": len(files),
"files_untested": len(untested),
"files_generated_excluded": len(generated),
"line_rate": 0.0,
"func_rate": 0.0,
}
if totals["lines_valid"]:
totals["line_rate"] = round(totals["lines_covered"] / totals["lines_valid"], 4)
if totals["funcs_valid"]:
totals["func_rate"] = round(totals["funcs_covered"] / totals["funcs_valid"], 4)
if args.json:
payload = {"totals": totals, "modules": modules, "files": files,
"untested_sources": untested, "generated_excluded": generated}
Path(args.json).write_text(
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8", newline="\n")
baseline = load_baseline(args.baseline)
if args.build_only:
regress, notes = [], ["--build-only:只编译覆盖率形态(带 PDB),不采集数据"]
else:
regress, notes = diff_baseline(baseline, modules, files, totals, full_run)
print_report(modules, files, totals, untested, generated, notes, regress, args.verbose)
if args.md:
write_markdown(args.md, modules, files, totals, untested, notes, regress)
print("[OK] Markdown 报告: {}".format(args.md))
if args.update_baseline:
write_baseline(args.baseline, modules, files, totals, tested, untested)
print("[OK] 基线已写入 {}".format(os.path.relpath(args.baseline, REPO_ROOT)))
return 0
if totals["modules_ok"] != totals["modules_total"]:
return 1
return 1 if regress else 0
def main():
ap = argparse.ArgumentParser(
prog="check_test_coverage.py",
description="主机侧单测(L0.5)覆盖率:插桩跑测试 + 统计自研生产源码行/函数覆盖 + 基线增量门禁")
ap.add_argument("--module", action="append", metavar="NAME",
help="只处理指定模块(可重复;默认全部)")
ap.add_argument("--list", action="store_true", help="只列出发现的模块名")
ap.add_argument("--baseline", default=str(BASELINE_PATH),
help="基线 JSON 路径(默认 scripts/test_coverage_baseline.json)")
ap.add_argument("--update-baseline", action="store_true", help="把本次结果写回基线")
ap.add_argument("--md", metavar="PATH", help="额外输出 Markdown 报告")
ap.add_argument("--json", metavar="PATH", help="额外输出 JSON 明细")
ap.add_argument("--work-dir", metavar="PATH",
help="中间产物目录(默认临时目录下 dtu_test_cov)")
ap.add_argument("--clean", action="store_true", help="跑前清空中间目录")
ap.add_argument("--timeout", type=int, default=600, metavar="SEC",
help="单模块插桩运行超时(默认 600 秒)")
ap.add_argument("--build-only", action="store_true",
help="只按覆盖率开关编译、不采集(排障用)")
ap.add_argument("--verbose", action="store_true", help="多打探测过程与未触及文件全表")
args = ap.parse_args()
if args.timeout <= 0:
print("[USAGE] --timeout 必须为正整数", file=sys.stderr)
return 3
try:
return run(args)
except KeyboardInterrupt:
print("[ABORT] 用户中断", file=sys.stderr)
return 2
BT_MARK = chr(96)
if __name__ == "__main__":
sys.exit(main())
+31
View File
@@ -10,6 +10,9 @@
覆盖范围(与 CI 完全一致):
L0 静态门禁 = `pre-commit run --all-files` + 提交信息门禁(gitlint)
L0.5 主机侧纯逻辑单测 = `test/host/**/run_unit_test.py`
L0.5c 覆盖率(**opt-in:`--coverage`**,2026-10-23)= `scripts/check_test_coverage.py`
默认**不跑**、CI 也**不跑**:它依赖 VS 可选的「代码覆盖率工具」组件,且与 L0.5 那 16 个
钩子跑的是同一批测试(覆盖率只是换一种看法)。口径与盲区 → docs/测试说明/主机单测覆盖率口径.md
⚠️ gitlint 必须**单独跑**(2026-10-11 审计):`stages: [commit-msg]` 写在 gitlint **上游**
的 `.pre-commit-hooks.yaml`(项目 `.pre-commit-config.yaml` 里查不到这一行),而
@@ -332,6 +335,30 @@ def run_l05(args):
return rc_all
def run_coverage():
"""覆盖率附加步骤(opt-in,CI 不跑):主机单测覆盖率 + 基线增量门禁。
单独一步而不并进 L0.5:L0.5 是**必须绿**的门禁,覆盖率是**趋势视图**,两者失败含义不同
(前者=测试挂了,后者=覆盖回退了 / 缺工具链)。退出码原样透传,由 main 汇总。
"""
print("")
print("=" * 64)
print(" L0.5c 主机单测覆盖率(mscoverage 动态插桩 + 基线增量门禁)")
print("=" * 64)
script = REPO_ROOT / "scripts" / "check_test_coverage.py"
if not script.is_file():
print("[WARN] 缺 scripts/check_test_coverage.py —— 跳过该步")
return 0
rc = _run([PY, str(script)])
if rc == 2:
print(" (退出码 2 = 环境错误:多半没装 VS 的「代码覆盖率工具」组件,或需设")
print(" DTU_MS_COVERAGE。口径与排障 → docs/测试说明/主机单测覆盖率口径.md)")
elif rc == 1:
print(" (退出码 1 = 有单测失败,或已覆盖行/函数相对基线回退;确认无误后")
print(" 跑 scripts/check_test_coverage.py --update-baseline 重登基线)")
return rc
def main(argv=None):
for stream in (sys.stdout, sys.stderr):
try:
@@ -344,6 +371,8 @@ def main(argv=None):
g = ap.add_mutually_exclusive_group()
g.add_argument("--l0-only", action="store_true", help="只跑 L0 静态门禁")
g.add_argument("--l05-only", action="store_true", help="只跑 L0.5 主机侧单测")
ap.add_argument("--coverage", action="store_true",
help="额外跑主机单测覆盖率(约 25 s;需 VS 代码覆盖率组件;默认不跑,CI 也不跑)")
ap.add_argument("--keep-going", action="store_true",
help="某步失败也继续跑后续步骤(默认首错即停)")
ap.add_argument("--commits", type=int, default=10, metavar="N",
@@ -388,6 +417,8 @@ def main(argv=None):
if not args.l0_only:
rc05 = run_l05(args)
rc = rc or rc05
if args.coverage:
rc = rc or run_coverage()
except FileNotFoundError as e:
print("[ERROR] 环境错误:{}".format(e))
return 2
+286
View File
@@ -1592,6 +1592,292 @@
"subitem_ok": true,
"owner_note": "B 层口径同其余 TC-HOST 族;本族是 2026-10-21 补漏——上批模块码改名前它是纯数字 `TC-104-*`,未进当时的悬空普查,故漏建族",
"cursor_note": "游标按实测最大号收录(2026-10-21)"
},
{
"name": "TC-HOST-NETCFG",
"class": "TC",
"title": "主机侧单测用例号:网络配置编排与跨任务保存交接(test/host/net_cfg)",
"pattern": "\\bTC-NETCFG-[0-9]{1,4}[a-z]?\\b",
"digit_width": [
2,
4
],
"owner_docs_glob": [
"test/host/net_cfg/*"
],
"scope": "global",
"frozen": false,
"cursor": "TC-NETCFG-056",
"cursor_owner_only": true,
"check_dangling": true,
"allow_orphans": true,
"subitem_ok": true,
"owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);本族 2026-10-23 随覆盖率口径 §9 P1 第①批新建模块补登记",
"cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌"
},
{
"name": "TC-HOST-FTPCFG",
"class": "TC",
"title": "主机侧单测用例号:FTP 鉴权配置 [ftp] 段(test/host/ftp_cfg)",
"pattern": "\\bTC-FTPCFG-[0-9]{1,4}[a-z]?\\b",
"digit_width": [
2,
4
],
"owner_docs_glob": [
"test/host/ftp_cfg/*"
],
"scope": "global",
"frozen": false,
"cursor": "TC-FTPCFG-014",
"cursor_owner_only": true,
"check_dangling": true,
"allow_orphans": true,
"subitem_ok": true,
"owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);本族 2026-10-23 随覆盖率口径 §9 P1 第①批新建模块补登记",
"cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌"
},
{
"name": "TC-HOST-PROTSTOR",
"class": "TC",
"title": "主机侧单测用例号:保护定值持久化胶水层(test/host/prot_set_store)",
"pattern": "\\bTC-PROTSTOR-[0-9]{1,4}[a-z]?\\b",
"digit_width": [
2,
4
],
"owner_docs_glob": [
"test/host/prot_set_store/*"
],
"scope": "global",
"frozen": false,
"cursor": "TC-PROTSTOR-016",
"cursor_owner_only": true,
"check_dangling": true,
"allow_orphans": true,
"subitem_ok": true,
"owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);本族 2026-10-23 随覆盖率口径 §9 P1 第①批新建模块补登记",
"cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌"
},
{
"name": "TC-HOST-TASKPROF",
"class": "TC",
"title": "主机侧单测用例号:任务耗时探针与上报节流(test/host/task_prof)",
"pattern": "\\bTC-TASKPROF-[0-9]{1,4}[a-z]?\\b",
"digit_width": [
2,
4
],
"owner_docs_glob": [
"test/host/task_prof/*"
],
"scope": "global",
"frozen": false,
"cursor": "TC-TASKPROF-020",
"cursor_owner_only": true,
"check_dangling": true,
"allow_orphans": true,
"subitem_ok": true,
"owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);本族 2026-10-23 随覆盖率口径 §9 P1 第①批新建模块补登记",
"cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌"
},
{
"name": "TC-HOST-TC-HOST-LFTPIO",
"class": "TC",
"title": "主机侧单测用例号:FTP 文件 IO 层与路径规范化(test/host/lftpd_io)",
"pattern": "\\bTC-LFTPIO-[0-9]{1,4}[a-z]?\\b",
"digit_width": [
2,
4
],
"owner_docs_glob": [
"test/host/lftpd_io/*"
],
"scope": "global",
"frozen": false,
"cursor": "TC-LFTPIO-037",
"cursor_owner_only": true,
"check_dangling": true,
"allow_orphans": true,
"subitem_ok": true,
"owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 P1 新建模块补登记",
"cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌"
},
{
"name": "TC-HOST-TC-HOST-LFTSTR",
"class": "TC",
"title": "主机侧单测用例号:FTP 命令行字符串工具(同模块内)(test/host/lftpd_io)",
"pattern": "\\bTC-LFTSTR-[0-9]{1,4}[a-z]?\\b",
"digit_width": [
2,
4
],
"owner_docs_glob": [
"test/host/lftpd_io/*"
],
"scope": "global",
"frozen": false,
"cursor": "TC-LFTSTR-005",
"cursor_owner_only": true,
"check_dangling": true,
"allow_orphans": true,
"subitem_ok": true,
"owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 P1 新建模块补登记",
"cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌"
},
{
"name": "TC-HOST-NETSEL",
"class": "TC",
"title": "主机侧单测用例号:NET 层 select/poll 事件观测(test/host/net_select)",
"pattern": "\\bTC-NETSEL-[0-9]{1,4}[a-z]?\\b",
"digit_width": [
2,
4
],
"owner_docs_glob": [
"test/host/net_select/*"
],
"scope": "global",
"frozen": false,
"cursor": "TC-NETSEL-019",
"cursor_owner_only": true,
"check_dangling": true,
"allow_orphans": true,
"subitem_ok": true,
"owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 P1 新建模块补登记",
"cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌"
},
{
"name": "TC-HOST-NETLIS",
"class": "TC",
"title": "主机侧单测用例号:NET 层监听自动重建(test/host/net_listener)",
"pattern": "\\bTC-NETLIS-[0-9]{1,4}[a-z]?\\b",
"digit_width": [
2,
4
],
"owner_docs_glob": [
"test/host/net_listener/*"
],
"scope": "global",
"frozen": false,
"cursor": "TC-NETLIS-026",
"cursor_owner_only": true,
"check_dangling": true,
"allow_orphans": true,
"subitem_ok": true,
"owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 假 CH395F 立项第一批补登记",
"cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌"
},
{
"name": "TC-HOST-NETPEND",
"class": "TC",
"title": "主机侧单测用例号:NET 层挂起 recv 与挂起 send(test/host/net_pending)",
"pattern": "\\bTC-NETPEND-[0-9]{1,4}[a-z]?\\b",
"digit_width": [
2,
4
],
"owner_docs_glob": [
"test/host/net_pending/*"
],
"scope": "global",
"frozen": false,
"cursor": "TC-NETPEND-040",
"cursor_owner_only": true,
"check_dangling": true,
"allow_orphans": true,
"subitem_ok": true,
"owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 假 CH395F 第二批补登记",
"cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌"
},
{
"name": "TC-HOST-NETEV",
"class": "TC",
"title": "主机侧单测用例号:NET 层事件分发与周期对账(test/host/net_events)",
"pattern": "\\bTC-NETEV-[0-9]{1,4}[a-z]?\\b",
"digit_width": [
2,
4
],
"owner_docs_glob": [
"test/host/net_events/*"
],
"scope": "global",
"frozen": false,
"cursor": "TC-NETEV-058",
"cursor_owner_only": true,
"check_dangling": true,
"allow_orphans": true,
"subitem_ok": true,
"owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 假 CH395F 第三批补登记",
"cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌"
},
{
"name": "TC-HOST-LFTINET",
"class": "TC",
"title": "主机侧单测用例号:FTP 的 socket 适配层(test/host/lftpd_inet)",
"pattern": "\\bTC-LFTINET-[0-9]{1,4}[a-z]?\\b",
"digit_width": [
2,
4
],
"owner_docs_glob": [
"test/host/lftpd_inet/*"
],
"scope": "global",
"frozen": false,
"cursor": "TC-LFTINET-070",
"cursor_owner_only": true,
"check_dangling": true,
"allow_orphans": true,
"subitem_ok": true,
"owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 P1 FTP 批次补登记",
"cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌"
},
{
"name": "TC-HOST-LFTPD",
"class": "TC",
"title": "主机侧单测用例号:FTP 控制通道协议层(test/host/lftpd)",
"pattern": "\\bTC-LFTPD-[0-9]{1,4}[a-z]?\\b",
"digit_width": [
2,
4
],
"owner_docs_glob": [
"test/host/lftpd/*"
],
"scope": "global",
"frozen": false,
"cursor": "TC-LFTPD-034",
"cursor_owner_only": true,
"check_dangling": true,
"allow_orphans": true,
"subitem_ok": true,
"owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 P1 FTP 批次补登记",
"cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌"
},
{
"name": "TC-HOST-I104SRV",
"class": "TC",
"title": "主机侧单测用例号:IEC 104 上行服务生命周期(test/host/iec104_serv)",
"pattern": "\\bTC-I104SRV-[0-9]{1,4}[a-z]?\\b",
"digit_width": [
2,
4
],
"owner_docs_glob": [
"test/host/iec104_serv/*"
],
"scope": "global",
"frozen": false,
"cursor": "TC-I104SRV-041",
"cursor_owner_only": true,
"check_dangling": true,
"allow_orphans": true,
"subitem_ok": true,
"owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 P1 收官批次补登记",
"cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌"
}
],
"unmanaged_bare": {
+977
View File
@@ -0,0 +1,977 @@
{
"files": {
"App/algo/adc_health.c": {
"func_rate": 1.0,
"funcs_covered": 6,
"funcs_valid": 6,
"line_rate": 1.0,
"lines_covered": 108,
"lines_valid": 108
},
"App/algo/adc_input.c": {
"func_rate": 1.0,
"funcs_covered": 14,
"funcs_valid": 14,
"line_rate": 0.975,
"lines_covered": 117,
"lines_valid": 120
},
"App/algo/freq.c": {
"func_rate": 1.0,
"funcs_covered": 8,
"funcs_valid": 8,
"line_rate": 0.9692,
"lines_covered": 126,
"lines_valid": 130
},
"App/algo/phasor.c": {
"func_rate": 1.0,
"funcs_covered": 6,
"funcs_valid": 6,
"line_rate": 0.9818,
"lines_covered": 54,
"lines_valid": 55
},
"App/algo/prot_elm.c": {
"func_rate": 1.0,
"funcs_covered": 8,
"funcs_valid": 8,
"line_rate": 0.9717,
"lines_covered": 103,
"lines_valid": 106
},
"App/algo/prot_set.c": {
"func_rate": 0.9565,
"funcs_covered": 22,
"funcs_valid": 23,
"line_rate": 0.9665,
"lines_covered": 173,
"lines_valid": 179
},
"App/algo/sample_ring.c": {
"func_rate": 1.0,
"funcs_covered": 5,
"funcs_valid": 5,
"line_rate": 1.0,
"lines_covered": 41,
"lines_valid": 41
},
"App/cfg/cfg_file.c": {
"func_rate": 0.9615,
"funcs_covered": 25,
"funcs_valid": 26,
"line_rate": 0.8773,
"lines_covered": 572,
"lines_valid": 652
},
"App/cfg/prot_set_store.c": {
"func_rate": 1.0,
"funcs_covered": 4,
"funcs_valid": 4,
"line_rate": 0.931,
"lines_covered": 27,
"lines_valid": 29
},
"App/fs/vol_lock.c": {
"func_rate": 1.0,
"funcs_covered": 3,
"funcs_valid": 3,
"line_rate": 1.0,
"lines_covered": 17,
"lines_valid": 17
},
"App/log/soe_rec.c": {
"func_rate": 1.0,
"funcs_covered": 7,
"funcs_valid": 7,
"line_rate": 0.931,
"lines_covered": 81,
"lines_valid": 87
},
"App/protocol/iec104/iec104_asdu.c": {
"func_rate": 1.0,
"funcs_covered": 10,
"funcs_valid": 10,
"line_rate": 0.932,
"lines_covered": 96,
"lines_valid": 103
},
"App/protocol/iec104/iec104_link.c": {
"func_rate": 1.0,
"funcs_covered": 23,
"funcs_valid": 23,
"line_rate": 0.9062,
"lines_covered": 367,
"lines_valid": 405
},
"App/protocol/iec104/iec104_pts.c": {
"func_rate": 0.7647,
"funcs_covered": 13,
"funcs_valid": 17,
"line_rate": 0.6724,
"lines_covered": 117,
"lines_valid": 174
},
"App/sync/time_sync.c": {
"func_rate": 1.0,
"funcs_covered": 4,
"funcs_valid": 4,
"line_rate": 1.0,
"lines_covered": 99,
"lines_valid": 99
},
"App/task/iec104_serv.c": {
"func_rate": 0.875,
"funcs_covered": 7,
"funcs_valid": 8,
"line_rate": 0.8417,
"lines_covered": 117,
"lines_valid": 139
},
"App/util/sha256.c": {
"func_rate": 1.0,
"funcs_covered": 7,
"funcs_valid": 7,
"line_rate": 0.9737,
"lines_covered": 111,
"lines_valid": 114
},
"App/util/sys_clock.c": {
"func_rate": 1.0,
"funcs_covered": 8,
"funcs_valid": 8,
"line_rate": 0.9936,
"lines_covered": 156,
"lines_valid": 157
},
"App/util/task_prof.c": {
"func_rate": 1.0,
"funcs_covered": 9,
"funcs_valid": 9,
"line_rate": 0.9462,
"lines_covered": 88,
"lines_valid": 93
},
"App/util/task_watchdog.c": {
"func_rate": 1.0,
"funcs_covered": 8,
"funcs_valid": 8,
"line_rate": 1.0,
"lines_covered": 55,
"lines_valid": 55
},
"Drivers/BSP/GD5F2GQ5UE/nand_ftl.c": {
"func_rate": 0.6364,
"funcs_covered": 14,
"funcs_valid": 22,
"line_rate": 0.4862,
"lines_covered": 123,
"lines_valid": 253
},
"Drivers/BSP/NET/lftpd/lftpd.c": {
"func_rate": 0.8077,
"funcs_covered": 21,
"funcs_valid": 26,
"line_rate": 0.6427,
"lines_covered": 304,
"lines_valid": 473
},
"Drivers/BSP/NET/lftpd/lftpd_auth.c": {
"func_rate": 1.0,
"funcs_covered": 2,
"funcs_valid": 2,
"line_rate": 0.9737,
"lines_covered": 37,
"lines_valid": 38
},
"Drivers/BSP/NET/lftpd/lftpd_inet.c": {
"func_rate": 1.0,
"funcs_covered": 9,
"funcs_valid": 9,
"line_rate": 1.0,
"lines_covered": 127,
"lines_valid": 127
},
"Drivers/BSP/NET/lftpd/lftpd_io.c": {
"func_rate": 0.3846,
"funcs_covered": 5,
"funcs_valid": 13,
"line_rate": 0.5346,
"lines_covered": 85,
"lines_valid": 159
},
"Drivers/BSP/NET/lftpd/lftpd_string.c": {
"func_rate": 1.0,
"funcs_covered": 1,
"funcs_valid": 1,
"line_rate": 1.0,
"lines_covered": 7,
"lines_valid": 7
},
"Drivers/BSP/NET/net_events.c": {
"func_rate": 1.0,
"funcs_covered": 8,
"funcs_valid": 8,
"line_rate": 0.9777,
"lines_covered": 175,
"lines_valid": 179
},
"Drivers/BSP/NET/net_listener.c": {
"func_rate": 1.0,
"funcs_covered": 7,
"funcs_valid": 7,
"line_rate": 0.9921,
"lines_covered": 125,
"lines_valid": 126
},
"Drivers/BSP/NET/net_pending.c": {
"func_rate": 0.75,
"funcs_covered": 12,
"funcs_valid": 16,
"line_rate": 0.8711,
"lines_covered": 169,
"lines_valid": 194
},
"Drivers/BSP/NET/net_select.c": {
"func_rate": 1.0,
"funcs_covered": 5,
"funcs_valid": 5,
"line_rate": 0.8547,
"lines_covered": 100,
"lines_valid": 117
},
"Drivers/BSP/NETCFG/ftp_cfg.c": {
"func_rate": 1.0,
"funcs_covered": 4,
"funcs_valid": 4,
"line_rate": 0.9722,
"lines_covered": 35,
"lines_valid": 36
},
"Drivers/BSP/NETCFG/net_cfg.c": {
"func_rate": 0.9375,
"funcs_covered": 15,
"funcs_valid": 16,
"line_rate": 0.9643,
"lines_covered": 108,
"lines_valid": 112
}
},
"generated_by": "scripts/check_test_coverage.py --update-baseline",
"modules": {
"adc_health": {
"files": {
"App/algo/adc_health.c": 108
},
"lines_total": 108,
"ok": true
},
"adc_input": {
"files": {
"App/algo/adc_input.c": 120
},
"lines_total": 120,
"ok": true
},
"cfg_file": {
"files": {
"App/cfg/cfg_file.c": 652
},
"lines_total": 652,
"ok": true
},
"freq": {
"files": {
"App/algo/freq.c": 130,
"App/algo/phasor.c": 55,
"App/algo/sample_ring.c": 41
},
"lines_total": 226,
"ok": true
},
"ftp_auth": {
"files": {
"App/util/sha256.c": 114,
"Drivers/BSP/NET/lftpd/lftpd_auth.c": 38
},
"lines_total": 152,
"ok": true
},
"ftp_cfg": {
"files": {
"Drivers/BSP/NETCFG/ftp_cfg.c": 36
},
"lines_total": 36,
"ok": true
},
"iec104": {
"files": {
"App/algo/freq.c": 130,
"App/algo/phasor.c": 55,
"App/algo/prot_elm.c": 106,
"App/algo/prot_set.c": 179,
"App/algo/sample_ring.c": 41,
"App/protocol/iec104/iec104_asdu.c": 103,
"App/protocol/iec104/iec104_link.c": 405,
"App/protocol/iec104/iec104_pts.c": 174
},
"lines_total": 1193,
"ok": true
},
"iec104_serv": {
"files": {
"App/task/iec104_serv.c": 139
},
"lines_total": 139,
"ok": true
},
"lftpd": {
"files": {
"Drivers/BSP/NET/lftpd/lftpd.c": 473,
"Drivers/BSP/NET/lftpd/lftpd_string.c": 7
},
"lines_total": 480,
"ok": true
},
"lftpd_inet": {
"files": {
"Drivers/BSP/NET/lftpd/lftpd_inet.c": 127
},
"lines_total": 127,
"ok": true
},
"lftpd_io": {
"files": {
"Drivers/BSP/NET/lftpd/lftpd_io.c": 159,
"Drivers/BSP/NET/lftpd/lftpd_string.c": 7
},
"lines_total": 166,
"ok": true
},
"nand_port": {
"files": {
"Drivers/BSP/GD5F2GQ5UE/nand_ftl.c": 253
},
"lines_total": 253,
"ok": true
},
"net_cfg": {
"files": {
"Drivers/BSP/NETCFG/net_cfg.c": 112
},
"lines_total": 112,
"ok": true
},
"net_events": {
"files": {
"Drivers/BSP/NET/net_events.c": 179
},
"lines_total": 179,
"ok": true
},
"net_listener": {
"files": {
"Drivers/BSP/NET/net_listener.c": 126
},
"lines_total": 126,
"ok": true
},
"net_pending": {
"files": {
"Drivers/BSP/NET/net_pending.c": 194
},
"lines_total": 194,
"ok": true
},
"net_select": {
"files": {
"Drivers/BSP/NET/net_select.c": 117
},
"lines_total": 117,
"ok": true
},
"phasor": {
"files": {
"App/algo/phasor.c": 55,
"App/algo/sample_ring.c": 41
},
"lines_total": 96,
"ok": true
},
"prot_elm": {
"files": {
"App/algo/freq.c": 130,
"App/algo/phasor.c": 55,
"App/algo/prot_elm.c": 106,
"App/algo/prot_set.c": 179,
"App/algo/sample_ring.c": 41
},
"lines_total": 511,
"ok": true
},
"prot_set": {
"files": {
"App/algo/prot_set.c": 179
},
"lines_total": 179,
"ok": true
},
"prot_set_store": {
"files": {
"App/algo/prot_set.c": 179,
"App/cfg/prot_set_store.c": 29
},
"lines_total": 208,
"ok": true
},
"sha256": {
"files": {
"App/util/sha256.c": 114
},
"lines_total": 114,
"ok": true
},
"soe": {
"files": {
"App/log/soe_rec.c": 87,
"App/protocol/iec104/iec104_asdu.c": 103
},
"lines_total": 190,
"ok": true
},
"sys_clock": {
"files": {
"App/util/sys_clock.c": 157
},
"lines_total": 157,
"ok": true
},
"task_prof": {
"files": {
"App/util/task_prof.c": 93
},
"lines_total": 93,
"ok": true
},
"time_sync": {
"files": {
"App/sync/time_sync.c": 99
},
"lines_total": 99,
"ok": true
},
"vol_lock": {
"files": {
"App/fs/vol_lock.c": 17
},
"lines_total": 17,
"ok": true
},
"watchdog": {
"files": {
"App/util/task_watchdog.c": 55
},
"lines_total": 55,
"ok": true
}
},
"note": "主机单测覆盖率基线。口径与工具链限制见 docs/测试说明/主机单测覆盖率口径.md。门禁判据:files[].lines_covered 与 funcs_covered 只增不减,下降即拦提交。",
"schema": 1,
"tested_sources": [
"App/algo/adc_health.c",
"App/algo/adc_input.c",
"App/algo/freq.c",
"App/algo/phasor.c",
"App/algo/prot_elm.c",
"App/algo/prot_set.c",
"App/algo/sample_ring.c",
"App/cfg/cfg_file.c",
"App/cfg/prot_set_store.c",
"App/fs/vol_lock.c",
"App/log/soe_rec.c",
"App/protocol/iec104/iec104_asdu.c",
"App/protocol/iec104/iec104_link.c",
"App/protocol/iec104/iec104_pts.c",
"App/sync/time_sync.c",
"App/task/iec104_serv.c",
"App/util/sha256.c",
"App/util/sys_clock.c",
"App/util/task_prof.c",
"App/util/task_watchdog.c",
"Drivers/BSP/GD5F2GQ5UE/nand_ftl.c",
"Drivers/BSP/NET/lftpd/lftpd.c",
"Drivers/BSP/NET/lftpd/lftpd_auth.c",
"Drivers/BSP/NET/lftpd/lftpd_inet.c",
"Drivers/BSP/NET/lftpd/lftpd_io.c",
"Drivers/BSP/NET/lftpd/lftpd_string.c",
"Drivers/BSP/NET/net_events.c",
"Drivers/BSP/NET/net_listener.c",
"Drivers/BSP/NET/net_pending.c",
"Drivers/BSP/NET/net_select.c",
"Drivers/BSP/NETCFG/ftp_cfg.c",
"Drivers/BSP/NETCFG/net_cfg.c"
],
"totals": {
"files_generated_excluded": 5,
"files_tested": 32,
"files_untested": 112,
"func_rate": 0.9009,
"funcs_covered": 300,
"funcs_valid": 333,
"line_rate": 0.8582,
"lines_covered": 4020,
"lines_valid": 4684,
"modules_ok": 28,
"modules_total": 28
},
"untested_sources": [
{
"code_lines": 1251,
"path": "Drivers/BSP/NET/net_socket.c"
},
{
"code_lines": 801,
"path": "Drivers/BSP/CH395F/ch395f.c"
},
{
"code_lines": 689,
"path": "Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c"
},
{
"code_lines": 458,
"path": "App/log/sys_log_mcu.c"
},
{
"code_lines": 329,
"path": "Drivers/BSP/UI/ui_menu_view.c"
},
{
"code_lines": 323,
"path": "Drivers/BSP/UI/ui_net_sink.c"
},
{
"code_lines": 300,
"path": "Drivers/BSP/UI/ui_content_page.c"
},
{
"code_lines": 281,
"path": "App/task/default_task.c"
},
{
"code_lines": 265,
"path": "App/log/console_log.c"
},
{
"code_lines": 264,
"path": "Drivers/BSP/SD2506/sd2506.c"
},
{
"code_lines": 206,
"path": "Drivers/BSP/CH395F/ch395f.h"
},
{
"code_lines": 205,
"path": "Drivers/BSP/UI/ui_page_net_cfg.c"
},
{
"code_lines": 204,
"path": "Drivers/BSP/UI/ui_standby.c"
},
{
"code_lines": 171,
"path": "App/task/adc_task.c"
},
{
"code_lines": 157,
"path": "Drivers/BSP/TPAFE5160/tpafe5160.c"
},
{
"code_lines": 150,
"path": "Drivers/BSP/UI/ui_page_password.c"
},
{
"code_lines": 135,
"path": "App/app/app_main.c"
},
{
"code_lines": 129,
"path": "App/cfg/cfg_file_store_mcu.c"
},
{
"code_lines": 126,
"path": "Drivers/BSP/UI/ui_menu.c"
},
{
"code_lines": 123,
"path": "App/log/soe_store.c"
},
{
"code_lines": 123,
"path": "App/task/net_task.c"
},
{
"code_lines": 100,
"path": "Drivers/BSP/NET/net_types.h"
},
{
"code_lines": 98,
"path": "Drivers/BSP/SD2506/sd2506.h"
},
{
"code_lines": 93,
"path": "Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h"
},
{
"code_lines": 90,
"path": "App/task/protect_task.c"
},
{
"code_lines": 83,
"path": "Drivers/BSP/NET/net_socket.h"
},
{
"code_lines": 79,
"path": "Drivers/BSP/RS485/rs485.c"
},
{
"code_lines": 79,
"path": "Drivers/BSP/UI/ui_port.c"
},
{
"code_lines": 77,
"path": "App/algo/prot_set.h"
},
{
"code_lines": 77,
"path": "Drivers/BSP/dbg_log.h"
},
{
"code_lines": 71,
"path": "App/task/ui_task.c"
},
{
"code_lines": 62,
"path": "App/protocol/iec104/iec104_asdu.h"
},
{
"code_lines": 56,
"path": "App/protocol/iec104/iec104_link.h"
},
{
"code_lines": 54,
"path": "Drivers/BSP/TPAFE5160/tpafe5160.h"
},
{
"code_lines": 51,
"path": "App/util/task_watchdog.h"
},
{
"code_lines": 50,
"path": "App/algo/adc_health.h"
},
{
"code_lines": 44,
"path": "App/algo/adc_input.h"
},
{
"code_lines": 44,
"path": "App/log/soe_rec.h"
},
{
"code_lines": 42,
"path": "Drivers/BSP/UI/ui_content_page.h"
},
{
"code_lines": 41,
"path": "App/cfg/cfg_file.h"
},
{
"code_lines": 41,
"path": "Drivers/BSP/NET/lftpd/private/lftpd_status.h"
},
{
"code_lines": 41,
"path": "Drivers/BSP/UI/ui_page_ac.c"
},
{
"code_lines": 34,
"path": "Drivers/BSP/NETCFG/net_cfg.h"
},
{
"code_lines": 34,
"path": "Drivers/BSP/UI/ui_w_records.c"
},
{
"code_lines": 33,
"path": "Drivers/BSP/NET/net_config.h"
},
{
"code_lines": 33,
"path": "Drivers/BSP/NET/net_select.h"
},
{
"code_lines": 32,
"path": "Drivers/BSP/RS485/rs485.h"
},
{
"code_lines": 31,
"path": "App/log/sys_log.h"
},
{
"code_lines": 31,
"path": "Drivers/BSP/UI/ui_deco.c"
},
{
"code_lines": 29,
"path": "Drivers/BSP/NET/lftpd/lftpd_auth.h"
},
{
"code_lines": 28,
"path": "App/algo/prot_elm.h"
},
{
"code_lines": 28,
"path": "App/protocol/iec104/iec104_pts.h"
},
{
"code_lines": 27,
"path": "App/algo/phasor.h"
},
{
"code_lines": 27,
"path": "App/task/task_handles.h"
},
{
"code_lines": 27,
"path": "Drivers/BSP/UI/ui_page_device_info.c"
},
{
"code_lines": 26,
"path": "App/util/task_prof.h"
},
{
"code_lines": 26,
"path": "Drivers/BSP/NET/net_pending.h"
},
{
"code_lines": 25,
"path": "Drivers/BSP/UI/ui_port.h"
},
{
"code_lines": 25,
"path": "Drivers/BSP/bsp_err.h"
},
{
"code_lines": 24,
"path": "Drivers/BSP/UI/ui_tokens.h"
},
{
"code_lines": 22,
"path": "App/cfg/cfg_file_store.h"
},
{
"code_lines": 22,
"path": "App/util/sha256.h"
},
{
"code_lines": 22,
"path": "Drivers/BSP/NETCFG/ftp_cfg.h"
},
{
"code_lines": 22,
"path": "Drivers/BSP/UI/ui_page_runlog.c"
},
{
"code_lines": 22,
"path": "Drivers/BSP/UI/ui_page_selftest.c"
},
{
"code_lines": 20,
"path": "Drivers/BSP/UI/ui_page_dc.c"
},
{
"code_lines": 19,
"path": "App/log/console_log.h"
},
{
"code_lines": 19,
"path": "Drivers/BSP/UI/ui_w_records.h"
},
{
"code_lines": 18,
"path": "App/app/sys_events.h"
},
{
"code_lines": 18,
"path": "Drivers/BSP/NET/lftpd/lftpd.h"
},
{
"code_lines": 18,
"path": "Drivers/BSP/NET/net_listener.h"
},
{
"code_lines": 18,
"path": "Drivers/BSP/UI/ui_net_sink.h"
},
{
"code_lines": 17,
"path": "App/algo/sample_ring.h"
},
{
"code_lines": 16,
"path": "Drivers/BSP/UI/ui_menu_view.h"
},
{
"code_lines": 15,
"path": "App/algo/freq.h"
},
{
"code_lines": 15,
"path": "App/util/sys_clock.h"
},
{
"code_lines": 15,
"path": "Drivers/BSP/UI/ui_net_sink_backend_mcu.c"
},
{
"code_lines": 15,
"path": "Drivers/BSP/UI/ui_standby.h"
},
{
"code_lines": 14,
"path": "Drivers/BSP/UI/ui_style.c"
},
{
"code_lines": 13,
"path": "App/log/soe_store.h"
},
{
"code_lines": 13,
"path": "App/task/adc_task.h"
},
{
"code_lines": 13,
"path": "Drivers/BSP/NET/net_events.h"
},
{
"code_lines": 13,
"path": "Drivers/BSP/UI/ui_pwd_def.h"
},
{
"code_lines": 12,
"path": "App/app/app_main.h"
},
{
"code_lines": 12,
"path": "App/fs/vol_lock.h"
},
{
"code_lines": 12,
"path": "Drivers/BSP/GD5F2GQ5UE/nand_ftl.h"
},
{
"code_lines": 12,
"path": "Drivers/BSP/NET/lftpd/private/lftpd_io.h"
},
{
"code_lines": 12,
"path": "Drivers/BSP/UI/ui_net_sink_backend.h"
},
{
"code_lines": 11,
"path": "App/sync/time_sync.h"
},
{
"code_lines": 11,
"path": "App/task/net_task.h"
},
{
"code_lines": 11,
"path": "App/task/rs485_task.c"
},
{
"code_lines": 11,
"path": "App/task/rs485_task.h"
},
{
"code_lines": 11,
"path": "App/task/ui_task.h"
},
{
"code_lines": 11,
"path": "Drivers/BSP/NET/lftpd/private/lftpd_inet.h"
},
{
"code_lines": 11,
"path": "Drivers/BSP/UI/ui_menu.h"
},
{
"code_lines": 11,
"path": "Drivers/BSP/UI/ui_page_net_cfg.h"
},
{
"code_lines": 11,
"path": "Drivers/BSP/UI/ui_style.h"
},
{
"code_lines": 11,
"path": "Drivers/BSP/dbg_cfg.h"
},
{
"code_lines": 10,
"path": "App/task/default_task.h"
},
{
"code_lines": 6,
"path": "Drivers/BSP/UI/ui_page_password.h"
},
{
"code_lines": 5,
"path": "App/cfg/prot_set_store.h"
},
{
"code_lines": 5,
"path": "App/task/iec104_serv.h"
},
{
"code_lines": 5,
"path": "App/task/protect_task.h"
},
{
"code_lines": 5,
"path": "Drivers/BSP/NET/lftpd/private/lftpd_log.h"
},
{
"code_lines": 5,
"path": "Drivers/BSP/UI/ui_deco.h"
},
{
"code_lines": 5,
"path": "Drivers/BSP/UI/ui_page_ac.h"
},
{
"code_lines": 5,
"path": "Drivers/BSP/UI/ui_page_dc.h"
},
{
"code_lines": 5,
"path": "Drivers/BSP/UI/ui_page_device_info.h"
},
{
"code_lines": 4,
"path": "Drivers/BSP/UI/ui_page_runlog.h"
},
{
"code_lines": 4,
"path": "Drivers/BSP/UI/ui_page_selftest.h"
},
{
"code_lines": 3,
"path": "App/app/app_cfg.h"
},
{
"code_lines": 3,
"path": "Drivers/BSP/NET/lftpd/private/lftpd_string.h"
}
]
}
+217
View File
@@ -0,0 +1,217 @@
/*
* 模块名称:假 CH395F 芯片实现(NET 域主机侧单测共享件)
* 模块功能:实现 ch395f_fake.h 声明的假芯片。由测试单元 #include 本 .c 使用
* (本仓主机单测的既有做法:生产源与假件都进同一个 TU)。
* 创建日期:2026-10-23
*/
#include <string.h>
#include "ch395f_fake.h"
ch395f_fake_world_t g_fake;
ch395f_fake_obs_t g_chip;
static void fake_log(char c)
{
if (g_chip.log_n < (CH395F_FAKE_LOG_LEN - 1)) {
g_chip.log[g_chip.log_n++] = c;
g_chip.log[g_chip.log_n] = '\0';
}
}
void ch395f_fake_reset(void)
{
int i;
(void)memset(&g_fake, 0, sizeof(g_fake));
(void)memset(&g_chip, 0, sizeof(g_chip));
for (i = 0; i < CH395F_FAKE_SOCK_MAX; i++) {
g_fake.cached_open[i] = CH395F_SOCKET_CLOSED;
g_fake.cached_tcp[i] = CH395F_TCP_CLOSED;
g_fake.status_byte0[i] = CH395F_SOCKET_CLOSED;
g_fake.status_byte1[i] = CH395F_TCP_CLOSED;
g_fake.link_up = 1U;
g_fake.open_rc[i] = CH395F_ERR_SUCCESS;
g_fake.listen_rc[i] = CH395F_ERR_SUCCESS;
}
}
void ch395f_fake_push_recv_len(uint8_t sock, uint16_t v)
{
if ((sock < CH395F_FAKE_SOCK_MAX) &&
(g_fake.recv_len_n[sock] < CH395F_FAKE_QUEUE_LEN)) {
g_fake.recv_len_q[sock][g_fake.recv_len_n[sock]++] = v;
}
}
void ch395f_fake_set_peer(uint8_t sock, uint8_t b0, uint8_t b1, uint8_t b2,
uint8_t b3, uint16_t peer_port)
{
if (sock >= CH395F_FAKE_SOCK_MAX) {
return;
}
g_fake.remote[sock][0] = b0;
g_fake.remote[sock][1] = b1;
g_fake.remote[sock][2] = b2;
g_fake.remote[sock][3] = b3;
g_fake.remote[sock][4] = (uint8_t)(peer_port & 0xFFU);
g_fake.remote[sock][5] = (uint8_t)((peer_port >> 8) & 0xFFU);
}
void ch395f_fake_set_listening(uint8_t sock)
{
if (sock < CH395F_FAKE_SOCK_MAX) {
g_fake.cached_open[sock] = CH395F_SOCKET_OPEN;
g_fake.cached_tcp[sock] = CH395F_TCP_LISTEN;
}
}
ch395f_sock_state_t ch395f_sock_state_cached(uint8_t sn)
{
ch395f_sock_state_t s;
g_chip.cached_calls++;
g_chip.last_sock = sn;
s.open = (sn < CH395F_FAKE_SOCK_MAX) ? g_fake.cached_open[sn] : CH395F_SOCKET_CLOSED;
s.tcp_state = (sn < CH395F_FAKE_SOCK_MAX) ? g_fake.cached_tcp[sn] : CH395F_TCP_CLOSED;
return s;
}
ch395f_sock_state_t ch395f_get_sock_state(uint8_t sn)
{
return ch395f_sock_state_cached(sn);
}
void ch395f_get_socket_status(uint8_t sock, uint8_t *status)
{
g_chip.get_status_calls++;
g_chip.last_sock = sock;
fake_log('s');
if (status != NULL) {
status[0] = (sock < CH395F_FAKE_SOCK_MAX) ? g_fake.status_byte0[sock] : CH395F_SOCKET_CLOSED;
status[1] = (sock < CH395F_FAKE_SOCK_MAX) ? g_fake.status_byte1[sock] : CH395F_TCP_CLOSED;
}
}
/* 整体读即清:返回后该 socket 的挂起位图清零(真实芯片没有按位清除手段) */
uint8_t ch395f_get_sock_int_status(uint8_t sock)
{
uint8_t v = 0U;
g_chip.int_status_calls++;
g_chip.last_sock = sock;
fake_log('i');
if (sock < CH395F_FAKE_SOCK_MAX) {
v = g_fake.int_status[sock];
g_fake.int_status[sock] = 0U;
}
return v;
}
uint16_t ch395f_get_recv_len(uint8_t sock)
{
uint16_t v;
g_chip.last_sock = sock;
fake_log('R');
if ((sock >= CH395F_FAKE_SOCK_MAX) || (g_fake.recv_len_n[sock] == 0)) {
return 0U;
}
/* 按调用序号取;耗尽后回落最后一个值(便于表达"一直有数据"的持续态) */
if (g_fake.recv_len_i[sock] < g_fake.recv_len_n[sock]) {
v = g_fake.recv_len_q[sock][g_fake.recv_len_i[sock]++];
} else {
v = g_fake.recv_len_q[sock][g_fake.recv_len_n[sock] - 1];
}
return v;
}
void ch395f_read_recv_buf(uint8_t sock, uint8_t *p_data, uint16_t len)
{
g_chip.read_calls++;
g_chip.last_sock = sock;
g_chip.last_read_len = len;
fake_log('r');
if ((p_data != NULL) && (len > 0U)) {
(void)memset(p_data, 0, len); /* 交出去的是零,测试不依赖内容 */
}
}
void ch395f_write_send_buf(uint8_t sock, uint8_t *p_data, uint16_t len)
{
(void)p_data;
g_chip.write_calls++;
g_chip.last_sock = sock;
g_chip.last_read_len = len;
fake_log('w');
}
uint8_t ch395f_open_socket(uint8_t sock)
{
g_chip.open_calls++;
g_chip.last_sock = sock;
fake_log('o');
return (sock < CH395F_FAKE_SOCK_MAX) ? g_fake.open_rc[sock] : 0x01U;
}
uint8_t ch395f_close_socket(uint8_t sock)
{
g_chip.close_calls++;
g_chip.last_sock = sock;
fake_log('c');
return CH395F_ERR_SUCCESS;
}
uint8_t ch395f_tcp_listen(uint8_t sock)
{
g_chip.listen_calls++;
g_chip.last_sock = sock;
fake_log('l');
return (sock < CH395F_FAKE_SOCK_MAX) ? g_fake.listen_rc[sock] : 0x01U;
}
void ch395f_set_proto_type(uint8_t sock, uint8_t proto)
{
g_chip.set_proto_calls++;
g_chip.last_sock = sock;
g_chip.last_proto = proto;
fake_log('P');
}
void ch395f_set_sour_port(uint8_t sock, uint16_t port)
{
g_chip.set_port_calls++;
g_chip.last_sock = sock;
g_chip.last_port = port;
fake_log('p');
}
void ch395f_get_remot_ipp(uint8_t sock, uint8_t *p_buf)
{
if (p_buf == NULL) {
return;
}
if (sock < CH395F_FAKE_SOCK_MAX) {
(void)memcpy(p_buf, g_fake.remote[sock], 6U);
} else {
(void)memset(p_buf, 0, 6U);
}
}
void ch395f_set_keepalive_enable(uint8_t sock, uint8_t enable)
{
g_chip.keepalive_calls++;
g_chip.last_sock = sock;
g_chip.last_keepalive = enable;
}
uint8_t ch395f_link_up_cached(void)
{
return g_fake.link_up;
}
uint16_t ch395f_link_change_count(void)
{
return g_fake.link_change;
}
+146
View File
@@ -0,0 +1,146 @@
/*
* 模块名称:假 CH395F 芯片(NET 域主机侧单测共享件)
* 模块功能:给 NET 域的主机侧单测一个**可脚本化的假芯片**。真实 ch395f.h 拉 HAL 与
* SPI 寄存器定义,PC 上编不动;而 net_listener / net_pending / net_events
* 的行为恰恰完全由"芯片此刻报什么状态"决定,必须有假件才能测。
*
* ⚠ 仅供 test/host/** 使用,不参与 MCU 构建。
*
* 为什么值得共享一份(而不是每模块自带桩):三份假芯片各自漂移时,"假件与被测代码
* 同时错"这类问题再也不会报出来。各模块的 stub/ch395f.h 只做一件事——include 本文件,
* 于是签名与常量只有一份定义。
*
* 常量与类型必须与 Drivers/BSP/CH395F/ch395f.h 逐字一致(2026-10-23 从真实头抄录):
* CH395F_ERR_SUCCESS 0x00 / CH395F_PROTO_TYPE_TCP 0x03 / CH395F_SOCKET_CLOSED 0x00
* CH395F_TCP_CLOSED 0x00 / CH395F_TCP_LISTEN 0x01;ch395f_sock_state_t 为
* { uint8_t open; uint8_t tcp_state; } 两字节。
*
* 必须复刻的两条芯片怪癖(不复刻就等于把被测代码的坑假件掉):
* 1. ch395f_get_sock_int_status() 是**整体读即清**、芯片没有按位清除手段。本假件
* 返回该 socket 的挂起位图并同步清零。AGENTS 模块约定里 B3 的实证坑("顺手
* 全量清一遍"把别的在用 Socket 的门焊死)靠这条才照得出来。
* 2. *_cached 系列返回的是**缓存值**而不是现场值:open 状态与 TCP 状态分开、且
* "监听还活着"的判据是 (open != CLOSED && tcp_state == LISTEN)。测试据此能构造
* "缓存陈旧"的形态。
*
* 脚本化模型(沿用本仓既有假件风格:nand_port 的真头+假 diskio、lftpd_io 的可注入假堆):
* - 每个读函数一条按调用序号取的返回队列,队列耗尽后回落到**最后一个值**(便于
* 写"一直可读""一直关不掉"这类持续状态);
* - 每个写/控制函数一条调用记录(次数 + 最近一次入参),另给逐函数调用计数,
* 让"这一轮根本不该碰芯片"成为可断言的事;
* - g_chip.log[] 记录**调用顺序**(短名),用于断言"先清接收、再关、再读即清"这类
* 时序契约;
* - ch395f_fake_reset() 一次全清。
*
* 创建日期:2026-10-23
*/
#ifndef CH395F_FAKE_H
#define CH395F_FAKE_H
#include <stdint.h>
/* —— 与真实 ch395f.h 逐字一致的常量(值抄自真实头,勿"顺手改")—— */
#define CH395F_ERR_SUCCESS 0x00U
#define CH395F_PROTO_TYPE_TCP 0x03U
#define CH395F_SOCKET_CLOSED 0x00U
#define CH395F_SOCKET_OPEN 0x01U
#define CH395F_TCP_CLOSED 0x00U
#define CH395F_TCP_LISTEN 0x01U
#define CH395F_TCP_ESTABLISHED 0x05U
/* 发送/接收缓冲尺寸宏链:**与真实 ch395f.h 同构转抄**(2026-10-23 逐条核对)。
* net_pending.h 里的 NET_SEND_CHUNK_MAX 由这两个宏推导,是"一次最多往芯片写多少"
* 的真实上界,假件不能自己发明数字(否则分块测试就是在测假件的常量):
* RAM_BLOCK_SIZE 512 / SEND_BLOCKS 2 → SEND_BUF 1024
* RECV_BLOCKS 4 → RECV_BUF 2048 → SPI_DMA_BUF 2052 → SPI_DMA_MAX_PAYLOAD 2048
* ⇒ NET_SEND_CHUNK_MAX = min(1024, 2048) = 1024 */
#define CH395F_RAM_BLOCK_SIZE 512U
#define CH395F_SEND_BLOCKS 2U
#define CH395F_SEND_BUF_SIZE_BYTES (CH395F_SEND_BLOCKS * CH395F_RAM_BLOCK_SIZE)
#define CH395F_RECV_BLOCKS 4U
#define CH395F_RECV_BUF_SIZE_BYTES (CH395F_RECV_BLOCKS * CH395F_RAM_BLOCK_SIZE)
#define CH395F_SPI_DMA_BUF_SIZE (CH395F_RECV_BUF_SIZE_BYTES + 4U)
#define CH395F_SPI_DMA_MAX_PAYLOAD (CH395F_SPI_DMA_BUF_SIZE - 4U)
#define CH395F_FAKE_SOCK_MAX 8 /* 与 NET_MAX_SOCKETS 同量级,测试自用 */
#define CH395F_FAKE_QUEUE_LEN 24
#define CH395F_FAKE_LOG_LEN 96
typedef struct {
uint8_t open;
uint8_t tcp_state;
} ch395f_sock_state_t;
/* —— 可预置的芯片侧世界 —— */
typedef struct {
uint8_t cached_open[CH395F_FAKE_SOCK_MAX]; /* sock_state_cached().open */
uint8_t cached_tcp[CH395F_FAKE_SOCK_MAX]; /* sock_state_cached().tcp_state */
uint8_t status_byte0[CH395F_FAKE_SOCK_MAX]; /* get_socket_status 的 sock 状态 */
uint8_t status_byte1[CH395F_FAKE_SOCK_MAX]; /* get_socket_status 的 TCP 状态 */
uint8_t int_status[CH395F_FAKE_SOCK_MAX]; /* 读即清的挂起位图 */
uint16_t recv_len_q[CH395F_FAKE_SOCK_MAX][CH395F_FAKE_QUEUE_LEN];
int recv_len_n[CH395F_FAKE_SOCK_MAX];
int recv_len_i[CH395F_FAKE_SOCK_MAX];
uint8_t remote[CH395F_FAKE_SOCK_MAX][6]; /* get_remot_ipp 的 6 字节:ip[4]+port(小端) */
uint16_t link_change; /* link_change_count(链路变更计数) */
uint8_t link_up; /* link_up_cached */
uint8_t open_rc[CH395F_FAKE_SOCK_MAX]; /* open_socket 返回码 */
uint8_t listen_rc[CH395F_FAKE_SOCK_MAX]; /* tcp_listen 返回码 */
} ch395f_fake_world_t;
typedef struct {
/* 调用计数("不该碰芯片"的断言面) */
int open_calls;
int close_calls;
int listen_calls;
int int_status_calls;
int get_status_calls;
int read_calls;
int write_calls;
int set_proto_calls;
int set_port_calls;
int cached_calls;
/* 最近一次入参 */
uint8_t last_sock;
uint16_t last_read_len;
uint16_t last_port;
uint8_t last_proto;
uint8_t last_keepalive;
int keepalive_calls;
/* 调用顺序日志(短名首字母):
* 'c'=close 'i'=int_status(读即清) 's'=get_socket_status 'r'=read_recv_buf
* 'R'=get_recv_len 'o'=open_socket 'l'=tcp_listen 'p'=set_sour_port
* 'P'=set_proto_type */
char log[CH395F_FAKE_LOG_LEN];
int log_n;
} ch395f_fake_obs_t;
extern ch395f_fake_world_t g_fake;
extern ch395f_fake_obs_t g_chip;
void ch395f_fake_reset(void); /* 全清并把所有 socket 设为"已关闭" */
void ch395f_fake_push_recv_len(uint8_t sock, uint16_t v);
void ch395f_fake_set_listening(uint8_t sock);
void ch395f_fake_set_peer(uint8_t sock, uint8_t b0, uint8_t b1, uint8_t b2,
uint8_t b3, uint16_t peer_port); /* 预置对端六元组 */ /* 缓存:open!=CLOSED 且 tcp=LISTEN */
/* —— 假芯片实现(签名与真实 ch395f.h 一字不改)—— */
ch395f_sock_state_t ch395f_sock_state_cached(uint8_t sn);
ch395f_sock_state_t ch395f_get_sock_state(uint8_t sn);
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);
#endif /* CH395F_FAKE_H */
+244
View File
@@ -0,0 +1,244 @@
/*
* 模块名称:FTP 鉴权配置([ftp] 段)主机侧单元测试
* 模块功能:在 PC 上离线验证 Drivers/BSP/NETCFG/ftp_cfg.c 的注册契约与
* "出厂默认口令是否在用"的判定(大小写不敏感)。
*
* 被测对象:真实源文件 ../../../Drivers/BSP/NETCFG/ftp_cfg.c(#include 原样纳入)。
* 桩只有一个:真实头 App/cfg/cfg_file.h + 本文件给出的 cfg_file_register
* 可控实现(按值留档注册描述符)。本模块**零 stub 目录**——ftp_cfg.c 不
* 包含 dbg_log.h,也没有别的平台依赖。
*
* 为什么要主机侧单测(而不是只靠台架):
* 1. 这个模块是 **FTP 的门禁**(N-19):pwd_hash 存的是 SHA-256(user‖password),
* 判定写成大小写敏感就会在"用户手输大写 hex"时把正确口令当错误;写成敏感于
* 默认值大小写则会误报"已改口令",让出厂默认口令的启动告警**静默失效**——
* 现场表现是"以为改了口令"。
* 2. [ftp] 段刻意**不挂变更回调**(口令在下次登录 PASS 时才生效):哪天有人"顺手
* 补一个回调",poll 重载会去重建网络——这条契约只能靠断言钉住(TC-FTPCFG-004)。
* 3. write_en 出厂 = 1 是刻意的(FTP 推 config.ini 是既有维护工作流),改成 0
* 会让现场升级流程直接失败;同样需要一个显式断言(TC-FTPCFG-005)。
* ⚠ DEF_HASH 这个常量本身的正确性由 test/host/sha256/ 的 TC-SHA256-008 钉
* (它算 SHA256("adminadmin") 并与 FTP_CFG_DEF_HASH 比对),本模块不重复算。
*
* 覆盖点:
* TC-FTPCFG-001a get 返回恒非空(静态常驻,load 前后都可用)
* TC-FTPCFG-001 未注册即可 get:user=admin(出厂默认口令在用)
* TC-FTPCFG-002 pwd_hash 初值 = FTP_CFG_DEF_HASH,write_en 初值 = 1
* TC-FTPCFG-003 注册幂等:重复 register 只向引擎登记一次
* TC-FTPCFG-004 注册内容:段名 ftp / 三键 / 尺寸 / 默认值指针 / **p_cb 必须为 NULL**
* TC-FTPCFG-005 键表逐项:user(STR,16) / pwd_hash(STR,65) / write(UINT,1)
* TC-FTPCFG-006 出厂态 is_default_pwd = 1
* TC-FTPCFG-007 改了口令 → is_default_pwd = 0
* TC-FTPCFG-008 哈希存成**大写** hex 仍判"默认口令在用"(大小写不敏感)
* TC-FTPCFG-009 空哈希不是默认口令(且不读越界)
* TC-FTPCFG-010 混入非字母字符的串按逐字符比较(数字/下划线不被折叠改写)
* TC-FTPCFG-011 前缀关系不算相等("abc" vs "abcd")
* TC-FTPCFG-012 任一参数为 NULL 判不等(is_default_pwd 走不到,故直测静态函数)
* TC-FTPCFG-013 两串都为 NULL 也判不等(fail-closed,不许"都为空=相等")
* TC-FTPCFG-014 缓冲尺寸契约:user 16 / hash 65(64 hex + NUL)
*
* 用例顺序有依赖:001/002/006 必须在任何"改写 RAM 副本"的用例之前跑。
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-23
*/
#include <stdio.h>
#include <string.h>
#include "cfg_file.h"
#include "ftp_cfg.h"
#include "ftp_cfg.c"
/* ===================== 断言框架 ===================== */
static int s_total;
static int s_passed;
static int s_failed;
#define CHECK(cond, name) do { \
s_total++; \
if (cond) { s_passed++; printf(" PASS %s\n", name); } \
else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \
} while (0)
/* ===================== 假引擎:只留注册记录 ===================== */
typedef struct {
char name[CFG_FILE_MAX_NAME_LEN + 1u];
const void *p_data;
const void *p_defaults;
size_t data_size;
uint8_t key_count;
void (*p_cb)(void);
const cfg_key_def_t *p_keys;
} fake_reg_t;
static fake_reg_t g_reg;
static int g_reg_n;
static void fake_copy_name(char *dst, const char *src, size_t cap)
{
size_t i;
for (i = 0u; (i + 1u < cap) && (src[i] != '\0'); i++) {
dst[i] = src[i];
}
dst[i] = '\0';
}
int cfg_file_register(const cfg_section_reg_t *p_reg)
{
if ((p_reg == NULL) || (p_reg->name == NULL)) {
return -1;
}
fake_copy_name(g_reg.name, p_reg->name, sizeof(g_reg.name));
g_reg.p_data = p_reg->p_data;
g_reg.p_defaults = p_reg->p_defaults;
g_reg.data_size = p_reg->data_size;
g_reg.key_count = p_reg->key_count;
g_reg.p_cb = p_reg->p_cb;
g_reg.p_keys = p_reg->p_keys;
g_reg_n++;
return 0;
}
static void fake_reset(void)
{
(void)memset(&g_reg, 0, sizeof(g_reg));
g_reg_n = 0;
}
/* 把 s_cfg.pwd_hash 写成任意串(白盒:引擎就是这么原地更新 RAM 副本的) */
static void set_hash(const char *p_src)
{
size_t i;
for (i = 0u; (i + 1u < FTP_CFG_HASH_MAX) && (p_src[i] != '\0'); i++) {
s_cfg.pwd_hash[i] = p_src[i];
}
s_cfg.pwd_hash[i] = '\0';
}
/* 把出厂哈希原样写成大写形态(引擎写入的 hex 大小写不可控,判定必须容忍) */
static void set_hash_upper(void)
{
size_t i;
for (i = 0u; i + 1u < FTP_CFG_HASH_MAX; i++) {
char ch = FTP_CFG_DEF_HASH[i];
if ((ch >= 'a') && (ch <= 'z')) {
ch = (char)(ch - 32);
}
s_cfg.pwd_hash[i] = ch;
if (ch == '\0') {
break;
}
}
}
/* ===================== 注册契约 ===================== */
static void tc_register(void)
{
printf("-- TC-FTPCFG-003..005 register --\n");
fake_reset();
ftp_cfg_register();
CHECK(g_reg_n == 1, "TC-FTPCFG-003a 首次注册一次");
ftp_cfg_register();
CHECK(g_reg_n == 1, "TC-FTPCFG-003 重复 register 不重复登记(幂等)");
CHECK((strcmp(g_reg.name, "ftp") == 0) &&
(g_reg.data_size == sizeof(ftp_cfg_t)) &&
(g_reg.key_count == 3u) &&
(g_reg.p_defaults != NULL) &&
(g_reg.p_data == (const void *)&s_cfg) &&
(g_reg.p_cb == NULL),
"TC-FTPCFG-004 段名/尺寸/键数/默认值/**p_cb 必须为 NULL**");
CHECK((strcmp(g_reg.p_keys[0].name, "user") == 0) &&
(g_reg.p_keys[0].type == CFG_VAL_STR) &&
(g_reg.p_keys[0].len == FTP_CFG_USER_MAX) &&
(g_reg.p_keys[0].offset == offsetof(ftp_cfg_t, user)) &&
(strcmp(g_reg.p_keys[1].name, "pwd_hash") == 0) &&
(g_reg.p_keys[1].type == CFG_VAL_STR) &&
(g_reg.p_keys[1].len == FTP_CFG_HASH_MAX) &&
(strcmp(g_reg.p_keys[2].name, "write") == 0) &&
(g_reg.p_keys[2].type == CFG_VAL_UINT) &&
(g_reg.p_keys[2].len == 1u),
"TC-FTPCFG-005 键表逐项:user(STR,16)/pwd_hash(STR,65)/write(UINT,1)");
}
/* ===================== 出厂默认与只读访问 ===================== */
static void tc_defaults(void)
{
const ftp_cfg_t *p = ftp_cfg_get();
printf("-- TC-FTPCFG-001..002,014 defaults --\n");
/* 契约就是"恒非空"(静态常驻),单独一条断言;随后的显式守卫既让
* test-cppcheck 不再报"冗余或空指针二选一",也让真出问题时报在 001a 上 */
CHECK(p != NULL, "TC-FTPCFG-001a ftp_cfg_get 恒非空(静态常驻)");
if (p == NULL) {
return;
}
CHECK(strcmp(p->user, "admin") == 0, "TC-FTPCFG-001 未注册也可 get,user = admin");
CHECK((strcmp(p->pwd_hash, FTP_CFG_DEF_HASH) == 0) && (p->write_en == 1u),
"TC-FTPCFG-002 初值 = 出厂哈希 + write_en=1(维护工作流依赖)");
CHECK((FTP_CFG_USER_MAX == 16u) && (FTP_CFG_HASH_MAX == 65u) &&
(sizeof(p->user) == FTP_CFG_USER_MAX) &&
(sizeof(p->pwd_hash) == FTP_CFG_HASH_MAX),
"TC-FTPCFG-014 缓冲尺寸契约:user 16 / hash 65(64 hex + NUL)");
}
/* ===================== 默认口令判定(启动告警的依据) ===================== */
static void tc_default_pwd(void)
{
printf("-- TC-FTPCFG-006..009 default-password detection --\n");
CHECK(ftp_cfg_is_default_pwd() == 1, "TC-FTPCFG-006 出厂态判为默认口令");
set_hash("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
CHECK(ftp_cfg_is_default_pwd() == 0, "TC-FTPCFG-007 改了口令 → 不再默认");
set_hash_upper();
CHECK(ftp_cfg_is_default_pwd() == 1,
"TC-FTPCFG-008 大写 hex 仍判默认(比较必须大小写不敏感)");
set_hash("");
CHECK(ftp_cfg_is_default_pwd() == 0, "TC-FTPCFG-009 空哈希不是默认口令");
}
/* ===================== 静态比较函数(NULL / 折叠 / 前缀) ===================== */
static void tc_ci_eq(void)
{
printf("-- TC-FTPCFG-010..013 case-insensitive compare --\n");
CHECK(s_str_ci_eq("A_b1!Z", "a_b1!z") == 1,
"TC-FTPCFG-010 只折叠 A-Z,数字/下划线/符号原样比较");
CHECK(s_str_ci_eq("abc", "abcd") == 0, "TC-FTPCFG-011 前缀关系不算相等");
CHECK((s_str_ci_eq(NULL, "x") == 0) && (s_str_ci_eq("x", NULL) == 0),
"TC-FTPCFG-012 任一为 NULL 判不等");
CHECK(s_str_ci_eq(NULL, NULL) == 0,
"TC-FTPCFG-013 两个 NULL 也判不等(fail-closed)");
}
/* ===================== 入口 ===================== */
int main(void)
{
printf("=== FTP Auth Config Tests ===\n");
tc_defaults(); /* 必须最先:之后会改写 RAM 副本 */
tc_register();
tc_default_pwd();
tc_ci_eq();
printf("\n=== FTP Auth Config Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""FTP 鉴权配置(ftp_cfg,[ftp] 段)的主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/ftp_cfg/ftp_cfg_test.c(以 #include 纳入**生产源文件**
Drivers/BSP/NETCFG/ftp_cfg.c),在 PC 上离线跑注册契约(幂等、键表、**不挂回调**)
与出厂默认口令判定(大小写不敏感、NULL fail-closed),失败即非 0 退出。
工具链探测与判定全部委托给 test/host/common/unit_test_harness.py。
为什么要主机侧单测(而不是只用台架):
本模块是 FTP 的门禁(N-19)。判定写成大小写敏感 → 用户手输大写 hex 时正确口令被拒;
反过来对出厂值敏感 → "已改口令"误报,出厂默认口令的启动告警**静默失效**(现场以为
改了口令)。[ftp] 段刻意不挂变更回调(口令下次登录才生效),有人"顺手补上"就会在
poll 重载时去重建网络。write_en 出厂 = 1 也是刻意的(FTP 推 config.ini 是既有维护
工作流)。这些都是一行代码就能改错、而真机只看得到 230/530 的东西。
哈希常量本身的正确性归 test/host/sha256/(TC-SHA256-008),本模块不重复算。
用法(仓库根目录):
python test/host/ftp_cfg/run_unit_test.py
或统一用 .venv: & '.venv\Scripts\python.exe' test/host/ftp_cfg/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。
设计依据:docs/子系统设计/网络配置说明.md §3.4、docs/测试说明/主机单测覆盖率口径.md §9 P1
创建日期:2026-10-23
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
# 本模块零桩目录:ftp_cfg.c 不含 dbg_log.h,也没有别的平台依赖;
# cfg_file.h 用真实头(接口原汁),被调的 cfg_file_register 由测试单元给实现。
BUILD = TestBuild(
name="FTP Auth Config Tests",
src=os.path.join(HERE, "ftp_cfg_test.c"),
include_dirs=[
os.path.join(ROOT, "Drivers", "BSP", "NETCFG"),
os.path.join(ROOT, "App", "cfg"),
HERE,
],
summary_marker="=== FTP Auth Config Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+527
View File
@@ -0,0 +1,527 @@
/*
* 模块名称:IEC 60870-5-104 上行服务生命周期(iec104_serv)主机侧单元测试
* 模块功能:在 PC 上离线验证 App/task/iec104_serv.c 的服务侧骨架:监听建立与失败
* 回收、**网络代次一变就重建监听**(改了 IP 端口必须在新地址上继续服务)、
* accept 门控与表项失效自愈、单连接 socket 的断线语义(对端 FIN 只关数据
* 口、绝不关监听)、连续短写判死、活性分频探活、协议状态掉回 IDLE 时丢
* 连接并立即重建,以及时钟桥接的两个溢出边界(>4G 秒拒绝、未对时返回哨兵
* 而不是把 1970 当真实时间)。
*
* 被测对象:真实源文件 ../../../App/task/iec104_serv.c(#include 纳入,故它的 static
* s_bind_listen / s_drop_conn / s_service_link / s_serv_poll / s_send_apdu /
* s_clock_sync / s_epoch_ms 都能直接驱动——**不跑 iec104_serv_task 的无限
* 主循环**,那是 never-returning 的任务体,不适合单测)。
* iec104_link.h / iec104_pts.h / prot_set.h / sys_clock.h / sys_events.h /
* task_watchdog.h / net_types.h 全用**真实头**(iec104_state_t 枚举、
* iec104_link_cfg_t 函数指针签名、TASK_WD_IEC104=7、SYS_EVT_* 位值一改即
* 编译期报错);链路层(iec104_link_*)与 pts 层给假件——链路协议本身有
* test/host/iec104 的 97 条专测,本模块只管"服务怎么活、怎么死、怎么重生"。
*
* 为什么要主机侧单测(而不是只靠台架):
* 1. 104 主站掉线重连是**现场最常见的故障形态**,但台架上没法按需制造"改 IP 时
* 正好有连接""对端 FIN 与 RST 的区别""连续三次短写"这些时序;而这里每一条
* 处理错的后果都是端口永久失联或会话卡死,只能靠主站侧超时才发现。
* 2. 判死用**连续**短写计数(一次成功即复位),写成"累计"就会把偶发拥塞误判成
* 断线,主站看到的就是莫名掉线——这条只有能精确注入短写/成功序列才可测。
* 3. 时钟桥接的两个边界:epoch 秒超过 32 位必须拒绝(否则截断成 2106 年前的某个
* 乱值写进 RTC),未对时(sys_clock_get 返回 UINT32_MAX)必须回哨兵而不是 0,
* 否则对端会以为设备时间是 1970-01-01。
* 4. 活性分频(每 32 轮才探一次)是 CPU 预算的取舍:写错成每轮都探,就把 100ms
* 轮询变成了每轮两次系统调用;写错成分频相位,TESTFR 会迟迟不发。
*
* 覆盖点:TC-I104SRV-001..004 建监听;010..013 发送与连续短写判死;020..023 时钟
* 桥接;030..033 代次重建与 accept 门控;034..036 活性分频与对端 FIN;037..041
* 错误丢弃与协议掉线重生。
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-23
*/
#include <stdio.h>
#include <string.h>
#include "iec104_serv.h"
#include "net_socket.h"
#include "iec104_serv.c"
static int s_total;
static int s_passed;
static int s_failed;
#define CHECK(cond, name) do { \
s_total++; \
if (cond) { s_passed++; printf(" PASS %s\n", name); } \
else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \
} while (0)
int g_i4_info_count;
int g_i4_error_count;
int g_i4_debug_count;
/* 真实 sys_events.h 把它声明成 extern,这里给定义(本模块只在主循环里用,
测试设成 NULL 走"事件组不可用"支路,不阻塞静态辅助函数的驱动)。 */
osEventFlagsId_t sys_evtHandle;
/* ===================== 可控 socket 面 ===================== */
typedef struct {
int socket_rc;
int bind_rc;
int listen_rc;
int accept_rc;
int send_rc;
int recv_rc;
int can_read;
int armed;
int errno_val;
uint32_t gen;
int sock_exists;
int socket_calls;
int bind_calls;
int listen_calls;
int accept_calls;
int close_calls;
int last_closed_fd;
int last_accept_sock;
int recv_calls;
uint16_t last_bind_port_net;
uint16_t last_bind_family;
uint32_t last_bind_addr;
int delay_calls;
} i4_net_t;
static i4_net_t g_n;
/* net_get_sock 要返回一个真 net_sock_t(结构来自真实 net_types.h),先声明再用 */
static net_sock_t g_sock_probe;
int net_socket(int domain, int type) { (void)domain; (void)type; g_n.socket_calls++; return g_n.socket_rc; }
int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen)
{
const struct net_sockaddr_in *a = (const struct net_sockaddr_in *)addr;
(void)sockfd;
(void)addrlen;
g_n.bind_calls++;
g_n.last_bind_port_net = a->sin_port;
g_n.last_bind_family = a->sin_family;
g_n.last_bind_addr = a->sin_addr.s_addr;
return g_n.bind_rc;
}
int net_listen(int sockfd) { (void)sockfd; g_n.listen_calls++; return g_n.listen_rc; }
int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen)
{
(void)addr;
(void)addrlen;
g_n.accept_calls++;
g_n.last_accept_sock = sockfd;
return g_n.accept_rc;
}
int net_close(int sockfd) { g_n.close_calls++; g_n.last_closed_fd = sockfd; return 0; }
int net_send(int sockfd, const void *buf, int len, int flags)
{
(void)sockfd;
(void)buf;
(void)flags;
return g_n.send_rc;
}
int net_recv_nb(int sockfd, void *buf, int len)
{
(void)sockfd;
(void)buf;
(void)len;
g_n.recv_calls++;
return g_n.recv_rc;
}
int net_can_read(int sockfd) { (void)sockfd; return g_n.can_read; }
int net_get_errno(void) { return g_n.errno_val; }
int net_listener_armed(int sockfd) { (void)sockfd; return g_n.armed; }
uint32_t net_get_generation(void) { return g_n.gen; }
net_sock_t *net_get_sock(int sockfd) { (void)sockfd; return (g_n.sock_exists != 0) ? &g_sock_probe : NULL; }
uint16_t net_htons(uint16_t v) { return (uint16_t)((v >> 8) | (v << 8)); }
/* ===================== 假链路层与假 pts ===================== */
static int g_lk_init;
static int g_lk_open;
static int g_lk_close;
static int g_lk_poll;
static int g_lk_onrx;
static uint32_t g_lk_last_now;
static iec104_state_t g_lk_state;
void iec104_link_init(iec104_link_t *lk, const iec104_link_cfg_t *cfg, void *ctx)
{
(void)lk; (void)cfg; (void)ctx;
g_lk_init++;
}
void iec104_link_open(iec104_link_t *lk, uint32_t now_ms)
{
(void)lk;
g_lk_open++;
g_lk_last_now = now_ms;
}
void iec104_link_close(iec104_link_t *lk) { (void)lk; g_lk_close++; }
void iec104_link_poll(iec104_link_t *lk, uint32_t now_ms)
{
(void)lk;
g_lk_poll++;
g_lk_last_now = now_ms;
}
void iec104_link_on_rx(iec104_link_t *lk, const uint8_t *data, uint16_t len, uint32_t now_ms)
{
(void)lk; (void)data; (void)len; (void)now_ms;
g_lk_onrx++;
}
iec104_state_t iec104_link_state(const iec104_link_t *lk) { (void)lk; return g_lk_state; }
static int g_pts_init;
void iec104_pts_init(void) { g_pts_init++; }
/* ===================== 假时钟 / 定值 / 看门狗 / RTOS ===================== */
static uint32_t g_clock_val;
static int g_clock_set_rc;
static uint32_t g_clock_last_secs;
static int g_clock_set_calls;
static uint32_t g_dirty;
static int g_evt_set_calls;
static uint32_t g_evt_last;
static int g_wd_beats;
static int g_wd_last_id;
static uint32_t g_now;
uint32_t sys_clock_get(void) { return g_clock_val; }
int sys_clock_set_unix(uint32_t unix_secs)
{
g_clock_set_calls++;
g_clock_last_secs = unix_secs;
return g_clock_set_rc;
}
uint8_t prot_set_dirty_peek(void) { return (uint8_t)g_dirty; }
uint32_t osEventFlagsSet(osEventFlagsId_t ef_id, uint32_t flags)
{
(void)ef_id;
g_evt_set_calls++;
g_evt_last = flags;
return flags;
}
uint32_t osEventFlagsWait(osEventFlagsId_t ef_id, uint32_t flags, uint32_t options, uint32_t timeout)
{
(void)ef_id; (void)flags; (void)options; (void)timeout;
return 0u;
}
void task_wd_beat(task_wd_id_t id) { g_wd_beats++; g_wd_last_id = (int)id; }
uint32_t osKernelGetTickCount(void) { return g_now; }
void osDelay(uint32_t ticks)
{
(void)ticks;
g_n.delay_calls++;
if (g_n.delay_calls > 500) {
printf(" ABORT 跑飞(>500 轮 osDelay)\n");
fflush(stdout);
exit(2);
}
}
/* ===================== 复位 ===================== */
static void reset_all(void)
{
(void)memset(&g_n, 0, sizeof(g_n));
g_n.socket_rc = 5;
g_n.bind_rc = 0;
g_n.listen_rc = 0;
g_n.accept_rc = -1;
g_n.send_rc = 0;
g_n.armed = 1;
g_n.sock_exists = 1;
g_n.errno_val = NET_ERR_WOULDBLOCK;
g_n.gen = 100u;
(void)memset(&g_sock_probe, 0, sizeof(g_sock_probe));
g_lk_init = 0; g_lk_open = 0; g_lk_close = 0; g_lk_poll = 0; g_lk_onrx = 0;
g_lk_last_now = 0u;
g_lk_state = IEC104_ST_RUN;
g_pts_init = 0;
g_clock_val = 1700000000u;
g_clock_set_rc = 0;
g_clock_last_secs = 0u;
g_clock_set_calls = 0;
g_dirty = 0u;
g_evt_set_calls = 0;
g_evt_last = 0u;
g_wd_beats = 0;
g_wd_last_id = -1;
g_now = 5000u;
g_i4_info_count = 0;
g_i4_error_count = 0;
g_i4_debug_count = 0;
sys_evtHandle = NULL;
s_listen = -1;
s_client = -1;
s_gen = 100u;
s_liveness_ctr = 0u;
s_send_fail_run = 0u;
s_conn_dead = 0u;
}
/* ===================== 建监听 ===================== */
static void tc_bind_listen(void)
{
printf("-- TC-I104SRV-001..004 bind/listen --\n");
reset_all();
CHECK((s_bind_listen() == 0) && (s_listen == 5) && (g_n.listen_calls == 1) &&
(g_n.last_bind_family == NET_AF_INET) &&
(g_n.last_bind_port_net == net_htons(IEC104_SERV_PORT)) &&
(g_n.last_bind_addr == NET_INADDR_ANY),
"TC-I104SRV-001 建监听:2404 端口过 htons、绑任意地址、记下监听号");
reset_all();
g_n.socket_rc = -1;
CHECK((s_bind_listen() == -1) && (g_n.bind_calls == 0) && (g_n.close_calls == 0),
"TC-I104SRV-002 建不出 socket 就返回,不去 bind 也不关不存在的口");
reset_all();
g_n.bind_rc = -1;
CHECK((s_bind_listen() == -1) && (g_n.close_calls == 1) && (g_n.listen_calls == 0),
"TC-I104SRV-003 bind 失败要把刚建的口关掉(否则每次重试漏一个 socket 号)");
reset_all();
g_n.listen_rc = -1;
CHECK((s_bind_listen() == -1) && (g_n.close_calls == 1) && (s_listen == -1),
"TC-I104SRV-004 listen 失败同样关口且不留下半个监听");
}
/* ===================== 发送与连续短写判死 ===================== */
static void tc_send(void)
{
const uint8_t apdu[4] = { 0x68u, 0x02u, 0x00u, 0x00u };
printf("-- TC-I104SRV-010..013 send & fail streak --\n");
reset_all();
s_client = -1;
s_send_apdu(NULL, apdu, 4u);
CHECK((g_n.send_rc == 0) && (g_n.close_calls == 0) && (g_i4_error_count == 0),
"TC-I104SRV-010 没连接时直接返回(不发 net_send、不报错)");
reset_all();
s_client = 7;
g_n.send_rc = 4;
s_send_apdu(NULL, apdu, 4u);
CHECK((s_send_fail_run == 0u) && (s_conn_dead == 0u),
"TC-I104SRV-011 完整发出把连败计数清零");
reset_all();
s_client = 7;
g_n.send_rc = 2; /* 短写:只发了 2/4 */
s_send_apdu(NULL, apdu, 4u);
CHECK((s_send_fail_run == 1u) && (s_conn_dead == 0u) && (g_i4_error_count == 1),
"TC-I104SRV-012 第一次短写:留痕但不判死(拥塞很正常)");
s_send_apdu(NULL, apdu, 4u);
s_send_apdu(NULL, apdu, 4u);
CHECK((s_send_fail_run == 3u) && (s_conn_dead == 1u),
"TC-I104SRV-012b 连到 3 次才判死(IEC104_SEND_FAIL_MAX)");
reset_all();
s_client = 7;
g_n.send_rc = 1;
s_send_apdu(NULL, apdu, 4u);
s_send_apdu(NULL, apdu, 4u);
g_n.send_rc = 4;
s_send_apdu(NULL, apdu, 4u); /* 中间成功一次 */
g_n.send_rc = 1;
s_send_apdu(NULL, apdu, 4u);
s_send_apdu(NULL, apdu, 4u);
CHECK((s_conn_dead == 0u) && (s_send_fail_run == 2u),
"TC-I104SRV-013 **成功即复位**:偶发拥塞不会被累计成误杀");
}
/* ===================== 时钟桥接边界 ===================== */
static void tc_clock(void)
{
printf("-- TC-I104SRV-020..023 clock bridge --\n");
reset_all();
CHECK(s_clock_sync(NULL, 0x100000000ULL * 1000ULL) == -1,
"TC-I104SRV-020 秒数超过 32 位一律拒绝(截断写进 RTC 就是 2106 年后的乱值)");
CHECK(g_clock_set_calls == 0, "TC-I104SRV-020b 拒绝时根本不该碰时钟");
reset_all();
(void)s_clock_sync(NULL, 1700000123000ULL);
CHECK((g_clock_set_calls == 1) && (g_clock_last_secs == 1700000123u),
"TC-I104SRV-021 毫秒按整除换秒后写时钟");
reset_all();
g_clock_val = UINT32_MAX; /* 未对时的哨兵值 */
CHECK(s_epoch_ms(NULL) == UINT64_MAX,
"TC-I104SRV-022 未对时回 UINT64_MAX 哨兵(绝不能把 1970 当成设备时间)");
reset_all();
g_clock_val = 1700000123u;
CHECK(s_epoch_ms(NULL) == 1700000123000ULL, "TC-I104SRV-023 正常时对时给出毫秒");
}
/* ===================== 代次重建与 accept 门控 ===================== */
static void tc_generation(void)
{
printf("-- TC-I104SRV-030..033 generation & accept gate --\n");
reset_all();
s_listen = 5;
s_client = 9;
g_n.gen = 101u; /* 网络重建(现场改了 IP) */
s_serv_poll();
CHECK((g_n.close_calls == 2) && (g_n.last_closed_fd == 5) &&
(s_client == -1) && (s_liveness_ctr == 0u) && (s_conn_dead == 0u) &&
(g_lk_close == 1) && (s_gen == 101u) && (g_n.bind_calls == 1),
"TC-I104SRV-030 代次一变就拆掉旧连接与旧监听并重建(新 IP 上端口必须继续服务)");
reset_all();
s_listen = -1;
s_serv_poll();
CHECK((g_n.bind_calls == 1) && (s_listen == 5) && (g_n.accept_calls == 0),
"TC-I104SRV-031 监听丢了就补建,这一轮不去 accept");
reset_all();
s_listen = 5;
g_n.armed = 0; /* 监听还没就绪 */
g_n.sock_exists = 1;
s_serv_poll();
CHECK((g_n.accept_calls == 0) && (s_listen == 5),
"TC-I104SRV-032 监听门未开时不 accept(否则对着未就绪的号空转)");
reset_all();
s_listen = 5;
g_n.armed = 0;
g_n.sock_exists = 0; /* 表项已不存在 */
s_serv_poll();
CHECK((s_listen == -1) && (g_n.accept_calls == 0),
"TC-I104SRV-032b 表项已失效时把监听号丢掉,下一轮重建");
reset_all();
s_listen = 5;
g_n.accept_rc = 9;
s_send_fail_run = 3u;
s_conn_dead = 1u;
s_liveness_ctr = 7u;
s_serv_poll();
CHECK((s_client == 9) && (g_lk_open == 1) && (g_lk_last_now == 5000u) &&
(s_send_fail_run == 0u) && (s_conn_dead == 0u) && (s_liveness_ctr == 0u) &&
(g_i4_info_count == 1),
"TC-I104SRV-033 新连接:打开链路、三个失败计数全部复位");
}
/* ===================== 活性分频、对端 FIN、错误丢弃 ===================== */
static void tc_liveness(void)
{
printf("-- TC-I104SRV-034..041 liveness / EOF / drop --\n");
reset_all();
s_listen = 5;
s_client = 9;
g_n.can_read = 0;
s_liveness_ctr = 0u;
s_serv_poll();
CHECK((g_n.recv_calls == 0) && (g_lk_poll == 1) && (s_liveness_ctr == 1u),
"TC-I104SRV-034 无可读数据时按 32 分频探活,这一轮不收包(省 CPU 预算)");
reset_all();
s_listen = 5;
s_client = 9;
g_n.can_read = 0;
/* 这一轮收到的是"暂无数据"(非阻塞的空读),而不是 0——0 在 recv_nb 的语义里是
* 对端 FIN,会直接走 036 那条断开支路,测不到分频本身。 */
g_n.recv_rc = -1;
g_n.errno_val = NET_ERR_WOULDBLOCK;
s_liveness_ctr = 31u; /* 下一轮正好到分频点 */
s_serv_poll();
CHECK((g_n.recv_calls == 1) && (g_lk_poll == 1) && (s_client == 9),
"TC-I104SRV-035 分频到点才去收一次(TESTFR 等活性帧靠它发出去)");
reset_all();
s_listen = 5;
s_client = 9;
g_n.can_read = 1;
g_n.recv_rc = 0; /* 对端 FIN */
s_serv_poll();
CHECK((g_n.last_closed_fd == 9) && (s_client == -1) && (g_lk_close == 1) &&
(g_n.close_calls == 1) && (s_listen == 5) && (g_n.bind_calls == 0),
"TC-I104SRV-036 对端正常断开:只关数据口、**监听必须留着**(单连接 socket 关错就是端口失联)");
reset_all();
s_listen = 5;
s_client = 5; /* 单连接复用:数据号 == 监听号 */
g_n.can_read = 1;
g_n.recv_rc = 0;
s_serv_poll();
CHECK((g_n.close_calls == 0) && (s_client == -1) && (g_lk_close == 1),
"TC-I104SRV-036b 数据号与监听号同号时**不许关**(否则把监听一起毁掉)");
reset_all();
s_listen = 5;
s_client = 9;
g_n.can_read = 1;
g_n.recv_rc = -1;
g_n.errno_val = NET_ERR_WOULDBLOCK; /* 空转:没数据可读 */
s_serv_poll();
CHECK((s_client == 9) && (g_lk_close == 0) && (g_i4_error_count == 0),
"TC-I104SRV-037 WOULDBLOCK 是正常空转,不能当成错误丢连接");
reset_all();
s_listen = 5;
s_client = 9;
g_n.can_read = 1;
g_n.recv_rc = -1;
g_n.errno_val = NET_ERR_CONNRESET; /* 真错误 */
s_serv_poll();
CHECK((s_client == -1) && (s_listen == 5) && (g_i4_error_count == 1) &&
(g_lk_close == 1) && (s_liveness_ctr == 0u),
"TC-I104SRV-038 真错误:丢连接并**当场重建监听**(不留空窗)");
reset_all();
s_listen = 5;
s_client = 9;
s_conn_dead = 1u;
s_service_link(1000u);
CHECK((s_client == -1) && (g_n.close_calls == 1) && (g_i4_error_count == 1),
"TC-I104SRV-039 判死后由 service_link 收口(发送失败线索单独成一条)");
reset_all();
s_listen = 5;
s_client = 9;
g_lk_state = IEC104_ST_IDLE; /* 链路层自己回到了未建立 */
s_service_link(1000u);
CHECK((s_client == -1) && (g_lk_close >= 1) && (s_listen == 5),
"TC-I104SRV-040 协议状态掉回 IDLE 也要丢并重生(否则服务挂着但永不服务)");
reset_all();
s_listen = 9;
s_client = 9;
g_n.socket_rc = -1; /* 重建失败 */
g_n.sock_exists = 0; /* 且表项已经没了 */
g_n.can_read = 1;
g_n.recv_rc = 0;
s_serv_poll();
CHECK((s_client == -1) && (s_listen == -1) && (g_n.close_calls == 0),
"TC-I104SRV-041 表项没了又重建失败:丢掉假监听号,下一轮继续重试(数据号与监听同号时不许关)");
}
int main(void)
{
printf("=== IEC104 SERV Tests ===\n");
tc_bind_listen();
tc_send();
tc_clock();
tc_generation();
tc_liveness();
printf("\n=== IEC104 SERV Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""IEC 104 上行服务生命周期(iec104_serv)主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/iec104_serv/iec104_serv_test.c(以 #include 纳入生产源
App/task/iec104_serv.c),离线驱动它的全部 static 辅助函数(**不跑 never-returning
的任务主循环**),验证监听建立与失败回收、网络代次一变就重建监听、accept 门控与
表项失效自愈、单连接 socket 的断线语义、连续短写判死、活性分频探活、协议掉回 IDLE
时丢弃重生,以及时钟桥接的两个溢出边界。
为什么要主机侧单测(而不是只用台架):
104 主站掉线重连是现场最常见故障形态,但台架无法按需制造"改 IP 时正好有连接"
"对端 FIN 与 RST 的区别""连续三次短写";这几条处理错的后果都是端口永久失联或
会话卡死,只能等主站侧超时才发现。判死用**连续**短写计数(成功即复位),写成累计
就会把偶发拥塞误判成断线;时钟桥接的 >4G 秒必须拒绝、未对时必须回哨兵而不是 0;
活性分频写错就成了每轮多一次系统调用或 TESTFR 迟迟不发。
桩的边界:iec104_link.h / iec104_pts.h / prot_set.h / sys_clock.h / sys_events.h /
task_watchdog.h / net_types.h 用真实头(iec104_state_t、cfg 的函数指针签名、
TASK_WD_IEC104=7、SYS_EVT_* 位值一改即编译期报错);链路层与 pts 层给假件——链路
协议本身有 test/host/iec104 的 97 条专测,本模块只管服务怎么活、怎么死、怎么重生;
socket 面给可控假件(连 socket 号、发送返回、错误码、代次、门控都能注入)。
用法(仓库根目录):
python test/host/iec104_serv/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误。
设计依据:docs/子系统设计/*104* 设计文档、AGENTS.md 模块约定(单连接 socket 断开语义、
错误码经任务通知值回传)、docs/测试说明/主机单测覆盖率口径.md §9
创建日期:2026-10-23
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
def _header_dirs(base):
"""收集 base 下所有**真的放着 .h** 的目录。
104 服务要 include 的 iec104_serv.h / iec104_link.h / iec104_pts.h /
task_watchdog.h / sys_clock.h / sys_events.h / prot_set.h 散在 App 的多个
子目录里,逐个手写列表一定会漏(漏一个就是 C1083,而且报错落在被测源上,
很容易被误读成生产代码坏了)。这里按目录实际内容自动收集,多给几个 /I
对编译没有代价,却把"漏目录"这类脆错误消掉。
"""
dirs = []
for root, _sub, files in os.walk(base):
if any(f.endswith(".h") for f in files):
dirs.append(root)
return sorted(dirs)
BUILD = TestBuild(
name="IEC104 SERV Tests",
src=os.path.join(HERE, "iec104_serv_test.c"),
include_dirs=[
os.path.join(HERE, "stub"), # 必须先于真头目录(覆盖 net_socket.h 等)
] + _header_dirs(os.path.join(ROOT, "App")) +
# sys_clock.h 会拉 sd2506.h(Drivers/BSP/SD2506),BSP 的头也散在多个子目录里,
# 同样按实际内容收集。注意 stub 目录排在最前,桩依然优先于这些真头。
_header_dirs(os.path.join(ROOT, "Drivers", "BSP")) + [
os.path.join(ROOT, "Drivers", "BSP", "NET"),
HERE,
],
summary_marker="=== IEC104 SERV Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+27
View File
@@ -0,0 +1,27 @@
/*
* 模块名称:主机侧单测桩 - cmsis_os2.h(iec104_serv 模块专用)
* 模块功能:给 CMSIS-RTOS2 的六个入口一个宿主等价壳(tick、睡眠、事件组)。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 事件组只做成"记次数":本模块的测试**不驱动 iec104_serv_task 的无限主循环**
* (那会永不返回),只直调它的静态辅助函数,事件相关行为用可见计数器观察。
* 创建日期:2026-10-23
*/
#ifndef CMSIS_OS2_H
#define CMSIS_OS2_H
#include <stdint.h>
typedef void *osEventFlagsId_t;
typedef uint32_t osStatus_t;
#define osFlagsWaitAny 0x00000000u
#define osFlagsNoClear 0x00000001u
#define osFlagsError 0x80000000u
uint32_t osKernelGetTickCount(void);
void osDelay(uint32_t ticks);
uint32_t osEventFlagsWait(osEventFlagsId_t ef_id, uint32_t flags,
uint32_t options, uint32_t timeout);
uint32_t osEventFlagsSet(osEventFlagsId_t ef_id, uint32_t flags);
#endif /* CMSIS_OS2_H */
+19
View File
@@ -0,0 +1,19 @@
/*
* 模块名称:主机侧单测桩 - dbg_log.h(iec104_serv 模块专用)
* 模块功能:DBG_* 计数;本模块的判死/丢连接都靠 ERROR 留痕("i104 drop: send fail"
* 与 "protocol fault" 是两条不同的现场线索)。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef DBG_LOG_H
#define DBG_LOG_H
extern int g_i4_info_count;
extern int g_i4_error_count;
extern int g_i4_debug_count;
#define DBG_INFO(fmt, ...) do { (void)(fmt); g_i4_info_count++; } while (0)
#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_i4_debug_count++; } while (0)
#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_i4_error_count++; } while (0)
#endif /* DBG_LOG_H */
+15
View File
@@ -0,0 +1,15 @@
/*
* 模块名称:主机侧单测桩 - main.h(NET/BSP 共享形态,本模块用于 iec104_serv)
* 模块功能:只给 HAL_GetTick;某些 BSP 头的 include 链会绕到这里(真实 main.h 拉整棵
* HAL 头树,PC 上编不动)。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef MAIN_H
#define MAIN_H
#include <stdint.h>
uint32_t HAL_GetTick(void);
#endif /* MAIN_H */
+31
View File
@@ -0,0 +1,31 @@
/*
* 模块名称:主机侧单测桩 - net_socket.h(NET 域共享形态,见 net_listener 同名文件)
* 模块功能:给 104 服务端一个可控 socket 面:建口/绑定/监听/接受/关闭/发送、
* 非阻塞收(net_recv_nb)、可读门控(net_can_read)、监听门控
* (net_listener_armed)、错误码通道(net_get_errno)、网络代次
* (net_get_generation)与表项查询(net_get_sock)。
* 地址结构与 NET_AF_INET / NET_SOCK_STREAM / NET_INADDR_ANY /
* NET_ERR_WOULDBLOCK 一律取**真实** net_types.h。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef NET_SOCKET_H
#define NET_SOCKET_H
#include "net_types.h"
int net_socket(int domain, int type);
int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen);
int net_listen(int sockfd);
int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen);
int net_close(int sockfd);
int net_send(int sockfd, const void *buf, int len, int flags);
int net_recv_nb(int sockfd, void *buf, int len);
int net_can_read(int sockfd);
int net_get_errno(void);
int net_listener_armed(int sockfd);
uint32_t net_get_generation(void);
net_sock_t *net_get_sock(int sockfd);
uint16_t net_htons(uint16_t hostshort);
#endif /* NET_SOCKET_H */
+21
View File
@@ -0,0 +1,21 @@
/*
* 模块名称:主机侧单测桩 - sys_clock.h(iec104_serv 模块专用)
* 模块功能:只给本模块真正用到的两个入口:读秒与写秒。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 为什么必须桩这层而不是用真实头:真实 App/util/sys_clock.h 会 include sd2506.h
* (RTC 芯片驱动),那条链一直拉到 stm32f4xx_hal.h 与寄存器定义,PC 上编不动。
* 两个签名与真实头**逐字一致**(uint32_t sys_clock_get(void) /
* int sys_clock_set_unix(uint32_t)),改签名就会在测试里对不上假件而报错。
* 本模块最有价值的两条恰好是这两个入口的**边界**:>4G 秒必须拒绝、未对时的
* UINT32_MAX 哨兵必须传成 UINT64_MAX,所以桩掉实现不影响被测语义。
* 创建日期:2026-10-23
*/
#ifndef SYS_CLOCK_H
#define SYS_CLOCK_H
#include <stdint.h>
uint32_t sys_clock_get(void);
int sys_clock_set_unix(uint32_t unix_secs);
#endif /* SYS_CLOCK_H */
+736
View File
@@ -0,0 +1,736 @@
/*
* 模块名称:FTP 控制通道协议层(lftpd)主机侧单元测试
* 模块功能:在 PC 上离线验证 Drivers/BSP/NET/lftpd/lftpd.c 的控制通道状态机与 17 条
* 命令处理器:认证前置白名单、超长命令名拒绝(不越界写命令缓冲)、未知命令
* 502、QUIT 收尾时释放数据监听、大小写不敏感、PASV/EPSV 的 227/229 响应
* 编码、数据 accept 失败时不残留半开监听、STOR 失败要**删掉半成品**、
* 写开关关闭时 DELE/STOR 一律 550、CWD 的目录判定与截断安全、FEAT 多行
* 响应的首尾行格式。
*
* 被测对象:真实源文件 ../../../../Drivers/BSP/NET/lftpd/lftpd.c(#include 纳入,
* 因此命令表 commands[]、24 个 static cmd_* 与 handle_control_channel 都
* 可在同一 TU 内直接驱动)。假的是它下面三层:inet 适配(脚本化命令行
* 队列 + 响应记录)、io 层(极简内存视图:stat/open/read/write/opendir/
* readdir/unlink + 可注入失败)、认证与配置(ftp_auth_check/ftp_cfg_get
* 可注入结果,认证本身有 test/host/ftp_auth 的 10 条专测,不重复)。
*
* 为什么要主机侧单测(而不是只靠台架):
* 1. **半成品文件**是 FTP 最贵的坑:STOR 传到一半对端断了,如果不 unlink,Flash 上
* 就永久留一个残缺文件(现场表现为"升级包传坏了再也传不上去")。真机上要卡准
* "传一半掐线"的时机,主机侧把 io 层的写失败注进去就行。
* 2. 数据通道是**单连接 socket**:LIST/RETR/STOR 每条命令用完必须把监听关掉并复位
* data_socket=-1,否则下一条命令拿着上一个号继续 accept,表现是偶发连不上且
* 与上一会话时序相关。这类"忘了复位"只有把 accept 失败也注进来才测得干净。
* 3. 命令名缓冲是 command_tmp[4+1],而判断用 if (index >= 5) 先拦——这条边界
* (恰好 5 个字符的命令名必须被拒)就是防 memcpy 越界,改一行就没了。
* 4. 写开关([ftp] write_en=0)是现场防误删的闸门,DELE/STOR 必须在**碰文件之前**
* 就拒绝,且此时不该去 accept 数据通道。
*
* 覆盖点:TC-LFTPD-001 认证白名单;002 超长命令名;003 未知命令 502;004 QUIT 退出;
* 005 欢迎未送达就收工;006 退出时释放数据监听;007 大小写不敏感;008 无参数命令;
* 010 未开被动模式的数据命令 425;011/012/013 PASV/EPSV 响应;014 数据 accept 失败
* 不留残口;015/016 LIST 成功与目录打不开;017/018 RETR 成功与打不开;
* 019/020 STOR 删半成品与写开关;021 DELE 写开关;022/023 CWD;024 SIZE;025 PWD;
* 026 SYST;027 FEAT 多行;028/029 口令失败与锁定;030 TYPE/NOOP。
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-23
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "lftpd.h"
#include "lftpd_auth.h"
#include "ftp_cfg.h"
#include "ff.h"
#include "lftpd.c"
/* 命令行 trim 用**真实实现**(16 行、零依赖;lftpd_string.c 的契约由
test/host/lftpd_io 的 TC-LFTSTR-* 专测,这里只是满足链接、顺带让参数解析走真码)。 */
#include "lftpd_string.c"
static int s_total;
static int s_passed;
static int s_failed;
#define CHECK(cond, name) do { \
s_total++; \
if (cond) { s_passed++; printf(" PASS %s\n", name); } \
else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \
} while (0)
/* ===================== 桩:日志计数 =====================
* lftpd_log_info/error/peer/debug 是真实 private/lftpd_log.h 里的**宏**,展开成
* DBG_*;本模块只桩最底下的 dbg_log.h(计数)。这样真实分级逻辑一起被编译验证:
* peer 级归 INFO、debug 级在 MCU 上被整条摘掉。 */
int g_lg_info_count;
int g_lg_error_count;
int g_lg_debug_count;
/* ===================== 桩:假堆(返回给 vPortFree 的路径缓冲) ===================== */
static uint8_t g_pool[2048];
static size_t g_pool_used;
static int g_free_calls;
void *pvPortMalloc(size_t xWantedSize)
{
void *p;
g_pool_used = (g_pool_used + 7u) & ~(size_t)7u;
if ((g_pool_used + xWantedSize) > sizeof(g_pool)) {
return NULL;
}
p = &g_pool[g_pool_used];
g_pool_used += xWantedSize;
return p;
}
void vPortFree(void *pv)
{
if (pv != NULL) {
g_free_calls++;
}
}
/* ===================== 假 inet 层:脚本化命令行 + 响应记录 ===================== */
typedef struct {
const char *lines[16];
int line_ret[16];
int line_n;
int line_i;
char replies[32][256];
int reply_n;
int string_rc; /* write_string 返回(0=成功) */
int listen_rc;
int port;
int accept_to_rc; /* 数据通道 accept 结果 */
int data_write_rc; /* 数据写返回(<0 模拟对端断) */
int rx_left; /* 数据通道还剩多少字节可读 */
int close_calls;
int listen_calls;
int accept_calls;
int delay_calls;
} lftp_net_t;
static lftp_net_t g_n;
int lftpd_inet_read_line(int socket, char *buffer, size_t buffer_len)
{
(void)socket;
if (g_n.line_i >= g_n.line_n) {
return -1; /* 脚本喂完 = 对端关闭 */
}
(void)strncpy(buffer, g_n.lines[g_n.line_i], buffer_len - 1u);
buffer[buffer_len - 1u] = '\0';
return g_n.line_ret[g_n.line_i++];
}
int lftpd_inet_write_string(int socket, const char *message)
{
(void)socket;
if (g_n.reply_n < (int)(sizeof(g_n.replies) / sizeof(g_n.replies[0]))) {
(void)strncpy(g_n.replies[g_n.reply_n], message, sizeof(g_n.replies[0]) - 1u);
g_n.replies[g_n.reply_n][sizeof(g_n.replies[0]) - 1u] = '\0';
g_n.reply_n++;
}
return g_n.string_rc;
}
int lftpd_inet_write(int socket, const void *buf, int len)
{
(void)socket;
(void)buf;
return (g_n.data_write_rc != 0) ? g_n.data_write_rc : len;
}
int lftpd_inet_read(int socket, void *buf, int len)
{
int n;
(void)socket;
(void)buf;
if (g_n.rx_left <= 0) {
return 0; /* 对端结束数据 */
}
n = (g_n.rx_left > len) ? len : g_n.rx_left;
g_n.rx_left -= n;
return n;
}
int lftpd_inet_listen(int port)
{
(void)port;
g_n.listen_calls++;
return g_n.listen_rc;
}
int lftpd_inet_get_socket_port(int socket)
{
(void)socket;
return g_n.port;
}
int lftpd_inet_accept(int socket)
{
(void)socket;
return -1;
}
int lftpd_inet_accept_timeout(int socket, int timeout_ms)
{
(void)socket;
(void)timeout_ms;
g_n.accept_calls++;
return g_n.accept_to_rc;
}
int lftpd_inet_close(int socket)
{
(void)socket;
g_n.close_calls++;
return 0;
}
/* ===================== 假 io 层:极简内存视图 ===================== */
typedef struct {
int stat_rc;
int is_dir;
uint32_t size;
int open_read_rc;
int open_write_rc;
int write_short; /* >0:写返回这个短值 -> 触发半成品清理 */
int read_left;
int opendir_rc;
const char *names[4];
uint32_t sizes[4];
int dirs[4];
int readdir_i;
int readdir_n;
int unlink_calls;
char last_path[128];
} lftp_io_t;
static lftp_io_t g_io;
char *lftpd_io_canonicalize_path(const char *base, const char *name)
{
char *p = (char *)pvPortMalloc(128);
if (p == NULL) {
return NULL;
}
(void)snprintf(p, 128, "%s/%s", (base != NULL) ? base : "",
(name != NULL) ? name : "");
(void)strncpy(g_io.last_path, p, sizeof(g_io.last_path) - 1u);
return p;
}
int lftpd_io_stat(const char *path, uint32_t *p_size, int *p_is_dir)
{
(void)path;
if (p_size != NULL) { *p_size = g_io.size; }
if (p_is_dir != NULL) { *p_is_dir = g_io.is_dir; }
return g_io.stat_rc;
}
int lftpd_io_open_read(const char *path) { (void)path; return g_io.open_read_rc; }
int lftpd_io_open_write(const char *path) { (void)path; return g_io.open_write_rc; }
int lftpd_io_read(int handle, void *buf, int len)
{
int n;
(void)handle;
(void)buf;
if (g_io.read_left <= 0) {
return 0;
}
n = (g_io.read_left > len) ? len : g_io.read_left;
g_io.read_left -= n;
return n;
}
int lftpd_io_write(int handle, const void *buf, int len)
{
(void)handle;
(void)buf;
return (g_io.write_short > 0) ? g_io.write_short : len;
}
void lftpd_io_close(int handle) { (void)handle; }
int lftpd_io_unlink(const char *path) { (void)path; g_io.unlink_calls++; return 0; }
void *lftpd_io_opendir(const char *path)
{
(void)path;
return (g_io.opendir_rc != 0) ? NULL : (void *)&g_io;
}
int lftpd_io_readdir(void *dp, char *name, int name_max, uint32_t *p_size, int *p_is_dir)
{
int i = g_io.readdir_i;
(void)dp;
if (i >= g_io.readdir_n) {
return -1;
}
g_io.readdir_i++;
(void)strncpy(name, g_io.names[i], (size_t)name_max - 1);
name[name_max - 1] = '\0';
*p_size = g_io.sizes[i];
*p_is_dir = g_io.dirs[i];
return 0;
}
void lftpd_io_closedir(void *dp) { (void)dp; }
/* ===================== 假配置与假认证 ===================== */
static ftp_cfg_t g_cfg;
static int g_cfg_present = 1;
static int g_default_pwd;
static ftp_auth_result_t g_auth_res;
static int g_auth_calls;
const ftp_cfg_t *ftp_cfg_get(void) { return (g_cfg_present != 0) ? &g_cfg : NULL; }
int ftp_cfg_is_default_pwd(void) { return g_default_pwd; }
ftp_auth_result_t ftp_auth_check(const ftp_auth_creds_t *p_creds, uint8_t user_ok,
const char *p_pwd, ftp_auth_state_t *p_state,
uint32_t now_ms)
{
(void)p_creds;
(void)user_ok;
(void)p_pwd;
(void)p_state;
(void)now_ms;
g_auth_calls++;
return g_auth_res;
}
int ftp_auth_user_match(const char *p_cfg_user, const char *p_given)
{
if ((p_cfg_user == NULL) || (p_given == NULL)) {
return 0;
}
return (strcmp(p_cfg_user, p_given) == 0) ? 1 : 0;
}
/* ===================== 假 net 与 OS ===================== */
static uint32_t g_tick;
static int g_sockname_rc; /* net_getsockname 的返回码(PASV 分支注入失败用) */
uint32_t xTaskGetTickCount(void) { return g_tick; }
/* lftpd_task_wd_beat 的真实定义在 lftpd_inet.c 里(__weak,供 lftpd.c 覆盖),本模块
不拖那一层进来,故在此给计数假件——正好把"传输循环每轮喂狗"变成可断言的事。 */
static int g_wd_beats;
void lftpd_task_wd_beat(void) { g_wd_beats++; }
void vTaskDelay(TickType_t t)
{
(void)t;
g_n.delay_calls++;
if (g_n.delay_calls > 200) {
printf(" ABORT 循环跑飞(>200 轮)\n");
fflush(stdout);
exit(2);
}
}
int net_getsockname(int sockfd, struct net_sockaddr *addr, int *addrlen)
{
struct net_sockaddr_in *a = (struct net_sockaddr_in *)addr;
(void)sockfd;
(void)addrlen;
a->sin_addr.s_addr = 0x0101A8C0u; /* 192.168.1.1 的宿主序表示(假件自定) */
a->sin_port = net_htons(21u);
return g_sockname_rc;
}
int net_getpeername(int sockfd, struct net_sockaddr *addr, int *addrlen)
{
(void)sockfd;
(void)addr;
(void)addrlen;
return 0;
}
uint16_t net_htons(uint16_t v) { return (uint16_t)((v >> 8) | (v << 8)); }
uint16_t net_ntohs(uint16_t v) { return net_htons(v); }
uint32_t net_htonl(uint32_t v)
{
return ((v & 0xFFu) << 24) | (((v >> 8) & 0xFFu) << 16) |
(((v >> 16) & 0xFFu) << 8) | ((v >> 24) & 0xFFu);
}
char *net_inet_ntoa(uint32_t in, char *p_buf)
{
(void)snprintf(p_buf, 16, "%u.%u.%u.%u", (unsigned)(in & 0xFFu),
(unsigned)((in >> 8) & 0xFFu), (unsigned)((in >> 16) & 0xFFu),
(unsigned)((in >> 24) & 0xFFu));
return p_buf;
}
/* ===================== 复位与驱动 ===================== */
static lftpd_client_t g_cli;
static void reset_all(void)
{
(void)memset(&g_n, 0, sizeof(g_n));
(void)memset(&g_io, 0, sizeof(g_io));
g_n.string_rc = 0;
g_n.listen_rc = 20;
g_n.port = 5000;
g_n.accept_to_rc = 21; /* 默认数据通道能连上 */
g_n.data_write_rc = 0;
g_io.stat_rc = 0;
g_io.is_dir = 1;
g_io.size = 7u;
g_io.open_read_rc = 0;
g_io.open_write_rc = 0;
g_io.read_left = 0;
g_io.readdir_n = 0;
g_cfg_present = 1;
(void)memset(&g_cfg, 0, sizeof(g_cfg));
(void)strcpy(g_cfg.user, "admin");
g_cfg.write_en = 1u;
g_auth_res = FTP_AUTH_OK;
g_default_pwd = 0;
g_tick = 0u;
g_wd_beats = 0;
g_free_calls = 0;
g_pool_used = 0u;
g_lg_info_count = 0;
g_lg_error_count = 0;
g_lg_debug_count = 0;
g_sockname_rc = 0;
(void)memset(&g_cli, 0, sizeof(g_cli));
(void)strcpy(g_cli.directory, "/");
g_cli.socket = 10;
g_cli.data_socket = -1;
/* 不写死字段名:认证状态的结构由 lftpd_auth.h 定义,本模块只把它当黑盒清零
(口令/锁定逻辑有 test/host/ftp_auth 的 10 条专测,这里不重复验)。 */
(void)memset(&s_auth_state, 0, sizeof(s_auth_state));
}
static void feed(const char *l0, const char *l1, const char *l2, const char *l3)
{
int n = 0;
g_n.lines[n] = l0; g_n.line_ret[n++] = 0;
if (l1 != NULL) { g_n.lines[n] = l1; g_n.line_ret[n++] = 0; }
if (l2 != NULL) { g_n.lines[n] = l2; g_n.line_ret[n++] = 0; }
if (l3 != NULL) { g_n.lines[n] = l3; g_n.line_ret[n++] = 0; }
g_n.line_n = n;
}
static const char *rep(int k) { return (k < g_n.reply_n) ? g_n.replies[k] : ""; }
static int rep_has(const char *prefix)
{
int i;
int c = 0;
for (i = 0; i < g_n.reply_n; i++) {
if (strncmp(g_n.replies[i], prefix, strlen(prefix)) == 0) {
c++;
}
}
return c;
}
/* ===================== 控制通道闸门 ===================== */
static void tc_gates(void)
{
printf("-- TC-LFTPD-001..008 control channel gates --\n");
reset_all();
feed("RETR a.txt", "LIST", "PWD", NULL);
(void)handle_control_channel(&g_cli);
CHECK((rep_has("220 ") == 1) && (rep_has("530 ") == 3) &&
(g_n.accept_calls == 0),
"TC-LFTPD-001 未登录只放行 USER/PASS/QUIT/FEAT/SYST/NOOP,其它一律 530");
reset_all();
feed("ABCDEFG", "NOOP", NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((rep_has("500 ") == 1) && (rep_has("200 ") == 1),
"TC-LFTPD-002 命令名 >=5 字符先拒(command_tmp 只有 5 格,防 memcpy 越界)");
reset_all();
g_cli.auth_logged_in = 1; /* 否则会被认证白名单先以 530 拦掉,测不到分派 */
feed("FOO bar", NULL, NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((rep_has("502 ") == 1) && (rep_has("530 ") == 0),
"TC-LFTPD-003 认不出的命令回 502 而不是断开");
reset_all();
feed("QUIT", "NOOP", NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((rep_has("221 ") == 1) && (rep_has("200 ") == 0) && (g_n.line_i == 1),
"TC-LFTPD-004 QUIT 之后不再读下一条(会话就此结束)");
reset_all();
g_n.string_rc = -1; /* 220 都送不出去 */
feed("NOOP", NULL, NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((g_n.line_i == 0) && (g_lg_info_count >= 1),
"TC-LFTPD-005 欢迎未送达(对端已走)就直接收工,不空转读命令(该留痕按真实分级算 INFO)");
reset_all();
g_cli.data_socket = 33; /* 被动模式已开但客户端直接跑了 */
feed("QUIT", NULL, NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((g_n.close_calls == 1) && (g_cli.data_socket == -1),
"TC-LFTPD-006 退出时必须释放还开着的**数据监听**(单连接 socket 不能留残口)");
reset_all();
g_cli.auth_logged_in = 1;
feed("noop", NULL, NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK(rep_has("200 ") == 1, "TC-LFTPD-007 命令名大小写不敏感");
reset_all();
g_cli.auth_logged_in = 1;
g_io.opendir_rc = 0;
g_io.readdir_n = 0;
g_n.listen_rc = 20;
feed("LIST", NULL, NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((rep_has("425 ") == 1) && (g_n.accept_calls == 0),
"TC-LFTPD-010 没先开被动模式的数据命令一律 425,且不去 accept");
}
/* ===================== 被动模式与数据通道 ===================== */
static void tc_passive(void)
{
printf("-- TC-LFTPD-011..016 passive & data --\n");
reset_all();
g_cli.auth_logged_in = 1;
/* 直调处理器:走 handle_control_channel 的话,会话退出时 cleanup 一定把被动监听
关掉(那正是 TC-LFTPD-006 要验的行为),这里要看的是 PASV 自己留下的状态。 */
(void)cmd_pasv(&g_cli, NULL);
CHECK((strcmp(rep(0), "227 Entering Passive Mode (192,168,1,1,19,136).\r\n") == 0) &&
(g_cli.data_socket == 20) && (g_n.listen_calls == 1),
"TC-LFTPD-011 227 六元组:IP 经 htonl 后按高字节到低字节(点分序)、端口高/低,并记下被动监听号");
reset_all();
g_cli.auth_logged_in = 1;
g_sockname_rc = -1; /* 问不到本机地址 */
feed("PASV", NULL, NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((rep_has("425 ") == 1) && (g_n.close_calls == 1) &&
(g_cli.data_socket == -1),
"TC-LFTPD-012 拿不到本机 IP 时要把刚建的监听关掉(别留半个被动模式)");
reset_all();
g_cli.auth_logged_in = 1;
feed("EPSV", NULL, NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK(strcmp(rep(1), "229 Entering Extended Passive Mode (|||5000|).\r\n") == 0,
"TC-LFTPD-013 EPSV 只报端口(IPv6 客户端自己沿用控制连接地址)");
reset_all();
g_cli.auth_logged_in = 1;
g_n.listen_rc = 20;
g_n.accept_to_rc = -1; /* 对端一直不开数据通道 */
feed("PASV", "LIST", NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((rep_has("150 ") == 1) && (rep_has("425 ") == 1) &&
(g_cli.data_socket == -1) && (g_n.close_calls == 1),
"TC-LFTPD-014 数据 accept 失败:报 425 并**关掉监听复位**,不留给下一条命令");
reset_all();
g_cli.auth_logged_in = 1;
g_io.readdir_n = 2;
g_io.names[0] = "a.txt"; g_io.sizes[0] = 7u; g_io.dirs[0] = 0;
g_io.names[1] = "sub"; g_io.sizes[1] = 0u; g_io.dirs[1] = 1;
feed("PASV", "LIST", NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((rep_has("226 ") == 1) && (g_n.data_write_rc == 0),
"TC-LFTPD-015 LIST 成功:先 150、数据口写目录、再 226 收尾");
reset_all();
g_cli.auth_logged_in = 1;
g_io.opendir_rc = 1; /* 目录打不开 */
feed("PASV", "LIST", NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK(rep_has("550 ") == 1, "TC-LFTPD-016 目录打不开时 LIST 回 550(不是 226)");
}
/* ===================== 传输与写开关 ===================== */
static void tc_transfer(void)
{
printf("-- TC-LFTPD-017..021 transfer & write gate --\n");
reset_all();
g_cli.auth_logged_in = 1;
g_io.read_left = 5;
feed("PASV", "RETR a.txt", NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((rep_has("226 ") == 1) && (g_lg_info_count >= 1) && (g_wd_beats >= 1),
"TC-LFTPD-017 RETR 成功:226 收尾、留 DOWNLOAD 记录,且传输循环有喂狗");
reset_all();
g_cli.auth_logged_in = 1;
g_io.open_read_rc = -1;
feed("PASV", "RETR gone.txt", NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((rep_has("450 ") == 1) && (g_lg_error_count >= 1),
"TC-LFTPD-018 文件打不开回 450(传输失败与命令不合法要分开报)");
reset_all();
g_cli.auth_logged_in = 1;
g_n.rx_left = 900;
g_io.write_short = 100; /* 写到一半失败 */
feed("PASV", "STOR up.bin", NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((g_io.unlink_calls == 1) && (rep_has("450 ") == 1),
"TC-LFTPD-019 STOR 中途失败必须**删掉半成品**(否则 Flash 上永久留个坏文件)");
reset_all();
g_cli.auth_logged_in = 1;
g_cfg.write_en = 0u;
g_n.listen_rc = 20;
feed("PASV", "STOR up.bin", NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((rep_has("550 ") == 1) && (g_n.accept_calls == 0) &&
(g_io.open_write_rc == 0) && (g_io.unlink_calls == 0),
"TC-LFTPD-020 写开关关闭时 STOR 在碰文件之前就拒绝,且不开数据通道");
reset_all();
g_cli.auth_logged_in = 1;
g_cfg.write_en = 0u;
(void)cmd_dele(&g_cli, "a.txt");
CHECK(rep_has("550 ") == 1, "TC-LFTPD-021 写开关同样挡住 DELE");
reset_all();
g_cli.auth_logged_in = 1;
g_io.is_dir = 1;
(void)cmd_dele(&g_cli, "sub");
CHECK((rep_has("550 ") == 1) && (g_io.unlink_calls == 0),
"TC-LFTPD-022 DELE 不许删目录(本实现只支持删文件)");
reset_all();
g_cli.auth_logged_in = 1;
g_io.is_dir = 0;
(void)cmd_dele(&g_cli, "a.txt");
CHECK((g_io.unlink_calls == 1) && (rep_has("250 ") == 1),
"TC-LFTPD-022b 删普通文件走 unlink 并回 250");
}
/* ===================== 目录与元信息 ===================== */
static void tc_meta(void)
{
printf("-- TC-LFTPD-023..030 cwd / size / pwd / feat --\n");
reset_all();
(void)cmd_cwd(&g_cli, "");
CHECK((rep_has("550 ") == 1) && (strcmp(g_cli.directory, "/") == 0),
"TC-LFTPD-023 CWD 空参数拒绝,当前目录不变");
reset_all();
g_io.is_dir = 0;
(void)cmd_cwd(&g_cli, "a.txt");
CHECK(rep_has("550 ") == 1, "TC-LFTPD-023b CWD 到普通文件被拒(is_dir 必须真为目录)");
reset_all();
g_io.is_dir = 1;
(void)cmd_cwd(&g_cli, "logs");
CHECK((rep_has("250 ") == 1) && (strcmp(g_cli.directory, "//logs") == 0) &&
(g_free_calls == 1),
"TC-LFTPD-024 CWD 成功后更新当前目录并释放路径缓冲(不泄漏堆)");
reset_all();
(void)cmd_cwd(&g_cli, "/");
CHECK((rep_has("250 ") == 1) && (g_io.stat_rc == 0),
"TC-LFTPD-024b 回到根目录免 stat 直接放行(根一定存在)");
reset_all();
g_io.is_dir = 0;
g_io.size = 12345u;
(void)cmd_size(&g_cli, "up.bin");
CHECK(strcmp(rep(0), "213 12345\r\n") == 0, "TC-LFTPD-025 SIZE 报字节数");
reset_all();
g_io.stat_rc = -1;
(void)cmd_size(&g_cli, "nope");
CHECK(rep_has("550 ") == 1, "TC-LFTPD-025b 文件不存在时 SIZE 回 550");
reset_all();
(void)strcpy(g_cli.directory, "/logs");
(void)cmd_pwd(&g_cli, NULL);
CHECK(strcmp(rep(0), "257 \"/logs\"\r\n") == 0,
"TC-LFTPD-026 PWD 用双引号包住目录(RFC 959 要求)");
reset_all();
(void)cmd_syst(&g_cli, NULL);
CHECK(strcmp(rep(0), "215 UNIX Type: L8\r\n") == 0,
"TC-LFTPD-027 SYST 固定报 UNIX Type: L8(客户端据此决定模式)");
reset_all();
(void)cmd_feat(&g_cli, NULL);
CHECK((strncmp(rep(0), "211-", 4) == 0) && (strcmp(rep(1), "EPSV\r\n") == 0) &&
(strcmp(rep(2), "PASV\r\n") == 0) && (strncmp(rep(5), "211 ", 4) == 0) &&
(g_n.reply_n == 6),
"TC-LFTPD-028 FEAT 多行:首行 211-、四条能力无状态码、末行 211 结束");
reset_all();
(void)cmd_type(&g_cli, "I");
(void)cmd_noop(&g_cli, NULL);
CHECK(rep_has("200 ") == 2, "TC-LFTPD-030 TYPE 与 NOOP 都回 200");
}
/* ===================== 登录序列 ===================== */
static void tc_login(void)
{
printf("-- TC-LFTPD-031..033 login --\n");
reset_all();
feed("USER admin", "PASS good", "RETR a.txt", NULL);
(void)handle_control_channel(&g_cli);
CHECK((rep_has("331 ") == 1) && (rep_has("230 ") == 1) &&
(g_cli.auth_logged_in == 1u) && (strcmp(g_cli.auth_user, "admin") == 0),
"TC-LFTPD-031 USER 记下用户名与匹配位,PASS 过了就转已登录");
reset_all();
g_auth_res = FTP_AUTH_FAIL;
feed("USER admin", "PASS bad", NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((rep_has("530 ") == 1) && (g_cli.auth_logged_in == 0u) &&
(g_lg_info_count >= 1),
"TC-LFTPD-032 口令错:530 且**必须重新走 USER**(auth_user_ok 已清零)");
reset_all();
g_auth_res = FTP_AUTH_LOCKED;
g_cfg_present = 1;
feed("USER admin", "PASS bad", NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK(rep_has("530 ") == 1,
"TC-LFTPD-033 连错被锁时同样只回 530(不给攻击者区分提示)");
reset_all();
g_cfg_present = 0; /* 配置读不到 */
feed("USER admin", "PASS x", NULL, NULL);
(void)handle_control_channel(&g_cli);
CHECK((rep_has("331 ") == 1) && (rep_has("530 ") == 1) &&
(g_cli.auth_user_ok == 0u),
"TC-LFTPD-034 配置缺失时 USER 无法匹配、PASS 直接 530(fail-closed)");
}
int main(void)
{
printf("=== LFTPD Protocol Tests ===\n");
tc_gates();
tc_passive();
tc_transfer();
tc_meta();
tc_login();
printf("\n=== LFTPD Protocol Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""FTP 控制通道协议层(lftpd)主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/lftpd/lftpd_test.c(以 #include 纳入生产源
Drivers/BSP/NET/lftpd/lftpd.c),在 PC 上离线驱动控制通道状态机与 17 条命令
处理器:认证前置白名单、超长命令名拒绝、未知命令 502、QUIT 收尾释放数据监听、
大小写不敏感、PASV/EPSV 的 227/229 编码、数据 accept 失败不残留半开监听、
STOR 中途失败删半成品、写开关挡住 STOR/DELE、CWD 目录判定与堆释放、FEAT 多行
格式、登录序列与 fail-closed。工具链与判定委托 unit_test_harness.py。
为什么要主机侧单测(而不是只用台架):
STOR 传一半失败必须把半成品删掉,否则 Flash 上永久留个坏文件(现场表现为升级包
传坏了再也传不上去),真机要卡准"传一半掐线"的时机极难;数据通道是单连接 socket,
每条命令用完必须关监听并复位 data_socket=-1,漏一次就是偶发连不上且与上一会话
时序相关;command_tmp 只有 5 格,那条 index >= 5 的前置判断就是防 memcpy 越界,
改一行就没了;写开关必须在碰文件之前挡住。这些都要能注入失败才测得干净。
桩的边界:lftpd.h / private/*.h / lftpd_auth.h / ftp_cfg.h / ff.h / net_types.h 用真实
头(命令表、client 结构、状态码宏、ftp_cfg_t 字段一改即编译期报错);下面三层
(inet 适配、io 层、认证与配置)是假件——认证本身有 test/host/ftp_auth 的 10 条
专测,本模块只把它当可注入的黑盒,不重复验;认证状态结构体按黑盒 memset 清零,
不写死字段名。假 inet 给"脚本化命令行队列 + 响应记录",假 io 给"极简内存视图 +
可注入失败",另配 vTaskDelay 跑飞保险。
用法(仓库根目录):
python test/host/lftpd/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误。
设计依据:docs/测试说明/FTP_Test_Guide.md、docs/子系统设计/网络配置说明.md §3.4、
docs/测试说明/主机单测覆盖率口径.md §9
创建日期:2026-10-23
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
BUILD = TestBuild(
name="LFTPD Protocol Tests",
src=os.path.join(HERE, "lftpd_test.c"),
include_dirs=[
os.path.join(HERE, "stub"),
os.path.join(ROOT, "Drivers", "BSP", "NET", "lftpd"),
os.path.join(ROOT, "Drivers", "BSP", "NET"),
os.path.join(ROOT, "Drivers", "BSP", "NETCFG"), # ftp_cfg.h 落在这里
os.path.join(ROOT, "Lib", "FatFs"),
HERE,
],
extra_flags=["/D_CRT_SECURE_NO_WARNINGS"],
summary_marker="=== LFTPD Protocol Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+24
View File
@@ -0,0 +1,24 @@
/*
* 模块名称:主机侧单测桩 - FreeRTOS.h(lftpd 协议层模块专用)
* 模块功能:宿主等价的 tick 类型与换算(本仓 tick=1ms,故 pdMS_TO_TICKS 即原值),
* 让 FTP 的 3 s 监听重建退避、LFTPD_WAIT_SLICE_MS 分片可直接按毫秒断言。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef FREERTOS_H
#define FREERTOS_H
#include <stddef.h>
#include <stdint.h>
typedef uint32_t TickType_t;
typedef long BaseType_t;
typedef unsigned long UBaseType_t;
typedef void *TaskHandle_t;
#define pdMS_TO_TICKS(ms) ((TickType_t)(ms))
void *pvPortMalloc(size_t xWantedSize);
void vPortFree(void *pv);
#endif /* FREERTOS_H */
+29
View File
@@ -0,0 +1,29 @@
/*
* 模块名称:主机侧单测桩 - dbg_log.h(lftpd 协议层模块专用)
* 模块功能:DBG_* 换成计数,让"对端在 220 握手前就走了""传输未完成"这些只体现在
* 日志里的分支可断言。
*
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 为什么桩这层而不是 private/lftpd_log.h:真实那份是**宏**(lftpd_log_info 等直接
* 展开成 DBG_*),而且 MSVC 的引号 include 会先从 lftpd.c 自己的目录找
* private/lftpd_log.h——桩目录赢不过它。所以正确做法是让真实 lftpd_log.h 照常参与
* 编译(它的分级语义也就一起被验证了),只把最底下的 DBG_* 换成计数。
* 顺带说明分级(真实头注释里写得很清楚):lftpd_log_peer 是 **info** 级不是 error,
* 因为"客户端关了个窗口"不该把 @test 的 Forbidden 标记 [ERR] 触发成 FAIL,也不该
* 让任何人连 21 端口就走就能无限制造固件级 ERROR;而 DOWNLOAD/UPLOAD FAILED 仍记
* error(那是数据完整性事件)。
*
* 创建日期:2026-10-23
*/
#ifndef DBG_LOG_H
#define DBG_LOG_H
extern int g_lg_info_count;
extern int g_lg_error_count;
extern int g_lg_debug_count;
#define DBG_INFO(fmt, ...) do { (void)(fmt); g_lg_info_count++; } while (0)
#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_lg_debug_count++; } while (0)
#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_lg_error_count++; } while (0)
#endif /* DBG_LOG_H */
+34
View File
@@ -0,0 +1,34 @@
/*
* 模块名称:主机侧单测桩 - net_socket.h(lftpd 协议层模块专用)
* 模块功能:给 FTP 协议层四个地址/字节序入口(PASV 要用本机 IP 拼 227 响应),
* 并照做真实头对 FreeRTOS.h / task.h 的 include 链。地址结构与
* NET_AF_INET / NET_SOCK_STREAM 取**真实** net_types.h。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef NET_SOCKET_H
#define NET_SOCKET_H
#include "FreeRTOS.h"
#include "task.h"
/* 主机侧必要清理:lftpd.c 会 include ff.h,而这份 FatFs 在 Windows 支路上会把
* winsock.h 拉进来,它带着 "#define s_addr S_un.S_addr" 这个 BSD 兼容宏——于是真实
* net_types.h 里的字段声明 uint32_t s_addr; 被展开成 uint32_t S_un.S_addr; 直接炸
* (实测报 C2143:在 "." 前面缺 ";",再级联出一串"sin_addr 使用未定义的 struct")。
* 这里只 #undef 掉这个污染宏,让**固件那份结构体定义原样参与编译**:字段名仍是真实
* 的 s_addr,被测代码读的也是它。(lftpd_inet 批次不 include ff.h,所以没撞上。) */
#ifdef s_addr
#undef s_addr
#endif
#include "net_types.h"
int net_getsockname(int sockfd, struct net_sockaddr *addr, int *addrlen);
int net_getpeername(int sockfd, struct net_sockaddr *addr, int *addrlen);
uint16_t net_htons(uint16_t hostshort);
uint16_t net_ntohs(uint16_t netshort);
uint32_t net_htonl(uint32_t hostlong);
char *net_inet_ntoa(uint32_t in, char *p_buf); /* 两参:调用方给缓冲 */
#endif /* NET_SOCKET_H */
+18
View File
@@ -0,0 +1,18 @@
/*
* 模块名称:主机侧单测桩 - task.h(lftpd 协议层模块专用)
* 模块功能:vTaskDelay 与 xTaskGetTickCount 的可控假件接口。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 注意 lftpd.c 只 include 了 FreeRTOS.h,这两个符号在真机上是经 net_socket.h 的
* include 链拿到的——所以本模块的 net_socket.h 桩必须照做那条链(少一行就是 C2065,
* lftpd_inet 批次实测踩过)。
* 创建日期:2026-10-23
*/
#ifndef TASK_H
#define TASK_H
#include "FreeRTOS.h"
TickType_t xTaskGetTickCount(void);
void vTaskDelay(TickType_t xTicksToDelay);
#endif /* TASK_H */
+456
View File
@@ -0,0 +1,456 @@
/*
* 模块名称:FTP 的 socket 适配层(lftpd_inet)主机侧单元测试
* 模块功能:在 PC 上离线验证 Drivers/BSP/NET/lftpd/lftpd_inet.c 的监听/接受/读写
* 适配:端口必须过 htons、回来的端口要 ntohs 还原;accept 循环每轮喂狗并
* 让出 CPU、**网络代次一变就立刻撤掉 accept**(否则新 IP 生效后监听还挂在
* 旧代次上);带超时的 accept 在监听门撤防时立即放弃;read_line 的 CRLF
* 截断、分片拼接、-2 继续等、其它负值原样返回、200ms 分片与 30 s 总预算;
* 短写续传(FTP 响应少半条就是协议错乱)。
*
* 被测对象:真实源文件 ../../../../Drivers/BSP/NET/lftpd/lftpd_inet.c(#include 纳入)。
* private/lftpd_inet.h 用真实头;net_types.h 用**真实头**(struct
* net_sockaddr_in 的 sin_port 注释就写着"网络字节序",htons/ntohs 的契约
* 靠它才钉得住)。假的是 socket 层本身:accept 序列、bind/listen 返回码、
* 代次变化点、监听门开关、recv 的返回序列 + 字节流、send 的短写序列,
* 外加可控 tick 与 vTaskDelay 计数(带跑飞保险)。
*
* 为什么要主机侧单测(而不是只靠台架):
* 1. 代次撤 accept 与"监听门撤防就放弃"这两条只在**异常时刻**成立(网络重建、
* 客户端连上控制口却迟迟不开数据通道)。台架上要凑"对面正连着我顺手改 IP"
* 这种时序极难,而真机上它是现场最常见的自愈路径。
* 2. 200ms 分片 + 30 s 总预算是 FTP 控制信道的等待结构:分片写错会把看门狗饿到
* 复位(每轮都要喂),预算写错会让 ftpTask 永久卡在一条命令上。两者都需要
* "按毫秒数着"才能验证。
* 3. 短写续传:net_send 可以只写一部分;如果适配层按一次写完处理,FTP 响应就会
* 少半条(客户端表现为协议错乱、卡住),而这种短写在真机上取决于当时缓冲余量。
* 4. lftpd_task_wd_beat 在本文件是 __weak 空实现(真机由 lftpd.c 覆盖),主机侧
* 替换不掉,所以用同一段里的"睡 100ms"次数当喂狗轮数的代理(已在桩里注明)。
*
* 覆盖点:TC-LFTINET-001..004 监听;005/006 端口还原;010..012 accept 与代次;
* 020..022 带超时 accept 与撤防;030..036 read_line;040..043 write_string;
* 050 write;060..062 read;070 close。
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-23
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "private/lftpd_inet.h"
#include "net_types.h"
#include "net_socket.h"
#include "lftpd_inet.c"
static int s_total;
static int s_passed;
static int s_failed;
#define CHECK(cond, name) do { \
s_total++; \
if (cond) { s_passed++; printf(" PASS %s\n", name); } \
else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \
} while (0)
int g_lfi_info_count;
int g_lfi_error_count;
int g_lfi_debug_count;
/* ===================== 可控 socket 层 ===================== */
typedef struct {
int socket_rc;
int bind_rc;
int listen_rc;
int getsock_rc;
uint16_t getsock_port_net;
int accept_seq[16];
int accept_n;
int accept_i;
int accept_calls;
int gen;
int gen_flip_after;
int armed;
int recv_seq[24];
int recv_n;
int recv_i;
int recv_calls;
uint32_t last_recv_timeout;
char rx[512];
int rx_pos;
int send_seq[8];
int send_n;
int send_i;
int send_calls;
int send_off[8];
const char *send_base;
int socket_calls;
int bind_calls;
int listen_calls;
int close_calls;
int last_domain;
int last_type;
uint16_t last_bind_port_net;
int delay_calls;
TickType_t tick;
} lfi_net_t;
static lfi_net_t g_net;
static void net_reset(void)
{
(void)memset(&g_net, 0, sizeof(g_net));
g_net.socket_rc = 3;
g_net.bind_rc = 0;
g_net.listen_rc = 0;
g_net.getsock_rc = 0;
g_net.armed = 1;
g_net.gen = 7;
g_net.tick = 0u;
}
uint32_t net_get_generation(void) { return (uint32_t)g_net.gen; }
int net_listener_armed(int sockfd) { (void)sockfd; return g_net.armed; }
uint16_t net_htons(uint16_t hostshort)
{
return (uint16_t)((uint16_t)((hostshort & 0xFFU) << 8) | (uint16_t)(hostshort >> 8));
}
uint16_t net_ntohs(uint16_t netshort)
{
return net_htons(netshort);
}
int net_socket(int domain, int type)
{
g_net.socket_calls++;
g_net.last_domain = domain;
g_net.last_type = type;
return g_net.socket_rc;
}
int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen)
{
const struct net_sockaddr_in *a = (const struct net_sockaddr_in *)addr;
(void)sockfd;
(void)addrlen;
g_net.bind_calls++;
g_net.last_bind_port_net = a->sin_port;
return g_net.bind_rc;
}
int net_listen(int sockfd)
{
(void)sockfd;
g_net.listen_calls++;
return g_net.listen_rc;
}
int net_close(int sockfd)
{
(void)sockfd;
g_net.close_calls++;
return 0;
}
int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen)
{
int r;
(void)sockfd;
(void)addr;
(void)addrlen;
g_net.accept_calls++;
if ((g_net.gen_flip_after > 0) && (g_net.accept_calls >= g_net.gen_flip_after)) {
g_net.gen++; /* 模拟网络在这期间重建(代次 +1) */
}
if (g_net.accept_i < g_net.accept_n) {
r = g_net.accept_seq[g_net.accept_i++];
} else {
r = (g_net.accept_n > 0) ? g_net.accept_seq[g_net.accept_n - 1] : -1;
}
return r;
}
int net_getsockname(int sockfd, struct net_sockaddr *addr, int *addrlen)
{
struct net_sockaddr_in *a = (struct net_sockaddr_in *)addr;
(void)sockfd;
(void)addrlen;
a->sin_port = g_net.getsock_port_net;
return g_net.getsock_rc;
}
int net_recv_to(int sockfd, void *buf, int len, uint32_t timeout_ms)
{
int r;
int i;
(void)sockfd;
g_net.recv_calls++;
g_net.last_recv_timeout = timeout_ms;
/* 每次调用当作耗掉一个 200ms 片:让 30 s 总预算的到点路径可测 */
g_net.tick += 200u;
if (g_net.recv_i < g_net.recv_n) {
r = g_net.recv_seq[g_net.recv_i++];
} else {
r = (g_net.recv_n > 0) ? g_net.recv_seq[g_net.recv_n - 1] : -2;
}
if (r > 0) {
for (i = 0; (i < r) && (i < len) && (g_net.rx_pos < (int)sizeof(g_net.rx)); i++) {
((char *)buf)[i] = g_net.rx[g_net.rx_pos++];
}
}
return r;
}
int net_send(int sockfd, const void *buf, int len, int flags)
{
int r;
(void)sockfd;
(void)flags;
if (g_net.send_base == NULL) {
g_net.send_base = (const char *)buf;
}
g_net.send_calls++;
if (g_net.send_i < 8) {
g_net.send_off[g_net.send_i] = (int)((const char *)buf - g_net.send_base);
}
if (g_net.send_i < g_net.send_n) {
r = g_net.send_seq[g_net.send_i++];
} else {
r = (g_net.send_n > 0) ? g_net.send_seq[g_net.send_n - 1] : len;
}
return r;
}
TickType_t xTaskGetTickCount(void) { return g_net.tick; }
void vTaskDelay(TickType_t xTicksToDelay)
{
(void)xTicksToDelay;
g_net.delay_calls++;
if (g_net.delay_calls > 100) {
/* 跑飞保险:本模块每条用例都必须在有限轮内退出(accept 序列要么成功、
* 要么撤防、要么代次变化)。真跑飞就退出,绝不把挂死留给 pre-commit。 */
printf(" ABORT accept/等待循环跑飞(>100 轮未退出)\n");
fflush(stdout);
exit(2);
}
}
static void logs_clear(void)
{
g_lfi_info_count = 0;
g_lfi_error_count = 0;
g_lfi_debug_count = 0;
}
/* ===================== 监听与端口还原 ===================== */
static void tc_listen(void)
{
printf("-- TC-LFTINET-001..006 listen / port --\n");
net_reset(); logs_clear();
g_net.socket_rc = -1;
CHECK((lftpd_inet_listen(21) == -1) && (g_net.bind_calls == 0) &&
(g_lfi_error_count == 1), "TC-LFTINET-001 建 socket 失败即返回,不再 bind");
net_reset(); logs_clear();
g_net.bind_rc = -1;
CHECK((lftpd_inet_listen(21) == -1) && (g_net.close_calls == 1) &&
(g_net.listen_calls == 0), "TC-LFTINET-002 bind 失败要把 socket 关掉");
net_reset(); logs_clear();
g_net.listen_rc = -1;
CHECK((lftpd_inet_listen(21) == -1) && (g_net.close_calls == 1) &&
(g_lfi_error_count == 1), "TC-LFTINET-003 listen 失败同样关socket 并留痕");
net_reset(); logs_clear();
CHECK((lftpd_inet_listen(21) == 3) && (g_net.last_domain == NET_AF_INET) &&
(g_net.last_type == NET_SOCK_STREAM) &&
(g_net.last_bind_port_net == net_htons(21u)) && (g_net.listen_calls == 1),
"TC-LFTINET-004 流式监听口,且**端口过了 htons**(sin_port 是网络序)");
net_reset(); logs_clear();
g_net.getsock_rc = -1;
CHECK((lftpd_inet_get_socket_port(3) == -1) && (g_lfi_error_count == 1),
"TC-LFTINET-005 问不到名字就报 -1(不拿脏端口当结果)");
net_reset(); logs_clear();
g_net.getsock_port_net = net_htons(8888u);
CHECK(lftpd_inet_get_socket_port(3) == 8888,
"TC-LFTINET-006 端口回来要 ntohs 还原成宿主序");
}
/* ===================== accept:喂狗、代次、撤防 ===================== */
static void tc_accept(void)
{
printf("-- TC-LFTINET-010..012 / 020..022 accept --\n");
net_reset(); logs_clear();
g_net.accept_seq[0] = 9; g_net.accept_n = 1;
CHECK((lftpd_inet_accept(3) == 9) && (g_net.delay_calls == 0),
"TC-LFTINET-010 一击即中不睡不等");
net_reset(); logs_clear();
g_net.accept_seq[0] = -1; g_net.accept_seq[1] = -1; g_net.accept_seq[2] = 9;
g_net.accept_n = 3;
CHECK((lftpd_inet_accept(3) == 9) && (g_net.delay_calls == 2) &&
(g_lfi_info_count == 0),
"TC-LFTINET-011 每轮失败都让出一次 CPU(同段代码里也喂狗),直到成功");
net_reset(); logs_clear();
g_net.accept_seq[0] = -1; g_net.accept_seq[1] = -1; g_net.accept_seq[2] = -1;
g_net.accept_n = 3;
g_net.gen_flip_after = 3; /* 第 3 轮时网络重建 */
CHECK((lftpd_inet_accept(3) == -1) && (g_lfi_info_count == 1) &&
(g_net.accept_calls == 3),
"TC-LFTINET-012 **代次一变立刻撤掉 accept**(否则新 IP 生效后监听还挂在旧代次)");
net_reset(); logs_clear();
g_net.accept_seq[0] = -1; g_net.accept_seq[1] = 9; g_net.accept_n = 2;
CHECK((lftpd_inet_accept_timeout(3, 5000) == 9) && (g_net.delay_calls == 1),
"TC-LFTINET-020 超时窗内等到数据通道");
net_reset(); logs_clear();
g_net.accept_seq[0] = -1; g_net.accept_seq[1] = -1; g_net.accept_n = 2;
g_net.armed = 0; /* 监听门被撤(会话已放弃) */
CHECK((lftpd_inet_accept_timeout(3, 5000) == -1) && (g_net.accept_calls == 1) &&
(g_lfi_error_count == 1),
"TC-LFTINET-021 监听门撤防立即放弃,不对着死监听耗满 5 s");
net_reset(); logs_clear();
g_net.accept_seq[0] = -1; g_net.accept_n = 1;
CHECK((lftpd_inet_accept_timeout(3, 300) == -1) && (g_net.delay_calls == 3) &&
(g_lfi_info_count == 1),
"TC-LFTINET-022 对端一直不开数据通道:按 100ms 步进等满后 INFO 收场");
}
/* ===================== read_line:分片、截断、预算 ===================== */
static void tc_read_line(void)
{
char b[32];
int rc;
printf("-- TC-LFTINET-030..036 read_line --\n");
net_reset(); logs_clear();
(void)strcpy(g_net.rx, "USER admin\r\n");
g_net.recv_seq[0] = 12; g_net.recv_n = 1;
rc = lftpd_inet_read_line(3, b, sizeof(b));
CHECK((rc == 0) && (strcmp(b, "USER admin") == 0),
"TC-LFTINET-030 一次到位:CRLF 就地截成结束符(FTP 命令行的边界)");
net_reset(); logs_clear();
(void)strcpy(g_net.rx, "USER admin\r\n");
g_net.recv_seq[0] = 4; g_net.recv_seq[1] = 8; g_net.recv_n = 2;
rc = lftpd_inet_read_line(3, b, sizeof(b));
CHECK((rc == 0) && (strcmp(b, "USER admin") == 0),
"TC-LFTINET-031 分两片到达也要拼出整行(跨包命令行)");
net_reset(); logs_clear();
g_net.recv_seq[0] = 0; g_net.recv_n = 1;
CHECK(lftpd_inet_read_line(3, b, sizeof(b)) == -1,
"TC-LFTINET-032 读到 0(对端关闭)立刻失败,不进死循环");
net_reset(); logs_clear();
(void)strcpy(g_net.rx, "NOOP\r\n");
g_net.recv_seq[0] = -2; g_net.recv_seq[1] = 6; g_net.recv_n = 2;
rc = lftpd_inet_read_line(3, b, sizeof(b));
CHECK((rc == 0) && (strcmp(b, "NOOP") == 0),
"TC-LFTINET-033 -2 表示这一片超时,继续等而不是当错误");
net_reset(); logs_clear();
g_net.recv_seq[0] = -5; g_net.recv_n = 1;
CHECK(lftpd_inet_read_line(3, b, sizeof(b)) == -5,
"TC-LFTINET-034 其它负值**原样返回**(错误码不许被吞成 -1)");
net_reset(); logs_clear();
g_net.recv_seq[0] = -2; g_net.recv_n = 1; /* 一直超时,每轮耗 200ms */
rc = lftpd_inet_read_line(3, b, sizeof(b));
CHECK((rc == -1) && (g_net.recv_calls == 150) &&
(g_net.last_recv_timeout <= 200u),
"TC-LFTINET-035 30 s 总预算按 200ms 分片累加,到点退出(饿不到看门狗)");
net_reset(); logs_clear();
(void)memset(g_net.rx, 'A', 40); /* 没有 CRLF 的长行 */
g_net.recv_seq[0] = 7; g_net.recv_n = 1;
(void)memset(b, 0, sizeof(b));
rc = lftpd_inet_read_line(3, b, 8);
CHECK((rc == -1) && (b[7] == '\0'),
"TC-LFTINET-036 缓冲填满仍无行尾:失败且**不越界写**(留最后一格给结束符)");
}
/* ===================== 写与读 ===================== */
static void tc_write_read(void)
{
char b[16];
const char msg[] = "220 ready\r\n";
printf("-- TC-LFTINET-040..070 write / read / close --\n");
net_reset(); logs_clear();
g_net.send_seq[0] = 12; g_net.send_n = 1;
CHECK((lftpd_inet_write_string(3, msg) == 0) && (g_net.send_calls == 1),
"TC-LFTINET-040 一次写完");
net_reset(); logs_clear();
g_net.send_seq[0] = 5; g_net.send_seq[1] = 7; g_net.send_n = 2;
CHECK((lftpd_inet_write_string(3, msg) == 0) && (g_net.send_calls == 2) &&
(g_net.send_off[1] == 5),
"TC-LFTINET-041 短写后续传从**正确的偏移**接着写(少半条响应就是协议错乱)");
net_reset(); logs_clear();
g_net.send_seq[0] = 0; g_net.send_n = 1;
CHECK((lftpd_inet_write_string(3, msg) == -1) && (g_lfi_error_count == 1),
"TC-LFTINET-042 写 0 字节按失败处理(不是当成成功写了 0 字节)");
net_reset(); logs_clear();
g_net.send_seq[0] = -1; g_net.send_n = 1;
CHECK(lftpd_inet_write(3, msg, 12) == -1, "TC-LFTINET-043 写失败原样回 -1");
net_reset(); logs_clear();
g_net.send_seq[0] = 4; g_net.send_seq[1] = 6; g_net.send_n = 2;
CHECK((lftpd_inet_write(3, msg, 10) == 10) && (g_net.send_calls == 2),
"TC-LFTINET-050 二进制写也走短写续传,成功时回**请求长度**");
net_reset(); logs_clear();
(void)strcpy(g_net.rx, "STOR\r\n");
g_net.recv_seq[0] = 4; g_net.recv_n = 1;
CHECK(lftpd_inet_read(3, b, sizeof(b)) == 4, "TC-LFTINET-060 读到数据立刻交还");
net_reset(); logs_clear();
g_net.recv_seq[0] = -1; g_net.recv_n = 1;
CHECK(lftpd_inet_read(3, b, sizeof(b)) == -1, "TC-LFTINET-061 -1 直接失败返回");
net_reset(); logs_clear();
g_net.recv_seq[0] = -3; g_net.recv_n = 1;
CHECK((lftpd_inet_read(3, b, sizeof(b)) == -1) && (g_net.recv_calls > 1),
"TC-LFTINET-062 其它负值继续等,直到 30 s 预算耗尽");
net_reset(); logs_clear();
CHECK((lftpd_inet_close(3) == 0) && (g_net.close_calls == 1),
"TC-LFTINET-070 close 就是转 net_close");
}
int main(void)
{
printf("=== LFTPD INET Tests ===\n");
tc_listen();
tc_accept();
tc_read_line();
tc_write_read();
printf("\n=== LFTPD INET Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""FTP socket 适配层(lftpd_inet)主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/lftpd_inet/lftpd_inet_test.c(以 #include 纳入生产源
Drivers/BSP/NET/lftpd/lftpd_inet.c),离线验证监听/接受/读写适配:端口过 htons、
accept 每轮喂狗让出、网络代次一变就撤 accept、监听门撤防立即放弃、read_line 的
CRLF 截断与分片拼接与 200ms 分片 + 30s 总预算、错误码原样返回、短写续传。
为什么要主机侧单测(而不是只用台架):
代次撤 accept 与撤防放弃只在异常时刻成立(网络重建、客户端连上控制口却不开数据
通道),台架极难复现而真机上是常见自愈路径;200ms 分片写错会饿死看门狗、30s 预算
写错会让 ftpTask 永久卡在一条命令上;短写没续传就是"FTP 响应少半条"。这些都要
按毫秒与字节数着测。
桩的边界:private/lftpd_inet.h 与 net_types.h 用真实头(struct net_sockaddr_in 的
sin_port 注释就写着"网络字节序",htons/ntohs 契约靠它钉住);net_socket.h 是桩,
函数原型与真实头逐字一致;FreeRTOS.h / task.h 给宿主 tick 与 vTaskDelay(带跑飞
保险)。lftpd_task_wd_beat 是 __weak 空实现、主机侧替换不掉,故用同段代码里的
"睡 100ms"次数当喂狗轮数的代理。
用法(仓库根目录):
python test/host/lftpd_inet/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误。
设计依据:docs/子系统设计/网络配置说明.md §3.4、docs/测试说明/FTP_Test_Guide.md、
docs/测试说明/主机单测覆盖率口径.md §9
创建日期:2026-10-23
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
BUILD = TestBuild(
name="LFTPD INET Tests",
src=os.path.join(HERE, "lftpd_inet_test.c"),
include_dirs=[
os.path.join(HERE, "stub"),
os.path.join(ROOT, "Drivers", "BSP", "NET", "lftpd"),
os.path.join(ROOT, "Drivers", "BSP", "NET"),
HERE,
],
extra_flags=["/D_CRT_SECURE_NO_WARNINGS"],
summary_marker="=== LFTPD INET Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+21
View File
@@ -0,0 +1,21 @@
/*
* 模块名称:主机侧单测桩 - FreeRTOS.h(lftpd_inet 模块专用)
* 模块功能:宿主等价类型与 tick 换算。本仓 tick = 1ms,故 pdMS_TO_TICKS 就是原值——
* 这不是偷懒:写成本机换算,测试里的 30000ms 预算与 200ms 分片才可直接读。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef FREERTOS_H
#define FREERTOS_H
#include <stddef.h>
#include <stdint.h>
typedef uint32_t TickType_t;
typedef long BaseType_t;
typedef unsigned long UBaseType_t;
typedef void *TaskHandle_t;
#define pdMS_TO_TICKS(ms) ((TickType_t)(ms))
#endif /* FREERTOS_H */
+19
View File
@@ -0,0 +1,19 @@
/*
* 模块名称:主机侧单测桩 - dbg_log.h(lftpd_inet 模块专用)
* 模块功能:DBG_* 换成计数,让"建 socket/bind/listen 失败各留一条 ERROR""对端不开
* 数据通道时只 INFO"这些通道可断言。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef DBG_LOG_H
#define DBG_LOG_H
extern int g_lfi_info_count;
extern int g_lfi_error_count;
extern int g_lfi_debug_count;
#define DBG_INFO(fmt, ...) do { (void)(fmt); g_lfi_info_count++; } while (0)
#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_lfi_debug_count++; } while (0)
#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_lfi_error_count++; } while (0)
#endif /* DBG_LOG_H */
+34
View File
@@ -0,0 +1,34 @@
/*
* 模块名称:主机侧单测桩 - net_socket.h(lftpd_inet 模块专用)
* 模块功能:给 FTP 的 inet 适配层一个可控的 socket 面。地址结构与 NET_AF_INET /
* NET_SOCK_STREAM 一律取**真实** net_types.h(sin_port 的注释就写着
* "网络字节序",本模块的 htons/ntohs 契约靠它才钉得住);下面这些函数
* 原型与真实 net_socket.h 逐字一致(不一致就会在被测代码的调用点报错)。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。真实 net_socket.h 带整条 netTask 消息队列
* 与芯片依赖,PC 上编不动。
* 创建日期:2026-10-23
*/
#ifndef NET_SOCKET_H
#define NET_SOCKET_H
/* 真实 net_socket.h 会把 FreeRTOS.h / task.h 一起带给调用方,
* lftpd_inet.c 正是靠那条链拿 TickType_t / vTaskDelay / pdMS_TO_TICKS,
* 所以桩必须照做同样的 include(少一行就是 C2065 未声明)。 */
#include "FreeRTOS.h"
#include "task.h"
#include "net_types.h"
int net_socket(int domain, int type);
int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen);
int net_listen(int sockfd);
int net_close(int sockfd);
int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen);
int net_send(int sockfd, const void *buf, int len, int flags);
int net_recv_to(int sockfd, void *buf, int len, uint32_t timeout_ms);
int net_getsockname(int sockfd, struct net_sockaddr *addr, int *addrlen);
uint16_t net_htons(uint16_t hostshort);
uint16_t net_ntohs(uint16_t netshort);
uint32_t net_get_generation(void);
int net_listener_armed(int sockfd);
#endif /* NET_SOCKET_H */
+20
View File
@@ -0,0 +1,20 @@
/*
* 模块名称:主机侧单测桩 - task.h(lftpd_inet 模块专用)
* 模块功能:只声明两个时间入口:xTaskGetTickCount(read_line / read 的 30 s 总预算
* 时基)与 vTaskDelay(accept 轮的让出点,兼作"这一轮真的等了"的证据)。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 为什么用 vTaskDelay 计数而不是直接数喂狗:lftpd_task_wd_beat 在本文件里是
* __weak 空实现(真机由 lftpd.c 覆盖成真实喂狗),ARMCC 的 weak 在 MSVC 下走的是
* 普通定义那条支路,主机侧**替换不掉**它。而每轮 accept 失败后"喂狗 + 睡 100ms"
* 是同一段代码里的相邻两句,所以睡的次数就是喂狗轮数的可靠代理。
* 创建日期:2026-10-23
*/
#ifndef TASK_H
#define TASK_H
#include "FreeRTOS.h"
TickType_t xTaskGetTickCount(void);
void vTaskDelay(TickType_t xTicksToDelay);
#endif /* TASK_H */
+431
View File
@@ -0,0 +1,431 @@
/*
* 模块名称:FTP 文件 IO 层(lftpd_io)+ FTP 字符串小工具主机侧单元测试
* 模块功能:在 PC 上离线验证 Drivers/BSP/NET/lftpd/lftpd_io.c 的
* **路径规范化(含越权上跳钳位)**、FatFs 路径映射、单文件槽守卫、
* 卷锁获取/释放时机、等锁期间喂狗,以及 lftpd_string.c 的 trim。
*
* 被测对象:真实源文件 ../../../../Drivers/BSP/NET/lftpd/lftpd_io.c 与
* ../../../../Drivers/BSP/NET/lftpd/lftpd_string.c(#include 原样纳入;
* 两个 .c 的静态符号不冲突,可共用一个 TU,省一套桩)。桩与假件:
* - 真实头:Lib/FatFs/ff.h(FRESULT/FIL/FA_* 一律原汁)、
* App/fs/vol_lock.h、lftpd.h、private/lftpd_io.h、private/lftpd_string.h;
* - 假 FatFs:本文件实现 f_open(记录 path/mode、返回可注入 FRESULT),
* 其余 f_* 未触及的分支本批不测(见下方"未覆盖");
* - 假堆:pvPortMalloc/vPortFree = 静态池 + **可注入分配失败**,
* 用来跑"堆不够 → 返回 NULL"这条真机上 MicroLIB/FreeRTOS 堆耗尽会走、
* 而模拟器(宿主 CRT)永远走不到的分支;
* - 假卷锁:vol_lock_wait 按脚本逐次返回,lftpd_task_wd_beat 计数。
*
* 为什么要主机侧单测(而不是只靠台架):
* 1. **越权钳位是安全属性**:FTP 客户端可以发任意带 ".." 的路径,规范化必须
* "在根上跳不动"("/a" + "../../x" = "/x",而不是逃出根目录)。真机上没有
* 第二个文件系统可逃,测试里却能逐段构造,一次钉死(TC-LFTPIO-010..013)。
* 2. 这个函数返回的是 **pvPortMalloc 出来的指针**——它正是历史上落在 C 库 malloc
* 上的那一个(RAM 方案 §4.2.1:lftpd_io.c 3 处 malloc 已改 pvPortMalloc)。
* 分配失败必须**返回 NULL 且不泄漏第一块**(否则 FTP 一次 LIST 就把堆啃穿)。
* 只有假堆能稳定构造这条路径。
* 3. **单文件槽守卫**(s_file_open)与**卷锁释放时机**:open 成功时故意**不**释放
* 卷锁(整个传输期间独占卷,由 close 释放),失败时必须释放。写反了就是
* "defaultTask 再也拿不到卷"或"传输中途别人能改卷",两者都只在真机偶发。
* 4. 等卷锁的循环里必须喂看门狗(lftpd_task_wd_beat)——defaultTask 与 FTP 争卷时
* 这里可能空转上百毫秒,漏喂就是整机复位(这条在真机上极难稳定复现)。
*
* 覆盖点:
* —— FatFs 路径映射(静态函数直测,TC-LFTPIO-001..004)——
* TC-LFTPIO-001 NULL / 空串 → "0:"(卷根)
* TC-LFTPIO-002 "/" → "0:"
* TC-LFTPIO-003 "/a/b.txt" → "a/b.txt"(去掉前导斜杠)
* TC-LFTPIO-004 非斜杠开头原样返回
* —— 路径规范化(TC-LFTPIO-010..022)——
* TC-LFTPIO-010 相对名拼到 base 下
* TC-LFTPIO-011 绝对名忽略 base
* TC-LFTPIO-012 ".." 回退一级
* TC-LFTPIO-013 **上跳过头也不逃出根**("/a" + "../../x" = "/x")
* TC-LFTPIO-014 "." 段被丢弃
* TC-LFTPIO-015 "base/.." 归一到 "/"
* TC-LFTPIO-016 连续斜杠与尾部斜杠被折叠
* TC-LFTPIO-017 base 为 NULL 视作空串
* TC-LFTPIO-018 name 为 NULL 视作空串
* TC-LFTPIO-019 两者皆 NULL → "/"
* TC-LFTPIO-020 根 + ".." → "/"
* TC-LFTPIO-021 第一块分配失败 → NULL(且不崩、不动第二块)
* TC-LFTPIO-022 第二块(结果缓冲)分配失败 → NULL **且第一块已释放**
* —— 打开文件:守卫、卷锁、错误通道(TC-LFTPIO-030..041)——
* TC-LFTPIO-030 open_read 成功返回 0,f_open 收到 FA_READ 与映射后的路径
* TC-LFTPIO-031 已有文件打开时 open_read 返回 -1 且**不再碰卷锁与 FatFs**
* TC-LFTPIO-032 open_read 失败返回 -1、留 ERROR、并**释放卷锁**
* TC-LFTPIO-033 open_read 成功后卷锁**不释放**(传输期独占,由 close 释放)
* TC-LFTPIO-034 卷未就绪时循环等待,每轮喂一次看门狗
* TC-LFTPIO-035 open_write 用 FA_WRITE|FA_CREATE_ALWAYS(覆盖写语义)
* TC-LFTPIO-036 open_write 同样受单文件槽守卫
* TC-LFTPIO-037 open_write 失败也释放卷锁
* —— trim(TC-LFTSTR-001..005)——
* TC-LFTSTR-001 行首空白由**返回指针**跳过,原缓冲不搬移(调用方用的就是返回值)
* TC-LFTSTR-002 无前导空白时返回同一指针、内容不变
* TC-LFTSTR-003 词中间空白保留
* TC-LFTSTR-004 全空白串 → 空串
* TC-LFTSTR-005 **现状桩**:尾部空白/CRLF 实际不裁(见下"本批照出的问题")
*
* **处置(2026-10-23 用户裁定 = 改名)**:lftpd_string_trim 的尾部循环写成
* for (i = strlen(p); i >= 0 && isspace(p[i]); i--) —— i 起步那格是 '\0',
* isspace(0) 为假,循环一次都不执行,**尾部空白从未被裁掉**。今天不发作是因为
* FTP 命令行的 CRLF 在 lftpd_inet_read_line 里已用 strstr("\r\n") 截断,而唯一
* 调用点 lftpd.c:780 只关心行首。真正的风险是"以后有人拿它去尾部空白"——不会生效。
* 修法是一行(i 从 strlen(p)-1 起),但那会改变 FTP 参数(含空格的文件名/口令)
* 的可见行为,需与 FTP 域一起过;本批只用 TC-LFTSTR-005 把现状钉成"改了会响"。
*
* 未覆盖(诚实登记,下一批补):lftpd_io_read / write / close / stat / unlink /
* opendir / readdir / closedir —— 它们要先把假 FIL/FILINFO 的字段语义(fptr、
* obj.objsize、altname 等)做实,否则断言会落在假件自己写的规则上,失去意义。
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-23
*/
#include <stdio.h>
#include <string.h>
#include "ff.h"
#include "private/lftpd_io.h"
#include "private/lftpd_string.h"
#include "vol_lock.h"
#include "lftpd.h"
/* 被测生产源(原样纳入;静态符号不冲突) */
#include "lftpd_io.c"
#include "lftpd_string.c"
/* ===================== 断言框架 ===================== */
static int s_total;
static int s_passed;
static int s_failed;
#define CHECK(cond, name) do { \
s_total++; \
if (cond) { s_passed++; printf(" PASS %s\n", name); } \
else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \
} while (0)
/* ===================== dbg_log 桩计数 ===================== */
int g_lfio_info_count;
int g_lfio_error_count;
int g_lfio_debug_count;
static void logs_clear(void)
{
g_lfio_info_count = 0;
g_lfio_error_count = 0;
g_lfio_debug_count = 0;
}
/* ===================== 假堆(可注入分配失败) ===================== */
static uint8_t g_pool[512];
static size_t g_pool_used;
static int g_alloc_calls;
static int g_free_calls;
static int g_fail_at; /* >0:第 N 次 pvPortMalloc 开始失败;0 = 不失败 */
static void heap_reset(void)
{
g_pool_used = 0u;
g_alloc_calls = 0;
g_free_calls = 0;
g_fail_at = 0;
}
void *pvPortMalloc(size_t xWantedSize)
{
void *p;
g_alloc_calls++;
if ((g_fail_at > 0) && (g_alloc_calls >= g_fail_at)) {
return NULL;
}
if ((g_pool_used + xWantedSize + 8u) > sizeof(g_pool)) {
return NULL;
}
/* 8 字节对齐,模拟真实堆 */
g_pool_used = (g_pool_used + 7u) & ~(size_t)7u;
p = &g_pool[g_pool_used];
g_pool_used += xWantedSize;
return p;
}
void vPortFree(void *pv)
{
if (pv != NULL) {
g_free_calls++;
}
}
/* ===================== 假卷锁与喂狗 ===================== */
static int g_wait_seq[8]; /* 每次 vol_lock_wait 的返回值脚本(0=未就绪) */
static int g_wait_calls;
static int g_release_calls;
static int g_beat_calls;
int vol_lock_wait(uint32_t timeout_ms)
{
int r;
(void)timeout_ms;
r = (g_wait_calls < (int)(sizeof(g_wait_seq) / sizeof(g_wait_seq[0])))
? g_wait_seq[g_wait_calls] : 1;
g_wait_calls++;
return r;
}
void vol_lock_release(void)
{
g_release_calls++;
}
void lftpd_task_wd_beat(void)
{
g_beat_calls++;
}
int vol_lock_init(void) { return 0; }
/* ===================== 假 FatFs(只到本批用到的 f_open) ===================== */
static FRESULT g_open_rc;
static char g_open_path[64];
static BYTE g_open_mode;
static int g_open_calls;
FRESULT f_open(FIL *fp, const TCHAR *path, BYTE mode)
{
int i;
(void)fp;
g_open_calls++;
g_open_mode = mode;
for (i = 0; (path[i] != _T('\0')) && (i + 1 < (int)sizeof(g_open_path)); i++) {
g_open_path[i] = (char)path[i];
}
g_open_path[i] = '\0';
return g_open_rc;
}
/* 本批不测的 FatFs 入口:只为**可链接**而给出,一律返回 FR_INT_ERR——
* 真有测试走进来就会拿到 -1/-2 之类的意外返回值而断言失败,不会静默假绿。
* (下一批要把假 FIL/FILINFO 的字段语义做实再逐个展开) */
FRESULT f_close(FIL *fp) { (void)fp; return FR_INT_ERR; }
FRESULT f_read(FIL *fp, void *p_buff, UINT btr, UINT *br)
{
(void)fp; (void)p_buff; (void)btr;
if (br != NULL) { *br = 0u; }
return FR_INT_ERR;
}
FRESULT f_write(FIL *fp, const void *p_buff, UINT btw, UINT *bw)
{
(void)fp; (void)p_buff; (void)btw;
if (bw != NULL) { *bw = 0u; }
return FR_INT_ERR;
}
FRESULT f_stat(FILINFO *fno, const TCHAR *path) { (void)fno; (void)path; return FR_INT_ERR; }
FRESULT f_unlink(const TCHAR *path) { (void)path; return FR_INT_ERR; }
FRESULT f_opendir(DIR *dp, const TCHAR *path) { (void)dp; (void)path; return FR_INT_ERR; }
FRESULT f_readdir(DIR *dp, FILINFO *fno) { (void)dp; (void)fno; return FR_INT_ERR; }
FRESULT f_closedir(DIR *dp) { (void)dp; return FR_INT_ERR; }
/* ===================== 复位与辅助 ===================== */
static void reset_all(void)
{
heap_reset();
g_wait_calls = 0;
g_release_calls = 0;
g_beat_calls = 0;
g_open_calls = 0;
g_open_rc = FR_OK;
g_open_path[0] = '\0';
g_open_mode = 0;
g_wait_seq[0] = 1;
g_wait_seq[1] = 1;
g_wait_seq[2] = 1;
g_wait_seq[3] = 1;
s_file_open = 0;
logs_clear();
}
static char *canon(const char *base, const char *name)
{
char *p = lftpd_io_canonicalize_path(base, name);
static char keep[128];
if (p == NULL) {
return NULL;
}
(void)strncpy(keep, p, sizeof(keep) - 1u);
keep[sizeof(keep) - 1u] = '\0';
vPortFree(p);
return keep;
}
/* ===================== FatFs 路径映射 ===================== */
static void tc_fatfs_path(void)
{
printf("-- TC-LFTPIO-001..004 to_fatfs_path --\n");
CHECK((strcmp(to_fatfs_path(NULL), "0:") == 0) &&
(strcmp(to_fatfs_path(""), "0:") == 0), "TC-LFTPIO-001 NULL/空串 -> 0:");
CHECK(strcmp(to_fatfs_path("/"), "0:") == 0, "TC-LFTPIO-002 根 -> 0:");
CHECK(strcmp(to_fatfs_path("/a/b.txt"), "a/b.txt") == 0,
"TC-LFTPIO-003 去掉前导斜杠");
CHECK(strcmp(to_fatfs_path("a.txt"), "a.txt") == 0, "TC-LFTPIO-004 原样返回");
}
/* ===================== 路径规范化 ===================== */
static void tc_canon(void)
{
printf("-- TC-LFTPIO-010..022 canonicalize --\n");
reset_all();
CHECK(strcmp(canon("/dir", "f.txt"), "/dir/f.txt") == 0, "TC-LFTPIO-010 相对拼接");
CHECK(strcmp(canon("/dir", "/abs.txt"), "/abs.txt") == 0, "TC-LFTPIO-011 绝对忽略 base");
CHECK(strcmp(canon("/a/b", "../c"), "/a/c") == 0, "TC-LFTPIO-012 上跳一级");
CHECK(strcmp(canon("/a", "../../x"), "/x") == 0,
"TC-LFTPIO-013 上跳过头仍被钳在根内(安全属性)");
CHECK(strcmp(canon("/a", "."), "/a") == 0, "TC-LFTPIO-014 丢弃 . 段");
CHECK(strcmp(canon("/a", ".."), "/") == 0, "TC-LFTPIO-015 回退到根");
CHECK(strcmp(canon("/a//", "b"), "/a/b") == 0, "TC-LFTPIO-016 段间多余斜杠被折叠");
CHECK(strcmp(canon("/dir", "//x"), "/x") == 0,
"TC-LFTPIO-016b name 以斜杠开头按**绝对路径**处理(忽略 base,仍钳在根内)");
CHECK(strcmp(canon(NULL, "x"), "/x") == 0, "TC-LFTPIO-017 base NULL 视作空串");
CHECK(strcmp(canon("/a", NULL), "/a") == 0, "TC-LFTPIO-018 name NULL 视作空串");
CHECK(strcmp(canon(NULL, NULL), "/") == 0, "TC-LFTPIO-019 两个 NULL -> /");
CHECK(strcmp(canon("/", ".."), "/") == 0, "TC-LFTPIO-020 根上加 .. 仍是根");
reset_all();
g_fail_at = 1;
CHECK(lftpd_io_canonicalize_path("/a", "b") == NULL,
"TC-LFTPIO-021 首块分配失败返回 NULL");
CHECK(g_alloc_calls == 1, "TC-LFTPIO-021b 失败后不再分配第二块");
reset_all();
g_fail_at = 2;
CHECK(lftpd_io_canonicalize_path("/a", "b") == NULL,
"TC-LFTPIO-022 结果缓冲分配失败返回 NULL");
CHECK(g_free_calls == 1, "TC-LFTPIO-022b 第一块已释放(不泄漏堆)");
}
/* ===================== 打开文件:守卫 / 卷锁 / 错误通道 ===================== */
static void tc_open(void)
{
int rc;
printf("-- TC-LFTPIO-030..037 open_read / open_write --\n");
reset_all();
rc = lftpd_io_open_read("/a/b.txt");
CHECK((rc == 0) && (g_open_mode == FA_READ) &&
(strcmp(g_open_path, "a/b.txt") == 0),
"TC-LFTPIO-030 成功:FA_READ + 映射后的路径");
reset_all();
s_file_open = 1;
rc = lftpd_io_open_read("/x");
CHECK((rc == -1) && (g_open_calls == 0) && (g_wait_calls == 0),
"TC-LFTPIO-031 单文件槽守卫:已打开时不碰卷锁与 FatFs");
reset_all();
g_open_rc = FR_NO_FILE;
rc = lftpd_io_open_read("/x");
CHECK((rc == -1) && (g_release_calls == 1) && (g_lfio_error_count == 1),
"TC-LFTPIO-032 打开失败:返回 -1 + 留痕 + 释放卷锁");
reset_all();
rc = lftpd_io_open_read("/x");
CHECK((rc == 0) && (g_release_calls == 0) && (s_file_open == 1),
"TC-LFTPIO-033 成功后卷锁不释放(传输期独占)");
reset_all();
g_wait_seq[0] = 0; /* 第一轮:卷被 defaultTask 占着 */
g_wait_seq[1] = 0; /* 第二轮:仍未就绪 */
g_wait_seq[2] = 1; /* 第三轮:拿到 */
rc = lftpd_io_open_read("/x");
CHECK((rc == 0) && (g_beat_calls == 2) && (g_wait_calls == 3),
"TC-LFTPIO-034 等锁循环每轮喂看门狗(漏喂 = 整机复位)");
reset_all();
rc = lftpd_io_open_write("/up.bin");
CHECK((rc == 0) && (g_open_mode == (BYTE)(FA_WRITE | FA_CREATE_ALWAYS)),
"TC-LFTPIO-035 open_write 用覆盖写模式");
reset_all();
s_file_open = 1;
rc = lftpd_io_open_write("/up.bin");
CHECK((rc == -1) && (g_open_calls == 0), "TC-LFTPIO-036 open_write 同样有槽守卫");
reset_all();
g_open_rc = FR_DISK_ERR;
rc = lftpd_io_open_write("/up.bin");
CHECK((rc == -1) && (g_release_calls == 1), "TC-LFTPIO-037 open_write 失败也释放卷锁");
}
/* ===================== trim ===================== */
static void tc_trim(void)
{
char buf[32];
char *r;
printf("-- TC-LFTSTR-001..005 lftpd_string_ltrim --\n");
/* 实现是"返回一个跳过行首空白的指针",**不搬移缓冲**;调用方 lftpd.c:780
* 用的正是返回值。所以断言必须落在返回值上,落在 buf 上是错的。 */
(void)strcpy(buf, " PASS x");
r = lftpd_string_ltrim(buf);
CHECK((r != buf) && (strcmp(r, "PASS x") == 0) && (buf[0] == ' '),
"TC-LFTSTR-001 行首空白靠返回指针跳过,原缓冲不搬移");
(void)strcpy(buf, "NOOP");
r = lftpd_string_ltrim(buf);
CHECK((r == buf) && (strcmp(buf, "NOOP") == 0), "TC-LFTSTR-002 无前导空白返回同一指针");
(void)strcpy(buf, " USER admin ");
r = lftpd_string_ltrim(buf);
CHECK(strcmp(r, "USER admin ") == 0, "TC-LFTSTR-003 词中间空白保留");
(void)strcpy(buf, " \t\r\n");
r = lftpd_string_ltrim(buf);
CHECK(r[0] == '\0', "TC-LFTSTR-004 全空白 -> 空串");
/* 现状桩(不是期望行为):尾部循环从 strlen(p) 起步,那里是 '\0',
* isspace(0)=0 立刻退出 —— **尾部空白/CRLF 实际从未被裁**。今天无害是因为
* CRLF 在 lftpd_inet_read_line(lftpd_inet.c 的 strstr("\r\n"))里已截掉。
* 谁哪天把 trim 修对了,这条会红——那是提醒,不是回归。 */
/* 2026-10-23 处置:函数已由 lftpd_string_trim 改名为 lftpd_string_ltrim,
* 并删掉那条永不执行的尾部循环。于是"不去尾部"从潜伏陷阱变成**写进名字与注释
* 的契约**:RFC 959 里参数的尾随空格属于参数本体(PASS 口令、含空格的文件名),
* 去掉等于静默改数据;CRLF 由 lftpd_inet_read_line 截断。 */
(void)strcpy(buf, "LIST ");
r = lftpd_string_ltrim(buf);
CHECK(strcmp(r, "LIST ") == 0,
"TC-LFTSTR-005 尾随空格必须保留(ltrim 语义,RFC 959 参数本体)");
}
/* ===================== 入口 ===================== */
int main(void)
{
printf("=== LFTPD IO Tests ===\n");
tc_fatfs_path();
tc_canon();
tc_open();
tc_trim();
printf("\n=== LFTPD IO Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""FTP 文件 IO 层(lftpd_io + lftpd_string)的主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/lftpd_io/lftpd_io_test.c(同时纳入生产源
Drivers/BSP/NET/lftpd/lftpd_io.c 与 lftpd_string.c),在 PC 上离线验证路径规范化
(含 ".." 越权钳位)、FatFs 路径映射、单文件槽守卫、卷锁获取/释放时机、等锁期间
喂狗、分配失败返回 NULL 且不泄漏,以及 FTP 命令行 trim。失败即非 0 退出。
工具链与判定委托 test/host/common/unit_test_harness.py。
为什么要主机侧单测(而不是只用台架):
越权钳位是安全属性(FTP 客户端可发任意带 ".." 的路径),真机上没有第二个文件系统
可逃、测不出来;卷锁"成功不释放、失败必释放"写反了就是 defaultTask 永远拿不到卷
或传输中途卷被人改;等锁循环漏喂狗就是整机复位——三条都只在真机偶发、日志看不出
根因。分配失败分支更只有假堆能构造(模拟器宿主 CRT 永不失败)。
桩的边界:ff.h / vol_lock.h / lftpd.h / private/*.h 全用**真实头**(FRESULT、FA_*、
函数签名一律原汁);FreeRTOS.h 与 dbg_log.h 用本目录桩;f_open、卷锁、喂狗、
pvPortMalloc/vPortFree 由测试单元给可控实现(假堆可注入分配失败)。
read/write/close/stat/unlink/readdir 未列入本批:要先把假 FIL/FILINFO 的字段语义
做实,否则断言落在假件自己写的规则上(详见测试单元文件头"未覆盖")。
用法(仓库根目录):
python test/host/lftpd_io/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误。
设计依据:docs/内存管理/RAM使用分析与优化方案.md §4.2.1、docs/测试说明/主机单测覆盖率口径.md §9 P1
创建日期:2026-10-23
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
# 顺序敏感:stub 必须最先(覆盖 FreeRTOS.h / dbg_log.h)。
# lftpd_io.c 里 include 的是 "private/lftpd_io.h" 与 "lftpd.h",故给出 lftpd 目录;
# vol_lock.h 在 App/fs,ff.h 在 Lib/FatFs(与 test/host/nand_port 同一份真实头)。
BUILD = TestBuild(
name="LFTPD IO Tests",
src=os.path.join(HERE, "lftpd_io_test.c"),
include_dirs=[
os.path.join(HERE, "stub"),
os.path.join(ROOT, "Drivers", "BSP", "NET", "lftpd"),
os.path.join(ROOT, "App", "fs"),
os.path.join(ROOT, "Lib", "FatFs"),
HERE,
],
# /D_CRT_SECURE_NO_WARNINGS:生产 lftpd_io.c 里的 strcpy/strcat/strncpy 在
# MSVC 下刷 C4996 弃用告警(ARMCC 不报,且 lftpd 不在 coding-standard 范围内),
# 这是纯主机侧噪音;关掉它才能让本模块的编译输出保持可读。
extra_flags=["/D_CRT_SECURE_NO_WARNINGS"],
summary_marker="=== LFTPD IO Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+23
View File
@@ -0,0 +1,23 @@
/*
* 模块名称:主机侧单测桩 - FreeRTOS.h(lftpd_io 模块专用)
* 模块功能:lftpd_io.c 只经这个头拿到 pvPortMalloc / vPortFree 的原型(它自己不
* 调内核 API,等卷锁是 vol_lock 的事)。真实 FreeRTOS.h 会拉整棵
* portmacro / 编译器抽象层,PC 上编不动,故这里只给这两个原型。
*
* ⚠ 仅供 test/host/lftpd_io/ 使用,不参与 MCU 构建。
* 签名必须与 FreeRTOS 的 portmacro 一致(size_t 入参 / void 返回的 free);
* 真实实现由测试单元给出**可注入失败的假堆**,用来跑"分配失败返回 NULL"的分支
* ——那正是真机上 MicroLIB 512B C 堆与 FreeRTOS 堆耗尽时的形态。
*
* 创建日期:2026-10-23
*/
#ifndef FREERTOS_H
#define FREERTOS_H
#include <stddef.h>
void *pvPortMalloc(size_t xWantedSize);
void vPortFree(void *pv);
#endif /* FREERTOS_H */
+19
View File
@@ -0,0 +1,19 @@
/*
* 模块名称:主机侧单测桩 - dbg_log.h(lftpd_io 模块专用)
* 模块功能:DBG_* 换成计数,让"打开失败必须留痕、等锁循环不打日志"可断言。
* ⚠ 仅供 test/host/lftpd_io/ 使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef DBG_LOG_H
#define DBG_LOG_H
extern int g_lfio_info_count;
extern int g_lfio_error_count;
extern int g_lfio_debug_count;
#define DBG_INFO(fmt, ...) do { (void)(fmt); g_lfio_info_count++; } while (0)
#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_lfio_debug_count++; } while (0)
#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_lfio_error_count++; } while (0)
#endif /* DBG_LOG_H */
+549
View File
@@ -0,0 +1,549 @@
/*
* 模块名称:网络配置(NETCFG)主机侧单元测试
* 模块功能:在 PC 上离线验证 Drivers/BSP/NETCFG/net_cfg.c 的**编排与状态机**:
* 出厂默认有效性、注册幂等与注册内容、load 失败仍返回 0 的"RAM 恒有效"
* 契约、跨任务保存请求交接(IDLE/WAIT/OK/FAIL)、写盘期间新请求不丢、
* 写盘失败不得触发网络重建、MAC 三态优先级、IPv4 定长格式化。
*
* 被测对象:真实源文件 ../../../Drivers/BSP/NETCFG/net_cfg.c(#include 原样纳入,
* 保证测的是生产代码而非副本)。桩只有三处,都写在下面:
* - stub/dbg_log.h:日志宏 → 计数(本目录);
* - 配置文件引擎:用**真实头** App/cfg/cfg_file.h(接口契约原汁,引擎
* 一改签名本测试立刻编译期报错),被调的三个函数
* cfg_file_register / cfg_file_load / cfg_file_save 由本文件给出
* **可控实现**(同 test/host/nand_port/ 的路子);
* - ftp_cfg_register:只计数不实现——[ftp] 段自身的解析/默认值/默认
* 口令判定归 test/host/ftp_cfg/ 模块,两边不重复也不互相顶包。
* (不能把 ftp_cfg.c 也 include 进来:两个 .c 都有 static s_cfg,
* 同一个 TU 里直接重定义。)
* ⚠ 引擎的解析/写盘/沉降契约已由 test/host/cfg_file/(103 条)覆盖,
* 本模块刻意不重测引擎,只测 net_cfg.c 这一层的编排与交接。
*
* 为什么要主机侧单测(而不是只靠台架):
* 1. 保存链是**跨任务交接**(uiTask 投递 net_cfg_save_request → defaultTask 执行
* net_cfg_save_run → 成功才 net_cfg_request_apply → netTask 按网络代次重建监听)。
* "写盘期间 UI 又按了一次 ENTER"与"写盘失败却去重建网络"这两条,真机上要靠
* 手速 + 故障注入才碰得到;错了就是"改了 IP 不生效"或"保存失败网络被无端重建"
* 这类现场极难定位的问题——主机侧用假引擎的写盘钩子直接构造时序。
* 2. MAC 三态优先级(存储非零 > 芯片回写影子 > 全零)决定 UI 显示值和下发给
* CH395F 的 MAC;写错会**静默用错 MAC**(同网段 MAC 冲突,症状是偶发丢包)。
* 3. net_cfg_fmt_ipv4 的**定长 15 字符(每段 %03u 补零)**是 UI 编辑光标按固定偏移定位
* 字节的前提;哪天被"优化"成不补零,光标就错位——纯字符串行为,一次钉死。
*
* 覆盖点:
* —— 出厂默认(load 之前也必须可用,TC-NETCFG-001..004)——
* TC-NETCFG-001 未 load 时 ip = 工厂默认 192.168.1.100
* TC-NETCFG-002 未 load 时 mask = 255.255.255.0
* TC-NETCFG-003 未 load 时 gw = 192.168.1.1
* TC-NETCFG-004 未 load 时 mac = 全零(哨兵 = 不覆盖芯片内置 MAC)
* —— 注册与 load(TC-NETCFG-005..011)——
* TC-NETCFG-005 首次 load 注册 [network] 段恰好一次(且 load 返回 0)
* TC-NETCFG-006 首次 load 一并注册 [ftp] 段(N-19:所有受管段先注册、后 load)
* TC-NETCFG-007 重复 load 不重复注册(幂等;否则引擎按"段名重复"拒登 → 静默失配)
* TC-NETCFG-008 注册描述符内容:段名/尺寸/键数/默认值指针/变更回调/数据指针 = RAM 副本
* TC-NETCFG-009 load 成功:返回 0 且留一条 INFO、不误报 ERROR
* TC-NETCFG-010 **引擎报 -1 时 net_cfg_load 仍返回 0**(RAM 恒有效,不带崩网络启动)
* TC-NETCFG-011 load 失败必须留 ERROR(不许静默回落默认值)
* —— RAM 副本读写(TC-NETCFG-012..013)——
* TC-NETCFG-012 net_cfg_set 写入被 net_cfg_get 读回
* TC-NETCFG-013 net_cfg_set(NULL) 不改写(UI 侧误传不得毁掉配置)
* —— 直接保存(TC-NETCFG-014..015)——
* TC-NETCFG-014 引擎写盘成功 → net_cfg_save 返回 0
* TC-NETCFG-015 引擎写盘失败 → net_cfg_save 返回 -1 并留痕(错误码没被吞成 0)
* —— 跨任务保存交接状态机(TC-NETCFG-020..033,UI 保存行显示的就是它)——
* TC-NETCFG-020 初始态 = IDLE
* TC-NETCFG-021 save_request 后置 WAIT
* TC-NETCFG-022 save_request 经已注册回调唤醒属主任务(计数 +1)
* TC-NETCFG-023 未注册回调时 save_request 不崩、请求照样挂起
* TC-NETCFG-024a 有请求时 save_run 确实写盘一次(024 的前置)
* TC-NETCFG-024 无请求时空醒 → 返回 0 且**不写盘**(2s 周期轮询不刷 Flash)
* TC-NETCFG-025 save_run 成功后 pending 清零
* TC-NETCFG-026 save_run 成功后状态 = OK
* TC-NETCFG-027 save_run 成功后触发一次"立即应用"
* TC-NETCFG-028 **写盘进行中**状态仍是 WAIT(写盘前先清 pending,靠 busy 撑住;
* 否则这段窗口会退回上一轮的 OK/IDLE,用户看不到反馈只会再按一次)
* TC-NETCFG-029a **写盘期间投递的新请求不丢**:本轮结束后仍为 WAIT
* TC-NETCFG-029b 下一轮真的再写一次(不是只把状态挂在那里)
* TC-NETCFG-030 写盘失败 → 状态 = FAIL
* TC-NETCFG-031 **写盘失败不得触发"立即应用"**(内存值还没落盘,不能当已生效)
* TC-NETCFG-032 未注册应用回调时 request_apply 是空操作(模拟器就是这条路)
* TC-NETCFG-033a 应用回调注册后可被触发
* TC-NETCFG-033b 传 NULL 注销后不再被触发
* —— MAC 三态优先级(TC-NETCFG-040..045)——
* TC-NETCFG-040 存储 MAC 非零 → 用存储值(即使芯片已回写影子)
* TC-NETCFG-041 存储全零 + 芯片已回写 → 用影子值
* TC-NETCFG-042 存储全零 + 未回写 → 全零
* TC-NETCFG-043 set_active_mac(NULL) 不得把影子置为有效
* TC-NETCFG-044 set_active_mac 正常回写后影子生效
* TC-NETCFG-045 get_mac(NULL) 安全返回
* —— IPv4 定长格式化(TC-NETCFG-050..056,UI 光标定位依赖)——
* TC-NETCFG-050 常规地址渲染为定长 15 字符 "192.168.001.100"
* TC-NETCFG-051 全零地址补零为 "000.000.000.000"(仍是 15 字符,偏移不变)
* TC-NETCFG-052 255 边界 "255.255.255.255"
* TC-NETCFG-053 p_ip 为 NULL:不写缓冲
* TC-NETCFG-054 p_buf 为 NULL:不崩
* TC-NETCFG-055 len 为 0:不写缓冲
* TC-NETCFG-056 len 不足:截断但**必须 NUL 结尾**且不越界(UI 缓冲防线)
*
* 用例顺序有依赖(模块静态状态无法整体复位):出厂默认组必须最先跑(任何 load 之前);
* IDLE 组在任何 save_request 之前;需要干净初态处显式复位白盒量并注明原因。
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-23
*/
#include <stdio.h>
#include <string.h>
/* 真实引擎头(接口原汁)+ 被测生产源(原样纳入);dbg_log.h 命中本目录 stub */
#include "cfg_file.h"
#include "net_cfg.h"
#include "net_cfg.c"
/* ===================== 断言框架 ===================== */
static int s_total;
static int s_passed;
static int s_failed;
#define CHECK(cond, name) do { \
s_total++; \
if (cond) { s_passed++; printf(" PASS %s\n", name); } \
else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \
} while (0)
/* ===================== dbg_log 桩的计数定义 ===================== */
int g_netcfg_info_count;
int g_netcfg_error_count;
int g_netcfg_debug_count;
static void logs_clear(void)
{
g_netcfg_info_count = 0;
g_netcfg_error_count = 0;
g_netcfg_debug_count = 0;
}
/* ===================== 假引擎(可控实现;原型来自真实 cfg_file.h) ========== */
/* 注册描述符在 net_cfg.c 里是栈上临时量,必须**按值留档**后才可比对 */
typedef struct {
char name[CFG_FILE_MAX_NAME_LEN + 1u];
const void *p_data;
const void *p_defaults;
size_t data_size;
uint8_t key_count;
void (*p_cb)(void);
} fake_reg_t;
static fake_reg_t g_regs[CFG_FILE_MAX_SECTIONS];
static int g_reg_n;
static int g_ftp_reg_calls; /* ftp_cfg_register 被 net_cfg_load 调了几次 */
static int g_load_calls;
static int g_save_calls;
static int g_apply_calls;
static int g_req_cb_calls;
static int g_load_rc; /* 注入:cfg_file_load 返回值 */
static int g_save_rc; /* 注入:cfg_file_save 返回值 */
static int g_state_during_save = -1;/* 写盘钩子里读到的交接状态 */
static int g_requeue_during_save; /* 1 = 写盘中再投递一次请求 */
static void (*g_save_hook)(void); /* 写盘时刻回调(构造真机难复现的时序) */
static void fake_copy_name(char *dst, const char *src, size_t cap)
{
size_t i;
for (i = 0u; (i + 1u < cap) && (src[i] != '\0'); i++) {
dst[i] = src[i];
}
dst[i] = '\0';
}
int cfg_file_register(const cfg_section_reg_t *p_reg)
{
fake_reg_t *rec;
if ((p_reg == NULL) || (p_reg->name == NULL)) {
return -1;
}
if ((size_t)g_reg_n >= (size_t)CFG_FILE_MAX_SECTIONS) {
return -1;
}
rec = &g_regs[g_reg_n];
fake_copy_name(rec->name, p_reg->name, sizeof(rec->name));
rec->p_data = p_reg->p_data;
rec->p_defaults = p_reg->p_defaults;
rec->data_size = p_reg->data_size;
rec->key_count = p_reg->key_count;
rec->p_cb = p_reg->p_cb;
g_reg_n++;
return 0;
}
int cfg_file_load(void)
{
g_load_calls++;
return g_load_rc;
}
int cfg_file_save(void)
{
g_save_calls++;
if (g_save_hook != NULL) {
g_save_hook();
}
return g_save_rc;
}
/* [ftp] 段归 test/host/ftp_cfg/ 模块测;这里只验证"net_cfg_load 确实带上了它" */
void ftp_cfg_register(void)
{
g_ftp_reg_calls++;
}
static void fake_reset(void)
{
g_reg_n = 0;
g_ftp_reg_calls = 0;
g_load_calls = 0;
g_save_calls = 0;
g_apply_calls = 0;
g_req_cb_calls = 0;
g_load_rc = 0;
g_save_rc = 0;
g_state_during_save = -1;
g_requeue_during_save = 0;
g_save_hook = NULL;
logs_clear();
}
static int mac_eq(const uint8_t *p_a, const uint8_t *p_b)
{
return memcmp(p_a, p_b, 6u) == 0;
}
static int bytes_eq(const uint8_t *p_a, const uint8_t *p_b, size_t n)
{
return memcmp(p_a, p_b, n) == 0;
}
/* ===================== 回调 ===================== */
static void apply_cb(void)
{
g_apply_calls++;
}
static void req_cb(void)
{
g_req_cb_calls++;
}
/* 写盘时刻钩子:记录当时的交接状态,并按开关模拟"写盘期间 UI 又按了一次 ENTER" */
static void save_hook_probe(void)
{
g_state_during_save = net_cfg_save_state();
if (g_requeue_during_save != 0) {
(void)net_cfg_save_request();
}
}
/* ===================== 出厂默认(必须在任何 load 之前跑) ===================== */
static void tc_defaults(void)
{
const net_cfg_t *p = net_cfg_get();
static const uint8_t def_ip[4] = { 192u, 168u, 1u, 100u };
static const uint8_t def_mask[4] = { 255u, 255u, 255u, 0u };
static const uint8_t def_gw[4] = { 192u, 168u, 1u, 1u };
static const uint8_t zero[6] = { 0u, 0u, 0u, 0u, 0u, 0u };
printf("-- TC-NETCFG-001..004 defaults before load --\n");
CHECK(bytes_eq(p->ip, def_ip, 4u), "TC-NETCFG-001 ip = 192.168.1.100");
CHECK(bytes_eq(p->mask, def_mask, 4u), "TC-NETCFG-002 mask = 255.255.255.0");
CHECK(bytes_eq(p->gw, def_gw, 4u), "TC-NETCFG-003 gw = 192.168.1.1");
CHECK(mac_eq(p->mac, zero), "TC-NETCFG-004 mac = 全零(芯片内置哨兵)");
}
/* ===================== 注册与 load 契约 ===================== */
static void tc_register_load(void)
{
int rc;
printf("-- TC-NETCFG-005..011 register + load --\n");
fake_reset();
rc = net_cfg_load();
CHECK((rc == 0) && (g_reg_n == 1) && (strcmp(g_regs[0].name, "network") == 0),
"TC-NETCFG-005 首次 load 注册 [network] 恰好一次");
CHECK((g_ftp_reg_calls == 1) && (g_load_calls == 1),
"TC-NETCFG-006 一并注册 [ftp],且注册在 load 之前");
rc = net_cfg_load();
CHECK((rc == 0) && (g_reg_n == 1) && (g_ftp_reg_calls == 2),
"TC-NETCFG-007 重复 load 不重复注册 [network]");
CHECK((strcmp(g_regs[0].name, "network") == 0) &&
(g_regs[0].data_size == sizeof(net_cfg_t)) &&
(g_regs[0].key_count == 4u) &&
(g_regs[0].p_defaults != NULL) &&
(g_regs[0].p_cb != NULL) &&
(g_regs[0].p_data == (const void *)&s_cfg),
"TC-NETCFG-008 注册内容:尺寸/键数/默认值/回调/数据指针=RAM 副本");
fake_reset();
rc = net_cfg_load();
CHECK((rc == 0) && (g_netcfg_info_count == 1) && (g_netcfg_error_count == 0),
"TC-NETCFG-009 load 成功:返回 0 + 一条 INFO");
fake_reset();
g_load_rc = -1;
rc = net_cfg_load();
CHECK(rc == 0, "TC-NETCFG-010 引擎 -1 时仍返回 0(RAM 恒有效契约)");
CHECK(g_netcfg_error_count == 1, "TC-NETCFG-011 load 失败必须留 ERROR");
}
/* ===================== RAM 副本读写 ===================== */
static void tc_set_get(void)
{
net_cfg_t c;
static const uint8_t ip[4] = { 10u, 0u, 0u, 7u };
static const uint8_t mac[6] = { 0x02u, 0x11u, 0x22u, 0x33u, 0x44u, 0x55u };
printf("-- TC-NETCFG-012..013 set/get --\n");
c = *net_cfg_get();
memcpy(c.ip, ip, 4u);
memcpy(c.mac, mac, 6u);
net_cfg_set(&c);
CHECK(bytes_eq(net_cfg_get()->ip, ip, 4u) &&
mac_eq(net_cfg_get()->mac, mac), "TC-NETCFG-012 set 被 get 读回");
net_cfg_set(NULL);
CHECK(bytes_eq(net_cfg_get()->ip, ip, 4u), "TC-NETCFG-013 set(NULL) 不改写");
}
/* ===================== 直接保存 ===================== */
static void tc_save_direct(void)
{
int rc;
printf("-- TC-NETCFG-014..015 save --\n");
fake_reset();
rc = net_cfg_save();
CHECK((rc == 0) && (g_save_calls == 1), "TC-NETCFG-014 写盘成功返回 0");
fake_reset();
g_save_rc = -1;
rc = net_cfg_save();
CHECK((rc == -1) && (g_netcfg_error_count == 1),
"TC-NETCFG-015 写盘失败返回 -1 并留痕");
}
/* ===================== 跨任务保存交接状态机 ===================== */
static void tc_save_handoff(void)
{
int rc;
printf("-- TC-NETCFG-020..033 save handoff state machine --\n");
fake_reset();
net_cfg_set_apply_cb(NULL);
net_cfg_set_save_req_cb(NULL);
s_save_pending = 0u; /* 白盒复位:上一组的 save() 不碰 pending/result,此处 */
s_save_result = 0u; /* 只是防止将来有人调整后漏出脏态 */
CHECK(net_cfg_save_state() == NET_CFG_SAVE_ST_IDLE, "TC-NETCFG-020 初始 IDLE");
CHECK((net_cfg_save_request() == 0) &&
(net_cfg_save_state() == NET_CFG_SAVE_ST_WAIT), "TC-NETCFG-021 投递后 WAIT");
net_cfg_set_save_req_cb(req_cb);
g_req_cb_calls = 0;
(void)net_cfg_save_request();
CHECK(g_req_cb_calls == 1, "TC-NETCFG-022 投递经回调唤醒属主任务");
net_cfg_set_save_req_cb(NULL);
(void)net_cfg_save_request();
CHECK((g_req_cb_calls == 1) &&
(net_cfg_save_state() == NET_CFG_SAVE_ST_WAIT),
"TC-NETCFG-023 未注册回调时空操作,请求照样挂起");
g_save_rc = 0;
g_save_calls = 0;
rc = net_cfg_save_run();
CHECK((rc == 0) && (g_save_calls == 1), "TC-NETCFG-024a 有请求时确实写盘一次");
g_save_calls = 0;
rc = net_cfg_save_run();
CHECK((rc == 0) && (g_save_calls == 0),
"TC-NETCFG-024 无请求时空醒不写盘(2s 轮询不刷 Flash)");
(void)net_cfg_save_request();
net_cfg_set_apply_cb(apply_cb);
g_apply_calls = 0;
g_save_rc = 0;
rc = net_cfg_save_run();
CHECK((rc == 0) && (s_save_pending == 0u), "TC-NETCFG-025 成功后 pending 清零");
CHECK((net_cfg_save_state() == NET_CFG_SAVE_ST_OK) && (s_save_busy == 0u),
"TC-NETCFG-026 成功后状态 OK 且写盘标志已清");
CHECK(g_apply_calls == 1, "TC-NETCFG-027 成功后触发一次立即应用");
(void)net_cfg_save_request();
g_save_hook = save_hook_probe;
g_state_during_save = -1;
g_apply_calls = 0;
g_save_rc = 0;
rc = net_cfg_save_run();
CHECK((rc == 0) && (g_state_during_save == (int)NET_CFG_SAVE_ST_WAIT),
"TC-NETCFG-028 写盘进行中状态仍是 WAIT");
/* 写盘期间再投递一次:本轮不得把它当已处理,下轮必须真的再写 */
g_requeue_during_save = 1;
(void)net_cfg_save_request();
g_save_calls = 0;
rc = net_cfg_save_run();
CHECK((rc == 0) && (net_cfg_save_state() == NET_CFG_SAVE_ST_WAIT),
"TC-NETCFG-029a 写盘期间的新请求没丢");
g_requeue_during_save = 0;
g_save_hook = NULL;
g_save_calls = 0;
rc = net_cfg_save_run();
CHECK((rc == 0) && (g_save_calls == 1), "TC-NETCFG-029b 下一轮真的再写一次");
/* 写盘失败:置 FAIL 且**不得**触发应用(内存值没落盘,网络不能重建) */
s_save_pending = 0u;
s_save_result = 0u;
g_apply_calls = 0;
(void)net_cfg_save_request();
g_save_rc = -1;
rc = net_cfg_save_run();
CHECK((rc == -1) && (net_cfg_save_state() == NET_CFG_SAVE_ST_FAIL) &&
(s_save_busy == 0u),
"TC-NETCFG-030 失败置 FAIL 且写盘标志已清(否则 UI 永远卡 WAIT)");
CHECK(g_apply_calls == 0, "TC-NETCFG-031 写盘失败不得触发立即应用");
net_cfg_set_apply_cb(NULL);
g_apply_calls = 0;
net_cfg_request_apply();
CHECK(g_apply_calls == 0, "TC-NETCFG-032 未注册应用回调时是空操作");
net_cfg_set_apply_cb(apply_cb);
net_cfg_request_apply();
CHECK(g_apply_calls == 1, "TC-NETCFG-033a 注册后可被触发");
net_cfg_set_apply_cb(NULL);
net_cfg_request_apply();
CHECK(g_apply_calls == 1, "TC-NETCFG-033b 注销后不再被触发");
}
/* ===================== MAC 三态优先级 ===================== */
static void tc_mac_priority(void)
{
net_cfg_t c;
uint8_t out[6];
static const uint8_t stored[6] = { 0xAAu, 0xBBu, 0xCCu, 0x11u, 0x22u, 0x33u };
static const uint8_t shadow[6] = { 0x02u, 0x00u, 0x00u, 0x01u, 0x02u, 0x03u };
static const uint8_t zero[6] = { 0u, 0u, 0u, 0u, 0u, 0u };
printf("-- TC-NETCFG-040..045 mac display priority --\n");
c = *net_cfg_get();
memcpy(c.mac, stored, 6u);
net_cfg_set(&c);
net_cfg_set_active_mac(shadow);
memset(out, 0xFF, sizeof(out));
net_cfg_get_mac(out);
CHECK(mac_eq(out, stored), "TC-NETCFG-040 存储非零优先于芯片影子");
c = *net_cfg_get();
memcpy(c.mac, zero, 6u);
net_cfg_set(&c);
memset(out, 0xFF, sizeof(out));
net_cfg_get_mac(out);
CHECK(mac_eq(out, shadow), "TC-NETCFG-041 存储全零则用芯片回写影子");
s_active_valid = 0u; /* 白盒:回到"芯片还没回写"的上电初态 */
memset(out, 0xFF, sizeof(out));
net_cfg_get_mac(out);
CHECK(mac_eq(out, zero), "TC-NETCFG-042 两者皆无 → 全零");
net_cfg_set_active_mac(NULL);
CHECK(s_active_valid == 0u, "TC-NETCFG-043 NULL 回写不得把影子置有效");
net_cfg_set_active_mac(shadow);
memset(out, 0xFF, sizeof(out));
net_cfg_get_mac(out);
CHECK(mac_eq(out, shadow), "TC-NETCFG-044 正常回写后影子生效");
net_cfg_get_mac(NULL);
CHECK(1, "TC-NETCFG-045 get_mac(NULL) 安全返回");
}
/* ===================== IPv4 定长格式化 ===================== */
static void tc_fmt_ipv4(void)
{
static const uint8_t ip1[4] = { 192u, 168u, 1u, 100u };
static const uint8_t ip0[4] = { 0u, 0u, 0u, 0u };
static const uint8_t ipm[4] = { 255u, 255u, 255u, 255u };
char buf[24];
char guard[24];
printf("-- TC-NETCFG-050..056 fixed-width ipv4 format --\n");
memset(buf, 0x7A, sizeof(buf));
net_cfg_fmt_ipv4(ip1, buf, sizeof(buf));
/* 定长 = 4 段 × 3 位 + 3 个点 = 15(曾有误记为 11 的注释,已同日更正)
* 钉死这个长度是 UI 光标偏移(段 i 起点 4i、点号 3/7/11)的前提 */
CHECK((strcmp(buf, "192.168.001.100") == 0) && (strlen(buf) == 15u),
"TC-NETCFG-050 定长 15 字符且每段补零");
memset(buf, 0x7A, sizeof(buf));
net_cfg_fmt_ipv4(ip0, buf, sizeof(buf));
CHECK(strcmp(buf, "000.000.000.000") == 0, "TC-NETCFG-051 全零也占满 15 字符");
memset(buf, 0x7A, sizeof(buf));
net_cfg_fmt_ipv4(ipm, buf, sizeof(buf));
CHECK(strcmp(buf, "255.255.255.255") == 0, "TC-NETCFG-052 255 边界");
memset(guard, 0x7A, sizeof(guard));
memcpy(buf, guard, sizeof(buf));
net_cfg_fmt_ipv4(NULL, buf, sizeof(buf));
CHECK(memcmp(buf, guard, sizeof(buf)) == 0, "TC-NETCFG-053 p_ip NULL 不写缓冲");
net_cfg_fmt_ipv4(ip1, NULL, 16u);
CHECK(1, "TC-NETCFG-054 p_buf NULL 不崩");
memset(guard, 0x7A, sizeof(guard));
memcpy(buf, guard, sizeof(buf));
net_cfg_fmt_ipv4(ip1, buf, 0u);
CHECK(memcmp(buf, guard, sizeof(buf)) == 0, "TC-NETCFG-055 len 0 不写缓冲");
memset(buf, 0x7A, sizeof(buf));
net_cfg_fmt_ipv4(ip1, buf, 5u);
CHECK((strlen(buf) == 4u) && (buf[4] == '\0') && (buf[5] == (char)0x7A),
"TC-NETCFG-056 len 不足:截断但必须 NUL 结尾且不越界");
}
/* ===================== 入口 ===================== */
int main(void)
{
printf("=== NET Config Tests ===\n");
tc_defaults(); /* 必须最先:任何 load 之前 */
tc_register_load();
tc_set_get();
tc_save_direct();
tc_save_handoff();
tc_mac_priority();
tc_fmt_ipv4();
printf("\n=== NET Config Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""网络配置(net_cfg)的主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/net_cfg/net_cfg_test.c(它以 #include 纳入
**生产源文件** Drivers/BSP/NETCFG/net_cfg.c),在 PC 上离线跑出厂默认、
注册幂等、load/save 错误通道、跨任务保存交接状态机、MAC 三态优先级、
IPv4 定长格式化,失败即非 0 退出。零硬件、零烧录、毫秒级。
工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness
`test/host/common/unit_test_harness.py`(本文件只做声明)。
为什么要主机侧单测(而不是只用台架):
保存链跨三个任务(uiTask 投递 → defaultTask 写盘 → netTask 重建网络):
"写盘期间又投递一次"与"写盘失败却重建网络"这两条真机上要靠手速 + 故障注入
才碰得到,错了就是"改 IP 不生效 / 保存失败网络被无端重建";MAC 三态优先级
写错会静默用错 MAC(同网段冲突,只表现为偶发丢包);net_cfg_fmt_ipv4 的定长
11 字符是 UI 光标按固定偏移定位的前提。三条都在 PC 上钉死,且不占 MCU RAM。
桩的边界(细节见测试单元文件头):
配置文件引擎用**真实头**(接口原汁)+ 测试单元内的可控实现;[ftp] 段的
ftp_cfg_register 只计数——它归 test/host/ftp_cfg/ 模块,两边不互相顶包。
用法(仓库根目录):
python test/host/net_cfg/run_unit_test.py
或统一用 .venv: & '.venv\Scripts\python.exe' test/host/net_cfg/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。
设计依据:docs/子系统设计/网络配置说明.md、docs/测试说明/主机单测覆盖率口径.md §9 P1
创建日期:2026-10-23
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
# harness 位于 test/host/common/
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
# 顺序敏感:stub 必须**先于**生产目录,才能覆盖 dbg_log.h。cfg_file.h 用真实头
# (接口契约原汁,引擎改签名即编译期报错),net_cfg.h / ftp_cfg.h 亦用真实头。
BUILD = TestBuild(
name="NET Config Tests",
src=os.path.join(HERE, "net_cfg_test.c"),
include_dirs=[
os.path.join(HERE, "stub"),
os.path.join(ROOT, "Drivers", "BSP", "NETCFG"),
os.path.join(ROOT, "App", "cfg"),
HERE,
],
summary_marker="=== NET Config Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+27
View File
@@ -0,0 +1,27 @@
/*
* 模块名称:主机侧单测桩 - dbg_log.h(net_cfg 模块专用)
* 模块功能:把 MCU 版 DBG_* 宏替换为**计数**,让"该留痕的有没有留痕"变成可断言
* 的行为;同时不往 stdout 打日志——汇总行是 harness 的判据,必须干净。
*
* ⚠ 仅供 test/host/net_cfg/ 使用,不参与 MCU 构建。
*
* 为什么是计数而不是 printf 直通:net_cfg.c 的三条日志恰好对应三条契约
* (load OK / load FAIL / save OK|FAIL),计数能钉住"哪条该响、哪条不该响"
* ——例如"写盘失败必须留痕、但不得触发应用"这类只能靠计数判的分支。
* 计数器定义在 net_cfg_test.c,每组用例前清零。
*
* 创建日期:2026-10-23
*/
#ifndef DBG_LOG_H
#define DBG_LOG_H
extern int g_netcfg_info_count;
extern int g_netcfg_error_count;
extern int g_netcfg_debug_count;
#define DBG_INFO(fmt, ...) do { (void)(fmt); g_netcfg_info_count++; } while (0)
#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_netcfg_debug_count++; } while (0)
#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_netcfg_error_count++; } while (0)
#endif /* DBG_LOG_H */
+530
View File
@@ -0,0 +1,530 @@
/*
* 模块名称:NET 层事件分发与周期对账(net_events)主机侧单元测试
* 模块功能:在 PC 上离线验证 Drivers/BSP/NET/net_events.c 的五个事件入口与周期对账器:
* 对端六元组解析、**陈旧中断防误判**(芯片说还在连接就不许拆会话)、疑似
* 误报延后交给对账器、超时把会话标成异常释放(喂给 net_pending 收尾报
* CONNRESET 的那条输入)、掉线自愈自接,以及对账器的 100ms 周期闸门、
* 1000ms 冷却、五种不一致形态的稳定计数(死亡要连观 4 次、重开监听 2 次、
* 自接 1 次)与 U11 的 CLOSED->LISTENING 重同步。
*
* 被测对象:真实源文件 ../../../Drivers/BSP/NET/net_events.c(#include 原样纳入)。
* net_events.h / net_types.h / net_pending.h / net_listener.h 全用**真实头**
* (net_event_cb_t 回调签名、NET_EVENT_* / NET_SOCK_STATE_* /
* net_listener_settle_t / NET_LISTENER_REQ_* 一改即编译期报错)。假件:
* 共享假 CH395F(本批补齐对端六元组与链路变更计数)、可控 socket 表
* (net_sock_raw 对未在用槽位也返回项,与真实语义一致)、**可观测的事件
* 回调**(记 fd / 事件 / arg)、记录型 net_listener_settle/request 与
* net_pending_cancel / net_send_pending_cancel、可控时基、dbg_log 计数。
*
* 为什么要主机侧单测(而不是只靠台架):
* 1. CH395F 的中断是**整体读即清**且单连接 socket 复用同一个号,"迟到的 DISCONNECT"
* 与"丢了 CONNECT 的已连接"在真机上都会发生,却无法按需复现。误判的代价直接落在
* 现场设备上:把还活着的会话拆掉(104 主站看到莫名掉线),或让 socket 永远停在
* 已连接假象里不再重开。
* 2. 对账器的**稳定计数**(死亡 4 次、重开监听 2 次、自接 1 次)本质是"别拿一次
* 采样就把连接折腾死"。真机一次采样 100ms,凑够 4 次要 400ms,且必须一路稳定;
* 台架上没法按毫秒喂,也看不出为什么"偶尔不重开"。主机侧把时基做成显式量后,
* 第 3 次不该动、第 4 次该动、之后 1000ms 内不许再动,全部可精确断言。
* 3. handle_timeout_event 会置 released_abnormal=1,而 net_pending 收尾时正是靠
* 这个标志把挂起 recv 报成 CONNRESET 而不是 0(见 test/host/net_pending 的
* TC-NETPEND-014)。两个模块之间这条**跨文件因果**,只有两边都能离线跑时才可测。
*
* 覆盖点:TC-NETEV-001..006 CONNECT;010..014 DISCONNECT(含两条陈旧中断防误判);
* 020/021 RECV 与 SEND;030..032 TIMEOUT;040..042 掉线自愈;050..058 周期对账
* (周期闸门、链路变更留痕、五种不一致形态、稳定计数、冷却、in_use 兜底)。
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-23
*/
#include <stdio.h>
#include <string.h>
#include "net_events.h"
#include "net_listener.h"
#include "net_pending.h"
#include "net_socket.h"
#include "ch395f.h"
#include "ch395f_fake.c"
#include "net_events.c"
static int s_total;
static int s_passed;
static int s_failed;
#define CHECK(cond, name) do { \
s_total++; \
if (cond) { s_passed++; printf(" PASS %s\n", name); } \
else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \
} while (0)
/* ===================== dbg_log 计数 ===================== */
int g_nev_info_count;
int g_nev_error_count;
int g_nev_debug_count;
/* ===================== 可观测的事件回调 ===================== */
static int g_ev_n;
static int g_ev_fd[16];
static int g_ev_kind[16];
static void *g_ev_arg[16];
static int g_ev_token;
static void ev_cb(int sockfd, net_event_t event, void *arg)
{
if (g_ev_n < (int)(sizeof(g_ev_fd) / sizeof(g_ev_fd[0]))) {
g_ev_fd[g_ev_n] = sockfd;
g_ev_kind[g_ev_n] = (int)event;
g_ev_arg[g_ev_n] = arg;
}
g_ev_n++;
}
/* ===================== 记录型监听与挂起取消 ===================== */
static int g_settle_n;
static int g_settle_sock;
static int g_settle_level;
static int g_req_n;
static int g_req_sock;
static int g_req_reason;
static int g_pcan_n;
static int g_scan_n;
static int g_pcan_err;
void net_listener_settle(int sockfd, net_listener_settle_t level)
{
g_settle_n++;
g_settle_sock = sockfd;
g_settle_level = (int)level;
}
void net_listener_request(int sockfd, net_listener_req_t reason)
{
g_req_n++;
g_req_sock = sockfd;
g_req_reason = (int)reason;
}
void net_listener_clear(int sockfd) { (void)sockfd; }
void net_listener_tick(void) { }
void net_pending_cancel(int sockfd, int err)
{
(void)sockfd;
g_pcan_n++;
g_pcan_err = err;
}
void net_send_pending_cancel(int sockfd, int err)
{
(void)sockfd;
(void)err;
g_scan_n++;
}
/* ===================== 可控时基、errno 与 socket 表 ===================== */
static uint32_t g_now;
static int g_errno_last;
uint32_t HAL_GetTick(void) { return g_now; }
int net_set_errno(int err) { g_errno_last = err; return err; }
uint32_t xTaskGetTickCount(void) { return g_now; }
static net_sock_t g_tbl[NET_MAX_SOCKETS];
net_sock_t *net_sock_raw(int sockfd)
{
if ((sockfd < 0) || (sockfd >= NET_MAX_SOCKETS)) {
return NULL;
}
/* 与真实语义一致:对账循环必须能读到 in_use == 0 的槽位,所以这里不给 NULL */
return &g_tbl[sockfd];
}
net_sock_t *net_get_sock(int sockfd)
{
net_sock_t *p = net_sock_raw(sockfd);
if ((p == NULL) || (p->in_use == 0u)) {
return NULL;
}
return p;
}
static void reset_all(void)
{
int i;
(void)memset(g_tbl, 0, sizeof(g_tbl));
g_now = 1000u;
g_errno_last = 0;
g_ev_n = 0;
g_settle_n = 0;
g_settle_sock = -1;
g_settle_level = -1;
g_req_n = 0;
g_req_sock = -1;
g_req_reason = -1;
g_pcan_n = 0;
g_scan_n = 0;
g_pcan_err = 0;
g_nev_info_count = 0;
g_nev_error_count = 0;
g_nev_debug_count = 0;
ch395f_fake_reset();
(void)i;
}
/* 一个"带应用回调的在用槽位",ch395_sock 与数组下标同号 */
static net_sock_t *mk(int fd, net_sock_state_t st, uint16_t local_port)
{
net_sock_t *p = &g_tbl[fd];
p->in_use = 1u;
p->ch395_sock = (uint8_t)fd;
p->type = NET_SOCK_STREAM;
p->state = st;
p->auto_relisten = 1u;
p->local_port = local_port;
p->event_cb = ev_cb;
p->event_arg = (void *)&g_ev_token;
return p;
}
/* ===================== CONNECT ===================== */
static void tc_connect(void)
{
net_sock_t *p;
printf("-- TC-NETEV-001..006 CONNECT --\n");
reset_all();
p = mk(0, NET_SOCK_STATE_LISTENING, 2404u);
ch395f_fake_set_peer(0, 192u, 168u, 1u, 50u, 51234u);
handle_connect_event(p);
/* remote_ip 是**低字节在前**拼的(remote[0] 落在最低 8 位),与网络序相反:
* 192.168.1.50 -> 0x3201A8C0。这是既有实现的字节序,钉住它是防止有人"顺手
* 改成大端"导致所有按 IP 比对/白名单的判断静默错位。 */
CHECK((p->remote_ip_arr[0] == 192u) && (p->remote_ip_arr[3] == 50u) &&
(p->remote_ip == ((192u) | (168u << 8) | (1u << 16) | (50u << 24))) &&
(p->remote_port == 51234u),
"TC-NETEV-001 对端六元组解析:ip 低字节在前拼 32 位、端口取第 5/6 字节");
CHECK((p->state == NET_SOCK_STATE_ESTABLISHED) && (p->session_dead == 0u) &&
(p->lost_conn_seen == 0u) && (p->send_ready == 1u),
"TC-NETEV-002 监听口自接后转已连接并立刻可写");
CHECK((g_chip.keepalive_calls == 1) && (g_chip.last_keepalive == 1u) &&
(g_chip.int_status_calls == 1),
"TC-NETEV-003 建链即开 keepalive 并清一次挂起位(读即清)");
CHECK((g_ev_n == 1) && (g_ev_kind[0] == (int)NET_EVENT_CONNECTED) &&
(g_ev_fd[0] == 0) && (g_ev_arg[0] == (void *)&g_ev_token),
"TC-NETEV-004 事件带 fd 与调用方 arg 回调给应用");
reset_all();
p = mk(0, NET_SOCK_STATE_CONNECTING, 0u);
ch395f_fake_set_peer(0, 10u, 0u, 0u, 1u, 2404u);
handle_connect_event(p);
CHECK((p->state == NET_SOCK_STATE_ESTABLISHED) && (p->send_ready == 0u) &&
(g_chip.keepalive_calls == 1) && (g_ev_n == 1),
"TC-NETEV-005 主动连接分支同样转已连接,但**不动 send_ready**(与自接有别)");
reset_all();
p = mk(0, NET_SOCK_STATE_CLOSED, 2404u);
handle_connect_event(p);
CHECK((g_ev_n == 0) && (p->state == NET_SOCK_STATE_CLOSED),
"TC-NETEV-006 其它状态收到 CONNECT 中断一律忽略(不许凭空造会话)");
}
/* ===================== DISCONNECT ===================== */
static void tc_disconnect(void)
{
net_sock_t *p;
printf("-- TC-NETEV-010..014 DISCONNECT --\n");
reset_all();
p = mk(0, NET_SOCK_STATE_ESTABLISHED, 2404u);
g_fake.status_byte0[0] = CH395F_SOCKET_OPEN;
g_fake.status_byte1[0] = CH395F_TCP_ESTABLISHED;
handle_disconnect_event(p);
CHECK((p->state == NET_SOCK_STATE_ESTABLISHED) && (g_ev_n == 0) &&
(g_nev_error_count == 1),
"TC-NETEV-010 芯片说还在连接:忽略这个陈旧 DISCONNECT(不许拆活会话)");
reset_all();
p = mk(0, NET_SOCK_STATE_ESTABLISHED, 2404u);
g_fake.status_byte0[0] = CH395F_SOCKET_OPEN;
g_fake.status_byte1[0] = CH395F_TCP_CLOSED;
ch395f_fake_push_recv_len(0, 9u); /* 还有 9 字节没读走 */
handle_disconnect_event(p);
CHECK((p->state == NET_SOCK_STATE_ESTABLISHED) && (p->lost_conn_seen == 1u) &&
(g_ev_n == 0) && (g_settle_n == 0),
"TC-NETEV-011 疑似误报(一条数据都没收就断):延后交给对账器,不立即拆");
reset_all();
p = mk(0, NET_SOCK_STATE_ESTABLISHED, 2404u);
p->remote_port = 51234u;
g_fake.status_byte0[0] = CH395F_SOCKET_OPEN;
g_fake.status_byte1[0] = CH395F_TCP_CLOSED;
handle_disconnect_event(p);
CHECK((g_settle_n == 1) && (g_settle_level == (int)NET_LISTENER_SETTLE_GRACEFUL) &&
(p->session_dead == 1u) && (p->released_abnormal == 0u) &&
(p->last_peer_port == 51234u) &&
(p->state == NET_SOCK_STATE_CLOSED) &&
(g_ev_kind[0] == (int)NET_EVENT_DISCONNECTED),
"TC-NETEV-012 正常断开:优雅沉降 + 会话标死 + 记旧端口 + 发事件");
reset_all();
p = mk(0, NET_SOCK_STATE_LISTENING, 2404u);
g_fake.status_byte0[0] = CH395F_SOCKET_OPEN;
g_fake.status_byte1[0] = CH395F_TCP_LISTEN;
handle_disconnect_event(p);
CHECK((p->state == NET_SOCK_STATE_LISTENING) && (g_ev_n == 0) &&
(g_nev_info_count == 1) && (g_nev_error_count == 0),
"TC-NETEV-013 监听还在:陈旧监听 DISCONNECT 只留痕,不报 ERROR");
reset_all();
p = mk(0, NET_SOCK_STATE_LISTENING, 2404u);
g_fake.status_byte1[0] = CH395F_TCP_CLOSED; /* 芯片说不在 LISTEN */
handle_disconnect_event(p);
CHECK((p->state == NET_SOCK_STATE_LISTENING) && (g_ev_n == 0) && (g_req_n == 0),
"TC-NETEV-013b 监听丢了也只留痕:重开交给对账器,事件层不越权");
reset_all();
p = mk(0, NET_SOCK_STATE_CONNECTING, 0u);
handle_disconnect_event(p);
CHECK((g_nev_error_count == 1) && (g_ev_kind[0] == (int)NET_EVENT_DISCONNECTED),
"TC-NETEV-014 非常规状态收到 DISCONNECT:报 ERROR 但仍要通知应用");
}
/* ===================== RECV / SEND / TIMEOUT ===================== */
static void tc_recv_send_timeout(void)
{
net_sock_t *p;
printf("-- TC-NETEV-020/021/030..032 --\n");
reset_all();
p = mk(0, NET_SOCK_STATE_ESTABLISHED, 2404u);
handle_recv_event(p);
CHECK((p->readable == 1u) && (g_ev_kind[0] == (int)NET_EVENT_DATA_RECEIVED),
"TC-NETEV-020 RECV 事件置可读并通知应用");
reset_all();
p = mk(0, NET_SOCK_STATE_ESTABLISHED, 2404u);
p->send_ready = 1u;
handle_send_event(p);
CHECK((g_ev_kind[0] == (int)NET_EVENT_SEND_COMPLETE) && (p->send_ready == 1u),
"TC-NETEV-021 SEND 完成只发事件,字段留给写路径自己管");
reset_all();
p = mk(0, NET_SOCK_STATE_CONNECTING, 0u);
handle_timeout_event(p);
CHECK((p->state == NET_SOCK_STATE_CREATED) && (g_nev_error_count == 1) &&
(g_ev_kind[0] == (int)NET_EVENT_TIMEOUT),
"TC-NETEV-030 连接超时:退回 CREATED 并通知应用");
reset_all();
p = mk(0, NET_SOCK_STATE_ESTABLISHED, 2404u);
p->remote_port = 777u;
handle_timeout_event(p);
CHECK((g_settle_level == (int)NET_LISTENER_SETTLE_QUARANTINE) &&
(p->released_abnormal == 1u) && (p->session_dead == 1u) &&
(p->last_peer_port == 777u) && (p->state == NET_SOCK_STATE_CLOSED),
"TC-NETEV-031 会话超时:隔离级沉降 + 标异常释放(net_pending 收尾据此报 CONNRESET)");
reset_all();
p = mk(0, NET_SOCK_STATE_LISTENING, 2404u);
handle_timeout_event(p);
CHECK((p->state == NET_SOCK_STATE_LISTENING) && (g_nev_error_count == 1) &&
(g_ev_kind[0] == (int)NET_EVENT_TIMEOUT),
"TC-NETEV-032 监听超时只留痕:不改状态,但仍要通知等待方");
}
/* ===================== 掉线自愈 ===================== */
static void tc_lost_probe(void)
{
net_sock_t *p;
printf("-- TC-NETEV-040..042 lost-CONNECT probe --\n");
reset_all();
p = mk(0, NET_SOCK_STATE_CLOSED, 2404u);
p->lost_conn_seen = 1u;
g_fake.cached_tcp[0] = CH395F_TCP_CLOSED;
net_lost_connect_probe(p);
CHECK((p->lost_conn_seen == 0u) && (g_ev_n == 0),
"TC-NETEV-040 芯片不在已连接态:只清标记,不自作主张建会话");
reset_all();
p = mk(0, NET_SOCK_STATE_CLOSED, 2404u);
p->lost_conn_seen = 1u;
p->last_peer_port = 51234u;
g_fake.cached_tcp[0] = CH395F_TCP_ESTABLISHED;
ch395f_fake_set_peer(0, 10u, 0u, 0u, 9u, 51234u);
net_lost_connect_probe(p);
CHECK((p->lost_conn_seen == 0u) && (g_ev_n == 0),
"TC-NETEV-041 对端口与上次相同:认定是自己没看到 CONNECT,不自接第二遍");
/* 自愈的适用面是软件还写着 LISTENING、芯片其实已经连上——accept 分支只认
* LISTENING/CONNECTING。若软件态已被别处改成 CLOSED,本函数打完 INFO 后什么都
* 不会发生(现状留痕,见第二断言)。 */
reset_all();
p = mk(0, NET_SOCK_STATE_LISTENING, 2404u);
p->lost_conn_seen = 1u;
p->last_peer_port = 1u;
g_fake.cached_tcp[0] = CH395F_TCP_ESTABLISHED;
ch395f_fake_set_peer(0, 10u, 0u, 0u, 9u, 55555u);
net_lost_connect_probe(p);
CHECK((g_ev_n == 1) && (g_ev_kind[0] == (int)NET_EVENT_CONNECTED) &&
(p->state == NET_SOCK_STATE_ESTABLISHED) && (p->remote_port == 55555u) &&
(p->lost_conn_seen == 0u),
"TC-NETEV-042 丢了 CONNECT 中断时自愈:补一次 accept,会话不至于永久失联");
reset_all();
p = mk(0, NET_SOCK_STATE_CLOSED, 2404u);
g_fake.cached_tcp[0] = CH395F_TCP_ESTABLISHED;
ch395f_fake_set_peer(0, 10u, 0u, 0u, 9u, 55555u);
net_lost_connect_probe(p);
CHECK((g_ev_n == 0) && (p->state == NET_SOCK_STATE_CLOSED),
"TC-NETEV-042b 现状:软件已 CLOSED 时自愈接不上(只留痕,不凭空造会话)");
}
/* ===================== 周期对账 ===================== */
static void recon_step(uint32_t now)
{
g_now = now;
net_reconcile_tick();
}
static void tc_reconcile(void)
{
net_sock_t *p;
int infos;
int c;
printf("-- TC-NETEV-050..058 reconcile --\n");
/* 对账器里的 s_last / s_bad[] / s_cool[] 是函数内 static(外部够不着),
* 所以每个形态**各占一个 fd**、时间统一单调前进,互不串扰。 */
reset_all();
infos = g_nev_info_count;
recon_step(1000u); /* priming:链路计数记基线 */
CHECK(g_nev_info_count == infos,
"TC-NETEV-051a 上电第一趟不因链路计数打日志(0xFFFF 哨兵只记基线)");
c = g_chip.cached_calls;
recon_step(1000u); /* 同一时刻再调一次 */
CHECK(g_chip.cached_calls == c, "TC-NETEV-050 100ms 周期闸门:同一时刻不重复查芯片");
reset_all();
p = mk(1, NET_SOCK_STATE_ESTABLISHED, 2404u);
g_fake.cached_open[1] = CH395F_SOCKET_CLOSED;
g_fake.cached_tcp[1] = CH395F_TCP_CLOSED;
infos = g_nev_info_count;
recon_step(1100u);
recon_step(1200u);
recon_step(1300u);
CHECK((p->state == NET_SOCK_STATE_ESTABLISHED) && (g_ev_n == 0) &&
(g_nev_info_count == infos),
"TC-NETEV-052 会话死亡要**连观 4 次**才动手:前三次保持原状");
recon_step(1400u);
CHECK((p->state == NET_SOCK_STATE_CLOSED) && (p->session_dead == 1u) &&
(g_ev_kind[0] == (int)NET_EVENT_DISCONNECTED),
"TC-NETEV-052b 第 4 次稳定后走断开收尾");
p->state = NET_SOCK_STATE_ESTABLISHED; /* 假装又活了,验证冷却 */
recon_step(1500u);
recon_step(1600u);
recon_step(1700u);
recon_step(1800u);
CHECK(g_ev_n == 1, "TC-NETEV-052c 动作后 1000ms 冷却内不再重复折腾(哪怕稳定计数够了)");
reset_all();
p = mk(2, NET_SOCK_STATE_ESTABLISHED, 2404u);
g_fake.cached_open[2] = CH395F_SOCKET_OPEN;
g_fake.cached_tcp[2] = CH395F_TCP_LISTEN; /* 芯片自己重开监听了,却没发 DISCONNECT */
recon_step(2900u);
CHECK((p->state == NET_SOCK_STATE_ESTABLISHED) && (g_pcan_n == 0),
"TC-NETEV-053 芯片偷偷重开监听:需要 2 次稳定,第一次只累计");
recon_step(3000u);
CHECK((p->state == NET_SOCK_STATE_LISTENING) && (g_pcan_n == 1) &&
(g_scan_n == 1) && (g_pcan_err == NET_ERR_CONNRESET),
"TC-NETEV-053b 第 2 次稳定:重同步成监听并**取消两条挂起**(否则任务永远等不到回)");
reset_all();
p = mk(3, NET_SOCK_STATE_LISTENING, 2404u);
p->last_peer_port = 1u;
g_fake.cached_tcp[3] = CH395F_TCP_ESTABLISHED; /* 连上了却没 CONNECT 中断 */
ch395f_fake_set_peer(3, 172u, 16u, 0u, 5u, 40000u);
recon_step(4100u);
CHECK((p->state == NET_SOCK_STATE_ESTABLISHED) && (p->remote_port == 40000u) &&
(g_ev_kind[0] == (int)NET_EVENT_CONNECTED),
"TC-NETEV-054 连接完成但中断丢了:1 次即补 accept");
reset_all();
p = mk(4, NET_SOCK_STATE_LISTENING, 2404u);
g_fake.cached_open[4] = CH395F_SOCKET_CLOSED; /* 监听没了 */
g_fake.cached_tcp[4] = CH395F_TCP_CLOSED;
recon_step(5200u);
recon_step(5300u);
CHECK((g_req_n == 1) && (g_req_sock == 4) &&
(g_req_reason == (int)NET_LISTENER_REQ_RECON),
"TC-NETEV-055 监听丢了:2 次稳定后请求重开监听(不自已在事件层里开)");
reset_all();
p = mk(5, NET_SOCK_STATE_CLOSED, 2404u);
g_fake.cached_open[5] = CH395F_SOCKET_OPEN; /* 芯片还在 LISTEN */
g_fake.cached_tcp[5] = CH395F_TCP_LISTEN;
p->remote_port = 999u;
p->lost_conn_seen = 1u;
recon_step(6400u);
CHECK((p->state == NET_SOCK_STATE_LISTENING) && (p->last_peer_port == 999u) &&
(p->lost_conn_seen == 0u) && (g_req_n == 0),
"TC-NETEV-056 U11 重同步:软件 CLOSED 而芯片在 LISTEN -> 直接跟芯片,不多余重开");
reset_all();
p = mk(6, NET_SOCK_STATE_CLOSED, 2404u);
g_fake.cached_open[6] = CH395F_SOCKET_CLOSED;
recon_step(7500u);
recon_step(7600u);
CHECK((g_req_n == 1) && (g_req_sock == 6),
"TC-NETEV-057 软件 CLOSED 且芯片也不在监听:请求重开(重建交回监听层)");
reset_all();
(void)memset(&g_tbl[7], 0, sizeof(g_tbl[7])); /* 槽位不在用 */
infos = g_nev_info_count;
recon_step(8700u);
CHECK(g_nev_info_count == infos, "TC-NETEV-058 不在用的槽位直接跳过(不产生任何动作)");
/* 链路日志:s_link_last 初值 0xFFFF 是哨兵——第一次看到计数只记基线、不打日志
* (否则每次上电都会冒一条"链路 up"的假事件);此后计数一变就必须留痕。
* 本文件开头那次 priming 就是"基线"那一趟(TC-NETEV-051a),这里是"变化"那一趟。 */
reset_all();
g_fake.link_change = 7u;
infos = g_nev_info_count;
recon_step(9800u);
CHECK(g_nev_info_count == (infos + 1),
"TC-NETEV-051b 链路变更计数一变就留痕(基线那趟已由 051a 验证不打)");
}
int main(void)
{
printf("=== NET Events Tests ===\n");
tc_connect();
tc_disconnect();
tc_recv_send_timeout();
tc_lost_probe();
tc_reconcile();
printf("\n=== NET Events Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""NET 层事件分发与周期对账(net_events)主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/net_events/net_events_test.c(以 #include 纳入生产源
Drivers/BSP/NET/net_events.c 与共享假芯片 test/host/common/ch395f_fake.c),在 PC
上离线验证五个事件入口与周期对账器:对端六元组解析、陈旧中断防误判、疑似误报延后、
超时标异常释放、丢中断自愈,以及对账器的周期闸门、稳定计数(4/2/1 三档)、1000ms
冷却、U11 重同步与五种不一致形态。工具链与判定委托 unit_test_harness.py。
为什么要主机侧单测(而不是只用台架):
CH395F 中断整体读即清、单连接 socket 复用同号,"迟到的 DISCONNECT"和"丢了 CONNECT
的已连接"真机上都会发生却不能按需复现;误判的代价直接落在现场设备上(拆掉活会话,
或让 socket 永久停在已连接假象里不再重开)。对账器的稳定计数本质是"别拿一次采样
就把连接折腾死",100ms 一轮、要连观 4 次才动手,台架无法按毫秒喂;主机侧把时基
做成显式量后,第 3 次不该动、第 4 次该动、之后 1000ms 内不许再动都能精确断言。
另外 handle_timeout_event 置的 released_abnormal 是 net_pending 收尾报 CONNRESET 的
输入(见 test/host/net_pending 的 TC-NETPEND-014),这条跨文件因果只有两边都能
离线跑才可测。
桩的边界:net_events.h / net_types.h / net_pending.h / net_listener.h 用真实头;
net_socket.h 是桩,但 net_sock_raw 保留"未在用槽位也返回项"的真实语义(对账循环
必须能读 in_use==0);ch395f.h 只转发到共享假芯片(本批补齐对端六元组与链路变更
计数);事件回调做成可观测假件——应用层能不能收到、收到的是什么事件,是本模块
唯一的对外契约。刻意不定义 NET_BENCH_CMD_LOG(压测日志不是被测对象)。
用法(仓库根目录):
python test/host/net_events/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误。
设计依据:docs/网络/网络自愈架构设计.md §4.10、docs/网络/Net层阻塞模型架构设计.md、
docs/测试说明/主机单测覆盖率口径.md §9
创建日期:2026-10-23
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
BUILD = TestBuild(
name="NET Events Tests",
src=os.path.join(HERE, "net_events_test.c"),
include_dirs=[
os.path.join(HERE, "stub"),
os.path.join(ROOT, "test", "host", "common"),
os.path.join(ROOT, "Drivers", "BSP", "NET"),
HERE,
],
summary_marker="=== NET Events Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+25
View File
@@ -0,0 +1,25 @@
/*
* 模块名称:主机侧单测桩 - FreeRTOS.h(net_events 模块专用)
* 模块功能:宿主等价类型(TaskHandle_t 等),因为真实 net_pending.h 的原型绕不开。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef FREERTOS_H
#define FREERTOS_H
#include <stddef.h>
#include <stdint.h>
typedef void *TaskHandle_t;
typedef long BaseType_t;
typedef unsigned long UBaseType_t;
typedef enum {
eNoAction = 0,
eSetBits = 1,
eIncrement = 2,
eSetValueWithOverwrite = 3,
eSetValueWithoutOverwrite = 4
} eNotifyAction;
#endif /* FREERTOS_H */
+10
View File
@@ -0,0 +1,10 @@
/*
* 模块名称:主机侧单测桩 - ch395f.h(net_events 模块入口)
* 模块功能:只做转发,真身在 test/host/common/ch395f_fake.h/.c(NET 域共享假芯片)。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef CH395F_H
#define CH395F_H
#include "ch395f_fake.h"
#endif /* CH395F_H */
+10
View File
@@ -0,0 +1,10 @@
/*
* 模块名称:主机侧单测桩 - cmsis_os.h(net_events 模块专用)
* 模块功能:空壳;net_events.c include 它但不用 CMSIS API(事件直接同步分发)。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef CMSIS_OS_H
#define CMSIS_OS_H
#include <stdint.h>
#endif /* CMSIS_OS_H */
+20
View File
@@ -0,0 +1,20 @@
/*
* 模块名称:主机侧单测桩 - dbg_log.h(net_events 模块专用)
* 模块功能:DBG_* 计数。注意 net_events.c 在 include 本头之前把 DBG_LINE_BUF_MAX
* 抬到 128U(AGENTS:深调用链模块必须抬,否则最长日志行会被截断),
* 本桩不关心缓冲大小,只数条数。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef DBG_LOG_H
#define DBG_LOG_H
extern int g_nev_info_count;
extern int g_nev_error_count;
extern int g_nev_debug_count;
#define DBG_INFO(fmt, ...) do { (void)(fmt); g_nev_info_count++; } while (0)
#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_nev_debug_count++; } while (0)
#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_nev_error_count++; } while (0)
#endif /* DBG_LOG_H */
+11
View File
@@ -0,0 +1,11 @@
/*
* 模块名称:主机侧单测桩 - main.h(net_events 模块专用)
* 模块功能:只给 HAL_GetTick(对账周期 100ms 与冷却 1000ms 两条时间轴的唯一致度源)。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef MAIN_H
#define MAIN_H
#include <stdint.h>
uint32_t HAL_GetTick(void);
#endif /* MAIN_H */
@@ -0,0 +1,9 @@
/*
* 模块名称:主机侧单测桩 - net_latency_test.h(net_events 模块专用)
* 模块功能:空壳。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef NET_LATENCY_TEST_H
#define NET_LATENCY_TEST_H
#endif /* NET_LATENCY_TEST_H */
+19
View File
@@ -0,0 +1,19 @@
/*
* 模块名称:主机侧单测桩 - net_socket.h(NET 域共享形态,见 net_listener 同名文件)
* 模块功能:net_events.c 需要两个表项入口——net_sock_raw()(对账循环要能看到 in_use==0
* 的槽位,真实头注释就写着"应用层请一律用 net_get_sock(),本函数仅供
* net_events.c 的对账循环使用")与 net_get_sock();错误通道照抄真实头同名宏。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef NET_SOCKET_H
#define NET_SOCKET_H
#include "net_types.h"
net_sock_t *net_get_sock(int sockfd);
net_sock_t *net_sock_raw(int sockfd);
int net_set_errno(int err);
#define NET_ERR_RETURN_1(err) (net_set_errno(err), -1)
#endif /* NET_SOCKET_H */
+15
View File
@@ -0,0 +1,15 @@
/*
* 模块名称:主机侧单测桩 - task.h(net_events 模块专用)
* 模块功能:只声明 xTaskGetTickCount——它只在 NET_BENCH_CMD_LOG 打开时用到,本模块
* 刻意不开该宏(压测日志不是被测对象),声明留着是为了原型完整。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef TASK_H
#define TASK_H
#include "FreeRTOS.h"
uint32_t xTaskGetTickCount(void);
#endif /* TASK_H */
+10
View File
@@ -0,0 +1,10 @@
/*
* 模块名称:主机侧单测桩 - test_config.h(net_events 模块专用)
* 模块功能:空壳。**刻意不定义 NET_BENCH_CMD_LOG**:压测命令日志不是被测对象,
* 且它依赖真实 tick 序列;不定义即被条件编译整段摘掉。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef TEST_CONFIG_H
#define TEST_CONFIG_H
#endif /* TEST_CONFIG_H */
+545
View File
@@ -0,0 +1,545 @@
/*
* 模块名称:NET 层监听重建(net_listener)主机侧单元测试
* 模块功能:在 PC 上离线验证 Drivers/BSP/NET/net_listener.c 的**自动重开监听**状态机:
* 请求去重、五种放弃条件、挂起 recv 让路、30ms 退避与 2000ms 预算、
* 隔离/优雅两级沉降窗(400ms / 100ms)、800ms 上限"只留痕不放弃"、
* 接收区清理的 256 字节钳位与 16 轮上限、重建后的 peer 簿记,以及
* "先清接收 → 关断 → 读即清 → 读状态"的芯片调用顺序。
*
* 被测对象:真实源文件 ../../../Drivers/BSP/NET/net_listener.c(#include 原样纳入)。
* net_listener.h / net_types.h / net_config.h / net_pending.h 全用**真实头**
* (net_sock_t 字段名、NET_SOCK_STATE_* / NET_SOCK_STREAM / NET_ERR_* /
* net_listener_req_t 枚举一改就编译期报错)。假件:
* - 假 CH395F 芯片:test/host/common/ch395f_fake.h/.c(15 个真实原型 +
* 按调用序号取值的返回队列 + 调用计数与**调用顺序日志**);
* - 可控 socket 表(net_get_sock 可写回项)、可控时基(HAL_GetTick 由测试
* 显式设定,400/100/800/2000 四条时间线都能卡点)、挂起 recv 的有无、
* dbg_log 计数、net_set_errno 记录。
*
* 为什么要主机侧单测(而不是只靠台架):
* 1. CH395F 是**单连接 socket**:监听与数据通道复用同一号,断线后必须重开成 LISTEN,
* 否则端口"连接被拒"。重开时机由"芯片此刻报什么状态"决定,而这些状态在真机上
* 一闪而过——对端配合、时序、总线全都得凑齐才见得到一次,台架无法稳定复现。
* 2. 这里的时间窗是**四道互相牵制的闸门**(30ms 退避 / 400 与 100 沉降 / 800 留痕 /
* 2000 预算),排期登记里那句"沉降窗 800ms 上限只留痕"就是从这里来的:芯片永远
* 关不掉时,代码**不能放弃请求**,也不能每 30ms 刷一条日志。真机上验证一次要等
* 到真断线,主机侧把 tick 做成显式量后,1399ms 与 1400ms 的差别就是一行断言。
* 3. GET_SOCK_INT_STATUS 是**整体读即清**,"顺手全量清一遍"会把别的在用 socket 的
* 门焊死(AGENTS 模块约定 B3 实证坑)。假芯片必须复刻读即清,本文件用调用顺序
* 日志钉住"只读自己这一路"的时序。
*
* 覆盖点:
* TC-NETLIS-001 越界 fd:settle/request/clear 静默返回,tick 不产生任何芯片调用
* TC-NETLIS-010 芯片显示仍在监听 → 不重建,且请求被 clear(下次还能受理)
* TC-NETLIS-011 有挂起 recv / 挂起 send 时让路,但**保留请求**(不丢重建)
* TC-NETLIS-012 五种放弃条件(非 LISTENING/CLOSED 态、auto_relisten=0、非 STREAM、
* local_port=0、表项 in_use=0)→ 静默 clear,不报 ERROR
* TC-NETLIS-013 重建成功后请求清空,重复 tick 不重复重建
* TC-NETLIS-014 open 失败:关回去 + 报 BUSY + 30ms 退避(20ms 后不重试、30ms 重试),
* 首轮建立 2000ms 预算
* TC-NETLIS-015 tcp_listen 失败也要关断 socket
* TC-NETLIS-016 重建成功后簿记:转 LISTENING、清 send_ready/recv_bytes/remote_*、
* 把旧 remote_port 记进 last_peer_port、清 lost_conn_seen/released_abnormal
* TC-NETLIS-017 重建用的源端口与协议取自表项本身(端口不能漂)
* TC-NETLIS-018 预算耗尽:清请求 + 报一次 ERROR 交对账器接手,之后不再自己试
* TC-NETLIS-019 请求去重:已有请求时后来的 reason 不覆盖首个
* TC-NETLIS-020 隔离级沉降:关不掉就等,且**只在第一次**打 INFO
* TC-NETLIS-021 隔离级 hold = 400ms(399ms 不放行、1400ms 放行)
* TC-NETLIS-021b 优雅级 hold = 100ms(99ms 不放行、1100ms 放行)
* TC-NETLIS-022 800ms 上限**只留痕一次且不放弃**,到 2000ms 才交给对账器
* TC-NETLIS-023 清理接收区:单次读钳到 256 字节、最多 16 轮(读到空即停)
* TC-NETLIS-024 芯片调用顺序:清接收 → 关断 → 读即清 → 读状态
* TC-NETLIS-025 读即清真的清零(第二次读到 0)
* TC-NETLIS-026 表项不存在由 tick 直接放弃(net_relisten_locked 里的 BADF 分支
* 从 tick 不可达 —— 死分支留痕,见注释)
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-23
*/
#include <stdio.h>
#include <string.h>
#include "net_listener.h"
#include "net_pending.h"
#include "net_socket.h"
#include "ch395f.h"
/* 假芯片实现与生产源进同一个 TU(本仓主机单测既有做法) */
#include "ch395f_fake.c"
#include "net_listener.c"
/* ===================== 断言框架 ===================== */
static int s_total;
static int s_passed;
static int s_failed;
#define CHECK(cond, name) do { \
s_total++; \
if (cond) { s_passed++; printf(" PASS %s\n", name); } \
else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \
} while (0)
/* ===================== dbg_log 桩计数 ===================== */
int g_nlis_info_count;
int g_nlis_error_count;
int g_nlis_debug_count;
/* ===================== 可控 socket 表 ===================== */
static net_sock_t g_sock[NET_MAX_SOCKETS];
static uint8_t g_sock_valid[NET_MAX_SOCKETS];
net_sock_t *net_get_sock(int sockfd)
{
if ((sockfd < 0) || (sockfd >= NET_MAX_SOCKETS)) {
return NULL;
}
if (g_sock_valid[sockfd] == 0u) {
return NULL;
}
return &g_sock[sockfd];
}
/* ===================== 挂起 recv / send 的有无 ===================== */
static int g_pending[NET_MAX_SOCKETS];
static int g_send_pending[NET_MAX_SOCKETS];
int net_pending_active(int sockfd)
{
if ((sockfd < 0) || (sockfd >= NET_MAX_SOCKETS)) {
return 0;
}
return g_pending[sockfd];
}
int net_send_pending_active(int sockfd)
{
if ((sockfd < 0) || (sockfd >= NET_MAX_SOCKETS)) {
return 0;
}
return g_send_pending[sockfd];
}
/* ===================== 可控时基与 errno ===================== */
static uint32_t g_now;
static int g_errno_last;
static int g_errno_calls;
uint32_t HAL_GetTick(void)
{
return g_now; /* 由测试显式设定:400/100/800/2000 四条线要能卡点 */
}
int net_set_errno(int err)
{
g_errno_last = err;
g_errno_calls++;
return err;
}
/* ===================== 复位与建表 ===================== */
static void reset_all(void)
{
(void)memset(g_sock, 0, sizeof(g_sock));
(void)memset(g_sock_valid, 0, sizeof(g_sock_valid));
(void)memset(g_pending, 0, sizeof(g_pending));
(void)memset(g_send_pending, 0, sizeof(g_send_pending));
g_now = 1000u;
g_errno_last = 0;
g_errno_calls = 0;
g_nlis_info_count = 0;
g_nlis_error_count = 0;
g_nlis_debug_count = 0;
ch395f_fake_reset();
/* 关键:被测模块的 static 状态(s_relisten_req / _next / _dead / _quarantine /
* _settle_start / _settle_dead / _capped)不会随假世界一起复位。上一场景残留的
* next/dead 会把后面每一条时序断言都带偏(实测一次红了 9 条)。用模块自己的
* net_listener_clear() 清——它清的正是这些项,也顺带验证了 clear 的完整性。 */
for (int i = 0; i < NET_MAX_SOCKETS; i++) {
net_listener_clear(i);
}
}
/* 一个"刚断线、等待重开监听"的槽位 */
static void mk_closed_listen(int fd, uint16_t port)
{
g_sock_valid[fd] = 1u;
g_sock[fd].in_use = 1u;
g_sock[fd].type = NET_SOCK_STREAM;
g_sock[fd].state = NET_SOCK_STATE_CLOSED;
g_sock[fd].auto_relisten = 1u;
g_sock[fd].local_port = port;
}
/* ===================== 边界与去重 ===================== */
static void tc_bounds(void)
{
printf("-- TC-NETLIS-001/019 bounds --\n");
reset_all();
net_listener_settle(-1, NET_LISTENER_SETTLE_GRACEFUL);
net_listener_settle(NET_MAX_SOCKETS, NET_LISTENER_SETTLE_QUARANTINE);
net_listener_request(-1, NET_LISTENER_REQ_RECON);
net_listener_request(NET_MAX_SOCKETS, NET_LISTENER_REQ_RECON);
net_listener_clear(-1);
net_listener_clear(NET_MAX_SOCKETS);
net_listener_tick();
CHECK((g_chip.open_calls == 0) && (g_chip.close_calls == 0) &&
(g_nlis_error_count == 0),
"TC-NETLIS-001 越界 fd 静默返回,不打扰芯片");
reset_all();
mk_closed_listen(0, 2404);
net_listener_request(0, NET_LISTENER_REQ_ACCEPT);
net_listener_request(0, NET_LISTENER_REQ_DROP); /* 应被忽略 */
CHECK(s_relisten_req[0] == (uint8_t)(NET_LISTENER_REQ_ACCEPT + 1U),
"TC-NETLIS-019 请求去重:后来的 reason 不覆盖首个");
}
/* ===================== 该不该重建 ===================== */
static void tc_should_or_not(void)
{
printf("-- TC-NETLIS-010..013 --\n");
reset_all();
mk_closed_listen(0, 2404);
ch395f_fake_set_listening(0); /* 芯片说:还在 LISTEN */
net_listener_request(0, NET_LISTENER_REQ_ACCEPT);
net_listener_tick();
CHECK((g_chip.open_calls == 0) && (s_relisten_req[0] == 0u),
"TC-NETLIS-010 监听仍活着就不重建,且请求已 clear");
g_fake.cached_open[0] = CH395F_SOCKET_CLOSED; /* 之后才真死了 */
net_listener_request(0, NET_LISTENER_REQ_RECON);
net_listener_tick();
CHECK(g_chip.open_calls == 1,
"TC-NETLIS-010b 上一步确实清掉了请求(否则这里会被去重吞掉)");
reset_all();
mk_closed_listen(0, 2404);
g_pending[0] = 1;
net_listener_request(0, NET_LISTENER_REQ_EOF);
net_listener_tick();
CHECK((g_chip.open_calls == 0) && (s_relisten_req[0] != 0u),
"TC-NETLIS-011 挂起 recv 未结束时让路但保留请求");
g_pending[0] = 0;
g_send_pending[0] = 1;
net_listener_tick();
CHECK((g_chip.open_calls == 0) && (s_relisten_req[0] != 0u),
"TC-NETLIS-011b 挂起 send 同样挡路");
g_send_pending[0] = 0;
net_listener_tick();
CHECK((g_chip.open_calls == 1) && (s_relisten_req[0] == 0u),
"TC-NETLIS-011c 挂起结束后自动补上重建");
reset_all();
mk_closed_listen(0, 2404);
net_listener_request(0, NET_LISTENER_REQ_RECON);
net_listener_tick();
net_listener_tick();
CHECK(g_chip.open_calls == 1, "TC-NETLIS-013 重建成功后不重复重建");
}
static void tc_giveup_conditions(void)
{
int i;
int opened[5];
int cleared[5];
int errs[5];
printf("-- TC-NETLIS-012 五种放弃条件 --\n");
reset_all();
for (i = 0; i < 5; i++) {
uint8_t before_open = (uint8_t)g_chip.open_calls;
(void)before_open;
reset_all();
mk_closed_listen(0, 2404);
switch (i) {
case 0: g_sock[0].auto_relisten = 0u; break; /* 明确不自动重开 */
case 1: g_sock[0].type = (NET_SOCK_STREAM + 1); break; /* 不是 TCP */
case 2: g_sock[0].local_port = 0u; break; /* 没绑端口 */
case 3: g_sock[0].in_use = 0u; break; /* 槽位已释放 */
default: g_sock[0].state = NET_SOCK_STATE_ESTABLISHED; break;
}
net_listener_request(0, NET_LISTENER_REQ_RECON);
net_listener_tick();
opened[i] = g_chip.open_calls;
cleared[i] = (s_relisten_req[0] == 0u) ? 1 : 0;
errs[i] = g_nlis_error_count;
}
CHECK((opened[0] == 0) && (opened[1] == 0) && (opened[2] == 0) &&
(opened[3] == 0) && (opened[4] == 0),
"TC-NETLIS-012 五种条件下都不碰芯片");
CHECK((cleared[0] == 1) && (cleared[1] == 1) && (cleared[2] == 1) &&
(cleared[3] == 1) && (cleared[4] == 1),
"TC-NETLIS-012b 放弃即清请求(不留在表里反复白试)");
CHECK((errs[0] == 0) && (errs[2] == 0) && (errs[4] == 0),
"TC-NETLIS-012c 放弃是静默的(不该报 ERROR,那是对账器的活)");
reset_all();
g_sock_valid[0] = 0u; /* 连表项都没有 */
net_listener_request(0, NET_LISTENER_REQ_RECON);
net_listener_tick();
CHECK((g_chip.open_calls == 0) && (s_relisten_req[0] == 0u),
"TC-NETLIS-026 表项不存在由 tick 直接放弃");
/* 注:net_relisten_locked() 里的 NET_ERR_BADF 分支因此从 tick **不可达**
* (net_listener.c:112 那段只在 p_sock 非空时才可能被调到)——留痕:它是
* 内部兜底,不是活路径;将来若新增调用点,要重新审视这条。 */
}
/* ===================== 失败退避与预算 ===================== */
static void tc_backoff_and_budget(void)
{
printf("-- TC-NETLIS-014/015/018 --\n");
reset_all();
mk_closed_listen(0, 2404);
g_fake.open_rc[0] = 0x01U; /* open 永远失败 */
g_now = 1000u;
net_listener_request(0, NET_LISTENER_REQ_RECON);
net_listener_tick();
CHECK((g_chip.open_calls == 1) && (g_chip.close_calls == 1) &&
(g_errno_last == NET_ERR_BUSY) && (s_relisten_req[0] != 0u) &&
(s_relisten_next[0] == 1030u) && (s_relisten_dead[0] == 3000u),
"TC-NETLIS-014 open 失败:关回去 + BUSY + 30ms 退避 + 首轮建 2000ms 预算");
g_now = 1020u;
net_listener_tick();
CHECK(g_chip.open_calls == 1, "TC-NETLIS-014b 退避未到不重试(不空转打芯片)");
g_now = 1030u;
net_listener_tick();
CHECK(g_chip.open_calls == 2, "TC-NETLIS-014c 退避到点再试一次");
reset_all();
mk_closed_listen(0, 2404);
g_fake.listen_rc[0] = 0x01U; /* open 成功、listen 失败 */
net_listener_request(0, NET_LISTENER_REQ_RECON);
net_listener_tick();
CHECK((g_chip.listen_calls == 1) && (g_chip.close_calls == 1) &&
(s_relisten_req[0] != 0u),
"TC-NETLIS-015 listen 失败也要关断(不留下开了一半的 socket)");
reset_all();
mk_closed_listen(0, 2404);
g_fake.open_rc[0] = 0x01U;
g_now = 1000u;
net_listener_request(0, NET_LISTENER_REQ_RECON);
net_listener_tick(); /* 建预算 dead=3000 */
g_fake.open_rc[0] = CH395F_ERR_SUCCESS; /* 芯片后来能开了 */
g_now = 3000u; /* 但预算已耗尽 */
net_listener_tick();
CHECK((g_nlis_error_count == 1) && (s_relisten_req[0] == 0u) &&
(g_chip.open_calls == 1),
"TC-NETLIS-018 预算耗尽:报一次 ERROR 交对账器,之后不再自己试");
}
/* ===================== 成功重建的簿记 ===================== */
static void tc_bookkeeping(void)
{
net_sock_t *p;
printf("-- TC-NETLIS-016/017 bookkeeping --\n");
reset_all();
mk_closed_listen(0, 2404);
p = &g_sock[0];
p->state = NET_SOCK_STATE_CLOSED;
p->remote_port = 1234u;
p->remote_ip = 0x0A000001u;
p->remote_ip_arr[0] = 9u;
p->remote_ip_arr[3] = 7u;
p->send_ready = 1u;
p->recv_bytes = 77u;
p->released_abnormal = 1u;
p->lost_conn_seen = 1u;
net_listener_request(0, NET_LISTENER_REQ_DROP);
net_listener_tick();
CHECK((p->state == NET_SOCK_STATE_LISTENING) && (p->send_ready == 0u) &&
(p->recv_bytes == 0u) && (p->remote_port == 0u) &&
(p->remote_ip == 0u),
"TC-NETLIS-016 重建后回到干净监听态");
CHECK((p->last_peer_port == 1234u) && (p->lost_conn_seen == 0u) &&
(p->released_abnormal == 0u) && (p->remote_ip_arr[0] == 0u) &&
(p->remote_ip_arr[3] == 0u),
"TC-NETLIS-016b 旧对端端口进 last_peer_port,其余对端痕迹清零");
CHECK((g_chip.set_port_calls == 1) && (g_chip.last_port == 2404u) &&
(g_chip.set_proto_calls == 1) &&
(g_chip.last_proto == CH395F_PROTO_TYPE_TCP),
"TC-NETLIS-017 源端口与协议取自表项本身(监听端口不能漂)");
}
/* ===================== 沉降窗:隔离 400 / 优雅 100 ===================== */
static void tc_settle_windows(void)
{
printf("-- TC-NETLIS-020/021 沉降窗 --\n");
reset_all();
mk_closed_listen(0, 2404);
g_fake.status_byte0[0] = CH395F_SOCKET_OPEN; /* 芯片关不掉 */
g_fake.status_byte1[0] = CH395F_TCP_ESTABLISHED;
net_listener_settle(0, NET_LISTENER_SETTLE_QUARANTINE);
net_listener_request(0, NET_LISTENER_REQ_DROP);
g_now = 1000u;
net_listener_tick();
CHECK((g_chip.open_calls == 0) && (g_nlis_info_count == 1) &&
(s_relisten_settle_start[0] == 1000u) &&
(s_relisten_settle_dead[0] == 1800u) && (g_errno_last == NET_ERR_BUSY),
"TC-NETLIS-020 关不掉就等:首次记 start 并给 800ms 留痕线,返回 BUSY");
g_now = 1030u;
net_listener_tick();
CHECK(g_nlis_info_count == 1,
"TC-NETLIS-020b 等待期间**只在第一次**打日志(否则每 30ms 刷一条)");
/* 边界必须在**独立场景**里测:放行判定与 30ms 退避共用 s_relisten_next,
* 上一轮等待会把 next 设成"当次 + 30",于是同一条链里"恰好 400ms"那一次 tick
* 根本不会发起尝试。这本身是个该知道的事实——**hold 到点后最坏还要再等一个
* 退避周期才真正放行**(实测最多晚 30ms),写在这里免得后人以为是漏判。 */
reset_all();
mk_closed_listen(0, 2404);
g_fake.status_byte0[0] = CH395F_SOCKET_CLOSED;
g_fake.status_byte1[0] = CH395F_TCP_CLOSED;
net_listener_settle(0, NET_LISTENER_SETTLE_QUARANTINE);
net_listener_request(0, NET_LISTENER_REQ_DROP);
g_now = 1000u;
net_listener_tick(); /* 记 start=1000,next=1030 */
g_now = 1399u;
net_listener_tick();
CHECK((g_chip.open_calls == 0) && (s_relisten_quarantine[0] != 0u),
"TC-NETLIS-021 隔离级 hold=400ms:第 399ms 仍不放行");
reset_all();
mk_closed_listen(0, 2404);
g_fake.status_byte0[0] = CH395F_SOCKET_CLOSED;
g_fake.status_byte1[0] = CH395F_TCP_CLOSED;
net_listener_settle(0, NET_LISTENER_SETTLE_QUARANTINE);
net_listener_request(0, NET_LISTENER_REQ_DROP);
g_now = 1000u;
net_listener_tick();
g_now = 1400u;
net_listener_tick();
CHECK((g_chip.open_calls == 1) && (s_relisten_quarantine[0] == 0u),
"TC-NETLIS-021b 第 400ms 放行并清隔离(独立场景,不受退避槽遮挡)");
reset_all();
mk_closed_listen(0, 2404);
net_listener_settle(0, NET_LISTENER_SETTLE_GRACEFUL);
net_listener_request(0, NET_LISTENER_REQ_EOF);
g_now = 1000u;
net_listener_tick();
CHECK((g_chip.open_calls == 0) && (s_relisten_settle_start[0] == 1000u),
"TC-NETLIS-021c 优雅级同样先等(0 < 100ms)");
g_now = 1099u;
net_listener_tick();
CHECK(g_chip.open_calls == 0, "TC-NETLIS-021c 优雅级 hold=100ms:第 99ms 不放行");
reset_all();
mk_closed_listen(0, 2404);
net_listener_settle(0, NET_LISTENER_SETTLE_GRACEFUL);
net_listener_request(0, NET_LISTENER_REQ_EOF);
g_now = 1000u;
net_listener_tick();
g_now = 1100u;
net_listener_tick();
CHECK(g_chip.open_calls == 1, "TC-NETLIS-021d 优雅级第 100ms 放行(比隔离级快 4 倍)");
}
static void tc_cap_is_trace_only(void)
{
printf("-- TC-NETLIS-022 800ms 上限只留痕 --\n");
reset_all();
mk_closed_listen(0, 2404);
g_fake.status_byte0[0] = CH395F_SOCKET_OPEN; /* 永远关不掉 */
g_fake.status_byte1[0] = CH395F_TCP_ESTABLISHED;
net_listener_settle(0, NET_LISTENER_SETTLE_QUARANTINE);
net_listener_request(0, NET_LISTENER_REQ_PENDING);
g_now = 1000u;
net_listener_tick(); /* start=1000 settle_dead=1800 */
g_now = 1800u;
net_listener_tick();
CHECK((s_relisten_capped[0] == 1u) && (g_nlis_info_count == 2) &&
(g_chip.open_calls == 0) && (s_relisten_req[0] != 0u),
"TC-NETLIS-022 到 800ms 只**留痕一次**,既不重建也**不放弃请求**");
g_now = 1830u;
net_listener_tick();
CHECK(g_nlis_info_count == 2,
"TC-NETLIS-022b 留痕只打一次(之后继续默默等,不刷屏)");
g_now = 3000u;
net_listener_tick();
CHECK((g_nlis_error_count == 1) && (s_relisten_req[0] == 0u),
"TC-NETLIS-022c 最终由 2000ms 预算接手:报 ERROR 并交出请求");
}
/* ===================== 芯片交互:清理、顺序、读即清 ===================== */
static void tc_chip_interaction(void)
{
printf("-- TC-NETLIS-023/024/025 --\n");
reset_all();
mk_closed_listen(0, 2404);
net_listener_settle(0, NET_LISTENER_SETTLE_GRACEFUL);
ch395f_fake_push_recv_len(0, 1000u); /* 一直有 1000 字节 */
net_listener_request(0, NET_LISTENER_REQ_DROP);
g_now = 1000u;
net_listener_tick();
CHECK((g_chip.read_calls == 16) && (g_chip.last_read_len == 256u),
"TC-NETLIS-023 清接收区:单次钳到 256 字节、最多 16 轮(有守卫不死循环)");
reset_all();
mk_closed_listen(0, 2404);
net_listener_settle(0, NET_LISTENER_SETTLE_GRACEFUL);
ch395f_fake_push_recv_len(0, 5u);
ch395f_fake_push_recv_len(0, 0u); /* 第二轮就空了 */
net_listener_request(0, NET_LISTENER_REQ_DROP);
g_now = 1000u;
net_listener_tick();
CHECK((g_chip.read_calls == 1) && (g_chip.last_read_len == 5u),
"TC-NETLIS-023b 读到空立刻停手(不硬凑 16 轮)");
reset_all();
mk_closed_listen(0, 2404);
g_fake.status_byte0[0] = CH395F_SOCKET_OPEN; /* 停在等待分支,便于看顺序 */
net_listener_settle(0, NET_LISTENER_SETTLE_QUARANTINE);
net_listener_request(0, NET_LISTENER_REQ_DROP);
g_now = 1000u;
net_listener_tick();
CHECK((strcmp(g_chip.log, "Rcis") == 0) &&
(g_chip.int_status_calls == 1) && (g_chip.get_status_calls == 1),
"TC-NETLIS-024 顺序:清接收 → 关断 → 读即清 → 读状态(不可换)");
reset_all();
mk_closed_listen(0, 2404);
g_fake.int_status[0] = 0x3FU;
g_fake.status_byte0[0] = CH395F_SOCKET_OPEN;
net_listener_settle(0, NET_LISTENER_SETTLE_QUARANTINE);
net_listener_request(0, NET_LISTENER_REQ_DROP);
g_now = 1000u;
net_listener_tick();
CHECK((g_chip.int_status_calls >= 1) && (g_fake.int_status[0] == 0u),
"TC-NETLIS-025 读即清真的清零(复刻芯片无按位清除的怪癖)");
}
/* ===================== 入口 ===================== */
int main(void)
{
printf("=== NET Listener Tests ===\n");
tc_bounds();
tc_should_or_not();
tc_giveup_conditions();
tc_backoff_and_budget();
tc_bookkeeping();
tc_settle_windows();
tc_cap_is_trace_only();
tc_chip_interaction();
printf("\n=== NET Listener Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""NET 层监听自动重建(net_listener)的主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/net_listener/net_listener_test.c(以 #include 纳入生产
源 Drivers/BSP/NET/net_listener.c 与共享假芯片 test/host/common/ch395f_fake.c),
在 PC 上离线验证自动重开监听的整套闸门:请求去重、五种放弃条件、挂起 recv/send
让路、30ms 退避、400/100ms 两级沉降窗、800ms 上限只留痕、2000ms 预算交对账器、
接收区清理的 256B 钳位与 16 轮上限、重建后的 peer 簿记与芯片调用顺序。
工具链与判定委托 test/host/common/unit_test_harness.py。
为什么要主机侧单测(而不是只用台架):
CH395F 是单连接 socket——监听与数据通道复用同一号,断线后必须重开成 LISTEN,否则
端口"连接被拒"。而重开时机取决于"芯片此刻报什么状态",这些状态真机上一闪而过,
要凑齐对端行为、时序与总线才见得到一次。四道时间闸门(30/400·100/800/2000ms)互相
牵制,台架无法稳定复现;把 HAL_GetTick 做成显式量后,399ms 与 400ms 的差别就是一行
断言。GET_SOCK_INT_STATUS 是整体读即清("顺手全量清一遍"会把别的在用 socket 的门
焊死),必须由假芯片复刻才照得出来。
桩的边界:net_listener.h / net_types.h / net_config.h / net_pending.h 用真实头(net_sock_t
字段名与各枚举一改即编译期报错);net_socket.h 是桩(真实那份带 netTask 消息队列,
PC 上编不动),但 net_set_errno 与 NET_ERR_RETURN_1 的写法照抄真实头同名宏;
ch395f.h 只做转发,真身在 test/host/common/ch395f_fake.h/.c —— NET 域三个模块共享
一份假芯片,签名与常量只有一处定义。main.h / cmsis_os.h / FreeRTOS.h / task.h /
test_config.h / net_latency_test.h / dbg_log.h 是本目录桩;刻意不定义
TEST_LATENCY_PROBE(延迟探针不是被测对象,且它要写真实时间)。
用法(仓库根目录):
python test/host/net_listener/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误。
设计依据:docs/网络/网络自愈架构设计.md §4.10、docs/测试说明/主机单测覆盖率口径.md §9(假 CH395F 实施规格)
创建日期:2026-10-23
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
# 顺序敏感:stub 必须最先(覆盖 ch395f.h / net_socket.h / main.h / dbg_log.h);
# common 提供共享假芯片;NET 目录提供真实的 net_types.h / net_pending.h 等。
BUILD = TestBuild(
name="NET Listener Tests",
src=os.path.join(HERE, "net_listener_test.c"),
include_dirs=[
os.path.join(HERE, "stub"),
os.path.join(ROOT, "test", "host", "common"),
os.path.join(ROOT, "Drivers", "BSP", "NET"),
HERE,
],
summary_marker="=== NET Listener Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+19
View File
@@ -0,0 +1,19 @@
/*
* 模块名称:主机侧单测桩 - FreeRTOS.h(net_listener 模块专用)
* 模块功能:本模块不调任何内核 API,但真实 net_pending.h 的注册类原型里带
* TaskHandle_t 参数(include 链绕不开),故这里给出它的宿主等价物。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* TaskHandle_t 在 FreeRTOS 里就是 void * 不透明句柄,这里保持一致——它只出现在
* 原型签名中、net_listener.c 从不传它,所以宿主侧用什么表示都不影响被测语义;
* 但**原型必须能编**,否则真实 net_pending.h 就进不来(进不来就丢掉接口真实性)。
* 创建日期:2026-10-23
*/
#ifndef FREERTOS_H
#define FREERTOS_H
#include <stddef.h>
#include <stdint.h>
typedef void *TaskHandle_t; /* 与 FreeRTOS 一致:不透明指针句柄 */
#endif /* FREERTOS_H */
+11
View File
@@ -0,0 +1,11 @@
/*
* 模块名称:主机侧单测桩 - ch395f.h(net_listener 模块入口)
* 模块功能:本文件**只做转发**——真正的假芯片在 test/host/common/ch395f_fake.h/.c。
* ⚠ 转发而不是各写一份:NET 域三个模块共用同一份假芯片,签名与常量只有一处定义,
* 避免"三份桩各自漂移、假件与被测代码同时错却互相圆场"。
* 创建日期:2026-10-23
*/
#ifndef CH395F_H
#define CH395F_H
#include "ch395f_fake.h"
#endif /* CH395F_H */
+10
View File
@@ -0,0 +1,10 @@
/*
* 模块名称:主机侧单测桩 - cmsis_os.h(net_listener 模块专用)
* 模块功能:空壳。net_listener.c include 它但不用内核 API(重建在 tick 里同步做完)。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef CMSIS_OS_H
#define CMSIS_OS_H
#include <stdint.h>
#endif /* CMSIS_OS_H */
+16
View File
@@ -0,0 +1,16 @@
/*
* 模块名称:主机侧单测桩 - dbg_log.h(net_listener 模块专用)
* 模块功能:DBG_* 换成计数,让"只在第一次打日志""上限只留痕一次""预算耗尽才报 ERROR"
* 这三条日志契约可断言——它们正是排期登记里"沉降窗 800ms 上限只留痕"的原话。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef DBG_LOG_H
#define DBG_LOG_H
extern int g_nlis_info_count;
extern int g_nlis_error_count;
extern int g_nlis_debug_count;
#define DBG_INFO(fmt, ...) do { (void)(fmt); g_nlis_info_count++; } while (0)
#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_nlis_debug_count++; } while (0)
#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_nlis_error_count++; } while (0)
#endif /* DBG_LOG_H */
+12
View File
@@ -0,0 +1,12 @@
/*
* 模块名称:主机侧单测桩 - main.h(net_listener 模块专用)
* 模块功能:只给 HAL_GetTick(沉降窗/退避/预算三条时间轴的唯一致度源)。
* ⚠ 仅供 test/host/net_listener/ 使用,不参与 MCU 构建。测试把 tick 做成"写多少
* 就是多少"的显式量,于是 400ms / 100ms / 800ms / 2000ms 四条线都能精确卡点。
* 创建日期:2026-10-23
*/
#ifndef MAIN_H
#define MAIN_H
#include <stdint.h>
uint32_t HAL_GetTick(void);
#endif /* MAIN_H */
@@ -0,0 +1,9 @@
/*
* 模块名称:主机侧单测桩 - net_latency_test.h(net_listener 模块专用)
* 模块功能:空壳,配合 TEST_LATENCY_PROBE 不定义即被全部条件编译掉。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef NET_LATENCY_TEST_H
#define NET_LATENCY_TEST_H
#endif /* NET_LATENCY_TEST_H */
+20
View File
@@ -0,0 +1,20 @@
/*
* 模块名称:主机侧单测桩 - net_socket.h(NET 域共享形态)
* 模块功能:net_listener.c 需要 socket 表项类型与三个入口:net_get_sock()(本表项
* 可变指针,重建时要写回簿记)、net_set_errno() 与 NET_ERR_RETURN_1()。
* 状态类型与错误码常量一律取**真实** net_types.h(字段名/取值一改即编译期
* 报错);net_set_errno 与 NET_ERR_RETURN_1 的写法照抄真实 net_socket.h:528/532。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。刻意不 include 真实 net_socket.h:它带着
* 整条 netTask 消息队列与芯片依赖,主机侧编不动。
* 创建日期:2026-10-23
*/
#ifndef NET_SOCKET_H
#define NET_SOCKET_H
#include "net_types.h"
net_sock_t *net_get_sock(int sockfd);
int net_set_errno(int err);
#define NET_ERR_RETURN_1(err) (net_set_errno(err), -1)
#endif /* NET_SOCKET_H */
+9
View File
@@ -0,0 +1,9 @@
/*
* 模块名称:主机侧单测桩 - task.h(net_listener 模块专用)
* 模块功能:空壳。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef TASK_H
#define TASK_H
#endif /* TASK_H */
+10
View File
@@ -0,0 +1,10 @@
/*
* 模块名称:主机侧单测桩 - test_config.h(net_listener 模块专用)
* 模块功能:空壳。net_listener.c 只在 TEST_LATENCY_PROBE 打开时才用延迟探针宏,
* 主机单测刻意**不开**该宏(探针不是被测对象,且它要写真实时间)。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef TEST_CONFIG_H
#define TEST_CONFIG_H
#endif /* TEST_CONFIG_H */
+467
View File
@@ -0,0 +1,467 @@
/*
* 模块名称:NET 层挂起 recv / 挂起 send(net_pending)主机侧单元测试
* 模块功能:在 PC 上离线验证 Drivers/BSP/NET/net_pending.c 的两条挂起队列:注册闸门
* (越界/重复/执行任务自己发起)、**错误码经任务通知值回传**这条跨上下文
* 契约、收数据到达与截断、超时、会话塌掉的四种收尾分支(不丢尾包 / 干净
* 结束报 0 / 异常释放报 CONNRESET / 槽位消失报 CONNRESET)、settle_close 的
* 收尾语义,以及发送侧的分块上限、停滞判定、deadline 续期与取消语义。
*
* 被测对象:真实源文件 ../../../Drivers/BSP/NET/net_pending.c(#include 原样纳入)。
* net_pending.h / net_types.h / net_config.h / net_listener.h 全用**真实头**
* (TaskHandle_t 入参、NET_PENDING_REGISTERED、NET_SEND_TIMEOUT_MS、
* net_listener_req_t 枚举一改就编译期报错)。假件:
* - 假 CH395F 芯片(test/host/common/ch395f_fake.h/.c,含与真实头同构转抄
* 的缓冲尺寸宏链,NET_SEND_CHUNK_MAX 因此是真值 1024 而非假件自造);
* - **可观测的 xTaskNotify**(记目标句柄与通知值)与固定句柄的
* xTaskGetCurrentTaskHandle;
* - 记录型 net_listener_request;可控时基;可控 socket 表数组(tick 入参);
* 可控"芯片此刻还有多少字节可读";dbg_log 计数;net_set_errno 记录。
*
* 为什么要主机侧单测(而不是只靠台架):
* 1. AGENTS 模块约定写着:net_* 公开 API 的错误码经**任务通知值**回传,因为 net_msg_t
* 是**按值拷入队列**的,netTask 改的是自己那份副本,调用方永远看不到;也**勿给
* net_msg_t 加回传字段**(加了只会让调用方读到栈垃圾)。这条契约在真机上表现为
* "偶发拿到 0 或正数却不知道为什么",日志无痕;在 PC 上必须把 xTaskNotify 做成
* 可观测的才谈得上验证——台架根本读不到通知值。
* 2. 挂起 recv 的收尾有**四条**分支(还有尾包 / 干净结束报 0 / 异常释放报 CONNRESET /
* 槽位没了报 CONNRESET),差一条就是"对端已断而我方任务还挂着"或"最后一包数据
* 被丢掉"。真机要凑齐"关闭瞬间芯片接收区还剩字节"这种窗口极难。
* 3. 发送侧分块上限 = min(芯片发送缓冲, SPI DMA 上界):一次写太多会踩芯片 FIFO,
* 表现为"大包发送偶发数据错位";而 deadline 每写一轮就续期,写错就会把**长文件
* 在传输中途掐死**(FTP 上传大文件最容易撞)。两者都要能精确数着字节测。
*
* 覆盖点:TC-NETPEND-001..006 注册闸门;007..018 到达/截断/超时/四条收尾/取消与清理;
* 019..021 settle_close;030..040 挂起发送(参数校验、分块、续期、停滞、取消)。
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-23
*/
#include <stdio.h>
#include <string.h>
#include "net_pending.h"
#include "net_listener.h"
#include "net_socket.h"
#include "ch395f.h"
#include "ch395f_fake.c"
#include "net_pending.c"
static int s_total;
static int s_passed;
static int s_failed;
#define CHECK(cond, name) do { \
s_total++; \
if (cond) { s_passed++; printf(" PASS %s\n", name); } \
else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \
} while (0)
int g_npd_info_count;
int g_npd_error_count;
int g_npd_debug_count;
/* ============ 可观测的任务通知(本模块最重要的一条契约靠它才测得到) ============ */
static int g_owner_obj;
static int g_caller_obj;
static int g_notify_n;
static void *g_notify_target[16];
static uint32_t g_notify_value[16];
TaskHandle_t xTaskGetCurrentTaskHandle(void)
{
return (TaskHandle_t)&g_owner_obj;
}
BaseType_t xTaskNotify(TaskHandle_t xTaskToNotify, uint32_t ulValue, eNotifyAction eAction)
{
(void)eAction;
if (g_notify_n < (int)(sizeof(g_notify_target) / sizeof(g_notify_target[0]))) {
g_notify_target[g_notify_n] = (void *)xTaskToNotify;
g_notify_value[g_notify_n] = ulValue;
}
g_notify_n++;
return 1;
}
/* ============ 记录型监听请求 ============ */
static int g_lis_req_n;
static int g_lis_req_sock;
static int g_lis_req_reason;
void net_listener_request(int sockfd, net_listener_req_t reason)
{
g_lis_req_n++;
g_lis_req_sock = sockfd;
g_lis_req_reason = (int)reason;
}
void net_listener_settle(int sockfd, net_listener_settle_t level) { (void)sockfd; (void)level; }
void net_listener_clear(int sockfd) { (void)sockfd; }
void net_listener_tick(void) { }
/* ============ 可控时基与 errno ============ */
static uint32_t g_now;
static int g_errno_last;
static int g_errno_calls;
uint32_t HAL_GetTick(void) { return g_now; }
int net_set_errno(int err)
{
g_errno_last = err;
g_errno_calls++;
return err;
}
/* ============ 可控 socket 表(tick 的入参) ============ */
static net_sock_t g_socks[NET_MAX_SOCKETS];
static uint8_t g_rbuf[64];
static uint8_t g_wbuf[4096];
static void mk_sock(int fd, net_sock_state_t st)
{
g_socks[fd].in_use = 1u;
g_socks[fd].state = st;
g_socks[fd].type = NET_SOCK_STREAM;
g_socks[fd].auto_relisten = 1u;
g_socks[fd].local_port = 2404u;
}
static void reset_all(void)
{
int i;
(void)memset(g_socks, 0, sizeof(g_socks));
(void)memset(g_rbuf, 0, sizeof(g_rbuf));
g_now = 1000u;
g_errno_last = 0;
g_errno_calls = 0;
g_notify_n = 0;
g_lis_req_n = 0;
g_lis_req_sock = -1;
g_lis_req_reason = -1;
g_npd_info_count = 0;
g_npd_error_count = 0;
g_npd_debug_count = 0;
ch395f_fake_reset();
/* 被测模块自己的 static 必须复位:两张挂起表 + 属主句柄
(net_listener 批次在这里红过 9 条,同样的坑不踩第二遍)。 */
for (i = 0; i < NET_MAX_SOCKETS; i++) {
s_net_pending[i].active = 0u;
s_net_send[i].active = 0u;
}
s_owner_task = (TaskHandle_t)&g_owner_obj;
}
static void tc_recv_register(void)
{
int rc;
printf("-- TC-NETPEND-001..006 recv register --\n");
reset_all();
rc = net_pending_register(-1, g_rbuf, 10, 100u, (TaskHandle_t)&g_caller_obj);
CHECK((rc == -1) && (g_errno_last == NET_ERR_BADF), "TC-NETPEND-001 下越界 fd 报 BADF");
rc = net_pending_register(NET_MAX_SOCKETS, g_rbuf, 10, 100u, (TaskHandle_t)&g_caller_obj);
CHECK((rc == -1) && (g_errno_last == NET_ERR_BADF), "TC-NETPEND-001b 上越界同样拦下");
reset_all();
rc = net_pending_register(0, g_rbuf, 10, 100u, (TaskHandle_t)&g_caller_obj);
CHECK(rc == NET_PENDING_REGISTERED, "TC-NETPEND-005 注册成功回 0x70000000 标记");
rc = net_pending_register(0, g_rbuf, 10, 100u, (TaskHandle_t)&g_caller_obj);
CHECK((rc == -1) && (g_errno_last == NET_ERR_BUSY),
"TC-NETPEND-002 同 fd 已有挂起就拒绝(一条 socket 只挂一个 recv)");
reset_all();
rc = net_pending_register(0, g_rbuf, 10, 100u, NULL);
CHECK((rc == -1) && (g_errno_last == NET_ERR_BUSY),
"TC-NETPEND-003 caller 为空拒绝(没有回传目标就别挂起)");
rc = net_pending_register(0, g_rbuf, 10, 100u, (TaskHandle_t)&g_owner_obj);
CHECK((rc == -1) && (g_errno_last == NET_ERR_BUSY),
"TC-NETPEND-004 属主任务自己发起被拒(自己等自己 = 死锁 netTask)");
reset_all();
/* 必须先把槽位置成"在用且已建立":否则 tick 第一条兜底(in_use == 0 →
* CONNRESET)就会把挂起收掉,测的就不是时间轴了。 */
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
(void)net_pending_register(0, g_rbuf, 10, 0u, (TaskHandle_t)&g_caller_obj);
CHECK((s_net_pending[0].infinite == 1u) && (s_net_pending[0].deadline == 0u),
"TC-NETPEND-006 to_ms=0 记为无限等(不置 deadline)");
g_now = 999999u;
net_pending_tick(g_socks);
CHECK((s_net_pending[0].active == 1u) && (g_notify_n == 0),
"TC-NETPEND-006b 无限等不被时间掐掉(也不会偷偷发通知)");
}
static void tc_recv_paths(void)
{
printf("-- TC-NETPEND-007..018 recv completion --\n");
reset_all();
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
g_socks[0].readable = 1u;
(void)net_pending_register(0, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj);
ch395f_fake_push_recv_len(0, 5u);
net_pending_tick(g_socks);
CHECK((g_notify_n == 1) && (g_notify_value[0] == 5u) &&
(g_notify_target[0] == (void *)&g_caller_obj) &&
(g_socks[0].recv_bytes == 5u) && (g_socks[0].readable == 0u) &&
(s_net_pending[0].active == 0u),
"TC-NETPEND-007 数据到达:字节数经通知值回传给发起任务");
reset_all();
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
g_socks[0].readable = 1u;
(void)net_pending_register(0, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj);
ch395f_fake_push_recv_len(0, 100u);
net_pending_tick(g_socks);
CHECK((g_notify_value[0] == 10u) && (g_chip.last_read_len == 10u),
"TC-NETPEND-008 读量钳在调用方缓冲内(绝不越界写别人的 buf)");
reset_all();
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
g_socks[0].readable = 1u;
(void)net_pending_register(0, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj);
net_pending_tick(g_socks);
CHECK((g_notify_n == 0) && (g_socks[0].readable == 0u) &&
(s_net_pending[0].active == 1u),
"TC-NETPEND-009 空 readable:清标志继续等,不误交 0 字节");
reset_all();
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
(void)net_pending_register(0, g_rbuf, 10, 100u, (TaskHandle_t)&g_caller_obj);
g_now = 1100u;
net_pending_tick(g_socks);
CHECK((g_notify_value[0] == (uint32_t)NET_ERR_TIMEDOUT) &&
(g_errno_last == NET_ERR_TIMEDOUT) && (s_net_pending[0].active == 0u),
"TC-NETPEND-010 超时:错误码同时进通知值与 errno(跨上下文拿得到)");
reset_all();
(void)net_pending_register(0, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj);
g_socks[0].in_use = 0u;
net_pending_tick(g_socks);
CHECK(g_notify_value[0] == (uint32_t)NET_ERR_CONNRESET,
"TC-NETPEND-011 槽位消失报 CONNRESET(不能让任务熬到超时才发现)");
reset_all();
mk_sock(0, NET_SOCK_STATE_CLOSED);
(void)net_pending_register(0, g_rbuf, 20, 500u, (TaskHandle_t)&g_caller_obj);
ch395f_fake_push_recv_len(0, 12u);
net_pending_tick(g_socks);
CHECK((g_notify_value[0] == 12u) && (g_socks[0].recv_bytes == 12u) &&
(g_lis_req_n == 0),
"TC-NETPEND-012 关闭但有残留:先把最后一包读走再收尾(不丢尾包)");
reset_all();
mk_sock(0, NET_SOCK_STATE_CLOSED);
(void)net_pending_register(0, g_rbuf, 20, 500u, (TaskHandle_t)&g_caller_obj);
net_pending_tick(g_socks);
CHECK((g_notify_value[0] == 0u) && (g_lis_req_n == 1) && (g_lis_req_sock == 0) &&
(g_lis_req_reason == (int)NET_LISTENER_REQ_PENDING),
"TC-NETPEND-013 干净结束:报 0 并请求重开监听(单连接 socket 必须回 LISTEN)");
reset_all();
mk_sock(0, NET_SOCK_STATE_CLOSED);
g_socks[0].auto_relisten = 0u;
(void)net_pending_register(0, g_rbuf, 20, 500u, (TaskHandle_t)&g_caller_obj);
net_pending_tick(g_socks);
CHECK((g_notify_value[0] == 0u) && (g_lis_req_n == 0),
"TC-NETPEND-013b 不自动重开的 socket 不会被偷偷要求重建监听");
reset_all();
mk_sock(0, NET_SOCK_STATE_CLOSED);
g_socks[0].released_abnormal = 1u;
(void)net_pending_register(0, g_rbuf, 20, 500u, (TaskHandle_t)&g_caller_obj);
net_pending_tick(g_socks);
CHECK(g_notify_value[0] == (uint32_t)NET_ERR_CONNRESET,
"TC-NETPEND-014 异常释放报 CONNRESET 而不是 0(调用方要能分辨好坏收尾)");
reset_all();
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
g_socks[0].session_dead = 1u;
(void)net_pending_register(0, g_rbuf, 20, 500u, (TaskHandle_t)&g_caller_obj);
net_pending_tick(g_socks);
CHECK((g_notify_n == 1) && (g_lis_req_n == 1),
"TC-NETPEND-015 session_dead 与 CLOSED 同路(状态滞后时仍能收口)");
reset_all();
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
(void)net_pending_register(0, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj);
net_pending_cancel(0, NET_ERR_CONNRESET);
CHECK((g_notify_value[0] == (uint32_t)NET_ERR_CONNRESET) &&
(g_errno_last == NET_ERR_CONNRESET) && (s_net_pending[0].active == 0u),
"TC-NETPEND-016 取消:错误码经通知值送达并解除挂起");
net_pending_cancel(0, NET_ERR_CONNRESET);
CHECK(g_notify_n == 1, "TC-NETPEND-016b 重复取消幂等(不给同一任务发两次通知)");
reset_all();
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
mk_sock(2, NET_SOCK_STATE_ESTABLISHED);
(void)net_pending_register(0, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj);
(void)net_pending_register(2, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj);
net_pending_cancel_all(NET_ERR_CONNRESET);
CHECK((g_notify_n == 2) && (s_net_pending[0].active == 0u) &&
(s_net_pending[2].active == 0u),
"TC-NETPEND-017 cancel_all 只通知挂着的那两个(网络整体复位时用)");
reset_all();
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
(void)net_pending_register(0, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj);
net_pending_clear(0);
CHECK((s_net_pending[0].active == 0u) && (g_notify_n == 0),
"TC-NETPEND-018 clear 只解除挂起、不发通知(不给调用方假答复)");
}
static void tc_settle_close(void)
{
int before;
printf("-- TC-NETPEND-019..021 settle_close --\n");
reset_all();
mk_sock(0, NET_SOCK_STATE_CLOSED);
before = g_chip.read_calls + g_chip.get_status_calls;
net_pending_settle_close(0, &g_socks[0]);
CHECK((g_chip.read_calls + g_chip.get_status_calls) == before,
"TC-NETPEND-019 没挂起时直接返回(不打扰芯片)");
reset_all();
mk_sock(0, NET_SOCK_STATE_CLOSED);
(void)net_pending_register(0, g_rbuf, 20, 500u, (TaskHandle_t)&g_caller_obj);
ch395f_fake_push_recv_len(0, 7u);
net_pending_settle_close(0, &g_socks[0]);
CHECK((g_notify_value[0] == 7u) && (g_socks[0].recv_bytes == 7u) &&
(g_socks[0].readable == 0u),
"TC-NETPEND-020 收尾时把剩余字节读走并交还字节数(收口不丢数据)");
reset_all();
mk_sock(0, NET_SOCK_STATE_CLOSED);
(void)net_pending_register(0, g_rbuf, 20, 500u, (TaskHandle_t)&g_caller_obj);
net_pending_settle_close(0, &g_socks[0]);
CHECK(g_notify_value[0] == (uint32_t)NET_ERR_CONNRESET,
"TC-NETPEND-021 无残留则报 CONNRESET(关闭收尾不该被当成读到 0 字节)");
}
static void tc_send_paths(void)
{
int rc;
printf("-- TC-NETPEND-030..040 send pending --\n");
reset_all();
rc = net_send_pending_register(-1, g_wbuf, 100, 0, (TaskHandle_t)&g_caller_obj);
CHECK((rc == -1) && (g_errno_last == NET_ERR_BADF), "TC-NETPEND-030 越界 fd 报 BADF");
rc = net_send_pending_register(0, NULL, 100, 0, (TaskHandle_t)&g_caller_obj);
CHECK(g_errno_last == NET_ERR_INVAL, "TC-NETPEND-030b buf 为空报 INVAL");
rc = net_send_pending_register(0, g_wbuf, 0, 0, (TaskHandle_t)&g_caller_obj);
CHECK(g_errno_last == NET_ERR_INVAL, "TC-NETPEND-030c len 非正报 INVAL");
rc = net_send_pending_register(0, g_wbuf, 100, -1, (TaskHandle_t)&g_caller_obj);
CHECK(g_errno_last == NET_ERR_INVAL, "TC-NETPEND-030d sent 为负报 INVAL");
rc = net_send_pending_register(0, g_wbuf, 100, 100, (TaskHandle_t)&g_caller_obj);
CHECK((g_errno_last == NET_ERR_INVAL) && (s_net_send[0].active == 0u),
"TC-NETPEND-030e sent>=len 报 INVAL(已发完的不必挂)");
rc = net_send_pending_register(0, g_wbuf, 100, 50, NULL);
CHECK(g_errno_last == NET_ERR_BUSY, "TC-NETPEND-031 caller 为空报 BUSY");
rc = net_send_pending_register(0, g_wbuf, 100, 50, (TaskHandle_t)&g_owner_obj);
CHECK(g_errno_last == NET_ERR_BUSY, "TC-NETPEND-031b 属主自己发报 BUSY");
rc = net_send_pending_register(0, g_wbuf, 100, 50, (TaskHandle_t)&g_caller_obj);
CHECK(rc == NET_PENDING_REGISTERED, "TC-NETPEND-032 挂起发送注册成功");
rc = net_send_pending_register(0, g_wbuf, 100, 50, (TaskHandle_t)&g_caller_obj);
CHECK(g_errno_last == NET_ERR_BUSY, "TC-NETPEND-031c 同 fd 只允许一条挂起发送");
reset_all();
(void)net_send_pending_register(0, g_wbuf, 3000, 700, (TaskHandle_t)&g_caller_obj);
g_socks[0].in_use = 0u;
net_send_pending_tick(g_socks);
CHECK((g_notify_n == 1) && (g_notify_value[0] == 700u),
"TC-NETPEND-033 槽位消失回**已发字节数**(不是错误码,调用方据此算进度)");
reset_all();
mk_sock(0, NET_SOCK_STATE_CLOSED);
(void)net_send_pending_register(0, g_wbuf, 3000, 700, (TaskHandle_t)&g_caller_obj);
net_send_pending_tick(g_socks);
CHECK((g_npd_error_count == 1) && (g_errno_last == NET_ERR_CONNRESET) &&
(g_notify_value[0] == 700u),
"TC-NETPEND-034 会话塌了:留 ERROR + 报 CONNRESET,但仍把已发字节交回去");
reset_all();
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
g_socks[0].send_ready = 1u;
(void)net_send_pending_register(0, g_wbuf, 3000, 0, (TaskHandle_t)&g_caller_obj);
net_send_pending_tick(g_socks);
CHECK((g_chip.write_calls == 1) &&
(g_chip.last_read_len == (uint16_t)NET_SEND_CHUNK_MAX) &&
(g_socks[0].send_bytes == (uint32_t)NET_SEND_CHUNK_MAX) &&
(g_socks[0].send_ready == 0u) && (s_net_send[0].active == 1u),
"TC-NETPEND-035 单次写入钳在 NET_SEND_CHUNK_MAX(芯片发送缓冲上界)");
reset_all();
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
(void)net_send_pending_register(0, g_wbuf, 3000, 0, (TaskHandle_t)&g_caller_obj);
g_socks[0].send_ready = 1u;
net_send_pending_tick(g_socks);
g_socks[0].send_ready = 1u;
net_send_pending_tick(g_socks);
g_socks[0].send_ready = 1u;
net_send_pending_tick(g_socks);
CHECK((g_notify_value[0] == 3000u) && (s_net_send[0].active == 0u) &&
(g_chip.write_calls == 3),
"TC-NETPEND-036 分三块发完 3000 字节后交还总长度");
reset_all();
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
(void)net_send_pending_register(0, g_wbuf, 3000, 0, (TaskHandle_t)&g_caller_obj);
g_now = 1900u;
g_socks[0].send_ready = 1u;
net_send_pending_tick(g_socks);
g_now = 2800u;
g_socks[0].send_ready = 1u;
net_send_pending_tick(g_socks);
CHECK((g_npd_error_count == 0) && (s_net_send[0].active == 1u) &&
(g_chip.write_calls == 2),
"TC-NETPEND-038 每写一轮续期 deadline:还在推进就不该被 1000ms 掐死");
reset_all();
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
(void)net_send_pending_register(0, g_wbuf, 3000, 0, (TaskHandle_t)&g_caller_obj);
g_now = 2000u;
net_send_pending_tick(g_socks);
CHECK((g_npd_error_count == 1) && (g_errno_last == NET_ERR_TIMEDOUT) &&
(g_notify_value[0] == 0u),
"TC-NETPEND-037 停滞超时报 TIMEDOUT(对端不收时不能永久占着发送通道)");
reset_all();
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
(void)net_send_pending_register(0, g_wbuf, 100, 40, (TaskHandle_t)&g_caller_obj);
net_send_pending_cancel(0, NET_ERR_CONNRESET);
CHECK((g_npd_error_count == 1) && (g_errno_last == NET_ERR_CONNRESET) &&
(g_notify_value[0] == 40u) && (s_net_send[0].active == 0u),
"TC-NETPEND-039 取消挂起发送:留痕 + 交还已发字节");
net_send_pending_cancel(0, NET_ERR_CONNRESET);
CHECK(g_notify_n == 1, "TC-NETPEND-039b 重复取消幂等");
reset_all();
mk_sock(0, NET_SOCK_STATE_ESTABLISHED);
(void)net_send_pending_register(0, g_wbuf, 100, 40, (TaskHandle_t)&g_caller_obj);
net_send_pending_clear(0);
CHECK((s_net_send[0].active == 0u) && (g_notify_n == 0),
"TC-NETPEND-040 clear 不发通知(与 recv 侧同一条纪律)");
}
int main(void)
{
printf("=== NET Pending Tests ===\n");
tc_recv_register();
tc_recv_paths();
tc_settle_close();
tc_send_paths();
printf("\n=== NET Pending Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""NET 层挂起 recv / 挂起 send(net_pending)主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/net_pending/net_pending_test.c(以 #include 纳入生产源
Drivers/BSP/NET/net_pending.c 与共享假芯片 test/host/common/ch395f_fake.c),在 PC
上离线验证两条挂起队列:注册闸门、错误码经任务通知值回传、收数据到达/截断/超时、
会话塌掉的四条收尾分支、settle_close 语义、发送侧分块上限与停滞判定与 deadline
续期。工具链与判定委托 test/host/common/unit_test_harness.py。
为什么要主机侧单测(而不是只用台架):
本模块的核心契约是"跨上下文回传"——net_msg_t 按值拷入队列,netTask 改的是自己那份
副本,错误码只能搭任务通知值回来(也绝不能给 net_msg_t 加回传字段,否则调用方读到
栈垃圾)。台架根本读不到通知值,所以这条契约在真机上只表现为"偶发拿到 0 或正数却
不知道为何"。另外:收尾四条分支少一条就是"对端已断而我方任务还挂着"或"最后一包数据
被丢掉";发送分块上限踩错会写爆芯片 FIFO;deadline 续期写错会把 FTP 大文件在传输
中途掐死——这些都要能精确数着字节与毫秒测。
桩的边界:net_pending.h / net_types.h / net_config.h / net_listener.h 用真实头
(TaskHandle_t 入参与 NET_PENDING_REGISTERED、NET_SEND_TIMEOUT_MS、net_listener_req_t
一改即编译期报错);net_socket.h 是桩(net_set_errno 与 NET_ERR_RETURN_1 照抄真实头
同名宏);ch395f.h 只转发到共享假芯片(缓冲尺寸宏链与真实头同构转抄,
NET_SEND_CHUNK_MAX 因此是真值 1024);FreeRTOS.h / task.h 是宿主等价壳,但把
xTaskNotify 做成**可观测**假件——没有它,本模块最重要的那条契约根本无法验证。
用法(仓库根目录):
python test/host/net_pending/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误。
设计依据:AGENTS.md 模块约定(错误码经任务通知值回传)、
docs/网络/Net层阻塞模型架构设计.md、docs/测试说明/主机单测覆盖率口径.md §9
创建日期:2026-10-23
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
BUILD = TestBuild(
name="NET Pending Tests",
src=os.path.join(HERE, "net_pending_test.c"),
include_dirs=[
os.path.join(HERE, "stub"),
os.path.join(ROOT, "test", "host", "common"),
os.path.join(ROOT, "Drivers", "BSP", "NET"),
HERE,
],
summary_marker="=== NET Pending Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+28
View File
@@ -0,0 +1,28 @@
/*
* 模块名称:主机侧单测桩 - FreeRTOS.h(net_pending 模块专用)
* 模块功能:net_pending 的整个设计就是"错误码经**任务通知值**回传",所以它必须拿到
* TaskHandle_t / BaseType_t / eNotifyAction 这三个类型才能编译——真实
* net_pending.h 的原型里就带着它们(绕不开,且绕开就丢掉接口真实性)。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。三个宿主等价物与 FreeRTOS 一致:句柄是
* void*,BaseType_t 是 portBASE_TYPE(最小整型),通知动作是三值枚举。
* 创建日期:2026-10-23
*/
#ifndef FREERTOS_H
#define FREERTOS_H
#include <stddef.h>
#include <stdint.h>
typedef void *TaskHandle_t;
typedef long BaseType_t;
typedef unsigned long UBaseType_t;
typedef enum {
eNoAction = 0,
eSetBits = 1,
eIncrement = 2,
eSetValueWithOverwrite = 3,
eSetValueWithoutOverwrite = 4
} eNotifyAction;
#endif /* FREERTOS_H */
+10
View File
@@ -0,0 +1,10 @@
/*
* 模块名称:主机侧单测桩 - ch395f.h(net_pending 模块入口)
* 模块功能:只做转发,真身在 test/host/common/ch395f_fake.h/.c(NET 域共享假芯片)。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef CH395F_H
#define CH395F_H
#include "ch395f_fake.h"
#endif /* CH395F_H */
+18
View File
@@ -0,0 +1,18 @@
/*
* 模块名称:主机侧单测桩 - dbg_log.h(net_pending 模块专用)
* 模块功能:DBG_* 换成计数,让"会话塌了必须留 ERROR""停滞超时必须留 ERROR"可断言。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef DBG_LOG_H
#define DBG_LOG_H
extern int g_npd_info_count;
extern int g_npd_error_count;
extern int g_npd_debug_count;
#define DBG_INFO(fmt, ...) do { (void)(fmt); g_npd_info_count++; } while (0)
#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_npd_debug_count++; } while (0)
#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_npd_error_count++; } while (0)
#endif /* DBG_LOG_H */
+11
View File
@@ -0,0 +1,11 @@
/*
* 模块名称:主机侧单测桩 - main.h(net_pending 模块专用)
* 模块功能:只给 HAL_GetTick(recv 超时窗与 send 停滞窗的时基,由测试显式设定)。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef MAIN_H
#define MAIN_H
#include <stdint.h>
uint32_t HAL_GetTick(void);
#endif /* MAIN_H */
+18
View File
@@ -0,0 +1,18 @@
/*
* 模块名称:主机侧单测桩 - net_socket.h(NET 域共享形态,见 net_listener 同名文件)
* 模块功能:net_pending.c 需要 net_set_errno() 与 NET_ERR_RETURN_1()(写法照抄真实
* net_socket.h:528/532);socket 状态与错误码常量取**真实** net_types.h。
* 本模块**不用** net_get_sock——tick 收的是 net_sock_t 表数组(AGENTS 模块
* 约定:net_get_sock 对未用槽位返回 NULL,而 tick 必须读 in_use==0 兜底)。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef NET_SOCKET_H
#define NET_SOCKET_H
#include "net_types.h"
int net_set_errno(int err);
#define NET_ERR_RETURN_1(err) (net_set_errno(err), -1)
#endif /* NET_SOCKET_H */
+22
View File
@@ -0,0 +1,22 @@
/*
* 模块名称:主机侧单测桩 - task.h(net_pending 模块专用)
* 模块功能:只声明 net_pending 真正用到的两个内核入口,实现由测试单元给出**可观测**
* 的假件:xTaskNotify 把 (目标句柄, 通知值) 记进日志,
* xTaskGetCurrentTaskHandle 返回一个固定的"属主任务"句柄。
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 为什么这两个假件是重点:AGENTS 模块约定写着"net_msg_t 是按值拷入队列的,
* netTask 改的是自己那份副本,调用方永远看不到 → 错误码必须搭通知值回来",
* 还写着"勿给 net_msg_t 加回传字段"。这条契约只有把 xTaskNotify 做成可观测的,
* 才能在主机侧被验证(否则"调用方拿到错误码"这件事在 PC 上根本无法发生)。
* 创建日期:2026-10-23
*/
#ifndef TASK_H
#define TASK_H
#include "FreeRTOS.h"
TaskHandle_t xTaskGetCurrentTaskHandle(void);
BaseType_t xTaskNotify(TaskHandle_t xTaskToNotify, uint32_t ulValue,
eNotifyAction eAction);
#endif /* TASK_H */
+386
View File
@@ -0,0 +1,386 @@
/*
* 模块名称:NET 层 select/poll 事件观测主机侧单元测试
* 模块功能:在 PC 上离线验证 Drivers/BSP/NET/net_select.c 的**就绪判定与超时算术**:
* fd 集只保留就绪位、socket 状态对可读/可写的准入、非法 fd 的处理、
* tv_sec/tv_usec 的换算与截断、poll 的 revents 语义。
*
* 被测对象:真实源文件 ../../../Drivers/BSP/NET/net_select.c(#include 原样纳入)。
* net_select.h / net_types.h / net_config.h 全用**真实头**(net_sock_t 的
* 字段名与 NET_SOCK_STATE_* 枚举一改就编译期报错)。桩四个:main.h
* (HAL_GetTick)、cmsis_os.h(osDelay)、FreeRTOS.h/task.h(空壳),
* 外加 net_socket.h 桩(只声明 net_get_sock,类型仍取真实 net_types.h)
* 与 ch395f.h 空桩(本文件一个芯片函数都不调)。
*
* 为什么要主机侧单测(而不是只靠台架):
* netTask 是**串行 socket 栈**的唯一执行者,且在 IWDG required_mask 里:这里的
* 等待逻辑一旦算错,表现是"整个网络停摆 + 到点喂不上狗复位",而日志里什么都没有。
* 台架上无法构造"没人来连、超时该怎么算"这类边界(对端行为不可控),主机侧把
* tick 与 osDelay 做成可控量后可以精确复现"第几轮该退出去"。
*
* 覆盖点:
* TC-NETSEL-001 nfds = 0 → -1
* TC-NETSEL-002 nfds 为负 → -1
* TC-NETSEL-003 nfds 超过 NET_MAX_SOCKETS → -1(不许越界扫 socket 表)
* TC-NETSEL-004 就绪位回写:入参集合里的未就绪 fd 必须被清掉,只留就绪的
* TC-NETSEL-005 三个集合都可以传 NULL(不崩、不误伤)
* TC-NETSEL-006 timeout=NULL 时无限等,靠"对端事件到来"唤醒(假 osDelay 造事件)
* TC-NETSEL-007 tv_usec 换算是**整除截断**(1999us → 1ms,不是 2ms)
* TC-NETSEL-008 timeout = {0, 0} 只查一轮立即返回(**2026-10-23 修**:原先因
* `(timeout_ms > 0)` 前置判断而永不过期,等于把"非阻塞探测"变成死等)
* TC-NETSEL-009 LISTENING 的 socket 即使 readable=1 也不算可读
* TC-NETSEL-010 ESTABLISHED + send_ready → 可写位置位
* TC-NETSEL-011 非 ESTABLISHED(CONNECTING)即使 send_ready=1 也不可写
* TC-NETSEL-012 表项不存在的 fd 计入异常集合(**2026-10-23 修**:判定原本排在
* continue 之后,exceptfds 是恒为空的死参数)
* TC-NETSEL-012b 未请求异常集时,缺失的 fd 不计数(不影响只查可读的调用方)
* TC-NETSEL-012c 异常位只落到被请求的那个集合
* TC-NETSEL-013 poll 传 NULL 或 nfds<=0 → -1
* TC-NETSEL-014 poll 非法 fd(负数 / 越界)→ NET_POLLNVAL 且计入 ready
* TC-NETSEL-015 poll 对 CLOSED socket 置 NET_POLLHUP
* TC-NETSEL-016 poll 命中 POLLIN → revents 含 NET_POLLIN
* TC-NETSEL-017 现状桩:NET_POLLERR 在本实现里**永远置不上**(死分支,已登记)
* TC-NETSEL-018 poll 的 timeout = 0 立即返回(首轮就退,一次都不睡)
* TC-NETSEL-019 poll 每轮先把 revents 清零,上一轮的位不会带到这一轮
*
* 两处"现状桩"(008 / 017)钉的是**当前行为**,不是期望行为:修好它们这两条会红,
* 那是提醒而不是回归。理由写在下面的注释里。
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-23
*/
#include <stdio.h>
#include <string.h>
#include "net_select.h"
#include "net_socket.h"
#include "net_select.c"
/* ===================== 断言框架 ===================== */
static int s_total;
static int s_passed;
static int s_failed;
#define CHECK(cond, name) do { \
s_total++; \
if (cond) { s_passed++; printf(" PASS %s\n", name); } \
else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \
} while (0)
/* ===================== 可控 socket 表 ===================== */
static net_sock_t g_sock[NET_MAX_SOCKETS];
static uint8_t g_sock_valid[NET_MAX_SOCKETS];
const net_sock_t *net_get_sock(int sockfd)
{
if ((sockfd < 0) || (sockfd >= NET_MAX_SOCKETS)) {
return NULL;
}
if (g_sock_valid[sockfd] == 0u) {
return NULL;
}
return &g_sock[sockfd];
}
/* ===================== 可控时基与睡眠 ===================== */
static uint32_t g_tick;
static int g_delay_calls;
static int g_release_after; /* >0:睡够这个轮数后让 fd0 变可读(模拟对端事件) */
uint32_t HAL_GetTick(void)
{
return g_tick++; /* 每问一次时间就前进 1ms:轮数 = 毫秒数,便于精确断言 */
}
void osDelay(uint32_t ticks)
{
(void)ticks;
g_delay_calls++;
if (g_delay_calls > 500) {
/* 保险:本模块每条用例都必须在有限轮内退出(要么就绪、要么超时、要么由
* g_release_after 造事件)。跑飞就说明用例或实现出了问题——直接退出,
* 绝不能把挂死留给 pre-commit / CI(上一次就是这么卡住 exe 文件名的)。 */
printf(" ABORT 轮询跑飞(>500 轮未退出)\n");
fflush(stdout);
exit(2);
}
g_tick++;
if ((g_release_after > 0) && (g_delay_calls >= g_release_after)) {
g_sock_valid[0] = 1u;
g_sock[0].state = NET_SOCK_STATE_ESTABLISHED;
g_sock[0].readable = 1u;
}
}
/* ===================== 复位 ===================== */
static void reset_all(void)
{
(void)memset(g_sock, 0, sizeof(g_sock));
(void)memset(g_sock_valid, 0, sizeof(g_sock_valid));
g_tick = 0u;
g_delay_calls = 0;
g_release_after = 0;
}
static void make_sock(int fd, net_sock_state_t st, uint8_t readable, uint8_t send_ready)
{
g_sock_valid[fd] = 1u;
g_sock[fd].state = st;
g_sock[fd].readable = readable;
g_sock[fd].send_ready = send_ready;
}
/* ===================== 参数边界 ===================== */
static void tc_bounds(void)
{
net_fd_set_t r;
printf("-- TC-NETSEL-001..003 nfds bounds --\n");
reset_all();
NET_FD_ZERO(&r);
CHECK(net_select(0, &r, NULL, NULL, NULL) == -1, "TC-NETSEL-001 nfds=0 -> -1");
CHECK(net_select(-1, &r, NULL, NULL, NULL) == -1, "TC-NETSEL-002 nfds 为负 -> -1");
CHECK(net_select(NET_MAX_SOCKETS + 1, &r, NULL, NULL, NULL) == -1,
"TC-NETSEL-003 nfds 越界 -> -1(不许扫出表外)");
}
/* ===================== 就绪位与集合回写 ===================== */
static void tc_ready_sets(void)
{
net_fd_set_t r;
net_fd_set_t w;
int rc;
printf("-- TC-NETSEL-004..005 ready sets --\n");
reset_all();
make_sock(0, NET_SOCK_STATE_ESTABLISHED, 0u, 0u); /* 请求了但没就绪 */
make_sock(1, NET_SOCK_STATE_ESTABLISHED, 1u, 0u); /* 就绪 */
NET_FD_ZERO(&r);
NET_FD_SET(0, &r);
NET_FD_SET(1, &r);
rc = net_select(4, &r, NULL, NULL, NULL);
CHECK((rc == 1) && NET_FD_ISSET(1, &r) && !NET_FD_ISSET(0, &r),
"TC-NETSEL-004 回写只保留就绪位,未就绪的 fd0 被清掉");
reset_all();
make_sock(2, NET_SOCK_STATE_ESTABLISHED, 1u, 0u);
NET_FD_ZERO(&r);
NET_FD_SET(2, &r);
rc = net_select(3, &r, NULL, NULL, NULL); /* write/except 传 NULL */
CHECK((rc == 1) && (NET_FD_ISSET(2, &r)), "TC-NETSEL-005 其余集合可为 NULL");
/* 注意:timeout 传 NULL 时本函数会一直等到有事件(见 008 的现状说明),
* 所以这类用例必须让被请求的 fd 真的就绪,否则就是用例自己写死的死循环。 */
reset_all();
NET_FD_ZERO(&w);
NET_FD_ZERO(&r);
make_sock(2, NET_SOCK_STATE_ESTABLISHED, 0u, 1u); /* send_ready:可写就绪 */
NET_FD_SET(2, &w);
rc = net_select(3, NULL, &w, NULL, NULL);
CHECK((rc == 1) && NET_FD_ISSET(2, &w) && (r.fd_bits == 0u),
"TC-NETSEL-005b 只回写调用方真正请求过的集合");
}
/* ===================== 超时算术 ===================== */
static void tc_timeout(void)
{
net_fd_set_t r;
net_timeval_t tv;
int rc;
printf("-- TC-NETSEL-006..008 timeout arithmetic --\n");
reset_all();
g_release_after = 3; /* 第 3 轮睡完,对端才有数据 */
NET_FD_ZERO(&r);
NET_FD_SET(0, &r);
rc = net_select(1, &r, NULL, NULL, NULL); /* timeout = NULL:无限等 */
CHECK((rc == 1) && (g_delay_calls == 3) && NET_FD_ISSET(0, &r),
"TC-NETSEL-006 无限等被对端事件唤醒(第 3 轮)");
reset_all();
make_sock(0, NET_SOCK_STATE_ESTABLISHED, 0u, 0u);
NET_FD_ZERO(&r);
NET_FD_SET(0, &r);
tv.tv_sec = 0;
tv.tv_usec = 1999; /* 应为 1ms,不是 2ms */
rc = net_select(1, &r, NULL, NULL, &tv);
CHECK((rc == 0) && (g_delay_calls == 0),
"TC-NETSEL-007 tv_usec 整除截断:1999us 当 1ms,首轮即退");
/* 已修(2026-10-23):原来这里写的是 (timeout_ms > 0) && (已用时 >= timeout_ms),
* timeout = {0,0} 换算出 0 后**永不超时**,只能干等事件——而 POSIX 语义是
* "只查一轮立即返回"。调用方(netTask 在 IWDG required_mask 里)拿它当
* 非阻塞探测用就会卡死主循环。现在这条是正向断言。 */
reset_all();
make_sock(0, NET_SOCK_STATE_ESTABLISHED, 0u, 0u);
NET_FD_ZERO(&r);
NET_FD_SET(0, &r);
tv.tv_sec = 0;
tv.tv_usec = 0;
rc = net_select(1, &r, NULL, NULL, &tv);
CHECK((rc == 0) && (g_delay_calls == 0) && !NET_FD_ISSET(0, &r),
"TC-NETSEL-008 timeout={0,0} 只查一轮立即返回(与 poll 的 0 对齐)");
}
/* ===================== 状态准入 ===================== */
static void tc_state_admission(void)
{
net_fd_set_t r;
net_fd_set_t w;
net_timeval_t tv;
int rc;
printf("-- TC-NETSEL-009..012 state admission --\n");
reset_all();
make_sock(0, NET_SOCK_STATE_LISTENING, 1u, 0u); /* 监听中但报"可读" */
NET_FD_ZERO(&r);
NET_FD_SET(0, &r);
tv.tv_sec = 0;
tv.tv_usec = 1000;
rc = net_select(1, &r, NULL, NULL, &tv);
CHECK((rc == 0) && !NET_FD_ISSET(0, &r),
"TC-NETSEL-009 监听态不可读(accept 语义不在 select 这层)");
reset_all();
make_sock(0, NET_SOCK_STATE_ESTABLISHED, 0u, 1u);
NET_FD_ZERO(&w);
NET_FD_SET(0, &w);
rc = net_select(1, NULL, &w, NULL, &tv);
CHECK((rc == 1) && NET_FD_ISSET(0, &w),
"TC-NETSEL-010 已建立 + send_ready 才可写");
reset_all();
make_sock(0, NET_SOCK_STATE_CONNECTING, 0u, 1u);
NET_FD_ZERO(&w);
NET_FD_SET(0, &w);
rc = net_select(1, NULL, &w, NULL, &tv);
CHECK((rc == 0) && !NET_FD_ISSET(0, &w),
"TC-NETSEL-011 连接中不可写(send_ready 不作数)");
/* 已修(2026-10-23):异常判定原先排在 p_sock == NULL → continue 之后,而
* check_socket_error() 只有"表项为空 / fd 越界"才返回 1、越界的 i 又不在本循环
* 范围内 → exceptfds 是恒为空的死参数。现在把判定提到 continue 之前。 */
tv.tv_sec = 0;
tv.tv_usec = 1000;
reset_all();
g_sock_valid[0] = 0u;
NET_FD_ZERO(&r);
NET_FD_SET(0, &r);
rc = net_select(1, NULL, NULL, &r, &tv);
CHECK((rc == 1) && NET_FD_ISSET(0, &r),
"TC-NETSEL-012 表项不存在的 fd 计入异常集合");
reset_all();
g_sock_valid[0] = 0u;
NET_FD_ZERO(&r);
NET_FD_SET(0, &r); /* 只请求可读 */
rc = net_select(1, &r, NULL, NULL, &tv);
CHECK((rc == 0) && !NET_FD_ISSET(0, &r) && (g_delay_calls == 0),
"TC-NETSEL-012b 未请求异常集时不因缺失 fd 计数(不误伤只查可读的调用方)");
reset_all();
g_sock_valid[0] = 0u;
NET_FD_ZERO(&r);
NET_FD_SET(0, &r); /* 请求可读:不该置位 */
NET_FD_ZERO(&w);
NET_FD_SET(0, &w); /* 请求异常:应该置位 */
rc = net_select(1, &r, NULL, &w, &tv);
CHECK((rc == 1) && NET_FD_ISSET(0, &w) && !NET_FD_ISSET(0, &r),
"TC-NETSEL-012c 异常位只落到被请求的那个集合,不污染可读集合");
}
/* ===================== poll 语义 ===================== */
static void tc_poll(void)
{
net_pollfd_t p[4];
int rc;
printf("-- TC-NETSEL-013..019 poll --\n");
reset_all();
CHECK((net_poll_events(NULL, 1, 0) == -1) && (net_poll_events(p, 0, 0) == -1),
"TC-NETSEL-013 poll 参数非法 -> -1");
reset_all();
(void)memset(p, 0, sizeof(p));
p[0].fd = -1;
p[0].events = NET_POLLIN;
p[1].fd = NET_MAX_SOCKETS; /* 越界 */
p[1].events = NET_POLLIN;
rc = net_poll_events(p, 2, 0);
CHECK((rc == 2) && ((p[0].revents & NET_POLLNVAL) != 0) &&
((p[1].revents & NET_POLLNVAL) != 0),
"TC-NETSEL-014 负 fd 与越界 fd 都置 POLLNVAL 并计入 ready");
reset_all();
make_sock(0, NET_SOCK_STATE_CLOSED, 0u, 0u);
(void)memset(p, 0, sizeof(p));
p[0].fd = 0;
p[0].events = NET_POLLIN;
rc = net_poll_events(p, 1, 0);
CHECK((rc == 1) && ((p[0].revents & NET_POLLHUP) != 0),
"TC-NETSEL-015 CLOSED 置 POLLHUP");
reset_all();
make_sock(0, NET_SOCK_STATE_ESTABLISHED, 1u, 0u);
(void)memset(p, 0, sizeof(p));
p[0].fd = 0;
p[0].events = NET_POLLIN;
rc = net_poll_events(p, 1, 0);
CHECK((rc == 1) && ((p[0].revents & NET_POLLIN) != 0) &&
((p[0].revents & NET_POLLHUP) == 0),
"TC-NETSEL-016 可读置 POLLIN 且不误置 HUP");
reset_all();
make_sock(0, NET_SOCK_STATE_ESTABLISHED, 1u, 1u);
(void)memset(p, 0, sizeof(p));
p[0].fd = 0;
p[0].events = 0; /* 什么都不请求 */
(void)net_poll_events(p, 1, 0);
CHECK((p[0].revents & NET_POLLERR) == 0,
"TC-NETSEL-017 现状:POLLERR 实际永不为真(表项为空已被 POLLNVAL 抢先)");
reset_all();
make_sock(0, NET_SOCK_STATE_ESTABLISHED, 0u, 0u);
(void)memset(p, 0, sizeof(p));
p[0].fd = 0;
p[0].events = NET_POLLIN;
rc = net_poll_events(p, 1, 0); /* timeout = 0 */
CHECK((rc == 0) && (g_delay_calls == 0),
"TC-NETSEL-018 poll 的 timeout=0 立即返回(与 select 那条形成对照)");
reset_all();
make_sock(0, NET_SOCK_STATE_ESTABLISHED, 1u, 0u);
(void)memset(p, 0, sizeof(p));
p[0].fd = 0;
p[0].events = NET_POLLIN;
p[0].revents = NET_POLLOUT; /* 脏的上一轮残留 */
(void)net_poll_events(p, 1, 0);
CHECK((p[0].revents & NET_POLLOUT) == 0, "TC-NETSEL-019 每轮先清 revents");
}
/* ===================== 入口 ===================== */
int main(void)
{
printf("=== NET Select Tests ===\n");
tc_bounds();
tc_ready_sets();
tc_timeout();
tc_state_admission();
tc_poll();
printf("\n=== NET Select Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""NET 层 select/poll(net_select)的主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/net_select/net_select_test.c(以 #include 纳入生产源
Drivers/BSP/NET/net_select.c),在 PC 上离线验证就绪位回写、socket 状态对可读/
可写的准入、非法 fd 处理、tv_sec/tv_usec 超时换算、poll 的 revents 语义。
工具链与判定委托 test/host/common/unit_test_harness.py。
为什么要主机侧单测(而不是只用台架):
netTask 是串行 socket 栈的唯一执行者,且在 IWDG required_mask 里——这里的等待
逻辑算错,表现是"网络整体停摆 + 喂不上狗复位",而且日志里什么都没有。台架无法
构造"没人来连时该怎么退"(对端不可控);把 HAL_GetTick 与 osDelay 做成可控量后,
"第几轮该退出"能精确复现,包括 1999us 该按 1ms 还是 2ms 这种算术。
桩的边界:net_select.h / net_types.h / net_config.h 用真实头(net_sock_t 字段名与
NET_SOCK_STATE_* 一改就编译期报错);main.h / cmsis_os.h / FreeRTOS.h / task.h /
ch395f.h 是本目录桩——net_select.c 一个芯片函数都不调,所以 ch395f.h 是空壳。
⚠ 那个空壳不代表 NET 域桩已就绪:net_listener / net_pending / net_events 直接调
十多个 ch395f_* 且语义在 socket 状态机里,需要的是能脚本化返回的假芯片。
用法(仓库根目录):
python test/host/net_select/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误。
设计依据:docs/网络/Net层阻塞模型架构设计.md、docs/测试说明/主机单测覆盖率口径.md §9 P1
创建日期:2026-10-23
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
# 顺序敏感:stub 必须在 Drivers/BSP/NET 之前,才能用桩覆盖 net_socket.h 与 ch395f.h;
# net_select.h / net_types.h / net_config.h 仍从真实目录取。
BUILD = TestBuild(
name="NET Select Tests",
src=os.path.join(HERE, "net_select_test.c"),
include_dirs=[
os.path.join(HERE, "stub"),
os.path.join(ROOT, "Drivers", "BSP", "NET"),
HERE,
],
summary_marker="=== NET Select Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+15
View File
@@ -0,0 +1,15 @@
/*
* 模块名称:主机侧单测桩 - FreeRTOS.h(net_select 模块专用)
* 模块功能:net_select.c include 了它但不用任何内核 API(让 CPU 走 osDelay),
* 故只需一个空壳把 include 链走通。
* ⚠ 仅供 test/host/net_select/ 使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef FREERTOS_H
#define FREERTOS_H
#include <stddef.h>
#include <stdint.h>
#endif /* FREERTOS_H */
+21
View File
@@ -0,0 +1,21 @@
/*
* 模块名称:主机侧单测桩 - ch395f.h(NET 域共享形态,第一个使用者是本模块)
* 模块功能:给 NET 域一个**能在 PC 上编译**的芯片头占位。真实 ch395f.h 拉 HAL 与
* 寄存器定义,主机侧编不动;而 NET 域里 net_select.c 这类文件 include 它
* **只是为了拿类型/常量**,一个芯片函数都不调。
*
* ⚠ 仅供主机侧单测使用,不参与 MCU 构建。
* 边界要说清楚:本桩是**空**的,只服务"不调芯片函数"的文件。
* net_listener.c / net_pending.c / net_events.c 直接调 10 多个 ch395f_* 且语义在
* socket 状态机里(GET_SOCK_INT_STATUS 整体读即清等),那三块要的是**带脚本化返回
* 的假芯片**,不是这个空壳——别把本文件当成"NET 域桩已就绪"。
*
* 创建日期:2026-10-23
*/
#ifndef CH395F_H
#define CH395F_H
#include <stdint.h>
#endif /* CH395F_H */
+17
View File
@@ -0,0 +1,17 @@
/*
* 模块名称:主机侧单测桩 - cmsis_os.h(net_select 模块专用)
* 模块功能:net_select.c 只用 osDelay(1) 让出 CPU(等 fd 就绪 / 等超时)。
* ⚠ 仅供 test/host/net_select/ 使用,不参与 MCU 构建。
* 测试单元的实现把"睡 1 tick"做成"tick +1 并计数",还能在指定轮数后**造出**一个
* 就绪 socket —— 用来跑"无限等被事件唤醒"这类真机上要靠对端配合的分支。
* 创建日期:2026-10-23
*/
#ifndef CMSIS_OS_H
#define CMSIS_OS_H
#include <stdint.h>
void osDelay(uint32_t ticks);
#endif /* CMSIS_OS_H */
+17
View File
@@ -0,0 +1,17 @@
/*
* 模块名称:主机侧单测桩 - main.h(net_select 模块专用)
* 模块功能:只给 net_select.c 实际用到的一个符号:HAL_GetTick(超时判定的唯一时基)。
* ⚠ 仅供 test/host/net_select/ 使用,不参与 MCU 构建。
* 真实 main.h 拉整棵 HAL 头树,PC 上编不动。测试单元把 tick 做成"每调一次 +1"的
* 可控量,于是"轮询几轮后超时"这种时序可以精确复现,不用真等毫秒。
* 创建日期:2026-10-23
*/
#ifndef MAIN_H
#define MAIN_H
#include <stdint.h>
uint32_t HAL_GetTick(void);
#endif /* MAIN_H */
+22
View File
@@ -0,0 +1,22 @@
/*
* 模块名称:主机侧单测桩 - net_socket.h(net_select 模块专用)
* 模块功能:net_select.c 只需要两样东西:socket 表项类型(用**真实** net_types.h 的
* net_sock_t 与状态枚举,字段名一改就编译期报错)与 net_get_sock()。
* 后者由测试单元给可控实现(返回表中项或 NULL),从而能造出
* "已建立且可读 / 监听中 / 表项不存在 / 已关闭"四种观测态。
*
* ⚠ 仅供 test/host/net_select/ 使用,不参与 MCU 构建。
* 刻意不 include 真实 net_socket.h:它带着整条 netTask 消息队列与芯片依赖,
* 而 select 层的契约只依赖 socket 表项的形状。
*
* 创建日期:2026-10-23
*/
#ifndef NET_SOCKET_H
#define NET_SOCKET_H
#include "net_types.h"
const net_sock_t *net_get_sock(int sockfd);
#endif /* NET_SOCKET_H */
+11
View File
@@ -0,0 +1,11 @@
/*
* 模块名称:主机侧单测桩 - task.h(net_select 模块专用)
* 模块功能:空壳;net_select.c 不调任务 API。
* ⚠ 仅供 test/host/net_select/ 使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef TASK_H
#define TASK_H
#endif /* TASK_H */
@@ -0,0 +1,355 @@
/*
* 模块名称:保护定值持久化胶水层(prot_set_store)主机侧单元测试
* 模块功能:在 PC 上离线验证 Drivers/... 侧的平台胶水 App/cfg/prot_set_store.c 的
* **返回码分流**与**脏标志回置**:读不到=1(按无档处理)、装载成功=0、
* 版本/项数换代=2 且只打 INFO、magic 或校验被破坏=-1;保存无脏不写盘、
* 写失败必须把脏标志还回去(否则一轮卷忙就永久丢定值)。
*
* 被测对象:真实源文件 ../../../App/cfg/prot_set_store.c,并且**同时纳入真实的**
* ../../../App/cfg/prot_set.c —— 因为 blob 的 serialize/parse/脏标志都在
* prot_set.c 里(纯逻辑,已另有 test/host/prot_set/ 覆盖其算法)。本模块
* 不重测算法,只测"胶水层在真实 blob 上把返回码分对没有":只有拿真
* serialize 产出的 blob 去改 magic/ver/cnt/校验,才能构造出"换代"与
* "损坏"这两种真实形态;自己手搓 blob 会把测试写成对自己的桩。
* 平台后端 cfg_store_read / cfg_store_write_atomic 用**真实头**
* App/cfg/cfg_file_store.h(接口原汁)+ 本文件的可控实现。
*
* 为什么要主机侧单测(而不是只靠台架):
* 1. **换代与损坏必须分开**(B8 定值表 32→40 项时定下的契约):magic 完好但
* ver/cnt 与当前固件不符是**预期的升级路径**,归 2 并打 INFO;把它当损坏(-1)
* 会让设备在正常升级后报 ERROR + 自检留痕,现场查成一个根本不存在的故障。
* 2. 读失败与"文件不存在"同归 1 是**安全侧**选择(启动期卷锁本任务独占,
* try-lock 失败即真 I/O 故障,此时保持出厂默认比拿半截数据更安全)——钉住它,
* 防止将来有人"优化"成把读失败也当损坏重刷。
* 3. 写失败必须回置脏:定值经 iec104Task 下发,落盘在 defaultTask;一轮卷忙就把
* 脏标志吞掉,表现为"改了定值、重启又变回旧的",而且没有任何错误提示。
*
* 覆盖点:
* —— load 返回码分流(TC-PROTSTOR-001..008)——
* TC-PROTSTOR-001 后端读失败 → 1(按"无档"处理,保持出厂默认)
* TC-PROTSTOR-002 有效 blob → 0,且装载后定值生效
* TC-PROTSTOR-003 **ver 换代 → 2**(不是 -1)
* TC-PROTSTOR-004 ver 换代只打 **INFO**(不打 ERROR、不进自检留痕)
* TC-PROTSTOR-005 **cnt 换代 → 2**(B8 32→40 项就是这个形态)
* TC-PROTSTOR-006 magic 被破坏 → -1(真损坏)
* TC-PROTSTOR-007 magic/ver/cnt 都好但数据被改(校验不过)→ -1
* TC-PROTSTOR-008 长度不足头部 → -1,且**不得**误判为换代
* TC-PROTSTOR-009 装载成功路径不打 INFO("version change"措辞不许乱出现)
* —— 后端调用契约(TC-PROTSTOR-010..011)——
* TC-PROTSTOR-010 读取用的路径 = "/prot_set.bin"(8.3 合规,FF_USE_LFN=0 约束)
* TC-PROTSTOR-011 读取请求的容量 = PROT_SET_BLOB_SZ(blob 缓冲是静态区,不压栈)
* —— save 脏标志与写失败重试(TC-PROTSTOR-012..016)——
* TC-PROTSTOR-012 无脏标志 → 返回 0 且**一次都不写盘**
* TC-PROTSTOR-013 有脏 + 写成功 → 返回 0,路径与长度正确
* TC-PROTSTOR-014 写成功后脏被消费:紧接着再调不再写(不重复刷 Flash)
* TC-PROTSTOR-015 写失败 → 返回 -1 且**脏回置**
* TC-PROTSTOR-016 上一轮写失败后,下一轮仍会真的再写一次(定值不会静默丢失)
*
* 说明:serialize 失败这条分支在真实 prot_set.c 下不可达(缓冲尺寸恒等于
* PROT_SET_BLOB_SZ),故不硬造用例;胶水层里它是防御性代码,记录在此。
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-23
*/
#include <stdio.h>
#include <string.h>
#include "cfg_file_store.h"
#include "prot_set.h"
#include "prot_set_store.h"
/* 真实定值层(blob 编解码与脏标志在这里)+ 被测胶水层,均原样纳入 */
#include "prot_set.c"
#include "prot_set_store.c"
/* ===================== 断言框架 ===================== */
static int s_total;
static int s_passed;
static int s_failed;
#define CHECK(cond, name) do { \
s_total++; \
if (cond) { s_passed++; printf(" PASS %s\n", name); } \
else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \
} while (0)
/* ===================== dbg_log 桩计数 ===================== */
int g_pstore_info_count;
int g_pstore_error_count;
int g_pstore_debug_count;
static void logs_clear(void)
{
g_pstore_info_count = 0;
g_pstore_error_count = 0;
g_pstore_debug_count = 0;
}
/* ===================== 假平台后端(原型来自真实 cfg_file_store.h) ========= */
static uint8_t g_img[512]; /* 注入给 cfg_store_read 的文件内容 */
static uint32_t g_img_len;
static int g_read_rc; /* 注入:cfg_store_read 返回值 */
static int g_write_rc; /* 注入:cfg_store_write_atomic 返回值 */
static int g_read_calls;
static int g_write_calls;
static size_t g_read_cap;
static size_t g_write_len;
static char g_read_path[32];
static char g_write_path[32];
static void rec_path(char *dst, const char *src, size_t cap)
{
size_t i;
if (src == NULL) {
dst[0] = '\0';
return;
}
for (i = 0u; (i + 1u < cap) && (src[i] != '\0'); i++) {
dst[i] = src[i];
}
dst[i] = '\0';
}
int cfg_store_read(const char *p_path, uint8_t *p_buf, size_t cap, size_t *p_nread)
{
uint32_t i;
g_read_calls++;
rec_path(g_read_path, p_path, sizeof(g_read_path));
g_read_cap = cap;
if (p_nread != NULL) {
*p_nread = 0u;
}
if (g_read_rc != 0) {
return g_read_rc;
}
for (i = 0u; (i < g_img_len) && ((size_t)i < cap); i++) {
p_buf[i] = g_img[i];
}
if (p_nread != NULL) {
*p_nread = (size_t)i;
}
return 0;
}
int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len)
{
g_write_calls++;
rec_path(g_write_path, p_path, sizeof(g_write_path));
g_write_len = len;
(void)p_buf;
return g_write_rc;
}
/* 其余后端函数本模块不触发,给出空实现以满足链接 */
void *cfg_store_alloc(size_t size) { (void)size; return NULL; }
void cfg_store_free(void *p_buf) { (void)p_buf; }
uint32_t cfg_store_heap_free(void) { return 0u; }
int cfg_store_stat(const char *p_path, cfg_file_stat_t *p_st)
{
(void)p_path;
if (p_st != NULL) {
(void)memset(p_st, 0, sizeof(*p_st));
}
return -1;
}
int cfg_store_remove(const char *p_path) { (void)p_path; return -1; }
/* ===================== 辅助 ===================== */
static void backend_reset(void)
{
g_img_len = 0u;
g_read_rc = 0;
g_write_rc = 0;
g_read_calls = 0;
g_write_calls = 0;
g_read_cap = 0u;
g_write_len = 0u;
g_read_path[0] = '\0';
g_write_path[0] = '\0';
logs_clear();
}
/* 用**真实** serialize 造一份合法 blob,再按需改坏指定字段 */
static uint32_t make_blob(uint8_t *p_dst, uint32_t cap)
{
uint32_t len = 0u;
prot_set_init();
if (prot_set_store_serialize(p_dst, cap, &len) != 0) {
return 0u;
}
return len;
}
static void stage_blob(uint32_t len)
{
g_img_len = len;
g_read_rc = 0;
}
/* 把脏标志清干净(消费掉所有存量) */
static void dirty_clear(void)
{
while (prot_set_dirty_take() != 0u) {
/* 逐个消费直到 0 */
}
}
/* ===================== load 返回码分流 ===================== */
static void tc_load_triage(void)
{
uint8_t blob[PROT_SET_BLOB_SZ];
uint32_t len;
int rc;
printf("-- TC-PROTSTOR-001..009 load triage --\n");
backend_reset();
g_read_rc = -1;
rc = prot_set_store_load();
CHECK(rc == 1, "TC-PROTSTOR-001 读失败归 1(按无档处理)");
backend_reset();
len = make_blob(blob, sizeof(blob));
memcpy(g_img, blob, len);
stage_blob(len);
rc = prot_set_store_load();
CHECK(rc == 0, "TC-PROTSTOR-002 有效 blob 装载成功");
/* ver 换代:magic 完好、ver 与当前固件不同 → 预期升级路径,归 2 */
backend_reset();
len = make_blob(blob, sizeof(blob));
memcpy(g_img, blob, len);
g_img[4] = (uint8_t)(PROT_SET_BLOB_VER + 1u);
stage_blob(len);
rc = prot_set_store_load();
CHECK(rc == 2, "TC-PROTSTOR-003 ver 换代归 2(不是 -1)");
CHECK((g_pstore_info_count == 1) && (g_pstore_error_count == 0),
"TC-PROTSTOR-004 换代只打 INFO,不误报 ERROR");
/* cnt 换代:B8 定值表 32→40 项就是这个形态 */
backend_reset();
len = make_blob(blob, sizeof(blob));
memcpy(g_img, blob, len);
g_img[6] = (uint8_t)((uint32_t)PS_ID_COUNT + 1u);
stage_blob(len);
rc = prot_set_store_load();
CHECK(rc == 2, "TC-PROTSTOR-005 cnt 换代归 2");
/* magic 坏 = 真损坏 */
backend_reset();
len = make_blob(blob, sizeof(blob));
memcpy(g_img, blob, len);
g_img[0] = (uint8_t)(g_img[0] ^ 0xFFu);
stage_blob(len);
rc = prot_set_store_load();
CHECK((rc == -1) && (g_pstore_error_count == 0), "TC-PROTSTOR-006 magic 坏归 -1");
/* magic/ver/cnt 全好,但数据被改 → 校验不过 = 损坏 */
backend_reset();
len = make_blob(blob, sizeof(blob));
memcpy(g_img, blob, len);
g_img[PROT_SET_BLOB_HDR_SZ] = (uint8_t)(g_img[PROT_SET_BLOB_HDR_SZ] ^ 0x5Au);
stage_blob(len);
rc = prot_set_store_load();
CHECK(rc == -1, "TC-PROTSTOR-007 数据被改(校验失败)归 -1");
/* 截断到不足头部:不得误判为换代 */
backend_reset();
len = make_blob(blob, sizeof(blob));
memcpy(g_img, blob, len);
stage_blob(PROT_SET_BLOB_HDR_SZ - 1u);
rc = prot_set_store_load();
CHECK(rc == -1, "TC-PROTSTOR-008 长度不足头部归 -1 且不误判换代");
backend_reset();
len = make_blob(blob, sizeof(blob));
memcpy(g_img, blob, len);
stage_blob(len);
rc = prot_set_store_load();
CHECK((rc == 0) && (g_pstore_info_count == 0),
"TC-PROTSTOR-009 成功路径不打 INFO(version change 措辞不许乱出现)");
}
/* ===================== 后端调用契约 ===================== */
static void tc_backend_contract(void)
{
uint8_t blob[PROT_SET_BLOB_SZ];
uint32_t len;
printf("-- TC-PROTSTOR-010..011 backend contract --\n");
backend_reset();
len = make_blob(blob, sizeof(blob));
memcpy(g_img, blob, len);
stage_blob(len);
(void)prot_set_store_load();
CHECK(strcmp(g_read_path, "/prot_set.bin") == 0,
"TC-PROTSTOR-010 路径 = /prot_set.bin(8.3 合规)");
CHECK(g_read_cap == (size_t)PROT_SET_BLOB_SZ,
"TC-PROTSTOR-011 读取容量 = PROT_SET_BLOB_SZ(静态缓冲,不压栈)");
}
/* ===================== save 与脏标志 ===================== */
static void tc_save_dirty(void)
{
int rc;
printf("-- TC-PROTSTOR-012..016 save + dirty retry --\n");
backend_reset();
dirty_clear();
rc = prot_set_store_save();
CHECK((rc == 0) && (g_write_calls == 0), "TC-PROTSTOR-012 无脏不写盘");
prot_set_dirty_mark();
g_write_rc = 0;
rc = prot_set_store_save();
CHECK((rc == 0) && (g_write_calls == 1) &&
(strcmp(g_write_path, "/prot_set.bin") == 0) && (g_write_len > 0u),
"TC-PROTSTOR-013 有脏且写成功:路径与长度正确");
rc = prot_set_store_save();
CHECK((rc == 0) && (g_write_calls == 1),
"TC-PROTSTOR-014 脏已消费,紧接着再调不再刷 Flash");
prot_set_dirty_mark();
g_write_rc = -1;
rc = prot_set_store_save();
CHECK((rc == -1) && (g_write_calls == 2), "TC-PROTSTOR-015 写失败返回 -1");
/* 关键:失败必须把脏标志还回去,否则这一轮定值永久丢失 */
g_write_rc = 0;
g_write_calls = 0;
rc = prot_set_store_save();
CHECK((rc == 0) && (g_write_calls == 1),
"TC-PROTSTOR-016 上一轮写失败后下一轮仍真的再写(脏已回置)");
dirty_clear();
}
/* ===================== 入口 ===================== */
int main(void)
{
printf("=== Prot Set Store Tests ===\n");
tc_load_triage();
tc_backend_contract();
tc_save_dirty();
printf("\n=== Prot Set Store Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""保护定值持久化胶水层(prot_set_store)的主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/prot_set_store/prot_set_store_test.c(同时纳入**生产源**
App/cfg/prot_set_store.c 与 App/cfg/prot_set.c),在 PC 上离线验证 blob 装载的
返回码分流(1 无档 / 0 成功 / 2 换代 / -1 损坏)与保存的脏标志回置,失败即非 0
退出。工具链与判定委托 test/host/common/unit_test_harness.py。
为什么要主机侧单测(而不是只用台架):
换代与损坏必须分开(B8:定值表 32→40 项)——把正常的固件升级当损坏,会让设备在
升级后报 ERROR + 自检留痕,现场查一个不存在的故障;写盘失败若不把脏标志还回去,
表现是"改了定值、重启又变回旧的"且无任何提示。这两条真机上都要靠故障注入才碰得到。
为什么连 prot_set.c 一起编:blob 的编解码与脏标志都在它里面,只有拿**真** serialize
产出的 blob 去改 magic/ver/cnt/校验,才能造出真实的"换代"与"损坏"形态;手搓 blob
等于测自己的桩。定值算法本身归 test/host/prot_set/,本模块不重测。
用法(仓库根目录):
python test/host/prot_set_store/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误。
设计依据:App/cfg/prot_set_store.h 的返回码表、docs/测试说明/主机单测覆盖率口径.md §9 P1
创建日期:2026-10-23
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
# 顺序敏感:stub 先于生产目录(覆盖 dbg_log.h)。cfg_file_store.h / prot_set.h /
# prot_set_store.h 全用真实头,后端两个被调函数由测试单元给可控实现。
BUILD = TestBuild(
name="Prot Set Store Tests",
src=os.path.join(HERE, "prot_set_store_test.c"),
include_dirs=[
os.path.join(HERE, "stub"),
os.path.join(ROOT, "App", "cfg"),
os.path.join(ROOT, "App", "algo"),
HERE,
],
summary_marker="=== Prot Set Store Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+20
View File
@@ -0,0 +1,20 @@
/*
* 模块名称:主机侧单测桩 - dbg_log.h(prot_set_store 模块专用)
* 模块功能:把 MCU 版 DBG_* 宏替换为计数,让"换代只打 INFO、损坏不打"这类
* 留痕差异成为可断言的行为(B8 的分流契约一半就在措辞上)。
* ⚠ 仅供 test/host/prot_set_store/ 使用,不参与 MCU 构建。
* 创建日期:2026-10-23
*/
#ifndef DBG_LOG_H
#define DBG_LOG_H
extern int g_pstore_info_count;
extern int g_pstore_error_count;
extern int g_pstore_debug_count;
#define DBG_INFO(fmt, ...) do { (void)(fmt); g_pstore_info_count++; } while (0)
#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_pstore_debug_count++; } while (0)
#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_pstore_error_count++; } while (0)
#endif /* DBG_LOG_H */
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""任务耗时探针(task_prof)的主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/task_prof/task_prof_test.c(以 #include 纳入**生产源**
App/util/task_prof.c),在 PC 上离线验证样本有效性闸门、enter/exit 配对、上报节流
与两条通道(环形缓冲 / 裸发)、周期→微秒换算、glitch 汇总,失败即非 0 退出。
工具链与判定委托 test/host/common/unit_test_harness.py。
为什么要主机侧单测(而不是只靠真机日志):
这个模块自己错了,所有 PROF 行就都是假的:漏了"无效样本闸门",一次 DWT 复位就把
max 永久抬到 ~2^32 周期;"先 exit 再 enter"的首轮会把 0 当起点,造出几秒的假超大
样本;节流写错要么刷屏挤掉正事、要么停摆现场那一行发不出去;换算依赖
SystemCoreClock,改时钟树忘了同步则整表偏移而无人察觉。这些都得能构造时序来测。
桩的边界:只有 main.h 需要桩(真实 main.h 拉整棵 HAL 头树,PC 上编不动;桩里的字段名
与位掩码宏名必须与 CMSIS 一致,改了被测代码即编译失败)。console_log.h 用真实头,
两个出口由测试单元实现并留档内容——报告行是给人读的,断言直接盯字符串。
用法(仓库根目录):
python test/host/task_prof/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误。
设计依据:App/util/task_prof.h、docs/测试说明/主机单测覆盖率口径.md §9 P1
创建日期:2026-10-23
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
# 顺序敏感:stub 必须先于生产目录,才能用桩 main.h 覆盖真实 main.h。
# console_log.h / task_prof.h 用真实头(App/log、App/util)。
BUILD = TestBuild(
name="Task Prof Tests",
src=os.path.join(HERE, "task_prof_test.c"),
include_dirs=[
os.path.join(HERE, "stub"),
os.path.join(ROOT, "App", "util"),
os.path.join(ROOT, "App", "log"),
HERE,
],
summary_marker="=== Task Prof Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+44
View File
@@ -0,0 +1,44 @@
/*
* 模块名称:主机侧单测桩 - main.h(task_prof 模块专用)
* 模块功能:提供 task_prof.c 需要的**四个**平台符号,让 DWT 周期计数器变成
* 测试可控制的量:
* SystemCoreClock 测试可写(换算 us 的分母,真机 = 168 MHz)
* HAL_GetTick() 测试可控的毫秒时基(report 节流就看它)
* DWT->CTRL/CYCCNT 可写内存:CTRL 的 CYCCNTENA 位要能"读回 1",
* 否则 task_prof.c 永远认为 DWT 不可用
* CoreDebug->DEMCR 同上(TRCENA 位)
*
* ⚠ 仅供 test/host/task_prof/ 使用,不参与 MCU 构建。
* 真实 main.h 拉整棵 HAL 头树,主机侧无法编译,故这里只声明被测文件实际用到的
* 那几个符号——**结构体字段名与位掩码宏名必须与 CMSIS 一致**(DWT_Type 的
* CTRL/CYCCNT、DEMCR、DWT_CTRL_CYCCNTENA_Msk、CoreDebug_DEMCR_TRCENA_Msk),
* 改了这些名字被测代码会直接编译失败,不会静默放行。
*
* 创建日期:2026-10-23
*/
#ifndef MAIN_H
#define MAIN_H
#include <stdint.h>
typedef struct {
volatile uint32_t DEMCR;
} tp_coredebug_t;
typedef struct {
volatile uint32_t CTRL;
volatile uint32_t CYCCNT;
} tp_dwt_t;
extern tp_coredebug_t *const CoreDebug;
extern tp_dwt_t *const DWT;
#define CoreDebug_DEMCR_TRCENA_Msk (1UL << 24)
#define DWT_CTRL_CYCCNTENA_Msk (1UL << 0)
extern uint32_t SystemCoreClock;
uint32_t HAL_GetTick(void);
#endif /* MAIN_H */
+376
View File
@@ -0,0 +1,376 @@
/*
* 模块名称:任务耗时探针(task_prof)主机侧单元测试
* 模块功能:在 PC 上离线验证 App/util/task_prof.c 的**样本有效性闸门、enter/exit
* 配对、上报节流与通道选择、周期→微秒换算、glitch 汇总**。
*
* 被测对象:真实源文件 ../../../App/util/task_prof.c(#include 原样纳入)。桩:
* - stub/main.h:DWT/CoreDebug/SystemCoreClock/HAL_GetTick(本目录,
* 真实 main.h 拉整棵 HAL 头树无法在 PC 上编);
* - console_log.h 用**真实头**(App/log),两个出口函数
* dbg_console_write_line / dbg_console_write_raw 由本文件实现并**留档
* 内容**——报告行是要被人读的,所以断言直接盯字符串本身。
*
* 为什么要主机侧单测(而不是只靠真机日志):
* 这个模块是"性能问题的唯一现场证据",它自己错了所有 PROF 行就都是假的:
* 1. **无效样本闸门**(cyc==0 或 ≥ SystemCoreClock×10):漏了它,一次 DWT 复位
* 就把 max 抬到 ~2^32 周期,之后所有"最坏耗时"永久失真(真机上没人会去翻
* gl= 计数发现)。这条只能构造样本测。
* 2. **未 enter 就 exit 必须直接返回**:周期测量写成"先 exit 再 enter"时,首轮
* 会把 0 当起点,记下一次几秒的虚假超大样本——真机上表现为"偶发一条离谱
* PROF 行",查半年。
* 3. 节流契约(首次 5 s、之后每 2 s)与**两条通道的选择**(report 走环形缓冲、
* report_raw 走裸发):错了要么刷屏挤掉正事,要么停摆现场那一行发不出去。
* 4. 周期→微秒换算依赖 SystemCoreClock,改时钟树(比如 168→180 MHz)忘了同步
* 就会整表偏移;这条在真机上看不出来(没人对着示波器量 PROF)。
*
* 覆盖点:
* TC-TASKPROF-001 有效样本:count+1 / last=样本 / max=样本
* TC-TASKPROF-002 更小样本不改 max(max 只增不减)
* TC-TASKPROF-003 cyc=0 只累加 glitch,不动 count/last/max(计数器复位防线)
* TC-TASKPROF-004 cyc ≥ SystemCoreClock×10 同样只累加 glitch
* TC-TASKPROF-005 id 越界(=COUNT 与 +1)安全返回,不碰任何槽
* TC-TASKPROF-006 首次取时基即惰性使能 DWT(TRCENA + CYCCNTENA 读回)
* TC-TASKPROF-007 enter 置 in_seg 并记下起点
* TC-TASKPROF-008 exit 结算 Δ、清 in_seg、count+1
* TC-TASKPROF-009 **未 enter 直接 exit 不记账**(防首轮 0 起点造出假超大样本)
* TC-TASKPROF-010 重复 exit 不重复记账
* TC-TASKPROF-011 首次 5 s 之前一条都不发
* TC-TASKPROF-012 到点后发两行([PROF] + [PROF2]),走环形缓冲通道
* TC-TASKPROF-013 之后不足 2 s 不发(节流)
* TC-TASKPROF-014 满 2 s 再发两行
* TC-TASKPROF-015 report_raw 无视节流,恒发两行且走**裸发**通道
* TC-TASKPROF-016 行格式:以 [PROF]/[PROF2] 开头、以 \r\n 结尾
* TC-TASKPROF-017 周期→微秒换算按 SystemCoreClock(168 MHz 下 1680 周期 = 10 us)
* TC-TASKPROF-018 gl= 是所有槽 glitch 的汇总,不是单槽
* TC-TASKPROF-019 段内进行时的 n= 字段非零(此刻仍在段内的可视性)
* TC-TASKPROF-020 count 是累计值,上报不清零
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-23
*/
#include <stdio.h>
#include <string.h>
#include "console_log.h" /* 真实头:DBG_CON_LINE_MAX + 两个出口 */
#include "task_prof.h"
#include "task_prof.c"
/* ===================== 断言框架 ===================== */
static int s_total;
static int s_passed;
static int s_failed;
#define CHECK(cond, name) do { \
s_total++; \
if (cond) { s_passed++; printf(" PASS %s\n", name); } \
else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \
} while (0)
/* ===================== 桩实体 ===================== */
static tp_coredebug_t g_coredebug;
static tp_dwt_t g_dwt;
tp_coredebug_t *const CoreDebug = &g_coredebug;
tp_dwt_t *const DWT = &g_dwt;
uint32_t SystemCoreClock = 168000000u; /* 真机口径:168 MHz */
static uint32_t g_tick; /* HAL_GetTick 的可控值 */
uint32_t HAL_GetTick(void)
{
return g_tick;
}
/* 出口留档:两条通道分别计数;**每一行都单独留档**(一次上报会连发
* [PROF] 与 [PROF2] 两行,只存"最后一行"就没法断言第一行的字段) */
#define CAP_MAX 8
static int g_line_calls;
static int g_raw_calls;
static char g_line[CAP_MAX][DBG_CON_LINE_MAX];
static void cap(int slot, const char *src, unsigned len)
{
unsigned i;
if (slot >= CAP_MAX) {
return;
}
for (i = 0u; (i < len) && (i + 1u < DBG_CON_LINE_MAX); i++) {
g_line[slot][i] = src[i];
}
g_line[slot][i] = '\0';
}
int dbg_console_write_line(const char *line, unsigned len)
{
cap(g_line_calls + g_raw_calls, line, len);
g_line_calls++;
return (int)len;
}
void dbg_console_write_raw(const char *buf, unsigned len)
{
cap(g_line_calls + g_raw_calls, buf, len);
g_raw_calls++;
}
void dbg_console_writer_attach(void) { }
uint8_t dbg_console_writer_ready(void) { return 1u; }
/* ===================== 辅助 ===================== */
static void reset_all(void)
{
(void)memset(s_prof, 0, sizeof(s_prof));
(void)memset(&g_coredebug, 0, sizeof(g_coredebug));
(void)memset(&g_dwt, 0, sizeof(g_dwt));
s_dwt_ok = 0u;
s_prof_reported = 0u;
s_prof_last = 0u;
g_tick = 0u;
g_line_calls = 0;
g_raw_calls = 0;
(void)memset(g_line, 0, sizeof(g_line));
}
static void set_cyc(uint32_t v)
{
g_dwt.CYCCNT = v;
}
/* 从指定行的键上取整数值;键不存在或其后不是数字则返回 -1(断言因此会响)。
* 手写解析而不用 sscanf:后者在 MSVC 下触发 C4996(弃用告警),门禁输出要干净 */
static long field_of(int slot, const char *p_key)
{
const char *p;
long v = 0;
int digits = 0;
if (slot >= CAP_MAX) {
return -1L;
}
p = strstr(g_line[slot], p_key);
if (p == NULL) {
return -1L;
}
p += strlen(p_key);
while ((*p >= '0') && (*p <= '9')) {
v = (v * 10) + (long)(*p - '0');
p++;
digits++;
}
return (digits > 0) ? v : -1L;
}
/* ===================== 样本闸门 ===================== */
static void tc_sample_gate(void)
{
printf("-- TC-TASKPROF-001..005 sample gate --\n");
reset_all();
task_prof_sample(TASK_PROF_UI_PERIOD, 500u);
CHECK((s_prof[TASK_PROF_UI_PERIOD].count == 1u) &&
(s_prof[TASK_PROF_UI_PERIOD].last == 500u) &&
(s_prof[TASK_PROF_UI_PERIOD].max == 500u),
"TC-TASKPROF-001 有效样本记 count/last/max");
task_prof_sample(TASK_PROF_UI_PERIOD, 300u);
CHECK((s_prof[TASK_PROF_UI_PERIOD].max == 500u) &&
(s_prof[TASK_PROF_UI_PERIOD].last == 300u) &&
(s_prof[TASK_PROF_UI_PERIOD].count == 2u),
"TC-TASKPROF-002 更小样本不改 max");
reset_all();
task_prof_sample(TASK_PROF_UI_PERIOD, 0u);
CHECK((s_prof[TASK_PROF_UI_PERIOD].glitch == 1u) &&
(s_prof[TASK_PROF_UI_PERIOD].count == 0u) &&
(s_prof[TASK_PROF_UI_PERIOD].max == 0u),
"TC-TASKPROF-003 cyc=0 只累加 glitch(DWT 复位防线)");
reset_all();
task_prof_sample(TASK_PROF_UI_PERIOD, SystemCoreClock * 10u);
CHECK((s_prof[TASK_PROF_UI_PERIOD].glitch == 1u) &&
(s_prof[TASK_PROF_UI_PERIOD].count == 0u),
"TC-TASKPROF-004 超上界样本只累加 glitch");
reset_all();
task_prof_sample((task_prof_id_t)TASK_PROF_ID_COUNT, 100u);
task_prof_sample((task_prof_id_t)(TASK_PROF_ID_COUNT + 1), 100u);
task_prof_enter((task_prof_id_t)TASK_PROF_ID_COUNT);
task_prof_exit((task_prof_id_t)TASK_PROF_ID_COUNT);
CHECK((s_prof[0].count == 0u) && (s_prof[TASK_PROF_ID_COUNT - 1].count == 0u),
"TC-TASKPROF-005 id 越界安全返回,不碰任何槽");
}
/* ===================== 时基与 enter/exit 配对 ===================== */
static void tc_enter_exit(void)
{
printf("-- TC-TASKPROF-006..010 dwt enable + enter/exit --\n");
reset_all();
set_cyc(1000u);
(void)task_prof_cyc();
CHECK(((g_coredebug.DEMCR & CoreDebug_DEMCR_TRCENA_Msk) != 0u) &&
((g_dwt.CTRL & DWT_CTRL_CYCCNTENA_Msk) != 0u) &&
(s_dwt_ok == 1u),
"TC-TASKPROF-006 首次取时基即惰性使能 DWT");
reset_all();
set_cyc(2000u);
task_prof_enter(TASK_PROF_UI_WORK);
CHECK((s_prof[TASK_PROF_UI_WORK].in_seg == 1u) &&
(s_prof[TASK_PROF_UI_WORK].t_start == 2000u),
"TC-TASKPROF-007 enter 置 in_seg 并记起点");
set_cyc(2640u);
task_prof_exit(TASK_PROF_UI_WORK);
CHECK((s_prof[TASK_PROF_UI_WORK].in_seg == 0u) &&
(s_prof[TASK_PROF_UI_WORK].last == 640u) &&
(s_prof[TASK_PROF_UI_WORK].count == 1u),
"TC-TASKPROF-008 exit 结算 Δ 并清 in_seg");
reset_all();
task_prof_exit(TASK_PROF_ADC_BEAT);
CHECK((s_prof[TASK_PROF_ADC_BEAT].count == 0u) &&
(s_prof[TASK_PROF_ADC_BEAT].glitch == 0u),
"TC-TASKPROF-009 未 enter 直接 exit 不记账(防 0 起点假超大样本)");
reset_all();
set_cyc(100u);
task_prof_enter(TASK_PROF_NET_POLL);
set_cyc(150u);
task_prof_exit(TASK_PROF_NET_POLL);
task_prof_exit(TASK_PROF_NET_POLL);
CHECK(s_prof[TASK_PROF_NET_POLL].count == 1u, "TC-TASKPROF-010 重复 exit 不重复记账");
}
/* ===================== 上报节流与通道 ===================== */
static void tc_report_throttle(void)
{
printf("-- TC-TASKPROF-011..015 report throttle + channels --\n");
reset_all();
g_tick = 4999u;
task_prof_report();
CHECK((g_line_calls == 0) && (g_raw_calls == 0), "TC-TASKPROF-011 首 5 s 前不发");
g_tick = 5000u;
task_prof_report();
CHECK((g_line_calls == 2) && (g_raw_calls == 0),
"TC-TASKPROF-012 到点发两行走环形缓冲");
g_tick = 6999u;
task_prof_report();
CHECK(g_line_calls == 2, "TC-TASKPROF-013 不足 2 s 不发");
g_tick = 7000u;
task_prof_report();
CHECK(g_line_calls == 4, "TC-TASKPROF-014 满 2 s 再发两行");
g_line_calls = 0;
g_raw_calls = 0;
task_prof_report_raw();
CHECK((g_raw_calls == 2) && (g_line_calls == 0),
"TC-TASKPROF-015 report_raw 无视节流且走裸发通道");
}
/* ===================== 行内容与换算 ===================== */
static void tc_line_format(void)
{
printf("-- TC-TASKPROF-016..020 line content --\n");
/* 016:一次上报连发两行;行首前缀是采集脚本的抓取依据,行尾必须是 CRLF */
reset_all();
g_tick = 5000u;
task_prof_report();
CHECK((g_line_calls == 2) &&
(strncmp(g_line[0], "[PROF] ", 7) == 0) &&
(strncmp(g_line[1], "[PROF2] ", 8) == 0),
"TC-TASKPROF-016 两行行首分别是 [PROF] / [PROF2]");
CHECK((strstr(g_line[0], "\r\n") != NULL) &&
(strstr(g_line[1], "\r\n") != NULL),
"TC-TASKPROF-016b 两行都以 CRLF 结尾");
CHECK(field_of(0, "uip=") == 0L, "TC-TASKPROF-016c 未采样的槽打印 0");
/* 017:打印的是**微秒**,分母是 SystemCoreClock —— 断言落在打印字段上 */
reset_all();
task_prof_sample(TASK_PROF_UI_PERIOD, 1680u); /* 168 MHz → 10 us */
task_prof_sample(TASK_PROF_ADC_BEAT, 3360u); /* 168 MHz → 20 us */
g_tick = 5000u;
task_prof_report();
CHECK(field_of(0, "uip=") == 10L,
"TC-TASKPROF-017 168 MHz:1680 周期的 max 打印成 uip=10");
CHECK(field_of(0, "adcm=") == 20L,
"TC-TASKPROF-017b 同频:3360 周期的 max 打印成 adcm=20");
SystemCoreClock = 84000000u; /* 改时钟树而忘了同步 → 整表偏移,这里必须显式跟着变 */
g_tick = 7000u;
task_prof_report();
CHECK(field_of(2, "uip=") == 20L,
"TC-TASKPROF-017c 84 MHz 下同一批周期数打印成 20");
SystemCoreClock = 168000000u;
/* 018:gl= 是**所有槽**的 glitch 汇总,不是当前槽 */
reset_all();
task_prof_sample(TASK_PROF_UI_PERIOD, 0u);
task_prof_sample(TASK_PROF_ADC_BEAT, 0u);
task_prof_sample(TASK_PROF_NET_POLL, 0u);
g_tick = 5000u;
task_prof_report();
CHECK(field_of(0, "gl=") == 3L, "TC-TASKPROF-018 gl= 汇总所有槽的 glitch");
/* 019:段未结算时,"此刻已在段内多久"要看得见(uipn / netn 两个字段) */
/* 字段口径 = 上报时刻的 CYCCNT 减去各自的进段时刻(**不是**样本值):
* UI_PERIOD 进段 @1000、NET_POLL 进段 @10000,上报时 CYCCNT=51680
* uipn = (51680-1000)/168 = 301 netn = (51680-10000)/168 = 248 */
reset_all();
set_cyc(1000u);
task_prof_enter(TASK_PROF_UI_PERIOD);
set_cyc(10000u);
task_prof_enter(TASK_PROF_NET_POLL);
set_cyc(51680u);
g_tick = 5000u;
task_prof_report();
CHECK(field_of(0, "uipn=") == 301L,
"TC-TASKPROF-019 UI_PERIOD 段内:uipn= 上报此刻已停留的 us");
CHECK(field_of(1, "netn=") == 248L,
"TC-TASKPROF-019b NET_POLL 段内:[PROF2] 的 netn= 同步可见");
CHECK(field_of(0, "adcn=") == 0L,
"TC-TASKPROF-019c 未进段的 ADC_BEAT 打印 0(不串用别的槽)");
CHECK(s_prof[TASK_PROF_UI_PERIOD].in_seg == 1u,
"TC-TASKPROF-019d 上报不消费段状态(只观测)");
/* 020:count 是累计量,两轮上报之间不能清零 */
reset_all();
task_prof_sample(TASK_PROF_UI_WORK, 100u);
g_tick = 5000u;
task_prof_report();
g_tick = 7000u;
task_prof_report();
CHECK((s_prof[TASK_PROF_UI_WORK].count == 1u) && (field_of(2, "uic=") == 0L) &&
(field_of(0, "uic=") == 0L),
"TC-TASKPROF-020 上报不清零(UI_WORK 不在 uic= 字段,仍为累计 1)");
}
/* ===================== 入口 ===================== */
int main(void)
{
printf("=== Task Prof Tests ===\n");
tc_sample_gate();
tc_enter_exit();
tc_report_throttle();
tc_line_format();
printf("\n=== Task Prof Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}