54、C++项目实战:Modbus协议实现
Modbus协议,说实话,是工业自动化领域里最“老资格”的通信协议之一。我最早接触它是在一个工厂的数据采集项目里,当时设备全是PLC,上位机要读数据,一查资料,好家伙,全是Modbus。它简单、稳定、开放,直到今天,新出的设备也几乎都支持它。
今天我们就手撸一个Modbus协议的C++实现。别怕,这东西逻辑不复杂,但细节挺多。我会把我在项目中踩过的坑、总结的经验都揉进去。
Modbus协议的核心概念
Modbus本质上是一个主从(Master/Slave)协议。一个总线上只有一个主机(比如你的上位机),多个从机(比如传感器、PLC)。主机发起请求,从机响应。从机之间不通信。
它有两种常见的物理层实现:
- Modbus RTU:基于串口(RS232/RS485),数据是二进制传输,效率高。工业现场最常用。
- Modbus TCP:基于以太网,数据包封装在TCP/IP里。适合远程监控。
我个人习惯先从RTU开始实现,因为它更贴近底层,能让你真正理解协议的精髓。TCP无非是在外面套了一层壳。
数据模型与功能码
Modbus把数据分成了四个区,你可以理解为四个“表格”:
| 对象类型 | 访问方式 | 地址范围 | 数据大小 |
|---|---|---|---|
| 线圈(Coil) | 读写 | 00001-09999 | 1 bit |
| 离散输入(Discrete Input) | 只读 | 10001-19999 | 1 bit |
| 输入寄存器(Input Register) | 只读 | 30001-39999 | 16 bit |
| 保持寄存器(Holding Register) | 读写 | 40001-49999 | 16 bit |
功能码就是指令。比如:
0x01:读取线圈状态0x03:读取保持寄存器0x06:写单个保持寄存器0x10:写多个保持寄存器
嗯,这里要注意:地址在报文里是从0开始的,但用户看到的地址是从1开始的。比如用户说“读40001”,报文里实际地址是0x0000。这个偏移量,我当年第一次实现时就搞混了,调试了半天。
报文结构(RTU)
RTU的报文没有起始位和停止位,它靠时间间隔来区分帧。帧结构如下:
| 从机地址 (1 byte) | 功能码 (1 byte) | 数据 (N bytes) | CRC校验 (2 bytes) |
CRC校验用的是CRC-16(Modbus),多项式是0x8005。这个校验很重要,我曾经在一个项目中因为CRC计算错误,导致从机一直不响应,排查了整整一天。
下面是我常用的CRC计算函数:
uint16_t crc16_modbus(const uint8_t* data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_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;
}
这个函数我用了很多年,没出过问题。注意返回的CRC是低字节在前,高字节在后,发送时要调换顺序。
C++实现:从机端核心类设计
我们来实现一个Modbus RTU从机。我习惯把协议解析和硬件操作分开。先定义一个数据存储区:
class ModbusSlave {
public:
ModbusSlave(uint8_t slave_id) : id_(slave_id) {}
// 设置/获取保持寄存器
void setHoldingRegister(uint16_t addr, uint16_t value) {
if (addr < holding_regs_.size()) {
holding_regs_[addr] = value;
}
}
uint16_t getHoldingRegister(uint16_t addr) const {
if (addr < holding_regs_.size()) {
return holding_regs_[addr];
}
return 0;
}
// 处理接收到的请求帧
std::vector<uint8_t> handleRequest(const std::vector<uint8_t>& request);
private:
uint8_t id_;
std::vector<uint16_t> holding_regs_{100}; // 默认100个寄存器
std::vector<bool> coils_{100}; // 默认100个线圈
};
核心是handleRequest函数。它会解析请求,根据功能码执行操作,然后组装响应帧。我建议用switch-case来处理功能码,清晰明了。
解析与响应:以功能码0x03为例
功能码0x03是“读取保持寄存器”。请求帧格式:
| 从机地址 | 0x03 | 起始地址高 | 起始地址低 | 寄存器数量高 | 寄存器数量低 | CRC低 | CRC高 |
响应帧格式:
| 从机地址 | 0x03 | 字节数 | 数据... | CRC低 | CRC高 |
实现代码:
std::vector<uint8_t> ModbusSlave::handleRequest(const std::vector<uint8_t>& req) {
std::vector<uint8_t> resp;
if (req.size() < 4) return resp; // 太短,忽略
uint8_t slave_id = req[0];
if (slave_id != id_ && slave_id != 0) return resp; // 不是发给我的,或者不是广播
uint8_t func = req[1];
// 校验CRC(略,实际项目中必须做)
resp.push_back(slave_id);
resp.push_back(func);
switch (func) {
case 0x03: {
uint16_t start_addr = (req[2] << 8) | req[3];
uint16_t quantity = (req[4] << 8) | req[5];
if (quantity > 125) { // 异常:数量太多
resp[1] |= 0x80;
resp.push_back(0x02); // 异常码
break;
}
resp.push_back(quantity * 2); // 字节数
for (uint16_t i = 0; i < quantity; ++i) {
uint16_t val = getHoldingRegister(start_addr + i);
resp.push_back(val >> 8); // 高字节
resp.push_back(val & 0xFF); // 低字节
}
break;
}
// 其他功能码...
default: {
resp[1] |= 0x80;
resp.push_back(0x01); // 不支持的功能码
break;
}
}
// 添加CRC
uint16_t crc = crc16_modbus(resp.data(), resp.size());
resp.push_back(crc & 0xFF);
resp.push_back(crc >> 8);
return resp;
}
SVG:Modbus RTU请求-响应流程
避坑指南:我踩过的那些坑
- CRC校验位置:CRC只校验从机地址、功能码和数据部分,不包含CRC自身。我曾经把CRC也拿去算,结果永远对不上。
- 字节序:Modbus RTU是大端模式(高字节在前)。但有些设备厂商会搞错,如果你发现数据对不上,试试交换高低字节。
- 广播地址0:地址0是广播地址,所有从机都要接收,但不响应。我见过有人把从机地址设为0,结果整个网络瘫痪。
- 寄存器数量限制:一次读取的寄存器数量不能超过125个(0x7D)。这是协议规定的,防止缓冲区溢出。
扩展:Modbus TCP的实现差异
TCP版本和RTU主要有三点不同:
- 没有了CRC校验,因为TCP/IP协议栈自己会做校验。
- 增加了一个MBAP报文头(7字节),包含事务标识符、协议标识符、长度等。
- 没有了从机地址(但在MBAP里有一个单元标识符,用于网关场景)。
说白了,TCP就是把RTU的帧去掉CRC,前面加个MBAP头,然后通过socket发送。实现起来反而更简单。
总结
Modbus协议不复杂,但它是工业通信的基石。实现一遍,你就能理解主从架构、数据封装、校验和异常处理这些通用概念。我个人建议你从RTU开始,用串口连接两个设备实际测试一下。当你看到自己写的代码成功读取到传感器的数据时,那种成就感,嗯,很爽。
记住:协议是死的,但实现是活的。多考虑边界情况,多测试异常场景,你的代码才会更健壮。
公众号:蓝海资料掘金营,微信deep3321