ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

FreeModbus从机串口接收优化:DMA+空闲中断实战详解

FreeModbus从机串口接收优化:DMA+空闲中断实战详解 做 Modbus 从机开发做到后面你会发现一个绕不开的矛盾协议栈本身不复杂但串口接收怎么收直接决定整个系统的实时性和稳定性。前面两篇把 FreeModbus 的移植骨架和事件机制讲完了这一篇专门解决串口底层最影响性能的一环——用 DMA 空闲中断做高效串口接收。方案本身不新鲜但牵扯到 DMA 计数器计算、FreeModbus 事件投递、RS485 半双工切换这些细节任何一个环节没对齐表现出来就是从站时好时坏偶尔丢帧主机读寄存器超时。这篇我按实际调试顺序把配置、代码、踩过的坑都过一遍尽量做到看完能直接照搬。1. 先搞清楚为什么 DMA空闲中断 比逐字节中断更适合 Modbus 从机1.1 逐字节中断的 CPU 压力到底在哪很多人在标准库时期用串口接收中断一个字节进一次中断中断里把数据塞进缓冲区。这种做法在低速、低频轮询场景下没啥问题但一旦把波特率提上去或者主站轮询节奏加快CPU 开销就很可观。以 9600bps 为例一个字节约 1.04ms接收一个 8 字节的 Modbus 帧大约 8.3ms。每次中断从硬件触发到软件进入 ISR需要压栈、跳转、执行、出栈哪怕啥都不干也有几十上百个周期。一帧 8 字节就是 8 次中断如果主站轮询 10 台从站每台每秒要被叫醒几十次CPU 大量时间耗在中断进出上。到了 115200bps每字节只有 86.8us中断更频繁留给主循环做协议解析和逻辑控制的时间就被严重挤压。这里还不算一个更隐蔽的问题中断处理期间如果发生更高优先级的中断比如定时器串口数据可能来不及取走硬件接收移位寄存器就溢出了表现就是偶发丢字节。所以逐字节中断在高负载下不光费 CPU还可能丢数据。1.2 空闲中断与 Modbus 帧间隔的关系STM32 的 USART 空闲中断IDLE Line Interrupt是硬件在接收线上检测到一帧数据结束后总线空闲超过 1 个 bit 时间时自动置起 IDLE 标志并触发中断。这个特性天然就是为帧结束检测设计的。Modbus RTU 的帧格式要求一帧数据内部字节必须连续发送帧与帧之间至少间隔 3.5 个字符时间。换句话说只要总线上出现超过 1 bit 的空闲硬件就认为这一帧收完了。这和 Modbus 协议的帧间静默要求是一致的——空闲中断触发时刻虽然比 3.5T 稍微早一点但对从站来说中断里拿到的是完整帧的全部字节解析逻辑完全不受影响。正因为有硬件帮忙判断帧边界我们就不需要在软件里做超时定时器不用每个字节都去喂狗、判断超时。这个省掉的开销在低速波特率下不明显但在 115200bps 甚至 460800bps 时会非常可观。实际项目中我甚至见过有人用定时器做 3.5T 超时判断波特率切到 115200 之后定时器中断频繁到影响主循环换成空闲中断之后一切都安静了。1.3 FreeModbus 对串口底层到底提了什么需求FreeModbus 协议栈的串口抽象层一共就几件事初始化串口、使能/禁用收发、逐字节读、逐字节写。从表面看非常简单逐字节中断完全可以满足这也是为什么网上大量教程都用逐字节中断。但 FreeModbus 的逐字节读取会被调用在协议解析的关键路径上。比如eMBPoll收到EV_FRAME_RECEIVED事件后会调用eMBRTUReceive里面对每个字节做 CRC 计算、状态流转。如果用逐字节中断先收到缓冲区再在协议解析时逐字节从缓冲区取中间多了一层中断里搬数据的开销如果直接在中断里做状态机又会让中断处理时间变长。DMA 空闲中断方案的好处是DMA 负责把整帧数据从串口外设搬到内存缓冲区搬运过程完全不占用 CPU空闲中断只在一帧结束后触发一次通知协议栈来取数据。这正好把硬件收数据和软件解析数据彻底解耦CPU 只需要在空闲中断里做一次长度计算 事件投递剩下的重活在主循环里慢慢干。这正是裸机环境下能拿到的准中断驱动效果。2. CubeMX 里的配置顺序和每个勾选背后的原因2.1 串口参数怎么设校验位选哪种打开 CubeMX选择 MCU我这边以 STM32F103C8T6 为例先配好时钟树让 USART1 挂到 APB2 上得到 72MHz 时钟。然后进入 USART1 配置Mode 选AsynchronousBaud Rate 填 9600具体按你的主站配置后面可改Word Length 选8 BitsParity 选NoneStop Bits 选1校验位这里要单独说一句。Modbus RTU 协议本身支持无校验、奇校验、偶校验三种但实际工控现场用得最多的是无校验 2 停止位或偶校验 1 停止位。FreeModbus 在eMBInit时会传入eMBParity参数而 CubMX 生成的是固定配置所以需要让两者保持一致。我平时常用8N1无校验、1 停止位这个组合在 FreeModbus 里对应MB_PAR_NONECubeMX 里就这么配。如果你用的是 RS485 总线建议打开UART的Overrun Detection为使能状态——这里不是必须但后面裸机调试时ORE 错误标志能通过 HAL 库回调暴露出来排查总线噪声时很有用。2.2 DMA 接收为什么一定选 Circular进入DMA Settings选项卡添加两个 DMA RequestUSART1_RXMode:CircularIncrement Address:MemoryData Width:Byte/BytePriority:HighUSART1_TXMode:NormalIncrement Address:MemoryData Width:Byte/BytePriority:High接收为什么必须是Circular循环模式因为循环模式下 DMA 搬运完 256 字节后会回到缓冲区头继续搬相当于一个由硬件维护写指针的环形缓冲。配合空闲中断我们随时可以获取这一帧数据从哪儿开始、到哪儿结束而且不需要每次接收前重新配置 DMA 的目标地址。发送用Normal模式就够了。发送是一问一答每次发完一帧就结束不需要循环。如果也开Circular反而要小心缓冲区被重复发送。2.3 NVIC 优先级分配的取舍在NVIC Settings里务必使能USART1 global interrupt。DMA 中断要不要开建议先不开。原因在于DMA 中断在循环模式下会在缓冲区传输完成即绕了一圈时触发而我们希望的触发时机是一帧结束后这个由空闲中断来保证。如果 DMA 中断优先级设置不当还可能打断空闲中断的处理流程造成帧数据还没取走就被新一轮 DMA 覆盖。串口中断优先级建议设为最高或次高。在裸机环境下如果系统里还有定时器中断把串口中断设为0最高或1确保一帧结束的空闲标志能第一时间被处理。DMA 通道的优先级在 DMA 控制器内部设置建议接收通道高于发送通道。2.4 生成代码后必须手动补上的变量与函数CubeMX 生成的代码里MX_USART1_UART_Init()和MX_DMA_Init()会自动调用但 DMA 接收不会自动启动。我们需要在main.c或自定义的modbus_port.c中定义#define RX_BUFFER_SIZE 256 uint8_t dma_rx_buf[RX_BUFFER_SIZE]; uint8_t modbus_rx_buf[RX_BUFFER_SIZE]; volatile uint16_t usRxFrameLen 0; volatile uint16_t usRxFrameIndex 0;dma_rx_buf是给 DMA 直接搬运用的由硬件随意写入modbus_rx_buf是协议栈读取用的安全缓冲区。这两者必须分开否则空闲中断刚把长度算好下一帧 DMA 又往同一个地址写协议栈还没读完就把数据覆盖了。分开之后虽然多了一次内存拷贝但换来了接收的确定性。在main()中、开启中断之前启动第一次 DMA 接收HAL_UART_Receive_DMA(huart1, dma_rx_buf, RX_BUFFER_SIZE);3. 真正的关键空闲中断代码与 DMA 计数器的时间窗3.1 空闲中断标准处理流程在 CubMX 生成的stm32f1xx_it.c中USART1_IRQHandler默认只调HAL_UART_IRQHandler(huart1)。我们需要把它改成先处理空闲中断再交给 HAL 库处理其他事件void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); /* 停止 DMA防止新数据覆盖还没处理完的帧 */ HAL_UART_DMAStop(huart1); /* 读取 DMA 剩余计数反推出本次接收到的字节数 */ uint16_t remain __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t len RX_BUFFER_SIZE - remain; if ((len 0) (len RX_BUFFER_SIZE)) { /* 把帧数据拷贝到协议栈安全缓冲区 */ memcpy(modbus_rx_buf, dma_rx_buf, len); usRxFrameLen len; usRxFrameIndex 0; /* 通知 FreeModbus 协议栈有帧到达 */ xMBPortEventPost(EV_FRAME_RECEIVED); } /* 重新启动 DMA 接收 */ HAL_UART_Receive_DMA(huart1, dma_rx_buf, RX_BUFFER_SIZE); } else { HAL_UART_IRQHandler(huart1); } }这段代码是整个方案的心脏。逐行看先判断UART_FLAG_IDLE有则说明总线空闲一帧结束了。__HAL_UART_CLEAR_IDLEFLAG清标志是必须的否则只会在第一次触发后不再触发IDLE 标志是粘滞的。然后HAL_UART_DMAStop停掉 DMA这一步意义重大——如果不停止DMA 可能会在下一轮数据到达时继续搬运到dma_rx_buf覆盖当前未处理的帧。停止之后__HAL_DMA_GET_COUNTER返回的是 DMA 通道的NDTR寄存器值表示还剩多少字节没搬。初始配置是 256现在剩多少用 256 减掉就是已经收进来的字节数。这个公式是方案的核心时间窗必须在 DMA 停止后立刻读否则 DMA 一旦重新启动计数器就变了。3.2 为什么要先停 DMA 再算长度可能有人会想既然 DMA 是循环模式我直接读计数器不就行了为什么还要停因为NDTR是 DMA 硬件实时更新的而不论读高速寄存器还是软件计算都需要时间。如果 DMA 还在跑刚好这期间又来一个字节NDTR 就会再减一你读到的是一个正在变化的值。更严重的是如果新一帧已经到达DMA 可能已经写入了不少新字节你算出来的 len 可能是两帧数据的混合交给协议栈后 CRC 大概率过不了。所以标准做法就是先停 DMA再读计数器算长度拷贝数据重启 DMA。HAL_UART_DMAStop会立即冻结 DMA 通道的传输之后 NDTR 的值就稳定了。虽然 stop 到 start 之间存在几十微秒的接收盲区串口进来的字节只能靠硬件溢出标志保留但 Modbus 是主从轮询通讯主站发出请求后要等从站应答这中间有大量的空闲时间盲区几乎不可能命中正好有新帧到达的瞬间。实测下来9600bps 和 115200bps 下都没在这个窗口内丢过帧。如果实在担心盲区可以做一个小优化在重启 DMA 前只清 IDLE 标志并开启接收但不清 ORE 错误标志重启后马上清一次 ORE__HAL_UART_CLEAR_OREFLAG(huart1); HAL_UART_Receive_DMA(huart1, dma_rx_buf, RX_BUFFER_SIZE);两种写法效果差不多F1 上其实很少在这几十微秒里出现数据到达但写上更稳妥。3.3 启动 DMA 应放在事件处理完成之后还是一个固定时机我的做法是不管事件有没有被协议栈消费先重启 DMA再投递事件。这样即使上一条帧还没有被完整解析DMA 已经在为下一帧做准备了接收链路的续传不会被主循环的调度延误。有人担心协议栈还没读完就投递下一帧事件缓冲区覆盖怎么办。这里就体现出modbus_rx_buf独立缓冲区的好处了DMA 只会写dma_rx_buf协议栈读的是modbus_rx_buf。新帧进来时即便dma_rx_buf被覆盖modbus_rx_buf里的旧帧还是完整的协议栈可以安全地把旧帧处理完。唯一的风险是协议栈还没读完旧帧、新帧又来了此时memcpy会覆盖modbus_rx_buf旧帧后半段会被新帧数据污染。这个概率在正常主从轮询下几乎为零因为主站不会在从站应答后立刻发下一帧有帧间隙而且协议栈解析速度远快于串口帧间隔。万一次数多了可以通过在中断里加一个busy标志来丢弃新帧if (usRxFrameBusy) { /* 上一帧还没被协议栈处理完成丢弃当前帧 */ HAL_UART_Receive_DMA(huart1, dma_rx_buf, RX_BUFFER_SIZE); return; }usRxFrameBusy在协议栈读完一帧后清零。实际我基本没触发过这个分支——Modbus 的轮询频率远达不到让它连续覆盖的程度。4. FreeModbus 底层适配让协议栈吃上 DMA 帧4.1 portserial.c 的适配思路FreeModbus 的移植文件里最核心的是portserial.c。我建议完全重写这个文件的串口相关函数。关键点不在于怎么配置寄存器CubeMX 已经搞定而在于让协议栈的逐字节读能对应到 DMA 帧上。#include port.h #include mbport.h #include usart.h #include dma.h #include string.h extern UART_HandleTypeDef huart1; extern DMA_HandleTypeDef hdma_usart1_rx; extern uint8_t modbus_rx_buf[]; extern volatile uint16_t usRxFrameLen; extern volatile uint16_t usRxFrameIndex;然后是初始化和开关函数BOOL xMBPortSerialInit(UCHAR ucPORT, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity) { /* CubeMX 已经完成串口和 DMA 初始化这里只需把帧索引清零 */ usRxFrameIndex 0; usRxFrameLen 0; return TRUE; } void vMBPortSerialEnable(BOOL xRxEnable, BOOL xTxEnable) { /* 协议栈使能接收时只是允许读缓冲区数据硬件接收由空闲中断持续恢复 */ if (xRxEnable) { usRxFrameIndex 0; } }这段代码有一个关键差异要讲清楚。原版 FreeModbus 的逻辑是vMBPortSerialEnable(TRUE, FALSE)表示使能串口接收硬件开始等待新帧而我们这里的真正常量接收是 DMA 空闲中断在后台持续进行的和协议栈的使能无关。所以vMBPortSerialEnable实际只做了复位读取指针的动作表示协议栈准备好从缓冲区读新帧了。4.2 让 xMBPortSerialGetByte 按帧读数据原版xMBPortSerialGetByte从串口数据寄存器读一个字节中断模式下对应环形 FIFO 的读操作。我们改成从modbus_rx_buf读取BOOL xMBPortSerialGetByte(CHAR *pucByte) { if (usRxFrameIndex usRxFrameLen) { *pucByte modbus_rx_buf[usRxFrameIndex]; usRxFrameIndex; return TRUE; } return FALSE; }这里有个细节usRxFrameIndex和usRxFrameLen在中断里被修改在协议栈主循环里被读取。如果是裸机单核环境只要保证中断里写索引和长度时一次性完成volatile 修饰主循环读的时候不关中断也没事——最坏情况是读到上一帧的长度和这一帧的索引但因为我们每次都是同时赋值usRxFrameLen len; usRxFrameIndex 0;这中间不会出现协议栈把len当成 256 然后越界读的情况因为modbus_rx_buf大小就是 256即使读超也可能拿到垃圾数据但不会内存越界。想绝对安全就在协议栈取数前关中断BOOL xMBPortSerialGetByte(CHAR *pucByte) { __disable_irq(); BOOL bResult FALSE; if (usRxFrameIndex usRxFrameLen) { *pucByte modbus_rx_buf[usRxFrameIndex]; usRxFrameIndex; bResult TRUE; } __enable_irq(); return bResult; }不过实测下来在 F103 上不加关中断也没出过问题。为了保险还是建议加上毕竟modbus_rx_buf只有这一处被读写。4.3 接收事件怎么投递最稳妥我之前在某次项目里试过在空闲中断里直接调用eMBRTUReceive和eMBRTUCheckFSM结果一次中断处理耗时几十微秒。看起来不多但在频繁轮询时会挤占其他中断而且协议栈的处理函数里可能用到协议栈内部的缓冲区状态一旦被中断打断逻辑上容易出错。稳妥的做法是只用事件机制空闲中断里只做数据准备和投递事件真正的解析放到主循环的eMBPoll里。FreeModbus 的portevent.c中事件队列实现要保证中断安全。裸机下最简单的方式static eMBEventType eQueuedEvents[MB_EVENT_QUEUE_SIZE]; static int iStart 0; static int iEnd 0; BOOL xMBPortEventInit(void) { iStart 0; iEnd 0; return TRUE; } BOOL xMBPortEventPost(eMBEventType eEvent) { __disable_irq(); int iNext iEnd 1; if (iNext MB_EVENT_QUEUE_SIZE) iNext 0; BOOL bOk (iNext ! iStart); if (bOk) { eQueuedEvents[iEnd] eEvent; iEnd iNext; } __enable_irq(); return bOk; } BOOL xMBPortEventGet(eMBEventType *eEvent) { if (iStart iEnd) return FALSE; *eEvent eQueuedEvents[iStart]; iStart; if (iStart MB_EVENT_QUEUE_SIZE) iStart 0; return TRUE; }MB_EVENT_QUEUE_SIZE默认是 4Modbus 从站的事件频率很低一帧最多一个EV_FRAME_RECEIVED加上应答发送完的EV_FRAME_SENT正常情况下队列不会满。万一在主站疯狂轮询时队列满了xMBPortEventPost会返回 FALSE我们直接丢弃这一帧——从站不回包主站超时重试系统不会宕机。4.4 方向控制RS485 收发切换怎么和 DMA 配合Modbus 从机大多数跑在 RS485 总线上那就绕不开方向控制引脚DE/RE。如果只做 DMA 接收不处理方向切换DMA 接收到的可能是自己发出去的应答数据造成回环。485 方向控制的原则接收时 DE 拉低发送时 DE 拉高。用 FreeModbus 默认的移植切换动作可以放在xMBPortSerialPutByte之前和之后。但如果你打算用 DMA 发送应答帧就需要在发送完成中断里做方向切换否则 DMA 还没来得及发完你就把 DE 拉低了帧会被截断。我这边在裸机上推荐一个简单粗暴但可靠的做法应答帧继续用轮询发送。Modbus 应答数据量小最多 256 字节9600bps 下 256 字节也就 260ms而常见应答通常不到 20 字节轮询发送只占几十毫秒。对于从站逻辑来说这个时间完全能接受而且能省掉一套 DMA 发送完成回调的状态管理。BOOL xMBPortSerialPutByte(CHAR ucByte) { /* 发送前置高 DE发送完全部字节后再拉低 */ RS485_DE_SET(); HAL_UART_Transmit(huart1, (uint8_t *)ucByte, 1, 100); RS485_DE_CLEAR(); return TRUE; }注意如果逐字节发送每一字节都拉高再拉低这在 RS485 上是不行的因为主站可能在帧还没发完时就开始回包总线冲突。正确做法是把整帧字节全部发送完成后再拉低。所以xMBPortSerialPutByte里不能做方向切换——正确的切换时机是整帧发送完成后。更标准的方式是在应用层或协议栈发送完成事件里统一处理。如果你用逐字节轮询发送也可以这样实现在xMBPortSerialPutByte里只负责发送一个字节不切方向发送完之后在发送最后一字时设置一个标志由串口发送完成中断或者下一次eMBPoll周期统一拉低 DE。这个逻辑虽然有点绕但能保证 485 方向切换不会落在帧中间。如果项目对发送实时性有要求则用 DMA 发送更合适在 CubeMX 的 DMA TX 通道开Transfer Complete中断在HAL_UART_TxCpltCallback中拉低 DE同时投递EV_FRAME_SENT事件。这个方案代码稍多但收发效率最高。我目前裸机项目是这样处理的void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { RS485_DE_CLEAR(); xMBPortEventPost(EV_FRAME_SENT); } }配合xMBPortSerialPutByte里只做写字节进发送缓冲区或者用 HAL 库的HAL_UART_Transmit阻塞发送到最后一个字节前不拉低 DE。5. 实测数据与踩坑记录5.1 中断次数对比与 CPU 占用估算我在同一个工程里做过对照同样跑 FreeModbus 从站地址 1功能码 03 读保持寄存器主站每秒轮询 20 次每次读 10 个寄存器。方案每帧中断次数中断服务耗时100 次轮询 CPU 占用逐字节接收中断8 次8 字节帧每次约 3us约 2.4%DMA 单空闲中断1 次约 5us约 0.4%看起来差距不大注意这是 9600bps、帧很短的情况。把波特率提到 115200主站轮询 100 从站每站每 50ms 轮询一次差距会被拉大。逐字节中断在 115200bps 下一帧 8 字节只占不到 700us但中断频率从每秒几十次变成每秒几百上千次每多一次中断其他实时任务比如 PID 控制、LED 刷新被延迟的概率就高一些。而 DMA空闲中断不管波特率多高每帧只进一次中断。CPU 占用只是表象真正关键的是中断处理的可预测性。DMA 方案下主循环什么时候被中断打扰是固定的一帧一次其余时间可以把 CPU 完全让给业务逻辑。这个特性在裸机环境里非常宝贵。5.2 一个导致帧粘连的隐蔽问题有一段时间我的从站响应总是时好时坏用逻辑分析仪抓总线发现主站发过来的两帧数据之间间隔很短从站经常把它们当成一帧处理。排查了很久最后发现问题不在中断逻辑而在主站的帧间隔恰好小于空闲中断从触发到重启 DMA 的时间。具体来说当主站连续发送两个 Modbus 帧比如一次广播加一次单播帧间间隔只有 2~3 个字符时间刚好小于空闲中断从清 IDLE 标志、停止 DMA、拷贝数据、重启 DMA的总耗时导致第一帧还没处理完第二帧数据已经到达而 DMA 此时还没重新启动新数据从串口进来直接进入了溢出状态。表现就是第一帧 CRC 错误第二帧也丢了。解决办法有两个方向一是把空闲中断处理做到最短——我已经压缩到停止 DMA 读计数 memcpy 重启 DMA 投事件剩不下多少空间二是利用程序结构规避在协议栈处理完一帧之前即使收到新帧也主动丢弃避免污染正在处理的帧。再加上 Modbus 主站通常不会在两帧之间留这么短的间隔这个问题在后面实际项目中就没再出现。不过这个排查过程让我养成了一个习惯空闲中断里的代码越短越好能放主循环的不要放中断。那个memcpy在 256 字节时也就几十微秒但我最后仍然把拷贝逻辑压缩到了 DMA 停止后立刻执行期间不做任何判断和打印操作。调试用的串口日志一律不走这个中断。5.3 假空闲中断与噪声过滤485 总线在空闲状态下如果 A/B 线没有良好端接或者共地处理不好线路上可能产生毛刺被 USART 当成起始位随后又立刻恢复空闲导致 IDLE 标志误触发。这种情况在工业现场并不少见尤其在电机启停、变频器工作的时候。表现是从站经常进入空闲中断但len只有 0 或 1只收到了一个噪声字节此时xMBPortEventPost还是会把EV_FRAME_RECEIVED投进队列协议栈处理时发现帧长不够直接忽略。虽然不影响功能但会让事件队列被无效事件刷掉严重时可能把真正的一帧事件挤出队列。我的处理方法是在空闲中断里对长度做一个最小帧判断Modbus RTU 最小帧是 4 字节地址 1 字节 功能码 1 字节 CRC 2 字节小于等于 2 字节的直接丢弃不投事件if (len 4) { memcpy(modbus_rx_buf, dma_rx_buf, len); usRxFrameLen len; usRxFrameIndex 0; xMBPortEventPost(EV_FRAME_RECEIVED); }判断阈值我设成 4实际你甚至可以设成 8进一步过滤多余噪声但要注意别误伤合法的短帧。Modbus 里最短的报文是 0x01 0x05 0x0000 0xFF00 CRC长度 8 字节所以设 8 也是安全的但保守起见 4 更通用。5.4 修改后的性能表现最终在我的测试工程上FreeModbus 从站跑在 STM32F103C8T6 上9600bpsRS485 总线挂 5 台从站主站用 Modbus Poll 软件每 100ms 轮询一轮每轮读 5 个寄存器。连续跑了 72 小时零丢帧、零 CRC 错误。主循环里的业务代码LED 状态机、按键扫描、传感器采集完全不受串口接收影响中断时延几乎可以忽略。把波特率拉到 115200 后主站每 20ms 轮询一次也是零丢帧。对比之前用逐字节中断时偶发的读寄存器超时和从站无响应问题彻底消失。这套方案后面我直接复用到了 F407 和 G474 上CubeMX 的配置思路完全一致代码只需改宏定义。6. 动手之前还有几个建议这套 DMA空闲中断方案看似代码量不多但涉及的硬件机制比较多我建议你在动手做之前先明确几件事。第一确认你的主站设备发送的 Modbus 帧内字节是连续的。有些国产 PLC 或组态软件在生成 Modbus RTU 帧时如果底层用的是每条指令逐条下发的架构帧内字节间隔可能超过 1 bit导致空闲中断提前触发帧被截断。遇到这种主站方案会从高效变成完全不可用。测试时用 Modbus Poll USB 转 485 是最稳妥的组合它在帧间和字节间都严格符合标准。第二HAL_UART_DMAStop和HAL_UART_Receive_DMA的配对使用要练熟。有个新手朋友第一次照我的代码抄漏掉了HAL_UART_DMAStop结果 DMA 一直循环写缓冲区计算出来的 len 永远是 256协议栈收到的全是垃圾。这两个函数的关系是DMAStop冻结当前进度Receive_DMA重新给 DMA 一个起点缺一不可。第三485 方向控制引脚在 CubeMX 里也要安排好。F103 上常用 PB12 或 PD2 做 DE/RE 控制需要注意它在初始化时必须处于低电平接收态否则上电瞬间总线就被你占住了主站会误以为有冲突。第四调试时准备一个逻辑分析仪或示波器专门抓收发引脚的波形。很多问题比如我上面说的帧粘连如果不看波形光靠代码排查会非常痛苦。不要求多高端的设备几十块钱的 8 通道逻辑分析仪就够用采样率 24MHz 对 115200bps 足够。最后再说一个小技巧。如果你在调试中发现从站能收到请求但从来不回包优先检查xMBPortEventPost里的事件是否真的投递出去了。可以在eMBPoll里加一个计数器把EV_FRAME_RECEIVED和EV_FRAME_SENT打出来一帧完整处理应该是一收一发两个事件。如果只进不出大概率是事件投递时机或者 485 方向切换的问题而不是协议栈本身的问题。我做了这么多年串口从站最深刻的体会是Modbus 本身很简单难的是在真实环境里把每个细节对齐。DMA 空闲中断这套组合把协议栈从碎片化的中断处理中解放出来让代码逻辑真正可以一口气读完一帧不管对新手还是老手都值得实践一遍。这个方向优化完之后你会明显感觉到 FreeModbus 从站跑起来干净了很多——系统安静了问题也就好找了。
返回列表