# BSD Socket API 使用指南 > **恢复说明**:本文档曾在提交 `4e9ac0d`(2026-08-28)中误删,2026-09-13 从 git 历史恢复。API 随代码多轮同步(2026-09-13:`net_init` 4 参数 / `net_socket` 2 参数 / 网络代次号 / `NET_NO_OS` 双端构建;2026-09-23:`net_recv_to` / `net_sock_state` / `net_resync_listen` / `net_relisten`、挂起 recv 化、D10 非阻塞重监听、TRAP-NET-17 周期对账、TRAP-NET-18 排渣隔离)。历次同步的完整过程查 git log。 > > **伴读文档**:本文回答"上层该怎么调";**内部函数之间的调用关系、每个函数的设计作用与意图**(netTask / 消息队列 / 挂起 recv / D10 重试 / TRAP-NET-17 对账 / TRAP-NET-18 排渣各子系统,含总体调用图与端到端时序)见 `docs/网络/BSD_Socket_API_函数调用关系.md`。 ## 目录 1. [概述](#1-概述) 2. [架构说明](#2-架构说明) 3. [线程安全设计](#3-线程安全设计) 4. [快速开始](#4-快速开始) 5. [TCP 编程](#5-tcp-编程) 6. [UDP 编程](#6-udp-编程) 7. [I/O 多路复用](#7-io-多路复用) 8. [事件回调机制](#8-事件回调机制) 9. [字节序处理](#9-字节序处理) 10. [API 参考](#10-api-参考) 11. [完整示例](#11-完整示例) 12. [常见问题](#12-常见问题) --- ## 1. 概述 本项目基于 CH395F 以太网协议栈芯片,提供了一套兼容 Linux BSD Socket API 的网络编程接口。这套接口屏蔽了底层硬件细节,让嵌入式网络编程与标准 Linux 网络编程保持一致。 ### 1.1 支持的功能 | 功能 | 说明 | |------|------| | TCP Client | 主动连接远程服务器 | | TCP Server(单连接) | 支持 1 个客户端连接 | | UDP Client | 发送 UDP 数据报 | | UDP Server | 接收 UDP 数据报 | | select/poll | I/O 多路复用 | | 事件回调 | 异步事件通知 | ### 1.2 与 Linux Socket 的差异 | 特性 | Linux Socket | 本实现 | |------|--------------|--------| | Socket 数量 | 无限制 | 最多 8 个 | | 阻塞默认 | 默认阻塞(无隐藏超时) | **同**(`net_recv()` 无限阻塞,超时归调用方——`net_select` / `net_recv_to`) | | 非阻塞模式 | `O_NONBLOCK` / `MSG_DONTWAIT` | `net_setblocking(fd, 0)`(模式属性)/ `net_recv_nb()` / `NET_MSG_DONTWAIT`(单次) | | 错误码 | errno | `net_get_errno()` | | 头文件 | `` | `"net_socket.h"` | | 无限阻塞约束 | 无 | ⚠ IWDG `required_mask` 内任务**禁止**(嵌入式特有契约,§10.1) | --- ## 2. 架构说明 ### 2.1 分层架构 ![BSD 网络栈分层架构](../images/BSD网络栈分层.svg) ### 2.2 文件说明 | 文件 | 说明 | |------|------| | `net_config.h` | 配置宏定义(最大 Socket 数、超时时间等) | | `net_types.h` | 类型定义(地址结构、Socket 控制块等) | | `net_socket.h` | BSD Socket API 头文件,含 `net_msg_t` 消息类型定义 | | `net_socket.c` | API 核心实现:消息队列处理与线程安全封装、内部 `_sock()` 版本;另含挂起 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)+ `net_lost_connect_probe`;**只置事实与登记意图**,重建动作在 `net_listener_tick`;内部头 `net_events.h`,模拟器不编译 | | `net_listener.c`(**真机专属**) | **监听生命周期**(E17 单一所有者收口):`net_listener_request`(**只登记意图**,5 个登记点)/ `net_listener_tick`(**唯一执行者**,门集 + D10 重试 30ms/2s)/ `net_listener_clear` / `net_listener_settle`(沉降/隔离 100ms/400ms);"该不该重建"的周期判据在 `net_reconcile_tick` 形态③④(单一 100ms tick);内部头,模拟器不编译 | | `net_udp.c`(**真机专属**) | **UDP 数据面执行体**(2026-11-03 队列化改造新增):`net_udp_bind_exec` / `net_udp_set_peer_exec`(切芯片形态,不重开 Socket)/ `net_udp_sendto_exec`(**复用** `net_send_locked` 的 1KB 分块与挂起 send)/ `net_udp_recvfrom_exec` + `net_udp_tick`(挂起 recv 完成 / 预算超时)+ per-fd 出参槽(源地址写回调用方自己的内存,不占 `net_msg_t`)+ 截断排空;内部头 `net_udp.h`,模拟器不编译;用法与约束见 §6.0 / §6.3,调用结构见 `docs/网络/BSD_Socket_API_函数调用关系.md` §4.11 | | `net_select.h` | select/poll API 头文件 | | `net_select.c` | select/poll 实现(仅 MCU 端编译) | | `net_socket_sim.c`(`simulator/sim/`) | Winsock2 后端(模拟器端,与 `net_socket.c` 互斥编译),详见 §2.3 | > **双端构建**:`net_socket.h/.c`、`net_select.h/.c` 同时参与 MCU 端与 PC 模拟器端编译;模拟器端定义 `NET_NO_OS`,裁掉消息队列 / 事件回调等 OS 相关接口,仅保留 BSD Socket 核心 API(模拟器网络后端为 Winsock,`net_init` 不初始化 CH395F,详见 §2.3)。 > > **网口配置**:IP / 掩码 / 网关 / MAC 由 `Drivers/BSP/NETCFG/net_cfg` 持久化(上电加载,UI 网口设置页可改并立即生效),见 `docs/子系统设计/网络配置说明.md`。 ### 2.3 PC 模拟器端:net_socket_sim PC 模拟器(`simulator/`,CMake + SDL2)与 MCU 端共用同一份上层网络源码(`ui_net_sink.c`、`net_cfg.c` 等),网络后端换成 `simulator/sim/net_socket_sim.c`(Winsock2 实现)。构建时定义 `NET_NO_OS`,裁掉 `net_socket.h` 中的 FreeRTOS 相关接口:消息队列类型 `net_msg_t`、`net_process_messages()` 与 `net_send_sock()` / `net_recv_sock()` / `net_close_sock()` / `net_listen_locked()` 四个内部 API。上层使用同一套 `net_*` BSD API,对后端差异无感。 | 项 | MCU 端(`net_socket.c`) | 模拟器端(`net_socket_sim.c`) | |---|---|---| | 底层后端 | CH395F(SPI2) | Winsock2(链接 `ws2_32`) | | `net_init()` | 芯片初始化 / 复位 / 配置 IP·MAC / 分配 Socket 缓冲 | 仅确保 `WSAStartup`(幂等);ip/mask/gw/mac 四参数全部忽略 | | fd 模型 | 控制块索引 0~7 | 相同(并行数组 `s_ws[]` 存 Winsock 句柄,不侵入共享的 `net_sock_t`) | | 并发 / 阻塞 | 消息队列 + `netTask` 串行化 | 单线程直调;Winsock 句柄创建即非阻塞(FIONBIO),阻塞语义按控制块 `blocking` 属性在包装层模拟(`net_recv` 轮询等待 / `net_recv_nb` 立即返回,与 MCU 端对齐) | | `net_poll()` | CH395F 中断轮询 + 状态机更新 | 空操作(返回 0,非阻塞 I/O 直接反映事件) | | `net_accept()` | 单连接:监听 Socket 自身即数据通道(返回监听 Socket 自身) | Winsock 语义:返回独立于监听 Socket 的新 fd(监听 Socket 保持 `LISTENING`) | | 事件回调 | `net_poll` 检测后主动触发 | 仅存储,不主动触发(上层自行非阻塞轮询感知) | | 代次号 | 网络重建 +1(服务自愈依据) | 恒为 0(无重建概念,上层自愈逻辑空转) | | `net_bind()` | — | 先设 `SO_REUSEADDR`(断线重监听可复用端口) | | 错误码 | CH395F 状态码映射 | `WSAGetLastError()` 映射(`WSAEWOULDBLOCK` → `NET_ERR_WOULDBLOCK`) | | select/poll | `net_select.c` 参与编译 | 未编译(上层不依赖) | > **模拟器端网络启动**(镜像 MCU 端上电流程:defaultTask 在就绪屏障前加载 `net_cfg`,见 `simulator/main.c`):`WSAStartup` → `net_cfg_load()`(网口配置 RAM 副本,失败回退工厂默认)→ `ui_net_sink_start(7003)`(RemoDispBus TCP 监听,与 MCU 端同端口)→ 主循环 `ui_net_sink_poll()`(非阻塞协议收发)+ `net_cfg_save_run()`(执行 UI 保存请求,主循环 = 配置引擎属主)。不调用 `net_init()`——WSA 初始化由 `net_socket_sim.c` 内部 `ensure_wsa()` 幂等保证,`net_init()` 仅为对齐 API 而保留(上层代码双端统一调用)。 ### 2.4 Socket 状态机 ```mermaid stateDiagram-v2 [*] --> CLOSED : 初始(in_use = 0) CLOSED --> CREATED : net_socket() 分配描述符 CREATED --> BOUND : net_bind()(TCP) CREATED --> UDP_OPEN : net_bind()(UDP) CREATED --> LISTENING : net_listen() BOUND --> LISTENING : net_listen() BOUND --> CONNECTING : net_connect() CONNECTING --> ESTABLISHED : 连接建立(CONNECT 中断) CONNECTING --> CREATED : 连接超时 LISTENING --> ESTABLISHED : 客户端连接(standalone accept) ESTABLISHED --> CLOSED : 对端断开 / 超时 / net_close() CLOSED --> LISTENING : auto_relisten 自动重监听 UDP_OPEN --> CLOSED : net_close() ``` > 状态名与 `net_types.h` 的 `net_sock_state_t` 一一对应(共 8 态)。另有 `TCP_CLIENT_RECONNECTING` 状态:应用层用它标记 TCP Client 的重连阶段(非驱动自动迁移,故未画入上图)。 > **单连接 Server 自动重监听(`auto_relisten`)**:`net_listen()` 会为 TCP 监听 Socket 置 `auto_relisten = 1`。连接断开(`net_recv` 收到 EOF / 空闲 `net_accept`)时,net 层**自动**重新 `OPEN + TCP_LISTEN`,状态由 `ESTABLISHED`/`CLOSED` 自动回到 `LISTENING`,**应用层无需调用 `net_close()` + `net_listen()`**。这与 Linux BSD Socket“监听 Socket 常驻”语义一致——`net_accept()` 在每条新连接到达时返回,服务端循环不必维护重监听逻辑。 --- ## 3. 线程安全设计 ### 3.1 设计背景 NET 层(`net_socket.c` + `ch395f.c`)不是线程安全的: - Socket 控制块(`s_net_socks[]`)无互斥保护 - CH395F SPI 事务不重入(STM32 HAL SPI 无内部锁) - 状态机在多个任务中并发访问会崩溃 引入消息队列将所有的 CH395F 操作串行化到 `netTask` 中执行。 ### 3.2 消息队列模式 ```mermaid sequenceDiagram participant A as App Task A participant B as App Task B participant Q as 消息队列 (osMessageQueue) participant N as netTask A->>Q: net_send() → 入队 net_msg_t B->>Q: net_recv() → 入队 net_msg_t A->>A: ulTaskNotifyTake() 阻塞等待结果 B->>B: ulTaskNotifyTake() 阻塞等待结果 N->>Q: net_process_messages() 出队 N->>N: 执行请求(net_send_sock / net_recv_sock / net_close_sock) N->>Q: 写结果 N-->>A: xTaskNotifyGive() N-->>B: xTaskNotifyGive() A-->>A: 返回结果给调用方 B-->>B: 返回结果给调用方 Note over N: 每周期还调用 net_poll()
CH395F 中断轮询 + 状态机更新 ``` ### 3.3 关键规则 | 规则 | 说明 | |------|------| | **`netTask` 外(任意应用任务)** | 公共 API:`net_send()` / `net_recv()` / `net_recv_to()` / `net_accept()` / `net_connect()` / `net_close()` / `net_listen()` / `net_drop_conn()`——请求入队列,netTask 串行执行,调用方阻塞等回执。UDP 侧同一口径:`net_bind()`(UDP) / `net_sendto()` / `net_recvfrom()` / `net_recvfrom_to()` / `net_udp_set_peer()`(2026-11-03 队列化改造,见 §6)。**自愈无显式 API**(`net_relisten()` / `net_resync_listen()` 已退役,静默回收/监听失位由对账⑤与 reconciler 形态③④自动认领,见 §3.6) | | **`netTask` 内** | 内部 API `net_send_sock()` / `net_recv_sock()` / `net_close_sock()` / `net_listen_locked()` | | **`net_socket()` / `net_bind()`** | `net_socket()` 纯 RAM(分配控制块),任意任务直接调用;`net_bind()` 分两支——**TCP** 只记本地端口,仍是直接调用;**UDP** 要开芯片侧 Socket,走队列在 netTask 里执行(要发 5 条芯片命令,见 §6.0) | | **`net_poll()` 和 `net_process_messages()`** | 必须在 `netTask` 主循环调用;`net_process_messages()` 每轮先跑 **5 个非阻塞 tick**(D10 重监听重试 → 挂起 recv 完成 → 挂起 send 推进 → **UDP 挂起 recv 完成/超时** → TRAP-NET-17 对账+监听重建合一 reconciler 100ms),再处理队列 | ### 3.4 内部 API 说明 内部 API(带 `_sock` 后缀)直接操作 Socket 控制块和 CH395F,不经过消息队列。用于 `netTask` 中的网络业务逻辑,避免向自身发送消息造成死锁: ```c /* 在 netTask 中直接操作 Socket,绕过消息队列 */ int net_send_sock(net_sock_t *p_sock, const void *buf, int len); int net_recv_sock(net_sock_t *p_sock, void *buf, int len, int flags); int net_close_sock(net_sock_t *p_sock); ``` ### 3.5 注意事项 - **消息队列由 CubeMX 创建**:在 `MX_FREERTOS_Init()`(`osKernelInitialize()` 之后)中调用 `osMessageQueueNew(8, sizeof(net_msg_t), NULL)` 创建 - **队列句柄全局可见**:通过 `extern osMessageQueueId_t netMsgQueueHandle` 引用 - **`netTask` 不可向自身发消息**:`netTask` 中直接调 `net_send()` 会死锁(等待自己处理消息),必须在 `netTask` 中使用 `net_send_sock()` 等内部版本 - **阻塞 recv 不独占 netTask(挂起 recv 模型)**:无数据时公共 `net_recv()` / `net_recv_to()` **登记挂起请求后返回**(每 fd 至多一个,禁止覆盖——覆盖即丢失前调用方通知 = 永久挂死);完成由 `net_pending_tick()` 按主循环节拍检查(①数据 ②到点[仅显式预算] ③断连)并经任务通知唤醒调用方——等待期间调用方在自己上下文睡通知,netTask 零占用(消除队头阻塞)。副作用:`buf` 指针须保持有效至返回;close / 网络重建 / 陈旧重同步会**显式取消并通知**(`NET_ERR_CONNRESET` 等),调用方永不挂死 - **recv 语义主流化**:对齐 POSIX/Python——**模式归使用者、超时归调用方、API 不强加默认预算**。`net_recv()` 默认**无限阻塞**(`NET_RECV_TIMEOUT_MS` 30s 默认已删),唤醒源 = 数据 / EOF / close / 网络重建(CONNRESET),会话死亡由对账① ~400ms 判死兜底;非阻塞具名入口 `net_recv_nb()`(原 `NET_MSG_DONTWAIT` 路径转正),模式属性 `net_setblocking(fd, on)` 挂 socket 控制块;超时首选 `net_select(...)`,`net_recv_to(...)` 保留为**显式预算**便捷入口。内部看门狗降级为 **tripwire**:挂起超 60s 只 `[ERR]` 留痕一条(完成逻辑缺位探针),不再伪造 TIMEDOUT 返回值。⚠ **文档契约(嵌入式特有)**:IWDG `required_mask` 内任务**禁止无限阻塞 recv**——必须 `net_select` 超时或 `net_recv_to` 显式预算("超时归使用者"对受监控任务是义务不是选项) - **阻塞 send 不独占 netTask(挂起 send 模型)**:`net_send()` 快路径**有界自旋**等发送缓冲腾出(预算 `NET_SEND_SPIN_MS` = 2ms;健康链路实测块间等待 med 219µs ≪ 2ms,故正常发送不会触发挂起),超预算即登记挂起 send 返回;推进由 `net_send_pending_tick()` 按主循环节拍分块完成(①断连 ②推进 ③写完 ④无进展超时),调用方睡任务通知——netTask 最坏停滞由 `NET_SEND_TIMEOUT_MS`(1s)压到 2ms。返回值恒为**已写入字节数**:`= len` 全部写入,`< len` 为**短写**,调用方**必须**检查并重试剩余(断连/超时/取消均以已发字节数结束,不会伪造全部成功)。停滞计时按**无进展**刷新(每次成功写块重置),大文件传输不会被误判超时 - **队列阻塞语义**:只有 `osMessageQueuePut` 失败(8 长度固定队列,极端情形)时公共 API 阻塞;recv / send 挂起等待**不计入**队列占用(完成前队列中无消息滞留) - **哪些操作要求 netTask 已运行**:`net_listen()` / `net_accept()` / `net_connect()` / `net_send()` / `net_recv()` / `net_close()` 全经消息队列 → 只能在就绪屏障后的任务调用;`net_socket()` / `net_bind()` 为直接调用,但同样建议屏障后;**`net_listener_armed()` / `net_can_read()` 是纯 RAM 读控制块**(§10.1),不入队、不等回执,**任意任务可安全周期调用**(用途 = 门控,把"无谓的 RPC"降为 0) ### 3.6 芯片/本层状态对账与重监听自愈(TRAP-NET-09/-17/-18) 单连接模型下"监听 Socket = 数据通道",多种芯片行为会造成**芯片侧与本层状态失同步**,纯事件(中断)驱动无法自愈。当前有**四层自愈**(周期对账与监听重建合并在**单一 100ms `net_reconcile_tick`**),全在 netTask 内、全非阻塞、不冻结主循环: | 机制 | 触发 | 动作 | 关键参数 | |---|---|---|---| | **`auto_relisten`** | 数据通道正常 EOF / DISCONNECT | 重开 + 重新 LISTEN,回 LISTENING;应用层无需 close/重 listen | `net_listen()` 置位;`net_close()` 清 | | **D10 非阻塞重监听重试** | 重开失败(TRAP-NET-09:芯片 close 后需沉降) | 单次尝试失败即登记待重试,每轮到点重试;超时限放弃并记 `relisten give up` | 重试间隔 30ms / 时限 2s(自首次尝试);同 fd 有在途挂起 recv / send 时跳过(挂起保险,防毁在途连接 / 截断在途传输) | | **TRAP-NET-17 周期对账 + 监听重建(合一 `net_reconcile_tick`;对账①②⑤ + 原 pump③④/U11)** | 芯片 CONNECT/DISCONNECT 事件未收到(快速复连,读清消费竞态——E14 复核:非芯片侧丢失);静默回收 Socket(peer FIN,无 DISCONNECT 事件);监听型 fd 芯片监听失位 / 本层状态滞后 | 每 100ms 对在用 socket 读**驱动状态缓存**(`ch395f_sock_state_cached`,唯一刷新点 = `net_poll` 每轮全量刷新,E2 实测单次回读 18.1µs),修正**五类**失同步 + 一类被动重同步:① 本层 ESTABLISHED 而芯片已断 → 按断开处理(只置事实);② 本层 LISTENING 而芯片已握手 → 补 accept(幽灵甄别,属「relisten 沉降段原语化受阻」);③ 本层 LISTENING 而芯片监听失位 → 重建(`net_listener_request`);④ 本层 CLOSED(监听型 fd)而芯片不在 LISTEN → 重建;⑤ 本层 ESTABLISHED 而芯片已静默回收回 LISTEN(0x05/0x01)→ RESYNC 语义只修本层、不发芯片命令(原手动 API `net_resync_listen` 的语义并入);U11 本层 CLOSED 而芯片**正在** LISTEN → 只做状态重同步、不发芯片命令 | 周期 100ms;**分级去抖**:②=1 轮、③④⑤=2 轮、①=4 轮(M3 让位窗 400ms,**勿改小**)+ 动手后 **1s 冷却**(防"刚 open 未生效"瞬态重建风暴;U11 被动重同步不去抖、不占冷却);其它位置只置事实、不做重建动作 | | **TRAP-NET-18 排渣隔离** | 异常释放(芯片 TIMEOUT,如客户端中止上传):在途数据落进 RX 缓冲,直接复监听 → 下条连接把 ~2048B 残渣当自己的数据读走(上传静默截断);正常 FIN 释放亦有残留 DISCONNECT 收尾问题 | 复监听前沉降/隔离:持续排空 RX 残渣 + 主动 close + 排空中断,等芯片回 CLOSED/TCP_CLOSED 才 open+listen;到期强制放行 | 沉降等级(`net_listener_settle`):异常释放 QUARANTINE **400ms** / 正常释放 GRACEFUL **100ms**(2026-09-24 起正常 FIN 亦需沉降);上限 **800ms** 强制放行(**必须 < 2s 时限**,否则监听被放弃) | **行为结论**:监听器恢复从"一次性事件"变为"登记 → 到点重试"过程: - 正常断连:通常一次重开即成;失败时 ~30ms 量级恢复,最迟 ~1s(2s 后放弃,错误日志可见)。恢复窗内该端口拒绝新连接 → 调用方重连逻辑须容忍 ~1s 中断。 - **快速复连不再"监听死亡"(TRAP-NET-17)**:对账保证失同步按分级去抖在 100~400ms 内修正(②1 轮 / ③④⑤2 轮 / ①4 轮,100ms/轮;2026-09-23 生产路径:待机后 UI `:7003` 快速复连 10/10;FTP `:21` 100ms/0ms/600ms 间隔各 50/50;零重建风暴)。 - **中止复连首上传不再被静默截断(TRAP-NET-18)**:RST/FIN 中止后首笔复连上传完整通过(RST 10/10、FIN 5/5,SHA-256 全匹配)。详见 `docs/问题记录/CH395F_Trap_Records.md` TRAP-NET-17/-18。 --- ## 4. 快速开始 ### 4.1 包含头文件 ```c #include "net_socket.h" #include "net_select.h" ``` ### 4.2 初始化网络 ```c int main(void) { /* 初始化网络子系统 * 参数:IP地址, 子网掩码, 网关, MAC(NULL 或全零 = 芯片内置 MAC) * 传入 NULL 使用 DHCP 自动获取 * 本项目中由 netTask 调用(网口配置已由 defaultTask 在就绪屏障前 * 加载进 RAM 副本,netTask 只读副本组网) */ int ret = net_init("192.168.31.100", "255.255.255.0", "192.168.31.1", NULL); if (ret != 0) { printf("网络初始化失败!\r\n"); return -1; } /* 其他初始化... */ while (1) { net_poll(); /* 必须在主循环中调用 */ /* 其他任务... */ } } ``` ### 4.3 主循环要求 **重要**:本项目使用 FreeRTOS 多任务架构,`net_poll()` 和 `net_process_messages()` 均在 `netTask`(FreeRTOS 线程)中定期调用,而非主循环。 - **主循环**:系统初始化完成后进入空循环,等待 `osKernelStart()` 启动调度器 - **netTask**:在任务函数中持续运行,每 1ms 调用一次 `net_poll()` + `net_process_messages()`,负责: - 轮询 CH395F INT# 引脚电平(GPIO 轮询,无需 EXTI 中断) - 通过 `GET_GLOB_INT_STATUS_ALL`(2字节版)读取所有 8 个 Socket 的中断状态 - 更新所有 Socket 的连接状态(CONNECT / DISCONNECT / RECV / TIMEOUT) - 触发事件回调 - 处理数据接收 - `net_process_messages()` 每轮先跑 4 个**非阻塞**检查再取队列:D10 重监听重试(断连后监听器恢复)→ 挂起 recv 完成检查(阻塞 recv 唤醒,队头阻塞修复)→ 挂起 send 推进(阻塞 send 唤醒)→ TRAP-NET-17 芯片/本层对账 + 监听重建合一 reconciler(100ms 周期,读驱动状态缓存,事件未收到/静默回收/监听失位自愈,§3.6) - **监听 Socket 建立**:由就绪屏障后的服务任务(timeSyncTask / ftpTask / uiTask)经队列 API 建立(`net_socket` → `net_bind` → `net_listen`);断连后 net 层自动重监听(§3.6),服务层只需循环 `net_accept`,无需维护重监听逻辑 ```c int main(void) { /* 外设初始化... */ /* BSD Socket API 初始化 */ net_init("192.168.31.100", "255.255.255.0", "192.168.31.1", NULL); /* 创建 Socket、绑定、监听... */ osKernelInitialize(); MX_FREERTOS_Init(); /* 创建 netTask 等 FreeRTOS 对象 */ osKernelStart(); /* 启动调度器 */ while (1) { } /* 空循环,控制流已交给调度器 */ } /* netTask 中(freertos.c): */ void StartNetTask(void *argument) { for (;;) { net_poll(); /* CH395F 中断轮询 + Socket 状态更新 */ net_process_messages(); /* 处理其他任务发送的网络操作请求 */ /* 应用层网络业务逻辑... */ osDelay(1); /* 1ms 周期(tick = 1ms) */ } } ``` --- ## 5. TCP 编程 ### 5.1 TCP Server(单连接) 单连接模式下,1 个 Socket 既做监听又做数据通信: ```c #include "net_socket.h" void tcp_server_single(void) { int listen_sock; struct net_sockaddr_in addr; struct net_sockaddr_in client_addr; int client_addr_len; char buf[256]; int len; /* 1. 创建 Socket */ listen_sock = net_socket(AF_INET, SOCK_STREAM); if (listen_sock < 0) { printf("创建 Socket 失败\r\n"); return; } /* 2. 绑定地址和端口 */ addr.sin_family = AF_INET; addr.sin_port = net_htons(8080); /* 端口号 8080 */ addr.sin_addr.s_addr = net_inet_addr("0.0.0.0"); /* 监听所有网卡 */ if (net_bind(listen_sock, (struct net_sockaddr *)&addr, sizeof(addr)) != 0) { printf("绑定失败\r\n"); net_close(listen_sock); return; } /* 3. 开始监听 */ if (net_listen(listen_sock) != 0) { printf("监听失败\r\n"); net_close(listen_sock); return; } printf("TCP Server 启动,监听端口 8080\r\n"); /* 4. 主循环 */ while (1) { net_poll(); /* 尝试接受新连接 */ client_addr_len = sizeof(client_addr); int client_sock = net_accept(listen_sock, (struct net_sockaddr *)&client_addr, &client_addr_len); if (client_sock >= 0) { char ip_str[16]; printf("新连接: %s:%d\r\n", net_inet_ntoa(client_addr.sin_addr.s_addr, ip_str), net_ntohs(client_addr.sin_port)); /* 接收和发送数据(单连接模式:client_sock 即 listen_sock 自身) */ while (1) { len = net_recv(client_sock, buf, sizeof(buf) - 1, 0); if (len <= 0) { /* 对端关闭:net 层已在 EOF 时自动重监听, 直接 break,外层 net_accept 会接下一个连接 */ printf("连接断开,net 层自动重监听\r\n"); break; } buf[len] = '\0'; printf("收到: %s\r\n", buf); /* 回显数据 */ net_send(client_sock, buf, len, 0); } /* 单连接模式不要在此 net_close(client_sock):它即监听 Socket, 一旦关闭需重新 net_socket+net_bind+net_listen;重监听已由 net 层自动完成 */ } } } ``` ### 5.2 TCP Client ```c #include "net_socket.h" void tcp_client(void) { int sock; struct net_sockaddr_in addr; char buf[256]; int len; /* 1. 创建 Socket */ sock = net_socket(AF_INET, SOCK_STREAM); if (sock < 0) { printf("创建 Socket 失败\r\n"); return; } /* 2. 设置服务器地址 */ addr.sin_family = AF_INET; addr.sin_port = net_htons(8080); addr.sin_addr.s_addr = net_inet_addr("192.168.31.200"); /* 3. 连接服务器 */ printf("正在连接服务器...\r\n"); if (net_connect(sock, (struct net_sockaddr *)&addr, sizeof(addr)) != 0) { printf("连接失败\r\n"); net_close(sock); return; } /* 4. 等待连接建立(跨任务只读快照:net_sock_state, 描述符无效返回 -1) */ while (1) { net_poll(); if (net_sock_state(sock) == NET_SOCK_STATE_ESTABLISHED) { printf("连接成功!\r\n"); break; } HAL_Delay(10); } /* 5. 发送数据 */ const char *msg = "Hello, CH395F!"; net_send(sock, msg, strlen(msg), 0); printf("发送: %s\r\n", msg); /* 6. 接收数据 */ len = net_recv(sock, buf, sizeof(buf) - 1, 0); if (len > 0) { buf[len] = '\0'; printf("收到: %s\r\n", buf); } /* 7. 关闭连接 */ net_close(sock); } ``` --- ## 6. UDP 编程 > **UDP 已于 2026-11-03 队列化**(原"本项目不使用 UDP(2026-10-15 定案)"作废): > `net_bind`(UDP) / `net_sendto` / `net_recvfrom` / `net_recvfrom_to` / `net_udp_set_peer` 与 TCP 同一口径 > ——请求投 `netMsgQueue`,芯片事务**全部在 `netTask` 里串行执行**,调用方阻塞等回执(结果搭 32 位任务 > 通知值回来)。任何任务都能安全调用,不再有"单任务独占期"这个前提。第一个真实消费者是 SNTP(请求-应答)。 ### 6.0 先分清楚:芯片的 UDP 两种模式 CH395F 用 `DesIP` 一个寄存器区分两种 UDP 形态(手册 `docs/芯片手册/CH395F.md` §9.2.4),本层把它做成 socket 的**模式属性**(`net_types.h` `NET_UDP_MODE_SERVER` / `NET_UDP_MODE_CLIENT`)。两种形态收上来的 字节**不一样多**,这是 UDP 侧最容易踩的坑: | 模式 | DesIP | 收包布局 | 谁能发给我 | 本层如何进入 | |---|---|---|---|---| | **服务器** | `0xFFFFFFFF` | **8 字节信息头** + 载荷(头 [0-1] 长度、[2-3] 源端口、[4-7] 源 IP 线序) | 任意主机 | `net_bind()`(恒此模式:BSD 的 bind 只定本地端口,不选对端) | | **客户端** | 具体 IP | **无信息头**,整段就是载荷 | 只有该 IP | `net_sendto()` 带目的地址,或 `net_udp_set_peer(fd, &peer)` | - 收包时**按模式**决定是否剥头:客户端模式绝不剥。旧实现不分模式恒剥 8 字节,而 `net_sendto` 一旦给出 目的地址芯片就已是客户端形态——于是 SNTP 这类应答的**前 8 字节载荷被吃掉**,源地址还是从载荷里当 头读出来的假值(缺陷登记见 `docs/问题记录/CH395F_Trap_Records.md`,回归钉子在 `test/host/net_socket/ns_s7_udp.c` UDP-3 / UDP-4)。 - 从客户端模式回服务器形态:`net_udp_set_peer(fd, NULL)`,只改 DesIP/DesPort,**不重开 Socket**; 再次 `net_bind()` 则是重开(顺带丢掉芯片缓冲里的残留包),两者用途不同。 - 客户端模式下 `net_recvfrom` 回填的源地址就是那个固定对端——芯片已经替我按 IP 过滤过一遍,包上也没有头可取。 ### 6.1 UDP Client ```c #include "net_socket.h" void udp_client(void) { int sock; struct net_sockaddr_in local_addr; struct net_sockaddr_in dest_addr; char buf[256]; int len; /* 1. 创建 UDP Socket */ sock = net_socket(AF_INET, SOCK_DGRAM); if (sock < 0) { printf("创建 Socket 失败\r\n"); return; } /* 2. 绑定本地端口:UDP 侧**必做**(芯片侧 Socket 要在这里 open, * 没 bind 就收发一律 NET_ERR_NOTCONN)。端口给 0 = 本层取动态端口 */ local_addr.sin_family = AF_INET; local_addr.sin_port = net_htons(12345); local_addr.sin_addr.s_addr = net_inet_addr("0.0.0.0"); if (net_bind(sock, (struct net_sockaddr *)&local_addr, sizeof(local_addr)) != 0) { printf("绑定失败\r\n"); net_close(sock); return; } /* 3. 设置目标地址 */ dest_addr.sin_family = AF_INET; dest_addr.sin_port = net_htons(8888); dest_addr.sin_addr.s_addr = net_inet_addr("192.168.31.200"); /* 4. 发送数据:带目的地址的 sendto 顺手把 DesIP 指向该地址, * 芯片从此是**客户端模式**(只收这个 IP 的包,包上不带信息头,见 §6.0) */ const char *msg = "UDP Hello!"; if (net_sendto(sock, msg, strlen(msg), 0, (struct net_sockaddr *)&dest_addr, sizeof(dest_addr)) < 0) { printf("发送失败 %d\r\n", net_get_errno()); net_close(sock); return; } /* 5. 接收应答:受 IWDG 监管的任务用带预算的 net_recvfrom_to, * 超时返回 -2(不置 errno),不会把喂狗窗口耗在等待上(§10.1) */ len = net_recvfrom_to(sock, buf, sizeof(buf) - 1, 2000U, NULL, NULL); if (len > 0) { buf[len] = '\0'; /* 客户端模式的源地址就是这个固定对端,包上没有信息头 */ printf("收到: %s (from server)\r\n", buf); } else if (len == -2) { printf("应答超时\r\n"); } net_close(sock); } ``` ### 6.2 UDP Server ```c #include "net_socket.h" void udp_server(void) { int sock; struct net_sockaddr_in addr; char buf[256]; /* 1. 创建 UDP Socket */ sock = net_socket(AF_INET, SOCK_DGRAM); if (sock < 0) { printf("创建 Socket 失败\r\n"); return; } /* 2. 绑定本地端口 */ addr.sin_family = AF_INET; addr.sin_port = net_htons(8888); addr.sin_addr.s_addr = net_inet_addr("0.0.0.0"); if (net_bind(sock, (struct net_sockaddr *)&addr, sizeof(addr)) != 0) { printf("绑定失败\r\n"); net_close(sock); return; } printf("UDP Server 启动,监听端口 8888\r\n"); /* 3. 循环接收数据:一次调用 = 一个完整数据报(服务器模式自动剥 8 字节信息头) */ while (1) { struct net_sockaddr_in src_addr; int src_addr_len = sizeof(src_addr); /* 用带预算的收包当循环节拍:-2 = 本轮没人发包,不是错误; * 换 net_recvfrom(...) 就是无限等,受 IWDG 监管的任务里别这么写(§10.1) */ int len = net_recvfrom_to(sock, buf, sizeof(buf) - 1, 500U, (struct net_sockaddr *)&src_addr, &src_addr_len); if (len > 0) { buf[len] = '\0'; char ip_str[16]; printf("收到: %s (from %s:%d)\r\n", buf, net_inet_ntoa(src_addr.sin_addr.s_addr, ip_str), net_ntohs(src_addr.sin_port)); /* 回显数据。⚠ 带目的地址的 sendto 会把芯片切成**客户端模式** * (DesIP = 这个客户端),此后只有它一家能再发包进来。要长期服务 * 多个客户端,回完就把它切回来: */ (void)net_sendto(sock, buf, len, 0, (struct net_sockaddr *)&src_addr, src_addr_len); (void)net_udp_set_peer(sock, NULL); } } } ``` ### 6.3 UDP 调用约束(2026-11-03 队列化之后) | 约束 | 行为与理由 | |---|---| | 未 `net_bind()` 就 `sendto` / `recvfrom` / `set_peer` | 一律 `NET_ERR_NOTCONN`(-5):芯片侧 Socket 还没 open,此时写 DesIP 也会被随后的 open 复位 | | 一次收包 = 一个完整数据报 | 缓冲装不下时交付 `len` 字节并把剩余部分**读空丢弃**(留半包在芯片缓冲里,下一次收包会从上一包的尾巴开始解析,源地址与载荷从此全部错位);短交付靠返回值判读 | | 同一 fd 并发收包 | 第二个接收者拿 `NET_ERR_BUSY`(-11):数据报语义下无法把包归属到两个等待者,拒绝比交错投递安全(与 TCP 挂起 recv 同一守卫) | | 阻塞预算 | 受 IWDG 监管的任务禁用无限等待的 `net_recvfrom`,改 `net_recvfrom_to(fd, buf, len, to_ms, ...)`:超时返回 **-2** 且不置 errno(与 `net_recv_to` 同口径);或先 `net_select()` / `net_can_read()` 门控 | | 单包载荷 | ≤ 1472 字节(手册 §9.2.4);更长由本层按 1KB 分块、复用 TCP 的挂起 send 在 netTask 里推进,短写返回已发送字节数,调用方须检查返回值 | | fd 额度 | 与 TCP 共用 `NET_MAX_SOCKETS`(8),一个 UDP fd 占一个名额 | | 收包不占 netTask | 没数据时登记挂起(本 fd 一个槽)立即让出 netTask,完成条件由 `net_udp_tick` 每轮判定——与挂起 recv/send 同一套账,netTask 不会被一次 UDP 等待钉住 | --- ## 7. I/O 多路复用 ### 7.1 select 使用 `net_select()` 可以同时监控多个 Socket 的读写事件: ```c #include "net_select.h" void select_example(void) { int sock1, sock2; net_fd_set readfds; struct net_timeval timeout; /* 创建并连接两个 Socket ... */ while (1) { NET_FD_ZERO(&readfds); NET_FD_SET(sock1, &readfds); NET_FD_SET(sock2, &readfds); timeout.tv_sec = 0; timeout.tv_usec = 500000; /* 500ms 超时 */ int ret = net_select(sock2 + 1, &readfds, NULL, NULL, &timeout); if (ret > 0) { if (NET_FD_ISSET(sock1, &readfds)) { char buf[128]; int len = net_recv(sock1, buf, sizeof(buf), 0); /* 处理数据... */ } if (NET_FD_ISSET(sock2, &readfds)) { char buf[128]; int len = net_recv(sock2, buf, sizeof(buf), 0); /* 处理数据... */ } } else if (ret == 0) { /* 超时 */ } } } ``` ### 7.2 poll 使用 `net_poll_events()` 使用 `pollfd` 结构体数组: ```c #include "net_select.h" void poll_example(void) { net_pollfd fds[2]; fds[0].fd = sock1; fds[0].events = NET_POLLIN; fds[1].fd = sock2; fds[1].events = NET_POLLIN; while (1) { int ret = net_poll_events(fds, 2, 500); /* 500ms 超时 */ if (ret > 0) { if (fds[0].revents & NET_POLLIN) { char buf[128]; int len = net_recv(fds[0].fd, buf, sizeof(buf), 0); /* 处理数据... */ } if (fds[1].revents & NET_POLLIN) { char buf[128]; int len = net_recv(fds[1].fd, buf, sizeof(buf), 0); /* 处理数据... */ } } } } ``` ### 7.3 事件掩码说明 | 事件 | 说明 | |------|------| | `NET_POLLIN` | 有数据可读,或有新连接到达(监听 Socket) | | `NET_POLLOUT` | 发送缓冲区有空间,可以写入数据 | | `NET_POLLERR` | 发生错误 | | `NET_POLLHUP` | 连接挂起(对端关闭) | | `NET_POLLNVAL` | 无效的文件描述符 | --- ## 8. 事件回调机制 可以通过注册回调函数,异步获取 Socket 事件通知: ### 8.1 定义回调函数 ```c #include "net_socket.h" void my_event_callback(int sockfd, net_event_t event, void *arg) { switch (event) { case NET_EVENT_CONNECTED: printf("Socket %d: 连接建立\r\n", sockfd); break; case NET_EVENT_DISCONNECTED: printf("Socket %d: 连接断开\r\n", sockfd); break; case NET_EVENT_DATA_RECEIVED: printf("Socket %d: 收到数据\r\n", sockfd); /* 注意:这里只是通知,需要调用 net_recv() 读取 */ break; case NET_EVENT_SEND_COMPLETE: printf("Socket %d: 发送完成\r\n", sockfd); break; case NET_EVENT_TIMEOUT: printf("Socket %d: 操作超时\r\n", sockfd); break; case NET_EVENT_ERROR: printf("Socket %d: 发生错误\r\n", sockfd); break; } } ``` ### 8.2 注册回调 ```c void event_callback_example(void) { int sock; /* 创建 Socket ... */ /* 注册事件回调 */ net_set_event_cb(sock, my_event_callback, NULL); /* 主循环 */ while (1) { net_poll(); } } ``` --- ## 9. 字节序处理 网络协议使用**大端序**(网络字节序),而 STM32 使用**小端序**(主机字节序)。发送和接收数据时需要进行字节序转换。 ### 9.0 `s_addr` 的唯一约定(2026-10-24 统一;全仓只此一族,勿再引入第二套) **定义**:`sin_addr.s_addr` 一律存**网络字节序整数**,判据是 `&s_addr` 的**第 0 个内存字节 == 点分第一段**。 小端主机(STM32F407 与 PC 模拟器都是)上 `"192.168.31.100"` 的数值 = **`0x641FA8C0`**(内存里是 `C0 A8 1F 64`)。 | 方向 | 用哪个 API | 要点 | |---|---|---| | 字符串 → `s_addr` | `net_inet_addr()` | 失败返回 **0**(与 BSD 一致),故 `"0.0.0.0"` 与非法串同值;**别拿 `NET_INADDR_NONE` 当失败哨兵** | | `s_addr` → 字符串 | `net_inet_ntoa()` | 内部先 `NET_NTOHL` 再**高段在前**打印(TRAP-NET-21 定案) | **不变量(有单测钉住)**:`net_inet_ntoa(net_inet_addr(s), buf)` 还原 `s`;`net_inet_addr(net_inet_ntoa(v, buf))` 还原 `v`。 `net_getsockname` / `net_getpeername` / `net_recvfrom` 填出来的 `s_addr`,与喂进 `net_bind` / `net_connect` / `net_sendto` 的 `s_addr` **同族、可直接相等比较**。历史背景(同一文件曾并存两套约定、`ntoa` 注释里的例子取了废弃族的值) 见 `docs/问题记录/CH395F_Trap_Records.md` TRAP-NET-21 的 2026-10-24 后续条目。 **UDP 也走队列(2026-11-03 改造)**:`net_bind`(UDP) / `net_sendto` / `net_recvfrom` / `net_recvfrom_to` / `net_udp_set_peer` 与 TCP 同一口径——投 `netMsgQueue`、在 `netTask` 里碰芯片、结果搭 32 位任务通知值回来。 改造前有、现在作废的两件事:①"仅限本层执行任务(netTask)调用"——`NET_ERR_NOT_IN_NETTASK`(-12)自本 改造起**没有任何返回路径**,常量保留只为老代码与历史文档对得上号,新代码不要拿它当判据;②"UDP 只在单任务 独占期可用"。源地址回传不占 `net_msg_t`(该结构按值拷入队列,AGENTS 禁止加"回传用"字段),而是经 `Drivers/BSP/NET/net_udp.c` 的 per-fd 出参槽写回调用方自己的内存,代价是**同一 fd 不允许两个任务同时收包** (第二个拿 `NET_ERR_BUSY`)。芯片形态(服务器 / 客户端)与剥头规则见 §6.0,调用约束见 §6.3。 ### 9.1 端口号转换 ```c uint16_t port = 8080; /* 主机序 -> 网络序(发送前转换) */ uint16_t net_port = net_htons(port); /* 网络序 -> 主机序(接收后转换) */ uint16_t host_port = net_ntohs(net_port); ``` ### 9.2 IP 地址转换 ```c /* 字符串转网络序 IP */ uint32_t ip = net_inet_addr("192.168.31.100"); /* 网络序 IP 转字符串 */ char ip_str[16]; net_inet_ntoa(ip, ip_str); /* ip_str = "192.168.31.100" */ ``` ### 9.3 填充地址结构体 ```c struct net_sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = net_htons(8080); /* 端口转换 */ addr.sin_addr.s_addr = net_inet_addr("192.168.31.100"); /* IP 转换 */ /* 如果要绑定任意地址 */ addr.sin_addr.s_addr = net_inet_addr("0.0.0.0"); /* 或者直接 */ addr.sin_addr.s_addr = INADDR_ANY; ``` ### 9.4 读取地址信息 ```c struct net_sockaddr_in client_addr; int addr_len = sizeof(client_addr); int client_sock = net_accept(listen_sock, (struct net_sockaddr *)&client_addr, &addr_len); if (client_sock >= 0) { /* 转换端口到主机序 */ uint16_t port = net_ntohs(client_addr.sin_port); /* 转换 IP 到字符串 */ char ip_str[16]; net_inet_ntoa(client_addr.sin_addr.s_addr, ip_str); printf("客户端: %s:%d\r\n", ip_str, port); } ``` --- ## 10. API 参考 ### 10.1 核心 API #### net_init - 网络初始化 ```c int net_init(const char *ip, const char *mask, const char *gateway, const uint8_t *mac); ``` | 参数 | 说明 | |------|------| | `ip` | IP 地址字符串,NULL 使用 DHCP | | `mask` | 子网掩码字符串,NULL 使用默认 | | `gateway` | 网关地址字符串,NULL 使用默认 | | `mac` | MAC 地址(6 字节);NULL 或全零 = 芯片内置 MAC(`ch395f_init()` 后设置) | | 返回值 | 0 成功,-1 失败 | #### net_get_generation / net_generation_inc - 网络代次号 ```c uint32_t net_get_generation(void); void net_generation_inc(void); ``` | 函数 | 说明 | |------|------| | `net_get_generation` | 获取当前网络代次号(首次 `net_init` 成功前为 0,每次网络重建 +1) | | `net_generation_inc` | 代次号加 1(netTask 重建成功、网络已可用后调用) | **用途**:长驻 TCP 服务(FTP :21 / 对时 :8888 / 远程显示 :7003)在监听循环中检测代次号变化后主动重建监听 Socket,网口配置立即生效后可自动在新 IP 上恢复(重建会断开既有连接,属预期行为)。详见 `docs/子系统设计/网络配置说明.md` §4。 #### net_poll - 状态轮询 ```c int net_poll(void); ``` | 参数 | 说明 | |------|------| | 返回值 | 有事件发生的 socket 数量 | **注意**:必须在主循环中定期调用。 #### net_socket - 创建 Socket ```c int net_socket(int domain, int type); ``` | 参数 | 说明 | |------|------| | `domain` | 地址族,仅支持 `AF_INET` | | `type` | `SOCK_STREAM`(TCP) 或 `SOCK_DGRAM`(UDP) | | 返回值 | socket 描述符 (0~7),失败返回 -1 | #### net_bind - 绑定地址 ```c int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen); ``` | 参数 | 说明 | |------|------| | `sockfd` | socket 描述符 | | `addr` | 地址结构体指针 | | `addrlen` | 地址结构体长度 | | 返回值 | 0 成功,-1 失败 | #### net_listen - 开始监听 ```c int net_listen(int sockfd); ``` | 参数 | 说明 | |------|------| | `sockfd` | socket 描述符 | | 返回值 | 0 成功,-1 失败 | > **自动重监听**:`net_listen()` 对 TCP 监听 Socket 置 `auto_relisten = 1`。连接结束后 net 层自动回到 `LISTENING`,应用层无需手动 `net_close()` + `net_listen()`。 #### net_accept - 接受连接 ```c int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen); ``` | 参数 | 说明 | |------|------| | `sockfd` | 监听 socket 描述符 | | `addr` | 输出:客户端地址 | | `addrlen` | 输入输出:地址长度 | | 返回值 | 新连接描述符(单连接模式下 == 监听 `sockfd`),无连接时返回 -1(可循环重试) | > 单连接模式:监听 Socket 在 CONNECT 后**自身即数据通道**(§5.1)。`net_accept` 失败时若状态为 CLOSED 且 `auto_relisten` 开启,内部触发**非阻塞**重监听(失败交重试轮询,§3.6)。事件未收到也不会让监听死亡——合一对账(100ms 周期,§3.6)在 ~200ms 内修正"本层 LISTENING 而芯片监听消失"等失同步。 #### net_listener_armed / net_can_read - 就绪门控(**纯 RAM 读,不走消息队列**) ```c int net_listener_armed(int sockfd); /* 1 = 监听器在服役,值得发起 accept;0 = 跳过本轮 */ int net_can_read(int sockfd); /* 1 = 应发一次 recv;0 = 可跳过本轮 */ ``` | 项 | 说明 | |------|------| | 代价 | **零 RPC**:只读 Socket 控制块(`net_get_sock()`),不入队、不等 netTask 回执 → 任意任务上下文可安全周期调用,**不被 netTask 忙碌程度拖住** | | `net_listener_armed` 判据 | `ESTABLISHED`(CONNECT 已处理、待应用 accept——单连接模型的"连接已到"电平,漏放行则任务周期慢的消费者永不 accept),或 `LISTENING`,或 `CLOSED + auto_relisten` **且 RX 有残留**(closed-with-data 救回窗)——表达"这个监听器还在服役、这一轮值不值得发起 accept",而非"一定有连接"。(前身 `net_can_accept()` 只认 `ESTABLISHED`,会把监听服役窗口全挡掉、丢 CONNECT 探针从不运行——已退役,其 ESTABLISHED 放行态并入本函数,勿再引入) | | `net_can_read` 判据 | `ESTABLISHED` → 看 `readable`;**非 `ESTABLISHED` 一律返 1**(必须让上层 recv 一次拿到 EOF/错误并复位自身状态,门掉它会把"连接已消失"永久藏住);描述符失效也返 1(让 recv 报 BADF 走既有错误分支) | | 用途 | **门控**:无活时跳过 `net_accept()`/`net_recv()`——两者内部都是 `osMessageQueuePut(osWaitForever)` + `xTaskNotifyWait(portMAX_DELAY)`,**均无上界**(队列深 8 且与 FTP/timeSync 共享) | | ⚠ 不可作为唯一判据 | `readable` 只由 RECV_OK 中断置位,而 §3.6 的对账**不含**"芯片 RX 非空但 `readable=0`" → 事件未收到时 `net_can_read()` 恒返 0。**调用方必须保留低频兜底 recv**(`ui_net_sink.c` 每 32 轮无条件收一次,教训见 `docs/子系统设计/看门狗设计说明.md` §11.5 第 1 条) | | 竞态 | netTask 是唯一写者,单字读;最坏"晚一轮发现"(netTask 每 1 ms 就 `net_poll()`)或"扑空"(RPC 返回 `NET_ERR_WOULDBLOCK`),均无副作用 | | 模拟器端 | `net_socket_sim.c` 同名实现**恒返回 1**——Winsock 本就非阻塞、无 RPC 成本,恒 1 使门控在模拟器上逐位等价于改前行为 | #### net_connect - 连接服务器 ```c int net_connect(int sockfd, const struct net_sockaddr *addr, int addrlen); ``` | 参数 | 说明 | |------|------| | `sockfd` | socket 描述符 | | `addr` | 服务器地址 | | `addrlen` | 地址长度 | | 返回值 | 0 成功,-1 失败 | #### net_send - 发送数据 ```c int net_send(int sockfd, const void *buf, int len, int flags); ``` | 参数 | 说明 | |------|------| | `sockfd` | socket 描述符 | | `buf` | 数据缓冲区 | | `len` | 数据长度 | | `flags` | 通常为 0 | | 返回值 | 实际发送字节数,失败返回 -1 | #### net_recv - 接收数据(默认无限阻塞) ```c int net_recv(int sockfd, void *buf, int len, int flags); ``` | 参数 | 说明 | |------|------| | `sockfd` | socket 描述符 | | `buf` | 接收缓冲区(须保持有效至调用返回) | | `len` | 缓冲区大小 | | `flags` | 0=按 socket 阻塞属性等待,`NET_MSG_DONTWAIT`=单次强制非阻塞 | | 返回值 | 实际接收字节数 (>0);`0`=对端关闭(net 层随即自动重监听);`-1`=错误(非阻塞无数据=`NET_ERR_WOULDBLOCK`;close/网络重建/陈旧重同步取消=`NET_ERR_CONNRESET`;**阻塞形态无"预算内无数据"一档——默认无限等待,超时是调用方策略**) | > **语义主流化**:对齐 POSIX/Python——`net_recv()` 默认**无限阻塞**(旧 `NET_RECV_TIMEOUT_MS` 30s 默认预算已删,API 不再强加隐藏值)。唤醒源 = 数据 / EOF / close / 网络重建(`NET_ERR_CONNRESET`);会话死亡由对账①在 ~400ms 内判死兜底(§3.6),不存在"对端消失后永久挂死"窗口。需要"等但不无限等":首选 `net_select(r,w,e,timeout_ms)`(§7.1),或 `net_recv_to()` 显式预算。 > > **挂起 recv 模型**:无数据时阻塞接收**不占 netTask**——登记挂起请求(每 fd 至多一个,禁止覆盖:覆盖会丢失前调用方通知 = 永久挂死)后**立即返回**;完成按主循环节拍检查(①数据 ②到点[仅显式预算] ③断连)并经任务通知唤醒调用方。等待期间调用方在自己上下文睡通知,netTask 全程零占用(消除队头阻塞)。内部 tripwire(原看门狗降级):挂起超 60s 只 `[ERR]` 留痕一条("完成逻辑缺位"探针),**绝不伪造 TIMEDOUT 返回值**。 > > ⚠ **文档契约(嵌入式特有,Python 没有)**:IWDG `required_mask` 内任务(当前产品掩码 `0x1F7`:default/net/ftp/ui/timeSync/adc/iec104/protect)**禁止无限阻塞 recv**——必须 `net_select` 超时或 `net_recv_to` 显式预算并周期喂心跳,否则"对端连上不发数据"这种完全正常的场景 = IWDG 复位。 #### net_recv_nb - 接收数据(非阻塞具名入口) ```c int net_recv_nb(int sockfd, void *buf, int len); ``` | 参数 | 说明 | |------|------| | `sockfd` / `buf` / `len` | 同 `net_recv` | | 返回值 | >0 字节数;`0`=对端关闭;`-1`=无数据(`NET_ERR_WOULDBLOCK`)或真错误(`net_get_errno` 可查) | 原 `NET_MSG_DONTWAIT` 路径转正:等价 `net_recv(sockfd, buf, len, NET_MSG_DONTWAIT)`,单次强制非阻塞、不改变 socket 的 blocking 属性。受 IWDG 监控 / 有周期职责的任务的推荐默认入口(生产用户:`ui_net_sink` :7003、`iec104_serv` :2404)。 #### net_setblocking - 设置阻塞模式属性 ```c int net_setblocking(int sockfd, int on); ``` | 参数 | 说明 | |------|------| | `sockfd` | socket 描述符 | | `on` | 1=阻塞(`net_socket()` 默认);0=非阻塞 | | 返回值 | 0 成功;-1 失败(描述符无效/未占用) | 模式挂 socket 控制块属性(对齐 BSD `O_NONBLOCK` / Python `setblocking`):非阻塞模式下 `net_recv()` 无数据立即返回 `NET_ERR_WOULDBLOCK`(EOF 判定不变:CLOSED/session_dead 无数据仍返回 0 并触发自动重监听)。与超时预算**正交**——模式是粘滞属性,单次预算用 `net_recv_to` / `net_select`。直写控制块单字节(先例 `net_set_event_cb`),任意上下文可调。 #### net_recv_to - 接收数据(调用方指定超时,显式预算分片版) ```c int net_recv_to(int sockfd, void *buf, int len, uint32_t timeout_ms); ``` | 参数 | 说明 | |------|------| | `sockfd` / `buf` / `len` | 同 `net_recv` | | `timeout_ms` | 本片等待预算(ms,**必须 >0**;0 = `net_recv` 的无限等待语义,恰与本接口"有界"存在理由相反,直接拒 `NET_ERR_INVAL`——本接口是**显式预算**入口,没有"默认 30s"隐藏值) | | 返回值 | >0=字节数(允许部分读,TCP 分段);`0`=对端关闭(排空残留后);`-1`=真错误(`net_get_errno` 可查);**-2=本片预算内无数据**(**不是 errno**,不得依赖 errno 判超时——`s_net_errno` 是跨任务共享全局量) | 调用方累计自身总预算:见到 -2 发起下一片,直到收齐 / EOF / 总预算耗尽。生产用途:lftpd 数据行读、timeSync 帧读(队头阻塞修复,完整例 §11.2);受 IWDG 监控任务的长等待必须走本接口(或 `net_select`)+ 每片喂心跳。 #### net_sendto - 发送 UDP 数据报 ```c int net_sendto(int sockfd, const void *buf, int len, int flags, const struct net_sockaddr *dest_addr, int addrlen); ``` | 参数 | 说明 | |------|------| | `sockfd` | **必须已 `net_bind()`**(未 bind 回 -1 / `NET_ERR_NOTCONN`:芯片侧 Socket 还没 open) | | `buf` / `len` | 一个数据报的载荷,单包 ≤ 1472 字节(手册 §9.2.4);更长由本层按 1KB 分块、复用 TCP 的挂起 send 在 netTask 里推进 | | `flags` | 目前只消费 `NET_MSG_DONTWAIT`:芯片发送缓冲不可用时立即回 `NET_ERR_WOULDBLOCK`,不登记挂起 | | `dest_addr` / `addrlen` | 非 NULL = 顺带把芯片 DesIP 指向它 → 芯片转**客户端模式**(此后只收该 IP 的包、收包不带信息头,见 §6.0);NULL = 不改目的地址,沿用当前形态 | | 返回值 | > 0 = 已发送字节数(**可能短写**,须检查);-1 = 失败(`net_get_errno()`) | 任意任务可调用:请求入 `netMsgQueue`,芯片事务在 `netTask` 串行执行(2026-11-03 起,旧"仅限 netTask 直调"形态作废)。 #### net_recvfrom - 接收 UDP 数据报(无限等待形态) ```c int net_recvfrom(int sockfd, void *buf, int len, int flags, struct net_sockaddr *src_addr, int *addrlen); ``` - 一次调用 = 一个**完整数据报**:服务器模式自动剥 8 字节信息头并从中取源地址,客户端模式整段是载荷、源地址回填固定对端(§6.0)。 - `buf` 装不下时交付 `len` 字节并把剩余部分**读空丢弃**——半包留在芯片缓冲里,下一次收包会从上一包的尾巴开始解析。 - `src_addr` / `addrlen` 可为 NULL;非 NULL 时 `*addrlen` 给 `sizeof(struct net_sockaddr_in)`,回填的 `s_addr` 是网络序整数(§9.0)。 - ⚠ **无数据即无限等待**:受 IWDG 监管的任务改 `net_recvfrom_to()`,或先 `net_select()` / `net_can_read()` 门控。 - 同一 fd 已有别的任务在收 → 本调用回 -1 / `NET_ERR_BUSY`(数据报无法归属两个等待者)。 #### net_recvfrom_to - 接收 UDP 数据报(显式预算形态) ```c int net_recvfrom_to(int sockfd, void *buf, int len, uint32_t timeout_ms, struct net_sockaddr *src_addr, int *addrlen); ``` | 参数 | 说明 | |------|------| | `timeout_ms` | 等待预算(ms,**必须 > 0**;0 = 无限等待,与本接口"有界"的存在理由相反,直接拒 `NET_ERR_INVAL`——无限形态请用 `net_recvfrom`) | | 返回值 | > 0 = 本包交付字节数(小于芯片内包长 = 被截断,多余部分已丢弃);**-2 = 预算内没等到**(**不是 errno**,不得据此判错误);-1 = 真错误 | 与 `net_recv_to()` 同一口径,是受 IWDG 监控任务的合法 UDP 收包入口;等待期本 fd 登记一个挂起槽、netTask 立即让出(不占 CPU、不占 netTask)。 #### net_udp_set_peer - 切换 UDP 芯片形态(不重开 Socket) ```c int net_udp_set_peer(int sockfd, const struct net_sockaddr_in *peer); ``` | 参数 | 说明 | |------|------| | `peer` | 非 NULL = 客户端模式,DesIP/DesPort 指向该对端(只收它的包,收包无信息头);NULL = 回服务器模式(DesIP = `0xFFFFFFFF`、DesPort = 0,任意主机可发,收包带 8 字节信息头) | | 返回值 | 0 成功;-1 失败(未 bind → `NET_ERR_NOTCONN`) | 只写两个寄存器、**不重开 Socket**,因此不清芯片缓冲;要连残留包一起丢掉就再 `net_bind()` 一次(重开形态)。 #### net_udp_get_mode - 读当前 UDP 芯片形态 ```c int net_udp_get_mode(int sockfd); ``` 返回 `NET_UDP_MODE_SERVER` / `NET_UDP_MODE_CLIENT`,参数错回 -1。给诊断、单测和上层判读"要不要切回服务器模式"用;纯 RAM 读,不入队。 #### net_close - 关闭 Socket ```c int net_close(int sockfd); ``` 释放 Socket 控制块,TCP 先断开连接。⚠ **单连接模型(MCU 端 CH395F)不得对监听 fd 调本 API**——连接与监听同一 fd,close 会连监听一起毁掉(§12.4);EOF 时 net 层已 `auto_relisten`,应用层**不得**在 EOF 时 close 该 fd。"放弃会话但保监听"用下面的 `net_drop_conn()`。 #### net_drop_conn - 主动拆会话、保留监听(队头阻塞收口) ```c int net_drop_conn(int sockfd); ``` 应用**放弃会话**(对时 30s 预算耗尽、协议超时)时主动拆掉当前 TCP 会话并**保留监听**——单连接模型下挂起但 TCP 仍活的客户端会永久占住监听(PC 内核自动 ACK keepalive,芯片僵尸判不死,定案见 `docs/网络/网络自愈架构设计.md` §5.2 E13),而应用又不得 `net_close` 监听 fd(毁监听);本 API 补上"拆连接不拆监听"的应用侧形态。 - **netTask 侧**:先结束本 fd 挂起 recv(排空/取消 + 必通知),再 `sock_reset` QUARANTINE(FIN + 排渣 + 排空事件 + 等 byte0=00H,TRAP-NET-18 异常释放语义,400ms 底);本层收口 CLOSED;`auto_relisten` fd 随即 `net_listener_request` 登记重监听(下一轮 `net_listener_tick` 执行,30ms 节奏重试 + reconciler 形态④兜底) - **幂等**:无活动会话(非 ESTABLISHED/CONNECTING)直接返 0,不动监听 - **EOF 路径不要调**:EOF 时 net 层 `auto_relisten` 已接管(§12.4),无需也不应再拆 - 经消息队列委托 netTask(同其它 `net_*` API,非 netTask 上下文调用);等待期 netTask 有界占用 ~400-600ms - 模拟器(`net_socket_sim.c`,数据 fd ≠ 监听 fd)实现为关数据连接、监听常驻 - 台架定案(2026-10-13):CH395F close **无 RST 语义**——对端内核存活但不 close 时芯片停 FIN_WAIT_2 长尾,监听恢复受限于对端 close;对端配合则 TIME_WAIT 过渡 ~150ms → reconciler 形态④ relisten → 新连接接入。不配合场景兜底 = 芯片 TIMEOUT/keepalive 拆僵尸(E13 定案 30~90s)。详见 `docs/问题记录/CH395F_Trap_Records.md` TRAP-NET-07 #### 自愈与查询 API **net_sock_state — 查询 Socket 状态(只读快照)** ```c int net_sock_state(int sockfd); ``` 跨任务只读快照(单字节读);描述符无效/未占用返回 -1;值可能滞后 netTask 最新状态。用于**状态轮询**(客户端等 ESTABLISHED,§5.2),不替代 accept/connect。 **net_resync_listen / net_relisten — 已退役(勿再引入)** 两者曾是"应用层手动自愈"入口:`net_resync_listen()` 只修 MCU 侧状态(D14 静默回收),`net_relisten()` 无条件芯片侧 open+listen。现状: - **静默回收**由对账⑤自动认领(§3.6:本层 ESTABLISHED 而芯片 0x05/0x01 → RESYNC 语义,≤400ms 去抖 + 1s 冷却,纪律"不发芯片命令"原样保留在认领动作内)——应用无需判定"连接是否已死",也就消除了"误判活连接调 resync 把活连接标成监听态"的 footgun。 - **监听重建**由 reconciler 形态③④(100ms 周期,唯一重建入口 = `net_reconcile_tick`)/ D10(30ms 重试)单一所有者负责——`net_relisten()` 对健康监听器反而是 footgun(无条件 open→USE→close 自毁 ~1s,原注释自认)。 生产调用者本就为零(仅板级测试 Phase 13,已改由自动机制接管)。历史实现见 `net_socket.c` git 历史。 **net_getsockname — 获取 Socket 本地地址(IP+端口)** ```c int net_getsockname(int sockfd, struct net_sockaddr *addr, int *addrlen); ``` Socket 已创建或已绑定时可用;port 为 0(动态分配)时返回已分配的动态端口(49152 起)。 ### 10.2 消息队列 API #### net_process_messages - 处理消息队列 ```c void net_process_messages(void); ``` 处理其他任务投递的请求(send/recv/connect/accept/close/drop_conn)。每次调用先跑 4 个**非阻塞** tick,再取队列消息(0 超时出队): 1. **D10 重监听重试**(`net_listener_tick`):断连后监听器恢复的到点重试(30ms 间隔 / 2s 时限,§3.6) 2. **挂起 recv 完成检查**(`net_pending_tick`):按 ①数据 ②到点(仅显式预算) ③断连 检查每个挂起请求并通知调用方;④ 挂起超时留痕探针(编译期选项,默认关):挂起超阈值只 `[ERR]` 留痕一次,不强制结束 3. **挂起 send 推进**(`net_send_pending_tick`):按 ①断连 ②推进(每轮至多一块 `NET_SEND_CHUNK_MAX`) ③写完 ④无进展超时 推进挂起 send 并通知调用方(结果恒为已发字节数,短写由调用方检查) 4. **TRAP-NET-17 芯片/本层对账 + 监听重建(合一 `net_reconcile_tick`)**:100ms 周期,判据读驱动状态缓存(`ch395f_sock_state_cached`),修正**五类**失同步(①②③④⑤)+ U11 被动重同步——原 200ms 对账与 100ms 监听器 pump 已合并为单一 tick,**唯一重建入口**=形态③④(§3.6) > `net_msg_t.timeout_ms`:显式预算(`net_recv_to()`),0=**无限等待**(无 `NET_RECV_TIMEOUT_MS` 30s 默认预算——超时归调用方)。消息类型 7 种(send/recv/close/connect/listen/accept/drop_conn)——自愈类消息 `NET_MSG_RESYNC_LISTEN` / `NET_MSG_RELISTEN` 已随 API 退役删除。 #### net_send_sock - 发送数据(内部版本) ```c int net_send_sock(net_sock_t *p_sock, const void *buf, int len); ``` 直接操作 Socket 控制块进行发送,不经过消息队列。**只能在 `netTask` 中调用**,用于 `netTask` 内的网络业务逻辑。 #### net_recv_sock - 接收数据(内部版本) ```c int net_recv_sock(net_sock_t *p_sock, void *buf, int len, int flags); ``` 直接操作 Socket 控制块进行接收,不经过消息队列。**只能在 `netTask` 中调用**。 #### net_close_sock - 关闭 Socket(内部版本) ```c int net_close_sock(net_sock_t *p_sock); ``` 直接操作 Socket 控制块关闭连接,不经过消息队列。**只能在 `netTask` 中调用**。 #### net_listen_locked - 开始监听(内部版本) ```c int net_listen_locked(net_sock_t *p_sock); ``` 直接操作 Socket 控制块开始监听,不经过消息队列。**只能在 `netTask` 中调用**(供 TCP 服务的代次号重建逻辑使用)。 ### 10.3 辅助 API #### net_set_event_cb - 注册事件回调 ```c int net_set_event_cb(int sockfd, net_event_cb_t cb, void *arg); ``` #### net_get_sock - 获取 Socket 控制块 ```c net_sock_t *net_get_sock(int sockfd); ``` #### net_get_errno - 获取错误码 ```c int net_get_errno(void); ``` ### 10.4 字节序转换 ```c uint16_t net_htons(uint16_t hostshort); /* 主机序 -> 网络序 */ uint16_t net_ntohs(uint16_t netshort); /* 网络序 -> 主机序 */ uint32_t net_htonl(uint32_t hostlong); /* 主机序 -> 网络序 */ uint32_t net_ntohl(uint32_t netlong); /* 网络序 -> 主机序 */ uint32_t net_inet_addr(const char *cp); /* 字符串 -> 网络序 IP */ char *net_inet_ntoa(uint32_t addr, char *buf); /* 网络序 IP -> 字符串 */ ``` ### 10.5 select/poll ```c int net_select(int nfds, net_fd_set *readfds, net_fd_set *writefds, net_fd_set *exceptfds, net_timeval *timeout); int net_poll_events(net_pollfd *fds, int nfds, int timeout); ``` ### 10.6 fd_set 操作宏 ```c NET_FD_ZERO(fdset) /* 清空集合 */ NET_FD_SET(fd, fdset) /* 添加 fd */ NET_FD_CLR(fd, fdset) /* 移除 fd */ NET_FD_ISSET(fd, fdset) /* 检查 fd 是否在集合中 */ ``` --- ## 11. 完整示例 ### 11.1 TCP Client 自动重连 ```c #include "net_socket.h" #include #include #define SERVER_IP "192.168.31.200" #define SERVER_PORT 8080 void tcp_client_reconnect(void) { int sock = -1; struct net_sockaddr_in addr; uint32_t last_try = 0; uint32_t reconnect_interval = 5000; /* 5 秒重连间隔 */ /* 初始化网络 */ net_init("192.168.31.100", "255.255.255.0", "192.168.31.1", NULL); while (1) { net_poll(); /* 尝试重连 */ if (sock < 0 && (HAL_GetTick() - last_try) >= reconnect_interval) { last_try = HAL_GetTick(); sock = net_socket(AF_INET, SOCK_STREAM); if (sock >= 0) { memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = net_htons(SERVER_PORT); addr.sin_addr.s_addr = net_inet_addr(SERVER_IP); if (net_connect(sock, (struct net_sockaddr *)&addr, sizeof(addr)) == 0) { printf("正在连接服务器...\r\n"); } else { net_close(sock); sock = -1; } } } /* 检查连接状态 */ if (sock >= 0) { if (net_sock_state(sock) == NET_SOCK_STATE_ESTABLISHED) { /* 已连接,发送数据 */ static uint32_t last_send = 0; if ((HAL_GetTick() - last_send) >= 1000) { last_send = HAL_GetTick(); net_send(sock, "heartbeat", 9, 0); } /* 检查接收(非阻塞具名入口 net_recv_nb) */ char buf[128]; int len = net_recv_nb(sock, buf, sizeof(buf) - 1); if (len > 0) { buf[len] = '\0'; printf("收到: %s\r\n", buf); } else if (len == 0) { /* 连接断开 */ printf("连接断开\r\n"); net_close(sock); sock = -1; } } else if (net_sock_state(sock) == NET_SOCK_STATE_CLOSED) { /* 连接失败或断开 */ printf("连接失败,准备重连...\r\n"); net_close(sock); sock = -1; } } } } ``` ### 11.2 分片阻塞读(`net_recv_to`,timeSync 模式) 长等待 recv 曾独占 netTask(队头阻塞)。挂起 recv 模型下:单次 `net_recv_to` 单次占用有界(≤ 本片预算)且等待期间 netTask 零占用(挂起模型);调用方累计总预算,收齐协议帧再返回: ```c #define FRAME_LEN 8 /* "TIME" + uint32 大端 本地时间 */ #define TOTAL_BUDGET 30000U /* 总等待预算(ms) */ static int read_time_frame(int conn_sock, uint8_t buf[FRAME_LEN]) { int got = 0; TickType_t t0 = xTaskGetTickCount(); while (got < FRAME_LEN) { TickType_t elapsed = (TickType_t)(xTaskGetTickCount() - t0); uint32_t rem = (elapsed >= pdMS_TO_TICKS(TOTAL_BUDGET)) ? 0U : (uint32_t)(pdMS_TO_TICKS(TOTAL_BUDGET) - elapsed); if (rem == 0U) { return -1; /* 总预算耗尽 */ } int n = net_recv_to(conn_sock, buf + got, FRAME_LEN - got, rem); if (n > 0) { got += n; /* 允许部分读(TCP 分段) */ } else if (n == 0) { return (got >= FRAME_LEN) ? 0 : -1; /* EOF:收齐成功,否则失败 */ } else if (n == -2) { continue; /* 本片超时:累计预算,下片继续 */ } else { return -1; /* 真错误(net_get_errno 可查) */ } } return 0; } ``` 约束:`buf` 须在**整个读过程**有效(挂起持指针,不拷数据);同一 fd 同一时刻至多一个挂起 recv(覆盖会挂死前调用方);close/网络重建/陈旧重同步会通知(`NET_ERR_CONNRESET` 等)→ 上述 `n<0` 分支统一当失败处理。 --- ## 12. 常见问题 ### 12.1 net_poll() 必须调用吗? **是的**,且 `net_poll()` 与 `net_process_messages()` 缺一不可: - `net_poll()`:GPIO 轮询 INT#,**每轮批处理一批**中断,更新各 Socket 状态、触发事件回调(RECV 为电平触发,每轮只处理一批,避免饿死主循环)。 - `net_process_messages()`:除串行处理队列请求外,每轮先跑 4 个**非阻塞**检查——D10 重监听重试(断连后监听器恢复)→ 挂起 recv 完成检查(阻塞 recv 的唤醒)→ 挂起 send 推进(阻塞 send 的唤醒)→ TRAP-NET-17 芯片/本层对账 + 监听重建合一 reconciler(100ms 周期,读驱动状态缓存,事件未收到/静默回收/监听失位自愈,§3.6)。 建议主循环周期 ≤ 10ms(本项目 1ms)。若 netTask 主循环停摆(断点、任务被挂起),监听器不会恢复、阻塞 recv 不会完成——这是软件侧"监听死机"的最常见原因。 ### 12.2 Socket 数量有限制吗? 是的,CH395F 最多支持 8 个 Socket(索引 0~7)。其中: - TCP Server(单连接):1 个监听 Socket 即数据通道 - TCP Client:最多 8 个 - UDP:最多 8 个 ### 12.3 发送数据有什么限制? - 每次发送的数据长度不能超过 `NET_SEND_BUF_SIZE`(默认 4096 字节) - TCP 需要等待发送缓冲区空闲(`send_ready` 标志) - UDP 单包载荷最大 1472 字节(写入超过此值,CH395F 会自动分片为多个 UDP 包发送) - 接收侧:每 Socket RX 缓冲 2KB(4×512B,即芯片接收窗口)、TX FIFO 1KB(2×512B)、MSS=1024。对端常态把 2KB 窗口填满是**正常**现象,不等于"冻结"(TC-NET-604 结案,见 §12.10) ### 12.4 如何处理连接断开? **方式一:阻塞接收(无限等待——⚠ 仅限不受 IWDG 监控且无周期职责的任务,§10.1 文档契约)** ```c int len = net_recv(sock, buf, sizeof(buf), 0); if (len <= 0) { /* 连接断开或错误(EOF / CONNRESET / 真错误,net_get_errno 可查) */ net_close(sock); } ``` **方式二:非阻塞接收(`net_recv_nb`)** ```c int len = net_recv_nb(sock, buf, sizeof(buf)); if (len < 0 && net_get_errno() == NET_ERR_WOULDBLOCK) { /* 没有数据 */ } else if (len <= 0) { /* 连接断开 */ } ``` **⚠ 单连接模型(MCU 端 CH395F)的断开处理**:监听 Socket 在 CONNECT 后**自身即数据通道** (`g_client == g_listen`);`net_recv()` 返回 0(EOF)时 net 层已自动把该 Socket 放回 LISTEN (`auto_relisten`,BSD 风格"监听常驻")。此时**不得**再对它调 `net_close()`——那会把监听一起关掉, 后续连接直接被拒(连接与监听同一 fd,重建还受 TRAP-NET-09 时序限制)。正确写法: ```c } else if (len == 0) { if (client_fd != listen_fd) { net_close(client_fd); /* 多连接后端(PC 模拟器):数据通道独立,需显式关闭 */ } client_fd = -1; /* 单连接后端:监听已由 net 层恢复,直接等下一次 accept */ } ``` (2026-09-21 台架实证:`ui_net_sink` 曾在 EOF 时无条件 `net_close`,表现为 7003 答完一帧即断连、 端口随后"连接被拒"。) **应用主动放弃会话(非 EOF)**:若会话是**应用侧放弃**的(对时 30s 预算耗尽、协议超时)而对端 TCP 仍活,EOF 路径不会发生、挂起客户端会永久占住单连接监听——此时用 `net_drop_conn(fd)`(§10.1)拆会话保监听,**不要** `net_close` 监听 fd。EOF 路径**不要**调它(`auto_relisten` 已接管)。 **芯片静默回收(D14,2026-09-22 台架实证)**:对端发 FIN 后,芯片可能**自行把 Socket 回收回 LISTEN 且不发 DISCONNECT 中断** → 本层滞留 ESTABLISHED,应用继续往死连接上读写,新连接的字节被旧会话吞掉。 **无需应用处理**:对账⑤自动认领此形态(§3.6:2 轮去抖 ≈200ms + 1s 冷却)→ RESYNC 语义只修本层状态 + 取消本 fd 挂起 recv,**不发任何芯片命令**(发 close/重监听会摧毁芯片侧健康监听器 → 连接永拒,run8 实证 170s 拒绝)。等待期间该端口实际可接新连接(芯片侧已 LISTEN),新连接经对账补 accept 被本层感知。原显式 API `net_resync_listen(fd)` 已退役——"应用判定连接已死"这一步本身就是误判源(对活连接调用会把活连接标成监听态)。 ⚠ 切勿用**单次**芯片状态读决定重建:单次读可能落在"刚 open 未生效"瞬态,曾致监听反复重建直至 `relisten give up`(2026-09-23 实测风暴,三服务口全死)——任何重建动作必须去抖(连续确认)+ 冷却(§3.6)。 **方式三:事件回调** ```c void callback(int sockfd, net_event_t event, void *arg) { if (event == NET_EVENT_DISCONNECTED) { /* 连接断开 */ } } ``` ### 12.5 select 和 poll 的区别? | 特性 | select | poll | |------|--------|------| | 描述符集合 | fd_set(位掩码) | pollfd 数组 | | 最大 fd 数 | 32 | 无限制 | | 使用方式 | NET_FD_SET 宏 | 直接操作结构体 | 两者功能相同,推荐使用 `net_select()`,更接近 Linux 编程习惯。 ### 12.6 错误码说明 | 错误码 | 宏定义 | 说明 | |--------|--------|------| | 0 | NET_OK | 成功 | | -1 | NET_ERR | 通用错误 | | -2 | NET_ERR_BADF | 无效的 socket 描述符 | | -3 | NET_ERR_INVAL | 无效参数 | | -4 | NET_ERR_NOMEM | 内存不足 | | -5 | NET_ERR_NOTCONN | 未连接 | | -6 | NET_ERR_ISCONN | 已连接 | | -7 | NET_ERR_WOULDBLOCK | 非阻塞模式下操作未完成 | | -8 | NET_ERR_CONNRESET | 连接被重置 | | -9 | NET_ERR_TIMEDOUT | 操作超时 | | -10 | NET_ERR_NOSPACE | 发送缓冲区无空间 | | -11 | NET_ERR_BUSY | 操作未完成(BUSY) | ### 12.7 CH395F 命令执行状态码 CH395F 底层驱动返回的状态码(`ch395f_get_cmd_status()` 或 `ch395f_poll_cmd_status()`): | 代码 | 宏定义 | 说明 | |------|--------|------| | 0x00 | CH395F_ERR_SUCCESS | 成功 | | 0x10 | CH395F_ERR_BUSY | 忙,命令正在执行 | | 0x11 | CH395F_ERR_MEM | 内存管理错误 | | 0x12 | CH395F_ERR_BUF | 缓冲区错误 | | 0x13 | CH395F_ERR_TIMEOUT | 超时 | | 0x14 | CH395F_ERR_RTE | 路由错误 | | 0x15 | CH395F_ERR_ABRT | 连接中止 | | 0x16 | CH395F_ERR_RST | 连接复位 | | 0x17 | CH395F_ERR_CLSD | 连接关闭 | | 0x18 | CH395F_ERR_CONN | 无连接 | | 0x19 | CH395F_ERR_VAL | 值错误 | | 0x1A | CH395F_ERR_ARG | 参数错误 | | 0x1B | CH395F_ERR_USE | 已被使用(常见于重复 OPEN 或冲突配置) | | 0x1C | CH395F_ERR_IF | MAC 错误 | | 0x1D | CH395F_ERR_ISCONN | 已连接 | | 0x20 | CH395F_ERR_OPEN | 已打开 | | 0x5F | CH395F_CMD_RET_ABORT | 命令中止 | | 0xFA | CH395F_ERR_UNKNOW | 未知错误 | ### 12.8 故障排查 **问题:Socket 4~7 的 CONNECT/RECV 中断不触发** - 需使用 `GET_GLOB_INT_STATUS_ALL`(命令码 0x19,返回 2 字节)才能检测 Socket 4~7 的中断 - 1 字节版 `GET_GLOB_INT_STATUS`(命令码 0x29)仅支持 Socket 0~3 **问题:CH395F 初始化超时** - 芯片 INIT 需要约 200ms,轮询间隔应为 20ms(过短的查询可能干扰内部处理) - 超时值建议设为 4 秒以上 ### 12.9 线程安全问题 **问题:`net_send()` / `net_recv()` / `net_close()`(含 `net_recv_to()`)可以在任意任务中调用吗?** 可以。这些公共 API 通过消息队列将请求委托给 `netTask` 串行执行;**注意阻塞 recv 的"阻塞"发生在调用方自己的上下文**(挂起模型:登记请求后调用方睡任务通知,netTask 不等待,§3.6)——因此多个任务可同时持有 recv,netTask 只被数据路径短暂占用。 **问题:`netTask` 内可以直接调用 `net_send()` / `net_recv()` 吗?** 不可以。`netTask` 中必须使用 `net_send_sock()` / `net_recv_sock()` 等内部版本,否则会死锁(任务向自身发送消息,永远无人处理)。netTask 上下文的"立即看有无数据"需求用 `NET_MSG_DONTWAIT`(挂起请求对执行任务自身发起时直接拒绝,防自等自通知死锁)。 **问题:哪些操作要求 netTask 已运行?** `net_listen()` / `net_accept()` / `net_connect()` / `net_send()` / `net_recv()` / `net_close()` 全部经消息队列 → 只能在**netTask 已运行的上下文**(就绪屏障后的服务任务)调用;内核启动前的代码禁调任何 FreeRTOS/CMSIS-RTOS API,网络监听建立天然在内核启动后。`net_socket()` / `net_bind()` 为直接调用,但同样建议在屏障后。 **问题:消息队列由谁创建?在哪里创建?** 由 CubeMX 在 `MX_FREERTOS_Init()` 中创建(`osMessageQueueNew(8, sizeof(net_msg_t), NULL)`),在 `osKernelInitialize()` 之后执行,确保 FreeRTOS 内核已就绪。 **问题:`net_recv()` 返回 -1 后,`net_get_errno()` 的 `NET_ERR_WOULDBLOCK` 是谁设置的?** 消息体是**按值拷入队列**的:`osMessageQueuePut()` 复制整个 `net_msg_t`,`netTask` 改的是它自己那份副本, **调用方看不到**(极易踩坑)。因此实现上不靠消息字段回传错误码,而是把它搭在 `xTaskNotify()` 的通知值上: 失败时通知值 = 具体错误码(负值,如 `NET_ERR_WOULDBLOCK`),成功时 = 实际字节数/描述符; 公开 API 再把它恢复为 `s_net_errno`,并按 BSD 语义对外统一返回 -1。 推论:**不要给 `net_msg_t` 加"回传用"字段**——调用方读到的只会是自己的初值/栈垃圾; 需要回传请用调用方提供的指针,或复用通知值。(2026-09-21 实证:曾按"加 `msg.err` 字段"方式修错误码, 结果 `ui_net_sink` 每次空轮询都拿到栈垃圾 → 误判为"真错误" → 连上即 `net_close`。) ### 12.10 监听器自愈行为与沉降/隔离(D10 / TRAP-NET-09、TRAP-NET-17、TRAP-NET-18) **默认行为**(无需应用代码):断连 → `auto_relisten` 重开监听;重开失败 → 30ms 节奏重试(2s 时限);芯片事件未收到/静默回收/监听失位 → 单一 reconciler 100ms 自愈(分级去抖,对账与重建合一);中止复连首上传 → 沉降/隔离。全在 netTask 内,应用只需保证 `net_poll()` + `net_process_messages()` 主循环在跑。 | 场景 | 修复前症状 | 机制 | 生产验证(2026-09-23) | |---|---|---|---| | **TRAP-NET-17** 快速复连事件面未收到 → 监听静默死亡(E14 复核:非芯片丢事件,是读清消费竞态) | 待机后 UI `:7003` 快速复连 2/10 且**永久**拒连;FTP `:21` 100ms 间隔自第 29 轮死亡;设备日志**无报错**(状态静默滞留) | 单一 reconciler `net_reconcile_tick()`(100ms,对账①②⑤ + 重建③④/U11 合一,**唯一重建入口**=形态③④;判据读驱动状态缓存)+ 分级去抖(②1 轮/③④⑤2 轮/①4 轮)+ 冷却(1s);`handle_disconnect_event` 只置沉降事实、不再自行重建 | `:7003` 10/10;`:21` 100ms/0ms/600ms 各 50/50;UI 20/20;FTP 正常 10/10(SHA-256);中止 RST/FIN 5/5 + 5/5;零重建风暴(2026-10-13 合并后复测 `:7003` 50/50、`:21` 三档各 50/50 全绿) | | **TRAP-NET-18** 中止后首笔复连上传被静默截断 ≤2048B 并误报 226 成功 | RST/FIN 中止上传 → 紧接着的首笔上传"收"到 ~2KB 残留后判结束回 226 → 客户端以为成功 = **静默数据损坏**(第二笔起正常) | 异常释放(TIMEOUT)预重监听隔离:`net_drain_chip_rx()` 分块排空 RX 残渣 + 主动 close + 排空中断 + 等 CLOSED/TCP_CLOSED(400ms 起 / 800ms 上限 < 2s 时限) | RST 10/10、FIN 5/5 全通过;正常路径 30/30 + 20/20,SHA-256 全匹配 | | **TC-NET-604**"接收冻结"虚警 | 旧判读脚本把"对端填满 2KB RX 窗口"误判为 STALL/WEDGE | **结论:非缺陷**(芯片 RX 缓冲即窗口,对端填满是常态);判据改为脚本层 STEP + 客户端内容校验(RET SHA-256),虚警工具归档(`MDK-ARM/retracted_bench_20260922/`,README 禁重跑) | 真实 FTP 路径 227 轮全通过(8KB×10 … 1MB×2);并发 UI 流 600/600 CRC;设备零 FAIL/ERROR/WARN | **排障判据**(无 Wireshark 环境,纯生产路径):`MDK-ARM/ftp_freeze_probe.py`(真实 STOR + RETR SHA-256)、`test/host/ftp_abort_probe.py`(RST/FIN 中止复连)、`MDK-ARM/tcp_reconnect_probe.py`(快速复连存活甄别)、`MDK-ARM/bench_verdict.py`(脚本层 STEP 为真值;**`probe_classify.py` A/C 判读不可信**,见 retracted 归档 README)。详见 `docs/问题记录/CH395F_Trap_Records.md`。