整体逻辑:shell本质就是一个死循环:打印提示符 → 读取一行输入 → 分割参数 → 别名替换 → 内置命令直接执行 / 外部命令fork+exec执行。
一、头文件、宏、全局变量
①
cpp
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h> // Linux系统调用:fork,chdir,getcwd,execvp
#include <sys/types.h> // pid_t类型定义
#include <sys/wait.h> // waitpid,等待子进程
#include <string>
#include <unordered_map> // 哈希表,存放alias别名
#include <algorithm>
#include <unistd.h>Linux系统调用必备头文件,只有Linux才有,Windows不能跑这套代码<sys/wait.h>:waitpid、WEXITSTATUS用来获取子进程退出码unordered_map:C++哈希容器,key是别名,value是真实命令,alias ll="ls -l"就存在这里
②
cpp
#define COMMAND_SIZE 1024 // 命令缓冲区最大长度,一行输入最多1024字符
#define FORMAT "[%s@%s %s]# "//提示符模板:[用户名@主机名 当前文件夹]#
宏定义,预处理直接文本替换。
③
cpp
#define MAXARGC 128
char *g_argv[MAXARGC]; // 全局参数数组,和main(int argc,char* argv[])的argv完全一样
int g_argc = 0; // 参数个数,例如 ls -l 则g_argc=2
g_argv 存放分割后的命令字符串指针数组。
g_argv[0]= 命令名,g_argv[1]= 第一个参数,末尾必须NULL,execvp要求。
④
cpp
#define MAX_ENVS 100
char *g_env[MAX_ENVS]; // 自己维护的环境变量数组
int g_envs = 0; // 环境变量计数
我们拷贝系统环境变量到这个数组,自己管理环境变量。
⑤
cpp
std::unordered_map<std::string, std::string> alias_list; //别名表
key:别名,value:真实命令。alias ll='ls -l' → alias_list"ll" = "ls -l"
⑥
cpp
char cwd[1024]; // 存放当前目录字符串,getcwd写入
char cwdenv[1024]; // 构造PWD=xxx字符串,给putenv更新环境变量
char oldcwd[1024]; // 保存上一次目录,用于 cd -
int lastcode = 0; // 上一条命令退出码,echo $?读取
全局变量:在整个shell生命周期一直保存状态,每次循环不会重置。
二、工具函数,获取用户、主机、目录
① 获取USER环境变量:用户名
cpp
const char *GetUserName()
{
const char *name = getenv("USER");
return name == NULL ? "None" : name;
}
getenv("环境变量名"):读取环境变量。
USER 环境变量保存当前登录用户名;取不到返回None。
② 获取主机名 HOSTNAME
cpp
const char *GetHostName()
{
const char *hostname = getenv("HOSTNAME");
return hostname == NULL ? "None" : hostname;
}
读取主机名,终端里hostname命令查看。
③ 获取当前工作目录,同时更新环境变量PWD
cpp
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;
}
getcwd(buf,size)系统调用,获取当前进程工作目录存入cwd数组。snprintf拼接字符串,生成PWD=/home/xxxputenv()修改当前进程的环境变量,更新PWD。>
很多程序依赖PWD环境变量,cd切换目录后必须同步更新PWD。
④ 获取家目录HOME
cpp
const char *GetHome()
{
const char *home = getenv("HOME");
return home == NULL ? "" : home;
}
读取HOME环境变量,家目录,cd ~就跳转到这个路径。
⑤ 截取路径最后一段(提示符只显示文件夹名,不显示全路径)
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);
}
作用:只取路径最后一段文件夹名字 。
例:/home/student,最后一个/在索引5,substr(6)得到student。
根目录/特殊处理,直接返回/。
提示符不想展示一长串全路径,只显示文件夹名。
三、InitEnv() 初始化环境变量
cpp
void InitEnv()
{
extern char **environ; //系统全局环境变量数组
memset(g_env, 0, sizeof(g_env));
g_envs = 0;
//拷贝系统环境变量到我们自己的g_env数组
for(int i = 0; environ[i] && i < MAX_ENVS - 2; 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;
// 将我们的环境变量设置到进程
for(int i = 0; g_env[i]; i++)
{
putenv(g_env[i]);
}
environ = g_env; //替换全局环境变量指针
getcwd(oldcwd, sizeof(oldcwd)); //初始化上一次目录
}
extern char** environLinux内置全局变量,存放父进程传给我们的全部环境变量。memset清空g_env数组。- 循环读取每一条环境变量,
malloc开辟内存,strcpy复制字符串到我们自己数组。
为什么malloc:environ是父进程的内存,我们需要自己拷贝一份,方便后续修改。
- 添加自定义环境变量
HAHA=for_test environ = g_env:把系统环境指针指向我们自己维护的数组,整个进程环境变量由我们接管。- 初始化oldcwd,保存启动时的目录。
四、提示符、读取用户输入
① 拼接提示符字符串
cpp
void MakeCommandLine(char cmd_prompt[], int size)
{
snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), DirName(GetPwd()).c_str());
}
snprintf拼接提示符字符串。把用户名、主机名、文件夹填入FORMAT模板,生成[user@pc folder]#。
② 打印提示符
cpp
void PrintCommandPrompt()
{
char prompt[COMMAND_SIZE];
MakeCommandLine(prompt, sizeof(prompt));
printf("%s", prompt);
fflush(stdout);
}
- 调用MakeCommandLine生成提示符字符串
- printf打印
fflush(stdout):C标准输出是行缓冲,不加fflush,提示符可能卡在缓冲区,不立刻显示,等换行才输出。
③ 读取一行用户输入
cpp
bool GetCommandLine(char *out, int size)
{
char *c = fgets(out, size, stdin); //读取一行输入,包含末尾回车'\n'
if(c == NULL) return false;
out[strlen(out)-1] = 0; // 把换行符替换成'\0'字符串结束符
if(strlen(out) == 0) return false; //用户直接回车,空输入返回false
return true;
}
fgets(buf,maxlen,stdin):读取控制台一行输入。
输入
ls -l按下回车,缓冲区得到字符串ls -l\n。
strlen(out)-1定位到\n,赋值0('\0'),删除换行。
五、命令解析 CommandParse,字符串分割
① 字符串分割
cpp
bool CommandParse(char *commandline)
{
#define SEP " "
g_argc = 0;
// strtok分割字符串,以空格切割
g_argv[g_argc++] = strtok(commandline, SEP);
while( (g_argv[g_argc] = strtok(nullptr, SEP)) != nullptr && g_argc < MAXARGC-1 )
{
g_argc++;
}
return g_argc > 0 ? true:false;
}
strtok 字符串分割函数,会原地修改原字符串 ,把分隔符替换成\0。
- 第一次调用:
strtok(字符串,分隔符),返回第一个单词指针 - 后续调用:
strtok(NULL,分隔符),继续读取下一个单词,返回NULL代表分割结束
示例:输入ls -l -a
- g_argv0 = "ls",g_argc=1
- 循环取出 "-l" g_argv1, g_argc=2
- 取出 "-a" g_argv2, g_argc=3
- strtok返回NULL,循环结束
返回true,代表解析成功。
② 打印分割后的参数
cpp
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);
}
调试函数,打印分割后的参数,写代码的时候打开看分割是否正确,成品可以注释掉。
③ 别名替换 AliasReplace
cpp
bool AliasReplace(char *cmdline)
{
std::string cmd(g_argv[0]);
if(alias_list.find(cmd) != alias_list.end())
{
strcpy(cmdline, alias_list[cmd].c_str());
return true;
}
return false;
}
逻辑:拿到解析后的命令名g_argv0,去哈希表alias_list查找。
- 如果找到:把真实命令拷贝到cmdline缓冲区,返回true,main函数拿到true会重新调用CommandParse解析新命令 。
例:alias ll='ls -l',输入ll,命中别名,替换成ls -l,再重新分割。 - 找不到别名,返回false,不做处理。
六、内置命令实现
内置命令不能fork子进程!因为内置命令修改shell本身状态(当前目录、环境变量),子进程的修改无法传回父进程。
① cd 内置命令
cpp
bool Cd()
{
if(g_argc == 1)
{
//不带参数 cd → cd ~ 回到家目录
std::string home = GetHome();
if(home.empty()) return true;
getcwd(oldcwd, sizeof(oldcwd));
chdir(home.c_str());
}
else
{
std::string where = g_argv[1];
if(where == "-")
{
// cd - 回到上一次目录
char tmp[1024];
strcpy(tmp, oldcwd);
getcwd(oldcwd, sizeof(oldcwd));
chdir(tmp);
std::cout << tmp << std::endl;
}
else if(where == "~")
{
// cd ~
std::string home = GetHome();
getcwd(oldcwd, sizeof(oldcwd));
chdir(home.c_str());
}
else
{
getcwd(oldcwd, sizeof(oldcwd));
if(chdir(where.c_str()) == -1)
{
perror("cd");
lastcode = 1; //失败退出码1
}
}
}
return true;
}
chdir(path)系统调用:修改当前进程 工作目录。
每次切换目录前,先把当前目录保存进oldcwd,用于cd -。
cd不带参数:跳HOMEcd ~:跳HOMEcd -:oldcwd保存上次目录,切换回去并打印路径cd xxx:切换到xxx目录,失败perror打印错误,设置lastcode=1。
② echo 内置命令
cpp
void Echo()
{
if(g_argc >= 2)
{
for(int i = 1; i < g_argc; i++)
{
std::string opt = g_argv[i];
if(opt == "$?")
{
std::cout << lastcode;
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;
}
else
{
std::cout << opt;
}
if(i != g_argc - 1) std::cout << " ";
}
std::cout << std::endl;
}
}
循环遍历所有参数:
- 参数是
$?:打印lastcode(上一条命令退出码),重置lastcode - 参数以
$开头:截取后面字符串,getenv读取环境变量输出,例如'后面字符串,getenv读取环境变量输出,例如`后面字符串,getenv读取环境变量输出,例如'PATH` - 普通字符串:直接打印
参数之间空格分隔,最后换行。
③ export 设置环境变量
cpp
void Export()
{
if(g_argc != 2)
{
lastcode = 1;
return;
}
putenv(g_argv[1]);
}
用法export TEST=123。
putenv("TEST=123")直接添加/修改当前进程环境变量。参数数量不对,退出码置1。
④ alias 别名命令
cpp
void Alias()
{
if(g_argc == 1)
{
//无参:打印全部别名
for(auto &p : alias_list)
{
std::cout << "alias " << p.first << "='" << p.second << "'" << std::endl;
}
}
else if(g_argc == 2)
{
std::string s = g_argv[1];
size_t eq = s.find('=');
if(eq == std::string::npos)
{
lastcode = 1;
return;
}
std::string key = s.substr(0, eq);
std::string val = s.substr(eq+1);
alias_list[key] = val;
}
}
alias不带参数:范围遍历哈希表,打印全部已定义别名alias ll=ls -l:找到=,左边是别名key,右边是真实命令value,存入alias_list
⑤ 内置命令分发入口 CheckAndExecBuiltin
cpp
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")
{
Export();
return true;
}
else if(cmd == "alias")
{
Alias();
return true;
}
else if(cmd == "exit")
{
exit(lastcode);
}
return false; //不是内置命令,返回false
}
读取命令名g_argv0,if匹配:
匹配成功:执行对应内置函数,return true,main就不会执行外部命令。
没有匹配:return false,交给Execute()执行外部程序。
exit(lastcode)直接结束shell进程,返回退出码。
七、 Execute() 外部命令执行 fork + execvp
cpp
int Execute()
{
pid_t id = fork(); //复制进程,创建子进程
if(id == 0)
{
// =========子进程=========
execvp(g_argv[0], g_argv); //加载外部程序替换子进程镜像
perror("execvp fail"); //execvp成功就不会返回;失败才执行这行
exit(1);
}
// =========父shell进程=========
int status = 0;
pid_t rid = waitpid(id, &status, 0); //阻塞等待子进程结束
if(rid > 0)
{
lastcode = WEXITSTATUS(status); //拿到子进程退出码保存到lastcode
}
return 0;
}
核心进程知识点:
fork():创建子进程。调用一次,返回两次:- 返回0:当前是子进程
- 返回正数:父进程,返回值是子进程pid
- 返回负数:fork失败
execvp(程序名,argv数组):用新程序覆盖当前进程。成功之后,原来代码全部消失,不会回到这行后面。
execvp会自动搜索PATH环境变量,直接写
ls不用写/bin/ls
waitpid(pid,&status,0):父进程阻塞,卡住不动,等待子进程结束。WEXITSTATUS(status):从status提取子进程退出码,保存进全局lastcode,供echo $?读取。
八、 main函数,shell主循环
cpp
int main()
{
InitEnv(); //初始化环境变量
char alias_buf[COMMAND_SIZE];
while(true) //永久循环,直到exit退出
{
PrintCommandPrompt(); //1.打印提示符
char commandline[COMMAND_SIZE];
if(!GetCommandLine(commandline, sizeof(commandline)))
continue;
if(!CommandParse(commandline))//2.解析输入命令,分割参数
continue;
//别名替换,如果命中别名,重新解析
if(AliasReplace(alias_buf))
{
CommandParse(alias_buf);
}
if(CheckAndExecBuiltin()) //3.内置命令,直接执行
continue;
Execute(); //4.外部命令,fork+exec执行
}
return 0;
}
while(true)无限循环,shell一直运行。
流程一步一步:
PrintCommandPrompt()打印提示符[user@xxx folder]#GetCommandLine()读取一行输入,空输入continue跳过CommandParse()分割字符串,失败就continueAliasReplace()检测别名;如果命中别名,重新解析替换后的命令CheckAndExecBuiltin()判断内置命令,true代表已经执行完毕,continue回到循环开头- 不是内置命令,调用Execute()fork子进程运行外部命令。
子进程跑完,回到循环,再次打印提示符等待输入。
补充注意:代码是简易版,现存缺陷
- strtok用空格分割,多个连续空格会丢参数 ,不能识别引号(
echo "hello world"会分割成echo,"hello,world") - 不支持管道
|、重定向>、后台& - 没有信号处理,Ctrl+C直接杀死整个shell
- 内存泄漏:malloc分配的环境变量没有free
- 不支持分号连续命令
cmd1;cmd2