文章目录
- 前言
- 一、打印命令行提示符
- 代码功能概述
- 二、读取键盘输入的指令
- 2.1 为什么不继续使用`scanf()`而换成了`fgets()`?
- 2.2 调试输出的意义
- 2.3 为什么需要去掉换行符?
- 三、指令切割
- 补充知识: `strtok` 的函数原型
- 四、普通命令的执行
- 代码功能概述
- 五、内建指令执行
- 代码功能概述
- 结语
前言
在操作系统的世界里,Shell 作为用户与系统交互的桥梁,扮演着至关重要的角色。无论是资深的开发者,还是对系统运维感兴趣的新手,了解 Shell 的工作原理和实现机制都能极大地加深对操作系统底层运行逻辑的理解。
本文将带领大家深入探索简易 Shell 的实现过程,从最基础的打印命令行提示符开始,逐步实现读取用户输入、切割指令、执行普通命令以及处理内建指令等核心功能。每一步都配有详细的代码解析和关键知识点说明,不仅让你知其然,更能知其所以然。通过阅读本文,你将掌握 Shell 实现的关键技术,理解其中涉及的编程技巧和系统调用原理,同时也能体会到从无到有构建一个实用工具的乐趣与成就感。
为了方便理解我把源代码发给大家。(单击蓝色字体)
一、打印命令行提示符
#define MARK ":"
#define LABLE "$"
#define LINE_SIZE 1024char 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;
}
2.1 为什么不继续使用scanf()
而换成了fgets()
?
scanf
通常用于格式化输入,但它在处理用户输入时存在一些显著的缺点:
scanf
默认以空格、制表符或换行符作为输入的分隔符,因此只能读取一个单词(或无空格的字符串)。- 在交互式 Shell 中,用户输入的命令往往由多个部分组成(如命令和参数),
scanf
无法正确读取整行命令。 scanf
不会自动限制输入长度,如果用户输入超出缓冲区大小,就会导致缓冲区溢出,进而引发未定义行为甚至安全漏洞。
2.2 调试输出的意义
加调试输出 printf("echo: %s\n", s)
是为了测试程序的输入功能是否正常运行,具体验证以下几点:
- 提示符显示后,用户是否能输入数据:
- 如果
s == NULL
,则说明fgets()
未能成功读取输入(可能是因为输入错误或用户直接按下Ctrl+D
)。
- 如果
- 输入是否被正确存储到缓冲区
cline
:- 打印输入内容以确保其正确性。
- 缓冲区大小是否足够:
- 如果输入过长,可能会导致缓冲区溢出或截断。
调试结果:
2.3 为什么需要去掉换行符?
-
用户通过键盘输入时,输入内容会带有换行符(
\n
),这是因为按下回车键会在输入的末尾自动添加一个换行符。 -
例如,用户输入
ls -l
后,缓冲区中的数据实际是:ls -l\n\0
\n
是换行符。\0
是字符串的终止符。
-
在处理命令时,换行符通常是多余的:
- 它会影响字符串的比较。例如,
strcmp(command, "exit")
会返回不匹配,因为字符串实际是"exit\n"
。 - 如果直接打印字符串,换行符会造成多余的空行。
- 某些函数(如文件名或路径相关函数)可能会因为换行符导致逻辑错误。
- 它会影响字符串的比较。例如,
三、指令切割
#define DELIM " " // 分隔符
#define ARGC_SIZE 32char *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
结尾的字符串,表示分割的分隔符集合(例如,空格、逗号等)。
- 一个以
- 返回值:
- 成功:返回一个指向分割后的子字符串(token)的指针。
- 失败:如果没有更多的子字符串可以返回,则返回
NULL
。
注意点:循环结束时,i
的值比实际的参数个数多 1
(因为最后一次分割返回 NULL
)。
因此,用 i - 1
表示参数的实际个数。
调试结果:
四、普通命令的执行
#define EXIT_CODE 44extern 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)
{// 内置命令:cdif(_argc == 2 && strcmp(_argv[0], "cd") == 0){getpwd();chdir(_argv[1]); // 改变工作目录sprintf(getenv("PWD"), "%s", pwd); // 更新环境变量PWDreturn 1; // 返回已处理标志}// 内置命令:exportelse if(_argc == 2 && strcmp(_argv[0], "export") == 0){strcpy(myenv, _argv[1]); // 保存环境变量putenv(myenv); // 设置环境变量return 1; // 返回已处理标志}// 内置命令:echoelse 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。 - 功能:
- 如果参数为
"$?"
,显示上一个命令的退出状态(从全局变量lastcode
获取)。 - 如果参数以
$
开头,显示对应环境变量的值。 - 否则,直接打印参数内容。
- 如果参数为
- 测试:
故意写成ll
(没有定义的),导致子进程退出,退出码刚好是44。
- 检查用户输入的命令是否是
-
处理外部命令:自动为
ls
添加--color
参数if (strcmp(_argv[0], "ls") == 0)c
- 检查用户输入的命令是否是
ls
。 - 功能:
- 自动为命令添加
--color
参数,用于增强可读性(适用于 Linux 的ls
命令)。 - 确保参数数组以
NULL
结束。
- 自动为命令添加
- 测试:
总结:说了这么久的环境变量,那么请问我们登录的时候,系统中的 shell 的环境变量又是从哪里来的呢?答案是 Bash。那么 Bash 的环境变量又是从何而来?当然是系统自带的目录文件中写入的。
- 检查用户输入的命令是否是
结语
通过以上对简易 Shell 实现过程的详细讲解,相信大家对 Shell 的工作流程和实现细节已经有了较为全面的认识。从命令行提示符的设计,到输入指令的处理,再到不同类型命令的执行,每一个环节都凝聚着操作系统与编程的智慧。
虽然本文实现的 Shell 只是一个简化版本,但其中涉及的技术和思想为进一步探索更复杂、功能更强大的 Shell,乃至深入理解操作系统的运行机制奠定了坚实的基础。希望大家能将所学应用到实际开发或探索中,不断挖掘操作系统的奥秘。如果在阅读过程中有任何疑问或想法,欢迎在评论区交流分享,也别忘了点赞、收藏并持续关注后续更多精彩的技术内容!
今
天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,17的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是17前进的动力!