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;
}
注意: 异常响应时,功能码要加上0x80。比如请求是0x03,异常响应就是0x83。这是协议规定的,别搞错了。

SVG:Modbus RTU请求-响应流程

主机 (Master) 从机 (Slave) 请求帧 (功能码+数据) 解析 & 执行 响应帧 (数据/异常码) 接收 & 校验 帧间隔:3.5字符时间 RTU靠静默时间区分帧 超过1.5字符时间视为帧结束

避坑指南:我踩过的那些坑

  • CRC校验位置:CRC只校验从机地址、功能码和数据部分,不包含CRC自身。我曾经把CRC也拿去算,结果永远对不上。
  • 字节序:Modbus RTU是大端模式(高字节在前)。但有些设备厂商会搞错,如果你发现数据对不上,试试交换高低字节。
  • 广播地址0:地址0是广播地址,所有从机都要接收,但不响应。我见过有人把从机地址设为0,结果整个网络瘫痪。
  • 寄存器数量限制:一次读取的寄存器数量不能超过125个(0x7D)。这是协议规定的,防止缓冲区溢出。
调试小技巧: 用串口助手或者Modbus调试工具(比如ModScan)先模拟主机,看从机返回的数据对不对。我习惯先用Python写个简单的测试脚本,调通了再集成到C++项目里。

扩展:Modbus TCP的实现差异

TCP版本和RTU主要有三点不同:

  1. 没有了CRC校验,因为TCP/IP协议栈自己会做校验。
  2. 增加了一个MBAP报文头(7字节),包含事务标识符、协议标识符、长度等。
  3. 没有了从机地址(但在MBAP里有一个单元标识符,用于网关场景)。

说白了,TCP就是把RTU的帧去掉CRC,前面加个MBAP头,然后通过socket发送。实现起来反而更简单。

总结

Modbus协议不复杂,但它是工业通信的基石。实现一遍,你就能理解主从架构、数据封装、校验和异常处理这些通用概念。我个人建议你从RTU开始,用串口连接两个设备实际测试一下。当你看到自己写的代码成功读取到传感器的数据时,那种成就感,嗯,很爽。

记住:协议是死的,但实现是活的。多考虑边界情况,多测试异常场景,你的代码才会更健壮。


公众号:蓝海资料掘金营,微信deep3321