Files
STM32F4-Base/docs/网络/网络自愈架构设计.md
T
wandering1 a275539c17 docs(全仓): 内容页黄金 47 → 53 的当前态同步——含钩子显示名,登记簿口径另笔
task-9 让内容页黄金从 47 涨到 53(通讯设置页六态),本笔只做**当前态陈述**的同步;真值全部本轮实测:
`simulator/tests/ref/*.bin` = **53**;`git diff 31ca4a1f..HEAD` 对该目录 = **
6 个新增、零修改** ⇒ 存量 47 张
逐字节未变(这条是关键:新增页不该让旧页重生成,否则回归基线就失去意义)。

**四类命中各走各路,这是本笔的全部难点**(分类比替换重要得多):
· 当前态 → 改,25 处页数,落在 16 只文件(AGENTS、钩子显示名、导航器 docstring、`lvgl-simulator` s
kill、
  门禁索引与 `ui-golden` 详解、UI 自动化流程、内容页架构、页面补齐规划与扩充方案、数据供给、自动化测
  试流程、覆盖率口径、测试开发标准流程、模拟器两份 README);
· 变更记录历史行 → **一条不动**,另在其下一行插入当日「47 → 53」新行 6 条(含判据与零漂移证据);
· **同数不同义 → 不动**:覆盖率口径里的「47 个 .c」「47 = 13+5+29」、ram_report 的表行号 47、`#47-D`
  诊断号、LSI 47kHz、47,360 栈字数、像素哈希 3476/3474 —— 一并替换就会毁掉六个不相干的事实;
· 带日期戳 / 自declare 冻结的快照 → 不臆改(数据供给篇那句上一笔已加「当时」,页面补齐规划里那张
  「2026-10-25 复核…剩 21 叶」的表自带「完成即冻结不回改」声明)。

复扫残留当前态 47 = **0**。⚠ 顺手纠一条**会教错方法**的陈旧:naive 计数 `grep -c '#page='` 上一笔写的是
22,现在真值是 **23**(文件头示例行也算一次)——旧文档里那个 22 **恰好等于新的正确 bound 数**,不改的话,
下一个人用错算法还能碰巧对上,比错更糟。

⚠ `AGENTS.md` 里本次有两件事同行相邻(本笔的内容页 53 页 + 批次三那句 `TC-*` 停发),**分两句说明**:
前者是计数同步,后者是昨天那条「批次三不坐」裁定的入口文案,二者不是同一件事。

钩子显示名改在 `.pre-commit-config.yaml`(47 → 53)。**这条必须与代码同批入库**:配置脏而未 stage 时
pre-commit 会直接报错挡住全仓任何提交(今天已实测两次),所以本笔先落它,把后面几笔的路清开。

自查:`check_doc_links --all` rc=0(203 篇)、`check_ids --strict` rc=0 且不可判定绝对
数 **979 未变**(新增
文本里的提交号与 `#page=comm` 没推高它)、`gen_task_table --check` 0、`check_bom_drift`
 0、18 只文件 noBOM /
LF / U+FFFD=0;行数变动 +49/−47 全部可归属(插 6、删 2、并 1),其余为 1:1 等行替换。登记簿销项与叶数
(`UI补齐.md` + `docs/任务规划/README.md`)**不在本笔** —— 那是口径变更不是计数同步。
2026-10-27 13:54:05 +08:00

