【Linux系统】进程地址空间详解
Linux系列
文章目录
- Linux系列
- 前言
- 一、地址空间的区域划分
- 二、进程地址空间的引入
- 2.1 地址空间的概念
- 2.2 地址空间
- 2.3 进程地址空间的优点
- 三、页表
- 3.1 区域权限管理
- 3.2 惰性加载
- 总结
前言
进程地址空间是操作系统为每个运行中的进程分配的一个虚拟内存视图,它是所能访问的所有内存地址的集合。这给抽象层使得每个进程看起来都像是占用了整个计算机的内存资源,而实际上这些需您地址会v欸操作系统映射到物理内存中。本篇我将介绍进程地址空间的概念及使用这种方法带来的好处。
在学习这块之前,我们需要一些背景知识作为铺垫
一、地址空间的区域划分
在我们学习C/C++时经常会提到:哪个变量存在栈区、常量区不能被修改等一些概念,那么进程是如何来划分这些区域的呢?
下面我们通过程序打印来验证:
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<stdlib.h>
4 int num;
5 int tmp=1;
6 int main()
7 {
8 char *str="12345";
9 int *ptr=(int*)malloc(sizeof(int));
10 printf("code addr:%p\n",main);
11 printf("read only string addr:%p\n",str);
12 printf("init global value addr:%p\n",&tmp);
13 printf("uninit global value addr:%p\n",&num);
14 printf("heap:%p\n",ptr);
15 printf("stack add:%p\n",&str);
16 return 0;
17 }
程序执行结果和我们所画一致,有了这下铺垫,我们就可以浅谈一些进程地址空间了。
当然大家也可以采用打印的方式验证,堆区、栈区的增长方向。
二、进程地址空间的引入
我们在介绍fork
的博客中有一个这样的问题没有给大家解答:
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<stdlib.h>
4 int main()
5 {
6 pid_t id=fork();
7 if(id==0)
8 {
9 printf("I ma child,pid:%d,ppid:%d,id:%d,&id:%p\n",getpid(),getppid(),id,&id);
10 }
11 else
12 {
13 printf("I ma parent,pid:%d,ppid:%d,id:%d,&id:%p\n",getpid(),getppid(),id,&id);
14 }
15 return 0;
16 }
对于接收frok
返回值的变量id
来说,在父子进程中,同一个变量名,连地址都相同,我们打印却看到了不同的值,这是怎么做到的呢?如果我们打印出来的地址是物理地址,显然不会出现上面的现象!!我们将这个地址称为线性地址或虚拟地址。
2.1 地址空间的概念
在我们刚开时学习进程时,我们称一个加载到内存中的程序叫做进程,再到进程是PCB结构体+你的代码和数据,然后又有进程不能只简单的认为进程是PCB结构体数据+你的代码和数据还要考虑环境变量的影响,在本篇我们又要对进程重新定义了
当一个程序变为进程时,操作系统不仅会给他创建描述它的PCB
结构体对象,还会创建属于它的进程地址空间,对应的页表。
以上面父子进程的创建为例,让大家先有个框架,后面我们分开详细介绍
进程地址空间:内部存有虚拟地址(我们&获得的地址)
页表:KV
结构的映射结构
当我们的CPU
再对进程数据进行访问时,首先找到进程的PCB
结构体,通过结构体访问进场地址空间,得到数据的虚拟地址,这时CPU
通过cr3寄存器
中存储的该进程页表的地址找到,该进程的页表,使用得到的这个虚拟地址在页表中匹配,匹配成功后,通过虚拟地址映射的物理地址就可以找到想要访问的数据。这就是整个的访问流程,再结合我们上面提到的父子进程的问题接着分析:
子进程被创建是,并不是平白的在操作系统中多了一个进程,而是操作系统基本根据,父进程的PCB
结构体对象生成一份子进程的PCB
结构体对象(当然会做一些修改),将父进程的进程地址空间,拷贝一份给子进程,页表就有一点不一样了,为了好理解,我们也认为它拷了一份给子进程,这时就会出现这种现象:
这样就可以实现父子进程共用同一分代码了,现在我们就可以回答为什么,父子进程的同变量名,地址相同但是存储值不同?,或者说是写时拷贝是如何实现的?继续:
当子进程要修改数据时,首先CPU
找到子进程的PCB
结构体对象,同过PCB
结构体对象找到进程地址空间,拿到要修改数据的虚拟地址(现在可以知道两次取到的对象地址相同,是因为子进程的进程地址空间就是拷贝的父进程的当然相同),通过CPU
中cr3寄存器
存的页表地址找到对应页表,使用虚拟地址进行匹配,这时操作系统识别出你是子进程在访问父进程的数据还想要修改,操作系统就会另外给你一份空间将你要修改的值存入新的空间,并用这块新空间的物理地址,覆盖原来的物理地址,这就是写时拷贝,而我们在代码层面的取地址操作,取出的是虚拟地址,在底层他们的物理地址早就不一样了,所以对外表现出地址相同,值不相同的概念。
看到这里大家想一想,物理地址是和我们上面画的语言是连续的吗?或者是物理地址是否连续还重要吗?
上面的图,为了方便大家理解(还有就是图太大了),我将他们画成连续的了
希望大家看到这能有自己的理解,后面还会进行总结。
2.2 地址空间
为了先让大家有一个整体框架,我们在上面并没有详细介绍
什么叫做地址空间呢? 在介绍冯诺依曼体系结构中我们说过,设备间数据是传递是通过总线来完成的,今天我们就来浅谈一些这个概念:
在32为计算机中,有32位的地址和总线,每一根总线都代表高低电压(高表现为1,低表现为0),CPU
当他要访问内存中的某个地址的数据时,就会同这32根总线对内存进行充电(转化为0 1),找到对应位置的值,内存更具存储值进行放电让CPU
接受到信息
这32根总线0、1组合起来就对应2^32 个位置(最小单位位
),也就是4GB
内存,所谓的地址空间就是我们的地址总线排列组合形成的地址范围【0,2^32】。
如何理解地址空间的划分? 这好像我们高中的时间作息表一样,如:5点到6点是早读时间,6点到6:30是早餐时间,6:40到12点是上课时间等,地址也是这样将空间进行范围的划分,我们不需要管上的是什么课只需要将时间划分出来即可,也就是说我们只需要将内存区域的开始,到结束记录下来就可以了,为了方便访问,我们需要对划分的区域进行管理---------先描述再组织,创建出用于管理的结构体,表现为:
这个概念在前面的文章中一直在说
每个进程空间都是【0,4GB】我们计算机哪有那么多空间给他用啊?画饼,操作系统给每个进程都画一张4GB
的饼,所以对于每个进程来说,它只知道操作系统承诺要给他4GB
,操作系统也不管那么多,等j进程要用到4GB
时,我不给就是了。
2.3 进程地址空间的优点
1.让进程以统一的视角看待内存。
2.增加进程虚拟地址空间,可以让我们访问内存的时候,增加一个转换的过程,再这个转化过程中,系统可以对我们的寻址操作进行审查,一旦发现异常访问,直接拦截,该请求不会达到物理内存,保护物理内存。
3.因为有进程地址空间和页表的存在,将进程管理模块和内存管理模块进行解耦合!
三、页表
3.1 区域权限管理
在讲解上面部分时,我将页表简化了,下面我们来补充
我们在学习C/C++时经常说,处在常量区的数据不能被修改,那么对于物理内存来说,物理内存存在只读这个概念吗?显然这一点物理内存是办不到的。
正在运行的程序不能完成对常量的修改是应为,在虚拟内存转化为物理内存的过程中拦截了这个行为:
当CPU
要对常量数据进行修改时,找到页表后通过虚拟地址匹配成功,发现这个区域为只读权限,操作系统就会拦截这个行为。
3.2 惰性加载
大家想一下我们平时玩的游戏都大几百G,而我们的电脑运行内存是远远小于它的,那么这个游戏是如何在我们的电脑上运行的呢?操作系统对此采用惰性加载的方式,即一次向内存中加载50MB的代码数据,每当我们的CPU
去执行代码时,它就会去页表中找到对应的虚拟地址位置,然后再通过页表状态信息判断,这个代码有没有被加载到内存中,如果有就执行,如果没有就触发缺页中断,先将代码写入内存再执行,这就很完美的解决了,运行问题,当然我们也可以不提前加载50MB代码,当我们需要执行这里的代码时,就通过上面的方式先判断再加载。在我们介绍进程状态时,挂起状态就是这样做的。
总结
学完上面的我们就可以知道为什么说进程相互独立:进程的PCB
结构体是独立的、进程地址空间是独立的、页表是独立的、对进程切换时只需要切换PCB
结构体对象就可以了。
进程对数据存储位置不重视,要求虚拟地址连续就可以让进程以统一视角看待内存。