Linux-自定义shell命令解释器
Linux 自定义shell命令解释器
本章的目的是:
1.模块化实现一个具备基本命令行解释功能的自定义bash。
2.通过实现自定义bash串讲先前的重要知识,尤其是环境变量和命令行参数的理解。
首先我们对大致的变量和核心功能做一个大概介绍。
一.功能概览
1. 命令行参数相关
#define MAXARGC 128
char *g_argv[MAXARGC]; // 存储解析后的命令行参数数组
int g_argc = 0; // 参数个数计数器
- 功能:存储用户输入命令解析后的各个参数
- 示例:输入
ls -l /home
会被解析为g_argv[0]="ls"
,g_argv[1]="-l"
,g_argv[2]="/home"
2. 环境变量相关
#define MAX_ENVS 100
char *g_env[MAX_ENVS]; // 环境变量存储数组
int g_envs = 0; // 环境变量计数器
- 功能:存储和管理 shell 的环境变量
- 作用:为子进程提供执行环境
3.命令行参数表和环境变量表性质详解
特性 | 环境变量表 (Environment Variables) | 命令行参数表 (Command-line Arguments) |
---|---|---|
级别 | 进程级 | 进程级 |
存储形式 | KEY=VALUE 字符串数组 | 字符串指针数组 |
终止标记 | 以 NULL 指针结尾 | 以 NULL 指针结尾 |
继承性 | 子进程继承父进程的环境变量 | 子进程不继承父进程的参数,需要显式传递 |
修改性 | 运行时可以动态修改 | 通常在进程启动后只读 |
1.环境变量实际上是进程级的概念,但可以通过继承机制实现“系统级”的效果。
// 环境变量的继承链
系统启动 → init进程 → 登录shell → 当前shell → 子进程
2.环境变量表的内存布局
// 环境变量表在内存中的结构
char *environ[] = {
"PATH=/usr/bin:/bin",
"HOME=/home/user",
"USER=john",
"SHELL=/bin/bash",
NULL // 结束标记
};
3.本次实现bash中,对于初始化环境变量表的操作
#define MAX_ENVS 100
char *g_env[MAX_ENVS]; // 自定义环境变量表
int g_envs = 0; // 环境变量计数器
void InitEnv()
{
extern char **environ; // 系统全局环境变量表指针
// 从父进程复制环境变量
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] = NULL; // 必须的结束标记
// 注册到系统
for(int i = 0; g_env[i]; i++)
{
putenv(g_env[i]);
}
environ = g_env; // 重定向全局指针
}
- 深拷贝:避免直接使用父进程的环境变量指针
- 内存管理:需要手动管理
g_env
中字符串的内存 - 指针重定向:修改全局
environ
指向自定义表
对于之后要讲解的路径获取函数GetPwd,我们想实现实时更新系统环境变量中路径的值,就需要用到之前讲解的putenv进行修改,具体操作:
const char *GetPwd()
{
const char *pwd = getcwd(cwd, sizeof(cwd));
if(pwd != NULL)
{
// 更新 PWD 环境变量
snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);
putenv(cwdenv); // 动态修改环境变量表
}
return pwd;
}
4.命令行参数表的特性:命令行参数表一般由一个参数计数器argc和参数表argv组成。前者记录表中的参数个数(包含命令和选项),后者则是存放具体的命令行参数。
// 命令行参数表的结构示例
// 用户输入: ls -l /home
char *argv[] = {
"ls", // 程序名/命令名
"-l", // 参数1
"/home", // 参数2
NULL // 结束标记
};
int argc = 3; // 参数个数
5.命令行参数表我们在进行命令行指令的解析和执行时会重点使用。我们使用strtok函数将命令行指令以字符串形式按空格分割,然后以循环方式依次解析指令。
#define MAXARGC 128
char *g_argv[MAXARGC]; // 命令行参数表
int g_argc = 0; // 参数计数器
bool CommandParse(char *commandline)
{
g_argc = 0;
// 使用strtok解析命令行字符串
g_argv[g_argc++] = strtok(commandline, " ");
// 循环解析所有参数
while((g_argv[g_argc] = strtok(nullptr, " ")) != nullptr)
{
g_argc++;
if(g_argc >= MAXARGC - 1) break; // 防止溢出
}
g_argv[g_argc] = NULL; // 必须的结束标记
return g_argc > 0;
}
6.在实现bash过程中,重点应用了环境变量的读取+设置+传递子进程,以及命令行参数的别名展开+命令行解析+命令传递。
4.别名映射表
在解析命令前,先在别名表中以key-value形式查看是否存在指令别名
std::unordered_map<std::string, std::string> alias_list;
5.辅助变量
char cwd[1024]; // 当前工作目录缓冲区
char cwdenv[1024]; // 环境变量格式的工作目录
int lastcode = 0; // 上一个命令的退出状态码
6.核心功能模块
1. 环境信息获取函数
这些接口主要用于获取环境变量,并模仿系统bash中打印用户名-当前主机-当前工作目录的行为。
const char* GetUserName() // 获取当前用户名
const char* GetHostName() // 获取主机名
const char* GetPwd() // 获取当前工作目录
const char* GetHome() // 获取家目录路径
2. 环境初始化模块
void InitEnv()
功能:
- 从父进程继承环境变量
- 复制到自定义环境变量数组
- 添加测试环境变量 “HAHA=for_test”
- 设置全局环境变量表
3. 内建命令实现
我们在这里展开什么叫内建命令。先来说什么不是内建命令:之后的代码中大家可以观察到,我们在除了内建命令以外的其他命令(如ls等等)都没有采用创建新的子进程+程序替换的方式执行指令:
那么为什么所谓内建命令不采用这种方式呢?
我们可以试想:初始时我们有bash进程,此时输入了命令cd,功能大家都清楚——更改当前工作路径并回显。但是我们会发现实际上路径并不会改变,这时为什么?因为把cd这样的内建命令交给子进程做,都只会对子进程产生修改,而父进程bash的工作目录并不会改变。其他的一些内建命令也类似如此的理由设计为内建命令。
4. 命令行界面模块
提示符生成
void MakeCommandLine() // 生成格式化的提示符
void PrintCommandPrompt() // 打印提示符
- 格式:
[用户名@主机名 当前目录名]#
- 示例:
[user@localhost ~]#
命令输入处理
bool GetCommandLine() // 读取用户输入的命令行
bool CommandParse() // 解析命令行为参数数组
void PrintArgv() // 调试用:打印解析后的参数
5. 命令执行模块
内建命令检查
bool CheckAndExecBuiltin()
- 功能:检查是否为内建命令,如果是则直接执行
- 支持的内建命令:
cd
,echo
,export
,alias
- 返回
true
表示已处理,无需创建子进程
外部命令执行
int Execute()
流程:
fork()
创建子进程- 子进程:
execvp()
发生程序替换,执行外部命令 - 父进程:
waitpid()
等待子进程结束 - 记录退出状态码到
lastcode
6. 辅助函数
std::string DirName(const char *pwd) // 提取路径的最后一个目录名
主程序流程
int main()
初始化:调用
InitEnv()
设置环境变量主循环:
- 打印提示符
- 读取命令
- 解析命令
- 检查内建命令
- 执行外部命令
循环继续直到用户退出
二.模块内部逻辑详细讲解
1.环境初始化模块(InitEnv)
void InitEnv()
{
extern char **environ; // 引用外部全局环境变量表
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=for_test";
g_env[g_envs] = NULL; // 环境变量表必须以NULL结尾
// 将自定义环境变量设置到进程环境
for(int i = 0; g_env[i]; i++)
{
putenv(g_env[i]); // 设置到系统环境变量
}
environ = g_env; // 重定向全局环境变量指针
}
逻辑详解:
- 首先获取外部全局的
environ
变量(这是一个指向系统环境变量字符串数组的指针)。 - 将
g_env
数组清零,并重置计数器g_envs
为0。 - 遍历
environ
数组,将每个环境变量字符串拷贝到g_env
中(使用动态分配内存)。 - 添加一个测试环境变量 “HAHA=for_test”,注意g_env的结尾用NULL来表示参数传递完毕。
- 然后遍历
g_env
数组,使用putenv
将每个环境变量设置到当前进程的环境中。 - 最后将全局的
environ
指针指向我们的g_env
,这样后续的环境变量查找都会使用我们自定义的表。
注意:这里有一个潜在问题,因为 putenv
的参数是字符串指针,而我们将动态分配的字符串指针传递给它,这些指针在程序运行期间一直有效(因为不会释放),所以没有问题。但是,如果后续要修改环境变量,需要小心处理。
2.内建命令模块
该模块是我们检测到位内建命令之后,不创建子进程而由bash直接执行的命令。
cd指令
在这里我们仅对默认的cd和具体的路径做了处理,对于特殊字符例如回到上一级目录,回到根目录并没有实现。
bool Cd()
{
// cd argc = 1 表示只有"cd"命令,没有参数
if(g_argc == 1)
{
std::string home = GetHome();
if(home.empty()) return true; // 家目录为空则直接返回
chdir(home.c_str()); // 切换到家目录
}
else // 有参数的情况:cd <directory>
{
std::string where = g_argv[1]; // 获取目标目录参数
// 特殊目录处理(目前未实现)
if(where == "-")
{
// TODO: 应该切换到上一个工作目录
// 需要维护一个previous_dir变量
}
else if(where == "~")
{
// TODO: 应该展开为用户家目录
}
else
{
chdir(where.c_str()); // 切换到指定目录
}
}
return true;
}
如果没有参数(
g_argc==1
,因为命令名是第一个参数,所以只有命令名时参数个数为1),则切换到家目录。如果有参数,检查第一个参数(
g_argv[1]
):- 如果是
-
,表示切换到上一个目录(未实现)。 - 如果是
~
,表示切换到家目录(未实现)。 - 否则,切换到参数指定的目录。
- 如果是
注意:chdir
系统调用成功返回0,失败返回-1,但这里没有处理错误
echo指令
我们这里对两个参数的情况(echo ***)做了处理。分别做了查看最近一个可执行程序的退出码,查看环境变量以及普通的printf逻辑,没有对重定向等操作定义和完善。
void Echo()
{
if(g_argc == 2) // 只处理单个参数的情况
{
std::string opt = g_argv[1];
// 情况1: 输出上一条命令的退出码
if(opt == "$?")
{
std::cout << lastcode << std::endl;
lastcode = 0; // 重置退出码
}
// 情况2: 输出环境变量值
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;
}
// 情况3: 直接输出字符串
else
{
std::cout << opt << std::endl;
}
}
// 注意:当前实现不支持多个参数,如"echo hello world"
}
逻辑:
- 目前只处理一个参数的情况(即
echo
后面只有一个字符串)。 - 如果参数是
$?
,则打印上一个命令的退出码lastcode
,然后将其重置为0。 - 如果参数以
$
开头,则将其视为环境变量名,获取并打印该环境变量的值。 - 否则,直接打印参数字符串。
局限:目前只能处理一个参数,例如 echo hello world
会被解析为两个参数,而该函数只处理第二个参数(即hello
),忽略其余参数。
3.命令行解析模块(CommandParse)
bool CommandParse(char *commandline)
{
#define SEP " " // 使用空格作为分隔符
g_argc = 0;
// 第一次调用strtok,获取第一个token(命令名)
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; // 至少有一个token才返回true
}
在这里,我们定义了分隔符为SEP(这里是空格)进行命令行参数的分割。
逻辑:
- 使用
strtok
函数以空格为分隔符将命令行字符串分割成多个令牌。 - 第一个令牌通过
strtok(commandline, SEP)
获取,后续令牌通过strtok(nullptr, SEP)
获取。 - 每个令牌的指针被存入
g_argv
数组,并增加g_argc
计数。 - 当
strtok
返回NULL
时停止,此时g_argc
多计了一次,所以减一。 - 最后返回是否解析到至少一个参数(即命令名)。
示例:
输入:
"ls -l /home/user"
处理过程:
strtok(commandline, " ")
→"ls"
→g_argv[0]
strtok(nullptr, " ")
→"-l"
→g_argv[1]
strtok(nullptr, " ")
→"/home/user"
→g_argv[2]
strtok(nullptr, " ")
→NULL
→ 循环结束结果:
g_argc = 3
,g_argv = ["ls", "-l", "/home/user", NULL]
注意:strtok
会修改原始字符串,将分隔符替换为 \0
,因此原始命令字符串会被破坏。
4.内建命令检查模块(CheckAndExecBuiltin)
bool CheckAndExecBuiltin()
{
std::string cmd = g_argv[0]; // 获取命令名
// 命令分发逻辑
if(cmd == "cd")
{
Cd(); // 内建命令,直接在当前进程执行
return true; // 返回true表示已处理,无需fork
}
else if(cmd == "echo")
{
Echo(); // 内建命令
return true;
}
else if(cmd == "export")
{
// TODO: 设置环境变量功能
return true;
}
else if(cmd == "alias")
{
// TODO: 别名设置功能
// 如:alias ll='ls -l'
return true;
}
return false; // 返回false表示不是内建命令,需要外部执行
}
逻辑:
- 将第一个参数(命令名)转换为字符串。
- 与已知内建命令比较,如果匹配则调用相应的函数,并返回
true
表示已处理。 - 如果不是内建命令,返回
false
,以便后续执行外部命令。
5.外部命令执行模块 (Execute)
在经过命令行解析之后,我们需要对解析之后的指令进行执行(这里按逻辑已经确认是外部命令)。
int Execute()
{
// 步骤1: 创建子进程
pid_t id = fork();
if(id == 0) // 子进程分支
{
// 在子进程中执行外部命令
execvp(g_argv[0], g_argv); // 执行命令,替换当前进程映像
// 如果execvp失败,执行到这里
exit(1); // 以错误码退出子进程
}
// 父进程分支
int status = 0;
// 等待子进程结束
pid_t rid = waitpid(id, &status, 0);
if(rid > 0) // 成功等待到子进程结束
{
// 提取子进程的退出状态码
lastcode = WEXITSTATUS(status);
}
return 0;
}
- 使用
fork
创建子进程。 - 在子进程中,调用
execvp
执行命令,如果执行失败则退出子进程(退出码1)。 - 在父进程中,使用
waitpid
等待子进程结束,并获取退出状态。 - 使用
WEXITSTATUS
宏从状态中提取退出码,并记录到lastcode
中。
6.主循环(main)
为了不读取一个指令就退出bash程序,我们把bash设计为一个死循环,以便循环读取指令并执行。
int main()
{
InitEnv(); // 一次性环境初始化
while(true) // 无限命令循环
{
// 阶段1: 显示提示符
PrintCommandPrompt(); // 显示[user@host dir]#
// 阶段2: 读取命令
char commandline[COMMAND_SIZE];
if(!GetCommandLine(commandline, sizeof(commandline)))
continue; // 读取失败或空命令,重新循环
// 阶段3: 解析命令
if(!CommandParse(commandline))
continue; // 解析失败,重新循环
// 阶段4: 检查内建命令
if(CheckAndExecBuiltin())
continue; // 如果是内建命令且已执行,跳过外部执行
// 阶段5: 执行外部命令
Execute(); // 创建子进程执行
}
return 0;
}
逻辑:
初始化环境变量。
进入无限循环:
- 打印命令提示符。
- 读取用户输入的命令行。
- 如果读取失败(如EOF)则跳过本次循环。
- 解析命令行,如果解析失败(空命令)则跳过。
- 检查是否为内建命令,如果是则执行并跳过后续步骤(外部命令执行)。
- 如果不是内建命令,则创建子进程执行外部命令。
7.路径显示处理(DirName)
我们发现以上的打印结果会把完整的当前路径打出,而系统的bash是只取当前的工作目录名。所以我们需要把路径进行处理。
std::string DirName(const char *pwd)
{
std::string dir = pwd;
// 特殊情况:根目录
if(dir == "/") return "/";
// 查找最后一个斜杠位置
auto pos = dir.rfind("/");
if(pos == std::string::npos)
return "BUG?"; // 理论上不应该出现
// 返回最后一个斜杠后的部分
return dir.substr(pos+1);
}
逻辑:
- 如果当前目录是根目录 “/",则返回 “/"。
- 否则,查找最后一个 “/” 的位置,返回该位置之后的子字符串(即最后一个目录名)。
- 如果找不到 “/",返回 “BUG?"。
示例
/home/user
→rfind("/")
找到位置5 →substr(6)
→"user"
/usr/local/bin
→ 找到位置10 →substr(11)
→"bin"
8.提示符生成 (MakeCommandLine)
void MakeCommandLine(char cmd_prompt[], int size)
{
snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), DirName(GetPwd()).c_str());
}
逻辑:
- 使用
snprintf
格式化提示符字符串,格式为[用户名@主机名 当前目录名]#
。 - 其中当前目录名只显示最后一级目录。
三.完整源码与效果展示
源码如下:
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstring>
#include <unordered_map>
#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s %s]# "
// 下面是shell定义的全局数据
// 1. 命令行参数表
#define MAXARGC 128
char *g_argv[MAXARGC];
int g_argc = 0;
// 2. 环境变量表
#define MAX_ENVS 100
char *g_env[MAX_ENVS];
int g_envs = 0;
// 3. 别名映射表
std::unordered_map<std::string, std::string> alias_list;
// for test
char cwd[1024];
char cwdenv[1024];
// last exit code
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));
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;
}
//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;
}
}
}
// / /a/b/c
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 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);
}
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;
}
// 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);
}
bool CheckAndExecBuiltin()
{
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;
}
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;
}
int main()
{
// shell 启动的时候,从系统中获取环境变量
// 我们的环境变量信息应该从父shell统一来
InitEnv();
while(true)
{
// 1. 输出命令行提示符
PrintCommandPrompt();
// 2. 获取用户输入的命令
char commandline[COMMAND_SIZE];
if(!GetCommandLine(commandline, sizeof(commandline)))
continue;
// 3. 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
if(!CommandParse(commandline))
continue;
//PrintArgv();
// 检测别名
// 4. 检测并处理内键命令
if(CheckAndExecBuiltin())
continue;
// 5. 执行命令
Execute();
}
//cleanup();
return 0;
}
效果如下:
wujiahao@VM-12-14-ubuntu:~/process_test$ ./myshell
[wujiahao@None process_test]# echo hello
hello
[wujiahao@None process_test]# ls
Makefile myshell myshell.cc other.cc proc.c