Linux:进程控制

本篇文章为Linux学习的进程控制部分学习分享,希望也能够为你带来些许的帮助!

那咱们废话不多说,直接开始吧!


一、进程创建

当一个进程还没创建子进程时,它的数据段在页表中的权限是 "读写"。

可一旦调用fork()创建子进程后,情况就变了 ------ 子进程的数据段权限会改成 "只读"。为啥呢?因为子进程的代码和数据都是从父进程那拷贝来的,初始状态下父子俩共享这些东西,所以权限得保持一致,先设成 "只读"。

这时候要是有一方想修改数据,系统就会 "出面" 判断:

  1. 要是发现这个写入操作在页表里压根没有对应的映射关系 ------ 系统会吐槽:"好好好原来是个野指针啊",直接判定是真错误,把进程终止掉!
  2. 要是页表里有映射关系,只是用户想修改 ------ 那 OS 就会先搞 "写时拷贝" 操作,拷贝完了再把父子进程的数据段权限改回 "读写"。

关于写时拷贝的两个常见问题

1. 为啥创建子进程后不直接给子进程拷贝一份父进程的数据,非要搞写时拷贝呢?

这你就不知道了吧!

首先,子进程不一定会修改父进程的数据啊,可能就是只读,这时候直接拷贝一份纯属浪费内存;其次,写时拷贝本质是 "按需获取",只有被修改的内容才会复制,没改的(哪怕属于数据段)还能保持共享,比全拷贝省太多内存了;

而且这还是一种 "惰性申请",不需要的时候,内存资源可以先给别的进程用,能提高资源利用率。

2. 那为啥非要拷贝呢?只给子进程开辟需要的空间不行吗?

你想啊,咱们平时改数据,很多时候是 "覆盖式修改",但像 "++a" 这种操作,它得先知道 a 的原始值啊!没有原始数据咋自增?所以拷贝操作是必须的。

拓展问题:C/C++ 里的 malloc/new 是直接在物理内存上开辟空间吗?

当然不是!调用 malloc/new 的时候,系统只会先给你开辟一块虚拟地址空间。等你真要访问这块地址时,因为物理页还没分配,就会触发 "缺页中断"。

这时候系统才会实际去申请物理内存,再把虚拟地址和物理地址的映射关系建好。这整个过程就是 "惰性空间开辟",咱们用户完全感知不到,也不用操心。

二、进程终止

先搞懂两个基础问题

1. 进程终止后会发生啥?

系统会回收进程的虚拟地址、页表和物理内存,然后把 PCB 里的进程状态改成 "僵尸状态"。

2. main 函数里的 return 0 到底啥意思?这个 0 给谁了?

这个 return 的 0 啊,其实是进程运行结束后的 "退出码",会被系统拿到,用来判断进程任务完成得咋样:0 表示成功,非 0 表示失败,而且不同数字对应不同失败原因。

咱们在命令行里用echo $?就能查到最近一个进程的退出码。比如你把 main 函数的退出码设成 2:

第一次执行echo $?会显示 2,但第二次就变成 0 了

为啥?

因为第一次显示的是那个退出码为 2 的进程的结果,第二次显示的是echo $?这个命令自己的退出码(它成功完成了显示任务,所以是 0)。

系统其实早就给咱们准备了一堆错误原因,用strerror()函数就能拿到字符串形式的错误描述,足足有 134 种呢!

进程退出的三种情况

  1. 代码跑完,结果正确
  2. 代码跑完,结果错误
  3. 代码没跑完,进程异常

前两种情况由退出码决定,对应的有三种设置退出码的方法:

  1. 在 main 函数里 return n,n 就是退出码
  2. 直接调用exit(n),n 是退出码
  3. 直接调用_exit(n),n 是退出码

肯定有人会问:

return 和 exit () 有啥区别?exit () 和_exit () 又有啥区别?

