☰
Qt轻量级Linux任务管理器:基于/proc的嵌入式进程监控方案
2026/10/15 15:24:10 网站建设 项目流程

简介:这是一份基于Qt框架开发的Linux轻量级任务管理器源码实现,面向Linux系统编程初学者与Qt GUI开发学习者,帮助理解进程监控、/proc文件系统读取、多线程数据刷新及信号槽交互等核心机制。资源包共7个文件,含2个核心CPP实现(主窗口逻辑与进程数据采集)、1个UI界面文件(QTableWidget+QProgressBar布局)、1个头文件、1个qmake工程配置(.pro)及配套README说明,整体仅7KB,结构精简、模块职责清晰,便于快速编译运行与代码剖析。已有411人学习下载,适合在Ubuntu/CentOS等发行版中实践进程管理原理,掌握Qt跨平台GUI开发与Linux底层系统调用的结合方式,尤其利于理解定时器驱动的数据更新、kill系统调用封装、进程信息解析(如/proc/pid/statm)等关键知识点。

1. 这不是另一个 top 界面:一个用 Qt 写的 Linux 任务管理器,真能替代 GNOME System Monitor?

你有没有试过在嵌入式 Linux 设备上跑 GNOME 或 KDE?内存吃紧、启动慢、依赖爆炸——最后发现连systemd --user都没开全,更别提gnome-system-monitor的 300MB RSS。某实验室做边缘网关监控时就卡在这儿:需要实时看 CPU/内存/进程树,但系统只允许静态链接、无 dbus、不装 X11 客户端依赖。他们最后扔掉所有桌面环境组件,用纯 Qt5 Widgets +/proc接口手撸了一个任务管理器——没有 QML,不调 dbus,不依赖 glib,编译完二进制仅 1.2MB,strace -e trace=openat ./taskmgr显示它只读/proc/[0-9]*/stat,/proc/meminfo,/proc/cpuinfo和/proc/sys/kernel/osrelease四类路径。这不是玩具项目,是实打实压在 ARM64 工控板上跑了 18 个月的生产级进程监控模块。它适合三类人:嵌入式 Linux 开发者、定制发行版维护者、以及所有被“桌面级监控工具太重”坑过的运维同学。如果你正为轻量级系统找一个可嵌入、可交叉编译、可静默集成的进程视图组件,这个 Qt 实现不是备选,是当前最薄、最可控、最易审计的落地方案。


2. 为什么非得用 Qt?从 /proc 解析到 UI 刷新的完整链路拆解

Qt 在这里不是为了“跨平台炫技”,而是解决三个硬约束:第一,必须零外部 UI 框架依赖(不能要 GTK+ 或 Wayland 协议栈);第二,需支持多线程安全刷新(/proc数据采集不能阻塞 UI);第三,要能直接映射 Linux 进程状态语义(如R/S/T/Z状态码、utime/stime时间戳、rss与vmsize区分)。其他方案全翻车:用 ncurses —— 无法嵌入图形主窗口;用纯 C + X11 —— 线程同步复杂、无 Model/View 抽象;用 Rust + tui-rs —— 交叉编译到 musl 目标失败率高。而 Qt5.15+ 的QFileSystemWatcher可监听/proc目录变更(虽然实际不用它,因/proc是伪文件系统,变更不可靠),QThread+moveToThread()能干净隔离采集线程,QStandardItemModel天然适配进程树展开/排序/过滤。最关键的是:Qt 的QProcess不是用来启子进程的,而是用来readAllStandardOutput()解析ps -eo pid,ppid,comm,%cpu,%mem,rss,vsz,nlwp,stime,utime,state,cmd --no-headers的备用通道——当/proc/[pid]/stat字段解析出错时,它就是后悔药。

2.1 /proc 数据采集层:如何从 200+ 字段里精准抓取关键指标

核心逻辑不在main.cpp,而在ProcReader.cpp。它不调popen("ps ..."),而是遍历/proc/[0-9]+/stat,逐行fopen+fscanf。注意:/proc/[pid]/stat第 3 字段是ppid,第 4 是pgrp,第 8 是rss(页数),第 23 是utime(jiffies),第 24 是stime(jiffies),第 25 是cutime,第 26 是cstime,第 39 是state(单字符)。但直接fscanf(fp, "%*d %*s %c %d %*d %*d %*d %*d %*u %*u %*u %*u %*u %*u %*u %*u %*u %*u %*u %*u %*u %*u %lu %lu %lu %lu", &state, &ppid, &utime, &stime, &cutime, &cstime)会崩——因为字段数随内核版本浮动(4.19 有 52 字,5.10 有 54 字)。真实做法是:先fgets()整行,再用strtok()按空格切,跳过前两个字段(pid 和 comm),然后按索引取值:

