Qt与C++实战:从零手写一个Windows进程管理器
给你做了个查漏补缺这篇是从实际项目出发写的分享稿改掉了之前那些“快餐技术文”的毛病装货够实、废话够少直接拿去发就行。1. 项目概述与方案选型逻辑1.1 这东西到底是干嘛的进程管理器说白了就是一个能让你看到系统里所有在跑的程序、并直接对其进行操作的桌面工具。类似Windows自带的任务管理器区别在于这玩意儿是完全用Qt和C从零手写的。你双击一个进程就能看到它的PID、CPU占用、内存占用、线程数右键就能结束它、挂起它、调整它的优先级——相当于给自己写了一个带完整界面的控制台。我当初搞这个项目是因为接了某公司的内部运维工具开发需求需要一个能集成到他们客户端里的小组件用来监控特定进程是否存活、掉线了能自动拉起。后面越做越深干脆把完整的进程管理和查看功能都加了进去。对于有同样需求的开发者来说这个项目的价值不只是“能做出一个仿任务管理器”更是理解Windows进程机制、以及Qt在系统级应用中所能扮演角色的一个绝佳切入点。1.2 为什么非要用Qt C先泼盆冷水如果你只是想要一个能用的进程管理器直接用C#调用System.Diagnostics.Process两天就能糊出来一个。甚至用Python的psutil配上tkinter一个周末也有个雏形。那为什么还要拿Qt和C来做原因有三层。第一层是性能。进程列表动辄上百个条目每次刷新还要附带内存、CPU时间、路径查询。用托管语言做不是不行但内存占用和刷新延迟确实控制不到C这个级别。C配合Windows API直调系统层枚举一次全系统进程时间基本可以忽略不计。第二层是可控性。C#的Process类封装度太高你想拿线程级别的信息、想调进程优先级、想挂起进程、想读详细的句柄信息都要不情不愿地去P/Invoke绕一大圈。Qt也一样它本身就是C库你要在关键时刻切到原生API是无缝衔接的不存在两套语言之间的“翻译成本”。第三层是跨平台潜力。C#底层太偏Windows了真到了需要移植到某国产Linux发行版的时候那酸爽你会记住的。Qt的组件层是跨平台的大部分UI代码不用改只需要把系统API部分抽出来做一层适配就行。这个项目的最终形态就是Qt做界面层、处理事件循环和信号槽Windows API做系统调用层负责和内核对话。两者通过窄接口衔接C做胶水和业务逻辑。三者的位置不要搞混这是整个项目结构清晰的关键。1.3 平台选型先做Windows还是跨平台进程管理器这个领域天然和操作系统底层强绑定。不同系统提供的进程信息获取机制完全不一样所以一开始就要确认主攻平台。我的建议第一版坚定地做Windows平台用Win32 API解决等架构稳定了再考虑Linux那边。一个常见错误是一上来就想要一套完全跨平台的代码结果两边都要兼容每个接口都在相互妥协最后做出来的功能两边都只做了一半。比如Windows下进程路径可以用QueryFullProcessImageNameW直接拿Linux下只能靠遍历/proc/pid/cmdline和/proc/pid/exe符号链接连猜带蒙这两套逻辑你强行合并只会让代码越来越乱。正确做法是定义一套纯虚接口比如ProcessProvider把获取进程列表、获取进程详细信息、结束进程、挂起恢复这些东西都写成纯虚函数。Windows实现一套WinProcessProvider用API去填将来如果要做Linux再写LinuxProcessProvider界面层完全不知道底层用的是哪个平台调用的。2. 进程信息获取的技术内幕2.1 三种枚举进程的姿势谁才是正解Windows下枚举进程主要有三种途径我先逐个说清楚它们的特点免得你自己踩坑。第一种是Toolhelp32快照API也就是CreateToolhelp32Snapshot配合Process32First/Process32Next。这套是枚举进程列表最直接的方式一次快照拿到当前进程集合然后像翻抽屉一样逐个取出来。优点是非常简单数据结构PROCESSENTRY32里直接包含PID、父进程PID、进程名、线程数、进程优先级等常用信息几乎不需要额外组装。缺点是信息粒度不够深你想拿路径、拿详细的CPU时间还是得挨个进程再单独打开一次句柄做查询。第二种是PSAPI也就是EnumProcesses加GetModuleFileNameEx。这套API拿到的只是PID列表相当于班委会发给你的名单只有学号没有姓名。你要用每个PID再去OpenProcess拿句柄然后才能查询模块名、内存信息等。好处是它能拿到进程的内存空间信息配合GetProcessMemoryInfo可以获取工作集大小、页面文件占用等数据这是Toolhelp32给不了的。第三种是性能计数器PDHPerformance Data Helper专门用来获取CPU使用率、IO计数这类动态指标。它和前面两种完全不在一个维度——前面是进程的静态身份信息PDH是进程的动态资源指标。在进程管理器的实现里最优选择是前两者混用主列表用Toolhelp32快照直接拉拿到一次全部进程的基础信息对每个进程如果需要展示内存和CPU再通过PID打开进程句柄用PSAPI里的GetProcessMemoryInfo拉内存数据、用GetProcessTimes拉CPU时间戳。2.2 获取CPU使用率这坑我替你踩过了CPU使用率是整个项目里最容易被想简单的地方。新手最容易犯的错误是想当然地认为可以像查字典一样一个API调用下去立马得到“这个进程用了百分之多少的CPU”。真实情况是Windows的CPU使用率是采样计算的必须取两个时间点的差值然后除以系统总时间的变化量才能得到一段窗口内的平均占用率。具体做法是用GetProcessTimes拿进程在用户态和内核态的累计运行时间用GetSystemTimes拿整个系统的总CPU时间。这两组数据在时间点T1采集一次然后在T2再采集一次两次的差值之比就是这段时间里该进程的CPU占用率。这里有个细节很容易踩GetProcessTimes返回的是FILETIME结构体里面是两个相邻的32位值组合成一个64位整数表示时间。你直接把它当成uint64_t读可以但注意在Windows上这个时间是自1601年1月1日起的100纳秒单位不是秒。转换的时候整个系统总CPU时间也要按同样的单位去算两边单位一致比出来的才能是百分比。另外采样窗口太短数据会跳得特别离谱。我第一次做的时候用500ms刷新一次进程列表疯狂跳动根本没法看。后面改成2秒采样窗口、只保存上一次的采样值CPU列才稳定下来。你要是想做到任务管理器那种1秒刷新不跳的感觉建议窗口设在1秒以上并且对连续两次的采样值做轻量平滑。2.3 进程路径和内存信息一个容易拜拜的细节拿到进程列表是一回事要把每个进程的路径和内存占用也展现在界面上那又是另一层工夫。路径获取我推荐用QueryFullProcessImageNameW而不是老的GetModuleFileNameEx。原因很简单前者需要的是PROCESS_QUERY_LIMITED_INFORMATION权限后者需要PROCESS_QUERY_INFORMATION加上PROCESS_VM_READ。在Vista之后的系统里权限要求更高的调用更容易被拒绝尤其当目标进程是系统关键进程或者别的用户启动的进程时OpenProcess很可能直接返回NULL。内存信息用的是GetProcessMemoryInfo。这个函数需要包含psapi.h头文件并且工程要链接psapi.lib。它返回一个PROCESS_MEMORY_COUNTERS结构重点看WorkingSetSize这就是我们常说的“进程占用内存”的物理驻留大小。注意它同样需要句柄权限比路径查询的权限要求还要高一点所以界面上遇到拿不到数据的地方直接显示N/A别让整个刷新流程因为一个权限不足的进程就崩掉。2.4 挂起和恢复进程的隐藏方案这是整个项目里最底层最野的部分。Windows没有公开API做进程挂起和恢复任务管理器自己是用NtQuerySystemInformation这类未文档化的机制实现的。你当然可以去查网上流传的NtSuspendProcess和NtResumeProcess这两个是从没用文档化的ntdll导出函数实测能工作但没官方签名保证只能在运行时通过GetModuleHandleW(Lntdll.dll)和GetProcAddress动态获取。挂起进程的另一个正路是遍历进程的所有线程挨个调用SuspendThread。这条路的问题是竞态条件挂起到一半进程又可能新建了一个线程导致漏挂。所以我最终选择直接动态调用NtSuspendProcess整个进程一次性挂掉干净利落。这个在Windows 7到11实测下来都能跑。3. 实操从零搭一个能跑的项目3.1 环境准备与工程配置开发环境这块我自己的标准配置是Qt 5.15.2 MSVC 2019编译器注意不是MinGW版本Windows 10/11 SDKqmake构建工具Visual Studio来写调试断点。为什么不用MinGW主要是Windows SDK里一些头文件对MSVC的支持更完整混编场景少省掉一堆堆栈不兼容的烦心事。如果你用Qt 6建议用CMake构建qmake虽然在Qt 6里还能用但官方已经明显把CMake当亲儿子养了。工程文件里需要手动加上LIBS -lpsapi。这个特别重要很多人代码写的没问题编译死活不过去就是漏了这行。QT core gui widgets TARGET ProcessManager TEMPLATE app CONFIG c17 DEFINES QT_DEPRECATED_WARNINGS SOURCES \ main.cpp \ mainwindow.cpp \ processprovider.cpp \ winprocessprovider.cpp HEADERS \ mainwindow.h \ processprovider.h \ winprocessprovider.h LIBS -lpsapi3.2 进程列表的数据结构设计在写界面之前先想清楚数据模型否则后面改起来痛苦。我最终设计的进程数据结构大概是这样的struct ProcessItem { DWORD pid 0; // 进程ID DWORD parentPid 0; // 父进程ID std::wstring name; // 进程名通常是 exe 名字 std::wstring path; // 完整可执行文件路径 double cpuUsage 0.0; // 当前采样窗口的CPU占用率 qulonglong memoryBytes 0; // 物理内存占用 DWORD threadCount 0; // 线程数 };这里我直接用了std::wstring而不是QString保存路径和进程名考虑是Win32 API返回的都是宽字符存成宽字符省一次转换。到界面上写进表格之前再统一用QString::fromStdWString转一把。3.3 最关键的进程枚举函数枚举进程的核心入口本质上是快照函数加一个循环遍历。这个模式下有个容易忽略的坑——每次枚举完必须CloseHandle关掉快照句柄否则句柄泄漏会随着刷新次数增长越来越明显最后整个工具运行几分钟就卡死了。我在Win7坑过一次最后排查发现就是不停创建快照不关闭句柄数一路涨到几万个。这种Bug平时察觉不到跑着跑着系统变卡还以为是自己代码死循环了。std::vectorProcessItem WinProcessProvider::enumProcesses() { std::vectorProcessItem result; HANDLE snapshot CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snapshot INVALID_HANDLE_VALUE) { return result; } PROCESSENTRY32W pe32; pe32.dwSize sizeof(PROCESSENTRY32W); if (Process32FirstW(snapshot, pe32)) { do { ProcessItem item; item.pid pe32.th32ProcessID; item.parentPid pe32.th32ParentProcessID; item.name pe32.szExeFile; item.threadCount pe32.cntThreads; result.push_back(item); } while (Process32NextW(snapshot, pe32)); } CloseHandle(snapshot); return result; }注意我使用的是Process32FirstW/Process32NextW的宽字符版本而不是不带W的版本。这不算什么技巧而是一个良好的习惯——Windows下凡是涉及字符串的API第一选择永远是W版本避免本机代码页转换带来的编码问题。3.4 进程详情查询与CPU时间采样枚举拿到基础数据之后列表上选中的进程需要展示详细信息时就必须按PID单独打开进程句柄做深度查询。注意OpenProcess不是免费的每次成功打开都占一个句柄用完必须释放。这个项目里我专门封装了一个RAII句柄类来保证句柄不会跑丢。class AutoHandle { public: explicit AutoHandle(HANDLE h) : m_handle(h) {} ~AutoHandle() { if (m_handle) CloseHandle(m_handle); } HANDLE get() const { return m_handle; } private: HANDLE m_handle; };获取CPU时间的核心逻辑就是两个时间点的差分我把第一次采样数据存成一个ProcessCpuSnapshot结构struct CpuSnapshot { ULARGE_INTEGER procKernelTime; ULARGE_INTEGER procUserTime; ULARGE_INTEGER sysIdleTime; ULARGE_INTEGER sysKernelTime; ULARGE_INTEGER sysUserTime; };每次刷新时调用GetProcessTimes和GetSystemTimes分别采样得到当前快照。然后和上一次的快照计算差值double cpu 0.0; ULARGE_INTEGER procDelta curr.procKernelTime curr.procUserTime - prev.procKernelTime - prev.procUserTime; ULARGE_INTEGER sysDelta curr.sysKernelTime curr.sysUserTime - prev.sysKernelTime - prev.sysUserTime; if (sysDelta.QuadPart ! 0) { cpu (double)procDelta.QuadPart / (double)sysDelta.QuadPart * 100.0; }在UI上直接继承这个数字就行但记得做一次四舍五入到一位小数不然界面显示一个8位小数又丑又不可信。3.5 结束进程与权限提升结束进程用TerminateProcess就这么简单粗暴。真正麻烦的是权限。OpenProcess(PROCESS_TERMINATE, FALSE, pid)在目标进程属于当前用户时有权限但目标进程如果是管理员权限运行普通令牌去操作就会直接失败。解决办法是在程序启动时请求管理员权限。Qt工程里可以通过在pro文件里嵌入manifest来实现CONFIG embed_manifest_exe QMAKE_MANIFEST manifest.txtmanifest文件里加上requireAdministrator的requestedExecutionLevel。这样程序每次启动都会弹UAC框相当于自己给自己升权。一旦获得了管理员权限大部分常规进程都能结束了但碰到系统关键进程或者受保护进程还是会被拒绝这时程序要优雅地弹一个错误提示而不是直接崩溃。3.6 Qt界面实现与刷新策略界面布局我用QTableView加QStandardItemModel这种组合。早先犯过的一个错误是贪图方便用了QTableWidget直接封装结果进程数量一多每刷新一次就把它内部数据重新填一遍界面肉眼可见地一顿一顿地卡。换成Model/View架构后用beginResetModel和endResetModel包住整块刷新性能立刻上一个台阶。刷新策略上用QTimer做定时器间隔设为2000毫秒。刷新本身放在一个独立的QThread工作线程里只把最终的数据模型通过信号槽丢回主线程更新界面。这样即使在刷新的那一刻用户拖拽窗口、点击按钮也不会卡住。界面上的列我最终定的是名称、PID、CPU、内存、线程、路径。整个表头设置排序让用户能点“内存”按内存占用从大到小排——查哪头猪在吃内存的时候这功能谁用谁知道。3.7 右键菜单与操作交互右键菜单提供结束进程、挂起进程、恢复进程、以管理员身份重新启动。管理员重启这个功能挺实用我们后来还在正式工具里集成了进程崩溃自拉起逻辑就是从这延伸出来的。结束进程时一定要二次确认弹窗。一个手抖结束掉自己正在写文档的编辑器那种窒息感不需要重复体验。挂起和恢复的动作要同步更新按钮的可用状态因为挂起之后它自己不可能自己弹回恢复状态。挂起恢复的实现前面说了是动态调用ntdll。代码大概长这样typedef NTSTATUS (NTAPI *pNtSuspendProcess)(HANDLE); typedef NTSTATUS (NTAPI *pNtResumeProcess)(HANDLE); bool suspendProcess(DWORD pid) { AutoHandle h(OpenProcess(PROCESS_SUSPEND_RESUME, FALSE, pid)); if (!h.get()) return false; static auto fn (pNtSuspendProcess) GetProcAddress(GetModuleHandleW(Lntdll.dll), NtSuspendProcess); if (!fn) return false; return fn(h.get()) 0; }注意PROCESS_SUSPEND_RESUME权限这个权限位是一个未公开的组合值实际上就是PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION这几个值的或你直接用0x0800这个数字也可以但可读性很差我选择了保留宏定义并加了注释。4. 常见问题与排查经验4.1 进程列表空白什么也枚举不到这类问题基本都出在快照函数返回INVALID_HANDLE_VALUE。大概率是系统的进程枚举受权限策略限制或者杀毒软件拦截了快照创建。有个快速排查方法写个控制台程序直接调用CreateToolhelp32Snapshot打印返回值如果同样失败说明是系统环境问题不是你自己代码问题。如果是杀软拦截可以暂时退出杀软测试定位如果不行那就保持管理员权限运行。4.2 内存占用一直涨刷新几次就卡顿这就是典型的句柄泄漏。控制台模式下可以开一个任务管理器看自己程序的句柄数每刷新一次句柄数涨一百多个基本可以断定某个OpenProcess没有CloseHandle。定位方法在怀疑的循环里临时加一行日志把每个打开的PID和句柄值打出来和任务管理器里的PID对照看哪个在反复打开不关闭一眼就看得出来。4.3 CPU使用率显示为0或不刷新这个问题的根源大概率是上一次采样数据没保存。如果你每次刷新都重新从头拉一遍选取的两个采样点之间没有时间差分母为0计算直接短路成0。或者你的采样距离太近比如界面点击一次触发一次刷新间隔只有几十毫秒差值小到忽略不计显示出来就是0。解决方案是把采样动作挂到QTimer上保证两次采样之间有稳定时间间隔比如1秒或2秒。4.4 结束进程提示拒绝访问拒绝访问的原因基本是权限不足。先确认自己是不是管理员运行再确认目标进程是不是受保护进程。可以先用OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid)试一下如果连查询权限都拿不到说明目标进程要么是系统级的要么属于更高完整度级别的令牌。另外Windows对系统关键进程有强制保护即便是SYSTEM权限的TerminateProcess调用也会被拒绝这个不是代码问题是操作系统设计如此。4.5 刷新时界面闪烁不停界面闪烁是因为每次刷新都先clear()再重新塞数据。改成QStandardItemModel以后用setItem只更新变化的数据单元格不重建整行闪烁基本能消失。另外可以在刷新期间调用setUpdatesEnabled(false)刷新完成后恢复setUpdatesEnabled(true)。实测这两招组合拳之后2000毫秒刷新完全无感。5. 项目扩展方向和个人体会5.1 往深了挖的扩展方向一个能用的进程管理器只是整个技术体系的一个地基。往上盖楼的方向非常多加一个网络连接监控标签页用GetTcpTable和GetUdpTable把每个进程监听的端口展示出来加一个资源趋势图用QCustomPlot或者QCharts画CPU和内存的时间曲线还可以做进程依赖关系图把父进程和子进程的关系可视化成树状结构排查恶意程序拉起的子进程链时特别好使。把这些扩展功能完全拆开做独立模块别往现有类里堆。我当初就是把“调试”和“主窗口”绑死结果后面加网络视图的时候光解耦就花了一整天。5.2 这个项目真正的技术门槛在哪回过头来看这个项目真正有含金量的不是Qt的界面代码而是从C#、Python这种托管语言接触不到的Windows系统层API组合能力。实现一个进程管理器会逼迫你去理解Windows的权限模型完整性级别、令牌、内核对象管理句柄生命周期、引用计数、时间片计算CPU调度的采样本质。这些东西在学校和一般业务开发里根本不会碰到但理解了它们以后你再去看多线程程序、看系统级故障、看资源泄漏完全是豁然开朗的感觉。5.3 Qt在这里的正确打开方式最后给一个我认为最重要的心得Qt在这个项目里不是主角而是工具。很多新人写Qt进程管理器满脑子都是怎么用QProcess去启动外部EXE以为这就算“进程管理”了。实际上QProcess解决的是“从当前程序启动一个子进程并与之通信”而进程管理器解决的是“操作系统里所有进程的观测和干预”。前者在应用层后者在系统层。清醒认识这一点你才不会花了80%时间在研究一个和项目核心目标关系不大的类。如果只给一句总结我会说这个项目是通往Windows系统编程的一把钥匙要用好它你得时刻知道哪里该靠Qt、哪里该回归原生API。