我们在前面已经了解了进程的概念,以及如何进行进程控制。接下来我们就使用这些知识,来自己实现一个shell即命令行解释器!!!
一.打印命令行提示符
我们在使用Linux操作系统时,一登陆就会启动bash进程——命令行解释器,而bash则会先打印一串字符串,用来提醒用户当前的一些信息。这里面包括了用户名,主机名,当前的工作目录等等
所以我们的首要任务就是打印一个命令行,内容就是上面这些。而这些信息都存储在bash的环境变量中
我们可以将打印命令行提示符封装成一个函数:首先就是命令行的格式,我们可以用宏来表示。接着就是使用getenv来获取指定环境变量的内容,然后按照指定格式打印即可
#define FORMAT "[%s@%s %s]# " // 命令行提示符格式
const size_t COMMAND_SIZE = 1024; // 命令行提示符的最大长度
// 获取用户名
char* getUserName()
{
return getenv("USER");
}
// 获取主机名
char* getHostName()
{
return getenv("HOSTNAME");
}
// 获取当前工作目录
char* getPwd()
{
return getenv("PWD");
}
// 制作命令行提示符
void makeCommandLine(char* prompt, size_t size)
{
snprintf(prompt, size, FORMAT, getUserName(), getHostName(),getPwd());
}
// 打印命令行提示符
void printCommandLinePrompt()
{
char prompt[COMMAND_SIZE];
makeCommandLine(prompt, sizeof(prompt));
printf("%s",prompt);
fflush(stdout);
}
我们先来看一下结果:
虽然我们打印出了命令行提示符,但是却又一些问题:
- shell中的命令行提示符只打印当前工作目录,而我们的shell却打印出了当前工作目录的绝对路径
- shell进程应该是常驻内存的,启动我们自己的shell之后,应该是看不到系统shell的,接下来就是等待用户输入了!!
对于第一点,我们可以再封装一个函数,只取出当前目录!!!
我们在制作命令行提示符时,就调用下面这个函数
// 只取出工作目录
std::string getPwdName()
{
std::string pwd_name = getPwd();
size_t pos = pwd_name.rfind('/');
return pwd_name.substr(pos+1, std::string::npos);
}
启动myshell之后,等待用户输入其实就是让shell阻塞在那里了,这不就是scanf/cin么?所以我们可以先下一个scanf来看看现象:
我们看,成功解决了上面的问题。接下来我们要处理的就是命令行输入的问题了。
二.获取用户输入的命令
// 获取用户输入
bool getUserCommand(char* cmd, int size)
{
const char* ans = fgets(cmd, size, stdin);
if(ans == NULL) return false;
cmd[strlen(cmd) - 1] = '\0';
return true;
}
int main()
{
printCommandLinePrompt();
char command[COMMANDSIZE];
getUserCommand(command, sizeof(command));
return 0;
}
我们虽然借助上面可以获取用户输入的字符,先不管如何解析和执行该命令,输入完命令之后,假设执行完了,打印的应该还是我们自己的shell,但结果却不是
因为shell是常驻内存的程序,所以本质上应该是个死循环!!! 所以我们shell的逻辑都应该在该while循环中执行!!!
int main()
{
while(1)
{
printCommandLinePrompt();
char command[COMMANDSIZE];
getUserCommand(command, sizeof(command));
}
return 0;
}
另外,我们借助fgets获取字符,它以回车作为分隔符。但是如果我们只敲了回车,command里面是个空串,没必要进行后续的命令分析...所以我们在这里进行判断,如果返回false,则contine,重新输入命令,如果为true,才执行下面的逻辑
char command[COMMANDSIZE];
if(!getUserCommand(command, sizeof(command)))
continue;
三.切割命令,做命令分析
我们输入的命令中间可能会含有空格比如 “ ls -l -al”,我们需要对命令进行切割,以空格为分隔符,将命令拆分成多部份,放入到命令行参数表中!!!
以空格作为分割符来切割命令,我们可以使用strtok函数来实现。
// 命令行参数表
const size_t MAXARGC = 128;
char* argv[MAXARGC];
int argc = 0;
// 切割命令,对命令进行分析
void commandParse(char* cmd)
{
argc = 0;
argv[argc++] = strtok(cmd, " ");
while(NULL != (argv[argc++] = strtok(NULL, " "))) ;
argc--;
}
当使用strtok想继续对一个字符串继续切割时,第一个参数要传NULL。
这样写不仅切割了命令,而且还给命令行参数表最后放了一个NULL作为结尾。完美契合我们的目标!!!
我们先运行一下当前程序,并打印参数表,看看是否正确。
四.执行命令
我们拿到了命令行参数之后,接下来就是去执行命令。
我们在进程控制部分了解到了进程切换,而且我们也知道所有的进程都是bash的子进程。
所以我们执行命令的逻辑就是让shell创建子进程,让子进程去执行我们分析出来的命令。
// 执行命令
void exectue()
{
pid_t id = fork();
if(id == 0)
{
// child
execvp(argv[0], argv);
exit(1);
}
// father
int status = 0;
pid_t rid = waitpid(id, &status, 0); // 阻塞等待
if(rid < 0)
{
exit(2);
}
}
我们这里之所以选择execvp,是因为我们在处理的时候就已经拿到了命令行参数表,用这个最方便。
到现在,我们的minshell整体逻辑已经完成的差不多了,现在看一下运行的情况:
但是目前的minshell是有问题的,当我们执行cd命令时,我们当前的工作目录是不会改变的
这是因为cd作为内建命令,是由bash进程自己亲自执行的,而我们的cd是用子进程执行的,所以子进程的工作目录改变了,但shell并没有。
因此,我们在执行命令之前需要都命令进行分类——内建命令和其他命令。其他命令就去让子进程执行,而内建命令我们需要让shell自己执行!!!
五.执行内建命令
内建命令有好几个,这里我们以cd命令为例来做演示。
我们在分割命令之后,接着就是对第一个命令行参数的第一个参数进行分析,如果是cd等内建命令则用shell自己执行,反之让子进程执行。
如果一个命令是内建命令,他被shell执行之后,就不应该在被子进程执行了,所以直接continue。
// 执行内建命令
bool checkAndExectueBulidIn()
{
std::string cmd = argv[0];
if(cmd == "cd")
{
CD();
return true;
}
else if(cmd == "echo")
{
return true;
}
return false;
}
int main()
if(checkAndExectueBulidIn())
continue;
接下里就是执行cd命令的逻辑了:
我们可以使用chdir系统调用来改变当前的工作目录。因为对于cd命令有很多快捷方式,这里我们只实现一些。
// cd命令
void CD()
{
if(argc == 1)
{
chdir(getenv("HOME"));
}
else
{
std::string where = argv[1];
if(where == "~")
{
chdir(getenv("HOME"));
}
else if(where == "-")
{
// todo
}
else
{
chdir(argv[1]);
}
}
}
看结果:
我们使用cd命令之后,发现命令行提示符处的当前目录并没有改变,但是我们通过pwd来查看当前工作目录却发现目录已经改变了。这是为啥呢?
我们自己的shell的环境变量都是继承自父进程,也就是bash。而我们打印命令行提示符使用的是环境变量PWD,可环境变量在运行中一直都没有改变,所以导致我们每一次打印命令行提示符时不会改变。
为了解决这个问题,我们可以使用getcwd来获取跳转之后的目录,并将环境变量PWD进行修改,此时就可以达到路径改变的同时命令行提示符也发生改变!
// 当前工作目录
char pwd[MAXARGC];
char pwdenv[MAXARGC];
// 获取当前工作目录
char* getPwd()
{
char* s = getcwd(pwd, sizeof(pwd));
if(s != NULL)
{
snprintf(pwdenv, sizeof(pwdenv), "PWD=%s", pwd);
putenv(pwdenv); // 更新环境变量
}
return getenv("PWD");
}
// 只取出工作目录
std::string getPwdName()
{
std::string pwd_name = getPwd();
if(pwd_name.size() == 1) return pwd_name;
size_t pos = pwd_name.rfind('/');
return pwd_name.substr(pos+1, std::string::npos);
}
修改之后,命令行提示符处的信息就与当前的工作目录同步了。
除了cd ~外,我们还可以使用cd - ,快速切换到上一次所在的工作目录。
想要实现cd -,我们就得知道上一次的工作目录是存在环境变量OLDPWD中的。我们每次在执行cd命令时,oldpwd和pwd都会进行变化。所以我们在每次cd前,先将当前所在目录赋值给oldpwd,并putenv更新,然后再cd到指定目录,而每次执行完一个指令之后,打印命令行提示符时会更新pwd,所以不用管。
但是对于cd -来说,我们得先跳转到oldpwd,
// 更新上一次的工作目录
void reOldPwd()
{
snprintf(oldpwdenv, sizeof(pwdenv), "OLDPWD=%s", getenv("PWD"));
putenv(oldpwdenv);
}
// cd命令
void CD()
{
if(argc == 1)
{
reOldPwd();
chdir(getenv("HOME"));
}
else
{
std::string where = argv[1];
if(where == "~")
{
reOldPwd();
chdir(getenv("HOME"));
}
else if(where == "-")
{
chdir(getenv("OLDPWD"));
reOldPwd();
}
else
{
reOldPwd();
chdir(argv[1]);
}
}
}
再更新它。如果先更新oldpwd的话,此时pwd和oldpwd都指向当前目录,会导致跳转失败。
其他的内建命令待做......
六.环境变量
当登录用户后,bash进程就会启动,它会从配置文件中读取环境变量加载到bash内部。但是我们自己实现的minshell没有办法读取文件。所以这里我们选择读取父进程的环境变量以达到模拟从配置文件中读取环境变量的过程。
// 环境变量表
char* env[MAXARGC];
int envs = 0;
// 初始化环境变量
void InitEnviron()
{
// 模拟从配置文件读取环境变量
extern char** environ;
for(int i=0; environ[i]; ++i)
{
env[i] = (char*)malloc(strlen(environ[i]));
strcpy(env[i], environ[i]);
envs++;
}
env[envs] = NULL;
// 将环境变量加载到进程内部
for(int i=0; env[i]; i++)
{
putenv(env[i]);
}
// 让全局的环境变量指针指向自己的环境变量表
environ = env;
}
这下我们就有了自己的环境变量表了。
以上,就是一个简单的minshell,后面学到新知识后,在对其进行扩充。