Modbus 协议在工业物联网中的实现

2026年8月16日 0 By admin

Modbus 协议在工业物联网中的实现

Modbus 诞生于 1979 年,由 Modicon(现施耐德电气)推出,本意是让 PLC 之间能简单可靠地交换数据。四十多年后的今天,它依然是工业现场事实上的”通用语言”——无论是老旧的 RS-485 总线,还是接入工业物联网(IIoT)网关的以太网链路,Modbus 的身影无处不在。

对嵌入式工程师来说,Modbus 有一个迷人的特质:它足够简单,简单到你可以用一块 MCU 从零实现,却又足够健壮,能撑起整个车间的数据采集。 本文从协议本质出发,带你完成一套可用的 RTU/TCP 实现,并聊聊它在 IIoT 场景下的取舍。

一、为什么 Modbus 活到了今天

现代工业通信协议层出不穷:PROFINET、EtherCAT、OPC UA、MQTT。每一个都比 Modbus”先进”,但 Modbus 依然占据着传感器、电表、变频器、温控仪等海量末端设备的通信口。原因主要有三点:

  1. 零授权成本:Modbus 是开放协议,任何厂商都能免费实现,不存在授权壁垒。
  2. 实现门槛极低:核心只有”读线圈、读寄存器、写寄存器”几个操作,一个 8 位 MCU 就能跑起来。
  3. 硬件依赖少: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 | 0x800x02 是”非法数据地址”。常见异常码:

异常码 含义
0x01 非法功能码
0x02 非法数据地址
0x03 非法数据值
0x04 从机设备故障

这个机制的价值在于:主机端能明确区分”设备不在线”和”设备在线但参数错了”,这对现场排障极其重要——超时意味着断线,异常帧意味着配置问题。

六、Modbus TCP:换汤不换药

Modbus TCP 直接把 RTU 报文塞进以太网,结构几乎相同,只做三处替换:

  1. 去掉从机地址——由 IP 地址承担寻址职责。
  2. 去掉 CRC16——由 TCP 的校验机制负责可靠性。
  3. 加上 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、电表、传感器、变频器)

工程实践中,有三个坑值得提前规避:

  1. 轮询周期设计:RS-485 是半双工总线,同一时刻只能有一个设备说话。网关轮询几十个从机时,要留出足够的帧间隔与超时余量,否则总线冲突会让数据错误率飙升。

  2. 寄存器地址偏移:不同厂商对寄存器地址的起算基准不一致——有的从 0 开始,有的从 1 开始,还有的把”40001″这种 1 基址当 4xxxx 区。对接新设备前,务必用调试助手实测确认。

  3. 字节序陷阱:32 位浮点数(如温度、电能)在 Modbus 里拆成两个 16 位寄存器,高低字顺序、字节内顺序都有 ABCD/DCBA 等多种排列。通信通了却读数离谱,十有八九是字节序没对上。

八、写在最后

Modbus 的美感在于:它用最小的复杂度,换取了最广的兼容性。 作为嵌入式工程师,实现一套 Modbus 从机,本质上是完成一次”协议层”的完整工程训练——帧解析、校验计算、状态机设计、异常处理,每一环都是基本功。

而在 IIoT 大潮下,Modbus 非但没有消亡,反而因为边缘计算的兴起找到了新的位置:它在云端协议(MQTT、OPC UA)与海量存量设备之间,架起了一座谁都绕不开的桥。理解它、吃透它,你在工业现场就多了一门”通用语言”。

建议动手实践:拿一块 STM32/ESP32,实现一个上述的 RTU 从机,然后用 PC 上的 Modbus Poll 或 QModMaster 当主机去读。当你在调试助手上第一次看到温度值正确回传时,你对工业通信的理解会上升一个台阶。