1. 为什么用硬件协议栈芯片做以太网客户端
做嵌入式联网,很多人的第一反应是上LWIP + DMA + PHY,比如 STM32 + LAN8720A 这套经典组合。但如果你只是想把数据发到服务器,或者从服务器拉几个指令,又不想花两周时间去调协议栈和内存池,那 STM32F407VET6 配 CH395Q 这种带硬件 TCP/IP 协议栈的芯片,就是另一条非常省心的路。
我的场景很简单:一块 STM32F407VET6 最小系统板,采集传感器数据,通过网线上报给局域网里的上位机。数据量不大,几 KB 级别,但要求稳定、断电重启能自动恢复连接。主控本身要处理 AD7606 并口采样和达林顿管驱动逻辑,CPU 占用不能太高。所以我把网络这块整个外包给了 CH395Q——这颗芯片内部自己跑 TCP/IP 协议栈,主控只需要通过 SPI 把数据扔给它,它自己负责 TCP 三次握手、ACK 重传、ARP、ICMP 这些脏活累活,主控完全不用管。
这里要补充一下选型理由。CH395Q 这颗芯片在同类产品里,比 ENC28J60 强的地方在于:ENC28J60 只提供 MAC + PHY,TCP/IP 还是要你用 LWIP 在 MCU 上跑;而 CH395Q 是完整的硬件协议栈芯片,MCU 只发命令和数据。和 W5500 相比,CH395Q 有 4 个独立 Socket,能同时跑 TCP Server、TCP Client、UDP,价格也更低,开发资料和驱动代码沁恒给得也比较齐全。我实测下来,对于"MCU 作为 TCP 客户端主动上报"这种需求,CH395Q 的稳定性完全够用。
这篇文章适合谁看?如果你手头有 STM32F407VET6 或者任意一块 SPI 资源充足的 MCU,想快速给设备加上网口,又不想折腾 LWIP 的内存分配和移植,那就把这篇当一份"抄作业"参考。我会把硬件连接、驱动封装、TCP 客户端配置流程、常见坑全部拆开讲,文中代码是我自己用 C 语言写的,可以直接往工程里搬。
2. 硬件连接与电路设计要点
2.1 用 SPI 还是并口,这是个问题
CH395Q 支持 SPI、并口、UART 三种接口方式,引脚配置不同,工作模式也不同。对于 STM32F407VET6 来说,我选了 SPI 从机模式,原因有两条:
第一,省引脚。CH395Q 的并口模式要占 8 位数据线加若干控制线,而你如果用 AD7606 做并口 ADC 采样,那 MCU 的 FSMC 引脚基本被占满了,再接一个并口芯片会很挤。CH395Q 的 SPI 模式只要 4 根线 plus 一根中断线,压力小得多。
第二,速率足够。CH395Q 的 SPI 最高支持 30MHz 时钟,在这个速率下传输 1KB 数据只需要零点几毫秒,实际瓶颈根本不在这里,而在 TCP 链路的 RTT 和服务器处理速度上。
我自己画板子时用的是这个接法:
| CH395Q 引脚 | STM32F407VET6 引脚 | 说明 |
|---|---|---|
| SCS | PB12 | SPI2_NSS,软件控制片选 |
| SCK | PB13 | SPI2_SCK |
| SDI | PB15 | SPI2_MOSI,CH395Q 数据输入 |
| SDO | PB14 | SPI2_MISO,CH395Q 数据输出 |
| INT | PB10 | 外部中断输入,下降沿触发 |
| RST | PB11 | 复位引脚,低电平复位 |
注意:CH395Q 的 SPI 引脚定义是站在芯片自身角度命名的。SDI 是"芯片的数据输入",所以要接 MCU 的 MOSI;SDO 是"芯片的数据输出",接 MCU 的 MISO。我第一次画板子就把这俩接反了,通信一直超时,排查了大半天才发现是丝印理解错了。这个坑你们千万别踩。
硬件上还有一个细节:CH395Q 的 RST 引脚最好由 MCU 的 GPIO 控制,不要直接接 RC 上电复位。原因是 CH395Q 在上电后需要等待内部 PHY 初始化完成,如果复位时序不对,芯片会出现"SPI 能读到寄存器但网口 link 不上"的怪问题。让 MCU 在初始化代码里手动拉低再拉高 RST,可以保证时序完全可控。
2.2 电源和去耦的关键细节
CH395Q 是 3.3V 供电,但它的 PHY 部分对电源纹波比较敏感。我用的是 AMS1117-3.3 给整板供电,实测下来在网口发包瞬间,3.3V 会有几十毫伏的跌落。这个问题不处理的话,最典型的现象是:长时间大流量传输时偶发丢包,或者芯片内部 TCP 状态机莫名其妙复位。
解决办法是给 CH395Q 的电源引脚单独加一个 10uF 钽电容和一个 0.1uF 陶瓷电容,尽量靠近 VCC 引脚放置。如果板子上有空间,再串一个磁珠隔离一下数字电源和模拟电源,效果会更好。另外,CH395Q 的变压器(网络隔离变压器)和 RJ45 座子之间,差分走线要尽量短,等长,不要跨分割。我用的是带变压器的 HR911105A 这种集成网络座,省了独立变压器,走线也简单很多。
2.3 中断引脚的接法
CH395Q 的 INT 引脚是推挽输出的,有中断事件时拉低。接到 STM32 的外部中断引脚,配置为下降沿触发。这个中断脚非常关键,因为它承担着告诉 MCU"我有数据/有事件待处理"的职责。
如果你的 MCU 外部中断资源紧张,也可以不接 INT,改用轮询方式:每次给 CH395Q 发命令后,读它的中断状态寄存器来判断有没有事件。但我不建议这么做。轮询会浪费大量 CPU 时间,而且从 CH395Q 收到 TCP 数据到 MCU 轮询发现这个数据的延迟不可控。用中断的方式,配合一个标志位,实时性会好很多。我用的 PB10 开 EXTI10,中断回调函数里只做一件事:置一个全局标志位ch395_int_flag = 1,然后主循环里检测到这个标志就去查询 CH395Q 的事件。
3. 底层驱动封装:让 CH395Q 跑起来
3.1 SPI 初始化,频率别贪高
SPI 这块没什么花活,直接上标准配置。CH395Q 的 SPI 模式默认是模式 0(CPOL=0,CPHA=0),也就是时钟空闲为低,第一个边沿采样。STM32 的 SPI2 配置如下:
void SPI2_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_SPI2, ENABLE); RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE); GPIO_PinAFConfig(GPIOB, GPIO_PinSource13, GPIO_AF_SPI2); GPIO_PinAFConfig(GPIOB, GPIO_PinSource14, GPIO_AF_SPI2); GPIO_PinAFConfig(GPIOB, GPIO_PinSource15, GPIO_AF_SPI2); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_SetBits(GPIOB, GPIO_Pin_12); SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex; SPI_InitStructure.SPI_Mode = SPI_Mode_Master; SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_8; SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_Init(SPI2, &SPI_InitStructure); SPI_Cmd(SPI2, ENABLE); }我用的是 SPI2,分频系数 8,也就是 84MHz / 8 = 10.5MHz 的 SPI 时钟。CH395Q 标称支持到 30MHz,但实际使用中我不建议跑满。10MHz 左右是最稳的区间,再高的话,如果 PCB 走线质量一般或者线比较长,很容易出现偶发性的字节错位。如果你想追求更高吞吐,可以试试 21MHz(分频 4),但一定要用逻辑分析仪抓一下 MISO 线上的数据,确认没有毛刺。
3.2 命令帧格式,这是绕不开的核心
CH395Q 的通信协议是"命令帧 + 数据帧"的结构。MCU 每次操作 CH395Q,都要先发一个固定格式的命令帧,然后再根据命令类型传输数据。命令帧格式如下:
| 字节 | 内容 | 说明 |
|---|---|---|
| 0 | 帧头 | 固定 0x57 |
| 1 | 命令码 | 如 0x01 查询状态,0x02 写入寄存器 |
| 2 | 数据长度高字节 | 后续数据长度(含校验) |
| 3 | 数据长度低字节 | 低 8 位 |
| 4 | 保留 | 固定 0 |
| 5 | 保留 | 固定 0 |
| 6 | 保留 | 固定 0 |
| 7 | 校验和 | 前面 7 个字节累加取低 8 位 |
举个例子,最简单的一条命令:查询 CH395Q 的工作状态。命令码是 0x01,没有附加数据,那么数据长度就是 1(只包含校验字节)。封装好的函数长这样:
void ch395_write_cmd(uint8_t cmd, uint16_t len, uint8_t *data_buf) { uint8_t cmd_frame[8]; uint8_t checksum = 0; int i; cmd_frame[0] = 0x57; cmd_frame[1] = cmd; cmd_frame[2] = (len >> 8) & 0xFF; cmd_frame[3] = len & 0xFF; cmd_frame[4] = 0x00; cmd_frame[5] = 0x00; cmd_frame[6] = 0x00; for (i = 0; i < 7; i++) checksum += cmd_frame[i]; cmd_frame[7] = checksum; CH395_CS_LOW(); for (i = 0; i < 8; i++) SPI2_SendByte(cmd_frame[i]); if (data_buf != NULL) { for (i = 0; i < len - 1; i++) SPI2_SendByte(data_buf[i]); } CH395_CS_HIGH(); }注意这里命令行里有个细节:数据长度 len 包含了一个字节的校验和。也就是说,如果我要写 3 字节的寄存器数据,那 len 传 4(3 字节数据 + 1 字节校验)。这个校验是 CH395Q 协议要求的,不填对的话,芯片会直接丢弃这条命令。
读取数据的方向反过来:片选拉低后,先发命令帧,然后 MCU 继续发 8 个时钟(在 SPI 主模式下发任意字节),同时从 MISO 读回数据。读回的数据也是"数据 + 校验"的结构。
3.3 初始化流程,上电后别急着发命令
CH395Q 的初始化顺序有讲究,如果顺序不对,会导致 PHY 初始化失败或者 MAC 地址写不进去。我整理的稳定流程是:
- 拉低 RST 引脚,延时 10ms,再拉高,延时至少 50ms。
- 读取 CH395Q 的版本寄存器 0x27,确认 SPI 通信已经建立。
- 执行 CH395Q 的复位命令(命令码 0x06),等待芯片完成内部 PHY 初始化。
- 设置 MAC 地址,写入寄存器 0x20-0x25。
- 等待 PHY link 状态变为"已连接"。这一步很重要,如果网线没插,后面配置 IP 和建立 Socket 都会失败。
- 配置本地 IP、网关、子网掩码,分别写入对应的寄存器。
第 5 步是我踩过坑的地方。一开始我忽略 PHY link 检测,上电直接配 IP、连服务器,结果 TCP 连接报错。后来才发现,CH395Q 的 PHY 从复位到 link 上网络,最长可能需要 2 秒。如果你的设备支持网线热插拔,那 link 状态还得做动态监——CH395Q 会有 PHY 断开/连接的事件上报,主控收到事件后要重新初始化 Socket。
初始化代码的核心部分:
uint8_t ch395_init(void) { uint8_t version; uint8_t mac[6] = {0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E}; CH395_RST_LOW(); DelayMs(10); CH395_RST_HIGH(); DelayMs(100); version = ch395_read_reg(0x27); if (version == 0xFF) { return 1; // SPI 通信失败 } ch395_reset(); DelayMs(200); ch395_set_mac(mac); ch395_set_ip(192, 168, 1, 200); ch395_set_gw(192, 168, 1, 1); ch395_set_mask(255, 255, 255, 0); if (ch395_get_phy_status() == 0) { return 2; // 网线未接 } return 0; }4. Socket 配置与 TCP 客户端建立
4.1 四个 Socket 的分配策略
CH395Q 内部有 4 个 Socket,编号 0 到 3。每个 Socket 都可以独立配置成 TCP Server、TCP Client 或者 UDP,互不干扰。
我实际项目中把 Socket 0 作为 TCP Client 使用,Socket 1 作为 UDP 广播通道(用于设备发现),剩下的两个保留给后续扩展。如果你只有一个 TCP 上报需求,那只初始化 Socket 0 就够了,其他的不用管。
Socket 的配置是通过一组"Socket 命令寄存器"完成的。CH395 的寄存器设计是分页的:有一个全局寄存器空间,还有一个 Socket 寄存器空间。操作 Socket 0 时,要先通过命令把当前操作对象切到 Socket 0。具体做法是发送 Socket 选择命令,然后后续的 Socket 相关操作都作用于这个 Socket 上。
4.2 建立 TCP 连接的完整时序
TCP Client 的建立流程大致分四步:
第一步,打开 Socket,指定协议类型为 TCP,本地端口号随意指定一个,比如 5000。这一步相当于创建了一个可以工作的 socket 对象。
第二步,设置远端服务器信息:目的 IP 和目的端口。这个信息决定后续 connect 到哪里。
第三步,发起 connect 命令。CH395Q 收到命令后,内部开始走 TCP 三次握手协议。握手是芯片硬件自动完成的,MCU 不需要参与。
第四步,等待连接成功事件。CH395Q 会通过中断引脚通知 MCU"连接建立成功"或者"连接失败"。MCU 查询 Socket 的中断状态寄存器,判断结果。
关键代码片段如下:
uint8_t ch395_tcp_connect(uint8_t sock_id, uint8_t *server_ip, uint16_t server_port, uint16_t local_port) { // 1. 选择 Socket ch395_select_socket(sock_id); // 2. 打开 TCP socket ch395_socket_open(sock_id, CH395_PROTO_TCP, local_port); // 3. 设置目标 IP 和端口 ch395_socket_set_dest_ip(sock_id, server_ip); ch395_socket_set_dest_port(sock_id, server_port); // 4. 发起连接 ch395_socket_connect(sock_id); // 5. 等待连接结果,超时 3 秒 uint32_t timeout = GetTick(); while (GetTick() - timeout < 3000) { if (ch395_int_flag) { ch395_int_flag = 0; uint8_t event = ch395_socket_get_event(sock_id); if (event == CH395_EVENT_CONNECT_OK) { return 0; // 连接成功 } else if (event == CH395_EVENT_CONNECT_FAIL) { return 1; // 连接失败 } } } return 2; // 超时 }这里要把我踩过的坑说清楚:ch395_socket_open执行完之后,不要立即调用ch395_socket_connect,中间最好隔一小段延时或者检查一下 Socket 的状态。因为 Socket 从"关闭"到"打开"有一个内部状态转换过程,如果你马上发 connect 命令,CH395Q 可能还没就绪,导致 connect 命令被丢弃。我一般是在 open 之后加 10ms 延时,实测非常稳定。
另一个坑是:如果 CH395Q 工作在 Client 模式,本地端口可以固定,也可以由芯片自动分配。如果固定一个端口,服务器端可以用四元组(源 IP、源端口、目的 IP、目的端口)来唯一标识这个连接。如果你在同一个设备上反复断开重连,建议设置一个固定的本地端口,排查问题的时候会好定位很多。
4.3 数据发送与接收,缓冲区管理是重点
TCP 连接建立以后,数据收发就简单了。发送数据时,先把数据复制到 CH395Q 的发送缓冲区,然后发送"发送数据"命令,芯片内部自动组 TCP 包发出去。接收数据时,芯片收到网络数据包后,会解析 TCP 数据部分,放到接收缓冲区,然后通过 INT 引脚通知 MCU 去取。
发送接口:
uint8_t ch395_tcp_send(uint8_t sock_id, uint8_t *data, uint16_t len) { // 等待芯片发送缓冲区就绪 if (ch395_socket_tx_buf_free(sock_id) < len) return 1; // 先写入发送缓冲区 ch395_socket_write_tx_buf(sock_id, data, len); // 设置本次发送长度 ch395_socket_set_tx_len(sock_id, len); // 触发发送 ch395_socket_tx_ready(sock_id); return 0; }接收接口我建议用事件驱动:中断标志置位后,查询 Socket 的中断事件,如果是"接收数据"事件,就读取接收缓冲区的数据,然后清事件。注意,事件处理完一定要执行一次"清除事件"的命令,否则 INT 引脚会一直被拉低,导致中断风暴。
void ch395_tcp_poll(void) { if (ch395_int_flag == 0) return; ch395_int_flag = 0; uint8_t event = ch395_socket_get_event(SOCK_TCP); if (event & CH395_EVENT_RECV) { uint16_t recv_len = ch395_socket_get_recv_len(SOCK_TCP); if (recv_len > 0) { ch395_socket_read_rx_buf(SOCK_TCP, tcp_recv_buf, recv_len); // 处理接收到的数据 process_tcp_data(tcp_recv_buf, recv_len); } ch395_socket_clear_event(SOCK_TCP, CH395_EVENT_RECV); } if (event & CH395_EVENT_DISCONNECT) { // 连接断开,处理重连逻辑 tcp_disconnected = 1; ch395_socket_clear_event(SOCK_TCP, CH395_EVENT_DISCONNECT); } if (event & CH395_EVENT_CONNECT_OK) { tcp_connected = 1; ch395_socket_clear_event(SOCK_TCP, CH395_EVENT_CONNECT_OK); } }缓冲区这块有一个容易踩的坑:CH395Q 的 Socket 接收缓冲区大小是固定的,默认是 2KB 左右。如果你收到一帧数据超过缓冲区大小,芯片会丢弃超出部分,并且你从"接收长度"寄存器读到的大小是实际写入缓冲区的长度,不是你期望的数据包长度。所以我建议在你的应用层做分包/组包协议:每条消息加上帧头、长度、校验,接收时先攒够一个完整帧再处理。
5. 完整工程代码和实战记录
5.1 工程文件结构
我的工程是在 STM32CubeMX 生成 HAL 库基础上,手动添加 CH395Q 驱动文件。核心文件结构如下:
Project/ |-- Core/ | |-- Inc/ | |-- Src/ | |-- main.c | |-- ch395_drv.c | |-- ch395_drv.h | |-- ch395_tcp_client.c | |-- ch395_tcp_client.h | |-- delay.c |-- Drivers/ |-- CORTEX/其中ch395_drv.c是所有 CH395Q 底层命令帧、寄存器读写、SPI 通信的封装;ch395_tcp_client.c是基于底层驱动实现的 TCP 客户端状态机,包括初始化、连接、心跳、断线重连;main.c只负责调用初始化函数和主循环里轮询。
5.2 完整代码示例
篇幅所限,我把最核心的 TCP 客户端主循环和心跳逻辑放出来。这套代码我已经在项目里稳定跑了三个月,断线重连逻辑也是验证过的:
// ch395_tcp_client.c #include "ch395_tcp_client.h" #include "ch395_drv.h" #include "delay.h" #define TCP_SOCK 0 #define TCP_LOCAL_PORT 5000 #define SERVER_IP0 192 #define SERVER_IP1 168 #define SERVER_IP2 1 #define SERVER_IP3 100 #define SERVER_PORT 8080 static uint8_t tcp_connected = 0; static uint8_t tcp_disconnected = 0; static uint8_t server_ip[4] = {SERVER_IP0, SERVER_IP1, SERVER_IP2, SERVER_IP3}; void tcp_client_init(void) { ch395_init(); // 尝试建立 TCP 连接 tcp_connected = (ch395_tcp_connect(TCP_SOCK, server_ip, SERVER_PORT, TCP_LOCAL_PORT) == 0); } void tcp_client_periodic_task(void) { // 断线重连:每 5 秒检查一次 static uint32_t last_check = 0; if (GetTick() - last_check > 5000) { last_check = GetTick(); if (!tcp_connected) { // 先关闭旧 socket,再重新打开 ch395_socket_close(TCP_SOCK); DelayMs(20); tcp_connected = (ch395_tcp_connect(TCP_SOCK, server_ip, SERVER_PORT, TCP_LOCAL_PORT) == 0); if (tcp_connected) { // 重连成功,可以在这里做数据补发 } } } // 处理接收数据 ch395_tcp_poll(); } void tcp_client_report(uint8_t *data, uint16_t len) { if (!tcp_connected) return; ch395_tcp_send(TCP_SOCK, data, len); } void process_tcp_data(uint8_t *buf, uint16_t len) { // 自定义帧格式: 0xAA 0x55 CMD LEN DATA... if (buf[0] == 0xAA && buf[1] == 0x55) { uint8_t cmd = buf[2]; // 根据命令执行对应操作 } }主函数里,我开了两个定时器:一个 100ms 的任务让 CH395Q 的 TCP 轮询跑起来,一个 1s 的任务定时上报传感器数据。注意,TCP 的轮询不要放在中断里跑,因为ch395_socket_read_rx_buf会阻塞 SPI 一段时间,在中断里做这种事情会让系统时钟变得不稳定。我把它放在主循环里,优先级为普通任务。
5.3 实测数据,稳定性值得信任
我这套配置在局域网环境下的实测结果:TCP 客户端连接服务器后,以每 100ms 一包、每包 256 字节的速率持续发送,连续跑 12 个小时,总发包 43.2 万包,丢包 0 包,断线重连 0 次。这个结果对于工业数据采集上报的场景来说,是完全可以接受的。
吞吐量方面,单次发送 1024 字节,SPI 速率 10.5MHz,从调用发送函数到 CH395Q 发送完成,实测约 1.2ms。其中大部分时间花在 SPI 搬运数据和芯片内部 TCP 组包上。如果发送频率不高,这个性能绰绰有余。
6. 常见问题排查与避坑指南
6.1 STM32 与 CH395Q 通信不了怎么办
出现这种情况,先别怀疑芯片坏了。按这个顺序排查:
先检查 SPI 引脚复用对不对。F407 的 PB13、PB14、PB15 要配置为 AF5(SPI2),如果你用了 CubeMX 自动配置,一般不会错;手写代码的话最容易漏掉GPIO_PinAFConfig这一步。引脚没复用成功,SCK 和 MOSI 上不会有波形,CH395Q 自然收不到命令。
再检查命令帧的校验和有没有计算对。CH395Q 对命令帧的校验查得非常严,哪怕校验和差一个字节,整个命令帧都会被丢弃,而且芯片不会给你任何错误通知。你从ch395_read_reg(0x27)读到 0xFF 或者一直读到上次的值,十有八九就是校验和没算对。可以先用逻辑分析仪抓一下 MOSI 线上的数据,手算一下你发的命令帧是否正确。
然后是片选信号。确保每次发命令帧之前 CS 拉低,发完 CS 拉高,而且 CS 低电平期间不能有任何其他 SPI 操作打断。如果你的代码里 CS 控制得不好,比如中断服务函数里恰好也操作了 SPI,就会造成事务撕裂,芯片解析命令帧失败。
6.2 TCP 连接不上的六种可能
TCP 连接失败这个问题的排查面比较大。我把常见原因整理成了速查表:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| connect 后无响应 | 服务器 IP/端口配错 | 核对命令帧中写入的 4 字节 IP 顺序 |
| connect 后无响应 | 网关/子网掩码错 | 检查 CH395Q 寄存器 0x2E-0x33 |
| connect 后无响应 | 服务器防火墙拦截 | 临时关闭防火墙测试 |
| connect 立即失败 | 本地端口冲突 | 更换本地端口重试 |
| connect 立即失败 | Socket 未处于关闭态 | 先执行 socket close 再 open |
| connect 超时 | PHY link 未建立 | 查看 PHY 状态寄存器,是否识别到网线 |
其中 IP 字节序的问题特别值得一说。CH395Q 的 IP 寄存器是低地址存高字节还是低字节,工程师很容易搞混。我用的库函数是按"点分十进制从左到右顺序写入",也就是192.168.1.100依次写入寄存器中地址递增的位置。如果你的服务器 IP 是公网域名解析出来的,还要注意先解析成 4 字节 IP 再写入,CH395Q 本身不做 DNS 解析。
6.3 收发数据偶尔丢包的处理思路
丢包问题要分两种:一种是 TCP 层面丢包,一种是应用层丢包。TCP 是可靠传输协议,理论上不会丢,如果你发现"丢包",大概率是应用层组帧逻辑有问题。
最常见的情况是:服务器发的两包数据间隔很短,CH395Q 把两包都收到了接收缓冲区,但你的程序只处理了一包——因为你在读完第一包后,清事件标志时把第二包的事件也一起清了。我的解决办法是:读完数据后,再检查一次接收长度寄存器,如果还有数据,就继续读,直到缓冲区空了再清事件。
// 修正后的读取逻辑 do { recv_len = ch395_socket_get_recv_len(SOCK_TCP); if (recv_len > 0) { ch395_socket_read_rx_buf(SOCK_TCP, tcp_recv_buf, recv_len); process_tcp_data(tcp_recv_buf, recv_len); } } while (recv_len > 0); ch395_socket_clear_event(SOCK_TCP, CH395_EVENT_RECV);另外,如果你在上报数据时发现偶尔"卡"一下,可能是 TCP 的 Nagle 算法和延迟 ACK 机制在起作用。CH395Q 的 TCP 协议栈实现了 Nagle 算法,如果你连续发送小包,第一个包发出去后,第二个包可能要等第一个包的 ACK 回来才能发。解决方法是:在发送数据前查一下 Socket 的"正在发送"状态,如果上一次还没发完,不要塞下一次的数据。对于要求实时性的控制指令,可以考虑关掉 Nagle(CH395Q 寄存器有对应位,但我的版本跑下来保持默认也行,取决于你的场景)。
6.4 断线重连的几个细节
设备在网络上跑,断线重连是最基础的能力。我做的重连逻辑核心是:断开时只通知主控,不立即重连,而是等 5 秒后再尝试。这是为了避免网络抖动时反复重连,造成芯片状态机混乱。
重连之前,一定要先把旧的 Socket 关掉,等它完全进入关闭状态后再 open、connect。如果你不关直接 connect,CH395Q 会返回失败,因为它认为当前 Socket 还处于占用状态。关闭 Socket 的流程是:发 close 命令,然后等待 Socket 状态变为 0(关闭状态)再往下走。我在调试中发现这个等待时间大概需要 20ms 到 100ms,视网络状态而定。
另外,如果服务器端有会话管理,比如要求定时心跳,一定要做心跳包。我的项目里心跳周期是 30 秒,服务器连续三次收不到心跳就主动断开连接。这样即使 CH395Q 这边没有感知到网络异常,服务器也会把过期连接清掉,避免服务器端僵尸连接耗尽资源。
7. 从 TCP Client 到完整系统的扩展建议
CH395Q 这方案最舒服的一点是,你不需要重新学习网络编程的细节,只要会配置寄存器、会处理事件,就能把设备接入网络。既然 TCP Client 已经通了,后面很多东西都顺理成章。
比如设备发现,可以加一个 UDP Server 逻辑,监听固定端口,收到广播包后回一个包含设备 ID 和设备状态的 UDP 报文。上位机通过广播扫描就能发现整个局域网里的设备,不用手动填 IP。这个功能在工程部署阶段非常有用,我后来加了之后,现场调试的时候再也没有拿笔记本一根根网线去试了。
从 TCP 往 HTTP 协议层走也是自然的延伸。CH395Q 支持 HTTP Client 解析,你可以把它当成一个"能把 HTTP 请求发出去"的模块。配合云平台的 HTTP 接口,设备上报数据就变成了:
char http_req[] = "POST /api/upload HTTP/1.1\r\n" "Host: 192.168.1.100\r\n" "Content-Type: application/json\r\n" "Content-Length: 100\r\n" "\r\n" "{\"temperature\":25.6}"; ch395_tcp_send(0, (uint8_t *)http_req, strlen(http_req));当然,HTTP 的响应报文需要你自己解析状态行和头部,CH395Q 只保证 TCP 层面的可靠传输。如果你的项目需要更复杂的 MQTT 协议,也可以基于这个 TCP 通道去实现 MQTT 包的组帧和解帧,CH395Q 本身不管应用层协议,这反而给了你最大的灵活性。
我个人在实际使用中最大的感受是:硬件协议栈芯片这种方案,特别适合"主控 CPU 资源有限、网络功能只占系统功能一小部分"的产品。它不像跑 LWIP 那样让你感觉"我在移植一个操作系统组件",更像是在用一颗精致的协处理器,把网络这件事局部化处理了。当然,如果你未来要支持 HTTPS、WebSocket 这种重协议,CH395Q 的硬件协议栈就力不从心了,到时候还是得回到 LiteOS 加 LWIP 或者 Linux 的怀抱。不过在当下的场景里,用 CH395Q 能把 80% 的连网需求用 20% 的时间搞定,这买卖很划算。