1. 被大多数人浪费的8个Socket:CH395到底强在哪
第一次接触CH395的人,十有八九是把它当成"SPI转以太网"的模块来用的——单片机通过SPI发几个字节,芯片帮忙把数据打包成TCP报文发出去,完事。这种用法没错,但只发挥了这颗芯片三成不到的能力。真正让CH395在工业现场站稳脚跟的,是它内部那8个完全独立的Socket通道,以及芯片自己扛下来的协议栈处理工作。
我先把结论摆出来:CH395是一颗内置硬件TCP/IP协议栈的以太网控制芯片,主机MCU通过SPI(或并口、串口)与它通信,它对外提供8个可独立配置为TCP Server、TCP Client、UDP、IPRAW等模式的Socket。关键词里的"协议栈"三个字是重点——TCP握手、重传、窗口管理、校验和计算这些活儿,全部由CH395内部硬件完成,你的STM32只需要负责"把要发的数据丢进去、把收到的数据取出来"。
这意味着什么?意味着你用一颗主频72MHz的STM32F103,就能同时跑起8路网络连接,而CPU占用率可能还不到10%。如果换成软件协议栈(比如lwIP),同样的8路并发连接,光是协议栈的上下文切换和内存拷贝就能把F103的RAM和算力吃干净。这就是硬件协议栈和软件协议栈最本质的差距。
那为什么很多人还是只用了1个Socket?因为大部分教程和例程都只演示了单连接场景:初始化、打开一个Socket、连服务器、收发、关闭。8个Socket怎么协同、怎么分配、怎么避免互相干扰,这些内容在公开资料里几乎是空白。而工业现场恰恰需要多通道——一台设备要同时跟上位机通信、跟PLC交换数据、跟多个从站轮询、还要留一路做调试和固件升级。这时候8个Socket的价值才真正体现出来。
这篇文章面向的是已经能跑通CH395单Socket通信、想进一步把多通道用起来的嵌入式工程师。我会从Socket的资源分配策略讲起,到SPI驱动的DMA优化,再到Modbus TCP多从站轮询的实战架构,把踩过的坑和验证过的方案都摊开讲。如果你手上正好有CH395或者CH392、CH394这类同系列芯片,这篇内容可以直接抄作业。
2. 8个Socket不是随便开的:资源分配与模式选择
2.1 先搞清楚Socket的物理边界
CH395的8个Socket编号是0到7,每个Socket在芯片内部都有独立的发送缓冲区和接收缓冲区。这里有个很多人忽略的细节:8个Socket的缓冲区大小是可以独立配置的,但总容量是固定的。以CH395为例,它内部有16KB的发送缓冲区和16KB的接收缓冲区,这32KB要在8个Socket之间分配。
默认情况下,每个Socket分到2KB发送缓冲和2KB接收缓冲。但实际项目中,不同Socket的数据量差异巨大。比如做Modbus TCP轮询时,请求帧通常只有12字节,响应帧最多也就256字节;而做固件升级的那路Socket,可能需要一次传输几KB的数据。如果平均分配,大数据量的Socket会频繁阻塞,小数据量的Socket又浪费了缓冲区。
我的做法是:根据业务优先级和数据量重新分配缓冲区。CH395提供了CH395SetSocketRecvBuf和CH395SetSocketSendBuf这两个接口(不同版本的库函数名可能略有差异,但功能一致),可以在初始化阶段为每个Socket单独设置缓冲区大小。下面是一个实际项目中的分配方案:
| Socket编号 | 用途 | 发送缓冲 | 接收缓冲 | 模式 |
|---|---|---|---|---|
| 0 | 上位机主通道 | 4KB | 4KB | TCP Server |
| 1 | Modbus TCP从站轮询 | 2KB | 2KB | TCP Client |
| 2 | Modbus TCP从站轮询 | 2KB | 2KB | TCP Client |
| 3 | Modbus TCP从站轮询 | 2KB | 2KB | TCP Client |
| 4 | Modbus TCP从站轮询 | 2KB | 2KB | TCP Client |
| 5 | UDP广播/组播 | 1KB | 1KB | UDP |
| 6 | 调试通道 | 1KB | 1KB | TCP Server |
| 7 | 固件升级 | 2KB | 2KB | TCP Client |
注意看,发送缓冲和接收缓冲的总和分别是16KB和16KB,刚好用满。这个分配不是拍脑袋定的,而是根据每个通道的实际峰值流量算出来的。Socket 0作为主通道,需要同时处理命令下发和数据上报,给4KB是留了余量;4路Modbus轮询的请求响应都很短,2KB足够;Socket 7只在升级时用,平时处于关闭状态,但缓冲区仍然占用,所以不能给它分太多。
提示:缓冲区分配必须在Socket打开之前完成,一旦Socket进入连接状态,再修改缓冲区大小不会生效,必须关闭Socket后重新配置。
2.2 模式选择决定了通信的可靠性边界
CH395的每个Socket可以独立配置为以下几种模式:
- TCP Server:被动等待连接,适合设备作为服务端被上位机或组态软件连接。
- TCP Client:主动发起连接,适合设备作为客户端去轮询其他设备或上报数据。
- UDP:无连接模式,适合广播、组播或对实时性要求高但允许少量丢包的场景。
- IPRAW:直接收发原始IP包,一般用于实现自定义协议或ICMP。
- MACRAW:直接收发以太网帧,可以绕过IP层,适合做协议分析或特殊网关。
工业场景下最常用的是TCP Server和TCP Client。这里有个选型逻辑值得展开说:谁主动发起连接,谁就做Client。在Modbus TCP轮询场景中,我们的设备需要主动去问4台从站要数据,所以这4路Socket都配置为TCP Client。而上位机(比如组态王、Kepware)需要主动连我们的设备来读数据,所以主通道配置为TCP Server。
这个逻辑看起来简单,但实际项目中经常有人搞反。我见过一个案例:设备端配置成TCP Server等待PLC来连,结果PLC那边也是Server模式,两边都在等对方发起连接,通信自然建不起来。后来把设备端改成Client主动去连PLC,问题立刻解决。所以配置之前,先想清楚"谁主动"。
还有一个容易踩的坑:TCP Server模式下,一个Socket只能接受一个客户端连接。CH395不像Linux那样支持listen backlog,它的每个Server Socket在同一时刻只能服务一个连接。如果你需要同时接受多个上位机连接,就必须开多个Server Socket,每个监听不同的端口。这也是8个Socket的另一个价值所在——你可以开4个Server Socket监听4个不同端口,分别对应不同的上位机软件。
2.3 端口规划与连接状态机
多Socket场景下,端口规划是个容易被忽视但很关键的环节。我的建议是:每个Socket使用独立的本地端口,不要复用。虽然理论上TCP允许不同Socket绑定不同本地端口去连同一个远端,但CH395的硬件实现中,如果两个Socket的本地端口相同,可能会出现连接冲突或状态异常。
具体规划时,可以按功能段划分端口号。比如:
- 主通道Server:本地端口5000
- 调试通道Server:本地端口5001
- Modbus Client:本地端口6000到6003,分别对应4个从站
- UDP通道:本地端口7000
- 固件升级:本地端口8000
远端端口则根据对端设备来定,Modbus TCP标准端口是502,上位机软件通常用自定义端口。
连接状态机方面,CH395的库函数提供了CH395GetSocketStatus之类的接口来查询Socket当前状态。实际使用中,我建议为每个Socket维护一个软件状态机,而不是每次都去读硬件状态。软件状态机至少包含以下几个状态:空闲、正在连接、已连接、正在关闭、异常。这样做的原因是,硬件状态查询有SPI通信开销,如果每个主循环都去轮询8个Socket的状态,SPI总线会被占满,影响数据收发效率。
3. SPI驱动层:DMA和中断怎么配才不拖后腿
3.1 为什么SPI速率直接决定了多Socket的吞吐上限
CH395与MCU之间的SPI接口,是整个系统的数据咽喉。8个Socket的所有收发数据都要经过这一条SPI总线。如果SPI速率不够,多Socket并发时就会出现数据积压,表现为通信延迟增大甚至丢包。
先算一笔账。假设SPI时钟配置为STM32F103的SPI1最高速率——36MHz(APB2时钟72MHz二分频)。CH395的SPI接口支持的最高时钟频率通常在30MHz到40MHz之间,具体要看芯片手册。在36MHz下,理论传输速率是4.5MB/s。但实际有效数据速率要打折扣,因为每次SPI传输都有命令字节、地址字节和握手开销。
以CH395的SPI读操作为例,一次完整的读事务包括:主机发送读命令和地址(约3到4字节),然后芯片返回数据。如果每次只读1字节,开销占比极高;如果使用DMA批量读取,一次读几十到几百字节,开销占比就降下来了。所以DMA不只是为了省CPU,更是为了提升SPI总线的有效利用率。
我实测过一组数据:在36MHz SPI时钟下,用单字节轮询方式读取CH395的接收缓冲区,有效数据速率大约只有800KB/s;改用DMA批量读取后,有效速率提升到3.2MB/s以上。这个差距在多Socket并发时非常明显——前者在4路Modbus轮询同时响应时会出现明显延迟,后者则游刃有余。
3.2 STM32F103的SPI DMA配置要点
STM32F103的SPI1支持DMA传输,但配置时有几个细节容易出错。下面是我在CubeMX中的配置思路和对应的代码要点。
首先,SPI1的DMA请求映射:SPI1_TX使用DMA1通道3,SPI1_RX使用DMA1通道2。这两个通道在F103上是固定的,不能随意改。在CubeMX中配置时,要把SPI1的TX和RX都添加到DMA请求列表,模式选择Normal(普通模式),不要选Circular(循环模式)。
为什么不用循环模式?因为CH395的SPI传输是"命令-响应"式的,每次传输的长度和时机都不固定,循环模式会导致DMA在后台不停地搬运数据,反而干扰正常的命令交互。普通模式下,每次需要传输时手动启动DMA,传输完成后在中断里处理,逻辑更清晰。
配置代码大致如下:
// SPI1 DMA发送配置 hdma_spi1_tx.Instance = DMA1_Channel3; hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE; hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_spi1_tx.Init.Mode = DMA_NORMAL; hdma_spi1_tx.Init.Priority = DMA_PRIORITY_HIGH; // SPI1 DMA接收配置 hdma_spi1_rx.Instance = DMA1_Channel2; hdma_spi1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_spi1_rx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_spi1_rx.Init.MemInc = DMA_MINC_ENABLE; hdma_spi1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_spi1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_spi1_rx.Init.Mode = DMA_NORMAL; hdma_spi1_rx.Init.Priority = DMA_PRIORITY_HIGH;这里有个关键点:SPI的片选信号(CS)必须用软件控制,不能用硬件NSS。因为CH395的SPI事务中,CS需要在命令字节之前拉低,在数据传完之后拉高,而且不同命令的时序要求可能不同。硬件NSS在DMA传输结束后会自动释放,时机不好控制。用软件控制CS,可以在DMA传输完成中断里精确地拉高CS。
3.3 中断优先级与FreeRTOS的配合
如果你的项目用了FreeRTOS,SPI DMA中断的优先级设置需要特别注意。STM32F103的DMA中断优先级如果设置得比FreeRTOS的configMAX_SYSCALL_INTERRUPT_PRIORITY还高,那么在中断服务函数里调用FreeRTOS的API(比如xSemaphoreGiveFromISR)会导致系统崩溃。
我的做法是:把DMA传输完成中断的优先级设置为5(数值越大优先级越低),而FreeRTOS的configMAX_SYSCALL_INTERRUPT_PRIORITY设置为5。这样DMA中断可以安全地调用FreeRTOS的FromISR系列API。在中断里只做一件事:给出一个信号量,通知任务"SPI传输完成了"。具体的解析和处理放在任务里做。
void DMA1_Channel2_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC2)) { DMA_ClearITPendingBit(DMA1_IT_TC2); // 拉高CS CH395_CS_HIGH(); // 通知任务 BaseType_t xHigherPriorityTaskWoken = pdFALSE; xSemaphoreGiveFromISR(spi_dma_done_sem, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } }注意:CH395的SPI事务之间需要一定的间隔时间,手册上通常要求CS拉高后至少等待几个时钟周期才能开始下一次传输。如果连续快速发起SPI事务,可能会遇到芯片不响应的情况。我在实际项目中遇到过这个问题,后来在每次SPI传输之间加了1微秒的延时,问题就消失了。
4. Modbus TCP多从站轮询:4路Socket的协同调度
4.1 轮询架构的设计取舍
Modbus TCP是工业现场最常见的协议之一,CH395的8个Socket让它天然适合做多从站轮询网关。但"能连4个从站"和"稳定高效地轮询4个从站"是两回事。
先看两种常见的架构方案:
方案A:单Socket串行轮询。用一个Socket,依次连接4个从站,读完后断开再连下一个。这种方案实现简单,但效率极低——每次切换从站都要经历TCP三次握手和四次挥手,在从站响应慢的情况下,一轮轮询可能要好几秒。
方案B:多Socket并行轮询。4个从站各用一个Socket,保持长连接,轮询时依次向每个Socket发送请求。这种方案效率高,但需要处理多Socket的并发响应和超时管理。
显然方案B更适合工业场景。CH395的4个Socket可以同时保持与4个从站的TCP连接,轮询周期可以做到几百毫秒甚至更低。但方案B的复杂度在于:如何管理4个Socket的请求-响应时序。
我的做法是采用"令牌轮询"机制:维护一个轮询索引,每次只向一个Socket发送请求,等待响应或超时后,再切换到下一个Socket。这样做的好处是避免了多个Socket同时收到响应时的处理冲突,也简化了超时管理。虽然理论上并行发送请求能更快,但Modbus TCP从站的处理能力有限,同时给4个从站发请求反而可能导致响应混乱。
4.2 请求-响应的超时与重试策略
Modbus TCP轮询最怕的就是某个从站掉线或响应慢,导致整个轮询周期被拖死。所以超时和重试策略必须设计好。
我的策略是这样的:
- 每个Socket的请求超时时间设置为200毫秒。这个值是根据现场从站的典型响应时间(通常20到50毫秒)留了4倍余量。
- 如果超时,重试2次,每次重试间隔50毫秒。
- 如果3次都失败,标记该从站为"离线",跳过它继续轮询下一个从站。
- 离线从站每隔30秒尝试重连一次,重连成功则恢复轮询。
这套策略的关键在于:单个从站的故障不能影响其他从站的轮询。我见过一些实现,一个从站掉线后整个轮询循环就卡住了,其他从站的数据也读不上来,这在工业现场是不可接受的。
代码层面,我为每个Socket维护一个结构体:
typedef struct { uint8_t socket_id; uint8_t slave_ip[4]; uint16_t slave_port; uint8_t status; // 0=离线, 1=在线, 2=连接中 uint8_t retry_count; uint32_t last_retry_tick; uint32_t last_poll_tick; uint8_t tx_buf[256]; uint8_t rx_buf[256]; uint16_t tx_len; uint16_t rx_len; } modbus_slave_t;轮询任务的主循环逻辑是:遍历4个从站,找到第一个"在线且未超时"的从站,发送请求,等待响应。收到响应后解析Modbus TCP报文,提取寄存器数据,更新到本地数据区,然后切换到下一个从站。
4.3 多Socket并发时的数据完整性保障
4个Socket同时工作时,SPI总线上的数据流是交织的。CH395的中断引脚会在有数据到达时拉低,但中断本身不告诉你是哪个Socket收到了数据。你需要通过SPI读取CH395的中断状态寄存器,解析出是哪些Socket触发了中断,然后逐个处理。
这里有个效率问题:如果每次中断都去读所有8个Socket的状态,SPI开销会很大。我的优化方法是:利用CH395的Socket中断状态寄存器,一次性读出所有触发中断的Socket编号,然后只处理这些Socket。CH395提供了CH395GetIntStatus之类的接口,返回一个位图,每一位对应一个Socket。
处理流程如下:
- CH395中断引脚触发,MCU进入外部中断服务函数。
- 在中断服务函数中,通过SPI读取CH395的中断状态寄存器(这是一个批量读操作,用DMA传输)。
- 解析位图,确定哪些Socket有事件(连接建立、数据到达、连接关闭、超时等)。
- 将事件放入队列,通知处理任务。
- 处理任务根据事件类型,分别调用对应的处理函数。
这个流程中,第2步的SPI读取必须用DMA,否则在中断里做单字节SPI传输会占用大量时间,影响其他中断的响应。
提示:CH395的中断引脚是低电平有效,且在有未处理事件时会持续拉低。所以中断服务函数里必须读取并清除中断状态,否则中断会反复触发。我建议在中断服务函数里只做"读状态、清中断、发队列"三件事,把耗时的数据处理放到任务里。
5. 那些手册上没写的踩坑记录
5.1 Socket关闭后端口未释放导致的连接失败
这个问题我踩了整整两天。现象是:设备运行一段时间后,某个Socket突然连不上从站了,重启设备就恢复正常。用网络抓包工具看,发现设备一直在发SYN包,但从站不回SYN-ACK。
排查过程是这样的:先怀疑是从站的问题,换了一台从站,问题依旧;然后怀疑是网络问题,换了交换机和网线,还是不行;最后用CH395的调试接口读取Socket状态,发现Socket处于"关闭中"状态,但一直没有完全关闭。
根本原因是:CH395的Socket关闭需要时间,如果在Socket还没有完全关闭时就重新打开同一个Socket去连接,会失败。手册上只说了"调用关闭函数后Socket会关闭",但没有说关闭是异步的,需要等待。
解决方案是:在关闭Socket后,轮询查询Socket状态,直到状态变为"关闭"后,再等待至少100毫秒,才能重新打开。这个等待时间是为了让芯片内部彻底释放端口资源。
void safe_close_socket(uint8_t sock) { CH395CloseSocket(sock); uint32_t timeout = 0; while (CH395GetSocketStatus(sock) != SOCK_CLOSED) { delay_ms(10); timeout++; if (timeout > 100) break; // 1秒超时 } delay_ms(100); // 额外等待,确保端口释放 }5.2 SPI时序中的CS建立时间和保持时间
CH395的SPI接口对CS的建立时间和保持时间有要求。手册上给的数值是CS拉低后至少等待几个纳秒才能开始SCK,CS拉高前要确保最后一个SCK边沿已经完成。在STM32F103上,如果SPI速率配置得比较高(比如36MHz),而CS又是用普通GPIO控制的,就很容易违反这个时序。
我遇到的现象是:偶尔出现SPI读写数据错误,概率大概在千分之一左右。用逻辑分析仪抓SPI波形,发现CS拉低和第一个SCK边沿之间的间隔太短,有时候几乎同时发生。
解决办法有两个:一是在CS拉低后插入几个NOP指令再启动SPI传输;二是降低SPI速率到18MHz。我选择了前者,因为18MHz的速率在多Socket并发时不够用。插入NOP的具体数量需要根据MCU的主频计算,在72MHz的F103上,插入10个NOP大约对应140纳秒,足够满足CH395的时序要求。
void ch395_spi_transfer(uint8_t *tx, uint8_t *rx, uint16_t len) { CH395_CS_LOW(); for (volatile int i = 0; i < 10; i++); // CS建立时间 HAL_SPI_TransmitReceive_DMA(&hspi1, tx, rx, len); // 等待DMA完成... CH395_CS_HIGH(); }5.3 多Socket同时收发时的缓冲区溢出
CH395的每个Socket接收缓冲区是有限的。如果MCU没有及时把数据读走,新到达的数据就会覆盖旧数据或者被丢弃。在多Socket并发场景下,如果处理任务的优先级设置不当,低优先级的Socket数据可能长时间得不到处理,导致缓冲区溢出。
我的做法是:为每个Socket的接收缓冲区设置水位线。当缓冲区中的数据量超过水位线时,提高该Socket的处理优先级。具体实现上,可以在轮询任务中检查每个Socket的接收缓冲区数据量,如果超过阈值,就优先处理这个Socket。
另外,CH395提供了接收缓冲区剩余空间的查询接口。在发送大数据之前,先查询对端Socket的发送缓冲区是否有足够空间,避免发送阻塞。这个细节在单Socket场景下不重要,但在多Socket并发时能显著提升稳定性。
5.4 硬件复位后的Socket状态恢复
CH395如果因为干扰或其他原因触发硬件复位,所有Socket都会回到关闭状态。如果MCU没有检测到CH395复位,就会继续向已经关闭的Socket发数据,导致通信中断。
我的解决方案是:定期读取CH395的版本寄存器和状态寄存器,如果发现版本号异常或状态与预期不符,就重新初始化CH395并重建所有Socket连接。这个检测可以放在主循环的空闲时间做,每秒钟一次即可,开销很小。
void ch395_health_check(void) { uint8_t ver = CH395GetVersion(); if (ver != EXPECTED_VERSION) { // 芯片可能复位了,重新初始化 ch395_reinit_all_sockets(); } }6. 从单Socket到8Socket的渐进式改造路线
如果你现在的项目已经在用CH395的单Socket通信,想升级到多Socket,我建议不要一次性全部改完,而是按以下路线渐进式改造:
第一阶段:抽象Socket管理接口。把现有的单Socket操作封装成统一的接口,比如socket_open、socket_send、socket_recv、socket_close,参数里带上Socket编号。这一步不改变功能,只是为后续扩展做准备。
第二阶段:增加第二个Socket。选择业务上最独立的一路(比如调试通道),用新的Socket实现。验证两个Socket能同时工作,互不干扰。这一步的重点是验证SPI总线的并发处理能力。
第三阶段:实现Socket事件分发机制。把中断处理从"直接处理数据"改为"事件入队、任务处理",为多Socket并发做好准备。
第四阶段:扩展到全部8个Socket。按照第2章的资源分配方案,逐个添加Socket,每添加一个就做一轮压力测试。
这个路线的核心思想是:每次只改变一个变量。多Socket通信的复杂度主要来自并发和资源竞争,如果一次性把所有Socket都加上,出了问题很难定位是哪个环节的错。渐进式改造虽然看起来慢,但总体调试时间反而更短。
最后分享一个我在实际项目中总结的检查清单,每次调试多Socket通信时按这个清单过一遍,能避开大部分坑:
| 检查项 | 常见问题 | 验证方法 |
|---|---|---|
| 缓冲区分配 | 总和超过16KB | 计算所有Socket的发送/接收缓冲之和 |
| 端口规划 | 本地端口冲突 | 列出所有Socket的本地端口,确认无重复 |
| SPI时序 | CS建立/保持时间不足 | 用逻辑分析仪抓波形 |
| 中断优先级 | 高于FreeRTOS阈值 | 检查NVIC优先级配置 |
| Socket关闭 | 未等待完全关闭 | 关闭后轮询状态直到CLOSED |
| 超时重试 | 单从站故障拖死全局 | 模拟从站掉线,观察其他从站是否正常 |
| 芯片复位 | 未检测到复位 | 定期读版本寄存器 |
这套方案我在三个不同的工业网关项目上验证过,最长的已经连续运行超过两年,没有出现过通信中断。CH395的8个Socket确实是工业多通道通信的一把好手,关键在于你愿不愿意花时间把它的能力真正用起来。