873 lines
76 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.
# 网络自愈架构设计
> **定位**:CH395F 网络自愈架构的**现状权威描述**——架构与机制、为什么这样设计、关键参数及其
> 实证锚点、红线与延后项。
> **不写历史**:审计过程、分阶段实施勾选清单、台架原始记录、重构过程账目一律不入本文;
> 需要考古见 §12。正文只描述**当前如何组织**与**为什么这样组织**。
> **编号是冻结标识**:`I*` / `E*` / `F-*` / `形态*` / `U*` / `TRAP-NET-*` / `D*` 被代码注释、
> `test/host/` 探针与 `docs/问题记录/` 直接引用——**看懂即可,勿重排改名**,对照见下方导读。
## 导读:本文用到的编号与术语
| 记号 | 是什么 | 在哪查 |
|---|---|---|
| `I1`~`I8` | 本架构的**不变量**(Invariant)——违反即有实测后果 | §2 |
| `E0`~`E19` | **台架实验**编号(E = Experiment):每个实验是「假设 → 方法 → 判据 → 门禁哪个决策」 | §5.2(原始记录已归档,见 §12) |
| `F-NET-nn` / `F-DRV-nn` / `F-MS-nn` | 审计的**编号发现**:NET 层 / 驱动层 / 状态镜像层(**2026-10-27 停发**,指代一律用 §7 表的「条目名」列) | §7(保留 12 条历史对照 + 条目名对照) |
| `M1`~`M5` | 实验固件里的**候选修改代号**:M1 沉降无条件化 · M2 门控换 `net_listener_armed` · M3 可疑门 · M4/M4-lite 删沉降段全局读即清 · M5 `sock_reset` quiescent 排空 | §4.1、§4.3 |
| `形态①`~`形态⑤` | `net_reconcile_tick` 的五类**失同步判据** | §4.1 |
| `U1`~`U15` | 状态迁移的**"无人负责"组合**(架构欠债清单) | `docs/问题记录/CH395F_Trap_Records.md` 审计遗留章节 |
| `TRAP-NET-nn` | **陷阱记录**条目 | 同上 |
| `N-nn` | `docs/任务规划/登记簿.md` 的**任务/发现编号**(⚠ 已销项的号不再可查,本文引用处即其最终事实描述) | 该登记簿 |
| `D1`~`D7` | 本文**决策记录** | §10 |
| `自愈收敛阶段 N` | 2026-10 的分阶段实施标签,**只存在于历史文档与变更记录**(代码注释已全部改为平实语言);勾选清单已归档(§12)——**勿用它叙述设计,也勿再写回注释** | §12 |
| `TSL` / `TSC` / `TSD` | SPI 命令与数据之间的**时序间隔**要求 | `docs/芯片手册/CH395F.md` §7.4 |
| `n30` | 压测脚本 `n30_repro.py`(:21 背靠背 STOR 30 轮),自愈回归的**主判据** | `test/host/` |
| 沉降窗 / 隔离期 | 释放会话后、重建监听前的**最短等待**(GRACEFUL 100 ms / QUARANTINE 400 ms) | §4.3 |
| 让位窗 | M3 可疑门交给形态① 电平二次确认的 **400 ms** | §4.1 |
| 镜像 | net 层持有的 8 态 `net_sock_state_t`——**会话投影**,不是芯片事实源 | §3.3 |
> ⚠ **字母 `E` 有两套编号,靠连字符区分**:
> - `E0`~`E19`(**无**连字符)= 本文的**自愈实验**编号,逐条见 §5.2;
> - 带连字符的 `E-<nn>` = **历史审计发现**编号(属主文档已于 2026-10-15 删除,
> 编号冻结于 `docs/编号规范.md` §4.3)——
> §5.2 末行引用它是为了记录该发现的**收口台架**,**不是**实验编号
> (证据:`test/host/e02_probe.py` 的文档串自述它是该审计发现的 timeSync 触发形态探针);
> - 该审计编号族已冻结、不再新增,引用一律改说人话。
## 1. 为什么是这个架构
三个芯片事实决定架构形态(全部台架实证,见 `docs/问题记录/CH395F_Trap_Records.md`
TRAP-NET-09/-17/-18/-20 与 §5.2 锚点):
1. **芯片永不自动回 LISTEN**——单连接复用模型(监听与数据通道复用同一 Socket),
"监听常驻"只能是软件承诺(I4)。
2. **事件面不是可靠唯一信源**——事件可被其它合法读者消费,且状态面存在事件覆盖不了的幽灵
(E7b;E15:幽灵 = 芯片如实报告旧连接拆链进行中,非谎报)→ 正确性必须靠回读芯片事实 +
有界超时兜底(I3)。
3. **close 有沉降窗、RX 残渣有害**——带非空 RX 关闭会拆链停滞、吞下一会话 SYN 且自持
(E4 变体 C 0/20);沉降窗不定长(TRAP-NET-09/-20)→ 复用前清理必须内联进事务(I5)。
主流 offload 驱动(WIZnet ioLibrary / Zephyr / FreeRTOS+TCP / mbed / lwIP 移植)里**没有任何
一家维护第二份 socket 状态镜像**,也没有"周期修正镜像"的对账;芯片怪癖一律**内联进事务、
收在驱动层、以有界超时兜底**。本架构按同一形态收敛(§2~§4),代码级对照见 §1.5。
由此确定本层**不做**的三件事:不把中断事件当正确性来源(I3)、不把软件镜像当事实源(I1)、
不把监听重建散给多个所有者(I2)。
### 1.1 芯片层事实:LISTEN 不是能力,是一个会被消耗掉的槽位状态
| 事实 | 出处 |
|---|---|
| LISTEN 与 ESTABLISHED 是同一 TCP 状态字节的**互斥取值**(01H/04H) | `docs/芯片手册/CH395F.md` §5.22 L535-545 |
| 「Socket 会一直检测连接事件,**直到连接成功后**……每个 Socket 只可以建立一个连接」 | `docs/芯片手册/CH395F.md` §5.29 L609 |
| 连接结束后芯片**永不**自动回 LISTEN——全手册无此条款;重连必须"重新打开 Socket 再 CONNECT" | `docs/芯片手册/CH395F.md` §5.30 L615、§9.2.5 L1266 |
| 断开后芯片行为由 FUN_PARA bit3 分流:=0 芯片自动关 Socket(→`0x00/0x00`);**=1 芯片什么都不做(→`0x05/0x00`),MCU 必须自己 CLOSE** | `docs/芯片手册/CH395F.md` §5.23 L572、§5.52 L795② |
⚠ 本仓 `ch395f_set_fun_para(0x08)`(`Drivers/BSP/NET/net_socket.c`,bit3 `SOCKET_CLOSE`=1,
定案为"保留排空现场",论证见 §4.9 / D4)⇒ **断开后芯片必然停在 `0x05/0x00`**。
探针看到的"孤儿芯片态"不是芯片异常,而是这个配置的**直接且合法的后果**。
### 1.2 规范层:本仓做法与 TCP/POSIX 的所有权契约正面冲突
- RFC 1122 §4.2.2.18:被动 OPEN「**MUST NOT affect any previously created connection record**」。
- POSIX `accept()`:「The original socket remains open and can accept more connections」。
- lwIP 把"监听器归谁"直接写进 `tcp_close()` 文档:只有 socket 层创建的监听器才会被连带关闭,
raw API 自建的不会——**所有权是 API 契约的一部分,不是调用方的自觉**。
本仓让同一个 fd 兼任"监听器"和"连接"两个角色(handle/resource aliasing),
于是"关掉一条连接"和"毁掉一个监听器"在类型上无法区分——这正是红线 8
(「EOF 时不得 `net_close` 监听 fd」,§6)存在的唯一原因。
**需要靠铁律维持的正确性,就是架构欠债的凭证。**
### 1.3 实现层:fd 别名 → 边沿通知 → 多所有者
EOF 只在 **recv 那一刻**本层为 CLOSED 才给出(`net_recv_locked`,`Drivers/BSP/NET/net_socket.c`);
若该 fd 已被别的路径 relisten 回 LISTENING,同一个 recv 恒返 `WOULDBLOCK`。
这是一个**边沿**,不是一个**电平**——正是不变量 I2/I3 要消除的形状。
fd 别名让这个边沿**必须有人接**,而"谁重建监听"在类型上无法唯一化:同一 fd 既当监听器又当
连接时,任何持有 fd 的路径都"看起来有资格"重建。本架构的解法是把两件事彻底拆开(§4.1 / §4.2):
- **判据唯一**:周期性"该不该重建"只在 `net_reconcile_tick` 形态③④;
- **动作唯一**:真正下发 open+listen 只在 `net_listener_tick` 一处,其余路径一律
`net_listener_request()` **只登记意图**;
- **消费者驱动即时登记 3 处**(accept CLOSED 分支 / recv EOF / 挂起 recv 断连)与周期兜底
构成双层,**不可删**(§4.2)。
三个已实测的派生失效(保留作**症状判读**:I2/I3 被违反时就是这个样子):
| 失效 | 机理 | 现状机制 |
|---|---|---|
| 孤儿监听(B10) | 边沿没人接 → net 层等应用重建、应用等 net 层给 EOF、对账只裁 ESTABLISHED/LISTENING 直接跳过、芯片不会自己回 LISTEN → **四方互等** | 对账形态④ 认领孤儿(§4.1)+ 应用侧 `net_drop_conn`(§4.6) |
| 消费者卡死 | 抢先 relisten 把慢消费者的 EOF **永久**吞掉 → `g_client` 不清、新连接被旧会话静默吸收 | `session_dead` 粘滞**电平**消除(§3.3) |
| 监听被误关 | `net_can_read` 在非 ESTABLISHED(**含 LISTENING**)返 1 → 消费者 recv 得 `WOULDBLOCK` → 判"真错误"后无护栏 `net_close` 等于**关掉监听 fd** | 应用侧 `g_client != g_listen` 护栏(`Drivers/BSP/UI/ui_net_sink.c`)+ 红线 8(§6) |
### 1.4 为什么不走"芯片原生多连接模式"(A′ 出局)
芯片确实自带 lwIP 式模型:FUN_PARA bit1(≥0x44,本仓实测 0x4A)下
「服务器的 Socket **仅用来监听**,单片机需要分配其它 Socket 用于该连接」(`docs/芯片手册/CH395F.md` §9.2.6 L1295)。
但本产品**不可用**,三条硬证据:
1. **不支持多监听**——`docs/问题记录/CH395F_Trap_Records.md` 附 FTP 改造清单第 4 条
「CH395F 多连接模式不支持多监听(类型:架构)」,处置即 `fun_para=0x00`。
本产品有 21/8888/7003 **三个常驻监听** + 会话期动态 PASV 监听。
2. 池分配按**源端口匹配**(`docs/芯片手册/CH395F.md` §9.2.6 L1295),多监听器的池会互相串。
3. 实测:「多连接模式下最多接受 3 个客户端(Socket 1~3),Socket 4~7 的 CONNECT 中断从不触发」、
「多个 Socket 数据互相覆盖」——即 TRAP-NET-02/-11。
且 FUN_PARA **初始化后不可改**(`docs/芯片手册/CH395F.md` §5.52 L795⑥),这是开机一次性决策,
不能运行时降级。**结论:A′ 出局;根治只能在单连接复用模型内做**(红线 2,§6)。
### 1.5 与主流 offload 驱动的代码级对照("是不是过度设计"的正面回答)
**对照对象**: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` = **7 个函数**(4 公开 + 3 内部)/ 8 个 per-socket 数组。
可省:`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)、登记/执行分离(§4.2,E17)。
**真要大幅减掉这套机器,唯一杠杆是芯片**——而 §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. 设计原则(不变量)
| # | 不变量 | 违反的后果 |
|---|---|---|
| I1 | **单一事实源**:芯片状态的权威副本 = 驱动每 Socket 缓存,唯一刷新点 = `net_poll` 每轮全量刷新;net 层 8 态 `net_sock_state_t` 只是"会话投影"(展示/门控),决策点读缓存或主动回读 | 有损镜像产生"镜像说 A、芯片是 B"的决策窗口(§7「有损镜像决策窗口」) |
| I2 | **单一重建所有者**:唯一周期性监听重建决策者 = `net_reconcile_tick` 形态③④;事件处理器与沉降登记**只置事实、不动作** | 多所有者竞争——"谁先看到 DISCONNECT 谁重建"→ 抢先 relisten 吞掉慢消费者的 EOF(B10) |
| I3 | **事件只负责提前唤醒,正确性靠回读 + 有界超时** | 纯事件驱动 → 事件被读清消费竞态吞掉后监听静默死亡(TRAP-NET-17;E14 复核:事件非芯片侧丢失,是被消费) |
| I4 | **监听常驻 = 软件承诺**(`auto_relisten`),不是芯片属性 | 出向连接被误判孤儿 / 监听永久失效 |
| I5 | **复用前清理内联进事务**:`sock_reset` = 排空 RX → CLOSE → 排空事件(quiescent)→ 等 byte0=00H | 带非空 RX 关闭 → 拆链停滞、SYN 被吞、级联自持(E4 变体 C 0/20) |
| I6 | **分层**:驱动持事实与机械;net 层持策略与所有权;应用层零自愈责任 | 应用层重试/手动自愈 = 又一层补丁(原引用号在 §7 无定义行,删号留事实) |
| I7 | **全局中断寄存器唯一读者 = `net_poll`**(§7「全局读即清搁浅事件」;测试/台架代码同样禁自行读取,等事件一律直读本 Socket SINT) | 吞掉其它 Socket 的 CONNECT/DISCONNECT/RECV_OK 边沿事件(B3 级事故) |
| I8 | **不留未证明的复杂度**(TRAP-NET-20 铁律) | 正面执行:U15 内化不立项(E6 命中=0);TSL 变体只作待用补丁(E12) |
**去抖 + 冷却**:失配连续 N 轮才动手(分级,见 §4.1)+ 动手后 1s 冷却。去抖与冷却本是
"镜像世界"的自偿成本(§7「去抖冷却自偿成本」)——判据改读驱动缓存后,探针侧去抖已无必要(缓存无瞬态噪声);
reconciler 保留分级去抖,因为形态① 要让位 M3 窗、形态② 要覆盖芯片 10ms 级可见窗(E2)。
## 3. 架构总览
### 3.1 分层职责
| 层 | 持有什么 | 关键接口 |
|---|---|---|
| 驱动层 `Drivers/BSP/CH395F/ch395f.c/.h` | 每 Socket 状态缓存 + 类型化回读/谓词;每 Socket 事件读即清护栏;复合复位状态机;命令完成判据(状态回读);链路态缓存 | `ch395f_sock_state_cached` / `ch395f_sock_events` / `ch395f_sock_reset_begin` / `ch395f_sock_reset_poll` / `ch395f_cmd_done` / `ch395f_glob_event_feed` / `ch395f_link_up_cached` |
| net 层 `net_socket.c` / `net_events.c` / `net_listener.c` / `net_pending.c`(netTask 单所有者) | fd↔Socket 映射、消息队列串行化、挂起 recv/send;事件分发与回调;会话投影与粘滞电平;单一 reconciler;监听生命周期(armed/D10/settle);同步复位包装 | `net_poll` / `net_reconcile_tick` / `net_listener_request` / `net_listener_settle` / `net_listener_tick` / `net_sock_reset_sync` / `net_drop_conn` |
| 应用/服务层(`ui_net_sink` / `iec104_serv` / `lftpd` / `time_sync`) | 只管 fd 与数据;放弃会话走 `net_drop_conn`;EOF 不得 `net_close` 监听 fd | — |
### 3.2 主循环自愈三 tick
| tick | 节奏 | 职责 |
|---|---|---|
| `net_poll` | 主循环每轮(≈1ms) | INT# 引脚 → 全局事件汇聚(唯一读者)→ SINT 分派 → **驱动缓存全量刷新** → 链路位喂驱动 → 步进 `ch395f_sock_reset_poll` |
| `net_process_messages` | 每轮 | 开头按序跑三个非阻塞 tick:`net_listener_tick`(D10 重建**唯一执行者**)→ `net_pending_tick`(结束挂起 recv)→ `net_send_pending_tick`(推进挂起 send),随后排空 RPC 消息队列 |
| `net_reconcile_tick` | 100ms | 唯一周期**对账**——判据在此,重建动作在 §4.2 唯一执行者(§4.1) |
### 3.3 状态模型(会话投影)
- `net_sock_state_t` 8 态 = **本层会话投影**,非芯片状态事实源(I1)。
- `session_dead`:"会话已结束"的粘滞**电平**——消费者不再被抢先 relisten 饿死;
relisten / 形态④ / listen / RESYNC 刻意不清它(契约注释在 `net_types.h`)。
- `lost_conn_seen`:M3 可疑电平(§4.1.6)。
- `last_peer_port` + `released_abnormal`:幽灵甄别与异常释放簿记。
### 3.4 全景图
![网络不稳根因总览](../images/网络不稳根因总览.svg)
——七个叠加的芯片怪癖与现架构的拆法落点(证据链见陷阱记录与 §5.2)。
## 4. 机制详解
### 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 无长尾) | 1 次 SPI 读 |
| ③ | LISTENING ↔ 监听型 fd 而芯片不在 LISTEN | 登记重建请求(动作在 I2 唯一执行者 `net_listener_tick`) | 2 轮 | 无(转交) |
| ④ | 本层 CLOSED(auto_relisten)而芯片**不在** LISTEN | 登记重建请求(同 ③) | 2 轮 | 无(转交) |
| ⑤ | ESTABLISHED ↔ 芯片已回 LISTEN(静默回收;§7「静默回收无人认领」) | RESYNC 语义只修本层(转登端口 + 取消挂起 recv/send),零命令 | 2 轮 | **零** |
| U11 | 本层 CLOSED ↔ 芯片**正在** LISTEN | 只重同步本层为 LISTENING(转登端口) | **不去抖、不占冷却** | **零** |
⚠ **U11 不是形态④**。两者判据同族(本层 CLOSED + 监听型 fd)而结论相反:
**芯片在听 = 本层滞后 → 只改状态;芯片不在听 = 监听真没了 → 登记重建**。搞混的代价就是
U12 同源事故——对正听着的 Socket 重下发 open+listen 会吃 USE 错、走进关闭分支,
把健康监听器关掉。
表注:形态② 命中时的对端端口回读是本 tick 唯一按需 SPI。
#### 4.1.4 每个形态为什么长这样
**① 连接已失**:成因 = 连接被拆而 DISCONNECT 没被 netTask 收到,本层停在 ESTABLISHED、
消费者挂在 recv 上等永远不会来的数据。动作复用断开状态机(含陈旧事件甄别 + 状态迁移 +
事件派发),**只置事实、不做重建**(重建交 ③④)。判据是**"或"**(`open` 仍可为 `0x05`、
`tcp_state=0x00` 即成立)。⚠ 判读只认**设备日志**的 `chip closed` 行,**端口可达性不是判据**
(E18 方法论第一条)。
**② 握手已完成、事件没收到**:成因 = 芯片做完三次握手而 CONNECT 没送到,本层以为在听、
应用不会 accept,数据全堆在芯片 RX,应用侧**完全看不见**这条连接。⚠ **幽灵甄别
(TRAP-NET-20)是本形态最凶处**:拆链收尾窗口里芯片状态会**如实**显示 ESTABLISHED
(旧连接未拆净,E15 契约,非谎报),此时补 accept 就把新数据发给已死的旧连接 = 生产数据被
吞的直接成因;故仅当"对端端口非零且 = `last_peer_port`"时拦截,`peer==0` 不拦截
(对端端口提交有滞后,不得因此挡掉救援)。去抖 1 轮 = 最激进,因为它是"白捡的救援"、代价小。
⚠ 本形态**未经负向注入验证**(需吞 CONNECT 事件),依据是幽灵甄别 + E2/E10 台架记录,
**勿写成"已验证"**。
**③ 监听器失位**:成因 = 芯片上的监听器没了(close / 超时 / 回收)且没发事件,本层还以为
在听 → 端口拒连。最干净的靶 = 空闲监听器(无连接、无消费者)。
**④ 孤儿监听**:成因 = 会话结束、本层已收口 CLOSED,但监听没重建成功(或重建事件丢了)。
与 ③ 的差别**只在本层状态**:③ 是"以为还在听",④ 是"知道会话结束了、但监听器本该在"。
**它是常态唯一在干活的形态**(归因见 §4.1.5)。
**⑤ 芯片静默回收**:成因 = 芯片**自己**把 Socket 收回 LISTEN 且**不发 DISCONNECT 中断**
(台架实证),本层挂着一个已不存在的"活连接"。动作纪律 = **零芯片命令**:只改本层
state、转登 `last_peer_port`、清 `lost_conn_seen`、**取消挂起 recv/send**(必通知,防调用方
挂死)。为什么绝不能顺手 close + 重 open:芯片那个监听器是**健康的**,重下发会摧毁它 →
端口长期拒绝连接(实测 170 s 拒连;与 U11 同一条纪律)。
注入验证还顺带量出两个硬事实(§5.2 E18):状态机漏步进会 **SPI 风暴** → `[ERR][WD] task
stall`;芯片 close 后停在 **FIN_WAIT_2(`0x05/0x06`)≈1 s**(E15 的 2MSL 长尾)——这条时间
尺度就是 §4.3 沉降上限必须存在的理由。
#### 4.1.5 为什么 ①③⑤ 常态 0 命中
消费者驱动的挂起 recv / recv EOF 比 100 ms 对账**更快**,多数场景先把本层收口成
CLOSED → 由形态④ 接手。关键在两条快路径的判据(`net_pending.c` 的挂起 recv 结束判定 +
`net_socket.c` 的 recv 快路径):**只看本层 `state`、从不读芯片**——所以只要"有人在等",
事件流就先赢,①③⑤ 永远轮不到。推论就是职责边界:**①③⑤ 管的是"没有人在等"的场景**
(空闲监听器被静默 close、连接被静默拆掉而无人 recv、芯片静默回收),与 ④ **互补而非
重复**。→ **三条均不可降级为只留痕**;⑤ 的"取消挂起 recv/send"尤其不可省,否则消费者在
死连接上干等到 deadline。
#### 4.1.6 M3 可疑门
`handle_disconnect_event` ESTABLISHED 分支:芯片 `0x05/0x00`(Socket OPEN 但 TCP 槽 0x00——
活连接应为 0x04,逻辑矛盾态)且 `lost_conn_seen==0` 且 `recv_bytes==0` 且**芯片 RX 有在途
数据**(不可毁的消费者资产,与 closed-with-data 救回同源)→ 不释放,置可疑电平,
交形态① 电平二次确认;双锁存 = 强证据直接放行。
风暴态免疫(E9 R3:预置残渣后 `n30` 0/50;`:21 @0ms×50` 50/50 零误伤——健康 FIN 芯片为
0x07、静默回收为 0x00/0x00,均不命中判据)。
### 4.2 监听生命周期(单一所有者:登记 / 执行分离)
- **`auto_relisten`**:`net_listen` 成功即置位;所有重建路径的门控标志(I4)。
- **`net_listener_armed`**:唯一监听门控。⚠ **必须放行 ESTABLISHED**——它是单连接模型
的"待 accept"电平;漏放行时 i104 的 200ms 轮询抢不过形态② 去抖而整场饿死(E10)。
- **登记侧 `net_listener_request(fd, reason)`**:只置意图(`reason` 仅用于留痕归因),
**不碰芯片**,因此**不需要状态门**。登记点 5 处:reconciler 形态③④ / accept CLOSED 分支 /
recv EOF / `net_drop_conn` / 挂起 recv 断连。已在请求中**不重置重试时刻**(防饥饿)。
- **执行侧 `net_listener_tick()`**(netTask 每轮):**唯一**调用 `net_relisten_locked` 的地方,
门集集中一处——控制块有效 + `in_use` + 状态 ∈ {LISTENING, CLOSED} + 监听型
(`auto_relisten` / STREAM / `local_port≠0`)+ **芯片已在 LISTEN 即视为请求已达成、静默了结**
(U12 的结构性防护,读驱动状态缓存零 SPI)+ **挂起保险**(`net_pending_active` /
`net_send_pending_active` 命中则跳过,防重开 Socket 摧毁在途连接与在途传输)+ 30ms 节奏 /
2s 预算(D10)。"预算耗尽"不是终态——reconciler 形态③④ 周期重新登记。
⚠ **门集只在这一处**:任何"当场动作 + 自带门"的写法都等于把 U12 风险复制到每个调用点
(漏一处即事故)。
- **消费者驱动即时登记 ×3**(accept CLOSED 分支 / recv EOF / 挂起 recv 断连):与周期兜底
构成双层——**不可删**(台架实测:删掉后 CLOSED 窗口拉长到对账周期 + 隔离期,期间调用方
高频 `net_accept` → `[WD] task stall`)。动作延后一轮(≤1ms)执行,恢复窗与当场动作
持平(E17)。
- `net_listener_settle(fd, mode)`:登记"会话已结束,重建前需沉降/隔离"——**只置事实**,
动作只在 `net_listener_tick` 发生(I2)。
### 4.3 沉降与排渣隔离(TRAP-NET-18)
- 两档:**GRACEFUL 100ms 底**(正常释放)/ **QUARANTINE 400ms 底**(异常释放:芯片
TIMEOUT、FTP ABORT);**800ms 上限只留痕、不放行**。
- **800ms 上限为什么不放行**(E19 反事实 A/B):芯片未回 CLOSED 时强行 `open` 注定吃
USE 错(TRAP-NET-09),放行与不放行的端口恢复时刻**同一分布**——恢复由"芯片何时回
CLOSED"决定。**"监听器不会永久失效"的真正兜底 = D10 预算到期 + 对账形态④ 重新登记**,
不是放行;勿再把本处当兜底。上限到点只打**一行** `keep waiting` 且**隔离标志不清**
(下一轮继续排空残渣);留痕只留首末(每轮都打会 30ms 刷两行)。
- **隔离期持续排空 RX**:中止后残渣(实测 rxdrain=2048 = 芯片单 Socket RX 缓冲满容,
E4)会被下一条连接读走 → 隔离期边等沉降窗边排空。
- **GRACEFUL 无条件化**(M1;§7「沉降门放大器」):`recv_bytes≠0` 门是背靠背失效循环的放大器
——被陈旧 DISCONNECT 杀死的零数据会话免沉降 → 残渣杀下一条连接、自持循环;无条件化后
`n30` 8%→96.7%(E7b)。⚠ M1 必须与 M4-lite(删沉降段全局读即清)**同批**,否则沉降成为
每次断连热路径后放大「全局读即清」的搁浅面(E7 实测教训)。
### 4.4 驱动原语
- **类型化回读 + 缓存**:`ch395f_sock_state_cached(sn)` / `ch395f_get_sock_state(sn)` /
`ch395f_sock_is_listening(sn)`;`open=0` 时 `tcp_state` 返回哨兵 `0xFF`
(byte0 门控 byte1 的收口);sn>7 边界返回 CLOSED+哨兵。唯一刷新点 = `net_poll`
每轮全量(单次回读 18.1µs,E2)。
- **事件护栏**:`ch395f_sock_events(sn)` 只读本 Socket SINT(读即清),永不触碰全局
寄存器(I7)。测试/台架代码等事件同样直读 `ch395f_get_sock_int_status(sn)`——
不用全局位图门控(门控观察分不清"芯片未锁存"与"全局位被读清")。
⚠ **listen/relisten 的排事件读清必须在 LISTEN 命令之前**(E14:post-listen 读清会吞掉
即时到达的新连接 CONNECT——生产与台架同源根除)。
- **复合复位状态机**:`ch395f_sock_reset_begin(sn, mode)` / `ch395f_sock_reset_poll(sn)`
——非阻塞、`net_poll` 每轮步进(同步包装 600ms 放弃后复位机也不搁浅)。
序列(E4 定案):① 读空 RX(**取读空**——字节数可观测可留痕,保芯片"读出残留数据后再
关闭"的契约,`docs/芯片手册/CH395F.md` §5.52 bit3=1)② CLOSE_SOCKET ③ 排空本 Socket 事件
(drain-until-quiescent:连续两轮无新位)④ 轮询 byte0=00H。**LISTEN 态 Socket 的 CLOSE 是
二段**(E2:一次 CLOSE 只拆监听,byte0 恒留 05H 不自落)——状态机内置二段补发(只补发
一次)。GRACEFUL 100ms / QUARANTINE 400ms 底,600ms 封顶。
- **命令完成判据**:`ch395f_cmd_done` = **状态回读**(open 完成→byte0=05H;listen
完成→TCP=01H;close 完成→byte0=00H)——GET_CMD_STATUS 对这三条命令无判别力且陈旧
错误码(0x17)跨命令持续存活(E3);GET_CMD_STATUS 仅保留内核前慢命令与"命令级被拒"
检测(被拒码是新值覆盖残留,方向不受歧义影响)。
- **链路态缓存**:`net_poll` 读全局后把 bit0~3 喂 `ch395f_glob_event_feed`;PHY_CHANGE
→ 重读 PHY 更新 `link_up` 缓存 + 变化计数;reconciler 订阅留痕(运行期拔网线不再静默)。
- net 层同步包装 `net_sock_reset_sync`(listen/connect open 失败重试、close 快路径)
——忙等统一,close/connect 路径不再读清全局寄存器(I7)。
- ⚠ **监听重建(relisten)不接进本原语**:现序列 = 一次 CLOSE + CLOSE 前排空 + 等 CLOSED,
**不等价**于监听隔离段的"隔离窗内持续排空/关闭"(残渣在 CLOSE 之后才落进 RX),也不与
"同一芯片 Socket 被上层立即复用"共存(复位机会拆掉刚建的监听)。E16 实测回退,
三条前置语义与回归数据见 §8 延后项 1;在此之前 `net_listener.c` 保留自建序列。
### 4.5 recv 语义与挂起(pending)
- `net_recv()` 默认**无限阻塞**;`net_recv_nb()` + `net_setblocking(fd, on)`(模式是
socket 属性);`net_recv_to()` = 显式预算便捷入口(`timeout_ms=0` 仍拒 INVAL)。
- 唤醒源 = 数据 / EOF / close / 网络重建 CONNRESET;会话死亡兜底 = 形态①(≈400ms)。
- **挂起 recv / send**:阻塞 recv/send 不独占 netTask(挂起登记 + 主循环节拍完成),
架构与完成语义 → `docs/网络/Net层阻塞模型架构设计.md`。
**挂起超时留痕探针**(编译期开关 `NET_PENDING_TRIPWIRE`,默认关):挂起超阈值只
`[ERR]` 留痕一次(完成逻辑缺位),不伪造 TIMEDOUT。
- **术语**:"结束挂起 recv" = `net_pending_complete` 把结果(字节数 / EOF(0) / 错误码)
回传给调用任务并唤醒它,挂起槽随之释放。
- **文档契约**(嵌入式特有):IWDG `required_mask` 内任务**禁止无限阻塞 recv**——
必须 `net_select` 超时或 `net_recv_to` 显式预算。
- 上层 32 轮盲收(`ui_net_sink` / `iec104_serv`)保留现状(E6 裁决,§4.8)。
### 4.6 `net_drop_conn`——应用主动拆会话保监听(队头阻塞收口;异步收口 2026-10-25)
- 语义(**返回值 = 拆除与重建已登记**,不代表芯片已拆完):`net_pending_settle_close`
先结束挂起 recv(回传结果 + 唤醒调用方)→ 监听型(`auto_relisten`)fd:
`net_listener_settle(QUARANTINE)` + `net_listener_request(REQ_DROP)` 登记即返,
芯片拆除(FIN 关闭 + 排渣 + 排空事件 + 等 byte0=00H)与重建由 `net_listener_tick`
按 30ms 节奏执行(与被动超时/断连释放同一台机器),失败交 D10 + 形态④;
非监听 client fd 无监听可保,仍同步 `net_sock_reset_sync`(600ms 封顶)。
端口恢复窗 ~0.4~1s,与被动断连恢复窗(E1 发现 A)同型。
- **为什么监听型不得同步等待(探针窗口心跳缺口定位时的论证,随结论一并留档)**:旧形态在
`NET_MSG_DROP_CONN` 处理器内同步跑 `net_sock_reset_sync(QUARANTINE)`(2ms 节奏、
400ms 底/600ms 顶,自家注释原记「等待期 netTask 有界占用 ~400-600ms」),netTask
的 1ms 主循环冻结在同一迭代内:NET 位心跳停摆 + 串行总线整窗停摆(UI 推流/FTP/
104 在途消息回执顺延、调用方在其分片/无限等待中不见回执)+ 对时调用方自己睡在
`net_wait_result` 等回执、期间无分片心跳——五位同窗缺席 → 连续 8 个 100ms 抽样
停喂 → 声明 `[WD] task stall`(V5 时代 req=0x37,缺席位恰为 NET/FTP/UI/对时,
快照只剩 bit0,即日志中的 `mask=0x00000001`),缺口 800ms~1s,距 IWDG 最坏超时
1701ms 余量仅 ~0.7s;同场隔离负载(纯 FTP 120s)0 次——因为被动拆链走的本就是
异步沉降机(D10「重建必须非阻塞」条令),只有主动 drop 一条路径留在同步原语里。
- **评估否决的方向——「缩短重建重试(5×2s)」**:该退避链在
`time_sync_open_listener()` 的 bind/listen 失败路径,全程走
`time_sync_delay_fed()` ≤50ms 分片喂心跳,且**不在 drop→拆链→重监听的心跳缺口
链上**;缩短它不消除本缺口,反而加大对芯片沉降窗的撞车风险(TRAP-NET-09:关闭
后需沉降才能重开),故不采。
- **竞态与信源核对**:① 主路径置 CLOSED 后迟到的 DISCONNECT/TIMEOUT 事件被
`net_events` 的状态门拦截(只认活会话态),不会把刚登记的 QUARANTINE 降级为
GRACEFUL;② 零新增读清点——隔离窗内两处本 Socket 读清仍在 `net_listener_tick`
原位置(§4.10.2),全局寄存器唯一读者仍是 `net_poll`;③ 生产调用者全仓仅
`time_sync.c`(不消费返回值,accept 轮询天然容忍 WOULDBLOCK 窗)。
- **幂等**(非 ESTABLISHED/CONNECTING 直接返 0);**EOF 路径不得调**
(`auto_relisten` 已接管,再调属重复拆)。
- 芯片物理边界(台架定案,异步收口逐字不变——只移动「等待发生在哪」):
`close` **无 RST 语义**——对端配合(收到 FIN 后其内核 close):`TIME_WAIT` ~150ms
→ 形态④ relisten(恢复全程 ~500ms);对端内核存活但不 close:芯片停
`FIN_WAIT_2` 长尾,**监听恢复受限于对端 close**,兜底 = 芯片 TIMEOUT/KeepAlive 拆
僵尸 30~90s(E13)——不是应用可加速的路径。
- 回归锚点:`TC-NETSOCK-240efg / 321 全组 / 460ef`(登记式、netTask 零占用、
client fd 旧形态保留);台架判据 = V5 复跑(e02_probe + FTP ≥30min)零 stall,
见 `docs/测试说明/实机测试实施指南.md` §4。
### 4.7 KeepAlive(服务器侧)
- `handle_connect_event` LISTENING 分支开启,参数 **60s/5s/3**(规定位置见 `docs/芯片手册/CH395F.md` §5.56;
参数须 500ms 倍数且 IDLE>INTVL,TRAP-NET-06)。
- 实测判死 **≈30s**(早于手册公式 75s——芯片定时语义与公式不符,以实测为准;短参数
6s/2s/2 → ≈5.1s 证明参数可按需缩放);静默但**存活**的对端不误杀(对端会 ACK 探测)。
- 消费链路:`TIM_OUT` → `handle_timeout_event` QUARANTINE → relisten → 端口恢复。
- **僵尸会话自愈窗**(E13):对端静默死亡(拔线 / PC 崩溃 / 进程被杀,无 FIN/RST)→
芯片侧 ESTABLISHED 僵尸占住单连接监听——有在途 TX ≈30s(重传耗尽)/ 纯空闲
≈75-90s(KeepAlive)自动清除;**无需冷启动**(板上 RSTI←NRST,烧录/MCU 复位即芯片
硬件复位)。台架做连接类实验的控制手段 = 下一组开跑前静置 ≥90s 让僵尸清窗。
### 4.8 U15 探针(观察项,宏=0)
- `NET_U15_PROBE` 插桩留仓(产品构建编译消失):`net_poll` 每 32 轮对在用 fd 探测
`GET_RECV_LEN>0 && !readable`(两段确认),armed 日志 + 命中低频日志。
- **E6 裁决:命中=0 → 内化不立项**,上层盲收保留现状(I8);将来出现数据面失配再武装重裁。
### 4.9 FUN_PARA bit3=1 的必要性与安全边界(D4)
> 结论:**必须保留 `0x08`**。本节以手册原文 + 实测证据回答"bit3=1 引出这么多问题,
> 是否该改回 0",并给出软件侧的收口位置。
**① 必要性 = 硬性必需,不是可调项。** 手册对 bit3 的分流写得很直白:
- `docs/芯片手册/CH395F.md` §5.52 注释②:「此位为 0 表示在产生 SINT_STAT_DISCONNECT 或者 SINT_STAT_TIM_OUT 中断后,
CH395 会主动关闭 Socket。……**关闭 Socket 就意味着 CH395 会将此 Socket 内部缓冲区和
一些相关变量清除**,在某些应用中,如果 CH395 内部自行关闭 Socket 可能会导致残留的数据
被强行清除。这种情况下可以将该位置 1,**由单片机将数据读出来,然后再关闭 Socket**。」
- `docs/芯片手册/CH395F.md` §5.23 L572 同向:「为 0 则……主动将 Socket 状态置为关闭状态**并将相关缓冲区全部清除**;
反之则不会对 Socket 状态和相关缓冲区做任何操作,以方便外部单片机……**将残留的数据读出**。」
实测现场(TRAP-NET-20 判别探针):`st=0x05/0x00` 且 `recv_len=1027`——
Socket 仍 OPEN、芯片 TCP 已 CLOSED、**RX 缓冲里 STOR 载荷一个不少**。这正是 bit3=1 按规格
保留现场,不是芯片异常(`docs/问题记录/CH395F_Trap_Records.md` TRAP-NET-20「定案二」)。
**② bit3=0 的代价是「硬件保证的数据丢失」,且不可在软件层补救。**
本产品最典型的流量形态是「发完即 FIN」:ftplib `storbinary` 语义 = `sendall` 发完立即关
数据连接、回控制通道等 226。芯片把最后一段数据落进 RX 的同时抛出 DISCONNECT:
- bit3=1:RX 数据在,MCU 来得及读(TRAP-NET-18 的「排空 RX 再 close」修复才成立);
- bit3=0:芯片**当场**关 Socket 并清缓冲 → MCU 永远读不到尾部 → 文件被截断却仍回报 226
(静默损坏)。这是 bit3=0 的**必然结果**,不是概率问题。
即:改 bit3=0 等于用「一个硬件保证、不可修的数据丢失」去换「一个软件侧可修的关闭义务」,
严格更差。:8888 对时包、:7003 远显推流同样是「数据 + 随即关闭」形态,一并受害。
**③ bit3=0 也并不能真正消除孤儿态。** 芯片自动关以**收到** DISCONNECT/TIM_OUT 为前提,
而 TRAP-NET-17(E14 复核)已实证快速复连场景这两个中断会被读清消费掉 → 事件丢了,
bit3=0 同样停在已关/半关态,孤儿 Socket 照样存在。它只是降低出现概率,代价却是第②条。
**④ 所以「这么多问题」不归 bit3=1。** 断开后停 `0x05/0x00` 是 bit3=1 的**合法后果**(D4);
真正造成永久拒连、消费者卡死、EOF 被吞没的,是软件侧没把「该由 MCU 关 Socket」这条义务
落到唯一所有者(§1.3),以及 U12 的「relisten 的 LISTEN 失败不 close 芯片 Socket」与 open
失败路径不对称。bit3=1 只是把义务显式化,把原本被芯片掩盖的架构欠债照了出来。
**⑤ 安全处理 = 把 bit3=1 的契约在软件侧收口**(逐条对应上面的缺口):
| 契约要求 | 对应缺口 | 收口手段(现状) |
|---|---|---|
| 「会话结束」必须**谁都能看见**,不能只在边沿那一刻 | EOF 边沿化:抢先 relisten → recv 永久 `WOULDBLOCK` | `net_sock_t.session_dead` 粘滞电平,`net_recv_locked` 判据 `state==CLOSED \|\| session_dead`(§3.3) |
| 关闭 Socket 必须**一定有人做**、且只做一次 | 重建所有者无法唯一化 + U12 的 LISTEN 失败不回 close | 重建**判据**唯一化 = `net_reconcile_tick` 形态③④;重建**动作**唯一化 = `net_listener_tick`(§4.1 / §4.2) |
| 关闭姿势必须正确 | 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 读即清信源审计(谁在读、会不会互吞)
> 口径散在多处代码注释里,**没有总表时"想再加一处读清"的人不会先去读那几处**。
> 本节就是那张挡人的表:现存读清点全量清单 + 新增读清的准入判据。
#### 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)。
这就是"全局寄存器只有一个读者"这条约定的全部理由。
#### 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 三条已根除的互吞形态(勿复活)
| 形态 | 危害 | 现状 |
|---|---|---|
| 遍历 **8 个 Socket 全清 + 再读清全局**(`net_listen_locked` / `net_relisten_locked`) | 见 §4.10.1——别的在用 Socket 边沿永久丢失;且它在**每次断连的热路径**上 | 已删(多连接时代的调试遗留被原样搬进单连接模型,**不属于任何 Trap 的修复**) |
| 沉降段的全局读即清 | 每 30ms 一轮就搁浅其它 Socket 的 CONNECT/DISCONNECT/RECV_OK;沉降成为每次断连热路径后放大成 **FTP 控制/数据通道互杀**(E7 实测 lftpd 5s 超时) | 已删(M4-lite,与 M1 同批) |
| 排事件放在 `tcp_listen` **之后** | listen 后读清把**快速复连新连接的 CONNECT** 当场吞掉 | 已挪到 LISTEN **之前**(生产与台架同源根除,E14) |
#### 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 参数表
| 参数 | 值 | 依据锚点 |
|---|---|---|
| `NET_RECONCILE_PERIOD_MS` | 100ms | E2:每轮全量刷新成本 ≈72µs;更密的周期会把 uiTask 最坏单轮推高(`net_listener.c` 注释留痕) |
| 去抖(分级) | ②=1 轮 / ③④⑤=2 轮 / ①=4 轮 | E2:LISTEN 可见 ≤10ms 无长尾 → ② 可单轮;①=4 轮 ≈400ms = M3 让位窗 |
| 冷却 | 1s | 防重建风暴(E8 风暴签名) |
| GRACEFUL / QUARANTINE / 留痕上限 | 100ms / 400ms / 800ms | TRAP-NET-18 二阶段实测;上限必须 < D10 2s(§4.3) |
| D10 | 30ms / 2s | TRAP-NET-09/-20:沉降窗不定长;2s 是预算不是终态 |
| `sock_reset_sync` 封顶 | 600ms | E2:LISTEN 态二段 CLOSE ~180ms 落定 + 余量 |
| KeepAlive | 60s / 5s / 3 | E11:判死 ≈30.0s;不误杀空闲会话 |
| recv tripwire | 60s | §4.5 挂起留痕探针(不改语义) |
| 恢复窗(正常断连→监听可用) | 0.3~1s | E1 发现 A:释放只置事实 → 等周期 tick → SYN 静默丢 → 客户端 1s RTO → `:7003` 复连 p50≈1020ms |
### 5.2 实验锚点速查(E 编号 = 台架实验序号)
> **`E` + 序号 = 台架实验编号**(E = Experiment),不是错误码、也不是小节号。每个实验遵循
> 「假设 → 方法 → 判据 → 它门禁哪个决策」四段式;本表只给**方法与结论**,原始记录(假设、
> 原始日志、统计)已归档,取用命令见 §12。
> ⚠ **这些编号是冻结标识**:`net_socket.c` / `net_events.c` / `net_listener.c` 注释、
> `test/host/e02_probe.py`、`test/host/e6_soak.py`、`docs/问题记录/CH395F_Trap_Records.md`
> 都直接引用它们,**重排或改名会断链**。
> ⚠ 末行 **不是实验编号**——它记录的是**历史审计发现**
> (带连字符的 `E-<nn>` 族,属主文档已于 2026-10-15 删除并冻结),列在此处只为记录该发现的**收口台架**。
> 区分规则:**带连字符 = 审计发现,无连字符 = 自愈实验**。
| 编号 | 验证了什么(方法) | 结论(现状口径) | 落点 |
|---|---|---|---|
| **E0** | 直接读生产常态串口日志(开机后 ~3.5 h,零台架成本) | 常态下对账**零命中**(冷路径);日志噪声主要来自 D10 逐轮留痕 | reconciler 热路径成本可接受;重试日志只留首末 |
| **E1** | 现网固件 + PC 压测:`n30_repro.py` 背靠背 STOR、`ftp_regression`、`:7003` 快速复连、iec104 连断 | 恢复窗 0.3~1 s(`:7003` 复连 p50≈1020 ms);背靠背 STOR 存在失效循环 | `n30_repro` 纳入常备回归 |
| **E2** | 裸机 bench(`ch395f_reuse_bench.c` + DWT 计时):`GET_SOCKET_STATUS` 单次耗时;LISTEN 命令后回读看首次可见 TCP=01H 的延迟 | 单次回读 **18.1 µs**(全量刷新成本可忽略);LISTEN 可见延迟 ≤10 ms 无长尾;**LISTEN 态 CLOSE 是二段**(一次只拆监听,byte0 恒留 05H) | 驱动缓存刷新策略;形态② 去抖 1 轮;`sock_reset` 内置二段补发 |
| **E3** | 同场固件,OPEN / LISTEN / CLOSE / SET_* 五条命令各 50 样本,测 `GET_CMD_STATUS` 判别力 | OPEN / LISTEN / CLOSE **无判别力**,陈旧错误码 0x17 跨命令持续存活;SET 类**完全不更新** | 命令完成判据改用**状态回读**(`ch395f_cmd_done`) |
| **E4** | 裸机 3 复位变体 ×20 轮:先造污染会话(发 3000B 后 RST),复位后**立即**重开监听并上传 4096B 图案载荷,逐字节校验 | 变体 A(排空 RX→CLOSE→排空事件→等 byte0=00H)**20/20**;变体 C(什么都不做)**0/20** 级联自持;实测 `rxdrain=2048`(芯片单 Socket RX 缓冲满容) | §4.4 复位序列定案;两档沉降**都必须排 RX** |
| **E5** | `:7003` 上 connect→发 1B→对端 FIN→等 3 s→复连 ×10,找"芯片静默回收回 LISTEN 却不发 DISCONNECT" | 20 次 FIN **全部**正常产生 `DISCONNECTED`;静默回收在累计 ~110 次连接中断里**零复现** | 缺口真实但触发罕见 → 形态⑤ 顺带修,不单独立项 |
| **E6** | 插桩固件(`NET_U15_PROBE=1` + `test/host/e6_soak.py`):常态 + 每小时 `:7003`×10 + 压测,探"`GET_RECV_LEN>0` 但 `!readable`"失配 | 命中 **0**(~4 h 常态) | U15 观察项**不立项**(I8);上层盲收保留现状(§4.8) |
| **E7 / E7b** | 候选修改 M1(GRACEFUL 沉降无条件化)+ M2(门控换 `net_listener_armed`),叠加/去掉 M4-lite(删沉降段全局读即清),跑 `n30` | M1+M2+M4-lite 把 `n30` 成功率 **8% → 96.7%**;**M1 单独上会放大全局读即清的搁浅面(§7)** | 三者即现状(§4.2 门控 / §4.3 沉降);纪律:**M1 与 M4-lite 必须同批** |
| **E8** | 同固件同负载,**只变设备状态**跑 `n30` 矩阵(全新启动 / 累积后 / 仅 ST-Link 复位) | 故障率在 **2%~54%** 间随状态摆动 → 故障率 ∝ 芯片侧残留存量;抓到"风暴签名"三机制(§7「风暴态三机制」) | M3 可疑门 + `sock_reset` quiescent 排空;"沉降让位救回"红线 |
| **E9** | M3 可疑门判据三轮迭代(`0x05/0x00` + `lost_conn_seen==0` + `recv_bytes==0` + 芯片 RX 有在途数据) | 判据定案为**资产门版**;风暴态免疫 + 零误伤 | §4.1.6 M3 门 |
| **E10** | 不变量与机制定案后的全量台架回归 | 全绿;暴露 **`net_listener_armed` 必须放行 ESTABLISHED**——漏放行时 i104 的 200 ms 轮询抢不过形态② 去抖而整场饿死 | §4.2 门控定案 |
| **E11** | 临时固件开启服务器侧 KeepAlive(`CMD_SET_KEEPALIVE_SN`,60s/5s/3),用"禁用 PC 网卡"造"链路在、对端静默" | 真实可用;判死 **≈30.0 s**(早于手册公式 75 s,以实测为准);静默但**存活**的对端不误杀 | §4.7 KeepAlive 定案 |
| **E12** | SPI 缓存区 TSL 时序 A/B:手册要求"长度与数据之间"留 TSL(读 0.4 µs / 写 0.2 µs),本仓把命令头与数据打包进同一 DMA 突发(间隔=0),对照两臂 | 460 轮 / ≈58 MB 双向 SHA-256 **无可复现危害**、两臂无差异 | §6 红线 6;"两段+TSL"变体留作**待用补丁** |
| **E13** | 产品固件 + PC 网卡抖动注入,追"残渣风暴态"根因 | 根因 = **僵尸会话**(对端静默死亡、芯片侧 ESTABLISHED 未清、单连接监听被占),而非跨烧录残渣;`RSTI←NRST` 推翻"跨烧录存活";自愈链端到端 30~90 s 有效 | §4.7;PASV 预清缺口由 `sock_reset` 收口 |
| **E14** | 48 轮直读 SINT 矩阵复核"丢中断"说法(drain-int 现场抓取 + SINT 补读 ×3 + 超时归类) | **芯片侧事件丢失 = 0**;"丢失"实为 drain 读清消费 + 门控竞态 + 主机同步;幽灵态本轮 0/48 未复现 | §1 事实 2;生产 listen/relisten 排事件挪到 LISTEN 命令**之前**;TRAP-NET-17/-20 归因更正(机制保留不变) |
| **E15** | 幽灵态归因复核(WCH 官网 + 手册出处) | `0x04` + 旧端口 = 芯片**如实报告**旧连接拆链进行中,不是谎报:bit3=1 是自家配置(手册 §5.52②);拆链走 RFC 9293 §3.3.2 状态机(FIN_WAIT_1→FIN_WAIT_2→TIME_WAIT,2MSL 驻留);WCH 官方博客确认"断连即重连被拒"、官方方案 = 关闭完成后重开 | TRAP-NET-20 口径"谎报"→"如实报告";settle 等待 = 官方方案;幽灵甄别 = 护栏;I3 加强 |
| **E16** | 把 relisten 隔离段收口到驱动 `sock_reset` 原语(删 net 层自建序列与参数副本),同板 A/B 跑 `ftp_abort_probe` + `relisten_stress` + `n30_repro` | **收口失败、已回退**:失效面精确落在"中止后首笔复连";根因两条——残渣在 CLOSE **之后**才落进 RX(一次排空接不住);复位机"逼回 CLOSED"的目标与上层立即复用同一 Socket 冲突 | §8 延后项 1(三条前置语义);§4.4 警告;登记「relisten 沉降段原语化受阻」 |
| **E17** | **单一所有者收口**:`net_listener_try`(当场动作、**无状态门**,各调用点自带门)改为 `net_listener_request`(只登记意图)+ `net_listener_tick`(唯一执行者,门集含"芯片已在 LISTEN 即了结"),同板全量回归 | 全绿;**延后一轮(≤1 ms)执行不伤即时性**(恢复窗 p50 与当场动作持平);relisten 留痕减半 | §4.2 登记/执行分离;§9.2 门集;§11.1 判据 |
| **E18** | **负向注入打靶 · 三轮**(注入代码不入库):① 吞掉 DISCONNECT(在 i104 的 200 ms accept 轮询窗内 RST);③ 绕过本层簿记直接 close 芯片空闲监听;⑤ 开盲区吞掉芯片自发的中断 + 等芯片真 CLOSED 再 open+listen | **①③⑤ 全部命中并救回**;①⑤ 的前提会被更快的消费者路径消解 → 常态 0 命中;⑤ 还要先越过芯片 `FIN_WAIT_2`(E15 的 2MSL 长尾,实测 ≈1 s) | §4.1.4 / §4.1.5:**三条均不可降级为只留痕**;⑤ 的**取消挂起 recv/send** 不可省 |
| **E19** | **`force open` 反事实 A/B**(本层已收口 CLOSED + 芯片停 `FIN_WAIT_2` `0x05/0x06` + **对端挂住不关** = 队头阻塞场景;A = 现状放行,B = 只留痕不放行) | **放行与不放行的端口恢复时刻同一分布** → 动作在全部可产现场景内无可测收益;真正兜住"监听器永久失效"的是 **D10 预算 + 形态④ 重新登记**。两条实测坑:① 本层直接改 `state` **不会**进沉降窗(窗只由 `net_listener_settle` 开启);② `relisten retry budget exhausted` 在此被稳定复现 = 对端不配合拆链的签名,**非回归** | §4.3:800 ms 上限降级为**只留痕**(`keep waiting` 只留首末、隔离标志不清 → 继续排渣) |
| **队头阻塞审计**(非实验编号) | `test/host/e02_probe.py` 台架定案 `net_drop_conn` 收口后的时序 | 对端配合(收到 FIN 后其内核 close):`TIME_WAIT` ~150 ms → 形态④ relisten,恢复全程 ~500 ms;对端内核存活但不 close:芯片停 `FIN_WAIT_2` 长尾,**监听恢复受限于对端 close** | §4.6 |
## 6. 红线(明确不做的事)
1. **不改 EXTI/ISR**:全局事件寄存器整体读即清,ISR 读寄存器会把事件从 netTask 抢走
——正是 B3 事故形状。
2. **不启用多连接模式(FUN_PARA bit1)**:不支持多监听(A′ 出局);监听常驻只能软件承诺。
3. **不删消费者驱动即时登记 ×3**(§4.2;删除 → CLOSED 窗口拉长 → `[WD] task stall`)。
4. **不改单连接复用模型**:硬 TCP 栈原生主流形态(ioLibrary 无 accept、FreeRTOS reuse
同形),不是对 BSD 的偏离。
5. **SPI 分频 8、中断经 `net_poll` 集中读清**:PCB 约束 + I7(护栏是收窄读法,不是改路径)。
6. **不改 TSL 单突发**:E12 实测 460 轮/≈58MB 无危害;B 变体(两段+TSL)留作待用补丁
(已证明无吞吐/正确性回退)。
7. **不做盲收内化 / 不加 U15 对账**:E6 命中=0(I8)。
8. **EOF 时不得 `net_close` 监听 fd**:模块约定铁律(会连监听一起毁掉)。
9. **不把 DONE_CLOSE 当单次 CLOSE 的充分条件**:LISTEN 态 Socket 需二段(E2)
——走 `sock_reset`(内置补发)或保留 TRAP-NET-09 式重试。
10. **不把监听重建接进 `sock_reset` 原语**:三条前置语义未补齐(§8 延后项 1,E16)。
## 7. 编号发现速查(12 条,历史对照表)
> ⚠ **本节的条目号已于 2026-10-27 停发(编号规范 §9 批次一),正文/注释改用下表「条目名」列指代**;
> 下表保留编号列仅作历史对照(删表会让历史行的引用报「未登记前缀」假错),
> 全仓不得再新增对这些号的引用——代码注释与关联文档已改写为「条目名 + §7 落点」的语义形式(2026-10-27 批次一收口:
> 本文件残留 9 处正文引用(其中孤儿号 `F-DRV-9` 就地删号留事实)+ 1 处主机侧单测注释已全部转语义)。
| 编号(历史对照) | 条目名(现行指代) | 一句话结论 | 收口落点 |
|---|---|---|---|
| F-NET-2 | `静默回收无人认领` | "本层 ESTABLISHED 而芯片已回 LISTEN"无机制认领 | 对账形态⑤(§4.1) |
| F-NET-3 | `冗余监听 API 面` | `net_relisten` / `net_resync_listen` / `net_can_accept` 是冗余或过期 API 面 | 全部删除;监听门控统一 `net_listener_armed`(§4.2) |
| F-NET-4 | `iec104 过期门控` | iec104 过期门控 → 丢 CONNECT 探针对 :2404 从不运行 | 门控统一 `net_listener_armed`(§4.2) |
| F-NET-7 | `全局读即清搁浅事件` | 全局寄存器读即清残留会搁浅其它 Socket 事件 | I7 + `ch395f_sock_events` 护栏(§4.10) |
| F-NET-15 | `沉降门放大器` | GRACEFUL 沉降门 `recv_bytes≠0` = 背靠背失效循环放大器 | 沉降无条件化(与删沉降段全局读即清同批,§4.3) |
| F-NET-17 | `风暴态三机制` | 风暴态三机制(沉降自再生锁存 / 泵与救回互抢 RX / 芯片 OPEN 卡死放大) | M3 可疑门 + `sock_reset` quiescent + 让位红线(§4.1.6) |
| F-DRV-1 | `七处独立回读` | 同一芯片事实 7 处独立回读、三套去抖参数——单一事实源缺失是补丁膨胀的地基 | 驱动状态缓存 = 单一事实源(I1,§4.4) |
| F-DRV-4 | `close 序列两套变体` | "close+排空+等 CLOSED"两套同构变体参数漂移 | `ch395f_sock_reset` 统一(§4.4) |
| F-DRV-8 | `命令完成阻塞原语` | 命令完成等待是阻塞原语(`HAL_Delay` 忙等、4s 上限) | `cmd_done` 状态回读判据 + 非阻塞状态机(§4.4) |
| F-DRV-13 | `链路事件无人消费` | 链路事件四个全局位读清后无人消费,运行期拔网线完全静默 | `glob_event_feed` + `link_up` 缓存(§4.4) |
| F-MS-8 | `有损镜像决策窗口` | 芯片 11 态 → net 层 8 态有损投影,有损镜像必生决策窗口 | 镜像降级为会话投影(I1、§3.3) |
| F-MS-9 | `去抖冷却自偿成本` | 去抖/冷却是镜像世界的自偿成本 | 探针去抖已删(读缓存无瞬态);reconciler 保留分级去抖(让位窗需求) |
## 8. 延后项
1. **relisten 沉降段统一到驱动 `sock_reset` 原语(受阻)**:原语要能承接监听重建,先补三条
语义(缺一即回归,E16 实测数据见 §5.2):
① **窗口内持续清**:残渣在 CLOSE **之后**才落进 RX,只在 CLOSE 前排一次接不住——旧序列
每个 30 ms 重试轮都 CLOSE + 排空 + 排事件,整个隔离窗持续清,原语的"一次 CLOSE +
一次排空"与之不等价;
② **复用让位**:复位机的目标是把本 Socket 逼回 CLOSED,而 FTP 数据通道每笔传输都在同一
芯片 Socket 上重建监听;复位机未了结时它会去拆刚建的监听(WAIT_CLOSE 的二段 CLOSE
补发),并让 `net_sock_reset_sync` 600 ms 超时 → `close` 跳过排渣、`listen` 的 open
重试环退化成无等待空转;
③ **槽位可加入**:每 Socket 只有一个复位槽位,`ch395f_sock_reset_begin` 返 -1(已在运行)
不等于失败——同步包装必须**加入**已在跑的复位机,否则 `net_close` 走 "proceed
anyway"、排渣被跳过。
补齐 ①②③ 并各自回归后再接;登记 `docs/任务规划/登记簿.md` 条目「relisten 沉降段原语化受阻」。
2. **部分读→再查 recv_len 定向 TC**:旧"部分读归零"机理未证实(与手册 §5.34 及
TC-NET-201 相反),注释约束已降级为历史观察——以定向用例结案(陷阱记录「台架纪律」
节已挂该待办)。
3. **U15 观察项**:若将来出现数据面失配(RX>0 但 `!readable`),重新武装探针重裁(§4.8)。
4. **链路事件业务消费**:缓存与留痕已就绪;PHY_CHANGE → 自动重配的触发待产品需求。
## 9. 代码级事实清点
> 判读本架构或做决策时需要的**源码事实**。⚠ 只给符号名、不固化行号与行数——它们随重构
> 漂移,是"读代码前的地图"而**不是判据**;要判据请数符号,别数行。
### 9.1 会话投影的状态写入面
`net_sock_t.state` 的**全仓唯一写者**是 `net_socket.c`(13 处显式赋值 + 2 处 `memset`);
`net_types.h` 只定义、不写。因此"镜像说 A、芯片是 B"的窗口只可能由这一个文件制造——
这是 I1(镜像降级为会话投影)收敛成本低的根本原因。
### 9.2 关键状态门(做决策必须知道)
| 函数 | 状态门 |
|---|---|
| `net_listen_locked` | **只允许 BOUND / CREATED**——对已 CLOSED 的监听 fd 直接 `net_listen` 返 INVAL |
| `net_connect_locked` | **无状态门**(可在 LISTENING 上改成 CONNECTING) |
| `net_recv_locked` 快路径 | **无状态门** |
| `net_listener_armed` | 唯一监听门控;**必须放行 ESTABLISHED**(§4.2) |
| `net_listener_tick`(唯一执行者) | 门集**只在这一处**:状态 ∈ {LISTENING, CLOSED} + 监听型 + **芯片已在 LISTEN 即视为达成、绝不 open**(U12 结构性防护)+ 挂起保险。登记侧 `net_listener_request` 因此**不需要**状态门——任何"当场动作 + 调用点各自补门"的写法都会把 U12 风险复制 N 份(对已 LISTENING 的 fd 再 open+listen 会因 USE 错误走进关闭分支、**关掉正听着新 SYN 的 Socket**) |
### 9.3 三个服务的连接结束契约(现状)
| 服务 | 连接结束信号 | 监听重建者 | 备注 |
|---|---|---|---|
| `lftpd` 控制 :21 | `read_line != 0`;阻塞挂起 recv 保证 CLOSED 时必被结束(FIN→0 / 异常→CONNRESET) | net 层;应用**永不** close 控制 fd | 契约一致,**正面样板** |
| `time_sync` :8888 | EOF / 预算耗尽 → `net_drop_conn` 拆会话保监听 | net 层(形态④) | 同 fd 即监听 fd,应用侧**不得** `net_close`(红线 8)→ 走 §4.6 |
| `ui_net_sink` :7003 | `net_recv` 返 0 或真错误;非阻塞收帧 | net 层 | 重建条件**不得**写成 `net_get_sock(g_listen)==NULL`(fd 不释放 → 恒假);靠 `session_dead` 电平 + 形态④ |
| 模拟器后端 | `recv` 0 恒可得、fd 分离 | 无需 | Winsock 原生;对上述全部失效模式**零覆盖**(D6:双模型分裂暂不消除) |
## 10. 决策记录
| # | 决定 | 依据 |
|---|---|---|
| D1 | **A′(启用芯片多连接模式)出局**,根治必须在单连接复用模型内做 | §1.4:多监听不支持 / 池按源端口匹配会互串 / 实测 TRAP-NET-02、-11 / FUN_PARA 初始化后不可改 |
| D2 | 采用**电平化 + 单一所有者**路线,**不做监听器一等对象化**(listener 独立对象 + `accept` 返回虚拟句柄) | 后者需控制面重写 15~25 函数、RAM/Flash 上升,只有"同端口并发多连接"需求才值得 |
| D3 | 周期对账**保留**,其副作用由不变量消除 | TRAP-NET-17 实证事件面不是可靠唯一信源(E14 复核:非芯片侧丢失,是读清消费)→ 周期对账不可删 |
| D4 | `FUN_PARA` **保持 `0x08`**,不因孤儿态改配置 | bit3=1 是"保留排空现场"契约;bit3=0 会令芯片在 DISCONNECT/TIM_OUT 时**当场清缓冲** →「发完即 FIN」的尾部数据必被硬件擦除、**不可补救**。完整论证见 §4.9 |
| D6 | 真机/模拟器**双模型分裂暂不消除** | 消除即等于做监听器一等对象化;先靠不变量把应用侧契约对齐 |
| D7 | 同族残留**不在监听生命周期根治范围**,各自单列:U1(CONNECTING 滞留)、U11、U12、U14、U15,以及审计项 B4~B9 | 与监听生命周期不同因,混做会让回归面失控 |
> `D5` 是"分阶段实施顺序"的决策,实施完成后不再构成任何设计含义 → 已删除;**编号不回收**
> (沿用 `docs/编号规范.md` §5.5 的游标不回退约定)。
## 11. 模块化边界(`net_listener.c` / `net_events.c`)
> **本节只回答"组织度"**:搬什么、边界在哪、接口多窄。若问的是"这套沉降 / 重试机器本身
> 是不是我们想多了"——那是**合法性**问题,见 §1.5 与 ioLibrary 的代码级对照。
> 前提:**先收敛,再搬移**——§4.1 先把周期性重建收敛到唯一决策者,搬移边界才清晰。
### 11.1 可机械检验的判据(现状值)
| # | 判据 | 现状 |
|---|---|---|
| 1 | 模块外调用「重建监听」的**入口** | **单一登记入口 `net_listener_request` + 单一执行者 `net_listener_tick`**;5 个登记点(形态③④ / accept CLOSED 分支 / recv EOF / `net_drop_conn` / 挂起 recv 断连),**0 处模块外执行** |
| 2 | `s_relisten_*` 静态状态被**模块外直接读写** | **0**(外部经 `net_listener_settle`) |
| 3 | 依赖方向 | 新模块 → `net_socket.c` **单向**(不反向 include) |
⚠ **明确不承诺**:模块化**不消除 fd 别名**(同一 Socket 既当监听器又当连接)。
那是 CH395F 单连接模型 + bit1 多连接模式**不支持多监听**(§1.4)锁死的,属硬件上限。
重构只提升**组织度**,不改变架构上限——**别指望补丁消失,只指望补丁不再重复**。
### 11.2 边界划分
**在 `Drivers/BSP/NET/net_listener.c`**(**真机专属**,sim 不编):
| 内容 | 理由 |
|---|---|
| `net_relisten_locked` / `net_listener_request` / `net_listener_tick` / `net_listener_clear` / `net_relisten_done` | 「维持 fd 处于 LISTEN 态」的全部机制 |
| `net_drain_chip_rx` | 重建前的排空(只被 relisten 调用) |
| 8 个 `s_relisten_*` 数组 + 7 个 `NET_RELISTEN_*` 常量 | 状态与策略私有化(`s_relisten_capped` 属 §1.5「可省清单」) |
**在 `Drivers/BSP/NET/net_events.c`**(**真机专属**,sim 不编):
| 内容 | 理由 |
|---|---|
| `net_reconcile_tick`(形态①②③④⑤/U11) | 周期面的失同步判据 |
| 5 个 `handle_*_event` | 边沿面的事件处理 |
| `net_lost_connect_probe` | 形态② 的补 accept 动作(与对账同职责) |
**留在 `net_socket.c`**(并说明理由,避免"看着像就搬"):
| 内容 | 理由 |
|---|---|
| `net_listen_locked` | 是**应用 API 路径**(首次监听,含 BOUND/CREATED 状态门与缓冲)——属"连接层建监听",不是"维持监听" |
| `net_accept_locked` | 产出的是**连接**(LISTENING→ESTABLISHED 迁移),属连接层;它只**调用**新模块 |
| `net_listener_armed` | **双端公开 API**(sim 也实现);纯 RAM 查询、很薄,留在原处避免 sim 缺符号 |
| `net_sock_t` 全部字段 | 数据归 `net_types.h`(双端共享核心),**不随模块走** |
> ⚠ `net_sock_t` 的 `last_peer_port` / `lost_conn_seen` 是**跨层字段**:`net_relisten_locked`
> 要写它们(转登幽灵甄别基准),`net_lost_connect_probe` 与对账②也要读写。
> **这是无法消除的残余耦合**(单连接模型的固有结果),明确登记而非假装不存在。
### 11.3 对外接口(4 函数 + 2 枚举,全部窄接口)
```c
/* net_listener.h —— 只被 net_socket.c include(内部头,不进公开 net_socket.h,故 sim 无依赖) */
typedef enum {
NET_LISTENER_SETTLE_QUARANTINE = 1, /* 异常释放(TIMEOUT/RST):隔离 400ms + 排空 RX 残渣 */
NET_LISTENER_SETTLE_GRACEFUL = 2, /* 正常释放(DISCONNECT/FIN):沉降 100ms */
} net_listener_settle_t;
typedef enum { /* 登记来源:仅用于留痕归因,不改变动作 */
NET_LISTENER_REQ_RECON = 0, /* 对账形态③④:芯片侧监听失位 / 孤儿监听 */
NET_LISTENER_REQ_ACCEPT, /* 消费者 accept 撞上 CLOSED 的监听 fd */
NET_LISTENER_REQ_EOF, /* 消费者 recv 读到 EOF */
NET_LISTENER_REQ_DROP, /* net_drop_conn 拆会话保监听 */
NET_LISTENER_REQ_PENDING, /* 挂起 recv 因断连结束 */
} net_listener_req_t;
void net_listener_settle(int sockfd, net_listener_settle_t level); /* 只置事实,不动作 */
void net_listener_request(int sockfd, net_listener_req_t reason); /* 只登记意图,不碰芯片 */
void net_listener_clear(int sockfd); /* 清本 fd 的重建/沉降状态(新 fd 或 close 时) */
void net_listener_tick(void); /* 唯一执行者(netTask 每轮,非阻塞;30ms/2s) */
```
**反向依赖(新模块需要宿主提供的)**:`net_sock_t` 定义(`net_types.h`,共享头,不算反向
依赖)、`net_set_errno` / `NET_ERR_RETURN_1`、`net_pending_active`、`net_sock_raw`、
`ch395f_*` 驱动、`HAL_GetTick`。`net_listener.c` **不访问** `s_net_socks`——一律用
`p_sock->ch395_sock` 或调用方传入的 `sockfd`。
### 11.4 当前代码形状的由来(四条非搬移约束)
1. **`net_set_errno` 由 `static` 提升为模块间可见** —— 否则新模块链接期报 `L6218E:
Undefined symbol`;`NET_ERR_RETURN_1` 宏随之迁到 `net_socket.h`(它依赖前者,不能只移宏)。
2. **`net_pending_active(fd)` / `net_send_pending_active(fd)` 薄访问器** —— 监听器的
「本 fd 有在途挂起则跳过」保险(防重开 Socket 摧毁在途连接/在途传输)不得直读挂起 static
表,**语义原样保留**。
3. **`net_sock_raw()`** —— 对账必须看到 `in_use==0` / `state==CLOSED` 的槽位(形态④ 认领
孤儿监听正依赖 CLOSED 态),而 `net_get_sock()` 对未在用槽位返回 NULL 会漏掉这一类。
声明放**配对头** `net_socket.h` 而非内部头 `net_events.h`——规范 **S17 2.2** 要求非 static
函数声明在配对头文件。
4. **两个新模块都真机专属** —— 依赖 `ch395f_*` SPI 驱动与驱动状态缓存,而 sim 是 Winsock
后端(无 `ch395f_*`、无孤儿态、无"事件未收到/静默回收"概念)→ **无法**在 sim 编译。
故只注册 Keil 端,并在 `scripts/check_registration.py` 的 `SIM_EXEMPT` **显式点名**
(遵"显式清单 + 每行理由"原则)。
### 11.5 为什么对账与事件处理是**一个**模块(`net_events.c`)
判据(`net_reconcile_tick` 形态①②③④⑤/U11)与边沿事件处理(5 个 `handle_*_event`)是同一
职责的两半——一个周期面、一个事件面,都在做"芯片状态面 ↔ 本层镜像"的收敛。
**为什么不是一个模块拆成两个**:剥掉注释后实测**对账区真实调用事件处理器 2 处**(形态② 补
accept 走 `handle_connect_event`),且 `NET_RECON_STABLE_DEATH` 两边共用。拆成
`net_events.c` + `net_reconcile.c` 会造出跨模块调用;合起来这 2 处变模块内直调,
**跨模块导出为零**。
**两条可复用的方法论**:
- ⚠ **依赖扫描必须先剥注释**——未剥时 `alloc_socket` / `net_close_locked` /
`net_relisten_locked` 全被误报成依赖(其实只是注释里的提及),足以把人引向错误的边界。
- **搬移类改动要配机械证明**:剥注释后取**代码行多重集**,新旧之和比对,差异必须**恰好等于**
声明的改动集(fd 取法替换、`static` 去除、前向声明、新文件自身头部、新增访问器),
**无一处意外**——否则"注释和代码混在一起改"无法区分意外丢失。
## 12. 引用与归档约定
- 本文引用格式:`docs/网络/网络自愈架构设计.md` §N.M(紧邻写法);E 编号引 §5.2
(如"§5.2 E12"),F 编号引 §7。
- **勿引用已删除的过程章节**。过程考古:
- N34 自愈审计(三路审计、39 条编号发现全文、E0~E15 原始记录、分阶段实施清单):
`git show 1a35b36^:docs/网络/N34-自愈审计/`(目录合并前 7 份文档)或
`git log --oneline -- docs/网络/N34-自愈审计/`;
- 监听生命周期(本文 §1.1~§1.4 / §4.9 / §9~§11 的来源,含 P0~P3 勾选清单与台架验收记录):
`git log --oneline -- docs/子系统设计/网络Socket监听生命周期设计说明.md`;
- `自愈收敛阶段 N` 只出现在历史文档与变更记录里(代码注释已清理为平实语言)——
**勿再引入**,设计含义一律以本文 §2~§4 为准。