【Linux篇】自主Shell命令行解释器

📌 个人主页: 孙同学_

🔧 文章专栏: Liunx

💡 关注我,分享经验,助你少走弯路!

文章目录

    • [1. 获取用户名的接口](#1. 获取用户名的接口)
    • [2. 等待用户输入接口](#2. 等待用户输入接口)
    • [3. 将上述代码进行面向对象式的封装](#3. 将上述代码进行面向对象式的封装)
    • [4. 命令行解析](#4. 命令行解析)
    • [5. 执行命令](#5. 执行命令)
    • [6. 路径切割](#6. 路径切割)
    • [7. 解决cd命令路径不变](#7. 解决cd命令路径不变)
    • [8. 解决cd后环境变量未发生变化](#8. 解决cd后环境变量未发生变化)
    • [9. echo命令](#9. echo命令)
    • [10. 获取环境变量](#10. 获取环境变量)
    • [11. 总结](#11. 总结)
    • 12.代码实现

1. 获取用户名的接口

通过环境变量来获取

我们需要用到的接口getenv


cpp 复制代码
//获取用户名
const char* GetUserName()
{
	const char* name = getenv("USER");
	return name == NULL ? "None" : name;
}

 //获取主机名
const char* GetHostName()
{
	const char* hostname = getenv("HOSTNAME");
	return hostname == NULL ? "None" : hostname;
}

 //获取当前路径
const char* GetPwd()
{
	const char* pwd = getenv("PWD");
	return pwd == NULL ? "None" : pwd;
}

2. 等待用户输入接口

当我们没有输入时,我们会发现命令行会卡在这里等待我们输入

我们也让我们自己的命令行能等待输入

我们可以采用fgets以文件形式读取一行,也可以使用gets读取一行字符串

我们接下来进行C/C++混编的方式,因为我们后面会用到系统调用,而这些系统调用都是用C写的,如果我们纯用C++来实现的话可能会要适配某些接口。

我们下来用fgets实现


效果展示:

我们会发现最后多了一个空行,这里为什么会多一个空行呢?因为我们在输入完字符串后还按了一次回车,我们不想让它有这一行空行该怎么办?我们在输入字符串后后面还会有个\n,比如我们输入的是"ls -a -l"最后再按一次回车就变成了"ls -a -l \n",我们只需要输入完之后把最后的\n置为0就好了

效果展示:

🔖小tips: 这里会不会求出的字符串长度为0,然后再-1发生越界呢?答案是不会的,因为我们最后至少还要敲一次回车键,所以这个字符串的最小长度为1

3. 将上述代码进行面向对象式的封装

我们先认识一个新的接口snprintf

cpp 复制代码
//制作命令行提示符
void MakeCommandline(char com_prompt[], int size)
{
	snprintf(com_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());//我们想让"[%s@%s %s]# "以后能随便调整,所以我们define一下
}

//打印命令行提示符
void PrintCommandline()
{
	char prompt[COMMAND_SIZE];
	MakeCommandline(prompt, sizeof(prompt));//先制作
	printf("%s", prompt);//再打印
	fflush(stdout);
}

 //获取用户输入
bool GetCommandline(char* out, int size)
{
	//"ls -a -l "=>"ls -a -l \n"
	const char* c = fgets(out, size, stdin);//从标准输入里获取,放到out当中
	if (c == NULL) return 1;
	out[strlen(out) - 1] = 0;//清理\n 
	if (strlen(out) == 0) return false; //对于我们用户来说,有可能获取到的字符串的长度为0,为0直接return false 
	return true; //否则return true
}

int main()
{
	//printf("[%s@%s %s]# ",GetUserName(),GetHostName(),GetPwd());
    //1. 打印命令行提示符
	PrintCommandline();

	//2.获取用户输入
	char commandline[COMMAND_SIZE];//定义一个数组
	if (GetCommandline(commandline, sizeof(commandline)))//如果获取成功
	{
		printf("echo %s\n", commandline);//回显一下我们输入的内容,用作测试
	}

	return 0;
}

我们在shell中可以一直输入,我们的程序输入一次就结束了,所以shell永远不退出。我们应当不断地获取用户输入。

4. 命令行解析

我们在传字符串的时候不能"ls -a -l"整体传入,我们要将传入的字符串进行变形,将这一个字符串拆成"ls" "-a" "-l"。而且我们的命令行也不能在shell中直接替换,而要创建子进程。我们将字符串切成这样那么如何快速的找到每一个元素呢?命令行参数表

将打散的字符串以NULL结尾放到g_argv[]里面。在这里又来认识一个新的接口strtok

这个接口第一次切的时候第一个参数传的是要分割的字符串的起始地址,如果要接着切的话第一个参数就必须传NULL,当切除完毕返回值就为空表示没有字符串了。

分隔符是const char*所以我们不能传单引号,而要传双引号

cpp 复制代码
//命令行分析
bool CommandParse(char* commandline)
{
#define SEP " "
	g_argc = 0; //每次进来初始化为0
	//"ls -a -l" => "ls" "-a" "-l"
	g_argv[g_argc++] = strtok(commandline, SEP);

	while (g_argv[g_argc++] = strtok(nullptr, SEP));//再次想切的话传commandline就不对了,再要切历史字符串就得把它设为nullptr,再次切的话分隔符依旧是SEP,如果切成了返回的就是下一个字符串的起始地址
	//为什么可以这样切呢?因为再次切字串时,它一直切一直切最后就会会变成NULL,切成NULL首先会把g_argv数组置为NULL,符合命令行参数表的设定,NULL也会作为while的条件判断,最后就直接结束了
		 //并且g_argc也会统计出命令行参数有多少个
	g_argc--;//因为NULL也被统计到了里面
	return true;
}

 //测试形成的表结构
void PrintArgv()
{
	for (int i = 0; i < g_argc; i++)
	{
		printf("argv[%d]->%s\n", i, g_argv[i]);
	}
	printf("argc:%d", g_argc);
}

5. 执行命令

由于我们当前的进程还有自己的任务,所以我们将执行命令交给子进程来完成,那么就需要程序替换,execvp

cpp 复制代码
//执行命令
int Execute()
{
	pid_t id = fork();
	if (id == 0)
	{
		//子进程
		execvp(g_argv[0], g_argv);
		exit(1);
	}
	//父进程
	pid_t rid = waitpid(id, nullptr, 0);
	return 0;
}

6. 路径切割

系统的路径名只有一个,我们自己写的会跟一长串

所以我们对路径进行切割。 C++中有个命令rfind从后向前找,substr截字符串

cpp 复制代码
//路径切割
std::string DirName(const char* pwd)
{
#define SLASH "/"
	std::string dir = pwd;
	if (dir == SLASH) return SLASH;
	auto pos = dir.rfind(SLASH);
	if (pos == std::string::npos) return "BUG";//这里表示没有找到/
	return dir.substr(pos + 1);
}

7. 解决cd命令路径不变

到目前我们会发现我们执行cd命令时路径不发生改变,因为目前所有的命令都是子进程执行的,子进程改变路径时改的是自己的pwd,父进程bash的环境变量并没有改变,我们真正要改的是父进程的路径,因为把父进程的路径改了往后再创建子进程所有的子进程就会在新的路径下执行,因为所有的子进程的PCB都是拷贝父进程的PCB,因此cd这样的命令不能让子进程去执行,而要让父进程亲自执行,这种命令叫做内建命令

如何让bash亲自去执行呢?我们先来认识一个新的接口chdir


cpp 复制代码
 //处理cd命令
bool cd()
{
	if (g_argc == 1) //表明只是cd,没有带任何参数
	{
		std::string home = GetHome();//将home的路径拿过来
		if (home.empty())  return true;//如果是空就相当于环境变量获取失败了
		chdir(home.c_str());//走到这里不为空,就把当前路径切换成家路径了
	}
	else
	{
		std::string where = g_argv[1];
		//"cd -" / "cd ~"
		if (where == "-")
		{

		}
		else if (where == "~")
		{

		}
		else
		{
			chdir(where.c_str());
		}
	}
}
 //检测并处理内建命令
bool CheckAndExecBuiltin()
{
	std::string cmd = g_argv[0];
	if (cmd == "cd")//如果是内建命令
	{
		cd();
		return true;//是内建命令
	}
	return false;//否则不是内建命令
}

8. 解决cd后环境变量未发生变化

我们再切换路径后会发现路径变了,但是环境变量中的路径并没有变。原因是路径发生变化后环境变量没有进行刷新,所以我们要将新的路径更新到环境变量中。这里我们来认识一个系统调用getcwd,获取当前进程的工作路径。

cpp 复制代码
 //获取当前路径
const char* GetPwd()
{
	//const char* pwd = getenv("PWD");
	const char* pwd = getcwd(cwd, sizeof(cwd));
	if (pwd != NULL)
	{
		snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);//获得环境变量
		putenv(cwdenv);//将环境变量导给当前进程
	}
	return pwd == NULL ? "None" : pwd;
}

9. echo命令

echo命令也是内建命令,我们可以用它echo "hello"在屏幕上打印,echo $?查看上个进程的退出码,echo $PATH查看环境变量。


cpp 复制代码
//处理echo命令
void Echo()
{
	if (g_argc == 2)//意思是echo后面必须得跟东西
	{
		//echo "heool world"
		   //echo $?
			//echo $PATH
		std::string opt = g_argv[1];
		if (opt == "$?")//输出上一个程序退出的退出码
		{
			std::cout << lastcode << std::endl;
			lastcode = 0;//lastcode清零
		}
		else if (opt[0] == '$')//如果第一个字符时是$,那么说明查环境变量的内容了
		{
			std::string env_name = opt.substr(1);//去掉$后的内容就是环境变量的名字
			const char* env_value = getenv(env_name.c_str());
			if (env_value)
				std::cout << env_value << std::endl;
		}
		else
		{
			std::cout << opt << std::endl;
		}
	}
}

10. 获取环境变量

shell启动时需要从系统中获取环境变量,但是我们还做不到从配置文件中读,今天我们直接从父shell中拿就可以了,我们自己维护一张环境变量表,然后将表导进环境变量空间就行。

我们又用到一个接口environ

cpp 复制代码
 //初始化环境变量表
void InitEnv()
{
	extern char** environ;//声明一个环境变量所对应的信息
	memset(g_env, 0, sizeof(g_env));//将表中的信息全部置为0
	g_envs = 0;

	//本来要从配合文件中来
	//今天从父shell中来
	//1. 获取环境变量
	for (int i = 0; environ[i]; i++)
	{
		g_env[i] = (char*)malloc(strlen(environ[i]) + 1);
		//1.2拷贝
		strcpy(g_env[i], environ[i]);//把父进程环境变量里的值拷贝给g_env
		g_envs++;
	}
	g_env[g_envs] = NULL;
	//2.导入环境变量
	for (int i = 0; g_env[i]; i++)
	{
		putenv(g_env[i]);
	}
	environ = g_env;
	//3.clean清理
}

11. 总结

用下图的时间轴来表示事件的发生次序。其中时间从左向右。shell由标识为sh的方块代表,它随着时间的流逝从左向右移动。shell从用户读入字符串"ls"shell建立一个新的进程,然后在那个进程中运行ls程序并等待那个进程结束。

然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序并等待这个进程结束。所以要写一个shell,需要循环以下过程:

  • 获取命令行
  • 解析命令行
  • 建立⼀个子进程(fork)
  • 替换子进程(execvp)
  • 父进程等待子进程退出(wait)

12.代码实现

cpp 复制代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<cstdlib>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<unordered_map>

#define COMMAND_SIZE 1024 
#define FORMAT "[%s@%s %s]# "
#define MAXARGC 128

//命令行参数表
char* g_argv[MAXARGC];//全局的命令行参数表
int g_argc = 0;

//环境变量表
#define MAX_ENVS 100
char* g_env[MAX_ENVS];//正常情况下shell启动时应该从配置文件中读取环境变量来填充这张环境变量表,也就是说当shell启动时就应该拿环境变量表来初始化它
int g_envs = 0;//环境变量的个数

//别名映射表
std::unordered_map<std::string,std::string> alias_list;


//定义一个cwd
char cwd[1024];
char cwdenv[1024];//当前工作路径的env

//最新程序的退出码 last exit cide
int lastcode = 0;



//获取用户名
const char* GetUserName()
{
    const char* name = getenv("USER");
    return name == NULL ? "None" : name;
}

//获取主机名
const char* GetHostName()
{
    const char* hostname = getenv("HOSTNAME");
    return hostname == NULL ? "None" : hostname;
}

//获取当前路径
const char* GetPwd()
{
    //const char* pwd = getenv("PWD");
    const char* pwd = getcwd(cwd,sizeof(cwd));
    if(pwd != NULL)
    {
        snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);//获得环境变量
        putenv(cwdenv);//将环境变量导给当前进程
    }
    return pwd == NULL ? "None" : pwd;
}

//获取家目录
const char* GetHome()
{
    const char* home = getenv("HOME");
    return home == NULL ? "" : home;
}

//初始化环境变量表
void InitEnv()
{
    extern char** environ;//声明一个环境变量所对应的信息
    memset(g_env, 0, sizeof(g_env));//将表中的信息全部置为0
    g_envs = 0;

    //本来要从配合文件中来
    //今天从父shell中来
    //1. 获取环境变量
    for(int i = 0;environ[i];i++)
    {
        //1.1申请空间
        g_env[i] = (char*)malloc(strlen(environ[i])+1);
        //1.2拷贝
        strcpy(g_env[i],environ[i]);//把父进程环境变量里的值拷贝给g_env
        g_envs++;
    }
    g_env[g_envs] = NULL;
    //2.导入环境变量
    for(int i = 0;g_env[i];i++)
    {
        putenv(g_env[i]);
    }
    environ = g_env;
    //3.clean清理
}

//处理cd命令
bool cd()
{
    if(g_argc == 1) //表明只是cd,没有带任何参数
    {
        std::string home = GetHome();//将home的路径拿过来
        if(home.empty())  return true;//如果是空就相当于环境变量获取失败了
        chdir(home.c_str());//走到这里不为空,就把当前路径切换成家路径了
    }
    else
    {
        std::string where = g_argv[1];
        //"cd -" / "cd ~"
        if(where == "-")
        {
        
        }
        else if(where == "~")
        {
        
        }
        else
        {
            chdir(where.c_str());
        }
    }
    return true;
}

//处理echo命令
void Echo()
{
    if(g_argc == 2)//意思是echo后面必须得跟东西
    {
        //echo "heool world"
        //echo $?
        //echo $PATH
        std::string opt = g_argv[1];
        if(opt == "$?")//输出上一个程序退出的退出码
        {
            std::cout << lastcode <<std::endl;
            lastcode = 0;//lastcode清零
        }
        else if(opt[0] == '$')//如果第一个字符时是$,那么说明查环境变量的内容了
        {
            std::string env_name = opt.substr(1);//去掉$后的内容就是环境变量的名字
            const char* env_value = getenv(env_name.c_str());
            if(env_value)
                std::cout << env_value << std::endl;
        }
        else
        {
            std::cout << opt << std::endl;
        }
    }   

}

//路径切割
std::string DirName(const char* pwd)
{
#define SLASH "/"
    std::string dir = pwd;
    if(dir == SLASH) return SLASH;
    auto pos = dir.rfind(SLASH);
    if(pos == std::string::npos) return "BUG";//这里表示没有找到/
    return dir.substr(pos+1);
}

//制作命令行提示符
void MakeCommandline(char com_prompt[],int size)
{
    //snprintf(com_prompt, size, FORMAT,GetUserName(),GetHostName(),GetPwd());//我们想让"[%s@%s %s]# "以后能随便调整,所以我们define一下
    snprintf(com_prompt, size, FORMAT,GetUserName(),GetHostName(),DirName(GetPwd()).c_str());//我们想让"[%s@%s %s]# "以后能随便调整,所以我们define一下
}

//打印命令行提示符
void PrintCommandline()
{
    char prompt[COMMAND_SIZE];
    MakeCommandline(prompt,sizeof(prompt));//先制作
    printf("%s",prompt);//再打印
    fflush(stdout);
}

//获取用户输入
bool GetCommandline(char* out,int size)
{
    //"ls -a -l "=>"ls -a -l \n"
    const char *c = fgets(out,size,stdin);//从标准输入里获取,放到out当中
    if(c == NULL) return 1;
    out[strlen(out)-1] = 0;//清理\n 
    if(strlen(out) == 0) return false; //对于我们用户来说,有可能获取到的字符串的长度为0,为0直接return false 
    return true; //否则return true
}

//命令行分析
 bool CommandParse(char* commandline)
{
#define SEP " "
    g_argc = 0; //每次进来初始化为0
    //"ls -a -l" => "ls" "-a" "-l"
    g_argv[g_argc++] = strtok(commandline,SEP);
    
   while((bool)(g_argv[g_argc++] = strtok(nullptr,SEP)));//再次想切的话传commandline就不对了,再要切历史字符串就得把它设为nullptr,再次切的话分隔符依旧是SEP,如果切成了返回的就是下一个字符串的起始地址
   //为什么可以这样切呢?因为再次切字串时,它一直切一直切最后就会会变成NULL,切成NULL首先会把g_argv数组置为NULL,符合命令行参数表的设定,NULL也会作为while的条件判断,最后就直接结束了
   //并且g_argc也会统计出命令行参数有多少个
   g_argc--;//因为NULL也被统计到了里面
   return g_argc > 0 ? true : false;
}

//测试形成的表结构
void PrintArgv()
{
    for(int i = 0;i < g_argc; i++)
    {
        printf("argv[%d]->%s\n",i,g_argv[i]);
    }
    printf("argc:%d\n",g_argc);
}

//检测并处理内建命令
bool CheckAndExecBuiltin()
{
    std::string cmd = g_argv[0];
    if(cmd == "cd")//如果是内建命令
    {
        cd(); 
        return true;//是内建命令
    }
    else if(cmd == "echo")
    {
        Echo();
        return true;//内建命令执行完后return true
    }
    else if(cmd == "export")
    {
        
    }
    else if(cmd == "alias")//说明是一个别名
    {
        //std::string nickname = g_argv[1];
       // alisa_list.insert(k,v);
    }
    return false;//否则不是内建命令
}

//执行命令
int Execute()
{
    pid_t id = fork();
    if(id == 0)
    {
        //子进程
        execvp(g_argv[0],g_argv);
        exit(1);
    }
    int status = 0;//退出信息
    //父进程
    pid_t rid = waitpid(id, &status, 0);
    if(rid > 0) //说明等成功了
    {
        lastcode = WEXITSTATUS(status);//最后一个进程退出的退出码
    }
    return 0;
}
int main()
{
    //shell启动时需要从系统中获取环境变量
    //我们的环境变量信息应该从父shell中统一来
    InitEnv();


    while(true)
    {
         //printf("[%s@%s %s]# ",GetUserName(),GetHostName(),GetPwd());
         //1. 打印命令行提示符
         PrintCommandline();

         //2.获取用户输入
         char commandline[COMMAND_SIZE];//定义一个数组
         if(!GetCommandline(commandline,sizeof(commandline)))//如果获取失败continue重新获取
            continue;    
        
         // printf("echo %s\n",commandline);//回显一下我们输入的内容,用作测试

         //3."ls -a -l" -> "ls" "-a" "-l"
         //命令行分析
         if(!CommandParse(commandline))//分析commandline中的命令行
            continue;//只有解析成功后才往下走,否则就继续
         //PrintArgv();

         // 检查别名 直接将commandline替换成别名

         //4.检测并处理内建命令
         if(CheckAndExecBuiltin())
            continue;//若为内建命令就不用创建子进程了,由bash亲自执行
         //5. 执行命令
         Execute();

   }
    return 0;
}

👍 如果对你有帮助,欢迎:

  • 点赞 ⭐️
  • 收藏 📌
  • 关注 🔔
相关推荐
雨白6 小时前
Android 快捷方式实战指南:静态、动态与固定快捷方式详解
android
hqk6 小时前
鸿蒙项目实战:手把手带你实现 WanAndroid 布局与交互
android·前端·harmonyos
LING7 小时前
RN容器启动优化实践
android·react native
恋猫de小郭9 小时前
Flutter 发布官方 Skills ,Flutter 在 AI 领域再添一助力
android·前端·flutter
0xDevNull13 小时前
Linux切换JDK版本详细教程
linux
进击的丸子13 小时前
虹软人脸服务器版SDK(Linux/ARM Pro)多线程调用及性能优化
linux·数据库·后端
Kapaseker14 小时前
一杯美式搞懂 Any、Unit、Nothing
android·kotlin
黄林晴14 小时前
你的 Android App 还没接 AI?Gemini API 接入全攻略
android
恋猫de小郭1 天前
2026 Flutter VS React Native ,同时在 AI 时代 VS Native 开发,你没见过的版本
android·前端·flutter
冬奇Lab1 天前
PowerManagerService(上):电源状态与WakeLock管理
android·源码阅读