020 实现一个简易 Shell

从零到一实现一个简易 Shell

这应该是个蛮有趣的话题:“什么是 Shell”?相信只要摸过计算机,对于操作系统(不论是 LinuxUnix 或者是 Windows)有点概念的朋友们大多听过这个名词,因为只要有“操作系统“那么就离不开 Shell 这个东西。不过,在讨论 Shell 之前,我们先来了解一下计算机的运行状况吧!举个例子来说:当你要计算机传输出来“音乐”的时候,你的计算机需要什么东西呢?

  1. 硬件:当然就是需要你的硬件有“声卡芯片”这个配备,否则怎么会有声音;
  2. 核心管理:操作系统的核心可以支持这个芯片组,当然还需要提供芯片的驱动程序啰;
  3. 应用程序:需要使用者(就是你)输入发生声音的指令啰!

这就是基本的一个输出声音所需要的步骤!也就是说,你必须要“输入”一个指令之后,“硬件“才会通过你下达的指令来工作!那么硬件如何知道你下达的指令呢?那就是 kernel(核心)的控制工作了!也就是说,我们必须要通过“Shell”将我们输入的指令与 Kernel 沟通,好让 Kernel 可以控制硬件来正确无误的工作!基本上,我们可以通过下面这张图来说明一下:

image-20250422201800429

以上内容摘自《鸟哥的 Linux 私房菜基础学习篇(第四版)》311 页。


1. Shell 的基本功能

一个基本的 Shell 需要具备以下功能:

  • 提示符显示:显示当前用户、主机名和工作目录,例如 [user@host ~]#
  • 命令读取:从标准输入读取用户输入的命令。
  • 命令解析:将输入的命令行分割为命令和参数。
  • 命令执行:支持内置命令(如 cdexport)和外部命令(如 lscat)。
  • 重定向支持:支持输入重定向 <、输出重定向 > 和追加输出重定向 >>
  • 环境变量管理:支持查看和设置环境变量。
  • 退出机制:支持通过 exit 退出 Shell

让我们一步步实现这些功能。

2. 实现 Shell 的提示符

Shell 的提示符是用户交互的起点,通常显示为 [用户名@主机名 当前目录]#。我们需要获取用户名、主机名和当前工作目录。

1. 获取用户信息
  • 用户名:使用 getenv("USER") 获取当前用户名。
  • 主机名:使用 getenv("HOSTNAME") 获取主机名。
  • 当前目录:使用 getcwd() 获取当前工作目录。
2. 定义提示符格式

我们通过宏定义设置提示符的格式:

1
2
3
#define LEFT "["        // 左括号
#define RIGHT "]" // 右括号
#define LABLE "#" // 提示符号
3. 实现 interact 函数

interact 函数负责显示提示符并读取用户输入:

1
2
3
4
5
6
7
8
9
10
void interact(char* cline, int size)
{
getpwd(); // 获取当前工作目录
printf(LEFT"%s@%s %s"RIGHT""LABLE" ", getusername(), gethostname1(), pwd);
char* s = fgets(cline, size, stdin); // 读取用户输入
assert(s); // 确保读取成功
(void)s; // 显式标记该变量已被“使用”,从而抑制编译器警告(这是一种代码规范技巧,表明有意忽略此变量)
cline[strlen(cline) - 1] = '\0'; // 去除末尾换行符
check_redir(cline); // 检查重定向符号
}
  • getpwd() 调用 getcwd(pwd, sizeof(pwd)) 更新全局变量 pwd

  • printf 格式化输出提示符,例如 [user@host /home]#

  • fgets 从标准输入读取命令行。

  • check_redir 检查是否有重定向符号(稍后实现)。

  • 命令行解析:用户输入的命令行需要被分割成命令和参数。例如,输入 ls -l /home 应分割为 ["ls", "-l", "/home"]

3. 使用 strtok 分割字符串

我们使用 strtok 函数按空格或制表符分割命令行:

1
2
3
4
5
6
7
8
9
#define DELIM " \t"  // 分隔符:空格和制表符

int splitstring(char cline[], char* _argv[])
{
int i = 0;
argv[i++] = strtok(cline, DELIM); // 分割第一个 token
while (_argv[i++] = strtok(NULL, DELIM)); // 继续分割后续 token
return i - 1; // 返回参数个数
}
  • strtok(cline, DELIM) 分割第一个 token(命令)。
  • 循环调用 strtok(NULL, DELIM) 获取后续参数。
  • 返回值是参数个数 argc,存储在全局数组 argv 中。

