跳到主要内容Linux Shell 模拟实现:手写简易 Bash 解释器 | 极客日志C算法
Linux Shell 模拟实现:手写简易 Bash 解释器
通过 C 语言模拟实现简易 Linux Shell 解释器,涵盖指令读取、分割、进程创建及程序替换流程。重点处理内建命令如 cd、export、echo 的特殊逻辑,以及文件重定向机制。利用 fork、execvp、waitpid 等系统调用构建基础框架,结合 dup2 实现标准流重定向,展示操作系统底层交互原理。
月光旅人0 浏览 Linux 系统主要由内核和外壳(Shell)组成。普通用户无法直接操作内核,实际交互是在 Shell 层面进行的。在 Shell 之上存在命令行解释器(如 bash),负责接收并执行用户输入的指令。本文旨在通过 C 语言模拟实现一个简易版的命令行解释器。
Bash 的本质
在动手写代码前,得先理解 Bash 到底是什么。Bash 本质上也是一个进程,而且是一个持续运行的进程。我们常看到的命令提示符,就是 Bash 不断打印输出的结果。
输入指令后,Bash 会创建子进程并进行程序替换。由于进程间具有独立性,可以同时存在多个 Bash 实例,这也是多用户登录 Linux 时能同时使用 Bash 的原因。
需求分析
Bash 的核心任务是 命令解释 + 程序替换。因此它至少需要具备以下功能:
- 接收指令(字符串)
- 对指令进行分割,提取有效信息
- 创建子进程,执行进程替换
- 子进程结束后,父进程回收僵尸进程
- 处理特殊指令(如 cd、export 等)
基本框架
抛开细节,简易版 Bash 的代码骨架如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <string.h>
#include <assert.h>
void split(char* argv[ARGV_SIZE], char* ps);
int main(){
while(1){
printf("[User@myBash default]$ ");
fflush(stdout);
id = fork();
(id == ){
execvp(argv[], argv);
();
}
status = ;
waitpid(id, &status, );
(WIFEXITED(status)){
(WEXITSTATUS(status) == )
(, argv[], );
} {
(, (status >> )&, status & );
}
}
;
}
pid_t
if
0
0
exit
168
int
0
0
if
if
168
printf
"%s: Error - %s\n"
0
"The directive is not yet defined"
else
printf
"process run fail! [code_dump]:%d [exit_signal]:%d\n"
7
1
0x7F
return
0
核心内容
1. 指令读取
读取指令前,需预估命令长度。常见命令如 ls -a -l 长度较短,但为避免极端情况,预设最大长度为 1024,并使用数组作为缓冲区存储。
#define COM_SIZE 1024
char command[COM_SIZE];
Linux 中的大部分指令由 指令 [选项] 构成,中间有空格。常规的 scanf 遇到空格会触发输入缓冲区刷新,无法正常读取整行。这里主要使用 fgets 逐行读取,它可以包含空格。
fgets(command, COM_SIZE, stdin);
assert(command);
(void)command;
command[strlen(command)-1] = '\0';
注意:可能存在读取失败的情况,用 assert 断言解决;fgets 会把最后的 \n 读进去,为了避免出错,需要手动置为 \0。
2. 指令分割
获得指令后,需要将其分割成有效的参数表。程序替换时需要使用 argv 表,这张表由 指令、选项、NULL 构成。利用指令间的空格进行分割即可。
C 语言提供了字符串分割函数 strtok,可以直接使用,也可以手动实现。分割好的指令段依次存入 argv 表中供后续程序替换使用。argv 表实际为一个指针数组,可以存储字符串。考虑到实际使用时指令段通常不多,这里设置为 64。
#define ARGV_SIZE 64
char* argv[ARGV_SIZE];
split(argv, command);
利用 strtok 实现指令分割函数 split():
#define DEF_CHAR " "
void split(char* argv[ARGV_SIZE], char* ps){
assert(argv && ps);
int pos = 0;
argv[pos++] = strtok(ps, DEF_CHAR);
while(argv[pos++] = strtok(NULL, DEF_CHAR));
argv[pos] = NULL;
}
注意:指令分割结束后,需要在 argv 表末尾添加 NULL。
3. 程序替换
获得可用的 argv 表后,就可以开始子进程的程序替换操作了。这里使用 execvp 函数,理由如下:
v 表示 vector,正好对应我们的 argv 表。
p 为 path,可以根据 argv[0](指令),在 PATH 环境变量中寻找该程序并替换。
当然也可以使用 execve 系统级替换函数,但 execvp 更方便。
pid_t id = fork();
if(id == 0){
execvp(argv[0], argv);
exit(168);
}
注意:程序替换成功后,exit(168) 语句不会执行。
特殊情况处理
为了让 myBash 更加完善,需要对一些特殊情况进行处理。
1. ls 显示高亮
系统中的 Bash 在面对 ls 等文件显示指令时,会将特殊文件做颜色高亮处理。例如在我的环境下,可执行文件显示为绿色。
实现原理很简单:在指令结尾加上 --color=auto 语句即可。在指令分割结束后,判断是否为 ls,如果是,就在 argv 表后尾插入语句 --color=auto。
if(strcmp(argv[0], "ls") == 0){
int pos = 0;
while(argv[pos++]);
argv[pos - 1] = (char*)"--color=auto";
argv[pos] = NULL;
}
注意:因为 argv 表中的元素类型为 char*,所以在尾插语句时需要进行类型转换;尾插语句后,需要再次添加结尾以确保安全。
2. 内建命令与 cd
内建命令比较特殊,不同于普通命令直接进行程序替换,内建命令需要进行特殊处理。比如 cd 命令调用系统级接口 chdir 让父进程(myBash)进行目录间的移动。
切换目录的本质是令当前 Bash 移动到另一个目录下,不能直接使用子进程,因为需要移动的是父进程。对于当前的 myBash 来说,如果交给子进程处理 cd,指令会被拆分后交给子进程,这会导致目录切换无效。
特殊情况特殊处理,同 ls 高亮一样,对指令进行识别。如果识别到 cd 命令,就直接调用 chdir 函数令当前进程 myBash 移动至指定目录即可(不必再创建子进程进行替换)。
if(strcmp(argv[0], "cd") == 0){
if(strcmp(argv[1], "~") == 0)
chdir("/home");
else if(strcmp(argv[1], "-") == 0)
chdir(getenv("OLDPWD"));
else if(argv[1])
chdir(argv[1]);
continue;
}
- 如果路径为空,不进行操作。
- 如果路径为
~,回到 home 目录。
cd - 指令依赖于 OLDPWD 这个环境变量,直接拿来用即可。
3. export 环境变量
export 用于添加环境变量,添加的是父进程 myBash 的环境变量,而非子进程,需要特殊处理。
解决方法:先将待添加的环境变量拷贝至缓冲区,再从缓冲区中读取,并调用 putenv 函数添加至环境变量表。
为何不能直接通过 putenv(argv[1]) 添加?因为 argv[1] 中的内容是不断变化的,不能直接使用。一般用户自定义的环境变量,在 Bash 中需要用户自己维护。最好的方案就是使用缓冲区进行环境变量的拷贝放置,因为缓冲区中的内容不易变。
错误体现:直接使用 putenv(argv[1]),导致第一次添加可能成功,但第二次添加后,第一次的环境变量会被覆盖。
#define COM_SIZE 1024
#define ARGV_SIZE 64
char myEnv[ARGV_SIZE][COM_SIZE];
int env_pos = 0;
char myEnv[COM_SIZE][ARGV_SIZE];
int env_pos = 0;
while(1){
if(strcmp(argv[0], "export") == 0){
if(argv[1]){
strcpy(myEnv[env_pos], argv[1]);
putenv(myEnv[env_pos++]);
}
continue;
}
}
除了 export 需要特殊处理外,env 查看环境变量表也需要特殊处理,因为此时的 env 查看的是父进程 (myBash) 的环境变量表,因此不需要将指令交给子进程处理。
void showEnv(){
extern char** environ;
int pos = 0;
for(; environ[pos]; printf("%s\n", environ[pos++]));
}
if(strcmp(argv[0], "env") == 0){
showEnv();
continue;
}
完善后,env 指令显示的才是正确进程的环境变量表。
4. echo 命令
echo 命令也属于内建命令,其能实现很多功能,比如:查看环境变量、查看最近一个进程的退出码、输出重定向等。其中前两个实现比较简单,最后一个需要基础 IO 相关知识。
echo 指令查看环境变量时,指令为 echo $ 环境变量。可以先判断 argv[1][0] 是否为 $,如果是,就直接根据 argv[1][1] 获取环境变量信息并打印即可。
if(strcmp(argv[0], "echo") == 0 && argv[1][0] == '$'){
if(argv[1] && argv[1][0] == '$')
printf("%s\n", getenv(argv[1]+1));
continue;
}
echo 还能查看退出码:echo $?,对上述程序进行改造即可实现。
很简单,父进程在等待子进程结束后,可以轻而易举地获取其退出码。将退出码保存在一个全局变量中,供 echo $? 指令使用即可。
if(strcmp(argv[0], "echo") == 0 && argv[1][0] == '$'){
if(argv[1] && argv[1][0] == '$'){
if(argv[1][1] == '?')
printf("%d\n", exit_code);
else
printf("%s\n", getenv(argv[1] + 1));
}
continue;
}
5. 重定向
重定向的本质是关闭默认输出/输入流,打开新的文件流,从其中写入/读取数据。
echo 字符串 > 文件:向文件中写入数据,写入前会先清空内容。
echo 字符串 >> 文件:向文件中追加数据,追加前不会先清空内容。
可执行程序 < 文件:从文件中读取数据给可执行程序。
所以实现重定向的关键在于判断指令中是否含有 >、>>、< 这三个字符。如果有,就具体问题具体分析,完成重定向。
- 判断字符串中是否含有目标字符,如果有,就置当前位置为
\0,其后半部分不参与指令分割。
- 后半部分就是文件名,在打开文件时需要使用。
- 根据不同的字符,设置不同的标记位,用于判断打开文件的方式(只写、追加、只读)。
- 判断是否需要进行重定向,如果需要,在子进程创建后,打开目标文件,并调用
dup2 函数进行标准流的替换。
O_RDONLY:只读
O_WRONLY | O_CREAT | O_TRUNC:只写
O_WRONLY | O_CREAT | O_APPEND:追加
int dup2(int oldfd, int newfd);
char* filename = checkDir(command);
enum redir {
REDIR_INPUT = 0,
REDIR_OUTPUT,
REDIR_APPEND,
REDIR_NONE
} redir_type = REDIR_NONE;
char* checkDir(char* command){
size_t end = strlen(command);
char* ps = command + end;
while(end != 0){
if(command[end - 1] == '>'){
if(command[end - 2] == '>'){
command[end - 2] = '\0';
redir_type = REDIR_APPEND;
return ps;
}
command[end - 1] = '\0';
redir_type = REDIR_OUTPUT;
return ps;
} else if(command[end - 1] == '<'){
command[end - 1] = '\0';
redir_type = REDIR_INPUT;
return ps;
}
if(*(command + end - 1) != ' ') ps = command + end - 1;
end--;
}
return NULL;
}
pid_t id = fork();
if(id == 0){
if(redir_type == REDIR_INPUT){
int fd = open(filename, O_RDONLY);
dup2(fd, 0);
} else if(redir_type == REDIR_OUTPUT){
int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0666);
dup2(fd, 1);
} else if(redir_type == REDIR_APPEND){
int fd = open(filename, O_WRONLY | O_CREAT | O_APPEND, 0666);
dup2(fd, 1);
}
execvp(argv[0], argv);
exit(168);
}
注意:当前实现的重定向只是最简单的标准流替换,实际重定向更加复杂。
完整代码实现
本次实现的 myBash 如下所示,拷贝编译运行后,即可使用。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <string.h>
#include <assert.h>
#include <sys/stat.h>
#include <fcntl.h>
#define COM_SIZE 1024
#define ARGV_SIZE 64
#define DEF_CHAR " "
void split(char* argv[ARGV_SIZE], char* ps){
assert(argv && ps);
int pos = 0;
argv[pos++] = strtok(ps, DEF_CHAR);
while(argv[pos++] = strtok(NULL, DEF_CHAR));
argv[pos] = NULL;
}
void showEnv(){
extern char** environ;
int pos = 0;
for(; environ[pos]; printf("%s\n", environ[pos++]));
}
enum redir {
REDIR_INPUT = 0,
REDIR_OUTPUT,
REDIR_APPEND,
REDIR_NONE
} redir_type = REDIR_NONE;
char* checkDir(char* command){
size_t end = strlen(command);
char* ps = command + end;
while(end != 0){
if(command[end - 1] == '>'){
if(command[end - 2] == '>'){
command[end - 2] = '\0';
redir_type = REDIR_APPEND;
return ps;
}
command[end - 1] = '\0';
redir_type = REDIR_OUTPUT;
return ps;
} else if(command[end - 1] == '<'){
command[end - 1] = '\0';
redir_type = REDIR_INPUT;
return ps;
}
if(*(command + end - 1) != ' ') ps = command + end - 1;
end--;
}
return NULL;
}
int main(){
char myEnv[COM_SIZE][ARGV_SIZE];
int env_pos = 0;
int exit_code = 0;
while(1){
char command[COM_SIZE];
printf("[User@myBash default]$ ");
fflush(stdout);
fgets(command, COM_SIZE, stdin);
assert(command);
(void)command;
command[strlen(command) - 1] = '\0';
char *filename = checkDir(command);
char* argv[ARGV_SIZE];
split(argv, command);
if(strcmp(argv[0], "ls") == 0){
int pos = 0;
while(argv[pos++]);
argv[pos - 1] = (char*)"--color=auto";
argv[pos] = NULL;
}
if(strcmp(argv[0], "cd") == 0){
if(strcmp(argv[1], "~") == 0)
chdir("/home");
else if(strcmp(argv[1], "-") == 0)
chdir(getenv("OLDPWD"));
else if(argv[1])
chdir(argv[1]);
continue;
}
if(strcmp(argv[0], "export") == 0){
if(argv[1]){
strcpy(myEnv[env_pos], argv[1]);
putenv(myEnv[env_pos++]);
}
continue;
}
if(strcmp(argv[0], "env") == 0){
showEnv();
continue;
}
if(strcmp(argv[0], "echo") == 0 && argv[1][0] == '$'){
if(argv[1] && argv[1][0] == '$'){
if(argv[1][1] == '?')
printf("%d\n", exit_code);
else
printf("%s\n", getenv(argv[1] + 1));
}
continue;
}
pid_t id = fork();
if(id == 0){
if(redir_type == REDIR_INPUT){
int fd = open(filename, O_RDONLY);
dup2(fd, 0);
} else if(redir_type == REDIR_OUTPUT){
int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0666);
dup2(fd, 1);
} else if(redir_type == REDIR_APPEND){
int fd = open(filename, O_WRONLY | O_CREAT | O_APPEND, 0666);
dup2(fd, 1);
}
execvp(argv[0], argv);
exit(168);
}
int status = 0;
waitpid(id, &status, 0);
exit_code = WEXITSTATUS(status);
if(WIFEXITED(status)){
if(exit_code == 168)
printf("%s: Error - %s\n", argv[0], "The directive is not yet defined");
} else {
printf("process run fail! [code_dump]:%d [exit_signal]:%d\n", (status >> 7)&1, status & 0x7F);
}
}
return 0;
}





相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Gemini 图片去水印
基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online