深入理解Linux虚拟内存管理

转载自一步一图带你深入理解 Linux 虚拟内存管理并基于自己已有的理解进行标注和丰富

        从本文开始我们就正式开启了 Linux 内核内存管理子系统源码解析系列,笔者还是会秉承之前系列文章的风格,采用一步一图的方式先是详细介绍相关原理,在保证大家清晰理解原理的基础上,我们再来一步一步的解析相关内核源码的实现。有了源码的辅证,这样大家看得也安心,理解起来也放心。

        内存管理子系统可谓是 Linux 内核众多子系统中最为复杂最为庞大的一个,其中包含了众多繁杂的概念和原理,通过内存管理这条主线我们把可以把操作系统的众多核心系统给拎出来,比如:进程管理子系统,网络子系统,文件子系统等。

        由于内存管理子系统过于复杂庞大,其中涉及到的众多繁杂的概念又是一环套一环,层层递进。如何把这些繁杂的概念具有层次感地,并且清晰地,给大家梳理呈现出来真是一件比较有难度的事情,因此关于这个问题,笔者在动笔写这个内存管理源码解析系列之前也是思考了很久。

        万事开头难,那么到底什么内容适合作为这个系列的开篇呢 ?笔者还是觉得从大家日常开发工作中接触最多最为熟悉的部分开始比较好,比如:在我们日常开发中创建的类,调用的函数,在函数中定义的局部变量以及 new 出来的数据容器(Map,List,Set …等)都需要存储在物理内存中的某个角落。

        而我们在程序中编写业务逻辑代码的时候,往往需要引用这些创建出来的数据结构,并通过这些引用对相关数据结构进行业务处理。

        当程序运行起来之后就变成了进程,而这些业务数据结构的引用在进程的视角里全都都是虚拟内存地址,因为进程无论是在用户态还是在内核态能够看到的都是虚拟内存空间,物理内存空间被操作系统所屏蔽进程是看不到的。
        进程通过虚拟内存地址访问这些数据结构的时候,虚拟内存地址会在内存管理子系统中被转换成物理内存地址,通过物理内存地址就可以访问到真正存储这些数据结构的物理内存了。随后就可以对这块物理内存进行各种业务操作,从而完成业务逻辑。

  • 那么到底什么是虚拟内存地址 ?
  • Linux 内核为啥要引入虚拟内存而不直接使用物理内存 ?
  • 虚拟内存空间到底长啥样?
  • 内核如何管理虚拟内存?
  • 什么又是物理内存地址 ?如何访问物理内存?

本文笔者就来为大家详细一一解答上述几个问题,让我们马上开始吧~~~~

一、什么是虚拟内存地址

        类比快递的收货地址和真实地址位置,首先,收货地址是一个虚拟地址,它是人为定义的,而我们的城市,小区,街道是真实存在的,他们的地理位置就是物理地址。然后,我们切回计算机世界,在计算机里面,内存地址是用来定义数据在内存中存储位置的,内存地址也分为虚拟地址和物理地址。同样,这个虚拟地址也是人为定义的,类比我们现实世界的收货地址,而物理地址就是数据在物理内存中真实存储的位置,类比我们的城市,小区,街道的地理位置。

        说完定义后,现在开始讲一下虚拟内存地址到底长什么样。这里暂时不讲MMU的内容,后面有其他文章会细讲虚拟内存地址到物理内存地址的转换。

        这里以ARM64架构为例,64位虚拟内存地址格式如下:最高位(第63位)表示TTBRX (X=0/1),用于区分是用户虚拟内存地址和内核虚拟内存地址;62-48位是保留位,另做它用;47-39位表示PGD(Page Global Directory,全局页目录项)索引;38-30位表示PUD(Page Upper Directory,上层页目录项)索引;29-21位表示PMD(Page Middle Directory,中间页目录项)索引;20-12位表示PTE(Page Table Entry,页表项)索引;11-0位表示页内偏移。从上面也可知,在ARM64中,不考虑硬件支持情况,地址最大有效位宽是48位,最多是4级页表。实际上在x86架构中,地址最大有效位宽也是48位,最多4级页表。考虑硬件支持,ARM64,地址最大有效位宽是52位,x86可以达到56位。

+--------+--------+--------+--------+--------+--------+--------+--------+
|63    56|55    48|47    40|39    32|31    24|23    16|15     8|7      0|
+--------+--------+--------+--------+--------+--------+--------+--------+
 |                 |         |         |         |         |
 |                 |         |         |         |         v
 |                 |         |         |         |   [11:0]  in-page offset
 |                 |         |         |         +-> [20:12] L3 index
 |                 |         |         +-----------> [29:21] L2 index
 |                 |         +---------------------> [38:30] L1 index
 |                 +-------------------------------> [47:39] L0 index
 +-------------------------------------------------> [63] TTBR0/1

        类比收货地址的格式:xx省xx市xx区xx街道xx小区xx室,它是按照地区层次递进的。对比计算机世界中的虚拟内存地址这样的递进关系。简化来看可以得到如下图示。虚拟内存地址中的全局页目录项就类比我们日常生活中收获地址里的省,上层页目录项就类比市,中间层页目录项类比区县,页表项类比街道小区,页内偏移类比我们所在的楼栋和几层几号。

        这里大家只需要大体明白虚拟内存地址到底长什么样子,它的格式是什么,能够和日常生活中的收货地址对比理解起来就可以了,至于页目录项,页表项以及页内偏移这些计算机世界中的概念,大家暂时先不用管,后续文章中笔者会慢慢给大家解释清楚。

        32位虚拟地址的格式为: 页目录项PDE(Page Directory Entry)(10位)+ 页表项PTE(Page Table Entry)(10位) + 页内偏移(12位)。共 32 位组成的虚拟内存地址。跟收货地址类比,就如下图所示。不过考虑到现在基本都是64位了,32位的可以做个了解即可。

        注意:进程虚拟内存空间中的每一个字节都有与其对应的虚拟内存地址,一个虚拟内存地址表示进程虚拟内存空间中的一个特定的字节。

二、为什么使用虚拟内存地址访问内存

        通过第一节,我们知道了虚拟内存地址的含义以及其构成形式。此时大家可能会有疑惑,既然物理内存地址表示数据在物理内存中的存储位置,那为何不直接使用物理内存地址来访问内存而是选择虚拟内存地址呢?

        回答之前,那我们来看看,如果程序直接使用物理内存地址会发生情况?

        假设现在没有虚拟内存地址,我们在程序中对内存的操作全都都是使用物理内存地址,在这种情况下,程序员就需要精确的知道每一个变量在内存中的具体位置,我们需要手动对物理内存进行布局,明确哪些数据存储在内存的哪些位置,除此之外我们还需要考虑为每个进程究竟要分配多少内存?内存紧张的时候该怎么办?如何避免进程与进程之间的地址冲突?等等一系列复杂且琐碎的细节。

        当然如果系统是个单进程系统,如单片机或者小型嵌入式设备上,系统往往只有一个进程,显然其是独享系统所有物理资源包括内存资源。此时上面直接使用物理内存带来的问题还不算难题,问题不大。但是现代操作系统都是多进程的系统,支持的进程数没有上千也有上百,此时就需要考虑多进程之间的协同问题,此时直接使用物理内存地址访问内存所带来的上述问题就非常复杂了,指数级复杂度....。

        这里举个例子来说明一下。下面是个C++程序。

#include <iostream>
 
using namespace std;

int main (void)
{
    int i = 0;
    cin >> i;
    return 0; 
}

        在程序代码相同情况下,利用这份代码同时启动三个进程,暂时将进程依次命名为进程a,b,c。

        这三个进程用到的代码是一样的,都是我们提前写好的,可以被多次运行。由于我们是直接操作物理内存地址,假设变量 i 保存在 0x354 这个物理地址上。这三个进程运行起来之后,同时操作这个 0x354 物理地址,这样这个变量 i 的值不就混乱了吗? 三个进程就会出现变量的地址冲突。

        因此,在直接操作物理内存的情况下,我们需要知道每一个变量的位置都被安排在了哪里,而且还要注意和多个进程同时运行的时候,不能共用同一个地址,否则就会造成地址冲突。

        而且现实中,一个程序涉及到很多变量和函数调用,这都要我们给它们计算好位于物理内存的位置,还不能与其他进程冲突,复杂程度可想而知。

        那我们如何解决这个问题呢?程序的局部性原理再一次救了我们~~

程序局部性原理表现为:时间局部性和空间局部性。时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某块数据被访问,则不久之后该数据可能再次被访问。空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。

        从程序局部性原理的描述中我们可以得出这样一个结论:进程在运行之后,对于内存的访问不会一下子就要访问全部的内存,相反进程对于内存的访问会表现出明显的倾向性,更加倾向于访问最近访问过的数据以及热点数据附近的数据。

        根据这个结论我们就清楚了,无论一个进程实际可以占用的内存资源有多大,根据程序局部性原理,在某一段时间内,进程真正需要的物理内存其实是很少的一部分,我们只需要为每个进程分配很少的物理内存就可以保证进程的正常执行运转。

         而虚拟内存的引入正是要解决上述的问题,虚拟内存引入之后,进程的视角就会变得非常开阔,每个进程都拥有自己独立的虚拟地址空间,进程与进程之间的虚拟内存地址空间是相互隔离,互不干扰的。每个进程都认为自己独占所有内存空间,自己想干什么就干什么。系统上还运行了哪些进程和我没有任何关系。这样一来我们就可以将多进程之间协同的相关复杂细节统统交给内核中的内存管理模块来处理,极大地解放了程序员的心智负担。这一切都是因为虚拟内存能够提供内存地址空间的隔离,极大地扩展了可用空间。

        这样进程就以为自己独占了整个内存空间资源,给进程产生了所有内存资源都属于它自己的幻觉,这其实是 CPU 和操作系统使用的一个障眼法罢了,任何一个虚拟内存里所存储的数据,本质上还是保存在真实的物理内存里的。只不过内核帮我们做了虚拟内存到物理内存的这一层映射,将不同进程的虚拟地址和不同内存的物理地址映射起来。

        当 CPU 访问进程的虚拟地址时,经过MMU将虚拟地址转换成不同的物理地址,这样不同的进程运行的时候,虽然操作的是同一虚拟地址,但其实背后写入的是不同的物理地址,这样就不会冲突了。不过这里需要注意的是,不同进程同时访问同一虚拟地址进行读操作是OK的,写操作就需要加锁保证并发同步,一读一写同样也要并发同步,这部分暂时不在这里展开。

        总结来说,为何不直接使用物理内存地址来访问内存而是选择虚拟内存地址的原因如下:

1、物理内存永远是不足,属于稀缺资源。内存(memory)资源永远都是稀缺的,当越来越多的进程需要越来越来内存时,某些进程会因为得不到内存而无法运行;例如,某个程序运行需要32M的内存,而机器上只有16M的物理内存(通常解决方法是把程序分割成许多称为覆盖块overlay的片段。覆盖块0先运行,结束时它将调用下一个覆盖块;程序员需要将程序进行分割,这是一个费时费力的工作,并且相当枯燥,使用虚拟内存地址,利用动态映射,完美解决这个问题)

2、内存保护:虚拟内存地址为每个进程体分配各自的虚拟内存地址空间,保护每个进程的地址空间不被其他进程破坏。
3、提高内存使用效率:虚拟内存管理将主存(运行内存)看成是存储在磁盘上的地址空间的高速缓存,主存中保存热的数据,根据需要在磁盘和主存之间传送数据。直接使用物理内存地址,程序运行的地址固定,如果物理内存地址发生变化,可能需要重新编译程序,且对于多进程操作,存在进程间地址空间不隔离,一个进程内存异常影响其他进程。
4、简化内存管理:虚拟内存管理为每个进程提供了一致的地址空间(由什么构成每个基本都是一样的),从而简化了链接、加载、内存共享等过程;

三、进程虚拟内存空间

        第二节,我们讲了为了避免多进程直接使用物理内存地址,造成运行时的冲突,引入了虚拟内存地址,为每个进程分配独立的虚拟内存地址空间,使得进程以为自己独占全部内存资源。现在这节开始介绍进程虚拟内存空间具体长什么样子。

        注意:本小节我们只讨论进程用户态虚拟内存空间的布局,我们先把内核态的虚拟内存空间当做一个黑盒来看待,在后面的小节中笔者再来详细介绍内核态相关内容。

        我们从内核设计人员角度出发,我们该从哪些方面规划进程的虚拟内存空间呢?

        首先我们会想到的是一个进程运行起来是为了执行我们交代给进程的工作,执行这些工作的步骤我们通过程序代码事先编写好,然后编译成二进制文件存放在磁盘中,CPU 会执行二进制文件中的机器码来驱动进程的运行。所以在进程运行之前,这些存放在二进制文件中的机器码需要被加载进内存中,而用于存放这些机器码的虚拟内存空间叫做代码段

        在程序运行起来之后,总要操作变量吧,在程序代码中我们通常会定义大量的全局变量和静态变量,这些全局变量在程序编译之后也会存储在二进制文件中,在程序运行之前,这些全局变量也需要被加载进内存中供程序访问。所以在虚拟内存空间中也需要一段区域来存储这些全局变量。

  • 那些在代码中被我们指定了初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做数据段。
  • 那些没有指定初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做 BSS 段。这些未初始化的全局变量被加载进内存之后会被初始化为 0 值。

        上面介绍的这些全局变量和静态变量都是在编译期间就确定的,但是我们程序在运行期间往往需要动态的申请内存,所以在虚拟内存空间中也需要一块区域来存放这些动态申请的内存,这块区域就叫做堆。注意这里的堆指的是 OS 堆并不是 JVM 中的堆。

        除此之外,我们的程序在运行过程中还需要依赖动态链接库,这些动态链接库以 .so 文件的形式存放在磁盘中,比如 C 程序中的 glibc,里边对系统调用进行了封装。glibc 库里提供的用于动态申请堆内存的 malloc 函数就是对系统调用 sbrk 和 mmap 的封装。这些动态链接库也有自己的对应的代码段,数据段,BSS 段,也需要一起被加载进内存中。

        还有用于内存文件映射的系统调用 mmap,会将文件与虚拟内存进行映射,那么映射的这块内存(虚拟内存)也需要在虚拟地址空间中有一块区域存储。
        这些动态链接库中的代码段,数据段,BSS 段,以及通过 mmap 系统调用映射的共享内存区,在虚拟内存空间的存储区域叫做文件映射与匿名映射区。至于我这里写文件映射与匿名映射区,而不是直接是内存映射区,是因为内存映射分为文件映射和匿名映射,都是通过mmap来实现的。具体大家可参考这2篇文章从内核世界透视 mmap 内存映射的本质(原理篇)从内核世界透视 mmap 内存映射的本质(源码实现篇) 里面有关于mmap的详细介绍。

        最后我们在程序运行的时候总该要调用各种函数吧,那么调用函数过程中使用到的局部变量和函数参数也需要一块内存区域来保存。这一块区域在虚拟内存空间中叫做栈。 

        现在进程的用户态虚拟内存空间所包含的主要区域,笔者就为大家介绍完了,我们看到内核根据进程运行的过程中所需要不同种类的数据而为其开辟了对应的地址空间。总结来说,分别为:

  • 用于存放进程程序二进制文件中的机器指令的代码段
  • 用于存放程序二进制文件中定义的全局变量和静态变量的数据段和 BSS 段。
  • 用于在程序运行过程中malloc动态申请内存的堆。
  • 用于存放动态链接库以及内存映射区域的文件映射与匿名映射区。
  • 用于存放函数调用过程中的局部变量和函数参数的栈。

        对于ARM64架构的内核态的虚拟内存空间分布,可以参考:kernel内存泄漏分析方法的第一节,里面有详细介绍。至于X86架构内核态的虚拟内存空间,在本文后面会介绍。至于用户态的虚拟内存空间分布,ARM64架构和X86架构是一样的。

        注意:内核态虚拟地址空间,只能内核进程使用,用户进程无法直接访问,只能通过系统调用,请求内核进程帮忙完成需要在内核做的事情。

        以上就是我们通过一个程序在运行过程中所需要的数据所规划出的虚拟内存空间的分布,当然只是一个大概的规划。在真实的 Linux 系统中,进程的虚拟内存空间的具体规划又是如何的呢?下节介绍。

四、Linux进程虚拟内存空间分布

        第三节介绍了进程用户态虚拟内存空间中各个内存区域的大概分布。在此基础上,分别介绍其在32位或者64位Linux系统上真实分布情况。注意下面是以X86架构为主。

4.1 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开始的,而是从0x804 8000开始的。

        0x0000 0000 到 0x0804 8000 这段用户态虚拟内存地址是一段不可访问的保留区,因为在大多数操作系统中,数值比较小的地址通常被认为不是一个合法的地址,这块小地址是不允许访问的。比如在 C 语言中我们通常会将一些无效的指针设置为 NULL,指向这块不允许访问的地址。

        保留区的上边就是代码段和数据段,它们是从程序的二进制文件中直接加载进内存中的,BSS段中的数据也存在于二进制文件中,因为内核知道这些数据是没有初值的,所以在二进制文件中只会记录 BSS 段的大小,在加载进内存时会生成一段 0 填充的内存空间,即加载进内存后,被初始化为0。

        紧挨着 BSS 段的上边就是我们经常使用到的堆空间,从图中的红色箭头我们可以知道在堆空间中地址的增长方向是从低地址到高地址增长。内核中使用 start_brk 标识堆的起始位置,brk 标识堆当前的结束位置。当堆申请新的内存空间时,只需要将 brk 指针增加对应的大小,回收地址时减少对应的大小即可。比如当我们通过 malloc 向内核申请很小的一块内存时(128K 之内),就是通过改变 brk 位置实现的。

        堆空间的上边是一段待分配区域,用于扩展堆空间的使用。接下来就来到了文件映射与匿名映射区域。进程运行时所依赖的动态链接库中的代码段,数据段,BSS 段就加载在这里。还有我们调用 mmap 映射出来的一段虚拟内存空间也保存在这个区域。注意:在文件映射与匿名映射区的地址增长方向是从高地址向低地址增长。

        接下来用户态虚拟内存空间的最后一块区域就是栈空间了,在这里会保存函数运行过程所需要的局部变量以及函数参数等函数调用信息。栈空间中的地址增长方向是从高地址向低地址增长。每次进程申请新的栈地址时,其地址值是在减少的。在内核中使用 start_stack 标识栈的起始位置,RSP 寄存器中保存栈顶指针 stack pointer,RBP 寄存器中保存的是栈基地址。

        在栈空间的下边也有一段待分配区域用于扩展栈空间,在栈空间的上边就是内核空间了,用户态进程虽然可以看到这段内核空间地址,但是就是不能访问。这就好比我们在饭店里虽然可以看到厨房在哪里,但是厨房门上写着 “厨房重地,闲人免进” ,我们就是进不去。

4.2 64位Linux系统进程虚拟内存空间分布

        如果想要进程的实际虚拟内存空间分布,可通过指令cat /proc/pid/maps或者 pmap pid直接查看,当然也可以通过cat /proc/pid/maps >> /xx/pid_maps.txt方式保存到指定路径下,方便有文本软件打开查看。

        回归正题,从4.1节可知,32位系统,指针寻址范围是2^32,所能表达的虚拟内存空间为 4GB。那么理论上在64位系统上,指针寻址范围是2^64,所能表达的虚拟内存空间为 16 EB 。虚拟内存地址范围为:0x0000 0000 0000 0000 - 0xFFFF FFFF FFFF FFFF。但是,显然现实中,没有这么大的内存空间的运行内存,而且现实生活中也不会用到这么大范围的内存空间。

        因而,当前64位Linux系统,X86 CPU结构下,地址总线位宽最多支持48位,寻址范围是2^48,所能表达的虚拟内存空间为256TB。如果有硬件支持,可扩展到56位,这里暂时不细述这个。

        按照一般使用规律,其中低128T表示用户态虚拟内存空间,虚拟内存地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 。

        高128T表示内核态虚拟内存空间,虚拟内存地址范围为:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。

        这样一来就在用户态虚拟内存空间与内核态虚拟内存空间之间形成了一段 0x0000 7FFF FFFF F000 - 0xFFFF 8000 0000 0000 的地址空洞,我们把这个空洞叫做 canonical address 空洞。

        那么这个 canonical address 空洞是如何形成的呢?

        我们都知道在 64 位机器上的指针寻址范围为 2^64,但是在实际使用中我们只使用了其中的低 48 位来表示虚拟内存地址,那么这多出的高 16 位就形成了这个地址空洞。

        大家注意到在低 128T 的用户态地址空间:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 范围中,所以虚拟内存地址的高 16 位全部为 0 。如果一个虚拟内存地址的高 16 位全部为 0 ,那么我们就可以直接判断出这是一个用户空间的虚拟内存地址。同样的道理,在高128T 的内核态虚拟内存空间:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 范围中,所以虚拟内存地址的高 16 位全部为1。

        也就是说内核态的虚拟内存地址的高 16 位全部为 1 ,如果一个试图访问内核的虚拟地址的高 16 位不全为 1 ,则可以快速判断这个访问是非法的。

        这个高 16 位的空闲地址被称为 canonical。如果虚拟内存地址中的高 16 位全部为 0 (表示用户空间虚拟内存地址)或者全部为 1 (表示内核空间虚拟内存地址),这种地址的形式我们叫做 canonical form,对应的地址我们称作 canonical address 。

        那么处于 canonical address 空洞 :0x0000 7FFF FFFF F000 - 0xFFFF 8000 0000 0000 范围内的地址的高 16 位 不全为 0 也不全为 1 。如果某个虚拟地址落在这段 canonical address 空洞区域中,那就是既不在用户空间,也不在内核空间,肯定是非法访问了。当然未来我们也可以利用这块 canonical address 空洞,来扩展虚拟内存地址的范围,比如扩展到56位。

        在理解了 canonical address 这个概念之后,我们再来看下 64 位 Linux 系统下的真实虚拟内存空间布局情况:

        从上图中我们可以看出 64 位系统中的虚拟内存布局和 32 位系统中的虚拟内存布局大体上是差不多的。主要不同的地方有三点:

        第一,就是前边提到的由高 16 位空闲地址造成的 canonical address 空洞。访问这段范围内的虚拟内存地址是不合法的,因为它的高 16 位既不全为 0 也不全为 1,不是一个 canonical address,所以称之为 canonical address 空洞。

        第二,在代码段跟数据段的中间还有一段不可以读写的保护段,它的作用是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。

        第三,用户态虚拟内存空间与内核态虚拟内存空间分别占用128TB,其中低128TB分配给用户态虚拟内存空间,高128TB分配给内核态虚拟内存空间。

