数据收发实战: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 完整收发流程:从封装到解析

下面这张图展示了串口数据收发的完整生命周期:

串口数据收发完整流程 1. 构造 QByteArray 2. 调用 write() 3. 等待 bytesWritten 4. 发送完成确认 1. readyRead 触发 2. readAll() 读取 3. 累积到缓冲区 4. 帧协议解析 应用层数据处理 (校验/解析/响应) 关键要点 • 发送端:write() 是异步的,用 bytesWritten 确认发送完成 • 接收端:readyRead 可能分多次触发,必须用缓冲区累积 • 两端通过应用层协议(帧头/长度/校验)保证数据完整性

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;
};

最后提醒一句:串口调试时,一定要用串口助手先验证设备的原始数据格式。我曾经花了一整天调试解析代码,最后发现是设备手册上的寄存器地址写错了——嗯,从那以后我养成了“先看原始数据,再写解析代码”的习惯。