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)

flowchart TD subgraph 主线程【QtGuiApplication1 主窗口】 A[QUdpSocket UDP接收] -->|processPendingDatagrams| B[m_queue:ThreadSafeQueue<QByteArray>] A -->|原始帧| C[DataRecorder 录制器<br/>写入*.bin二进制文件] D[DataPlayer回放器<br/>QTimer定时器] -->|onTimerTick定时推送帧| B E[QCustomPlot绘图控件<br/>updatePlot槽函数] F[菜单栏:打开回放文件] --> D G[closeEvent窗口关闭事件] -->|1.停止回放定时器| D G -->|2.停止并等待线程退出| H[ProcessThread子线程] G -->|3.关闭录制文件| C end subgraph 子线程 ProcessThread H -->|tryPop取帧| B H -->|二进制协议解析<br/>校验帧头/类型/长度/校验和<br/>memcpy转float→double| I{解析成功?} I -->|emit dataReceived(double) 跨线程信号| E I -->|校验失败丢弃本帧| H end note1[共享核心:m_queue 线程安全队列<br/>网络、回放两个数据源都往这里塞数据,子线程统一消费]

设计亮点:网络真实数据、回放磁盘数据两条来源输入同一个队列,后续解析/绘图链路完全复用,不重复写代码。


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) 取帧 → 二进制协议解析:

    1. 帧最小长度 ≥ 12 字节
    2. 校验帧头 0xAA 0x55
    3. 校验数据类型 type = 0x01
    4. 小端序解析 4 字节长度域
    5. 校验负载长度等于 sizeof(float)
    6. XOR 异或校验和校验
    7. memcpy 内存拷贝,字节流转 float,强转 double
    8. emit 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):
    1. m_pointCount++,x/y 追加点
    2. 只保留最近 200 个点,超出 removeFirst() 删旧数据
    3. m_plot->graph(0)->setData(m_xData, m_yData)
    4. X 轴跟随数据滑动
    5. 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;,顺序不能乱:

  1. 停止回放器 m_player->stop()
  2. 通知消费者线程停止 m_processThread->stop(),wait(2000) 等待线程退出
  3. 关闭录制器 m_recorder.close(),确保落盘
  4. 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() 才返回。
  • 完整流程:
    1. 线程持有 mutex,发现队列空,调 wait(&m_mutex, t)
    2. wait 自动释放 mutex,线程休眠挂起(释放锁才能让生产者 push,否则死锁)
    3. 生产者拿到锁、push 数据、wakeOne() 唤醒该线程
    4. 线程被唤醒 → 重新参与 mutex 竞争 → 抢到锁后 wait 才返回
  • ⚠️ 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. 高频踩坑点汇总(照单避雷)

  1. Windows SDK 版本太高,报 _mm_loadu_si64 → 降级 SDK。
  2. Qt 模块配置 Debug 和 Release 两套都要改,容易漏 Release 导致链接报错。
  3. 不能删除自动生成的 ui_QtGuiApplication1.h 头文件。
  4. 字符串 API 拼写错误:fromLocal8Bit、trimmed() 漏括号、函数大小写 tryPop。
  5. 中文乱码:fromLocal8Bit、源文件编码 UTF-8 带 BOM。
  6. QCustomPlot 必须加 printsupport 模块。
  7. 线程安全核心原则:共享容器的所有访问路径都必须走带锁接口,不能有"后门"直接操作内部 m_queue(内部设 private 正是防误操作)。
  8. 退出顺序不能乱:先停回放定时器 → 停并等待子线程 → 关录制文件。

6. 核心关键要点复盘(写给自己)

  1. ThreadSafeQueue 是模板类,实例化为 ThreadSafeQueue<QByteArray>;线程、回放器拿队列指针,操作同一个队列对象,不是拷贝副本。
  2. ProcessThread 子线程严禁操作 UI;解析完靠 emit dataReceived(double) 跨线程信号交给主线程槽绘图。
  3. DataRecorder 是栈对象,懒创建——收到第一条 UDP 数据才打开录制文件。
  4. DataPlayer 回放不重新写协议解析,回放帧 push 进共享队列,完全复用 ProcessThread 解析链路。
  5. 锁(QMutex)解决内存数据竞争;条件变量(QWaitCondition)解决阻塞等待;二者配对使用同一个 mutex。
  6. Qt 模块 network、printsupport,Debug、Release 都配置。

7. 后续待开发路线(笔记原规划)

  1. 完成实时曲线 ✅
  2. 自定义二进制通信协议(帧头、长度、校验和) ✅
  3. 子线程二进制帧解析 ✅
  4. 原始数据二进制文件存储 ✅
  5. 文件读取、历史数据回放 ✅

目前已全部完成,剩余可扩展方向:实时+回放业务互斥开关、更丰富的协议类型支持、波形缩放交互等。

posted on 2026-10-16 11:45  中年二班  阅读(4)  评论(0)    收藏  举报