五、进程虚拟内存空间管理

        上一小节,我们介绍了进程虚拟内存地址空间在32位或者64位Linux系统上的分布。可以发现,无论是32位还是64位,进程虚拟内存地址空间的相对位置是不变,均包含了如下所示的几个核心内存区域。唯一不同的就是这些核心内存区域的绝对位置会有所不同。

        那么在此基础上,本小节将重点介绍内核是如何为进程管理这些虚拟内存区域。既然我们要介绍进程的虚拟内存地址空间管理,就离不开内核中进程的描述符task_struct结构体,大致如下。注意:下面的代码是kernel-5.4版本的

//include/linux/sched.h 
struct task_struct {
     .......... 省略 .......
        // 内存描述符表示进程虚拟地址空间
     struct mm_struct  *mm;
        // 进程id
     pid_t    pid;
        // 用于标识线程所属的进程 pid
     pid_t    tgid;
        // 进程打开的文件信息
     struct files_struct  *files;
 

     .......... 省略 .......
}

        在进程描述符 task_struct 结构中,有一个专门描述进程虚拟内存地址空间的内存描述符 mm_struct 结构,这个结构体中包含了前边几个小节中介绍的进程虚拟内存空间的全部信息。

每个进程都有唯一的 mm_struct 结构体,也就是前边提到的每个进程的虚拟地址空间都是独立,互不干扰的。当我们调用 fork() 函数创建进程的时候,表示进程地址空间的 mm_struct 结构会随着进程描述符 task_struct 的创建而创建。

        这里插播一些其他信息:Linux中创建进程一共有三个函数:第一, fork,创建子进程。第二,vfork,与fork类似,但是父子进程共享地址空间,而且子进程先于父进程运行。第三,clone,主要用于创建线程。而且从下面也可以看出,这三个函数对应的系统调用,最后统一都是由kernel_clone来实现进程的创建,差异就是输入的参数不同

//kernel/fork.c 
pid_t kernel_clone(struct kernel_clone_args *args)
{
        u64 clone_flags = args->flags;
        struct completion vfork;
        struct pid *pid;
        struct task_struct *p;
        .......... 省略 .......
            // 为进程创建 task_struct 结构,用父进程的资源填充 task_struct 信息
        p = copy_process(NULL, trace, NUMA_NO_NODE, args);
        .......... 省略 .......
}


#ifdef __ARCH_WANT_SYS_FORK
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
	struct kernel_clone_args args = {
		.exit_signal = SIGCHLD,
	};

	return kernel_clone(&args);
#else
	/* can not support in nommu mode */
	return -EINVAL;
#endif
}
#endif

#ifdef __ARCH_WANT_SYS_VFORK
SYSCALL_DEFINE0(vfork)
{
	struct kernel_clone_args args = {
		.flags		= CLONE_VFORK | CLONE_VM,
		.exit_signal	= SIGCHLD,
	};

	return kernel_clone(&args);
}
#endif

#ifdef __ARCH_WANT_SYS_CLONE
#ifdef CONFIG_CLONE_BACKWARDS
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
		 int __user *, parent_tidptr,
		 unsigned long, tls,
		 int __user *, child_tidptr)
#elif defined(CONFIG_CLONE_BACKWARDS2)
SYSCALL_DEFINE5(clone, unsigned long, newsp, unsigned long, clone_flags,
		 int __user *, parent_tidptr,
		 int __user *, child_tidptr,
		 unsigned long, tls)
#elif defined(CONFIG_CLONE_BACKWARDS3)
SYSCALL_DEFINE6(clone, unsigned long, clone_flags, unsigned long, newsp,
		int, stack_size,
		int __user *, parent_tidptr,
		int __user *, child_tidptr,
		unsigned long, tls)
#else
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
		 int __user *, parent_tidptr,
		 int __user *, child_tidptr,
		 unsigned long, tls)
#endif
{
	struct kernel_clone_args args = {
		.flags		= (lower_32_bits(clone_flags) & ~CSIGNAL),
		.pidfd		= parent_tidptr,
		.child_tid	= child_tidptr,
		.parent_tid	= parent_tidptr,
		.exit_signal	= (lower_32_bits(clone_flags) & CSIGNAL),
		.stack		= newsp,
		.tls		= tls,
	};

	return kernel_clone(&args);
}
#endif

        随后会在 copy_process 函数中创建 task_struct 结构,并拷贝父进程的相关资源到新进程的 task_struct 结构里,其中就包括拷贝父进程的虚拟内存空间 mm_struct 结构。这里可以看出子进程在新创建出来之后它的虚拟内存空间布局是和父进程的虚拟内存空间一模一样的,直接拷贝过来,当然对应的虚拟内存空间地址是不一样的。

//kernel/fork.c
/*
 * This creates a new process as a copy of the old one,
 * but does not actually start it yet.
 *
 * It copies the registers, and all the appropriate
 * parts of the process environment (as per the clone
 * flags). The actual kick-off is left to the caller.
 */
static __latent_entropy struct task_struct *copy_process(
					struct pid *pid,
					int trace,
					int node,
					struct kernel_clone_args *args)
{

    struct task_struct *p;
    // 创建 task_struct 结构
    p = dup_task_struct(current, node);

        ....... 初始化子进程 ...........

    // 分配 CPU
    retval = sched_fork(clone_flags, p);
        ....... 开始继承拷贝父进程资源  .......      
    // 继承父进程打开的文件描述符
	retval = copy_files(clone_flags, p);
    // 继承父进程所属的文件系统
	retval = copy_fs(clone_flags, p);
    // 继承父进程注册的信号以及信号处理函数
	retval = copy_sighand(clone_flags, p);
	retval = copy_signal(clone_flags, p);
    // 继承父进程的虚拟内存空间
	retval = copy_mm(clone_flags, p);
    // 继承父进程的 namespaces
	retval = copy_namespaces(clone_flags, p);
    // 继承父进程的 IO 信息
	retval = copy_io(clone_flags, p);

      ...........省略.........
    // 分配 pid
    pid = alloc_pid(p->nsproxy->pid_ns_for_children);

      ..........省略.........
}

        这里我们重点关注跟进程虚拟内存地址管理相关结构体mm_struct对应的copy_mm函数,正是在这里完成了子进程虚拟内存空间 mm_struct 结构的的创建以及初始化。

//kernel/fork.c
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
	// 创建子进程虚拟内存空间,父进程虚拟内存空间的局部变量
	struct mm_struct *mm, *oldmm;
	int retval;
	
			...... 省略 ......
	
	tsk->mm = NULL;
	tsk->active_mm = NULL;
		// 获取父进程虚拟内存空间
	oldmm = current->mm;
	if (!oldmm)
	return 0;
	
			...... 省略 ......
	// 通过 vfork 或者 clone 系统调用创建出的子进程(线程)和父进程共享虚拟内存空间
	if (clone_flags & CLONE_VM) {
			// 增加父进程虚拟地址空间的引用计数
			mmget(oldmm);
			// 直接将父进程的虚拟内存空间赋值给子进程(线程)
			// 线程共享其所属进程的虚拟内存空间
			mm = oldmm;
			goto good_mm;
	}
	
	retval = -ENOMEM;
	// 如果是 fork 系统调用创建出的子进程,则将父进程的虚拟内存空间以及相关页表拷贝到子进程自己的mm_struct结构中。
	mm = dup_mm(tsk);
	if (!mm)
	goto fail_nomem;

good_mm:
 //最终在这里更新到子进程mm_struct中,前面的mm都是局部变量,真正的子进程是入参的tsk
 tsk->mm = mm;
 tsk->active_mm = mm;
 return 0;

        ...... 省略 ......
}

        由于本小节中我们举的示例是通过 fork() 函数创建子进程的情形,所以这里大家先占时忽略 if (clone_flags & CLONE_VM) 这个条件判断逻辑,我们先跳过往后看~~。

        copy_mm 函数首先会将父进程的虚拟内存空间 current->mm 赋值给指针 oldmm。然后通过 dup_mm 函数将父进程的虚拟内存空间以及相关页表拷贝到子进程的 mm_struct 结构中。最后将拷贝出来的 mm_struct 赋值给子进程的 task_struct 结构。

        通过 fork() 函数创建出的子进程,它的虚拟内存空间以及相关页表相当于父进程对应内容的一份拷贝,直接从父进程中拷贝到子进程中。

        而当我们通过 vfork 或者 clone 系统调用创建出的子进程,首先会设置 CLONE_VM 标识,这样来到 copy_mm 函数中就会进入 if (clone_flags & CLONE_VM) 条件中,在这个分支中会将父进程的虚拟内存空间以及相关页表直接赋值给子进程。这样一来父进程和子进程的虚拟内存空间就变成共享的了。也就是说父子进程之间使用的虚拟内存空间是一样的,并不是一份拷贝。

        子进程共享了父进程的虚拟内存空间,这样子进程就变成了我们熟悉的线程(当然也有可能RCU机制在作用,子进程进行读操作时与父进程共享;子进程进行写操作,更改数据时,再复制父进程的虚拟内存空间,独立出来,不过这里暂时不涉及),是否共享地址空间几乎是进程和线程之间的本质区别。Linux 内核并不区别对待它们,线程对于内核来说仅仅是一个共享特定资源的进程而已。
        内核线程和用户态线程的区别就是内核线程没有相关的内存描述符 mm_struct 内核线程对应的 task_struct 结构中的 mm 域指向 NULL,所以内核线程之间调度是不涉及地址空间切换的。

        当一个内核线程被调度时,它会发现自己的虚拟地址空间为 NULL,虽然它不会访问用户态的内存,但是它会访问内核内存,聪明的内核会将调度之前的上一个用户态进程的虚拟内存空间 mm_struct 直接赋值给内核线程,因为内核线程不会访问用户空间的内存,它仅仅只会访问内核空间的内存,所以直接复用上一个用户态进程的虚拟地址空间就可以避免为内核线程分配 mm_struct 和相关页表的开销,以及避免内核线程之间调度时地址空间的切换开销。

        父进程与子进程的区别,进程与线程的区别,以及内核线程与用户态线程的区别其实都是围绕着这个 mm_struct 展开的。

        现在我们知道了跟进程虚拟内存地址管理相关的mm_struct结构体是如何被创建的,那么就要开始深入mm_struct结构内部,看内核是如何通过一个mm_struct来管理进程的虚拟内存空间的。

