Linux简易Shell编写
有了之前的一些学习,我们就可以通过前面学习的内容去理解并仿写一个具有部分功能的简易命令行程序。
1. Shell命令行
我们之前的学习都是在Linux系统的命令行中去完成的,而这个命令行就是shell,而这个命令行的作用就是解析我们输入的指令,然后完成相应的任务。
对于内建命令,自己执行,对于其他命令,进行解析,无法执行的,返回错误信息,可以执行的,创建子进程,然后进行程序替换后执行。
2. 命令行的组成
2.1 命令行提示部分
我们可以看到,当我们进入系统后,首先出现的就是一行内容:

其中,它们是由几个部分组成:[用户名@主机名 当前目录]提示符 。
- 用户名:当前登录的用户名
- 主机名:当前机器的主机名
- 当前目录:目前用户所处的目录
- 提示符:普通用户为$,root用户为#
实际上这一部分也就是一个字符串,我们只需要构建好这个字符串,打印到屏幕即可。
2.2 命令行输入部分
后面我们输入的内容,实际上也就是输入一个字符串,然后由shell将我们输入的内容进行解析,然后执行。
3. 代码实现
具体我们可以将shell的运行过程分为几个部分:
- 打印提示内容
- 读取用户输入内容
- 解析输入的命令
- 执行命令
然后循环上述过程即可。
3.1 命令行提示内容
这一部分,我们需要获取几个数据,也就是我们上面说到的用户名、主机名、当前目录,而这几个信息都可以在环境变量中进行获取,我们使用getenv命令(注意包含头文件stdlib.h)即可。
这里我们封装成几个函数来实现:
c
const char *GetUserName()
{
const char *user = getenv("USER");
if (user == NULL) return "None";
return user;
}
const char *GetHostName()
{
const char *hostName = getenv("HOSTNAME");
if (hostName == NULL) return "None";
return hostName;
}
const char *GetPwd()
{
const char *pwd = getenv("PWD");
if (pwd == NULL) return "None";
return pwd;
}
然后使用snprintf将上面几个字符串按照指定格式拼接起来即可(为了区别,我们这里使用>作为提示符),接下来封装一个函数将提示部分字符串拼接起来并打印到屏幕上:
c
void MakeCommandLineAndPrint()
{
char line[SIZE];
const char *userName = GetUserName();
const char *hostName = GetHostName();
const char *pwd = GetPwd();
snprintf(line, sizeof(line), "[%s@%s %s]> ", userName, hostName, pwd);
printf("%s", line);
fflush(stdout);
}
我们可以在main函数中直接调用MakeCommandLineAndPrint函数看看效果(这里为了更好的显示,调用函数后还printf了一个'\n'):

这里可以看到,我们的主机名似乎和系统的不太一样,以及我们的路径显示的是完整的路径,而不是当前目录,这里我们可以先将主机名进行修改,路径问题在后面再进行修改(因为后面还有其他的问题),我们只需要在GetHostName中对获取到的字符串进行截断即可(找到'.',修改为'\0'),但是从getenv中获取的字符串是const char*类型的,无法修改,所以我们只需要使用strncpy函数复制我们需要的那一部分出来即可,下面是修改后的MakeCommandLineAndPrint:
c
void MakeCommandLineAndPrint()
{
char line[SIZE];
const char *userName = GetUserName();
const char *hostName = GetHostName();
const char *pwd = GetPwd();
int hostNameLen = strlen(hostName);
char newHostName[hostNameLen];
int end = 0;
while (hostName[end] != '.' && end < hostNameLen) ++end;
strncpy(newHostName, hostName, end);
newHostName[end] = '\0';
snprintf(line, sizeof(line), "[%s@%s %s]> ", userName, newHostName, pwd);
printf("%s", line);
fflush(stdout);
}

3.2 读取用户输入内容
我们只需要创建一个字符串userCommand用来接收用户输入的内容,然后封装一个读入函数GetUserCommand,将内容输入到userCommand中,并返回该字符串的有效字符个数n,如果n小于0说明读入失败,结束程序,如果n等于0说明用户没有输入有效命令,执行下一轮循环,如果n大于0,则说明读取到有效内容,继续下一步。
c
int GetUserCommand(char com[], size_t size)
{
char *s = fgets(com, size, stdin);
if (s == NULL) return -1;
int len = strlen(com);
com[--len] = '\0'; // 将读入的\n删除
return len;
}
测试:
c
int main()
{
while (1)
{
// 1. 输出提示信息
MakeCommandLineAndPrint();
// 2. 读取用户输入内容
char userCommand[SIZE];
int n = GetUserCommand(userCommand, sizeof(userCommand));
if (n < 0) exit(-1);
else if (n == 0) continue;
printf("%s\n", userCommand);
}
return 0;
}
运行:

