Linux 进程控制
Linux 进程概念
- [Linux 进程控制](#Linux 进程控制)
一、写时拷贝
(1)原理

写实拷贝原理:
在进程当中父进程没有创建子进程,那么代码段是只读的,对应的数据段是读写的,当创建子进程的时候,操作系统就会把数据段改成只读的。当子进程修改数据的时候,发现虚拟地址合法,然后知道你访问的是数据段(因为虚拟地址有区域划分),而对应的数据段是只读的,所以这时候操作系统就会实现写时拷贝。操作系统查找页表的时候,如果对应的物理内存不存在,那么就会发生缺页中断,等待操作系统进行加载内存。其次就是发现物理内存存在但是权限是只读的,那么这时候就会发生写时拷贝
(2)意义
-
解决 fork 创建子进程开销大的问题
fork 传统实现需要把父进程整个虚拟地址空间全部复制一份物理内存。如果父进程内存很大,创建子进程会非常慢、占用大量内存。COW 让 fork只复制页表,不复制物理内存,fork 速度大幅提升。
-
大部分内存页面子进程根本不会修改,不需要拷贝
父进程有大量数据、全局变量、代码段,很多页面子进程只是读取,不会写。COW 做到按需拷贝:只读就共享;只有发生写操作,才单独复制被修改的那一页,节省内存。
二、进程终止
(1)进程退出的场景
1.代码运行完毕,结果正确
2.代码运行完毕,结果错误
3.代码异常终止
思考
针对代码返回的时候,因为返回的有可能是局部变量,所以这个返回值是如何返回的呢?

通过寄存器进行返回
实例:
Main 函数通过不同的返回值代表不同错误
c
#include<stdio.h>
int main()
{
printf("hello world\n");
FILE* fp=fopen("log.txt","r");
if(fp==NULL)
{
return 1;
}
fclose(fp);
return 0;
}
在Ubuntu中,无需额外查看最近运行的进程的返回值,这时候就需要用到echo指令
bash
echo $? //打印最近一个程序,退出时的退出码
结果:

(2)获取退出码
1.父进程通过回收子进程,来获取子进程退出情况
当进程退出的时候,进入僵尸状态,这个子进程就会把退出码写到自己的task_struct里面,然后让父进程来进行获取退出信息
2.通过系统函数strerror
Strerror 系统函数可以把错误码转换成字符串(string)来方便我们进行查看
打印出全部错误码对应的描述:

2.errno来进行返回错误码
c
#include<stdio.h>
#include<errno.h>
#include<string.h>
int main()
{
printf("hello world\n");
FILE *fp=fopen("log.txt","r");//打开文件log.txt
if(fp==NULL)
{
return errno;
}
fclose(fp);
return 0;
}
(3) 进程常见退出方法
1.异常退出
c
#include<stdio.h>
#include<errno.h>
#include<string.h>
int main()
{
int a=10;
a/=0;
return 89;
}
执行结果:

程序异常终止,对应的退出码无意义:
2.exit
c
#include<stdio.h>
#include<errno.h>
#include<string.h>
#include<stdlib.h>
void fun()
{
printf("fun begin\n");
exit(42);
printf("fun end!\n");
}
int main()
{
fun();
printf("main!\n");
}
执行结果:


概念:任何地方调用exit,表示进程结束!!并返回给父进程bash,子进程的退出码!!
Exit C语言提供 _Exit 系统提供!!
区别:
exit与_exit
- 使用
exit()退出进程时,会刷新缓冲区,将缓冲区中的数据写入文件后再终止进程; - 使用
_exit()退出进程时,不会刷新缓冲区,缓冲区中的数据会直接丢失。
本质原因:C 语言在用户态封装了一层缓冲区(stdio 缓冲区),只有当缓冲区写满、遇到换行符或进程正常退出(如调用
exit())等条件满足时,才会将数据真正刷新到文件中。而_exit()是系统调用,直接终止进程,不经过 C 语言的缓冲区刷新流程。
三、进程等待
(1)为什么要等待
- 解决僵尸进程问题:子进程退出后若父进程不及时回收,会一直处于僵尸状态,占用内核资源(如 task_struct),通过等待可以及时回收,避免资源泄漏。
- 获取子进程退出信息 :父进程需要通过
wait/waitpid等系统调用回收子进程资源,并获取子进程的退出码、终止信号等状态信息,以便判断子进程是否正常结束。
(2)等待是什么
1.系统调用

Linux系统下,通过系统调用进行进程等待
c
#include<unistd.h>
int main()
{
pid_t id=fork();
if(id==0)
{
//child
int cnt=5;
while(cnt)
{
printf("我是一个子进程,pid: %d,ppid: %d\n",getpid(),getppid());
cnt--;
sleep(1);
}
exit(5);
}
//father
sleep(1000);
return 0;
}
结果:

状态:Z+代表僵尸进程!!!
c
pid_t wait(int* status);
作用: 阻塞等待任意一个子进程退出,并回收其资源。
返回值:
- 成功:返回被回收的子进程的 PID,父进程可通过
status参数拿到子进程的退出信息; - 失败:返回
-1(例如当前没有子进程可等待)。
说明:
wait是阻塞式系统调用,若子进程尚未退出,父进程会一直挂起等待,直到有子进程终止才返回。
代码:
c
//father
pid_t rid=wait(NULL);
if(rid>0)
{
printf("wait success,rid: %d\n");
}
等待成功:
1:就是回收子进程的僵尸状态
2:父进程回收子进程退出信息

waitpid()
c
pid_t waitpid(pid_t pid, int* status, int options);
作用: 等待指定子进程退出,并回收其资源。
返回值:
- 成功:返回被回收的子进程的 PID;
- 失败:返回
-1(例如指定的子进程不存在或没有可等待的子进程)。
参数说明:
pid:指定要等待的子进程。若为-1,则等待任意一个子进程退出(与wait等价);status:输出型参数,父进程通过它获取子进程的退出信息,根据退出码判断子进程是否正常结束;options:阻塞控制选项。传0表示阻塞等待;传WNOHANG表示非阻塞,若子进程未退出则立即返回0。
非阻塞等待(WNOHANG)的实现方式:
当 options 传入 WNOHANG 时,waitpid 采用**轮询(polling)**的方式等待:
- 父进程调用
waitpid(pid, &status, WNOHANG)后,内核会立即检查指定的子进程是否已经退出; - 若子进程尚未退出 ,
waitpid不会挂起 父进程,而是立刻返回0,父进程可以继续执行后续代码; - 父进程通常会在循环中反复调用
waitpid,每次调用之间可以处理其他任务或短暂休眠(如sleep),直到某次调用返回子进程的 PID,表示回收成功。
c
// 非阻塞等待示例:轮询回收子进程
pid_t rid;
while ((rid = waitpid(id, &status, WNOHANG)) == 0)
{
// 子进程还没退出,先干点别的事
printf("子进程还在运行,我先忙别的...\n");
sleep(1); // 休眠 1 秒后再来检查
}
// 走到这里说明回收成功
printf("回收成功,子进程 PID: %d\n", rid);
对比:阻塞等待(
options=0)时,父进程会一直挂起,直到子进程退出才返回;而非阻塞等待(WNOHANG)则让父进程不被卡住,可以边做其他事情边轮询检查子进程状态,适合需要同时处理多个任务的场景。

通过 status 获取退出信息
wait/waitpid 的 status 参数是输出型参数 ,父进程通过它拿到子进程的退出状态。status 是一个 32 位整数,其低 16 位被划分为两部分:
- 低 8 位(bit 0~7):存放终止信号。若子进程被信号杀死,这里就是对应的信号编号;
- 高 8 位(bit 8~15) :存放退出码。若子进程正常退出(如调用
exit(5)),退出码5就保存在这里。
注意:
status不能直接当作退出码使用,需要借助宏来解析,否则拿到的数值是「信号 + 退出码」拼在一起的结果,含义不直观。
常用解析宏:
WIFEXITED(status):判断子进程是否正常退出 (即通过exit/return结束);WEXITSTATUS(status):在正常退出的前提下,取出真正的退出码;WIFSIGNALED(status):判断子进程是否被信号终止;WTERMSIG(status):在被信号终止的前提下,取出终止信号编号。
c
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
// 子进程:正常退出,退出码为 5
exit(1);
}
// 父进程:阻塞等待子进程退出
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
if (WIFEXITED(status))
{
// 正常退出,取出真正的退出码
printf("子进程正常退出,退出码: %d\n", WEXITSTATUS(status));
}
else if (WIFSIGNALED(status))
{
// 被信号杀死,取出终止信号
printf("子进程被信号终止,信号: %d\n", WTERMSIG(status));
}
}
return 0;
}
现象:

