前言:这篇介绍进程程序替换,认识 exec 函数族,再把进程创建、程序替换和进程等待结合起来,然后再写一个能执行简单命令的 Shell。
1.进程程序替换
前面通过 fork 创建了子进程,但父子进程运行的仍然是同一个程序,只是可以进入不同的代码分支。
如果想让子进程去运行另一个程序,比如执行 ls 查看文件,该怎么做?这就需要用到进程程序替换。
程序替换会把磁盘上的另一个程序加载进来,让当前进程开始执行它。原程序的代码和数据被替换,接下来运行的是新程序。
这里先记住两个结论:
- fork 创建新进程,exec 替换当前进程运行的程序。
- 程序替换成功后,不会再返回原来的调用位置;替换失败才返回 -1,并设置 errno。
先看一个直接进行程序替换的例子:

程序先打印“程序开始执行”,随后通过 execl 执行 ls,终端上显示的就是文件列表。后面的“程序执行结束”没有打印。
这里被替换的是整个原程序,不执行后半段代码。替换成功以后,当前进程就去执行 ls 了,等 ls 结束也不会回来打印“程序执行结束”。
再来看父子进程配合的情况:

先用 fork 创建子进程,子进程打印自己的 PID,再执行 execl,运行已经编译好的 ./other。
替换前打印的是 11173,新程序打印的也是 11173。这说明执行程序发生了变化,但还是原来的那个子进程,PID 没变。
父进程没有进行程序替换,所以它等待子进程结束之后,仍然可以打印“程序执行结束”。
2.exec 函数族
平时说的 exec 是一组函数的统称。它们做的事情相同,主要区别在于:怎样传参数、怎样找程序、给新程序什么环境变量。
函数名不用一个个死记,先看后面的几个字母:
| l | list | 把参数一个个列出来 |
| v | vector | 把参数放在指针数组中传入,这里不是 C++ 的 std::vector |
| p | PATH | 文件名不含 / 时,按照环境变量 PATH 查找程序 |
| e | environment | 自己传入新程序要使用的环境变量表 |
组合起来,常见的几个接口就是:
| execl | 逐个列出 | 按给定路径 | 使用当前环境 |
| execlp | 逐个列出 | 可以按 PATH 查找 | 使用当前环境 |
| execle | 逐个列出 | 按给定路径 | 由 envp 指定 |
| execv | 指针数组 | 按给定路径 | 使用当前环境 |
| execvp | 指针数组 | 可以按 PATH 查找 | 使用当前环境 |
| execve | 指针数组 | 按给定路径 | 由 envp 指定 |
这里的路径既可以是 /usr/bin/ls 这样的绝对路径,也可以是 ./other 这样的相对路径,不是一定要把完整路径写出来。
2.1execl 与 execlp
先看 execl,可以把它的参数理解为:执行谁,给它哪些参数。

下面用几段 C 代码说明接口用法,每段分别理解即可,不要把它们当作会依次执行的程序。
execl("/bin/ps", "ps", "-ef", NULL);
这几个参数分别表示:
| "/bin/ps" | 要执行的程序路径 |
| "ps" | 传给新程序的 argv[0],通常写程序名 |
| "-ef" | 传给新程序的选项,对应 argv[1] |
| NULL | 表示参数列表结束 |
第一个 "/bin/ps" 是用来找程序的,后面的 "ps" 才是新程序看到的第一个命令行参数,二者的作用不同。
图中“命令行怎么写就怎么写”,指的是把程序名和各个选项分别传进去。例如前面的 ls -l -a,要分别传入 "ls"、"-l"、"-a",不能把整条命令当成一个字符串交给 execl 解析。
末尾要传的是空指针。在 execl 这类可变参数函数中,可以用 (char *)NULL 明确它的类型。
execlp 比 execl 多了一个 p,使用起来更接近平时输入命令的习惯:
execlp("ps", "ps", "-ef", NULL);

