STM32移植FreeModbus完整指南:从源码裁剪到调试技巧
简介面向 STM32 开发者的 FreeModbus 移植工程包附带移植笔记与大量中文注释。内容围绕 Modbus 协议在 STM32 上的落地应用涵盖 RTU/ASCII 模式配置、USART 串口适配、定时器中断以及寄存器读写等功能码实现适用于工业自动化、物联网网关等嵌入式通信场景也适合刚接触协议栈移植的工程师学习。包内共 1316 个文件以 C 源码与头文件为主496 个 .c、568 个 .h另含链接脚本、启动文件、工程配置和说明文档压缩包约 6.79MB可导入 Keil 或 IAR 工程二次修改。目前已有 3985 人学习下载。包内提供完整 FreeModbus 源码、串口及定时器适配层、调试配置和移植笔记重要代码均有中文注释可帮助读者理解地址、波特率、校验等参数设置并掌握从串口初始化到应用层功能码处理的完整移植流程。 搞嵌入式这几年隔三差五就能在群里看到有人问“怎么在STM32上跑Modbus”其实答案基本就一个直接用FreeModbus开源协议栈然后花半天时间对着移植笔记把串口和定时器部分填上剩下的事就是调试了。我自己第一次移植FreeModbus时也踩了一堆坑有些坑到现在翻论坛还能看到有人反复踩。所以我决定把这次完整移植过程整理成一篇笔记性质的文章把源码怎么组织、哪些文件要改、哪些宏要开、波特率查表怎么联动定时器以及调试时真正让人头大的几个细节全部按实际动手顺序串一遍。文末会附上完整工程文件的获取方式里面有带详细注释的源代码适合所有想在STM32上快速接入Modbus读写的开发者参考。1. 移植前的源码理解与工程裁剪1.1 FreeModbus源码目录结构与功能划分FreeModbus的源码结构其实非常清爽拿到手之后不要急着往工程里拖文件先花半小时把目录看明白后面能少走很多弯路。核心部分在modbus目录下mb.c是整个协议栈的状态机入口所有对外API都封装在mb.h里mbconfig.h是总配置文件决定你要启用哪些功能码、使用RTU还是ASCII模式mbfunc.c、mbfunccoil.c、mbfuncdisc.c、mbfuncholding.c、mbfuncinput.c这一组文件对应Modbus不同功能码的具体实现mbrtu.c是RTU模式的帧收发核心对串口中断里收到的原始字节做协议帧的切分和校验mbtcp.c则是TCP模式。用户真正需要自己实现的只有一个port层也就是port.h、portserial.c、porttimer.c这三个文件加上一个portevent.c。portserial.c负责串口的初始化、字节收发、收发中断使能porttimer.c负责提供Modbus协议需要的时间基准主要用在RTU模式的帧间超时判断和字符间超时判断上。说白了FreeModbus把底层硬件完全抽象掉了你想把它跑在STM32、AVR还是MSP430上本质上就是在填这三个文件的空。1.2 只保留必要文件的“最小移植集”裁剪工程时我的做法是从demo目录里找一个最接近的参考平台比如AVR的demo把结构套过来。STM32工程最终只需要保留以下文件modbus核心目录里的mb.c、mb.h、mbconfig.h、mbfunc.c、mbfuncholding.c以及RTU相关的mbrtu.c、mbrtu.h。port目录下自己实现的port.h、portserial.c、porttimer.c。不需要加入mbtcp.c、mbfuncdisc.c、mbfuncinput.c这些文件除非你确认要用到对应功能。mbconfig.h里的宏开关决定了编译哪些功能这部分在剪裁时容易忽略实际上裁剪工程时很多看似多余的文件就是靠这一组宏自动屏蔽掉的。建议把所有功能码相关宏打开因为在调试初期你没办法预料上位机到底会发什么功能码过来。还有一个容易被忽略的关键点mb.h里通过#include port.h引用平台相关的头文件所以port.h的路径必须让编译器找得到。我习惯把port目录直接放到工程根目录下和modbus目录平级然后在KEIL的Include Paths里同时添加这两个路径省去一堆相对路径的麻烦。2. 从时钟频率到分频系数的波特率联动原理2.1 为什么所有移植教程都在强调“改波特率查表法”FreeModbus里最经典的操作就是打开tables.c你会发现里面有一张写死的查表按照不同波特率返回对应的定时器分频系数。这个表的关键在xMBPortTimersInit里串口波特率变了这张表的索引也要跟着变否则Modbus的帧间超时时间就全错了。具体来说RTU模式下两个帧之间的空闲间隔至少要等3.5个字节的传输时间如果这个时间算错了要么会把上一帧的尾部当成下一帧的头部要么会把还没结束的帧误判为超时导致通信彻底乱套。所以波特率查表的本质是在为“字符间隔计时器”设定合适的tick数。以常见的50us时基为例假设波特率9600一个字节包含起始位1位、数据8位、停止位1位共10位一位时间是104.17us一个字节约1.042ms。3.5个字节约3.65ms除以50us时基算出来大概需要73个tick。如果波特率变成115200一位时间约8.68us一个字节约86.8us3.5个字节约304us除以50us时基只需要6个tick。这就是为什么改动波特率后定时器参数必须同步调整否则用9600的帧间隔去跑115200的波特率协议栈会把连续的两帧当成一帧处理。tables.c里的查表函数实现大概是这样的USHORT usTimerT35_50us( void ) { USHORT usTimerT35_50us; switch( ulBaudRate ) { case 2400: usTimerT35_50us 916; break; case 4800: usTimerT35_50us 458; break; case 9600: usTimerT35_50us 229; break; case 19200: usTimerT35_50us 114; break; case 38400: usTimerT35_50us 57; break; case 57600: usTimerT35_50us 38; break; case 115200: usTimerT35_50us 19; break; default: usTimerT35_50us 229; break; } return usTimerT35_50us; }这里计算出来的是多少个50us的tick所以在xMBPortTimersInit里初始化定时器时直接把usTimerT35_50us作为定时器周期重载值。STM32的定时器一般采用向上计数配置好了之后定时器中断每到一个tick就触发一次协议栈在中断回调里检查距离上一帧最后一个字节的间隔是否已经超过这个tick数从而决定是否把当前收到的缓冲区当成一个完整报文交给上层解析。2.2 时钟配置对查表系数的最终影响如果只用默认的HSE(8MHz)分频得到72MHz主频那么最简单的配置是让定时器时钟等于主频然后根据分频系数计算预分频值。为了得到50us的时基预分频值PSC等于定时器时钟频率乘以50us减1。比如72MHz时钟下PSC取3599也就是3600分频得到20kHz计数器频率一个计数周期恰好是50us。然后ARR取上面查表算出来的tick数减1这里有一个容易踩的坑查表返回的tick数已经是实际需要的计数周期数而STM32的定时器ARR是自动重载值想要产生N个tick中断ARR要设置成N-1。这个细节我第一次移植时漏了导致所有帧的超时都提前了一个tick实际通信表现为偶发性的帧丢包排查了很久才定位到。xMBPortTimersInit的典型实现片段TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; /* 72MHz / 3600 20kHz, 每个tick 50us */ TIM_TimeBaseStructure.TIM_Prescaler 3600 - 1; TIM_TimeBaseStructure.TIM_Period usTimerT35_50us() - 1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit( TIM2, TIM_TimeBaseStructure );我建议把波特率定义成宏放在port.h里并且让usTimerT35_50us直接读取这个宏这样以后改波特率只需要动一个地方不用翻tables.c去手工改那张表。提示如果你在调试时发现通信偶尔正常、偶尔完全无响应十有八九就是定时器ARR配置和查表系数差了1或者分频系数没有按50us时基来算。3. 串口与定时器中断如何配合才能不丢帧3.1 串口发送用查询方式接收用中断方式FreeModbus官方demo的默认做法是发送时把要发出去的数据全部用查询方式推给串口接收则完全靠串口中断一个字节一个字节往回调函数里塞。这种“发查询收中断”的组合是经过考量的——发送通常是低频的、主动的行为主循环闲的时候处理即可接收则是被动且突发性强的必须由中断快速响应否则高波特率下一个字节还没读完下一个字节就来了触发溢出中断后面的数据全丢。串口中断服务函数里要做的事很简单判断接收中断标志然后读取DR寄存器再调用prvvUARTRxISR()把当前字节递交给协议栈void USART1_IRQHandler( void ) { if( USART_GetITStatus( USART1, USART_IT_RXNE ) ! RESET ) { uint8_t ucByte USART_ReceiveData( USART1 ); prvvUARTRxISR( ucByte ); } if( USART_GetITStatus( USART1, USART_IT_TXE ) ! RESET ) { USART_ITConfig( USART1, USART_IT_TXE, DISABLE ); } }需要特别注意prvvUARTTxISR()这个函数它是发送中断通知入口但我们在查询发送模式下根本不会打开发送中断。但如果你的工程里开着串口空闲中断或者DMA接收中断这两个中断如果配置不当很容易和prvvUARTRxISR产生竞争导致同一个字节被协议栈之外的地方消费掉。所以裸机移植时建议只开RXNE中断其他所有串口相关中断统统关闭等整体跑通了再考虑DMA方式。3.2 定时器中断是Modbus状态机的“心跳”xMBPortTimersInit初始化完之后定时器中断会一直运行它并不是说每次中断都要处理大量协议逻辑而是在中断里给协议栈提供一个“查询超时是否到点”的机会。FreeModbus在prvvTimerExpiredISR()中维护了一个计数器当串口还在接收字节时这个计数器会被清零一旦串口安静下来定时器就持续累计tick当累计值超过上面算出的T35说明一个完整报文已经接收完毕协议栈就会把缓冲区里的数据交给CRC校验和功能码解析模块。真正的运行时序是这样的串口中断收到字节0x01调用prvvUARTRxISR协议栈记下时间戳并清空超时计数。串口中断收到最后一个字节超时计数重新从0开始累加。定时器中断周期到来发现距离最后一个字节已经超过3.5字符时间于是判定帧结束启动协议处理。所以在中断优先级分配上定时器中断必须能打断主循环里的其他耗时操作推荐优先级设置成高于普通外设中断但低于串口接收中断。为什么串口接收优先级更高因为字节的接收是硬实时的一个字节没及时取走就是真的丢了而定时器超时判断稍微晚几个tick影响不大无非是协议处理晚几微秒。3.3 485方向控制引脚和发送完成标志的处理如果你做的是RS485版本的Modbus那还躲不开方向控制引脚。在vMBPortSerialEnable函数里xRxEnable参数为TRUE时打开接收方向为FALSE时切换为发送方向。常见的正确写法是void vMBPortSerialEnable( BOOL xRxEnable, BOOL xTxEnable ) { if( xTxEnable ) { RS485_DIR_PORT-BSRR RS485_DIR_TX_PIN; /* 置高切换到发送方向 */ } else { RS485_DIR_PORT-BRR RS485_DIR_TX_PIN; /* 清低切换回接收方向 */ } }方向切换后不能立刻发数据因为RS485收发器的转换时间摆在那里通常建议加一个3到5us的小延时最稳妥的做法是参考收发器数据手册的转换时间。发送完成后也不要急着把方向切回接收否则最后一个字节还在移位寄存器里时就切走数据线对端会收到一个被截断的停止位。标准的做法是检查串口的发送完成标志TC等TC置位后延时一小段时间再切回接收方向。4. 寄存器读写回调和功能码注册的实现要点4.1 保持寄存器回调函数的三种操作类型FreeModbus协议栈解析完一帧报文后会走到功能码对应的回调函数。以常用的保持寄存器为例需要实现eMBRegHoldingCB这个回调函数签名如下eMBErrorCode eMBRegHoldingCB( UCHAR * pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode )usAddress是从报文里解析出来的寄存器地址注意这里是协议地址不是数组下标。如果你的寄存器表是一个结构体数组那在回调里必须自己完成地址到实际数据位置的映射。我习惯用一个静态数组模拟保持寄存器然后直接把协议地址当成数组偏移量static USHORT usHoldingRegisters[100]; eMBErrorCode eMBRegHoldingCB( UCHAR * pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode ) { eMBErrorCode eStatus MB_ENOERR; int iRegIndex usAddress - 1; if( ( usAddress 1 ) ( usAddress usNRegs - 1 100 ) ) { switch( eMode ) { case MB_REG_WRITE: while( usNRegs 0 ) { usHoldingRegisters[iRegIndex] ( pucRegBuffer[0] 8 ) | pucRegBuffer[1]; pucRegBuffer 2; iRegIndex; usNRegs--; } break; case MB_REG_READ: while( usNRegs 0 ) { pucRegBuffer[0] ( UCHAR )( usHoldingRegisters[iRegIndex] 8 ); pucRegBuffer[1] ( UCHAR )( usHoldingRegisters[iRegIndex] 0xFF ); pucRegBuffer 2; iRegIndex; usNRegs--; } break; default: eStatus MB_ENOREG; break; } } else { eStatus MB_ENOREG; } return eStatus; }这里有个细节Modbus协议里寄存器的字节序是高字节在前Big-Endian而STM32是小端存储所以读写时必须做高低字节的拼接与拆分。第一次做的人很容易在字节序上栽跟头尤其是用Modbus Poll这类上位机工具测试时明明写进了1读出来却是256就是字节序没有处理好。4.2 通过宏开关打开需要支持的功能码mbconfig.h里有这样几个宏#define MB_FUNC_READ_INPUT_REGISTER_ENABLED ( 1 ) #define MB_FUNC_READ_HOLDING_REGISTER_ENABLED ( 1 ) #define MB_FUNC_WRITE_SINGLE_REGISTER_ENABLED ( 1 ) #define MB_FUNC_WRITE_MULTIPLE_REGISTERS_ENABLED ( 1 )需要留意的是FreeModbus原版默认并没有把06功能码写单个寄存器和16功能码写多个寄存器全部打开不同版本之间宏的默认值还不一样。如果你只开了03功能码那么上位机发来06时协议栈会直接回一个非法功能码的异常帧。所以移植完成后检查这组宏是必须的一步别等实测时才发现。打开宏之后协议栈会把对应功能码的分发逻辑编译进去在mbfuncholding.c的vMBFuncWriteSingleRegister等函数里完成具体的寄存器写入操作。如果不需要支持线圈和离散输入相关的宏保持关闭即可这样裁剪后的代码量能小不少。4.3 协议栈初始化与主循环轮询的衔接协议栈初始化流程一般是修改波特率参数后调用eMBInit(MB_RTU, 0x01, 0, 38400, MB_PAR_NONE)完成从站地址、串口参数和模式配置然后调用eMBEnable()使能协议栈。主循环里不断调用eMBPoll()来处理底层已经收上来的帧。这个eMBPoll一定轮询频率要足够高我的经验是主循环里除了Modbus轮询不要放太多耗时任务否则高波特率下协议栈缓冲区里的数据可能还没处理完新的帧又进来了。如果需要多主站并发读写可以先在主循环里处理完Modbus再处理其他任务让每个循环周期的Modbus响应时间尽量短。实测下来在主频72MHz的STM32F103上115200波特率下最坏情况循环周期控制在1ms以内Modbus通信非常稳定。5. 移植中真正折腾人的是这些细节5.1 现象低波特率正常高波特率偶发丢字节这个问题我排查了整整半天最后定位到是串口中断服务函数里做了太多事情。某一次改动时我在RXNE中断里顺手加了一行Debug日志输出而日志输出恰恰用到了同一个串口导致中断嵌套和发送时序全乱了。排查思路其实很明确高波特率下字节间隔短如果中断响应时间超过一个字节时间硬件接收缓冲区就会被覆盖表现就是丢字节。所以串口中断服务函数必须短平快绝对不能在里面做延时、打印、大数组拷贝这些事。调试日志可以用另一个串口或者干脆先用LED翻转配合逻辑分析仪来观察等通信稳定了再考虑加回日志。5.2 现象上位机能读到数据但写寄存器总是失败这类问题十有八九出在功能码宏没开全或者回调函数里地址越界判断做错了。比如寄存器地址标称是40001上位机发过来的协议地址就是0对应到回调里usAddress是0。如果usAddress - 1作为数组下标再判断usAddress自身的合法范围就可能出现所有寄存器都无法写入。我自己的习惯是统一把地址映射关系写成一个宏比如#define REG_HOLDING_START_ADDR ( 0x0000 )和#define REG_HOLDING_END_ADDR ( 0x0063 )然后在回调里用这两个宏判断避免硬编码数字串来串去。5.3 现象两块STM32板子之间通信不稳定一帧长数据必出错这种问题的根源经常在CRC校验上。Modbus RTU的CRC16多项式是0xA001查表法用的是高低字节互换的初始值0xFFFF如果移植时把查表用的初始值或者异或关系弄反了短报文侥幸通过长报文必然翻车。排查时可以先用串口助手直接抓总线上的原始字节再用Python脚本计算一遍期望的CRC值对比协议栈实际发送的CRC字节到底差在哪里。FreeModbus自带的usMBCRC16函数没问题问题几乎都出在自定义的上位机解析代码或者串口抓包工具上。5.4 现象发送方向切回接收时线路尾部多出一个毛刺这个我之前也遇到过。调试时用示波器看485总线上的波形发现方向切换后总线电平在停止位之后出现了一个短毛刺导致紧接着的下一帧数据对端解析出错。对比数据手册后确认是方向切换时间不够收发器还处在发送模式总线没被释放干净。解决办法就是在TC置位后延时一个位时间再切方向或者在方向切换前先把TX引脚的电平拉成空闲状态。RS485这一步比较磨人但把收发器时序调稳了整个通信链路就踏实一大半。6. 工程文件获取与二次开发建议这次移植的完整工程文件我已经整理好了包含STM32标准外设库版本的整个源码、FreeModbus全部核心文件、port层移植代码以及我加上的详细中文注释。工程基于STM32F103C8T6验证过使用的IDE是KEIL MDK5打开即可编译下载。如果你用的是STM32F407或者HAL库的工程移植思路完全一样只需替换portserial.c和porttimer.c里的底层调用即可。注意标准外设库和HAL库在串口中断注册、定时器中断回调函数名上有差异工程里的stm32f10x_it.c中断处理函数按照标准外设库的写法来HAL库版本需要换成对应的回调机制。拿到工程后我建议按下面的顺序走一遍先什么都不改编译下载用Modbus Poll连接确认能读到保持寄存器数据。再改从站地址、波特率、奇偶校验测试通信是否依然稳定。然后修改寄存器个数和应用层逻辑把真实的传感器数据填进保持寄存器数组。最后再加入16功能码的批量写测试验证多条寄存器同时写入的边界情况。如果后续要做多个从站挂总线需要把每个设备的从站地址区分开同时注意总线长度和终端电阻。我做过一条1200米左右的485总线在波特率9600下加了120欧终端电阻后通信非常可靠两端设备距离远时波形上的振荡明显减小。高波特率下对线缆和终端电阻的要求更严格你需要自己根据现场情况权衡是提升速率还是保证距离。如果对实时性有更高要求可以在eMBPoll的调用频率上做优化或者把轮询从主循环里挪到定时器中断驱动的任务调度中去。最后再分享一个调试心得FreeModbus自带的demo里默认从站地址是1如果你的上位机软件里设的从站地址不是1就会一直显示超时无响应。别一上来就怀疑移植有问题先用串口助手抓一下总线上的原始报文直观看到从站有没有响应、响应的报文字节流对不对很多问题瞬间就明朗了。本文还有配套的精品资源点击获取

相关新闻

最新新闻

日新闻

周新闻

月新闻