docs(网络): 自愈架构 §4.1 形态详解 / §4.10 读即清审计 / §1.5 横向对照

四块内容,都是"把已经做完的事写进文档",无新增设计。

1. §4.1 形态表扩成 6 小节:两个所有者 / 四个词 / 形态表(补"芯片命令"列与
   U11 独立行)/ 每个形态为什么长这样、怎么验的 / 为什么 ①③⑤ 常态 0 命中 /
   M3 可疑门与沿革。
   ⚠ 修一处文档 bug:原 ④ 行实际描述的是 U11,真正的 ④ 没有行。已补 U11
   独立行,并在 ⚠ 注记里记录这次更正及其 U12 后果。

2. §4.10 读即清信源审计:两个读即清寄存器与两级门控、现存读清点全量清单
   (产品代码 6 处)、已根除的三条互吞实例、一处"看着像缺陷其实是配对
   消费"、新增一处读清的三条准入判据 + 结构性残余。AGENTS.md 的 CH395F
   单连接 socket 条目加指针。

3. §1.5 与主流 offload 驱动的代码级对照(正面回答"是不是过度设计"):
   ioLibrary close()/listen() 原文、四个机制一一映射、三条约束差异表、
   "死等把代价挪到运行时"、lwIP/Zephyr 对照、诚实清单(可省 16 B / 不可省
   沉降窗 + D10 + listen 前排清 + 登记执行分离)、唯一杠杆是芯片。

4. 模块归属修正(承接 63cf73b 的 net_events.c 抽出):
   - §11.5 新增一节,含"为什么是一个模块而不是两个"(对账真实调用事件
     处理器 2 处 + NET_RECON_STABLE_DEATH 两边共用)与三处非纯搬移;
   - §9 行数快照更新,并加警告:这类快照**当天就会漂移**,是地图不是判据;
   - §1.5 的 net_listener.c 行数随注释精简同步(508/277 → 386/155)。
   引用修正波及 5 个文件:BSD_Socket_API 使用指南文件职责表(拆出
   net_events.c 行)、函数调用关系 §4.8 标题、Net 层阻塞模型两处形态⑤表格、
   CH395F_Trap_Records 两处"对应文件"(保留"原在 net_socket.c"的历史)。
   ⚠ doc-links 只校验 § 编号存在性,照不出"函数在哪个文件"——这类归属
   漂移门禁不拦,必须人工扫。

