# Net 层阻塞模型架构设计 > **模块名称**:Net 层挂起 recv / send 模型(`Drivers/BSP/NET/net_pending.c` + netTask 主循环节拍) > **文档定位**:**现状架构设计说明**——本层**当前如何组织等待与完成**、为什么这样设计、数据结构与接口 > 契约、实现落点、边界条件与性能特征。历史演进、门禁实测记录与修订日志一律不保留在文档内(需要时查 > git 历史)。 > **状态**:recv 与 send 两条挂起路径**均已落地**(真机台架验证);ACCEPT 尚未挂起化,判据见 §10。 > **相关文档**: > `docs/网络/BSD_Socket_API_使用指南.md`(应用视角 API 语义)、 > `docs/网络/BSD_Socket_API_函数调用关系.md`(调用链与执行域)、 > `docs/网络/网络自愈架构设计.md`(周期对账与监听生命周期——与本模型的分工见 §2.4;模块化边界见该文档 §11)、 > `docs/测试说明/专项验证/链路延迟测试说明.md`(延迟基线与故障对照)、 > 本文件 §10(ACCEPT 挂起化不实施的判据) ## 目录 - [1. 问题域:等待为什么必须挪出 netTask](#1-问题域等待为什么必须挪出-nettask) - [2. 架构总览](#2-架构总览) - [3. 一次阻塞 recv 的全过程](#3-一次阻塞-recv-的全过程) - [4. 数据结构](#4-数据结构) - [5. 挂起的请求会被什么结束](#5-挂起的请求会被什么结束) - [6. 模块接口契约](#6-模块接口契约) - [7. 实现落点(改/查本模型看哪里)](#7-实现落点改查本模型看哪里) - [8. 调用方契约](#8-调用方契约) - [9. 性能与资源特征](#9-性能与资源特征) - [10. 设计边界与已知代价](#10-设计边界与已知代价) - [11. 设计铁律(为什么是这样)](#11-设计铁律为什么是这样) - [12. 排查辅助:挂起超时留痕探针(编译期选项,默认关)](#12-排查辅助挂起超时留痕探针编译期选项默认关) - [13. 源码索引](#13-源码索引) --- ## 1. 问题域:等待为什么必须挪出 netTask netTask 是**全部 socket 操作的唯一执行者**——CH395F 的 SPI 总线归它独占(SPI 铁律), 因此所有 socket 操作必须经消息队列串行化到 netTask 上下文完成。 这带来一个结构性约束:**消费者发起的"等待数据"会占住执行者**。若 `net_recv` 采用 "在 netTask 内自旋轮询直到数据到达"的形态,一条空闲连接即可让整个网络层停摆—— 所有其他 socket 的操作都要排队等它。 > **约束的本质**:本项目的"数据到达"无法被免费观测。业界正统形态(LwIP 等)依赖网卡 > DMA/IRQ 把数据递到协议线程,数据到达是 O(1) 事件;而本项目的数据到达发生在 CH395F > **芯片内部**,唯一观测手段是一次 SPI 事务,而 SPI 只许 netTask 发起。 > → **观测本身占用执行者**,等待无法挪出 netTask。 设计自由度因此只剩两条:① 每次占用执行者多久 ② 等待期间执行者还能做什么。三条候选路线: | 路线 | 单次占用执行者 | 等待期间执行者 | 结论 | |---|---|---|---| | A 执行者内自旋 | 整个等待期(可无限) | **完全停摆** | ❌ 队头阻塞:一条空闲连接即可冻结整个网络层 | | B 有界分片轮询(`net_recv_to` 分片) | ≤ `timeout_ms` | 分片间隙可用 | ⚠️ 止血:仍周期性占用,调用方须自管总预算 | | C **挂起登记(本模型)** | 微秒级(登记一次) | 照常服务整个队列 | ✅ 结构性消灭 | 三条路线的量化对照见历史审计的队头阻塞发现(属主文档已于 2026-10-15 删除)。本模型选 C;路线 B 的入口 `net_recv_to` **仍在 API 面上**,定位是**调用方显式预算**(§6.2、§8),与挂起登记互补。 > **send 是同一问题的第二个面**:`net_send` 写入 CH395F 发送 FIFO 后要等 `send_ready` > (由 `SENBUF_FREE` 中断经 `net_poll` 重新置位)。这段等待若发生在 netTask 内,就是同一种 > **执行者占用型**队头阻塞——量级比 recv 小一个数量级,但同样冻结整个消息队列,并顺延挂起 > recv 的唤醒。故 send 纳入同一模型:**快路径保留有界自旋**(`NET_SEND_SPIN_MS` = 2 ms, > 健康链路块间等待 med 219 µs,正常发送根本不触发),超预算才登记挂起(§4.5、§5.5)。 > 判据始终只有一个:**执行者被占多久**(§10)。 --- ## 2. 架构总览 核心思想:**把"等数据"从执行者内的自旋,改为执行者外的登记**。四个支点: 1. **登记而非占用**——netTask 只花微秒级把请求记进表里,随即去处理下一条消息; 2. **等待落在调用方自己的上下文**——睡在任务通知上,与 LwIP 的消费者线程模型同构; 3. **完成判定复用主循环既有节拍**——判据是 RAM 里的标志与到期时间,不引入额外 SPI 事务; 4. **唯一完成出口**——所有完成/取消都汇聚到一个函数(通知 + 清槽),结构上排除"漏通知"和 "完成两次"。 ![Net 层阻塞模型 · pending 请求架构](../images/Net层阻塞模型.svg) 上图四层结构: | 区域 | 内容 | |---|---| | **左栏 · 调用方任务** | `net_recv` → 判"数据已在?" → **是**走快速路径;**否**(`timeout > 0`)登记挂起 recv 后在自己上下文睡任务通知。`net_send` 同构:快路径短暂自旋写块,超预算则登记挂起 send 后睡 | | **中栏 · netTask 主循环** | ~1ms 节拍:`net_poll`(刷新 `readable` + 全量刷新驱动状态缓存)→ 4 个非阻塞 tick(`net_listener_tick` D10 → `net_pending_tick` 结束挂起 recv → `net_send_pending_tick` 推进/结束挂起 send → `net_reconcile_tick` 对账 + 监听重建唯一入口)→ 排空消息队列 | | **下带 · 判定** | recv 每轮按序判定:数据到达 / 到点(仅显式预算)/ EOF / 同 fd 后续操作 / 网络重建(**五路**,§5.1 简称);send 每轮按序判定:断连 / 推进 / 写完 / 无进展超时(**四路**,§5.5 简称);挂起超时留痕探针(编译期选项,默认关,见 §12)只留痕 | | **回环** | 判定完成经 `xTaskNotify` 唤醒调用方(结果 = 字节数 / 负错误码) | > 图形由 `tools/arch_diagram/gen_net_pending_svg.py` 生成(改流程后重跑;见 > `docs/架构设计/图形绘制流程.md`)。 ### 2.1 关键性质 - 等待期间 netTask **零额外占用**——`net_pending_tick` 只读标志与到期时间, 数据到达判据 `readable` 由主循环既有的 `net_poll()` 每轮刷新,**不引入额外 SPI 事务**; - **挂起 send 的占用有界且非阻塞**——`net_send_pending_tick` 每轮至多写一块(`≤ NET_SEND_CHUNK_MAX`), 写完即返回,绝不 `osDelay` 忙等;故 netTask 最坏停滞 = `NET_SEND_SPIN_MS`(2 ms), 而不是 `NET_SEND_TIMEOUT_MS`(1 s); - 调用方**在自己的上下文睡眠**(任务通知),与 LwIP 的消费者线程模型同构; - **公开 API 契约不因本模型改变**:`net_recv`(默认无限阻塞)/ `net_recv_nb` + `net_setblocking` / `net_recv_to`(唯一显式预算入口)/ `net_send`(返回已写入字节数, 短写须检查)。超时归属语义详见 `docs/网络/网络自愈架构设计.md` §4.5 与 `docs/网络/BSD_Socket_API_使用指南.md` §3.5。 ### 2.2 模块边界 本模型是 `net_socket.c` 内部的**一层**,实现独立成模块,只被少数确定位置消费: | 文件 | 持有 | 对外接口 | |---|---|---| | `Drivers/BSP/NET/net_pending.c` / `.h` | 挂起 recv 表 `s_net_pending[]`、挂起 send 表 `s_net_send[]`、执行者句柄 `s_owner_task`、全部完成/取消逻辑 | 16 个函数(recv 组 9 + send 组 7,其中 2 个 static,对外 14 个,§6.1) | | `Drivers/BSP/NET/net_socket.c` | 消息队列与分派、`net_recv_locked` / `net_send_locked` 的挂起登记、close/drop_conn/重建/对账的结算调用 | 调用方(§7) | | `Drivers/BSP/NET/net_listener.c` | 监听器重开 | 消费 `net_pending_active()` / `net_send_pending_active()` 作"挂起保险" | | `App/task/net_task.c` | 主循环节拍(`net_poll` → `net_process_messages` → `osDelay(1)`) | 提供 tick 节拍 | 依赖方向**单向**:`net_pending.c` → `net_types.h` / `net_socket.h` / `net_listener.h` / `ch395f.h`(send 侧分块写与缓冲常量)/ `main.h`;无反向 include。`net_listener.c` 经 `net_pending.h` 反向消费 `net_pending_active()` / `net_send_pending_active()`,二者均为 **纯 RAM 读**,任意上下文安全。 ### 2.3 与主循环的关系 netTask 主循环(`App/task/net_task.c` → `net_task_func`)每轮: ```text task_wd_beat() → 处理 SYS_EVT_NET_RECONFIG → net_poll() → net_process_messages() → osDelay(1) ``` `net_process_messages()`(`Drivers/BSP/NET/net_socket.c`)内部**先跑四个非阻塞 tick, 再排空消息队列**,顺序固定: | 顺序 | tick | 职责 | 与挂起模型的关系 | |---|---|---|---| | ① | `net_listener_tick()` | D10 重监听重试(30ms/2s) | 跳过有在途挂起 recv / send 的 fd(§7.1 集成点 8 / 20) | | ② | `net_pending_tick(s_net_socks)` | **结束挂起 recv**(数据/到点/断连) | recv 的主完成检查入口 | | ③ | `net_send_pending_tick(s_net_socks)` | **推进/结束挂起 send**(断连/推进/写完/超时) | send 的主推进入口 | | ④ | `net_reconcile_tick()` | 100ms 对账 + 监听重建唯一入口 | 形态⑤(芯片静默回收)取消本 fd 挂起 recv / send(§7.1 集成点 7 / 17) | 四个 tick 均非阻塞、**绝不 `osDelay` 忙等**;tick 先于消息分派,保证"断连后即使无应用 消息,监听器与挂起 recv / send 也能自主收敛"。②③ 相对顺序无关(两张独立表), 但都必须在 ④ 之前——对账可能取消同一 fd 的两侧挂起。 ### 2.4 与网络自愈架构的分工 两篇文档描述同一段代码的两个正交面,边界如下: | 关注点 | 归属文档 | |---|---| | "一次阻塞 recv 如何被唤醒/结束"(本模型:登记、五路完成、接口、集成点) | **本文** | | "芯片与本层状态失同步后如何周期收敛"(对账五形态、沉降隔离、D10 重试、KeepAlive) | `docs/网络/网络自愈架构设计.md` | | 监听生命周期的模块化边界(`net_listener.c` 拆分依据) | `docs/网络/网络自愈架构设计.md` §11 | 交叉点只有两处:`net_reconcile_tick` 形态⑤ 取消挂起(§5.2 第 5 路)、 `net_listener_tick` 的挂起 recv 保险(§7.1 集成点 8)。 --- ## 3. 一次阻塞 recv 的全过程 一句话链:**登记(μs 级)→ 调用方自己睡 → 每轮判定(只读标志与到期时间)→ 公共出口(通知 + 清槽)→ 两段式等待取结果**。以 `net_recv(sockfd, buf, len, 0)` 阻塞路径为例: ```mermaid flowchart TD A["调用方任务:net_recv(sockfd, buf, len, 0)"] --> B["消息入队,随后在
自己上下文睡(两段式等待)"] B -.-> C subgraph NT["netTask —— 唯一执行者(~1ms 节拍)"] C{"数据已在?
readable / recv_len > 0"} C -- "是(常见)" --> D["直接读出"] C -- "否 + 阻塞模式" --> E["登记挂起请求(μs 级)
守卫:每 fd 至多一个、拒绝 netTask 自等待"] E --> F["随即返回,去处理下一条消息
—— 等待不占用执行者"] F --> G["每轮检查每个挂起 fd"] G -- "未命中 → 等下一轮" --> G G -- "数据到达" --> H1["SPI 读出"] G -- "到点(仅显式预算)" --> H2["结果 = TIMEDOUT"] G -- "断连(CLOSED / session_dead)" --> H3["排空:有残留 → 读出
无残留 → EOF(0) / 异常 CONNRESET"] H1 --> I H2 --> I H3 --> I end D --> I I["通知调用方(同一回传通道):通知值 = 结果原值
挂起路径经公共出口 net_pending_complete:清槽、防重复通知"] I --> J["调用方醒来取结果
0 = EOF 也是有效通知(以 pdTRUE 判定)"] K["取消路径:close / drop_conn / 网络重建 / 对账 resync"] -. "先结束挂起、再动芯片" .-> I ``` 1. **入队**(调用方任务):`net_recv` 把请求打包进 `netMsgQueue`,随后进入两段式等待 (步骤 5)——netTask 不被占用。 2. **快速路径**(netTask,`net_recv_locked`):入口先 `net_poll()` 刷新状态机与可读标志,再查 `ch395f_get_recv_len`;数据已在(`recv_len > 0`)→ 直接读出、经通知值回传字节数,不产生 挂起。常见路径到此为止。 3. **登记**(netTask):无数据且阻塞模式 → `net_pending_register` 过两条守卫(§4.3)→ 记 `buf`/`len`/`caller` 与预算(`net_recv_to` → `deadline`;无限等待 → `infinite=1` 无 deadline)→ 返回哨兵 `NET_PENDING_REGISTERED`(分派据此**不发即时回执**,§6.2), netTask 立即处理下一条消息。 4. **每轮判定 → 公共出口**(netTask):`net_pending_tick` 对每个挂起 fd 按序 ①数据 → ②到点 → ③断连(判据见 §5.2);命中即进 `net_pending_complete`——所有完成/取消路径的**唯一**出口: `xTaskNotify` 覆盖式写入**通知值 = 结果原值**(>0 字节数 / 0 EOF / 负错误码)→ 清 `active`; 无在位挂起则静默返回(防重复通知)。 5. **唤醒与取结果**(调用方任务):所有 `net_*` API 的回执共用同一条任务通知通道,**两段式 等待**防"入队→等待入口"间隙内通知丢失(细则见 §7.2 集成点 11)。 6. **取消路径**(第 4/5 路,共同规则"**先结束挂起、再动芯片**"):close / drop_conn 首行先 `net_pending_settle_close` 结算(芯片 RX 有残数据 → 按字节数送达;无残数据 → 取消 CONNRESET),对账发现芯片静默回收走 `net_pending_cancel`,网络重建在 `net_init` 顶部 `net_pending_cancel_all`——**取消必通知**,否则调用方永久挂死(§5.2 第 4/5 路)。 > **`buf` 生命周期**:挂起期间 `buf` 指针须保持有效至调用返回(挂起表按指针写入,§8)。 --- ## 4. 数据结构 ### 4.1 挂起 recv 请求表 `net_pending_t` | 字段 | 类型 | 含义 | 生命周期 | |---|---|---|---| | `active` | `uint8_t` | 1 = 本 fd 有挂起 recv | 登记置 1;`net_pending_complete` / `clear` 清 0 | | `infinite` | `uint8_t` | 1 = 无限等待(无 deadline) | 登记按 `to_ms == 0` 定;每轮 ② 判定据此跳过 | | `tripwire_logged` | `uint8_t` | 1 = 本轮挂起已留痕(保证只留一条) | **仅 `NET_PENDING_TRIPWIRE` 编译期存在**;登记清 0 | | `buf` | `void *` | 调用方接收缓冲(仅指针,不拷贝) | 登记写入;调用方保证有效至返回 | | `len` | `int` | 缓冲容量 | 登记写入 | | `deadline` | `uint32_t` | 截止时刻(tick)= 登记时刻 + 本次预算 | 无限等待时置 0(可观测性用,② 判定不看) | | `registered` | `uint32_t` | 登记时刻(tick) | 留痕探针基准 | | `caller` | `TaskHandle_t` | 调用方任务(完成通知目标) | 登记写入 | - **存储**:**独立静态数组** `s_net_pending[NET_MAX_SOCKETS]`(不置于 `net_sock_t` 内)。 - **容量**:每 fd **至多一个**挂起请求。 - **占用**:结构 24 B/fd × 8 fd = **192 B**(`tripwire_logged` 落在对齐填充里,开启探针 不增加占用);另 `s_owner_task` 4 B。 - **`infinite` 与 `deadline` 正交**:`to_ms == 0` 即"0 预算 = 无限等待"(无默认预算)。 ### 4.2 为什么独立成表而不是塞进 `net_sock_t` `net_close_locked` 对 `net_sock_t` 做**整块 `memset`**。若挂起状态置于其中,`close` 会 静默抹掉 pending——调用方永远等不到通知,**永久挂死**。独立表把"跨时间状态"与 socket 生命周期解耦,与 `net_listener_tick` 的重试表同构。 ### 4.3 登记守卫(两条硬防线,recv / send 共用) recv 与 send 两张表的登记函数(`net_pending_register` / `net_send_pending_register`) 执行**完全相同**的两条守卫: | 守卫 | 判据 | 理由 | |---|---|---| | 每 fd 至多一个,**不覆盖** | `p->active != 0` → 返回 `BUSY` | 覆盖 = 丢掉首个调用方的通知 = 该调用方永久挂起 | | 拒绝执行者自等待 | `caller == NULL` 或 `caller == s_owner_task` → 返回 `BUSY` | netTask 上下文自等待 = 无人能完成它 = 死锁;netTask 内必须用 `NET_MSG_DONTWAIT` / 非阻塞发送 | `s_owner_task` 由 `net_pending_capture_owner()` 在 `net_init` 顶部捕获(= 调用者, NET 层为 netTask,硬件层为 testTask)。 ### 4.4 挂起槽状态机 ```mermaid stateDiagram-v2 [*] --> IDLE IDLE --> WAITING: net_pending_register(过两守卫) WAITING --> WAITING: tick 每轮判定未命中
(含中间态 CONNECTING/LISTENING 保持等待) WAITING --> IDLE: net_pending_complete
(数据 / 到点 / EOF / 取消) IDLE --> IDLE: complete 对非在位槽位静默返回(防重复通知) note right of WAITING 等待期零执行者占用: tick 只读标志与到期时间 end note ``` 槽位只有两个状态:**IDLE**(无挂起)与 **WAITING**(有挂起)。所有出口都汇聚到 `net_pending_complete`(或幂等的 `net_pending_clear`),因此**不存在"完成两次"或 "漏通知"的中间态**。挂起 send 槽位(§4.5)同构,只是"完成条件"由"数据到达"换为"全部写完"。 ### 4.5 挂起 send 请求表 `net_send_pending_t` | 字段 | 类型 | 含义 | 生命周期 | |---|---|---|---| | `active` | `uint8_t` | 1 = 本 fd 有挂起 send | 登记置 1;`net_send_pending_complete` / `clear` 清 0 | | `buf` | `const void *` | 调用方发送缓冲基址(**仅指针,不拷贝**) | 登记写入;调用方保证有效至返回 | | `len` | `int` | 本次发送**总**长度(含登记前已写入芯片的部分) | 登记写入 | | `sent` | `int` | 已写入芯片的字节数(**进度**;登记时可 > 0) | 每次成功写块累加;完成时即结果值 | | `deadline` | `uint32_t` | 无进展超时时刻 = **上次成功写块时刻** + `NET_SEND_TIMEOUT_MS` | 每次成功写块刷新;`NET_SEND_TIMEOUT_MS == 0` 时置 0(不限) | | `registered` | `uint32_t` | 登记时刻(tick) | 日志基准 | | `caller` | `TaskHandle_t` | 调用方任务(完成通知目标) | 登记写入 | - **存储**:**独立静态数组** `s_net_send[NET_MAX_SOCKETS]`,与 `s_net_pending[]` **完全独立** (各自一张表、各占一槽)。 - **容量**:每 fd **至多一个**挂起 send。 - **占用**:结构 28 B/fd × 8 fd = **224 B**(RW_IRAM1,与 `s_net_pending` 的 192 B 并列)。 - **与 recv 表可并存**:同一 fd 可同时有一个挂起 recv 与一个挂起 send——收发两个方向互不阻塞 (SPI 由 netTask 串行,本就支持该并发)。 - **`deadline` 按无进展刷新**:这是与 recv 表 `deadline`(一次预算、不刷新)的**关键差异**, 理由见 §5.5。 --- ## 5. 挂起的请求会被什么结束 recv 与 send 两张挂起表**各自独立**,完成语义分别定义:recv 是"等一次到达即完成"(五路), send 是"分块推进直到全部写完"(四路 + 显式取消)。两表可同一 fd 并存,互不阻塞(§4.5)。 ### 5.1 挂起 recv 五路总表 挂起 recv 被结束的方式只有 5 种,下文简称**五路**——①②③ 由 `net_pending_tick()` 每轮对每个挂起 fd 按序判定,④⑤ 由显式操作触发。 | # | 条件 | 动作 | 设计要点 | |---|---|---|---| | 1 | **数据到达** | SPI 读出 + 通知(结果 = 字节数) | 由 `net_poll` 维护的 `readable` 门控,**零额外 SPI**;仅 `ESTABLISHED` / `UDP_OPEN` 态可读;spurious readable 需清除 | | 2 | **到点** `now >= deadline`(仅显式预算登记) | 通知 `NET_ERR_TIMEDOUT`(对外 -2 映射保持) | 状态无关;用有符号回绕安全差值,防 tick 回绕;这是 `net_recv_to` 调用方策略的正常兑现,不是伪造 | | 3 | **断连**(`CLOSED` 或 `session_dead`) | 排空:有数据则读出;否则按 `released_abnormal` 给 `CONNRESET` 或 0(EOF),并 `auto_relisten` + 通知 | CLOSE 先行完成挂起 = `net_close` 的 settle 钩子 | | 4 | **同 fd 后续操作**:close / drop_conn / 对账 resync | 先结束挂起 recv,再执行新操作 | per-fd 串行;同 fd 第二个 recv 被登记守卫拒绝(BUSY);send 无需结算(收发不冲突,SPI 由 netTask 串行) | | 5 | **网络重建**(`net_stack_bring_up`,芯片复位) | **取消 + 必须通知** | `net_init` 顶部 `net_pending_cancel_all(CONNRESET)`,先于芯片复位;**取消不通知 = 调用方永久挂死** | ### 5.2 挂起 recv 逐路判据与动作 **第 1 路 · 数据到达**(`net_pending_tick` 内): - 门控:`state == ESTABLISHED || state == UDP_OPEN` 且 `readable != 0`; - 命中后 `ch395f_get_recv_len` → 有数据则 `min(可用, len)` 读出、累加 `recv_bytes`、 清 `readable`、以字节数完成;`avail == 0`(虚假标志)只清 `readable` 继续等待。 - **零额外 SPI 的来源**:`readable` 由 `net_poll` 每轮刷新(INT# 电平触发链保证缓冲有数据时 每轮重读 SINT 重位置位),`net_pending_tick` 不发起探测事务。 **第 2 路 · 到点**: - 判据 `(p->infinite == 0) && ((int32_t)(now - p->deadline) >= 0)`;有符号差值抗 tick 回绕。 - 无限等待登记(`infinite == 1`)**无 deadline,永不因时间结束**——这是调用方策略。 **第 3 路 · 断连**: - 判据 `state == CLOSED || session_dead != 0`(`session_dead` 是"会话已结束"的**粘滞电平**, 定义见 `Drivers/BSP/NET/net_types.h`)。 ⚠ **判据不可收窄**:只判 `CLOSED` 时,抢先 relisten 后第 1 路(要求 `ESTABLISHED`)与 第 3 路(要求 `CLOSED`)两头落空,挂起只能干等第 2 路;无限等待登记没有第 2 路, **漏判 = 永久挂死**(留痕探针只留痕、不唤醒)。 - 动作:查芯片残留 → 有则按数据完成;无则 `auto_relisten` 时 `net_listener_request()` **登记**重建(动作在下一轮 `net_listener_tick`,≤1ms),随后按 `released_abnormal` 决定 `CONNRESET`(异常释放)或 `0`(正常 FIN)。 - ⚠ **执行侧安全不变量**:不得对已 `LISTENING` 的 fd 再 `open+listen`——否则 `USE` 错误会 走进关闭分支、关掉正听着的 Socket(U12)。这条门**由 `net_listener_tick` 单一所有者持有**, 各登记点不复述(漏一处即事故)。 - 该处**保留即时登记**(而非只交 reconciler):它只在 `p->active != 0`(有挂起 recv)时 到达,属"消费者在等这条连接结局"的**消费者驱动路径**;删掉会把 CLOSED 窗口拉长到 reconciler 周期 + 隔离期(~100ms),期间调用方高频 `net_accept` → `[WD] task stall`。 **第 4 路 · 同 fd 后续操作**: - `net_close_locked` / `net_drop_conn_locked` 的**首行**调 `net_pending_settle_close` (有数据先送达、无数据取消,**必通知**),再动芯片; - 对账形态⑤(芯片静默回收回 LISTEN)走 `net_pending_cancel(i, CONNRESET)`——连接已被芯片 收走,无排空窗口,直接取消。 **第 5 路 · 网络重建**: - `net_init` 顶部(控制块清零与芯片复位**之前**)`net_pending_cancel_all(NET_ERR_CONNRESET)`; - 遍历 8 个 fd 逐一通知,未在位者静默跳过。 ### 5.3 为什么 recv 判据顺序是 ①→②→③ 1. **数据优先于断连**:断连时芯片缓冲可能仍有残数据(bit3=1 契约),先判数据可让 最后一次读到完整内容,避免"断连即丢尾包"; 2. **数据优先于到点**:数据与到点同时成立时交付数据(更贴近调用方意图,且 `net_recv_to` 的调用方以"本片有数据"为准继续累计预算); 3. **到点优先于断连**:显式预算登记在超时时刻应兑现超时语义,即便此刻连接恰好已断—— 调用方拿到 `-2` 后按自己的预算循环决定是否续轮,下一轮会拿到断连结果。 ### 5.4 recv 状态推断的克制 `CONNECTING` / `LISTENING` 等中间态**保持等待**,**不做**状态推断式取消—— `CONNECT → ESTABLISHED` 是合法迁移,推断取消会误杀正在建链的等待。陈旧 resync (芯片静默回收)由显式 `net_pending_cancel` 处理,不靠推断。 ### 5.5 挂起 send 会被什么结束(四路 + 显式取消) send 与 recv 语义不同:recv 是"等一次到达即完成",send 是"**分块推进直到全部写完**"。 因此 `net_send_pending_tick()` 每轮对每个挂起 send 按序判定 **①断连 → ②推进 → ③写完 → ④无进展超时**, 下文简称**四路**;另有显式取消路径(close / drop_conn / resync / 重建)。 | # | 条件 | 动作 | 设计要点 | |---|---|---|---| | 1 | **断连**(`state != ESTABLISHED` 或 `session_dead`) | 以**已发字节数**结束 | 剩余字节不可能送达;判据同快速路径——离开 `ESTABLISHED` 后即便再回来也是**新会话**,旧字节绝不投到新连接;`session_dead` 为冗余保险 | | 2 | **推进**:`sent < len` 且 `send_ready != 0` | 写下一块(`min(剩余, NET_SEND_CHUNK_MAX)`),清 `send_ready`,刷新无进展计时 | **每轮至多一块**——写完即清 `send_ready`,下一块须等 `SENBUF_FREE` 中断经 `net_poll` 重新置位;与快速路径共用分块粒度(TRAP-NET-16) | | 3 | **全部写完**(`sent >= len`) | 以**总长度**结束 | 调用方见 `ret == len` 即成功 | | 4 | **无进展超时**(`now >= deadline`) | 以**已发字节数**结束(短写)+ `TIMEDOUT` | 停滞计时按**无进展**刷新(每次成功写块重置)——大文件传输总时长必然超过单块预算,按总时长判会误杀;这与 recv 侧"到点"是**有意的语义差异** | | — | **显式取消**:close / drop_conn / resync / 重建 | 以**已发字节数**结束,原因记 `net_set_errno` + `[ERR]` | 已写入芯片的字节**不回收**(对端可能已收到);未发部分放弃,由调用方按短写处理。**取消必通知**(同 recv 铁律) | **为什么 send 结果恒为"已发字节数"而非错误码**:`net_send` 的契约是"返回实际写入字节数", 短写是**一等公民**(`ret < len` 即短写,调用方重试剩余)。断连/超时/取消都不伪造"全部成功", 也不把结果改写为负错误码——原因经 `net_set_errno` 记录在 netTask 自己的槽位,调用方以 `ret != len` 判定短写(详见 `docs/网络/BSD_Socket_API_使用指南.md` §3.5)。 **为什么 send 用"无进展"而非"总时长"计时**:recv 的"到点"是调用方**显式预算**的兑现(一次等待 一个 deadline 就够);send 的等待是**多次块间等待的累积**,一个 100 KB 传输在健康链路下也要 经历上百次 `send_ready` 等待,总时长远超单块预算。若按总时长判,正常大传输会被误杀成超时—— 故 `deadline` 每次成功写块后重置为"当前 + `NET_SEND_TIMEOUT_MS`",只判"块间停滞"。 **末块边界:写完末块不再等发送缓冲**(`net_send_locked` 内层等待循环里置于超时/自旋判定 **之前**的 `sent >= len` 守卫)——"等 SENBUF_FREE"的意义是"还有下一块要写";写完末块后 剩余已是 0,此时任何"等缓冲区"型预算分支都不该再走。若继续走并落到登记,登记函数按 `sent >= len → INVAL` 拒收(**无剩余块可挂起**),一次已完整交付芯片的发送会被报成失败。 该守卫同时保证后续 `net_send_pending_register` 调用必伴随 `sent < len`,登记必获受理; 末块路径零额外延迟。**自旋预算与"末块"是两个正交维度,组合必须显式覆盖**——回归由 `test/host/ftp_regression.py`(RETR 1 KB 单块帧最敏感)覆盖。 --- ## 6. 模块接口契约 ### 6.1 接口总表 全部声明在 `Drivers/BSP/NET/net_pending.h`,实现见 `Drivers/BSP/NET/net_pending.c`。 recv 与 send 各一组(send 组前缀 `net_send_pending_`),共 16 个函数(2 个 static,对外 14 个)。 **公共 / recv 组**: | 函数 | 上下文 | 前置条件 | 返回 | 副作用 | 调用者 | |---|---|---|---|---|---| | `net_pending_capture_owner(void)` | 执行任务 | 创建消息队列前 | — | 捕获 `s_owner_task` | `net_init` 顶部 | | `net_pending_register(fd, buf, len, to_ms, caller)` | netTask | 无在位挂起;`caller` 非空且非执行者 | `NET_PENDING_REGISTERED` / `-1`(BUSY/BADF) | 占槽、写字段 | `net_recv_locked` 阻塞无数据路径 | | `net_pending_tick(p_socks)` | netTask | `p_socks = s_net_socks` | — | 完成命中的挂起(通知 + 清槽) | `net_process_messages` 开头 | | `net_pending_complete(fd, result)`(static) | netTask | — | — | 通知调用方、清槽、失败记 errno | 本模块内部所有完成/取消路径 | | `net_pending_clear(fd)` | **任意** | — | — | 静默清槽(不通知) | `alloc_socket` / `net_close_locked` 尾部 | | `net_pending_cancel(fd, err)` | netTask | — | — | 经 `complete` 通知 | `net_reconcile_tick` 形态⑤ | | `net_pending_cancel_all(err)` | netTask | 芯片复位之前 | — | 逐 fd 通知 | `net_init` 顶部 | | `net_pending_settle_close(fd, p_sock)` | netTask | `ch395f_close_socket` 之前 | — | 排空或取消 + 通知 | `net_close_locked` / `net_drop_conn_locked` 首行 | | `net_pending_active(fd)` | **任意**(纯 RAM 读) | — | `1`/`0` | 无 | `net_listener_tick` 挂起保险 | **send 组**(与 recv 组一一对称): | 函数 | 上下文 | 前置条件 | 返回 | 副作用 | 调用者 | |---|---|---|---|---|---| | `net_send_pending_register(fd, buf, len, sent, caller)` | netTask | 无在位挂起 send;`caller` 非空且非执行者;`0 ≤ sent < len` | `NET_PENDING_REGISTERED` / `-1`(BUSY/BADF/INVAL) | 占槽、写字段 | `net_send_locked` 超预算路径 | | `net_send_pending_tick(p_socks)` | netTask | `p_socks = s_net_socks` | — | 推进/完成命中的挂起 send(通知 + 清槽) | `net_process_messages` 开头 | | `net_send_pending_complete(fd, result)`(static) | netTask | — | — | 通知调用方、清槽 | 本模块内部所有 send 完成/取消路径 | | `net_send_pending_clear(fd)` | **任意** | — | — | 静默清槽(不通知) | `alloc_socket` / `net_close_locked` 尾部 | | `net_send_pending_cancel(fd, err)` | netTask | — | — | 经 `complete` 通知(结果 = 已发字节数) | `net_close_locked` / `net_drop_conn_locked` / `net_reconcile_tick` 形态⑤ | | `net_send_pending_cancel_all(err)` | netTask | 芯片复位之前 | — | 逐 fd 通知 | `net_init` 顶部 | | `net_send_pending_active(fd)` | **任意**(纯 RAM 读) | — | `1`/`0` | 无 | `net_listener_tick` 挂起 send 保险 | > `net_pending_complete` / `net_send_pending_complete` 是各自方向**唯一**的通知出口, > `clear` 是**唯一**静默清槽出口——任何新增路径必须二选一,不得自行写 > `s_net_pending[fd].active = 0` / `s_net_send[fd].active = 0`。 ### 6.2 关键契约细则 - **通知值即结果**:`xTaskNotify(caller, (uint32_t)result, eSetValueWithOverwrite)`, 32 位整体不切分;`result < 0` 时先 `net_set_errno(result)`(记入 netTask 任务槽位)。 - **失败码回传路径(两条)**:快速路径由 `net_process_messages` 回传该条消息**自身**的 错误码;挂起路径由 `net_pending_complete` 记 errno 并把负值作为通知值——两处取的都是 netTask 自己的 errno 槽位(按任务隔离),不受其他任务影响。 - **`NET_PENDING_REGISTERED` 哨兵**:`0x70000000`,取值不与字节数空间冲突;分派器据此 置 `do_notify = 0`,把回执推迟到真正的完成时刻,避免"登记成功"被误报为处理结果。 - **`net_recv_to` 与 `timeout_ms = 0`**:`net_recv_to` 拒收 `timeout_ms == 0`(`INVAL`), 因为 0 在 netTask 侧意味着"无限等待",与该接口"有界等待"的存在理由相反。 - **`-2` 映射**:`net_recv_to` 见到 `NET_ERR_TIMEDOUT` 返回 `-2`(**先于**通用 `< 0` 分支 判断,否则 `-9` 会被吞成 `-1`);超时只凭返回码判定,不依赖 errno(无跨任务竞态)。 - **send 短写契约**:见 §8 调用方契约(同一件事的两面:模块侧保证、调用方侧义务)。 - **`net_send_sock`(netTask 内部版)**:其阻塞用法会把 `caller` 置为 netTask 自身, 登记必被"拒绝执行者自等待"守卫拒(`BUSY`)→ 返回 `-1`。故该接口只适合"发送缓冲可用" 的场合;netTask 内需长时间等待的发送须自行分片(见 §7.2 集成点 13)。 ### 6.3 编译期开关 | 宏 | 位置 | 默认 | 作用 | |---|---|---|---| | `NET_PENDING_TRIPWIRE` | `Drivers/BSP/NET/net_pending.h` | `0`(产品构建编译剔除,零成本) | 开启挂起超时留痕探针(见 §12) | | `NET_RECV_TRIPWIRE_MS` | 同上(仅探针开启时定义) | `60000` | 留痕阈值 | | `NET_SEND_SPIN_MS` | `Drivers/BSP/NET/net_config.h` | `2` | **发送快速路径**的有界自旋预算(ms):单次"等发送缓冲腾出"超过本值即登记挂起 send,netTask 最坏停滞即本值。健康链路块间等待 med 219 µs / max 378 µs(≈5× 余量),故正常发送不会触发挂起 | | `NET_SEND_TIMEOUT_MS` | `Drivers/BSP/NET/net_config.h` | `1000` | 发送**无进展**超时(ms):快速路径的块间停滞上限;挂起路径的 `deadline` 亦按本值刷新。`0` = 不限 | --- ## 7. 实现落点(改/查本模型看哪里) 本节是"**改/查本模型时看哪里**"的落地清单:每个集成点若缺失或改动,后果都是确定的。 ### 7.1 集成点总表 **recv 组**(编号 1–12): | # | 集成点 | 文件 / 函数 | 调用 | 缺失后果 | |---|---|---|---|---| | 1 | 执行者句柄捕获 | `net_socket.c` `net_init` 顶部 | `net_pending_capture_owner()` | 自等待守卫失效(`s_owner_task` 恒 NULL)→ netTask 内阻塞 recv/send 登记成功 → **死锁** | | 2 | 重建前取消 | `net_socket.c` `net_init` 顶部(芯片复位前) | `net_pending_cancel_all(NET_ERR_CONNRESET)` | 重建前挂起的调用方**永久挂死** | | 3 | 每轮完成检查 | `net_socket.c` `net_process_messages` 开头 | `net_pending_tick(s_net_socks)` | 挂起**永不完成** | | 4 | 挂起登记与回执抑制 | `net_socket.c` `net_process_messages` 的 `NET_MSG_RECV` 分支 | `net_recv_locked` 返回哨兵 → `do_notify = 0` | "登记成功"被当结果回执(误报) | | 5 | 关闭结算 | `net_socket.c` `net_close_locked` 首行 | `net_pending_settle_close` | close 丢已到数据 / 调用方挂死 | | 6 | 拆会话结算 | `net_socket.c` `net_drop_conn_locked` 首行 | `net_pending_settle_close` | 同上 | | 7 | 静默回收取消 | `net_events.c` `net_reconcile_tick` 形态⑤ | `net_pending_cancel(i, NET_ERR_CONNRESET)` | 芯片静默回收后挂起 recv 无唤醒源 | | 8 | 重开 Socket 保险 | `net_listener.c` `net_listener_tick` | `net_pending_active(i)` 命中则 `continue` | 重开 Socket **摧毁在途连接** | | 9 | 新 fd 清槽 | `net_socket.c` `alloc_socket` | `net_pending_clear(sockfd)` | fd 复用继承旧挂起 → 新会话立即 EOF / 结果错位 | | 10 | close 幂等清槽 | `net_socket.c` `net_close_locked` 尾部 | `net_pending_clear(sockfd)` | 槽位残留(虽经 settle 已通知,仍须归零) | | 11 | 唤醒通道(两段式) | `net_socket.c` `net_wait_result` | `xTaskNotifyWait` 零等待 + 阻塞 | 通知丢失 → 调用方挂死 | | 12 | 双端注册 | `MDK-ARM/STM32F407-Demo.uvprojx` + `simulator/CMakeLists.txt` | `net_pending.c` 只入真机 | 编译缺失 / 模拟器误编(§7.4) | **send 组**(编号 13–20,与 recv 组一一对称): | # | 集成点 | 文件 / 函数 | 调用 | 缺失后果 | |---|---|---|---|---| | 13 | 发送有界自旋 | `net_socket.c` `net_send_locked` 两个等待循环 | 超 `NET_SEND_SPIN_MS` 即 `net_send_pending_register(...)` | netTask 停滞回到 1 s(队头阻塞);须在 1 s 超时判定**之后**再判自旋预算,保证 `SPIN > TIMEOUT` 误配时仍以超时收口 | | 14 | 每轮推进 | `net_socket.c` `net_process_messages` 开头(`net_pending_tick` 之后) | `net_send_pending_tick(s_net_socks)` | 挂起 send **永不推进 / 永不完成** | | 15 | send 登记与回执抑制 | `net_socket.c` `net_process_messages` 的 `NET_MSG_SEND` 分支 | `net_send_locked` 返回哨兵 → `do_notify = 0` | "登记成功"被当结果回执(误报) | | 16 | 关闭 / 拆会话取消 | `net_socket.c` `net_close_locked` / `net_drop_conn_locked` | `net_send_pending_cancel(fd, NET_ERR_CONNRESET)` | 挂起 send 的调用方**永久挂死**(已发字节数收不回来) | | 17 | 静默回收取消 | `net_events.c` `net_reconcile_tick` 形态⑤ | `net_send_pending_cancel(i, NET_ERR_CONNRESET)` | 芯片静默回收后挂起 send 无唤醒源 | | 18 | 重建前取消 | `net_socket.c` `net_init` 顶部(芯片复位前) | `net_send_pending_cancel_all(NET_ERR_CONNRESET)` | 重建前挂起的调用方**永久挂死** | | 19 | 新 fd / close 清槽 | `net_socket.c` `alloc_socket` / `net_close_locked` 尾部 | `net_send_pending_clear(sockfd)` | fd 复用继承旧挂起 send → 新会话误写旧缓冲 | | 20 | send 重开保险 | `net_listener.c` `net_listener_tick` | `net_send_pending_active(i)` 命中则 `continue` | 重开 Socket **摧毁在途传输**(半写的数据流被截断) | ### 7.2 顺序敏感 / 易错集成点 **1–2. `net_init` 顶部顺序**(`Drivers/BSP/NET/net_socket.c`): ```text net_pending_cancel_all(NET_ERR_CONNRESET) ← 必须最早(芯片复位前) memset(s_net_socks, 0, ...) ← 控制块清零 net_pending_capture_owner() ← 捕获执行者 ... ch395f_check_exist / ch395f_reset ... ← 芯片复位 ``` 顺序不可交换:取消必须先于清零与复位,否则挂起调用方既收不到通知、其 `caller` 也可能随控制块一起失效。 **3–4 / 14–15. `net_process_messages` 开头与分派**:四个 tick 按 §2.3 的固定顺序先跑, 再排空消息队列。`NET_MSG_RECV` 分支:`msg.result = net_recv_locked(...)`;`NET_MSG_SEND` 分支: `msg.result = net_send_locked(..., msg.caller)`;两者若等于 `NET_PENDING_REGISTERED` 则 `do_notify = 0`(**不发即时回执**)。send 分支还须把 `msg.caller`(调用方任务句柄) 透传给 `net_send_locked`——挂起登记的目标。 **5–6 / 8 / 20. 结算与保险**:`net_close_locked` / `net_drop_conn_locked` 的**第一行**就是 `net_pending_settle_close(...)`——关闭既不得让等待中的调用方永久悬挂,也不得丢掉已到达的数据 (芯片缓冲在 close 前可读)。`net_listener_tick` 对每个待重试 fd 先做状态门(仅 `LISTENING`/`CLOSED` 才重试),再 `net_pending_active(i) || net_send_pending_active(i)` 命中即 `continue`——重开 Socket 会摧毁在途连接与在途传输(半写的数据流被截断)。 **11. 两段式等待**(`net_wait_result`,所有 `net_*` API 共用): - 第一段零等待拾取,消费"入队 → 等待入口"间隙内已落地的通知; - 判定必须用返回值 `pdTRUE`/`pdFALSE`,**不能判值 ≠ 0**(0 = EOF 是合法通知); - 清除掩码必须全 1(传 0 = 不清除,会残留上次结果被误读为本次结果); - **禁止在本路径加逐调用日志**——该路径每条 API 调用都过一遍,会淹没排查用的证据日志。 **13. 发送有界自旋**(`net_send_locked`):两个等待循环都在 `net_poll()` 之后判预算, **判定顺序不可交换**: - **外层(写块前,等发送缓冲腾出)**:断连 → `CONNRESET` 收口 → `NET_SEND_TIMEOUT_MS` (1 s 停滞)→ `TIMEDOUT` 收口 → `NET_SEND_SPIN_MS` 超预算 → `net_send_pending_register(...)`。 此处位于写块之前,循环条件保证 `sent < len`,登记必获受理。 - **内层(写块后,等 `SENBUF_FREE`)**:**先判 `sent >= len`**——末块已全部交付芯片, 直接跳出返回(§5.5 末块边界),再判超时、再判自旋预算。 超时判定放在自旋判定**之前**,是为了保证 `NET_SEND_SPIN_MS > NET_SEND_TIMEOUT_MS` 的误配下仍以超时收口, 不会"永远登记不出去"。健康路径块间等待 med 219 µs ≪ 2 ms,**永不触发**挂起—— 快速路径行为与非挂起实现一致。 **14–19. send 组生命周期**:与 recv 组逐点对称(§7.1 表)——四处取消点(close / drop_conn / 对账形态⑤ / `net_init` 顶部)**必通知**,两处清槽点(`alloc_socket` / close 尾部)静默归零。 ### 7.3 改动检查清单 **新增一种"结束挂起 recv"的条件**: - [ ] 在 `net_pending_tick` 的 ①②③ 判定链中插入,或走显式 `net_pending_cancel*`; - [ ] 所有出口**必须**经 `net_pending_complete`(不得直接清 `active`); - [ ] 若新条件是"取消",**必须通知**(取消不通知 = 永久挂死); - [ ] 复核第 1 路的 `readable` 门控与第 3 路的判据未被新条件破坏(两头落空即挂死); - [ ] 更新本文 §5 与 `docs/网络/BSD_Socket_API_使用指南.md` §3.5。 **新增一个使用阻塞 recv 的消费者**: - [ ] 确认该任务**不在** IWDG `required_mask` 内;若在,**禁止无限阻塞**,必须 `net_select` 超时或 `net_recv_to` 显式预算(契约见 §8); - [ ] `buf` 保持有效至调用返回; - [ ] 保留"总预算 − 已耗时"循环骨架(`n == -2` 续轮)。 **新增一种"结束挂起 send"的条件**: - [ ] 在 `net_send_pending_tick` 的 ①②③④ 判定链中插入,或走显式 `net_send_pending_cancel*`; - [ ] 所有出口**必须**经 `net_send_pending_complete`(不得直接清 `active`); - [ ] 若新条件是"取消",**必须通知**(取消不通知 = 永久挂死); - [ ] 结果恒为**已发字节数**(短写语义),**不得**改写为负错误码——原因走 `net_set_errno`; - [ ] 复核"无进展计时刷新"与**末块边界**(§5.5)未被新条件破坏; - [ ] 更新本文 §5.5 与 `docs/网络/BSD_Socket_API_使用指南.md` §3.5。 **新增一个 socket fd 生命周期路径**:任何 `memset(p_sock)` / 释放 fd 的路径都须 配套 `net_pending_clear(fd)`(新 fd 不得继承)或 `net_pending_settle_close`(关闭前结算), **两个方向都要**(recv 与 send 各自一张表)——漏掉 send 侧会让新 fd 误写旧缓冲。 ### 7.4 双端注册与模拟器边界 - **真机**:`Drivers/BSP/NET/net_pending.c` 必须注册进 `MDK-ARM/STM32F407-Demo.uvprojx` 的 NET 分组(`net_listener.c` 旁)。 - **模拟器**:`simulator/CMakeLists.txt` **不编译** `net_pending.c` 与 `net_listener.c` ——模拟器走 `simulator/sim/net_socket_sim.c`(单线程 Winsock,无法复现挂起模型内部机制; 外部语义已由包装层按 blocking 属性/预算模拟)。故挂起表 / tick / 通知路径在模拟器上 **零覆盖**,只能硬件验证。 --- ## 8. 调用方契约 调用方**不改协议、不改 API**,但需知悉五条: | 契约 | 说明 | |---|---| | `buf` 生命周期 | 挂起期间 `buf` 指针须**保持有效**至调用返回(挂起表按指针写入,recv / send 同) | | **send 短写** | `net_send` 返回值恒为已写入字节数:`= len` 全部写入,`< len` 为短写,调用方**必须**检查并重试剩余(`ui_net_sink` / `iec104_serv` 按 `ret != total` 判短写;`lftpd_inet_write` 循环 `while (n > 0)`、`n <= 0` 视为致命)。挂起 send 以已发字节数结束,不会伪造全部成功 | | 剩余预算循环 | 调用方保留"总预算 − 已耗时"循环骨架(`n == -2` 续轮,预算耗尽退出)——这是**公开契约要求**(30s 总超时语义 + 跨 TCP 分段累积),与本架构无关 | | IWDG 内禁无限阻塞 | IWDG `required_mask` 内任务**禁止无限阻塞 recv**——必须 `net_select` 超时或 `net_recv_to` 显式预算("超时归使用者"对受监控任务是义务不是选项,否则"对端连上不发数据" = IWDG 复位) | | 超时精度 | 完成判定随主循环节拍(~1ms);主循环被长操作占用时,超时**最多顺延**相应时长(已知代价,见 §10) | --- ## 9. 性能与资源特征 | 指标 | 值 | 来源 / 条件 | |---|---|---| | 队列响应性(挂起连接下) | **92.5 ms** | `test/host/e02_probe.py`:挂起对时客户端 + FTP banner 探测 | | M3 静默档帧周期 | med 58.9 / p95 69.9 / max 72.1 ms | `docs/测试说明/专项验证/链路延迟测试说明.md` §6.3(零尖峰) | | M3 压力档帧周期 | med 60.1 / p95 66.4 / max 69.3 ms | 同上(零尖峰) | | netTask 栈峰值 | **1116 B / 3840 B(余 70%)**,FTP 传输期峰 **1164 B**;静态最坏链 ≈1.9 KB | `docs/内存管理/RAM使用分析与优化方案.md` §2.3 | | 挂起表静态 RAM | recv `s_net_pending[]` **192 B** + send `s_net_send[]` **224 B**(RW_IRAM1) | §4.1 / §4.5 | **为什么这些数字成立**: - 压力档下 MCU 侧 `prod_cycle_mcu` 纹丝不动(min/max 相差 <1%)——队头阻塞工况不再向 UI 帧周期传导;`net_pending_tick` 在无挂起时是纯指针读(零 SPI),队列往返段不受影响。 - **send 侧**:发送停滞期 netTask 不再被冻结——netTask 最坏停滞由 `NET_SEND_TIMEOUT_MS` (1 s)压到 `NET_SEND_SPIN_MS`(2 ms),且停滞不再顺延 `net_pending_tick` 的挂起 recv 唤醒。健康链路块间等待 med 219 µs / max 378 µs(≈5× 余量),故**正常发送完全不触发挂起**, 快路径行为不变。 --- ## 10. 设计边界与已知代价 | 项 | 现状 | 说明 | |---|---|---| | **SEND 路径**(执行者占用问题)✅ **已纳入本模型** | `net_send_locked` 快路径有界自旋 ≤ `NET_SEND_SPIN_MS`(2 ms);超预算登记挂起 send,由 `net_send_pending_tick` 按主循环节拍分块推进(`NET_SEND_CHUNK_MAX` = 1 KB/块),调用方在自己上下文睡 | 停滞期 netTask **不再冻结**:挂起 send 每轮至多写一块,`net_pending_tick` 与消息队列照常推进。健康链路块间等待 med 219 µs ≪ 2 ms,快路径行为不变。`NET_SEND_TIMEOUT_MS` 是**无进展**超时(每次成功写块刷新,见 §5.5) | | **ACCEPT 路径决定不纳入**(调用方轮询问题;2026-10-15 决策) | `net_accept_locked` 单次 `net_poll` 后**立即**返回 `ESTABLISHED` 或 `WOULDBLOCK`——**不占用 netTask**(0 停滞) | 代价不在执行者,而在**调用方轮询**:没有"阻塞到有连接"的入口,各服务(`ui_net_sink` / `iec104_serv` 100 ms / `time_sync` / `lftpd`)各自手写轮询循环,每轮一次完整 RPC 往返(入队 + 通知 + 唤醒),并带来一个轮询周期级的 accept 延迟抖动。pending 化的收益是**消除轮询 churn 与抖动**,不是解队头阻塞 | | **超时精度** | 随主循环节拍(~1ms),被长操作占用时顺延 | 文档化、不修;发送停滞不再顺延整层(send 已在模型内),顺延面只剩单次长操作本身 | | **挂起超时留痕探针** | 编译期选项 `NET_PENDING_TRIPWIRE` 默认关(排查挂死时开);未接自检 selftest | 可选改进,随窗口(无排期) | | **模拟器不可建模** | `net_socket_sim.c` 单线程 Winsock 无法复现**挂起模型内部机制**(外部语义已由包装层按 blocking 属性/预算模拟) | 挂起表 / tick / 通知路径在模拟器上零覆盖,只能硬件验证(§7.4) | | **单 fd 单挂起** | 每 fd 至多一个挂起 recv,第二个被 `BUSY` 拒绝 | 由登记守卫保证;同 fd 并发 recv 本就无意义(per-fd 串行语义) | > **SEND 与 ACCEPT 的 pending 化收益性质不同,勿混为一谈**。判据只有一个: > **执行者被占多久**——SEND 挂起化前 ≤1 s/次(占用),ACCEPT 0(不占用)。 > - SEND 是**执行者占用**问题,与 RECV 同源:pending 化直接解队头阻塞,收益真实,**已落地** > (§5.5、§7.1 send 组); > - ACCEPT 是**调用方轮询**问题:执行者本就零占用,"accept 慢"来自调用方轮询周期, > 不是 netTask 卡住——pending 化换的是 RPC 开销与延迟抖动,**不解决任何队头阻塞**。 > 故 ACCEPT 优先级低于 SEND,**决定不挂起化(2026-10-15)**——保留调用方轮询。 --- ## 11. 设计铁律(为什么是这样) 1. **执行者永不在等待中自旋**——等待必须在调用方上下文完成,netTask 只负责"登记"与"结束"。 send 侧的"快路径有界自旋"是该铁律的**受控例外**:自旋被 `NET_SEND_SPIN_MS` 硬限(2 ms), 超限即转为挂起登记,故执行者占用有界。 2. **取消必通知**——任何取消路径(close / 重建 / 陈旧重同步)都必须唤醒调用方, 否则是永久挂死而非错误返回(recv / send 同)。 3. **挂起状态与 socket 表解耦**——`net_sock_t` 会被整块 `memset`,跨时间状态不能寄居其中。 4. **不做状态推断式取消**——中间态(`CONNECTING`/`LISTENING`)合法迁移存在, 推断取消会误杀正常建链等待。 5. **数据到达不引入额外 SPI**——判据复用主循环既有 `net_poll` 维护的 `readable` 标志。 6. **唯一完成出口**——所有完成/取消都经 `net_pending_complete`(recv)/ `net_send_pending_complete` (send);任何路径不得自行清 `active`。 7. **send 结果恒为已发字节数**——短写是一等公民;断连 / 超时 / 取消**不伪造**全部成功、 **不把结果改写为负错误码**,原因只经 `net_set_errno` 留痕。send 停滞计时按**无进展** 刷新(每次成功写块重置),绝不按总时长判(会误杀大传输)。 8. **写完末块即结束,不再等发送缓冲**——"等缓冲区"只为下一块服务;`sent == len` 时任何 预算分支都不得再走(§5.5 末块边界)。 --- ## 12. 排查辅助:挂起超时留痕探针(编译期选项,默认关) 五路判定 + 显式 cancel 是挂起 recv 的**全部唤醒责任**(§5)。一个挂起请求若超过阈值 (60s,`NET_RECV_TRIPWIRE_MS`)还没被任何一路结束,只有一种解释:**完成路径漏了它** (代码缺陷)——而不是"数据还没到":无限等待本来就是调用方的合法策略,不该被强行打断。 探针的处理:`net_pending_tick` 对这样的 fd **只打一条 `[ERR]` 日志**(每 fd 一次, 防刷屏),然后让它继续等——**绝不强制结束、绝不伪造 TIMEDOUT 返回值**。为什么不用 "强制超时"兜底:强制结束会把"唤醒路径有 bug"这个**缺陷**伪装成一次**普通超时**—— 调用方拿到假结果,缺陷永远潜伏。 开关:`NET_PENDING_TRIPWIRE`(`Drivers/BSP/NET/net_pending.h`,默认 0 = 编译剔除、产品 零成本)——排查"调用方挂死唤不醒"时置 1 重烧。**开与关语义完全一致**:探针只留痕, 不参与完成。代码里该机制名为 tripwire(绊线:挂住 = 踩到缺陷 wiring)。 判据基准:无限登记按 `registered` 计,显式登记按 `deadline` 计。 --- ## 13. 源码索引 | 文件 | 关键符号 | 职责 | |---|---|---| | `Drivers/BSP/NET/net_pending.h` | `NET_PENDING_REGISTERED` / `NET_PENDING_TRIPWIRE` / `NET_RECV_TRIPWIRE_MS` / `NET_SEND_CHUNK_MAX` | 哨兵、编译期开关、分块上限(唯一定义处);16 个函数声明(§6.1) | | `Drivers/BSP/NET/net_config.h` | `NET_SEND_SPIN_MS` / `NET_SEND_TIMEOUT_MS` | 发送自旋预算与无进展超时 | | `Drivers/BSP/NET/net_pending.c` | recv 组 / send 组全部实现(两张挂起表 + 完成出口 + 探针) | 本模型主体(§4、§5、§6) | | `Drivers/BSP/NET/net_socket.c` | `net_recv_locked` / `net_send_locked` | 快速路径 + 挂起登记入口(含末块守卫与有界自旋) | | `Drivers/BSP/NET/net_socket.c` | `net_process_messages` / `net_wait_result` | 四 tick 节拍与消息分派(含 `NET_MSG_SEND` 透传 `caller`)、哨兵抑制、两段式唤醒 | | `Drivers/BSP/NET/net_socket.c` | `net_init` / `net_close_locked` / `net_drop_conn_locked` / `alloc_socket` | 捕获执行者、结算、清槽、取消(recv + send 双侧) | | `Drivers/BSP/NET/net_events.c` | `net_reconcile_tick` | 形态⑤(芯片静默回收)取消两侧挂起 | | `Drivers/BSP/NET/net_listener.c` | `net_listener_tick` | 消费 `net_pending_active` / `net_send_pending_active` 作挂起保险 | | `App/task/net_task.c` | `net_task_func` | 主循环节拍(`net_poll` → `net_process_messages`) |