本篇博客,来教大家用C写一个简易的linux shell,帮助理解之前学习的进程控制相关知识

演示系统:CentOS7.6

[TOC]

前言

之所以说是简易的shell,是因为我们现在的水平肯定写不出来linux系统里面那么复杂的shell。

我们的目的仅仅是为了学习父子进程、进程替换、内建命令等等知识,并把这些知识的作用通过这个小shell体现出来

源码仓库:gitee


1.基础框架

之前的学习中有提到过,我们在linux命令行内运行的很多进程,都是以子进程的方式运行的。说白了就是bash进程里面给我们fork创建了其他子进程,再用子进程进行进程替换,指向对应的可执行文件

而需要做到这一点,我们要一步一步来

  • bash首先要显示命令行的提示符用户名@主机名 路径(参考之前vim博客中的进度条程序)
  • 获取用户的输入内容
  • 从用户的输入中,以" "空格为分割,分离出命令和参数
  • fork创建子进程,子进程执行进程替换,父进程等待子进程结束

这一切都是在一个while(1)的死循环里面执行的,bash本质上就是一个死循环的父进程

2.开整一个

2.1 打印命令行提示符

先来试试打印出命令行的提示符吧!

1
2
printf("[慕雪@FS-1041 当前路径]# ");
fflush(stdout); //刷新缓冲区,达到不换行的效果

image-20221016155902790

  • 为何要使用fflush

如果不这么弄,而使用\n换行,就会出现命令行提示符一直在闪动打印。这不是我们想要的结果

光是打印一个基本的路径可不太够哦,我们还可以试着获取环境变量的PWD得到当前的路径,再打印出来

1
2
3
4
5
6
char cur_pwd[SIZE] = "~";
int sz_pwd = strlen(getenv("HOME"));
strcat(cur_pwd, getenv("PWD") + sz_pwd);
printf("[慕雪@FS-1041 %s]# ", cur_pwd);

fflush(stdout); //刷新缓冲区,达到不换行的效果

这里我们必须要去掉PWD前面/home/用户名的内容,将其替换成~

打印出来的效果如下,是不是和我们linux的命令行很像啦!

1
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# 

你还可以从环境变量中获取HOSTNAMEUSER来替换掉前面的内容

这里为了和linux自己的shell区分一下,我就不替换了


2.2 获取用户输入

C语言获取用户输入,我们一般用的是scanf

但是这个函数在现在这个地方可不那么好用喽!我们输入命令的时候需要用空格分开命令行参数。scanf会因为空格而停止接受

我们可以用gets函数来解决这个问题!

1
2
3
4
5
#define NUM 1024
char cmd_line[NUM]; //命令行输入
// 2.获取用户的输入内容
memset(cmd_line, '\0', sizeof(cmd_line) * sizeof(char));
fgets(cmd_line, NUM, stdin); //标准输入stdin获取键盘输入内容

获取了之后先打印一下cmd_line,可以看到成功获取了我们输入的结果

1
2
3
4
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# test i k d
test i k d

[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]#

但为什么多打了一个换行呢?

这是因为fgets在接受输入的时候,把我们输入结束的回车也给收起来辣

1
cmd_line[strlen(cmd_line) - 1] = '\0'; // 去掉\n回车

光是去掉回车还是有点问题,如果我们只敲了一个回车,后续我们分离参数的时候,总不能对一个空的字符串进行处理吧?

所以还需要单独判断strlen(cmd_line)==1的情况,直接continue

1
2
3
4
5
6
if(strlen(cmd_line)==1)
{
continue;//等于1的情况只能是敲了一个回车
}
// 其他情况去掉\n回车
cmd_line[strlen(cmd_line) - 1] = '\0';

这样我们的bash就和linux自己的bash一样,敲回车会直接新起一行,不做任何操作

image-20221016161632902

如果不这么处理,就会引发段错误导致bash直接终止

1
2
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# 
Segmentation fault

2.3 分离参数

获取好用户输入啦,下一步就是分离参数了!

这里面我们直接使用strtok这个函数即可!

