数据收发实战:QByteArray 与信号槽的默契配合
串口通信的核心,说白了就是两件事:发出去和收回来。但实际项目中,这两件事远没有想象中那么简单。我记得刚接手第一个工业控制项目时,就因为在数据封装上栽了跟头——设备死活不响应,折腾了两天才发现是字节顺序搞反了。嗯,今天我们就来彻底搞定数据收发这一块。
4.1 QByteArray:串口通信的“通用语言”
在 Qt 的世界里,QByteArray 就是串口数据的标准容器。它不像 QString 那样关心编码,也不像 QVector 那样有类型约束——它就是纯粹的字节数组,拿来就能用。
核心认知:串口不认 int、float、QString,它只认 0 和 1 组成的字节流。QByteArray 就是你在应用层和物理层之间的翻译官。
4.1.1 数据封装的基本操作
我个人习惯把数据封装分成三步:构造、填充、校验。下面是一个典型的 Modbus RTU 帧封装示例:
// 构造一个 Modbus RTU 读取保持寄存器的请求帧
QByteArray frame;
frame.resize(8); // 预分配空间,避免多次内存拷贝
// 填充数据
frame[0] = 0x01; // 从站地址
frame[1] = 0x03; // 功能码:读取保持寄存器
frame[2] = 0x00; // 起始地址高字节
frame[3] = 0x00; // 起始地址低字节
frame[4] = 0x00; // 寄存器数量高字节
frame[5] = 0x0A; // 寄存器数量低字节(读取10个)
// 计算 CRC16 校验
uint16_t crc = calculateCRC16(frame.constData(), 6);
frame[6] = crc & 0xFF; // CRC 低字节
frame[7] = (crc >> 8) & 0xFF; // CRC 高字节
这里有个细节要注意:CRC 校验的字节顺序。我在项目中遇到过,有些设备要求低字节在前,有些要求高字节在前。你想想看,如果顺序搞反了,设备会直接丢弃这个帧,而且没有任何错误提示——这就是所谓的“静默失败”。
4.1.2 常用构造方法对比
| 方法 | 适用场景 | 注意事项 |
|---|---|---|
QByteArray() |
空数据初始化 | 后续必须 resize 或 append |
QByteArray(size, char) |
固定长度填充 | 适合协议帧预分配 |
QByteArray::fromHex() |
十六进制字符串转字节 | 常用于调试日志解析 |
QByteArray::fromRawData() |
零拷贝引用已有内存 | 不管理内存生命周期 |
operator+=() / append() |
动态拼接数据 | 频繁调用可能触发多次内存分配 |
我的建议:如果帧长度固定,优先用 resize() 预分配 + 下标赋值。如果帧长度可变,用 append() 但尽量一次拼完,避免在循环里频繁追加。
4.2 write():把数据送出去
数据封装好了,下一步就是发出去。QSerialPort 的 write() 方法看起来简单,但用起来有不少门道。
4.2.1 write() 的三种形态
// 方式一:直接传 QByteArray
serial->write(frame);
// 方式二:传 C 风格字符串 + 长度
serial->write(frame.constData(), frame.size());
// 方式三:传 QByteArray 的局部片段
serial->write(frame.mid(2, 4)); // 只发送第3到第6个字节
这三种方式在底层最终都调用了同一个系统调用。我个人习惯用方式一,代码更简洁,而且 QByteArray 的隐式共享机制能减少不必要的拷贝。
4.2.2 写入完成后的确认
这里有个常见的误解:write() 返回的字节数并不代表数据已经发送完毕,它只代表数据已经写入到操作系统的发送缓冲区。真正的发送完成,需要靠 bytesWritten() 信号来确认。
// 连接 bytesWritten 信号
connect(serial, &QSerialPort::bytesWritten,
this, [this](qint64 bytes) {
qDebug() << "已确认发送字节数:" << bytes;
// 可以在这里更新发送进度或触发下一帧发送
});
我曾经踩过的坑:在一个高速数据采集项目中,我连续调用 write() 发送大量数据帧,没有等待 bytesWritten 信号。结果操作系统缓冲区溢出,后面的帧被丢弃了。解决方案是在发送队列中维护一个待发送列表,每收到一次 bytesWritten 才发送下一帧。
4.3 read() 与 readyRead:把数据接回来
接收数据是串口编程中最容易出问题的环节。为什么?因为串口数据是异步到达的,你永远不知道设备什么时候会回数据,也不知道一次会回多少字节。
4.3.1 readyRead 信号的正确用法
很多新手会这样写:
// ❌ 错误示范
connect(serial, &QSerialPort::readyRead, this, [this]() {
QByteArray data = serial->readAll();
processFrame(data); // 假设一次读完就是一帧完整数据
});
为什么不对?因为 readyRead 信号可能在一帧数据还没传输完时就触发了。比如设备要发送 100 个字节,但操作系统可能在收到 50 个字节时就通知 Qt 有数据可读。这时候你读到的只是半帧数据。
正确的做法是:
// ✅ 正确做法:使用缓冲区累积数据
QByteArray m_buffer; // 成员变量,用于累积接收数据
connect(serial, &QSerialPort::readyRead, this, [this]() {
// 1. 读取所有可用数据到缓冲区
m_buffer.append(serial->readAll());
// 2. 循环解析缓冲区中的完整帧
while (m_buffer.size() >= FRAME_MIN_SIZE) {
// 检查帧头
if ((unsigned char)m_buffer[0] != FRAME_HEADER) {
m_buffer.remove(0, 1); // 丢弃无效字节
continue;
}
// 检查帧长度是否足够
int frameLen = getFrameLength(m_buffer);
if (m_buffer.size() < frameLen) {
break; // 数据还不够,等待下一次 readyRead
}
// 提取完整帧
QByteArray frame = m_buffer.left(frameLen);
m_buffer.remove(0, frameLen);
// 处理完整帧
processFrame(frame);
}
});
关键原则:永远不要在 readyRead 槽函数中假设一次读取就是一帧完整数据。用缓冲区累积 + 帧协议解析才是正道。
4.3.2 read() 与 readAll() 的选择
| 方法 | 行为 | 推荐场景 |
|---|---|---|
readAll() |
读取缓冲区中所有可用数据 | 配合缓冲区累积策略 |
read(n) |
读取指定字节数,可能少于 n | 已知帧长度,分步读取 |
peek(n) |
预览数据但不移除 | 检查帧头或长度字段 |
我个人几乎只用 readAll(),因为它能一次性把操作系统缓冲区清空,减少 readyRead 信号的触发次数。你想想看,如果每次只读几个字节,readyRead 可能会被频繁触发,反而增加了 CPU 开销。
4.4 信号槽机制:串口事件的“消息总线”
Qt 的信号槽机制是串口编程的基石。除了 readyRead 和 bytesWritten,还有几个信号值得关注:
// 完整的信号连接示例
connect(serial, &QSerialPort::readyRead, this, &SerialManager::onReadyRead);
connect(serial, &QSerialPort::bytesWritten, this, &SerialManager::onBytesWritten);
connect(serial, &QSerialPort::errorOccurred, this, &SerialManager::onError);
connect(serial, &QSerialPort::baudRateChanged, this, [](qint32 baud) {
qDebug() << "波特率已变更:" << baud;
});
我的习惯:把所有串口信号连接集中放在一个 setupConnections() 函数中,方便维护和排查。另外,errorOccurred 信号一定要处理——很多串口故障(比如设备断开)都是通过这个信号暴露出来的。
4.5 完整收发流程:从封装到解析
下面这张图展示了串口数据收发的完整生命周期:
4.6 实战案例:温湿度传感器数据采集
最后,我们用一个完整的例子来串联今天的内容。假设我们要读取一个 Modbus 温湿度传感器:
class TempHumiditySensor : public QObject {
Q_OBJECT
public:
explicit TempHumiditySensor(QSerialPort *serial, QObject *parent = nullptr)
: QObject(parent), m_serial(serial) {
// 连接信号
connect(m_serial, &QSerialPort::readyRead,
this, &TempHumiditySensor::onDataReceived);
connect(m_serial, &QSerialPort::bytesWritten,
this, [this](qint64 bytes) {
qDebug() << "已发送" << bytes << "字节";
});
}
void requestData() {
// 构造读取命令:地址0x01,功能码0x03,起始地址0x0000,读取2个寄存器
QByteArray cmd;
cmd.resize(8);
cmd[0] = 0x01;
cmd[1] = 0x03;
cmd[2] = 0x00;
cmd[3] = 0x00;
cmd[4] = 0x00;
cmd[5] = 0x02;
uint16_t crc = calculateCRC16(cmd.constData(), 6);
cmd[6] = crc & 0xFF;
cmd[7] = (crc >> 8) & 0xFF;
m_serial->write(cmd);
}
private slots:
void onDataReceived() {
m_buffer.append(m_serial->readAll());
// 检查是否收到完整响应(地址+功能码+字节数+数据+CRC = 9字节)
while (m_buffer.size() >= 9) {
if ((unsigned char)m_buffer[0] != 0x01 ||
(unsigned char)m_buffer[1] != 0x03) {
m_buffer.remove(0, 1);
continue;
}
int dataLen = (unsigned char)m_buffer[2];
int frameLen = 3 + dataLen + 2; // 地址+功能码+长度+数据+CRC
if (m_buffer.size() < frameLen) break;
QByteArray frame = m_buffer.left(frameLen);
m_buffer.remove(0, frameLen);
// 解析温度(第3-4字节)和湿度(第5-6字节)
if (frame.size() >= 7) {
int16_t temp = (frame[3] << 8) | frame[4];
int16_t hum = (frame[5] << 8) | frame[6];
emit dataReceived(temp / 10.0, hum / 10.0);
}
}
}
signals:
void dataReceived(double temperature, double humidity);
private:
QSerialPort *m_serial;
QByteArray m_buffer;
};
最后提醒一句:串口调试时,一定要用串口助手先验证设备的原始数据格式。我曾经花了一整天调试解析代码,最后发现是设备手册上的寄存器地址写错了——嗯,从那以后我养成了“先看原始数据,再写解析代码”的习惯。