5.1 内核如何划分用户态和内核态虚拟内存空间

        由于前面的介绍可知,进程的虚拟内存地址空间分为两部分:用户态虚拟内存空间和内核态虚拟内存空间。

        那这用户态和内核态虚拟内存空间在内核中是如何划分的呢?

        这就用到了进程的内存描述符 mm_struct 结构体中的 task_size变量,task_size 定义了用户态虚拟地址空间与内核态虚拟地址空间之间的分界线。

//include/linux/mm_types.h
struct mm_struct {
    unsigned long task_size; /* size of task vm space */
}

        通过前边小节的内容介绍,我们知道在 32 位系统中用户态虚拟内存空间为 3 GB,虚拟内存地址范围为:0x0000 0000 - 0xC000 000 。内核态虚拟内存空间为 1 GB,虚拟内存地址范围为:0xC000 000 - 0xFFFF FFFF。

        32 位系统中用户地址空间和内核地址空间的分界线在 0xC000 000 地址处,那么显然进程的 mm_struct 结构中的 task_size 为 0xC000 000。kernel-5.4关于 TASK_SIZE 的定义,如下所示。

//arch/x86/include/asm/page_32_types.h
/*
 * This handles the memory map.
 *
 * A __PAGE_OFFSET of 0xC0000000 means that the kernel has
 * a virtual address space of one gigabyte, which limits the
 * amount of physical memory you can use to about 950MB.
 *
 * If you want more physical memory than this then see the CONFIG_HIGHMEM4G
 * and CONFIG_HIGHMEM64G options in the kernel configuration.
 */
#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的值在32位系统下的值为0xC000 000。

choice
	prompt "Memory split"
	depends on MMU
	default VMSPLIT_3G //这里
	help
	  Select the desired split between kernel and user memory.

	  If you are not absolutely sure what you are doing, leave this
	  option alone!

	config VMSPLIT_3G //这里
		bool "3G/1G user/kernel split"
	config VMSPLIT_3G_OPT
		depends on !ARM_LPAE
		bool "3G/1G user/kernel split (for full 1G low memory)"
	config VMSPLIT_2G
		bool "2G/2G user/kernel split"
	config VMSPLIT_1G
		bool "1G/3G user/kernel split"
endchoice

config PAGE_OFFSET
	hex
	default PHYS_OFFSET if !MMU
	default 0x40000000 if VMSPLIT_1G
	default 0x80000000 if VMSPLIT_2G
	default 0xB0000000 if VMSPLIT_3G_OPT
	default 0xC0000000  //这里

         而在 64 位系统中,只使用了其中的低 48 位来表示虚拟内存地址。其中用户态虚拟内存空间为低 128 T,虚拟内存地址范围为:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000 。内核态虚拟内存空间为高 128 T,虚拟内存地址范围为:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。

        64 位系统中用户地址空间和内核地址空间的分界线在 0x0000 7FFF FFFF F000 地址处,那么自然进程的 mm_struct 结构中的 task_size 为 0x0000 7FFF FFFF F000 。kernel-5.4在关于 TASK_SIZE的定义,如下图所示。

//arch/x86/include/asm/page_64_types.h
#ifdef CONFIG_X86_5LEVEL
#define __VIRTUAL_MASK_SHIFT	(pgtable_l5_enabled() ? 56 : 47)
#else
#define __VIRTUAL_MASK_SHIFT	47 //这里
#endif

#define TASK_SIZE_MAX	((_AC(1,UL) << __VIRTUAL_MASK_SHIFT) - PAGE_SIZE) //这里

#define DEFAULT_MAP_WINDOW	((1UL << 47) - PAGE_SIZE)

/* This decides where the kernel will search for a free chunk of vm
 * space during mmap's.
 */
#define IA32_PAGE_OFFSET	((current->personality & ADDR_LIMIT_3GB) ? \
					0xc0000000 : 0xFFFFe000)

#define TASK_SIZE_LOW		(test_thread_flag(TIF_ADDR32) ? \
					IA32_PAGE_OFFSET : DEFAULT_MAP_WINDOW)
#define TASK_SIZE		(test_thread_flag(TIF_ADDR32) ? \
					IA32_PAGE_OFFSET : TASK_SIZE_MAX) //这里

        从上面可知,64位X86架构Linux系统中kernel是如何计算这个TASK_SIZE,首先是1左移47位,得到的地址是0x0000 8000 0000 0000,然后减去一个PAGE_SIZE(默认是4K=2^12),相当于减去2^12,那就得到0x0000 7FFF FFFF F000,共128TB。因而在 64 位系统中的 TASK_SIZE 为 0x0000 7FFF FFFF F000 。从这里也可以看出64位虚拟内存空间的布局是和物理内存页 page 的大小有关的,物理内存页 page 默认大小 PAGE_SIZE 为 4KB。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)

        而内核空间的起始地址是 0xFFFF 8000 0000 0000 。在 0x00007FFFFFFFF000 - 0xFFFF 8000 0000 0000 之间的内存区域就是我们在 《4.2 64 位机器上进程虚拟内存空间分布》小节中介绍的 canonical address 空洞。 

        插播一下,在ARM64架构下,32/64位Linux系统中,关于TASK_SIZE定义在arch/arm64/include/asm/processor.h文件中,如下图。而且ARM64架构,根据TTBR0/1_ELx寄存器地址,来区分是用户态/内核态虚拟内存空间。

/*
 * TASK_SIZE - the maximum size of a user space task.
 * TASK_UNMAPPED_BASE - the lower boundary of the mmap VM area.
 */

#define DEFAULT_MAP_WINDOW_64	(UL(1) << VA_BITS_MIN)
 /*这里,vabits_actual代表实际使用地址有效位宽,在kernel内存泄漏分析方法中有介绍。我们这里就是48位,相当于是0x0001 0000 0000 0000,
这个地址用户态虚拟内存区域是取不到的,相当于是数学上的开区间,用户态虚拟内存空间上限就是0X0000 FFFF FFFF FFFF,是上面地址的后一个字节
*/
#define TASK_SIZE_64		(UL(1) << vabits_actual)

#ifdef CONFIG_COMPAT
#if defined(CONFIG_ARM64_64K_PAGES) && defined(CONFIG_KUSER_HELPERS)
/*
 * With CONFIG_ARM64_64K_PAGES enabled, the last page is occupied
 * by the compat vectors page.
 */
#define TASK_SIZE_32		UL(0x100000000)
#else
#define TASK_SIZE_32		(UL(0x100000000) - PAGE_SIZE) //这里
#endif /* CONFIG_ARM64_64K_PAGES */
#define TASK_SIZE		(test_thread_flag(TIF_32BIT) ? \
				TASK_SIZE_32 : TASK_SIZE_64) //这里
#define TASK_SIZE_OF(tsk)	(test_tsk_thread_flag(tsk, TIF_32BIT) ? \
				TASK_SIZE_32 : TASK_SIZE_64)
#define DEFAULT_MAP_WINDOW	(test_thread_flag(TIF_32BIT) ? \
				TASK_SIZE_32 : DEFAULT_MAP_WINDOW_64)
#else
#define TASK_SIZE		TASK_SIZE_64 //这里
#define DEFAULT_MAP_WINDOW	DEFAULT_MAP_WINDOW_64
#endif /* CONFIG_COMPAT */

5.2 内核如何布局进程虚拟内存空间

        在5.1节我们知道了内核利用task_struct->mm_struct->task_size划分进程用户态虚拟内存空间和内核态虚拟内存空间之后,如下图。那么本节我们介绍进程用户态虚拟内存空间在内核是如何划分的。为方便大家理解,只保留了进程用户态虚拟内存空间中的核心区域。

        前边我们提到,内核中采用了一个叫做内存描述符的 mm_struct 结构体来表示进程虚拟内存空间的全部信息。在本小节继续从 mm_struct 结构体内部去寻找相关的线索。

struct mm_struct {
		...... 省略 ........
 		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 */
		atomic64_t    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 */
		...... 省略 ........
		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;
		...... 省略 ........
};

        内核中用 mm_struct 结构体中的上述参数来定义上图中用户态虚拟内存空间里的不同内存区域。

        start_code 和 end_code 定义代码段的起始和结束位置,程序编译后的二进制文件中的机器码被加载进内存之后就存放在这里。

        start_data 和 end_data 定义数据段的起始和结束位置,二进制文件中存放的全局变量和静态变量被加载进内存中就存放在这里。

        后面紧挨着的是 BSS 段,用于存放未被初始化的全局变量和静态变量,这些变量在加载进内存时会生成一段 0 填充的内存区域 (BSS 段), BSS 段的大小是固定的。

        下面就是堆了,在堆中内存地址的增长方向是由低地址向高地址增长,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 结构体中除了上述用于划分虚拟内存区域的变量之外,还定义了一些虚拟内存与物理内存映射内容相关的统计变量,操作系统会把物理内存划分成一页一页的区域来进行管理,所以物理内存到虚拟内存之间的映射也是按照页为单位进行的。这部分内容在Linux物理物理内存管理之三大结构系列文章中有涉及,大家这里只需要有个概念就行。

        mm_struct 结构体中的 total_vm 表示在进程虚拟内存空间中总共与物理内存映射的页的总数注意映射这个概念,它表示只是将虚拟内存与物理内存建立关联关系,并不代表真正的分配物理内存。

        当内存吃紧的时候,有些页可以换出到硬盘上,而有些页因为比较重要,不能换出。locked_vm 就是被锁定不能换出的虚拟内存页总数,pinned_vm 表示既不能换出,也不能移动的虚拟内存页总数。

        data_vm 表示数据段中映射的虚拟内存页数目,exec_vm 是代码段中存放可执行文件的虚拟内存页数目,stack_vm 是栈中所映射的虚拟内存页数目,这些变量均是表示进程虚拟内存空间中的虚拟内存使用情况。

