Qt手写Modbus RTU串口Slave端:协议解析与CRC校验实战
简介这份资源是一套基于QT框架与QSerialPort模块实现的Modbus串口从站slave程序面向学习工业通信协议、串口编程及QT应用开发的初学者与嵌入式开发者帮助理解从站如何接收并响应主站请求、完成寄存器与线圈的数据交换。压缩包共3个文件包含1个cpp源码、1个pro工程文件和1个user配置整体约12KB体量轻巧便于直接编译运行与逐行研读。源码覆盖串口初始化、信号槽监听、Modbus RTU/ASCII报文解析、功能码处理、响应报文构建及超时与校验错误处理等关键环节可帮助读者掌握QSerialPort参数配置与协议解析的完整思路。目前已有1477人学习下载适合作为串口通信与Modbus协议入门的实践参考。1. 一个串口Slave端为什么值得单独拆一遍很多做上位机或者工控项目的开发者第一次接触 Modbus 都是从 Master 端开始的——拿个库发请求读寄存器完事。但真正到了现场调试你会发现 Slave 端才是那个“玄学”最多的环节主站读不到数据到底是帧格式不对、CRC 算错还是串口参数没对齐没有 Slave 端做对照你连问题出在哪一层都判断不了。这份 QT modbus 串口 Slave 程序解决的就是这个场景。它基于 Qt 的 QSerialPort 模块实现了一个 Modbus RTU 从站能响应主站的读写请求支持线圈和寄存器的模拟数据。适合两类人一是正在做 Modbus 主从通信调试、需要一个可控 Slave 端做验证的开发者二是想拿 Qt 写串口通信、但不想从零搭框架的人。整个项目不依赖第三方 Modbus 库协议解析和 CRC 校验都是手写的拿来改寄存器映射或者加自定义功能码都很直接。2. 技术选型与整体架构为什么用 QSerialPort 手撸协议2.1 为什么不用现成的 Modbus 库Qt 生态里做 Modbus 通信常见做法有三种用 Qt 自带的 QtSerialBus 模块、用 libmodbus 这类 C 库、或者直接基于 QSerialPort 手写协议解析。这个项目选了第三种原因很实际。QtSerialBus 虽然官方支持但它的 Modbus 部分在 RTU 模式下对串口参数的控制不够细比如你想在运行中动态调整帧间隔或者手动控制 RTS 引脚会比较别扭。libmodbus 功能全但引入了一个 C 库依赖跨平台编译时多一层麻烦而且它的回调式 API 和 Qt 的信号槽机制混在一起写代码风格会很割裂。手写协议解析的好处是整个数据流完全透明。从串口收到一个字节开始到拼出一帧、校验 CRC、解析功能码、生成响应、再写回串口每一步你都能打断点看。对于调试阶段来说这种透明度比库的便利性更重要。2.2 模块划分与数据流项目的核心模块可以分成四层第一层是串口配置与收发基于 QSerialPort 和 QSerialPortInfo负责枚举可用串口、设置波特率/数据位/停止位/校验位以及字节流的读写。第二层是帧解析器负责从连续的字节流中识别出一帧完整的 Modbus RTU 报文。RTU 模式没有起始符和结束符靠的是 3.5 个字符时间的静默间隔来分帧但在实际代码里通常用定时器或者字节超时来近似实现。第三层是协议处理根据功能码分发到对应的处理函数比如读线圈、读保持寄存器、写单个寄存器等然后组装响应帧。第四层是数据模型用 QVector 或者数组模拟线圈和寄存器的存储区Slave 端的所有读写操作最终都落到这个数据模型上。数据流向是串口收到字节 → 帧解析器判断帧完整性 → 协议处理层解析功能码和地址 → 操作数据模型 → 生成响应帧 → 串口发送。2.3 关键类与信号槽设计整个项目围绕一个核心类展开通常命名为ModbusSlave或者SerialSlave它继承自 QObject内部持有 QSerialPort 实例。串口的readyRead信号连接到槽函数onReadyRead()在这个槽里做字节读取和帧解析。帧解析完成后如果确认是一帧合法的 Modbus 请求会触发一个内部信号或者直接调用处理函数。处理完成后通过 QSerialPort 的write()方法把响应帧发回去。这种设计的优点是串口读写和协议处理解耦你可以单独测试协议处理逻辑不需要真的插一个串口设备。常见做法是把协议处理部分抽成一个独立的类或者命名空间只依赖数据模型不依赖 QSerialPort。3. 核心模块拆解从串口配置到 CRC 校验的完整链路3.1 串口初始化与参数配置串口配置是 Slave 端能正常工作的前提。Modbus RTU 常见的串口参数是 9600 或 19200 波特率、8 数据位、1 停止位、无校验或者偶校验。这个项目里串口参数通常通过界面或者配置文件传入然后在初始化时设置。// 初始化串口 void ModbusSlave::initSerialPort(const QString portName, int baudRate) { serialPort new QSerialPort(this); serialPort-setPortName(portName); // 设置波特率常见 9600 / 19200 serialPort-setBaudRate(baudRate); // Modbus RTU 常用 8 数据位 serialPort-setDataBits(QSerialPort::Data8); // 通常 1 位停止位 serialPort-setStopBits(QSerialPort::OneStop); // 无校验或偶校验视主站配置而定 serialPort-setParity(QSerialPort::NoParity); // 关闭流控 serialPort-setFlowControl(QSerialPort::NoFlowControl); if (!serialPort-open(QIODevice::ReadWrite)) { // 打开失败通常是串口被占用或不存在 emit errorOccurred(serialPort-errorString()); return; } // 连接数据到达信号 connect(serialPort, QSerialPort::readyRead, this, ModbusSlave::onReadyRead); }这里有几个参数需要和主站严格对齐波特率、数据位、停止位、校验位。任何一项不一致收到的数据都会是乱码或者直接收不到。实际调试中最常见的翻车就是主站用偶校验、Slave 端设了无校验结果通信完全不通但两边都觉得自己没问题。setFlowControl(QSerialPort::NoFlowControl)这一行也值得注意。Modbus RTU 通常不用硬件流控如果误开了 RTS/CTS在某些 USB 转串口设备上会导致数据发不出去。3.2 帧接收与 3.5 字符间隔处理Modbus RTU 的帧边界靠静默间隔来界定。协议规定帧间隔至少 3.5 个字符时间在 9600 波特率下大约是 4ms。实际实现中有两种常见做法一种是用 QTimer 在收到第一个字节后启动一个 4ms 左右的定时器超时则认为一帧结束另一种是依赖 QSerialPort 的readAll()一次性读取当前缓冲区里的所有字节然后根据帧长度字段来判断。这个项目通常采用定时器方案因为更可靠。代码结构大致如下void ModbusSlave::onReadyRead() { // 把新到达的字节追加到接收缓冲区 rxBuffer.append(serialPort-readAll()); // 重启帧间隔定时器3.5 字符时间 frameTimer-start(FRAME_GAP_MS); // 例如 4ms } void ModbusSlave::onFrameTimerTimeout() { // 定时器超时认为一帧接收完毕 if (rxBuffer.isEmpty()) return; // 尝试解析这一帧 QByteArray frame rxBuffer; rxBuffer.clear(); processFrame(frame); }FRAME_GAP_MS的取值需要根据波特率调整。9600 波特率下一个字符时间约 1.04ms3.5 个字符约 3.6ms取 4ms 比较安全。如果波特率更高比如 115200这个值可以缩小到 1ms 左右。取值太大帧会被延迟处理取值太小一帧可能被拆成两段。注意有些 USB 转串口芯片的驱动会做数据缓冲导致字节到达的时间间隔不均匀这时候定时器方案可能会误判帧边界。如果遇到这种情况可以改用“根据功能码和字节数计算预期帧长”的方式来辅助判断。3.3 CRC16 校验的手写实现Modbus RTU 的 CRC 校验是绕不过去的。虽然 Qt 没有内置 Modbus CRC 函数但算法本身不复杂查表法或者逐位计算都可以。这个项目通常用逐位计算代码短容易验证。// Modbus CRC16 计算多项式 0xA001 quint16 ModbusSlave::calculateCRC(const QByteArray data) { quint16 crc 0xFFFF; for (int i 0; i data.size(); i) { crc ^ static_castquint8(data.at(i)); for (int bit 0; bit 8; bit) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }这个函数的输入是不包含 CRC 本身的报文数据输出是 16 位 CRC 值。在 Modbus RTU 帧里CRC 低字节在前、高字节在后所以组装响应帧的时候要注意字节序。校验的时候把收到的整帧数据包括末尾两字节 CRC做一次 CRC 计算如果结果是 0说明校验通过。这是 Modbus 协议的一个特性包含正确 CRC 的完整帧再算一次 CRC 会得到 0。bool ModbusSlave::verifyFrame(const QByteArray frame) { if (frame.size() 4) // 最短帧地址 功能码 CRC 两字节 return false; quint16 crc calculateCRC(frame); return (crc 0); }3.4 功能码分发与数据模型操作Modbus 的功能码决定了 Slave 要做什么。这个项目通常支持最常用的几个0x01 读线圈、0x03 读保持寄存器、0x05 写单个线圈、0x06 写单个寄存器、0x0F 写多个线圈、0x10 写多个寄存器。功能码分发的逻辑一般是一个 switch-casevoid ModbusSlave::processFrame(const QByteArray frame) { if (!verifyFrame(frame)) { // CRC 校验失败直接丢弃 return; } quint8 slaveAddr static_castquint8(frame.at(0)); // 地址不匹配则忽略0 是广播地址通常不响应 if (slaveAddr ! m_slaveAddress slaveAddr ! 0) return; quint8 funcCode static_castquint8(frame.at(1)); QByteArray response; switch (funcCode) { case 0x01: response handleReadCoils(frame); break; case 0x03: response handleReadHoldingRegisters(frame); break; case 0x05: response handleWriteSingleCoil(frame); break; case 0x06: response handleWriteSingleRegister(frame); break; case 0x0F: response handleWriteMultipleCoils(frame); break; case 0x10: response handleWriteMultipleRegisters(frame); break; default: // 不支持的功能码返回异常响应 response buildExceptionResponse(slaveAddr, funcCode, 0x01); break; } if (!response.isEmpty()) { serialPort-write(response); } }以读保持寄存器0x03为例请求帧的格式是从站地址 0x03 起始地址高字节 起始地址低字节 寄存器数量高字节 寄存器数量低字节 CRC 两字节。响应帧是从站地址 0x03 字节数 寄存器数据 CRC 两字节。QByteArray ModbusSlave::handleReadHoldingRegisters(const QByteArray frame) { quint8 slaveAddr static_castquint8(frame.at(0)); quint16 startAddr (static_castquint8(frame.at(2)) 8) | static_castquint8(frame.at(3)); quint16 regCount (static_castquint8(frame.at(4)) 8) | static_castquint8(frame.at(5)); // 检查地址范围是否越界 if (startAddr regCount m_holdingRegisters.size()) { return buildExceptionResponse(slaveAddr, 0x03, 0x02); // 非法数据地址 } QByteArray response; response.append(static_castchar(slaveAddr)); response.append(static_castchar(0x03)); response.append(static_castchar(regCount * 2)); // 字节数 for (int i 0; i regCount; i) { quint16 value m_holdingRegisters.at(startAddr i); response.append(static_castchar(value 8)); // 高字节在前 response.append(static_castchar(value 0xFF)); // 低字节在后 } // 追加 CRC quint16 crc calculateCRC(response); response.append(static_castchar(crc 0xFF)); // CRC 低字节在前 response.append(static_castchar(crc 8)); // CRC 高字节在后 return response; }这里有几个容易出错的点寄存器数据是大端序高字节在前CRC 是小端序低字节在前字节数字段是寄存器数量乘以 2。这些细节如果搞反了主站收到的数据就是错的。数据模型方面m_holdingRegisters通常是一个QVectorquint16初始化时分配好大小比如 100 个寄存器。线圈状态可以用QVectorbool或者位压缩的字节数组来存。用 QVector 的好处是地址直接映射到下标读写都方便。4. 避坑与常见问题排查4.1 主站发请求但 Slave 无响应现象主站发送读寄存器请求串口助手上能看到数据发出但 Slave 端没有任何响应主站超时。原因最常见的是从站地址不匹配。Modbus 请求帧的第一个字节是从站地址如果 Slave 程序里设置的地址是 1而主站发的是 2Slave 会直接忽略这一帧。另一个可能是串口参数不一致比如波特率或校验位不同导致 Slave 收到的字节流本身就是乱的CRC 校验通不过。解决先用串口助手抓一下 Slave 端实际收到的原始字节确认数据是否完整、CRC 是否正确。如果数据本身就是乱的检查两边串口参数。如果数据正常但没响应检查从站地址设置。4.2 CRC 校验总是失败现象Slave 端收到了数据但verifyFrame总是返回 false帧被丢弃。原因CRC 计算的范围不对。Modbus RTU 的 CRC 计算范围是从站地址到数据段的最后一个字节不包括 CRC 本身。如果计算时把 CRC 两字节也算进去了结果肯定不对。另一个可能是字节序问题有些实现里 CRC 高字节在前但 Modbus 标准是低字节在前。解决拿一个已知正确的 Modbus 帧做测试比如01 03 00 00 00 01 84 0A手动算一下 CRC 应该是0x0A84在帧里存为84 0A。用这个帧验证你的 CRC 函数。4.3 帧被拆分或合并现象主站发了一帧但 Slave 端处理了两次或者两帧被合并成一帧处理。原因帧间隔定时器的阈值设置不合理。如果阈值太小一帧数据还没收完定时器就超时了导致帧被截断如果阈值太大两帧之间的间隔被忽略两帧被合并。解决根据波特率调整FRAME_GAP_MS。9600 波特率下 4ms 比较合适115200 下可以试 1ms。如果 USB 转串口驱动有缓冲导致间隔不均匀可以改用“根据功能码计算预期帧长”的方式来判断帧完整性。4.4 写寄存器后主站读到的值没变现象主站用 0x06 功能码写了一个寄存器返回正常但再用 0x03 读的时候值还是旧的。原因写操作没有真正落到数据模型上。常见的情况是处理函数里解析出了地址和值但忘记更新m_holdingRegisters数组或者更新的是另一个副本。解决在写处理函数里加日志或者断点确认m_holdingRegisters[startAddr] value这一行确实执行了。另外注意地址越界检查如果写地址超出了数组范围应该返回异常响应而不是静默失败。4.5 串口打开失败现象程序启动后串口打不开提示“No such file or directory”或者“Permission denied”。原因串口名不对或者串口被其他程序占用。在 Linux 下还可能是权限问题普通用户没有/dev/ttyUSB0的读写权限。解决用QSerialPortInfo::availablePorts()枚举当前可用串口确认名字。Linux 下把用户加入dialout组或者用sudo运行。Windows 下注意 COM 号是否被其他软件占用。5. 二次开发与调试技巧从改寄存器映射到加自定义功能码拿到这个项目之后最直接的改造就是调整寄存器映射。比如你的实际设备有 200 个保持寄存器但项目里默认只开了 100 个那就改初始化时的数组大小。如果寄存器地址不是从 0 开始的而是在协议里定义了偏移量可以在读写函数里加一个偏移常量。// 在初始化时调整寄存器数量 m_holdingRegisters.resize(200); // 如果协议地址从 40001 开始加一个偏移 static const quint16 ADDR_OFFSET 40001; quint16 actualAddr startAddr - ADDR_OFFSET;加自定义功能码也不复杂。在processFrame的 switch-case 里加一个 case然后实现对应的处理函数。比如有些设备用 0x41 做自定义的批量读你只需要按照协议格式解析请求、组装响应就行。CRC 和帧发送的逻辑可以直接复用。调试的时候我一般会加一个“原始帧日志”开关把收到的每一帧原始字节和解析结果都打到日志里。这样主站发什么、Slave 回什么一目了然。Qt 里可以用qDebug()配合QByteArray::toHex()输出十六进制。// 在 processFrame 开头加日志 qDebug() RX: frame.toHex( ); // 在发送响应前加日志 qDebug() TX: response.toHex( );还有一个实用技巧加一个“模拟数据变化”的定时器让某些寄存器的值周期性变化比如每秒加一。这样主站读的时候能看到数据在动方便验证通信是否真的通了而不是读到一个静态的初始值就以为成功了。最后说一个我踩过的坑。有一次调试时主站始终读不到数据查了半天 CRC、地址、波特率都没问题最后发现是 Slave 程序里串口打开后没有清空接收缓冲区上一次调试残留的数据干扰了帧解析。从那以后我每次打开串口后都强制走一遍serialPort-clear()把收发缓冲区都清干净再开始通信。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取