1.进程创建

1.1 fork 函数回顾

在 Linux 中,fork 是一个非常重要的系统调用,用于从一个已存在的进程创建一个新进程。新创建的进程称为子进程,原进程称为父进程

函数原型:

#include <unistd.h>
pid_t fork(void);

返回值:

  • 在子进程中返回 0

  • 在父进程中返回子进程的 PID

  • 出错时返回 -1


内核执行流程

当进程调用 fork,控制权转移到内核中的 fork 代码后,内核会依次完成以下步骤:

  1. 分配新的内存块和内核数据结构给子进程

  2. 将父进程的部分数据结构内容拷贝至子进程

  3. 将子进程添加到系统进程列表中

  4. fork 返回,调度器开始调度


关键点补充

  • 子进程与父进程的数据是独立的(写时拷贝技术优化)

  • 子进程从 fork 调用后的下一条指令开始执行

  • 父子进程的执行顺序由调度器决定,无法预知

  • 父进程中返回的子进程 PID 用于后续管理(如 wait 等)

xqq@ubuntu-server:~/linux/moduleVI$ cat test_fork.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
    pid_t pid;
    printf("Before: pid is %d\n", getpid());
    if ( (pid=fork()) == -1 )perror("fork()"),exit(1);
    printf("After:pid is %d, fork return %d\n", getpid(), pid);
    sleep(1);
    return 0;
}
xqq@ubuntu-server:~/linux/moduleVI$ ./test_fork.exe
Before: pid is 943511
After:pid is 943511, fork return 943512
After:pid is 943512, fork return 0
这⾥看到了三⾏输出,⼀⾏before,两⾏after。进程943511先打印before消息,然后它有打印after。 另⼀个after消息有943512打印的。注意到进程943512没有打印before,为什么呢?如下图所⽰
所以,fork之前⽗进程独⽴执⾏,fork之后,⽗⼦两个执⾏流分别执⾏。注意,fork之后,谁先执⾏完 全由调度器决定。

1.2 写时拷⻉

这个在上一节博客详细讲过,这里回顾一下。

通常,⽗⼦代码共享,⽗⼦再不写⼊时,数据也是共享的,当任意⼀⽅试图写⼊,便以写时拷⻉的⽅式各⾃⼀份副本。具体⻅下图:

正是因为写时拷贝技术的存在,父子进程在 fork 之后才得以真正实现独立,为进程的独立性提供了核心技术保证。写时拷贝是一种延迟申请的优化策略,它能有效提高整机物理内存的利用率。

带来的好处

  • 节省内存:避免了不必要的完整拷贝,只在实际需要时按页分配。

  • 加快进程创建速度:大幅减少了 fork 时的处理时间和复制开销。

  • 提高独立性:在性能优化的同时,最终保证了父子进程拥有各自独立的内存空间,谁写谁复制。


1.3 fork 常规用法

根据父子进程接下来的任务是否相同,fork 主要有两种典型用法:

用法一:父子进程执行同一程序中的不同代码段

父进程复制自己,目的是让父子进程并行处理不同的任务。通常通过 fork 的返回值配合 if…else 进行分流。

  • 典型场景:网络服务器中,父进程持续监听并等待客户端请求;每当有新请求到达,就 fork 出一个子进程来专门处理该请求,而父进程则立刻返回继续监听。

用法二:子进程执行一个完全不同的程序

子进程从 fork 返回后,需要承担与父进程完全不同的职责。它会立即调用 exec() 系列函数,用一个新的程序替换当前进程的代码和数据。

  • 典型场景:一个通用控制程序需要启动特定的外部工具或任务。常见的如 Shell 执行命令,或守护进程启动辅助子程序时使用。

这两种用法的核心区别在于:

  • 用法一侧重于协作并行,父子共同完成一个完整功能的不同环节。

  • 用法二侧重于功能分离,子进程完全蜕变为另一个独立的程序实体。

1.4 fork调⽤失败的原因

fork 失败时会返回 -1,并设置 errno 来指示具体错误。主要原因可归纳为两类:

一、系统资源限制

  • 进程数量已达上限

    1. 系统总进程数超过了 /proc/sys/kernel/pid_max 的限制。

    2. 实际可用的 PID 已经耗尽。

  • 用户进程数超过限制

    • 当前用户创建的进程数超过了 ulimit -uRLIMIT_NPROC)设定的上限。

  • 内存不足

    • 系统没有足够的物理内存或交换空间来为子进程分配必要的内核数据结构(如 task_struct、内核栈等)。

二、系统级限制

  • 达到系统总进程数限制:整个系统的进程总数触发了内核参数 threads-max 的硬限制。

  • 特定环境限制:在某些特殊系统或容器环境中,可能存在额外的策略限制。

错误码速查


注意fork 失败通常不是因为编程错误,而是运行环境的资源限制导致的。排查时优先检查进程数上限和内存使用情况。

2.进程终止

进程终⽌的本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。

2.1 进程退出场景

进程退出的场景可以分为三大类:

一、代码运行完毕,结果正确

程序正常执行完所有逻辑,达到了预期目的后主动退出。

  • 通常以 return 0 或 exit(0) 结束,表示成功。

  • 退出码为 0 或被定义为 EXIT_SUCCESS 的值。

二、代码运行完毕,结果不正确

程序正常执行结束,但因输入、逻辑或环境等原因,未得到预期结果。

  • 通常以 return 非0 或 exit(非0) 结束,表示某种错误。

  • 退出码通常自定义,用来向父进程传递错误类型。

  • 常见约定:EXIT_FAILURE(通常为 1)表示一般性失败。

三、代码异常终止

