连接与装载是一个比较晦涩的话题,你们每每容易陷入复杂的细节中而难以看清问题的原本面目。从本质上讲各个系统的编译、连接、装载过程都是大同小异的,或许能够用一种更抽象的形式来理解这些过程,梳理清楚宏观的前因后果有利于对特定系统进行深刻学习。前端
本文主要根据《程序员的自我修养 —— 连接、装载与库》和本身的理解总结而来,书的内容是基于 GCC 的,不过笔者尽可能以更抽象、更简洁的方式把问题讲清楚,避开那些恼人的细节。程序员
不直接使用机器语言进行应用程序开发是为了提升开发效率,但程序终究是机器运行的,因此才有了复杂的编译连接过程,将源代码转换为机器指令。算法
程序员通常使用 IDE 进行应用程序开发,对于须要先编译成机器语言再运行的程序,在执行运行指令后时常会陷入漫长的等待才能运行起来,这期间计算机作了大量工做:后端
大体流程就是如此,不一样平台在细节处理上会有所不一样,下面分析具体过程。数组
在静态连接以前,能够简单理解为程序员在 IDE 中写的参与运行的代码文件会转换为对应的目标文件,了解目标文件的构成是理解连接装载的前提。缓存
目标文件中包含了编译后的机器指令、数据,还包含了用于连接的信息、调试信息等,这些内容按照属性不一样以段 (Section) 的形式分开存储。函数
该图只是个大体结构,还有不少段没有例举出来。好比还有 Readonly Data 段存储只读数据(const 修饰变量和字符串常量),Debug 存储调试信息,以及动态连接相关的 Dynamic 段等。这些繁杂的 Section 这里不直接展开,而是优先关注图中这些具备表明性的结构。学习
文件头是访问目标文件的入口,是一个结构体,它包含了文件类型(并非用拓展名判断类型的)、字节序、入口地址等基本信息,这里最须要关注的是它提供了段表在目标文件中的偏移。优化
Section Table 是一个很是重要的段,它是一个结构体数组,每个元素包含了某个段的段名(其实是在字符串表中的偏移)、段在目标文件的偏移、段的长度、段访问权限、段类型(并非用段名判断类型的)、段虚拟地址等。操作系统
目标文件中用到了段名、符号名等字符串,字符串的长度不定,没法用固定的格式表示,因此将这些字符串集中起来依次放入一个表,字符串之间用\0
分割。如此,目标文件中访问字符串只须要提供一个偏移。
函数、变量等字符串每每主要是指令来访问,段表名字符串主要是连接器来访问,为了分离职责,使用 字符串表 来存储普通字符串,使用 段表字符串表 来存储段表中用到的字符串。
文件头中除了包含段表的偏移,还包含了段表字符串表在段表中的下标,因而可知,经过访问文件头就能访问到全部的段。
函数和变量统称为 符号 ,符号表记录了目标文件中用到的全部符号,值得注意的是还会包含段名,段名是编译器生成的而不是源代码中的。
符号表是一个结构体数组,每个元素记录了某个符号的符号名(在字符串表中的下标)、符号值、符号类型(段仍是函数或变量)、符号绑定信息(局部仍是全局、弱符号仍是强符号)、符号所在段(在段表中的下标)、符号大小(数据类型的大小)。
这里须要注意的是符号值:
因而可知,符号表相似于“路由器”的角色,它能告诉咱们某个符号在哪一个位置,固然目标文件中的符号表并不是一个已经知晓全部“路由信息”的“路由器”,在后文分享连接时会详细说明。
符号分为弱符号与强符号,对于 C/C++ 来讲,编译器默认函数和已初始化的全局变量为强符号,未初始化的全局变量为弱符号,可使用__attribute__ ((weak))
定义一个弱符号,编译器决议符号时有以下规则:
弱符号的场景:组件提供弱符号的默认函数,开发者可使用强符号的自定义函数覆盖实现。与弱符号对应的还有弱引用,若是弱引用的符号有定义,连接器决议该符号,若是弱引用的符号未定义,连接器不认为是一个错误。
BSS 段存放是的未初始化的局部静态变量,不一样编译器实现可能有差别,因此主要是理解思想。
BSS 段在图中之因此标记为灰色是由于它不占用目标文件空间(能够理解为不占磁盘空间),但在装载时和其它段同样分配虚拟空间。应该很容易想到,未初始化的局部静态变量之因此不占用磁盘是由于它们的默认值都为 0,既然都是 0 就不必专门拿磁盘空间来存它们的值。若是局部静态变量初始值设置为了 0,编译器仍然可能进行优化,把它放到这个 BSS 段。
BSS 段存在的意义就很明显了:节约磁盘空间。
排除只会存在于栈中的局部变量、存在于只读数据段的常量,还有一种符号可能也会放入 BSS 段:未初始化的全局变量。GCC 不会将其放入 BSS 段,而是在符号表中将其标记为 Common(具体看静态连接 Common 块)。
注意:此部分说的地址若非特别指明均指虚拟地址。
模块在编译成目标文件的过程当中,编译器会试图修正内部的符号引用,若是符号是定义在模块内部的,直接修正调用地址(可能是相对调用,并无肯定实际虚拟地址);若是符号是定义在模块外部的,编译器则没法得知这个符号的调用地址。
这个外部符号可能定义在其它目标文件中(这部分不考虑定义在共享文件中的状况),如何修正外部符号的引用正是静态连接的核心问题。
静态连接是指将多个目标文件合并为一个可执行文件,直观感受就是将全部目标文件的段合并。须要注意的是可执行文件与目标文件的结构基本一致,不一样的是是否“可执行”。
另外,可执行文件要被操做系统装载到内存中运行,因此还须要为其分配虚拟地址。
注意:页映射、装载相关请看后文。
首先须要明白有页映射机制的存在,虚拟页与物理页大小一致,装载是以页为单位的,一般状况下须要保证一个虚拟页的信息的访问权限一致。
最简单的方式是将各个目标文件的段按顺序叠加,这样作有个很大的问题就是没法判断相邻段之间的访问权限是否一致,因此虚拟页分配时只能将每个段都视为不一样属性。那么若是页大小是 4096 字节,即便段只有 1 字节,也要占用一个虚拟页大小的地址空间,这样会形成不少内存碎片浪费空间。
采用类似段合并策略,将相同属性的段合并有利于管理,装载完全部段将使用更少的虚拟地址页,有效下降内存消耗(在装载部分有分析进一步的优化)。
空间分配过程完成后,每一个段的虚拟地址就肯定了。值得注意的是,图中段的位置并非表示虚拟地址,而是能够理解为在磁盘中的位置。那么段的虚拟地址存放在哪儿呢?实际上就是存放在前面提到的段表里面,段表数组元素有一个属性就是段虚拟地址。
须要注意的是,全部目标文件的符号表会合并为一个 全局符号表 ,这是一个很是重要的段。
段的虚拟地址肯定后,就须要肯定每个段中的符号地址,以前提到的编译时修正符号地址只是一个相对地址,好比0 + 0x66
(0x66
表示符号在段中的偏移)。这里修正的方式很简单,就是段起始地址+符号偏移,好比段起始地址为0x88888888
,则修正为0x88888888 + 0x66
。
绝对地址引用比相对地址引用速度更快,因此连接器会尽量的将符号引用修正为绝对地址引用。
另外,还要将 全局符号表 中对应的符号地址就行修正。
须要注意一点,这个步骤修正的仍然是某个段内部定义的符号,而对于这个段引用的外部符号仍然处于待修正状态。
通过上面的步骤,可执行文件生成了,各个段及其内部符号引用虚拟地址肯定了,还差最后一步:修正各个段中对外部符号的引用地址,这个过程称为 重定位 (各个目标文件已经合并为一个文件了,这里说的外部符号实际上是对于合并以前而言)。
在这以前须要了解一下重定位入口的集合——重定位表。每个须要重定位的段都有一个与之对应的重定位表。
重定位表也是一个结构体数组,该结构体包含:
基于前面介绍的各类段结构,符号解析与重定位过程实际上很是简单,无非就是根据重定位入口的符号在符号表的下标,找到该符号对应的目标地址,找出重定位表对应的段,根据重定位入口的偏移填入这个目标地址。
连接器扫描完全部的重定位表,全部的重定位入口符号都能在全局符号表中找到,不然连接器就会报符号未定义错误。
Common 机制能够理解为延迟决议,便可能有多个不定因素影响,在考虑完全部不定因素后才能决议。
未初始化的全局变量属于弱符号,编译器将其标记为 Common。对于某个目标文件来讲,它没法肯定其它目标文件中是否有强符号或者占用字节更长的弱符号(强弱符号前面有讲解)。因此只有在连接器遍历完全部目标文件后才能肯定这个符号的占用空间大小,那个时候再去为未初始化的全局变量在 BSS 段分配虚拟空间。
这么处理的直接缘由是编译器容许符号重名。
可执行文件存在于磁盘中,须要读入内存才能由 CPU 执行,在讨论如何将可执行文件装载以前,须要先了解物理内存分配策略。
这里主要讨论物理内存如何为各个进程分配空间。最简单的方式就是直接为进程划分物理内存区域,这会有不少缺点:
加入虚拟内存中间层,直接解决地址空间不隔离、程序运行地址不肯定的问题。咱们在前文所提到的地址都是指的虚拟地址,对于每个进程来讲,都是本身独占虚拟内存空间,而最终的物理地址区域由操做系统映射。
然而,单纯的将程序所占虚拟地址空间直接映射到物理内存没法解决内存使用效率低的问题,物理内存仍然会快速消耗殆尽。
程序局部性原理:一个程序在运行时,某段时间内只使用到了一部分程序数据。因此将虚拟地址、物理内存、磁盘空间都划分为页为单位,写入物理内存的粒度缩小为页,而非整个程序。
虚拟地址空间中的页称做 虚拟页 (VP, Virtual Page) ,物理内存中的页称做 物理页 (PP, Physical Page) ,磁盘中的页叫作 磁盘页 (DP, Disk Page) ,进程捕获到虚拟页未装载时称为 页错误 (Page Fault) ,虚拟地址到物理地址的转换通常使用 MMU (Memory Management Unit) 。
核心思路:进程读取某个地址时,其所在虚拟页 A_VP 发现未绑定物理页 A_PP,发生页错误,操做系统接管进程,找到虚拟页 A_VP 对应的磁盘页 A_DP,将 A_DP 写入物理页 A_PP(若物理页使用殆尽会使用淘汰算法去清理或压缩部分物理页),将 A_VP 与 A_PP 绑定,以后控制权交由进程,访问地址成功。
前面已经分析了,可执行文件将段按照页整数倍来分配虚拟地址,虽然已经将全部目标文件中类似段合并了,但每一个段对于一个页(好比 4096 字节)来讲仍是过小了,仍然会浪费不少虚拟地址空间,从而映射后也会浪费物理内存。
对于操做系统来讲,它并不关心每一个段的类型,主要是关心它们的访问权限。因此,前面提到的类似段合并的过程当中,不只将多个类似 Section 合并为一个 Section,连接器还会尽可能将权限相同的 Section 放在一块儿,称之为 Segment 。
那么连接器在进行虚拟地址分配时,就不用让每个 Section 进行页对齐,而是让每个 Segment 进行页对齐,如此一来进一步节约了虚拟地址空间。思考一下便知,Segment 只是在装载时有用,在分析可执行文件及其连接过程只须要关心 Section 也不会有什么问题。
装载时是以 Segment 为单位的,访问权限须要基于这个 Segment 来设置。那么实际上有一个装载时很重要的段:程序头表 。
程序头表也是结构体数组,每个元素包含 Segment 在文件中的偏移、虚拟地址起点、访问权限(Segment 中全部 Section 访问权限一致)、虚拟地址空间长度、文件中空间长度等。
值得提出的是 关于 BSS 的处理 。对于 Segment 来讲,可能并不能撑满一个页大小,那么就能够拓展一些虚拟空间,即其 虚拟地址空间长度 > 文件中空间长度 ,这表示拓展的部分只在装载时占虚拟空间而不占磁盘,这正好用来存放各个 BSS Section。考虑其访问权限,须要注意的是 BSS 能够和数据 Segment 合并,但不能和指令相关 Segment 合并。
BSS Section 见缝插针,进一步减小了内存碎片。
尽管已经按照 Segment 装载可执行文件,仍然存在一些内存碎片,因此有些 UNIX 系统作了更进一步的优化:将 Segment 接壤部分共享一个物理页,而后将物理页映射两次。
然而这么作事后, Segment 的虚拟地址就再也不是页大小的整数倍了,就涉及到一些计算这里不展开了。
根据前面分析的页映射机制,可执行文件装载进内存须要两个映射关系:
建立一个进程,或者说建立一个虚拟空间,第一步是操做系统建立一个页目录(Page Directory),也就是虚拟空间与物理内存的映射表,映射关系可在发生页错误时设置。
第二步是创建虚拟空间与可执行文件的映射关系。前面已经分析过了,可执行文件的 程序头表 已经包含了每个 Segment 的虚拟地址、在文件中的偏移。那么经过读取程序头表就能肯定每个虚拟页对应的可执行文件区间(若是是以 Section 来装载,这个思路一样适用于段表)。
第三步就是将 CPU 指令寄存器设置为可执行文件入口,启动运行。
不将某些目标文件静态连接在一块儿,而把连接过程推迟到运行时,这是 动态连接 的基本思想。这样能实现一个最重要的功能,就是共享的目标文件在内存中只须要存在一份,而后由多个进程进行连接使用。这种共享的目标文件通常称做 共享对象、共享库、共享模块 。
该图简明的表示了共享对象实现原理,进程 A 和 B 只使用了一份共享对象的指令内存数据。
动态连接共享对象带来的好处:
动态连接的缺点:
大体说明了动态连接的原理和特色,下面来具体分析技术细节。
简单方案: 共享对象虚拟地址固定 。那就得在可执行文件的段分配虚拟地址时,为所用到的共享对象预留虚拟空间,彷佛能解决问题。不过细想一下,这样作存在两个问题:
这些是致命问题,因此直接舍弃这种思路。
正确的思路是:装载器根据当前虚拟地址空间空闲状况,动态分配一块虚拟空间给共享对象。
共享对象并不是彻底能被多个进程复用(参照上面共享对象实现的图),通常只有指令部分是进程共享的,而数据部分仍然是进程独立的。缘由很简单,数据部分可能是可读写的,进程间只能使用独立的副本,而指令是只读的,多进程共享也没有影响。
共享对象的虚拟地址是装载器动态分配的,那么共享对象的数据段里面绝对地址引用是须要修复的。
和目标文件同样,共享对象数据段中如有绝对地址引用,会生成对应的重定位表,当动态连接器把这个共享对象装载后,会根据重定位表将数据段中的地址引用修正。这个方法叫作 装载时重定位 。
对于共享对象的指令部分来讲,没法使用装载时重定位来处理 。由于咱们说的装载其实是指装载到虚拟空间,那指令部分的绝对地址引用就须要根据当前进程的虚拟地址进行修正。然而各个进程的虚拟空间是独立的,因此被修正的指令部分并不能被其它进程使用。
地址无关代码 (PIC, Position-independent Code) 技术:把指令中须要被修改的部分分离出来跟数据部分放在一块儿,那么指令部分装载后就不须要修正内部引用地址,从而实现多进程共用。
和目标文件同样,共享对象中的函数地址、变量的相对位置是不变的,因此调用和跳转经过相对地址调用指令就能处理了,数据能够经过当前 PC 值加上偏移量来访问。
模块间的符号引用要在装载时才能肯定,这对于每个进程来讲都是须要修正的。处理方式是,在数据段里面创建一个指向这些变量的指针数组,这个指针数组称做 全局偏移表 (Global Offset Table, GOT) 。指令经过相对寻址就能找到数据段中的 GOT,从而找到须要访问变量的目标地址。
定义在模块内部的全局变量,有一种特殊状况:extern int global;
。这时编译器其实判断不了这个符号是定义在内部仍是外部的,就不知道该不应分配空间。在共享库编译时,编译器处理方式是默认把定义在模块内部的全局变量当作定义在其它模块,经过 GOT 实现。动态连接时就能进行判断:若可执行文件中有副本,指向该副本;不然指向该共享对象中的副本。
加入全局符号表时,一个共享对象 里的全局符号被 另外一个共享对象 同名全局符号覆盖的现象称做全局符号介入。若是一个共享对象中使用相对寻址访问这个全局符号,发生全局符号介入时就可能须要对这个引用重定位了,那么这个共享对象的指令部分就不能实现 PIC 了。因此对于全局符号来讲,一样采用 GOT 方式来访问。
Dynamic 段 相似于文件头,是动态连接重要结构,包含了动态连接符号表、动态连接重定位表、动态连接字符串表、依赖的共享文件(递归加载全部依赖)等。这些眼熟的表名字实际上功能结构和静态连接时那些表很是类似。最大的区别就是目标文件的重定位是在静态连接时完成,共享对象的重定位是在装载时完成。
值得提出的是可执行文件也能够编译为共享对象形式。
本文的编排和《程序员的自我修养 —— 连接、装载与库》相似,有不少笔者的总结、提炼、串联的描述,总的来讲算是造成了逻辑通路,但愿能为读者朋友提供一些帮助。
对于编译、连接、装载相关的技术细节,可能须要深刻到具体平台去研究,否则老是有些挥之不去的盲点。不过只要对基本流程原理有所把握,相信这并不是难事。