通过Linux理解操作系统(四):内存管理(上)
关于内存,最直观的理解可以将其想象成一个个格子,每个格子由一个地址标记出来并且存了一个字节的数据,对于32位的机器,可以有2^32个地址,也就是理论上可以存4GB的数据(实际的机器不一定是4G的物理内存)。的确,对于程序员而言这样的理解已经足以满足我们编写程序的要求了,而内存实际的物理模型也是这个样子的。但是,对于系统而言,这样简单的模型是不够的,因为正常情况下系统中都会运行着多个程序,如果这些程序都可以直接对任意一个内存地址进行操作,那么一个程序就很有可能直接的修改了另外一个程序保存在内存中的数据,这种情况下会发生什么,不好说,但肯定会悲剧。所以操作系统必须实现一些机制,来保证各个进程可以和谐友爱地使用这有限的内存,同时又要保证内存的使用效率,这些就是我本文要讲的主要内容了。
1、基本概念
为了解决前面提到的问题,操作系统对物理内存做了抽象,得到一个重要的概念叫做地址空间,指可以用来访问内存的地址集合,也就是0X00000000到0xFFFFFFFF,大小是4个G 。每个进程都有自己的地址空间,且在每个进程自己看来这4G就相当于物理内存,它可以使用任意地址去访问他们,而不需要担心影响到其他进程。因为这里的地址并不是实际的物理地址,而是虚拟地址,它需要经过系统转化成物理地址后再去访问内存,且系统保证了不同的进程中的同一个虚拟地址会映射到不同的物理地址,也就不会操作到同一块内存(除非那一块内存是共享的)。又因为通常一个程序也不会使用到4G的内存,所以4G的物理内存可以同时存放多个程序的数据而不会重叠,即使4个G都已经放满了,也可以通过将一部分暂时没用的数据保存到磁盘的方式来腾出空间放其它的数据,具体如何操作,我们之后再讲,这里只要知道,我们的程序是通过虚拟地址来访问内存的,而系统保证了每个进程通过地址空间访问到的都会是自己的数据就可以了。
有了地址空间的概念后,在讨论程序如何使用内存的时候,我们就可以将物理内存的概念抛到一边了,接下来我们就看看Linux里的进程是如何使用地址空间的:
在Linux中,虽然每个进程有4G地址空间,但是其中只有3G是属于它自己的,也就是所谓的用户空间,剩下的1G则是所有进程共享的,也就是内核空间,这1G的内核空间里保存了重要的内核数据比如用于分页查询的页表,还有之前提到的进程描述符等,这些内容在系统运行过程中将一直保存在内存当中,且对于运行在用户模式下的进程是不可见的,只有当进程切换到内核模式后,才能够对内核空间的资源进行访问(以及进行系统调用的权限),又因为内核空间是所有进程共享的,所以利用内核空间进行进程间通信就是一件理所当然的事情了,所有IPC对象如消息队列,共享内存和信号量都存在于内核空间中。
而用户空间又根据逻辑功能分成了3个段:Text,Data 和 Stack,如下图所示。
其中,Text段的内容是只读的且整个段的大小不会改变,它保存了程序的执行指令,来源于可执行文件,我们知道程序经过编译之后会得到一个可执行文件,这个可执行文件里就保存了程序执行的机器指令,在运行时,就将这些指令拷贝到Text段里然后CPU从这里读取指令并执行。
data段顾名思义是保存了程序中的数据,包括各种类型的变量,数组,字符串等,它包括两个部分,一个是有初始化的数据区,保存了程序中有初始值的数据,一个是无初始化的数据区(通常叫做 BSS),保存了程序中没有初始值的数据,且BSS区的数据在程序加载时会自动初始化为0。注意这里的数据不包括函数内的局部变量,因为那是在stack段中的。举个例子,熟悉C/C++的人知道如果我们程序中的全局变量没有设置初始值的话,会自动初始化为0,而局部变量没有设置初始值的话,则他们的值是不确定的,其原因就在这里,当全局量不设初始值时,会保存在BSS区里,这里自动为0,若有初始值,则在有初始化的区,而局部变量在Stack段则是没有初始化。跟Text段不同,data段里的数据可以被修改,而且data段的大小也可能在程序运行过程中改变,比如说当调用malloc时,data段的地址会往上扩展,而这些动态分配的内存就称为堆。
stack段位于用户空间的最顶部,可以向下增长,它被用来存放进行函数调用的栈。当程序执行时,main函数的栈最先创建,伴随着传进来的环境变量和执行参数,并压入系统栈中(指stack段),当在main函数中调用另一个函数A时,系统会先在main的栈中压入函数A的参数和返回地址,并为A创建一个新的栈并压入系统栈中,而当A返回时,则A的栈被弹出,这样就使得当前执行的函数总是在系统栈的顶部(这里的顶部在上图中是在下方,因为stack段是往下增长的),这就是函数调用的一个粗略过程。
2、地址空间的应用
前面已经提到了地址空间的概念,进程只管使用地址空间里的地址去读写数据,而不管实际的数据是放在什么地方,接下来我们就看看系统利用这点可以干些什么。
(1)共享text段:我们已经知道了text段是存放程序运行的机器指令的,那么当多个进程运行同一个程序的时候,它们的text段肯定也是一样的,在这个时候,为了节省物理内存,系统是不会把每个进程的text段内容都放到物理内存的,而是只保存了一份,然后让各个进程的地址空间的text都映射到这一区域,这样做对于每个进程的运行不会有任何影响,同时又节省了宝贵的物理内存。实际上系统还保证了同一份指令在内存中只会存在一份,一个实际例子就是动态连接库的使用。
(2)内存映射文件:因为进程使用地址空间的地址读写数据时不用管实际的数据在哪,那就意味着这些数据甚至可以不在内存中,内存映射文件就是利用了这一点,通过保留进程地址空间的一个区域,并将这块区域映射到磁盘上的一个文件,进程就可以像操作内存一样来访问这个文件(即像访问数组一样可以使用指针,偏移量等),而不用使用到文件的IO操作,当然这其中肯定需要操作系统提供相应的机制来去实现逻辑地址到实际文件存放位置的转换,但这就不是我们所关心的了。使用内存映射文件还有一个好处就是,多个进程可以同时映射到同一个文件,又因为此时的这一份文件在进程看来就是内存,也就是说可以将其视为一块共享内存,这意味着每个进程对这块区域的修改对于其他进程都是实时可见的,当然这里的效率会比将数据实际放在内存时要低,但是却带来了另一个好处就是磁盘空间相对于内存来讲是无限的,因此可以实现大数据量的数据共享。
好了,到这里我们对内存就有了一个比较细致的理解,其中地址空间是一个值得细细体味的概念,下一篇文章我们再看看系统是通过怎样的机制来使得我们的程序可以如此方便地访问内存的。