STM32F411移植FreeModbus V1.6:Modbus RTU从站与DMA接收改造实战
简介FreeModbus V1.6 是一份成熟的 Modbus 协议栈开源实现支持 RTU 与 ASCII 两种传输模式面向嵌入式开发者和工业自动化工程师解决了 MCU 与上位机或 PLC 之间快速集成 Modbus 通信的问题。完整覆盖读写线圈、离散输入、保持/输入寄存器以及从站 ID 上报等常见功能码适合需要构建稳定工业通信链路的项目。压缩包共 1104 个文件约占 4.4MB主要包含 492 个头文件与 418 个 C 源文件配合 34 个 txt 说明文档、20 个汇编启动文件以及多种链接脚本lds/sct/ld/lcf并提供了 makefile、vcproj、ewp、uv2、zdsproj 等工程配置方便在不同 IDE 中直接编译移植。已有 664 人学习适合作为学习 Modbus 协议栈分层实现和进行嵌入式项目移植的参考。包内还包含 demo_rtu.bat、simple.bat 等演示脚本tasks.c、portable.h.bak 等源码备份加上 README、changelog 和 dox 文档可帮助读者快速定位移植接口、了解版本变更与 API 用法缩短开发调试周期。整套代码分层清晰便于二次开发和功能裁剪。 做嵌入式工控的朋友应该都摸过FreeModbus这个经典开源协议栈V1.6发布至今这么多年依然是很多设备联网改造的首选。它体积小、代码清晰、跨平台RTU和ASCII协议都支持资源紧张的老单片机也能跑得动。最近我正好在STM32F411上把FreeModbus V1.6完整移植了一遍还顺手把串口接收换成了DMA方式整条链路调通之后稳定性和CPU占用都让我很满意。这里把整个流程、原理和踩过的坑整理出来给准备在STM32系列上做Modbus从站的朋友一个参考。这篇内容适合谁如果你正在做PLC通信、组态软件对接、传感器数据上送这类项目或者手头有设备需要从私有协议改造成标准Modbus RTU那这篇文章可以帮你少走不少弯路。我会从源码结构讲起再到STM32F411的串口和定时器移植最后补上DMA接收的改造思路。文中涉及的代码都是我在实际工程里验证过的可以直接照抄再按自己的引脚和时钟改一改。1. 为什么选择FreeModbus V1.6而不是自己写协议栈1.1 FreeModbus到底解决什么问题Modbus在工控现场属于“约定俗成”的通信语言。PLC、触摸屏、上位机组态软件基本都原生支持Modbus RTU只要你的设备把寄存器地址公开对方就能通过标准报文直接读写。FreeModbus V1.6正是用来帮你实现Modbus从站协议的一整套C代码它把帧解析、CRC校验、功能码处理、异常响应都封装好了你只需要提供串口读写和定时器中断这两个最基础的硬件接口。很多工程师觉得Modbus协议看着简单报文格式就那么几行干脆自己写。但实际做下去就会发现坑很多CRC计算容易错位、3.5字符帧间隔难以精确控制、多寄存器读写时地址越界处理麻烦、异常码响应不规范等。FreeModbus V1.6在这些细节上已经过大量项目锤炼直接用它是性价比最高的选择。1.2 和libmodbus、自研方案对比libmodbus也很流行但它的定位偏主机Master方向且更依赖操作系统和文件描述符在裸机MCU上跑并不顺手。FreeModbus则专门面向从机Slave场景支持裸机轮询和RTOS两种运行方式代码里大量使用条件编译裁剪非常方便。自研方案最麻烦的不是帧解析而是协议兼容性。比如上位机发送04功能码读输入寄存器你不仅要正确返回数据还要在寄存器地址非法时返回异常码02。FreeModbus V1.6把这些细节全部处理好了你只需做的事情就是初始化、使能、在主循环里轮询eMBPoll然后在寄存器回调函数里把你的变量映射到协议栈。这是真正意义上的“开箱即用”。2. FreeModbus V1.6源码结构与移植的核心原理2.1 源码目录结构mb、port、demo三层拿到FreeModbus V1.6源码后首先要分清目录职责。整个协议栈主要分三层mb目录核心协议代码包括帧处理、功能码、CRC校验。这部分基本是通用的。port目录移植层也就是你需要改写的部分。包含portserial.c、porttimer.c、portevent.c。demo目录各类硬件平台的参考例程里面有AVR、STM32、LPC等工程的雏形。理解了这个分层就知道移植工作本质上就是补全port层接口。协议栈在运行过程中会调用这些接口去操作串口和定时器而串口和定时器在不同MCU上的实现方式完全不同这就是需要配置的“对接层”。2.2 必须理解的三个函数调用链FreeModbus V1.6的运行可以浓缩成三个函数eMBInit(MB_RTU, 0x01, 0, 9600, MB_PAR_NONE); eMBEnable(); while (1) { eMBPoll(); }eMBInit负责把协议栈初始化到指定模式。第一个参数是通信模式MB_RTU是常用模式如果你的设备有特殊需求也可以用MB_ASCII。第二个参数是从站地址必须跟你的设备拨码开关或配置保持一致。第三个参数是串口号协议栈在调用xMBPortSerialInit时会把ucPort原样传给你方便你通过参数区分UART1还是UART2。eMBEnable调用后定时器和串口中断才真正开始工作。从这一刻起协议栈就进入了被动等待状态只要串口上有合法的Modbus帧进来它就会自动完成接收、校验、响应的全部流程。eMBPoll是事件轮询函数。它需要放在主循环里被反复调用协议栈的接收完成事件、定时器超时事件都通过事件队列传上来eMBPoll从队列里取出事件并处理。很多人刚接触时以为eMBPoll只是处理接收的其实它还负责发送完成后的状态切换漏掉它会导致只收不发。2.3 port层到底要改哪些文件从实践角度看普通裸机移植只需要重点改三个文件文件需要实现的接口作用portserial.cxMBPortSerialInit、xMBPortSerialPutByte、xMBPortSerialGetByte、vMBPortSerialEnable串口收发和收发方向切换porttimer.cxMBPortTimersInit、vMBPortTimersEnable、vMBPortTimersDisable字节间隔定时器RTU模式判断帧结束portevent.cxMBPortEventPost、xMBPortEventGet事件队列裸机工程可以用全局变量实现此外还要在中断服务函数里调用两个回调串口收到一个字节时调用pxMBFrameCBByteReceived发送缓冲区空且允许发送时调用pxMBFrameCBTransmitterEmpty。定时器溢出中断里调用vMBPortTimerExpired。移植的核心就是把这几个回调跟硬件中断对接好协议栈就能正常运转了。3. STM32F411移植实操从建工程到跑通RTU从站3.1 硬件准备与CubeMX的基础配置我这次用的是STM32F411CEU6核心板主频96MHz。选择F411的原因很简单带DMA、主频高、价格便宜很多项目都在用它做协议转换或采集设备。准备一个USART转USB模块调试时配合串口助手或者Modbus Poll上位机工具使用。用STM32CubeMX生成工程时我的配置思路是这样的选择USART2作为Modbus串口波特率96008数据位、无校验、1停止位开启接收中断和发送中断。这里需要提醒一下Modbus RTU默认就是8位数据位不要配置成9位否则协议栈解析会错位。分配一个基本定时器TIM6定时周期1ms开启更新中断专门给FreeModbus提供帧间隔计时。如果后面要做DMA改造还需要开启USART2的接收DMA通道。F411的USART都比较灵活你可以根据自己板子调整引脚。把USART2的TX配到PA2RX配到PA3这是最常用的映射。3.2 移植第一步portserial.c的串口对接FreeModbus的串口操作接口并不是直接让人调HAL库的它给了你一个类似“注册表”的层你只需要把HAL库和DMA的操作填进去。先看最关键的初始化函数BOOL xMBPortSerialInit(UCHAR ucPort, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity) { // 这些参数在eMBInit时传入这里根据参数完成串口外设的配置 // 通过CubeMX生成的MX_USART2_UART_Init()已经配置好了默认参数 // 如果波特率或校验方式跟默认不同需要在这里修改USART2的句柄 return TRUE; }这里有一个常见误区很多人在CubeMX里把波特率固定成9600然后不管eMBInit传多少波特率实际串口都是9600在工作。如果你的设备要支持1200、2400、4800等多种波特率就必须在xMBPortSerialInit里根据传入的ulBaudRate重新初始化USART而不是只用默认配置。接下来是逐字节收发BOOL xMBPortSerialPutByte(CHAR ucByte) { // 发送一个字节轮询方式或者中断方式都可以 // 推荐先把字节写入发送数据寄存器再发一个发送中断使能 // 等发送完成中断触发后在中断里调用pxMBFrameCBTransmitterEmpty() while (__HAL_UART_GET_FLAG(huart2, UART_FLAG_TXE) RESET); huart2.Instance-DR ucByte; return TRUE; }接收部分更讲究时机。协议栈要求每收到一个字节就要通知一次所以我的做法是直接在主程序里打开接收中断在串口中断里取出数据收寄存器然后立刻调用协议回调void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart2, UART_FLAG_RXNE) ! RESET) { pxMBFrameCBByteReceived(); } // 发送完成中断根据实际情况处理 HAL_UART_IRQHandler(huart2); }有朋友问过为什么不直接调用HAL_UART_Receive_IT其实协议栈自己维护接收状态机它需要“每收到一个字节就回调一次”这种原始数据流而不是HAL库里的缓存式接收。所以最直接的方式反而是操作寄存器。vMBPortSerialEnable是收发方向的切换开关。它的作用很关键接收帧期间要关闭发送中断发送期间要关闭接收中断否则可能会在响应的同时误收自己的数据。void vMBPortSerialEnable(BOOL xRxEnable, BOOL xTxEnable) { if (xRxEnable) { __HAL_UART_ENABLE_IT(huart2, UART_IT_RXNE); } else { __HAL_UART_DISABLE_IT(huart2, UART_IT_RXNE); } if (xTxEnable) { __HAL_UART_ENABLE_IT(huart2, UART_IT_TC); } else { __HAL_UART_DISABLE_IT(huart2, UART_IT_TC); } }3.3 移植第二步porttimer.c的定时器对接RTU模式下协议栈需要判断一帧数据什么时候结束。Modbus标准规定两个字节之间如果超过3.5个字符时间就认为这一帧已经结束。以9600波特率计算一个字符约1.1ms3.5个字符大约4ms。FreeModbus用了一个更通用的换算方式以50微秒为单位计算超时时间在初始化时把值传给xMBPortTimersInit。在STM32上我用TIM6作为基础定时器配置成1ms中断一次。初始化函数里重新计算并装载自动重装值BOOL xMBPortTimersInit(USHORT usTim1Timerout50us) { // usTim1Timerout50us 是以50us为单位实际中断间隔usTim1Timerout50us * 50us // 如果TIM6配置成1ms中断这里需要根据参数换算重装值 return TRUE; }移植时最容易出问题的点在于vMBPortTimersEnable和vMBPortTimersDisable在协议栈里会被反复调用每收到一个字节就要重新启动定时器用来监视帧间隔。所以定时器的启停必须干净利落不能被其他中断阻塞。把TIM6中断优先级设得比串口高一级没问题但要注意两个中断之间不要互相抢占太久。定时器溢出中断里调用void TIM6_DAC_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(htim6, TIM_FLAG_UPDATE) ! RESET) { __HAL_TIM_CLEAR_FLAG(htim6, TIM_FLAG_UPDATE); vMBPortTimerExpired(); } HAL_TIM_IRQHandler(htim6); }3.4 主程序流程与寄存器回调映射完成port层三个文件后主程序的搭建就很简单了。我的工程里是这样组织的int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART2_UART_Init(); MX_TIM6_Init(); // 注册回调从机地址、串口、波特率 eMBInit(MB_RTU, 0x01, 0, 9600, MB_PAR_NONE); eMBEnable(); while (1) { // 处理Modbus请求 eMBPoll(); // 用户其他业务逻辑 } }寄存器回调是真正跟业务打交道的地方。协议栈收到读保持寄存器的请求后会调用你的回调函数你需要根据寄存器地址把数据填入usRegHoldingBufeMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { // usAddress 是寄存器地址1-65536 // 数据处理逻辑 // 读操作把变量值写入pucRegBuffer // 写操作从pucRegBuffer读取数据并更新变量 return MB_ENOERR; }建议把所有业务变量都统一管理起来用一个结构体或者数组维护然后在回调里按地址做相对偏移。比如我的设备里有温度、湿度、开关状态等变量把地址规划成0x0000温度、0x0001湿度、0x0002开关回调里直接按usAddress判断范围再处理。这样后期增加点位时只需要改回调函数不需要动协议栈本身。4. DMA串口接收改造解决高波特率下的中断风暴4.1 为什么要做DMA接收改造FreeModbus默认的逐字节中断接收方式在小数据量场景下没有任何问题但如果你把波特率调到115200甚至更高一帧数据里每个字节都会触发一次中断MCU被频繁打断CPU占用率明显上升。而且高波特率下字节间隔很短如果主循环里有复杂的浮点运算或者其他阻塞操作很可能会漏字节或者收到帧后来不及处理。用DMA接收的思路很简单串口收到数据后由DMA自动搬运到内存缓冲区整个过程不需要CPU干预。等一帧完整数据收完了再统一交给FreeModbus去解析这样既能减少中断次数又能避免高频数据丢失。我最终采用的方案是DMA接收 串口空闲中断( IDLE )。串口收到一帧数据后如果检测到总线空闲就会触发IDLE中断这时DMA已经把这帧数据全部搬到了缓冲区我只需要在中断里处理这一帧即可。4.2 DMA接收配置与缓冲区的闭环管理CubeMX里开启USART2的接收DMA数据宽度都选Byte方向PeripheralToMemory模式Normal。然后定义一个环形接收思路我这里为了贴近协议栈采用更直接的方法申请一个uint8_t xRxBuffer[256]作为DMA接收目标。每次启动DMA接收前把DMA目标地址设为xRxBuffer接收长度设为256。串口进IDLE中断时用__HAL_DMA_GET_COUNTER计算本次实际接收到的字节数。把这几个字节逐个“喂”给FreeModbus。正常情况下一个Modbus RTU帧不会超过256字节所以缓冲区容量足够。代码里核心逻辑是这样的void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart2, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart2); uint16_t rxLen RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart2.hdmarx); // 把DMA缓冲区数据按字节交给协议栈 for (uint16_t i 0; i rxLen; i) { pxMBFrameCBByteReceived(); } // 重启DMA接收准备接收下一帧 HAL_UART_Receive_DMA(huart2, xRxBuffer, RX_BUF_SIZE); } HAL_UART_IRQHandler(huart2); }这个方案能跑通的关键在于pxMBFrameCBByteReceived内部会调用xMBPortSerialGetByte而后者需要从串口数据寄存器读取数据。在使用DMA接收时数据已经不在数据寄存器里了。所以必须同步修改xMBPortSerialGetByte让它从xRxBuffer的对应位置取数据而不是读huart2.Instance-DR。我采用一个索引变量gRxReadIndex来跟踪当前已读取的字节位置每次xMBPortSerialGetByte从缓冲区取一个字节并递增索引BOOL xMBPortSerialGetByte(CHAR *pucByte) { if (gRxReadIndex gRxTotalLen) { *pucByte xRxBuffer[gRxReadIndex]; return TRUE; } return FALSE; }整套流程就变成了串口空闲中断到来DMA已经填充好缓冲区然后协议栈在后续处理中通过xMBPortSerialGetByte逐步消费缓冲区数据。这个模式下中断里只是提交数据真正耗时的帧解析放到主循环的eMBPoll里完成实时性也能得到保证。4.3 DMA发送改造的取舍发送方向折腾DMA的实际收益没有接收那么明显。Modbus响应帧通常只有几十个字节一次性写入发送数据寄存器也就几十个时钟周期用DMA发送反而要处理DMA完成中断和总线占用问题。我的做法是如果没有大批量连续发送场景发送就保持原来的逐字节方式接收用DMA就足够了。如果你确实要用DMA发送要注意发送完成中断的时机。Modbus RTU要求发送完一帧后要留出足够的时间间隔用DMA发送时必须在HAL_UART_TxCpltCallback里调用发送完成的协议回调并重新使能接收。否则上位机会因为响应帧尾和下一帧之间的间隔不对而报错。5. 常见问题与排查技巧实录移植FreeModbus V1.6的过程中我遇到过不少问题这里挑几个高频率的整理成表格方便大家直接对照排查。现象可能原因解决方案上位机收不到响应定时器未启用或优先级太低确认TIM6更新中断已开启中断服务函数里有调用vMBPortTimerExpired响应帧偶尔丢DMA缓冲区长度不够或IDLE中断被阻塞增大缓冲区IDLE中断里只做缓冲提交不要做耗时处理数据全部错位CRC报错串口数据位配置成了9位或波特率不一致检查USART配置确保8位数据位波特率与协议栈参数一致节点地址无响应但直连上位机能通设备地址配置错误或回调里寄存器地址偏移错确认eMBInit传的地址和上位机的轮询地址一致默认寄存器起始地址从0x0001写寄存器成功但读出来是旧值eMBRegHoldingCB里写操作没有更新变量检查eMode开关写操作分支里要从pucRegBuffer memcpy到变量最让我印象深刻的一次坑是IDLE中断配合DMA时第一次收到的数据总是对的第二次开始就乱掉。排查了半天发现是DMA接收结束后没有重新启动DMA导致第二次数据直接丢弃。这个问题在逻辑上很容易忽略因为第一次启动都在主程序里后面就依赖IDLE中断里面重启DMA。另外还要提一下中断嵌套。我的串口中断优先级设为2TIM6定时器设为1数值越小优先级越高。这个设计保证即使串口正在处理定时器溢出也能立刻打断避免帧超时判断被延迟。反过来如果定时器优先级更低遇到高频串口中断时帧超时计数会被挤掉经常出现收不到完整帧的情况。6. 进阶经验把FreeModbus用得更顺手的几个技巧移植跑通只是第一步真正在产品里稳定运行还需要注意一些细节。第一个技巧是合理规划寄存器地址映射。我习惯用一个寄存器地址分配表格来管理所有数据比如0x0000到0x001F放只读状态0x0020到0x003F放可读写的设置参数。回调函数里只需要按地址段一次判断不做大量switch-case嵌套代码既清晰又不容易出错。第二个技巧是在调试阶段开启FreeModbus的调试宏。在mbconfig.h里有一个调试开关打开后协议栈会把接收错误、超时等信息通过调试串口输出能快速定位是哪一步出了问题。产品发布前再关掉几乎不增加额外代码量。第三个技巧是用逻辑分析仪验证波形。调Modbus问题比调普通串口更需要看时序我用逻辑分析仪抓UART波形能直观看到请求帧和响应帧的字节间隔是否满足3.5字符要求。很多上位机“偶发超时”的问题最后都是通过波形看出来响应间隔偏大导致的。最后说个我个人习惯的小技巧在eMBPoll前后加一个GPIO翻转用来测量协议栈处理一帧请求到底花了多少时间。用示波器一看就清楚是否需要在主循环里给Modbus处理让出更多时间。实测下来在96MHz主频的F411上FreeModbus V1.6处理一条03功能码请求并生成响应时间不到1ms整个系统留出了充足的余量去跑业务逻辑。这个版本虽然老但在性能和代码可读性之间找到了很好的平衡值得继续用。本文还有配套的精品资源点击获取

相关新闻

最新新闻

日新闻

周新闻

月新闻