目录

1. 自主Shell命令行解释器

1.1 目标

1.2 实现原理

1.3 完整源码

2. 源码解析

2.1 输出命令行提示符

2.2 获取用户输入的命令

2.3 命令行分析

2.4 检测并处理内建命令

2.4.1 cd命令实现:

2.4.2 echo命令实现:

2.5 执行命令

2.6 扩展知识

2.6.1 环境变量表

2.6.2 别名映射表

2.6.3 重定向


1. 自主Shell命令行解释器

1.1 目标

  • 要能处理普通命令
  • 要能处理内建命令
  • 要能帮助我们理解内建命令/本地变量/环境变量这些概念
  • 要能帮助我们理解shell的允许原理

1.2 实现原理

考虑下⾯这个与shell典型的互动:

[root@localhost epoll]# ls
client.cpp readme.md server.cpp utility.h
[root@localhost epoll]# ps
 PID TTY TIME CMD
 3451 pts/0 00:00:00 bash
 3514 pts/0 00:00:00 ps

⽤下图的时间轴来表⽰事件的发⽣次序。其中时间从左向右。shell由标识为sh的⽅块代表,它随着时 间的流逝从左向右移动。shell从⽤⼾读⼊字符串"ls"。shell建⽴⼀个新的进程,然后在那个进程中运 ⾏ls程序并等待那个进程结束。

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

  1. 获取命令⾏
  2. 解析命令⾏
  3. 建⽴⼀个⼦进程(fork)
  4. 替换⼦进程(execvp)
  5. ⽗进程等待⼦进程退出(wait)

根据这些思路,和我们前⾯的学的技术,就可以⾃⼰来实现⼀个shell了。

1.3 完整源码

#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <unordered_map>

#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s %s]# "  // 命令行提示符格式

// 1.命令行参数表
#define MAXARGC 128 // 最大参数个数
int g_argc = 0;
char* g_argv[MAXARGC];// 参数数组,需要的是char*,因为每个元素都是一个字符串

// 2.环境变量表
#define MAX_ENVS 100
int g_envs = 0;
char* g_env[MAX_ENVS];

char cwd[1024];// 存储当前工作路径
char cwdenv[1024];// 用于更新PWD

int lastcode = 0;// 最近一个进程的退出码

// 别名功能
std::unordered_map<std::string, std::string> alias_list;

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

// 获取主机名i
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;
//}

// 获取当前工作路径(执行cd后可随之改变)
const char* GetPwd()
{
	const char* pwd = getcwd(cwd,sizeof(cwd));
	if(pwd != NULL)
	{
		snprintf(cwdenv,sizeof(cwdenv),"PWD=%s",cwd);
		putenv(cwdenv);// 更新PWD
	}
	return pwd == NULL?"None":pwd;
}

// 获取当前工作目录
std::string GetDirName(const char* pwd)
{
// shift+~:光标所在位置,大小写切换
#define SLASH "/"
	std::string dir = pwd;
	// '/','/a/b/c'
	// 只有根返回根
	if(dir == SLASH) return SLASH;

	// 反向查找最后一个'/'
	auto pos = dir.rfind(SLASH);
	// npos,没找到,处理无'/'的异常情况
	if(pos == std::string::npos) return "BUG?";

	return dir.substr(pos+1);
}

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

// 制作命令行提示符
void MakeCommandPrompt(char cmd_prompt[],int size)
{
	// snprintf 是 C/C++ 中用于格式化字符串并安全写入指定大小缓冲区的函数
	// str是字符串首地址,format是格式,...是填充信息
	// int snprintf(char *str, size_t size, const char *format, ...);
	//snprintf(cmd_prompt,size,FORMAT,GetUserName(),GetHostName(),GetPwd());
	
	// .c_str将string转换成const char*
	snprintf(cmd_prompt,size,FORMAT,GetUserName(),GetHostName(),GetDirName(GetPwd()).c_str());
}