程序未能正常执行至结束,而是被外部信号强制终止。

  • 常见情况:

    • 段错误(SIGSEGV)—— 访问非法内存

    • 浮点异常(SIGFPE)—— 除零等运算错误

    • 非法指令(SIGILL)—— 执行了无效指令

    • 被信号杀死(如 kill 发送 SIGKILLSIGTERM

  • 本质区别:异常终止属于被操作系统或硬件强制结束,而非程序自愿退出。


总结:前两种属于正常退出(程序主动结束),退出码反映了执行结果;第三种属于异常退出(被动接收信号),核心特征是进程收到了终止信号。

父进程创建子进程,通常是分配任务给子进程去执行。子进程完成任务后,父进程需要拿到以下反馈信息:

  1. 子进程是否正常结束
  2. 如果正常退出,执行结果如何
  3. 如果异常终止,是被哪个信号杀死的

2.2 进程常⻅退出⽅法

2.2.1 退出码

我们之前编写C/C++程序,main函数喜欢在末尾写return 0;main函数也是函数也要被别人调,那么main函数的返回值给了谁,返回值代表什么含义呢?

退出码(退出状态)可以告诉我们最后⼀次执⾏的命令的状态。在命令结束以后,我们可以知道命令是成功完成的还是以错误结束的。其基本思想是,程序返回退出代码 0 时表⽰执⾏成功,没有问题。代码 1 0 以外的任何代码都被视为不成功。

echo $?是打印最近一个程序(进程)的退出码,也就是进程退出码,这个退出码最后要写到自己task_struct内部的,后来子进程变成僵尸状态,父进程要从子进程的task_struct读取退出信息(exit_code)

演示:

xqq@ubuntu-server:~/linux/moduleVII$ cat proc.c
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main()
{
    printf("Hello world\n");
    FILE* fp=fopen("log.txt","r");
    if(fp==NULL) return errno;
    fclose(fp);
    return 0;
}
xqq@ubuntu-server:~/linux/moduleVII$ l
errnuminfo.c  errnuminfo.exe*  Makefile  proc.c  proc.exe*
xqq@ubuntu-server:~/linux/moduleVII$ ./proc.exe#父进程是bash,通过获取退出码来查看是否成功
Hello world
xqq@ubuntu-server:~/linux/moduleVII$ echo $?
2 #errnum:2->errinfo:No such file or directory
xqq@ubuntu-server:~/linux/moduleVII$ echo $? #说明echo $?成功了
0

关键点:如果没有僵尸状态,父进程还没来得及调用 wait,子进程的 task_struct 就被销毁了,父进程就永远无法获取退出码!

2.2.2正常终止的三种方式

方式一:从 main 函数返回

int main()
{
    int a=0;
    return 0;
}

本质: return 语句会调用 exit(main的返回值),由启动代码负责调用 exit()

特点:会执行清理操作并且会刷新 IO 缓冲区


方式二:调用 exit() 函数

函数原型:

调用 exit() 后,程序立即终止,不会返回原来的调用点。并返回子进程退出吗给父进程bash

xqq@ubuntu-server:~/linux/moduleVII$ cat proc.c
#include <stdio.h>
#include <stdlib.h>
void fun()
{
    printf("I will begin\n");
    exit(40);
    printf("fun end\n");
}
int main()
{
    fun();
    printf("after fun\n");
    return 0;
}
xqq@ubuntu-server:~/linux/moduleVII$ ./proc.exe
I will begin
xqq@ubuntu-server:~/linux/moduleVII$ echo $?
40

特点: 会执行清理函数(atexit 注册的)、刷新 IO 缓冲区、可以在程序的任何地方调用


方式三:调用 _exit() / _Exit() 函数

函数原型:

xqq@ubuntu-server:~/linux/moduleVII$ cat proc.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
    printf("main!");//不带\n刷新缓冲区
    sleep(2);
    _exit(40);
    return 0;
}
xqq@ubuntu-server:~/linux/moduleVII$ ./proc.exe#没有打印main!
xqq@ubuntu-server:~/linux/moduleVII$ echo $?
40

特点:不执行 atexit 清理函数、不刷新 stdio 缓冲区(可能导致输出丢失)、不关闭标准 IO 流(由内核关闭)、最轻量、最快的退出方式

_exit VS exit :exit 是 C 标准库对系统调用 _exit 的封装,它本身只负责执行用户态清理(冲刷缓冲区、调用 atexit),最终必须依赖 _exit 陷入内核才能真正终止进程

三种方式的核心区别对比表

总结

2.2.3Linux Shell 中的主要退出码:

2.2.4 将退出码转化为错误描述方式

  • 使用strerror()函数

2.2.5异常终止时退出码的计算规则

xqq@ubuntu-server:~/linux/moduleVII$ cat proc.c
#include <stdio.h>
int main()
{
    int b= 10;
    b/=0;//异常
    return 23;//退出码无意义
}
xqq@ubuntu-server:~/linux/moduleVII$ ./proc.exe
Floating point exception (core dumped)
xqq@ubuntu-server:~/linux/moduleVII$ echo $?
136
  1. 代码中有除零错误b /= 0;(整数除法除以零)

  2. 操作系统检测到非法操作,向进程发送 SIGFPE 信号(Floating Point Exception,实际上也包括整数除零)

  3. 进程被信号杀死(异常终止,不是正常退出)

  4. Shell 计算退出码128 + 信号编号 = 136

一旦程序出现异常,一般是进程收到信号

3.进程等待

之前我们说过僵尸进程是一种内存泄漏,下面将讲解怎么处理僵尸进程

3.1 进程等待的必要性

