当前位置: 首页 > news >正文

Linux进程控制小练习|手撕一个简易版shell(Version 1.0)

目录

手撕一个简单的shell(版本1)

输出提示内容

获取用户输入

拆分用户输入

执行对应程序

内建命令处理

cd命令处理

export命令处理

env命令处理

echo命令处理

优化


手撕一个简单的shell(版本1)

在写一个shell之前,需要了解本次实现的shell在系统中做的事情:

  1. 从硬盘中读取环境变量存储到自己的环境变量表(本次以拷贝系统shell的环境变量到模拟实现程序中为例)
  2. 当在shell运行的过程中,如果用户输入一个指令且该指令可以对应到一个程序,则该程序会正常运行

根据上面的描述以及系统shell的直观感受,可以将本次模拟的shell需要做的内容分为下面的几个步骤:

  1. 输出提示内容,例如:[epsda@ham-carrier ~]$,后面紧跟用户输入的指令
  2. 获取用户输入
  3. 将用户的输入拆分成单独的有效的字符串存入命令行参数表
  4. 创建子进程调用exec家族函数调用用户输入的指令执行指定的程序

输出提示内容

根据系统shell的样式[epsda@ham-carrier ~]$,可以看出包括下面的内容:

  1. 用户名
  2. @
  3. 主机名
  4. 当前路径
  5. 用户提示符$或者#

基本设计思路如下:

在系统环境变量中,存储着当前环境的用户名、主机名和当前路径,对于用户提示符,可以考虑判断是否是root用户,如果是则打印#,否则打印$

示例代码如下:

void printfCommandHint()
{// 获取用户名char* user = getenv("USER");// 获取主机名char* hostname = getenv("HOSTNAME");// 获取当前工作路径char* pwd = getenv("PWD");// 打印信息printf("[%s@%s %s]%c", user, hostname, pwd,strcmp(user, "root") == 0 ? '#' : '$');
}

获取用户输入

获取用户输入可以有很多方法,但是如果直接使用scanf或者cin则无法一次性获取到用户输入的指令和选项,所以直接使用scanfcin是不妥的,可以考虑使用使用fgets或者getline来代替

另外,因为不论是fgets还是getline,都默认以\n作为结束标志,但是需要注意,fgets尽管以\n结束,但是依旧会被读取到目标字符串中,但是getline不会,所以使用fgets一定要清除\n

需要注意的是,为了保证其他函数可以获取到输入的内容,可以定义一个数组存储输入的内容,但是这个数组不可以是一个局部变量,除非作为参数传递而不作为返回值,防止出现野指针问题

下面给出两种版本:

fgets版本:

const int BaseSize = 1024;
// 存储用户的输入的数组
char input_arr[BaseSize];char* getInput()
{// fgets以\n结束,但是会读取\nfgets(input_arr, BaseSize, stdin);// 尽管可能没有输入指令。但是输入指令存在\nsize_t sz = strlen(input_arr);// 消除\ninput_arr[sz - 1] = '\0';// 如果没有输入则直接返回空if(sz == 0){return nullptr;}return input_arr;
}

getline版本:

char* getInput()
{std::cin.getline(input_arr, BaseSize);if(strlen(input_arr) == 0){return nullptr;}return input_arr;
}

拆分用户输入

所谓拆分用户输入,就是为了将用户输入的指令转换成单个字符串,因为指令和选项都是以空格分隔,所以可以考虑将空格作为分隔符,此处因为前面是使用字符数组作为存储字符串的载体,所以考虑使用C语言的strtok函数拆分

拆分出来的内容即为命令行参数,所以还需要一张命令行参数表,该表需要被子进程拿到,所以需要定义为全局变量

最后,因为是持续等待用户输入,所以下一次用户输入新的指令,需要覆盖前面的指令,所以需要对命令行参数个数和命令行参数列表全部重置

示例代码:

void parseInput()
{// 清空上一次的命令行参数和个数memset(global_argv, 0, BaseSize);global_argc = 0;// 拆分读取的字符串for(char* ch = strtok(input_arr, " "); (bool)ch; ch = strtok(nullptr, " "))global_argv[global_argc++] = ch;
}

执行对应程序

对于外部命令,需要创建一个子进程来执行,所以基本流程就是创建子进程,让子进程调用exec家族函数执行对应程序,这里推荐选用execvp函数,而对于父进程来说,只需要等待子进程执行结束回收子进程即可

