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

从零开始实现Shell | Linux进程调度实战

前言

本文将从0开始实现一个迷你的命令解释器,即myShell。myShell作为一个mini Shell将具备Shell程序的两个基本功能:解析指令与执行指令。

参考大佬博客:
Tutorial - Write a Shell in C • Stephen Brennan

何为Shell

定义

Shell是命令行解释器(command Interpreter)的统称,也是一种脚本解释语言。

感性版本

大语言模型生成的小故事:

在很久很久以前,在一个神秘的数字王国里,有一座庄严的城堡——这座城堡就是操作系统的内核,它掌控着整个王国的力量和秘密。但这座城堡太过复杂和危险,普通的居民(也就是我们日常使用计算机的人)根本无法直接进入其中。为了让居民能够安全、便捷地向城堡传达他们的请求,国王特地设立了一个聪明又忠诚的使者,这位使者名叫 Shell。
Shell 就像城堡的大门,站在那里等待居民的指令:当你告诉它“打开文件”、“运行程序”时,Shell 会把你的话翻译成城堡内部能理解的语言,然后迅速将结果反馈回来。正因为有了这位使者,居民们不必担心直接触碰城堡的深处,也不必费心学习那些只有宫廷高手才能明白的奥秘。
不仅如此,Shell 还有一个神奇的本领:它能记录下一连串的指令,组合成一个个小小的剧本,也就是我们所说的“脚本”。这样,居民们就可以提前编排好一系列任务,让 Shell 自动地、连续地把事情办好,就像一个勤劳的管家,替你完成日常琐事一样。
这位忠实的使者 Shell 不仅是居民与城堡之间的翻译官,更是个智慧的记录者和执行者。它既保护了王国的核心秘密,又大大简化了居民的生活。正如某位博主用生动的小故事所描述的那样,Shell 就是那位替你奔走传话、自动化处理繁琐事务的神奇传话人。

关系图

在这里插入图片描述

简单来说,Shell就是操作系统上层的外壳程序,协助用户与内核之间的通信。将用户输入的命令,解析传递给操作系统内核,同时返回来自操作系统内核的反馈。

From Draft to Demo

整个小项目将采用C/C++混编进行实现,原因只有一点:方便。C++中的string类有更多的接口,在处理字符串的时候可以更加轻松。如果你想实现一个更原汁原味的Shell(Linux中的Bash使用纯C语言实现),也可以尝试使用纯C语言编写!

循环结构

无论简单的程序还是复杂的程序,从其开始到运行结束,一定具备三个环节:

  1. 初始化,载入资源
  2. 执行任务
  3. 销毁,回收资源

由此我们可以确定主函数中的框架:

int main() {system("clear");//initate my own shellInitShell();//get into a work loopLoops();return 0;
}

初始化Shell可难可简单,我们首先让myShell能够工作起来,所以先关注于Loops()如何实现。Shell程序在其工作循环中会循环做三件事:

  1. 读取命令
  2. 解析命令
  3. 执行命令

其运行流程如下图:

初始化
工作循环
是否有命令输入
读取命令
分析命令
执行命令
检查是否收到退出信号
退出
回收资源

由此,我们可以明确,在Loops()中需要解决的核心问题有:

  1. 如何读取命令?
  2. 得到命令字符串后,如何解析命令?
  3. 得到命令及其选项后,如何执行命令?
    接下来我们将一步步解决上面的三个问题,完成myShell Demo。

读取指令

读取命令比较简单,本质就是处理用户的输入操作:从标准输入读取完整的、直到换行符\n的一行输入,并丢弃最后的换行符。这个基本技能是C语言入门时一定遇到过的问题,可以参考C Primer Plus。

此处,使用一个动态数组作为输入的缓冲区【需要判断缓冲区是否已满,否则需要进行扩容】。同时,这里为了模仿Shell的功能,加入了对“续行”的判断,如果遇到反斜杠\,则需要另起一行,并给出用户提示符【fflush()是对标准输出做强制刷新,确保用户提示符能够在屏幕上显示】。

