Linux内核源码分析 (B.0)从 Linux 内核角度探秘 JDK NIO 文件读写本质-CSDN博客

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

Linux内核源码分析 (B.0)从 Linux 内核角度探秘 JDK NIO 文件读写本质

文章目录


写在本文开始之前…

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

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

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

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

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

当程序运行起来之后就变成了进程而这些业务数据结构的引用在进程的视角里全都都是虚拟内存地址因为进程无论是在用户态还是在内核态能够看到的都是虚拟内存空间物理内存空间被操作系统所屏蔽进程是看不到的。

进程通过虚拟内存地址访问这些数据结构的时候虚拟内存地址会在内存管理子系统中被转换成物理内存地址通过物理内存地址就可以访问到真正存储这些数据结构的物理内存了。随后就可以对这块物理内存进行各种业务操作从而完成业务逻辑。

  • 那么到底什么是虚拟内存地址

  • Linux 内核为啥要引入虚拟内存而不直接使用物理内存

  • 虚拟内存空间到底长啥样

  • 内核如何管理虚拟内存

  • 什么又是物理内存地址 如何访问物理内存

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

本文概要.png

1. 到底什么是虚拟内存地址

首先人们提出地址这个概念的目的就是用来方便定位现实世界中某一个具体事物的真实地理位置它是一种用于定位的概念模型。

举一个生活中的例子比如大家在日常生活中给亲朋好友邮寄一些本地特产时都会填写收件人地址以及寄件人地址。以及在日常网上购物时都会在相应电商 APP 中填写自己的收获地址。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

随后快递小哥就会根据我们填写的收货地址找到我们的真实住所将我们网购的商品送达到我们的手里。

收货地址是用来定位我们在现实世界中真实住所地理位置的而现实世界中我们所在的城市街道小区房屋都是一砖一瓦一草一木真实存在的。但收货地址这个概念模型在现实世界中并不真实存在它只是人们提出的一个虚拟概念通过收货地址这个虚拟概念将它和现实世界真实存在的城市小区街道的地理位置一一映射起来这样我们就可以通过这个虚拟概念来找到现实世界中的具体地理位置。

综上所述收货地址是一个虚拟地址它是人为定义的而我们的城市小区街道是真实存在的他们的地理位置就是物理地址。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

比如现在的广东省深圳市在过去叫宝安县河北省的石家庄过去叫常山安徽省的合肥过去叫泸州。不管是常山也好石家庄也好又或是合肥也好泸州也罢这些都是人为定义的名字而已但是地方还是那个地方它所在的地理位置是不变的。也就说虚拟地址可以人为的变来变去但是物理地址永远是不变的。

现在让我们把视角在切换到计算机的世界在计算机的世界里内存地址用来定义数据在内存中的存储位置的内存地址也分为虚拟地址和物理地址。而虚拟地址也是人为设计的一个概念类比我们现实世界中的收货地址而物理地址则是数据在物理内存中的真实存储位置类比现实世界中的城市街道小区的真实地理位置。

说了这么多那么到底虚拟内存地址长什么样子呢

我们还是以日常生活中的收货地址为例做出类比我们都很熟悉收货地址的格式xx省xx市xx区xx街道xx小区xx室它是按照地区层次递进的。同样在计算机世界中的虚拟内存地址也有这样的递进关系。

这里我们以 Intel Core i7 处理器为例64 位虚拟地址的格式为全局页目录项9位+ 上层页目录项9位+ 中间页目录项9位+ 页表项9位+ 页内偏移12位。共 48 位组成的虚拟内存地址。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

虚拟内存地址中的全局页目录项就类比我们日常生活中收获地址里的省上层页目录项就类比市中间层页目录项类比区县页表项类比街道小区页内偏移类比我们所在的楼栋和几层几号。

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

32 位虚拟地址的格式为页目录项10位+ 页表项10位 + 页内偏移12位。共 32 位组成的虚拟内存地址。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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

2. 为什么要使用虚拟地址访问内存

经过第一小节的介绍我们现在明白了计算机世界中的虚拟内存地址的含义及其展现形式。那么大家可能会问了既然物理内存地址可以直接定位到数据在内存中的存储位置那为什么我们不直接使用物理内存地址去访问内存而是选择用虚拟内存地址去访问内存呢

在回答大家的这个疑问之前让我们先来看下如果在程序中直接使用物理内存地址会发生什么情况

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

如果我们在单进程系统中比如嵌入式设备上开发应用程序系统中只有一个进程这单个进程独享所有的物理资源包括内存资源。在这种情况下上述提到的这些直接使用物理内存的问题可能还好处理一些但是仍然具有很高的开发门槛。

然而在现代操作系统中往往支持多个进程需要处理多进程之间的协同问题在多进程系统中直接使用物理内存地址操作内存所带来的上述问题就变得非常复杂了。

这里笔者为大家举一个简单的例子来说明在多进程系统中直接使用物理内存地址的复杂性。

比如我们现在有这样一个简单的 Java 程序。

    `public static void main(String[] args) throws Exception {                      string i = args[0];           ..........       }`

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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

现实中一个程序会有很多的变量和函数这样一来我们给它们都需要计算一个合理的位置还不能与其他进程冲突这就很复杂了。

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

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

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

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

而虚拟内存的引入正是要解决上述的问题虚拟内存引入之后进程的视角就会变得非常开阔每个进程都拥有自己独立的虚拟地址空间进程与进程之间的虚拟内存地址空间是相互隔离互不干扰的。每个进程都认为自己独占所有内存空间自己想干什么就干什么。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

系统上还运行了哪些进程和我没有任何关系。这样一来我们就可以将多进程之间协同的相关复杂细节统统交给内核中的内存管理模块来处理极大地解放了程序员的心智负担。这一切都是因为虚拟内存能够提供内存地址空间的隔离极大地扩展了可用空间。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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

当 CPU 访问进程的虚拟地址时经过地址翻译硬件将虚拟地址转换成不同的物理地址这样不同的进程运行的时候虽然操作的是同一虚拟地址但其实背后写入的是不同的物理地址这样就不会冲突了。

3. 进程虚拟内存空间

上小节中我们介绍了为了防止多进程运行时造成的内存地址冲突内核引入了虚拟内存地址为每个进程提供了一个独立的虚拟内存空间使得进程以为自己独占全部内存资源。

那么这个进程独占的虚拟内存空间到底是什么样子呢在本小节中笔者就为大家揭开这层神秘的面纱~~~

在本小节内容开始之前我们先想象一下如果我们是内核的设计人员我们该从哪些方面来规划进程的虚拟内存空间呢

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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

  • 那些在代码中被我们指定了初始值的全局变量和静态变量在虚拟内存空间中的存储区域我们叫做数据段。

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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

还有用于内存文件映射的系统调用 mmap会将文件与内存进行映射那么映射的这块内存虚拟内存也需要在虚拟地址空间中有一块区域存储。

这些动态链接库中的代码段数据段BSS 段以及通过 mmap 系统调用映射的共享内存区在虚拟内存空间的存储区域叫做文件映射与匿名映射区。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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

  • 用于存放进程程序二进制文件中的机器指令的代码段

  • 用于存放程序二进制文件中定义的全局变量和静态变量的数据段和 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。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

但是用户态虚拟内存空间中的代码段并不是从 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 pointerRBP 寄存器中保存的是栈基地址。

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

4.2 64 位机器上进程虚拟内存空间分布

上小节中介绍的 32 位虚拟内存空间布局和本小节即将要介绍的 64 位虚拟内存空间布局都可以通过 cat /proc/pid/maps 或者 pmap pid 来查看某个进程的实际虚拟内存布局。

我们知道在 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 空洞。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

那么这个 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 系统下的真实虚拟内存空间布局情况

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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

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

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

  3. 用户态虚拟内存空间与内核态虚拟内存空间分别占用 128T其中低128T 分配给用户态虚拟内存空间高 128T 分配给内核态虚拟内存空间。

5. 进程虚拟内存空间的管理

在上一小节中笔者为大家介绍了 Linux 操作系统在 32 位机器上和 64 位机器上进程虚拟内存空间的布局分布我们发现无论是在 32 位机器上还是在 64 位机器上进程虚拟内存空间的核心区域分布的相对位置是不变的它们都包含下图所示的这几个核心内存区域。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

唯一不同的是这些核心内存区域在 32 位机器和 64 位机器上的绝对位置分布会有所不同。

那么在此基础之上内核如何为进程管理这些虚拟内存区域呢这将是本小节重点为大家介绍的内容~~

既然我们要介绍进程的虚拟内存空间管理那就离不开进程在内核中的描述符 task_struct 结构。

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

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

每个进程都有唯一的 mm_struct 结构体也就是前边提到的每个进程的虚拟地址空间都是独立互不干扰的。

当我们调用 fork() 函数创建进程的时候表示进程地址空间的 mm_struct 结构会随着进程描述符 task_struct 的创建而创建。

long _do_fork(unsigned long clone_flags,          unsigned long stack_start,          unsigned long stack_size,          int __user *parent_tidptr,          int __user *child_tidptr,          unsigned long tls)   {           ......... 省略 ..........    struct pid *pid;    struct task_struct *p;              ......... 省略 ..........       // 为进程创建 task_struct 结构用父进程的资源填充 task_struct 信息    p = copy_process(clone_flags, stack_start, stack_size,       child_tidptr, NULL, trace, tls, NUMA_NO_NODE);               ......... 省略 ..........   }   

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

static __latent_entropy struct task_struct *copy_process(        unsigned long clone_flags,        unsigned long stack_start,        unsigned long stack_size,        int __user *child_tidptr,        struct pid *pid,        int trace,        unsigned long tls,        int node)   {          struct task_struct *p;       // 创建 task_struct 结构       p = dup_task_struct(current, node);              ....... 初始化子进程 ...........              ....... 开始继承拷贝父进程资源  .......             // 继承父进程打开的文件描述符    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);            ...........省略.........       // 分配 CPU       retval = sched_fork(clone_flags, p);       // 分配 pid       pid = alloc_pid(p->nsproxy->pid_ns_for_children);      .     ..........省略.........   }   

这里我们重点关注 copy_mm 函数正是在这里完成了子进程虚拟内存空间 mm_struct 结构的的创建以及初始化。

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 赋值给子进程    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) 条件中在这个分支中会将父进程的虚拟内存空间以及相关页表直接赋值给子进程。这样一来父进程和子进程的虚拟内存空间就变成共享的了。也就是说父子进程之间使用的虚拟内存空间是一样的并不是一份拷贝。

子进程共享了父进程的虚拟内存空间这样子进程就变成了我们熟悉的线程是否共享地址空间几乎是进程和线程之间的本质区别。Linux 内核并不区别对待它们线程对于内核来说仅仅是一个共享特定资源的进程而已

内核线程和用户态线程的区别就是内核线程没有相关的内存描述符 mm_struct 内核线程对应的 task_struct 结构中的 mm 域指向 Null所以内核线程之间调度是不涉及地址空间切换的。

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

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

现在我们知道了表示进程虚拟内存空间的 mm_struct 结构是如何被创建出来的相关背景那么接下来笔者就带大家深入 mm_struct 结构内部来看一下内核如何通过这么一个 mm_struct 结构体来管理进程的虚拟内存空间的。

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

通过 《3. 进程虚拟内存空间》小节的介绍我们知道进程的虚拟内存空间分为两个部分一部分是用户态虚拟内存空间另一部分是内核态虚拟内存空间。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

那么用户态的地址空间和内核态的地址空间在内核中是如何被划分的呢

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

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位地址空间.png

32 位系统中用户地址空间和内核地址空间的分界线在 0xC000 000 地址处那么自然进程的 mm_struct 结构中的 task_size 为 0xC000 000。

我们来看下内核在 /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 000。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

/arch/arm/Kconfig.png

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

64位地址空间.png

64 位系统中用户地址空间和内核地址空间的分界线在 0x0000 7FFF FFFF F000 地址处那么自然进程的 mm_struct 结构中的 task_size 为 0x0000 7FFF FFFF F000 。

我们来看下内核在 /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      

我们来看下在 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 定义在 /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 空洞。

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

在我们理解了内核是如何划分进程虚拟内存空间和内核虚拟内存空间之后那么在 《3. 进程虚拟内存空间》小节中介绍的那些虚拟内存区域在内核中又是如何划分的呢

接下来笔者就为大家介绍下内核是如何划分进程虚拟内存空间中的这些内存区域的本小节的示例图中笔者只保留了进程虚拟内存空间中的核心区域方便大家理解。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

前边我们提到内核中采用了一个叫做内存描述符的 mm_struct 结构体来表示进程虚拟内存空间的全部信息。在本小节中笔者就带大家到 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 */             ...... 省略 ........   }   

内核中用 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 是环境变量的位置。它们都位于栈中的最高地址处。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

在 mm_struct 结构体中除了上述用于划分虚拟内存区域的变量之外还定义了一些虚拟内存与物理内存映射内容相关的统计变量操作系统会把物理内存划分成一页一页的区域来进行管理所以物理内存到虚拟内存之间的映射也是按照页为单位进行的。这部分内容笔者会在后续的文章中详细介绍大家这里只需要有个概念就行。

mm_struct 结构体中的 total_vm 表示在进程虚拟内存空间中总共与物理内存映射的页的总数。

注意映射这个概念它表示只是将虚拟内存与物理内存建立关联关系并不代表真正的分配物理内存。

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

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

现在关于内核如何对进程虚拟内存空间进行布局的内容我们已经清楚了那么布局之后划分出的这些虚拟内存区域在内核中又是如何被管理的呢我们接着往下看~~~

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

在上小节的介绍中我们知道内核是通过一个 mm_struct 结构的内存描述符来表示进程的虚拟内存空间的并通过 task_size 域来划分用户态虚拟内存空间和内核态虚拟内存空间。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

而在划分出的这些虚拟内存空间中如上图所示里边又包含了许多特定的虚拟内存区域比如代码段数据段堆内存映射区栈。那么这些虚拟内存区域在内核中又是如何表示的呢

本小节中笔者将为大家介绍一个新的结构体 vm_area_struct正是这个结构体描述了这些虚拟内存区域 VMAvirtual 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;   }   

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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

vm_page_prot 和 vm_flags 都是用来标记 vm_area_struct 结构表示的这块虚拟内存区域的访问权限和行为规范。

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

vm_page_prot 偏向于定义底层内存管理架构中页这一级别的访问控制权限它可以直接应用在底层页表中它是一个具体的概念。

页表用于管理虚拟内存到物理内存之间的映射关系这部分内容笔者后续会详细讲解这里大家有个初步的概念就行。

虚拟内存区域 VMA 由许多的虚拟页 (page) 组成每个虚拟页需要经过页表的转换才能找到对应的物理页面。页表中关于内存页的访问权限就是由 vm_page_prot 决定的。

vm_flags 则偏向于定于整个虚拟内存区域的访问权限以及行为规范。描述的是虚拟内存区域中的整体信息而不是虚拟内存区域中具体的某个独立页面。它是一个抽象的概念。可以通过 vma->vm_page_prot = vm_get_page_prot(vma->vm_flags) 实现到具体页面访问权限 vm_page_prot 的转换。

下面笔者列举一些常用到的 vm_flags 方便大家有一个直观的感受

vm_flags访问权限
VM_READ可读
VM_WRITE可写
VM_EXEC可执行
VM_SHARD可多进程之间共享
VM_IO可映射至设备 IO 空间
VM_RESERVED内存区域不可被换出
VM_SEQ_READ内存区域可能被顺序访问
VM_RAND_READ内存区域可能被随机访问

VM_READVM_WRITEVM_EXEC 定义了虚拟内存区域是否可以被读取写入执行等权限。

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

VM_SHARD 用于指定这块虚拟内存区域映射的物理内存是否可以在多进程之间共享以便完成进程间通讯。

设置这个值即为 mmap 的共享映射不设置的话则为私有映射。这个等后面我们讲到 mmap 的相关实现时还会再次提起。

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

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

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

VM_RAND_READ 的设置会暗示内核应用程序会对这块虚拟内存区域进行随机读取内核则会根据实际情况减少预读的内存页数甚至停止预读。

我们可以通过 posix_fadvisemadvise 系统调用来暗示内核是否对相关内存区域进行顺序读取或者随机读取。相关的详细内容大家可以看下笔者上篇文章 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》中的第 9 小节文件页预读部分。

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

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

接下来的三个属性 anon_vmavm_filevm_pgoff 分别和虚拟内存映射相关虚拟内存区域可以映射到物理内存上也可以映射到文件中映射到物理内存上我们称之为匿名映射映射到文件中我们称之为文件映射。

那么这个映射关系在内核中该如何表示呢这就用到了 vm_area_struct 结构体中的上述三个属性。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

当我们调用 malloc 申请内存时如果申请的是小块内存低于 128K则会使用 do_brk() 系统调用通过调整堆中的 brk 指针大小来增加或者回收堆内存。

如果申请的是比较大块的内存超过 128K时则会调用 mmap 在上图虚拟内存空间中的文件映射与匿名映射区创建出一块 VMA 内存区域这里是匿名映射。这块匿名映射区域就用 struct anon_vma 结构表示。

当调用 mmap 进行文件映射时vm_file 属性就用来关联被映射的文件。这样一来虚拟内存区域就与映射文件关联了起来。vm_pgoff 则表示映射进虚拟内存中的文件内容在文件中的偏移。

当然在匿名映射中vm_area_struct 结构中的 vm_file 就为 nullvm_pgoff 也就没有了意义。

vm_private_data 则用于存储 VMA 中的私有数据。具体的存储内容和内存映射的类型有关我们暂不展开论述。

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

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);       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。里面定义了内核针对文件操作的函数指针具体的实现根据不同的文件类型有所不同。

针对 Socket 文件类型这里的 file_operations 指向的是 socket_file_ops。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

进程中管理文件列表结构.png

在 ext4 文件系统中管理的文件对应的 file_operations 指向 ext4_file_operations专门用于操作 ext4 文件系统中的文件。还有针对 page cache 页高速缓存相关操作定义的 address_space_operations 。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

还有我们在 《从 Linux 内核角度看 IO 模型的演变》一文中介绍到socket 相关的操作接口定义在 inet_stream_ops 函数集合中负责对上给用户提供接口。而 socket 与内核协议栈之间的操作接口定义在 struct sock 中的 sk_prot 指针上这里指向 tcp_prot 协议操作函数集合。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

系统IO调用结构.png

对 socket 发起的系统 IO 调用时在内核中首先会调用 socket 的文件结构 struct file 中的 file_operations 文件操作集合然后调用 struct socket 中的 ops 指向的 inet_stream_opssocket 操作函数最终调用到 struct sock 中 sk_prot 指针指向的 tcp_prot 内核协议栈操作函数接口集合。

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

我们继续来到 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;   }   

在内核中其实是通过一个 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。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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

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

尤其在进程虚拟内存空间中包含的内存区域 VMA 比较多的情况下使用红黑树查找特定虚拟内存区域的时间复杂度是 O( logN ) 可以显著减少查找所需的时间。

所以在内核中同样的内存区域 vm_area_struct 会有两种组织形式一种是双向链表用于高效的遍历另一种就是红黑树用于高效的查找。

每个 VMA 区域都是红黑树中的一个节点通过 struct vm_area_struct 结构中的 vm_rb 将自己连接到红黑树中。

而红黑树中的根节点存储在内存描述符 struct mm_struct 中的 mm_rb 中

struct mm_struct {        struct rb_root mm_rb;   }   

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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

经过前边这么多小节的内容介绍现在我们已经熟悉了进程虚拟内存空间的布局以及内核如何管理这些虚拟内存区域并对进程的虚拟内存空间有了一个完整全面的认识。

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

在 《3. 进程虚拟内存空间》小节中我们介绍进程的虚拟内存空间时提到我们写的程序代码编译之后会生成一个 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);           ...... 省略 ........     // 创建并初始化栈对应的 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

7. 内核虚拟内存空间

现在我们已经知道了进程虚拟内存空间在内核中的布局以及管理那么内核态的虚拟内存空间又是什么样子的呢本小节笔者就带大家来一层一层地拆开这个黑盒子。

之前在介绍进程虚拟内存空间的时候笔者提到不同进程之间的虚拟内存空间是相互隔离的彼此之间相互独立相互感知不到其他进程的存在。使得进程以为自己拥有所有的内存资源。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

而内核态虚拟内存空间是所有进程共享的不同进程进入内核态之后看到的虚拟内存空间全部是一样的。

什么意思呢比如上图中的进程 a进程 b进程 c 分别在各自的用户态虚拟内存空间中访问虚拟地址 x 。由于进程之间的用户态虚拟内存空间是相互隔离相互独立的虽然在进程a进程b进程c 访问的都是虚拟地址 x 但是看到的内容却是不一样的背后可能映射到不同的物理内存中。

但是当进程 a进程 b进程 c 进入到内核态之后情况就不一样了由于内核虚拟内存空间是各个进程共享的所以它们在内核空间中看到的内容全部是一样的比如进程 a进程 b进程 c 在内核态都去访问虚拟地址 y。这时它们看到的内容就是一样的了。

这里笔者和大家澄清一个经常被误解的概念由于内核会涉及到物理内存的管理所以很多人会想当然地认为只要进入了内核态就开始使用物理地址了这就大错特错了千万不要这样理解进程进入内核态之后使用的仍然是虚拟内存地址只不过在内核中使用的虚拟内存地址被限制在了内核态虚拟内存空间范围中这也是本小节笔者要为大家介绍的主题。

在清楚了这个基本概念之后下面笔者分别从 32 位体系 和 64 位体系下为大家介绍内核态虚拟内存空间的布局。

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

在前边《5.1 内核如何划分用户态和内核态虚拟内存空间》小节中我们提到内核在 /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 000

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

在 32 位体系结构下进程用户态虚拟内存空间为 3 GB虚拟内存地址范围为0x0000 0000 - 0xC000 000 。内核态虚拟内存空间为 1 GB虚拟内存地址范围为0xC000 000 - 0xFFFF FFFF。

本小节我们主要关注 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 大小

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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

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

明白了这个关系之后我们接下来就看一下这块直接映射区域在物理内存中究竟存的是什么内容~~~

在这段 896M 大小的物理内存中前 1M 已经在系统启动的时候被系统占用1M 之后的物理内存存放的是内核代码段数据段BSS 段这些信息起初存放在 ELF格式的二进制文件中在系统启动的时候被加载进内存。

我们可以通过 cat /proc/iomem 命令查看具体物理内存布局情况。

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

当进程被创建完毕之后在内核运行的过程中会涉及内核栈的分配内核会为每个进程分配一个固定大小的内核栈一般是两个页大小依赖具体的体系结构每个进程的整个调用链必须放在自己的内核栈中内核栈也是分配在直接映射区。

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

通过以上内容的介绍我们了解到内核虚拟内存空间最前边的这段 896M 大小的直接映射区如何与物理内存进行映射关联并且清楚了直接映射区主要用来存放哪些内容。

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

我们都知道内核对物理内存的管理都是以页为最小单位来管理的每页默认 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 这段虚拟内存上。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

注意这里的 ZONE_DMA 和 ZONE_NORMAL 是内核针对物理内存区域的划分。

现在物理内存中的前 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 大小的内核虚拟内存空间究竟是如何布局的

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

内核虚拟内存空间中的 3G + 896M 这块地址在内核中定义为 high_memoryhigh_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)   

7.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   

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

和用户态进程使用 malloc 申请内存一样在这块动态映射区内核是使用 vmalloc 进行内存分配。由于之前介绍的动态映射的原因vmalloc 分配的内存在虚拟内存上是连续的但是物理内存是不连续的。通过页表来建立物理内存与虚拟内存之间的映射关系从而可以将不连续的物理内存映射到连续的虚拟内存上。

由于 vmalloc 获得的物理内存页是不连续的因此它只能将这些物理内存页一个一个地进行映射在性能开销上会比直接映射大得多。

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

7.1.4 永久映射区

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

而在 PKMAP_BASE 到 FIXADDR_START 之间的这段空间称为永久映射区。在内核的这段虚拟地址空间中允许建立与物理高端内存的长期映射关系。比如内核通过 alloc_pages() 函数在物理内存的高端内存中申请获取到的物理内存页这些物理内存页可以通过调用 kmap 映射到永久映射区中。

LAST_PKMAP 表示永久映射区可以映射的页数限制。

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

8.1.5 固定映射区

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

内核虚拟内存空间中的下一个区域为固定映射区区域范围为FIXADDR_START 到 FIXADDR_TOP。

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)   

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

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

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

7.1.6 临时映射区

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

笔者在之前文章 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》 的 “ 12.3 iov_iter_copy_from_user_atomic ” 小节中介绍在 Buffered IO 模式下进行文件写入的时候在下图中的第四步内核会调用 iov_iter_copy_from_user_atomic 函数将用户空间缓冲区 DirectByteBuffer 中的待写入数据拷贝到 page cache 中。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

但是内核又不能直接进行拷贝因为此时从 page cache 中取出的缓存页 page 是物理地址而在内核中是不能够直接操作物理地址的只能操作虚拟地址。

那怎么办呢所以就需要使用 kmap_atomic 将缓存页临时映射到内核空间的一段虚拟地址上这段虚拟地址就位于内核虚拟内存空间中的临时映射区上然后将用户空间缓存区 DirectByteBuffer 中的待写入数据通过这段映射的虚拟地址拷贝到 page cache 中的相应缓存页中。这时文件的写入操作就已经完成了。

由于是临时映射所以在拷贝完成之后调用 kunmap_atomic 将这段映射再解除掉。

size_t iov_iter_copy_from_user_atomic(struct page *page,       struct iov_iter *i, unsigned long offset, size_t bytes)   {     // 将缓存页临时映射到内核虚拟地址空间的临时映射区中     char *kaddr = kmap_atomic(page),      *p = kaddr + offset;     // 将用户缓存区 DirectByteBuffer 中的待写入数据拷贝到文件缓存页中     iterate_all_kinds(i, bytes, v,       copyin((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len),       memcpy_from_page((p += v.bv_len) - v.bv_len, v.bv_page,            v.bv_offset, v.bv_len),       memcpy((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len)     )     // 解除内核虚拟地址空间与缓存页之间的临时映射这里映射只是为了临时拷贝数据用     kunmap_atomic(kaddr);     return bytes;   }   

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

到现在为止整个内核虚拟内存空间在 32 位体系下的布局笔者就为大家详细介绍完毕了我们再次结合前边《4.1 32 位机器上进程虚拟内存空间分布》小节中介绍的进程虚拟内存空间和本小节介绍的内核虚拟内存空间来整体回顾下 32 位体系结构 Linux 的整个虚拟内存空间的布局

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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 将进程虚拟内存空间和内核虚拟内存空间分割开来。

#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   

64 位系统中的 TASK_SIZE 为 0x00007FFFFFFFF000

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

64位地址空间.png

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

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

本小节我们主要关注 0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF 这段内核虚拟内存空间的布局情况。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

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   

从图中 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 VMEMMAP_START  __VMEMMAP_BASE_L4      #define VMALLOC_END  (VMALLOC_START + (VMALLOC_SIZE_TB << 40) - 1)   

从 VMEMMAP_START 开始是 1T 大小的虚拟内存映射区用于存放物理页面的描述符 struct page 结构用来表示物理内存页。

VMEMMAP_START 变量定义在 /arch/x86/include/asm/pgtable_64_types.h 文件中

#define __VMEMMAP_BASE_L4 0xffffea0000000000UL      # define VMEMMAP_START  __VMEMMAP_BASE_L4   

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

__START_KERNEL_map 变量定义在 /arch/x86/include/asm/page_64_types.h 文件中

#define __START_KERNEL_map  _AC(0xffffffff80000000, UL)   

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

到现在为止整个内核虚拟内存空间在 64 位体系下的布局笔者就为大家详细介绍完毕了我们再次结合前边《4.2 64 位机器上进程虚拟内存空间分布》小节介绍的进程虚拟内存空间和本小节介绍的内核虚拟内存空间来整体回顾下 64 位体系结构 Linux 的整个虚拟内存空间的布局

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

8. 到底什么是物理内存地址

聊完了虚拟内存我们接着聊一下物理内存我们平时所称的内存也叫随机访问存储器 random-access memory 也叫 RAM 。而 RAM 分为两类

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

CPU缓存结构.png

  • 另一类则是动态 RAM ( DRAM )这类 DRAM 用于我们常说的主存上其特点的是访问速度慢相对高速缓存访问速度为 50 - 200 个时钟周期但是容量大造价便宜些相对高速缓存。

内存由一个一个的存储器模块memory module组成它们插在主板的扩展槽上。常见的存储器模块通常以 64 位为单位 8 个字节传输数据到存储控制器上或者从存储控制器传出数据。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image.png

如图所示内存条上黑色的元器件就是存储器模块memory module。多个存储器模块连接到存储控制器上就聚合成了主存。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

内存结构.png

而 DRAM 芯片就包装在存储器模块中每个存储器模块中包含 8 个 DRAM 芯片依次编号为 0 - 7 。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

存储器模块.png

而每一个 DRAM 芯片的存储结构是一个二维矩阵二维矩阵中存储的元素我们称为超单元supercell每个 supercell 大小为一个字节8 bit。每个 supercell 都由一个坐标地址ij。

i 表示二维矩阵中的行地址在计算机中行地址称为 RAS (row access strobe行访问选通脉冲)。 j 表示二维矩阵中的列地址在计算机中列地址称为 CAS (column access strobe,列访问选通脉冲)。

下图中的 supercell 的 RAS = 2CAS = 2。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

DRAM结构.png

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

图中 DRAM 芯片包含了两个地址引脚( addr )因为我们要通过 RASCAS 来定位要获取的 supercell 。还有 8 个数据引脚data因为 DRAM 芯片的 IO 单位为一个字节8 bit所以需要 8 个 data 引脚从 DRAM 芯片传入传出数据。

注意这里只是为了解释地址引脚和数据引脚的概念实际硬件中的引脚数量是不一定的。

8.1 DRAM 芯片的访问

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

DRAM芯片访问.png

  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与内存之间的总线结构.png

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 只会访问虚拟内存在操作总线之前需要把虚拟内存地址转换为物理内存地址总线上传输的都是物理内存地址这里省略了虚拟内存地址到物理内存地址的转换过程这部分内容笔者会在后续文章的相关章节详细为大家讲解这里我们聚焦如果通过物理内存地址读取内存数据。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

CPU读取内存.png

首先 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 如何根据物理内存地址从主存中读取数据

前边介绍到当主存中的存储控制器感受到了存储总线上的地址信号时会将内存地址从存储总线上读取出来。

随后会通过内存地址定位到具体的存储器模块。还记得内存结构中的存储器模块吗

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

内存结构.png

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

存储器模块.png

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

DRAM芯片访问.png

我们知道一个 supercell 存储了一个字节 8 bit 数据这里我们从 DRAM0 到 DRAM7 依次读取到了 8 个 supercell 也就是 8 个字节然后将这 8 个字节返回给存储控制器由存储控制器将数据放到存储总线上。

CPU 总是以 word size 为单位从内存中读取数据在 64 位处理器中的 word size 为 8 个字节。64 位的内存每次只能吞吐 8 个字节。

CPU 每次会向内存读写一个 cache line 大小的数据 64 个字节但是内存一次只能吞吐 8 个字节。

所以在物理内存地址对应的存储器模块中DRAM0 芯片存储第一个低位字节 supercell DRAM1 芯片存储第二个字节…依次类推 DRAM7 芯片存储最后一个高位字节。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

读取存储器模块数据.png

由于存储器模块中这种由 8 个 DRAM 芯片组成的物理存储结构的限制内存读取数据只能是按照物理内存地址8 个字节 8 个字节地顺序读取数据。所以说内存一次读取和写入的单位是 8 个字节。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

内存IO单位.png

而且在程序员眼里连续的物理内存地址实际上在物理上是不连续的。因为这连续的 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 只会访问虚拟内存地址只不过在操作总线之前通过一个地址转换硬件将虚拟内存地址转换为物理内存地址然后将物理内存地址作为地址信号放在总线上传输由于地址转换的内容和本文主旨无关考虑到文章的篇幅以及复杂性笔者就没有过多的介绍。

好了本文的全部内容到这里就结束了感谢大家的收看我们下篇文章见~~~

本文转自 https://mp.weixin.qq.com/s?__biz=Mzg2MzU3Mjc3Ng==&mid=2247486732&idx=1&sn=435d5e834e9751036c96384f6965b328&chksm=ce77cb4bf900425d33d2adfa632a4684cf7a63beece166c1ffedc4fdacb807c9413e8c73f298&scene=178&cur_album_id=2559805446807928833#rd如有侵权请联系删除。

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: linux

“Linux内核源码分析 (B.0)从 Linux 内核角度探秘 JDK NIO 文件读写本质-CSDN博客” 的相关文章