// 环境变量表
void InitEnv()
{
	// shell启动的时候,从系统中获取环境变量
	// 我们的环境变量信息应该从shell统一来
	
	extern char** environ;
	// 1.获取环境变量
	// void *memset(void *s, int c, size_t n);
	memset(g_env,0,sizeof(g_env));
	g_envs = 0;
	for(int i = 0;environ[i];i++)
	{
		g_env[i] = (char*)malloc(strlen(environ[i])+1);// 申请空间
		strcpy(g_env[i],environ[i]);
		g_envs++;
	}
	g_env[g_envs++] = (char*)"HAHA=test";
	g_env[g_envs] = NULL;// 环境变量表最后一个元素置空

	// 2.导成环境变量
	for(int i=0;g_env[i];i++)
	{
		putenv(g_env[i]);
	}
	environ=g_env;
}

// 内建命令
void CD()
{
    // cd(默认会家目录)、cd 指定路径、cd -、cd~
	// 只有cd,返回家目录
	if(g_argc == 1)
	{
		std::string home = GetHome();
		// 无法获取家目录
		if(home.empty())
			return;
		// int chdir(const char *path);// 作用:改变当前进程的工作目录
		chdir(home.c_str());
	}
	else
	{
		std::string where = g_argv[1];
		if(where == "-")// 上一级目录
		{
			//TODU
		}
		else if(where == "~")
		{
			std::string home = GetHome();
		    // 无法获取家目录
		    if(home.empty())
		    	return;
		    // int chdir(const char *path);// 作用:改变当前进程的工作目录
		    chdir(home.c_str());
		}
		else
		{
			chdir(where.c_str());
		}
	}
}

void ECHO()
{
	if(g_argc == 2)
	{
	    // echo "xxxx"
	    // echo $?
	    // echo $PATH
		std::string opt = g_argv[1];
		if(opt == "$?")
		{
			// 打印最近一个进程的退出码
			std::cout<<lastcode<<std::endl;
			lastcode = 0;// 更新退出码
		}
		else if(opt[0] == '$')
		{
			// 打印环境变量
			std::string env_name = opt.substr(1);//截取string,去除$
			const char* env_value = getenv(env_name.c_str());
			// 获取成功,打印环境变量的值
			if(env_value)
				std::cout<<env_value<<std::endl;
		}
		else
		{
			// 打印字符串
			std::cout<<opt<<std::endl;
		}
	}
}

// 1.打印命令行提示符
void PrintCommandPrompt()
{
	char prompt[COMMAND_SIZE];
	MakeCommandPrompt(prompt,sizeof(prompt));
	printf("%s",prompt);
	fflush(stdout);// 没有'\n',刷新一下
}

// 2.获取用户输入的命令
bool GetCommandLine(char* out, int size)
{
	// "ls -a -l"->"ls -a -l\n"字符串
	// char *fgets(char *s, int size, FILE *stream);// 获取行输入
	char* cmd = fgets(out,size,stdin);
	if(cmd == NULL) return false;

	//'\n'也被读取, 清理'\n'
	out[strlen(out)-1] = 0;

	// 只按回车的情况
	if(strlen(out) == 0) return false;

	return true;
}

// 3.命令行分析 "ls -a -l" -> "ls" "-a" "-l"
bool CommandParse(char* commandline)
{
#define SEP " " // 定义分隔符
	// char *strtok(char *str, const char *delim);
	// 按分隔符拆解字符串,第一次传字符串,后续传NULL
	g_argc = 0;
	g_argv[g_argc++] = strtok(commandline,SEP);
	// 直到切除完毕,返回null
	while((bool)(g_argv[g_argc++] = strtok(nullptr,SEP)));
	g_argc--;

	return g_argc > 0?true:false;
}

// 打印分析完的命令
void PrintArgv()
{
	for(int i = 0;g_argv[i];i++)
		printf("g_argv[%d]->%s\n",i,g_argv[i]);
	printf("g_argc:%d\n",g_argc);
}

