写在前面
fork() 解决的是"复制一个执行流",exec 函数族解决的是"让这个执行流改去运行另一个程序",waitpid() 则让父进程等待并回收子进程。把这三件事连起来,就得到了 Shell 执行外部命令的核心骨架。
这篇文章先讲清进程程序替换的原理、exec 函数族的命名规律、命令行参数与环境变量怎样交给新程序,再亲手搭出一个可以执行 ls、pwd、touch 等外部命令的第一版 Shell。
本篇严格停在"外部命令执行"这一进度:cd、export、env、echo $? 等内建命令,以及更完整的环境管理,留到后续实现。
知识框架
下面这张图先把全文主线串起来。阅读时只要始终抓住一句话:Shell 负责组织参数、创建子进程并等待;子进程负责通过 exec 换成目标程序。

目录
- 为什么创建子进程之后还要"换程序"
exec到底替换了什么exec函数族的命名规律- 命令行参数和环境变量从哪里来
- C/C++ 程序与脚本怎样被启动
proc.c:分阶段验证替换、参数与环境表- 自定义 Shell 的四步闭环
myshell.cc:第一版完整实现- 当前实现的边界与常见误区
- 总结
一、为什么创建子进程之后还要"换程序"
1. fork() 只复制,不会自动换成别的程序
父进程调用 fork() 后,系统得到父、子两个执行流。子进程会从 fork() 返回处继续执行,最初看到的代码与数据和父进程相同。
但现实需求往往不是"让子进程再跑一遍父进程的程序",而是:
- 父进程继续承担控制工作;
- 子进程去运行
ls、python3或用户编写的其他程序; - 父进程等待子进程结束并回收资源。
这就需要进程程序替换(process program replacement):让已经存在的进程加载另一份程序。
2. 为什么通常是 fork() 后让子进程调用 exec
如果父进程直接调用 exec,父进程自己的代码也会被新程序覆盖,原来的控制逻辑无法继续。
更常见的组合是:
cpp
pid_t id = fork();
if (id == 0)
{
// 子进程换成目标程序
execvp(...);
exit(1); // 能走到这里,说明替换失败
}
// 父进程保留原程序,等待子进程
waitpid(id, nullptr, 0);
这样既保留了父进程,又让子进程拥有了新的任务。Shell 执行外部命令用的正是这个基本模型。
二、exec 到底替换了什么
1. 换的是程序映像,不是进程身份
调用成功后,新程序的代码和数据被装入调用进程的用户地址空间,旧程序的用户态代码、数据、堆和栈随之被替换,并从新程序的启动入口开始执行。
但调用前后仍是同一个进程,因此它的 PID(Process ID,进程标识符)不变。可以把它概括为:
换程序,不换进程。

