在上一篇的内容中,已经对于动静态库的制作和使用有了一个基本的理解和认知,那么本篇将要讨论的内容是,动态库的底层原理和与进程相结合构建出一份完整的调用网,进而对于进程和动态库有一个更加清楚的认知

动态库加载

基于前面的两个问题做出的一个原理剖析

静态库我们就不考虑加载了,主要原因是因为静态库本身就已经被加载到了程序的内部,所以没有过多的意义,那么动态库加载有什么意义呢?

在使用gcc进行编译的时候,使用的选项是-c,然后就会形成.o文件,将这样的文件打包就形成了静态库,动态库也是这样的原理,只是在这之前多了一个叫做fPIC的选项,这个东西叫做与位置无关码,那么什么是与位置无关码?该如何理解呢?

库和程序都要加载

在了解它之前,先要清楚的概念是,现如今形成的一般的可执行程序的格式叫做elf文件,也就是说给了一份源代码,经过编译后会形成一个elf格式,生成的这个二进制是有规则的,格式是elf,而对于可执行程序中的elf中会存在很多很多的内容,比如包括有代码区,全局数据区,只读数据区等等,同时也会生成一张表,在这张表上会记录的是函数的具体位置,我在这里调用的这个函数所在的位置是某个.so库里面的某个地址,这样就把可执行程序中用到的全部方法都列到了这张表上,最终达成的效果是,把每一个库里面所用到的方法的地址都填进来,使得最终可执行程序和库中的特定方法的地址产生了关联,这样的过程就叫做动态链接

在经过了动态链接后,就要进行加载,也就是把可执行程序加载到内存中,但是这不够,前面也说到了,库函数同样需要加载到内存中,现在的可执行程序中只有库函数的地址,但是却没有库函数的视线,所以想要让程序真正运行起来,需要的就是把程序所依赖的函数库也要加载进来,虽然可能并不是要立刻加载进来,但是当执行到这个代码语句的时候,这个库必须存在可以让进程调用这个函数

在调用的过程时,跳转到库内的对应位置,调用后再返回到原来的位置之后就可以继续执行了,因此我们说,动态链接要加载的不止是自己,与动态链接关联的库也要全部加载,如果我们要链接的库本身不存在,就会直接报错,在程序加载期间,加载器就会报错说不存在

可执行程序的地址问题

可执行程序在编译形成可执行文件后,但是还没有加载到内存中,对于这个单独的文件,它有地址吗?答案是有的,这是因为当程序被编译之后,所有的函数名变量名等等内容,都不再会存在,取而代之的是一个一个的地址,从汇编代码的角度来讲,每一步的跳转背后都是一个一个的地址,比如当前在函数的内部定义了一个临时变量,这个临时变量是在栈区存在的,但是此时可执行程序中可并没有栈,栈是在程序加载到内存中运行的时候才有栈区的概念,但是不影响,这个变量是在函数的内部形成的,这也就意味着在形成变量的时候,是通过寄存器为出发点,再加上偏移量,就可以进行访问,所以最终函数名变量名都是地址了

在C/C++程序中,当调用取地址操作的时候,实际上是在打印的时候,地址数字直接把取地址变量名替换掉了,所以就能看到对应的地址了,包括函数也是这样,所有的函数都是没有函数名的,只有一个二进制的代码块,只要找到代码块的位置,就能从上往下进行执行了,最后执行到return语句就返回了

地址问题

上面这个模块引出的观点是,程序在编译好,没有被加载到内存中的这个独立的过程中,实际上在内部就已经有地址了,只是这个地址需要考虑到另外一个问题,就是在代码中是如何对于各个变量函数进行编址,也就是说,在编译的过程中是如何对这些内容编出对应的地址的呢?理论依据就前面所说的虚拟地址空间的概念

虚拟地址空间不仅仅是操作系统的一种映射技术,更重要的是,虚拟地址空间是一套标准,操作系统在内部要为进程创建地址,代码区,堆区栈区等等内容,而在编译可执行程序的时候,可以按照虚拟地址空间的方式来对可执行程序进行编译,所以说在形成的代码区域的对应位置就会有各种各样的区,这样就完成了编址的目的,未来在对这部分内容进行加载的时候,就相当于直接按照内存进行加载,内存是这样的,磁盘也是这样的,加载的时候就更容易进行模块化的对应过程,因此这个部分想要输出的观点是,虚拟地址空间,不仅仅是操作系统里面的概念,更是在编译器编译的时候,也要按照这样的规则来编译可执行程序,这样才能在加载的时候,进行从磁盘文件到内存的一种映射

