# 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_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`) |