别急,听我慢慢说:

  • return 本来是表示函数调用结束,但 main 函数比较特殊,它是进程的入口,所以 main 里的 return 也代表进程结束;而 exit () 是专门表示进程退出的,跟 return 不一样,它能在程序任意地方调用。
  • exit () 和_exit () 的区别更关键:exit () 是库函数,_exit () 是系统调用;用 exit () 退出时,输出缓冲区会自动刷新(哪怕 printf 里没加 \n),但_exit () 会直接结束进程,不刷新缓冲区。

给你看几个测试用例就明白了:

  • return的测试:return n,退出码就是 n

  • exit () 的测试:不但会把缓冲区内容打印出来,而且调用后确实退出了,退出码是 3。

  • _exit () 的测试:啥都不打印,但退出码是 3,这就说明它真没刷新缓冲区。

但不管是哪种方式,最终都得调用系统调用,让系统来完成真正的进程删除!毕竟库函数和系统调用是上下层关系,exit () 内部肯定封装了_exit (),不然没法实现进程终止。

那输出缓冲区到底在哪儿呢?

首先咱能肯定这是块内存空间,而且绝对不在操作系统里!

从用户接口的结构图能看出来,库(lib)属于用户操作接口,如果缓冲区在操作系统里,那 exit () 和_exit () 应该都能刷新,但事实是_exit () 没刷新。

所以啊,这缓冲区其实在咱们的库里面,真名应该叫 "库缓冲区"!

三、进程等待

为啥要搞进程等待?

还记得不?子进程要是没被父进程回收,就会变成僵尸进程 ------ 一旦变僵尸,就算用kill -9都搞不定,会造成内存泄漏。所以进程等待有两个必要原因:

  • 一方面:回收子进程(必须做)
  • 另一方面:获取子进程的退出信息(可选做)

啥是进程等待?就是让父进程通过等待的方式,回收处于僵尸状态的子进程,有需要的话再拿它的退出信息。

先看 wait () 函数

1. 基本原型与功能

  • 功能:等待任意一个子进程结束,回收它的资源,还能获取退出状态(如果 status 不是 NULL 的话)。
  • 返回值:成功返回子进程 PID,失败返回 - 1(比如没有子进程)。
2. 子进程回收流程

子进程终止 → 进入僵尸状态(保留 PCB 和退出状态) → 父进程调用 wait () → 内核操作:

  1. 移除子进程的 PCB(回收进程表项)
  2. 要是 status 不为 NULL,就把状态信息复制到用户空间
  3. 给父进程返回子进程的 PID
3. 状态信息的编码机制

内核会把退出状态编成一个 16 位整数(存在 status 里):

  • 高 8 位:存正常退出的状态码(exit () 或 return 的参数)
  • 低 7 位:存导致进程终止的信号编号(如果是被信号终止的)
  • 第 8 位:标志位(core dump 标志,这里先不说)

举个例子:

  • 子进程执行exit(42):status 的高 8 位是 42,低 8 位是 0
  • 子进程被 SIGTERM(信号 15)终止:低 7 位是 15,高 8 位没啥意义
4. 状态解析宏(得包含 <sys/wait.h>)
宏函数 作用 解析逻辑
WIFEXITED(status) 判断子进程是否正常退出(返回非零就是) (status & 0xFF) == 0
WEXITSTATUS(status) 获取正常退出的状态码(得先调用 WIFEXITED) (status >> 8) & 0xFF
WIFSIGNALED(status) 判断子进程是否被信号终止 ((status & 0xFF) > 0) && ((status & 0xFF) < 128)
WTERMSIG(status) 获取终止子进程的信号编号(得先调用 WIFSIGNALED) status & 0x7F
WCOREDUMP(status) 判断子进程是否生成 core dump 文件 status & 0x80
5. wait(&status) vs wait(NULL)
特性 wait(&status) wait(NULL)
获取退出状态 能拿到状态码和终止原因 啥都拿不到
子进程回收 能正常回收,避免僵尸进程 也能正常回收
典型场景 需要根据子进程结果做决策(比如错误处理) 只需要确保子进程结束(比如批处理脚本)
性能 稍微有点开销(要复制状态到用户空间) 理论上快一点(不用复制)

