
1 什么是进程程序替换
前面我们学过:fork () 之后,父子进程各自执行父进程代码的一部分(走不同的代码分支)。但问题来了 ------如果子进程就想去执行一个全新的程序呢?
例:父进程是服务器,fork 出子进程后,子进程想去执行磁盘上的另一个可执行程序(比如执行 ls、执行另一个编译好的二进制程序)。
答案就是进程的程序替换。
程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中。
关键认知:程序替换不是创建新进程,而是把 "当前这个进程" 的用户空间代码和数据整个换掉,换成另一个程序的代码和数据。
2 替换原理:加载全新程序到进程地址空间
2.1 核心机制

- 用 fork 创建子进程后,子进程执行的是和父进程相同的程序(但可能执行不同的代码分支);
- 子进程往往要调用一种 exec 函数 来执行另一个程序;
- 当进程调用一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换 ,从新程序的**启动例程(startup routine)**开始执行;
- 调用 exec 并不创建新进程 ,所以调用 exec 前后,该进程的 id 并未改变(PID 不变)。
2.2 示意图理解(加载过程)
cpp
PCB(task_struct) 虚拟内存 页表 物理内存 磁盘
┌────────────┐ ┌────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ pid不变 │ │ 栈 stack │ │ │ │ 堆 heap │ │ 要执行的 │
│ (进程身份 │ │ 堆 heap │ ──映射──▶│ │────▶│ 栈 stack │────▶│ 程序 │
│ 保留) │ ───────▶│ 数据段 data │ │ 页表 │ │ 数据段 data │ │ (ELF) │
│ │ mm指针 │ 代码段 text │ │ │ │ 代码段 text │ │ 代码/数据 │
└────────────┘ └────────────┘ └──────────┘ └──────────┘ └──────────┘
执行流程:磁盘上要执行的新程序(ELF 格式可执行文件)的代码和数据,通过页表映射关系,加载 / 替换进调用进程的用户空间(代码段、数据段、堆、栈区域被新程序内容覆盖)。PCB 和 PID 保持不变,变的只是用户空间的代码和数据内容。
一句话记忆:进程程序替换 = 进程 "换芯"(换掉代码和数据),"壳"(PCB、PID、内核身份)不变。
3 六种 exec 替换函数(exec 函数簇)
3.1 函数解释:为什么 exec 只有出错返回值
其实有 六种以 exec 开头的函数 ,统称 exec 函数。头文件都是 <unistd.h>:
cpp
#include <unistd.h>
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[]);
函数行为特性(重要考点):
- 这些函数如果调用成功 ,则加载新的程序,从启动代码开始执行,不再返回(后续代码永远执行不到,进程已经被新程序替换);
- 如果调用出错 ,则返回
-1; - 所以 exec 函数只有出错的返回值,而没有成功的返回值。
理解:因为一旦成功,进程代码就被新程序完全替换掉了,exec 后面的代码根本不会执行,自然也不可能有 "成功后的返回值"。只有失败时才会回到原进程继续执行并返回 -1。
3.2 命名理解:l /v/p /e 后缀规律
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
表格
| 后缀字母 | 全称 | 含义 |
|---|---|---|
l |
list | 参数采用列表(可变参数形式,一个一个传) |
v |
vector | 参数采用数组(指针数组) |
p |
path | 有 p 就自动搜索环境变量 PATH(无需写全路径) |
e |
env | 表示自己维护环境变量(手动传入环境变量数组) |
六个函数完整对照表(面试高频):
表格
| 函数名 | 参数格式 | 是否带路径 | 是否使用当前环境变量 |
|---|---|---|---|
execl |
列表 | 不是 | 是 |
execlp |
列表 | 是 | 是 |
execle |
列表 | 不是 | 不是,须自己组装环境变量 |
execv |
数组 | 不是 | 是 |
execvp |
数组 | 是 | 是 |
execve |
数组 | 不是 | 不是,须自己组装环境变量 |
3.3 exec 调用举例
cpp
#include <unistd.h>
int main()
{
char *const argv[] = {"ps", "-ef", NULL}; // v 后缀用的参数数组
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL}; // e 后缀用的环境变量
// l:列表传参,写全路径
execl("/bin/ps", "ps", "-ef", NULL);
// 带 p:可以使用环境变量PATH,无需写全路径
execlp("ps", "ps", "-ef", NULL);
// 带 e:需要自己组装环境变量
execle("ps", "ps", "-ef", NULL, envp);
// v:数组传参,写全路径
execv("/bin/ps", argv);
// 带 p:可以使用环境变量PATH,无需写全路径
execvp("ps", argv);
// 带 e:需要自己组装环境变量(execve 是真正的系统调用)
execve("/bin/ps", argv, envp);
exit(0);
}
记忆口诀:l 是列表 (list)、v 是数组 (vector)、p 是带路径搜索 (PATH)、e 是自己给环境变量 (env)。
3.4 exec 函数簇调用关系:只有 execve 是系统调用(重点)
事实上,只有 execve 是真正的系统调用,其它五个函数最终都调用 execve。 所以:
execve在 man 手册第 2 节(系统调用 section 2);- 其它五个函数在 man 手册第 3 节(库函数 section 3)。
函数之间的关系图:

cpp
execlp ──把可变参数保存到──▶ execvp ──依次在PATH环境变量指示的各目录中查找该程序──▶
execl ──把可变参数保存到──▶ execv ──使用environ所指向的当前环境变量表───────────▶
execle ──把可变参数保存到──▶ execve (系统调用) ◀── 最终都要调用它
即:
execlp/execl/execle先把 "可变参数" 保存到以 NULL 结尾的指针数组中;execlp → execvp:execvp 会依次在 PATH 环境变量指示的各目录中查找该程序;execl → execv:execv 会使用 environ 所指向的当前环境变量表;execle → execve:execve 是最终的系统调用,所有 exec 函数最后都调它。
课件提示(课堂上会结合时间演示):环境变量如何传入的问题------ 带 e 的函数要自己组装环境变量传入,不带 e 的函数默认继承当前进程的环境表,这一点在后面写 shell 时体现得非常明显。
4 自主 Shell 命令行解释器
学完进程程序替换,我们来亲手实现一个 mini shell,把 fork、exec、wait 三大件串起来,同时真正搞懂 shell 的运行原理。
4.1 目标
- 要能处理普通命令(如 ls、ps,交给子进程执行);
- 要能处理内建命令(如 cd、export、env、echo,由 shell 自己执行);
- 要能帮助我们理解 内建命令 / 本地变量 / 环境变量 这些概念;
- 要能帮助我们理解 shell 的运行原理。
4.2 实现原理:shell 与用户的典型互动
先看一个 shell 的典型互动:
cpp
[root@localhost epoll]# ls
client.cpp readme.md server.cpp utility.h
[root@localhost epoll]# ps
PID TTY TIME CMD
3451 pts/0 00:00:00 bash
3514 pts/0 00:00:00 ps
用时间轴理解事件发生次序(时间从左向右):

