做了这么多年嵌入式,串口通信这块我调试得最多,而 MODBUS 协议又是串口调试里绕不开的一道坎。前几篇笔记聊过串口、I2C、定时器之类的实战问题,这篇把 MODBUS 单独拎出来写,一是因为它太常用了,二是因为我发现很多朋友对它其实是"半懂不懂"——帧能发出去,但从站就是不回,或者偶尔回偶尔不回,卡上半天查不出原因。
这篇笔记我会从协议本身讲起,把 RTU 帧格式、寄存器模型、功能码这些基础概念掰开揉碎,再结合我实际写过的一个从站协议栈和调试过程中的真实故障,把从零调通 MODBUS 的过程完整走一遍。不管你是刚接触单片机通信的新手,还是已经写过几句串口收发、正被"从站无响应"折磨的嵌入式开发,这篇文章应该都能帮你少走不少弯路。你不需要有很深的背景,只要写过串口收发、知道什么是寄存器,剩下的跟着我一步步来就行。
1. 先别急着调收发,搞懂 MODBUS 这四件事再动手
1.1 MODBUS 是谁,为什么四十多年了还在用
MODBUS 是 1979 年由 Modicon 公司提出的应用层报文协议,后来施耐德电气把它开放出来,现在由 Modbus-IDA 组织维护。它最厉害的地方不是技术多新,而是"足够简单、完全免费、实现成本极低"。在 PLC、变频器、电表、温控器、传感器这些工业设备上,你几乎都能看到它的身影。
我自己的体会是:做嵌入式产品,你要对接的外部设备越杂,MODBUS 的价值就越明显。甲乙两个厂家的设备,寄存器地址定义可能差很远,但协议帧格式是统一的。你只要把 MODBUS 这套通信逻辑写一遍,后面接什么设备都是改寄存器映射表的事,不用为每一家重新发明轮子。
而且这个协议在国内还有一个特殊身份——它已经被列为国家标准 GB/T 19582。这意味着很多行业项目中,通信接口必须支持 MODBUS,不是你想不想用的问题,而是甲方验收就认这个。所以做嵌入式,尤其是工业、电力、物联网网关方向,MODBUS 基本是逃不掉的必选项。
1.2 四种数据对象:位和寄存器的分工
MODBUS 把设备里的数据抽象成四种对象,新手最常在这里犯迷糊。我帮你整理成一张表:
| 数据对象 | 位/字 | 读写属性 | 对应功能码 | 典型用途 |
|---|---|---|---|---|
| 线圈(Coil) | 位 | 可读可写 | 0x01、0x05、0x0F | 继电器输出、指示灯 |
| 离散输入(Discrete Input) | 位 | 只读 | 0x02 | 开关状态、限位信号 |
| 保持寄存器(Holding Register) | 16位字 | 可读可写 | 0x03、0x06、0x10 | 运行参数、设定值 |
| 输入寄存器(Input Register) | 16位字 | 只读 | 0x04 | 采集的模拟量、传感器数据 |
一句话记忆方式:线圈和离散输入是"开关",区别在于线圈能让你去控制它,离散输入只能看不能碰;寄存器和输入寄存器是"数据仓库",保持寄存器是"可改写的参数区",输入寄存器是"只读的采集区"。
实际项目里,保持寄存器是用的最多的,因为像温度设定值、PID 参数、工作模式这些都需要既能读又能写。输入寄存器主要用于电压、电流、温度这些实时采集值。线圈则用来做启停控制,比如电机启动、阀门开闭。
1.3 RTU 帧格式详细拆解:主站问了什么,从站答什么
MODBUS RTU 的消息帧结构非常紧凑,一个完整的请求帧长这样:
| 字段 | 从站地址 | 功能码 | 数据区 | CRC16 |
|---|---|---|---|---|
| 长度 | 1 字节 | 1 字节 | N 字节 | 2 字节 |
举个例子,我想读取从站地址 0x01 的设备,从保持寄存器起始地址 0x0000 开始连续读 10 个寄存器,请求帧就是:
01 03 00 00 00 0A C5 CD其中 01 是从站地址,03 是读保持寄存器功能码,00 00 是起始寄存器地址(高字节在前),00 0A 是要读的数量(十进制 10),C5 CD 是前面所有字节算出来的 CRC16 校验值。
从站正常响应是这样的:
01 03 14 [20个字节的寄存器数据] [CRC16低字节] [CRC16高字节]这里 14 是十六进制 20,表示后面跟着 20 个数据字节(10 个寄存器 × 2 字节)。注意 MODBUS RTU 里 CRC 是低字节在前发送,这是最容易被坑的地方,后面我会专门讲。
这里有一个细节必须记住:寄存器数量字段的单位是"寄存器个数",不是字节数。很多新手读 10 个寄存器,响应长度却按 10 字节去解析,数据全错位了。响应里的字节数 = 寄存器数量 × 2。
1.4 功能码不用全会,但核心这几个必须吃透
MODBUS 功能码有很多,但日常开发你先把下面这几个玩明白就够用了:
| 功能码 | 名称 | 请求数据 | 响应数据 |
|---|---|---|---|
| 0x01 | 读线圈 | 起始地址 + 数量 | 字节数 + 位打包数据 |
| 0x02 | 读离散输入 | 起始地址 + 数量 | 字节数 + 位打包数据 |
| 0x03 | 读保持寄存器 | 起始地址 + 数量 | 字节数 + 寄存器数据 |
| 0x04 | 读输入寄存器 | 起始地址 + 数量 | 字节数 + 寄存器数据 |
| 0x05 | 写单个线圈 | 线圈地址 + 值(0xFF00/0x0000) | 原样回复 |
| 0x06 | 写单个寄存器 | 寄存器地址 + 值 | 原样回复 |
| 0x0F | 写多个线圈 | 起始地址 + 数量 + 字节数 + 位数据 | 起始地址 + 数量 |
| 0x10 | 写多个寄存器 | 起始地址 + 数量 + 字节数 + 寄存器数据 | 起始地址 + 数量 |
当主站发了不支持的功能码,或者寄存器地址越界,从站会返回异常帧。异常帧的功能码会把最高位置 1(比如 0x83 表示对 0x03 的异常响应),后面跟一个异常码。常见的异常码就几个:
- 01:非法功能码,从站根本不支持这个功能
- 02:非法数据地址,寄存器地址越界或者数量超过范围
- 03:非法数据值,比如写线圈的值不是 0xFF00 而是别的
- 04:从站设备故障,处理时真的出错了
我之前调试遇到过:上位机工程师拿着协议文档,非要用功能码 03 去读离散输入,从站不停回异常码 01。双方扯了半天,最后发现是文档没看仔细。所以功能码和数据对象的匹配关系,一定要在协议设计阶段就定清楚。
2. RTU、ASCII、TCP 怎么选,以及那个总被问到的 485 问题
2.1 485 是物理层,MODBUS 是应用层,别再混为一谈
这是嵌入式面试八股文里的保留题目,也是实际沟通中翻车最多的地方。RS-485 是一个物理层电气标准,它规定了电压差、线缆、拓扑;MODBUS 是一个应用层报文协议,它规定了数据怎么封装、怎么解析。一个管"走什么样的路",一个管"路上说什么话"。
RS-485 是差分信号传输,A、B 两线之间的电压差来决定逻辑 0 和 1,抗干扰能力强,传输距离能到 1200 米左右,支持一主多从的半双工总线结构。这些特性决定了它在工业现场的地位。MODBUS 跑在 RS-485 上面,就是最常见的 MODBUS RTU over RS-485,也是绝大多数工业设备采用的组合。
实际接线时,A/B 别接反是最低级也最常见的错误。不同厂家对 A/B 的标注还不太一样,有的标 A+、B-,有的标 D+、D-。接反了的表现通常是完全没响应,或者偶尔收到乱码。我自己的习惯是:用万用表量一下空闲时的电压,A 线对地大约 2.5V 以上,B 线对地大约 2.5V 以下,这样就能判断哪根是 A 哪根是 B。
另外注意 RS-485 是半双工,同一时刻只能有一个设备发数据。所以主站发完请求后,必须把总线让出来给从站回复。这个"让出来"的时间控制不好,就会出现后面我会讲的方向切换问题。
2.2 RTU 与 ASCII 的取舍:效率与可读性的博弈
MODBUS 有两种串行传输模式:RTU 模式和 ASCII 模式。RTU 用二进制直接传输,每 8 位是一个字节,效率高,但不可读;ASCII 把每个字节拆成两个 ASCII 字符传输,效率差不多减半,但是数据在串口调试助手里直接就是可见的十六进制文本。
| 对比项 | MODBUS RTU | MODBUS ASCII |
|---|---|---|
| 数据格式 | 二进制字节 | 十六进制字符 |
| 校验方式 | CRC16 | LRC(纵向冗余校验) |
| 帧间隔校验 | 3.5 字符时间 | 1 字符时间 |
| 效率 | 高 | 低(约一半) |
| 排障难易 | 需要协议分析工具 | 肉眼可读,方便 |
实际项目里,我基本只推荐 RTU。ASCII 模式的历史意义在于早期通信链路可靠性差、终端可打印,现在这个优势已经没什么意义了。RTU 效率高、支持性好,市面上绝大多数从站设备默认就是 RTU。只有当你在做一些非常特殊的文本网关转发,或者对端设备只支持 ASCII 时,才需要去碰它。
2.3 TCP 模式:从串口到以太网的进化
随着工业以太网普及,MODBUS TCP 越来越常见。它和 RTU 最大的区别是传输载体从串口变成了 TCP/IP,端口号固定为 502。TCP 模式下没有 CRC 校验,因为 TCP 协议本身保证了数据完整性,取而代之的是一个 MBAP 报文头。
MBAP 头共 7 个字节:
| 字段 | 长度 | 说明 |
|---|---|---|
| 事务处理标识符 | 2 字节 | 用于匹配请求和响应 |
| 协议标识符 | 2 字节 | 0x0000 表示 MODBUS |
| 长度 | 2 字节 | 后续字节数 |
| 单元标识符 | 1 字节 | 相当于 RTU 的从站地址 |
面试题常问"RTU 和 TCP 有什么区别",标准答法就是三条:一是 RTU 有 CRC16,TCP 没有;二是 RTU 从站地址在帧首字节,TCP 的单元标识符在 MBAP 头末尾;三是 RTU 靠 3.5 字符时间间隔分帧,TCP 靠 TCP 的字节流自己处理边界。补充一点,TCP 里的事务处理标识符很重要,它让同一个 TCP 连接上可以同时发起多个未完成的请求,靠事务 ID 来区分响应对应哪个请求。RTU 一主一从一问一答的模式在 TCP 里被打破了。
做网关产品时,经常要把 RTU 转 TCP 或者 TCP 转 RTU,转换逻辑说穿了就是:把 RTU 帧的 CRC 去掉,加上 MBAP 头,从站地址挪到单元标识符位置。反过来就是从 TCP 帧里剥掉 MBAP 头,按单元标识符重新计算 CRC,封装成 RTU 帧发到串口总线上。
3. 从零手写一个 MODBUS 从站:寄存器映射、状态机与 CRC
3.1 自己写还是用现成协议栈
很多人在网上问"MODBUS 从站代码要不要自己写",我的看法很直接:如果你是在学习、样机验证、或者协议功能非常简单(就几个寄存器),自己写完全没问题,几百行代码的事;如果你是做工业级产品,功能多、要稳定、要过认证,那就用 FreeModbus 这类成熟协议栈,别重复造轮子。
FreeModbus 是嵌入式领域最常用的开源 MODBUS 协议栈,它支持 RTU、ASCII、TCP,资源占用也小,很多厂商的 SDK 里都直接集成了。它把串口接收、帧解析、功能码分发都封装好了,你只需要实现寄存器读写回调接口。不过 FreeModbus 也有学习成本,它的回调机制和定时器接口要理解清楚,否则出了问题一样一头雾水。
我自己是先把 FreeModbus 源码读了一遍,搞清楚它的状态机怎么写,然后在一个小项目里完全手写了一个精简版从站。这个过程让我对协议的掌握上了一个台阶,后面用回 FreeModbus 时,出问题也很快能定位。所以我建议你至少手写一次,不为别的,就为调试时你能一眼看出问题出在哪个环节。
3.2 寄存器映射设计:把物理量变成地址空间
写从站的第一步,是设计寄存器映射表。举一个我实际做过的温控设备例子:
#define REG_NUM 100 #define REG_TEMPERATURE 0x0000 // 当前温度,只读 #define REG_TARGET_TEMP 0x0001 // 目标温度,可写 #define REG_HEATER_ON 0x0002 // 加热开关,可写 #define REG_ALARM_TEMP 0x0003 // 报警温度,可写 #define REG_STATUS 0x0004 // 设备状态,只读 uint16_t holding_regs[REG_NUM]; // 初始化默认值 holding_regs[REG_TARGET_TEMP] = 2500; // 25.00°C,放大100倍存储 holding_regs[REG_ALARM_TEMP] = 4000; // 40.00°C这里有一个工程上非常重要的习惯:浮点数不要直接往寄存器里塞。MODBUS 寄存器是 16 位整数,浮点数要么放大 10 倍、100 倍再存,要么用两个寄存器拼一个 32 位 float。放大倍数用定点数的方式最简单直观,上位机也容易理解。我见过有人直接把 float 的四个字节硬塞进两个寄存器,结果上位机解析时大小端没对齐,调试了整整一天。
读寄存器功能码 0x03 的处理逻辑大概是:
uint8_t read_holding_regs(uint16_t start_addr, uint16_t count, uint8_t *resp) { if (start_addr + count > REG_NUM) { return EXCEPTION_ILLEGAL_ADDR; } resp[0] = count * 2; for (uint16_t i = 0; i < count; i++) { resp[1 + i * 2] = holding_regs[start_addr + i] >> 8; // 高字节 resp[2 + i * 2] = holding_regs[start_addr + i] & 0xFF; // 低字节 } return count * 2 + 1; }注意寄存器数据在 MODBUS 报文里是大端序,高字节在前。很多 MCU 是小端存储,所以发送时要手动调整字节序。我踩过这个坑,当时用结构体指针直接强转发送,上位机读到的数据全是高低字节互换的,后来老老实实按字节搬运。
3.3 串口收发的状态机设计:从字节流中分出帧
RTU 模式没有帧头帧尾,它靠的是时间间隔来分帧:一帧内部字节间隔不能超过 3.5 个字符时间,帧与帧之间至少间隔 3.5 个字符时间。
举个例子,波特率 9600 时,一个字符是 11 位(1 起始位 + 8 数据位 + 1 校验位 + 1 停止位),3.5 个字符时间大约是 3.5 × 11 / 9600 ≈ 4.0ms。也就是说,串口连续收字节时,如果两个字节之间的间隔超过 4ms,当前帧就结束了。
从站的接收状态机可以这样设计:
空闲状态 --> 收到第一个字节 --> 接收状态 接收状态 --> 字节间隔超时 T35 --> 帧接收完成 帧接收完成 --> 校验地址 --> 解析功能码 --> 执行操作 --> 发送响应实际代码里,我会用 MCU 的定时器做一个 3.5 字符时间的超时判断,每当串口收到一个字节,就重置定时器;定时器溢出则说明一帧收完了,触发帧处理函数。
volatile uint8_t rx_buf[256]; volatile uint16_t rx_len = 0; volatile uint8_t frame_ready = 0; void UART_RX_IRQHandler(void) { rx_buf[rx_len++] = UART_ReceiveByte(); T35_TIMER_Reset(); // 重置3.5字符时间定时器 } void T35_TIMER_IRQHandler(void) { if (rx_len > 0) { frame_ready = 1; // 一帧接收完成 } }主循环里检查到 frame_ready 后,先校验从站地址(是自己或者是广播地址 0x00),再校验 CRC,然后分发功能码,最后组响应帧发送。
用定时器 T35 而不是简单地用"接收中断里判断空闲"的好处是精确可控。有些工程师图省事,在串口空闲中断里分帧,这在数据量小的时候没问题,但遇到干扰或者主站发送不稳定时,错误率明显更高。
3.4 CRC16:计算法和查表法都给你
MODBUS RTU 使用 CRC16-MODBUS 算法,多项式是 0x8005(反向多项式 0xA001),初始值 0xFFFF,结果不异或。直接计算法代码很短:
uint16_t crc16_modbus(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= buf[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }这个算法每处理一个字节要循环 8 次,波特率 9600 时完全没压力,但到了 115200 甚至更高,加上其他任务,CPU 占用就不太好看了。这时候推荐查表法:
static const uint16_t crc_table[256] = { 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, // ... 完整的256项表格 }; uint16_t crc16_modbus_table(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { uint8_t idx = (uint8_t)(crc ^ buf[i]); crc = (crc >> 8) ^ crc_table[idx]; } return crc; }查表法的速度大约是计算法的 4 到 5 倍,代价是 512 字节的 ROM,对现在的 MCU 来说完全不是问题。
接下来是重点字节序问题。CRC 计算出来的 16 位校验值,发送时低字节在前、高字节在后。比如前面例子里的 CRC 是 0xC5CD(按计算顺序是高字节 C5、低字节 CD),发送时先发 CD,再发 C5。我见过不下五个项目,从站不起作用,最后查出来都是 CRC 发送顺序反了。你如果自己写协议栈,一定要记住:低字节先发。
4. 调试全流程实战:从接线到工具,一个坑一个坑跨过去
4.1 调试前的工位准备:你最少需要这几样东西
正式调 MODBUS 前,我建议你把工位准备好,别等出了问题才想起来缺工具。基本配置是:
- USB 转 RS-485 模块一个,电脑上虚拟出一个串口
- USB 转 TTL 模块一个,用来监听总线上主从之间实际跑的原始数据
- 串口调试助手,比如 SSCOM,用来手动发帧、看响应
- Modbus Poll / Modbus Slave 工具,分别用来模拟主站和从站
- 示波器或逻辑分析仪,用于排查电气层问题
我最推荐的调试拓扑是:电脑 USB 转 485 接设备的 485 口,同时用 USB 转 TTL 的 RX 引脚并联监听 485 模块的 A/B 端数据,这样既能看到电脑发了什么,也能看到设备回了什么。有的工程师只用 Modbus Poll 看结果,一旦不通就两眼一抹黑,完全不知道数据卡在哪一环。加一路监听,很多问题瞬间就有答案了。
设备上电前,先用万用表确认 485 的 A/B 电压正常,然后检查设备端有没有终端电阻,总线超过一定长度或者节点多,首尾需要并联 120 欧电阻匹配阻抗,否则波形反射会直接导致误码。
4.2 用串口调试助手手动发一帧:最直观的验证方式
在写任何代码之前,先用串口调试助手手动发一帧,确认设备本身能响应,这是最笨最有效的方法。假设设备从站地址是 01,我想读它保持寄存器从 0x0000 开始的两个寄存器,先算好 CRC,然后以十六进制发送:
01 03 00 00 00 02 C4 0B正常情况下设备会回:
01 03 04 C8 00 01 F4 xx xx这表示设备响应了 4 个数据字节,第一个寄存器值是 0xC800(十进制 51200,按放大 100 倍算就是 512.00),第二个寄存器值是 0x01F4(十进制 500,即 5.00)。如果设备不回,先检查地址对不对、波特率对不对,再看 CRC 对不对。手动发帧的好处是每一步都可控,你可以确定是我的帧不对还是设备不对,这就把问题范围缩小到只跟一台设备和一条串口线有关。
手动测试通过后,再用 Modbus Poll 这类工具去做自动化测试,效率会高很多。
4.3 案例一:从站地址不匹配,请求和响应不在一个频道
这是我的老本行里最常见的故障之一。现象是:Modbus Poll 发送超时,从站没响应,但用示波器抓波形,请求帧明明发出去了。我当时排查步骤是这样的:
第一步,监听总线。用 USB 转 TTL 监听电脑发给设备的数据,发现电脑发的是地址 01 的请求帧。第二步,查看设备实际配置的从站地址。那台设备用拨码开关设地址,我一看,拨到了 0x02。电脑发地址 01,设备地址是 02,设备根本不会理会这帧数据。
可能你会觉得这也太低级了,但实际项目里,地址不匹配的变体非常多:拨码拨错、上位机配置文件和实际不一致、设备出厂默认地址被改过、广播地址 0x00 被误用(地址 0x00 是广播,从站不应该响应)。排查这类问题,第一步永远是"确认主站请求的地址和从站实际配置的地址一致",这句话我每次调试都会先默念一遍。
4.4 案例二:485 半双工方向切换时机不对,响应数据被截断
RS-485 是半双工,发送和接收共用一对线,由 MCU 的 DE/RE 引脚控制方向。发送时拉高 DE,发送完必须拉低回到接收状态。这个切换时机如果不对,就会出现:主站偶尔能收到从站回复,但回复内容缺尾巴,或者后面跟着乱码。
原因通常是开发者用"发送完最后一个字节"作为切换时机,但实现时只等到了 TXE(发送寄存器空),而不是 TC(发送完成)。TXE 表示数据已经交给移位寄存器,但还没真正发完;TC 才表示整个字节的每一位都移出去了。如果在 TXE 就切方向,最后一个字节的后面几位会被硬生生截断。
正确的做法有两种:要么用 UART 的 TC 中断里切方向,要么在发送完最后一个字节后延时 1 到 2 个字符时间再拉低 DE。我实测下来,用 DMA 发送时,一定要在 DMA 传输完成中断里、且确认 UART 已经发出最后一个停止位之后再切换方向。有的 MCU 需要先等 TC 标志位,再操作方向引脚,顺序不能反。
这个坑特别隐蔽,因为问题不是"完全不通",而是"时通时不通"。我建议所有设计 RS-485 电路的朋友,在看协议之前先把硬件方向控制这一环想清楚,它制造的故障能让你怀疑人生。
4.5 案例三:帧间隔 3.5 字符时间引发的粘包问题
还有一个非常常见的故障:从站明明收数据了,也解析了,但就是不响应,或者响应很慢。监听数据发现:主站连续发了两帧,第一帧和第二帧之间的间隔小于 3.5 个字符时间,从站的串口把两帧数据合在一起当成一帧了,CRC 自然过不了,直接丢弃。
这问题出在主站不发,主站的发送逻辑太"着急"了。比如用 Modbus Poll 设置非常短的轮询周期,或者主站代码里发完一帧没等响应就立刻发下一帧。RTU 规范明确要求帧间隔至少 3.5 个字符时间,所以主站在连续发送时必须保证这个间隔。
对应的从站侧也要做防护:用 T35 超时来切帧,而不是简单依赖"串口空闲中断"或者"收到固定长度就处理"。我用过一个很稳的方案:串口每收到一个字节就进中断存字节并重置 T35 定时器,T35 超时后把"帧接收完成"标志位置 1。这样不管主站帧间隔有多大波动,从站都能按时间间隔正确地切出每一帧。
另外,如果总线上挂了很多从站,从站的响应延迟也要注意:协议规定从站在收到请求后,响应时间一般不能超过某个值(常见设备规格里会写),但也不能太快,有的主站对过快的响应反而不适应,因为它内部的定时器还没准备好。这种"快也错慢也错"的问题,只能用实测值来反复试。
4.6 案例四:寄存器地址从 0 还是从 1 开始的老坑
MODBUS 协议里,数据地址是从 0 开始编址的,但很多设备手册为了配合 PLC 的习惯,会用"40001"这种地址表示方法。手册上写"保持寄存器 40001",对应的 MODBUS 报文地址是 0x0000;写"40002",报文地址是 0x0001。
这个偏移量经常让人栽跟头。我遇到过:上位机工程师按手册上的 40001 直接填入主站工具的数据地址,结果主站工具把 40001 当成了协议地址,发出去的是 0x9C41,直接越界,从站回异常码 02。本质上是没有搞清"PLC 习惯地址"和"协议地址"之间的映射关系。
正确的换算方法是:协议地址 = 手册地址 - 1。比如手册写 30010(输入寄存器),协议地址就是 0x0009。如果你的从站是自己定义的,最好在协议文档里明确写出协议地址,不要用 4xxxx 的表示法,省得大家互相猜。
4.7 案例五:CRC 字节序错误,从站把每条请求都丢了
这个案例在我写第一个 MODBUS 从站时真实发生过。现象特别诡异:用串口调试助手手动发帧,计算出的 CRC 和网上在线工具一致,但设备就是不回。后来抱着试试看的心态,把 CRC 的高字节和低字节互换了一下再发,设备立刻响应了。
原因就是前面强调过的:CRC 发送时要低字节在前。很多在线工具计算出来显示的是高字节在前,比如显示 C5CD,你按 C5 CD 的顺序填入发送框,就反了。正确填入顺序是 CD C5。我后来养成了一个习惯:所有涉及发送的 CRC,都先自己用代码打出来对比一遍,确认低字节在前才发。
如果你在调试时怎么都调不通,又怀疑是 CRC 问题,最快的验证方法就是:用 Modbus Poll 这类工具发一帧,然后抓取总线上的实际数据,用你自己的 CRC 函数跑一遍,对比看是不是一致。工具生成的帧 CRC 一定是正确的,如果你的函数算出来对不上,说明你的算法或者字节序有问题。
4.8 工具链技巧:Modbus Poll 和 Modbus Slave 的正确用法
Modbus Poll 是 Windows 上模拟主站的神器,界面里可以设置从站地址、功能码、起始地址、寄存器数量、轮询周期。我一般这样配置:
- 在 Connection 里设置串口号、波特率、数据位 8、停止位 1、无校验
- Setup 里选择功能码 03,起始地址填 0,数量填寄存器表长度
- 启动轮询后,如果通信正常,右边的寄存器表格会实时刷新数据
- 通信出错时,左下角状态栏会显示错误码,比如 Timeout waiting response 或者 Exception code 01
Modbus Slave 则是用来模拟从站,当你的主站程序还没开发完,但想在电脑上先验证主站逻辑时就用它。我经常在开发网关设备时,一边用 Modbus Slave 模拟下位机,一边用实际网关设备转发数据,两边对照很快能找到问题。
还有一个很实用的技巧:Modbus Poll 同时打开"发送窗口",可以把历史上发出的每一帧都列出来,配合总线监听,能精确看到主站到底发了什么、期望收到什么。很多"从站不回"的问题,盯着这个窗口盯十分钟基本就有线索了。
5. 调测高频问题速查表:遇到故障先查这张表
最后我把这些年调试 MODBUS 遇到的高频问题整理成一个速查表。出了故障别慌,先对照一下:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 从站完全无响应 | 接线错误、A/B 接反 | 万用表量 A/B 电压,确认空闲电平 |
| 从站完全无响应 | 从站地址不匹配 | 确认主站请求地址 = 设备实际地址 |
| 从站完全无响应 | 波特率/校验位不匹配 | 用监听确认实际波特率 |
| 从站完全无响应 | CRC 算法或字节序错误 | 用 Modbus Poll 发帧抓包对比 |
| 响应乱码 | 485 方向切换过早/过晚 | 检查 TXE 与 TC 的切换时机 |
| 间歇性通信失败 | 帧间隔小于 3.5 字符时间 | 主站加延时,从站用 T35 切帧 |
| 读到数据但数值不对 | 大小端/字节序问题 | 检查寄存器高低字节顺序 |
| 返回异常码 01 | 功能码不支持 | 核对功能码与数据对象类型 |
| 返回异常码 02 | 寄存器地址越界 | 核查起始地址 + 数量是否超过范围 |
| 返回异常码 03 | 写入的值非法 | 检查写线圈是否为 0xFF00/0x0000 |
| 多个从站互相干扰 | 缺少终端电阻 | 总线首尾并联 120 欧电阻 |
| 多个从站互相干扰 | 从站地址重复 | 逐个设备确认地址拨码 |
这张表里的每一条,都是我实际踩过或者帮别人排查过的问题。你可以直接把这张表打印出来贴在工位上,省下很多重复排查的时间。
另外分享一个波形层的排查技巧:用逻辑分析仪抓 UART RX 引脚,观察两帧之间的间隔。如果间隔忽大忽小,或者小于 3.5 字符时间,基本就能定位是帧间隔问题。如果波形上沿不干净、毛刺明显,就要考虑屏蔽、接地和终端电阻的问题了。调试通信这种事,我个人的体会是:软件和协议的问题占八成,但剩下两成电气问题往往最让人崩溃,所以示波器该上还是得上。
MODBUS 协议本身不难,但真正做产品时,它的坑全藏在细节里。字节序、帧间隔、方向切换、地址映射,每一个单独拎出来都不算大事,但它们组合在一起,就足以让一个经验不足的工程师debug整整一周。希望这篇调试笔记能帮你少走一些弯路。最后再分享一个我自己的小习惯:每次调 MODBUS 前,我都会用 Modbus Poll 和设备先跑一遍全功能码测试,确认所有寄存器区间可读可写之后,再开始写应用层的业务逻辑,这样我就能确定"协议通不通"和"业务对不对"是两件独立的事,排查问题时心态会稳很多。