代码示例:解析不同退出场景

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();
    int status;

    if (pid == 0) {
        // 子进程:三种场景选一个测试
        // exit(42);                 // 场景1:正常退出,状态码42
        // raise(SIGTERM);          // 场景2:被信号15终止
        // *(int*)0 = 0;            // 场景3:段错误(信号11)
    } else {
        wait(&status);  // 获取状态

        if (WIFEXITED(status)) {
            printf("子进程正常退出,状态码:%d\n", WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("子进程被信号 %d 终止", WTERMSIG(status));
            if (WCOREDUMP(status)) printf(",生成了core dump文件\n");
            else printf("\n");
        }
    }
}

状态编码与解析的底层逻辑

1. 编码示例(子进程 exit (42))

status 的二进制长这样:

cpp 复制代码
0000 0000 0010 1010 0000 0000
└───────────┘ └───────────┘
   高8位(42)    低8位(0)
2. 解析示例(宏展开)

假设 status 是 0x002A00(十进制 10752):

  • WIFEXITED (status) → (0x002A00 & 0xFF) == 0 → 0x00 == 0 → 真
  • WEXITSTATUS(status) → (0x002A00 >> 8) & 0xFF → 0x2A & 0xFF → 42

要是子进程被 SIGTERM(15)终止,status 是 0x00000F:

  • WIFSIGNALED (status) → (0x00000F & 0xFF) > 0 → 0x0F > 0 → 真
  • WTERMSIG(status) → 0x00000F & 0x7F → 0x0F → 15

常见应用场景

检查命令执行结果:
cpp 复制代码
if (WEXITSTATUS(status) != 0) {
    fprintf(stderr, "子进程执行失败,状态码:%d\n", WEXITSTATUS(status));
}
处理信号:
cpp 复制代码
if (WIFSIGNALED(status) && WTERMSIG(status) == SIGSEGV) {
    printf("子进程段错误!\n");
}
批处理任务:
cpp 复制代码
// 循环回收所有子进程并检查状态
while ((pid = wait(&status)) > 0) {
    if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
        printf("子进程 %d 成功完成\n", pid);
    }
}
关键总结
  1. 状态编码:内核把退出状态打包成 16 位整数,通过 status 传给用户空间。
  2. 解析工具:必须用宏函数解析 status,别直接按位操作(不同系统编码可能不一样)。
  3. 安全检查:调用 WEXITSTATUS 或 WTERMSIG 之前,必须先用 WIFEXITED 或 WIFSIGNALED 判断状态类型。

那要回收多个子进程咋办?

得循环调用 wait () 才行!多进程里,父进程往往是最先创建、最后退出的。

其实还有个更强大的函数 ------waitpid(),它不光能实现 wait () 的功能,还有额外技能,所以真要说的话,这个函数更全面!

waitpid () 函数详解

1. 函数原型与参数
  • pid 参数:指定要等的子进程,规则如下:

    • >0(指定子进程 pid):等 PID 等于这个值的子进程

    • -1:等任意子进程(跟 wait () 一样)

    • 0:等跟调用进程同组的所有子进程(fork 出来的子进程默认跟父进程同组,main 进程是组长)

    • < -1:等进程组 ID 等于这个值绝对值的所有子进程

  • status 指针:跟 wait () 一样,存子进程退出状态(不为 NULL 的话)

  • options 标志:

    • WNOHANG:非阻塞模式,子进程没结束就立即返回 0
    • WUNTRACED:跟踪被 SIGSTOP 暂停的子进程
    • WCONTINUED:跟踪被 SIGCONT 恢复的子进程
2. 返回值
  • 成功:
    • 子进程结束了:返回子进程 PID
    • WNOHANG 且子进程没结束:返回 0
  • 失败:返回 - 1,还会设置 errno(比如 ECHILD 表示没有子进程)
3. 核心特性
  • 精准控制:能指定等特定子进程或进程组
  • 非阻塞模式:用 WNOHANG 的话,父进程不用一直堵着,能去干别的事(非阻塞轮询状态)
  • 进程跟踪:能监控子进程的暂停(WUNTRACED)和恢复(WCONTINUED)

