进程程序替换
一、为什么需要程序替换?
在 Linux 中,我们可以使用 "fork()" 创建子进程。子进程会获得父进程的执行环境,并从 "fork()" 返回的位置继续执行。
但是,在实际开发中,子进程往往需要执行与父进程不同的程序。例如,Shell 接收到用户输入的 "ls" 命令后,就需要让相应的进程执行 "ls" 程序。
此时就需要使用程序替换机制。
程序替换是指通过特定的接口,将磁盘上的新程序加载到当前进程的地址空间中,替换原来的用户空间程序映像,并从新程序的启动代码开始执行。
需要注意,程序替换不会创建新进程。调用 "exec" 前后,进程的 PID 不会改变。
因此,"fork()" 和 "exec()" 的作用不同:
- "fork()":创建一个新的进程。
- "exec()":让当前进程执行另一个程序。
- "fork() + exec()":先创建子进程,再让子进程执行指定程序。
程序替换不会改变PID
假设子进程的 PID 是 1234。
调用 execl() 之前,子进程的 PID 是 1234;调用成功后,运行 ls 的进程 PID 仍然是 1234。
因为 exec 替换的是进程的程序映像,而不是创建一个新进程。
二、exec 函数族
Linux 常见的 exec 系列函数有六个:
只要理解函数名称中几个字母的含义,就可以把它们记住。
其中,l 和 v 决定参数怎么传,p 决定怎么查找程序,e 决定怎么指定环境变量。
1.l:参数列表传递
例如:这里的参数依次是:
execl("/bin/ls", "ls", "-l", "-a", NULL);
"/bin/ls":要执行的程序的路径。
"ls":新程序接收到的 argv[0],通常用于表示程序名。
"-l":第一个命令行参数。
"-a":第二个命令行参数。
NULL:参数列表的结束标志。
这相当于在终端执行:
ls -l -a
为什么最后要写NULL
因为 execl() 的参数是通过可变参数列表传递的,函数需要知道参数在哪里结束。
2.v:参数数组传递
如果参数很多,或者参数需要动态生成,可以使用数组
这里的 argv 相当于:
数组同样必须使用NULL作为结束标志
对比一下:
两种写法都可以执行相同的命令,区别仅在于参数的组织方式。
3.p:自动搜索 PATH
先看两种写法:
第一种写法指定了程序的完整路径 /bin/ls。
第二种写法只提供程序名 ls,带有 p 的函数会按照 PATH 环境变量查找可执行文件。
例如,环境变量可能包含:
程序就会依次在这些目录中搜索 ls。
因此不带 p:通常需要指定要执行文件的路径。
带 p:可以通过 PATH 搜索程序。
严格来说,不带 p 的函数并不强制要求使用绝对路径,也可以传入相对路径;关键是它不会像带 p 的函数那样自动搜索 PATH。
4.e:自己指定环境变量
程序运行时可以读取环境变量,例如 PATH、HOME 等。
通常,新程序会继承调用进程的环境变量。如果希望为新程序提供一套指定的环境变量,就可以使用带 e 的函数。
例如:
这里的 envp 就是新程序的环境变量数组。
它同样以 NULL 结尾。
需要注意,使用 e 并不意味着在原有环境变量上自动追加几个变量,而是将你传入的环境变量数组作为新程序的环境。因此,如果没有把 HOME 等变量放进去,新程序就不一定能读取到它们。
execle() 和 execve() 都可以指定环境变量,但传参方式不同。
execle() 的 NULL 用来结束可变参数列表,然后才是 envp。
而 execve() 直接通过数组 argv 传递参数,不需要额外的可变参数结束标志
5. e:自己指定环境变量
程序运行时可以读取环境变量,例如 PATH、HOME 等。
通常,新程序会继承调用进程的环境变量。如果希望为新程序提供一套指定的环境变量,就可以使用带 e 的函数。
例如:
这里的 envp 就是新程序的环境变量数组。
它同样以 NULL 结尾。
需要注意,使用 e 并不意味着在原有环境变量上自动追加几个变量,而是将你传入的环境变量数组作为新程序的环境。因此,如果没有把 HOME 等变量放进去,新程序就不一定能读取到它们。
execve 才是真正的系统调用
六个函数中:
execl()、execlp()、execle()、execv()、execvp() 是供程序使用的库函数接口。
execve() 对应 Linux 的底层系统调用接口。
其他五个函数通常会在用户空间完成参数组织、环境处理或 PATH 搜索等工作,最终调用 execve() 执行程序替换。
例如:
由于 execlp() 需要根据 PATH 找到 ls,它可能先尝试多个候选路径,然后调用 execve() 执行找到的程序。
在 Linux 的手册中,execve 位于第 2 节,即系统调用;其他几个函数位于第 3 节,即库函数。
exec 调用成功后不会返回
因为原来的程序已经被新程序替换了。成功之后执行的是新程序的代码,并不是原程序继续往下运行。
这就是 exec 最重要的特点:成功不返回,失败才返回。
三.fork() 和 exec() 如何配合使用
这个程序的执行过程是:
父进程调用 fork() 创建子进程。
子进程调用 execlp(),执行 ls -l。
父进程调用 waitpid(),等待子进程结束。
子进程运行结束后,父进程继续输出提示。
这里使用 _exit(1) 处理替换失败,是因为子进程在 fork() 后如果替换失败,通常应该尽快结束,而不是继续执行原程序中不该执行的逻辑。
这也是 Shell 执行外部命令时常见的基本思路:创建子进程、执行新程序、等待子进程结束。
⾃主Shell命令⾏解释器
一.Shell到底是什么
Shell 就是我们在 Linux 终端中使用的命令行解释器。
例如,你在终端输入:ls
Shell 并不是自己去读取磁盘目录并打印文件,而是负责组织整个执行过程:
fork() 创建的是一个新进程;exec() 不创建新进程,而是替换当前进程正在执行的程序。
假设你在 Shell 中输入:ls
此时可以把进程关系理解为:
为什么不直接让 Shell 自己执行 ls?
因为 exec() 会替换当前进程的程序。如果 Shell 直接调用 exec(),Shell 自己就会变成 ls,原来的 Shell 程序也就不再运行了。
这样 ls 执行完以后,原来的 Shell 无法继续接收命令。
所以正确的做法是:
父进程 Shell 保留自己。
fork() 创建子进程。
子进程通过 exec() 执行 ls。
父进程通过 waitpid() 等待子进程结束。
子进程结束后,父进程继续运行。
写作
Linux 进程控制实战:从 fork、exec 到自主 Shell 的实现
二、Shell 的整体工作原理
Shell 的核心任务,是将用户输入的命令转化为实际的执行操作。
例如,用户输入:ls -l
此时,Shell 首先读取这行文本,然后解析出命令名 ls 和参数 -l。接下来,它判断这是否属于 Shell 自己实现的内建命令。
如果是内建命令,就由当前 Shell 直接处理;如果是普通外部命令,则创建子进程,让子进程执行对应程序。
父进程随后等待子进程结束,获取执行状态,再回到循环开头,等待用户输入下一条命令。
Shell 本身并不需要实现所有命令的功能。它主要负责理解用户的需求,并利用操作系统提供的机制安排程序执行。
三、Shell 的基础数据结构
在实现过程中,需要使用一些全局变量保存命令参数、退出状态和环境变量。
const int basesize = 1024;
const int argvnum = 64;
const int envnum = 64;
char *gargv[argvnum];
int gargc = 0;
int lastcode = 0;
char *genv[envnum];
char pwd[basesize];
char pwdenv[basesize];
各变量的作用如下:
basesize:命令行缓冲区的容量。
argvnum:命令参数数组的容量。
envnum:环境变量数组的容量。
gargv:保存解析后的命令名和参数。
gargc:记录当前命令的参数数量。
lastcode:保存上一条命令的退出状态。
genv:保存 Shell 维护的环境变量。
pwd:保存当前工作目录。
pwdenv:保存 PWD=路径 形式的环境变量字符串。
例如,用户输入:
ls -l
解析后,参数数组可以表示为:
gargv[0] = "ls"
gargv[1] = "-l"
gargv[2] = nullptr
gargc = 2
其中,gargv 是字符串指针数组,最后一个 nullptr 用于标记参数结束。
这些数据结构为后续命令执行提供了基础。
四、命令提示符的实现
为了让用户知道当前 Shell 已经准备好接收输入,需要显示一个命令提示符。
例如:
[user@localhost project]#
这个提示符包含用户名、主机名和当前目录名称。
4.1 获取用户名和主机名
可以通过 getenv() 获取对应的环境变量:
string GetUserName()
{
const char *value = getenv("USER");
return value ? value : "None";
}
string GetHostName()
{
const char *value = getenv("HOSTNAME");
return value ? value : "None";
}
getenv() 用于读取当前进程环境中的指定变量。如果变量不存在,则返回空指针。
需要注意,USER 和 HOSTNAME 并不保证在所有环境中都存在,因此实际代码需要处理变量缺失的情况。
4.2 获取当前工作目录
string GetPwd()
{
if (getcwd(pwd, sizeof(pwd)) == nullptr)
{
return "None";
}
snprintf(pwdenv, sizeof(pwdenv),
"PWD=%s", pwd);
return pwd;
}
getcwd() 用于获取当前进程的工作目录,并将结果写入缓冲区。
例如,当前目录为:
/home/user/project
函数就会获得对应路径。
snprintf() 则可以将路径格式化成 PWD=/home/user/project 这样的字符串。
4.3 提取最后一级目录
string LastDir()
{
string curr = GetPwd();
if (curr == "/" || curr == "None")
{
return curr;
}
size_t pos = curr.rfind('/');
if (pos == string::npos)
{
return curr;
}
return curr.substr(pos + 1);
}
rfind() 用于查找最后一个 / 的位置,substr() 用于截取该位置之后的字符串。
例如:
/home/user/project
最终得到:
project
这样,提示符只需要显示当前目录的最后一级名称即可。
五、读取并解析命令行
5.1 获取用户输入
bool GetCommandLine(char command_buffer[], int size)
{
if (fgets(command_buffer, size, stdin) == nullptr)
{
return false;
}
size_t len = strlen(command_buffer);
if (len > 0 && command_buffer[len - 1] == '\n')
{
command_buffer[len - 1] = '\0';
}
return strlen(command_buffer) > 0;
}
fgets() 从标准输入读取用户输入,并将内容保存到缓冲区。
例如,输入:
ls -l
读取到的字符串通常包含末尾的换行符:
"ls -l\n"
因此,需要在确认末尾存在换行符后将其删除。
这里还需要注意:如果读取失败,特别是遇到 EOF,主循环应该适当退出,而不是不断重新显示提示符。上述代码仅展示读取逻辑,实际主循环还应对返回值进行正确处理。
5.2 解析命令行
用户输入的是完整字符串,但执行程序时需要将命令名和参数分别组织起来。
例如:
ls -a -l
可以解析为:
gargv[0] = "ls"
gargv[1] = "-a"
gargv[2] = "-l"
gargv[3] = nullptr
简化的解析方式可以使用 strtok():
void ParseCommandLine(char command_buffer[])
{
memset(gargv, 0, sizeof(gargv));
gargc = 0;
char *token = strtok(command_buffer, " ");
while (token != nullptr && gargc < argvnum - 1)
{
gargv[gargc++] = token;
token = strtok(nullptr, " ");
}
gargv[gargc] = nullptr;
}
第一次调用 strtok() 时传入原字符串,后续传入 nullptr,表示从上一次处理的位置继续查找。
需要注意,strtok() 会直接修改原字符串,并且这种解析方式不能完整处理引号、转义、管道和重定向等 Shell 语法。
六、执行普通命令:fork、exec 与 waitpid
6.1 为什么需要创建子进程?
假设 Shell 直接用新程序替换自己,那么原来的 Shell 就无法继续执行,也就无法在命令结束后读取下一条输入。
因此,通常采用父子进程分工的方式:
父进程保留 Shell,负责管理命令执行过程。
子进程负责执行外部程序。
这就是 fork() 和 exec() 需要配合使用的原因。
6.2 fork():创建子进程
pid_t id = fork();
if (id < 0)
{
// 创建失败
}
else if (id == 0)
{
// 子进程
}
else
{
// 父进程
}
fork() 会创建一个子进程。
它的返回值有三种情况:
小于零:创建失败。
等于零:当前代码运行在子进程中。
大于零:当前代码运行在父进程中,返回值是子进程的 PID。
调用后,父子进程都会从 fork() 返回处继续执行,但可以根据返回值走不同的分支。
6.3 exec():替换程序映像
在子进程中,可以调用:
execvp(gargv[0], gargv);
它会尝试执行指定的程序,并使用 gargv 作为参数数组。
例如:
ls -l
对应的调用参数为:
execvp("ls", gargv);
成功后,当前子进程原来的程序映像会被新程序替换。
需要特别注意:
exec() 不会创建新进程。
fork() 负责创建子进程,exec() 负责替换当前进程的程序映像。成功执行后,进程的 PID 通常保持不变。
如果 exec() 返回了,说明执行失败,因此应该处理错误并退出子进程。
6.4 waitpid():等待子进程结束
父进程需要等待子进程结束,避免命令尚未完成时就继续执行后续流程。
int status = 0;
pid_t rid = waitpid(id, &status, 0);
参数含义:
id:需要等待的子进程 PID。
&status:用于保存子进程的终止状态。
0:使用阻塞等待方式。
如果指定的子进程尚未结束,父进程通常会等待它结束后再继续执行。
6.5 获取退出状态
if (WIFEXITED(status))
{
lastcode = WEXITSTATUS(status);
}
WIFEXITED(status) 用于判断子进程是否正常退出。
WEXITSTATUS(status) 则用于提取正常退出时的退出码。
通常,退出码 0 表示成功,非零值表示失败或其他状态,但具体含义由程序决定。
Shell 保存退出码后,就可以为后续的 $? 等功能提供基础。
完整的执行流程可以概括为:
父进程 Shell
|
| fork()
↓
创建子进程
|
| exec()
↓
执行外部程序
|
| 进程结束
↓
父进程 waitpid()
|
| 获取退出状态
↓
继续读取下一条命令
七、内建命令与普通命令
并不是所有命令都适合通过创建子进程来执行。
本次实验主要实现了 cd、export、env 和 echo 等简化版内建命令。
7.1 cd:改变当前工作目录
chdir(gargv[1]);
chdir() 用于改变调用进程的当前工作目录。
为什么 cd 必须由当前 Shell 自己执行?
因为如果在子进程中调用 chdir(),改变的只是子进程自己的工作目录。子进程结束后,父 Shell 的工作目录不会自动改变。
因此,cd 必须由当前 Shell 处理。
实际代码中还应检查 chdir() 的返回值,确认目录切换是否成功。
7.2 export:管理环境变量
环境变量可以为程序提供运行配置,例如:
HOME=/home/user
PATH=/usr/bin:/bin
USER=user
getenv() 可以读取环境变量,而 setenv() 可以设置当前进程的环境变量。
本次实验通过 genv 数组维护一份环境变量列表,并在启动外部程序时将其传递给执行接口。
需要注意,这份实现属于教学用简化版本,还没有完整实现标准 Shell 的本地变量、导出属性和同名变量更新机制。
7.3 env:输出环境变量
程序遍历 genv 数组,逐行输出环境变量。
由于环境变量数组以 nullptr 作为结束标记,因此可以通过检查当前指针是否为空来决定是否继续循环。
7.4 echo:输出内容与退出状态
例如:
echo hello
输出:
hello
而:
echo $?
用于查看上一条命令的退出状态。
本次实验只实现了非常有限的变量展开,并不具备标准 Shell 的完整变量处理能力。
八、环境变量的初始化与传递
Shell 启动时,通常已经从父进程继承了一组环境变量。
通过 environ,程序可以访问当前进程的环境变量数组。
例如:
extern char **environ;
在初始化过程中,可以遍历 environ,将环境变量复制到 genv 中。
在实际实现中,需要注意数组容量、内存分配失败、字符串释放以及同名变量更新等问题。
执行外部程序时,可以使用显式传递环境变量的接口,例如 execve():
execve(path, gargv, genv);
这里需要传入可执行文件的路径,而不是简单依赖 PATH 搜索。
本次源码使用的 execvpe() 属于 GNU 扩展,它支持 PATH 搜索,并允许显式传入环境变量数组。但在 glibc 中,程序路径搜索所使用的 PATH 与传入的环境数组并不完全等同。
因此,环境变量不仅影响当前 Shell 的行为,还会影响后续启动的程序。
九、exec/exit 与 call/return 的联系
教材最后将 exec/exit 与 call/return 进行类比,主要是为了说明不同执行单元之间的任务分工。
9.1 函数调用
例如:
int Add(int a, int b)
{
return a + b;
}
调用者传递参数,被调用函数执行计算,然后通过 return 返回结果。
main()
↓ 调用 Add()
Add() 执行计算
↓ return 结果
main() 获取返回值
9.2 进程协作
在 Shell 中,父进程通过 fork() 创建子进程,子进程通过 exec() 执行新程序,程序结束时提供退出状态,父进程再通过 waitpid() 获取该状态。
父进程
↓ fork()
子进程
↓ exec()
新程序执行任务
↓ exit()
父进程 waitpid()
二者都体现了任务分工与结果反馈的思想。
不过,它们并不完全相同:
函数调用发生在同一个进程内部。
fork() 创建新的进程。
exec() 替换当前进程的程序映像。
exit() 终止进程并提供退出状态。
waitpid() 用于等待子进程并获取其终止状态。
因此,exec/exit 与 call/return 的类比,强调的是任务委托与结果反馈,而不是接口之间的一一对应关系。