1. 回收子进程资源,防止僵尸进程

  • 子进程结束时,其大部分资源会被操作系统回收,但仍会保留少量信息(如进程 ID、退出状态、CPU 时间等)存放在内核的进程表中,等待父进程读取。

  • 如果父进程不调用 wait() 或 waitpid(),这些已终止的子进程就会变成僵尸进程(Z)

  • 僵尸进程虽然几乎不占用内存,但会占用进程表项(PID 资源),如果大量积累,会导致系统无法创建新进程。

必要性核心1:防止资源泄漏和系统过载


2. 获取子进程的退出状态

  • 父进程通常需要知道子进程的任务是否成功完成,例如:

    • 子进程正常退出(exit(0) 或 return 0)还是异常终止(如段错误、被信号杀死)

    • 子进程的返回值或错误码

  • 通过 wait() 或 waitpid() 可以获取子进程的退出状态信息

必要性核心2:实现父子进程之间的同步与结果传递


3. 控制进程执行顺序(同步)

  • 父进程可能需要等待子进程完成某个任务后再继续执行(例如:编译程序 → 运行编译结果)。

  • wait() 会让父进程阻塞,直到子进程结束,从而实现顺序协调。

必要性核心3:实现进程间的简单同步机制


4. 避免孤儿进程和资源抢占

  • 如果不等待子进程,父进程可能提前退出,导致子进程变成孤儿进程(被 init/systemd 进程收养)。虽然通常无害,但在某些系统(如实时系统或嵌入式系统)中可能造成问题。

  • 在某些设计下,父进程需要显式等待子进程才能安全释放共享资源(如文件锁、共享内存)。

必要性核心4:保证资源生命周期管理的确定性

3.2 处理僵尸进程的方法

案例:子进程运行 5 秒后退出,父进程睡眠 100 秒,此时子进程成为僵尸进程(因为父进程没有调用 wait()

xqq@ubuntu-server:~/linux/moduleVII$ cat proc.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
    pid_t id =fork();
    if(id==0)
    {
        int cnt =5;
        while(cnt--)
        {
            printf("I am child, pid:%d,ppid:%d\n",getpid(),getppid());
            sleep(1);
        }
        exit(0);
    }
    //子进程直接退出,在这里就是父进程
    sleep(100);
    return 0;
}
xqq@ubuntu-server:~/linux/moduleVII$ ./proc.exe
I am child, pid:977252,ppid:977251
I am child, pid:977252,ppid:977251
I am child, pid:977252,ppid:977251
I am child, pid:977252,ppid:977251
I am child, pid:977252,ppid:977251

运行结果:

wait()和waitpid()原型:

3.2.1 wait()方法

wait() 是用于等待任意一个子进程状态改变的系统调用,通常用于等待子进程终止。

参数:status(wstatus)

指向整数的指针,用于接收子进程的退出状态信息,如果为 NULL,表示不关心退出状态

返回值:

常见的 errno 值:

  • ECHILD:调用进程没有子进程

  • EINTR:被信号中断


行为特点:

  • 阻塞等待如果没有子进程结束,父进程会一直阻塞

  • 等待任意子进程:一旦任意一个子进程状态改变(终止、暂停、恢复),wait() 就会返回

  • 自动回收僵尸进程:成功调用后,子进程的僵尸状态被清除


status 状态解析宏

status/wstatus 并不是单纯存储退出码,它是一个复合状态字,把多种进程终止信息打包进了一个整数里。

注意:虽然 int 有 32 位bit,但 wstatus 只用了低 16 位,高 16 位是闲置的,当低七位均为0(也就是代码跑完了,没有被信号杀死)才会去查退出状态,这也是 WIFEXITED 这个宏判断的本质。

其中的core dump是来标识当进程死的时候,有没有生成core dump文件

  • 1:有,磁盘上某处已经写了一个 core 文件,你可以去分析

  • 0:没有,啥也没留下

通过 status 可以获取子进程的退出信息,需要使用以下宏:

这些宏的典型实现(简化版)如下,可以看到它们就是位运算的组合:

// 判断是否为正常退出(低字节的终止信号字段为0)
#define WIFEXITED(status)   (((status) & 0x7f) == 0)

// 提取退出码(存储在高8位)
#define WEXITSTATUS(status) (((status) >> 8) & 0xff)

// 判断是否被信号杀死
#define WIFSIGNALED(status) (((signed char) ((status) & 0x7f) + 1) >> 1) > 0

// 提取终止信号
#define WTERMSIG(status)    ((status) & 0x7f)

// 判断是否产生core dump
#define WCOREDUMP(status)   ((status) & 0x80)

注意:信号本质也是宏,通过让信号编号从 1 开始,使得检查进程是否正常退出变得极其高效

#define SIGHUP  1
#define SIGINT  2
#define SIGQUIT 3
#define SIGILL  4
#define SIGTRAP 5
#define SIGABRT 6 //...

示例代码:

xqq@ubuntu-server:~/linux/moduleVII$ cat proc.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
    pid_t id =fork();
    if(id==0)
    {
        int cnt =5;
        while(cnt--)
        {
            printf("I am child, pid:%d,ppid:%d\n",getpid(),getppid());
            sleep(1);
        }
        exit(0);
    }
    //子进程直接退出,在这里就是父进程
    sleep(10);
    pid_t rid = wait(NULL);//return pid NULL不关心退出状态
    if(rid>0)
    {
        printf("wait succes,rid:%d\n",rid);
    }
    else
    {
        printf("wait fail\n");
    }
    sleep(10);
    return 0;
}
xqq@ubuntu-server:~/linux/moduleVII$ ./proc.exe
I am child, pid:979941,ppid:979940
I am child, pid:979941,ppid:979940
I am child, pid:979941,ppid:979940
I am child, pid:979941,ppid:979940
I am child, pid:979941,ppid:979940
wait succes,rid:979941
xqq@ubuntu-server:~/linux/moduleVII$ 

