做嵌入式这几年,凡是碰过工控设备、传感器采集、PLC通信的,迟早要和MODBUS协议打交道。这篇是调试笔记第7篇,我准备把这块硬骨头认真啃一遍:从报文结构、功能码、CRC校验,到用串口调试助手完整抓一次包,再到现场调试遇到的那些“玄学”问题。全程按照调试现场的操作顺序来写,不搞理论复读,你看完应该能明白主机发出去的01 03 00 00 00 01这些字节到底是什么意思,从站回的一串十六进制又该怎么解析成温度、压力和转速。
可能你会觉得MODBUS太“老”了,但现在跑在现场的设备里,变频器、温控表、电量仪、IO采集模块,十台有八台都带MODBUS接口。搞嵌入式如果不熟悉这套协议,进工控项目基本寸步难行。这篇笔记适合这几类人:刚入行的嵌入式软件工程师、做单片机又要对接传感器模块的开发者、以及被设备厂商协议手册搞得头疼的调试人员。我会尽量把每个字节掰开揉碎讲,看完可以直接套用。
1. 为什么嵌入式工程师绕不开MODBUS协议
1.1 工业现场的主从“通用语言”
MODBUS本质上是一种应用层通信协议,1979年由Modicon公司推出,初衷是给PLC之间、PLC与上位机之间提供一套标准通信规则。后来因为简单、开放、无授权限制,逐渐成了工业自动化领域事实上的通用语言。它的核心模型是主从架构:总线上有一台主机(主站),若干从机(从站),主机发起请求,从机响应,从机之间不直接通信。
这个主从模型非常契合工业现场的很多场景。比如一个水泵房监控系统,上位机或者DTU作为主机,下面挂了几台温度传感器、压力变送器、电能表,每台设备分配一个地址,主机按地址轮询数据,一主多从、井井有条。正是这种简单可靠的设计,让MODBUS在PLC、HMI、SCADA、传感器仪表之间扎根了四十多年,至今仍是很多设备出厂默认支持的协议。
还有一点是调试友好。MODBUS RTU报文全部是十六进制字节,没有复杂的ASN.1编码、没有XML解析,一个串口调试助手就能看穿一切。这种“透明”特性在实际联调中太重要了——出了问题,抓包看一下是哪一端不规范,责任一目了然。
1.2 RTU、TCP、ASCII三种形态怎么选
MODBUS家族里最常见的三种形态是RTU、TCP和ASCII。很多新手一看协议名称就头大,其实区分起来不复杂,核心差异就在“载体”和“编码”上。
MODBUS RTU跑在串口上(RS-232/RS-485),数据用二进制方式传输,紧凑高效,是嵌入式设备里用得最多的形态。MODBUS TCP则是把MODBUS报文封装进TCP/IP帧里,跑在以太网上,不需要CRC校验(TCP本身有可靠性保障),适合跨设备、跨系统的数据采集。MODBUS ASCII也是串口传输,但把每个字节拆成两个ASCII字符发送,报文变长了一倍,传输效率低,只有极少数老设备或者无线透传场景还在用。
我做了个表方便你对比:
| 形态 | 载体 | 数据编码 | 校验方式 | 适用场景 |
|---|---|---|---|---|
| MODBUS RTU | RS-232/RS-485 | 二进制 | CRC16 | 绝大多数工控设备、嵌入式采集 |
| MODBUS TCP | 以太网 | 二进制 | TCP自带校验 | 上位机、跨设备组网 |
| MODBUS ASCII | RS-232/RS-485 | ASCII字符 | LRC | 老设备、部分无线透传 |
实际选型时,我的建议很简单:如果设备只有串口,优先RTU;如果涉及局域网远程采集,优先TCP;除非设备手册明确只支持ASCII,否则不用纠结后两种。这篇笔记后面所有实战都围绕RTU展开,因为串口调试里我们碰到的几乎全是它。
1.3 认识RS-485:MODBUS最常见的物理层搭档
聊MODBUS RTU,绕不开RS-485。要特别注意一个概念:RS-485是物理层标准,解决的是“电平怎么传、线怎么接、能传多远”的问题;MODBUS是应用层标准,解决的是“字节怎么组织、报文什么含义”的问题。两者不是一个层面的东西,但实际应用中经常绑在一起出现。
RS-485采用差分信号传输,抗干扰能力强,理论传输距离可达1200米,支持一条总线上挂32个节点(加中继还能更多),正好满足工业现场的参数需求。使用MODBUS RTU over RS-485时,一般用屏蔽双绞线把主机和从机的A、B端串联起来,注意A接A、B接B,别接反。总线两端还需要各加一个120欧的终端电阻,用于消除信号反射。
很多新手容易把“485”和“MODBUS”混为一谈,以为485协议就是MODBUS协议。实际485只规定了电气特性,它上面可以跑MODBUS,也可以跑自定义协议。我们从设备厂商那里拿到的“MODBUS寄存器表”,其实是应用层的约定;而“RS-485通信,波特率9600,8数据位,无校验,1停止位”,这是物理层的约定。两者配合好,通信才算打通。
2. MODBUS RTU报文拆解:一帧数据到底在说什么
2.1 报文结构总览
MODBUS RTU报文格式非常规整,一帧数据由四部分组成:地址码(1字节)、功能码(1字节)、数据段(N字节)、CRC16校验(2字节)。没有帧头帧尾标记,帧与帧之间靠静默间隔区分,一般要求不少于3.5个字符时间的空闲。
例如主机要给地址为1的从站发一个“读保持寄存器”请求,读取起始地址0x0000的1个寄存器,最经典的请求帧是这样:
| 字段 | 示例值 | 含义 |
|---|---|---|
| 从站地址 | 0x01 | 目标从站地址为1 |
| 功能码 | 0x03 | 读保持寄存器 |
| 起始地址 | 0x00 0x00 | 从寄存器地址0开始读 |
| 寄存器数量 | 0x00 0x01 | 读1个寄存器 |
| CRC16 | 0xD8 0x44 | 校验码,低字节在前 |
完整报文就是01 03 00 00 00 01 D8 44。这里有个容易忽略的细节:CRC在线上传输时低字节在前。后面我会专门讲CRC怎么算。
从站收到无误后,会回复类似01 03 02 07 D0 C8 7A这样的响应,含义是“地址1,功能码03,后续数据2个字节,寄存器值为0x07D0(十进制2000),CRC校验”。你能看到,响应里的地址、功能码都和请求对应,这样主机才能确认这是对哪一条请求的应答。
2.2 地址码与功能码:从站怎么知道该干什么
地址码决定“这帧报文是发给谁的”。MODBUS规定从站地址范围1~247,0是广播地址,所有从站都会接收但不会回复。实际项目中,设备地址一般通过拨码开关或者配置软件设置,同一总线上不能重复。
功能码决定“要干什么”。MODBUS把数据分成两类:位(开关量)和字(寄存器),又区分读写操作,于是有了下面这些常用功能码:
| 功能码 | 名称 | 操作对象 | 典型用途 |
|---|---|---|---|
| 0x01 | 读线圈 | 位 | 读取DO状态 |
| 0x02 | 读离散输入 | 位 | 读取DI状态 |
| 0x03 | 读保持寄存器 | 字 | 读取模拟量输出/参数 |
| 0x04 | 读输入寄存器 | 字 | 读取模拟量输入 |
| 0x05 | 写单个线圈 | 位 | 控制单个DO |
| 0x06 | 写单个寄存器 | 字 | 修改单个参数 |
| 0x0F | 写多个线圈 | 位 | 批量控制DO |
| 0x10 | 写多个寄存器 | 字 | 批量修改参数 |
在嵌入式项目里,我碰得最多的就是03和06,一个读数据一个写参数。比如读传感器采集到的温度值,用03;下发电机的转速设定值,用06。02和04多见于读取开关量输入、模拟量输入,01和05多用于控制继电器输出。你不需要把每个功能码都背下来,但至少要能看懂设备手册里的寄存器表和功能码说明。
2.3 CRC16校验:手算一遍就永久记住
CRC16校验是MODBUS RTU最容易出问题的地方,也是新手最怕的手工计算。其实原理不复杂,本质是把整帧数据当作一个大数,用生成多项式0xA001反复做异或和移位运算,得到一个16位的校验值。虽然实际调试中都用工具生成,但手算一遍能帮你彻底理解报文的完整度,排查问题时心里更有底。
我们以请求帧前6个字节01 03 00 00 00 01为例算一遍CRC16:
- 初始化CRC寄存器为0xFFFF。
- 取第一个字节0x01,与CRC低字节异或,CRC变成0xFFFE。
- 右移1位,如果移出的最低位是1,就再异或0xA001;如果是0,继续右移。
- 重复8次,处理完第一个字节。
- 取第二个字节0x03,重复上述移位异或过程。
- 依次处理完所有字节,最后得到的CRC寄存器值就是0x44D8。
- 发送时先低字节后高字节:
D8 44。
这一段处理流程用C语言写出来非常简短,实际项目中可以直接复用:
uint16_t crc16_modbus(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *buf++; for (int i = 0; i < 8; i++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }这段代码我用了很多年,在STM32和Linux平台都验证过,不会出错。注意MODBUS CRC是低位在前的发送顺序,封装发送函数时记得把返回值的低字节放前面,高字节放后面。很多人第一次写MODBUS驱动,CRC算得不对,结果就是主机发出来的报文总被从站丢弃。
2.4 大端还是小端:寄存器数据的字节序陷阱
MODBUS寄存器数据默认是大端模式,也就是高字节在前、低字节在后。比如从站返回的寄存器值是0x07D0,线上看到的字节就是07 D0。这一点和很多嵌入式工程师的习惯相反,因为我们用的STM32是低端MCU,内存里小端存储,直接强转指针去读缓冲区很容易拿到一个反过来的值。
举个例子,从站回复01 03 02 07 D0 C8 7A,数据段解析方法是:
uint16_t value = (buf[3] << 8) | buf[4]; // 0x07D0如果你写成buf[3] | (buf[4] << 8),结果就是0xD007,差了十万八千里。处理32位浮点数或32位整数时更要小心,需要按先后顺序组合4个字节,并且注意设备手册是否按照“ABCD”还是“CDAB”的寄存器顺序存放。这里没有捷径,只能逐个设备去核对手册里的寄存器说明,用已知值去验证。
3. 常用功能码实战:读、写、批量操作
3.1 03功能码:读保持寄存器(请求+响应逐字节解析)
0x03是最常用的功能码,一次可以读取1到125个保持寄存器。它的请求格式是:从站地址、功能码、起始地址(2字节)、寄存器数量(2字节)、CRC。响应格式是:从站地址、功能码、后续字节数、寄存器数据、CRC。
假设我们要读取从站地址为1的设备的2个保持寄存器,从0号寄存器开始。请求报文应该这样组织:
| 字段 | 值 | 说明 |
|---|---|---|
| 从站地址 | 01 | 目标从站1 |
| 功能码 | 03 | 读保持寄存器 |
| 起始地址 | 00 00 | 从第0个寄存器开始 |
| 寄存器数量 | 00 02 | 连续读取2个 |
| CRC | 98 45 | CRC校验,低字节在前 |
完整请求:01 03 00 00 00 02 98 45(CRC值为我前面手算流程的延续,实际调试时用工具生成即可)。
假设从站里0号寄存器存的是温度,值是0x0123;1号寄存器存的是湿度,值是0x4567。从站响应会是这样:
| 字段 | 值 | 说明 |
|---|---|---|
| 从站地址 | 01 | 应答从站1 |
| 功能码 | 03 | 对应请求的功能码 |
| 字节数 | 04 | 后面数据共4个字节 |
| 数据 | 01 23 45 67 | 两个寄存器值连在一起 |
| CRC | 工具生成 | 校验 |
这里要理解“字节数”这个字段的作用。因为读取数量不固定,响应数据长度不固定,从站必须明确告诉主机后面跟了多少个字节,主机才能正确切分数据帧。同理,写多个寄存器的请求里也有一个“字节数”字段。
3.2 06功能码:写单个寄存器
0x06用于写单个保持寄存器,常用于参数设定、模式切换。请求和响应格式完全一样:从站地址、功能码、寄存器地址(2字节)、寄存器值(2字节)、CRC。从站成功执行后会原样回显这帧报文,主机看到响应和请求一致就说明写入成功。
例如把从站1的寄存器0x0100写入值0x00FF,请求可以写成:
| 字段 | 值 | 说明 |
|---|---|---|
| 从站地址 | 01 | 目标从站1 |
| 功能码 | 06 | 写单个寄存器 |
| 寄存器地址 | 01 00 | 写第0x0100号寄存器 |
| 寄存器值 | 00 FF | 要写入的数据 |
| CRC | 工具生成 | 校验 |
如果从站返回的响应和请求不一致,或者返回异常码,基本可以判定写入失败。最常见的失败原因是寄存器地址不存在或者只读。很多设备手册会把寄存器标成“R”或者“R/W”,只有标记R/W的寄存器才允许用06或16功能码写。
3.3 16功能码:写多个寄存器
0x10(十进制的16)功能码用于一次写入多个连续寄存器,批量下发参数时效率很高。请求格式是:从站地址、功能码、起始地址(2字节)、寄存器数量(2字节)、字节数(1字节)、数据(N字节)、CRC。响应格式则简化了,只返回:从站地址、功能码、起始地址、寄存器数量、CRC。
比如要把从站1的2号、3号寄存器分别写入0x000A、0x000B,请求:
| 字段 | 值 |
|---|---|
| 从站地址 | 01 |
| 功能码 | 10 |
| 起始地址 | 00 02 |
| 寄存器数量 | 00 02 |
| 字节数 | 04 |
| 数据 | 00 0A 00 0B |
| CRC | 工具生成 |
响应则是:01 10 00 02 00 02 + CRC。新手容易把请求里的“字节数”算错:有几个寄存器就乘以2,别直接填寄存器个数。还有,一次写入的数量不能超过从站支持的极限,很多设备限制一次最多写123个寄存器,超过部分会被忽略。
3.4 其他功能码速查表与异常码
除了03、06、16,项目里还可能用到01、02、05、0F等位操作功能码。它们的请求结构基本一致,只是“数据”的含义从寄存器值变成了“线圈状态”或“离散输入状态”。01和05的线圈状态约定为:0xFF00表示ON,0x0000表示OFF,这个设计是为了防止干扰误判,看到0xFF01这样的值时不建议当作合法ON。
从站处理请求时如果发现错误(地址越界、功能码不支持、数据非法),会返回一个异常响应。异常响应的格式是:从站地址、功能码(原功能码最高位置1)、异常码、CRC。比如03功能码请求出错,返回的功能码就是0x83。常见异常码含义:
| 异常码 | 含义 | 常见原因 |
|---|---|---|
| 0x01 | 非法功能码 | 从站不支持该功能码 |
| 0x02 | 非法数据地址 | 寄存器地址越界、不存在 |
| 0x03 | 非法数据值 | 写入值超范围或数量为0 |
| 0x04 | 从站设备故障 | 从站内部错误 |
| 0x06 | 从站忙 | 正在处理其他任务,稍后重试 |
调试时看到异常响应不要慌,先对号入座。我碰到最多的是0x02,基本都是因为我拿错了寄存器地址表,或者起始地址选得超出设备实际寄存器范围。
4. 串口调试助手实战抓包全过程
4.1 调试环境与接线
纸上谈兵讲再多,不如亲手抓一次包。先准备环境:一台电脑、一个USB转RS-485模块、一个支持MODBUS RTU协议的从站设备,或者临时用MODBUS从站模拟软件代替真实设备。软件方面,串口调试助手一定要选支持HEX收发和CRC自动生成的,实在没有就自己写个小工具。
接线是第一个容易翻车的点:USB转485模块的A接从站的A,B接从站的B,千万不要接反。部分模块上标的是“D+”和“D-”,D+对应A,D-对应B。线尽量短,如果通信距离超过几十米或者现场干扰明显,必须在总线的首尾两端各接一个120欧终端电阻。模块和从站之间最好共地,外用电源供电的设备尤其注意地电位差,否则通信会时好时坏。
硬件准备好后,先确认串口参数。MODBUS RTU最常见的配置是9600波特率、8数据位、无校验、1停止位,写做9600-8-N-1。不过确定参数必须以从站设备手册为准,有些仪表默认是19200或者偶校验,参数不对连一帧报文都发不出去。
4.2 手动发送一帧报文读取温湿度传感器
下面用一个真实感很强的场景演示:某温湿度传感器从站地址为1,0号寄存器存温度(放大10倍),1号寄存器存湿度(放大10倍)。配置是9600-8-N-1,我们要读取这两个寄存器。
第一步,在串口调试助手里选择正确的COM口,设置波特率9600,数据位8,校验位None,停止位1,打开串口。
第二步,组织请求报文。地址1,功能码03,起始地址0,数量2,加上CRC,最终要发送的HEX报文是01 03 00 00 00 02 98 45。如果你用的调试助手支持“添加CRC”功能,可以先只输入01 03 00 00 00 02,让工具自动算出CRC,再拼到末尾发送。
第三步,以HEX格式发送,然后观察接收区。如果一切正常,你会收到类似01 03 04 01 2C 00 82的响应。逐个字节解析:
| 字节 | 含义 |
|---|---|
| 01 | 从站确认,地址1 |
| 03 | 功能码回显 |
| 04 | 后面跟4个字节数据 |
| 01 2C | 0号寄存器值,0x012C,十进制300,温度=30.0°C |
| 00 82 | 1号寄存器值,0x0082,十进制130,湿度=13.0% |
看到这个结果,整个链路就通了。如果接收区空荡荡,先检查线序和串口参数;如果收到的是01 83 02这样的异常帧,说明从站收到了数据,但是寄存器地址超出范围,把起始地址调低再试。
4.3 用从站模拟器验证写寄存器
真实设备不在手边时,可以用MODBUS从站模拟软件(比如Modbus Slave、Docklight配合模拟端)来验证主机逻辑。这类软件可以创建若干个寄存器,实时改变值,还能抓收发的帧。
我习惯用模拟器验证写功能码。打开模拟器,新建一个从站,地址设为1,创建若干保持寄存器。然后用串口调试助手发一帧06写单个寄存器请求,比如写地址0x0100,值0x00FF。发送前注意,一定要让串口调试助手或手动工具算好CRC,否则模拟器不会回应。收到响应后,去模拟器界面看0x0100寄存器的值,如果变成了255,说明写入成功。
这个步骤对于写设备驱动非常有用。在写单片机代码之前,先用模拟器把协议行为摸透,代码跑起来遇到问题就知道是协议问题还是驱动问题。很多联调现场“软件瞎猜、硬件乱动”,就是因为没有先把协议层的交互验证清楚。
4.4 Python脚本自动化调试模板
如果需要在电脑上做批量读取、连续记录数据,手动点串口助手效率太低。我这边一直留着一个Python调试模板,用pyserial发送请求帧并解析响应,非常适合验证协议逻辑。
import serial def crc16_modbus(data: bytes) -> bytes: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 0x0001: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return bytes([crc & 0xFF, (crc >> 8) & 0xFF]) if __name__ == "__main__": ser = serial.Serial('COM3', 9600, timeout=1) base = bytes.fromhex('01 03 00 00 00 02') request = base + crc16_modbus(base) print('发送:', request.hex().upper()) ser.write(request) resp = ser.read(64) print('接收:', resp.hex().upper()) if len(resp) >= 5 and resp[1] == 0x03: count = resp[2] data = resp[3:3 + count] temp_raw = (data[0] << 8) | data[1] hum_raw = (data[2] << 8) | data[3] print(f'温度: {temp_raw / 10.0:.1f} C, 湿度: {hum_raw / 10.0:.1f} %') ser.close()这段脚本我实测可用,注意换成自己的COM口号和波特率。如果你把这段封装成函数,配合循环,就能实现轮询采集、自动记录、超时报警,直接当简易SCADA用。实际调试时我还会把响应帧的hex打印出来,和串口助手的抓包对照,两边一致才能确认链路没问题。
5. 调试中踩过的坑与排查思路
5.1 永远先查串口参数
MODBUS RTU调试不出数据,第一优先级永远检查串口参数。波特率、数据位、校验位、停止位,四项中任何一项不一致,从站收到的都是乱码,根本不会响应。我见过有人折腾了一下午换了好几个USB转485模块,最后发现是校验位设成了偶校验,从站却是无校验。
排查顺序建议这样:先在电脑上用调试助手自发自收测试USB转485模块;再用万用表量A、B之间有没有电压,正常空闲状态应该有2~5V左右的压差;然后核对从站实际配置,很多设备是拿拨码开关设置波特率的,别只看软件配置。最后才需要考虑协议帧对不对。
5.2 从站不回数据,从这几处查
最典型的故障现象是主机发请求,接收区一片空白。这时候按“帧格式—线路—地址”三步排查。
先看HEX发送有没有勾选对,有没有把ASCII文本当成HEX发出去。常见低级错误是发送框里填了01 03 00 00 00 02 98 45但没勾选HEX模式,结果把字符“01”的ASCII码0x30 0x31发出去,从站肯定不认。
再看485线序和终端电阻。A/B接反后有些从站设备因为有保护电路,偶尔也能收到数据但回帧发不回来,表现出来就是“请求有去无回”。查线的同时检查一下发送和接收状态,部分USB转485模块会自动切换收发方向,但切换延迟异常时也会导致响应接收不完整。
最后确认从站地址。请求帧里地址是01,从站拨码却设成了02,从站收到一个不是发给自己的报文,直接丢弃。广播地址0除外,一般不用。
5.3 线材、终端电阻与共地干扰
串口通信在实验室里怎么接都通,一到现场就毛病百出,多半是干扰问题。RS-485虽然抗干扰能力强,但前提是布线规范:使用屏蔽双绞线,屏蔽层单端接地;总线要手拉手串联拓扑,不要星形连接;总线两端接120欧终端电阻吸收信号反射。
还有一个经常被忽略的坑是共地。如果主机和从站各自使用独立电源供电,地电位差过大时,即使485电平正常也可能出现通信不稳定、偶发错误CRC。解决办法是将所有设备的GND接到一起,或者选择带隔离的USB转485模块。我后来在现场布线时,直接给每个从站都用隔离电源供电,问题少很多。
5.4 地址越界与异常响应码
当从站回复异常码0x02或0x03时,先别急着怀疑硬件,多半是请求帧里的寄存器地址或数量写错了。我遇到过一个案例,设备手册写寄存器起始地址是40001(PLC风格的保持寄存器地址),对应MODBUS协议里的地址实际是0x0000。新手如果直接把40001填进去,换算成十六进制是0x9C41,远超从站的寄存器范围,自然报0x02。
还有数量越界。读取数量不能超过125个寄存器,某些设备为了节省处理资源,还会把上限设得更低。如果请求里数量和地址加起来超过设备地址空间,也会返回异常码。遇到这类问题,我通常会把请求中的“起始地址+数量”和手册里的寄存器范围表做一遍核对,几分钟就能定位。
5.5 调试心得:先把“标准请求”打印出来贴在工位
最后分享一个我自己坚持很久的小习惯:在开发板上电之前,先把设备手册里的核心寄存器整理成一张表,然后用CRC工具算好对应的标准请求帧,打印出来贴在工位旁边。比如读取温度是01 03 00 00 00 01 D8 44,读取湿度是01 03 00 01 00 01 95 C8,写入阈值是01 06 00 02 00 64加CRC。
这样调试时遇到问题,我可以先用手动发送“标准请求帧”来判断是硬件链路问题还是软件解析问题。如果手动发包能收到正常响应,那就是驱动的组装帧有问题;如果手动发包都没响应,那就是硬件链路和从站配置的问题。这个思路帮我节省了大量排查时间,也让新接手项目的同事能快速上手。MODBUS协议本身并不难,难的是在纷繁的设备差异和现场环境里保持一种有条理的调试方法。把基础帧格式吃透,把常用工具用熟,绝大多数通信问题都能迎刃而解。