全局变量定义如下:

1
2
3
4
#define LINE_SIZE 1024
#define ARGC_SIZE 32
char commandline[LINE_SIZE]; // 存储用户输入
char* argv[ARGC_SIZE]; // 存储分割后的参数
4. 命令执行

Shell 需要区分两种命令:

  • 内置命令:由 Shell 直接处理,如 cdexportecho
  • 外部命令:通过 forkexec 执行系统中的可执行文件。
5. 内置命令实现

内置命令在 Shell 进程中直接执行,无需创建子进程。我们在 buildCommand 函数中实现:

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
int buildCommand(char* _argv[], int _argc)
{
// cd 命令:切换目录
if (_argc == 2 && strcmp(_argv[0], "cd") == 0)
{
chdir(argv[1]); // 切换工作目录
getpwd();
sprintf(getenv("PWD"), "%s", pwd); // 更新 PWD 环境变量
return 1; // 表示已处理
}
// export 命令:设置环境变量
else if (_argc == 2 && strcmp(_argv[0], "export") == 0)
{
strcpy(myenv, _argv[1]);
putenv(myenv); // 添加到环境变量表
return 1;
}
// echo 命令:打印参数
else if (_argc == 2 && strcmp(_argv[0], "echo") == 0)
{
if (strcmp(_argv[1], "$?") == 0)
{
printf("%d\n", lastcode); // 打印上一次命令退出码
lastcode = 0;
}
else if (*_argv[1] == '$')
{
char* val = getenv(_argv[1] + 1); // 获取环境变量值
if (val)
{
printf("%s\n", val);
}
}
else
{
printf("%s\n", _argv[1]); // 直接打印参数
}
return 1;
}
// 增强 ls 命令
if (strcmp(_argv[0], "ls") == 0)
{
_argv[_argc++] = "--color"; // 添加颜色选项
_argv[_argc] = NULL;
}
return 0; // 未处理,交给外部命令执行
}
  • cd:使用 chdir 切换目录,并更新 PWD 环境变量。
  • export:使用 putenv 设置环境变量,myenv 是全局缓冲区。
  • echo:支持打印上一次退出码、环境变量 VAR 或普通字符串。
  • ls 增强:自动添加 --color 选项以显示彩色输出。
  • 返回值:1 表示内置命令已处理,0 表示需要外部执行。
6. 外部命令执行

外部命令通过 fork 创建子进程并使用 execvp 执行:

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
void NormalExcute(char* _argv[])
{
pid_t id = fork();
if (id < 0)
{
perror("fork");
return;
}
else if (id == 0) // 子进程
{
int fd = 0;
if (rdir == IN_RDIR)
{
fd = open(rdirfilename, O_RDONLY);
dup2(fd, 0); // 重定向标准输入
}
else if (rdir == OUT_RDIR)
{
fd = open(rdirfilename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
dup2(fd, 1); // 重定向标准输出
}
else if (rdir == APPEND_RDIR)
{
fd = open(rdirfilename, O_CREAT | O_WRONLY | O_APPEND, 0666);
dup2(fd, 1); // 追加重定向标准输出
}
execvp(_argv[0], _argv); // 执行命令
exit(EXIT_CODE); // exec 失败退出
}
else // 父进程
{
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid == id)
{
lastcode = WEXITSTATUS(status); // 记录退出码
}
}
}
  • fork() 创建子进程。

  • 子进程根据重定向类型(rdir)打开文件并使用 dup2 重定向。

  • execvp 执行命令,从 PATH 中查找可执行文件。

  • 父进程使用 waitpid 等待子进程结束,并记录退出码到 lastcode

  • 重定向支持,Shell 支持三种重定向:

    • 输入重定向< filename

    • 输出重定向> filename

    • 追加输出重定向>> filename

7. 解析重定向符号

check_redir 函数中解析重定向:

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
#define NONE -1
#define IN_RDIR 0
#define OUT_RDIR 1
#define APPEND_RDIR 2

char* rdirfilename = NULL; // 重定向文件名
int rdir = NONE; // 重定向类型