结果:

总结:wait() 是简单但有限的进程等待接口、必须调用它(或 waitpid())来防止僵尸进程、如果需要等待特定子进程或非阻塞等待,应使用下面介绍的 waitpid()

3.2.2 waitpid()方法

waitpid() 是 wait() 的增强版本,提供了更灵活的进程等待控制。


参数详解

参数1:pid_t pid - 指定等待哪个子进程

参数2:int *wstatus - 退出状态指针

  • 与 wait() 的 wstatus 参数完全相同

  • 可以用相同的宏解析(WIFEXITEDWEXITSTATUS 等)

  • 传入 NULL 表示不关心退出状态

参数3:int options - 控制选项(可以组合使用)


返回值

常见 errno:

  • ECHILD:没有匹配的子进程

  • EINTR:被信号中断

  • EINVAL:options 参数无效


基础示例:等待指定子进程

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
    pid_t pid1 = fork();
    if (pid1 == 0)
    {
        // 第一个子进程
        printf("Child 1 (PID: %d) running...\n", getpid());
        sleep(2);
        exit(10);
    }
    pid_t pid2 = fork();
    if (pid2 == 0)
    {
        // 第二个子进程
        printf("Child 2 (PID: %d) running...\n", getpid());
        sleep(5);
        exit(20);
    }
    // 父进程:只等待特定的子进程(pid1)
    int wstatus;
    pid_t rid = waitpid(pid1, &wstatus, 0);
    if (WIFEXITED(wstatus))
        printf("Waited for child %d, exit code: %d\n", rid, WEXITSTATUS(wstatus));
    
    // 等待另一个子进程(如果不等待会变僵尸)
    waitpid(pid2, NULL, 0);
    return 0;
}

输出:

xqq@ubuntu-server:~/linux/moduleVII$ ./proc.exe
Child 1 (PID: 992622) running...
Child 2 (PID: 992623) running...
(2秒后)
Waited for child 992622, exit code: 10
(3秒后)
(进程结束)
xqq@ubuntu-server:~/linux/moduleVII$ 

非阻塞等待示例(WNOHANG)

WNOHANG 本质上也是一个宏,被定义为整数常量。

// 在 <sys/wait.h> 中
#define WNOHANG     1   // 不阻塞,立即返回
#define WUNTRACED   2   // 也返回已停止的子进程状态
#define WCONTINUED  4   // 也返回已继续的子进程状态

这个示例结合非阻塞轮询给出,具体参考下面业务实战


wait() 与 waitpid() 对比


3.2.3 常见使用模式

模式1:非阻塞轮询

pid_t rid;
int wstatus;

while ((rid = waitpid(-1, &wstatus, WNOHANG)) == 0)
{
    // 子进程还在运行,可以做其他事情
    printf("Parent working...\n");
    sleep(1);
}

张三(父进程)手里有一道 waitpid() 这个唯一的“联系方式”。他第一次打电话前在心里默念了参数:pid 是李四的手机号,options 是 WNOHANG(非阻塞模式)。电话一接通,这句“我们复习吧”就是一次系统调用。李四(子进程)在电话那头立刻回答“我在复习数据结构”,这就等于 waitpid() 返回了 0——表示人还活着,但还没完事。张三没有傻等,主动挂断电话,CPU 控制权又回到张三手里,他可以先去干点别的(比如自己翻书)。

过了一会,张三心里发慌,又用同样的参数拨过去。这叫轮询(Polling,本质就是循环)。每一次都是全新的 waitpid() 调用,每一次李四都回答“还没好”,每一次都返回 0。这个循环里,张三在“打电话-挂断-干等-再打”之间反复横跳,CPU 时间就在一次次无果的系统调用中被消耗掉。他的焦虑和徒劳,恰恰就是轮询的代价——忙等,没消息也得反复查。

终于,最后一次电话接通,李四说“我好了,走吧”。这一次 waitpid() 返回的是李四的 PID(一个大于 0 的数),表示子进程状态已改变,成功回收。张三听完立刻挂电话,两人见面——僵尸进程被收割,资源释放。

这个故事如果再延伸一下:如果张三第一次打电话时没用 WNOHANG,而是用 0(阻塞模式),那电话就不会挂断,他会一直举着手机等到李四说“我好了”为止,这就是阻塞等待

模式2:等待所有子进程(非阻塞版)

int wstatus;
pid_t rid;

while ((rid = waitpid(-1, &wstatus, WNOHANG)) > 0)
{
    printf("Reaped child: %d\n", rid);
}
// rid == -1 时表示没有更多子进程

模式3:带超时的等待

int timeout = 10;
int wstatus;

while (timeout > 0)
{
    pid_t rid = waitpid(pid, &wstatus, WNOHANG);
    
    if (rid == pid)
    {
        printf("Child exited\n");
        break;
    }
    
    sleep(1);
    timeout--;
}

if (timeout == 0)
{
    printf("Timeout! Killing child...\n");
    kill(pid, SIGTERM);
}

业务实战:事件循环 + 异步回收子进程模型

主进程不阻塞,一边轮询检查子进程状态,一边处理自己的业务逻辑

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define NUM 5
typedef void (*func_t)();//函数指针类型
func_t handlers[NUM+1]={NULL};//防止越界,这里是全局变量,默认为空,但是为了增加可读性还是建议初始化
//下面是任务
void Download()
{
    printf("我是一个下载任务...\n");
}

void Flush()
{
    printf("我是一个刷新任务...\n");
}

