Files
STM32F4-Base/docs/网络/BSD_Socket_API_使用指南.md
T
super_agent_bot fff961d803
CI / 静态门禁 (pre-commit) (push) Failing after 49s
CI / 主机侧单测 (MSVC) (push) Failing after 30s
CI / 静态门禁 (pre-commit) (pull_request) Failing after 45s
CI / 主机侧单测 (MSVC) (pull_request) Failing after 44s
feat(net/udp): UDP 数据面并入 netMsgQueue 串行执行(#73)
前置来自 #57 SNTP 调研:旧 net_sendto / net_recvfrom 由调用方直调
SPI 且无限自旋收包,既违约「netTask 是芯片唯一访问者」,又把 IWDG
预算吃在自旋里。

改造面
- 新增 Drivers/BSP/NET/net_udp.c/.h(真机专属,模拟器不编译):
  bind/set_peer/sendto/recvfrom 四条执行体 + 挂起收包表 + tick;
  net_msg_type_t 加四型,公开 API 一律「入队 → 睡在通知上」。
- 源地址出参不搭 net_msg_t(按值拷贝,AGENTS 禁加回传字段):改住
  net_udp.c 的 per-fd 槽,netTask 就地写回调用方内存,通知值只带
  结果码;每条出口都清槽,泄漏 = 该 fd 永久 BUSY。
- 发送复用 net_send_locked,不写第二份分块逻辑;「会话是否已死」
  收成一条判据 net_send_state_down()(TCP 看 ESTABLISHED、UDP 看
  UDP_OPEN),原先 4 处各写一遍。
- 顺带修掉真缺陷(新条 TRAP-NET-22):旧 sendto 把 DesIP 写成具体
  IP,芯片静默切 UDP Client,而旧 recvfrom 无条件剥 8 字节 → SNTP
  这类 48 B 应答丢载荷前 8 字节并交出垃圾源地址。加 udp_mode(塞进
  原有 padding,sizeof 不变)、bind 恒 SERVER、显式对端 sendto 切
  CLIENT、set_peer(fd, NULL) 轻量回 SERVER,仅 SERVER 且 avail >= 8
  才剥头。
- 截断定案:一次调用交付一个数据报,buf 装不下的尾巴用 64 B 栈上
  scratch 排空(不用 clear_recv_buf——它连后面已到达的包一起丢)。
- 取消点接齐:net_init 重建 cancel_all、net_socket 分配 fd clear、
  net_close settle_close + clear、重 bind 前先结算等包者,
  net_process_messages 第 5 个 tick 位挂 net_udp_tick。
- 收紧两处契约(原实现无生产调用者):UDP 未 bind 一律 -5;
  net_recv / _nb / _to 拒 DGRAM(绕过剥头就是静默吃 8 字节);
  NET_ERR_NOT_IN_NETTASK(-12) 退役为历史常量并写明原因。

验证
- 主机单测 test/host/net_socket 全套 616/616 绿(改造前 470):新
  分片 ns_s7_udp.c 20 组,改写 440 / 555 / 560 组;覆盖率
  net_udp.c 234/236(99.2%)、net_socket.c 956/969(98.7%),
  scripts/test_coverage_baseline.json 随本提交登记新文件。
- 测试抓到 4 个被测码真 bug:自等守卫误用调用侧谓词、芯片长度被读
  两次、槽指针取在 fd 合法性检查之前、重 bind 静默作废等待者。
- Keil ARMCC 0 Error(s) 0 Warning(s);net_udp.o Code 1,358 / ZI 224
  (8 fd × 28 B,其余模块零增长),全仓 RW_IRAM1 72.2%;内存台账
  受管区按门禁提示用 --write / --update 机器回填,未手改数字。
- pre-commit 全量钩子(含 doc-links、coding-standard、mem-budget、
  generated-fresh、bsp-err、registration、nested-comment)全绿。

限度
- 本轮无板子:真机回归 5 条(两端互通、set_peer(NULL) 能否恢复收
  广播、截断排空后下一包完整性、挂起 recv 期间 IWDG 余量、bind 写
  DesIP 与 open 先后的真芯片效果)已写进覆盖率口径「台架必验」。
- scripts/stack_report.py 退出码 1 是 10-26 起挂在账上的 iec104Task
  栈债(margin 1,016 < 1,024,见 docs/代码规范/pre-commit使用说明.md:
  122);本轮只让 net_bind 的 UDP 分支多一层静态帧(1,544 → 1,600),
  netTask margin 1,888 与 timeSyncTask 1,152 不受影响。抬栈要过
  mem-budget 台账,不在本分支自行处理。
- 登记簿销项按仓库约定留给主工作树,本分支不动 docs/任务规划/**。
2026-11-03 18:44:01 +08:00

79 KiB
Raw Blame History

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. 概述
  2. 架构说明
  3. 线程安全设计
  4. 快速开始
  5. TCP 编程
  6. UDP 编程
  7. I/O 多路复用
  8. 事件回调机制
  9. 字节序处理
  10. API 参考
  11. 完整示例
  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()
头文件 <sys/socket.h> "net_socket.h"
无限阻塞约束 无 ⚠ IWDG required_mask 内任务禁止(嵌入式特有契约,§10.1)

2. 架构说明

2.1 分层架构

BSD 网络栈分层架构

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 状态机

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 消息队列模式

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()<br/>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 中的网络业务逻辑,避免向自身发送消息造成死锁:

/* 在 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 包含头文件

#include "net_socket.h"
#include "net_select.h"

4.2 初始化网络

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,无需维护重监听逻辑
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 既做监听又做数据通信:

#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

#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

#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

#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 的读写事件:

#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 结构体数组:

#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 定义回调函数

#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 注册回调

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 端口号转换

uint16_t port = 8080;

/* 主机序 -> 网络序(发送前转换) */
uint16_t net_port = net_htons(port);

/* 网络序 -> 主机序(接收后转换) */
uint16_t host_port = net_ntohs(net_port);

9.2 IP 地址转换

/* 字符串转网络序 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 填充地址结构体

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 读取地址信息

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 - 网络初始化

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 - 网络代次号

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 - 状态轮询

int net_poll(void);
参数 说明
返回值 有事件发生的 socket 数量

注意:必须在主循环中定期调用。

net_socket - 创建 Socket

int net_socket(int domain, int type);
参数 说明
domain 地址族,仅支持 AF_INET
type SOCK_STREAM(TCP) 或 SOCK_DGRAM(UDP)
返回值 socket 描述符 (0~7),失败返回 -1

net_bind - 绑定地址

int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen);
参数 说明
sockfd socket 描述符
addr 地址结构体指针
addrlen 地址结构体长度
返回值 0 成功,-1 失败

net_listen - 开始监听

int net_listen(int sockfd);
参数 说明
sockfd socket 描述符
返回值 0 成功,-1 失败

自动重监听:net_listen() 对 TCP 监听 Socket 置 auto_relisten = 1。连接结束后 net 层自动回到 LISTENING,应用层无需手动 net_close() + net_listen()。

net_accept - 接受连接

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 读,不走消息队列)

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 - 连接服务器

int net_connect(int sockfd, const struct net_sockaddr *addr, int addrlen);
参数 说明
sockfd socket 描述符
addr 服务器地址
addrlen 地址长度
返回值 0 成功,-1 失败

net_send - 发送数据

int net_send(int sockfd, const void *buf, int len, int flags);
参数 说明
sockfd socket 描述符
buf 数据缓冲区
len 数据长度
flags 通常为 0
返回值 实际发送字节数,失败返回 -1

net_recv - 接收数据(默认无限阻塞)

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 - 接收数据(非阻塞具名入口)

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 - 设置阻塞模式属性

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 - 接收数据(调用方指定超时,显式预算分片版)

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 数据报

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 数据报(无限等待形态)

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 数据报(显式预算形态)

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)

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 芯片形态

int net_udp_get_mode(int sockfd);

返回 NET_UDP_MODE_SERVER / NET_UDP_MODE_CLIENT,参数错回 -1。给诊断、单测和上层判读"要不要切回服务器模式"用;纯 RAM 读,不入队。

net_close - 关闭 Socket

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 - 主动拆会话、保留监听(队头阻塞收口)

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 定案 3090s)。详见 docs/问题记录/CH395F_Trap_Records.md TRAP-NET-07

自愈与查询 API

net_sock_state — 查询 Socket 状态(只读快照)

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+端口)

int net_getsockname(int sockfd, struct net_sockaddr *addr, int *addrlen);

Socket 已创建或已绑定时可用;port 为 0(动态分配)时返回已分配的动态端口(49152 起)。

10.2 消息队列 API

net_process_messages - 处理消息队列

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 - 发送数据(内部版本)

int net_send_sock(net_sock_t *p_sock, const void *buf, int len);

直接操作 Socket 控制块进行发送,不经过消息队列。只能在 netTask 中调用,用于 netTask 内的网络业务逻辑。

net_recv_sock - 接收数据(内部版本)

int net_recv_sock(net_sock_t *p_sock, void *buf, int len, int flags);

直接操作 Socket 控制块进行接收,不经过消息队列。只能在 netTask 中调用。

net_close_sock - 关闭 Socket(内部版本)

int net_close_sock(net_sock_t *p_sock);

直接操作 Socket 控制块关闭连接,不经过消息队列。只能在 netTask 中调用。

net_listen_locked - 开始监听(内部版本)

int net_listen_locked(net_sock_t *p_sock);

直接操作 Socket 控制块开始监听,不经过消息队列。只能在 netTask 中调用(供 TCP 服务的代次号重建逻辑使用)。

10.3 辅助 API

net_set_event_cb - 注册事件回调

int net_set_event_cb(int sockfd, net_event_cb_t cb, void *arg);

net_get_sock - 获取 Socket 控制块

net_sock_t *net_get_sock(int sockfd);

net_get_errno - 获取错误码

int net_get_errno(void);

10.4 字节序转换

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

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 操作宏

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 自动重连

#include "net_socket.h"
#include <stdio.h>
#include <string.h>

#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 零占用(挂起模型);调用方累计总预算,收齐协议帧再返回:

#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 文档契约)

int len = net_recv(sock, buf, sizeof(buf), 0);
if (len <= 0) {
    /* 连接断开或错误(EOF / CONNRESET / 真错误,net_get_errno 可查) */
    net_close(sock);
}

方式二:非阻塞接收(net_recv_nb)

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 时序限制)。正确写法:

} 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)。

方式三:事件回调

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。