简介:这套资源围绕STM32F407与HAL库环境,提供完整的FreeModbus从机程序移植工程,面向需要实现Modbus RTU通信的嵌入式开发者,适用于工业控制、设备数据采集与自动化系统等场景。工程基于HAL库封装,覆盖底层驱动、协议栈核心、串口中断处理与寄存器映射等模块,结构清晰可直接复用。压缩包共687个文件,以C源码(384个)和头文件(155个)为主,另含启动文件、链接脚本、Keil工程配置、Hex固件、硬件初始化文件及说明文档,整体仅6.36MB,便于下载和二次开发。资源已有1932人学习,内容从时钟与串口初始化、中断回调、寄存器映射到报文解析均有完整实现,还附带编译产物与调试脚本,可帮助读者理解移植关键步骤,根据硬件需求裁剪协议参数,快速搭建稳定可靠的Modbus从机节点,减少从零开发的工作量。 我去年在一个设备改造项目里,需要把一台老设备的控制板换成基于STM32F407的板子,上位机只认Modbus RTU协议。当时手头正好有探索者开发板,HAL库的环境也搭好了,于是盯上了开源的freemodbus协议栈,准备把它移植成从机程序。整个过程踩了不少坑,也把freemodbus的底裤翻了个底朝天。写这篇文章,就是想把这套“HAL库 + freemodbus从机”的完整路子捋一遍,从CubeMX配置到协议栈对接,再到实际调试中那些文档里不写的坑,一次性交代清楚。如果你正准备在F407这类M4芯片上移植Modbus从机,这篇文章应该能帮你少走几星期弯路。
1. 移植思路与整体方案
1.1 为什么选freemodbus而不是手写协议栈
很多朋友一开始接触Modbus RTU,第一反应是自己写串口收发、自己算CRC、自己拼报文。我承认,如果只是做一个简单的读写保持寄存器,手写一个精简版确实很快。但一旦涉及多个功能码、广播地址、异常响应、多从机轮询这些场景,自己维护一套协议栈的成本就会直线上升。
freemodbus是一个开源的Modbus协议栈实现,它把协议解析、CRC校验、异常处理、功能码分发这些脏活累活都封装好了。我们要做的,只是给它提供一个串口字节收发接口、一个定时器接口,再实现几个寄存器读写回调函数。它支持RTU和ASCII模式,从机和主机角色都有。尤其是从机程序,结构非常清晰,移植到HAL库环境下的工作量其实很小。
还有一点很关键:freemodbus是经过大量项目验证的,报文异常处理和边界条件都考虑得比较完整。比如从机地址不匹配时如何处理、非法功能码如何返回异常帧、广播帧怎么处理,这些在协议栈里都有现成逻辑。自己写很容易漏掉这些细节。
1.2 移植前需要准备的软硬件环境
在动手之前,先把环境和材料备齐,免得写到一半发现缺东少西。我这次用的硬件是正点原子探索者STM32F407开发板,核心芯片是STM32F407ZGT6,主频168MHz,板载的串口1通过USB转串口芯片连接电脑,调试很方便。
软件方面,我用的STM32CubeMX版本是6.x,固件包对应F4系列1.27以上的版本都可以。IDE我习惯用Keil MDK,如果你用IAR或者STM32CubeIDE,操作逻辑也类似。freemodbus源码我建议去官网下载最新稳定版,目前常见的是1.6版本。
提示:下载freemodbus源码时,注意核对版本。老版本对某些编译器的兼容性不如新版本好,在Keil下编译时可能遇到一些类型定义的警告,后面我会专门讲怎么处理。
1.3 整体架构:一张图看懂数据流
整个从机程序的架构可以拆成三层。最底层是硬件层,也就是STM32的串口外设和定时器外设,由HAL库驱动。中间是移植层,就是freemodbus要求的几个接口函数,包括串口字节发送、串口字节接收回调、定时器启动停止和超时回调。最上层是应用层,也就是Modbus地址和实际设备数据的映射关系,比如上位机写寄存器地址0x0001,对应设备的启动停止命令,这个对应关系就在回调函数里实现。
数据流的走向是这样的:上位机发送报文,串口接收中断把字节交给freemodbus的接收状态机;协议栈根据RTU模式,通过定时器判断一帧数据是否接收完毕(3.5个字符时间的静默);帧完整后,协议栈解析功能码,调用我们注册的读写回调函数;回调函数读回实际数据,协议栈组帧、算CRC,最后通过串口发送出去。
这个架构的好处是,协议解析和硬件驱动完全解耦。以后如果要把Modbus跑在CAN上,甚至跑在TCP上,只需要替换移植层函数就行,协议栈内部代码几乎不用动。
2. 用CubeMX配置STM32F407的基础外设
2.1 时钟树与串口参数配置
先用STM32CubeMX新建一个项目,芯片选择STM32F407ZGT6。时钟配置这里要稍微注意下,F407最高主频168MHz,外部高速晶振HSE一般是8MHz,经过PLL倍频到168MHz。我习惯直接在Clock Configuration页面里把HCLK输入168,让CubeMX自动计算分频系数,省得手动一个个填。
串口方面,我选择USART1作为Modbus RTU的物理通道。参数配置建议:波特率9600、数据位8、停止位1、校验位None。9600是工业现场最通用的波特率,跑Modbus RTU完全够用。如果你需要更快的轮询速度,可以后续改到19200或者38400,注意3.5个字符时间的定时器参数要跟着重新算。
有一点要提醒:USART1的全局中断必须打开,并且中断优先级建议设置为抢占优先级1、子优先级0,也就是一个较高的优先级。Modbus RTU对接收实时性要求很高,如果串口中断被其他低优先级中断频繁打断,很可能导致字节接收超时,帧接收不完整。
2.2 定时器的选择与参数计算
Modbus RTU的帧结束判断用的是定时器。RTU协议规定,帧内两个字节间隔不能超过1.5个字符时间,帧结束以3.5个字符时间静默为准。freemodbus内部就是靠这个定时器来判定一帧数据是否接收完成。
F407上有高级定时器TIM1/TIM8,通用定时器TIM2-TIM5,基本定时器TIM6/TIM7。我选TIM6作为Modbus专用定时器,理由很简单:它是基本定时器,功能纯粹,不会和其他PWM、编码器等功能冲突,而且挂在APB1总线上,配置简单。
这里重点说下定时器参数怎么算。以9600波特率为例,一个字符帧包含1个起始位、8个数据位、1个停止位,共10位。所以一个字符的传输时间是 1/9600 * 10 = 1.0417ms。3.5个字符时间就是3.645ms。定时器时钟如果配置为84MHz(APB1定时器时钟通常是系统时钟的一半,但定时器有2倍频,实际就是168MHz,这里用84是错的,准确说是APB1分频后×2),我们让定时器每50us产生一次中断,那么定时器自动重装载值就是 84MHz * 50us = 4200。要判断3.645ms,也就是约73次定时器中断。这个“每次中断计数值”在freemodbus的定时器接口里叫tTICK,下面是具体计算:
注意:这里的预分频和重装载值是配合freemodbus的定时器接口使用的,freemodbus要求定时器周期为50us到100us之间,太长了会影响帧判断精度,太短了又频繁进中断增加CPU负担。实测下来50us是比较合适的平衡点。
2.3 GPIO与中断优先级的坑
串口发收发引脚在CubeMX里配置好复用功能即可,PA9是USART1_TX,PA10是USART1_RX。这里有个容易踩坑的地方:很多人只配置串口但忘了把对应的GPIO速度调到High,导致高速波特率下波形失真。虽然9600波特率下影响不大,但养成好习惯,GPIO Speed设为Very High。
中断优先级这里我再啰嗦一句。串口接收中断应该放在一个独立的优先级组,比如优先级分组2(抢占2位+子优先级2位)下,USART1抢占优先级设为1,TIM6全局中断设为2。这样串口中断优先级高于定时器中断。为什么?因为每次串口收到字节,都要尽快把数据取走并刷新定时器计数值,如果定时器中断阻塞了串口接收,就会丢字节。而定时器中断本身对实时性要求没那么高,晚几十微秒处理完全没问题。
3. freemodbus源码结构与移植实战
3.1 源码目录一目了然
freemodbus的源码下载解压后,目录结构是这样的:demo文件夹里是各种平台的示例,modbus文件夹里是协议栈核心代码,其中include放头文件,mb.c/mb.h是协议栈主逻辑,functions文件夹放各个功能码的处理函数(读线圈、写寄存器等等),port文件夹是移植层。
我们真正要改的文件其实很少,集中在port文件夹里。最核心的是这三个:portserial.c(串口移植)、porttimer.c(定时器移植)、portevent.c(事件通知,一般用裸机标志位实现)。如果你用的是freeRTOS版本,还涉及portother.c这些文件,但我这里只说裸机环境。
3.2 串口移植:从HAL回调到协议栈接口
串口移植是整个过程最关键的一步,因为HAL库的串口收发模式跟freemodbus期望的字节流模式不太一样。
freemodbus期望的接口很原始:有一个函数能把单个字节发出去,有一个函数能把收到的字节交给协议栈。对应到HAL库,我们可以这样实现发送部分:
BOOL xMBPortSerialPutByte( CHAR ucByte ) { /* 等待发送完成,这里用while等待,数据量小,影响不大 */ while( !__HAL_UART_GET_FLAG( &huart1, UART_FLAG_TXE ) ); __HAL_UART_SEND_DATA( &huart1, (uint8_t)ucByte ); return TRUE; }接收部分是关键。freemodbus提供了一个函数prvvUARTRxISR(),需要在串口收到每个字节时调用它。在HAL库环境下,我们有几种方式:
方式一:直接用HAL_UART_Receive_IT启动单字节接收,然后在回调函数HAL_UART_RxCpltCallback里,把接收到的字节交给prvvUARTRxISR,然后再启动下一次接收。这种方式代码简单,但每次接收一个字节都要重新使能一次中断,对于高频通信场景开销稍大。
方式二:我实际采用的是直接在串口中断服务函数里读数据寄存器,不经过HAL的接收流程。具体做法是重写USART1_IRQHandler,判断RXNE标志位后直接读取DR寄存器,然后调用prvvUARTRxISR。这种方式效率最高,延迟最小。
void USART1_IRQHandler(void) { if( __HAL_UART_GET_FLAG( &huart1, UART_FLAG_RXNE ) != RESET ) { uint8_t ucByte = (uint8_t)( huart1.Instance->DR & 0xFF ); prvvUARTRxISR( ucByte ); } HAL_UART_IRQHandler( &huart1 ); }提示:很多人直接调用HAL_UART_IRQHandler,然后依赖HAL回调接收,这在Modbus这种需要逐字节精确处理的场景下不够干脆。建议按方式二操作,这也是freemodbus在裸机上最常见的移植写法。
3.3 定时器移植:帧超时判断的节拍器
定时器移植要做的事情有两件:一是启动定时器,二是定时器中断里调用协议栈的TICK处理函数。对应到porttimer.c,核心代码是这样的:
BOOL xMBPortTimersInit( USHORT usTim1Timerout50us ) { /* 注意:usTim1Timerout50us是50us为单位的超时值 */ /* 这里把参数换算成定时器计数值 */ __HAL_TIM_SET_AUTORELOAD( &htim6, ( usTim1Timerout50us * 50 - 1 ) ); return TRUE; } void vMBPortTimersEnable( void ) { __HAL_TIM_CLEAR_FLAG( &htim6, TIM_FLAG_UPDATE ); __HAL_TIM_SET_COUNTER( &htim6, 0 ); HAL_TIM_Base_Start_IT( &htim6 ); } void vMBPortTimersDisable( void ) { HAL_TIM_Base_Stop_IT( &htim6 ); }定时器中断服务函数里,调用协议栈提供的TIMERExpiredISR()即可:
void TIM6_DAC_IRQHandler(void) { if( __HAL_TIM_GET_FLAG( &htim6, TIM_FLAG_UPDATE ) != RESET ) { __HAL_TIM_CLEAR_FLAG( &htim6, TIM_FLAG_UPDATE ); ( void )prvvTIMERExpiredISR(); } }这里有个细节要特别注意:xMBPortTimersInit里传入的usTim1Timerout50us,单位是50us的倍数。这个值是在eMBInit调用时根据波特率自动算出来的。9600波特率下,4ms除以50us等于80,所以协议栈会传入80左右的值。我这个实现里用了(usTim1Timerout50us * 50 - 1)来设置自动重装载值,但因为定时器预分频我设置的是168MHz/50us=8400对应的分频值,所以重装载值直接就是usTim1Timerout50us对应的计数值,这里需要根据自己的预分频设置做相应换算,我下面的配置里用的分频是168-1(即1MHz计数频率),所以重载值是usTim1Timerout50us * 50 - 1。
3.4 寄存器回调函数与应用层绑定
freemodbus和应用程序之间的桥梁是四个回调函数:eMBRegHoldingCB(保持寄存器读写)、eMBRegInputCB(输入寄存器读)、eMBRegCoilsCB(线圈读写)、eMBRegDiscreteCB(离散输入读)。我们只需要根据自己的实际需求实现其中几个。
以最常见的保持寄存器为例。假设设备有16个保持寄存器,分别对应不同的控制参数,我定义一个全局数组:
static uint16_t usRegHoldingBuf[16];然后在eMBRegHoldingCB里根据读写方向和地址范围做映射:
eMBErrorCode eMBRegHoldingCB( UCHAR * pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode ) { USHORT usRegIndex = usAddress - 1; /* Modbus地址从1开始,数组从0开始 */ if( ( usRegIndex + usNRegs ) > 16 ) { return MB_ENOREG; } if( eMode == MB_REG_READ ) { for( USHORT i = 0; i < usNRegs; i++ ) { pucRegBuffer[2*i] = ( UCHAR )( usRegHoldingBuf[usRegIndex+i] >> 8 ); pucRegBuffer[2*i+1] = ( UCHAR )( usRegHoldingBuf[usRegIndex+i] & 0xFF ); } } else { for( USHORT i = 0; i < usNRegs; i++ ) { usRegHoldingBuf[usRegIndex+i] = ( ( USHORT )pucRegBuffer[2*i] << 8 ) + pucRegBuffer[2*i+1]; } } return MB_ENOERR; }注意这里有个字节序问题:Modbus协议规定寄存器数据的字节序是高字节在前(Big-Endian)。而STM32是小端处理器,所以从报文缓冲区读取数据时,必须手动把高字节左移8位再和低字节组合。这也是很多新手调试时发现读写数据不对头的高频原因。
4. 联调测试与疑难杂症排查实录
4.1 用Modbus Poll模拟上位机验证
移植完成后,推荐用Modbus Poll这个工具来模拟上位机进行功能测试。它可以直接设置从机地址、功能码、寄存器地址和长度,还能看到每一帧收发报文和错误信息,调试效率非常高。
我的测试套路是这样的:先测03功能码(读保持寄存器),手动修改几个寄存器的值,看看Modbus Poll读回来的数据是否一致。再测06功能码(写单个寄存器)和10功能码(写多个寄存器),在Modbus Poll里写值,然后通过串口调试助手或者直接在单片机代码里打印数组内容,确认写入生效。最后测异常场景:访问不存在的寄存器地址,或者发一个不支持的寄存器数量,看协议栈是否正确返回异常码。
4.2 经典坑1:定时器初始化数据算错导致帧超时
我第一次移植时,定时器参数没算对,结果表现为上位机能发请求但一直收不到响应。用逻辑分析仪抓串口波形发现,报文其实已经完整发过来了,但freemodbus把完整的帧拆成了两段来解析,自然就CRC校验失败而不响应。
后来查代码发现是定时器中断周期太短,远小于协议栈期望的50us节拍,导致状态机在帧接收中途就认为超时了。对照协议栈源码,在vMBPortTimersEnable被调用时,会先清一次计数器,然后每次字节到达都会重装计数器,只有连续超过3.5个字符时间没有新字节才会触发帧结束。所以定时器周期必须和xMBPortTimersInit的参数严格匹配,差一点都不行。
4.3 经典坑2:CRC校验错误却找不到原因
还有一个印象深刻的坑:测试时发现,只要寄存器数量超过8个,读取就大概率出错。单步调试发现,收到的报文内容完全正确,但CRC校验就是不过。最后排查发现是串口接收中断和主循环共享了同一个数组,在中断里写数组的同时主循环在读数组,造成数据竞争。
解决方式很简单,在freemodbus的接收状态机处理完一帧之前,不要让主循环去碰接收缓冲区。实际上freemodbus已经通过事件机制(xMBPortEventPost)把数据接收和处理流程串起来了,只要不要在自己代码里额外引用协议栈内部的接收缓冲区,就不会有这种问题。我当时是在调试输出时直接打印了协议栈内部buffer,结果干扰了正常流程。
4.4 经典坑3:中断优先级配置不当导致丢字节
在系统里加了一个高频率的ADC采样任务后,Modbus开始偶尔无响应。检查后确认是ADC中断优先级设置得太高(抢占优先级0),串口接收中断(抢占优先级1)被长时间抢占,导致在极端情况下两个字节之间的间隔超过了1.5个字符时间,协议栈判定帧错误而丢弃。
调整方案是把串口接收中断优先级提升到最高(抢占优先级0),定时器中断优先级保持2,ADC中断降到3。实测丢字节问题消失。这里我特别建议:Modbus RTU通信的串口接收中断,在整个系统中应该拥有最高的抢占优先级。
4.5 常见问题排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 上位机发请求无任何响应 | 从机地址不匹配、串口参数不一致、使能未调用 | 检查报文地址和数据参数 |
| 收到响应但CRC错误 | 波特率不一致、定时器参数错位、线路干扰 | 用逻辑分析仪抓波形核对 |
| 偶发性超时无响应 | 串口中断被抢占、缓冲竞争 | 检查中断优先级分配、静态检查缓冲区 |
| 读写数据字节顺序颠倒 | 大小端处理错误 | 核对高低字节组合代码 |
| 多个寄存器读写异常 | 地址映射越界、寄存器数量超限 | 检查回调函数地址范围和数量判断 |
4.6 关于HAL库串口空闲中断和DMA的扩展思考
说到这,肯定有朋友会问:能不能用HAL库的串口空闲中断加DMA来做接收?能,而且确实能大幅降低CPU占用。但freemodbus的裸机版本内部是一个逐字节的状态机,它天然需要每个字节都经过处理,DMA加空闲中断的模式反而和它的设计逻辑拧着来。
如果你确实想用DMA,可以考虑把Modbus整体跑在freemodbus的RTOS版本上,把DMA收满一帧后的回调转换成事件发送给协议栈处理。或者更简单一点:在F407这种主频168MHz的芯片上,9600波特率每秒也就960个字节,每个字节触发一个中断的CPU开销微乎其微,完全不用担心性能问题。我实测跑19200波特率,串口中断 + 协议栈解析,CPU占用率都不到5%,所以裸机中断方案是最省心的选择。
5. 关键代码整合与工程结构建议
5.1 主循环里的协议栈初始化流程
把整个移植流程串起来,主函数里的关键流程其实很简洁:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_TIM6_Init(); /* 初始化Modbus从机,RTU模式,从机地址1,串口1,9600波特率 */ eMBInit( MB_RTU, 0x01, 0, 9600, MB_PAR_NONE ); /* 启用协议栈 */ eMBEnable(); while(1) { /* 轮询处理Modbus事件 */ eMBPoll(); /* 其他应用代码 */ // 用户任务... } }eMBPoll是裸机环境下必须周期性调用的函数,它负责取出串口解析完成的事件,分发到各个功能码处理函数。如果长时间不调用eMBPoll,即使串口收完了数据,协议栈也不会处理,更不会返回响应。这一点一定要记得,我就见过有人把eMBPoll放进了一个被阻塞的死循环后面,结果整个Modbus就像死了一样。
5.2 工程文件的归类整理
移植完成后,建议把freemodbus的源文件整理到统一的文件夹下,方便管理和后续复用。我习惯这样组织工程目录:
Modbus/目录放协议栈核心源文件Modbus/port/目录放移植层文件(portserial.c、porttimer.c、portevent.c)App/modbus_app.c放寄存器映射和应用层回调函数的实现- 编译时把Modbus目录下的所有.c文件加入工程
这样做的最大好处是,下次换芯片或者换平台时,只需要把Modbus/port/下的文件重新适配一遍,协议栈核心目录完全不动。我在F103、F407、甚至GD32上都是这么干的,迁移效率非常高。
5.3 移植步骤自查清单
为了方便大家照着一步步来,我把完整的移植步骤整理成一个清单,每完成一项就勾选一下:
- CubeMX配置USART1,波特率9600,8N1,开启全局中断
- CubeMX配置TIM6,预分频和重装载值按50us节拍计算,开启全局中断
- 下载freemodbus源码,把modbus目录复制进工程
- 删除demo代码,只保留port文件夹下的空闲模板
- 实现portserial.c中的串口初始化、收发函数
- 实现porttimer.c中的定时器初始化、使能、失能函数
- 在USART1中断里调用prvvUARTRxISR
- 在TIM6中断里调用prvvTIMERExpiredISR
- 实现寄存器回调函数,注册到协议栈
- 在main函数里调用eMBInit、eMBEnable,循环调用eMBPoll
- 用Modbus Poll联调测试
6. 用上位机实测的完整验证过程
6.1 读保持寄存器(03功能码)实测
启动设备后打开Modbus Poll,从机地址设为1,功能码选择03(读保持寄存器),起始地址设为0,数量设为10,点击连接后,正常情况应该立即看到10个寄存器的数值实时刷新。
我第一次测试时,发现读回来的值全是乱的,十六进制看是8位高低字节交换了,也就是把寄存器值0x1234读成了0x3412。这就是前面说的大小端问题。在回调函数里把高低字节的组装顺序调整后,立刻恢复正常。这个坑十有八九会踩,建议大家写回调函数时直接按我前面给的代码模板来,一次到位。
6.2 写单个寄存器(06功能码)实测
在Modbus Poll里双击一个寄存器,输入新值后发送,单片机能正确收到并且在下一次读操作时返回新值。这里有个细节:06功能码写入后,上位机通常会马上再读一次确认,所以写入回调函数里要确保数据已经真正写入到对应的变量中,而不是只存在临时缓冲区里。
我实测时发现,如果写入的是控制参数(比如PID的Kp值),设备需要立即生效。所以我在写回调函数里加了判断,如果地址是控制参数区,写入后直接调用对应的参数更新函数。这属于应用层逻辑,大家根据自己的设备需求来扩展。
6.3 异常响应测试
Modbus协议规定,当请求的寄存器地址和数量超出范围时,从机必须返回异常码。我的实测场景是读取地址100的寄存器(实际只定义了16个寄存器),从机返回异常码0x02(非法数据地址)。
这个测试很重要,很多三流实现这里会直接不回包或者干脆返回错误数据,这会让上位机调试程序非常痛苦。freemodbus在这个场景下的行为是规范的:识别出地址越界后,返回异常响应帧。如果发现从机对非法地址没有响应,优先排查回调函数里的地址范围判断逻辑,确认return MB_ENOREG是否被正确触发。
6.4 多从机轮询场景验证
设备改造项目中,总线上挂了3个从机,地址分别为1、2、3。我把另外两个从机都接上后,用Modbus Poll依次轮询三个站点,确认从机1不会响应地址2的报文,从机2也不会响应地址1的报文。
这个场景验证的核心是协议栈的地址过滤机制。freemodbus在收到一帧报文后,首先解析从机地址,如果既不匹配本机地址也不是广播地址,直接丢弃不处理。这个逻辑在协议栈内部已经实现了,我们要做的只是确保eMBInit时传入正确的从机地址。另外注意一点:广播地址是0,广播帧从机必须处理但不需要回复,freemodbus默认支持这个行为,实测中会收到广播请求并执行写操作,但不会发送任何响应帧,这是符合协议规范的。
7. 顺带说说几个容易被忽视的细节
7.1 串口引脚与RS485方向控制
如果你的设备是走RS485物理层,那么还需要一个GPIO控制收发方向。常见的做法是用一个引脚接MAX485的DE/RE引脚,发送数据时拉高,接收时拉低。
freemodbus的串口移植里,方向切换的时机很关键:必须在最后一字节发送完成后,再把方向切回接收。我建议直接在xMBPortSerialPutByte里,发送前拉高方向脚,然后在查询TXE标志空闲后拉低。实测下来这样最稳妥。
如果用HAL库的HAL_UART_Transmit阻塞发送,它内部有等待发送完成的机制,方向切换的时机放在发送函数返回后即可。但要注意HAL_UART_Transmit等待的是TXE标志,如果串口还有字节在移位寄存器里,直接切换方向会导致最后一字节被截断。更稳妥的是在切换方向前额外等待UART_FLAG_TC(发送完成)标志。
7.2 关于波特率的自适应问题
有些项目希望从机能够自动适应不同波特率,也就是上位机发什么波特率就用什么波特率。这个功能在freemodbus原生代码里是不支持的,需要自己额外实现动态波特率检测。
常见的方案是:在空闲状态下,先监测总线上的第一个字节,根据起始位和停止位之间的时间差估算波特率,然后重新初始化串口和定时器参数。这个方案我做过一次,效果勉强可用,但稳定性一般,尤其是在现场噪声干扰较大的情况下,误判概率不低。所以我的建议是:如果项目允许,尽量固定波特率,省去这一层麻烦。
7.3 从HAL库中断回调函数引发的问题
搜索热词里提到过一个现象:“HAL库串口空闲中断回调函数有bug吗”。我在移植过程中确实发现,HAL库的HAL_UART_RxCpltCallback这类回调函数是在中断上下文中执行的,如果你在回调里做了比较重的处理(比如直接调用prvvUARTRxISR后再调用协议栈的帧处理),就可能拉长中断时间,导致后续字节丢失。
这也是我前面推荐直接重写中断服务函数、绕开HAL回调的原因之一。在Modbus这种逐字节处理的场景下,中断路径越短越好,HAL库的通用回调机制虽然方便,但不一定是最优解。如果你的代码里已经在用HAL回调接收Modbus数据,建议认真检查一下中断服务函数的总执行时间。
8. 写在最后的一点操作心得
这次移植项目让我对Modbus RTU协议和freemodbus的代码结构都有了更深的理解。以前总觉得Modbus协议简单,不就是读写寄存器嘛。但真正把它跑起来,发现协议解析、错误处理、边界条件这些细节,处处都是坑。
几个印象深刻的教训,在这里再强调一次。第一,定时器参数必须严格按照50us节拍来算,这是freemodbus整个时间片状态机的基石,错了就是错乱。第二,串口接收中断优先级必须足够高,建议抢占优先级设到最高,别问为什么,去现场排查几次随机丢包就明白了。第三,回调函数里的大小端处理一定要小心,Modbus的Big-Endian和STM32原生的小端经常把人绕晕,建议把所有寄存器读写回调都统一按照高字节在前的模式来实现,减少思维负担。
关于调试工具,我再分享一个实用技巧。如果你手头没有逻辑分析仪,可以把波特率临时降到1200,用串口调试助手手动模拟一帧Modbus报文,观察设备是否响应。这种方法虽然原始的,但对于排查协议栈是否正常运行非常有效。我在现场调试的时候经常先用这个办法确认从机活着,再上Modbus Poll做系统测试。
最后,这次移植的代码量和复杂度其实都不大,核心改动集中在port层。搞明白freemodbus的架构之后,以后再实现其他协议的移植,也会轻松很多。如果后面有机会,我再写一篇关于如何在这套基础上增加自定义功能码的文章,欢迎关注交流。
本文还有配套的精品资源,点击获取