1
char * strtok ( char * str, const char * sep );

它的作用是根据分隔符返回这个分隔符在字符串里面的起始位置;如果传入的是一个NULL,则从上一次处理的位置继续往后处理。

  • strtok函数找到str中的下一个标记,并将其用\0结尾,返回一个指向这个标记的指针
  • 如果字符串中不存在更多的标记,则返回 NULL 指针

该函数的详解参考我的博客 点我

😥最开始的时候我忘记了这个函数,直接自己写了一个分离算法,debug了好久才勉强搞出来,太笨蛋了

1
2
3
4
5
6
7
8
#define SEP " " //分隔符
size_t cmd_args_num = 0; //分离出来的参数个数
char *cmd_args[SIZE]; //分离参数

cmd_args[0] = strtok(cmd_line, SEP);
cmd_args_num = 1;
while (cmd_args[cmd_args_num++] = strtok(NULL, SEP));
cmd_args_num--;//这里-1是因为while循环最后会多++ 1次

注意!=赋值操作符是有返回值的!它的返回值是我们的左值,也就是每一次获取到的strtok的结果,这个结果被cmd_args[cmd_args_num]所接受

那么,当strtok返回NULL的时候,while就会接受到=的返回值,从而停止循环

1
2
3
4
for(int j=0;j<cmd_args_num;j++)
{
printf("args[%d] %s\n",j,cmd_args[j]);
}

通过打印,可以看到它成功分离出来了我们的参数

1
2
3
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# ls -l
args[0] ls
args[1] -l

单独处理ls

在linux的bash下,我们执行的ls都是带颜色的。这是因为centos的配置文件中,将ls设置成了ls --color=auto的别名,要想我们自己bash里面的ls也带上颜色,则需要单独处理一下ls

1
2
3
4
5
6
7
8
// 3.分离出命令和参数
cmd_args[0] = strtok(cmd_line, SEP);
cmd_args_num = 1;
// 给ls命令添加颜色
if (strcmp(cmd_args[0], "ls") == 0)
cmd_args[cmd_args_num++] = (char *)"--color=auto";
while (cmd_args[cmd_args_num++] = strtok(NULL, SEP));
cmd_args_num--;//这里-1是因为while循环最后会多++一次

最终ls -l分离出来的参数如下

1
2
3
4
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# ls -l
args[0] ls
args[1] --color=auto
args[2] -l

2.4 进程替换

参数分离出来了,下一步要做的,便是进程替换了

我们需要使用的是exec函数里面的哪一个呢?

  • pexec函数,它会自动去PATH里面查找可执行文件
  • v的,函数,因为我们的传参已经分离在了一个字符指针数组里面

基本的代码如下,父进程打印内容是为了测试,实际的bash肯定是没有这个打印的~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 6.创建程序 替换
pid_t ret_id = fork();
if (ret_id == 0) //子进程
{
execvp(cmd_args[0], cmd_args); //程序替换
exit(134); //执行到这里,子进程一定替换失败
}
// 父进程
int status = 0;
pid_t ret = waitpid(ret_id, &status, 0);
printf("\n");
if (ret > 0)
{
printf("bash等待子进程成功!code: %d, sig: %d\n", WEXITSTATUS(status), WTERMSIG(status));
}

运行成功!

image-20221016164559695

执行python3的文件也是ok的

1
2
3
4
5
6
7
8
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# python3 test.py
args[0] python3
args[1] test.py

hello python

bash等待子进程成功!code: 0, sig: 0
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]#

3.内建命令

完成了上面的几步后,一个基础的bash就搞定了

但是这样还不够,不信cd试一下?

1
2
3
4
5
6
7
8
9
10
11
12
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# ls
makefile myshell myshell.c myshell_err.c test test.cpp test.py

bash等待子进程成功!code: 0, sig: 0
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# cd test

bash等待子进程成功!code: 0, sig: 0
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# ls
makefile myshell myshell.c myshell_err.c test test.cpp test.py

bash等待子进程成功!code: 0, sig: 0
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]#

