进程管理深度解析
# 03.进程管理深度解析
Qt 应用的底层是一个 Linux 进程——理解 fork/exec/僵尸/孤儿/守护进程,才能写出正确的多进程架构和可靠的进程保活策略。
# 目录介绍
# 3.1 案例引入
# 3.1.1 Qt 应用启动后 PID 变成了 1
某嵌入式设备上 Qt 应用 HmiApp 用 Shell 脚本启动,发现进程运行一段时间后 PID 变成了 1,且 QProcess 启动的子进程无法被 wait 回收。
出问题的启动脚本:
#!/bin/sh
# start.sh —— 设备启动时由 init 调用
cd /opt/hmi
./HmiApp &
echo "HmiApp started with PID $!"
# 脚本退出 → HmiApp 的父进程 Shell 消失
故障时间线:
t0: init(PID 1) ──→ start.sh(PID 120) // shell 脚本启动
t1: start.sh ──→ HmiApp(PID 121) & // fork 子进程,后台运行
t2: start.sh 退出 // shell 进程消失
t3: 内核:HmiApp 的父进程 PID 120 已退出
内核:把 HmiApp 的父进程改为 PID 1 (init) // ← 进程被收养
t4: HmiApp 调用 QProcess::start("sensor")
→ fork 子进程 sensor(PID 122)
→ sensor 运行完毕后退出 → 变成僵尸
t5: init(PID 1) 不负责 wait HmiApp 的孙子进程
sensor 僵尸永远不被回收 → /proc/122/status: State: Z
t6: 每 10 分钟重启一次 sensor → 僵尸累积 → 100+ 僵尸进程
t7: 系统进程槽耗尽 (kernel.pid_max = 32768) → fork 返回 EAGAIN
HmiApp 无法创建任何新进程 → 整个 HMI 界面卡死
根因:init(PID 1) 收养了孤儿进程 HmiApp,但 HmiApp 的 QProcess 子进程 sensor 的祖先进程 init 不关心也不 wait 孙子辈进程——导致僵尸永远堆积。
# 3.1.2 僵尸进程的形成链路
僵尸进程的完整生命周期:
1. 子进程退出 (exit / 被信号杀死)
│
▼
2. 内核释放子进程大部分资源(内存、打开的文件、信号处理器…)
但保留:PID + 退出状态 (exit code) + 资源使用统计 (rusage)
│
▼
3. 子进程变成僵尸 (ZOMBIE) —— 等待父进程 wait() 回收
│
├─ 父进程调 wait() → 内核把退出状态交给父进程 → 子进程彻底消失
│
└─ 父进程不调 wait() → 僵尸永远存活
└─ 父进程退出 → 僵尸被 init(PID 1) 收养 → init 调 wait() 回收
为什么 PID 不能直接释放?
POSIX 语义要求:父进程必须能通过 wait() 拿到子进程的退出状态——所以子进程退出后,必须保留 PID 和一组最小信息直到父进程确认。这个"残留物"就是僵尸。
HmiApp 案例中僵尸无法回收的原因:
init(PID 1) ← 只回收自己的直接子进程
│
└─ HmiApp(PID 121) ← 被 init 收养
│
└─ sensor(PID 122) [ZOMBIE] ← 祖先进程是 init,不是 init 的直接子进程
← 只有 HmiApp 能 wait,但 HmiApp 用的是 QProcess
← QProcess 的 finished 信号在子进程退出时触发
← 如果 QProcess 对象提前析构,wait 不会被调用
修复方案(QProcess 的正确用法):
// 方案 A:在 QProcess 析构前显式 wait
QProcess *proc = new QProcess(this);
proc->start("sensor");
// ...
if (!proc->waitForFinished(5000)) {
proc->kill();
proc->waitForFinished(); // 确保回收
}
// 方案 B:连接 finished 信号(Qt 事件循环自动回收)
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
proc, &QProcess::deleteLater); // finished 后自动 delete → 析构时 wait
// 方案 C:用 waitpid 兜底清理
void cleanup_zombie_children() {
while (waitpid(-1, nullptr, WNOHANG) > 0) {
// -1 表示等待任意子进程,WNOHANG 非阻塞
}
}
# 3.1.3 核心问题
fork()之后,子进程和父进程到底共享了什么?不共享什么?fork/vfork/clone三者的根本差异是什么?为什么嵌入式低内存设备上fork是性能杀手?exec六兄弟怎么选?替换进程映像后哪些东西还在?- 僵尸、孤儿、守护进程的关系是什么?标准守护进程模板里每一步的作用?
- 为什么嵌入式脚本启动的 Qt 应用总出僵尸进程?如何治本?
# 3.2 进程的诞生:fork
# 3.2.1 fork 的写时拷贝机制
fork() 是最昂贵的"拷贝"——但只拷贝元数据,不拷贝物理内存。
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("child PID: %d, PPID: %d\n", getpid(), getppid());
} else if (pid > 0) {
// 父进程
printf("parent PID: %d, child PID: %d\n", getpid(), pid);
} else {
perror("fork");
}
fork 后两进程共享了什么?
| 共享 | 不共享(独立拷贝) |
|---|---|
| 代码段(text segment) | 数据段(data、bss)——但有 COW 保护 |
打开的文件描述符(同一 struct file) | 进程 ID (PID) |
信号处理器(sigaction 设置) | 父进程 ID (PPID) |
当前工作目录(cwd) | fork() 的返回值 |
| 环境变量 | 文件锁(flock 锁不被继承) |
资源限制(rlimit) | 挂起的信号集(子进程为空) |
| 进程组 ID | 定时器(alarm 剩余时间为 0) |
写时拷贝(Copy-On-Write, COW)机制:
fork() 之后,父子进程的页表项都指向同一块物理页,且都标记为只读。
任何一方写该页 → CPU 触发 Page Fault → 内核捕获 →
内核分配新物理页 → 拷贝原页内容 → 更新触发方的页表 → 恢复执行。
结果:只有真正被写的页才被复制,未碰的页始终共享。
COW 的实际效果:
int global = 42; // 数据段
pid_t pid = fork();
if (pid == 0) {
printf("child global = %d\n", global); // 42 —— 读到父进程的值
global = 100; // COW 触发!子进程拿到独立副本
printf("child global = %d\n", global); // 100
} else {
sleep(1);
printf("parent global = %d\n", global); // 42 —— 父进程不变
}
COW 的两个主要代价:
| 代价 | 说明 |
|---|---|
| 页表复制 | fork 时必须复制父进程的页表(每 4KB 一页一个 PTE),128 MB 虚拟内存 = 32K 个 PTE ≈ 128 KB 页表 |
| 后续 Page Fault | 子进程写的每一页都要触发一次 do_wp_page 缺页异常,比普通写多 ~2μs |
# 3.2.2 vfork 的轻量替代
vfork() 是 fork 的精简版——不复制页表,子进程直接借用父进程的地址空间,直到 exec 或 _exit。
pid_t pid = vfork();
if (pid == 0) {
// 子进程 —— 直接运行在父进程的地址空间上!
// 规则 1:不能修改除 pid_t 变量外的任何数据
// 规则 2:不能 return(会破坏父进程栈帧)
// 规则 3:必须调用 _exit() 或 exec()
execl("/bin/ls", "ls", NULL);
_exit(127); // 如果 exec 失败
}
// 父进程 —— 在子进程 exec/_exit 之后恢复执行
// vfork 保证子进程先运行
vfork vs fork 对比:
| 特性 | fork | vfork |
|---|---|---|
| 页表复制 | ✅ 复制(COW 保护) | ❌ 不复制 |
| 父子执行顺序 | 不确定 | 子进程先执行(父进程阻塞) |
| 子进程地址空间 | 独立(COW) | 共享父进程的 |
| 适用场景 | 通用 | fork + 立即 exec |
| 现代使用 | 99% 的场景 | 仅在极致性能需求时 |
vfork 的危险性:
// ❌ 危险的 vfork 用法
pid_t pid = vfork();
if (pid == 0) {
global_var = 100; // ← 父进程的变量被修改!
return 0; // ← 破坏父进程栈帧!父进程"返回"的位置混乱
}
// ✅ 正确的 vfork 用法(只做 exec 前的准备,不写入)
pid_t pid = vfork();
if (pid == 0) {
close(3); // 关闭不需要的 fd —— 这也修改了文件描述符表!
// 但实际上 POSIX 允许修改 fd 表,因为它不在"地址空间"的概念内
execlp("myapp", "myapp", NULL);
_exit(127);
}
现代 Linux 上 fork 已足够快——COW 页表复制只是一次 memcpy,远快于淘汰物理页。所以 vfork 在今天基本已废弃,仅存在 POSIX 历史原因。
# 3.2.3 clone 的精细粒度控制
clone() 是 Linux 的"超级 fork"——可以精确控制父子进程共享哪些资源。fork、vfork、pthread_create 的底层都是 clone。
#define _GNU_SOURCE
#include <sched.h>
// 创建线程:共享地址空间、文件系统、fd 表、信号处理器
pid_t tid = clone(thread_func, // 子线程入口
child_stack + 8192, // 子线程栈(高地址向低地址生长)
CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD,
NULL); // 传入 thread_func 的参数
// 创建进程(= fork 的等价):
pid_t pid = clone(child_func, child_stack + 8192, SIGCHLD, NULL);
// SIGCHLD = 子进程退出时给父进程发 SIGCHLD 信号
clone 标志位全览:
| 标志位 | 子进程共享父进程的… | fork | vfork | pthread |
|---|---|---|---|---|
CLONE_VM | 虚拟地址空间 | ❌ | ✅ | ✅ |
CLONE_FS | 文件系统信息(root、cwd、umask) | ✅ | ✅ | ✅ |
CLONE_FILES | 文件描述符表 | ✅ | ✅ | ✅ |
CLONE_SIGHAND | 信号处理器表 | ❌ | ❌ | ✅ |
CLONE_THREAD | 同一线程组(同 PID) | ❌ | ❌ | ✅ |
CLONE_VFORK | 父进程阻塞直到子进程 exec/exit | ❌ | ✅ | ❌ |
CLONE_PARENT | 父进程相同(收养关系) | ❌ | ❌ | ❌ |
CLONE_NEWNS | 新挂载命名空间(容器基础) | ❌ | ❌ | ❌ |
CLONE_NEWPID | 新 PID 命名空间 | ❌ | ❌ | ❌ |
CLONE_NEWNET | 新网络命名空间 | ❌ | ❌ | ❌ |
fork / vfork / pthread_create 的本质对应:
fork() = clone(SIGCHLD, 0)
vfork() = clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0)
pthread_create()= clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD, ...)
clone 的"进程 vs 线程"分界线:是否共享 CLONE_VM。共享地址空间 → 线程;不共享 → 进程。
# 3.2.4 嵌入式场景:fork 在低内存设备上的代价
在 128 MB / 256 MB 的嵌入式设备上,fork() 之后不立刻 exec() 会导致三个严重问题:
问题 1:COW 页表过大——128 MB 地址空间 ≈ 32K 页表项,一次 fork 就要复制 128 KB 页表。fork 自身耗时在 ARM Cortex-A7 上约 200 μs——看起来少,但内存碎片化严重(分配连续物理页来存放页表)可能把耗时推到 5 ms 以上。
问题 2:内存过量提交(OOM-killer 风险):
// 嵌入式设备 128 MB 内存,父进程已用 80 MB
pid_t pid = fork(); // fork 成功(因为有 COW,内核认为不需要立即分配 80 MB)
if (pid == 0) {
// 子进程开始写脏页 → COW 触发 → 需要新物理页
// 但只剩 48 MB 空闲 → 某些页分配失败 → OOM-killer 杀死进程
memset(large_buffer, 0, 80_MB); // ← 可能触发 OOM
}
问题 3:vm.overcommit_memory 的影响:
# /proc/sys/vm/overcommit_memory
# 0 —— 启发式过量提交(默认):fork 可能成功但后续写时 OOM
# 1 —— 总是过量提交:fork 永远成功(最容易 OOM)
# 2 —— 禁止过量提交:只有真实空闲内存才允许 fork
# 嵌入式系统强烈建议设为 2
echo 2 > /proc/sys/vm/overcommit_memory
嵌入式最佳实践:
// ✅ 正确:fork 后立刻 exec(子进程地址空间马上被替换,COW 不会真正触发)
pid_t pid = fork();
if (pid == 0) {
execve("/usr/bin/sensor", argv, envp); // 立刻替换掉 80 MB 的地址空间
_exit(127);
}
// ❌ 错误:fork 后不 exec,子进程长时间持有父进程的 80 MB COW 映射
pid_t pid = fork();
if (pid == 0) {
// 做一大堆初始化工作,写大量内存 → COW 大量触发 → OOM
init_sensors();
while (1) { process(); }
}
QProcess 在低内存设备上的优化:
// QProcess 默认使用 fork + exec(正确路径)
QProcess proc;
proc.start("sensor"); // 内部 fork → exec,不会 COW 大量触发
// 但 connect 到 QProcess 对象的信号前,如果父进程 malloc 大量内存,
// 恰好 fork 发生在 malloc 之后 exec 之前 —— 此时 COW 页表已建立。
// Qt 6.5+ 用 vfork 优化了这个窗口(posix_spawn 内部)。
# 3.3 进程的变身:exec 家族
# 3.3.1 execl/execv/execle/execve/execlp/execvp 差异表
exec 不会创建新进程——它替换当前进程的地址空间为新程序。fork + exec = 先"克隆自己"再"卸下克隆体换上别人"。
六个 exec 变体命名规则:
exec [l|v] [e] [p]
│ │ └─ p: PATH 环境变量搜索(不加 p 用绝对/相对路径)
│ └─ e: 显式传递环境变量 (envp)
└─ l: 参数以变长列表传递 (list)
v: 参数以数组传递 (vector)
完整差异表:
| 变体 | 参数形式 | 路径搜索 | 环境变量 | 原型 |
|---|---|---|---|---|
execl | 列表(char *arg0, ..., NULL) | 否 | 继承父进程 | int execl(const char *path, const char *arg, ..., NULL) |
execv | 数组(char *argv[]) | 否 | 继承父进程 | int execv(const char *path, char *const argv[]) |
execle | 列表 | 否 | 显式传递 | int execle(const char *path, const char *arg, ..., NULL, char *const envp[]) |
execve | 数组 | 否 | 显式传递 | int execve(const char *path, char *const argv[], char *const envp[]) |
execlp | 列表 | 是 (PATH) | 继承父进程 | int execlp(const char *file, const char *arg, ..., NULL) |
execvp | 数组 | 是 (PATH) | 继承父进程 | int execvp(const char *file, char *const argv[]) |
记忆口诀:
- l = list:参数一个一个写(
"ls", "-l", NULL),末尾必须 NULL - v = vector:参数放在
char *argv[]里,argv[0]是程序名 - e = environment:最后一个参数是
char *envp[](传递自定义环境) - p = PATH:第一个参数只需文件名(
"ls"),内核在 PATH 里找 - 内核真正执行的只有
execve——其他五个都是 C 库的壳,最终调execve
典型用法:
// 用 execl:适合参数已知、数量少的情况
execl("/bin/ls", "ls", "-l", "-a", NULL);
// 用 execvp:适合参数动态构造的情况
char *args[] = {"ls", "-l", "-a", NULL};
execvp("ls", args); // 会在 PATH 中找 ls
// 用 execve:需要传递特殊环境变量
char *env[] = {"PATH=/usr/bin", "HOME=/root", NULL};
execve("/bin/ls", args, env);
// 用 execlp:最常见
execlp("python3", "python3", "script.py", NULL);
# 3.3.2 替换后哪些资源保留
exec 成功后,进程地址空间全部被替换——但内核为进程维护的结构体 task_struct 中的部分字段保留:
| 保留 | 被重置/清除 |
|---|---|
| PID(进程号不变) | 代码段、数据段、栈、堆 —— 全部替换为新程序 |
已打开的文件描述符(除非标记 FD_CLOEXEC) | 信号处理器恢复为默认(SIG_DFL) |
| 进程组 ID、会话 ID | 挂起的信号集 |
当前工作目录(cwd) | alarm 剩余时间为 0 |
根目录(如 chroot 设置过) | 文件锁(flock)保留(但有坑) |
资源限制(rlimit) | 内存映射(mmap 非文件映射被清除) |
| umask | atexit 注册的函数列表 |
| nice 值(调度优先级) |
【核心坑】FD_CLOEXEC 标志——"exec 时自动关闭 fd"
int fd = open("secret.db", O_RDONLY);
// 没有设置 FD_CLOEXEC!
pid_t pid = fork();
if (pid == 0) {
execlp("untrusted_app", "untrusted_app", NULL);
// untrusted_app 继承了 fd → 可以读 secret.db!
}
// 正确做法:
int fd = open("secret.db", O_RDONLY | O_CLOEXEC); // 打开时就标记
// 或者:
fcntl(fd, F_SETFD, fcntl(fd, F_GETFD) | FD_CLOEXEC); // 之后设置
【安全实践】所有 open 都加 O_CLOEXEC——防止子进程泄露不该有的文件描述符。这是 Android 和 Chromium 项目的硬性规定。
# 3.3.3 script 脚本的 shebang 机制
shebang(#!)不是内核的系统调用特性——是 execve 系统调用内部的检测逻辑。
#!/usr/bin/python3
print("hello")
当 execve("script.py", ...) 被调用时,内核检测前两个字节是 #!:
1. execve("script.py", argv)
│
▼
2. 内核读取 script.py 的第一行 → "#! /usr/bin/python3"
│
▼
3. 内核重新 execve("/usr/bin/python3", ["/usr/bin/python3", "script.py", ...])
│
▼
4. Python 解释器解释脚本内容
shebang 的两条限制:
| 限制 | 说明 |
|---|---|
| 最大长度 | Linux 下 #! 行最长为 256 字节(包括路径和参数) |
| 只递归一次 | script1 的 shebang 指向 script2——不会再加一层,直接失败 |
嵌入式场景:shebang 不存在时
# 嵌入式系统可能没有 Python 解释器,shebang 指向的路径为空
$ ./test.py
-bash: ./test.py: /usr/bin/python3: bad interpreter: No such file or directory
排查法:
# 检查解释器是否存在
readelf -h /usr/bin/python3 2>&1 || echo "解释器不存在"
# 或用 file 命令看解释器路径
file test.py
# 输出:test.py: Python script, ASCII text executable
# 3.4 进程终结与遗孤处理
# 3.4.1 wait/waitpid 的正确用法
四个等待接口:
#include <sys/wait.h>
// 等待任意子进程(阻塞直到有子进程退出)
pid_t wait(int *wstatus);
// 等待指定子进程(可指定选项)
pid_t waitpid(pid_t pid, int *wstatus, int options);
// Linux 扩展:等待指定 PID 的子进程,可获取 rusage
pid_t wait3(int *wstatus, int options, struct rusage *rusage);
// Linux 扩展:waitpid + wait3 的结合
pid_t wait4(pid_t pid, int *wstatus, int options, struct rusage *rusage);
waitpid 的 pid 参数含义:
| pid 值 | 含义 |
|---|---|
> 0 | 等待指定 PID 的子进程 |
0 | 等待同一进程组内的任意子进程 |
-1 | 等待任意子进程(等同 wait) |
< -1 | 等待进程组 ID 等于 \|pid\| 的任意子进程 |
options 选项:
| 选项 | 含义 |
|---|---|
WNOHANG | 非阻塞:无子进程退出时立即返回 0(不挂起) |
WUNTRACED | 返回已停止的子进程(被 SIGSTOP/SIGTSTP 暂停) |
WCONTINUED | 返回已恢复的子进程(收到了 SIGCONT) |
正确的 wait 循环模板:
void wait_all_children() {
pid_t pid;
int status;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
if (WIFEXITED(status)) {
printf("child %d exited with status %d\n", pid, WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("child %d killed by signal %d%s\n", pid, WTERMSIG(status),
WCOREDUMP(status) ? " (core dumped)" : "");
} else if (WIFSTOPPED(status)) {
printf("child %d stopped by signal %d\n", pid, WSTOPSIG(status));
}
}
}
退出状态解析宏:
| 宏 | 用途 |
|---|---|
WIFEXITED(status) | 正常退出?exit(n) 或 return n → WEXITSTATUS(status) 取 n |
WIFSIGNALED(status) | 被信号杀死?→ WTERMSIG(status) 取信号编号 |
WCOREDUMP(status) | 是否产生 core dump? |
WIFSTOPPED(status) | 被暂停?→ WSTOPSIG(status) 取信号编号 |
WIFCONTINUED(status) | 被恢复? |
# 3.4.2 僵尸进程的根因与清理
僵尸是内核的设计——不是 bug。内核保留子进程的 PID 和退出状态直到父进程调用 wait。如果父进程不调 wait,僵尸就永远存在。
僵尸占用什么资源?
| 资源 | 占用情况 |
|---|---|
| 内存(代码、数据、栈、堆) | ❌ 已释放 |
| 打开的文件描述符 | ❌ 已关闭 |
| PID | ✅ 占用(不回收) |
/proc/<pid>/ 目录 | ✅ 占用 |
| 内核进程表槽位(task_struct) | ✅ 占用(约 1.7 KB) |
僵尸无法被 kill -9 杀掉——因为僵尸已经死了,只是在等父进程"认领"。kill -SIGKILL <zombie_pid> 会返回成功但无任何效果。
僵尸的识别:
ps aux | grep 'Z' # STAT 列显示 Z 或 Z+
cat /proc/<pid>/status | grep State # 输出:State: Z (zombie)
# 查看僵尸的父进程
ps -o ppid= -p <zombie_pid>
僵尸的清理路径:
1. 父进程 wait() → 僵尸消失(最优)
2. 父进程退出 → init 收养 → init 调 wait() → 僵尸消失(次优)
3. 父进程变成僵尸也不处理 → 僵尸永远存在 → 只能重启系统(最差)
SIGCHLD 信号机制——父进程不阻塞也能知道子进程退出了:
void sigchld_handler(int sig) {
int saved_errno = errno; // 保存 errno,信号可能随时打断
while (waitpid(-1, nullptr, WNOHANG) > 0) {
// 回收所有退出的子进程
}
errno = saved_errno;
}
// 注册 SIGCHLD 处理器
struct sigaction sa = {.sa_handler = sigchld_handler};
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; // SA_NOCLDSTOP:子进程暂停不通知
sigaction(SIGCHLD, &sa, nullptr);
显式忽略 SIGCHLD——告诉内核"我不关心子进程退出状态",内核自动回收:
// 告诉内核自动回收子进程(不会变僵尸)
signal(SIGCHLD, SIG_IGN);
// 此时 fork 的子进程退出后,父进程无法 wait 到(返回 ECHILD)
// 且子进程退出状态丢失(只回收 PID,不保留退出码)
# 3.4.3 孤儿进程与 init 收养
孤儿进程:父进程已经退出但子进程还在运行的进程,由 init(PID 1) 收养。
父进程 fork → 子进程
父进程退出(子进程的 PPID 变成 1)
子进程继续运行——它是孤儿
为什么收养给 init(PID 1)?
PID 1 是内核在启动阶段创建的第一个用户态进程。它有两个特殊职责:
- 收养所有孤儿进程(内核硬编码——父进程退出时子进程的 PPID 强制改为 1)
- 定期调用
wait()回收僵尸
孤儿 vs 僵尸:
| 类型 | 定义 | 能否 kill | 资源占用 |
|---|---|---|---|
| 孤儿 | 父进程已退出,子进程还在运行 | 能(kill 信号有效) | 正常进程 |
| 僵尸 | 子进程已退出,父进程未 wait | 不能(kill 无效) | 只占 PID + task_struct |
| 守护进程 | 故意让自己变成孤儿(脱离终端) | 能 | 正常进程 |
探索实验:
# 创建一个孤儿进程,观察 PPID 变化
sh -c 'sleep 100 &' &
# 父进程 sh 立即退出,sleep 变成孤儿,PPID 变为 1
ps -eo pid,ppid,cmd | grep sleep
# 输出: 12345 1 sleep 100
# 3.4.4 嵌入式 init(PID 1) 的信号处理陷阱
PID 1 的特殊信号规则(内核硬编码):
| 信号 | 普通进程 | PID 1 |
|---|---|---|
SIGTERM / SIGINT | 默认终止 | 忽略(不显式注册处理器就不会终止) |
SIGKILL | 强制终止 | 忽略 |
SIGSTOP | 强制暂停 | 忽略 |
SIGCHLD | 默认忽略 | 需显式注册处理器回收子进程 |
这就是 HmiApp 案例的底层原因——init 不显式注册 SIGCHLD 处理器时,所有被它收养的进程的子进程(孙子辈)退出后都会变成僵尸。
BusyBox init 的 /etc/inittab 规范:
# 在 /etc/inittab 中使用 respawn 保证进程保活
::respawn:/opt/hmi/HmiApp
# init 会:
# 1. fork + exec HmiApp
# 2. wait 它(回收退出状态)
# 3. 如果 HmiApp 退出,重新 fork + exec(respawning)
systemd 的替代方案(现代嵌入式 Linux):
[Service]
Type=simple
ExecStart=/opt/hmi/HmiApp
Restart=always # 等价于 respawn
RestartSec=5s
嵌入式 init 的选型建议:
| init 系统 | 大小 | 适合场景 |
|---|---|---|
| BusyBox init | ~100 KB | 极小设备(< 16 MB 闪存) |
| systemd | ~5 MB | 复杂设备(支持 socket activation、cgroups) |
| OpenRC | ~2 MB | 中规模嵌入式 |
| finit | ~500 KB | 轻量替代 |
| 自定义 init 脚本 | ~10 KB | 定制设备 |
# 3.5 进程关系与继承
# 3.5.1 进程组与会话
Linux 的进程组织层次:
会话 (Session)
└─ 进程组 (Process Group) ← 会话可以有多个进程组
├─ 进程 A ← 进程组可以有多个进程
├─ 进程 B
└─ 进程 C
└─ 进程组
└─ 进程 D
进程组的作用:信号的广播目标——对进程组发信号(kill -HUP -PGID),组内所有进程都收到。
// 创建新进程组
setpgid(0, 0); // 子进程创建自己的进程组(PGID = 自己的 PID)
// 查看进程组
printf("PGID: %d\n", getpgid(0));
// 向整个进程组发信号
kill(-pgid, SIGTERM); // 负 PGID → 进程组
会话的作用:一个会话对应一个终端登录。shell 登录时创建会话,该 shell 是会话首领(session leader),所有从这个 shell 启动的进程默认属于这个会话。
// 创建新会话(脱离当前终端)
pid_t sid = setsid(); // 调用进程变成新会话的首领 + 新进程组的首领
# 3.5.2 控制终端与后台进程
控制终端:会话可以关联一个终端设备(/dev/tty*),当终端断开时内核向会话首领发 SIGHUP。
终端关闭
│
▼
内核向会话首领发 SIGHUP
│
▼
会话首领(shell)收到 SIGHUP → 向前台进程组发 SIGHUP
│
▼
前台进程组所有进程默认终止
│
▼
后台进程组不受影响(除非终端完全断开,设备不可用)
nohup 命令的原理:
nohup ./long_task &
# 等价于:
# 1. 忽略 SIGHUP 信号
# 2. 重定向 stdout/stderr 到 nohup.out
SIGHUP 的嵌入式实践——用 SIGHUP 做"平滑重启"(配置文件变更后不杀进程,而是通知它重新加载):
void sighup_handler(int sig) {
reload_config(); // 重新读配置文件
}
signal(SIGHUP, sighup_handler);
// 发送信号:kill -HUP <pid> ← 触发重新加载
# 3.5.3 守护进程的标准模板
守护进程的 8 步法(每一步都有确切目的):
#include <fcntl.h>
#include <signal.h>
#include <unistd.h>
#include <sys/stat.h>
void daemonize() {
pid_t pid;
// 步骤 1:fork,父进程退出 —— 子进程变成孤儿
pid = fork();
if (pid < 0) exit(EXIT_FAILURE);
if (pid > 0) exit(EXIT_SUCCESS); // 父进程退出
// 步骤 2:setsid —— 脱离原会话、原进程组、原控制终端
if (setsid() < 0) exit(EXIT_FAILURE);
// 步骤 3:再次 fork,子进程退出 —— 确保进程不是会话首领
// (会话首领可以重新关联终端,再 fork 一次就无法关联了)
pid = fork();
if (pid < 0) exit(EXIT_FAILURE);
if (pid > 0) exit(EXIT_SUCCESS);
// 步骤 4:umask(0) —— 让守护进程创建的文件权限完全由 open 控制
umask(0);
// 步骤 5:chdir("/") —— 避免占用某个文件系统目录,
// 防止该目录所在文件系统无法 umount
if (chdir("/") < 0) exit(EXIT_FAILURE);
// 步骤 6:关闭从父进程继承的所有文件描述符
for (int fd = sysconf(_SC_OPEN_MAX) - 1; fd >= 0; fd--) {
close(fd);
}
// 步骤 7:重定向 0/1/2 到 /dev/null
int null_fd = open("/dev/null", O_RDWR);
if (null_fd >= 0) {
dup2(null_fd, STDIN_FILENO);
dup2(null_fd, STDOUT_FILENO);
dup2(null_fd, STDERR_FILENO);
if (null_fd > 2) close(null_fd);
}
// 步骤 8:信号处理(可选)——忽略 SIGHUP 或注册自定义处理
signal(SIGHUP, SIG_IGN);
}
嵌入式精简版(嵌入式系统通常不需要 8 步全做):
void daemonize_lite() {
if (fork() > 0) _exit(0); // 1. fork 一次
setsid(); // 2. 脱离终端
if (fork() > 0) _exit(0); // 3. 再 fork 一次
chdir("/"); // 4. 换根目录
close(STDIN_FILENO); // 5. 关掉三标准流
close(STDOUT_FILENO);
close(STDERR_FILENO);
}
# 3.6 关键结论与速查表
核心结论五条:
fork()创建进程,exec()换身体——fork复制元数据(COW 保护),exec替换地址空间。两者组合才是"创建新程序"的标准姿势- 僵尸不是 bug 是设计——POSIX 要求父进程能拿到子进程退出状态;不
wait= 僵尸永久占用 PID;SIGCHLD处理或SIG_IGN是好习惯 clone()是 Linux 的万能创生器——共享地址空间 → 线程,不共享 → 进程;CLONE_NEW*系列标志是容器技术(Docker)的底层基础- 嵌入式 fork 要小心——低内存 + 不立 exec = COW 大量触发 → OOM;
overcommit_memory=2是嵌入式标配 - 守护进程的核心是脱离终端——
setsid()脱离会话,二次fork防止重新关联终端,chdir("/")防止阻塞挂载点卸载
fork/exec 速查表:
| 操作 | 创建什么 | 共享地址空间 | 典型场景 |
|---|---|---|---|
fork() | 新进程 | ❌ (COW) | 通用 |
vfork() | 新进程(轻量) | ✅(直到 exec) | fork + 立即 exec 的性能优化 |
clone(SIGCHLD) | 新进程 | ❌ | fork 的底层实现 |
clone(CLONE_VM\|CLONE_THREAD) | 新线程 | ✅ | pthread_create 的底层实现 |
clone(CLONE_NEWNS\|CLONE_NEWPID) | 新容器 | 视标志而定 | Docker / LXC |
fork + exec | 新程序 | ❌(先 COW 再替换) | 启动外部命令 |
posix_spawn() | 新进程(一步到位) | ❌ | 无需 fork 时的更优替代 |
wait 系列速查表:
| 接口 | 等谁 | 阻塞 | 是否获取 rusage |
|---|---|---|---|
wait(&status) | 任意子进程 | 阻塞 | 否 |
waitpid(pid, &status, 0) | 指定 PID | 阻塞 | 否 |
waitpid(pid, &status, WNOHANG) | 指定 PID | 非阻塞 | 否 |
wait3(&status, 0, &rusage) | 任意子进程 | 阻塞 | 是 |
wait4(pid, &status, 0, &rusage) | 指定 PID | 阻塞 | 是 |
waitid(P_PID, id, &info, WEXITED) | 指定 PID | 阻塞 | 更详细 siginfo |
常见坑点速查:
| 坑 | 原因 | 解决 |
|---|---|---|
| 僵尸进程累积 | 父进程未 wait 子进程 | SIGCHLD 处理器 + WNOHANG 循环 |
| fork 后 COW OOM | 低内存设备 fork 后不立 exec | fork + 立即 exec;或 overcommit=2 |
| exec 后 fd 泄露 | 未设 FD_CLOEXEC | 所有 open 加 O_CLOEXEC |
| QProcess 子进程僵尸 | Qt 事件循环未及时回收 | finished 信号 + deleteLater |
| Shell 脚本子进程变孤儿 | shell 退出,子进程被 init 收养 | 用进程管理器(systemd / supervisord) |
system() 阻塞主线程 | system() = fork + exec + wait 三步 | 改用 fork + exec 或线程池 |
下一步:在你的开发板上写一个简单的进程监控程序——周期性扫描 /proc 目录下的所有僵尸进程,记录其 PID 和父进程,验证你对 waitpid 和进程生命周期的理解。
延伸阅读:
man 2 fork/man 2 clone—— 进程创建接口man 2 wait—— 进程等待接口man 3 daemon—— glibc 提供的 daemon() 函数man 7 credentials—— 进程凭证(UID/GID)继承规则- 《The Linux Programming Interface》(Kerrisk) —— 第 24-28、34-37 章
- Linux kernel / clone flags (opens new window)