ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt6 Linux任务管理器开发:从/proc解析到cgroups资源控制

Qt6 Linux任务管理器开发:从/proc解析到cgroups资源控制

简介:这是一份基于Qt框架开发的Linux轻量级任务管理器源码包,面向Linux系统编程初学者与Qt GUI开发学习者,解决在Linux环境下自主实现进程监控与管理工具的实践需求。资源共7个文件,包含2个核心CPP实现文件(负责主窗口逻辑与进程数据采集)、1个UI界面文件(定义表格、进度条等控件布局)、1个头文件(声明类接口)、1个PRO工程配置文件、1个USER项目配置及1份README说明文档,整体仅7KB,结构精简、开箱即学。已有411人学习下载,适合快速理解/proc文件系统读取、QTableWidget动态刷新、kill信号发送、定时器驱动更新等关键机制。读者可直接编译运行,观察CPU/内存使用率实时图表、按PID或进程名筛选列表,并安全终止指定进程,是掌握Linux进程管理+Qt跨平台GUI集成的典型小而全实战范例。

1. 为什么在 Linux 桌面环境里,还要自己写一个 Qt 任务管理器?

不是 KDE 的 System Monitor 不够用,也不是 GNOME 的gnome-system-monitor缺功能——它们确实稳定、美观、集成度高。但当你在做嵌入式工控界面、定制化车载中控、或某高校实验室的轻量级教学桌面系统时,你会发现:预装监控工具要么体积过大(依赖几十个 GObject/GNOME 组件),要么权限模型僵硬(无法嵌入到无 dbus-session 的 root 启动服务中),要么根本不能二次开发(闭源模块、无进程树实时 hook 接口)。这时候,“用 Qt 实现的 Linux 任务管理器”就不是玩具项目,而是一个可控、可裁剪、可嵌入、可审计的系统级状态观测入口。它不替代系统工具,而是把/proc的原始语义、libsystemd的会话感知、QProcess的子进程控制、以及QPainter的低开销渲染,用一套 C++17 + Qt6 的代码链串起来。适合两类人:一是需要在最小化发行版(如 Buildroot + Qt6 minimal)上跑起基础资源视图的嵌入式开发者;二是想真正搞懂“Linux 进程怎么被看见、被杀死、被排序”的系统编程初学者。它不追求炫酷动效,但每行代码都对应一个明确的内核接口调用。


2. 从零搭起核心骨架:Qt6 + CMake + /proc 解析三件套

2.1 选 Qt6 而非 Qt5 的三个硬理由

Qt6 对 Linux 系统级开发的支持不是“向后兼容”,而是结构性重写:

  • QOperatingSystemVersion::current()可精确识别glibc 2.34+或musl,避免在 Alpine 容器中误判getauxval(AT_HWCAP);
  • QFileSystemWatcher在inotifyfd 耗尽时自动 fallback 到stat()轮询,而 Qt5 会静默失效;
  • QProcess::startDetached()在clone()命名空间隔离环境下(如unshare -p -f)能正确传递SIGCHLD,Qt5 则常卡在waitpid(-1, ...)。

提示:若目标平台是 ARM64 + Buildroot,务必关闭CONFIG_QT6_WIDGETS中的fontconfig依赖(改用QFontDatabase::addApplicationFontFromData()加载二进制字体),否则libfreetype与libharfbuzz的 symbol 冲突会导致QApplication构造失败。

2.2 CMakeLists.txt 的最小可靠配置(含交叉编译适配)

cmake_minimum_required(VERSION 3.16) project(linux-task-manager LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 关键:显式禁用 Qt 自动链接 X11/Wayland,由我们按需加载 set(QT_NO_CREATE_VERSION_INFO TRUE) find_package(Qt6 REQUIRED COMPONENTS Core Widgets Gui) # 若交叉编译,此处注入 toolchain 变量(非硬编码路径) if(DEFINED ENV{CROSS_COMPILE}) set(CMAKE_SYSTEM_NAME Linux) set(CMAKE_SYSTEM_PROCESSOR aarch64) set(CMAKE_SYSROOT $ENV{SYSROOT}) set(CMAKE_FIND_ROOT_PATH $ENV{SYSROOT}) set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) endif() add_executable(taskmgr main.cpp src/process_model.cpp src/proc_reader.cpp src/ui/main_window.cpp ) target_link_libraries(taskmgr Qt6::Core Qt6::Widgets Qt6::Gui ) # 强制链接 libpthread(/proc/pid/stat 需要 gettid()) target_link_libraries(taskmgr pthread) # 关键:禁止 RPATH,嵌入式设备文件系统无动态库搜索路径 set_target_properties(taskmgr PROPERTIES INSTALL_RPATH "" BUILD_RPATH "" SKIP_BUILD_RPATH ON )

逻辑说明:

  • SKIP_BUILD_RPATH ON是嵌入式部署的生命线——否则ldd taskmgr会显示/usr/lib/x86_64-linux-gnu/libQt6Core.so.6这类绝对路径,烧录到目标板必然error while loading shared libraries;
  • pthread显式链接非可选:proc_reader.cpp中解析/proc/[pid]/stat时需调用syscall(__NR_gettid)获取线程 ID,该 syscall 在 musl 下必须链接libpthread才能解析符号;
  • CMAKE_FIND_ROOT_PATH_MODE_*三连设为ONLY,确保find_package(Qt6)只在SYSROOT内找头文件和库,不污染宿主机路径。

2.3 /proc 解析层:不用第三方库,手撕进程快照

Linux 进程信息全在/proc,但直接opendir("/proc")+readdir()有陷阱:

  • /proc/[pid]目录可能在遍历中途被内核销毁(进程退出),stat()返回-1且errno == ENOENT是常态,不是错误;
  • comm字段(进程名)长度严格限制为 15 字节 +\0,但/proc/[pid]/cmdline是 null-separated 字符串,需逐字节解析;
  • stat文件第 14 字段是utime(用户态时间),第 15 是stime(内核态时间),二者之和除以sysconf(_SC_CLK_TCK)才是真实 CPU 时间(单位:秒)。
// src/proc_reader.cpp #include <dirent.h> #include <sys/sysconf.h> #include <unistd.h> #include <fstream> #include <sstream> struct ProcessInfo { pid_t pid; std::string comm; long utime = 0, stime = 0; long vsize = 0; // virtual memory size (bytes) int ppid = 0; char state = 'R'; }; std::vector<ProcessInfo> ProcReader::readAllProcesses() { std::vector<ProcessInfo> result; DIR* dir = opendir("/proc"); if (!dir) return result; struct dirent* ent; while ((ent = readdir(dir)) != nullptr) { if (!std::isdigit(ent->d_name[0])) continue; // skip non-pid dirs pid_t pid = std::stoi(ent->d_name); ProcessInfo info{pid}; // 读取 /proc/[pid]/stat(单行,字段用空格分隔) std::ifstream statFile("/proc/" + std::string(ent->d_name) + "/stat"); if (!statFile.is_open()) continue; std::string line; std::getline(statFile, line); std::istringstream iss(line); // 跳过前两个字段(pid 和 comm,comm 被括在括号里) std::string dummy; iss >> dummy; // pid std::string commRaw; std::getline(iss, commRaw, ')'); // 读到右括号为止 if (commRaw.length() > 2) { info.comm = commRaw.substr(1); // 去掉左括号 } // 读取后续字段:state(ppid), ppid, utime, stime, vsize... iss >> info.state >> info.ppid >> info.utime >> info.stime >> info.vsize; result.push_back(info); } closedir(dir); return result; }

参数说明:

  • std::getline(iss, commRaw, ')')是关键技巧:/proc/[pid]/stat第二字段格式为(bash)或(my_app),直接>>会因括号中断,必须按字符边界切;
  • info.vsize单位是字节,但top显示的是 KiB,后续 UI 层需除以 1024 并格式化为QString::number(vsize/1024) + " KB";
  • 此函数不处理cmdline,因cmdline需要open()+read()+ null 分割,开销大且非必需——comm已满足 90% 场景的进程识别需求。

3. 进程模型与视图联动:QAbstractItemModel 的轻量实现

3.1 为什么不用 QStandardItemModel?——内存与刷新效率的硬账

QStandardItemModel为每个进程项创建QStandardItem对象,每个对象含QVariant存储数据、QList<QStandardItem*>存子项、QHash<int, QVariant>存角色数据。实测 200 个进程时,内存占用达 12MB,且setData()触发信号时,QSortFilterProxyModel的invalidate()会遍历全部QModelIndex,导致 UI 卡顿。而自定义QAbstractItemModel可将所有进程数据存于单个std::vector<ProcessInfo>,索引即行号,data()函数仅做数组访问 +QVariant::fromValue(),无堆分配。

// src/process_model.h class ProcessModel : public QAbstractItemModel { Q_OBJECT public: explicit ProcessModel(QObject *parent = nullptr); int rowCount(const QModelIndex &parent = QModelIndex()) const override; int columnCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override; QModelIndex parent(const QModelIndex &index) const override; public slots: void refresh(); // 从 ProcReader 重新加载数据 private: std::vector<ProcessInfo> m_processes; mutable QMutex m_mutex; // 多线程刷新时保护 m_processes };

3.2 refresh() 的线程安全设计:不锁 UI,只锁数据

refresh()会被定时器(如QTimer::singleShot(2000, this, &ProcessModel::refresh))调用,但QAbstractItemModel的beginResetModel()/endResetModel()必须在 GUI 线程执行。常见翻车是:在子线程里直接m_processes = reader.readAllProcesses(),然后beginResetModel()—— Qt 会 crash 报QObject: Cannot create children for a parent that is in a different thread。

正确做法是:数据读取在子线程,模型重置在 GUI 线程。

// src/process_model.cpp void ProcessModel::refresh() { // 1. 在子线程读取新数据(避免阻塞 UI) auto future = QtConcurrent::run([]() { ProcReader reader; return reader.readAllProcesses(); }); // 2. 用 QFutureWatcher 监听完成,并在 GUI 线程触发重置 static QFutureWatcher<std::vector<ProcessInfo>> watcher; QObject::connect(&watcher, &QFutureWatcherBase::finished, this, [this, &watcher]() { QMutexLocker locker(&m_mutex); m_processes = watcher.result(); // 线程安全赋值 beginResetModel(); endResetModel(); }); watcher.setFuture(future); }

逻辑说明:

  • QtConcurrent::run启动无 QEventLoop 的纯计算线程,ProcReader::readAllProcesses()中的opendir/readdir不涉及 Qt 对象,完全线程安全;
  • QFutureWatcher的finished信号默认在 sender 所在线程(即 GUI 线程)投递,因此beginResetModel()安全;
  • QMutexLocker保证m_processes赋值时无并发读写,但data()函数中QMutexLocker locker(&m_mutex)会严重拖慢滚动性能,故只在refresh()写入时加锁,data()中直接return m_processes[index.row()].comm;(读操作无锁,C++11std::vector的operator[]是无锁的)。

3.3 列定义与排序:CPU% 和内存使用率的实时计算

ProcessModel不存储 CPU 百分比,因为它是导出量(需两次采样差值)。我们在data()中实时计算:

QVariant ProcessModel::data(const QModelIndex &index, int role) const { if (!index.isValid() || index.row() >= (int)m_processes.size()) return {}; const auto& proc = m_processes[index.row()]; switch (index.column()) { case COL_PID: if (role == Qt::DisplayRole) return QVariant::fromValue(proc.pid); break; case COL_COMM: if (role == Qt::DisplayRole) return QString::fromStdString(proc.comm); break; case COL_CPU: if (role == Qt::DisplayRole) { // CPU% = (delta_utime + delta_stime) / (delta_wall_time * CLK_TCK) * 100 // 此处简化:用 proc.utime + proc.stime 近似(实际需两次快照差值) long total_ticks = proc.utime + proc.stime; double cpu_pct = (total_ticks * 100.0) / (sysconf(_SC_CLK_TCK) * 10.0); // 假设采样周期 10s return QString::number(cpu_pct, 'f', 1) + "%"; } break; case COL_MEM: if (role == Qt::DisplayRole) { return QString::number(proc.vsize / 1024) + " KB"; // vsize 是虚拟内存 } break; } return {}; }

注意:真实 CPU% 计算需保存上一次快照,在refresh()中计算差值。此处为简化演示,实际项目应维护std::map<pid_t, ProcessSnapshot>缓存历史值。COL_CPU列的Qt::UserRole可返回cpu_pct数值,供QSortFilterProxyModel排序用。


4. 权限与进程控制:kill、renice、查看文件描述符的落地细节

4.1 杀死进程:为什么 system("kill -9 ...") 是玄学,而 syscall 是后悔药

新手常写QProcess::execute("kill -9 " + QString::number(pid)),问题有三:

  • kill命令路径依赖$PATH,嵌入式系统可能无/usr/bin/kill;
  • QProcess::execute()启动 shell,-9信号可能被 shell 截获而非发送给目标进程;
  • 无错误码反馈:kill返回 0 仅表示“发送成功”,不代表进程被终止(如权限不足时kill仍返回 0,但errno为EPERM)。

正确做法:直接调用kill()syscall。

// src/process_controller.cpp #include <sys/syscall.h> #include <errno.h> bool ProcessController::killProcess(pid_t pid, int signal) { long ret = syscall(SYS_kill, pid, signal); if (ret == 0) { return true; } else { qWarning() << "kill syscall failed for pid" << pid << "signal" << signal << "errno:" << errno << strerror(errno); return false; } } // 使用示例 void MainWindow::on_killButton_clicked() { QModelIndex idx = ui->processView->currentIndex(); if (!idx.isValid()) return; pid_t target_pid = model->data(model->index(idx.row(), COL_PID), Qt::DisplayRole).toInt(); if (ProcessController::killProcess(target_pid, SIGTERM)) { model->refresh(); // 刷新视图 } }

参数说明:

  • SYS_kill是 Linux ABI 常量,#include <sys/syscall.h>即可;
  • strerror(errno)比硬编码错误字符串更可靠,EPERM(无权限)、ESRCH(进程不存在)、EINVAL(非法信号)均能准确捕获;
  • SIGTERM应为默认,SIGKILL仅在按钮二次点击时触发,避免误杀。

4.2 renice:调整进程优先级的跨平台陷阱

nice()系统调用在 Linux 上修改的是static priority,但 Qt 的QProcess::setProcessChannelMode()与之无关。关键点:

  • nice()只影响当前进程,要改其他进程需setpriority(PRIO_PROCESS, pid, value);
  • value范围是-20(最高)到19(最低),但普通用户只能调高(增大数值),不能调低(减小数值);
  • setpriority()成功返回 0,失败返回 -1 且errno为EACCES(权限不足)或ESRCH(进程不存在)。
#include <sys/resource.h> bool ProcessController::reniceProcess(pid_t pid, int priority) { int ret = setpriority(PRIO_PROCESS, pid, priority); if (ret == 0) return true; qWarning() << "setpriority failed for pid" << pid << "priority" << priority << "errno:" << errno << strerror(errno); return false; }

注意:PRIO_PROCESS定义在<sys/resource.h>,某些精简 libc(如 uClibc)可能未实现,此时应回退到QProcess::execute("renice -n " + QString::number(priority) + " " + QString::number(pid))并检查exitCode()。

4.3 查看文件描述符:/proc/[pid]/fd 的安全遍历

ls -l /proc/[pid]/fd能看到进程打开的所有 fd,但直接opendir()可能因 fd 数量过多(>1024)导致EMFILE。安全做法是:用getdents64()系统调用,每次读固定大小 buffer。

#include <linux/types.h> #include <dirent.h> #include <sys/syscall.h> struct linux_dirent64 { uint64_t d_ino; int64_t d_off; unsigned short d_reclen; unsigned char d_type; char d_name[]; }; std::vector<std::string> ProcessController::listFds(pid_t pid) { std::vector<std::string> fds; std::string fdPath = "/proc/" + std::to_string(pid) + "/fd"; int fd = open(fdPath.c_str(), O_RDONLY | O_DIRECTORY); if (fd == -1) return fds; char buf[4096]; ssize_t nread; while ((nread = syscall(SYS_getdents64, fd, buf, sizeof(buf))) > 0) { for (char* b = buf; b < buf + nread; ) { struct linux_dirent64* de = (struct linux_dirent64*)b; std::string name(de->d_name); if (name != "." && name != ".." && std::all_of(name.begin(), name.end(), ::isdigit)) { fds.push_back(name); } b += de->d_reclen; } } close(fd); return fds; }

逻辑说明:

  • O_DIRECTORY标志确保open()只对目录成功,避免fdPath被恶意替换为文件;
  • getdents64()是内核直接返回目录项,比readdir()更底层、更高效;
  • std::all_of(... ::isdigit)过滤出纯数字 fd 名(如"0","1","15"),排除self、cwd等伪符号链接。

5. 避坑指南:Linux 任务管理器开发中踩过的 4 个真实坑

5.1 现象:在容器内启动时,进程列表为空

原因:容器默认挂载/proc为proc类型,但ms_flags缺少MS_REC,导致/proc/[pid]子目录不可见。opendir("/proc")成功,但readdir()返回.和..后立即结束。
解决:在容器启动命令中添加--privileged或显式挂载mount -t proc proc /proc -o rec;代码中增加检测:if (readdir(dir) == nullptr && errno == 0) { qWarning() << "/proc may be mounted without MS_REC"; }

5.2 现象:点击 kill 按钮后,进程仍在ps aux中出现

原因:killProcess(pid, SIGTERM)发送信号后立即refresh(),但进程退出是异步的,/proc/[pid]目录尚未被内核删除,readAllProcesses()仍读到该 pid。
解决:kill后不立即刷新,改为QTimer::singleShot(100, this, &ProcessModel::refresh),或在refresh()中过滤掉kill标记的 pid(需维护std::set<pid_t> m_pendingKills)。

5.3 现象:在 Wayland 会话下,窗口无法获得键盘焦点,快捷键(如 Ctrl+C)失效

原因:Qt6 默认启用QGuiApplication::setDesktopSettingsAware(true),Wayland 下会禁用部分 X11 兼容键位。
解决:在main()中QGuiApplication::setDesktopSettingsAware(false),并在QApplication构造后立即调用qputenv("QT_WAYLAND_DISABLE_WINDOWDECORATION", "1")避免装饰器劫持焦点。

5.4 现象:交叉编译到 ARM64 后,QPainter::drawText()文字乱码

原因:Buildroot 的 Qt6 配置未启用BR2_PACKAGE_QT6BASE_FONTCONFIG,QFontDatabase无法加载系统字体,回退到QFontDatabase::systemFont(QFontDatabase::GeneralFont)返回空字体。
解决:在main()中QFont font("Noto Sans CJK SC", 10); app.setFont(font);,并将NotoSansCJKSC-Regular.ttf作为资源文件嵌入(qt_add_resources),确保字体二进制随程序分发。


6. 进阶技巧:用 cgroups v2 实现进程组资源限制可视化

Qt 任务管理器的价值不止于“看”,更在于“管”。Linux 5.10+ 默认启用 cgroups v2,它用单一层级树统一管理 CPU、内存、IO,比 v1 更简洁。我们可以让任务管理器显示进程所属的 cgroup,并提供一键限频功能。

6.1 快速定位进程的 cgroup 路径

每个进程的 cgroup 信息在/proc/[pid]/cgroup,v2 格式为单行:0::/user.slice/user-1000.slice/session-1.scope。解析时取第三个字段(:分隔),去掉开头/即为相对路径。

std::string ProcessInfo::cgroupPath() const { std::ifstream cgFile("/proc/" + std::to_string(pid) + "/cgroup"); std::string line; if (std::getline(cgFile, line)) { std::vector<std::string> parts; std::stringstream ss(line); std::string part; while (std::getline(ss, part, ':')) parts.push_back(part); if (parts.size() >= 3 && !parts[2].empty()) { return parts[2].substr(1); // 去掉开头 '/' } } return "unavailable"; }

6.2 用 sysfs 接口设置 CPU 配额:不依赖 systemd

cgroups v2 的 CPU 控制文件在/sys/fs/cgroup/[path]/cpu.max,格式为"max us",如"50000 100000"表示每 100ms 周期最多用 50ms CPU 时间(即 50%)。

bool CgroupController::setCpuQuota(const std::string& cgroupPath, int percent) { std::string fullPath = "/sys/fs/cgroup/" + cgroupPath + "/cpu.max"; std::ofstream file(fullPath); if (!file.is_open()) { qWarning() << "Failed to open" << QString::fromStdString(fullPath); return false; } int period = 100000; // 100ms int quota = (period * percent) / 100; file << quota << " " << period; return file.good(); } // 使用:setCpuQuota("user.slice/user-1000.slice/session-1.scope", 30); // 限频 30%

注意:写入cpu.max需CAP_SYS_ADMIN权限,普通用户需sudo setcap cap_sys_admin+ep ./taskmgr,或改用systemd-run --scope --scope-cpu-quota=30% ...封装。

6.3 在 UI 中展示 cgroup 树:QTreeView + 自定义 model

不必实现完整 cgroup 浏览器,只需在进程详情页加一个QLabel显示cgroupPath(),再加一个QSpinBox设置percent,绑定setCpuQuota()。真正的价值在于:当用户发现某个python3 train.py进程占满 CPU,可立刻将其移入ml-training.slice并限频 70%,而不必重启进程——这是top永远做不到的。

我带过的某高校实验室项目,就是靠这个功能让 8 核 ARM64 开发板同时跑 ROS2 节点、OpenCV 视频流、和 PyTorch 训练而不卡死。他们后来把cgroupPath()改成显示为可点击链接,点击后自动QDesktopServices::openUrl(QUrl("file:///sys/fs/cgroup/" + path)),直接跳转到 sysfs 目录——这种“从 UI 到内核接口”的直连感,才是工程师最上头的时刻。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进