因此基于上述的原理,在可执行程序进行编制的时候,函数对应的长度和起始地址都是已经确定好的,对应的区间起始地址和长度也都是确定好的,这些地址都是在加载到内存之前就已经全部确认好的,得出的结论是,我们所有的可执行程序在进行还没有加载到内存的时候,我们的代码和数据其实已经具备了虚拟地址空间这样的概念

逻辑地址和平坦模式

上述的可执行程序已经有了虚拟地址,那么对应在磁盘当中也应该有同样的一套地址,只不过在磁盘中不叫做虚拟地址,名字叫做逻辑地址,而逻辑地址其实可以理解为是相对地址,所谓相对地址就是基地址和偏移量组合起来的概念,那么在这里所说的代码和数据都可以借助这样的原理,在编译的时候都写好对应的位置,就能进行快速的跳转,把内容放置到磁盘中,虽然这个程序还没有被用户所执行,但是这个程序基本上应该是遵循什么样的逻辑思路已经被确定下来了

如何理解逻辑地址?其实最简单的一种方法,就是假定基地址是0,那么对于32位的机器来说,它的偏移量的取值范围就是从[0,FFFFFFFF],这种起始偏移量为零的可执行程序的编址方式,在Linux中就叫做平坦模式,所以这里我们就引出了平坦模式的概念

所以,程序是有代码区和数据区的,本质上就是规定代码区的起始地址是什么,偏移量是多少,未来代码区的起始地址就会放到寄存器中,数据区的起始地址是多少,偏移量是多少,每一个区域都会形成一个段,每一个数据段里面的起始地址和偏移量都会采用这样的方式来进行定位,最初的Linux使用了虚拟地址,所以就规定了基地址就是零,偏移量是多少是根据未来的不同情况来决定

不过对于现在来说,基本上逻辑地址和虚拟地址的概念没什么太大的区别,对于一个可执行程序,在磁盘中所使用的地址叫做逻辑地址,只不过这个逻辑地址采用的是起始地址为0,偏移量是从0到全F的这样的一种方式进行编址,这个就叫做逻辑地址

绝对编址和相对编址

计算机在对程序进行编址的过程中,会有上面的两套编址方式,这两种编址方式其实就是参考点的选择问题,也很好理解,这里不再过多赘述,想得出的结论是,如果采用绝对编址的方式,当一个模块发生了变动,其他的所有模块都会发生变动,但是如果采取的是相对编址,不管相对于参考的内容如何进行改变,区域和区域之间的地址不会有任何变化,只需要在对应的位置加上所谓的偏移量就足够了,这就是想要输出的核心观点

与位置无关码

通过上述的这一系列过程,就引出了与位置无关码的概念,说白了就是与位置无关,采用的是函数在库中的起始偏移量是多少,未来这个库在内存中的什么位置加载到对应的位置,函数的地址都不会改变,这就是与位置无关

小结

上面输出的几个重要的观点总结如下

第一个是,在编译形成的elf格式的可执行程序,在Linux中如果采取的是动态链接的方式将程序和库里面的指定内容产生关联,那么在未来进行加载的时候,程序要加载,库也要加载

第二个是,如果程序没有被加载,里面会有地址吗?答案是有的,因为这样的地址实际上是在计算机编译好之后就已经存在的,根据虚拟地址空间的方式做好了一定的编址,编址结束后,代码后就不再会存在任何变量名符号等等内容,取而代之的是从0到全F的这样的编址形成的可执行程序,因此我们说,当程序编译好之后,对于函数的调用就转换成了对于地址的调用,因此在汇编语言中可以看到,实际上对于函数等等的调用都会转换成的是call一个地址来表示函数的调用

