Modbus 协议在工业物联网中的实现
Modbus 协议在工业物联网中的实现
Modbus 诞生于 1979 年,由 Modicon(现施耐德电气)推出,本意是让 PLC 之间能简单可靠地交换数据。四十多年后的今天,它依然是工业现场事实上的”通用语言”——无论是老旧的 RS-485 总线,还是接入工业物联网(IIoT)网关的以太网链路,Modbus 的身影无处不在。
对嵌入式工程师来说,Modbus 有一个迷人的特质:它足够简单,简单到你可以用一块 MCU 从零实现,却又足够健壮,能撑起整个车间的数据采集。 本文从协议本质出发,带你完成一套可用的 RTU/TCP 实现,并聊聊它在 IIoT 场景下的取舍。
一、为什么 Modbus 活到了今天
现代工业通信协议层出不穷:PROFINET、EtherCAT、OPC UA、MQTT。每一个都比 Modbus”先进”,但 Modbus 依然占据着传感器、电表、变频器、温控仪等海量末端设备的通信口。原因主要有三点:
- 零授权成本:Modbus 是开放协议,任何厂商都能免费实现,不存在授权壁垒。
- 实现门槛极低:核心只有”读线圈、读寄存器、写寄存器”几个操作,一个 8 位 MCU 就能跑起来。
- 硬件依赖少:RTU 模式只靠串口(RS-485),ASCII 模式甚至可以直接用调试助手观察。
正因如此,Modbus 成了 IIoT 网关”向下兼容”的标配:云端要的是 MQTT/JSON,现场给的是 Modbus 寄存器,中间那层翻译就是绝大多数边缘网关的核心工作。
二、协议骨架:一个请求,一个响应
Modbus 的交互模型是严格的主从(Master/Slave)结构:主机发请求,从机回响应,从机从不主动说话。一份标准请求包含四要素:
- 从机地址(1 字节,RTU 模式)
- 功能码(1 字节)
- 数据域(内容取决于功能码)
- 校验(CRC16 或 LRC)
最常用的功能码只有寥寥几个,掌握它们就掌握了 90% 的现场需求:
| 功能码 | 名称 | 访问对象 | 数据单位 |
|---|---|---|---|
| 0x01 | 读线圈 | 位输出 | Bit |
| 0x02 | 读离散输入 | 位输入 | Bit |
| 0x03 | 读保持寄存器 | 16 位字 | Word |
| 0x04 | 读输入寄存器 | 16 位字 | Word |
| 0x05 | 写单线圈 | 位输出 | Bit |
| 0x06 | 写单寄存器 | 16 位字 | Word |
| 0x0F | 写多线圈 | 位输出 | Bit |
| 0x10 | 写多寄存器 | 16 位字 | Word |
其中 0x03(读保持寄存器) 和 0x06(写单寄存器) 是使用频率最高的两个,几乎能覆盖所有”读传感器值、写控制量”的需求。
三、RTU 帧格式与 CRC16
RTU 是串口场景下的绝对主流,帧格式非常紧凑:
+--------+--------+--------+...+--------+--------+
| 地址 | 功能码 | 数据 | | CRC低 | CRC高 |
+--------+--------+--------+...+--------+--------+
一帧读 2 个保持寄存器的请求报文如下(十六进制):
01 03 00 00 00 02 C4 0B
拆解:地址 01,功能码 03,起始寄存器 0x0000,寄存器数量 0x0002,CRC 0x0BC4。
CRC16 是 RTU 可靠性的基石,使用标准 CRC-16/MODBUS 多项式 0xA001(即多项式 0x8005 的位反转版)。下面是一个通用查表实现,可直接用于 8 位 MCU:
/* CRC-16/MODBUS 查表实现 */
static const uint16_t crc16_table[256] = {
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
/* ... 省略中间项,完整表共 256 项 ... */
0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040
};
uint16_t modbus_crc16(const uint8_t *buf, uint16_t len)
{
uint16_t crc = 0xFFFF;
while (len--) {
crc = (crc >> 8) ^ crc16_table[(crc ^ *buf++) & 0xFF];
}
return crc;
}
注意:报文中的 CRC 是低字节在前存储的。发送时先送
crc & 0xFF,再送crc >> 8。很多初学者在这里摔过跟头——用调试助手看到校验位”看起来反了”,其实只是字节序问题。
四、从零实现一个从机
从机(Slave)是嵌入式设备最常见的角色。以”一块采集温度并响应读寄存器请求的 MCU 板卡”为例,核心逻辑可以归纳为一个状态机:
/* 简化的 RTU 从机接收状态机 */
#define FRAME_MAX 256
static uint8_t rx_buf[FRAME_MAX];
static uint16_t rx_len = 0;
void uart_byte_received(uint8_t ch)
{
/* T3.5 字符间隔判断帧结束,简化版按长度累积 */
rx_buf[rx_len++] = ch;
if (rx_len >= FRAME_MAX) rx_len = 0;
}
/* 在主循环里被周期性调用 */
void modbus_poll(void)
{
if (rx_len < 4) return; /* 至少 地址+功能码+CRC */
uint16_t crc = modbus_crc16(rx_buf, rx_len - 2);
uint16_t recv_crc = rx_buf[rx_len - 2] | (rx_buf[rx_len - 1] << 8);
if (crc != recv_crc) {
rx_len = 0; /* CRC 错误,丢弃 */
return;
}
if (rx_buf[0] != SLAVE_ADDR) {
rx_len = 0; /* 不是发给本机,忽略 */
return;
}
switch (rx_buf[1]) {
case 0x03: handle_read_holding_regs(); break;
case 0x06: handle_write_single_reg(); break;
case 0x10: handle_write_multi_regs(); break;
default: send_exception(0x01); /* 非法功能码 */
}
rx_len = 0;
}
读保持寄存器的响应构造如下——注意功能码回显、字节数 = 寄存器数 × 2、数据按寄存器值高字节在前:
void handle_read_holding_regs(void)
{
uint16_t start = (rx_buf[2] << 8) | rx_buf[3];
uint16_t count = (rx_buf[4] << 8) | rx_buf[5];
if (count == 0 || count > 125) {
send_exception(0x03); /* 非法数据值 */
return;
}
uint8_t tx[3 + count * 2];
tx[0] = SLAVE_ADDR;
tx[1] = 0x03;
tx[2] = count * 2; /* 字节数 */
for (uint16_t i = 0; i < count; i++) {
uint16_t val = reg_holding[start + i]; /* 你的寄存器表 */
tx[3 + i * 2] = val >> 8; /* 高字节在前 */
tx[4 + i * 2] = val & 0xFF;
}
uint16_t crc = modbus_crc16(tx, 3 + count * 2);
uart_send(tx, 3 + count * 2);
uart_send((uint8_t)(crc & 0xFF));
uart_send((uint8_t)(crc >> 8));
}
五、异常响应:不能只回”收到”
从机遇到无法处理的请求时,不能沉默,必须回异常帧。异常响应的功能码最高位置 1,后面跟一个异常码。比如主机发了一个寄存器地址越界的读请求,从机应回:
地址 | 0x83 | 0x02
其中 0x83 = 0x03 | 0x80,0x02 是”非法数据地址”。常见异常码:
| 异常码 | 含义 |
|---|---|
| 0x01 | 非法功能码 |
| 0x02 | 非法数据地址 |
| 0x03 | 非法数据值 |
| 0x04 | 从机设备故障 |
这个机制的价值在于:主机端能明确区分”设备不在线”和”设备在线但参数错了”,这对现场排障极其重要——超时意味着断线,异常帧意味着配置问题。
六、Modbus TCP:换汤不换药
Modbus TCP 直接把 RTU 报文塞进以太网,结构几乎相同,只做三处替换:
- 去掉从机地址——由 IP 地址承担寻址职责。
- 去掉 CRC16——由 TCP 的校验机制负责可靠性。
- 加上 MBAP 头(7 字节):事务标识符(2 字节)+ 协议标识符(固定 0x0000)+ 长度(2 字节)+ 单元标识符(1 字节,通常复用原从机地址)。
一个 03 读请求的 TCP 报文(请求)长这样:
00 01 | 00 00 | 00 06 | 01 | 03 00 00 00 02
└事务─┘ └协议─┘ └长度─┘ └单元┘ └──PDU──────┘
在 Linux 下,用 raw socket 或现成库(如 libmodbus)都能轻松实现。libmodbus 的 TCP 主从代码量极少:
#include <modbus/modbus.h>
/* 主机读 10 个保持寄存器 */
modbus_t *ctx = modbus_new_tcp("192.168.1.50", 502);
modbus_connect(ctx);
uint16_t regs[10];
int rc = modbus_read_registers(ctx, 0, 10, regs);
if (rc == 10) {
/* 读取成功 */
}
modbus_close(ctx);
modbus_free(ctx);
七、Modbus 在 IIoT 架构中的位置
在工业物联网里,Modbus 几乎从不直接上云——它扮演的是边缘层与设备层的桥梁角色。典型分层如下:
云端(MQTT / OPC UA / HTTP 上报)
↑
边缘网关(协议转换:Modbus → JSON/MQTT)
↑
现场总线(RS-485 / 以太网,Modbus RTU / TCP)
↑
末端设备(PLC、电表、传感器、变频器)
工程实践中,有三个坑值得提前规避:
-
轮询周期设计:RS-485 是半双工总线,同一时刻只能有一个设备说话。网关轮询几十个从机时,要留出足够的帧间隔与超时余量,否则总线冲突会让数据错误率飙升。
-
寄存器地址偏移:不同厂商对寄存器地址的起算基准不一致——有的从 0 开始,有的从 1 开始,还有的把”40001″这种 1 基址当 4xxxx 区。对接新设备前,务必用调试助手实测确认。
-
字节序陷阱:32 位浮点数(如温度、电能)在 Modbus 里拆成两个 16 位寄存器,高低字顺序、字节内顺序都有 ABCD/DCBA 等多种排列。通信通了却读数离谱,十有八九是字节序没对上。
八、写在最后
Modbus 的美感在于:它用最小的复杂度,换取了最广的兼容性。 作为嵌入式工程师,实现一套 Modbus 从机,本质上是完成一次”协议层”的完整工程训练——帧解析、校验计算、状态机设计、异常处理,每一环都是基本功。
而在 IIoT 大潮下,Modbus 非但没有消亡,反而因为边缘计算的兴起找到了新的位置:它在云端协议(MQTT、OPC UA)与海量存量设备之间,架起了一座谁都绕不开的桥。理解它、吃透它,你在工业现场就多了一门”通用语言”。
建议动手实践:拿一块 STM32/ESP32,实现一个上述的 RTU 从机,然后用 PC 上的 Modbus Poll 或 QModMaster 当主机去读。当你在调试助手上第一次看到温度值正确回传时,你对工业通信的理解会上升一个台阶。