Qt/C++ 实现串口设备通信:从串口扫描、连接到数据收发

1,269 阅读4分钟

在传感器、扫码枪、仪器仪表和控制器项目中,上位机经常需要通过串口与设备通信。

Qt 提供的 QSerialPort 已经封装了串口打开、数据读写和错误通知。实际开发时,可以把这些操作集中到一个通信类中,避免串口逻辑散落在各个按钮事件里。

下面使用异步信号槽完成通信,不在界面线程中阻塞等待数据。

一、扫描电脑上的串口

使用 qmake 的项目,需要添加:

QT += serialport

Qt 6 的 CMake 项目可以添加:

find_package(Qt6 REQUIRED COMPONENTS SerialPort)
target_link_libraries(your_app PRIVATE Qt6::SerialPort)

其中 your_app 替换为实际目标名称。包含 Q_OBJECT 的类需要启用 moc,CMake 项目通常设置:

set(CMAKE_AUTOMOC ON)

扫描串口:

#include <QSerialPortInfo>
#include <QDebug>

void scanSerialPorts()
{
    const auto ports = QSerialPortInfo::availablePorts();

    for (const auto& port : ports) {
        qDebug() << "端口名称:" << port.portName()
                 << "设备描述:" << port.description()
                 << "设备厂商:" << port.manufacturer();
    }
}

如果需要填充下拉框,可以把描述作为显示文本,把端口名称存入用户数据:

for (const auto& port : QSerialPortInfo::availablePorts()) {
    ui->portComboBox->addItem(
        port.portName() + " " + port.description(),
        port.portName());
}

连接时读取 currentData(),不要再从显示文本中截取端口号。

二、封装串口管理类

以下示例使用常见的 115200、8N1、无流控 参数。实际连接时必须以设备文档为准。

#include <QObject>
#include <QSerialPort>
#include <QByteArray>

class SerialDevice : public QObject
{
    Q_OBJECT

public:
    explicit SerialDevice(QObject* parent = nullptr)
        : QObject(parent)
    {
        connect(&port_, &QSerialPort::readyRead,
                this, [this] {
            const QByteArray data = port_.readAll();

            if (!data.isEmpty())
                emit dataReceived(data);
        });

        connect(&port_, &QSerialPort::errorOccurred,
                this, [this](QSerialPort::SerialPortError error) {
            if (error == QSerialPort::NoError)
                return;

            const QString message = port_.errorString();

            if (error == QSerialPort::ResourceError)
                close();

            emit errorOccurred(message);
        });
    }

    bool open(const QString& name, qint32 baudRate = 115200)
    {
        close();
        port_.setPortName(name);

        const bool configured =
                port_.setBaudRate(baudRate) &&
                port_.setDataBits(QSerialPort::Data8) &&
                port_.setParity(QSerialPort::NoParity) &&
                port_.setStopBits(QSerialPort::OneStop) &&
                port_.setFlowControl(QSerialPort::NoFlowControl);

        if (!configured)
            return false;

        return port_.open(QIODevice::ReadWrite);
    }

    void close()
    {
        if (port_.isOpen())
            port_.close();
    }

    bool send(const QByteArray& data)
    {
        if (!port_.isOpen())
            return false;

        return port_.write(data) == data.size();
    }

signals:
    void dataReceived(QByteArray data);
    void errorOccurred(QString message);

private:
    QSerialPort port_{this};
};

这个类只负责串口操作,收到数据后直接发出信号,不在内部判断设备命令。

send() 返回成功,表示数据被接受进入发送流程,不代表设备已经执行了指令。执行结果仍然需要通过响应消息确认。

三、发送和接收二进制数据

在窗口中创建通信对象,并在连接前订阅信号:

auto* device = new SerialDevice(this);

connect(device, &SerialDevice::dataReceived,
        this, [](const QByteArray& data) {
    qDebug().noquote()
        << "收到:" << data.toHex(' ').toUpper();

    // 将 data 交给持续存在的协议解析器。
});

connect(device, &SerialDevice::errorOccurred,
        this, [](const QString& message) {
    qWarning() << "串口错误:" << message;
});

if (!device->open("COM3")) {
    qWarning() << "串口打开失败";
    return;
}

const QByteArray command =
        QByteArray::fromHex("010300000002C40B");

if (!device->send(command))
    qWarning() << "指令未完整写入发送缓冲区";

这里实际发送的是:

01 03 00 00 00 02 C4 0B

不要把 "01 03" 这样的显示文本,直接按字符串发送给使用二进制协议的设备。

另外,QByteArray::fromHex() 会忽略非十六进制字符。程序中写死的合法指令可以直接转换,用户输入则应先进行严格检查,包括字符是否合法、有效字符数量是否为偶数。

四、串口通信中容易忽略的问题

一次接收不等于一条消息。

设备发送:

AA 55 01 00 02 10 20 33

程序可能先收到:

AA 55 01 00

再收到剩余部分。因此,readyRead 中应读取原始字节,然后交给协议解析器持续组包。

不要在接收回调中堆积耗时操作。

大量追加文本、逐条写文件和复杂计算,都可能拖慢事件处理。接收频率高时,可以先缓存,再批量保存或刷新界面。

不要混用异步与阻塞等待。

已经通过 readyRead 接收数据时,通常不需要再在按钮事件里调用 waitForReadyRead()。阻塞等待会影响界面响应,也容易让状态处理变得混乱。

重连时清理旧状态。

设备拔出、重新打开串口后,应清理残留半包和未完成的指令等待。否则旧连接的数据可能与新连接的数据拼接在一起。

串口类负责收发,解析器负责识别完整消息,业务层负责处理设备功能。这个边界确定下来后,增加新指令会容易很多。