因此从上面的结论是,虚拟地址空间不仅仅是指导操作系统进行这样的设计,更重要的是,虚拟地址空间也是当代编译器必须支持的内容,如果没有提前建立好虚拟地址空间,那么在程序中如何对于这些空间建立映射,更怎么使用呢?因此,在进行编译的时候,就已经按照对应的地址空间的方式来进行编译了,这样的模式就叫做平坦模式,所谓平坦模式就是基础量是0,偏移量是0到全F,这样的模式在Linux下就叫做平坦模式

第三个是,绝对编址和相对编址,这个其实很好理解,这里不再多说,只是想提及的是,在对于一份代码进行编址的过程中,可以采取绝对编址,也可以采取相对编址,举两个最典型的特征是,例如在可执行程序中的编址就是绝对编址,也就是说在可执行程序中的地址都是已经提前确定好的,而在可执行程序调用库函数的过程中采取的就是相对编址,这样可以根据被调用函数在库中的相对偏移量来更快的确认地址来进行函数的调用,进而提高加载的效率来满足我们的各种各样的需求

动态库的加载与进程相结合

下面要提到的内容会和前面的知识向整合,我们上面这么大的篇幅谈的内容都是动态库的加载,那么加载的目的地是哪里?答案是操作系统,而加载的目的就是因为有进程的存在,进程的调用才会产生上面的这些内容,因此本篇总结的核心内容就在于此,总结的核心内容就是关于上面的这一系列过程是如何与进程相结合,如何供进程使用的

动态库的加载

首先,画出下面的逻辑图,从而引出要讨论的主题:

在这里插入图片描述
上图引出了我们要探讨的第一个问题,就是动态库是如何与虚拟以及物理内存,以及PCB建立关系的

先理解一下上图要表达的一个过程,顺便进行一些知识整合:当用户要加载一个进程时,操作系统就会为进程创建一个task_struct,并且把程序加载到内存中,并且会创建对应的虚拟地址空间用来维护各个区域,最后再经过页表将数据进行对应,这是可以理解的,也是前面已经提及到的内容

那么现在新增的内容是,进程中会调用一些函数,这些函数会与多个动态库有联系,而我们知道,在可执行程序采用动态链接进行链接库的时候,会想办法让可执行程序与库建立联系,其实也就是存储了对应的地址,在动态库内有关于各个函数的偏移量,这样借助库的绝对地址以及函数相对于库的相对地址,就能找到对应的函数,而前面已经知道的结论是,在进行加载的过程中,不仅可执行程序需要加载,库也需要加载,但是库并不是立刻就被加载,而是在它需要被调用的时候才会被加载到内存中,加载之后,在寻址执行正文代码的过程中,不仅要找到正文的代码,也要找到库对应的代码,才能进行合适的调用,因此会把动态库加载到共享区中

动态库会被加载到堆和栈之间的这一块区域,也被叫做共享区,之后也会在页表中和物理地址建立对应的联系,库被加载后就可以被进程所用了,那共享库被使用的时候,一定会被加载到一个固定的位置吗?显然不是,因为在可执行程序进行加载的过程中可能会调用很多很多的库,并不一定会被映射到对应的位置,而可执行程序的源代码也会通过一些源码直接打开一些库把内容加载进来,然后进行运行库中的方法,这样的话就相当于通过编译链接本身就已经指明了要调用的一些库,所以想要加载到固定的位置是不太可能也没有必要的,更何况还可能在加载的过程中去移出一些不再会使用的库,因此让库加载到对应的地方这件事,做不到

所以最终的设计模式是,库被加载之后,被映射到了指定使用了该库的进程的地址空间中的共享区部分,最终的效果是让库在共享区中的任意位置,都可以正确运行,这样的理论前提就是页表的存在,不管动态库最终被加载到什么位置都不重要,只要有页表的存在,可以和物理地址之间建立起联系就足够了,当然前提是库加载到内存中之后位置不能发生改变

在前面的内容中对于地址空间有这样的理解,CPU是最后执行代码的执行者,它在执行进程当中代码的过程时会通过地址空间找到代码的正文部分,再通过正文借助页表,找到可执行程序,把指令和地址等等都读到CPU当中,所以CPU访问的大多都是虚拟地址

库函数的加载

在这里插入图片描述
下面要讨论的内容是关于库中实现的函数是如何进行加载的,具体理解如下

