登记簿、文档与代码注释里的存量字母加数字编号(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%。
52 KiB
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
- 2. 架构总览
- 3. 一次阻塞 recv 的全过程
- 4. 数据结构
- 5. 挂起的请求会被什么结束
- 6. 模块接口契约
- 7. 实现落点(改/查本模型看哪里)
- 8. 调用方契约
- 9. 性能与资源特征
- 10. 设计边界与已知代价
- 11. 设计铁律(为什么是这样)
- 12. 排查辅助:挂起超时留痕探针(编译期选项,默认关)
- 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. 架构总览
核心思想:把"等数据"从执行者内的自旋,改为执行者外的登记。四个支点:
- 登记而非占用——netTask 只花微秒级把请求记进表里,随即去处理下一条消息;
- 等待落在调用方自己的上下文——睡在任务通知上,与 LwIP 的消费者线程模型同构;
- 完成判定复用主循环既有节拍——判据是 RAM 里的标志与到期时间,不引入额外 SPI 事务;
- 唯一完成出口——所有完成/取消都汇聚到一个函数(通知 + 清槽),结构上排除"漏通知"和 "完成两次"。
上图四层结构:
| 区域 | 内容 |
|---|---|
| 左栏 · 调用方任务 | 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)每轮:
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) 阻塞路径为例:
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
- 入队(调用方任务):
net_recv把请求打包进netMsgQueue,随后进入两段式等待 (步骤 5)——netTask 不被占用。 - 快速路径(netTask,
net_recv_locked):入口先net_poll()刷新状态机与可读标志,再查ch395f_get_recv_len;数据已在(recv_len > 0)→ 直接读出、经通知值回传字节数,不产生 挂起。常见路径到此为止。 - 登记(netTask):无数据且阻塞模式 →
net_pending_register过两条守卫(§4.3)→ 记buf/len/caller与预算(net_recv_to→deadline;无限等待 →infinite=1无 deadline)→ 返回哨兵NET_PENDING_REGISTERED(分派据此不发即时回执,§6.2), netTask 立即处理下一条消息。 - 每轮判定 → 公共出口(netTask):
net_pending_tick对每个挂起 fd 按序 ①数据 → ②到点 → ③断连(判据见 §5.2);命中即进net_pending_complete——所有完成/取消路径的唯一出口:xTaskNotify覆盖式写入通知值 = 结果原值(>0 字节数 / 0 EOF / 负错误码)→ 清active; 无在位挂起则静默返回(防重复通知)。 - 唤醒与取结果(调用方任务):所有
net_*API 的回执共用同一条任务通知通道,两段式 等待防"入队→等待入口"间隙内通知丢失(细则见 §7.2 集成点 11)。 - 取消路径(第 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_task4 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 挂起槽状态机
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 判据顺序是 ①→②→③
- 数据优先于断连:断连时芯片缓冲可能仍有残数据(bit3=1 契约),先判数据可让 最后一次读到完整内容,避免"断连即丢尾包";
- 数据优先于到点:数据与到点同时成立时交付数据(更贴近调用方意图,且
net_recv_to的调用方以"本片有数据"为准继续累计预算); - 到点优先于断连:显式预算登记在超时时刻应兑现超时语义,即便此刻连接恰好已断——
调用方拿到
-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):
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. 设计铁律(为什么是这样)
- 执行者永不在等待中自旋——等待必须在调用方上下文完成,netTask 只负责"登记"与"结束"。
send 侧的"快路径有界自旋"是该铁律的受控例外:自旋被
NET_SEND_SPIN_MS硬限(2 ms), 超限即转为挂起登记,故执行者占用有界。 - 取消必通知——任何取消路径(close / 重建 / 陈旧重同步)都必须唤醒调用方, 否则是永久挂死而非错误返回(recv / send 同)。
- 挂起状态与 socket 表解耦——
net_sock_t会被整块memset,跨时间状态不能寄居其中。 - 不做状态推断式取消——中间态(
CONNECTING/LISTENING)合法迁移存在, 推断取消会误杀正常建链等待。 - 数据到达不引入额外 SPI——判据复用主循环既有
net_poll维护的readable标志。 - 唯一完成出口——所有完成/取消都经
net_pending_complete(recv)/net_send_pending_complete(send);任何路径不得自行清active。 - send 结果恒为已发字节数——短写是一等公民;断连 / 超时 / 取消不伪造全部成功、
不把结果改写为负错误码,原因只经
net_set_errno留痕。send 停滞计时按无进展 刷新(每次成功写块重置),绝不按总时长判(会误杀大传输)。 - 写完末块即结束,不再等发送缓冲——"等缓冲区"只为下一块服务;
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) |