平衡球 Linux Qt 上位机:实现与架构

7885 字
39 分钟
平衡球 Linux Qt 上位机:实现与架构

本文档的目标不是只告诉你“如何运行”,而是带你从入口、协议、 Linux I/O、线程通信一直读到 Qt 界面。读完后,你应该能自己说清数据 从 GD32 到曲线的完整路径,也能够继续修改代码。

上位机连接 PTY 模拟器后的运行界面
上位机连接 PTY 模拟器后的运行界面


1. 这个升级项目解决了什么问题#

原工程的 GD32F407 负责采集小球位置、运行 FreeRTOS 任务、执行 PID 计算并 控制舵机。Linux 端不代替 MCU 的实时控制环,而是承担上位机职责:

  • 从串口持续接收 B0 位置帧和 B1 PID 帧。
  • 以曲线和数值展示当前位置、目标位置和 PID 参数。
  • 下发 F0 目标位置命令和 E0 PID 参数命令。
  • 将运行数据同时记录为 CSV 并上传到虚拟机 192.168.28.128 上的 MySQL。
  • 使用 QSqlTableModel 在界面中查询和刷新历史数据。
  • 在没有开发板时,通过 PTY 伪串口模拟真实设备。

这个边界很重要:PID 仍然在 MCU 上执行。Linux 调度延迟不确定,串口也有 传输延迟,如果把 10 ms 或 50 ms 控制环移到普通 Linux 用户态,控制周期抖动会 明显变大。上位机适合做人机交互、数据管理和参数配置,MCU 适合做硬实时控制。


2. 开发和运行环境#

当前工程已在以下环境实际编译和运行:

项目当前环境
Linux 虚拟机Ubuntu 24.04.3 x86_64
虚拟机地址bob@192.168.28.128
远端工程/home/bob/balance-ball-linux-host/linux-host
QtQt 6.4.2 Widgets
Qt 用户安装目录/home/bob/.local/balance-qt6
构建工具CMake + GNU g++ 13
Linux I/Otermios + epoll + eventfd + timerfd
界面线程模型Qt 主线程 + 一个串口 QThread

Qt 被解包到用户目录,不需要 sudo。tools/install_user_qt6.sh 会下载 Qt Base 的开发文件、运行库和 QPA 图形插件,然后生成 env.sh。


3. 目录结构与每个目录的职责#

linux-host/
├── CMakeLists.txt # 构建目标和测试入口
├── balance-console-screenshot.png # 真实 PTY 联调截图
├── 项目实现与架构阅读指南.md # 本文档
├── src/
│ ├── main.cpp # Qt 程序入口和命令行参数
│ ├── protocol/
│ │ ├── protocol_codec.h # 协议数据结构和公共接口
│ │ └── protocol_codec.cpp # 组帧、拆帧、大小端转换
│ ├── io/
│ │ ├── serial_worker.h # 串口工作线程声明
│ │ └── serial_worker.cpp # termios/epoll/eventfd/timerfd
│ └── app/
│ ├── database_config.h # MySQL 连接参数与建表 SQL
│ ├── main_window.h # 主窗口状态和接口
│ ├── main_window.cpp # 界面、信号槽、CSV/MySQL 记录
│ ├── position_plot.h # 实时曲线控件声明
│ └── position_plot.cpp # QPainter 自绘曲线
├── tests/
│ ├── protocol_codec_test.cpp # 协议单元测试
│ ├── database_integration_test.cpp # 真实 MySQL 端到端测试
│ ├── main_window_test.cpp # 目标/PID 界面语义测试
│ └── serial_worker_test.cpp # 真实 PTY 串口集成测试
└── tools/
├── device_simulator.cpp # 无板卡设备模拟器
├── install_user_qt6.sh # 无 sudo 安装 Qt
├── run_console.sh # 构建并启动上位机
└── run_simulator.sh # 构建并启动模拟器

这里有一个值得保留的工程边界:protocol 不知道串口,io 不知道 界面布局,app 通过已解析数据更新界面。这使协议可以独立测试,也使串口实现 之后能够替换成 TCP 而不重写整个界面。


4. 整体架构和线程边界#

B0/B1/F0/E0

bytesReceived 排队信号

enqueueFrame

GD32 / PTY 模拟器

Linux 串口从设备

SerialWorker

termios + epoll

StreamParser

粘包/拆包/校验

MainWindow

Qt 主线程

PositionPlot

CSV 文件

MySQL

balance_ball

QSqlTableModel

历史查询

B0/B1/F0/E0

bytesReceived 排队信号

enqueueFrame

GD32 / PTY 模拟器

Linux 串口从设备

SerialWorker

termios + epoll

StreamParser

粘包/拆包/校验

MainWindow

Qt 主线程

PositionPlot

CSV 文件

MySQL

balance_ball

QSqlTableModel

历史查询

4.1 Qt 主线程负责什么#

  • 创建和绘制所有 QWidget。
  • 响应连接、下发、记录等用户操作。
  • 调用 StreamParser::feed() 解析已收到的字节块。
  • 更新数值和曲线,并将数据写入 CSV/MySQL。
  • 通过 QSqlTableModel 在对话框中展示 MySQL 历史记录。

