
1. 被大多数人浪费的8个SocketCH395到底强在哪第一次接触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 Server1Modbus TCP从站轮询2KB2KBTCP Client2Modbus TCP从站轮询2KB2KBTCP Client3Modbus TCP从站轮询2KB2KBTCP Client4Modbus TCP从站轮询2KB2KBTCP Client5UDP广播/组播1KB1KBUDP6调试通道1KB1KBTCP Server7固件升级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本地端口5001Modbus 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最高速率——36MHzAPB2时钟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通道3SPI1_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的配合如果你的项目用了FreeRTOSSPI 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拉低后至少等待几个纳秒才能开始SCKCS拉高前要确保最后一个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确实是工业多通道通信的一把好手关键在于你愿不愿意花时间把它的能力真正用起来。