虚拟内存概念以及作用
在没有虚拟内存概念的时候(DOS
时代),程序寻址用的都是物理地址(实际存在硬件里面的空间地址,Physical Memory Address
)。程序能寻址的范围是有限的,这取决于CPU
的地址线条数。比如在32
位平台下,寻址的范围是2^32^
也就是 4G
。并且这是固定的,如果没有虚拟内存,且每次开启一个进程都给4G
的物理内存,就可能会出现很多问题:
-
因为物理内存时有限的,当有多个进程要执行的时候,都要给
4G
内存,很显然你内存小一点,这很快就分配完了,于是没有得到分配资源的进程就只能等待。当一个进程执行完了以后,再将等待的进程装入内存。这种频繁的装入内存的操作是很没效率的; -
由于指令都是直接访问物理内存的,那么我这个进程就可以修改其他进程的数据,甚至会修改内核地址空间的数据,这是我们不想看到的;
-
因为内存时随机分配的,所以程序运行的地址也是不正确的。
于是针对上面会出现的各种问题,虚拟内存技术就出来了。
虚拟内存是物理内存和进程之间的中间层。

虚拟内存 从字面上理解就是一个假象的内存空间,非真实物理硬件内存空间,是操作系统内核为了对进程地址空间进行管理而精心设计的一个逻辑意义上的内存空间概念,是为程序运行和操作使用的内存地址。
虚拟内存它的作用有:
- 安全隔离
- 操作系统给每个进程分配了属于自己的私有虚拟内存,保证了进程的独立运行;
- 隔离了物理地址, 凡是程序运行过程中可能需要用到的指令或者数据都必须在虚拟内存空间中;
- 共享内存
- 利用映射技术,多个进程之间通过共享内存的方式有效共享代码库等;
- 创建给主存更多的空间以及更善用碎片空间
- 虚拟内存地址通常是连续的地址空间,解决主存非连续空间分配内存给某进程善用碎片空间;
- 操作系统的内存管理模块控制下,在触发缺页中断时利用分页技术将实际的物理内存分配给虚拟内存,可以获取虚拟内存空间远远大于实际物理内存大小,例如
32
位系统4g
物理内存下,每个进程都可以获得4G
虚拟内存,如果运行20
个进程,则相当于可以使用80g
的虚拟内存空间;
- 作为缓存用,进行更有效的寻址
虚拟内存实现原理概念如下图:
进程的虚拟内存空间结构
虚拟内存的管理是以进程为基础,每个进程都有各自的虚拟地址空间,空间大小由CPU
的位数决定。每个进程的内核空间是所有进程所共享的。
以 32
位系统为例,32
位Linux
系统理论上每个进程最大可分配的虚拟地址空间(232 B = 4 GB),其中0-3G
为用户空间,3-4G
为内核空间。
以 64
位系统为例,64
位Linux
系统理论上每个进程最大可分配的虚拟地址空间(264 B = 17179869184 GB), 但实现64
位长的地址只会增加系统的复杂度和地址转换的成本, 带不来任何好处. 所以目前的x86-64
架构CPU
都遵循AMD
的Canonical form
, 即只有虚拟地址的最低48
位才会在地址转换时被使用, 且任何虚拟地址的48
位至63
位必须与47
位一致(sign extension
)。也就是说, 总的虚拟地址空间为 256TB( 248 B), 其中128T
为用户空间,128T
为内核空间。
那么这个进程独占的虚拟内存空间到底是什么样子呢?在本小节中,就为大家揭开这层神秘的面纱。
首先我们会想到的是一个进程运行起来是为了执行我们交代给进程的工作,执行这些工作的步骤我们通过程序代码事先编写好,然后编译成二进制文件存放在磁盘中,CPU
会执行二进制文件中的机器码来驱动进程的运行。所以在进程运行之前,这些存放在二进制文件中的机器码需要被加载进内存中,而用于存放这些机器码的虚拟内存空间叫做代码段。为了防止代码和常量遭到修改,代码段被设置为只读。
在程序运行起来之后,总要操作变量吧,在程序代码中我们通常会定义大量的全局变量和静态变量,这些全局变量在程序编译之后也会存储在二进制文件中,在程序运行之前,这些全局变量也需要被加载进内存中供程序访问。所以在虚拟内存空间中也需要一段区域来存储这些全局变量。
- 那些在代码中被我们指定了初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做数据段(
Data
段)。数据段属于静态内存分配(静态存储区),可读可写。 - 那些没有指定初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做
BSS
段。这些未初始化的全局变量被加载进内存之后会被初始化为零值。
上面所说的全局变量和静态变量都是在编译期间就确定的,但是我们程序在运行期间往往需要动态的申请内存,所以在虚拟内存空间中也需要一块区域来存放这些动态申请的内存,这块区域就叫做堆(Heap
)。注意这里的堆指的是 OS
堆并不是 JVM
中的堆。
除此之外,我们的程序在运行过程中还需要依赖动态链接库,这些动态链接库以 .so
文件的形式存放在磁盘中,比如 C
程序中的 glibc
,里边对系统调用进行了封装。glibc
库里提供的用于动态申请堆内存的 malloc
函数就是对系统调用 sbrk
和 mmap
的封装。这些动态链接库也有自己的对应的代码段,数据段,BSS
段,也需要一起被加载进内存中。
还有用于内存文件映射的系统调用 mmap
,会将文件与内存进行映射,那么映射的这块内存(虚拟内存)也需要在虚拟地址空间中有一块区域存储。
这些动态链接库中的代码段,数据段,BSS
段,以及通过 mmap
系统调用映射的共享内存区,在虚拟内存空间的存储区域叫做文件映射与匿名映射区。
最后我们在程序运行的时候总该要调用各种函数吧,那么调用函数过程中使用到的局部变量和函数参数也需要一块内存区域来保存。这一块区域在虚拟内存空间中叫做栈(Stack
)。
至此,进程的虚拟内存空间所包含的主要区域已经介绍完成,再次总结下:
- 代码(
Code
)段: 存放进程程序二进制文件中的机器指令。这块区域的大小在程序运行时就已经确定,并且为了防止代码和常量遭到修改,代码段被设置为只读; - 数据(
Data
)段: 存放程序中已初始化且初值不为0的全局变量和静态局部变量。数据段属于静态内存分配(静态存储区),可读可写; BSS
段:存放没有指定初始值的全局变量和静态变量;- 堆(
Heap
):堆用来存放动态分配的内存。堆内存由用户申请分配和释放,从低地址向高地址增长。不同于数据结构中的堆,存储空闲内存的方式类似链表,因此空闲内存分布不连续,可动态扩张或缩减; - 内存映射段 : 是高效的
I/O
映射方式,用于装载一个共享的动态内存库。用户可使用系统接口创建共享共享内存,做进程间通信。它也叫共享区,主要包括 共享内存、动态链接库 等共享资源,从低地址向高地址增长; - 栈(
Stack
)区: 栈用来存放程序中临时创建的局部变量,如函数的参数、内部变量等。每当一个函数被调用时,就会将参数压入进程调用栈中,调用结束后返回值也会被放回栈中。同时,每调用一次函数就会在调用栈上维护一个独立的栈帧 ,所以在递归较深时容易导致栈溢出。栈内存的申请和释放由 编译器 自动完成,并且 栈容量由系统预先定义 。栈从高地址向低地址增长。
以上就是我们通过一个程序在运行过程中所需要的数据所规划出的虚拟内存空间的分布,这些只是一个大概的规划,在真实的 Linux
系统中,进程的虚拟内存空间的又是如何的呢?
在真实的Linux
系统下进程的虚拟空间结构在 32
位机器上和64
位机器上是不同的:
-
在
32
位机器上,指针的寻址范围为2^32^
,所能表达的虚拟内存空间为4 GB
,进程的虚拟内存地址范围为: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
,指向这块不允许访问的地址。堆空间中地址的增长方向是从低地址到高地址增长,因此堆空间的上边是一段待分配区域,用于扩展堆空间的使用。
栈空间中地址的**增长方向是从高地址向低地址增长,**因此在栈空间的下边也有一段待分配区,域用于扩展栈空间。
再来看看用户态的地址空间和内核态的地址空间在
32
位系统内核中是如何被划分的呢?这就用到了进程的内存描述符
mm_struct
结构体中的task_size
变量,task_size
定义了用户态地址空间与内核态地址空间之间的分界线,源码如下:// linux v5.6 path: include/linux/mm_types.h struct mm_struct { ...... unsigned long task_size; /* size of task vm space */ ...... }
我们来看下内核在关于
TASK_SIZE
的定义:// linux v5.6 path: /arch/x86/include/asm/page_32_types.h #define __PAGE_OFFSET_BASE _AC(CONFIG_PAGE_OFFSET, UL) #define __PAGE_OFFSET __PAGE_OFFSET_BASE /* * User space process size: 3GB (default). */ #define TASK_SIZE __PAGE_OFFSET
__PAGE_OFFSET
在内核/arch/arm/Kconfig
文件中的相关定义:可以从代码中看出用户地址空间和内核地址空间的分界线在
0xC000 000
地址处,那么自然进程的mm_struct
结构中的task_size
为0xC000 000
。 - 其中用户态虚拟内存空间为
-
在
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
。
- 其中低
从上图中我们可以看出 64
位系统中的虚拟内存布局和 32
位系统中的虚拟内存布局大体上是差不多的。主要不同的地方有三点:
- 我们都知道在
64
位机器上的指针寻址范围为2^64^
,但是在实际使用中我们只使用了其中的低48
位来表示虚拟内存地址,那么这多出的高16
位就形成了这个地址空洞,称为canonical address
空洞。在这段范围内的虚拟内存地址是不合法的,因为它的高16
位既不全为0
也不全为1
,不是一个canonical address
,所以称之为canonical address
空洞; - 在代码段跟数据段的中间还有一段不可以读写的保护段,它的作用是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行;
- 用户态虚拟内存空间与内核态虚拟内存空间分别占用
128T
,其中低128T
分配给用户态虚拟内存空间,高128T
分配给内核态虚拟内存空间。
再来看看用户态的地址空间和内核态的地址空间在64
位系统内核中是如何被划分的呢?
来看看64
位系统中关于 TASK_SIZE
的定义:
PAGE_SIZE
定义:
从代码中可以看出,64
位系统中内核通过task_size_max()
来计算 TASK_SIZE
,计算逻辑是 1
左移 47
位得到的地址是 0x0000800000000000
,然后减去一个 PAGE_SIZE
(默认为 4K
),就是 0x00007FFFFFFFF000
,共 128T
。所以在 64
位系统中的 TASK_SIZE
为 0x00007FFFFFFFF000
。
内核空间的虚拟内存相关
内核对进程虚拟内存区域划分与组织
内核如何为进程划分这些虚拟内存区域呢?
要解答这个问题前,我们先要熟悉几个内核的结构:
task_struct 结构
每个进程在内核中都有一个进程控制块(PCB
)来维护进程相关的信息,Linux
内核的进程控制块是task_struct
结构体:
struct task_struct
{
/*...*/
pid_t pid; // 进程id,进程的唯一标识
pid_t tgid; // 用于标识线程所属的进程 pid
struct mm_struct *mm; //mm_struct类型的指针,指向他的虚拟地址空间的用户空间部分
struct files_struct *files; //进程打开的文件信息
/*...*/
}
在进程描述符 task_struct
结构中,有一个专门描述进程虚拟地址空间的内存描述符 mm_struct
结构,每个进程都有唯一的 mm_struct
结构体,也就是前边提到的每个进程的虚拟地址空间都是独立,互不干扰的。
mm_struct 结构
内存描述符的 mm_struct
结构体来表示进程虚拟内存空间的全部信息。在内核中用 mm_struct
结构体中的上述属性来定义上图中虚拟内存空间里的不同内存区域。
struct mm_struct {
unsigned long task_size; //内核和用户态的起始地址
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 */
struct vm_area_struct *mmap;
struct rb_root mm_rb;
/*...*/
}
在mm_struct
结构体中,如下划定不同的虚拟内存空间区域:
start_code
和end_code
定义代码段的起始和结束位置,程序编译后的二进制文件中的机器码被加载进内存之后就存放在这里。start_data
和end_data
定义数据段的起始和结束位置,二进制文件中存放的全局变量和静态变量被加载进内存中就存放在这里。
后面紧挨着的是 BSS
段,用于存放未被初始化的全局变量和静态变量,这些变量在加载进内存时会生成一段 0
填充的内存区域 (BSS
段), BSS
段的大小是固定的。
- 接着是堆(
Heap
)了,在堆中内存地址的增长方向是由低地址向高地址增长,start_brk
定义堆的起始位置,brk
定义堆当前的结束位置。我们使用malloc
申请小块内存时(低于128K
),就是通过改变brk
位置调整堆大小实现的。 - 接下来就是内存映射区,在内存映射区内存地址的增长方向是由高地址向低地址增长,
mmap_base
定义内存映射区的起始地址。进程运行时所依赖的动态链接库中的代码段,数据段,BSS
段以及我们调用mmap
映射出来的一段虚拟内存空间就保存在这个区域。 start_stack
是栈的起始位置在RBP
寄存器中存储,栈的结束位置也就是栈顶指针stack pointer
在RSP
寄存器中存储。在栈中内存地址的增长方向也是由高地址向低地址增长。arg_start
和arg_end
是参数列表的位置,env_start
和env_end
是环境变量的位置。它们都位于栈中的最高地址处。
在 mm_struct
结构体中除了上述用于划分虚拟内存区域的变量之外,还定义了一些虚拟内存与物理内存映射内容相关的统计变量,操作系统会把物理内存划分成一页一页的区域来进行管理,所以物理内存到虚拟内存之间的映射也是按照页为单位进行的。
total_vm
表示在进程虚拟内存空间中总共与物理内存映射的页的总数。locked_vm
表示被锁定不能换出的内存页总数,pinned_vm
表示既不能换出,也不能移动的内存页总数。因为当内存吃紧的时候,有些页可以换出到硬盘上,而有些页因为比较重要,不能换出。data_vm
表示数据段中映射的内存页数目,exec_vm
是代码段中存放可执行文件的内存页数目,stack_vm
是栈中所映射的内存页数目,这些变量均是表示进程虚拟内存空间中的虚拟内存使用情况。
通过上述两个结构体,我们可以对虚拟内存空间的布局有了更新一个层面的了解,我们用下面的图更新下我们对虚拟内存空间图:
vm_area_struct 结构
通过对mm_struct
结构体,我们大致上了解了内核中对虚拟内存的区域划分情况,那么接下来的问题就是在内核中这些虚拟内存区域是如何被组织的呢?
对的,就是通过mm_struct
结构体中的vm_area_struct
结构体:
struct mm_struct {
/*...*/
struct vm_area_struct *mmap;
/*...*/
}
vm_area_struct
结构体它表示的是一块连续的虚拟地址空间区域,给进程使用的。内核每次为用户空间中分配一个空间使用时,都会生成一个vm_are_struct
结构用于记录跟踪分配情况,一个vm_are_struct
就代表一段虚拟内存空间。
vm_are_struct
结构体定义:
//linux 5.6 include/linux/mm_types.h
struct vm_area_struct {
unsigned long vm_start; //虚存区起始
unsigned long vm_end; //虚存区结束
struct vm_area_struct *vm_next, *vm_prev; //前后指针
struct rb_node vm_rb; //红黑树中的位置
unsigned long rb_subtree_gap;
struct mm_struct *vm_mm; //所属的 mm_struct
pgprot_t vm_page_prot;
unsigned long vm_flags; //标志位
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} shared;
struct list_head anon_vma_chain;
struct anon_vma *anon_vma;
const struct vm_operations_struct *vm_ops; //vma对应的实际操作
unsigned long vm_pgoff; //文件映射偏移量
struct file * vm_file; //映射的文件
void * vm_private_data; //私有数据
atomic_long_t swap_readahead_info;
#ifndef CONFIG_MMU
struct vm_region *vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
struct mempolicy *vm_policy; /* NUMA policy for the VMA */
#endif
struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;
linux
内核使用vm_area_struct
结构来表示一个独立的虚拟内存区域,由于每个不同质的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct
结构来分别表示不同类型的虚拟内存区域。各个vm_area_struct
结构使用链表或者树形结构链接,方便进程快速访问。
每个 vm_area_struct
结构对应于虚拟内存空间中的唯一虚拟内存区域 VMA
,vm_start
指向了这块虚拟内存区域的起始地址(最低地址),vm_start
本身包含在这块虚拟内存区域内。vm_end
指向了这块虚拟内存区域的结束地址(最高地址),而 vm_end
本身包含在这块虚拟内存区域之外,所以 vm_area_struct
结构描述的是 [vm_start,vm_end)
这样一段左闭右开的虚拟内存区域。
vm_area_struct
结构中的 vm_next
,vm_prev
指针分别指向 VMA
节点所在双向链表中的后继节点和前驱节点,内核中的这个 VMA
双向链表是有顺序的,所有 VMA
节点按照低地址到高地址的增长方向排序。
双向链表中的最后一个 VMA
节点的 vm_next
指针指向 NULL
,双向链表的头指针存储在内存描述符 struct mm_struct
结构中的 mmap
中,正是这个 mmap
串联起了整个虚拟内存空间中的虚拟内存区域。
在每个虚拟内存区域 VMA
中又通过 struct vm_area_struct
中的 vm_mm
指针指向了所属的虚拟内存空间 mm_struct
。
而扫描链表找到与特定地址关联的区域,在有大量区域时是非常低效的操作(数据密集型的应用程序就是这样)。因此vm_area_struct
的各个实例还通过红黑树管理,可以显著加快扫描速度。每个 VMA
区域都是红黑树中的一个节点,通过 struct vm_area_struct
结构中的 vm_rb
将自己连接到红黑树中。
而红黑树中的根节点存储在内存描述符 struct mm_struct
中的 mm_rb
中:
struct mm_struct {
struct rb_root mm_rb;
}
再来看看vm_page_prot
和 vm_flags
,这两字段都是用来标记 vm_area_struct
结构表示的这块虚拟内存区域的访问权限和行为规范。
-
vm_page_prot
偏向于定义底层内存管理架构中页这一级别的访问控制权限,它可以直接应用在底层页表中,它是一个具体的概念。虚拟内存区域VMA
由许多的虚拟页 (page
) 组成,每个虚拟页需要经过页表的转换才能找到对应的物理页面。页表中关于内存页的访问权限就是由vm_page_prot
决定的。 -
vm_flags
则偏向于定于整个虚拟内存区域的访问权限以及行为规范。描述的是虚拟内存区域中的整体信息,而不是虚拟内存区域中具体的某个独立页面。它是一个抽象的概念。可以通过vma->vm_page_prot = vm_get_page_prot(vma->vm_flags)
实现到具体页面访问权限vm_page_prot
的转换。下面列举一些常用到的
vm_flags
方便大家有一个直观的感受:VM_READ
,VM_WRITE
,VM_EXEC
定义了虚拟内存区域是否可以被读取,写入,执行等权限;VM_SHARD
用于指定这块虚拟内存区域映射的物理内存是否可以在多进程之间共享,以便完成进程间通讯;VM_IO
的设置表示这块虚拟内存区域可以映射至设备IO
空间中。通常在设备驱动程序执行mmap
进行IO
空间映射时才会被设置;VM_RESERVED
的设置表示在内存紧张的时候,这块虚拟内存区域非常重要,不能被换出到磁盘中;VM_SEQ_READ
的设置用来暗示内核,应用程序对这块虚拟内存区域的读取是会采用顺序读的方式进行,内核会根据实际情况决定预读后续的内存页数,以便加快下次顺序访问速度;VM_RAND_READ
的设置会暗示内核,应用程序会对这块虚拟内存区域进行随机读取,内核则会根据实际情况减少预读的内存页数甚至停止预读。
vm_flags 访问权限 VM_READ 可读 VM_WRITE 可写 VM_EXEC 可执行 VM_SHARD 可多进程之间共享 VM_IO 可映射至设备 IO 空间 VM_RESERVED 内存区域不可被换出 VM_SEQ_READ 内存区域可能被顺序访问 VM_RAND_READ 内存区域可能被随机访问
最后用图梳理下VM
的基本模型概念:
在 Linux 6.1
版本开始,VMA
布局使用了 maple tree
(它是一颗B
树)来替代红黑树,也删除了vma
之间的额外的双向链表。所呈现的VMA
结构就不会像我们上图所示的这样了。
而maple tree
的设计中也是按照 lockless
方式的要求来的,使用 read-copy-update (RCU)
方式。一个进程的每个 VMA
块都会链接到mm_struct
中的maple_tree
。
内核虚拟内存空间
32 位体系内核虚拟内存布局
直接映射区
所谓的直接映射区,就是这一块空间是连续的,地址范围为 3G
– 3G + 896m
,之所以这块 896M
大小的区域称为直接映射区或者线性映射区,是因为这块连续的虚拟内存地址会映射到 0 - 896M
这块连续的物理内存上。
也就是这块区域中的虚拟内存地址直接减去 0xC000 0000 (3G) 就得到了物理内存地址。
内核态虚拟内存空间的前 896M 区域是直接映射到物理内存中的前 896M 区域中的,直接映射区中的映射关系是一比一映射。映射关系是固定的不会改变。
在这段 896M
大小的物理内存中,前 1M
已经在系统启动的时候被系统占用,1M
之后的物理内存存放的是内核代码段,数据段,BSS
段(这些信息起初存放在 ELF
格式的二进制文件中,在系统启动的时候被加载进内存)。具体的物理内存布局可以查看:
[root@VM-16-10-centos ~]# cat /proc/iomem
......
00001000-0009fbff : System RAM
0009fc00-0009ffff : reserved
000a0000-000bffff : PCI Bus 0000:00
000c0000-000c95ff : Video ROM
000c9800-000ca1ff : Adapter ROM
000ca800-000ccbff : Adapter ROM
000f0000-000fffff : reserved
000f0000-000fffff : System ROM
00100000-bffddfff : System RAM
25000000-34ffffff : Crash kernel
bb000000-bb7a1279 : Kernel code
bb7a127a-bbd6c2bf : Kernel data
bbf65000-bc272fff : Kernel bss
bffde000-bfffffff : reserved
......
当我们使用 fork
系统调用创建进程的时候,内核会创建一系列进程相关的描述符,比如之前提到的进程的核心数据结构 task_struct
,进程的内存空间描述符 mm_struct
,以及虚拟内存区域描述符 vm_area_struct
等。这些进程相关的数据结构也会存放在物理内存前 896M
的这段区域中,当然也会被直接映射至内核态虚拟内存空间中的 3G
– 3G + 896m
这段直接映射区域中。
而在直接映射区域中,设置了前 16M
专门让内核用来为 DMA
分配内存,这块 16M
大小的内存区域我们称之为 ZONE_DMA
。而直接映射区中剩下的部分也就是从 16M
到 896M
(不包含 896M
)这段区域,我们称之为 ZONE_NORMAL
。(注意这里的 ZONE_DMA
和 ZONE_NORMAL
是内核针对物理内存区域的划分)
而什么是DMA
呢?DMA
其实就是直接存储器访问,是单片机的一个外设,它的主要功能是用来搬数据,但是不需要占用CPU
,即在传输数据的时候,CPU
可以干其他的事情,好像是多线程一样。在 X86
体系结构下,ISA
总线的 DMA
(直接内存存取)控制器,只能对内存的前16M
进行寻址,这就导致了 ISA
设备不能在整个 32
位地址空间中执行 DMA
,只能使用物理内存的前 16M
进行 DMA
操作。
高端内存 - HIGH_MEMORY
物理内存 896M
以上的区域被内核划分为 ZONE_HIGHMEM
区域(如上图已经标记出来),我们称之为高端内存。该区域是x86-32
下特有的。
我们假设我们的物理内存假设为 4G
,高端内存区域为 4G - 896M = 3200M
,而32
系统下内核虚拟空间只有1G
大小,无法管理全部的内存空间,那么这块 3200M
大小的 ZONE_HIGHMEM
区域该如何映射到内核虚拟内存空间中呢?
这样一来物理内存中的 ZONE_HIGHMEM
区域就只能采用动态映射的方式映射到 128M
大小的内核虚拟内存空间中。
从0xF8000000
~ 0xFFFFFFFF
地址空间范围内找一段相应大小空闲的逻辑地址空间,借用一会。借用这段逻辑地址空间,建立映射到想访问的那段物理内存(即填充内核PTE
页面表),临时用一会,用完后归还。这样别人也可以借用这段地址空间访问其他物理内存,实现了使用有限的地址空间,访问所有所有物理内存。
为了防止访问越界,针对内核故障的保护,系统会在 high_memory
和VMALLOC
区域留8M
,因此假如理论上vmalloc size
有300M
,实际可用的也是只有292M
。
// linux v6.5 path: include/asm/pgtable_32_areas.h
#define VMALLOC_OFFSET (8*1024*1024)
再来看看VMALLOC
区域,虚拟内存中连续、但物理内存中不连续的内存区,可以在vmalloc
区域分配。该机制通常用于用户过程,内核自身会试图尽力避免非连续的物理地址。内核通常会成功,因为大部分大的内存块都在启动时分配给内核,那时内存的碎片尚不严重。但在已经运行了很长时间的系统上,在内核需要物理内存时,就可能出现可用空间不连续的情况。此类情况,主要出现在动态加载模块时。
vmalloc
区域的起始地址,取决于在直接映射物理内存时,使用了多少虚拟地址空间内存(因此也依赖于上文的high_memory
变量)。vmalloc
区域从可被VMALLOC_OFFSET
整除的地址开始。VMALLOC_START
到 VMALLOC_END
之间称为内核动态映射空间,也即内核想像用户态进程一样 malloc
申请内存,在内核里面可以使用 vmalloc
。假设物理内存里面,896M
到 1.5G
之间已经被用户态进程占用了,并且映射关系放在了进程的页表中,内核 vmalloc
的时候,只能从分配物理内存 1.5G
开始,就需要使用这一段的虚拟地址进行映射,映射关系放在专门给内核自己用的页表里面。
vmalloc
区域代码:
// linux v6.5 path: include/asm/pgtable_32_areas.h
#define VMALLOC_START ((unsigned long)high_memory + VMALLOC_OFFSET)
#ifdef CONFIG_HIGHMEM
# define VMALLOC_END (PKMAP_BASE - 2 * PAGE_SIZE)
#else
# define VMALLOC_END (LDT_BASE_ADDR - 2 * PAGE_SIZE)
#endif
vmalloc
区域图示:
永久映射区
内核从 PKMAP_BASE
到 FIXADDR_START
这段空间用于映射高端内存,在这段虚拟地址空间中允许建立与物理高端内存(ZONE_HIGHMEM
)的长期映射关系。
通常情况下,这个空间是 4M
大小,因此仅仅需要一个页表即可,内核通过来 pkmap_page_table
寻找这个页表。通过 kmap()
,可以把一个 page
映射到这个空间来。由于这个空间是 4M
大小,最多能同时映射 1024
个 page
。因此,对于不使用的的 page
,及应该时从这个空间释放掉(也就是解除映射关系),通过 kunmap()
,可以把一个 page
对应的线性地址从这个空间释放出来。
// linux v6.5 path: include/asm/pgtable_32_areas.h
#define LAST_PKMAP 1024 //表示永久映射区可以映射的页数限制
#define PKMAP_BASE \
((LDT_BASE_ADDR - PAGE_SIZE) & PMD_MASK)
示意图如下:
固定映射区
FIXADDR_START
到 FIXADDR_TOP
(0xFFFF F000
) 的空间,称为固定映射区域,主要用于满足特殊需求。
FIXADDR_START
和 FIXADDR_TOP
定义代码如下:
// linux v6.5 path: include/asm/fixmap.h
extern unsigned long __FIXADDR_TOP;
#define FIXADDR_TOP ((unsigned long)__FIXADDR_TOP)
#else
#define FIXADDR_TOP (round_up(VSYSCALL_ADDR + PAGE_SIZE, 1<<PMD_SHIFT) - \
PAGE_SIZE)
#endif
#define FIXADDR_START (FIXADDR_TOP - FIXADDR_SIZE)
固定映射是与物理地址空间中的固定页关联的虚拟地址空间项,但具体关联的页帧可以自由选择。它与通过固定公式与物理内存关联的直接映射页相反,虚拟固定映射地址与物理内存位置之间的关联可以自行定义,关联建立后内核总是会注意到的。
最后一个内存段由固定映射占据。这些地址指向物理内存中的随机位置。相对于内核空间起始处的线性映射,在该映射内部的虚拟地址和物理地址之间的关联不是预设的,而可以自由定义,但定义后不能改变。固定映射区域会一直延伸到虚拟地址空间顶端。
固定映射地址的优点在于,在编译时对此类地址的处理类似于常数,内核一启动即为其分配了物理地址。此类地址的解引用比普通指针要快速。内核会确保在上下文切换期间,对应于固定映射的页表项不会从TLB
刷出,因此在访问固定映射的内存时,总是通过TLB
高速缓存取得对应的物理地址。
对每个固定映射地址都会创建一个常数,加入到fixed_addresses
枚举值列表中,内核提供了fix_to_virt
函数,用于计算固定映射常数的虚拟地址。
临时映射区
临时映射区(Temporary Mapping Area
)通常指的是内核中的一块用于临时映射物理内存页面的区域。这个区域的主要作用是在内核中进行页表管理、物理内存管理和一些高级操作时,提供一个暂时的、虚拟地址映射到物理内存的地方。
内核中不能直接进行拷贝,因为此时从 page cache
中取出的缓存页 page
是物理地址,而在内核中是不能够直接操作物理地址的,只能操作虚拟地址。
所以就需要使用 kmap_atomic
将缓存页临时映射到内核空间的一段虚拟地址上,这段虚拟地址就位于内核虚拟内存空间中的临时映射区上,然后将用户空间缓存区 DirectByteBuffer
中的待写入数据通过这段映射的虚拟地址拷贝到 page cache
中的相应缓存页中。这时文件的写入操作就已经完成了。
由于是临时映射,所以在拷贝完成之后,调用 kunmap_atomic
将这段映射再解除掉。
void *kmap_atomic(struct *page, enum kmtype type);
void kunmap_atomic(void *kvaddr, enum km_type type);
临时映射区示意图如下:
至此,32
位系统下总体内核空间以及用户空间已经基本点到,用图表示下32
位体系结构 Linux
的整个虚拟内存空间的布局:
64 位体系内核虚拟内存布局
因为32
位系统下,内核内存空间有限,但是到了64
位系统,内核虚拟内存空间的布局和管理就变得容易多了,因为进程虚拟内存空间和内核虚拟内存空间各自占用 128T
的虚拟内存,实在是太大了,我们可以在这里边随意翱翔。
因此在 64
位体系下的内核虚拟内存空间与物理内存的映射就变得非常简单,由于虚拟内存空间足够的大,即便是内核要访问全部的物理内存,直接映射就可以了,不在需要用到高端内存那种动态映射方式。
内核在通过 TASK_SIZE
将进程虚拟内存空间和内核虚拟内存空间分割开来。代码定位如下:
// linux v6.5 path: include/asm/page_64_types.h
#define __VIRTUAL_MASK_SHIFT 47
#define task_size_max() ((_AC(1,UL) << __VIRTUAL_MASK_SHIFT) - PAGE_SIZE)
#define TASK_SIZE_MAX task_size_max()
#define IA32_PAGE_OFFSET ((current->personality & ADDR_LIMIT_3GB) ? \
0xc0000000 : 0xFFFFe000
#define TASK_SIZE (test_thread_flag(TIF_ADDR32) ? \
IA32_PAGE_OFFSET : TASK_SIZE_MAX)
从上述代码中,可以得出64
位系统中的 TASK_SIZE
为 0x00007FFFFFFFF000
。
在 64
位系统中,只使用了其中的低 48
位来表示虚拟内存地址。其中用户态虚拟内存空间为低 128 T
,虚拟内存地址范围为:0x0000 0000 0000 0000
- 0x0000 7FFF FFFF F000
。
内核态虚拟内存空间为高 128 T
,虚拟内存地址范围为:0xFFFF 8000 0000 0000
- 0xFFFF FFFF FFFF FFFF
。如图:
此处我们主要关注 0xFFFF 8000 0000 0000
- 0xFFFF FFFF FFFF FFFF
这段内核虚拟内存空间的布局情况。
64
位内核虚拟内存空间从 0xFFFF 8000 0000 0000
开始到 0xFFFF 8800 0000 0000
这段地址空间是一个 8T
大小的内存空洞区域。
紧着着 8T
大小的内存空洞下一个区域就是 64T
大小的直接映射区。这个区域中的虚拟内存地址减去 PAGE_OFFSET
就直接得到了物理内存地址。
然后是VMALLOC_START
到 VMALLOC_END
的这段区域是 32T
大小的 vmalloc
映射区,这里类似用户空间中的堆,内核在这里使用 vmalloc
系统调用申请内存。在vmalloc
映射区上下各有1T
的保留区。
VMALLOC_START
和 VMALLOC_END
变量定义:
// linux v6.5 path: include/asm/pgtable_64_types.h
#define __VMALLOC_BASE_L4 0xffffc90000000000UL
# define VMALLOC_START __VMALLOC_BASE_L4
#define VMALLOC_SIZE_TB_L4 32UL
# define VMALLOC_SIZE_TB VMALLOC_SIZE_TB_L4
#define VMEMORY_END (VMALLOC_START + (VMALLOC_SIZE_TB << 40) - 1)
#define VMALLOC_END VMEMORY_END
从 VMEMMAP_START
开始是 1T
大小的虚拟内存映射区,用于存放物理页面的描述符 struct page
结构用来表示物理内存页。VMEMMAP_START
变量定义:
// linux v6.5 path: include/asm/pgtable_64_types.h
#define __VMEMMAP_BASE_L4 0xffffea0000000000UL
# define VMEMMAP_START __VMEMMAP_BASE_L4
从 __START_KERNEL_map
开始是大小为 512M
的区域用于存放内核代码段、全局变量、BSS
等。这里对应到物理内存开始的位置,减去 __START_KERNEL_map
就能得到物理内存的地址。这里和直接映射区有点像,但是不矛盾,因为直接映射区之前有 8T
的空洞区域,早就过了内核代码在物理内存中加载的位置。
__START_KERNEL_map
变量定义:
// linux v6.5 path: include/asm/page_64_types.h
#define __START_KERNEL_map _AC(0xffffffff80000000, UL)
到现在为止,整个内核虚拟内存空间在 64
位体系下的布局基本清晰,用图来表示下:
好了,虚拟内存机制的内容就先讲到这里了。后续对内存的分页以及映射进行相关介绍。
参考资料:
bin的技术小屋 https://www.cnblogs.com/binlovetech/p/16824522.html
Go 语言设计与实现https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator
Linux内核 https://zhuanlan.zhihu.com/p/451405962
Haonan https://zhuanlan.zhihu.com/p/404813126
Jormungand https://blog.csdn.net/Jormungand_V/article/details/119382609
驿站Eventually https://blog.csdn.net/z97i714/article/details/109902209?spm=1001.2101.3001.6650.14&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ESEARCHCACHE%7Edefault-14-109902209-blog-119382609.pc_relevant_priorsearch_v1&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ESEARCHCACHE%7Edefault-14-109902209-blog-119382609.pc_relevant_priorsearch_v1&utm_relevant_index=23
eleven_yw https://www.cnblogs.com/yaoxiaowen/p/7805964.html
凌桓丶 https://oreki.blog.csdn.net/article/details/111084633?spm=1001.2101.3001.6650.10&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromBaidu%7Edefault-10-111084633-blog-116699177.pc_relevant_default&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromBaidu%7Edefault-10-111084633-blog-116699177.pc_relevant_default&utm_relevant_index=16
https://zhuanlan.zhihu.com/p/488123510