Qt 规定 QWidget 只能在 GUI 主线程操作,所以 SerialWorker 不会直接调用 任何界面函数。

4.2 串口工作线程负责什么#

  • 打开和配置串口。
  • 在 epoll_wait() 中等待串口可读、可写、唤醒和定时事件。
  • 将收到的字节以 Qt 信号发给主线程。
  • 将多个下发帧按入队顺序写入串口。
  • 断线后每秒尝试重连,并每秒上报通信统计。

4.3 跨线程通信为什么是安全的#

bytesReceived 等信号以 Qt::QueuedConnection 连接到主窗口。工作线程发出信号 时,Qt 只把参数复制到主线程事件队列;真正的界面槽函数会在主线程执行。

主线程调用 enqueueFrame() 时,发送队列受 QMutex 保护。入队后通过 eventfd 唤醒 epoll_wait()。这不依赖 Qt 工作线程的事件循环,因为 SerialWorker::run() 本身就是一个持续运行的 Linux 事件循环。


5. 协议格式:先把“12 字节和 16 字节”说清楚#

5.1 通用帧结构#

AA AA CMD LEN PAYLOAD... CHECKSUM BB
字段长度含义
帧头2 字节固定为 AA AA
CMD1 字节B0/B1/F0/E0
LEN1 字节只表示 PAYLOAD 的字节数
PAYLOADLEN 字节命令数据
CHECKSUM1 字节CMD + LEN + PAYLOAD 的 8 位累加和
帧尾1 字节固定为 BB

MCU 的上报帧之后可能还有 0A 换行字节。它是传输时附加的分隔字节, 不在 LEN 中,也不参与校验。解析器会把它当作帧间噪声跳过。

5.2 B0 位置帧逐字节解释#

假设位置是 120 mm,即 0x0078:

索引 0 1 2 3 4 5 6 7
字节 AA AA B0 02 00 78 CHECKSUM BB
含义 帧头 命令 长度 高 低 校验 帧尾

所以原 MCU 代码中:

frame[4] = cur_pos >> 8; /* 位置高字节 */
frame[5] = cur_pos & 0xFF; /* 位置低字节 */

是完全对得上的。4 和 5 是数组索引,不是“第 4 个 PID 字节”。 B0 的 payload 只有 2 字节,完整协议帧是 8 字节;如果再附加 0A,串口 总共发送 9 字节。

5.3 B1 PID 帧的 12 字节在哪里#

kp、ki、kd 都是 32 位 float:

3 个 float × 4 字节 = 12 字节 payload

B1 完整帧的索引如下:

索引 0 1 2 3 4..7 8..11 12..15 16 17
含义 AA AA B1 0C kp ki kd CHECKSUM BB
长度 2 1 1 4 4 4 1 1

因此:

完整 B1 协议帧 = 2 + 1 + 1 + 12 + 1 + 1 = 18 字节
如果帧后还发 0A = 19 个串口字节

PID 数据本身仍然只有 12 字节。 你看到索引最大到 15,是因为前面 已经有 4 个字节的帧头、命令和长度,不是 PID 变成了 16 字节。

5.4 为什么 B0 是高字节在前,B1 float 却是低字节在前#

这是原嵌入式协议的字段约定,不是 C/C++ 强制规定:

  • B0/F0 的 16 位位置通过移位显式拆分,协议约定为大端。
  • B1 是 MCU 内存中的 float 按字节上传,GD32 是小端,所以为小端。
  • E0 下发 PID 又按原接收协议使用大端 float。

理想的新协议应该统一端序,但上位机首先要兼容已有 MCU 实现。所以代码 对每个字段显式使用 qFromLittleEndian 或 qToBigEndian,不直接用结构体 reinterpret_cast。

5.5 本项目的命令表#

CMD方向LENpayload
B0MCU → Linux2当前位置,16 位大端
B1MCU → Linux12kp/ki/kd,3 个 32 位小端 float
F0Linux → MCU2目标位置,16 位大端
E0Linux → MCU51 字节选择器 + 4 字节大端 float

6. 建议的代码阅读顺序#

不要一开始就从 main_window.cpp 第一行读到最后一行。界面代码很长,会掩盖 真正的主线。建议分七轮:

  1. 读 protocol_codec.h,只记住帧结构、命令字和公共接口。
  2. 读 protocol_codec_test.cpp,用具体字节理解协议预期行为。
  3. 读 protocol_codec.cpp,重点看 StreamParser::feed()。
  4. 读 serial_worker.h 中的信号、队列和原子变量,再读 run()。
  5. 读 serial_worker_test.cpp,看测试怎样在 PTY 两端扮演 Linux 和 MCU。
  6. 读 MainWindow::onWorkerBytes() 和两个 send...(),先跳过 setupUi()。
  7. 最后读 setupUi()、PositionPlot::paintEvent() 和模拟器。

每读完一轮,都尝试回答:“这个模块接收什么,输出什么,它在哪个线程 运行?”如果这三点能答清楚,就不会在细节中迷路。


7. protocol_codec 逐函数讲解#

7.1 byteAt()#