// 4. 检测并处理内键命令
bool CheckAndExecBuiltin()
{
	std::string cmd = g_argv[0];
	if(cmd == "cd")
	{
		CD();
		//lastcode = 0;// 更新退出码;
		return true;
	}
	else if(cmd == "echo")
	{
		ECHO();
		//lastcode = 0;// 更新退出码;
		return true;
	}
	return false;
}

// 5.执行命令
void 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);
	//(void)rid;// 使用一下,避免警告
	lastcode = WEXITSTATUS(status);
}

int main()
{
	// shell启动的时候,从系统中获取环境变量
	// 我们的环境变量信息应该从shell统一来
	InitEnv();

	while(true)
	{
		// 1.输出命令行提示符
    	PrintCommandPrompt();
    
    	// 2.获取用户输入的命令
    	char commandline[COMMAND_SIZE];
		// 获取失败则继续获取
		if(!GetCommandLine(commandline,sizeof(commandline)))
			continue;
    	// 获取成功输出信息
    	//if(GetCommandLine(commandline,sizeof(commandline)))
    	//	printf("echo:%s\n",commandline);
		
		// 3.命令行分析 "ls -a -l" -> "ls" "-a" "-l"
		if(!CommandParse(commandline))
			continue;
		//PrintArgv();
		
		// 4. 检测并处理内键命令
		if(CheckAndExecBuiltin())
			continue;

		// 5.执行命令
		Execute();

    }

	return 0;
}

2. 源码解析

2.1 输出命令行提示符

命令行提示符格式:

用户名、主机名、当前工作路径我们可以使用getenv()获取

然后通过snprintf函数将这些信息填充至我们的数组中

snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());

snprintf函数解析:int snprintf(char *str, size_t size, const char *format, ...);

snprintf 是安全的字符串格式化函数,核心功能是:

  • 按指定格式(如 FORMAT)将数据拼接成字符串;
  • 写入指定大小的缓冲区,且严格限制写入字节数(最多写 size-1 个字符,最后自动加 \0);
  • 彻底避免 sprintf 因内容超出缓冲区大小导致的内存越界、程序崩溃问题。
#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s %s]# "// 命令行提示符格式

// 获取用户名字
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;
}

// 制作命令行提示符
void MakeCommandLine(char cmd_prompt[], int size)
{
    snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), DirName(GetPwd()).c_str());
    //snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());
}

// 打印命令行提示符
void PrintCommandPrompt()
{
    char prompt[COMMAND_SIZE];
    MakeCommandLine(prompt, sizeof(prompt));
    printf("%s", prompt);
    fflush(stdout);
}

2.2 获取用户输入的命令

命令+选项由空格隔开,所以不能由scanf读取,所以我们使用fgets函数进行行读取

char *fgets(char *s, int size, FILE *stream);

fgets 是 C/C++ 中安全的行读取函数,核心功能是:

  • 从指定输入流(如 stdin 标准输入)读取一行字符;
  • 最多读取 size-1 个字符(或遇到换行符 / 文件结束),末尾自动加 \0 形成合法字符串;
  • 避免 gets 函数无长度限制导致的内存越界问题。

注意:行读取时也会读取到'\n',所以我们要清理一下:out[strlen(out)-1] = 0; // 清理\n

main函数逻辑中,读取失败 / 无有效命令时,跳过本次循环的后续逻辑(解析、执行),回到循环开头重新打印命令行提示符,等待用户输入。

bool GetCommandLine(char *out, int size)
{
    // ls -a -l => "ls -a -l\n" 字符串
    char *c = fgets(out, size, stdin);
    if(c == NULL) 
        return false;
    out[strlen(out)-1] = 0; // 清理\n

    if(strlen(out) == 0) 
        return false;

    return true;
}

// 2. 获取用户输入的命令
char commandline[COMMAND_SIZE];
if(!GetCommandLine(commandline, sizeof(commandline)))
    continue;

