
🔥铅笔小新z:个人主页
🎬博客专栏:Linux学习
💫滴水不绝,可穿石;步履不休,能至渊。

我们来看一个例子:

为什么我们执行完 sleep 100 之后,再执行 ls, ll .pwd程序没反应呢?
这刚好可以结合我们上节课讲的进程创建(fork()) 和这节课要讲的程序替换(exec) ,这个看似简单的现象背后,其实藏着操作系统对终端控制权和进程状态的管理机制。
-
核心原因:bash 被阻塞了
首先要明确,你在终端里交互的那个程序叫做 bash 。bash 的主要工作流程是一个死循环:读取你的输入 -> 解析命令 -> 执行命令 -> 等待执行完毕 -> 再次读取。
当你在 bash中执行一个命令时,默认情况下它是作为前台进程(Foreground Process) 运行的。
- bash 把终端的"控制权"交给了
sleep 100这个进程。 - bash 自身会调用一个名为
wait()或waitpid()的系统调用,进入睡眠(阻塞)状态 。它在苦苦等待sleep这个子进程结束并汇报退出状态。 - 在这 100 秒内,bash 这个程序是"休眠"的,它不再主动读取你键盘敲击的新命令。
- bash 把终端的"控制权"交给了
-
深入底层:结合上节课的知识
fork阶段: 你敲下sleep 100,bash 进程通过fork()克隆了一个子进程。exec阶段: 这个子进程通过exec族函数,把自己替换成了磁盘上的sleep程序。wait阶段: 父进程(bash)调用wait()挂起自己,死死盯着这个子进程。因为它是前台运行的,bash 必须等它完事儿才能继续工作。
三、进程程序替换
3.1 替换原理
3.1.1 初始程序替换
我们先来看一个现象:
c
1 #include<stdio.h>
2 #include<unistd.h>
3
4 int main()
5 {
6 printf("我的程序要运行了!\n");
7 execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
8 printf("我的程序运行完毕了!\n");
9 return 0;
10 }

