
在操作系统的世界里,Shell 作为用户与系统交互的桥梁,扮演着至关重要的角色。无论是资深的开发者,还是对系统运维感兴趣的新手,了解 Shell 的工作原理和实现机制都能极大地加深对操作系统底层运行逻辑的理解。
本文将带领大家深入探索简易 Shell 的实现过程,从最基础的打印命令行提示符开始,逐步实现读取用户输入、切割指令、执行普通命令以及处理内建指令等核心功能。每一步都配有详细的代码解析和关键知识点说明,不仅让你知其然,更能知其所以然。通过阅读本文,你将掌握 Shell 实现的关键技术,理解其中涉及的编程技巧和系统调用原理,同时也能体会到从无到有构建一个实用工具的乐趣与成就感。
为了方便理解我把源代码发给大家。(单击蓝色字体)
#define MARK ":"
#define LABLE "$"
#define LINE_SIZE 1024
char commandline[LINE_SIZE]; // 用于存储输入的命令行
char pwd[LINE_SIZE]; // 当前工作目录
// 获取用户名
const char* getusername()
{
return getenv("USER");
}
// 获取主机名
const char* gethostname()
{
return getenv("HOSTNAME");
}
// 获取当前工作目录并进行格式化
void getpwd()
{
getcwd(pwd, sizeof(pwd)); // 获取当前工作目录
const char* home = getenv("HOME"); // 获取用户主目录
if (home != NULL && strcmp(pwd, home) == 0)
{
// 如果当前目录等于用户主目录,则返回 "~"
strcpy(pwd, "~");
}
else if (home != NULL && strncmp(pwd, home, strlen(home)) == 0)
{
// 如果当前目录是用户主目录的子目录,则替换主目录部分为 "~"
static char relative_path[LINE_SIZE];
snprintf(relative_path, sizeof(relative_path), "~%s", pwd + strlen(home));
strcpy(pwd, relative_path);
}
}
int main()
{
getpwd();
printf("%s@%s"MARK"%s"LABLE" ", getusername(), gethostname(), pwd);
scanf("%s", commandline); return 0;
}MARK 和 LABLE 定义了提示符中的分隔符 : 和 $,用于提示符的格式化输出。LINE_SIZE 设置了缓冲区大小(1024),用于存储命令行输入和当前工作目录。commandline和 pwd: commandline: 存储从用户输入读取的命令行。pwd: 存储当前工作目录。getusername() 和 gethostname() 分别通过调用 getenv() 获取环境变量 USER 和 HOSTNAME,用于表示用户名和主机名。getpwd()
获取当前工作目录并将其格式化:
HOME),用 ~ 替代完整路径。~ 替代主目录部分。main() 函数中调用 getpwd() 获取当前工作目录,然后通过 printf()
按以下格式打印提示符:

scanf() 等待用户输入命令,将输入存储到 commandline 中。// 交互式模式,显示提示符并读取用户输入
void interact(char* cline, int size)
{
getpwd(); // 获取当前工作目录
printf("%s@%s"MARK"%s"LABLE" ", getusername(), gethostname(), pwd); // 显示提示符
char* s = fgets(cline, size, stdin); // 读取用户输入
assert(s); // 确保输入不为空
printf("echo: %s\n", s); // 测试能否读取成功
cline[strlen(cline)-1] = '\0'; // 去掉末尾的换行符
}
int main()
{
while(1) // 循环运行直到用户退出
{
// 1. 获取用户输入的命令行
interact(commandline, sizeof(commandline));
}
return 0;
}scanf()而换成了fgets()?scanf 通常用于格式化输入,但它在处理用户输入时存在一些显著的缺点:
scanf 默认以空格、制表符或换行符作为输入的分隔符,因此只能读取一个单词(或无空格的字符串)。scanf 无法正确读取整行命令。scanf 不会自动限制输入长度,如果用户输入超出缓冲区大小,就会导致缓冲区溢出,进而引发未定义行为甚至安全漏洞。加调试输出 printf("echo: %s\n", s) 是为了测试程序的输入功能是否正常运行,具体验证以下几点:
s == NULL,则说明 fgets() 未能成功读取输入(可能是因为输入错误或用户直接按下 Ctrl+D)。cline: 调试结果:

