news 2026/9/14 7:40:45

QT四轴上位机实战:串口通信、姿态绘图与指令控制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
QT四轴上位机实战:串口通信、姿态绘图与指令控制

简介:面向QT与无人机开发初学者,这份资源提供了四轴飞行器上位机软件的初级版本。内容涵盖基于Qt的GUI控制面板、串口通信模块、下位机协议解析以及简单的实时数据显示逻辑,适合希望上手无人机地面站基础开发、理解上位机与飞控交互流程的读者。压缩包共34个文件,大小约10.28MB,包含可运行的exe程序、Qt核心dll动态库、下位机C语言工程源码,以及hex固件和编译中间文件(如obj、lst、m51)等,便于对照学习程序结构与编译产物。已有208人学习该资源,说明其具备一定的参考价值。通过这个初级版,读者可以熟悉QT控件布局与信号槽机制,掌握QSerialPort串口通信和基础协议解析方法,并了解陀螺仪等传感器数据的处理与状态显示思路,为进一步开发功能完善的无人机地面站打下基础。

1. 用 QT 做四轴上位机,先想清楚这版做到什么程度

拿到一个叫 QT四轴上位机初级版.zip.zip 的压缩包,常见的第一反应是双击打开找 exe。但这类初级版本质上是一个工程模板,它给你的是串口通信、数据解析、界面绘制三条线的最小骨架。四轴飞行器没有地面站也能飞,但调 PID、看姿态、切飞行模式时,没有上位机就只能靠串口打印,效率低得让人头疼。这篇博文按“串口链路—状态可视化—指令下发—打包发布”的顺序,讲一套能跑通的 QT Widgets 方案,新手跟着敲完,熟手也能拿去对照自己的代码。

2. 搭建 QT 串口通信:四轴上位机与飞控的底层数据链路

2.1 环境准备:选 QT 版本和开启 serialport 模块

做四轴上位机不需要上 QML,用 Widgets 足够。QT 5.15 或 6.x 都行,编译器建议 MSVC 或 MinGW 统一位数,最常见的组合是 5.15.2 + MSVC2019_64。网上大部分 qt 安装教程都停在“装完能写 HelloWorld”,忽略了 Kit 和模块勾选,导致后面#include <QtSerialPort/QSerialPort>都报错。在 Qt Creator 里创建 Qt Widgets Application 后,先改工程文件:

QT += core gui widgets serialport

如果你用 CMake 管理工程,则写成:

find_package(Qt6 REQUIRED COMPONENTS Widgets SerialPort) target_link_libraries(my_gcs PRIVATE Qt6::Widgets Qt6::SerialPort)

如果 qmake 里不加serialport,编译器只报头文件找不到,不会提示你去改 pro 文件,新手很容易卡在这。下表是几组常见组合和对应坑,选型时直接对照:

组合适用场景典型坑
5.15.2 + MSVC2019_64老项目兼容、教程资料最多目标机器缺 VC 运行库
6.5 + MinGW 64新项目、不依赖旧插件主控板和界面库位数必须一致
5.12 + ARM 交叉编译跑在板载屏幕上的 HMI交叉工具链里要自带 serialport 模块

串口模块是 Qt SerialPort,它底层封装了 Windows 的 COM 和 Linux 的 tty,不需要自己写 WinAPI,这也是选 QT 做初级版上位机最省事的地方。装完环境后,先用下面代码验证模块可用:

#include <QtSerialPort/QSerialPortInfo> qDebug() << QSerialPortInfo::availablePorts().size();

正常运行会打印当前系统可用的串口数量,不是 0 就说明模块加载成功。

2.2 枚举串口:让下位机一插就能被找到

打开串口之前,先做枚举。我一般会在主窗口构造函数里调用refreshPorts(),把可用串口填进 QComboBox,用户插拔 USB 转 TTL 后点刷新按钮重新扫描。代码如下:

