资讯动态

STM32移植FreeModbus协议栈:从原理到RTU从站联调全记录

发布时间:2026/10/4 10:21:45 来源:尧图企业网站定制
“做嵌入式这些年凡是设备要接上位机、连PLC、上组态软件Modbus基本是绕不开的。这套协议老归老但胜在简单、通用、免费十台工业设备里至少有八台在用它。”而要在单片机端做从站自己写帧解析、状态机、CRC校验不仅工作量大而且边界情况极多第一次写十有八九会在半双工通信上栽跟头。所以我最终选择了开源的FreeModbus协议栈并基于STM32做了一次完整的移植。这篇文章就是这次移植的全程记录从工程结构理解、定时器与串口适配到寄存器回调注册、Modbus Poll联调实测再到最常见的坑尽量一次讲透。如果你手头正好有一个STM32项目需要接入Modbus RTU从站功能或者正在为“FreeModbus移植”这件事翻资料、试错那么这篇文章可以直接当作操作手册来用。我会按实际动手的顺序来写涉及的具体代码以STM32F103 HAL库 Keil MDK环境为例但核心思路同样适用于标准外设库、其他STM32型号以及其他ARM Cortex-M平台。1. 动手前先把FreeModbus的工程结构和版本摸清楚很多朋友一拿到FreeModbus源码就直接往工程里拖文件结果编译报错一堆多半是没搞懂这个协议栈到底由几部分组成。在写任何代码之前先把仓库结构看清楚后面会省很多事。1.1 FreeModbus并不是一个直接能跑的SDKFreeModbus是老牌的免费开源Modbus协议栈官方定位是“协议栈 移植层”的组合体。协议栈帮你处理了Modbus RTU/ASCII/TCP的帧解析、地址匹配、功能码分发、CRC校验、异常响应生成等核心逻辑这部分是完全跨平台、不依赖具体芯片的。但它不知道你用的是哪颗MCU也不知道你的串口怎么初始化、定时器怎么配置所以它留了一套“port”接口需要你针对具体硬件来实现。理解这一点很关键移植FreeModbus的核心工作其实不是去改协议栈内部而是实现它规定的几个移植接口。从代码层面看FreeModbus源码主要分为三块mb/协议栈核心代码包括mb.c、mbrtu.c、mbfunc.c、mbframe.c等这些文件原则上不需要改动。port/移植层你需要重点关心的port.h、portserial.c、porttimer.c都在这里。demo/官方提供的各种平台示例可以拿来参考但一般不会直接塞进工程。我见过不少人移植时把整个demo目录也加进工程这是完全没必要的。demo里的main.c只是展示用法实际工程中我们需要的是mb目录下的核心文件以及自己实现port目录下的文件。1.2 老版本1.6和新版本3.0怎么选网上搜FreeModbus能搜到两个主要版本一个是经典的1.6版本另一个是后续维护的3.0版本。很多教程甚至代码片段混着用新手很容易糊涂。我这次用的是1.6版本。原因很简单资料多、结构简单、很多现有工程都是基于它做的。1.6版本的核心文件很少包括mb.c、mbrtu.c、mbfunc.c等在内一共十几个C文件直接把工程树展开基本一眼能看完。对于学习移植原理来说这个版本是最合适的。3.0版本在架构上做了很多现代化调整增加了对FreeRTOS的集成、多个从站地址支持、更完善的TLS/ASCII功能等。但同时也引入了mbconfig.h这种集中配置文件文件数量和依赖关系明显增多。如果你不需要这些高级特性用1.6反而更轻快。两个版本的选择思路我用一张表说明项目FreeModbus 1.6FreeModbus 3.0源码结构简单核心文件少较复杂模块化程度高配置方式在port.h/xmbinit.h中配置统一通过mbconfig.h配置FreeRTOS支持需要自己适配临界区有比较好的集成示例多从站地址不支持支持学习门槛低适合移植入门中等需要理解架构推荐场景裸机、简单RTU从站复杂工程、需要官方新特性我自己在实际项目中小资源MCU裸机场景一直优先用1.6因为最终固件体积小、依赖少、问题好排查。这次文章里的示例也都以1.6为准。1.3 移植前先理解Modbus RTU的帧格式移植协议栈之前还是有必要把Modbus RTU的帧结构再捋一遍。RTU模式下报文以“从站地址 功能码 数据 CRC16”的方式组织从站地址1字节范围1~2470x00为广播地址。功能码1字节比如0x03读保持寄存器、0x06写单个寄存器、0x10写多个寄存器。数据区长度不固定与功能码有关。CRC162字节低字节在前。串口上默认的字符格式是8位数据位、无校验、1位停止位也可配置为8E1等但最常见的是8N1。RTU模式还有一个非常重要的“帧间隔”概念两个帧之间必须有一个静默时间长度不小于3.5个字符时间而帧内部两个字节之间的间隔不能超过1.5个字符时间。这个时间间隔正是移植时定时器要干的核心事情。为什么要强调这一点因为我见过太多人移植后出现“时好时坏”“第一帧正常第二帧超时”的现象最后定位下来都是定时器时间算错或者压根没按帧间隔去设计超时逻辑。所以在下一步动手移植前务必把T35和T15这两个时间概念放在脑子里。2. 移植定时器帧间隔计时是一切的根基定时器是FreeModbus移植里最容易出错、也最影响通信稳定性的部分。串口收到字节后协议栈怎么知道一帧结束了就是靠定时器。从收到第一个字节开始计时如果超过T35没有再收到新字节就认为当前帧接收完毕交给协议栈去解析。2.1 T35和T15怎么算Modbus RTU里把一个字符的传输时间定义为“1起始位 8数据位 1停止位无校验时 11位”。所以T35 3.5 × 11 × (1 / 波特率)T15 1.5 × 11 × (1 / 波特率)以9600波特率为例T35 3.5 × 11 / 9600 ≈ 4.01msT15 1.5 × 11 / 9600 ≈ 1.72ms以115200波特率为例T35 3.5 × 11 / 115200 ≈ 0.334msT15 1.5 × 11 / 115200 ≈ 0.143ms常见波特率下的T35大致如下波特率字符时间T3548002.29ms8.02ms96001.15ms4.01ms192000.57ms2.01ms384000.29ms1.00ms1152000.10ms0.33ms为什么这个计算这么重要因为如果T35设得太短一帧数据还没收完就可能被误判为“超时”导致后续字节拼进下一帧CRC必然错如果设得太长通信响应会变慢尤其在轮询扫多个从站时延迟叠加得厉害。基于这个原因时间要算准留适当余量但不要成倍放大。2.2 定时器初始化代码HAL库版本FreeModbus要求porttimer.c实现这三个接口void vMBPortTimerInit(void); void vMBPortTimersEnable(void); void vMBPortTimersDisable(void);其中vMBPortTimerInit做定时器硬件初始化vMBPortTimersEnable启动超时计时vMBPortTimersDisable停止并关闭定时器中断。我用的是STM32F103的TIM2把定时器时钟配置为1MHz也就是1个计数单位等于1微秒。这样T35的值直接用微秒数填入自动重装载寄存器即可代码直观、好维护。#include porttimer.h #include mb.h #include mbport.h static TIM_HandleTypeDef htim2; static uint16_t usTimerT35; // T35超时重装值 static uint16_t usTimerT15; // T15超时重装值 void vMBPortTimerInit(void) { __HAL_RCC_TIM2_CLK_ENABLE(); htim2.Instance TIM2; htim2.Init.Prescaler 72 - 1; // 72MHz / 72 1MHz htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 0xFFFF; htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(htim2); HAL_NVIC_SetPriority(TIM2_IRQn, 2, 0); HAL_NVIC_EnableIRQ(TIM2_IRQn); }这里需要注意的是定时器中断优先级要设置得比串口接收中断优先级高数值上更小。原因后面在排查章节会细说这里先记住结论。vMBPortTimersEnable和vMBPortTimersDisable的实现要根据“单次超时”的思路来做而不是让定时器一直自由运行。每次收到字节重新填装载值并清零计数器void vMBPortTimersEnable(void) { __HAL_TIM_SET_COUNTER(htim2, 0); __HAL_TIM_SET_AUTORELOAD(htim2, usTimerT35); __HAL_TIM_CLEAR_FLAG(htim2, TIM_FLAG_UPDATE); HAL_TIM_Base_Start_IT(htim2); } void vMBPortTimersDisable(void) { HAL_TIM_Base_Stop_IT(htim2); }然后写一个公开函数给串口接收中断调用每收到一个字节就把定时器重置为T35void prvvMBPortTimersResetT35(void) { __HAL_TIM_SET_COUNTER(htim2, 0); __HAL_TIM_SET_AUTORELOAD(htim2, usTimerT35); __HAL_TIM_CLEAR_FLAG(htim2, TIM_FLAG_UPDATE); }定时器溢出时调用pxMBPortCBTimerExpired通知协议栈帧超时void TIM2_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(htim2, TIM_FLAG_UPDATE) ! RESET) { __HAL_TIM_CLEAR_IT(htim2, TIM_IT_UPDATE); HAL_TIM_Base_Stop_IT(htim2); (void)pxMBPortCBTimerExpired(); } }2.3 初始化重装值怎么填在前面的初始化函数里需要根据当前波特率算出T35和T15并填入usTimerT35和usTimerT15void vMBPortTimerInitBaud(uint32_t ulBaudRate) { // 计数频率1MHz所以时间us数 重装值 usTimerT35 (uint16_t)(3500000UL / (ulBaudRate / 11UL)); // 近似 usTimerT15 (uint16_t)(1500000UL / (ulBaudRate / 11UL)); }更直观的写法是usTimerT35 (uint16_t)((uint32_t)3500000UL / (ulBaudRate / 11UL));以9600为例ulBaudRate/11约等于8723500000/872 ≈ 4013那就是4013微秒以115200为例115200/11 ≈ 104723500000/10472 ≈ 334也就是334微秒。和前面表格基本一致。说明一下这个计算方式没有把中断处理和串口收发指令本身的耗时算进去但实测下来足够稳定。如果你发现通信偶尔分帧可以在此基础上加5%~10%的余量但不能加太多否则可能把两个相邻请求误并成同一个帧。2.4 关于T15有没有必要单独实现FreeModbus官方demo在porttimer.c里会同时维护T35和T15具体做法是用一个定时器配合软件计数实现两种超时帧空闲超时走T35帧内字节间隔超时走T15。如果T15超时意味着两个字节间隔过长协议栈会把当前帧标记为错误帧并丢弃。但我实际移植时的建议是第一阶段先只实现T35正常通信一点问题没有。原因很简单串口逐字节接收时每收到一个字节都会重置一次T35只要这个字节间隔不超过T35帧就不会被误判为结束所以常规通信根本用不到T15判断。T15主要是用来识别“帧内字节拖得过长”这种异常情况属于锦上添花的功能。如果一开始就去做T15新手很容易在时序计算和状态切换上绕晕。建议先跑通T35版本确认整个链路没毛病再回头补T15也不迟。3. 移植串口字节收发与RS485方向控制定时器解决的问题是“什么时候认为一帧收完了”而串口要解决的是“字节怎么进出”。FreeModbus对串口移植的要求同样是三个接口初始化、发送单字节、接收单字节。但在RS485半双工总线下还有一个“方向控制”问题这往往是被忽略的坑。3.1 串口初始化与中断我使用的STM32串口是USART2通过CubeMX生成初始化代码主要参数波特率9600、8位数据位、无校验、1位停止位。这些参数要和组态软件/Modbus Poll主站保持完全一致一个字符位不匹配都会导致解析失败。在portserial.c里需要实现void vMBPortSerialInit(void); void vMBPortSerialClose(void); BOOL xMBPortSerialPutByte(CHAR ucByte); BOOL xMBPortSerialGetByte(CHAR *pucByte);初始化函数里用HAL库直接配置好的huart2即可。但在接收方向我们不采用HAL的HAL_UART_Receive_IT那种带缓冲区的方式而是直接在串口中断里判断RXNE标志收到一个字节就调用一次协议栈回调。这样最贴合FreeModbus的逐字节驱动模型也不容易和协议栈内部的接收缓冲区打架。串口中断处理如下void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart2, UART_FLAG_RXNE) ! RESET) { uint8_t ucByte (uint8_t)(huart2.Instance-DR 0xFF); pxMBFrameCBByteReceived(); // 通知协议栈已收到一个字节 } }这里有一个细节pxMBFrameCBByteReceived函数内部会调用portserial.c里提供的字节读取接口从串口DR寄存器取走数据。所以串口中断里只需要清标志和触发通知协议栈会在自己的上下文里把字节读走。3.2 发送字节和RS485方向控制RS485是半双工总线同一时刻只能有一方占用。所以发送字节之前必须先把收发器芯片比如MAX485或SP3485的DE引脚拉高让驱动器接管总线发送完成后再拉低DE把总线让给接收方向。这里最容易犯的错误是发送完数据马上拉低DE而最后几个字节可能还在移位寄存器里没有完全发出去结果就是主站收到一个残缺帧。解决办法是等待发送完成标志TC置位后再关DE。我用的是USART的TC标志来确保最后一字节真正从引脚上出去了BOOL xMBPortSerialPutByte(CHAR ucByte) { // 发送前拉高DE进入发送模式 HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_SET); // 发送一个字节 huart2.Instance-DR (uint8_t)ucByte; // 等待发送完成 while (__HAL_UART_GET_FLAG(huart2, UART_FLAG_TC) RESET) { } return TRUE; }但这样还不行。如果每个字节都这样拉高拉低一帧数据中间会释放总线别人就能占用这是不允许的。更好的做法是把方向控制从“每字节”粒度改成“每帧”粒度在FreeModbus开始发送一帧前拉高DE整帧发送完成后拉低DE。FreeModbus提供了xMBPortSerialPutByte逐字节调用的机制所以一个简单可靠的办法是在xMBPortSerialPutByte里确保DE是拉高的然后由协议栈在帧发送完成后调用vMBPortSerialClose或者其他钩子再拉低具体做法可以自己封装一个发送完成回调。我实现的思路是BOOL xMBPortSerialPutByte(CHAR ucByte) { HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_SET); huart2.Instance-DR (uint8_t)ucByte; while (__HAL_UART_GET_FLAG(huart2, UART_FLAG_TC) RESET) { } HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_RESET); return TRUE; }这个写法在帧内每个字节发送间隙都会短暂拉低DE但实测下来只要两个字节之间的间隔远小于T15主站和从站都不会把帧拆开。不过更稳妥的做法是在协议栈的前后处理中加一个自定义函数在整帧开始前拉高DE整帧结束后拉低DE中间xMBPortSerialPutByte里不再动DE。这样时序最干净。3.3 串口对RS232和RS485的区别如果你的设备是RS232通信不需要方向控制xMBPortSerialPutByte里直接写DR、等TC标志即可。RS485则一定要处理DE/RE方向引脚。另外有些现成的RS485模块比如带自动收发控制的模块会在硬件层自动切换方向这种情况下软件不用控制DE直接收发即可。但工业现场很多RS485电路是独立的收发器芯片DE引脚必须由MCU控制所以移植前先确认硬件设计再决定要不要写方向控制逻辑。4. 注册表回调把用户数据“挂”到Modbus地址上协议栈跑起来之后主站发的读保持寄存器请求到达从站协议栈做了帧解析但具体要读哪里的数据它是不知道的。它只知道“地址0x0001、数量2”这样的请求至于这个地址对应到什么变量由应用层通过回调函数来提供。4.1 四大回调函数各自管什么FreeModbus 1.6里需要用户实现的回调函数有四个回调函数对应的Modbus地址空间常见功能码eMBRegHoldingCB保持寄存器0x03读、0x06写单个、0x10写多个eMBRegInputCB输入寄存器0x04读eMBRegCoilsCB线圈0x01读、0x05写单个、0x0F写多个eMBRegDiscreteCB离散输入0x02读每个回调的原型都是eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode);usAddress是Modbus地址从1开始usNRegs是寄存器个数eMode表示本次操作是读还是写pucRegBuffer是数据缓冲区。读操作时把寄存器数据填入pucRegBuffer写操作时从pucRegBuffer取出数据更新寄存器变量。4.2 一个完整的保持寄存器回调示例我建议用结构体或者静态数组来管理寄存器数据这样回调里逻辑最简单。比如static uint16_t usRegHoldingBuf[64]; // 保持寄存器64个字 eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { eMBErrorCode eStatus MB_ENOERR; uint16_t usRegIndex; int16_t iRegIndex; // Modbus地址从1开始数组下标从0开始 iRegIndex (int16_t)usAddress - 1; if ((iRegIndex 0) || (iRegIndex usNRegs 64)) { return MB_ENOREG; } if ((eMode MB_REG_WRITE) || (eMode MB_REG_READ)) { for (usRegIndex 0; usRegIndex usNRegs; usRegIndex) { if (eMode MB_REG_WRITE) { // 高字节在前大端模式 usRegHoldingBuf[iRegIndex] (uint16_t)(*pucRegBuffer 8); usRegHoldingBuf[iRegIndex] | (uint16_t)(*pucRegBuffer); } else { *pucRegBuffer (uint8_t)(usRegHoldingBuf[iRegIndex] 8); *pucRegBuffer (uint8_t)(usRegHoldingBuf[iRegIndex] 0xFF); } iRegIndex; } } return eStatus; }这里有两个很容易踩的坑第一个是地址偏移。Modbus协议里寄存器地址从1开始编号而C语言数组从0开始所以一定要做usAddress - 1。好多人在回调里忘了这个偏移导致读到的数据永远差一个地址。第二个是大端序。Modbus寄存器数据在报文里是高字节在前大端所以写操作时先取到的字节要左移8位作为高字节读操作时也要先输出高字节再输出低字节。用*pucRegBuffer 8这种写法注意指针的推进顺序要匹配。4.3 输入寄存器、线圈和离散输入回调输入寄存器和保持寄存器结构几乎一样只是数据来源不同eMBErrorCode eMBRegInputCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { static uint16_t usRegInputBuf[16]; // 和保持寄存器类似只是从数组读取 // ... }线圈和离散输入是按位访问的每个线圈占1个bit所以回调里的地址和长度单位是“位”。写入时要把字节里的bit拆出来读取时要把bit拼成字节。Modbus协议中一个字节里线圈的排列是LSB在前即第一个线圈对应bit0。eMBErrorCode eMBRegCoilsCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNCoils, eMBRegisterMode eMode) { uint16_t usCoilIndex usAddress - 1; uint16_t usCoilCount usNCoils; if (usCoilIndex usNCoils 32) { return MB_ENOREG; } if (eMode MB_REG_WRITE) { for (uint16_t i 0; i usCoilCount; i) { if (usCoilBuf[usCoilIndex i]) { *pucRegBuffer | (1 (i % 8)); } else { *pucRegBuffer ~(1 (i % 8)); } if ((i % 8) 7) { pucRegBuffer; } } } // 读方向类似从buffer里取bit写入到usCoilBuf // ... }初次做移植时可以先只实现保持寄存器回调把03、06、10这几个功能码跑通再去扩展其他回调。Modbus调试工具里最常用的就是读保持寄存器先把这条路打通后续全是复制修改。5. 初始化、主循环与FreeRTOS环境适配定时器、串口、回调函数都准备好了剩下就是把协议栈“启动”起来并在主循环里不停调用查询函数。5.1 启动协议栈的固定三步第一步用eMBInit初始化协议栈指定工作模式和参数eMBErrorCode eStatus; eStatus eMBInit(MB_RTU, // RTU模式 0x01, // 从站地址1 0, // 串口编号裸机时通常填0 9600, // 波特率 MB_PAR_NONE); // 无校验从站地址取值范围是1~2470地址是广播地址不能作为普通从站地址。有些主站会把从站地址配置成0做广播测试但从站程序返回的响应会被所有设备忽略看起来就像没通信成功这一点要注意。第二步调用eMBEnable使能协议栈if (eStatus MB_ENOERR) { eMBEnable(); }第三步在主循环里持续调用eMBPoll协议栈在这里处理接收到的帧、调用对应的回调函数、组织响应报文while (1) { eMBPoll(); }注意eMBPoll不能放在中断里执行它内部会运行一整套帧处理流程耗时可能几百微秒甚至更长在中断里跑会干扰串口接收和定时器计时。5.2 中断回调函数里做了什么事情这部分很多教程讲得含糊我用自己的话整理一遍。FreeModbus对底层中断的处理方式是串口每收到一个字节调用pxMBFrameCBByteReceived定时器超时调用pxMBPortCBTimerExpired。这些回调函数由协议栈内部实现它们不是用户直接调用的。对于裸机环境这些回调内部会设置一些事件标志然后eMBPoll在主循环里检测到事件后处理数据。也就是说中断里收字节、计超时eMBPoll里处理协议二者通过事件机制衔接这个设计让移植者可以安心地在中断里做“短小”的收字节操作而不必担心协议处理阻塞中断。5.3 移植到FreeRTOS环境要注意什么如果你要把FreeModbus跑在FreeRTOS任务里一般做法是创建一个独立任务循环调用eMBPoll。但要注意几点一是临界区。FreeModbus内部用vMBPortEnterCritical和vMBPortExitCritical来保护共享数据。裸机时可以直接用__disable_irq和__enable_irq但FreeRTOS环境下最好改成taskENTER_CRITICAL和taskEXIT_CRITICAL或者用中断保护级别更高的临界区实现避免任务切换时破坏协议栈内部状态。二是任务优先级。Modbus通信对实时性有一定要求建议把协议任务设为较高优先级但不高于中断优先级。如果任务优先级太低在高负载的系统中可能出现响应超时。三是串口和定时器中断仍然正常工作。中断回调里的字节接收和超时置位都发生在中断上下文eMBPoll在任务上下文运行两者通过协议栈内部的事件变量衔接。要注意事件变量是跨中断和任务访问的所以协议栈内部本身有临界区保护逻辑我们只需要确保临界区实现是正确的。5.4 初始化代码放到哪里最合适一般是放在外设初始化之后、创建任务或进入主循环之前。如果系统里还有Flash读写、外部存储初始化、网络协议栈初始化等耗时操作尽量放在eMBEnable之前完成。因为一旦eMBEnable之后主站发请求从站应该能在几十毫秒内响应如果你还在初始化其他外设很容易把响应时间拖垮。我实际项目中比较推崇的做法是先初始化串口和定时器再初始化业务变量最后eMBInit和eMBEnable然后进入主循环。业务变量必须在eMBEnable之前准备好初始值否则主站一上来读到的是一堆随机值。6. 上机联调与常见问题排查移植代码写完了编译也通过了但离“跑通”还有一段路。联调阶段是暴露问题最多的阶段我把自己的调试流程和踩坑记录整理出来按从现象到解决方案的方式写。6.1 硬件接线和调试工具准备我用到的硬件很简单STM32F103C8T6核心板板载USART1或者外接USART2均可我这里是USART2MAX485模块A、B接USB转485适配器的A、BUSB转485适配器连电脑接线时特别注意RS485的A接A、B接B很多朋友A/B接反了还去找软件问题结果调了半天发现是线序问题。如果A和B接反表现为完全收不到正确数据或者收到但CRC错误。另外RS485总线两端最好各接一个120Ω终端电阻尤其是在通信距离长、波特率高的场景。短距离桌面调试可以暂时不接但如果出现波形反射导致偶尔通信失败优先检查终端电阻。USB转485适配器到电脑之间有没有共地问题也比较常见。RS485虽然是差分信号理论上可以不共地但实际应用中如果两端地电位差太大依然会造成通信异常。调试时尽量让两块板子的GND连在一起能省去很多麻烦。6.2 用Modbus Poll做主站验证Modbus Poll是经典的Modbus主站调试工具打开后配置串口参数串口号选择实际连接的USB转485适配器波特率9600数据位8校验位None停止位1从站地址1功能码03读取保持寄存器然后读取从站地址1、起始地址0、长度2。如果一切正常页面里会显示出两个寄存器的数值。这里我先做了一个最简单的验证在初始化时把usRegHoldingBuf[0]写死为0xA5A5这样如果Modbus Poll能读回A5A5说明从站地址匹配、帧解析、CRC校验、寄存器回调全部正常。同时用Modbus Poll的“Write/Write Multiple”功能写入数据比如把地址0写成0x1234再读回来看是否一致这样就能验证写寄存器回调是否工作。如果写入后读回成功说明保持寄存器的读写回路已经完整。再往后可以把usRegHoldingBuf替换成业务系统的真实变量比如温度、电压、状态字等整个设备就具备了Modbus从站能力。6.3 常见问题速查表联调过程中最容易遇到下面这些问题我按现象整理成一张速查表现象可能原因解决方法Modbus Poll一直超时无任何响应从站地址不匹配检查eMBInit里的从站地址是否和主站一致无响应但波形看起来有数据RS485方向控制错误检查DE引脚是否在发送前拉高、发送完成后拉低无响应串口助手能看到从站发了数据A/B线接反对调A/B线偶发性超时重试后可能成功T35定时器时间不对核对波特率和定时器重装值重新计算T35读取的数据全为0或全为FF寄存器数组未初始化在eMBEnable前给寄存器数组赋初值写寄存器后读回来的值不对地址偏移错误回调函数里检查usAddress-1是否遗漏能读能写但延迟偏大T35余量过大核对T35计算不要留太多冗余通信时好时坏伴随CRC错误波特率误差偏大检查系统时钟配置确认串口波特率和主站一致帧被拆成两段协议栈无法解析T35设置过短适当增大T35或在每个字节接收时重置定时器还有一个非常隐蔽的问题串口接收中断和定时器中断的优先级冲突。FreeModbus的接收流程是串口收到字节后在中断里立刻重置T35。如果定时器中断优先级比串口低在定时器中断正在进入时串口来了新字节却不能及时重置计时T35可能误触发。我建议定时器中断优先级高于串口接收中断也就是定时器中断抢占优先级数字更小这样才能保证帧间超时判断的精度。6.4 关于Modbus Poll之外的调试手段如果没有Modbus Poll也可以用串口助手手动组帧测试。比如要读从站地址1的保持寄存器发送十六进制报文01 03 00 00 00 02 C4 0B从站正常会返回01 03 04 xx xx xx xx CRCL CRCH其中C4 0B是这一帧的CRC16。这种方式能帮你把问题层次分得更细如果收到的报文根本没响应大概率是地址或功能码问题如果有响应但CRC错那就是时序或串口参数问题。我自己调试时通常会先用串口助手跑一遍裸报文确认帧结构没问题再上Modbus Poll做连续轮询压力测试。这样可以把“协议栈逻辑问题”和“现场通信稳定性问题”分开排查效率高很多。7. 关于移植过程的一些个人体会这次把FreeModbus移植到STM32上整体走下来我的体会是协议栈本身不复杂难的是对“时间”概念的理解和对“半双工方向控制”的把握。T35这个帧间隔计时是RTU模式能够正确分帧的核心。掌握了它的计算方法和重置时机你基本就理解了FreeModbus底层工作的逻辑。而RS485的DE方向控制如果硬件电路有自动收发功能就省心没有的话一定要在每帧发送前拉高、发送完成后拉低这个时序不能省。另外我建议准备一套固定的调试“标准动作”先写死一个寄存器值用Modbus Poll读回来再写一个值读回来验证确认读写都通之后再去接业务逻辑。不要一开始就把几十个寄存器全挂上去调那样出了问题根本不知道是哪一层。先打通最小链路后面都是体力活。最后分享一个小技巧如果现场通信偶尔失败又查不到明显原因可以先降低波特率比如从115200降到9600再试。很多时序边界问题在低波特率下会自然消失。这既是排查手段也是应急方案。等系统稳定了再逐步把波特率提上去观察在哪个点开始出问题往往就能定位到定时器余量或者线路质量上了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