欢迎来到博主的专栏:从0开始linux 博主ID:代码小豪
文章目录
可执行链接格式(ELF)进程加载逻辑地址cpu寻址区域划分
动态库加载
根据我们上一篇博客所说,当我们运行一个程序时,首先这个进程的二进制文件会先从磁盘当中加载进来,接着操作系统创建进程对应的PCB(即task_struct),接着由于进程存在进程地址空间(因为cpu要在内存中找到对应的数据和指令所在的地址。)。因此需要建立一个页表,将进程地址空间中的虚拟地址与物理地址进行映射。因此进程与内存之间的关系如下图:
由于test进程当中与动态库进行了链接。这个动态库可以是C标准库,也可以是第三方库,总之大多数进程里面总是会与其他动态库进行链接的。而动态库链接的原理我们在上一篇文章当中也提到过,即当进程需要用到动态库的方法时,会将动态库从磁盘当中加载到内存中,并且将动态库的地址,保存在进程空间地址的共享区当中,由于进程空间地址与物理地址存在映射,因此通过页表,我们也可以找到动态库在内存的数据,因此当我们的进程需要用到动态库中的方法时,会跳转到动态库里执行指令,当执行结束后,带着执行结果回到进程当中。由于一个动态库可以被多个进程链接,因此起到节省空间的效果(相当于多个进程都将相同的指令放在动态库中,因此进程的空间会减少,因为它们的方法都共享在动态库中,因此存放动态库的进程空间也被叫做共享区)。
但是具体是如何跳转,以及进程是怎么找到物理内存,进程地址空间又是如何创建的?请听我娓娓道来。
可执行链接格式(ELF)
ELF的全称为(excuteable and linkable format),即可执行链接格式,用通俗的话来说,每一个可执行程序(动态库,二进制文件)都有其一个格式,而非单纯的数据与指令的合集。(实际上非可执行文件也有其ELF,比如编译形成的.o文件)。
比如我们的进程地址空间,为什么操作系统可以知道一个进程的代码区的起始地址,结束地址?为什么运行程序的时候就能知道数据的虚拟地址是多少?难道是随机生成的吗?当然不是,这些进程地址,都是在源文件编译生成可执行程序时,编译器就将这些信息写进可执行程序当中了。我们可以通过size指令,查看一个可执行程序的信息。 比如text说明的是代码区的信息,data和bss则是和数据有关的信息。这些信息,与操作系统如何构建进程地址空间有关,当我们编译源文件生成可执行程序时,编译器会根据一个格式,将源文件当中的数据,代码按照该格式来编译,而不是将指令和数据编译成二进制就一走而之。就比如我们翻译英文时,英语水平比较差的,就是将单词一个一个的翻译,而水平比较高的,则会通过语境,语义来翻译,也就是所谓的信达雅。因此编译器按照格式编译源文件也是差不多的道理。目的是为了让读者(计算机)更加方便。
而这个格式就叫做ELF格式,ELF格式如下: 我们的二进制文件,动态库,.o文件,它们在磁盘当中的数据组成,都是按照这个ELF格式。
ELF Header之类的是什么,我们后面再说,我们先来注意这个Section。Section我们称为节或者段,我们看到的text,data,bss之类的数据,都是存储在节当中。比如我们现在有一个code1.c,code2.c,code3.c文件要形成一个可执行程序code,首先我们要分别编译出它们对应的.o文件,code1.o,code2.o,code3.o。而每个.o文件都有一个其对应的ELF结构。
每一个文件被编译后,都会有各自的代码段,数据段,这些代码段,数据段都是保存在Section当中、我们假设Section1保存的是text,Section2保存的是data。那么code1.o,code2.o,code3.o的ELF如下: 现在要生成可执行程序code,就要将code1.o,code2.o,code3.o进行链接。链接的原理,其实就是将ELF的各个Section,进行合并。 这也就是为什么,我们的函数可以将定义放在其他文件当中,因为发生链接时,会将其他文件的函数(代码段)一起合并形成一个进程,这样进程就能随时找到函数的定义(静态库的原理)。
我们加载程序的时候,其实不单加载程序包括链接动态库,链接.o文件时,是要让操作系统读取ELF的,但是ELF要有属性让操作系统读取的,这个属性保存在ELF Header当中,我们可以用readelf -h [ordinary file]查看到二进制文件的ELF Header的内容。(注意,一定要是二进制文件) 这个ELF header的内容,其实就是ELF的属性信息,比如ELF格式是什么类型的?文件是可执行程序,还是动态库。有了ELF Header,操作系统可以通过ELF Header当中记录的属性信息,对ELF格式进行处理。
而program Header Table optional。则是记录ELF当中的段的地址。我们可以使用readelf -l [ordinary file]指令查看到一个二进制文件对应的program Header table optional的信息。 ELF Header和program header table optional都是为了记录一个二进制文件的ELF信息,这些信息都是给操作系统看到,通过这些信息,操作系统就能根据这些信息来根据ELF生成进程地址。
而每个Section的大小不一样,权限也不一样,有些Section可以读写,有些Section的权限则是只读,因此我们需要一个东西将Section管理起来。这个东西就是Section Header Table。
Section Header Table记录着一个二进制文件,每个section的偏移量,和读写权限,这里我们先来了解一下偏移量代表着什么?
首先,我们可以将一个二进制文件的内容,视为一个二进制构成的一维数组,因此我们可以通过偏移量,来定位一个数组的元素,而这个由二进制文件的内容组成的数组,其数组元素其实就是程序的数据和指令。对于任何一个文件,其文件内容就是一个展开的异位数组,因此我们可以用地址+偏移量的方式,定位文件的任何一个区域。
而我们的程序如果变成了进程,就会将其加载到内存当中,同时会生成进程地址空间。既然是要组成地址空间,那么二进制文件的每一个数据,每一个指令,都要有其虚拟地址吧。而每个指令的虚拟地址=起始地址+偏移量。比如我们知道Section1的起始地址,假设其偏移量为0,那么Section1的虚拟地址就是起始地址。而Section2的偏移量为400,那么section2的地址就等于section1的起始地址+偏移量。通过偏移量,生成进程地址空间时,就能将ELF当中的各个数据节的地址都标明出来。
我们可以通过readelf -S [ordinary file]查看到一个二进制文件的Section Header table optional。
那么讲了这么多,这个ELF,其实就是方便操作系统,认识一个进程的!!!一个进程的数据,代码要被操作系统管理起来,如何管理呢?根据这个ELF的内容来管理。
进程加载
逻辑地址
现在我们知道了,一个可执行程序运行时,首先是将磁盘当中的可执行程序从磁盘加载到内存中,然后操作系统创建对应的PCB(task_struct)以及进程地址空间(mm_struct),然后通过页表,将虚拟地址与物理地址进行映射,然后cpu处理进程时,找到内存当中的进程数据,拿到CPU当中进行处理。而一个可执行程序,在磁盘当中的内容,是按照ELF格式保存的。 我们知道一个进程会有自己的进程地址空间,比如栈区,堆区,代码区等等。进程运行起来,这些对应的xx区是有对应的地址的,那么问题来了,我们的mm_struct是个结构体对吧,那么在进程运行之前,我们的mm_struct还没有初始化,那么它肯定是要初始化之后,才能有进程地址空间,它是根据什么内容来进行初始化的呢?
这里我们要考虑一个问题,二进制文件里面的内容都是二进制,我们看不懂,但是二进制文件是有其ELF格式的,而ELF格式当中记录每个Section的地址。那么问题来了,可执行程序的数据和指令都有其地址,那么这个地址是运行之后才有的,还是运行之前,这些地址信息就保存在可执行程序当中了呢?
我们现在有一个可执行程序test,其在之前链接了我们自己的动态库libmylib.so(上一篇文章)。我们将其反汇编生成test.s文件,反汇编的指令如下:
objdump -S test > test.s
现在test.s就是test二进制文件反汇编转换成的汇编文件。我们打开test.s。 一个源文件生成可执行程序需要进行一下几步:预处理,编译,汇编,链接,而我们所谓的编译,其实就是将源文件翻译成汇编语言文件的一个过程,而所谓的反汇编,其实就是将二进制文件,重新转换成汇编文件。我们可以看到,在编译期间,编译器就已经将我们的源文件,按照一个一个节(Section)的形式,保存我们的源文件的数据和代码了。这说明ELF格式,就是在编译期间生成的。
我们还可以看到在这些数据和指令的右边,存在16进制的数字,这些数字其实就是我们每个指令对应的地址!!!
所以我们可以得出结论,一个进程当中的每个指令和数据的地址,在加载到内存之前,就已经存在于二进制文件当中了。我们将这些存在于二进制文件当中的地址,称之为逻辑地址。根据我们前面说的,一个地址,等于其实地址+偏移量,逻辑地址也不例外。
逻辑地址=起始地址+偏移量
根据我们前面所说,每个节的偏移量都是记录在section header table当中的,因此我们可以轻松的找到每个section的入口地址。 接下来我们要输出一个观点,那就是每个指令,都有其长度,cpu可以轻易的知道一个指令对应的长度。所以,我们如果得到一个指令的地址,然后当前指令的地址加上当前指令的长度,我们就得到了下一个指令的地址!!!(因为二进制文件的内容,可以视为一个一维数组)
当前指令的地址+当前指令的长度=下一行指令的长度
所以,如果我们能拿到一个指令的地址,剩余的指令其实是可以通过推导得出的,因此我们只需要记录下一些比较重要的地址就可以了,比如函数的入口地址,变量的地址,因为它们的地址需要跳转的。其余的指令的地址,只需要让cpu自己推导就行。
比如我们在main函数当中调用了my_strcpy函数,那么我们可以在汇编文件当中找到main的代码段。 因为这些函数调用都有其地址,当cpu执行这些函数时,会跳转到对应的方法处,而我们又知道这些方法的入口地址,因此就可以通过当前指令地址+指令长度的方式=下个指令地址的方式,获得所有指令和数据的地址。
那么虚拟地址和逻辑地址有什么关系呢?实际上linux当中的虚拟地址,其实就是逻辑地址的值,因为mm_struct会根据ELF当中的逻辑地址,来初始化进程地址空间的地址。只是虚拟地址是用来描述进程地址的,而逻辑地址是用来描述磁盘当中的可执行程序。
因此我们可以在ELF当中,读取到各个节的虚拟地址。 如果觉得逻辑地址与虚拟地址的关系还模糊不清,那么我们换句话说,我们的程序在编译时,会编译出各个段的逻辑地址,当可执行程序运行时,就会变成进程。而可执行程序当中的逻辑地址,会被进程的PCB用来初始化mm_struct,而mm_struct当中的地址,就是虚拟地址。
cpu寻址
现在我们要有一个认识,那就是进程地址空间中的虚拟地址,并不是在程序加载到内存之后才有的,而是当可执行程序还在磁盘当中时就已经存在了。进程地址空间只是根据磁盘当中的逻辑地址,初始化出了虚拟地址而已。
那么现在进程已经加载到内存了,下一步就是让cpu执行进程当中的指令和数据。为了让cpu能够将进程连续的执行下去,在cpu内部存在一个叫做pc(指令计数器)的寄存器(进程切换文章当中讲过)。pc寄存器的作用,是保存下一个执行的指令的地址,那么问题来了,当我们的进程开始运行时,此时pc指针应该怎么找到进程的入口地址?
答案是保存在ELF header当中,我们使用readelf -h指令查看一个可执行程序的ELF Header。进程的入口地址就保存在Entry point address当中。 因此,当一个进程运行时,操作系统会读取可执行程序的ELF Header,这样就能找到进程的入口地址Entry point address。接着讲入口地址,交给pc寄存器,这样子后续的指令,就能通过当前指令的地址+当前指令的长度的方式,继续保存在pc寄存器当中。
我们假设有一个代码编译后形成的指令如下: 我们假设该进程的入口地址是0x01,那么pc寄存器当中的数据如下: 根据前面所述,一个程序的指令的编制是逻辑地址,加载到内存之后就变成了虚拟地址,因此除了虚拟地址,还有其物理地址在真实的内存当中,通过一个页表进行映射。 但是我们的pc指针记录的是虚拟地址,那么该如何拿到物理地址呢?这里要介绍另一个寄存器,叫做CR3,CR3寄存器保存的是进程中,页表的物理地址(页表是内存级的结构,由操作系统创建在内存当中)。 而除了CR3,CPU中还存在一个硬件设备,叫做MMU,用来记录页表当中的内容(即虚拟地址和物理地址之间的映射)。因此cpu寻址的本质,其实通过pc指针记录下待执行指令的虚拟地址,接着通过MMU+CR3查页表当中的内容(即物理地址),接着通过物理地址找到指令,将指令加载到eif寄存器当中,至此,我们就能让cpu通过虚拟地址寻址了!!! 因此,实际上查表这个工作是由cpu当中的硬件电路完成的,而pc指针在eif执行完指令后,就会指向下一条指令的虚拟地址(当前指令地址+当前指令长度,或者跳转指定地址),接着循环整个工作。那么这个进程就能执行了。
因此我们现在回看虚拟地址,就会发现虚拟地址其实是硬件(cpu)、操作系统、编译器,三方合作下的产物,使用虚拟地址,可以让编译器忽略物理地址,按照平坦模式的编址方式进行编址,效率变高,而cpu也不需要考虑物理地址了,只要有虚拟地址,节能通过虚拟地址找到物理地址,这样内存就可以完成分段加载的功能(即用得上的才加载,用不上不加载,用上了再加载。),提高了内存的使用率。所以我们现在能看到,16G运存的笔记本,可以运行上百G的游戏,这其实是分段加载的功劳。
区域划分
前面已经说到ELF和进程地址空间的关系,但是在ELF格式当中,我们只看到了代码区和数据区对应的Section,但是栈区呢?堆区呢?共享区呢?这里我们可以将区域分为静态区域和动态区域,静态区域则是代码区和数据区,代码区域是静态的很好理解,因为在进程运行的过程中,代码不会自行增加,也不会自行减少,而数据区的数据(如全局变量),也不会自主增加和减少,它们的个数是固定的。
而我们栈区、堆区、共享区的则不是,我们在学习c/c++时,都知道局部变量是有生命周期的,生命周期开始时变量被创建,生命周期结束时变量被销毁,因此它们的个数是动态的,随着进程的运行,个数会发生变化,而堆区则是根据的我们进程的资源使用情况来看的,向操作系统申请资源时,堆区就会变大,销毁资源时,堆区就会变小。而共享区则是用来保存动态库的,有人这时候就会说了,一个程序链接的动态库不是固定的吗?没错,一个程序链接的动态库是固定的,但是动态库加载的个数却不是固定的,比如一个程序链接了10个动态库,我们就要将10个动态库全都加载到内存当中吗?当然不是,只有当我们的进程,需要执行动态库当中的方法时,才会将动态库加载到内存,因此共享区也是动态的。
而这些动态的区域,其具有不确定性,因此不会被记录在ELF格式当中,那么我们该如何生成它们的进程地址空间呢?我们的mm_struct(其实就是描述进程地址空间的结构体)存在一个成员,该成员是一个vm_area_struct的结构体指针,我们可以看看源码。 我们可以看到vm_area_struct的备注,VMAs表示有多个vm_area_struct结构体,而list表示这些结构体都是以链表的形式链接起来的。
当我们的栈区、堆区、共享区这些动态区域一旦发生变化,那么就会有对应的vm_area_struct被插入,或者被删除。这些被链入的vm_area_struct都有其vm_start和vm_end表示该区间的起始地址和结束地址,这些地址就是虚拟地址,因此通过vm_area_struct,我们就能找到这些区间对应的虚拟地址,然后通过页表映射物理地址。这样就能完成动态区域的地址空间划分了。
动态库加载
动态库在没加载之前,是保存在磁盘当中的,而且动态库的格式,也是ELF格式,这说明动态库当中的每个方法,其实都是其对应的逻辑地址的。
当动态库加载到内存中时(有进程需要用到所链接的动态库方法),操作系统会将动态库从磁盘加载进内存当中,而且操作系统需要负责对动态库进行管理。我们在之前的章节中提到过,操作系统对目标对象的管理方法,是先描述在组织。比如为了管理文件,会将文件描述成struct file,然后通过struct file进行文件管理,管理进程时,现将进程描述成struct task_struct。通过task_struct对进程进行管理。那么对于动态库也不例外,这个动态库也会被一个结构体管理起来。该结构体当中会保存动态库的属性,比如动态库被加载到内存的物理地址,动态库当前与几个进程进行链接,都要被记录下来。我们暂时认为该结构体的名称为struct shared_lib
现在有一个链接了libc.so的进程test被加载到内存当中。那么当执行动态库方法时,动态库也会被加载到内存当中,于此同时,进程test的进程地址空间的共享区,会记录下动态库的虚拟地址。由于操作系统可以通过struct shared_lib来找到libc.so的物理地址,因此就能在进程test的页表当中,映射出共享区的libc.so的虚拟地址和物理地址之间的关系。
而动态库当中的方法,在编译期间会按照ELF格式,编入方法的偏移量。因此我们就可以通过偏移量找到共享区当中的方法。比如libc.so当中存在一个printf函数,其对应的偏移量为0x100。我们的进程需要执行printf函数,就要跳转到动态库libc.so当中。那么如何跳转呢?在进程test当中,所有与libc.so方法有关的指令,都会跳转到libc.so所对应在test共享区当中的虚拟地址。而且由于知道方法对应的偏移量,cpu就能通过入口地址+偏移量的方式,定位到libc.so的对应方法的地址处。也就是所谓的跳转的原理。
那么进程是怎么知道这些方法在各自动态库中的偏移量呢?答案就是这些偏移量都保存在可执行程序的ELF当中,在一个名叫GOT的Section。我们通过readelf -S [ordinary file]的方式看看GOT。 got的全称叫做gobal offset table,即全局偏移量表,所有链接的动态库方法的偏移量,全都保存在got当中,因此进程可以轻松的读取到所使用的动态库方法,在动态库的什么位置,然后进行跳转的操作。