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);// 循环获取后续所有参数,直到返回NULLwhile((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_testg_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 = 1if(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 $PATHstd::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; // 清理\nif(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){//childexecvp(g_argv[0], g_argv);exit(1);}int status = 0;// fatherpid_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