【Linux系统】自制简易shell的实现日志




在这里插入图片描述



注:本文使用的系统是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 函数的返回值是一个指向当前工作目录路径的指针。可能的返回值和情况如下:

  1. 成功

    返回指向该缓冲区的指针。

  2. 失败

    • 如果函数调用失败,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

常见的内建命令包括 cdexportunsetalias 等。

我们下面会讲解我们自制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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值