void check_redir(char* cmd)
{
char* pos = cmd;
while (*pos)
{
if (*pos == '>')
{
if (*(pos + 1) == '>') // >>
{
*pos++ = '\0';
*pos++ = '\0';
while (isspace(*pos)) pos++;
rdirfilename = pos;
rdir = APPEND_RDIR;
break;
}
else // >
{
*pos = '\0';
pos++;
while (isspace(*pos)) pos++;
rdirfilename = pos;
rdir = OUT_RDIR;
break;
}
}
else if (*pos == '<') // <
{
*pos = '\0';
pos++;
while (isspace(*pos)) pos++;
rdirfilename = pos;
rdir = IN_RDIR;
break;
}
pos++;
}
}
  • 遍历命令行,检测 <>>>
  • 将符号替换为 \0 以分割命令和文件名。
  • 设置全局变量 rdirrdirfilename
  • interact 函数调用 check_redir 进行解析。
8. 执行重定向

NormalExcute 中根据 rdir 处理重定向:

  • 输入重定向:打开文件并重定向到标准输入(文件描述符 0)。
  • 输出重定向:创建或截断文件并重定向到标准输出(文件描述符 1)。
  • 追加输出重定向:创建或追加文件并重定向到标准输出。
  • 环境变量管理:
    • 查看:通过 echo $VAR 查看环境变量值。
    • 设置:通过 export VAR = VALUE 设置环境变量。

这些功能已在 buildCommandechoexport 实现中完成。

9. 主循环

Shell 的主循环负责持续运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main()
{
while (!quit)
{
rdirfilename = NULL; // 重置重定向信息
rdir = NONE;
interact(commandline, sizeof(commandline)); // 获取输入
int argc = splitstring(commandline, argv); // 解析命令
if (argc == 0) continue;
int n = buildCommand(argv, argc); // 处理内置命令
if (!n)
{
NormalExcute(argv); // 执行外部命令
}
}
return 0;
}
  • 重置重定向状态。
  • 获取并解析用户输入。
  • 处理内置命令或外部命令。
  • quit 变量控制退出(当前代码中未实现 exit 命令,可扩展)。

3. 源码一览

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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <ctype.h>
#include <fcntl.h>

// ======================== 宏定义区域 ========================
#define LEFT "[" // shell 显示用的左括号
#define RIGHT "]" // shell 显示用的右括号
#define LABLE "#" // shell 显示用的提示符号
#define DELIM " \t" // 分隔符(空格和制表符)
#define LINE_SIZE 1024 // 每一行命令最大长度
#define ARGC_SIZE 32 // 最大命令参数个数
#define EXIT_CODE 44 // 子进程执行失败退出码

// 重定向类型定义
#define NONE -1
#define IN_RDIR 0 // 输入重定向(<)
#define OUT_RDIR 1 // 输出重定向(>)
#define APPEND_RDIR 2 // 追加输出重定向(>>)

// ======================== 全局变量 ========================
int lastcode = 0; // 上一次命令的返回码
int quit = 0; // 控制是否退出 shell
extern char** environ; // 系统环境变量表
char commandline[LINE_SIZE]; // 存储用户输入命令
char* argv[ARGC_SIZE]; // 存储分割后的命令参数
char pwd[LINE_SIZE]; // 当前工作目录
char* rdirfilename = NULL; // 重定向的文件名
int rdir = NONE; // 当前重定向类型

char myenv[LINE_SIZE]; // 存储 export 设置的环境变量

// ======================== 工具函数 ========================

// 获取当前用户名
const char* getusername()
{
return getenv("USER");
}

// 获取主机名
const char* gethostname1()
{
return getenv("HOSTNAME");
}

// 获取当前路径(PWD)
void getpwd()
{
getcwd(pwd, sizeof(pwd));
}

// 解析重定向符号(< > >>)并设置 rdir 和 rdirfilename
void check_redir(char* cmd)
{
char* pos = cmd;
while (*pos)
{
if (*pos == '>')
{
if (*(pos + 1) == '>') // >> 追加重定向
{
*pos++ = '\0';
*pos++ = '\0';
while (isspace(*pos))
{
pos++;
}

rdirfilename = pos;
rdir = APPEND_RDIR;
break;
}
else // > 普通输出重定向
{
*pos = '\0';
pos++;
while (isspace(*pos))
{
pos++;
}

rdirfilename = pos;
rdir = OUT_RDIR;
break;
}
}
else if (*pos == '<') // < 输入重定向
{
*pos = '\0';
pos++;
while (isspace(*pos))
{
pos++;
}

rdirfilename = pos;
rdir = IN_RDIR;
break;
}
pos++;
}
}

