博客的第一篇技术文,从 Modbus 开始。它是工控通讯里的”普通话”——几乎所有 PLC、仪表、变频器、传感器都认它。这篇文章分两部分:先把一帧报文逐字节 + 图解看懂;再把我在 RS-485 现场踩过、见过的坑,按物理层 / 时序层 / 协议层分类列全

一句话理解 Modbus

Modbus 是主从(Master / Slave)+ 一问一答的协议:只有主站发起请求,从站被动应答。

1
2
3
4
5
6
7
sequenceDiagram
participant M as 主站 Master
participant S as 从站 Slave
M->>S: 请求帧(功能码 + 地址 + 数量)
Note over S: 校验 CRC → 处理请求
S-->>M: 响应帧(数据 或 异常码)
Note over M: 校验 CRC → 解析数据

学 Modbus 核心就三件事:数据放在哪(数据模型)、怎么点名(功能码 + 地址)、报文长什么样(帧结构)

数据模型:四种”区”

数据区 读写 位宽 传统地址 典型用途
线圈 Coils 读 / 写 1 bit 0xxxx 开关量输出(继电器、DO)
离散输入 Discrete Inputs 只读 1 bit 1xxxx 开关量输入(按钮、DI)
输入寄存器 Input Registers 只读 16 bit 3xxxx 采集量(温度、AI)
保持寄存器 Holding Registers 读 / 写 16 bit 4xxxx 参数、设定值(最常用)

实际项目里 90% 的时间你在和”保持寄存器”打交道

RTU 帧结构:图解 + 逐字节

Modbus RTU 一帧的骨架:

1
2
3
4
5
┌───────────┬──────────┬──────────────────┬───────────┐
│ 从站地址 │ 功能码 │ 数据 │ CRC │
│ 1 字节 │ 1 字节 │ N 字节 │ 2 字节 │
└───────────┴──────────┴──────────────────┴───────────┘
01 03 00 00 00 02 C4 0B

以”读从站 1 号、保持寄存器从地址 0 开始的 2 个寄存器“为例,主站发:

1
01 03 00 00 00 02 C4 0B
字节 含义
1 01 从站地址 = 1
2 03 功能码 = 读保持寄存器
3–4 00 00 起始地址 = 0
5–6 00 02 寄存器数量 = 2
7–8 C4 0B CRC(低字节在前)

两个容易翻车的细节:

  1. 地址和数量是大端(高字节在前):00 02 = 2
  2. CRC 是低字节在前:真实值是 0x0BC4,发送顺序却是 C4 0B。”数据大端、CRC 小端”的不一致,是新手第一个坑

从站的响应

从站读到 2 个寄存器,值 0x000A(10) 和 0x0064(100),回复:

1
01 03 04 00 0A 00 64 DB DA
字节 含义
1 01 从站地址
2 03 功能码(正常回显)
3 04 后面数据字节数 = 4
4–5 00 0A 寄存器 1 = 10
6–7 00 64 寄存器 2 = 100
8–9 DB DA CRC

CRC 校验

