模拟实现一个简单的命令行解释器(shell)
阿里云国内75折 回扣 微信号:monov8 |
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6 |
目录
前言
我们通过各种指令来实现对操作系统进行各种操作这些指令本质上和我们写的可执行程序并没有区别当然我们也可以实现一个类似于shell的命令行解释器。
环境变量与本地变量
上一篇博客中已经简单的讲解了环境变量怎么修改怎么添加。
这里要引进另一个概念本地变量。
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
}
else if(id == 0)
{
sleep(1);
printf("这是子进程pid : %d ppid : %d | %s\n",getpid(),getppid(),getenv("MY_ENV"));
}
else
{
sleep(1);
printf("这是父进程pid : %d ppid : %d | %s\n",getpid(),getppid(),getenv("MY_ENV"));
}
return 0;
}
从这里可以看出本地变量完全是独立的只在本进程内有效bash不能被子进程使用。
但是当本地变量被添加到环境变量中时由于环境变量具有全局属性可以被子进程所使用继承。
和环境变量相关的命令
1. echo: 显示某个环境变量值
2. export: 设置一个新的环境变量
3. env: 显示所有环境变量
4. unset: 清除环境变量
5. set: 显示本地定义的shell变量和环境变量
获取环境变量的三种方法
mian函数是可以带参数的——命令行参数。
int amin(int argc,char* argv[])
argc是表示命令行元素的数量argv则是将一个长字符串改成一个个短字符串写入argv,数组最后一个元素为NULL表示结束。
除了命令行参数main函数还有一个参数
int amin(int argc,char* argv[] char* env[])
env[]内部的内容与argv[]比较相似
第一种
就是通过命令行第三个参数来获得环境变量
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
int main(int argc,char* argv[], char* env[])
{
for(int i = 0; env[i]; ++i)
{
printf("i : %d -> %s\n", i, env[i]);
}
return 0;
}
第二种
通过第三方变量environ获取
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
int main(int argc,char* argv[], char* env[])
{
exturn char** environ;
for(int i = 0; environ[i]; ++i)
{
printf("i : %d -> %s\n", i, environ[i]);
}
return 0;
}
运行结果与第一种相同。
第三种
通过getenv()函数获取环境变量
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
int main()
{
printf("%s\n", getenv("PATH"));
return 0;
}
进程地址空间
我们先引入一个现象来了解进程地址空间
这时候问题产生了子进程和父进程中n的地址是一样的但是值却不一样所以这个地址指的不是物理地址也就是说子进程和父进程的内存空间是虚拟的读取数值相同的地址时读的并不是同一块内存。
这时候我们必须接受一个概念那就是虚拟地址空间也就是说进程中的地址和物理地址并不是一回事。
我们要控制进程是依托于PCB的在PCB中有一个mm_struct就是用来分配空间的其中code_start和code_end等是用来表示进程地址空间相应区域的起始地址和结束地址以32位系统为例
页表
代码和数据由磁盘加载到内存中内存和磁盘的数据交互的过程叫做IO并且基本单位是4KB。
进程运行之后在mm_struct中记录着进程地址空间的分配页表的作用就是将进程地址空间也就是虚拟地址和物理地址作一个映射。通过页表进程可以使用内存。
每一个进程都有一个单独的页表
为什么存在进程地址空间
我们从三个方面来阐述
第一
当然是因为安全问题如果进程可以直接访问物理地址那么完全有可能发生越界访问。页表的另一个作用就是防止进程访问不属于它的空间。
第二
地址空间的存在可以使进程和进程的数据代码解耦保证了进程独立性这样的特质。
因为进程具有独立性当一个进程对被共享的数据进行修改不能影响到其他进程。通过写时拷贝可以实现这一特性。
当父进程分出子进程时两个进程分享数据和代码这时两个进程所使用的数据在物理内存上是同一块当有一个进程要对数据进行写入或者修改时系统会进行数据拷贝更改页表映射再修改数据这个过程被称为写时拷贝。
第三
让进程以统一的视角来看待对应的代码和数据及各个区域方便编译器也以统一的视角编译代码。
要了解这句话我们需要接受这么几个概念
1.在磁盘上的可执行文件没有被加载到内存上是有逻辑地址空间的在我们对代码进行反汇编时可以看出来。
2.虚拟地址空间是系统和编译器都要遵守的规则。
3.当程序加载到内存中之后就有了一个天然的物理地址。
可执行程序中有虚拟地址空间和进程地址空间是同一套也就是说在编译阶段就把代码和常量数据在进程地址空间中的位置确定了。当可执行程序从磁盘中被加载到内存中有一个物理地址被填到页表的右侧这时由于程序内部已经有代码的虚拟地址了直接填入页表左侧。cpu通过PCB访问内存也就是说CPU从头到尾不接触物理内存。
进程控制
进程的产生
聊到进程控制我们会遇到一个怎么也绕不过去的函数
fork()
那为什么在代码中会有两个返回值呢我们要了解到一个函数在运行到return之前主要的功能都已经实现了。也就是说在运行返回值时子进程已经产生了并且由于写时拷贝此时id值不一样就可以解释了。
进程终止
进程退出场景
代码运行完毕结果正确
代码运行完毕结果不正确
代码异常终止
正常终止运行完毕有三种方式
1. 从main返回
2. 调用exit
3. _exit
在命令行可以通过 echo $? 查看进程退出码
exit是库函数_exit是系统调用接口那么两者之间有什么区别
我们先来看一下代码
再看运行结果
再将exit修改为_exit再看结果
很明显地看到_exit并没有打印hello word!!!,数据丢失这是由于exit比_exit多做了一件事就是将缓冲区中的数据进行IO.这也说明了缓冲区是用户级的如果缓冲区是在操作系统内预留了那么_exit也应该可以对缓冲区进行刷新才对。
进程等待
进程等待必要性 之前讲过子进程退出父进程如果不管不顾就可能造成‘僵尸进程’的问题进而造成内存泄漏。
另外进程一旦变成僵尸状态那就刀枪不入“杀人不眨眼”的kill -9 也无能为力因为谁也没有办法 杀死一个已经死去的进程。
最后父进程派给子进程的任务完成的如何我们需要知道。如子进程运行完成结果对还是不对 或者是否正常退出。
父进程通过进程等待的方式回收子进程资源获取子进程退出信息
进程等待的方法
我们重点讲一下waitpid():
pid_ t waitpid(pid_t pid, int *status, int options);
返回值
当正常返回的时候waitpid返回收集到的子进程的进程ID
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在
参数
pid
Pid=-1,等待任一个子进程。与wait等效。 Pid>0.等待其进程ID与pid相等的子进程。
status:
WIFEXITED(status): 若为正常终止子进程返回的状态则为真。查看进程是否是正常退出
WEXITSTATUS(status): 若WIFEXITED非零提取子进程退出码。查看进程的退出码
options:
WNOHANG: 若pid指定的子进程没有结束则waitpid()函数返回0不予以等待。若正常结束则返回该子进 程的ID。
wait和waitpid都有一个status参数该参数是一个输出型参数由操作系统填充。
如果传递NULL表示不关心子进程的退出状态信息。
否则操作系统会根据该参数将子进程的退出信息反馈给父进程。
status不能简单的当作整形来看待可以当作位图来看待具体细节如下图只研究status低16比特 位
options参数传递0表示如果子进程还没结束则阻塞等待。
进程返回先看终止信号如果都为零说明无异常程序运行结束否则程序异常退出。如果无异常看退出状态推断任务完成情况。
第一个是正常结束终止信号为0第二个是进程异常 终止信号不为0。
进程替换
在此之前我们写的代码子进程是通过if条件判断执行父进程代码的一部分但是如果我们想要让子进程执行全新的代码呢
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数 以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动 例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
替换函数
这些都是库函数但其实更底层是系统调用接口
函数解释
这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
如果调用出错则返回-1
所以exec函数只有出错的返回值而没有成功的返回值。
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
l(list) : 表示参数采用列表
v(vector) : 参数用数组
p(path) : 有p自动搜索环境变量
PATH e(env) : 表示自己维护环境变量
我们通过第一种和最后一种来练习
我们上面是使用系统路径中的指令接下来让程序替换成我们自己的可执行文件
值得注意的是putenv()用来添加环境变量。
模拟实现一个shell
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<assert.h>
#include<string.h>
#define LINESIZE 1024
#define ARGNUM 64
int main()
{
char* myarg[ARGNUM];
char command[LINESIZE];
while(1)
{
printf("[用户名@ 主机名 当前地址#] ");
fflush(stdout);
//接受指令 分割指令
char* c = fgets(command,LINESIZE - 1,stdin);
assert(c != NULL);
(void)c;
command[strlen(command) - 1] = 0;
myarg[0] = strtok(command, " ");
int i = 1;
while(myarg[i++] = strtok(NULL," "));
//执行指令
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
int exeret = execvp(myarg[0],myarg);
if(exeret == -1)
{
exit(10);
}
exit(1);
}
waitpid(id,NULL,0);
}
return 0;
}