赞
踩
目录
总结
本文主要从以下5个问题来进行:
1. 那么到底什么是虚拟内存地址 ?
2. Linux 内核为啥要引入虚拟内存而不直接使用物理内存 ?
3. 虚拟内存空间到底长啥样?
4. 内核如何管理虚拟内存?
5. 什么又是物理内存地址 ?如何访问物理内存?
联想一下快递投递的过程,其中快递单上填写的地址就是“虚拟地址”的概念,只是为了把快递准确无误地送到客户手里,这个“虚拟地址”只是起到了一个定位的作用。通过收货地址这个虚拟概念将它和现实世界真实存在的城市,小区,街道的地理位置一一映射起来,这样我们就可以通过这个虚拟概念来找到现实世界中的具体地理位置。收货地址是一个虚拟地址,它是人为定义的,而我们的城市,小区,街道是真实存在的,他们的地理位置就是物理地址。
现在让我们把视角在切换到计算机的世界,在计算机的世界里内存地址用来定义数据在内存中的存储位置的,内存地址也分为虚拟地址和物理地址。而虚拟地址也是人为设计的一个概念,类比我们现实世界中的收货地址,而物理地址则是数据在物理内存中的真实存储位置,类比现实世界中的城市,街道,小区的真实地理位置。接下来看一下虚拟内存到底长什么样。
32 位虚拟地址的格式为:页目录项(10位)+ 页表项(10位) + 页内偏移(12位)。共 32 位组成的虚拟内存地址。
在回答大家的这个疑问之前,让我们先来看下,如果在程序中直接使用物理内存地址会发生什么情况?
假设现在没有虚拟内存地址,我们在程序中对内存的操作全都都是使用物理内存地址,在这种情况下,程序员就需要精确的知道每一个变量在内存中的具体位置,我们需要手动对物理内存进行布局,明确哪些数据存储在内存的哪些位置,除此之外我们还需要考虑为每个进程究竟要分配多少内存?内存紧张的时候该怎么办?如何避免进程与进程之间的地址冲突?等等一系列复杂且琐碎的细节。
如果我们在单进程系统中进行内存管理可能还好,但是多个进程的时候如何避免内存冲突呢?假如现在有a, b, c三个进程,都需要操作i变量,假设变量 i 保存在 0x354 这个物理地址上。这三个进程运行起来之后,同时操作这个 0x354 物理地址,这样这个变量 i 的值不就混乱了吗? 三个进程就会出现变量的地址冲突。如图所示:
所以在直接操作物理内存的情况下,我们需要知道每一个变量的位置都被安排在了哪里,而且还要注意和多个进程同时运行的时候,不能共用同一个地址,否则就会造成地址冲突。现实中一个程序会有很多的变量和函数,这样一来我们给它们都需要计算一个合理的位置,还不能与其他进程冲突,这就很复杂了。那么我们该如何解决这个问题呢?程序的局部性原理再一次救了我们。
程序的局部性原理:时间局部性和空间局部性。时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某块数据被访问,则不久之后该数据可能再次被访问。空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。
从程序局部性原理的描述中我们可以得出这样一个结论:进程在运行之后,对于内存的访问不会一下子就要访问全部的内存,相反进程对于内存的访问会表现出明显的倾向性,更加倾向于访问最近访问过的数据以及热点数据附近的数据。
根据这个结论我们就清楚了,无论一个进程实际可以占用的内存资源有多大,根据程序局部性原理,在某一段时间内,进程真正需要的物理内存其实是很少的一部分,我们只需要为每个进程分配很少的物理内存就可以保证进程的正常执行运转。虚拟内存的引入正是要解决上述的问题,虚拟内存引入之后,进程的视角就会变得非常开阔,每个进程都拥有自己独立的虚拟地址空间,进程与进程之间的虚拟内存地址空间是相互隔离,互不干扰的。每个进程都认为自己独占所有内存空间。
当 CPU 访问进程的虚拟地址时,经过地址翻译硬件将虚拟地址转换成不同的物理地址,这样不同的进程运行的时候,虽然操作的是同一虚拟地址,但其实背后写入的是不同的物理地址,这样就不会冲突了。
(1)在进程运行之前,这些存放在二进制文件中的机器码需要被加载进内存中,而用于存放这些机器码的虚拟内存空间叫做代码段。
(2)进程运行需要操作变量:大量的全局变量和静态变量。指定了初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做数据段。没有指定初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做 BSS 段。
(3)全局变量和静态变量都是在编译期间就确定的。在进程运行期间,有可能会动态申请内存,堆就是用来存放动态申请的内存的。
(4)动态链接库中的代码段,数据段,BSS 段,以及通过 mmap 系统调用映射的共享内存区,在虚拟内存空间的存储区域叫做文件映射与匿名映射区。
(5)在程序运行的时候总该要调用各种函数,那么该函数对应的局部变量和参数使用栈来存储。
如下图所示:
在 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 这段虚拟内存地址是一段不可访问的保留区,因为在大多数操作系统中,数值比较小的地址通常被认为不是一个合法的地址,这块小地址是不允许访问的。比如在 C 语言中我们通常会将一些无效的指针设置为 NULL,指向这块不允许访问的地址。紧挨着 BSS 段的上边就是我们经常使用到的堆空间,从图中的红色箭头我们可以知道在堆空间中地址的增长方向是从低地址到高地址增长。堆空间的上边是一段待分配区域,用于扩展堆空间的使用。
我们理所应当的会认为在 64 位机器上,指针的寻址范围为 2^64,所能表达的虚拟内存空间为 16 EB 。虚拟内存地址范围为:0x0000 0000 0000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。事实上在目前的 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 。
如果一个虚拟内存地址的高 16 位全部为 0 ,那么我们就可以直接判断出这是一个用户空间的虚拟内存地址。在高 128T 的内核态虚拟内存空间:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 范围中,所以虚拟内存地址的高 16 位全部为 1 。也就是说内核态的虚拟内存地址的高 16 位全部为 1 ,如果一个试图访问内核的虚拟地址的高 16 位不全为 1 ,则可以快速判断这个访问是非法的。
在代码段跟数据段的中间还有一段不可以读写的保护段,它的作用是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。
在进程描述符 task_struct 结构中,有一个专门描述进程虚拟地址空间的内存描述符 mm_struct 结构,这个结构体中包含了前边几个小节中介绍的进程虚拟内存空间的全部信息。当我们调用 fork() 函数创建进程的时候,表示进程地址空间的 mm_struct 结构会随着进程描述符 task_struct 的创建而创建。do_fork()-->copy_process()-->copy_mm()函数完成了子进程虚拟内存空间 mm_struct 结构的的创建以及初始化。
- static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
- {
- // 子进程虚拟内存空间,父进程虚拟内存空间
- struct mm_struct *mm, *oldmm;
- int retval;
-
- tsk->min_flt = tsk->maj_flt = 0;
- tsk->nvcsw = tsk->nivcsw = 0;
- #ifdef CONFIG_DETECT_HUNG_TASK
- tsk->last_switch_count = tsk->nvcsw + tsk->nivcsw;
- #endif
-
- tsk->mm = NULL;
- tsk->active_mm = NULL;
- // 获取父进程虚拟内存空间
- oldmm = current->mm;
- if (!oldmm)
- return 0;
-
- /* initialize the new vmacache entries */
- vmacache_flush(tsk);
- // 通过 vfork 或者 clone 系统调用创建出的子进程(线程)和父进程共享虚拟内存空间
- if (clone_flags & CLONE_VM) {
- atomic_inc(&oldmm->mm_users);// 增加父进程虚拟地址空间的引用计数
- mm = oldmm; // 直接将父进程的虚拟内存空间赋值给子进程(线程)线程共享其所属进程的虚拟内存空间
- goto good_mm;
- }
-
- retval = -ENOMEM;
- mm = dup_mm(tsk);// 如果是 fork 系统调用创建出的子进程,则将父进程的虚拟内存空间以及相关页表拷贝到子进程中的 mm_struct 结构中。
- if (!mm)
- goto fail_nomem;
-
- good_mm:
- tsk->mm = mm;
- tsk->active_mm = mm;// 将拷贝出来的父进程虚拟内存空间 mm_struct 赋值给子进程
- return 0;
-
- fail_nomem:
- return retval;
- }
内核线程和用户态线程的区别就是内核线程没有相关的内存描述符 mm_struct ,内核线程对应的 task_struct 结构中的 mm 域指向 Null,所以内核线程之间调度是不涉及地址空间切换的。 当一个内核线程被调度时,它会发现自己的虚拟地址空间为 Null,虽然它不会访问用户态的内存,但是它会访问内核内存,聪明的内核会将调度之前的上一个用户态进程的虚拟内存空间 mm_struct 直接赋值给内核线程,因为内核线程不会访问用户空间的内存,它仅仅只会访问内核空间的内存,所以直接复用上一个用户态进程的虚拟地址空间就可以避免为内核线程分配 mm_struct 和相关页表的开销,以及避免内核线程之间调度时地址空间的切换开销。
进程的内存描述符 mm_struct 结构体中的 task_size 变量 定义了用户态地址空间与内核态地址空间之间的分界线。
- struct mm_struct {
- unsigned long task_size; /* size of task vm space */
- }
前边我们提到,内核中采用了一个叫做内存描述符的 mm_struct 结构体来表示进程虚拟内存空间的全部信息。在本小节中笔者就带大家到 mm_struct 结构体内部去寻找下相关的线索。
mmap_base 定义内存映射区的起始地址。进程运行时所依赖的动态链接库中的代码段,数据段,BSS 段以及我们调用 mmap 映射出来的一段虚拟内存空间就保存在这个区域。 start_stack 是栈的起始位置在 RBP 寄存器中存储,栈的结束位置也就是栈顶指针 stack pointer 在 RSP 寄存器中存储。在栈中内存地址的增长方向也是由高地址向低地址增长。mm_struct 结构体中的 total_vm 表示在进程虚拟内存空间中总共与物理内存映射的页的总数。当内存吃紧的时候,有些页可以换出到硬盘上,而有些页因为比较重要,不能换出。locked_vm 就是被锁定不能换出的内存页总数,pinned_vm 表示既不能换出,也不能移动的内存页总数。data_vm 表示数据段中映射的内存页数目,exec_vm 是代码段中存放可执行文件的内存页数目,stack_vm 是栈中所映射的内存页数目,这些变量均是表示进程虚拟内存空间中的虚拟内存使用情况。
vm_area_struct描述了虚拟内存区域 VMA(virtual memory area),例如代码区、数据段、BSS段、堆等。每个 vm_area_struct 结构对应于虚拟内存空间中的唯一虚拟内存区域 VMA,vm_start 指向了这块虚拟内存区域的起始地址(最低地址),vm_start 本身包含在这块虚拟内存区域内。vm_end 指向了这块虚拟内存区域的结束地址(最高地址),而 vm_end 本身包含在这块虚拟内存区域之外,所以 vm_area_struct 结构描述的是 [vm_start,vm_end) 这样一段左闭右开的虚拟内存区域。
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 的转换。
比如代码段这块内存区域的权限是可读,可执行,但是不可写。数据段具有可读可写的权限但是不可执行。堆则具有可读可写,可执行的权限(Java 中的字节码存储在堆中,所以需要可执行权限),栈一般是可读可写的权限,一般很少有可执行权限。而文件映射与匿名映射区存放了共享链接库,所以也需要可执行的权限。
虚拟内存区域可以映射到物理内存上,也可以映射到文件中,映射到物理内存上我们称之为匿名映射,映射到文件中我们称之为文件映射。当我们调用 malloc 申请内存时,如果申请的是小块内存(低于 128K)则会使用 do_brk() 系统调用通过调整堆中的 brk 指针大小来增加或者回收堆内存。如果申请的是比较大块的内存(超过 128K)时,则会调用 mmap 在上图虚拟内存空间中的文件映射与匿名映射区创建出一块 VMA 内存区域(这里是匿名映射)。这块匿名映射区域就用 struct anon_vma 结构表示。
struct vm_area_struct 结构中还有一个 vm_ops 用来指向针对虚拟内存区域 VMA 的相关操作的函数指针。
- struct vm_operations_struct {
- void (*open)(struct vm_area_struct * area);
- void (*close)(struct vm_area_struct * area);
- int (*fault)(struct vm_area_struct *vma, struct vm_fault *vmf);
- void (*map_pages)(struct vm_area_struct *vma, struct vm_fault *vmf);
-
- /* notification that a previously read-only page is about to become
- * writable, if an error is returned it will cause a SIGBUS */
- int (*page_mkwrite)(struct vm_area_struct *vma, struct vm_fault *vmf);
当指定的虚拟内存区域被加入到进程虚拟内存空间中时,open 函数会被调用;当虚拟内存区域 VMA 从进程虚拟内存空间中被删除时,close 函数会被调用;当进程访问虚拟内存时,访问的页面不在物理内存中,可能是未分配物理内存也可能是被置换到磁盘中,这时就会产生缺页异常,fault 函数就会被调用;当一个只读的页面将要变为可写时,page_mkwrite 函数会被调用;
- 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. */
- /* linked list of VM areas per task, sorted by address */
- struct vm_area_struct *vm_next, *vm_prev;
- struct rb_node vm_rb;
- struct mm_struct *vm_mm; /* The address space we belong to. */
- struct list_head anon_vma_chain; /* Serialized by mmap_sem &
- page_table_lock */
- struct anon_vma *anon_vma; /* Serialized by page_table_lock */
- pgprot_t vm_page_prot; /* Access permissions of this VMA. */
- unsigned long vm_flags; /* Flags, see mm.h. */
- unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
- units, *not* PAGE_CACHE_SIZE */
- struct file * vm_file; /* File we map to (can be NULL). */
- 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 结构的双向链表将虚拟内存空间中的这些虚拟内存区域 VMA 串联起来的。 vm_area_struct 结构中的 vm_next ,vm_prev 指针分别指向 VMA 节点所在双向链表中的后继节点和前驱节点,内核中的这个 VMA 双向链表是有顺序的,所有 VMA 节点按照低地址到高地址的增长方向排序。 双向链表中的最后一个 VMA 节点的 vm_next 指针指向 NULL,双向链表的头指针存储在内存描述符 struct mm_struct 结构中的 mmap 中,正是这个 mmap 串联起了整个虚拟内存空间中的虚拟内存区域。
- struct mm_struct {
- struct vm_area_struct *mmap; /* list of VMAs */
- }
每个虚拟内存区域 VMA 中又通过 struct vm_area_struct 中的 vm_mm 指针指向了所属的虚拟内存空间 mm_struct。
在进程虚拟内存空间中包含的内存区域 VMA 比较多的情况下,使用红黑树查找特定虚拟内存区域的时间复杂度是 O( logN ) ,可以显著减少查找所需的时间。所以在内核中,同样的内存区域 vm_area_struct 会有两种组织形式,一种是双向链表用于高效的遍历,另一种就是红黑树用于高效的查找。每个 VMA 区域都是红黑树中的一个节点,通过 struct vm_area_struct 结构中的 vm_rb 将自己连接到红黑树中。而红黑树中的根节点存储在内存描述符 struct mm_struct 中的 mm_rb 中:
- struct mm_struct {
- struct vm_area_struct *mmap; /* list of VMAs */
- struct rb_root mm_rb;
进程的虚拟内存空间 mm_struct 以及这些虚拟内存区域 vm_area_struct 是如何被创建并初始化的呢?
我们写的程序代码编译之后会生成一个 ELF 格式的二进制文件,这个 ELF 格式的二进制文件中的布局和我们前边讲的虚拟内存空间中的布局类似,也是一段一段的,每一段包含了不同的元数据。磁盘文件中的段我们叫做 Section,内存中的段我们叫做 Segment,也就是内存区域。
磁盘文件中的这些 Section 会在进程运行之前加载到内存中并映射到内存中的 Segment。通常是多个 Section 映射到一个 Segment。比如磁盘文件中的 .text,.rodata 等一些只读的 Section,会被映射到内存的一个只读可执行的 Segment 里(代码段)。而 .data,.bss 等一些可读写的 Section,则会被映射到内存的一个具有读写权限的 Segment 里(数据段,BSS 段)。
那么这些 ELF 格式的二进制文件中的 Section 是如何加载并映射进虚拟内存空间的呢?
内核中完成这个映射过程的函数是 load_elf_binary ,这个函数的作用很大,加载内核的是它,启动第一个用户态进程 init 的是它,fork 完了以后,调用 exec 运行一个二进制程序的也是它。当 exec 运行一个二进制程序的时候,除了解析 ELF 的格式之外,另外一个重要的事情就是建立上述提到的内存映射。
- static int load_elf_binary(struct linux_binprm *bprm)
- {
- // 设置虚拟内存空间中的内存映射区域起始地址 mmap_base
- setup_new_exec(bprm);
-
- /* Do this so that we can load the interpreter, if need be. We will
- change some of these later */
- // 创建并初始化栈对应的 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);
- // 将进程依赖的动态链接库 .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;
- }
不同进程之间的虚拟内存空间是相互隔离的,彼此之间相互独立,相互感知不到其他进程的存在。使得进程以为自己拥有所有的内存资源。
而内核态虚拟内存空间是所有进程共享的,不同进程进入内核态之后看到的虚拟内存空间全部是一样的。
本小节我们主要关注 0xC000 000 - 0xFFFF FFFF 这段虚拟内存地址区域也就是内核虚拟内存空间的布局情况。
在总共大小 1G 的内核虚拟内存空间中,位于最前边有一块 896M 大小的区域,我们称之为直接映射区或者线性映射区,地址范围为 3G -- 3G + 896m 。之所以这块 896M 大小的区域称为直接映射区或者线性映射区,是因为这块连续的虚拟内存地址会映射到 0 - 896M 这块连续的物理内存上。也就是说 3G -- 3G + 896m 这块 896M 大小的虚拟内存会直接映射到 0 - 896M 这块 896M 大小的物理内存上,这块区域中的虚拟内存地址直接减去 0xC000 0000 (3G) 就得到了物理内存地址。所以我们称这块区域为直接映射区。
大家这里只需要记得内核态虚拟内存空间的前 896M 区域是直接映射到物理内存中的前 896M 区域中的,直接映射区中的映射关系是一比一映射。映射关系是固定的不会改变。明白了这个关系之后,我们接下来就看一下这块直接映射区域在物理内存中究竟存的是什么内容。通过命令"cat /proc/iomem"命令可以查看具体物理内存布局情况。
在这段 896M 大小的物理内存中,前 1M 已经在系统启动的时候被系统占用,1M 之后的物理内存存放的是内核代码段,数据段,BSS 段(这些信息起初存放在 ELF格式的二进制文件中,在系统启动的时候被加载进内存)。当我们使用 fork 系统调用创建进程的时候,内核会创建一系列进程相关的描述符,比如之前提到的进程的核心数据结构 task_struct,进程的内存空间描述符 mm_struct,以及虚拟内存区域描述符 vm_area_struct 等。这些进程相关的数据结构也会存放在物理内存前 896M 的这段区域中,当然也会被直接映射至内核态虚拟内存空间中的 3G -- 3G + 896m 这段直接映射区域中。当进程被创建完毕之后,在内核运行的过程中,会涉及内核栈的分配,内核会为每个进程分配一个固定大小的内核栈(一般是两个页大小,依赖具体的体系结构),每个进程的整个调用链必须放在自己的内核栈中,内核栈也是分配在直接映射区。
我们都知道内核对物理内存的管理都是以页为最小单位来管理的,每页默认 4K 大小,理想状况下任何种类的数据页都可以存放在任何页框中,没有什么限制。比如:存放内核数据,用户数据,缓冲磁盘数据等。但是实际的计算机体系结构受到硬件方面的限制制约,间接导致限制了页框的使用方式。比如在 X86 体系结构下,ISA 总线的 DMA (直接内存存取)控制器,只能对内存的前16M 进行寻址,这就导致了 ISA 设备不能在整个 32 位地址空间中执行 DMA,只能使用物理内存的前 16M 进行 DMA 操作。
因此直接映射区的前 16M 专门让内核用来为 DMA 分配内存,这块 16M 大小的内存区域我们称之为 ZONE_DMA。而直接映射区中剩下的部分也就是从 16M 到 896M(不包含 896M)这段区域,我们称之为 ZONE_NORMAL。从字面意义上我们可以了解到,这块区域包含的就是正常的页框(使用没有任何限制)。
而物理内存 896M 以上的区域被内核划分为 ZONE_HIGHMEM 区域,我们称之为高端内存。 本例中我们的物理内存假设为 4G,高端内存区域为 4G - 896M = 3200M,那么这块 3200M 大小的 ZONE_HIGHMEM 区域该如何映射到内核虚拟内存空间中呢? 由于内核虚拟内存空间中的前 896M 虚拟内存已经被直接映射区所占用,而在 32 体系结构下内核虚拟内存空间总共也就 1G 的大小,这样一来内核剩余可用的虚拟内存空间就变为了 1G - 896M = 128M。 显然物理内存中 3200M 大小的 ZONE_HIGHMEM 区域无法继续通过直接映射的方式映射到这 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)
接下来 VMALLOC_START 到 VMALLOC_END 之间的这块区域成为动态映射区。采用动态映射的方式映射物理内存中的高端内存。
和用户态进程使用 malloc 申请内存一样,在这块动态映射区内核是使用 vmalloc 进行内存分配。由于之前介绍的动态映射的原因,vmalloc 分配的内存在虚拟内存上是连续的,但是物理内存是不连续的。通过页表来建立物理内存与虚拟内存之间的映射关系,从而可以将不连续的物理内存映射到连续的虚拟内存上。(由于 vmalloc 获得的物理内存页是不连续的,因此它只能将这些物理内存页一个一个地进行映射,在性能开销上会比直接映射大得多。)
而在 PKMAP_BASE 到 FIXADDR_START 之间的这段空间称为永久映射区。在内核的这段虚拟地址空间中允许建立与物理高端内存的长期映射关系。比如内核通过 alloc_pages() 函数在物理内存的高端内存中申请获取到的物理内存页,这些物理内存页可以通过调用 kmap 映射到永久映射区中。
- #ifdef CONFIG_X86_PAE
- #define LAST_PKMAP 512
- #else
- #define LAST_PKMAP 1024
- #endif
-
- #define PKMAP_BASE ((FIXADDR_START - PAGE_SIZE * (LAST_PKMAP + 1)) \
- & PMD_MASK)
内核虚拟内存空间中的下一个区域为固定映射区,区域范围为:FIXADDR_START 到 FIXADDR_TOP。 FIXADDR_START 和 FIXADDR_TOP 定义在内核源码 /arch/x86/include/asm/fixmap.h 文件中:
- extern unsigned long __FIXADDR_TOP; //0xFFFF F000
- #define FIXADDR_TOP ((unsigned long)__FIXADDR_TOP)
- #define FIXADDR_START (FIXADDR_TOP - FIXADDR_SIZE)
在内核虚拟内存空间的直接映射区中,直接映射区中的虚拟内存地址与物理内存前 896M 的空间的映射关系都是预设好的,一比一映射。 在固定映射区中的虚拟内存地址可以自由映射到物理内存的高端地址上,但是与动态映射区以及永久映射区不同的是,在固定映射区中虚拟地址是固定的,而被映射的物理地址是可以改变的。也就是说,有些虚拟地址在编译的时候就固定下来了,是在内核启动过程中被确定的,而这些虚拟地址对应的物理地址不是固定的。采用固定虚拟地址的好处是它相当于一个指针常量(常量的值在编译时确定),指向物理地址,如果虚拟地址不固定,则相当于一个指针变量。 那为什么会有固定映射这个概念呢 ? 比如:在内核的启动过程中,有些模块需要使用虚拟内存并映射到指定的物理地址上,而且这些模块也没有办法等待完整的内存管理模块初始化之后再进行地址映射。因此,内核固定分配了一些虚拟地址,这些地址有固定的用途,使用该地址的模块在初始化的时候,将这些固定分配的虚拟地址映射到指定的物理地址上去。
在内核虚拟内存空间中的最后一块区域为临时映射区,应用在把物理地址临时映射到虚拟地址上。使用 kmap_atomic 将缓存页临时映射到内核空间的一段虚拟地址上,这段虚拟地址就位于内核虚拟内存空间中的临时映射区上,然后将用户空间缓存区中的待写入数据通过这段映射的虚拟地址拷贝到 page cache 中的相应缓存页中。由于是临时映射,所以在拷贝完成之后,调用 kunmap_atomic 将这段映射再解除掉。
32 位体系结构 Linux 的整个虚拟内存空间的布局:
因此在 64 位体系下的内核虚拟内存空间与物理内存的映射就变得非常简单,由于虚拟内存空间足够的大,即便是内核要访问全部的物理内存,直接映射就可以了,不在需要用到《7.1.2 ZONE_HIGHMEM 高端内存》小节中介绍的高端内存那种动态映射方式。
聊完了虚拟内存,我们接着聊一下物理内存,我们平时所称的内存也叫随机访问存储器( random-access memory )也叫 RAM 。而 RAM 分为两类: 一类是静态 RAM( SRAM ),这类 SRAM 用于 CPU 高速缓存 L1Cache,L2Cache,L3Cache。其特点是访问速度快,访问速度为 1 - 30 个时钟周期,但是容量小,造价高。另一类则是动态 RAM ( DRAM ),这类 DRAM 用于我们常说的主存上,其特点的是访问速度慢(相对高速缓存),访问速度为 50 - 200 个时钟周期,但是容量大,造价便宜些(相对高速缓存)。
内存由一个一个的存储器模块(memory module)组成,它们插在主板的扩展槽上。常见的存储器模块通常以 64 位为单位( 8 个字节)传输数据到存储控制器上或者从存储控制器传出数。据。而 DRAM 芯片就包装在存储器模块中,每个存储器模块中包含 8 个 DRAM 芯片,依次编号为 0 - 7 。
而每一个 DRAM 芯片的存储结构是一个二维矩阵,二维矩阵中存储的元素我们称为超单元(supercell),每个 supercell 大小为一个字节(8 bit)。每个 supercell 都由一个坐标地址(i,j)。 i 表示二维矩阵中的行地址,在计算机中行地址称为 RAS (row access strobe,行访问选通脉冲)。 j 表示二维矩阵中的列地址,在计算机中列地址称为 CAS (column access strobe,列访问选通脉冲)。
DRAM 芯片中的信息通过引脚流入流出 DRAM 芯片。每个引脚携带 1 bit的信号。 图中 DRAM 芯片包含了两个地址引脚( addr ),因为我们要通过 RAS,CAS 来定位要获取的 supercell 。还有 8 个数据引脚(data),因为 DRAM 芯片的 IO 单位为一个字节(8 bit),所以需要 8 个 data 引脚从 DRAM 芯片传入传出数据。
我们现在就以读取上图中坐标地址为(2,2)的 supercell 为例,来说明访问 DRAM 芯片的过程:
(1)首先存储控制器将行地址 RAS = 2 通过地址引脚发送给 DRAM 芯片。
(2)DRAM 芯片根据 RAS = 2 将二维矩阵中的第二行的全部内容拷贝到内部行缓冲区中。
(3)接下来存储控制器会通过地址引脚发送 CAS = 2 到 DRAM 芯片中。
(4)DRAM芯片从内部行缓冲区中根据 CAS = 2 拷贝出第二列的 supercell 并通过数据引脚发送给存储控制器。
本小节我们来介绍下 CPU 是如何访问内存的:
CPU 与内存之间的数据交互是通过总线(bus)完成的,而数据在总线上的传送是通过一系列的步骤完成的,这些步骤称为总线事务(bus transaction)。 其中数据从内存传送到 CPU 称之为读事务(read transaction),数据从 CPU 传送到内存称之为写事务(write transaction)。 总线上传输的信号包括:地址信号,数据信号,控制信号。其中控制总线上传输的控制信号可以同步事务,并能够标识出当前正在被执行的事务信息:
(1) 当前这个事务是到内存的?还是到磁盘的?或者是到其他 IO 设备的?
(2) 这个事务是读还是写?
(3) 总线上传输的地址信号(物理内存地址),还是数据信号(数据)?
IO bridge 负责将系统总线上的电子信号转换成存储总线上的电子信号。IO bridge 也会将系统总线和存储总线连接到IO总线(磁盘等IO设备)上。这里我们看到 IO bridge 其实起的作用就是转换不同总线上的电子信号。
假设 CPU 现在需要将物理内存地址为 A 的内容加载到寄存器中进行运算。(CPU 只会访问虚拟内存,在操作总线之前,需要把虚拟内存地址转换为物理内存地址,总线上传输的都是物理内存地址,这里省略了虚拟地址到物理地址的转换)。
存储控制器会将物理内存地址转换为 DRAM 芯片中 supercell 在二维矩阵中的坐标地址(RAS,CAS)。并将这个坐标地址发送给对应的存储器模块。随后存储器模块会将 RAS 和 CAS 广播到存储器模块中的所有 DRAM 芯片。依次通过 (RAS,CAS) 从 DRAM0 到 DRAM7 读取到相应的 supercell 。我们知道一个 supercell 存储了一个字节( 8 bit ) 数据,这里我们从 DRAM0 到 DRAM7 依次读取到了 8 个 supercell 也就是 8 个字节,然后将这 8 个字节返回给存储控制器,由存储控制器将数据放到存储总线上。
CPU 总是以 word size 为单位从内存中读取数据,在 64 位处理器中的 word size 为 8 个字节。64 位的内存每次只能吞吐 8 个字节。
(1)CPU 将要写入的物理内存地址 A 放入系统总线上。
(2)通过 IO bridge 的信号转换,将物理内存地址 A 传递到存储总线上。
(3)存储控制器感受到存储总线上的地址信号,将物理内存地址 A 从存储总线上读取出来,并等待数据的到达。
CPU 将寄存器中的数据拷贝到系统总线上,通过 IO bridge 的信号转换,将数据传递到存储总线上。存储控制器感受到存储总线上的数据信号,将数据从存储总线上读取出来。存储控制器通过内存地址 A 定位到具体的存储器模块,最后将数据写入存储器模块中的 8 个 DRAM 芯片中。
本文我们从虚拟内存地址开始聊起,一直到物理内存地址结束,包含的信息量还是比较大的。首先笔者通过一个进程的运行实例为大家引出了内核引入虚拟内存空间的目的及其需要解决的问题。
在我们有了虚拟内存空间的概念之后,笔者又近一步为大家介绍了内核如何划分用户态虚拟内存空间和内核态虚拟内存空间,并在次基础之上分别从 32 位体系结构和 64 位体系结构的角度详细阐述了 Linux 虚拟内存空间的整体布局分布。
cat /proc/pid/maps
或者 pmap pid
命令来查看进程用户态虚拟内存空间的实际分布。cat /proc/iomem
命令来查看进程内核态虚拟内存空间的的实际分布。在我们清楚了 Linux 虚拟内存空间的整体布局分布之后,笔者又介绍了 Linux 内核如何对分布在虚拟内存空间中的各个虚拟内存区域进行管理,以及每个虚拟内存区域的作用。在这个过程中还介绍了相关的内核数据结构,近一步从内核源码实现角度加深大家对虚拟内存空间的理解。
最后笔者介绍了物理内存的结构,以及 CPU 如何通过物理内存地址来读写内存中的数据。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。