☰
CH395硬件TCP/IP协议栈芯片:8个独立Socket多通道通信实战
2026/10/7 17:15:37 网站建设 项目流程

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上位机主通道4KB4KBTCP Server
1Modbus TCP从站轮询2KB2KBTCP Client
2Modbus TCP从站轮询2KB2KBTCP Client
3Modbus TCP从站轮询2KB2KBTCP Client
4Modbus TCP从站轮询2KB2KBTCP Client
5UDP广播/组播1KB1KBUDP
6调试通道1KB1KBTCP Server
7固件升级2KB2KBTCP 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。

处理流程如下:

  1. CH395中断引脚触发,MCU进入外部中断服务函数。
  2. 在中断服务函数中,通过SPI读取CH395的中断状态寄存器(这是一个批量读操作,用DMA传输)。
  3. 解析位图,确定哪些Socket有事件(连接建立、数据到达、连接关闭、超时等)。
  4. 将事件放入队列,通知处理任务。
  5. 处理任务根据事件类型,分别调用对应的处理函数。

这个流程中,第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确实是工业多通道通信的一把好手,关键在于你愿不愿意花时间把它的能力真正用起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询