写在本文开始之前....

从本文开始咱们就正式开启了 Linux 内核内存管理子系统源码解析系列,笔者还是会秉承之前系列文章的格调,采纳一步一图的形式先是具体介绍相干原理,在保障大家清晰了解原理的根底上,咱们再来一步一步的解析相干内核源码的实现。有了源码的辅证,这样大家看得也安心,了解起来也释怀,最起码能够证实笔者没有胡编乱造骗大家,哈哈~~

内存管理子系统堪称是 Linux 内核泛滥子系统中最为简单最为宏大的一个,其中蕴含了泛滥繁冗的概念和原理,通过内存治理这条主线咱们把能够把操作系统的泛滥外围零碎给拎进去,比方:过程管理子系统,网络子系统,文件子系统等。

因为内存管理子系统过于简单宏大,其中波及到的泛滥繁冗的概念又是一环套一环,层层递进。如何把这些繁冗的概念具备层次感地,并且清晰地,给大家梳理出现进去真是一件比拟有难度的事件,因而对于这个问题,笔者在动笔写这个内存治理源码解析系列之前也是思考了很久。

万事开头难,那么到底什么内容适宜作为这个系列的开篇呢 ?笔者还是感觉从大家日常开发工作中接触最多最为相熟的局部开始比拟好,比方:在咱们日常开发中创立的类,调用的函数,在函数中定义的局部变量以及 new 进去的数据容器(Map,List,Set .....等)都须要存储在物理内存中的某个角落。

而咱们在程序中编写业务逻辑代码的时候,往往须要援用这些创立进去的数据结构,并通过这些援用对相干数据结构进行业务解决。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

这里咱们以 Intel Core i7 处理器为例,64 位虚拟地址的格局为:全局页目录项(9位)+ 下层页目录项(9位)+ 两头页目录项(9位)+ 页内偏移(12位)。共 48 位组成的虚拟内存地址。

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

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

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

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

2. 为什么要应用虚拟地址拜访内存

通过第一大节的介绍,咱们当初明确了计算机世界中的虚拟内存地址的含意及其展示模式。那么大家可能会问了,既然物理内存地址能够间接定位到数据在内存中的存储地位,那为什么咱们不间接应用物理内存地址去拜访内存而是抉择用虚拟内存地址去拜访内存呢?

在答复大家的这个疑难之前,让咱们先来看下,如果在程序中间接应用物理内存地址会产生什么状况?

假如当初没有虚拟内存地址,咱们在程序中对内存的操作全都都是应用物理内存地址,在这种状况下,程序员就须要准确的晓得每一个变量在内存中的具体位置,咱们须要手动对物理内存进行布局,明确哪些数据存储在内存的哪些地位,除此之外咱们还须要思考为每个过程到底要调配多少内存?内存缓和的时候该怎么办?如何防止过程与过程之间的地址抵触?等等一系列简单且琐碎的细节。

如果咱们在单过程零碎中比方嵌入式设施上开发应用程序,零碎中只有一个过程,这单个过程独享所有的物理资源包含内存资源。在这种状况下,上述提到的这些间接应用物理内存的问题可能还好解决一些,然而依然具备很高的开发门槛。

然而在古代操作系统中往往反对多个过程,须要解决多过程之间的协同问题,在多过程零碎中间接应用物理内存地址操作内存所带来的上述问题就变得非常复杂了。

这里笔者为大家举一个简略的例子来阐明在多过程零碎中间接应用物理内存地址的复杂性。

比方咱们当初有这样一个简略的 Java 程序。

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

在程序代码雷同的状况下,咱们用这份代码同时启动三个 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 位虚拟内存空间布局都能够通过 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 空洞。

那么这个 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 构造。

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. 过程虚拟内存空间》大节的介绍咱们晓得,过程的虚拟内存空间分为两个局部:一部分是用户态虚拟内存空间,另一部分是内核态虚拟内存空间。

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

这就用到了过程的内存描述符 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 位零碎中用户地址空间和内核地址空间的分界线在 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。

而在 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 。

咱们来看下内核在 /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. 过程虚拟内存空间》大节中介绍的那些虚拟内存区域在内核中又是如何划分的呢?

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

前边咱们提到,内核中采纳了一个叫做内存描述符的 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 是环境变量的地位。它们都位于栈中的最高地址处。

在 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)。

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 构造对应于虚拟内存空间中的惟一虚拟内存区域 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 则偏差于定于整个虚拟内存区域的拜访权限以及行为规范。形容的是虚拟内存区域中的整体信息,而不是虚拟内存区域中具体的某个独立页面。它是一个形象的概念。能够通过 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 的相干操作的函数指针。

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。

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

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

对 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 中的一些重要的要害属性。

当初咱们曾经相熟了这些虚拟内存区域,那么接下来的问题就是在内核中这些虚拟内存区域是如何被组织的呢?

咱们持续来到 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。

咱们能够通过 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;}

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

通过前边这么多大节的内容介绍,当初咱们曾经相熟了过程虚拟内存空间的布局,以及内核如何治理这些虚拟内存区域,并对过程的虚拟内存空间有了一个残缺全面的意识。

当初咱们再来回到最后的终点,过程的虚拟内存空间 mm_struct 以及这些虚拟内存区域 vm_area_struct 是如何被创立并初始化的呢?

在 《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. 内核虚拟内存空间

当初咱们曾经晓得了过程虚拟内存空间在内核中的布局以及治理,那么内核态的虚拟内存空间又是什么样子的呢?本大节笔者就带大家来一层一层地拆开这个黑盒子。

之前在介绍过程虚拟内存空间的时候,笔者提到不同过程之间的虚拟内存空间是互相隔离的,彼此之间互相独立,互相感知不到其余过程的存在。使得过程认为本人领有所有的内存资源。

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

什么意思呢?比方上图中的过程 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

在 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 大小

尽管这块区域中的虚拟地址是间接映射到物理地址上,然而内核在拜访这段区域的时候还是走的虚拟内存地址,内核也会为这块空间建设映射页表。对于页表的概念笔者后续会为大家具体解说,这里大家只须要简略了解为页表保留了虚拟地址到物理地址的映射关系即可。

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

明确了这个关系之后,咱们接下来就看一下这块间接映射区域在物理内存中到底存的是什么内容~~~

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

咱们能够通过 cat /proc/iomem 命令查看具体物理内存布局状况。

当咱们应用 fork 零碎调用创立过程的时候,内核会创立一系列过程相干的描述符,比方之前提到的过程的外围数据结构 task_struct,过程的内存空间描述符 mm_struct,以及虚拟内存区域描述符 vm_area_struct 等。

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

当过程被创立结束之后,在内核运行的过程中,会波及内核栈的调配,内核会为每个过程调配一个固定大小的内核栈(个别是两个页大小,依赖具体的体系结构),每个过程的整个调用链必须放在本人的内核栈中,内核栈也是调配在间接映射区。

与过程用户空间中的栈不同的是,内核栈容量小而且是固定的,用户空间中的栈容量大而且能够动静扩大。内核栈的溢出危害十分微小,它会间接悄无声息的笼罩相邻内存区域中的数据,毁坏数据。

通过以上内容的介绍咱们理解到内核虚拟内存空间最前边的这段 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 这段虚拟内存上。

留神这里的 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 大小的内核虚拟内存空间到底是如何布局的?

内核虚拟内存空间中的 3G + 896M 这块地址在内核中定义为 high_memory,high_memory 往上有一段 8M 大小的内存空洞。空洞范畴为:high_memory 到 VMALLOC_START 。

VMALLOC_START 定义在内核源码 /arch/x86/include/asm/pgtable_32_areas.h 文件中:

#define VMALLOC_OFFSET    (8 * 1024 * 1024)#define VMALLOC_START    ((unsigned long)high_memory + VMALLOC_OFFSET)

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

和用户态过程应用 malloc 申请内存一样,在这块动静映射区内核是应用 vmalloc 进行内存调配。因为之前介绍的动静映射的起因,vmalloc 调配的内存在虚拟内存上是间断的,然而物理内存是不间断的。通过页表来建设物理内存与虚拟内存之间的映射关系,从而能够将不间断的物理内存映射到间断的虚拟内存上。

因为 vmalloc 取得的物理内存页是不间断的,因而它只能将这些物理内存页一个一个地进行映射,在性能开销上会比间接映射大得多。

对于 vmalloc 分配内存的相干实现原理,笔者会在前面的文章中为大家解说,这里大家只须要明确它在哪块虚拟内存区域中流动即可。

7.1.4 永恒映射区

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

LAST_PKMAP 示意永恒映射区能够映射的页数限度。
#define PKMAP_BASE        \    ((LDT_BASE_ADDR - PAGE_SIZE) & PMD_MASK)#define LAST_PKMAP 1024

8.1.5 固定映射区

内核虚拟内存空间中的下一个区域为固定映射区,区域范畴为: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 长期映射区

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

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

然而内核又不能间接进行拷贝,因为此时从 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 的整个虚拟内存空间的布局:

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 位零碎中,只应用了其中的低 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 这段内核虚拟内存空间的布局状况。

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 的整个虚拟内存空间的布局:

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

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

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

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

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

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

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

首先 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 个 DRAM 芯片,编号从 0 - 7 。

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

咱们晓得一个 supercell 存储了一个字节( 8 bit ) 数据,这里咱们从 DRAM0 到 DRAM7 顺次读取到了 8 个 supercell 也就是 8 个字节,而后将这 8 个字节返回给存储控制器,由存储控制器将数据放到存储总线上。

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

CPU 每次会向内存读写一个 cache line 大小的数据( 64 个字节),然而内存一次只能吞吐 8 个字节。

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

因为存储器模块中这种由 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 只会拜访虚拟内存地址,只不过在操作总线之前,通过一个地址转换硬件将虚拟内存地址转换为物理内存地址,而后将物理内存地址作为地址信号放在总线上传输,因为地址转换的内容和本文宗旨无关,思考到文章的篇幅以及复杂性,笔者就没有过多的介绍。

好了,本文的全部内容到这里就完结了,感激大家的收看,咱们下篇文章见~~~