QByteArray::at() 返回 char,而 char 在某些编译器上是有符号的。0xAA 可能被解释为负数。byteAt() 统一转换为 quint8,避免协议比较时出现 符号扩展。

7.2 floatFromLittleEndian()#

  1. 先把 4 字节按小端解码为 quint32 位模式。
  2. 再用 std::memcpy() 把相同位模式复制到 float。

不用 reinterpret_cast<float *> 的原因是它可能同时触发未对齐访问和 C++ 严格别名 问题。memcpy 是这种位级转换的稳妥写法。

7.3 floatToBigEndian()#

过程与上面相反:先拿到 float 的 32 位模式,再用 qToBigEndian() 存入 4 字节数组。它用于 E0 PID 参数下发。

7.4 StreamParser::feed()#

这是协议层最重要的函数。串口和 TCP 都是字节流,一次 read() 不会 保证恰好返回一帧。可能发生:

  • 半包:一帧被分成多次接收。
  • 粘包:一次接收多帧。
  • 粘包 + 半包:前面有完整帧,最后只有下一帧的一部分。
  • 噪声:帧头之前有 0A、无效字节或损坏帧。

feed() 的处理顺序是:

追加新字节
↓
查找 AA AA
↓
不足 4 字节? 保留,等下次
↓
读 LEN,如果 > 64 则拒绝并重新寻找帧头
↓
整帧还没到? 保留,等下次
↓
检查 CHECKSUM 和 BB
↓
生成 Frame,从缓冲区删除该帧,继续解析后续数据

如果找不到完整 AA AA,但缓冲区最后一字节是 AA,它会保留这一 字节。因为它可能是下一次数据中帧头的第一个 AA。

LEN 上限 64 是内存和异常流量保护。如果上位机收到 LEN=100,解析器 不会等待 100 字节,而是计一次拒绝、移除一个字节,然后继续搜索下一个 AA AA。这避免故意或损坏的长度字节让缓冲区无限增长。

7.5 checksum8()#

将 command、payload.size() 和所有 payload 字节累加,只保留低 8 位。 它能发现常见传输错误,但不是密码学完整性保护。

7.6 encodeFrame()#

按帧头、命令、长度、payload、校验、帧尾的顺序构造通用帧。Linux 下发 不额外加 0A,因为帧长已经由 LEN 唯一确定。

7.7 encodeTargetPosition()#

把 16 位目标位置拆为高字节和低字节,再构造 F0 帧。测试中 0x1234 的完整帧必须是 AA AA F0 02 12 34 38 BB。

7.8 encodePidValue()#

一个 E0 帧只下发一个 PID 参数。payload 是“选择器 + 4 字节大端 float”, 选择器 0/1/2 分别表示 Kp/Ki/Kd。所以界面点击“下发 PID”后会连续入队 3 帧,而不是一个包发三个 float。

7.9 decodePosition() 和 decodePidValues()#

它们首先检查 CMD 和 payload 长度。只有匹配时才返回 std::optional 中的数值, 否则返回 std::nullopt。这让调用者必须显式处理“这不是我要的帧”。

7.10 toHexText()#

把字节显示为 AA AA F0 ... 格式,只用于通信日志和测试诊断,不参与 协议传输。


8. SerialWorker 逐函数讲解#

8.1 三个被 epoll 监听的 fd#

fd事件用途
serialFdEPOLLIN/EPOLLOUT/ERR/HUP收发串口数据和检测断线
localWakeFdEPOLLIN主线程通知“有新发送帧”或“请求退出”
timerFdEPOLLIN每秒重连和更新统计

epoll 并不只是为了“高并发服务器”。对这个单设备项目,它的价值是把串口、 跨线程唤醒和定时器统一到一个不忙等待的事件循环。不需要为了简历故意宣称 “高并发”,应该准确表述为“基于 epoll 的事件驱动非阻塞 I/O”。

8.2 baudFlag()#

把界面中的整数波特率映射为 termios 需要的 B115200 等常量。不支持的值 返回 0,后续配置会以 EINVAL 失败。

8.3 addEpollFd()#

封装 epoll_ctl(EPOLL_CTL_ADD),将 fd 和关心的事件注册到 epoll 实例。

8.4 enqueueFrame()#

  1. 空帧直接忽略。
  2. 加锁 txMutex_。
  3. 把整个 QByteArray 放入 txQueue_。
  4. 解锁后调用 wakeLoop()。

“整帧入队”和“只由一个工作线程写 fd”两个条件,保证多个界面操作 同时发送时不会把两帧的字节交叉。volatile 不能实现这个保证,因为它 不提供互斥、原子性或线程间 happens-before 关系。

8.5 requestStop()#

以 release 语义将 stopRequested_ 设为 true,然后写 eventfd。如果只改标志而 不唤醒,工作线程可能永远阻塞在 epoll_wait(-1)。

8.6 run()#

run() 是整个 I/O 线程的生命周期:

  1. 创建 epoll、eventfd 和 timerfd。
  2. 设置 timerfd 首次 1 秒后触发,之后每秒触发。
  3. 尝试打开串口。
  4. 进入 epoll_wait()。
  5. 按 fd 类型分发事件。
  6. 检查到退出标志后注销串口,关闭所有 fd,发出 finished()。