5.3 内核如何管理虚拟内存区域

        从5.1和5.2节,我们知道内核通过 task_size 域来划分用户态虚拟内存空间和内核态虚拟内存空间,并通过mm_struct结构体对进程用户态虚拟内存空间进行布局。本节开始介绍内核如何管理进程用户态虚拟内存空间。

        根据前面小节,我们知道内核将进程用户态虚拟内存空间布局划分成如上图所示。但是这些内存区域在内核中有时如何表示的呢?拿什么来管理这里区域?

        内核使用了vm_area_struct结构体来管理这些虚拟内存区域VMA(virtual memory area)。下面只列出本文涉及的参数成员。

/*
 * This struct describes a virtual memory area. There is one of these
 * per VM-area/task. A VM area is any part of the process virtual memory
 * space that has a special rule for the page-fault handlers (ie a shared
 * library, the executable area etc).
 */
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;    /* Flags, see mm.h. */
	...... 省略 ........
	struct anon_vma *anon_vma; /* Serialized by page_table_lock */
	/* Function pointers to deal with this struct. */
	const struct vm_operations_struct *vm_ops;
	unsigned long vm_pgoff;  /* Offset (within vm_file) in PAGE_SIZE
			units */ 
	struct file * vm_file;  /* File we map to (can be NULL). */
	void * vm_private_data;  /* was vm_pte (shared mem) */
	...... 省略 ........
}

        每个 vm_area_struct 结构对应于虚拟内存空间中的唯一虚拟内存区域 VMAvm_start 指向了这块虚拟内存区域的起始地址(最低地址),vm_start 本身包含在这块虚拟内存区域内。vm_end 指向了这块虚拟内存区域的结束地址(最高地址),而 vm_end 本身包含在这块虚拟内存区域之外,所以 vm_area_struct 结构描述的是 [vm_start,vm_end) 这样一段左闭右开的虚拟内存区域。

5.4 定义虚拟内存区域的访问权限和行为规范

        vm_page_prot 和 vm_flags 都是用来标记 vm_area_struct 结构表示的这块虚拟内存区域的访问权限和行为规范。但有所差异,下面会讲。

        上边小节中我们也提到,内核会将整块物理内存划分为一页一页大小的区域,以页为单位来管理这些物理内存,每页大小默认 4K 。而虚拟内存最终也是要和物理内存一一映射起来的,所以在虚拟内存空间中也有虚拟页的概念与之对应,虚拟内存中的虚拟页映射到物理内存中的物理页。无论是在虚拟内存空间中还是在物理内存中,内核管理内存的最小单位都是页。不过在物理内存中,我们习惯叫页帧。

        vm_page_prot 偏向于定义底层内存管理架构中页这一级别的访问控制权限,它可以直接应用在底层页表中,它是一个具体的概念。页表用于管理虚拟内存到物理内存之间的映射关系,这部分内容后续会详细讲解,涉及MMU,这里暂时不讲。

        虚拟内存区域 VMA 由许多的虚拟页 (page) 组成,每个虚拟页需要经过页表的转换才能找到对应的物理页面。页表中关于内存页的访问权限就是由 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内存区域可能被随机访问

        VM_READ,VM_WRITE,VM_EXEC 定义了虚拟内存区域是否可以被读取,写入,执行等权限。比如代码段这块内存区域的权限是可读,可执行,但是不可写。数据段具有可读可写的权限但是不可执行。堆则具有可读可写,可执行的权限(Java 中的字节码存储在堆中,所以需要可执行权限),栈一般是可读可写的权限,一般很少有可执行权限。而文件映射与匿名映射区存放了共享链接库,所以也需要可执行的权限。

        VM_SHARD 用于指定这块虚拟内存区域映射的物理内存是否可以在多进程之间共享,以便完成进程间通讯。设置这个值即为 mmap 的共享映射,不设置的话则为私有映射。这个等后面我们讲到 mmap 的相关实现时还会再次提起。

        VM_IO 的设置表示这块虚拟内存区域可以映射至设备 IO 空间中。通常在设备驱动程序执行 mmap 进行 IO 空间映射时才会被设置。

        VM_RESERVED 的设置表示在内存紧张的时候,这块虚拟内存区域非常重要,不能被换出到磁盘中。

        VM_SEQ_READ 的设置用来暗示内核,应用程序对这块虚拟内存区域的读取是会采用顺序读的方式进行,内核会根据实际情况决定预读后续的内存页数,以便加快下次顺序访问速度。因为是顺序读,下次读取命中的概率高,因而可提前预读。

        VM_RAND_READ 的设置会暗示内核,应用程序会对这块虚拟内存区域进行随机读取,内核则会根据实际情况减少预读的内存页数甚至停止预读。
        我们可以通过 posix_fadvise,madvise 系统调用来暗示内核是否对相关内存区域进行顺序读取或者随机读取。相关的详细内容,大家可以看下 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》中的第 9 小节文件页预读部分。

        通过这一系列的介绍,我们可以看到 vm_flags 就是定义整个虚拟内存区域的访问权限以及行为规范,而内存区域中内存的最小单位为页(4K),虚拟内存区域中包含了很多这样的虚拟页,对于虚拟内存区域 VMA 设置的访问权限也会全部复制到区域中包含的内存页中。

5.5 关联内存映射中的映射关系

        接下来的三个属性 anon_vma,vm_file,vm_pgoff 分别和虚拟内存映射相关,虚拟内存区域可以映射到物理内存上,也可以映射到文件中,映射到物理内存上我们称之为匿名映射,映射到文件中我们称之为文件映射。
        那么这个映射关系在内核中该如何表示呢?这就用到了 vm_area_struct 结构体中的上述三个属性。

        当我们调用 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 中的私有数据。具体的存储内容和内存映射的类型有关,我们暂不展开论述。

5.6 针对虚拟内存区域的相关操作

        struct vm_area_struct 结构中还有一个 vm_ops 用来指向针对虚拟内存区域 VMA 的相关操作的函数指针。

//include/linux/mm.h 
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 函数会被调用。
        struct vm_operations_struct 结构中定义的都是对虚拟内存区域 VMA 的相关操作函数指针。内核中这种类似的用法其实有很多,在内核中每个特定领域的描述符都会定义相关的操作。比如文章《从 Linux 内核角度探秘 JDK NIO 文件读写本质》中介绍的内核文件描述符 struct file 中定义的 struct file_operations *f_op。

5.7 虚拟内存区域在内核中是如何被组织的

        在上一小节中,我们介绍了内核中用来表示虚拟内存区域 VMA 的结构体 struct vm_area_struct ,并详细为大家剖析了 struct vm_area_struct 中一些用于管理虚拟内存区域的重要关键参数。

        现在我们已经熟悉了这些虚拟内存区域,那么接下来的问题就是在内核中这些虚拟内存区域是如何被组织的呢?

        同样继续来到 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. */
 
    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;
}

        在kernel中,其是通过一个struct vm_area_struct *类型的双向链表将进程用户态虚拟内存空间中这些虚拟内存区域VMA串联起来的。

        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。环环相扣,牛逼......

//include/linux/mm_types.h 
struct mm_struct {
    struct vm_area_struct *mmap;  /* list of VMAs */
}

        根据如上描述,得到的示意图如下: 

        我们可以通过 cat /proc/pid/maps 或者 pmap pid 查看进程的虚拟内存空间布局以及其中包含的所有内存区域。两个命令背后的实现原理就是通过遍历内核中的这个 vm_area_struct 双向链表(struct vm_area_struct *mmap)获取的。

        内核中关于这些虚拟内存区域的操作除了遍历之外还有许多需要根据特定虚拟内存地址在虚拟内存空间中查找特定的虚拟内存区域。

        尤其在进程虚拟内存空间中包含的内存区域 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;
}

六、程序编译后的二进制文件如何映射到虚拟内存空间中

        从上面也可知,struct mm_struct结构负责进程用户态虚拟内存空间的布局和划分,我们知道了其包含哪些虚拟内存区域。而struct vm_area_struct结构负责这些虚拟内存区域的管理和组织,这些虚拟内存区域的访问权限,行为规范,映射关系和对虚拟内存区域的相关操作等均由vm_area_struct结构负责,对进程的虚拟内存空间有了一个完整全面的认识。。

        现在我们再来回到最初的起点,进程的虚拟内存空间 mm_struct 以及这些虚拟内存区域 vm_area_struct 是如何被创建并初始化的呢?

        在《三、进程虚拟内存空间》小节中,我们介绍进程的虚拟内存空间时提到,我们写的程序代码编译之后会生成一个 ELF 格式的二进制文件(ELF文件可以在Linux下运行,但不能裸机运行),这个二进制文件中包含了程序运行时所需要的元信息,比如程序的机器码,程序中的全局变量以及静态变量等。

        这个 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 的格式之外,另外一个重要的事情就是建立上述提到的内存映射。

//fs/binfmt_elf.c
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;

     ...... 省略 ........
}
  • setup_new_exec 设置虚拟内存空间中的文件映射与匿名映射区域起始地址 mmap_base
  • setup_arg_pages 创建并初始化栈对应的 vm_area_struct 结构。置 mm->start_stack 就是栈的起始地址也就是栈底,并将 mm->arg_start 是指向栈底的。
  • elf_map 将 ELF 格式的二进制文件中.text ,.data,.bss 部分映射到虚拟内存空间中的代码段,数据段,BSS 段中。
  • set_brk 创建并初始化堆对应的的 vm_area_struct 结构,设置 current->mm->start_brk = current->mm->brk,设置堆的起始地址 start_brk,结束地址 brk。 起初两者相等表示堆是空的。
  • load_elf_interp 将进程依赖的动态链接库 .so 文件映射到虚拟内存空间中的内存映射区域
  • 初始化内存描述符 mm_struct

