在上一章,我们已经知道了ELF文件的生成过程和虚拟地址的本质,但是我们的程序还在磁盘上躺着,仅仅只是初始化出了VMA,程序还没有真正的运行起来,在本章,正式开始讲解如何加载磁盘数据到物理内存中。
1.前情回顾
在上一章已经说明过,操作系统一般是按照4kb来加载磁盘数据到物理内存中的,在虚拟地址空间中的"页",物理内存中的"页框"和加载磁盘数据"块"的单位全部都是4kb的,根据已有的知识,我们已经能够搭建出整套加载机制的大框架了,我们可以将磁盘,物理内存,虚拟地址空间全部抽象成以4kb为单位的数组,如下图:

整套内存管理机制,无论是磁盘空间管理,虚拟地址空间管理还是物理内存管理,可以认为就是对这三个数据进行操作,在上一章,我们已经让虚拟地址空间与磁盘空间这两个"数组"之间产生了联系,也就是利用磁盘上ELF文件程序头表中记录的信息初始化出程序的虚拟地址空间,简单回顾一下,在ELF文件的程序头表中记录了各个段的VirtAddr信息与MemSiz信息,比如下方程序中程序头表记录的信息:
cpp
#include <stdio.h>
int main() {
getchar();
return 0;
}

我们重点关注LOAD段,这部分才是程序主要内容所在的段,在上一章已经说明,各个主要的段都会存在自己的VMA,在此处明确的说明,所谓主要的段就是LOAD段,Linux操作系统只会为LOAD段创建对应的VMA,此处有一个细节是上一章没有提及的,注意到上方的ELF文件中只存在四个LOAD段,下面来看看操作系统实际上为该程序映射了几个VMA(将程序运行起来,然后使用pmap命令查看):

很明显,该程序本身创建了六个VMA,其它的VMA是给动态库创建的,所以为什么会多出来两个?其中一个在上一章大概的说明过原因,是由LOAD段分裂出来的,在此处进行详细的补充,观察上方程序头表中最后一个LOAD段和最下方的GNU_RELRO段,我们发现这两个段的VirtAddr的一样的,再来看看这两个段的Memsiz,一个是0x00134,另一个是0x00128,最后发现一个LOAD段的权限是RW的,而GNU_RELRO段的权限是R,如上一章所述,GNU_RELRO段就是用来修改权限的,因为在实际加载时,操作系统由于某些原因需要W权限,但是在加载完成后,为了保护数据不再被修改,操作系统就会把需要被保护部分的权限修改成R的,从上方的程序头表中可以看出,在最后一个LOAD段中需要被保护(加载后修改权限)的部分是从0x00003ed8到0x00003ed8+0x00128,也就是GNU_RELRO段包含的范围,在加载后,这部分内容的权限就由RW变为了R,显然,在最后一个LOAD段中不是所有内容都要被修改权限的,0x00134 - 0x00128 != 0,这意味着这个LOAD段被分割成了两个部分,一部分的权限是R,一部分的权限是RW,又由于一个VMA只能够表示一种权限,因此操作系统就多创建出了一个VMA,使用了两个VMA来映射这个被分割的LOAD段,分裂后的结果就是上方图片中下面的两个test.exe对应的VMA了,从权限来看可以发现确实一个是R,另一个是RW的。第二个多出来的段是anon,该段是匿名映射,具体来说是系统自动为程序开辟了一块堆空间,使用cat /proc/12687/maps可以查出(中间的数字是进程pid):

heap的意思就是堆了,匿名映射一般分为映射堆空间,映射栈空间和普通的匿名映射,因为这部分的信息是在程序运行起来是动态变化的,在磁盘中没有对应具体的有名称的段,因此就只能是匿名的了。并且还可以发现,映射到VMA中的Address是严格满足4kb对齐的,并且地址的大小是4字节的,这一点在上一章已经详细的解释过了(vm_start向下对齐),此处不再说明。
从程序头表中我们还可以挖掘出很多信息,如图:

注意到笔者使用相同颜色框起来的那些段,它们都是属于同一个VMA的,也就是说在LOAD段中是包含了非LOAD段,只不过readelf工具把它们都给显示出来了,实际加载时就会顺带着把非LOAD段一起加载到物理内存中,并且只有权限相同的段才能够放在一起,唯一一个比较奇怪的段是GNU_STACK段,该段的所有信息都是0,在此处说明一下,该段的效果是用来告诉操作系统要给为该程序的栈区分配什么样的权限,由于栈区的内存占用是运行时在物理内存空间中动态开辟的,其大小是不固定的因此预先在磁盘中记录就是没有意义的,还注意到没有可执行权限,这与当代操作系统的栈区保护机制有关,笔者会在内存攻防篇详细介绍。
前情回顾完毕,下面进入本章的主要内容,也就是磁盘上的文件是如何被4kb加载到物理内存中的。
2.程序的动态加载阶段
同样从一个实例出发,就使用上方的程序为例,也就是这个:
cpp
#include <stdio.h>
int main() {
getchar();
return 0;
}
现在该程序经过了预处理,编译,汇编,链接四个阶段变成了一个ELF文件,然后该文件就静静的躺着磁盘上,直到某一刻,程序员开始执行程序,然后操作系统开始加载磁盘上的程序,最开始的时候通过文件的inode编号在磁盘中找到文件,然后把位于ELF开始位置的第一个LOAD段直接加载到物理内存中,在该段中就有ELF头和程序头表,然后根据程序头表初始化程序出程序各个段的VMA,这一步可以认为是把程序"加载"到了虚拟地址空间中,并且操作系统还将ELF头中记录的程序入口地址在映射成虚拟地址后存入了CPU上的PC寄存器(一般称为PC指针)中,现在所有前置工作都准备完毕,得出了一张加载程序所需的"蓝图",也就是这样子的:

先来说说其中一条信息具体的含义:
|----------------------------------------------------------------------------------------------|
| 5a70f000-5a710000 r--p 00000000 08:02 1311089 /home/tcw/TCW/Mycode/CPPCode/blogtest/test.exe |
5a70f000-5a710000 表示了该段对应的VMA的起始虚拟地址与结束虚拟地址,也就是把vm_start和vm_end的值打印出来了,r--p 表示VMA的权限,对应的就是VMA中的vm_flags,其中p的意思是私有,拥有私有权限就意味着如果在内存中对该程序进行了修改,修改的部分不会写回到磁盘上的ELF文件中,00000000 表示该段在ELF文件中的偏移,也就是Offset字段的信息,对应的就是VMA中的vm_pgoff,也就是本小节要介绍的字段,其实已经大概可以猜测出该字段有什么用了,08:02是主机号,1311089是文件的inode编号,最后面那一长串信息就是执行文件的路径了,对应的是VMA中的vm_file,根据加载后的VMA蓝图和ELF文件,就可以画出程序在磁盘和虚拟地址空间中的信息(先不管最后的heap对应的VMA):

在上图中,磁盘空间画出的0x0000就是ELF文件的起始地址,蓝色框框中存放的是有效信息,是根据程序头表中LOAD段的Offset字段信息与FileSiz字段信息绘制的,在虚拟地址空间中就是为该程序创建的五个VMA,各个页的起始与结束虚拟地址是根据VMA中的vm_start与vm_end绘制的,蓝色部分就意味着会映射到有效数据,是根据程序头表中的MemSiz信息就行绘制的,要说明的是,在最开始的时候,操作系统必须先把ELF头和程序头表所处的LOAD段加载到物理内存中,然后才能够初始化出程序的虚拟地址空间,此时程序的主体部分还没有被加载到物理内存中,并且就算ELF头和程序头表的总大小没有占满4kb,操作系统也会从ELF文件的起始位置加载4kb到物理内存中,然后通过读取ELF头找到程序头表所在的位置,最后就可以根据程序头表中记录的信息初始化出来各个段的VMA了。
入口地址就是ELF头中记录的入口地址加上映射的随机偏移量,可以看出本次映射的随机偏移量是0x5a70f000,要说明是时每次运行程序的随机偏移量都是不同的,证明如下:

同样的程序,结束运行后再次运行,得出的虚拟地址就是不同的了,这就是随机偏移量导致的,随机偏移这个东西和内存攻防有关,笔者将在那一章详细说明,这也是我们在编程中对同一个变量取地址,在两次运行中会取出不同的地址值的底层原因,比如下方程序:
cpp
#include <stdio.h>
int main()
{
int a = 10;
printf("&a = %p\n", &a);
return 0;
}
两次运行对变量a取地址的结果:

好了,现在所有静态部分的准备工作都完毕了,正式开始把程序加载到物理内存。
2.1程序的动态加载
先来提出一个问题,操作系统在加载磁盘上ELF文件中的段中,要如何得知该段在ELF文件中的具体位置?笔者首先想到的答案就是去看程序头表,但是要知道,在最开始初始化VMA的时候就已经读取过程序头表了,难度之后的每次加载都要重新读取程序头表吗,这显然是浪费时间的,因此实际上在初始化VMA时就会记录下来加载段在ELF文件中的位置了,具体就记录在VMA中的vm_pgoff字段中,也就是这个:

由于操作系统加载磁盘数据是以4kb为单位的,并且对于磁盘中的ELF文件来说,起始位置就是ELF文件的开始位置,因此操作系统加载时就是以ELF文件的开始位置为起点,往后加载指定段所在的4kb内存块的,比如下面这个ELF文件:

在上文已经说明过,实际上加载的只有LOAD段,看到LOAD段的Offset信息,可以发现该ELF文件中的段在磁盘中的划分情况是:LOAD1段位于[0x0000, 0x1000)这个4kb中,LOAD2段位于[0x1000, 0x2000)这个4kb中,LOAD3段位于[0x2000, 0x3000)这个4kb中,最后的LOAD4比较尴尬,它的上半部分位于[0x2000, 0x3000)4kb中,下半部分位于[0x3000, 0x4000)4kb中,画出来就是这样的:

对于第四个LOAD段,在上文说明过其在加载后是会被划分为两个VMA的,划分的依据就是最后GNU_RELRO中记录的信息,注意到FileSiz的大小是0x128,0x2ed8+0x128正好就等于0x3000 ,因此LOAD4段的前0x128部分在加载后是会和LOAD3段合并在[0x2000, 0x3000)中的,并且可以发现LOAD3段的权限正好就是R的,符合了一个页框只有一种权限的原则,最后LOAD4段的后0x8部分就位于[0x3000, 0x4000)中,操作系统在初始化VMA时,就会根据这些信息初始化出来VMA中的vm_pgoff信息,各个VMA的vm_pgoff信息就是这样的:

红色框框中的信息就是把各个VMA的vm_pgoff信息给打印出来了,明显发现有两个0x2000,这就是因为LOAD4段的前0x128被划分到了[0x2000, 0x3000)中,结果也证明了上方的分析是正确的。现在在CPU的PC指针中就记录着程序的入口虚拟地址各个VMA中的vm_pgoff也记录着磁盘上ELF文件中各个段所处的4kb块的号数,假设此时物理内存的使用情况是这样的:

加载程序前,CPU首先就把PC指针中的虚拟地址交给MMU硬件单元,对于MMU硬件单元,在上一章已经介绍过其作用了,就是通过查页表把一个虚拟地址转换为对应的物理地址,然后MMU硬件单元通过10 10 12寻址找到了页目录中的0x169位置,注意到此时整个页表体系中为该程序创建的只要一张空的页目录,没有任何页表,这其实就是懒加载机制的核心:页表是按需创建的,物理页框也是按需分配的,最开始的时候只会为进程提供一张空的页目录,MMU硬件单元在通过程序入口虚拟地址的前10个bit位找到页目录中0x169号页表项时,发现什么都没有,就向操作系统发送了一次缺页中断异常,操作系统就执行中断向量表中的do_page_fault函数,在确定了入口虚拟地址0x5a710060的访问是合法的后,就匹配上了对应VMA的vm_start(0x5a710000),然后开始创建对应的页表,将页表的起始物理地址填写到了页目录0x169号位置,如下图:

操作系统再通过入口虚拟地址的中10个bit位找到了页表中对应的页表项,显然又是空的,并没有映射到物理页框,于是操作系统首先读取VMA中的vm_pgoff信息,得知了要加载的段位于ELF文件中的[0x1000, 0x2000)这一个4kb中,然后通过文件的inode编号找到了磁盘上的ELF文件,直接将找到段所处的4kb加载到任意一个空闲的物理页框中,最后再把物理页框的起始物理地址填写到对应的页表项中,如下图:

此时操作系统的工作就完成了,它转头就告诉MMU硬件单元自己搞定了,于是MMU硬件单元再次使用10 10 12定位,在这一次,成功通过前10位在页目录中定位到了页表,通过中间10位在页表中定位到了指定页框,最后通过后12位页内偏移0x60定位到物理页框中的入口指令地址0x3060,MMU就将定位到的物理地址返回给CPU,CPU成功找到物理内存中的指令,程序开始执行。
上方的流程还有许多细节需要填充,比如最初的页目录是如何定位的?每次都走这么复杂的流程,肯定会简单CPU的执行效率,是否存在解决方案?围绕这两个问题,再来介绍CPU中的两个集成硬件:CR3寄存器和TLB硬件缓存。
首先是CR3寄存器,在其中存储的值很简单,就是页目录的起始物理地址,CPU通过该值定位到进程在物理内存中存储的页目录,如下图:

至于TBL硬件缓存也称为"快表",就是专门为了加快MMU查找而存在的,其原理比较复杂。在解析之前,得先来介绍一下PC指针的工作流程,也回答上一章的历史遗留问题:要如何通过前一条指令找到后一条指令,让指令的执行像水一样流动起来?我们来具体的看看上方可执行程序的反汇编指令码:

看到第一条指令在ELF文件中虚拟地址是0x1060,这就是程序入口地址的指令了,在初始化VMA时加上了随机偏移变为了0x5A710060,这就是最开始存放在PC寄存器中的虚拟地址,首先在PC也叫做程序计数器,在其中存储的永远是CPU要执行的下一条指令的虚拟地址,比如在最开始CPU没有执行该程序的如何指令,在PC中存储的就是CPU要执行的程序入口指令的虚拟地址0x5A710060,PC中一条重要的规则就是存储指令的虚拟地址是自动增加的,比如上方_start中的指令,在CPU取出PC中的0x5A710060时(取址阶段),PC指针就会递增指令所占用的字节数,在上方的第一条指令是:31 ed,占用两字节,因此PC指针中存储的新虚拟地址就是0x5A710062,配合上页表后12为页内偏移正好能够定位到下一条指令5e,然后CPU执行完第一条指令后再次来取址,就成功找到了5e指令,PC就递增到0x5A710063,对应着下一条指令89 e1的虚拟地址....就这样,CPU的执行就像水一样流动起来了,要说明的是在不同的机器架构中指令的长度是不同的,主要分为定长指令与变长指令,但是执行流程都是使用PC指针递增的,PC指针具体的更新时间分为两种情况,第一种就是在CPU取址后立刻递增,第二种就是在CPU执行完毕一条指令再次来取址时才递增,此处以取址阶段更新PC为例。显然这就是不需要记录下来所有指令的虚拟地址,而是只需要记录下来vm_start和vm_end的原因了,因为PC的指令虚拟地址递增机制,只需要给它一个指令的入口虚拟地址,它就可以找到所有指令的虚拟地址。当然也不是所有的虚拟地址改变都是简单的递增指令长度的,在遇到if,while和函数跳转等非连续的指令,PC指针就会直接记录下来跳转目标的位置的虚拟地址,比如在上图中的call就是函数跳转指令,在其后方记录的就是要跳转0x1030处,因此CPU在执行到call后再次向PC指针中取址就会执行到0x1030位置的指令:

PC指针被取走了虚拟地址后开始自动递增,发现0x1030处的指令长度是6字节,并且没有跳转,那么就直接递增6变为0x1036,遇到if,while等语句也是类似的操作,程序的执行就流动起来了。不过对于if和while来说也有比较特殊的地方,因为if和while不像函数,固定的就是跳转到某一个位置,if和while具体的跳转与条件判断有关,也就是说如果遇到了if和while,那么PC指针是不能在CPU取址后立刻更新的,而是要等待CPU执行完判断语句得出结果后,才能够进行更新,那么流水就会"卡"一下(专业的说法叫流水线停顿),现代CPU为了提高流水的速度,专门设计了分支预测功能,该功能比较复杂,分为多种不同的策略,简单来说就是CPU会采取某种策略预测分支执行的结果,主要分为静态预测和根据历史数据预测,静态预测有假定每次判断都成立(适用于while循环),或是假定每次判断都不成立,根据历史数据进行预测有如果上一次判断成立,那么就假定本次判断也成立,如果上一次判断不成立,那么就假定本次判断也不成立,具体的策略还有很多,部分高级策略还涉及到了神经网络,此处不再介绍,下面回到TBL硬件缓存中:
在上一章说明过:由于页目录与页表的起始物理地址一定是物理页框的起始地址,那么就一定是4kb的倍数,其二进制的最后12位就是全零的,因此在页目录页表项和页表页表项中的后12位就可以填写物理页框的权限信息,由于在程序运行时页表项和物理页框是一一对应的关系,因此一个物理页框在同一个时刻就只能拥有一种权限。并且也容易得知,在虚拟地址的10 10 12定位法中,通过前20位就能够定位到一个物理页框了,后12位是页内偏移,那么对于任意一个虚拟地址,就可以将其分为两部分,一部分是前20位,另一部分是后12位,我们称前20位为VPN,专门用于定位物理页框,后12位为offset,专门用于定位物理页框中某一字节的页内偏移,而TBL中的一个标准条目记录的主要内容就是VPN和对应的物理页框起始物理地址,比如在该图中:

LOAD2段的所以内容都是在一个物理页框中的,起始物理地址是0x3000,对应的虚拟页中的所有虚拟地址的VPN就都是一样的,只有offset是不同的,此时在TBL中记录的就是:
|---------|----------|
| 虚拟VPN | 物理页框起始地址 |
| 0x5A710 | 0x3000 |
在第一次触发缺页中断MMU通过虚拟地址的10 10 12定位法找到对应的物理页框后,MMU就会把虚拟的VPN和对应物理页框的起始地址填入TBL的一个条目中。因此实际的流程是:CPU每从PC寄存器中获取了一个虚拟地址后,首先会把虚拟地址交给MMU,MMU对将虚拟地址划分为VPN和offset,然后不少直接去查页表,而是拿着VPN到TBL中找,如果VPN在TBL中,那么直接就找到了对应物理页框起始地址,MMU直接就将物理页框起始地址加上offset得出指令所在的物理地址,然后返回给CPU,如果没有在TBL中找到对应的VPN,那么才是走页表定位那一套流程,很明显,大部分指令的物理地址获取走的都是TBL的,像上图中有5个虚拟页,那么最多也就走5次页表定位流程,然后TBL中就会记录下来所有段VPN和物理页框起始地址的对应关系,后续的所有指令查找走的都是VPN,当然具体情况也是非常复杂的,还涉及到进程间切换,TBL条目不足等情况,在此处简单的讲解一下,一般在进程切换时就会刷新TBL,重新记录新进程的VPN与物理页框起始地址,在TBL条目不足时会根据具体的算法策略覆盖掉TBL中的某些条目。
动态加载部分其实还有非常多的细节没有说明,但是最好得配合上具体的场景,也就是说得讲进程,线程等其它概念和虚拟地址空间的关系,在本系列的其它文章中,笔者会详细的进行介绍,单独的虚拟地址空间至此就结束了,希望能够让读者有所收获。