注:本文使用的系统是Ubuntu系统
声明:本文程序是按顺序往下编写的,为了简洁,方便说明,每一步骤仅仅会展示当前相较于上一步代码的修改部分(其他的未修改代码则省略),因此建议一步一步按顺序往下学习和操作
效果演示
从命令 ./Myshell
输入执行后,就开始了我们自制shell的运行
可以看到我们的自制 shell 已经在较大程度上模拟了系统 shell 的运行
一、准备工作
1、先在当前目录创建一个文件夹 test
:存放所有文件
2、进入该 test
文件夹
命令:cd test
3、创建 Myshell.cpp
文件:自制shell代码写在这
命令:touch Myshell.cpp
4、创建并配置 配置文件 Makefile
:用于程序运行
命令:cat > Makefile
重定向到 Makefail 文件,粘贴入下面这段内容,然后 ctrl+C
退出(退出时会出现符号 ^C
)
Myshell:Myshell.cpp
g++ -o $@ $^ -std=c++17
.PHONY:clean
clean:
rm -f Myshell
5、如何编译运行 Myshell.cpp
文件
(1)输入命令 make
:系统会将 Myshell.cpp
文件编译运行,生成一个可执行文件 Myshell
(2)输入 ./Myshell
:即可运行
之后我们的自制shell,就会写到 Myshell.cpp
文件中,通过命令:make --> ./Myshell
的方式编译运行
二、主逻辑
1、打印命令行提示符
PrintCommandLine();
2、获取用户命令
GetCommandLine();
3、分析命令
ParseCommandLine();
4、执行命令
ExecuteCommandLine();
三、具体实现逻辑
1、构建基础框架
#include<iostream>
using namespace std;
int main(){
while(true)
{
// 4个主步骤
//1、打印命令提示符
PrintCommandLine();
//2、获取用户命令
GetCommandLine();
//3、分析命令
ParseCommandLine();
//4、执行命令
ExecuteCommandLine();
}
return 0;
}
2、主函数一:打印命令提示符
2.1 分析命令提示符各部分
- mine:本次会话登录的用户名
- @:分隔符
- iZwz9asjf1ddlt6fy1ebqpZ:主机名
在Ubuntu系统中通过 hostname 命令获取
在Centos系统中通过获取环境变量HOSTNAME:getenv(“HOSTNAME”);
-
: 冒号分隔
-
~/linux-learning/_10_15_Lesson_of_My_shell:冒号后面的这串字符是 当前进程所处路径
-
$:表示当前用户为普通用户
(#:表示当前用户为超级用户 root)
2.2 获取命令提示符各部分
主要方法:通过环境变量获取
(1)获取主机名
使用 C 语言标准库函数 gethostname
-
使用
<unistd.h>
中的gethostname
函数来获取主机名。 -
int gethostname(char *name, size_t len); // 获取主机名放到数组name中,需要传入数组name的大小
-
关于返回值
On success, zero is returned. On error, -1 is returned, and errno is set appropriately.
(2)获取cwd
使用 C 语言标准库函数 getcwd
char *getcwd(char *buf, size_t size);
参数
buf
:指向一个缓冲区的指针,用于存放当前工作目录的路径字符串。size
:指定缓冲区的大小。
返回值
getcwd
函数的返回值是一个指向当前工作目录路径的指针。可能的返回值和情况如下:
-
成功:
返回指向该缓冲区的指针。
-
失败:
- 如果函数调用失败,
getcwd
返回NULL
。此时可以通过调用strerror
函数来获取错误信息 - 如果提供的缓冲区不够大(即
size
太小),getcwd
也会返回NULL
- 如果函数调用失败,
(3)获取环境变量
通过函数 getenv
获取环境变量
函数原型
#include <stdlib.h>
char *getenv(const char *name);
参数
- name:一个指向字符串的指针,表示要获取的环境变量的名称。
返回值
- 如果找到指定的环境变量,getenv 返回指向该环境变量值的指针。
- 如果没有找到指定的环境变量,getenv 返回 NULL。
我们需要获取当前工作目录 PWD
getenv("PWD")
2.3 固定格式
系统的命令提示符的格式为 :"用户名@主机名:当前工作路径$ "
为了增加辨识度,我们自制shell的格式为:"[用户名@主机名:当前工作路径]% "
- 将 $ 换成 %
- 外面套一层 […]
如何固定格式输出:
snprintf 函数
:固定格式输出
#include <stdio.h> int snprintf(char *str, size_t size, const char *format, ...); 参数 - str:指向缓冲区的指针,格式化的字符串将被写入该缓冲区。(存放结果的数组) - size:缓冲区的大小(以字节为单位),必须足够大以容纳格式化的字符串加上终止符 \0。(结果数组大小) - format:格式化字符串,与 printf 中的格式化字符串相似。(目标格式字符串) - ...:可变参数列表,对应格式化字符串中的各个占位符。 返回值 snprintf 函数返回实际需要的字符数(不包括终止符 \0),如果返回值大于或等于 size,则表示输出被截断。
2.4 最终实现
🚩逻辑实现
获取用户名:string GetUser()
获取主机名:string GetHostname()
获取当前工作路径cwd:string Getpwd()
形成完整的命令提示符:string MakeCommandLine()
打印最终结果:void PrintCommandLine()
#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<unistd.h>
#include<cstring>
#include<string.h>
using namespace std;
#define hostnameSize 64
#define standard_size 128
char hostname[hostnameSize];
char command_Line[standard_size];
// 获取用户名
string GetUser(){
string s = getenv("USER");
return s.empty() ? "None" : s;
}
// 获取主机名
string GetHostname(){
int ret = gethostname(hostname, hostnameSize);
if(ret == -1)
{
//获取失败,错误码处理
return "None";
}
string s = hostname;
return s;
}
// 获取当前工作路径cwd
string Getpwd(){
string pwd = getenv("PWD");
return pwd.empty() ? "None" : pwd;
}
// 形成完整的命令提示符
string MakeCommandLine()
{
// 为了区分,使用 % 表示当前用户,同时使用[]框住
// 注意:命令提示符的 % 之后需要加一个空格
snprintf(command_Line, standard_size, "[%s@%s:%s]%% ", GetUser().c_str(), GetHostname().c_str(), Getpwd().c_str());
return command_Line;
}
// 打印最终结果
void PrintCommandLine()
{
printf("%s", MakeCommandLine().c_str());
fflush(stdout);
}
int main(){
while(true)
{
// 4个主步骤
//1、打印命令提示符
PrintCommandLine();
sleep(1);
//2、获取用户命令
// GetCommandLine();
//3、分析命令
// ParseCommandLine();
//4、执行命令
// ExecuteCommandLine();
}
return 0;
}
🚩运行结果
后面两行即为我们的结果:用户名、主机名、工作路径、几个分隔符 都对上了
3、主函数二:获取用户命令
通过用户输入,获取用户命令,处理命令字符串
因为命令+选项就会存在空格,因此不能使用 scanf 和 cin,因为这两种方式遇到空格停止读取
选择使用 fgets
实现版本一:
char command_buff[standard_size]; // 获取用户输入命令
//2、获取用户命令
bool GetCommandLine()
{
char* res = fgets(command_buff, standard_size, stdin);
if(res == NULL) return false; // 若返回NULL,说明获取失败
if(strlen(command_buff) == 0) return false; // 没获取到,返回false
return true;
}
int main(){
while(true)
{
//2、获取用户命令
if(!GetCommandLine()) continue; // 获取命令失败则重新获取
// 打印测试刚刚获取到的命令
printf("%s\n", command_buff);
}
return 0;
}
本代码运行结果
【疑问】
输入一条命令字符串,就执行打印
为什么打印时,系统已经帮我们换行了??
答:输入命令后,按回车,这里既表示结束输入命令,也表示输入了一个换行符
因此我们的自制命令会”自动“换行
【疑问】输入空串,即无输入时,我们的shell居然也会打印换行?
本质是:我们 fgets 读取我们输入字符串时,同时回车键也被读取了,因此字符串数组 command_buff 中一直有一个换行符在结尾,需要处理一下
实现版本二:
解决回车会产生换行符问题
//2、获取用户命令
bool GetCommandLine()
{
char* res = fgets(command_buff, standard_size, stdin);
if(res == NULL) return false; // 若返回NULL,说明获取失败
// 处理末尾换行符
command_buff[strlen(command_buff)-1] = '\0'; / 优化
if(strlen(command_buff) == 0) return false; // 没获取到,返回false
return true;
}
本代码运行结果
这下正常了
4、主函数三:分析命令
本函数的作用是将命令和选项分割,分别存储在 argv 数组中
strtok 函数
:按照分隔符切割字符串,分割命令和选项
- 首次切割:需要提供 str
- 后续切割:每次切割后会自动保留上次切割的位置,后续切割传 null
- 循环切割
- 切割到末尾:返回NULL
使用样例:
#include <stdio.h>
#include <string.h>
int main() {
char str[] = "apple,orange,banana,grape";
const char *delim = ",";
char *token = strtok(str, delim);
while (token != NULL) {
printf("%s\n", token);
token = strtok(NULL, delim);
}
return 0;
}
代码实现
#define standard_size 128
int argc = 0; // 初始化参数计数器 argc 为 0
char* argv[standard_size]; // 初始化参数数组 argv,最大支持 128 个参数
// 3、分析命令
void ParseCommandLine()
{
// 每轮置为空
argc = 0; // 重置参数计数器 argc 为 0
memset(argv, 0, sizeof(argv)); // 将 argv 数组清零,确保每个元素都为 nullptr
// 使用 strtok 函数按空格分割命令缓冲区 command_buff
// 第一次调用 strtok 时需要提供待分割的字符串和分隔符
argv[argc++] = strtok(command_buff, " "); // 获取第一个参数,并递增 argc
// 循环调用 strtok 直至没有更多参数
// 使用 nullptr 作为参数告诉 strtok 从上次分割的位置继续分割
while ((bool)(argv[argc++] = strtok(nullptr, " "))) ;
// 最后一次循环结束后,argc 会多 1,因此需要减 1
argc--;
}
// 用于测试:打印 argv 数组
void debug()
{
// 遍历 argv 数组并打印每个非空参数
for (int i = 0; argv[i]; ++i) {
printf("argv[%d]: %s\n", i, argv[i]); // 打印参数索引和参数值
}
}
int main(){
while(true)
{
//3、分析命令
ParseCommandLine();
debug(); // 用于测试:打印 argv 数组
}
return 0;
}
本代码运行结果
确实按照空格,将命令名和选项一一分割开,并存储到 argv 数组中了
5、主函数四:执行命令
想要执行命令,核心操作是创建子进程来执行该命令的代码:fork + exec
注:命令的代码是系统本身配置文件写好的,无需自己手搓,只需要 exec 调用执行系统的命令可执行文件即可,这里仅仅实现 shell 表层的功能
执行命令的本质是,通过 exec 函数调用对应路径下的程序文件,向程序的main函数中传入 argv数组等参数,执行该命令的程序代码
通过 exec 系列函数进行程序替换时,通常会利用子进程来执行
#include<sys/types.h>
#include<sys/wait.h>
// 4、执行命令
void ExecuteCommandLine()
{
pid_t id = fork(); // 创建一个新的子进程
if (id == 0) // 如果是子进程
{
// 替换当前进程映像为由 argv[0] 指定的可执行文件
execvp(argv[0], argv);
exit(0); // 如果替换失败,退出子进程
}
int status = 0; // 用于存储子进程的退出状态
pid_t rid = waitpid(id, &status, 0); // 等待子进程结束,并获取其退出状态
}
int main()
{
while (true)
{
// 4、执行命令
ExecuteCommandLine(); // 执行命令行
}
return 0;
}
本代码运行结果
到这一步,我们自制的shell就能运行系统命令了!!!
阶段性胜利!!,我们的自制shell已经完成 90% 了,后面是一些补充与优化:如内建命令
6、内建命令
1. 何为内建命令
内建命令(builtin command)是指在某些命令行解释器(如 shell)中直接实现的功能,而不是通过外部程序来执行的命令。内建命令通常直接嵌入到 shell 的源代码中,因此执行效率更高,并且不需要创建新的进程来执行(即无需通过创建子进程执行的命令),减少进程切换的开销。
2. 如何识别内建命令
在 Bash 中,可以通过 type
命令来判断一个命令是否为内建命令:
type cd
如果命令是内建命令,type
会输出 “is a shell builtin”。如果是外部命令,则会输出 “is hashed” 或者显示命令的完整路径。
cd is a shell builtin
常见的内建命令包括 cd
、export
、unset
、alias
等。
我们下面会讲解我们自制shell需要添加使用的内建命令
注意,下面的演示,基本都是在自制shell中演示的,因为内建命令也是针对自制shell设计的,因此称为重写某某内建命令
3. 重写内建命令 cd
🚩分析现象
当你使用自制的shell,执行命令 cd ..
想要跳转上级目录时,发现跳转不了
cd
命令只会改变执行该命令的进程的工作目录,而不会影响其他进程的工作目录。
这意味着如果子进程执行 cd
命令,那么更改的只是子进程的工作目录,而不会影响父进程(即我们的自制shell)的工作目录,这就与我们的目地背道而驰了
因此,cd
命令就必须要写成内建命令(即让父进程(如我们自制shell的进程)自己执行的命令)
🚩代码实现
// 自制shell的 cd 命令
bool run_cd()
{
if (argc == 2) // 检查是否有两个参数(命令本身和目录路径)
{
chdir(argv[1]); // 改变当前工作目录为 argv[1] 指定的路径
return true; // 成功改变目录
}
else
{
return false; // 参数数量不正确,返回 false
}
}
// 检查是否是内建命令,如果是则执行自己的逻辑 run_cd()
bool Check_And_Exec_BuiltinCommand()
{
if (strcmp(argv[0], "cd") == 0) // 检查命令是否为 "cd"
{
return run_cd(); // 如果是 "cd" 命令,执行 run_cd() 函数
}
return false; // 如果不是 "cd" 命令,返回 false
}
int main(){
while(true)
{
//...
// 内建命令
Check_And_Exec_BuiltinCommand();
//4、执行命令
ExecuteCommandLine();
}
return 0;
}
chdir
函数
用于改变当前进程的工作目录的一个标准库函数。
#include <unistd.h> int chdir(const char *path); // path: 表示要切换到的新目录的路径
- 如果修改成功,
chdir
返回 0。- 如果修改失败,返回 -1
🚩本代码运行结果
cd 起作用了
但是,命令提示符的路径没有变
可以发现,cd 修改了路径,但是环境变量PWD没有被修改
因此,我们命令提示符上的路径就不能通过环境变量PWD更新路径,因为环境变量自己都要被更新
string Getpwd(){
string pwd = getenv("PWD"); // 因此这里使用错误,这里不能直接通过环境变量PWD更新路径
return pwd.empty() ? "None" : pwd;
}
4. 关于命令提示符中 获取实时路径
🚩实时路径通过getcwd函数获取
getcwd
函数用于获取当前工作目录的完整路径。
#include <unistd.h> char *getcwd(char *buf, size_t size);
参数
buf
:一个指向字符数组的指针,用于存储当前工作目录的路径。size
: