目录
一、什么是 Modbus
Modbus是一种开放、简单、广泛使用的工业通信协议,由美国 Modicon 公司(现施耐德电气)于 1979 年发明,最初用于其生产的可编程逻辑控制器(PLC)之间通信。Modbus 是工业自动化领域事实上的标准,被誉为"工业界的 HTTP"——几乎所有工业设备都支持它。
Modbus 是应用层协议,独立于底层物理层和传输层。它有三大主流变体:
- Modbus RTU:基于 RS-232/RS-485 串行链路,二进制紧凑编码,是最常见的串行 Modbus
- Modbus ASCII:同样基于串行链路,但用 ASCII 字符(0-9, A-F)表示数据,可读性更好但效率低
- Modbus TCP:基于以太网 TCP/IP,去除 CRC(由 TCP 自带校验),用 MBAP 头替代从站地址
Modbus 是真正的"开放协议":① 规范公开免费;② 无专利授权费;③ 任何厂商可自由实现;④ 已被纳入 IEC 61158 和 IEC 61784 标准。这种开放性使其在 40 多年后仍占据工业通信"半壁江山"。
二、Modbus 与其他工业协议对比
Modbus 在工业通信生态中扮演"入门级"角色,与 EtherCAT、PROFINET、CANopen 等形成互补。
| 对比维度 | Modbus RTU | Modbus TCP | EtherCAT | CANopen |
|---|---|---|---|---|
| 物理层 | RS-485/RS-232 | 以太网 100M | 以太网 100M | CAN 双绞线 |
| 通信模型 | 主从轮询 | 客户端/服务端 | 主从 + 飞越读取 | 主从 + 事件 |
| 周期速度 | 10~100ms | 1~10ms | 0.1ms | 1~10ms |
| 节点数 | 32(RS-485) | 理论无限 | 65535 | 127 |
| 同步性 | 无 | 无 | DC 纳秒级 | SYNC 同步 |
| 开放性 | 完全开放 | 完全开放 | 开放 | 开放 |
| 实现成本 | 极低 | 低 | 高 | 中 |
| 典型场景 | 仪表/变频器 | SCADA/IO | 多轴伺服 | 移动机械 |
• 简单仪表读取、对实时性要求不高(>100ms) → Modbus RTU(最便宜、最普及)
• 需要远程监控、对接 SCADA/上位机 → Modbus TCP(IT 友好、易扩展)
• 需要微秒级多轴运动控制 → EtherCAT(见同系列 EtherCAT 文章)
• 车载、移动设备 → CANopen(抗干扰、节点多)
三、Modbus 通信模型:主从架构
Modbus 采用经典的主从(Master/Slave)架构。在 RTU 中叫"主站/从站",在 TCP 中改叫"客户端/服务端"但本质相同:只有主站能主动发起请求,从站只能被动响应。
3.1 三种通信模式
3.2 一问一答的时序
Modbus RTU 在同一 RS-485 总线上只能有一个主站。如果需要多主站共享从站,必须用 Modbus TCP(多客户端可同时连接同一服务端,但服务端仍需处理并发)。
四、Modbus 数据模型:四类数据
Modbus 把所有数据抽象为四类数据表,每类有不同的读写权限和数据宽度。这是 Modbus 设计上最优雅、最经典的部分。
Modbus 历史使用 5 位十进制编号,首位区分类型:
•
0xxxx → 线圈(可读写 1 位)•
1xxxx → 离散输入(只读 1 位)•
3xxxx → 输入寄存器(只读 16 位)•
4xxxx → 保持寄存器(可读写 16 位)注意:协议帧中实际传输的"寄存器地址"是去掉首位后的0 基偏移。例如 40001 对应协议地址 0x0000。
4.1 大端字节序与 32 位数据
Modbus 寄存器宽度为 16 位,但很多实际数据(如 IEEE 754 浮点、32 位整数)需要 32 位。这时需要用两个连续寄存器存储一个 32 位数据,这就涉及字节序问题。
不同厂家设备使用不同字节序!施耐德 PLC 通常用 CDAB,西门子用 ABCD,部分国产仪表用 BADC。读取 32 位数据前必须先确认设备文档,或在调试时观察原始字节流判断。
五、Modbus 功能码详解
功能码(Function Code)是请求帧的"动作指令",告诉从站要做什么操作。功能码占 1 个字节,0x01~0x06 是最常用的 6 个。
| 功能码 | 操作 | 数据类型 | 典型用途 |
|---|---|---|---|
| 0x01 | 读线圈 | 多个 Bit(可读写) | 批量读取继电器输出状态 |
| 0x02 | 读离散输入 | 多个 Bit(只读) | 批量读取开关量输入 |
| 0x03 | 读保持寄存器 | 多个 Word(可读写) | 读设定值、运行参数 |
| 0x04 | 读输入寄存器 | 多个 Word(只读) | 读温度、电压等采集值 |
| 0x05 | 写单个线圈 | 1 个 Bit | 启停单台电机 |
| 0x06 | 写单个保持寄存器 | 1 个 Word | 设定单个目标值 |
| 0x0F | 写多个线圈 | 多个 Bit | 批量控制继电器 |
| 0x10 | 写多个保持寄存器 | 多个 Word | 批量设定参数 |
| 0x17 | 读/写多个寄存器 | 多个 Word | 原子操作:先读后写 |
5.1 异常响应与异常码
当从站无法处理请求时(如地址越界、功能码不支持),会返回异常响应——把功能码最高位置 1(异或 0x80),并附带异常码。
| 异常码 | 名称 | 含义 |
|---|---|---|
| 0x01 | Illegal Function | 不支持此功能码 |
| 0x02 | Illegal Data Address | 地址越界 |
| 0x03 | Illegal Data Value | 值超出范围 |
| 0x04 | Slave Device Failure | 从站内部故障 |
| 0x05 | Acknowledge | 已收到,处理中(长任务) |
| 0x06 | Slave Device Busy | 从站忙,请重试 |
| 0x0A | Gateway Path Unavailable | 网关路径不可用 |
六、Modbus RTU:串行链路实现
Modbus RTU(Remote Terminal Unit)是 Modbus 在串行链路上的实现,物理层通常用 RS-485(多节点)或 RS-232(点对点)。它使用二进制紧凑编码,比 ASCII 模式效率高出一倍。
6.1 RTU 的帧定界难题
RTU 帧没有起始/结束标志符,依靠 3.5 个字符时间的空闲间隔作为帧边界。这是一个聪明但容易出错的设计。
• Linux 串口驱动可能因中断合并、调度延迟导致帧内字符间隔超过 1.5 字符,造成"帧分裂"
• 高波特率(115200)下 3.5 字符时间不足 1ms,普通操作系统难以保证
• 解决方案:① 用低波特率(9600/19200);② 使用带硬件 FIFO 的串口芯片(如 FT232);③ 启用串口低延迟模式(Linux TTY 低延迟标志)
七、Modbus RTU 帧结构
RTU 帧由从站地址、功能码、数据、CRC16四部分组成,无起始/结束标志,最紧凑高效。
7.1 三种帧类型对比
八、CRC16 校验:可靠性的基石
Modbus RTU 使用 CRC-16-Modbus算法(多项式 0xA001,即 0x8005 反转),对地址、功能码、数据全部计算 2 字节校验值,低字节在前传输。这是 RTU 抗干扰的关键。
8.1 CRC16 C 语言实现
// CRC-16-Modbus 算法(查表法,速度优先) #include <stdint.h> static const uint16_t crc_table[256] = { 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440, // ...(共 256 项,此处省略,可在线生成) }; uint16_t modbus_crc16(const uint8_t *data, size_t len) { uint16_t crc = 0xFFFF; while (len--) { crc = (crc >> 8) ^ crc_table[(crc ^ *data++) & 0xFF]; } return crc; } // 用法:构造帧时附加 CRC(低字节在前) uint8_t frame[8] = {0x01, 0x03, 0x00, 0x0A, 0x00, 0x02}; uint16_t crc = modbus_crc16(frame, 6); frame[6] = crc & 0xFF; // 低字节 frame[7] = (crc >> 8) & 0xFF; // 高字节 // 结果:frame = 01 03 00 0A 00 02 25 CA
九、Modbus TCP:以太网实现
Modbus TCP(也叫 Modbus TCP/IP)于 1997 年发布,将 Modbus 应用层协议承载在标准 TCP/IP 协议栈之上,使用固定端口 502,让工业设备能直接接入 IT 网络。
9.1 Modbus TCP 的关键变化
9.2 TCP 网络拓扑
十、Modbus TCP 帧结构(MBAP 头)
Modbus TCP 帧由 MBAP 头(7 字节)+ PDU(协议数据单元)组成。MBAP 头替代了 RTU 的从站地址和 CRC。
• 事务 ID:客户端自增计数,用于异步请求-响应配对。响应帧必须回填相同 ID
• 协议 ID:0x0000 表示 Modbus 协议(保留给未来其他协议)
• 长度:从单元 ID 起的字节数(含单元 ID + PDU)
• 单元 ID:用于通过 TCP-RTU 网关访问后端 RTU 从站;直连设备通常用 0x00 或 0xFF
十一、Modbus RTU vs TCP 全面对比
| 对比维度 | Modbus RTU | Modbus TCP |
|---|---|---|
| 物理层 | RS-485 / RS-232 | 以太网 100M/1G |
| 通信模型 | 主从(轮询) | 客户端/服务端(并发) |
| 寻址方式 | 1 字节从站地址(1~247) | IP 地址 + 单元 ID(0~255) |
| 帧定界 | 3.5 字符空闲时间 | TCP 字节流(无需定界) |
| 校验 | CRC16(必选) | TCP 校验和(无需 CRC) |
| 波特率 | 1200~115200 bps | 100 Mbps+ |
| 响应时间 | 10~100 ms | 1~10 ms(局域网) |
| 节点数 | ≤32(RS-485 标准) | 理论无限(受带宽限制) |
| 拓扑 | 总线型 | 星型/树型 |
| 多主 | 不支持 | 支持(多客户端) |
| 穿透 NAT | 不支持 | 支持(可上云) |
| 布线成本 | 低(双绞线) | 中(网线 + 交换机) |
| 实时性 | 低(10ms 级) | 中(1ms 级,无确定性) |
| 典型场景 | 现场仪表组网 | 车间 SCADA、上云 |
十二、Modbus 特性与能力矩阵
12.1 Modbus 的优势
- 简单:协议规范仅 100 多页,一周即可开发主从站
- 开放:无版权、无授权费、文档公开
- 普及:几乎所有工业设备(PLC、变频器、仪表、IO)都支持
- 稳定:40 多年验证,无重大缺陷
- 灵活:可承载于 RS-232/485/TCP/UDP 等多种底层
- 易调试:抓包工具丰富(Modbus Poll/Slave、Wireshark)
12.2 Modbus 的局限
- 无同步机制:无法满足多轴运动控制(不能与 EtherCAT DC 比)
- 主从模型:RTU 不能多主,从站无法主动上报
- 响应慢:RTU 9600bps 读 10 个寄存器约需 30ms
- 地址空间小:每类数据仅 9999 个
- 无安全机制:明文传输、无认证、无加密(Modbus TCP 可被嗅探)
- 数据模型简单:仅支持 Bit/Word,复杂数据需用户自定义拆分
Modbus 协议本身无任何安全机制!Modbus TCP 直接暴露 502 端口到公网将面临扫描、未授权访问、数据篡改等风险。生产环境务必:① 通过防火墙白名单限制访问;② 使用 VPN 接入;③ 不直接暴露到互联网;④ 关键操作前用 Modbus 网关做协议过滤。
十三、Modbus 应用场景全景
13.1 经典案例:电力监控系统
十四、代码实战:Qt/C++ Modbus RTU + TCP
Qt 提供了官方的 QtSerialBus 模块(含 Modbus RTU/TCP 主从端封装),但需要单独编译。下面我们用更通用的方式:RTU 用开源 libmodbus 库,TCP 用 Qt 网络模块自行实现,便于跨平台部署。
14.1 基于 libmodbus 的 RTU 主站封装
libmodbus 是 C 语言编写的轻量 Modbus 库,支持 RTU/TCP,被工业界广泛使用。我们用 Qt 包装它。
// ModbusRtuMaster.h —— 基于 libmodbus 的 Qt 封装 #ifndef MODBUSRTUMASTER_H #define MODBUSRTUMASTER_H #include <QObject> #include <QTimer> #include <QVector> #include <QDebug> #include "modbus.h" // libmodbus 头文件 class ModbusRtuMaster : public QObject { Q_OBJECT public: explicit ModbusRtuMaster(QObject *parent = nullptr) : QObject(parent), m_ctx(nullptr) {} ~ModbusRtuMaster() { if (m_ctx) modbus_free(m_ctx); } // 初始化串口 bool init(const QString& port, int baud, int dataBits = 8, int parity = 'N', int stopBits = 1) { // 创建 RTU 上下文:"/dev/ttyUSB0" 或 "COM3" m_ctx = modbus_new_rtu(port.toLatin1().constData(), baud, parity, dataBits, stopBits); if (!m_ctx) return false; // 设置从站地址 modbus_set_slave(m_ctx, 1); // 设置响应超时 500ms uint32_t tv_sec = 0, tv_usec = 500000; modbus_set_response_timeout(m_ctx, tv_sec, tv_usec); // 启用调试输出(开发期) modbus_set_debug(m_ctx, TRUE); if (modbus_connect(m_ctx) == -1) { modbus_free(m_ctx); m_ctx = nullptr; return false; } return true; } // 读保持寄存器(功能码 0x03) QVector<uint16_t> readHoldingRegisters(int slave, int addr, int num) { modbus_set_slave(m_ctx, slave); QVector<uint16_t> dest(num); int rc = modbus_read_registers(m_ctx, addr, num, dest.data()); if (rc == -1) { qWarning() << "读寄存器失败:" <&slave << addr; return {}; } return dest; } // 写单个寄存器(功能码 0x06) bool writeSingleRegister(int slave, int addr, uint16_t value) { modbus_set_slave(m_ctx, slave); return modbus_write_register(m_ctx, addr, value) != -1; } // 读线圈(功能码 0x01) QVector<uint8_t> readCoils(int slave, int addr, int num) { modbus_set_slave(m_ctx, slave); QVector<uint8_t> dest(num); int rc = modbus_read_bits(m_ctx, addr, num, dest.data()); if (rc == -1) return {}; return dest; } // 写多个线圈(功能码 0x0F) bool writeMultipleCoils(int slave, int addr, const QVector<uint8_t>& values) { modbus_set_slave(m_ctx, slave); return modbus_write_bits(m_ctx, addr, values.size(), values.constData()) != -1; } private: modbus_t *m_ctx; }; #endif
14.2 完整 Modbus TCP 客户端(纯 Qt 实现)
下面用 Qt 的 QTcpSocket 手工实现 Modbus TCP 客户端,体现协议帧构造原理。
// ModbusTcpClient.h —— 纯 Qt 实现的 Modbus TCP 客户端 #ifndef MODBUSTCPCLIENT_H #define MODBUSTCPCLIENT_H #include <QObject> #include <QTcpSocket> #include <QQueue> #include <QDebug> #include <QDataStream> struct ModbusRequest { quint16 transId; // 事务 ID(配对用) quint8 unitId; // 单元 ID quint8 funcCode; // 功能码 QByteArray payload; // PDU 数据 }; class ModbusTcpClient : public QObject { Q_OBJECT public: explicit ModbusTcpClient(QObject *parent = nullptr) : QObject(parent), m_transId(0) { connect(&m_socket, &QTcpSocket::readyRead, this, &ModbusTcpClient::onReadyRead); connect(&m_socket, &QTcpSocket::connected, this, &ModbusTcpClient::onConnected); connect(&m_socket, &QTcpSocket::disconnected, this, &ModbusTcpClient::onDisconnected); } // 连接服务端 void connectToServer(const QString& host, quint16 port = 502) { m_socket.connectToHost(host, port); } // 读保持寄存器(0x03) quint16 readHoldingRegisters(quint8 unitId, quint16 addr, quint16 count) { QByteArray pdu; QDataStream ds(&pdu, QIODevice::WriteOnly); ds.setByteOrder(QDataStream::BigEndian); ds << addr << count; // 起始地址 + 数量(4 字节) return sendRequest(unitId, 0x03, pdu); } // 写单个寄存器(0x06) quint16 writeSingleRegister(quint8 unitId, quint16 addr, quint16 value) { QByteArray pdu; QDataStream ds(&pdu, QIODevice::WriteOnly); ds.setByteOrder(QDataStream::BigEndian); ds << addr << value; return sendRequest(unitId, 0x06, pdu); } signals: void responseReceived(quint16 transId, quint8 funcCode, const QByteArray& data); void errorOccurred(const QString& msg); private slots: void onReadyRead() { while (m_socket.bytesAvailable() >= 9) { // MBAP 头 7B + 至少 2B QByteArray mbap = m_socket.peek(7); QDataStream ds(mbap); ds.setByteOrder(QDataStream::BigEndian); quint16 transId, protoId, length; quint8 unitId; ds >> transId >> protoId >> length >> unitId; if (m_socket.bytesAvailable() < 7 + length - 1) break; m_socket.read(7); // 丢弃 MBAP 头 QByteArray pdu = m_socket.read(length - 1); quint8 funcCode = (quint8)pdu[0]; QByteArray data = pdu.mid(1); if (funcCode & 0x80) { qWarning() << "Modbus 异常:" << (int)(pdu[1] & 0xFF); errorOccurred("Modbus 异常响应"); } else { responseReceived(transId, funcCode, data); } } } void onConnected() { qDebug() << "Modbus TCP 已连接"; } void onDisconnected() { qDebug() << "Modbus TCP 已断开"; } private: quint16 sendRequest(quint8 unitId, quint8 funcCode, const QByteArray& payload) { quint16 transId = ++m_transId; quint16 length = 1 + 1 + payload.size(); // 单元 ID + 功能码 + PDU QByteArray frame; QDataStream ds(&frame, QIODevice::WriteOnly); ds.setByteOrder(QDataStream::BigEndian); ds << transId // 事务 ID(2B) << (quint16)0 // 协议 ID(2B,恒 0) << length // 长度(2B) << unitId // 单元 ID(1B) << funcCode // 功能码(1B) << payload; // 数据区 m_socket.write(frame); m_socket.flush(); return transId; } QTcpSocket m_socket; quint16 m_transId; }; #endif
14.3 使用示例:电力仪表数据采集
// 电力采集主流程 #include "ModbusRtuMaster.h" #include "ModbusTcpClient.h" #include <QCoreApplication> #include <QTimer> int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); // === RTU 示例:读 1 号仪表的电压、电流、功率 === ModbusRtuMaster rtu; if (!rtu.init("COM3", 9600, 8, 'N', 1)) { qCritical() << "串口打开失败"; return -1; } // 假设仪表寄存器表: // 0x4000 = 电压 (0.1V),0x4001 = 电流 (0.01A),0x4002 = 功率 (0.1W) auto regs = rtu.readHoldingRegisters(1, 0x4000, 3); if (regs.size() == 3) { qInfo() << "电压:" << regs[0] / 10.0 << "V"; qInfo() << "电流:" << regs[1] / 100.0 << "A"; qInfo() << "功率:" << regs[2] / 10.0 << "W"; } // === TCP 示例:远程控制变频器启停 === ModbusTcpClient tcp; QObject::connect(&tcp, &ModbusTcpClient::responseReceived, [](quint16 transId, quint8 funcCode, const QByteArray& data){ qDebug() << "收到响应:" << transId << funcCode << data.toHex(); }); tcp.connectToServer("192.168.1.10", 502); // 延迟 1s 后发送命令 QTimer::singleShot(1000, [&](){ // 写寄存器 0x0001 = 0x0001(启动变频器) tcp.writeSingleRegister(0xFF, 0x0001, 0x0001); }); return app.exec(); }
• 采集多块仪表时建议按波特率计算总轮询时间,确保 1 秒内能完成所有从站
• 9600bps 下读 1 块仪表 3 个寄存器约需 30ms,10 块仪表串行轮询需 300ms
• 实际工程中常做分组并行:每个 RS-485 总线一个线程,同时采集
• Modbus TCP 利用异步特性可同时发多个请求,配对事务 ID 提升吞吐
十五、调试技巧与常见问题
15.1 常用调试工具
| 工具 | 用途 | 平台 |
|---|---|---|
| Modbus Poll | RTU/TCP 主站模拟器,可视化寄存器 | Windows |
| Modbus Slave | 从站模拟器,配合 Poll 测试 | Windows |
| Wireshark | TCP Modbus 协议解析 | 跨平台 |
| comto / 串口助手 | RTU 原始字节流抓取 | Windows |
| mbpoll (CLI) | 命令行 Modbus 主站 | Linux/Win |
| diagslave | 开源从站模拟器 | 跨平台 |
15.2 八大常见故障
| 故障现象 | 可能原因 | 排查方法 |
|---|---|---|
| 从站不响应 | 波特率/校验位不匹配;从站地址错误;接线反 | 用串口助手对比波特率;确认 485 A/B 极性 |
| CRC 校验错 | 帧内字符间隔过大;线材质量差引入干扰 | 示波器检查总线波形;缩短总线长度 |
| 偶发通信失败 | 终端电阻缺失;节点过多;波特率过高 | 两端加 120Ω 电阻;节点超过 32 用中继器 |
| 响应数据错乱 | 多主站同时访问;时序混乱 | 同一 RS-485 仅一个主站;加锁防并发 |
| TCP 连接被拒 | 服务端 502 端口未开放;防火墙拦截 | telnet 测试端口;检查防火墙规则 |
| 32 位数据错 | 字节序不匹配(ABCD vs CDAB) | 读两寄存器原始字节,按 4 种序试解 |
| 异常码 0x02 | 寄存器地址越界(含 0 基索引偏移) | 查仪表文档,确认协议地址 = 显示地址 - 1 |
| 异常码 0x06 | 从站忙(如正在写参数) | 主站重试机制,间隔 100~500ms |
① 永远先确认波特率、校验位、停止位三者匹配,80% 的 RTU 故障源于此
② 检查终端电阻:RS-485 总线两端必须各加 120Ω,否则信号反射
③ 确认从站地址:仪表面板显示地址 ≠ 协议帧地址(前者多为 1~247,后者相同但需正确编码)
④ 抓不到 RTU 帧?检查串口是否支持低延迟模式,关闭 VTIME/VMIN 缓冲
十六、总结
Modbus 是工业通信领域的"普通话"——简单、开放、普及。它不是最快的,不是最强的,但因为它足够简单、足够开放,成为了所有工业设备都支持的"最低公约数"。
核心要点速记
- 主从架构是 Modbus 的灵魂——主站主动询问,从站被动响应,简单可靠
- 四类数据:线圈/离散输入/输入寄存器/保持寄存器,覆盖开关量输入输出和模拟量输入输出
- 功能码 0x01~0x06 是日常主力,覆盖 90% 应用场景
- RTU 用二进制 + CRC16 + 3.5 字符定界,TCP 用 MBAP 头 + TCP 自带校验
- 32 位数据 字节序是常见陷阱,不同厂家不同序(ABCD / CDAB / BADC / DCBA)
- RTU 选型:现场设备少、距离远、对延迟要求不高
- TCP 选型:需要 IT 对接、多主访问、上云或跨网段
- 调试三要素:① 波特率/校验位 ② 终端电阻 ③ 从站地址
- 安全:Modbus 无内建安全机制,Modbus TCP 切勿直接暴露公网
- 定位:Modbus 是"工业通信的普通话"——不是最快,但所有人都会说
嵌入式工业通信系列至此涵盖 RS-232/485 → CAN → 串口全景 → EtherCAT → Modbus 五大主题。Modbus 与 EtherCAT 互补:Modbus 负责"最后一公里"采集与 IT 对接,EtherCAT 负责实时运动控制。两者结合,覆盖了工业通信 95% 以上的需求。