(3)怎么做
当子进程退出时,内核会把退出信息写入子进程的 PCB(进程控制块,即 task_struct) 中,父进程通过 wait/waitpid 回收子进程时,就是从 PCB 中读取这些数据。
PCB 中与退出信息相关的两个关键字段:
int exit_code:记录子进程的退出码 。若子进程正常退出(如调用exit(5)),这里保存的就是5;int exit_signal:记录子进程的终止信号 。若子进程被信号杀死(如SIGKILL),这里保存的就是对应的信号编号。
也就是说:子进程退出后,退出码和终止信号并不会凭空消失,而是被内核保存在 PCB 中,等待父进程来取。这正是「父进程通过回收子进程来获取退出信息」的底层实现。
在 Linux 内核中 PCB 结构体:

获取流程总结:
- 子进程退出,内核将
exit_code和exit_signal写入子进程的 PCB; - 子进程进入僵尸状态(Z+),等待父进程回收;
- 父进程调用
wait/waitpid,从 PCB 中读取这两个字段; - 父进程通过
WIFEXITED/WEXITSTATUS/WIFSIGNALED/WTERMSIG等宏解析出真正的退出码或终止信号; - 回收完成后,子进程的 PCB 被释放,僵尸状态解除。
四、进程的程序替换
(1)程序替换的概念
当我们执行程序时,如果执行到 execl 这个函数,就会把该函数调用的新程序的代码和数据 ,覆盖 到当前进程的代码和数据上。PCB 不变 ,但页表映射关系有可能发生调整 。这种策略叫做进程的程序替换!
核心要点:
- PCB 不变:进程的标识、PID、父子关系等内核信息保持不变,替换前后仍是同一个进程;
- 代码和数据被覆盖:当前进程的代码段、数据段被新程序的内容整体替换;
- 页表映射可能调整:新程序的代码和数据加载到内存后,页表会重新建立虚拟地址到物理地址的映射关系。
(2)程序替换接口
程序替换接口:

(3)进程替换的原理
进程替换的原理-fork:

替换流程:
- 父进程调用
fork()创建子进程,子进程复制父进程的代码和数据(借助写时拷贝,开销很小); - 子进程中调用
execl等exec系列函数,加载新程序; - 内核将新程序的代码和数据加载到内存,覆盖子进程原有的代码和数据;
- 子进程的 PCB 保持不变,但页表映射关系被重新调整,指向新程序的代码和数据;
- 子进程开始执行新程序的入口函数(如
main),完成程序替换。
注意:
exec系列函数只有失败才返回-1,成功时不会返回------因为一旦替换成功,原来的代码已经被覆盖,根本没有机会再执行返回语句了。
(4)程序替换接口函数
程序替换接口:

execl 函数:
c
int execl(const char* path, const char* arg, ...);
参数说明:
- 第一个参数
path:要执行程序的路径 + 程序名 ,指定新程序在磁盘上的位置;

- 第二个参数
arg(可变参数) :以const char*形式传入的命令行参数,以NULL结尾 ,表示参数列表结束;

代码示例:
c
#include <unistd.h>
#include <stdio.h>
int main()
{
// 子进程执行 ls 命令,参数以 NULL 结尾
execl("/bin/ls", "ls", "-l", NULL);
// 只有 exec 失败才会执行到这里
perror("execl failed");
return 1;
}
应用场景: 如果我们不想让程序后面的部分继续执行,那么可以创建子进程,让子进程去执行这个替换程序。父进程负责回收子进程,子进程负责执行新程序,两者互不干扰。
加载器
概念:
这个加载器,就是先创建进程,然后让这个进程去替换,执行指令。
为什么 exec 能替换任何程序?
核心原因:只要是一个进程,就能被替换 。
exec系列函数替换的是「进程」这个载体,而不是「某种特定语言写的程序」。无论新程序是 C、Python、Shell 还是其他任何语言写的,对内核来说都一样------它们最终都会被编译/解释成可执行的二进制文件,加载进内存后就是一段「代码 + 数据」。
理解这句话的关键:
-
替换的是进程,不是语言 :
exec只关心「当前进程的代码和数据被新程序的代码和数据覆盖」,它不关心新程序是什么语言写的。只要是能运行的程序,内核就能把它加载进来。 -
解释型语言(如 Python)的本质 :当我们
execl("/usr/bin/python3", "python3", "script.py", NULL)时,真正被加载进内存的不是script.py的源码,而是 Python 解释器(python3 这个二进制文件) 的代码和数据。替换完成后,进程执行的是解释器的二进制代码。 -
解释器再读脚本 :解释器运行起来后,才从磁盘上读取
script.py的源码,逐行解释执行。也就是说------进程替换的是解释器,脚本文件是解释器运行后自己去读的。
一句话总结:
因为
exec替换的是「进程」这个通用载体,而任何语言最终都要落到「二进制文件」上才能被 CPU 执行。所以 C 语言可以调用任何语言------本质是 C 程序先fork出一个子进程,再用exec把子进程替换成目标语言的解释器二进制文件,解释器再去读取并执行对应的脚本文件。
为什么 CPU 需要二进制文件,而不是直接执行内存里的代码?
核心原因:CPU 只认识机器指令(二进制),不认识任何高级语言。CPU 的指令集(如 x86、ARM)规定了一组固定的二进制编码,每条编码对应一个操作(如加法、跳转、读写内存)。CPU 执行时,只是从内存中取出这些二进制指令,解码后执行------它根本不关心这些指令是从哪来的。
execlp 函数:
c
int execlp(const char* file, const char* arg, ...);
参数说明:
file:程序名 (在环境变量PATH中查找的程序名,无需写完整路径);arg:可变参数,依次传入程序名 + 命令行参数列表;- 参数列表以
NULL结尾,表示结束。
特点:
execlp会通过环境变量PATH自动查找程序,因此不需要写绝对路径,只需给出程序名即可。
execv 函数:
c
int execv(const char* path, char* const argv[]);
参数说明:
path:绝对路径,即可执行程序在磁盘上的完整路径;argv:命令行参数数组 ,依次对应argv[0](程序名)、argv[1](选项1)、argv[2](选项2)......
特点:
execv需要手动指定完整路径 ,参数通过数组形式传入,而不是可变参数。
execvp 函数:
c
int execvp(const char* file, char* const argv[]);
参数说明:
file:程序名 (在环境变量PATH中查找);argv:命令行参数数组 ,依次对应argv[0](程序名)、argv[1](选项1)、argv[2](选项2)......
特点:
execvp会默认继承父进程的环境变量表 ,从而获取到对应的env[]环境变量,同时通过PATH自动查找程序。
execvpe 函数:
c
int execvpe(const char* file, char* const argv[], char* const envp[]);
参数说明:
file:程序名 (在环境变量PATH中查找);argv:命令行参数数组 ,依次对应argv[0](程序名)、argv[1](选项1)、argv[2](选项2)......;envp:自定义环境变量表,用于传入新的环境变量表。
特点:
execvpe相比execvp多了一个envp参数,可以手动指定环境变量表 ,而不是默认继承父进程的环境变量。这样可以让当前进程获取到最新的环境变量表。
如何新增环境变量:
如果需要新增环境变量,可以使用 putenv 函数:
用environ 之前要进行声明,extern char** environ
c
int putenv(char* string);

结果:

