Qt 数据采集项目 Demo
Qt 数据采集系统 · 学习笔记
时间:2026-10-16
目标:编程小白通过编写简单 Demo,复刻一个实际已交付软件项目的功能(Qt 工业 UDP 数据采集系统)
主题:完整回顾"实现阶段 → 关键原理 → 易错点"全链路
0. 一句话项目画像
一个基于 VS2015 + Qt5.9.0 msvc2015_64 的 Qt 工业数据采集 Demo,实现了一条完整数据流:
硬件/网络 → UDP接收 → 线程安全队列 → 独立线程解析 → QCustomPlot实时波形 → 二进制录制 → 文件回放
学习方式:手敲代码、重复练习 3 遍以上,搭配 NetAssist 网络调试助手验证。
1. 项目文件全景
1.1 手写文件(自己敲的)
| 文件 | 职责 |
|---|---|
main.cpp |
Qt 应用入口,创建主窗口、启动事件循环 |
QtGuiApplication1.h/.cpp |
主窗口 QMainWindow,粘合所有模块 |
ThreadSafeQueue.h |
线程安全模板队列(只有 .h,无 .cpp) |
ProcessThread.h/.cpp |
消费者子线程,做二进制协议解析 |
DataRecorder.h/.cpp |
录制模块,原始帧写二进制 bin 文件 |
DataPlayer.h/.cpp |
回放模块,读取 bin 文件、定时推送帧 |
1.2 外部文件(官网下载,非手写)
qcustomplot.h/qcustomplot.cpp:第三方实时绘图库
1.3 手写 vs Qt 自带对照表
| 模块 | Qt 框架提供 | 自己手写 |
|---|---|---|
| 线程同步 | QMutex、QMutexLocker、QWaitCondition、QQueue |
ThreadSafeQueue 模板封装 |
| 子线程 | QThread 基类 |
ProcessThread,重写 run()、二进制协议解析 |
| UDP 网络 | QUdpSocket、readyRead 信号 |
processPendingDatagrams 读取入队逻辑 |
| 绘图 | 第三方 QCustomPlot(下载) | updatePlot 数据追加、滑动窗口逻辑 |
| 文件读写 | QFile |
DataRecorder 二进制格式封装 |
| 回放定时 | QTimer 定时器 |
DataPlayer bin 解析、帧缓存、推送队列 |
| UI 主窗口 | QMainWindow、信号槽、closeEvent、QMenu/QAction/QFileDialog |
QtGuiApplication1 业务粘合代码 |
2. 整体数据流(核心架构)
【数据源1:UDP网络】 → push 进入线程安全队列【生产者】
【数据源2:回放文件DataPlayer】 → push 进入同一个线程安全队列【生产者】
↓
ProcessThread 子线程 tryPop 取出原始字节帧 → 二进制协议解析 → emit 信号抛 double 到主线程
↓
主线程槽函数 updatePlot → QCustomPlot 实时绘图
同时 UDP 原始帧直接写入 bin 录制文件;回放复用整套解析绘图链路。
对象调用流程图(Mermaid)
设计亮点:网络真实数据、回放磁盘数据两条来源输入同一个队列,后续解析/绘图链路完全复用,不重复写代码。
3. 分阶段实现细节(按开发顺序)
阶段 1:基础项目骨架搭建(main.cpp + QtGuiApplication1)
main.cpp:创建QtGuiApplication1 w,show(),a.exec()启动 Qt 事件循环。- 配置 Qt Modules:
core;gui;widgets;network,Debug 和 Release 两套配置都要改。 - 此时还没有队列、线程、绘图、录制回放。
阶段 2:线程安全模板队列 ThreadSafeQueue.h
解决多线程数据竞争:生产者写队列、消费者读队列。
- 只有 .h,没有 .cpp(模板类原因,见第 5 节)。
- Qt 依赖:
QQueue<T>(存储容器)、QMutex+QMutexLocker(互斥锁)、QWaitCondition(条件变量)。 - 自定义模板类
ThreadSafeQueue<T>,项目实际用T = QByteArray(原始字节帧)。
公有接口:
| 函数 | 作用 | 调用方 |
|---|---|---|
push(const T& data) |
入队,wakeOne() 唤醒等待的消费者 |
生产者(UDP 接收、DataPlayer 回放) |
tryPop(T& data, int TimeoutMs=100) |
出队;队空则等待;成功返回1,失败返回0 | 消费者(ProcessThread) |
size() |
调试用,返回队列元素个数 | 调试代码 |
私有成员: QQueue<T> m_queue、QMutex m_mutex、QWaitCondition m_cond
实例化位置:
QtGuiApplication1.h定义ThreadSafeQueue<QByteArray> m_queue;,生命周期跟随主窗口;传给线程、回放器的都是队列指针,操作同一个实例。
阶段 3:消费者业务线程 ProcessThread(子线程协议解析,不阻塞 UI)
-
继承
QThread,重写run()。 -
构造函数接收外部队列指针
ThreadSafeQueue<QByteArray> *queue,m_running=true。 -
stop():置m_running=false,通知线程退出。 -
run():while(m_running)循环 →tryPop(data, 100)取帧 → 二进制协议解析:- 帧最小长度 ≥ 12 字节
- 校验帧头
0xAA 0x55 - 校验数据类型 type = 0x01
- 小端序解析 4 字节长度域
- 校验负载长度等于
sizeof(float) - XOR 异或校验和校验
memcpy内存拷贝,字节流转 float,强转 doubleemit dataReceived(value);跨线程把 double 抛回主线程
-
任一校验失败
continue丢弃本帧,继续取下一帧。
初始化时机(QtGuiApplication1 构造函数):
m_processThread = new ProcessThread(&m_queue, this);
connect(m_processThread, &ProcessThread::dataReceived, this, &QtGuiApplication1::updatePlot);
m_processThread->start();
线程退出:主窗口
closeEvent调stop()+wait(2000)。
阶段 4:UDP 接收网络数据(生产者,只读取+入队,不解析)
m_udpSocket = new QUdpSocket(this);
m_udpSocket->bind(QHostAddress::AnyIPv4, 8888);
connect(m_udpSocket, &QUdpSocket::readyRead, this, &QtGuiApplication1::processPendingDatagrams);
processPendingDatagrams()(主线程执行):
while (m_udpSocket->hasPendingDatagrams())
{
读取UDP数据包到QByteArray datagram;
m_queue.push(datagram); // 只入队,不做解析
}
关键:UDP 接收只做两件事——读包、入队;解析计算全部交给 ProcessThread 子线程,主线程不做耗时操作,防止 UI 卡死。
阶段 5:QCustomPlot 实时绘图
- 加入
qcustomplot.h/.cpp,Qt 模块追加printsupport(Debug&Release 都配)。 - 成员:
m_plot、QVector<double> m_xData/m_yData、int m_pointCount。 - 构造函数:
new QCustomPlot→setCentralWidget→addGraph→ 设置画笔/坐标轴/范围,m_pointCount=0。 - 槽函数
updatePlot(double value):m_pointCount++,x/y 追加点- 只保留最近 200 个点,超出
removeFirst()删旧数据 m_plot->graph(0)->setData(m_xData, m_yData)- X 轴跟随数据滑动
m_plot->replot()刷新画面,更新窗口标题
阶段 6:DataRecorder 数据录制(原始帧写二进制 bin)
- 设计:UDP 收到的原始
QByteArray原样写磁盘,不做协议解析。 - 接口:
open()(写 8 字节文件头:XX2406魔数 6 字节 + 版本 2 字节)、close()(析构自动调用)、writeFrame()、isOpen()。 writeFrame:先写 4 字节小端序帧长度 → 写原始帧 →flush()强制落盘。- 主窗口使用:懒创建——第一次收到 UDP 数据包才打开录制文件,生成带时间戳的 bin 文件名;
closeEvent关闭文件。
bin 文件格式:
[8字节文件头] [4字节小端帧长N][N字节原始帧] [4字节帧长M][M字节原始帧] ...
阶段 7:DataPlayer 回放模块(读取 bin,复用现有解析绘图链路)
- 核心思路:回放不重写解析;读取 bin 解析全部帧存内存
m_frames,定时器定时把帧 push 进同一个共享队列,复用 ProcessThread 整套解析、绘图。 - 接口:
loadFile()(校验 XM2406 魔数、解析帧)、start(intervalMs=50)、stop()、frameCount()。 - 信号:
playFinished()、playError(const QString&)。 - 私有槽
onTimerTick():下标到尾 → 停定时器 + 发playFinished();否则m_queue->push(m_frames[m_currentIndex]),下标自增。 - 成员:
m_queue(共享队列指针)、m_frames、m_currentIndex、QTimer m_timer。 - 主窗口集成:菜单栏【文件】→【打开回放文件】→
QFileDialog::getOpenFileName→loadFile→start(50)。
阶段 8:窗口关闭安全释放资源 closeEvent
重写 void closeEvent(QCloseEvent *event) override;,顺序不能乱:
- 停止回放器
m_player->stop() - 通知消费者线程停止
m_processThread->stop(),wait(2000)等待线程退出 - 关闭录制器
m_recorder.close(),确保落盘 event->accept()允许关闭
不做该顺序 → 定时器继续触发、线程后台运行、文件未关闭 → 内存泄漏、数据丢失、程序崩溃。
4. 关键原理问答(重点复盘)
4.1 为什么 UDP 和回放能 push 进同一个队列,不会冲突?
- 核心:
ThreadSafeQueue内部用QMutex锁保护,本身设计就是支持"多生产者 + 单消费者"并发访问的线程安全容器。 - 现状(UDP、回放都在主线程):主线程串行执行,两个 push 天然不会同时跑;但 主线程 push 和 ProcessThread 子线程 tryPop 是跨线程并发,这组访问才是靠锁保护的。
- 如果把回放也放子线程:依然不会内存冲突,因为所有读写队列的入口(push/tryPop)共用同一把锁,同一时刻只有一个线程能操作内部
QQueue。 - 锁职责 vs 条件变量职责:
- QMutex:解决数据竞争(内存冲突),保证内部 QQueue 不被多线程同时读写
- QWaitCondition:不负责防冲突,只负责"队空时消费者阻塞等待、有数据后唤醒",避免空轮询浪费 CPU
- ⚠️ 业务层注意:锁只解决程序崩溃、内存损坏,不能阻止实时帧和回放帧在队列里混合。若同时开采集+回放,曲线会穿插。上层应加业务开关互斥(如
isPlaying时暂停 UDP 接收)。
4.2 ThreadSafeQueue 为什么只有 .h 没有 .cpp?
- 因为它是 C++ 模板类。模板实现代码不能单独放 .cpp,否则链接报
undefined reference。 - 原理:模板函数依赖类型参数
T,编译 cpp 时编译器不知道未来用什么 T,不会提前生成QByteArray版本的函数;只有写ThreadSafeQueue<QByteArray> m_queue;时编译器才现场实例化。 - 如果实现放 cpp,编译 cpp 时没有该类型,链接时找不到函数实现。
- 可行方案:
- ✅ 声明 + 全部实现放 .h(本项目用的,小白最省事)
- 显式实例化
template class ThreadSafeQueue<QByteArray>;(写死类型,通用性差) - export 模板(C++20 才支持,VS2015 不支持)
- 补充:普通非模板类(ProcessThread、DataRecorder 等)声明放 h、实现放 cpp 完全正常,只有模板才有此限制。多个 cpp include 同一个模板 .h 不会重定义(弱链接特性自动合并)。
4.3 "模板类"怎么理解?
- 普通类 = 做好的成品;模板类 = 一张"类的图纸/模具",不是可直接 new 的成品。
ThreadSafeQueue<T>中的T是类型占位符,不是真实类型;单独写ThreadSafeQueue q;是错的。- 必须填具体类型才生成真正的类:
ThreadSafeQueue<QByteArray> q1; // 造一个存QByteArray的队列 ThreadSafeQueue<double> q2; // 用同一份模具造存double的队列ThreadSafeQueue<QByteArray>和ThreadSafeQueue<double>是两个完全独立不同的类,出自同一份模板图纸。 - 生活化比方:模具(模板类)本身不能吃,灌入不同原料(类型 T),做出的雪糕(实例化后的类)才能吃。
4.4 模板用法是 C++ 语法特有吗?还是人为构造?
- ✅ 模板(类模板/函数模板)是 C++ 标准原生语法特性,不是人为模拟的技巧。
template、typename/class是 C++ 保留关键字,编译器原生识别并做实例化、类型推导。C++98 就有,VS2015 完全支持。- 人为模拟的通用容器方案(不是语言模板):
void*+ 强转、#define宏拼接、继承基类多态——都有明显缺点,属于人为构造。 - 其他语言:Java/C# 泛型(类型擦除,机制不同)、Python 动态类型(不需要模板)、C 语言完全没有模板。
- 区分:
- 语法能力(template 机制)是语言给的 ✅
- "写一个线程安全队列模板"这件业务设计,是人写的 ✍️
4.5 m_cond.wait() 执行时会一直阻塞吗?
wait()会阻塞,但会先自动释放关联的 mutex 锁,然后休眠阻塞;被唤醒后重新抢回锁,wait()才返回。- 完整流程:
- 线程持有 mutex,发现队列空,调
wait(&m_mutex, t) wait自动释放 mutex,线程休眠挂起(释放锁才能让生产者 push,否则死锁)- 生产者拿到锁、push 数据、
wakeOne()唤醒该线程 - 线程被唤醒 → 重新参与 mutex 竞争 → 抢到锁后
wait才返回
- 线程持有 mutex,发现队列空,调
- ⚠️
wait返回 ≠ 队列一定有数据:存在虚假唤醒(spurious wakeup),所以判断空队列必须用while不能if。 - 两种阻塞区别:
mutex.lock()阻塞:抱着锁等别人释放condition.wait()阻塞:主动释放锁,休眠等信号,唤醒后再抢锁
- 带超时版本
wait(&mutex, TimeoutMs):超时会自动醒来返回 false;无超时wait(&mutex)会一直等直到收到信号。
4.6 wait() 是 Qt 自带方法吗?唤醒后怎么保证拿到锁?
- ✅
m_cond类型是QWaitCondition,wait()是QWaitCondition类自己的成员函数,不是继承来的;配套wakeOne()/wakeAll()也是它的成员。QWaitCondition和QMutex是两个独立 Qt 类,配合使用。 - ❌ 唤醒后不保证一定能拿到锁!
wakeOne()只是把休眠线程叫醒、让它加入锁竞争,没有优先权。若别的线程抢先拿到锁,该线程会继续阻塞在锁竞争上,等对方释放才拿到。 - 所以代码用
while(m_queue.isEmpty())双重判断:即使被唤醒但抢锁慢了、队列又被别的线程取空,拿到锁后再判断一次,为空则继续等待。
5. 高频踩坑点汇总(照单避雷)
- Windows SDK 版本太高,报
_mm_loadu_si64→ 降级 SDK。 - Qt 模块配置 Debug 和 Release 两套都要改,容易漏 Release 导致链接报错。
- 不能删除自动生成的
ui_QtGuiApplication1.h头文件。 - 字符串 API 拼写错误:
fromLocal8Bit、trimmed()漏括号、函数大小写tryPop。 - 中文乱码:
fromLocal8Bit、源文件编码 UTF-8 带 BOM。 - QCustomPlot 必须加
printsupport模块。 - 线程安全核心原则:共享容器的所有访问路径都必须走带锁接口,不能有"后门"直接操作内部
m_queue(内部设 private 正是防误操作)。 - 退出顺序不能乱:先停回放定时器 → 停并等待子线程 → 关录制文件。
6. 核心关键要点复盘(写给自己)
ThreadSafeQueue是模板类,实例化为ThreadSafeQueue<QByteArray>;线程、回放器拿队列指针,操作同一个队列对象,不是拷贝副本。- ProcessThread 子线程严禁操作 UI;解析完靠
emit dataReceived(double)跨线程信号交给主线程槽绘图。 DataRecorder是栈对象,懒创建——收到第一条 UDP 数据才打开录制文件。- DataPlayer 回放不重新写协议解析,回放帧 push 进共享队列,完全复用 ProcessThread 解析链路。
- 锁(QMutex)解决内存数据竞争;条件变量(QWaitCondition)解决阻塞等待;二者配对使用同一个 mutex。
- Qt 模块
network、printsupport,Debug、Release 都配置。
7. 后续待开发路线(笔记原规划)
- 完成实时曲线 ✅
- 自定义二进制通信协议(帧头、长度、校验和) ✅
- 子线程二进制帧解析 ✅
- 原始数据二进制文件存储 ✅
- 文件读取、历史数据回放 ✅
目前已全部完成,剩余可扩展方向:实时+回放业务互斥开关、更丰富的协议类型支持、波形缩放交互等。
浙公网安备 33010602011771号