示例代码如下:

void executeProgram()
{pid_t id = fork();if(id == 0){execvp(global_argv[0], global_argv);exit(0);}// 父进程等待waitpid(id, nullptr, 0);
}

内建命令处理

前面四个步骤已经完成了一个基本的shell,在Linux中,绝大部分的命令都是外部命令,但是前面提到除了外部命令还有内建命令,例如cdechoexport等,而内建命令是不可以交给子进程处理的,因为子进程处理只是改变子进程的状态,不会影响父进程,而cdechoexport这些命令本质修改的就是当前父进程的属性,所以为了保证这些内建命令生效,需要单独对这些命令进行处理

最简单的处理方式就是在子进程执行前进行判断,如果是内建命令就直接由当前进程执行,而不交给子进程,本次分别处理下面的内建命令:

  1. cd命令
  2. export命令
  3. env命令
cd命令处理

cd命令的主要作用是更改当前的工作路径,所以当用户执行cd命令时,程序内部需要执行chdir函数来更改当前的工作路径cwd,示例代码如下:

bool checkIfBuiltInAndExecute()
{if(strcmp(global_argv[0], "cd") == 0){// 判断是否有两个参数防止越界访问if(global_argc == 2){chdir(global_argv[1]);return true;}}return false;
}

处理完cd命令可以发现尽管更改了工作路径,但是终端提示内容没有改变,原因是当前终端提示内容是程序加载时从父进程(系统shell)继承的环境变量,此时不论多少次获取,都只会读取到程序启动时的继承环境变量,所以pwd的值自始至终都是最开始启动的值,为了解决这个问题,可以将getenv函数换成getcwd函数

示例代码如下:

void printfCommandHint()
{// 存储当前工作路径char current_wd[BaseSize];// 获取用户名char* user = getenv("USER");// 获取主机名char* hostname = getenv("HOSTNAME");// 获取当前工作路径getcwd(current_wd, BaseSize);// 打印信息printf("[%s@%s %s]%c ", user, hostname, current_wd,strcmp(user, "root") == 0 ? '#' : '$');
}

单独处理回到家目录的情况:

bool checkIfBuiltInAndExecute()
{if(strcmp(global_argv[0], "cd") == 0){// 判断是否有两个参数防止越界访问if(global_argc == 2){// 处理cd ~if(strcmp(global_argv[1], "~") == 0){std::string user(getenv("USER"));if(user == "root"){chdir("/root");}else {chdir(getenv("HOME"));}}else {chdir(global_argv[1]);}return true;}}return false;
}
export命令处理

export命令是将指定的环境变量导入到当前进程对应的环境变量表,所以也不可以交给子进程处理,当前模拟的shell所有的环境变量均是与系统shell共享的,因为并没有修改,一旦修改,就会发生写时拷贝,为了让模拟的shell拥有自己的环境变量,可以在程序启动时,加载父进程的环境变量到当前的模拟shell程序中,再处理export命令

模拟的shell复制系统shell的环境变量示例代码如下:

// 环境变量表
char* global_env[BaseSize];
// 声明父进程的环境变量表
extern char** environ;void initShell()
{// 拷贝父进程的环境变量表for(size_t i = 0; environ[i]; i++){global_env[i] = environ[i];}// 最后一个位置置为空global_env[strlen(*(environ))] = nullptr;
}

处理export命令如下:

bool checkIfBuiltInAndExecute()
{// ...else if(strcmp(global_argv[0], "export") == 0){if(global_argc == 2){size_t i = 0;// 先找到环境变量表为空位置for(; global_env[i]; i++);// 添加新的环境变量,不考虑重复,理论上不会出现空间不足导致越界global_env[i] = (char*)malloc(sizeof(strlen(global_argv[1])));strncpy(global_env[i], global_argv[1], (strlen(global_argv[1]) + 1));global_env[++i] = nullptr;return true;}}return false;
}
env命令处理

env本质就是遍历当前进程的环境变量,因为当前模拟的shell进程的环境变量表已经拷贝完成,所以只需要遍历拷贝后的环境变量表

示例代码如下:

bool checkIfBuiltInAndExecute()
{// ...else if(strcmp(global_argv[0], "env") == 0){for(size_t i = 0; global_env[i]; i++){printf("%s\n", global_env[i]);}return true;}return false;
}
echo命令处理

本次修改的echo主要考虑两种情况:

  1. echo $?:打印上一次进程的退出信息码
  2. echo 字符串:打印指定字符串

对于第一种,考虑创建一个全局变量接收每一个指令执行后的退出码,对于内建命令使用自定义退出码,对于外部命令通过status变量和相关的宏操作获取

对于第二种,考虑直接打印字符串即可

示例代码如下:

// 添加退出码位置与echo命令设置
bool checkIfBuiltInAndExecute()
{if(strcmp(global_argv[0], "cd") == 0){// ...exitCode = NORMAL;return true;}else if(strcmp(global_argv[0], "export") == 0){// ...exitCode = NORMAL;return true;}else if(strcmp(global_argv[0], "env") == 0){// ...exitCode = NORMAL;return true;}else if(strcmp(global_argv[0], "echo") == 0){// 获取第二个参数的第一个字符和第二个字符if(global_argv[1][0] == '$' && global_argv[1][1]){printf("%d\n", exitCode);}else {// 直接输出字符串printf("%s\n", global_argv[1]);}// 将上一次的退出码置为0exitCode = NORMAL;return true;}exitCode = FAILED;return false;
}// 其他位置
void executeProgram()
{// ...// 存储进程退出码int status = 0;// 父进程等待pid_t rid = waitpid(id, &status, 0);// 正常回收情况下if(rid > 0){if(WIFEXITED(status)){// 正常退出码exitCode = WEXITSTATUS(status);}else if(WIFSIGNALED(status)) {// 被信号终止exitCode = WTERMSIG(status);}}
}

优化

修改了父进程的环境变量后,子进程应该拿到的是当前父进程的环境变量,所以将开始的execvp函数改为execvpe函数:

void executeProgram()
{// ...execvpe(global_argv[0], global_argv, global_env);// ...
}

观察终端提示符可以发现,模拟的shell程序会显示从根路径一直到当前路径,为了尽可能还原,可以考虑将获取到的字符串按照/进行截取,一共分为两种情况:

  1. 在根目录:路径只有一个/
  2. 不在根目录:最后一个目录是所需

所以可以考虑将最后一个/作为标志,反向取出字符,直到遇到最后一个/,单独处理根目录的情况即可,示例代码如下:

std::string getCwd()
{char current_wd[BaseSize];getcwd(current_wd, BaseSize);std::string s1(current_wd);if(s1.size() == 1){return s1;}std::string ret;// 反向遍历字符串,直到遇到第一个/auto rit = s1.rbegin();while(rit != s1.rend()){if((*rit) == '/'){break;}ret.push_back((*rit));++rit;}// 反转字符串reverse(ret.begin(), ret.end()); return ret;
}

也可以使用下面的版本:

std::string getCwd()
{    char current_wd[BaseSize];getcwd(current_wd, BaseSize);std::string user(current_wd);// 如果是根目录,直接返回/if (user.size() == 1){return user;}// 否则从末尾向前找倒数第一个/size_t pos = user.rfind("/");// 返回找到后的字符串进行截取return user.substr(pos + 1);
}


http://www.mrgr.cn/news/50585.html

相关文章:

  • 致同举办企业重组案例及南沙“双15”税收优惠政策分享会
  • 全面解析CUPS零日远程代码执行漏洞曝光事件
  • Mac book不会应用双开?一篇文章教会你最全的应用双开方法
  • 高端官网制作公司怎么分辨是否靠谱?2024专业网站制作公司哪家好TOP5
  • 如何选择适合自己的电子元器件?
  • 性格色彩报告的解读
  • 光控资本:中航电测西部大开发概念股接力大涨,它们业绩如何?
  • SSD | (四)NAND闪存(中)
  • HiT-SR:基于层级Transformer的超分辨率,计算高效且能提取长距离关系 | ECCV‘24
  • Accessibility into Development for Web Developers
  • 标题:民峰金融:全球投资者的智能化财富管理平台
  • 自学网络安全Web安全,一般人我还是劝你算了吧
  • 一文深度学习java内存马
  • vue3 计算字符串的高度与宽度,通过Canvas API的TextMetrics 接口来实现
  • 初识Java: 常见注意事项总结
  • 从零创建苹果App应用,不知道怎么申请证书的可以先去看我的上一篇文章
  • .net core 3.0 与 6.0 有哪些不同
  • web 0基础第六节 表格标签
  • springboot 拷贝了一个module 启不起来
  • kubernetes--资源调度Selector/Deployment/SatatefulSet/DaemonSet