void Log()
{
    printf("我是一个记录日志任务...\n");
}
//注册
void registerHandler(func_t h[],func_t f)
{
    int i=0;
    for(;i<NUM;i++)
    {
        if(h[i]==NULL)
            break;
    }
    if(i==NUM)return;//满了
    h[i]=f;
    h[i+1]=NULL;
}
int main()
{
    //注册任务
    registerHandler(handlers,Download);
    registerHandler(handlers,Flush);
    registerHandler(handlers,Log);
    pid_t id =fork();
    if(id==0)
    {
        while(1)
        {
            sleep(3);
            printf("我是一个子进程:%d,ppid:%d\n",getpid(),getppid());
            sleep(1);
        }
        exit(10);
    }
    while(1)
    {
        int status=0;
        pid_t rid = waitpid(id,&status,WNOHANG);//非阻塞模式
        if(rid>0)
        {
            printf("wait success,rid:%d,exit code:%d,exit signal:%d\n",rid,WEXITSTATUS(status),WTERMSIG(status));
            break;
        }
        else if(rid==0)
        {
            //函数指针回调
            for(int i=0;handlers[i];i++)
            {
                handlers[i]();
            }
            printf("本轮调用结束,子进程没退出\n");
            sleep(1);
        }
        else
        {
            printf("等待失败\n");
            break;
        }
    }
    return 0;
}

运行结果:


3.2.4注意事项


总结

waitpid() 的优势:

  1. 精确控制:等待指定的子进程

  2. 非阻塞:可以轮询检查子进程状态

  3. 进程组管理:可以等待整个进程组

  4. 更灵活:支持捕获暂停/恢复事件

常见的几个问题:

  • 父进程是怎么拿到子进程的退出信息的?

父进程通过 waitpid() 拿到子进程退出信息的过程,本质上是内核充当了一个“死亡登记处”

子进程终结时,内核会立即释放它的用户态内存(代码、数据、堆栈),但会在内核空间里为它保留一个“墓碑”——也就是 task_struct,并将其状态设为 Z(僵尸)。这个结构中存着子进程的exit_code,它的低7位记录“死因”(0代表自然死亡,非0则是杀死它的信号编号),高8位记录“遗言”(也就是退出码),bit 7 记录“是否保留尸检报告”(core dump)。此后,子进程唯一要做的就是等待父进程来“收尸”。

当父进程调用 waitpid() 踏入内核,内核就会在它的“已亡故子女”中查找。一旦找到指定的僵尸子进程,内核就把它的 exit_code 原封不动地拷贝到父进程用户空间的 wstatus 变量里,这个动作就是把“墓碑”上的信息誊写给了父进程。紧接着,内核会销毁这个 task_struct,子进程的 PID 和所有痕迹才被彻底清除。

所以说,wstatus 并不是父进程凭空计算出来的,而是内核从子进程的“死亡档案”(task_struct)里抄录过来的。waitpid() 就是那个抄录动作,而 WIFEXITEDWEXITSTATUS 这些宏,则是我们拿到的用来解读这份“死亡报告”的密码本。

  • 我们在命令行输入sleep 1000为什么命令行会卡住
xqq@ubuntu-server:~/linux/moduleVII$ sleep 1000
ls 
pwd
cd ..
^C

由于sleep也是命令,运行起来就是进程,其父进程就是bash,当sleep运行期间,bash就在阻塞等待,bash 只有一个主线程,它正堵在 waitpid() 里在终端敲 ls,字符确实被终端驱动接收了但 bash 根本没在读输入——它还卡在 waitpid() 系统调用里,CPU 控制权在睡眠,只有当 sleep 1000 结束或被 Ctrl+C 杀掉,waitpid() 返回,bash 才会回来读输入

4.进程程序替换

先给一个简单的例子:

xqq@ubuntu-server:~/linux/moduleVII$ cat procreplace.c
#include<stdio.h>
#include<unistd.h>
int main()
{
    printf("my process is running\n");
    execl("/usr/bin/ls","ls","-a","-l",NULL);
    printf("my process end\n");//不执行
    return 0;
}
xqq@ubuntu-server:~/linux/moduleVII$ ./procreplace.exe
my process is running
total 80
drwxrwxr-x  3 xqq xqq  4096 May 12 15:51 .
drwxrwxr-x 12 xqq xqq  4096 May 10 19:43 ..
-rw-rw-r--  1 xqq xqq   183 May 10 20:39 errnuminfo.c
-rwxrwxr-x  1 xqq xqq 16056 May 10 20:39 errnuminfo.exe
-rw-rw-r--  1 xqq xqq    66 May 10 20:37 Makefile
-rw-rw-r--  1 xqq xqq  3605 May 12 00:14 proc.c
-rwxrwxr-x  1 xqq xqq 16432 May 12 10:14 proc.exe
-rw-rw-r--  1 xqq xqq   183 May 12 15:51 procreplace.c
-rwxrwxr-x  1 xqq xqq 16008 May 12 15:51 procreplace.exe
drwxrwxr-x  2 xqq xqq  4096 May 12 10:07 .vscode
procreplace.exe 这个进程,在调用 execl 之前还在执行你的代码,execl 一成功,它的代码段、数据段、堆、栈全部被 ls 覆盖。PID 还是那个 PID,但进程的"灵魂"已经换成了 ls。这就是进程程序替换。

4.1 进程程序替换

一、引入:为什么要程序替换?

前面学到,fork() 可以创建子进程,但子进程只是父进程的副本——代码一样、数据一样,只能靠 if-else 区分身份来执行不同分支。如果想在子进程里运行一个完全不同的程序(比如在 shell 里敲 ls),光靠 fork 不够,还需要程序替换


