exec系列接口中的环境变量
在之前我们学习了exec系类函数的功能就是将一个程序替换成另外一个程序。
然后就会出现下面的问题:
首先父进程对应的环境变量的信息是从bash中来的,因为我们自己写的父进程在运行的时候首先就要成为bash的子进程。这里我们将bash称为祖父进程,我们自己写的父进程,和父进程创建的进程为孙子进程。这三位其实使用都是一套环境变量(bash的环境变量)。在这里我们在bash中导入一个myval环境变量,然后父进程会继承bash的环境变量,而孙子进程也会继承父进程的环境变量。那么在孙子进程处打印环境变量我们应该可以看到MYVAL的环境变量。
下面就是在孙子进程中打印了所有的环境变量,然后就发现了这个环境变量存在于孙子进程中。
果然这个环境变量会被孙子进程拿到。那么还有一个验证方法就是在父进程中手动导入一个环境变量,然后再去看孙子进程是否会打印这个环境便量的值。
这个父进程自己导入的环境变量,bash是看不到的,因为这个导入的环境变量是导入在父进程自己的进程地址空间上的,所以bash无法看到这个新导入的环境变量。 但是这个新的环境变量应该由子进程继承下去,那么怎么在代码中实现呢 ?需要使用下面的函数
这样就将MYVAL2给导出去了,这样子进程也应该继承这个环境变量(如果这个环境变量被成功的导入到了父进程的上下文),这个新增不会影响bash,因为这里是bash先将自己环境变量继承给了父进程,父进程在自己添加,所以不会影响到bash,而bash是先添加环境变量再将环境变量继承给子进程,所以再bash中添加才会影响到子进程(包括孙子进程),而在父进程中新增不会影响到bash
果然在最终的子进程也能够看到这个父进程导入的环境变量。
而在bash这里没有看到这个MYVAL2的环境变量值
故验证了上面的结论
这里我们能够得出的初步结论就是:
上图中右边的圆圈看作是bash原本的环境变量,而蓝色的方框是bash新增的环境变量,红色的方框是父进程新增的环境变量。父进程新增的环境变量不会影响到bash,bash新增会影响到父进程,但是这两个进程的新增都会影响到,孙子进程(孙子进程会包含前面所有的新增)。
下面还有一个结论:
首先这个结论也就帮助我们理解了,为什么我们的子进程在替换成为其它程序之后,还能够打印出环境变量。即使我们在父进程这里使用execl没有传递任何有关环境变量的值。
那么这里的原理是什么呢?
原因如下:
首先父进程自己的task_struct指向自己的进程地址空间,而在进程地址空间中的地址都经过页表映射映射到了物理内存中,而在父进程栈区的上方我们可以看到一个储存命令行参数和环境变量的空间。而此时父进程创建了子进程,子进程就会将父进程中的task_struct全部拷贝一份,页表也是如此(除了如果发生写入会引起写时拷贝以外),子进程会完全拷贝一份父进程的task_struct和页表,由此父进程的环境变量也就被子进程继承下来了。
但是还有一个问题这里的环境变量和命令行参数也是父进程/子进程的数据啊,而进程的程序替换会替换进程的代码和数据
而根据我们一开始的结论(环境变量的继承不会受到程序替换的影响),我们能够知道一个事实就是:
即无论存在多少子进程,这些子进程的环境变量都会从父进程处获取,即使子进程执行了程序替换也没有关系,程序替换不会影响环境变量。所以如果要将环境变量传递给子进程很简单,在父进程中导入新的环境变量后直接fork创建子进程即可。这里即使你是在子进程中使用environ来获取环境变量也能够获得从父进程那里新增的幻境变量。
以上我们都是在验证一个结论那就是环境变量是具有全局属性的,无论你的子进程是否进行了程序替换,子进程中的环境变量一定是从父进程那里继承得来的。
在子进程获得环境变量的时候具有两种情况:
第一种子进程要原封不动的获得父进程的环境变量:
有两种方法:第一种就是直接用(默认就是原封不动的传递)
第二种:使用execle
这里代码也能解释为什么程序替换后的新程序能够得到我们在bash中输入的命令行,因为在替换程序的时候,这个exec接口就会将命令行参数,环境变量等信息传递给替换后的新程序。所以在替换后的新程序里面能够打印出我们传递给新程序的命令行参数(可以打印因为这些命令行参数都被传递到了下图中的argv数组中,打印数组中的内容就能够看到我们传递给新程序的命令)
这样也完成了将父进程的环境变量原封不动的传递给了子进程,其实还可以使用main函数的参数,但这里我选择了使用environ这个全局的变量。
然后下面是test.cc的文件(也是形成mytest的源文件)
运行结果:
由此就能得到结论:
还有第二种情况:
这也很简单,我们首先创建一个我们自己的环境变量列表:
这里出现报错的原因是数组中的内容是const char*的内容,,但是数组是一个char* const的数组,不替换成const char*因为execle这个函数最后需要的环境变量列表就是一个char* const的列表,所以这里才这么写。
然后下面是运行:
然后此时的子进程能够拿到的环境变量就是我们新创建的环境变量表了。
这里还能得出一个结论就是:
这里传递环境变量不是新增式的传递而是覆盖式的传递环境变量。
还有第三种情况:
此时我们只需要在父进程中导入新的环境变量:
然后执行下面的:
这样我们的子进程就能够得到新增的环境变量了(包含原来的环境变量)
此时无论你是显示的传递环境变量还是隐式的传递环境变量,都能够让子进程得到原来的环境变量和新增的环境变量。
由此就能得到下面的三种交给子进程环境变量的方式:
而环境变量在传递的时候以覆盖式方式传递。
下面我们再来认识一下exec*系列:
然后会发现在2号手册中还存在一个execve的函数,为什么它会单独在一个手册中呢?因为计算机在底层实现的时候,就是将左边的这些函数的参数都替换成了右边函数的参数,然后再去执行execve的,所以即使你不传环境变量,底层也会自动的传递父进程的环境变量给替换后的程序。而存在左边的这些函数原因主要式为了满足各种调用的场景。
下面我们如果将子进程中的程序替换修改成子进程会去执行ls命令,那么如果我们调用父进程,是不是就相当于父进程创建了一个子进程而这个子进程则帮助父进程去执行了ls命令。如果我们能够将这个父进程一直运行下去,那么也就意味着,父进程能够一直去接收我们用户输入的指令,然后父进程将指令打散成字符串数组,fork后传递给子进程,让子进程能够去运行这个命令,此时我们的父进程是不是就相当于一个bash。而不进行循环,那么父进程就相当于一个只执行一次的shell。
模拟实现shell
打印命令行提示符
下面我们就来设计一个属于我们自己的简单版的shell。
首先就是:
这个其实就是一个字符串,在bash进程将这一个字符串打印出来之后,bash进程会一直等待我们用户输入指令 。
首先第一步我们要来创建Makefile文件和.c文件。
然后我们要来打印一下上面的LHY那些提示信息(用户名,主机名,工作目录)。
首先上面的这些信息都存在于环境变量中 ,其实在系统中是存在获取这些信息的系统调用的,但是这里我就不去调用这些系统调用了,而是自己写一下:
这些信息都存在于环境变量中,下面我们要使用一个函数接口帮助我们从环境变量中获取这些信息。
这里使用getenv来根据环境变量名字获取环境变量内容。
写好后的代码:
下面我们保存退出来运行一下:
此此时我们就成功的构建了一个我们自己的命令行提示符。$是os的#是我自己写的。
那么下一个步骤自然是要让命令行停止之后,让我们的用户能够输入命令。
获取用户输入的命令
首先假设最长的命令也不可会超过1024,那么就先定义一个1024字节大小的命令储存数组。
运行后就会发现不对,标准的命令行光标是在命令行提示符的后面停止的,但是我们写的却是在命令行提示符的下一行停住的()。
原因就是在打印的后面带上了一个\n, 把他去除就可以了。
这样就符合标准了。下面我们需要检测一下我输入的这个命令有没有保存在这个数组中呢?
这里我们先做一个打印检测一下:
然后就会发现在usercommand中没有空格以后的内容,原因则是scanf在输入命令的时候,遇到空格和换行都会停止输入。
所以这里的解决方法就是不使用scanf输入。存在一个按行获取的接口就是fgets
其中第一个和第二个对应的是你要将读到的内容放到哪一个缓冲区中,而第三个则是对应你要从哪一个文件中读取。
那么为什么要挑选这一个接口呢?
而这里键盘其实就是标准的输入流,所以这里的fgets就从stdin中获取信息读到缓冲区中。如果fgets获取成功了那么返回的就是你所获得的这个字符串储存位置的起始地址,获取失败了就是NULL。
但是这里出现了一个问题,那就是我在下面打印数组中的内容的时候明明没有将\n,放到printf中?但是为什么最后打印完后还是换行了呢?
原因是我们在输入完成一个命令行后,肯定会按一下enter键,这个键就是换行的意思,所以这里即使我在打印的地方没有加入\n,也会多出一个\n
啊啊啊啊啊,但是我们并不想要这个\n怎么解决呢?我们知道的是这个\n肯定是在usercommand最后一个字符的后面所以我们可以这么修改一下:
这里strlen遇到\n也会让个数+1(计入到有效字符中除非遇到\0才会结束)
这里会出现越界访问吗?很明显不会因为就算你什么都不输入,最后还是会按一个enter键,这个按键就会被输入进去(此时的strlen为1)减去一个1之后为0,此时就将uset中的内容修改成空串了。那么也就不会出现越界的情况。
下面就不会出现这个换行了。
而我们现在在上面写的所有内容都可以被称作为获取命令行输入,所以这里我们就来封装一下:
那么下面我们就是需要将我们所得到的字符串做判断和分割。分割完后就要使用子进程去执行命令了。
分割用户输入的命令
那么首先来解决分割的问题,如果是在c++中可以使用substr和find函数来解决,但是c中没有substr和find。
这里的方法可以使用两个常量记录一个有效字符的长度,首先begin和end都指向了l,然后让end++。直到end指向空格,将这之中的字符赋值给一个新的字符串。这是一个方法但是过于麻烦。还有一个方法就是遍历或者字符串将空格变成\0,然后使用指针分别指向第一个/第二个/第三个字符串这样,也能得到分割后的字符串。这也是很麻烦的。并且我们被打散的字符串也是需要我们去将其保存起来的,那么在c中存不存在一个接口能够做到打散字符串,并将其保存呢?
为了解决上面的问题,我们首先肯定是要先拥有一个能够保存分割后字符串的空间。
这里我们就是创建一个指针数组,并且保证让第一个位置指向的是开始命令的开头,其余在后面排序分布。最后当将命令行集齐完整之后以NULL结尾。为了保证分割后的子串是一个独立的子串所以这里还需要保证ls后面-a后面和-l后面都是一个\0。
所以我们这里采用的方式是将每一个空格的位置都变成\0,但是这个工作由我们去做吗?
这里可以采用字符串分割函数(c中的字符串分割函数strtok)。
这个函数在使用的时候需要注意首先在第一次分割的时候需要指明要分割的是哪一个字符串,以及分割符号,然后这个函数会返回分割后字符串的开始位置的指针。
然后在下一次使用strtok的时候就不要指明要分割的字符串了,只需要写明分割符号,然后当strtok将原字符串分割完毕之后会返回NULL。
下面是第一种将字符串分割然后储存的方法。
这里存在一个错误就是在打印argv数组中的内容的时候,不应该等于argc,应该是小于
还有一种写法,不要放了strtok的返回值失败后是一个NULL,所以我们可以使用下面的写法。
这两种写法都是可行的,这里我选择下面的这种,下面自然是要去完成这种写法的封装了。
需要注意的是这里我将空格#defiine了(SEP就是字符串空格)
然后是运行的结果:
运行结果:
由此我们就完成了对字符串的分割。
创建子进程使用程序替换执行命令
下面就是要去执行对应的命令了,首先执行命令的时候父进程肯定是不能去运行命令的(内建命令除外),因为如果及那个父进程替换成新的程序,那么我们之前写的代码都白写了(因为程序替换会替换代码和数据),所以这里我们是使用一个子进程去执行对应的命令。
在创建完子进程之后的下一步自然就是选择合适的程序替换接口了。
那么那么多的程序替换接口这里我们选择哪一个呢?
这里选择的是execvp,原因如下首先就是分割完的字符串已经被放到一个agrv中了,其次我们不知道绝要替换的程序所在绝对文件路径,只能让操作系统自己去环境变量中寻找,我们只需要提供给一个程序名字即可。所以 这里选择的程序替换接口就是execvp。
程序如下:
在不进行封装的情况下就完成了。但是上面的代码还存在一个问题那就是我们的shell只会跑一次,所以这里我们还需要在做一个操作那就是让shell死循环即可
此时我们就可以去执行ls,top等等简单的命令了,而且我们的shell并不会执行完一次之后就退出了。
下面我们来解决一种极端的情况,那就是如果用户什么命令都不输入,那么这个时候我们的shell虽然不会报错,但是如果用户什么命令都没有输入,那么我们的shell也不应该去创建子进程所以我们修改一个getusercommad的返回值。
这样也就能够解决输入空串或者是读取数据失败的情况了。
但是这么写的话会显得很不好看,所以这里我们依旧将执行命令的这些语句做一下封装。
以上我们就能够完成一个比较简单的shell了,这里我们也知道了os中的shell也是一个进程,它能够不同的运行的原理是因为它是一个死循环,所以为了防止shell挂了,所以shell会创建子进程去完成用户输入的命令(如果shell不创建子进程执行命令,除了shell只能执行一次之外,还有shell会存在挂掉的风险,如果shell挂掉了,就没有软软件做命令行解释了),而因为每一个进程都是具有独立性的所以即使子进程挂了也不会影响shell的运行。
下面我们再来完善一下我们这个简单shell的遗留问题。
实现内建命令
cd命令
第一个遗留问题
会发现pwd命令不会更新我们当前所在的路径,原因在于这个cd命令是在子进程上执行的,也就是cd一直在修改子进程的工作目录,而父进程则一直没有变化,所以为了让父进程能够修改工作目录,cd这个命令只能由shell来完成,不能创建子进程去执行,这种只能由shell来执行的命令也就是内建命令
所以我们需要修改一下我们的bash,让其在执行命令之前检查一下这个命令是否是内建命令
程序如下:
如果此时这里的这个命令是内建命令那么我就会去执行cd()函数,那么我们如何做到将mybash的工作目录做出修改呢?
很简单,在c中存在一个函数接口就是chdir。这个函数就能够帮助我们切换到指定的工作目录。
由此我们就能够完成第一个内建命令。
下面我们再来理解什么是内建命令呢?
使用易于理解的话表示就是所谓的内建命令也就是shell内部的一个函数。由此内建命令没有创建子进程。
下面是运行结果:
现在能够完成在父进程中修改工作目录了,但是现在还存在一个问题,那就是我们的命令行提示符中的路径依旧没有改变。
那么我们在完成一次cd命令之后,肯定要更新一下环境变量PWD。
然后在mybash运行获取pwd的时候就会获得新的文件路径了。
那么这里我们既然要修改环境变量中的pwd,首先就要获取到新的环境变量值。
可以使用sprintf()这个函数:
最后是运行截图:
可以看到我们自己的shell中的命令行提示符确实是改变了。
但是这里是存在几个问题的。
首先第一个问题:在我们将cwd这个环境变量导入到mybash中时,当cd这个函数执行完毕之后,cwd这个空间就会被回收,但是这个空间中保存着我需要的环境变量啊,所以这里第一个需要改变:要将cwd这个数组变成一个全局的数组,要让其一直存在,只要myshell这个进程不结束,那么这个cwd就不应该消失
下面一个问题就是当我们输入的是一个绝对路径的时候,我们的命令行提示符确实是将绝对的路径打印出来了,但是如果我们输入的是相对1路径("."和“..")呢?
从上图可以看到因为用户输入的path只是一个"."也就导致了环境变量导出错误了,所以这里需要再使用一个c函数接口:
在C语言中,可以使用getcwd函数来获取当前工作目录(即pwd)。getcwd函数的原型如下:
char *getcwd(char *buf, size_t size);
该函数将当前工作目录的绝对路径存储在buf指向的缓冲区中,并返回指向该缓冲区的指针。size参数指定了缓冲区的大小。
下面是代码的修改:
这里的cwd就是我在全局设定的一个字符数组大小依旧是1024字节
最后是运行的截图:
可以看到此时我们的这个shell就是一个完善的shell了,命令行提示符中的这个文件路径也会一直在修改。
export命令
下面我们在来实现一个内建命令export,这个命令很明显是需要mybash这个父进程自己进行环境变量的导入。在c中是存在一个接口函数能够导出环境变量(putenv)
如果这么去导出环境变量是会存在问题的。
我现在导入了一个myval1,然后下面我们再来执行一下其它的命令,再来查看以下环境变量。
这里我执行了一个错误的ls命令,然后再去查看env就发现我们新导入的那个环境变量不见了。
原因是,如果我们使用的是下面的这个方法:
此时的argv中的指针指向的是下面的这个数组
但是每一次循环输入命令都会重现给usertcommand赋值(对这个数组(usercommand)进行划分之后的各个字符串就被储存在字符串数组(argv)中了)重新赋值之后,我们储存myval的那个空间就被新的值覆盖了,所以再次查看环境变量的时候我们就看不到这个环境变量了。
所以我们需要再mybash中设定一个二维数组用于储存新的环境变量。然后在拷贝了argv[1]中的内容之后再将这个环境变量导出。
创建新增环境变量的空间。
下面是运行的截图:
在执行完新的命令后再去看这个环境变量:
myval依旧存在。
echo $?/(环境变量命令)
下面我们再来实现一个echo $PATH/?的命令
首先这个命令(echo $?)输出的是 上一次进程的退出码,所以这里我们需要完善一下这个myshell.c。
所以我们在这里先创建一个全局变量用于记录 最后一次进程的退出码
对于内建命令因为一直都是执行成功所以这里就不修改退出码让这个退出码一直都是0,但是对于非内建命令,我们需要获取到子进程的退出码:
所以这里我们需要这么写。
然后再来修改echo的内建命令:
这里需要注意的是argv是一个指针数组,而我们要获得的?/PATH是在(#?这个字符串的后面),所以我们首先让一个指针指向$,然后让其+1,然后判断这个字符是否是?.
运行截图:
这里我首先就导致了ls常出现错误,然后打印上一次的退出码,139,然后如果我想要打印环境变量自然也是可以打印的。
完整的代码
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<string.h>
#include<stdlib.h>// getenv需要的头文件
#define NUM 1024 // 这里就是假设最长的命令大小
#define SIZE 64 // 假设最长的一条命令能够被划分成为下面的一部分
#define SEP " "// 这里依旧是设定一个分割的符号
char cwd[1024];// 创建一个全局的字符数组用于储存文件路径
char env[100][100];int n = 0;// 这里使用一个二维数组去储存环境变量
//这里能够储存100个新增的环境变量,每一个环境变量的大小最多也就是100个字节,然后n代表的就是新增的环境变量的个数
int lastcode; // 这个全局变量里面保存的就是最后一次进程的退出码
#define debug 1// 用于激活分割字符串中的打印代码
const char* getUsername()
{const char* name = getenv("USER");// 获取用户名if(name){return name;}return "none";// 如果用户名字获取失败则返回一个none
}
const char* getHostname()
{const char* hostname = getenv("HOSTNAME");if(hostname){return hostname;// 主机名如果获取成功则直接返回}return "none";// 主机名获取石板返回none
}
const char* getCwd()
{const char* cwd = getenv("PWD");if(cwd){return cwd;// 路径获取成功}return "none";// 路径获取失败
}
int getUsercommand(char* command,int num)
{printf("[%s@%s %s]# ",getUsername(),getHostname(),getCwd());char* ret = fgets(command,num,stdin);// 这就就是从标准输入流中读取数据,然后放到usercommand数组中// 下面检测一下输入的命令究竟存进去没有if(ret == NULL){// 这里就代表着从标准输入流处获得的命令失败了return -1; // 如果获取信息失败了返回一个-1}// 运行到这里代表数组中肯定是存在内容了command[strlen(command)-1] = '\0'; // 首先使用strlen获取usercommand中有效字符的个数,然后字符的个数减去1就是\n所在的位置// 因为strlen在计算长度的时候\n也会被计入到计算中return strlen(command);
}
void commandSpilt(char* argv[],char* command)// 这个函数的功能就是将usercommadn中的内容进行分割并放到argv中
{int argc = 0;argv[argc++] = strtok(command,SEP);while(argv[argc++] = strtok(NULL,SEP));
#ifdef debug // 这里就是一个条件编译,如果#define debug设置的值是非0就会执行下面的代码for(int i = 0;argv[i];i++){printf("%d:%s\n",i,argv[i]);}
#endif
}
int excute(char* argv[])// 创建子进程失败返回-1,正常执行返回0
{pid_t id = fork();if(id < 0)// 如果创建子进程失败了那么就让其直接退出{return -1;}// 因为在我们现在执行的情况下不可能出现子进程创建失败的情况,所以这里我们就不考虑创建子进程失败的退出码了else if(id == 0){// childexecvp(argv[0],argv);// 要执行的命令储存在argv数组中的第一个位置,而选项自然就在后面exit(1);//子进程在完成替换之后直接退出即可}else{// fatherint status;pid_t rid = waitpid(id,&status,0);// 使用阻塞等待的方式等待子进程去执行命令if(rid>0){//等待成功之后,去获取子进程的退出码lastcode = WEXITSTATUS(status);// 这里通过一个宏来得出,上一个子进程的退出码}return 0;}
}
void cd(char* path)
{// 在完成了切换路径之后我们需要修改一下main函数中的环境变量chdir(path);char tmp[1024];// 创建一个临时的数据空间getcwd(tmp,sizeof(cwd));// 这里将当前的绝对路径写入到tmp中sprintf(cwd,"PWD=%s",tmp); // 再将当前的绝对路径写到cwd中putenv(cwd);// 这里就是将PWD中的内容导出到环境变量中去,因为在shell的原来的环境变量中本来就包含存在一个PWD所以这里就会将PWD中的值覆盖了
}
int doBuildin(char* argv[]) // 这个函数的功能就是检查当前的命令是否是内建命令,如果是内建命令,那就执行不是那就直接退出即可
{if(strcmp(argv[0],"cd") == 0){//此时代表此时要执行的是cd命令,而cd则是内建命令char* path = NULL;if(argv[1] == NULL){path = ".";cd(path); // 这里去执行cd命令}else{path = argv[1];cd(path);// 不然就转换工作目录到argv[1]中}return 1;}else if(strcmp(argv[0],"export")==0)// 这里就是需要执行将一个环境变量导入到mybash中的环境变量表中{// 但是不要忘了,我们现在将一个环境变量导出去之后,需要使用一个全局的空间去储存这个新增的环境变量strcpy(env[n],argv[1]);// 拷贝当前要新增的环境变量于env的一个空间中putenv(env[n++]);// 再将这个环境变量导出之后,让n++}else if(strcmp(argv[0],"echo")==0)// 这里我们再完成一个内建命令echo{// 下面我们需要获取的就是echo 后面的?,这里$和?是在一个字符串中的char* str = argv[1];// 让str指向$,if(strcmp(str+1,"?")==0)// 让char*的指针+1,跳过一个字符之后,自然就让其指向了?这个字符或者是非问号的path{printf("%d\n",lastcode);// 打印上一个进程的退出码lastcode = 0;// 此时的echo也是一个命令,退出码自然要修改}else{// 这里代表要打印环境变量的值printf("%s\n",getenv(str+1));}}else if(0)// 这里是其它的内建命令{}return 0;// 不是内建命令返回0
}
// 上面的三个函数能够帮助我们获取用户名主机名和当前的路径
int main()
{char usercommand[NUM];// 创建一个命令储存数组// 打印命令行提示符&&获取用户输入的命令成功while(1){int k = getUsercommand(usercommand,sizeof(usercommand)); // 在这里调用一下获取用户输入的函数,然后将我们提供的缓冲区(命令储存数组)传递过去if(k<=0){continue; // 当我们什么都不输入或者是读取信息错误的时候就不再往下执行}// 下面我们需要将从用户处获得到的命令分割成符合要求的子串char* argv[SIZE];// 储存分割后的字符串的空间commandSpilt(argv,usercommand); // 完成字符串的分割int l = doBuildin(argv);if(l){continue;// 然后就是内建命令的返回值设定}// 下面就是需要父进程去创建子进程执行命令excute(argv);// 首先对于需要创建子进程执行的命令这里统一退出码为0}return 0;
}
这个文件我还没有完全的完成,以后完善了之后,会继续在这篇博客上更新。
希望这篇博客能对你有所帮助,写得不好,请见谅。如果发现了任何的错误欢迎指出。