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

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 |
| Qt | Qt 6.4.2 Widgets |
| Qt 用户安装目录 | /home/bob/.local/balance-qt6 |
| 构建工具 | CMake + GNU g++ 13 |
| Linux I/O | termios + epoll + eventfd + timerfd |
| 界面线程模型 | Qt 主线程 + 一个串口 QThread |
Qt 被解包到用户目录,不需要 sudo。tools/install_user_qt6.sh 会下载 Qt Base
的开发文件、运行库和 QPA 图形插件,然后生成 env.sh。
3. 目录结构与每个目录的职责
1 | linux-host/ |
这里有一个值得保留的工程边界:protocol 不知道串口,io 不知道
界面布局,app 通过已解析数据更新界面。这使协议可以独立测试,也使串口实现
之后能够替换成 TCP 而不重写整个界面。
4. 整体架构和线程边界
1 | flowchart LR |
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 通用帧结构
1 | AA AA CMD LEN PAYLOAD... CHECKSUM BB |
| 字段 | 长度 | 含义 |
|---|---|---|
| 帧头 | 2 字节 | 固定为 AA AA |
| CMD | 1 字节 | B0/B1/F0/E0 |
| LEN | 1 字节 | 只表示 PAYLOAD 的字节数 |
| PAYLOAD | LEN 字节 | 命令数据 |
| CHECKSUM | 1 字节 | CMD + LEN + PAYLOAD 的 8 位累加和 |
| 帧尾 | 1 字节 | 固定为 BB |
MCU 的上报帧之后可能还有 0A 换行字节。它是传输时附加的分隔字节,
不在 LEN 中,也不参与校验。解析器会把它当作帧间噪声跳过。
5.2 B0 位置帧逐字节解释
假设位置是 120 mm,即 0x0078:
1 | 索引 0 1 2 3 4 5 6 7 |
所以原 MCU 代码中:
1 | frame[4] = cur_pos >> 8; /* 位置高字节 */ |
是完全对得上的。4 和 5 是数组索引,不是“第 4 个 PID 字节”。
B0 的 payload 只有 2 字节,完整协议帧是 8 字节;如果再附加 0A,串口
总共发送 9 字节。
5.3 B1 PID 帧的 12 字节在哪里
kp、ki、kd 都是 32 位 float:
1 | 3 个 float × 4 字节 = 12 字节 payload |
B1 完整帧的索引如下:
1 | 索引 0 1 2 3 4..7 8..11 12..15 16 17 |
因此:
1 | 完整 B1 协议帧 = 2 + 1 + 1 + 12 + 1 + 1 = 18 字节 |
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 | 方向 | LEN | payload |
|---|---|---|---|
B0 |
MCU → Linux | 2 | 当前位置,16 位大端 |
B1 |
MCU → Linux | 12 | kp/ki/kd,3 个 32 位小端 float |
F0 |
Linux → MCU | 2 | 目标位置,16 位大端 |
E0 |
Linux → MCU | 5 | 1 字节选择器 + 4 字节大端 float |
6. 建议的代码阅读顺序
不要一开始就从 main_window.cpp 第一行读到最后一行。界面代码很长,会掩盖
真正的主线。建议分七轮:
- 读
protocol_codec.h,只记住帧结构、命令字和公共接口。 - 读
protocol_codec_test.cpp,用具体字节理解协议预期行为。 - 读
protocol_codec.cpp,重点看StreamParser::feed()。 - 读
serial_worker.h中的信号、队列和原子变量,再读run()。 - 读
serial_worker_test.cpp,看测试怎样在 PTY 两端扮演 Linux 和 MCU。 - 读
MainWindow::onWorkerBytes()和两个send...(),先跳过setupUi()。 - 最后读
setupUi()、PositionPlot::paintEvent()和模拟器。
每读完一轮,都尝试回答:“这个模块接收什么,输出什么,它在哪个线程
运行?”如果这三点能答清楚,就不会在细节中迷路。
7. protocol_codec 逐函数讲解
7.1 byteAt()
QByteArray::at() 返回 char,而 char 在某些编译器上是有符号的。0xAA
可能被解释为负数。byteAt() 统一转换为 quint8,避免协议比较时出现
符号扩展。
7.2 floatFromLittleEndian()
- 先把 4 字节按小端解码为
quint32位模式。 - 再用
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() 的处理顺序是:
1 | 追加新字节 |
如果找不到完整 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 | 事件 | 用途 |
|---|---|---|
serialFd |
EPOLLIN/EPOLLOUT/ERR/HUP | 收发串口数据和检测断线 |
localWakeFd |
EPOLLIN | 主线程通知“有新发送帧”或“请求退出” |
timerFd |
EPOLLIN | 每秒重连和更新统计 |
epoll 并不只是为了“高并发服务器”。对这个单设备项目,它的价值是把串口、
跨线程唤醒和定时器统一到一个不忙等待的事件循环。不需要为了简历故意宣称
“高并发”,应该准确表述为“基于 epoll 的事件驱动非阻塞 I/O”。
8.2 baudFlag()
把界面中的整数波特率映射为 termios 需要的 B115200 等常量。不支持的值
返回 0,后续配置会以 EINVAL 失败。
8.3 addEpollFd()
封装 epoll_ctl(EPOLL_CTL_ADD),将 fd 和关心的事件注册到 epoll 实例。
8.4 enqueueFrame()
- 空帧直接忽略。
- 加锁
txMutex_。 - 把整个
QByteArray放入txQueue_。 - 解锁后调用
wakeLoop()。
“整帧入队”和“只由一个工作线程写 fd”两个条件,保证多个界面操作
同时发送时不会把两帧的字节交叉。volatile 不能实现这个保证,因为它
不提供互斥、原子性或线程间 happens-before 关系。
8.5 requestStop()
以 release 语义将 stopRequested_ 设为 true,然后写 eventfd。如果只改标志而
不唤醒,工作线程可能永远阻塞在 epoll_wait(-1)。
8.6 run()
run() 是整个 I/O 线程的生命周期:
- 创建 epoll、eventfd 和 timerfd。
- 设置 timerfd 首次 1 秒后触发,之后每秒触发。
- 尝试打开串口。
- 进入
epoll_wait()。 - 按 fd 类型分发事件。
- 检查到退出标志后注销串口,关闭所有 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:
1 | ┌─标题与连接工具栏───────────────────────┐ |
setupUi() 最后才连接按钮信号和槽。数值控件的变化不会自动下发,必须点击
明确的“下发”按钮,避免用户输入过程中反复改变 MCU 参数。
通信日志区已删除,左侧空间全部用于位置轨迹。标题只保留“平衡球控制台”。
9.3 refreshPorts()
扫描 /dev/ttyUSB* 和 /dev/ttyACM*。串口下拉框可编辑,因此 PTY 的/dev/pts/N 可直接输入,不需要把短暂存在的 PTY 枚举到普通硬件串口列表。
9.4 connectDevice() 和 startDemoMode()
这两个是命令行自动化入口。--device 会填写串口和波特率后连接;--demo 会立即启动内置数学模型。如果已经有连接,会先停止旧连接。
9.5 startSerial()
- 从界面取设备路径和波特率。
- 创建
QThread和SerialWorker。 - 把 worker 移到工作线程。
- 连接 started/run、数据、状态、错误、统计和 finished 信号。
- 清空协议解析器的历史半包。
- 启动线程。
9.6 stopSerial()
先调用 requestStop() 写 eventfd,再请求 QThread 退出并等待最多 3 秒。线程停止后
才销毁 worker 和 thread,避免 fd 仍在使用时对象已经被删除。
9.7 onWorkerBytes()
这是“串口字节到界面数值”的桥梁:
- 累加接收字节数。
- 如果开启原始帧日志,显示十六进制字节。
- 把数据交给
parser_.feed()。 - 对 B0 调用
updateTelemetry()。 - 对 B1 更新 PID 实时显示;用户正在编辑时不覆盖编辑框。
- 更新接受帧、拒绝帧和半包缓冲字节数。
解析在主线程是可接受的,因为单帧最长受限且运算量很小。如果以后变成
多设备、高帧率或复杂解码,可再把解析移到 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 文件并写表头:
1 | 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()
每次重绘执行:
- 清空背景。
- 预留坐标轴文字边距。
- 绘制 0~300 mm 水平网格。
- 绘制最近 12 秒时间网格。
- 绘制黄色目标虚线。
- 把样本映射到图表坐标,用
QPainterPath连成青绿曲线。 - 在最新位置绘制游标和圆点。
mapPoint() 完成“时间/毫米坐标”到“像素坐标”的线性映射。因为屏幕 y 轴
向下增大,而数学图表 y 轴向上增大,所以 y 计算从 plotArea.bottom() 往上减。
11. PTY 设备模拟器的实现
11.1 PTY 是什么
PTY(pseudo terminal)是 Linux 内核提供的主从设备对。模拟器持有主端 fd,
Qt 上位机像普通串口一样打开 /dev/pts/N 从端。
1 | 模拟器 write(master) → 上位机 read(/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 登录虚拟机
1 | ssh 192.168.28.128 |
12.2 首次安装用户目录 Qt
1 | cd /home/bob/balance-ball-linux-host/linux-host |
脚本不调用 sudo dpkg -i,而是使用 apt-get download 下载 deb,再用 dpkg-deb -x
解包到 ~/.local/balance-qt6。它会强制下载 Core/Gui/Sql/Widgets 和libqt6sql6-mysql 驱动,即使这些包
在系统 dpkg 中已显示安装。原因是用户目录中的 Qt CMake 配置会按自己的
前缀寻找运行库。
12.3 手动构建和测试
1 | source /home/bob/.local/balance-qt6/env.sh |
当前验证结果:
1 | protocol_codec_test Passed |
12.4 配置虚拟机本机 MySQL
上位机使用的连接参数是:
1 | host: 192.168.28.128 |
这里的主机地址是 Ubuntu 虚拟机的网卡地址。已实际验证 TCP 登录:
1 | mysql -h192.168.28.128 -ubob -p -e "SELECT 1;" |
服务器返回 MySQL 8.0.46 和 bob@%,该账号具有 CREATE、INSERT、SELECT
等所需权限。程序会自动建库建表,无需手工创建 balance_ball。
生产环境不应在源码中保存密码,本项目按当前本地学习环境的明确要求配置。
position_records 表结构:
| 字段 | 类型 | 含义 |
|---|---|---|
id |
BIGINT AUTO_INCREMENT | 主键 |
recorded_at |
DATETIME(3) | 毫秒级记录时间 |
current_mm |
DOUBLE | 当前位置 |
target_mm |
DOUBLE | 已下发的有效目标 |
kp / ki / kd |
DOUBLE | 设备当前回报 PID |
12.5 无串口的内置演示模式
1 | ./tools/run_console.sh --demo |
这适合检查界面、曲线和 CSV/MySQL,但不检查 termios/epoll。
也可以使用命令行直接开始记录:
1 | ./tools/run_console.sh --demo --record /tmp/balance-demo.csv |
--record 调用的是和界面“开始记录”按钮相同的函数,因此可以用于无人值守
验收,不是另外伪造的数据库通道。
12.6 使用 PTY 模拟真实串口
终端 1:
1 | cd /home/bob/balance-ball-linux-host/linux-host |
输出示例:
1 | 平衡球设备模拟器已启动 |
终端 2:
1 | cd /home/bob/balance-ball-linux-host/linux-host |
/dev/pts/0 每次运行都可能变化,必须使用模拟器当次打印的路径。
在界面中改变目标和 PID 并点击下发后,终端 1 会打印:
1 | 目标位置更新为 180 mm |
12.7 以后连接真实板卡
- 把 USB 转串口插入虚拟机,确保不是只映射给 Windows 主机。
- 用
dmesg或ls /dev/ttyUSB* /dev/ttyACM*确认设备名。 - 确保用户有权限。常见做法是将用户加入
dialout组后重新登录。 - 运行:
1 | ./tools/run_console.sh --device /dev/ttyUSB0 --baud 115200 |
13. CMake 构建目标
| 目标 | 类型 | 用途 |
|---|---|---|
balance_protocol |
静态库 | 协议编解码,供程序和测试共用 |
balance_console |
Qt 可执行程序 | 主上位机 |
protocol_codec_test |
Qt Test | 协议单元测试 |
sql_driver_test |
Qt Test | 验证 QMYSQL 运行时驱动可加载 |
database_integration_test |
Qt Test + MySQL | 真实建表、写入、查询与清理 |
main_window_test |
Qt Test | 有效目标和 PID 编辑/下发语义 |
serial_worker_test |
Qt Test + PTY | Linux I/O 集成测试,只在 Linux 构建 |
balance_device_simulator |
标准 C++ 程序 | 独立 PTY 设备模拟器 |
CMAKE_AUTOMOC 会对带 Q_OBJECT 的类自动运行 Qt moc,生成信号槽需要的元对象代码。
14. 测试在验证什么
14.1 protocol_codec_test
| 用例 | 验证内容 |
|---|---|
encodesTargetPosition |
F0 大端位置和校验和 |
parsesFragmentedPositionFrame |
一帧分三次到达仍只输出一帧 |
parsesStickyFramesAndPidValues |
B0+B1 粘包、换行和小端 float |
rejectsCorruptFrameAndResynchronizes |
损坏帧后能找回下一帧 |
boundsInvalidPayloadLength |
LEN=255 不会让缓冲无限等待 |
14.2 serial_worker_test
测试创建真实 PTY 主从对:
SerialWorker在 QThread 中打开 PTY 从端。- 测试从主端写 B0,检查
bytesReceived收到数据并解出 120 mm。 - 调用
enqueueFrame()发 F0,从 PTY 主端读回并逐字节比较。 - 连续入队 Kp/Ki/Kd 三个 E0 帧,验证字节顺序不交叉。
- 请求线程退出,验证 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. 阅读完成后的自检问题
- B1 中 PID 是 12 字节,为什么完整帧是 18 字节?
- 如果一次
read()只返回AA AA B0,解析器怎样处理? - 如果 LEN 是 100,为什么解析器不会继续等待 100 字节?
- 主线程入队一帧后,正阻塞在
epoll_wait()的工作线程怎样被唤醒? - 为什么串口只在一个线程中执行
write()? - 为什么 EPOLLOUT 不能一直注册?
bytesReceived是在哪个线程发出,onWorkerBytes()又在哪个线程执行?- 内置演示模式和 PTY 模拟器分别能验证什么?
- 为什么 PID 运算仍然留在 GD32/FreeRTOS 端?
- 如果以后加 TCP,哪些模块应该复用,哪个模块需要替换?
如果这 10 个问题可以不看代码说清楚,你对这个 Linux 上位机的主体架构就
已经真正理解了。