docs(net/udp): TRAP-NET-22 追加真机定案,网络与测试文档同步(#73)
CI / 静态门禁 (pre-commit) (push) Failing after 5m30s
CI / 主机侧单测 (MSVC) (push) Successful in 50s
CI / 静态门禁 (pre-commit) (pull_request) Has been cancelled
CI / 主机侧单测 (MSVC) (pull_request) Has been cancelled

- `docs/问题记录/CH395F_Trap_Records.md`:TRAP-NET-22 追加 2026-11-04 段——现象
  (客户端形态 48 B 读成 56 B、首字节 0x38)、根因(**帧格式在 OPEN_SOCKET 那一刻
  由 DesIP 定死**,运行时改 DesIP 不改它)、修法(恒剥 + 源地址一律取信息头),以及
  这条最该记的教训:手册一句话读错,就写错一层契约,而且主机侧 240 轮随机长跑会
  照着同一个错模型绿过去。
- `docs/网络/BSD_Socket_API_使用指南.md` §6.0/§6.3:形态表与"客户端不剥头"条款
  按定案重写,`sendto` 显式目的地址 = 切发送去向,不再承诺帧格式变化。
- `docs/网络/BSD_Socket_API_函数调用关系.md` §4.11:`net_udp.c` 一表的收包路径
  说明同步。
- `docs/测试说明/单元测试/主机单测覆盖率口径.md` 第 4 轮:26 组 UDP-1~26、全套
  677/677、UDP-26 的来由(覆盖率门禁照出兜底路径没人走,补测试而不是降基线)、
  以及真机第一轮 33/47 到第二轮 77/77 的经过。
- `docs/测试说明/板级测试/CH395F_Test_Guide.md` §12.6:阶段 16 的实测结果表
  (2026-11-04 08:42,板端 `77/77`、PC 端 `27/27`,台架必验 ①~⑤ 全部成立)+
  关键串口原文 + 唯一未量项(等待期 CPU 占用 / IWDG 余量的定量读数)如实留白。

登记簿销项按仓库约定不进本分支(分支不动 `docs/任务规划/**`)。
This commit is contained in:
2026-11-04 09:15:53 +08:00
parent 66725c0fa3
commit f10bb8df65
5 changed files with 86 additions and 46 deletions
@@ -719,15 +719,16 @@ uint16_t ch395f_link_change_count(void);
- **不写第二份分块逻辑**:UDP 发送复用 `net_send_locked`(1KB 分块 + 挂起 send 都在里面)。为此把"会话是否已死"收成一条判据 `net_send_state_down()`(`net_pending.c`):TCP 看 `ESTABLISHED`、UDP 看 `UDP_OPEN`;原先 `net_send_locked` 两处 + `net_send_pending_tick` + `net_pending_tick` 各写一遍,共 4 处。
- **顺带修掉的形态缺陷**(真隐患):`net_sendto` 为发一个包把 DesIP 从 `0xFFFFFFFF` 写成具体 IP,芯片静默切到 UDP Client 形态(收包**不带** 8 字节信息头),而旧 `recvfrom` 无条件剥 8 字节 → 请求-响应式协议(SNTP 48 B 应答)会丢载荷前 8 字节并交出垃圾源地址。**修法**:`net_sock_t` 加 `udp_mode`(塞进原有 2 字节 padding,`sizeof(net_sock_t)` 不变)、`bind` 恒 SERVER、显式对端 `sendto` 切 CLIENT、`net_udp_set_peer(fd, NULL)` 轻量回 SERVER(不重开 Socket 因此不清芯片缓冲)、只在 SERVER 且 `avail >= 8` 时剥头(残包不猜头,TC-NETSOCK-440o 契约不变)。
- **截断语义定案**:一次调用交付一个数据报,`buf` 装不下的尾巴**必须排空**——`ch395f_read_recv_buf` 是按长度推进读指针的消费式读,不排空则下一次 `recvfrom` 交出上一个包的残料。排空用 64 B 栈上 scratch 循环读,**不用** `ch395f_clear_recv_buf`:它清整个接收缓冲,会把后面已经到达的包一起丢。
- **用例面**:新分片 `test/host/net_socket/ns_s7_udp.c` 共 25 组 UDP-1~25(bind 的 5 条芯片命令**顺序**、未 bind 一律 -5、服务器形态剥头逐字节、客户端形态不剥头、`sendto` 切形态、挂起收包在后续 poll 轮到达、预算超时按调用方 deadline 计、close 与重建结算不许静默丢通知、出参槽互斥与泄漏、截断排空、netTask 自等守卫、`net_init` 重建取消、流式 API 拒 `DGRAM`、非 netTask 调用零芯片事务、执行体与公开入口的参数守卫(越界 fd / `STREAM` / 未 bind / 零长 / 出参容量不足——拒时必须零芯片事务)、"队列未绑定"与"队列满"两条失败出口不许漏槽、`net_udp_set_peer` 轻/重两种回服务器模式(重开丢缓冲 vs 不重开)、挂起 send 由 `net_send_pending` 结算且登记时不许抢发第二份回执、`net_udp_tick` 的会话作废与 stale-readable 兜底、重 bind 必须把还在等包的调用方按 -8 结算、`bind` 遇满队列必须 BUSY 且不给 fd 投毒、**芯片 `open` 回 `ERR_BUSY` 时精确码必须沿通知值回到等待方**(真芯片会这么拒)、**队列里"别人的失败"排在我这条之前时不许污染我的回执**(同 545 在 TCP 侧的钉子,UDP bind 侧补一条)、`net_recvfrom_to` 的"真失败"与"超时"两条出口分开、**netTask 直调路径(板级用例走的那条)的 `set_peer` / `recvfrom` 正例与三种拒绝**、**UDP-24 = 240 轮定种子(`20261104`)随机长跑**:每轮随机载荷长度 1~112(112 = 假件收包流 128 B 上限减掉 8 字节信息头,不是魔数)、随机源端口与源 IP、随机 SERVER/CLIENT 形态、随机"显式对端 `sendto`"与"按记账对端 `sendto`",每 17 轮 `close` + 重 bind 一次;断言收在六族不变量上(读到的长度必须等于芯片宣布值、载荷既不吞信息头也不丢真字节、源地址按形态分别取自表头/对端记账、`udp_mode` 与命令严格一致、`close`/重 bind 后出参槽与挂起槽都不残留、每次 `sendto` 交给芯片的字节数恰好 = `len`),并要求 ≥90% 轮次端到端干净(实测 240/240)——**形态切换 × 挂起结算 × 关闭重建这三件事的交叉面,逐条用例排不出组合,只能靠随机扫**、**UDP-25 = 两条 UDP socket 并存的隔离**(前 24 组全都只用一条 socket,漏掉的正是队列化真正要保的那半句:等待是**一条 socket 自己的事**——fdB 来包不得结算、不得通知 fdA 的 parked 收包,fdA 的调用缓冲一个字节都不能动;反过来 fdB parked 时 fdA 自己 `recvfrom` 必须照常拿到自己那一包,串行的是命令不是等待者;关停在等的那条必须当场结算,不许留槽);改写 TC-NETSOCK-440(补 bind 前置 + 新增 440p~440u)、555(表头逐字节按形态分路)、560 组("拒在 netTask 外"→"经队列后非 netTask 仍零 SPI")、500h-i / 506 / 545。全套 **667/667 绿**(改造前 470)。
- **用例面**:新分片 `test/host/net_socket/ns_s7_udp.c` 共 26 组 UDP-1~26(bind 的 5 条芯片命令**顺序**、未 bind 一律 -5、服务器形态剥头逐字节、**客户端形态同样剥头**(UDP-4 的判据 2026-11-04 由真机改写过:运行时改 DesIP 不改收包帧格式,见 TRAP-NET-22 追加段)、`sendto` 切形态、挂起收包在后续 poll 轮到达、预算超时按调用方 deadline 计、close 与重建结算不许静默丢通知、出参槽互斥与泄漏、截断排空、netTask 自等守卫、`net_init` 重建取消、流式 API 拒 `DGRAM`、非 netTask 调用零芯片事务、执行体与公开入口的参数守卫(越界 fd / `STREAM` / 未 bind / 零长 / 出参容量不足——拒时必须零芯片事务)、"队列未绑定"与"队列满"两条失败出口不许漏槽、`net_udp_set_peer` 轻/重两种回服务器模式(重开丢缓冲 vs 不重开)、挂起 send 由 `net_send_pending` 结算且登记时不许抢发第二份回执、`net_udp_tick` 的会话作废与 stale-readable 兜底、重 bind 必须把还在等包的调用方按 -8 结算、`bind` 遇满队列必须 BUSY 且不给 fd 投毒、**芯片 `open` 回 `ERR_BUSY` 时精确码必须沿通知值回到等待方**(真芯片会这么拒)、**队列里"别人的失败"排在我这条之前时不许污染我的回执**(同 545 在 TCP 侧的钉子,UDP bind 侧补一条)、`net_recvfrom_to` 的"真失败"与"超时"两条出口分开、**netTask 直调路径(板级用例走的那条)的 `set_peer` / `recvfrom` 正例与三种拒绝**、**UDP-24 = 240 轮定种子(`20261104`)随机长跑**:每轮随机载荷长度 1~112(112 = 假件收包流 128 B 上限减掉 8 字节信息头,不是魔数)、随机源端口与源 IP、随机 SERVER/CLIENT 形态、随机"显式对端 `sendto`"与"按记账对端 `sendto`",每 17 轮 `close` + 重 bind 一次;断言收在六族不变量上(读到的长度必须等于芯片宣布值、载荷既不吞信息头也不丢真字节、源地址**恒**取自表头(客户端形态同样——真机定案)、`udp_mode` 与命令严格一致、`close`/重 bind 后出参槽与挂起槽都不残留、每次 `sendto` 交给芯片的字节数恰好 = `len`),并要求 ≥90% 轮次端到端干净(实测 240/240)——**形态切换 × 挂起结算 × 关闭重建这三件事的交叉面,逐条用例排不出组合,只能靠随机扫**、**UDP-25 = 两条 UDP socket 并存的隔离**(前 24 组全都只用一条 socket,漏掉的正是队列化真正要保的那半句:等待是**一条 socket 自己的事**——fdB 来包不得结算、不得通知 fdA 的 parked 收包,fdA 的调用缓冲一个字节都不能动;反过来 fdB parked 时 fdA 自己 `recvfrom` 必须照常拿到自己那一包,串行的是命令不是等待者;关停在等的那条必须当场结算,不许留槽)、**UDP-26 = 芯片宣布不足 8 字节的残包**(无从判头的那一侧:整包原样交付、一个字节都不许剥、源地址退到对端记账,没记账时交出全零这个"我不知道"而不是包里的字节——这也是 `net_udp_src_from_peer()` 唯一的读者:真机定案把剥头判据改成"只看长度、与形态无关"之后,覆盖率门禁照出这条兜底一行没人走,就补在这一侧,而不是去把基线调低);改写 TC-NETSOCK-440(补 bind 前置 + 新增 440p~440u)、555(表头逐字节按形态分路)、560 组("拒在 netTask 外"→"经队列后非 netTask 仍零 SPI")、500h-i / 506 / 545。全套 **677/677 绿**(改造前 470)。
- **假件新增(一律可加不可改)**:`g_fx.wlen[]` + `g_fx.last_write[]` 捕获 `ch395f_write_send_buf` 的**真实字节**(此前假件忽略 `p_data`,UDP 截断与排空无从断言);`ch395f_fake_set_write_delay(fd, n)` 让 SENBUF_FREE 第 n 次才到(钉"排空必须在下一次写之前完成");`g_chip.read_calls` 供"读了几个 scratch 块"断言。`g_fake` / `g_chip` 既有字段一字未动。
- **两条教训(都是被测码真 bug,不是测法自误)**:① **执行侧自等守卫不能复用调用侧谓词**——`net_udp_recvfrom_exec` 用 `net_ctx_is_owner_task()` 判自等,而它在 netTask 内恒真 → 每一条经队列投进来的正常 recvfrom 都被拒;正确判据是 `caller == xTaskGetCurrentTaskHandle()`(对齐 `net_pending.c` 的 `caller == s_owner_task`)。抓它的是 UDP-8a。② **芯片长度只能读一次并传参**——`net_udp_datagram_read` 内部又调一次 `ch395f_get_recv_len`,于是"剥几字节"与"读几字节"不同源;假件的 recv_len 队列**每次调用弹一个值**,这条设计当场把双读暴露出来。现由调用方读一次 `avail` 传入三个调用点。
- **补测阶段又抓到两条(同一批缺口分析顺手挖的)**:③ **槽指针必须在 fd 合法性检查之后取**——`net_udp_recvfrom_exec` 原先先算 `&s_net_udp[sockfd]` 再判 `p_sock == NULL`,越界 fd 立刻得到一个越界槽指针(哪怕当下没解引用,后面登记就是野写)。④ **作废不等于可以不吭声**——`net_udp_bind_exec` 重开芯片 Socket 会丢掉缓冲里的包,却只 `net_udp_clear()`:A 任务正阻塞在 `net_recvfrom`、B 任务重 bind 同一个 fd,A 就永远睡死。现在重开之前先按 `NET_ERR_CONNRESET` 结算并通知(UDP-20 钉住)。教训同 `net_pending`:**任何"取消挂起"的动作都必须走 complete 那条路,clear 只是归零**。
- **随机长跑把两条"测法自误"逼了出来(不是被测码 bug,但只有长跑才逼得出)**:⑤ **连续 `sendto` 必须先把 SENBUF_FREE 钉成电平**——不注入 SENBUF_FREE 时,第二次发送会**合法地**转挂起(`net_send_locked` 自旋 2 个 tick 就 `net_send_pending_register`),而本套的挂起 send 替身从不完成,于是 `FATAL: blocking wait still got no receipt after 50 rounds` 直接掐死整个套件、连汇总行都没有(正确姿势照 440i/440j:`ch395f_fake_set_sint_sticky(fd, CH395F_SINT_STAT_SENBUF_FREE)` + `push_glob(1<<(fd+4))` + `g_ns_int_pin_set=0`,注释里点名 TRAP-NET-04,并写明"背靠背发送的真机面留给 TC-NET-1609")。⑥ **假件的 `ch395f_get_recv_len` 是"按调用序号取、耗尽后重复最后一个"的脚本且容量有限**——240 轮只追加不清空,序号撞满容量后每轮都读到同一个陈旧长度(症状:`len` 在随机而 `n` 恒为 28,前 24 轮却全绿,最容易误判成产品 bug)。补了加法假件助手 `ch395f_fake_reset_recv_len(sock)`(只把该 sock 的脚本长度与序号归零,既有单轮用例不调用它、行为一字未变),soak 每轮开头调一次。教训:**长跑用例对假件的第一要求是"状态可重排",不是"能追加"**。
- **本轮覆盖率**(收尾那次全仓跑数,`--update-baseline` 已把数字连同本轮提交一起落盘):`net_udp.c` 237/239 行(99.2%)、函数 **14/14**;`net_socket.c` 966/969(99.7%)、函数 **59/59**;`net_pending.c` 173/199(86.9%);全仓 line 8372/9290(90.1%)、47/47 套绿,无回退。剩两处未覆盖:`net_udp.c` 的槽越界兜底(调用方已挡,属防御性代码)与 `net_socket.c` 错误码槽重查竞态(真并发才走得到,改造前后同为那 2 行)。
- **台架必验(本轮无板子,主机测替代不了)**:① 两端真机互通,服务器形态收发 + 客户端形态往返,重点看 48 B 这类小应答**载荷完整**且源地址正确;② `sendto` 切形态后 `net_udp_set_peer(fd, NULL)` 能否真恢复收广播——手册只写 DesIP 语义,没写切换时机;③ 截断包排空后下一个包是否完整(scratch 循环在真芯片读指针上的行为);④ 挂起 recv 等待期间的 IWDG 余量;⑤ bind 在 open **之前**写 DesIP 的顺序在真芯片上的实际效果(TRAP-NET-14 的 UDP 侧对照)。
- **这 5 条已落成可执行的板级阶段**:阶段 16(`docs/测试说明/板级测试/CH395F_Test_Guide.md` §12.6,板端 `test/board/net_test_task.c` 的 `net_layer_udp_bench_test_main`,PC 侧 `test/host/udp_queue_bench.py`),TC-NET-1601~1609 与 ①~⑤ 的对应关系写在表里。**真机只跑了个开头**:2026-11-03 夜第一次上台架,① 的上半段已见证据——`TC-NET-1601` 建 socket/bind `:6001`/形态回 SERVER、`TC-NET-1602` 服务端形态收到 20 B 且 `addrlen`/`sin_family`/源端口全对、回显逐字节一致并把形态翻成 CLIENT(剥 8 字节信息头与源地址回填在真芯片上成立);②③④⑤ 未测,板子当晚两次失电(SWD `No target connected`、ping 不通,19:19 与 21:0x 各一次),两趟还各暴露一个**台架**缺陷(peer 被 Windows 的 ICMP 10054 打死;单步预算 8 s < 板端 25 s park 导致整条梯子错位),修法与守卫见上面那份指南。PR #74 的合并前提仍是这条回归跑完整;跑完请把结果回填本条并把上面「台架必验」改写成实测结论。
- **同一轮补的免对端那半(2026-11-04)**:第一次上台架就是被"没有对端"卡住的——peer 脚本被 Windows 的 ICMP 10054 打死、梯子错位,1601 之后一步都测不到,整趟证据归零。故阶段 16 加了 **TC-NET-1610**:17 条断言不需要 PC 发一个包,把「形态记账(新建 socket 与 bind 都读 SERVER、`sendto` 显式对端才切 CLIENT)」「参数与出参守卫(未 bind 报 -5、流式 API 读 `DGRAM` 报 -3、`to_ms == 0` 报 -3、`src_addr` 缺 `addrlen` 或容量只有 4 字节报 -3、空缓冲 `DONTWAIT` 报 -7)」「`net_recvfrom_to(2000)` 返回 **-2** 且实测睡眠落在 2~4.5 s(等待睡在任务通知上,不在 SPI 总线上自旋)」「`net_close` 后同一槽重新 bind 不被上一条命毒化」四类在真芯片上一次确认。执行位置在 1601 之前、编号留在段尾(编号是台账用的,不等于执行顺序)。代价只有 flash:Code +2,380 B、RO +3,108 B(全是断言字符串),`RW/ZI` 一字未增——它复用那块 1,600 B 收发缓冲。**ARMCC 已编过(bench 与产品两态都 0 error 0 warning,2026-11-04 01:06),但仍未在真机上跑过**:台架断线期间它一条也测不到,所以这里只算"编过",不算证据。
- **台架必验(登记于本轮,2026-11-04 已全部跑完,见下一条)**:① 两端真机互通,服务器形态收发 + 客户端形态往返,重点看 48 B 这类小应答**载荷完整**且源地址正确;② `sendto` 切形态后 `net_udp_set_peer(fd, NULL)` 能否真恢复收其它源的包——手册只写 DesIP 语义,没写切换时机;③ 截断包排空后下一个包是否完整(scratch 循环在真芯片读指针上的行为);④ 挂起 recv 等待期间的 IWDG 余量;⑤ bind 在 open **之前**写 DesIP 的顺序在真芯片上的实际效果(TRAP-NET-14 的 UDP 侧对照)。
- **真机回归结果(2026-11-04 08:42,PR #74 分支 head `1b5e5d4` + 剥头修正)**:板端 `=== Phase 16: 77/77 PASSED (failed=0) ===`、PC peer `=== UDP Bench PC Peer: 27/27 PASSED (failed=0) ===`,①~⑤ **全部成立**,逐条串口原文见 `docs/测试说明/板级测试/CH395F_Test_Guide.md` §12.6 的「实测结果」表。**第一轮(08:16)其实是 33/47**:`TC-NET-1603` 报 `tag 0x02 got 0x38 (n=56)`,把本轮最大的一条设计错误照了出来——**芯片加不加 8 字节信息头是在 OPEN_SOCKET 那一刻由 DesIP 定死的,运行时把 DesIP 改成具体对端并不会让头消失**,而 UDP-4/UDP-24 当时按"客户端形态不剥头"写死了假件脚本,240 轮随机全绿也没发现。修法:`net_udp_datagram_read` 的剥头条件改为**只看 `avail >= 8`、与 `udp_mode` 无关**(`udp_mode` 退化为发送去向 + 对端记账),主机侧 UDP-4 与 UDP-24 客户端轮按真机重写(UDP-4 补一条 `addrlen`/family 断言,改完判据后覆盖率门禁又照出兜底那侧一行没人走,补 UDP-26 残包组 → 全套 **677/677**),板级 1603 的判据文字同步改写;定案登记在 TRAP-NET-22 的 2026-11-04 追加段。产品镜像尺寸 `Code=249120 RO=29836 RW=2052 ZI=149996`(比改造前基线少 4 B,`RW/ZI` 一字未增)。
- **这 5 条已落成可执行的板级阶段**:阶段 16(`docs/测试说明/板级测试/CH395F_Test_Guide.md` §12.6,板端 `test/board/net_test_task.c` 的 `net_layer_udp_bench_test_main`,PC 侧 `test/host/udp_queue_bench.py`),TC-NET-1601~1609 与 ①~⑤ 的对应关系写在表里。**真机跑了四轮才跑完整**:2026-11-03 夜第一次上台架,① 的上半段已见证据——`TC-NET-1601` 建 socket/bind `:6001`/形态回 SERVER、`TC-NET-1602` 服务端形态收到 20 B 且 `addrlen`/`sin_family`/源端口全对、回显逐字节一致并把形态翻成 CLIENT;②③④⑤ 未测,板子当晚两次失电(SWD `No target connected`、ping 不通,19:19 与 21:0x 各一次),两趟还各暴露一个**台架**缺陷(peer 被 Windows 的 ICMP 10054 打死;单步预算 8 s < 板端 25 s park 导致整条梯子错位,另有一版 peer 在失败后瞬间狂重发,单个日志刷到 940,949 B),修法与守卫见上面那份指南。2026-11-04 08:16 第一次跑完整梯子是 **33/47**,唯一的真缺陷就是上面那条剥头判据,14 条 FAIL 全是它的级联;修完 08:42 第二轮 **77/77**,跑完立刻把板子恢复成产品镜像(`Code=249120`,串口 `[PROF2]` 心跳连续、`netn=0`、`[ERR][WD] task stall` 零条)并释放台架锁。
- **同一轮补的免对端那半(2026-11-04)**:第一次上台架就是被"没有对端"卡住的——peer 脚本被 Windows 的 ICMP 10054 打死、梯子错位,1601 之后一步都测不到,整趟证据归零。故阶段 16 加了 **TC-NET-1610**:17 条断言不需要 PC 发一个包,把「形态记账(新建 socket 与 bind 都读 SERVER、`sendto` 显式对端才切 CLIENT)」「参数与出参守卫(未 bind 报 -5、流式 API 读 `DGRAM` 报 -3、`to_ms == 0` 报 -3、`src_addr` 缺 `addrlen` 或容量只有 4 字节报 -3、空缓冲 `DONTWAIT` 报 -7)」「`net_recvfrom_to(2000)` 返回 **-2** 且实测睡眠落在 2~4.5 s(等待睡在任务通知上,不在 SPI 总线上自旋)」「`net_close` 后同一槽重新 bind 不被上一条命毒化」四类在真芯片上一次确认。执行位置在 1601 之前、编号留在段尾(编号是台账用的,不等于执行顺序)。代价只有 flash:Code +2,380 B、RO +3,108 B(全是断言字符串),`RW/ZI` 一字未增——它复用那块 1,600 B 收发缓冲。**ARMCC 已编过(bench 与产品两态都 0 error 0 warning),并在 2026-11-04 08:42 那轮真机上 17 条全绿**(`TC-NET-1610a..q`,含 `1610i: park slept 2002ms against a 2000ms budget` 这条"睡在通知上"的硬证据)——它也正是那一轮板子断线时唯一还能自证的段落,先跑完再等对端的设计目的达到了。
- 本轮改动面:`Drivers/BSP/NET/net_udp.c` 与 `Drivers/BSP/NET/net_udp.h`(新)、`net_socket.c` / `net_socket.h`、`net_pending.c` / `net_pending.h`、`net_types.h`、`MDK-ARM/STM32F407-Demo.uvprojx`、`scripts/check_registration.py`、`test/host/common/ch395f_fake.c` 与 `test/host/common/ch395f_fake.h`、`test/host/net_socket/`(含新分片);文档 → `docs/网络/BSD_Socket_API_使用指南.md` §6.0 / §6.1 / §6.2 / §6.3 / §9.0、`docs/网络/BSD_Socket_API_函数调用关系.md` §4.11、`docs/问题记录/CH395F_Trap_Records.md`(TRAP-NET-14 UDP 段 + 新条)。
### P3|**明确不做**(把理由写死,免得下次又被列回待办:29 个 `.c` = 4,814 行)
@@ -1128,32 +1128,62 @@ WAIT_CLOSE 阶段已内置二段补发(只补一次),生产 net 层则靠
## 12.6 阶段 16:BSD UDP 数据面真机回归(NET 层,需 PC peer)
**动机**:PR #74 把 UDP 数据面并入 `netMsgQueue` 串行执行(见
`docs/网络/BSD_Socket_API_函数调用关系.md` §4.11),并顺手修掉「DesIP 形态与剥头不一致」
这个会让 48 字节应答丢掉前 8 字节的缺陷(`docs/问题记录/CH395F_Trap_Records.md` 的 TRAP-NET-22)。
`docs/网络/BSD_Socket_API_函数调用关系.md` §4.11),并顺手处理「DesIP 形态与剥头判据不一致」
这条登记在 `docs/问题记录/CH395F_Trap_Records.md` TRAP-NET-22 的疑点。
主机单测(`test/host/net_socket/ns_s7_udp.c`,`docs/测试说明/单元测试/主机单测覆盖率口径.md` 第 4 轮)
把逻辑面钉死了,但芯片行为——DesIP 生效时机、UDP 的 SINT 触发、读指针上的残包排空——在桩里怎么编都"对"。
**这句话在 2026-11-04 被兑现了:桩里全绿的"按形态决定剥不剥",真机第一轮就翻车**(见下面「实测把设计改写了」)。
下面 5 条就是那轮登记簿留下的「台架必验」的可执行形态。
**架构**:板端一个 UDP socket,先 `net_bind(:6001)` 做服务端,再从收到的包里学对端地址回显。
**必须板端先听**:PC 侧是 DHCP(本轮实测 192.168.31.161),`net_test_task.h` 里那个
`TEST_PC_PEER_IP` 192.168.31.2 并不在本机上,让板端当客户端就连不上;板端当服务端则从不断言
"对端是谁"才收,对端从实测包文学来,PC 换 IP 也不用改固件。PC 侧跑
`test/host/udp_queue_bench.py` 逐步激励,用**三个不同源端口的 socket** 分批发("客户端形态会不会
把别的源过滤掉"那一步必须有区分度才有意义)。
`test/host/udp_queue_bench.py` 逐步激励,用**三个不同源端口的 socket** 分批发("切形态之后还能不能收别人的包"
那一步必须有区分度才有意义)。
| 用例 | 内容 | 通过标准 | 必验 |
|------|------|----------|------|
| TC-NET-1600 | 载荷样式自检(fill/match 自洽) | 篡改第 9 字节 → match 返回 10;换 tag → 返回 1 | — |
| TC-NET-1601 | socket + bind :6001 + bind 后首包 | `net_udp_get_mode` = SERVER;20 B 收到且长度/样式/`addrlen`/`sin_family` 全对、学到 PC 源端口;回显后 mode = CLIENT | ① ⑤ |
| TC-NET-1602 | **48 B 应答落在客户端形态**(SNTP 尺寸) | 必须读到 48 B 且 `buf[0]` 就是载荷首字节——判据写错会只剩 40 B 且前 8 字节被当信息头吃掉;回显走 `dest_addr = NULL`(用记账对端那条路) | ① |
| TC-NET-1603 | `net_udp_set_peer(fd, NULL)` 回服务器形态 | 返回 0、mode = SERVER,且**不重开**芯片 socket;随后**换新源端口**发来的包必须收到(客户端形态会被 DesIP 过滤),再回显给这个新端口 | ② |
| TC-NET-1604 / 1605 | 截断与残包排空 | 200 B 的包用 64 B 缓冲只读到 64;紧接着 30 B 的下一个包必须整包到达(残包没排干净,这里会读到上一条的尾巴) | ③ |
| TC-NET-1601 | 新建 DGRAM socket + `net_bind(:6001)` | `fd` 有效、bind 返回 0、`net_udp_get_mode` = SERVER | ⑤ |
| TC-NET-1602 | bind 后首包(20 B,服务端形态) | 长度/样式/`addrlen`/`sin_family` 全对、学到 PC 源端口;回显后 mode = CLIENT(带地址的 `sendto` 记了账) | ① |
| TC-NET-1603 | **48 B 应答落在客户端形态**(SNTP 尺寸) | 必须读到 **48 B** 且 `buf[0]` 是载荷首字节——芯片此时**仍在前置 8 字节信息头**(真机定案,见下),判据若改成"客户端形态不剥"就会读成 56 B、首字节 0x38;回显走 `dest_addr = NULL`(用记账对端那条路) | ① |
| TC-NET-1604 | `net_udp_set_peer(fd, NULL)` 回服务端形态 | 返回 0、mode = SERVER,且**不重开**芯片 socket;随后**换新源端口**发来的包必须收到,再回显给这个新端口 | ② |
| TC-NET-1605 | 截断与残包排空 | 200 B 的包用 64 B 缓冲只读到 64;紧接着 30 B 的下一个包必须整包到达(残包没排干净,这里会读到上一条的尾巴) | ③ |
| TC-NET-1606 | 超时出口 | 空窗 `net_recvfrom_to(..., 1000)` 返回 **-2**(不是 -9),实耗 1000~4000 ms;下一步照常收发(槽没被毒化) | — |
| TC-NET-1607 | 挂起等待期间的 IWDG 余量 | PC 迟到约 12 s 才发;板端 `net_recvfrom_to(..., 30000)` 醒来看见 24 B 正确载荷,实测等待落在 7~25 s,`net_get_generation()` 不变(没重建没复位)。硬证据在串口日志:这段 `[ERR][WD] task stall` 零条、`[PROF]` 心跳连续 | ④ |
| TC-NET-1608 | 关旧 socket → 新开 → 重 bind :6002 | 新 socket 的首包必到(DesIP 要在 `OPEN_SOCKET` **之前**写才生效) | ⑤ |
| TC-NET-1609 | 分块边界 1 / 20 / 1024 / 1025 / 1472 | 每条往返字节完全一致 | — |
| TC-NET-1610 | **免对端自检**(17 条,编号留在段尾、执行放在 1601 之前,是为了断线重跑时编号不漂) | PC 一个包都不发也能确认:新建 DGRAM socket 读到 SERVER;未 bind 时 `net_sendto` 报 -5、流式 `net_recv` 读 DGRAM 报 -3;经队列 `bind :6003` 成功且 mode = SERVER;空缓冲 `DONTWAIT` 报 -7;`net_recvfrom_to(2000)` 返回 **-2** 且实测睡眠 2~4.5 s(等待睡在任务通知上,没在 SPI 总线上自旋);`to_ms == 0` 报 -3;`src_addr` 缺 `addrlen`、容量只有 4 字节都报 -3;`set_peer(fd, NULL)` 在本就是 SERVER 时返回 0 且无副作用;`net_close` 后同一槽重新 bind :6003 不被上一条命毒化 | — |
**实测把设计改写了(2026-11-04,第一轮 33/47)**:第一轮真机跑出来
`TC-NET-1603: tag 0x02 got 0x38 (n=56)` —— 带显式目的地址的 `net_sendto` 把 DesIP 改成 PC 之后,
芯片**照旧**给每个到达包前置 8 字节信息头(`0x38` = 56 就是头里的长度字段),设计时按手册 §9.2.4
写的"客户端形态不带信息头"其实说的是**开 Socket 那一刻**的 DesIP,运行时再改不算。于是
`net_udp_datagram_read` 的剥头判据从 `udp_mode == SERVER && avail >= 8` 改成**只看 `avail >= 8`**,
`udp_mode` 退化为"发送去向 + 对端记账";主机侧 UDP-4 与 UDP-24 soak 的客户端轮也跟着按真机重写
(假件当时按"客户端不剥头"脚本化,240 轮全绿也照样没发现这件事——TRAP-NET-22 教训④)。
改完的第二轮就是下面的全绿。
**实测结果(2026-11-04 08:42,板端 `fw build: Nov 4 2026`,phase16d.hex,PC = DHCP 192.168.31.161)**:
```
板端 === Phase 16: 77/77 PASSED (failed=0) ===
PC 端 === UDP Bench PC Peer: 27/27 PASSED (failed=0) ===
```
| 必验 | 实测证据(串口原文) | 结论 |
|------|----------------------|------|
| ① 服务端形态首包 + 剥头 + 学到源端口 | `TC-NET-1602: tag 0x01 20B from port 62073 OK`、`src addrlen=16`、`src family=2`、`1602d: echo 20B rc=20`;48 B 在客户端形态 `1603a: 48B intact in client form, header stripped (n=48)` | ✅ 载荷完整,源地址逐字节对 |
| ② 切回服务端形态后换源仍收得到 | `1604a: set_peer(NULL) rc=0`、`1604b: mode back to SERVER (0)`、`1604c: datagram from a NEW source port received (n=20)`(源端口 55351 ≠ 上一条 62073)、`1604d: echo to the new source rc=20` | ✅ 轻切换(不重开)确实恢复了多源接收 |
| ③ 截断包排空后下一个包完整 | `1605`:200 B 用 64 B 缓冲读到 64,紧接 30 B 的 `0x05` 整包到达并回显 `rc=30`;`1606` 空窗探针之后仍正常 | ✅ 残包排空有效 |
| ④ 挂起等待不吃 IWDG 预算 | `1606b: waited 1001ms with a 1000ms budget`(-2 出口);`1607a/b/c/d/e`:迟到包唤醒,`waited 11385ms (expect 7000..25000)`、`gen 1 -> 1`、载荷样式完好、回显 `rc=24`;整段日志 `[ERR][WD] task stall` **零条**,`[WD] armed: timeout ~2498ms (worst ~1701ms), miss_tol=800ms` 仍在线 | ✅ 睡在通知上,不是自旋 |
| ⑤ DesIP 写在 open 之前 + 重开后首包 | `1608a/b/c`(新 socket bind :6002 = 0、mode SERVER)、`1608d` 首包到达;`1609a..e`:1 / 20 / 1024 / 1025 / 1472 B 全部往返一致,`0x0A 1024B`、`0x0B 1025B`、`0x0C 1472B` 各条 `echo rc=` 等于原长 | ✅ 顺序与重建路径成立 |
PC 侧同时量到 >1 KB 回显被芯片发送缓冲拆开:`TC 0x0B: >1KB 回显按芯片发送缓冲在 1024 处拆包(实测 [1024, 1])`、
`TC 0x0C: …(实测 [1024, 448])`——这是 UDP 上共用 `net_send_locked` 的**已知语义**(见下「限度」第一条),
peer 脚本按"多片拼接"判定,不是缺陷。
**没有对端也要有证据**:1601 之后每一步都在等 PC 的包——网线没插好、防火墙静默丢包、peer 脚本没起,
任何一条都会让整条阶梯停在 1602(2026-11-03 首跑就是这么停的)。TC-NET-1610 只依赖芯片能把 socket
open 起来,形态记账(SERVER/CLIENT)、参数与出参守卫、"阻塞收包已改成挂起登记"这三件事在完全不互通
+33 -24
View File
@@ -446,24 +446,30 @@ void tcp_client(void)
> ——请求投 `netMsgQueue`,芯片事务**全部在 `netTask` 里串行执行**,调用方阻塞等回执(结果搭 32 位任务
> 通知值回来)。任何任务都能安全调用,不再有"单任务独占期"这个前提。第一个真实消费者是 SNTP(请求-应答)。
### 6.0 先分清楚:芯片的 UDP 两种模式
### 6.0 先分清楚:芯片的 UDP 两种形态是在 **open Socket 那一刻**定死的
CH395F 用 `DesIP` 一个寄存器区分两种 UDP 形态(手册 `docs/芯片手册/CH395F.md` §9.2.4),本层把它做成
socket 的**模式属性**(`net_types.h` `NET_UDP_MODE_SERVER` / `NET_UDP_MODE_CLIENT`)。两种形态收上来的
字节**不一样多**,这是 UDP 侧最容易踩的坑:
CH395F 用 `DesIP` 一个寄存器区分两种 UDP 形态(手册 `docs/芯片手册/CH395F.md` §9.2.4 讲的是**初始化步骤
②**),本层把它做成 socket 的**模式属性**(`net_types.h` `NET_UDP_MODE_SERVER` / `NET_UDP_MODE_CLIENT`),
但这个属性**只描述发送去向与芯片的对端过滤,不描述收包帧格式**——帧格式在开 Socket 时就已经定了:
| 模式 | DesIP | 收包布局 | 谁能发给我 | 本层如何进入 |
|---|---|---|---|---|
| **服务器** | `0xFFFFFFFF` | **8 字节信息头** + 载荷(头 [0-1] 长度、[2-3] 源端口、[4-7] 源 IP 线序) | 任意主机 | `net_bind()`(恒此模式:BSD 的 bind 只定本地端口,不选对端) |
| **客户端** | 具体 IP | **无信息头**,整段就是载荷 | 只有该 IP | `net_sendto()` 带目的地址,或 `net_udp_set_peer(fd, &peer)` |
| 开 Socket 时的 DesIP | 收包布局 | 谁能发给我 | 本层会不会产生这种 Socket |
|---|---|---|---|
| `0xFFFFFFFF`(服务器形态) | **8 字节信息头** + 载荷(头 [0-1] 长度、[2-3] 源端口、[4-7] 源 IP 线序) | 任意主机 | **会,且只有这一种**:`net_bind()` 恒此形态(BSD 的 bind 只定本地端口,不选对端) |
| 具体 IP(客户端形态) | 无信息头,整段就是载荷 | 只有该 IP | 不会:要它必须**在 `open_socket()` 之前**就把 DesIP 写成具体 IP,而 `bind` 先于 open 且语义上不该选对端 |
- 收包时**按模式**决定是否剥头:客户端模式绝不剥。旧实现不分模式恒剥 8 字节,而 `net_sendto` 一旦给出
目的地址芯片就已是客户端形态——于是 SNTP 这类应答的**前 8 字节载荷被吃掉**,源地址还是从载荷里当
头读出来的假值(缺陷登记见 `docs/问题记录/CH395F_Trap_Records.md`,回归钉子在
`test/host/net_socket/ns_s7_udp.c` UDP-3 / UDP-4)。
- 从客户端模式回服务器形态:`net_udp_set_peer(fd, NULL)`,只改 DesIP/DesPort,**不重开 Socket**;
再次 `net_bind()` 则是重开(顺带丢掉芯片缓冲里的残留包),两者用途不同。
- 客户端模式下 `net_recvfrom` 回填的源地址就是那个固定对端——芯片已经替我按 IP 过滤过一遍,包上也没有头可取。
- **运行时把 DesIP 从全 F 改成具体对端,只改变发送去向,不会让信息头消失**——2026-11-04 08:16 真机
阶段 16 第一轮实测:切到 CLIENT 形态后 PC 发来的 48 B 应答,板端读到 **n=56、首字节 0x38**(= 56,
正是信息头的长度字段)。所以 `net_recvfrom` / `net_recvfrom_to` **恒剥 8 字节头、恒从头部取源地址**,
`udp_mode` 不参与剥头判定(把它加回剥头条件是登记过又被真机打回的做法,别再来一遍,见
`docs/问题记录/CH395F_Trap_Records.md` TRAP-NET-22 的 2026-11-04 追加段)。
- `net_sendto()` 带目的地址、或 `net_udp_set_peer(fd, &peer)`:把 DesIP/DesPort 记到该对端,此后可用
`net_sendto(fd, buf, len, flags, NULL, 0)` 直接发它。**源地址与形态无关**,永远是包头里那个真正的发送方
——NAT 后的时间服务器就是"我发给 `119.28.220.214:123`、回来时源端口是 51999"那种,取记账值会答错。
- 从客户端形态回服务器形态:`net_udp_set_peer(fd, NULL)`,只改 DesIP/DesPort,**不重开 Socket**(不丢已
缓冲的应答,SNTP 这类请求-应答靠它);再次 `net_bind()` 则是重开(顺带丢掉芯片缓冲里的残留包),两者用途不同。
- ⚠ 运行时改窄 DesIP 到底会不会让芯片把其它主机的包过滤掉,**台架还没测出结论**(阶段 16 的 1604 只验到
"切回 SERVER 后换一个源端口的新包能收到")。要做**多源**服务(多个客户端同时发)就保持服务器形态、每次发送
都带显式 `dest_addr`——这条路径完全不碰这个未知项。
### 6.1 UDP Client
@@ -502,8 +508,9 @@ void udp_client(void)
dest_addr.sin_port = net_htons(8888);
dest_addr.sin_addr.s_addr = net_inet_addr("192.168.31.200");
/* 4. 发送数据:带目的地址的 sendto 顺手把 DesIP 指向该地址,
* 芯片从此是**客户端模式**(只收这个 IP 的包,包上不带信息头,见 §6.0) */
/* 4. 发送数据:带目的地址的 sendto 顺手把 DesIP/DesPort 指向该地址,
* 本层记账转为**客户端形态**(下一次 dest_addr 传 NULL 就发往它,见 §6.0);
* 收包帧格式不受影响——Socket 是 bind 时在全F 形态下开的,恒带 8 字节信息头 */
const char *msg = "UDP Hello!";
if (net_sendto(sock, msg, strlen(msg), 0,
(struct net_sockaddr *)&dest_addr, sizeof(dest_addr)) < 0) {
@@ -518,7 +525,8 @@ void udp_client(void)
if (len > 0) {
buf[len] = '\0';
/* 客户端模式的源地址就是这个固定对端,包上没有信息头 */
/* 应答就来自刚记账的那个对端,所以没取源地址也要得对;真要取,它来自
* 信息头,与当前形态无关(§6.0) */
printf("收到: %s (from server)\r\n", buf);
} else if (len == -2) {
printf("应答超时\r\n");
@@ -577,9 +585,10 @@ void udp_server(void)
net_inet_ntoa(src_addr.sin_addr.s_addr, ip_str),
net_ntohs(src_addr.sin_port));
/* 回显数据。⚠ 带目的地址的 sendto 会把芯片切成**客户端模式**
* (DesIP = 这个客户端),此后只有它一家能再发包进来。要长期服务
* 多个客户端,回完就把它切回来: */
/* 回显数据。⚠ 带目的地址的 sendto 会把本层记账切成**客户端形态**
* (DesIP = 这个客户端):此后不带地址的 sendto 都发往它。改窄 DesIP
* 会不会让芯片顺手把别的源过滤掉,台架没测出结论(§6.0),所以多客户端
* 服务别赌——回完就切回来: */
(void)net_sendto(sock, buf, len, 0,
(struct net_sockaddr *)&src_addr, src_addr_len);
(void)net_udp_set_peer(sock, NULL);
@@ -1065,7 +1074,7 @@ int net_sendto(int sockfd, const void *buf, int len, int flags,
| `sockfd` | **必须已 `net_bind()`**(未 bind 回 -1 / `NET_ERR_NOTCONN`:芯片侧 Socket 还没 open) |
| `buf` / `len` | 一个数据报的载荷,单包 ≤ 1472 字节(手册 §9.2.4);更长由本层按 1KB 分块、复用 TCP 的挂起 send 在 netTask 里推进 |
| `flags` | 目前只消费 `NET_MSG_DONTWAIT`:芯片发送缓冲不可用时立即回 `NET_ERR_WOULDBLOCK`,不登记挂起 |
| `dest_addr` / `addrlen` | 非 NULL = 顺带把芯片 DesIP 指向它 → 芯片转**客户端模式**(此后只收该 IP 的包、收包不带信息头,见 §6.0);NULL = 不改目的地址,沿用当前形态 |
| `dest_addr` / `addrlen` | 非 NULL = 顺带把芯片 DesIP/DesPort 指向它 → 本层记账转**客户端形态**(此后 `NULL` 发送就发往它;收包帧格式不变,见 §6.0);NULL = 不改目的地址,沿用当前形态 |
| 返回值 | > 0 = 已发送字节数(**可能短写**,须检查);-1 = 失败(`net_get_errno()`) |
任意任务可调用:请求入 `netMsgQueue`,芯片事务在 `netTask` 串行执行(2026-11-03 起,旧"仅限 netTask 直调"形态作废)。
@@ -1077,7 +1086,7 @@ int net_recvfrom(int sockfd, void *buf, int len, int flags,
struct net_sockaddr *src_addr, int *addrlen);
```
- 一次调用 = 一个**完整数据报**:服务器模式自动剥 8 字节信息头并从中取源地址,客户端模式整段是载荷、源地址回填固定对端(§6.0)。
- 一次调用 = 一个**完整数据报**:自动剥掉芯片的 8 字节信息头并从中取源地址;本层 Socket 恒带这个头,与当前 `udp_mode` 无关(§6.0)。
- `buf` 装不下时交付 `len` 字节并把剩余部分**读空丢弃**——半包留在芯片缓冲里,下一次收包会从上一包的尾巴开始解析。
- `src_addr` / `addrlen` 可为 NULL;非 NULL 时 `*addrlen` 给 `sizeof(struct net_sockaddr_in)`,回填的 `s_addr` 是网络序整数(§9.0)。
- ⚠ **无数据即无限等待**:受 IWDG 监管的任务改 `net_recvfrom_to()`,或先 `net_select()` / `net_can_read()` 门控。
@@ -1105,7 +1114,7 @@ int net_udp_set_peer(int sockfd, const struct net_sockaddr_in *peer);
| 参数 | 说明 |
|------|------|
| `peer` | 非 NULL = 客户端模式,DesIP/DesPort 指向该对端(只收它的包,收包无信息头);NULL = 回服务器模式(DesIP = `0xFFFFFFFF`、DesPort = 0,任意主机可发,收包带 8 字节信息头) |
| `peer` | 非 NULL = 客户端形态,DesIP/DesPort 指向该对端(不带地址的发送就发往它;**收包仍带 8 字节信息头**,因为头是开 Socket 时定的,见 §6.0);NULL = 回服务器形态(DesIP = `0xFFFFFFFF`、DesPort = 0,任意主机可发) |
| 返回值 | 0 成功;-1 失败(未 bind → `NET_ERR_NOTCONN`) |
只写两个寄存器、**不重开 Socket**,因此不清芯片缓冲;要连残留包一起丢掉就再 `net_bind()` 一次(重开形态)。
@@ -160,7 +160,7 @@ flowchart TB
| ~~`net_resync_listen`~~ | ~~`NET_MSG_RESYNC_LISTEN`~~ | **已删除**(语义并入对账⑤) | — | — | D14 静默回收(芯片自行回监听、不发 DISCONNECT)现由 `net_reconcile_tick` 判据⑤自动认领:本层 ESTABLISHED 且芯片 0x05/0x01 → 只修本层(state→LISTENING + 转登 `last_peer_port` + 清 `lost_conn_seen` + 取消挂起 recv),**不发芯片命令**(纪律原样保留,run8 实证 170s 拒绝);去抖/冷却沿用对账框架 |
| `net_bind`(**UDP 支**) | `NET_MSG_UDP_BIND`(端口按 `ntohs` 后的值传,0 = 由执行体取动态号) | `net_udp_bind_exec` | Socket 4~7 兜底 `set_send_buf` / `set_recv_buf` + `set_proto_type(UDP)` + `set_sour_port` + `set_des_ip(0xFFFFFFFF)` + `set_des_port(0)` + `open_socket` | 0 / -1 | **bind 恒服务器模式**(BSD 的 bind 只定本地端口,不选对端);DesIP/端口写在 open **之前**(open 复位寄存器,写晚了等于没写);动态端口游标在 netTask 内推进(旧形态在调用方上下文改静态量 = 跨任务竞争) |
| `net_sendto` | `NET_MSG_UDP_SENDTO`(目的地址按值拷进 `msg.addr`,`addrlen > 0` 才改 DesIP) | `net_udp_sendto_exec` → **共用** `net_send_locked` | `set_des_ip` / `set_des_port`(带地址时)+ `write_send_buf` 分块 ≤ 1KB | 实际字节数(**可能短写**)/ -1 | 带目的地址 = 切**客户端模式**并记账 `remote_*`;发送与 TCP 走同一条分块 + 挂起 send(`net_send_pending`)通路,不为 UDP 复制第二份 1KB 分块逻辑;未 bind → NOTCONN(芯片侧还没 open) |
| `net_recvfrom` / `net_recvfrom_to` | `NET_MSG_UDP_RECVFROM`(**出参指针不入队**:`net_udp_outparam_arm` 存进本 fd 槽,netTask 直接写回调用方内存) | `net_udp_recvfrom_exec`(即时读 / 登记挂起)+ `net_udp_tick`(完成 / 超时结算) | `net_poll` + `get_recv_len` + `read_recv_buf`(剥头 + 截断排空) | >0 / -1 / **-2(`_to` 预算内无数据,不占 errno)** | 一次调用 = 一个完整数据报;**按 `udp_mode` 决定剥不剥 8 字节信息头**(服务器剥、客户端不剥——旧形态不分模式恒剥,客户端模式等于吃掉载荷前 8 字节并回填假源地址);无数据 + 阻塞 → 登记挂起立即让出 netTask;缓冲装不下时剩余部分**读空丢弃**;同 fd 第二个接收者 → BUSY |
| `net_recvfrom` / `net_recvfrom_to` | `NET_MSG_UDP_RECVFROM`(**出参指针不入队**:`net_udp_outparam_arm` 存进本 fd 槽,netTask 直接写回调用方内存) | `net_udp_recvfrom_exec`(即时读 / 登记挂起)+ `net_udp_tick`(完成 / 超时结算) | `net_poll` + `get_recv_len` + `read_recv_buf`(剥头 + 截断排空) | >0 / -1 / **-2(`_to` 预算内无数据,不占 errno)** | 一次调用 = 一个完整数据报;**恒剥 8 字节信息头、恒从头部取源地址**(本层 Socket 一律在 `DesIP = 0xFFFFFFFF` 的形态下开出,运行时改 DesIP 不改收包帧格式——真机实测见 TRAP-NET-22 的 2026-11-04 追加段;曾经写成"按 `udp_mode` 决定剥不剥",是真机第一轮跑翻的);无数据 + 阻塞 → 登记挂起立即让出 netTask;缓冲装不下时剩余部分**读空丢弃**;同 fd 第二个接收者 → BUSY |
| `net_udp_set_peer` | `NET_MSG_UDP_PEER`(`addrlen == 0` = 回服务器模式) | `net_udp_set_peer_exec` | `set_des_ip` + `set_des_port`(**不重开 Socket**) | 0 / -1 | 模式切换的轻量入口,给"上一轮当客户端问、这一轮要听所有人"的收包方;要连芯片缓冲残留一起丢掉就再 `net_bind()` 一次(重开形态) |
### 4.4 直调 API(不经队列)
+9 -9
View File
@@ -237,16 +237,16 @@ CH395F 以太网驱动与 NET 层的实证陷阱:现象、定案根因、修
- **教训**:显示层字节序 bug 不影响协议本身(FTP 功能全程正常),但会系统性误导排障——**日志里的地址也要当被测对象核对**。
- **后续(2026-10-24):同一文件里的`s_addr` 两套约定已统一**。修完段序之后,本条只解决了显示侧——当时 `net_inet_addr` 仍属**另一族**(小端累加后又 `NET_HTONL`,返回 `0xC0A80164`),而 `net_inet_ntoa` / `net_getsockname` 属 POSIX 网络序那族(`0x6401A8C0`),两者**不互逆**;按前者写的消费者(`net_connect_locked` 用 `>>24` 取首段)恰好自洽,所以一直没人撞上。现已统一到 **POSIX 网络序一族**(判据 `&s_addr[0] == 第一段`;小端上 192.168.1.100 = `0x6401A8C0`):改 `net_inet_addr` + 出向 IP 数组改按内存字节顺位拆装(**进与出同时换族 → 芯片侧数组净效果不变**)+ 模拟器 `simulator/sim/net_socket_sim.c` 逐字同步;`net_inet_ntoa` 函数体一字未动。**为什么它能长期不被发现**:本条"修复"行下方 `ntoa` 自己注释里的例子(`192.168.1.100 → 0xC0A80164`)**取的是废弃族的值**——注释替错误背书,比没有注释更糟。规范定义见 `docs/网络/BSD_Socket_API_使用指南.md` §9.0;往返不变量由 TC-NETSOCK-100g / 100h 钉住。
## TRAP-NET-22 · UDP 形态(DesIP)与剥头不一致 → 应答丢前 8 字节
## TRAP-NET-22 · UDP 形态(DesIP)与剥头不一致 → 剥头判据最终由真机定案
- **状态**:✅(2026-11-03 修复,UDP 队列化改造顺带照出)
- **现象**(改前):`net_bind(UDP)` 后 `net_sendto()` 发请求、再 `net_recvfrom()` 收应答,**收到的 48 字节应答少了前 8 字节**(SNTP 用例里魔数 / 时间戳整体左移 8 字节),且 `src_addr` 是垃圾。
- **根因(定案)**:CH395F 的 UDP 有两种形态,**由 DesIP 这一个寄存器决定,写一次即全局生效**(手册 §9.2.4):DesIP = `0xFFFFFFFF` → **UDP Server**,每个到达包**前置 8 字节信息头**(`[0-1]` 长度、`[2-3]` 源端口、`[4-7]` 源 IP);DesIP = 具体 IP → **UDP Client**,**不带**信息头,且只收该对端的包。旧实现 `net_bind()` 把 DesIP 写成 `0xFFFFFFFF`(服务器形态),但 `net_sendto()` 为了发出这个包把 DesIP 改成目的 IP —— **芯片就此静默切到 Client 形态**,而 `net_recvfrom()` 仍按服务器形态**无条件剥掉 8 字节**(判据只有 `recv_len >= 8`,没有形态条件)。
- **为什么长期没人发现**:全仓无生产 UDP 调用者(只有 `test/board` 的硬件层用例,直接用 `ch395f_*`、不经这层 API),`docs/网络/BSD_Socket_API_使用指南.md` §6 甚至写着"本项目不使用 UDP";唯一会踩中它的是**请求-响应式单包应答**协议(SNTP / DHCP 类),本仓一个都没有。
- **修复(定案)**:`net_sock_t` 新增 `udp_mode`(`NET_UDP_MODE_SERVER / CLIENT`,住 `net_types.h`,塞进原有 2 字节 padding → `sizeof(net_sock_t)` 不变);`net_bind()` 恒置 SERVER(BSD 语义里 bind 不选对端);显式对端的 `net_sendto()` 切 CLIENT;新增 `net_udp_set_peer(fd, NULL)` 回 SERVER(只写 DesIP/DesPort、**不重开 Socket**,因此不清芯片缓冲;要把残留包一起丢就再 `net_bind()`);剥头条件收紧为 `udp_mode == SERVER && avail >= 8`,CLIENT 形态源地址取对端记账。
- **教训**:① **芯片的"形态"是隐式全局状态,API 里每一次"顺手写一个寄存器"都可能切换它**——写之前先问"这一下改变了芯片的什么模式、谁负责改回来";② **解析收包字节流必须有形态前提**,`if (len >= 8) 剥 8` 这种"看着像头就剥"的写法,在协议边界上迟早吃掉载荷;③ "没有生产调用者"不等于"被测过"——这层 API 的 UDP 路径是本轮第一次被端到端测到(`ns_s7_udp.c` UDP-1~14 + TC-NETSOCK-440 / 555)。
- **关联**:`docs/网络/BSD_Socket_API_使用指南.md` §6.0(形态表 + 谁负责切换)/ §6.3;`docs/网络/BSD_Socket_API_函数调用关系.md` §4.11;`docs/测试说明/单元测试/主机单测覆盖率口径.md`「第 4 轮」;TRAP-NET-14(同一族字节序口径:信息头里源端口小端、源 IP 点分内存顺位);TRAP-NET-10(收包出参为何不能搭 `net_msg_t`)。
- **未上台架**:本条差异在主机测里靠假件契约钉住(真芯片不校验我们写的值),真机复验项见"第 4 轮"台架清单。
- **状态**:✅(2026-11-03 随 UDP 队列化改造登记;**2026-11-04 真机定案,本条的根因半句被实测推翻并改正**)
- **登记时的推断(当时未实测,全仓无生产 UDP 调用者、也无台架用例)**:`net_bind(UDP)` 后 `net_sendto()` 带目的地址、再 `net_recvfrom()` 收应答,会**少收前 8 字节**(SNTP 的 48 B 应答只剩 40 B,魔数与时间戳整体左移),且 `src_addr` 是垃圾。
- **根因(2026-11-04 08:16 真机定案)**:CH395F 的 UDP 两种形态**是在 OPEN_SOCKET 那一刻由 DesIP 定死的**(手册 §9.2.4 说的是"初始化步骤②",读的时候漏了"初始化"这个限定语):开 Socket 时 DesIP = `0xFFFFFFFF` → **UDP Server**,此后**每一个**到达包都**前置 8 字节信息头**(`[0-1]` 长度、`[2-3]` 源端口、`[4-7]` 源 IP);开 Socket 时 DesIP = 具体 IP → **UDP Client**,才不带信息头。开完之后再改 DesIP:实测**只改发送去向,不改收包帧格式**。
- **实测证据(PR #74 阶段 16 第一轮)**:板端 bind `:6001`(服务器形态)→ 收满一个 20 B 包后 `net_sendto` 带显式目的地址(DesIP 被改成 PC 的 IP,`udp_mode` = CLIENT)→ PC 发一个 48 B 应答 → 板端 `net_recvfrom` 读到 **n=56、缓冲首字节 0x38**(= 56,正是信息头的长度字段)。头**没消失**。本层 Socket 一律由 `net_bind` 在全F 形态下开出,所以**恒有头、恒该剥**;旧实现"无条件剥 8 字节"在载荷层面恰好是对的,登记的那条"丢 8 字节"现象并不成立("垃圾源地址"同理——头一直在,从里头取就是对的)。
- **修复(定案)**:`net_udp_datagram_read` 的剥头条件是 `avail >= 8`,**与 `udp_mode` 无关**,并在函数注释里写明"不许改成按形态判"——这条判据是本轮从"看着更严谨"的写法被真机打回原形的,代码里留了反例说明。`udp_mode` / `net_udp_set_peer()` 仍保留,但只描述**芯片的对端过滤与发送去向**(`net_sendto` 带地址 = 切 CLIENT 并记账,`net_udp_set_peer(fd, NULL)` = 轻切回 SERVER,只写两个寄存器、**不重开 Socket** 因而不丢已缓冲的应答);`net_bind()` 恒置 SERVER(BSD 语义里 bind 不选对端);不足 8 字节的残包无从判头,整包交付、源地址退到对端记账。运行时把 DesIP 改窄是否真会让芯片按源过滤掉其它主机的包,台架**还没测出结论**(本轮 1604 只验到"切回 SERVER 后换源端口的新包能收到"),所以 `udp_mode` 的过滤语义只在注释里按手册口径叙述,不当已证事实用。
- **为什么长期没人发现**:全仓无生产 UDP 调用者(只有 `test/board` 的硬件层用例,直接用 `ch395f_*`、不经这层 API),`docs/网络/BSD_Socket_API_使用指南.md` §6 甚至写着"本项目不使用 UDP"。
- **教训**:① **读手册推断出来的"现象"必须写成假设,不能写成已观测现象**——本条原来把推断直接写进"现象(改前)",于是"修复"也跟着错:按形态决定剥不剥的判据看起来比"恒剥 8 字节"更严谨,真机第一轮就翻车,而且翻得隐蔽(长度对不上、首字节是个看着像长度的数);② **芯片的模式是"开 Socket 时定死的隐式全局状态"**,写寄存器的时机比写什么值更关键(TRAP-NET-14 的 DesIP 顺序是同一条族);③ 形态切换仍然是一个真实的隐式状态迁移(它改发送去向),"顺手写一个寄存器"之前要问"这一下改变了什么、谁负责改回来";④ "没有生产调用者"不等于"被测过",而**主机假件按我们的理解脚本化,永远测不出理解本身错了**——假件按"CLIENT 不剥头"喂裸载荷,667 条全绿也照样漏掉真芯片的行为(`ns_s7_udp.c` UDP-1~25 + TC-NETSOCK-440 / 555)。
- **关联**:`docs/网络/BSD_Socket_API_使用指南.md` §6.0(形态表 + 谁负责切换)/ §6.3;`docs/网络/BSD_Socket_API_函数调用关系.md` §4.11;`docs/测试说明/单元测试/主机单测覆盖率口径.md`「第 4 轮」;`docs/测试说明/板级测试/CH395F_Test_Guide.md` §12.6(阶段 16 与实测结果);TRAP-NET-14(信息头里源端口小端、源 IP 点分内存顺位);TRAP-NET-10(收包出参为何不能搭 `net_msg_t`)。
---