Files
STM32F4-Base/docs/网络/Net层阻塞模型架构设计.md
T
wandering1 0f7b5a87fb chore(编号): 存量编号全量转语义短语,门禁识别面与语料同批重打
登记簿、文档与代码注释里的存量字母加数字编号(N-x / S-x / T-x / E-x / A-x / 卡号)
一律改写为语义短语,净删 2,028 处引用;删除基线与待确认、并行分道方案、排期详述、
采集保护收尾 4 篇(内容已并入 docs/任务规划/README 与登记簿)。

门禁机器与三张账同批重打:check_ids.py 新增变更记录行豁免与第二根护栏
undecidable_total_ceiling;check_test_traceability.py 只加 stdout/stderr UTF-8
reconfigure(TC_RE 未动);check_cubemx_survival.py 的 --write 固定 newline=LF;
id_registry / id_aliases(+19 条 retired-to-phrase)/ id_known_backlog 重打。

test/ 56 个文件同步 TC 标签语义化,其中 18 个 TU 改了 CHECK / printf 里的标签文字,
是本批唯一改变构建产物的文件集合。

为什么合成一笔、不拆四笔(实测撞闸,非偏好):pre-commit 跑钩子前会 stash 未 staged
部分,拆开后中间提交必然是「新规则 + 旧语料」——实跑可判定 85.15% < 基线 86.00%、
不可判定 1,453 > 上限 1,166,编号门禁直接拦下。编号识别面与它描述的语料只能同码移动。

实跑基线(工作区全量):754 文件 / 8,230 处编号字面量 / 可判定 7,079 = 86.01%
(基线 86.0%,容差 0.1pp)/ 不可判定绝对数 1,151(上限 1,166)/ 校验 1-6 错误级 0 /
告警 = 悬空 2(App/algo/freq.c 的 S2,属 §4.4 同号不同义)+ 孤儿 50。

基线从 84.0 抬到 86.0 的理由见 docs/门禁说明/id-check.md §7.6:本批真删了 2,028 处
引用,可判定面随真实内容上升,不是把分母做小。同批新增排除 docs/硬件平台/ 语料,使
识别面少 264 处——芯片手册位号与网表料号不属编号空间(registry 有 excluded_paths_note)。

⚠ 余量只剩 0.11pp(约 10 处)与绝对数 15 处:把「可判定的引用」改成散文会立刻撞第二
根护栏。另遗留一项:id_aliases 里 retired 的 N / S / T / A 别名没有 scope(U1~U5 有),
今后有人新写一个 A-4,校验 3 不会响,只能靠人,建议下批补 scope。

固件不变证据:115 个 .c/.h 剥注释并归一字符串后与 HEAD 逐字节全等,残留代码差异 0;
App / Src / Drivers 的字符串字面量零改动。@build 0 错误 0 警告;40/40 主机套件、2,889
断言全通过;覆盖率 line 89.2% / func 92.6%;pre-commit 48 钩子 --all-files 全绿;
check_doc_links --all 通过(176 文档,章节引用无法判定 0);逐字节卫生核过:14 个带
BOM 文件与 HEAD 一致、0 个 CR、0 个 U+FFFD,字符串字面量纯 ASCII。

.cubemx_survival_baseline.json 的 watchdog-module 哈希 56bf27 变 2bb051 属合法:
Src/freertos.c 的 USER CODE 区内只有注释文字随编号改写,代码未变。

同批带上体积同码凭证与 RAM 台账行(@build 之后盖戳):RW_IRAM1 79,008 B / 60.3%,
RW_CCM 57,376 B / 87.9%,阈值 92%。
2026-10-26 12:28:14 +08:00

706 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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["消息入队,随后在<br/>自己上下文睡(两段式等待)"]
B -.-> C
subgraph NT["netTask —— 唯一执行者(~1ms 节拍)"]
C{"数据已在?<br/>readable / recv_len > 0"}
C -- "是(常见)" --> D["直接读出"]
C -- "否 + 阻塞模式" --> E["登记挂起请求(μs 级)<br/>守卫:每 fd 至多一个、拒绝 netTask 自等待"]
E --> F["随即返回,去处理下一条消息<br/>—— 等待不占用执行者"]
F --> G["每轮检查每个挂起 fd"]
G -- "未命中 → 等下一轮" --> G
G -- "数据到达" --> H1["SPI 读出"]
G -- "到点(仅显式预算)" --> H2["结果 = TIMEDOUT"]
G -- "断连(CLOSED / session_dead)" --> H3["排空:有残留 → 读出<br/>无残留 → EOF(0) / 异常 CONNRESET"]
H1 --> I
H2 --> I
H3 --> I
end
D --> I
I["通知调用方(同一回传通道):通知值 = 结果原值<br/>挂起路径经公共出口 net_pending_complete:清槽、防重复通知"]
I --> J["调用方醒来取结果<br/>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 每轮判定未命中<br/>(含中间态 CONNECTING/LISTENING 保持等待)
WAITING --> IDLE: net_pending_complete<br/>(数据 / 到点 / 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`) |