linux_mem
本文《linux_mem》由小宇宙从原站整理搬运,详细讲解相关技术要点。
4.6 深入理解 Linux 虚拟内存管理 作者:公众号@bin的技术小屋 来源:3.5 万字 + 60 张图 |一步一图带你深入理解 Linux 虚拟内存管理 (opens new window) 这一篇会比较硬核,是比较全面的一篇 Linux 虚拟内存管理的文章,文章多达 3.5 万字 + 60 张图,耐心读下去,肯定对 Linux 虚拟内存管理有很深刻的理解! 开车! 内存管理子系统可谓是 Linux 内核众多子系统中最为复杂最为庞大的一个,其中包含了众多繁杂的概念和原理,通过内存管理这条主线我们把可以把操作系统的众多核心系统给拎出来,比如:进程管理子系统,网络子系统,文件子系统等。 由于内存管理子系统过于复杂庞大,其中涉及到的众多繁杂的概念又是一环套一环,层层递进。如何把这些繁杂的概念具有层次感地,并且清晰地,给大家梳理呈现出来真是一件比较有难度的事情,因此关于这个问题,我在动笔写这个内存管理源码解析系列之前也是思考了很久。 万事开头难,那么到底什么内容适合作为这个系列的开篇呢 ?我还是觉得从大家日常开发工作中接触最多最为熟悉的部分开始比较好,比如:在我们日常开发中创建的类,调用的函数,在函数中定义的局部变量以及 new 出来的数据容器(Map,List,Set .....等)都需要存储在物理内存中的某个角落。 而我们在程序中编写业务逻辑代码的时候,往往需要引用这些创建出来的数据结构,并通过这些引用对相关数据结构进行业务处理。 当程序运行起来之后就变成了进程,而这些业务数据结构的引用在进程的视角里全都都是虚拟内存地址,因为进程无论是在用户态还是在内核态能够看到的都是虚拟内存空间,物理内存空间被操作系统所屏蔽进程是看不到的。 进程通过虚拟内存地址访问这些数据结构的时候,虚拟内存地址会在内存管理子系统中被转换成物理内存地址,通过物理内存地址就可以访问到真正存储这些数据结构的物理内存了。随后就可以对这块物理内存进行各种业务操作,从而完成业务逻辑。 那么到底什么是虚拟内存地址 ? Linux 内核为啥要引入虚拟内存而不直接使用物理内存 ? 虚拟内存空间到底长啥样? 内核如何管理虚拟内存? 什么又是物理内存地址 ?如何访问物理内存? 本文我就来为大家详细一一解答上述几个问题,让我们马上开始吧 1. 到底什么是虚拟内存地址 首先人们提出地址这个概念的目的就是用来方便定位现实世界中某一个具体事物的真实地理位置,它是一种用于定位的概念模型。 举一个生活中的例子,比如大家在日常生活中给亲朋好友邮寄一些本地特产时,都会填写收件人地址以及寄件人地址。以及在日常网上购物时,都会在相应电商 APP 中填写自己的收货地址。 随后快递小哥就会根据我们填写的收货地址找到我们的真实住所,将我们网购的商品送达到我们的手里。 收货地址是用来定位我们在现实世界中真实住所地理位置的,而现实世界中我们所在的城市,街道,小区,房屋都是一砖一瓦,一草一木真实存在的。但收货地址这个概念模型在现实世界中并不真实存在,它只是人们提出的一个虚拟概念,通过收货地址这个虚拟概念将它和现实世界真实存在的城市,小区,街道的地理位置一一映射起来,这样我们就可以通过这个虚拟概念来找到现实世界中的具体地理位置。 综上所述,收货地址是一个虚拟地址,它是人为定义的,而我们的城市,小区,街道是真实存在的,他们的地理位置就是物理地址。 比如现在的广东省深圳市在过去叫宝安县,河北省的石家庄过去叫常山,安徽省的合肥过去叫泸州。不管是常山也好,石家庄也好,又或是合肥也好,泸州也罢,这些都是人为定义的名字而已,但是地方还是那个地方,它所在的地理位置是不变的。也就说虚拟地址可以人为的变来变去,但是物理地址永远是不变的。 现在让我们把视角在切换到计算机的世界,在计算机的世界里内存地址用来定义数据在内存中的存储位置的,内存地址也分为虚拟地址和物理地址。而虚拟地址也是人为设计的一个概念,类比我们现实世界中的收货地址,而物理地址则是数据在物理内存中的真实存储位置,类比现实世界中的城市,街道,小区的真实地理位置。 说了这么多,那么到底虚拟内存地址长什么样子呢? 我们还是以日常生活中的收货地址为例做出类比,我们都很熟悉收货地址的格式:xx省xx市xx区xx街道xx小区xx室,它是按照地区层次递进的。同样,在计算机世界中的虚拟内存地址也有这样的递进关系。 这里我们以 Intel Core i7 处理器为例,64 位虚拟地址的格式为:全局页目录项(9位)+ 上层页目录项(9位)+ 中间页目录项(9位)+ 页表项(9位)+ 页内偏移(12位)。共 48 位组成的虚拟内存地址。 虚拟内存地址中的全局页目录项就类比我们日常生活中收获地址里的省,上层页目录项就类比市,中间层页目录项类比区县,页表项类比街道小区,页内偏移类比我们所在的楼栋和几层几号。 这里大家只需要大体明白虚拟内存地址到底长什么样子,它的格式是什么,能够和日常生活中的收货地址对比理解起来就可以了,至于页目录项,页表项以及页内偏移这些计算机世界中的概念,大家暂时先不用管,后续文章中我会慢慢给大家解释清楚。 32 位虚拟地址的格式为:页目录项(10位)+ 页表项(10位) + 页内偏移(12位)。共 32 位组成的虚拟内存地址。 进程虚拟内存空间中的每一个字节都有与其对应的虚拟内存地址,一个虚拟内存地址表示进程虚拟内存空间中的一个特定的字节。 2. 为什么要使用虚拟地址访问内存 经过第一小节的介绍,我们现在明白了计算机世界中的虚拟内存地址的含义及其展现形式。那么大家可能会问了,既然物理内存地址可以直接定位到数据在内存中的存储位置,那为什么我们不直接使用物理内存地址去访问内存而是选择用虚拟内存地址去访问内存呢? 在回答大家的这个疑问之前,让我们先来看下,如果在程序中直接使用物理内存地址会发生什么情况? 假设现在没有虚拟内存地址,我们在程序中对内存的操作全都都是使用物理内存地址,在这种情况下,程序员就需要精确的知道每一个变量在内存中的具体位置,我们需要手动对物理内存进行布局,明确哪些数据存储在内存的哪些位置,除此之外我们还需要考虑为每个进程究竟要分配多少内存?内存紧张的时候该怎么办?如何避免进程与进程之间的地址冲突?等等一系列复杂且琐碎的细节。 如果我们在单进程系统中比如嵌入式设备上开发应用程序,系统中只有一个进程,这单个进程独享所有的物理资源包括内存资源。在这种情况下,上述提到的这些直接使用物理内存的问题可能还好处理一些,但是仍然具有很高的开发门槛。 然而在现代操作系统中往往支持多个进程,需要处理多进程之间的协同问题,在多进程系统中直接使用物理内存地址操作内存所带来的上述问题就变得非常复杂了。 这里我为大家举一个简单的例子来说明在多进程系统中直接使用物理内存地址的复杂性。 比如我们现在有这样一个简单的 Java 程序。 在程序代码相同的情况下,我们用这份代码同时启动三个 JVM 进程,我们暂时将进程依次命名为 a , b , c 。 这三个进程用到的代码是一样的,都是我们提前写好的,可以被多次运行。由于我们是直接操作物理内存地址,假设变量 i 保存在 0x354 这个物理地址上。这三个进程运行起来之后,同时操作这个 0x354 物理地址,这样这个变量 i 的值不就混乱了吗? 三个进程就会出现变量的地址冲突。 所以在直接操作物理内存的情况下,我们需要知道每一个变量的位置都被安排在了哪里,而且还要注意和多个进程同时运行的时候,不能共用同一个地址,否则就会造成地址冲突。 现实中一个程序会有很多的变量和函数,这样一来我们给它们都需要计算一个合理的位置,还不能与其他进程冲突,这就很复杂了。 那么我们该如何解决这个问题呢?程序的局部性原理再一次救了我们 程序局部性原理表现为:时间局部性和空间局部性。时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某块数据被访问,则不久之后该数据可能再次被访问。空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。 从程序局部性原理的描述中我们可以得出这样一个结论:进程在运行之后,对于内存的访问不会一下子就要访问全部的内存,相反进程对于内存的访问会表现出明显的倾向性,更加倾向于访问最近访问过的数据以及热点数据附近的数据。 根据这个结论我们就清楚了,无论一个进程实际可以占用的内存资源有多大,根据程序局部性原理,在某一段时间内,进程真正需要的物理内存其实是很少的一部分,我们只需要为每个进程分配很少的物理内存就可以保证进程的正常执行运转。 而虚拟内存的引入正是要解决上述的问题,虚拟内存引入之后,进程的视角就会变得非常开阔,每个进程都拥有自己独立的虚拟地址空间,进程与进程之间的虚拟内存地址空间是相互隔离,互不干扰的。每个进程都认为自己独占所有内存空间,自己想干什么就干什么。 系统上还运行了哪些进程和我没有任何关系。这样一来我们就可以将多进程之间协同的相关复杂细节统统交给内核中的内存管理模块来处理,极大地解放了程序员的心智负担。这一切都是因为虚拟内存能够提供内存地址空间的隔离,极大地扩展了可用空间。 这样进程就以为自己独占了整个内存空间资源,给进程产生了所有内存资源都属于它自己的幻觉,这其实是 CPU 和操作系统使用的一个障眼法罢了,任何一个虚拟内存里所存储的数据,本质上还是保存在真实的物理内存里的。只不过内核帮我们做了虚拟内存到物理内存的这一层映射,将不同进程的虚拟地址和不同内存的物理地址映射起来。 当 CPU 访问进程的虚拟地址时,经过地址翻译硬件将虚拟地址转换成不同的物理地址,这样不同的进程运行的时候,虽然操作的是同一虚拟地址,但其实背后写入的是不同的物理地址,这样就不会冲突了。 3. 进程虚拟内存空间 上小节中,我们介绍了为了防止多进程运行时造成的内存地址冲突,内核引入了虚拟内存地址,为每个进程提供了一个独立的虚拟内存空间,使得进程以为自己独占全部内存资源。 那么这个进程独占的虚拟内存空间到底是什么样子呢?在本小节中,我就为大家揭开这层神秘的面纱 在本小节内容开始之前,我们先想象一下,如果我们是内核的设计人员,我们该从哪些方面来规划进程的虚拟内存空间呢? 本小节我们只讨论进程用户态虚拟内存空间的布局,我们先把内核态的虚拟内存空间当做一个黑盒来看待,在后面的小节中我再来详细介绍内核态相关内容。 首先我们会想到的是一个进程运行起来是为了执行我们交代给进程的工作,执行这些工作的步骤我们通过程序代码事先编写好,然后编译成二进制文件存放在磁盘中,CPU 会执行二进制文件中的机器码来驱动进程的运行。所以在进程运行之前,这些存放在二进制文件中的机器码需要被加载进内存中,而用于存放这些机器码的虚拟内存空间叫做代码段。 在程序运行起来之后,总要操作变量吧,在程序代码中我们通常会定义大量的全局变量和静态变量,这些全局变量在程序编译之后也会存储在二进制文件中,在程序运行之前,这些全局变量也需要被加载进内存中供程序访问。所以在虚拟内存空间中也需要一段区域来存储这些全局变量。 那些在代码中被我们指定了初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做数据段。 那些没有指定初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做 BSS 段。这些未初始化的全局变量被加载进内存之后会被初始化为 0 值。 上面介绍的这些全局变量和静态变量都是在编译期间就确定的,但是我们程序在运行期间往往需要动态的申请内存,所以在虚拟内存空间中也需要一块区域来存放这些动态申请的内存,这块区域就叫做堆。注意这里的堆指的是 OS 堆并不是 JVM 中的堆。 除此之外,我们的程序在运行过程中还需要依赖动态链接库,这些动态链接库以 .so 文件的形式存放在磁盘中,比如 C 程序中的 glibc,里边对系统调用进行了封装。glibc 库里提供的用于动态申请堆内存的 malloc 函数就是对系统调用 sbrk 和 mmap 的封装。这些动态链接库也有自己的对应的代码段,数据段,BSS 段,也需要一起被加载进内存中。 还有用于内存文件映射的系统调用 mmap,会将文件与内存进行映射,那么映射的这块内存(虚拟内存)也需要在虚拟地址空间中有一块区域存储。 这些动态链接库中的代码段,数据段,BSS 段,以及通过 mmap 系统调用映射的共享内存区,在虚拟内存空间的存储区域叫做文件映射与匿名映射区。 最后我们在程序运行的时候总该要调用各种函数吧,那么调用函数过程中使用到的局部变量和函数参数也需要一块内存区域来保存。这一块区域在虚拟内存空间中叫做栈。 现在进程的虚拟内存空间所包含的主要区域,我就为大家介绍完了,我们看到内核根据进程运行的过程中所需要不同种类的数据而为其开辟了对应的地址空间。分别为: 用于存放进程程序二进制文件中的机器指令的代码段 用于存放程序二进制文件中定义的全局变量和静态变量的数据段和 BSS 段。 用于在程序运行过程中动态申请内存的堆。 用于存放动态链接库以及内存映射区域的文件映射与匿名映射区。 用于存放函数调用过程中的局部变量和函数参数的栈。 以上就是我们通过一个程序在运行过程中所需要的数据所规划出的虚拟内存空间的分布,这些只是一个大概的规划,那么在真实的 Linux 系统中,进程的虚拟内存空间的具体规划又是如何的呢?我们接着往下看 4. Linux 进程虚拟内存空间 在上小节中我们介绍了进程虚拟内存空间中各个内存区域的一个大概分布,在此基础之上,本小节我就带大家分别从 32 位 和 64 位机器上看下在 Linux 系统中进程虚拟内存空间的真实分布情况。 4.1 32 位机器上进程虚拟内存空间分布 在 32 位机器上,指针的寻址范围为 2^32,所能表达的虚拟内存空间为 4 GB。所以在 32 位机器上进程的虚拟内存地址范围为:0x0000 0000 0xFFFF FFFF。 其中用户态虚拟内存空间为 3 GB,虚拟内存地址范围为:0x0000 0000 0xC000 000 。 内核态虚拟内存空间为 1 GB,虚拟内存地址范围为:0xC000 000 0xFFFF FFFF。 但是用户态虚拟内存空间中的代码段并不是从 0x0000 0000 地址开始的,而是从 0x0804 8000 地址开始。 0x0000 0000 到 0x0804 8000 这段虚拟内存地址是一段不可访问的保留区,因为在大多数操作系统中,数值比较小的地址通常被认为不是一个合法的地址,这块小地址是不允许访问的。比如在 C 语言中我们通常会将一些无效的指针设置为 NULL,指向这块不允许访问的地址。 保留区的上边就是代码段和数据段,它们是从程序的二进制文件中直接加载进内存中的,BSS 段中的数据也存在于二进制文件中,因为内核知道这些数据是没有初值的,所以在二进制文件中只会记录 BSS 段的大小,在加载进内存时会生成一段 0 填充的内存空间。 紧挨着 BSS 段的上边就是我们经常使用到的堆空间,从图中的红色箭头我们可以知道在堆空间中地址的增长方向是从低地址到高地址增长。 内核中使用 start\ brk 标识堆的起始位置,brk 标识堆当前的结束位置。当堆申请新的内存空间时,只需要将 brk 指针增加对应的大小,回收地址时减少对应的大小即可。比如当我们通过 malloc 向内核申请很小的一块内存时(128K 之内),就是通过改变 brk 位置实现的。 堆空间的上边是一段待分配区域,用于扩展堆空间的使用。接下来就来到了文件映射与匿名映射区域。进程运行时所依赖的动态链接库中的代码段,数据段,BSS 段就加载在这里。还有我们调用 mmap 映射出来的一段虚拟内存空间也保存在这个区域。 注意:在文件映射与匿名映射区的地址增长方向是从高地址向低地址增长 。 接下来用户态虚拟内存空间的最后一块区域就是栈空间了,在这里会保存函数运行过程所需要的局部变量以及函数参数等函数调用信息。 栈空间中的地址增长方向是从高地址向低地址增长 。每次进程申请新的栈地址时,其地址值是在减少的。 在内核中使用 start\ stack 标识栈的起始位置,RSP 寄存器中保存栈顶指针 stack pointer,RBP 寄存器中保存的是栈基地址。 在栈空间的下边也有一段待分配区域用于扩展栈空间,在栈空间的上边就是内核空间了,进程虽然可以看到这段内核空间地址,但是就是不能访问。这就好比我们在饭店里虽然可以看到厨房在哪里,但是厨房门上写着 “厨房重地,闲人免进” ,我们就是进不去。 4.2 64 位机器上进程虚拟内存空间分布 上小节中介绍的 32 位虚拟内存空间布局和本小节即将要介绍的 64 位虚拟内存空间布局都可以通过 或者 来查看某个进程的实际虚拟内存布局。 我们知道在 32 位机器上,指针的寻址范围为 2^32,所能表达的虚拟内存空间为 4 GB。 那么我们理所应当的会认为在 64 位机器上,指针的寻址范围为 2^64,所能表达的虚拟内存空间为 16 EB 。虚拟内存地址范围为:0x0000 0000 0000 0000 0000 0xFFFF FFFF FFFF FFFF 。 好家伙 !!! 16 EB 的内存空间,我都没见过这么大的磁盘,在现实情况中根本不会用到这么大范围的内存空间, 事实上在目前的 64 位系统下只使用了 48 位来描述虚拟内存空间,寻址范围为 2^48 ,所能表达的虚拟内存空间为 256TB。 其中低 128 T 表示用户态虚拟内存空间,虚拟内存地址范围为:0x0000 0000 0000 0000 0x0000 7FFF FFFF F000 。 高 128 T 表示内核态虚拟内存空间,虚拟内存地址范围为:0xFFFF 8000 0000 0000 0xFFFF FFFF FFFF FFFF 。 这样一来就在用户态虚拟内存空间与内核态虚拟内存空间之间形成了一段 0x0000 7FFF FFFF F000 0xFFFF 8000 0000 0000 的地址空洞,我们把这个空洞叫做 canonical address 空洞。 那么这个 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 位系统中的虚拟内存布局大体上是差不多的。主要不同的地方有三点: 1. 就是前边提到的由高 16 位空闲地址造成的 canonical address 空洞。在这段范围内的虚拟内存地址是不合法的,因为它的高 16 位既不全为 0 也不全为 1,不是一个 canonical address,所以称之为 canonical address 空洞。 2. 在代码段跟数据段的中间还有一段不可以读写的保护段,它的作用是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。 3. 用户态虚拟内存空间与内核态虚拟内存空间分别占用 128T,其中低128T 分配给用户态虚拟内存空间,高 128T 分配给内核态虚拟内存空间。 5. 进程虚拟内存空间的管理 在上一小节中,我为大家介绍了 Linux 操作系统在 32 位机器上和 64 位机器上进程虚拟内存空间的布局分布,我们发现无论是在 32 位机器上还是在 64 位机器上,进程虚拟内存空间的核心区域分布的相对位置是不变的,它们都包含下图所示的这几个核心内存区域。 唯一不同的是这些核心内存区域在 32 位机器和 64 位机器上的绝对位置分布会有所不同。 那么在此基础之上,内核如何为进程管理这些虚拟内存区域呢?这将是本小节重点为大家介绍的内容 既然我们要介绍进程的虚拟内存空间管理,那就离不开进程在内核中的描述符 task\ struct 结构。 在进程描述符 task\ struct 结构中,有一个专门描述进程虚拟地址空间的内存描述符 mm\ struct 结构,这个结构体中包含了前边几个小节中介绍的进程虚拟内存空间的全部信息。 每个进程都有唯一的 mm\ struct 结构体,也就是前边提到的每个进程的虚拟地址空间都是独立,互不干扰的。 当我们调用 fork() 函数创建进程的时候,表示进程地址空间的 mm\ struct 结构会随着进程描述符 task\ struct 的创建而创建。 随后会在 copy\ process 函数中创建 task\ struct 结构,并拷贝父进程的相关资源到新进程的 task\ struct 结构里,其中就包括拷贝父进程的虚拟内存空间 mm\ struct 结构。 这里可以看出子进程在新创建出来之后它的虚拟内存空间是和父进程的虚拟内存空间一模一样的,直接拷贝过来 。 这里我们重点关注 copy\ mm 函数,正是在这里完成了子进程虚拟内存空间 mm\ struct 结构的创建以及初始化。 由于本小节中我们举的示例是通过 fork() 函数创建子进程的情形,所以这里大家先占时忽略 这个条件判断逻辑,我们先跳过往后看 copy\ mm 函数首先会将父进程的虚拟内存空间 current mm 赋值给指针 oldmm。然后通过 dup\ mm 函数将父进程的虚拟内存空间以及 相关页表 拷贝到子进程的 mm\ struct 结构中。最后将拷贝出来的 mm\ struct 赋值给子进程的 task\ struct 结构。 通过 fork() 函数创建出的子进程,它的虚拟内存空间以及相关页表相当于父进程虚拟内存空间的一份拷贝,直接从父进程中拷贝到子进程中。 而当我们通过 vfork 或者 clone 系统调用创建出的子进程,首先会设置 CLONE\ VM 标识,这样来到 copy\ mm 函数中就会进入 条件中,在这个分支中会将父进程的虚拟内存空间以及相关页表直接赋值给子进程。这样一来父进程和子进程的虚拟内存空间就变成共享的了。也就是说父子进程之间使用的虚拟内存空间是一样的,并不是一份拷贝。 子进程共享了父进程的虚拟内存空间,这样子进程就变成了我们熟悉的线程, 是否共享地址空间几乎是进程和线程之间的本质区别。Linux 内核并不区别对待它们,线程对于内核来说仅仅是一个共享特定资源的进程而已 。 内核线程和用户态线程的区别就是内核线程没有相关的内存描述符 mm\ struct ,内核线程对应的 task\ struct 结构中的 mm 域指向 Null,所以内核线程之间调度是不涉及地址空间切换的。 当一个内核线程被调度时,它会发现自己的虚拟地址空间为 Null,虽然它不会访问用户态的内存,但是它会访问内核内存,聪明的内核会将调度之前的上一个用户态进程的虚拟内存空间 mm\ struct 直接赋值给内核线程,因为内核线程不会访问用户空间的内存,它仅仅只会访问内核空间的内存,所以直接复用上一个用户态进程的虚拟地址空间就可以避免为内核线程分配 mm\ struct 和相关页表的开销,以及避免内核线程之间调度时地址空间的切换开销。 父进程与子进程的区别,进程与线程的区别,以及内核线程与用户态线程的区别其实都是围绕着这个 mm\ struct 展开的。 现在我们知道了表示进程虚拟内存空间的 mm\ struct 结构是如何被创建出来的相关背景,那么接下来我就带大家深入 mm\ struct 结构内部,来看一下内核如何通过这么一个 mm\ struct 结构体来管理进程的虚拟内存空间的。 5.1 内核如何划分用户态和内核态虚拟内存空间 通过 《3. 进程虚拟内存空间》小节的介绍我们知道,进程的虚拟内存空间分为两个部分:一部分是用户态虚拟内存空间,另一部分是内核态虚拟内存空间。 那么用户态的地址空间和内核态的地址空间在内核中是如何被划分的呢? 这就用到了进程的内存描述符 mm\ struct 结构体中的 task\ size 变量,task\ size 定义了用户态地址空间与内核态地址空间之间的分界线。 通过前边小节的内容介绍,我们知道在 32 位系统中用户态虚拟内存空间为 3 GB,虚拟内存地址范围为:0x0000 0000 0xC000 000 。 内核态虚拟内存空间为 1 GB,虚拟内存地址范围为:0xC000 000 0xFFFF FFFF。 32 位系统中用户地址空间和内核地址空间的分界线在 0xC000 000 地址处,那么自然进程的 mm\ struct 结构中的 task\ size 为 0xC000 000。 我们来看下内核在 文件中关于 TASK\ SIZE 的定义。 如下图所示:\ \ PAGE\ OFFSET 的值在 32 位系统下为 0xC000 000。 而在 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 。 我们来看下内核在 文件中关于 TASK\ SIZE 的定义。 我们来看下在 64 位系统中内核如何来计算 TASK\ SIZE,在 task\ size\ max() 的计算逻辑中 1 左移 47 位得到的地址是 0x0000800000000000,然后减去一个 PAGE\ SIZE (默认为 4K),就是 0x00007FFFFFFFF000,共 128T。所以在 64 位系统中的 TASK\ SIZE 为 0x00007FFFFFFFF000 。 这里我们可以看出,64 位虚拟内存空间的布局是和物理内存页 page 的大小有关的,物理内存页 page 默认大小 PAGE\ SIZE 为 4K。 PAGE\ SIZE 定义在 文件中: 而内核空间的起始地址是 0xFFFF 8000 0000 0000 。在 0x00007FFFFFFFF000 0xFFFF 8000 0000 0000 之间的内存区域就是我们在 《4.2 64 位机器上进程虚拟内存空间分布》小节中介绍的 canonical address 空洞。 5.2 内核如何布局进程虚拟内存空间 在我们理解了内核是如何划分进程虚拟内存空间和内核虚拟内存空间之后,那么在 《3. 进程虚拟内存空间》小节中介绍的那些虚拟内存区域在内核中又是如何划分的呢? 接下来我就为大家介绍下内核是如何划分进程虚拟内存空间中的这些内存区域的,本小节的示例图中,我只保留了进程虚拟内存空间中的核心区域,方便大家理解。 前边我们提到,内核中采用了一个叫做内存描述符的 mm\ struct 结构体来表示进程虚拟内存空间的全部信息。在本小节中我就带大家到 mm\ struct 结构体内部去寻找下相关的线索。 内核中用 mm\ struct 结构体中的上述属性来定义上图中虚拟内存空间里的不同内存区域。 start\ code 和 end\ code 定义代码段的起始和结束位置,程序编译后的二进制文件中的机器码被加载进内存之后就存放在这里。 start\ data 和 end\ data 定义数据段的起始和结束位置,二进制文件中存放的全局变量和静态变量被加载进内存中就存放在这里。 后面紧挨着的是 BSS 段,用于存放未被初始化的全局变量和静态变量,这些变量在加载进内存时会生成一段 0 填充的内存区域 (BSS 段), BSS 段的大小是固定的, 下面就是 OS 堆了,在堆中内存地址的增长方向是由低地址向高地址增长, 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 结构体中除了上述用于划分虚拟内存区域的变量之外,还定义了一些虚拟内存与物理内存映射内容相关的统计变量,操作系统会把物理内存划分成一页一页的区域来进行管理,所以物理内存到虚拟内存之间的映射也是按照页为单位进行的。这部分内容我会在后续的文章中详细介绍,大家这里只需要有个概念就行。 mm\ struct 结构体中的 total\ vm 表示在进程虚拟内存空间中总共与物理内存映射的页的总数。 注意映射这个概念,它表示只是将虚拟内存与物理内存建立关联关系,并不代表真正的分配物理内存。 当内存吃紧的时候,有些页可以换出到硬盘上,而有些页因为比较重要,不能换出。locked\ vm 就是被锁定不能换出的内存页总数,pinned\ vm 表示既不能换出,也不能移动的内存页总数。 data\ vm 表示数据段中映射的内存页数目,exec\ vm 是代码段中存放可执行文件的内存页数目,stack\ vm 是栈中所映射的内存页数目,这些变量均是表示进程虚拟内存空间中的虚拟内存使用情况。 现在关于内核如何对进程虚拟内存空间进行布局的内容我们已经清楚了,那么布局之后划分出的这些虚拟内存区域在内核中又是如何被管理的呢?我们接着往下看 5.3 内核如何管理虚拟内存区域 在上小节的介绍中,我们知道内核是通过一个 mm\ struct 结构的内存描述符来表示进程的虚拟内存空间的,并通过 task\ size 域来划分用户态虚拟内存空间和内核态虚拟内存空间。 而在划分出的这些虚拟内存空间中如上图所示,里边又包含了许多特定的虚拟内存区域,比如:代码段,数据段,堆,内存映射区,栈。那么这些虚拟内存区域在内核中又是如何表示的呢? 本小节中,我将为大家介绍一个新的结构体 vm\ area\ struct,正是这个结构体描述了这些虚拟内存区域 VMA(virtual memory area)。 每个 vm\ area\ struct 结构对应于虚拟内存空间中的唯一虚拟内存区域 VMA,vm\ 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 偏向于定义底层内存管理架构中页这一级别的访问控制权限,它可以直接应用在底层页表中,它是一个具体的概念。 页表用于管理虚拟内存到物理内存之间的映射关系,这部分内容我后续会详细讲解,这里大家有个初步的概念就行。 虚拟内存区域 VMA 由许多的虚拟页 (page) 组成,每个虚拟页需要经过页表的转换才能找到对应的物理页面。页表中关于内存页的访问权限就是由 vm\ page\ prot 决定的。 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 空间