二、概念

进程程序替换,是指一个进程在执行期间,用磁盘上的一个全新程序,替换当前正在执行的程序。

  • 当前进程的用户空间代码段、数据段、堆、栈全部被新程序覆盖

  • 替换完成后,从新程序的入口(main 或 _start)开始执行

  • 调用 exec 不创建新进程,进程的 PID 不变

关键结论:


三、原理:OS 如何完成替换?

整个替换过程由操作系统内核完成,用户进程只需发起一次 exec 系统调用,剩下的工作由内核接手。核心步骤:

第 1 步:加载新程序

进程调用 exec → 陷入内核 → 内核根据传入的文件路径,找到磁盘上的可执行文件,将其代码和数据读入内存。

谁来做这件事? 磁盘和内存都是硬件,只有操作系统有权限和有能力,将数据从磁盘这个硬件,搬运到内存这个硬件。进程自己是做不到的。

第 2 步:替换当前程序(覆盖地址空间)

内核收回旧程序的用户空间内存,把新程序的代码段、数据段、堆、栈,按照 ELF 文件格式的要求,在内存中重新布局。

旧程序的代码、数据、堆、栈全部被释放并覆盖

第 3 步:更新页表

CPU 访问内存靠的是页表——页表记录了虚拟地址到物理地址的映射。

旧程序的页表映射着旧程序的代码和数据,替换后内核更新页表,让同样的虚拟地址空间,映射到新程序的物理内存上。从此 CPU 取指、访存,拿到的都是新程序的内容。

第 4 步:执行新程序

内核设置用户态栈(包括命令行参数 argv、环境变量 envp),然后修改 task_struct 中保存的 CPU 上下文,将程序计数器指向新程序的入口地址(通常是 _start),从内核态返回用户态时,CPU 就从新程序的入口开始执行。


四、本质:进程 = 加载器

从上面的过程可以看出,程序替换的本质工作就是"加载"

正常启动一个程序(比如在 shell 里执行 ls),完整链路是:

shell ──fork()──▶ 子进程 ──exec("ls")──▶ ls 运行
                      │                    │
                      └─ 空壳(只有 PCB)    └─ 被 exec 注入灵魂

fork 创建了一个只有进程描述符(task_struct)的空壳,exec 才真正把程序从磁盘装入内存,赋予这个进程执行的能力。

所以可以这样理解:

exec 就是进程空间的"装载机"——它把磁盘上沉睡的程序唤入内存,装进一个已经存在的进程躯壳里,让它跑起来。它不创造新生命(不新建进程),只负责更换灵魂(替换程序)。

 

五、小结

4.2 程序替换函数exec系列

exec 不是一个函数,而是一组函数,统称为 exec 系列。它们功能相同——用新程序替换当前进程,区别在于参数传递方式程序查找方式


一、六个 exec 函数

#include <unistd.h>

int execl (const char *path,   const char *arg0, ... /*, (char *)NULL */);
int execv (const char *path,   char *const argv[]);
int execle(const char *path,   const char *arg0, ... /*, (char *)NULL, char *const envp[] */);
int execve(const char *path,   char *const argv[], char *const envp[]);
int execlp(const char *file,   const char *arg0, ... /*, (char *)NULL */);
int execvp(const char *file,   char *const argv[]);

二、命名规律

函数名遵循 exec + 后缀 的规律,即我要执行谁,要怎么执行它。每个字母有明确含义:

记忆技巧:

  • l → list → 一个一个传参,最后加 NULL

  • v → vector → 打包成数组传进去

  • p → PATH → 可以只写文件名,不用写完整路径

  • e → environment → 可以自己指定环境变量


三、六个函数的区别

核心结论:

只有 execve 是真正的系统调用,其余五个都是库函数,最终都要调用 execve


四、各参数详解

1. path(完整路径)vs file(文件名)

// 需要完整路径:path
execl ("/usr/bin/ls", "ls", "-l", NULL);   // 必须写全路径
execl ("ls", "ls", "-l", NULL);            // 找不到

// 只需文件名:file(带 p 的函数)
execlp("ls", "ls", "-l", NULL);            // 从 PATH 里找
execlp("/usr/bin/ls", "ls", "-l", NULL);   // 写全路径也行

带 p 的两个函数(execlpexecvp)会在 PATH 环境变量列出的目录中搜索:

  • 如果 file 包含 /,直接当路径处理

  • 如果不包含 /,去 PATH 里挨个目录找

2. 参数列表 l vs 参数数组 v

// l 风格:一个一个写,最后必须以 NULL 结尾
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);

// v 风格:先组装数组,再传进去
char *argv[] = {"ls", "-a", "-l", NULL};
execv("/usr/bin/ls", argv);

注意: 不管是 l 还是 v,第一个参数 argv[0] 都应该是程序名(约定,可以随便写,但建议写程序名)。

3. 环境变量 e

// 没有 e:自动继承父进程的全部环境变量
execl("/usr/bin/env", "env", NULL);

// 有 e:自定义环境变量,父进程的环境变量一个都不保留
char *envp[] = {"PATH=/usr/bin", "HOME=/home/xqq", "USER=xqq", NULL};
execle("/usr/bin/env", "env", NULL, envp);

带 e 适用于需要给子程序传递特定环境变量的场景,如修改 LD_LIBRARY_PATH 来加载自定义动态库。


五、返回值

情况 返回值
成功 不返回(原程序代码已被覆盖,无"回来"的可能)
失败 返回 -1,设置 errno,原程序继续执行

所以判断 exec系列 是否成功的唯一方式是:

execl("/bin/ls", "ls", NULL);
// 能执行到这里,一定失败了
perror("execl");
exit(1);