串口 EPOLLIN 到来后会循环 read(),直到返回 EAGAIN。这是边缘式思路中 常见的“一次把已到达数据读干净”,也可减少 epoll 唤醒次数。当前注册使用 默认水平触发,即使未一次读完也不会丢数据。

8.7 openSerial()#

使用以下标志打开设备:

  • O_RDWR:同时读写。
  • O_NOCTTY:不让该串口成为进程的控制终端。
  • O_NONBLOCK:读写不阻塞工作线程。
  • O_CLOEXEC:以后如果启动子进程,子进程不继承该 fd。

打开失败不会让线程退出。它会报告“等待设备”,再由 timerfd 每秒重试。 这对 USB 转串口拔插很实用。

8.8 configureSerial()#

cfmakeraw() 关闭终端的行缓冲、回显和特殊字符处理,让串口成为原始二进制 通道。之后配置 8 数据位、1 停止位、无校验、无硬件流控,即 8N1。

VMIN=0 和 VTIME=0 配合 O_NONBLOCK,使没有数据时 read() 立即返回;等待 由 epoll 负责,不由 termios 超时负责。

8.9 collectOutgoingFrames()#

在互斥锁保护下把队列中的整帧顺序追加到 pending。之后所有实际 write() 都由工作线程完成。

8.10 flushPending()#

Linux write() 可能只写出部分字节,所以使用 offset 记住已写位置。 如果遇到 EAGAIN,它会给串口加上 EPOLLOUT 关注;内核通知可写时再继续。 全部写完后清空 pending、重置偏移,并取消 EPOLLOUT,避免可写事件造成 空转。

8.11 consumeWakeEvent() 和 wakeLoop()#

eventfd 内部是一个 64 位计数器。wakeLoop() 写入 1,consumeWakeEvent() 把已累积的唤醒值读空。多次快速入队可以合并成一次 epoll 处理,不会需要 一帧对应一个线程事件。


9. MainWindow 逐函数讲解#

9.1 setupStyle()#

设置全局 Qt Style Sheet。界面使用浅灰工作区、白色图表、青绿色实时信号、 黄色目标线和红色故障状态。风格的目标是高频使用的仪器控制台,不是宣传页。

9.2 setupUi()#

界面主体是一个水平 QSplitter:

┌─标题与连接工具栏───────────────────────┐
│ 左侧:全尺寸位置曲线 │ 右侧:数值/目标/PID/统计 │
└─状态栏───────────────────────────────────┘

setupUi() 最后才连接按钮信号和槽。数值控件的变化不会自动下发,必须点击 明确的“下发”按钮,避免用户输入过程中反复改变 MCU 参数。 通信日志区已删除,左侧空间全部用于位置轨迹。标题只保留“平衡球控制台”。

9.3 refreshPorts()#

扫描 /dev/ttyUSB* 和 /dev/ttyACM*。串口下拉框可编辑,因此 PTY 的 /dev/pts/N 可直接输入,不需要把短暂存在的 PTY 枚举到普通硬件串口列表。

9.4 connectDevice() 和 startDemoMode()#

这两个是命令行自动化入口。--device 会填写串口和波特率后连接; --demo 会立即启动内置数学模型。如果已经有连接,会先停止旧连接。

9.5 startSerial()#

  1. 从界面取设备路径和波特率。
  2. 创建 QThread 和 SerialWorker。
  3. 把 worker 移到工作线程。
  4. 连接 started/run、数据、状态、错误、统计和 finished 信号。
  5. 清空协议解析器的历史半包。
  6. 启动线程。

9.6 stopSerial()#

先调用 requestStop() 写 eventfd,再请求 QThread 退出并等待最多 3 秒。线程停止后 才销毁 worker 和 thread,避免 fd 仍在使用时对象已经被删除。

9.7 onWorkerBytes()#

这是“串口字节到界面数值”的桥梁:

  1. 累加接收字节数。
  2. 如果开启原始帧日志,显示十六进制字节。
  3. 把数据交给 parser_.feed()。
  4. 对 B0 调用 updateTelemetry()。
  5. 对 B1 更新 PID 实时显示;用户正在编辑时不覆盖编辑框。
  6. 更新接受帧、拒绝帧和半包缓冲字节数。

解析在主线程是可接受的,因为单帧最长受限且运算量很小。如果以后变成 多设备、高帧率或复杂解码,可再把解析移到 worker,对界面只发已解码的结构体。

9.8 sendTargetPosition()#

从 spin box 取目标,构造 F0 帧并整帧入队。只有这个函数成功执行后, 输入框数值才成为 targetPosition_,并进入曲线和记录。因此只修改输入框而 没有点击“下发目标”时,CSV 和 MySQL 仍记录上一个有效目标。

9.9 sendPidParameters()#

读取三个 spin box,对 Kp/Ki/Kd 分别构造一个 E0 帧。三帧顺序进入同一 发送队列,并由同一 I/O 线程写出。原先 PID 难以修改的原因是 B1 每 100 ms 将 MCU 旧值重新写回 spin box。现在使用 pidEditorDirty_ 区分“用户待下发值”和 “设备实时回报值”,只在用户没有编辑,或 B1 已与下发值一致时同步编辑器。