七、内核虚拟内存空间

        现在我们已经知道了进程用户态虚拟内存空间在内核中的布局以及管理,那么内核态的虚拟内存空间又是什么样子的呢?本小节将拆开这个黑盒。

        之前在介绍进程虚拟内存空间的时候,笔者提到不同进程之间的用户态虚拟内存空间是相互隔离的,彼此之间相互独立,相互感知不到其他进程的存在。使得进程以为自己拥有所有的内存资源。而内核态虚拟内存空间是所有进程共享的,不同进程进入内核态之后看到的虚拟内存空间全部是一样的。

        什么意思呢?比如上图中的进程 a,进程 b,进程 c 分别在各自的用户态虚拟内存空间中访问虚拟地址 x 。由于进程之间的用户态虚拟内存空间是相互隔离相互独立的,虽然在进程a,进程b,进程c 访问的都是虚拟地址 x 但是看到的内容却是不一样的(背后可能映射到不同的物理内存中)。
        但是当进程 a,进程 b,进程 c 进入到内核态之后情况就不一样了,由于内核虚拟内存空间是各个进程共享的,所以它们在内核空间中看到的内容全部是一样的,比如进程 a,进程 b,进程 c 在内核态都去访问虚拟地址 y。这时它们看到的内容就是一样的了。注意:内核态也是有虚拟内存空间的,不是只有物理内存空间,进入内核态,仍然使用的是虚拟内存地址。

        有上面基础后,下面基于X86架构分别从 32 位体系 和 64 位体系下为大家介绍内核态虚拟内存空间的布局。

7.1 32 位体系内核虚拟内存空间布局

        在前边《5.1 内核如何划分用户态和内核态虚拟内存空间》小节中我们提到,内核在page_32_types.h 文件中通过 TASK_SIZE 将进程用户态虚拟内存空间和内核态虚拟内存空间分割开来。TASK_SIZE为0xC000 000, 0xC000 000 - 0xFFFF FFFF 这段虚拟内存地址区域也就是进程内核态虚拟内存空间的布局情况。

7.1.1 直接映射区

        在总共大小 1G 的内核虚拟内存空间中,位于最前边有一块 896M 大小的区域,我们称之为直接映射区或者线性映射区,地址范围为 3G – 3G + 896m 。

        之所以这块 896M 大小的区域称为直接映射区或者线性映射区,是因为这块连续的虚拟内存地址会映射到 0 - 896M 这块连续的物理内存上。
        也就是说 3G – 3G + 896m 这块 896M 大小的虚拟内存会直接映射到 0 - 896M 这块 896M 大小的物理内存上,这块区域中的虚拟内存地址直接减去 0xC000 0000 (3G) 就得到了物理内存地址。所以我们称这块区域为直接映射区。为了方便理解,这里我们假设机器上的物理内存为4G大小。

        注意:虽然这块区域中的虚拟地址是直接映射到物理地址上,但是内核在访问这段区域的时候还是走的虚拟内存地址,内核也会为这块空间建立映射页表。于页表的概念笔者后续会为大家详细讲解,这里大家只需要简单理解为页表保存了虚拟地址到物理地址的映射关系即可。

        这里只需要记得内核态虚拟内存空间的前 896M 区域是直接映射到物理内存中的前 896M 区域中的,直接映射区中的映射关系是一比一映射。映射关系是固定的不会改变

        接下来就看一下这块直接映射区域在物理内存中究竟存的是什么内容。

        在这段 896M 大小的物理内存中,前 1M 已经在系统启动的时候被系统占用,1M 之后的物理内存存放的是内核代码段,数据段,BSS 段(这些信息起初存放在 ELF格式的二进制文件中,在系统启动的时候被加载进内存)。可以通过 cat /proc/iomem 命令查看具体物理内存布局情况。

        当我们使用 fork 系统调用创建进程的时候,内核会创建一系列进程相关的描述符,比如之前提到的进程的核心数据结构 task_struct,进程的内存空间描述符 mm_struct,以及虚拟内存区域描述符 vm_area_struct 等。

        这些进程相关的数据结构也会存放在物理内存前 896M 的这段区域中,当然也会被直接映射至内核态虚拟内存空间中的 3G – 3G + 896m 这段直接映射区域中。

        当进程被创建完毕之后,在内核运行的过程中,会涉及内核栈的分配,内核会为每个进程分配一个固定大小的内核栈(一般是两个页大小,依赖具体的体系结构,以4K为例,则大小就是16KB),每个进程的整个调用链必须放在自己的内核栈中,内核栈也是分配在直接映射区。实际上,内核线程也是有自己的内核栈的,也是两个页大小,16KB,如前面所述,因为内核是不区分线程和进程的,线程就是一个共享特定资源的特殊进程。

        与进程用户空间中的栈不同的是,内核栈容量小而且是固定的,用户空间中的栈容量大而且可以动态扩展内核栈的溢出危害非常巨大,它会直接悄无声息的覆盖相邻内存区域中的数据,破坏数据。

        通过以上内容的介绍我们了解到内核虚拟内存空间最前边的这段 896M 大小的直接映射区如何与物理内存进行映射关联(即:直接/线性映射),并且清楚了直接映射区主要用来存放哪些内容即:系统启动占用,内核代码段,数据段,BSS段,进程内核栈)。

        写到这里,笔者觉得还是有必要再次从功能划分的角度为大家介绍下这块直接映射区域。

        我们都知道内核对物理内存的管理都是以页为最小单位来管理的,每页默认 4K 大小,理想状况下任何种类的数据页都可以存放在任何页框中,没有什么限制。比如:存放内核数据,用户数据,缓冲磁盘数据等。但是实际的计算机体系结构受到硬件方面的限制制约,间接导致限制了页框的使用方式。

        比如在 X86 体系结构下,ISA 总线的 DMA (直接内存存取)控制器,只能对内存的前16M 进行寻址,这就导致了 ISA 设备不能在整个 32 位地址空间中执行 DMA,只能使用物理内存的前 16M 进行 DMA 操作。因此直接映射区的前 16M 专门让内核用来为 DMA 分配内存,这块 16M 大小的内存区域我们称之为 ZONE_DMA。用于 DMA 的内存必须从 ZONE_DMA 区域中分配。

        而直接映射区中剩下的部分也就是从 16M 到 896M(不包含 896M)这段区域,我们称之为 ZONE_NORMAL。从字面意义上我们可以了解到,这块区域包含的就是正常的页框(使用没有任何限制)。ZONE_NORMAL 由于也是属于直接映射区的一部分,对应的物理内存 16M 到 896M 这段区域也是被直接映射至内核态虚拟内存空间中的 3G + 16M 到 3G + 896M 这段虚拟内存上。

        注意:ZONE_DMA 和 ZONE_NORMAL 是内核针对物理内存区域的划分,涉及到Linux物理内存管理,这里知道即可。

        现在物理内存中的前 896M 的区域也就是前边介绍的 ZONE_DMA 和 ZONE_NORMAL 区域到内核虚拟内存空间的映射笔者就为大家介绍完了,它们都是采用直接映射的方式,一比一映射。

7.1.2 ZONE_HIGHMEM 高端内存

        而物理内存 896M 以上的区域被内核划分为 ZONE_HIGHMEM 区域,我们称之为高端内存。

        本例中我们的物理内存假设为 4G,高端内存区域为 4G - 896M = 3200M,那么这块 3200M 大小的 ZONE_HIGHMEM 区域该如何映射到内核虚拟内存空间中呢?

        由于内核虚拟内存空间中的前 896M 虚拟内存已经被直接映射区所占用,而在 32 体系结构下内核虚拟内存空间总共也就 1G 的大小,这样一来内核剩余可用的虚拟内存空间就变为了 1G - 896M = 128M

        显然物理内存中 3200M 大小的 ZONE_HIGHMEM 区域无法继续通过直接映射的方式映射到这 128M 大小的虚拟内存空间中。

        这样一来物理内存中的 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 文件中:

/*
 * Just any arbitrary offset to the start of the vmalloc VM area: the
 * current 8MB value just means that there will be a 8MB "hole" after the
 * physical memory until the kernel virtual memory starts.  That means that
 * any out-of-bounds memory accesses will hopefully be caught.
 * The vmalloc() routines leaves a hole of 4kB between each vmalloced
 * area for the same reason. ;)
 */
#define VMALLOC_OFFSET (8 * 1024 * 1024)

#define VMALLOC_START ((unsigned long)high_memory + VMALLOC_OFFSET)

7.1.3 vmalloc 动态映射区

        接下来 VMALLOC_START 到 VMALLOC_END 之间的这块区域成为动态映射区。采用动态映射的方式映射物理内存中的高端内存。

//arch/x86/include/asm/pgtable_32_areas.h 
#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 分配的内存在虚拟内存上是连续的,但是物理内存是不连续的。通过页表来建立物理内存与虚拟内存之间的映射关系,从而可以将不连续的物理内存映射到连续的虚拟内存上。由于 vmalloc 获得的物理内存页是不连续的,因此它只能将这些物理内存页一个一个地进行映射,在性能开销上会比直接映射大得多。

        关于 vmalloc 分配内存的相关实现原理,在后面的文章中为大家讲解,这里大家只需要明白它在哪块虚拟内存区域中活动即可。

7.1.4 永久映射区

        而在 PKMAP_BASE 到 FIXADDR_START 之间的这段空间称为永久映射区。在内核的这段虚拟地址空间中允许与物理高端内存建立长期映射关系。比如内核通过 alloc_pages() 函数在物理内存的高端内存中申请获取到的物理内存页,这些物理内存页可以通过调用 kmap 映射到永久映射区中,通过kunmap可以解除永久映射。LAST_PKMAP 表示永久映射区可以映射的页数限制。更多关于这些参数的信息,可以参考一文掌握 Linux 内存管理文档。

#define PKMAP_BASE  \
 ((LDT_BASE_ADDR - PAGE_SIZE) & PMD_MASK)

#define LAST_PKMAP 1024

7.1.5 固定映射区

        内核虚拟内存空间中的下一个区域为固定映射区,区域范围为:FIXADDR_START 到 FIXADDR_TOP,如下是代码中的定义。

//arch/x86/mm/pgtable_32.c 
unsigned long __FIXADDR_TOP = 0xfffff000;
EXPORT_SYMBOL(__FIXADDR_TOP);

//arch/x86/include/asm/fixmap.h 
/*
 * We can't declare FIXADDR_TOP as variable for x86_64 because vsyscall
 * uses fixmaps that relies on FIXADDR_TOP for proper address calculation.
 * Because of this, FIXADDR_TOP x86 integration was left as later work.
 */