waitpid () vs wait () 对比表

特性 wait() waitpid()
函数原型 wait(&status) waitpid(pid, &status, options)
等待范围 任意子进程 可指定特定子进程或进程组
阻塞行为 必须等子进程结束 能通过 WNOHANG 设成非阻塞
进程跟踪 只支持已终止的子进程 支持暂停和恢复的子进程
回收指定子进程 没法选,只能按系统调度顺序 能通过 pid 精准回收
典型场景 简单顺序执行,不用并发 复杂多进程管理(比如并发服务器、shell)
关键代码示例
非阻塞等待(轮询模式):
cpp 复制代码
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();
    int status;

    if (pid == 0) {
        sleep(5);  // 子进程睡5秒
        exit(0);
    } else {
        while (waitpid(pid, &status, WNOHANG) == 0) {
            printf("子进程还在跑,父进程可以去干别的...\n");
            sleep(1);  // 父进程继续干活
        }
        printf("子进程结束了\n");
    }
}
等待特定子进程组:
cpp 复制代码
// 等进程组ID为1234的所有子进程
waitpid(-1234, &status, 0);  // pid=-1234表示进程组ID
处理多个子进程(非阻塞模式):
cpp 复制代码
while (1) {
    pid_t pid = waitpid(-1, &status, WNOHANG);
    if (pid == 0) {
        // 没子进程结束,继续干别的
        continue;
    } else if (pid == -1) {
        // 所有子进程都回收完了,或者出错了
        break;
    } else {
        // 处理已结束的子进程状态
        if (WIFEXITED(status)) {
            printf("子进程 %d 退出,状态码:%d\n", pid, WEXITSTATUS(status));
        }
    }
}
注意事项
  1. 资源泄漏风险:用 WNOHANG 的时候,要是没正确循环回收所有子进程,可能会堆一堆僵尸进程
  2. 信号干扰:信号可能会打断 waitpid (),这时候得检查 errno 是不是 EINTR,是的话就重新调用
  3. 状态解析:跟 wait () 完全一样,得用宏函数解析 status
总结
  • 选 wait ():简单场景,就等任意子进程结束,不用啥复杂控制
  • 选 waitpid ():复杂场景,比如需要:
    • 等特定子进程或进程组
    • 非阻塞轮询(比如并发服务器)
    • 跟踪子进程的暂停 / 恢复状态

再深入说说函数调用的详细过程

子进程的 PCB 里,有两个成员变量:int exit_code(退出码)和int exit_signal(退出信号)。

子进程退出时,会把这俩数字写到自己的 PCB 里。父进程调用 waitpid () 的时候,就会拿到这俩数字;调用完了,OS 才会释放目标 task_struct------ 这就是为啥进程结束时不能直接把 task_struct 删了。

waitpid () 不同模式的返回值:

  • 第三个参数是 0:父进程阻塞着等子进程返回退出信息
  • 第三个参数是 WNOHANG:非阻塞轮询等待。如果拿到了已退出子进程的状态,返回子进程 PID;拿到未退出的,返回 0;函数自己失败了,返回 - 1。

要是代码里只等一次,比如这样:

就会发现父进程问了一次就走了,子进程变成孤儿进程,被 init 程序领养了。所以要实现轮询,得程序员自己加循环!

补充个小知识

  • *.cc 是 C++ 代码文件格式
  • *.hpp 是头文件,允许把类、函数的声明和实现写在一起,能直接用 include 包含

用代码模拟非阻塞等待的场景

非阻塞等待的时候,父进程在等的同时能去执行其他任务,这样系统效率更高。咋模拟呢?

先定义个工具箱(Tool.hpp):里面放方法集的定义,比如新方法的写入、执行方式。

再定义个工具执行器(Task.hpp):这个头文件里写各个方法的具体实现。

然后把这俩加到之前的轮询代码里 ------ 运行之后就能看到,父进程最终成功回收了子进程。

四、进程程序替换

咱们之前说,fork 之后子进程执行的是父进程的一部分代码。那要是想让子进程执行一段全新的程序咋办?这就得用到 "进程程序替换" 了。

