目录
引入进程程序替换
进程程序替换
初步使用exec系列函数
原理分析
做一个简易的shell
cd - 内置命令的理解
export - 环境变量的深入理解
引入进程程序替换
对于fork的学习让我们知道:fork()之后的,父子进程各自执行父进程代码的一部分。但是创建一个子进程的目的,肯定是为了做与父进程不同的事,于是想让子进程执行一个全新进程。就有了进程程序替换。
进程程序替换
程序替换,是通过特定的接口,加载磁盘上的一个权限的程序(代码和数据),加载到调用的进程地址空间中。
#问:进程替换,有没有创建新的子进程?
答:没有!进程替换是通过将磁盘中的代码和数据放到内存中,然后改变子进程内核数据结构中的页表映射关系。
是什么,进程程序替换?
程序替换是通过特定的接口,加载磁盘上的一个程序(代码和数据),即加载到调用进程的地址空间中。
为什么,进程程序替换?
想让子进程执行其他的程序,即不想让子进程执行父进程的部分而是执行一个全新的程序。
怎么办,进程程序替换?
进程程序替换的核心在于,如何将程序放入内存当中,即所谓的新程序的加载。Linux中采用exec系列函数解决程序的加载。而exec系列函数进行的操作是在,几乎不变化内核结构PCB的角度,将新的磁盘上的程序加载到内存,并和进程的页表重新建立映射。
初步使用exec系列函数
- 可知:
上面的6个函数是man 3,所以是由库提供的。是对系统调用的再次封装。而且其文档中有环境变量时的environ,也可以看出其进程程序替换与环境变量的知识关联。此处可更加深层次的了解环境变量。
int execl(const char *path, const char *arg, ...);
- path:路径 + 目标文件名
- arg,. . .:可变参数列表(可以传入多个不定个数参数)
使用方式与,在命令行上执行一样,参数一个一个对应填即可,最后一个参数必须是NULL,标识参数传递完毕。
复习:
此处,对应环境变量时的知识,其中的main函数的第二个参数。即命令行参数的存储的char*数组。
#include <stdio.h> int main(int argc, char *argv[]) { int i = 0; while(argv[i]) { printf("%d: %s\n", i, argv[i++]); } return 0; }
最后一个参数是NULL,表示命令行参数的结束。
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("当前进程的开始代码\n");
execl("/usr/bin/ls","ls","-l",NULL);
return 0;
}
原理分析
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("当前进程的开始代码\n");
execl("/usr/bin/ls","ls",NULL);
printf("当前进程的结束\n");
return 0;
}
我们发现该程序所执行的进程,只是将execl函数之前的printf执行了,并未将其后的printf执行。这正是因为execl是程序替换,调用该函数成功后,会将当前进程的所有的代码和数据都进行替换!包括已经执行的与没有执行的。只不过由于execl函数之前的printf已经执行并输出了。所以,一旦调用成功,后续代码全部不执行。这也代表着execl无需进行返回值的判断,成功之后程序被替换,后续判断也会被替换没。所以没有判定返回值的意义。
融汇贯通的理解:
加载新进程之前,子进程的数据和代码与父进程共享,并且数据具有写时拷贝,这是fork的基本知识。而当子进程加载新进程的时候,在fork时所讲的代码不变而共享的存在,在此时也可以说是和数据一样,称为一种“写入”。所以代码也是需要父子分离,也是需要写时拷贝。如此在不仅仅是数据,代码也是需要写时拷贝的。
如此在execl之后:父子进程在代码和数据上就彻底分开了,虽然曾经不冲突。
库中的exec系列函数 - 极其相似
- l(list) : 表示参数采用列表
- v(vector) : 参数用数组
- p(path) : 有p自动搜索环境变量PATH
- e(env) : 表示自己维护环境变量
函数名 | 参数格式 | 路径提供 | 当前环境变量 |
---|---|---|---|
execl | 列表 | 无 | 默认 |
execlp | 列表 | 有 | 默认 |
execle | 列表 | 无 | 需自行传 |
execlp与execl的区别可以说是多了一个p,即可以理解为execl的基础上多了一个PATH,意思就是会自行在环境变量PATH里查找,不需要使用写明执行的程序在哪个路径下。
int execlp(const char *file, const char *arg, ...);
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("当前进程的开始代码\n");
execlp("ls","ls", "-l", NULL);
printf("当前进程的结束\n");
return 0;
}
execlp与execl的区别可以说是多了一个e,即可以理解为execl的基础上多了一个evn,也就是environ。意思就是需要我们自行传环境变量。
int execle(const char *path, const char *arg, ..., char * const envp[]);
#include <stdio.h>
#include <unistd.h>
int main(int argc, char *argv[], char* env[])
{
//extern char **environ;
printf("当前进程的开始代码\n");
execle("/usr/bin/ls", "ls", "-l", NULL, env);
//execle("/usr/bin/ls", "ls", "-l", NULL, environ);
printf("当前进程的结束\n");
return 0;
}
函数名 | 参数格式 | 路径提供 | 当前环境变量 |
---|---|---|---|
execv | 数组 | 无 | 默认 |
execvp | 数组 | 有 | 默认 |
execvpe | 数组 | 有 | 需自行传 |
execv与execl的区别可以说是l变为了v,即可以理解为l是list、v是vector,即一个是可变的链态传递,一个是定的数组态传递。
int execv(const char *path, char *const argv[]);
#include <stdio.h>
#include <unistd.h>
#define NUM 16
int main()
{
printf("当前进程的开始代码\n");
char *const _argv[NUM] = {
(char*)"ls",
(char*)"-a",
(char*)"-l",
(char*)"-i",
NULL
};
execv("/usr/bin/ls", _argv);
printf("当前进程的结束\n");
return 0;
}
execvp与execv的区别可以说是多了一个p,即可以理解为execv的基础上多了一个PATH,意思就是会自行在环境变量PATH里查找,不需要使用写明执行的程序在哪个路径下。
int execvp(const char *file, char *const argv[]);
#include <stdio.h>
#include <unistd.h>
#define NUM 16
int main()
{
printf("当前进程的开始代码\n");
char *const _argv[NUM] = {
(char*)"ls",
(char*)"-a",
(char*)"-l",
(char*)"-i",
NULL
};
execvp("ls", _argv);
printf("当前进程的结束\n");
return 0;
}
execvpe与execvp的区别可以说是多了一个e,即可以理解为execl的基础上多了一个evn,也就是environ。意思就是需要我们自行传环境变量。
int execvpe(const char *file, char *const argv[], char *const envp[]);
#include <stdio.h>
#include <unistd.h>
#define NUM 16
int main(int argc, char *argv[], char* env[])
{
//extern char **environ;
printf("当前进程的开始代码\n");
char *const _argv[NUM] = {
(char*)"ls",
(char*)"-a",
(char*)"-l",
(char*)"-i",
NULL
};
execvpe("/usr/bin/ls", _argv, env);
//execvpe("/usr/bin/ls", _argv, environ);
printf("当前进程的结束\n");
return 0;
}
系统进程程序替换接口execve函数
#include <stdio.h>
#include <unistd.h>
#define NUM 16
int main()
{
extern char **environ;
printf("当前进程的开始代码\n");
char *const _argv[NUM] = {
(char*)"ls",
(char*)"-a",
(char*)"-l",
(char*)"-i",
NULL
};
execve("/usr/bin/ls", _argv, environ);
printf("当前进程的结束\n");
}
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
融汇贯通的理解:
进程程序替换原理:结合进程地址空间,通过将新的磁盘数据加载到内存中,通过改变子进程页表的映射关系,以此达到建立进程的时候,先创建子进程再加载代码与数据的操作。
我们的手机或者电脑,并不是先将代码与数据加载到内存,而是先把进程创建出来。然后子进程通过加载函数exec系列系统接口,将磁盘的软件、app程序加载到内存里。
所以说:进程是一个运行起来的程序,是对的但是不够准确。因为程序也有可能没运行(并未进程替换),但是进程已经有了。
拓展:
windows也有进程程序替换的操作,以前我们在windows所用的代码书写工具VS19等,本质上就是一个进程,它们是一个集成开发环境,可以写代码也可以编译代码等。分别对应编辑器、编译器等,其是一个个的模块,它们自身只提供编辑(写代码)功能。而后面的操作需要我们进行安装对应模块,如编译代码时,由如VS19来创建子进程,并使用进程程序替换来让子进程来编译代码。于是乎我们的代码崩亏并不会印象到VS19的运行,因为进程具有相对独立性。
做一个简易的shell
shell执行命令的时候,我们所执行的命令是处于磁盘的、在文件系统里面、在目录结构里面是一个特定路径下的程序,跑起来之后变成一个进程。其运行的本质就是shell给其创建了一个子进程,然后让子进程直接执行一个exec类型的系统接口,将磁盘的内容加载到内存中去跑起来。
简易的shell的核心
- 需要将从标准输入中获取的支付串进行分析,将其分散为可用的命令(如:"ls -a -l -i" -> "ls" "-a" "-l" "-i")。利用C字符串函数strtok
- 将需要补充的命令参数进行补充(如:ls命令的颜色显示参数"--color=auto")。利用if语句判断第一个参数
- 利用fork后执行程序替换,让子进程执行命令,父进程阻塞等待。此时父进程就是shell
(下列代码点是1,2,3,5,省略了4,由于4更难,完成简单的后再讲解)
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#define NUM 1024
#define SIZE 32
#define SEP " "
//保存完整的命令行字符串
static char cmd_line[NUM];
//保存打散之后的命令行字符串
static char *g_argv[SIZE];
int main()
{
while(1)
{
//1. 打印出提示信息 [qcr@我的系统 myshell]#
printf("[qcr@我的系统 myshell]#");
fflush(stdout);
memset(cmd_line, '0', sizeof cmd_line);
//2. 获取用户的键盘输入(如:输入的是各种指令和选项: "ls -a -l -i")
if(fgets(cmd_line, sizeof cmd_line, stdin) == NULL)
continue;
//意义:"-a -l -i\n\0"
//3. 命令行字符串解析:"ls -a -l -i" -> "ls" "-a" "-l" "-i"
cmd_line[strlen(cmd_line) - 1] = '\0';
g_argv[0] = strtok(cmd_line, SEP); //第一次调用,要传入原始字符串
int index = 1;
if(strcmp(g_argv[0], "ls") == 0)
g_argv[index++] = "--color=auto";
while(g_argv[index++] = strtok(NULL, SEP)); //第二次调用,如果还要解析原始字符串,传入NULL
//5. fork()
pid_t id = fork();
if(id == 0)
{
//child
printf("下面功能让子进程进行的\n");
execvp(g_argv[0], g_argv); // ls -a -l -i
exit(1);
}
//father
int status = 0;
pid_t ret = waitpid(id, &status, 0); //阻塞等待
if(ret > 0) printf("exit code: %d\n", WEXITSTATUS(status));
}
return 0;
}
上面利用50多行就简单实现了一个我们的shell,由于功能实现不全,所以只能实现部分命令(后续博客会随着知识上升,会随之完整,但限于为理解,所以会很简陋)。此处的我们的shell是一个while循环打造的,所以结束需要使用CTRL + c。由于输入分析不够完整,所以对于Delete需要CTRL + Delete。
cd - 内置命令的理解
当我们使用myshell执行cd会发现:
当我们执行cd的时候,会发现使用cd成功了,但是myshell中的当前地址并且改变。这是因为我们将cd的操作放在了子进程上,子进程进行了cd并不会改变父进程的当前地址。
我们执行的命令是通过父进程也就是myshell创出的子进程,经过进程替换成我们所输入的命令的可执行程序的代码和数据,其执行起来的进程是子进程角度上的。我们需要在父进程上。
内置命令:让父进程(myshell)自己执行的命令,叫做内置命令,内建命令。
cd由于是内置命令,所以其是与普通的如"ls"命令是不同的,其并不是一个存储在硬盘的可执行程序,而是由系统提供的系统接口实现。
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#define NUM 1024
#define SIZE 32
#define SEP " "
//保存完整的命令行字符串
static char cmd_line[NUM];
//保存打散之后的命令行字符串
static char *g_argv[SIZE];
int main()
{
while(1)
{
//1. 打印出提示信息 [qcr@我的系统 myshell]#
printf("[qcr@我的系统 myshell]#");
fflush(stdout);
memset(cmd_line, '0', sizeof cmd_line);
//2. 获取用户的键盘输入(如:输入的是各种指令和选项: "ls -a -l -i")
if(fgets(cmd_line, sizeof cmd_line, stdin) == NULL)
continue;
//意义:"-a -l -i\n\0"
cmd_line[strlen(cmd_line) - 1] = '\0';
g_argv[0] = strtok(cmd_line, SEP); //第一次调用,要传入原始字符串
int index = 1;
if(strcmp(g_argv[0], "ls") == 0)
g_argv[index++] = "--color=auto";
while(g_argv[index++] = strtok(NULL, SEP)); //第二次调用,如果还要解析原始字符串,传入NULL
//4.内置命令
if(strcmp(g_argv[0], "cd") == 0) //父进程执行
{
if(g_argv[1] != NULL) chdir(g_argv[1]);
continue;
}
//fork
pid_t id = fork();
if(id == 0)
{
//child
printf("下面功能让子进程进行的\n");
execvp(g_argv[0], g_argv); // ls -a -l -i
exit(1);
}
//5.father
int status = 0;
pid_t ret = waitpid(id, &status, 0); //阻塞等待
if(ret > 0) printf("exit code: %d\n", WEXITSTATUS(status));
}
return 0;
}
export - 环境变量的深入理解
makefile
.PHONY:all
all:myshell mytest
myshell:myshell.c
gcc -o $@ $^
mytest:mytest.c
gcc -o $@ $^
.PHONY:clean
clean:
rm -rf myshell mytest
all没有目标文件有两个路径文件,于是会分别执行两个路径,其又作为目标文件有各自对应的路径,于是就有了两个可执行程序。
myshell.c
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>
#define NUM 1024
#define SIZE 32
#define SEP " "
//保存完整的命令行字符串
static char cmd_line[NUM];
//保存打散之后的命令行字符串
static char *g_argv[SIZE];
//利用缓冲区,将环境变量数据传递给子进程
static char g_myargv[64];
int main()
{
while(1)
{
//1. 打印出提示信息 [qcr@我的系统 myshell]#
printf("[qcr@我的系统 myshell]#");
fflush(stdout);
memset(cmd_line, '0', sizeof cmd_line);
//2. 获取用户的键盘输入(如:输入的是各种指令和选项: "ls -a -l -i")
if(fgets(cmd_line, sizeof cmd_line, stdin) == NULL)
continue;
//意义:"-a -l -i\n\0"
cmd_line[strlen(cmd_line) - 1] = '\0';
g_argv[0] = strtok(cmd_line, SEP); //第一次调用,要传入原始字符串
int index = 1;
if(strcmp(g_argv[0], "ls") == 0)
g_argv[index++] = "--color=auto";
while(g_argv[index++] = strtok(NULL, SEP)); //第二次调用,如果还要解析原始字符串,传入NULL
//4.内置命令
if(strcmp(g_argv[0], "cd") == 0) //父进程执行
{
if(g_argv[1] != NULL) chdir(g_argv[1]);
continue;
}
//在myshell中添加环境变量 - 是父进程上的行为,所以是内置命令
//如:g_val[0] = export;g_val[1] = Hello="你好"
if(strcmp(g_argv[0], "export") == 0 && g_argv[1] != NULL)
{
strcpy(g_myargv, g_argv[1]);
int ret = putenv(g_myargv);
if(ret == 0) printf("export success\n");
continue;
}
//5.fork
pid_t id = fork();
if(id == 0)
{
//child
printf("%s\n", getenv("Hello"));
printf("下面功能让子进程进行的\n");
execvp(g_argv[0], g_argv); // ls -a -l -i
exit(1);
}
//father
int status = 0;
pid_t ret = waitpid(id, &status, 0); //阻塞等待
if(ret > 0) printf("exit code: %d\n", WEXITSTATUS(status));
}
return 0;
}
//利用缓冲区,将环境变量数据传递给子进程
static char g_myargv[64];
//在myshell中添加环境变量 - 是父进程上的行为,所以是内置命令
//如:g_val[0] = export;g_val[1] = Hello="你好"
if(strcmp(g_argv[0], "export") == 0 && g_argv[1] != NULL)
{
strcpy(g_myargv, g_argv[1]);
int ret = putenv(g_myargv);
if(ret == 0) printf("export success\n");
continue;
}
复习:
环境变量表中,存储的是环境变量字符串的地址,通过使用字符串地址的方式找到环境变量。
此处所写的myshell是用while为核心的,而在经过export环境变量操作后continue,将会进行下一次while,而上一个while的环境变量的实际存储位置是在g_argv中,再次循环会因为命令的输入而刷新掉实际环境变量的数据。于是环境变量表中存储的地址就会变为野指针。所以我们需要写一个全局的变量,进行拷贝存放。
mytest.c
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("Hello=%s\n",getenv("Hello"));
return 0;
}
用于检验我们所创建的shell是否export了环境变量。因为由程序mytest在myshell中运行,是属于myshell的子进程,而环境变量具有全局属性,所以test会继承myshell环境变量。
融汇贯通的理解:
在环境变量时,有所谓的环境变量以及本地变量,有export的是环境变量,无export的是本地变量,环境变量具有全局属性,能被子进程继承。本地变量不能被子进程继承,而在此处我们可以更加深刻的理解。进程程序替换本质上:是将磁盘中的一个程序全部执行起来,其实是一个加载器的角色,而当它加载的时候,我们可以手动的导入我们自定义的环境变量,也可以使用默认的环境变量(父进程的继承给子进程)。
shell执行命令的方式通常有两种:
- 第三方提供的对应的在磁盘中有具体的二进制文件的可执行程序(由子进程执行)。
- shell内部,自己实现的方法,由自己(父进程)来进行执行。 (因为有一些命令就是要影响到shell本身,如:cd,export)
从myshell的角度更可以直接的解释,程序替换的必要性,一定是和应用场景有关的,有时候就是需要子进程去执行一个全新的程序。