严格地说,不能把这个过程简单理解成"又创建了一个进程"。exec 会重建用户地址空间中的程序映射,但进程身份仍然延续。fork() 后的私有可写页通常采用写时拷贝(copy-on-write);只读代码页常可直接共享。调用 exec 时,旧映射被新程序映射替换,这与"代码页也先做一次普通内存复制"不是一回事。
2. 为什么 exec 成功后没有返回值
exec 函数的返回规律很特殊:
- 成功:旧代码已经不存在,不会回到原调用点;
- 失败:返回
-1,并设置errno。errno用来记录最近一次失败对应的错误编号,可以配合perror()查看可读原因。
因此下面这种判断没有成功分支:
cpp
execvp(g_argv[0], g_argv);
// 只有失败才会执行到这里
exit(1);
⚠️ 易错点:不能把"成功返回 0"套到 exec 上。只要 exec 返回了,就意味着失败。
3. PID 不变怎样验证
可以在替换前的子进程里打印一次 getpid(),再让目标程序启动后打印一次 getpid()。如果两次 PID 相同,就说明代码换了,进程身份没有换。
4. 它为什么像一个加载器
一个磁盘文件要真正运行,最终必须有代码和数据进入进程地址空间。exec 完成的正是"把指定程序加载进当前进程并开始执行"的关键动作。
这也解释了为什么它不只会启动系统命令:只要目标最终能以 Linux 进程的形式运行,就可以被 exec 启动。
三、exec 函数族的命名规律
1. 六个常用封装与一个底层入口
常见接口如下:
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[]);
GNU/Linux 的 glibc 还提供:
c
int execvpe(const char *file, char *const argv[], char *const envp[]);
其中 execve 是 Linux 系统调用入口,其他常用形式是 C 库为了方便不同传参场景提供的封装。
在 Linux 终端中可执行:
bash
man 2 execve
man 3 exec
第一条查看系统调用,第二条查看 C 库封装。成功打开手册后,可以重点寻找 SYNOPSIS、返回值和各字母的含义。
2. 四个字母记住全部规律
l= list:参数一个接一个地写在可变参数列表里;v= vector:参数先放进argv指针数组,再整体传入;p= PATH:只给文件名,由函数按PATH搜索;e= environment:调用者显式提供新的环境变量表。
例如:
c
execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
这里第一部分回答"执行谁",后面的字符串回答"怎样执行"。最后必须用 NULL 结束可变参数列表。
等价的数组风格可以写成:
c
char *const argv[] = {
(char *const)"ls",
(char *const)"-l",
(char *const)"-a",
NULL
};
execv("/usr/bin/ls", argv);
3. "执行谁"和 argv[0] 为什么看起来重复
下面两个 "ls" 的职责不同:
c
execlp("ls", "ls", "-l", NULL);
- 第一个
"ls"告诉execlp去找哪个可执行文件; - 第二个
"ls"会成为新程序收到的argv[0]。
按照惯例,argv[0] 放程序名,但内核并不会替你验证它是否真的等于文件名。写规范代码时仍应保持两者语义清楚。
4. p 的含义:按 PATH 搜索
没有 p 时,通常要提供绝对路径或相对路径:
c
execv("/usr/bin/ls", argv);
带 p 时可以只写文件名:
c
execvp("ls", argv);
execvp 会按当前进程环境中的 PATH 查找可执行文件。若传入的字符串本身含 /,例如 "./other",就按给定路径执行,不再做普通的 PATH 搜索。
5. 用一张图读懂 l/v/p/e
把接口名称拆开后,选择函数就不再需要死记:先判断参数是逐个传还是数组传,再判断是否需要 PATH 搜索和显式环境表。所有常用封装最终都围绕 execve 完成程序替换。

