简介:这份资源是一套基于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.04ms,3.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_cast<quint8>(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-case:
void ModbusSlave::processFrame(const QByteArray &frame) { if (!verifyFrame(frame)) { // CRC 校验失败,直接丢弃 return; } quint8 slaveAddr = static_cast<quint8>(frame.at(0)); // 地址不匹配则忽略(0 是广播地址,通常不响应) if (slaveAddr != m_slaveAddress && slaveAddr != 0) return; quint8 funcCode = static_cast<quint8>(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_cast<quint8>(frame.at(0)); quint16 startAddr = (static_cast<quint8>(frame.at(2)) << 8) | static_cast<quint8>(frame.at(3)); quint16 regCount = (static_cast<quint8>(frame.at(4)) << 8) | static_cast<quint8>(frame.at(5)); // 检查地址范围是否越界 if (startAddr + regCount > m_holdingRegisters.size()) { return buildExceptionResponse(slaveAddr, 0x03, 0x02); // 非法数据地址 } QByteArray response; response.append(static_cast<char>(slaveAddr)); response.append(static_cast<char>(0x03)); response.append(static_cast<char>(regCount * 2)); // 字节数 for (int i = 0; i < regCount; ++i) { quint16 value = m_holdingRegisters.at(startAddr + i); response.append(static_cast<char>(value >> 8)); // 高字节在前 response.append(static_cast<char>(value & 0xFF)); // 低字节在后 } // 追加 CRC quint16 crc = calculateCRC(response); response.append(static_cast<char>(crc & 0xFF)); // CRC 低字节在前 response.append(static_cast<char>(crc >> 8)); // CRC 高字节在后 return response; }这里有几个容易出错的点:寄存器数据是大端序,高字节在前;CRC 是小端序,低字节在前;字节数字段是寄存器数量乘以 2。这些细节如果搞反了,主站收到的数据就是错的。
数据模型方面,m_holdingRegisters通常是一个QVector<quint16>,初始化时分配好大小,比如 100 个寄存器。线圈状态可以用QVector<bool>或者位压缩的字节数组来存。用 QVector 的好处是地址直接映射到下标,读写都方便。
4. 避坑与常见问题排查
4.1 主站发请求但 Slave 无响应
现象:主站发送读寄存器请求,串口助手上能看到数据发出,但 Slave 端没有任何响应,主站超时。
原因:最常见的是从站地址不匹配。Modbus 请求帧的第一个字节是从站地址,如果 Slave 程序里设置的地址是 1,而主站发的是 2,Slave 会直接忽略这一帧。另一个可能是串口参数不一致,比如波特率或校验位不同,导致 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(),把收发缓冲区都清干净再开始通信。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取