尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32 FreeModbus V1.6移植实战:从零实现Modbus RTU从站(含DMA串口)

STM32 FreeModbus V1.6移植实战:从零实现Modbus RTU从站(含DMA串口) 简介FreeModbus V1.6 是一套可在嵌入式设备上运行的 Modbus 协议栈源码同时支持 RTU 与 ASCII 两种传输模式覆盖 0x01 读线圈、0x02 读离散输入、0x03 读保持寄存器、0x04 读输入寄存器、0x05 写单线圈、0x06 写单寄存器、0x0F 写多线圈、0x10 写多寄存器、0x17 读写多寄存器以及 0x11 报告从站 ID 等常用功能码非常适合工业控制、PLC 通信和单片机主从机项目的底层通信开发。整个资源包共 1104 个文件压缩后约 4.4MB核心代码由 .c 与 .h 文件构成另附链接脚本、构建脚本、移植模板、演示批处理以及示例任务代码能够帮助开发者在不同 MCU 平台上快速完成协议栈移植。借助移植层和演示工程读者可直观理解状态机调度与串口收发逻辑再结合自己的硬件平台修改底层接口即可获得稳定的 Modbus 通信能力。已有 664 人学习下载适合具备一定嵌入式基础、希望直接集成 Modbus 功能的工程师参考。 做嵌入式的朋友尤其是接触过工业现场设备的人大概率都绕不开Modbus RTU。而提起在STM32上实现Modbus RTU从站FreeModbus V1.6几乎是一个绕不开的选项。这个版本发布至今已经十几年没有更新但直到今天很多量产设备、开源项目、教学案例里跑的仍然是这一版。最近我在STM32F411上把FreeModbus V1.6完整移植了一遍顺手接了DMA串口把Modbus轮询、寄存器读写都调通了过程中踩了几个比较有代表性的坑。这篇文章就把完整过程和排查思路整理出来给准备在STM32平台上移植FreeModbus的朋友做个参考。如果你只是想让板子尽快跑起来完成项目这篇文章可以直接照着抄如果你是做产品想知道这套老协议栈到底能不能撑住现场设备的长期运行需求它也值得你花几分钟看完。后面所有代码都在STM32F411上实测通过但思路对绝大多数带UART和基本定时器的MCU都适用。下面不绕弯子直接进入正题。1. FreeModbus V1.6一版十多年前发布的协议栈为什么还在被大量移植1.1 先搞清楚它到底是个什么东西FreeModbus是一个开源的Modbus从站协议栈实现V1.6是稳定发布版本中最常被用到的一个。它做的事情很纯粹帮你解析Modbus RTU报文、校验CRC、维护状态机、分发功能码然后把最终的数据读写请求用回调函数的形式提交给应用层。也就是说协议栈管“交通”应用层管“货物”两边通过固定接口对接不需要你关心报文格式和超时状态机这些繁琐细节。很多刚接触的人一看到源码目录里有demo、port、mb、functions四个文件夹就发怵觉得代码量很大。其实真正需要你动手改的只有port目录下的几个平台相关文件剩下的大部分代码都跟你无关。mb目录是协议核心functions目录是功能码处理demo目录只是一个参考入口。移植FreeModbus本质上是完成MCU外设事件和协议栈回调函数之间的对接。1.2 为什么选它而不是其他Modbus方案市面上Modbus方案不少有商业协议栈也有各种修改版。我把它们放在一起对比过结论很明确如果只需要做一个从站设备FreeModbus V1.6仍然是最稳的选择之一。它的优势体现在几个方面。第一代码量小裁剪后核心只有两三千行RAM占用极小在资源紧张的单片机上跑毫无压力。第二状态机按字节驱动天然适合中断处理不依赖RTOS也能工作得很好。第三V1.6作为发布多年的版本被无数项目验证过边界情况处理得比较成熟。相比之下很多增加主站功能或加入OS抽象层的修改版功能更全但代码复杂度和潜在问题也多不少对于只需要从站功能的项目来说属于过度设计。1.3 移植任务的总览六个关键函数FreeModbus对平台的抽象很克制真正需要你实现的平台相关函数就这几个文件函数职责portserial.cxMBPortSerialInit初始化串口、引脚、DMAportserial.cxMBPortSerialPutByte发送一个字节portserial.cxMBPortSerialGetByte读取一个接收到的字节portserial.cvMBPortSerialEnable使能/失能收发中断porttimer.cxMBPortTimersInit初始化T3.5超时定时器porttimer.cvMBPortTimersEnable / Disable启停超时定时器再加上串口接收中断里调用prvvUARTRxISR()、发送完成中断里调用prvvUARTTxReadyISR()、定时器超时中断里调用prvvTIMERExpiredISR()整个移植工作就完成了。这比大多数人想象的要简单但恰恰因为简单才容易在细节上栽跟头。2. 移植的第一步理清端口层三块责任别急着写代码2.1 串口部分的收发回调不能搞反端口层最容易犯的第一个错误是把两个中断回调的作用搞混。prvvUARTRxISR()是“收到一个字节”的通知由接收中断调用prvvUARTTxReadyISR()是“发送寄存器为空”的通知由发送完成中断调用。协议栈内部靠这两个信号推进状态机如果顺序反了或者漏调了其中一个最典型的症状就是主站发请求从站没响应但从站串口上能看到完整的数据波形。我在F411上写的串口中断处理逻辑大致是这样的void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_RXNE) ! RESET) { // 收到一个字节交给协议栈 prvvUARTRxISR(); __HAL_UART_CLEAR_FLAG(huart1, UART_FLAG_RXNE); } if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC) ! RESET) { // 最后一字节发送完成通知协议栈发送阶段结束 prvvUARTTxReadyISR(); __HAL_UART_CLEAR_FLAG(huart1, UART_FLAG_TC); } HAL_UART_IRQHandler(huart1); }这里有个细节值得多说一句发送完成标志建议用TC而不是TXE。TXE表示发送数据寄存器为空但移位寄存器可能还在往外送最后一个字节如果此时做RS485方向切换会把帧尾截断主站那边就会报CRC错误。TC表示整个发送过程结束用这个标志做方向切换才安全。2.2 定时器部分基础定时器就够用T3.5超时判断是Modbus RTU帧结束的判定依据也是FreeModbus移植中最关键的定时器。它不需要PWM输出不需要输入捕获只需要一个能产生更新中断的基本定时器即可所以STM32上的TIM6或者TIM7是最合适的选择。把它们留给别的功能用通用定时器反而浪费。xMBPortTimersInit的核心工作是配置定时器的预分频和自动重载值让定时器溢出周期等于T3.5。vMBPortTimersEnable的作用是清空计数器、使能更新中断并启动定时器vMBPortTimersDisable则相反。协议栈在每次接收到新字节时都会重新启动这个定时器如果两个字节之间的间隔超过T3.5就认为一帧结束开始解析数据。所以它的每次溢出都意味着“帧结束了该干活了”。2.3 初始化调用顺序不要自作主张FreeModbus的API调用顺序是固定的先eMBInit再注册回调最后eMBEnable之后循环调用eMBPoll。eMBInit会根据你传入的串口号、地址、波特率调用前面提到的xMBPortSerialInit和xMBPortTimersInit。很多人喜欢在main函数开头自己先初始化串口和定时器然后再调eMBInit这样会导致外设被初始化两次容易出现配置覆盖或中断未生效的诡异现象。我建议的做法是外设时钟和GPIO在eMBInit之前开启但串口参数、定时器参数完全交给xMBPortSerialInit和xMBPortTimersInit去配置。这样移植代码和业务逻辑的边界最清晰出问题也容易排查。3. T3.5定时器与RS485方向控制最容易翻车的两个现场3.1 T3.5到底怎么算为什么是11位Modbus RTU规定帧与帧之间的间隔必须大于等于3.5个字符时间接收端在此基础上判断一帧数据是否结束。一个字节在串行线路上占用的时间不是8位而是11位1个起始位、8个数据位、1个校验位或者无校验时的停止位、1个停止位。所以T3.5的计算公式是T3.5 3.5 × 11 / 波特率常见的几个波特率对应数值我列成了表格方便直接查波特率T3.5毫秒近似值96004.0104.0 ms192002.0052.0 ms384001.0021.0 ms1152000.3340.33 msFreeModbus V1.6在内部会把T3.5换算成定时器计数周期然后通过xMBPortTimersInit参数传给你。不同移植版本对参数含义的解释略有不同我移植时直接看的是源码里的注释以“协议栈传入的数值即定时器自动重载值”为准。如果你拿到的版本参数语义不明确可以用逻辑分析仪抓一下主站的帧间隔再回头校准定时器周期。3.2 STM32F411上的TIM6配置实例F411的APB1定时器时钟经过倍频后通常是100MHz我把它分频100倍得到1MHz的计数频率也就是每个计数tick为1微秒。波特率9600时T3.5约4.01ms对应的自动重载值就是4010。下面是裁剪过的核心配置void vMBPortTimersInit(USHORT usTimerT35Value) { __HAL_RCC_TIM6_CLK_ENABLE(); htim6.Instance TIM6; htim6.Init.Prescaler 100 - 1; // 100MHz / 100 1MHz1us一个tick htim6.Init.CounterMode TIM_COUNTERMODE_UP; htim6.Init.Period usTimerT35Value - 1; // 自动重载值由协议栈传入 htim6.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_DISABLE; HAL_TIM_Base_Init(htim6); HAL_NVIC_SetPriority(TIM6_DAC_IRQn, 2, 0); HAL_NVIC_EnableIRQ(TIM6_DAC_IRQn); } void TIM6_DAC_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(htim6, TIM_FLAG_UPDATE) ! RESET) { __HAL_TIM_CLEAR_FLAG(htim6, TIM_FLAG_UPDATE); prvvTIMERExpiredISR(); } }注意Period的实际含义是自动重载值减1因为定时器是从0开始计数的。这个细节如果搞错定时器会提前1个tick溢出在高速率下可能造成帧结束判定偏差。还有一点vMBPortTimersEnable里建议做两件事清空计数器然后使能更新中断并启动定时器。如果只启动定时器而忘记清计数器第二次接收时定时器可能从上次残留值继续计导致超时时间错误。3.3 RS485的DE引脚时机比电平更重要工业现场Modbus RTU基本都是RS485总线DE/RE引脚方向切换是个经典问题。FreeModbus没有提供方向控制的接口需要你自己在串口发送和发送完成的地方控制一个GPIO。最直接的方案是xMBPortSerialPutByte里发送第一个字节前拉高DE发送完成中断里拉低DE。但这里有一个非常容易踩的坑如果在TXE中断里拉低DE或者用HAL_UART_Transmit这样的阻塞发送函数并在函数返回后立刻拉低DE最后一帧的停止位可能还没完全发出去。正确的做法是像我前面说的使用TC标志。当TC置位时说明最后一个停止位已经从引脚上送出去了这时候拉低DE才不会截断帧尾。另外如果板上用了自动方向切换芯片或者收发器自带延时方向控制的代码可以做得更简单但绝大多数MAX485类芯片都需要软件控制方向。我在调试时用示波器同时抓A/B差分波形和DE引脚电平发现DE拉低瞬间如果和停止位重叠主站收到的帧末尾会多出一些杂散电平CRC大概率报错。这类问题常常被误判为定时器不准实际都是方向切换时机的问题。4. DMA串口方案什么场景值得上接入思路和隐藏的坑4.1 标准中断接收在什么情况下不够用FreeModbus默认的接收路径是每个字节触发一次接收中断然后调用prvvUARTRxISR()。在9600或19200波特率下这个频率很低CPU完全没压力。但在115200甚至更高波特率下每字节间隔只有约87微秒如果主站连续发送大量寄存器数据中断频率会明显上升加上协议栈在中断里做的事情不止收数据还有定时器重启和状态机推进对实时性要求较高的场景就有点吃力了。DMA方案的核心价值就在这里把逐字节接收从CPU中断里解放出来由DMA把整帧数据搬到内存CPU只在帧结束空闲中断时处理一次。需要注意的是DMA不会让Modbus通信本身变快因为波特率是主站和从站协商好的它只是减少了CPU被频繁打断的次数让系统有更多精力处理其他任务。4.2 IDLEDMA整帧接收后如何喂给协议栈DMA方案和FreeModbus对接的关键在于协议栈的接收状态机是按字节推进的它并不知道DMA已经帮你收好了一整帧。所以最稳妥的对接方式是在串口空闲中断触发时读出DMA当前接收长度然后把缓冲区里的字节逐个调用prvvUARTRxISR()喂给协议栈让协议栈按原有逻辑完成帧结束判断和解析。这样不需要改动协议栈内部任何代码维护成本最低。伪代码如下void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 计算DMA已接收长度 uint16_t len RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 把这一帧数据按字节投递给协议栈 for (uint16_t i 0; i len; i) { prvvUARTRxISR(); } // 清空DMA计数器准备下一帧 __HAL_DMA_SET_COUNTER(hdma_usart1_rx, RX_BUF_SIZE); } HAL_UART_IRQHandler(huart1); }这里有一个容易翻车的地方prvvUARTRxISR()内部会读取串口数据寄存器来获取字节内容。如果用DMA接收数据已经被DMA搬到了缓冲区里串口数据寄存器里已经什么都没有了所以必须自己维护一个接收索引在循环里逐个取出缓冲区字节。更简单的做法是每次都从缓冲区头部开始读取然后重置DMA计数器但这要求一帧数据必须在缓冲区里连续保存下一帧数据到来前必须处理完。4.3 STM32F411上DMA接收的配置要点F411的UART支持DMA循环模式配合空闲中断可以很方便地实现不定长接收。我这里用的是普通模式而非循环模式每帧接收完成并处理完后重新装载DMA计数器逻辑更直观也不容易出现缓冲区覆盖问题。DMA初始化时注意几点。第一接收缓冲区大小必须大于最大Modbus报文长度Modbus RTU最大报文通常是256字节所以缓冲区我开了512字节留了余量。第二DMA的数据宽度是字节方向和串口接收请求要匹配。第三空闲中断标志需要在读取数据长度之后再清除顺序不能反否则可能丢失中断。还要提醒一句如果只是做点对点测试波特率不高的情况下标准中断接收方案完全够用没必要为了用DMA而用DMA。DMA方案的收益在高速率、频繁通信、系统任务繁杂时才能体现出来引入DMA也意味着多一个排查链路项目进度紧时反而拖后腿。5. 跑通第一帧数据寄存器回调设计与调试链路5.1 最小启动流程从xMBInit到eMBPoll把端口层写完终于到了让协议栈真正工作起来的环节。启动流程固定如下int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); // 1. 初始化Modbus协议栈RTU模式从站地址1串口1波特率9600无校验 eMBInit(MB_RTU, 0x01, 1, 9600, MB_PAR_NONE); // 2. 注册寄存器访问回调 eMBRegHoldingCB(RegHoldingCB); eMBRegInputCB(RegInputCB); eMBRegCoilsCB(RegCoilsCB); eMBRegDiscreteCB(RegDiscreteCB); // 3. 使能协议栈 eMBEnable(); while (1) { // 4. 主循环轮询处理接收完成的帧 eMBPoll(); } }eMBPoll需要在主循环里被高频调用但千万不能阻塞。很多人在main里写了大段延时或者HAL_Delay导致eMBPoll无法及时处理请求从站响应就会慢半拍。实测下来轮询间隔控制在1到2毫秒内响应时延就很稳定。如果主循环有其他耗时任务建议把eMBPoll放到定时器中断或者RTOS高优先级任务里。5.2 保持寄存器和输入寄存器回调的写法寄存器回调是协议栈和应用层数据之间的桥梁。以保持寄存器为例回调函数会收到三个关键参数读写缓冲区指针、起始地址、寄存器个数。典型写法如下eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { uint16_t regIndex usAddress - REG_HOLDING_START; if (usAddress REG_HOLDING_START || usAddress usNRegs REG_HOLDING_START REG_HOLDING_NUM) { // 地址越界返回错误主站会收到异常码02 return MB_ENOERR 0 ? MB_ENOREG : MB_ENOREG; } if (eMode MB_REG_WRITE) { // 写操作主站发来的数据在缓冲区里按大端格式读出 for (USHORT i 0; i usNRegs; i) { holdingRegs[regIndex i] (pucRegBuffer[2 * i] 8) | pucRegBuffer[2 * i 1]; } } else { // 读操作把寄存器值按大端格式填入缓冲区 for (USHORT i 0; i usNRegs; i) { pucRegBuffer[2 * i] holdingRegs[regIndex i] 8; pucRegBuffer[2 * i 1] holdingRegs[regIndex i] 0xFF; } } return MB_ENOERR; }Modbus寄存器数据是大端字节序也就是高位字节在前。这一点新手特别容易忽略如果填反了读出来得到的数据每个寄存器都不对但看起来又“有值”非常迷惑。我调试时遇到过一次类似问题用Modbus Poll连续读了几十个寄存器发现数值高低位完全颠倒了排查过程一度以为是报文地址算错了最后才意识到是大端小端的问题。5.3 用Modbus Poll验证异常码02和03的典型排查路径调试工具我用的是Modbus Poll它是最常用的Modbus主站模拟软件。新建连接时选好串口、波特率、从站地址、功能码然后点连接开始轮询。如果从站无响应优先检查三处串口RX/TX引脚是否接反、T3.5定时器是否溢出、RS485方向控制是否正常。如果从站有响应但返回异常码问题大概率出在回调函数里。异常码01非法功能码说明主站请求的功能码在协议栈里不支持检查功能码枚举是否在functions目录里有对应实现。FreeModbus默认支持01、02、03、04、05、06、15、16等功能码如果主站用了其他扩展功能码就需要自己扩展。异常码02非法数据地址回调函数检测到地址越界并返回MB_ENOREG。重点检查寄存器的起始地址映射尤其是Modbus协议里地址偏移的问题。从站回调里拿到的usAddress是报文中携带的地址不是物理索引必须先做偏移计算。异常码03非法数据值写寄存器时值超出范围或者功能码要求的数据长度不匹配。比如写单个保持寄存器使用了功能码16写多个寄存器或者写入值刚好超过寄存器位宽能表达的范围。从站端如果还是查不出来可以在从站里加一个调试串口把接收到的原始报文和eMBPoll返回的错误码打印出来。FreeModbus的eMBPoll返回值是eMBErrorCode通过它可以判断当前状态机的处理结果这一步是我做产品时排查通信问题最常用的手段。最后再分享一个我在实际项目中养成的习惯FreeModbus移植完成后先用Modbus Poll连续跑一晚上在线轮询如果一晚上没有出现任何CRC错误和超时说明T3.5时序、方向切换和中断优先级基本是稳定了的。如果测试中发现偶发超时先别急着改代码用逻辑分析仪抓一下从站的响应波形看看T3.5和帧间隔是否在标准范围内很多时候问题不是出在代码逻辑而是出在时钟配置或者中断响应延迟上。等到波形和协议栈状态都对上了这个从站才能真正放心地丢到工业现场去跑。本文还有配套的精品资源点击获取
返回列表