这里第一个参数只写 "ps",函数就会按照当前环境变量 PATH 中的目录去查找。
需要注意的是,只有文件名中不含 / 时才这样搜索。如果传入的是 ./other 或 /bin/ps,就按照给定路径执行。
2.2execv 与 execvp
参数数量少时,一个个列出来比较直观;如果参数是运行过程中才拿到的,放进数组会方便很多。
execv 使用的就是以空指针结尾的参数数组:
char *const argv[] = {"ps", "-ef", NULL};
execv("/bin/ps", argv);
数组中,argv[0] 是 "ps",argv[1] 是 "-ef",最后一个元素为 NULL。接口通过这个空指针知道参数已经结束。
如果还想按照 PATH 查找,就沿用这个数组,改用 execvp:
execvp("ps", argv);
execvp 很适合用来编写简陋 Shell。 因为用户这一行输入了几个参数,我们事先并不知道;读取命令后,把它拆成数组,再交给 execvp 就可以了。
这里的数组示例按 C 写法理解,后面的 Shell 则使用 C++ 实现,参数直接指向读入的命令字符串。
2.3环境变量怎么传
没有 e 的接口,会把当前进程的环境变量交给新程序;带 e 的接口,则使用我们指定的环境变量表。
沿用前面的 argv,可以这样为 execve 指定环境变量:
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
execve("/bin/ps", argv, envp);
argv 里放命令行参数,envp 里放环境变量。它们都是指针数组,也都以空指针结尾,只是里面的字符串含义不同。
环境变量按 名字=内容 的形式组织。例如上面的 PATH=/bin:/usr/bin,就是新程序能拿到的一个环境变量。显式传入 envp 时,新程序使用的就是这张表,不会自动把遗漏的原环境变量补进来。
另外还能看到把 v、p、e 合在一起的 execvpe:

这些接口最终完成的是同一件事。在上面列出的六个接口中,execve 是系统调用,其他几个是在它的基础上提供更方便的传参、查找方式。先根据参数形式选接口,具体声明需要时再查就行。
3.编写一个简陋 Shell
现在把前面的内容连起来。我们输入 ls,终端就能列出文件,是因为 Shell 读到了命令,然后让相应的程序去执行。
对于这里要实现的简单外部命令,流程就是:
打印提示符 → 读取命令 → 拆分参数 → 创建子进程 → 子进程替换程序 → 父进程等待 → 继续读取下一条命令。
为什么不让 Shell 自己直接调用 exec?因为替换成功以后,它自己就去运行 ls 了,原来的读命令循环也没了。所以需要让子进程执行外部程序,父进程继续保留 Shell 的工作。
除此之外,还要提前判断是不是内建命令。这份实现中的 cd 和 echo,会直接在 Shell 进程中处理。
3.1文件划分与主循环
整个实现分为三个文件:
| myShell.h | 头文件、常量、全局变量声明和函数声明 |
| myShell.cpp | 提示符、命令读取、参数拆分、内建命令和外部命令执行 |
| main.cpp | 把各个步骤组织成循环 |
先看 myShell.h:
#include <stdio.h>
#include <cstring>
#include <stdlib.h>
#include <string>
#include <unistd.h>
#include <iostream>
#include <sys/types.h>
#include <sys/wait.h>
#define FORMAT "[%s@%s %s]#"
#define COMMAND_SIZE 1024
// 命令行参数表
#define MAXARGC 248
extern char *g_argv[MAXARGC];
extern int g_argc;
// 环境变量表
#define MAXENVS 248
extern char *g_env[MAXENVS];
extern int g_envc;
// 模拟进程工作路径
extern char cwd[1024];
extern char cwdenv[1024];
// 退出码
extern int lastcode;
// 初始化环境变量
void InitEvn();
// 打印命令行
void PrintCommandPrompt();
// 获取用户输入
bool GetCommandLine(char* commandline, int size);
// 处理命令
bool CommandParse(char* commandline);
// 处理内联命令
bool CheckExecBuiltin();
// 执行
int ExecCommand();
g_argv 保存拆分后的命令行参数,g_argc 记录参数个数;g_env 保存环境变量字符串,g_envc 记录数量。lastcode 用来保存后面取得的外部命令退出码。
cwd 和 cwdenv 虽然都与路径有关,用途却不同:前者放当前工作目录,后者放 PWD=路径 这样的环境变量字符串。
再看 main.cpp,先从整体上了解执行顺序:
#include "myShell.h"
int main()
{
InitEvn();
while (1)
{
PrintCommandPrompt();
char commandline[COMMAND_SIZE];
if (!GetCommandLine(commandline, sizeof(commandline)))
continue;
if (!CommandParse(commandline))
continue;
if (CheckExecBuiltin())
continue;
ExecCommand();
}
return 0;
}
启动时先初始化环境,之后不断打印提示符、获取命令、拆分参数。
如果本轮没有读到有效输入,就通过 continue 重新开始;如果命中了内建命令,处理完也直接进入下一轮。剩下的命令才交给 ExecCommand。
下面按功能展开 myShell.cpp,各段按顺序接起来就是这个文件的内容。
3.2环境变量与命令提示符
先定义全局变量,并完成环境变量的初始化:
#include "myShell.h"
char *g_argv[MAXARGC];
int g_argc = 0;
char *g_env[MAXENVS];
int g_envc = 0;
int lastcode = 0;
char cwd[1024];
char cwdenv[1024];
void InitEvn()
{
extern char **environ;
memset(g_env, 0, sizeof(g_env));
g_envc = 0;
for (int i = 0; environ[i]; i++)
{
g_env[i] = (char*)malloc(strlen(environ[i]) + 1);
strcpy(g_env[i], environ[i]);
putenv(g_env[i]);
g_envc += 1;
}
}
environ 指向当前进程的环境变量表,里面保存的是一个个 名字=内容 字符串。
循环取出这些字符串,申请空间、复制内容,再调用 putenv 放到当前进程的环境中。这里的 g_env 保存这些字符串的地址。
putenv 会继续使用传入的那块字符串空间,所以这里用 malloc 分配的空间不能在放进去后马上释放。后面保存 PWD 的 cwdenv 也是全局数组,函数返回后仍然存在。
接下来获取提示符需要的信息:
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()
{
if (getcwd(cwd, sizeof(cwd)) != NULL)
{
snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);
putenv(cwdenv); // 同步维护当前进程的 PWD 环境变量
return cwd;
}
return "None";
}
const char* GetHome()
{
const char* home = getenv("HOME");
return home == NULL ? "None" : home;
}
std::string CutPwd(const char* pwd)
{
std::string str(pwd);
if (str == "/")
{
return str;
}
auto pos = str.rfind("/");
if (pos == std::string::npos)
return "BUG";
return str.substr(pos + 1);
}
void PrintCommandPrompt()
{
char commandLine[COMMAND_SIZE];
snprintf(commandLine, sizeof(commandLine), FORMAT, GetUserName(), GetHostName(), CutPwd(GetPwd()).c_str());
printf("%s", commandLine);
fflush(stdout);
}
这一段主要做三件事。
首先,getenv 通过名字读取环境变量。GetUserName 读取 USER,GetHostName 读取 HOSTNAME,GetHome 读取 HOME。如果对应变量不存在,就返回 "None"。
其次,GetPwd 用 getcwd 取得进程当前的工作目录,再把它组织成 PWD=… 放入环境变量。**实际工作目录由进程维护,我这里只是简单的模拟一下。
CutPwd 则取路径最后一段,让提示符短一些。例如完整路径为 /home/hjy/MyShell,提示符里只显示 MyShell;如果当前就在根目录 /,则直接保留 /。
最后,PrintCommandPrompt 按照 FORMAT 拼接并打印提示符。这里没有打印换行,因此紧跟一个 fflush(stdout),让提示符及时显示出来,再等用户输入。
末尾的 # 也是我们自己打印的字符,主要是未来做区分并不是获得了root身份。
3.3读取命令并拆分参数
Shell 读到的首先是一整行字符串。要交给 execvp,还需要把程序名和各个参数拆出来:
bool GetCommandLine(char* commandline, int size)
{
char *tmp = fgets(commandline, size, stdin);
if (tmp == NULL)
return false;
commandline[strlen(commandline) – 1] = 0; // 清理 \\n
if (strlen(commandline) == 0)
return false;
return true;
}
bool CommandParse(char* commandline)
{
#define BLANK " "
g_argc = 0;
g_argv[g_argc++] = strtok(commandline, BLANK);
while ((bool)(g_argv[g_argc++] = strtok(NULL, BLANK)));
g_argc -= 1;
return g_argc > 0 ? true : false;
}
fgets 从标准输入读取一行内容。在正常输入一行并按下回车的情况下,结尾会带有 \\n,所以代码把最后一个字符改成 0,去掉这个换行。
如果只按回车,去掉换行后就是空字符串,当前这轮不执行命令。
接下来用 strtok 按空格拆分。比如输入 ls -l -a,拆完之后就是:
| g_argv[0] | 指向 "ls" |
| g_argv[1] | 指向 "-l" |
| g_argv[2] | 指向 "-a" |
| g_argv[3] | 空指针 |
第一次调用 strtok 时传入 commandline,后面传入 NULL,表示继续拆分刚才那一行。它会修改原字符串,把分隔位置改成字符串结束符,再返回各段的地址。
3.4内建命令 cd 与 echo
普通外部命令可以交给子进程执行,但 cd 要单独处理。
子进程改变自己的工作目录,不会改变父进程的工作目录。 如果把 cd 放到子进程中,等子进程退出,Shell 自己还留在原来的目录,下一条命令也就没有真正“切换过去”。
因此,cd 必须在当前 Shell 进程中执行。这里也把 echo 放在内部处理,方便打印文字、查看环境变量和保存的退出码:
void Cd()
{
if (g_argc == 1) // 回到家目录
{
const char* homedir = GetHome();
chdir(homedir);
}
else
{
std::string where(g_argv[1]);
if (where == "~")
{
chdir(GetHome());
}
else
{
chdir(where.c_str());
}
}
}
void Echo()
{
if (g_argc == 2)
{
std::string opt = g_argv[1];
if (opt == "$?")
{
std::cout << lastcode << std::endl;
lastcode = 0;
}
else if (opt[0] == '$') // 获取环境变量
{
std::string envName = opt.substr(1);
const char* envValue = getenv(envName.c_str());
if (envValue != NULL)
std::cout << envValue << std::endl;
else
std::cout << std::endl;
}
else
{
std::cout << opt << std::endl;
}
}
}
bool CheckExecBuiltin()
{
std::string command(g_argv[0]);
if (command == "cd")
{
Cd();
return true;
}
else if (command == "echo")
{
Echo();
return true;
}
return false;
}
Cd 的处理比较直接:
- 只有 cd,没有其他参数:通过 HOME 回到家目录。
- 参数恰好是 ~:同样回到家目录。
- 传入其他路径:把这个路径交给 chdir。
chdir 成功后,Shell 进程的工作目录发生变化。下一次打印提示符时,GetPwd 会重新取得路径,并更新 PWD,后面创建的子进程也会继承这个工作目录。
我只实现了一小部分功能,毕竟主要目的还是学习。
Echo 则只处理命令后面恰好有一个参数的情况:
| echo hello | 打印 hello |
| echo $HOME | 通过 getenv 读取 HOME 并打印 |
| echo $? | 打印 lastcode,随后把它设置为 0 |
这里的 $HOME、$? 是 Echo 函数自己判断并处理的,还没有实现对所有命令通用的变量替换。
CheckExecBuiltin 负责看命令名是不是 cd 或 echo,如果是就执行对应函数,并返回 true。主循环拿到这个结果,直接进入下一轮,不再创建子进程。
3.5执行外部命令并等待
命令读取、参数数组和内建命令都准备好之后,外部命令的执行部分反而不长:
int ExecCommand()
{
pid_t id = fork();
if (id == 0)
{
// child
execvp(g_argv[0], g_argv);
exit(1);
}
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
lastcode = WEXITSTATUS(status);
}
return 0;
}
先通过 fork 创建子进程。子进程中 id == 0,于是调用:execvp(g_argv[0], g_argv)。
这里的两个参数正好对应前面拆分的结果:
- g_argv[0]:要执行的程序名,比如 ls。
- g_argv:完整参数数组,其中包括程序名、选项以及末尾的空指针。
execvp 成功后,子进程就去运行外部程序;只有失败才会执行 exit(1),避免继续进入 Shell 的读命令循环。
父进程走到 waitpid(id, &status, 0),阻塞等待刚才创建的子进程。
等子进程结束,父进程回收它的资源,并从 status 中取得退出码,保存到 lastcode。然后 ExecCommand 返回,主循环再次打印提示符,等待下一条命令。
来看简单外部命令的运行效果:

进入自制 Shell 后,分别输入 ls、pwd、whoami,能看到文件列表、当前路径和用户名。每执行完一条命令,提示符都会重新出现,说明父进程一直保留着读命令的循环。
完
网硕互联帮助中心





评论前必须登录!
注册