1. 程序替换原理

说白了就是把物理内存里的代码和数据,换成磁盘里新程序的 ------ 注意,这期间不会创建新进程(PID 不变),只是代码和数据换了,页表映射可能会因为代码 / 数据大小变化而调整。

而且子进程替换代码的时候,会触发类似写时拷贝的操作:系统给子进程新开辟代码段和数据段,把新程序的代码和数据导进去。这样才能保证父子进程相互独立,毕竟进程间独立性是很重要的性质!

说到这你肯定能联想到:咱们用 shell 的时候,其实一直在做进程替换!每次在命令行输个命令,shell 就 fork 一个子进程,子进程通过 exec 系列函数执行命令;父进程(bash)就用 waitpid 等子进程完事,回收它的退出信息 ------ 这就是为啥echo $?能拿到上一条命令的退出码。

2. 进程切换接口(exec 系列函数)

2.1 execl()

  • 特点:第一个参数是程序路径,后面跟命令参数,最后得用 NULL 结尾
  • 示例:
2.2 execv()
  • 特点:第一个参数是程序路径,第二个参数是参数数组(得用 NULL 结尾)
  • 示例:

还记得 main 函数的参数不?咱们可以借助它们,在命令行里完成进程转换。

2.3 execlp()

  • 特点:更方便,第一个参数直接写命令名,它会自己去环境变量 PATH 里找程序路径;第二个参数还是命令选项,用 NULL 结尾
  • 示例:
2.4 execvp()
  • 特点:结合了 execv 和 execlp 的优点,第一个参数是命令名(会去 PATH 里找),第二个参数是参数数组
  • 示例:

用这些函数,子进程既能执行系统命令,也能执行咱们自己写的程序 ------ 只要保证能正确找到可执行文件的路径和名称就行。

甚至还能在.c 文件里执行其他语言的代码!比如:

  1. 创建 mypy.py,写点 Python 代码
  1. 用 python 解释器执行这个脚本
  1. 在.c 文件里这么写:

这样就能在 C 文件里跑 Python 代码了!

2.5 execvpe()

这个函数跟 execvp 唯一的区别是:最后要加个环境变量表。默认传用户自己定义的,也能直接传系统的 env。

要是想传改过后的系统环境变量表,还得用putenv()函数。

其实上面说的这些都是库函数,系统调用里有个execve------ 所有 exec 系列库函数里都封装了它。

最后一个问题:有时候没给 main 函数传对应的参数,为啥还能拿到需要的信息?

因为这些信息在你的地址空间里,就像全局变量一样,就算不通过参数传,子进程照样能拿到!


那么以上就是本次学习分享的所有内容了~

非常感谢你能够看到这里!

如果感觉本文对你有帮助的话还请给个三连 这将会给我莫大的鼓舞!

后续我依旧会继续更新Linux的学习分享~

就让我们 下次再见!

相关推荐
Joey_Chen2 小时前
【What · Why · How】浅析select/poll/epoll与IO多路复用
linux·服务器
“αβ”2 小时前
线程安全的单例模式
linux·服务器·开发语言·c++·单例模式·操作系统·vim
gnawkhhkwang2 小时前
clock_nanosleep系统调用及示例
linux
渡我白衣3 小时前
综合:日志的实现
linux
嶔某3 小时前
网络:基础概念
linux·服务器·网络·c++
ArabySide3 小时前
【Linux】Ubuntu上安装.NET 9运行时与ASP.NET Core项目部署入门
linux·ubuntu·.net
字节高级特工4 小时前
【Linux篇】进程间通信:进程IPC
linux·运维·服务器
二向箔reverse4 小时前
在 CentOS 中安装 MySQL 的过程与问题解决方案
linux·mysql·centos
努力做小白4 小时前
Linux驱动22 --- RV1126 环境搭建&&设备树修改
linux·驱动开发·单片机·嵌入式硬件·rv1126
字节高级特工4 小时前
深入探索Linux:忙碌的车间“进程”间通信
linux·运维·服务器