#ifdef CONFIG_X86_32
/*
 * Leave one empty page between vmalloc'ed areas and
 * the start of the fixmap.
 */
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_SIZE		(__end_of_permanent_fixed_addresses << PAGE_SHIFT)
#define FIXADDR_START		(FIXADDR_TOP - FIXADDR_SIZE) //这里

        在内核虚拟内存空间的直接映射区中,直接映射区中的虚拟内存地址与物理内存前 896M 的空间的映射关系都是预设好的,一比一映射。

        在固定映射区中的虚拟内存地址可以自由映射到物理内存的高端地址上,但是与动态映射区以及永久映射区不同的是,在固定映射区中虚拟地址是固定的,而被映射的物理地址是可以改变的。也就是说,有些虚拟地址在编译的时候就固定下来了,是在内核启动过程中被确定的,而这些虚拟地址对应的物理地址不是固定的。采用固定虚拟地址的好处是它相当于一个指针常量(常量的值在编译时确定),指向物理地址,如果虚拟地址不固定,则相当于一个指针变量。

        那为什么会有固定映射这个概念呢 ? 比如:在内核的启动过程中,有些模块需要使用虚拟内存并映射到指定的物理地址上,而且这些模块也没有办法等待完整的内存管理模块初始化之后再进行地址映射。因此,内核固定分配了一些虚拟地址,这些地址有固定的用途,使用该地址的模块在初始化的时候,将这些固定分配的虚拟地址映射到指定的物理地址上去。在《kernel内存泄漏分析方法》的1.1.2节也要介绍这个映射区

7.1.6 临时映射区

        在内核虚拟内存空间中的最后一块区域为临时映射区,那么这块临时映射区是用来干什么的呢?

        临时内核映射区主要用于当必须创建一个映射而当前的上下文又不能睡眠,内核提供了临时映射(也就是所谓的原子映射)。有一组保留的映射,可以存放新创建的临时映射。内核可以原子地把ZONE_HIGMEM高端内存中的一个页映射到某个保留的映射中。因此,临时映射可以用在不能睡眠的地方,比如中断处理程序中,因为获取映射时绝不会阻塞。从其使用的建立临时映射kmap_atomic和解除临时映射kunmap_atomic函数也可知,操作是原子操作,不能被抢占,不能被睡眠。由于是临时映射,当然用完之后,立马就要用kunmap_atomic解除映射,和kmap_atomic配对使用。

void *kmap_atomic(struct *page, enum kmtype type);
void kunmap_atomic(void *kvaddr, enum km_type type);

7.1.7 32位体系结构下 Linux 虚拟内存空间整体布局

        到此为止,整个内核态虚拟内存空间在x86架构32位Linux系统的布局,已经详细介绍完毕了。结合前边 4.1 节 《32位Linux系统进程虚拟内存空间分布》中介绍的进程用户态虚拟内存空间和前面7.1节介绍的内核态虚拟内存空间来整体回顾下x86架构32位Linux系统的整个虚拟内存空间的布局:

7.2 64 位体系内核虚拟内存空间布局

        内核态虚拟内存空间在 32 位体系下只有 1G 大小,实在太小了,因此需要精细化的管理,于是按照功能分类划分除了很多内核虚拟内存区域,这样就显得非常复杂。

        到了 64 位体系下,内核虚拟内存空间的布局和管理就变得容易多了,因为进程虚拟内存空间和内核虚拟内存空间各自占用 128T 的虚拟内存,实在是太大了,我们可以在这里边随意翱翔,随意挥霍。

        因此在 64 位体系下的内核虚拟内存空间与物理内存的映射就变得非常简单,由于虚拟内存空间足够的大,即便是内核要访问全部的物理内存,直接映射就可以了,不在需要用到《7.1.2 ZONE_HIGHMEM 高端内存》小节中介绍的高端内存那种动态映射方式。

        在前边《5.1 内核如何划分用户态和内核态虚拟内存空间》小节中我们提到,内核在 /arch/x86/include/asm/page_64_types.h 文件中通过 TASK_SIZE 将进程虚拟内存空间和内核虚拟内存空间分割开来。x86架构下,64位系统TASK_SIZE 为 0x0000 7FFF FFFF F000。

        用户态虚拟内存空间前面4.2 节 《64位Linux系统进程虚拟内存空间分布》已经介绍,本节主要介绍0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 这段内核虚拟内存空间的布局情况。直接上图,如下所示。

        64 位内核虚拟内存空间从 0xFFFF 8000 0000 0000 开始到 0xFFFF 8800 0000 0000 这段地址空间是一个 8T 大小的内存空洞区域。紧接着 8T 大小的内存空洞下一个区域就是 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

//arch/x86/include/asm/page_types.h
#define PAGE_OFFSET		((unsigned long)__PAGE_OFFSET)

         从图中 VMALLOC_START 到 VMALLOC_END 的这段区域是 32T 大小的 vmalloc 映射区,这里类似用户空间中的堆,内核在这里使用 vmalloc 系统调用申请内存。VMALLOC_START 和 VMALLOC_END 变量定义如下代码所示:

//arch/x86/include/asm/pgtable_64_types.h
#define __VMALLOC_BASE_L4	0xffffc90000000000UL
#define __VMALLOC_BASE_L5 	0xffa0000000000000UL

#define VMALLOC_SIZE_TB_L4	32UL
#define VMALLOC_SIZE_TB_L5	12800UL

#define __VMEMMAP_BASE_L4	0xffffea0000000000UL
#define __VMEMMAP_BASE_L5	0xffd4000000000000UL

//arch/x86/kernel/head64.c
#ifdef CONFIG_DYNAMIC_MEMORY_LAYOUT
unsigned long page_offset_base __ro_after_init = __PAGE_OFFSET_BASE_L4;
EXPORT_SYMBOL(page_offset_base);
unsigned long vmalloc_base __ro_after_init = __VMALLOC_BASE_L4; //这里
EXPORT_SYMBOL(vmalloc_base);
unsigned long vmemmap_base __ro_after_init = __VMEMMAP_BASE_L4; //这里
EXPORT_SYMBOL(vmemmap_base);
#endif

//arch/x86/include/asm/pgtable_64_types.h
#ifdef CONFIG_DYNAMIC_MEMORY_LAYOUT
# define VMALLOC_START		vmalloc_base //这里
# define VMALLOC_SIZE_TB	(pgtable_l5_enabled() ? VMALLOC_SIZE_TB_L5 : VMALLOC_SIZE_TB_L4)
# define VMEMMAP_START		vmemmap_base //这里
#else
# define VMALLOC_START		__VMALLOC_BASE_L4
# define VMALLOC_SIZE_TB	VMALLOC_SIZE_TB_L4
# define VMEMMAP_START		__VMEMMAP_BASE_L4
#endif /* CONFIG_DYNAMIC_MEMORY_LAYOUT */

#define VMALLOC_END		(VMALLOC_START + (VMALLOC_SIZE_TB << 40) - 1) //这里

        从 VMEMMAP_START 开始是 1T 大小的虚拟内存映射区,用于存放物理页面的描述符 struct page 结构用来表示物理内存页。VMEMMAP_START 变量定义在如上代码所示。

        从 __START_KERNEL_map 开始是大小为 512M 的区域用于存放内核代码段、全局变量、BSS 等这里对应到物理内存开始的位置,减去 __START_KERNEL_map 就能得到物理内存的地址。这里和直接映射区有点像,但是不矛盾,因为直接映射区之前有 8T 的空洞区域,早就过了内核代码在物理内存中加载的位置。__START_KERNEL_map 变量定义如下代码所示。

//arch/x86/include/asm/page_64_types.h
#define __START_KERNEL_map  _AC(0xffffffff80000000, UL)

7.2.1 64位体系结构下 Linux 虚拟内存空间整体布局

        到此为止,整个内核态虚拟内存空间在x86架构64位Linux系统的布局,已经详细介绍完毕了。结合前边 4.2 节 《64位Linux系统进程虚拟内存空间分布》中介绍的进程用户态虚拟内存空间和前面7.2节介绍的内核态虚拟内存空间来整体回顾下x86架构64位Linux系统的整个虚拟内存空间的布局: 

八、到底什么是物理内存地址

        聊完了虚拟内存,我们接着聊一下物理内存。这里说下物理内存的定义和要求。注意点:前面的256T都是针对虚拟内存空间的寻址范围,不代表物理内存也这么大。正常使用时,所有进程都在线访问挤爆物理内存的概率也很低,平均下来都是分时需求,因而物理内存可以不用这么大,手机目前最大也就16GB,普通电脑16/32GB也够用了。

        所谓物理内存指:计算机在运行时,需要使用的数据的存放介质。

        物理内存满足的要求:数据读写速度快;断电数据消失,通过一定频率的电流刷新维持内部状态,断电后状态会恢复初始,即数据消失;价格相对于nand、emmc和UFS等闪存贵,但比SRAM便宜;

        根据上述特性,在计算机系统中,该部分被用做运行内存,即上电后将数据从存储介质中拷贝到运行内存,提供给到CPU使用;

        运行内存使用的器件属于随机访问存储器( random-access memory )也叫 RAM 。而 RAM 分为两类:

  • 一类是静态 RAM( SRAM ),这类 SRAM 用于 CPU 高速缓存 L1Cache,L2Cache,L3Cache。其特点是访问速度快,访问速度为 1 - 30 个时钟周期,但是容量小,造价高

  • 另一类则是动态 RAM ( DRAM ),这类 DRAM 用于我们常说的主存(运行内存)上,其特点的是访问速度慢(相对高速缓存),访问速度为 50 - 200 个时钟周期,但是容量大,造价便宜些(相对高速缓存)无论是手机,电脑还是服务器,现在用的都是DDR SDRAM(Double Data Rate Synchronous Dynamic Random Access Memory,双倍数据率同步动态随机存取存储器)。关于DDR介绍可参考:内存管理之DDR概念理解DDR问题分析调试 

      主存由一个个的存储器模块(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,列访问选通脉冲)。下图中的 supercell 的 RAS = 2,CAS = 2。

        DRAM 芯片中的信息通过引脚流入流出 DRAM 芯片。每个引脚携带 1 bit的信号。

        图中 DRAM 芯片包含了两个地址引脚( addr ),因为我们要通过 RAS,CAS 来定位要获取的 supercell 。还有 8 个数据引脚(data),因为 DRAM 芯片的 IO 单位为一个字节(8 bit),所以需要 8 个 data 引脚从 DRAM 芯片传入传出数据。注意这里只是为了解释地址引脚和数据引脚的概念,实际硬件中的引脚数量是不一定的。

8.1 DRAM 芯片的访问

        现在就以读取上图中坐标地址为(2,2)的 supercell 为例,来说明访问 DRAM 芯片的过程。

  1. 首先存储控制器将行地址 RAS = 2 通过地址引脚发送给 DRAM 芯片。
  2. DRAM 芯片根据 RAS = 2 将二维矩阵中的第二行的全部内容拷贝到内部行缓冲区中。
  3. 接下来存储控制器会通过地址引脚发送 CAS = 2 到 DRAM 芯片中。
  4. DRAM芯片从内部行缓冲区中根据 CAS = 2 拷贝出第二列的 supercell 并通过数据引脚发送给存储控制器。

