Shell外壳的简易模拟
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Shell外壳的简易模拟相关的知识,希望对你有一定的参考价值。
写在前面
我们来谈目前进程控制的最后的一个内容,这个博客内容主要涉及到几个进程替换的相关函数,我们需要学习它们的用法.最后我们需要模拟实现一个简易的shell作为进程控制的成果.
进程替换
这个是我们今天的理论部分,需要花费的时间比较多,内容多但是都不难,都是和上面我们谈到知识有关联的.
什么是替换
我们知道了如果我们创建一个子进程,那么这个子进程执行的是父进程的代码片段,如果我们非要让子进程执行一个新的程序,我们该如何做到呢?这就是进程替换作用.所谓的替换就是李代桃僵,我们用一个新的程序覆盖替换旧的.
为何要替换
那么这里我们有点疑惑了,我们为什么要子进程去执行其他的程序?有什么实际意义吗?这里我们就要谈一下,一般而言在服务器设计的时候,我们希望让子进程干下面两件事:
- 让子进程执行父进程的代码片段
- 让子进程执行磁盘中其他程序,这个程序可能是C\\++,Java,php等语言写的,我们可以通过进程替换来实现
想一想,在一个公司里面有使用不同语言进行编程,我们如何把不同语言的程序运行在Linux环境上呢?进程替换可以把这些语言进行混编,后面我们也会简单的演示一下.
替换原理
我们知道对于一个进程,存在task_struct,虚拟内存,页表等相关信息.进程的虚拟地址空间通过页表映射到实际的物理内存中,这些内容在前面我们都谈过了.此时如果我们fork一个子进程,该子进程的task_struct,虚拟内存,页表会以父进程为模板来进行创建和实例化,此时的代码片段和数据都是按照写时拷贝和父进程共享.那么我们可以通过一个方法,把磁盘中的另外一个程序记载都内存中,想办法把子进程的页表映射到这个程序的物理地址.这就是进程替换的原理.
- 将磁盘中的程序加载到内存
- 重新建立页表映射,谁执行程序替换谁就重新建立映射
发生进程替换的子进程会和父进程彻底分离,该进程执行一个全新的程序,注意此时没有创建新的进程原因是该子进程的pid都没有变,我们只是改变一些映射罢了.
替换
我们已经知道原理了,那么我们应该如何做才能实现这个功能呢?此时我们在想我们把一个程序加载到内存中,这个操作是我们来做的吗?不是,要知道OS是不相信任何人的,所以这是OS亲自完成的,我们需要做的就是调用OS给我们提供的接口,让它完成这个动作.我们需要看一下OS给我们提供的接口.
[bit@Qkj 11_11]$ man execl
我们发现这里存在6个函数,我们疑惑为何会存在这么多的接口,不就是要实现一个进程替换的功能吗?这里是和C\\++函数的重载一样,是为了适配不同的场景.这里我们只谈5个接口,只要我们会了这5个接口,其他的不在话下.注意,我们下面接口的使用是按照该函数的参数规范来使用的,或许你会发现有的时候一个接口具有默认行为,这里我不支持不反对,可以等到熟悉了这些接口再来自己用一下默认行为.
execl
我们先来见见猪跑,先看看现象,只有把现象给大家呈现出来,我们来逐步了解.这个我们先用execl这个系统接口(这个说法不准确).
int execl(const char *path, const char *arg, ...);
这里我们先不谈子进程呢个,只是简单的进程替换,这个时候我们开始想我们想要发生进程替换,我们需要初步完成几个事情.
- 要有一个程序,程序不就是一个文件吗?我们需要先找到这个程序的地址,这个地址可以唯一标定一个程序
- 我们知道指令也是程序,这些程序可能携带选项,我们需要明确的告诉OS这个程序要不要携带选项
上面我们看到了 execl的函数参数中带有**...**,这里是可变参数,我们之前专门写了一篇博客简单的说了它的原理,这里就不谈了.再来分析下这个函数,我们要解决两个问题,程序在哪里?这就是第一个参数的作用.程序是否携带选项?这个就是后面的参数.有的人可能还会疑惑,下面看这个图片.
命令行上我们怎么写,这里就怎么写,最后必须以空指针NULL作为结束的标志,表示参数传递完比,先来简单的见识一下.
#include <stdio.h>
#include <unistd.h>
int main()
printf("我是一个进程, pid %d\\n",getpid());
execl("/usr/bin/ls","-a","-l", NULL);
printf("我执行结束了\\n");
return 0;
不同于上面,我们再来试试一个不带选项的指令.
int main()
printf("我是一个进程, pid %d\\n",getpid());
execl("/usr/bin/top","top", NULL);
printf("我执行结束了\\n");
return 0;
你有没有发现一个问题,进程是发生替换了,但是我们第二个打印函数的结果好象没有出来.这里是为什么呢?这是应为一旦我们执行execl替换成功,是将当前程序的代码和数据全部都被替换了,execl后面的代码都没了,所以后面的printf就没了.那么前面的printf就存在呢?因为此时它的程序替换还没有开始.
这里我们又存在一个疑问,程序替换函数要不要判断返回值?我们要明白判断返回值的目的是为了什么?它是为了确保替换成功,此时一旦成功了,我们后面的代码就被替换了,判断返回值的代码都没了不会被执行.
那么它的返回值有意义吗?有的,前面我们说的是替换成功了,要是失败呢?这里就可以下一个结论,我们不需要判断进程替换的返回值,执行成功程序被替换,失败了程序继续往下走,返回值就是记录一下替换失败的原因,没什么用.
int main()
printf("我是一个进程, pid %d\\n",getpid());
execl("/usr/bin/aaaaaaaaaa","top", NULL);
printf("替换失败\\n"); //到这里替换一定失败了
return 0;
现在我们开始想了,对于父进程而言,一旦我们替换成功,这个父进程的后续代码就不会执行了,所以我们希望通过子进程来发生进程替换,这里我们创建子进程,下面就是我们要实现的框架.
#include <stdio.h>
#include <unistd.h>
int main()
pid_t id = fork();
if(id == 0)
// child
// 发生进程替换
// 到这里 一定是父进程 (如果替换成功的话)
return 0;
下面我们正式的使用一下.
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
pid_t id = fork();
if(id == 0)
// child
// 发生进程替换
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
exit(1); // 只要子进程执行这个语句,替换一定失败了
// 到这里 一定是父进程 (如果替换成功的话)
int status = 0;
pid_t ret = waitpid(id, &status, 0); // 阻塞等待
sleep(3);
if(ret == id)
printf("父进程等待成功\\n");
return 0;
写时拷贝
这里我们需要分析一个问题,子进程发生了替换会不会影响父进程呢? 不会的,因为进程具有独立性,我们只是改变了子进程.那么我们是如何做到的呢?前面我们谈过写时拷贝,一旦我们fork出子进程,数据层面发生写时拷贝,父子进程代码共享.这里我们修改了子进程的代码为何不会影响父进程?
我们知道,父子进程开始的共用的时候共用同一块实际的内存,一旦我们子进程修改数据,这个时候发生写时拷贝,数据层面发生分离.同样的,这里我们可以这么理解,当我们子进程发生替换的时候,子进程在代码层面上也发生了写时拷贝,完成代码层面的分离.这里就是进程替换是发生了数据和代码两个层面上的分离.
execv
现在我们可以吃吃猪肉了,我们需要把后面的接口都要使用一下,最后得大家提炼规律.和上面的一样,发生进程替换我们需要知道新程序的地址在哪里和如何执行这个程序,先来看看该接口的声明.
int execv(const char *path, char *const argv[]);
第一个参数没有发生变化,表示该程序的地址,这里我们重点谈一下第二个参数,它是什么?是一个指针数组,该数组的元素是指向字符串的指针,这里和上面的可变参数作用一样,都是告知这个程序如何执行,只是使用的方法变了而已.
int main()
pid_t id = fork();
if(id == 0)
// child
// 发生进程替换
char* argv[] = (char*)"ls",
(char*)"-a",
(char*)"-l",
NULL; // 最后一个一定是 NULL
execv("/usr/bin/ls", argv);
exit(1); // 只要子进程执行这个语句,替换一定失败了
// 到这里 一定是父进程 (如果替换成功的话)
int status = 0;
pid_t ret = waitpid(id, &status, 0); // 阻塞等待
sleep(3);
if(ret == id)
printf("父进程等待成功\\n");
return 0;
函数对比
我们需要对比一下前面的两个接口,便于我们记忆.上面两个没有本质的不同,就是参数传递的不同.这里给大家一个记忆的方法.
- execl -> l -> list
- execv -> v -> vector
execlp
我们再来看第三个接口,这里我们发现了多了一个p,这里的p代表的是PTAH.我们分析一下.在环境变量那里我们已经知道了执行一个指令,默认的搜索路径就是PATH,所以这个接口我们可以不用带路径,只要改指令可以在PATH中找到,此时带一个程序名就可以了.看一下函数声明,第一个参数就是我们刚才谈的,第二个是一个可变参数,告知该程序如何执行.
int execlp(const char *file, const char *arg, ...);
试一下吧.
int main()
pid_t id = fork();
if(id == 0)
// child
execlp("ls", "ls", "-a", "-l", NULL);
exit(1); // 只要子进程执行这个语句,替换一定失败了
// 到这里 一定是父进程 (如果替换成功的话)
int status = 0;
pid_t ret = waitpid(id, &status, 0); // 阻塞等待
sleep(3);
if(ret == id)
printf("父进程等待成功\\n");
return 0;
这里我们有一个问题,我们发生进程替换中传入的两个参数ls,可以省略吗? 意义一样吗?这里我们就可回答了,不可以省略,第一个参数是我们需要执行什么程序,第二个参数是告知我们如何执行该程序.
execvp
和上面一样,只不过可变参数变成了数组罢了.
int main()
pid_t id = fork();
if(id == 0)
// child
// 发生进程替换
char* argv[] = (char*)"ls",
(char*)"-a",
(char*)"-l",
NULL;
execvp("ls", argv);
exit(1); // 只要子进程执行这个语句,替换一定失败了
// 到这里 一定是父进程 (如果替换成功的话)
int status = 0;
pid_t ret = waitpid(id, &status, 0); // 阻塞等待
sleep(3);
if(ret == id)
printf("父进程等待成功\\n");
return 0;
execle
在谈最后一个接口之前,我们这里补充的内容.上面我们谈到了子进程可以发生任意进程的替换,可是上面我们都是使用的Linux自己的指令,我们是不是可以自己写一个程序让子进程替换它呢?这里我们用一下.
Makefile
在写自己的程序之前,我们还要补充个知识点.前面我们只用的makefile都是编译一个程序,现在我们要面临一次性得到两个可执行程序的情况,这里我吗们该如何做到?下面是我们的想法.
myexec:myexec.c
g++ -std=c++11 -o $@ $^
mycmd:mycmd.cpp
g++ -std=c++11 -o $@ $^
.PHONY:clean
clean:
rm -f myexec
可惜我们make一下只能得到一个可执行程序,这里我们需要使用一个伪目标,帮助我们完成
.PHONY:all
all:myexec mycmd
myexec:myexec.c
g++ -std=c++11 -o $@ $^
mycmd:mycmd.cpp
g++ -std=c++11 -o $@ $^
.PHONY:clean
clean:
rm -f myexec mycmd
现在我们可以测试自己想要的东西了,这里编辑一下源文件
#include <iostream>
using namespace std;
int main()
cout << "hello bit" << endl;
cout << "hello bit" << endl;
cout << "hello bit" << endl;
return 0;
看好了,我们用子进程来替换这个程序,这里使用execl接口.
int main()
pid_t id = fork();
if(id == 0)
// child
// 发生进程替
execl("/home/bit/104/2022/11_12/mycmd", "mycmd",NULL);
exit(1); // 只要子进程执行这个语句,替换一定失败了
// 到这里 一定是父进程 (如果替换成功的话)
int status = 0;
pid_t ret = waitpid(id, &status, 0); // 阻塞等待
sleep(3);
if(ret == id)
printf("父进程等待成功\\n");
return 0;
对于execl接口的第一个参数,我们没有必要写绝对路径,可以找到这个程序就可以,这里可以换成相对路径.
int main()
pid_t id = fork();
if(id == 0)
// child
// 发生进程替
execl("./mycmd", "mycmd",NULL); // 相对路径
exit(1); // 只要子进程执行这个语句,替换一定失败了
// 到这里 一定是父进程 (如果替换成功的话)
int status = 0;
pid_t ret = waitpid(id, &status, 0); // 阻塞等待
sleep(3);
if(ret == id)
printf("父进程等待成功\\n");
return 0;
前面我还说了,进程替换可以替换成其他语言,上面的是C语言替换C++,由于C语言和C++具有很高的关联性,我们这里再测试一门脚本语言.
#! usr/bin/bash
cnt=0;
while [ $cnt -le 4 ]
do
echo "hello shell"
let cnt++
done
先暂时看一下该脚本的结果.
这里我们也用子进程发生替换,你会发现都可以运行.此时我们进一步论证我们上面说的为何发生进程替换的第二条原因.
int main()
pid_t id = fork();
if(id == 0)
// child
// 发生进程替
execl("/usr/bin/bash", "bash", "test.sh", NULL);
exit(1); // 只要子进程执行这个语句,替换一定失败了
// 到这里 一定是父进程 (如果替换成功的话)
int status = 0;
pid_t ret = waitpid(id, &status, 0); // 阻塞等待
sleep(3);
if(ret == id)
printf("父进程等待成功\\n");
return 0;
execle
好了,我们可以看一看这个借口了,其中我们发现这里面有三个参数,前两个都已经认识了,这里重点分析第三个.
int execle(const char *path, const char *arg,
..., char * const envp[]);
我们再环境变量那里谈过子进程会继承父进程的环境变量,这里面的第三个参数是环境变量.
#include <iostream>
using namespace std;
int main()
cout << "PATH " << getenv("PATH") << endl;
cout << "-------------------------"<< endl;
cout << "MYPATH " << getenv("MYPATH") << endl;
cout << "hello bit" << endl;
cout << "hello bit" << endl;
cout << "hello bit" << endl;
return 0;
上面的代码我们知道肯定会发生崩溃.原因就是当前父进程是没有MYPATH这个环境变量的.
如果我们手动导入一下环境变量,可以这么做,不过这里我们运行的结果也是会崩溃.
int main()
pid_t id = fork();
if(id == 0)
// child
char* env[] =
(char*) "MYPATH=YouCanSeeME!!"
;
execle("./mycmd", "mycmd", NULL,
env); // 手动导入环境变量
exit(1); // 只要子进程执行这个语句,替换一定失败了
// 到这里 一定是父进程 (如果替换成功的话)
int status = 0;
pid_t ret = waitpid(id, &status, 0); // 阻塞等待
sleep(3);
if(ret == id)
printf("父进程等待成功\\n");
return 0;
这里我们先不打印PATH,把代码给注释一下 ,此时你就会发现我们成功了.
int main()
// cout << "PATH " << getenv("PATH") << endl;
cout << "-------------------------"<< endl;
cout << "MYPATH " << getenv("MYPATH") << endl;
cout << "hello bit" << endl;
cout << "hello bit" << endl;
cout << "hello bit" << endl;
return 0;
这里有一个问题,我们这里传入的环境变量是追加式的添加还是覆盖式的添加?从上面的结果可以看出是覆盖式的导入环境变量,至于我们如何追加,得到shell那里在谈,我们还需要再次分析一下环境变量.
execve
我们不是说要谈5个接口吗?这里为何还要增加一个?这是由于上面5个包括一个我们没有谈的都是对这个接口的封住,本质底层调用的就是这个接口,这里我们需要和大家说明一下.下面我们看这个系统接口的第一个参数,看着和execlp的参数名一摸一样,实际上这里是需要我们传入真实的地址的,不仅仅依赖默认的搜索路径PATH.
int execve(const char *filename, char *const argv[],
char *const envp[]);
shell
到这里我们需要把进程控制的所有的内容通过制作一个简易的shell做一个总结,里面涉及的内容还是比较多的.我们一点一点来分析.
我们在想,所谓的shell外壳不就是一个死循环,等待我们输入指令,当这个循环得到指令的时候,把这个指令给子进程让他进行子进程替换,处理这个指令,同时又不会影响父进程的死循环吗!!思路只要我们打开了,代码实现就变得容易的多了.
此时我们先来搭建出框架,来一个死循环,注意这里我把头文件给省略了,等一会你用到哪个函数就包哪个头文件.
int main()
while(1)
return 0;
此时我们再分析,shell的题诗符不久是下面的三个字符串吗?关于字符串的内容,例如主机名和当前路径都可以调用函数得到,,这里我们写糙一点,直接固定死.
看看更新的代码,我们直接使用printf函数打印.
int main()
while(1)
printf("zhangsan@主机名 当前路径# ");
return 0;
这里你会发现,全屏幕都是在打印,我们不想要这样,这里我们需要来一步解决.先来看我们需要把他一行一行打印,这里我们使用换行.
int main()
while(1)
printf("zhangsan@主机名 当前路径# \\n");
return 0;
我们需要让谈在这一行暂停住,这里我们使用sleep.
#include <stdio.h>
#include <unistd.h>
int main()
while(1)
printf("zhangsan@主机名 当前路径# \\n");
sleep(3);
return 0;
这里有会出事,我们把他暂停住是为了在用一行输入,可不是另起一行,此时删除掉换行.
int main()
while(1)
printf("zhangsan@主机名 当前路径# ");
sleep(3);
return 0;
这个原因我们在进度条那里分析过,这里需要我们刷新缓冲区.
#include <stdio.h>
#include <unistd.h>
int main()
while(1)
printf("zhangsan@主机名 当前路径# ");
fflush(stdout);
sleep(3);
return 0;
上面我们打印的忘记带[]了,这里添加一下,现在可以了 .
int main()
while(1)
printf("[zhangsan@主机名 当前路径]# ");
fflush(stdout);
sleep(3);
return 0;
fgets
现在我们就可以选择输入函数了,这里肯定不能选scanf,他遇到空格自动结束,这里我们选择fget,这个是从一个输入流中提取字符到字符数组中,最大的容量是size个字符.
char *fgets(char *s, int size, FILE *stream);
我们这里开始使用一下.
#define NUM 1024
char command_line[NUM];
int main()
while(1)
printf("[zhangsan@主机名 当前路径]# ");
fgets(command_line, NUM, stdin);
printf("%s\\n",command_line);
fflush(stdout);
sleep(3);
return 0;
这里存在一个问题,这里面为何会多出来一个空格呢?这是由于我们在输入字符的时候,最后一下我们按了一个enter键,fgets把这个也读进去了.此时我们需要消除一下,由于我们是C语言写的代码,最后一个是字符\\0.这里使用strlen函数,注意下标.
shell简易入门指南