这次我们来看一个很适合作为“系统编程第一个工程项目”的 Qt 实战项目Windows 实时系统监控工具。项目本身不复杂但把 C 系统编程和 Qt 桌面开发最常见的内容都串起来了Windows 系统 API 调用、定时采集、多线程、图表绘制、进程列表、界面刷新。做完这个项目你对“程序怎么拿到系统信息”“怎么让界面实时刷新不卡顿”会有非常直观的理解。这个项目的核心亮点可以概括为技术栈干净C Qt Windows API不依赖第三方监控库。功能覆盖全面CPU、内存、磁盘、网络、进程列表基本覆盖系统监控的主要维度。工程结构清晰适合按“采集层 业务层 UI 层”拆分方便后续扩展成托盘工具、远程监控上报工具等。本文会带大家完成以下内容从环境梳理开始逐步拆解采集层、界面层和多线程刷新机制最后给出功能验证方式和常见问题排查清单。如果你刚学完 C 基础想在 Qt 里做第一个真正有“工程感”的项目这篇可以直接收藏。1. 核心能力速览能力项说明项目类型Windows 桌面监控工具 / C Qt 教学项目技术栈C17 Qt 5.15 / Qt 6.x Win32 API主要功能CPU 使用率、内存占用、磁盘容量、网络速率、进程列表实时监控运行平台Windows 10 / Windows 11x64编译环境MSVC 2019/2022 或 MinGWQt Charts 模块启动方式编译后直接运行 exe外部依赖仅 Qt、Windows SDK无第三方监控库接口 API / 批量任务不涉及可后续扩展为 HTTP 上报接口适合场景C Qt 学习、系统编程入门、本地性能分析、进程排查这个项目最大的价值不是“功能多”而是“知识密度高”。你写一个带界面、带图表、带进程管理的桌面程序至少要接触 10 个以上 Windows API还要处理多线程数据传递、Qt 信号槽连接、图表数据更新等问题。一个项目把这些全部过一遍比单独看语法教程有用得多。2. 项目架构与核心思路开发之前先把架构想清楚。系统监控工具最忌讳的做法是在 UI 线程里直接调用 Windows API 做耗时操作比如遍历进程列表、读取网络接口状态。如果每秒钟在主线程里跑一次全量采集界面很容易卡成 PPT。更好的做法是采用经典的三层结构层级职责关键类/模块采集层调用 Windows API 获取原始系统数据CpuMonitor、MemoryMonitor、DiskMonitor、NetworkMonitor、ProcessMonitor业务层聚合数据、计算时间增量、维护历史缓冲MonitorManagerUI 层展示数字、曲线、表格接受用户操作MainWindow、MonitorChart、ProcessTable数据流向是单向的采集线程(Worker) - 发出 MonitorData 结构体 - 主线程信号槽 - 更新 UI需要注意几个设计点采集线程不能直接操作 UI 控件必须通过信号槽把数据发回主线程。CPU 使用率和网络速率需要计算两次采样之间的差值不能直接读取瞬时值。历史曲线需要一个固定长度的环形缓冲或队列避免图表数据无限增长。UI 刷新频率可以比采集频率低一些例如采集每 1 秒一次图表刷新每 1 秒两次避免 CPU 占用过高。3. 环境准备与项目初始化3.1 开发环境这个项目需要准备的环境非常简单Qt 5.15 或 Qt 6.x包含 Qt Charts 模块。编译器选择 MSVC 2019/2022 或 MinGW。CMake 3.16 或 Qt Creator 自带的 qmake。如果你用 Visual Studio Code 配置 C/C 环境需要提前装好 C/C 扩展、CMake Tools 扩展并在系统 PATH 中配置好 MSVC 或 Qt 的编译器路径。更稳妥的做法是直接用 Qt Creator它对 Qt 项目的 CMake 配置更加友好省去很多环境变量问题。Qt 安装时如果不知道选哪个编译器套件可以直接选择“MSVC 2019 64-bit”或者“MinGW 11.2.0 64-bit”并勾选 Qt Charts 模块。不勾选 Charts 的话后面find_package(Qt6 COMPONENTS Charts)会直接报错。3.2 项目目录结构建议按下面的结构组织项目采集层和 UI 层分开后续扩展功能会非常方便SystemMonitor/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── MainWindow.h │ ├── MainWindow.cpp │ ├── monitors/ │ │ ├── CpuMonitor.h │ │ ├── CpuMonitor.cpp │ │ ├── MemoryMonitor.h │ │ ├── MemoryMonitor.cpp │ │ ├── DiskMonitor.h │ │ ├── DiskMonitor.cpp │ │ ├── NetworkMonitor.h │ │ ├── NetworkMonitor.cpp │ │ ├── ProcessMonitor.h │ │ └── ProcessMonitor.cpp │ ├── worker/ │ │ ├── MonitorWorker.h │ │ └── MonitorWorker.cpp │ └── ui/ │ ├── MonitorChart.h │ ├── MonitorChart.cpp │ ├── ProcessTable.h │ └── ProcessTable.cpp3.3 CMake 配置模板下面是一个可以直接用的 CMakeLists.txt 模板。注意Qt6和Qt5需要按你实际安装的版本调整。cmake_minimum_required(VERSION 3.16) project(SystemMonitor LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) find_package(Qt6 REQUIRED COMPONENTS Widgets Charts) # 如果使用 Qt5改为 # find_package(Qt5 REQUIRED COMPONENTS Widgets Charts) # Windows API 依赖 if(WIN32) target_link_libraries(SystemMonitor PRIVATE psapi iphlpapi) endif() add_executable(SystemMonitor src/main.cpp src/MainWindow.cpp src/MainWindow.h src/monitors/CpuMonitor.cpp src/monitors/CpuMonitor.h src/monitors/MemoryMonitor.cpp src/monitors/MemoryMonitor.h src/monitors/DiskMonitor.cpp src/monitors/DiskMonitor.h src/monitors/NetworkMonitor.cpp src/monitors/NetworkMonitor.h src/monitors/ProcessMonitor.cpp src/monitors/ProcessMonitor.h src/worker/MonitorWorker.cpp src/worker/MonitorWorker.h src/ui/MonitorChart.cpp src/ui/MonitorChart.h src/ui/ProcessTable.cpp src/ui/ProcessTable.h ) target_link_libraries(SystemMonitor PRIVATE Qt6::Widgets Qt6::Charts )编译时如果提示找不到Qt6Charts检查一下 Qt 安装是否勾选了 Charts 模块或者在 Qt Maintenance Tool 中补充安装。4. 系统信息采集层设计采集层是整个项目的核心。每个监视器只负责一类数据的获取便于单独测试。4.1 CPU 使用率采集CPU 使用率不能直接读一次系统时间就得出结果。Windows API 提供GetSystemTimes可以获取系统的空闲时间、内核时间和用户时间通过两次采样的差值计算出使用率。#include windows.h #include QtGlobal class CpuMonitor { public: struct CpuTimes { qint64 idle 0; qint64 kernel 0; qint64 user 0; }; bool snapshot(CpuTimes times) { FILETIME idle, kernel, user; if (!GetSystemTimes(idle, kernel, user)) { return false; } times.idle toInt64(idle); times.kernel toInt64(kernel); times.user toInt64(user); return true; } double usage() { CpuTimes now; if (!snapshot(now)) { return 0.0; } if (m_last.kernel 0) { m_last now; return 0.0; } qint64 idleDelta now.idle - m_last.idle; qint64 totalDelta (now.kernel - m_last.kernel) (now.user - m_last.user); m_last now; if (totalDelta 0) { return 0.0; } return (1.0 - static_castdouble(idleDelta) / totalDelta) * 100.0; } private: CpuTimes m_last; static qint64 toInt64(const FILETIME ft) { ULARGE_INTEGER value; value.LowPart ft.dwLowDateTime; value.HighPart ft.dwHighDateTime; return static_castqint64(value.QuadPart); } };这里有一个容易踩的坑第一次调用usage()时由于没有上次快照无法计算差值应该直接返回 0 或等待第二次采样。实际开发中第一次采样结果会丢弃。4.2 内存监控内存信息通过GlobalMemoryStatusEx一次获取同时能拿到物理内存总量、可用量、虚拟内存总量和系统内存使用率占用率直接用系统算好的dwMemoryLoad即可#include windows.h class MemoryMonitor { public: void snapshot() { MEMORYSTATUSEX memInfo; memInfo.dwLength sizeof(memInfo); if (GlobalMemoryStatusEx(memInfo)) { m_totalPhys memInfo.ullTotalPhys; m_availPhys memInfo.ullAvailPhys; m_usagePercent memInfo.dwMemoryLoad; } } double usagePercent() const { return m_usagePercent; } qint64 totalPhys() const { return static_castqint64(m_totalPhys); } qint64 availPhys() const { return static_castqint64(m_availPhys); } private: DWORD m_usagePercent 0; ULONGLONG m_totalPhys 0; ULONGLONG m_availPhys 0; };内存监控属于“瞬时值”不需要差值计算实现最简单。UI 上可以做成百分比进度条显示已用内存/总内存。4.3 磁盘监控磁盘监控可以对每个逻辑分区单独统计。GetDiskFreeSpaceExW可以获取指定路径所在分区的总容量和剩余容量#include windows.h #include QString class DiskMonitor { public: void snapshot(const QString path) { ULARGE_INTEGER total, freeBytes; // path 形如 C:/、D:/ const wchar_t *dir reinterpret_castconst wchar_t *(path.utf16()); if (GetDiskFreeSpaceExW(dir, nullptr, total, freeBytes)) { m_totalGB total.QuadPart / (1024.0 * 1024.0 * 1024.0); m_freeGB freeBytes.QuadPart / (1024.0 * 1024.0 * 1024.0); m_usedPercent 100.0 * (1.0 - static_castdouble(freeBytes.QuadPart) / total.QuadPart); } } double totalGB() const { return m_totalGB; } double freeGB() const { return m_freeGB; } double usedPercent() const { return m_usedPercent; } private: double m_totalGB 0.0; double m_freeGB 0.0; double m_usedPercent 0.0; };如果你的程序要监控“磁盘读写速率”那就不能靠GetDiskFreeSpaceEx了需要调用 PDH 计数器或者使用GetDiskPerformance这类接口。对于第一个工程项目做到分区容量监控已经足够。4.4 网络流量监控网络实时速率的计算思路和 CPU 类似先读取网络接口累计收发字节数两次采样做差值再除以时间间隔。Windows 下可以调用GetIfTable获取网卡接口表读取dwInOctets和dwOutOctets字段这两个字段表示接口累计接收和发送的字节数。第一次采集同样需要做基线丢弃。#include winsock2.h #include iphlpapi.h #pragma comment(lib, iphlpapi.lib) class NetworkMonitor { public: struct Snapshot { DWORD inOctets 0; DWORD outOctets 0; }; bool read(Snapshot snap) { ULONG size 0; if (GetIfTable(nullptr, size, TRUE) ! ERROR_INSUFFICIENT_BUFFER) { return false; } QVectorBYTE buffer(size); PMIB_IFTABLE table reinterpret_castPMIB_IFTABLE(buffer.data()); if (GetIfTable(table, size, TRUE) ! NO_ERROR) { return false; } DWORD totalIn 0, totalOut 0; for (ULONG i 0; i table-dwNumEntries; i) { MIB_IFROW row table-table[i]; // 忽略 loopback 回环接口只看实际物理网卡 if (row.dwType IF_TYPE_SOFTWARE_LOOPBACK) { continue; } totalIn row.dwInOctets; totalOut row.dwOutOctets; } snap.inOctets totalIn; snap.outOctets totalOut; return true; } // 调用方式保存上一次快照计算两次差值后除以采样间隔 double speedKbps(Snapshot prev, Snapshot now, double seconds) { if (seconds 0) return 0.0; double inKb (now.inOctets - prev.inOctets) * 8.0 / 1024.0; return inKb / seconds; } private: Snapshot m_last; };这里要注意GetIfTable需要包含winsock2.h和iphlpapi.h在 CMake 里已经链接了iphlpapi。如果你的网络环境中有多个虚拟网卡统计结果会包含所有接口的汇总实际显示速度可能比单块物理网卡高。4.5 进程列表监控进程列表是系统监控工具里比较有交互感的功能。可以用EnumProcesses枚举 PID再配合OpenProcess和QueryFullProcessImageNameW获取进程路径#include windows.h #include psapi.h #pragma comment(lib, psapi.lib) #include QString #include QVector struct ProcessInfo { DWORD pid 0; QString name; QString path; }; bool collectProcesses(QVectorProcessInfo out) { DWORD processIds[4096]; DWORD bytesReturned 0; if (!EnumProcesses(processIds, sizeof(processIds), bytesReturned)) { return false; } DWORD count bytesReturned / sizeof(DWORD); out.clear(); out.reserve(count); for (DWORD i 0; i count; i) { DWORD pid processIds[i]; if (pid 0) { continue; } HANDLE process OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, pid); if (!process) { continue; } wchar_t buffer[MAX_PATH] {0}; DWORD bufferSize MAX_PATH; if (QueryFullProcessImageNameW(process, 0, buffer, bufferSize)) { ProcessInfo info; info.pid pid; info.path QString::fromWCharArray(buffer); info.name info.path.section(\\, -1); out.append(info); } CloseHandle(process); } return true; }需要注意某些系统进程在低权限下无法打开OpenProcess会返回空句柄这是正常的跳过即可。管理员权限运行可以读到更多进程信息。5. 界面展示与图表刷新设计界面层我建议用QTabWidget组织页面一个 Tab 放实时曲线一个 Tab 放进程列表“系统概览”页CPU 使用率曲线、内存使用率曲线、各分区磁盘容量进度条、网络上行/下行速率。“进程管理”页进程名、PID、路径的表格支持点击结束进程或刷新列表。图表部分使用 Qt Charts 的QSplineSeries或QLineSeries搭配QChartView显示实时曲线。下面给出一个简单示例#include QtCharts/QChartView #include QtCharts/QLineSeries #include QtCharts/QValueAxis using namespace QtCharts; class MonitorChart : public QChartView { Q_OBJECT public: explicit MonitorChart(QWidget *parent nullptr) : QChartView(parent) { m_series new QLineSeries(); m_chart new QChart(); m_chart-addSeries(m_series); m_axisX new QValueAxis(); m_axisX-setRange(0, 60); m_axisY new QValueAxis(); m_axisY-setRange(0, 100); m_chart-addAxis(m_axisX, Qt::AlignBottom); m_chart-addAxis(m_axisY, Qt::AlignLeft); m_series-attachAxis(m_axisX); m_series-attachAxis(m_axisY); m_chart-legend()-hide(); setChart(m_chart); } void appendValue(double value) { m_series-append(m_index, value); if (m_series-count() 60) { m_series-remove(0); } m_chart-axisX()-setRange(m_index - 60, m_index); } private: QLineSeries *m_series; QChart *m_chart; QValueAxis *m_axisX; QValueAxis *m_axisY; qreal m_index 0; };曲线只保留最近 60 个点避免内存和绘制压力无限增长。如果你希望曲线更平滑可以把QLineSeries换成QSplineSeries但要注意数据点较多时会有一定 CPU 开销。6. 多线程与刷新机制设计系统监控工具要“实时”但不能因为实时刷新导致界面卡顿所以采集工作要放到单独线程中。6.1 Worker 类设计建议使用QThread 普通 QObject Worker的方式而不是直接继承 QThread 重写 run()。原因是 Worker 的生命周期更容易管理停止线程也更安全。#include QObject #include QVector #include monitors/CpuMonitor.h #include monitors/MemoryMonitor.h #include monitors/DiskMonitor.h #include monitors/NetworkMonitor.h #include monitors/ProcessMonitor.h struct MonitorData { double cpuUsage 0.0; double memoryUsage 0.0; double memoryUsedGB 0.0; double memoryTotalGB 0.0; double diskUsedPercent 0.0; double diskFreeGB 0.0; double netDownloadKbps 0.0; double netUploadKbps 0.0; QVectorProcessInfo processes; }; class MonitorWorker : public QObject { Q_OBJECT public: explicit MonitorWorker(QObject *parent nullptr); public slots: void start(); void stop(); signals: void dataReady(const MonitorData data); private: void collectAndEmit(); CpuMonitor m_cpu; MemoryMonitor m_memory; DiskMonitor m_disk; NetworkMonitor m_network; bool m_running false; };在collectAndEmit中把所有采集器的数据聚合到MonitorData结构体通过emit dataReady(data)发回主线程void MonitorWorker::collectAndEmit() { MonitorData data; data.cpuUsage m_cpu.usage(); m_memory.snapshot(); data.memoryUsage m_memory.usagePercent(); data.memoryUsedGB (m_memory.totalPhys() - m_memory.availPhys()) / (1024.0 * 1024.0 * 1024.0); data.memoryTotalGB m_memory.totalPhys() / (1024.0 * 1024.0 * 1024.0); m_disk.snapshot(QStringLiteral(C:/)); data.diskUsedPercent m_disk.usedPercent(); data.diskFreeGB m_disk.freeGB(); // 网络需要根据两次采样间隔计算这里简化处理 NetworkMonitor::Snapshot now; if (m_network.read(now)) { if (m_lastNet.inOctets ! 0) { double seconds m_timer.elapsed() / 1000.0; data.netDownloadKbps m_network.speedKbps(m_lastNet, now, seconds); } m_lastNet now; m_timer.restart(); } collectProcesses(data.processes); emit dataReady(data); }6.2 主线程接收与定时刷新主窗口创建一个子线程把 Worker 移入线程连接信号槽#include worker/MonitorWorker.h MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { m_worker new MonitorWorker(); m_thread new QThread(this); m_worker-moveToThread(m_thread); connect(m_thread, QThread::finished, m_worker, QObject::deleteLater); connect(m_worker, MonitorWorker::dataReady, this, MainWindow::updateUI); m_thread-start(); QMetaObject::invokeMethod(m_worker, start, Qt::QueuedConnection); } void MainWindow::updateUI(const MonitorData data) { m_cpuChart-appendValue(data.cpuUsage); m_memoryChart-appendValue(data.memoryUsage); m_memoryLabel-setText(QStringLiteral(内存占用%1% (%2 GB / %3 GB)) .arg(data.memoryUsage, 0, f, 1) .arg(data.memoryUsedGB, 0, f, 1) .arg(data.memoryTotalGB, 0, f, 1)); // 刷新进程表格 refreshProcessTable(data.processes); }Worker 内部要有停止机制。最简单的做法是增加一个m_running标志在start()里启动一个QTimer定时执行采集间隔设为 1000 msvoid MonitorWorker::start() { if (m_running) return; m_running true; m_timer.start(1000); } void MonitorWorker::stop() { m_running false; m_timer.stop(); }注意QTimer在 Worker 内部创建后必须依赖事件循环运行。也就是说moveToThread之后线程必须exec()进入事件循环定时器才能触发。如果定时器不工作先检查线程是否通过start()正常启动了事件循环。7. 功能测试与效果验证项目写完后不要直接开始扩展功能先跑一轮基础验证。下面是适合这个项目的验证清单。测试项操作方式预期结果判断成功标准CPU 监控打开一个多线程耗时任务比如压缩大文件CPU 曲线上升空闲时下降曲线变化与任务状态基本一致内存监控连续打开大型软件或浏览器多个标签页已用内存增加、可用内存减少内存占用数据与任务管理器接近磁盘监控向监控分区拷贝大文件已用容量上升、剩余容量下降数据刷新及时不出现明显滞后网络监控下载大文件或运行网速测试工具下载速率曲线上升数值与资源监视器接近且上行/下行方向正确进程列表启动一个测试程序查看进程列表测试程序在列表中可见能正确显示进程名和 PID界面流畅度连续运行 5 分钟窗口拖拽、点击无卡顿UI 帧率稳定CPU 占用率不异常升高长时间稳定性连续运行 12 小时以上无崩溃、无内存持续增长任务管理器中程序内存占用保持稳定这里的判断标准是“与任务管理器接近”而不是完全一致。不同工具刷新频率、采样区间和统计口径不同数值有 5% 以内的偏差属于正常现象。如果发现数据一直为 0先检查采集代码是否真的被调用。可以在 Worker 的collectAndEmit()里临时加一条qDebug()输出如果日志没有任何打印说明信号槽连接或者 Worker 线程启动有问题。8. 常见问题与排查方法问题现象可能原因排查方式解决方案编译报错找不到 QtChartsQt 安装时未勾选 Charts 模块检查 Qt 安装目录下是否有Qt6Charts用 Qt Maintenance Tool 补充安装 Charts中文显示乱码源文件编码或 UI 编码不一致检查源文件是否为 UTF-8源文件统一使用 UTF-8并在 CMake 中设置编码选项CPU 占用率过高采集频率过高或 UI 刷新过于频繁观察任务管理器 CPU 占用降低采集频率曲线只保留最近 60 个点曲线不更新Worker 的 QTimer 未触发检查线程事件循环是否执行确认moveToThread后线程已exec()某些进程看不到权限不足对比任务管理器进程数使用管理员权限启动程序界面卡顿UI 线程中执行了耗时操作检查槽函数里是否有阻塞操作将采集全部移到 Worker 线程网络速率显示为 0第一次采样被丢弃或表结构读取失败检查返回码确保第一次采样作为基线第二次采样后开始计算释放时崩溃线程未正确退出Worker 被提前销毁查看退出日志在主窗口关闭时先quit()再等待线程结束一个容易忽视的点是在关闭窗口时一定要先停止 Worker 和采集定时器再退出线程事件循环最后销毁对象。如果不按顺序处理可能出现“线程还在执行采集对象已经被销毁”的崩溃问题。9. 最佳实践、优化方向与总结做到这里一个能用的实时系统监控工具已经成型了。接下来如果想进一步提升这里提供几个工程化方向给每个监视器增加开关让用户自行决定要显示哪些指标。把刷新间隔从固定值改成可配置比如提供 0.5 秒、1 秒、2 秒三档。进程列表支持按 CPU、内存排序甚至可以显示单个进程的 CPU 占用。增加记录日志功能把采样数据写入本地文件方便赛后分析。打包发布时使用windeployqt把 Qt 运行库和插件一并复制到 exe 目录。需要注意的是系统监控工具本身涉及系统资源和进程操作开发和使用时应限定在自有设备或明确授权的设备范围内。不要把它改造成监控他人设备、绕过系统权限的工具。涉及进程操作时结束进程前尽量二次确认避免误杀重要系统进程。做这类项目的核心经验是第一次先把“采集 简单显示”跑通再去加图表、加进程管理、加多线程优化。不要在一开始就追求完美的 UI 效果和复杂的动画。项目能跑起来之后你自然知道哪里需要优化哪里需要加什么功能。如果要把这个项目作为简历项目或系统编程练习建议在 README 里写清楚每个模块用到的 Windows API、QThread 的连接方式、以及监控数据的计算公式。别人看到的不只是一张 UI 截图而是一套完整的工程思路。最后给一个很实在的建议先动手把CpuMonitor和MemoryMonitor跑通再把它们挂到界面显示。这两个模块最简单也能最快看到效果。跑通之后再考虑网络和进程列表难度会平滑很多。这个项目做完你对 C Qt 开发、Windows 系统编程的认识会明显上一个台阶。