3.3 解析读入的字符串
由于我们后续进行程序替换需要分别输入指令和选项,所以我们需要将读入的字符串进行分割,例如我们读入的是ls -a -l,我们就需要分成"ls" "-a" "-l",然后将他们放到一个数组Argv中,最后一个字符串的后一个字符串修改为NULL(似曾相识,这不就是main的参数之一吗?!),这个操作我们使用strtok函数就可以完成:
c
void SplitCommand(char *Argv[], char com[])
{
Argv[0] = strtok(com, " ");
int index = 1;
while ((Argv[index++] = strtok(NULL, " ")));
}
测试:
c
int main()
{
while (1)
{
// 1. 输出提示信息
MakeCommandLineAndPrint();
// 2. 读取用户输入内容
char userCommand[SIZE];
int n = GetUserCommand(userCommand, sizeof(userCommand));
if (n < 0) exit(-1);
else if (n == 0) continue;
// 3. 分割命令行字符串
char *Argv[SIZE];
SplitCommand(Argv, userCommand);
for (int i = 0; Argv[i] != NULL; i++)
printf("%s\n", Argv[i]);
}
return 0;
}
运行:

3.4 执行命令
有了上面的argv数组,我们就可以通过execvp函数来让子进程执行对应的命令,这里我们封装一个ExecuteCommand函数来实现:
c
void ExecuteCommand(char *Argv[])
{
pid_t id = fork();
if (id < 0) exit(1);
if (id == 0)
{
// child
execvp(Argv[0], Argv);
exit(errno); // 如果创建子进程出现错误,直接退出进程并返回错误码
}
// father
int status = 0;
int rid = waitpid(id, &status, 0);
if (rid > 0)
{
lastcode = WEXITSTATUS(status); // 设置一个全局变量,记录最近一次子进程的退出码
if (lastcode != 0) printf("%s:%s:%d\n", Argv[0], strerror(lastcode), lastcode);
}
}
测试:
c
int main()
{
while (1)
{
// 1. 输出提示信息
MakeCommandLineAndPrint();
// 2. 读取用户输入内容
char userCommand[SIZE];
int n = GetUserCommand(userCommand, sizeof(userCommand));
if (n < 0) exit(-1);
else if (n == 0) continue;
// 3. 分割命令行字符串
char *Argv[SIZE];
SplitCommand(Argv, userCommand);
// 4. 执行命令
ExecuteCommand(Argv);
}
return 0;
}
运行:

3.5 细节调整
到这里我们大体的框架就已经完成了,但是还有一些细节需要解决。
3.5.1 内建命令
当我们使用上面的代码来使用cd命令时,我们会看到下面的现象

无论我们使用多少次cd,都无法改变当前的路径,这是为什么??
我们之前说过,所谓的当前路径,就是这个进程在它的环境变量表中维护的一条环境变量,而我们通过上面的逻辑使用cd命令时,创建子进程执行了cd,那么改变的环境变量就是子进程的环境变量,而不是父进程的,子进程无法修改父进程的环境变量(进程的独立性),所以cd命令不能创建子进程来执行,而是要父进程亲历亲为,而这一类需要父进程自己执行命令也就是我们之前介绍过的内建命令。
为了解决这个问题,我们就需要在执行命令之前判断这个命令是否是内建命令,如果是,则自己执行,如果不是,则让子进程执行。
这里我们封装一个函数CheckBuildin来实现这个功能:
c
const char *getHome()
{
const char *home = getenv("HOME");
if (home == NULL) home = "/";
return home;
}
void Cd(char *Argv[])
{
const char *path = Argv[1];
if (path == NULL || strcmp(path, "~") == 0) path = getHome(); // 如果没有指定路径,或路径为~,返回家目录
// 指定路径为-时返回上一次目录,这里就不具体实现了,说明:需要添加一个全局的变量,然后在每次cd进行维护
chdir(path); // 修改当前进程的工作目录
// 更新当前进程的环境变量
char temp[SIZE * 2];
getcwd(temp, sizeof(temp)); // 获取当前工作目录
snprintf(cwd, sizeof(cwd), "PWD=%s", temp); // 这里的cwd需要在全局进行维护,因为添加到环境变量本质是将这个字符串的指针添加到环境变量表中,如果不在全局维护,出了这这个函数后就会丢失
putenv(cwd); // 添加到环境变量
}
int CheckBuildin(char *Argv[])
{
const char *com = Argv[0];
int flag = 0; // 如果是内建命令,设置为1,提示后续无需创建子进程完成
if (strcmp(com, "cd") == 0)
{
flag = 1;
Cd(Argv); // 内建命令需要我们自己完成
}
// ...可以自己实现其他的内建命令
return flag;
}
运行:

