目录
前言
环境变量与本地变量
和环境变量相关的命令
获取环境变量的三种方法
第一种
第二种
第三种
进程地址空间
页表
为什么存在进程地址空间
第一
第二
第三
进程控制
进程的产生
进程终止
进程等待
进程替换
模拟实现一个shell
前言
我们通过各种指令来实现对操作系统进行各种操作,这些指令本质上和我们写的可执行程序并没有区别,当然我们也可以实现一个类似于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;
}