如何在GNU/Linux下实现自己的shell

TASK

打造一个绝无伦比的 xxx-super-shell (xxx 是你的名字),它能实现下面这些功能:

  • 实现 管道 (也就是 |)

  • 实现 输入输出重定向(也就是 < > >>)

    • 要求实现 在管道组合命令的两端实现重定向运算符
1
2
3
4
# Require 
cat < 1.txt | grep -C 10 abc | grep -L efd | tac >> 2.txt
# Does not require
cat < 1.txt | grep -C 10 abc > test1.txt | test2.txt > grep -L efd | tac >> 2.txt
  • 实现 后台运行(也就是 &

  • 实现 cd,要求支持能切换到绝对路径,相对路径和支持 **cd -**

  • 屏蔽一些信号(如 ctrl + c 不能终止)

  • 界面美观

  • 开发过程记录、总结、发布在个人博客中

要求:

  • 不得出现内存泄漏,内存越界等错误
  • 学会如何使用 gdb 进行调试,使用 valgrind 等工具进行检测

测试用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
xxx@xxx ~ $ ./xxx-super-shell
xxx@xxx ~ $ echo ABCDEF
xxx@xxx ~ $ echo ABCDEF > ./1.txt
xxx@xxx ~ $ cat 1.txt
xxx@xxx ~ $ ls -t >> 1.txt
xxx@xxx ~ $ ls -a -l | grep abc | wc -l > 2.txt
xxx@xxx ~ $ python < ./1.py | wc -c
xxx@xxx ~ $ mkdir test_dir
xxx@xxx ~/test_dir $ cd test_dir
xxx@xxx ~ $ cd -
xxx@xxx ~/test_dir $ cd -
xxx@xxx ~ $ ./xxx-super-shell # shell 中嵌套 shell
xxx@xxx ~ $ exit
xxx@xxx ~ $ exit
  • 核心为掌握Linux系统编程进程的部分

框架主体

main()

从main函数来分析实现的整体框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main(){
signal(SIGINT,SIG_IGN);//屏蔽ctrl+c
signal(SIGTSTP,SIG_IGN); //屏蔽ctrl+z
while(1){
char*argv[MAX]={NULL};
printname();
char*command=readline(" ");//readline函数输出给出的字符串并读取一行输入,并为读取的输入动态分配内存,返回值为指向读取输入的指针
if (command == NULL) continue;//屏蔽ctrl+d
if (strlen(command) == 0) continue;//回车不爆栈
int argc=1;
argv[0] = strtok(command, " ");
for(int i=1;argv[i] = strtok(NULL, " ");i++) argc++;//将命令行输入分割为多个命令
analyze_cmd(argc,argv);//解析命令
do_cmd(argc,argv);//实现命令
free(command); //释放空间
clear_para();//重置参数
}
}
  • 一些声明如MAX可以结合文章最后的全部代码查看
  • 首先要调用signal函数屏蔽一些信号
  • 由于shell是交互进程,所以我们进入while(1)循环
  • printname函数负责每次输入命令前和后的终端名字显示和路径显示
  • 这里使用了一个动态链接库readline,需要我们单独下载并通过相应头文件使用
  • 整体框架已经有了,下面给出各个部分的详细解释

接口详解

printname()

1
2
3
4
5
6
7
8
void printname(){
char pathname[PATHMAX];
getcwd(pathname,PATHMAX);//获取当前目录
printf(BLUE"Whosefrienda-shell"CLOSE);//打印shell名称
printf(GREEN" :%s"CLOSE,pathname);//打印路径
printf("$ ");
fflush(stdout);//清除缓冲区
}
  • 这里的BLUE和GREEN和CLOSE是通过宏定义实现的:
1
2
3
#define BLUE "\033[34m"//宏定义实现有色字体
#define GREEN "\033[32m"
#define CLOSE "\033[0m"

analyze_cmd(argc,argv)()

这里用了全局变量

1
2
3
4
5
6
int cd =0;
int i_redir=0;
int o_redir=0;
int _pipe=0;
int a_o_redir=0;
int pass=0;//命令解析的参数
code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
int analyze_cmd(int argc,char*argv[]){
if (argv[0] == NULL) return 0;
if (strcmp(argv[0], "cd") == 0) cd = 1;
for (int i = 0; i < argc; i++){
if (strcmp(argv[i], ">") == 0) o_redir = 1;
if (strcmp(argv[i], "|") == 0) _pipe = 1;
if (strcmp(argv[i], ">>") == 0) a_o_redir = 1;
if (strcmp(argv[i], "<") == 0) i_redir = 1;
if (strcmp(argv[i], "&") == 0){
pass = 1;
argv[i]=NULL;
}
}
}

每个参数都在do_cmd函数中辅助判定,从而使用不同的接口来实现命令。

void do_cmd(int argc,char*argv[])

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void do_cmd(int argc,char*argv[]){
if(pass==1) argc--;
if (cd == 1) mycd(argv);
else if (strcmp(argv[0], "history") == 0) showhistory();//展示历史命令
else if (strcmp(argv[0], "exit") == 0)
{
printf("exit\n");
printf("有停止的任务\n");
exit(0);
}
else if ( i_redir== 1) iredir(argv);// <
else if ( o_redir== 1) oredir(argv);// >
else if ( a_o_redir== 1) aoredir(argv);// >>
else if ( _pipe == 1) mymulpipe(argv, argc);// | 管道放在最后判定,因为重定向中也有管道的判定
else //需要fork子进程进行执行的命令
{
if (pid < 0)
{
perror("fork");
exit(1);
}
else if (pid == 0) //子进程
{
execvp(argv[0], argv);
perror("command");
exit(1);
}
else if (pid > 0) //父进程
{
if(pass==1)
{
printf("%d\n",pid);
return;
}
waitpid(pid, NULL, 0);
}
}
}

这个接口实际上通过判定参数真假值来调用其他函数来实现命令,本身只实现没有重定向和管道等的需要fork和execve的简单命令

  • 这里只讲一下fork子进程实现的命令,其他在下面的具体接口再详解
  • fork返回两个pid值,一个是父进程的,一个是子进程的,fork后的代码会被父进程和子进程分别执行一遍,所以需要进行判定来分别编写父进程和子进程需要执行的代码
  • 这里,子进程需要调用execvp来加载命令实现需要的代码
  • 父进程则调用waitpid来监控子进程的进行,并且在有&的情况下将控制权重新交给主函数,从而让子进程在后台执行命令的同时不影响shell前台继续执行新命令

void showhistory()

1
2
3
4
5
6
7
8
void showhistory()
{
int i = 0;
HIST_ENTRY **his;
his = history_list();
while (his[i] != NULL)
printf("%-3d %s\n", i, his[i++]->line);
}

这里的HIST_ENTRY类型和history_list函数都在<readline/history.h>中有定义

void mycd(char *argv[])

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
char lastpath[MAX];//为实现cd-而声明
void mycd(char *argv[]){
if (argv[1] == NULL)//未输入要跳转的目录的情况
{
getcwd(lastpath, sizeof(lastpath));
chdir("/home");
}
else if (strcmp(argv[1], "-") == 0)//实现cd -
{
char newlastpath[MAX];
getcwd(newlastpath, sizeof(lastpath));
chdir(lastpath);
printf("%s\n", lastpath);
strcpy(lastpath, newlastpath);
}
else if (strcmp(argv[1], "~") == 0)//跳转主目录
{
getcwd(lastpath, sizeof(lastpath));
chdir("/home/wanggang");
}
else
{
getcwd(lastpath, sizeof(lastpath));
chdir(argv[1]);//跳转到输入的路径名
}
}
  • 为实现cd-,声明了lastpath来记录之前的路径
  • 主要调用chdir来改变当前路径

void oredir(char *argv[])

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void oredir(char *argv[]){
char *preargv[MAX] = {NULL};
int i = 0;
while (strcmp(argv[i], ">"))
{
preargv[i] = argv[i];
i++;
}
int preargc=i;//重定向前面参数的个数
i++;
int fdout = dup(1);//让标准输出获取一个新的文件描述符
int fd = open(argv[i], O_WRONLY | O_CREAT | O_TRUNC,0666);
dup2(fd, 1);
pid_t pid = fork();
if (pid == 0) //子进程
{
if (_pipe=1) //管道'|'
{
mymulpipe(preargv, preargc);
}
else
execvp(preargv[0], preargv);
}
else if (pid > 0)//父进程
{
if(pass==1)
{
printf("%d\n",pid);
return;
}
waitpid(pid, NULL, 0);
}
dup2(fdout, 1);
}
  • 定义preargv将重定向符之前的命令保存,并获得重定向符之后的文件描述符(没有该文件就创建一个)
  • fork子进程运行preargv里保存的命令
  • 最后将获得的文件描述符重定向到标准输出

void mymulpipe(char *argv[], int argc)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
void mymulpipe(char *argv[], int argc ){
pid_t pid;
int index[10];//存放每个管道的下标
int number=0;//统计管道个数
for(int i=0;i<argc;i++)
if(!strcmp(argv[i],"|")) index[number++]=i;
int cmdcount=number+1;//命令个数
char* cmd[cmdcount][10];
for(int i=0;i<cmdcount;i++)//将命令以管道分割存放组数组里
{
if(i==0)
{
int n=0;
for(int j=0;j<index[i];j++)
{
cmd[i][n++]=argv[j];
}
cmd[i][n]=NULL;
}
else if(i==number)
{
int n=0;
for(int j=index[i-1]+1;j<argc;j++)
{
cmd[i][n++]=argv[j];
}
cmd[i][n]=NULL;
}
else
{
int n=0;
for(int j=index[i-1]+1;j<index[i];j++)
{
cmd[i][n++]=argv[j];
}
cmd[i][n]=NULL;
}
}//命令已经分割好了,下面可以创建管道了。
int fd[number][2]; //存放管道的描述符
for(int i=0;i<number;i++)//循环创建多个管道
{
pipe(fd[i]);
}
int i=0;
for(i=0;i<cmdcount;i++)//父进程循环创建多个并列子进程
{
pid=fork();
if(pid==0)//子进程退出,防止创建过多进程
break;
}
if(pid==0)//子进程
{
if(number)
{
if(i==0)//第一个子进程
{
dup2(fd[0][1],1);//绑定写端
close(fd[0][0]);//关闭读端
//其他进程读写端全部关闭
for(int j=1;j<number;j++)
{
close(fd[j][1]);
close(fd[j][0]);
}
}
else if(i==number)//最后一个子进程
{
dup2(fd[i-1][0],0);//打开读端
close(fd[i-1][1]);//关闭写端
//其他进程读写端全部关闭
for(int j=0;j<number-1;j++)
{
close(fd[j][1]);
close(fd[j][0]);
}
}
else //中间进程
{
dup2(fd[i-1][0],0);//前一个管道的读端打开
close(fd[i-1][1]);//前一个写端关闭
dup2(fd[i][1],1);//后一个管道的写端打开
close(fd[i][0]);//后一个读端关闭
//其他的全部关闭
for(int j=0;j<number;j++)
{
if(j!=i&&j!=(i-1))
{
close(fd[j][0]);
close(fd[j][1]);
}
}
}
}

execvp(cmd[i][0],cmd[i]);//执行命令
perror("execvp");
exit(1);
}
else{//父进程
for(i=0;i<number;i++)
{
close(fd[i][0]);
close(fd[i][1]);//父进程端口全部关掉

}
if(pass==1)
{
pass=0;
printf("%d\n",pid);
return;
}
for(int j=0;j<cmdcount;j++)//父进程等待子进程
wait(NULL);
}
  • 这里分两大步,第一步是通过一个二维数组将各个管道两端的命令分隔开
  • 第二步是fork出相应数量的进程并创建相应数量的管道来执行命令

参考资料

1.《Linux/Unix系统编程手册》

2.学长的shell


如何在GNU/Linux下实现自己的shell
https://whosefrienda.github.io/2024/03/28/如何在GNU-Linux下实现自己的shell/
作者
WhosefriendA
发布于
2024年3月28日
许可协议