Shell是Linux操作系统的核心交互界面,作为用户与系统内核之间的桥梁,它接收用户输入的命令、解释并执行这些命令。无论是系统管理员还是开发人员,每天都会使用Shell来完成各种任务。但你是否曾好奇过Shell是如何工作的?它是如何解析命令、创建进程并管理执行环境的?
本文将带您深入探索Shell的内部机制,通过实际编写一个简单的Shell外壳,您将了解:
通过亲手实现一个支持基本命令(如ls
、cd
、pwd
、export
等)的Shell,您将获得对操作系统底层机制(如进程控制、环境变量传递)的深刻理解,这些知识对于系统编程、性能优化和高级脚本编写都至关重要。
无论您是Linux新手还是经验丰富的开发者,这个项目都将帮助您:
fork()
、exec()
、wait()
)的实际应用接下来,让我们从零开始,一步步构建一个功能完整的Shell外壳!
⽤下图的时间轴来表⽰事件的发⽣次序。其中时间从左向右。shell由标识为sh的⽅块代表,它随着时间的流逝从左向右移动。shell从⽤⼾读⼊字符串"ls"。shell建⽴⼀个新的进程,然后在那个进程中运⾏ls程序并等待那个进程结束
然后shell读取新的⼀⾏输⼊,建⽴⼀个新的进程,在这个进程中运⾏程序 并等待这个进程结束。 所以要写⼀个shell,需要循环以下过程:
其中有一些细节需要注意,但在前面先不着急。了解了命令行的基本操作之后我们把shell的功能拆分为几个不同的板块:
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
#define SIZE 128
int argc;
char *argv[SIZE];
仿照main函数的命令行参数,我们定义了全局变量argc
和argv
来储存用户输入命令的数量和内容。
要实现命令行提示符的打印,我们观察其结构可知,提示部分主要由:用户名、主机名、当前路径组成;在环境变量中分别对应:USER、HOSTNAME、PWD。因此我们可以从程序执行之后的环境变量中获取。
代码部分:
string GetUsername()
{
return getenv("USER");
}
string GetHostname()
{
return getenv("HOSTNAME");
}
string GetPWD()
{
return getenv("PWD");
}
string MakeComLin()
{
char line[SIZE] = {0};
snprintf(line, SIZE, "[%s@%s %s]%% ", GetUsername().c_str(), GetHostname().c_str(), GetPWD().c_str());
return line;
}
void PrintComLin()
{
printf("%s", MakeComLin().c_str());
fflush(stdout);
}
将获取环境变量的函数重新进行封装,使用string
作为返回值
输入命令时会带有不同的选项,选项的长度以及数量都是不确定的,而且选项之间会有空格间隔,这样就导致不能使用scanf
以及cin
等输入方式,这些输入方式会把空壳视为一次输入结束的标志。因此我们这次使用fgets
这个函数指定输入流为stdin
bool GetComLin(char arr[], int size)
{
char *result = fgets(arr, size, stdin);
if(!result)
return false;
arr[strlen(arr) - 1] = '\0';
if(strlen(arr) == 0) return false;
return true;
}
bool
类型的返回值,用于判断获取用户输入是否成功;arr
中,由于用户每一次输入命令都是通过回车输入,所以每一次获取到的字符串后面都会有一个\n
,不利于我们后面处理指令,因此在这里将strlen(arr)-1
这个位置的字符替换成\0
来去掉\n
false
处理用户输入的字符串,将这一整个字符串打散为命令和选项,存入前面定义的全局变量argc
和argv
中
bool ProcessComLin(char arr[])
{
const char *s = " ";
memset(argv, 0, sizeof(argv));
argc = 0;
argv[argc] = strtok(arr, s);
while((argv[++argc] = strtok(nullptr, s)) != 0);
return true;
}
bool
类型的返回值,用于判断是否成功;memset
这次输入之前的指令,并把argc
置零strtok
来拆分字符串,第一次拆分需要输入被拆分的字符串后面如果继续拆分则输入nullptr
在这里我们创建子进程用于运行用户输入的命令,父进程等待子进程运行结束后继续运行,子进程使用进程程序替换,替换为对应命令的程序。
bool ExcuteComLin()
{
pid_t id = fork();
if(id < 0) return false;
else if(id == 0)
{
execvp(argv[0], argv);
exit(1);
}
pid_t pid = waitpid(id, nullptr, 0);
if(pid < 0)
return false;
return true;
}
bool
类型的返回值,用于判断是否成功;到这一步这个简单的shell外壳大体的框架已经搭好了,一些最基本的指令:ls pwd env
可以运行了。但是还有很多地方需要改进。
如果我们按照之前的想法,当用户输入命令之后去创建一个新的子进程执行输入的命令在一般情况下是没有问题的,但是如果执行与路径切换有关的命令时就并不正确了。在之前的学习中我们知道了子进程的环境变量继承自父进程的环境变量但是如果我们在子进程中调用cd等路径切换的命令,他只会影响子进程的当前路径,当子进程运行结束退出,对父进程没有影响,导致执行完命令之后父进程的工作路径并没有发生改变。所以路径切换应在父进程下改变。shell⾃⼰执⾏命令,本质是shell调⽤⾃⼰的函数,这叫做内建命令
代码更改:
// shell⾃⼰执⾏命令,本质是shell调⽤⾃⼰的函数
bool CheckAndExecBuiltCommand()
{
if(strcmp(gargv[0], "cd") == 0)
{
// 内建命令
if(gargc == 2)
{
chdir(gargv[1]);
}
return true;
}
return false;
}
虽然经过上面的修改可以执行cd命令了,使用pwd
指令也可以看到路径的切换。但是会发现进程的环境变量并没有随着进程的路径改变而改变。这是因为环境变量是需要shell程序维护的,并不是自动更新的,环境变量表需要shell自己更新。之前我们使用Linux的shell帮我们更新了环境变量,所以我们不需要手动修改。
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% ls
makefile myshell myshell.cpp
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% pwd
/home/lisihan/lession16/myshell
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% cd ..
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% pwd
/home/lisihan/lession16 #用pwd查发现路径已经改变了
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% env
XDG_SESSION_ID=881
HOSTNAME=hcss-ecs-b735
TERM=xterm
SHELL=/bin/bash
HISTSIZE=10000
SSH_CLIENT=218.77.77.250 22487 22
SSH_TTY=/dev/pts/1
USER=lisihan
#..........#
PATH=/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/lisihan/.local/bin:/home/lisihan/bin
PWD=/home/lisihan/lession16/myshell #环境变量中的PWD没有改变
#..........#
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% ^C
[lisihan@hcss-ecs-b735 myshell]$
所以现在我们不建议从环境变量中获取当前路径(用户名和主机名当前情况不会修改暂时可以用),因为shell是维护环境变量的,所以我们需要从系统重获取(系统调用),然后再把环境变量进行更新。
修改后的代码:
char pwd[SIZE]; //新增全局变量
char pwdenv[SIZE];
string GetPWD()
{
getcwd(pwd, sizeof(pwd));
sprintf(pwdenv, "PWD=%s", pwd);
putenv(pwdenv);
return pwd;
}
修改后执行
[lisihan@hcss-ecs-b735 myshell]$ ls
makefile myshell myshell.cpp
[lisihan@hcss-ecs-b735 myshell]$ make
g++ -o myshell myshell.cpp -std=c++11
[lisihan@hcss-ecs-b735 myshell]$ ./myshell
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% ls
makefile myshell myshell.cpp
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% pwd
/home/lisihan/lession16/myshell #用pwd查发现路径已经改变了
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% cd ..
[lisihan@hcss-ecs-b735 /home/lisihan/lession16]% pwd
/home/lisihan/lession16
[lisihan@hcss-ecs-b735 /home/lisihan/lession16]% env
XDG_SESSION_ID=881
HOSTNAME=hcss-ecs-b735
TERM=xterm
SHELL=/bin/bash
HISTSIZE=10000
SSH_CLIENT=218.77.77.250 22487 22
SSH_TTY=/dev/pts/1
USER=lisihan
#..........#
PATH=/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/lisihan/.local/bin:/home/lisihan/bin
PWD=/home/lisihan/lession16 #环境变量中的PWD也改变了
#..........#
[lisihan@hcss-ecs-b735 /home/lisihan/lession16]% ^C
[lisihan@hcss-ecs-b735 myshell]$
注意:
putenv
修改环境变量的时候并不会修改系统的环境变量表,而是会进行写时拷贝,重新生成一张表。这也解释了为什么子进程环境变量的改变为什么不会影响父进程目前我们不考虑直接读取环境变量的配置文件,直接用从父进程导入环境变量的方法为我们的shell创建一个环境变量表,注意==环境变量表的最后一个元素是nullptr
==在最后要手动加上
char *genv[SIZE]; //新增全局变量
void EnvInit()
{
extern char **environ;
int index = 0;
while(environ[index])
{
genv[index] = (char*)malloc(strlen(environ[index]+1));
strncpy(genv[index], environ[index], strlen(environ[index]));
index++;
}
genv[index] = nullptr;
}
export和env命令都需要改变shell自身的环境变量因此属于内建命令:
void AddEnv(const char *item)
{
int index = 0;
while(genv[index])
{
index++;
}
genv[index] = (char*)malloc(strlen(item)+1);
strncpy(genv[index], item, strlen(item)+1);
genv[++index] = nullptr;
}
bool CheckAndExecBuiltCommand()
{
if(strcmp(argv[0], "cd") == 0)
{
// 内建命令
if(argc == 2)
{
chdir(argv[1]);
}
return true;
}
else if(strcmp(argv[0], "export") == 0)
{
if(argc == 2)
{
AddEnv(argv[1]);
}
return true;
}
else if(strcmp(argv[0], "env") == 0)
{
for(int i = 0; genv[i]; i++)
{
printf("%s\n", genv[i]);
}
return true;
}
return false;
}
这样使用这两个命令就可以直接使用我们自己shell的环境变量了。
但是我们创建这样的环境变量目的是使用它,因此shell创建出来的子进程也要继承我们shell自己的环境变量,因此我们需要对前面创建子进程的代码进行一下修改:只需要把之前的execvp
函数改成使用execvpe
函数就可以给子进程导入我们自己的环境变量了
bool ExcuteComLin()
{
if(!CheckAndExecBuiltCommand())
{
pid_t id = fork();
if(id < 0) return false;
else if(id == 0)
{
execvpe(argv[0], argv, genv);
exit(1);
}
pid_t pid = waitpid(id, nullptr, 0);
if(pid < 0)
return false;
}
return true;
}
echo命令:
这个命令在之前的笔记中已经讲过了,echo
命令在 Linux 中用于在终端显示一行文本。它可以用来输出字符串、变量的值等。如果只是从定义中理解可能很难理解这个命令为什么会是一个内建命令。但是我们曾经学过,当我们使用系统的shell时我们可以直接在命令行中创建变量,但我们知道这个变量不在环境变量表中而是在shell的一个本地变量表中,因此当我们使用echo命令查询本地变量的时候是不能创建子进程的,因为本地变量表不会被子进程继承,因此ceho
命令是一个内建命令。
[lisihan@hcss-ecs-b735 myshell]$ a=100
[lisihan@hcss-ecs-b735 myshell]$ echo $a
100
如何在我们自己的shell中实现echo命令呢?由于需要实现进程的退出码的打印所以就直接放出整个代码了。
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
#define SIZE 128
int argc;
char *argv[SIZE];
char pwd[SIZE];
char pwdenv[SIZE];
char *genv[SIZE];
int lastcode = 0;
string GetUsername()
{
return getenv("USER");
}
string GetHostname()
{
return getenv("HOSTNAME");
}
string GetPWD()
{
getcwd(pwd, sizeof(pwd));
sprintf(pwdenv, "PWD=%s", pwd);
putenv(pwdenv);
return pwd;
}
tring MakeComLin()
{
char line[SIZE] = {0};
string tmp = GetPWD();
if(tmp != "/")
{
size_t pos = tmp.rfind("/");
tmp = tmp.substr(pos + 1);
}
snprintf(line, SIZE, "[%s@%s %s]%% ", GetUsername().c_str(), GetHostname().c_str(), tmp.c_str());
return line;
}
void PrintComLin()
{
printf("%s", MakeComLin().c_str());
fflush(stdout);
}
bool GetComLin(char arr[], int size)
{
char *result = fgets(arr, size, stdin);
if(!result)
return false;
arr[strlen(arr) - 1] = '\0';
if(strlen(arr) == 0) return false;
return true;
}
bool ProcessComLin(char arr[])
{
const char *s = " ";
memset(argv, 0, sizeof(argv));
argc = 1;
argv[argc] = strtok(arr, s);
while((argv[++argc] = strtok(nullptr, s)) != 0);
return true;
}
void debug()
{
printf("argc:%d\n", argc);
for(int i = 0; i < argc; i++)
{
printf("argv[%d]:%s\n", i, argv[i]);
}
}
void AddEnv(const char *item)
{
int index = 0;
while(genv[index])
{
index++;
}
genv[index] = (char*)malloc(strlen(item)+1);
strncpy(genv[index], item, strlen(item)+1);
genv[++index] = nullptr;
}
bool CheckAndExecBuiltCommand()
{
if(strcmp(argv[0], "cd") == 0)
{
// 内建命令
if(argc == 2)
{
chdir(argv[1]);
lastcode = 0;
}
else
{
lastcode = 1;
}
return true;
}
else if(strcmp(argv[0], "export") == 0)
{
if(argc == 2)
{
AddEnv(argv[1]);
lastcode = 0;
}
else
{
lastcode = 1;
}
return true;
}
else if(strcmp(argv[0], "env") == 0)
{
for(int i = 0; genv[i]; i++)
{
printf("%s\n", genv[i]);
}
return true;
}
else if(strcmp(argv[0], "echo") == 0)
{
if(argc == 2)
{
if(argv[1][0] == '$')
{
if(argv[1][1] == '?')
{
printf("%d\n", lastcode);
lastcode = 0;
}
else
{
//查询变量暂时不写
}
}
}
else
{
lastcode = 2;
}
return true;
}
return false;
}
bool ExcuteComLin()
{
if(!CheckAndExecBuiltCommand())
{
pid_t id = fork();
if(id < 0) return false;
else if(id == 0)
{
execvpe(argv[0], argv, genv);
exit(1);
}
int status;
pid_t pid = waitpid(id, &status, 0);
if(pid > 0)
{
if(WIFEXITED(status))
{
lastcode = WIFEXITED(status);
}
else
{
lastcode = 2;
}
return true;
}
return false;
}
return true;
}
void EnvInit()
{
extern char **environ;
int index = 0;
while(environ[index])
{
genv[index] = (char*)malloc(strlen(environ[index]+1));
strncpy(genv[index], environ[index], strlen(environ[index]));
index++;
}
genv[index] = nullptr;
}
int main()
{
char comlin_data[SIZE] = {0};
EnvInit();
while(1)
{
//命令行提示符打印
PrintComLin();
//获取用户命令
if(!GetComLin(comlin_data, SIZE))
{
continue;
}
//分析命令
ProcessComLin(comlin_data);
//debug();
//执行命令
ExcuteComLin();
}
return 0;
}
两层概念,第一层在命令行当中,一个命令究竟是怎么执行的?其实对普通命令来讲,它就是解析命令行,通过调用exec系列的函数进行fork创建子进程程序替换。第二点,什么叫做命令行参数表,环境变量表?命令行参数表是从命令行依次获取的,是被shell自己解析自己维护的环境。变量表是从系统配置文件里读取进来,也是由shell自己维护的,维护好之后,这两个表我们就理解成是全局的两个指针数组,通过esecvpe这样的系统调用接口,把环境变量传递给所有的子进程,因为所有的程序都有命令和参数都有环境变量,所以我就可以把我的环境变量交给它,这就是为什么环境变量具有全局性的根本原因。