cpp
bash ─读入"ls"─▶ bash ─fork建子进程─▶ [子进程 ls]─运行命令─▶ 退出 ─▶ bash 等待子进程退出
bash ─读入"ps"─▶ bash ─fork建子进程─▶ [子进程 ps]─运行命令─▶ 退出 ─▶ bash 等待子进程退出
- shell(用 bash 方块代表)从用户读入字符串
"ls"; - shell 建立一个新的进程 (fork),然后在那个进程中运行 ls 程序,并等待那个进程结束(wait);
- 然后 shell 读取新的一行输入,建立一个新的进程,在这个进程中运行 ps 程序并等待这个进程结束。
所以写一个 shell,需要循环以下 5 步过程:
- 获取命令行(读取用户输入的一整条命令字符串);
- 解析命令行 (把
"ls -a -l -n"拆成命令名 + 参数数组); - 建立一个子进程(fork);
- 替换子进程(execvp /execve,子进程去执行新程序);
- 父进程等待子进程退出(wait)。
根据这些思路 + 我们前面学的技术(fork /exec/wait),就可以自己实现一个 shell 了。
4.3 完整源码实现
① 头文件与全局变量
cpp
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <ctype.h>
using namespace std;
const int basesize = 1024; // 命令行缓冲区大小
const int argvnum = 64; // 参数个数上限
const int envvnum = 64; // 环境变量个数上限
// 全局的命令行参数表
char *argv[argvnum];
int gargc = 0;
// 全局的变量
int lastcode = 0; // 记录上一条命令的退出码(echo $? 用)
// 我的系统的环境变量
char *genv[envvnum];
// 全局的当前shell工作路径
char pwd[basesize];
char pwdenv[basesize];
// 去除字符串前导空格
#define TrimSpace(pos) do{\
while(isspace(*pos)){\
pos++;\
}\
}while(0)
② 用户名、主机名、当前路径
cpp
string GetUserName()
{
string name = getenv("USER");
return name.empty() ? "None" : name;
}
string GetHostName()
{
string hostname = getenv("HOSTNAME");
return hostname.empty() ? "None" : hostname;
}
string GetPwd()
{
if(nullptr == getcwd(pwd, sizeof(pwd))) return "None";
snprintf(pwdenv, sizeof(pwdenv), "PWD=%s", pwd);
putenv(pwdenv); // PWD=XXX
return pwd;
}
// 获取当前目录的最后一级名字(用于提示符)
string LastDir()
{
string curr = GetPwd();
if(curr == "/" || curr == "None") return curr;
// /home/whb/XXX
size_t pos = curr.rfind("/");
if(pos == std::string::npos) return curr;
return curr.substr(pos+1);
}
③ 打印命令行提示符(第 1 步)
cpp
// 构造形如 [user@host dir]# 的提示符
string MakeCommandLine()
{
char command_line[basesize];
snprintf(command_line, basesize, "[%s@%s %s]# ",
GetUserName().c_str(), GetHostName().c_str(), LastDir().c_str());
return command_line;
}
void PrintCommandLine() // 1. 命令行提示符
{
printf("%s", MakeCommandLine().c_str());
fflush(stdout);
}
④ 获取用户命令(第 2 步)
cpp
bool GetCommandLine(char command_buffer[], int size) // 2. 获取用户命令
{
// 我们认为:要将用户输入的命令行,当做一个完整的字符串
// 例如 "ls -a -l -n"
char *result = fgets(command_buffer, size, stdin);
if(!result)
{
return false;
}
command_buffer[strlen(command_buffer)-1] = 0; // 去掉末尾换行符
if(strlen(command_buffer) == 0) return false; // 空命令
return true;
}
⑤ 解析命令行(第 3 步)
cpp
void ParseCommandLine(char command_buffer[], int len) // 3. 分析命令
{
(void)len;
memset(argv, 0, sizeof(argv));
gargc = 0;
// "ls -a -l -n"
const char *sep = " ";
argv[gargc++] = strtok(command_buffer, sep); // 切出第一个单词(命令名)
// = 是刻意写的:当 strtok 返回 NULL 时,把 NULL 也存进 argv
while((bool)(argv[gargc++] = strtok(nullptr, sep)));
gargc--; // 把多出的那个 NULL 去掉
}
void debug() // 调试用:打印解析结果
{
printf("argc: %d\n", gargc);
for(int i = 0; argv[i]; i++)
{
printf("argv[%d]: %s\n", i, argv[i]);
}
}
⑥ 执行命令(第 4 步:fork + exec + wait)
cpp
// 在shell中
// 有些命令,必须由子进程来执行(普通命令)
// 有些命令,不能由子进程执行,要由shell自己执行 ------ 内建命令 built command
bool ExecuteCommand() // 4. 执行命令(普通外部命令)
{
// 让子进程进行执行
pid_t id = fork();
if(id < 0) return false;
if(id == 0)
{
// 子进程
// 1. 执行命令:用 execve 替换子进程为用户要执行的程序
execve(argv[0], argv, genv);
// 2. 退出:execve 失败才走到这里
exit(1);
}
// 父进程
int status = 0;
pid_t rid = waitpid(id, &status, 0); // 5. 等待子进程退出
if(rid > 0)
{
if(WIFEXITED(status))
{
lastcode = WEXITSTATUS(status); // 记录子进程退出码,供 echo $? 使用
}
else
{
lastcode = 100;
}
return true;
}
return false;
}
⑦ 环境变量操作:AddEnv 与 InitEnv
cpp
// 往环境变量数组 genv 中添加一项(export 用)
void AddEnv(const char *item)
{
int index = 0;
while(genv[index])
{
index++;
}
genv[index] = (char*)malloc(strlen(item)+1);
strncpy(genv[index], item, strlen(item)+1);
genv[++index] = nullptr;
}
// 作为一个shell,获取环境变量应该从系统的配置来
// 我们今天就直接从父shell中获取环境变量
void InitEnv()
{
extern char **environ;
int index = 0;
while(environ[index])
{
genv[index] = (char*)malloc(strlen(environ[index])+1);
strncpy(genv[index], environ[index], strlen(environ[index])+1);
index++;
}
genv[index] = nullptr;
}
⑧ 内建命令处理(第 5 步:shell 自己执行)
cpp
// shell自己执行命令,本质是shell调用自己的函数
bool CheckAndExecBuiltCommand()
{
// 内建命令:cd
if(strcmp(argv[0], "cd") == 0)
{
if(gargc == 2)
{
chdir(argv[1]); // 改变当前工作目录
lastcode = 0;
}
else
{
lastcode = 1;
}
return true;
}
// 内建命令:export
else if(strcmp(argv[0], "export") == 0)
{
if(gargc == 2)
{
AddEnv(argv[1]); // 把变量加入环境变量表
lastcode = 0;
}
else
{
lastcode = 2;
}
return true;
}
// 内建命令:env ------ 打印当前环境变量
else if(strcmp(argv[0], "env") == 0)
{
for(int i = 0; genv[i]; i++)
{
printf("%s\n", genv[i]);
}
lastcode = 0;
return true;
}
// 内建命令:echo
else if(strcmp(argv[0], "echo") == 0)
{
if(gargc == 2)
{
// echo $? echo $PATH echo hello
if(argv[1][0] == '$')
{
if(argv[1][1] == '?') // echo $?:打印上一条命令退出码
{
printf("%d\n", lastcode);
lastcode = 0;
}
}
else
{
printf("%s\n", argv[1]); // echo hello:直接打印
lastcode = 0;
}
}
else
{
lastcode = 3;
}
return true;
}
return false; // 不是内建命令,返回 false,走普通命令执行流程
}
⑨ 主函数:五步循环
cpp
int main()
{
InitEnv();
char command_buffer[basesize];
while(true)
{
PrintCommandLine(); // 1. 命令行提示符
if( !GetCommandLine(command_buffer, basesize) ) // 2. 获取用户命令
{
continue;
}
ParseCommandLine(command_buffer, strlen(command_buffer)); // 3. 分析命令
if( CheckAndExecBuiltCommand() ) // 5. 先检查内建命令(shell自己执行)
{
continue;
}
ExecuteCommand(); // 4. 执行普通外部命令(fork+exec+wait)
}
return 0;
}
运行效果(自己动手编译运行):
cpp
[whb@localhost myshell]# ls
client.cpp server.cpp utility.h readme.md
[whb@localhost myshell]# env # 内建命令,打印环境变量
PATH=/usr/local/bin:/usr/bin:/bin
...
[whb@localhost myshell]# echo hello # 内建命令,打印 hello
hello
[whb@localhost myshell]# echo $? # 打印上一条命令退出码
0
4.4 总结:exec/exit 就像 call/return
在继续学习新知识前,我们来思考函数和进程之间的相似性。
exec/exit 就像 call/return。
一个 C 程序由很多函数组成。一个函数可以调用另外一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有它的局部变量,不同的函数通过 call/return 进行通信。
这种通过参数和返回值,在拥有私有数据的函数间通信的模式,是结构化程序设计的基础。Linux 鼓励将这种应用于程序之内的模式,扩展到程序之间:
cpp
调用者(函数) ──call/return──▶ 被调用函数(函数内)
调用者(进程) ──fork,exec───▶ 被调用的程序(新进程)
调用者(进程) ◀──exit────── 被调用的程序(返回值 n)
调用者(进程) ◀──wait(&ret)── 拿到返回值
一句话总结整个图: 一个 C 程序可以 fork/exec 另一个程序,并传给它一些参数。这个被调用的程序执行一定的操作,然后通过 exit(n) 来返回值。调用它的进程可以通过 wait(&ret) 来获取 exit 的返回值。

这就是 Linux 进程间协作的核心范式:fork(创建)→ exec(替换运行)→ exit(返回退出码)→ wait(回收并获取结果)。shell 就是这一范式的典型实现者,而你亲手写的这个 mini shell,就是它最好的例证。
5 面试 & 高频考点总结
Q1 进程程序替换会不会创建新进程?PID 变不变?
不会创建新进程。exec 只是把当前进程的用户空间代码和数据替换为新程序,调用 exec 前后该进程的 id 并未改变(PID 不变),PCB 保留。
Q2 exec 函数为什么只有出错的返回值?
因为 exec 一旦调用成功,进程代码就被新程序完全替换,exec 后面的代码根本不会执行,自然没有 "成功返回值";只有出错时才返回 -1 并继续执行原进程后续代码。
Q3 exec 函数簇中 l /v/p /e 分别代表什么?
l (list) 参数用列表;v (vector) 参数用数组;p (path) 有 p 自动搜索环境变量 PATH,无需写全路径;e (env) 自己维护环境变量,需手动组装环境变量数组。
Q4 六个 exec 函数,哪个是真正的系统调用?它们在 man 手册第几节?
只有 execve 是真正的系统调用,其他五个最终都调用 execve。execve 在 man 手册第 2 节(系统调用),其它五个在 man 手册第 3 节(库函数)。
Q5 带 p 和不带 p 的 exec 有什么区别?
带 p(execlp、execvp):会依次在 PATH 环境变量指示的各目录中查找该程序,因此无需写全路径;不带 p(execl、execv):必须写全路径。
Q6 带 e 和不带 e 的 exec 在环境变量上有什么区别?
不带 e(execl、execlp、execv、execvp):使用当前进程已有的环境变量表(environ);带 e(execle、execve):不使用当前环境变量,必须自己组装环境变量数组 envp 传入。
Q7 自己写一个 shell,核心五步是什么?
①获取命令行 → ②解析命令行 → ③fork 建立子进程 → ④exec 替换子进程 → ⑤父进程 wait 等待子进程退出。循环往复。
Q8 什么是内建命令?为什么 cd 必须是内建命令,不能交给子进程执行?
内建命令(built-in command)由 shell 自己执行,如 cd、export、env、echo。因为普通命令交给子进程执行时,子进程的修改(如 chdir 改变目录)不会影响父进程 shell;而 cd 必须改变 shell 自身的当前目录,所以必须由 shell 自己调用 chdir 执行。
Q9 为什么说 exec/exit 就像 call/return?
函数之间通过 call 传参、被调函数 return 返回值的通信模式,被 Linux 扩展到程序之间:一个程序用 fork/exec 调用另一个程序并传参,被调用程序用 exit (n) 返回值,调用方用 wait (&ret) 获取该返回值。二者结构完全类比。
