1. 深入理解 Linux 虚拟内存管理⚓
1.1 Linux 进程虚拟内存空间的布局⚓
查看某个进程的实际虚拟内存布局: cat /proc/pid/maps
或者 pmap pid
。
1.1.1 32 位机器⚓
在 32 位机器上,指针的寻址范围为 2^32,所能表达的虚拟内存空间为 4 GB。所以在 32 位机器上进程的虚拟内存地址范围为:0x0000 0000 - 0xFFFF FFFF
。
- 内核态虚拟内存空间为 1 GB,虚拟内存地址范围为:
0xC000 0000 - 0xFFFF FFFF
。进程虽然可以看到这段内核空间地址,但是就是不能访问。 - 用户态虚拟内存空间为 3 GB,虚拟内存地址范围为:
0x0000 0000 - 0xC000 0000
。注意图中终止地址是错的!0xC000 0000 = 12*16^7 = 3GB
- 不可访问的保留区,
0x0000 0000 - 0x0804 8000
。比如在 C 语言中我们通常会将一些无效的指针设置为 NULL,指向这块不允许访问的地址。
保留区的上边就是代码段和数据段,它们是从程序的二进制文件中直接加载进内存中的,BSS 段中的数据也存在于二进制文件中,因为内核知道这些数据是没有初值的,所以在二进制文件中只会记录 BSS 段的大小,在加载进内存时会生成一段 0 填充的内存空间。
内核中使用 start_brk
标识堆的起始位置,brk
标识堆当前的结束位置。当堆申请新的内存空间时,只需要将 brk 指针增加对应的大小,回收地址时减少对应的大小即可。比如当我们通过 malloc
向内核申请很小的一块内存时(128K 之内),就是通过改变 brk 位置实现的。
文件映射与匿名映射区域。进程运行时所依赖的动态链接库中的代码段,数据段,BSS 段就加载在这里。还有我们调用 mmap
映射出来的一段虚拟内存空间也保存在这个区域。注意:在文件映射与匿名映射区的地址增长方向是从高地址向低地址增长。
栈空间会保存函数运行过程所需要的局部变量以及函数参数等函数调用信息。栈空间中的地址增长方向是从高地址向低地址增长。
在内核中使用 start_stack
标识栈的起始位置,RSP
寄存器中保存栈顶指针 stack pointer
,RBP
寄存器中保存的是栈基地址。
Note
RBP 用于访问函数内的局部变量和参数,而 RSP 用于栈空间的分配和释放。它们在程序执行期间动态地变化,确保正确的函数调用和栈帧管理。 - RBP(Base Pointer Register)寄存器是用来指向当前栈帧的基址的寄存器。它通常用于寻找局部变量和函数参数的地址。RBP 指向当前函数栈帧的底部,它的值不随着函数的嵌套而改变,因此可以被用作一个稳定的参考点来访问函数内的局部变量。 - RSP(Stack Pointer Register)寄存器是用来指向当前栈顶的指针。它在函数调用和返回时用于分配和释放栈空间。当函数被调用时,RSP 会被减小相应的值,以为新的栈帧腾出空间。当函数返回时,RSP 会被恢复到先前的值,释放掉该函数的栈帧空间。
1.1.2 64 位机器⚓
2^64,所能表达的虚拟内存空间为 16 EB 。
但在目前的 64 位系统下只使用了 48 位
来描述虚拟内存空间,寻址范围为 2^48 ,所能表达的虚拟内存空间为 256TB
。
- 内核态虚拟内存空间 128 TB,虚拟内存地址范围为:
0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF
。0x0000 7FFF FFFF F000 = 128 TB
- 用户态虚拟内存空间 128 TB,虚拟内存地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 。并不是严格的 128 TB大小。
canonical address 空洞
,用户态虚拟内存空间与内核态虚拟内存空间之间形成的一段0x0000 7FFF FFFF F000 - 0xFFFF 8000 0000 0000
地址空洞。
在低 128T 的用户态地址空间中,所有虚拟内存地址的高 16 位全部为 0 。在高 128T 的内核态虚拟内存空间中,所有虚拟内存地址的高 16 位全部为 1 。这个高 16 位的空闲地址被称为 canonical 。
如果虚拟内存地址中的高 16 位全部为 0 (表示用户空间虚拟内存地址)或者全部为 1 (表示内核空间虚拟内存地址),这种地址的形式我们叫做 canonical form
,对应的地址我们称作 canonical address
。
那么对于 canonical address 空洞
: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 分配给内核态虚拟内存空间。
1.2 进程虚拟内存空间的管理⚓
进程在内核中的描述符 task_struct 结构:
struct task_struct {
// 进程id
pid_t pid;
// 用于标识线程所属的进程 pid
pid_t tgid;
// 进程打开的文件信息
struct files_struct *files;
// 内存描述符表示进程虚拟地址空间
struct mm_struct *mm;
.......... 省略 .......
}
mm_struct
这个结构体中包含了进程虚拟内存空间的全部信息。每个进程都有唯一的 mm_struct 结构体,也就是前边提到的每个进程的虚拟地址空间都是独立,互不干扰的。
当我们调用 fork()
函数创建进程的时候,表示进程地址空间的 mm_struct
结构会随着进程描述符 task_struct
的创建而创建。
- 通过
fork()
函数创建出的子进程,它的虚拟内存空间以及相关页表相当于父进程虚拟内存空间的一份拷贝。 - 通过
vfork
或者clone
系统调用创建出的子进程,会将父进程的虚拟内存空间以及相关页表直接赋值给子进程。父进程和子进程的虚拟内存空间是共享的。这样子进程就变成了我们熟悉的线程。
是否共享地址空间几乎是进程和线程之间的本质区别。Linux 内核并不区别对待它们,线程对于内核来说仅仅是一个共享特定资源的进程而已。
内核线程和用户态线程的区别就是内核线程没有相关的内存描述符 mm_struct
,内核线程对应的 task_struct 结构中的 mm 域指向 Null,所以内核线程之间调度是不涉及地址空间切换的。
1.2.1 内核如何划分用户态和内核态虚拟内存空间⚓
task_size
定义了用户态地址空间与内核态地址空间之间的分界线:
struct mm_struct {
unsigned long task_size; /* size of task vm space */
}
对于 32 位系统,内核在 /arch/x86/include/asm/page_32_types.h
文件中关于 TASK_SIZE
的定义:
/*
* User space process size: 3GB (default).
*/
#define TASK_SIZE __PAGE_OFFSET
__PAGE_OFFSET
的值在 32 位系统下为 0xC000 0000
:
对于64 位系统,内核在 /arch/x86/include/asm/page_64_types.h
文件中关于 TASK_SIZE 的定义:
#define TASK_SIZE (test_thread_flag(TIF_ADDR32) ? \
IA32_PAGE_OFFSET : TASK_SIZE_MAX)
#define TASK_SIZE_MAX task_size_max()
#define task_size_max() ((_AC(1,UL) << __VIRTUAL_MASK_SHIFT) - PAGE_SIZE)
#define __VIRTUAL_MASK_SHIFT 47
task_size_max()
的计算逻辑中 1 左移 47 位得到的地址是 0x0000800000000000
,然后减去一个 PAGE_SIZE (默认为 4K)
的大小0x1000
,就是 0x00007FFFFFFFF000
,共 128T。
可以看出,64 位虚拟内存空间的布局是和物理内存页 page 的大小有关的。
PAGE_SIZE 定义在 /arch/x86/include/asm/page_types.h
文件中:
/* PAGE_SHIFT determines the page size */
#define PAGE_SHIFT 12
#define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT)
1.2.2 内核如何布局进程虚拟内存空间⚓
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 */
struct vm_area_struct *mmap; /* list of VMAs, VMAs 链表的头节点 */
struct rb_root mm_rb; // VMAs 红黑树的根节点
...... 省略 ........
}
start_code
和end_code
是代码段的起始和结束位置。程序编译后的二进制文件中的机器码被加载进内存之后就放在这里。start_data
和end_data
是数据段的起始和结束位置。二进制文件中存放的全局变量和静态变量被加载进内存后存放在这里- BSS 段大小是固定的,存放未被初始化的全局变量和静态变量。这些变量在被加载到内存时会生成一段 0 填充的内存区域(BSS 段)。
start_brk
是堆的起始位置,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
是栈中所映射的内存页数目,这些变量均是表示进程虚拟内存空间中的虚拟内存使用情况。
1.3 进程虚拟内存区域的管理⚓
代码段,数据段,堆,内存映射区,栈 这些虚拟内存区域在内核中使用vm_area_struct
这个结构体描述,VMA(virtual memory area)
:
struct vm_area_struct {
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address
within 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_SIZE
units */
void * vm_private_data; /* was vm_pte (shared mem) */
/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;
}
1.3.1 定义虚拟内存区域的访问权限和行为规范⚓
vm_page_prot
和 vm_flags
都是用来标记 vm_area_struct 结构的,表示了这块虚拟内存区域的访问权限和行为规范。
-
虚拟内存区域 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 空间
- VM_RESERVED: 内存区域不可被换出
- VM_SEQ_READ: 内存区域可能被顺序访问。暗示内核可以预读
- VM_RAND_READ: 内存区域可能被随机访问。暗示内核减少甚至停止预读
比如代码段这块内存区域的权限是可读,可执行,但是不可写。数据段具有可读可写的权限但是不可执行。堆则具有可读可写,可执行的权限(Java 中的字节码存储在堆中,所以需要可执行权限),栈一般是可读可写的权限,一般很少有可执行权限。而文件映射与匿名映射区存放了共享链接库,所以也需要可执行的权限。
对于虚拟内存区域 VMA 设置的访问权限也会全部复制到区域中包含的内存页中。
1.3.2 关联内存映射中的映射关系⚓
接下来的三个属性 anon_vma,vm_file,vm_pgoff
和虚拟内存映射相关,虚拟内存区域可以映射到物理内存上,也可以映射到文件中,映射到物理内存上我们称之为匿名映射,映射到文件中我们称之为文件映射。
匿名映射的情况
当调用 malloc
申请内存时,如果申请的是小块内存(低于 128K)则会使用 do_brk()
系统调用通过调整堆中的 brk 指针大小来增加或者回收堆内存。
如果申请的是比较大块的内存(超过 128K)时,则会调用 mmap
在虚拟内存空间中的文件映射与匿名映射区创建出一块 VMA
内存区域(这里是匿名映射)。这块匿名映射区域就用 struct anon_vma
结构表示。
文件映射的情况
当调用 mmap 进行文件映射时,vm_file
属性就用来关联被映射的文件。这样一来虚拟内存区域就与映射文件关联了起来。vm_pgoff
则表示映射进虚拟内存中的文件内容在文件中的偏移。
当然在匿名映射中,vm_area_struct
结构中的 vm_file 就为 null,vm_pgoff 也就没有了意义。
vm_private_data
则用于存储 VMA 中的私有数据。
1.3.3 针对虚拟内存区域的相关操作⚓
vm_ops
用来指向针对虚拟内存区域 VMA 的相关操作的函数指针:
struct vm_operations_struct {
void (*open)(struct vm_area_struct * area);
void (*close)(struct vm_area_struct * area);
vm_fault_t (*fault)(struct vm_fault *vmf);
vm_fault_t (*page_mkwrite)(struct vm_fault *vmf);
..... 省略 .......
}
- 当指定的虚拟内存区域被加入到进程虚拟内存空间中时,
open
函数会被调用 - 当虚拟内存区域 VMA 从进程虚拟内存空间中被删除时,
close
函数会被调用 - 当进程访问虚拟内存时,访问的页面不在物理内存中,可能是未分配物理内存也可能是被置换到磁盘中,这时就会产生缺页异常,
fault
函数就会被调用。 - 当一个只读的页面将要变为可写时,
page_mkwrite
函数会被调用。
1.3.4 虚拟内存区域在内核中是如何被组织的⚓
struct vm_area_struct
结构中,与组织结构相关的一些属性:
struct vm_area_struct {
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. */
……
内核中关于这些虚拟内存区域的操作除了遍历之外还有许多需要根据特定虚拟内存地址在虚拟内存空间中查找特定的虚拟内存区域。
尤其在进程虚拟内存空间中包含的内存区域 VMA 比较多的情况下,使用红黑树查找特定虚拟内存区域的时间复杂度是 O( logN ) ,可以显著减少查找所需的时间。
所以在内核中,同样的内存区域 vm_area_struct 会有两种组织形式:
- 一种是双向链表用于高效的遍历
- 另一种就是红黑树用于高效的查找。
1.3.4.1 双向链表⚓
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
。
1.3.4.2 红黑树⚓
每个 VMA 区域都是红黑树中的一个节点,通过 struct vm_area_struct
结构中的 vm_rb
将自己连接到红黑树中。而红黑树中的根节点存储在内存描述符 struct mm_struct
中的 mm_rb
中。
1.4 二进制文件如何映射到虚拟内存空间⚓
程序代码编译之后会生成一个 ELF 格式的二进制文件,这个二进制文件中包含了程序运行时所需要的元信息,比如程序的机器码,程序中的全局变量以及静态变量等。
ELF 格式的二进制文件中的布局和我们前边讲的虚拟内存空间中的布局类似,也是一段一段的,每一段包含了不同的元数据。
磁盘文件中的段我们叫做 Section
,内存中的段我们叫做 Segment
,也就是内存区域。
Section 会在进程运行之前加载到内存中并映射到 Segment。通常是多个 Section 映射到一个 Segment。
比如磁盘文件中的 .text
,.rodata
等一些只读的 Section,会被映射到内存的一个只读可执行的 Segment 里(代码段)。而 .data
,.bss
等一些可读写的 Section,则会被映射到内存的一个具有读写权限的 Segment 里(数据段,BSS 段)。
内核使用 load_elf_binary
加载 Section 并映射进虚拟内存空间。
这个函数的作用很大:
- 加载内核
- 启动第一个用户态进程 init
- fork 完了以后,调用 exec 运行一个二进制程序
当 exec 运行一个二进制程序的时候,除了解析 ELF 的格式之外,另外一个重要的事情就是建立上述提到的内存映射:
static int load_elf_binary(struct linux_binprm *bprm)
{
...... 省略 ........
// 设置虚拟内存空间中的内存映射区域起始地址 mmap_base
setup_new_exec(bprm);
...... 省略 ........
// 创建并初始化栈对应的 vm_area_struct 结构。
// 设置 mm->start_stack 就是栈的起始地址也就是栈底,并将 mm->arg_start 是指向栈底的。
retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
executable_stack);
...... 省略 ........
// 将二进制文件中的代码部分映射到虚拟内存空间中
error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
elf_prot, elf_flags, total_size);
...... 省略 ........
// 创建并初始化堆对应的的 vm_area_struct 结构
// 设置 current->mm->start_brk = current->mm->brk,设置堆的起始地址 start_brk,结束地址 brk。 起初两者相等表示堆是空的
retval = set_brk(elf_bss, elf_brk, bss_prot);
...... 省略 ........
// 将进程依赖的动态链接库 .so 文件映射到虚拟内存空间中的内存映射区域
elf_entry = load_elf_interp(&loc->interp_elf_ex,
interpreter,
&interp_map_addr,
load_bias, interp_elf_phdata);
...... 省略 ........
// 初始化内存描述符 mm_struct
current->mm->end_code = end_code;
current->mm->start_code = start_code;
current->mm->start_data = start_data;
current->mm->end_data = end_data;
current->mm->start_stack = bprm->p;
...... 省略 ........
}
1.5 内核的虚拟内存空间⚓
不同进程之间的虚拟内存空间是相互隔离的,彼此之间相互独立,相互感知不到其他进程的存在。使得进程以为自己拥有所有的内存资源。
而内核态虚拟内存空间是所有进程共享的,不同进程进入内核态之后看到的虚拟内存空间全部是一样的。
Warning
进程进入内核态之后使用的仍然是虚拟内存地址,只不过在内核中使用的虚拟内存地址被限制在了内核态虚拟内存空间范围中。
1.5.1 32 位体系内核虚拟内存空间布局⚓
32 位下,内核态虚拟内存空间为 1 GB,虚拟内存地址范围为:0xC000 000 - 0xFFFF FFFF
。
内核虚拟内存空间在 32 位体系下只有 1G 大小,实在太小了,因此需要精细化的管理,于是按照功能分类划分除了很多内核虚拟内存区域。
1.5.1.1 直接映射区⚓
内核虚拟内存空间中,位于最前边有一块 896M
大小的区域,我们称之为直接映射区或者线性映射区,地址范围为 3G - 3G + 896M
。
这块 896M 大小的虚拟内存会直接映射到 0 - 896M
这块 896M 大小的物理内存上,这块区域中的虚拟内存地址直接减去 0xC000 0000 (3G)
就得到了物理内存地址。所以我们称这块区域为直接映射区。
Note
虽然这块区域中的虚拟地址是直接映射到物理地址上,但是内核在访问这段区域的时候还是走的虚拟内存地址,内核也会为这块空间建立映射页表。
在这段 896M 大小的物理内存中,前 1M 已经在系统启动的时候被系统占用,1M 之后的物理内存存放的是内核代码段,数据段,BSS 段(这些信息起初存放在 ELF格式的二进制文件中,在系统启动的时候被加载进内存)。
可以通过 cat /proc/iomem
命令查看具体物理内存布局情况。
1.5.1.1.1 存了啥⚓
当使用 fork
系统调用创建进程的时候,内核会创建一系列进程相关的描述符,比如之前提到的进程的核心数据结构 task_struct
,进程的内存空间描述符 mm_struct
,以及虚拟内存区域描述符 vm_area_struct
等。
这些进程相关的数据结构会被存放到这 896M 的物理内存和内核态虚拟内存空间中。
当进程被创建完毕之后,在内核运行的过程中,会涉及内核栈的分配,内核会为每个进程分配一个固定大小的内核栈(一般是两个页大小,依赖具体的体系结构),每个进程的整个调用链必须放在自己的内核栈中,内核栈也是分配在直接映射区。
与进程用户空间中的栈不同的是,内核栈容量小而且是固定的,用户空间中的栈容量大而且可以动态扩展。内核栈的溢出危害非常巨大,它会直接悄无声息的覆盖相邻内存区域中的数据,破坏数据。
1.5.1.1.2 DMA⚓
内核对物理内存的管理都是以页为最小单位来管理的,每页默认 4K 大小,理想状况下任何种类的数据页都可以存放在任何页框中,没有什么限制。但是实际的计算机体系结构受到硬件方面的限制制约,间接导致限制了页框的使用方式。
比如在 X86 体系结构下,ISA 总线的 DMA (直接内存存取)控制器
,只能对内存的前 16M 进行寻址,这就导致了 ISA 设备不能在整个 32 位地址空间中执行 DMA,只能使用物理内存的前 16M 进行 DMA 操作。
因此直接映射区的前 16M 专门让内核用来为 DMA 分配内存,这块 16M
大小的内存区域我们称之为 ZONE_DMA
。
而直接映射区中剩下的部分也就是从 16M 到 896M(不包含 896M)
这段区域,我们称之为 ZONE_NORMAL
。从字面意义上我们可以了解到,这块区域包含的就是正常的页框(使用没有任何限制)。
用于 DMA 的内存必须从 ZONE_DMA
区域中分配。
1.5.1.2 ZONE_HIGHMEM 高端内存⚓
物理内存 896M 以上的区域被内核划分为 ZONE_HIGHMEM
区域,我们称之为高端内存
。
- 物理内存假设为 4G,高端内存区域为
4G - 896M = 3200M
。 - 内核剩余可用的虚拟内存空间为
1G - 896M = 128M
。
这样一来物理内存中的 ZONE_HIGHMEM 区域就只能采用动态映射的方式映射到 128M 大小的内核虚拟内存空间中,也就是说只能动态的一部分一部分的分批映射,先映射正在使用的这部分,使用完毕解除映射,接着映射其他部分。
内核虚拟内存空间中的 3G + 896M 这块地址在内核中定义为 high_memory
,high_memory 往上有一段 8M
大小的内存空洞
。空洞范围为:high_memory 到 VMALLOC_START
。
VMALLOC_START 定义在内核源码 /arch/x86/include/asm/pgtable_32_areas.h
文件中:
#define VMALLOC_OFFSET (8 * 1024 * 1024)
#define VMALLOC_START ((unsigned long)high_memory + VMALLOC_OFFSET)
1.5.1.3 vmalloc 动态映射区⚓
接下来 VMALLOC_START 到 VMALLOC_END
之间的这块区域成为动态映射区
。采用动态映射的方式映射物理内存中的高端内存。
#ifdef CONFIG_HIGHMEM
# define VMALLOC_END (PKMAP_BASE - 2 * PAGE_SIZE)
#else
# define VMALLOC_END (LDT_BASE_ADDR - 2 * PAGE_SIZE)
#endif
和用户态进程使用 malloc 申请内存一样,在这块动态映射区内核是使用 vmalloc
进行内存分配。由于之前介绍的动态映射的原因,vmalloc 分配的内存在虚拟内存上是连续的,但是物理内存是不连续的。通过页表来建立物理内存与虚拟内存之间的映射关系,从而可以将不连续的物理内存映射到连续的虚拟内存上。
1.5.1.4 永久映射区⚓
在 PKMAP_BASE 到 FIXADDR_START
之间的这段空间称为永久映射区
。在内核的这段虚拟地址空间中允许建立与物理高端内存的长期映射关系。比如内核通过 alloc_pages()
函数在物理内存的高端内存中申请获取到的物理内存页,这些物理内存页可以通过调用 kmap
映射到永久映射区中。
LAST_PKMAP 表示永久映射区可以映射的页数限制:
#define PKMAP_BASE \
((LDT_BASE_ADDR - PAGE_SIZE) & PMD_MASK)
#define LAST_PKMAP 1024
1.5.1.5 固定映射区⚓
下一个区域为固定映射区
,区域范围为:FIXADDR_START 到 FIXADDR_TOP
。
内核文件/arch/x86/include/asm/fixmap.h
:
#define FIXADDR_START (FIXADDR_TOP - FIXADDR_SIZE)
extern unsigned long __FIXADDR_TOP; // 0xFFFF F000
#define FIXADDR_TOP ((unsigned long)__FIXADDR_TOP)
在固定映射区中虚拟地址是固定的,而被映射的物理地址是可以改变的。也就是说,有些虚拟地址在编译的时候就固定下来了,是在内核启动过程中被确定的,而这些虚拟地址对应的物理地址不是固定的。采用固定虚拟地址的好处是它相当于一个指针常量(常量的值在编译时确定),指向物理地址,如果虚拟地址不固定,则相当于一个指针变量。
那为什么会有固定映射这个概念呢 ?
比如:在内核的启动过程中,有些模块需要使用虚拟内存并映射到指定的物理地址上,而且**这些模块也没有办法等待完整的内存管理模块初始化之后再进行地址映射**。因此,内核固定分配了一些虚拟地址,这些地址有固定的用途,使用该地址的模块在初始化的时候,将这些固定分配的虚拟地址映射到指定的物理地址上去。
1.5.1.6 临时映射区⚓
内核虚拟内存空间中的最后一块区域为临时映射区
。
ChatGPT:
临时映射区(Temporary Mapping Area),是用来进行短期映射的。临时映射区在内核中用于临时映射和访问物理页,通常用于临时性操作,如内存拷贝、页表操作等。
临时映射区的存在可以提高内核的效率和灵活性。它允许内核在需要时将物理页映射到虚拟地址空间中的特定区域,以便在处理器执行某些操作时能够直接访问这些页面。这样可以避免频繁地为临时操作分配和释放内存区域,从而提高内核的性能。
临时映射区通常不会长时间保持映射状态,一旦临时操作完成,内核就会解除映射关系并释放相关资源,以便其他操作使用该内存区域。因此,临时映射区是一种临时性的、动态分配和释放的内存空间,用于进行内核的临时操作。
例
图中的第四步,内核会调用 iov_iter_copy_from_user_atomic
函数将用户空间缓冲区 DirectByteBuffer 中的待写入数据拷贝到 page cache
中。
而内核不能直接进行拷贝,因为此时从 page cache 中取出的缓存页 page 是物理地址,而在内核中是不能够直接操作物理地址的,只能操作虚拟地址。
所以就需要使用 kmap_atomic
将缓存页临时映射到内核空间的一段虚拟地址上,这段虚拟地址就位于内核虚拟内存空间中的临时映射区上,然后将用户空间缓存区 DirectByteBuffer 中的待写入数据通过这段映射的虚拟地址拷贝到 page cache 中的相应缓存页中。这时文件的写入操作就已经完成了。
由于是临时映射,所以在拷贝完成之后,调用 kunmap_atomic
将这段映射再解除掉。
意思就是在拷贝用户数据到 page cache 时在临时映射区建立临时的映射关系,实现拷贝的目的。
1.5.2 64 位体系内核虚拟内存空间布局⚓
内核态虚拟内存空间为高 128 T,虚拟内存地址范围为:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF
。
在 64 位体系下的内核虚拟内存空间与物理内存的映射就变得非常简单,由于虚拟内存空间足够的大,即便是内核要访问全部的物理内存,直接映射就可以了,不在需要用到《ZONE_HIGHMEM 高端内存》小节中介绍的高端内存那种动态映射方式。
1.5.2.1 内存空洞区域⚓
从 0xFFFF 8000 0000 0000 开始到 0xFFFF 8800 0000 0000
这段地址空间是一个 8T
大小的内存空洞
区域。
1.5.2.2 直接映射区⚓
内存空洞下一个区域就是 64T 大小的直接映射区。这个区域中的虚拟内存地址减去 PAGE_OFFSET
就直接得到了物理内存地址。
PAGE_OFFSET 变量定义在 /arch/x86/include/asm/page_64_types.h
文件中:
#define __PAGE_OFFSET_BASE _AC(0xffff880000000000, UL)
#define __PAGE_OFFSET __PAGE_OFFSET_BASE
1.5.2.3 vmalloc 映射区⚓
VMALLOC_START 到 VMALLOC_END
的这段区域是 32T
大小的 vmalloc 映射区
,这里类似用户空间中的堆,内核在这里使用 vmalloc 系统调用申请内存。
/arch/x86/include/asm/pgtable_64_types.h
文件:
#define __VMALLOC_BASE_L4 0xffffc90000000000UL
#define VMEMMAP_START __VMEMMAP_BASE_L4
#define VMALLOC_END (VMALLOC_START + (VMALLOC_SIZE_TB << 40) - 1)
1.5.2.4 虚拟内存映射区⚓
从 VMEMMAP_START
开始是 1T
大小的虚拟内存映射区
,用于存放物理页面的描述符 struct page
结构(用来表示物理内存页)。
/arch/x86/include/asm/pgtable_64_types.h
文件:
#define __VMEMMAP_BASE_L4 0xffffea0000000000UL
# define VMEMMAP_START __VMEMMAP_BASE_L4
1.5.2.5 512M 代码段⚓
从 __START_KERNEL_map
开始是大小为 512M
的区域用于存放内核代码段、全局变量、BSS
等。这里对应到物理内存开始的位置,减去 __START_KERNEL_map
就能得到物理内存的地址。这里和直接映射区有点像,但是不矛盾,因为直接映射区之前有 8T 的空洞区域,早就过了内核代码在物理内存中加载的位置。
/arch/x86/include/asm/page_64_types.h
文件:
#define __START_KERNEL_map _AC(0xffffffff80000000, UL)