要清楚的是,库函数在库中存储是以相对编址的形式存在的,那么可执行程序加载到内存中,其中一部分代码加载到内存中了,在代码区中此时也经过页表的映射建立好了联系,加载器要加载的不仅仅是可执行程序,它会进行判断,发现需要加载的内容还有部分库,也会一并加载进来,加载的地方就在共享区中,并且库的位置可以随意加载,但是加载后库的位置必须固定,所以此时库加载进来库的起始位置就确定了,于是在可执行程序中就会把库的符号都替换成对应的地址,在上图中也能看到,对应的地址会在库加载到内存后发生变换,此时的正文代码中存储的就是库的地址,而不是库的名称,而执行到这个代码的时候,会发现这个代码需要用到库中的某个方法,那么调用的根据就是相对编址

一种理解的方式是,在进行加载的时候,会先把库加载到内存中,此时库的位置就已经确定了,加载的过程会读取对应的符号表,于是就可以把库的名称全部替换成地址和对应的偏移量,而找对应库中的方法也可以借助这个相对偏移量来找,从这里也能理解为什么加载库的地址可以随意加载,只要固定就可以,因为进程不关注你在哪加载的,它只关注的是库的方法该从哪获取,只要这个库的位置固定了,那么借助库的位置找到函数的方法地址也是水到渠成的事,这也就是在进行编址的时候要带上fPIC选项的原因,这样的编址得出的结果是与位置无关的,因为存储的是相对位置

从上图中可以发现一点,函数或者其他内容进行的跳转,都是在地址空间当中进行的跳转,例如可能会在正文代码中存在很多很多的函数调用,会在自己的地址空间内不停地调用和返回,其次的一个结论是,当动态库映射到了地址空间中后,调用库函数的本质就是在地址空间内进行函数的跳转,和对应调用普通函数是没有区别的,顶多是调用距离的问题,这对于地址空间来说是没有区别的

其他程序加载问题

下面的问题是,当其他程序进行也进行使用的时候,这些库还需要加载吗?答案肯定是否定的,当后续启动一些程序的时候,这些库可能早就被加载到物理内存中去了,此时只需要把程序加载到内存中,构建PCB,再对应构建出页表,就能把库直接映射到共享区,然后再继续其他的内容就可以了,这个就叫做动态链接

谈谈共享的含义

为什么叫做共享库呢?假设现在操作系统中存在几十个,几百个程序都和某个共享库有关系,但是在物理内存中只会存在一个动态库,所以叫做共享库,它强调的内容是,在程序进行加载的时候都是直接把库映射到自己的共享区就可以了,而静态库就失去了这个能力,它需要一份一份的拷贝到对应的函数调用处,所以也就会浪费空间,那么动态库的本质就是把整个系统中所有经常使用的代码都放到一起,这样不必使用很多份,有效的节省内存空间

实现轮转

下面就是最终结果,一个程序是如何转起来的呢?

在这里插入图片描述
上图展示的就是整个逻辑过程,CPU当中存在一个指令寄存器,当它要执行指令的时候,其实就是把正文部分的代码直接读取到自己的指令寄存器中,然后由CPU去读取指令,分析指令,读取指令,而在前面关于虚拟地址空间的概念中提及到,程序在编译的时候虚拟地址就已经建立好了,也就是说在磁盘当中形成的这个可执行程序中就已经有了基本的地址,此时形成的地址就叫做逻辑地址,此时将这个可执行程序加载到内存中,内存中和磁盘中的文件形式和地址都是完全不变的

可执行程序的代码段里面加载内存的时候,就相当于把对应的地址加载到内存中,经过页表映射到对应的位置,未来CPU在执行的时候就可以找到对应的地址,因此,可执行程序加载到内存中是一定要占据物理内存的,有自己的物理地址,才能建立映射,而未来在使用时的任意的方法以及调用,使用的都是提前建立好的这一套地址,物理内存放的位置可以变更,但是实际的逻辑地址是不可以变更的,未来也只是在此基础上用页表进行的映射,仅此而已

那么也就是说,当可执行程序加载进来之后,页表的右侧是可以填的,因为已经有了对应的物理地址,物理地址已经确定了,更重要的一点是,形成的可执行程序的符号表会记录下来入口地址,也就是entry,用来提供给操作系统读取,说白了就是告诉操作系统,你应该从这里开始这个程序了!那函数的入口地址有了,那么就可以在建立页表时候建立main函数与其所在区域的映射关系