2.3 命令行分析

命令行分析就是将获取到的用户输入的命令: "ls -a -l" 解析成-> "ls" "-a" "-l"

我们可以使用strtok函数对字符串进行分割

char *strtok(char *str, const char *delim);

strtok 是 C/C++ 中字符串分割函数,核心功能是:

  • 按指定分隔符(如空格、逗号)将一个字符串拆分成多个子字符串
  • 首次调用传入待分割字符串,后续调用传入 NULL,持续分割剩余内容,直到返回 NULL 表示分割完毕;
  • 你的代码场景:将用户输入的命令行(如 ls -a -l)按空格拆分成 ls、-a、-l,为后续解析命令参数(g_argv)提供基础。
/ 1. 命令行参数表
#define MAXARGC 128
char *g_argv[MAXARGC];
int g_argc = 0; 

// 3. 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
bool CommandParse(char *commandline)
{
#define SEP " "
    g_argc = 0;
    // 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
    g_argv[g_argc++] = strtok(commandline, SEP);
    while((bool)(g_argv[g_argc++] = strtok(nullptr, SEP)));
    g_argc--;
    return g_argc > 0 ? true:false;
}

// 检查分析后的命令
void PrintArgv()
{
    for(int i = 0; g_argv[i]; i++)
    {
        printf("argv[%d]->%s\n", i, g_argv[i]);
    }
    printf("argc: %d\n", g_argc);
}

// 3. 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
if(!CommandParse(commandline))
     continue;
//PrintArgv();

2.4 检测并处理内建命令

我们主要实现以下两个内建命令:

  1. cd - 目录切换命令
  2. echo - 输出命令

2.4.1 cd命令实现:

cd命令有很多用法:

  • cd(无参数):进入用户家目录
  • cd 目录路径:进入指定目录
  • cd -:返回上一次所在的目录
  • cd ~:进入用户家目录
  • cd ..:返回上一级目录

我们这边实现一下cd(默认进入家目录)、cd 指定目录

  • 默认进入家目录

    • CheckAndExecBuiltin()检测到cd命令且参数个数为1时
    • 通过环境变量获取家目录路径
    • 使用chdir()修改当前进程工作目录
  • 进入指定目录

    • 当参数个数为2时
    • 直接使用chdir()切换到第二个参数指定的目录

2.4.2 echo命令实现:

echo命令也有很多用法:

  • echo "字符串":输出字符串
  • echo $?:输出最近进程的退出码
  • echo $环境变量:输出环境变量值

当参数个数为2时:

  • 输出进程退出码

    • 若第二个参数为$?
    • 从全局变量lastcode获取最近进程退出码并输出
    • 输出后将lastcode重置为0
  • 输出环境变量

    • 若第二个参数以$开头
    • 使用getenv()获取对应环境变量值并输出
  • 输出字符串

    • 不符合上述情况时
    • 直接输出第二个参数内容

当检测到内建命令时,系统会返回true。执行完if(CheckAndExecBuiltin()) continue;语句后,程序会重新进入while循环从头开始执行,而不会继续执行外部命令处理函数的逻辑。

// 最近一个进程的退出码
int lastcode = 0;

const char *GetHome()
{
    const char *home = getenv("HOME");
    return home == NULL ? "" : home;
}

//command
bool Cd()
{
    // cd argc = 1
    if(g_argc == 1)
    {
        std::string home = GetHome();
        if(home.empty()) return true;
        chdir(home.c_str());
    }
    else
    {
        std::string where = g_argv[1];
        // cd - / cd ~
        if(where == "-")
        {
            // Todu
        }
        else if(where == "~")
        {
            // Todu
        }
        else
        {
            chdir(where.c_str());
        }
    }
    return true;
}

void Echo()
{
    if(g_argc == 2)
    {
        // echo "hello world"
        // echo $?
        // echo $PATH
        std::string opt = g_argv[1];
        if(opt == "$?")
        {
            std::cout << lastcode << std::endl;
            lastcode = 0;
        }
        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;
        }
    }
}