void UiMainWindow::refreshPorts() { ui->comboPort->clear(); const auto infos = QSerialPortInfo::availablePorts(); for (const QSerialPortInfo &info : infos) { QString display = info.portName(); if (!info.description().isEmpty()) { display += " " + info.description(); } ui->comboPort->addItem(display, info.portName()); } }

addItem的第二个参数是 UserData,后面打开串口时直接用currentData().toString()取纯端口名,不会把描述文本带进来。注意这里description()在 Linux 下经常为空,所以要做判空拼接,否则下拉框会出现多余空格。接着设置串口参数:

m_serial.setPortName(ui->comboPort->currentData().toString()); m_serial.setBaudRate(115200); m_serial.setDataBits(QSerialPort::Data8); m_serial.setParity(QSerialPort::NoParity); m_serial.setStopBits(QSerialPort::OneStop); m_serial.setFlowControl(QSerialPort::NoFlowControl);

串口参数必须与飞控下位机保持一致。四轴飞控默认常用 115200 或 57600,数据位 8、无校验、1 停止位,也就是 8N1。如果下位机是 10Hz 发一帧,波特率高一点没关系;如果下位机每帧不到 20 字节,57600 足够。CH340 芯片在 Windows 下枚举名可能是COM3USB-SERIAL CH340,过滤时不要只按COM前缀判断,原因是有驱动会带描述后缀。

2.3 接收数据:粘包和半包怎么处理

下位机以 20~50Hz 发传感器数据,一次readyRead()读到的数据可能包含半帧、一帧或好几帧。初级上位机最常见的错误是在槽函数里“读一次解析一次”,遇到半帧直接丢,结果姿态值一顿一顿。我一般用 QByteArray 做接收缓冲,先找帧头,再按长度截整帧:

void UiMainWindow::onReadyRead() { m_buffer.append(m_serial.readAll()); while (m_buffer.size() >= 4) { // 找帧头 0xAA,如果缓冲区开头不是帧头就丢弃 int headIdx = m_buffer.indexOf(char(0xAA)); if (headIdx < 0) { m_buffer.clear(); break; } if (headIdx > 0) { m_buffer.remove(0, headIdx); } // 第二字节是负载长度,最小一帧 4 字节:帧头+长度+校验+数据 quint8 len = static_cast<quint8>(m_buffer.at(1)); if (len > 128) { // 长度异常,丢弃当前字节,避免死循环 m_buffer.remove(0, 1); continue; } if (m_buffer.size() < 2 + len + 1) { break; // 整帧没到齐,等下次 readyRead } QByteArray frame = m_buffer.left(2 + len + 1); if (verifyFrame(frame)) { handleFrame(frame); } m_buffer.remove(0, 2 + len + 1); } }

m_buffer是成员变量,跨槽函数保存。verifyFrame用累加和校验,初级版比 CRC16 简单,而且能挡住大部分串口干扰。参数len一定要做上限检查,否则一帧坏数据里长度被写成 255,m_buffer.size() < 2 + len + 1会一直为真,缓冲区越积越大,拖慢 UI。

需要注意:这种方式要求帧内不能出现 0xAA 转义,协议设计时让数据区里的 0xAA 做转义,或者干脆在协议里固定用 0xAA 0x55 作为双字节帧头。初级版用单字节帧头加长度就能满足,但下位机在发送浮点数据时,某个字节恰好等于 0xAA 的概率是 1/256,累积起来很容易误判。所以我建议协议里用双字节帧头,后续章节会展开讲。

3. 用 QT 绘图把四轴姿态画出来:从串口字节到飞行仪表盘

3.1 建立状态结构体,把字节解码成可读字段

解析不能每个界面控件都写一遍memcpy,那样改一个字段要动七八处。我习惯先建一个四轴状态结构体,把 handleFrame 里的载荷按字段填充进去,界面部分只读结构体。

struct QuadState { float roll = 0.0f; float pitch = 0.0f; float yaw = 0.0f; float height = 0.0f; float batteryVoltage = 0.0f; quint8 flightMode = 0; };

假设下位机发来的帧头之后是 12 字节姿态、4 字节高度、2 字节电压、1 字节模式,载荷排列固定且按小端序。解析时这样写:

QuadState state; const char *p = payload.constData(); memcpy(&state.roll, p, 4); memcpy(&state.pitch, p + 4, 4); memcpy(&state.yaw, p + 8, 4); memcpy(&state.height, p + 12, 4); quint16 voltageRaw = 0; memcpy(&voltageRaw, p + 16, 2); state.batteryVoltage = voltageRaw / 100.0f; state.flightMode = static_cast<quint8>(payload.at(18));

这里用memcpy而不是强制转换指针,是因为const char*float*在 ARM 平台可能遇到对齐异常。电压用原始值除以 100 是飞控端常用的定点缩放,可以在不传浮点的情况下保留两位小数。飞行模式建议用数字枚举而不是字符串,界面层负责把枚举映射成中文。

3.2 自定义仪表盘:用 QPainter 做高度表和电池进度条

四轴上位机初级版不需要做三维姿态球,一个带刻度的圆表盘加一个电池进度条就够用了。这里要用到 qt 绘图,核心是继承 QWidget 重写paintEvent。下面是一个简化高度表的核心绘制:

void AltimeterWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); QRectF rectF = rect().adjusted(10, 10, -10, -10); painter.drawEllipse(rectF); // 值范围 0~100 映射到 135°~405°,刻度间隔 10 for (int value = 0; value <= 100; value += 10) { double angle = 135 + (270.0 * value / 100); double rad = qDegreesToRadians(angle + 90); // 计算内外圈刻度点 QPointF inner( rectF.center().x() + qCos(rad) * rectF.width() * 0.35, rectF.center().y() + qSin(rad) * rectF.height() * 0.35); QPointF outer( rectF.center().x() + qCos(rad) * rectF.width() * 0.45, rectF.center().y() + qSin(rad) * rectF.height() * 0.45); painter.drawLine(inner, outer); } }

角度偏移+90是因为qCos(0)指向水平右侧,而仪表盘 0 应该在垂直上方。用rectF.width()做系数能保证窗口拉伸后刻度跟着变,不会因为固定像素而变形。更新数值时不要直接调用repaint(),用update()让 QT 在下一帧统一刷新,否则高频串口数据会让事件循环卡死。

电池显示直接用 QProgressBar 改样式就能满足初级版,不需要特意自定义。如果想把低电量标红、正常电量标绿,可以用样式表:

ui->batteryBar->setRange(0, 100); ui->batteryBar->setValue(static_cast<int>(state.batteryVoltage * 10)); if (state.batteryVoltage < 3.5f) { ui->batteryBar->setStyleSheet("QProgressBar::chunk { background: red; }"); }

进度条范围设成 0~100,电压 3.0V 对应 30%,4.2V 对应 42%。这里乘以 10 而不是 100,是因为信号数值切换太快会导致进度条闪烁,初级版只要求“看得见趋势”。

3.3 波形曲线:QCustomPlot 比 Qt Charts 更适合高频数据

姿态角、高度和电压如果想看实时曲线,常选 QCustomPlot。qt 绘图效率比较下来,QCustomPlot 在动态追加几百个点后依然能维持 30fps,而 Qt Charts 的动态刷新在 PC 上也能跑,但封装偏重,改样式更麻烦。四轴初级版用 QCustomPlot 就够了。

ui->plot->addGraph(); ui->plot->graph(0)->setPen(QPen(Qt::green)); ui->plot->yAxis->setRange(-180, 180); ui->plot->xAxis->setRange(0, 100);

在定时器槽里更新:

ui->plot->graph(0)->addData(m_pointIndex++, state.roll); ui->plot->xAxis->setRange(m_pointIndex - 100, m_pointIndex); ui->plot->replot();

定时器用 50ms 刷新一次,也就是 20Hz,串口 50Hz 的数据并不需要每一笔都画进曲线,界面刷新率高于 30Hz 就会浪费 CPU。replot()在 QCustomPlot 里比update()更直接,因为它绕过 QT 的合成器直接触发绘制,但也不能密集到每次串口读包都调,否则 UI 线程会被拖死。

4. 控制指令下发与协议设计:让四轴上位机从监视器变成遥控台

4.1 指令帧协议:双字节帧头加序号

上位机不只是读数据,还要下发解锁、加锁、切模式、调油门。如果再沿用上一章的“长度+累加和”格式,会缺少“命令是否发到”“下位机是否执行”的确认依据。因此指令帧我一般单独设计一组格式,与遥测帧分开:

字段长度说明
帧头2 字节0xAA 0x55
指令类型1 字节0x01 解锁,0x02 加锁,0x03 飞行模式,0x04 遥控量
序号1 字节0~255 回绕
数据长度1 字节数据区字节数
数据N 字节具体参数,遥控量时是 4 个字节的油门/滚转/俯仰/偏航
校验1 字节整个帧的累加和低 8 位

发送代码封装成一个函数:

void UiMainWindow::sendCommand(quint8 type, const QByteArray &data) { QByteArray frame; frame.append(char(0xAA)); frame.append(char(0x55)); frame.append(char(type)); frame.append(char(m_sendSeq++)); frame.append(char(data.size())); frame.append(data); quint8 crc = 0; for (int i = 0; i < frame.size(); ++i) { crc += static_cast<quint8>(frame.at(i)); } frame.append(char(crc)); m_serial.write(frame); }

m_sendSeq是成员变量,每发一帧自增,可以让下位机检测丢帧。累加和不是最强的校验,但对付四轴这种距离短、干扰小的现场足够,下位机解析成本也低。数据长度用 1 字节,说明单条指令最多 255 字节,四轴遥控量远到不了这个数。

4.2 界面槽函数和控制定时器

按钮触发很简单,点“解锁”时调用:

void UiMainWindow::on_unlockButton_clicked() { sendCommand(0x01, QByteArray()); }

但遥控量不能靠点击,得用 QTimer 周期性发送。常见做法是界面里四个 slider 或者 Joystick 控件,每次值改变时把最新控制量写进成员变量,定时器按 20Hz 发送:

QByteArray data; union { float f; char bytes[4]; } convert; convert.f = ui->throttleSlider->value(); data.append(convert.bytes, 4); // 同理 append pitch/roll/yaw sendCommand(0x04, data);

这里用union转浮点,和接收端memcpy的思路一致。滑块默认范围如果设成 0~1000,需要在下位机做对应缩放。注意 float 的位模式不能直接当作字节送到不同单片机上,两端都必须按小端序解释。如果下位机用 STM32 也按小端解析,这样就通。

4.3 ACK 确认和超时重发

下位机收到指令后回复确认帧,比如 0x81 表示解锁成功、0x82 表示加锁成功。初级版不要求完整重传机制,但至少要区分“命令没发出去”和“命令被拒绝”。我在接收解析里增加一个命令回执表:

QHash<quint8, QByteArray> m_pendingCmd;

发送时先存储,再启动单次定时器:

m_pendingCmd.insert(type, data); QTimer::singleShot(500, this, [this, type]() { if (m_pendingCmd.contains(type)) { m_pendingCmd.remove(type); ui->statusLabel->setText(QStringLiteral("指令 %1 超时无应答").arg(type)); } });

收到 ACK 时移除对应项。油门遥控这类周期性指令不需要 ACK,只有解锁、加锁、切模式这类离散命令才做确认。这里要注意闭包捕获type,不要捕获this之外的悬空引用。如果用户下位机不支持 ACK,就删掉这段逻辑,不要让 UI 弹错误提示误导操作员。

5. 四轴上位机发布时的几个坑:windeployqt 和插件路径

开发机上能跑,不等于拷到别的电脑能跑。QT 程序发布最常遇到的是打开 exe 报错“缺少 DLL 文件”,或控制台提示qt_qpa_platform_plugin_path找不到。用 windeployqt 处理时,注意顺序和参数:

cd build\my_gcs windeployqt my_gcs.exe --release --no-translations

执行后会自动复制 Qt 运行库、平台插件和 styles 插件到 exe 所在目录。如果程序用了 serialport,windeployqt 在多数情况下会自动带出 QtSerialPort.dll,但有些历史版本不会,需要从编译器目录手动复制。复制完看目录里有没有platforms/qwindows.dll,没有它程序会直接闪退,报错信息就是qt_qpa_platform_plugin_path相关。

发布前还有两件事值得做。第一,在 exe 同目录放一个qt.conf,内容固定为:

[Paths] Plugins = plugins Prefix = .

作用是让程序从自身目录找插件,而不是跑到注册表或源码目录找,这也是解决“开发机能跑、别的机器不能跑”高频办法。第二,检查串口模块的依赖库,用dumpbin /dependents my_gcs.exe可以列出直接依赖的 DLL。如果看到Qt5SerialPort.dll,就把 Qt 安装目录下对应编译器版本的 DLL 一起复制。

四轴上位机的界面里串口下拉框、曲线绘制、电池进度条这三样东西,在发布后最容易出现风格错乱,最常见原因是编译器版本与 windeployqt 版本不一致。发布包必须与编译器一致,比如用 5.15.2 + MSVC2019_64 构建,就不能用 5.15.2 MinGW 的 windeployqt,否则运行到replot()或中文渲染时会崩溃。发布完成后,建议找一台没装 QT 的干净虚拟机跑一遍“打开串口—接收数据—曲线刷新—发送指令”四个步骤,再交付给用户。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 7:40:21

Lithe-IDEA:面向Spring Boot全生命周期的轻量级开发协作者

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 7:38:39

Ant Design源码审阅:大厂级React+TS工程实践证据链分析

1. 项目概述&#xff1a;这不是一次普通代码走读&#xff0c;而是一场面向工程落地的“证据链式”审阅Valhalla 静态工程审阅系列&#xff0c;名字里带“Valhalla”不是为了炫技——北欧神话中英灵殿&#xff08;Valhalla&#xff09;是为真正经受住战场考验的战士准备的归宿。…

作者头像 李华
网站建设 2026/9/14 7:38:01

微信聊天记录导出:WeChatMsg 免费把对话存成 HTML、Word、CSV

微信聊天记录导出&#xff1a;WeChatMsg 免费把对话存成 HTML、Word、CSV 【免费下载链接】WeChatMsg 提取微信聊天记录&#xff0c;将其导出成HTML、Word、CSV文档永久保存&#xff0c;对聊天记录进行分析生成年度聊天报告 项目地址: https://gitcode.com/GitHub_Trending/w…

作者头像 李华
网站建设 2026/9/14 7:37:33

context-mode:基于SQLite+FTS5+BM25的本地上下文感知实践

1. 项目概述&#xff1a;什么是 context-mode&#xff1f;它不是玄学&#xff0c;而是可落地的上下文感知机制 “context-mode”这个词最近在开发者社区里频繁出现&#xff0c;尤其和 MCP、SQLite、FTS5、BM25 这些词绑在一起刷屏。很多人第一反应是&#xff1a;“又一个新造概…

作者头像 李华
网站建设 2026/9/14 7:37:27

Python文件操作全解析:open读写、编码与pathlib实战

1. 文件操作在Python学习路线中的位置&#xff1a;为什么这里最容易懵如果你正跟着教程一路学到文件操作&#xff0c;我猜你现在的心情大概率是&#xff1a;前面的列表、字典、循环、函数都还好好的&#xff0c;一碰到open()就开始各种花式报错——文件找不到、中文乱码、写入的…

作者头像 李华
网站建设 2026/9/14 7:36:06

SPL框架:用SQL思维优化LLM提示词工程

1. 项目概述&#xff1a;当SQL思维遇上LLM开发最近在折腾大模型应用开发时&#xff0c;发现一个特别有意思的现象&#xff1a;我们团队三个工程师写的Prompt&#xff0c;对同一个问题能给出三种不同风格的答案。更糟的是&#xff0c;每次换模型都得重写整套提示词&#xff0c;调…

作者头像 李华