四、命令行参数和环境变量从哪里来
1. main 的参数不是凭空出现的
目标程序常见入口是:
cpp
int main(int argc, char *argv[], char *env[])
argc:命令行参数数量;argv:以空指针结尾的命令行参数表;env:以空指针结尾的环境变量表。
新程序收到的 argv,正是调用方执行 exec* 时交给它的那张表。Shell 先把用户输入拆成 argv,再通过 execvp 交给子进程中的新程序。
第三个 main 参数是许多 Unix/Linux 实现支持的扩展写法,不属于最可移植的 C/C++ 入口形式;需要可移植地读取环境时,可以使用 getenv() 或 extern char **environ。
2. 不显式传环境表时发生什么
execl、execv、execvp 等不带 e 的封装会沿用调用进程当前的环境。C/C++ 程序还可以通过:
c
extern char **environ;
取得当前环境表的入口。
如果使用带 e 的接口并显式传入 envp,新程序使用的是调用者提供的这张环境表,而不是自动把旧表与新表合并。
例如:
c
char *const envp[] = {
(char *const)"MYVAL=123456789",
NULL
};
execve("./other", argv, envp);
目标程序通常只会看到这张显式提供的表。若希望保留旧环境并增加新变量,可以先更新当前进程环境,再把 environ 传入。
3. putenv() 的作用方向
putenv() 修改的是调用它的进程的环境。父进程看不到子进程后来新增的变量;子进程却可以继承父进程已有的环境。
如果进程关系是 A → B → C,而 B 调用 putenv():
- A 看不到 B 新增的变量;
- B 自己能看到;
- B 之后创建的 C 可以继承它。
⚠️ 易错点:putenv() 通常直接把传入指针放进环境表,并不保证复制字符串,因此那块字符存储必须继续有效。局部数组离开作用域后再被环境表引用会产生悬空指针。需要更直观的"复制键和值"语义时,可以考虑 setenv()。
4. execvpe() 的平台边界
execvpe() 是 GNU 扩展,不是 POSIX(可移植操作系统接口标准)规定的接口。在 glibc(GNU/Linux 常用的 C 标准库实现)环境下,通常需要在包含头文件前定义 _GNU_SOURCE,编译器才能看到完整声明。
本篇保留原始代码与原始 Makefile,不擅自加宏。如果严格编译模式提示 execvpe 未声明,原因是功能测试宏,而不是程序替换原理失效。
还有一个容易忽略的 glibc 细节:execvpe() 搜索文件时读取的是调用进程当前环境中的 PATH,不是传入 envp 里的 PATH。本例传的是 "./other",字符串里已经含 /,因此不依赖这一步搜索。
五、C/C++ 程序与脚本怎样被启动
1. 用一个目标程序观察 argv 与环境表
文件名:other.cc
cpp
#include <iostream>
#include <cstdio>
#include <unistd.h>
int main(int argc, char *argv[], char *env[])
{
std::cout << "hello C++, My Pid Is: " << getpid() << std::endl;
for(int i = 0; i < argc; i++)
{
printf("argv[%d]: %s\n", i, argv[i]);
}
printf("\n");
for(int i = 0; env[i]; i++)
{
printf("env[%d]: %s\n", i, env[i]);
}
return 0;
}
这个程序没有复杂业务,只做三件事:
- 打印自己的 PID;
- 逐项打印
argv; - 逐项打印环境变量表。
在目标目录中执行:
bash
g++ other.cc -o other
这条命令把 other.cc 编译成名为 other 的可执行文件。成功后,目录中应出现 other,它供后面的 proc 通过 "./other" 启动。
2. 脚本实际启动的是解释器
文件名:other.py
python
#!/usr/bin/python3
print ("hello python")
文件名:other.sh
bash
#!/usr/bin/bash
echo "hello shell!"
Python、Shell 这类脚本需要解释器。可以把调用关系理解为:新进程映像来自解释器二进制,脚本路径则作为参数交给解释器。
例如,要通过显式解释器运行脚本,命令形态是:
bash
/usr/bin/python3 test/other.py
/usr/bin/bash test/other.sh
脚本首行的 #! 称为 shebang。直接执行具有权限的脚本时,系统会据此选择解释器。
⚠️ 易错点:当前目录里的脚本已经位于 test/ 子目录,而 proc.c 中相关调用只是注释下来的演示历史。若直接照注释使用 "other.py" 或 "other.sh",当前工作目录与文件路径必须匹配。
六、proc.c:分阶段验证替换、参数与环境表
1. 构建文件
文件名:Makefile
makefile
proc:proc.c
gcc -o $@ $^ -std=c99
.PHONY:clean
clean:
rm -f proc
在目标目录执行:
bash
make
$@ 代表目标名 proc,$^ 代表全部依赖文件 proc.c。成功后会生成 proc。
注意:这个 Makefile 只构建 proc,不会自动构建 other。因此完整实验顺序是先执行:
bash
g++ other.cc -o other
make
./proc
2. 主程序的演进:先替换,再传参数,最后传环境
proc.c 同时保留了多次实验的调用记录。如果把生效代码和所有历史调用整块当成一个"最终版本",反而会混淆每一步解决的问题。我按接口演进拆开来看;下面都是阶段片段,不能单独编译。
第一版只验证"子进程换程序,父进程继续运行"。列表风格的调用点依次尝试了系统命令、自己编译的程序和脚本解释器:
c
//execlp("/usr/bin/ls", "ls", "-ln", "-a", NULL);
//child
//execl("/usr/bin/ls", "/usr/bin/ls", "-ln", "-a", NULL);
//execl("./other", "other", NULL);
//execl("/usr/bin/python3", "python", "other.py", NULL);
//execl("/usr/bin/bash", "bash", "other.sh", NULL);
第二版把命令行参数组织成以 NULL 结尾的 argv 数组,开始使用 v 风格接口:
c
char *const argv[] = {
(char*const)"other",
(char*const)"-a",
(char*const)"-b",
(char*const)"-c",
(char*const)"-d",
NULL
};
//execvp("./other", argv);
//execv("/usr/bin/ls", argv);
最后一版增加三条环境变量,再把当前环境表交给 execvpe():
c
char *const addenv[] = {
(char *const)"MYVAL=123456789",
(char *const)"MYVAL1=123456789",
(char *const)"MYVAL2=123456789",
NULL
};
for(int i = 0; addenv[i]; i++)
{
putenv(addenv[i]);
}
extern char **environ;
execvpe("./other", argv, environ);
无论换哪一种接口,控制骨架都不变:子进程负责替换,父进程负责等待;exec 一旦返回,子进程就按失败路径退出。
讲解片段(注释为博客补充,不是完整程序)
c
if(fork() == 0)
{
// 这里准备 argv、环境表并调用 exec*
exit(1);
}
waitpid(-1, NULL, 0);
printf("我的程序运行完毕了\n");
3. 顺着执行路径读代码
程序入口先打印"我的程序要运行了",随后调用 fork():
- 子进程进入
if; - 父进程跳过
if,在waitpid(-1, NULL, 0)处阻塞等待任意一个子进程。
子进程先打印 PID,再准备 argv:
other -a -b -c -d
然后循环调用 putenv(),把三条 MYVAL* 变量增量加入子进程当前环境。接着把 environ 传给 execvpe(),因此新程序既能看到原环境,也能看到新增变量。
execvpe("./other", argv, environ) 成功后,子进程开始运行 other,PID 不变,exit(1) 不会执行。若失败,才会落到 exit(1)。
父进程等到子进程结束后打印"我的程序运行完毕了"。
4. 当前代码的限制
- 没有检查
fork()失败; - 没有通过
perror()打印execvpe()失败原因; waitpid()的退出信息参数传了NULL,父进程不知道子进程的退出码;putenv()接收的是强制转换后的字符串字面量。它们具有静态存储期,所以生命周期足够长,但不应尝试修改;- 在某些严格编译环境中,
execvpe()需要_GNU_SOURCE才有声明。
这些限制不会改变本例要观察的主线:参数表和环境表由调用方准备,子进程通过 exec 换成目标程序,父进程等待。
七、自定义 Shell 的四步闭环
1. 第一步:打印提示符
一个最小提示符可以由用户名、主机名、当前目录和提示字符组成,例如 [用户名@主机名 当前目录]#。
代码通过 getenv("USER")、getenv("HOSTNAME") 和 getenv("PWD") 读取现有环境变量,再用 snprintf() 安全地拼进固定大小的缓冲区。
因为提示符末尾没有换行,打印后要调用:
cpp
fflush(stdout);
其中 stdout 是标准输出流。若不主动刷新,缓冲机制可能让提示符不能立即显示。
2. 第二步:读取整行输入
scanf("%s", ...) 会把空格当分隔符,不适合一次读入:
bash
ls -a -l
因此使用 fgets() 从标准输入流 stdin 读取整行。fgets() 会保留换行符,所以代码再把末尾的 '\n' 改成 '\0'。
3. 第三步:把字符串切成 argv
用户输入是一整段字符:
bash
ls -a -l
而 execvp() 需要:
cpp
char *argv[] = {
(char *)"ls",
(char *)"-a",
(char *)"-l",
nullptr
};
strtok() 会把分隔符位置改写成 '\0',并返回每个子串的起始地址。第一次传原字符串,之后传 nullptr,直到返回空指针。
这里的 g_argv 只保存指向 commandline 内部的指针,没有复制各个单词。因此 commandline 必须在 execvp() 使用完这些指针之前一直有效。当前代码在同一次循环内完成解析、fork 和执行,生命周期满足要求。
4. 第四步:fork + execvp + waitpid
解析完成后:
- Shell 父进程调用
fork(); - 子进程调用
execvp(g_argv[0], g_argv); - 父进程调用
waitpid(); - 回收完成后进入下一轮,再次打印提示符。

