在引出进程地址空间的概念之前,我们先看一段代码:
通过fork
创建子进程,并打印出数据val
的地址,在一段时间后让子进程修改val
的数值,发生写时拷贝,再次观察对比子进程和父进程分别打印出的val
的地址。
#include
#include
int main()
{
int val=20;
int ret=fork();
int flag=0;
while(1)
{
if(ret==0)
{
sleep(2);
printf("son val= %d\n",val);
printf("son val addr= %p\n",&val);
if(flag==2)
val=200;
flag++;
}
else
{
sleep(2);
printf("father val= %d\n",val);
printf("father val addr= %p\n",&val);
}
}
}
通过运行结果发现:
当发生写时拷贝以后,子进程的val
确实被修改为了200
,而父进程的val
没有受到影响。但是父子进程对应val
的地址都没有发生变化,可是明明两者的val
都不在同一块空间了,为什么地址一样呢?
由此,我们大胆推论:语言层面上打印出来的地址都是虚拟地址!
事实确实是如此,在语言层面上用户打印出来的地址其实都是虚拟地址,前面在介绍进程的时候,我们说进程由程序和对应的数据结构组成,在当时我们只介绍了其中一种数据结构PCB
,其实还有一种数据结构,也就是进程地址空间,其在Linux
中对应的名字为mm_struct
。
struct mm_struct
{
//进程地址空间
}
在32位
的电脑下,进程地址空间的大小一般是4GB
。
对于进程地址空间我们简单进行理解,我们常讲的内存分区其实就是对于进程地址空间而谈的,程序加载到内存成为进程,进程地址空间也随之产生,进程地址空间可以看作是一个结构体。
一个进程地址空间的内部大致像下面一样:
通过end
和start
来标识每个区域的大小。事实上,每个进程地址空间大小基本和物理内存差不多,虽然这里我们只标识了区域的起始和结束,但是其内部还是一个个地址,从0000000....~fffffff....
,这些地址也 称作虚拟地址(线性地址),在语言层面上打印出的地址也就是进程地址空间内的虚拟地址。
问题又来了,你和我说进程地址空间里的地址是虚拟地址,但是一个进程的运行是需要访问实际物理内存的代码和数据的,那么通过进程地址空间又该如何实现这一过程呢?
页表
和MMU
的出现很好的解决了这一过程的实现:
页表:人如其名,页表也就是一张表,每个进程都会有一张自己的页表,其由三部分组成:虚拟地址,物理地址和权限。
MUU
:MMU
是一个集成在CPU
内部的硬件,可以用来查询页表,其又叫做内存管理单元。
页表可以将虚拟地址可以被映射成物理地址,通过地址的映射进程就可以很好的读取物理内存中的代码和数据。
接下里通过一个引子我们来回答页表中权限的作用:
在语言层面,如C
语言为例子,对于字符常量区的内容我们只能读但是不能改,这一背后的原因在语言层面上我们是很难解释的,其实也是因为页表的存在。
在页表内,虚拟地址和物理地址会产生映射,页表发现这个虚拟地址是字符常量区的地址,因此在权限这一栏就只给了读权限,所以说我们无法修改。
注
\color{red}{注}
注:页表和MMU
对地址的转换本质还是操作系统在进行操作,还是由操作系统说了算,可以做到有效的权限管理,页表其实是操作系统的代言人。
对此我们还可以回答一个问题:
为什么只读的数据在物理内存中只存储一份,如:
char *p="hello world"
char *str="hello world"
通过地址空间和页表…配件的存在,通过地址映射,这些个指针都可以指向同一块空间,因此只需要这一份数据,并且可以减轻内存的负担。
接下来我们需要知道为何需要进程地址空间的存在。
( 1 ) (1) (1)保护进程
页表的存在等于加了一个中间层。具有监控的作用,操作系统可以判断进程的操作对内存的访问是否正常,可以进行进程访存的风险管理。
例如小时候你的红包会先给你妈,在你要用的时候你会去向你妈要,你妈会判断你买这个东西是否合理有用,如果不合理可以不让你买,这时你妈就对你的操作起到一种监控的作用。
(
2
)
(2)
(2)提高内存使用率
当一个进程申请内存空间的时候,操作系统可能不会立即将物理内存空间分配给内存,而是将进程地址空间中分配一段空间给进程,当进程真正需要使用的时候再将物理地址和虚拟地址建立映射,将实际的内存空间分配给进程。
正是有了进程地址空间的存在,即使内存满了有时候我们还可以继续申请内存,例如我们向堆区申请100M
的内存,这时操作系统看内存满了,可能就会先让你的进程地址空间中堆区的字段heap_end+=100
,可以将这块空间先给其他急需使用的进程使用 ,使得内存使用收益最大化,当你真正需要使用内存的时候再通过一些内存置换算法,将内存实实在在的给你。这样无疑提高了内存的利用率。看其本质,操作系统的存在就是通过管理下层,尽可能的发挥下层的利用率来为上层用户提高稳定,高效,安全的服务,所以进程地址空间的存在使得操作系统可以更好的分配内存空间,通过进程地址空间这种假内存
的存在来提高内存的使用率。
当你真的需要使用空间的时候,操作系统为你在内存中会为你在内存中开辟实实在在的空间,并且将这个空间和你的虚拟地址空间之间建立映射关系,这个就叫基于缺页终端进行物理内存申请,这个概念后面我们会再进行解释。
(
2
)
(2)
(2)减少内存管理的负担
在立即进程地址空间的第三个作用之前,我们需要回答一个问题:
CPU是如何根据进程来查找进程 运行的起始地址的?
我们知道一个进程伴随着代码和数据,所对应的每个进程代码的入口运行地址都是不同的,如果没有进程地址空间的存在,CPU
在调度不同的进程的时候,需要去查找每个进程对应的入口运行地址,这样就会使得内存管理负担很大,而进程地址空间可以很好的解决这一问题:一个程序加载到内存,对应的是其代码和数据,当加载器将代码和数据加载到内存以后,操作系统会读取进程代码的运行入口地址,接着将进程地址空间中的虚拟地址和代码的起始地址建立映射关系,这样使得CPU
每次只要调度同一个虚拟地址就能完成进程代码的初始运行,后续映射操作操作系统会指挥硬件完成。(在不同的操作系统下情况可能不同)
进程地址空间的作用总结:
页表的存在等于加了一个中间层。具有监控的作用,操作系统可以判断进程的操作对内存的访问是否正常,可以进行进程访存的风险管理。
2.进程地址空间的存在可以使得将内存申请和内存使用的概念在时间上划分清楚,通过虚拟地址空间,来屏蔽底层申请内存的过程,达到进程读写内存和操作系统进行内存管理操作,进行软件上面的分离。其实本质也就是提高内存的利用率 ,内存可以立即给你,当你要的多的时候,可以等你要用的时候再给你
3.站在
CPU
和应用层的角度,进程统一可以看作统一使用4GB
空间(32位下
),而且每个空间区域的相对位置,是相对确定的。并且程序的代码和数据可以加载到物理内存的任意位置。可以减轻内容管理的压力。
操作系统最终这样设计的目的,达到一个目标:每个进程都认为自己独占系统内存,每个进程都可以以同样的视角来看待内存空间,这个过程本质也是在消除进程之间的差异化,差异化越小,操作系统的管理成本越低。
在明白了进程地址空间的概念以后,卧槽重新看向进程地址空间内的区域划分。
对于进程地址空间中的区域划分,我们通过一段代码进行验证:
int initval=10;
int uninitval;
int main(int argc,char* argv[],char* env[])
{
printf("code addr=%p\n",main);
char *s ="hello world";
printf("string rdonly=%p\n",s);
printf("init addr =%p\n",&initval);
printf("uninitval addr=%p\n",&uninitval);
int *p1= (int *)malloc(sizeof(4));
int *p2= (int *)malloc(sizeof(8));
int st1;
int st2;
printf("heap addr=%p\n",p1);
printf("heap addr=%p\n",p2);
printf("stack addr=%p\n",&st1);
printf("stack addr=%p\n",&st2);
for(int i=0;argv[i];i++)
printf("argv[%d]=%p\n",i,argv[i]);
for(int i=0;env[i];i++)
printf("env[%d]=%p\n",i,env[i]);
}
通过打印出的地址可以发现,命令行参数和环境变量在栈的上面。
从今天起,对于进程我们应该有一个更深入的理解:
进程=程序的代码和数据+PCB+页表+进程地址空间。
如何管理:先描述,再组织。
对于fork
创建出来的进程,其子进程的页表,进程地址空间,PCB
都会以父进程为模板构建,并且会共享父进程的代码和数据。但是普通的父子进程没有这层继承属性。
在理解了进程地址空间的概念以后,我们需要从地址空间的角度理解写时拷贝。
我们知道,fork
创建出来的子进程和父进程的代码和数据是共享的,代码是不能修改的,因此在页表权限中,代码段的映射被设置为了只读,数据是可修改的,但是一般不会进行修改,所以在页表权限中也被设置为了只读。当子进程想要修改数据的时候,会发生缺页中断,何为缺页中断?一个进程往一个不可写的内存写入数据时,系统就会发生缺页中断,这时子进程的写入操作就会被操作系统暂停,接着操作系统会检查子进程的代码,操作系统发现子进程要写入的空间是父进程和子进程共享的,发生写时拷贝,操作系统重新找了一块空间,将原空间的内容拷贝到新空间中,重新建立映射关系,这时因为两者对应的这块数据空间是独立的,所以页表对应项的只读权限就被去掉了,接着操作系统会启动子进程,让其继续进行写入操作。在这个中断的过程中,发生了什么子进程其实不知道的,因为他被中断了,而父进程也不知道,因为和他就没关系。