验证:check_doc_links --all --sections-strict PASS(2146 处引用,可判定
1263,无法判定 0,142 文档);ci_local L0 32 钩子 + L0.5 953/953 全过。
This commit is contained in:
2026-10-14 18:18:06 +08:00
parent dd51221ebc
commit 2a6080827d
6 changed files with 286 additions and 25 deletions
+1 -1
View File
@@ -139,7 +139,7 @@
## 模块约定
- **CH395F 单连接 socket**:监听与数据通道复用同一 Socket(CONNECT 后转数据通道,DISCONNECT/TIMEOUT 后重开 → LISTEN);所有 CH395F 中断在 `net_poll()` 内经 `GET_GLOB_INT_STATUS` 集中读取并清除,**不得**在 ISR 或其他地方单独处理
- **CH395F 单连接 socket**:监听与数据通道复用同一 Socket(CONNECT 后转数据通道,DISCONNECT/TIMEOUT 后重开 → LISTEN);所有 CH395F 中断在 `net_poll()` 内经 `GET_GLOB_INT_STATUS` 集中读取并清除,**不得**在 ISR 或其他地方单独处理。⚠ 全局寄存器**整体读即清、芯片无按位清除手段**,而 net_poll 只在全局位为 1 时才读该 Socket 的 SINT → "顺手全量清一遍"等于把别的在用 Socket 的门焊死(B3 实证)。**现存读清点全量审计 + 新增一处读清的三条准入判据 → `docs/网络/网络自愈架构设计.md` §4.10**
- 所有 `net_*` socket 操作经 `netMsgQueue` 在 netTask 串行执行;非 netTask 任务直接调 `net_*` API,勿直接碰 CH395F 驱动
- **`net_*` 公开 API 的错误码经"任务通知值"回传**:`net_msg_t` 是**按值拷入队列**的,netTask 改的是自己那份副本,调用方永远看不到 → 错误码搭 `xTaskNotify()` 通知值回来。**勿给 `net_msg_t` 加"回传用"字段**(调用方只会读到栈垃圾)
- **单连接 socket 的断开语义**:EOF 时 net 层已 `auto_relisten`,应用层**不得**在 EOF 时 `net_close` 该 fd(会连监听一起毁掉 → 端口"连接被拒");仅当数据 fd ≠ 监听 fd(模拟器多连接后端)时才关
+2 -1
View File
@@ -62,7 +62,8 @@
| `net_config.h` | 配置宏定义(最大 Socket 数、超时时间等) |
| `net_types.h` | 类型定义(地址结构、Socket 控制块等) |
| `net_socket.h` | BSD Socket API 头文件,含 `net_msg_t` 消息类型定义 |
| `net_socket.c` | API 核心实现:消息队列处理与线程安全封装、内部 `_sock()` 版本;另含 L3 pending 化 recv、TRAP-NET-17 周期对账 + 监听重建**合一** reconciler(`net_reconcile_tick`,100ms,分级去抖+冷却,形态①②③④⑤/U11,自愈收敛阶段 3) |
| `net_socket.c` | API 核心实现:消息队列处理与线程安全封装、内部 `_sock()` 版本;另含 L3 pending 化 recv |
| `net_events.c`(**真机专属**) | **状态面收敛**(2026-10-14 从 `net_socket.c` 抽出,与 `net_listener.c` 同族):5 个 `handle_*_event`(`net_poll` 检测中断后的分派目标)+ `fire_event` + TRAP-NET-17 周期对账 + 监听重建**合一** reconciler(`net_reconcile_tick`,100ms,分级去抖+冷却,形态①②③④⑤/U11,自愈收敛阶段 3)+ `net_lost_connect_probe`;**只置事实与登记意图**,重建动作在 `net_listener_tick`;内部头 `net_events.h`,模拟器不编译 |
| `net_listener.c`(**真机专属**) | **监听生命周期**(N-32 §10 模块化,E17 单一所有者收口):`net_listener_request`(**只登记意图**,5 个登记点)/ `net_listener_tick`(**唯一执行者**,门集 + D10 重试 30ms/2s)/ `net_listener_clear` / `net_listener_settle`(沉降/隔离 100ms/400ms);"该不该重建"的周期判据在 `net_reconcile_tick` 形态③④(自愈收敛阶段 3,单一 100ms tick);内部头,模拟器不编译 |
| `net_select.h` | select/poll API 头文件 |
| `net_select.c` | select/poll 实现(仅 MCU 端编译) |
@@ -211,7 +211,7 @@ flowchart TB
> `session_dead` **不在此清零**(与 `net_relisten_locked` 同一契约:重建监听 ≠ 应用已确认会话结束;清零只发生在 `handle_connect_event`)。
### 4.8 TRAP-NET-17 周期对账 + 监听重建(合一 `net_reconcile_tick`,事件未收到自愈,`net_socket.c`)
### 4.8 TRAP-NET-17 周期对账 + 监听重建(合一 `net_reconcile_tick`,事件未收到自愈,`net_events.c`)
`net_reconcile_tick()`(`net_process_messages` 每轮调用;周期 **100ms**,`NET_RECONCILE_PERIOD_MS`;自愈收敛阶段 3 起含原 pump 形态③④/U11):
@@ -487,7 +487,7 @@ recv 与 send 各一组(send 组前缀 `net_send_pending_`),共 14 个函
| 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_socket.c` `net_reconcile_tick` 形态⑤ | `net_pending_cancel(i, NET_ERR_CONNRESET)` | 芯片静默回收后挂起 recv 无唤醒源 |
| 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 已通知,仍须归零) |
@@ -502,7 +502,7 @@ recv 与 send 各一组(send 组前缀 `net_send_pending_`),共 14 个函
| 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_socket.c` `net_reconcile_tick` 形态⑤ | `net_send_pending_cancel(i, 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 **摧毁在途传输**(半写的数据流被截断) |
+278 -18
View File
@@ -125,6 +125,78 @@ EOF 只在 **recv 那一刻**本层为 CLOSED 才给出(`net_recv_locked`,`D
且 FUN_PARA **初始化后不可改**(`docs/芯片手册/CH395F.md` §5.52 L795⑥),这是开机一次性决策,
不能运行时降级。**结论:A′ 出局;根治只能在单连接复用模型内做**(红线 2,§6)。
### 1.5 与主流 offload 驱动的代码级对照("是不是过度设计"的正面回答)
> 2026-10-14 补。此前本节只有"主流方案无一维护第二份 socket 状态镜像"一句**结论**,
> 没有代码级对照——于是"沉降/重试这套机器是不是我们想多了"会被反复问。这张表就是答案。
**对照对象**:WIZnet **ioLibrary** `Ethernet/socket.c`(W5100~W6300 官方驱动,TCP offload
芯片的事实标准)。其关闭/重开路径原文:
```c
int8_t close(uint8_t sn) {
setSn_CR(sn, Sn_CR_CLOSE);
while (getSn_CR(sn)); /* 等命令被芯片处理 */
setSn_IR(sn, 0xFF); /* ① 复用前清中断 */
sock_is_sending &= ~(1 << sn); /* ② per-socket 记账清零 */
sock_remained_size[sn] = 0;
sock_pack_info[sn] = PACK_NONE;
while (getSn_SR(sn) != SOCK_CLOSED); /* ③ 等芯片状态真落地才返回 */
}
int8_t disconnect(uint8_t sn) {
...
while (getSn_SR(sn) != SOCK_CLOSED) { /* ③ 同样等 */
if (getSn_IR(sn) & Sn_IR_TIMEOUT) { close(sn); return SOCKERR_TIMEOUT; }
} /* ④ 有界超时兜底 */
}
int8_t socket(...) { close(sn); ... while (getSn_SR(sn) == SOCK_CLOSED); } /* 重开前先关干净 */
int8_t listen(...) { while (getSn_SR(sn) != SOCK_LISTEN) { close(sn); return SOCKERR_SOCKCLOSED; } }
```
⇒ **① 复用前排渣 ② per-socket 记账 ③ 等状态落地 ④ 有界超时——本仓一条不少**:
分别对应 listen 前排清 SINT(§4.10)、`s_relisten_*[]`、沉降窗(§4.3)、D10 预算(§4.2)。
**这套形状不是本仓发明,是 offload 芯片驱动的通用形态。**
**那本仓多出来的体积从哪来——三条约束的兑换券**:
| 约束 | ioLibrary | 本仓 | 复杂度落点 |
|---|---|---|---|
| **不能阻塞** | `while (getSn_SR(sn) != SOCK_CLOSED);` 就地死等,`close()` 里连超时都没有 | netTask 绝不能冻结(IWDG + 另外 3 个常驻监听 + 消息队列同任务) | ⚠ **头号来源**:他们一个 `while()` 的事,本仓必须翻译成**可续跑状态机**——`s_relisten_*[]` 8 个数组**全部是"把 while 换成状态"的代价**,无一是额外逻辑 |
| **监听器归谁** | 8 个独立硬件 socket,关数据 socket 碰不到监听 socket | 单 socket 复用(§1.1) | "监听器需重新武装"这个概念在 ioLibrary 里**根本不存在** |
| **谁做 TCP 收尾** | 硬件自动:W5500 数据手册明写断开成功 → `SOCK_CLOSED`,否则 **TCP 超时后也变 `SOCK_CLOSED`** | `bit3=1` 下芯片按设计**什么都不做**,等 MCU 关(§1.1) | 沉降被 WIZnet 芯片自己吸收,CH395F 明确甩给软件 |
| **清中断的代价** | `setSn_IR(sn, 0xFF)` 随便清(每 socket 独立 W1C) | 全局寄存器整体读即清、无按位手段 | 他们一行清干净,本仓必须守 §4.10.5 三条准入判据 |
⚠ **ioLibrary 那两条死等不是"更简单",是"把代价挪到运行时"**:官方论坛有"系统挂死、SPI
驱动在 `close()` 后永远等不到回复"与"`close()` 只轮询 `Sn_CR` 不轮询 `Sn_SR`"的质疑帖。
本仓没有死等的自由(上表第一行),复杂度只是换了个地方存着。
**lwIP / Zephyr / FreeRTOS+TCP 为什么没有这套机器**:完整 TCP 栈跑在 MCU 里,
TIME_WAIT/2MSL 藏在栈内部,应用永远看不见 FIN_WAIT_2;监听器所有权是显式 API(lwIP raw API
把"`tcp_close()` 只对 socket 层创建的监听器生效"写进文档)。⇒ **它们把复杂度付在 RAM/CPU
上,本仓付在驱动代码上**——用 offload 芯片就是为了省那颗栈,**复杂度不会消失,只会搬家**
(§1.2 那句"需要靠铁律维持的正确性就是架构欠债的凭证"的另一面:欠债要还,但还债的货币
由芯片选型决定)。
**本仓可安全减的部分(诚实清单)**——该文件的**内部组织度**(搬移边界 / 窄接口 / 可机械
检验判据)见 §11,本节只管"这台机器该不该存在":`net_listener.c` 实测 **386 行 = 注释 155 +
代码 204**(原 508 = 注释 277 + 代码 204;2026-10-14 注释精简 −44%,20 条陷阱的现场叙述迁入
本文,**代码逐行未改**)——**7 个函数**(4 公开 + 3 内部)/ 8 个 per-socket 数组,
**代码量本来就不大,过去大的是 20 条陷阱的现场注释**;精简后那些叙述在本文件里。
可省:`s_relisten_settle_dead[]`(恒 = `settle_start + NET_RELISTEN_SETTLE_MS`,
可就地算)、`s_relisten_capped[]`(可压成 `quarantine` 的一个 bit)——合计 16 B。
**不可省**:沉降窗(E4 变体 C 带残渣关闭 0/20)、D10(= ioLibrary 的 TIMEOUT 等价物)、
listen 前排清(§4.10)、登记/执行分离(E17,12 个所有者收敛成 1)。
**真要大幅减掉这套机器,唯一杠杆是芯片**——而 §1.4 已用三条硬证据否掉多连接模式,且
`FUN_PARA` 初始化后不可改。WCH 官方博客原话(E15 已引):"客户端主动断连并立即重新发起
连接请求,此时 CH395 会拒绝该连接请求,**因此需要程序中做一些特殊处理**"——官方口径的
"特殊处理"就是本文件。
**出处**:ioLibrary `Ethernet/socket.c`(github.com/Wiznet/ioLibrary_Driver);
W5500 数据手册 W5500_ds_v110e.pdf(`Sn_SR` 断开 / TCP 超时条款);WIZnet maker 论坛
帖 13028(close 后永久等待致挂死)与 10305(轮询口径质疑);lwIP 2.0.x Raw API 文档;
WCH 官方博客 cnblogs sw111/18451802。
## 2. 设计原则(不变量)
| # | 不变量 | 违反的后果(均实测) |
@@ -158,7 +230,7 @@ reconciler 保留分级去抖,因为形态①要让位 M3 窗、形态②要
|---|---|---|
| `net_poll` | 主循环每轮(≈1ms) | INT# 引脚 → 全局事件汇聚(唯一读者)→ SINT 分派 → **驱动缓存全量刷新** → 链路位喂驱动 → 步进 `ch395f_sock_reset_poll` |
| `net_process_messages` | 每轮 | RPC 消息处理;开头每轮 `net_pending_tick`(挂起 recv 结束)+ `net_listener_tick`(D10) |
| `net_reconcile_tick` | 100ms | 唯一周期对账 + 重建(§4.1) |
| `net_reconcile_tick` | 100ms | 唯一周期**对账**——判据在此,重建动作在 §4.2 唯一执行者(§4.1) |
### 3.3 状态模型(会话投影)
@@ -177,25 +249,108 @@ reconciler 保留分级去抖,因为形态①要让位 M3 窗、形态②要
### 4.1 `net_reconcile_tick`——单一 100ms tick(唯一周期性自愈入口)
#### 4.1.1 为什么需要它:状态有两个所有者
| 所有者 | 是什么 | 谁更新 |
|---|---|---|
| **本层** `p_sock->state` | 软件枚举(`CLOSED=0` / `LISTENING=3` / `ESTABLISHED=5`)= 会话投影,**非芯片事实源**(I1) | **中断事件流**:CONNECT / DISCONNECT / RECV_OK |
| **芯片** `c.open` + `c.tcp_state` | CH395F 两个真实状态字节,`net_poll` 每轮刷进驱动缓存(新鲜度 ≤1ms) | 芯片自己的 TCP 状态机 |
主路径是事件流:芯片状态一变就发中断、本层跟随。**中断没送到 netTask**(被门控丢弃 /
时序窗口错过 / 芯片压根不发——形态⑤ 台架实证)→ 两侧脱钩,只有两类坏结果:
- **连接已死而本层以为活着** → 消费者挂在 recv 上,而对端再也不会发数据;
- **监听器已没而本层以为在听** → 端口对 SYN 不响应,表现是"连接被拒"。
本 tick = 每 100ms 把两侧摆在一起比对的那只手,**5 种失配组合 = 5 个形态**。
#### 4.1.2 读判据前必须知道的四个词
- `chip_listening` = `(open != CLOSED) && (tcp_state == LISTEN)`,即 `0x05/0x01`。
⚠ **`0x05/0x00`(Socket 开着、TCP 已关的二段关闭中间态)不算在听**——这个区分很关键。
- `listener_fd` = `auto_relisten && type==STREAM && local_port!=0`:监听型 fd 门。少了它,
**出向连接**(本层 CLOSED + 已绑端口)会被误判成监听器而被 relisten。
- **去抖三档**(连续失配几轮才动手,一轮 = 100ms):② **1 轮**(E2:LISTEN 可见 ≤10ms
无长尾)|③④⑤ **2 轮**|① **4 轮**(≈400ms,M3 让位窗——① 动作最重,要给正常事件流
先自己收口的机会)。动手后置 **1s 冷却**防风暴;U11 被动重同步**不去抖、不占冷却**。
- **判据在对账、动作在别处**(I2):③④ 只 `net_listener_request()` 登记意图,真正执行
重建的是 `net_listener_tick` 一处(§4.2)。
#### 4.1.3 形态表(判据 → 动作)
判据读驱动缓存(零 SPI;例外见表注),统一 1s 冷却:
| 形态 | 判据(本层 ↔ 芯片) | 动作 | 去抖 |
|---|---|---|---|
| ① | ESTABLISHED ↔ 芯片 CLOSED / TCP_CLOSED | 复用 `handle_disconnect_event`(置事实 + GRACEFUL 沉降) | **4 轮**(≈400ms,M3 让位窗) |
| ② | LISTENING ↔ 芯片已握手(TCP=ESTABLISHED) | `GET_REMOT_IPP` 幽灵甄别 → 补 accept(`handle_connect_event`) | **1 轮**(E2:LISTEN 可见 ≤10ms 无长尾) |
| ③ | 监听型 fd 而芯片不在 LISTEN | 登记重建请求(动作在 I2 唯一执行者 `net_listener_tick`) | 2 轮 |
| ④ | 本层 CLOSED(auto_relisten)而芯片**正在** LISTEN(U11) | 只重同步本层为 LISTENING,**零芯片命令** | 2 轮 |
| ⑤ | ESTABLISHED ↔ 芯片已回 LISTEN(静默回收,F-NET-2) | RESYNC 语义只修本层(转登端口 + 取消挂起 recv),零命令 | 2 轮 |
| 形态 | 判据(本层 ↔ 芯片) | 动作 | 去抖 | 芯片命令 |
|---|---|---|---|---|
| ① | ESTABLISHED ↔ 芯片 CLOSED / TCP_CLOSED | 复用 `handle_disconnect_event`(置事实 + GRACEFUL 沉降) | **4 轮**(≈400ms,M3 让位窗) | 无(置事实) |
| ② | LISTENING ↔ 芯片已握手(TCP=ESTABLISHED) | `GET_REMOT_IPP` 幽灵甄别 → 补 accept(`handle_connect_event`) | **1 轮**(E2:LISTEN 可见 ≤10ms 无长尾) | 1 次 SPI 读 |
| ③ | LISTENING ↔ 监听型 fd 而芯片不在 LISTEN | 登记重建请求(动作在 I2 唯一执行者 `net_listener_tick`) | 2 轮 | 无(转交) |
| ④ | 本层 CLOSED(auto_relisten)而芯片**不在** LISTEN | 登记重建请求(同 ③,原 pump②) | 2 轮 | 无(转交) |
| ⑤ | ESTABLISHED ↔ 芯片已回 LISTEN(静默回收,F-NET-2) | RESYNC 语义只修本层(转登端口 + 取消挂起 recv/send),零命令 | 2 轮 | **零** |
| U11 | 本层 CLOSED ↔ 芯片**正在** LISTEN | 只重同步本层为 LISTENING(转登端口) | **不去抖、不占冷却** | **零** |
⚠ **U11 不是形态④**(本表 2026-10-14 修正:旧表把 U11 误标成 ④、且**漏了真 ④ 的行**,
还把 U11 的去抖写成 2 轮)。两者判据同族(本层 CLOSED + 监听型 fd)而结论相反:
**芯片在听 = 本层滞后 → 只改状态;芯片不在听 = 监听真没了 → 登记重建**。搞混的代价就是
U12 同源事故——对正听着的 Socket 重下发 open+listen 会吃 USE 错、走进关闭分支,
把健康监听器关掉。
表注:形态② 命中时的对端端口回读是本 tick 唯一按需 SPI。
- **为什么 ①③⑤ 常态 0 命中**(E18 负向注入打靶三轮,2026-10-14):消费者驱动的 pending
recv / recv EOF 比 100 ms 对账**更快**,多数场景先把本层收口成 CLOSED → 由形态④ 接手。
①③⑤ 的职责边界是**"没有人在等"的场景**,与 ④ 互补而非重复。三条都已主动打中并确认救回:
① 吞掉 DISCONNECT 后 **4/4 救回**;③ 空闲监听器被静默 close,两轮独立复现;⑤ 开盲区吞掉
芯片自发中断 + 把芯片收回 LISTEN 后命中 `-> resync`(零芯片命令、挂起 recv 被取消、端口
立刻可复连)。→ **三条均不可降级为只留痕**;⑤ 的"取消挂起 recv/send"尤其不可省,
否则消费者在死连接上干等到 deadline。
#### 4.1.4 每个形态为什么长这样、怎么验的
**① 连接已失**:成因 = 连接被拆而 DISCONNECT 没被 netTask 收到,本层停在 ESTABLISHED、
消费者挂在 recv 上等永远不会来的数据。动作复用断开状态机(含陈旧事件甄别 + 状态迁移 +
事件派发),**只置事实、不做重建**(N-32,重建交 ③④)。验法 = 注入吞掉该端口的 DISCONNECT
分派(前提:在 i104 的 200ms accept 轮询窗内由 PC 发 RST,让芯片真的拆链)→
`reconcile: chip closed (0x05/0x00) -> disconnect`。日志里 `open` 仍是 `0x05`、
`tcp_state=0x00` → 判据是**"或"**,故成立(E18 第 2 轮 4/4 救回)。
⚠ 第 1 轮曾**误判命中**:拿 PC 侧"能连上"当判据,而设备日志里根本没有 `chip closed` 行
(干活的是 ③)→ **判据是设备日志,不是端口可达性**(E18 方法论第一条)。
**② 握手已完成、事件没收到**:成因 = 芯片做完三次握手而 CONNECT 没送到,本层以为在听、
应用不会 accept,数据全堆在芯片 RX,应用侧**完全看不见**这条连接。⚠ **幽灵甄别(N-30 /
TRAP-NET-20)是本形态最凶处**:拆链收尾窗口里芯片状态会**如实**显示 ESTABLISHED(旧连接
未拆净,E15 契约,非谎报),此时补 accept 就把新数据发给已死的旧连接 = 生产 `LIST#4`
数据被吞的直接成因;故仅当"对端端口非零且 = `last_peer_port`"时拦截,`peer==0` 不拦截
(对端端口提交有滞后,不得因此挡掉救援)。去抖 1 轮 = 最激进,因为它是"白捡的救援"、代价小。
⚠ **② 不在 E18 靶单上**(需吞 CONNECT 事件)→ 其依据是幽灵甄别 + E2/E10 台架记录,
**勿写成"已负向注入验证"**。
**③ 监听器失位**:成因 = 芯片上的监听器没了(close / 超时 / 回收)且没发事件,本层还以为
在听 → 端口拒连。验法 = 绕过本层全部簿记,直接对**空闲**监听器(`:7003` 远显,无连接、
无消费者 = 最干净的靶)发 `ch395f_close_socket()` → `chip not listening (0x00/0xFF,
was state=3) -> relisten`(`was state=3` 即 LISTENING)。E18 第 1、2 轮两次独立复现。
**④ 孤儿监听**:成因 = 会话结束、本层已收口 CLOSED,但监听没重建成功(或重建事件丢了)。
与 ③ 的差别**只在本层状态**:③ 是"以为还在听",④ 是"知道会话结束了、但监听器本该在"。
**它是常态唯一在干活的形态**(归因见 §4.1.5)。④ 不是靶,却被 E18 第 1 轮的失败反复
误打中(没吞掉芯片自发 DISCONNECT → 本层塌成 CLOSED → 活落到 ④)——这些"打歪了"的样本
正是 §4.1.5 结论的来源。
**⑤ 芯片静默回收**:成因 = 芯片**自己**把 Socket 收回 LISTEN 且**不发 DISCONNECT 中断**
(run8 台架实证),本层挂着一个已不存在的"活连接"。动作纪律 = **零芯片命令**:只改本层
state、转登 `last_peer_port`、清 `lost_conn_seen`、**取消挂起 recv/send**(必通知,防调用方
挂死)。为什么绝不能顺手 close + 重 open:芯片那个监听器是**健康的**,重下发会摧毁它 →
端口永久拒绝,**run8 实测 170s 拒连**(与 U11 同一条纪律)。验法 = 四步机:开盲区吞掉芯片
自发的一切中断 + close → **等芯片真回 CLOSED** → 绕过本层 open+listen → 确认落到
`0x05/0x01` 而本层仍 ESTABLISHED → `chip re-listened w/o DISCONNECT -> resync`。
两次失败各暴露一个硬事实(详见 §5.2 E18):状态机漏 `step=1` → 每 ~1ms 重复 close →
**SPI 风暴** → `[ERR][WD] task stall`;等待预算 200 轮太短——芯片 close 后停在
**FIN_WAIT_2(`0x05/0x06`)实测 972 轮 ≈1s**(E15 的 2MSL 长尾),靠 PC 侧 RST 才打断。
这条实测同时是 §4.3 沉降上限"为什么必须存在"的时间尺度证据。
#### 4.1.5 为什么 ①③⑤ 常态 0 命中
消费者驱动的 pending recv / recv EOF 比 100 ms 对账**更快**,多数场景先把本层收口成
CLOSED → 由形态④ 接手。关键在两条快路径的判据(`net_pending.c` 的挂起 recv 结束判定 +
`net_socket.c` 的 recv 快路径):**只看本层 `state`、从不读芯片**——所以只要"有人在等",
事件流就先赢,①③⑤ 永远轮不到。推论就是职责边界:**①③⑤ 管的是"没有人在等"的场景**
(空闲监听器被静默 close、连接被静默拆掉而无人 recv、芯片静默回收),与 ④ **互补而非
重复**。怎么打中的见 §4.1.4、证据见 §5.2 E18。→ **三条均不可降级为只留痕**;⑤ 的
"取消挂起 recv/send"尤其不可省,否则消费者在死连接上干等到 deadline。
#### 4.1.6 M3 可疑门与沿革
- **M3 可疑门**(`handle_disconnect_event` ESTABLISHED 分支):芯片 `0x05/0x00`
(Socket OPEN 但 TCP 槽 0x00——活连接应为 0x04,逻辑矛盾态)且 `lost_conn_seen==0`
@@ -372,6 +527,70 @@ Socket 仍 OPEN、芯片 TCP 已 CLOSED、**RX 缓冲里 STOR 载荷一个不少
| 关闭姿势必须正确 | TRAP-NET-12:陈旧 DISCONNECT 锁存秒断下一条连接 | `sock_reset` 序列 = 排 RX → CLOSE → 排事件 → 等 `byte0=00H`(§4.4) |
| 异常释放须先排空残渣再关 | TRAP-NET-18:~2KB RX 残渣被下条连接当自己的数据读走 | 隔离期持续排空 RX + 主动 close + 等 `CLOSED/TCP_CLOSED`(§4.3) |
### 4.10 读即清信源审计(谁在读、会不会互吞)
> 2026-10-14 全库审计(grep `get_sock_int_status` / `glob_int_status` / `int_status_sn`)。
> 口径原本散在 **B3、F-NET-15 M4-lite、E14、TRAP-NET-12/-18** 四处代码注释里,**没有总表**——
> 而"想再加一处读清"的人不会先去读那四处。本节就是那张挡人的表。
#### 4.10.1 两个读即清寄存器与两级门控
| 寄存器 | 命令 | 宽度 | 语义 | 合法读者 |
|---|---|---|---|---|
| 全局中断状态 | `GET_GLOB_INT_STATUS_ALL` | 16 位 | 含**全部 8 个 Socket** 的事件位(Socket n = bit(n+4));**整体读即清,芯片无按位清除手段** | **`net_poll` 唯一**(AGENTS 模块约定) |
| 本 Socket 中断状态 SINT | `GET_INT_STATUS_SN` | 8 位 | 单个 Socket 的事件位;读即清 | `net_poll` 分派 + §4.10.2 的定点排渣 |
`net_poll` 是**两级门控**:先读全局位,某 Socket 位为 0 就 `continue`、**不去读它的 SINT**。
⇒ 任何"顺手全量清一遍"的代码,等于**把别的在用 Socket 的门一并焊死**——它们的
CONNECT/DISCONNECT 边沿**永久丢失**(RECV 属条件位、下轮可补回,TRAP-NET-08)。
这就是 B3 那类代码的真实危害,也是"全局寄存器只有一个读者"这条约定的全部理由。
#### 4.10.2 现存读清点全量清单(产品代码 6 处)
| 位置 | 时机 | 会不会吞掉有用事件 |
|---|---|---|
| `net_poll` 全局读清(`net_socket.c`) | 事件汇聚入口 | 契约本体——读完立刻按位分派,不吞 |
| `net_poll` 分派读 SINT(同上) | 受全局位门控 | 不吞 |
| `net_listen_locked` 预清 | **`tcp_listen` 之前** | 芯片此时还没在听,**产生不了 CONNECT** |
| `net_relisten_locked` 预清(`net_listener.c`) | **`tcp_listen` 之前**(E14 定案改法) | 同上 |
| 沉降/隔离窗每 30ms(`net_listener.c`) | `close` 之后、`open` 之前 | 吞的是上一条会话残留 DISCONNECT/TIMEOUT = **本意排渣** |
| `net_connect_locked` / `net_close_locked` 预清、`sock_reset` 第 3 步(驱动) | 出向连接前 / 关闭前 / 复位排空至 quiescent | "本 Socket 即将被重新使用"的定点排渣(TRAP-NET-12/-18 两级预清) |
**共同点:全部只碰本 Socket,且都落在"这个 Socket 即将被重新使用"的边界上。**
⇒ 当前**没有已知的"事件被读清互吞"活口**(`test/board` 台架里的读清点不算——它们剥离了
net 层,本就以"我自己是唯一读者"为前提)。
#### 4.10.3 已根除的三条互吞实例(历史,勿复活)
| 已删的东西 | 危害 | 定性 |
|---|---|---|
| **B3**:`net_listen_locked` / `net_relisten_locked` 遍历 **8 个 Socket 全清 + 再读清全局** | 见 §4.10.1——别的在用 Socket 边沿永久丢失;而后者是**每次断连的热路径** | git 考古:多连接时代调试遗留被原样搬进单连接模型(e862009bc → 319d00e3 → db64e6acf),**不属于任何 Trap 的修复** |
| **F-NET-15 M4-lite**:沉降段的全局读即清 | 每 30ms 一轮就搁浅其它 Socket 的 CONNECT/DISCONNECT/RECV_OK;M1 使沉降成为每次断连热路径后,放大成 **FTP 控制/数据通道互杀**(E7 实测 lftpd 5s 超时) | 已删,注释留在 `net_listener.c` 隔离段 |
| **E14**:排事件放在 `tcp_listen` **之后** | listen 后读清把**快速复连新连接的 CONNECT** 当场吞掉(生产与台架同源根除) | **已挪到 LISTEN 之前**(`net_listener.c` / `net_listen_locked` 同改法) |
#### 4.10.4 一处「看着像缺陷、其实是配对消费」
`handle_connect_event` 末尾有一处 `(void)ch395f_get_sock_int_status()`,注释只写"清除残留
TIMEOUT",极易被误读成"随手丢弃整字节"。查调用链才定性:它有**三个调用点**,其中
**形态② 补 accept 这条路径根本没读过 SINT**(CONNECT 事件本来就没送到),芯片里那个
CONNECT 位还锁着——软件已补做 accept,**必须清掉**,否则 `net_poll` 下一轮看到全局位会
**二次分派同一个 CONNECT**。
残留风险只剩 net_poll 路径:从分派读 SINT 到此处是**微秒级窗口**,其间锁存进同一字节的
DISCONNECT 会被 `(void)` 丢掉。但 RECV_OK 是条件位(下轮补回)、DISCONNECT 由形态① 在
400ms 内兜住 → **有界,不建议改动**。⚠ 真要动它,必须先读懂形态② 那条路径再动手。
#### 4.10.5 再加一处读清的三条准入判据
1. **只碰本 Socket**——绝不碰全局寄存器(唯一读者 = `net_poll`)。
2. **必须在 `tcp_listen` 之前**(E14:之后读清会吞掉新连接的 CONNECT)。
3. **必须说清两件事**:吞掉的是**谁的什么位**(上一条会话残留 = 合法;本 Socket 新事件 =
违规),以及万一吞错**由哪条兜底**(对账形态 / 条件位自愈)。
**结构性残余**:两级门控存在"全局位没置、SINT 却有位"的**理论**可能。E14 的 48 轮里
2/48 属**门控观察伪象**(SINT 30ms 内其实已捕获),**不是已证实的漏读**。兜底仍是 I3:
**事件只负责提前唤醒,正确性靠回读 + 有界超时**。
## 5. 关键参数与实证锚点
### 5.1 参数表
@@ -483,8 +702,10 @@ Socket 仍 OPEN、芯片 TCP 已 CLOSED、**RX 缓冲里 STOR 载荷一个不少
## 9. 代码级事实清点
> 判读本架构或做决策时需要的**源码事实**。⚠ 只给符号名、不固化行号——行号随重构漂移
> (本仓曾出现文档内两处行数快照自相矛盾)。行数快照(2026-10-14):
> `net_socket.c` 3702 行、`net_listener.c` 467 行、`net_listener.h` 97 行。
> (本仓曾出现文档内两处行数快照自相矛盾)。行数快照(2026-10-14,两次模块化之后):
> `net_socket.c` 3119 行、`net_events.c` 663 行、`net_listener.c` 386 行、`net_listener.h` 126 行。
> ⚠ **这类快照当天就会漂移**(本节上一版写的 3702/467/97 已被同日的 §11.5 作废)——
> 它是"读代码前的地图",**不是判据**;要判据请数符号,别数行。
### 9.1 会话投影的状态写入面
@@ -528,6 +749,9 @@ Socket 仍 OPEN、芯片 TCP 已 CLOSED、**RX 缓冲里 STOR 载荷一个不少
> **动机**:真机 net 层 `net_socket.c` 一度 4126 行,而模拟器做同样的事只有 807 行(5 倍);
> `relisten` 一词在文件里出现 110 次。诉求 = **高内聚低耦合**。
> 先由 §4.1 把"周期性重建"收敛到唯一决策者,搬移边界才第一次清晰——**先收敛,再搬移**。
>
> ⚠ **本节只回答"组织度"**(搬什么、边界在哪、接口多窄)。若问的是"这套沉降 / 重试机器
> 本身是不是我们想多了"——那是**合法性**问题,见 §1.5 与 ioLibrary 的代码级对照。
### 11.1 可机械检验的判据
@@ -549,7 +773,7 @@ Socket 仍 OPEN、芯片 TCP 已 CLOSED、**RX 缓冲里 STOR 载荷一个不少
|---|---|
| `net_relisten_locked` / `net_listener_request` / `net_listener_tick` / `net_listener_clear` / `net_relisten_done` | 「维持 fd 处于 LISTEN 态」的全部机制 |
| `net_drain_chip_rx` | 重建前的排空(只被 relisten 调用) |
| 7 个 `s_relisten_*` 数组 + 7 个 `NET_RELISTEN_*` 常量 | 状态与策略私有化 |
| 8 个 `s_relisten_*` 数组 + 7 个 `NET_RELISTEN_*` 常量 | 状态与策略私有化(第 8 个 `s_relisten_capped` 是 E19 降级时新增,属 §1.5「可省清单」) |
| ~~`net_listener_pump`~~ | 原唯一的周期性重建决策者——**阶段 3 已删除**,决策并入 `net_reconcile_tick` 形态③④(§4.1) |
**留在 `net_socket.c`**(并说明理由,避免"看着像就搬"):
@@ -619,6 +843,42 @@ void net_listener_tick(void); /* 唯一执行者(netTask 每轮,
> 接口改造必然改变代码生成。修订为:**RW-data / ZI-data 必须完全不变**(数据总量不变),
> `Code` / `RO-data` 允许极小变化。
### 11.5 同族第二模块:`net_events.c`(2026-10-14,事件处理 + 周期对账)
§11 把"动作"搬进 `net_listener.c` 后,`net_socket.c` 里剩下的**判据**(`net_reconcile_tick`
形态①②③④⑤/U11)与**边沿事件处理**(5 个 `handle_*_event`)仍是同一职责的两半——
一个周期面、一个事件面,都在做"芯片状态面 ↔ 本层镜像"的收敛。本次把它们合成**一个**模块。
**结果**:`net_socket.c` 3720 → 3119 行(−601);新增 `net_events.c` 663 / `net_events.h` 46。
`RW-data` 1112 → 1112、`ZI-data` 154456 → 154456 **完全不变**;`Code` 212316 → 212296(−20 B,
拆 TU 的链接器布局差)。`@test` PASS:635ms 到 `net init: OK`,`:8888`/`:2404`/FTP 均建立,
`[ERR]`/Fault/relisten/reconcile 命中 **0** 条。
**为什么是一个模块而不是两个**(与原计划不同,值得记):剥掉注释后实测**对账区真实调用
事件处理器 2 处**(形态② 补 accept 走 `handle_connect_event`),且 `NET_RECON_STABLE_DEATH`
两边共用。拆成 `net_events.c` + `net_reconcile.c` 会造出跨模块调用;合起来这 2 处变模块内
直调,**跨模块导出为零**。⚠ 依赖扫描**必须先剥注释**——未剥时 `alloc_socket` /
`net_close_locked` / `net_relisten_locked` 全被误报成依赖(其实只是注释里的提及),
足以把人引向错误的边界。
**三处「非纯搬移」**(沿用 §11.4 的口径,均已留痕):
1. **fd 取法替换**:`p_sock - s_net_socks` → `p_sock->ch395_sock`(`net_socket()` 里
`ch395_sock = sockfd`,恒等;模拟器同构造)。⚠ 随之**收窄**两处沉降登记的范围门
`(qfd >= 0) && (qfd < MAX)` → `qfd < MAX`:`ch395_sock` 是 `uint8_t`,`(int)` 后恒非负,
留着被 cppcheck 判 `knownConditionTrueFalse` 拦提交。**勿加回**。
2. **新增 `net_sock_raw()`**:对账必须看到 `in_use==0` / `state==CLOSED` 的槽位(形态④
认领孤儿监听正依赖 CLOSED 态),而 `net_get_sock()` 对未在用槽位返回 NULL 会漏掉这一类。
声明放**配对头** `net_socket.h` 而非内部头 `net_events.h`——规范 **S17 2.2** 要求非 static
函数声明在配对头文件(同 §11.4 第 1 条 `net_set_errno` 先例)。
3. **真机专属 + `SIM_EXEMPT` 显式点名**:同 §11.4 第 3 条(依赖 `ch395f_*` SPI 与驱动状态
缓存,Winsock 后端无"事件未收到/静默回收"概念,无失同步可收敛)。
**搬移完整性的机械证明**:剥注释后取**代码行多重集**,`net_socket.c` + `net_events.c` 之和
与原 `net_socket.c` 相比,差异**恰好等于**上面声明的改动集(3 处 fd 取法 + 1 处数组访问 +
8 处 `static` 去除 + 6 行前向声明 + 新文件自身 15 行头 + `net_sock_raw` 三行),**无一处意外**。
搬移类改动只有配上这种证明才敢合入——否则"注释和代码混在一起改"无法区分意外丢失。
## 12. 引用与归档约定
- 本文引用格式:`docs/网络/网络自愈架构设计.md` §N.M(紧邻写法);E 编号引 §5.2
+2 -2
View File
@@ -117,7 +117,7 @@ CH395F 以太网驱动与 NET 层的实证陷阱:现象、定案根因、修
2. `handle_disconnect_event` LISTENING 分支:回读芯片状态——已不可监听即重建。
- **踩坑记录(务必先读)**:基于单次状态读的重建动作放在 accept 热路径 → 把"刚 open/listen 尚未生效"的瞬态误判为失配 → 监听反复重建直至 `relisten give up` → **三个监听口全部死亡(风暴)**。**结论:凡基于单次状态读的重建动作,必须去抖。**
- **回归基线**:`:7003` 300ms×10:**10/10**;`:21` 100ms×50:**50/50**;`:21` 0ms/600ms×50:**50/50**;`:7003` 200ms×20:**20/20**;FTP 10×64KB SHA-256:**10/10**;`relisten give up`:**0**。三类失同步均有自愈实测计数留痕。
- **对应文件**:`net_socket.c`(`net_reconcile_tick`);`MDK-ARM/tcp_reconnect_probe.py`。
- **对应文件**:`net_events.c`(`net_reconcile_tick`;原在 `net_socket.c`,2026-10-14 抽出);`MDK-ARM/tcp_reconnect_probe.py`。
## TRAP-NET-20 · FTP 数据连接三连故障——三重定案
@@ -135,7 +135,7 @@ CH395F 以太网驱动与 NET 层的实证陷阱:现象、定案根因、修
- **定案三:LIST 吞包 = 背靠背小段写毒化芯片 TX**:决定性对照——同一目录,LIST #1(行间天然间隔)通过;LIST #4/#7(每行一次 `net_send` ~60B 背靠背小段)必挂。芯片对同一连接的**背靠背多段小写入**会毒化 TX(芯片报 SEND_OK 但对端全部丢弃,无 RST 无 DISCONNECT);RETR 1KB 满块连发不受影响。**修复**:`send_list`/`send_nlst` 整份目录**一次性批量发送**(static 1536B 缓冲;注意 `lftpd_inet_write` 成功返回**字节数**非 0)。
- **与 TRAP-NET-17 的关系**:周期对账对常驻监听器有效;FTP 数据通道是"活 1 秒就死"的短命监听器,对账②无幽灵甄别时既救不全又帮凶。本条把"看状态补 accept"内联进 accept 热路径(短去抖、无冷却)+ 端口甄别,对账②保留为兜底网。
- **教训(总纲)**:**CH395F 的"成功"(中断位/状态码)只代表芯片本地动作完成,不代表对端收到;判定必须回到对端可见证据(主机侧收发)+ 手册语义(FUN_PARA §5.52、状态码 §5.22)**。
- **对应文件**:`net_socket.c`(`net_lost_connect_probe`/closed-with-data accept/`net_reconcile_tick`②/`last_peer_port`)、`net_types.h`、`lftpd.c`(批量发送);台架 `test/board/ch395f_reuse_bench.{c,h}` + `test/host/ch395f_reuse_host.py`(主分支存档)。
- **对应文件**:`net_events.c`(`net_lost_connect_probe` / `net_reconcile_tick`②;原在 `net_socket.c`,2026-10-14 抽出)、`net_socket.c`(closed-with-data accept)、`net_types.h`(`last_peer_port`)、`lftpd.c`(批量发送);台架 `test/board/ch395f_reuse_bench.{c,h}` + `test/host/ch395f_reuse_host.py`(主分支存档)。
---