最近在调一款485接口的温湿度变送器,串口助手把01 03 00 00 00 02 C4 0B发出去了,示波器上也能看到总线电平在跳,可传感器就是一言不发。折腾了一个下午,最后发现原因特别基础——不是波特率配错,也不是从机地址写错,而是我把CRC校验的两个字节发送顺序搞反了。
说实话,MODBUS协议本身并不复杂,帧格式寥寥几行就能写完。但真正到了调试现场,从寄存器地址偏不偏移、字节序怎么排、CRC高低字节谁先发、到RS485方向切换的时机,任何一个细节都能让你白耗半天。这篇笔记我打算把这几年跟MODBUS死磕的经验完整梳理一遍,从帧格式、寄存器映射、CRC手算、串口助手实战到物理层时序问题,全流程走一遍。如果你正被某个MODBUS从机折腾得怀疑人生,或者刚接触嵌入式准备入坑工业通信,这篇笔记应该能让你少走不少弯路。
1. 一次现场排查:从机不回包,问题出在哪
先说那次现场经历。设备是一块带RS485接口的温湿度变送器,手册上写得很清楚:默认地址01,波特率9600,8数据位无校验1停止位,功能码03读保持寄存器,温湿度各占一个16位寄存器。按照手册给的示例报文01 03 00 00 00 02 C4 0B发出去,正常应该回01 03 04 02 8B 01 F4 8B B6这样一串数据。
但实际现象是:从机完全没反应。
1.1 排查链路:从物理层往上层一层层剥
我当时的排查顺序是这样的,也是我觉得最有效的顺序:
- 第一步,确认TX/RX有没有接反。485是半双工两根线,A和B接反了是收不到的。我用万用表量了A-B之间的电压,静态时大概在2V左右,正常。
- 第二步,确认波特率。监听仪抓到的波形和9600bps对得上,排除。
- 第三步,确认报文内容。用逻辑分析仪抓了串口助手发出的数据,确实是
01 03 00 00 00 02 C4 0B,一个字节没差。 - 第四步,怀疑是RS485方向切换问题。用示波器看DE引脚的电平,发送期间和发送结束后的释放时机都正常。
- 第五步,回到报文本身,一个问题一个字段地核对,最后才发现CRC字节的顺序。
问题就在这里。CRC16计算出来的值,在MODBUS RTU协议里规定是低字节先发,高字节后发。我算出来的CRC值是0x0BC4,正确发送顺序应该是C4 0B。而我当时下意识按照习惯写成了高字节在前0B C4,从机收到的校验和跟内部计算对不上,直接就把这帧丢弃了。
提示:CRC校验失败时,从机最常见的处理方式就是直接丢弃报文,不发任何响应。所以你看到"发命令没反应"这种症状时,不要只盯着地址和波特率,CRC字节顺序也是高频翻车点。
1.2 为什么MODBUS值得花时间彻底搞懂
那次排查之后我就意识到,MODBUS协议虽然老,但在工业现场的地位短时间内不会被动摇。变频器、电表、传感器、PLC、温控器、网关,基本都带MODBUS接口。RS485总线上一挂几十个设备是常态,很多设备甚至只支持MODBUS RTU这一种通信方式。
对于嵌入式开发来说,MODBUS协议是个特别好的练手对象:帧结构明确,状态机好写,调试手段丰富,踩坑的复杂度又刚好够你积累经验。这个协议搞透了,后面再接触CAN、Profinet、EtherCAT这些,很多思路是相通的。
2. RTU帧的每个字节都得较真:地址、功能码、数据、CRC
MODBUS RTU的报文帧结构非常紧凑,一帧数据就是地址码、功能码、数据、CRC四段。咱们用01 03 00 00 00 02 C4 0B这帧来逐字节拆解。
| 字段 | 字节内容 | 长度 | 含义 |
|---|---|---|---|
| 从机地址 | 01 | 1字节 | 目标从机的地址,范围1~247 |
| 功能码 | 03 | 1字节 | 读保持寄存器 |
| 起始地址 | 00 00 | 2字节 | 从40001开始读(协议地址从0计) |
| 寄存器数量 | 00 02 | 2字节 | 读2个寄存器,共4字节数据 |
| CRC校验 | C4 0B | 2字节 | 对前面所有字节做CRC16-MODBUS计算,低字节在前 |
地址码这个字段看起来简单,实际有两层含义要想清楚。如果主站发的是00,这是广播地址,所有从机都会接收并执行命令,但这种模式下从机不会回复任何数据。而平时调试时如果地址配置错了,比如设备实际是02,你发01,从机同样会静默——地址不匹配就丢帧,这是协议的基本约定。
功能码定义了这次操作的类型。实际项目里最常用的是03读保持寄存器、04读输入寄存器、06写单个寄存器、10(十六进制0x10)写多个寄存器。还有一些设备支持01读线圈和05写单个线圈,不过那是针对开关量设备的,做传感器采集的项目里用得少一些。
数据段的内容取决于功能码。读操作时,数据段是起始地址加寄存器数量;写操作时,数据段除了寄存器地址和数量,还要带上要写入的字节数和具体数据。这些字段都是2字节一组,高字节在前低字节在后。
CRC段是让新手最容易栽跟头的部分。MODBUS RTU规定的CRC16不是普通的查表CRC,它的算法参数是固定的,后面我会单独用一整节来讲。
2.1 功能码背后的权限差异
03和04这两个功能码,在实际设备中对应的是两片不同的存储区。03读的是保持寄存器,这个区域是"可读可写"的,很多设备把可配置的参数、累计值、写进去的设定值都放在这里。04读的是输入寄存器,属于"只读"区域,传感器采集到的实时数据通常放这里。
但要注意,并不是所有设备都严格区分这两片区域。有的设备为了省事,把实时数据也放到保持寄存器里,手册里会说"采用Modbus Poll工具读取40001地址即温度值"。所以我拿到一个新设备,第一件事不是抄代码,而是先翻手册确认数据到底放在哪片区域,然后再决定用03还是04。
2.2 异常响应帧:从机说"不"的方式
命令发出去,从机有时候会回异常帧。常见的异常响应帧结构是:从机地址 + (功能码 | 0x80) + 异常码 + CRC。比如请求功能码03,如果寄存器地址超出范围,从机可能回01 83 02 C0 F1这样的帧。其中83就是0x03 | 0x80,02是异常码,表示非法数据地址。
我遇到过好几次,主站程序里没做异常码解析,一旦从机回异常帧,程序就把整帧当普通数据去解,结果解析出完全不合理的数值。所以无论你是裸机开发还是用现成协议栈,都一定要处理异常响应帧。异常码的含义就那么几个:01非法功能码、02非法数据地址、03非法数据值、04从站设备故障。
3. 存储区与寄存器模型:为什么协议地址总是对不上
很多做过MODBUS的都有过这种体验:手册上写着"温度寄存器地址40001",代码里却填了0x0000,读上来的数据又对不上号。这个问题的根源在于MODBUS有两套地址体系,一套是PLC传统的"数据区块地址",一套是协议报文里实际传输的"协议地址"。
3.1 四类数据区的经典编号和协议地址换算
传统MODBUS把数据分为四类存储区,分别对应不同的功能码:
| 数据区 | PLC编号范围 | 协议地址范围 | 对应功能码 | 读写属性 |
|---|---|---|---|---|
| 线圈 | 00001~09999 | 0x0000~0xFFFF | 01读、05写单、0F写多 | 可读可写 |
| 离散输入 | 10001~19999 | 0x0000~0xFFFF | 02读 | 只读 |
| 输入寄存器 | 30001~39999 | 0x0000~0xFFFF | 04读 | 只读 |
| 保持寄存器 | 40001~49999 | 0x0000~0xFFFF | 03读、06写单、10写多 | 可读可写 |
重点来了:协议报文里的地址是从0x0000开始的,而PLC传统编号是从1开始的。也就是说,手册上写的40001,对应协议地址0x0000;40002对应0x0001,依此类推。
如果你拿到的手册写的是"保持寄存器40001为温度值",你在代码里填起始地址时就应该填0x0000,而不是填40000或者40001。很多主站库(比如libmodbus)在调用接口时已经把数据类型和地址偏移帮你处理好了,比如MODBUS_REGISTER和实际地址直接对应。但如果你是自己拼报文,这个换算关系必须刻在脑子里。
3.2 使用Modbus Poll这类工具来做地址映射验证
我调试从机时,习惯先用Modbus Poll这样的上位机工具验证地址映射关系,而不是直接写代码。Modbus Poll里可以选功能码,填协议地址,设置数据格式,工具会把数据按你选的类型展示出来。这样你能快速确认"40001是否就是温度""数据是16位有符号还是无符号""小数的分辨率是多少"。
等上位机完全读通了,再写主站代码也不迟。用工具排除掉地址和数据类型的问题后,代码里出了bug,你就能很确定是代码本身的逻辑问题,而不是又在跟地址映射较劲。
3.3 寄存器数量上限的约束
一次读操作能读多少个寄存器是有限制的,不是你想读多少就读多少。MODBUS RTU的报文长度上限是256字节,减去地址、功能码、CRC这些固定开销,最大的响应数据字节数是252,折算下来大约125个寄存器。所以一次03功能码请求的寄存器数量不要超过125。
实际项目中如果要采集的数据很多,比如一个配电柜里有几十路电压电流,我一般会分段读,一次读64个寄存器,分几次读完。另外要注意,有些老式设备对一次读的数量限制得更严,比如最多读16个寄存器。遇到读多了就异常的情况,先看看功能码和数据量是不是超了设备的限制。
4. 手把手用串口助手完成一次完整报文交互
纸上谈兵没有意义,咱们直接用串口助手把一帧报文的完整交互走一遍。手头有USB转485模块和任意MODBUS从机设备(或者用模拟从机软件也行)就可以跟着做。
4.1 第一步:把串口参数对齐
打开串口助手,选好COM口,波特率选9600,数据位8,停止位1,校验位None。这三个参数必须跟从机的配置完全一致,任何一项不匹配都会导致通信失败。有的设备还有校验位 Odd 或 Even 的选项,如果默认配置读不出来,可以试试这两个。
这里有个细节值得多说一句:串口助手面板上显示的"9600,8,N,1"这个参数组合,是MODBUS RTU最常用的默认配置,但在实际项目里,也有人故意把波特率设成19200、38400甚至115200来满足传输速度需求。你需要在设备的规格书里查清楚具体默认值再连。
4.2 第二步:发送读请求帧
在串口助手的发送区输入01 03 00 00 00 02 C4 0B(十六进制格式发送)。我把这帧拆开再解释一遍,方便你对照理解:
01:访问地址为01的从机03:读保持寄存器00 00:从协议地址0x0000(即PLC地址40001)开始00 02:连续读取2个寄存器C4 0B:CRC16校验字节,低字节C4先发,高字节0B后发
注意发送时要勾选串口助手的"HEX发送"选项,否则软件会把这串十六进制字符当成ASCII码原样发送出去,那报文就全变形了。
4.3 第三步:观察并解析响应帧
如果一切正常,你应该能在接收区看到类似01 03 04 02 8B 01 F4 8B B6的帧。拆开解析:
01:从机地址03:功能码,与请求一致04:后续数据字节数,4个字节02 8B:第一个寄存器的原始值,0x028B换算为十进制是65101 F4:第二个寄存器的原始值,0x01F4换算为十进制是5008B B6:CRC校验
如果寄存器数据代表的是温度,分辨率是0.1,那么651对应的实际温度就是65.1度;500按0.1的分辨率就是50.0度。这个分辨率是你换算数据的关键,来自设备手册里对寄存器格式的定义。有的设备还会用有符号数表示负温度,比如-5度会存成FF FB,解析时就要按int16来算。
4.4 用计算器和在线工具核对CRC
当你手里有一帧报文,不确定CRC算得对不对,可以这样验证:打开Windows自带的计算器,切换到程序员模式,选HEX,按字节输入请求帧的每一个字节,然后观察CRC寄存器的计算结果是否和帧尾一致。当然,手工算效率太低了,我建议直接使用在线CRC计算工具,选CRC-16/MODBUS参数(多项式0x8005,初值0xFFFF,结果异或值0x0000),输入十六进制字节,对比生成的CRC值。
5. CRC16校验:手算一遍胜过看十遍文档
CRC校验是MODBUS RTU协议数据完整性的最后一道防线,也是新手最容易写错的地方。算错CRC的后果刚刚说过,从机会直接静默丢弃报文,而排查这个问题往往比想象中更耗时。
5.1 查表法和逐位法的实现思路
MODBUS RTU使用的CRC16算法参数是固定的,我用一张表说清楚:
| 参数 | 值 |
|---|---|
| 多项式 | 0x8005 |
| 初始值 | 0xFFFF |
| 输入反射(RefIn) | 是 |
| 输出反射(RefOut) | 是 |
| 结果异或值 | 0x0000 |
实际写代码时,很多人不会直接用0x8005多项式去移位计算,而是把多项式镜面反射成0xA001,然后用右移的方式算。这部分逻辑Loosely可以这样描述:CRC初值为0xFFFF,每个字节先和CRC低字节异或,然后右移8次,每次如果最低位为1就再异或0xA001。
网上流传的逐位计算代码很多,我写一个C语言版本方便你对照:
uint16_t crc16_modbus(const uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) { crc >>= 1; crc ^= 0xA001; } else { crc >>= 1; } } } return crc; }用这个函数对01 03 00 00 00 02这6个字节计算,得到的CRC是0x0BC4。发送时要把低字节0xC4放在前面,高字节0x0B放在后面,所以完整帧是01 03 00 00 00 02 C4 0B。
查表法是把256个可能的字节对应的CRC增量提前算好存进表格,计算时通过查表减少移位操作,适合对计算速度有要求的场景。对于MCU来说,如果通信频率不高(几十Hz的轮询),逐位法完全够用;如果要做高速数据记录,建议用查表法,省出来的CPU时间留给其他任务。
5.2 写CRC代码时最容易踩的三个坑
第一个坑是初始值写错。有些参考代码用的是其他CRC16变体的初始值0x0000,直接搬过来结果全错。MODBUS协议规定的初始值必须是0xFFFF。
第二个坑是字节顺序搞反。很多人在CRC计算完成后,直接按大端顺序发送,结果就是从机不响应。记住:MODBUS RTU规定CRC两个字节低字节在前,高字节在后。我那次现场调试栽的跟头就是这个。
第三个坑是漏算字节。有的主站程序在拼帧时,把CRC计算的范围搞错了,比如漏掉了功能码,或者把CRC本身也加了进去。CRC计算范围是所有在CRC之前发送的字节,也就是从地址码到数据段的最后一个字节。有人图省事把整帧发完再把回包整个算一遍,那叫校验数据,跟发送前的CRC生成是两码事。
6. 数据解析阶段的字节序陷阱:ABCD还是CDAB
报文能正常收上来了,CRC也对了,但解析出来的温度值却离谱到几百上千度?遇到这种问题,八成是字节序搞错了。
MODBUS寄存器是16位的,一个寄存器传输2个字节,协议规定寄存器内部是高字节在前。这本身不难理解。真正麻烦的是32位数据,比如32位无符号整数、单精度浮点数,这些数据必须占用两个或更多的寄存器。由于各设备厂商对多寄存器存储顺序的定义完全不同,解析时就会出现四种排列方式。
6.1 单精度浮点在四个寄存器字节顺序中的排列差异
假设一个单精度浮点数在内存中的4个字节是 A B C D(A是最高字节),MODBUS连续读两个寄存器会得到两段16位数据。按不同的寄存器顺序和字节顺序,常见的存储方式有四种,业内经常用ABCD、CDAB、BADC、DCBA来指代:
| 存储顺序 | 寄存器1 | 寄存器2 | 说明 |
|---|---|---|---|
| ABCD | AB | CD | 大端序,高字在前 |
| CDAB | CD | AB | 小端序,低字在前 |
| BADC | BA | DC | 字内字节交换,高字在前 |
| DCBA | DC | BA | 全字节反转 |
比如浮点数25.5的IEEE754表示是0x41CC0000。按ABCD顺序存储,第一个寄存器是41 CC,第二个寄存器是00 00。但换一台设备可能就变成00 00 41 CC。如果不清楚设备的字节序标准,直接按大端去解析出来的数值和真实值有天壤之别。
实际调试中,我碰到的设备用CDAB这种"低字在前、字内高位在前"的最多。你可以先用Modbus Poll这类工具,切换数据格式(Float ABCD、Float CDAB等)去试,看哪个选项读出来的数据跟设备LCD屏幕显示的数值吻合,那这个设备用的就是哪种字节序。
6.2 用联合体处理字节序
在STM32这类小端MCU上处理MODBUS字节序,我习惯用联合体来转换,代码直观又不容易出错:
typedef union { float f; uint8_t bytes[4]; } float_bytes_t; float modbus_to_float(uint8_t *reg_buf, uint8_t order) { float_bytes_t fb; if (order == ORDER_ABCD) { fb.bytes[0] = reg_buf[0]; fb.bytes[1] = reg_buf[1]; fb.bytes[2] = reg_buf[2]; fb.bytes[3] = reg_buf[3]; } else if (order == ORDER_CDAB) { fb.bytes[0] = reg_buf[2]; fb.bytes[1] = reg_buf[3]; fb.bytes[2] = reg_buf[0]; fb.bytes[3] = reg_buf[1]; } // 其他顺序类似处理 return fb.f; }在小端单片机里,直接对uint8_t数组按不同顺序填充,再用联合体转成浮点数,省去了位运算移位和memcpy混淆的风险。这段代码看起来简单,但就是这种简单的处理方式,在你调试时排查数据异常能省下一整晚时间。
6.3 16位数据的符号和无符号问题
还有一个容易忽略的点:很多传感器手册写的温度寄存器,其实是用有符号16位整数表示的,正温度是正数,负温度是用补码表示。如果解析时没有把uint16_t转成int16_t,负温度会被读出来一个六万多的超大正值。
uint16_t raw = (reg_buf[0] << 8) | reg_buf[1]; int16_t signed_val = (int16_t)raw; float temperature = signed_val * 0.1f;这个转换在做温度、角度、速度之类有负值的数据时必须加上。
7. 物理层和时序的坑:RS485方向切换、帧间隔、终端电阻
协议层全部对了,通信稳定不稳定就要看物理层了。MODBUS RTU跑在RS485上,RS485半双工的特性带来了一系列帧时序问题。我把这一年多调试里遇到过的物理层问题放在一起说。
7.1 收发切换的时机
RS485是半双工总线,同一时刻只能有一方在发送。MCU通过DE/RE引脚控制收发器的方向。主站发送完请求帧后,必须立刻把DE拉低、切换到接收状态,才能收到从站的响应。
这里有个不那么显而易见的坑:发送完最后一个字节后,串口的TX移位寄存器可能还没把数据全部发完,如果你在写中断里马上拉低DE,尾巴会被砍掉。标准做法是先等发送完成标志(TC)置位,再延时一到两个字符时间,最后切换方向。具体延时长度跟波特率有关,9600bps下一个字符约1.04ms,延时2ms比较稳妥。
7.2 3.5字符时间帧间隔
MODBUS RTU的帧间隔要求是:两帧之间必须至少有3.5个字符时间的静默期。这个间隔是协议判断一帧结束的重要依据。从机收到一个字节后,如果在3.5个字符时间内没有再收到新字节,就认为当前帧接收完毕,开始解析。如果间隔太短,从机可能把两帧数据当成一帧处理;如果间隔过长,又有可能把一帧拆成两帧。
在MCU里实现时,一种常见做法是开启串口接收超时中断(比如STM32的空闲中断),或者在定时器中断里判断"距上次收到字节是否超过t3.5"。有些从机固件为了省事,直接用固定时长比如5ms来判断帧结束,这在9600bps下问题不大,但波特率提高到115200时,3.5字符时间就只有约0.3ms,稍不注意就把帧切断了。
7.3 终端电阻和总线偏置
一条485总线的两端需要各接一个120欧终端电阻,用来匹配总线阻抗、减少信号反射。如果总线上只有一台从机加一个USB转485调试器这种短距离场景,不接终端电阻通常也能通信,但距离超过几十米或者波特率较高时,反射信号就可能造成误码。
总线偏置是另一个容易忽略的细节。某些USB转485模块在空闲状态下A、B之间的电压差不够,导致接收端读到乱码。特别是从机直接由MCU的UART接MAX485这类芯片时,如果A、B线没有加上下拉偏置电阻,总线空闲时收发器可能输出随机电平,产生杂散帧。我在一块自制板卡上就踩过这个坑,现象是主站时不时收到00 FF之类的乱码,查了半天才发现是缺了下拉电阻。解决办法是在A线上加10k上拉到VCC,B线上加10k下拉到GND,给总线一个确定的空闲电平。
7.4 从机固件里接收处理的几条建议
从机端的固件处理,核心就一句话:用中断或DMA收字节,用定时器判断帧结束,不要在串口中断里做耗时操作。我常用的结构是串口接收中断把字节放进环形缓冲,定时器中断里检查是否超时,超过3.5字符时间就把缓冲区的数据作为完整帧送进解析函数。解析函数里用状态机逐字节读地址、功能码、数据和CRC,CRC校验通过才执行对应的寄存器读写操作,然后组响应帧发送。
这样设计的好处是,即使总线上有其他设备的噪声干扰,也不会影响当前帧的接收完整性。而响应帧的组帧,我建议封装成独立函数,发送时统一把关CRC生成,避免每处调用都手动拼CRC导致写错。
8. 调试工具组合:示波器、逻辑分析仪和串口助手的配合
文本稿到这里,最后想说说调试工具的组合。很多人手上只有一个串口助手,遇到问题就反复重发报文碰运气。工具用到位,很多疑难杂症能快速定位。
8.1 示波器看电平,逻辑分析仪看数据
排查物理层问题时,示波器是必不可少的。用示波器看RS485的A/B差分波形,能直接看到信号幅度是否正常、有无反射振铃、帧间隔是否足够。但示波器看数据不太方便,这时候逻辑分析仪更顺手:接上RX和TX两根线,用触发模式抓一帧完整报文,然后对照协议文档逐个字节检查。
实际上,我调试时最常用的组合是:串口助手负责发命令和看数据,逻辑分析仪负责抓总线上的原始波形。当主站能发但收不到响应时,逻辑分析仪能告诉你"从机到底有没有回数据"。如果逻辑分析仪上能看到从机的响应帧,但串口助手收不到,那问题就在主站侧的接收链路;如果连从机的响应帧都没有,问题要么在请求帧内容,要么在从机本身。
8.2 自己写一个简单的MODBUS调试上位机
如果你和我一样,经常要跟不同协议的设备打交道,可以考虑自己用Python写一个简单的MODBUS主站调试脚本。用pyserial发报文、解析响应,还可以自动轮询多个寄存器地址。我贴一个简化版思路:
import serial import struct def crc16_modbus(data: bytes) -> bytes: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 0x0001: crc >>= 1 crc ^= 0xA001 else: crc >>= 1 return bytes([crc & 0xFF, (crc >> 8) & 0xFF]) ser = serial.Serial('COM5', 9600, timeout=1) req = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x02]) req += crc16_modbus(req) ser.write(req) resp = ser.read(32) print(resp.hex(' '))这个脚本的核心价值在于:你可以把它推广成自动遍历地址、自动切换功能码、自动记录异常帧的批量测试工具。批量测试工具在接入新设备时特别有用,能把十几个寄存器一口气读出来,比手动一条条发命令高效得多。
8.3 记录现场日志,别信记忆
调MODBUS还有一个特别容易忽略的习惯问题:记录。每次调试的报文、参数、设备型号、数据格式都值得记下来。我踩过最蠢的一次坑是,换了台同型号传感器,用上次的参数配,怎么都不通,后来发现这台新传感器的地址是03而不是01,出厂默认被改过。如果当时记录里写了设备序列号和对应地址,这种事就不会发生。
我的习惯是维护一个简单的设备清单,每台设备的Modbus地址、波特率、数据区映射、字节序、浮点格式都记全。这样再去现场排查时,不用每次重新探测一遍。
调试MODBUS协议这件事,门槛不高,但真正做到稳定可靠,需要把帧结构、寄存器模型、CRC、字节序、物理层时序这些细节串起来。每一次"设备没反应"的背后,基本都指向这几个方向里的某一个。我的体会是:先用手上工具把报文链路理顺,再动手写代码,比上来就对着代码库狂翻bug要高效得多。希望这篇笔记能帮你少踩几个我已经踩平了的坑。