八、myshell.cc:第一版完整实现
1. 构建文件
文件名:Makefile
makefile
myshell:myshell.cc
g++ -o $@ $^ -std=c++11 #-std=c99
.PHONY:clean
clean:
rm -f myshell
进入 myshell 目录执行:
bash
make
./myshell
make 成功后生成 myshell,第二条命令启动它。可以测试 ls -l、pwd、touch demo.txt 等外部命令;按 Ctrl+C 结束当前 Shell。
2. 完整源码
文件名:myshell.cc
cpp
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s %s]# "
// 下面是shell定义的全局数据
#define MAXARGC 128
char *g_argv[MAXARGC];
int g_argc = 0;
const char *GetUserName()
{
const char *name = getenv("USER");
return name == NULL ? "None" : name;
}
const char *GetHostName()
{
const char *hostname = getenv("HOSTNAME");
return hostname == NULL ? "None" : hostname;
}
const char *GetPwd()
{
const char *pwd = getenv("PWD");
return pwd == NULL ? "None" : pwd;
}
void MakeCommandLine(char cmd_prompt[], int size)
{
snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());
}
void PrintCommandPrompt()
{
char prompt[COMMAND_SIZE];
MakeCommandLine(prompt, sizeof(prompt));
printf("%s", prompt);
fflush(stdout);
}
bool GetCommandLine(char *out, int size)
{
// ls -a -l => "ls -a -l\n" 字符串
char *c = fgets(out, size, stdin);
if(c == NULL) return false;
out[strlen(out)-1] = 0; // 清理\n
if(strlen(out) == 0) return false;
return true;
}
// 3. 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
bool CommandParse(char *commandline)
{
#define SEP " "
g_argc = 0;
// 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
g_argv[g_argc++] = strtok(commandline, SEP);
while((bool)(g_argv[g_argc++] = strtok(nullptr, SEP)));
g_argc--;
return true;
}
void PrintArgv()
{
for(int i = 0; g_argv[i]; i++)
{
printf("argv[%d]->%s\n", i, g_argv[i]);
}
printf("argc: %d\n", g_argc);
}
int Execute()
{
pid_t id = fork();
if(id == 0)
{
//child
execvp(g_argv[0], g_argv);
exit(1);
}
// father
pid_t rid = waitpid(id, nullptr, 0);
(void)rid; // rid使用一下
return 0;
}
int main()
{
while(true)
{
// 1. 输出命令行提示符
PrintCommandPrompt();
// 2. 获取用户输入的命令
char commandline[COMMAND_SIZE];
if(!GetCommandLine(commandline, sizeof(commandline)))
continue;
// 3. 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
CommandParse(commandline);
//PrintArgv();
// 4. 执行命令
Execute();
}
return 0;
}
一个小插曲:为什么 getenv("HOSTNAME") 获取不到主机名
在编写自定义 Shell 的命令行提示符时,我最初通过下面的代码获取主机名:
cpp
const char *GetHostName()
{
const char *hostname = getenv("HOSTNAME");
return hostname == nullptr ? "None" : hostname;
}
运行程序后,提示符中的主机名显示为 None。但在 Linux 终端中执行:
bash
echo "$HOSTNAME"
却可以正常打印主机名。
问题排查
继续使用下面的命令检查:
bash
printenv HOSTNAME
发现没有任何输出。
这说明 HOSTNAME 虽然是当前 Bash 能够识别的 Shell 变量,但它没有被导出到环境变量表中。
echo "$HOSTNAME" 是由 Bash 先完成变量展开,再把结果交给 echo,因此即使变量没有导出,也可以正常显示。
而 C/C++ 中的 getenv() 只能读取当前进程继承到的环境变量,不能读取 Bash 内部尚未导出的普通 Shell 变量。因此:
cpp
getenv("HOSTNAME");
返回了空指针。
临时解决方法
可以在终端中导出 HOSTNAME:
bash
export HOSTNAME
或者明确使用系统主机名赋值:
bash
export HOSTNAME="$(hostname)"
然后再次检查:
bash
printenv HOSTNAME
此时子进程便可以通过 getenv("HOSTNAME") 读取主机名。
但是,这种方案依赖外部环境。如果换一个终端、调试器或运行方式,HOSTNAME 仍然可能不存在。
推荐方案:使用 gethostname()
主机名属于系统信息,更合理的做法是直接使用 Linux 提供的 gethostname() 接口,而不是依赖环境变量。
myshell.cc 已经包含:
cpp
#include <unistd.h>
因此可以把 GetHostName() 修改为:
cpp
const char *GetHostName()
{
// 使用 static,保证函数返回后字符数组依然存在
static char hostname[256] = {0};
// 预留一个位置存放字符串结束符 '\0'
if(gethostname(hostname, sizeof(hostname) - 1) == -1)
{
return "None";
}
// 防止主机名过长时没有自动添加结束符
hostname[sizeof(hostname) - 1] = '\0';
return hostname;
}
这段代码的作用
gethostname() 直接向系统获取当前主机名:
- 成功时返回
0; - 失败时返回
-1; - 主机名会被写入
hostname数组; - 使用
static可以保证函数结束后数组仍然有效; - 最后手动补上
'\0',避免字符串没有正确结束。
修改后,命令行提示符的生成方式保持不变:
cpp
void MakeCommandLine(char cmd_prompt[], int size)
{
snprintf(
cmd_prompt,
size,
FORMAT,
GetUserName(),
GetHostName(),
GetPwd()
);
}
最终结论
这次问题并不在 snprintf(),而在主机名的数据来源。
echo "$HOSTNAME"可以读取 Bash 内部的 Shell 变量;getenv("HOSTNAME")只能读取已经导出的环境变量;HOSTNAME没有导出时,getenv()会返回空指针;- 主机名属于系统信息,推荐使用
gethostname(); - 当前工作目录属于进程状态,推荐使用
getcwd()。
因此,自定义 Shell 获取提示符信息时,更稳妥的方案是:
text
用户名:getenv("USER")
主机名:gethostname()
当前工作目录:getcwd()
3. 从 main() 看整体节奏
main() 只有一个无限循环,循环体恰好对应 Shell 的四个阶段:
PrintCommandPrompt():打印提示符;GetCommandLine():读取一整行;CommandParse():建立g_argv;Execute():创建子进程、替换、等待。
这就是第一版命令行解释器的骨架。
4. 提示符相关函数
GetUserName()、GetHostName()、GetPwd() 都通过 getenv() 读取环境。若找不到变量就返回 "None",避免把空指针直接交给 %s。
5. 输入函数的边界
当前实现默认 fgets() 读到的最后一个字符一定是换行符:
cpp
out[strlen(out)-1] = 0;
正常交互输入通常成立,但若输入超过缓冲区、文件结束前没有换行,最后一个有效字符可能被误删。更稳健的实现应先检查末尾是否真是 '\n',但这里不修改原代码,只明确当前边界。
6. 参数解析的边界
strtok(commandline, " ") 只能处理最基本的空格切分,它暂不理解:
- 引号与转义;
- 制表符和更复杂的空白;
- 管道
|; - 输入输出重定向;
- 后台任务
&; - 通配符展开;
- 超过
MAXARGC的大量参数。
因此它是帮助理解 Shell 主路径的最小模型,不是 Bash 的完整替代品。
7. 执行函数的资源路径
Execute() 中只有子进程调用 execvp()。父进程执行 waitpid(id, nullptr, 0),阻塞到这个子进程结束。
当前实现忽略了子进程退出状态,也没有处理:
fork()返回-1;execvp()失败原因;waitpid()被信号中断;- 子进程退出码。
这些都是后续增强点,但资源关系已经正确:父进程等待并回收自己创建的子进程。
九、当前实现的边界与常见误区
1. cd 为什么不能照搬外部命令路径
当前 Shell 把所有命令都交给子进程。即使子进程成功调用 chdir(),改变的也只是子进程自己的当前工作目录;子进程结束后,Shell 父进程仍在原目录。
cd 必须由 Shell 自己执行,因为它要修改 Shell 的持久状态。这类命令称为内建命令(built-in command)。本篇只识别这个边界,不提前实现。
2. PWD 不一定等于内核记录的真实当前目录
当前提示符从环境变量 PWD 取值,而不是调用 getcwd()。如果程序改变了当前目录却没有同步更新 PWD,显示值可能过期。
3. exec 成功不返回,失败也不等于进程自动结束
exec 失败时只是返回 -1。调用者必须决定如何报告错误、设置退出码和结束子进程。当前源码选择直接 exit(1)。
4. argv 必须以空指针结尾
无论手工构造还是由 strtok() 生成,参数表最后都要有 NULL。否则 exec 无法确定数组在哪里结束。
5. strtok() 会修改原字符串
它会把分隔符改写成 '\0'。因此不能把只读字符串直接交给它,也不能指望解析后仍保留原始命令行的完整字面内容。
6. 第一版 Shell 不是 Bash
它只能解释最简单的"命令 + 空格分隔参数",尚未支持引号、转义、管道、重定向、通配符、变量展开、作业控制和信号转发。理解主流程之后,再逐项补这些能力,结构才不会乱。
总结
进程程序替换的核心是:exec 把新程序装进已有进程,PID 不变,成功后旧代码不再继续执行。l/v/p/e 分别描述参数列表、参数数组、PATH 搜索和显式环境表,最终都围绕"执行谁、怎样执行、带什么环境"展开。
第一版 Shell 则把前面的知识连成了闭环:打印提示符,读取整行,切分出 argv,fork() 创建子进程,子进程 execvp() 执行外部命令,父进程 waitpid() 等待回收,再回到下一轮。至此,命令行为什么能启动程序、main 的参数从哪里来、Shell 为什么要维护参数表,都有了可以落到代码上的答案。