Modbus RTU 用 CRC-16/MODBUS:多项式 0xA001,初值 0xFFFF。C 实现(后面通讯库直接用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdint.h>

uint16_t modbus_crc16(const uint8_t *data, uint16_t len) {
uint16_t crc = 0xFFFF;
for (uint16_t i = 0; i < len; i++) {
crc ^= data[i];
for (int j = 0; j < 8; j++) {
if (crc & 0x0001)
crc = (crc >> 1) ^ 0xA001;
else
crc >>= 1;
}
}
return crc; // 发送时:低字节在前,高字节在后
}

拿它算 01 03 00 00 00 02,得 0x0BC4,发送 C4 0B——和报文对得上。

写操作 & 异常响应

写单个保持寄存器(0x06),把地址 1 写成 0x0003

1
2
请求:01 06 00 01 00 03 98 0B
响应:01 06 00 01 00 03 98 0B ← 原样回显表示成功

如果从站处理不了,会回异常响应:功能码最高位置 1(0x030x83),后跟异常码:

1
01 83 02 C0 F1     ← 83=读保持寄存器出错, 02=非法数据地址

主站怎么判断收到的是数据还是异常?流程如下:

1
2
3
4
5
6
flowchart TD
A[收到响应帧] --> B{CRC 校验通过?}
B -->|否| C[丢弃 / 重发]
B -->|是| D{功能码最高位 = 1 ?}
D -->|是 0x8X| E[异常响应<br/>读异常码定位问题]
D -->|否| F[正常数据<br/>按字节数解析寄存器]

常见异常码:01 非法功能码、02 非法数据地址(最常见)、03 非法数据值、04 从站故障、06 从站忙。

Modbus TCP:换了个信封

到以太网上,去掉 CRC(TCP 保证可靠),前面加 7 字节 MBAP 头

1
2
3
4
5
6
┌─────────────── MBAP 头(7 字节)──────────────┬───── PDU ─────┐
│ 事务ID │ 协议ID │ 长度 │ 单元ID │ 功能码 + 地址 + 数量 │
│ 2字节 │ 2字节 │ 2字节 │ 1字节 │ │
├─────────┼─────────┼─────────┼────────┼─────────────────────────┤
│ 00 01 │ 00 00 │ 00 06 │ 01 │ 03 00 00 00 02 │
└─────────┴─────────┴─────────┴────────┴─────────────────────────┘

从”功能码”往后(PDU),RTU 和 TCP 完全一样。所以写库时应把 PDU 的组包/解包做成公共层,RTU、TCP 只是外面套不同的”信封”。


RS-485 现场踩坑大全

协议看懂只是第一步。真正让人熬夜的,是 485 总线上那些”时好时坏”的毛病。按层次分类列一遍:

一、时序层(最隐蔽,最容易”偶发”)

1. RTU 的 3.5 字符帧间隔

RTU 没有起止符,靠”总线静默 ≥ 3.5 个字符时间”来判断一帧结束:

1
2
3
4
帧1 最后一字节                      帧2 第一字节
│ │
...[CRC_H]│◄──── 静默 ≥ 3.5 字符时间 ────►│[地址]...
总线空闲,谁都不发
  • 现象:偶发”解析失败””长度不对”,尤其高波特率下
  • 原因:115200 下 3.5 字符时间才约 0.3 ms,普通串口/USB 转串口根本保证不了这么精确的间隔,两帧被”粘”在一起,或一帧被拆成两段
  • 解决:接收端用”空闲超时”重组帧(收到数据后启动定时器,超过约 1.75~3.5 字符时间没有新字节就认为一帧结束);低速场景可适当放宽;对时序敏感的场合直接上带硬件 T35 定时的 MCU 串口

2. 收发方向切换(半双工的命门)

RS-485 是半双工,靠 DE/RE 引脚切换收发方向:

  • 现象:从站响应的最后一个字节或 CRC 经常丢,或者主站收到自己发的数据
  • 原因:发送完成后过早把方向切回接收——此时 UART 移位寄存器里最后一个字节还没真正发到线上,就被截断了
  • 解决:一定要等发送完成中断(TC / TXE,不是 TXE 缓冲空) 再切方向;切换点前后各留一点死区时间。这是自己写 485 驱动时最经典的一个坑

3. USB 转串口的 latency timer

  • 现象:用电脑 + USB 转串口调试时莫名丢帧、响应慢,换到嵌入式设备上反而正常
  • 原因:FTDI 等芯片默认 latency timer 16 ms,会把零散字节攒一批再上报,彻底打乱帧间隔
  • 解决:把 latency timer 调到 1~2 ms(驱动高级设置里改);这个坑排查半天,改一个参数就好

二、物理层(接线和电阻)

4. 终端电阻(120Ω)

1
2
3
4
5
6
7
8
9
   主站            从站1          从站2               从站N
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ M │ │ S1 │ │ S2 │ ...... │ Sn │
└─┬──┬─┘ └─┬──┬─┘ └─┬──┬─┘ └─┬──┬─┘
│ │ │ │ │ │ │ │
═══╪══╪════════════╪══╪═══════════╪══╪═══════════════╪══╪═══ A(D+)
═══╪══╪════════════╪══╪═══════════╪══╪═══════════════╪══╪═══ B(D-)
[120Ω] [120Ω]
▲ 总线两端各一个 120Ω 终端电阻,中间的从站都不接
  • 现象:短距离好好的,拉长或提速后误码、丢包
  • 原因:阻抗不匹配产生信号反射;缺失、或中间也乱接终端电阻都会出问题
  • 解决只在总线物理两端各接一个 120Ω,中间节点不接

5. 偏置 / 失效保护电阻(failsafe bias)

  • 现象:总线空闲时从站偶尔收到乱码、假帧
  • 原因:所有节点都不发送时,A/B 之间电平悬空、状态不确定,可能被噪声解析成起始位
  • 解决:在总线上加一组上/下拉偏置电阻,保证空闲时是确定的”1”(很多带失效保护的收发器已内置)

6. 共地问题

  • 现象:通讯时好时坏,严重时收发器烧毁
  • 原因:485 虽是差分,但仍需地参考。多设备不共地、或地电位差过大,共模电压超出收发器范围
  • 解决:单独走一根信号地;跨设备/远距离用隔离型 485 收发器或磁隔离

7. A/B(D+/D-)接反、拓扑不对

  • 现象:完全通讯不上,或整条总线不稳
  • 原因:A/B 接反;不同厂家 A/B 定义还可能相反;用了星型/长分支而不是手拉手菊花链,产生反射
  • 解决:接反了对调即可(不会烧);严格走菊花链,分支尽量短

三、协议 / 配置层

8. 地址偏移:40001 还是 0?

组态里填 40001,报文里发的却是 0——那个”4”只表示保持寄存器区,真实偏移是 地址 - 40001

  • 现象:整体读不到、或数据错位一个寄存器
  • 解决:跨品牌对接先问清对方文档给的是”传统地址”还是”协议地址”

9. 32 位数 / 浮点的字序

一个 32 位整数或 float 占两个寄存器,谁在前 Modbus 没强制规定,于是 ABCDCDABBADCDCBA 四种字序现场都能遇到。

  • 现象:整数/浮点数值离谱,但又不像是通讯错误
  • 解决:先怀疑字序,四种挨个试;库里要把字序做成可配置项

10. 串口参数不匹配 / 地址冲突 / 广播

  • 参数:波特率、校验位、停止位有一个不对就全乱码——最基础也最常见,先查这个
  • 地址冲突:两个从站同地址,总线互相打架,表现为随机错乱
  • 广播(地址 0):写广播时从站不响应,别傻等超时

小结

Modbus 协议本身很简单:地址 + 功能码 → 一问一答 → 校验。但工程上 80% 的时间花在上面那些 RS-485 现场坑里——时序(帧间隔、收发切换)、物理(终端/偏置/共地)、配置(地址/字序/参数)。把这份清单存下来,现场排障能少熬很多夜。

接下来我会开源一个用 C/C++ 写的多协议通讯库,第一个实现 Modbus RTU/TCP。这篇里提到的 PDU 公共层、CRC、字序处理、帧间隔重组、方向切换时序,都会变成实实在在的代码。进展同步在这里。

现场还踩过哪些这里没列到的坑?欢迎评论区或加我微信一起补充。