// 显示 shell 提示符并获取用户输入命令
void interact(char* cline, int size)
{
getpwd();
printf(LEFT"%s@%s %s"RIGHT""LABLE" ", getusername(), gethostname1(), pwd);
char* s = fgets(cline, size, stdin); // 获取用户输入
assert(s); // 确保输入成功
(void)s; // 显式标记该变量已被“使用”,从而抑制编译器警告(这是一种代码规范技巧,表明有意忽略此变量)

cline[strlen(cline) - 1] = '\0'; // 去除换行符
check_redir(cline); // 检查是否有重定向
}

// 将命令行字符串根据空格分割成参数数组
int splitstring(char cline[], char* _argv[])
{
int i = 0;
argv[i++] = strtok(cline, DELIM);
while (_argv[i++] = strtok(NULL, DELIM)); // 使用 strtok 循环分割

return i - 1;
}

// 执行普通命令(fork+exec)
void NormalExcute(char* _argv[])
{
pid_t id = fork();
if (id < 0)
{
perror("fork");
return;
}
else if (id == 0) // 子进程执行命令
{
int fd = 0;
if (rdir == IN_RDIR)
{
fd = open(rdirfilename, O_RDONLY);
dup2(fd, 0); // 标准输入重定向
}
else if (rdir == OUT_RDIR)
{
fd = open(rdirfilename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
dup2(fd, 1); // 标准输出重定向
}
else if (rdir == APPEND_RDIR) {
fd = open(rdirfilename, O_CREAT | O_WRONLY | O_APPEND, 0666);
dup2(fd, 1); // 标准输出追加重定向
}

execvp(_argv[0], _argv); // 执行命令(从 PATH 路径中查找)
exit(EXIT_CODE); // exec 出错则退出
}
else // 父进程等待子进程结束
{
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid == id)
{
lastcode = WEXITSTATUS(status); // 记录子进程退出码
}
}
}

// 构建内置命令:cd, export, echo
int buildCommand(char* _argv[], int _argc)
{
if (_argc == 2 && strcmp(_argv[0], "cd") == 0)
{
chdir(argv[1]); // 切换工作目录
getpwd();
sprintf(getenv("PWD"), "%s", pwd); // 更新环境变量 PWD
return 1;
}
else if (_argc == 2 && strcmp(_argv[0], "export") == 0)
{
strcpy(myenv, _argv[1]);
putenv(myenv); // 添加或修改环境变量
return 1;
}
else if (_argc == 2 && strcmp(_argv[0], "echo") == 0)
{
if (strcmp(_argv[1], "$?") == 0)
{
printf("%d\n", lastcode); // 打印上一次命令的返回码
lastcode = 0;
}
else if (*_argv[1] == '$')
{
char* val = getenv(_argv[1] + 1); // 获取环境变量值
if (val)
{
printf("%s\n", val);
}
}
else
{
printf("%s\n", _argv[1]);
}
return 1;
}

// 针对 ls 增加颜色选项
if (strcmp(_argv[0], "ls") == 0)
{
_argv[_argc++] = "--color"; // 自动加上颜色显示
_argv[_argc] = NULL;
}
return 0;
}

// ======================== 主函数入口 ========================
int main()
{
while (!quit)
{
// 初始化重定向信息
rdirfilename = NULL;
rdir = NONE;

// 获取用户输入的命令行
interact(commandline, sizeof(commandline));

// 分割命令行为参数数组
int argc = splitstring(commandline, argv);
if (argc == 0) continue;

// 处理内置命令
int n = buildCommand(argv, argc);

// 执行普通命令
if (!n)
{
NormalExcute(argv);
}
}
return 0;
}

这个 Shell 虽简单,但展示了 Shell 的相对核心机制。当然还有一些其他功能没有实现,以及代码中还多场景考虑不周到、兼容性、健壮性等问题处理不够完美,还是等以后学深了再完善吧~