
这套《嵌入式调试笔记》写到第7篇终于到了MODBUS。起因是前两周去现场处理一个“上位机不定时报通信故障”的案子设备端用STM32F103做主控通过RS485接了一块控制屏协议就是MODBUS RTU。我一开始怀疑是屏端配置问题示波器挂上去才发现物理层收发切换时序不干净一帧报文的尾巴被截断了从机CRC算出来不对自然不回包。这种经历在嵌入式现场实在太常见了。MODBUS这协议诞生了几十年至今仍是工业自动化、能源、楼宇、仪器仪表里覆盖率最高的通讯协议之一。凡是带RS485、RS232、网口的上位机系统基本都绕不开它。这篇笔记把协议本身、物理层注意事项、STM32上的移植过程、调试工具用法以及我踩过的三个坑串起来讲一遍适合嵌入式开发、设备调试以及刚接触工控通讯的工程师参考。1. MODBUS协议核心拆解帧结构、功能码与寄存器模型1.1 RTU帧不是简单的“头数据校验”很多新手看MODBUS RTU帧以为就是“地址 功能码 数据 CRC”思想没错但实际调起来帧和帧之间的时序比字段本身更容易出问题。RTU帧一次完整的报文结构是这样字段长度说明从站地址1字节0为广播地址1~247为有效从站地址功能码1字节指定操作类型数据N字节和功能码相关可以是寄存器地址、数量、写入数据CRC162字节低字节在前高字节在后帧的结束不是靠长度字段而是靠“静默时间”报文内相邻两个字节的间隔不能超过1.5个字符时间从最后一个字节结束开始超过3.5个字符时间没有新字节接收端就认为这一帧结束了。这个规定是MODBUS RTU最容易忽略的地方。为什么有这个规定因为RS485是半双工总线没有专门的“帧开始/帧结束”信号只能靠时间间隔来切分报文。一帧数据如果没有在规定的字符时间内连续发出来从机可能把一帧拆成两帧或者把两帧合成一帧CRC校验会直接失败。字符时间的计算公式1个字符在8N1格式下等于1起始位 8数据位 1停止位 10位如果是带校验的8E1就是11位。以9600波特率、8N1为例1个字符时间约1.042ms3.5个字符时间约3.65ms实际调试中很多主机发完一帧会刻意等4ms再发下一帧。CRC16校验算法固定初值0xFFFF多项式0xA001每个字节先跟CRC低字节异或然后右移8次每次移位前如果最低位是1就再异或0xA001。我在下面调试脚本里也会写这个函数调试时不用每次手工算。1.2 四种寄存器/线圈模型与地址映射MODBUS把从站内部数据分成四张表名称容易混但记住读写权限就清楚了数据对象协议地址范围PLC习惯地址位宽读写属性线圈Coil0x0000~0xFFFF000001~0655361位可读可写离散输入Discrete Input0x0000~0xFFFF100001~1655361位只读输入寄存器Input Register0x0000~0xFFFF300001~36553616位只读保持寄存器Holding Register0x0000~0xFFFF400001~46553616位可读可写最常用的是保持寄存器。实际项目里设备参数、运行状态、控制指令基本都是放保持寄存器里的因为又读又写一个表全搞定了。这里有个坑上位机组态软件里显示“40001”对应协议地址是0x0000“40010”对应协议地址0x0009。也就是说上位机把4开头的编号当成从1开始的“人话地址”而协议帧里用的是从0开始的偏移。你在固件里处理回调时直接用协议地址0号开始就行千万别看到40001就减40001再减1不同厂商的实现并不统一最稳妥的做法是以协议帧里的地址为准。1.3 常用功能码和异常响应功能码不多项目里翻来覆去就这几个功能码名称用途0x01读线圈读取从站线圈状态0x02读离散输入读取离散输入0x03读保持寄存器最常用读取保持寄存器0x04读输入寄存器读取输入寄存器0x05写单个线圈控制单个DO0x06写单个保持寄存器写单个寄存器0x0F写多个线圈批量写线圈0x10写多个保持寄存器批量写寄存器举个例子读从站1、起始寄存器地址0、长度10个保持寄存器的请求是01 03 00 00 00 0A C5 CD如果从站正常响应会返回“01 03 14 [20字节数据] CRC”。其中0x14是十六进制20表示后面跟20个数据字节。寄存器数据都是高字节在前这是MODBUS的固定规矩。当请求有问题时从站返回的不是正常响应而是异常码功能码最高位置1比如0x03变成0x83后面跟一个异常码。常见异常码0x01非法功能从站不支持这个功能码0x02非法数据地址寄存器地址超范围0x03非法数据值写入的值不合法0x04从站设备故障调试时看到异常响应先不要怀疑CRC先对一下地址和寄存器数量是否超出固件定义。MODBUS TCP和RTU的区别也要提一句TCP的MBAP报文头是“事务标识2字节 协议标识2字节 长度2字节 单元标识1字节”后面才是功能码和数据并且没有CRC。RTU封装成TCP后相当于把从站地址换成了单元标识省掉了CRC。如果你在一个Linux网关里同时跑RTU和TCP要做的是映射从站地址和单元标识的关系剩下的协议逻辑完全一样。2. 物理层为何是MODBUS调试验收的第一关RS232/RS485与收发切换2.1 TTL、RS232、RS485现场到底用哪个协议层再标准物理层不对也白搭。MODBUS可以跑在不同物理接口上最常见的是RS232和RS485。RS232实现简单但只能点对点距离短电平摆幅大容易受干扰RS485是差分信号支持多点组网抗干扰强工业现场主力就是它。接口电平距离节点数工作模式TTL0~3.3V/5V板内cm级1对1全双工RS232±3~±15V15米左右1对1全双工RS485A-B差分1200米最多32个标准负载半双工STM32这类MCU出来是TTL电平要接RS485必须加收发器芯片最常用的是MAX485、SP3485、ISL83485。RS232一般用MAX3232芯片转换。我曾经遇到一个项目板子上RS232芯片的电荷泵电容焊错了容值导致输出电平不够上位机偶尔能收到偶尔收不到排查了半天。2.2 半双工收发切换最容易出bug的地方RS485是半双工收和发共用一对差分线方向切换由收发器的DE发送使能和RE接收使能引脚控制。很多硬件设计把DE和RE连在一起接一个GPIO或者UART的RTS脚接收模式拉低发送模式拉高。看起来简单但切换时机非常讲究。问题最常出在“发送完成”的判断上。有人用TXE发送数据寄存器空中断去拉低DE这是错的。TXE只表示数据从数据寄存器挪到了移位寄存器并不代表移位寄存器已经把所有位发送出去了。此时如果直接切到接收方向最后一个字节甚至最后几个位会被硬生生截断对端收到的是一个不完整的帧帧间隔也被破坏CRC大概率不过。正确做法是用TC传输完成标志或者发送完成后加一个小的延时再拉低DE。在FreeMODBUS这一类协议栈里端口层会提供类似vMBPortSerialEnable的接口由协议栈在合适的时候切换收发方向你只需在硬件抽象层里把它映射到GPIO即可。2.3 终端电阻、屏蔽层与地线RS485总线两端需要各接一个120欧姆终端电阻。为什么是120欧姆因为特性阻抗为120欧姆的标准双绞线终端匹配才能减少信号反射。一两个节点、短线缆也许不接也能跑但线长超过几十米或节点变多后反射导致的数据错乱就会冒出来。另外两个高频坑一是屏蔽层要单端接地不要两端都接地避免形成地环路二是总线拓扑尽量手拉手避免星型或T型分叉分支太长会产生驻波波形畸变严重时就是“时好时坏”的诡异故障。最后一个提醒A、B线接反了两台设备都接反通信也可能“正常”但这是一种靠互相妥协维持的假正常万一其中一台换掉了故障立刻暴露。所以接线时最好统一标准用示波器或者万用表确认A、B对应关系。3. STM32F103上FreeMODBUS V1.6移植手记从配置到跑通3.1 为什么选FreeMODBUS而不是自己撸帧解析很多初学者拿到MODBUS第一反应是自己写解析其实协议解析本身不难但要把帧间隔、异常响应、广播处理、多寄存器读写这些边界情况全考虑进去工作量不小。FreeMODBUS是开源协议栈支持RTU、ASCII、TCP代码结构清晰专门有port目录做平台适配让它跑在STM32F103上并不复杂。FreeMODBUS目录结构里mb目录是协议核心demo目录是各种平台示例port目录是你需要自己写的适配层核心文件就三个port.h、portserial.c、porttimer.c。串口层负责收发字节定时器层负责3.5字符超时判断poll函数负责协议栈状态机轮询。3.2 串口和定时器移植的关键代码先初始化串口和定时器。我这里用标准外设库v3.5举例使用USART1和TIM2波特率96008数据位、无校验、1停止位。void USART1_IRQHandler(void) { // 接收中断 if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t ucByte USART_ReceiveData(USART1); pxMBFrameCBByteReceived(ucByte); // 交给协议栈处理 } // 发送完成中断 if (USART_GetITStatus(USART1, USART_IT_TC) ! RESET) { USART_ClearITPendingBit(USART1, USART_IT_TC); pxMBFrameCBTransmitFinished(); // 通知协议栈发送完成 } }定时器中断里要调用vMBPortTimerExpired()协议栈用它来做1.5字符和3.5字符的超时判断。定时器溢出周期需要配成3.5字符时间以9600波特率8N1为例约4ms左右。void TIM2_IRQHandler(void) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); vMBPortTimerExpired(); }下面几行代码是初始化协议栈并启用它eMBInit(MB_RTU, 0x01, 0, 9600, MB_PAR_NONE); eMBEnable(); while (1) { eMBPoll(); // 周期调用协议栈状态机 }eMBInit的第一个参数是模式第二参数是从站地址我用0x01。第三参数在这里填0即可因为FreeMODBUS把串口编号当作“端口号”实际串口硬件映射在portserial.c里。最后一个参数是校验位MB_PAR_NONE对应无校验也可以填MB_PAR_EVEN用偶校验。3.3 寄存器回调函数把协议和数据表接起来协议栈本身不关心你的寄存器存在哪它只提供回调。你需要实现eMBRegHoldingCB、eMBRegInputCB、eMBRegCoilsCB、eMBRegDiscreteCB四个函数。最常用的是保持寄存器回调。eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { // usAddress 是协议地址注意从0开始 for (USHORT i 0; i usNRegs; i) { if (eMode MB_REG_READ) { // 读操作把寄存器的值按高字节在前填入缓冲区 USHORT usValue usHoldingReg[usAddress i]; pucRegBuffer[i * 2] usValue 8; pucRegBuffer[i * 2 1] usValue 0xFF; } else if (eMode MB_REG_WRITE) { // 写操作从缓冲区取出数据还原成16位整数 usHoldingReg[usAddress i] (pucRegBuffer[i * 2] 8) | pucRegBuffer[i * 2 1]; } } return MB_ENOERR; }注意usAddress是协议地址从0开始。如果你在组态软件里看到40001对应到这里就是usAddress0。不要试图把40001直接传进来否则数组越界。3.4 跑通后的第一轮验证移植完成后先用ModbusPoll这个主站模拟工具连接串口协议选择RTU从站地址填1功能码选03读保持寄存器起始地址填0数量填希望读取的寄存器个数。如果能读到预置的初值协议栈基本就通了。我习惯先用串口调试助手发一帧最原始的报文验证物理链路比如计算好CRC的请求01 03 00 00 00 01 xx xx看从站回不回01 03 02 [数据] CRC。如果连自己发的固定帧都回不对说明问题在物理层或中断不进协议栈的锅。4. 调试工具组合串口助手、ModbusPoll、ModbusSlave怎么分工4.1 串口调试助手看原始字节与链路通断串口调试助手是嵌入式调试的基本盘但它在MODBUS调试中只承担“物理链路验证”的角色。它不做协议解析也不懂帧间隔它能做的是帮你看原始十六进制字节流、发送自定义报文、带上时间戳检查收发顺序。我经常这样用先用串口助手发一帧已知CRC的报文给从站看从站是否回包、回包内容是否符合预期。这能把“链路通不通”和“协议栈对不对”快速分开。如果串口助手发过去没反应而ModbusPoll也没反应那多半不是协议栈问题而是物理层故障这时候直接上示波器。调试助手里有个非常实用的功能是定时发送可以用来做压力测试比如每隔100ms发一帧读请求观察从站是否稳定回包。这比手工点发送更能暴露帧间隔和中断冲突问题。4.2 ModbusPoll主站模拟与压力测试ModbusPoll是调试从站最好用的工具。它把MODBUS帧封装成图形界面你可以选功能码、填寄存器地址和数量它会周期性轮询以表格形式显示寄存器值变化。配置流程通常是这样Connection选择串口设置COM口、波特率、校验位、停止位Setup里的Read/Write Definition选择功能码填从站地址和寄存器地址设置扫描周期把十六进制显示打开同时看解码值和原始值真正有价值的是两件事一是它可以模拟“一个不按常理出牌的主站”比如一次性读大量寄存器或者写入超范围的寄存器用来测试从站固件的边界处理二是它的错误统计能直观反映超时率和异常帧数量这在判断“时好时坏”的故障时很有用。4.3 ModbusSlave从站模拟器ModbusSlave的作用正好反过来它模拟一个MODBUS从站帮你调试上位机程序或主站逻辑。举个例子你写了一个上位机采集程序但真实设备还没到位就可以用ModbusSlave创建一组寄存器填入模拟数据然后让上位机连接你电脑的串口或网络端口完整走一遍通信逻辑。这比拿着真设备反复测试高效得多还能故意设置异常状态测试上位机的异常处理能力。在调试FreeMODBUS从站固件时我的做法是三步走用ModbusPoll当我固件的主站验证从站能正常响应用ModbusSlave当“标准从站”让我自己的上位机脚本或屏端程序去读它验证上位机的解析逻辑最后让两边同时跑人为制造串口冲突看哪边先扛不住4.4 自己写一个小脚本当备用主站GUI工具有个缺点不方便自动化回归。我在现场经常临时写一个Python脚本用pyserial发MODBUS RTU请求把响应打出来既能当主站验证又能批量跑不同寄存器地址。import serial, time def crc16_modbus(data): crc 0xFFFF for b in data: crc ^ b for _ in range(8): if crc 1: crc (crc 1) ^ 0xA001 else: crc 1 return crc ser serial.Serial(/dev/ttyUSB0, 9600, timeout0.2) req bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x01]) crc crc16_modbus(req) req bytes([crc 0xFF, crc 8]) ser.write(req) resp ser.read(64) print(req:, req.hex().upper()) print(resp:, resp.hex().upper()) ser.close()这个脚本的价值在于能扫码不同地址、延时、重试次数还能把完整报文记录到日志文件里。现场排查时这种自定义脚本往往比商用工具更灵活因为没有界面限制想怎么改怎么改。5. 三个真实调试案例的完整排查链路5.1 案例一一帧报文被“腰斩”从站就是不回应现象ModbusPoll连接后大部分时间超时偶尔能读到一次数据把设备侧收到数据的日志打印出来发现帧长度总是少一字节或者CRC错。排查链路先看串口助手打印的原始帧发现从站收到的帧里最后一个字节经常缺失。于是挂示波器看RS485的A、B差分波形发现主机发送端在最后一个字节还没完全发完时电平就已经被切回接收状态了。检查代码发现DE引脚是在发送中断TXE里被拉低的。TXE标志位只表示UART的数据寄存器已经把数据交给了移位寄存器但移位寄存器此时还在逐位移出数据。DE一旦拉低收发器立刻转为接收移位寄存器剩余的几个位被切断帧尾巴就没了。解决改成在TC传输完成中断里拉低DE或者发完最后一字节后加一个与1字符时间相当的延时再拉低。修改后波形完整从站响应恢复正常。这个案例在RS485调试里非常典型。半双工方向切换的时机永远是第一个要怀疑的点。5.2 案例二CRC正确、波形正常、从站就是不回包现象用ModbusPoll轮询从站上位机收到的永远是超时但把报文抓出来看地址、功能码、CRC全都正确示波器波形也很干净没有任何毛刺。排查链路先是怀疑从站的中断没进去但用调试器看接收中断确实触发了字节也确实进到了协议栈。继续单步排查发现协议栈初始化时从站地址被设成了0x00。查MODBUS规范才知道地址0是广播地址从站要处理广播帧但不需要回任何响应。所以主机发给地址0的请求从站永远“收到但沉默”。解决把从站地址改成1~247之间的值重启后立刻正常。附带还发现了一个隐藏问题请求的寄存器数量超过从站固件定义的寄存器范围从站会回0x83异常响应而不是正常响应这个也要在固件里提前规划好不能随便给上位机报“非法数据地址”之外的信息。5.3 案例三手一摸线就正常一松开就乱码现象现场工控机和设备通信时好时坏用手压住接头附近线缆就正常手一松就出现乱码、错帧和超时。排查链路一开始以为接头松动重新压了端子问题依旧。用示波器抓总线波形发现数据帧末尾有明显振铃且总线空闲电平不稳定。查下来发现总线上没有终端电阻线缆路径还贴着变频器的动力电缆走了一段。终端电阻缺失导致信号反射动力电缆耦合过来的噪声进一步干扰了差分电平于是表现为“时好时坏”且和环境、触摸位置都相关。解决在总线两端各加一个120欧姆终端电阻通信线和动力线分离布线屏蔽层单端接地故障消失。这个案例给了一个教训RS485“看起来通了”不等于“设计对了”。现场稳定性和波形裕量才是调试验收的重头。6. 参数速查与我习惯的排查顺序6.1 RTU常用参数速查参数常用值说明波特率9600 / 19200 / 115200主从必须一致数据位8MODBUS RTU固定校验位无校验或偶校验主从必须一致停止位1或2注意停止位影响字符时间从站地址1~2470是广播地址不响应CRC16初值0xFFFF多项式0xA001低字节在前9600波特率8N1下3.5个字符间隔约4ms19200约2ms115200约0.33ms。这个时间对定时器设计很重要配大了帧间隔不合法配小了会把长帧切断。6.2 我自己的排查顺序和习惯遇到MODBUS通信问题我基本按这个顺序来先看物理层接线、A/B顺序、终端电阻、地线、屏蔽用串口助手发自定义帧确认链路能通抓原始报文日志看地址、功能码、数据、CRC用ModbusPoll从站模拟验证固件用ModbusSlave主站模拟验证上位机还不行就上示波器看波形能不能撑住最后一个我自己的偏执习惯任何MODBUS调试串日志我都会打印十六进制原始帧同时把解码后的关键字段也打出来。比如收到01 03 02 00 64 XX XX日志里既要有原始hex也要有“保持寄存器00x0064100”这样的解析结果。宁可日志长一些也不要在现场靠猜。这个习惯帮我节省过太多回头路了。