Files
STM32F4-Base/docs/网络/Net层阻塞模型架构设计.md
wandering1 f754d59218 docs(测试说明): 按用途分六个子目录并同步全库引用
分层:流程与规范/ 单元测试/ 板级测试/ 专项验证/ 测试工具/ 测试数据/,
顶层只留新增的目录导航 README.md(归层判据 + 硬约束 + 本次处置记录)。

- 搬迁 28 个路径:测试数据三个目录 58 个文件为纯改名(R100,内容逐字节不变)
- 全库引用同步 124 个文件:AGENTS / docs 索引 / skills / 门禁注释 / scripts
- 门禁面:scripts/check_test_traceability.py 的 glob 改 rglob
  (分层后不递归扫描=追溯门禁直接瞎掉)
- scripts/rename_ref.py:收尾的 ℹ 提示在 GBK 代码页抛 UnicodeEncodeError,
  让进程以退出码 1 收场而改写其实已落盘(RC 与真实结果相反);改为把
  stdout/stderr reconfigure 成 UTF-8,与 check_doc_links.py 等同法
- docs/文档规范.md §1.1 补「子目录文件过多时再分一层」;§3.6 补 rename_ref
  搬整目录的两个实测坑:① 会改写目录内部文件的内容 ② 内部文件的 ../ 出链
  不补层级(本次 23 个被误改的证据文件已按 HEAD 原始字节逐字节还原)
- docs/README.md §6 改为与六个子目录一一对应,并补登原先漏登记的
  「启动日志原子性复验流程」一行
- .cubemx_survival_baseline.json:App/util/task_watchdog.c 的注释路径也随分层更新,
  该文件是存活审计的 watchdog-module 漂移锚点,按脚本自己的提示跑 --write 重建基线
  (实测重建后仅 watchdog-module 一条哈希变化)
- 铁律 4 例外(本次在任务树里改了单写者文件):AGENTS.md 与
  .pre-commit-config.yaml 只被 rename_ref 改写路径字面量(后者全在注释行),
  任务表块与钩子声明一字未动

验证:check_doc_links.py --sections-strict 覆盖全部 88 个改动 .md 全绿;
check_test_traceability.py 输出与主树逐行一致;196 个文本文件无 BOM、无新增
CR、无 U+FFFD、无行尾空白;陈旧引用面由主树 25 处降到 6 处(无新增断链)。
2026-10-30 07:16:07 +08:00

52 KiB
Raw Permalink Blame History

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

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 请求架构

上图四层结构:

区域 内容
左栏 · 调用方任务 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
  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 挂起槽状态机

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):

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)