9.10 toggleRecording() 和 appendRecordLine()#

开始记录时打开 CSV 文件并写表头:

timestamp,current_mm,target_mm,kp,ki,kd

开始记录时同时调用 ensureDatabaseConnection()。MySQL 可用时,每次 B0 更新会 向 CSV 和 position_records 各写一行;MySQL 不可用时降级为“仅 CSV”,不中断监控。 演示模式中每 25 个 20 ms 周期刷新一次文件缓冲, 在性能和异常退出时的数据丢失量之间折中。

9.11 ensureDatabaseConnection()#

使用 QMYSQL 连接虚拟机 192.168.28.128:3306,用户名 bob、密码按当前 学习环境配置为 已隐藏。 连接成功后自动创建 balance_ball 数据库和 position_records 表。函数具有懒连接 语义:启动上位机时不强制连 MySQL,只在开始记录或点击查询时连接。

9.12 insertDatabaseRecord()#

使用预处理 SQL 和命名绑定参数插入时间、当前位置、有效目标和 PID。不使用 字符串拼接数值,避免格式和 SQL 转义问题。写入失败后将数据库标记为不可用, 后续仍继续 CSV 记录。

9.13 showDatabaseRecords()#

创建历史记录对话框,用 QSqlTableModel 直接绑定 position_records。表格按 ID 倒序显示、禁止界面编辑,并提供刷新按钮重新执行 select()。

9.14 startDemo() 和 updateDemoData()#

内置演示模式每 20 ms 用 QTimer 更新一次简化二阶模型,可以快速检查界面和 CSV/MySQL,但它不经过串口。需要验证 Linux I/O 时,应该使用 PTY 模拟器。

9.15 updateTelemetry()#

统一更新当前位置、有效目标、曲线和 CSV/MySQL。将所有入口汇总到一个函数, 可以避免演示数据和串口数据对界面的更新行为不一致。


10. PositionPlot 怎样画实时曲线#

10.1 为什么没有使用 Qt Charts#

这个项目只需要一条位置曲线和一条目标线。用 QPainter 自绘可以减少依赖, 也能精确控制网格、游标、颜色和时间窗。

10.2 appendSample()#

使用 QElapsedTimer 记录单调时间,不受系统时钟调整影响。每个样本保存为 QPointF(秒, 毫米)。只保留可见 12 秒再多 1 秒的数据,防止长时间运行后 内存无限增长。

10.3 paintEvent()#

每次重绘执行:

  1. 清空背景。
  2. 预留坐标轴文字边距。
  3. 绘制 0~300 mm 水平网格。
  4. 绘制最近 12 秒时间网格。
  5. 绘制黄色目标虚线。
  6. 把样本映射到图表坐标,用 QPainterPath 连成青绿曲线。
  7. 在最新位置绘制游标和圆点。

mapPoint() 完成“时间/毫米坐标”到“像素坐标”的线性映射。因为屏幕 y 轴 向下增大,而数学图表 y 轴向上增大,所以 y 计算从 plotArea.bottom() 往上减。


11. PTY 设备模拟器的实现#

11.1 PTY 是什么#

PTY(pseudo terminal)是 Linux 内核提供的主从设备对。模拟器持有主端 fd, Qt 上位机像普通串口一样打开 /dev/pts/N 从端。

模拟器 write(master) → 上位机 read(/dev/pts/N)
模拟器 read(master) ← 上位机 write(/dev/pts/N)

因为上位机打开的真是一个 Linux 字符设备,所以 termios、非阻塞 read/write 和 epoll 全部会真实执行。这比把随机数直接塞给界面更接近真实硬件联调。

11.2 DeviceState#

保存位置、速度、目标、Kp/Ki/Kd 和模拟时间。它是模拟设备的全部可变状态。

11.3 encodeFrame()、sendPosition() 和 sendPid()#

模拟器故意不链接 Qt 协议库,而是用标准 C++ 独立实现编码。这避免测试两端 共享同一个错误实现后仍然看起来“互相匹配”。

  • B0 每 20 ms 发送一次。
  • B1 每 100 ms 发送一次。
  • 上报帧后附加换行,模拟 MCU 当前行为。

11.4 updatePhysics()#

使用一个带阻尼和正弦扰动的简化二阶模型。它不是精确的球板动力学模型, 但可以产生连续、有惯性、能够趋近目标的位置数据,适合验证曲线和命令下发。

11.5 parseIncoming() 和 executeFrame()#

模拟器也会处理半包、粘包和错误帧。收到 F0 后改变目标;收到 E0 后按 选择器改变对应 PID 值,并在终端打印更新结果。


12. 构建、测试和运行#

12.1 登录虚拟机#

Terminal window
ssh 192.168.28.128

12.2 首次安装用户目录 Qt#

Terminal window
cd /home/bob/balance-ball-linux-host/linux-host
bash tools/install_user_qt6.sh
source /home/bob/.local/balance-qt6/env.sh