用户通过键盘输入时,输入内容会带有换行符(\n),这是因为按下回车键会在输入的末尾自动添加一个换行符。
例如,用户输入 ls -l 后,缓冲区中的数据实际是:
ls -l\n\0\n 是换行符。\0 是字符串的终止符。在处理命令时,换行符通常是多余的:
strcmp(command, "exit") 会返回不匹配,因为字符串实际是 "exit\n"。#define DELIM " " // 分隔符
#define ARGC_SIZE 32
char *argv[ARGC_SIZE]; // 用于存储命令行参数
// 将输入的命令行分割为参数数组
int splitstring(char* cline, char* _argv[])
{
int i = 0;
argv[i++] = strtok(cline, DELIM); // 分割第一个参数
while(_argv[i++] = strtok(NULL, DELIM)); // 分割剩余的参数
return i - 1; // 返回参数个数
}
int main()
{
while(1) // 循环运行直到用户退出
{
// 1. 获取用户输入的命令行
interact(commandline, sizeof(commandline));
// 2. 分割命令行字符串为指令和参数
int argc = splitstring(commandline, argv);
// 调试代码
for(int i = 0; argv[i]; i++)
{
printf("[%d]->%s\n", i, argv[i]);
}
// 3. 如果没有输入指令(空行),跳过本次循环
if(argc == 0) continue;
}
return 0;
}strtok 的函数原型char *strtok(char *str, const char *delim);char *str: NULL 表示继续上一次的分割。const char *delim: \0 结尾的字符串,表示分割的分隔符集合(例如,空格、逗号等)。NULL。注意点:循环结束时,i 的值比实际的参数个数多 1(因为最后一次分割返回 NULL)。
因此,用 i - 1 表示参数的实际个数。
调试结果:

#define EXIT_CODE 44
extern char **environ; // 环境变量
int lastcode = 0; // 上次命令的退出状态
int quit = 0; // 是否退出
// 执行外部命令
void normalExcute(char* _argv[])
{
pid_t id = fork(); // 创建子进程
if(id < 0)
{
perror("fork"); // 创建失败,打印错误信息
return;
}
if(id == 0) // 子进程
{
execvpe(_argv[0], _argv, environ);
exit(EXIT_CODE);
}
else // 父进程
{
int status = 0;
waitpid(id, &status, 0); // 等待子进程完成
if (WIFEXITED(status)) // 检查子进程是否正常退出
{
lastcode = WEXITSTATUS(status); // 获取子进程的退出状态
}
}
}
int main()
{
while(!quit) // 循环运行直到用户退出
{
// 1. 获取用户输入的命令行
interact(commandline, sizeof(commandline));
// 2. 分割命令行字符串为指令和参数
int argc = splitstring(commandline, argv);
// 3. 如果没有输入指令(空行),跳过本次循环
if(argc == 0) continue;
// 4.执行普通外部指令
normalExcute(argv);
}
return 0;
}功能:执行外部命令并处理子进程的退出状态。
关键点:
fork 创建子进程。execvpe 替换子进程执行映像。waitpid 等待子进程结束并处理退出状态。结果:命令执行结果的退出码存储在 lastcode 中供后续使用。
调试结果:

