Re:Linux系统篇(二十七)进程篇·十二:从零构建属于你的自定义 Shell 解释器


◆ 博主名称: 小此方-CSDN博客 大家好,欢迎来到小此方的博客。
⭐️Linux系列个人专栏: 【主题曲】Linux
⭐️此方的GitHub: github_此方
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


文章目录

  • 概要&序論
  • 零、准备工作
    • [0.1 部分接口使用方式回顾](#0.1 部分接口使用方式回顾)
      • [0.1.1 size_t rfind(const string& str, size_t pos = npos);](#0.1.1 size_t rfind(const string& str, size_t pos = npos);)
      • [0.1.2 char* fgets(char* str, int n, FILE* stream);](#0.1.2 char* fgets(char* str, int n, FILE* stream);)
      • [0.1.3 char* strtok(char* str, const char* delimiters);](#0.1.3 char* strtok(char* str, const char* delimiters);)
      • [0.1.4 int snprintf(char* s, size_t n, const char* format, ...);](#0.1.4 int snprintf(char* s, size_t n, const char* format, ...);)
    • [0.2 基础架构](#0.2 基础架构)
  • [一、第一阶段实现:Shell 可以处理普通命令](#一、第一阶段实现:Shell 可以处理普通命令)
    • [1.1 核心实现代码](#1.1 核心实现代码)
  • [二、第二阶段实现:Shell 可以处理内建命令](#二、第二阶段实现:Shell 可以处理内建命令)
    • [2.1 内建命令的原理与处理](#2.1 内建命令的原理与处理)
  • 三、第三阶段实现:环境变量的处理
    • [3.1 第二、三阶段合并版完整实现](#3.1 第二、三阶段合并版完整实现)
    • [3.2 回答一些细节问题](#3.2 回答一些细节问题)
      • [3.2.1 为什么在接收命令行的时候不适用 scanf 而要用 fgets?](#3.2.1 为什么在接收命令行的时候不适用 scanf 而要用 fgets?)
      • [3.2.2 为什么使用字符数组而不是 string?](#3.2.2 为什么使用字符数组而不是 string?)
      • [3.2.3 思考一个悖论:我们的模拟实现 shell 能不能执行 su -?](#3.2.3 思考一个悖论:我们的模拟实现 shell 能不能执行 su -?)
      • [3.2.4 为什么要有内建命令?内建命令与特有内建命令](#3.2.4 为什么要有内建命令?内建命令与特有内建命令)
        • [1. 改变 Shell 自身状态的绝对必要性](#1. 改变 Shell 自身状态的绝对必要性)
        • [2. 保证命令在任何情况下都可用](#2. 保证命令在任何情况下都可用)
        • [3. 兼容性与 POSIX 标准](#3. 兼容性与 POSIX 标准)
      • [3.2.5 chdir() 的小细节:只改路径,不改变量](#3.2.5 chdir() 的小细节:只改路径,不改变量)
      • [3.2.6 命令加不加 -f 的区别](#3.2.6 命令加不加 -f 的区别)

概要&序論

  Hello 大家好我是此方 。本文是Linux系统,进程篇的最终篇。为了更深刻的理解命令行的工作原理,我们要融合前面进程篇一到十一全部的知识,写一个简单的Shell命令行程序,自定义Shell的编写按照深入程度一共可以分为四个阶段。本文主要讲前三个阶段。第四阶段我们放在Linux系统文件篇中讲解。好的,我们开始吧

零、准备工作

0.1 部分接口使用方式回顾

  首先我必须带大家复习一下Shell中会用到但是你99%的概率已经忘得一干二净的接口:

0.1.1 size_t rfind(const string& str, size_t pos = npos);

  • 参数说明
    • str:指定要查找的目标子字符串或字符。
    • pos:开始逆向搜索的起始索引位置。默认值为 npos,表示从字符串的末尾开始向前查找。
  • 返回值 :返回目标子串最后一次出现时的首字符索引;若未找到匹配内容,则返回 std::string::npos

0.1.2 char* fgets(char* str, int n, FILE* stream);

  • 参数说明
    • str:指向一个字符数组(缓冲区)的指针,用于存储读取到的字符串。
    • n:本次读取的最大字符数(包含自动添加的空字符 \0)。通常传入缓冲区的实际大小,函数最多读取 n-1 个字符。
    • stream:指向 FILE 对象的指针,指定输入流。在自定义 Shell 中我们固定传入 stdin(标准输入)。
  • 返回值 :成功时返回与参数 str 相同的缓冲区指针;若读取时遇到文件末尾(EOF)且未读入任何字符,或读取过程中发生错误,则返回 NULL

0.1.3 char* strtok(char* str, const char* delimiters);

  • 参数说明
    • str:待切分的原始字符串指针。注意 :在首次调用时须传入该字符串;在后续获取同字符串的剩余 Token 时,该参数必须传入 NULL
    • delimiters:包含所有作为分隔符的字符集合(如空字符 " ")。
  • 返回值 :返回指向当前切分出的子串(Token)起始位置的指针;若已经没有可以切分的标记,则返回 NULL

0.1.4 int snprintf(char* s, size_t n, const char* format, ...);

  • 参数说明
    • s:指向目标字符数组(缓冲区)的指针,用于存放格式化后的最终字符串。
    • n:缓冲区的大小。可写空间最大为 n,函数最多写入 n-1 个有效字符,并强制在末尾追加 \0
    • format:格式化控制字符串(如 "%s@%s"),规定了后续参数的输出格式。
    • ...:可变参数列表,根据 format 中的占位符传入对应的变量。
  • 返回值 :返回假设缓冲区足够大时,预期写入 的字符总数(不包含结尾的 \0)。若返回值大于等于 n,则说明输出被截断了。

0.2 基础架构

  在动手写代码之前,核心执行流如图一所示,主要逻辑为:打印提示符 -> 获取输入 -> 切分字符串 -> 识别并执行


一、第一阶段实现:Shell 可以处理普通命令

  本阶段实现 Shell 执行外部命令(如 ls -a -l)的基础流:父进程解析命令,子进程通过 execvp 替换执行。

1.1 核心实现代码

cpp 复制代码
#include<iostream>
#include<cstdlib>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<cstring>
#include<cstdio>
using namespace std;
#define COMMAND_SIZE 1024
#define COMMANDLINE "[%s@%s %s]# "

int g_argc=0;
char* g_argv[128] = {0};

const char* GetUserName()
{
	const char* name = getenv("USER"); // 获取当前用户名
	return name == nullptr? "None" : name;
}
const char* GetHostName()
{
	const char* host =getenv("HOSTNAME"); // 获取主机名
	return host==nullptr? "None" :host;
}

string GetDirName(const char* pwd)
{
#define SEP "/"
	string str = pwd;
	if(str == SEP) return "/";
	auto e = str.rfind(SEP); // 从后往前查找路径分隔符
	if(e==string::npos) return "BUG?";
	return str.substr(e+1); // 提取当前最后一级目录名

}
string GetCwd()
{
	const char* pwd = getenv("PWD"); // 获取当前绝对路径
	if(pwd==NULL)return "None";
	return GetDirName(pwd);
}

void MakeCommandLine(char* commandline , int size)
{
	// 拼接用户名、主机名和当前目录,格式化输出至目标缓冲区
	snprintf(commandline,size,COMMANDLINE,GetUserName(),GetHostName(),GetCwd().c_str());
}

void PrintCommandLine()
{
	char CommandLine [COMMAND_SIZE]={0};
	MakeCommandLine(CommandLine,sizeof(CommandLine)); // 构建命令提示符
	printf("%s",CommandLine);
	fflush(stdout); // 刷新标准输出缓冲区
}

bool GetCommandLine(char* cml,int size)
{
	char* c = fgets(cml,size,stdin); // 从标准输入读取一行
	if(c==NULL) return false;
	if(strlen(cml)-1==0) return false;
	cml[strlen(cml)-1]={0}; // 剔除最后的换行符 '\n'
	return true;
}
void CommandParse(char* cml)
{
#define SPL " "
	g_argc =0;
	g_argv[g_argc++] = strtok(cml,SPL); // 首次切分获取程序名
	while((bool)(g_argv[g_argc++]=strtok(nullptr,SPL))); // 循环切分获取参数
	g_argc--;
}
void Execute()
{
	pid_t _id = fork(); // 创建子进程
	if(_id==0)
	{
		execvp(g_argv[0],g_argv); // 子进程执行程序替换
		exit(0);
	}
	pid_t rid= waitpid(_id,nullptr,0); // 父进程阻塞等待子进程退出
	(void ) rid;
	return ;
}

int main()
{
	while(true)
	{
		PrintCommandLine(); // 1. 打印命令行提示符

		char commandline [COMMAND_SIZE] ={0} ;
		if(!GetCommandLine(commandline,sizeof(commandline))) // 2. 获取输入
			continue;
		CommandParse(commandline); // 3. 命令行分析

		Execute(); // 4. 创建子进程执行
	}

	return 0;
}

二、第二阶段实现:Shell 可以处理内建命令

  诸如 cdecho 等命令,直接改变子进程状态对父进程(Shell)无意义,必须由父进程亲自执行,此类命令称为内建命令

2.1 内建命令的原理与处理

  父进程在调用 fork 之前,需优先对命令进行检测拦截,若为内建命令则在自身内部调用对应的系统接口(如 chdir())完成操作。

三、第三阶段实现:环境变量的处理

  环境变量具有全局属性,子进程可继承。Shell 需要自主维护一张环境变量表,并在启动时导入系统的 environ

3.1 第二、三阶段合并版完整实现

cpp 复制代码
#include<iostream>
#include<unistd.h>
#include<cstdio>
#include<cstring>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;

const int PROMPT_SIZE = 128;
#define PROMPT "[%s@%s %s ]#"

#define COMMAND_SIZE 1024
int g_argc = 0;
char* g_argv[COMMAND_SIZE] ={0};

size_t ExitCode = 0; // 记录最近一次子进程退出的退出码

const int ENV_SIZE =100;
int g_envs=0;
char* g_env[ENV_SIZE] ={ 0 }; // Shell 维护的环境变量表

const char* GetUserName()
{
	const char* name = getenv("USER");
	return name==NULL?"None": name;
}

const char* GetHostName()
{
	const char* host = getenv("HOSTNAME");
	return host==NULL?"None": host;
}

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

string GetDirectory(char* _cwd)
{
	string str = _cwd;
	size_t pos = str.rfind("/");
	if(pos==string :: npos) return "None";
	string ret = str.substr(pos+1);
	return ret;
}

string GetCwd()
{
	char* cwd = getenv("PWD");
	return GetDirectory(cwd);
}

void MakePrompt(char* prompt,int size)
{
	snprintf(prompt,size,PROMPT,GetUserName(),GetHostName(),GetCwd().c_str());
}

void PrintCommandPrompt()
{
	char CommandPrompt[PROMPT_SIZE]={0};
	MakePrompt(CommandPrompt,sizeof(CommandPrompt));
	printf("%s",CommandPrompt);
}

bool GetCommandLine(char* _cmd,int size)
{
	char* ptr = fgets(_cmd , size , stdin);
	if(ptr==NULL) return false;
	_cmd[strlen(_cmd)-1]=0; // 剥离末尾换行符
	if(strlen(_cmd)==0) return false;
	return true;
}
bool AnalyseCommandLine(char* _cmd)
{
#define ESC " "
	g_argc =0;
	g_argv[g_argc++] = strtok(_cmd,ESC);
	while((bool)(g_argv[g_argc++]=strtok(NULL,ESC)));
	g_argc--;
	return g_argc>0?true:false;	
}

void Execute()
{
	pid_t _id = fork();
	if(_id==0)
	{
		execvp(g_argv[0],g_argv);
		exit(0);
	}	
	int statue = 0;
	pid_t rid = waitpid(_id,&statue, 0);
	if(rid > 0) 
	      ExitCode = WEXITSTATUS(statue); // 获取并记录子进程退出码
	return ;
}
void CommandCd()
{
	if(g_argc == 1)
	{
		chdir(GetHome()); // 无参数默认切换到 HOME 路径
	}
	else if(g_argc == 2)
	{
		if(strcmp(g_argv[1],"-")==0)
			chdir(getenv("OLDPWD")); // 切换到上一次所在目录
		else if(strcmp(g_argv[1],"~")==0)
			chdir(getenv("HOME")); // 切换到家目录
		else 
			chdir(g_argv[1]); // 切换到指定路径
	}
	else
		cout<<"command not found"<<endl;
}

void CommandEcho()
{
	string What = g_argv[1];
	if(g_argc==2)
	{
		if(What=="$?")
		{
			cout<<ExitCode<<endl; // 打印并重置最近的退出码
			ExitCode = 0;
		}
		else if(What[0]=='$')
		{
			string sub = What.substr(1);
			printf("%s\n",getenv(sub.c_str())); // 解析并打印环境变量值
		}
		else
		{
			cout<<What<<endl;
		}
	}
	else cout<<"command not found"<<endl;
}

bool CheckBuild_inCommand()
{
	string _cmd = g_argv[0];
	if(_cmd == "cd")
	{
		CommandCd(); // 拦截并执行内建 cd
		return true;
	}
	else if(_cmd == "echo")
	{
		CommandEcho(); // 拦截并执行内建 echo
		return true;
	}
	return false;
}

void Initenv()
{
	extern char** environ ;
	memset(g_env,0,sizeof(g_env));
	g_envs = 0;
	// 1. 将系统原生环境变量深拷贝导入自定义表
	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*)"TEST=1234567890"; // 追加自定义测试环境变量
	g_env[g_envs]=NULL;
	// 2. 将表内变量导出至当前进程环境中
	for(int i = 0;g_env[i];i++)
	{
		putenv(g_env[i]);
	}
	environ = g_env; // 修改全局指针指向
}


int main()
{
	Initenv(); // 初始化环境变量表
	while(1)
	{
		PrintCommandPrompt(); // 1. 打印提示符
		char CommandLine[COMMAND_SIZE]={0};
		if(!GetCommandLine(CommandLine,sizeof(CommandLine))) // 2. 获取输入
			continue;
		if(!AnalyseCommandLine(CommandLine)) // 3. 解析命令
			continue;
		if(CheckBuild_inCommand()) // 4. 检测并执行内建命令
			continue;
		Execute(); // 5. 执行外部命令
	}
	return 0;
}

3.2 回答一些细节问题

  在实现完前三个阶段的 Shell 后,我们不妨停下来思考几个细节问题。这些问题能帮我们彻底打通从"应用层字符串处理"到"内核进程管理"的任督二脉。

3.2.1 为什么在接收命令行的时候不适用 scanf 而要用 fgets?

  • 空格截断问题scanf 在读取字符串时,默认会以空格制表符换行符作为数据读取的结束标志。
  • 无法读取完整命令 :在命令行中,输入带参数的指令(例如 ls -a -l)是家常便饭。如果使用 scanf,它只会读取到 ls,后面的参数全被截留在输入缓冲区中,导致 Shell 无法正常解析多参数命令。
  • fgets 的优势fgets 会读取整行输入,直到遇到换行符 \n 为止,能够完美地将包含空格的完整命令字符串整条读入到临时的命令行数组中。

3.2.2 为什么使用字符数组而不是 string?

  • 为了和 exec* 体系函数适配底层 :系统调用及 C 标准底层接口(如 execvpstrtok)全都是基于 C 风格的字符指针数组(char* [])设计的。
  • 避免频繁转换开销 :如果强行使用标准库的 std::stringstd::vector<string>,每次调用底层系统接口时,都必须通过 .c_str() 进行繁琐的指针转换与内存重构,不仅增加了不必要的性能开销,还会使底层的内存控制变得极难维护。

3.2.3 思考一个悖论:我们的模拟实现 shell 能不能执行 su -?

  • 结论不能正常完全运行,或者说执行后无法维持状态。
  • 原因分析su - 的本质是切换当前用户身份,这需要创建新的会话并彻底重写当前 Shell 进程的权限凭证及环境。
  • 身份局限性 :由于我们的模拟 Shell 只是一个运行在用户态的普通子进程,不具备操作系统的核心常驻管理权限。当子进程执行 su - 替换时,一旦子进程生命周期结束或发生环境冲突,由于父进程(我们的 Shell)没有维护会话切换的完备机制,整个权限树和会话环境就会瞬间崩溃。

3.2.4 为什么要有内建命令?内建命令与特有内建命令

  为了更清晰地理解内建命令存在的意义,我们可以从以下几个维度来看:

1. 改变 Shell 自身状态的绝对必要性

  如果有些命令(如 cd)交由子进程去执行,改变的仅仅是子进程的工作目录,子进程一退出,父进程(Shell)的工作目录毫无变化。因此,这类命令必须 由 Shell 进程自身直接调用对应的系统接口(如 chdir)来完成。

2. 保证命令在任何情况下都可用

  外置命令(普通命令)强依赖于环境变量 $PATH 以及磁盘文件系统的完整性。

  • 极端场景 :假设你不小心误删了 /usr/bin 目录,或者由于磁盘故障导致 /bin 挂载失败。
  • 内建命令的作用 :此时你的 Shell 依然能够运行 cdpwdecho 等内建命令。这给了管理员在极端崩溃环境下一线"自救"的机会。如果连 cd 都是外置的,当路径出问题时,你甚至无法移动到备份目录。
3. 兼容性与 POSIX 标准

  许多工具(如 test[kill)在 POSIX 标准中被定义为必须存在的实用程序。

  • 系统为了保证任何调用(比如通过 C 语言的 system() 函数或不带 Shell 的环境)都能找到这些命令,必须在磁盘上放置一份独立的、可执行的二进制文件。
  • 而 Shell 为了自己的效率特性支持 (如 kill 需要操作作业控制),又必须在内部自己实现一份。这就形成了部分命令既有内建版、又有外置版的共存状态。

3.2.5 chdir() 的小细节:只改路径,不改变量

  当我们执行内建命令 cd .. 时,底层调用的其实是系统调用 chdir("..")。这里隐藏着一个极易被忽视的 Bug:

  • 操作系统行为 :内核成功修改了当前进程 PCB(进程控制块)中的 cwd(当前工作目录)指针,进程的实际落脚点确实变了。
  • 副作用 :它并不会 自动去更新你环境表(char** environ)里那个名叫 PWD 的字符串!
  • 后果 :这会导致你的 GetCwd() 或者是通过 getenv("PWD") 获取到的提示符路径,依然停留在原地,从而出现提示符与实际工作路径分离的"假移动"现象。因此,在完善的 Shell 实现中,调用 chdir 成功后,必须手动调用 putenvsetenv 来同步更新 PWD 环境变量。

3.2.6 命令加不加 -f 的区别

  在 Linux 命令行生态中(如 rmmkdir),-f--force)代表强制删除/执行。它在业务逻辑处理和返回值上有着极为分明的区别:

场景 不加 -f -f
文件存在 正常删除 正常删除
文件不存在 报错:"No such file or directory" 静默失败,不报错
只读文件 (无确认) 需要 y 确认 直接删除,不询问
返回码 (文件不存在时) 非 0 (失败) 0 (成功)

好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye! ---《進程篇》完·结,感谢您的观看,我们《文件篇》见---

相关推荐
落羽的落羽1 小时前
【项目】JsonRpc框架——开发实现2(业务层)
linux·数据结构·c++·人工智能·算法·json·动态规划
Shadow(⊙o⊙)1 小时前
mkfifo()命名管道-FIFO客户端 服务端模拟。*System V消息队列、信号量(信号灯)。
linux·运维·服务器·开发语言·c++
daad7771 小时前
继续记录SITL的大循环
linux
酉鬼女又兒1 小时前
零基础入门计算机网络:点对点协议PPP、媒体接入控制基本概念、静态划分信道技术、CSMA/CD与CSMA/CA协议全面详解
服务器·网络·网络协议·计算机网络·职场和发展·求职招聘·媒体
maosheng11461 小时前
基于AI 文本生成的自动化Linux 运维文档系统
运维·人工智能·自动化
智塑未来1 小时前
2025-2026年具身智能机器人自动化程度综合评测:五大品牌自研大模型与操作系统全对比
运维·机器人·自动化
AI_零食1 小时前
奶茶大数据运维表 - 鸿蒙PC Electron框架技术实现详解
运维·前端·华为·electron·开源·harmonyos·鸿蒙
Shadow(⊙o⊙)1 小时前
System V共享内存详解,shm系列接口,三种共享内存删除机制。System V通信缺点分析
linux·运维·服务器·开发语言·网络·c++
morning_judger1 小时前
Agent开发系列(七)-可观测性Agent的设计
运维·人工智能