脚本不调用 sudo dpkg -i,而是使用 apt-get download 下载 deb,再用 dpkg-deb -x 解包到 ~/.local/balance-qt6。它会强制下载 Core/Gui/Sql/Widgets 和 libqt6sql6-mysql 驱动,即使这些包 在系统 dpkg 中已显示安装。原因是用户目录中的 Qt CMake 配置会按自己的 前缀寻找运行库。

12.3 手动构建和测试#

Terminal window
source /home/bob/.local/balance-qt6/env.sh
cd /home/bob/balance-ball-linux-host/linux-host
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j2
ctest --test-dir build --output-on-failure

当前验证结果:

protocol_codec_test Passed
sql_driver_test Passed
database_integration_test Passed
main_window_test Passed
serial_worker_test Passed
100% tests passed, 0 tests failed

12.4 配置虚拟机本机 MySQL#

上位机使用的连接参数是:

host: 192.168.28.128
port: 3306
user: bob
password: 已隐藏
database: balance_ball
table: position_records

这里的主机地址是 Ubuntu 虚拟机的网卡地址。已实际验证 TCP 登录:

Terminal window
mysql -h192.168.28.128 -ubob -p -e "SELECT 1;"

服务器返回 MySQL 8.0.46 和 bob@%,该账号具有 CREATE、INSERT、SELECT 等所需权限。程序会自动建库建表,无需手工创建 balance_ball。 生产环境不应在源码中保存密码,本项目按当前本地学习环境的明确要求配置。

position_records 表结构:

字段类型含义
idBIGINT AUTO_INCREMENT主键
recorded_atDATETIME(3)毫秒级记录时间
current_mmDOUBLE当前位置
target_mmDOUBLE已下发的有效目标
kp / ki / kdDOUBLE设备当前回报 PID

12.5 无串口的内置演示模式#

Terminal window
./tools/run_console.sh --demo

这适合检查界面、曲线和 CSV/MySQL,但不检查 termios/epoll。 也可以使用命令行直接开始记录:

Terminal window
./tools/run_console.sh --demo --record /tmp/balance-demo.csv

--record 调用的是和界面“开始记录”按钮相同的函数,因此可以用于无人值守 验收,不是另外伪造的数据库通道。

12.6 使用 PTY 模拟真实串口#

终端 1:

Terminal window
cd /home/bob/balance-ball-linux-host/linux-host
./tools/run_simulator.sh

输出示例:

平衡球设备模拟器已启动
Qt 上位机串口请选择:/dev/pts/0

终端 2:

Terminal window
cd /home/bob/balance-ball-linux-host/linux-host
./tools/run_console.sh --device /dev/pts/0 --baud 115200

/dev/pts/0 每次运行都可能变化,必须使用模拟器当次打印的路径。

在界面中改变目标和 PID 并点击下发后,终端 1 会打印:

目标位置更新为 180 mm
Kp 更新为 0.2
Ki 更新为 0.01
Kd 更新为 0.6

12.7 以后连接真实板卡#

  1. 把 USB 转串口插入虚拟机,确保不是只映射给 Windows 主机。
  2. 用 dmesg 或 ls /dev/ttyUSB* /dev/ttyACM* 确认设备名。
  3. 确保用户有权限。常见做法是将用户加入 dialout 组后重新登录。
  4. 运行:
Terminal window
./tools/run_console.sh --device /dev/ttyUSB0 --baud 115200

13. CMake 构建目标#

目标类型用途
balance_protocol静态库协议编解码,供程序和测试共用
balance_consoleQt 可执行程序主上位机
protocol_codec_testQt Test协议单元测试
sql_driver_testQt Test验证 QMYSQL 运行时驱动可加载
database_integration_testQt Test + MySQL真实建表、写入、查询与清理
main_window_testQt Test有效目标和 PID 编辑/下发语义
serial_worker_testQt Test + PTYLinux I/O 集成测试,只在 Linux 构建
balance_device_simulator标准 C++ 程序独立 PTY 设备模拟器

CMAKE_AUTOMOC 会对带 Q_OBJECT 的类自动运行 Qt moc,生成信号槽需要的元对象代码。


14. 测试在验证什么#

14.1 protocol_codec_test#

用例验证内容
encodesTargetPositionF0 大端位置和校验和
parsesFragmentedPositionFrame一帧分三次到达仍只输出一帧
parsesStickyFramesAndPidValuesB0+B1 粘包、换行和小端 float
rejectsCorruptFrameAndResynchronizes损坏帧后能找回下一帧
boundsInvalidPayloadLengthLEN=255 不会让缓冲无限等待

14.2 serial_worker_test#

测试创建真实 PTY 主从对:

  1. SerialWorker 在 QThread 中打开 PTY 从端。
  2. 测试从主端写 B0,检查 bytesReceived 收到数据并解出 120 mm。
  3. 调用 enqueueFrame() 发 F0,从 PTY 主端读回并逐字节比较。
  4. 连续入队 Kp/Ki/Kd 三个 E0 帧,验证字节顺序不交叉。
  5. 请求线程退出,验证 eventfd 能唤醒 epoll 且线程在 2 秒内停止。

这个测试覆盖了“能编译”无法覆盖的真实 Linux fd 行为。

14.3 main_window_test#

  • 将目标输入从 160 改为 210,不点击下发时有效目标仍是 160。
  • 点击“下发目标”后,有效目标才变为 210。
  • 编辑 Kp/Ki/Kd 后数值保持可写,点击下发后演示控制器采用新参数。

14.4 database_integration_test#

  • 使用工程配置的地址和账号真实连接 MySQL。
  • 自动创建 balance_ball 和 position_records。
  • 插入一条 120/160 mm 的测试数据并用 SQL 读回逐字段比较。
  • 使用 QSqlTableModel 过滤并读取该行,覆盖查询对话框的核心路径。
  • 验证完成后删除自己的测试行,不影响正式记录。

15. 常见问题和面试表达#

15.1 既然只有一个平衡球,为什么还用 epoll#

不要回答“因为高并发”。这个项目当前不是高并发。准确回答是:

epoll 用于将串口可读/可写、跨线程 eventfd 唤醒和 timerfd 定时重连统一到 一个事件循环,避免忙轮询。如果以后接入 TCP 或多设备,该模型也可扩展, 但当前简历不应夸大为高并发系统。

15.2 为什么不在 Qt 主线程里直接 read#

即使 fd 是非阻塞的,持续读写、断线处理和重连仍会占用 GUI 事件循环。 独立 I/O 线程可以让绘制和用户操作保持响应,再用排队信号穿过线程边界。

15.3 为什么协议解析不依赖“一次 read 一帧”#

因为串口驱动只保证字节顺序,不保证应用层帧边界。正确解析器必须保留 半包、循环处理粘包,并在错误后重新同步帧头。

15.4 多个任务同时发送时怎样防止帧交叉#

上层以完整 QByteArray 入队,队列受互斥锁保护,只有 SerialWorker 一个线程执行底层 write()。部分写使用 offset 继续,完成当前 pending 数据后才清空。

15.5 volatile 能不能替代互斥锁#

不能。volatile 主要告诉编译器每次进行实际内存访问,常用于寄存器或 中断可见变量。它不保证复合操作原子性,不保护容器内部结构,也不建立 C++ 线程间内存顺序。本项目的停止标志用 std::atomic_bool,队列用 QMutex。

15.6 为什么 PID 周期改变后要重新整定#

离散 PID 中积分项累加频率和微分项的时间尺度都取决于采样周期 dt。 如果代码的积分和微分公式没有显式带 dt,把 50 ms 改成 10 ms 后,每秒积分 累加次数变成 5 倍,而相邻误差变化量也对应变小。即使公式正确引入 dt, 采样、滤波、舍机和被控对象的离散特性也已变化,仍需复核稳定性和响应。

15.7 本项目和 FreeRTOS 上的队列怎样对应#

FreeRTOS Queue 适合在 MCU 任务或 ISR 之间传递固定大小消息,具有阻塞和唤醒语义。 Linux Qt 上位机中使用 QQueue<QByteArray> + QMutex + eventfd,是因为帧长可变, 而工作线程已经以 epoll 为唤醒中心。两者解决的都是“消息传递 + 同步”, 但应根据平台和事件模型选择,不需强行使用同一实现。


16. 如何把它写进嵌入式 Linux 简历#

项目名称可以写成:

基于 FreeRTOS 与 Linux Qt 的平衡球控制与监控系统

推荐描述:

在 GD32F407 + FreeRTOS 平衡球控制项目上开发 Linux Qt6 上位机,完成 位置/PID 实时监控、目标和参数下发、曲线展示与 CSV/MySQL 数据记录, 使用 QSqlTableModel 实现历史数据查询。基于 termios 配置 115200 8N1 二进制串口,使用 epoll + eventfd + timerfd 实现非阻塞收发、跨线程唤醒、统计和断线重连;设计支持半包、粘包、 错帧重同步和长度上限保护的流式协议解析器。搭建 PTY 伪串口设备模拟器和 Qt Test 自动化测试,在无开发板环境下完成协议与 Linux I/O 双向联调。

面试时应主动说明:当前是单设备,epoll 是事件驱动 I/O 设计,不是为了虚构 高并发指标。这种边界意识比简单堆叠“高并发”关键词更有说服力。


17. 后续升级路线#

阶段 1:真实板卡回归#

  • 确认 B0/B1 的实际帧长、换行和 float 端序。
  • 对比 Qt 与原上位机显示值。
  • 测试 USB 串口拔插和自动重连。
  • 确认 MCU 对 F0/E0 的校验和范围保护。

阶段 2:协议健壮性#

  • 增加协议版本、序号和应答帧。
  • 下发命令在超时时重试,但必须设计幂等语义。
  • 将 8 位累加和升级为 CRC16。
  • 统一所有多字节整数和 float 端序。

阶段 3:数据与可观测性#

  • 记录帧序号、延迟、丢帧和重连次数。
  • 增加记录文件分卷和元数据。
  • 对 CSV 增加离线回放功能,用同一曲线控件复现运行过程。

