简介:基于Qt框架和串口通信模块开发的一个Modbus从站程序,面向工业自动化、物联网及嵌入式通信开发者,支持与PLC等主站设备互联,适合作为学习Modbus协议与Qt串口编程的入门样例。程序实现了从站的核心流程:配置串口参数、监听接收数据、解析主站报文、根据功能码读写寄存器或线圈,并构造响应帧回传,同时包含超时和校验异常处理。压缩包共三个文件,包含一个源程序文件、一个工程构建文件和一个用户配置文件,整体大小仅12KB,文件数量少、结构紧凑,适合逐行阅读核心代码。目前已有1476人学习下载。通过研读源码,可以掌握串口参数设置、信号槽监听机制、Modbus报文格式以及响应构建方法,对开发实际的工业通信项目有直接参考意义,尤其适合快速搭建Modbus从站测试环境。
1. QT Modbus 串口 Slave 从站程序:从串口到从站的完整链路
第一次调串口从站程序的人,往往以为“从站”就是串口收到什么就回什么。直到你拿 USB 转串口接上主站,发一帧 01 03 00 00 00 0A,程序没反应。抓包一看,请求帧地址对、功能码对、CRC 也对,就是从站静默。问题通常不是 CRC 写错了,而是帧间隔定时器设得太短,一帧被拆成两段,程序在长度校验阶段就把整包丢了。这类 QT Modbus 串口 Slave 程序资源,核心就是用 QSerialPort 接收 RTU 请求、解析功能码、读写保持寄存器并回帧,中间涉及串口参数、帧间隔、CRC 校验和异常码几个环节。刚做设备调试的人需要它来快速验证主站逻辑,做上位机联调的人需要它当仿真从站用,都绕不开这些细节。
2. 架构先行:为什么用手写帧解析而不是 QModbusServer
2.1 QSerialPort 与 QModbusServer 的取舍
Qt 官方其实提供了 QModbusServer 类,封装了大部分 RTU 协议逻辑,新建工程时用它会省很多事。但我在这里建议用手写 QSerialPort,理由是调试时能看清每一字节。QModbusServer 把帧拆分、CRC 校验、异常响应都封装在库里,联调出问题时你拿到的是库抛出的错误码,而不是原始报文。在真实设备调试里,主站发过来一帧异常帧,你更需要知道它错在地址还是错在长度,这时候手写解析的价值就体现出来了。另一个常见理由是定制化需求——有些设备会拓展自己的功能码,或者要按厂商规定返回特殊错误码,库封装不够灵活。如果你的项目本身已经在用 QSerialPort 做收发,改造成从站程序也只是加一层解析逻辑的事。
2.2 单线程事件循环够用,先不要上多线程
串口从站的流量一般不大,每秒几十帧已经是比较高的负载。Qt 的串口读写本身是异步的,readyRead 信号触发时数据已经在缓冲区里,不涉及耗时阻塞操作。所以一个 QObject 派生类加一个事件循环线程就足够了,不需要单独开工作线程。有些开发者一上来就搞两个线程,读线程负责收帧、写线程负责回帧,反而引入线程安全问题——串口设备在同一时刻只允许一个线程访问,多线程意味着要加锁,加锁之后又可能产生死锁或者阻塞事件循环。
我一般会把收发逻辑放在同一个对象里,通过信号槽驱动。串口收到数据,触发 readyRead,把数据追加到缓冲区并重置帧定时器;帧定时器超时说明一个完整帧已经收完,进入解析流程。整个过程只跑在 Qt 主事件循环里,天然是单线程的,不需要考虑并发冲突。这种方式在数据量不高的场景下性能完全够用,代码也好维护。
2.3 数据流设计:缓冲区、帧定时器、寄存器表
先把这个从站类的骨架搭出来,后续帧解析都围绕这个结构展开:
class ModbusSlave : public QObject { Q_OBJECT public: explicit ModbusSlave(QObject *parent = nullptr); bool openSerialPort(const QString &portName, int baudRate); void setSlaveAddress(quint8 addr); void setRegisterCount(int count); // 保持寄存器数量 private slots: void handleReadyRead(); // 串口数据到达 void handleFrameTimer(); // 帧间隔定时器触发 private: void processFrame(const QByteArray &frame); quint16 calcCRC(const QByteArray &data); void handleReadHoldingRegisters(const QByteArray &frame); void handleWriteSingleRegister(const QByteArray &frame); void handleWriteMultipleRegisters(const QByteArray &frame); void sendErrorResponse(quint8 func, quint8 errorCode); QSerialPort *m_port = nullptr; // 串口对象 QByteArray m_buffer; // 累积收到的原始数据 QTimer *m_frameTimer = nullptr; // 帧间隔定时器 int m_frameIntervalMs = 15; // 帧间隔阈值,可调 quint8 m_slaveAddr = 1; // 从站地址 QVector<quint16> m_registers; // 保持寄存器数据区 };这个设计里三个成员各司其职。m_buffer 负责累积串口数据,因为 readyRead 不一定一次收完一帧,可能收了一半就触发信号,也可能一次性收了好几帧,所以必须用缓冲区暂存。m_frameTimer 是判断一帧是否结束的关键,它每收到新数据就重置,只有连续 m_frameIntervalMs 毫秒没有新数据,才认为一帧传输完了。m_registers 就是标准的 Modbus 保持寄存器区,每个元素是一个 16 位无符号整数,对应地址从 0 开始的寄存器。
这种“缓冲区 + 帧定时器”的组合是串口从站最常见的架构,比用固定长度判断帧结束要稳妥得多。因为串口是流式传输,没有消息边界,一帧的结束只能靠时间间隔来判断。
2.4 接线与硬件层注意事项
软件之外要提醒一句:从站程序能不能收到完整帧,跟硬件接线和串口线质量关系很大。RS485 半双工模式下,A/B 线接反会把数据线电平颠倒,表现出来就是主站一发数据,从站收到一堆乱码或者干脆没反应。我用示波器看过这种现象,A/B 接反时波形上下颠倒,CRC 校验几乎不可能通过。排查办法是先用串口助手把主站发的原始数据读出来,看第一个字节是否等于从站地址,如果连地址都不对,基本可以怀疑硬件链路。确认链路没问题后,再回头看程序逻辑。
3. 串口参数与帧间隔配置:改错一个校验位,联调失败一整天
3.1 串口参数初始化与寄存器表映射
串口参数直接影响两边能不能正常通信。主站和从站必须完全一致,否则数据全是乱码。打开串口的初始化代码大致是这么写的:
bool ModbusSlave::openSerialPort(const QString &portName, int baudRate) { m_port = new QSerialPort(this); m_port->setPortName(portName); m_port->setBaudRate(baudRate); // 波特率,如9600 m_port->setDataBits(QSerialPort::Data8); // 8位数据位,RTU标准 m_port->setParity(QSerialPort::NoParity); // 无校验位 m_port->setStopBits(QSerialPort::OneStop); // 1位停止位 m_port->setFlowControl(QSerialPort::NoFlowControl); // 关闭流控 if (!m_port->open(QIODevice::ReadWrite)) { qWarning() << "串口打开失败:" << m_port->errorString(); return false; } m_registers.fill(0x0000, 64); // 默认64个保持寄存器 connect(m_port, &QSerialPort::readyRead, this, &ModbusSlave::handleReadyRead); connect(m_frameTimer, &QTimer::timeout, this, &ModbusSlave::handleFrameTimer); return true; }串口参数这里有几个坑。数据位用 8 位是 Modbus RTU 的标准配置,有些设备支持 7 位数据位,但那是给 ASCII 模式用的,RTU 模式下必须用 8 位。校验位默认是无校验,但有些老设备用偶校验,这时候主站和从站的校验位不一致,表现就是能收到数据但 CRC 校验经常不过。我调过一个温度采集模块,主站用偶校验,从站程序用无校验,结果 CRC 一直接收失败,最后查半天才发现是校验位配置项没对齐。
初始化参数时需要确认,主站软件里的数据位、停止位、校验位和代码里的配置完全一致。实际联调时先发一帧短命令验证,能收到正常响应再继续。
3.2 帧间隔时间怎么算:从 3.5 个字符时间说起
Modbus RTU 协议规定,帧与帧之间的间隔至少是 3.5 个字符时间,一个字符在串口上传输需要 11 位(1 起始位 + 8 数据位 + 1 校验位 + 1 停止位)。所以帧间隔的计算公式是:
间隔时间 = 3.5 × 11 / 波特率
按这个公式算,9600 波特率下间隔约 4.01ms,19200 波特率下约 2.0ms,115200 波特率下只有 0.335ms。实际写代码时,我不会直接用这个理论值,而是把定时器设到 10~20ms 左右。原因有两个:一是 Windows 和 Linux 下定时器精度受系统调度影响,设太短可能因为定时器抖动导致一帧没收完就触发处理;二是 USB 转串口设备的数据传输有批量延迟,一次 readyRead 可能把好几帧内容一起带过来,帧间隔设太紧会误拆。
定时器触发的处理逻辑是这样的:
void ModbusSlave::handleReadyRead() { m_buffer.append(m_port->readAll()); // 先追加到缓冲区 m_frameTimer->start(m_frameIntervalMs); // 每收到数据就重置定时器 }这里的关键点是 start 方法是重启定时器,不是重新创建。只要还有新数据到达,定时器就不断往后推,直到没有后续数据,定时器超时执行。超时后在 handleFrameTimer 里对整个缓冲区做一次完整解析,解析完清空缓冲区,准备接收下一帧。这套逻辑对连续多帧粘在一起的情况也有效,因为定时器超时后会把缓存里的所有帧一次性处理掉。
3.3 寄存器数量与地址范围的检查
保持寄存器数量决定从站能响应多大的地址范围。通信协议里,寄存器地址是 16 位无符号数,理论范围是 0~65535。实际设备里往往只有几十个寄存器,超出范围的访问应该返回异常码 0x02(非法数据地址)。
我建议在初始化时把寄存器表大小固定下来,不要动态扩容。因为主站可能并发访问多个寄存器,如果从站表大小不固定,主站发一帧读 125 个寄存器的请求,程序在越界检查时要特殊处理初始值。固定大小后代码简单很多:
m_registers.fill(0x0000, 128); // 128个保持寄存器,地址0~127将寄存器数量设到 128,满足大多数测试场景。后续如果需要更多寄存器,改这里一个数字即可。地址越界检查在功能码处理函数里做,回头第 4 章详细展开。
4. 核心代码:单帧解析、CRC 校验与三个常用功能码
4.1 帧解析入口:长度检查与 CRC 校验的双保险
当帧定时器超时,缓冲区里应该有一整帧 RTU 请求。在动手处理功能码之前,先做两件事:检查帧长是否合法,检查 CRC 是否正确。RTU 帧结构是地址 1 字节 + 功能码 1 字节 + 数据 N 字节 + CRC 2 字节,最短的帧是广播写命令也有 8 字节。小于 8 字节的帧直接丢弃,不用回帧。
void ModbusSlave::handleFrameTimer() { if (m_buffer.isEmpty()) { return; } // 把缓冲区当成一帧处理,RTU帧至少8字节(地址+功能码+数据+CRC2字节) if (m_buffer.size() < 8) { m_buffer.clear(); return; } // 校验CRC:只计算地址+功能码+数据部分,最后两字节是收到的CRC quint16 crcReceived = ((quint8)m_buffer.at(m_buffer.size() - 2)) | ((quint8)m_buffer.at(m_buffer.size() - 1) << 8); quint16 crcCalculated = calcCRC(m_buffer.left(m_buffer.size() - 2)); if (crcCalculated != crcReceived) { m_buffer.clear(); return; // CRC错误,静默丢弃,不回错误帧 } processFrame(m_buffer.left(m_buffer.size() - 2)); m_buffer.clear(); }这里有个容易看晕的细节:CRC 在 RTU 帧里是低字节在前、高字节在后存储。所以组合 crcReceived 时,最后一个字节是低 8 位,倒数第二个字节是低 8 位左移 8 位。计算端收到的低位就是 CRC 的低位。如果读到的是“高字节在前”,说明主站发帧时字节序配置有误,需要回主站检查。
CRC16 的计算逻辑看起来长,其实很机械,就是多项式 0xA001 的移位异或:
quint16 ModbusSlave::calcCRC(const QByteArray &data) { quint16 crc = 0xFFFF; for (int i = 0; i < data.size(); ++i) { crc ^= (quint8)data.at(i); // 异或当前字节到低8位 for (int bit = 0; bit < 8; ++bit) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; // LSB为1时右移并异或多项式 } else { crc >>= 1; // LSB为0时只右移 } } } return crc; }这个函数是 Modbus RTU 的标准 CRC 计算,不用改,直接用。多项式 0xA001 和初值 0xFFFF 是协议规定的固定参数,换了任何一个数,算出来的结果都对不上。调试时如果 CRC 不过,先在计算器里用已知帧验证一下函数对不对,比如地址 01、功能码 03、数据 00 00 00 0A,它的 CRC 是多少可以预先算出来,再用代码跑一遍对比。
4.2 03 功能码:读保持寄存器并拼装响应帧
03 功能码是最常用的读操作,主站发请求后,从站把指定地址范围的寄存器内容填到响应帧里。请求帧数据区是 2 字节起始地址加 2 字节寄存器数量,响应帧数据区是 1 字节字节数加 N 个 16 位寄存器值。
void ModbusSlave::handleReadHoldingRegisters(const QByteArray &frame) { // frame不含最后2字节CRC if (frame.size() < 5) { return; } quint16 startAddr = ((quint8)frame.at(2) << 8) | (quint8)frame.at(3); quint16 count = ((quint8)frame.at(4) << 8) | (quint8)frame.at(5); if (count < 1 || count > 125) { sendErrorResponse(0x03, 0x03); // 非法数据值 return; } if (startAddr + count > (quint16)m_registers.size()) { sendErrorResponse(0x03, 0x02); // 非法数据地址 return; } QByteArray resp; resp.append((char)m_slaveAddr); resp.append(0x03); resp.append((char)(count * 2)); // 数据字节数 = 寄存器数 × 2 for (int i = 0; i < count; ++i) { quint16 value = m_registers[startAddr + i]; resp.append((char)((value >> 8) & 0xFF)); // 高字节在前 resp.append((char)(value & 0xFF)); // 低字节在后 } quint16 crc = calcCRC(resp); resp.append((char)(crc & 0xFF)); // CRC低字节在前 resp.append((char)((crc >> 8) & 0xFF)); m_port->write(resp); }寄存器值的字节序是高位在前,跟 CRC 的低位在前相反。这个顺序一旦写反,主站收到的数值会错位。比如寄存器值是 0x1234,正确返回是 12 34,如果错成 34 12,主站解析到的是 0x3412,数值完全变了。调试时如果发现寄存器值乘以或除以 256,基本就是字节序的问题,先把这里检查一遍。
4.3 06 与 10 功能码:写单个和写多个保持寄存器
写单个寄存器用 06 功能码,请求帧数据区是 2 字节寄存器地址 + 2 字节写入值。正常响应就是把请求原样回给主站,很多初学者容易忘了回帧,结果主站一直报超时。
void ModbusSlave::handleWriteSingleRegister(const QByteArray &frame) { if (frame.size() != 8) { // 地址1+功能码1+地址2+值2+CRC2,但CRC已剥离所以是6 sendErrorResponse(0x06, 0x03); return; } quint16 regAddr = ((quint8)frame.at(2) << 8) | (quint8)frame.at(3); quint16 regValue = ((quint8)frame.at(4) << 8) | (quint8)frame.at(5); if (regAddr >= (quint16)m_registers.size()) { sendErrorResponse(0x06, 0x02); return; } m_registers[regAddr] = regValue; // 响应帧直接原样返回,加上CRC QByteArray resp = frame.left(6); // 不含CRC的部分 quint16 crc = calcCRC(resp); resp.append((char)(crc & 0xFF)); resp.append((char)((crc >> 8) & 0xFF)); m_port->write(resp); }写多个寄存器用 10(十六进制 0x10) 功能码,数据区结构是地址 2 字节 + 数量 2 字节 + 字节数 1 字节 + 数据 N 字节。解析时注意字节数必须等于数量乘以 2,如果主站发来的数据长度跟数量不匹配,说明帧有问题,返回异常码 0x03。
void ModbusSlave::handleWriteMultipleRegisters(const QByteArray &frame) { if (frame.size() < 9) { sendErrorResponse(0x10, 0x03); return; } quint16 startAddr = ((quint8)frame.at(2) << 8) | (quint8)frame.at(3); quint16 count = ((quint8)frame.at(4) << 8) | (quint8)frame.at(5); quint8 byteCount = (quint8)frame.at(6); if (byteCount != count * 2) { sendErrorResponse(0x10, 0x03); // 字节数与数量不匹配 return; } if (startAddr + count > (quint16)m_registers.size()) { sendErrorResponse(0x10, 0x02); return; } int dataIndex = 7; for (int i = 0; i < count; ++i) { quint16 value = ((quint8)frame.at(dataIndex) << 8) | (quint8)frame.at(dataIndex + 1); m_registers[startAddr + i] = value; dataIndex += 2; } // 写多个的响应帧只回起始地址和数量 QByteArray resp; resp.append((char)m_slaveAddr); resp.append(0x10); resp.append((char)((startAddr >> 8) & 0xFF)); resp.append((char)(startAddr & 0xFF)); resp.append((char)((count >> 8) & 0xFF)); resp.append((char)(count & 0xFF)); quint16 crc = calcCRC(resp); resp.append((char)(crc & 0xFF)); resp.append((char)((crc >> 8) & 0xFF)); m_port->write(resp); }跟 06 功能码不同,写多个寄存器的响应帧不包含实际写入的数据,只回地址和数量,这是协议规定的格式。主站收到这个响应就知道写成功了,不需要逐一确认值。
4.4 异常响应与广播地址的处理
当请求有问题时,从站需要回一个异常帧。异常帧的格式是功能码的最高位置 1,表示异常响应,再加一个字节的异常码。常用异常码是:0x01 非法功能、0x02 非法数据地址、0x03 非法数据值。
void ModbusSlave::sendErrorResponse(quint8 func, quint8 errorCode) { QByteArray resp; resp.append((char)m_slaveAddr); resp.append((char)(func | 0x80)); // 功能码最高位置1 resp.append((char)errorCode); quint16 crc = calcCRC(resp); resp.append((char)(crc & 0xFF)); resp.append((char)((crc >> 8) & 0xFF)); m_port->write(resp); }另一个容易忽略的细节是广播地址 0x00。Modbus 协议规定,地址 0 是广播地址,主站发广播帧时所有从站都要处理,但不需要回任何响应帧。从站程序里收到地址为 0 的请求,执行写操作后直接清空缓冲区返回,不回帧。如果不加这个判断,从站会回一个地址 0 的响应帧,主站收到后可能因为地址不匹配而忽略,甚至误报错误。
5. 避坑记录:串口从站联调的五个典型问题
5.1 现象一:主站请求帧正常,从站就是不回帧
现象:主站软件发送 01 03 00 00 00 0A,串口助手确实收到了这 8 个字节,CRC 按工具计算也正确,但从站程序没有任何输出。
原因:这通常不是 CRC 或地址问题,而是帧间隔定时器设得太短。假设波特率 115200 下理论帧间隔只有 0.335ms,但 USB 转串口芯片的数据传输存在延迟,主站发出的 8 个字节可能被拆成两次到达,中间间隔超过定时器阈值。程序以为一帧已经结束,实际上只收到了前 4 个字节,长度不足 8 就丢弃了。
解决:把 m_frameIntervalMs 从默认值 15ms 再改大到 20~30ms。在 Windows 系统上,定时器精度受系统时钟和 CPU 调度影响,极端情况下误差可能达到 10ms 以上。调大帧间隔后,两段数据会在缓冲区内被拼接成完整帧,长度校验就能通过。代价是响应时间会变慢,但对从站测试场景完全可接受。
5.2 现象二:CRC 校验总不过,但串口助手算出来是对的
现象:主站发的报文在串口助手里计算 CRC 是正确的,程序里 calcCRC 返回的值跟收到的 CRC 就是不一致。
原因:最常见的是计算范围不对。有人从功能码开始算,把地址字节排除在外;有人把收到的两字节 CRC 也带进计算里。正确做法是只对“地址 + 功能码 + 数据区”计算,收到帧最后两字节是待校验的 CRC,不能参与计算。
解决:把 calcCRC 的参数改成 m_buffer.left(m_buffer.size() - 2),确保最后的 CRC 字节不进计算。另外要用无符号字节参与计算,QByteArray::at 返回的是 char,直接异或会把符号位带进去,必须强转成 quint8,否则高位字节大于 127 时 CRC 结果就不对。
5.3 现象三:主站输入“0x01”,从站收到的是 ASCII 字符
现象:上位机软件里输入框写了 01 03 00 00 00 0A,点击发送后从站收到的是 30 31 20 30 33 这类十六进制值,帧长明显多出一倍。
原因:这是典型的前端输入框没有做 Hex 转换。界面输入的是 ASCII 字符串“01 03 00 00 00 0A”,每个字符对应一个 ASCII 码,发送时没有把字符串转成字节数组。串口驱动不认识“01”这种文本格式,它会原样发出去。
解决:在发送前加一层转换,把形如“01 03 00 00”的字符串按空格拆分,每两个十六进制字符转成一个字节:
QByteArray hexStringToBytes(const QString &hexStr) { QByteArray result; QString cleanStr = hexStr.trimmed(); QStringList hexList = cleanStr.split(" ", Qt::SkipEmptyParts); for (const QString &hexPart : hexList) { bool ok = false; int byte = hexPart.toInt(&ok, 16); if (ok) { result.append((char)(byte & 0xFF)); } } return result; }这个函数也可以用来处理从站 log 里打印出来的原始报文,方便复制回主站做回放。我在几个项目里都遇到过这类问题,本质是 UI 层把“可读文本”和“二进制数据”混为一谈,需要在发送边界做显式转换。
5.4 现象四:USB 转串口偶发超时,一压测就翻车
现象:程序单发单收测试正常,但用主站工具连续循环发送时,偶尔会出现超时或者响应迟到。
原因:USB 转串口的数据传输有批量端点缓冲机制,速度上来以后多条报文可能被合并成一次 readyRead 信号,缓冲区里同时有好几帧。如果帧间隔定时器设计成“超时就清空缓冲区”,合并的帧会被拆开处理,后一帧的人为间隔可能不够,导致解析失败。
解决:不要把缓冲区和帧一一对应。处理完一帧后,检查缓冲区剩余长度是否还能构成一帧,如果够就继续处理,不够则等待下一次 readyRead:
void ModbusSlave::handleFrameTimer() { while (m_buffer.size() >= 8) { if (/* 当前缓冲区长度足够解析一帧 */) { QByteArray frame = m_buffer.left(nextFrameLength()); processFrame(frame); m_buffer.remove(0, frame.size()); } else { break; } } }压测场景下超时通常不是 CPU 性能不够,而是事件循环接收数据时刚好赶上定时器触发。在设计上留够缓冲余量,处理完一帧后不清空全部缓冲区,而是精确移除已处理的部分,压测稳定很多。
5.5 现象五:改了波特率后从站变哑巴
现象:程序里把波特率从 9600 改成 115200,重新打开串口后主站发什么从站都不回了。
原因:Modbus RTU 帧间隔跟波特率强相关。115200 下理论间隔只有 0.335ms,如果代码里还保持 9600 时序的 15ms 间隔,主站发的两段数据可能被从站正确接收,但主站软件本身有自己的超时判断——通常主站等待从站响应的时间是 50~200ms。从站响应虽然发出了,但排在主站超时之后才到,主站已经判定超时。还有一种情况是改波特率后主站软没同步切换,两边参数不一致,收到的数据直接就是乱码。
解决:改波特率后,把主站软件、从站代码、串口助手的波特率设置全部对比一遍,确认三处一致。同时检查从站的帧间隔策略是否需要随波特率调整,如果从站响应速度本来就慢,主站的超时时间适当放长一些。调这类问题我一般先抓原始字节流,确认两端码率一致,再查协议解析,不要上来就怀疑代码逻辑。
6. 验证技巧:用主站模拟器和时间戳日志压一遍
6.1 用主站模拟器完整走一遍协议
代码写完不意味着能直接上设备联调,先用电脑上的主站模拟器自测一遍。常见的主站工具有 Modbus Poll 和各类串口调试助手,这里以 Modbus Poll 为例,配置步骤基本一样:
| 配置项 | 值 | 说明 |
|---|---|---|
| 串口参数 | 根据从站初始化设置 | 波特率、数据位、校验位、停止位要和从站一致 |
| 从站地址 | 1 | 和代码里的 m_slaveAddr 一致 |
| 功能码 | 03 | 读取保持寄存器 |
| 起始地址 | 0 | 对应寄存器表起始 |
| 长度 | 10 | 连续读 10 个寄存器 |
| 显示格式 | unsigned / hex | 验证值和预期是否一致 |
先读保持寄存器,确认 10 个寄存器值显示为 0。再用 06 功能码写寄存器 0,值为 0x1234,此时读操作应该看到寄存器 0 变成 0x1234。接着用 10(0x10) 功能码连续写寄存器 0~4 五个值,读回来验证。最后故意读一个越界地址,比如起始地址 200、长度 10,此时从站应该返回异常码 0x02,而不是沉默。这一套走下来,协议层面的兼容性基本能测通。
6.2 时间戳日志:判断帧间隔与响应时间是否合理
联调时只靠眼睛盯着界面远远不够,我习惯在从站代码里加一个独立的日志输出,记录每帧到达的时间差和 CRC 校验结果。写一个简单的帧打印函数:
void ModbusSlave::logRawFrame(const QByteArray &raw, const QString &direction) { // direction: "RX" 表示接收, "TX" 表示发送 QString hexStr; for (int i = 0; i < raw.size(); ++i) { hexStr += QString("%1 ") .arg((quint8)raw.at(i), 2, 16, QLatin1Char('0')) .toUpper(); } qDebug() << QDateTime::currentDateTime().toString("hh:mm:ss.zzz") << direction << hexStr; }把日志打开后,能看到主站发帧和从站回帧之间的时间差。正常情况下响应应该在几毫秒到几十毫秒之间。如果响应时间经常超过 100ms,要么帧间隔定时器过大,要么事件循环被什么耗时操作卡住。这些经验拿到设备现场能省很多排查时间。
6.3 压测脚本与回归验证
联调通过后,不要急着收工。写一个简单的循环发送脚本,连续发 1000 帧读请求,统计从站响应的成功率和平均响应时间。如果中途出现 CRC 错误或者无响应,把日志对应时间戳拉出来看,是偶发丢帧还是持续超时,然后针对性调整串口参数或者缓冲策略。从那以后我每写完一个从站程序,都强制先跑一遍压测脚本再交付,认认真真走完这套流程,联调阶段的玄学问题基本能提前清掉。希望帮到你。
本文还有配套的精品资源,点击获取