diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index ae236eb0..f07eff21 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -208,10 +208,13 @@ **下一步(按性价比排序)**: -1. **扩大覆盖面**:按 **§9 的建桩优先级清单**吃。**P1 已吃完**(2026-10-24 合入主干,13 个 .c 全部 - 进分母,`files_tested` 19 → 32);下一步是 **P2 的 5 个 / 2,226 行**(`net_socket.c` 1,251 行需分片、 - `sys_log_mcu`+`cfg_file_store_mcu`+`soe_store` 共用一套假 FatFs、`console_log`)。P3 的 29 个 / - 4,814 行**刻意不做**并已写明理由。比把已覆盖文件从 85% 推到 95% 值钱得多。 +1. **扩大覆盖面**:按 **§9 的建桩优先级清单**吃 —— **清单已吃穿**:P1 13 个 `.c`(`files_tested` 19 → 32) + 与 P2 5 个 `.c`(`files_tested` 32 → **37**,实测见 §9「P2 落地」)都已进分母,**剩下的 29 个 `.c` + 全部是 P3「刻意不做」**、每条都能在 P3 表里找到归属(本节终点判据就此达成)。于是这一条**没有下一批可排**, + 只剩两类事:① 把 §9 登记为本批「现状」的条目按用户裁定落成 fix(体量最大的一块是 + `App/log/sys_log_mcu.c` 卷忙时整批丢弃日志);② 真想把百分比再往上推,就只能回头补**已进分母文件**的 + 未测分支(缺口最大的三块:`lftpd_io.c` 53.5%、`net_pending.c` 87.1%、`lftpd.c` 64.3%)—— + 按 §5 那仍是自分数、不是 KPI,性价比低于 ①。 2. **不做**:第二条覆盖率通道(gcc/WSL、`dotnet-coverage`、OpenCppCoverage 三条路都不走,用户裁定 2026-10-23)。要重开这个裁定,前提是能同时给出"两份分母怎么对齐"的答案,否则只是多一本对不上的账。 @@ -415,6 +418,137 @@ uint16_t ch395f_link_change_count(void); 3. **`console_log.c`(265)** —— 单写者契约 K1/K2 已经写成表,照抄成用例最省力: `docs/子系统设计/控制台单写者日志设计说明.md`。 +### P2 落地(2026-10-24,5 个文件全部进分母,套件 28 → 33 全绿) + +三个模块码并行落地(`net_socket` 分片吞、存储三件套共用新建假 FatFs、`console_log` 独立), +**新增 5 套 659 条断言**:`net_socket` 420、`console_log` 104、`sys_log_mcu` 75、`cfg_file_store_mcu` 34、`soe_store` 26。 + +| 新纳入分母的 `.c` | 插桩行 / 函数 | 断言 | 套件目录 | +|---|---|---|---| +| `net_socket.c` | **881/883 = 99.8%**、52/52 | 420 | `test/host/net_socket/` | +| `sys_log_mcu.c` | **310/313 = 99.0%**、12/12 | 75 | `test/host/sys_log_mcu/` | +| `console_log.c` | **188/189 = 99.5%**、15/15 | 104 | `test/host/console_log/` | +| `cfg_file_store_mcu.c` | **97/97 = 100%**、10/10 | 34 | `test/host/cfg_file_store_mcu/` | +| `soe_store.c` | **78/79 = 98.7%**、4/4 | 26 | `test/host/soe_store/` | +| **P2 合计** | **1,554/1,561 = 99.6%**、93/93 | 659 | — | + +**账**:套件 28 → **33** 全绿、`files_tested` 32 → **37**、未触及 **112 → 107**(`.c` **34 → 29**、`.h` 仍 78)、 +分母 4,684 → **6,245** 插桩行,`line=4020/4684(85.8%)` → **`5574/6245(89.3%)`**、 +`func=300/333(90.1%)` → **`393/426(92.2%)`**。恒等式跟着更新且仍然咬合:`29 = 0(P1 剩) + 0(P2) + 29(P3)`, +并且**本节终点判据(下面「顺序建议与记账」第 3 条)就此达成**:剩下的 29 条 `.c` 逐条都能在 P3 那张表里找到归属 +(芯片驱动 5 + UI 17 + 任务编排 6 + 内核前装配 1 = 29),没有一条是"既没进分母、又说不清为什么不测"的真窟窿。 + +⚠ **这批罕见地没有摊薄、反而 +3.5pp** —— 不是因为更勤奋,而是 5 个文件**全都以 ≥98.7% 进分母** +(P1 那批是 `lftpd_io.c` 53.5%、`lftpd.c` 64.3% 这种"半份进分母")。**别把它读成"百分比才是 KPI"**: +按 §5 口径这批该盯的仍是 `files_tested` 32→37 与未触及 `.c` 34→29 这两个数,89.3% 只是自分数。 + +**两套分母别再混用**(写给下次估工作量的人):本节清单里的"有效代码行"是**去注释/去空行**的静态计数, +插桩分母只数 mscoverage 认为可执行的行——`net_socket.c` 清单 1,251 / 插桩 **883**、`sys_log_mcu.c` 458 / **313**、 +`console_log.c` 265 / **189**、`cfg_file_store_mcu.c` 129 / **97**、`soe_store.c` 123 / **79**。 +按 1,251 估进度会把 99.8% 读成 70.4%。另:`for (;;)` 的收尾大括号会被算进可执行行 +(`soe_store.c:202` 就是这种非真语句),别当成漏测去"补"。 + +**画像期两处口径被落地推翻**(就地更正,不回改上文原话):① "⚠ **没有 `ch395f.h`**——NET 域那批必须新建它" +已由 P1 建好(`test/host/net_listener/stub/ch395f.h` 转发到共享假芯片);② "FatFs 侧**不建假头**,沿用真实 +`ff.h` + 测试单元内假 diskio"——P2 走的是**另一条并存的路**:`test/host/common/fatfs_fake.{h,c}` 以真实 +`Lib/FatFs/ff.h` 为**声明源**、在 `fatfs_fake.c` 里**定义** `f_*`(签名一漂移就编译期报错), +提供内存卷 + 可注入返回码/短写/`FR_DENIED`。两条路的共同点是**都不自造假头**。假件**刻意只提供三个生产文件 +实际调用的那 9 个 `f_*`**(`f_close f_lseek f_mkdir f_open f_read f_rename f_stat f_unlink f_write`), +不给 `f_mount/f_mkfs/f_opendir/f_readdir/f_sync` —— 于是"自愈逻辑把名字/参数类错误码升级成卷操作"这类违规 +会在**链接期**就响,而不是靠评审眼神(`docs/架构设计/STM32F4-Base存储架构说明.md` §10.1 陷阱二的机器化兜底)。 + +**编号**:`TC-HOST-*` 族 22 → **27**(本批补 `NETSOCK`/`SYSLOG`/`CFGFST`/`SOESTORE`/`CONLOG`, +游标分别 `TC-NETSOCK-545`/`TC-SYSLOG-100`/`TC-CFGFST-053`/`TC-SOESTORE-024`/`TC-CONLOG-135`, +属主登记处 `test/host/<目录>/*`,`cursor_owner_only`/`allow_orphans`/`subitem_ok` 照 B 层口径)。 +**先补族再引号**这条链本批又走了一遍,而且踩在收口前:`net_socket` 起初自取两字母码 `TC-NS-*`(465 处), +那会整批落进通用 `TC` 族(属主 `docs/测试说明/*_Test_Guide.md`、**无** `allow_orphans`) += 458 条台账外新账直接拦提交;正名为 `TC-NETSOCK-` 是 bytes 级替换(465 处 / 0 残留 / 兄弟目录 `git diff` 全空 / +复跑 420 全绿)。**写死成纪律**:队友起手就按 Lead 给的模块码写号,两字母不可检索的码(`NS`)不要取。 +本批**未**给新套件单挂 `*-unit-test` 钩子(与 P1 那 12 套同口径):聚合闸 `test-coverage` 的触发面 +`^(App/|Drivers/BSP/|test/host/|…)` 已自动发现新模块并按基线守回退。 + +**假件常量必须与真头逐字对齐(本批最值钱的机制结论)**:共享假芯片里 `CH395F_SOCKET_OPEN` 曾是 `0x01U` +(真头 `Drivers/BSP/CH395F/ch395f.h`:220 = **`0x05U`**、`docs/芯片手册/CH395F.md`:527)、 +`CH395F_TCP_ESTABLISHED` 曾是 `0x05U`(真头:229 = **`0x04U`**、手册:539)。 +**三套存量单测一条都没红**——host 编译里生产码看到的也是假宏,宏对宏自洽 → 错在"值层", +表现正是"把断言写在假件规则上而门禁毫无反应"。修法不是改断言而是**加机械闸**: +`test/host/net_socket/run_unit_test.py` 在编译之前做真头/假件同名 `CH395F_*` 常量的文本核对 +(真头 106 项 / 假件 32 项 / 同名 22 项,只比纯字面量取值),并做过负例验证:把值改回 `0x01U` → +输出 `[DRIFT] CH395F_SOCKET_OPEN 真=0x05 假=0x01` 且 rc=1 拒绝编译。跨头 `_Static_assert` 做不到, +文本核对是目前唯一能常驻的拦法。**推论**:任何"照抄真头常量"的共享假件都该配这样一根闸。 + +**两条 FatFs 实证陷阱都做了正反两面**:`FA_OPEN_APPEND` 不含 `FA_WRITE`(缺写位 → `f_write` 恒 +`FR_DENIED` 的对照保真断言 3 处)与"`FR_OK` + 短写"判失败必须校验写回字节数;短名违规**分档** +(体 > 8 / 扩展 > 3 / 非法字符 / 以点开头 → `FR_INVALID_NAME`;含 `:` → `FR_INVALID_DRIVE`), +`FF_USE_LFN=0` 下 `.fschk` 走的正是"扩展域 5 字符超限"这条真实路径;自愈只动自己的文件 +(反复失败 20 轮后 `/log` 目录与邻居文件未被触碰)。 + +**本批照出的现状(只登记、未改生产码;逐条已上报用户待裁定)**: + +| # | 位置 | 现象与影响 | 钉住它的用例 | +|---|---|---|---| +| 1 | `App/log/sys_log_mcu.c:509~537` | **疑似数据丢失(建议按 P1 定级)**:批量路径 `done = n` 只有 `rc==-3`/`rc==-1` 两个分支会改它,**`rc==-2`(卷忙)没有分支** → 紧随其后的 `for (i = done; i < n; …)` 回投循环一次都不执行 → 最多 8 条日志既不落盘也不回投、不计故障、不打日志。同段 `:531` 注释与 `docs/子系统设计/运行与自检记录设计说明.md` §4.4 都写"条目不丢(旧实现 -2 丢那一条)"→ **现状比旧实现更糟**(旧丢 1 条、现一次丢 8 条)。对照:**逐条慢路径遇 `-2` 会正确回投尾部**,同一错误码在两条路径行为相反。触发面常见:卷锁 v1.2 之后 ftpTask 传文件期间 defaultTask 每 2 s 排空一次 | TC-SYSLOG-035 / 035b / 035c | +| 2 | `App/log/sys_log_mcu.c:527~529` | `rc==-1` 打 `DBG_ERROR("drop %lu entries …")`,但该分支 `done=0` → 整批其实**全部回投**、一条没丢 → 文案与行为相反,且持续写故障时每轮 drain 都重投重试一遍 | TC-SYSLOG-036 | +| 3 | `App/log/sys_log_mcu.c:616` vs `627~628` | 守卫写成 `desc_len <= SYSLOG_DESC_MAX` 就 `-1`,而 `dl` 恒 ≤ 28 → 截断分支**永不可达**。无害(宁可拒绝不越界),但"传小缓冲能拿到截断内容"的直觉是错的 | TC-SYSLOG-098 / 057 | +| 4 | `Drivers/BSP/NET/net_socket.c:2825` / `:2869` / `:3080` | **同一文件两套 IP 字节序约定,`inet_addr` 与 `ntoa`/`getsockname` 不互逆**:`net_inet_addr` 小端组装后又 `NET_HTONL` → `0xC0A80164`;`net_inet_ntoa` 交换后高段在前 → 只能正确还原 `getsockname` 那族(`0x6401A8C0`);`net_connect_locked:1571` 用 `>>24` 取首段 → 只配 `inet_addr` 那族。**为什么长期不被看见**:`ntoa` 自己 `:2865~2867` 那段 E13 修复注释里的例子「192.168.1.100 → 0xC0A80164 → "192.168.1.100"」取的是**另一族**的值(交换宏下实算 `"100.1.168.192"`)。前提已核清:`Drivers/BSP/NET/net_types.h`:56 是 `#ifdef __BIG_ENDIAN` 才恒等,STM32 与 x86/MSVC 都走交换分支 → **不存在"主机语义≠目标语义"**。当前无调用者跨族(`lftpd.c`:864 走 `getpeername→ntoa` 同族恰好正确),属埋在库 API 面上的地雷;统一必须跨 `inet_addr`/`ntoa`/`getsockname`/`connect` 解析/`lftpd.c`:548 一起过 | TC-NETSOCK-100b / 100c / 100d、107f、430h(钉的是**具体数值**,统一时会五条一起红并互相指认,不会被误读成单点回归) | +| 5 | `Drivers/BSP/NET/net_socket.c:2408` / `:2537` | **UDP 两个公开 API 绕过 netTask 串行化**:`net_sendto`/`net_recvfrom` 直接 `net_poll()`(`:2482`/`:2501`/`:2563`)+ `ch395f_write_send_buf`(`:2487`)/`ch395f_read_recv_buf`(`:2575`/`:2604`),**全程不经 `netMsgQueue`** → 与 AGENTS「所有 `net_*` 经 netTask 串行执行」及 `docs/问题记录/CH395F_Trap_Records.md` TRAP-NET-14 正面冲突。全仓无生产调用者(只有 `test/board` 与模拟器)所以未爆。建议:改走消息队列,或在 `net_socket.h` 显式标注"未串行化,仅限 netTask" | TC-NETSOCK-440 | +| 6 | `App/log/soe_store.c` 函数头 ④ | 注释写"打开/写入结构性错误 → `unlink` 重试一次(同 run.log)",实现里 `f_open` 失败**直接 return -1、不重试**(只有 `f_write` 失败才重试);且 `run.log` 侧 `s_append_rec` 的重试码集合还含 `FR_INVALID_NAME`/`FR_NO_PATH` → 两套自愈集合不一致。属"注释夸大实现"还是"实现少一半"待裁 | TC-SOESTORE-022 | +| 7 | `App/cfg/cfg_file_store_mcu.c:50` | 函数头写"未挂载时 `f_open` 返回 `FR_NO_SYSTEM`"——**`FRESULT` 里没有这个码**(`Lib/FatFs/ff.h` 共 20 个码,未挂载实际是 `FR_NOT_READY`/`FR_NO_FILESYSTEM`)。只在注释里不参与编译,危害是后人照它写判据会写出永不成立的分支 | —(注释面) | +| 8 | `App/log/console_log.c:317` vs `:285` | 冷却起点用 `s_con_cool_until = (uint32_t)HAL_GetTick()`,而判据拿 `!= 0U` 当哨兵 → 故障恰好落在 `HAL_GetTick()==0` 那一拍(开机头 1 ms,或 49.7 天回绕到 0)时**冷却等于没记**,下一轮 drain 立刻再耗一次 100 ms 块超时。撞的正是控制台域那条已登记的"背靠背块超时拖长主循环 → 喂狗节奏饥饿"。建议:另用一个"在冷却"标志,别拿 tick 值当哨兵 | TC-CONLOG-081 / 082(按现状钉) | +| 9 | `App/log/console_log.c:394~412` | `con_append` 的**空位判据在 `taskENTER_CRITICAL` 之外**(check-then-act)。生产者按 `docs/子系统设计/控制台单写者日志设计说明.md` §3.1 是"全部任务 + 内核前主线程 + ISR",而同文档 §4.1 的 head/tail 行写作"单生产者/单写者"——**两处口径互斥,后者需要更正**。多生产者可在环 >95% 满时同时过判据 → `head` 各自推进后合计越过容量 → `con_used()` 回绕变小 → 写者少发一段,被覆盖的行**既不打 `[ERR]` 也不进 `dropped`**(静默损坏;索引恒取模,不会越界)。**这条没有用例、只报推断**:单线程宿主构造不出真抢占点(把注入点挂在 `taskENTER_CRITICAL` 上已在区内,不算抢占点) | —(无用例,仅登记) | +| 10 | `App/log/console_log.c:181~183` + `:306~331` | `huart1.Instance == NULL`(HAL 早期 / 断言-故障上报路径的专用短路,返回 `HAL_ERROR`)在 drain 里被当成**僵尸通道**走 `con_uart_reinit()` → `MX_USART1_UART_Init()`(含 MspInit 的 RCC/GPIO 重配)。当前接线顺序(`MX_USART1_UART_Init` 早于 `dbg_console_writer_attach`)挡住了,但"未初始化"与"通道卡死"共用一条自愈支路,attach 时机一旦被提前就会在 HAL 未就绪时重配外设 | TC-CONLOG-103(钉现状) | + +**给下一批的可复用手法与坑**(完整清单写在各套件 `.c` 文件头,这里只留跨模块通用的): + +1. **测试 TU 里 `#define fputc <改名符号>` 再纳入生产源**:`App/log/console_log.c`:492 的 + `int fputc(int, FILE*)` 与宿主 CRT 撞符号(`libucrt.lib(fputc.obj) error LNK2005` + `LNK1169`)。 + 改名纳入后函数体与行覆盖一字不动;顺带实测确认**宿主 printf 不走用户 `fputc`**(测试输出不会被吞进环形缓冲)。 +2. **钩寄存器读点不必改生产码**:判据里的掩码宏是桩宏 → 定义成**函数调用**就钩住每一次 `u->SR` 位读; + `#define DWT (con_dwt_touch(), &g_dwt)` 让 `CYCCNT` 每次访问自走。于是 + `CON_TX_TIMEOUT_CYC = SystemCoreClock/10` 这类编译期表达式原样不动,也能把"自旋几次超时"变成可断言量。 + ⚠ 结构体成员访问本身(`u->SR`/`u->DR`)钩不到,别绕进去。 +3. **跑飞保险要挂在被测模块的天然计数点上**:`net_send_locked`/`net_sendto`/`net_recvfrom` 的等待环里 + **没有 `osDelay`**,只给 `osDelay` 加保险挡不住这些死循环(实测第一次跑就真卡死)。挂在 `net_poll()` + 每轮必读的 `HAL_GPIO_ReadPin`(INT#)上,附带让 `NET_SEND_SPIN_MS` 这类有界自旋预算**第一次可量测**。 + 同一纪律的另一半:假内核对 `xTaskNotifyWait(portMAX_DELAY)` 泵 50 轮仍无通知就 `exit(2)` 打 `FATAL` —— + 它当场抓出一处把 `net_close` 返回语义判错的用例,而不是让门禁卡死。 +4. **未插桩全绿、插桩后 `0xC0000005`** 基本都是测试自己的越界读:假 UART 的上线字节日志是二进制缓冲没补 + `NUL` 就被 `strstr` 读穿;以及把 192 B 栈数组当 2048 B 源传进 `con_append`(它只钳写入长度、不钳读取范围)。 + 守则:凡是把缓冲当 C 字符串读的地方先补 `NUL` 或改 `memcmp` 定长比。 +5. **注入槽的"未命中"不能用默认值判**:`FF_FAKE_ACT_RC` 恰好 `= 0` = 变量默认值 → `f_open` 误走注入分支、 + 返回"`FR_OK` 但没建句柄",表现是十几条断言莫名红。要显式 `hit` 标志 + `act` 双条件。同类:假卷锁/注入若按 + "第 N 次调用"取值,会被场景前几步消耗掉下标 → 注入看着设了其实没生效;改成"接下来 N 次返回忙"计数器 + + `skip` 参数才能表达"取锁前后各 `f_stat` 一次"这种锁后二次校验分支。 +6. **假卷每文件簇池必须大于生产单文件容量上限**(`run.log`/`soe.log` = 1024×64 = 65,536 B)。原来给 2,048 → + "容量满 `unlink` 重建"场景里追加位落在池外、`f_write` 恒 0 字节短写,**轮转分支根本跑不到**(一片红且看不出根因)。 +7. **共享调用日志会被长轮询挤掉**:假芯片 `g_chip.log` 只有 96 格,一次 `sock_reset` 的几十轮把更早的顺序信息 + 挤出可查范围 → 顺序断言假绿/假红。做顺序断言前先 `clear_log`、用 `log_find` 查。 +8. **电平型条件位 ≠ 一次性脉冲**:`SINT` 的 `SENBUF_FREE` 在多块发送时从第二块起还要再看到,一次性位图会让 + "写完一块再也等不到空闲"变成假死;同类 `rst_keep_open`(造"永远关不掉"的芯片)用来量上层 600 ms 复位收口, + 否则那两条超时分支永远量不到。**新旋钮必须默认关**,存量套件行为才逐字不变。 +9. **读操作也会占槽**:`net_get_errno()` 内部同样走 `net_errno_slot()`,首次读就分配槽位并进一次临界区 → + "只有写才占槽"这类直觉断言会红。这是真实语义,不是缺陷。 +10. **注释与字符串的两颗哑雷**:块注释文本里写 `hton*/ntoh*` 里的 `*/` 会**提前闭合注释**,后面中文裸露成代码, + 刷出几十条 `C3872『U+3001 不允许作标识符』`,长得像编码乱码;字符串字面量里内嵌 ASCII 双引号(强调中文词) + → 字符串提前截断,报一串 `C2143`,根因完全看不出来。要引用一律全角「」。另:用 heredoc / + `python - <`:并行期每人只跑 `--module <自己模块>` + (子目录互不冲突);**绝不加 `--clean`**(它 `rmtree` 整棵共享根,会把队友正在编译的目录连根删掉, + 表现为"莫名其妙的编译失败、重跑就好");**不带 `--module` 的全量闸只在收口时由 Lead 单跑**。 + 另注:逐套 `run_unit_test.py` 写的是固定 `tempfile/_obj` 与 `.exe`,所以**同一套件同一时刻 + 只能一人跑**(不同套件互不干扰)。 + +**两处口径问答(免得下次重新推导)**:① 子集数不能和并集基线直接比 —— `soe_store` 单套对 +`App/log/soe_rec.c` 只到 78/87(89.7%),而台账是全部 33 套的**并集** 81/87(93.1%);本批 +`--update-baseline` 前后逐文件比对**下降文件数 = 0**,所以那不是回退。判据只有一条:全量闸的并集 ≥ 基线。 +② `console_log.c` 与 `soe_store.c` 那 1~2 行未覆盖都不是漏测:`console_log.c:352` 是 `snprintf` 溢出后的 +钳位(`note[64]`,最长 42 B → 数学上到不了)、`soe_store.c:202` 是 `for (;;)` 收尾大括号。 + ### P3|**明确不做**(把理由写死,免得下次又被列回待办:29 个 `.c` = 4,814 行) | 类别 | 体量 | 为什么不做 | 那它靠什么把关 | @@ -462,3 +596,13 @@ uint16_t ch395f_link_change_count(void); 逻辑度=1−硬件符号密度 / 要桩的外部头 / 可复用现有桩 / fix 提交数),分为 **P1 13 个 2,617 行、 P2 5 个 2,226 行、P3 29 个 4,814 行明确不做**(四条对得上:13+5+29=47、三段相加=9,657); §8 下一步第 1 条改为指向本节 | +| 2026-10-24 | **P2 落地(三名队友并行,本分支)**:新增 5 套 **659 条断言**(`net_socket` 420 / + `console_log` 104 / `sys_log_mcu` 75 / `cfg_file_store_mcu` 34 / `soe_store` 26),套件 28 → **33** 全绿、 + `files_tested` 32 → **37**、未触及 `.c` **34 → 29**(恒等式 `29 = 0(P1) + 0(P2) + 29(P3)`,清单吃穿)、 + 分母 4,684 → **6,245**、`line` 85.8% → **89.3%**、`func` 90.1% → **92.2%**(这批**没有摊薄**:5 个文件 + 全都以 ≥98.7% 进分母)。新建共享假 FatFs `test/host/common/fatfs_fake.{h,c}`、共享假 CH395F 纯扩展 + (两个与真头不符的常量改成真值 + 加真头/假件常量文本核对闸,负例验证过)。补 `TC-HOST-*` 五族 + (22 → 27)与 `scripts/test_coverage_baseline.json` 重登(逐文件比对:下降 0 个)。照出 **10 条现状** + (重点:`sys_log_mcu.c:509~537` 卷忙整批丢日志、`net_socket.c` 同文件两套 IP 字节序、UDP 两个公开 API + 绕过 netTask、`console_log.c` 冷却哨兵与空位判据),**生产码一行未改**,逐条登记在 §9「P2 落地」表内待裁定; + §8 第 1 条同步改为「清单已吃穿」 | diff --git a/scripts/id_registry.json b/scripts/id_registry.json index 5d704efa..c9a60b6f 100644 --- a/scripts/id_registry.json +++ b/scripts/id_registry.json @@ -1549,6 +1549,116 @@ "owner_note": "B 层口径(用户裁定 2026-10-21):这些号实测在代码目录外零消费者(唯一外部引用是 id_aliases.json 自己),故属主登记处 = 单测代码本身,不逐号补文档;class 仍为 TC → 跨模块同号由校验 4 抓", "cursor_note": "游标按实测最大号收录(2026-10-21):越界即视为未登记的冒号牌" }, + { + "name": "TC-HOST-NETSOCK", + "class": "TC", + "title": "主机侧单测用例号:CH395F socket 栈核心(fd 表分配/跨上下文消息/错误码经通知值回传/监听门控)(net_socket)", + "pattern": "\\bTC-NETSOCK-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/net_socket/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-NETSOCK-545", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(用户裁定 2026-10-21):这些号实测在代码目录外零消费者(唯一外部引用是 id_aliases.json 自己),故属主登记处 = 单测代码本身,不逐号补文档;class 仍为 TC → 跨模块同号由校验 4 抓", + "cursor_note": "游标按实测最大号收录(2026-10-21):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-SYSLOG", + "class": "TC", + "title": "主机侧单测用例号:运行与自检日志存储(定长记录 + 批量写 + 读窗口)(sys_log_mcu)", + "pattern": "\\bTC-SYSLOG-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/sys_log_mcu/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-SYSLOG-100", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(用户裁定 2026-10-21):这些号实测在代码目录外零消费者(唯一外部引用是 id_aliases.json 自己),故属主登记处 = 单测代码本身,不逐号补文档;class 仍为 TC → 跨模块同号由校验 4 抓", + "cursor_note": "游标按实测最大号收录(2026-10-21):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-CFGFST", + "class": "TC", + "title": "主机侧单测用例号:配置文件存储引擎(落盘与回读校验)(cfg_file_store_mcu)", + "pattern": "\\bTC-CFGFST-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/cfg_file_store_mcu/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-CFGFST-053", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(用户裁定 2026-10-21):这些号实测在代码目录外零消费者(唯一外部引用是 id_aliases.json 自己),故属主登记处 = 单测代码本身,不逐号补文档;class 仍为 TC → 跨模块同号由校验 4 抓", + "cursor_note": "游标按实测最大号收录(2026-10-21):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-SOESTORE", + "class": "TC", + "title": "主机侧单测用例号:SOE 事件顺序记录持久化(soe_store)", + "pattern": "\\bTC-SOESTORE-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/soe_store/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-SOESTORE-024", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(用户裁定 2026-10-21):这些号实测在代码目录外零消费者(唯一外部引用是 id_aliases.json 自己),故属主登记处 = 单测代码本身,不逐号补文档;class 仍为 TC → 跨模块同号由校验 4 抓", + "cursor_note": "游标按实测最大号收录(2026-10-21):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-CONLOG", + "class": "TC", + "title": "主机侧单测用例号:控制台单写者日志(环形缓冲 + TX 软复位自愈)(console_log)", + "pattern": "\\bTC-CONLOG-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/console_log/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-CONLOG-135", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(用户裁定 2026-10-21):这些号实测在代码目录外零消费者(唯一外部引用是 id_aliases.json 自己),故属主登记处 = 单测代码本身,不逐号补文档;class 仍为 TC → 跨模块同号由校验 4 抓", + "cursor_note": "游标按实测最大号收录(2026-10-21):越界即视为未登记的冒号牌" + }, { "name": "TC-HOST-ADCHEALTH", "class": "TC", diff --git a/scripts/test_coverage_baseline.json b/scripts/test_coverage_baseline.json index 30ca48fc..6d54c06b 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -64,6 +64,14 @@ "lines_covered": 572, "lines_valid": 652 }, + "App/cfg/cfg_file_store_mcu.c": { + "func_rate": 1.0, + "funcs_covered": 10, + "funcs_valid": 10, + "line_rate": 1.0, + "lines_covered": 97, + "lines_valid": 97 + }, "App/cfg/prot_set_store.c": { "func_rate": 1.0, "funcs_covered": 4, @@ -80,6 +88,14 @@ "lines_covered": 17, "lines_valid": 17 }, + "App/log/console_log.c": { + "func_rate": 1.0, + "funcs_covered": 15, + "funcs_valid": 15, + "line_rate": 0.9947, + "lines_covered": 188, + "lines_valid": 189 + }, "App/log/soe_rec.c": { "func_rate": 1.0, "funcs_covered": 7, @@ -88,6 +104,22 @@ "lines_covered": 81, "lines_valid": 87 }, + "App/log/soe_store.c": { + "func_rate": 1.0, + "funcs_covered": 4, + "funcs_valid": 4, + "line_rate": 0.9873, + "lines_covered": 78, + "lines_valid": 79 + }, + "App/log/sys_log_mcu.c": { + "func_rate": 1.0, + "funcs_covered": 12, + "funcs_valid": 12, + "line_rate": 0.9904, + "lines_covered": 310, + "lines_valid": 313 + }, "App/protocol/iec104/iec104_asdu.c": { "func_rate": 1.0, "funcs_covered": 10, @@ -240,6 +272,14 @@ "lines_covered": 100, "lines_valid": 117 }, + "Drivers/BSP/NET/net_socket.c": { + "func_rate": 1.0, + "funcs_covered": 52, + "funcs_valid": 52, + "line_rate": 0.9977, + "lines_covered": 881, + "lines_valid": 883 + }, "Drivers/BSP/NETCFG/ftp_cfg.c": { "func_rate": 1.0, "funcs_covered": 4, @@ -280,6 +320,20 @@ "lines_total": 652, "ok": true }, + "cfg_file_store_mcu": { + "files": { + "App/cfg/cfg_file_store_mcu.c": 97 + }, + "lines_total": 97, + "ok": true + }, + "console_log": { + "files": { + "App/log/console_log.c": 189 + }, + "lines_total": 189, + "ok": true + }, "freq": { "files": { "App/algo/freq.c": 130, @@ -390,6 +444,13 @@ "lines_total": 117, "ok": true }, + "net_socket": { + "files": { + "Drivers/BSP/NET/net_socket.c": 883 + }, + "lines_total": 883, + "ok": true + }, "phasor": { "files": { "App/algo/phasor.c": 55, @@ -439,6 +500,14 @@ "lines_total": 190, "ok": true }, + "soe_store": { + "files": { + "App/log/soe_rec.c": 87, + "App/log/soe_store.c": 79 + }, + "lines_total": 166, + "ok": true + }, "sys_clock": { "files": { "App/util/sys_clock.c": 157 @@ -446,6 +515,13 @@ "lines_total": 157, "ok": true }, + "sys_log_mcu": { + "files": { + "App/log/sys_log_mcu.c": 313 + }, + "lines_total": 313, + "ok": true + }, "task_prof": { "files": { "App/util/task_prof.c": 93 @@ -486,9 +562,13 @@ "App/algo/prot_set.c", "App/algo/sample_ring.c", "App/cfg/cfg_file.c", + "App/cfg/cfg_file_store_mcu.c", "App/cfg/prot_set_store.c", "App/fs/vol_lock.c", + "App/log/console_log.c", "App/log/soe_rec.c", + "App/log/soe_store.c", + "App/log/sys_log_mcu.c", "App/protocol/iec104/iec104_asdu.c", "App/protocol/iec104/iec104_link.c", "App/protocol/iec104/iec104_pts.c", @@ -508,27 +588,24 @@ "Drivers/BSP/NET/net_listener.c", "Drivers/BSP/NET/net_pending.c", "Drivers/BSP/NET/net_select.c", + "Drivers/BSP/NET/net_socket.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 + "files_tested": 37, + "files_untested": 107, + "func_rate": 0.9225, + "funcs_covered": 393, + "funcs_valid": 426, + "line_rate": 0.8926, + "lines_covered": 5574, + "lines_valid": 6245, + "modules_ok": 33, + "modules_total": 33 }, "untested_sources": [ - { - "code_lines": 1251, - "path": "Drivers/BSP/NET/net_socket.c" - }, { "code_lines": 801, "path": "Drivers/BSP/CH395F/ch395f.c" @@ -537,10 +614,6 @@ "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" @@ -557,10 +630,6 @@ "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" @@ -593,18 +662,10 @@ "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" diff --git a/test/host/cfg_file_store_mcu/cfg_file_store_mcu_test.c b/test/host/cfg_file_store_mcu/cfg_file_store_mcu_test.c new file mode 100644 index 00000000..70d5f4b9 --- /dev/null +++ b/test/host/cfg_file_store_mcu/cfg_file_store_mcu_test.c @@ -0,0 +1,666 @@ +/* + * 模块名称:配置存储后端(cfg_file_store_mcu)主机侧单元测试(L0.5) + * 模块功能:在 PC 上离线验证 App/cfg/cfg_file_store_mcu.c 的五个卷操作 + 三个堆操作: + * cfg_store_read / cfg_store_write_atomic / cfg_store_stat / + * cfg_store_remove / 两个路径 getter / alloc-free-heap_free + * 重心放在**原子写**与它的 v1.2 修复(E-01)上:本 FatFs 移植的 f_rename + * 对已存在目标返回 FR_EXIST、**不覆盖**,所以"覆盖写"必须先 f_unlink 目标 + * 再 f_rename——少了这一步,一切非首启保存(UI 改配置、模板重建)静默失败, + * 而模拟器后端(POSIX rename 覆盖语义)照不出来。 + * + * 被测对象:真实源文件 App/cfg/cfg_file_store_mcu.c(#include 原样纳入)。 + * 假件与桩: + * test/host/common/fatfs_fake.{h,c} —— 假 FatFs 内存卷(真实 ff.h 声明; + * 它的 f_rename **如实返回 FR_EXIST**,正是 E-01 的那颗雷) + * stub/FreeRTOS.h —— 三个分配器原型(签名逐条对齐 portable.h:163/164/166) + * 假卷锁 / 假堆(可注入分配失败)写在本文件里 + * + * 为什么要主机侧单测(而不是只靠台架): + * 1. **E-01 原子写**:unlink→rename 这个非原子窗口是"掉电时配置暂失、开机按首启 + * 重建工厂模板"的有意取舍。任何一次"顺手优化"(比如给 rename 加覆盖、或把 + * unlink 挪到 rename 之后)都会让配置保存静默失败或留下脏 TMP,而这在 POSIX + * 后端上完全看不出来。→ 假卷把 FR_EXIST 原样喂进来。 + * 2. **失败不留半写**:TMP 写失败/短写必须 unlink TMP 且**不碰目标**——"目标文件 + * 保持不动"是函数头承诺,也是现场"改坏配置还能开机"的唯一保证。 + * 3. **卷锁 try 语义**:v1.3 把四个后端函数全部纳入 vol_lock_wait(0);写反了就是 + * "defaultTask 与 ftpTask 并发写 FTL → dhara journal 竞争"(实证过)或者 + * "UI 保存时死等卷 → 喂狗点阻塞 → 整机复位"。四个函数的 try/release 配对 + * 只能靠计数断言。 + * 4. **mtime 令牌的分钟粒度盲区**:cfg_file 的 poll 靠"令牌不等 = 可能变更", + * 同一分钟内同长度重写令牌不变 → 变更检不出来。这是设计接受的限制,但必须 + * 钉在测试里,否则后人会以为令牌是内容哈希。 + * 5. **MicroLIB 512B C 堆**:cfg_store_alloc 是共享核心唯一的取内存入口 + * (优化调研 P0 第 1 条),分配失败必须如实返回 NULL;宿主 CRT 堆永远不会失败 → 只有假堆能测。 + * + * 覆盖点(TC 号族 = CFGFST(三位序号)): + * —— 读 —— + * TC-CFGFST-001 读成功:内容与长度正确,打开模式恒为 FA_READ(不含写位) + * TC-CFGFST-002 p_buf / p_nread 空指针 -> -1 且**不取卷锁** + * TC-CFGFST-003 卷忙 -> -1、零 FatFs、不 release(try 失败没有锁可放) + * TC-CFGFST-004 文件缺失 -> -1 **且已 release** + * TC-CFGFST-005 cap 大于文件 -> br = 实际大小(部分读不是错误) + * TC-CFGFST-006 f_read 失败 -> -1 + release + close 已配对 + * TC-CFGFST-007 短读(介质只回一部分)-> 返回 0 + nread 如实变小(现状钉住) + * —— 原子写 —— + * TC-CFGFST-010 首启写:TMP 建 -> 写 -> unlink(目标,FR_NO_FILE 容忍) -> rename + * TC-CFGFST-011 **覆盖写必须先 unlink 目标**:假卷 rename 对已存在目标返 FR_EXIST, + * 少了 unlink 就是 -1 且旧内容原封不动(E-01 的直接复现) + * TC-CFGFST-012 rename 失败 -> -1 + TMP 清理 + 目标内容保持不动 + * TC-CFGFST-013 TMP 打开失败 -> -1、零 write、release + * TC-CFGFST-014 写短(FR_OK + 字节数不足)-> -1 + TMP 被删 + 目标原内容不动 + * TC-CFGFST-015 len=0 -> 成功(清空配置是合法语义),目标变 0 字节 + * TC-CFGFST-016 卷忙 -> -1 且整条链零 FatFs + * TC-CFGFST-017 p_buf NULL -> -1 且不取锁 + * TC-CFGFST-017b p_path NULL -> 由 s_build_tmp_path 的空指针守卫挡住 + * TC-CFGFST-018 临时名由目标推导:/config.ini -> /config.TMP;/cfg/a.ini -> + * /cfg/config.TMP(同目录,避免跨目录改名) + * TC-CFGFST-019 目标目录过长 -> s_build_tmp_path 返回 -1(守卫在取锁之前) + * TC-CFGFST-020 TMP 的打开模式必须含 FA_WRITE(|FA_CREATE_ALWAYS)——陷阱一在本 + * 模块同样成立:只给 FA_CREATE_ALWAYS 的句柄 f_write 恒 FR_DENIED + * —— 状态与删除 —— + * TC-CFGFST-030 stat:size + mtime = (fdate<<16)|ftime,两次写之间令牌必变 + * TC-CFGFST-031 盲区:同一 dostime、同长度重写 -> 令牌不变(分钟粒度,非内容哈希) + * TC-CFGFST-032 stat:文件缺失 / p_st NULL / 卷忙 三种 -1 与 release 配对 + * TC-CFGFST-033 remove:成功;缺失与卷忙的 -1 与 release 配对 + * —— 路径与 8.3 —— + * TC-CFGFST-040 config_path / tmp_path 返回值在严格 8.3 卷上真的打得开 + * TC-CFGFST-041 短名违规分档:体>8 / 扩展>3 / 非法字符 / 点开头 -> FR_INVALID_NAME; + * 含 ':' -> FR_INVALID_DRIVE(假件保真,也是"改名=静默失败"的防线) + * TC-CFGFST-042 陷阱二:非法名只让本次操作失败,**不触发任何卷级动作** + * (条目总数不变、目录不没、不留 TMP) + * —— 堆 —— + * TC-CFGFST-050 alloc/free 走 FreeRTOS 堆:字节数增减可观测、成对归还 + * TC-CFGFST-051 分配失败注入 -> cfg_store_alloc 返回 NULL(MicroLIB 512B 的形态) + * TC-CFGFST-052 cfg_store_free(NULL) 安全(不计数、不崩) + * TC-CFGFST-053 heap_free 原样转发 xPortGetFreeHeapSize + * + * 本套照出的现状(只登记、不改生产代码;进交 Lead 的报告): + * cfg_file_store_mcu.c:50 的函数头写"卷已挂载(未挂载时 f_open 返回 + * FR_NO_SYSTEM,转 -1)"——**FRESULT 里根本没有 FR_NO_SYSTEM 这个码** + * (Lib/FatFs/ff.h 的枚举是 FR_OK..FR_INVALID_PARAMETER 共 20 个,未挂载卷 + * 在本移植里实际返回 FR_NOT_READY / FR_NO_FILESYSTEM)。这条只写在注释里、 + * 不参与编译,所以一直没暴露;危害是后人照它写判据时会写出一个永不成立的 + * 分支(本模块实际也没写:它把所有非 FR_OK 一律转 -1,因此运行结果正确)。 + * + * 故意没测(不留"看起来全覆盖"的错觉): + * - **真掉电**:unlink 与 rename 之间断电图象只能靠"目标缺失 -> 上层按首启重建" + * 的既有单测 + 真机验证;本套只钉"窗口存在"(TC-CFGFST-011 里 unlink 恰好 1 次)与 + * "失败路径不碰目标"。 + * - 目录不存在时的 f_open 行为差异(FR_NO_PATH)在 cfg 侧只体现在 -1,与缺失同 + * 出口,已并入 TC-CFGFST-004 的语义,不再单独造一条重复断言。 + * - 卷未挂载(真实 ff.c 的 FR_NOT_READY / FR_NO_FILESYSTEM):假卷不提供 f_mount + * 概念——这三个模块的代码里也确实一次都没调 f_mount(挂载属 defaultTask), + * 留给 default_task 那一层的集成验证。 + * - cfg_file.c 共享核心(928 行)不在本批:它有自己的存量单测口径。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-24(P2 主机单测覆盖率批次,task-2) + */ + +#include +#include +#include +#include + +#include "ff.h" +#include "fatfs_fake.h" +#include "FreeRTOS.h" /* 桩:三个分配器原型 */ +#include "cfg_file_store.h" /* 真实头 */ +#include "vol_lock.h" /* 真实头 */ + +/* ===================== 断言框架 ===================== */ + +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) + +#define P_CFG "/config.ini" +#define P_TMP "/config.TMP" + +/* ===================== 假件与生产源码纳入 ===================== */ + +#include "fatfs_fake.c" +#include "cfg_file_store_mcu.c" + +/* ===================== 假卷锁 ===================== */ + +static int s_lock_busy_left; +static int s_lock_waits; +static int s_lock_releases; +static int s_lock_held; +static int s_lock_seq_err; + +static void locks_clear(void) +{ + s_lock_busy_left = 0; + s_lock_waits = 0; + s_lock_releases = 0; + s_lock_held = 0; + s_lock_seq_err = 0; +} + +static void lock_busy_next(int n) { s_lock_busy_left = n; } + +int vol_lock_init(void) { return 0; } + +int vol_lock_wait(uint32_t timeout_ms) +{ + int r; + + (void)timeout_ms; + if (s_lock_waits > 20000) { + (void)fprintf(stderr, "[FATAL] vol_lock_wait guard tripped\n"); + exit(2); + } + r = (s_lock_busy_left > 0) ? 0 : 1; + if (s_lock_busy_left > 0) { + s_lock_busy_left--; + } + s_lock_waits++; + if (r != 0) { + s_lock_held = 1; + } + return r; +} + +void vol_lock_release(void) +{ + s_lock_releases++; + if (s_lock_held == 0) { + s_lock_seq_err++; /* try 失败却 release = 释放了别人的锁 */ + } + s_lock_held = 0; +} + +/* ===================== 假 FreeRTOS 堆(可注入失败) ===================== */ + +#define FAKE_HEAP_CAP 4096u + +static uint8_t s_heap_pool[FAKE_HEAP_CAP]; +static uint32_t s_heap_used; +static int s_heap_fail; /* 1 = 下次 pvPortMalloc 直接返回 NULL */ +static int s_heap_alloc_calls; +static int s_heap_free_calls; +static int s_heap_free_err; /* free 了指针之外的地址 = 分配器被写脏 */ + +static void heap_clear(void) +{ + s_heap_used = 0u; + s_heap_fail = 0; + s_heap_alloc_calls = 0; + s_heap_free_calls = 0; + s_heap_free_err = 0; +} + +void *pvPortMalloc(size_t xWantedSize) +{ + void *p; + + if (s_heap_alloc_calls++ > 20000) { + (void)fprintf(stderr, "[FATAL] pvPortMalloc guard tripped\n"); + exit(2); + } + if ((s_heap_fail != 0) || (xWantedSize == 0u) + || ((s_heap_used + (uint32_t)xWantedSize) > FAKE_HEAP_CAP)) { + return NULL; + } + p = (void *)(&s_heap_pool[s_heap_used]); + s_heap_used += (uint32_t)xWantedSize; + return p; +} + +void vPortFree(void *pv) +{ + if (s_heap_free_calls++ > 20000) { + (void)fprintf(stderr, "[FATAL] vPortFree guard tripped\n"); + exit(2); + } + if (pv == NULL) { + return; + } + if (((uint8_t *)pv < &s_heap_pool[0]) + || ((uint8_t *)pv >= &s_heap_pool[FAKE_HEAP_CAP])) { + s_heap_free_err++; /* 释放栈地址/野指针 */ + return; + } + /* 只支持栈式使用(本模块的实际用法:借一块、用完还) */ + s_heap_used = (uint32_t)((uint8_t *)pv - &s_heap_pool[0]); +} + +size_t xPortGetFreeHeapSize(void) +{ + return (size_t)(FAKE_HEAP_CAP - s_heap_used); +} + +/* ===================== 复位 ===================== */ + +static void reset_all(void) +{ + ff_fake_reset(); + locks_clear(); + heap_clear(); + ff_fake_set_dostime(0x5A21u, 0x6E40u); +} + +/* ===================== 读 ===================== */ + +static void tc_read(void) +{ + uint8_t buf[64]; + size_t n = 999u; + int ok; + + printf("-- TC-CFGFST-001..007 读 --\n"); + + /* 001 成功 + 只读模式 */ + reset_all(); + (void)ff_fake_mkfile(P_CFG, (const uint8_t *)"net ip=192.168.1.100\n", 20u); + ok = (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == 0) && (n == 20u) + && (memcmp(buf, "net ip=192.168.1.100\n", 20u) == 0) + && (g_ff_obs.last_open_mode == (BYTE)FA_READ) + && (s_lock_waits == 1) && (s_lock_releases == 1) && (s_lock_seq_err == 0); + CHECK(ok, "TC-CFGFST-001 读成功:内容与长度正确,打开模式就是 FA_READ(不带写位)"); + + /* 002 空指针守卫在取锁之前 */ + reset_all(); + ok = (cfg_store_read(P_CFG, NULL, sizeof(buf), &n) == -1) + && (cfg_store_read(P_CFG, buf, sizeof(buf), NULL) == -1); + CHECK(ok && (s_lock_waits == 0) && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0), + "TC-CFGFST-002 空指针 -> -1 且不取卷锁(守卫顺序正确)"); + + /* 003 卷忙 */ + reset_all(); + lock_busy_next(1); + ok = (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == -1); + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0) && (s_lock_releases == 0) + && (s_lock_seq_err == 0), + "TC-CFGFST-003 卷忙 -> -1:零 FatFs、不 release(没拿到锁不放锁)"); + + /* 004 文件缺失:必须已放锁 */ + reset_all(); + ok = (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == -1); + CHECK(ok && (s_lock_releases == 1) && (s_lock_seq_err == 0), + "TC-CFGFST-004 文件缺失 -> -1 **且已释放卷锁**(漏放 = 之后所有卷操作全瘫)"); + + /* 005 cap > 文件大小 */ + reset_all(); + (void)ff_fake_mkfile(P_CFG, (const uint8_t *)"abc", 3u); + n = 999u; + ok = (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == 0) && (n == 3u) + && (g_ff_obs.last_read_btr == 64u) && (g_ff_obs.last_read_br == 3u); + CHECK(ok, "TC-CFGFST-005 要 64 只给 3 -> nread=3 且仍算成功(短读不是错误)"); + + /* 006 f_read 失败 */ + reset_all(); + (void)ff_fake_mkfile(P_CFG, (const uint8_t *)"abc", 3u); + ff_fake_inject_rc(P_CFG, FF_FAKE_OP_READ, FR_DISK_ERR, 1); + ok = (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == -1); + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_CLOSE] == 1) + && (s_lock_releases == 1) && (s_lock_seq_err == 0), + "TC-CFGFST-006 读失败 -> -1 + close 已配对 + 放锁"); + + /* 007 介质短读:现状是"返回 0 + nread 如实变小",调用方靠 nread 定长度 */ + reset_all(); + (void)ff_fake_mkfile(P_CFG, (const uint8_t *)"0123456789", 10u); + ff_fake_inject_read_short(P_CFG, 4u, 1); + n = 999u; + ok = (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == 0) && (n == 4u); + CHECK(ok, "TC-CFGFST-007 注入短读 -> 返回 0、nread=4(上层按 nread 截断使用)"); +} + +/* ===================== 原子写 ===================== */ + +static void tc_write_atomic(void) +{ + uint8_t buf[64]; + size_t n; + int ok; + + printf("-- TC-CFGFST-010..020 原子写(E-01 核心)--\n"); + + /* 010 首启写(目标不存在) */ + reset_all(); + ok = (cfg_store_write_atomic(P_CFG, (const uint8_t *)"v1", 2u) == 0); + CHECK(ok && (ff_fake_size(P_CFG) == 2u) && (ff_fake_exists(P_TMP) == 0) + && (g_ff_obs.calls[FF_FAKE_OP_RENAME] == 1) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) /* unlink 目标:FR_NO_FILE 无害 */ + && (strcmp(g_ff_obs.last_rename_old, "/CONFIG.TMP") == 0) + && (strcmp(g_ff_obs.last_rename_new, "/CONFIG.INI") == 0) + && (s_lock_releases == 1) && (s_lock_seq_err == 0), + "TC-CFGFST-010 首启写:TMP -> rename -> 目标 2 字节、TMP 不留痕"); + + /* 011 覆盖写:假卷的 f_rename 对已存在目标返回 FR_EXIST —— 这就是 E-01 */ + reset_all(); + ok = (cfg_store_write_atomic(P_CFG, (const uint8_t *)"old-content", 11u) == 0); + { + int unlinks_before = g_ff_obs.calls[FF_FAKE_OP_UNLINK]; + ok = ok && (cfg_store_write_atomic(P_CFG, (const uint8_t *)"new", 3u) == 0); + CHECK(ok && (ff_fake_size(P_CFG) == 3u) + && (ff_fake_fetch(P_CFG, buf, 0u, sizeof(buf)) == 3u) + && (memcmp(buf, "new", 3u) == 0) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == unlinks_before + 1), + "TC-CFGFST-011 覆盖写:unlink 目标恰一次再 rename -> 新内容落地(E-01)"); + } + /* 同一条的"反面":直接 rename 到已存在目标必败(证明上一步的 unlink 不是多余的) */ + { + static FIL probe; + FRESULT fr; + + (void)ff_fake_mkfile(P_TMP, (const uint8_t *)"x", 1u); + fr = f_rename(P_TMP, P_CFG); + CHECK(fr == FR_EXIST, + "TC-CFGFST-011b 假件保真:目标已存在时 f_rename 返回 FR_EXIST、不覆盖"); + if (fr == FR_OK) { + (void)f_close(&probe); + } + } + + /* 012 rename 失败 -> TMP 清理 + 目标不动 */ + reset_all(); + (void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"keep-me", 7u); + ff_fake_inject_rc(P_TMP, FF_FAKE_OP_RENAME, FR_DENIED, 1); /* 键 = 旧路径 */ + ok = (cfg_store_write_atomic(P_CFG, (const uint8_t *)"replace", 7u) == -1); + /* 目标在 rename 之前已被 unlink(非原子窗口),所以这里钉的是: + * TMP 一定被清掉、锁一定释放、绝不留半成品 */ + CHECK(ok && (ff_fake_exists(P_TMP) == 0) && (ff_fake_exists(P_CFG) == 0) + && (s_lock_releases == 2) && (s_lock_seq_err == 0), + "TC-CFGFST-012 rename 失败 -> -1 + TMP 清理 + 放锁( unlink→rename 窗口如实存在)"); + + /* 013 TMP 打不开 */ + reset_all(); + (void)ff_fake_mkfile(P_CFG, (const uint8_t *)"orig", 4u); + ff_fake_inject_rc(P_TMP, FF_FAKE_OP_OPEN, FR_DISK_ERR, 1); + ok = (cfg_store_write_atomic(P_CFG, (const uint8_t *)"new", 3u) == -1); + CHECK(ok && (ff_fake_size(P_CFG) == 4u) && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 0) + && (g_ff_obs.calls[FF_FAKE_OP_RENAME] == 0) + && (s_lock_releases == 1) && (s_lock_seq_err == 0), + "TC-CFGFST-013 TMP 打开失败 -> -1 且**目标原内容一个字节没动**"); + + /* 014 短写 = 失败:TMP 被删、目标不动、不 rename */ + reset_all(); + (void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"good-config", 11u); + /* 本次要写 11 字节,注入"只回 5"才算真短 */ + ff_fake_inject_write_short(P_TMP, 5u, 1); + ok = (cfg_store_write_atomic(P_CFG, (const uint8_t *)"bad-content", 11u) == -1); + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_RENAME] == 1) + && (ff_fake_exists(P_TMP) == 0) + && (ff_fake_size(P_CFG) == 11u) + && (ff_fake_fetch(P_CFG, buf, 0u, sizeof(buf)) == 11u) + && (memcmp(buf, "good-config", 11u) == 0) + && (s_lock_seq_err == 0), + "TC-CFGFST-014 TMP 短写判失败 -> 删 TMP、不 rename、目标仍是旧内容"); + + /* 015 len = 0 合法(清空配置) */ + reset_all(); + (void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"x", 1u); + ok = (cfg_store_write_atomic(P_CFG, (const uint8_t *)"", 0u) == 0); + n = 999u; + CHECK(ok && (ff_fake_size(P_CFG) == 0u) + && (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == 0) && (n == 0u), + "TC-CFGFST-015 写 0 字节成功(清空配置),回读 nread=0"); + + /* 016 卷忙:整条链零 I/O */ + reset_all(); + (void)ff_fake_mkfile(P_CFG, (const uint8_t *)"orig", 4u); + lock_busy_next(1); + ok = (cfg_store_write_atomic(P_CFG, (const uint8_t *)"new", 3u) == -1); + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0) + && (g_ff_obs.calls[FF_FAKE_OP_RENAME] == 0) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 0) + && (ff_fake_size(P_CFG) == 4u) && (s_lock_releases == 0), + "TC-CFGFST-016 卷忙 -> -1:不建 TMP、不动目标、不 release"); + + /* 017 p_buf NULL */ + reset_all(); + ok = (cfg_store_write_atomic(P_CFG, NULL, 3u) == -1); + CHECK(ok && (s_lock_waits == 0) && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0), + "TC-CFGFST-017 p_buf NULL -> -1 且不取锁"); + + /* 017b 目标路径本身是 NULL:s_build_tmp_path 的空指针分支(读函数有守卫, + * 写函数**没有** p_path 的显式守卫,全靠推导函数把它挡在取锁之前) */ + reset_all(); + (void)ff_fake_mkfile(P_CFG, (const uint8_t *)"keep", 4u); + ok = (cfg_store_write_atomic(NULL, (const uint8_t *)"x", 1u) == -1); + CHECK(ok && (s_lock_waits == 0) && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0) + && (ff_fake_size(P_CFG) == 4u), + "TC-CFGFST-017b p_path NULL -> -1:不取锁、不开文件、目标不动"); + + /* 018 临时名由目标推导(同目录) */ + reset_all(); + (void)ff_fake_mkdir_direct("/cfg"); + ok = (cfg_store_write_atomic("/cfg/a.ini", (const uint8_t *)"z", 1u) == 0); + CHECK(ok && (strcmp(g_ff_obs.last_open_path, "/CFG/CONFIG.TMP") == 0) + && (ff_fake_exists("/cfg/config.TMP") == 0) /* 已被 rename 走,不是留档 */ + && (strcmp(g_ff_obs.last_rename_new, "/CFG/A.INI") == 0) + && (ff_fake_size("/cfg/a.ini") == 1u), + "TC-CFGFST-018 目标是 /cfg/a.ini -> 临时文件落在同目录 /cfg/config.TMP"); + CHECK(strcmp(cfg_store_tmp_path(), P_TMP) == 0, + "TC-CFGFST-018b getter 仍是卷根 /config.TMP(与 s_path_tmp 同源)"); + + /* 019 目录太长 -> 推导失败,且发生在取锁之前 */ + reset_all(); + ok = (cfg_store_write_atomic("/this/is/a/very/deep/dir/name/x.ini", + (const uint8_t *)"y", 1u) == -1); + CHECK(ok && (s_lock_waits == 0) && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0), + "TC-CFGFST-019 临时名缓冲放不下 -> -1 且不取锁(不拿半截路径去开文件)"); + + /* 020 陷阱一在本模块同样成立 */ + reset_all(); + (void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"m", 1u); + CHECK((g_ff_obs.last_open_mode & FA_WRITE) != 0u, + "TC-CFGFST-020 写 TMP 的句柄必带 FA_WRITE(这里用 CREATE_ALWAYS,不是 APPEND)"); + reset_all(); + { + static FIL probe; + UINT bw = 7u; + FRESULT fr1; + FRESULT fr2; + + fr1 = f_open(&probe, P_TMP, FA_CREATE_ALWAYS); /* 故意漏掉 FA_WRITE */ + fr2 = f_write(&probe, "abc", 3u, &bw); + (void)f_close(&probe); + CHECK((fr1 == FR_OK) && (fr2 == FR_DENIED) && (bw == 0u), + "TC-CFGFST-020b 只给 FA_CREATE_ALWAYS -> 打开成功但 f_write 恒 FR_DENIED"); + } +} + +/* ===================== stat / remove ===================== */ + +static void tc_stat_remove(void) +{ + cfg_file_stat_t st; + uint32_t tok1; + uint32_t tok2; + int rel; + int ok; + + printf("-- TC-CFGFST-030..033 状态令牌与删除 --\n"); + + /* 030 size + 令牌 */ + reset_all(); + ff_fake_set_dostime(0x5A21u, 0x6E40u); + (void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"abcd", 4u); + tok1 = ((0x5A21u << 16) | 0x6E40u); + ok = (cfg_store_stat(P_CFG, &st) == 0) && (st.size == 4u) && (st.mtime == tok1); + ff_fake_set_dostime(0x5A22u, 0x6E40u); /* 次日同一时刻 */ + (void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"efgh", 4u); + tok2 = ((0x5A22u << 16) | 0x6E40u); + ok = ok && (cfg_store_stat(P_CFG, &st) == 0) && (st.mtime == tok2) + && (tok1 != tok2); + CHECK(ok && (s_lock_seq_err == 0) && (s_lock_releases == s_lock_waits), + "TC-CFGFST-030 令牌 = (fdate<<16)|ftime,日期进位一定换令牌"); + + /* 031 盲区:同一分钟、同长度重写 -> 令牌不变 */ + reset_all(); + (void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"aaaa", 4u); + (void)cfg_store_stat(P_CFG, &st); + ok = (st.size == 4u); + { + uint32_t before = st.mtime; + (void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"bbbb", 4u); + (void)cfg_store_stat(P_CFG, &st); + CHECK(ok && (st.size == 4u) && (st.mtime == before), + "TC-CFGFST-031 同分钟同长度重写 -> 令牌不变(poll 检不出,设计已接受)"); + } + + /* 032 stat 三种 -1 */ + reset_all(); + rel = s_lock_releases; + ok = (cfg_store_stat(P_CFG, NULL) == -1) && (s_lock_releases == rel); /* 守卫在取锁前 */ + ok = ok && (cfg_store_stat(P_CFG, &st) == -1) && (s_lock_releases == rel + 1); + (void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"x", 1u); + rel = s_lock_releases; + lock_busy_next(1); + ok = ok && (cfg_store_stat(P_CFG, &st) == -1) && (s_lock_releases == rel); + rel = s_lock_releases; + ff_fake_inject_rc(P_CFG, FF_FAKE_OP_STAT, FR_DISK_ERR, 1); + ok = ok && (cfg_store_stat(P_CFG, &st) == -1) && (s_lock_releases == rel + 1) + && (s_lock_seq_err == 0); + CHECK(ok, "TC-CFGFST-032 stat:NULL 守卫/缺失/卷忙/读错误四种出口的放锁配对"); + + /* 033 remove */ + reset_all(); + (void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"x", 1u); + rel = s_lock_releases; + ok = (cfg_store_remove(P_CFG) == 0) && (ff_fake_exists(P_CFG) == 0) + && (s_lock_releases == rel + 1); + ok = ok && (cfg_store_remove(P_CFG) == -1) /* 再删:文件已不在 */ + && (s_lock_releases == rel + 2); + lock_busy_next(1); + ok = ok && (cfg_store_remove(P_CFG) == -1) && (s_lock_releases == rel + 2) + && (s_lock_seq_err == 0); + CHECK(ok, "TC-CFGFST-033 remove:成功 / 已不存在的 -1 / 卷忙的 -1 与放锁配对"); +} + +/* ===================== 路径与 8.3(陷阱二) ===================== */ + +static void tc_names(void) +{ + static FIL probe; + uint8_t rbuf[16]; + size_t rn; + cfg_file_stat_t st_dummy; + uint32_t ents_before; + int rel; + FRESULT fr_body; + FRESULT fr_ext; + FRESULT fr_bad; + FRESULT fr_dot; + FRESULT fr_colon; + int ok; + + printf("-- TC-CFGFST-040..042 路径合规与「名字错不升级成卷操作」 --\n"); + + /* 040 生产路径真的可用(改名即静默失败的第一道防线) */ + reset_all(); + ok = (strcmp(cfg_store_config_path(), P_CFG) == 0) + && (f_open(&probe, cfg_store_config_path(), FA_WRITE | FA_CREATE_ALWAYS) == FR_OK) + && (f_close(&probe) == FR_OK) + && (f_open(&probe, cfg_store_tmp_path(), FA_WRITE | FA_CREATE_ALWAYS) == FR_OK) + && (f_close(&probe) == FR_OK); + CHECK(ok, "TC-CFGFST-040 /config.ini 与 /config.TMP 都是合法 8.3,真能在卷上建出来"); + + /* 041 短名违规分档 */ + reset_all(); + fr_body = f_open(&probe, "/configlooooong.ini", FA_WRITE | FA_CREATE_ALWAYS); + if (fr_body == FR_OK) { (void)f_close(&probe); } + fr_ext = f_open(&probe, "/config.ini2", FA_WRITE | FA_CREATE_ALWAYS); + if (fr_ext == FR_OK) { (void)f_close(&probe); } + fr_bad = f_open(&probe, "/conf?ig.ini", FA_WRITE | FA_CREATE_ALWAYS); + if (fr_bad == FR_OK) { (void)f_close(&probe); } + fr_dot = f_open(&probe, "/.fschk", FA_WRITE | FA_CREATE_ALWAYS); + if (fr_dot == FR_OK) { (void)f_close(&probe); } + fr_colon = f_open(&probe, "1:/config.ini", FA_READ); + if (fr_colon == FR_OK) { (void)f_close(&probe); } + CHECK((fr_body == FR_INVALID_NAME) && (fr_ext == FR_INVALID_NAME) + && (fr_bad == FR_INVALID_NAME) && (fr_dot == FR_INVALID_NAME) + && (fr_colon == FR_INVALID_DRIVE), + "TC-CFGFST-041 分档:体>8 / 扩展>3 / 非法字符 / 点开头 -> FR_INVALID_NAME;':' -> FR_INVALID_DRIVE"); + + /* 042 名字类错误码绝不升级成卷操作:非法目标名走一遍原子写 */ + reset_all(); + (void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"keep", 4u); + ents_before = (uint32_t)ff_fake_count_entries(); + rn = 0u; + rel = s_lock_releases; + ok = (cfg_store_read("/.fschk", rbuf, sizeof(rbuf), &rn) == -1) + && (cfg_store_stat("/.fschk", &st_dummy) == -1) + && (cfg_store_remove("/.fschk") == -1); + CHECK(ok && ((uint32_t)ff_fake_count_entries() == ents_before) + && (ff_fake_size(P_CFG) == 4u) && (s_lock_seq_err == 0) + && (s_lock_releases == rel + 3), + "TC-CFGFST-042 非法名只让本次调用失败:条目数不变、已有配置不动、锁把把放过"); + /* 非法目标名进原子写:推导出的 TMP 也非法 -> 第一步开文件就失败, + * 绝不能把已经存在的目标删掉 */ + reset_all(); + (void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"keep", 4u); + ents_before = (uint32_t)ff_fake_count_entries(); + ok = (cfg_store_write_atomic("/badname-tooooo-long.ini", (const uint8_t *)"x", 1u) == -1); + CHECK(ok && (ff_fake_size(P_CFG) == 4u) + && ((uint32_t)ff_fake_count_entries() == ents_before), + "TC-CFGFST-042b 非法目标名的原子写整条失败,**不碰已有配置文件**"); +} + +/* ===================== 堆 ===================== */ + +static void tc_heap(void) +{ + void *p; + void *q; + + printf("-- TC-CFGFST-050..053 分配后端(优化调研 P0 第 1 条)--\n"); + + /* 050 成对归还 */ + reset_all(); + p = cfg_store_alloc(256u); + (void)cfg_store_free(p); + CHECK((p != NULL) && (s_heap_alloc_calls == 1) && (s_heap_free_calls == 1) + && (s_heap_used == 0u) && (s_heap_free_err == 0), + "TC-CFGFST-050 alloc/free 走 FreeRTOS 堆且成对归还(借多少还多少)"); + + /* 051 分配失败如实返回 NULL */ + reset_all(); + s_heap_fail = 1; + p = cfg_store_alloc(2048u); + q = cfg_store_alloc(8u); + cfg_store_free(p); /* NULL 必须安全 */ + CHECK((p == NULL) && (q == NULL) && (s_heap_free_calls == 0), + "TC-CFGFST-051 堆见底 -> cfg_store_alloc 返回 NULL(MicroLIB 512B 的真形态)"); + + /* 052 free(NULL) 安全 + 不计数 */ + reset_all(); + cfg_store_free(NULL); + CHECK((s_heap_free_calls == 0) && (s_heap_free_err == 0), + "TC-CFGFST-052 cfg_store_free(NULL) 不触碰分配器"); + + /* 053 heap_free 转发 */ + reset_all(); + (void)cfg_store_alloc(100u); + CHECK(cfg_store_heap_free() == (FAKE_HEAP_CAP - 100u), + "TC-CFGFST-053 cfg_store_heap_free 原样转发 xPortGetFreeHeapSize"); +} + +/* ===================== 入口 ===================== */ + +int main(void) +{ + printf("=== Cfg File Store Mcu Tests ===\n"); + + tc_read(); + tc_write_atomic(); + tc_stat_remove(); + tc_names(); + tc_heap(); + + printf("\n=== Cfg File Store Mcu Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/cfg_file_store_mcu/run_unit_test.py b/test/host/cfg_file_store_mcu/run_unit_test.py new file mode 100644 index 00000000..f9d2b382 --- /dev/null +++ b/test/host/cfg_file_store_mcu/run_unit_test.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +r"""配置存储后端(cfg_file_store_mcu)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/cfg_file_store_mcu/cfg_file_store_mcu_test.c(以 + #include 纳入**生产源** App/cfg/cfg_file_store_mcu.c,并纳入共享假卷 + test/host/common/fatfs_fake.c),在 PC 上离线跑读、原子写(E-01 的 + unlink->rename 全链)、状态令牌、删除、路径 8.3 合规、假堆分配,失败即非 0 退出。 + 工具链与判定委托 test/host/common/unit_test_harness.py。 + +为什么要主机侧单测(而不是只用台架): + 1. **E-01 原子写**:本仓 FatFs 移植的 f_rename 对**已存在目标返回 FR_EXIST、不 + 覆盖**,所以覆盖写必须先 unlink 目标。模拟器后端(POSIX rename 覆盖语义) + 永远照不出这一条 → 曾经"一切非首启保存静默失败"。假卷按真实 ff.c 返回 + FR_EXIST,把这条做成必然失败的断言。 + 2. **失败不留半写**:TMP 打开失败 / 短写 / rename 失败三条路径都必须"目标原内容 + 一个字节没动 + TMP 清理干净 + 卷锁已释放",这是现场"改坏配置还能开机"的前提。 + 3. **卷锁 try 语义(v1.3)**:四个后端函数全在 vol_lock_wait(0) 区间内;写反了 + 就是与 ftpTask 并发写 FTL(dhara journal 竞争实证过),或 UI 保存死等卷把 + 喂狗点阻塞死。只能靠调用计数断言。 + 4. **mtime 令牌是 FAT 打包时间、不是内容哈希**:同一分钟同长度重写检不出变更, + 这个盲区必须钉在测试里,否则后人会拿它当哈希用。 + 5. **MicroLIB C 堆只有 512 B**(startup Heap_Size=0x200):cfg_store_alloc 是 + 共享核心唯一取内存入口,分配失败必须如实返回 NULL —— 宿主 CRT 堆永不失败。 + +桩的边界:ff.h / cfg_file_store.h / vol_lock.h 用**真实头**;FreeRTOS.h 是桩 + (真实头会拉整棵 portmacro),三条原型逐字对齐 portable.h:163/164/166。 + FatFs 由 test/host/common/fatfs_fake.c 给 RAM 实现(**显式纳入,不用目录通配**, + 避免被卷进别人的构建);假卷锁与假堆强符号写在本测试单元里。 + +用法(仓库根目录): + & '.venv\Scripts\python.exe' test/host/cfg_file_store_mcu/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:docs/子系统设计/配置文件设计说明.md §6.2(mtime 令牌)、 +docs/架构设计/STM32F4-Base存储架构说明.md §10.1 第 10/11 条、 +docs/测试说明/主机单测覆盖率口径.md §9 P2 第 2 项 +创建日期:2026-10-24 +""" +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),其余全用真实头。 +# 假卷 fatfs_fake.c 由测试单元 #include 纳入(同 ch395f_fake.c 惯例,显式列文件、不通配)。 +BUILD = TestBuild( + name="Cfg File Store Mcu Tests", + src=os.path.join(HERE, "cfg_file_store_mcu_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "test", "host", "common"), + os.path.join(ROOT, "App", "cfg"), + os.path.join(ROOT, "App", "fs"), + os.path.join(ROOT, "Lib", "FatFs"), + HERE, + ], + summary_marker="=== Cfg File Store Mcu Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/cfg_file_store_mcu/stub/FreeRTOS.h b/test/host/cfg_file_store_mcu/stub/FreeRTOS.h new file mode 100644 index 00000000..fd2e66e4 --- /dev/null +++ b/test/host/cfg_file_store_mcu/stub/FreeRTOS.h @@ -0,0 +1,37 @@ +/* + * 模块名称:主机侧单测桩 - FreeRTOS.h(cfg_file_store_mcu 套件专用) + * 模块功能:App/cfg/cfg_file_store_mcu.c 只经这个头拿三个分配器原型 + * (pvPortMalloc / vPortFree / xPortGetFreeHeapSize),它自己不碰任何内核 + * API——卷是 vol_lock 的事、卷操作是 ff.h 的事。真实 FreeRTOS.h 会拉整棵 + * portmacro + 编译器抽象层,PC 上编不动,所以只给这三条原型。 + * + * ⚠ 仅供 test/host/cfg_file_store_mcu/ 使用,不参与 MCU 构建。 + * + * 签名逐条对齐真实头(改一处即编译期报错,这就是桩存在的意义): + * Middlewares/Third_Party/FreeRTOS/Source/include/portable.h:163 + * void *pvPortMalloc( size_t xSize ) PRIVILEGED_FUNCTION; + * 同文件 :164 + * void vPortFree( void *pv ) PRIVILEGED_FUNCTION; + * 同文件 :166 + * size_t xPortGetFreeHeapSize( void ) PRIVILEGED_FUNCTION; + * (真实实现 Middlewares/.../portable/MemMang/heap_4.c:315 定义 xPortGetFreeHeapSize) + * PRIVILEGED_FUNCTION 在 pc 端口展开为空,桩里不写等价。 + * + * 实现放在测试单元里,是一份**可注入失败的假堆**:真机上 MicroLIB 的 C 堆只有 + * 512 B(startup Heap_Size=0x200),配置引擎的 KB 级临时缓冲必然走 FreeRTOS 堆, + * 堆见底时 pvPortMalloc 返回 NULL —— 这条分支模拟器(宿主 CRT 堆)永远走不到, + * 只有假堆能稳定构造(cfg_store_alloc 是共享核心的唯一取内存入口,必须测)。 + * + * 创建日期:2026-10-24 + */ + +#ifndef FREERTOS_H +#define FREERTOS_H + +#include + +void *pvPortMalloc(size_t xWantedSize); +void vPortFree(void *pv); +size_t xPortGetFreeHeapSize(void); + +#endif /* FREERTOS_H */ diff --git a/test/host/common/ch395f_fake.c b/test/host/common/ch395f_fake.c index 717b490f..e74e0c60 100644 --- a/test/host/common/ch395f_fake.c +++ b/test/host/common/ch395f_fake.c @@ -9,8 +9,13 @@ #include "ch395f_fake.h" +/* 时基:假复位机的最短沉降窗要用(与真实 main.h 同名同签名,各测试 TU 自带实现) */ +uint32_t HAL_GetTick(void); + ch395f_fake_world_t g_fake; ch395f_fake_obs_t g_chip; +ch395f_fake_ext_t g_fx; +ch395f_fake_ext_obs_t g_fxchip; static void fake_log(char c) { @@ -35,6 +40,33 @@ void ch395f_fake_reset(void) g_fake.open_rc[i] = CH395F_ERR_SUCCESS; g_fake.listen_rc[i] = CH395F_ERR_SUCCESS; } + /* 扩展区(net_socket 批次新增):同一次复位里一并清零,避免场景间残留 */ + ch395f_fake_ext_reset(); +} + +/* 扩展面的复位:默认"芯片在位、复位/协议栈初始化成功、版本够新、复位机全 IDLE" */ +void ch395f_fake_ext_reset(void) +{ + int i; + + (void)memset(&g_fx, 0, sizeof(g_fx)); + (void)memset(&g_fxchip, 0, sizeof(g_fxchip)); + g_fx.exist_rc = (int)CH395F_STATUS_OK; + g_fx.reset_rc = (int)CH395F_STATUS_OK; + g_fx.init_rc = (int)CH395F_STATUS_OK; + g_fx.version = 0x46U; /* 台架实测芯片版本(版本门控的默认通过值)*/ + g_fx.phy_status = 0x08U; /* PHY 100M_FULL(非 DISCONN,glob_event_feed 判 link up)*/ + for (i = 0; i < CH395F_FAKE_SOCK_MAX; i++) { + g_fx.connect_rc[i] = CH395F_ERR_SUCCESS; + g_fx.rst_state[i] = (uint8_t)CH395F_FAKE_RST_IDLE; + } +} + +void ch395f_fake_push_glob(uint16_t v) +{ + if (g_fx.glob_n < CH395F_FAKE_GLOB_QUEUE) { + g_fx.glob_q[g_fx.glob_n++] = v; + } } void ch395f_fake_push_recv_len(uint8_t sock, uint16_t v) @@ -94,7 +126,9 @@ void ch395f_get_socket_status(uint8_t sock, uint8_t *status) } } -/* 整体读即清:返回后该 socket 的挂起位图清零(真实芯片没有按位清除手段) */ +/* 整体读即清:返回后该 socket 的挂起位图清零(真实芯片没有按位清除手段)。 + * 唯一新增:清完立刻回填**电平型**掩码 sint_sticky(默认全 0 → 与既有行为逐字相同, + * net_listener / net_events / net_pending 三套不受影响)。 */ uint8_t ch395f_get_sock_int_status(uint8_t sock) { uint8_t v = 0U; @@ -104,11 +138,37 @@ uint8_t ch395f_get_sock_int_status(uint8_t sock) fake_log('i'); if (sock < CH395F_FAKE_SOCK_MAX) { v = g_fake.int_status[sock]; - g_fake.int_status[sock] = 0U; + g_fake.int_status[sock] = g_fx.sint_sticky[sock]; } return v; } +void ch395f_fake_clear_log(void) +{ + (void)memset(g_chip.log, 0, sizeof(g_chip.log)); + g_chip.log_n = 0; +} + +int ch395f_fake_log_find(char c1) +{ + int i; + + for (i = 0; i < g_chip.log_n; i++) { + if (g_chip.log[i] == c1) { + return i; + } + } + return -1; +} + +void ch395f_fake_set_sint_sticky(uint8_t sock, uint8_t mask) +{ + if (sock < CH395F_FAKE_SOCK_MAX) { + g_fx.sint_sticky[sock] = mask; + g_fake.int_status[sock] |= mask; + } +} + uint16_t ch395f_get_recv_len(uint8_t sock) { uint16_t v; @@ -215,3 +275,344 @@ uint16_t ch395f_link_change_count(void) { return g_fake.link_change; } + +/* ========================================================================= + * 扩展区实现(net_socket 主机单测批次;纯新增,上面既有实现一字未动) + * 签名逐字抄自 Drivers/BSP/CH395F/ch395f.h。 + * ========================================================================= */ + +ch395f_status_t ch395f_check_exist(void) +{ + g_fxchip.exist_calls++; + fake_log('E'); + return (ch395f_status_t)g_fx.exist_rc; +} + +ch395f_status_t ch395f_reset(void) +{ + g_fxchip.reset_calls++; + fake_log('Z'); + return (ch395f_status_t)g_fx.reset_rc; +} + +ch395f_status_t ch395f_init(void) +{ + g_fxchip.init_calls++; + fake_log('n'); + return (ch395f_status_t)g_fx.init_rc; +} + +uint8_t ch395f_get_version(void) +{ + return g_fx.version; +} + +void ch395f_set_fun_para(uint32_t para) +{ + g_fxchip.set_fun_para_calls++; + g_fx.fun_para = para; + fake_log('F'); +} + +void ch395f_set_tcp_mss(uint16_t mss) +{ + g_fxchip.set_mss_calls++; + g_fx.mss = mss; + fake_log('Q'); +} + +void ch395f_set_retrans_count(uint8_t cnt) +{ + g_fxchip.set_retrans_calls++; + g_fx.retrans_cnt = cnt; +} + +void ch395f_set_retrans_period(uint16_t period) +{ + g_fx.retrans_period = period; +} + +void ch395f_set_keepalive_idle(uint32_t time) +{ + g_fxchip.set_ka_calls++; + g_fx.ka_idle = time; +} + +void ch395f_set_keepalive_intvl(uint32_t time) +{ + g_fx.ka_intvl = time; +} + +void ch395f_set_keepalive_cnt(uint8_t cnt) +{ + g_fx.ka_cnt = cnt; +} + +void ch395f_set_dhcp(uint8_t enable) +{ + g_fxchip.set_dhcp_calls++; + g_fx.dhcp_enable = enable; + fake_log('D'); +} + +void ch395f_ping_enable(uint8_t enable) +{ + g_fxchip.ping_calls++; + g_fx.ping_enable = enable; +} + +void ch395f_set_ip_addr(uint8_t *p_ip) +{ + g_fxchip.set_ip_calls++; + fake_log('I'); + if (p_ip != NULL) { + (void)memcpy(g_fx.ip, p_ip, 4U); + } +} + +void ch395f_set_gwip_addr(uint8_t *p_gwip) +{ + g_fxchip.set_gw_calls++; + fake_log('N'); + if (p_gwip != NULL) { + (void)memcpy(g_fx.gw, p_gwip, 4U); + } +} + +void ch395f_set_mask_addr(uint8_t *p_mask) +{ + g_fxchip.set_mask_calls++; + fake_log('K'); + if (p_mask != NULL) { + (void)memcpy(g_fx.mask, p_mask, 4U); + } +} + +void ch395f_set_mac_addr(uint8_t *p_mac) +{ + g_fxchip.set_mac_calls++; + fake_log('M'); + if (p_mac != NULL) { + (void)memcpy(g_fx.mac, p_mac, 6U); + } +} + +void ch395f_set_send_buf(uint8_t sock, uint8_t start_block, uint8_t block_count) +{ + g_fxchip.set_send_buf_calls++; + fake_log('b'); + if (sock < CH395F_FAKE_SOCK_MAX) { + g_fx.send_blk_start[sock] = start_block; + g_fx.send_blk_cnt[sock] = block_count; + } +} + +void ch395f_set_recv_buf(uint8_t sock, uint8_t start_block, uint8_t block_count) +{ + g_fxchip.set_recv_buf_calls++; + fake_log('u'); + if (sock < CH395F_FAKE_SOCK_MAX) { + g_fx.recv_blk_start[sock] = start_block; + g_fx.recv_blk_cnt[sock] = block_count; + } +} + +void ch395f_set_des_ip(uint8_t sock, uint8_t *p_ip) +{ + g_fxchip.set_des_ip_calls++; + fake_log('e'); + if ((sock < CH395F_FAKE_SOCK_MAX) && (p_ip != NULL)) { + (void)memcpy(g_fx.des_ip[sock], p_ip, 4U); + } +} + +void ch395f_set_des_port(uint8_t sock, uint16_t port) +{ + g_fxchip.set_des_port_calls++; + fake_log('q'); + if (sock < CH395F_FAKE_SOCK_MAX) { + g_fx.des_port[sock] = port; + } +} + +uint8_t ch395f_tcp_connect(uint8_t sock) +{ + g_fxchip.connect_calls++; + g_chip.last_sock = sock; + fake_log('T'); + return (sock < CH395F_FAKE_SOCK_MAX) ? g_fx.connect_rc[sock] : 0x01U; +} + +uint8_t ch395f_get_phy_status(void) +{ + fake_log('g'); + return g_fx.phy_status; +} + +/* 全局中断状态(0x19,2 字节):**整体读即清**,且含全部 Socket 位—— + * 假件按调用序号返回预置值(耗尽后回落最后一个),并单独计数(B3 唯一读者=net_poll) */ +uint16_t ch395f_get_glob_int_status_all(void) +{ + uint16_t v = 0U; + + g_fxchip.glob_calls++; + fake_log('G'); + if (g_fx.glob_n == 0) { + v = 0U; + } else if (g_fx.glob_i < g_fx.glob_n) { + v = g_fx.glob_q[g_fx.glob_i++]; + } else { + v = g_fx.glob_q[g_fx.glob_n - 1]; + } + g_fxchip.glob_bits = (int)v; + return v; +} + +/* 与真实驱动同判据:只有 PHY_CHANGE 位才回读 PHY;链路电平变了才计一次变更 */ +void ch395f_glob_event_feed(uint16_t glob_bits) +{ + uint8_t up; + + if ((glob_bits & CH395F_GINT_STAT_PHY_CHANGE) == 0U) { + return; + } + up = (ch395f_get_phy_status() != CH395F_PHY_DISCONN) ? 1U : 0U; + if (up != g_fake.link_up) { + g_fake.link_up = up; + g_fake.link_change++; + } +} + +/* 只读本 Socket 的 SINT(读即清),永不碰全局寄存器——与真实护栏原语同语义 */ +uint8_t ch395f_sock_events(uint8_t sn) +{ + g_fxchip.sock_events_calls++; + return ch395f_get_sock_int_status(sn); +} + +/* —— sock_reset 复位机(契约等价:四步 + 最短沉降窗 + 二段 CLOSE 补发)—— + * 与驱动一致的**可断言点**:begin 重复启动必须被拒;GRACEFUL 满 100ms、 + * QUARANTINE 满 400ms 才继续;quiescent 要连续 2 轮无事件;byte0 回 00H 才算完成; + * 芯片 byte0=05H 且 TCP=00H 时补发第二段 CLOSE。 + * 不复制驱动的超时数值细节(那是驱动自己的单测面),本假件不自造失败: + * 想测超时由测试把 cached_open 一直留成 OPEN,由上层 net_sock_reset_sync 的 600ms 收口。 */ +int ch395f_sock_reset_begin(uint8_t sn, ch395f_reset_mode_t mode) +{ + g_fxchip.rst_begin_calls++; + fake_log('X'); + if (sn >= CH395F_FAKE_SOCK_MAX) { + return -1; + } + if (g_fx.rst_state[sn] != (uint8_t)CH395F_FAKE_RST_IDLE) { + g_fxchip.rst_begin_reject++; + return -1; + } + g_fx.rst_state[sn] = (uint8_t)CH395F_FAKE_RST_DRAINED; + g_fx.rst_mode[sn] = (mode == CH395F_RESET_QUARANTINE) ? 1U : 0U; + g_fx.rst_t0[sn] = HAL_GetTick(); + g_fx.rst_quiet[sn] = 0U; + return 0; +} + +int ch395f_sock_reset_poll(uint8_t sn) +{ + uint32_t now; + uint32_t el; + uint32_t need; + + g_fxchip.rst_poll_calls++; + fake_log('x'); + if (sn >= CH395F_FAKE_SOCK_MAX) { + return -1; + } + if (g_fx.rst_state[sn] == (uint8_t)CH395F_FAKE_RST_IDLE) { + return 0; /* 未启动/已完成:幂等 */ + } + now = HAL_GetTick(); + el = now - g_fx.rst_t0[sn]; + + switch (g_fx.rst_state[sn]) { + case CH395F_FAKE_RST_DRAINED: + /* 第 1 步:读空 RX;第 2 步:CLOSE */ + if (ch395f_get_recv_len(sn) != 0U) { + uint8_t scratch[8]; + g_fxchip.rst_rx_discard_calls++; + ch395f_read_recv_buf(sn, scratch, 8U); + } + g_fxchip.rst_close_calls++; + (void)ch395f_close_socket(sn); + if (g_fx.rst_keep_open[sn] == 0U) { + g_fake.cached_open[sn] = CH395F_SOCKET_CLOSED; + g_fake.cached_tcp[sn] = CH395F_TCP_CLOSED; + } + g_fx.rst_state[sn] = (uint8_t)CH395F_FAKE_RST_MINWAIT; + g_fx.rst_t0[sn] = now; + break; + case CH395F_FAKE_RST_MINWAIT: + need = (g_fx.rst_mode[sn] != 0U) ? 400U : 100U; + if (el >= need) { + g_fx.rst_state[sn] = (uint8_t)CH395F_FAKE_RST_QUIET; + g_fx.rst_t0[sn] = now; + g_fx.rst_quiet[sn] = 0U; + } + break; + case CH395F_FAKE_RST_QUIET: + if (ch395f_sock_events(sn) == 0U) { + g_fx.rst_quiet[sn]++; + if (g_fx.rst_quiet[sn] >= CH395F_FAKE_RST_QUIESCENT) { + g_fx.rst_state[sn] = (uint8_t)CH395F_FAKE_RST_WAITCLS; + g_fx.rst_t0[sn] = now; + } + } else { + g_fx.rst_quiet[sn] = 0U; + } + break; + case CH395F_FAKE_RST_WAITCLS: + if (ch395f_cmd_done(sn, CH395F_DONE_CLOSE) == 1) { + g_fx.rst_state[sn] = (uint8_t)CH395F_FAKE_RST_IDLE; + g_fxchip.rst_done[sn]++; + return 0; + } + /* 二段 CLOSE 补发(LISTEN 态一次 CLOSE 只拆监听,byte0 恒 05H) */ + if ((g_fake.cached_open[sn] == CH395F_SOCKET_OPEN) && + (g_fake.cached_tcp[sn] == CH395F_TCP_CLOSED) && + (g_fx.rst_quiet[sn] != 0xFFU)) { + g_fx.rst_quiet[sn] = 0xFFU; + g_fxchip.rst_close_calls++; + (void)ch395f_close_socket(sn); + if (g_fx.rst_keep_open[sn] == 0U) { + g_fake.cached_open[sn] = CH395F_SOCKET_CLOSED; + g_fake.cached_tcp[sn] = CH395F_TCP_CLOSED; + } + } + break; + default: + g_fx.rst_state[sn] = (uint8_t)CH395F_FAKE_RST_IDLE; + g_fxchip.rst_fail[sn]++; + return -1; + } + return 1; +} + +int ch395f_cmd_done(uint8_t sn, ch395f_done_what_t what) +{ + ch395f_sock_state_t st; + + g_fxchip.cmd_done_calls++; + g_fxchip.last_cmd_done_what = (uint8_t)what; + fake_log('z'); + if (sn >= CH395F_FAKE_SOCK_MAX) { + return -1; + } + st = ch395f_get_sock_state(sn); + switch (what) { + case CH395F_DONE_OPEN: + return (st.open == CH395F_SOCKET_OPEN) ? 1 : 0; + case CH395F_DONE_LISTEN: + return (st.open == CH395F_SOCKET_OPEN && st.tcp_state == CH395F_TCP_LISTEN) ? 1 : 0; + case CH395F_DONE_CLOSE: + return (st.open == CH395F_SOCKET_CLOSED) ? 1 : 0; + default: + return -1; + } +} diff --git a/test/host/common/ch395f_fake.h b/test/host/common/ch395f_fake.h index 6453b169..41bf996c 100644 --- a/test/host/common/ch395f_fake.h +++ b/test/host/common/ch395f_fake.h @@ -44,10 +44,10 @@ #define CH395F_ERR_SUCCESS 0x00U #define CH395F_PROTO_TYPE_TCP 0x03U #define CH395F_SOCKET_CLOSED 0x00U -#define CH395F_SOCKET_OPEN 0x01U +#define CH395F_SOCKET_OPEN 0x05U /* 真头 ch395f.h:220 = 0x05U(05H SOCKET_OPEN,docs/芯片手册/CH395F.md:527)*/ #define CH395F_TCP_CLOSED 0x00U #define CH395F_TCP_LISTEN 0x01U -#define CH395F_TCP_ESTABLISHED 0x05U +#define CH395F_TCP_ESTABLISHED 0x04U /* 真头 ch395f.h:229 = 0x04U(04H TCP_ESTABLISHED,docs/芯片手册/CH395F.md:539)*/ /* 发送/接收缓冲尺寸宏链:**与真实 ch395f.h 同构转抄**(2026-10-23 逐条核对)。 * net_pending.h 里的 NET_SEND_CHUNK_MAX 由这两个宏推导,是"一次最多往芯片写多少" @@ -66,6 +66,68 @@ #define CH395F_FAKE_SOCK_MAX 8 /* 与 NET_MAX_SOCKETS 同量级,测试自用 */ #define CH395F_FAKE_QUEUE_LEN 24 #define CH395F_FAKE_LOG_LEN 96 +#define CH395F_FAKE_GLOB_QUEUE 24 + +/* ===== 扩展区(2026-10-24,net_socket 主机单测批次;**纯新增**,上面既有宏/字段/函数 + * 一字未动,net_listener / net_events / net_pending 三套的语义不受影响)===== + * + * 追加的是 net_socket.c 这一层会调、而前三个模块不调的芯片原语(初始化下发类 + + * 全局中断 + sock_reset 复位机 + cmd_done 判据)。签名/常量同样**逐字抄自真实头** + * Drivers/BSP/CH395F/ch395f.h,改真头即编译期/断言期暴露漂移。 + * + * 必须复刻的第三条芯片怪癖(前两套测不到、net_socket.c 却是主路): + * 3. GET_GLOB_INT_STATUS_ALL(0x19,2 字节)**整体读即清**,且它是全局寄存器 + * (含全部 Socket 事件位)。AGENTS 模块约定 B3:唯一读者只能是 net_poll。 + * 假件按调用序号返回预置值,并单独计数,让"别处偷偷读全局"成为可断言的事。 + * 4. sock_reset 复位机(E4 定案四步:读空 RX → CLOSE → 排空事件(quiescent) → + * 等 byte0=00H,含 LISTEN 态二段 CLOSE 补发)。这里做**契约等价**实现: + * 阶段推进与最短等待(GRACEFUL 100ms / QUARANTINE 400ms)照抄驱动, + * 但不复制驱动的超时数值细节(那属于驱动自己的单测面)。 + */ +#define CH395F_ERR_BUSY 0x10U /* 真头 ch395f.h:170 */ +#define CH395F_ERR_MEM 0x11U /* 真头 ch395f.h:171 */ +#define CH395F_ERR_TIMEOUT 0x13U /* 真头 ch395f.h:173 */ +#define CH395F_ERR_VAL 0x19U /* 真头 ch395f.h:179 */ +#define CH395F_ERR_USE 0x1BU /* 真头 ch395f.h:181(open 已被占用)*/ +#define CH395F_PROTO_TYPE_UDP 0x02U /* 真头 ch395f.h:213 */ +#define CH395F_FUN_PARA_SOCKET_CLOSE (1UL << 3) /* 真头 ch395f.h:205 */ +#define CH395F_TCP_MSS 1024U /* 真头 ch395f.h:124 */ +#define CH395F_PHY_DISCONN 0x01U /* 真头 ch395f.h:191 */ +#define CH395F_GINT_STAT_PHY_CHANGE 0x04U /* 真头 ch395f.h:245 */ +#define CH395F_SOCK_STATE_NONE 0xFFU /* 真头 ch395f.h:762 */ + +#define CH395F_SINT_STAT_SENBUF_FREE 0x01U /* 真头 ch395f.h:263 */ +#define CH395F_SINT_STAT_SEND_OK 0x02U /* 真头 ch395f.h:264 */ +#define CH395F_SINT_STAT_RECV_OK 0x04U /* 真头 ch395f.h:265 */ +#define CH395F_SINT_STAT_CONNECT 0x08U /* 真头 ch395f.h:266 */ +#define CH395F_SINT_STAT_DISCONNECT 0x10U /* 真头 ch395f.h:267 */ +#define CH395F_SINT_STAT_SOCK_TIMEOUT 0x40U /* 真头 ch395f.h:269 */ + +typedef enum { + CH395F_STATUS_OK = 0, /* 真头 ch395f.h:275 */ + CH395F_STATUS_ERROR = -1, + CH395F_STATUS_TIMEOUT = -2, + CH395F_STATUS_NOT_DETECTED = -3 +} ch395f_status_t; + +typedef enum { + CH395F_RESET_GRACEFUL = 0, /* 真头 :803:最短等待 100ms */ + CH395F_RESET_QUARANTINE /* 真头 :804:最短等待 400ms */ +} ch395f_reset_mode_t; + +typedef enum { + CH395F_DONE_OPEN = 0, /* 真头 :826:byte0 变 05H */ + CH395F_DONE_LISTEN, /* 真头 :827:byte0=05H 且 TCP=01H */ + CH395F_DONE_CLOSE /* 真头 :828:byte0=00H */ +} ch395f_done_what_t; + +/* 复位机阶段(与驱动同名状态的**契约等价**简化,不代表驱动内部实现) */ +#define CH395F_FAKE_RST_IDLE 0 +#define CH395F_FAKE_RST_DRAINED 1 /* 已读空 RX 并发过 CLOSE */ +#define CH395F_FAKE_RST_MINWAIT 2 /* 等最短沉降窗(100/400ms) */ +#define CH395F_FAKE_RST_QUIET 3 /* 排空本 Socket 事件(连续 2 轮无位) */ +#define CH395F_FAKE_RST_WAITCLS 4 /* 等 byte0=00H(含二段 CLOSE 补发) */ +#define CH395F_FAKE_RST_QUIESCENT 2U typedef struct { uint8_t open; @@ -111,7 +173,13 @@ typedef struct { /* 调用顺序日志(短名首字母): * '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 */ + * 'P'=set_proto_type 'w'=write_send_buf + * 扩展区新增(net_socket 批次):'G'=读全局中断 'v'=sock_events(读即清) + * 'X'=sock_reset_begin 'x'=sock_reset_poll 'z'=cmd_done 'T'=tcp_connect + * 'I'=set_ip_addr 'N'=set_gwip 'K'=set_mask 'M'=set_mac 'D'=set_dhcp + * 'F'=set_fun_para 'Q'=set_tcp_mss 'b'=set_send_buf 'u'=set_recv_buf + * 'e'=set_des_ip 'q'=set_des_port 'g'=get_phy_status 'E'=check_exist + * 'Z'=reset 'n'=init */ char log[CH395F_FAKE_LOG_LEN]; int log_n; } ch395f_fake_obs_t; @@ -143,4 +211,133 @@ 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); +/* ===== 扩展区第二部分:新增状态世界 + 新增假芯片原型 ===== + * ⚠ 刻意**不往既有 ch395f_fake_world_t / ch395f_fake_obs_t 里加字段**,而是另开一份 + * ch395f_fake_ext_t / ch395f_fake_ext_obs_t:既有三套单测的复位与断言面一字不动, + * 新增状态由 ch395f_fake_reset() 调 ch395f_fake_ext_reset() 一并清零(场景间不残留)。 + */ +typedef struct { + /* —— 初始化/参数下发面(net_init 主路)—— */ + int exist_rc; /* ch395f_check_exist 返回(ch395f_status_t)*/ + int reset_rc; /* ch395f_reset 返回 */ + int init_rc; /* ch395f_init 返回 */ + uint8_t version; /* ch395f_get_version(版本门控 0x46 的用点位)*/ + uint8_t dhcp_enable; /* ch395f_set_dhcp */ + uint8_t ping_enable; /* ch395f_ping_enable */ + uint8_t ip[4]; /* ch395f_set_ip_addr */ + uint8_t gw[4]; /* ch395f_set_gwip_addr */ + uint8_t mask[4]; /* ch395f_set_mask_addr */ + uint8_t mac[6]; /* ch395f_set_mac_addr */ + uint32_t fun_para; /* ch395f_set_fun_para 最近一次实发值 */ + uint16_t mss; /* ch395f_set_tcp_mss */ + uint8_t retrans_cnt; + uint16_t retrans_period; + uint32_t ka_idle; + uint32_t ka_intvl; + uint8_t ka_cnt; + uint8_t send_blk_start[CH395F_FAKE_SOCK_MAX]; /* ch395f_set_send_buf */ + uint8_t send_blk_cnt[CH395F_FAKE_SOCK_MAX]; + uint8_t recv_blk_start[CH395F_FAKE_SOCK_MAX]; /* ch395f_set_recv_buf */ + uint8_t recv_blk_cnt[CH395F_FAKE_SOCK_MAX]; + uint8_t des_ip[CH395F_FAKE_SOCK_MAX][4]; /* ch395f_set_des_ip */ + uint16_t des_port[CH395F_FAKE_SOCK_MAX]; /* ch395f_set_des_port */ + uint8_t connect_rc[CH395F_FAKE_SOCK_MAX]; /* ch395f_tcp_connect 返回 */ + /* —— 全局中断(0x19,整体读即清;唯一读者应为 net_poll)—— */ + uint16_t glob_q[CH395F_FAKE_GLOB_QUEUE]; + int glob_n; + int glob_i; + uint8_t phy_status; /* ch395f_get_phy_status(喂 glob_event_feed)*/ + /* —— sock_reset 复位机(契约等价,见文件头注释 4)—— */ + uint8_t rst_state[CH395F_FAKE_SOCK_MAX]; + uint8_t rst_mode[CH395F_FAKE_SOCK_MAX]; + uint32_t rst_t0[CH395F_FAKE_SOCK_MAX]; + uint8_t rst_quiet[CH395F_FAKE_SOCK_MAX]; /* 连续无事件轮数 */ + /* 1=复位机**不**替芯片把 cached_open 落回 CLOSED(造"永远关不掉"的芯片形态, + * 用来量上层的复位超时收口)。默认 0 → 与既有行为一致。 */ + uint8_t rst_keep_open[CH395F_FAKE_SOCK_MAX]; + /* SINT 的**电平型**来源(可脚本化):读即清之后立刻把这个掩码放回 int_status。 + * 默认 0 → 与既有假件行为逐字相同(读一次就没了)。真实芯片的 RECV_OK / + * SENBUF_FREE 本就是"条件位":只要条件还在(RX 仍非空 / 发送缓冲仍空),下一轮 + * 就还会再报一次。net_socket.c 的多块发送要的就是这个稳态,没有它就只能 + * 造"写完一块就再也等不到空闲"的假死形态。 */ + uint8_t sint_sticky[CH395F_FAKE_SOCK_MAX]; +} ch395f_fake_ext_t; + +typedef struct { + int exist_calls; + int reset_calls; + int init_calls; + int set_ip_calls; + int set_gw_calls; + int set_mask_calls; + int set_mac_calls; + int set_dhcp_calls; + int ping_calls; + int set_fun_para_calls; + int set_mss_calls; + int set_retrans_calls; + int set_ka_calls; + int set_send_buf_calls; + int set_recv_buf_calls; + int set_des_ip_calls; + int set_des_port_calls; + int connect_calls; + int glob_calls; /* 读全局中断次数(B3:除 net_poll 外应为 0)*/ + int glob_bits; /* 最近一次读到的 16 位全局位图 */ + int sock_events_calls; /* ch395f_sock_events(只碰本 Socket 的护栏原语)*/ + int rst_begin_calls; + int rst_begin_reject; /* begin 被拒次数(复位机已在运行)*/ + int rst_poll_calls; + int rst_done[CH395F_FAKE_SOCK_MAX]; /* 复位机完成计数 */ + int rst_fail[CH395F_FAKE_SOCK_MAX]; /* 复位机失败计数 */ + int cmd_done_calls; + uint8_t last_cmd_done_what; + int rst_close_calls; /* 复位机内部发出的 CLOSE 命令(含二段补发)*/ + int rst_rx_discard_calls; /* 复位机 RX 排空事务次数 */ +} ch395f_fake_ext_obs_t; + +extern ch395f_fake_ext_t g_fx; +extern ch395f_fake_ext_obs_t g_fxchip; + +/* 由 ch395f_fake_reset() 自动调用;单独暴露是为了让测试能只清扩展面 */ +void ch395f_fake_ext_reset(void); +void ch395f_fake_push_glob(uint16_t v); /* 预置全局中断返回序列 */ +void ch395f_fake_set_sint_sticky(uint8_t sock, uint8_t mask); +/* 顺序断言辅助:清空调用日志 / 找某个短名第一次出现的位置(-1=没调过)。 + * 刻意提供"清零日志",否则一次长轮询(如 sock_reset 的几十轮 'x')会把 96 格 + * 环形外的顺序信息挤掉,让后续场景的顺序断言失真。 */ +void ch395f_fake_clear_log(void); +int ch395f_fake_log_find(char c); + +/* —— 新增假芯片实现(签名与真实 ch395f.h 一字不改)—— */ +ch395f_status_t ch395f_check_exist(void); +ch395f_status_t ch395f_reset(void); +ch395f_status_t ch395f_init(void); +uint8_t ch395f_get_version(void); +void ch395f_set_fun_para(uint32_t para); +void ch395f_set_tcp_mss(uint16_t mss); +void ch395f_set_retrans_count(uint8_t cnt); +void ch395f_set_retrans_period(uint16_t period); +void ch395f_set_keepalive_idle(uint32_t time); +void ch395f_set_keepalive_intvl(uint32_t time); +void ch395f_set_keepalive_cnt(uint8_t cnt); +void ch395f_set_dhcp(uint8_t enable); +void ch395f_ping_enable(uint8_t enable); +void ch395f_set_ip_addr(uint8_t *p_ip); +void ch395f_set_gwip_addr(uint8_t *p_gwip); +void ch395f_set_mask_addr(uint8_t *p_mask); +void ch395f_set_mac_addr(uint8_t *p_mac); +void ch395f_set_send_buf(uint8_t sock, uint8_t start_block, uint8_t block_count); +void ch395f_set_recv_buf(uint8_t sock, uint8_t start_block, uint8_t block_count); +void ch395f_set_des_ip(uint8_t sock, uint8_t *p_ip); +void ch395f_set_des_port(uint8_t sock, uint16_t port); +uint8_t ch395f_tcp_connect(uint8_t sock); +uint8_t ch395f_get_phy_status(void); +uint16_t ch395f_get_glob_int_status_all(void); +void ch395f_glob_event_feed(uint16_t glob_bits); +uint8_t ch395f_sock_events(uint8_t sn); +int ch395f_sock_reset_begin(uint8_t sn, ch395f_reset_mode_t mode); +int ch395f_sock_reset_poll(uint8_t sn); +int ch395f_cmd_done(uint8_t sn, ch395f_done_what_t what); + #endif /* CH395F_FAKE_H */ diff --git a/test/host/common/fatfs_fake.c b/test/host/common/fatfs_fake.c new file mode 100644 index 00000000..46472a93 --- /dev/null +++ b/test/host/common/fatfs_fake.c @@ -0,0 +1,1077 @@ +/* + * 模块名称:假 FatFs 内存卷(实现) + * 模块功能:实现 fatfs_fake.h 声明的九个 f_* 原型与卷控制/注入/观测接口。 + * 由测试单元 #include 本 .c 使用(照 test/host/common/ch395f_fake.c 的做法)。 + * + * 语义与 Lib/FatFs/ff.c 的对应关系全部写在 fatfs_fake.h 文件头;本文件只在 + * 每条"复刻点"上标一句 ff.c 的出处,不再重复论证。 + * + * 刻意不做的事: + * - 不做真 FAT 扇区/簇链模拟——被测的是应用侧对 FRESULT 与写回字节数的处置, + * 不是 ff.c 自己(ff.c 属 Lib/ 第三方,不进覆盖率分母); + * - 不做线程安全:三个被测模块都是"唯一写者 + 卷锁串行化",假卷跟着单线程即可; + * - 不实现 f_mount/f_mkfs/f_readdir:调用即链接失败(见头文件说明)。 + * + * 创建日期:2026-10-24(P2 主机单测覆盖率批次,task-2) + */ + +#include +#include +#include + +#include "fatfs_fake.h" + +/* ff.c 内部标志(ff.h 不导出 FA_SEEKEND,本移植 FA_OPEN_APPEND = 0x10|0x20) */ +#define FF_FAKE_SEEKEND 0x20u +#define FF_FAKE_CREATE_MASK (FA_CREATE_ALWAYS | FA_OPEN_ALWAYS | FA_CREATE_NEW) + +/* f_open 成功时写进 fp->obj.fs 的"已挂载卷"指针(真实 ff.c 存的就是 FATFS*, + * 失败清 0)。用本文件里一个真对象的地址而不是硬造整数:MSVC x64 下 + * (FATFS *)0xF01DBEEE 会刷 C4312「转换到大得多的类型」。 */ +static FATFS s_vol; +#define FF_FAKE_FS_MAGIC (&s_vol) + +typedef struct { + int used; + int is_dir; + uint32_t size; /* 逻辑长度(可大于已存内容) */ + uint32_t stored; /* data[] 里真实存了多少字节 */ + uint16_t fdate; + uint16_t ftime; + char path[FF_FAKE_PATH_MAX]; /* 归一化:全大写、以 '/' 开头、无重复分隔符 */ + uint8_t data[FF_FAKE_CLUSTER]; +} ff_fake_ent_t; + +typedef struct { + int used; + int any_path; /* 1 = 不限路径 */ + int op; + int act; + int skip; /* >0:本次命中先放行(skip--),不注入 */ + FRESULT rc; + UINT n; + int times; + char path[FF_FAKE_PATH_MAX]; +} ff_fake_inj_t; + +ff_fake_obs_t g_ff_obs; + +static ff_fake_ent_t s_ent[FF_FAKE_MAX_ENT]; +static ff_fake_inj_t s_inj[FF_FAKE_INJ_MAX]; +static uint16_t s_dostime_d = 0x5A21u; /* 2025-10-06 的 FAT 日期编码(任意基准) */ +static uint16_t s_dostime_t = 0x6E40u; /* 13:52:00 */ +static uint8_t s_dirty[FF_FAKE_MAX_ENT]; + +/* ─────────────────────────── 小工具 ─────────────────────────── */ + +static void s_guard(int op) +{ + g_ff_obs.calls[op]++; + if (g_ff_obs.calls[op] > FF_FAKE_GUARD) { + (void)fprintf(stderr, "[FATAL] fatfs_fake: op %d call guard tripped\n", op); + exit(2); + } +} + +static char s_up(char c) +{ + if ((c >= 'a') && (c <= 'z')) { + return (char)(c - 32); + } + return c; +} + +static int s_ieq(const char *a, const char *b) +{ + while ((*a != '\0') && (*b != '\0')) { + if (s_up(*a) != s_up(*b)) { + return 0; + } + a++; + b++; + } + return (*a == *b) ? 1 : 0; +} + +static int s_is_sep(char c) +{ + return ((c == '/') || (c == '\\')) ? 1 : 0; +} + +/* 真实 ff.c create_name 非 LFN 分支的逐字模拟:主名 <=8、扩展 <=3、非法字符集。 + * 返回 1 = 合法短名段。 */ +/* 非法字符单独判,不用字符串表:ff.c 的表里带双引号与反斜杠, + * 写进主机侧字符串字面量既易错又踩本仓"字面量必须纯 ASCII 且不含双引号"的坑 */ +static int s_sfn_bad(char c) +{ + switch (c) { + case '*': case '+': case ',': case ';': + case '<': case '=': case '>': + case '[': case ']': case '|': + case '"': case '?': case 0x7F: + return 1; + default: + return 0; + } +} + +static int s_sfn_ok(const char *seg, int len) +{ + int i = 0; + int ni = 8; + int k; + + if (len <= 0) { + return 0; /* 真实 ff.c:i == 0 -> FR_INVALID_NAME */ + } + for (k = 0; k < len; k++) { + char c = seg[k]; + + if ((c == '.') || (i >= ni)) { /* 主名结束 / 字段溢出? */ + if (ni == 11) { + return 0; /* 扩展域已开:再来点或再溢出 = 非法(.fschk 死在这) */ + } + if (c != '.') { + return 0; /* 主名溢出 */ + } + i = 8; + ni = 11; /* 进入扩展域 */ + continue; + } + if (s_sfn_bad(c) != 0) { + return 0; /* 非法字符(ff.c create_name 的 strchr 表) */ + } + if (s_is_sep(c)) { + return 0; /* 段里不该有分隔符(调用方已切段) */ + } + i++; + } + return (i == 0) ? 0 : 1; +} + +/* 归一化路径:剥盘符 + 剥前导分隔符 + 全大写 + 折叠重复分隔符 + 去尾部分隔符。 + * 返回 FRESULT;out 形如 "/LOG/RUN.LOG",根为 "/"。 + * 盘符判定照 ff.c get_ldnumber:本移植 FF_VOLUMES=1,只有 "0:" 合法, + * 其它 "N:" 形态(含路径中间的冒号)一律 FR_INVALID_DRIVE。 */ +static FRESULT s_norm(const char *path, char *out, int out_sz) +{ + int o = 0; + int i = 0; + const char *p = path; + + if (path == NULL) { + return FR_INVALID_NAME; + } + /* 盘符前缀:到第一个 ':' 之前的整体必须是单个数字 0 */ + { + const char *q = p; + while ((*q != '\0') && (*q != ':')) { + q++; + } + if (*q == ':') { + if ((q == p + 1) && (p[0] == '0')) { + p = q + 1; + } else { + return FR_INVALID_DRIVE; + } + } + } + out[0] = '/'; + o = 1; + while (s_is_sep(*p)) { + p++; + } + while (*p != '\0') { + const char *seg; + int len; + + while (s_is_sep(*p)) { + p++; + } + if (*p == '\0') { + break; /* 尾部多余分隔符:折叠掉 */ + } + seg = p; + len = 0; + while ((p[len] != '\0') && (!s_is_sep(p[len]))) { + len++; + } + if (!s_sfn_ok(seg, len)) { + return FR_INVALID_NAME; + } + if ((o + len + 1) >= out_sz) { + return FR_INVALID_NAME; /* 路径过长:假件一律以名字类错误拒绝 */ + } + if (i != 0) { + out[o++] = '/'; + } + { + int k; + for (k = 0; k < len; k++) { + out[o++] = s_up(seg[k]); + } + } + out[o] = '\0'; + i++; + p += len; + } + if (o == 1) { + return FR_INVALID_NAME; /* 空路径 = 卷根本身:真实 f_open 报 NS_NONAME */ + } + return FR_OK; +} + +/* 取父目录路径:"/LOG/RUN.LOG" -> "/LOG","/LOG" -> "/" */ +static void s_parent(const char *norm, char *out) +{ + const char *slash = strrchr(norm, '/'); + + if ((slash == NULL) || (slash == norm)) { + out[0] = '/'; + out[1] = '\0'; + } else { + int n = (int)(slash - norm); + (void)memcpy(out, norm, (size_t)n); + out[n] = '\0'; + } +} + +static int s_find(const char *norm) +{ + int i; + + for (i = 0; i < (int)FF_FAKE_MAX_ENT; i++) { + if ((s_ent[i].used != 0) && (strcmp(s_ent[i].path, norm) == 0)) { + return i; + } + } + return -1; +} + +static int s_alloc(void) +{ + int i; + + for (i = 0; i < (int)FF_FAKE_MAX_ENT; i++) { + if (s_ent[i].used == 0) { + return i; + } + } + return -1; +} + +static int s_has_children(const char *dir_norm) +{ + int i; + int dl = (int)strlen(dir_norm); + + for (i = 0; i < (int)FF_FAKE_MAX_ENT; i++) { + if (s_ent[i].used == 0) { + continue; + } + if (((int)strlen(s_ent[i].path) > dl) && + (strncmp(s_ent[i].path, dir_norm, (size_t)dl) == 0) && + (s_ent[i].path[dl] == '/')) { + return 1; + } + } + return 0; +} + +/* 中间目录段必须存在(真实 follow_path:非末段找不到 -> FR_NO_PATH) */ +static FRESULT s_check_parent(const char *norm) +{ + char par[FF_FAKE_PATH_MAX]; + int idx; + + s_parent(norm, par); + if (strcmp(par, "/") == 0) { + return FR_OK; + } + idx = s_find(par); + if (idx < 0) { + return FR_NO_PATH; + } + if (s_ent[idx].is_dir == 0) { + return FR_NO_PATH; /* 真实 follow_path:不是子目录 -> FR_NO_PATH */ + } + return FR_OK; +} + +/* 命中注入则返回 1 并把 rc/n 带出 */ +static int s_hit(int op, const char *norm, FRESULT *rc, int *act, UINT *n) +{ + int i; + + for (i = 0; i < (int)FF_FAKE_INJ_MAX; i++) { + if (s_inj[i].used == 0) { + continue; + } + if (s_inj[i].op != op) { + continue; + } + if ((s_inj[i].any_path == 0) && (!s_ieq(s_inj[i].path, norm))) { + continue; + } + if (s_inj[i].skip > 0) { + /* 路径匹配上了才算"放行一次"——不然别的路径/别的 op 会把 skip 吃掉, + * 注入永远打不到真正要打击的那一次调用 */ + s_inj[i].skip--; + return 0; + } + *rc = s_inj[i].rc; + *act = s_inj[i].act; + *n = s_inj[i].n; + s_inj[i].times--; + if (s_inj[i].times <= 0) { + s_inj[i].used = 0; + } + return 1; + } + return 0; +} + +/* 定长缓冲的字符串拷贝(不用 strncpy/strcpy:MSVC /W4 报 C4996 弃用,噪音) */ +static void s_keep(char *dst, const char *src) +{ + int i; + + for (i = 0; (i < ((int)FF_FAKE_PATH_MAX) - 1) && (src[i] != '\0'); i++) { + dst[i] = src[i]; + } + dst[i] = '\0'; +} + +/* 同 s_keep,但目标容量由调用方给(FILINFO.fname 是 13 字节,与路径上限不同) */ +static void s_keep_n(char *dst, int dst_sz, const char *src) +{ + int i; + + for (i = 0; (i < (dst_sz - 1)) && (src[i] != '\0'); i++) { + dst[i] = src[i]; + } + dst[i] = '\0'; +} + +/* ─────────────────────────── 世界控制 ─────────────────────────── */ + +void ff_fake_reset(void) +{ + (void)memset(&g_ff_obs, 0, sizeof(g_ff_obs)); + (void)memset(s_ent, 0, sizeof(s_ent)); + (void)memset(s_inj, 0, sizeof(s_inj)); + (void)memset(s_dirty, 0, sizeof(s_dirty)); + s_dostime_d = 0x5A21u; + s_dostime_t = 0x6E40u; +} + +void ff_fake_set_dostime(uint16_t fdate, uint16_t ftime) +{ + s_dostime_d = fdate; + s_dostime_t = ftime; +} + +static void s_add_inj_skip(const char *path, int op, int act, FRESULT rc, UINT n, int skip, int times) +{ + int i; + char norm[FF_FAKE_PATH_MAX]; + + if (times <= 0) { + return; + } + if (path == NULL) { + for (i = 0; i < (int)FF_FAKE_INJ_MAX; i++) { + if (s_inj[i].used == 0) { + s_inj[i].used = 1; + s_inj[i].any_path = 1; + s_inj[i].op = op; + s_inj[i].act = act; + s_inj[i].rc = rc; + s_inj[i].n = n; + s_inj[i].times = times; + s_inj[i].skip = skip; + s_inj[i].path[0] = '\0'; + return; + } + } + return; + } + /* 注入路径也走一遍归一化,好让大小写/前导斜杠的差异不影响命中 */ + if (s_norm(path, norm, (int)sizeof(norm)) != FR_OK) { + norm[0] = '/'; + norm[1] = '\0'; + } + for (i = 0; i < (int)FF_FAKE_INJ_MAX; i++) { + if (s_inj[i].used == 0) { + s_inj[i].used = 1; + s_inj[i].any_path = 0; + s_inj[i].op = op; + s_inj[i].act = act; + s_inj[i].rc = rc; + s_inj[i].n = n; + s_inj[i].times = times; + s_inj[i].skip = skip; + s_keep(s_inj[i].path, norm); + return; + } + } +} + +void ff_fake_inject_rc(const char *path, int op, FRESULT rc, int times) +{ + s_add_inj_skip(path, op, FF_FAKE_ACT_RC, rc, 0u, 0, times); +} + +/* 前 skip 次命中放行,之后 times 次注入 rc(见头文件注释的用途说明) */ +void ff_fake_inject_rc_skip(const char *path, int op, FRESULT rc, int skip, int times) +{ + s_add_inj_skip(path, op, FF_FAKE_ACT_RC, rc, 0u, skip, times); +} + +void ff_fake_inject_write_short(const char *path, UINT n, int times) +{ + s_add_inj_skip(path, FF_FAKE_OP_WRITE, FF_FAKE_ACT_WSHORT, FR_OK, n, 0, times); +} + +void ff_fake_inject_read_short(const char *path, UINT n, int times) +{ + s_add_inj_skip(path, FF_FAKE_OP_READ, FF_FAKE_ACT_RSHORT, FR_OK, n, 0, times); +} + +/* ─────────────────────────── 卷直读直写 ─────────────────────────── */ + +int ff_fake_exists(const char *path) +{ + char norm[FF_FAKE_PATH_MAX]; + int idx; + + if (s_norm(path, norm, (int)sizeof(norm)) != FR_OK) { + return 0; + } + idx = s_find(norm); + if (idx < 0) { + return 0; + } + return (s_ent[idx].is_dir != 0) ? 2 : 1; +} + +uint32_t ff_fake_size(const char *path) +{ + char norm[FF_FAKE_PATH_MAX]; + int idx; + + if (s_norm(path, norm, (int)sizeof(norm)) != FR_OK) { + return 0xFFFFFFFFu; + } + idx = s_find(norm); + return (idx < 0) ? 0xFFFFFFFFu : s_ent[idx].size; +} + +uint32_t ff_fake_time_token(const char *path) +{ + char norm[FF_FAKE_PATH_MAX]; + int idx; + + if (s_norm(path, norm, (int)sizeof(norm)) != FR_OK) { + return 0u; + } + idx = s_find(norm); + if (idx < 0) { + return 0u; + } + return ((uint32_t)s_ent[idx].fdate << 16) | (uint32_t)s_ent[idx].ftime; +} + +uint32_t ff_fake_fetch(const char *path, uint8_t *p_buf, uint32_t off, uint32_t cap) +{ + char norm[FF_FAKE_PATH_MAX]; + int idx; + uint32_t n; + + if ((p_buf == NULL) || (s_norm(path, norm, (int)sizeof(norm)) != FR_OK)) { + return 0u; + } + idx = s_find(norm); + if (idx < 0) { + return 0u; + } + if (off >= s_ent[idx].stored) { + return 0u; + } + n = s_ent[idx].stored - off; + if (n > cap) { + n = cap; + } + (void)memcpy(p_buf, &s_ent[idx].data[off], n); + return n; +} + +int ff_fake_mkfile(const char *path, const uint8_t *p_data, uint32_t len) +{ + char norm[FF_FAKE_PATH_MAX]; + int idx; + + if (s_norm(path, norm, (int)sizeof(norm)) != FR_OK) { + return -1; + } + idx = s_find(norm); + if (idx < 0) { + idx = s_alloc(); + if (idx < 0) { + return -1; + } + s_ent[idx].used = 1; + s_ent[idx].is_dir = 0; + s_keep(s_ent[idx].path, norm); + } + if (len > FF_FAKE_CLUSTER) { + len = FF_FAKE_CLUSTER; + } + s_ent[idx].stored = len; + s_ent[idx].size = len; + if (len > 0u) { + if (p_data != NULL) { + (void)memcpy(s_ent[idx].data, p_data, len); + } else { + /* p_data == NULL = "只铺长度、内容清零":造"已有 N 条记录"的前置状态 + * 时用这条,不必真把 N*64 字节写进池子 */ + (void)memset(s_ent[idx].data, 0, len); + } + } + s_ent[idx].fdate = s_dostime_d; + s_ent[idx].ftime = s_dostime_t; + return 0; +} + +/* 只给逻辑长度:造"已有 1024 条记录 = 65536 字节"这类前置状态, + * 内容不真存(簇池只有 2 KB),读到的超出部分按 0 处理 */ +int ff_fake_mkfile_size(const char *path, uint32_t size) +{ + if (ff_fake_mkfile(path, NULL, 0u) != 0) { + return -1; + } + { + char norm[FF_FAKE_PATH_MAX]; + int idx; + + (void)s_norm(path, norm, (int)sizeof(norm)); + idx = s_find(norm); + if (idx < 0) { + return -1; + } + s_ent[idx].size = size; + s_ent[idx].stored = 0u; + } + return 0; +} + +int ff_fake_mkdir_direct(const char *path) +{ + char norm[FF_FAKE_PATH_MAX]; + int idx; + + if (s_norm(path, norm, (int)sizeof(norm)) != FR_OK) { + return -1; + } + idx = s_find(norm); + if (idx >= 0) { + s_ent[idx].is_dir = 1; + return 0; + } + idx = s_alloc(); + if (idx < 0) { + return -1; + } + s_ent[idx].used = 1; + s_ent[idx].is_dir = 1; + s_ent[idx].size = 0u; + s_ent[idx].stored = 0u; + s_keep(s_ent[idx].path, norm); + return 0; +} + +int ff_fake_count_entries(void) +{ + int i; + int n = 0; + + for (i = 0; i < (int)FF_FAKE_MAX_ENT; i++) { + if (s_ent[i].used != 0) { + n++; + } + } + return n; +} + +/* ─────────────────────────── f_* 实现 ─────────────────────────── */ + +static void s_inval(FIL *fp) +{ + fp->obj.fs = NULL; /* 真实 ff.c:失败即 fp->obj.fs = 0 */ + fp->obj.sclust = 0u; + fp->obj.objsize = 0u; + fp->fptr = 0u; + fp->flag = 0u; +} + +FRESULT f_open(FIL *fp, const TCHAR *path, BYTE mode) +{ + char norm[FF_FAKE_PATH_MAX]; + FRESULT fr; + FRESULT inj_rc = FR_OK; + int inj_act = 0; + UINT inj_n = 0u; + int hit = 0; + int idx; + + s_guard(FF_FAKE_OP_OPEN); + if (fp == NULL) { + return FR_INVALID_OBJECT; + } + s_inval(fp); + fr = s_norm(path, norm, (int)sizeof(norm)); + if (fr != FR_OK) { + g_ff_obs.last_open_rc = (int)fr; + return fr; + } + hit = s_hit(FF_FAKE_OP_OPEN, norm, &inj_rc, &inj_act, &inj_n); + if ((hit != 0) && (inj_act == FF_FAKE_ACT_RC)) { + g_ff_obs.last_open_mode = mode; + s_keep(g_ff_obs.last_open_path, norm); + g_ff_obs.last_open_rc = (int)inj_rc; + return inj_rc; + } + g_ff_obs.last_open_mode = mode; + s_keep(g_ff_obs.last_open_path, norm); + + idx = s_find(norm); + if (idx >= 0) { /* 对象已存在 */ + if ((mode & FA_CREATE_NEW) != 0u) { + g_ff_obs.last_open_rc = (int)FR_EXIST; + return FR_EXIST; /* 真实 f_open:Cannot create as new file */ + } + if (s_ent[idx].is_dir != 0) { /* 对目录本身开文件 */ + if ((mode & FF_FAKE_CREATE_MASK) != 0u) { + g_ff_obs.last_open_rc = (int)FR_DENIED; /* 真实:Cannot overwrite (DIR) */ + return FR_DENIED; + } + g_ff_obs.last_open_rc = (int)FR_NO_FILE; /* 真实 f_open:3850 */ + return FR_NO_FILE; + } + if ((mode & FA_CREATE_ALWAYS) != 0u) { + s_ent[idx].size = 0u; /* 截零(真实 ff.c 复位目录项 + 删簇链) */ + s_ent[idx].stored = 0u; + s_dirty[idx] = 1u; + } + } else { /* 对象不存在 */ + /* 真实 ff.c:中间目录段不存在时 follow_path 就已经是 FR_NO_PATH 了, + * **先判父目录再判创建位**,否则 "/log 未建 + 只给 FA_READ" 会被误报成 + * FR_NO_FILE,被测代码的自愈分支就走不到 */ + fr = s_check_parent(norm); + if (fr != FR_OK) { + g_ff_obs.last_open_rc = (int)fr; + return fr; + } + if ((mode & FF_FAKE_CREATE_MASK) == 0u) { + g_ff_obs.last_open_rc = (int)FR_NO_FILE; + return FR_NO_FILE; + } + idx = s_alloc(); + if (idx < 0) { + g_ff_obs.last_open_rc = (int)FR_DENIED; /* 目录满(真实 dir_register) */ + return FR_DENIED; + } + s_ent[idx].used = 1; + s_ent[idx].is_dir = 0; + s_ent[idx].size = 0u; + s_ent[idx].stored = 0u; + s_keep(s_ent[idx].path, norm); + s_dirty[idx] = 1u; + } + + /* 句柄:mode 原值进 flag(真实 ff.c 同做法),因此"只给 FA_OPEN_APPEND" + * 的句柄在 f_write 里必然拿 FR_DENIED —— 陷阱一的复刻度量 */ + fp->obj.fs = FF_FAKE_FS_MAGIC; + fp->obj.sclust = (DWORD)idx + 1u; + fp->obj.objsize = (FSIZE_t)s_ent[idx].size; + fp->flag = mode; + fp->err = 0u; + fp->fptr = ((mode & FF_FAKE_SEEKEND) != 0u) ? (FSIZE_t)s_ent[idx].size : 0u; + g_ff_obs.last_open_rc = (int)FR_OK; + return FR_OK; +} + +static int s_chk(FIL *fp, int *p_idx) +{ + if ((fp == NULL) || (fp->obj.fs != FF_FAKE_FS_MAGIC)) { + return 0; /* FR_INVALID_OBJECT */ + } + if ((fp->obj.sclust == 0u) || (fp->obj.sclust > (DWORD)FF_FAKE_MAX_ENT)) { + return 0; + } + *p_idx = (int)fp->obj.sclust - 1; + if (s_ent[*p_idx].used == 0) { + return 0; + } + return 1; +} + +FRESULT f_write(FIL *fp, const void *p_buff, UINT btw, UINT *bw) +{ + FRESULT fr; + FRESULT inj_rc = FR_OK; + int inj_act = 0; + UINT inj_n = 0u; + int hit = 0; + int idx = -1; + uint32_t pos; + uint32_t room; + uint32_t n; + + if (bw != NULL) { + *bw = 0u; + } + s_guard(FF_FAKE_OP_WRITE); + g_ff_obs.last_write_btw = btw; + g_ff_obs.last_write_bw = 0u; + if (!s_chk(fp, &idx)) { + g_ff_obs.last_write_rc = (int)FR_INVALID_OBJECT; + return FR_INVALID_OBJECT; + } + /* 陷阱一:只给 FA_OPEN_APPEND(0x30) 不含 FA_WRITE(0x02) -> 恒返 FR_DENIED */ + if ((fp->flag & FA_WRITE) == 0u) { + g_ff_obs.last_write_rc = (int)FR_DENIED; + return FR_DENIED; + } + hit = s_hit(FF_FAKE_OP_WRITE, s_ent[idx].path, &inj_rc, &inj_act, &inj_n); + if ((hit != 0) && (inj_act == FF_FAKE_ACT_RC)) { + g_ff_obs.last_write_rc = (int)inj_rc; + return inj_rc; + } + if (p_buff == NULL) { + g_ff_obs.last_write_rc = (int)FR_INT_ERR; + return FR_INT_ERR; + } + + pos = (uint32_t)fp->fptr; + /* pos 可能已经越过簇池上沿(ff_fake_mkfile_size 造的"已有 64 KB 记录"就是这种), + * 等价于"一个簇都分配不出来":短写到 0 字节,仍然 FR_OK */ + room = (pos >= FF_FAKE_CLUSTER) ? 0u : (FF_FAKE_CLUSTER - pos); + n = btw; + if ((hit != 0) && (inj_act == FF_FAKE_ACT_WSHORT)) { + if (n > inj_n) { + n = inj_n; /* 定点短写:恰好写 inj_n 字节后"当成功"返回 */ + } + } + if (n > room) { + n = room; /* 真实 ff.c:4102 clst == 0 -> break:FR_OK + 短写 */ + } + if (n > 0u) { + (void)memcpy(&s_ent[idx].data[pos], p_buff, n); + if ((pos + n) > s_ent[idx].stored) { + s_ent[idx].stored = pos + n; + } + } + fp->fptr = (FSIZE_t)(pos + n); + if (fp->fptr > fp->obj.objsize) { + fp->obj.objsize = fp->fptr; + } + s_ent[idx].size = (uint32_t)fp->obj.objsize; + s_dirty[idx] = 1u; + if (bw != NULL) { + *bw = n; + } + g_ff_obs.last_write_bw = n; + g_ff_obs.last_write_rc = (int)FR_OK; + fr = FR_OK; /* 短写也是 FR_OK:判失败只能靠 *bw(§10.1 第 11 条) */ + return fr; +} + +FRESULT f_read(FIL *fp, void *p_buff, UINT btr, UINT *br) +{ + FRESULT inj_rc = FR_OK; + int inj_act = 0; + UINT inj_n = 0u; + int hit = 0; + int idx = -1; + uint32_t pos; + uint32_t remain; + uint32_t n; + + if (br != NULL) { + *br = 0u; + } + s_guard(FF_FAKE_OP_READ); + g_ff_obs.last_read_btr = btr; + g_ff_obs.last_read_br = 0u; + if (!s_chk(fp, &idx)) { + return FR_INVALID_OBJECT; + } + if ((fp->flag & FA_READ) == 0u) { /* 真实 f_read:Check access mode */ + return FR_DENIED; + } + hit = s_hit(FF_FAKE_OP_READ, s_ent[idx].path, &inj_rc, &inj_act, &inj_n); + if ((hit != 0) && (inj_act == FF_FAKE_ACT_RC)) { + return inj_rc; + } + if (p_buff == NULL) { + return FR_INT_ERR; + } + pos = (uint32_t)fp->fptr; + remain = ((uint32_t)fp->obj.objsize > pos) ? ((uint32_t)fp->obj.objsize - pos) : 0u; + n = btr; + if (n > remain) { + n = remain; /* 真实 f_read:btr 被 remain 截断后照常 FR_OK */ + } + if ((hit != 0) && (inj_act == FF_FAKE_ACT_RSHORT) && (n > inj_n)) { + n = inj_n; + } + { + uint32_t k; + uint8_t *dst = (uint8_t *)p_buff; + + for (k = 0u; k < n; k++) { + dst[k] = ((pos + k) < s_ent[idx].stored) ? s_ent[idx].data[pos + k] : 0u; + } + } + fp->fptr = (FSIZE_t)(pos + n); + if (br != NULL) { + *br = n; + } + g_ff_obs.last_read_br = n; + return FR_OK; +} + +FRESULT f_lseek(FIL *fp, FSIZE_t ofs) +{ + int idx = -1; + uint32_t want = (uint32_t)ofs; + + s_guard(FF_FAKE_OP_LSEEK); + g_ff_obs.last_lseek_ofs = ofs; + if (!s_chk(fp, &idx)) { + return FR_INVALID_OBJECT; + } + if (want > (uint32_t)fp->obj.objsize) { + if ((fp->flag & FA_WRITE) == 0u) { + want = (uint32_t)fp->obj.objsize; /* 只读句柄:钳到文件尾,不报错 */ + } else if (want > FF_FAKE_CLUSTER) { + return FR_DENIED; /* 可写但要超出簇池 = 簇分配不出来 */ + } else { + s_ent[idx].size = want; /* 可写句柄:扩展(真实 ff.c 补零簇链) */ + fp->obj.objsize = (FSIZE_t)want; + s_dirty[idx] = 1u; + } + } + fp->fptr = (FSIZE_t)want; + return FR_OK; +} + +FRESULT f_close(FIL *fp) +{ + int idx = -1; + FRESULT inj_rc = FR_OK; + int inj_act = 0; + UINT inj_n = 0u; + int hit = 0; + + s_guard(FF_FAKE_OP_CLOSE); + if (!s_chk(fp, &idx)) { + return FR_INVALID_OBJECT; + } + hit = s_hit(FF_FAKE_OP_CLOSE, s_ent[idx].path, &inj_rc, &inj_act, &inj_n); + if ((hit != 0) && (inj_act == FF_FAKE_ACT_RC)) { + return inj_rc; /* 真实 f_close 会因 sync 失败返错且释放句柄 */ + } + if (s_dirty[idx] != 0u) { /* 真实 f_close:sync 里用 get_fattime 回写目录项 */ + s_ent[idx].fdate = s_dostime_d; + s_ent[idx].ftime = s_dostime_t; + s_dirty[idx] = 0u; + } + s_inval(fp); + return FR_OK; +} + +FRESULT f_stat(const TCHAR *path, FILINFO *fno) +{ + char norm[FF_FAKE_PATH_MAX]; + FRESULT fr; + FRESULT inj_rc = FR_OK; + int inj_act = 0; + UINT inj_n = 0u; + int hit = 0; + int idx; + + s_guard(FF_FAKE_OP_STAT); + fr = s_norm(path, norm, (int)sizeof(norm)); + if (fr != FR_OK) { + return fr; + } + hit = s_hit(FF_FAKE_OP_STAT, norm, &inj_rc, &inj_act, &inj_n); + if ((hit != 0) && (inj_act == FF_FAKE_ACT_RC)) { + return inj_rc; + } + idx = s_find(norm); + if (idx < 0) { + /* 真实 follow_path:末段找不到 -> FR_NO_FILE,但**中间段**找不到 -> FR_NO_PATH */ + fr = s_check_parent(norm); + return (fr == FR_OK) ? FR_NO_FILE : fr; + } + if (fno != NULL) { + (void)memset(fno, 0, sizeof(*fno)); + fno->fsize = (FSIZE_t)s_ent[idx].size; + fno->fdate = s_ent[idx].fdate; + fno->ftime = s_ent[idx].ftime; + fno->fattrib = (s_ent[idx].is_dir != 0) ? AM_DIR : AM_ARC; + { + const char *base = strrchr(norm, '/'); + base = (base != NULL) ? (base + 1) : norm; + s_keep_n(fno->fname, (int)sizeof(fno->fname), base); + } + } + return FR_OK; +} + +FRESULT f_unlink(const TCHAR *path) +{ + char norm[FF_FAKE_PATH_MAX]; + FRESULT fr; + FRESULT inj_rc = FR_OK; + int inj_act = 0; + UINT inj_n = 0u; + int hit = 0; + int idx; + + s_guard(FF_FAKE_OP_UNLINK); + fr = s_norm(path, norm, (int)sizeof(norm)); + if (fr != FR_OK) { + return fr; + } + s_keep(g_ff_obs.last_unlink_path, norm); + hit = s_hit(FF_FAKE_OP_UNLINK, norm, &inj_rc, &inj_act, &inj_n); + if ((hit != 0) && (inj_act == FF_FAKE_ACT_RC)) { + return inj_rc; + } + idx = s_find(norm); + if (idx < 0) { + fr = s_check_parent(norm); + return (fr == FR_OK) ? FR_NO_FILE : fr; + } + if ((s_ent[idx].is_dir != 0) && (s_has_children(norm) != 0)) { + return FR_DENIED; /* 真实 f_unlink:目录非空不可删 */ + } + s_ent[idx].used = 0; + s_dirty[idx] = 0u; + return FR_OK; +} + +FRESULT f_mkdir(const TCHAR *path) +{ + char norm[FF_FAKE_PATH_MAX]; + FRESULT fr; + FRESULT inj_rc = FR_OK; + int inj_act = 0; + UINT inj_n = 0u; + int hit = 0; + int idx; + + s_guard(FF_FAKE_OP_MKDIR); + fr = s_norm(path, norm, (int)sizeof(norm)); + if (fr != FR_OK) { + return fr; + } + s_keep(g_ff_obs.last_mkdir_path, norm); + hit = s_hit(FF_FAKE_OP_MKDIR, norm, &inj_rc, &inj_act, &inj_n); + if ((hit != 0) && (inj_act == FF_FAKE_ACT_RC)) { + return inj_rc; + } + if (s_find(norm) >= 0) { + return FR_EXIST; /* 真实 f_mkdir:已存在 -> FR_EXIST */ + } + fr = s_check_parent(norm); + if (fr != FR_OK) { + return fr; + } + idx = s_alloc(); + if (idx < 0) { + return FR_DENIED; + } + s_ent[idx].used = 1; + s_ent[idx].is_dir = 1; + s_ent[idx].size = 0u; + s_ent[idx].stored = 0u; + s_ent[idx].fdate = s_dostime_d; + s_ent[idx].ftime = s_dostime_t; + s_keep(s_ent[idx].path, norm); + return FR_OK; +} + +FRESULT f_rename(const TCHAR *path_old, const TCHAR *path_new) +{ + char nold[FF_FAKE_PATH_MAX]; + char nnew[FF_FAKE_PATH_MAX]; + FRESULT fr; + FRESULT inj_rc = FR_OK; + int inj_act = 0; + UINT inj_n = 0u; + int hit = 0; + int idx; + int dup; + + s_guard(FF_FAKE_OP_RENAME); + fr = s_norm(path_old, nold, (int)sizeof(nold)); + if (fr != FR_OK) { + return fr; + } + fr = s_norm(path_new, nnew, (int)sizeof(nnew)); + if (fr != FR_OK) { + return fr; + } + s_keep(g_ff_obs.last_rename_old, nold); + s_keep(g_ff_obs.last_rename_new, nnew); + hit = s_hit(FF_FAKE_OP_RENAME, nold, &inj_rc, &inj_act, &inj_n); + if ((hit != 0) && (inj_act == FF_FAKE_ACT_RC)) { + return inj_rc; + } + idx = s_find(nold); + if (idx < 0) { + fr = s_check_parent(nold); + return (fr == FR_OK) ? FR_NO_FILE : fr; + } + dup = s_find(nnew); + if (dup >= 0) { + /* E-01 台架实证的根因:本移植(与上游同语义)"new name already in use" + * 一律 FR_EXIST、不覆盖。原子写因此必须先 unlink 目标再 rename。 */ + return FR_EXIST; + } + fr = s_check_parent(nnew); + if (fr != FR_OK) { + return fr; + } + if (s_ent[idx].is_dir != 0) { /* 目录改名:同步搬走全部子项 */ + int dl = (int)strlen(nold); + int i; + + for (i = 0; i < (int)FF_FAKE_MAX_ENT; i++) { + if ((s_ent[i].used == 0) || (i == idx)) { + continue; + } + if (((int)strlen(s_ent[i].path) > dl) && + (strncmp(s_ent[i].path, nold, (size_t)dl) == 0) && + (s_ent[i].path[dl] == '/')) { + char tmp[FF_FAKE_PATH_MAX]; + int m; + + for (m = 0; nnew[m] != '\0'; m++) { + tmp[m] = nnew[m]; + } + s_keep_n(&tmp[m], (int)sizeof(tmp) - m, &s_ent[i].path[dl]); + s_keep(s_ent[i].path, tmp); + } + } + } + s_keep(s_ent[idx].path, nnew); + s_ent[idx].fdate = s_dostime_d; + s_ent[idx].ftime = s_dostime_t; + return FR_OK; +} diff --git a/test/host/common/fatfs_fake.h b/test/host/common/fatfs_fake.h new file mode 100644 index 00000000..3d561fd8 --- /dev/null +++ b/test/host/common/fatfs_fake.h @@ -0,0 +1,149 @@ +/* + * 模块名称:假 FatFs 内存卷(存储域主机侧单测共享件) + * 模块功能:给 App/log 与 App/cfg 的主机侧单测一个可脚本化的假文件系统—— + * 类型与常量取自**真实 Lib/FatFs/ff.h**(FRESULT / FIL / FILINFO / FA_* + * 一字不改),本件在 fatfs_fake.c 里给出九个 f_* 原型的 RAM 实现: + * f_open / f_read / f_write / f_lseek / f_close / f_stat / f_unlink / + * f_mkdir / f_rename。 + * + * 仅供 test/host/** 使用:不参与 MCU 构建,也不参与 simulator 构建。 + * + * 为什么共享一份(照 test/host/common/ch395f_fake.{h,c} 的先例): + * sys_log_mcu / soe_store / cfg_file_store_mcu 三块的成败**全部**由 + * 「这次 f_open 拿到什么码、f_write 写回多少字节」决定。三份假件各写一遍, + * 漂移 = 「假件与被测代码同时错而无人报错」。签名与真实头同源 → 真实 API 一改 + * 就编译期报错(同 test/host/nand_port 的「真头 + 假 diskio」路子)。 + * + * —— 必须复刻的三条本仓实证语义(docs/架构设计/STM32F4-Base存储架构说明.md §10.1)—— + * 1) FA_OPEN_APPEND(0x30) **不含 FA_WRITE(0x02)**:本移植 FA_OPEN_APPEND = + * FA_OPEN_ALWAYS(0x10) | FA_SEEKEND(0x20),而 FA_SEEKEND 只定义在 ff.c:74、 + * ff.h 里没有。所以只给 FA_OPEN_APPEND 打开的文件**能打开、能创建**,但 + * f_write 恒返 FR_DENIED(7)——2026-09-20 台架「/log 写失败 x5/开机」的真根因 + * (§10.1 第 11 条)。假件按真实 ff.c 一样把 mode 原值存进 fp->flag 再判 + * flag & FA_WRITE,不做任何「便利放行」。 + * 2) f_write 簇分配失败 = **FR_OK + 短写**(无错误码,判失败只能校验写回字节数): + * 真实 ff.c:4102 的 if (clst == 0) break; → 循环带着已写计数退出 → FR_OK。 + * 假件把「每文件固定后备簇池」当介质:写到装不下就返回 FR_OK + 截断字节数; + * 另给定点注入,让「恰好短一字节」这种真机要撞坏块才有的形态可复现。 + * 3) FF_USE_LFN=0 → **严格 8.3 短名**:主名 <=8、扩展 <=3、非法字符集 + * * + ; < = > [ ] | 与 0x7F(ff.c create_name 非 LFN 分支逐字模拟)。 + * 「.fschk」的真实死法 = 开头那个点把游标直接推进扩展域、随后 5 个字符撑爆 + * 扩展域上限 3 → FR_INVALID_NAME(6);路径里任何位置出现 ':' 被 get_ldnumber + * 当盘符前缀吃下 → FR_INVALID_DRIVE(11)。两者都是**纯 RAM 里的名字判定、 + * 不碰盘**(§10.1 第 10 条,含「测试用例须同时接受这两个码」的口径)。 + * + * —— 其余与真实 ff.c 对齐的路径/错误码细则 —— + * - 前导分隔符被剥掉、恒从卷根开始(FF_FS_RPATH=0,见 ff.c follow_path); + * - 中间目录段不存在 → FR_NO_PATH;末段不存在且未给创建位 → FR_NO_FILE; + * - 对目录本身 f_open(无创建位)→ FR_NO_FILE; + * - FA_CREATE_NEW 撞上已存在 → FR_EXIST;FA_CREATE_ALWAYS → 截零 + 刷新时间戳; + * - 路径剥完分隔符为空 → FR_INVALID_NAME(ff.c create_name 的 i==0 分支); + * - f_stat:父目录在而对象不在 → FR_NO_FILE;父目录也不在 → FR_NO_PATH; + * - f_unlink 不存在 → FR_NO_FILE;f_mkdir 已存在 → FR_EXIST; + * - **f_rename 撞已存在的新名 → FR_EXIST、不覆盖**(ff.c「new name already in + * use」,2026-09-22 台架 E-01 暴露的那条:缺了它,一切「目标存在的覆盖写」 + * 必然失败,而模拟器端 POSIX rename 的覆盖语义照不出来)。不复刻本条 = + * cfg_store_write_atomic 里「先 unlink 再 rename」那一步永远测不出必要性; + * - f_lseek 越过文件尾:只读句柄**钳到 objsize 且不报错**(ff.c 正常寻址分支), + * 可写句柄才扩展文件; + * - f_open 失败即作废句柄(ff.c「fp->obj.fs = 0」)→ 后续 f_write 拿 + * FR_INVALID_OBJECT,不会拿着脏句柄继续写; + * - f_close 成功即释放句柄,并把打开期间的变更落成新的 fdate/ftime + * (真实 ff.c 在 close 里用 get_fattime 回写目录项)。 + * + * —— 脚本化模型(沿用本仓既有假件风格:nand_port 的 RAM 假芯片 / lftpd_io 的假堆)—— + * - 注入:按路径 + op 预置「接下来 N 次返回某码 / 只写回 n 字节 / 只读回 n 字节」; + * path 传 NULL = 不限路径; + * - 观测:逐 op 调用计数 + 最近一次入参(路径、mode、btw/bw、btr/br、lseek 偏移), + * 让「这轮压根不该碰盘」与「打开模式必须带写位」成为可断言的事; + * - 卷:条目直读直写(ff_fake_mkfile / ff_fake_mkfile_size / ff_fake_fetch)用来 + * 铺前置状态——「run.log 已经有 1024 条记录」不可能靠真写 65536 字节铺出来; + * - 假时钟:ff_fake_set_dostime 控制 fdate/ftime,FAT 分钟粒度导致 + * 「同分钟内同长度重写不可检出」这件事只有它能测(cfg_store_stat 的 mtime 令牌); + * - ff_fake_reset() 一次全清(卷 + 注入 + 观测 + 时钟)。 + * + * 有意**不提供** f_mount / f_mkfs / f_opendir / f_readdir:三个被测模块一次都不该 + * 调它们,真调了就是链接期报错——比运行时断言更硬,也不会静默通过。 + * + * 创建日期:2026-10-24(P2 主机单测覆盖率批次,task-2) + */ + +#ifndef FATFS_FAKE_H +#define FATFS_FAKE_H + +#include + +/* 真实头在 Lib/FatFs/;本文件不重定义任何 FatFs 类型或宏 */ +#include "ff.h" + +#define FF_FAKE_MAX_ENT 16u /* 卷内条目上限(文件 + 目录) */ +#define FF_FAKE_PATH_MAX 24u /* 归一化后路径上限("/log/selftest.log" = 17) */ +/* 每文件的后备簇池:必须**大于生产侧单文件容量上限**(run.log / soe.log + * = 1024 条 x 64 B = 65536 B,见 sys_log.h:SYSLOG_RUN_MAX 与 soe_store.h: + * SOE_STORE_MAX),否则测"容量满 unlink 重建"时追加位落在池外,f_write 恒 + * 返回 0 字节短写,轮转场景根本跑不到。写满仍然如实返回 FR_OK + 短写 + *(真实 ff.c 簇分配失败的同一形态)。16 条目 x 66 KB ≈ 1 MB 静态,主机侧无压力。 */ +#define FF_FAKE_CLUSTER 67584u +#define FF_FAKE_INJ_MAX 8u /* 注入槽位数 */ +#define FF_FAKE_GUARD 50000 /* 跑飞保险:单个 f_* 调用数超此值直接 exit(2) */ + +/* 注入与计数的操作编号(fatfs_fake.c 内部同一套) */ +enum { + FF_FAKE_OP_OPEN = 0, + FF_FAKE_OP_READ, + FF_FAKE_OP_WRITE, + FF_FAKE_OP_LSEEK, + FF_FAKE_OP_CLOSE, + FF_FAKE_OP_STAT, + FF_FAKE_OP_UNLINK, + FF_FAKE_OP_MKDIR, + FF_FAKE_OP_RENAME, + FF_FAKE_OP_N +}; + +/* 注入动作 */ +enum { + FF_FAKE_ACT_RC = 0, /* 直接返回指定 FRESULT,不做任何数据搬运 */ + FF_FAKE_ACT_WSHORT, /* 仅 f_write:FR_OK 但只写回 n 字节 */ + FF_FAKE_ACT_RSHORT /* 仅 f_read :FR_OK 但只读回 n 字节 */ +}; + +typedef struct { + int calls[FF_FAKE_OP_N]; /* 逐 op 调用次数(含失败的调用) */ + BYTE last_open_mode; /* 最近一次 f_open 的 mode 原值(断言 FA_WRITE 位) */ + char last_open_path[FF_FAKE_PATH_MAX]; + char last_unlink_path[FF_FAKE_PATH_MAX]; + char last_mkdir_path[FF_FAKE_PATH_MAX]; + char last_rename_old[FF_FAKE_PATH_MAX]; + char last_rename_new[FF_FAKE_PATH_MAX]; + UINT last_write_btw; /* 请求写的字节数 */ + UINT last_write_bw; /* 实际写回的字节数(短写的断言面) */ + UINT last_read_btr; + UINT last_read_br; + FSIZE_t last_lseek_ofs; + int last_open_rc; /* 最近一次 f_open 的返回码 */ + int last_write_rc; /* 最近一次 f_write 的返回码 */ +} ff_fake_obs_t; + +extern ff_fake_obs_t g_ff_obs; + +/* —— 世界控制 —— */ +void ff_fake_reset(void); /* 卷 + 注入 + 观测 + 时钟一次全清 */ +void ff_fake_set_dostime(uint16_t fdate, uint16_t ftime); /* 假 get_fattime() 的两个域 */ + +/* —— 故障注入(times = 生效次数;path = NULL 表示不限路径)—— */ +void ff_fake_inject_rc(const char *path, int op, FRESULT rc, int times); +void ff_fake_inject_write_short(const char *path, UINT n, int times); +void ff_fake_inject_read_short(const char *path, UINT n, int times); + +/* —— 卷内容直读直写(绕开打开语义,用来铺前置状态)—— */ +int ff_fake_exists(const char *path); /* 0 无 / 1 文件 / 2 目录 */ +uint32_t ff_fake_size(const char *path); /* 无此文件返回 0xFFFFFFFF */ +uint32_t ff_fake_time_token(const char *path); /* (fdate<<16)|ftime,同 cfg_store_stat */ +uint32_t ff_fake_fetch(const char *path, uint8_t *p_buf, uint32_t off, uint32_t cap); +int ff_fake_mkfile(const char *path, const uint8_t *p_data, uint32_t len); +int ff_fake_mkfile_size(const char *path, uint32_t size); /* 只给逻辑长度(造容量满) */ +int ff_fake_mkdir_direct(const char *path); /* 无条件建目录 */ +int ff_fake_count_entries(void); /* 卷内条目数(含目录) */ + +#endif /* FATFS_FAKE_H */ diff --git a/test/host/console_log/console_log_test.c b/test/host/console_log/console_log_test.c new file mode 100644 index 00000000..8f7d05e1 --- /dev/null +++ b/test/host/console_log/console_log_test.c @@ -0,0 +1,922 @@ +/* + * 模块名称:控制台单写者日志(console_log)主机侧单元测试 + * 模块功能:在 PC 上离线验证 App/log/console_log.c 的单写者契约(G1~G5 / K1~K5,见 + * docs/子系统设计/控制台单写者日志设计说明.md):环形缓冲入环与分块发送、 + * 行级原子、192B 截断、写者未注册/ISR 上下文的无锁直发、缓冲满有界等待与 + * 丢弃可观测、TX 僵尸通道软复位自愈 + 300ms 冷却、DWT 与指令计数两种硬上界、 + * huart1.Instance 未初始化的 NULL 短路、fputc 重定向、drain 250ms 预算。 + * + * 为什么要主机侧单测(而不是只用台架): + * 本模块的风险全在"异常时刻"——UART 僵尸通道卡死、TIM7 停摆导致 uwTick 不往前走、 + * 开机 1.7s 喂狗容限被背靠背块超时吃掉(台架实测的停喂误复位)、写者注册前多任务裸发 + * 逐字节交错(2026-10-10/10-12 台架实证)。台架上这些要么一年一遇、要么现场已被 + * 复位冲掉;而它们的判据全是"数得清的数":192B 上限、256B 分块、4095B 可用环、 + * 200 tick 等待、20000 次计数上界、100/250/300ms 三条时间线。把它们做成断言,是这台 + * 设备上唯一能把这几条线钉死的办法(板端套件只能"在真机上跑一遍")。 + * + * 桩的边界(细节见 stub/stm32f4_fake.h 头注释): + * console_log.h / sys_events.h(含真实 SYS_EVT_CONSOLE_TX 位)用**真实头**; + * usart.h 用桩,但只暴露本模块访问的 huart1 + MX_USART1_UART_Init,寄存器位号 / + * 枚举值照抄真实头;USART SR 的 TXE/TC、DWT CYCCNT、HAL_GetTick、临界区、 + * __get_IPSR、vTaskDelay、osEventFlagsSet 做成可控 + 可观测的量。 + * ⚠ 本文件**刻意不定义**任何 syslog_* / DBG_* 符号:console_log.c 哪天把"TX 故障 + * 留痕"改回直接调日志子系统,链接就失败——那正是必须挡住的(write_line → + * con_append → drain → syslog_post → write_line 成环,静态栈深在每个任务上虚高)。 + * + * 覆盖点(编号族 TC-CONLOG-*,属主登记处 = 本文件;实占 104 号 / 104 条断言): + * 000..006 宿主 printf 与本模块 fputc 的隔离 + 参数校验与 192B 截断 + * 010..018 写者未注册 = 无锁直发(不入环、不置事件位、零临界区、不等待、256B 有界) + * 020..038 注册后走环形:入环不发送、按 256B 分块、多生产者不交错、跨环尾两段拷贝、 + * 入环长度钳 RING/2 + * 040..044 写者自记录先排空(不自等)/ drain 重入保护 / 未注册时 drain 让位 + * 050..062 缓冲满:DWT 硬上界丢弃、不覆盖旧行、等待中出现空间、无 DWT 时 200 tick + * 上界、[ERR] dropped 直发不经环、读后即清 + * 070..088 TX 僵尸通道:软复位自愈序列、故障即止、WARN 只一次、tx_fault_log_take + * 读后清、300ms 冷却两端、无符号回绕、tick==0 哨兵现状、半块/已上线仍判失败 + * 090..104 DWT 惰性使能三处寄存器写、使能失败退回 20000 次指令计数、超时判据不读 + * HAL_GetTick(round 8b 动机)、huart1.Instance == NULL 短路两路 + * 110..117 fputc 重定向(未注册直发 / 注册逐字节入环 / ISR 上下文裸发 / 环满时 ISR + * 仍不等待) + * 120..122 drain 单次 250ms 预算与下轮续排(让位喂狗但不丢字节) + * 130..135 计数支路也能把整块发完(不只会超时)+ 其 TC 计数上界 + DWT 支路 TC 抖动 + * 后正常收尾 + con_append 直调时的自有参数校验 + * + * 实测覆盖率(scripts/check_test_coverage.py --module console_log,/Od + /Zi 口径): + * App/log/console_log.c **188/189 行 = 99.5%,函数 15/15 = 100%**。 + * + * 故意没测(逐行说明见交付报告): + * - console_log.c:352 唯一未覆盖行:snprintf 溢出后的钳位 `n = sizeof(note)-1`。 + * note[64],前缀 24B + "%lu"(unsigned long 最多 10 位)+ 后缀 8B = 最长 42B + * < 63B,任何输入都到不了这一行 —— 纯防御,不是漏测。 + * - 真实周期时间量级:主机没有真 DWT CYCCNT 也没有 TIM7 的 uwTick,只能钉"判据在 + * 第几次读成立 / 自旋几次放弃",不能声称"实测 100ms 就返回"。 + * - ISR 与写者同时裸发的真总线交错(docs §13-O1):宿主单线程,造不出两个执行流。 + * - osEventFlagsWait 唤醒写者与 50ms 兜底:写者任务体在 App/task/default_task.c。 + * - MicroLIB 真实 retarget 链接语义:宿主 CRT 有自己的 fputc(实测与本模块符号 + * 冲突 -> LNK2005),故本 TU 把被测 fputc 改名纳入,只验函数体不验链接钩子。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL;跑飞保险 exit(2)。 + * + * 创建日期:2026-10-24 + */ + +#include +#include +#include +#include + +/* 平台假件先纳入:它给出 USART/DWT/HAL/内核入口的宿主等价声明(stub 目录排第一) */ +#include "stm32f4_fake.h" + +/* ===================== 假件全局量(声明见 stub/stm32f4_fake.h) ===================== */ +uint32_t SystemCoreClock = 168000000U; /* 与 MCU 端一致(168 MHz) */ + +USART_TypeDef g_con_uart; +CoreDebug_Type g_con_core_debug; +DWT_Type g_con_dwt; +UART_HandleTypeDef huart1; + +uint32_t con_uart_tx_n; +uint8_t con_uart_tx[CON_UART_TX_MAX]; +uint32_t con_uart_block_n; +uint32_t con_uart_block_len[CON_UART_BLOCK_MAX]; +uint32_t con_uart_cur_len; +uint32_t con_uart_txe_reads; +uint32_t con_uart_tc_reads; +int con_uart_txe_stuck; +int con_uart_txe_after; +int con_uart_txe_ready_left; +int con_uart_tc_after; +int con_uart_tc_stuck; +int con_uart_pending; + +uint32_t con_dwt_step; +int con_dwt_ctrl_sticky; +uint32_t con_dwt_reads; + +uint32_t con_tick_now; +uint32_t con_tick_step; +uint32_t con_tick_reads; + +static char s_task_writer; /* 假写者任务句柄(取地址当不透明句柄) */ +static char s_task_other; /* 假"别的任务"句柄 */ +TaskHandle_t con_task_self; + +uint32_t con_delay_calls; +void (*con_delay_hook)(void); + +uint32_t con_crit_enter; +uint32_t con_crit_exit; +uint32_t con_crit_max_depth; +static uint32_t s_crit_depth; + +uint32_t con_ipsr; + +osEventFlagsId_t sys_evtHandle; /* 真实 sys_events.h 声明的全局句柄 */ +static char s_evt_obj; /* 假事件组对象(非 NULL 即可) */ +uint32_t con_evt_sets; +uint32_t con_evt_flags; + +uint32_t con_nvic_clears; +uint32_t con_rcc_disables; +uint32_t con_rcc_enables; +static uint32_t s_mx_init_calls; +static int s_mx_repair; /* 1 = 重初始化把僵尸通道治好(模型行为) */ + +/* ===================== 假件实现 ===================== */ + +uint32_t HAL_GetTick(void) +{ + uint32_t v = con_tick_now; + + con_tick_reads++; + if (con_tick_reads > 2000000U) { + printf("[FATAL] fake HAL_GetTick called %lu times (runaway)\n", + (unsigned long)con_tick_reads); + exit(2); + } + con_tick_now += con_tick_step; + return v; +} + +TaskHandle_t xTaskGetCurrentTaskHandle(void) +{ + return con_task_self; +} + +void vTaskDelay(TickType_t ticks) +{ + (void)ticks; + con_delay_calls++; + if (con_delay_calls > 5000U) { + printf("[FATAL] fake vTaskDelay called %lu times (runaway)\n", + (unsigned long)con_delay_calls); + exit(2); + } + if (con_delay_hook != 0) { + con_delay_hook(); + } +} + +void con_crit_push(void) +{ + con_crit_enter++; + s_crit_depth++; + if (s_crit_depth > con_crit_max_depth) { + con_crit_max_depth = s_crit_depth; + } +} + +void con_crit_pop(void) +{ + con_crit_exit++; + if (s_crit_depth == 0U) { + printf("[FATAL] critical section exit without enter\n"); + exit(2); + } + s_crit_depth--; +} + +osStatus_t osEventFlagsSet(osEventFlagsId_t ef_id, uint32_t flags) +{ + (void)ef_id; + con_evt_sets++; + con_evt_flags = flags; + return (osStatus_t)flags; +} + +/* 真实版是 CubeMX 生成的 HAL_UART_Init 封装;桩按脚本决定"软复位到底治没治好" */ +void MX_USART1_UART_Init(void) +{ + s_mx_init_calls++; + huart1.Instance = &g_con_uart; + huart1.gState = HAL_UART_STATE_READY; + huart1.ErrorCode = HAL_UART_ERROR_NONE; + if (s_mx_repair != 0) { + con_uart_txe_stuck = 0; + con_uart_txe_after = 0; + con_uart_txe_ready_left = -1; + con_uart_tc_after = 0; + con_uart_tc_stuck = 0; + } +} + + +void con_uart_reset(void) +{ + memset(&g_con_uart, 0xFF, sizeof(g_con_uart)); /* SR 恒全 1:就绪与否由钩子决定 */ + con_uart_tx_n = 0U; + con_uart_block_n = 0U; + con_uart_cur_len = 0U; + con_uart_txe_reads = 0U; + con_uart_tc_reads = 0U; + con_uart_txe_stuck = 0; + con_uart_txe_after = 0; + con_uart_txe_ready_left = -1; + con_uart_tc_after = 0; + con_uart_tc_stuck = 0; + con_uart_pending = 0; +} + +void con_dwt_reset(void) +{ + memset(&g_con_dwt, 0, sizeof(g_con_dwt)); + memset(&g_con_core_debug, 0, sizeof(g_con_core_debug)); + con_dwt_step = 0U; + con_dwt_ctrl_sticky = 1; + con_dwt_reads = 0U; +} + +/* ===================== 断言 ===================== */ +static int s_total, s_passed, 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) + +/* ===================== 被测代码(原样纳入生产源) ===================== + * ⚠ 唯一一处生产符号改名:**宿主 CRT 自己占用了 fputc 这个全局符号**(实测 + * libucrt.lib(fputc.obj) 与本 TU 冲突 -> LNK2005 / LNK1169,ARMCC + MicroLIB 上 + * 没有这件事,那里 fputc 正是留给用户实现的 retarget 钩子)。所以把被测函数临时 + * 改名后再纳入,函数体与行覆盖统计一字未动,测试直接调 con_fputc_under_test 验它。 + * 顺带回答"测试自己的 printf 会不会被这个钩子吞掉":不会——宿主 CRT 用的是内部 + * _fputc_nolock/_output 那条链,不解析用户态 fputc;TC-CONLOG-000 再钉一次。 */ +#define fputc con_fputc_under_test +#include "console_log.c" +#undef fputc + +/* ===================== 复位:生产 static + 假件,一次不漏 ===================== */ +static void reset_all(void) +{ + /* 生产模块的 static(10 个数据项 + 1 个任务句柄)——漏一个就会跨场景污染 */ + memset(s_con_ring, 0, sizeof(s_con_ring)); + s_con_head = 0U; + s_con_tail = 0U; + s_con_drop = 0U; + s_con_tx_fault = 0U; + s_con_tx_fault_log = 0U; + s_con_cool_until = 0U; + s_con_draining = 0U; + s_con_writer_up = 0U; + s_con_dwt_up = 0U; + s_con_writer_task = 0; + + con_uart_reset(); + con_dwt_reset(); + + con_tick_now = 0U; + con_tick_step = 0U; + con_tick_reads = 0U; + + con_task_self = (TaskHandle_t)&s_task_other; + con_delay_calls = 0U; + con_delay_hook = 0; + + con_crit_enter = 0U; + con_crit_exit = 0U; + con_crit_max_depth = 0U; + s_crit_depth = 0U; + + con_ipsr = 0U; + + sys_evtHandle = (osEventFlagsId_t)&s_evt_obj; + con_evt_sets = 0U; + con_evt_flags = 0U; + + con_nvic_clears = 0U; + con_rcc_disables = 0U; + con_rcc_enables = 0U; + s_mx_init_calls = 0U; + s_mx_repair = 0; + + huart1.Instance = &g_con_uart; + huart1.gState = HAL_UART_STATE_READY; + huart1.RxState = HAL_UART_STATE_READY; + huart1.ErrorCode = HAL_UART_ERROR_NONE; +} + +/* ===================== 观察辅助 ===================== */ +static uint32_t used_now(void) +{ + return con_used(); +} + +static const char *tx_at(uint32_t skip) +{ + return (skip >= con_uart_tx_n) ? "" : (const char *)&con_uart_tx[skip]; +} + +static int tx_is(uint32_t skip, const char *s) +{ + size_t n = strlen(s); + + if ((size_t)con_uart_tx_n < (size_t)skip + n) { + return 0; + } + return memcmp(tx_at(skip), s, n) == 0; +} + +/* 在已上线字节里找一段(tx 是二进制缓冲,不依赖 NUL 结尾) */ +static int tx_contains(const char *needle) +{ + size_t nl = strlen(needle); + uint32_t i; + + if ((size_t)con_uart_tx_n < nl) { + return 0; + } + for (i = 0U; (size_t)i + nl <= (size_t)con_uart_tx_n; i++) { + if (memcmp(&con_uart_tx[i], needle, nl) == 0) { + return 1; + } + } + return 0; +} + +static uint32_t tx_count(const char *needle) +{ + size_t nl = strlen(needle); + uint32_t i; + uint32_t hits = 0U; + + if ((size_t)con_uart_tx_n < nl) { + return 0U; + } + for (i = 0U; (size_t)i + nl <= (size_t)con_uart_tx_n; i++) { + if (memcmp(&con_uart_tx[i], needle, nl) == 0) { + hits++; + } + } + return hits; +} + +/* 写者任务上下文里排空(生产契约:drain 只允许写者任务调) */ +static void writer_drain(void) +{ + con_task_self = (TaskHandle_t)&s_task_writer; + dbg_console_drain(); + con_task_self = (TaskHandle_t)&s_task_other; +} + +/* 后台排空(不改当前任务句柄):模拟"生产者等一轮,写者腾出了空间" */ +static void bg_drain_hook(void) +{ + dbg_console_drain(); +} + +/* 把环填到只剩 want_free 字节可用:走真实入环路径(顺带踩到回绕)。 + * 填充期间把 DWT 上界调大——万一算差一位也不会卡在等待里出不来。 */ +static void fill_ring(uint32_t want_free) +{ + char pad[DBG_CON_LINE_MAX]; + uint32_t i; + + memset(pad, 'Z', sizeof(pad)); + con_dwt_step = SystemCoreClock / 4U; + for (i = 0U; i < 40U; i++) { + uint32_t free_space = (uint32_t)((DBG_CON_RING_SIZE - 1U) - used_now()); + uint32_t n; + + if (free_space <= want_free) { + break; + } + n = free_space - want_free; + if (n > DBG_CON_LINE_MAX) { + n = DBG_CON_LINE_MAX; + } + if (dbg_console_write_line(pad, n) != (int)n) { + break; + } + } + con_dwt_step = 0U; + s_con_drop = 0U; /* 填充过程中的边角丢弃不算场景断言 */ +} + +/* 注册写者(defaultTask),随后把"当前任务"换回别的任务 */ +static void attach_writer(void) +{ + con_task_self = (TaskHandle_t)&s_task_writer; + dbg_console_writer_attach(); + con_task_self = (TaskHandle_t)&s_task_other; +} +/* ============ TC-CONLOG-000..006 参数校验与截断 ============ */ +static void tc_params(void) +{ + char buf[DBG_CON_LINE_MAX + 128U]; + + printf("-- TC-CONLOG-000..006 参数校验与截断 --\n"); + reset_all(); + printf("(probe) 这行走宿主 CRT\n"); + fflush(stdout); + CHECK(con_uart_tx_n == 0U && used_now() == 0U, + "TC-CONLOG-000 宿主 printf 不经过本模块 fputc(否则测试输出会被吞进环)"); + + reset_all(); + con_uart_txe_stuck = 1; /* 通道卡死:任何字节都不该上线 */ + CHECK(dbg_console_write_line(0, 5U) == -1, "TC-CONLOG-001 write_line(NULL) 返回 -1"); + CHECK(dbg_console_write_line("abc", 0U) == -1, "TC-CONLOG-002 write_line(len=0) 返回 -1"); + dbg_console_write_raw(0, 5U); + dbg_console_write_raw("abc", 0U); + CHECK(con_uart_tx_n == 0U, "TC-CONLOG-003 两路 NULL/0 都不碰 UART"); + CHECK(dbg_console_dropped() == 0U, "TC-CONLOG-004 参数非法不计入丢弃"); + + reset_all(); + memset(buf, 'A', sizeof(buf)); + CHECK(dbg_console_write_line(buf, sizeof(buf)) == (int)DBG_CON_LINE_MAX, + "TC-CONLOG-005 超过 DBG_CON_LINE_MAX 的行按 192 截断(返回值即交付字节数)"); + CHECK(con_uart_tx_n == DBG_CON_LINE_MAX, "TC-CONLOG-006 截断后恰好上线 192 字节"); +} + +/* ============ TC-CONLOG-010..018 写者未注册 = 无锁直发 ============ */ +static void tc_unattached(void) +{ + char big[DBG_CON_CHUNK_MAX + 64U]; + + printf("-- TC-CONLOG-010..018 写者未注册:无锁直发 --\n"); + reset_all(); + CHECK(dbg_console_writer_ready() == 0U, "TC-CONLOG-010 attach 之前 writer_ready() == 0"); + CHECK(dbg_console_write_line("hello", 5U) == 5, "TC-CONLOG-011 未注册时整行直发,返回行长"); + CHECK(tx_is(0U, "hello"), "TC-CONLOG-012 直发内容原样上线(没经过环形缓冲)"); + CHECK(used_now() == 0U, "TC-CONLOG-013 直发不入环(head == tail)"); + CHECK(con_evt_sets == 0U, "TC-CONLOG-014 直发不置 SYS_EVT_CONSOLE_TX(还没写者可唤醒)"); + CHECK(con_crit_enter == 0U, + "TC-CONLOG-015 直发零临界区 = 2026-10-10 逐字节交错的根因(不是丢整行)"); + + reset_all(); + memset(big, 'B', sizeof(big)); + dbg_console_write_raw(big, sizeof(big)); + CHECK(con_uart_tx_n == DBG_CON_CHUNK_MAX, "TC-CONLOG-016 write_raw 有界:只发 256 字节"); + + reset_all(); + con_uart_txe_after = 3; /* 前 3 次不就绪(TXE 抖动,未超上界) */ + CHECK(dbg_console_write_line("CD", 2U) == 2, "TC-CONLOG-017 TXE 抖动后仍发全"); + CHECK(tx_is(0U, "CD") && con_delay_calls == 0U, + "TC-CONLOG-018 直发既不等 tick 也不入环(ISR/崩溃钩子可用的前提)"); +} + +/* ============ TC-CONLOG-020..037 注册后走环形缓冲 ============ */ +static void tc_ring(void) +{ + char line[DBG_CON_LINE_MAX]; + static char oversize[3000]; /* 超长入环的源(>= 钳位后的 2048B) */ + uint32_t i; + + printf("-- TC-CONLOG-020..027 注册后:生产者入环、写者发送 --\n"); + reset_all(); + attach_writer(); + CHECK(dbg_console_writer_ready() == 1U, "TC-CONLOG-020 attach 之后 writer_ready() == 1"); + CHECK(s_con_writer_task == (TaskHandle_t)&s_task_writer, + "TC-CONLOG-021 attach 记下的正是调用方的任务句柄(自等保护的依据)"); + CHECK(dbg_console_write_line("netTask up\r\n", 12U) == 12, "TC-CONLOG-022 入环成功返回行长"); + CHECK(con_uart_tx_n == 0U, "TC-CONLOG-023 生产者绝不碰 UART"); + CHECK(used_now() == 12U, "TC-CONLOG-024 整行一次入环(used = 12)"); + CHECK(con_evt_sets == 1U && con_evt_flags == SYS_EVT_CONSOLE_TX, + "TC-CONLOG-025 入环后置真实事件位 SYS_EVT_CONSOLE_TX(bit6)唤醒写者"); + CHECK(con_crit_enter == 1U && con_crit_exit == 1U && con_crit_max_depth == 1U, + "TC-CONLOG-026 整行入环恰好一层临界区(行级原子的实现依据)"); + + writer_drain(); + CHECK(tx_is(0U, "netTask up\r\n"), "TC-CONLOG-027 写者排空:上线字节与入环内容逐字节相同"); + + printf("-- TC-CONLOG-028..032 分块 256B 与多生产者不交错 --\n"); + reset_all(); + attach_writer(); + memset(line, 'C', sizeof(line)); + for (i = 0U; i < 5U; i++) { + (void)dbg_console_write_line(line, sizeof(line)); + } + writer_drain(); + CHECK(con_uart_block_n == 4U, "TC-CONLOG-028 960B 按 DBG_CON_CHUNK_MAX 切成 4 块"); + CHECK(con_uart_block_len[0] == 256U && con_uart_block_len[1] == 256U + && con_uart_block_len[2] == 256U && con_uart_block_len[3] == 192U, + "TC-CONLOG-029 块长 256/256/256/192(单次发送上限真被执行)"); + CHECK(con_uart_tx_n == 960U && con_uart_txe_reads == 960U, + "TC-CONLOG-030 每字节恰好装载一次 DR(就绪即写,没有重复发)"); + + reset_all(); + attach_writer(); + con_task_self = (TaskHandle_t)&s_task_other; + (void)dbg_console_write_line("[A] 11111\r\n", 11U); + con_task_self = (TaskHandle_t)&s_task_writer; /* 写者自己也记一行 -> 先排空 */ + (void)dbg_console_write_line("[B] 22222\r\n", 11U); + con_task_self = (TaskHandle_t)0; /* 连"无句柄"也算一个生产者 */ + (void)dbg_console_write_line("[C] 33333\r\n", 11U); + writer_drain(); + CHECK(tx_is(0U, "[A] 11111\r\n[B] 22222\r\n[C] 33333\r\n"), + "TC-CONLOG-031 三个生产者各写一行 -> 上线仍是三行连续原文(G1 行级原子)"); + CHECK(strstr((const char *)&con_uart_tx[5], "[B] 22222") != 0, + "TC-CONLOG-032 行边界没被打断(没有任何一行被插进另一行中间)"); + + printf("-- TC-CONLOG-033..037 跨环尾:两段拷贝 + 按连续段分块 --\n"); + reset_all(); + attach_writer(); + s_con_head = 4090U; + s_con_tail = 4090U; + (void)dbg_console_write_line("0123456789ABCDEF", 16U); + CHECK(s_con_head == 10U && used_now() == 16U, + "TC-CONLOG-033 跨环尾入环后 head 回绕到 10(4090+16-4096)"); + CHECK(memcmp(&s_con_ring[4090], "012345", 6) == 0 + && s_con_ring[0] == (uint8_t)'6' && s_con_ring[9] == (uint8_t)'F' + && s_con_ring[10] == 0U, + "TC-CONLOG-034 回绕两段拷贝位置正确(前 6B 填到环尾、余 10B 从 0 起)"); + writer_drain(); + CHECK(con_uart_block_n == 2U && con_uart_block_len[0] == 6U + && con_uart_block_len[1] == 10U, + "TC-CONLOG-035 drain 按连续段分块:6 + 10(绝不把环中间当连续段读越界)"); + CHECK(tx_is(0U, "0123456789ABCDEF"), "TC-CONLOG-036 回绕行的上线内容与顺序都对"); + + reset_all(); + attach_writer(); + /* 源缓冲必须给到 3000B:con_append 只钳长度,不会钳读取范围, + * 拿 192B 的栈数组当 2048B 源就是越界读(插桩跑直接 0xC0000005) */ + memset(oversize, 'D', sizeof(oversize)); + CHECK(con_append(oversize, (uint16_t)3000U) == 0, "TC-CONLOG-037 直调 con_append 超长仍成功"); + CHECK(used_now() == (DBG_CON_RING_SIZE / 2U), + "TC-CONLOG-038 入环长度钳在 RING/2=2048(保证任何一行都恒可入环)"); +} + +/* ============ TC-CONLOG-040..042 写者自记录 / 重入 / 未注册 drain ============ */ +static void tc_writer_self(void) +{ + char line[DBG_CON_LINE_MAX]; + uint32_t i; + + printf("-- TC-CONLOG-040..042 写者自记录先排空 / drain 重入保护 --\n"); + reset_all(); + attach_writer(); + memset(line, 'E', sizeof(line)); + for (i = 0U; i < 21U; i++) { /* 21*192 = 4032,只剩 63B 可用 */ + (void)dbg_console_write_line(line, sizeof(line)); + } + con_task_self = (TaskHandle_t)&s_task_writer; /* 写者自己记一行 */ + CHECK(dbg_console_write_line("F", 1U) == 1, "TC-CONLOG-040 写者自记录不丢行"); + CHECK(con_delay_calls == 0U, + "TC-CONLOG-041 写者自记录先排空再入环:绝不进等待(否则就是等自己 -> 死锁)"); + CHECK(con_uart_tx_n == 4032U && used_now() == 1U && dbg_console_dropped() == 0U, + "TC-CONLOG-042 老行全部上线、新行入环,零丢弃"); + + reset_all(); + attach_writer(); + (void)dbg_console_write_line("hold", 4U); + s_con_draining = 1U; /* 模拟正处于 drain 之内 */ + writer_drain(); + CHECK(con_uart_tx_n == 0U && used_now() == 4U, + "TC-CONLOG-043 drain 重入保护:嵌套调用直接让位(写者自记录不触发递归 drain)"); + + reset_all(); + (void)dbg_console_write_line("early\r\n", 7U); /* 未注册 -> 已直发 */ + con_uart_tx_n = 0U; + writer_drain(); + CHECK(con_uart_tx_n == 0U, "TC-CONLOG-044 写者未注册时 drain 不做任何事"); +} + +/* ============ TC-CONLOG-050..062 缓冲满 / 丢弃 / 可观测 ============ */ +static void tc_full_drop(void) +{ + printf("-- TC-CONLOG-050..053 缓冲满:DWT 硬上界丢弃且不覆盖旧行 --\n"); + reset_all(); + attach_writer(); + fill_ring(10U); /* 只剩 10B 可用 */ + con_dwt_step = SystemCoreClock / 4U; /* 一次判据 = 250ms > 200ms 上界 */ + CHECK(dbg_console_write_line("0123456789ABCDEFGHIJ", 20U) == -1, + "TC-CONLOG-050 环满 + DWT 上界到 -> 丢这一行(返回 -1)"); + CHECK(dbg_console_dropped() == 1U, "TC-CONLOG-051 丢弃计数 +1"); + CHECK(con_delay_calls == 0U, + "TC-CONLOG-052 有 DWT 时用周期判据,不必先烧 200 个 tick(K4)"); + CHECK(s_con_head == 4085U && used_now() == 4085U && con_uart_tx_n == 0U, + "TC-CONLOG-053 丢弃不推进 head、也不生产者自己发(没半写、更没覆盖未发出的旧行)"); + + printf("-- TC-CONLOG-054..056 等待中出现空间 -> 入环成功 --\n"); + reset_all(); + attach_writer(); + fill_ring(10U); + con_dwt_step = 0U; /* 时间静止:只能靠写者腾地方 */ + con_delay_hook = bg_drain_hook; /* 每等一轮 = 写者排空一次 */ + CHECK(dbg_console_write_line("0123456789ABCDEFGHIJ", 20U) == 20, + "TC-CONLOG-054 等一轮后腾出空间 -> 整行入环成功"); + CHECK(con_delay_calls == 1U && dbg_console_dropped() == 0U, + "TC-CONLOG-055 只等了一轮,且不算丢弃"); + con_delay_hook = 0; + CHECK(con_uart_tx_n == 4085U, "TC-CONLOG-056 腾地方的正是写者(老行已上线)"); + + printf("-- TC-CONLOG-057..059 无 DWT 支路:等待上界 = DBG_CON_WAIT_MS 个 tick --\n"); + reset_all(); + con_dwt_ctrl_sticky = 0; /* 调试单元被关 -> DWT 使能失败 */ + attach_writer(); + fill_ring(10U); + CHECK(s_con_dwt_up == 0U, "TC-CONLOG-057 DWT 使能失败时 s_con_dwt_up 保持 0"); + CHECK(dbg_console_write_line("0123456789ABCDEFGHIJ", 20U) == -1, + "TC-CONLOG-058 无 DWT 时仍会丢(判据退回 tick 计数,不会无限等)"); + CHECK(con_delay_calls == DBG_CON_WAIT_MS && dbg_console_dropped() == 1U, + "TC-CONLOG-059 无 DWT 的等待上界恰为 DBG_CON_WAIT_MS(200) 次 vTaskDelay"); + + printf("-- TC-CONLOG-060..062 丢弃可观测:[ERR] 行直发不经环 --\n"); + reset_all(); + attach_writer(); + fill_ring(10U); + con_dwt_step = SystemCoreClock / 4U; + (void)dbg_console_write_line("0123456789ABCDEFGHIJ", 20U); /* 丢 1 行 */ + con_dwt_step = 0U; + writer_drain(); + CHECK(tx_contains("[ERR] CONSOLE dropped 1 lines"), + "TC-CONLOG-060 排空末尾刷出 [ERR] CONSOLE dropped 1 lines(丢行永不静默,@test 判 FAIL)"); + CHECK(dbg_console_dropped() == 0U, "TC-CONLOG-061 刷完即清零(下一轮不重复告警)"); + CHECK(used_now() == 0U, + "TC-CONLOG-062 告警行走裸发:不入环 -> 不会 con_append->drain 递归"); +} +/* ============ TC-CONLOG-070..081 TX 僵尸通道:自愈 / 冷却 / 半块 ============ */ + +/* 公共前置:注册写者 + 把环填满 + 让通道卡死 */ +static void fault_setup(uint32_t tick0, int repair) +{ + attach_writer(); + fill_ring(0U); + con_tick_now = tick0; + con_uart_txe_stuck = 1; + con_dwt_step = SystemCoreClock / 4U; /* 一次判据就跨过 CON_TX_TIMEOUT_CYC */ + s_mx_repair = repair; + writer_drain(); +} + +static void tc_tx_fault(void) +{ + printf("-- TC-CONLOG-070..074 软复位没治好:故障即止 + 留痕只能靠标志 --\n"); + reset_all(); + fault_setup(5000U, 0); + CHECK(dbg_console_tx_fault() == 1U, "TC-CONLOG-070 发送超时 -> tx_fault 计数 +1"); + CHECK(s_mx_init_calls == 1U && con_nvic_clears == 1U + && con_rcc_disables == 1U && con_rcc_enables == 1U, + "TC-CONLOG-071 自愈完整一遍:清 pending -> 断时钟 -> 供时钟 -> 重初始化"); + CHECK(used_now() == (DBG_CON_RING_SIZE - 1U) - DBG_CON_CHUNK_MAX, + "TC-CONLOG-072 故障即止:只丢当前 256B 块,其余留环里等下轮(让位喂狗)"); + CHECK(con_uart_tx_n == 0U, + "TC-CONLOG-073 通道没治好时连那条 WARN 也上不了线(裸发同样有界、不重试)"); + CHECK(dbg_console_tx_fault_log_take() == 1U && dbg_console_tx_fault_log_take() == 0U, + "TC-CONLOG-074 留痕标志读后清:写者据此在 drain 之外补记 run.log(断调用图成环)"); + + printf("-- TC-CONLOG-075..077 软复位治好了:WARN 上线且只一次 --\n"); + reset_all(); + con_tick_now = 7000U; + attach_writer(); + (void)dbg_console_write_line("X", 1U); + con_uart_txe_ready_left = 0; /* 一装载就卡死 */ + con_dwt_step = SystemCoreClock / 4U; + s_mx_repair = 1; /* 重初始化把通道治好 */ + writer_drain(); + CHECK(tx_contains("[WARN] CONSOLE tx fault"), "TC-CONLOG-075 首次故障的 WARN 行成功上线"); + CHECK(used_now() == 0U && s_con_cool_until == 7000U && dbg_console_tx_fault() == 1U, + "TC-CONLOG-076 故障块丢弃(行已出环不可重放)+ 冷却截止记为当时 tick"); + con_uart_txe_ready_left = -1; + con_dwt_step = 0U; + writer_drain(); /* tick 未推进 -> 仍在冷却 */ + CHECK(s_con_cool_until == 7000U && s_mx_init_calls == 1U, + "TC-CONLOG-077 冷却期内 drain 直接让位:不重试、不自愈第二次"); +} + +static void tc_tx_cool(void) +{ + printf("-- TC-CONLOG-078..084 300ms 冷却两端 + 回绕 + tick==0 现状 --\n"); + reset_all(); + fault_setup(7000U, 1); /* 治好的那次故障 */ + CHECK(s_con_cool_until == 7000U && used_now() == 3839U, + "TC-CONLOG-078 故障后剩 3839B 在环里(本块被丢)"); + con_dwt_step = 0U; + con_uart_txe_stuck = 0; + con_tick_now = 7299U; /* 差 1ms */ + writer_drain(); + CHECK(s_con_cool_until == 7000U && used_now() == 3839U && s_mx_init_calls == 1U, + "TC-CONLOG-079 差 1ms 仍在冷却:环不动、不重复自愈(冷却即节奏保护)"); + con_tick_now = 7300U; /* 到点 */ + writer_drain(); + CHECK(s_con_cool_until == 0U && used_now() == 0U && dbg_console_tx_fault() == 1U, + "TC-CONLOG-080 到点即清冷却、放行本轮排空,且没有新增故障"); + + reset_all(); + fault_setup(0U, 0); + CHECK(s_con_cool_until == 0U && dbg_console_tx_fault() == 1U, + "TC-CONLOG-081 现状:tick==0 时记出的 cool_until 撞上 0 哨兵 = 等于没冷却"); + writer_drain(); + CHECK(dbg_console_tx_fault() == 2U + && used_now() == (DBG_CON_RING_SIZE - 1U) - 2U * DBG_CON_CHUNK_MAX, + "TC-CONLOG-082 上一条的后果:下一轮立刻再耗一次块超时(节奏保护在这一 tick 失效)"); + + reset_all(); + attach_writer(); + fill_ring(0U); + s_con_cool_until = 0xFFFFFFFFU; /* 上轮故障记在回绕点 */ + con_tick_now = 100U; /* 现在刚回绕到 100 */ + writer_drain(); + CHECK(s_con_cool_until == 0xFFFFFFFFU && used_now() == 4095U, + "TC-CONLOG-083 跨 49.7 天回绕仍判在冷却内(无符号差值 101 < 300)"); + con_tick_now = 401U; /* 无符号差值 402 >= 300 */ + writer_drain(); + CHECK(s_con_cool_until == 0U && used_now() == 0U, + "TC-CONLOG-084 回绕后到点 -> 清冷却并把 4095B 全排空"); +} + +static void tc_tx_half(void) +{ + printf("-- TC-CONLOG-085..088 超时判据的真实后果:半块 / 已上线仍判失败 --\n"); + reset_all(); + attach_writer(); + (void)dbg_console_write_line("ABCDEFGH", 8U); + con_uart_tc_stuck = 1; /* 字节都进了 DR,只有 TC 不置位 */ + con_dwt_step = SystemCoreClock / 4U; + writer_drain(); + CHECK(tx_is(0U, "ABCDEFGH"), "TC-CONLOG-085 TC 卡死:8 字节都已上线"); + CHECK(dbg_console_tx_fault() == 1U && con_uart_block_n == 0U, + "TC-CONLOG-086 已上线仍整块判失败(docs §13-O2:丢弃/故障两本账没分开的现状)"); + + reset_all(); + attach_writer(); + (void)dbg_console_write_line("ABCDEFGH", 8U); + con_uart_txe_ready_left = 3; /* 只挤得出 3 个字节 */ + con_dwt_step = SystemCoreClock / 4U; + writer_drain(); + CHECK(tx_is(0U, "ABC"), "TC-CONLOG-087 TXE 中途卡死 -> 只有已写进 DR 的 3 字节上线"); + CHECK(dbg_console_tx_fault() == 1U && used_now() == 0U, + "TC-CONLOG-088 半块同样走自愈分支,整块出环(尾部 5 字节随块作废)"); +} + +/* ============ TC-CONLOG-090..099 DWT 使能 / 指令计数上界 ============ */ +static void tc_dwt(void) +{ + printf("-- TC-CONLOG-090..093 DWT 惰性使能与就绪即走 --\n"); + reset_all(); + (void)dbg_console_write_line("ok\r\n", 4U); /* 走一次直发 -> 顺带使能 DWT */ + CHECK(s_con_dwt_up == 1U, "TC-CONLOG-090 con_dwt_ensure 成功使能后置 s_con_dwt_up"); + CHECK((g_con_core_debug.DEMCR & CoreDebug_DEMCR_TRCENA_Msk) != 0U, + "TC-CONLOG-091 真的写了 DEMCR.TRCENA(位号与 core_cm4.h 一致)"); + CHECK((g_con_dwt.CTRL & DWT_CTRL_CYCCNTENA_Msk) != 0U, + "TC-CONLOG-092 真的写了 DWT.CTRL.CYCCNTENA"); + CHECK(tx_is(0U, "ok\r\n") && con_dwt_reads == 4U, + "TC-CONLOG-093 通道就绪时发送循环没再读 CYCCNT(3 次 ensure + 1 次取基准 t0)"); + + printf("-- TC-CONLOG-094..097 DWT 使能失败 -> con_tx_counted 计数上界 --\n"); + reset_all(); + con_dwt_ctrl_sticky = 0; /* CYCCNTENA 写不进去 */ + con_uart_txe_stuck = 1; + dbg_console_write_raw("Q", 1U); + CHECK(s_con_dwt_up == 0U, "TC-CONLOG-094 DWT 使能失败 -> 走计数上界支路"); + CHECK(con_uart_txe_reads == CON_TX_GUARD_MAX, + "TC-CONLOG-095 每字节最多自旋 CON_TX_GUARD_MAX(20000) 次即放弃(有界,非死等)"); + CHECK(con_uart_tx_n == 0U, "TC-CONLOG-096 计数上界内没发出去就整块放弃"); + CHECK(con_dwt_reads == 3U, + "TC-CONLOG-097 计数支路一次都不读 CYCCNT(不依赖 DWT,也就不依赖时间源)"); + + printf("-- TC-CONLOG-098..099 超时判据不依赖 HAL_GetTick(round 8b 的动机) --\n"); + reset_all(); + con_uart_txe_stuck = 1; /* tick 冻结在 0,模拟 TIM7 停摆 */ + con_dwt_step = SystemCoreClock / 10U; /* 一次判据正好 = 100ms 上界 */ + dbg_console_write_raw("Q", 1U); + CHECK(con_tick_reads == 0U && con_uart_tx_n == 0U, + "TC-CONLOG-098 tick 完全不走也能退出:裸发一次都没读 HAL_GetTick"); + reset_all(); + con_uart_txe_stuck = 1; + con_dwt_step = SystemCoreClock / 4U; + dbg_console_write_raw("Z", 1U); + CHECK(con_uart_txe_reads == 1U, + "TC-CONLOG-099 DWT 支路第一次自旋就判超时(上界真的起作用,不会拖满 20000 次)"); +} + +/* ============ TC-CONLOG-100..104 huart1.Instance == NULL 短路 ============ */ +static void tc_null_uart(void) +{ + printf("-- TC-CONLOG-100..104 USART1 未初始化:安全让位,不再触发二次故障 --\n"); + reset_all(); + huart1.Instance = 0; + dbg_console_write_raw("boom", 5U); + CHECK(con_uart_tx_n == 0U && con_uart_txe_reads == 0U, + "TC-CONLOG-100 Instance==NULL 时一次寄存器都不碰(不解引用地址 0)"); + CHECK(con_dwt_reads == 3U, + "TC-CONLOG-101 仍然先惰性使能 DWT 再短路(ensure 在判空之前,顺序照旧)"); + + reset_all(); + huart1.Instance = 0; + CHECK(dbg_console_write_line("early", 5U) == 5, + "TC-CONLOG-102 未注册路径(内核前)撞上无串口也不崩、仍返回行长"); + + reset_all(); + attach_writer(); + (void)dbg_console_write_line("abc", 3U); + huart1.Instance = 0; /* 写者起来了但串口被毁 */ + s_mx_repair = 1; + writer_drain(); + CHECK(dbg_console_tx_fault() == 1U && s_mx_init_calls == 1U && huart1.Instance != 0, + "TC-CONLOG-103 未初始化也被当作僵尸通道自愈:重初始化把句柄装回来"); + con_dwt_step = SystemCoreClock / 4U; + writer_drain(); /* 仍在冷却 -> 让位 */ + CHECK(used_now() == 0U, "TC-CONLOG-104 自愈那一轮已把出环字节丢掉,没留下脏数据"); +} + +/* ============ TC-CONLOG-110..115 fputc 重定向 ============ */ +static void tc_fputc(void) +{ + printf("-- TC-CONLOG-110..115 fputc 重定向(printf 遗留路径) --\n"); + reset_all(); + CHECK(con_fputc_under_test('Q', stdout) == 'Q', "TC-CONLOG-110 fputc 原样返回字符(C 库约定)"); + CHECK(tx_is(0U, "Q") && used_now() == 0U, + "TC-CONLOG-111 未注册写者:printf 的字节直发(与旧行为一致)"); + + reset_all(); + attach_writer(); + (void)con_fputc_under_test('R', stdout); + CHECK(con_uart_tx_n == 0U && used_now() == 1U && s_con_ring[0] == (uint8_t)'R', + "TC-CONLOG-112 注册后 fputc 也收敛到单写者路径(逐字节入环)"); + writer_drain(); + CHECK(tx_is(0U, "R"), "TC-CONLOG-113 写者把 printf 字节发出"); + + reset_all(); + attach_writer(); + con_ipsr = 12U; /* 假异常号:任意非 0 */ + CHECK(dbg_console_write_line("ISR", 3U) == 3, "TC-CONLOG-114 中断上下文写整行返回行长"); + CHECK(tx_is(0U, "ISR") && used_now() == 0U && con_delay_calls == 0U, + "TC-CONLOG-115 中断上下文一律裸发:不入环、不等待(否则在 ISR 里睡死)"); + + reset_all(); + attach_writer(); + fill_ring(0U); /* 环全满 */ + con_ipsr = 5U; + con_dwt_step = SystemCoreClock / 4U; + CHECK(dbg_console_write_line("0123456789", 10U) == 10, + "TC-CONLOG-116 环全满 + 中断上下文:仍直发成功(ISR 绝不进丢弃等待)"); + CHECK(dbg_console_dropped() == 0U && con_delay_calls == 0U, + "TC-CONLOG-117 这条路径既不丢行也不记丢弃(K1:内核前/ISR 不依赖任何内核对象)"); +} + +/* ============ TC-CONLOG-120..122 drain 250ms 预算 ============ */ +static void tc_budget(void) +{ + printf("-- TC-CONLOG-120..122 单次 drain 预算 250ms(让位喂狗轮询) --\n"); + reset_all(); + attach_writer(); + fill_ring(0U); /* 4095B = 16 块 */ + con_tick_step = 130U; /* 每次读 tick 走 130ms */ + writer_drain(); + CHECK(con_uart_block_n == 2U, "TC-CONLOG-120 一轮只发 2 块就因预算让位(不是发完)"); + CHECK(used_now() == (DBG_CON_RING_SIZE - 1U) - (2U * DBG_CON_CHUNK_MAX), + "TC-CONLOG-121 让位时余下 3583B 留在环里(下轮续排,不丢)"); + con_tick_step = 0U; + writer_drain(); /* 预算内发完余下 3583B */ + CHECK(used_now() == 0U && con_uart_tx_n == (DBG_CON_RING_SIZE - 1U), + "TC-CONLOG-122 下轮把余下的 3583B 也发完:预算让位不丢字节(累计 4095)"); +} + +/* ============ TC-CONLOG-130..135 计数支路的成功路径 / TC 抖动 / 直调 NULL ============ */ +static void tc_counted_paths(void) +{ + printf("-- TC-CONLOG-130..132 DWT 不可用 -> con_tx_counted 也能发全(不只会超时) --\n"); + reset_all(); + con_dwt_ctrl_sticky = 0; /* CYCCNTENA 落不住 */ + con_tick_now = 9000U; /* tick 冻结:证明与本支路无关 */ + dbg_console_write_raw("AB", 2U); + CHECK(s_con_dwt_up == 0U && tx_is(0U, "AB"), + "TC-CONLOG-130 计数支路把整块发完并返回成功(回退路径没退化)"); + CHECK(con_uart_tc_reads == 1U && con_dwt_reads == 3U && con_tick_reads == 0U, + "TC-CONLOG-131 计数支路既不等 tick 也不再读 CYCCNT(零外部时间源依赖)"); + + reset_all(); + con_dwt_ctrl_sticky = 0; + con_uart_tc_stuck = 1; /* 字节能进 DR,TC 永不置位 */ + dbg_console_write_raw("CD", 2U); + CHECK(tx_is(0U, "CD") && con_uart_tc_reads == CON_TX_GUARD_MAX, + "TC-CONLOG-132 TC 卡死时计数支路同样最多自旋 CON_TX_GUARD_MAX 次即放弃"); + + printf("-- TC-CONLOG-133..135 DWT 支路 TC 抖动 / con_append 直调参数校验 --\n"); + reset_all(); + con_dwt_step = 1U; /* 时间走得极慢:不会误判超时 */ + con_uart_tc_after = 2; /* 前两轮 TC 不就绪 */ + dbg_console_write_raw("EF", 2U); + CHECK(tx_is(0U, "EF") && con_uart_tc_reads == 3U, + "TC-CONLOG-133 TC 抖动(未超上界)后仍算整块成功:TC 循环能正常退出"); + reset_all(); + CHECK(con_append(0, 3U) == -1, "TC-CONLOG-134 con_append(NULL) 自己也要挡住(不只靠上层)"); + CHECK(con_append("abc", 0U) == -1, "TC-CONLOG-135 con_append(len=0) 同样拒绝"); +} + +/* ===================== main ===================== */ +int main(void) +{ + /* 先验一次宿主 CRT 与本模块 fputc 的关系(被 tc_params 记成 TC-CONLOG-000) */ + tc_params(); + tc_unattached(); + tc_ring(); + tc_writer_self(); + tc_full_drop(); + tc_tx_fault(); + tc_tx_cool(); + tc_tx_half(); + tc_dwt(); + tc_null_uart(); + tc_fputc(); + tc_budget(); + tc_counted_paths(); + + printf("=== Console Log Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + fflush(stdout); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/console_log/run_unit_test.py b/test/host/console_log/run_unit_test.py new file mode 100644 index 00000000..f919e9a1 --- /dev/null +++ b/test/host/console_log/run_unit_test.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""控制台单写者日志(console_log)主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/console_log/console_log_test.c(以 #include 纳入生产源 + App/log/console_log.c),在 PC 上离线验证单写者环形缓冲的全套契约:192B 行截断、 + 256B 分块、跨环尾两段拷贝、写者未注册/ISR 上下文的无锁直发、缓冲满两条上界 + (DWT 周期 / 200 tick)与丢弃计数、[ERR] 告警行不经环(不递归)、TX 僵尸通道软复位 + 自愈 + 故障即止 + 300ms 冷却(含无符号回绕)、DWT 使能失败退回 20000 次指令计数、 + huart1.Instance==NULL 短路、fputc 重定向、drain 250ms 预算。 + 工具链与判定委托 test/host/common/unit_test_harness.py。 + +为什么要主机侧单测(而不是只用台架): + 本模块的坑全是"异常时刻 + 节奏":ORE 僵尸通道在软复位后毫秒级重锁、TIM7 停摆让 + HAL 的 tick 超时永不成立(round 8a 台架:唯一喂狗者死等 TXE -> IWDG 误复位)、 + 背靠背块超时把主循环拉到 450~500ms 饿死喂狗(台架实测的停喂误复位)。这些在台架上 + 要么一年一遇、要么现场已被复位冲掉;把 100/250/300ms 三条时间线做成显式量之后, + 299ms 与 300ms 的差别就是一行断言。详见 + docs/子系统设计/控制台单写者日志设计说明.md(G1~G5 / K1~K5 / §12 ORE 僵尸通道)。 + +桩的边界:console_log.h 与 sys_events.h 用真实头(SYS_EVT_CONSOLE_TX 位定义一改即 + 编译期报错);usart.h 是桩但只暴露本模块访问的 huart1 + MX_USART1_UART_Init, + USART SR 位号、DWT/CoreDebug 位掩码、HAL_StatusTypeDef 枚举值均照抄真实头, + 宿主编不动的那条 main.h -> stm32f4xx_hal.h -> core_cm4.h 链由 stub/stm32f4_fake.h + 以普通结构体替代(理由写在该文件头注释里)。 + ⚠ 测试单元刻意不定义任何 syslog_* / DBG_* 符号:生产源哪天恢复"在 drain 里调日志 + 子系统",本套件会以链接失败告警(那是设计上必须挡住的成环)。 + +用法(仓库根目录): + python test/host/console_log/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:docs/子系统设计/控制台单写者日志设计说明.md、docs/测试说明/主机单测覆盖率口径.md §9「P2 落地」清单第 3 条 +创建日期:2026-10-24 +""" +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 必须最先(覆盖 usart.h / FreeRTOS.h / task.h / cmsis_os.h / cmsis_os2.h), +# 其余是真实头目录(console_log.h 在 App/log,sys_events.h 在 App/app)。 +BUILD = TestBuild( + name="Console Log Tests", + src=os.path.join(HERE, "console_log_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "App", "log"), + os.path.join(ROOT, "App", "app"), + HERE, + ], + summary_marker="=== Console Log Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/console_log/stub/FreeRTOS.h b/test/host/console_log/stub/FreeRTOS.h new file mode 100644 index 00000000..e5b70e82 --- /dev/null +++ b/test/host/console_log/stub/FreeRTOS.h @@ -0,0 +1,16 @@ +/* + * 模块名称:主机侧单测桩 - FreeRTOS.h(console_log 模块专用) + * 模块功能:TaskHandle_t / TickType_t 与临界区两个宏(真实版来自 FreeRTOS.h + + * portmacro.c 的 vPortEnterCritical/vPortExitCritical)。宿主单线程 -> + * 临界区退化成计数,但**配对与层数照旧可断言**:本模块三处临界区 + * (整行入环、推进 tail、drain 每块一次)的原子性语义靠计数核对。 + * ⚠ 仅供 test/host/console_log/ 使用,不参与 MCU 构建。 + * 创建日期:2026-10-24 + */ +#ifndef FREERTOS_H +#define FREERTOS_H + +#include +#include "stm32f4_fake.h" + +#endif /* FREERTOS_H */ diff --git a/test/host/console_log/stub/cmsis_os.h b/test/host/console_log/stub/cmsis_os.h new file mode 100644 index 00000000..b9fb8cad --- /dev/null +++ b/test/host/console_log/stub/cmsis_os.h @@ -0,0 +1,13 @@ +/* + * 模块名称:主机侧单测桩 - cmsis_os.h(console_log 模块专用) + * 模块功能:空壳(真实那份是 CMSIS-RTOS v1 兼容层)。console_log.c include 它但 + * 一个 v1 API 都不用;事件组走的是 cmsis_os2.h 的 osEventFlagsSet。 + * ⚠ 仅供 test/host/console_log/ 使用,不参与 MCU 构建。 + * 创建日期:2026-10-24 + */ +#ifndef CMSIS_OS_H +#define CMSIS_OS_H + +#include + +#endif /* CMSIS_OS_H */ diff --git a/test/host/console_log/stub/cmsis_os2.h b/test/host/console_log/stub/cmsis_os2.h new file mode 100644 index 00000000..8ac1cf80 --- /dev/null +++ b/test/host/console_log/stub/cmsis_os2.h @@ -0,0 +1,18 @@ +/* + * 模块名称:主机侧单测桩 - cmsis_os2.h(console_log 模块专用) + * 模块功能:给真实 App/app/sys_events.h 一个能在宿主上编的最小内核类型面: + * osEventFlagsId_t / osStatus_t / 事件位选项宏。sys_events.h 只需要 + * osEventFlagsId_t 来声明 sys_evtHandle,而 SYS_EVT_* 位定义全是真实值 + * —— 测试断言"入环后必须置 SYS_EVT_CONSOLE_TX(bit6)"用的就是真实宏。 + * osEventFlagsSet 原型与真实头逐字一致,实体在 stm32f4_fake.h 声明、 + * 由测试单元实现(计数 + 记录最后一次掩码)。 + * ⚠ 仅供 test/host/console_log/ 使用,不参与 MCU 构建(真实那份拉 FreeRTOS 内核头)。 + * 创建日期:2026-10-24 + */ +#ifndef CMSIS_OS2_H +#define CMSIS_OS2_H + +#include +#include "stm32f4_fake.h" + +#endif /* CMSIS_OS2_H */ diff --git a/test/host/console_log/stub/stm32f4_fake.h b/test/host/console_log/stub/stm32f4_fake.h new file mode 100644 index 00000000..88b79bd8 --- /dev/null +++ b/test/host/console_log/stub/stm32f4_fake.h @@ -0,0 +1,274 @@ +/* + * 模块名称:主机侧单测桩 - 假 HAL/CMSIS 寄存器面 + 内核入口(console_log 模块专用) + * 模块功能:让 App/log/console_log.c 在 PC 上编起来,并把它依赖的硬件/内核面做成 + * **可脚本控制**的模型: + * ① USART1:TXE/TC 可注入「就绪 / 永远不就绪(僵尸通道)/ 前 N 次不就绪」; + * 写进 DR 的字节被收进一条上线字节日志,并按「一次 TC 就绪 = 一块」记块长。 + * ② DWT CYCCNT:每次访问推进 con_dwt_step 个周期(= 给时间定价), + * CTRL 的 CYCCNTENA 可脚本成「写不进去」以走 con_tx_counted 支路。 + * ③ HAL_GetTick:显式量 + 每次读自动步进。 + * ④ 内核入口:临界区计数、当前任务句柄、vTaskDelay(可挂回调 + 跑飞保险)、 + * __get_IPSR、osEventFlagsSet、NVIC/RCC 宏。 + * ⚠ 仅供 test/host/console_log/ 使用,不参与 MCU 构建(MCU 走真实 stm32f4xx*.h)。 + * + * 为什么必须桩(换机复核依据):真实链是 usart.h -> main.h -> stm32f4xx_hal.h -> + * stm32f4xx.h -> core_cm4.h,里面是 ARMCC 内联汇编与 __IO 寄存器绝对地址 + * (0xE0001000 一类),宿主 MSVC 既编不动也访问不了。本桩把同样的位语义搬到普通 + * 结构体上,字段名 / 位号 / 枚举值一律照真实头抄,生产代码写法一字不改即可编译: + * USART_TypeDef.SR/DR、USART_SR_TXE=bit7、USART_SR_TC=bit6(stm32f4xx_hal_uart.h) + * DWT_Type.CTRL/CYCCNT + DWT_CTRL_CYCCNTENA_Msk=bit0、CoreDebug_Type.DEMCR + + * CoreDebug_DEMCR_TRCENA_Msk=bit24(core_cm4.h) + * HAL_StatusTypeDef{OK=0,ERROR=1,BUSY=2,TIMEOUT=3}(stm32f4xx_hal_def.h) + * HAL_UART_STATE_RESET=0x00、HAL_UART_ERROR_NONE=0x00、USART1_IRQn=7 + * UART_HandleTypeDef 只搬本模块访问的 4 个字段(Instance/gState/RxState/ErrorCode), + * 余下(Init/Lock/...)刻意不搬:抄半份 HAL 结构只会更快与真实头漂移。 + * + * ★ 两个手法(本桩唯一的技巧点,改之前先读懂): + * a) 「读寄存器位」没法用结构体成员钩(u->SR 是成员访问,宏替换不了成员名),但生产 + * 代码的判据写作 (u->SR & USART_SR_TXE),而 **USART_SR_TXE 是本桩的宏** —— 把它 + * 定义成函数调用,于是每次读该位都进 con_uart_read_txe():在这里计数、判跑飞、按 + * 脚本决定就绪与否。SR 成员恒为全 1,就绪完全由钩子返回值决定(返回掩码=就绪, + * 返回 0=不就绪,(全1 & 0)==0 让生产代码继续等)。TC 同理。 + * b) DWT 同理:#define DWT (con_dwt_touch(), &g_con_dwt) —— 每次 DWT-> 访问先走一遍 + * touch()(CYCCNT += step、计数判跑飞、按需让 CYCCNTENA 位落不住),再把地址给出去。 + * 真实硬件 CYCCNT 每周期 +1,这里「每次访问 +step」是同一个模型、只是步长交给测试。 + * 于是 CON_TX_TIMEOUT_CYC = SystemCoreClock/10 这类编译期表达式**不必改生产代码** + * 就能在 1 次自旋内成立(step 取 = 上界值即可)。 + * c) DR 的写不做钩子(写就是写进真成员)。「哪个字节真正上线了」由 a) 的就绪事件推定: + * 生产代码每写一个 DR 之前必然刚读到一次就绪的 TXE(两个发送循环都是这个结构), + * 所以「上次就绪之后写进 DR 的那个字节」= 此刻离开发送寄存器上线的字节 —— + * 与真实「TXE 空 -> 装载 -> 移位」时序一致,不是桩自己发明的规则。 + * 由此还能照出超时路径的真实后果:TXE 卡死时最后那个字节**没有**被写进 DR, + * 也就不会出现在上线日志里(半个块丢失、丢在哪个字节可查)。 + * + * 创建日期:2026-10-24 + */ +#ifndef STM32F4_FAKE_H +#define STM32F4_FAKE_H + +#include +#include +#include +#include + +/* ===== 与真实头同值的位 / 枚举(来源见文件头) ===== */ +#define USART_SR_TXE_Msk (0x0080U) /* USART1->SR bit7 发送寄存器空 */ +#define USART_SR_TC_Msk (0x0040U) /* USART1->SR bit6 发送完成 */ +#define DWT_CTRL_CYCCNTENA_Msk (0x00000001U) +#define CoreDebug_DEMCR_TRCENA_Msk (0x01000000U) +#define USART1_IRQn 7 + +#define HAL_UART_STATE_RESET 0x00U +#define HAL_UART_STATE_READY 0x20U +#define HAL_UART_ERROR_NONE 0x00U + +typedef enum { + HAL_OK = 0x00U, + HAL_ERROR = 0x01U, + HAL_BUSY = 0x02U, + HAL_TIMEOUT = 0x03U +} HAL_StatusTypeDef; + +/* 主机没有真周期钟。CON_TX_TIMEOUT_CYC = SystemCoreClock/10 与 + * CON_APPEND_WAIT_CYC = SystemCoreClock/5 都是编译期表达式,取值原样保留 + * 168 MHz(与 MCU 端 SystemCoreClock 一致),时间快慢由 con_dwt_step 决定。 */ +extern uint32_t SystemCoreClock; + +/* ------------------------------------------------------------------ */ +/* ① 假 USART1 */ +/* ------------------------------------------------------------------ */ +typedef struct { + volatile uint32_t SR; + volatile uint32_t DR; +} USART_TypeDef; + +typedef struct { + USART_TypeDef *Instance; + volatile uint32_t gState; + volatile uint32_t RxState; + volatile uint32_t ErrorCode; +} UART_HandleTypeDef; + +extern USART_TypeDef g_con_uart; /* 寄存器实体(单 TU,非 static) */ + +#define CON_UART_TX_MAX 20000U /* 上线字节日志容量 */ +#define CON_UART_BLOCK_MAX 64U /* 块长记录容量 */ + +extern uint32_t con_uart_tx_n; /* 已上线字节数 */ +extern uint8_t con_uart_tx[CON_UART_TX_MAX]; +extern uint32_t con_uart_block_n; /* 已完成的块数 */ +extern uint32_t con_uart_block_len[CON_UART_BLOCK_MAX]; +extern uint32_t con_uart_cur_len; /* 当前块已上线字节数 */ +extern uint32_t con_uart_txe_reads; +extern uint32_t con_uart_tc_reads; +extern int con_uart_txe_stuck; /* 1 = TXE 永不就绪(僵尸通道) */ +extern int con_uart_txe_after; /* 前 N 次 TXE 读**不**就绪,之后就绪 */ +extern int con_uart_txe_ready_left; /* >=0 = 只准就绪 N 次,之后卡死 */ +extern int con_uart_tc_after; /* 前 N 次 TC 读不就绪,之后就绪 */ +extern int con_uart_tc_stuck; /* 1 = TC 永不就绪 */ +extern int con_uart_pending; /* 1 = 有个字节已进 DR 待收 */ + +void con_uart_reset(void); + +static void con_uart_tx_put(uint8_t b) { + if (con_uart_tx_n + 1U >= CON_UART_TX_MAX) { + printf("[FATAL] fake USART: tx log overflow (>=%u)\n", CON_UART_TX_MAX); + exit(2); + } + con_uart_tx[con_uart_tx_n++] = b; + /* 每次都在尾部补一个 NUL:让 strstr 一类字符串函数不可能读过缓冲末尾 */ + con_uart_tx[con_uart_tx_n < CON_UART_TX_MAX ? con_uart_tx_n : (CON_UART_TX_MAX - 1U)] = 0U; + con_uart_cur_len++; +} + +/* 收走「上次就绪之后写进 DR 的字节」:它此刻才真正上线 */ +static void con_uart_take_byte(void) { + if (con_uart_pending == 0) { + return; + } + con_uart_pending = 0; + con_uart_tx_put((uint8_t)(g_con_uart.DR & 0xFFU)); +} + +static uint32_t con_uart_read_txe(void) { + con_uart_take_byte(); + con_uart_txe_reads++; + if (con_uart_txe_reads > 200000U) { + printf("[FATAL] fake USART: TXE read %lu times (runaway)\n", + (unsigned long)con_uart_txe_reads); + exit(2); + } + if ((con_uart_txe_stuck != 0) || (con_uart_txe_after > 0)) { + if (con_uart_txe_after > 0) { + con_uart_txe_after--; + } + return 0U; + } + if (con_uart_txe_ready_left >= 0) { + if (con_uart_txe_ready_left == 0) { + return 0U; /* 配额用尽 -> 从这一字节起通道卡死 */ + } + con_uart_txe_ready_left--; + } + con_uart_pending = 1; + return USART_SR_TXE_Msk; +} + +static uint32_t con_uart_read_tc(void) { + con_uart_take_byte(); + con_uart_tc_reads++; + if (con_uart_tc_reads > 200000U) { + printf("[FATAL] fake USART: TC read %lu times (runaway)\n", + (unsigned long)con_uart_tc_reads); + exit(2); + } + if (con_uart_tc_stuck != 0) { + return 0U; + } + if (con_uart_tc_after > 0) { /* TC 抖动:等几轮才置位 */ + con_uart_tc_after--; + return 0U; + } + if (con_uart_block_n >= CON_UART_BLOCK_MAX) { + printf("[FATAL] fake USART: block log overflow (>=%u)\n", CON_UART_BLOCK_MAX); + exit(2); + } + con_uart_block_len[con_uart_block_n++] = con_uart_cur_len; + con_uart_cur_len = 0U; + return USART_SR_TC_Msk; +} + +/* ★手法 a):判据里的掩码宏定义成函数调用 -> 每次读该位都进钩子 */ +#define USART_SR_TXE (con_uart_read_txe()) +#define USART_SR_TC (con_uart_read_tc()) + +/* ------------------------------------------------------------------ */ +/* ② 假 DWT / CoreDebug */ +/* ------------------------------------------------------------------ */ +typedef struct { + volatile uint32_t CTRL; + volatile uint32_t CYCCNT; +} DWT_Type; + +typedef struct { + volatile uint32_t DEMCR; +} CoreDebug_Type; + +extern DWT_Type g_con_dwt; +extern CoreDebug_Type g_con_core_debug; +extern uint32_t con_dwt_step; /* 每次 DWT 访问推进的周期数 */ +extern int con_dwt_ctrl_sticky; /* 0 = CYCCNTENA 写不进去 */ +extern uint32_t con_dwt_reads; + +void con_dwt_reset(void); + +/* ★手法 b):每次访问先「花时间」,再决定是否让使能位落得住 */ +static void con_dwt_touch(void) { + con_dwt_reads++; + if (con_dwt_reads > 200000U) { + printf("[FATAL] fake DWT: accessed %lu times (runaway)\n", + (unsigned long)con_dwt_reads); + exit(2); + } + g_con_dwt.CYCCNT += con_dwt_step; + if (con_dwt_ctrl_sticky == 0) { + g_con_dwt.CTRL &= ~DWT_CTRL_CYCCNTENA_Msk; + } +} + +#define DWT (con_dwt_touch(), &g_con_dwt) +#define CoreDebug (&g_con_core_debug) + +/* ------------------------------------------------------------------ */ +/* ③ HAL 时基(真实原型 uint32_t HAL_GetTick(void),stm32f4xx_hal.c) */ +/* ------------------------------------------------------------------ */ +extern uint32_t con_tick_now; +extern uint32_t con_tick_step; /* 每次读之后自动推进的 ms(0 = 不动) */ +extern uint32_t con_tick_reads; +uint32_t HAL_GetTick(void); + +/* ------------------------------------------------------------------ */ +/* ④ 内核入口 */ +/* ------------------------------------------------------------------ */ +typedef void *TaskHandle_t; +typedef uint32_t TickType_t; +typedef long BaseType_t; +typedef unsigned long UBaseType_t; + +extern TaskHandle_t con_task_self; /* xTaskGetCurrentTaskHandle 的返回值 */ +TaskHandle_t xTaskGetCurrentTaskHandle(void); + +extern uint32_t con_delay_calls; /* vTaskDelay 次数 = 真的等过 */ +extern void (*con_delay_hook)(void); /* 每次 delay 跑一遍(可模拟别的任务排空) */ +void vTaskDelay(TickType_t ticks); + +extern uint32_t con_crit_enter; +extern uint32_t con_crit_exit; +void con_crit_push(void); +void con_crit_pop(void); +#define taskENTER_CRITICAL() con_crit_push() +#define taskEXIT_CRITICAL() con_crit_pop() + +/* 真实 __get_IPSR 是 core_cmFunc.h 的静态内联;这里做成宏读一个可控量 */ +extern uint32_t con_ipsr; /* 0 = 线程模式;!=0 = 中断/异常上下文 */ +#define __get_IPSR() (con_ipsr) + +/* 事件组:原型与真实 cmsis_os2.h 逐字一致 */ +typedef void *osEventFlagsId_t; +typedef uint32_t osStatus_t; +#define osFlagsWaitAny 0x00000000u +#define osFlagsNoClear 0x00000001u + +extern uint32_t con_evt_sets; +extern uint32_t con_evt_flags; +osStatus_t osEventFlagsSet(osEventFlagsId_t ef_id, uint32_t flags); + +/* NVIC / RCC:真实版是寄存器写。自愈路径的判据 = 重初始化做了几回、次序对不对 */ +extern uint32_t con_nvic_clears; +extern uint32_t con_rcc_disables; +extern uint32_t con_rcc_enables; +#define __NVIC_ClearPendingIRQ(irqn) do { (void)(irqn); con_nvic_clears++; } while (0) +#define __HAL_RCC_USART1_CLK_DISABLE() do { con_rcc_disables++; } while (0) +#define __HAL_RCC_USART1_CLK_ENABLE() do { con_rcc_enables++; } while (0) + +#endif /* STM32F4_FAKE_H */ diff --git a/test/host/console_log/stub/task.h b/test/host/console_log/stub/task.h new file mode 100644 index 00000000..eb9c4a18 --- /dev/null +++ b/test/host/console_log/stub/task.h @@ -0,0 +1,16 @@ +/* + * 模块名称:主机侧单测桩 - task.h(console_log 模块专用) + * 模块功能:本模块用到的两个内核入口,原型与真实 task.h 一致: + * TaskHandle_t xTaskGetCurrentTaskHandle(void); + * void vTaskDelay(const TickType_t xTicksToDelay); + * vTaskDelay 在桩里**不睡**,只计数 + 跑回调(测试用回调模拟"别的任务 + * 把环形缓冲排空"),并带跑飞保险:超过 5000 次直接 exit(2)。 + * ⚠ 仅供 test/host/console_log/ 使用,不参与 MCU 构建。 + * 创建日期:2026-10-24 + */ +#ifndef TASK_H +#define TASK_H + +#include "FreeRTOS.h" + +#endif /* TASK_H */ diff --git a/test/host/console_log/stub/usart.h b/test/host/console_log/stub/usart.h new file mode 100644 index 00000000..bc22ea7c --- /dev/null +++ b/test/host/console_log/stub/usart.h @@ -0,0 +1,20 @@ +/* + * 模块名称:主机侧单测桩 - usart.h(console_log 模块专用) + * 模块功能:与真实 Inc/usart.h 对本模块可见的部分逐字一致:extern UART_HandleTypeDef + * huart1; + void MX_USART1_UART_Init(void);(真实那份还声明 huart2~5 与 + * MX_USART2~5_UART_Init,本模块只碰 USART1,搬多出来的声明没有意义)。 + * ⚠ 仅供 test/host/console_log/ 使用,不参与 MCU 构建。 + * 真实 Inc/usart.h 的 include 链(main.h -> stm32f4xx_hal.h -> stm32f4xx.h -> + * core_cm4.h)在宿主上编不动,寄存器面由 stm32f4_fake.h 提供(理由见该文件头)。 + * 创建日期:2026-10-24 + */ +#ifndef USART_H +#define USART_H + +#include +#include "stm32f4_fake.h" + +extern UART_HandleTypeDef huart1; +void MX_USART1_UART_Init(void); + +#endif /* USART_H */ diff --git a/test/host/net_socket/net_socket_test.c b/test/host/net_socket/net_socket_test.c new file mode 100644 index 00000000..79e5bf4a --- /dev/null +++ b/test/host/net_socket/net_socket_test.c @@ -0,0 +1,657 @@ +/* + * 模块名称:NET 套接字核心(net_socket)主机侧单元测试 + * 模块功能:在 PC 上离线验证 Drivers/BSP/NET/net_socket.c 的**跨上下文决策面**。 + * 被测对象:真实源文件 #include 原样纳入(同 TU 内可直接驱动它的 static)。 + * + * 分片(同一 TU 内按片组织;一个目录=一个模块名,所以只有一个 runner): +* 分片 1 纯函数与 fd 表:inet_addr/inet_ntoa(E13 段序定案)、hton 与 ntoh 四件套、 + * alloc_socket/net_get_sock/net_sock_raw 的边界、net_socket() 的"新 fd 不得继承"、 + * net_bind 的动态端口与回绕、getsockname/getpeername、setblocking/set_event_cb/ + * sock_state、net_poll 未初始化短路。 + * 分片 2 每任务错误码槽(E-03)+ 队列句柄绑定(adopt 幂等):内核未跑→[0] 槽退化、 + * 不同任务不同槽位互不覆写、槽位用尽回退 [0]、临界区成对且不嵌套泄漏。 + * 分片 3 跨上下文消息面:八个 msg.type 全分派 + 结果经**任务通知值**回传 + + * "登记挂起即不发即时回执"(do_notify=0)+ 失败时把 netTask 自己槽位的精确 + * 错误码搭通知值带回来 + 调用方在自己的槽位里恢复 errno。 +* 分片 4 状态机与闸门:监听门控 net_listener_armed 三支放行/其余挡下、net_can_read、 + * net_poll 的"全局位命中才读本 Socket SINT、永不读全局"(B3/F-NET-7)、 + * 事件计数、每轮全量刷新驱动缓存 + sock_reset 步进(I1/E-02)。 +* 分片 5 读写错误分支:send 分块上限/短写/挂起登记/末块不登记(NET_PENDING 拒收边界)、 + * recv 快路径与 EOF vs WOULDBLOCK、connect 命令被拒的回滚、accept 的 + * closed-with-readable 排空接受、close 的先结算再关芯片。 + * + * ⚠ 断言只钉**真实契约**(AGENTS 模块约定 + docs/网络/网络自愈架构设计.md §4.10 + + * 源码注释里的定案编号),不钉假件规则:凡"因为假件这么做所以断言这么做"的一律不写。 + * + * 入口:int main(void),返回 0=PASS / 1=FAIL。跑法见同目录 run_unit_test.py。 + * + * 创建日期:2026-10-24 + */ + +#include +#include + +#include "net_socket.h" +#include "net_types.h" +#include "net_pending.h" +#include "net_listener.h" +#include "net_events.h" +#include "ch395f.h" + +/* dbg_log 桩的计数定义(桩头只 extern) */ +int g_ns_info_count; +int g_ns_error_count; +int g_ns_debug_count; + +/* —— 假件实现与生产源进同一个 TU(本仓主机单测既有做法)—— */ +#include "ch395f_fake.c" +#include "ns_rtos.c" +#include "ns_peers.c" + +/* —— 被测生产源 —— */ +#include "net_socket.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 int ns_slot_owner_count(void) +{ + int i; + int n = 0; + + for (i = 0; i < (int)NET_ERRNO_TASK_SLOTS; i++) { + if (s_net_errno_slots[i].owner != NULL) { + n++; + } + } + return n; +} + +static int ns_slot_index_of(TaskHandle_t h) +{ + int i; + + for (i = 0; i < (int)NET_ERRNO_TASK_SLOTS; i++) { + if (s_net_errno_slots[i].owner == (void *)h) { + return i; + } + } + return -1; +} + +/* ===================== 场景复位 ===================== */ +/* 生产模块的 static 在场景之间会残留,每个场景开头必须显式清干净(漏一次红一片) */ +static void reset_all(void) +{ + (void)memset(s_net_socks, 0, sizeof(s_net_socks)); + (void)memset(s_net_errno_slots, 0, sizeof(s_net_errno_slots)); + (void)memset(s_local_ip, 0, sizeof(s_local_ip)); + s_dynamic_port = 49152; + s_net_generation = 0U; + s_net_initialized = 0U; + s_net_msg_queue = NULL; + g_ns_info_count = 0; + g_ns_error_count = 0; + g_ns_debug_count = 0; + ch395f_fake_reset(); + ns_rtos_reset(); + ns_peers_reset(); +} + +/* 把网络层拉到"已初始化 + 队列已绑定"的常态(绝大多数场景的前置) */ +static void bring_up(void) +{ + static int s_fake_queue; /* 句柄只是不透明标识,假内核不解引用 */ + + reset_all(); + (void)net_msg_queue_adopt((osMessageQueueId_t)&s_fake_queue); + /* 装泵:调用方阻塞等待时以 netTask 身份跑一轮消息分派(= 跨上下文那条链) */ + g_ns_pump = net_process_messages; + (void)net_init("192.168.1.100", "255.255.255.0", "192.168.1.1", NULL); + g_peers.pending_capture_calls = 0; /* bring_up 内部的登记动作不该算进场景 */ + g_ns_error_count = 0; + g_ns_info_count = 0; +} + +/* 直接造一个在用的控制块(多数场景不需要走完整的 socket()/bind()) */ +static int mk_sock(int type, net_sock_state_t state) +{ + int fd = net_socket(NET_AF_INET, type); + + if (fd < 0) { + return fd; + } + s_net_socks[fd].state = state; + return fd; +} +/* ===================== 分片:纯函数 / 错误码槽 / 消息面 / 状态机 / 读写分支 ===================== */ +/* 一个目录 = 一个模块名(check_test_coverage 的发现规则),所以只有一个 runner 与一个 + * TU;分片以文件维度拆开,是为了让 1251 行口径的最大块能逐片推进、逐片可查。 */ +#include "ns_s3_msg.c" +#include "ns_s4_state.c" +#include "ns_s5_io.c" +#include "ns_s6_edges.c" + +/* = 分片 1 纯函数面与 fd 表 ===================== */ + +/* TC-NETSOCK-100 点分十进制 ↔ 32 位:钉**当前行为**并显式标注疑点(见报告) + * 现状:net_inet_addr 把首段放在**最高字节**(0xC0A80164),而 net_getsockname 组装 + * s_addr 时把首段放在**最低字节**,net_inet_ntoa 只与后者互为逆——同一文件里 + * 存在两套字节序约定。这里逐条钉住,不替生产"圆场"。 */ +static void tc_100_inet_helpers(void) +{ + char buf[16]; + + reset_all(); + + CHECK(net_inet_addr(NULL) == 0U, "TC-NETSOCK-100a inet_addr(NULL) 返 0(无效)"); + CHECK(net_inet_addr("192.168.1.100") == 0xC0A80164U, + "TC-NETSOCK-100b inet_addr 首段落在最高字节(当前实现)"); + CHECK(net_inet_ntoa(0xC0A80164U, buf) == buf && strcmp(buf, "100.1.168.192") == 0, + "TC-NETSOCK-100c ntoa(同一 32 位值) 与 inet_addr 不互逆(疑点已报,钉当前行为)"); + CHECK(net_inet_ntoa(0x6401A8C0U, buf) == buf && strcmp(buf, "192.168.1.100") == 0, + "TC-NETSOCK-100d ntoa 与 getsockname 的 s_addr 约定互逆(E13 定案的段序:高段在前)"); + CHECK(net_inet_ntoa(0U, NULL) == NULL, "TC-NETSOCK-100e ntoa(NULL 缓冲) 返 NULL 不崩"); + /* 单段/缺分隔的容错:逐段解析,非数字段当 0(钉实现,不钉"应该是多少") */ + CHECK(net_inet_addr("10.0.0.1") == 0x0A000001U, "TC-NETSOCK-100f inet_addr 10.0.0.1"); +} + +static void tc_101_byteorder(void) +{ + reset_all(); + + CHECK(net_htons(0x1234U) == 0x3412U, "TC-NETSOCK-101a htons 换端"); + CHECK(net_ntohs(net_htons(80U)) == 80U, "TC-NETSOCK-101b htons/ntohs 互逆"); + CHECK(net_htonl(0x01020304U) == 0x04030201U, "TC-NETSOCK-101c htonl 换端"); + CHECK(net_ntohl(net_htonl(0xDEADBEEFU)) == 0xDEADBEEFU, "TC-NETSOCK-101d htonl/ntohl 互逆"); +} + +static void tc_102_parse_ip(void) +{ + uint8_t a[4]; + + reset_all(); + + (void)memset(a, 0xEE, sizeof(a)); + parse_ip_string(NULL, a); + CHECK(a[0] == 0xEEU, "TC-NETSOCK-102a parse_ip_string(NULL) 直接返回,不写目标缓冲"); + parse_ip_string("192.168.1.100", a); + CHECK(a[0] == 192U && a[1] == 168U && a[2] == 1U && a[3] == 100U, + "TC-NETSOCK-102b parse_ip_string 段 0 落在 [0](芯片 set_ip_addr 要的顺位)"); + parse_ip_string("10.20.30.40", NULL); /* 只验不崩 */ + CHECK(1, "TC-NETSOCK-102c parse_ip_string 目标为 NULL 不崩"); +} + +static void tc_103_alloc_and_getsock(void) +{ + int i; + int fd; + net_sock_t *p; + + reset_all(); + + CHECK(net_get_sock(-1) == NULL && net_get_sock(NET_MAX_SOCKETS) == NULL, + "TC-NETSOCK-103a net_get_sock 越界返 NULL(含负数)"); + CHECK(net_get_sock(0) == NULL, "TC-NETSOCK-103b 未分配的槽位返 NULL"); + CHECK(net_sock_raw(3) == &s_net_socks[3], + "TC-NETSOCK-103c net_sock_raw 不判 in_use(对账必须看到 CLOSED 槽位)"); + CHECK(net_sock_state(0) == -1, "TC-NETSOCK-103d 未占用 sock_state 返 -1 且不设 errno"); + + /* 逐个占满:alloc_socket 线性扫描首个空闲槽 */ + for (i = 0; i < NET_MAX_SOCKETS; i++) { + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + if (fd != i) { + break; + } + } + CHECK(i == NET_MAX_SOCKETS, "TC-NETSOCK-103e alloc_socket 按序分配 0..7"); + p = net_get_sock(0); + CHECK(p != NULL && p->in_use == 1U, "TC-NETSOCK-103f 分配的槽位 in_use=1"); + + reset_all(); +} + +static void tc_104_socket_no_inherit(void) +{ + int fd; + net_sock_t *p; + + reset_all(); + + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + CHECK(fd == 0, "TC-NETSOCK-104a net_socket 返回首个空闲 fd"); + p = &s_net_socks[fd]; + CHECK(p->ch395_sock == (uint8_t)fd, "TC-NETSOCK-104b 芯片 Socket 号与描述符一一对应"); + CHECK(p->state == NET_SOCK_STATE_CREATED && p->type == NET_SOCK_STREAM, + "TC-NETSOCK-104c 初态 CREATED + 类型记下"); + CHECK(p->send_ready == 1U && p->readable == 0U, + "TC-NETSOCK-104d 初态:发送缓冲视为空闲、无可读"); + /* 三条"新 fd 不得继承"的定案,逐条钉(都是真机上静默出错的形态) */ + CHECK(p->blocking == 1U, "TC-NETSOCK-104e blocking 默认必须显式置 1(BSD 语义)"); + CHECK(p->released_abnormal == 0U, "TC-NETSOCK-104f 不得继承上一条的释放原因(TRAP-NET-18)"); + CHECK(p->session_dead == 0U, "TC-NETSOCK-104g 不得继承上一条的会话结束电平(N-32 P1)"); + CHECK(g_peers.listener_clear_calls == 1 && g_peers.pending_clear_calls == 1 && + g_peers.send_clear_calls == 1, + "TC-NETSOCK-104h 新 fd 必须清监听重试 + 两张挂起表(各一次)"); + + /* 脏槽位复用:把上一条会话的残留写进去,再看新建是否清干净 */ + p->blocking = 0U; + p->released_abnormal = 1U; + p->session_dead = 1U; + p->readable = 1U; + p->send_ready = 0U; + p->event_cb = (net_event_cb_t)0x1234; + /* 这里刻意用 netTask 直调版(net_close_sock)而不是公开 net_close: + * 本场景没绑消息队列,公开入口会先撞上"队列未创建"的 NET_ERR, + * 而那属于分片 3 的断言面(TC-NETSOCK-110h)。close 的清零主路径在 net_close_locked。 */ + CHECK(net_close_sock(&s_net_socks[fd]) == 0, + "TC-NETSOCK-104i net_close_sock 成功(close 的 memset 是清零主路径)"); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + p = &s_net_socks[fd]; + CHECK(fd == 0 && p->blocking == 1U && p->released_abnormal == 0U && + p->session_dead == 0U && p->readable == 0U && p->send_ready == 1U && + p->event_cb == NULL, + "TC-NETSOCK-104j 复用同一槽位:残留字段全部不得带走"); + /* 地址族不支持 → INVAL(在 alloc 之前,不占槽) */ + CHECK(net_socket(NET_AF_INET6, NET_SOCK_STREAM) == -1 && + net_get_errno() == NET_ERR_INVAL && s_net_socks[1].in_use == 0U, + "TC-NETSOCK-104k 非 INET 地址族 → INVAL 且不占槽位"); + + reset_all(); +} + +static void tc_105_bind(void) +{ + struct net_sockaddr_in in; + int fd; + int rc; + + reset_all(); + + (void)memset(&in, 0, sizeof(in)); + in.sin_family = NET_AF_INET; + in.sin_port = net_htons(2404); + in.sin_addr.s_addr = net_inet_addr("192.168.1.100"); + + CHECK(net_bind(-1, (struct net_sockaddr *)&in, sizeof(in)) == -1 && + net_get_errno() == NET_ERR_BADF, "TC-NETSOCK-105a 越界 fd → BADF"); + CHECK(net_bind(0, (struct net_sockaddr *)&in, sizeof(in)) == -1 && + net_get_errno() == NET_ERR_BADF, "TC-NETSOCK-105b 未创建就 bind → BADF"); + + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + CHECK(net_bind(fd, NULL, sizeof(in)) == -1 && net_get_errno() == NET_ERR_INVAL, + "TC-NETSOCK-105c addr 为 NULL → INVAL"); + CHECK(net_bind(fd, (struct net_sockaddr *)&in, 1) == -1 && + net_get_errno() == NET_ERR_INVAL, "TC-NETSOCK-105d addrlen 不足 → INVAL"); + CHECK(net_bind(fd, (struct net_sockaddr *)&in, sizeof(in)) == 0, + "TC-NETSOCK-105e TCP bind 成功"); + CHECK(s_net_socks[fd].local_port == 2404U && + s_net_socks[fd].state == NET_SOCK_STATE_BOUND, + "TC-NETSOCK-105f TCP 只记端口转主机序 + 进 BOUND,不碰芯片"); + CHECK(g_chip.set_port_calls == 0 && g_chip.open_calls == 0, + "TC-NETSOCK-105g TCP bind 期间不得下发源端口/开芯片 Socket(打开留给 listen)"); + + /* port=0 → 动态端口 49152 起,并推进 */ + reset_all(); + (void)memset(&in, 0, sizeof(in)); + in.sin_port = 0U; + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + rc = net_bind(fd, (struct net_sockaddr *)&in, sizeof(in)); + CHECK(rc == 0 && s_net_socks[fd].local_port == 49152U, + "TC-NETSOCK-105h port=0 → 分配 49152"); + CHECK(s_dynamic_port == 49153U, + "TC-NETSOCK-105h2 计数器已推进到 49153(分配即推进,不留重复)"); + (void)net_socket(NET_AF_INET, NET_SOCK_STREAM); /* 只创建不 bind */ + CHECK(s_dynamic_port == 49153U, + "TC-NETSOCK-105i3 创建 socket **不**消耗动态端口(只有 bind 消耗)"); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + rc = net_bind(fd, (struct net_sockaddr *)&in, sizeof(in)); + CHECK(rc == 0 && s_net_socks[fd].local_port == 49153U && s_dynamic_port == 49154U, + "TC-NETSOCK-105i 第二次 bind 拿下一个端口(不会把同一个端口发两次)"); + + /* 回绕:把计数器压到 65535,分配后应回 49152(不是 0——0 会让芯片按"未绑定"处理) */ + s_dynamic_port = 65535U; + reset_all(); + s_dynamic_port = 65535U; + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + rc = net_bind(fd, (struct net_sockaddr *)&in, sizeof(in)); + CHECK(rc == 0 && s_net_socks[fd].local_port == 65535U && s_dynamic_port == 49152U, + "TC-NETSOCK-105j 动态端口越过 65535 回绕到 49152(不回 0)"); +} + +static void tc_106_bind_udp(void) +{ + struct net_sockaddr_in in; + int fd; + + reset_all(); + (void)memset(&in, 0, sizeof(in)); + in.sin_family = NET_AF_INET; + in.sin_port = net_htons(8888); + + /* UDP:bind 就直接开芯片侧(无监听态),且必须配 DesIP=255.255.255.255 收任意源 */ + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + CHECK(net_bind(fd, (struct net_sockaddr *)&in, sizeof(in)) == 0, "TC-NETSOCK-106a UDP bind 成功"); + CHECK(s_net_socks[fd].state == NET_SOCK_STATE_UDP_OPEN, "TC-NETSOCK-106b UDP 直接进 UDP_OPEN"); + CHECK(g_chip.set_proto_calls == 1 && g_chip.last_proto == CH395F_PROTO_TYPE_UDP, + "TC-NETSOCK-106c 协议下发为 UDP(不是 TCP)"); + CHECK(g_chip.set_port_calls == 1 && g_chip.last_port == 8888U, + "TC-NETSOCK-106d 源端口随 bind 下发"); + CHECK(g_fx.des_ip[0][0] == 0xFFU && g_fx.des_ip[0][1] == 0xFFU && + g_fx.des_ip[0][2] == 0xFFU && g_fx.des_ip[0][3] == 0xFFU && + g_fx.des_port[0] == 0U, + "TC-NETSOCK-106e UDP Server 模式:DesIP=全 FF + 目的端口 0(否则只收单一对端)"); + CHECK(g_chip.open_calls == 1, "TC-NETSOCK-106f UDP bind 开一次芯片 Socket"); + + /* 槽位 >=4 必须补设收发缓冲(net_init 已配过,这里是兜底):布局 6 块/槽 */ + reset_all(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + CHECK(fd == 0, "TC-NETSOCK-106g 先拿 fd0(对照:不补缓冲)"); + CHECK(g_fxchip.set_recv_buf_calls == 0, "TC-NETSOCK-106h fd<4 的 UDP bind 不重设缓冲"); + + reset_all(); + for (fd = 0; fd < 4; fd++) { + (void)net_socket(NET_AF_INET, NET_SOCK_DGRAM); + } + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); /* fd4 */ + (void)net_bind(fd, (struct net_sockaddr *)&in, sizeof(in)); + CHECK(g_fx.recv_blk_start[4] == (uint8_t)(4 * 6) && + g_fx.recv_blk_cnt[4] == (uint8_t)CH395F_RECV_BLOCKS && + g_fx.send_blk_start[4] == (uint8_t)(4 * 6 + (int)CH395F_RECV_BLOCKS) && + g_fx.send_blk_cnt[4] == (uint8_t)CH395F_SEND_BLOCKS, + "TC-NETSOCK-106i fd>=4 兜底重设收发缓冲,块布局与 net_init 同构(6 块/槽)"); + + /* open 失败 → BUSY 且留在原状态(不得谎报 UDP_OPEN) */ + reset_all(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + g_fake.open_rc[fd] = CH395F_ERR_USE; + CHECK(net_bind(fd, (struct net_sockaddr *)&in, sizeof(in)) == -1 && + net_get_errno() == NET_ERR_BUSY && + s_net_socks[fd].state != NET_SOCK_STATE_UDP_OPEN && + g_ns_error_count == 1, + "TC-NETSOCK-106j UDP open 失败 → BUSY + 留痕 + 状态不前进"); +} + +static void tc_107_peer_and_localname(void) +{ + struct net_sockaddr_in in; + struct net_sockaddr *sa = (struct net_sockaddr *)∈ + int addrlen; + int fd; + + reset_all(); + (void)net_init("192.168.1.100", "255.255.255.0", "192.168.1.1", NULL); + + CHECK(net_getsockname(-1, sa, &addrlen) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-107a getsockname 越界 → BADF"); + CHECK(net_getsockname(0, sa, &addrlen) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-107b 未用且 CLOSED 的槽 → BADF"); + + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + (void)memset(&in, 0, sizeof(in)); + in.sin_port = net_htons(7003); + CHECK(net_bind(fd, sa, (int)sizeof(in)) == 0, "TC-NETSOCK-107c bind 前置成功"); + addrlen = (int)sizeof(in); + (void)memset(&in, 0, sizeof(in)); + CHECK(net_getsockname(fd, sa, &addrlen) == 0, "TC-NETSOCK-107d getsockname 成功"); + CHECK(in.sin_family == NET_AF_INET && net_ntohs(in.sin_port) == 7003U && + addrlen == (int)sizeof(in), + "TC-NETSOCK-107e 回填族/端口(网络序)/长度"); + /* 钉**具体顺位**而不是二选一:getsockname 走的是「首段在最低字节」这一族 + * (与 net_inet_ntoa、net_events 的 remote_ip 同族),所以 192.168.1.100 = + * 0x6401A8C0。net_inet_addr 返回的是另一族(首段在最高字节,0xC0A80164), + * 两族不互逆——已作为疑似缺陷报出,这里刻意不写成「两个都行」。 */ + CHECK(in.sin_addr.s_addr == 0x6401A8C0U, + "TC-NETSOCK-107f 本机 IP 顺位=首段在最低字节(与 ntoa 同族;net_inet_addr 是另一族)"); + + addrlen = 1; + CHECK(net_getsockname(fd, sa, &addrlen) == -1 && net_get_errno() == NET_ERR_INVAL, + "TC-NETSOCK-107g 长度不足 → INVAL"); + CHECK(net_getsockname(fd, sa, NULL) == -1 && net_get_errno() == NET_ERR_INVAL, + "TC-NETSOCK-107h addrlen 为 NULL → INVAL"); + + /* getpeername:没有 CONNECT 回读过对端 → NOTCONN(不发 SPI 是它的卖点) */ + (void)memset(&in, 0, sizeof(in)); + addrlen = (int)sizeof(in); + CHECK(net_getpeername(fd, sa, &addrlen) == -1 && net_get_errno() == NET_ERR_NOTCONN, + "TC-NETSOCK-107i 尚无对端 → NOTCONN(remote_port==0 判据)"); + CHECK(g_chip.get_status_calls == 0 && g_fxchip.glob_calls == 0, + "TC-NETSOCK-107j getpeername 纯读缓存,不发任何芯片事务"); + + s_net_socks[fd].remote_port = 51999U; + s_net_socks[fd].remote_ip = 0x0B01A8C0U; + (void)memset(&in, 0, sizeof(in)); + addrlen = (int)sizeof(in); + CHECK(net_getpeername(fd, sa, &addrlen) == 0 && + net_ntohs(in.sin_port) == 51999U && in.sin_addr.s_addr == 0x0B01A8C0U && + in.sin_family == NET_AF_INET, + "TC-NETSOCK-107k getpeername 回填缓存的对端 IP:端口"); + CHECK(net_getpeername(-1, sa, &addrlen) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-107l getpeername 越界 → BADF"); + CHECK(net_getpeername(5, sa, &addrlen) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-107m getpeername 未在用 → BADF"); + addrlen = 2; + CHECK(net_getpeername(fd, sa, &addrlen) == -1 && net_get_errno() == NET_ERR_INVAL, + "TC-NETSOCK-107n getpeername 长度不足 → INVAL"); + + reset_all(); +} + +static void tc_108_sockprops(void) +{ + int fd; + int arg = 7; + + reset_all(); + CHECK(net_setblocking(-1, 1) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-108a setblocking 越界 → BADF"); + CHECK(net_setblocking(0, 1) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-108b 未占用 → BADF"); + CHECK(net_set_event_cb(-1, NULL, NULL) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-108c set_event_cb 越界 → BADF"); + CHECK(net_set_event_cb(0, NULL, NULL) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-108d set_event_cb 未占用 → BADF"); + + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + CHECK(net_setblocking(fd, 0) == 0 && s_net_socks[fd].blocking == 0U, + "TC-NETSOCK-108e setblocking(0) 落属性(模式挂控制块,不是单次参数)"); + CHECK(net_setblocking(fd, 5) == 0 && s_net_socks[fd].blocking == 1U, + "TC-NETSOCK-108f 非 0 一律视为阻塞"); + CHECK(net_set_event_cb(fd, (net_event_cb_t)0x1234, &arg) == 0 && + s_net_socks[fd].event_cb == (net_event_cb_t)0x1234 && + s_net_socks[fd].event_arg == &arg, + "TC-NETSOCK-108g 注册回调与用户参数"); + CHECK(net_set_event_cb(fd, NULL, NULL) == 0 && s_net_socks[fd].event_cb == NULL, + "TC-NETSOCK-108h cb=NULL 取消注册"); + s_net_socks[fd].state = NET_SOCK_STATE_ESTABLISHED; + CHECK(net_sock_state(fd) == (int)NET_SOCK_STATE_ESTABLISHED, + "TC-NETSOCK-108i sock_state 返状态快照"); + CHECK(net_sock_state(NET_MAX_SOCKETS) == -1, "TC-NETSOCK-108j sock_state 越界 -1"); + s_net_socks[fd].in_use = 0U; + CHECK(net_sock_state(fd) == -1, "TC-NETSOCK-108k sock_state 未占用 -1"); + + reset_all(); +} + +static void tc_109_generation(void) +{ + reset_all(); + CHECK(net_get_generation() == 0U, "TC-NETSOCK-109a 初值 0"); + net_generation_inc(); + net_generation_inc(); + CHECK(net_get_generation() == 2U, "TC-NETSOCK-109b 每次网络重建 +1(服务据此重建监听)"); + reset_all(); +} + +static void tc_110_queue_adopt(void) +{ + static int q1; + static int q2; + + reset_all(); + CHECK(net_msg_queue_adopt(NULL) == -1, "TC-NETSOCK-110a 空句柄 → -1"); + CHECK(net_msg_queue_adopt((osMessageQueueId_t)&q1) == 0, "TC-NETSOCK-110b 首次绑定成功"); + CHECK(net_msg_queue_adopt((osMessageQueueId_t)&q2) == -1, + "TC-NETSOCK-110c 重复绑定必须被拒(不静默换句柄)"); + CHECK(s_net_msg_queue == (osMessageQueueId_t)&q1, + "TC-NETSOCK-110d 重复绑定**保留首次**句柄——换掉会让已入队请求永不处理"); + /* 未绑定时所有公开 API 一律 NET_ERR,而不是崩在空句柄上 */ + reset_all(); + CHECK(net_listen(0) == -1 && net_get_errno() == NET_ERR, + "TC-NETSOCK-110e 未绑定队列:net_listen → NET_ERR"); + CHECK(net_recv(0, NULL, 0, 0) == -1 && net_get_errno() == NET_ERR, + "TC-NETSOCK-110f 未绑定队列:net_recv → NET_ERR(越界检查在队检查之前)"); + CHECK(net_send(0, "x", 1, 0) == -1 && net_get_errno() == NET_ERR, + "TC-NETSOCK-110g 未绑定队列:net_send → NET_ERR"); + CHECK(net_close(0) == -1 && net_get_errno() == NET_ERR, + "TC-NETSOCK-110h 未绑定队列:net_close → NET_ERR"); + CHECK(net_drop_conn(0) == -1 && net_get_errno() == NET_ERR, + "TC-NETSOCK-110i 未绑定队列:net_drop_conn → NET_ERR"); + CHECK(net_recv_to(0, NULL, 0, 10U) == -1 && net_get_errno() == NET_ERR, + "TC-NETSOCK-110j 未绑定队列:net_recv_to → NET_ERR"); + CHECK(net_accept(0, NULL, NULL) == -1 && net_get_errno() == NET_ERR, + "TC-NETSOCK-110k 未绑定队列:net_accept → NET_ERR"); + CHECK(net_connect(0, NULL, 0) == -1 && net_get_errno() == NET_ERR, + "TC-NETSOCK-110l 未绑定队列:net_connect → NET_ERR"); + /* 越界优先于"未初始化":调用方拿到的码要能区分"我传错了"和"网还没起来" */ + CHECK(net_send(NET_MAX_SOCKETS, "x", 1, 0) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-110m 越界检查先于队列检查 → BADF"); + reset_all(); +} + +/* ===================== 分片 2 每任务错误码槽(E-03) ===================== */ + +static void tc_120_errno_slot_per_task(void) +{ + reset_all(); + + /* 内核没跑起来:不做槽位分配,一律退到 [0] 槽(中断/内核前上下文) */ + g_ns_kernel_state = osKernelInactive; + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + (void)net_set_errno(NET_ERR_BADF); + CHECK(net_get_errno() == NET_ERR_BADF && s_net_errno_slots[0].owner == NULL, + "TC-NETSOCK-120a 内核未运行 → 用 [0] 槽且**不占槽**(不写 owner)"); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_net; + CHECK(net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-120b 内核未运行时所有任务共享 [0] 槽(退化语义)"); + + /* 内核在跑:每个任务自己的槽位 */ + g_ns_kernel_state = osKernelRunning; + reset_all(); + g_ns_kernel_state = osKernelRunning; + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + (void)net_set_errno(NET_ERR_TIMEDOUT); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_net; + CHECK(net_get_errno() == NET_OK, + "TC-NETSOCK-120c 另一个任务读不到 caller 的码(E-03 的正面表述)"); + (void)net_set_errno(NET_ERR_WOULDBLOCK); + CHECK(net_get_errno() == NET_ERR_WOULDBLOCK, "TC-NETSOCK-120d 本任务读自己的码"); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + CHECK(net_get_errno() == NET_ERR_TIMEDOUT, + "TC-NETSOCK-120e 互不覆写:caller 的码没被 netTask 抢走(旧实现的红线)"); + /* 临界区:两个任务各占一次槽 → 两次进入、两次退出,绝不泄漏嵌套计数 */ + CHECK(g_ns_crit_enter == 2 && g_ns_crit_exit == 2 && ns_os_crit_depth() == 0, + "TC-NETSOCK-120f 每次**首次占槽**进一次临界区且成对退出(无泄漏)"); + + /* 快路径只读不写:已有槽位的任务反复读写都不该再进临界区、也不该再占槽 */ + { + int e0 = g_ns_crit_enter; + int owners0 = ns_slot_owner_count(); + int i_caller = ns_slot_index_of((TaskHandle_t)&g_ns_task_caller); + int i_net = ns_slot_index_of((TaskHandle_t)&g_ns_task_net); + + CHECK(i_caller >= 0 && i_net >= 0 && i_caller != i_net, + "TC-NETSOCK-120g 两个任务落在**不同**槽位(槽位表可查,不是靠约定)"); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + (void)net_set_errno(NET_ERR_BUSY); + (void)net_get_errno(); + (void)net_set_errno(NET_ERR_CONNRESET); + CHECK(g_ns_crit_enter == e0, "TC-NETSOCK-120h 命中已有槽位:全程零临界区(快路径只读)"); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_net; + CHECK(net_get_errno() == NET_ERR_WOULDBLOCK && + ns_slot_owner_count() == owners0, + "TC-NETSOCK-120i 别的任务反复读也不新增槽位、也读不到 caller 的码"); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + CHECK(net_get_errno() == NET_ERR_CONNRESET, "TC-NETSOCK-120j caller 读到自己最新的码"); + } + + /* 槽位用尽 → 回退 [0](刻意不重试,避免把竞态带回来) */ + reset_all(); + g_ns_kernel_state = osKernelRunning; + { + int i; + static int filler[10]; + for (i = 0; i < (int)NET_ERRNO_TASK_SLOTS; i++) { + g_ns_cur_task = (TaskHandle_t)&filler[i]; + (void)net_set_errno(NET_ERR_ISCONN); + } + g_ns_cur_task = (TaskHandle_t)&filler[0]; + CHECK(net_get_errno() == NET_ERR_ISCONN, "TC-NETSOCK-120k 8 个槽各归各主"); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; /* 第 9 个任务 */ + (void)net_set_errno(NET_ERR_NOMEM); + CHECK(net_get_errno() == NET_ERR_NOMEM, + "TC-NETSOCK-120l 槽位用尽:新任务落 [0] 槽仍读得到自己的码"); + g_ns_cur_task = (TaskHandle_t)&filler[0]; + CHECK(net_get_errno() == NET_ERR_NOMEM, + "TC-NETSOCK-120m 用尽后退化为共享 [0] 槽(只影响排障可读性,不新增共享槽位)"); + } + reset_all(); +} + +static void tc_121_errno_slot_isr(void) +{ + reset_all(); + g_ns_kernel_state = osKernelRunning; + g_ns_cur_task = NULL; /* 中断上下文/无句柄 */ + (void)net_set_errno(NET_ERR_CONNRESET); + CHECK(net_get_errno() == NET_ERR_CONNRESET && s_net_errno_slots[0].owner == NULL, + "TC-NETSOCK-121 句柄为 NULL(中断上下文)→ 退 [0] 槽且不改槽位表"); + reset_all(); +} + +/* ===================== 主入口 ===================== */ + +int main(void) +{ + printf("=== NET Socket Tests (net_socket.c 主机单测) ===\n"); + + printf("[shard1] 纯函数面与 fd 表\n"); + tc_100_inet_helpers(); + tc_101_byteorder(); + tc_102_parse_ip(); + tc_103_alloc_and_getsock(); + tc_104_socket_no_inherit(); + tc_105_bind(); + tc_106_bind_udp(); + tc_107_peer_and_localname(); + tc_108_sockprops(); + tc_109_generation(); + tc_110_queue_adopt(); + + printf("[shard2] 每任务错误码槽 + 队列绑定\n"); + tc_120_errno_slot_per_task(); + tc_121_errno_slot_isr(); + + printf("[shard3] 跨上下文消息与通知值回传\n"); + ns_run_s3(); + + printf("[shard4] 状态机与闸门\n"); + ns_run_s4(); + + printf("[shard5] 建流与读写分支\n"); + ns_run_s5(); + + printf("[shard6] 边界残支与其余出口\n"); + ns_run_s6(); + + printf("=== NET Socket Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/net_socket/ns_peers.c b/test/host/net_socket/ns_peers.c new file mode 100644 index 00000000..fee85cb9 --- /dev/null +++ b/test/host/net_socket/ns_peers.c @@ -0,0 +1,379 @@ +/* + * 模块名称:NET 层兄弟模块桩(net_socket 主机侧单测专用) + * 模块功能:net_socket.c 依赖的三个兄弟模块(net_listener / net_pending / net_events) + * 在这里**按边界契约打桩**,让本套单测只测 net_socket.c 自己那部分决策。 + * + * ⚠ 仅供 test/host/net_socket/ 使用,不参与 MCU 构建。 + * + * 为什么打桩而不是 #include 真实源(而不是"越真越好"): + * 1. 那三个模块**已各有自己的主机单测**(net_listener 34 条、net_events 34 条、 + * net_pending 43 条),本套再跑一遍不增加任何对 net_socket.c 的判别力, + * 只会把"谁的红"变成糊账; + * 2. 本套真正要钉的是**边界契约**——net_socket.c 在什么条件下把什么 reason 登记给 + * 监听层、什么条件下把挂起请求登记给 pending 层、以及"登记了就不能再自己发回执"。 + * 桩把这些变成可断言的记录,比让真实 tick 在背后默默跑更有判别力; + * 3. 原型一律取**真实头**(net_listener.h / net_pending.h / net_events.h), + * 兄弟模块改签名 → 本套编译期就红。 + * + * 桩的取值纪律(不自己发明语义,全部按真实头的函数说明): + * - net_pending_register / net_send_pending_register 返回 NET_PENDING_REGISTERED, + * 与真实实现一致(登记成功=哨兵,供分派跳过即时通知);可注入 -1 测登记被拒; + * - net_pending_tick 支持"下一轮把脚本里的结果当作数据到达,通知登记时的 caller", + * 复刻真实 tick 的"数据到达即通知"契约; + * - 其余全为纯记录(次数 + 最近一次入参),供"不该登记/必须登记"的断言。 + * + * 创建日期:2026-10-24 + */ + +#include +#include +#include + +#include "net_listener.h" +#include "net_pending.h" +#include "net_events.h" +#include "net_socket.h" +#include "ns_peers.h" +#include "ns_rtos.h" + +ns_peers_obs_t g_peers; + +/* 桩内部"完成/取消"要发通知:转给假内核,这样测试能断言通知次数与通知值 */ +void ns_peers_note_notify(TaskHandle_t to, uint32_t v) +{ + ns_rtos_post_notify(to, v); +} + +/* 挂起登记簿(只记一条,够本套断言用) */ +static int s_pend_active[NET_MAX_SOCKETS]; +static void *s_pend_buf[NET_MAX_SOCKETS]; +static int s_pend_len[NET_MAX_SOCKETS]; +static TaskHandle_t s_pend_caller[NET_MAX_SOCKETS]; +static int s_snd_active[NET_MAX_SOCKETS]; +static TaskHandle_t s_snd_caller[NET_MAX_SOCKETS]; + +/* tick 完成脚本:fd -> 结果值(一次生效) */ +static int s_pend_done_armed[NET_MAX_SOCKETS]; +static int s_pend_done_val[NET_MAX_SOCKETS]; +static int s_snd_done_armed[NET_MAX_SOCKETS]; +static int s_snd_done_val[NET_MAX_SOCKETS]; + +void ns_peers_reset(void) +{ + (void)memset(&g_peers, 0, sizeof(g_peers)); + (void)memset(s_pend_active, 0, sizeof(s_pend_active)); + (void)memset(s_pend_buf, 0, sizeof(s_pend_buf)); + (void)memset(s_pend_len, 0, sizeof(s_pend_len)); + (void)memset(s_pend_caller, 0, sizeof(s_pend_caller)); + (void)memset(s_snd_active, 0, sizeof(s_snd_active)); + (void)memset(s_snd_caller, 0, sizeof(s_snd_caller)); + (void)memset(s_pend_done_armed, 0, sizeof(s_pend_done_armed)); + (void)memset(s_pend_done_val, 0, sizeof(s_pend_done_val)); + (void)memset(s_snd_done_armed, 0, sizeof(s_snd_done_armed)); + (void)memset(s_snd_done_val, 0, sizeof(s_snd_done_val)); + g_peers.pend_register_rc = NET_PENDING_REGISTERED; + g_peers.send_register_rc = NET_PENDING_REGISTERED; +} + +void ns_peers_arm_pending_done(int sockfd, int result) +{ + if ((sockfd >= 0) && (sockfd < NET_MAX_SOCKETS)) { + s_pend_done_armed[sockfd] = 1; + s_pend_done_val[sockfd] = result; + } +} + +void ns_peers_arm_send_pending_done(int sockfd, int result) +{ + if ((sockfd >= 0) && (sockfd < NET_MAX_SOCKETS)) { + s_snd_done_armed[sockfd] = 1; + s_snd_done_val[sockfd] = result; + } +} + +/* ================= net_listener.h ================= */ + +void net_listener_request(int sockfd, net_listener_req_t reason) +{ + g_peers.listener_req_calls++; + g_peers.last_req_fd = sockfd; + g_peers.last_req_reason = (int)reason; + if ((sockfd >= 0) && (sockfd < NET_MAX_SOCKETS)) { + g_peers.req_armed[sockfd] = 1; + g_peers.req_reason[sockfd] = (int)reason; + } +} + +void net_listener_clear(int sockfd) +{ + g_peers.listener_clear_calls++; + g_peers.last_clear_fd = sockfd; + if ((sockfd >= 0) && (sockfd < NET_MAX_SOCKETS)) { + g_peers.req_armed[sockfd] = 0; + } +} + +void net_listener_tick(void) +{ + g_peers.listener_tick_calls++; +} + +void net_listener_settle(int sockfd, net_listener_settle_t level) +{ + g_peers.listener_settle_calls++; + g_peers.last_settle_fd = sockfd; + g_peers.last_settle_level = (int)level; +} + +/* ================= net_pending.h(recv 侧) ================= */ + +void net_pending_capture_owner(void) +{ + g_peers.pending_capture_calls++; +} + +int net_pending_register(int sockfd, void *buf, int len, uint32_t to_ms, + TaskHandle_t caller) +{ + g_peers.pending_reg_calls++; + g_peers.last_pend_fd = sockfd; + g_peers.last_pend_to = to_ms; + g_peers.last_pend_caller = caller; + if (g_peers.pend_register_rc != NET_PENDING_REGISTERED) { + return g_peers.pend_register_rc; /* 测"登记被拒"分支 */ + } + if ((sockfd >= 0) && (sockfd < NET_MAX_SOCKETS)) { + s_pend_active[sockfd] = 1; + s_pend_buf[sockfd] = buf; + s_pend_len[sockfd] = len; + s_pend_caller[sockfd] = caller; + g_peers.pend_active_now[sockfd] = 1; + } + return NET_PENDING_REGISTERED; +} + +void net_pending_tick(net_sock_t *p_socks) +{ + int i; + + g_peers.pending_tick_calls++; + (void)p_socks; + /* 复刻真实 tick 的主用例:数据到达 → 按登记时的 caller 通知 → 清槽 */ + for (i = 0; i < NET_MAX_SOCKETS; i++) { + if ((s_pend_active[i] != 0) && (s_pend_done_armed[i] != 0)) { + s_pend_done_armed[i] = 0; + s_pend_active[i] = 0; + g_peers.pend_active_now[i] = 0; + g_peers.pending_notified++; + ns_peers_note_notify(s_pend_caller[i], (uint32_t)s_pend_done_val[i]); + } + } +} + +void net_pending_clear(int sockfd) +{ + g_peers.pending_clear_calls++; + g_peers.last_pend_clear_fd = sockfd; + if ((sockfd >= 0) && (sockfd < NET_MAX_SOCKETS)) { + s_pend_active[sockfd] = 0; + g_peers.pend_active_now[sockfd] = 0; + } +} + +void net_pending_cancel(int sockfd, int err) +{ + g_peers.pending_cancel_calls++; + g_peers.last_pend_cancel_err = err; + if ((sockfd >= 0) && (sockfd < NET_MAX_SOCKETS)) { + s_pend_active[sockfd] = 0; + g_peers.pend_active_now[sockfd] = 0; + } +} + +void net_pending_cancel_all(int err) +{ + int i; + + g_peers.pending_cancel_all_calls++; + g_peers.last_pend_cancel_err = err; + for (i = 0; i < NET_MAX_SOCKETS; i++) { + if (s_pend_active[i] != 0) { + g_peers.pending_notified++; + ns_peers_note_notify(s_pend_caller[i], (uint32_t)err); + } + s_pend_active[i] = 0; + g_peers.pend_active_now[i] = 0; + } +} + +void net_pending_settle_close(int sockfd, net_sock_t *p_sock) +{ + g_peers.pending_settle_calls++; + g_peers.last_pend_fd = sockfd; + (void)p_sock; + if ((sockfd >= 0) && (sockfd < NET_MAX_SOCKETS) && (s_pend_active[sockfd] != 0)) { + /* 真实实现:有数据先送达,无数据按 CONNRESET 取消——两者都**必通知** */ + s_pend_active[sockfd] = 0; + g_peers.pend_active_now[sockfd] = 0; + g_peers.pending_notified++; + ns_peers_note_notify(s_pend_caller[sockfd], (uint32_t)NET_ERR_CONNRESET); + } +} + +int net_pending_active(int sockfd) +{ + if ((sockfd < 0) || (sockfd >= NET_MAX_SOCKETS)) { + return 0; + } + return s_pend_active[sockfd]; +} + +/* ================= net_pending.h(send 侧) ================= */ + +int net_send_pending_register(int sockfd, const void *buf, int len, int sent, + TaskHandle_t caller) +{ + g_peers.send_reg_calls++; + g_peers.last_snd_fd = sockfd; + g_peers.last_snd_sent = sent; + g_peers.last_snd_len = len; + (void)buf; + if (g_peers.send_register_rc != NET_PENDING_REGISTERED) { + return g_peers.send_register_rc; + } + /* 真实实现的硬前置:sent >= len 视为参数非法拒收(末块边界) */ + if (sent >= len) { + return -1; + } + if ((sockfd >= 0) && (sockfd < NET_MAX_SOCKETS)) { + s_snd_active[sockfd] = 1; + s_snd_caller[sockfd] = caller; + } + return NET_PENDING_REGISTERED; +} + +void net_send_pending_tick(net_sock_t *p_socks) +{ + int i; + + g_peers.send_tick_calls++; + (void)p_socks; + for (i = 0; i < NET_MAX_SOCKETS; i++) { + if ((s_snd_active[i] != 0) && (s_snd_done_armed[i] != 0)) { + s_snd_done_armed[i] = 0; + s_snd_active[i] = 0; + g_peers.send_notified++; + ns_peers_note_notify(s_snd_caller[i], (uint32_t)s_snd_done_val[i]); + } + } +} + +void net_send_pending_clear(int sockfd) +{ + g_peers.send_clear_calls++; + if ((sockfd >= 0) && (sockfd < NET_MAX_SOCKETS)) { + s_snd_active[sockfd] = 0; + } +} + +void net_send_pending_cancel(int sockfd, int err) +{ + g_peers.send_cancel_calls++; + g_peers.last_snd_cancel_err = err; + if ((sockfd >= 0) && (sockfd < NET_MAX_SOCKETS) && (s_snd_active[sockfd] != 0)) { + s_snd_active[sockfd] = 0; + g_peers.send_notified++; + ns_peers_note_notify(s_snd_caller[sockfd], (uint32_t)err); + } +} + +void net_send_pending_cancel_all(int err) +{ + int i; + + g_peers.send_cancel_all_calls++; + g_peers.last_snd_cancel_err = err; + for (i = 0; i < NET_MAX_SOCKETS; i++) { + if (s_snd_active[i] != 0) { + g_peers.send_notified++; + ns_peers_note_notify(s_snd_caller[i], (uint32_t)err); + } + s_snd_active[i] = 0; + } +} + +int net_send_pending_active(int sockfd) +{ + if ((sockfd < 0) || (sockfd >= NET_MAX_SOCKETS)) { + return 0; + } + return s_snd_active[sockfd]; +} + +/* ================= net_events.h(事件面与对账) ================= */ + +/* 事件处理桩:按真实头注释的效果改控制块(CONNECT→ESTABLISHED + 记对端等), + * 让 net_poll 的"中断 → 状态机"分派可断言;同时计数。 */ +void handle_connect_event(net_sock_t *p_sock) +{ + g_peers.evt_connect_calls++; + if (p_sock != NULL) { + p_sock->state = NET_SOCK_STATE_ESTABLISHED; + p_sock->readable = 0U; + p_sock->released_abnormal = 0U; + p_sock->session_dead = 0U; + } +} + +void handle_disconnect_event(net_sock_t *p_sock) +{ + g_peers.evt_disconnect_calls++; + if (p_sock != NULL) { + p_sock->last_peer_port = p_sock->remote_port; + p_sock->state = NET_SOCK_STATE_CLOSED; + p_sock->session_dead = 1U; + } +} + +void handle_recv_event(net_sock_t *p_sock) +{ + g_peers.evt_recv_calls++; + if (p_sock != NULL) { + p_sock->readable = 1U; + } +} + +void handle_send_event(net_sock_t *p_sock) +{ + g_peers.evt_send_calls++; + if (p_sock != NULL) { + p_sock->send_ready = 1U; + } +} + +void handle_timeout_event(net_sock_t *p_sock) +{ + g_peers.evt_timeout_calls++; + if (p_sock != NULL) { + p_sock->last_peer_port = p_sock->remote_port; + p_sock->state = NET_SOCK_STATE_CLOSED; + p_sock->released_abnormal = 1U; + p_sock->session_dead = 1U; + } +} + +void net_reconcile_tick(void) +{ + g_peers.reconcile_calls++; +} + +void net_lost_connect_probe(net_sock_t *p_sock) +{ + g_peers.lost_probe_calls++; + g_peers.last_probe_state = (p_sock != NULL) ? (int)p_sock->state : -1; + if ((p_sock != NULL) && (g_peers.lost_probe_hit != 0)) { + /* 脚本:探针命中 → 直接转 ESTABLISHED(复刻补 accept 的净效果) */ + p_sock->state = NET_SOCK_STATE_ESTABLISHED; + p_sock->lost_conn_seen = 0U; + } +} diff --git a/test/host/net_socket/ns_peers.h b/test/host/net_socket/ns_peers.h new file mode 100644 index 00000000..67c9b54c --- /dev/null +++ b/test/host/net_socket/ns_peers.h @@ -0,0 +1,82 @@ +/* + * 模块名称:NET 层兄弟模块桩的观测面(net_socket 主机侧单测专用) + * 模块功能:声明 ns_peers.c 的记录结构与复位入口。原型层面的约束(必须与真实头逐字 + * 一致)在 ns_peers.c 里由"include 真实头 + 直接实现同名函数"保证: + * 签名漂移 = 本套编译期红。 + * ⚠ 仅供 test/host/net_socket/ 使用,不参与 MCU 构建。 + * 创建日期:2026-10-24 + */ +#ifndef NS_PEERS_H +#define NS_PEERS_H + +#include + +#include "FreeRTOS.h" +#include "net_types.h" + +typedef struct { + /* —— net_listener —— */ + int listener_req_calls; + int last_req_fd; + int last_req_reason; + int listener_clear_calls; + int last_clear_fd; + int listener_tick_calls; + int listener_settle_calls; + int last_settle_fd; + int last_settle_level; + int req_armed[NET_MAX_SOCKETS]; /* 登记未清=1(clear/tick 后归 0) */ + int req_reason[NET_MAX_SOCKETS]; + + /* —— net_pending(recv) —— */ + int pending_capture_calls; + int pending_reg_calls; + int pend_register_rc; /* 注入登记结果(默认 NET_PENDING_REGISTERED) */ + int last_pend_fd; + uint32_t last_pend_to; + TaskHandle_t last_pend_caller; + int pending_tick_calls; + int pending_clear_calls; + int last_pend_clear_fd; + int pending_cancel_calls; + int pending_cancel_all_calls; + int last_pend_cancel_err; + int pending_settle_calls; + int pending_notified; /* 经由桩真实发出通知的次数(必通知契约) */ + int pend_active_now[NET_MAX_SOCKETS]; + + /* —— net_pending(send) —— */ + int send_reg_calls; + int send_register_rc; + int last_snd_fd; + int last_snd_sent; + int last_snd_len; + int send_tick_calls; + int send_clear_calls; + int send_cancel_calls; + int send_cancel_all_calls; + int last_snd_cancel_err; + int send_notified; + + /* —— net_events —— */ + int evt_connect_calls; + int evt_disconnect_calls; + int evt_recv_calls; + int evt_send_calls; + int evt_timeout_calls; + int reconcile_calls; + int lost_probe_calls; + int lost_probe_hit; /* 1=让探针命中(补 accept) */ + int last_probe_state; +} ns_peers_obs_t; + +extern ns_peers_obs_t g_peers; + +void ns_peers_reset(void); +void ns_peers_arm_pending_done(int sockfd, int result); +void ns_peers_arm_send_pending_done(int sockfd, int result); + +/* 由 ns_peers.c 调用,把"桩内部完成的通知"接到假内核的通知面上 */ +void ns_peers_note_notify(TaskHandle_t to, uint32_t v); + +#endif /* NS_PEERS_H */ diff --git a/test/host/net_socket/ns_rtos.c b/test/host/net_socket/ns_rtos.c new file mode 100644 index 00000000..d4061ae9 --- /dev/null +++ b/test/host/net_socket/ns_rtos.c @@ -0,0 +1,334 @@ +/* + * 模块名称:假内核实现(net_socket 主机侧单测专用) + * 模块功能:实现 ns_rtos.h 声明的假队列/假通知/假时基。由测试单元 #include 使用。 + * ⚠ 仅供 test/host/net_socket/ 使用,不参与 MCU 构建。 + * 创建日期:2026-10-24 + */ + +#include +#include +#include + +#include "ns_rtos.h" +#include "net_socket.h" + +int g_ns_task_caller; +int g_ns_task_net; + +TaskHandle_t g_ns_cur_task; +ns_pump_fn g_ns_pump; +ns_poll_hook_fn g_ns_on_poll; +int g_ns_pump_calls; +int g_ns_block_waits; +int g_ns_notify_calls; +uint32_t g_ns_notify_last_val; +TaskHandle_t g_ns_notify_last_to; +int g_ns_put_calls; +int g_ns_get_calls; +int g_ns_crit_enter; +int g_ns_crit_exit; +uint32_t g_ns_tick; +osKernelState_t g_ns_kernel_state = osKernelRunning; + +/* 通知槽:按句柄区分(只服务上面两个假任务,超出的句柄一律现场分配不了→报错) */ +typedef struct { + TaskHandle_t h; + uint32_t val; + int pending; +} ns_notify_slot_t; + +static ns_notify_slot_t s_slot[2]; + +/* 假队列:定长环形,存 net_msg_t 按值拷贝(与真队列语义一致) */ +static net_msg_t s_q[NS_Q_CAP]; +static int s_q_head; +static int s_q_count; + +void ns_rtos_reset(void) +{ + (void)memset(s_slot, 0, sizeof(s_slot)); + s_slot[0].h = (TaskHandle_t)&g_ns_task_caller; + s_slot[1].h = (TaskHandle_t)&g_ns_task_net; + (void)memset(s_q, 0, sizeof(s_q)); + s_q_head = 0; + s_q_count = 0; + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + g_ns_pump = NULL; + g_ns_on_poll = NULL; + g_ns_pump_calls = 0; + g_ns_block_waits = 0; + g_ns_notify_calls = 0; + g_ns_notify_last_val = 0U; + g_ns_notify_last_to = NULL; + g_ns_put_calls = 0; + g_ns_get_calls = 0; + g_ns_crit_enter = 0; + g_ns_crit_exit = 0; + g_ns_tick = 0U; + g_ns_kernel_state = osKernelRunning; + g_ns_int_pin_set = 1; + g_ns_poll_tick_inc = 1U; + g_ns_poll_calls = 0U; +} + +void ns_rtos_q_flush(void) +{ + s_q_head = 0; + s_q_count = 0; +} + +int ns_rtos_q_count(void) +{ + return s_q_count; +} + +void ns_rtos_q_fill(int n) +{ + int i; + + for (i = 0; (i < n) && ((uint32_t)s_q_count < NS_Q_CAP); i++) { + (void)memset(&s_q[(s_q_head + s_q_count) % (int)NS_Q_CAP], 0, sizeof(net_msg_t)); + s_q_count++; + } +} + +int ns_rtos_q_put_raw(const net_msg_t *msg) +{ + if (msg == NULL || (uint32_t)s_q_count >= NS_Q_CAP) { + return -1; + } + (void)memcpy(&s_q[(s_q_head + s_q_count) % (int)NS_Q_CAP], msg, sizeof(net_msg_t)); + s_q_count++; + return 0; +} + +/* 假内核里的"另一次通知发出":与 xTaskNotify 共用同一套观测计数, + * 这样"谁发了几次通知"在测试里只有一个口径(挂起完成/取消走的就是这条) */ +void ns_rtos_post_notify(TaskHandle_t to, uint32_t v) +{ + int i; + + g_ns_notify_calls++; + g_ns_notify_last_val = v; + g_ns_notify_last_to = to; + for (i = 0; i < 2; i++) { + if (s_slot[i].h == to) { + s_slot[i].val = v; + s_slot[i].pending = 1; + return; + } + } + printf("FATAL: ns_rtos_post_notify 收到未知任务句柄\n"); + exit(2); +} + +static ns_notify_slot_t *slot_of(TaskHandle_t h) +{ + int i; + + for (i = 0; i < 2; i++) { + if (s_slot[i].h == h) { + return &s_slot[i]; + } + } + return NULL; +} + +/* —— CMSIS-RTOS2 侧(原型逐字取自真实 cmsis_os2.h:689/697/298/459)—— */ + +osStatus_t osMessageQueuePut(osMessageQueueId_t mq_id, const void *msg_ptr, + uint8_t msg_prio, uint32_t timeout) +{ + (void)mq_id; + (void)msg_prio; + (void)timeout; + + g_ns_put_calls++; + if (msg_ptr == NULL) { + return osErrorParameter; + } + if ((uint32_t)s_q_count >= NS_Q_CAP) { + return osErrorResource; /* 队列满(真机是 osWaitForever 阻塞,测试里用满队列测 BUSY 分支) */ + } + (void)memcpy(&s_q[(s_q_head + s_q_count) % (int)NS_Q_CAP], msg_ptr, sizeof(net_msg_t)); + s_q_count++; + return osOK; +} + +osStatus_t osMessageQueueGet(osMessageQueueId_t mq_id, void *msg_ptr, + uint8_t *msg_prio, uint32_t timeout) +{ + (void)mq_id; + (void)msg_prio; + + g_ns_get_calls++; + if (s_q_count == 0) { + (void)timeout; + return osErrorTimeout; + } + if (msg_ptr != NULL) { + (void)memcpy(msg_ptr, &s_q[s_q_head], sizeof(net_msg_t)); + } + s_q_head = (s_q_head + 1) % (int)NS_Q_CAP; + s_q_count--; + return osOK; +} + +osKernelState_t osKernelGetState(void) +{ + return g_ns_kernel_state; +} + +osStatus_t osDelay(uint32_t ticks) +{ + static int guard; + + g_ns_tick += ticks; + if (++guard > 20000) { + printf("FATAL: osDelay 跑飞(%d 次)—— 等待条件永不满足\n", guard); + exit(2); + } + if (guard > 0) { + /* 每次 osDelay 也让 HAL_GetTick 往前走,600ms 复位上限才量得到 */ + g_ns_tick += 0U; + } + return osOK; +} + +/* —— FreeRTOS 侧(桩 task.h 声明)—— */ + +TaskHandle_t xTaskGetCurrentTaskHandle(void) +{ + return g_ns_cur_task; +} + +BaseType_t xTaskNotify(TaskHandle_t xTaskToNotify, uint32_t ulValue, + eNotifyAction eAction) +{ + ns_notify_slot_t *p = slot_of(xTaskToNotify); + + (void)eAction; /* 本测试只用 eSetValueWithOverwrite,语义相同 */ + g_ns_notify_calls++; + g_ns_notify_last_val = ulValue; + g_ns_notify_last_to = xTaskToNotify; + if (p == NULL) { + printf("FATAL: xTaskNotify 收到未知任务句柄\n"); + exit(2); + } + p->val = ulValue; + p->pending = 1; + return pdTRUE; +} + +BaseType_t xTaskNotifyWait(uint32_t ulBitsToClearOnEntry, + uint32_t ulBitsToClearOnExit, + uint32_t *pulNotificationValue, + TickType_t xTicksToWait) +{ + ns_notify_slot_t *p = slot_of(g_ns_cur_task); + + if (p == NULL) { + printf("FATAL: xTaskNotifyWait 当前任务句柄未知\n"); + exit(2); + } + if (pulNotificationValue != NULL) { + *pulNotificationValue = p->val & (~ulBitsToClearOnEntry); + } + if (p->pending != 0) { + if (pulNotificationValue != NULL) { + *pulNotificationValue = p->val; + } + p->val = p->val & (~ulBitsToClearOnExit); + p->pending = 0; + return pdTRUE; + } + if (xTicksToWait == 0U) { + return pdFALSE; /* 零等待拾取:没有就是没有 */ + } + + /* 阻塞分支:这就是"睡到 netTask 回执"的调度点 → 驱动一轮 netTask 再拾取 */ + g_ns_block_waits++; + if (g_ns_pump == NULL) { + printf("FATAL: 走到阻塞等待但测试没装 pump 钩子\n"); + exit(2); + } + { + TaskHandle_t save = g_ns_cur_task; + int rounds = 0; + + while (rounds < NS_PUMP_MAX) { + rounds++; + g_ns_pump_calls++; + g_ns_cur_task = (TaskHandle_t)&g_ns_task_net; /* 以 netTask 身份跑 */ + g_ns_pump(); + g_ns_cur_task = save; + if (p->pending != 0) { + break; + } + } + if (p->pending == 0) { + printf("FATAL: 阻塞等待 %d 轮仍无回执(该通知的没通知 = 真机必挂死)\n", rounds); + exit(2); + } + } + if (pulNotificationValue != NULL) { + *pulNotificationValue = p->val; + } + p->val = p->val & (~ulBitsToClearOnExit); + p->pending = 0; + return pdTRUE; +} + +uint32_t xTaskGetTickCount(void) +{ + return g_ns_tick; +} + +int ns_os_crit_depth(void) +{ + return g_ns_crit_enter - g_ns_crit_exit; +} + +void ns_os_enter_critical(void) +{ + g_ns_crit_enter++; +} + +void ns_os_exit_critical(void) +{ + g_ns_crit_exit++; +} + +/* —— main.h 桩:时基 + INT# 引脚 —— */ + +uint32_t HAL_GetTick(void) +{ + return g_ns_tick; +} + +/* INT# 电平可注入:默认 SET(无中断)——net_poll 默认走"引脚高即退出"的短路分支 */ +int g_ns_int_pin_set = 1; +uint32_t g_ns_poll_tick_inc = 1U; +uint32_t g_ns_poll_calls; + +/* 读 INT# 就是"net_poll 又轮了一轮"的天然计数点: + * - 时基自走 g_ns_poll_tick_inc 毫秒(默认 1ms),NET_SEND_SPIN_MS=2 这种 + * "有界自旋"预算才有可量测的时间轴; + * - 跑飞保险:轮询次数超阈值直接 exit(2)。net_send_locked / net_recvfrom 的 + * 等待环里没有 osDelay,光靠 osDelay 的保险挡不住死循环(实测过一次挂门禁)。 */ +GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin) +{ + (void)GPIOx; + (void)GPIO_Pin; + g_ns_poll_calls++; + g_ns_tick += g_ns_poll_tick_inc; + if (g_ns_on_poll != NULL) { + g_ns_on_poll(); /* 并发事件注入点(在时间推进之后,超时判定才量得到) */ + } + if (g_ns_poll_calls > 400000U) { + printf("FATAL: net_poll 轮询 %lu 次仍未收敛(等待条件永不满足)\n", + (unsigned long)g_ns_poll_calls); + exit(2); + } + return (g_ns_int_pin_set != 0) ? GPIO_PIN_SET : GPIO_PIN_RESET; +} diff --git a/test/host/net_socket/ns_s3_msg.c b/test/host/net_socket/ns_s3_msg.c new file mode 100644 index 00000000..5dcb07d5 --- /dev/null +++ b/test/host/net_socket/ns_s3_msg.c @@ -0,0 +1,613 @@ +/* + * 分片 3:跨上下文消息面(net_process_messages 全分派 + 结果经任务通知值回传) + * 前置:net_socket_test.c 已纳入生产源与假内核/假芯片/兄弟模块桩(同一 TU)。 + * + * 这一片是整套里最值钱的部分,钉的是 AGENTS 模块约定的两条硬契约: + * A.「所有 net_* socket 操作经 netMsgQueue 在 netTask 串行执行」 + * B.「net_* 公开 API 的错误码经任务通知值回传:net_msg_t 是按值拷入队列的, + * netTask 改的是自己那份副本,调用方永远看不到 → 错误码搭 xTaskNotify 通知值回来; + * 勿给 net_msg_t 加回传字段(加了只会让调用方读到栈垃圾)」 + * 断言形态因此刻意是:先看**通知值本身**(32 位整体即结果,负值=精确错误码), + * 再看调用方在自己上下文恢复出的 errno——而不是去读队列里的 msg.result + * (那正是旧实现的错觉,也是 B 条明文禁止的方向)。 + * + * 创建日期:2026-10-24 + */ + +/* close 场景的增量快照基准(前面场景也会 clear,必须按增量断言) */ +static int s_close_base_clear; +static int s_close_base_settle; + +/* 造一条已建立的 TCP 会话(多数消息场景的前置) */ +static int mk_established(int type) +{ + int fd = net_socket(NET_AF_INET, type); + net_sock_t *p; + + if (fd < 0) { + return fd; + } + p = &s_net_socks[fd]; + p->state = NET_SOCK_STATE_ESTABLISHED; + p->send_ready = 1U; + p->readable = 0U; + p->local_port = 2404U; + p->remote_port = 51999U; + p->remote_ip = 0x0B01A8C0U; + return fd; +} + +/* TC-NETSOCK-200 未知消息类型:枚举闭合外的值也必须给出**本条消息自身**的确定结果 + * (不许把上一条的回执残留当成这次的结果) */ +static void tc_200_unknown_type(void) +{ + net_msg_t msg; + + bring_up(); + (void)memset(&msg, 0, sizeof(msg)); + msg.type = (net_msg_type_t)999; + msg.sockfd = 0; + msg.caller = (TaskHandle_t)&g_ns_task_caller; + msg.result = 12345; /* 脏值:分派必须覆写它 */ + CHECK(ns_rtos_q_put_raw(&msg) == 0, "TC-NETSOCK-200a 原始消息入队"); + CHECK(g_ns_notify_calls == 0, "TC-NETSOCK-200b 入队本身不产生通知"); + + net_process_messages(); /* 以 netTask 身份跑一轮 */ + + CHECK(g_ns_notify_calls == 1, "TC-NETSOCK-200c 未知 type 仍必发回执(否则调用方永久悬挂)"); + CHECK(g_ns_notify_last_val == (uint32_t)NET_ERR, + "TC-NETSOCK-200d 未知 type 的通知值 = 本条的精确错误码 NET_ERR(不是上一条残留)"); + CHECK(ns_rtos_q_count() == 0, "TC-NETSOCK-200e 本轮队列被排空(非阻塞 while 语义)"); + reset_all(); +} + +/* TC-NETSOCK-201 队列为空:四个自愈 tick 必跑,且不产生任何通知 */ +static void tc_201_ticks_run_before_dispatch(void) +{ + bring_up(); + + net_process_messages(); + + CHECK(g_peers.listener_tick_calls == 1 && g_peers.pending_tick_calls == 1 && + g_peers.send_tick_calls == 1 && g_peers.reconcile_calls == 1, + "TC-NETSOCK-201a 空队列也要跑满四个自愈 tick(D10/pending×2/对账)"); + CHECK(g_ns_notify_calls == 0 && g_ns_get_calls == 1, + "TC-NETSOCK-201b 空队列:取一次即退,不发通知"); + reset_all(); +} + +/* TC-NETSOCK-202 队列句柄未绑定:直接返回,连 tick 都不该跑 */ +static void tc_202_no_queue_bound(void) +{ + reset_all(); + g_ns_pump = net_process_messages; + + net_process_messages(); + + CHECK(g_peers.listener_tick_calls == 0 && g_ns_get_calls == 0, + "TC-NETSOCK-202 未绑定队列就调 net_process_messages → 立即返回(不碰芯片也不跑 tick)"); + reset_all(); +} + +/* TC-NETSOCK-210 SEND 快路径:端到端跑通"入队 → 分派 → 写芯片 → 通知值=字节数" */ +static void tc_210_send_end_to_end(void) +{ + char buf[8]; + int fd; + int rc; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + (void)memset(buf, 'A', sizeof(buf)); + + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + rc = net_send(fd, buf, (int)sizeof(buf), 0); + + CHECK(rc == (int)sizeof(buf), "TC-NETSOCK-210a net_send 返回实际写入字节数"); + CHECK(g_chip.write_calls == 1 && g_chip.last_read_len == (uint16_t)sizeof(buf), + "TC-NETSOCK-210b 一次写满(健康链路不拆块、不进挂起)"); + CHECK(s_net_socks[fd].send_bytes == sizeof(buf), "TC-NETSOCK-210c 发送字节统计累计"); + CHECK(g_ns_notify_calls == 1 && g_ns_notify_last_val == (uint32_t)sizeof(buf), + "TC-NETSOCK-210d 结果经通知值回传(32 位整体即结果)"); + CHECK(g_ns_notify_last_to == (TaskHandle_t)&g_ns_task_caller, + "TC-NETSOCK-210e 通知发给**登记的那个 caller**,不是广播"); + CHECK(g_ns_block_waits == 1, + "TC-NETSOCK-210f 零等待没拾到 → 走阻塞分支(两段式拾取的第二段)"); + reset_all(); +} + +/* TC-NETSOCK-211 大帧分块:超过 NET_SEND_CHUNK_MAX 必须按硬件发送缓冲上界切块 + * (TRAP-NET-16:单次写 >1KB 会写穿 1KB 发送 FIFO) */ +static void tc_211_send_chunking(void) +{ + static char buf[3000]; + int fd; + int rc; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + /* 造"每写完一块芯片就报 SENBUF_FREE"的健康链路:让 net_poll 把 send_ready 置回来 */ + g_ns_int_pin_set = 0; + ch395f_fake_push_glob((uint16_t)(1U << (fd + 4))); + /* SENBUF_FREE 是**条件位**:发送缓冲空着就还会再报。用假件的电平型掩码而不是 + * 一次性位图,否则第二块起就再也等不到空闲 → 变成测假件的清零时机。 */ + ch395f_fake_set_sint_sticky((uint8_t)fd, CH395F_SINT_STAT_SENBUF_FREE); + + rc = net_send(fd, buf, 2500, 0); + + CHECK(rc == 2500, "TC-NETSOCK-211a 2500 字节全部交付"); + CHECK(g_chip.write_calls == 3, "TC-NETSOCK-211b 按 1024 上界切成 3 块(1024/1024/452)"); + CHECK(g_chip.last_read_len == 452U, "TC-NETSOCK-211c 末块只写剩余量"); + CHECK(NET_SEND_CHUNK_MAX == 1024, + "TC-NETSOCK-211d 分块粒度取芯片发送缓冲(不是假件自定的数)"); + reset_all(); +} + +/* TC-NETSOCK-212 SEND 挂起登记:等发送缓冲超预算 → 登记挂起并**跳过即时回执** */ +static void tc_212_send_pending_defers_notify(void) +{ + static char buf[64]; + net_msg_t msg; + int fd; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + s_net_socks[fd].send_ready = 0U; /* 芯片一直不腾缓冲 */ + (void)memset(&msg, 0, sizeof(msg)); + msg.type = NET_MSG_SEND; + msg.sockfd = fd; + msg.buf = buf; + msg.len = (int)sizeof(buf); + msg.caller = (TaskHandle_t)&g_ns_task_caller; + + CHECK(ns_rtos_q_put_raw(&msg) == 0, "TC-NETSOCK-212a SEND 消息入队"); + net_process_messages(); + + CHECK(g_peers.send_reg_calls == 1, + "TC-NETSOCK-212b 超自旋预算后登记挂起 send(而不是继续占着 netTask 忙等)"); + CHECK(g_peers.last_snd_fd == fd && g_peers.last_snd_len == (int)sizeof(buf) && + g_peers.last_snd_sent == 0, + "TC-NETSOCK-212c 登记的进度=已写芯片字节数(此处 0),总长与 caller 一并带走"); + CHECK(g_ns_notify_calls == 0, + "TC-NETSOCK-212d 登记成功**绝不发即时回执**(否则「登记成功」被当成本次结果)"); + CHECK(g_ns_notify_last_to == NULL, + "TC-NETSOCK-212e 一次通知都没发生(0x70000000 哨兵值也没可能漏进通知值)"); + + /* 完成时刻:由挂起 send 的 tick 结束(本套由桩脚本模拟"缓冲腾出来了") */ + ns_peers_arm_send_pending_done(fd, 64); + net_process_messages(); + CHECK(g_ns_notify_calls == 1 && g_ns_notify_last_val == 64U, + "TC-NETSOCK-212f 挂起完成后才发回执,通知值=实际写出字节数"); + reset_all(); +} + +/* TC-NETSOCK-213 SEND 失败:未建立连接 → 通知值带**精确**错误码,不是笼统 -1 */ +static void tc_213_send_error_code_roundtrip(void) +{ + char buf[4]; + net_msg_t msg; + int fd; + int rc; + + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); /* CREATED,未连接 */ + (void)memset(&msg, 0, sizeof(msg)); + msg.type = NET_MSG_SEND; + msg.sockfd = fd; + msg.buf = buf; + msg.len = (int)sizeof(buf); + msg.caller = (TaskHandle_t)&g_ns_task_caller; + (void)ns_rtos_q_put_raw(&msg); + net_process_messages(); + + CHECK(g_ns_notify_last_val == (uint32_t)NET_ERR_NOTCONN, + "TC-NETSOCK-213a 未连接的 SEND:通知值=NET_ERR_NOTCONN(精确码回传,供调用方分支)"); + + /* 端到端:公开 net_send 的 BADF 形态(fd 越界由**分派侧**再判一次) */ + reset_all(); + bring_up(); + (void)memset(&msg, 0, sizeof(msg)); + msg.type = NET_MSG_SEND; + msg.sockfd = 3; /* 合法范围内但未分配 */ + msg.buf = buf; + msg.len = 4; + msg.caller = (TaskHandle_t)&g_ns_task_caller; + (void)ns_rtos_q_put_raw(&msg); + net_process_messages(); + CHECK(g_ns_notify_last_val == (uint32_t)NET_ERR_BADF, + "TC-NETSOCK-213b 范围内但未分配的 fd:net_get_sock 判空 → BADF 搭通知值回来"); + + /* 调用方上下文恢复 errno:拿到的必须是"本任务的"精确码 */ + reset_all(); + bring_up(); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + rc = net_send(6, buf, 4, 0); /* 6 未分配 → 分派侧 BADF → 通知值 -2 */ + CHECK(rc == -1, "TC-NETSOCK-213c 对外保持 BSD 语义:失败一律 -1"); + CHECK(net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-213d 调用方在自己的错误码槽里拿到精确码(E-03 + 通知值回传合力)"); + reset_all(); +} + +/* TC-NETSOCK-220 RECV 快路径:芯片缓冲里已有数据 → 当场读出,不登记挂起 */ +static void tc_220_recv_fast_path(void) +{ + char buf[16]; + int fd; + int rc; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + s_net_socks[fd].readable = 1U; + ch395f_fake_push_recv_len((uint8_t)fd, 1400U); + + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + rc = net_recv(fd, buf, 10, NET_MSG_DONTWAIT); + + CHECK(rc == 10, "TC-NETSOCK-220a 缓冲 1400B、只给 10B 缓冲 → 读满 10(部分读合法)"); + CHECK(g_chip.read_calls == 1 && g_chip.last_read_len == 10U, + "TC-NETSOCK-220b 只读调用方给的量(不得按芯片可用量越界写)"); + CHECK(s_net_socks[fd].recv_bytes == 10U, "TC-NETSOCK-220c 接收字节统计累计"); + CHECK(s_net_socks[fd].readable == 0U, + "TC-NETSOCK-220d 读过即清 readable(下一次 RECV_OK 中断再置)"); + CHECK(g_peers.pending_reg_calls == 0, "TC-NETSOCK-220e 数据已在手,不该登记挂起"); + reset_all(); +} + +/* TC-NETSOCK-221 RECV 非阻塞无数据 → WOULDBLOCK(且不许登记挂起) */ +static void tc_221_recv_wouldblock(void) +{ + char buf[8]; + int fd; + int rc; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + rc = net_recv(fd, buf, (int)sizeof(buf), NET_MSG_DONTWAIT); + CHECK(rc == -1 && net_get_errno() == NET_ERR_WOULDBLOCK, + "TC-NETSOCK-221a DONTWAIT 无数据 → -1 + WOULDBLOCK"); + CHECK(g_peers.pending_reg_calls == 0, + "TC-NETSOCK-221b 非阻塞绝不登记挂起(否则调用方以为自己在等,实际已被挂住)"); + + /* 模式属性(net_setblocking(0))与单次标志同判据 */ + CHECK(net_setblocking(fd, 0) == 0, "TC-NETSOCK-221c 切非阻塞模式成功"); + rc = net_recv(fd, buf, (int)sizeof(buf), 0); + CHECK(rc == -1 && net_get_errno() == NET_ERR_WOULDBLOCK, + "TC-NETSOCK-221d blocking 属性=0 与 NET_MSG_DONTWAIT 等价生效"); + CHECK(net_recv_nb(fd, buf, (int)sizeof(buf)) == -1 && + net_get_errno() == NET_ERR_WOULDBLOCK, + "TC-NETSOCK-221e net_recv_nb 具名入口同路径"); + reset_all(); +} + +/* TC-NETSOCK-222 RECV EOF:会话已结束 + 无数据 → 返 0 并登记重建(不是 -1) */ +static void tc_222_recv_eof_relisten(void) +{ + char buf[8]; + int fd; + int rc; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + s_net_socks[fd].state = NET_SOCK_STATE_CLOSED; + s_net_socks[fd].auto_relisten = 1U; + + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + rc = net_recv(fd, buf, (int)sizeof(buf), NET_MSG_DONTWAIT); + + CHECK(rc == 0, "TC-NETSOCK-222a CLOSED+无数据 → EOF(0),不是错误"); + CHECK(g_peers.listener_req_calls == 1 && + g_peers.last_req_reason == (int)NET_LISTENER_REQ_EOF, + "TC-NETSOCK-222b EOF 登记 REQ_EOF(重建动作交监听层唯一执行者,本层不动芯片)"); + CHECK(g_chip.open_calls == 0 && g_chip.listen_calls == 0 && g_chip.close_calls == 0, + "TC-NETSOCK-222c 登记路径上**一个芯片事务都不发**(单一所有者不变量 II)"); + + /* 抢先 relisten 后 state 已回 LISTENING,但会话电平仍是"已结束" */ + reset_all(); + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + s_net_socks[fd].state = NET_SOCK_STATE_LISTENING; + s_net_socks[fd].session_dead = 1U; + s_net_socks[fd].auto_relisten = 1U; + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + rc = net_recv(fd, buf, (int)sizeof(buf), NET_MSG_DONTWAIT); + CHECK(rc == 0, + "TC-NETSOCK-222d 已 relisten 回 LISTENING 仍判 EOF(N-32 P1 电平化:否则 EOF 永久失踪)"); + + /* auto_relisten=0(客户端 fd):EOF 不登记重建,交调用方决定重连 */ + reset_all(); + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + s_net_socks[fd].state = NET_SOCK_STATE_CLOSED; + s_net_socks[fd].session_dead = 1U; + s_net_socks[fd].auto_relisten = 0U; + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + rc = net_recv(fd, buf, (int)sizeof(buf), NET_MSG_DONTWAIT); + CHECK(rc == 0 && g_peers.listener_req_calls == 0, + "TC-NETSOCK-222e 非监听 fd 的 EOF 不许替调用方擅自重建"); + reset_all(); +} + +/* TC-NETSOCK-223 RECV 阻塞无数据 → 登记挂起 + 跳过即时回执;到点由 tick 结束 */ +static void tc_223_recv_pending_roundtrip(void) +{ + char buf[16]; + net_msg_t msg; + int fd; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + + (void)memset(&msg, 0, sizeof(msg)); + msg.type = NET_MSG_RECV; + msg.sockfd = fd; + msg.buf = buf; + msg.len = (int)sizeof(buf); + msg.flags = 0; + msg.timeout_ms = 250U; /* 显式预算必须原样带到登记处 */ + msg.caller = (TaskHandle_t)&g_ns_task_caller; + (void)ns_rtos_q_put_raw(&msg); + net_process_messages(); + + CHECK(g_peers.pending_reg_calls == 1 && g_peers.last_pend_fd == fd, + "TC-NETSOCK-223a 阻塞无数据 → 登记挂起 recv(netTask 零占用返回)"); + CHECK(g_peers.last_pend_to == 250U, + "TC-NETSOCK-223b 调用方的显式预算随登记带走(0=无限,这里必须不是 0)"); + CHECK(g_peers.last_pend_caller == (TaskHandle_t)&g_ns_task_caller, + "TC-NETSOCK-223c 完成时要知道通知谁"); + CHECK(g_ns_notify_calls == 0, + "TC-NETSOCK-223d 登记即发回执 = 把「登记成功」当结果,本条钉它没发生"); + + /* 数据到达:由挂起 recv 的 tick 结束(脚本模拟) */ + ns_peers_arm_pending_done(fd, 7); + net_process_messages(); + CHECK(g_ns_notify_calls == 1 && g_ns_notify_last_val == 7U, + "TC-NETSOCK-223e 数据到达后才回执,通知值=字节数"); + reset_all(); +} + +/* TC-NETSOCK-224 net_recv_to:分片超时用独立返回码 -2(不靠 errno,避免跨任务竞态) */ +static void tc_224_recv_to_timeout_code(void) +{ + char buf[16]; + int fd; + int rc; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + + /* 先造一条"已登记"的挂起,再让 tick 判到点 */ + ns_peers_arm_pending_done(fd, NET_ERR_TIMEDOUT); + rc = net_recv_to(fd, buf, (int)sizeof(buf), 30U); + CHECK(rc == -2, "TC-NETSOCK-224a 本片超时 → 独立返回码 -2(与真错误 -1 区分)"); + reset_all(); +} + +/* TC-NETSOCK-225 RECV 参数非法:不碰芯片、不登记 */ +static void tc_225_recv_bad_args(void) +{ + char buf[4]; + int fd; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + + CHECK(net_recv_to(0, buf, 4, 0U) == -1 && net_get_errno() == NET_ERR_INVAL, + "TC-NETSOCK-225a net_recv_to(timeout=0) 直接拒——0 在 netTask 侧是「无限等待」," + "与本接口「有界占用」的存在理由相反"); + CHECK(g_ns_put_calls == 0, "TC-NETSOCK-225b 拒绝发生在入队之前"); + + CHECK(net_recv(fd, NULL, 4, NET_MSG_DONTWAIT) == -1 && + net_get_errno() == NET_ERR_INVAL, "TC-NETSOCK-225c buf 为 NULL → INVAL"); + CHECK(net_recv(fd, buf, 0, NET_MSG_DONTWAIT) == -1 && + net_get_errno() == NET_ERR_INVAL, "TC-NETSOCK-225d len<=0 → INVAL"); + CHECK(net_send(fd, NULL, 4, 0) == -1 && net_get_errno() == NET_ERR_INVAL, + "TC-NETSOCK-225e send 的 buf 为 NULL → INVAL"); + CHECK(net_send(fd, buf, -1, 0) == -1 && net_get_errno() == NET_ERR_INVAL, + "TC-NETSOCK-225f send 的 len<0 → INVAL"); + CHECK(g_chip.write_calls == 0 && g_chip.read_calls == 0 && + g_peers.pending_reg_calls == 0 && g_peers.send_reg_calls == 0, + "TC-NETSOCK-225g 参数非法路径上零芯片事务、零挂起登记"); + reset_all(); +} + +/* TC-NETSOCK-230 CLOSE:先结束挂起再关芯片,且 last_peer_port 跨释放保留 */ +static void tc_230_close_order_and_peer(void) +{ + net_msg_t msg; + int fd; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + s_net_socks[fd].auto_relisten = 1U; + /* 登记一条挂起 recv,再看 close 是否先把它结算掉(排空/取消都必通知) */ + (void)memset(&msg, 0, sizeof(msg)); + msg.type = NET_MSG_RECV; + msg.sockfd = fd; + msg.buf = (void *)"x"; + msg.len = 1; + msg.caller = (TaskHandle_t)&g_ns_task_net; /* 另一个任务在等这条流 */ + (void)ns_rtos_q_put_raw(&msg); + net_process_messages(); /* 走登记路径(不发回执) */ + CHECK(g_peers.pending_reg_calls == 1 && g_ns_notify_calls == 0, + "TC-NETSOCK-230a 前置:已登记挂起且未回执"); + + /* 芯片侧此刻仍是 OPEN:close 才需要真去拆(已 CLOSED 的形态见 TC-NETSOCK-232) */ + g_fake.cached_open[fd] = CH395F_SOCKET_OPEN; + g_ns_notify_calls = 0; + { + s_close_base_clear = g_peers.listener_clear_calls; + s_close_base_settle = g_peers.pending_settle_calls; + } + (void)memset(&msg, 0, sizeof(msg)); + msg.type = NET_MSG_CLOSE; + msg.sockfd = fd; + msg.caller = (TaskHandle_t)&g_ns_task_caller; + (void)ns_rtos_q_put_raw(&msg); + net_process_messages(); + + CHECK(g_peers.pending_settle_calls == s_close_base_settle + 1, + "TC-NETSOCK-230b close 必先结算本 fd 的挂起 recv(否则等待方永久悬挂)"); + CHECK(g_peers.pending_notified == 1, + "TC-NETSOCK-230c 结算必通知(取消也要通知,不是静默清槽)"); + CHECK(g_chip.close_calls >= 1 && g_fxchip.rst_begin_calls == 1, + "TC-NETSOCK-230d 芯片仍 OPEN → 走 sock_reset 确定性排渣把它拆掉"); + CHECK(g_fxchip.glob_calls == 0, + "TC-NETSOCK-230e close 全程不得读全局中断寄存器(B3:唯一读者=net_poll)"); + CHECK(s_net_socks[fd].in_use == 0U && s_net_socks[fd].last_peer_port == 51999U, + "TC-NETSOCK-230f 控制块清零但 last_peer_port 保留(幽灵甄别基准属芯片 Socket 级)"); + CHECK(g_peers.listener_clear_calls == s_close_base_clear + 1, + "TC-NETSOCK-230g close 幂等清理监听重试(关闭即取消待重试,D10)"); + CHECK(g_peers.pend_active_now[fd] == 0, + "TC-NETSOCK-230h close 后本 fd 不再有挂起 recv(两张表都归零)"); + reset_all(); +} + +/* TC-NETSOCK-231 CLOSE 未在用:net_get_sock 判空 → BADF 通知值 */ +static void tc_231_close_badfd(void) +{ + net_msg_t msg; + + bring_up(); + (void)memset(&msg, 0, sizeof(msg)); + msg.type = NET_MSG_CLOSE; + msg.sockfd = 5; + msg.caller = (TaskHandle_t)&g_ns_task_caller; + (void)ns_rtos_q_put_raw(&msg); + net_process_messages(); + CHECK(g_ns_notify_last_val == (uint32_t)NET_ERR_BADF, + "TC-NETSOCK-231a close 未分配的 fd → BADF 经通知值回传"); + CHECK(g_chip.close_calls == 0, "TC-NETSOCK-231b BADF 不碰芯片"); + + bring_up(); + (void)memset(&msg, 0, sizeof(msg)); + msg.type = NET_MSG_ACCEPT; /* 没有 in_use 的监听 */ + msg.sockfd = 5; + msg.caller = (TaskHandle_t)&g_ns_task_caller; + (void)ns_rtos_q_put_raw(&msg); + net_process_messages(); + CHECK(g_ns_notify_last_val == (uint32_t)NET_ERR_INVAL, + "TC-NETSOCK-231c accept 未在用 → INVAL(控制块级校验,不是 fd 越界)"); + reset_all(); +} + +/* TC-NETSOCK-240 DROP_CONN:四类前置各走各的分支(幂等语义是关键) */ +static void tc_240_drop_conn_branches(void) +{ + net_msg_t msg; + int fd; + + /* 非在用 → BADF */ + bring_up(); + (void)memset(&msg, 0, sizeof(msg)); + msg.type = NET_MSG_DROP_CONN; + msg.sockfd = 2; + msg.caller = (TaskHandle_t)&g_ns_task_caller; + (void)ns_rtos_q_put_raw(&msg); + net_process_messages(); + CHECK(g_ns_notify_last_val == (uint32_t)NET_ERR_BADF, "TC-NETSOCK-240a 未分配 → BADF"); + + /* UDP → INVAL */ + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + (void)memset(&msg, 0, sizeof(msg)); + msg.type = NET_MSG_DROP_CONN; + msg.sockfd = fd; + msg.caller = (TaskHandle_t)&g_ns_task_caller; + (void)ns_rtos_q_put_raw(&msg); + net_process_messages(); + CHECK(g_ns_notify_last_val == (uint32_t)NET_ERR_INVAL, + "TC-NETSOCK-240b 非 TCP 流 → INVAL(本 API 只拆 TCP 会话)"); + + /* 监听态但无活动会话 → 幂等 0,不动芯片 */ + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + s_net_socks[fd].state = NET_SOCK_STATE_LISTENING; + s_net_socks[fd].auto_relisten = 1U; + (void)memset(&msg, 0, sizeof(msg)); + msg.type = NET_MSG_DROP_CONN; + msg.sockfd = fd; + msg.caller = (TaskHandle_t)&g_ns_task_caller; + (void)ns_rtos_q_put_raw(&msg); + net_process_messages(); + CHECK(g_ns_notify_last_val == 0U, "TC-NETSOCK-240c 无活动会话 → 幂等成功 0"); + CHECK(g_chip.close_calls == 0 && g_fxchip.rst_begin_calls == 0, + "TC-NETSOCK-240d 幂等分支不得拆芯片(会把在听的监听毁掉)"); + + /* ESTABLISHED + 监听型 → QUARANTINE 拆除 + 登记重建 */ + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + s_net_socks[fd].auto_relisten = 1U; + (void)memset(&msg, 0, sizeof(msg)); + msg.type = NET_MSG_DROP_CONN; + msg.sockfd = fd; + msg.caller = (TaskHandle_t)&g_ns_task_caller; + (void)ns_rtos_q_put_raw(&msg); + net_process_messages(); + + CHECK(g_peers.pending_settle_calls == 1 && g_peers.send_cancel_calls == 1, + "TC-NETSOCK-240e 拆会话前先结束两张表的挂起(必通知)"); + CHECK(g_fxchip.rst_begin_calls == 1, + "TC-NETSOCK-240f 拆除走 sock_reset 确定性排渣(不是 close+盲等)"); + CHECK(g_peers.listener_req_calls == 1 && + g_peers.last_req_reason == (int)NET_LISTENER_REQ_DROP, + "TC-NETSOCK-240g 监听型 fd 拆完登记 REQ_DROP(重建交监听层,本层不自己 open)"); + CHECK(s_net_socks[fd].state == NET_SOCK_STATE_CLOSED && + s_net_socks[fd].in_use == 1U && s_net_socks[fd].local_port == 2404U && + s_net_socks[fd].auto_relisten == 1U, + "TC-NETSOCK-240h 只收口会话簿记:fd 保留、监听属性与端口都留下"); + CHECK(g_fxchip.glob_calls == 0, + "TC-NETSOCK-240i 拆除路径也只碰本 Socket(F-NET-7:不读全局)"); + reset_all(); +} + +/* TC-NETSOCK-250 入队失败(队列满)→ 调用方 BUSY,不留下"已提交"的假象 */ +static void tc_250_put_fail_busy(void) +{ + int fd; + int rc; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + ns_rtos_q_fill((int)NS_Q_CAP); /* 把队列灌满 */ + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + + rc = net_send(fd, "abc", 3, 0); + CHECK(rc == -1 && net_get_errno() == NET_ERR_BUSY, + "TC-NETSOCK-250a 队列满 → net_send 报 BUSY(不是静默丢弃)"); + CHECK(g_chip.write_calls == 0 && g_ns_notify_calls == 0, + "TC-NETSOCK-250b 入队失败时零芯片事务、零通知(调用方不会误等回执)"); + CHECK(net_listen(fd) == -1 && net_get_errno() == NET_ERR_BUSY, + "TC-NETSOCK-250c 同一形态对 net_listen 成立"); + CHECK(net_close(fd) == -1 && net_get_errno() == NET_ERR_BUSY, + "TC-NETSOCK-250d 同一形态对 net_close 成立"); + CHECK(net_drop_conn(fd) == -1 && net_get_errno() == NET_ERR_BUSY, + "TC-NETSOCK-250e 同一形态对 net_drop_conn 成立"); + CHECK(net_recv_to(fd, (void *)"x", 1, 5U) == -1 && net_get_errno() == NET_ERR_BUSY, + "TC-NETSOCK-250f 同一形态对 net_recv_to 成立"); + reset_all(); +} + +static void ns_run_s3(void) +{ + tc_200_unknown_type(); + tc_201_ticks_run_before_dispatch(); + tc_202_no_queue_bound(); + tc_210_send_end_to_end(); + tc_211_send_chunking(); + tc_212_send_pending_defers_notify(); + tc_213_send_error_code_roundtrip(); + tc_220_recv_fast_path(); + tc_221_recv_wouldblock(); + tc_222_recv_eof_relisten(); + tc_223_recv_pending_roundtrip(); + tc_224_recv_to_timeout_code(); + tc_225_recv_bad_args(); + tc_230_close_order_and_peer(); + tc_231_close_badfd(); + tc_240_drop_conn_branches(); + tc_250_put_fail_busy(); +} diff --git a/test/host/net_socket/ns_s4_state.c b/test/host/net_socket/ns_s4_state.c new file mode 100644 index 00000000..6ebe7890 --- /dev/null +++ b/test/host/net_socket/ns_s4_state.c @@ -0,0 +1,346 @@ +/* + * 分片 4:net_poll 中断分派、监听/可读闸门、close 与 drop_conn 的拆除序列 + * + * 这一片钉的是 AGENTS 模块约定里**没有文档就无法发现**的那条芯片约束: + * 所有 CH395F 中断在 net_poll() 内经 GET_GLOB_INT_STATUS 集中读取并清除,不得在 + * ISR 或其他地方单独处理。全局寄存器整体读即清、芯片无按位清除手段,而 net_poll + * 只在全局位为 1 时才读该 Socket 的 SINT → 顺手全量清一遍等于把别的在用 Socket 的 + * 门焊死(B3 实证,详见 docs/网络/网络自愈架构设计.md §4.10)。 + * + * 断言手法(刻意不用「读了几次」这种弱口径):GET_SOCK_INT_STATUS 是**读即清**, + * 所以给每个 Socket 预置互不相同的挂起位图,跑一轮 net_poll 后看哪些位图被清掉了 + * ——被清=被读过。这比数调用次数更硬:它直接照出「门有没有被焊死」。 + * + * 创建日期:2026-10-24 + */ + +/* 预置:给每个 Socket 挂一个只有自己才有的 SINT 位图(只用低 6 位,避开保留位) */ +static void prime_sint_masks(void) +{ + int i; + + for (i = 0; i < NET_MAX_SOCKETS; i++) { + g_fake.int_status[i] = (uint8_t)(0x01U << (i % 6)); + } + g_chip.int_status_calls = 0; +} + +/* TC-NETSOCK-300 net_poll 的前置闸门 */ +static void tc_300_poll_gates(void) +{ + /* 1) 未初始化:直接 0,连引脚都不该读 */ + reset_all(); + g_ns_pump = net_process_messages; + CHECK(net_poll() == 0, "TC-NETSOCK-300a 未初始化 → 0 事件"); + CHECK(g_ns_poll_calls == 0U && g_fxchip.glob_calls == 0, + "TC-NETSOCK-300b 未初始化不得读 INT# 引脚、不得读全局中断(省 SPI)"); + + /* 2) 初始化了但 INT# 为高(无中断待处理):不读全局寄存器 */ + bring_up(); + g_ns_int_pin_set = 1; + CHECK(net_poll() == 0, "TC-NETSOCK-300c INT# 高 → 0 事件"); + CHECK(g_fxchip.glob_calls == 0, + "TC-NETSOCK-300d INT# 高时不得发全局中断读取(引脚轮询就是为省这一步)"); + + /* 3) INT# 低但全局状态为 0:读了全局一次,且不进 Socket 循环 */ + bring_up(); + g_ns_int_pin_set = 0; + ch395f_fake_push_glob(0U); + CHECK(net_poll() == 0, "TC-NETSOCK-300e 全局位全 0 → 0 事件"); + CHECK(g_fxchip.glob_calls == 1 && g_chip.int_status_calls == 0, + "TC-NETSOCK-300f 全局只读一次,读到 0 立即收手(不逐个 Socket 扫)"); + reset_all(); +} + +/* TC-NETSOCK-301 门控精度:只读全局位置位的那个 Socket 的 SINT(B3 的正面表述) */ +static void tc_301_poll_only_gated_sockets(void) +{ + int i; + int a; + int b; + + bring_up(); + a = mk_established(NET_SOCK_STREAM); + b = mk_established(NET_SOCK_STREAM); + (void)mk_established(NET_SOCK_STREAM); + CHECK(a == 0 && b == 1, "TC-NETSOCK-301a 前置:三个在用 fd"); + + prime_sint_masks(); + g_ns_int_pin_set = 0; + ch395f_fake_push_glob((uint16_t)(1U << (1 + 4))); /* Socket n 的位 = bit(n+4) */ + + (void)net_poll(); + + CHECK(g_fake.int_status[0] == (uint8_t)(0x01U << 0) && + g_fake.int_status[2] == (uint8_t)(0x01U << 2), + "TC-NETSOCK-301b 未点亮的 Socket 0/2 挂起位图没被清(读即清=读过,没读=还在)"); + CHECK(g_fake.int_status[1] == 0U, "TC-NETSOCK-301c 点亮的 Socket 1 被读走(位图清零)"); + CHECK(g_chip.int_status_calls == 1, "TC-NETSOCK-301d 整轮只发了一次 SINT 读取"); + /* 缓存刷新环是另一回事:它对所有在用 fd 都要刷(单一事实源 I1) */ + CHECK(g_fxchip.rst_poll_calls == 3, + "TC-NETSOCK-301e 每轮对全部 3 个在用 fd 步进 sock_reset(IDLE 槽零 SPI)"); + (void)net_poll(); + CHECK(g_fxchip.rst_poll_calls == 6, + "TC-NETSOCK-301f 再一轮再加 3 次:刷新环不受全局位门控(事件面与状态面分离)"); + reset_all(); + + /* 高位字节:Socket 4~7 的全局位在 bit8~bit11 */ + bring_up(); + for (i = 0; i < 5; i++) { + (void)mk_established(NET_SOCK_STREAM); + } + prime_sint_masks(); + g_ns_int_pin_set = 0; + ch395f_fake_push_glob((uint16_t)(1U << (4 + 4))); + (void)net_poll(); + CHECK(g_fake.int_status[4] == 0U && g_fake.int_status[3] == (uint8_t)(0x01U << 3), + "TC-NETSOCK-301g Socket4~7 用高位字节 bit(n+4)=bit8..11 定位(算错就永远收不到 4~7 的事件)"); + reset_all(); +} + +/* TC-NETSOCK-302 六种 SINT 位各自的分派与事件计数 */ +static void tc_302_poll_event_dispatch(void) +{ + static const uint8_t bits[6] = { + CH395F_SINT_STAT_CONNECT, CH395F_SINT_STAT_DISCONNECT, CH395F_SINT_STAT_RECV_OK, + CH395F_SINT_STAT_SEND_OK, CH395F_SINT_STAT_SOCK_TIMEOUT, CH395F_SINT_STAT_SENBUF_FREE + }; + int k; + int fd; + + for (k = 0; k < 6; k++) { + int expect = (bits[k] == CH395F_SINT_STAT_SENBUF_FREE) ? 0 : 1; + int n; + + reset_all(); + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + s_net_socks[fd].send_ready = 0U; + g_ns_int_pin_set = 0; + g_fake.int_status[fd] = bits[k]; + ch395f_fake_push_glob((uint16_t)(1U << (fd + 4))); + + n = net_poll(); + CHECK(n == expect, "TC-NETSOCK-302a 事件计数按位型区分(SENBUF_FREE 是状态位不是事件)"); + switch (bits[k]) { + case CH395F_SINT_STAT_CONNECT: + CHECK(g_peers.evt_connect_calls == 1, "TC-NETSOCK-302b CONNECT → handle_connect_event"); + break; + case CH395F_SINT_STAT_DISCONNECT: + CHECK(g_peers.evt_disconnect_calls == 1, "TC-NETSOCK-302c DISCONNECT → handle_disconnect_event"); + break; + case CH395F_SINT_STAT_RECV_OK: + CHECK(g_peers.evt_recv_calls == 1 && s_net_socks[fd].readable == 1U, + "TC-NETSOCK-302d RECV_OK → handle_recv_event 置 readable 电平"); + break; + case CH395F_SINT_STAT_SEND_OK: + CHECK(g_peers.evt_send_calls == 1, "TC-NETSOCK-302e SEND_OK → handle_send_event"); + break; + case CH395F_SINT_STAT_SOCK_TIMEOUT: + CHECK(g_peers.evt_timeout_calls == 1, "TC-NETSOCK-302f TIMEOUT → handle_timeout_event"); + break; + default: + CHECK(s_net_socks[fd].send_ready == 1U && g_peers.evt_send_calls == 0, + "TC-NETSOCK-302g SENBUF_FREE 只置 send_ready(不发事件、不误当 SEND_OK)"); + break; + } + CHECK(g_peers.evt_connect_calls + g_peers.evt_disconnect_calls + + g_peers.evt_recv_calls + g_peers.evt_send_calls + + g_peers.evt_timeout_calls <= 1, + "TC-NETSOCK-302h 一位进来只敲一个 handler(不串味)"); + } + + /* 同一次 SINT 里多个位 → 多个 handler 都被叫到,事件数叠加 */ + reset_all(); + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + g_ns_int_pin_set = 0; + g_fake.int_status[fd] = (uint8_t)(CH395F_SINT_STAT_RECV_OK | CH395F_SINT_STAT_DISCONNECT); + ch395f_fake_push_glob((uint16_t)(1U << (fd + 4))); + CHECK(net_poll() == 2, "TC-NETSOCK-302i 同批多事件位:各计一次(不吞后到的那位)"); + CHECK(g_peers.evt_recv_calls == 1 && g_peers.evt_disconnect_calls == 1, + "TC-NETSOCK-302j 两位各归各的 handler"); + reset_all(); +} + +/* TC-NETSOCK-303 PHY 变更位经驱动 feed 落到链路缓存(F-DRV-13 接线) */ +static void tc_303_phy_feed(void) +{ + bring_up(); + g_ns_int_pin_set = 0; + g_fx.phy_status = CH395F_PHY_DISCONN; /* 下一次 feed 读到断开 */ + ch395f_fake_push_glob(CH395F_GINT_STAT_PHY_CHANGE); + (void)net_poll(); + CHECK(g_fake.link_up == 0U && g_fake.link_change == 1U, + "TC-NETSOCK-303 PHY_CHANGE → 驱动重读 PHY 并计一次链路变更(拔网线不再静默)"); + reset_all(); +} + +/* TC-NETSOCK-310 监听门控 net_listener_armed 的三支放行与全部挡下形态 */ +static void tc_310_listener_armed(void) +{ + int fd; + + reset_all(); + CHECK(net_listener_armed(-1) == 0 && net_listener_armed(NET_MAX_SOCKETS) == 0, + "TC-NETSOCK-310a 越界 fd → 0(纯 RAM 读,不发 RPC)"); + CHECK(net_listener_armed(0) == 0, "TC-NETSOCK-310b 未分配的 fd → 0"); + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + CHECK(net_listener_armed(fd) == 1, + "TC-NETSOCK-310c ESTABLISHED(待 accept 电平)必须放行:i104 台架实证不放行则会话整场饿死"); + s_net_socks[fd].state = NET_SOCK_STATE_LISTENING; + CHECK(net_listener_armed(fd) == 1, + "TC-NETSOCK-310d LISTENING 放行:补 accept 探针在 net_accept_locked 内部,挡住永远到不了"); + + s_net_socks[fd].state = NET_SOCK_STATE_CLOSED; + s_net_socks[fd].auto_relisten = 1U; + s_net_socks[fd].local_port = 7003U; + s_net_socks[fd].readable = 1U; + CHECK(net_listener_armed(fd) == 1, + "TC-NETSOCK-310e CLOSED+auto_relisten+有残留数据 → 放行(保住 closed-with-readable 接受)"); + s_net_socks[fd].readable = 0U; + CHECK(net_listener_armed(fd) == 0, + "TC-NETSOCK-310f CLOSED 但无残留 → 必须挡下(否则整个 CLOSED 窗每轮发 RPC → 喂不上狗)"); + s_net_socks[fd].readable = 1U; + s_net_socks[fd].auto_relisten = 0U; + CHECK(net_listener_armed(fd) == 0, "TC-NETSOCK-310g 非监听型 fd 的 CLOSED 不放行"); + s_net_socks[fd].auto_relisten = 1U; + s_net_socks[fd].local_port = 0U; + CHECK(net_listener_armed(fd) == 0, "TC-NETSOCK-310h 还没绑端口的半成品监听不放行"); + s_net_socks[fd].state = NET_SOCK_STATE_CONNECTING; + CHECK(net_listener_armed(fd) == 0, "TC-NETSOCK-310i CONNECTING 不是监听态 → 0"); + reset_all(); +} + +/* TC-NETSOCK-311 recv 门控 net_can_read:非 ESTABLISHED 一律放行 */ +static void tc_311_can_read(void) +{ + int fd; + + reset_all(); + CHECK(net_can_read(0) == 1, + "TC-NETSOCK-311a 描述符失效 → 1:让 recv 去报 BADF,别把连接不存在藏住"); + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + s_net_socks[fd].readable = 0U; + CHECK(net_can_read(fd) == 0, "TC-NETSOCK-311b ESTABLISHED 且无可读 → 0"); + s_net_socks[fd].readable = 1U; + CHECK(net_can_read(fd) == 1, "TC-NETSOCK-311c readable 置位 → 1"); + s_net_socks[fd].state = NET_SOCK_STATE_CLOSED; + s_net_socks[fd].readable = 0U; + CHECK(net_can_read(fd) == 1, + "TC-NETSOCK-311d 连接已非 ESTABLISHED → 必须放行一次 recv 取回 EOF/错误"); + reset_all(); +} + +/* TC-NETSOCK-320 close 的拆除序列:快路径(芯片已 CLOSED)与慢路径(走复位机) */ +static void tc_320_close_two_paths(void) +{ + int fd; + + /* 快路径:芯片已 CLOSED → 只清本 Socket 残留事件,不发复位 */ + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + g_fake.cached_open[fd] = CH395F_SOCKET_CLOSED; + CHECK(net_close(fd) == 0, "TC-NETSOCK-320a close 成功(快路径)"); + CHECK(g_fxchip.cmd_done_calls >= 1 && g_fxchip.rst_begin_calls == 0, + "TC-NETSOCK-320b 已 CLOSED → 跳过复位(省掉 100~600ms 的无占用)"); + CHECK(g_fxchip.sock_events_calls >= 1, + "TC-NETSOCK-320c 快路径仍要清本 Socket 残留事件(防下一连接误判刚建立就断开)"); + CHECK(g_fxchip.glob_calls == 0, "TC-NETSOCK-320d 全程零全局读取(F-NET-7)"); + reset_all(); + + /* 慢路径:芯片还 OPEN → 走 sock_reset,且必须是 GRACEFUL */ + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + g_fake.cached_open[fd] = CH395F_SOCKET_OPEN; + CHECK(net_close(fd) == 0, "TC-NETSOCK-320e close 成功(慢路径)"); + CHECK(g_fxchip.rst_begin_calls == 1 && g_fx.rst_mode[fd] == 0U, + "TC-NETSOCK-320f 慢路径用 GRACEFUL(正常释放 100ms 底),不是 QUARANTINE"); + CHECK(g_fx.rst_state[fd] == (uint8_t)CH395F_FAKE_RST_IDLE, + "TC-NETSOCK-320g 复位机收口回 IDLE(槽位可复用,不搁浅)"); + reset_all(); + + /* 复位启动被拒(槽位已在运行)→ close 仍放行,只留痕 */ + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + g_fake.cached_open[fd] = CH395F_SOCKET_OPEN; + g_fx.rst_state[fd] = (uint8_t)CH395F_FAKE_RST_DRAINED; + CHECK(net_close(fd) == 0, "TC-NETSOCK-320h 复位启动失败也放行 close(不卡调用方)"); + CHECK(g_fxchip.rst_begin_reject == 1 && g_ns_error_count >= 1, + "TC-NETSOCK-320i 复位没成必须 [ERR] 留痕,交 reconciler/pump 对账接管"); + reset_all(); +} + +/* TC-NETSOCK-321 drop_conn 用 QUARANTINE(异常释放语义),且不动监听簿记 */ +static void tc_321_drop_conn_settle(void) +{ + int fd; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + s_net_socks[fd].auto_relisten = 1U; + s_net_socks[fd].readable = 1U; + s_net_socks[fd].lost_conn_seen = 3U; + g_fake.cached_open[fd] = CH395F_SOCKET_OPEN; + + CHECK(net_drop_conn(fd) == 0, "TC-NETSOCK-321a drop_conn 成功"); + CHECK(g_fxchip.rst_begin_calls == 1 && g_fx.rst_mode[fd] == 1U, + "TC-NETSOCK-321b 拆会话用 QUARANTINE 400ms 底(TRAP-NET-18 异常释放,与 close 分级)"); + CHECK(s_net_socks[fd].lost_conn_seen == 0U, + "TC-NETSOCK-321c 新会话起点:事件未收到去抖计数清零(N-30)"); + CHECK(s_net_socks[fd].local_port == 2404U && s_net_socks[fd].auto_relisten == 1U && + s_net_socks[fd].in_use == 1U, + "TC-NETSOCK-321d 端口/监听属性/fd 全部保留:这就是拆连接不拆监听"); + CHECK(g_peers.listener_req_calls == 1 && + g_peers.last_req_reason == (int)NET_LISTENER_REQ_DROP, + "TC-NETSOCK-321e 登记 REQ_DROP,重建动作在 net_listener_tick 一处"); + CHECK(s_net_socks[fd].readable == 0U && s_net_socks[fd].state == NET_SOCK_STATE_CLOSED, + "TC-NETSOCK-321f 收口会话簿记:清 readable + 转 CLOSED"); + reset_all(); +} + +/* TC-NETSOCK-322 close 前必先把挂起结算掉,再动芯片(顺序不可换) */ +static void tc_322_close_before_chip(void) +{ + int fd; + int close_at; + int settle_at; + char rxbuf[4]; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + g_fake.cached_open[fd] = CH395F_SOCKET_OPEN; + /* 先登记一条挂起 recv:走 netTask 直调版 recv(无数据 + 阻塞 → 登记挂起) */ + g_ns_pump = NULL; /* 本场景不靠泵,直接看登记结果 */ + CHECK(net_recv_sock(&s_net_socks[fd], rxbuf, (int)sizeof(rxbuf), 0) + == NET_PENDING_REGISTERED, + "TC-NETSOCK-322p 前置:阻塞 recv 登记了挂起(返回哨兵)"); + ch395f_fake_clear_log(); + settle_at = g_peers.pending_settle_calls; + close_at = g_chip.close_calls; + + CHECK(net_close_sock(&s_net_socks[fd]) == 0, "TC-NETSOCK-322a netTask 直调 close 成功"); + CHECK(g_peers.pending_settle_calls == settle_at + 1, + "TC-NETSOCK-322b close 必先结算本 fd 的挂起 recv"); + CHECK(g_chip.close_calls > close_at || g_fxchip.rst_close_calls > 0, + "TC-NETSOCK-322c 结算之后芯片侧才收到 CLOSE"); + CHECK(g_peers.pending_notified == 1, + "TC-NETSOCK-322d 结算必通知(取消也要通知,不是静默清槽 → 否则等待方永久悬挂)"); + reset_all(); +} + +static void ns_run_s4(void) +{ + tc_300_poll_gates(); + tc_301_poll_only_gated_sockets(); + tc_302_poll_event_dispatch(); + tc_303_phy_feed(); + tc_310_listener_armed(); + tc_311_can_read(); + tc_320_close_two_paths(); + tc_321_drop_conn_settle(); + tc_322_close_before_chip(); +} diff --git a/test/host/net_socket/ns_s5_io.c b/test/host/net_socket/ns_s5_io.c new file mode 100644 index 00000000..b103cecb --- /dev/null +++ b/test/host/net_socket/ns_s5_io.c @@ -0,0 +1,609 @@ +/* + * 分片 5:建流三件套(listen / accept / connect)的锁定版决策 + net_init 下发面 + * + UDP 数据报路径 + netTask 直调入口 + * + * 为什么值得单独一片:这三条是「真机极难按需复现」的决策分支——open 被芯片拒 + * (USE)、LISTEN 命令失败、TCP_CONNECT 被命令级拒绝、CONNECT 事件未收到的补 accept、 + * 对端「发完就 FIN」的 closed-with-readable,在台架上要凑齐对端行为+时序+总线状态 + * 才见得到一次;而每一条的错误形态都是「静默毁掉监听」或「数据被吞」(TRAP-NET-09/ + * -12/-18/-20 与 E14 复核实验的原文结论)。 + * + * 创建日期:2026-10-24 + */ + +/* 断线注入点:第二次轮询时把状态打回 CLOSED(模拟 net_poll 处理 DISCONNECT 之后的形态)。 + * 用假内核现成的「每轮 net_poll 回调」注入并发事件,而不是自己发明时间或事件 API。 */ +static int s_ns_poll_seen; +static void ns_drop_conn_on_second_poll(void) +{ + s_ns_poll_seen++; + if (s_ns_poll_seen == 2) { + s_net_socks[0].state = NET_SOCK_STATE_CLOSED; + s_net_socks[0].session_dead = 1U; + } +} + +/* TC-NETSOCK-400 net_init 的下发面与门控(一次调用钉十条契约) */ +static void tc_400_net_init_downlink(void) +{ + uint8_t mac_zero[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_real[6] = {0x02, 0x11, 0x22, 0x33, 0x44, 0x55}; + int i; + + /* 1) 芯片不在位:直接失败,且不得继续下发任何参数 */ + reset_all(); + g_fx.exist_rc = (int)CH395F_STATUS_NOT_DETECTED; + CHECK(net_init("192.168.1.100", "255.255.255.0", "192.168.1.1", NULL) == -1, + "TC-NETSOCK-400a 芯片不在位 → -1"); + CHECK(g_fxchip.reset_calls == 0 && g_fxchip.init_calls == 0, + "TC-NETSOCK-400b 不在位就不再复位/初始化(别把错误拖到协议栈里)"); + CHECK(g_ns_error_count == 1, "TC-NETSOCK-400c 芯片不在位必留 [ERR](否则只剩端口不通的现象)"); + + /* 2) 协议栈初始化失败 */ + reset_all(); + g_fx.init_rc = (int)CH395F_STATUS_ERROR; + CHECK(net_init("192.168.1.100", "255.255.255.0", "192.168.1.1", NULL) == -1, + "TC-NETSOCK-400d ch395f_init 失败 → -1"); + CHECK(g_fxchip.ping_calls == 0, "TC-NETSOCK-400e 初始化失败后不得继续使能 PING(半配置状态)"); + + /* 3) 版本门控:低于 0x46 必须**拒置** bit3(SOCKET_CLOSE),退回芯片自动关断 */ + reset_all(); + g_fx.version = 0x45U; + CHECK(net_init("192.168.1.100", "255.255.255.0", "192.168.1.1", NULL) == 0, + "TC-NETSOCK-400f 版本不足不是致命错:继续起来"); + CHECK(g_fx.fun_para == 0U && g_ns_error_count == 1, + "TC-NETSOCK-400g ver<0x46 → FUN_PARA 实发 0(bit3 拒置)+ [ERR] 留痕,不静默带病运行"); + + /* 4) 版本够新:FUN_PARA=0x08;bit1(TCP_SERVER 多连接) 与 bit0(保留) 都必须为 0 */ + reset_all(); + CHECK(net_init("192.168.1.100", "255.255.255.0", "192.168.1.1", NULL) == 0, + "TC-NETSOCK-400h 正常初始化成功"); + /* bit3=SOCKET_CLOSE 要置;bit1=TCP_SERVER 多连接(本项目单连接模型)与 + * bit0=保留位都必须为 0——旧实现把 0x09 当「多 Socket 模式」下发,2026-10-11 + * 定案为 0x08(bit0 是保留位,证据链 TRAP-NET-15)。所以掩码要考 bit0|bit1, + * 拿 0x09 考会把我们正想要的 bit3 一起算进去(第一版就这么写错了)。 */ + CHECK(g_fx.fun_para == CH395F_FUN_PARA_SOCKET_CLOSE && + (g_fx.fun_para & 0x03UL) == 0UL, + "TC-NETSOCK-400i FUN_PARA 只置 bit3,bit0/bit1 为 0(0x09 是历史误值,TRAP-NET-15)"); + CHECK(g_fx.mss == (uint16_t)CH395F_TCP_MSS && g_fx.retrans_cnt == 8U && + g_fx.retrans_period == 200U, + "TC-NETSOCK-400j MSS/重传参数取 net_config.h 真值(8 次 / 200ms,P0-a)"); + CHECK(g_fx.ka_idle == 60000U && g_fx.ka_intvl == 5000U && g_fx.ka_cnt == 3U, + "TC-NETSOCK-400k KeepAlive 60s/5s/3(且必须在 ch395f_init 之前下发)"); + CHECK(g_fx.mss != 0U || g_fx.ka_idle != 0U ? 1 : 0, "TC-NETSOCK-400k2 参数确有下发"); + CHECK(g_chip.log_n == 0 || + ch395f_fake_log_find('n') > ch395f_fake_log_find('Q'), + "TC-NETSOCK-400l MSS/KeepAlive 等 SET 类命令排在 INIT_CH395 之前(手册硬约束)"); + + /* 5) IP/掩码/网关三样都按点分顺位下发,并缓存本机 IP 供 getsockname 用 */ + CHECK(g_fx.ip[0] == 192U && g_fx.ip[1] == 168U && g_fx.ip[2] == 1U && + g_fx.ip[3] == 100U && g_fx.gw[3] == 1U && g_fx.mask[0] == 255U, + "TC-NETSOCK-400m IP/网关/掩码按 parse_ip_string 顺位下发(段 0 在 [0])"); + CHECK(g_fxchip.set_dhcp_calls == 0 && g_fx.dhcp_enable == 0U, + "TC-NETSOCK-400n 指定了静态 IP 就绝不使能 DHCP"); + + /* 6) ip=NULL → 走 DHCP,不下发静态三样 */ + reset_all(); + (void)net_init(NULL, NULL, NULL, NULL); + CHECK(g_fxchip.set_dhcp_calls == 1 && g_fx.dhcp_enable == 1U, + "TC-NETSOCK-400o ip=NULL → 使能 DHCP"); + CHECK(g_fxchip.set_ip_calls == 0 && g_fxchip.set_gw_calls == 0 && + g_fxchip.set_mask_calls == 0, "TC-NETSOCK-400p DHCP 形态不下发静态地址"); + + /* 7) MAC:全零=用芯片内置,不下发;非零必须下发(写 EEPROM,须在 INIT 之前) */ + reset_all(); + (void)net_init("192.168.1.100", "255.255.255.0", "192.168.1.1", mac_zero); + CHECK(g_fxchip.set_mac_calls == 0, "TC-NETSOCK-400q MAC 全零 → 保持芯片内置,不下发"); + reset_all(); + (void)net_init("192.168.1.100", "255.255.255.0", "192.168.1.1", mac_real); + CHECK(g_fxchip.set_mac_calls == 1 && g_fx.mac[0] == 0x02U && g_fx.mac[5] == 0x55U, + "TC-NETSOCK-400r 非零 MAC 下发(设备在线身份与 UI 显示一致)"); + CHECK(ch395f_fake_log_find('M') < ch395f_fake_log_find('n') || + ch395f_fake_log_find('M') < 0, + "TC-NETSOCK-400s SET_MAC 排在 INIT_CH395 **之前**(TRAP-NET-19:顺序反了本次上电不生效)"); + + /* 8) 收发缓冲块布局:6 块/Socket,先配数据 Socket 再配监听 Socket 0(TRAP-NET-01) */ + reset_all(); + (void)net_init("192.168.1.100", "255.255.255.0", "192.168.1.1", NULL); + CHECK(g_fxchip.set_recv_buf_calls == NET_MAX_SOCKETS && + g_fxchip.set_send_buf_calls == NET_MAX_SOCKETS, + "TC-NETSOCK-400t 8 个 Socket 全部显式重分配(上电默认只给 0~3)"); + for (i = 0; i < NET_MAX_SOCKETS; i++) { + if (g_fx.recv_blk_start[i] != (uint8_t)(i * 6) || + g_fx.recv_blk_cnt[i] != (uint8_t)CH395F_RECV_BLOCKS || + g_fx.send_blk_start[i] != (uint8_t)(i * 6 + (int)CH395F_RECV_BLOCKS) || + g_fx.send_blk_cnt[i] != (uint8_t)CH395F_SEND_BLOCKS) { + break; + } + } + CHECK(i == NET_MAX_SOCKETS, + "TC-NETSOCK-400u 块布局 recv=i*6 / send=recv+4,Socket7 恰好用尽 0~47(手册 §5.45)"); + CHECK(g_fx.ping_enable == 1U && g_fxchip.ping_calls == 1, "TC-NETSOCK-400v 使能 PING 响应"); + + /* 9) net_init 的第一件事是取消挂起并通知调用方(顺序错了 = 调用方永久挂死) */ + reset_all(); + (void)net_init("192.168.1.100", "255.255.255.0", "192.168.1.1", NULL); + CHECK(g_peers.pending_cancel_all_calls == 1 && + g_peers.send_cancel_all_calls == 1, + "TC-NETSOCK-400w 重建前两张挂起表都要取消(recv/send 各一张,漏一张就有人永远等)"); + CHECK(g_peers.last_pend_cancel_err == NET_ERR_CONNRESET && + g_peers.last_snd_cancel_err == NET_ERR_CONNRESET, + "TC-NETSOCK-400x 取消原因 = CONNRESET"); + CHECK(g_peers.pending_capture_calls == 1, + "TC-NETSOCK-400y 登记执行任务(挂起登记的自等死锁守卫据此判「谁在执行」)"); + reset_all(); +} + +/* TC-NETSOCK-410 net_listen_locked:open 重试、清事件时序、失败对称关断 */ +static void tc_410_listen_locked(void) +{ + struct net_sockaddr_in in; + int fd; + + /* 非 TCP 流 → INVAL */ + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + CHECK(net_listen_locked(fd) == -1 && net_get_errno() == NET_ERR_INVAL, + "TC-NETSOCK-410a UDP 不能 listen"); + + /* 状态不对(还没 bind 也没到 CREATED)→ INVAL */ + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + CHECK(net_listen_locked(fd) == -1 && net_get_errno() == NET_ERR_INVAL, + "TC-NETSOCK-410b 已建立的 fd 再 listen → INVAL(不许把数据通道打回监听)"); + + /* 成功路径:BOUND → LISTENING,并启用 auto_relisten */ + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + (void)memset(&in, 0, sizeof(in)); + in.sin_port = net_htons(2404); + CHECK(net_bind(fd, (struct net_sockaddr *)&in, (int)sizeof(in)) == 0, "TC-NETSOCK-410c bind 前置"); + ch395f_fake_clear_log(); + CHECK(net_listen_locked(fd) == 0, "TC-NETSOCK-410d listen 成功"); + CHECK(s_net_socks[fd].state == NET_SOCK_STATE_LISTENING && + s_net_socks[fd].auto_relisten == 1U && s_net_socks[fd].lost_conn_seen == 0U, + "TC-NETSOCK-410e 进 LISTENING + auto_relisten=1(BSD 监听常驻)+ 去抖计数清零"); + CHECK(g_chip.set_port_calls == 1 && g_chip.last_port == 2404U, + "TC-NETSOCK-410f 源端口取控制块的 local_port(端口不能漂)"); + CHECK(ch395f_fake_log_find('i') >= 0 && + ch395f_fake_log_find('i') < ch395f_fake_log_find('l'), + "TC-NETSOCK-410g 清本 Socket 事件排在 TCP_LISTEN **之前**(E14:反了会吞掉新 CONNECT)"); + CHECK(g_fxchip.glob_calls == 0, + "TC-NETSOCK-410h listen 全程不得读全局中断寄存器(B3 审计掉的正是这个)"); + + /* open 一直失败:重试 3 次,每次都走确定性排渣,最后 BUSY */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + g_fake.open_rc[fd] = CH395F_ERR_USE; + CHECK(net_listen_locked(fd) == -1 && net_get_errno() == NET_ERR_BUSY, + "TC-NETSOCK-410i open 三次仍失败 → BUSY"); + CHECK(g_chip.open_calls == 3 && g_fxchip.rst_begin_calls == 3, + "TC-NETSOCK-410j 三次 open + 三次 sock_reset(TRAP-NET-09 的重试语义保留,不是盲等 500ms)"); + CHECK(g_chip.listen_calls == 0, "TC-NETSOCK-410k open 没成绝不发 LISTEN 命令"); + + /* LISTEN 命令失败:open 已成的 Socket 必须关回去(U12 对称,否则卡 OPEN 无监听) */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + g_fake.listen_rc[fd] = CH395F_ERR_VAL; + CHECK(net_listen_locked(fd) == -1 && net_get_errno() == NET_ERR_BUSY, + "TC-NETSOCK-410l TCP_LISTEN 失败 → BUSY"); + CHECK(g_chip.close_calls == 1 && g_chip.open_calls == 1, + "TC-NETSOCK-410m LISTEN 失败必须 close 已开的 Socket(否则下次 open 恒返 USE)"); + CHECK(s_net_socks[fd].state != NET_SOCK_STATE_LISTENING, + "TC-NETSOCK-410n 失败不得谎报 LISTENING"); + reset_all(); +} + +/* TC-NETSOCK-411 公开 net_listen:越界 / 队列 / 结果恢复 */ +static void tc_411_listen_public(void) +{ + struct net_sockaddr_in in; + int fd; + + bring_up(); + CHECK(net_listen(-1) == -1 && net_get_errno() == NET_ERR_BADF, "TC-NETSOCK-411a 越界 → BADF"); + CHECK(net_listen(NET_MAX_SOCKETS) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-411b 上界越界 → BADF"); + + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + (void)memset(&in, 0, sizeof(in)); + in.sin_port = net_htons(7003); + CHECK(net_bind(fd, (struct net_sockaddr *)&in, (int)sizeof(in)) == 0, "TC-NETSOCK-411c bind"); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + CHECK(net_listen(fd) == 0, "TC-NETSOCK-411d 端到端 listen 成功(入队→分派→通知值 0)"); + CHECK(s_net_socks[fd].state == NET_SOCK_STATE_LISTENING, + "TC-NETSOCK-411e 公开入口确实驱动了芯片侧监听"); + CHECK(g_ns_notify_last_val == 0U, + "TC-NETSOCK-411f 成功的 listen 通知值=0(哨兵 0x70000000 不许漏进来)"); + + /* 已监听的 fd 再 listen:netTask 里 INVAL,经通知值回传 */ + CHECK(net_listen(fd) == -1 && net_get_errno() == NET_ERR_INVAL, + "TC-NETSOCK-411g 重复 listen → -1 且 errno=INVAL(精确码,不是笼统 -1)"); + reset_all(); +} + +/* TC-NETSOCK-420 accept:单连接模型的四种形态 */ +static void tc_420_accept_shapes(void) +{ + struct net_sockaddr_in in; + struct net_sockaddr *sa = (struct net_sockaddr *)∈ + int addrlen; + int fd; + + /* 1) 已有可接受连接:返回的就是监听 fd 自身(单连接),并回填对端 */ + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + addrlen = (int)sizeof(in); + (void)memset(&in, 0, sizeof(in)); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + CHECK(net_accept(fd, sa, &addrlen) == fd, "TC-NETSOCK-420a 单连接:accept 返回监听 fd 自身"); + CHECK(in.sin_family == NET_AF_INET && net_ntohs(in.sin_port) == 51999U && + addrlen == (int)sizeof(in), + "TC-NETSOCK-420b 回填对端地址与长度(lftpd 打印「谁连进来」就靠这个)"); + CHECK(g_peers.lost_probe_calls == 0, + "TC-NETSOCK-420c 非 LISTENING 不触发补 accept 探针(探针是监听态专用自救)"); + + /* 2) LISTENING 且探针命中 → 接受(CONNECT 事件未收到的自愈路径) */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + s_net_socks[fd].state = NET_SOCK_STATE_LISTENING; + s_net_socks[fd].local_port = 2404U; + s_net_socks[fd].remote_port = 55555U; + g_peers.lost_probe_hit = 1; + addrlen = 0; + CHECK(net_accept(fd, NULL, NULL) == fd, + "TC-NETSOCK-420d 探针把监听转 ESTABLISHED → accept 立即成功(N-30 主根因修复)"); + CHECK(g_peers.lost_probe_calls == 1, + "TC-NETSOCK-420e LISTENING 才调探针(每轮 accept 一次,带去抖计数)"); + + /* 3) LISTENING 且无连接 → WOULDBLOCK,且不动监听重建 */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + s_net_socks[fd].state = NET_SOCK_STATE_LISTENING; + addrlen = (int)sizeof(in); + CHECK(net_accept(fd, sa, &addrlen) == -1 && net_get_errno() == NET_ERR_WOULDBLOCK, + "TC-NETSOCK-420f 无连接 → WOULDBLOCK(不阻塞 netTask)"); + CHECK(g_peers.listener_req_calls == 0, + "TC-NETSOCK-420g 监听还活着时绝不登记重建(那是 U12 事故的形态)"); + + /* 4) CLOSED + auto_relisten + RX 有残留 → 接受"已关但可读",先排空再 EOF */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + s_net_socks[fd].state = NET_SOCK_STATE_CLOSED; + s_net_socks[fd].auto_relisten = 1U; + s_net_socks[fd].local_port = 21U; + s_net_socks[fd].remote_port = 44444U; + ch395f_fake_push_recv_len((uint8_t)fd, 1027U); + addrlen = (int)sizeof(in); + CHECK(net_accept(fd, sa, &addrlen) == fd, + "TC-NETSOCK-420h closed-with-readable:接受这条连接去排空(N-30 定案,反了就是 425)"); + CHECK(g_peers.listener_req_calls == 0, + "TC-NETSOCK-420i 有残留数据时**不**登记重建(先 relisten 会把已到达的数据销毁)"); + CHECK(g_chip.get_status_calls == 0, + "TC-NETSOCK-420j 排空判据只读 GET_RECV_LEN,不发状态查询"); + + /* 5) CLOSED + auto_relisten + RX 空 → 登记 REQ_ACCEPT 并 WOULDBLOCK */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + s_net_socks[fd].state = NET_SOCK_STATE_CLOSED; + s_net_socks[fd].auto_relisten = 1U; + s_net_socks[fd].local_port = 21U; + CHECK(net_accept(fd, sa, &addrlen) == -1 && net_get_errno() == NET_ERR_WOULDBLOCK, + "TC-NETSOCK-420k CLOSED 且无残留 → WOULDBLOCK"); + CHECK(g_peers.listener_req_calls == 1 && + g_peers.last_req_reason == (int)NET_LISTENER_REQ_ACCEPT && + g_peers.last_req_fd == fd, + "TC-NETSOCK-420l 消费者撞空才登记即时重建(N-32 P2:删掉它会拉长 CLOSED 窗)"); + CHECK(g_chip.open_calls == 0 && g_chip.listen_calls == 0, + "TC-NETSOCK-420m 登记路径零芯片事务(重建动作只在 net_listener_tick 一处)"); + + /* 6) 非监听型 CLOSED(客户端)→ 不擅自登记 */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + s_net_socks[fd].state = NET_SOCK_STATE_CLOSED; + s_net_socks[fd].auto_relisten = 0U; + CHECK(net_accept(fd, sa, &addrlen) == -1 && g_peers.listener_req_calls == 0, + "TC-NETSOCK-420n 客户端 fd 的 CLOSED 不登记监听重建"); + reset_all(); +} + +/* TC-NETSOCK-421 accept 的公开入口边界 */ +static void tc_421_accept_public(void) +{ + struct net_sockaddr_in in; + struct net_sockaddr *sa = (struct net_sockaddr *)∈ + int addrlen; + int fd; + + bring_up(); + CHECK(net_accept(-1, sa, &addrlen) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-421a 越界 → BADF"); + fd = mk_established(NET_SOCK_STREAM); + addrlen = 0; + CHECK(net_accept(fd, sa, NULL) == fd, + "TC-NETSOCK-421b addrlen 为 NULL 仍可 accept(addr 回填被跳过,不崩)"); + addrlen = (int)sizeof(in); + CHECK(net_accept(fd, NULL, &addrlen) == fd, + "TC-NETSOCK-421c addr 为 NULL:只要 fd,不回填地址"); + reset_all(); +} + +/* TC-NETSOCK-430 connect:地址簿记、open 重试、命令被拒回滚 */ +static void tc_430_connect(void) +{ + struct net_sockaddr_in in; + int fd; + + bring_up(); + CHECK(net_connect(-1, NULL, 0) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-430a 越界 → BADF"); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + CHECK(net_connect(fd, NULL, (int)sizeof(in)) == -1 && net_get_errno() == NET_ERR_INVAL, + "TC-NETSOCK-430b addr=NULL → INVAL(入队之前就把参数挡下)"); + CHECK(net_connect(fd, (struct net_sockaddr *)&in, 1) == -1 && + net_get_errno() == NET_ERR_INVAL, "TC-NETSOCK-430c addrlen 不足 → INVAL"); + CHECK(ns_rtos_q_count() == 0, "TC-NETSOCK-430d 参数非法不产生消息(不占 netTask)"); + + /* 成功:地址簿记 + 大端顺位 + 状态 CONNECTING */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + (void)memset(&in, 0, sizeof(in)); + in.sin_family = NET_AF_INET; + in.sin_port = net_htons(8888); + in.sin_addr.s_addr = net_inet_addr("192.168.1.200"); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + CHECK(net_connect(fd, (struct net_sockaddr *)&in, (int)sizeof(in)) == 0, + "TC-NETSOCK-430e connect 命令下发成功(异步,成败由中断裁定)"); + CHECK(s_net_socks[fd].state == NET_SOCK_STATE_CONNECTING, + "TC-NETSOCK-430f 本地状态转 CONNECTING"); + CHECK(s_net_socks[fd].remote_port == 8888U, + "TC-NETSOCK-430g 对端端口转主机序存簿记"); + CHECK(g_fx.des_ip[fd][0] == 192U && g_fx.des_ip[fd][1] == 168U && + g_fx.des_ip[fd][2] == 1U && g_fx.des_ip[fd][3] == 200U, + "TC-NETSOCK-430h 目的 IP 按大端数组下发(set_des_ip 要的顺位,反了就连不上)"); + CHECK(g_chip.set_port_calls == 1 && g_fx.des_port[fd] == 8888U, + "TC-NETSOCK-430i 源端口下发一次(SET_SOUR_PORT),目的端口另走 SET_DES_PORT=8888"); + CHECK(g_fxchip.sock_events_calls == 1 && g_fxchip.glob_calls == 0, + "TC-NETSOCK-430j 清残留中断只清本 Socket(F-NET-7:原「再读清全局」会焊死别人)"); + CHECK(ch395f_fake_log_find('v') < ch395f_fake_log_find('T') || + ch395f_fake_log_find('v') < 0, + "TC-NETSOCK-430k 清事件排在 TCP_CONNECT 之前(TRAP-NET-12/-18 的两级预清)"); + + /* 命令级被拒(非 BUSY):必须回滚,不能停在 CONNECTING 干等 */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + g_fx.connect_rc[fd] = CH395F_ERR_VAL; + CHECK(net_connect(fd, (struct net_sockaddr *)&in, (int)sizeof(in)) == -1 && + net_get_errno() == NET_ERR_INVAL, "TC-NETSOCK-430l 芯片拒命令 → -1"); + CHECK(g_chip.close_calls == 1, + "TC-NETSOCK-430m 被拒要按手册「再次连接需从 OPEN 重开」关掉 Socket"); + CHECK(s_net_socks[fd].session_dead == 1U && + s_net_socks[fd].state == NET_SOCK_STATE_CLOSED, + "TC-NETSOCK-430n 命令级失败同样是本会话结束:置 session_dead+CLOSED(B2:否则挂起 recv 干等)"); + CHECK(g_ns_error_count >= 1, "TC-NETSOCK-430o 被拒必留 [ERR]"); + + /* BUSY 只是命令仍在执行,不得判失败 */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + g_fx.connect_rc[fd] = CH395F_ERR_BUSY; + CHECK(net_connect(fd, (struct net_sockaddr *)&in, (int)sizeof(in)) == 0, + "TC-NETSOCK-430p BUSY=芯片还在执行 → 维持原行为交给中断裁定(慢芯片不该被误杀)"); + CHECK(s_net_socks[fd].state == NET_SOCK_STATE_CONNECTING && g_chip.close_calls == 0, + "TC-NETSOCK-430q BUSY 不回滚、不关 Socket"); + + /* open 三次失败 → BUSY,且不发 CONNECT */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + g_fake.open_rc[fd] = CH395F_ERR_USE; + CHECK(net_connect(fd, (struct net_sockaddr *)&in, (int)sizeof(in)) == -1 && + net_get_errno() == NET_ERR_BUSY, "TC-NETSOCK-430r open 三次失败 → BUSY"); + CHECK(g_chip.open_calls == 3 && g_fxchip.connect_calls == 0, + "TC-NETSOCK-430s open 没成不发 TCP_CONNECT"); + + /* 非 TCP 流 / 未在用 → INVAL(netTask 侧再判一次) */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + CHECK(net_connect_locked(fd, (const struct net_sockaddr *)&in, (int)sizeof(in)) == -1 && + net_get_errno() == NET_ERR_INVAL, "TC-NETSOCK-430t UDP 槽位不走 TCP connect"); + CHECK(net_connect_locked(7, (const struct net_sockaddr *)&in, (int)sizeof(in)) == -1 && + net_get_errno() == NET_ERR_INVAL, + "TC-NETSOCK-430u 范围内但未在用 → INVAL(in_use 与控制块一起考;BADF 只考越界)"); + CHECK(net_connect_locked(NET_MAX_SOCKETS, (const struct net_sockaddr *)&in, + (int)sizeof(in)) == -1, "TC-NETSOCK-430v 越界 → BADF"); + reset_all(); +} + +/* TC-NETSOCK-440 UDP 数据报:sendto 分块 / recvfrom 剥 8 字节报文头 */ +static void tc_440_udp_datagram(void) +{ + static char big[2100]; + char buf[64]; + struct net_sockaddr_in in; + struct net_sockaddr *sa = (struct net_sockaddr *)∈ + int addrlen; + int fd; + + bring_up(); + CHECK(net_sendto(-1, "x", 1, 0, NULL, 0) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-440a sendto 越界 → BADF"); + CHECK(net_sendto(0, "x", 1, 0, NULL, 0) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-440b 未在用 → BADF"); + CHECK(net_recvfrom(0, buf, sizeof(buf), 0, NULL, NULL) == -1 && + net_get_errno() == NET_ERR_BADF, "TC-NETSOCK-440c recvfrom 未在用 → BADF"); + + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + CHECK(net_sendto(fd, NULL, 5, 0, NULL, 0) == -1 && net_get_errno() == NET_ERR_INVAL, + "TC-NETSOCK-440d sendto 参数非法 → INVAL"); + CHECK(net_recvfrom(fd, NULL, 5, NET_MSG_DONTWAIT, NULL, NULL) == -1 && + net_get_errno() == NET_ERR_INVAL, "TC-NETSOCK-440e recvfrom buf 为空 → INVAL"); + + /* 带目的地址:先设 DesIP/DesPort 再写数据(不带上限的自旋实现,健康链路一次写完) */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + (void)memset(&in, 0, sizeof(in)); + in.sin_port = net_htons(8888); + in.sin_addr.s_addr = net_inet_addr("192.168.1.9"); + CHECK(net_sendto(fd, "hello", 5, 0, sa, (int)sizeof(in)) == 5, + "TC-NETSOCK-440f sendto 成功返回字节数"); + CHECK(g_fx.des_ip[fd][3] == 9U && g_fx.des_port[fd] == 8888U, + "TC-NETSOCK-440g 目的地址随本次发送下发(UDP 无连接,地址是每包带的)"); + CHECK(g_chip.write_calls == 1 && s_net_socks[fd].send_bytes == 5U, + "TC-NETSOCK-440h 小包一次写完 + 统计"); + + /* 大报分块(仍受 NET_SEND_CHUNK_MAX 上界约束) */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + g_ns_int_pin_set = 0; + ch395f_fake_push_glob((uint16_t)(1U << (fd + 4))); + ch395f_fake_set_sint_sticky((uint8_t)fd, CH395F_SINT_STAT_SENBUF_FREE); + CHECK(net_sendto(fd, big, 2100, 0, NULL, 0) == 2100, "TC-NETSOCK-440i 2100B 全部交付"); + CHECK(g_chip.write_calls == 3, "TC-NETSOCK-440j UDP 也按 1024 上界分块(写穿 FIFO 的约束同 TCP)"); + + /* recvfrom:前 8 字节是芯片 UDP 头(长度/srcPort/srcIP),要剥掉再交出去 */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + ch395f_fake_push_recv_len((uint8_t)fd, 8U + 20U); + addrlen = (int)sizeof(in); + (void)memset(&in, 0, sizeof(in)); + CHECK(net_recvfrom(fd, buf, (int)sizeof(buf), 0, sa, &addrlen) == 20, + "TC-NETSOCK-440k 返回 20:报文头 8 字节被剥掉,不把头部当数据交给应用"); + CHECK(addrlen == (int)sizeof(in) && in.sin_family == NET_AF_INET, + "TC-NETSOCK-440l 回填源地址结构与长度"); + + /* 无数据 + 非阻塞 → WOULDBLOCK(阻塞形态是自旋无预算,本套刻意不测,见交付说明) */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + CHECK(net_recvfrom(fd, buf, (int)sizeof(buf), NET_MSG_DONTWAIT, NULL, NULL) == -1 && + net_get_errno() == NET_ERR_WOULDBLOCK, "TC-NETSOCK-440m 非阻塞无数据 → WOULDBLOCK"); + s_net_socks[fd].blocking = 0U; + CHECK(net_recvfrom(fd, buf, (int)sizeof(buf), 0, NULL, NULL) == -1 && + net_get_errno() == NET_ERR_WOULDBLOCK, "TC-NETSOCK-440n 非阻塞属性同判据生效"); + /* 短于 8 字节的残包:不剥头,整包交出去(钉当前实现的边界,不猜"应该怎么算") */ + reset_all(); + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + ch395f_fake_push_recv_len((uint8_t)fd, 5U); + CHECK(net_recvfrom(fd, buf, (int)sizeof(buf), 0, NULL, NULL) == 5, + "TC-NETSOCK-440o recv_len<8 时不剥头,整包返回(当前实现)"); + reset_all(); +} + +/* TC-NETSOCK-450 netTask 直调入口的 NULL 守卫与委托 */ +static void tc_450_direct_entries(void) +{ + bring_up(); + CHECK(net_send_sock(NULL, "x", 1) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-450a net_send_sock(NULL) → BADF"); + CHECK(net_recv_sock(NULL, (void *)"x", 1, 0) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-450b net_recv_sock(NULL) → BADF"); + CHECK(net_close_sock(NULL) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-450c net_close_sock(NULL) → BADF"); + + /* netTask 内直调 send:挂起登记会被"执行者自等待"守卫拒 → 返回 -1(BUSY) */ + reset_all(); + bring_up(); + { + int fd = mk_established(NET_SOCK_STREAM); + + s_net_socks[fd].send_ready = 0U; + g_ns_cur_task = (TaskHandle_t)&g_ns_task_net; + g_peers.send_register_rc = -1; /* 复刻守卫:netTask 自己发的必被拒 */ + CHECK(net_send_sock(&s_net_socks[fd], "hello", 5) == -1, + "TC-NETSOCK-450d netTask 内等不到缓冲时不登记挂起(没人能替它完成)→ -1"); + CHECK(g_peers.send_reg_calls == 1, + "TC-NETSOCK-450e 确实走到了登记调用(被拒的是登记侧,不是本层没试)"); + } + reset_all(); +} + +/* TC-NETSOCK-460 复位收口超时:芯片永远关不掉时上层必须有界退出(不得无限占用 netTask) */ +static void tc_460_reset_sync_timeout_bound(void) +{ + uint32_t t_before; + int fd; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + g_fake.cached_open[fd] = CH395F_SOCKET_OPEN; + g_fx.rst_keep_open[fd] = 1U; /* 复位机怎么努力芯片都不落回 CLOSED */ + t_before = g_ns_tick; + + CHECK(net_close(fd) == 0, + "TC-NETSOCK-460a 芯片关不掉也照样放行 close(fd 侧簿记必须继续收口)"); + CHECK((g_ns_tick - t_before) <= 700U, + "TC-NETSOCK-460b 复位等待有 600ms 硬上限(无上限=netTask 被一颗坏芯片拖死)"); + CHECK(g_ns_error_count >= 1, + "TC-NETSOCK-460c 复位超时必 [ERR]:这条 [ERR] 是唯一能发现它的信号"); + CHECK(s_net_socks[fd].in_use == 0U, + "TC-NETSOCK-460d 超时也照常释放槽位(不因芯片状态卡住本地 fd 表)"); + reset_all(); + + /* drop_conn 同一条超时线 */ + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + s_net_socks[fd].auto_relisten = 1U; + g_fake.cached_open[fd] = CH395F_SOCKET_OPEN; + g_fx.rst_keep_open[fd] = 1U; + t_before = g_ns_tick; + CHECK(net_drop_conn(fd) == 0 && (g_ns_tick - t_before) <= 700U && + g_ns_error_count >= 1, + "TC-NETSOCK-460e drop_conn 也走同一条有界收口并留痕"); + reset_all(); +} + +/* TC-NETSOCK-470 发送途中的并发事件:断线要按短写交出已发量,不能假装全成功 */ +static void tc_470_send_disconnect_midway(void) +{ + static char big[2100]; + int fd; + int rc; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + /* 第一块写得进去,第二块等待期间对端断线(net_poll 处理 DISCONNECT 把状态打回 CLOSED) */ + g_ns_int_pin_set = 0; + ch395f_fake_push_glob((uint16_t)(1U << (fd + 4))); + g_fake.int_status[fd] = CH395F_SINT_STAT_DISCONNECT; + g_ns_on_poll = ns_drop_conn_on_second_poll; + s_ns_poll_seen = 0; + + rc = net_send(fd, big, 2100, 0); + + CHECK(rc >= 0 && rc < 2100, + "TC-NETSOCK-470a 断线只截断剩余量:按已发数返回(短写),不是 -1 也不是谎报 2100"); + CHECK((int)s_net_socks[fd].send_bytes == rc, + "TC-NETSOCK-470b 字节统计与实际交付一致(对端已收到的不能回收)"); + CHECK(g_ns_error_count >= 1, + "TC-NETSOCK-470c 短写必 [ERR](静默截断曾让 UI 推流被对端误判为全程无字节)"); + reset_all(); +} + +static void ns_run_s5(void) +{ + tc_400_net_init_downlink(); + tc_410_listen_locked(); + tc_411_listen_public(); + tc_420_accept_shapes(); + tc_421_accept_public(); + tc_430_connect(); + tc_440_udp_datagram(); + tc_450_direct_entries(); + tc_460_reset_sync_timeout_bound(); + tc_470_send_disconnect_midway(); +} diff --git a/test/host/net_socket/ns_s6_edges.c b/test/host/net_socket/ns_s6_edges.c new file mode 100644 index 00000000..9afe7053 --- /dev/null +++ b/test/host/net_socket/ns_s6_edges.c @@ -0,0 +1,395 @@ +/* + * 分片 6:边界残支(公开入口的越界/队满、直调入口的参数支、等待环里的其余出口) + * + * 这一片不为「覆盖率好看」而写:每条都对应一条**能被改坏**的分支——越界检查挪到 + * 入队之后,调用方就会在 netTask 里等到一个没人处理的 BADF;短写留痕被删掉, + * 上层就会像 2026-09-21 那次 UI 推流一样「对端永久等待剩余字节」而日志全白。 + * 唯一一条刻意**不是**真实时序的场景是 TC-NETSOCK-530(一次轮询之间被压了 1s), + * 它在真机上的对应形态是 netTask 被更高优先级任务压住——用大步进时基表达, + * 并在注释里写明它考的是「停滞超时先于自旋预算生效」这条判定顺序。 + * + * 创建日期:2026-10-24 + */ + +/* TC-NETSOCK-500 六个公开入口的越界形态:BADF 必须在**入队之前**由调用方侧挡下 */ +static void tc_500_public_bounds(void) +{ + struct net_sockaddr_in in; + char buf[8]; + int addrlen; + + bring_up(); + (void)memset(&in, 0, sizeof(in)); + addrlen = (int)sizeof(in); + + CHECK(net_send(NET_MAX_SOCKETS, buf, 4, 0) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-500a net_send 越界 → BADF"); + CHECK(net_recv(NET_MAX_SOCKETS, buf, 4, NET_MSG_DONTWAIT) == -1 && + net_get_errno() == NET_ERR_BADF, "TC-NETSOCK-500b net_recv 越界 → BADF"); + CHECK(net_recv_to(NET_MAX_SOCKETS, buf, 4, 10U) == -1 && + net_get_errno() == NET_ERR_BADF, "TC-NETSOCK-500c net_recv_to 越界 → BADF"); + CHECK(net_close(NET_MAX_SOCKETS) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-500d net_close 越界 → BADF"); + CHECK(net_drop_conn(NET_MAX_SOCKETS) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-500e net_drop_conn 越界 → BADF"); + CHECK(net_accept(NET_MAX_SOCKETS, (struct net_sockaddr *)&in, &addrlen) == -1 && + net_get_errno() == NET_ERR_BADF, "TC-NETSOCK-500f net_accept 越界 → BADF"); + CHECK(net_connect(NET_MAX_SOCKETS, (struct net_sockaddr *)&in, (int)sizeof(in)) == -1 && + net_get_errno() == NET_ERR_BADF, "TC-NETSOCK-500g net_connect 越界 → BADF"); + CHECK(net_sendto(NET_MAX_SOCKETS, buf, 4, 0, NULL, 0) == -1 && + net_get_errno() == NET_ERR_BADF, "TC-NETSOCK-500h net_sendto 越界 → BADF"); + CHECK(net_recvfrom(NET_MAX_SOCKETS, buf, 4, NET_MSG_DONTWAIT, NULL, NULL) == -1 && + net_get_errno() == NET_ERR_BADF, "TC-NETSOCK-500i net_recvfrom 越界 → BADF"); + CHECK(g_ns_put_calls == 0, + "TC-NETSOCK-500j 全部越界形态零入队(入队之后才挡 = 让 netTask 处理一个没主的请求)"); + reset_all(); +} + +/* TC-NETSOCK-501 队满时每个公开入口都要报 BUSY(不能有的报、有的静默) */ +static void tc_501_all_entries_busy_on_full_queue(void) +{ + struct net_sockaddr_in in; + int fd; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + + ns_rtos_q_fill((int)NS_Q_CAP); + CHECK(net_accept(fd, NULL, NULL) == -1 && net_get_errno() == NET_ERR_BUSY, + "TC-NETSOCK-501a net_accept 队满 → BUSY"); + ns_rtos_q_fill((int)NS_Q_CAP); + (void)memset(&in, 0, sizeof(in)); + CHECK(net_connect(fd, (struct net_sockaddr *)&in, (int)sizeof(in)) == -1 && + net_get_errno() == NET_ERR_BUSY, "TC-NETSOCK-501b net_connect 队满 → BUSY"); + ns_rtos_q_fill((int)NS_Q_CAP); + CHECK(net_recv_to(fd, (void *)"x", 1, 20U) == -1 && net_get_errno() == NET_ERR_BUSY, + "TC-NETSOCK-501c net_recv_to 队满 → BUSY"); + ns_rtos_q_fill((int)NS_Q_CAP); + CHECK(net_recv(fd, (void *)"x", 1, NET_MSG_DONTWAIT) == -1 && + net_get_errno() == NET_ERR_BUSY, "TC-NETSOCK-501d net_recv 队满 → BUSY"); + ns_rtos_q_fill((int)NS_Q_CAP); + CHECK(net_send(fd, "x", 1, 0) == -1 && net_get_errno() == NET_ERR_BUSY, + "TC-NETSOCK-501e net_send 队满 → BUSY"); + CHECK(g_ns_notify_calls == 0, "TC-NETSOCK-501f 队满路径零通知(调用方直接拿返回值走人)"); + reset_all(); +} + +/* TC-NETSOCK-502 fd 表用尽:NOMEM 且不影响已建立的会话 */ +static void tc_502_socket_table_full(void) +{ + int i; + int fd; + + bring_up(); + for (i = 0; i < NET_MAX_SOCKETS; i++) { + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + if (fd < 0) { + break; + } + } + CHECK(i == NET_MAX_SOCKETS, "TC-NETSOCK-502a 8 个槽位全部可分配"); + CHECK(net_socket(NET_AF_INET, NET_SOCK_STREAM) == -1 && + net_get_errno() == NET_ERR_NOMEM, "TC-NETSOCK-502b 第 9 个 → NOMEM(不越界写表)"); + s_net_socks[3].state = NET_SOCK_STATE_ESTABLISHED; + CHECK(net_get_sock(3) != NULL && net_listener_armed(3) == 1, + "TC-NETSOCK-502c 表满不影响已在用会话(alloc 失败只回 NOMEM,不动别人的槽位)"); + reset_all(); +} + +/* TC-NETSOCK-503 net_wait_result 的两段式拾取:先零等待,已有通知就不必再睡 */ +static void tc_503_zero_wait_pickup(void) +{ + int fd; + int rc; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + /* 预置一条**已挂起**的通知(真机上=上一条消息的回执还没被读走), + * 公开入口应先零等待拾取它,而不是再睡一次(睡过头就是 实测踩过的坑) */ + ns_rtos_post_notify((TaskHandle_t)&g_ns_task_caller, (uint32_t)(int)4); + g_ns_block_waits = 0; + + rc = net_send(fd, "abcd", 4, 0); + + CHECK(rc == 4, "TC-NETSOCK-503a 零等待就拾到通知值 → 直接返回结果"); + CHECK(g_ns_block_waits == 0, "TC-NETSOCK-503b 已有通知时绝不进阻塞分支"); + CHECK(g_peers.send_reg_calls == 0 && g_chip.write_calls == 0, + "TC-NETSOCK-503c 本次调用没有真的写芯片(拾到的是上一条消息的回执——这就是 实测踩过的形态)"); + reset_all(); +} + +/* TC-NETSOCK-504 直调入口的参数支(这些分支从公开入口不可达,只能直接钉) */ +static void tc_504_locked_entry_branches(void) +{ + struct net_sockaddr_in in; + int fd; + + bring_up(); + (void)memset(&in, 0, sizeof(in)); + in.sin_port = net_htons(80); + in.sin_addr.s_addr = net_inet_addr("10.1.1.1"); + + fd = net_socket(NET_AF_INET, NET_SOCK_STREAM); + CHECK(net_connect_locked(fd, NULL, (int)sizeof(in)) == -1 && + net_get_errno() == NET_ERR_INVAL, "TC-NETSOCK-504a connect_locked addr=NULL → INVAL"); + CHECK(net_connect_locked(fd, (const struct net_sockaddr *)&in, 1) == -1 && + net_get_errno() == NET_ERR_INVAL, "TC-NETSOCK-504b connect_locked addrlen 不足 → INVAL"); + s_net_socks[fd].in_use = 1U; + CHECK(net_send_locked(&s_net_socks[fd], "x", 1, NULL) == -1 && + net_get_errno() == NET_ERR_NOTCONN, "TC-NETSOCK-504c 未建立的直调 send → NOTCONN"); + CHECK(net_recv_locked(&s_net_socks[fd], (void *)"x", 1, NET_MSG_DONTWAIT, 0U, NULL) == -1 && + net_get_errno() == NET_ERR_WOULDBLOCK, "TC-NETSOCK-504d 直调 recv 无数据非阻塞 → WOULDBLOCK"); + CHECK(net_recv_locked(&s_net_socks[fd], NULL, 1, NET_MSG_DONTWAIT, 0U, NULL) == -1 && + net_get_errno() == NET_ERR_INVAL, "TC-NETSOCK-504e 直调 recv buf 空 → INVAL"); + reset_all(); +} + +/* TC-NETSOCK-505 发送途中缓冲再也不腾出来:内层等待也要转挂起,并且末块不许登记 */ +static void tc_505_inner_wait_and_last_chunk(void) +{ + static char big[2100]; + int fd; + int rc; + + /* 第一块写得进、之后芯片不再报空闲 → 第二块的**内层**等待转挂起 */ + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + g_ns_int_pin_set = 0; + ch395f_fake_push_glob((uint16_t)(1U << (fd + 4))); + g_fake.int_status[fd] = CH395F_SINT_STAT_SENBUF_FREE; /* 只给一次空闲 */ + rc = net_send_locked(&s_net_socks[fd], big, 2100, + (TaskHandle_t)&g_ns_task_caller); + CHECK(rc == NET_PENDING_REGISTERED, + "TC-NETSOCK-505a 内层等待(写完一块后等发完)超预算也转挂起,不留 in-place 忙等"); + CHECK(g_chip.write_calls == 2 && g_peers.last_snd_sent == 2048, + "TC-NETSOCK-505b 登记的进度=已写进芯片的字节数(1024+1024=2048),已发的绝不重发"); + reset_all(); + + /* 末块:全部写完但芯片不回执 → 按完成返回,**不许**登记挂起 + * (2026-10-14 台架回归 TC-NET-902 正是登记了却无人完成 → 永久挂死) */ + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + rc = net_send_locked(&s_net_socks[fd], big, 1024, + (TaskHandle_t)&g_ns_task_caller); + CHECK(rc == 1024, "TC-NETSOCK-505c 单块恰好写完 → 返回 1024(完成)"); + CHECK(g_peers.send_reg_calls == 0, + "TC-NETSOCK-505d 末块等待不得登记挂起(sent>=len 的守卫必须挡住)"); + reset_all(); +} + +/* TC-NETSOCK-506 UDP sendto:等待腾出与短写留痕(与 TCP 同一套语义的另一份实现) */ +static void tc_506_sendto_stall_and_short(void) +{ + static char big[2100]; + int fd; + int rc; + + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + s_net_socks[fd].send_ready = 0U; + g_ns_int_pin_set = 0; + ch395f_fake_push_glob((uint16_t)(1U << (fd + 4))); + /* 每轮只走 1ms:先命中 NET_SEND_TIMEOUT_MS(1s) 之前不会有别出口 → 短写 */ + g_ns_poll_tick_inc = 500U; + rc = net_sendto(fd, big, 2100, 0, NULL, 0); + CHECK(rc < 2100 && rc >= 0, + "TC-NETSOCK-506a sendto 停滞只拿到部分:按已发数返回(短写语义与 TCP 一致)"); + CHECK(g_ns_error_count >= 2, + "TC-NETSOCK-506b 停滞 [ERR] + 短写 [ERR] 都要留(缺一就退化成静默截断)"); + reset_all(); + + /* 写完一块后等发完的超时出口 */ + bring_up(); + fd = net_socket(NET_AF_INET, NET_SOCK_DGRAM); + g_ns_int_pin_set = 0; + ch395f_fake_push_glob((uint16_t)(1U << (fd + 4))); + g_fake.int_status[fd] = CH395F_SINT_STAT_SENBUF_FREE; /* 只给一次 */ + g_ns_poll_tick_inc = 500U; + rc = net_sendto(fd, big, 2100, 0, NULL, 0); + CHECK(rc >= 1024 && rc < 2100, + "TC-NETSOCK-506c 第一块已交付、第二块等发完超时 → 返回 1024..2099 之间的实际值"); + reset_all(); +} + +/* TC-NETSOCK-530 停滞超时优先于自旋预算:考判定顺序,不考真实时长 */ +static void tc_530_stall_timeout_before_spin(void) +{ + static char buf[64]; + int fd; + int rc; + + /* 一次轮询之间被压了 2s(真机形态:netTask 被更高优先级任务压住), + * 停滞超时(NET_SEND_TIMEOUT_MS=1s)必须**先于**自旋预算(2ms)命中: + * 生产代码里两条判定的先后顺序就是这条断言的内容。 */ + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + s_net_socks[fd].send_ready = 0U; + g_ns_poll_tick_inc = 2000U; + + rc = net_send_locked(&s_net_socks[fd], buf, (int)sizeof(buf), + (TaskHandle_t)&g_ns_task_caller); + CHECK(rc == 0, + "TC-NETSOCK-530a 停滞超时按已发数 0 返回(短写形态,不是 -1)"); + CHECK(g_peers.send_reg_calls == 0, + "TC-NETSOCK-530b 停滞超时优先:不进挂起登记(判定顺序被换掉的话这里会登记)"); + CHECK(net_get_errno() == NET_ERR_TIMEDOUT, + "TC-NETSOCK-530c errno = TIMEDOUT(供调用方区分停滞与断线)"); + reset_all(); +} + +/* TC-NETSOCK-535 已经写出去一块之后的超时收口(内层等待的另一条出口) */ +static void tc_535_inner_timeout_after_chunk(void) +{ + static char big[2100]; + int fd; + int rc; + + /* 第一块写得进(send_ready 初值 1),第二块的**内层**等待直接撞上停滞超时 + * (判定顺序:先超时后自旋,所以这里收口在超时支而不是登记挂起) */ + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + g_ns_poll_tick_inc = 2000U; + + rc = net_send_locked(&s_net_socks[fd], big, 2100, + (TaskHandle_t)&g_ns_task_caller); + + CHECK(rc == 1024, "TC-NETSOCK-535a 已交付的第一块照样交出(1024),剩余按短写处理"); + CHECK(g_peers.send_reg_calls == 0, + "TC-NETSOCK-535b 停滞超时收口在登记挂起**之前**(换序就会登记一条永远完不成的挂起)"); + CHECK(g_chip.write_calls == 1, "TC-NETSOCK-535c 只写了一次芯片:不得把已交付的块重发"); + reset_all(); +} + +/* TC-NETSOCK-536 net_recv_to 的三条回传分支(-2 / -1+errno / >=0) */ +static void tc_536_recv_to_three_exits(void) +{ + char buf[16]; + int fd; + + /* >=0:正常拿到数据 */ + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + s_net_socks[fd].readable = 1U; + ch395f_fake_push_recv_len((uint8_t)fd, 12U); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + CHECK(net_recv_to(fd, buf, (int)sizeof(buf), 100U) == 12, + "TC-NETSOCK-536a 有数据 → 返回实际字节数(走 >=0 支)"); + + /* <0 且非 TIMEDOUT:分派侧 BADF → 恢复 errno 后返回 -1 */ + reset_all(); + bring_up(); + (void)net_socket(NET_AF_INET, NET_SOCK_STREAM); /* 占掉 fd0,用未分配的 fd1 */ + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + CHECK(net_recv_to(1, buf, (int)sizeof(buf), 100U) == -1 && + net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-536b 真错误 → -1 且 errno 从通知值恢复(与 -2 严格区分)"); + + /* ==TIMEDOUT:已在 TC-NETSOCK-224 钉过 -2;这里补一条「-2 不改写 errno」 */ + reset_all(); + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + (void)net_set_errno(NET_OK); + ns_peers_arm_pending_done(fd, NET_ERR_TIMEDOUT); + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + CHECK(net_recv_to(fd, buf, (int)sizeof(buf), 30U) == -2 && + net_get_errno() == NET_OK, + "TC-NETSOCK-536c 超时只凭返回码判定,**不改写**错误码槽(本分支刻意不设 errno)"); + reset_all(); +} + +/* TC-NETSOCK-537 fire_event:注册了回调才会被叫到(本层唯一的回调触发点在 SEND_OK) */ +static void tc_537_event_callback_path(void) +{ + int fd; + + bring_up(); + fd = mk_established(NET_SOCK_STREAM); + /* handle_send_event 是兄弟模块(net_events)的职责,本层只负责把中断交给它; + * 回调是否触发属 net_events 的断言面(其单测 TC-NETEV 已覆盖),此处只钉 + * 「net_poll 不因回调未注册而跳过事件」。 */ + s_net_socks[fd].event_cb = NULL; + g_ns_int_pin_set = 0; + ch395f_fake_push_glob((uint16_t)(1U << (fd + 4))); + g_fake.int_status[fd] = CH395F_SINT_STAT_SEND_OK; + CHECK(net_poll() == 1, "TC-NETSOCK-537 未注册回调也照常计事件(不因空指针跳过分发)"); + reset_all(); +} + +/* TC-NETSOCK-540 close / drop_conn 的失败结果也要经通知值恢复精确 errno */ +static void tc_540_negative_results(void) +{ + net_msg_t msg; + int fd; + + /* close 的负结果支:net_close_locked 自身恒返 0(复位没成也放行,见 TC-NETSOCK-320h), + * 所以 CLOSE 唯一的负值来自**分派侧**的 net_get_sock 判空 → 通知值 -2 → + * 调用方在自己的槽位里恢复成 BADF。范围合法但未分配的 fd 正好走这条。 */ + bring_up(); + fd = 3; /* 范围内、未分配 */ + g_ns_cur_task = (TaskHandle_t)&g_ns_task_caller; + CHECK(net_close(fd) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-540a close 未分配的 fd → -1 且 errno 从通知值恢复成 BADF(不是笼统 NET_ERR)"); + CHECK(net_drop_conn(fd) == -1 && net_get_errno() == NET_ERR_BADF, + "TC-NETSOCK-540a2 drop_conn 同一条负值恢复支"); + reset_all(); + + /* drop_conn 同理:用未分配 fd 走分派侧 BADF 后再由调用方恢复 */ + bring_up(); + (void)memset(&msg, 0, sizeof(msg)); + msg.type = NET_MSG_DROP_CONN; + msg.sockfd = 1; + msg.caller = (TaskHandle_t)&g_ns_task_net; + (void)ns_rtos_q_put_raw(&msg); + net_process_messages(); + CHECK(g_ns_notify_last_val == (uint32_t)NET_ERR_BADF, + "TC-NETSOCK-540b drop_conn 分派侧 BADF 经通知值回传"); + + /* RECV 的 BADF 支(分派侧 net_get_sock 判空) */ + reset_all(); + bring_up(); + (void)memset(&msg, 0, sizeof(msg)); + msg.type = NET_MSG_RECV; + msg.sockfd = 4; + msg.buf = (void *)"x"; + msg.len = 1; + msg.caller = (TaskHandle_t)&g_ns_task_net; + (void)ns_rtos_q_put_raw(&msg); + net_process_messages(); + CHECK(g_ns_notify_last_val == (uint32_t)NET_ERR_BADF, + "TC-NETSOCK-540c recv 分派侧 BADF 同样经通知值回传"); + reset_all(); +} + +/* TC-NETSOCK-545 未绑队列时的 sendto/recvfrom/getpeername 等纯本地入口 */ +static void tc_545_local_entries_without_queue(void) +{ + struct net_sockaddr_in in; + int addrlen = (int)sizeof(in); + + reset_all(); + CHECK(net_getsockname(0, (struct net_sockaddr *)&in, &addrlen) == -1 && + net_get_errno() == NET_ERR_BADF, "TC-NETSOCK-545a 未初始化也能安全报 BADF(不崩)"); + CHECK(net_getpeername(0, (struct net_sockaddr *)&in, &addrlen) == -1, + "TC-NETSOCK-545b getpeername 同上"); + CHECK(net_inet_addr("1.2.3.4") != 0U, "TC-NETSOCK-545c 纯函数不依赖初始化"); + reset_all(); +} + +static void ns_run_s6(void) +{ + tc_500_public_bounds(); + tc_501_all_entries_busy_on_full_queue(); + tc_502_socket_table_full(); + tc_503_zero_wait_pickup(); + tc_504_locked_entry_branches(); + tc_505_inner_wait_and_last_chunk(); + tc_506_sendto_stall_and_short(); + tc_530_stall_timeout_before_spin(); + tc_535_inner_timeout_after_chunk(); + tc_536_recv_to_three_exits(); + tc_537_event_callback_path(); + tc_540_negative_results(); + tc_545_local_entries_without_queue(); +} diff --git a/test/host/net_socket/run_unit_test.py b/test/host/net_socket/run_unit_test.py new file mode 100644 index 00000000..357dfa6f --- /dev/null +++ b/test/host/net_socket/run_unit_test.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""NET 套接字核心(net_socket)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/net_socket/net_socket_test.c(以 #include 纳入生产源 + Drivers/BSP/NET/net_socket.c + 共享假芯片 test/host/common/ch395f_fake.c + + 本目录的假内核 ns_rtos.c 与兄弟模块桩 ns_peers.c),在 PC 上离线验证: + 跨上下文消息全分派、结果经任务通知值回传、每任务错误码槽(E-03)、 + 监听门控与 closed-with-readable、net_poll 的中断分派与"只读自己 SINT", + 以及读写路径的短写 / 挂起登记 / EOF / 命令被拒回滚。 + 工具链与判定委托 test/host/common/unit_test_harness.py。 + +先跑常量闸(本 runner 独有的第二道检查,见 check_fake_chip_constants 注释), +不干净直接不编译——编译错误会把"假件值漂移"这件事埋得很深。 + +为什么要主机侧单测(而不是只用台架): + net_socket.c 是本产品**唯一的 SPI 串行化点**:所有 net_* 公开 API 把请求按值拷进 + 消息队列,由 netTask 串行执行,结果只能经 xTaskNotify 的 32 位通知值回来 + (net_msg_t 是按值入队的,netTask 改自己那份副本,调用方永远看不到——AGENTS + 模块约定明写"勿给 net_msg_t 加回传字段")。这条链的正确性取决于两个任务的 + 交错时序,真机上"通知丢了"表现为永久挂死并被看门狗复位,日志里只剩一条 + [ERR];而错误码本身还按任务隔离(E-03),跨任务覆写只会让排障读到别人的码。 + 把队列/通知/时基做成显式量后,"登记了挂起却仍发即时回执"这种缺陷在 PC 上 + 一次就能钉死。 + +桩的边界(每一层为什么这么切): + - 真实头:net_socket.h / net_types.h / net_config.h / net_pending.h / + net_listener.h / net_events.h / cmsis_os2.h(只依赖 stdint+stddef,宿主可编) + → 字段名、枚举取值、队列原型一改即编译期报错; + - 假芯片:test/host/common/ch395f_fake.h/.c(NET 域共享件,本批只做**纯新增**扩展: + net_socket.c 这一层才会调的初始化下发类 + 全局中断 0x19 + sock_reset 复位机 + + cmd_done 判据;既有宏/字段/函数一字未动); + - 假内核:本目录 ns_rtos.c(osMessageQueue 环形队列 + 任务通知 + 时基 + + 临界区计数);**阻塞等待 = 调度点**:xTaskNotifyWait(portMAX_DELAY) 里以 + netTask 身份跑一轮 net_process_messages(),于是单次公开 API 调用能跑完整条 + 入队→分派→通知值回传→调用方恢复 errno 的链; + - 兄弟模块桩:本目录 ns_peers.c(net_listener/net_pending/net_events 的边界契约 + + 调用记录)——这三个模块各有自己的主机单测(34/34/43 条),本套只测 + net_socket.c 自己的决策,并把"该不该登记、登记哪个 reason"变成可断言面; + - main.h / FreeRTOS.h / task.h / dbg_log.h / test_config.h / net_latency_test.h + 是本目录桩;刻意不定义 TEST_LATENCY_PROBE 与 NET_BENCH_CMD_LOG。 + - 不需要 #undef s_addr:本套不 include ff.h(唯一会经 Windows 支路拉进 winsock.h + 并把 s_addr 定义成 S_un.S_addr 的来源),真实 net_types.h 用的是自有的 + struct net_in_addr 镜像类型,不受污染。 + +用法(仓库根目录): + python test/host/net_socket/run_unit_test.py + +退出码:0=PASS / 1=测试失败(含常量闸不过)/ 2=环境错误。 + +设计依据:docs/网络/网络自愈架构设计.md §4.10、docs/测试说明/主机单测覆盖率口径.md §9 +创建日期:2026-10-24 +""" +import os +import re +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 / FreeRTOS.h / task.h / main.h / dbg_log.h); +# CMSIS_RTOS_V2 提供**真实** cmsis_os2.h;common 提供共享假芯片;NET 目录给真实 +# net_socket.h / net_types.h / net_pending.h / net_listener.h / net_events.h;HERE 放本模块头。 +BUILD = TestBuild( + name="NET Socket Tests", + src=os.path.join(HERE, "net_socket_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "Middlewares", "Third_Party", "FreeRTOS", "Source", + "CMSIS_RTOS_V2"), + os.path.join(ROOT, "test", "host", "common"), + os.path.join(ROOT, "Drivers", "BSP", "NET"), + HERE, + ], + # /wd4244:生产源里 ch395f_set_*(sockfd, ...) 一律传 int fd 给 uint8_t 形参, + # ARMCC 不报、MSVC /W4 每条都报一遍(20+ 条)——那是宿主与原编译器的诊断差异, + # 不是本批要盯的问题,屏蔽以保持门禁输出可读(不改生产代码去"消警")。 + extra_flags=["/wd4244", "/wd4100"], + summary_marker="=== NET Socket Tests:", +) + + +# --------------------------------------------------------------------------- +# 常量闸:真头与假件的**同名** CH395F_* 常量数值必须逐一对齐 +# --------------------------------------------------------------------------- +# +# 为什么必须有这根闸(2026-10-24 实测漂移):假件头自称"值抄自真实头,勿顺手改", +# 实际 CH395F_SOCKET_OPEN 写成 0x01(真值 0x05)、CH395F_TCP_ESTABLISHED 写成 0x05 +# (真值 0x04)。host 编译里生产码看到的也是假宏,宏对宏自洽 → 三套全绿也照不出来。 +# 谁一旦写**数字**断言或从假件抄常量,就拿到了与真芯片不同的值。 +# +# 为什么只能文本核对:两个头在 host 编译里是互斥的(stub/ch395f.h 转发到假件), +# 跨头 _Static_assert 不可能同时看到两份定义 → 编译期闸不存在。 +# +# 只做同名常量的数值比对,且**只比对两边都是纯十六进制/十进制字面量的**: +# 宏链(CH395F_SEND_BUF_SIZE_BYTES 那种引用其它宏的)跳过——它们由结构而不是值约束。 +_DEFINE_RE = re.compile(r"^\s*#\s*define\s+(CH395F_[A-Z0-9_]+)\s+([^\s/]+)") +_NUM_RE = re.compile(r"^(0[xX][0-9a-fA-F]+|[0-9]+)(U|UL|ULL)?$") + + +def parse_consts(path): + """{CH395F_xxx: int},只收字面量取值的宏;文件不存在返回 None。""" + if not os.path.isfile(path): + return None + out = {} + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + for line in f: + m = _DEFINE_RE.match(line) + if not m: + continue + name, val = m.group(1), m.group(2) + if not _NUM_RE.match(val): + continue + digits = re.sub(r"[uUlL]+$", "", val) + try: + num = int(digits, 0) + except ValueError: + continue + out.setdefault(name, (num, line.rstrip("\n"))) + except OSError: + return None + return out + + +def check_fake_chip_constants(verbose=True): + """返回不一致的 (名字, 真头值, 假件值) 列表;两侧任一头读不到按环境问题报告。""" + real = parse_consts(os.path.join(ROOT, "Drivers", "BSP", "CH395F", "ch395f.h")) + fake = parse_consts(os.path.join(HERE, os.pardir, "common", "ch395f_fake.h")) + if real is None: + return None + if fake is None: + return None + drift = [] + for name, (val, _line) in sorted(real.items()): + if name in fake and fake[name][0] != val: + drift.append((name, val, fake[name][0])) + if verbose and drift: + print("[DRIFT] 假件常量与真实 ch395f.h 不一致(host 编译里宏对宏自洽,照不出来):", + file=sys.stderr) + for name, rv, fv in drift: + print(" {} 真=0x{:02X} 假=0x{:02X}".format(name, rv, fv), file=sys.stderr) + return drift + + +if __name__ == "__main__": + drift = check_fake_chip_constants() + if drift is None: + print("[ENV] 常量闸无法读取真头或假件头(路径不对?)", file=sys.stderr) + sys.exit(2) + if drift: + print("[FAIL] 常量闸拦下 {} 处漂移:修 test/host/common/ch395f_fake.h 到真值" + "(出处见 Drivers/BSP/CH395F/ch395f.h 与 docs/芯片手册/CH395F.md)" + .format(len(drift)), file=sys.stderr) + sys.exit(1) + print("[OK] 常量闸:假件 CH395F_* 常量与真实 ch395f.h 同名项全对齐") + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/net_socket/stub/FreeRTOS.h b/test/host/net_socket/stub/FreeRTOS.h new file mode 100644 index 00000000..dd46b89e --- /dev/null +++ b/test/host/net_socket/stub/FreeRTOS.h @@ -0,0 +1,40 @@ +/* + * 模块名称:主机侧单测桩 - FreeRTOS.h(net_socket 模块专用) + * 模块功能:给宿主一份与 FreeRTOS 语义等价的类型定义。net_socket.c 用到 TaskHandle_t / + * TickType_t / BaseType_t / eNotifyAction / pdTRUE / portMAX_DELAY,真实那份 + * 经 portmacro.h 拉编译器与芯片寄存器,PC 上编不动。 + * ⚠ 枚举成员与取值照抄 Middlewares/.../include/task.h:92-97 与 projdefs.h:44 + * (eSetBits=0 起、pdTRUE=1、pdFALSE=0),改了就是换分母。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-24 + */ +#ifndef FREERTOS_H +#define FREERTOS_H + +#include +#include + +typedef void *TaskHandle_t; +typedef long BaseType_t; +typedef unsigned long UBaseType_t; +typedef uint32_t TickType_t; + +#define pdFALSE ((BaseType_t)0) +#define pdTRUE ((BaseType_t)1) +#define pdPASS pdTRUE +#define pdFAIL pdFALSE + +#ifndef portMAX_DELAY +#define portMAX_DELAY ((TickType_t)0xFFFFFFFFU) +#endif + +/* 与真实 task.h 同序(eSetBits..eSetValueWithoutOverwrite),取值不可重排 */ +typedef enum { + eNoAction = 0, + eSetBits, + eIncrement, + eSetValueWithOverwrite, + eSetValueWithoutOverwrite +} eNotifyAction; + +#endif /* FREERTOS_H */ diff --git a/test/host/net_socket/stub/ch395f.h b/test/host/net_socket/stub/ch395f.h new file mode 100644 index 00000000..34d09601 --- /dev/null +++ b/test/host/net_socket/stub/ch395f.h @@ -0,0 +1,10 @@ +/* + * 模块名称:主机侧单测桩 - ch395f.h(net_socket 模块入口) + * 模块功能:只做转发,真身在 test/host/common/ch395f_fake.h/.c(NET 域共享假芯片)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-24 + */ +#ifndef CH395F_H +#define CH395F_H +#include "ch395f_fake.h" +#endif /* CH395F_H */ diff --git a/test/host/net_socket/stub/cmsis_os.h b/test/host/net_socket/stub/cmsis_os.h new file mode 100644 index 00000000..b2e553b8 --- /dev/null +++ b/test/host/net_socket/stub/cmsis_os.h @@ -0,0 +1,14 @@ +/* + * 模块名称:主机侧单测桩 - cmsis_os.h(net_socket 模块专用) + * 模块功能:net_socket.c include 的是 CMSIS-RTOS **v1** 头,但它实际用的 API + * (osDelay / osMessageQueueGet/Put / osKernelGetState / osOK / osWaitForever) + * 全在 v2 头里。真实 v1 头会拉 FreeRTOS.h + task.h + cmsis_os2.h, + * 前两个由本目录桩接管,cmsis_os2.h 用**真实头**(只依赖 stdint/stddef, + * 宿主可编)——所以本桩就是一次转发,把 v2 的真实类型/枚举/原型接进来。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-24 + */ +#ifndef CMSIS_OS_H +#define CMSIS_OS_H +#include "cmsis_os2.h" +#endif /* CMSIS_OS_H */ diff --git a/test/host/net_socket/stub/dbg_log.h b/test/host/net_socket/stub/dbg_log.h new file mode 100644 index 00000000..b49c7d4e --- /dev/null +++ b/test/host/net_socket/stub/dbg_log.h @@ -0,0 +1,23 @@ +/* + * 模块名称:主机侧单测桩 - dbg_log.h(net_socket 模块专用) + * 模块功能:DBG_* 换成计数,风格与本目录既有 NET 域桩一致(net_listener / net_events + * 同名文件)。net_socket.c 有 11 条 DBG_ERROR + 6 条 DBG_INFO,其中 + * 「短写必留痕」「版本门控拒置 bit3 必 [ERR]」「open 失败必 [ERR]」 + * 「close 复位没成也放行仍要留痕」这四条本身就是可断言的契约。 + * 本模块在 include 本头之前把 DBG_LINE_BUF_MAX 设为 128U(AGENTS 深调用链纪律), + * 桩不关心缓冲大小,只数条数。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-24 + */ +#ifndef DBG_LOG_H +#define DBG_LOG_H + +extern int g_ns_info_count; +extern int g_ns_error_count; +extern int g_ns_debug_count; + +#define DBG_INFO(fmt, ...) do { (void)(fmt); g_ns_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_ns_debug_count++; } while (0) +#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_ns_error_count++; } while (0) + +#endif /* DBG_LOG_H */ diff --git a/test/host/net_socket/stub/main.h b/test/host/net_socket/stub/main.h new file mode 100644 index 00000000..54eb2bc8 --- /dev/null +++ b/test/host/net_socket/stub/main.h @@ -0,0 +1,35 @@ +/* + * 模块名称:主机侧单测桩 - main.h(net_socket 模块专用) + * 模块功能:net_socket.c 从真实 Inc/main.h 只要三样:HAL_GetTick(自旋/超时/挂起预算的 + * 唯一致度源)、HAL_GPIO_ReadPin(轮询 CH395F INT#,低电平有效)、 + * CH395_INT_GPIO_Port / CH395_INT_Pin(Inc/main.h:60-61,PE5)。 + * 引脚名/号按真实头转抄(真值改了就编译期/断言期报错),GPIO_TypeDef 用不透明 + * void*(本层不碰寄存器,只需类型能编)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-24 + */ +#ifndef MAIN_H +#define MAIN_H + +#include + +typedef void GPIO_TypeDef; + +typedef enum { + GPIO_PIN_RESET = 0, + GPIO_PIN_SET +} GPIO_PinState; + +/* 转抄 Inc/main.h:60-61 */ +#define CH395_INT_Pin 0x0020U /* GPIO_PIN_5 */ +#define CH395_INT_GPIO_Port ((GPIO_TypeDef *)0) + +uint32_t HAL_GetTick(void); +GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin); + +/* 每轮 net_poll 时基自走多少毫秒(默认 1ms:轮询本身就要花时间,自走才量得到 + * NET_SEND_SPIN_MS 这类"有界自旋"预算;测试可置 0 造"时钟冻结"形态) */ +extern uint32_t g_ns_poll_tick_inc; +extern uint32_t g_ns_poll_calls; /* net_poll 经 GPIO 读 INT# 的次数(也是跑飞保险计数) */ + +#endif /* MAIN_H */ diff --git a/test/host/net_socket/stub/net_latency_test.h b/test/host/net_socket/stub/net_latency_test.h new file mode 100644 index 00000000..d8517349 --- /dev/null +++ b/test/host/net_socket/stub/net_latency_test.h @@ -0,0 +1,9 @@ +/* + * 模块名称:主机侧单测桩 - net_latency_test.h(net_socket 模块专用) + * 模块功能:空壳,配合 TEST_LATENCY_PROBE 不定义即把 lat_* 调用全部条件编译掉。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-24 + */ +#ifndef NET_LATENCY_TEST_H +#define NET_LATENCY_TEST_H +#endif /* NET_LATENCY_TEST_H */ diff --git a/test/host/net_socket/stub/ns_rtos.h b/test/host/net_socket/stub/ns_rtos.h new file mode 100644 index 00000000..a49b82e3 --- /dev/null +++ b/test/host/net_socket/stub/ns_rtos.h @@ -0,0 +1,84 @@ +/* + * 模块名称:假内核(net_socket 主机侧单测专用) + * 模块功能:给 net_socket.c 一份可在 PC 上跑的 FreeRTOS/CMSIS-RTOS2 薄壳—— + * 消息队列(osMessageQueuePut/Get,真实 cmsis_os2.h 的原型)、 + * 任务通知(xTaskNotify / xTaskNotifyWait)、当前任务句柄、时基、临界区计数。 + * + * ⚠ 仅供 test/host/net_socket/ 使用,不参与 MCU 构建。 + * + * 关键设计:**阻塞等待即调度点**。net_wait_result 的第二段 + * xTaskNotifyWait(..., portMAX_DELAY) 在真机上是"睡到 netTask 回执";这里把这一次 + * 等待实现成"调用测试装泵的 pump 钩子(= 以 netTask 身份跑一轮 + * net_process_messages()),再拾取通知值"。于是单次 net_send()/net_recv() 调用就能 + * 把"入队 → 分派 → 通知值回传 → 调用方恢复 errno"整条跨上下文链真实跑完, + * 而不是把中间环节假掉。 + * + * 泵保险:pump 钩子最多跑 NS_PUMP_MAX 轮,超了直接 exit(2)——否则一条"该通知却没通知" + * 的缺陷会让门禁挂死而不是报错(本仓假 osDelay 的跑飞保险同一条纪律)。 + * + * 创建日期:2026-10-24 + */ +#ifndef NS_RTOS_H +#define NS_RTOS_H + +#include "FreeRTOS.h" +#include "cmsis_os2.h" +#include "main.h" /* 桩:HAL_GetTick / HAL_GPIO_ReadPin / CH395_INT_* */ + +#define NS_Q_CAP 8u +#define NS_PUMP_MAX 50 + +/* 两个假任务:调用方任务 + netTask(句柄取地址即唯一) */ +extern int g_ns_task_caller; +extern int g_ns_task_net; + +/* 当前任务句柄(测试直接读写:切换"我是调用方"还是"我是 netTask") */ +extern TaskHandle_t g_ns_cur_task; + +/* 阻塞等待时跑的泵(测试装:通常 = 跑一轮 net_process_messages) */ +typedef void (*ns_pump_fn)(void); +extern ns_pump_fn g_ns_pump; +extern int g_ns_pump_calls; /* 泵被驱动的次数 */ +extern int g_ns_block_waits; /* 真正走到"阻塞分支"的次数 */ + +/* 通知/队列观测面 */ +extern int g_ns_notify_calls; /* xTaskNotify 总次数 */ +extern uint32_t g_ns_notify_last_val; +extern TaskHandle_t g_ns_notify_last_to; +extern int g_ns_put_calls; +extern int g_ns_get_calls; +extern int g_ns_crit_enter; +extern int g_ns_crit_exit; +extern uint32_t g_ns_tick; + +void ns_rtos_reset(void); +void ns_rtos_q_flush(void); +int ns_rtos_q_count(void); +void ns_rtos_q_fill(int n); /* 预灌 n 条占位消息(把队列灌到满,测 BUSY 分支) */ + +/* 测试用的显式通知投递(不经队列,直接给某任务置通知值) */ +void ns_rtos_post_notify(TaskHandle_t to, uint32_t v); + +/* 手工投一条原始消息(绕过公开包装器:造未知 type、造 netTask 自调等异常形态) */ +int ns_rtos_q_put_raw(const net_msg_t *msg); + +/* —— 下面是给桩头对接的实现(原型与真实头一致) —— */ +osStatus_t osMessageQueuePut(osMessageQueueId_t mq_id, const void *msg_ptr, + uint8_t msg_prio, uint32_t timeout); +osStatus_t osMessageQueueGet(osMessageQueueId_t mq_id, void *msg_ptr, + uint8_t *msg_prio, uint32_t timeout); +osKernelState_t osKernelGetState(void); +osStatus_t osDelay(uint32_t ticks); + +/* 每轮 net_poll(= 一次 INT# 读取)都调一下测试回调:用来在"等待发送缓冲/等待数据" + * 的循环途中投递并发事件(对端断线、芯片突然可读),而不必自己发明时间 API */ +typedef void (*ns_poll_hook_fn)(void); +extern ns_poll_hook_fn g_ns_on_poll; + +/* CH395F INT# 电平(1=SET 无中断,net_poll 走短路分支) */ +extern int g_ns_int_pin_set; + +/* 内核状态可注入(net_errno_slot 靠它判"内核没跑起来"的退化分支) */ +extern osKernelState_t g_ns_kernel_state; + +#endif /* NS_RTOS_H */ diff --git a/test/host/net_socket/stub/task.h b/test/host/net_socket/stub/task.h new file mode 100644 index 00000000..f097adaf --- /dev/null +++ b/test/host/net_socket/stub/task.h @@ -0,0 +1,32 @@ +/* + * 模块名称:主机侧单测桩 - task.h(net_socket 模块专用) + * 模块功能:声明 net_socket.c 用到的三件套 + 临界区宏。原型与真实 + * Middlewares/.../include/task.h 逐字一致(返回值/参数顺序都不许改), + * 实现在 ns_rtos.c(假内核:任务通知值 + 当前任务句柄可切换)。 + * taskENTER_CRITICAL/EXIT_CRITICAL 记计数——临界区只存在于本层槽位分配, + * 计数能断言"只在占槽时进临界区、且成对退出"。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-24 + */ +#ifndef TASK_H +#define TASK_H + +#include "FreeRTOS.h" + +TaskHandle_t xTaskGetCurrentTaskHandle(void); +BaseType_t xTaskNotify(TaskHandle_t xTaskToNotify, uint32_t ulValue, + eNotifyAction eAction); +BaseType_t xTaskNotifyWait(uint32_t ulBitsToClearOnEntry, + uint32_t ulBitsToClearOnExit, + uint32_t *pulNotificationValue, + TickType_t xTicksToWait); +uint32_t xTaskGetTickCount(void); + +int ns_os_crit_depth(void); +void ns_os_enter_critical(void); +void ns_os_exit_critical(void); + +#define taskENTER_CRITICAL() ns_os_enter_critical() +#define taskEXIT_CRITICAL() ns_os_exit_critical() + +#endif /* TASK_H */ diff --git a/test/host/net_socket/stub/test_config.h b/test/host/net_socket/stub/test_config.h new file mode 100644 index 00000000..9913af31 --- /dev/null +++ b/test/host/net_socket/stub/test_config.h @@ -0,0 +1,11 @@ +/* + * 模块名称:主机侧单测桩 - test_config.h(net_socket 模块专用) + * 模块功能:空壳。net_socket.c 只在 TEST_LATENCY_PROBE 打开时才用延迟探针, + * 主机单测刻意**不开**(探针要写真实时间/真实周期计数,不是被测对象); + * 也不定义 NET_BENCH_CMD_LOG(压测命令日志同理,见 net_events 同名说明)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-24 + */ +#ifndef TEST_CONFIG_H +#define TEST_CONFIG_H +#endif /* TEST_CONFIG_H */ diff --git a/test/host/soe_store/run_unit_test.py b/test/host/soe_store/run_unit_test.py new file mode 100644 index 00000000..7248411e --- /dev/null +++ b/test/host/soe_store/run_unit_test.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +r"""SOE 落盘胶水层(soe_store)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/soe_store/soe_store_test.c(以 #include 纳入**生产源** + App/log/soe_store.c 与 App/log/soe_rec.c,并纳入共享假卷 + test/host/common/fatfs_fake.c),在 PC 上离线跑 post 侧零 I/O 契约、环/队列分流与 + 丢弃计数、drain 侧的卷锁 try / FA_WRITE|FA_OPEN_APPEND / 写回字节数校验 / 容量满 + 重建 / 故障留痕去重,失败即非 0 退出。工具链与判定委托 + test/host/common/unit_test_harness.py。 + +⚠ 与 test/host/soe/ 是两码事:那套测的是 soe_rec.c 的**编解码**(纯逻辑,零 FS); + 本套测 soe_store.c 的**落盘胶水**。目录、模块名、TC 号族都分开 + (TC 模块码用 SOESTORE,SOE 已被存量占用)。 + +为什么要主机侧单测(而不是只用台架): + 1. post 侧"一条 DBG 都不许打"是 protectTask 的**栈预算硬约束**(函数头原话: + 该任务静态最坏链 = 经 s_log_active_edge 的 console 尾链 872 B,post 里加一条 + 日志就 +320 B → stack_report FAIL)。真机上忘这条不会崩,只会某次最坏路径 + 把栈打穿;只有把 DBG_* 换成计数才能机械断言"这条路径 0 日志"。 + 2. 环写失败不推进 tail(不丢)而队列写失败丢已出队那一条——**两条通道不对称**, + 台架要恰好撞上"FTP 正占卷"才看得到一次。 + 3. 短写(FR_OK + 写回不足)、容量满重建、故障留痕去重都要故障注入才成立。 + 4. 陷阱二(名字/参数类错误码严禁升级成卷操作)只有假卷能把 FR_INVALID_NAME 稳定 + 喂进自愈路径,并断言"邻居文件与 /log 目录一根毛都没动"。 + +桩的边界:ff.h / sys_clock.h / vol_lock.h / soe_rec.h / soe_store.h 用**真实头** + (FRESULT、FA_*、sys_clock_get 签名一改即编译期报错);main.h 是桩,只为截断 + sys_clock.h -> sd2506.h -> main.h -> stm32f4xx_hal.h 这条链(真实 main.h 第一行 + 就拉整棵 HAL);cmsis_os.h / dbg_log.h 是本目录桩。FatFs 由 + test/host/common/fatfs_fake.c 给 RAM 实现(**显式纳入,不用目录通配**, + 避免被卷进别人的构建)。sys_clock_* / vol_lock_* / 消息队列强符号桩在本测试单元里。 + +用法(仓库根目录): + & '.venv\Scripts\python.exe' test/host/soe_store/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:docs/架构设计/STM32F4-Base存储架构说明.md §10.1 第 10/11 条、 +docs/算法/继电保护功能复现规划.md §3.4(L4-4/L4-5,B9)、 +docs/测试说明/主机单测覆盖率口径.md §9 P2 第 2 项 +创建日期:2026-10-24 +""" +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 必须最先(覆盖 cmsis_os.h / dbg_log.h / main.h), +# 其余全是真实头目录;假卷 fatfs_fake.c 由测试单元 #include 纳入(同 ch395f_fake.c 惯例)。 +BUILD = TestBuild( + name="SOE Store Tests", + src=os.path.join(HERE, "soe_store_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "test", "host", "common"), + os.path.join(ROOT, "App", "log"), + os.path.join(ROOT, "App", "util"), + os.path.join(ROOT, "App", "fs"), + os.path.join(ROOT, "Drivers", "BSP"), # bsp_err.h(sd2506.h 依赖) + os.path.join(ROOT, "Drivers", "BSP", "SD2506"), + os.path.join(ROOT, "Lib", "FatFs"), # 真实 ff.h / ffconf.h + HERE, + ], + summary_marker="=== SOE Store Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/soe_store/soe_store_test.c b/test/host/soe_store/soe_store_test.c new file mode 100644 index 00000000..155d20c8 --- /dev/null +++ b/test/host/soe_store/soe_store_test.c @@ -0,0 +1,708 @@ +/* + * 模块名称:SOE 落盘胶水层(soe_store)主机侧单元测试(L0.5) + * 模块功能:在 PC 上离线验证 App/log/soe_store.c 的四条对外契约与它自己的 + * "照 sys_log_mcu 的台架实证纪律"注释: + * - post 侧**零文件系统、零日志**(protectTask 栈预算 + DoD 硬约束); + * - 环 / 队列两条待发通道的分流与"丢弃只计数"; + * - drain 侧的 vol_lock try、FA_WRITE|FA_OPEN_APPEND、写回字节数校验、 + * 容量满 unlink 重建、故障留痕去重; + * - **名字/参数类错误码绝不升级成卷操作**(存储架构 §10.1 第 10 条)。 + * + * 被测对象:真实源文件(#include 原样纳入,不改生产代码): + * App/log/soe_store.c —— 本模块主体 + * App/log/soe_rec.c —— 64B 记录构建(纯逻辑,零 OS,直接用真件; + * test/host/soe 已单独覆盖其编解码,本套不重测) + * 假件与桩: + * test/host/common/fatfs_fake.{h,c} —— 假 FatFs 内存卷(真实 ff.h 声明) + * stub/dbg_log.h / stub/cmsis_os.h / stub/main.h —— 本目录桩 + * sys_clock / vol_lock 的可控强符号实现写在本文件里 + * + * 为什么要主机侧单测(而不是只靠台架): + * 1. **post 侧禁 DBG 是栈预算硬约束**(soe_store.c 函数头原话:protectTask 静态最 + * 坏链 = 经 s_log_active_edge 的 console 尾链 872 B,post 内任何日志调用点都会 + * 再垫高一帧,stack_report 实测 +320 B → FAIL)。真机上"忘了这条"不会崩,只会 + * 在某次最坏路径上把栈打穿 → 只有把 DBG_* 换成计数才能机械断言"这条路径 0 日志"。 + * 2. **环 / 队列的丢弃语义不对称**:环写失败**不推进 tail**(下轮重试、条目不丢), + * 队列条目已出队、卷忙就丢那一条。真机上要恰好撞上"FTP 正在占卷"才看得到, + * 而"到底丢不丢"是运维读 SOE 时能不能信任序号连续的前提。 + * 3. **短写 = 失败**(FR_OK + 写回字节数不足,簇分配没有错误码)与**满则重建** + * 这两条只在介质坏块 / 容量见底时发作;假卷可以把"只写回 63 字节"直接喂进来。 + * 4. 写故障留痕的去重(s_fault_reported)决定了长跑时 /log 与串口会不会被同一条 + * 故障刷屏——真机要复现"失败→恢复→再失败"这个序列极慢。 + * + * 覆盖点(TC 号族 = SOESTORE(三位序号);SOE 已被存量 test/host/soe 占用): + * —— post 侧(零 I/O 契约)—— + * TC-SOESTORE-001 post 3 条只写环:零 FatFs 调用、零卷锁调用、零日志 + * TC-SOESTORE-002 墙钟未建立(UINT32_MAX)→ flags 带 IV 且 epoch 字段落 0(不造假时标) + * TC-SOESTORE-003 seq 上电内单调 +0123(丢弃也占号:号不跳 = 假象) + * TC-SOESTORE-004 环满(8 条)→ 第 9 条丢弃 + 计数,不阻塞、不崩、**不打日志** + * TC-SOESTORE-005 内核运行 + 队列已建 → 走队列,环不再写入 + * TC-SOESTORE-006 队列满 → 丢弃 + 计数(与环满同一条可观测出口) + * TC-SOESTORE-007 丢弃计数饱和:UINT32_MAX 不再回绕 + * —— drain 侧:落盘与打开模式 —— + * TC-SOESTORE-010 首次 drain 惰性建队列;1 条落盘 = 64 字节且可被 soe_rec_parse 解回 + * TC-SOESTORE-011 打开模式必须是 FA_WRITE|FA_OPEN_APPEND;对照保真:只给 + * FA_OPEN_APPEND 的句柄 f_write 恒返 FR_DENIED(陷阱一,本仓实证根因) + * TC-SOESTORE-012 多条落盘:一条一次 open/write/close(本模块**没有**批量合并—— + * 与 sys_log 的 N-24 不同),FIFO 顺序、seq 递增 + * TC-SOESTORE-013 open 与 close 严格配对(脏句柄不跨调用残留) + * —— drain 侧:故障注入 —— + * TC-SOESTORE-014 短写 63B → 判失败 → unlink 重建 → 重试成功 → 最终恰好 64 字节 + * TC-SOESTORE-015 连续两次短写 → 返回 -1;留痕恰好一次(去重);恢复后再失败才再留痕 + * TC-SOESTORE-016 卷忙(vol_lock_wait(0)=0)→ 返回 -2;**不碰 FatFs、不 release** + * TC-SOESTORE-017 环条目在写失败时不推进 tail → 下轮重试不丢、且不重复落盘 + * TC-SOESTORE-018 队列条目已出队:卷忙只丢当前一条,剩余留在队列(不对称现状钉住) + * TC-SOESTORE-019 容量满(1024 条)→ unlink 重建 + INFO 留痕,新文件恰好 64 字节 + * TC-SOESTORE-020 丢弃留痕按差值上报一次,不刷屏 + * TC-SOESTORE-021 队列创建失败(返回 NULL)→ 条目滞留环、下轮再试,不崩 + * —— 陷阱二:名字/参数类错误码不得升级成卷操作 —— + * TC-SOESTORE-022 f_open 返 FR_INVALID_NAME → 只留痕返回 -1:**不 unlink**、 + * 不碰 /log 目录、条目全部保留(自愈只针对自己的文件) + * TC-SOESTORE-023 写路径反复失败时 f_unlink 次数有界(每轮 <=1),目录与邻居文件不动 + * TC-SOESTORE-024 假卷严格 8.3 校验的保真自检:".fschk" -> 名字/盘符类拒绝码, + * 而生产路径 SOE_PATH 必过(钉住"改名即静默写失败"的约束) + * + * 覆盖率台账(--module soe_store 自量):App/log/soe_store.c 78/79 行 = 98.7%、 + * 函数 4/4。唯一未覆盖行 soe_store.c:202 = drain 里 for (;;) 的收尾大括号, + * 该循环只能靠 break 退出,不存在"走到行尾再回跳"的机器码,属工具口径缺口。 + * + * 本套照出的现状(只登记、不改生产代码;详见交 Lead 的报告): + * soe_store.c 的函数头第 ④ 条写"打开/写入结构性错误 → unlink 重试一次(同 run.log)", + * 但实现里 **f_open 失败直接 return -1、不 unlink 重试**(只有 f_write 失败才重试)。 + * run.log 侧(sys_log_mcu.c s_append_rec)则对 FR_NO_FILE/FR_NO_PATH/FR_INVALID_NAME + * 等一整套码做 unlink+重开。两套并不一致 —— 是注释夸大了实现,还是实现漏了一半, + * 需生产侧裁定;本套只用 TC-SOESTORE-022 把现状钉成"改了会响"。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-24(P2 主机单测覆盖率批次,task-2) + */ + +#include +#include +#include +#include + +#include "ff.h" /* 真实 Lib/FatFs/ff.h:FRESULT / FA_* / FIL 原汁 */ +#include "fatfs_fake.h" /* 假卷:类型与常量都取自上面那份真头 */ +#include "cmsis_os.h" /* 桩:消息队列 + 内核状态(签名照 cmsis_os2.h) */ +#include "dbg_log.h" /* 桩:DBG_* -> 计数 */ +#include "sys_clock.h" /* 真实头(App/util):签名一改即编译期报错 */ +#include "vol_lock.h" /* 真实头(App/fs) */ +#include "soe_rec.h" +#include "soe_store.h" + +/* ===================== 断言框架 ===================== */ + +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) + +/* ===================== 假件与生产源码纳入 ===================== */ + +#include "fatfs_fake.c" /* 假 FatFs 实现 */ +#include "soe_rec.c" /* 真实 64B 记录构建(纯逻辑) */ +#include "soe_store.c" /* 被测主体 */ + +/* ===================== dbg_log 桩计数(stub/dbg_log.h 声明) ===================== */ + +int g_soe_err_count; +int g_soe_info_count; +int g_soe_debug_count; + +/* ===================== 假墙钟 ===================== */ + +static uint32_t s_clock_now = 1760234567u; /* 2025-10-12 前后,CP56 年域内 */ +static int s_clock_valid = 1; + +uint32_t sys_clock_get(void) +{ + return (s_clock_valid != 0) ? s_clock_now : UINT32_MAX; +} + +/* sys_clock.h 里声明、本模块不调用的接口:给出空实现,纯为可链接 */ +int sys_clock_init(void) { return 0; } +int sys_clock_set(uint32_t unix_secs, const sd2506_time_t *p_time) +{ + (void)unix_secs; (void)p_time; return 0; +} +int sys_clock_set_unix(uint32_t unix_secs) { (void)unix_secs; return 0; } +int sys_clock_get_time(sd2506_time_t *p_time) { (void)p_time; return -1; } +uint32_t HAL_GetTick(void) { return 0u; } + +char *sys_clock_get_str(char *buf, size_t buf_size) +{ + (void)buf; (void)buf_size; + return NULL; +} + +/* ===================== 假卷锁(try 语义 + 配对检查) ===================== */ + +static int s_lock_busy_left; /* >0:接下来 N 次 vol_lock_wait 返回"忙"(try 失败) + * 用计数器而不是按序号取值的脚本数组——脚本数组的下标 + * 会被场景里前几次 drain 消耗掉,注入看着设了其实没生效 */ +static int s_lock_waits; +static int s_lock_releases; +static int s_lock_held; +static int s_lock_seq_err; /* 未持锁却 release = 锁纪律被写反 */ + +static void lock_busy_next(int n) +{ + s_lock_busy_left = n; +} + +static void locks_clear(void) +{ + s_lock_busy_left = 0; + s_lock_waits = 0; + s_lock_releases = 0; + s_lock_held = 0; + s_lock_seq_err = 0; +} + +int vol_lock_init(void) { return 0; } + +int vol_lock_wait(uint32_t timeout_ms) +{ + int r; + + (void)timeout_ms; + /* 跑飞保险:调用数越界直接判死,不让死循环拖挂门禁 */ + if (s_lock_waits > 20000) { + (void)fprintf(stderr, "[FATAL] vol_lock_wait call guard tripped\n"); + exit(2); + } + r = (s_lock_busy_left > 0) ? 0 : 1; + if (s_lock_busy_left > 0) { + s_lock_busy_left--; + } + s_lock_waits++; + if (r != 0) { + s_lock_held = 1; + } + return r; +} + +void vol_lock_release(void) +{ + s_lock_releases++; + if (s_lock_held == 0) { + s_lock_seq_err++; /* 没拿到锁就释放:fail-open 语义被写反的证据 */ + } + s_lock_held = 0; +} + +/* ===================== 假消息队列(真 FIFO + 满/空语义) ===================== */ + +#define MQ_SLOTS 16u +#define MQ_MAXMSG 64u /* SOE 记录 64B;syslog 侧条目 12B,同一池够用 */ + +typedef struct { + int used; /* 槽位有无消息 */ + uint8_t data[MQ_MAXMSG]; +} mq_slot_t; + +static mq_slot_t s_q_pool[MQ_SLOTS]; +static uint32_t s_q_head; /* 出队下标 */ +static uint32_t s_q_cnt; +static uint32_t s_q_cap; /* osMessageQueueNew 给的 msg_count */ +static uint32_t s_q_msgsz; +static int s_q_new_calls; +static int s_q_new_fail; /* 1 = osMessageQueueNew 返 NULL */ +static int s_q_get_calls; +static int s_q_put_calls; +static int s_q_create_fail_left; /* 预留:连续失败注入(当前用 0/1) */ + +static void queue_clear(void) +{ + (void)memset(s_q_pool, 0, sizeof(s_q_pool)); + s_q_head = 0u; + s_q_cnt = 0u; + s_q_cap = SOE_QUEUE_MAX; + s_q_msgsz = SOE_REC_SIZE; + s_q_new_calls = 0; + s_q_new_fail = 0; + s_q_create_fail_left = 0; + s_q_get_calls = 0; + s_q_put_calls = 0; +} + +osMessageQueueId_t osMessageQueueNew(uint32_t msg_count, uint32_t msg_size, + const osMessageQueueAttr_t *attr) +{ + (void)attr; + s_q_new_calls++; + if ((s_q_new_fail != 0) || (s_q_create_fail_left > 0)) { + if (s_q_create_fail_left > 0) { + s_q_create_fail_left--; + } + return NULL; + } + s_q_cap = msg_count; + s_q_msgsz = msg_size; + s_q_head = 0u; + s_q_cnt = 0u; + /* 句柄本身没有意义:返回一个稳定非空指针即可 */ + return (osMessageQueueId_t)&s_q_pool[0]; +} + +osStatus_t osMessageQueuePut(osMessageQueueId_t mq_id, const void *msg_ptr, + uint8_t msg_prio, uint32_t timeout) +{ + uint32_t tail; + + (void)mq_id; (void)msg_prio; (void)timeout; + if (s_q_put_calls++ > 20000) { + (void)fprintf(stderr, "[FATAL] osMessageQueuePut guard tripped\n"); + exit(2); + } + if ((msg_ptr == NULL) || (s_q_cnt >= s_q_cap) || (s_q_msgsz > MQ_MAXMSG)) { + return osErrorResource; /* 队列满:真实 CMSIS 在 timeout=0 时的返回 */ + } + tail = (s_q_head + s_q_cnt) % MQ_SLOTS; + (void)memcpy(s_q_pool[tail].data, msg_ptr, s_q_msgsz); + s_q_pool[tail].used = 1; + s_q_cnt++; + return osOK; +} + +osStatus_t osMessageQueueGet(osMessageQueueId_t mq_id, void *msg_ptr, + uint8_t *msg_prio, uint32_t timeout) +{ + (void)mq_id; (void)msg_prio; (void)timeout; + if (s_q_get_calls++ > 20000) { + (void)fprintf(stderr, "[FATAL] osMessageQueueGet guard tripped\n"); + exit(2); + } + if ((msg_ptr == NULL) || (s_q_cnt == 0u)) { + return osErrorResource; /* 队列空 */ + } + (void)memcpy(msg_ptr, s_q_pool[s_q_head].data, s_q_msgsz); + s_q_pool[s_q_head].used = 0; + s_q_head = (s_q_head + 1u) % MQ_SLOTS; + s_q_cnt--; + return osOK; +} + +uint32_t osMessageQueueGetCount(osMessageQueueId_t mq_id) +{ + (void)mq_id; + return s_q_cnt; +} + +osStatus_t osMessageQueueReset(osMessageQueueId_t mq_id) +{ + (void)mq_id; + s_q_head = 0u; + s_q_cnt = 0u; + return osOK; +} + +/* ===================== 内核状态开关 ===================== */ + +static osKernelState_t s_kernel_state = osKernelInactive; + +osKernelState_t osKernelGetState(void) +{ + return s_kernel_state; +} + +/* ===================== 复位与辅助 ===================== */ + +static void reset_all(void) +{ + ff_fake_reset(); + locks_clear(); + queue_clear(); + s_clock_now = 1760234567u; + s_clock_valid = 1; + s_kernel_state = osKernelInactive; + g_soe_err_count = 0; + g_soe_info_count = 0; + g_soe_debug_count = 0; + /* 生产模块的 static 在场景之间会残留:逐位显式清,漏一次会红一片 + *(test/host/lftpd_io 与 nand_port 同一教训) */ + (void)memset(s_ring, 0, sizeof(s_ring)); + s_ring_head = 0u; + s_ring_tail = 0u; + s_queue = NULL; + s_seq = 0u; + s_drop_cnt = 0u; + s_drop_logged = 0u; + s_fault_reported = 0u; + /* 生产语义前置:/log 由 defaultTask 挂载时建好,本模块假定它在(soe_store.h: + * "由 syslog_drain 在卷就绪门之后挂接") */ + (void)ff_fake_mkdir_direct("/log"); +} + +/* 用假卷 + 真 soe_rec_parse 校验落盘内容:路径常量取自生产宏 SOE_PATH */ +static int read_rec(uint32_t idx, soe_rec_t *p_out) +{ + uint8_t raw[SOE_REC_SIZE]; + + if (ff_fake_fetch(SOE_PATH, raw, idx * SOE_REC_SIZE, SOE_REC_SIZE) != SOE_REC_SIZE) { + return 0; + } + return (soe_rec_parse(raw, p_out) == 0) ? 1 : 0; +} + +/* ===================== TC-SOESTORE-001..007:post 侧 ===================== */ + +static void tc_post_side(void) +{ + soe_rec_t out; + uint32_t i; + int ok; + + printf("-- TC-SOESTORE-001..007 post 侧(零 I/O 契约)--\n"); + + /* 001:post 3 条只写环,绝不碰盘、绝不碰卷锁、绝不打日志 */ + reset_all(); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 2u, 0x1111u); + soe_store_post((uint8_t)SOE_T_ELM_RETURN, 2u, 0x2222u); + soe_store_post((uint8_t)SOE_T_PLATE_IN, SOE_ELM_NONE, 1u); + ok = (s_ring_head == 3u) && (s_ring_tail == 0u) && (s_drop_cnt == 0u); + for (i = 0u; i < (uint32_t)FF_FAKE_OP_N; i++) { + if (g_ff_obs.calls[i] != 0) { + ok = 0; + } + } + CHECK(ok && (s_lock_waits == 0) && (g_soe_err_count == 0) && (g_soe_info_count == 0), + "TC-SOESTORE-001 post 零 FatFs / 零卷锁 / 零日志(protectTask 栈预算)"); + + /* 002:墙钟未建立 -> IV + epoch 字段 0(不造假时标) */ + reset_all(); + s_clock_valid = 0; + soe_store_post((uint8_t)SOE_T_SAMPLE_BLOCK, 1u, 0xF0u); + soe_store_drain(); /* 环 -> 卷:不 drain 就还在 RAM 里 */ + ok = read_rec(0u, &out); + CHECK(ok && (out.flags & SOE_FLG_IV) != 0u && (out.epoch_s == 0u) + && (out.cp56[2] & 0x80u) != 0u && (out.value == 0xF0u), + "TC-SOESTORE-002 无墙钟 -> flags.IV + epoch=0 + CP56 IV 位(B9 口径)"); + + /* 003:seq 单调;丢弃的那条也占号(真机丢号 = 主机看到的号不连续是**如实**) */ + reset_all(); + for (i = 0u; i < 9u; i++) { + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, (uint8_t)i, i); + } + ok = (s_seq == 9u) && (s_drop_cnt == 1u); + CHECK(ok, "TC-SOESTORE-003 seq 单调递增,丢弃也占号(号不跳才是假象)"); + + /* 004:环满丢弃不阻塞、不崩、**不打日志**(留痕归 drain 侧) */ + reset_all(); + for (i = 0u; i < 9u; i++) { + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, (uint8_t)i, i); + } + CHECK((s_ring_head == 8u) && (s_drop_cnt == 1u) + && (g_soe_err_count == 0) && (g_soe_info_count == 0), + "TC-SOESTORE-004 环满丢弃:只计数,post 侧零 DBG(栈尾链 +320B 教训)"); + + /* 005:内核运行 + 队列已建 -> 走队列,环不再写 */ + reset_all(); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 1u, 1u); /* 先落一条,drain 会建队列 */ + s_kernel_state = osKernelRunning; + soe_store_drain(); /* 建队列 + 落第 1 条 */ + s_ring_head = 0u; /* 观察用:清环位 */ + s_ring_tail = 0u; + soe_store_post((uint8_t)SOE_T_ELM_RETURN, 1u, 2u); + CHECK((s_ring_head == 0u) && (s_q_cnt == 1u) && (s_q_msgsz == SOE_REC_SIZE), + "TC-SOESTORE-005 队列已建 + 内核运行 -> 条目进队列、环不再写"); + + /* 006:队列满 -> 丢弃计数(与环满同一条可观测出口) */ + reset_all(); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 1u, 1u); + s_kernel_state = osKernelRunning; + soe_store_drain(); /* 建队列 */ + (void)osMessageQueueReset(s_queue); + for (i = 0u; i < SOE_QUEUE_MAX + 2u; i++) { + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, (uint8_t)i, i); + } + CHECK((s_q_cnt == SOE_QUEUE_MAX) && (s_drop_cnt == 2u), + "TC-SOESTORE-006 队列满 -> 丢弃并计数(soe_store_dropped 同一出口)"); + + /* 007:计数饱和不回绕 */ + reset_all(); + s_drop_cnt = UINT32_MAX; + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 1u, 1u); + CHECK((s_drop_cnt == UINT32_MAX) && (soe_store_dropped() == UINT32_MAX), + "TC-SOESTORE-007 丢弃计数饱和:UINT32_MAX 不回绕"); +} + +/* ===================== TC-SOESTORE-010..021:drain 落盘与故障注入 ===================== */ + +static void tc_drain_paths(void) +{ + soe_rec_t out; + uint32_t i; + int ok; + int opens_before; + int writes_before; + + printf("-- TC-SOESTORE-010..021 drain 落盘 / 故障注入 --\n"); + + /* 010:首次 drain 惰性建队列 + 1 条 64B 落盘且能解析回来 */ + reset_all(); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 3u, 0xABCDu); + soe_store_drain(); + ok = (s_q_new_calls == 1) && (s_q_msgsz == SOE_REC_SIZE) + && (ff_fake_size(SOE_PATH) == SOE_REC_SIZE); + CHECK(ok && read_rec(0u, &out) && (out.type == (uint8_t)SOE_T_ELM_ASSERT) + && (out.elm_id == 3u) && (out.value == 0xABCDu) && (out.seq == 0u), + "TC-SOESTORE-010 惰性建队列 + 64B 定长落盘 + 记录可解回"); + + /* 011:打开模式必须带 FA_WRITE —— 陷阱一 */ + reset_all(); + soe_store_post((uint8_t)SOE_T_PLATE_OUT, SOE_ELM_NONE, 0u); + soe_store_drain(); + ok = (g_ff_obs.last_open_mode == (BYTE)(FA_WRITE | FA_OPEN_APPEND)); + /* 同一断言的"为什么必须带写位"保真对照:只给 FA_OPEN_APPEND 的句柄 f_write + * 必须拿 FR_DENIED——假件如果哪天放行,这条会红,整套陷阱一断言随之失效 */ + { + static FIL probe; + static uint8_t payload[SOE_REC_SIZE]; + UINT bw = 0u; + FRESULT fr2; + + fr2 = f_open(&probe, SOE_PATH, FA_OPEN_APPEND); + { + FRESULT fr3 = f_write(&probe, payload, SOE_REC_SIZE, &bw); + CHECK(ok && (fr2 == FR_OK) && (fr3 == FR_DENIED) && (bw == 0u), + "TC-SOESTORE-011 模式必为 FA_WRITE|FA_OPEN_APPEND;缺写位 f_write 恒 FR_DENIED"); + } + (void)f_close(&probe); + } + + /* 012:多条 = 一条一次 open/write/close(本模块刻意没有批量合并)+ FIFO */ + reset_all(); + for (i = 0u; i < 4u; i++) { + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, (uint8_t)(i + 1u), 100u + i); + } + soe_store_drain(); + ok = (ff_fake_size(SOE_PATH) == 4u * SOE_REC_SIZE) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 4) + && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 4); + if (ok) { + for (i = 0u; i < 4u; i++) { + if (!read_rec(i, &out) || (out.seq != i) || (out.value != 100u + i)) { + ok = 0; + } + } + } + CHECK(ok, "TC-SOESTORE-012 逐条 open/write/close(无批量合并)+ FIFO 顺序正确"); + + /* 013:open/close 严格配对 */ + reset_all(); + soe_store_post((uint8_t)SOE_T_SAMPLE_RESUME, SOE_ELM_NONE, 0u); + soe_store_drain(); + CHECK(g_ff_obs.calls[FF_FAKE_OP_CLOSE] == g_ff_obs.calls[FF_FAKE_OP_OPEN], + "TC-SOESTORE-013 open/close 配对(脏句柄不跨调用残留)"); + + /* 014:短写判失败 -> unlink 重建 -> 重试成功 -> 恰好 64 字节 */ + reset_all(); + ff_fake_inject_write_short(SOE_PATH, SOE_REC_SIZE - 1u, 1); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 7u, 0x555Au); + soe_store_drain(); + CHECK((ff_fake_size(SOE_PATH) == SOE_REC_SIZE) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] >= 1) + && (g_ff_obs.last_write_bw == SOE_REC_SIZE) + && (g_soe_err_count == 0), + "TC-SOESTORE-014 FR_OK+短写被当失败处理(不校验字节数就会静默收下 63B)"); + + /* 015:连续**两轮** drain 都失败 -> 留痕只许 1 次(一轮 = 首写 + 自愈重试 + * = 2 次 f_write,所以注入 4 次零字节短写才够撑两轮) */ + reset_all(); + ff_fake_inject_write_short(SOE_PATH, 0u, 4); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 1u, 1u); + soe_store_drain(); /* 第 1 轮失败:留痕 1 次,环不推进 */ + ok = (g_soe_err_count == 1) && (s_ring_tail == 0u) && (s_fault_reported == 1u); + soe_store_drain(); /* 第 2 轮仍失败:不得再留痕 */ + CHECK(ok && (g_soe_err_count == 1) && (s_ring_tail == 0u), + "TC-SOESTORE-015 写故障留痕去重(防串口与 selftest 被同一条故障刷屏)"); + + /* 015b:成功一次复位去重位,之后再失败才会重新留痕 */ + /* 015b:失败(留痕1) -> 恢复(去重位复位) -> 再失败(必须留痕2)。 + * 少了"成功复位"这一步,一次真机坏块就会让后续所有写故障永久静默。 */ + reset_all(); + ff_fake_inject_write_short(SOE_PATH, 0u, 2); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 1u, 1u); + soe_store_drain(); /* 失败 -> 留痕 1 */ + soe_store_drain(); /* 注入耗尽 -> 成功 -> 复位 */ + ok = (g_soe_err_count == 1) && (s_fault_reported == 0u) && (s_ring_tail == 1u); + ff_fake_inject_write_short(SOE_PATH, 0u, 2); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 2u, 2u); + soe_store_drain(); /* 又失败 -> 留痕 2 */ + CHECK(ok && (g_soe_err_count == 2) && (s_fault_reported == 1u), + "TC-SOESTORE-015b 恢复后再失败必须重新留痕(漏复位 = 永久静默)"); + + /* 016:卷忙 -> -2:不碰 FatFs、不 release、环不推进 */ + reset_all(); + lock_busy_next(1); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 1u, 1u); + soe_store_drain(); + ok = (s_lock_waits == 1) && (s_lock_releases == 0) && (s_lock_seq_err == 0); + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0) + && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 0) + && (s_ring_tail == 0u) && (g_soe_err_count == 0), + "TC-SOESTORE-016 卷忙 = 顺延:零 FatFs、零 release、不计故障(喂狗点纪律)"); + + /* 017:写失败时环不推进 tail -> 下轮重试、既不丢也不重复 */ + reset_all(); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 5u, 0x77u); + ff_fake_inject_rc(SOE_PATH, FF_FAKE_OP_OPEN, FR_DISK_ERR, 1); + soe_store_drain(); /* open 失败 -> -1,tail 不动 */ + ok = (s_ring_tail == 0u) && (ff_fake_size(SOE_PATH) == 0u || + ff_fake_exists(SOE_PATH) == 0); + soe_store_drain(); /* 下轮成功 */ + CHECK(ok && (s_ring_tail == 1u) && (ff_fake_size(SOE_PATH) == SOE_REC_SIZE) + && read_rec(0u, &out) && (out.elm_id == 5u), + "TC-SOESTORE-017 环条目写失败不推进 tail:不丢、下轮也不重复落"); + + /* 018:队列条目已出队 -> 卷忙只丢当前一条,剩余留在队列(现状不对称) */ + reset_all(); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 1u, 1u); + s_kernel_state = osKernelRunning; + soe_store_drain(); /* 建队列 */ + (void)osMessageQueueReset(s_queue); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 10u, 10u); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 11u, 11u); + opens_before = g_ff_obs.calls[FF_FAKE_OP_OPEN]; /* 前面那次 drain 已开过盘 */ + writes_before = g_ff_obs.calls[FF_FAKE_OP_WRITE]; + lock_busy_next(1); + soe_store_drain(); + CHECK((s_q_cnt == 1u) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == opens_before) + && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == writes_before) + && (s_lock_waits >= 1) && (s_lock_seq_err == 0) && (s_q_get_calls >= 2), + "TC-SOESTORE-018 队列路径卷忙:只丢已出队那一条、本轮零 FatFs(与环路径不对称)"); + + /* 019:容量满 -> unlink 重建 + INFO 留痕,新文件恰好 64 字节 */ + reset_all(); + CHECK(ff_fake_mkfile_size(SOE_PATH, (uint32_t)SOE_STORE_MAX * SOE_REC_SIZE) == 0, + "TC-SOESTORE-019 前置:铺 1024 条(64KB)假记录"); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 9u, 0x99u); + soe_store_drain(); + CHECK((g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) + && (ff_fake_size(SOE_PATH) == SOE_REC_SIZE) + && (g_soe_info_count == 1) && read_rec(0u, &out) && (out.elm_id == 9u), + "TC-SOESTORE-019 满则删除重建:新文件只剩本次这一条"); + + /* 020:丢弃留痕按差值上报一次,不刷屏 */ + reset_all(); + for (i = 0u; i < SOE_RING_MAX + 3u; i++) { + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, (uint8_t)i, i); /* 环 8 + 丢 3 */ + } + soe_store_drain(); /* 差值上报 1 条 */ + soe_store_drain(); /* 无新丢弃 -> 不再报 */ + CHECK((s_drop_cnt == 3u) && (s_drop_logged == 3u) && (g_soe_err_count == 1), + "TC-SOESTORE-020 丢弃留痕按差值上报一次(defaultTask 侧才是日志安全区)"); + + /* 021:队列创建失败 -> 条目滞留环、下轮再试、不崩 */ + /* 021:osMessageQueueNew 返回 NULL —— 生产代码里环在队列**之前**排空, + * 所以条目不丢;留痕一次,内核已运行时后续 post 仍走环(s_queue 为空), + * 下一轮再建队列。真机上 heap_4 见底就是这种形态。 */ + reset_all(); + s_kernel_state = osKernelRunning; + s_q_new_fail = 1; + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 4u, 4u); + soe_store_drain(); + ok = (s_queue == NULL) && (g_soe_err_count == 1) + && (s_ring_tail == 1u) && (ff_fake_size(SOE_PATH) == SOE_REC_SIZE); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 5u, 5u); /* 队列没建成 -> 仍进环 */ + CHECK(ok && (s_q_cnt == 0u) && (s_ring_head == 2u), + "TC-SOESTORE-021 队列创建失败:环路径照常落盘不丢条目 + 留痕一次"); + s_q_new_fail = 0; + soe_store_drain(); + CHECK((s_queue != NULL) && (s_ring_tail == 2u) + && (ff_fake_size(SOE_PATH) == 2u * SOE_REC_SIZE) && (g_soe_err_count == 1), + "TC-SOESTORE-021b 堆恢复后队列补建、滞留条目全部落盘"); +} + +/* ===================== TC-SOESTORE-022..024:陷阱二 —— 名字/参数类错误码 ===================== */ + +static void tc_name_error_not_volume(void) +{ + uint32_t i; + int ok; + + printf("-- TC-SOESTORE-022..024 名字/参数类错误码严禁升级成卷操作 --\n"); + + /* 022:FR_INVALID_NAME 只让本条失败,**不得** unlink 别人、不得动目录 */ + reset_all(); + (void)ff_fake_mkfile("/log/run.log", NULL, 0u); /* 邻居文件(同卷) */ + ff_fake_inject_rc(SOE_PATH, FF_FAKE_OP_OPEN, FR_INVALID_NAME, 2); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 1u, 1u); + soe_store_drain(); /* open 失败 -> -1 */ + ok = (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 0) + && (ff_fake_exists("/log") == 2) + && (ff_fake_exists("/log/run.log") == 1) + && (s_ring_tail == 0u) && (g_soe_err_count == 1); + CHECK(ok, "TC-SOESTORE-022 FR_INVALID_NAME -> 只留痕失败;不 unlink、目录与邻居文件不动"); + + /* 023:反复失败的自愈上限:每轮最多 unlink 一次,条目与卷都不被吞 */ + reset_all(); + (void)ff_fake_mkfile("/log/run.log", NULL, 0u); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 2u, 2u); + for (i = 0u; i < 5u; i++) { + ff_fake_inject_write_short(SOE_PATH, 0u, 2); /* 每轮两次短写都失败 */ + soe_store_drain(); + } + ok = (g_ff_obs.calls[FF_FAKE_OP_UNLINK] <= (int)(5 * 1)) + && (ff_fake_exists("/log") == 2) && (ff_fake_exists("/log/run.log") == 1) + && (s_ring_tail == 0u); + ff_fake_inject_write_short(SOE_PATH, 0u, 0); /* 清掉不再需要的注入 */ + (void)ok; + CHECK(ok, "TC-SOESTORE-023 长跑反复失败:unlink 有界(<=1/轮),绝不升级为整卷动作"); + + /* 024:假卷严格 8.3 校验的保真自检 —— 生产路径必过、点开头必被拒 */ + reset_all(); + { + static FIL probe; + FRESULT fr_ok; + FRESULT fr_dot; + FRESULT fr_colon; + + fr_ok = f_open(&probe, SOE_PATH, FA_WRITE | FA_OPEN_APPEND); + (void)f_close(&probe); + fr_dot = f_open(&probe, "/log/.fschk", FA_WRITE | FA_CREATE_ALWAYS); + if (fr_dot == FR_OK) { + (void)f_close(&probe); + } + fr_colon = f_open(&probe, "1:/log/soe.log", FA_WRITE | FA_OPEN_APPEND); + if (fr_colon == FR_OK) { + (void)f_close(&probe); + } + CHECK((fr_ok == FR_OK) + && ((fr_dot == FR_INVALID_NAME) || (fr_dot == FR_INVALID_DRIVE)) + && (fr_colon == FR_INVALID_DRIVE), + "TC-SOESTORE-024 假件保真:/log/soe.log 过 8.3,.fschk 与坏盘符被拒"); + } + /* 生产路径本身合规的正向断言:改名成非 8.3 会让上面那条保真自检变红 */ + reset_all(); + soe_store_post((uint8_t)SOE_T_ELM_ASSERT, 1u, 1u); + soe_store_drain(); + CHECK((strcmp(g_ff_obs.last_open_path, "/LOG/SOE.LOG") == 0) + && (g_ff_obs.last_open_rc == (int)FR_OK), + "TC-SOESTORE-024b SOE_PATH 在严格 8.3 卷上确实打得开(改名=静默写失败的防线)"); +} + +/* ===================== 入口 ===================== */ + +int main(void) +{ + printf("=== SOE Store Tests ===\n"); + + tc_post_side(); + tc_drain_paths(); + tc_name_error_not_volume(); + + printf("\n=== SOE Store Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/soe_store/stub/cmsis_os.h b/test/host/soe_store/stub/cmsis_os.h new file mode 100644 index 00000000..c74b1843 --- /dev/null +++ b/test/host/soe_store/stub/cmsis_os.h @@ -0,0 +1,91 @@ +/* + * 模块名称:主机侧单测桩 - cmsis_os.h(soe_store 套件专用) + * 模块功能:给 App/log/soe_store.c 一个主机可编译的 CMSIS-RTOS V2 **消息队列 + + * 内核状态**最小子集。 + * + * ⚠ 仅供 test/host/soe_store/ 使用,不参与 MCU 构建。 + * 真实 Middlewares/.../CMSIS_RTOS_V2/cmsis_os.h 第 126/127/155 行会拉 + * FreeRTOS.h + task.h + cmsis_os2.h(整棵内核 + portmacro),PC 上编不动。 + * + * 保真点(逐条对照真实头,签名一字不改;改动真实头即编译期报错): + * - osMessageQueueId_t = void * (cmsis_os2.h:207) + * - osMessageQueueAttr_t 六字段同序同型 (cmsis_os2.h:272~279) + * ——soe_store.c 用 .name 指定成员初始化,字段名也必须一致 + * - osStatus_t osMessageQueuePut(id, const void *, uint8_t, uint32_t) (:689) + * - osStatus_t osMessageQueueGet (id, void *, uint8_t *, uint32_t) (:697) + * - osMessageQueueId_t osMessageQueueNew(uint32_t, uint32_t, const attr *)(:676) + * - osKernelState_t osKernelGetState(void) (cmsis_os2.h:298) + * - osOK = 0 / osErrorResource = -3 (cmsis_os2.h:177/180) + * - osKernelRunning = 2 (cmsis_os2.h:63) + * 本模块只用到内核状态的两个值,其余按真实枚举原样列出(少列会让"顺手改了 + * 状态判定"这类漂移编不出来)。 + * + * 队列行为由测试单元给**真 FIFO + 满/空语义**的强符号实现(不是恒返 osOK 的空壳): + * soe_store_post 的"队列满就丢弃并计数"、drain 的"取空即停"全靠这两条才测得出。 + * + * 创建日期:2026-10-24 + */ + +#ifndef CMSIS_OS_H +#define CMSIS_OS_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* 不透明句柄(真实 cmsis_os2.h:195~207 全是 void *) */ +typedef void *osMessageQueueId_t; +typedef void *osThreadId_t; +typedef void *osTimerId_t; +typedef void *osEventFlagsId_t; +typedef void *osMutexId_t; +typedef void *osSemaphoreId_t; + +/* 状态码(cmsis_os2.h:176~186) */ +typedef enum { + osOK = 0, + osError = -1, + osErrorTimeout = -2, + osErrorResource = -3, + osErrorParameter = -4, + osErrorNoMemory = -5, + osErrorISR = -6 +} osStatus_t; + +/* 内核状态(cmsis_os2.h:60~68) */ +typedef enum { + osKernelInactive = 0, + osKernelReady = 1, + osKernelRunning = 2, + osKernelLocked = 3, + osKernelSuspended = 4, + osKernelError = -1 +} osKernelState_t; + +/* 消息队列属性(cmsis_os2.h:272~279,字段名与顺序原样) */ +typedef struct { + const char *name; + uint32_t attr_bits; + void *cb_mem; + uint32_t cb_size; + void *mq_mem; + uint32_t mq_size; +} osMessageQueueAttr_t; + +osMessageQueueId_t osMessageQueueNew(uint32_t msg_count, uint32_t msg_size, + const osMessageQueueAttr_t *attr); +osStatus_t osMessageQueuePut(osMessageQueueId_t mq_id, const void *msg_ptr, + uint8_t msg_prio, uint32_t timeout); +osStatus_t osMessageQueueGet(osMessageQueueId_t mq_id, void *msg_ptr, + uint8_t *msg_prio, uint32_t timeout); +uint32_t osMessageQueueGetCount(osMessageQueueId_t mq_id); +osStatus_t osMessageQueueReset(osMessageQueueId_t mq_id); +osKernelState_t osKernelGetState(void); + +#ifdef __cplusplus +} +#endif + +#endif /* CMSIS_OS_H */ diff --git a/test/host/soe_store/stub/dbg_log.h b/test/host/soe_store/stub/dbg_log.h new file mode 100644 index 00000000..551a0182 --- /dev/null +++ b/test/host/soe_store/stub/dbg_log.h @@ -0,0 +1,30 @@ +/* + * 模块名称:主机侧单测桩 - dbg_log.h(soe_store 套件专用) + * 模块功能:DBG_* 换成计数,让"post 侧一条日志都不许打、drain 侧按差值只打一次" + * 成为可断言的行为。 + * + * ⚠ 仅供 test/host/soe_store/ 使用,不参与 MCU 构建。 + * + * 为什么这条计数在本模块**尤其**重要(App/log/soe_store.c 的函数头原话): + * soe_store_post() 由 protectTask 调用,该任务的静态最坏链 = 经 s_log_active_edge + * 的 console 尾链(872 B,B5 实测),post 里任何日志调用点都会再垫高一帧 + * (stack_report 实测 +320 B → FAIL)。所以"post 零 DBG"不是风格问题, + * 是栈预算硬约束;真实宏展开成 snprintf + 192 B 行缓冲,只有把宏换成计数 + * 才能在主机上把这条钉住。 + * + * 创建日期:2026-10-24 + */ + +#ifndef DBG_LOG_H +#define DBG_LOG_H + +/* 计数定义在 soe_store_test.c */ +extern int g_soe_err_count; +extern int g_soe_info_count; +extern int g_soe_debug_count; + +#define DBG_INFO(fmt, ...) do { (void)(fmt); g_soe_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_soe_debug_count++; } while (0) +#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_soe_err_count++; } while (0) + +#endif /* DBG_LOG_H */ diff --git a/test/host/soe_store/stub/main.h b/test/host/soe_store/stub/main.h new file mode 100644 index 00000000..25e84323 --- /dev/null +++ b/test/host/soe_store/stub/main.h @@ -0,0 +1,34 @@ +/* + * 模块名称:主机侧单测桩 - main.h(soe_store 套件专用) + * 模块功能:截断 App/util/sys_clock.h -> Drivers/BSP/SD2506/sd2506.h -> main.h + * -> Inc/main.h -> stm32f4xx_hal.h 这条 include 链。 + * + * ⚠ 仅供 test/host/soe_store/ 使用,不参与 MCU 构建。 + * 真实 Inc/main.h 第一行就 #include "stm32f4xx_hal.h"(整棵 HAL + CMSIS), + * PC 上编不动;本桩与 test/host/sys_clock/stub/main.h 同一招(该套件已验证 + * "真实 sys_clock.h + 真实 sd2506.h + 桩 main.h"这条组合可行),好处是 + * **sys_clock.h / sd2506.h 保持真实**:sys_clock_get() 的签名一改即编译期报错, + * 不必像 test/host/time_sync 那样连 sys_clock.h 一起桩掉。 + * + * 创建日期:2026-10-24 + */ + +#ifndef __MAIN_H +#define __MAIN_H + +#include /* size_t:真实 sys_clock.h 的 get_str 原型要用 */ +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* HAL 毫秒时基(MCU 端 = TIM7 中断累加)。本模块不读它,声明在此只为让 + * 真实 main.h 的消费者(sd2506.h / sys_clock.h)在 PC 上也拿得到同一符号。 */ +uint32_t HAL_GetTick(void); + +#ifdef __cplusplus +} +#endif + +#endif /* __MAIN_H */ diff --git a/test/host/sys_log_mcu/run_unit_test.py b/test/host/sys_log_mcu/run_unit_test.py new file mode 100644 index 00000000..3154b9d7 --- /dev/null +++ b/test/host/sys_log_mcu/run_unit_test.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +r"""运行/自检记录落盘后端(sys_log_mcu)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/sys_log_mcu/sys_log_mcu_test.c(以 #include 纳入**生产源** + App/log/sys_log_mcu.c,并纳入共享假卷 test/host/common/fatfs_fake.c),在 PC 上离线 + 跑 64B 定长记录格式、单条/批量两条写路径、syslog_drain 的 preboot+队列排空与 + "失败回投不丢条目"、N-24 的 8 条滑动读窗口 + 写者代数失效、挂载幂等与 + 上电/重启分类、sys_log_evidence 的 wdev.log 取证通道,失败即非 0 退出。 + 工具链与判定委托 test/host/common/unit_test_harness.py。 + +为什么要主机侧单测(而不是只用台架): + 1. **FA_OPEN_APPEND 不含 FA_WRITE**(存储架构 §10.1 第 11 条):漏写位时 f_open + 会成功、文件甚至被创建出来,只有 f_write 恒返 FR_DENIED —— 真机表现是 + "/log 写失败 x5/开机",2026-09-20 才定位到根因。假卷按真实 ff.c 判 fp->flag + 就能把这条做成必然失败的断言。 + 2. **f_write 簇分配失败 = FR_OK + 短写**(同一页第 11 条后半):只判 fr != FR_OK + 等于把丢记录当写成功;短写注入只有假卷给得出。 + 3. **挂载幂等**:syslog_mcu_mounted 曾在建 /log 失败后永久 no-op(运行期日志与自检 + 全部滞留),改成幂等 + 重试后又必须防"每 2s 刷一条同样错误" → 要注入 mkdir + 失败才测得到,且必须能数出"到底留痕几次"。 + 4. **读窗口 try 语义 / N-24 读侧**:窗口"命中即零 I/O"与"写者一落地就失效"是一枚硬币两面, + 只做前者会把陈旧读当正确、只做后者会退回逐条 open/seek/close 的旧实现。 + +桩的边界:ff.h / sys_log.h / soe_store.h / sys_clock.h / vol_lock.h 用**真实头**; + main.h 是桩,只为截断 sys_clock.h -> sd2506.h -> main.h -> stm32f4xx_hal.h; + cmsis_os.h / dbg_log.h 本目录桩。soe_store_drain() 用**计数桩**(本套只断言 + "每次 drain 各调一次"这个 B9 挂接点,SOE 落盘本身归 test/host/soe_store/)。 + FatFs 由 test/host/common/fatfs_fake.c 给 RAM 实现(**显式纳入,不用目录通配**)。 + sys_clock_* / vol_lock_* / osMessageQueue* 强符号桩写在本测试单元里。 + +用法(仓库根目录): + & '.venv\\Scripts\\python.exe' test/host/sys_log_mcu/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:docs/子系统设计/运行与自检记录设计说明.md §4.4/§4.5/§5.3、 +docs/架构设计/STM32F4-Base存储架构说明.md §10.1 第 10/11 条、 +docs/测试说明/主机单测覆盖率口径.md §9 P2 第 2 项 +创建日期:2026-10-24 +""" +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 必须最先(覆盖 cmsis_os.h / dbg_log.h / main.h),其余全用真实头。 +# 假卷 fatfs_fake.c 由测试单元 #include 纳入(同 ch395f_fake.c 惯例,显式列文件、不通配)。 +BUILD = TestBuild( + name="Sys Log Mcu Tests", + src=os.path.join(HERE, "sys_log_mcu_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "test", "host", "common"), + os.path.join(ROOT, "App", "log"), + os.path.join(ROOT, "App", "util"), + os.path.join(ROOT, "App", "fs"), + os.path.join(ROOT, "Drivers", "BSP"), + os.path.join(ROOT, "Drivers", "BSP", "SD2506"), + os.path.join(ROOT, "Lib", "FatFs"), + HERE, + ], + summary_marker="=== Sys Log Mcu Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/sys_log_mcu/stub/cmsis_os.h b/test/host/sys_log_mcu/stub/cmsis_os.h new file mode 100644 index 00000000..4c0d7272 --- /dev/null +++ b/test/host/sys_log_mcu/stub/cmsis_os.h @@ -0,0 +1,91 @@ +/* + * 模块名称:主机侧单测桩 - cmsis_os.h(sys_log_mcu 套件专用) + * 模块功能:给 App/log/sys_log_mcu.c(与 soe_store.c)一个/g主机可编译的 CMSIS-RTOS V2 **消息队列 + + * 内核状态**最小子集。 + * + * ⚠ 仅供 test/host/sys_log_mcu/ 使用,不参与 MCU 构建。 + * 真实 Middlewares/.../CMSIS_RTOS_V2/cmsis_os.h 第 126/127/155 行会拉 + * FreeRTOS.h + task.h + cmsis_os2.h(整棵内核 + portmacro),PC 上编不动。 + * + * 保真点(逐条对照真实头,签名一字不改;改动真实头即编译期报错): + * - osMessageQueueId_t = void * (cmsis_os2.h:207) + * - osMessageQueueAttr_t 六字段同序同型 (cmsis_os2.h:272~279) + * ——sys_log_mcu.c 用 .name 指定成员初始化,字段名也必须一致 + * - osStatus_t osMessageQueuePut(id, const void *, uint8_t, uint32_t) (:689) + * - osStatus_t osMessageQueueGet (id, void *, uint8_t *, uint32_t) (:697) + * - osMessageQueueId_t osMessageQueueNew(uint32_t, uint32_t, const attr *)(:676) + * - osKernelState_t osKernelGetState(void) (cmsis_os2.h:298) + * - osOK = 0 / osErrorResource = -3 (cmsis_os2.h:177/180) + * - osKernelRunning = 2 (cmsis_os2.h:63) + * 本模块只用到内核状态的两个值,其余按真实枚举原样列出(少列会让"顺手改了 + * 状态判定"这类漂移编不出来)。 + * + * 队列行为由测试单元给**真 FIFO + 满/空语义**的强符号实现(不是恒返 osOK 的空壳): + * soe_store_post 的"队列满就丢弃并计数"、drain 的"取空即停"全靠这两条才测得出。 + * + * 创建日期:2026-10-24 + */ + +#ifndef CMSIS_OS_H +#define CMSIS_OS_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* 不透明句柄(真实 cmsis_os2.h:195~207 全是 void *) */ +typedef void *osMessageQueueId_t; +typedef void *osThreadId_t; +typedef void *osTimerId_t; +typedef void *osEventFlagsId_t; +typedef void *osMutexId_t; +typedef void *osSemaphoreId_t; + +/* 状态码(cmsis_os2.h:176~186) */ +typedef enum { + osOK = 0, + osError = -1, + osErrorTimeout = -2, + osErrorResource = -3, + osErrorParameter = -4, + osErrorNoMemory = -5, + osErrorISR = -6 +} osStatus_t; + +/* 内核状态(cmsis_os2.h:60~68) */ +typedef enum { + osKernelInactive = 0, + osKernelReady = 1, + osKernelRunning = 2, + osKernelLocked = 3, + osKernelSuspended = 4, + osKernelError = -1 +} osKernelState_t; + +/* 消息队列属性(cmsis_os2.h:272~279,字段名与顺序原样) */ +typedef struct { + const char *name; + uint32_t attr_bits; + void *cb_mem; + uint32_t cb_size; + void *mq_mem; + uint32_t mq_size; +} osMessageQueueAttr_t; + +osMessageQueueId_t osMessageQueueNew(uint32_t msg_count, uint32_t msg_size, + const osMessageQueueAttr_t *attr); +osStatus_t osMessageQueuePut(osMessageQueueId_t mq_id, const void *msg_ptr, + uint8_t msg_prio, uint32_t timeout); +osStatus_t osMessageQueueGet(osMessageQueueId_t mq_id, void *msg_ptr, + uint8_t *msg_prio, uint32_t timeout); +uint32_t osMessageQueueGetCount(osMessageQueueId_t mq_id); +osStatus_t osMessageQueueReset(osMessageQueueId_t mq_id); +osKernelState_t osKernelGetState(void); + +#ifdef __cplusplus +} +#endif + +#endif /* CMSIS_OS_H */ diff --git a/test/host/sys_log_mcu/stub/dbg_log.h b/test/host/sys_log_mcu/stub/dbg_log.h new file mode 100644 index 00000000..24b724ac --- /dev/null +++ b/test/host/sys_log_mcu/stub/dbg_log.h @@ -0,0 +1,29 @@ +/* + * 模块名称:主机侧单测桩 - dbg_log.h(sys_log_mcu 套件专用) + * 模块功能:DBG_* 换成计数,让"写故障只报第一次(L2 去重)"与"挂载失败重试不刷屏" + * 成为可断言的行为。 + * + * ⚠ 仅供 test/host/sys_log_mcu/ 使用,不参与 MCU 构建。 + * + * 为什么本模块尤其需要计数而不是直通打印:sys_log_mcu.c 的 s_append_rec() 里 + * 有一条刻意设计的静默规则(源码注释)—— + * "L2 错误去重:持续故障只报第一次,恢复前静默(消除每次开机同一错误刷屏)" + * 直通 printf 只能让人眼看,"到底报了几次"必须可数才能钉住;而另一头, + * 挂载幂等里的"建 /log 失败日志去重(成功复位)"同样是次断言。 + * + * 创建日期:2026-10-24 + */ + +#ifndef DBG_LOG_H +#define DBG_LOG_H + +/* 计数定义在 sys_log_mcu_test.c */ +extern int g_sys_err_count; +extern int g_sys_info_count; +extern int g_sys_debug_count; + +#define DBG_INFO(fmt, ...) do { (void)(fmt); g_sys_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_sys_debug_count++; } while (0) +#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_sys_err_count++; } while (0) + +#endif /* DBG_LOG_H */ diff --git a/test/host/sys_log_mcu/stub/main.h b/test/host/sys_log_mcu/stub/main.h new file mode 100644 index 00000000..b1556178 --- /dev/null +++ b/test/host/sys_log_mcu/stub/main.h @@ -0,0 +1,34 @@ +/* + * 模块名称:主机侧单测桩 - main.h(sys_log_mcu 套件专用) + * 模块功能:截断 App/util/sys_clock.h -> Drivers/BSP/SD2506/sd2506.h -> main.h + * -> Inc/main.h -> stm32f4xx_hal.h 这条 include 链。 + * + * ⚠ 仅供 test/host/sys_log_mcu/ 使用,不参与 MCU 构建。 + * 真实 Inc/main.h 第一行就 #include "stm32f4xx_hal.h"(整棵 HAL + CMSIS), + * PC 上编不动;本桩与 test/host/sys_clock/stub/main.h 同一招(该套件已验证 + * "真实 sys_clock.h + 真实 sd2506.h + 桩 main.h"这条组合可行),好处是 + * **sys_clock.h / sd2506.h 保持真实**:sys_clock_get() 的签名一改即编译期报错, + * 不必像 test/host/time_sync 那样连 sys_clock.h 一起桩掉。 + * + * 创建日期:2026-10-24 + */ + +#ifndef __MAIN_H +#define __MAIN_H + +#include /* size_t:真实 sys_clock.h 的 get_str 原型要用 */ +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* HAL 毫秒时基(MCU 端 = TIM7 中断累加)。本模块不读它,声明在此只为让 + * 真实 main.h 的消费者(sd2506.h / sys_clock.h)在 PC 上也拿得到同一符号。 */ +uint32_t HAL_GetTick(void); + +#ifdef __cplusplus +} +#endif + +#endif /* __MAIN_H */ diff --git a/test/host/sys_log_mcu/sys_log_mcu_test.c b/test/host/sys_log_mcu/sys_log_mcu_test.c new file mode 100644 index 00000000..2007825e --- /dev/null +++ b/test/host/sys_log_mcu/sys_log_mcu_test.c @@ -0,0 +1,1534 @@ +/* + * 模块名称:运行/自检记录落盘后端(sys_log_mcu)主机侧单元测试(L0.5) + * 模块功能:在 PC 上离线验证 App/log/sys_log_mcu.c 的四族契约—— + * A 64B 定长记录格式(时间戳 / epoch ASCII / 描述三段 + 截断 + 占位) + * B 写侧(单条 s_append_rec、批量 s_append_many、N-24 的"一批一次 + * open/write/close"、容量轮转、L1 自愈、L2 留痕去重、卷锁 try) + * C 排空(syslog_drain 的 preboot + 队列两条来源、换文件分批、 + * 失败回投不丢条目、-3 退回慢路径) + * D 读侧(N-24 的 8 条滑动窗口 + 写者代数失效 + 越界/忙/短读处置) + * 外加 syslog_mcu_mounted 的 挂载幂等与上电/重启分类、sys_log_evidence + * 的 wdev.log 取证通道(8KB 护栏 + 第二条写必须在释放卷锁之后)。 + * + * 被测对象:真实源文件 App/log/sys_log_mcu.c(#include 原样纳入,不改生产代码)。 + * 假件与桩: + * test/host/common/fatfs_fake.{h,c} —— 假 FatFs 内存卷(真实 ff.h 声明) + * soe_rec.c 不纳入;soe_store_drain() 用**计数桩**(B9 挂接点本身由 + * test/host/soe_store/ 那套测,本套只断言"每次 drain 各调一次") + * stub/{cmsis_os,dbg_log,main}.h 本目录桩;sys_clock_* / vol_lock_* / + * osMessageQueue* 的可控强符号实现写在本文件里 + * + * 为什么要主机侧单测(而不是只用台架)——本文件的四条坑全是"日志看不出来"型的: + * 1. **FA_OPEN_APPEND 不含 FA_WRITE**(§10.1 第 11 条):漏写位时 f_open 会成功 + * (文件甚至被创建出来),只有 f_write 恒返 FR_DENIED(7)。真机表现是 + * "/log 写失败 x5/开机",2026-09-20 才定位到。→ 用假卷把这条做成必然失败的断言。 + * 2. **f_write 簇分配失败 = FR_OK + 短写**:只判 fr != FR_OK 就等于把丢记录当成功。 + * 3. **挂载幂等**:syslog_mcu_mounted 曾在建 /log 失败后永久 no-op(运行期日志与自检 + * 全部滞留),且 2s 重试会刷屏 → 幂等 + 留痕去重都要注入 mkdir 失败才测得到。 + * 4. **读窗口 try 语义 / N-24 读侧**:读窗口"命中即零 I/O"与"写者一落地就必须失效重填"是同一 + * 枚硬币的两面——只做前者会把陈旧读当正确,只做后者会退化成逐条 open/seek/close + * 的旧实现(UI 翻 20 条 = 20 次 FAT 往返)。两者都靠假卷的调用计数才断言得了。 + * + * 覆盖点(TC 号族 SYSLOG,形如 TC-SYSLOG-001,族内按功能分档;缺口在小节末尾如实登记): + * —— A 记录格式 —— + * TC-SYSLOG-001 布局:[0..18] 时间戳、[19]=' '、[20..29]=epoch "%010u"、[30]=' '、 + * [31..] 描述、尾部 NUL 填充到 64B + * TC-SYSLOG-002 描述超长截断到 SYSLOG_DESC_MAX,且不越 64B 边界 + * TC-SYSLOG-003 墙钟未建立:get_str 返 NULL -> 占位 "1970-01-01 08:00:00"; + * get() 返 UINT32_MAX -> epoch 字段落 "0000000000" + * TC-SYSLOG-004 单条路径与批量路径产出的同内容记录**逐字节相同**(N-24 抽出 + * s_format_rec 的全部理由:UI 按定长字段偏移解析) + * —— B 写侧 —— + * TC-SYSLOG-010 单条落盘成功 + 打开模式必须 FA_WRITE|FA_OPEN_APPEND;close 配对 + * TC-SYSLOG-011 只给 FA_OPEN_APPEND 的对照保真:f_write 恒 FR_DENIED(陷阱一) + * TC-SYSLOG-012 卷忙(try 失败)-> 返回 -2、零 FatFs、不计故障、不 release + * TC-SYSLOG-013 FR_OK + 短写 -> 判失败 -> unlink 重建重试 -> 恰好 64 字节 + * TC-SYSLOG-014 容量满(1024 条)-> 先 unlink 再写;新文件只剩本次一条 + * TC-SYSLOG-015 满且 unlink 失败(真错误码)-> 返回 -1 并释放卷锁 + * TC-SYSLOG-016 满且 unlink 返 FR_NO_FILE -> 视为无害,继续写 + * TC-SYSLOG-017 无效 id / 空指针守卫 -> -1 + * TC-SYSLOG-018 写成功 -> 读侧代数 s_rd_gen 自增(窗口失效钩子) + * TC-SYSLOG-019 连续两轮写失败只留痕一次;成功一次复位后再失败才再留痕 + * TC-SYSLOG-020 批量:8 条 = 一次 open + 一次 512B write + 一次 close + * TC-SYSLOG-021 批量入参守卫(id 越界 / n=0 / n>8 / 空指针)-> -1 + * TC-SYSLOG-022 剩余容量装不下一批 -> 返回 -3 且**不 unlink**(轮转只在慢路径) + * TC-SYSLOG-023 批量卷忙 -> -2;批量短写 -> unlink 重试成功 + * TC-SYSLOG-024 批量持续失败 -> -1 + 留痕去重(与单条同一判据) + * —— C 排空 —— + * TC-SYSLOG-030 卷未就绪 -> drain 直接返回,preboot 条目滞留不丢 + * TC-SYSLOG-031 首次 drain 惰性建队列(内核对象在 osKernelStart 之后) + * TC-SYSLOG-032 preboot 8 条同文件 -> 一批 512B 落盘 + preboot 计数清零 + * TC-SYSLOG-033 两个文件混排 -> 按文件分批,顺序不乱、条目不丢 + * TC-SYSLOG-034 换文件那条不消耗(preboot 下标回退 / 队列回投) + * TC-SYSLOG-035 卷忙 -> 整批回投队列,下一轮全部落盘(旧实现 -2 直接丢一条) + * TC-SYSLOG-036 队列满回投失败 -> "hold-over drop" 留痕,不崩 + * TC-SYSLOG-037 -3 -> 逐条慢路径,容量轮转只发生一次 + * TC-SYSLOG-038 慢路径中途写失败 -> 该条丢弃留痕 + 尾部回投 + * TC-SYSLOG-039 批量整体 -1 -> 整批丢弃留痕一次并结束本轮 + * TC-SYSLOG-040 drain 尾部各调一次 soe_store_drain()(B9 挂接点) + * TC-SYSLOG-041 post:内核未运行走 preboot、满则丢弃留痕;内核运行走队列、满则留痕; + * id 越界/NULL 描述静默丢弃 + * —— D 读侧 —— + * TC-SYSLOG-050 count = filesize/64(O(1));文件缺失/未挂载 -> 0 + * TC-SYSLOG-051 窗口命中 -> 第二次读**零 FatFs 调用** + * TC-SYSLOG-052 写者落地 -> 代数变化 -> 窗口重填并读到新内容 + * TC-SYSLOG-053 窗口按 8 条对齐:跨窗读 idx=8 -> 一次 open + lseek(512) + read(64) + * TC-SYSLOG-054 越界 idx -> -1;空文件 -> -1 + * TC-SYSLOG-055 读时卷忙 -> -1(读窗口 try 语义:UI 每 100ms 喂心跳,绝不阻塞) + * TC-SYSLOG-056 短读 -> 作废窗口(s_rd_win_cnt=0),不把半窗当有效命中 + * TC-SYSLOG-057 get_record:缓冲容量守卫 + 描述按 desc_len 截断 + * TC-SYSLOG-058 last_epoch:无记录 -1;有记录解析 10 位 ASCII + * —— E mounted / 上电重启分类 —— + * TC-SYSLOG-060 /log 不存在 -> f_mkdir 成功 + 幂等(第二次不再建目录) + * TC-SYSLOG-061 f_mkdir 失败 -> 不就绪、留痕去重、后续重试成功并复位 + * TC-SYSLOG-062 卷忙 -> 本轮顺延、不置就绪 + * TC-SYSLOG-063 末条 epoch 距 now < 600s -> "reboot";>=600s 或墙钟无效 -> "power on" + * TC-SYSLOG-064 f_stat /log 已存在 -> 不再调 f_mkdir(幂等靠 stat,不是靠状态位) + * —— F 取证通道 sys_log_evidence —— + * TC-SYSLOG-070 写 /log/wdev.log 变长行("ts 描述\\r\\n")+ 随后补一条 run.log + * TC-SYSLOG-071 卷锁顺序硬约束:第二条写在 release **之后**(非递归锁,反了自死锁) + * TC-SYSLOG-072 >8192B -> 先 unlink 再写(防长跑无界增长) + * TC-SYSLOG-073 卷忙 -> 返回 -1 且 wdev.log 零字节(不阻塞喂狗点) + * TC-SYSLOG-074 wdev 写失败 -> 返回 -1,但第二条 run.log 仍写(独立证据语义) + * —— H 补齐分支(自愈 / 锁后二次校验 / 回投 / 占位时标 / 空指针)—— + * TC-SYSLOG-090 批量 open 失败一次 -> unlink 重开 -> 整批落全、不留痕 + * TC-SYSLOG-091 批量 open 持续失败 -> -1 + 留痕一次 + 已放锁 + * TC-SYSLOG-092 取锁后二次 count 失败 -> 放锁返回 -1(漏放 = FTP 永久等不到卷) + * TC-SYSLOG-093 读时 f_open 失败 -> 放锁 -1;下轮恢复后窗口正常重填 + * TC-SYSLOG-094 无墙钟 -> wdev.log 落占位时标(取证行也必须可解析) + * TC-SYSLOG-095 wdev 短写 -> 返回 -1,第二条独立证据仍落 run.log + * TC-SYSLOG-096 队列创建失败 -> 一条都不排(不静默丢),堆恢复后排空并接上 SOE + * TC-SYSLOG-097 队列侧换文件那条原样回投 -> 三条全落、各文件内顺序不乱 + * TC-SYSLOG-098 desc_len=29 时截断分支不可达(守卫取值导致,登记死分支) + * TC-SYSLOG-099 p_rec / p_desc 空指针守卫(守卫在取锁之前) + * TC-SYSLOG-100 last_epoch 读失败 -> -1 且 *p_epoch 清零 + * —— G 陷阱二:名字/参数类错误码严禁升级成卷操作 —— + * TC-SYSLOG-080 短名违规分档保真:体>8 / 扩展>3 / 非法字符 / 以点开头(.fschk) + * 各自落 FR_INVALID_NAME;含 ':' 落 FR_INVALID_DRIVE + * TC-SYSLOG-081 生产路径全过严格 8.3:/log /run.log /selftest.log /wdev.log /soe.log + * TC-SYSLOG-082 FR_INVALID_NAME 反复失败 20 轮:unlink 次数有界、/log 目录与 + * 邻居文件一根毛没动(自愈只针对自己的文件) + * + * 覆盖率台账(--module sys_log_mcu 自量):App/log/sys_log_mcu.c 310/313 行 = + * 99.0%、函数 12/12。未覆盖 3 行:L628(下面第三条死分支)、L727~728 + * (sys_log_evidence 里 snprintf 返回 <=0 的分支——宿主 snprintf 不可能失败, + * 假件也不伪造返回值;为刷数字去伪造等于把断言写在假件规则上,宁可留空)。 + * + * 本套照出的现状(**只钉现状、不改生产代码**;三条都进交 Lead 的报告): + * + * 【疑似缺陷 A:卷忙时整批日志被静默丢弃】sys_log_mcu.c:509~537 的批量路径里 + * done 先被置成 n,只有 rc==-3 与 rc==-1 两个分支会改它;**rc==-2(卷忙)没有 + * 任何分支**,于是紧随其后的 "for (i = done; i < n; i++) osMessageQueuePut(...)" + * 一次都不执行 —— 整批(最多 8 条)既没落盘也没回投,且不计故障、不打日志。 + * 而同一段代码自己的注释写的是"rc == -2(卷忙):整批回投队列,静默顺延", + * 函数头 465~470 行与设计文档 §4.4 又都写明"**条目不丢**(旧实现 -2 卷忙会 + * 直接丢掉那一条)"。⇒ 现状比旧实现更糟:旧实现丢 1 条,现在一次丢 8 条。 + * 触发条件极常见:卷锁 v1.2 之后 ftpTask 传文件期间 defaultTask 每 2s 排空 + * 一次,每次撞上卷忙就吞一批。对照:逐条慢路径(-3 分支)遇 -2 会正确回投 + * 尾部 —— 同一个错误码在两条路径上行为相反(TC-SYSLOG-035 与 035b 成对钉住)。 + * + * 【疑似缺陷 B:rc==-1 的留痕文案与实际行为相反】sys_log_mcu.c:527~529 打 + * DBG_ERROR("drop %lu entries id=%d"),但该分支把 done 置 0 -> 整批**回投队列**, + * 一条都没丢(下一轮重试)。排障时按"日志说丢了"去找会走错方向;而真正会丢的 + * 是上面那个没有任何日志的 -2 分支。另外写故障持续时每轮 drain 都会重投重 + * 试一遍(s_append_many 内部的 L2 去重只压得住它自己那条,压不住这条)。 + * + * 【死分支:syslog_get_record 的截断不可达】sys_log_mcu.c:616 的守卫是 + * desc_len <= SYSLOG_DESC_MAX 直接 -1,而函数里 dl 恒 <= SYSLOG_DESC_MAX, + * 于是 627~628 的 "if (dl >= desc_len) dl = desc_len - 1" 永不成立。 + * 无害(宁可拒绝也不越界),但"传小缓冲就能拿到截断内容"的直觉是错的(TC-SYSLOG-098)。 + * + * 故意没测(写在这里,不留"看起来全覆盖"的错觉): + * - 真跨任务并发:读窗口用"对齐 32 位读写在 ARM 上原子"免锁,主机 x64 + 单线程 + * 编不出真竞态;本套只断言单写者语义与代数失效逻辑本身。 + * - f_close 内部 sync_fs -> disk_ioctl(CTRL_SYNC) -> ftl_flush_cache() 的掉电落盘 + * 链:属 nand_port/GD5F 层,假卷里没有 FTL,"关文件即落盘"要靠真机 + N-22 台账。 + * - snprintf 失败(n<=0)分支:宿主 snprintf 不会失败,且假件不提供注入—— + * 为了刷覆盖率去伪造返回值等于把断言写在假件规则上,宁可留空。 + * - DBG 文本内容:桩只计数(真实宏展开成 snprintf + 192B 行缓冲)。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-24(P2 主机单测覆盖率批次,task-2) + */ + +#include +#include +#include +#include +#include + +#include "ff.h" +#include "fatfs_fake.h" +#include "cmsis_os.h" +#include "dbg_log.h" +#include "sys_clock.h" +#include "vol_lock.h" +#include "sys_log.h" +#include "soe_store.h" + +/* ===================== 断言框架 ===================== */ + +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) + +/* 路径常量:与生产 s_paths[] / sys_log_evidence() 里的字面量一致(写死在测试里 + * 是为了"改生产路径名必须同时改测试",改名静默失效正是 §10.1 第 10 条的老账) */ +#define P_RUN "/log/run.log" +#define P_SELFTEST "/log/selftest.log" +#define P_WDEV "/log/wdev.log" + +/* ===================== 假件与生产源码纳入 ===================== */ + +#include "fatfs_fake.c" +#include "sys_log_mcu.c" + +/* ===================== dbg_log 桩计数 ===================== */ + +int g_sys_err_count; +int g_sys_info_count; +int g_sys_debug_count; + +/* ===================== soe_store 挂接点计数桩 ===================== */ + +static int s_soe_drain_calls; + +void soe_store_drain(void) +{ + if (s_soe_drain_calls++ > 20000) { + (void)fprintf(stderr, "[FATAL] soe_store_drain guard tripped\n"); + exit(2); + } +} + +void soe_store_post(uint8_t type, uint8_t elm_id, uint32_t value) +{ + (void)type; (void)elm_id; (void)value; +} + +uint32_t soe_store_dropped(void) +{ + return 0u; +} + +/* ===================== 假墙钟 ===================== */ + +static uint32_t s_now = 1760234567u; +static int s_clock_ok = 1; + +uint32_t sys_clock_get(void) +{ + return (s_clock_ok != 0) ? s_now : UINT32_MAX; +} + +char *sys_clock_get_str(char *buf, size_t buf_size) +{ + struct tm gmt; + time_t t; + size_t need = SYSLOG_TS_LEN + 1u; + + if ((s_clock_ok == 0) || (buf == NULL) || (buf_size < need)) { + return NULL; + } + t = (time_t)s_now; +#if defined(_WIN32) + if (gmtime_s(&gmt, &t) != 0) { + return NULL; + } +#else + gmt = *gmtime(&t); +#endif + (void)strftime(buf, buf_size, "%Y-%m-%d %H:%M:%S", &gmt); + return buf; +} + +int sys_clock_init(void) { return 0; } +int sys_clock_set(uint32_t unix_secs, const sd2506_time_t *p_time) +{ + (void)unix_secs; (void)p_time; return 0; +} +int sys_clock_set_unix(uint32_t unix_secs) { (void)unix_secs; return 0; } +int sys_clock_get_time(sd2506_time_t *p_time) { (void)p_time; return -1; } +uint32_t HAL_GetTick(void) { return 0u; } + +/* ===================== 假卷锁(try 语义 + 配对检查) ===================== */ + +static int s_lock_busy_left; +static int s_lock_busy_skip; /* 先放行 N 次再开始忙(同一次 drain 里多把锁的场景) */ +static int s_lock_waits; +static int s_lock_releases; +static int s_lock_held; +static int s_lock_seq_err; + +static void lock_busy_next(int n) +{ + s_lock_busy_skip = 0; + s_lock_busy_left = n; +} + +/* 前 skip 次 vol_lock_wait 正常放行,接下来 busy 次返回"忙"。 + * 需要它是因为同一次 syslog_drain() 里会连拿两把锁:批量路径那把必须先成功, + * 才谈得上"退回逐条慢路径后第二把忙"。 */ +static void lock_busy_after(int skip, int busy) +{ + s_lock_busy_skip = skip; + s_lock_busy_left = busy; +} + +static void locks_clear(void) +{ + s_lock_busy_left = 0; + s_lock_busy_skip = 0; + s_lock_waits = 0; + s_lock_releases = 0; + s_lock_held = 0; + s_lock_seq_err = 0; +} + +int vol_lock_init(void) { return 0; } + +int vol_lock_wait(uint32_t timeout_ms) +{ + int r; + + (void)timeout_ms; + if (s_lock_waits > 20000) { + (void)fprintf(stderr, "[FATAL] vol_lock_wait guard tripped\n"); + exit(2); + } + if (s_lock_busy_skip > 0) { + s_lock_busy_skip--; + r = 1; + } else { + r = (s_lock_busy_left > 0) ? 0 : 1; + if (s_lock_busy_left > 0) { + s_lock_busy_left--; + } + } + s_lock_waits++; + if (r != 0) { + s_lock_held = 1; + } + return r; +} + +void vol_lock_release(void) +{ + s_lock_releases++; + if (s_lock_held == 0) { + s_lock_seq_err++; + } + s_lock_held = 0; +} + +/* ===================== 假消息队列(真 FIFO + 满/空语义) ===================== */ + +#define MQ_SLOTS 16u +#define MQ_MAXMSG 24u /* sizeof(syslog_entry_t) 在 x64 = 16,留余量 */ + +typedef struct { + int used; + uint8_t data[MQ_MAXMSG]; +} mq_slot_t; + +static mq_slot_t s_q_pool[MQ_SLOTS]; +static uint32_t s_q_head; +static uint32_t s_q_cnt; +static uint32_t s_q_cap; +static uint32_t s_q_msgsz; +static int s_q_new_calls; +static int s_q_new_fail; +static int s_q_get_calls; +static int s_q_put_calls; + +static void queue_clear(void) +{ + (void)memset(s_q_pool, 0, sizeof(s_q_pool)); + s_q_head = 0u; + s_q_cnt = 0u; + s_q_cap = SYSLOG_QUEUE_MAX; + s_q_msgsz = (uint32_t)sizeof(syslog_entry_t); + s_q_new_calls = 0; + s_q_new_fail = 0; + s_q_get_calls = 0; + s_q_put_calls = 0; +} + +osMessageQueueId_t osMessageQueueNew(uint32_t msg_count, uint32_t msg_size, + const osMessageQueueAttr_t *attr) +{ + (void)attr; + s_q_new_calls++; + if (s_q_new_fail != 0) { + return NULL; + } + s_q_cap = msg_count; + s_q_msgsz = msg_size; + s_q_head = 0u; + s_q_cnt = 0u; + return (osMessageQueueId_t)&s_q_pool[0]; +} + +osStatus_t osMessageQueuePut(osMessageQueueId_t mq_id, const void *msg_ptr, + uint8_t msg_prio, uint32_t timeout) +{ + uint32_t tail; + + (void)mq_id; (void)msg_prio; (void)timeout; + if (s_q_put_calls++ > 20000) { + (void)fprintf(stderr, "[FATAL] osMessageQueuePut guard tripped\n"); + exit(2); + } + if ((msg_ptr == NULL) || (s_q_cnt >= s_q_cap) || (s_q_msgsz > MQ_MAXMSG)) { + return osErrorResource; + } + tail = (s_q_head + s_q_cnt) % MQ_SLOTS; + (void)memcpy(s_q_pool[tail].data, msg_ptr, s_q_msgsz); + s_q_pool[tail].used = 1; + s_q_cnt++; + return osOK; +} + +osStatus_t osMessageQueueGet(osMessageQueueId_t mq_id, void *msg_ptr, + uint8_t *msg_prio, uint32_t timeout) +{ + (void)mq_id; (void)msg_prio; (void)timeout; + if (s_q_get_calls++ > 20000) { + (void)fprintf(stderr, "[FATAL] osMessageQueueGet guard tripped\n"); + exit(2); + } + if ((msg_ptr == NULL) || (s_q_cnt == 0u)) { + return osErrorResource; + } + (void)memcpy(msg_ptr, s_q_pool[s_q_head].data, s_q_msgsz); + s_q_pool[s_q_head].used = 0; + s_q_head = (s_q_head + 1u) % MQ_SLOTS; + s_q_cnt--; + return osOK; +} + +uint32_t osMessageQueueGetCount(osMessageQueueId_t mq_id) +{ + (void)mq_id; + return s_q_cnt; +} + +osStatus_t osMessageQueueReset(osMessageQueueId_t mq_id) +{ + (void)mq_id; + s_q_head = 0u; + s_q_cnt = 0u; + return osOK; +} + +/* ===================== 内核状态开关 ===================== */ + +static osKernelState_t s_kernel_state = osKernelInactive; + +osKernelState_t osKernelGetState(void) +{ + return s_kernel_state; +} + +/* ===================== 复位与铺状态 ===================== */ + +static void reset_all(void) +{ + ff_fake_reset(); + locks_clear(); + queue_clear(); + s_now = 1760234567u; + s_clock_ok = 1; + s_kernel_state = osKernelInactive; + g_sys_err_count = 0; + g_sys_info_count = 0; + g_sys_debug_count = 0; + s_soe_drain_calls = 0; + /* 生产模块的 static 残留:逐位显式清(漏一个就会"上一场景的尾巴"咬下一场景) */ + (void)memset(s_preboot, 0, sizeof(s_preboot)); + s_preboot_cnt = 0u; + (void)memset(s_batch_src, 0, sizeof(s_batch_src)); + s_queue = NULL; + s_fs_ready = 0u; + s_mount_fail_reported = 0u; + (void)memset(s_rd_win, 0, sizeof(s_rd_win)); + s_rd_win_id = -1; + s_rd_win_base = 0u; + s_rd_win_cnt = 0u; + s_rd_win_gen = 0u; + s_rd_gen[SYSLOG_RUN] = 0u; + s_rd_gen[SYSLOG_SELFTEST] = 0u; + /* 卷状态:/log 由 defaultTask 挂载时建好;本套多数场景直接假定已建, + * "没建 -> 补建" 那条路径单独用 TC-SYSLOG-060/061 测 */ + (void)ff_fake_mkdir_direct("/log"); +} + +/* 就绪但**不**走 mounted()(避免上电那条记录混进断言) */ +static void ready_only(void) +{ + s_fs_ready = 1u; +} + +/* 用生产 API 落一条记录(时间戳/epoch 由假墙钟决定) */ +static void put_rec(syslog_id_t id, const char *desc) +{ + ready_only(); + (void)s_append(id, desc); +} + +/* 从假卷直读第 i 条原始记录 */ +static int raw_at(const char *path, uint32_t i, uint8_t *p_out) +{ + return (ff_fake_fetch(path, p_out, i * SYSLOG_REC_SIZE, SYSLOG_REC_SIZE) + == SYSLOG_REC_SIZE) ? 1 : 0; +} + +/* ===================== A 记录格式 ===================== */ + +static void tc_format(void) +{ + uint8_t rec[SYSLOG_REC_SIZE]; + uint8_t b[SYSLOG_REC_SIZE]; + int ok; + uint32_t i; + + printf("-- TC-SYSLOG-001..004 记录格式(64B 定长)--\n"); + + /* 001 布局 */ + reset_all(); + s_now = 1760234567u; + s_clock_ok = 1; + s_format_rec(SYSLOG_RUN, "hello", rec); + ok = (memcmp(rec, "2025-10-12 02:02:47", SYSLOG_TS_LEN) == 0) + && (rec[SYSLOG_TS_LEN] == ' ') + && (memcmp(rec + SYSLOG_EPOCH_OFFS, "1760234567", SYSLOG_EPOCH_LEN) == 0) + && (rec[SYSLOG_EPOCH_OFFS - 1u] == ' ') + && (memcmp(rec + SYSLOG_DESC_OFFS, "hello", 5u) == 0) + && (rec[SYSLOG_DESC_OFFS + 5u] == '\0'); + CHECK(ok, "TC-SYSLOG-001 [0..18]ts / [19]空格 / [20..29]epoch / [30]空格 / [31..]desc"); + ok = 1; + for (i = SYSLOG_DESC_OFFS + 6u; i < SYSLOG_REC_SIZE; i++) { + if (rec[i] != 0u) { + ok = 0; + } + } + CHECK(ok, "TC-SYSLOG-001b 尾部 NUL 填充到 64B(定长解析的地基)"); + + /* 002 描述截断 */ + reset_all(); + { + char big[SYSLOG_DESC_MAX + 16u]; + (void)memset(big, 'A', sizeof(big) - 1u); + big[sizeof(big) - 1u] = '\0'; + s_format_rec(SYSLOG_RUN, big, rec); + ok = 1; + for (i = 0u; i < SYSLOG_DESC_MAX; i++) { + if (rec[SYSLOG_DESC_OFFS + i] != (uint8_t)'A') { + ok = 0; + } + } + CHECK(ok && (rec[SYSLOG_DESC_OFFS + SYSLOG_DESC_MAX] == 0u) + && (rec[SYSLOG_REC_SIZE - 1u] == 0u), + "TC-SYSLOG-002 超长描述截到 28B,不越 64B 边界"); + } + + /* 003 墙钟未建立 */ + reset_all(); + s_clock_ok = 0; + s_format_rec(SYSLOG_RUN, "x", rec); + CHECK((memcmp(rec, "1970-01-01 08:00:00", SYSLOG_TS_LEN) == 0) + && (memcmp(rec + SYSLOG_EPOCH_OFFS, "0000000000", SYSLOG_EPOCH_LEN) == 0), + "TC-SYSLOG-003 无墙钟 -> 占位时间戳 + epoch 全 0(不造假时标)"); + + /* 004 单条 vs 批量逐字节相同 */ + reset_all(); + s_now = 1700000000u; + s_format_rec(SYSLOG_RUN, "same-content", rec); + ready_only(); + (void)memset(b, 0, sizeof(b)); + s_format_rec(SYSLOG_RUN, "same-content", b); + CHECK(memcmp(rec, b, SYSLOG_REC_SIZE) == 0, + "TC-SYSLOG-004 同一输入两次格式化逐字节一致(UI 定长偏移解析的前提)"); + reset_all(); + s_now = 1700000000u; + ready_only(); + s_format_rec(SYSLOG_RUN, "ab", rec); + { + uint8_t batch[SYSLOG_REC_SIZE * 2u]; + (void)memset(batch, 0, sizeof(batch)); + s_format_rec(SYSLOG_RUN, "ab", batch); + s_format_rec(SYSLOG_RUN, "ab", batch + SYSLOG_REC_SIZE); + CHECK((memcmp(rec, batch, SYSLOG_REC_SIZE) == 0) + && (memcmp(rec, batch + SYSLOG_REC_SIZE, SYSLOG_REC_SIZE) == 0), + "TC-SYSLOG-004b 批量路径复用同一 s_format_rec(两条都等于单条)"); + } +} + +/* ===================== B 写侧 ===================== */ + +static void tc_write_single(void) +{ + uint8_t rec[SYSLOG_REC_SIZE]; + int ok; + + printf("-- TC-SYSLOG-010..019 单条写路径 --\n"); + + /* 010 成功 + 打开模式 */ + reset_all(); + ready_only(); + ok = (s_append(SYSLOG_RUN, "boot") == 0) + && (g_ff_obs.last_open_mode == (BYTE)(FA_WRITE | FA_OPEN_APPEND)) + && (ff_fake_size(P_RUN) == SYSLOG_REC_SIZE) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 1) + && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 1) + && (g_ff_obs.calls[FF_FAKE_OP_CLOSE] == 1) + && (s_lock_releases == 1) && (s_lock_seq_err == 0) + && (g_sys_err_count == 0); + CHECK(ok, "TC-SYSLOG-010 单条落盘成功:模式带写位 + open/write/close 各一次 + 放锁"); + + /* 011 陷阱一保真对照 */ + reset_all(); + { + static FIL probe; + UINT bw = 99u; + FRESULT fr1; + FRESULT fr2; + + fr1 = f_open(&probe, P_RUN, FA_OPEN_APPEND); + fr2 = f_write(&probe, rec, SYSLOG_REC_SIZE, &bw); + (void)f_close(&probe); + CHECK((fr1 == FR_OK) && (fr2 == FR_DENIED) && (bw == 0u), + "TC-SYSLOG-011 只给 FA_OPEN_APPEND -> f_write 恒 FR_DENIED(2026-09-20 真根因)"); + } + + /* 012 卷忙 */ + reset_all(); + ready_only(); + lock_busy_next(1); + ok = (s_append(SYSLOG_RUN, "x") == -2); + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0) + && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 0) + && (s_lock_releases == 0) && (s_lock_seq_err == 0) && (g_sys_err_count == 0), + "TC-SYSLOG-012 卷忙 = 顺延:不碰盘、不 release、不计故障(喂狗点绝不阻塞)"); + + /* 013 短写 -> 自愈 */ + reset_all(); + ready_only(); + ff_fake_inject_write_short(P_RUN, SYSLOG_REC_SIZE - 1u, 1); + ok = (s_append(SYSLOG_RUN, "short") == 0); + CHECK(ok && (ff_fake_size(P_RUN) == SYSLOG_REC_SIZE) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) + && (g_ff_obs.last_write_bw == SYSLOG_REC_SIZE), + "TC-SYSLOG-013 FR_OK+短写 63B 被判失败 -> unlink 重建 -> 恰好 64 字节"); + + /* 014 容量满 -> 删除重建 */ + reset_all(); + ready_only(); + (void)ff_fake_mkfile_size(P_RUN, (uint32_t)SYSLOG_RUN_MAX * SYSLOG_REC_SIZE); + ok = (s_append(SYSLOG_RUN, "wrap") == 0); + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) + && (ff_fake_size(P_RUN) == SYSLOG_REC_SIZE) + && (syslog_count(SYSLOG_RUN) == 1u), + "TC-SYSLOG-014 满 1024 条 -> 先 unlink 再写,新文件只剩本次一条"); + + /* 015 满 + unlink 真失败 */ + reset_all(); + ready_only(); + (void)ff_fake_mkfile_size(P_RUN, (uint32_t)SYSLOG_RUN_MAX * SYSLOG_REC_SIZE); + ff_fake_inject_rc(P_RUN, FF_FAKE_OP_UNLINK, FR_DISK_ERR, 1); + ok = (s_append(SYSLOG_RUN, "x") == -1); + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0) + && (s_lock_releases == 1) && (s_lock_seq_err == 0), + "TC-SYSLOG-015 容量轮转时 unlink 失败 -> 返回 -1 且**已释放卷锁**(漏放 = FTP 永久拿不到卷)"); + + /* 016 满 + unlink 返 FR_NO_FILE 无害 */ + reset_all(); + ready_only(); + (void)ff_fake_mkfile_size(P_RUN, (uint32_t)SYSLOG_RUN_MAX * SYSLOG_REC_SIZE); + /* 注入只替换返回码、不真的删条目(真机上 FR_NO_FILE 的含义恰恰是"条目已不在"), + * 所以这里钉的是**处置**:该码不进错误分支、流程继续把这条写下去。 + * 文件大小断言留给 TC-SYSLOG-014(那里 unlink 是真删)。 */ + ff_fake_inject_rc(P_RUN, FF_FAKE_OP_UNLINK, FR_NO_FILE, 1); + ok = (s_append(SYSLOG_RUN, "gone") == 0); + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) + && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 1) && (g_sys_err_count == 0), + "TC-SYSLOG-016 unlink 返 FR_NO_FILE 被当无害:不进错误分支、不留痕、继续写"); + + /* 017 入参守卫 */ + reset_all(); + ready_only(); + ok = (s_append((syslog_id_t)SYSLOG_COUNT, "x") == -1) + && (s_append_rec(SYSLOG_RUN, NULL) == -1) + && (s_append(SYSLOG_RUN, NULL) == -1) + && (s_append_rec((syslog_id_t)9u, rec) == -1); + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0), + "TC-SYSLOG-017 id 越界 / 空 rec / 空 desc -> -1 且不碰卷锁与盘"); + + /* 018 写成功 -> 读侧代数自增 */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "a"); + ok = (s_rd_gen[SYSLOG_RUN] == 1u) && (s_rd_gen[SYSLOG_SELFTEST] == 0u); + (void)s_append(SYSLOG_SELFTEST, "b"); + CHECK(ok && (s_rd_gen[SYSLOG_SELFTEST] == 1u), + "TC-SYSLOG-018 每次落盘只自增本 id 的代数(另一文件的窗口不被误失效)"); + + /* 019 留痕去重 */ + reset_all(); + ready_only(); + ff_fake_inject_write_short(P_RUN, 0u, 4); + ok = (s_append(SYSLOG_RUN, "e1") == -1); + (void)s_append(SYSLOG_RUN, "e2"); /* 第二轮仍失败 */ + ok = ok && (g_sys_err_count == 1); + (void)s_append(SYSLOG_RUN, "ok"); /* 注入耗尽 -> 成功 -> 复位 */ + ff_fake_inject_write_short(P_RUN, 0u, 2); + (void)s_append(SYSLOG_RUN, "e3"); /* 再失败 -> 再留痕 */ + CHECK(ok && (g_sys_err_count == 2), + "TC-SYSLOG-019 持续故障只报第一次;恢复后再失败必须重新留痕"); +} + +static void tc_write_many(void) +{ + uint8_t batch[SYSLOG_BATCH_MAX * SYSLOG_REC_SIZE]; + uint32_t i; + int ok; + + printf("-- TC-SYSLOG-020..024 批量写路径(N-24)--\n"); + + /* 020 8 条一次 I/O */ + reset_all(); + ready_only(); + (void)memset(batch, 0x5A, sizeof(batch)); + ok = (s_append_many(SYSLOG_RUN, batch, SYSLOG_BATCH_MAX) == 0); + CHECK(ok && (ff_fake_size(P_RUN) == SYSLOG_BATCH_MAX * SYSLOG_REC_SIZE) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 1) + && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 1) + && (g_ff_obs.last_write_btw == SYSLOG_BATCH_MAX * SYSLOG_REC_SIZE) + && (g_ff_obs.last_write_bw == SYSLOG_BATCH_MAX * SYSLOG_REC_SIZE) + && (g_ff_obs.calls[FF_FAKE_OP_CLOSE] == 1), + "TC-SYSLOG-020 一批 8 条 = 一次 open + 一次 512B write + 一次 close(旧实现 8 轮)"); + + /* 021 入参守卫 */ + reset_all(); + ready_only(); + ok = (s_append_many(SYSLOG_RUN, batch, 0u) == -1) + && (s_append_many(SYSLOG_RUN, batch, SYSLOG_BATCH_MAX + 1u) == -1) + && (s_append_many(SYSLOG_RUN, NULL, 1u) == -1) + && (s_append_many((syslog_id_t)SYSLOG_COUNT, batch, 1u) == -1); + CHECK(ok && (s_lock_waits == 0) && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0), + "TC-SYSLOG-021 批量入参守卫在**取卷锁之前**(越界 n 不占用卷)"); + + /* 022 容量不足 -> -3 且不 unlink */ + reset_all(); + ready_only(); + (void)ff_fake_mkfile_size(P_RUN, ((uint32_t)SYSLOG_RUN_MAX - 3u) * SYSLOG_REC_SIZE); + ok = (s_append_many(SYSLOG_RUN, batch, SYSLOG_BATCH_MAX) == -3); + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 0) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0) + && (s_lock_releases == 1) && (s_lock_seq_err == 0) + && (ff_fake_size(P_RUN) == ((uint32_t)SYSLOG_RUN_MAX - 3u) * SYSLOG_REC_SIZE), + "TC-SYSLOG-022 装不下一批 -> -3 交回慢路径;批量路径**绝不**自己 unlink 重建"); + + /* 023 批量卷忙 / 短写自愈 */ + reset_all(); + ready_only(); + lock_busy_next(1); + ok = (s_append_many(SYSLOG_RUN, batch, 4u) == -2); + ff_fake_inject_write_short(P_RUN, 100u, 1); /* 本批 4 条 = 256 B,100 才算真短 */ + ok = ok && (s_append_many(SYSLOG_RUN, batch, 4u) == 0); + CHECK(ok && (ff_fake_size(P_RUN) == 4u * SYSLOG_REC_SIZE) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) + && (g_sys_err_count == 0), + "TC-SYSLOG-023 批量卷忙 -> -2;短写(256 只回 100)-> 自愈重建后整批落全"); + + /* 024 批量持续失败 + 去重 */ + reset_all(); + ready_only(); + ff_fake_inject_write_short(P_RUN, 0u, 4); + ok = (s_append_many(SYSLOG_RUN, batch, 2u) == -1); + (void)s_append_many(SYSLOG_RUN, batch, 2u); + CHECK(ok && (g_sys_err_count == 1) && (s_rd_gen[SYSLOG_RUN] == 2u), + "TC-SYSLOG-024 批量持续失败留痕去重;代数照样自增(宁多失效一轮也不读脏窗)"); + (void)i; +} + +/* ===================== C 排空 ===================== */ + +static void tc_drain(void) +{ + uint8_t rec[SYSLOG_REC_SIZE]; + uint32_t i; + int ok; + + printf("-- TC-SYSLOG-030..041 syslog_drain 排空 --\n"); + + /* 030 卷未就绪:条目滞留 preboot,drain 直接返回。 + * 注意两条容易被读漏的事实: + * a) 队列还没建时(s_queue == NULL)即使内核已运行,post 也走 preboot 数组 + * (syslog_post 的条件是 s_queue != NULL && 内核运行,两个条件都要满足); + * b) drain 里"惰性建队列"在 s_fs_ready 判定**之前**,所以卷没就绪也会先把 + * 队列建出来 —— 于是就绪前的条目一律滞留 preboot、就绪后一次排空。 */ + reset_all(); + s_kernel_state = osKernelRunning; + syslog_post(SYSLOG_RUN, "early"); + ok = (s_preboot_cnt == 1u) && (s_q_cnt == 0u) && (s_queue == NULL); + syslog_drain(); + CHECK(ok && (s_q_new_calls == 1) && (s_preboot_cnt == 1u) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0) && (s_soe_drain_calls == 0), + "TC-SYSLOG-030 卷未就绪 -> 队列先建成、条目滞留 preboot、不落盘也不调 SOE"); + + /* 031 惰性建队列 */ + reset_all(); + ready_only(); + ok = (s_queue == NULL); + syslog_drain(); + CHECK(ok && (s_queue != NULL) && (s_q_new_calls == 1) && (s_q_msgsz == sizeof(syslog_entry_t)), + "TC-SYSLOG-031 队列在**首次 drain**(defaultTask 上下文)才创建(内核前建对象 = 启动失败)"); + + /* 032 preboot 8 条一批落 */ + reset_all(); + ready_only(); + for (i = 0u; i < SYSLOG_QUEUE_MAX; i++) { + syslog_post(SYSLOG_RUN, "pre"); + } + CHECK(s_preboot_cnt == SYSLOG_QUEUE_MAX, "TC-SYSLOG-032a 内核未运行:8 条全进 preboot 数组"); + syslog_drain(); + ok = (s_preboot_cnt == 0u) && (syslog_count(SYSLOG_RUN) == SYSLOG_BATCH_MAX) + && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 1) + && (g_ff_obs.last_write_btw == SYSLOG_BATCH_MAX * SYSLOG_REC_SIZE); + CHECK(ok, "TC-SYSLOG-032b preboot 8 条同文件 -> 一次 512B 合并写(数组下标看而不删)"); + + /* 033 + 034 两文件混排:换文件那条不消耗 */ + reset_all(); + ready_only(); + syslog_post(SYSLOG_RUN, "r1"); + syslog_post(SYSLOG_RUN, "r2"); + syslog_post(SYSLOG_SELFTEST, "s1"); + syslog_post(SYSLOG_RUN, "r3"); + syslog_drain(); + ok = (syslog_count(SYSLOG_RUN) == 3u) && (syslog_count(SYSLOG_SELFTEST) == 1u); + if (ok) { + ok = raw_at(P_RUN, 0u, rec) && (memcmp(rec + SYSLOG_DESC_OFFS, "r1", 2u) == 0) + && raw_at(P_RUN, 1u, rec) && (memcmp(rec + SYSLOG_DESC_OFFS, "r2", 2u) == 0) + && raw_at(P_RUN, 2u, rec) && (memcmp(rec + SYSLOG_DESC_OFFS, "r3", 2u) == 0) + && raw_at(P_SELFTEST, 0u, rec) && (memcmp(rec + SYSLOG_DESC_OFFS, "s1", 2u) == 0); + } + /* selftest 插在 run 中间,于是 run.log 被切成"r1r2"与"r3"两批: + * 合计 3 次 open/write/close,而不是 2 次 —— 换文件处即分批处。 */ + CHECK(ok && (s_preboot_cnt == 0u) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 3) + && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 3), + "TC-SYSLOG-033/034 换文件即分批(run 被切成两批 = 共 3 轮 I/O)+ 顺序不乱"); + + /* 035【现状桩 = 疑似生产缺陷,见文件头"本套照出的现状"】 + * sys_log_mcu.c:509~537 的批量路径里 done 先被置成 n,只有 -3 / -1 分支会 + * 把它改回 0;**rc == -2(卷忙)没有任何分支**,于是紧随其后的 + * "for (i = done; i < n; i++) osMessageQueuePut(...)" 一次都不执行 —— + * 整批(最多 8 条)**静默丢失**,而同一段代码上方的注释写的是 + * "rc == -2(卷忙):整批回投队列,静默顺延",设计文档 §4.4 也写明 + * "队列条目在落盘失败(-2 卷忙 / -1 写失败 / -3 容量不足)时回投队列—— + * 条目不丢(旧实现 -2 卷忙会直接丢掉那一条)"。 + * ⇒ 本条按**当前实现**断言(丢了就是丢了),并显式命名为 DEFECT, + * 生产侧一旦按注释补上 done=0,这条会红,提醒同步翻转成"不丢"的正向断言。 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); /* 建队列 */ + for (i = 0u; i < 3u; i++) { + syslog_post(SYSLOG_RUN, "hold"); + } + lock_busy_next(1); + syslog_drain(); /* 批量 -2 */ + CHECK((syslog_count(SYSLOG_RUN) == 0u) && (s_q_cnt == 0u) + && (g_sys_err_count == 0) && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0), + "TC-SYSLOG-035[DEFECT 现状] 卷忙 -> 整批 3 条既没落盘也没回投(静默丢失)"); + + /* 035b 对照组:慢路径(-3 分支内)遇到 -2 时**会**把尾部回投 —— 同一卷忙 + * 在两条路径上行为不一致,这就是"批量路径漏了一个分支"最直接的证据 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); + for (i = 0u; i < 3u; i++) { + syslog_post(SYSLOG_RUN, "slow-hold"); + } + /* 让批量路径先返回 -3(容量只剩 1 条),从而进入逐条慢路径,再让卷忙 */ + (void)ff_fake_mkfile_size(P_RUN, ((uint32_t)SYSLOG_RUN_MAX - 1u) * SYSLOG_REC_SIZE); + lock_busy_after(1, 1); /* 批量那把必须拿到,慢路径那把才忙 */ + /* 批量路径先拿锁 -> 发现装不下一批 -> 返回 -3(此时一条都没写);逐条慢路径 + * 的第二把锁遇到卷忙 -> break -> done=0 -> 整批 3 条全部回投队列。 */ + g_ff_obs.calls[FF_FAKE_OP_OPEN] = 0; + syslog_drain(); + CHECK((s_q_cnt == 3u) && (syslog_count(SYSLOG_RUN) == SYSLOG_RUN_MAX - 1u) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0), + "TC-SYSLOG-035b 慢路径遇卷忙 -> 3 条**整批回投**(同一码在两条路径上行为相反)"); + + /* 035c 卷忙丢掉的是"整批"而不是"一条":8 条一批全丢 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); + for (i = 0u; i < SYSLOG_QUEUE_MAX; i++) { + syslog_post(SYSLOG_RUN, "eight"); + } + lock_busy_next(1); + syslog_drain(); + CHECK((s_q_cnt == 0u) && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 0), + "TC-SYSLOG-035c 卷忙一次吞掉一整批(8 条),不是旧实现的 1 条"); + + /* 036 回投溢出(hold-over drop):批量里混了 preboot 与队列两个来源时, + * 回投的目标队列已经被"没被取走的那些"占着 -> 装不下的部分才真被丢掉。 + * 队列 8 槽占满 + preboot 5 条 = 一批取 8 条(5 preboot + 3 队列), + * 失败后 done=0 整批回投 -> 队列只剩 3 个空位 -> 必然触发 5 次留痕。 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); /* 建队列 */ + for (i = 0u; i < SYSLOG_QUEUE_MAX; i++) { + syslog_post(SYSLOG_RUN, "q8"); /* 队列 8 槽占满 */ + } + s_kernel_state = osKernelInactive; + for (i = 0u; i < 5u; i++) { + syslog_post(SYSLOG_RUN, "p5"); /* preboot 再放 5 条 */ + } + ok = (s_q_cnt == SYSLOG_QUEUE_MAX) && (s_preboot_cnt == 5u); + ff_fake_inject_write_short(P_RUN, 0u, 2); /* 整批写失败 */ + g_sys_err_count = 0; + s_kernel_state = osKernelRunning; + syslog_drain(); + CHECK(ok && (s_q_cnt == SYSLOG_QUEUE_MAX) && (s_preboot_cnt == 0u) + && (g_sys_err_count == 1 + 5 + 1), + "TC-SYSLOG-036 回投装不下 -> 5 次 hold-over drop + drop/wfail 各 1 条留痕"); + + /* 037 -3 退回慢路径:容量轮转只发生一次 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); + for (i = 0u; i < 4u; i++) { + syslog_post(SYSLOG_RUN, "slow"); + } + (void)ff_fake_mkfile_size(P_RUN, ((uint32_t)SYSLOG_RUN_MAX - 2u) * SYSLOG_REC_SIZE); + syslog_drain(); + ok = (syslog_count(SYSLOG_RUN) == 2u) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) + && (g_ff_obs.calls[FF_FAKE_OP_WRITE] >= 4); + CHECK(ok && (s_q_cnt == 0u), + "TC-SYSLOG-037 -3 -> 逐条慢路径:装满剩位 -> 满 -> unlink 重建 -> 继续,全程 unlink 恰一次"); + + /* 038 慢路径中途写失败 -> 该条丢 + 尾部回投 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); + for (i = 0u; i < 3u; i++) { + syslog_post(SYSLOG_RUN, "mid"); + } + (void)ff_fake_mkfile_size(P_RUN, ((uint32_t)SYSLOG_RUN_MAX - 1u) * SYSLOG_REC_SIZE); + ff_fake_inject_write_short(P_RUN, 0u, 2); /* 第一条就失败 */ + g_sys_err_count = 0; + syslog_drain(); + ok = (s_q_cnt == 2u) && (ff_fake_size(P_RUN) == 0u) && (g_sys_err_count >= 2); + syslog_drain(); /* 之后成功 */ + CHECK(ok && (s_q_cnt == 0u) && (syslog_count(SYSLOG_RUN) == 2u), + "TC-SYSLOG-038 慢路径写失败:只丢**当前那一条**,尾部 2 条下轮补齐"); + + /* 039【现状桩 = 第二个不一致】rc == -1 分支把 done 置 0 并打 + * DBG_ERROR("drop %lu entries id=%d"),但 done=0 意味着紧随其后的循环把 + * **整批全部回投队列** —— 日志说"丢了多少条",实际一条没丢(下轮还会重试)。 + * 措辞与行为相反,排障时会把人往错方向带;本条按现状钉住。 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); + for (i = 0u; i < 3u; i++) { + syslog_post(SYSLOG_RUN, "wfail"); + } + ff_fake_inject_write_short(P_RUN, 0u, 2); + g_sys_err_count = 0; + syslog_drain(); + CHECK((s_q_cnt == 3u) && (syslog_count(SYSLOG_RUN) == 0u) && (g_sys_err_count == 2), + "TC-SYSLOG-039[DEFECT 现状] 批量 -1:日志报 drop,条目却整批回投(3 条仍在队列)"); + syslog_drain(); /* 注入耗尽 -> 这次真的落下去 */ + CHECK((syslog_count(SYSLOG_RUN) == 3u) && (s_q_cnt == 0u), + "TC-SYSLOG-039b 回投的 3 条下一轮补齐(与 035 的卷忙整批丢失形成对照)"); + + /* 040 B9 挂接:每次 drain 尾部各调一次 soe_store_drain */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); + ok = (s_soe_drain_calls == 1); + syslog_drain(); + CHECK(ok && (s_soe_drain_calls == 2), + "TC-SYSLOG-040 drain 尾部必调 soe_store_drain(同写者、同卷就绪门)"); + + /* 041 post 分流与守卫 */ + reset_all(); + ready_only(); + syslog_post((syslog_id_t)SYSLOG_COUNT, "bad-id"); + syslog_post(SYSLOG_RUN, NULL); + CHECK((s_preboot_cnt == 0u) && (s_q_cnt == 0u), + "TC-SYSLOG-041a id 越界 / NULL 描述 -> 静默丢弃(不占待发位)"); + reset_all(); + ready_only(); + for (i = 0u; i < SYSLOG_QUEUE_MAX + 2u; i++) { + syslog_post(SYSLOG_RUN, "pf"); + } + CHECK((s_preboot_cnt == SYSLOG_QUEUE_MAX) && (g_sys_err_count == 2), + "TC-SYSLOG-041b preboot 满(8 槽)-> 丢弃 + 每条一次留痕(日志不得反压业务)"); + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + s_q_cap = 2u; /* 造一个很短的队列 */ + s_q_msgsz = (uint32_t)sizeof(syslog_entry_t); + s_queue = (osMessageQueueId_t)&s_q_pool[0]; + syslog_post(SYSLOG_RUN, "q1"); + syslog_post(SYSLOG_RUN, "q2"); + syslog_post(SYSLOG_RUN, "q3"); + CHECK((s_q_cnt == 2u) && (g_sys_err_count == 1), + "TC-SYSLOG-041c 内核运行 -> 走队列;队列满丢弃 + 留痕"); +} + +/* ===================== D 读侧 ===================== */ + +static void tc_read(void) +{ + char ts[SYSLOG_TS_LEN + 1u]; + char desc[SYSLOG_DESC_MAX + 1u]; + uint32_t epoch; + uint32_t i; + int ok; + + printf("-- TC-SYSLOG-050..058 读侧(N-24 滑动窗口)--\n"); + + /* 050 count */ + reset_all(); + ready_only(); + CHECK(syslog_count(SYSLOG_RUN) == 0u, "TC-SYSLOG-050a 文件缺失 -> count 0"); + for (i = 0u; i < 3u; i++) { + (void)s_append(SYSLOG_RUN, "c"); + } + ok = (syslog_count(SYSLOG_RUN) == 3u) && (syslog_count((syslog_id_t)7u) == 0u); + CHECK(ok, "TC-SYSLOG-050b count = filesize/64(O(1),不读盘内容);id 越界 -> 0"); + + /* 051 窗口命中零 I/O */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "w0"); + (void)s_append(SYSLOG_RUN, "w1"); + { + int opens_base = g_ff_obs.calls[FF_FAKE_OP_OPEN]; + int reads_base = g_ff_obs.calls[FF_FAKE_OP_READ]; + int opens_after; + ok = (syslog_get_record(SYSLOG_RUN, 1u, ts, sizeof(ts), desc, sizeof(desc)) == 0); + opens_after = g_ff_obs.calls[FF_FAKE_OP_OPEN]; + ok = ok && (strcmp(desc, "w1") == 0) + && (opens_after == opens_base + 1) /* 填窗这一次 I/O */ + && (g_ff_obs.calls[FF_FAKE_OP_READ] == reads_base + 1); + ok = ok && (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, sizeof(desc)) == 0) + && (strcmp(desc, "w0") == 0) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == opens_after) /* 命中 -> 零 I/O */ + && (g_ff_obs.calls[FF_FAKE_OP_READ] == reads_base + 1); + } + CHECK(ok, "TC-SYSLOG-051 窗口命中 -> 第二次读**零 FatFs 调用**(UI 翻页不再逐条开合)"); + + /* 052 写者代数失效 */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "old"); + ok = (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, sizeof(desc)) == 0) + && (strcmp(desc, "old") == 0); + (void)s_append(SYSLOG_RUN, "new"); /* 代数 +1 -> 窗口必须重填 */ + ok = ok && (syslog_get_record(SYSLOG_RUN, 1u, ts, sizeof(ts), desc, sizeof(desc)) == 0) + && (strcmp(desc, "new") == 0); + CHECK(ok, "TC-SYSLOG-052 写者一落地代数就变 -> 窗口重填,绝不返回陈旧内容"); + + /* 053 跨窗口 */ + reset_all(); + ready_only(); + for (i = 0u; i < 9u; i++) { + (void)s_append(SYSLOG_RUN, "x"); + } + (void)syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, sizeof(desc)); + { + int opens_before = g_ff_obs.calls[FF_FAKE_OP_OPEN]; + ok = (syslog_get_record(SYSLOG_RUN, 8u, ts, sizeof(ts), desc, sizeof(desc)) == 0); + ok = ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == opens_before + 1) + && (g_ff_obs.last_lseek_ofs == (FSIZE_t)(SYSLOG_RD_WINDOW * SYSLOG_REC_SIZE)) + && (g_ff_obs.last_read_btr == SYSLOG_REC_SIZE); + } + CHECK(ok, "TC-SYSLOG-053 idx=8 跨窗 -> 一次 open + lseek(8*64) + read(1 条)(窗口按 8 对齐)"); + + /* 054 越界 */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "only"); + ok = (syslog_get_record(SYSLOG_RUN, 1u, ts, sizeof(ts), desc, sizeof(desc)) == -1) + && (s_read_raw(SYSLOG_RUN, 5u, (uint8_t *)desc) == -1); + reset_all(); + ready_only(); + ok = ok && (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, sizeof(desc)) == -1); + CHECK(ok, "TC-SYSLOG-054 越界 idx / 空文件 -> -1(等锁前后各校验一次 count)"); + + /* 055 读时卷忙 */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "busy"); + (void)syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, sizeof(desc)); /* 填窗 */ + /* 换到另一 id 迫使真读盘,再让卷忙 */ + (void)s_append(SYSLOG_SELFTEST, "s"); + lock_busy_next(1); + ok = (syslog_get_record(SYSLOG_SELFTEST, 0u, ts, sizeof(ts), desc, sizeof(desc)) == -1) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] >= 1) && (s_lock_seq_err == 0); + CHECK(ok, "TC-SYSLOG-055 读时卷忙 -> -1(读窗口 try 语义:uiTask 也绝不阻塞等锁,下轮刷新重读)"); + + /* 056 短读作废窗口 */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "half"); + (void)s_append(SYSLOG_RUN, "half2"); + s_rd_win_id = -1; + s_rd_win_cnt = 0u; + ff_fake_inject_read_short(P_RUN, 1u, 1); + ok = (s_read_raw(SYSLOG_RUN, 0u, (uint8_t *)desc) == -1) + && (s_rd_win_cnt == 0u) && (s_rd_win_id == -1); + CHECK(ok, "TC-SYSLOG-056 短读 -> 窗口作废(半窗当有效命中 = UI 显示半条脏记录)"); + + /* 057 get_record 守卫与截断 */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "01234567890123456789012345678901234567"); + ok = (syslog_get_record(SYSLOG_RUN, 0u, NULL, 32u, desc, sizeof(desc)) == -1) + && (syslog_get_record(SYSLOG_RUN, 0u, ts, SYSLOG_TS_LEN, desc, sizeof(desc)) == -1) + && (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), NULL, sizeof(desc)) == -1) + /* 守卫是 desc_len <= SYSLOG_DESC_MAX(不是 0),所以 28 及以下整条被拒 */ + && (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, SYSLOG_DESC_MAX) == -1) + && (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, SYSLOG_DESC_MAX + 1u) == 0) + /* 唯一"能读"的边界是 desc_len = 29:这时函数里 dl >= desc_len 那条截断 + * 永远不成立(dl 由记录内容决定,恒 <= SYSLOG_DESC_MAX = 28),所以拿到的是 + * 整 28 字符而不是截断结果 —— 即"传刚够的缓冲会被拒、传更小的也被拒, + * 从来没有截断"。登记为死分支,见文件头"本套照出的现状"。 */ + && (strlen(desc) == (size_t)SYSLOG_DESC_MAX); + CHECK(ok, "TC-SYSLOG-057 守卫:ts_len<=19 或 desc_len<=28 一律 -1;29 才读得到整 28 字符"); + + /* 058 last_epoch */ + reset_all(); + ready_only(); + ok = (syslog_last_epoch(SYSLOG_RUN, &epoch) == -1) && (epoch == 0u); + s_now = 1700000123u; + (void)s_append(SYSLOG_RUN, "e"); + ok = ok && (syslog_last_epoch(SYSLOG_RUN, &epoch) == 0) && (epoch == 1700000123u); + CHECK(ok, "TC-SYSLOG-058 last_epoch:无记录 -1;有记录解析 10 位 ASCII"); +} + +/* ===================== E mounted / 上电重启分类 ===================== */ + +static void tc_mounted(void) +{ + uint8_t rec[SYSLOG_REC_SIZE]; + int ok; + + printf("-- TC-SYSLOG-060..064 挂载后处理(挂载幂等 + 上电/重启分类)--\n"); + + /* 060 /log 不在 -> 建目录 + 写上电记录;第二次调用完全静默 */ + reset_all(); + s_fs_ready = 0u; + s_kernel_state = osKernelInactive; + ok = (ff_fake_exists("/log") == 2); + syslog_post(SYSLOG_RUN, "pre-mount"); /* 就绪前排队的条目也应当被带出来 */ + syslog_mcu_mounted(); + ok = ok && (s_fs_ready == 1u) && (syslog_count(SYSLOG_RUN) == 1u); + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_MKDIR] == 0) && (g_sys_err_count == 0), + "TC-SYSLOG-060a /log 已存在 -> 只写上电/重启记录一条"); + /* 060b 幂等:再来一次既不建目录也不写第二条 */ + reset_all(); + s_fs_ready = 0u; + syslog_mcu_mounted(); /* 第一次:写一条上电/重启记录 */ + { + uint32_t cnt1 = syslog_count(SYSLOG_RUN); + int mkdirs1 = g_ff_obs.calls[FF_FAKE_OP_MKDIR]; + syslog_mcu_mounted(); /* 第二次:应完全静默 */ + CHECK((s_fs_ready == 1u) && (syslog_count(SYSLOG_RUN) == cnt1) + && (g_ff_obs.calls[FF_FAKE_OP_MKDIR] == mkdirs1), + "TC-SYSLOG-060b 幂等再调用:不再建目录、不再写上电记录(2s 复查不刷屏)"); + } + (void)rec; + + /* 061 建目录失败:不就绪 + 留痕去重 + 后续重试成功 */ + reset_all(); + s_fs_ready = 0u; + ff_fake_inject_rc("/log", FF_FAKE_OP_STAT, FR_DISK_ERR, 3); /* 逼它每次都走 mkdir 分支 */ + ff_fake_inject_rc("/log", FF_FAKE_OP_MKDIR, FR_DENIED, 2); + g_sys_err_count = 0; + syslog_mcu_mounted(); /* 失败 1 -> 留痕 */ + ok = (s_fs_ready == 0u) && (g_sys_err_count == 1); + syslog_mcu_mounted(); /* 失败 2 -> 不再留痕 */ + ok = ok && (g_sys_err_count == 1) && (s_lock_releases == 2) && (s_lock_seq_err == 0); + syslog_mcu_mounted(); /* 注入耗尽 -> 成功 */ + CHECK(ok && (s_fs_ready == 1u) && (s_mount_fail_reported == 0u), + "TC-SYSLOG-061 mkdir 失败:不就绪、留痕一次(防 2s 刷屏)、第三轮自愈并复位标志"); + + /* 062 卷忙:本轮顺延,不置就绪、不碰盘 */ + reset_all(); + s_fs_ready = 0u; + lock_busy_next(1); + syslog_mcu_mounted(); + CHECK((s_fs_ready == 0u) && (g_ff_obs.calls[FF_FAKE_OP_STAT] == 0) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0) && (s_lock_releases == 0), + "TC-SYSLOG-062 卷忙 -> 直接返回(try 失败不 release,下轮 2s 后再试)"); + + /* 063 上电 / 重启分类:阈值 SYSLOG_REBOOT_WINDOW_S 两侧 + 墙钟无效 */ + reset_all(); + s_fs_ready = 0u; + s_now = 1760234567u; + (void)ff_fake_mkfile_size(P_RUN, SYSLOG_REC_SIZE); /* 有一条末条记录可解析 */ + /* 末条 epoch 造在 5 分钟前 -> reboot */ + { + uint8_t one[SYSLOG_REC_SIZE]; + (void)memset(one, 0, sizeof(one)); + (void)memcpy(one, "2025-10-12 01:57:47", SYSLOG_TS_LEN); + one[SYSLOG_TS_LEN] = ' '; + one[SYSLOG_EPOCH_OFFS - 1u] = ' '; + (void)memcpy(one + SYSLOG_EPOCH_OFFS, "1760234267", SYSLOG_EPOCH_LEN); /* -300s */ + (void)ff_fake_mkfile(P_RUN, one, SYSLOG_REC_SIZE); + } + syslog_mcu_mounted(); + ok = raw_at(P_RUN, 1u, rec) && (memcmp(rec + SYSLOG_DESC_OFFS, "reboot", 6u) == 0) + && (rec[SYSLOG_DESC_OFFS + 6u] == '\0'); + CHECK(ok, "TC-SYSLOG-063a 末条 epoch 距 now 300s(<600) -> 描述落 \"reboot\""); + + reset_all(); + s_fs_ready = 0u; + { + uint8_t one[SYSLOG_REC_SIZE]; + (void)memset(one, 0, sizeof(one)); + (void)memcpy(one, "2025-10-12 08:00:00", SYSLOG_TS_LEN); + one[SYSLOG_TS_LEN] = ' '; + one[SYSLOG_EPOCH_OFFS - 1u] = ' '; + (void)memcpy(one + SYSLOG_EPOCH_OFFS, "1760200000", SYSLOG_EPOCH_LEN); /* -9.5h */ + (void)ff_fake_mkfile(P_RUN, one, SYSLOG_REC_SIZE); + } + syslog_mcu_mounted(); + ok = raw_at(P_RUN, 1u, rec) && (memcmp(rec + SYSLOG_DESC_OFFS, "power on", 8u) == 0); + CHECK(ok, "TC-SYSLOG-063b 间隔 >=600s -> \"power on\"(掉电超过 10 分钟算重新上电)"); + + reset_all(); + s_fs_ready = 0u; + s_clock_ok = 0; + { + uint8_t one[SYSLOG_REC_SIZE]; + (void)memset(one, 0, sizeof(one)); + (void)memcpy(one + SYSLOG_EPOCH_OFFS, "1760234560", SYSLOG_EPOCH_LEN); /* 距 now 7s */ + (void)ff_fake_mkfile(P_RUN, one, SYSLOG_REC_SIZE); + } + syslog_mcu_mounted(); + ok = raw_at(P_RUN, 1u, rec) && (memcmp(rec + SYSLOG_DESC_OFFS, "power on", 8u) == 0); + CHECK(ok, "TC-SYSLOG-063c 墙钟无效(UINT32_MAX)-> 保守判 \"power on\",不拿假差值判重启"); + + /* 064 幂等靠 stat,不靠状态位:/log 已在就不再 mkdir */ + reset_all(); + s_fs_ready = 0u; + syslog_mcu_mounted(); + ok = (g_ff_obs.calls[FF_FAKE_OP_MKDIR] == 0) && (g_ff_obs.calls[FF_FAKE_OP_STAT] >= 1); + CHECK(ok, "TC-SYSLOG-064 /log 已存在时靠 f_stat 短路,f_mkdir 一次都不调"); +} + +/* ===================== F 取证通道 sys_log_evidence ===================== */ + +static void tc_evidence(void) +{ + uint8_t raw[8u * SYSLOG_REC_SIZE]; + int ok; + + printf("-- TC-SYSLOG-070..074 sys_log_evidence(wdev.log 取证通道)--\n"); + + /* 070 变长行 + 第二条 run.log */ + reset_all(); + s_now = 1760234567u; + ok = (sys_log_evidence("task stall declared") == 0); + { + uint32_t n = ff_fake_fetch(P_WDEV, raw, 0u, sizeof(raw)); + ok = ok && (n > 0u) + && (memcmp(raw, "2025-10-12 02:02:47 task stall declared\r\n", 41u) == 0) + && (syslog_count(SYSLOG_RUN) == 1u) + && raw_at(P_RUN, 0u, raw) + && (memcmp(raw + SYSLOG_DESC_OFFS, "wd stall declared", 17u) == 0); + } + CHECK(ok, "TC-SYSLOG-070 wdev.log 写变长行(带 CRLF),并补一条 run.log 独立证据"); + + /* 071 卷锁顺序:第二条写必须在释放之后(非递归锁,反了 = 自死锁) */ + reset_all(); + (void)sys_log_evidence("order probe"); + CHECK((s_lock_waits == 2) && (s_lock_releases == 2) && (s_lock_seq_err == 0) + && (s_lock_held == 0), + "TC-SYSLOG-071 取锁两次各放一次(在持锁状态下调 s_append 会永远拿不到锁)"); + + /* 072 >8192B 护栏 */ + reset_all(); + (void)ff_fake_mkfile_size(P_WDEV, 8193u); + ok = (sys_log_evidence("after rotate") == 0); + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) + && (ff_fake_size(P_WDEV) > 0u) && (ff_fake_size(P_WDEV) < 200u), + "TC-SYSLOG-072 超过 8KB -> 先 unlink 再写(长跑不被无界撑爆)"); + + /* 073 卷忙 */ + reset_all(); + lock_busy_next(1); + ok = (sys_log_evidence("busy probe") == -1); + CHECK(ok && (ff_fake_exists(P_WDEV) == 0) && (syslog_count(SYSLOG_RUN) == 0u) + && (s_lock_releases == 0) && (s_lock_seq_err == 0), + "TC-SYSLOG-073 卷忙 -> -1 且**两条写都没发生**(喂狗点绝不阻塞等锁)"); + + /* 074 wdev 写失败:返回 -1,但第二条仍写("独立证据"语义) */ + reset_all(); + ff_fake_inject_rc(P_WDEV, FF_FAKE_OP_OPEN, FR_DISK_ERR, 1); + ok = (sys_log_evidence("second write still") == -1); + CHECK(ok && (syslog_count(SYSLOG_RUN) == 1u) && (g_sys_err_count == 0), + "TC-SYSLOG-074 wdev 写失败不阻断第二条 run.log(多一条独立证据的原始意图)"); +} + +/* ===================== G 陷阱二:名字/参数类错误码严禁升级成卷操作 ===================== */ + +static void tc_name_rules(void) +{ + static FIL probe; + FRESULT fr_body; + FRESULT fr_ext; + FRESULT fr_bad; + FRESULT fr_dot; + FRESULT fr_colon; + int ok; + uint32_t i; + + printf("-- TC-SYSLOG-080..082 陷阱二分档与「绝不升级」 --\n"); + + /* 080 短名违规**分档**:每条都落到 FR_INVALID_NAME;':' 落到 FR_INVALID_DRIVE */ + reset_all(); + fr_body = f_open(&probe, "/log/selftestt.log", FA_WRITE | FA_CREATE_ALWAYS); + if (fr_body == FR_OK) { (void)f_close(&probe); } + fr_ext = f_open(&probe, "/log/run.logbak", FA_WRITE | FA_CREATE_ALWAYS); + if (fr_ext == FR_OK) { (void)f_close(&probe); } + fr_bad = f_open(&probe, "/log/ru*n.log", FA_WRITE | FA_CREATE_ALWAYS); + if (fr_bad == FR_OK) { (void)f_close(&probe); } + fr_dot = f_open(&probe, "/log/.fschk", FA_WRITE | FA_CREATE_ALWAYS); + if (fr_dot == FR_OK) { (void)f_close(&probe); } + fr_colon = f_open(&probe, "1:/log/run.log", FA_WRITE | FA_OPEN_APPEND); + if (fr_colon == FR_OK) { (void)f_close(&probe); } + CHECK((fr_body == FR_INVALID_NAME) && (fr_ext == FR_INVALID_NAME) + && (fr_bad == FR_INVALID_NAME) && (fr_dot == FR_INVALID_NAME) + && (fr_colon == FR_INVALID_DRIVE), + "TC-SYSLOG-080 分档:体>8 / 扩展>3 / 非法字符 / 点开头 -> FR_INVALID_NAME;':' -> FR_INVALID_DRIVE"); + + /* 081 生产实际用的每个名字都必须过严格 8.3(改名 = 静默写失败的第一道防线) */ + reset_all(); + ok = (f_open(&probe, P_RUN, FA_WRITE | FA_OPEN_APPEND) == FR_OK) && (f_close(&probe) == FR_OK); + ok = ok && (f_open(&probe, P_SELFTEST, FA_WRITE | FA_OPEN_APPEND) == FR_OK) + && (f_close(&probe) == FR_OK); + ok = ok && (f_open(&probe, P_WDEV, FA_WRITE | FA_OPEN_APPEND) == FR_OK) + && (f_close(&probe) == FR_OK); + ok = ok && (f_stat("/log", NULL) == FR_OK); + CHECK(ok, "TC-SYSLOG-081 生产路径 /log、run.log、selftest.log、wdev.log 全部 8.3 合规"); + + /* 082 名字类错误码不得升级成卷操作:反复失败也只动自己的文件 */ + reset_all(); + ready_only(); + (void)ff_fake_mkfile(P_SELFTEST, NULL, SYSLOG_REC_SIZE); /* 邻居文件 */ + (void)ff_fake_mkfile("/config.ini", NULL, 12u); /* 同卷别的东西 */ + ff_fake_inject_rc(P_RUN, FF_FAKE_OP_OPEN, FR_INVALID_NAME, 200); + for (i = 0u; i < 20u; i++) { + (void)s_append(SYSLOG_RUN, "bad-name"); + } + ok = (ff_fake_exists("/log") == 2) + && (ff_fake_size(P_SELFTEST) == SYSLOG_REC_SIZE) + && (ff_fake_size("/config.ini") == 12u) + && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] <= (int)(2u * 20u)) + && (g_sys_err_count == 1); + CHECK(ok, "TC-SYSLOG-082 反复拿 FR_INVALID_NAME 也只 unlink 自己的文件:目录/邻居不动、留痕一次"); +} + +/* ===================== H 补齐分支(自愈 / 二次校验 / 回投 / 占位时标) ===================== */ + +static void tc_extra_branches(void) +{ + char ts[SYSLOG_TS_LEN + 1u]; + char desc[SYSLOG_DESC_MAX + 1u]; + uint8_t raw[SYSLOG_REC_SIZE]; + uint32_t epoch; + uint32_t i; + int ok; + + printf("-- TC-SYSLOG-090..100 补齐分支 --\n"); + + /* 090 批量路径的 L1 自愈:open 失败一次 -> unlink + 重开 -> 整批照常落盘 + * (这条钉的是 s_append_many 里那段"和单条同一份自愈表";漏了它, + * FTP 侧删过文件之后的第一批日志就会整批丢) */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "seed"); /* 先让文件存在 */ + ff_fake_inject_rc(P_RUN, FF_FAKE_OP_OPEN, FR_DISK_ERR, 1); + { + static uint8_t batch[SYSLOG_BATCH_MAX * SYSLOG_REC_SIZE]; + (void)memset(batch, 0x33, sizeof(batch)); + ok = (s_append_many(SYSLOG_RUN, batch, 2u) == 0); + } + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 3) /* 单条 1 + 批量首开 1 + 自愈重开 1 */ + && (ff_fake_size(P_RUN) == 2u * SYSLOG_REC_SIZE) && (g_sys_err_count == 0), + "TC-SYSLOG-090 批量 open 失败一次 -> unlink 重开 -> 整批 2 条落全、不留痕"); + + /* 091 批量 open 永久失败 -> -1(走的是"打开失败"那条 else,不是写失败) */ + reset_all(); + ready_only(); + ff_fake_inject_rc(P_RUN, FF_FAKE_OP_OPEN, FR_DISK_ERR, 99); + { + static uint8_t batch[SYSLOG_BATCH_MAX * SYSLOG_REC_SIZE]; + ok = (s_append_many(SYSLOG_RUN, batch, 3u) == -1); + } + CHECK(ok && (ff_fake_exists(P_RUN) == 0) && (g_sys_err_count == 1) + && (s_lock_releases == 1) && (s_lock_seq_err == 0), + "TC-SYSLOG-091 批量打开持续失败 -> -1 + 留痕一次 + 已释放卷锁"); + + /* 092 取锁后二次校验:等锁期间写者可能已把文件删掉(容量轮转/取证重建) + * -> 释放锁再返回 -1,绝不能拿锁前的旧 total 去 lseek */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "one"); + ff_fake_inject_rc_skip(P_RUN, FF_FAKE_OP_STAT, FR_DISK_ERR, 1, 1); + /* 前面那次 s_append 已经取放锁各一次,所以这里的基线是 1,不是 0 */ + ok = (s_lock_releases == 1); + ok = ok && (s_read_raw(SYSLOG_RUN, 0u, raw) == -1) + && (s_lock_releases == 2) && (s_lock_seq_err == 0); + CHECK(ok && (s_rd_win_cnt == 0u), + "TC-SYSLOG-092 锁后二次 count 失败 -> 放锁返回 -1(漏放 = FTP 永久等不到卷)"); + + /* 093 读时 f_open 失败 -> 放锁返回 -1(另一条提前退出路径) */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "one"); + s_rd_win_id = -1; + s_rd_win_cnt = 0u; + ff_fake_inject_rc(P_RUN, FF_FAKE_OP_OPEN, FR_DISK_ERR, 1); + ok = (s_read_raw(SYSLOG_RUN, 0u, raw) == -1) + && (s_lock_releases == 2) && (s_lock_seq_err == 0); + /* 第二次读(注入耗尽)应成功,证明窗口没被留在半初始化状态 */ + ok = ok && (s_read_raw(SYSLOG_RUN, 0u, raw) == 0) && (s_rd_win_cnt == 1u); + CHECK(ok, "TC-SYSLOG-093 读打开失败 -> 放锁 -1;下轮恢复后窗口正常重填"); + + /* 094 墙钟未建立时 evidence 用占位时标(不得写半截或未初始化缓冲) */ + reset_all(); + s_clock_ok = 0; + ok = (sys_log_evidence("no clock") == 0); + if (ok) { + (void)ff_fake_fetch(P_WDEV, raw, 0u, SYSLOG_TS_LEN); + ok = (memcmp(raw, "1970-01-01 08:00:00", SYSLOG_TS_LEN) == 0); + } + CHECK(ok, "TC-SYSLOG-094 无墙钟 -> wdev.log 落占位时标(取证行也必须可解析)"); + + /* 095 wdev 短写:FR_OK + 字节数不足 -> -1,但第二条 run.log 照写 */ + reset_all(); + ff_fake_inject_write_short(P_WDEV, 3u, 1); + ok = (sys_log_evidence("short line") == -1); + CHECK(ok && (ff_fake_size(P_WDEV) == 3u) && (syslog_count(SYSLOG_RUN) == 1u) + && (g_sys_err_count == 0), + "TC-SYSLOG-095 wdev 短写判失败返回 -1,第二条独立证据仍落 run.log"); + + /* 096 队列创建失败:条目滞留 preboot、留痕一次、下轮建成后排空 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + s_q_new_fail = 1; + syslog_post(SYSLOG_RUN, "nq"); + syslog_drain(); + ok = (s_queue == NULL) && (g_sys_err_count == 1) && (s_preboot_cnt == 1u) + && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0) && (s_soe_drain_calls == 0); + s_q_new_fail = 0; + syslog_drain(); + CHECK(ok && (s_queue != NULL) && (syslog_count(SYSLOG_RUN) == 1u) + && (s_preboot_cnt == 0u) && (s_soe_drain_calls == 1), + "TC-SYSLOG-096 队列没建成 -> 一条都不排(不静默丢),堆恢复后排空并接上 SOE"); + + /* 097 换文件那条来自队列 -> 原样回投队列(不是 preboot 下标回退) + * 队列里 RUN/SELFTEST/RUN 交替:三条必须全落且各自文件内顺序不乱 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); /* 建队列 */ + syslog_post(SYSLOG_RUN, "qa"); + syslog_post(SYSLOG_SELFTEST, "qb"); + syslog_post(SYSLOG_RUN, "qc"); + ok = (s_q_cnt == 3u); + syslog_drain(); + if (ok) { + ok = (syslog_count(SYSLOG_RUN) == 2u) && (syslog_count(SYSLOG_SELFTEST) == 1u) + && raw_at(P_RUN, 0u, raw) && (memcmp(raw + SYSLOG_DESC_OFFS, "qa", 2u) == 0) + && raw_at(P_RUN, 1u, raw) && (memcmp(raw + SYSLOG_DESC_OFFS, "qc", 2u) == 0) + && raw_at(P_SELFTEST, 0u, raw) && (memcmp(raw + SYSLOG_DESC_OFFS, "qb", 2u) == 0) + && (s_q_cnt == 0u); + } + CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 3), + "TC-SYSLOG-097 队列侧换文件回投:三条全落、各文件内顺序不乱(回投不是丢弃)"); + /* 098 dl >= desc_len 的截断分支:守卫已挡掉 desc_len <= SYSLOG_DESC_MAX, + * 而 dl 由记录内容决定、恒 <= SYSLOG_DESC_MAX -> 该分支在**当前守卫取值下 + * 永不可达**。这条断言把"不可达"这件事本身钉住:一旦有人把守卫放宽成 + * desc_len == 0,本条就会因为读到截断结果而红,提醒补截断用例。 */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "abcdefghijklmnopqrstuvwxyz0123"); /* 34 字符 -> 截到 28 */ + ok = (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, SYSLOG_DESC_MAX + 1u) == 0) + && (strlen(desc) == (size_t)SYSLOG_DESC_MAX) + && (desc[SYSLOG_DESC_MAX] == '\0'); + CHECK(ok, "TC-SYSLOG-098 desc_len=29 时截断分支不可达(拿到整 28 字符,非 28-1)"); + + /* 099 空指针守卫:s_read_raw 的 p_rec 与 evidence 的 p_desc */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "g"); + ok = (s_read_raw(SYSLOG_RUN, 0u, NULL) == -1) + && (syslog_get_record(SYSLOG_RUN, 0u, ts, sizeof(ts), desc, sizeof(desc)) == 0) + && (sys_log_evidence(NULL) == -1); + CHECK(ok && (s_lock_seq_err == 0), + "TC-SYSLOG-099 p_rec / p_desc 空指针守卫(守卫顺序在取锁之前,不放锁)"); + + /* 100 last_epoch:有记录但读失败 -> -1 且把 *p_epoch 清 0 + * (调用方 syslog_mcu_mounted 直接拿这个值判上电/重启,留旧值就会误判) */ + reset_all(); + ready_only(); + (void)s_append(SYSLOG_RUN, "e"); + s_rd_win_id = -1; + s_rd_win_cnt = 0u; + epoch = 0xA5A5A5A5u; + ff_fake_inject_rc(P_RUN, FF_FAKE_OP_OPEN, FR_DISK_ERR, 1); + ok = (syslog_last_epoch(SYSLOG_RUN, &epoch) == -1) && (epoch == 0u); + CHECK(ok, "TC-SYSLOG-100 last_epoch 读失败 -> -1 且 *p_epoch 清零(不留调用方旧值)"); + (void)i; +} + +/* ===================== 入口 ===================== */ + +int main(void) +{ + printf("=== Sys Log Mcu Tests ===\n"); + + tc_format(); + tc_write_single(); + tc_write_many(); + tc_drain(); + tc_read(); + tc_mounted(); + tc_evidence(); + tc_name_rules(); + tc_extra_branches(); + + printf("\n=== Sys Log Mcu Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +}