编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • Android提升进阶

  • iOS开发和进阶

  • Web开发和进阶

  • Linux应用开发

    • Linux应用开发
    • QML基础入门

    • QT核心库实践

    • Linux系统编程

      • Linux系统编程基础
      • 系统调用原理精讲
      • 文件IO深度解析
      • 进程管理深度解析
        • 线程并发深度解读
        • 信号处理深度解读
        • 进程间通信IPC
        • 网络IO多路复用
        • 内存管理深度解读
        • 动态链接与共享库
        • Linux与定时器
      • 综合项目实战

    • IoT智能硬件开发

    • Apps
    • Linux应用开发
    • Linux系统编程
    杨充
    2025-07-02
    目录

    进程管理深度解析

    # 03.进程管理深度解析

    Qt 应用的底层是一个 Linux 进程——理解 fork/exec/僵尸/孤儿/守护进程,才能写出正确的多进程架构和可靠的进程保活策略。

    # 目录介绍

    • 3.1 案例引入
      • 3.1.1 Qt 应用启动后 PID 变成了 1
      • 3.1.2 僵尸进程的形成链路
      • 3.1.3 核心问题
    • 3.2 进程的诞生:fork
      • 3.2.1 fork 的写时拷贝机制
      • 3.2.2 vfork 的轻量替代
      • 3.2.3 clone 的精细粒度控制
      • 3.2.4 嵌入式场景:fork 在低内存设备上的代价
    • 3.3 进程的变身:exec 家族
      • 3.3.1 execl/execv/execle/execve/execlp/execvp 差异表
      • 3.3.2 替换后哪些资源保留
      • 3.3.3 script 脚本的 shebang 机制
    • 3.4 进程终结与遗孤处理
      • 3.4.1 wait/waitpid 的正确用法
      • 3.4.2 僵尸进程的根因与清理
      • 3.4.3 孤儿进程与 init 收养
      • 3.4.4 嵌入式 init(PID 1) 的信号处理陷阱
    • 3.5 进程关系与继承
      • 3.5.1 进程组与会话
      • 3.5.2 控制终端与后台进程
      • 3.5.3 守护进程的标准模板
    • 3.6 关键结论与速查表

    # 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 核心问题

    1. fork() 之后,子进程和父进程到底共享了什么?不共享什么?
    2. fork / vfork / clone 三者的根本差异是什么?为什么嵌入式低内存设备上 fork 是性能杀手?
    3. exec 六兄弟怎么选?替换进程映像后哪些东西还在?
    4. 僵尸、孤儿、守护进程的关系是什么?标准守护进程模板里每一步的作用?
    5. 为什么嵌入式脚本启动的 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 是内核在启动阶段创建的第一个用户态进程。它有两个特殊职责:

    1. 收养所有孤儿进程(内核硬编码——父进程退出时子进程的 PPID 强制改为 1)
    2. 定期调用 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 关键结论与速查表

    核心结论五条:

    1. fork() 创建进程,exec() 换身体——fork 复制元数据(COW 保护),exec 替换地址空间。两者组合才是"创建新程序"的标准姿势
    2. 僵尸不是 bug 是设计——POSIX 要求父进程能拿到子进程退出状态;不 wait = 僵尸永久占用 PID;SIGCHLD 处理或 SIG_IGN 是好习惯
    3. clone() 是 Linux 的万能创生器——共享地址空间 → 线程,不共享 → 进程;CLONE_NEW* 系列标志是容器技术(Docker)的底层基础
    4. 嵌入式 fork 要小心——低内存 + 不立 exec = COW 大量触发 → OOM;overcommit_memory=2 是嵌入式标配
    5. 守护进程的核心是脱离终端——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)
    上次更新: 2026/07/12, 19:39:51
    文件IO深度解析
    线程并发深度解读

    ← 文件IO深度解析 线程并发深度解读→

    最近更新
    01
    audit
    07-27
    02
    C++入门教程全章思考题汇编
    07-24
    03
    12.技术团队建设能力
    07-21
    更多文章>
    Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式