char myenv[LINE_SIZE]; // 用于存储导出的环境变量
// 构建内置命令
int buildCommand(char* _argv[], int _argc)
{
// 内置命令:cd
if(_argc == 2 && strcmp(_argv[0], "cd") == 0)
{
getpwd();
chdir(_argv[1]); // 改变工作目录
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 添加 --color 参数
if(strcmp(_argv[0], "ls") == 0)
{
_argv[_argc++] = "--color";
_argv[_argc] = NULL; // 确保参数数组以 NULL 结束
}
return 0; // 返回未处理标志
}
int main()
{
while(!quit) // 循环运行直到用户退出
{
// 1. 获取用户输入的命令行
interact(commandline, sizeof(commandline));
// 2. 分割命令行字符串为指令和参数
int argc = splitstring(commandline, argv);
// 3. 如果没有输入指令(空行),跳过本次循环
if(argc == 0) continue;
// 4. 尝试执行内置命令
int n = buildCommand(argv, argc);
// 5. 如果不是内置命令,执行普通外部指令
if(!n) normalExcute(argv);
}
return 0;
}该函数 buildCommand 的功能是处理内置命令,包括 cd、export 和 echo,并对特定外部命令(如 ls)添加额外参数。如果输入的命令属于内置命令范围,函数会执行相应逻辑并返回已处理标志;否则返回未处理标志,交由其他部分(如外部命令执行器)处理。
处理内置命令:cd
if (_argc == 2 && strcmp(_argv[0], "cd") == 0)cd,且参数个数为 2(命令名 + 目标目录)。chdir 改变当前工作目录为用户指定的路径(_argv[1])。PWD,同步当前工作目录的变化。cd 命令是由 bash 本身去做,而不是创建一个子进程去做,故而需要改变的是当前可执行程序的工作目录,并且需要将环境变量中的 PWD 改变。
处理内置命令:export
else if (_argc == 2 && strcmp(_argv[0], "export") == 0)export,且参数个数为 2。变量=值 格式字符串存储到全局变量 myenv。putenv 将该变量添加到环境变量中。commandline当中,只要当下一次输入指令,上一次定义的环境变量就会被清空。putenv 添加环境变量,并不是把对应的字符串深拷贝到系统的环境变量表当中,而是把该字符串的地址保存在系统的环境变量表中(浅拷贝)。因此我们要确保保存环境变量字符串的那个地址里的环境变量不会被修改,所以我们需要为用户输入的环境变量,也就是那一串字符串单独开辟一块空间进行存储,保证在内次重新输入指令的时候,不会影响到之前用户添加的环境变量。所以我们需要定义一个二维数组用于存储导出的环境变量(这里只简单地分配了一维数组)。

注意看,连续两次的写入导致第一次的定义的环境变量被覆盖了。
处理内置命令:echo
else if (_argc == 2 && strcmp(_argv[0], "echo") == 0)echo,且参数个数为 2。

故意写成 ll (没有定义的),导致子进程退出,退出码刚好是44。
处理外部命令:自动为 ls 添加 --color 参数
if (strcmp(_argv[0], "ls") == 0)cls。--color 参数,用于增强可读性(适用于 Linux 的 ls 命令)。NULL 结束。
总结:说了这么久的环境变量,那么请问我们登录的时候,系统中的 shell 的环境变量又是从哪里来的呢?答案是 Bash。那么 Bash 的环境变量又是从何而来?当然是系统自带的目录文件中写入的。
通过以上对简易 Shell 实现过程的详细讲解,相信大家对 Shell 的工作流程和实现细节已经有了较为全面的认识。从命令行提示符的设计,到输入指令的处理,再到不同类型命令的执行,每一个环节都凝聚着操作系统与编程的智慧。
虽然本文实现的 Shell 只是一个简化版本,但其中涉及的技术和思想为进一步探索更复杂、功能更强大的 Shell,乃至深入理解操作系统的运行机制奠定了坚实的基础。希望大家能将所学应用到实际开发或探索中,不断挖掘操作系统的奥秘。如果在阅读过程中有任何疑问或想法,欢迎在评论区交流分享,也别忘了点赞、收藏并持续关注后续更多精彩的技术内容!