六、代码对比

l 版本 vs v 版本

// execl:参数展开写
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);

// execv:参数用数组
char *argv[] = {"ls", "-a", "-l", NULL};
execv("/usr/bin/ls", argv);

完整路径 vs 文件名

// 不带 p:必须写路径
execl("/usr/bin/ls", "ls", "-l", NULL);

// 带 p:可以只写文件名
execlp("ls", "ls", "-l", NULL);

默认环境 vs 自定义环境

// 不带 e:继承父进程环境
execl("/usr/bin/env", "env", NULL);

// 带 e:自定义环境
char *envp[] = {"PATH=/usr/bin", NULL};
execle("/usr/bin/env", "env", NULL, envp);

七、总结

需要什么 用哪个函数
最常用,简单粗暴 execl
参数很多,用数组管理 execv
只写程序名,不写路径 execlp 或 execvp
要用自定义环境变量 execle 或 execve
记住只有一个真身就行 execve(系统调用)

所有 exec 函数成功都不返回,失败返 -1。调用 exec 是单行道,一旦成功,旧程序的代码和数据就不复存在——对它来说,没有回头路,只有新旅程。

4.3子进程替换——fork + exec + wait 模式

xqq@ubuntu-server:~/linux/moduleVII$ cat procreplace.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
    pid_t id = fork();

    if(id == 0)
    {
        // 子进程
        printf("i am a child, i will be replaced\n");
        sleep(1);
        execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
        exit(1);  // 只有 exec 失败才会执行
    }

    // 父进程
    waitpid(-1, NULL, 0);
    printf("the process is finished\n");
}
xqq@ubuntu-server:~/linux/moduleVII$ ./procreplace.exe
i am a child,i will be replaced
total 80
drwxrwxr-x  3 xqq xqq  4096 May 12 18:53 .
drwxrwxr-x 12 xqq xqq  4096 May 10 19:43 ..
-rw-rw-r--  1 xqq xqq   183 May 10 20:39 errnuminfo.c
-rwxrwxr-x  1 xqq xqq 16056 May 10 20:39 errnuminfo.exe
-rw-rw-r--  1 xqq xqq    66 May 10 20:37 Makefile
-rw-rw-r--  1 xqq xqq  3605 May 12 00:14 proc.c
-rwxrwxr-x  1 xqq xqq 16432 May 12 10:14 proc.exe
-rw-rw-r--  1 xqq xqq   525 May 12 18:53 procreplace.c
-rwxrwxr-x  1 xqq xqq 16176 May 12 18:53 procreplace.exe
drwxrwxr-x  2 xqq xqq  4096 May 12 10:07 .vscode
the process is finshed

这个程序就是 shell 运行命令的最小原型

shell 的工作原理:

  1.   fork()  → 创建子进程(躯壳)
  2.   exec()  → 子进程程序替换(注入灵魂)
  3.   wait()  → 父进程等待子进程结束(收尸)

 fork + exec + wait,就是每次在终端敲命令时,bash 在背后做的事情——一模一样的三个步骤。

问题1:为什么子进程进行替换,父进程无任何影响?

核心原因:进程具有独立性。

问题2:shell 是如何执行起来一个指令的?

以输入 ls -l -a 为例,shell 内部经历五个步骤:

① 读取输入
   用户输入 ls -l -a,shell 从标准输入读入字符串
② 解析命令
   将字符串按空格切分 → argv[] = {"ls", "-l", "-a"},判断是内置命令还是外部命令
③ 创建子进程
   fork() → 产生一个与 shell 几乎完全相同的子进程
④ 程序替换
   子进程调用 exec("ls") → 自己的代码被 ls 替换 ,内核加载 ls 的代码和数据,更新页表
⑤ 等待回收
   父进程(shell)调用 waitpid() 阻塞等待,子进程结束后被回收,shell 重新显示提示符

在终端敲的每一条外部命令,都是 shell 用 fork + exec + wait 这个模式帮你执行的。敲命令,其实就是让 shell 创建一个替身去跑那个程序。下一篇博客将简单实现一个shell来加深理解

问题3:exec 可以执行我们自己编写的程序吗?

可以,完全没有限制。

exec 只认可执行文件格式(Linux 下是 ELF 格式),不关心这个文件是怎么来的。

唯一条件:文件必须有可执行权限,用 chmod +x 赋予。

4.4 环境变量如何传⼊

一、环境变量来源

在 Linux 中,每个进程的环境变量有三个可能的来源:

