【Linux】虚拟内存的概念和布局
内存地址分为虚拟地址(或者叫逻辑地址)和物理地址。虚拟地址也是人为设计的一个概念,类比我们现实世界中的收货地址,而物理地址则是数据在物理内存中的真实存储位置,类比现实世界中的城市,街道,小区的真实地理位置。
为什么要使用虚拟地址访问内存
假设现在没有虚拟内存地址,我们在程序中对内存的操作全都都是使用物理内存地址,在这种情况下,程序员就需要精确的知道每一个变量在内存中的具体位置,我们需要手动对物理内存进行布局,明确哪些数据存储在内存的哪些位置,除此之外我们还需要考虑为每个进程究竟要分配多少内存?内存紧张的时候该怎么办?如何避免进程与进程之间的地址冲突?等等一系列复杂且琐碎的细节。
如果我们在单进程系统中比如嵌入式设备上开发应用程序,系统中只有一个进程,这单个进程独享所有的物理资源包括内存资源。在这种情况下,上述提到的这些直接使用物理内存的问题可能还好处理一些,但是仍然具有很高的开发门槛。
虚拟内存引入之后,进程的视角就会变得非常开阔,每个进程都拥有自己独立的虚拟地址空间,进程与进程之间的虚拟内存地址空间是相互隔离,互不干扰的。每个进程都认为自己独占所有内存空间,自己想干什么就干什么。
但是内核态虚拟内存空间是所有进程共享的,不同进程进入内核态后看到的虚拟内存空间全部都是一样的。
Linux虚拟内存空间整体布局
进程的虚拟内存空间分为两个部分:一部分是用户态虚拟内存空间,另一部分是内核态虚拟内存空间
对于用户空间:
-
在进程运行之前,这些存放在二进制文件中的机器码需要被加载进内存中,而用于存放这些机器码的虚拟内存空间叫做代码段。
-
那些在代码中被我们指定了初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做数据段。
-
那些没有指定初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做 BSS 段。这些未初始化的全局变量被加载进内存之后会被初始化为 0 值。
-
们程序在运行期间往往需要动态的申请内存,所以在虚拟内存空间中也需要一块区域来存放这些动态申请的内存,这块区域就叫做堆。
-
动态链接库中的代码段,数据段,BSS 段,以及通过 mmap 系统调用映射的共享内存区,在虚拟内存空间的存储区域叫做文件映射与匿名映射区。
-
调用函数过程中使用到的局部变量和函数参数也需要一块内存区域来保存。这一块区域在虚拟内存空间中叫做栈。
对于内核空间:
-
直接映射区:总共大小 1G 的内核虚拟内存空间中,位于最前边有一块
896M
大小的区域,我们称之为直接映射区或者线性映射区,地址范围为3G -- 3G + 896m
。之所以这块896M
大小的区域称为直接映射区或者线性映射区,是因为这块连续的虚拟内存地址会映射到0 - 896M
这块连续的物理内存上。在这段896M
大小的物理内存中,前1M
已经在系统启动的时候被系统占用,1M
之后的物理内存存放的是内核代码段,数据段,BSS 段。进程相关的数据结构也会存放在物理内存前896M
的这段区域中。在内核运行的过程中,会涉及内核栈的分配,内核会为每个进程分配一个固定大小的内核栈
(一般是两个页大小,依赖具体的体系结构),每个进程的整个调用链必须放在自己的内核栈中,内核栈也是分配在直接映射区。与进程用户空间中的栈不同的是,内核栈容量小而且是固定的。 -
直接映射区的前 16M 专门让内核用来为 DMA 分配内存,这块 16M 大小的内存区域我们称之为 ZONE_DMA。
-
直接映射区中剩下的部分也就是从 16M 到 896M(不包含 896M)这段区域,我们称之为 ZONE_NORMAL。
-
高端内存:物理内存 896M 以上的区域被内核划分为 ZONE_HIGHMEM 区域,物理内存假设为 4G,高端内存区域为 4G - 896M = 3200M,那么这块 3200M 大小的 ZONE_HIGHMEM 区域该如何映射到内核虚拟内存空间中呢?内核剩余可用的虚拟内存空间就变为了 1G - 896M = 128M。显然物理内存中 3200M 大小的 ZONE_HIGHMEM 区域无法继续通过直接映射的方式映射到这 128M 大小的虚拟内存空间中。物理内存中的 ZONE_HIGHMEM 区域就只能采用动态映射的方式映射到 128M 大小的内核虚拟内存空间中,也就是说只能动态的一部分一部分的分批映射,先映射正在使用的这部分,使用完毕解除映射,接着映射其他部分。
-
8M空洞:内核虚拟内存空间中的 3G + 896M 这块地址在内核中定义为 high_memory,high_memory 往上有一段 8M 大小的内存空洞。空洞范围为:high_memory 到 VMALLOC_START 。
-
vmalloc 动态映射区:VMALLOC_START 到 VMALLOC_END 之间的这块区域成为动态映射区。采用动态映射的方式映射物理内存中的高端内存。vmalloc 分配的内存在虚拟内存上是连续的,但是物理内存是不连续的。
-
永久映射区:在 PKMAP_BASE 到 FIXADDR_START 之间的这段空间称为永久映射区。在内核的这段虚拟地址空间中允许建立与物理高端内存的长期映射关系。比如内核通过 alloc_pages() 函数在物理内存的高端内存中申请获取到的物理内存页,这些物理内存页可以通过调用 kmap 映射到永久映射区中。
-
固定映射区:FIXADDR_START 到 FIXADDR_TOP直接的区域为固定映射区。在固定映射区中的虚拟内存地址可以自由映射到物理内存的高端地址上,但是与动态映射区以及永久映射区不同的是,在固定映射区中虚拟地址是固定的,而被映射的物理地址是可以改变的。比如:在内核的启动过程中,有些模块需要使用虚拟内存并映射到指定的物理地址上,而且这些模块也没有办法等待完整的内存管理模块初始化之后再进行地址映射。因此,内核固定分配了一些虚拟地址,这些地址有固定的用途,使用该地址的模块在初始化的时候,将这些固定分配的虚拟地址映射到指定的物理地址上去。
-
临时映射区:在内核中是不能够直接操作物理地址的,只能操作虚拟地址。
那怎么办呢?所以就需要使用 kmap_atomic 将缓存页临时映射到内核空间的一段虚拟地址上,这段虚拟地址就位于内核虚拟内存空间中的临时映射区上,然后将用户空间缓存区 DirectByteBuffer 中的待写入数据通过这段映射的虚拟地址拷贝到 page cache 中的相应缓存页中。这时文件的写入操作就已经完成了。由于是临时映射,所以在拷贝完成之后,调用 kunmap_atomic 将这段映射再解除掉。
32位体系结构下Linux虚拟内存空间整体布局
在 32
位机器上,指针的寻址范围为 2^32
,所能表达的虚拟内存空间为 4 GB
。所以在 32 位机器上进程的虚拟内存地址范围为:0x0000 0000 - 0xFFFF FFFF
。其中用户态虚拟内存空间为 3 GB
,虚拟内存地址范围为:0x0000 0000 - 0xC000 000
。内核态虚拟内存空间为 1 GB
,虚拟内存地址范围为:0xC000 000 - 0xFFFF FFFF
。
但是用户态虚拟内存空间中的代码段并不是从 0x0000 0000
地址开始的,而是从 0x0804 8000
地址开始。
0x0000 0000
到0x0804 8000
这段虚拟内存地址是一段不可访问的保留区,因为在大多数操作系统中,数值比较小的地址通常被认为不是一个合法的地址,这块小地址是不允许访问的。比如在 C 语言中我们通常会将一些无效的指针设置为 NULL,指向这块不允许访问的地址。
栈空间中的地址增长方向是从高地址向低地址增长。文件映射与匿名映射区的地址增长方向是从高地址向低地址增长。堆空间的地址增长方向是从低地址向高地址增长
64位体系结构下Linux虚拟内存空间整体布局
我们理所应当的会认为在 64
位机器上,指针的寻址范围为 2^64
,所能表达的虚拟内存空间为16 EB
。虚拟内存地址范围为:0x0000 0000 0000 0000 0000 - 0xFFFF FFFF FFFF FFFF
。好家伙 !!! 16 EB 的内存空间,笔者都没见过这么大的磁盘,在现实情况中根本不会用到这么大范围的内存空间,事实上在目前的 64 位系统下只使用了 48 位
来描述虚拟内存空间,寻址范围为 2^48
,所能表达的虚拟内存空间为 256TB
。其中低 128 T 表示用户态虚拟内存空间
,虚拟内存地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000
。高 128 T 表示内核态虚拟内存空间
,
虚拟内存地址范围为:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF
。这样一来就在用户态虚拟内存空间与内核态虚拟内存空间之间形成了一段 0x0000 7FFF FFFF F000 - 0xFFFF 8000 0000 0000
的地址空洞,我们把这个空洞叫做canonical address
空洞。
64 位系统中的虚拟内存布局和 32 位系统中的虚拟内存布局大体上是差不多的。主要不同的地方有三点:
- 就是前边提到的由高 16 位空闲地址造成的 canonical address 空洞。在这段范围内的虚拟内存地址是不合法的,因为它的高 16 位既不全为 0 也不全为 1,不是一个 canonical address,所以称之为 canonical address 空洞。
- 在代码段跟数据段的中间还有一段不可以读写的保护段,它的作用是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。
- 用户态虚拟内存空间与内核态虚拟内存空间分别占用 128T,其中低128T 分配给用户态虚拟内存空间,高 128T 分配给内核态虚拟内存空间。
进程虚拟内存空间的管理
task_struct
结构体
在 Linux 中每一个进程都由 task_struct
数据结构来定义。task_struct
就是我们通常所说的 PCB(Process Control Block)。
struct task_struct {// 进程idpid_t pid;// 用于标识线程所属的进程 pidpid_t tgid;// 进程打开的文件信息struct files_struct *files;// 内存描述符表示进程虚拟地址空间struct mm_struct *mm;.......... 省略 .......
}
在进程描述符task_struct
结构中,有一个专门描述进程虚拟地址空间的内存描述符mm_struct
结构,这个结构体中包含了前边几个小节中介绍的进程虚拟内存空间的全部信息。每个进程都有唯一的mm_struct
结构体,也就是前边提到的每个进程的虚拟地址空间都是独立,互不干扰的。
- 通过
fork()
函数创建出的子进程,它的虚拟内存空间以及相关页表相当于父进程虚拟内存空间的一份拷贝,直接从父进程中拷贝到子进程中。 - 通过
vfork 或者 clone
系统调用创建出的子进程,会将父进程的虚拟内存空间以及相关页表直接赋值给子进程。这样一来父进程和子进程的虚拟内存空间就变成共享的了。也就是说父子进程之间使用的虚拟内存空间是一样的,并不是一份拷贝。
子进程共享了父进程的虚拟内存空间,这样子进程就变成了我们熟悉的线程,是否共享地址空间几乎是进程和线程之间的本质区别。Linux 内核并不区别对待它们,线程对于内核来说仅仅是一个共享特定资源的进程而已。
内核线程和用户态线程的区别就是内核线程没有相关的内存描述符 mm_struct
,内核线程对应的 task_struct
结构中的 mm 域指向 Null
,所以内核线程之间调度是不涉及地址空间切换的。
mm_struct
结构体
struct mm_struct {unsigned long task_size; /* size of task vm space */unsigned long start_code, end_code, start_data, end_data;unsigned long start_brk, brk, start_stack;unsigned long arg_start, arg_end, env_start, env_end;unsigned long mmap_base; /* base of mmap area */unsigned long total_vm; /* Total pages mapped */unsigned long locked_vm; /* Pages that have PG_mlocked set */unsigned long pinned_vm; /* Refcount permanently increased */unsigned long data_vm; /* VM_WRITE & ~VM_SHARED & ~VM_STACK */unsigned long exec_vm; /* VM_EXEC & ~VM_WRITE & ~VM_STACK */unsigned long stack_vm; /* VM_STACK */...... 省略 ........
}
start_code
和end_code
定义代码段的起始和结束位置,程序编译后的二进制文件中的机器码被加载进内存之后就存放在这里。start_data
和end_data
定义数据段的起始和结束位置,二进制文件中存放的全局变量和静态变量被加载进内存中就存放在这里。- 后面紧挨着的是
BSS 段
,用于存放未被初始化的全局变量和静态变量,这些变量在加载进内存时会生成一段 0 填充的内存区域 (BSS 段),BSS 段的大小是固定的
。 - 在堆中内存地址的增长方向是由低地址向高地址增长,
start_brk
定义堆的起始位置,brk
定义堆当前的结束位置。我们使用malloc
申请小块内存时(低于 128K),就是通过改变brk
位置调整堆大小实现的。 - 在内存映射区内存地址的增长方向是由高地址向低地址增长,
mmap_base
定义内存映射区的起始地址。 start_stack
是栈的起始位置在RBP
寄存器中存储,栈的结束位置也就是栈顶指针stack pointer
在 RSP 寄存器中存储。在栈中内存地址的增长方向也是由高地址向低地址增长。arg_start
和arg_end
是参数列表的位置,env_start
和env_end
是环境变量的位置。它们都位于栈中的最高地址处。total_vm
表示在进程虚拟内存空间中总共与物理内存映射的页的总数。(操作系统会把物理内存划分成一页一页的区域来进行管理,所以物理内存到虚拟内存之间的映射也是按照页为单位进行的)- 当内存吃紧的时候,有些页可以换出到硬盘上,而有些页因为比较重要,不能换出。
locked_vm
就是被锁定不能换出的内存页总数,pinned_vm
表示既不能换出,也不能移动的内存页总数。 data_vm
表示数据段中映射的内存页数目,exec_vm
是代码段中存放可执行文件的内存页数目,stack_vm
是栈中所映射的内存页数目。
vm_area_struct
结构体
这个结构体描述了这些虚拟内存区域 VMA
(virtual memory area)。每个 vm_area_struct
结构对应于虚拟内存空间中的唯一虚拟内存区域 VMA
。
struct vm_area_struct {unsigned long vm_start; /* Our start address within vm_mm. */unsigned long vm_end; /* The first byte after our end addresswithin vm_mm. *//** Access permissions of this VMA.*/pgprot_t vm_page_prot;unsigned long vm_flags; struct anon_vma *anon_vma; /* Serialized by page_table_lock */struct file * vm_file; /* File we map to (can be NULL). */unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZEunits */ void * vm_private_data; /* was vm_pte (shared mem) *//* Function pointers to deal with this struct. */const struct vm_operations_struct *vm_ops;struct vm_area_struct *vm_next, *vm_prev;struct rb_node vm_rb;struct list_head anon_vma_chain; struct mm_struct *vm_mm; /* The address space we belong to. */
}
-
vm_start
指向了这块虚拟内存区域的起始地址(最低地址),vm_start
本身包含在这块虚拟内存区域内。vm_end
指向了这块虚拟内存区域的结束地址(最高地址),而vm_end
本身包含在这块虚拟内存区域之外,所以vm_area_struct
结构描述的是[vm_start,vm_end)
这样一段左闭右开的虚拟内存区域。 -
vm_page_prot
和vm_flags
都是用来标记vm_area_struct
结构表示的这块虚拟内存区域的访问权限和行为规范。内核会将整块物理内存划分为一页一页大小的区域,以页为单位来管理这些物理内存,每页大小默认 4K 。而虚拟内存最终也是要和物理内存一一映射起来的,所以在虚拟内存空间中也有虚拟页的概念与之对应,虚拟内存中的虚拟页映射到物理内存中的物理页。无论是在虚拟内存空间中还是在物理内存中,内核管理内存的最小单位都是页。 -
页表中关于内存页的访问权限就是由
vm_page_prot
决定的。vm_flags
则偏向于定于整个虚拟内存区域的访问权限以及行为规范,描述的是虚拟内存区域中的整体信息,而不是虚拟内存区域中具体的某个独立页面。 -
可以通过
vma->vm_page_prot = vm_get_page_prot(vma->vm_flags)
实现到具体页面访问权限vm_page_prot
的转换。 -
一些常用到的
vm_flags
方便大家有一个直观的感受:vm_flags 访问权限 VM_READ 可读 VM_WRITE 可写 VM_EXEC 可执行 VM_SHARD 可多进程之间共享 VM_IO 可映射至设备 IO 空间 VM_RESERVED 内存区域不可被换出 VM_SEQ_READ 内存区域可能被顺序访问 VM_RAND_READ 内存区域可能被随机访问 -
当我们调用
malloc
申请内存时,如果申请的是小块内存(低于 128K)则会使用do_brk()
系统调用通过调整堆中的brk
指针大小来增加或者回收堆内存。如果申请的是比较大块的内存(超过 128K)时,则会调用mmap
在上图虚拟内存空间中的文件映射与匿名映射区创建出一块VMA
内存区域(这里是匿名映射)。这块匿名映射区域就用struct anon_vma
结构表示。 -
当调用
mmap
进行文件映射时,vm_file
属性就用来关联被映射的文件。这样一来虚拟内存区域就与映射文件关联了起来。vm_pgoff
则表示映射进虚拟内存中的文件内容,在文件中的偏移。 -
vm_private_data
则用于存储VMA
中的私有数据。 -
在内核中其实是通过一个
struct vm_area_struct
结构的双向链表将虚拟内存空间中的这些虚拟内存区域VMA
串联起来的。vm_area_struct
结构中的vm_next
,vm_prev
指针分别指向VMA
节点所在双向链表中的后继节点和前驱节点,内核中的这个VMA
双向链表是有顺序的,所有VMA
节点按照低地址到高地址的增长方向排序。 -
双向链表中的最后一个
VMA
节点的vm_next
指针指向NULL
,双向链表的头指针存储在内存描述符struct mm_struct
结构中的mmap
中,正是这个mmap
串联起了整个虚拟内存空间中的虚拟内存区域。 -
vm_mm
指针指向了所属的虚拟内存空间mm_struct
。
我们可以通过 cat /proc/pid/maps
或者 pmap pid
查看进程的虚拟内存空间布局以及其中包含的所有内存区域。这两个命令背后的实现原理就是通过遍历内核中的这个vm_area_struct
双向链表获取的。
内核中关于这些虚拟内存区域的操作除了遍历之外还有许多需要根据特定虚拟内存地址在虚拟内存空间中查找特定的虚拟内存区域。
尤其在进程虚拟内存空间中包含的内存区域VMA
比较多的情况下,使用红黑树查找特定虚拟内存区域的时间复杂度是 O( logN )
,可以显著减少查找所需的时间。
存空间布局以及其中包含的所有内存区域。这两个命令背后的实现原理就是通过遍历内核中的这个vm_area_struct
双向链表获取的。
内核中关于这些虚拟内存区域的操作除了遍历之外还有许多需要根据特定虚拟内存地址在虚拟内存空间中查找特定的虚拟内存区域。
尤其在进程虚拟内存空间中包含的内存区域VMA
比较多的情况下,使用红黑树查找特定虚拟内存区域的时间复杂度是 O( logN )
,可以显著减少查找所需的时间。
所以在内核中,同样的内存区域 vm_area_struct
会有两种组织形式,一种是双向链表用于高效的遍历,另一种就是红黑树用于高效的查找。