文章目录
- 📖 前言
- 1. 进程程序替换
- 1.1 程序替换的概念:
- 1.2 为什么要程序替换:
- 1.3 程序替换的原理:
- 2. 六个exec替换函数
- 2.1 execl函数:
- 2.2 execv函数:
- 2.3 execlp函数:
- 2.4 execvp函数:
- 2.5 execle函数:
- 2.6 execvpe函数:
- 3. 实现简易版shell
- 3.1 内建命令等问题的解决:
- 3.1 - 1 cd命令的处理:
- 3.2 - 2 export的处理:
📖 前言
上一节我们讲了进程终止和进程等待等一系列问题,并做了相应的验证,本章将继续对进程控制进行学习,我们将学习进程程序替换,进行相关验证,运用系统进程程序替换接口,自己模拟写一个shell,该shell能够实现执行指令,等一系列命令行操作……
1. 进程程序替换
1.1 程序替换的概念:
概念引入:
将可执行程序加载到内存,并且重新调整子进程的页表映射,使之指向新的进程的代码和数据段,这种过程就叫做程序替换。
子进程执行的是父进程的代码片段,那么如果我们想让创建出来的子进程,执行全新的程序呢?
此时就要用到:进程的程序替换。
1.2 为什么要程序替换:
原因:
- 原因是我们想让我们的子进程执行一个全新的程序。
- 不同语言写的功能互相调用,这就是为什么要有程序替换的原因。
我们一般在服务器设计(Linux编程)的时候,往往需要子进程干两件种类事情:
-
- 1.让子进程执行父进程的代码片段(服务器代码)
-
- 2.让子进程执行磁盘中一个全新的程序(shell, 想让客户端执行对应的程序,通过我们的进程,执行其他人写的进程代码等等)C/C++ -> C/C++/Python/Shell/Php/Java…
1.3 程序替换的原理:
程序替换的原理:
- 将磁盘中的程序,加载入内存结构。
- 重新建立页表映射,谁执行程序替换,就重新建立子进程的映射关系。
效果:让我们的父进程和子进程彻底分离,并让子进程执行一个全新的程序!
父进程的映射关系:
程序替换之后,子进程的映射关系:
调整子进程的页表,让其不再与父进程代码和数据有任何关系,而是指向自己的代码和自己的数据区。
注意:
- 请问:这个过程有没有创建新的讲程呢?
- —— 没有!!!
小结:
- 说白了就是让
fork
创建子进程,不想让子进程执行父进程代码片段。 - 我们想让子进程执行磁盘当中全新的程序,而且我们没有创建新的进程。
- 因为子进程的内核数据结构基本没变,只是重新建立了虚拟到物理的映射关系罢了。
- 包括子进程的
PID
都不变,压根就没有创建新的进程,只不过让新的进程执行了不同的程序罢了。
2. 六个exec替换函数
上述我们讲了什么是程序替换,下面就要来见见猪跑了。
程序替换是由操作系统完成的,调用系统调用接口来完成操作。
- 我们如果想执行一个全新的程序,我们需要做几件事情:
- 先找到这个程序在哪里? —— 程序在那里
- 程序可能携带选项进行执行(也可以不携带) —— 怎么执行
明确告诉OS,我想怎么执行这个程序是什么,要不要带选项。
我们平时在命令行中敲的指令都是一个一个可执行程序。
- 程序替换的是子进程:(重点)
- 进程替换永远影响的是进程的本身,子进程的替换永远不会影响父进程,因为进程具有独立性。
- 独立性体现在内核层面,不同进程有不同的地址空间,有不同的页表替换只是加入新的代码和数据。
- 重新建立的是页表映射但并不影响内核数据结构的具体情况。
- 子进程虽然和父进程代码共享数据写实拷贝,但是一旦发生进程替换了,就认为代码和数据发生了双写实拷贝,就彻底将两个进程分开了。
- 所以引入子进程的原因就是,一方面把需求做到位,另一方面不影响父进程,因为父进程可能还要接收新的命令,再去执行新的程序。
六个exec替换函数:
2.1 execl函数:
int execl(const char *path, const char *arg, ...);
path:
- 这个是路径,可执行程序的路径。
arg:
- 命令行怎么写(1s -1 -a), 这个参数就怎么填"ls",“-l”,“-a”,最后必须是NULL结尾
- 标识 “如何执行程序的” 参数传递完毕
… :
- 可变参数,可以传多个参数
第一个参数是解决了,程序在哪里的问题,第二个参数往后所有的参数,解决的都是程序如何执行的问题。
代码演示:
#include <stdio.h>
#include <unistd.h>
int main()
{
//让我的程序执行系统上的: ls -a -i这样的一个命令
printf("我是一个进程,我的pid是 : %d\n", getpid());
//int ret = execl("/usr/bin/ls", "ls", "-l", "-a", NULL); //带选项
//execl("/usr/bin/top", "top", NULL); //不带选项
//execl("/usr/bin/which", "which", "pwd", NULL); //不带选项
//下面这行代码没有打印出来
//一旦代码执行到这里,必然是进程替换失败了
//替换失败的情况
int ret = execl("/usr/bin/lsssss", "ls", "-l", "-a", NULL); //带选项
printf("我执行完毕了,我的pid : %d, ret = %d\n", getpid(), ret);
return 0;
}
一旦进程替换成功了,就不会再执行程序替换函数以后的代码了,因为直接去是该进程被替换掉了。
显而易见,代码中程序替换以后的打印内容并没有显示出来,说明进程替换以后的代码压根就没执行,而是去执行ls进程了。
总结:
- 一旦替换成功,是将当前进程的代码和数据全部替换了!!
- 前一个printf被执行是因为程序替换并没有执行。
- 所以替换上面的代码依旧是当前进程执行执行,execl之后代码就不复存在了。
所以程序替换不用判断返回值:
不需要返回值,一旦有值返回那么必然是返回失败了!!!因为只要成功了,就不会有返回值,而失败的时候,必然会继续向后执行!!最多通过返回值得到什么原因导致的替换失败!
引入进程创建:
- 子进程执行程序替换,会不会影响父进程呢??
-
- 不会(因为进程具有独立性)
- 为什么,如何做到的??
-
- 数据层面发生写时拷贝!
-
- 当程序替换的时候,我们可以理解成为,代码和数据都发生了写时拷贝完成父子的分离!
)
2.2 execv函数:
int execv(const char *path, char *const argv[]);
实现的功能和execl
一模一样。
path:
- 这个是路径,可执行程序的路径。
argv[]:
- 如何执行,和execl的唯一区别就是传参方式的不一样
代码演示:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
printf("我是父进程,我的pid是:%d\n", getpid());
pid_t id = fork();
if(id == 0)
{
//子进程
//我们要子进程执行全新的程序,以前我们是子进程执行父进程的代码片段
printf("我是子进程,我的pid是:%d\n", getpid());
//char* const argv_[] = {
// (char*)"ls",
// (char*)"-l",
// (char*)"-a",
// (char*)"-i",
// NULL
//};
char* const argv_[] = {
(char*)"top",
NULL
};
//execv("/usr/bin/ls", argv_);
execv("/usr/bin/top", argv_);
}
//一定是父进程
int status = 0;
int ret = waitpid(id, &status, 0);
if(ret == id)
{
sleep(2);
printf("进程等待成功!\n");
}
return 0;
}
程序替换不仅可以替换成指令,还可以替换成我们自己写的可执行程序。
- 用exec系列,这种系统级的函数,可以把任何语言耦合到一起。
- 任何程序都可以用系统级接口调用其他语言的。
- 所以说操作系统是所有技术的基座。
2.3 execlp函数:
int execlp(const char *file, const char *arg, ...);
file:
- 你想执行什么程序。 —— 找到它
- 执行指令的时候,默认的搜索路径,在哪里搜索呢?—— 环境变量PATH
- 命名带p的,可以不带路径,只说出你要执行哪一个程序即可!
arg:
- 想如何执行它
代码演示:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
//有时候不想让父进程做一件事,只想让子进程做一件事
//将进程创建引入进来
int main()
{
printf("我是父进程,我的pid是:%d\n", getpid());
pid_t id = fork();
if(id == 0)
{
//子进程
//我们要子进程执行全新的程序,以前我们是子进程执行父进程的代码片段
printf("我是子进程,我的pid是:%d\n", getpid());
execlp("ls", "ls", "-a", "-l", "-i", NULL);//这里出现了两个ls,含义一样吗?-- 不一样!
//第一个参数是供系统去找要执行谁的指令,后面一坨是表示如何执行该指令
exit(100); //只要执行了exit,就意味着,execl系列的函数失败了 -- 进程替换失败了
}
//一定是父进程
int status = 0;
int ret = waitpid(id, &status, 0);
if(ret == id)
{
sleep(2);
printf("wait success, ret : %d, 我所等待子进程的退出码: %d, 退出信号是: %d\n", ret, (status >> 8) & 0xFF, status & 0x7F);
}
return 0;
}
作用和execI和execv是一样的,也是执行一个新的程序。
2.4 execvp函数:
int execvp(const char *file, char *const argv[]);
file:
- PATH找,只要程序名即可。
argv[]:
- 如何执行,将命令行参数字符串,统一放入数组中即可完成调用!
2.5 execle函数:
int execle(const char *path, const char *arg, ..., char * const envp[]);
envp[]:
- 环境变量
execle:test.c程序替换代码演示:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
//环境变量的指针声明
extern char** environ;
printf("我是父进程,我的pid是:%d\n", getpid());
pid_t id = fork();
if(id == 0)
{
//子进程
//我们要子进程执行全新的程序,以前我们是子进程执行父进程的代码片段
printf("我是子进程,我的pid是:%d\n", getpid());
//绝对路径
//execl("/home/Zh_Ser/linux/lesson16/mycmd", "mycmd", NULL);
//相对路径
//execl("./mycmd", "mycmd", NULL);
//我们来手动导入一个环境变量
char* const env_[] = {
(char*)"MYPATH=You Can See Me!!",
NULL
};
//e: 添加环境变量给目标进程,是覆盖式的!
//execle("./mycmd", "mycmd", NULL, env_);
//execle("/usr/bin/ls", "ls", NULL, env_);
execle("./mycmd", "mycmd", NULL, environ);
exit(100); //只要执行了exit,就意味着,execl系列的函数失败了 -- 进程替换失败了
}
//一定是父进程
int status = 0;
int ret = waitpid(id, &status, 0);
if(ret == id)
{
sleep(2);
printf("进程等待成功!\n");
}
return 0;
}
上述代码代码在程序替换的时候,执行了./mycmd,目的是手动导入环境变量的时候,执行./mycmd获取导入的环境变量。
mycmd.cpp代码演示:
#include <iostream>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
using namespace std;
int main()
{
extern char** environ;
cout << "打印环境变量" << endl;
for (int i = 0; environ[i]; i++)
{
printf("%d: %s\n", i, environ[i]);
}
cout << "+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++" << endl;
//根据环境变量名,获取环境变量的内容
cout << "PATH:" << getenv("PATH") << endl;
cout << "----------------------------------------------" << endl;
cout << "MYPATH:" << getenv("MYPATH") << endl;
cout << "----------------------------------------------" << endl;
//程序崩溃了 -- 因为环境变量里根本就没有MYPATH
cout << "Hello World!" << endl;
cout << "Hello World!" << endl;
cout << "Hello World!" << endl;
cout << "Hello World!" << endl;
return 0;
}
mycmd是为了获取环境变量。
如果我们用这种方式导入环境变量:
出现的情况:
将mycmd.cpp中的getenv(“PATH”)给屏蔽掉,再执行test结果就可以将MYPATH打印出来:
e: 添加环境变量给目标进程,是覆盖式的!所以环境变量只剩下MYPATHT。
子进程会继承父进程的环境变量的!!(重点)
- 子进程会继承父进程的环境变量,当父进程调用fork()创建子进程时,子进程会继承父进程的所有环境变量。
- 当子进程调用execlp()等函数执行其他程序时,子进程也会继承父进程的环境变量。
- 如果需要在子进程中更改环境变量,可以使用setenv()或putenv()等函数进行更改。
- 但是,更改的环境变量只会影响当前进程和它的子进程,并不会影响父进程或其他进程的环境变量。
如何理解覆盖?(重点)
- 当子进程调用execle()函数替换自己的程序时,可以传递一个新的环境变量数组,以覆盖子进程继承的父进程的环境变量。如果不传递新的环境变量数组,子进程会继承父进程的环境变量。因此,如果在调用execle()函数时没有传递新的环境变量数组,子进程的环境变量不会被覆盖。
- 如果传递了新的环境变量数组,则子进程的环境变量将被替换为新的环境变量数组中的值。这可能会导致子进程无法访问父进程中的一些环境变量,除非在新的环境变量数组中显式地包含它们。
验证execle覆盖了子进程会继承父进程的环境变量:
- 我们执行test程序的时候
- 调用execle接口,程序替换去执行mytest
- 既然mytest是替换了子进程,它就会继承父进程的全部环境变量
- execle函数我们传了一个env_将子进程的环境变量覆盖了
我们在mycmd程序开始的地方,加了查看全部环境变量的代码:
extern char** environ;
for (int i = 0; environ[i]; i++)
{
printf("%d: %s\n", i, environ[i]);
}
目的是通过该代码查看子进程(mycmd)的环境变量,被execle传的env_覆盖之后的样子:
显而易见,子进程的环境变量只有env_[]的内容了!!!所以getenv("PATH")
才获取不到!!!
正确做法:
我们将全部环境变量传过去,将environ传过去。
补充:(重点)
- ls 是一个常见的系统命令,它通常位于系统的某个标准路径(如 /bin 或 /usr/bin)。即使 PATH 为空,execlp() 会检查这些标准路径,找到 ls 的可执行文件并执行它。
- 可能是直接在execlp中定义好的路径了,所以 PATH 环境变量没了也可以找到。
详细说明:
- 如果PATH环境变量为空,execlp()函数会无法在环境变量中查找可执行文件的路径。但是,execlp()函数会检查一些默认路径,例如/bin、/usr/bin等,来查找可执行文件。因此,即使PATH为空execlp()函数也可能会在这些默认路径中找到可执行文件并执行它。
- 但是,如果在默认路径中也找不到可执行文件,则execlp()函数会执行失败,并将errno设置为ENOENT,表示无法找到可执行文件。因此,如果需要执行特定路径下的可执行文件,最好使用execv()或execve()等函数,并指定可执行文件的完整路径。这样可以避免依赖PATH环境变量来查找可执行文件的路径。
验证:
- 我们在在mycmd程序中再进行程序替换
- 用execlp函数第一个参数是在PATH路径下找的可执行文件
- mycmd进程的环境变量中只有一句话(只有MYPATH了)
- 但是我们照样可以在mycmd中进行程序替换执行出ls的结果!!
即使我们将父进程中的PATH给改了,命令行中都用不了ls,execlp照样可以找到ls并执行它。
2.6 execvpe函数:
int execvpe(const char *file, char *const argv[], char *const envp[]);
有了上面的基础这个想必就不用再啰嗦了,只是第二个参数传的不同,这里传的是一个指针数组。
这些函数原型看起来很容易混,但只要掌握了规律:
- l (list) : 表示参数采用列表
- v (vector) : 参数用数组
- p (path) : 有p自动搜索环境变量PATH
- e (env) : 表示自己维护环境变量
为什么有那么多的接口?
- 目的是:适配应用场景
- 其实上述函数都是对系统接口的封装
严格意义来说不是系统接口,是基于系统接口之上的封装。
真正意义上的系统接口:
int execve(const char *filename, char *const argv[], char *const envp[]);
上述6个函数在执行时都会调用execve()函数,将参数列表和环境变量数组转换为execve()函数所需的格式,并调用execve()函数来执行可执行文件。因此,execve()函数是这些函数的底层实现。
3. 实现简易版shell
只要我们懂得了程序替换的原理,会用程序替换的接口,就很好理解:
- shell本身执行起来就是个死循环
- 我们命令行就是去执行其他程序
- shell创建子进程,将子进程给替换掉就ok了
- 过程中要获取输入指令等操作…
myshell代码实现:
#include <stdio.h>
#include <string.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#define SEP " "
#define NUM 1024
#define SIZE 128
char command_line[NUM];
char* command_args[SIZE];
char env_buffer[NUM];
extern char** environ;
//对应上层的内建命令
int ChangeDir(const char* new_path)
{
chdir(new_path);
return 0;//调用成功
}
void PutEnvInMyShell(char* new_env)
{
putenv(new_env);
}
int main()
{
//shell本质就是一个死循环
while(1)
{
//不关心获取这些属性的接口,搜索一下都有
//1.显示提示符
printf("[用户名@我的主机名 当前目录]# ");
fflush(stdout);
//2.获取用户输入
memset(command_line, '\0', sizeof(command_line));
//从键盘获取,标准输入,stdin,获取到的是C风格的字符串(stdio.h结尾的),'\0'结尾
fgets(command_line, NUM, stdin);
command_line[strlen(command_line) - 1] = '\0';//清空\n回车
//printf("%s\n", command_line);
//3. "ls -a -l -i" -> "ls" "-a" "-l" "-i" 字符串切分 -- 因为这些参数一定得以列表或者数组方式传递给程序替换接口
//shell必须切分,因为必须调用execl函数
//将第一个字符串地址用0号下标指向,第二个字符串地址用1号下标指向
command_args[0] = strtok(command_line, SEP);
int index = 1;
//给ls命令添加颜色: 如果提取出来的程序名是ls -- 1下标设置成改颜色的
if(strcmp(command_args[0], "ls") == 0) command_args[index++] = (char*)"--color=auto";
//strtok截取成功返回字符串起始地址
//截取失败,返回NULL
while(command_args[index++] = strtok(NULL, SEP));
//for debug
//int i = 0;
//for(i = 0; i < index; i++)
//{
// printf("%d : %s\n", i, command_args[i]);
//}
//4.TODO -- 编写后面的逻辑,内建命令(由父Shell自己实现的自己调用的一个函数)
if(strcmp(command_args[0], "cd") == 0 && command_args[1] != NULL)
{
//让调用方进行路径切换,父进程
ChangeDir(command_args[1]);
continue;
}
//走到这里一定是将命令行参数解析完了,包括命令 + 选项
//将环境变量的信息导入在了父进程的上下文当中
if(strcmp(command_args[0], "export") == 0 && command_args[1] != NULL)
{
//环境变量列表(是个指针数组,每个元素是个指针指向一个环境变量)
//我们传的是一个字符串首地址,但是环境变量的内容还是我们自己维护的
//目前,环境变量信息在comman_line,会被清空,那么环境变量当然就没有了
//所以此处我们需要自己保存一下环境变量的内容
strcpy(env_buffer, command_args[1]);
PutEnvInMyShell(env_buffer);
//PutEnvInMyShell(command_args[1]);//MYENV=112233
continue;
}
//5.创建进程,执行
//如果自己直接程序替换的话,就把自己写的shell给替换了
pid_t id = fork();
if(id == 0)
{
//子进程
//6.程序替换
//execvpe(command_args[0], command_args, environ);
execvp(command_args[0], command_args);
exit(1);//执行到这里,子进程一定替换失败了
}
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret > 0)
{
printf("等待子进程成功: sig: %d, code: %d\n", status & 0x007F, (status & 0xFF00) >> 8);
}
}//end while
return 0;
}
ls设置颜色的办法:
3.1 内建命令等问题的解决:
3.1 - 1 cd命令的处理:
在命令行中操作cd时,会跳转路径,但是用绝对命令时,就不行了,还是原来的路径:
- 一个进程也存在对应路径, 进程对应的路径可以理解成
- 当进程启动的时候时,在哪个路径启动时,这个进程所在路径就是当前进程所启动的路径
一般一个进程的路径是会被于进程继承的,路径的变化我们希望的是父进程路径的变化。
原因就是,我们平时用的cd时做过处理的cd:
- 我们知道指令都是一些可执行程序
- 执行可执行程序就是去执行其他程序,程序替换了
- 如果我们创建的子进程跳转路径
- 子进程退出之后,只是子进程的路径跳转了
- 并不影响父进程的路径,会发现命令行路径还是没变
- 显然这这种做法是不可取的
就不能用程序替换的方式来执行一些特殊的命令了:
- 我们可以在父进程将一些命令单独处理
- 让其不进行程序替换
重点:
- 程序替换影响的是子进程和父进程没关系,子进程一 跑就完了,曾经所有的操作就没有意义,路径切换就没意义了,所以我们要让父进程的路径发生变化。
- 如果有些行为,是必须让父进程shell执行的,不想让子进程执行,绝对不能创建子进程!只能是父进程自己实现对应的代码!
内建命令:
- 我们把由父进程自己提供的代码或者提供的逻辑(在命令行上体现的也是一个命令),但是这部分命令不是子进程执行的,而是父进程自己执行的,我们叫做内建命令。
- 由shell自己执行的命令,我们称之为内建(内置bind- in)命令。
更改工作目录的函数:
验证一下:
3.2 - 2 export的处理:
导入环境变量:
export不是一个可执行程序和cd,ls,cat等指令不同:
export是一个shell内置命令,用于设置环境变量。它并不是一个可执行程序,而是由shell解释器直接执行的命令。当我们在shell中使用export命令时,它会将指定的环境变量设置为当前shell进程的环境变量,以便后续的命令或程序可以使用该环境变量。
所以用execvp进行程序替换的时候,是不能替换成功的!
注意:
- 环境变量列表(是个指针数组,每个元素是个指针指向一个环境变量)
- 我们传的是一个字符串首地址,但是环境变量的内容还是我们自己维护的
- 目前,环境变量信息在comman_line,会被清空,那么环境变量当然就没有了
- 所以此处我们需要自己保存一下环境变量的内容
环境变量是数据,进程替换不是替换进程的代码和数据吗?
- 但是环境变量是属于系统的数据
- 子进程在执行程序替换时
- 当前进程的环境变量数据,不会被替换掉
- 而且是以父进程为模版继承下来的
- 所以才会让父进程以内建命令的方式putenv,子进程就能直接获取了
环境变量的数据,在进程的上下文中:
- 环境变量会被子进程继承下去,所以他会有全局属性。
- 当我们进行程序替换的时候,当前进程的环境变量非但不会被替换,而且是继承父进程的!!