注意:DRAM 芯片的 IO 单位为一个 supercell ,也就是一个字节(8 bit)。

8.2 CPU 如何读写主存

        前边我们介绍了内存的物理结构,以及如何访问内存中的 DRAM 芯片获取 supercell 中存储的数据(一个字节)。本小节我们来介绍下 CPU 是如何访问内存的:

        CPU 与内存之间的数据交互是通过总线(bus)完成的,而数据在总线上的传送是通过一系列的步骤完成的,这些步骤称为总线事务(bus transaction)。

        其中数据从内存传送到 CPU 称之为读事务(read transaction),数据从 CPU 传送到内存称之为写事务(write transaction)。

        总线上传输的信号包括:地址信号,数据信号,控制信号。其中控制总线上传输的控制信号可以同步事务,并能够标识出当前正在被执行的事务信息:

  • 当前这个事务是到内存的?还是到磁盘的?或者是到其他 IO 设备的?
  • 这个事务是读还是写?
  • 总线上传输的地址信号(物理内存地址),还是数据信号(数据)?。

        这里大家需要注意总线上传输的地址均为物理内存地址。比如:在 MESI 缓存一致性协议中当 CPU core0 修改字段 a 的值时,其他 CPU 核心会在总线上嗅探字段 a 的物理内存地址,如果嗅探到总线上出现字段 a 的物理内存地址,说明有人在修改字段 a,这样其他 CPU 核心就会失效字段 a 所在的 cache line 。

        如上图所示,其中系统总线是连接 CPU 与 IO bridge 的,存储总线是来连接 IO bridge 和主存的。IO bridge 负责将系统总线上的电子信号转换成存储总线上的电子信号。IO bridge 也会将系统总线和存储总线连接到IO总线(磁盘等IO设备)上。这里我们看到 IO bridge 其实起的作用就是转换不同总线上的电子信号。

8.3 CPU 从内存读取数据过程

        假设 CPU 现在需要将物理内存地址为 A 的内容加载到寄存器中进行运算,这个过程是怎么样的?

        大家需要注意的是 CPU 只会访问虚拟内存地址,在操作系统总线之前,需要把虚拟内存地址转换为物理内存地址,总线上传输的都是物理内存地址,这里省略了虚拟内存地址到物理内存地址的转换过程(MMU)介绍,这部分内容我在Linux内存管理概述的2.1.1节有涉及,原理大致如下,后面也会单独出文章详解,这里我们聚焦如何通过物理内存地址读取内存数据。

        下面就是CPU从物理内存地址A读取数据过程示意图。

        首先 CPU 芯片中的总线接口会在总线上发起读事务(read transaction),前面提及的总线事务的一种。该读事务分为以下步骤进行: 

  1. CPU 将物理内存地址 A 放到系统总线上。随后 IO bridge 将信号传递到存储总线上。

  2. 主存感受到存储总线上的地址信号并通过存储控制器将存储总线上的物理内存地址 A 读取出来。

  3. 存储控制器通过物理内存地址 A 定位到具体的存储器模块,从 DRAM 芯片中取出物理内存地址 A 对应的数据 X。

  4. 存储控制器将读取到的数据 X 放到存储总线上,随后 IO bridge 将存储总线上的数据信号转换为系统总线上的数据信号,然后继续沿着系统总线传递。

  5. CPU 芯片感受到系统总线上的数据信号,将数据从系统总线上读取出来并拷贝到寄存器中。

        以上就是 CPU 读取内存数据到寄存器中的完整过程。

        但是其中还涉及到一个重要的过程,这里我们还是需要摊开来介绍一下,那就是存储控制器如何通过物理内存地址 A 从主存中读取出对应的数据 X 的?

        接下来我们结合前边介绍的内存结构以及从 DRAM 芯片读取数据的过程,来总体介绍下如何从主存中读取数据。

8.4 如何根据物理内存地址从主存中读取数据

        8.3节我们知道,当主存中存储控制器在存储总线上读取到了地址信号时,控制模块会根据地址信号,定位到具体存储器模块的具体DRAM芯片。本节就详细讲解一下存储控制器如何通过物理内存地址 A 从主存中读取出对应的数据 X 的?主存的构成大致如下。

        而每个存储器模块中包含了 8 个 DRAM 芯片,编号从 0 - 7 。

        存储控制器会将物理内存地址转换为 DRAM 芯片中 supercell 在二维矩阵中的坐标地址(RAS,CAS)。并将这个坐标地址发送给对应的存储器模块。随后存储器模块会将 RAS 和 CAS 广播到存储器模块中的所有 DRAM 芯片。依次通过 (RAS,CAS) 从 DRAM0 到 DRAM7 读取到相应的 supercell 。

        我们知道一个 supercell 存储了一个字节( 8 bit ) 数据这里我们从 DRAM0 到 DRAM7 依次读取到了 8 个 supercell 也就是 8 个字节,然后将这 8 个字节返回给存储控制器,由存储控制器将数据放到存储总线上。所以在物理内存地址对应的存储器模块中,DRAM0 芯片存储第一个低位字节( supercell ),DRAM1 芯片存储第二个字节,…依次类推 DRAM7 芯片存储最后一个高位字节。

        注意:CPU 总是以 word size 为单位从内存中读取数据,在 64 位处理器中的 word size 为 8 个字节。64 位的内存每次只能吞吐 8 个字节。CPU 每次会向内存读写一个 cache line 大小的数据( 64 个字节),但是内存一次只能吞吐 8 个字节。因而CPU每次向内存读取一个cache line时,内存需要吞吐8次。

        由于存储器模块中这种由 8 个 DRAM 芯片组成的物理存储结构的限制,内存读取数据只能是按照物理内存地址,8 个字节 8 个字节地顺序读取数据。所以说内存一次读取和写入的单位是 8 个字节。由此我们也知道,在程序员眼里连续的物理内存地址实际上在物理上是不连续的。因为这连续的 8 个字节其实是存储于不同的 DRAM 芯片上的。每个 DRAM 芯片存储一个字节(supercell)

8.5 CPU 向内存写入数据过程

        我们现在假设 CPU 要将寄存器中的数据 X 写到物理内存地址 A 中。同样的道理,CPU 芯片中的总线接口会向总线发起写事务(write transaction)。写事务步骤如下:

  1. CPU 将要写入的物理内存地址 A 放入系统总线上。

  2. 通过 IO bridge 的信号转换,将物理内存地址 A 传递到存储总线上。

  3. 存储控制器感受到存储总线上的地址信号,将物理内存地址 A 从存储总线上读取出来,并等待数据的到达。

  4. CPU 将寄存器中的数据拷贝到系统总线上,通过 IO bridge 的信号转换,将数据传递到存储总线上。

  5. 存储控制器感受到存储总线上的数据信号,将数据从存储总线上读取出来。

  6. 存储控制器通过内存地址 A 定位到具体的存储器模块,最后将数据写入存储器模块中的 8 个 DRAM 芯片中

九、总结

        本文我们从虚拟内存地址开始聊起,一直到物理内存地址结束,包含的信息量还是比较大的。首先我通过一个进程的运行实例为大家引出了内核引入虚拟内存空间的目的及其需要解决的问题。

        在我们有了虚拟内存空间的概念之后,我又近一步为大家介绍了内核如何划分用户态虚拟内存空间和内核态虚拟内存空间,并在此基础之上分别从 32 位体系结构和 64 位体系结构的角度详细阐述了 Linux 虚拟内存空间的整体布局分布。

  • 我们可以通过 cat /proc/pid/maps 或者 pmap pid 命令来查看进程用户态虚拟内存空间的实际分布。

  • 还可以通过 cat /proc/iomem 命令来查看进程内核态虚拟内存空间的的实际分布。

        在我们清楚了 Linux 虚拟内存空间的整体布局分布之后,我又介绍了 Linux 内核如何对分布在虚拟内存空间中的各个虚拟内存区域进行管理,以及每个虚拟内存区域的作用。在这个过程中还介绍了相关的内核数据结构,近一步从内核源码实现角度加深大家对虚拟内存空间的理解。

        最后我介绍了物理内存的结构,以及 CPU 如何通过物理内存地址来读写内存中的数据。这里我需要特地再次强调的是 CPU 只会访问虚拟内存地址,只不过在操作总线之前,通过一个地址转换硬件将虚拟内存地址转换为物理内存地址,然后将物理内存地址作为地址信号放在总线上传输,由于地址转换的内容和本文主旨无关,考虑到文章的篇幅以及复杂性,没有过多的介绍。

参考资料

一步一图带你深入理解 Linux 虚拟内存管理

一步一图带你深入理解 Linux 物理内存管理

Memory Layout on AArch64 Linux

一文掌握 Linux 内存管理

  • 4
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 10
    评论
作者: [爱尔兰] 戈尔曼(Gorm 出版社: 北京航空航天大学出版社 原作名: Understanding the Linux Virtual Memory Manager 内容简介 · · · · · · 深入理解Linux虚拟内存管理,ISBN:9787810777308,作者:(爱尔兰)戈尔曼著;白洛等 作者简介 · · · · · · Mel Gorman曾获得爱尔兰利马瑞克大学的计算机学士和硕士学位。他的研究领域广泛:从网页开发到摄影机的实时显示系统。Mel Gorman认为,即使是最难以攻克的项目也并没有想象中那么艰难。他曾经还担任过系统管理员,主要管理Linux, 也涉及到Solaris和Windows。现今Mel Gorman是都柏林IBM公司的Java程序开发员。 Mel Gorman的大部分技能都来自于他自己在利马瑞克大学的生活经历,与大学里计算机社区的广泛接触,以及实地工作经验这三者的完美结合。是计算机社区使他接触到了Linux,并相信Linux用途广泛,而绝不仅仅只是一个便利的邮箱地址。这个社区还使他对开放源码软件产生了兴趣,尤其是在Linux内核方面。他永远感激利马瑞克大学为他提供的这个平台,使他认识了许多有识之士,并有两年时间来研究VM。 闲暇时Mel Gorman喜欢和女友凯伦呆在一起,或者弹弹吉他(尽管并不擅长),读读手边的书籍,与朋友和家人(他们避免谈及有关VM的话题)一同消磨时光。又或是制订一些可能并无价值的计划(有时仅仅在想象中完成它们)。只要安迪说服他乘坐游艇是个不错的娱乐项目,他也会去尝试。Mel Gorman还在犹豫着是继续创作关于Linux的文章,还是向从前一样在Linux环境下编写程序,因为后者才是他最初的意愿。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值