来源 说明
继承 从父进程拷贝过来的
自定义 调用带 e 的 exec 函数显式传入
系统默认 某些变量由内核或 shell 初始化时设置(如 PATHHOME

二、两种传入方式的本质区别

1. 不带 e:自动继承(默认行为)

execl ("/usr/bin/env", "env", NULL);   // 继承父进程环境
execv ("/usr/bin/env", argv);          // 继承父进程环境
execlp("env", "env", NULL);            // 继承父进程环境
execvp("env", argv);                   // 继承父进程环境

发生了什么:
内核在替换时,自动将当前进程的环境变量表(存储在 environ 全局变量指向的数组)原封不动地传给新程序。新程序通过 main(int argc, char *argv[], char *envp[]) 的第三个参数或 extern char **environ 就能拿到。

fork 复制环境变量,exec 无差别继承——这是父子进程传递环境变量的默认路径。

 

即使不这个environ ,由于子进程会继承父进程,会将虚拟地址空间拷贝过去,子进程在命令行参数环境变量区域也能找到。

这就是 fork 的写时拷贝 + 不带 e 的 exec = 环境变量自动传递的根本原因。


2. 带 e:显式自定义(完全覆盖)

char *envp[] = {
    "PATH=/usr/bin",
    "HOME=/home/xqq",
    "USER=xqq",
    NULL          // 必须以 NULL 结尾
};

execle ("/usr/bin/env", "env", NULL, envp);  // 只传自定义的
execve ("/usr/bin/env", argv, envp);         // 只传自定义的

execve ("/usr/bin/env", argv, envp); 参数:

发生了什么:
内核完全忽略当前进程的环境变量表,直接把你构造的 envp 数组当成新程序的环境变量。父进程原来的 SHELLLANG 等统统丢失。


三、代码验证

#include <stdio.h>
#include <unistd.h>
int main()
{
    char *envp[] = {
        "MY_VAR=hello_from_exec",
        "PATH=/usr/bin",
        NULL
    };

    printf("//////传给子进程的环境变量//////\n");
    pid_t id = fork();
    if(id == 0)
    {
        // 带 e:只传自定义的 2 个环境变量
        execle("/usr/bin/env", "env", NULL, envp);
        perror("execle failed");
    }
    else
    {
        waitpid(id, NULL, 0);
    }
    return 0;
}

输出:

xqq@ubuntu-server:~/linux/moduleVII$ ./send_env.exe
//////传给子进程的环境变量//////
MY_VAR=hello_from_exec
PATH=/usr/bin

HOMEUSERSHELL 全都没了——因为被 envp 完全覆盖了。


fork + execve 参数传递实验:

xqq@ubuntu-server:~/linux/moduleVII/test_send_args$ ll
total 48
drwxrwxr-x 2 xqq xqq  4096 May 13 00:41 ./
drwxrwxr-x 4 xqq xqq  4096 May 13 00:18 ../
-rw-rw-r-- 1 xqq xqq   409 May 13 00:23 print_all_args.c
-rwxrwxr-x 1 xqq xqq 16056 May 13 00:41 print.exe*
-rwxrwxr-x 1 xqq xqq 16312 May 13 00:41 send.exe*
-rw-rw-r-- 1 xqq xqq   654 May 13 00:38 test_send.c
xqq@ubuntu-server:~/linux/moduleVII/test_send_args$ cat test_send.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

int main()
{
    // 自定义命令行参数
    char *argv[] = {
        "print.exe",
         "-a", "-b","-c","-d", NULL};

    // 自定义环境变量
    char *envp[] = {
        "MY_NAME=xqq",
        "MY_LANG=C",
        NULL
    };

    pid_t id = fork();
    if(id == 0)
    {
        // 子进程:用自定义 argv 和 envp 替换
        printf("子进程pid :%d\n",getpid());
        execve("./print.exe", argv, envp); #从这里开始,send.exe 的代码就被覆盖了
        perror("execve failed");
        exit(1);#只有 execve 失败才会执行
    }

    waitpid(id, NULL, 0);// 阻塞等待子进程结束
    printf("父进程回收完毕\n");

    return 0;
}
xqq@ubuntu-server:~/linux/moduleVII/test_send_args$ cat print_all_args.c
#include<stdio.h>
#include<unistd.h>
int main(int argc,char*argv[],char*envp[])
{
    printf("I am a replace proc,i will print all args\n");
    printf("mypid is :%d\n",getpid());
    printf("argv:\n");
    for(int i=0;i<argc;i++)
    {
        printf("argv[%d]:%s\n",i,argv[i]);
    }
    printf("envp:\n");
    for(int i=0;envp[i];i++)
    {
        printf("envp[%d]:%s\n",i,envp[i]);
    }
    return 0;
}
xqq@ubuntu-server:~/linux/moduleVII/test_send_args$ ./send.exe
子进程pid :1001267
I am a replace proc,i will print all args
mypid is :1001267 #进程还是那个进程,只是代码和数据被换了。这就是"程序替换"——躯壳不变,灵魂更换。
argv:
argv[0]:print.exe
argv[1]:-a
argv[2]:-b
argv[3]:-c
argv[4]:-d
envp: #带 e 的 execve 完全覆盖环境变量
envp[0]:MY_NAME=xqq
envp[1]:MY_LANG=C
父进程回收完毕
xqq@ubuntu-server:~/linux/moduleVII/test_send_args$ 

 

四、如果想在继承的基础上增改怎么办?

带 e 的函数是完全覆盖,但我们可以手动实现"继承 + 增改":

方式一:用 setenv 修改,再用不带 e 的 exec

// 子进程中
setenv("MY_VAR", "custom_value", 1);  // 修改当前进程的环境变量
execl("/usr/bin/env", "env", NULL);   // 不带 e,自动继承(含修改过的)

方式二:手动拼接环境变量数组

extern char **environ;

// 先拷贝一份当前的环境变量
int count = 0;
while(environ[count]) count++;

char **new_env = malloc((count + 3) * sizeof(char*));
for(int i = 0; i < count; i++)
    new_env[i] = environ[i];

// 追加自己的变量
new_env[count++] = "MY_VAR=hello";
new_env[count++] = "ANOTHER_VAR=world";
new_env[count] = NULL;

execve("/usr/bin/env", argv, new_env);

方式三:putenv函数

这个函数就是谁调它,那就在谁的环境变量列表里新增环境变量

#include <stdlib.h>
int putenv(char *string);
  • 参数:一个格式为 "KEY=VALUE" 的字符串

  • 返回值:成功返回 0,失败返回非零值

  • 注意:传入的字符串不会被拷贝putenv 直接把这个指针加入环境变量表


五、总结

核心原则:不带 e 是继承,带 e 是覆盖。fork 负责复制环境变量表,exec 决定用哪一份——自己的还是新造的。

 

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