何为shell?
shell作为命令语言解释器,负责解释我们在Linux命令行中输入的指令,并将解释后的指令传递给Linux内核。简而言之,shell是用户和Linux内核之间交互的桥梁,用户不直接接触Linux内核,而是通过shell程序与内核交互。
shell的本质是一个C语言程序,与我们平常使用的QQ,浏览器一样,区别就是:一旦Linux系统运行起来,shell程序也会运行,而QQ,浏览器这些程序需要我们手动去运行,shell则自动运行
只要用户输入一个指令,就会被shell解释。指令分为两种
1.内建命令,由shell自身执行,如打印当前工作目录(pwd),输出文本消息(echo)
2.其他命令,是一个存储在某个目录下的程序,由shell的子进程执行,如列出目录内容(ls),拷贝文件(cp)
对于用户来说,不用关心该命令是否为内建命令,只要输入命令,就会得到结果。但对于shell来说,需要检查该命令是否为内建命令,如果是,需要自己执行该命令。如果不是,shell会去PATH路径列表下查找是否有该程序,如果有,解释该指令并创建子进程执行该指令,没有的话直接报错。
为了更深刻的理解shell的运行过程,我们模拟实现一个自己的shell程序
shell的模拟实现
提示符打印
shell被启动后,就会在命令行中等待命令的输入,在输入命令的地方前还有一串提示符
[用户名@主机名 当前所在文件夹] $
实现shell的第一步是打印出这串提示符,用户名,主机名和当前路径都能通过getenv获取环境变量函数得到。需要特别处理的是当前目录,getenv得到的是绝对路径,而提示符显示的是当前文件夹,所以得到绝对路径后要遍历这串字符串,记录最后一个’/'出现的位置,返回该位置的后一个位置,得到的就是当前文件夹
const char* get_dir(const char* path)
{
int i = 0;
int index = 0;
while (path[i] != '\0')
{
if (path[i] == '/')
{
index = i;
}
i++;
}
return path + index + 1;
}
// 1.显示提示符
printf("[%s@%s %s^]$ ", getenv("USER"), getenv("HOSTNAME"), get_dir(getenv("PWD")));
fflush(stdout);
// 由于缓冲区的存在,不打印'\n'缓冲区是不会立即刷新的,直到缓冲区满了才会刷新出内容
// 所以这里打印需要手动刷新缓冲区
用户输入消息的获取
比如我要执行ls命令,ls命令后还可以携带选项,ls -a -l,shell需要获取指令与选项,并将它们分割(shell接收到的是一串字符串"ls -a -l",shell需要将字符串分割为更小的字符串"ls" “-a” “-l”)。
定义command_line接收一整行的指令,该数组中的元素保存的是字符,command_args保存分割后的指令,该数组中的元素要保存的是分割后的字符串。
#define NUM 1024
#define SIZE 128
char command_line[NUM];
char* command_args[SIZE];
在存储数据之前先使用memset函数将两个数组置空。
memset(command_line, '\0', sizeof(command_line) * sizeof(char));
memset(command_args, '\0', sizeof(command_args) * sizeof(char));
获取整行数据可以使用fgets
fgets将从stream流中获取至多size个字符的字符串并保存到s中,如果遇到了’\0’或者读到了文件结束,fgets将中断。
所以我们可以用fgets从stdin标准输入流中读取NUM个字符到command_line数组中,这样就获取到了命令行的输入
fgets(command_line, NUM, stdin);
但获取到的字符串的’\0’之前还有一个’\n’(因为我们在命令的最后敲了回车,来表示命令输入的结束,这个’\n’会影响命令的识别,需要将其消除::用strlen得到字符串长度(不包括’\0’),字符串最后一个字符’\n’,的下标为长度-1,将该位置置为’\0’,表示字符串的结束,同时也消除了’\n’
command_line[strlen(command_line) - 1] = '\0'; // 消除'\n'
获取到整行字符串后,使用strtok将整行字符串分割成命令与选项
strtok使用:第一次调用strtok需要传入要被分割的字符串,之后如果要继续分割,就不用继续传该字符串的地址,只要传NULL,第二个参数是分割字符串的标志,对于分割整行命令,其分割的标志为" "。
当没有字符串要分割时,strtok就返回NULL,否则返回分割的第一个字符串,根据这个特点,可以先对command_line进行第一次分割并保存到command_args中,接着使用while循环分割完所有的子串。
// 获取命令并解析
#define SEP " "
#define NUM 1024
#define SIZE 128
char command_line[NUM];
char* command_args[SIZE];
memset(command_line, '\0', sizeof(command_line) * sizeof(char));
memset(command_args, '\0', sizeof(command_args) * sizeof(char));
fgets(command_line, NUM, stdin);
command_line[strlen(command_line) - 1] = '\0'; // 消除'\n'
command_args[0] = strtok(command_line, SEP);
int index = 1;
// 当还有字符串要分割时,strtok返回值不为空
// 分割结束,strtok返回空,while循环的条件为假,循环结束
while (command_args[index++] = strtok(NULL, SEP));
创建子进程执行程序
分析完指令后,shell会调用fork()函数创建一个子进程,让子进程调用exec()程序替换函数,替换子进程,让子进程执行成分析后的指令,同时父进程阻塞等待子进程并打印退出消息。
int id = fork();
if (id == 0)
{
// child
execvp(command_args[0], command_args);
exit(-1); // 如果程序替换失败,子进程会执行该语句,返回-1
}
else
{
int status = 0;
int ret = waitpid(id, &status, 0);
if (ret == id) // 等待成功
{
// 父进程接收子进程的退出码,并打印
printf("等待成功, code:%d, sig:%d\n", (status >> 8) & 0xFF, status & 0x7F);
}
}
运行结果
myshell是编译myshell.c生成的可执行程序,运行shell,为了与Linux自己的shell区分,我写的shell打印出的提示符有个^,上面是打印提示符的效果。
执行ls -a命令的结果
但是目前实现的shell有一个问题:执行pwd命令,打印当前的工作目录,然后cd …回到上一级目录,再执行pwd,通过打印的目录我们发现:没有回到上一级目录,要知道其中的原因就需要了解内建命令。
内建命令
开头说到,shell的命令分为内部命令和其他命令,何为内建命令? 内建命令是一个需要shell自己执行的命令,即shell不创建子进程,自己亲自执行的命令。
为什么要有内建命令? shell创建子进程执行指令,当子进程执行完命令,子进程就退出了。 有这样一个场景:当子进程执行的指令是cd更改工作路径时,子进程执行cd命令,其工作路径确实发生了改变,但是父进程的工作路径没有变化(进程具有独立性,两个进程不会相互影响),接着子进程退出,此时向shell输入pwd命令,打印当前工作目录,shell又创建子进程执行pwd命令,这个子进程与之前退出的子进程没有关系,是重新创建的,由于父子进程代码共享,它们的工作路径相同,所以打印出的工作路径没有变化。体现给用户的感觉就是cd没有被执行,与上面截图中的问题相同。
所以,shell对于一些命令是必须要亲自执行的:比如cd更改工作路径,将shell的工作路径修改后,由于子进程的工作路径与父进程相同,更改分进程的工作路径后,父进程创建出的子进程的工作路径也是被修改过的,体现给用户的感觉就是当前的工作路径改变了。
对于内建命令,shell是怎么实现的? shell中有许多内建命令,当shell接收到指令并解析后,需要判断用户输入的命令是否为内建命令,如果是就执行拦截操作,使子进程不再被创建,自己执行该指令。如果不是内建命令,则创建子进程执行该命令。
接着谈模拟实现:
Linux提供了一个系统接口,可以改变当前工作路径,chdir,将当前进程的工作路径修改为path。
// 对该系统接口封装
void change_dir(const char* new_path)
{
chdir(new_path);
}
// 获取命令并解析
#define SEP " "
#define NUM 1024
#define SIZE 128
char command_line[NUM];
char* command_args[SIZE];
memset(command_line, '\0', sizeof(command_line) * sizeof(char));
memset(command_args, '\0', sizeof(command_args) * sizeof(char));
fgets(command_line, NUM, stdin);
command_line[strlen(command_line) - 1] = '\0'; // 消除'\n'
command_args[0] = strtok(command_line, SEP);
int index = 1;
// 当还有字符串要分割时,strtok返回值不为空
// 分割结束,strtok返回空,while循环的条件为假,循环结束
while (command_args[index++] = strtok(NULL, SEP));
// 解析完命令后,判断是否有内建命令需要拦截
if (strcmp(command_args[0], "cd") == 0 && command_args[1] != NULL) // 要确保第二个参数不为空
{
change_dir(command_args[1]); // 第二个参数是要进入的工作路径
continue; // shell被死循环执行,continue跳过后面创建子进程的代码
}
shell对于内建命令的拦截,是在解析指令后,判断命令是否为内建,如果是,跳转执行对应的函数,并跳过之后创建子进程的过程。
添加内建命令的拦截后再执行cd指令。
环境变量与内建命令
我们知道,环境变量具有全局属性,具体表现是:一般情况下,子进程的环境变量继承父进程的环境变量,父进程的环境变量改变后,创建的子进程的环境变量也会跟着改变,但不会影响父进程的父进程,也就是说:全局属性体现为向下影响
环境变量的创建或修改需要使用export指令,格式为:export xxx=value,比如创建一个环境变量myval,它的值为123:export myval=123。基于对内建命令的理解,如果让shell创建子进程执行export创建myval这个环境变量,那么shell的子进程就具有了myval,当子进程执行完export指令,子进程退出,由于子进程的环境变量不会向上影响,所以shell的环境变量没有改变,环境变量改变的是已经退出的子进程,体现给用户的感觉是:环境变量没有被创建。我们创建环境变量的目的是希望在shell中创建环境变量,使得shell以下的子进程都具有该变量,所以export也是一个内建命令,需要特别的处理。
接着来说模拟实现:
Linux提供了修改环境变量的接口,将要修改或创建的环境变量以xxx=value的字符串方式传入该接口,xxx环境变量就会被创建或者修改。
所以在shell解析完指令后,判断指令是否为export,如果是则进行拦截,调用该接口创建环境变量。
(系统中有一个环境变量列表envrion,类型为char**,存储了当前进程的环境变量字符串char*,当你使用putenv添加环境变量时,实质上是向envrion的列表中添加了一个字符串的地址
需要注意的是添加环境变量时,环境变量列表不会environ 不会拷贝字符串,所以原字符串的修改会影响添加到环境变量列表中的环境变量)
if (strcmp(command_args[0], "export") == 0 && command_args[1] != NULL)
{
// command_args[1]是xxx=value形式的字符串
add_env(command_args[1]); // 是否有问题?
continue;
}
由于模拟实现的shell使用command_line获取整行的输入,接着将每个命令与选项的首地址放到数组command_args中,myshell每次读取命令时,都会对command_line进行清空,也就意味着command_args[1]所指向的字符串也会被清空,在myshell添加完环境变量后,command_args[1]被添加到environ中,再次接收其他指令,command_args[1]被清空,环境变量也就不存在,所以向environ传递的字符串必须要是一个不会被随意更改的字符串。
#define NUM 1024
char env_buffer[NUM];
// 更规范的做法是动态申请堆上的空间,这里的全局变量只是用来测试,只能添加一个环境变量
void add_env(char* new_env)
{
putenv(new_env);
}
if (strcmp(command_args[0], "export") == 0 && command_args[1] != NULL)
{
// 先拷贝字符串到env_buffer中,再将env_buffer添加到环境变量列表中
strcpy(env_buffer, command_args[1]);
add_env(env_buffer);
continue;
}
添加完export内建命令的运行结果。我在当前目录下写了个C++程序mycmd,运行mycmd将打印环境变量"lll"所对应的值,然后运行myshell程序,运行mycmd,没有打印信息,说明进程中没有lll环境变量,然后使用export命令创建环境变量lll并赋值123,最后运行mycmd程序,打印出lll变量的值——123,说明环境变量创建成功,export内建命令完成工作。
提示符最后的优化
模拟实现内建命令cd后,可以使用cd进入一个工作目录,但是命令行的提示符却没有发生改变
有了对环境变量的理解,模拟的shell可以完成最后的一次优化:使提示符打印出的当前工作目录与cd命令同步。
提示符打印的当前工作目录是根据环境变量PWD获取的,而我们模拟实现的cd命令只是将当前工作路径修改(chdir函数不会改变PWD环境变量),所以模拟实现的cd命令还应该改变PWD环境变量,使打印的提示也随着cd改变。
还需要根据chdir的返回值判断要进入的工作目录是否存在,当工作目录不存在时,不能修改PWD环境变量,应该要提示用户输入的路径错误。只有输入的工作目录存在,shell才会跳转到该目录下,并且修改PWD环境变量。
char path_buf[NUM];
void change_dir(const char* new_path)
{
strcpy(path_buf, "PWD=" );
if (chdir(new_path) == -1)
{
printf("cd: %s: No such file or directory\n", new_path);
}
else // 当更改目录成功,修改环境变量中的PWD变量
{
// 进入到新的工作路径下,获取当前工作路径,并连接到path_buf后,作为PWD的value值
strcat(path_buf, get_current_dir_name());
putenv(path_buf);
}
}
优化后的shell运行结果
模拟实现的shell完整代码
当然,到目前为止,myshell与真正的shell之间还是有差距的,还有优化的空间,比如ls对应当前目录下的文件,myshell的打印没有颜色,shell有颜色,这需要在ls后加上–color=auto
实际上我们经常使用的ls,是ls --color=auto的别名(alias是一个取别名操作),在模拟实现的shell中,解析完命令后判断命令是否有ls,如果有,再在解析的参数后加上–color=auto。
但是这些不是模拟实现shell的最终目的,模拟实现shell的最终目的是
理解shell的工作原理,shell的本质:一个C语言程序,shell对于两种命令的处理方式以及shell是怎么创建子进程执行指令的(fork+exec)。
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#define NUM 1024
#define SIZE 128
#define SEP " "
char command_line[NUM];
char* command_args[SIZE];
char env_buffer[NUM];
char path_buf[NUM];
void change_dir(const char* new_path)
{
strcpy(path_buf, "PWD=" );
if (chdir(new_path) == -1)
{
printf("cd: %s: No such file or directory\n", new_path);
}
else // 当更改目录成功,修改环境变量中的PWD变量
{
strcat(path_buf, get_current_dir_name());
putenv(path_buf);
}
}
void add_env(char* new_env)
{
putenv(new_env);
}
const char* get_dir(const char* path)
{
int i = 0;
int index = 0;
while (path[i] != '\0')
{
if (path[i] == '/')
{
index = i;
}
i++;
}
return path + index + 1;
}
int main()
{
while (1)
{
// 1.显示提示符
printf("[%s@%s %s^]$ ", getenv("USER"), getenv("HOSTNAME"), get_dir(getenv("PWD")));
fflush(stdout);
// 2.获取用户输入
memset(command_line, '\0', sizeof(command_line) * sizeof(char));
memset(command_args, '\0', sizeof(command_args) * sizeof(char));
fgets(command_line, sizeof(command_line) * sizeof(char), stdin);
if (strlen(command_line) > 1)
{
command_line[strlen(command_line) - 1] = '\0'; // 消除'\n'
command_args[0] = strtok(command_line, SEP);
int index = 1;
if (strcmp(command_args[0], "ls") == 0)
command_args[index++] = "--color=auto";
while (command_args[index++] = strtok(NULL, SEP));
if (strcmp(command_args[0], "cd") == 0 && command_args[1] != NULL)
{
change_dir(command_args[1]);
continue;
}
if (strcmp(command_args[0], "export") == 0 && command_args[1] != NULL)
{
strcpy(env_buffer, command_args[1]);
add_env(env_buffer);
continue;
}
// 创建子进程执行其他命令
int id = fork();
if (id == 0)
{
// child
execvp(command_args[0], command_args);
exit(-1);
}
else
{
int status = 0;
int ret = waitpid(id, &status, 0);
if (ret == id) // 等待成功
{
printf("等待成功, code:%d, sig:%d\n", (status >> 8) & 0xFF, status & 0x7F);
}
}
} // end of -- if (strlen(command_line) > 1)
} // end of -- while(1)
return 0;
}