3.5.2 路径截断
我们的shell中的提示信息显示的是完整的路径,而我们只需要显示当前所在的目录,就需要截断当前的路径,只显示最后的目录,只需要使用一个指针移动到我们需要的位置即可,这里我们封装一个函数SkipPath来实现,同时我们的MakeCommandLineAndPrint函数也需要修改一部分:
c
const char *SkipPath(const char *p)
{
p += strlen(p) - 1;
while (*p != '/') --p;
if (*(p + 1) != '\0') ++p;
return p;
}
void MakeCommandLineAndPrint()
{
char line[SIZE];
const char *userName = GetUserName();
const char *hostName = GetHostName();
const char *pwd = GetPwd();
int hostNameLen = strlen(hostName);
char newHostName[hostNameLen];
int end = 0;
while (hostName[end] != '.' && end < hostNameLen) ++end;
strncpy(newHostName, hostName, end);
newHostName[end] = '\0';
snprintf(line, sizeof(line), "[%s@%s %s]> ", userName, newHostName, SkipPath(pwd));
printf("%s", line);
fflush(stdout);
}
运行:

4. 完整代码
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define SIZE 512
int lastcode = 0;
char cwd[SIZE * 2];
const char *GetUserName()
{
const char *user = getenv("USER");
if (user == NULL) return "None";
return user;
}
const char *GetHostName()
{
const char *hostName = getenv("HOSTNAME");
if (hostName == NULL) return "None";
return hostName;
}
const char *GetPwd()
{
const char *pwd = getenv("PWD");
if (pwd == NULL) return "NO PWD";
return pwd;
}
const char *SkipPath(const char *p)
{
p += strlen(p) - 1;
while (*p != '/') --p;
if (*(p + 1) != '\0') ++p;
return p;
}
void MakeCommandLineAndPrint()
{
char line[SIZE];
const char *userName = GetUserName();
const char *hostName = GetHostName();
const char *pwd = GetPwd();
int hostNameLen = strlen(hostName);
char newHostName[hostNameLen];
int end = 0;
while (hostName[end] != '.' && end < hostNameLen) ++end;
strncpy(newHostName, hostName, end);
newHostName[end] = '\0';
snprintf(line, sizeof(line), "[%s@%s %s]> ", userName, newHostName, SkipPath(pwd));
printf("%s", line);
fflush(stdout);
}
int GetUserCommand(char com[], size_t size)
{
char *s = fgets(com, size, stdin);
if (s == NULL) return -1;
int len = strlen(com);
com[--len] = '\0'; // 将读入的\n删除
return len;
}
void SplitCommand(char *Argv[], char com[])
{
Argv[0] = strtok(com, " ");
int index = 1;
while ((Argv[index++] = strtok(NULL, " ")));
}
void ExecuteCommand(char *Argv[])
{
pid_t id = fork();
if (id < 0) exit(1);
if (id == 0)
{
// child
execvp(Argv[0], Argv);
exit(errno); // 如果创建子进程出现错误,直接退出进程并返回错误码
}
// father
int status = 0;
int rid = waitpid(id, &status, 0);
if (rid > 0)
{
lastcode = WEXITSTATUS(status); // 设置一个全局变量,记录最近一次子进程的退出码
if (lastcode != 0) printf("%s:%s:%d\n", Argv[0], strerror(lastcode), lastcode);
}
}
const char *getHome()
{
const char *home = getenv("HOME");
if (home == NULL) home = "/";
return home;
}
void Cd(char *Argv[])
{
const char *path = Argv[1];
if (path == NULL || strcmp(path, "~") == 0) path = getHome(); // 如果没有指定路径,或路径为~,返回家目录
// 指定路径为-时返回上一次目录,这里就不具体实现了,说明:需要添加一个全局的变量,然后在每次cd进行维护
chdir(path); // 修改当前进程的工作目录
// 更新当前进程的环境变量
char temp[SIZE * 2];
getcwd(temp, sizeof(temp)); // 获取当前工作目录
snprintf(cwd, sizeof(cwd), "PWD=%s", temp); // 这里的cwd需要在全局进行维护,因为添加到环境变量本质是将这个字符串的指针添加到环境变量表中,如果不在全局维护,出了这这个函数后就会丢失
putenv(cwd); // 添加到环境变量
}
int CheckBuildin(char *Argv[])
{
const char *com = Argv[0];
int flag = 0; // 如果是内建命令,设置为1,提示后续无需创建子进程完成
if (strcmp(com, "cd") == 0)
{
flag = 1;
Cd(Argv); // 内建命令需要我们自己完成
}
// ...可以自己实现其他的内建命令
return flag;
}
int main()
{
while (1)
{
// 1. 输出提示信息
MakeCommandLineAndPrint();
// 2. 读取用户输入内容
char userCommand[SIZE];
int n = GetUserCommand(userCommand, sizeof(userCommand));
if (n < 0) exit(-1);
else if (n == 0) continue;
// 3. 分割命令行字符串
char *Argv[SIZE];
SplitCommand(Argv, userCommand);
// 4. 判断是否是内建命令
int flag = CheckBuildin(Argv);
if (flag == 1) continue;
// 5. 执行命令
ExecuteCommand(Argv);
}
return 0;
}