阶段 4:网络化(真有需求时再做)#

  • 在嵌入式 Linux 板上将串口转换为 TCP/WebSocket 服务。
  • Qt 客户端通过网络连接远端设备。
  • 将运输层接口抽象为 Serial/TCP 两种实现,复用同一协议层。
  • 只在多设备、多用户或远程监控确实存在时,再设计认证、并发和流控。

18. 阅读完成后的自检问题#

  1. B1 中 PID 是 12 字节,为什么完整帧是 18 字节?
  2. 如果一次 read() 只返回 AA AA B0,解析器怎样处理?
  3. 如果 LEN 是 100,为什么解析器不会继续等待 100 字节?
  4. 主线程入队一帧后,正阻塞在 epoll_wait() 的工作线程怎样被唤醒?
  5. 为什么串口只在一个线程中执行 write()?
  6. 为什么 EPOLLOUT 不能一直注册?
  7. bytesReceived 是在哪个线程发出,onWorkerBytes() 又在哪个线程执行?
  8. 内置演示模式和 PTY 模拟器分别能验证什么?
  9. 为什么 PID 运算仍然留在 GD32/FreeRTOS 端?
  10. 如果以后加 TCP,哪些模块应该复用,哪个模块需要替换?

如果这 10 个问题可以不看代码说清楚,你对这个 Linux 上位机的主体架构就 已经真正理解了。

平衡球 Linux Qt 上位机:实现与架构
https://benjian.xyz/posts/balance-ball-linux-qt/
作者
JIAN
发布于
2026-08-13
许可协议
CC BY-NC-SA 4.0
1
1. 这个升级项目解决了什么问题
2
2. 开发和运行环境
3
3. 目录结构与每个目录的职责
4
4. 整体架构和线程边界
4.1 Qt 主线程负责什么
4.2 串口工作线程负责什么
4.3 跨线程通信为什么是安全的
5
5. 协议格式:先把“12 字节和 16 字节”说清楚
5.1 通用帧结构
5.2 B0 位置帧逐字节解释
5.3 B1 PID 帧的 12 字节在哪里
5.4 为什么 B0 是高字节在前,B1 float 却是低字节在前
5.5 本项目的命令表
6
6. 建议的代码阅读顺序
7
7. protocol_codec 逐函数讲解
7.1 byteAt()
7.2 floatFromLittleEndian()
7.3 floatToBigEndian()
7.4 StreamParser::feed()
7.5 checksum8()
7.6 encodeFrame()
7.7 encodeTargetPosition()
7.8 encodePidValue()
7.9 decodePosition() 和 decodePidValues()
7.10 toHexText()
8
8. SerialWorker 逐函数讲解
8.1 三个被 epoll 监听的 fd
8.2 baudFlag()
8.3 addEpollFd()
8.4 enqueueFrame()
8.5 requestStop()
8.6 run()
8.7 openSerial()
8.8 configureSerial()
8.9 collectOutgoingFrames()
8.10 flushPending()
8.11 consumeWakeEvent() 和 wakeLoop()
9
9. MainWindow 逐函数讲解
9.1 setupStyle()
9.2 setupUi()
9.3 refreshPorts()
9.4 connectDevice() 和 startDemoMode()
9.5 startSerial()
9.6 stopSerial()
9.7 onWorkerBytes()
9.8 sendTargetPosition()
9.9 sendPidParameters()
9.10 toggleRecording() 和 appendRecordLine()
9.11 ensureDatabaseConnection()
9.12 insertDatabaseRecord()
9.13 showDatabaseRecords()
9.14 startDemo() 和 updateDemoData()
9.15 updateTelemetry()
10
10. PositionPlot 怎样画实时曲线
10.1 为什么没有使用 Qt Charts
10.2 appendSample()
10.3 paintEvent()
11
11. PTY 设备模拟器的实现
11.1 PTY 是什么
11.2 DeviceState
11.3 encodeFrame()、sendPosition() 和 sendPid()
11.4 updatePhysics()
11.5 parseIncoming() 和 executeFrame()
12
12. 构建、测试和运行
12.1 登录虚拟机
12.2 首次安装用户目录 Qt
12.3 手动构建和测试
12.4 配置虚拟机本机 MySQL
12.5 无串口的内置演示模式
12.6 使用 PTY 模拟真实串口
12.7 以后连接真实板卡
13
13. CMake 构建目标
14
14. 测试在验证什么
14.1 protocol_codec_test
14.2 serial_worker_test
14.3 main_window_test
14.4 database_integration_test
15
15. 常见问题和面试表达
15.1 既然只有一个平衡球,为什么还用 epoll
15.2 为什么不在 Qt 主线程里直接 read
15.3 为什么协议解析不依赖“一次 read 一帧”
15.4 多个任务同时发送时怎样防止帧交叉
15.5 volatile 能不能替代互斥锁
15.6 为什么 PID 周期改变后要重新整定
15.7 本项目和 FreeRTOS 上的队列怎样对应
16
16. 如何把它写进嵌入式 Linux 简历
17
17. 后续升级路线
阶段 1:真实板卡回归
阶段 2:协议健壮性
阶段 3:数据与可观测性
阶段 4:网络化(真有需求时再做)
18
18. 阅读完成后的自检问题