目录
- 一、进程创建
- 二、进程终止
-
- 1.进程终止是在做什么?
- 2.进程终止的三种情况
- 3.退出码和退出信号
-
- [3.1 进程退出的两种方式](#3.1 进程退出的两种方式)
- [3.2 什么是退出码?](#3.2 什么是退出码?)
- [3.3 为什么父进程要关心子进程的退出码](#3.3 为什么父进程要关心子进程的退出码)
- 4.如何终止进程?
- 三、进程等待
-
- 1.为什么需要等待?
- 2.如何等待?
-
- [2.1 `wait`函数](#2.1
wait函数) - 2.2`waitpid`函数
- [2.1 `wait`函数](#2.1
- 3.status是怎么存子进程的退出信息
-
- [3.1 提取信息的工具宏](#3.1 提取信息的工具宏)
- [3.2 四个宏的使用顺序(先判断,再提取)](#3.2 四个宏的使用顺序(先判断,再提取))
- 四、进程程序替换
-
- [1. exec系列函数的 "进程程序替换"](#1. exec系列函数的 “进程程序替换”)
- [2. 6个exec系列函数](#2. 6个exec系列函数)
一、进程创建
最常用的就是系统调用fork,它是由内核直接提供的,是用户态进程创建新进程的核心入口。
1.fork基础知识
函数原型:
c
#include<unistd.h>
pid_t fork(void);
pid_t: 进程ID类型,本质是整型
特点: 调用一次,返回两次
返回值:
- pid<0:fork失败(系统进程数满/内存不足)
- pid==0:当前是子进程
- pid>0:当前是父进程,返回值是子进程的PID
核心特性:
1.子进程拷贝父进程的地址空间和页表,以及代码(共享) 和 数据(写时拷贝)
2.子进程从fork()之后的代码开始执行
3.父子进程调度顺序是不确定的,谁先跑由OS决定
4.各自变量独立,互不影响
示例:fork创建子进程

fork刚完成时的状态:父子进程都有独立的task_struct(进程控制块)和struct mm_struct(虚拟地址空间管理结构)。两个进程的页表都指向同一份物理内存空间,没有直接复制物理内存,页表根据引用计数来标记权限,计数>1时,也就是有多个进程都指向这块空间,如果任何一方尝试修改数据,都会触发内核的写保护异常,内核启动写时拷贝。
写时拷贝内核干了啥?
内核为子进程在物理内存中分配一块新的内存空间,复制原来共享数据的内容,更新子进程的页表项,原来指向父进程物理页的条目,改为指向新分配的物理页(也就是0x7fd)。把新页表项标记为可写,解除只读权限。最终父子进程独立起来了,父进程依然指向原来的物理页,数据不受影响,而子进程的页表指向新的物理页,后续两个进程修改,不会影响另一个进程。
💡为什么代码段不发生写时拷贝?
代码段本身就是只读的,不会被进程修改,所以即使多个进程共享,也不会触发写保护异常,因此不需要复制物理页。
2.fork底层逻辑
c
int main()
{
pid_t id=fork();
if(id==0)
{
printf("I am child process! pid=%d,ppid=%d\n",getpid(),getppid());
exit(0);//子进程完就直接终止掉
}
else if(id>0)
{
printf("I am father process! pid=%d,ppid=%d\n",getpid(),getppid());
}
else
{
perror("fork");
}
return 0;
}
fork的执行流程 :父进程执行到fork时,调用fork进入内核态,在内核里创建子进程(分配PCB,拷贝父进程mm_struct、页表等),此时还未出fork函数,但是子进程已经创建好了,然后在fork内部return,父子进程都要从fork里面return出来,子进程退出返回0,父进程退出返回创建好的子进程id,两个进程都从fork函数里返回用户态,所以就有 一次调用,两次返回。
二、进程终止
1.进程终止是在做什么?
先释放掉曾经的代码和数据所占据的空间,再释放内核数据结构
进程终止本质上就是:
1.释放资源 :释放进程的代码段、数据段、栈、堆占用的内存空间,还有文件描述符等系统资源
2.进入僵尸状态(Z) :进程退出后,会保留一个task_struct结构体,里面存着进程退出信息(退出码,退出信号),等待父进程回收。只有父进程调用了wait()/waitpid()读取了退出状态,进程才会彻底从系统中消失。
2.进程终止的三种情况
| 情况 | 场景 | 退出码是否有效 | 本质 |
|---|---|---|---|
| a.代码跑完,结果正确 | 正常执行结束,return 0或者exit(0) |
有效 | 主动正常退出 |
| b.代码跑完,结果不正确 | 执行完了,但逻辑错误,比如exit(非0)或return 非0 |
有效 | 主动正常退出,但结果不符合预期 |
| c.代码执行中出现异常,提前退出 | 段错误/除零错误/被信号杀死,比如kill -9 |
代码执行异常,提前终止,退出码无效,由退出信号决定 | 被动异常终止 |
示例:解引用空指针,会引发段错误异常,OS会发送11号信号终止掉该进程,出现异常,只用看退出信号就OK,此时退出码无效

总结:程序编译运行的时候,崩溃了。操作系统发现你的进程做了不该做的事情,OS杀了进程,一旦出现异常,退出码就没有意义了!进程出异常:本质是因为进程收到了OS发送给进程的信号(kill -11 pid)

进程的终止信息要如何看呢?
step1 .先确认是否异常:异常的话,就不用看退出码了,直接看退出信号是多少,就可以判断进程为什么异常了
step2.不是异常,就一定是代码跑完了,看退出码就行了
3.退出码和退出信号
3.1 进程退出的两种方式
1.正常退出:进程执行完代码,主动调用return/exit()退出。
2.异常退出:进程被信号(如:SIGKILL、SIGSEGV)终止,或运行时出错崩溃。
所以父进程要判断子进程的退出情况,只需要两个关键信息:
退出码(eixt_code) :正常退出时的返回值
退出信号(eixt_signal):异常退出时,导致进程终止的信号编号
3.2 什么是退出码?
1.退出码的含义
退出码,0表示成功,非0表示失败
非0值(1~255):表示进程执行失败,不同的数值代表不同的失败原因。退出码也可以自定义。

2.父进程如何获取退出码?
在bash中,通过$?环境变量可以获取最近一个进程的退出码

3.3 为什么父进程要关心子进程的退出码
1.判断任务是否成功:父进程可以通过退出码,知道子进程的任务是否完成。
2.定位失败原因:不同的非0退出码,对应不同的错误场景,方便排查问题。
3.还有一点就是知道子进程的退出情况是为用户负责,要告诉用户该进程的情况。
4.如何终止进程?
1.main函数里的return
- 只有在
main函数里调用return,才表示终止整个进程 - 普通函数中的
return,值表示函数结束,不会终止进程 - 本质上,
main函数里的return n等价于调用exit(n),它会先执行清理工作再退出
2.exit()函数(C库函数)
- 在代码的任意位置调用
exit(),都表示进程退出 - exit是C标准库提供的函数,会在退出刷新并关闭所打开的缓冲区 ,底层调用的是系统调用
_exit()退出进程
3._exit()是系统调用
- 直接进内核,不碰用户态任何东西
- 所以不会刷新C标准库缓冲区
- 直接释放进程资源,结束
exit()和_exit()的区别
1.使用_exit(),刷新不了用户态的缓冲区
bash
printf("hello world");
//注意这里不加/n,加了/n的话,标准输入stdout默认是行缓冲,遇到换行符会自动刷新缓冲区,所以hello world会立刻输出,不会被_exit()丢弃
_exit(0);
结果:hello world没有被打印,丢弃了
2.使用exit(),可以刷新用户态的缓冲区
bash
printf("hello world");
exit(0);
结果:exit在调用_exit之前会刷新用户态缓冲区,所以hello world会被打印
💡总结 :
exit()能刷新缓冲区,因为它是库函数,做了用户态清理
_exit()不刷新缓冲区,因为它是内核级退出,不管用户态
三、进程等待
1.为什么需要等待?
任何子进程,在退出的情况下,一般必须要被父进程进行等待。进程在退出的时候,如果父进程没有回收它,子进程会变成僵尸进程(PCB留在系统里,占用PID资源),所以父进程要通过等待,来解决子进程退出的僵尸问题,回收系统资源,获取子进程的退出信息。
2.如何等待?
wait和waitpid就是父进程用来回收子进程、获取子进程退出信息的系统调用。
2.1 wait函数
c
pid_t wait(int* status);
作用:阻塞等待任意一个子进程退出,并回收它
返回值:等待成功返回退出子进程的PID,失败返回-1
status:输出型参数,用来接收子进程的退出状态(退出码+退出信号),传NULL表示不关心退出状态
c
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
#include<wait.h>
int main()
{
printf("I am father!,pid=%d,ppid=%d\n",getpid(),getppid());
pid_t id=fork();
if(id==0)
{
//child
printf("I am child!,pid=%d,ppid=%d\n",getpid(),getppid());
exit(0);//子进程执行完直接退出
}
//father
pid_t rid=wait(NULL);//阻塞等待任意子进程 等价于 waitpid(-1,NULL,0)
if(rid>0)
printf("wait success!,rid=%d\n",rid);
else
printf("wait failed!\n");
return 0;
}
2.2waitpid函数
c
pid_t waitpid(pid_t pid,int* status,int options);
比wait更灵活,支持回收指定对象和非阻塞模式
pid参数:
①pid=-1:等待任意子进程(和wait完全等价)
②pid>0:等待PID等于pid的特定子进程
options参数:
①options=0:默认阻塞等待(和wait一样)
②options=WNOHANG:非阻塞模式,如果子进程还没有退出,函数直接返回0,不阻塞父进程
例子:
返回值:
返回值>0 :成功回收了一个子进程,返回值就是这个子进程的pid
返回值==0 :设置了WNOHANG非阻塞模式,调用成功,但子进程还没有退出,需要父进程下次再检查
返回值<0:等待失败,比如没有对应的子进程
两种等待方式:阻塞vs非阻塞的区别
阻塞等待(options=0):父进程调用waitpid后,如果子进程没有退出,父进程会一直停在这里,CPU时间片被占用,没法做其他事情。
非阻塞等待(options=WNOHANG):父进程调用waitpid后,不管子进程退没退出,函数都会立刻返回。如果子进程退出了,则返回子进程的pid,父进程处理回收;如果子进程没退出,则返回0,父进程可以取做自己的事情,隔一段时间再来调用waitpid检查。
示例:非阻塞等待(WNOHANG)+循环=非阻塞轮询
c
#define N 3
typedef void(*func_t)();//无参无返回值的函数指针类型
func_t tasks[N]={NULL};
void PrintLog()
{
printf("执行PrintLog()任务...\n");
}
void DownLoad()
{
printf("执行DownLoad()任务...\n");
}
void MysqlDataSync()
{
printf("执行MysqlDataSync()任务...\n");
}
void LoadTask()
{
tasks[0]=PrintLog;
tasks[1]=DownLoad;
tasks[2]=MysqlDataSync;
}
void HandlerTask()
{
for(int i=0;i<N;i++)
{
tasks[i]();//回调方式 -- 回调 = 把函数当参数传进去
}
}
void DoOtherThing()
{
HandlerTask();
}
void ChildRun()
{
int cnt=5;
while(cnt--)
{
printf("I am child process,pid:%d,ppid:%d,cnt:%d\n",getpid(),getppid(),cnt);
sleep(1);
}
}
int main()
{
printf("I am father process,pid:%d,ppid:%d\n",getpid(),getppid());
pid_t id=fork();
LoadTask();
if(id==0)
{
//child
ChildRun();
printf("child quit...\n");
exit(0);
}
else
{
//father
while(1)
{
int status=0;
pid_t rid=waitpid(-1,&status,WNOHANG);//非阻塞等待
sleep(1);//隔一段时间检查一次
if(rid>0)
{
//wait success
if(WIFEXITED(status))
{
printf("等待成功,进程正常退出,退出码为:%d\n",WEXITSTATUS(status));
}
else
{
printf("等待失败,进程异常退出\n");
}
break;
}
else if(rid==0)
{
//子进程还没有退出,父进程可以干其他事情
printf("child if running,father check next time\n");
DoOtherThing();
}
else
{
//等待失败
printf("father failed!\n");
break;
}
}
}
return 0;
}
3.status是怎么存子进程的退出信息
status是一个32位正数,里面按位存储了两种信息:
低7位 :存退出信号(如果进程被信号杀死,这里是信号编号;正常退出则为0)
次低8位 :退出码(范围0~255,全0到全1)

这种提取方式需要对status有一定的了解,不友好,所以Linux中提供了提取信息的工具宏
3.1 提取信息的工具宏
它们都是用来解析wait/waitpid拿到的status变量的
| 宏 | 作用 | 关键限制 |
|---|---|---|
WIFEXITED(status) |
判断进程释放正常退出 | 返回0为假(异常终止),非0为真(正常退出) |
WEXITSTATUS(status) |
提取正常退出的退出码 | 仅在WIFEXITED(status)为真时有效,否则结果无意义 |
WIFSIGNALED(status) |
判断进程释放被信号杀死 | 0为假(正常退出),非0为真(被信号终止) |
WTERMSIG(status) |
提取杀死进程的信号编号 | 仅在WIFSIGNALED(status)为真时有效,否则结果无意义 |
3.2 四个宏的使用顺序(先判断,再提取)
重点:
WIFEXITED和WIFSIGNALED是互斥的,一个进程要么正常退出,要么被信号杀死,不可能同时满足,所以先判断WIFEXITED还是先判断WIFSIGNALED最终执行的分支都是一样的,输出结果不会出错
c
int main()
{
pid_t id=fork();
if(id==0)
{
//child
//什么都不干,正常退出返回0
exit(10);
}
else
{
//father
int status=0;
pid_t rid=waitpid(-1,&status,0);
if(rid>0)
{
//wait success
if(WIFEXITED(status))
{
//正常退出->获取有效退出码
printf("进程正常退出,退出码为%d\n",WEXITSTATUS(status));
}
else if(WIFSIGNALED(status))
{
//被信号杀死的->获取有效的信号编号
printf("进程被信号:%d异常终止\n",WTERMSIG(status));
}
}
}
return 0;
}
四、进程程序替换
1. exec系列函数的 "进程程序替换"
c
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("testexec ... begin!\n");
execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
printf("testexec ... end!\n");
return 0;
}
现象:

为什么execl后面的代码没有执行?execl到底做了什么?
exec系列函数的本质,就是把当前进程的"代码和数据",直接替换成磁盘上另一个程序(比如ls)的代码和数据。但进程的PID、PCB(进程控制块)、地址空间都不变,只有代码段、数据段、堆、栈的内容被完全替换了。所以说执行了execl之后,进程原本的代码已经被替换了,后续的代码不会被执行了。
进程的程序替换!有没有创建新的进程呢?
答案是没有,你可以抽象的理解该进程被 "夺舍" 了。
图形解释理解:

execl执行后,不是同一块物理内存。旧的物理内存会被释放,新程序会加载到新的物理内存中,页表也会被重新映射。
补充:exec系列函数的特性
如果
execl执行成功,那么当前进程已经变成了ls进程,原来的程序代码已经被覆盖了,根本找不到printf("testexec...end!\n")这条指令了,只有当它调用失败时,它才会返回-1,程序才会继续往后执行。
2. 6个exec系列函数
6个exec函数其实都是基于execve系统调用函数封装的,这6个函数的核心区别就是参数传递方式和环境变量的处理不同
1.exec系列函数的作用是:用新的程序,替换当前进程的代码段、数据段和栈段,进程的PID不变,但执行的程序变成了新的文件
2.命名规则
exec系列函数的名字是又后缀字母组合而成的,每个字母都有固定含义:
| 字母 | 全称 | 含义 |
|---|---|---|
l |
list | 命令行参数以列表形式传入(可变参数以NULL结尾) |
v |
vector | 命令行参数以数组形式传入(char* argv[],数组最后以NULL元素结尾) |
p |
path | 会自动在PATH环境变量中查找程序,不用写完整路径 |
e |
environment | 允许自定义环境变量,传给新程序 |
3.用法演示
①execl: 路径+列表传参
c
int execl(const char* path,const char* arg,...);
path :必须写完整路径
arg:程序名+参数列表,最后必须NULL
c
//执行 ls -l -a
execl("/usr/bin/ls","ls","-l","-a",NULL);
②execv原型: 路径+数组传参
c
int execv(const char* path,char* const argv[]);
path :完整路径
argv:参数数组,数组最后以NULL结尾
c
//执行 ls -l -a
char* argv[]={"ls","-l",NULL};
execv("/usr/bin/ls",argv);
③execlp原型: 自动搜索PATH+列表传参
c
int execlp(const char* file,const char* arg);
file: 程序名,自动在PATH中搜
c
//执行ls -l
execlp("ls","ls","-l",NULL);
//不用写完整路径,只用告诉它程序名即可,带p会自动在PATH里找的,后面还是列表传参
④execvp原型: 自动搜索PATH+数组传参
c
int execvp(const char* file,char* const argv[]);
file: 程序名,自动在PATH中搜
c
char* argv[]={"ls","-l",NULL};
execvp("ls",argv);
⑤execle原型: 路径+列表传参+自定义环境/系统环境
c
int execl(const char* path,const char*arg,...,char* const envp[]);
比execl多了最后一个参数:自定义环境变量数组
c
char* env[]={"PATH=/bin","USER=root",NULL};
execle("/bin/ls","ls","-l",NULL,env);
⑥execvpe原型:自动搜索PATH+数组传参+自定义环境/系统环境
c
int execvpe(const char* file,char* const argv[],char* const envp[]);
file: 程序名,自动在PATH里找
argv: 参数数组
envp: 自定义环境变量数组