虚拟内存系统(二)—— 虚拟内存的结构

我们都知道虚拟内存有分区,那都有哪些分区?怎么划分分区的?分区是怎么初始化的?
这篇文章就探讨这些问题。

一、都有哪些分区

笼统点说,虚拟内存被划分为内核区域和进程区域,内核区域只有操作系统内核能够操作。
内存分区图
内核区域在上面(地址最大的区域),内核区域往下一直到 0x40000000 是进程内存区域,0x00x40000000 没有被使用。
内核区域最上面分配着两个内存页,这两个内存页的最下面保存着每个进程特有的 thread_infothread_info 的字段 task_struct 里面有进程的活动数据,比如 pgd (页表地址)、PID(进程 ID)、mmap(内存段)等等。thread_info 往上的区域属于内核运行时栈,从虚拟地址的最上往下伸缩。内核区域最下面保存着内核代码和数据区域,它被映射到被所有进程共享的物理页上。中间连续的虚拟页也被映射到连续的物理页上,方便内核访问物理内存特定位置(比如页表)。
进程区域和内核区域有类似的结构。最上面是往下增长的进程运行时栈,最下面是进程代码和数据区域进程运行时堆在代码和数据区域上方,往上增长。共享库内存区域被映射到被所有进程共享的物理页上,处于虚拟空间的栈和堆之间的空闲区域上。

二、怎么划分

进程怎么知道其虚拟空间的划分是怎么样的?每个进程用统一的划分方式吗?分区和分区之间是紧凑相连的吗?
内核区域的分区是每个进程统一的划分方式,其地址范围是系统运行时的常量。
进程区域的分区则每个进程不同,其中每个内存段之间可能有空隙,每个进程需要记录这些内存段的起止地址

内存段的起止地址都由 task_stuctmm_struct 字段记录。 mm_struct 中的 mmap 字段指向了一个 vm_area_struct 链表,vm_area_struct 结构类似于:

1
2
3
4
5
6
7
8
9
10
struct vm_area_struct;

struct {
long vm_end; // 内存段的结束地址
long vm_start; // 内存段的开始地址
vm_area_struct * vm_next; // 指向下一个 vm_area_struct
int vm_port; // 内存段内所有页的读写许可权限
int vm_flags; // 内存段内所有页面时进程私有的还是与其它进程共享的
...
} 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,则 vmavma->pre 都属于 Stack 内存的段。
你可能会好奇怎么有多个栈内存段。其实在单线程情况下是只有一个栈内存段的,但是在多线程情况下,每个线程都有自己独立的栈内存段。而这些栈内存段是紧挨在一起的,每个栈内存段的大小都是一个运行时常数。
其实不仅是栈,heap内存也可能包括多个内存段。判断一个内存段是否属于 heap ,需要借用 mm_struct 里面的 start_brk(heap 内存段的开始位置) 和 brk(heap 内存段的结束位置)。vma 的开始和结束地址在 mm_structstart_brkbrk 之间,则说明地址在 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
2
3
4
5
6
7
8
void * mmap(void * start, size_t length, int port, int flags, int fd, off_t offset);
// start 暗示映射到虚拟地址空间的 start 地址,但不一定就是这个地址,具体以返回值为准
// length 映射的字节数
// port 映射为共有还是私有
// flags 映射到的虚拟地址段的读写权限
// fd 映射的文件对象
// offset 从映射的文件对象 fd 的 offset 处开始
// 映射 fd 文件 offset 处开始的 length 个字节到虚拟地址 start 开始的 length 个字节处,类型为 port,读写权限为 flags。

对于私有映射,如果每个进程的改动都是独立的,那意味着每个进程都要有内存来保存它们。这样的话,映射私有对象所带来的加速加载和节省内存的意义看似就不存在了。然而,“写时复制”技术在这里帮了忙。

四、写回和写时复制

4.1 写回

从理论上来说,物理内存只是虚拟内存的缓存,虚拟内存是保存在磁盘的交换文件上面的。但是,我们也看到只有被修改了的虚拟页被换出时才会被写到磁盘上,一般情况下对虚拟页的读写是直接读写物理内存页这个缓存的。
这种平时只修改数据到缓存,直到缓存被淘汰时再把数据写入主存的操作被称为写回。它的好处是避免了性能低下且无用的主存写入操作。

4.2 写时复制

对于共享而来的私有对象,初始化时虚拟页仍然被映射到共享的物理页上。当进程对某虚拟页中的数据进行修改时,把改虚拟页对应的物理页复制一份,再把改虚拟页映射到复制出来的物理页上,最后进行修改。这种初始化到共享数据上,等到修改时再复制出来进行修改的策略被称为“写时复制”。
采用写时复制策略后,虚拟页仍然能够从物理页中快速初始化。而且只有被修改的页才被复制了副本,对内存的消耗永远不会比完全拷贝整个对象大。

五、总结

虚拟内存被分为内核区和进程区。
内核区的范围是系统运行时的常量,对所有进程一样。内核区中保存着内核的代码和数据,以及内核运行栈和进程的 task_struct
每个进程的进程内存区总范围都不一样,其中子内存段的范围也不一样,并且中间还有空隙。进程使用 vm_area_struct 链表来保存每个内存段的范围、共享属性和权限。
内存段通过映射来初始化,可以映射到磁盘文件、请求二进制 0 页和共享对象。
映射到磁盘和请求二进制 0 的虚拟页,在第一次读取时才调入。映射到私有共享对象的页,在第一次修改时才被复制。
虚拟页只有被换出时才被写入到磁盘交换文件。