ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

无人机地面站Qt源码解析:从串口通信到自绘仪表盘

无人机地面站Qt源码解析:从串口通信到自绘仪表盘 简介一套基于QT Creator开发的无人机地面站完整工程涵盖通信、控制与显示模块适合无人机应用开发者、算法研究者及智能飞行爱好者学习参考。压缩包内共181个文件以C源码cpp/h为主另有pro/pri工程配置、png/qrc/ui界面资源以及少量dll/lib运行库和css样式文件整体仅1.8MB便于快速下载与编译。源码中实现了串口通信、地面站仪表盘、飞机参数设置等核心模块并包含定位导航、避障、路径规划等无人机算法的可参考实现可帮助读者理解地面站与飞控的数据交互流程。目前已有272人学习。通过研究工程结构、阅读代码注释与配置说明能够掌握QT跨平台界面开发与无人机软件系统集成的基本方法对入门无人机智能控制领域具有实践价值。1. 无人机地面站zip包到手为什么先看qextserialport这条串口线一个标注“通过QT Creator编译”的无人机地面站源码包解压后通常是一堆平铺的.cpp文件。新手习惯先找窗口和控件老手会先找串口封装层——因为地面站能不能跑起来不取决于界面多漂亮而取决于下行的控制帧和上行的遥测帧是否走得通。这套源码里frameserial.cpp负责串口收发qextserialport.cpp负责平台适配package_ano_422.cpp负责协议解析三者构成数据链路的主干gaugeplane.cpp这类自绘控件只是把解析结果画出来而已。对想做二次开发的Qt应用工程师以及想弄懂无人机调参闭环的算法工程师来说这条链路才是值得反复读的部分。2. 串口通信链路qextserialport的选型、移植与参数配置2.1 为什么老地面站项目普遍用qextserialport而不是QSerialPortQt 5.1 之后官方才有稳定的 QtSerialPort 模块而这套源码里保留着 qextserialport.cpp、qextserialport_unix.cpp、qextserialport_win.cpp 三个文件说明它从 Qt 4 时代一路带过来跨平台串口逻辑完全自持不依赖 Qt 版本迭代。qextserialport 的价值不在于功能多而在于接口足够薄构造时指定设备名和查询模式设置波特率、数据位、停止位、校验位open 之后 read/write几乎不用关心底层是 termios 还是 Win32 DCB。它的 queryMode 设计对地面站尤其友好QextSerialPort::EventDriven模式下串口有数据到达时发出readyRead()信号配合 Qt 事件循环做异步读取不会阻塞界面刷帧Polling模式则适合在无事件循环的算法线程里定时读。新入门的人常在这里犯迷糊——选了 EventDriven 却忘了串口对象必须创建在有事件循环的线程里结果readyRead永远不触发。2.2 三个源文件的分工与 pro 文件条件编译qextserialport.cpp 是平台无关的封装主体负责把对上层暴露的 API 统一起来qextserialport_unix.cpp 走 POSIX termios处理 tcgetattr、cfsetispeed、非标准波特率等细节qextserialport_win.cpp 走 CreateFile DCB overlapped I/O。跨平台能力就是这么拆出来的。项目里保留这套文件意味着你拿到 zip 后不需要额外安装第三方库只要把对应平台源文件加进工程就行。在 QT Creator 里打开项目后第一件事是检查 .pro 文件是否把这些串口文件按平台分开编译规范写法是QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets SOURCES frameserial.cpp \ frameplanesetting.cpp \ serialdownload.cpp \ gaugeplane.cpp \ progressbarwater.cpp \ package_ano_422.cpp \ qextserialport.cpp unix: SOURCES qextserialport_unix.cpp win32: SOURCES qextserialport_win.cppqmake 的unix:和win32:作用域指令会在对应平台上自动追加源文件这样同一个 zip 在 Windows 和 Linux 下都能编译。注意这里不能把两个平台文件同时加进 SOURCES否则会重复定义端口操作函数。如果你准备把项目切成 CMake也要保留同样的平台分支逻辑只是把作用域换成if(UNIX)和if(WIN32)。2.3 串口初始化代码与地面站常用配置frameserial.cpp 里通常会封装一个打开串口的方法核心代码大约是这样#include qextserialport.h QextSerialPort *openGroundStationPort(const QString portName) { // EventDriven有数据到达时发 readyRead 信号适合 GUI 线程异步读取 QextSerialPort *port new QextSerialPort( portName, QextSerialPort::EventDriven); port-setBaudRate(BAUD57600); // 与飞控固件串口参数保持一致 port-setDataBits(DATA_8); port-setParity(PAR_NONE); port-setStopBits(STOP_1); port-setFlowControl(FLOW_OFF); // 地面站通常不启用硬件流控 port-setTimeout(10); // 仅 Polling 模式生效 if (!port-open(QIODevice::ReadWrite)) { qWarning() open serial failed: port-errorString(); delete port; return nullptr; } connect(port, QextSerialPort::readyRead, [port]() { emit serialDataArrived(port-readAll()); }); return port; }构造函数第二个参数选了EventDriven对应项目里最常见的异步读取写法setTimeout(10)在事件驱动模式下没有实际意义但如果这段代码被人改成 Polling 模式它就成了每次 read 的阻塞超时单位是毫秒。波特率这里填BAUD57600是枚举值某些非标准波特率比如 125000、250000在 qextserialport 里没有对应枚举需要走setBaudRate(125000)以外的自定义分支这是做飞控调试时最常踩的坑之一。地面站串口参数选型上下面这组是常见配置不是绝对标准但覆盖面很广参数项常用值说明波特率57600 / 115200短距离 USB 串口下 115200 更稳数传模块常降到 57600数据位8串口传输的默认选择停止位1配合 8 数据位无校验校验位无ANO 422 协议自带累加校验链路层不需要再开校验流控无数传模块用硬件流控会导致收发互相等待2.4 串口层最常见的三个编译和运行问题在 Linux 下编译通过但打开设备失败90% 是权限问题。当前用户不在 dialout 组里时/dev/ttyUSB0 只有 root 能读写运行日志会报 Permission denied解决办法是把用户加进组并重新登录sudo usermod -a -G dialout $USER第二个常见问题是重插 USB 转串口设备后设备名漂移ttyUSB0 变成 ttyUSB1。地面站代码里如果把设备名写死会导致一连串连接失败我一般会在启动参数里加一个设备名输入框或者用 udev 规则按 USB 序列号固定别名。第三个问题是串口被占用——程序异常退出后句柄未释放或者另一个地面站实例还在跑open 会返回设备或资源忙此时用lsof /dev/ttyUSB0找到占用进程先杀进程再重新连接。3. ANO 422协议解析状态机与下行参数封包3.1 qextserialport只保证字节流协议边界要自己做串口本身没有消息边界底层驱动按字节到达的先后顺序把数据交给上层一次readAll()可能只读到半帧也可能一次读到两帧半。所以 frameserial.cpp 里拿到原始字节后不能直接拿去画仪表必须先把字节流送进协议解析器由它负责截出完整的一帧。package_ano_422.cpp 就是一个典型的协议解析模块名字里的 422 指的是地面站与飞控之间的消息协议格式而不是 RS-422 电气接口。解析这类流式协议最稳的做法是状态机一个字节一个字节地喂每个字节推进一次状态状态收敛到“帧头已对齐、长度已解析、数据区已收满、校验已核对”时输出一个完整帧。不要用字符串查找或正则去拆二进制帧地面站的数据流里什么字节都可能出现只有状态机能同时处理半包、粘包和错误字节穿插这三种情况。3.2 ANO 422帧格式与校验策略以这套源码里最常见的封包方式为例一帧数据的典型布局如下字节偏移字段长度说明0-1帧头2固定 0xAA 0xAF用于字节对齐2数据区长度1数据区字节数不含命令 ID3命令 ID1消息类型如 0x01 表示姿态数据4 .. 3n数据区n与命令对应的参数4n校验和1命令 ID 与数据区的累加和取低 8 位这里把命令 ID 单独放在长度字段之后好处是解析器不需要知道每种消息的数据长度——长度字段已经告诉你后面要收多少字节坏处是校验范围必须写清楚。我习惯把校验定义为“从命令 ID 开始到数据区最后一个字节的累加和”帧头和长度字段不计入这样一旦数据区中间错一位校验必挂丢帧率很快暴露出来。3.3 状态机解析核心代码package_ano_422.cpp 里可以拆出这样一个逐字节解析的成员函数int PackageANO422::pushByte(unsigned char c, unsigned char *frame, int maxLen) { switch (m_state) { case 0: // 等第一个帧头 if (c 0xAA) m_state 1; break; case 1: // 等第二个帧头 m_state (c 0xAF) ? 2 : 0; break; case 2: // 已经拿到帧头读长度字段 m_len c; if (m_len 0 || m_len maxLen - 5) { // 长度越界直接复位 m_state 0; break; } m_idx 3; // 帧头2字节 长度1字节后开始存数据 m_sum 0; frame[0] 0xAA; frame[1] 0xAF; frame[2] c; m_state 3; break; case 3: // 收命令 ID 数据区共 m_len 1 字节 frame[m_idx] c; m_sum c; if (m_idx m_len 4) // 命令ID占1字节所以边界是 len 4 m_state 4; break; case 4: // 校验字节 frame[m_idx] c; m_state 0; return (m_sum c) ? (m_len 5) : 0; } return 0; }状态机从m_idx 3开始存数据是因为 frame 数组前三个位置已经被帧头和长度占掉。case 3 里的m_len 4是这么算的命令 ID 占 1 字节数据区占 m_len 字节两者合计 m_len 1 个字节起始下标是 3所以收满后 m_idx 会停在 3 m_len 1 m_len 4。校验和累加m_sum c覆盖了命令 ID 和数据区与帧格式表里定义的校验范围一致。返回值是整帧长度0 表示校验失败上层据此决定丢弃还是纠错重传。调用方拿到的是一串没有意义的字节流就得在读取回调里逐字节喂给这个函数int len anoParser.pushByte((unsigned char)c, frame, sizeof(frame)); if (len 0) { // 到这里才说明得到了一帧完整且校验通过的数据 handleAnoFrame(frame, len); }如果 10Hz 的遥测数据总在handleAnoFrame里打印校验失败警告先别怀疑飞控用十六进制 dump 把原始字节存下来数一数大概率是串口波特率配置错了半个档位或者有另一个程序同时打开了同一个串口在抢数据。3.4 下行封包参数下发与地面站控制闭环无人机算法在地面站里最直接的落点就是下行封包。frameplanesetting.cpp 管参数界面serialdownload.cpp 管把参数写进飞控——这两部分数据链路都走 422 协议。比如要下发一组 PID 参数不能把浮点数直接裸发得先定义好命令 ID、字节序和定标系数再组装成一帧QByteArray buildPidFrame(float kp, float ki, float kd) { QByteArray payload; payload.append(0x10); // 命令 IDPID 参数写入 union { float f; unsigned char b[4]; } conv; conv.f kp; payload.append(conv.b, 4); // 小端序需与飞控一致 conv.f ki; payload.append(conv.b, 4); conv.f kd; payload.append(conv.b, 4); QByteArray frame; frame.append((char)0xAA); frame.append((char)0xAF); frame.append((char)payload.size() - 1); // 长度字段不含命令 ID frame.append(payload); unsigned char sum 0; for (int i 0; i payload.size(); i) sum (unsigned char)payload.at(i); frame.append((char)sum); return frame; }浮点数按 IEEE 754 转成 4 字节后直接 append这里默认飞控端按小端序解析与 x86 和绝大多数 ARM 平台一致。payload.size() - 1是长度字段的精髓——payload 里含命令 ID而协议要求长度只统计数据区所以必须减 1。整帧算完校验和再写串口port-write(frame)只是把数据送进驱动缓冲区真正的物理发送由底层完成高频下发时注意别在 UI 线程里连续 write 大帧否则界面刷帧会被卡成幻灯片。4. 自绘仪表盘与Qt刷新策略gaugeplane的绘制主线4.1 为什么地面站界面不贴图而是用QPainter重绘gaugeplane.cpp 是无人机地面站里最有代表性的自绘控件一个 QWidget 子类重写paintEvent用 QPainter 把表盘背景、刻度线、指针和数值全部画出来。压缩包里还有 progressbarwater.cpp同样是用 QPainter 画水的填充效果说明整个 UI 走的是矢量绘制路线不依赖 PNG 贴图。好处有三任意缩放不糊、换肤只改画笔颜色、运行时改量程不用换素材。代价是绘制开销大一点但地面站仪表盘本身刷新率不高10Hz 到 30Hz 完全在 Qt 的承受范围内。这类控件的核心不是画笔 API而是坐标变换。把复杂的角度计算交给QPainter::translate和rotate代码会简洁得多也更容易维护。4.2 表盘绘制核心代码gaugeplane 的paintEvent可以拆成三段准备画布、画静态背景、画动态指针。一个可以抄作业的骨架如下void GaugePlane::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); const int side qMin(width(), height()); p.translate(width() / 2.0, height() / 2.0); // 原点移到控件中心 p.scale(side / 240.0, side / 240.0); // 按控件大小缩放坐标 drawScale(p); // 画刻度与表盘 drawPointer(p); // 画当前指针 drawValue(p); // 画数值文本 } void GaugePlane::drawPointer(QPainter *p) { // 把当前值映射到 -135° ~ 135° 的表盘范围 double angle (m_value - m_rangeMin) / (m_rangeMax - m_rangeMin) * 270.0 - 135.0; p-save(); p-rotate(angle); // 旋转坐标系指针默认朝 12 点方向 QPolygon tri; tri QPoint(0, -90) // 指针尖 QPoint(-7, 10) // 左下角 QPoint(7, 10); // 右下角 p-setPen(Qt::NoPen); p-setBrush(QColor(0xD8, 0x3A, 0x2E)); p-drawConvexPolygon(tri); p-restore(); // 恢复旋转前的坐标系 p-setBrush(QColor(40, 40, 40)); p-drawEllipse(-12, -12, 24, 24); // 指针转轴 }translate把坐标原点移到控件中心scale则把逻辑坐标系固定到 240x240这样控件无论被拉伸成 200 还是 480 像素指针长度和刻度间距都按比例缩放不会因为窗口变大而变形。save和restore必须成对出现否则旋转角度会叠加到下一次绘制上指针越转越偏。指针的三角形定义在 12 点方向通过rotate(angle)转过去这个设计省去了手写三角函数算顶点坐标的麻烦。4.3 数据到达后怎么触发重绘刷新策略选择仪表盘数据来自串口解析线程或者串口信号回调。直接把每帧数据都调update()是最简单的写法但遇到 GPS 数据 50Hz、姿态数据 200Hz 的地面站重绘频率会白白消耗 CPU界面还可能闪烁。常见做法是分两种策略按需选刷新策略实现方式适用场景QTimer 固定刷新每 50ms 调一次update()数据源频率不稳定或带有突发性数据到达即重绘readyRead 回调里直接update()数据频率低且均匀如 10Hz 遥测节流重绘记录上次重绘时间间隔不足 33ms 就跳过高频数据源 希望限制 UI 开销我倾向于第三种。地面站里很多数据是“高频到达但低频变化”比如温度传感器每 10ms 上报一次但数值可能 1 秒才变 0.1 度每次都重绘非常浪费。给 GaugePlane 加一个成员变量记录上次 paint 的时间戳if (m_lastPaintTimer.isValid() m_lastPaintTimer.elapsed() 33) return; m_lastPaintTimer.restart(); update();33ms 对应约 30 帧每秒的人眼流畅上限十几路仪表同时刷也不会有明显掉帧感受。这个节流判断放在数据更新的 setter 里而不是paintEvent里避免把绘制函数写脏。4.4 QPainter自绘仪表盘的三个性能坑第一Antialiasing必须开但不要在paintEvent里临时开关渲染提示构造函数里设一次就好。第二指针三角形、刻度线这些常量不要在 paint 里反复new定义成成员变量或者用栈对象减少堆分配对绘制帧时间的扰动。第三如果控件背景完全静态比如表盘底纹和刻度线永远不变可以把它们先画到一张 QPixmap 上paintEvent 里只贴图、画指针这样刷新成本从“画 200 条线”降到“画 1 张图 1 个多边形”在低配工控机上体感差异非常明显。5. 构建、交叉编译与离线回放调试技巧5.1 从zip到QT Creator跑通的顺序拿到压缩包后先别急着双击 .pro按这个顺序做能少踩一半坑解压到纯英文路径目录里不能有中文和空格否则 qmake 在 Windows 上会因路径编码问题报错打开 QT Creator 后选择“打开项目”定位到 .pro 文件在弹出的 Kit 选择界面选 Desktop Qt 对应版本不要选 Android 或 WebAssembly Kit最后点构建先确认阴影构建目录已勾选构建产物会输出到 build-xxx 文件夹不会污染源码目录。如果是直接从 GitHub 页面下载的 zip 而不是 git clone解压后没有 .git 目录想关联上游仓库时直接 pull 到非空目录会报“refusing to merge unrelated histories”。常见做法是git init、git remote add origin 仓库地址后先git fetch再用git rebase origin/main把本地改动挪到上游代码之上而不是在未关联历史的分支上直接 pull。5.2 构建报错排查与静态编译整个编译期常见报错集中在四类按频率排序如下报错特征根因处理方式could not find EOCD或invalid zip archive压缩包下载不完整换下载源或重新下载用 7-Zip 测试压缩包完整性No rule to make target qextserialport.cpp.pro 里文件路径写错检查源码文件和 .pro 是否在同一目录层级undefined reference to QextSerialPort::openqextserialport_unix.cpp 未参与编译确认 .pro 里 unix 作用域分支存在运行时报Permission denied后退出串口设备无权限加入 dialout 组或临时sudo chmod 666 /dev/ttyUSB0如果想把最终程序发给别人免装 Qt 运行需要走静态编译。用 Qt 官方源码静态构建一次工具链再配置到 QT Creator 的 Manual Kit 里项目本身不需要改任何代码。构建静态 Qt 时注意加-static -release去掉不需要的模块能明显缩短编译时间./configure -static -release -prefix /opt/qt-static \ -opensource -confirm-license -skip webengine -nomake examples make -j8这套项目基于 qmake切 CMake 时除了把 .pro 里的 SOURCES 和 HEADERS 平移进 CMakeLists还要把 unix/win32 平台分支用if(UNIX)和if(WIN32)重写同时保持 qextserialport 两个平台文件互斥编译这是最容易被忽略的一步。5.3 用离线串口日志回放协议栈参数配置界面调好了、仪表盘也画出来了但飞控不在手边怎么验证 package_ano_422.cpp 的解析逻辑答案是让协议层脱离串口单独跑。先在真机上用任意串口助手把原始数据流存成 .bin 文件然后写一个回放脚本把文件字节逐批喂给pushByte绕过硬件直接测状态机QFile logFile(/tmp/telemetry.bin); if (!logFile.open(QIODevice::ReadOnly)) return; QByteArray chunk; while (!logFile.atEnd()) { chunk logFile.read(128); for (char c : chunk) { int len anoParser.pushByte((unsigned char)c, frame, sizeof(frame)); if (len 0) { handleAnoFrame(frame, len); parseOk; // 统计校验通过帧数 } else { parseErr; } } } qInfo() parse ok: parseOk error: parseErr;回放时把parseOk和parseErr两个计数输出到控制台协议栈的健壮性一目了然如果错误帧数占比超过 1%优先检查波特率是否和录制时一致其次检查校验和的计算范围。用这个 log 回放技巧新进的开发者不需要连飞控就能熟悉帧结构和解析流程把真机留到最后的联调阶段至少能省出半天排错时间。本文还有配套的精品资源点击获取
返回列表