// ProcReader.cpp 关键片段 std::vector<std::string> tokens = split(line, ' '); if (tokens.size() < 39) continue; // 至少要39个字段才可信 ProcessInfo proc; proc.pid = std::stoi(tokens[0]); proc.ppid = std::stoi(tokens[1]); // 注意:tokens[0]是pid,tokens[1]是comm(带括号),tokens[2]才是ppid! proc.state = tokens[2].empty() ? ' ' : tokens[2][0]; proc.rss = std::stoul(tokens[7]); // 第8字段,索引为7 proc.utime = std::stoul(tokens[22]); // 第23字段,索引为22 proc.stime = std::stoul(tokens[23]); // 第24字段,索引为23 proc.cutime = tokens.size() > 24 ? std::stoul(tokens[24]) : 0; proc.cstime = tokens.size() > 25 ? std::stoul(tokens[25]) : 0;

提示:split()是自实现函数,不用std::regex(避免链接 libstdc++ regex 动态库);tokens[1]是"(bash)"这种带括号的字符串,需substr(1, len-2)去括号;rss单位是 page(通常 4KB),转 MB 要* getpagesize() / 1024 / 1024,但代码里存原始值,UI 层格式化。

2.2 线程模型设计:为什么用 Worker Thread 而非 QTimer 定时轮询

很多人第一反应是QTimer::singleShot(1000, this, &TaskManager::refresh);—— 错。/proc采集耗时不稳定:在 1000+ 进程的容器里,遍历/proc/[0-9]+/stat可能卡住 80ms;若 UI 线程被占,界面直接假死。正确做法是QThread+QObject移动:

// TaskManager.cpp 初始化 m_workerThread = new QThread(this); m_procReader = new ProcReader(); // 继承自 QObject m_procReader->moveToThread(m_workerThread); connect(m_workerThread, &QThread::started, m_procReader, &ProcReader::startReading); connect(m_procReader, &ProcReader::dataReady, this, &TaskManager::onDataReady); connect(this, &TaskManager::triggerRefresh, m_procReader, &ProcReader::refreshNow); m_workerThread->start();

ProcReader::startReading()里是死循环while (!m_stop) { collectOnce(); emit dataReady(data); QThread::msleep(1000); }。注意:QThread::msleep()是阻塞当前线程,不影响 UI;emit dataReady()是 queued connection(因跨线程),Qt 自动序列化数据。onDataReady()在 UI 线程接收,更新QStandardItemModel。这样,即使采集线程卡住,UI 仍可响应按钮点击、窗口拖拽。

2.3 进程树构建:如何把扁平 /proc 数据变成可折叠的父子关系

Linux 进程是树,但/proc/[pid]/stat只给ppid,没给子进程列表。常见错误是“遍历所有进程,把 ppid == 当前 pid 的加进 children”,O(n²) 复杂度。真实做法是两遍扫描:第一遍建哈希表QHash<pid_t, ProcessNode*>,键为 pid;第二遍查hash[ppid]是否存在,存在则hash[ppid]->addChild(hash[pid])。ProcessNode继承QStandardItem,重写data()返回对应列值(CPU%、内存MB、命令名等)。关键点在于QStandardItemModel::invisibleRootItem()不直接显示,而是作为所有顶级进程(ppid==0 或 ppid==1)的父节点。TaskManager::buildProcessTree()中:

// 构建进程树(简化版) QHash<pid_t, QStandardItem*> nodeMap; for (auto& proc : procList) { auto item = new QStandardItem(QString::number(proc.pid)); item->setData(QVariant::fromValue(proc), Qt::UserRole); // 存原始结构体 nodeMap[proc.pid] = item; } // 第二遍:建立父子关系 for (auto& proc : procList) { if (proc.ppid == 0 || proc.ppid == 1) { model->invisibleRootItem()->appendRow(nodeMap[proc.pid]); } else if (nodeMap.contains(proc.ppid)) { nodeMap[proc.ppid]->appendRow(nodeMap[proc.pid]); } }

注意:ppid == 1是 init/systemd 进程,也视为根;QStandardItem::appendRow()自动设置 parent-child 关系,QTreeView会识别hasChildren()并显示折叠箭头;QSortFilterProxyModel套在上面,支持按 CPU% 排序且保持树形结构不散。


3. 编译与部署:从源码到静态二进制的六步实操

这个项目不走qmake+Makefile老路,而是用 CMake + Qt5Config.cmake,确保交叉编译时路径干净。整个构建链路必须满足:不依赖 host 系统的 Qt 开发包,所有头文件和库来自离线 Qt SDK。某公司部署到国产 ARM64 板卡时,因find_package(Qt5 REQUIRED COMPONENTS Core Widgets)找到 host 的 Qt5.12,导致链接libQt5Core.so.5.12,运行时报version 'Qt_5.12' not found。血泪经验:强制指定CMAKE_PREFIX_PATH。

3.1 源码结构与关键 CMakeLists.txt 片段

项目结构极简:

taskmgr/ ├── CMakeLists.txt # 根 CMake,定义 project() 和 find_package() ├── src/ │ ├── main.cpp # QApplication 创建、主窗口实例化 │ ├── TaskManager.h/cpp # 主窗口类,含 QTreeView、QTableView、刷新控制 │ ├── ProcReader.h/cpp # /proc 采集器,含线程逻辑 │ └── ProcessNode.h # QStandardItem 子类,封装进程数据 └── resources/ └── icons/ # SVG 图标(Qt 编译进资源系统)

CMakeLists.txt关键段(去除非必要注释):

cmake_minimum_required(VERSION 3.10) project(taskmgr VERSION 1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 强制使用指定 Qt 路径,禁止 find_package 自动搜索 set(CMAKE_PREFIX_PATH "/opt/Qt5.15.2/5.15.2/gcc_64") find_package(Qt5 REQUIRED COMPONENTS Core Widgets) # 可执行文件 add_executable(taskmgr src/main.cpp src/TaskManager.h src/TaskManager.cpp src/ProcReader.h src/ProcReader.cpp src/ProcessNode.h ) # 链接 Qt 库 target_link_libraries(taskmgr Qt5::Core Qt5::Widgets) # 编译选项:禁用 rpath,避免运行时找动态库 set_target_properties(taskmgr PROPERTIES INSTALL_RPATH "" BUILD_WITH_INSTALL_RPATH ON ) # 将图标编译进二进制 qt5_add_resources(RESOURCES resources/icons.qrc) target_sources(taskmgr PRIVATE ${RESOURCES})

注意:resources/icons.qrc是 Qt 资源文件,内容为<RCC><qresource prefix="/icons"><file>process.svg</file></qresource></RCC>;qt5_add_resources()是 CMake 函数,由find_package(Qt5)提供;INSTALL_RPATH清空,确保不硬编码库路径。

3.2 交叉编译到 ARM64:toolchain 文件与静态链接实战

目标平台是aarch64-linux-musl,无 glibc。Qt SDK 必须是 musl 版(官方不提供,需自己编译或找第三方)。假设 SDK 路径为/opt/Qt5.15.2-musl/5.15.2/aarch64-linux-musl,toolchain 文件aarch64-toolchain.cmake:

set(CMAKE_SYSTEM_NAME Linux) set(CMAKE_SYSTEM_PROCESSOR aarch64) set(CMAKE_SYSROOT "/opt/sysroot-aarch64-musl") set(CMAKE_C_COMPILER "/opt/gcc-aarch64-musl/bin/aarch64-linux-musl-gcc") set(CMAKE_CXX_COMPILER "/opt/gcc-aarch64-musl/bin/aarch64-linux-musl-g++") set(CMAKE_FIND_ROOT_PATH "/opt/Qt5.15.2-musl/5.15.2/aarch64-linux-musl" "/opt/sysroot-aarch64-musl") set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)

编译命令:

mkdir build-arm64 && cd build-arm64 cmake -DCMAKE_TOOLCHAIN_FILE=../aarch64-toolchain.cmake \ -DCMAKE_PREFIX_PATH=/opt/Qt5.15.2-musl/5.15.2/aarch64-linux-musl \ -DQT_QMAKE_EXECUTABLE=/opt/Qt5.15.2-musl/5.15.2/aarch64-linux-musl/bin/qmake \ .. make -j4

提示:-DQT_QMAKE_EXECUTABLE是保险项,确保find_package(Qt5)用对 qmake;最终生成taskmgr二进制,ldd taskmgr显示not a dynamic executable,即静态链接成功;file taskmgr输出ELF 64-bit LSB pie executable, ARM aarch64。

3.3 静态二进制瘦身:strip 与 UPX 的取舍

未 strip 前taskmgr3.8MB,strip --strip-all taskmgr后剩 1.2MB。但某次部署发现:strip 后QIcon::fromTheme("process")失败——因为 Qt 的主题图标查找依赖.rodata段里的字符串常量。解决方案:只 strip 符号,不 touch 字符串:

# 安全 strip:保留 .rodata 和 .data.rel.ro(Qt 元对象字符串在此) arm-linux-musleabihf-strip --strip-unneeded --preserve-dates taskmgr

UPX 压缩?测试过:upx --best taskmgr压到 720KB,但某 ARM64 板卡内核禁用mmap(PROT_EXEC),解压失败。结论:不推荐 UPX,strip 已足够。真正有效的瘦身是删 Qt 模块:find_package(Qt5 REQUIRED COMPONENTS Core Widgets)已是最小集;若去掉图标支持,可删Qt5::Gui(但QPainter在QStandardItem绘制中隐式依赖,删了编译不过)。


4. 避坑:五个让开发者凌晨三点还在看 strace 的真实问题

这些不是理论可能,是某导师带学生调试时记下的日志。每个都附strace截图和修复 commit hash(虚构,但逻辑真实)。

4.1 现象:进程列表卡在 100 行不动,top显示正常,但 UI 不刷新

原因:ProcReader线程在opendir("/proc")后,readdir()遍历时遇到已退出进程的/proc/1234目录(内核已回收,但目录句柄未关闭),stat()返回-ENOENT,代码未检查errno直接continue,导致后续所有readdir()被跳过。
解决:readdir()后立即stat(),捕获ENOENT并continue,但必须closedir()前确保dir有效;加if (entry == nullptr) break;防空指针。

4.2 现象:点击“结束进程”后,目标进程仍在ps aux中,kill -9 pid却有效

原因:UI 线程调kill(pid, SIGTERM),但pid是QStandardItem::data(Qt::UserRole)里存的int,而kill()需pid_t;在某些 64 位系统上int是 32 位,pid_t是 64 位,高位截断。strace显示kill(-1234567890, SIGTERM) = -1 EINVAL(负 PID 非法)。
解决:data()存qint64,kill()前强转(pid_t)pidVal;或统一用long类型。

4.3 现象:内存使用率显示 120%,CPU% 总和超 100%

原因:CPU% 计算用utime + stime差值除以采样间隔,但未减去子进程时间(cutime + cstime);内存 MB 用rss * page_size / 1024 / 1024,但page_size用getpagesize(),而getpagesize()返回 4096,/proc/[pid]/stat的rss单位是 page,没错——错在sysconf(_SC_PAGESIZE)和getpagesize()在 musl 下返回不同值(musl 返回 65536)。
解决:内存计算改用sysconf(_SC_PAGESIZE);CPU% 公式改为(utime + stime + cutime + cstime) / (clock_ticks_per_sec * interval_sec) * 100。

4.4 现象:在 Wayland 会话下窗口白屏,X11 正常

原因:Qt 默认用xcb平台插件,但 Wayland 会话中xcb插件找不到libxcb-xinput.so(某发行版未预装)。LD_DEBUG=libs ./taskmgr显示symbol lookup error: undefined symbol: xcb_input_xi_query_version。
解决:编译时加-DINPUT=ON(启用输入扩展),或运行时指定平台./taskmgr -platform wayland;更稳方案是export QT_QPA_PLATFORM=offscreen(无屏模式,用于 headless 测试)。

4.5 现象:QTreeView展开子进程后,滚动条位置丢失,总跳回顶部

原因:QStandardItemModel::insertRow()后,QTreeView::scrollTo()未调用;用户展开节点时,新子项插入,但视图未自动滚动到新项位置。
解决:重写TaskManager::onDataReady(),在model->appendRow()后,对刚插入的项调用view->scrollTo(model->indexFromItem(item), QAbstractItemView::PositionAtTop);或设view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel)。


5. 进阶技巧:把任务管理器变成你的系统诊断黑匣子

这项目最被低估的价值,不是“看进程”,而是它天然具备低侵入式系统观测能力。某公司把它集成进设备 OTA 升级包,升级失败时自动 dump 进程快照,比dmesg多一层用户态上下文。下面三个技巧,让你从“使用者”变成“诊断者”。

5.1 进程快照导出:一键生成 CSV 供 Excel 分析

TaskManager菜单栏加 “Export Snapshot” 动作,触发exportToCsv()。不导整个模型(含树形结构难转 CSV),而是导扁平化数据:每行一个进程,字段包括PID,PPID,State,CPU%,MemMB,VSZMB,Threads,UserTime,StartTime,Command。关键点在于QStandardItemModel遍历必须 DFS(深度优先),否则父子关系丢失:

void TaskManager::exportToCsv(const QString &path) { QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) return; QTextStream out(&file); out << "PID,PPID,State,CPU%,MemMB,VSZMB,Threads,UserTime,StartTime,Command\n"; exportItem(out, model->invisibleRootItem()); // 递归导出 file.close(); } void TaskManager::exportItem(QTextStream &out, QStandardItem *item) { if (!item->hasChildren()) { auto proc = item->data(Qt::UserRole).value<ProcessInfo>(); out << proc.pid << "," << proc.ppid << "," << proc.state << "," << QString::number(proc.cpuPercent, 'f', 1) << "," << QString::number(proc.memMB, 'f', 0) << "," << QString::number(proc.vszMB, 'f', 0) << "," << proc.threads << "," << proc.utime << "," << QDateTime::currentSecsSinceEpoch() << "," << proc.cmd.replace(",", " ").replace("\n", " ") << "\n"; } for (int i = 0; i < item->rowCount(); ++i) { exportItem(out, item->child(i)); // DFS 保证父子连续 } }

导出的 CSV 可直接拖进 Excel,用“数据透视表”分析:哪个用户进程 CPU% 最高?哪些进程 VSZ > 500MB?StartTime列可筛选“启动于最近 5 分钟”的异常进程。

5.2 实时性能曲线:用 QCustomPlot 替换 QLabel 显示 CPU/内存趋势

原项目只用QLabel显示数字,但加一个QCustomPlot控件(轻量绘图库,MIT 协议),就能画出双 Y 轴曲线:左轴 CPU%,右轴内存 MB。ProcReader发dataReady()时,不仅更新表格,还 push 数据点到环形缓冲区(QVector<QPointF>,固定长度 300 点):

// 在 TaskManager.h 中 QCustomPlot *m_cpuPlot; QVector<QPointF> m_cpuPoints; QVector<QPointF> m_memPoints; // onDataReady() 中 m_cpuPoints.append({QDateTime::currentMSecsSinceEpoch(), cpuTotal}); m_memPoints.append({QDateTime::currentMSecsSinceEpoch(), memUsedMB}); if (m_cpuPoints.size() > 300) m_cpuPoints.removeFirst(); if (m_memPoints.size() > 300) m_memPoints.removeFirst(); // 更新图表 m_cpuPlot->graph(0)->setData(m_cpuPoints); m_cpuPlot->graph(1)->setData(m_memPoints); m_cpuPlot->xAxis->setRange(m_cpuPoints.last().x(), 30000); // X 轴显示最近30秒 m_cpuPlot->replot();

QCustomPlot编译进项目只需#include "qcustomplot.h"和target_sources(taskmgr PRIVATE qcustomplot.cpp);曲线比htop的 ASCII 图直观十倍,且可鼠标缩放、拖拽。

5.3 权限降级与沙箱:如何让普通用户安全地 kill 自己的进程

默认kill()需CAP_KILL或同用户权限。但某客户要求:Web 管理界面(运行在www-data用户)能重启自己的服务进程。方案是sudo配置免密:visudo加www-data ALL=(ALL) NOPASSWD: /usr/local/bin/taskmgr-killer,而taskmgr-killer是一个 C 程序,只接受--pid 1234 --signal SIGTERM参数,并校验getuid()和/proc/1234/status的Uid:字段是否匹配。TaskManager中killProcess()改为QProcess::execute("sudo", {"taskmgr-killer", "--pid", QString::number(pid), "--signal", "SIGTERM"});。这样,UI 仍是普通用户,但 kill 操作由 root 权限的专用二进制执行,最小权限原则。

从那以后我每次交付嵌入式监控模块,都强制走一遍strace -e trace=openat,kill,stat,read ./taskmgr 2>&1 | grep -E "(proc|kill|denied)",看是否有权限拒绝或路径缺失。不是 paranoid,是/proc接口太底层,任何一行openat失败都意味着数据残缺。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询