bool CheckAndExecBuiltin()
{
    //如果内键命令做重定向,更改shell的标准输入,输出,错误
    std::string cmd = g_argv[0];
    if(cmd == "cd")
    {
        Cd();
        return true;
    }
    else if(cmd == "echo")
    {
        Echo();
        return true;
    }
    else if(cmd == "export")
    {
    }
    else if(cmd == "alias")
    {
       // std::string nickname = g_argv[1];
       // alias_list.insert(k, v);
    }

    return false;
}

// 检测并处理内键命令
if(CheckAndExecBuiltin())
	continue;

2.5 执行命令

命令执行的逻辑是通过进程替换实现的:首先创建子进程,子进程调用execvp(const char *file, char *const argv[])完成程序替换,并将命令行参数传递过去;随后父进程通过waitpid函数等待并回收子进程;最后使用WEXITSTATUS()函数更新最近进程的退出码(lastcode)。

int Execute()
{
    pid_t id = fork();
    if(id == 0)
    {
        //child
        execvp(g_argv[0], g_argv);
        exit(1);
    }
    int status = 0;
    // father
    pid_t rid = waitpid(id, &status, 0);
    if(rid > 0)
    {
        lastcode = WEXITSTATUS(status);
    }
    return 0;
}

// 执行命令
Execute();

2.6 扩展知识

2.6.1 环境变量表

  • 清空自定义环境变量数组 g_env,重置计数器 g_envs,声明系统环境变量全局指针 environ
  • 遍历系统 environ 中的所有环境变量,为每个变量分配独立内存并拷贝内容到 g_env,同步更新计数器;
  • g_env 中添加自定义环境变量 HAHA=for_test 作为测试,并按规范以 NULL 结尾;
  • 通过 putenv()g_env 中的变量注册为系统环境变量,最后将系统 environ 指向自定义的 g_env,完成环境变量表的接管。
// 2. 环境变量表
#define MAX_ENVS 100
char *g_env[MAX_ENVS];
int g_envs = 0;

void InitEnv()
{
    extern char **environ;
    memset(g_env, 0, sizeof(g_env));
    g_envs = 0;

    //本来要从配置文件来
    //1. 获取环境变量
    for(int i = 0; environ[i]; i++)
    {
        // 1.1 申请空间
        g_env[i] = (char*)malloc(strlen(environ[i])+1);
        strcpy(g_env[i], environ[i]);
        g_envs++;
    }
    g_env[g_envs++] = (char*)"HAHA=for_test"; //for_test
    g_env[g_envs] = NULL;

    //2. 导成环境变量
    for(int i = 0; g_env[i]; i++)
    {
        putenv(g_env[i]);
    }
    environ = g_env;
}

2.6.2 别名映射表

  • 利用unordered_map<std::string, std::string>的键值对特性,键存别名(如ll),值存真实命令(如ls -l),实现别名到原命令的快速映射(哈希查找效率高);
  • 解析alias 别名=原命令格式的输入,提取别名和原命令字符串,插入到alias_list中(需处理重复别名的覆盖逻辑);
  • 在命令行解析(CommandParse)后、执行命令前,检查当前命令(g_argv[0])是否存在于alias_list的键中,若存在则替换为对应的值,并重新拆分替换后的命令为g_argv数组(如ll替换为ls -l后,g_argv需更新为["ls", "-l", ...]);
  • 解析unalias 别名输入,从alias_list中删除对应键值对,支持清空所有别名(unalias -a);
  • 避免别名递归解析(如别名ls指向ls -a),解析时仅替换一次或限制递归深度;执行内置命令时跳过别名解析(如alias自身不触发映射)。
// 3. 别名映射表
std::unordered_map<std::string, std::string> alias_list;

2.6.3 重定向

请前往下一篇博客查看

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