从零到一实现一个简易 Shell
这应该是个蛮有趣的话题:“什么是 Shell”?相信只要摸过计算机,对于操作系统(不论是 Linux、Unix 或者是 Windows)有点概念的朋友们大多听过这个名词,因为只要有“操作系统“那么就离不开 Shell 这个东西。不过,在讨论 Shell 之前,我们先来了解一下计算机的运行状况吧!举个例子来说:当你要计算机传输出来“音乐”的时候,你的计算机需要什么东西呢?
- 硬件:当然就是需要你的硬件有“声卡芯片”这个配备,否则怎么会有声音;
- 核心管理:操作系统的核心可以支持这个芯片组,当然还需要提供芯片的驱动程序啰;
- 应用程序:需要使用者(就是你)输入发生声音的指令啰!
这就是基本的一个输出声音所需要的步骤!也就是说,你必须要“输入”一个指令之后,“硬件“才会通过你下达的指令来工作!那么硬件如何知道你下达的指令呢?那就是 kernel(核心)的控制工作了!也就是说,我们必须要通过“Shell”将我们输入的指令与 Kernel 沟通,好让 Kernel 可以控制硬件来正确无误的工作!基本上,我们可以通过下面这张图来说明一下:
以上内容摘自《鸟哥的
Linux私房菜基础学习篇(第四版)》311 页。
1. Shell 的基本功能
一个基本的 Shell 需要具备以下功能:
- 提示符显示:显示当前用户、主机名和工作目录,例如
[user@host ~]#。 - 命令读取:从标准输入读取用户输入的命令。
- 命令解析:将输入的命令行分割为命令和参数。
- 命令执行:支持内置命令(如
cd、export)和外部命令(如ls、cat)。 - 重定向支持:支持输入重定向
<、输出重定向>和追加输出重定向>>。 - 环境变量管理:支持查看和设置环境变量。
- 退出机制:支持通过
exit退出Shell。
让我们一步步实现这些功能。
2. 实现 Shell 的提示符
Shell 的提示符是用户交互的起点,通常显示为 [用户名@主机名 当前目录]#。我们需要获取用户名、主机名和当前工作目录。
1. 获取用户信息
- 用户名:使用
getenv("USER")获取当前用户名。 - 主机名:使用
getenv("HOSTNAME")获取主机名。 - 当前目录:使用
getcwd()获取当前工作目录。
2. 定义提示符格式
我们通过宏定义设置提示符的格式:
#define LEFT "[" // 左括号
#define RIGHT "]" // 右括号
#define LABLE "#" // 提示符号
3. 实现 interact 函数
interact 函数负责显示提示符并读取用户输入:
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 函数按空格或制表符分割命令行:
#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中。
全局变量定义如下:
#define LINE_SIZE 1024
#define ARGC_SIZE 32
char commandline[LINE_SIZE]; // 存储用户输入
char* argv[ARGC_SIZE]; // 存储分割后的参数
4. 命令执行
Shell 需要区分两种命令:
- 内置命令:由
Shell直接处理,如cd、export、echo。 - 外部命令:通过
fork和exec执行系统中的可执行文件。
5. 内置命令实现
内置命令在 Shell 进程中直接执行,无需创建子进程。我们在 buildCommand 函数中实现:
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 执行:
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 函数中解析重定向:
#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以分割命令和文件名。 - 设置全局变量
rdir和rdirfilename。 interact函数调用check_redir进行解析。
8. 执行重定向
在 NormalExcute 中根据 rdir 处理重定向:
- 输入重定向:打开文件并重定向到标准输入(文件描述符 0)。
- 输出重定向:创建或截断文件并重定向到标准输出(文件描述符 1)。
- 追加输出重定向:创建或追加文件并重定向到标准输出。
- 环境变量管理:
- 查看:通过
echo $VAR查看环境变量值。 - 设置:通过
export VAR = VALUE设置环境变量。
- 查看:通过
这些功能已在 buildCommand 的 echo 和 export 实现中完成。
9. 主循环
Shell 的主循环负责持续运行:
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. 源码一览
#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 的相对核心机制。当然还有一些其他功能没有实现,以及代码中还多场景考虑不周到、兼容性、健壮性等问题处理不够完美,还是等以后学深了再完善吧~