这样,程序就能跑起来了,一旦加载就有了物理地址,而在刚开始运行的时候,对应整个程序的虚拟起始地址main函数的地址也就知道,那么最开始就能在页表中建立对应的映射关系,那么CPU在执行这个程序的时候,只需要把main函数的地址加载到指令寄存器中,指令寄存器拿到的就是虚拟地址,指令寄存器之后会找到对应的进程的地址空间,找到页表,就能进行对应的转换,转换后就能找到对应的物理地址,读取到对应的第一条指令,假设现在该读取到的是一个函数的指令,那么就要call对应的地址,但是也问题不大,起始地址都有了,剩下的虚拟地址都是连续的,一个一个的向下执行,将对应的地址加载到CPU中,CPU就能对应的执行函数跳转,CPU得到的数据从始至终都是虚拟地址,它想要获取物理地址也再简单不过,借助页表就可以完美找到

小结

结论就是,编译器形成的可执行程序的地址全部都是虚拟地址,加载到内存的时候编址不变,虚拟地址的入口地址也被记录下来,于是就可以在虚拟地址和物理地址之间构建映射关系,并且读取到CPU的寄存器中,CPU在执行代码的过程中,直接从虚拟地址映射到逻辑地址,只有在物理地址才能找到代码的真实指令,之后会在虚拟地址的空间中逐条语句的向后进行,当需要call命令就进行对应的函数跳转,跳转到对应的位置进行读取对应的实现方法即可

当可执行程序被编译形成的时候,里面已经有了绝对编址形成的虚拟地址,加载的时候会把文件加载到物理内存中,在物理内存中找到对应的具体地址,这样就固定了,而一个程序加载到内存中就意味着它既有虚拟地址,又有物理地址,每一个代码语句和变量都是拥有的,虚拟地址是方便操作系统去寻找,而物理地址方便CPU去读取指令使用,当程序加载进来之后,页表从虚拟地址到物理地址就全部建立起来了

对于页表的建立,可以简单理解为,当可执行程序加载到内存中之后,页表的左边存储的是虚拟地址,页表的右边存储的是物理地址,而虚拟地址我们已经有了,那么就都填充到页表的左边,物理内存一旦加载了就有对应的物理地址,那么就存储在页表的右边,那么这样的映射关系就已经完美的建立完毕了,页表也正是填充完毕,可以供操作系统随时调度查找了

页表建立之后,会提供一个入口地址,也就是main函数的地址,通过虚拟地址转换物理地址就可以找到对应的起始地址,执行结束对应的雨具之后,读取下一条指令,读取的也是虚拟地址,那么再经过页表就能找到物理地址,读取指令,然后继续下一条指令…

总结

CPU内执行可执行程序,会做的就是虚拟地址到物理地址的转换,为什么?

原因就在于此,CPU在访问代码和内存的时候,会做虚拟到物理的地址转换,如果CPU在读取正文没有被修饰的时候,读取到的就是物理地址,但是现在读取到的是虚拟地址,所以就要存在从虚拟地址向物理地址的转换,也就有了页表的意义

那问题是,为什么CPU不直接读取物理地址呢?因为CPU读取虚拟地址很方便,体现在去执行语句很方便,更是因为借助页表映射物理地址更加方便,程序加载到内存的时候页表就都创建好了,直接去映射就可以了

动态库总结

  1. 动态库在内存中只有一份,不像静态库大家自己用自己的
  2. 那可以把动态库统一管理起来,每次加载的时候分配一个虚拟地址x,其他进程也要使用这个动态库的话,它的虚拟地址也填x,这样就容易管理
  3. 如果不这么做,每个程序对动态库也写死,就无法确保动态库虚拟地址的一致,管理就麻烦
  4. 因为动态库的虚拟地址是加载才分配的,为了方便后续找到区分对应函数,就引入了偏移量的概念
  5. 动态库的更新替换更加方便,只需要更改对应的偏移关系就可以,不用修改程序代码(虚拟地址是第一次加载才分配的)
  6. 动态库跨平台的兼容性就更好
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