四、进程程序替换
-
之前用fork创建子进程后,父子进程执行同一个程序的不同代码段。
-
如何使子进程执行另一个不同的程序呢?子进程需要进行程序替换!
-
程序替换,就是通过特定的接口,将磁盘上一个全新的程序(包括代码和数据)加载到调用进程的地址空间中。
4.1 程序替换的原理
在进行程序替换时,操作系统会将新程序的代码和数据加载到调用进程的地址空间中。这个过程通常包括以下几个步骤:
-
加载数据:操作系统会将新程序的代码和数据从磁盘或其他存储介质中读取到内存中。这些代码和数据会被加载到物理内存中的合适位置。
-
调整进程地址空间(重定位):由于新的程序可能与原有程序的地址空间不同,因此需要进行地址重定位。操作系统会根据新程序的要求,将程序中的地址引用进行调整,使其指向正确的物理内存位置。
-
更新页表:操作系统会更新进程的页表,以映射新程序的代码和数据所在的物理内存页。这样,进程就可以通过虚拟内存地址访问到正确的物理内存。
-
清理旧程序:操作系统会释放原有程序占用的物理内存页,以便为新程序腾出空间。这些旧的物理内存页会被标记为可重用,以供其他进程使用。
总的来说,程序替换是通过加载新程序的代码和数据到物理内存中,并进行地址重定位和页表更新来完成的。这样,进程就可以执行新的程序了。
4.2 程序替换函数
- 子进程往往要调用exec*函数进行程序替换以执行另一个程序,exec*函数是加载器的底层调用接口。
- 当进程调用exec*函数时,该进程用户空间的代码和数据完全被新程序替换,从新程序的启动例程开始执行。
- 调用exec并不创建新进程,所以调用exec前后该进程的pid并未改变。
下面是exec*系统调用:
4.2.1 execl函数
参数:
- path:待替换程序的所在路径+文件名
- arg, …:命令行参数,以字符串的形式一个个传入,最后以NULL结尾(标识参数传递完毕)。
返回值:
-
exec函数只有发生错误失败时才会返回,返回值是-1。
-
exec函数一旦调用成功,后续的所有代码都不会执行,也根本不需要有返回值。
提示:
- execl拆解速记:exec(execute) - l(list)
- 命令行参数表和环境变量表都是以NULL结尾,用于表示结束。
测试代码:
#include <iostream>
#include <unistd.h>
using namespace std;
int main(){
cout << "当前进程的开始代码!" << endl;
execl("/usr/bin/ls", "ls", "-a", "-l", "--color=auto", NULL);
cout << "当前进程的结束代码!" << endl; //程序被替换,所以不会被打印
}
测试结果:
4.2.2 execv函数
与execl比较,只是第二个参数argv不同。
argv:命令行参数表(字符指针数组),数组元素同样要以NULL结尾。
其他特性和execl一模一样
execv拆解速记:exec(execute) - v(vector)
测试代码:
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
int main(){
pid_t id = fork();
if(id == 0)
{
//子进程执行流
cout << "I'm child process! child_pid:" << getpid() << endl;
char *const argv[] = {(char*)"ls", (char*)"-a", (char*)"-l", (char*)"--color=auto", NULL};
execv("/usr/bin/ls", argv);
}
else if(id > 0)
{
//父进程执行流
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret > 0)
{
if(WIFEXITED(status))
{
cout << "子进程正常退出!child_pid:" << ret;
cout << " exit_code:" << WEXITSTATUS(status) << endl;
}
else{
cout << "子进程崩溃!child_pid:" << ret;
cout << " exit_signal:" << (status & 0x7F) << endl;
}
}
else if(ret == -1)
{
cout << "等待子进程失败!" << endl;
}
}
else{
perror("子进程创建失败!");
return 1;
}
}
测试结果:
4.2.3 execlp函数
与execl比较,只是第一个参数file不同。
file:待替换的程序名,该程序的所在路径必须在PATH环境变量中。
其他特性和execl一模一样
execlp拆解速记:exec(execute) - l(list) - p(PATH)
测试代码:
//......
if(id == 0)
{
//子进程执行流
cout << "I'm child process! child_pid:" << getpid() << endl;
execlp("ls", "ls", "-a", "-l", NULL);
//......
运行结果:
当然也可以执行我们自己编写的代码
可以使用绝对路径或相对路径:
- 绝对路径:
execv("/home/zty/code/Linux/20230721/test", argv);
- 相对路径:
execv("./test", argv);
测试代码:这里测试的是之前写过的一个接收命令行选项参数的简单程序
测试结果:
补充内容:makefile一次性构建多个可执行程序
.PHONY:all all:myproc test myproc:myproc.cc g++ $^ -o $@ -std=c++11 test:test.cc g++ $^ -o $@ -std=c++11 .PHONY:clean clean: rm -f myproc
甚至还可以执行其他语言编写的程序
shell脚本
#! /usr/bin/bash
echo "hello shell!"
运行命令:bash test.sh
将进程替换为shell程序:execlp("bash", "bash", "test.sh", NULL);
python脚本
#! /usr/bin/python3.6
print("hello Python/n")
运行命令:python test.py
将进程替换为Python程序:execlp("python", "python", "test.py", NULL);
如果python脚本文件具有可执行权限:execlp("./test.py", "test.py", NULL);
注意:
- shell,Python,Java等编程语言拥有自己的解释器,通过解释器可以直接运行所编写的程序,无需编译生成可执行程序。
- 可以给脚本文件加上可执行权限,然后直接
./test.py
运行程序。实际仍然是通过解释器执行程序的。
4.2.4 execle函数
与execl相比,新增了第三个参数envp
envp:环境变量表(字符指针数组),数组元素同样要以NULL结尾。
其他特性和execl一模一样
execle拆解速记:exec(execute) - l(list) - e(environment)
测试代码:
//......
if(id == 0)
{
//子进程执行流
cout << "I'm child process! child_pid:" << getpid() << endl;
char *const _env[] = {"MYENV=122", NULL}; //设置MYENV环境变量
execle("./test", "test", "-e", "NULL", _env);//test -e选项打印MYENV环境变量
//......
测试结果:
提示:环境变量具有全局属性,是因为可以通过类似于execle的系统调用,将父进程环境变量表传递给子进程。
4.2.5 execve函数
execve是一个系统调用函数,用于在Linux系统中执行一个新的程序。它的参数解释如下:
-
const char *filename
:要执行的程序的路径。可以是绝对路径,也可以是相对路径。 -
char *const argv[]
:一个字符串数组,用于传递给新程序的命令行参数。数组的最后一个元素必须为NULL,表示参数列表的结束。 -
char *const envp[]
:一个字符串数组,用于传递给新程序的环境变量。数组的最后一个元素必须为NULL,表示环境变量列表的结束。如果envp为NULL,则新程序将继承当前进程的环境变量。
execve函数的返回值是一个整数,如果执行成功,它不会返回,而是直接在当前进程中加载并执行新程序。如果发生错误,返回值为-1,并设置errno来指示具体的错误类型。
注意:
- execve函数会替换当前进程的代码和数据,将其替换为新程序的代码和数据。因此,execve函数之后的代码将不会被执行。
- 如果希在执行新程序后继续执行其他操作,可以使用fork函数创建一个子进程,在子进程中调用execve函数,而在父进程中继续执行其他操作。
execve是真正的系统调用,而上面的6个接口实际上是系统提供的基本封装。 他们会将接收到的参数进行合并处理,最后底层还是会调用execve:
总结exec*函数的命名方式:
- +l/v:命令行参数以可变参数列表或者指针数组传入
- +p:是否在环境变量PATH中查找程序路径
- +e:是否自己维护环境变量表
4.3 简单的命令行解释器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
char cmd_line[1024]; //用于接收存储整条命令
char* cmd_param[32]; //将整条命令拆解成一个个参数
char env_buffer[64]; //环境变量缓冲区
//shell运行原理:父进程接收并解析命令,创建子进程执行命令,父进程等待。
int main(){
//0.命令行解释器是常驻内存进程,不退出
while(1)
{
//1.打印提示信息:[root@localhost myshell]#
printf("[root@localhost myshell]# ");
fflush(stdout); //[1]
//sleep(1);
//2.获取用户输入(包括指令和选项):"ls -a -l -i"
memset(cmd_line, '\0', sizeof(cmd_line));
if(fgets(cmd_line, sizeof(cmd_line), stdin) == NULL) continue; //[2]
if(strcmp(cmd_line, "\n") == 0) continue; //[2]
cmd_line[strlen(cmd_line)-1] = '\0'; //[3]
//printf("%s\n", cmd_line);
//3.命令行字符串解析:"ls -a -l" --> "ls" "-a" "-l"
cmd_param[0] = strtok(cmd_line, " "); //[4]
int i = 1;
while(cmd_param[i++] = strtok(NULL, " "));
//for(int i = 0; cmd_param[i]; ++i)
//{
// printf("%s ",cmd_param[i]);
//}
//printf("\n");
//4.内置命令:让父进程(shell)自己执行的指令,又叫内建命令
//内建命令本身就是shell中的一个函数调用
if(strcmp(cmd_param[0], "cd") == 0) //cd指令切换父进程(shell)工作目录
{
if(cmd_param[1] != NULL)
chdir(cmd_param[1]); //[5]
continue;
}
if(strcmp(cmd_param[0], "export") == 0) //export指令导出环境变量,使其可被子进程继承
{
if(cmd_param[1] != NULL)
{
strcpy(env_buffer, cmd_param[1]); //[6]
putenv(env_buffer); //[7]
}
continue;
}
//5.创建子进程执行命令:
int id = fork();
if(id == 0)
{
printf("I'm child process! pid:%d ppid:%d\n", getpid(), getppid());
execvp(cmd_param[0], cmd_param);
exit(1);
}
//6.父进程(shell)等待子进程,获取退出状态,回收资源
int status = 0;
int ret = waitpid(-1, &status, 0); //阻塞等待
if(ret > 0)
{
if(WIFEXITED(status))
{
//正常退出返回退出码
printf("normal exit! child_pid:%d exit_code:%d\n", ret, WEXITSTATUS(status));
}
else
{
//异常退出返回退出信号
printf("abnormal exit! child_pid:%d exit_signal:%d\n", ret, status&0x7F);
}
}
else if(ret < 0)
{
printf("Waiting failed!\n");
}
}
}
设计流程:
- 命令行解释器是常驻内存进程,不退出
- 打印提示信息:[root@localhost myshell]#
- 获取用户输入(包括指令和选项):“ls -a -l -i”
- 命令行字符串解析:“ls -a -l” --> “ls” “-a” “-l”
- 内置命令:让父进程(shell)自己执行的指令,又叫内建命令,内建命令本身就是shell中的一个函数调用
- 创建子进程执行命令
- 父进程(shell)等待子进程,获取退出状态,回收资源
解释:
- [1] 由于打印的提示信息不带’\n’,所以缓冲区中的数据不会自动刷新到显示器,需要手动fflush(stdout)强制刷新,使其立马显示在屏幕上。
- [2] 如果没有获取到任何字符或者获取失败直接continue;需要注意的是’\n’也会被读取,如果只读取到’\n’也要continue;
- [3] 需要将获取到的最后一个换行符替换为’\0’,否则会被当做命令的一部分处理。
- [4] C库函数strtok的用法:C 库函数 – strtok() | 菜鸟教程 (runoob.com)
- [5] strtok工作原理:将指定的分隔符替换为’\0’,将原字符串分割。依次返回子串的首字符地址。
- [6] Linux系统调用chdir:
- 功能:用于改变当前进程的工作目录;这意味着后续的文件操作(如打开文件、读写文件)将在新的当前目录下进行。
- 头文件:<unistd.h>
- 参数:切换的路径
- 返回值:成功返回0,失败返回-1
- [7] cmd_param保存的是cmd_line中各命令行参数的首字符地址。memset会将cmd_line清空,导致环境变量丢失。因此需要将环境变量拷贝到缓冲区。
- [8] Linux系统调用putenv:
- 功能:用于设置环境变量
- 头文件:<stdlib.h>
- 参数:环境变量键值对字符串
- 返回值:设置成功返回0,失败返回非0值
提示:
- shell执行的命令通常有两种:
- 外部命令:第三方提供的在磁盘中有具体二进制文件的可执行程序(由子进程执行),如:ls,ps,pwd,我们编写的程序
- 内置命令:shell内部自己实现的方法,由父进程(shell)自己执行,这些命令就是要影响shell本身,如:cd,export
- 进程的环境变量会被子进程继承,并且子进程进行程序替换并不会替换环境变量相关的内容
- shell的环境变量是从哪里来的?
- 最初的环境变量是保存在配置文件(shell脚本)中的。shell启动的时候,通过读取配置文件,获得起始环境变量。
为什么要程序替换?
和应用场景有关,有时候我们必须让子进程执行新的程序。
为什么要创建子进程执行程序?
为了不影响父进程,如上面的shell程序,我们想让父进程聚焦在读取命令,解析命令,指派进程执行程序的功能上!
如果不创建子进程,我们就只能替换父进程程序了