可见我们的程序把系统的命令执行了。
这种现象就是进程的程序替换。
当一个程调用了程序替换函数后,操作系统会将磁盘上的新程序(全新的代码段、数据段)加载到内存中,直接覆盖掉当前进程的内存空间(包括原有的代码段、数据段、堆、栈等)。
- PID 不变: 进程的身份证号(PID)不会发生任何改变。在操作系统看来,这还是原来的那个进程。
- 从头开始: 替换成功后,新程序会从它的 main 函数开始执行。
- 一去不复返: 因为原进程的代码已经被新程序的代码完全覆盖了,所以如果替换成功,替换函数之后的代码将永远没有机会执行。
3.1.2 深度剖析执行逻辑
如果我们在终端编译并运行这段代码,你会观察到一个非常奇妙的现象:
屏幕上会先打印出 "我的程序要运行了!" ,紧接着打印出当前目录下的文件列表(这是 ls -a -l 的输出结果)。
但是,你绝对看不到**"我的程序运行完毕了!"**这句话。
为什么第 8 行和第 9 行代码仿佛"凭空消失"了呢?这就是进程替换的原理在起作用。我们通过你写的这几行代码,来剖析一下底层的运作过程:
3.1.2.1 第 6 行
printf("我的程序要运行了!\n");
程序刚开始运行时,一切正常。操作系统为你这个程序分配了内存空间,把你的代码加载到代码段。CPU 顺着代码一行行执行,在终端打印出了第一句话。
3.1.2.2 第 7 行:(execl)
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
当 CPU 执行到这一行时,调用了系统底层的 execve 机制(execl 是其封装)。这时候发生了翻天覆地的变化:
-
找新程序: 操作系统根据第一个参数
/usr/bin/ls,去磁盘上找到了ls这个可执行文件。 -
清空与覆盖(核心动作): 操作系统非常暴力地把你当前进程内存中的代码段、数据段、堆区、栈区全部清空 。然后,把刚刚在磁盘上找到的
ls程序的代码和数据,原封不动地搬进这个被清空的内存空间里。 -
重置大脑: CPU 的程序计数器(PC 指针,用来记录下一行要执行哪条指令的寄存器)被强制重置,指向了刚刚加载进来的
ls程序的main函数入口。
3.1.2.3 第 8 行去哪了?
printf("我的程序运行完毕了!\n");
答案是:这行代码在内存中已经不存在了。
在第 7 行执行成功的那一瞬间,你原本写的所有 C 代码(包括这句 printf 和后面的 return 0;),在内存中就已经被 ls 的二进制代码无情地覆盖掉了。CPU 正在忙着从头执行 ls 的逻辑,根本不知道曾经这里还有个第 8 行代码。
3.2 替换函数
在 Linux/Unix 环境下,我们主要使用 exec 函数族来进行程序替换。C 语言标准库为我们提供了 6 个常用的包装函数,以及 1 个底层的系统调用。
我们先来看看这 6 个主要函数的长相(包含在 <unistd.h> 头文件中):
c
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
3.2.1 替换函数解释
这几个函数的作用是完全一样的:停下现在正在做的事,去执行那个新程序。 它们的区别仅仅在于传递参数的方式不同。
关键规则:
-
参数的结束标志: 无论传什么参数给新程序,参数列表的最后必须以
NULL结尾,这是告诉操作系统"参数传完了"。 -
返回值:
-
失败: 如果新程序找不到,或者权限不够,函数会返回
-1,并继续执行原程序接下来的代码。 -
成功: 没有返回值! 因为如果成功,原来的代码都没了,根本无处可返。
-
3.2.2 函数命名解释
这 6 个函数看起来眼花缭乱,但其实它们的命名有着极其严谨的规律。理解了后缀字母的含义,你就能瞬间记住它们。
exec 是基础,后面的字母是"配置选项":
-
l(list - 列表): 表示参数采用列表 的形式,一个一个传进去。例如:"ls", "-a", "-l", NULL。 -
v(vector - 矢量/数组): 表示参数打包成一个指针数组 传进去。你需要先定义一个char *argv[] = {"ls", "-a", "-l", NULL};,然后把argv传进去。
(注:l 和 v 是互斥的,必须选一个)
-
p(path - 路径): 带p的函数,第一个参数只需要传可执行文件的名字 (比如"ls"),操作系统会自动去环境变量PATH定义的目录里找它。如果不带p,你就必须老老实实写绝对路径或相对路径(比如"/bin/ls")。 -
e(environment - 环境变量): 带e的函数,允许你组装一个自定义的环境变量数组 传递给新程序。如果不带e,新程序会默认继承当前进程的环境变量。
补充: 实际上,只有 execve 是真正的 系统调用 (System Call),另外 5 个都是 C 标准库提供的 封装函数 (Library Functions)。它们在底层最终都会调用 execve 来向操作系统内核申请服务。
结合这个过程,你可以思考一个小问题:既然
execl成功后会把当前进程完全替换掉,那我们在写 Bash 这样的命令行解释器时,如果用户输入了ls命令,Bash 是如何做到既能执行ls,又能保证自己(Bash 进程)不被覆盖掉的呢?
------ 创建了子进程,让子进程进行进程替换! ------
结合刚才给出的那段 C 语言代码,如果 Bash 作为一个持续运行的程序,直接调用了 exec 函数去执行 ls,那么 Bash 自己的代码(包括死循环读取用户输入的逻辑)就会在瞬间被 ls 的代码完全覆盖。这样一来,ls 执行完之后,终端就直接退出了,因为 Bash 已经"自杀"了。
为了既能执行新程序,又能保全自己,Bash 采用了这种极其巧妙的策略。我们把它总结为 Linux 进程控制的 "黄金铁三角":
-
第一步:
fork()------ 创造分身(替身使者)。 Bash 先克隆出一个一模一样的子进程。这个子进程拥有和 Bash 一样的权限和环境变量。 -
第二步:
exec()------ 夺舍重生(替换灵魂)。 Bash 让子进程去执行exec。子进程心甘情愿地牺牲掉自己的 Bash 代码,把内存空间让给ls程序。 -
第三步:
wait()------ 幕后监工(收尸善后)。 作为父进程的 Bash 本尊,在幕后调用wait挂起自己,静静等待子进程(也就是ls)执行完毕并回收它的状态码,然后再重新打印出命令提示符,等待你的下一次输入。
3.2.3 替换函数代码例子
3.2.3.1 execl
c
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 int main()
6 {
7 if (fork() == 0)
8 {
9 printf("我的程序要运行了!\n");
10 execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
11
12 }
13 waitpid(-1, NULL, 0);
14 printf("我的程序运行完毕了!\n");
15 return 0;
16 }
3.2.3.2 execv
c
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 int main()
6 {
7 if (fork() == 0)
8 {
9 printf("我的程序要运行了!\n");
10
11 char *const argv[] = {
12 "ls",
13 "-a",
14 "-l",
15 NULL
16 };
17
18 execv("/usr/bin/ls", argv);
19
20 }
21 waitpid(-1, NULL, 0);
22 printf("我的程序运行完毕了!\n");
23 return 0;
24 }
3.2.3.3 execvp
c
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<sys/types.h>
4 #include<sys/wait.h>
5 int main()
6 {
7 if (fork() == 0)
8 {
9 printf("我的程序要运行了!\n");
10
11 char *const argv[] = {
12 "ls",
13 "-a",
14 "-l",
15 NULL
16 };
17
18 execvp("ls", argv);
19
20 }
21 waitpid(-1, NULL, 0);
22 printf("我的程序运行完毕了!\n");
23 return 0;
24 }
3.2.3.4 execvpe
c
1 #define _GNU_SOURCE
2 #include<stdio.h>
3 #include<unistd.h>
4 #include<sys/types.h>
5 #include<sys/wait.h>
6
7 char *const env[] = {
8 "MY_AGE=20",
9 "MY_SECRET=1",
10 NULL
11 };
12 int main()
13 {
14 if (fork() == 0)
15 {
16 printf("我的程序要运行了!\n");
17
18 char *const argv[] = {
19 "ls",
20 "-a",
21 "-l",
22 NULL
23 };
24
25
26 execvpe("ls", argv, env);
27
28 }
29 waitpid(-1, NULL, 0);
30 for (int i = 0; env[i]; i++)
31 {
32 printf("%d -> %s\n", i, env[i]);
33 }
34 printf("我的程序运行结束了!\n");
35 return 0;
36 }
注意: 使用 execvpe时,要在文件第一行加上 #define _GNU_SOURCE。

3.2.4 给子进程新增环境变量的方法
3.2.4.1 直接用 putenv
在子进程里先 putenv("MYVAL=123456789"),然后直接调用 execl、execv 等不带 e 的函数。系统会自动把当前(已经被 putenv 修改过)的环境变量传递过去。这是最推荐、最省事的做法。
3.2.4.2 "exec*e, putenv(); environ;"
如果你非要用带 e 的函数(比如 execvpe),你可以先通过 putenv() 把变量加到当前进程中,然后利用系统全局指针 environ(它指向当前进程完整的环境变量表),把 environ 作为第三个参数传给 exec*e。
注意: 使用 environ 时,要加上声明:extern char **environ。