简介:这是一套面向Qt串口开发初学者与STM32入门学习者的上下位机联调示例,围绕上位机与STM32单片机之间的串口数据收发展开,并通过界面按钮控制LED与蜂鸣器,帮助读者理解串口配置、收发处理及简单外设控制的基本流程。压缩包共135个文件、约24.11MB,包含35个.h与33个.c源文件、2个.cpp与1个.ui界面文件、1个.exe可执行程序,以及hex、uvprojx工程文件和d、o、crf等编译中间文件,兼顾源码阅读、工程复现与直接运行。已有10856人学习下载,说明其适合课程实验、毕业设计前期验证与自学练手。包内提供STM32单片机源程序、Qt源工程、打包好的上位机软件和配置说明文档,读者可对照工程结构梳理串口通信链路,观察按钮指令如何驱动LED和蜂鸣器,并借助现成程序排查环境配置问题;使用Qt源程序时需注意避免中文路径引起编译异常。
1. 一套串口环境,卡住的不只是驱动
第一次给别人搭 Qt 上位机对接 STM32 串口通信的环境,我以为最麻烦的是装 Qt,结果真正翻车的是串口模块没勾、STM32 那边波特率差了一倍。这类环境看着简单——电脑上一头跑界面程序,板子上一头跑 USART,中间一根 USB 转 TTL 线,可真装起来,版本、组件、时钟、中断、驱动任意一处不对,现象都长一个样:点按钮没反应。新手容易把它当成“代码没写对”,老手知道先怀疑环境。这套东西适合两类人:手上有 STM32 开发板、想做个能点灯能响蜂鸣器的上位机练手的;以及要把实验室里现成的板子和 PC 连起来做数据收发的。装不好不会报大错,只会让你在“能编译、能打开串口、就是没动作”之间反复怀疑人生。
2. 装 Qt 和串口模块:版本、组件与那三个必勾选项
Qt 这边的安装,决定后面八成的问题。装之前先把两件事定下来:用 Qt 5 还是 Qt 6,用 MSVC 还是 MinGW。这两个选择会直接影响你后面能不能找到QtSerialPort、编译出来的 exe 能不能在别人机器上跑。
2.1 Qt 5.15.2 还是 Qt 6.5:按编译器和串口模块倒推
我一般推荐两条线:老项目、老教程多,选 Qt 5.15.2;新起项目、想要长期维护,选 Qt 6.5.x 这个 LTS。两者的串口模块名字不同——Qt 5 里叫QtSerialPort,Qt 6 里是Qt6::SerialPort,CMake 里写法也不一样,抄错一个字母就是编译期报错。
真正要注意的是编译器配套:
| Qt 版本 | 推荐编译器 | 串口模块组件名 | 备注 |
|---|---|---|---|
| Qt 5.15.2 | MinGW 8.1 64-bit | Qt Serial Port | 自带编译器,装完即用 |
| Qt 5.15.2 | MSVC 2019 64-bit | Qt Serial Port | 需先装 VS 生成工具 |
| Qt 6.5.x | MinGW 11.2 64-bit | Qt Serial Port | 与 5.x 工程文件不通用 |
| Qt 6.5.x | MSVC 2019/2022 64-bit | Qt Serial Port | 调试器需另配 |
我自己的习惯是:只是做串口收发、不涉及复杂第三方库,优先 MinGW。它不用额外装 Visual Studio,省掉一个几 GB 的依赖,调试器也跟着装上。代价是某些需要 MSVC 编译的库用不了,但串口这个小项目碰不到。
注意:同一个 Qt 版本下,MinGW 和 MSVC 的 kit 不能混用。你装了 Qt 5.15.2 的 MinGW 版,就不要再拿 MSVC 的 kit 去构建,链接阶段会报一堆undefined reference,看着像代码问题,其实是工具链错配。
2.2 安装器里哪些组件不能省
Qt 现在走在线安装器,下载下来是个几十 MB 的引导程序,真正的组件在安装过程中勾选。这一步是血泪经验最多的地方——默认勾选里通常没有串口模块。
安装时的勾选清单:
- 在 Qt 版本树下展开你选的那个版本(比如
Qt 5.15.2)。 - 勾上对应编译器的套件,比如
MinGW 8.1.0 64-bit。 - 在同一层展开后,务必勾上
Qt Serial Port。这一项默认不选。 Developer and Designer Tools里勾上MinGW(如果用 MinGW)和Qt Creator。- 需要看界面设计的话,
Qt Designer跟着 Creator 一起装即可。
装完之后,去安装目录下确认一下有没有这个文件夹:
# 以 Qt 5.15.2 MinGW 64 位为例,路径按自己的安装盘符改 ls /opt/Qt/5.15.2/mingw81_64/include/QtSerialPort ls /opt/Qt/5.15.2/mingw81_64/lib/libQt5SerialPort.a能看到QtSerialPort头文件目录和libQt5SerialPort库文件,说明组件装上了。如果只有QtCore、QtGui、QtWidgets,那就是漏勾了。
漏勾不用重装整个 Qt。打开安装目录下的MaintenanceTool,选“添加或移除组件”,展开对应版本,把Qt Serial Port补上,走完流程就行。这一步比重装省一两个小时。
2.3 工程文件怎么写:CMake 和 qmake 各一份
Qt 6 默认推 CMake,Qt 5 时代 qmake 更常见。两种我都给一份,照着改工程名就行。
CMake 版本(Qt 6 或 Qt 5 都可用,改find_package的版本号):
cmake_minimum_required(VERSION 3.16) project(Stm32Host LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 处理 Q_OBJECT 的 moc set(CMAKE_AUTOUIC ON) # 处理 .ui 文件 set(CMAKE_AUTORCC ON) # 处理 .qrc 资源 # Qt 6 写法;Qt 5 改成 find_package(Qt5 REQUIRED COMPONENTS Widgets SerialPort) find_package(Qt6 REQUIRED COMPONENTS Widgets SerialPort) add_executable(Stm32Host main.cpp mainwindow.cpp mainwindow.h mainwindow.ui ) # 关键:SerialPort 必须显式链接,否则运行期才报找不到模块 target_link_libraries(Stm32Host PRIVATE Qt6::Widgets Qt6::SerialPort )qmake 版本(Qt 5 项目常见):
QT += core gui serialport greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = Stm32Host TEMPLATE = app CONFIG += c++17 SOURCES += main.cpp mainwindow.cpp HEADERS += mainwindow.h FORMS += mainwindow.ui两段配置的核心都是那一行依赖声明。CMake 里是Qt6::SerialPort,qmake 里是QT += serialport。少这一行,#include <QSerialPort>直接报找不到头文件;加了对但没链接,编译能过,运行时会提示Cannot load library ... QtSerialPort,属于典型的“编译期没问题、运行期翻车”。
参数上还有个小取舍:CMAKE_CXX_STANDARD 17不是必须,但 Qt 6.5 的部分新接口用到了 C++17 特性,写 17 省心。qmake 那边CONFIG += c++17同理。
3. STM32 端先跑通:时钟、波特率与中断接收
上位机装好只是半套环境。另一半在板子上,而板子这一侧最容易出的问题是“串口能发不能收”或者“收几个字节就丢”。顺序上我建议先让 STM32 用最朴素的方式把串口跑通,再接 Qt。
3.1 时钟树与 USART 参数必须对齐
STM32 的 USART 波特率是从总线时钟分频出来的,时钟配错,波特率就不准,表现是上位机收到一堆乱码。用 CubeMX 配置时先确认两件事:外部晶振频率填对(常见 8MHz),系统时钟树里 USART 挂的总线频率是不是你预期的值。
USART 参数按下面这张表配,和 Qt 侧完全一致:
| 参数 | 取值 | 说明 |
|---|---|---|
| 波特率 | 115200 | 调试阶段别用 921600 |
| 数据位 | 8 | 对应 Qt 的 Data8 |
| 校验位 | None | 对应 NoParity |
| 停止位 | 1 | 对应 OneStop |
| 流控 | None | 对应 NoFlowControl |
| 模式 | 异步收发 | TX/RX 都开 |
初始化代码大致长这样(HAL 库,CubeMX 生成后一般不用手改):
/* USART1 初始化:115200 8N1,无硬件流控 */ huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; if (HAL_UART_Init(&huart1) != HAL_OK) { Error_Handler(); /* 初始化失败通常卡在这里,先查时钟 */ }OverSampling选 16 是常规做法,只有在高波特率且时钟余量紧张时才考虑 8。这一项改了,两边都得跟着改,别只改一边。
3.2 中断接收加环形缓冲,别用轮询硬扛
新手最常写的是在主循环里HAL_UART_Receive(&huart1, buf, len, 100)轮询。数据量小、节奏慢的时候能跑,一旦上位机连续发按钮命令,或者以后要加传感器上报,就会漏字节。常见做法是开接收中断,把字节先塞进环形缓冲,主循环慢慢取。
#define RX_RING_SIZE 256 static uint8_t rxRing[RX_RING_SIZE]; static volatile uint16_t rxHead = 0; /* 中断里写 */ static volatile uint16_t rxTail = 0; /* 主循环读 */ static uint8_t rxByte; /* 单字节接收暂存 */ /* 启动时调用一次,打开第一字节的接收中断 */ void uart_rx_start(void) { HAL_UART_Receive_IT(&huart1, &rxByte, 1); } /* 每收到一个字节进一次中断 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uint16_t next = (rxHead + 1) % RX_RING_SIZE; if (next != rxTail) { /* 未满才写入,满了丢新字节 */ rxRing[rxHead] = rxByte; rxHead = next; } HAL_UART_Receive_IT(&huart1, &rxByte, 1); /* 重新武装 */ } }这段的关键点有三个。第一,rxHead和rxTail都加了volatile,因为一个在中断里改、一个在主循环里改,不加编译器可能把读操作优化掉。第二,缓冲区满的时候丢的是新字节而不是覆盖旧数据,这样帧头不容易被破坏。第三,中断回调最后必须重新调用HAL_UART_Receive_IT,HAL 库的单字节中断接收是一次性的,忘了这一句就只收得到一个字节,现象是“第一次能收,后面全无反应”。
主循环里这样取数据:
int uart_read_byte(uint8_t *out) { if (rxTail == rxHead) return 0; /* 空 */ *out = rxRing[rxTail]; rxTail = (rxTail + 1) % RX_RING_SIZE; return 1; }中断里只做入队,不做解析、不调printf、不点灯。中断里做耗时操作是另一个高频翻车点,后面避坑章节会展开。
3.3 帧协议和 LED、蜂鸣器动作
裸串口传单字节最省事,但一旦丢一个字节,后面全错位。加个简单的帧结构,重同步成本低很多。我一般用这套:
| 字节序号 | 含义 | 取值 |
|---|---|---|
| 0 | 帧头 1 | 0xAA |
| 1 | 帧头 2 | 0x55 |
| 2 | 命令 | 0x01 控制 LED,0x02 控制蜂鸣器 |
| 3 | 数据 | 0x00 关,0x01 开 |
| 4 | 校验 | 命令 + 数据,取低 8 位 |
| 5 | 帧尾 | 0x0D |
| 6 | 帧尾 | 0x0A |
板子侧的解析用状态机,逐字节喂进去:
typedef enum { S_H1, S_H2, S_CMD, S_DATA, S_SUM, S_T1, S_T2 } parse_state_t; static parse_state_t st = S_H1; static uint8_t cmd, dat; void frame_feed(uint8_t b) { switch (st) { case S_H1: if (b == 0xAA) st = S_H2; break; case S_H2: st = (b == 0x55) ? S_CMD : S_H1; break; case S_CMD: cmd = b; st = S_DATA; break; case S_DATA: dat = b; st = S_SUM; break; case S_SUM: /* 校验不过就回帧头状态,重新找 0xAA */ st = ((uint8_t)(cmd + dat) == b) ? S_T1 : S_H1; break; case S_T1: st = (b == 0x0D) ? S_T2 : S_H1; break; case S_T2: if (b == 0x0A) frame_exec(cmd, dat); st = S_H1; break; default: st = S_H1; break; } }执行动作就是一个 switch:
void frame_exec(uint8_t cmd, uint8_t dat) { switch (cmd) { case 0x01: /* LED:高电平点亮,按自己板子的接法改 */ HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, dat ? GPIO_PIN_SET : GPIO_PIN_RESET); break; case 0x02: /* 蜂鸣器:有源蜂鸣器给电平就响 */ HAL_GPIO_WritePin(BEEP_GPIO_Port, BEEP_Pin, dat ? GPIO_PIN_SET : GPIO_PIN_RESET); break; default: break; /* 未知命令直接忽略,不要回错误帧,省得两边死循环 */ } }状态机的写法比“收满 7 字节再判断”稳,因为中途插进杂字节它能自己回到帧头重新同步。代价是每次只处理一个字节,主循环里要循环取完缓冲。
蜂鸣器这里提醒一句:有源蜂鸣器给电平就响,无源的必须给 PWM 才响。买的时候看清楚,别拿无源的当有源的接,然后怀疑代码。板子上如果是 PNP 三极管驱动,电平还要反过来。
4. 上位机侧:串口枚举、打开参数与按钮映射
Qt 这侧最容易犯的错是“参数凭感觉填”。串口打开失败、能开但收不到,九成能在这几个参数上找到原因。
4.1 串口枚举与打开参数
先枚举,再打开,不要写死COM3。别人机器上可能是COM5,写死就得改代码重编。
#include <QSerialPort> #include <QSerialPortInfo> #include <QDebug> /* 枚举当前系统里所有可用串口 */ void listPorts() { const auto ports = QSerialPortInfo::availablePorts(); for (const QSerialPortInfo &info : ports) { qDebug() << "port:" << info.portName() << "desc:" << info.description() << "manu:" << info.manufacturer(); } }description和manufacturer这两个字段很有用。CH340 会显示成USB-SERIAL CH340,CP2102 显示成Silicon Labs CP210x,插拔时靠它区分哪个是板子,比记 COM 号靠谱。
打开串口:
QSerialPort *serial = new QSerialPort(this); serial->setPortName("COM5"); // 从下拉框取,别写死 serial->setBaudRate(QSerialPort::Baud115200); // 必须与 STM32 一致 serial->setDataBits(QSerialPort::Data8); serial->setParity(QSerialPort::NoParity); serial->setStopBits(QSerialPort::OneStop); serial->setFlowControl(QSerialPort::NoFlowControl); if (!serial->open(QIODevice::ReadWrite)) { qDebug() << "open failed:" << serial->errorString(); // 常见原因:被串口助手占用、COM 号不存在、驱动未装 }open的第二个参数用ReadWrite,只读或只写都收不全。打开失败时一定把errorString()打出来,它是唯一能告诉你“被占用”还是“不存在”的线索。很多人只判断返回值不打印原因,然后凭猜。
4.2 收发走信号槽,别在主线程里 while 等
接收用readyRead信号,事件驱动,不要写while (serial->waitForReadyRead(1000))。后者会把界面线程卡住,现象是窗口变灰、按钮点不动。
QByteArray rxBuffer; // 跨多次 readyRead 的粘包缓冲 connect(serial, &QSerialPort::readyRead, this, [this]() { rxBuffer.append(serial->readAll()); // 先全部取出,避免缓冲区堆满 parseFrames(); // 逐帧解析,处理粘包和半包 });readAll()一定要在回调里立刻调,Qt 的内部接收缓冲有限,读取不及时会丢数据。rxBuffer在整个对象生命周期里累积,解析函数每次从头部找完整的帧,找到一个消费一个,剩下的留着等下次数据到达。
发送就简单了:
/* 组帧:与 STM32 端的状态机严格对应 */ QByteArray makeFrame(quint8 cmd, quint8 data) { QByteArray f; f.append(char(0xAA)); f.append(char(0x55)); f.append(char(cmd)); f.append(char(data)); f.append(char(quint8(cmd + data))); // 校验取低 8 位,C++ 会自动截断 f.append(char(0x0D)); f.append(char(0x0A)); return f; } void sendLed(bool on) { serial->write(makeFrame(0x01, on ? 0x01 : 0x00)); serial->flush(); // 确保立刻写出,调试阶段建议保留 }flush()在调试阶段留着,能保证数据立刻进驱动;正式发布如果发送频率高可以去掉,避免每次写都同步等待。
4.3 按钮到帧的映射和日志区
界面上的按钮不用写两套逻辑,做一个映射表最省事:
| 控件 | 命令字 | 数据 | 帧内容(HEX) |
|---|---|---|---|
| 开 LED | 0x01 | 0x01 | AA 55 01 01 02 0D 0A |
| 关 LED | 0x01 | 0x00 | AA 55 01 00 01 0D 0A |
| 开蜂鸣器 | 0x02 | 0x01 | AA 55 02 01 03 0D 0A |
| 关蜂鸣器 | 0x02 | 0x00 | AA 55 02 00 02 0D 0A |
四个按钮槽函数里各调一次sendLed/sendBeep,不要写四份组帧代码。以后加功能只改映射表。
日志区强烈建议加一个QPlainTextEdit,收发的每一帧都以十六进制打印出来,带上时间戳:
void logFrame(const QString &dir, const QByteArray &frame) { QString hex = frame.toHex(' ').toUpper(); ui->logView->appendPlainText( QString("[%1] %2 %3") .arg(QTime::currentTime().toString("hh:mm:ss.zzz")) .arg(dir, hex)); }这个日志区看着不起眼,排错时是黑匣子。上位机点了按钮、板子没反应,第一件事就是看日志里有没有发出去、发的内容对不对。没有日志,你只能靠猜。
5. 避坑记录:五条现场踩出来的坑
这一章按“现象 → 原因 → 解决”写,都是我或者别人真遇到过的。串口这东西玄学成分不少,但绝大多数所谓玄学最后都能落到这五条里。
5.1 编译通过但运行时提示找不到串口模块
现象:代码里#include <QSerialPort>不报错,编译链接都过,双击 exe 弹出This application failed to start because no Qt platform plugin could be loaded,或者命令行里提示Cannot load library ... Qt5SerialPort.dll。
原因:开发机上装了串口模块,但打包或运行目录里没带上Qt5SerialPort.dll(Qt 6 是Qt6SerialPort.dll)。调试运行时 Qt Creator 会自动加库路径,直接双击 exe 就露馅。
解决:用windeployqt把依赖拉全。
# 在 exe 所在目录执行,路径按自己环境改 windeployqt.exe --qmldir . Stm32Host.exe执行完会在同目录生成platforms/、styles/等文件夹和一堆 dll,其中包括串口那个。如果只想补一个 dll,也可以从 Qt 安装目录的bin下手工拷Qt5SerialPort.dll,但不推荐,漏一个还是起不来。判断标准很简单:把整个输出目录拷到另一台没装 Qt 的机器上,能双击打开,才算部署完成。
5.2 串口列表里只有 COM1 或者干脆是空的
现象:插上 USB 转 TTL 线,设备管理器里能看到,Qt 的availablePorts()却返回空或者只返回一个主板自带的 COM1。
原因:两种可能。一是 USB 转串口芯片的驱动没装好,设备管理器里带黄色感叹号,系统根本没把它当串口;二是程序启动时枚举了一次就再没刷新,插拔发生在枚举之后。
解决:先在设备管理器里确认端口号正常显示、没有感叹号。CH340 需要单独装驱动,CP2102 大部分系统自带。然后在界面上加一个“刷新”按钮,重新调一次listPorts(),别在构造函数里只枚举一次。还有一种少见情况:某些虚拟串口软件占用了端口号段,卸载掉就恢复。
5.3 板子能收不能发,或者发几个字节就停
现象:上位机能点亮 LED,但读不到板子回传的任何数据;或者第一次上电通信正常,跑几分钟后彻底没反应。
原因:前者多半是 STM32 侧只开了发送没开接收,或者中断没使能;后者是HAL_UART_RxCpltCallback里忘了重新调用HAL_UART_Receive_IT,HAL 的单字节中断接收是一次性的,收完一个字节就停了。
解决:检查 CubeMX 里 USART 的 NVIC 中断有没有勾上,uart_rx_start()有没有在main初始化后调用一次。然后在回调末尾补上重新武装那一句。调试时可以在回调里翻转一个空闲的 GPIO,用示波器看有没有脉冲,有脉冲说明中断在进,问题就在后面的解析。
5.4 点按钮蜂鸣器一直响,或者一上电就响
现象:程序刚烧录、还没点任何按钮,蜂鸣器就长鸣;或者点“关”之后还在响。
原因:GPIO 初始化时默认电平是高,而蜂鸣器驱动三极管是 PNP 或者低电平有效。CubeMX 生成的初始化代码里,HAL_GPIO_WritePin的初始状态如果填了GPIO_PIN_SET,上电瞬间就导通了。
解决:在MX_GPIO_Init()里,先把输出电平写成关闭状态,再配置成输出模式。顺序反了会有毫秒级的毛刺,蜂鸣器会“滴”一声。另外确认板子上蜂鸣器是有源还是无源:无源蜂鸣器给直流电平不响,必须给 2kHz 左右的方波,这时候代码里的WritePin就要换成定时器 PWM 输出。
5.5 上位机界面点几下就卡死
现象:连续点按钮,界面过几秒变灰、无响应,任务管理器里 CPU 占用不高。
原因:在主线程里写了阻塞式等待,比如while (!serial->waitForReadyRead(500)),或者在readyRead槽里做了耗时解析(比如每收一帧就sleep一下做动画)。串口是事件驱动的,主线程一阻塞,界面消息循环就停了。
解决:把所有等待改成信号槽。确实需要后台处理大量数据时,把串口对象moveToThread到一个工作线程,通过信号把解析结果发回界面线程更新控件。这个项目数据量很小,直接在主线程用readyRead就够了,别提前上多线程,反而增加调试成本。
6. 装完怎么验证:从回环到整机联调
环境装完不等于跑通,必须按顺序验证,跳步会浪费更多时间。我一般分三级:先纯软件回环,再串口助手对发,最后接板子整机联调。
第一级,USB 转 TTL 模块自己短接。把模块的 TX 和 RX 用一根杜邦线连起来,打开上位机选这个串口,发一帧AA 55 01 01 02 0D 0A,日志区应该原样收到同样的字节。收不到就说明串口打开参数、驱动或者模块本身有问题,跟 STM32 一点关系没有。这一步能把问题范围砍掉一半。
第二级,用串口助手代替板子。上位机发命令,串口助手手动回一帧相同的格式;串口助手发命令,上位机日志里能看到解析结果。这一步验证的是组帧和解析逻辑,不涉及硬件。很多“板子不响应”的问题其实在这一级就能发现是上位机帧格式写错了。
第三级,接 STM32。按这个顺序点:
| 验证项 | 操作 | 通过标准 |
|---|---|---|
| 串口能打开 | 选对 COM 号点击连接 | 无报错,按钮可点 |
| 发送通路 | 点“开 LED” | 日志出现AA 55 01 01 02 0D 0A |
| 接收通路 | 板子主动回一帧心跳 | 日志里出现且校验正确 |
| 执行动作 | 点开 / 关 LED | 板子 LED 亮灭跟随 |
| 蜂鸣器 | 点开 / 关蜂鸣器 | 响一声后停,不长鸣 |
| 连续操作 | 快速点二十次 | 不丢帧、界面不卡 |
有个细节容易被忽略:验证“关”比验证“开”更重要。只测开不测关,很容易漏掉 5.4 那种电平反相的问题。
整套跑通之后,我习惯把这次用到的版本号、COM 号、波特率、帧格式记在一个README里,放在工程根目录。下次换机器或者给别人搭环境,照着这份记录走一遍,比回忆快得多,也避免“上次明明能跑”的尴尬。
从那以后我每次配这套 Qt 上位机加 STM32 串口通信的环境,都强制先做一遍 USB 转 TTL 自回环,再动板子。这个习惯救过我很多次——大部分所谓的“板子问题”,其实一根杜邦线就能定位。希望帮到你。
本文还有配套的精品资源,点击获取