else if(ch == '\\') {getchar(); //get the '\n'printf(">");fflush(stdout);continue;}

完整的读取指令的代码如下:

char* GetCommand() {int buff_size = BUFF_SIZE;char *buff = (char*) malloc(buff_size);if(buff == NULL) {perror("malloc failed!");exit(EXIT_FAILURE);}char ch = '\0';int cot = 0;while((ch = getchar()) != EOF) {if(ch == '\n') {break;}else if(ch == '\\') {getchar(); //get the '\n'printf(">");fflush(stdout);continue;}if(cot == buff_size) {buff_size = buff_size + BUFF_SIZE; buff = (char*) realloc(buff, buff_size);}buff[cot++] = ch;}if(cot == buff_size) {                             buff_size = buff_size + BUFF_SIZE;             buff = (char*) realloc(buff, buff_size);}                                                  buff[cot] = '\0';return buff;
}

解析指令

Linux中的一条指令格式都是

$ [command] [option]

因此,在解析命令要做的就是将一整个字符串进行拆分,然后单独存储指令与选项。为了后续能够直接进行指令的执行,这里将使用一个字符串数组char *argv进行存储,argc记录数组中的元素数量。具体实现时,只需要使用C语言中的strtok()函数进行字符串分割即可。
实现细节:

  • argv的最后一个元素必须以NULL结尾。
  • argvargc均为全局变量,这样可以避免在函数之间的传参问题。
  • 返回值此处设置为bool类型,是提前判断命令解析后的有效性。同样可以设置为void类型,在函数中就只进行命令的解析。
bool ParseCommand(char* command) {argc = 0;const char* split = " ";char* token = strtok(command, split);while(token != NULL) {argv[argc++] = token;token = strtok(NULL, split);}argv[argc] = NULL;if(argc == 0) {// the part of command is 0return false;}return true;
}

执行指令

该部分是myShell中最有技术含量的部分,将通过创建一个子进程,并使用系统调用exec(execute a file)实现。

exec家族

通过命令查看系统调用exec的详细用法:

$ man exec

在使用该系统调用前,首先需要导入头文件<unstd.h>。
它提供了不同的调用形式,但是本质都是将当前进程中的文件进行替换,转而继续执行替换后的内容。此处,便是借助该系统调用,替换当前的myShell程序,让其执行我们解析得到的命令。

int execl(const char *pathname, const char *arg, .../* (char  *) NULL */);
int execlp(const char *file, const char *arg, .../* (char  *) NULL */);
int execle(const char *pathname, const char *arg, .../*, (char *) NULL, char *const envp[] */);int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[])

通过函数定义,我们可以发现,这些函数接口本质上传入两个东西:命令+参数(可选envp),正是我们在解析命令时得到的东西。
不同的exec在使用上略有不同,我们可以通过其后缀进行区分。区分技巧:

  • 后缀’v’及后缀‘l’:‘v’可以理解为“vector",后缀带’v‘,表示期望你传入一个向量(数组);’l‘可以理解为’list‘,表示你需要挨个输入选项。
  • 有无后缀’p’:‘p’可以理解为“path”,后缀带‘p’,代表函数会帮你处理路径,只需要传入期望执行的文件名即可
  • 有无后缀’e’:‘e’可以理解为“Environment”,代表期望你传入一个环境变量数组。
    根据前面实现细节的不同,选择合适的exec接口,便能直接传入对应变量,以执行命令。

fork()

通过系统调用fork(),可以创建一个子进程,使用该函数需要包含头文件<sys/types.h><unistd.h>,函数原型为:

pid_t fork(void);

在父进程里,该函数的返回值为子进程的进程编号,通过系统调用waitpid(),等待子进程的退出并回收子进程。具体的使用方法,此处不展开,在具体实现中使用了宏定义WEXITSTATUS(),获得子进程的退出信息。

最终得到完整的模块代码:

bool ExecuteCommand() {pid_t id = fork();if(id == 0) {execvp(argv[0], argv);perror("execvp error");exit(2);}int status = 0;pid_t pid = waitpid(id, &status, NULL);if(pid > 0) {lastcode = WEXITSTATUS(status);}else if(pid == -1) {perror("wait process error!");exit(3);}return true;
}

初始化

当上述模块实现完成后,在Loops中分别调用,该Shell程序便能运行了,理论上是不需要任何的初始化工作。
但是为了完全模仿真实的Shell状态,我们初始化的时候,可以构建一个Shell的提示信息。具体实现就是每次读取指令前,格式化输出以下字符串。

[user]@[hostname]:[cwd]$

如果后续还想扩展内建命令,环境变量表也可以在初始化的时候导入。

拓展

上述内容实现后,初步完成了myShell Demo,但是相比Linux中的Shell,仍然可以拓展功能,让Shell更加完善:

  • 内建命令
  • 输入、输出重定向
  • 管道

  • 本文拓展实现了前面两个功能.

内建(Built-in)命令

内建命令,顾名思义,这些命令不再在子进程中中执行,而是在Shell内部执行。具体哪些命令需要实现为内建命令,这是由命令的具体任务所决定的。比如’cd‘命令,在切换目录的同时,我们期望切换当前进程的工作目录,而不是子进程的,所以应该由Shell自己执行该操作。
myShell中实现了三个常见的内建命令:

  • cd
  • help
  • echo

cd

使用系统调用unistd.h需要包含头文件 :

#include <unistd.h>

使用系统调用函数chdir(),达到切换当前工作目录(CWD,Current Work Directory)的目的。参数path可以传入相对路径,也可以传入绝对路径

int chdir(const char \*path);

该函数执行成功执行则会返回0,否则返回-1;

bool cd() {if(argc != 2) {printf("check your command! usage: cd <target dir>\n");lastcode = 126;return true;}if(chdir(argv[1]) != 0) {perror("chdir error!");}return true;
}

help

本质是格式化输出,向用户输出帮助信息。

bool help() {if(argc != 1) {printf("check your command! help doesn't support any options!\n");lastcode = 126;return true;}printf("myShell, version 2.0 -- 2025.05\n");printf("\nThe usage method is the same as that of a common shell.\n");printf("These commands are built-in command:\n");for(int i = 0; i < builtc; i++) {printf("%d. %s\n", i + 1, builtv[i]);}printf("\n----------------------\n");printf("v2.0 update log:\n");printf("Now supports redirect operation\n\n");return true;
}

echo

原版echo的功能还是比较强大的,除了简单的向屏幕输出字符串,其还支持输出变量、命令执行结果等。在myShell中,仅实现了对环境变量以及程序退出码的输出。

bool echo() {if(argc == 1) {printf("\n");return true;}std::string opt = argv[1];if(opt == "$?") {std::cout << lastcode << std::endl;lastcode = 0;}else if(opt[0] == '$') {if(env.count(opt.substr(1)) != 0) {std::cout << env[opt.substr(1)] << std::endl;}else {std::cout << std::endl;}}else {std::cout << opt << std::endl;}lastcode = 0;return true;
}

重定向模块

在Linux中,万物皆文件,所以向屏幕打印,本质是向“屏幕文件”输出。因此,重定向可以理解为使用其他文件“替换”屏幕文件,就能实现重定向输出的效果。重定向输入的原理也是一样。这里将借用两个系统调用open()dup2实现这一功能.

open()

系统调用open()函数,根据条件打开(创建)文件。
需要包括头文件:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

函数原型:

int open(const char *pathname, int flags, mode_t mode);
  • pathname:: 该参数指定期望打开的文件路径,如果该路径下的文件不存在,则会根据flags的情况,选择性的
  • flags:: flags标识文件的打开方式,以位图的形式输入(多个状态标识可以直接或运算[ | ]后,作为一个变量传入)
  • mode:: mode参数是搭配具体的flags操作一起使用,用于创建文件时的权限指定。可以直接按8进制的形式输入四位的权限,也可以输入宏定义的标签,详见工作手册。
flags
  • O_CREAT
    如果文件路径不存在,则会创建新文件,并根据mode设定文件的权限。
  • O_RDONLY
    文件以只读的形式打开。
  • O_WRONLY
    文件以只写的形式打开。
  • O_RDWR
    文件打开后可读可写。
  • O_APPEND
    文件以追加的模式打开,输入偏移位置将为文件原始内容的末尾。
  • O_TRUNC
    如果文件存在,且允许写入(即满足O_RDONLY或O_WRONLY),输入偏移位置则会定位到文件开头,即间接清空文件的原始内容。

其返回值就是打开文件的文件描述符,将通过文件描述符实现重定向。

dup2

使用该系统调用,需要包含头文件:

#include <unistd.h>

函数原型为:

int dup2(int oldfd, int newfd);

作用效果为,将当前的文件描述符oldfd,替换为newfd
由于标准输入、标准输出及标准错误的文件描述符分别为:

stdin  --> 0
stdout --> 1
stderr --> 2

所以重定向输出时,newfd为0;重定向输入时,newfd为1.

http://www.xdnf.cn/news/1424953.html

相关文章:

  • Product Hunt 每日热榜 | 2025-09-01
  • 基于YOLOv11的脑卒中目标检测及其完整数据集——推动智能医疗发展的新机遇!
  • 齿轮里的 “双胞胎”:分度圆与节圆
  • [React]监听Form中某个字段的变化
  • 微算法科技(NASDAQ:MLGO)张量网络与机器学习融合,MPS分类器助力顶夸克信号识别
  • deepseek doubao chatgpt 优缺点分析
  • 并发--并发中的线程状态及不同状态下线程所在队列
  • React学习教程,从入门到精通, React 入门指南:创建 React 应用程序的语法知识点(7)
  • OpenCV-CUDA 图像处理
  • 数据库常见故障类型
  • 知识产品和标准化
  • 在 Qt 中加载 .qm 翻译文件
  • 跳跃游戏(二):DFS 求解最少跳跃次数与最优路径
  • 专项智能练习(Word)
  • JavaSE:抽象类和接口
  • 计算机视觉(五):blur
  • 原子操作(Atomic Operation) 是指不可被中断的操作——要么完整执行,要么完全不执行
  • 贵州在假期及夏天结束后保持旅游活力的策略分析
  • AI如何重塑电力工程设计?揭秘良策金宝AI的六大“超能力”
  • SQLSERVER关键字:N
  • VBA数据库解决方案第二十二讲:根据工作表数据生成数据库中数据表
  • 算法练习——189.轮转数组
  • 【逆序对 博弈】P10737 [SEERC 2020] Reverse Game|普及+
  • 【开题答辩全过程】以 基于JSP的养生网站设计与实现为例,包含答辩的问题和答案
  • MySQL 中 InnoDB 引擎的事务隔离级别与“可重复读”隔离级别下的 SQL 编写规范
  • Linux 进程间通信(IPC)
  • 大型语言模型微调 内容预告(69)
  • 【Docker】2025版Ubuntu 22.04 安装 Docker Docker Compose 指南
  • 电力工程师的AI时代已来,这6大功能彻底颠覆传统工作模式
  • 系统性学习数据结构-第二讲-顺序表与链表