诶,为什么cd了之后,再次ls,路径没有变化呢?

这是因为我们的cd是被子进程执行的,切换的是子进程的工作目录。可子进程执行完cd之后就结束运行了,它根本没有影响到父进程bash!


之前学习的时候,我们提到过内建命令这一个概念。有一些命令不应该是子进程执行的,而应该是bash自己执行的,比如这里的cd,还有导入环境变量的export

其实说白了就是bash检测到内建命令,就执行他自己的一个函数呗

3.1 cd和export命令

cd/export命令,c语言中都有现成的函数供我们使用,还是很方便的

1
2
3
4
5
6
7
8
9
10
11
12
13
//这里导入环境变量之后,不会影响linux的shell
//而是从我们的myshell开始所有子进程都会继承
int PutEnvIn(char *new_env)
{
putenv(new_env);
return 0;
}
//不使用内建命令,则不会生效
int ChangeDir(const char *new_path)
{
chdir(new_path);
return 0; // 调用成功
}

以下是main函数里面的内容,完整代码请去我的代码仓库查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 5.内建命令
if (strcmp(cmd_args[0], "cd") == 0 && cmd_args[1] != NULL)
{
ChangeDir(cmd_args[1]); //让调用方进行路径切换, 父进程
continue;
}
// 目前,环境变量信息在cmd_line,会被清空
// 此处我们需要自己保存一下环境变量内容
char env_buffer[SIZE][NUM];
size_t env_num = 0; //环境变量的数量
if (strcmp(cmd_args[0], "export") == 0 && cmd_args[1] != NULL)
{
strcpy(env_buffer[env_num], cmd_args[1]);
PutEnvIn(env_buffer[env_num]);
env_num++;
continue;
}

这时候cd就能正常执行了,不过pwd还没有修改,我没想好要怎么操作捏

1
2
3
4
5
6
7
8
9
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# ls
makefile myshell myshell.c myshell_err.c test test.cpp test.py

bash等待子进程成功!code: 0, sig: 0
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# cd test
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# ls

bash等待子进程成功!code: 0, sig: 0
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]#

试一试export,也没问题呢

1
2
3
4
5
6
7
8
9
//test.cpp
#include<iostream>
#include<stdlib.h>
using namespace std;
int main()
{
cout << "ts= " << getenv("ts") <<endl;
return 0;
}
1
2
3
4
5
6
7
8
9
10
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# make test
g++ test.cpp -o test -std=c++11

bash等待子进程成功!code: 0, sig: 0
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# export ts=12341
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# ./test
ts= 12341

bash等待子进程成功!code: 0, sig: 0
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]#

3.2 alias别名设置

上面两个命令有现成的,alias的设置就需要我们手写啦

1
2
3
4
5
6
7
8
9
10
#define NUM 1024
#define SIZE 128
//变量名别名
typedef struct alias_cmd
{
char _cmd[SIZE];
char _acmd[SIZE];
} alias;
alias cmd_alias[SIZE]; //缓存别名键值对(结构体)
size_t alias_num = 0; //已缓存的别名个数

这里我先定义了一个结构体,用来存放变量别名的键值对,方便我们进行替换

然后就是漫长的替换步骤,这部分我debug了非常久才写出来,都带了注释,大家可以看看

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
//设置别名(新命令,原命令)
void set_alias(char *cmd, char *acmd)
{
//查找别名里是否已经有了这个
for (int i = 0; i < alias_num; i++)
{
if (strcmp(cmd_alias[i]._cmd, cmd) == 0) //是已有的别名
{
strcpy(cmd_alias[i]._acmd, acmd);
// printf("set cmd %s acmd %s\n",cmd_alias[i]._cmd,cmd_alias[i]._acmd);
return;
}
}

//没有提前退出,说明是新增别名
strcpy(cmd_alias[alias_num]._cmd, cmd);
strcpy(cmd_alias[alias_num]._acmd, acmd);
alias_num++;
}
//判断一个命令是否有别名
bool is_alias(char *cmd_args[], int sz)
{
int i = 0;
for (i = 0; i < alias_num; i++)
{
if (strcmp(cmd_alias[i]._cmd, cmd_args[0]) == 0) //是别名
{
size_t index = 1, j;
char *cmd_args_temp[SIZE]; //临时数组用于分离别名里面的命令
memset(cmd_line_alias, '\0', sizeof(cmd_line_alias) * sizeof(char)); //清空别名命令缓存
//先把别名中的命令分开
strcpy(cmd_line_alias, cmd_alias[i]._acmd); //不能直接使用_acmd,不然会影响下次别名使用
cmd_args_temp[0] = strtok(cmd_line_alias, SEP);
//别名的时候也需要设置ls的颜色(需要保证原本命令的第一个不是ls,不然本来就已经有"--color=auto"了)
if (strcmp(cmd_args_temp[0], "ls") == 0 && strcmp(cmd_args[0], "ls") != 0)
cmd_args_temp[index++] = (char *)"--color=auto";
while (cmd_args_temp[index++] = strtok(NULL, SEP))
;
index--; // while会多+1,需要重新操作一下
//从原本数组的第二位开始往后设置
for (j = 1; j < cmd_args_num; j++)
{
cmd_args_temp[index++] = cmd_args[j];
}
//替换掉原本的数组
cmd_args_num = index; //此时的index长度正确,不需要-1
for (j = 0; j < cmd_args_num; j++) //因为while最后会多++一次,所以需要-1
{
//原本的位置没有那么大空间,放不下,不能拷贝
// strcpy(cmd_args[j],cmd_args_temp[j]);
cmd_args[j] = cmd_args_temp[j];
// printf("temp[%d] %s args[%d] %s\n",j,cmd_args_temp[j],j,cmd_args[j]);
}
cmd_args[j] = NULL; //最后一个位置设置成NULL
return true;
}
}
return false;
}

其实肯定是有更好的方案的,但是我还没想出来咋弄。现在这个能跑就OK,哈哈

以最基本的ll命令来测试以下,替换成功!修改已有的别名也是没有问题的

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
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# alias ll='ls -l'
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# ll
total 60
-rw-rw-r-- 1 muxue muxue 136 Oct 15 23:21 makefile
-rwxrwxr-x 1 muxue muxue 14040 Oct 16 17:05 myshell
-rw-rw-r-- 1 muxue muxue 8217 Oct 16 16:59 myshell.c
-rw-rw-r-- 1 muxue muxue 6942 Oct 15 22:38 myshell_err.c
-rwxrwxr-x 1 muxue muxue 9072 Oct 16 17:08 test
-rw-rw-r-- 1 muxue muxue 130 Oct 15 23:22 test.cpp
-rw-rw-r-- 1 muxue muxue 21 Oct 16 00:11 test.py

bash等待子进程成功!code: 0, sig: 0
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# alias ll='ls -l -a'
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]# ll
total 68
drwxrwxr-x 2 muxue muxue 4096 Oct 16 17:08 .
drwxrwxr-x 13 muxue muxue 4096 Oct 15 17:31 ..
-rw-rw-r-- 1 muxue muxue 136 Oct 15 23:21 makefile
-rwxrwxr-x 1 muxue muxue 14040 Oct 16 17:05 myshell
-rw-rw-r-- 1 muxue muxue 8217 Oct 16 16:59 myshell.c
-rw-rw-r-- 1 muxue muxue 6942 Oct 15 22:38 myshell_err.c
-rwxrwxr-x 1 muxue muxue 9072 Oct 16 17:08 test
-rw-rw-r-- 1 muxue muxue 130 Oct 15 23:22 test.cpp
-rw-rw-r-- 1 muxue muxue 21 Oct 16 00:11 test.py

bash等待子进程成功!code: 0, sig: 0
[慕雪@FS-1041 ~/git/linux/code/22-10-15_myshell]#

结语

就这样,一个最基本的bash或者说shell就被我们搞定啦

其实内建命令远不止3里面提到的那几个,不过我们学习的目的已经达到了~也没必要死磕在这里