写在本文开端之前….
从本文开端咱们就正式开启了 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 位系统中的虚拟内存布局大体上是差不多的。首要不同的当地有三点:
-
便是前边说到的由高 16 位闲暇地址构成的 canonical address 空泛。在这段规模内的虚拟内存地址是不合法的,因为它的高 16 位既不全为 0 也不全为 1,不是一个 canonical address,所以称之为 canonical address 空泛。
-
在代码段跟数据段的中心还有一段不能够读写的保护段,它的效果是避免程序在读写数据段的时分越界拜访到代码段,这个保护段能够让越界拜访行为直接崩溃,避免它持续往下运转。
-
用户态虚拟内存空间与内核态虚拟内存空间别离占用 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 芯片的进程。
-
首要存储控制器将行地址 RAS = 2 经过地址引脚发送给 DRAM 芯片。
-
DRAM 芯片依据 RAS = 2 将二维矩阵中的第二行的悉数内容复制到内部行缓冲区中。
-
接下来存储控制器会经过地址引脚发送 CAS = 2 到 DRAM 芯片中。
-
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)。 该读业务分为以下进程进行:
-
CPU 将物理内存地址 A 放到系统总线上。随后 IO bridge 将信号传递到存储总线上。
-
主存感遭到存储总线上的地址信号并经过存储控制器将存储总线上的物理内存地址 A 读取出来。
-
存储控制器经过物理内存地址 A 定位到具体的存储器模块,从 DRAM 芯片中取出物理内存地址 A 对应的数据 X。
-
存储控制器将读取到的数据 X 放到存储总线上,随后 IO bridge 将存储总线上的数据信号转化为系统总线上的数据信号,然后持续沿着系统总线传递。
-
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)。写业务进程如下:
-
CPU 即将写入的物理内存地址 A 放入系统总线上。
-
经过 IO bridge 的信号转化,将物理内存地址 A 传递到存储总线上。
-
存储控制器感遭到存储总线上的地址信号,将物理内存地址 A 从存储总线上读取出来,并等候数据的到达。
-
CPU 将寄存器中的数据复制到系统总线上,经过 IO bridge 的信号转化,将数据传递到存储总线上。
-
存储控制器感遭到存储总线上的数据信号,将数据从存储总线上读取出来。
-
存储控制器经过内存地址 A 定位到具体的存储器模块,终究将数据写入存储器模块中的 8 个 DRAM 芯片中。
总结
本文咱们从虚拟内存地址开端聊起,一直到物理内存地址完毕,包含的信息量仍是比较大的。首要笔者经过一个进程的运转实例为咱们引出了内核引进虚拟内存空间的意图及其需求解决的问题。
在咱们有了虚拟内存空间的概念之后,笔者又近一步为咱们介绍了内核怎么区分用户态虚拟内存空间和内核态虚拟内存空间,并在次根底之上别离从 32 位系统结构和 64 位系统结构的视点具体阐述了 Linux 虚拟内存空间的全体布局散布。
-
咱们能够经过
cat /proc/pid/maps
或许pmap pid
指令来检查进程用户态虚拟内存空间的实践散布。 -
还能够经过
cat /proc/iomem
指令来检查进程内核态虚拟内存空间的的实践散布。
在咱们清楚了 Linux 虚拟内存空间的全体布局散布之后,笔者又介绍了 Linux 内核怎么对散布在虚拟内存空间中的各个虚拟内存区域进行办理,以及每个虚拟内存区域的效果。在这个进程中还介绍了相关的内核数据结构,近一步从内核源码完结视点加深咱们对虚拟内存空间的了解。
终究笔者介绍了物理内存的结构,以及 CPU 怎么经过物理内存地址来读写内存中的数据。这儿笔者需求特别再次强调的是 CPU 只会拜访虚拟内存地址,只不过在操作总线之前,经过一个地址转化硬件将虚拟内存地址转化为物理内存地址,然后将物理内存地址作为地址信号放在总线上传输,因为地址转化的内容和本文宗旨无关,考虑到文章的篇幅以及杂乱性,笔者就没有过多的介绍。
好了,本文的悉数内容到这儿就完毕了,感谢咱们的收看,咱们下篇文章见~~~