我们都知道虚拟内存有分区,那都有哪些分区?怎么划分分区的?分区是怎么初始化的?
这篇文章就探讨这些问题。
一、都有哪些分区
笼统点说,虚拟内存被划分为内核区域和进程区域,内核区域只有操作系统内核能够操作。
内核区域在上面(地址最大的区域),内核区域往下一直到 0x40000000
是进程内存区域,0x0
到 0x40000000
没有被使用。
内核区域最上面分配着两个内存页,这两个内存页的最下面保存着每个进程特有的 thread_info
。thread_info
的字段 task_struct
里面有进程的活动数据,比如 pgd (页表地址)、PID
(进程 ID)、mmap
(内存段)等等。thread_info
往上的区域属于内核运行时栈,从虚拟地址的最上往下伸缩。内核区域最下面保存着内核代码和数据区域,它被映射到被所有进程共享的物理页上。中间连续的虚拟页也被映射到连续的物理页上,方便内核访问物理内存特定位置(比如页表)。
进程区域和内核区域有类似的结构。最上面是往下增长的进程运行时栈,最下面是进程代码和数据区域。进程运行时堆在代码和数据区域上方,往上增长。共享库内存区域被映射到被所有进程共享的物理页上,处于虚拟空间的栈和堆之间的空闲区域上。
二、怎么划分
进程怎么知道其虚拟空间的划分是怎么样的?每个进程用统一的划分方式吗?分区和分区之间是紧凑相连的吗?
内核区域的分区是每个进程统一的划分方式,其地址范围是系统运行时的常量。
进程区域的分区则每个进程不同,其中每个内存段之间可能有空隙,每个进程需要记录这些内存段的起止地址。
内存段的起止地址都由 task_stuct
的 mm_struct
字段记录。 mm_struct
中的 mmap
字段指向了一个 vm_area_struct
链表,vm_area_struct
结构类似于:
1 | struct vm_area_struct; |
之前提到,每当程序寻址到 PTE 时要检查地址的合法性,其实在缺页时(没找到 PTE 时)同样要检查地址的合法性。在没有 PTE 记录中的 flag 的情况下,vm_area_struct
就派上用场了。
操作系统遍历所有的 vma
,检查地址是否在某个 vma
的范围内。定位到段后,再根据 vma->vm_port
检查内存操作的权限是否合法。如果地址不在任何内存段的范围内,或者操作不符合 vma->vm_port
的要求,就触发段保护。
需要注意的是,判断地址是否属于 vma
并不是简单地判断地址值 X
是否满足 X > vm->vm_start && X <= vm->vm_end
,这只适合简单情况。当 X == vm->vm.vm_start && vm->GROWS_DOWN
时,vma
增长方向反过来了, X
就是 vma
的最后一个地址,属于 vma
范围之内。如果 vma
下面的内存段 vma->pre
也是反过来增长的,并且 X == vma->pre->vm_end
,则 vma
和 vma->pre
都属于 Stack 内存的段。
你可能会好奇怎么有多个栈内存段。其实在单线程情况下是只有一个栈内存段的,但是在多线程情况下,每个线程都有自己独立的栈内存段。而这些栈内存段是紧挨在一起的,每个栈内存段的大小都是一个运行时常数。
其实不仅是栈,heap内存也可能包括多个内存段。判断一个内存段是否属于 heap
,需要借用 mm_struct
里面的 start_brk
(heap 内存段的开始位置) 和 brk
(heap 内存段的结束位置)。vma
的开始和结束地址在 mm_struct
的 start_brk
和 brk
之间,则说明地址在 heap 内存中。
三、怎么初始化
确定每一个内存段的范围后,怎么初始化其中的值呢?
栈和堆内存段不需要初始化,它们的值由程序运行时再决定。
代码段、数据段(.data、.ro-data、.bbs)和共享内存段需要初始化,初始化手段有文件页初始化、二进制零页初始化和共享内存对象初始化。
3.1 从文件页初始化
.text、 .data 和 .ro-data 都是从可执行文件或者动态链接库文件中读出来的,使用的是文件初始化。
在加载这些文件时,文件中的 .text、.data 和 .ro-data 被划分为页面大小的块,然后这些文件分页被映射到虚拟内存分页。
程序在运行时,根据按需分页调度策略,只有虚拟页第一次被读写时才会从文件页复制到物理内存页(和虚拟页面)。
3.2 从二进制零页初始化
.bbs 保存着未初始化或者初始化为 0 的静态变量以及初始化为 0 的全局变量。这些 0 变量本身并没有保存在可执行文件或者动态链接库文件中,但是其总内存段大小和起止位置在加载件时被统计出来。.bbs 内存段也被映射到文件页上被初始化,不过这个文件是内核构建的匿名文件。匿名文件中的页全部是二进制的 0,被称为请求二进制 0 页(demand-zero page)。
程序在运行时按需调入二进制 0 页,从而实现了把 .bbs 变量初始化为 0 的操作。
3.3 从共享对象初始化
对于动态链接库这种可以被多数进程共享的文件,每个进程在加载时会根据共享库的路径判断它是否已经被加载。如果已经加载了,就把虚拟页直接映射到它的物理页上去,提高加载速度,也降低了对物理内存的消耗。
映射共享对象时,可以指定映射的 flag,用于表明共享对象被映射为共有的还是私有的。共有的共享对象,其它进程对它的修改在本进程中是可见的,反之亦然。而私有对象的改动只有本进程可见。
对共享库对象的映射是私有映射还是共有映射呢?
答案是对只读数据(.text、.ro-data、.PLT)使用共有映射,对可读可写段使用私有映射(.data、.GOT),对 .bbs 还是使用二进制 0 页初始化。
这说明了映射不仅可以指定文件名称,还可以指定文件范围和类型。其实,一次完整的映射操作包括这些参数:
1 | void * mmap(void * start, size_t length, int port, int flags, int fd, off_t offset); |
对于私有映射,如果每个进程的改动都是独立的,那意味着每个进程都要有内存来保存它们。这样的话,映射私有对象所带来的加速加载和节省内存的意义看似就不存在了。然而,“写时复制”技术在这里帮了忙。
四、写回和写时复制
4.1 写回
从理论上来说,物理内存只是虚拟内存的缓存,虚拟内存是保存在磁盘的交换文件上面的。但是,我们也看到只有被修改了的虚拟页被换出时才会被写到磁盘上,一般情况下对虚拟页的读写是直接读写物理内存页这个缓存的。
这种平时只修改数据到缓存,直到缓存被淘汰时再把数据写入主存的操作被称为写回。它的好处是避免了性能低下且无用的主存写入操作。
4.2 写时复制
对于共享而来的私有对象,初始化时虚拟页仍然被映射到共享的物理页上。当进程对某虚拟页中的数据进行修改时,把改虚拟页对应的物理页复制一份,再把改虚拟页映射到复制出来的物理页上,最后进行修改。这种初始化到共享数据上,等到修改时再复制出来进行修改的策略被称为“写时复制”。
采用写时复制策略后,虚拟页仍然能够从物理页中快速初始化。而且只有被修改的页才被复制了副本,对内存的消耗永远不会比完全拷贝整个对象大。
五、总结
虚拟内存被分为内核区和进程区。
内核区的范围是系统运行时的常量,对所有进程一样。内核区中保存着内核的代码和数据,以及内核运行栈和进程的 task_struct
。
每个进程的进程内存区总范围都不一样,其中子内存段的范围也不一样,并且中间还有空隙。进程使用 vm_area_struct
链表来保存每个内存段的范围、共享属性和权限。
内存段通过映射来初始化,可以映射到磁盘文件、请求二进制 0 页和共享对象。
映射到磁盘和请求二进制 0 的虚拟页,在第一次读取时才调入。映射到私有共享对象的页,在第一次修改时才被复制。
虚拟页只有被换出时才被写入到磁盘交换文件。