虚拟内存布局
虚拟地址空间大小:
- 32位虚拟地址空间 [0 ~ 2^32 - 1] 总共4GB
- 64位虚拟地址空间 [0 ~ 2^64 - 1] 总共16 777 216TB
不管是运行在用户态还是内核态,都需要使用虚拟地址,这是因为计算机硬件要求的,CPU要经过地址转换得到最终的物理地址,软件必须服从硬件的规定。
内核态的虚拟空间和某一个程序没有关系,所有程序通过系统调用进入到内核之后,看到的虚拟地址空间都是一样的。
-
32 位虚拟地址空间中,[0x0000 0000, 0xBFFF FFFF] 的 3GB 空间用作用户态,是每一个应用程序可以使用的虚拟地址范围,[0xC000 0000, 0xFFFF FFFF] 的 1GB 空间用作内核态,是给操作系统内核使用的虚拟地址范围
-
每个用户程序都使用相同的虚拟地址空间 [0x0000 0000, 0xBFFF FFFF],每个内核程序也使用相同的虚拟地址空间 [0xC000 0000, 0xFFFF FFFF]
-
64 位虚拟地址空间中,低 128T 虚拟地址空间被用作用户态,高 128T 虚拟地址空间被用作内核态,中间留有空洞
用户态虚拟内存布局,从上往下分别是:
-
栈(stack)
-
mmap内存映射区
-
运行时堆区(heap)
-
数据段(.bss和.data)
-
代码段(.text)
其中栈空间是倒着向下增长的,堆区空间是向上增长的,栈和堆是相对增长,消耗中间的地址空间。mmap区和堆区也可以相对增长(64位中mmap区往哪边增长都可以)
内存映射
Linux 中通过将一块虚拟地址内存空间和一个文件对象关联起来,以初始化这块虚拟内存的内容,这个过程称为内存映射(memory mapping)。
这里的文件对象可以是:
- ① Linux文件系统中的普通文件,
- ② 也可以是一个匿名文件,即一块全部包含二进制零的物理内存。
普通文件的内存映射:
-
首先将用户程序的虚拟地址和磁盘文件进行映射,
-
当访问虚拟页的时候,由缺页异常处理程序将磁盘文件内容加载到物理内存中,同时更新用户程序的虚拟页表中虚拟页号对应的物理页号,这样程序通过虚拟地址访问物理内存就相当于访问该文件内容,对物理内存操作就相当于操作该文件。
匿名文件的内存映射:
- 直接将一块包含二进制零的物理内存和用户的虚拟地址进行映射,缺页异常处理程序将对应的物理页号更新到虚拟页表中即可。
需要注意,内存映射也是要经过页表将虚拟内存地址映射到物理内存地址的,并不是说可以直接在代码中访问到关联的物理内存地址。记住,用户程序中能访问到的永远是虚拟地址空间,即便是通过mmap内存映射也是如此。
共享内存映射
共享内存映射:
-
当一个文件和程序 1 的虚拟地址建立内存映射之后,程序 2 需要访问该文件内容时,可以直接与对应的物理内存页建立映射,实现不同的程序的虚拟内存共享同一块物理内存,即共享同一个文件对象。
-
共享内存映射的最佳应用就是著名的动态链接共享库(.so库文件),它可以节约物理内存,避免重复浪费(相对于静态链接库而言)。通过共享内存映射,不同的应用程序只需要共享物理内存中的同一份共享库代码,不需要每个应用程序都保留一份,因此提高了内存利用率。
然而,只要有一个程序试图写私有区域内的某个页面,那么这个写操作就会触发一个保护故障。故障处理程序注意到保护异常是由于程序试图写私有的写时赋值区域中的一个页面而引起的,它就会在物理内存中创建这个页面的一个新副本,更新页表项指向这个新的副本,然后恢复这个页面的可写权限。这就是写时复制技术。
内存映射函数 mmap
内存映射的详细流程图:https://www.processon.com/view/link/627f9bfdf346fb3cb3fb696f
#include <unistd.h>
#include <sys/mmam.h>
// 返回:若成功时则返回指向映射区域的指针,若出错则返回 MAP_FAILED(-1)
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset)
参数 prot
包含描述新映射的虚拟内存区域的访问权限位:
PROT_EXEC
:这个区域内的页面由可以被 CPU 执行的指令组成PROT_READ
:这个区域内的页面可读PROT_WRITE
:这个区域内的页面可写PROT_NONE
:这个区域内的页面不能被访问
参数 flags
由描述被映射对象类型的位组成:
MAP_ANON
:如果设置了MAP_ANON
标记位,那么这个被映射的对象就是一个匿名对象,而相应的虚拟页面是请求二进制零的MAP_PRIVATE
:表示被映射的对象是一个私有的、写时复制的对象MAP_SHARED
:表示是一个共享对象。
bufp = mmap(NULL, size, PROT_READ, MAP_PRIVATE | MAP_ANON, 0, 0)
上面代码表示让内核新建一个新的包含size
字节的只读、私有、请求二进制零的虚拟内存区域。如果调用成功,那么bufp
则是新区域的起始地址。
#include <unistd.h>
#include <sys/mmam.h>
// 若成功则返回 0,若出错则返回 -1
int munmap(void *start, size_t length);
munmap
函数删除从虚拟地址 start
开始的,由接下来 length
字节组成的区域。接下来对已删除区域的引用会导致段错误。
- Linux 提供了
mmap
和munmap
函数来进行虚拟地址和文件对象的映射和删除映射操作 - 操作系统加载应用的 ELF 文件时,用户程序的每一段虚拟地址都是通过
mmap
采用内存映射加载到内存的,就是把虚拟地址空间和 ELF 中某一部分关联映射,用户程序的某些段可能是私有的映射(如栈和运行时堆),某些可能是共享的映射(如so共享库映射区)
内存映射区的管理
用户程序的虚拟内存的每一段映射区,被抽象成一个结构体vm_area_struct
,为提高查询效率,不同映射区之间的vm_area_struct
使用链表+红黑树来维护。
问题:给你一个虚拟地址,如何快速找到这个虚拟地址位于哪一个 vm_area_struct
?
-
顺序遍历链表,对于每个节点确定虚拟地址是否在节点映射虚拟地址范围内,时间复杂度 O(n)
-
mmap 是一个常见的操作,所以链表可能会挺长的
mmap流程:
内存布局总结图
32 位系统虚拟内存布局总结图链接:https://www.processon.com/view/link/6232f11d5653bb074b22d983
64 位系统虚拟内存布局总结图链接:https://www.processon.com/view/link/623311f8e401fd0726c20d50
64位直接映射区范围大小是64T,足够覆盖所有的物理内存范围,所以不需要也不存在高端内存映射区。
用户程序页表和内核页表
每个应用程序的虚拟地址空间分为两部分:
- ① 用户态虚拟地址空间
- ② 内核态虚拟地址空间
所以一个应用程序的页表也有两部分:
- ① 第一部分用于映射用户态虚拟页到物理页
- ② 第二部分用于映射内核态虚拟页到物理页
其中:
- 用户态虚拟页到物理页的映射,在 CPU 访问用户态虚拟内存发生缺页异常时,由缺页异常处理程序维护映射关系
- 内核态虚拟页到物理页的映射,是在程序加载启动的时候,从内核页表拷贝过来的
注意:内核页表是所有应用程序共享的,而用户态页表是每个应用程序私有的,每个程序都不一样。
程序页表的每一个页表项中有一个标记位,来标记这个页表项是属于用户态还是内核态:
- 当程序运行在用户态的时候,只能访问标记为用户态的页表项,
- 当程序通过系统调用,陷入到内核态后,可以访问程序页表中的所有页表项
- 内核程序是有特权的
- 页表项:有效位、权限、物理页号
- 标记位的作用:隔离内核态和用户态的页表
问题:为什么维护了内核页表,还要将内核页表拷贝到程序页表?
-
主要为了提高性能,当一个应用程序通过系统调用陷入内核态时,就不需要切换页表了,直接使用程序页表就可以,因为程序页表中包含了内核页表部分。切换页表需要消耗性能,比如需要刷新 TLB 页表项缓存。
-
空间换时间的设计
缺页异常处理程序
用户态缺页异常处理程序的执行逻辑流程图:https://www.processon.com/view/link/6235ab0ce0b34d75aad6ba71
问题:用户态程序访问页表会陷入内核态吗?
-
首先,页表都是保存在内核态的,这个主要是为了安全,因为这样的话用户态程序就不能随意修改页表了,也就是只有内核可以修改页表。当发生缺页异常,执行内核中的缺页异常处理程序时,修改页表。
-
程序处于用户态时,访问页表也不需要陷入内核态,因为访问页表是 MMU+TLB+CR3 这些硬件完成的不关程序啥事情,所以,这时程序也不需要陷入内核态。