1、第 4 章 存储空间分配$Revision: 2.3 $Date: 1999/06/15 03:30:36 $链接器或加载器的首要任务是存储分配.一旦分配了存储空间后,链接器就可以继续进行符号绑定和代码调整.在一个可链接目标文件中定义的多数符号都是相对于文件内的存储区域定义的,所以只有存储区域确定了才能够进行符号解析.与链接的其它方面情况相似,存储分配的基本问题是很简单的,但处理计算机体系结构和编程语言语义特性的细节让问题复杂起来.存储分配的大多数工作都可以通过优雅和相对架构无关的方法来处理,但总有一些细节需要特定机器的专门技巧来解决.段和地址每个目标或可执行文件都会采用目标地址空间的某种模式
2、.通常这里的目标是目标计算机的应用程序地址空间,但某些情况下(例如共享库) 也会是其它东西 .在一个重定位链接器或加载器中的基本问题是要确保程序中的所有段都被定义并具有地址,并且这些地址不能发生重叠(除非有意这样).每一个链接器输入文件都包含一系列各种类型的段.不同类型的段以不同的方式来处理.通常,所有相同类型的段,诸如可执行代码段,会在输出文件中被合并为一个段.有时候段是在其它段的基础上合并得到的(如 Fortran 的公共块 ),以及在越来越多的情况下(如共享库和 C+专有特性),链接器本身会创建一些段并将其放置在输出中.存储布局是一个“两遍“ 的过程 ,这是因为每个段的地址在所有其它段的
3、大小未确定前是无法分配的.简单的存储布局在一种简单而不现实的情形下,链接器的输入文件包含一系列的模块,将它们称为 M1, M2, . Mn,每一个模块都包含一个单独的段,从位置 0 开始长度依次为 L1, L2, . Ln,并且目标地址空间也是从 0 开始.如图 1 所示.-图 4-1:单独段的存储空间分配从位置 0 开始的多个段按照一个跟着另一个的方式重定位-链接器或加载器依次检查各个模块,按顺序分配存储空间.模块 Mi 的起始地址为从 L1到 Li-1 相加的总和,链接得到的程序长度为从 L1 到 Ln 相加的总和.多数体系结构要求数据必须对齐于字边界,或至少在对齐时运行速度会更快些.因此
4、链接器通常会将 Li 扩充到目标体系结构最严格的对齐边界(通常是 4 或 8 个字节)的倍数.例 1:假定一个称为 main 的主程序要与三个分别称为 calif,mass 和 newyork 的子例程链接(按照地理位置划分风险投资). 每个例程的大小为(16 进制数字):名称尺寸-ain1017calif 920ass 615newyork1390假定从 16 进制的地址 1000 处开始分配存储空间,并且要求 4 字节对齐,那么存储分配的结果可能是:名称位置-ain1000 - 2016calif2018 - 2937ass2938 - 2f4cnewyork2f50 - 42df由于对齐
5、的原因,2017 处的一个字节和 2f4d 处的三个字节被浪费了,但无须忧虑.多种段类型除最简单格式外所有的目标格式,都具有多种段的类型,链接器需要将所有输入模块中相应的段组合在一起.在具有文本和数据段的 UNIX 系统上,被链接的文件需要将所有的文本段都集中在一起,然后跟着的是所有的数据,在后面是逻辑上的 BSS(即使 BSS 在输出文件中不占空间,它仍然需要分配空间来解析 BSS 符号,并指明当输出文件被加载时要分配的 BSS 空间尺寸).这就需要两级存储分配策略.现在每一个模块 Mi 具有大小为 Ti 的文本段,大小为 Di 的数据段 ,以及大小为 Bi 的 BSS段,如图 2 所示.-
6、图 4-2:多种段的存储分配按类型将文本,数据和 BSS 段分别归并-在读入每个输入模块时,链接器为每个 Ti,Di,Bi 按照(就像是)每个段都各自从位置0 处开始的方式分配空间.在读入了所有的输入文件后,链接器就可以知道这三种段各自总的大小 Ttot,Dtot 和 Btot.由于数据段跟在文本段之后,链接器将 Ttot 加到每一个数据段所分配的地址上,接着,由于 BSS 跟在文本和数据段之后,所以链接器会将 Ttot,Dtot 的和加到每一个 BSS 段分配的地址上.同样,链接器通常会将分配的大小按照对齐要求扩充补齐.段与页面的对齐如果文本和数据被加载到独立的内存页中,这也是通常的情况,文
7、本段的大小必须扩充为一个整页,相应的数据和 BSS 段的位置也要进行调整 .很多 UNIX 系统都使用一种技巧来节省文件空间,即在目标文件中数据紧跟在文本的后面,并将那个(文本和数据共存的)页在虚拟内存中映射两次,一次是只读的文本段,一次是写时复制(copy-on-write)的数据段.这种情况下,数据段在逻辑上起始于文本段末尾紧接着的下一页,这样就不需扩充文本段,数据段也可对齐于紧接着文本段后的 4K(或者其它的页尺寸 )页边界.例 2:我们将例 1 扩展,使得每个例程都有文本 ,数据和 BSS 段.字对齐要求还是 4 个字节,但页大小为 0x1000 字节.名称文本段数据段 BSS 段-a
8、in1017 32050calif 920 217100ass 615 300840newyork139012131400(均为 16 进制数字)链接器首先分配文本段,然后是数据段,接着是 BSS.注意这里数据段起始于页边界 0x5000,但 BSS 紧跟在数据的后面,这是因为在运行时数据和 BSS 在逻辑上是一个段.名称文本段数据段 BSS 段-ain1000-20165000-531f695c-69abcalif2018-29375320-544669ac-6aabass2938-2f4c5448-57476aac-72ebnewyork2f50-42df5748-695a72ec-86e
9、b在 0x42e0 到 0x5000 之间的页结尾处浪费了一些空间.虽然 BSS 段的结束位置在页面中部的 0x86eb 处,但程序们普遍都会紧跟其后分配“堆“空间.公共块和其它特殊段上面这种简单的段分配策略在链接器处理的 80%的存储分配中都工作的很好,但剩下的那些情况就需要用特殊的技巧来处理了.这里我们来看看比较常见的几个.公共块公共块存储是一个可以追溯到 50 年代 Fortran I 时的特性 .在最初的 Fortran 系统中,每一个子程序(主程序,函数或者子例程 )都有各自局部声明和分配的标量和数组变量 .同时还有一个各例程都可以使用的存储标量和数组的公共区域.公共块存储被证明是非
10、常有用的,并且在后续 Fortran 中单一的公共块(就是我们现在知道的空白公共块 ,即它的名称是空白的)已经普及为多个可命名的公共块 ,每一个子程序都可以声明它们所用的公共块.在最初的 40 年中,Fortran 不支持动态存储分配,公共块是 Fortran 程序用来绕开这个限制的首要工具.标准 Fortran 允许在不同例程中声明不同大小的空白公共块,其中最大的尺寸最终生效.Fortran 系统们无一例外的都将它扩展为允许以不同的大小来声明所有类型的公共块,同样还是最大的尺寸最终生效.大型的 Fortran 系统经常会超过它们所运行系统的内存容量限制,在没有动态内存分配时,程序员不得不频繁
11、的重新创建软件包,压缩尺寸来解决软件包遇到的此类问题.在一个软件包中除一个之外的其它子程序都将公共块声明为只有一个元素的数组.剩下的那个子程序声明所有公共块的实际大小,并在程序启动时将这些尺寸都保存在其余软件包可以使用的(在另一个公共块中的)变量中.这样就可以通过修改和重新编译定义这些公共块的一个例程,来调整公共块的尺寸,然后再重新链接.从 60 年代开始 Fortran 增加了 BLOCK DATA 数据类型来为任意公共块 (空白公共块除外,这是为数不多的限制)的部分或全部来指明局部初始数据值,这在某种程度上更复杂了.通常用来初始化公共块的在 BLOCK DATA 中的公共块尺寸,也在链接时
12、被用来当作该公共块的实际大小.在处理公共块时,链接器会将输入文件中声明的每个公共块当作一个段来处理,但并不会将这些段串联起来,而是将相同名称的公共块重叠在一起.这里会将声明的最大的尺寸作为段的大小,除非在某一个输入文件中存在该段的已初始化的版本.在某些系统上,已初始化的公共块是一个单独的段类型,而在另一些系统上它可能只是数据段的一部分.UNIX 链接器总是一贯支持公共块,甚至从最早版本的 UNIX 都具有一个 Fortran 子集的编译器,并且 UNIX 版本的 C 语言传统上会将未初始化的全局变量作为公共块对待.但在ELF之前的 UNIX 目标文件只有文本,数据和 BSS 段,没有办法直接声
13、明一个公共块 .作为一个特殊技巧,链接器将未定义但具有非零初值的符号当作是公共块,而该值就是公共块的尺寸.链接器将遇到的此类符号中最大的数值作为该公共块的尺寸.对于每一个公共块,它在输出文件的 BSS 段中定义了相应的符号,在每一个符号的后面分配所需要的空间 .-图 4-3:Unix 公共块在 BSS 末尾的公共块-C+重复代码消除在某些编译系统中,C+ 编译器会由于虚函数表 ,模板和外部 inline 函数而产生大量的重复代码.这些特性的设计是隐含的期望那种程序所有部分都可以被运行的环境.一个虚函数表(通常简称为 vtbl)包含一个类的所有虚函数(可以被子类覆盖的例程) 的地址.每个带有任何
14、虚函数的类都需要一个 vtbl.模板本质上就是以数据类型为参数的宏 ,并能够根据特定的类型参数集可以扩展为特定的例程.确保是否存在一个对普通例程的引用可供调用是程序员的责任,就是说对如 hash(int)和 hash(char *)每一类 hash 函数都有确定的定义,hash(T)模板可以根据程序中使用 hash 函数时不同的参数数据类型创建对应的 hash 函数.在每个源代码文件都被单独编译的环境中,最简单的方法就是将所有的 vtbl 都放入到每一个目标文件中,扩展所有该文件用到的模板例程和外部 inline 函数,这样做的结果就是产生大量的冗余代码.最简单的方法就是在链接时仍然将那些重复
15、代码保留着.那么得到的程序肯定可以正确的工作,但代码会膨胀的比理想尺寸大三倍或者更多.在那些使用简单链接器的系统上,某些 C+系统使用了一种迭代链接的方法,并采用独立的数据库来管理将哪些函数扩展到哪些地方,或者添加 progma(向编译器提供信息的程序源代码)向编译器反馈足够的信息以仅仅产生必须的代码 .我们将在第 11 章涉及这些.最近的很多 C+系统已经正面解决了这个问题 ,要么是让链接器更聪明一些,要么就是将链接器整合到程序开发环境的其它部分中(后一种方法我们在第 11 章还会涉及到). 链接器的方法是让编译器在每个目标文件中生成所有可能的重复代码,然后让链接器来识别和消除重复的代码.M
16、S Windows 链接器为代码区段定义了 COMDAT 标志来告诉链接器忽略除明确命名区段外的所有重复区段.编译器会根据模板给每个区段命名,名字中包含了参数类型,如图 4 所示.-图 4-4:WindowsIMAGE_COMDAT_SELECT_NODUPLICATES 1 Warn if multiple identically namedsections occur.IMAGE_COMDAT_SELECT_ANY 2 Link one identically named section,discard the rest.IMAGE_COMDAT_SELECT_SAME_SIZE 3 Li
17、nk one identically named section,discard the rest. Warn if a discardedsection isnt the same size.IMAGE_COMDAT_SELECT_EXACT_MATCH 4 Link one identically named section,discard the rest. Warn if a discardedsection isnt identical in size andcontents. (Not implemented.)IMAGE_COMDAT_SELECT_ASSOCIATIVE 5 L
18、ink this section if another specifiedsection is also linked.-GNU 链接器是通过定义一个“link once“类型的区段(与公共块很相似)来解决这个模板的问题的.如果链接器看到诸如.gnu.linkonce.name 之类的区段名称,它会将第一个明确命名的此类区段保留下来并忽略其它冗余区段.同样编译器会将模板扩展到一个采用简化模板名称的.gnu.linkonce 区段中.这种策略工作的相当不错,但它并不是万能的.例如,它不能保护功能上并不完全相同的 vtbl 和扩展模板.一些链接器尝试去检查被忽略的和保留的区段是否是每个字节都相同.
19、这种方法是很保守的,但是如果两个文件采用了不同的优化选项,或编译器的版本不同,就会产生报错信息.另外,它也不能尽可能多的忽略冗余代码.在多数 C+系统中,所有的指针都具有相同的内部表示,这意味着一个模板的具有指向 int 类型指针参数的实例和指向float 类型指针参数的实例会产生相同的代码( 即使它们的 C+数据类型不同).某些链接器也尝试忽略那些和其它区段每个字节都相同的 link-once 区段,哪怕它们的名字并不是完全的相同,但这个问题仍然没有得到满意的解决.虽然我们在这里只是讨论了模板的问题,但相同的问题也会发生在外部 inline 函数,缺省构造,复制和赋值例程中,也可以采用相同的
20、方法处理.初始化和终结另一个问题并不仅限于 C+,但在 C+上尤为严重,就是初始化和终结代码 (initializers and finalizers).一般来说,如果它们可以在程序启动的时候可以运行一个初始化例程,并在程序结束的时候运行一个终结例程,那把它们写成库会更容易些.C+允许静态变量.如果一个变量的类具有构造函数,那这个构造函数在程序启动时会被调用来对初始化变量,同样如果一个变量的类具有析构函数,那析构函数也会在程序退出时被调用.有很多办法可以在不需要链接器支持的情况下做到这一点,我们将会在第 11 章讨论到,但现代链接器通常都会直接支持该特性.通常的方法是将每个目标文件中的初始化代
21、码都放入一个匿名的例程中,然后将指向该例程的指针放置在名为.init(或其它相近名字)的段中.链接器将所有的.init 段串联在一起,因此就创建了一个指向所有这些初始化例程的指针列表.程序的初始化部分只需要遍历该列表依次调用所有例程即可.退出时的代码可以采用相同方法,只是段的名字改为了.fini.实践证明这种方法也不是完全令人满意的,因为有一些初始化代码要求比另外一些更早的运行.C+ 定义指出应用程序级的构造函数运行顺序是不确定的,但 I/O 和其它系统库的构造函数需要在应用程序自己的构造函数之前执行.完美的方法应当是让每一个初始化例程都精确的列出它们的依赖关系,并在此基础上进行拓扑排序.Be
22、OS 操作系统的动态链接器就是这么做的,使用到了库的引用依赖关系(如果库 A 依赖于库 B,那么库 B 的初始化代码就可能需要先运行).一个更简单的近似方法是设置多个用于初始化的段,如.init 和.ctor, 这样启动程序首先为所有库级初始化调用.init 中的例程,然后为 C+的构造函数调用.ctor 中的例程.同样的问题出现在程序结束时,对应的段为.dtor 和.fini.有一个系统甚至还允许程序员设置优先级编号,0 至 127 为用户代码,128 至 255 是系统库,链接器在合并代码之前会先将初始化和终结代码按优先级编号排序,最高优先级的初始化代码最先运行.但这仍不能令人完全满意,因
23、为构造函数之间会存在顺序依赖关系,从而产生非常难以调试的错误,但在这里 C+将避免这些错误的责任交给了程序员.该策略的一个变种是将实际的初始化代码放在.init 段中,当链接器合并它们的时候该段会成为完成所有初始化工作的 inline 代码.只有少量系统进行了这种尝试,但在不支持直接寻址的计算机上是很难让它工作的,因为从每个目标文件中提取出来的代码块还要能够对它们原本文件中的数据进行寻址,通常这都需要寄存器来指向可以指向寻址数据的表.匿名例程采用和其它例程相同的方式来初始化它们的寻址过程,借助已有的方案来减少寻址的问题.IBM 伪寄存器IBM 主机系统的链接器提供了一种称为“外部模拟(exte
24、rnal dummy)“区段或“伪寄存器(pseudo-registers)“的有趣特性.360 是较早的无直接寻址的主机架构之一 ,这就意味着实现小数据区域共享要付出昂贵的开销.每一个引用全局对象的例程都需要一个 4 字节的指针指向该对象,如果这个对象只有开头 4 个字节那么大的话,这将是相当大的开销.例如 PL/1 程序对每一个打开的文件和其它全局对象都需要一个指针(虽然 PL/1 应用程序的程序员无法访问伪寄存器,但它是唯一使用伪寄存器的高级语言.它使用伪寄存器指向打开文件的控制块这样应用程序就可以包括进那些对 I/O 系统的 inline 调用).一个相关的问题是 OS/360 不支持
25、我们现在所说的那种称为进程/任务级本地存储的东西,并且对共享库只提供非常有限的支持.如果两个作业运行同样的程序,或者这个程序被标注为可重入(这时它们共享整个程序 ,代码和数据), 或者标注为不可重入 (这时不共享任何东西).所有的程序都被加载到相同的地址空间,因此相同程序的多个实例必须标注出实例本身数据的范围(360 系统不具备硬件内存重定位功能 ,尽管 370 支持了,但也知道 OS/VS操作系统的若干个版本之后系统才提供进程独立的地址空间).伪寄存器可以帮助解决这些问题,如图 5 所示.每一个输入文件都可以声明(多个) 伪寄存器,也称为外部模拟区段(360 系统的汇编语言中,它与结构体的声
26、明很相似). 每个伪寄存器都有名字,长度和对齐要求.在链接时,链接器将所有的伪寄存器都收集到一个逻辑段中,将最大的尺寸和最严格的对齐要求施加于每个伪寄存器,并为它们分配在该逻辑段中不会相互重叠的偏移量.但链接器不会为伪寄存器段分配空间.它只是计算该段的大小,并将其存储在程序的数据段中以特殊的 CXD(cumulative external dummy,即重定位项)标识的位置.当引用一个伪寄存器时,程序代码还需要另一个特殊的 XD(external dummy),它是用来指示将偏移量放置在哪一个该伪寄存器所属逻辑段内的重定位类型.程序的初始化代码为伪寄存器动态的分配空间,使用 CXD 可以知道需
27、要多大的空间,并按惯例将这个空间的地址存放在寄存器 12 中,在整个程序运行期间都不会改变.程序中的任何一部分都可以通过将寄存器 12 的值与某个伪寄存器对应的 XD 的值相加得到该伪寄存器的地址.一般都是通过 load 和 store 指令来完成的,将 R12(寄存器 12)作为索引寄存器与嵌入到指令的地址替换域中的 XD 项相加(地址替换域只有 12 位,但由于 XD 将 16 位半字的高 4 位保持为 0,即基址寄存器为 0,所以仍然可以产生正确的结果).-图 4-5:精灵寄存器通过 R12 指向一串地址块.各种例程通过偏移量引用它们.-这样的结果就是程序的所有部分都可以 load,st
28、ore 和其它 RX 格式指令来直接访问所有的伪寄存器.如果一个程序存在多个活动的实例,每个实例就可以通过采用不同的 R12 值来分配独立的空间.尽管最初引用伪寄存器的原因现在大多数都已经被废弃了,但为链接器提供可以高效访问线程本地地址的方法确实一个非常好的思想,并且仍然出现在很多现代操作系统中,其中最著名的就是 Windows.同样 ,现代的 RISC 机器也分享了 360 系统有限的寻址范围,因此需要使用内存指针表来寻址任意的内存地址.在很多 RISC UNIX 系统上,编译器为每个模块创建两个数据段,一个是通常的数据段,另一个是“小(small)“数据段,即大小低于某一个尺寸阀值的静态对
29、象.链接器将所有的小数据段收集在一起,然后让程序的启动代码将合并的小数据段的地址放入一个保留的寄存器中.这样就可以通过和这个寄存器相关的基址寻址来直接引用这些小数据.要注意,与伪寄存器不同,小数据的存储空间既会被链接器分配,也会被链接器放置到输出中,在每个程序中只有一份小数据.某些 UNIX 系统支持线程,但线程级的存储是特定的程序代码完成的,不需要链接器的特殊帮助.特殊的表链接器分配存储的最后一个资源是链接器本身.尤其是当应用程序使用共享库或者重叠技术时,链接器会创建由指针,符号或其它别的数据构成的多个段来在运行时支持库或者重叠.一旦这些库被建立了,链接器会按照对待任何其它段的方式来为它们分
30、配存储空间.X86 分段的存储分配8086 和 80286 的分段内存寻址的怪癖要求导致了少量特殊的东西.x86 OMF 目标文件给每个段都有一个名字和可选的类别.所有具有相同名字的段,会根据由编译器或者汇编器设置的一些标志位来合并到一个大的段中,并且所有类别相同的段都会被连续的分配在一个块中.编译器或汇编器使用类别名来标注段的类型(诸如代码或静态数据), 因此链接器可以将给定类别的所有的段分配在一起.当某个类别的所有段总长小于 64K 时,它们可以被当作使用一个段寄存器的单独寻址“组“ 来对待,这样可以节省客观的时间和空间 .图 6 所示为一个由三个输入文件链接而成的程序,三个输入文件依次为
31、 main,able 和baker.文件 main 中包含段 MAINCODE 和 MAINDATA,able 中包含段 ABLECODE 和ABLEDATA,baker 中包含段 BAKERCODE,BAKERDATA 和 BAKERLDATA.每一个代码段都是 CODE 类别,数据段都是 DATA 类别,但“ 大数据“BAKERLDATA 不赋予类别.在链接好的程序中,假定 CODE 段最大 64K,它们在运行时可以当作单独的段来对待 ,可以使用 short(而不是 far)调用和跳转指令,以及一个不变的 CS 段代码寄存器.同样如果所有的 DATA 段可以装在 64K 中,则它们也可以当
32、作单一的段来对待,使用 short 的内存引用指令和一个不变的 DS 数据段寄存器.BAKERLDATA 段在运行时作为一个独立的段处理,程序代码会加载一个段寄存器(通常是 ES)来指向它.-图 4-6:X86CODE 类别的 MAINCODE,ABLECODE 和 BAKERCODE 段DATA 类别的 MAINDATA,ABLEDATA 和 BAKERDATA 段单独的 BAKERLDATA 段-实模式和 286 保护模式的程序几乎是以相同的方式来链接的.主要的不同在于链接器一旦在保护模式程序中生成链接好的段,链接器就完成工作了,只有在程序加载时才会赋予实际的内存地址和段号.在实模式中,链
33、接器还有额外的一步就是为段分配线性地址,并相对于程序起始位置为这些段分配段落(paragraph)号.然后在加载的时候 ,程序加载器必须调整实模式程序中所有的段落号(paragraph number)或者保护模式程序中所有的段号(segent number)以反映程序被加载的实际位置.链接器控制脚本传统上链接器可以允许用户对输出数据进行有限的控制.由于链接器已经开始要面对内存组织非常复杂的目标环境,诸如众多的嵌入式处理器和目标环境,因此就非常必要对目标地址空间和输出文件中的数据提供更加精确的控制.具有一系列固定段的简单链接器通常具有可以指定各个段基地址的开关参数,这样程序就可以被加载到非标准的
34、应用环境中(操作系统内核通常会用到这些开关参数).有一些链接器具有数量庞大的命令行开关参数,由于系统经常会限制命令行的长度,因此经常将这些命令行逻辑上连续的放置在一个文件中.例如,微软的链接器在文件中为每个区段设置特性时最多可以采用大约 50 个命令行开关选项,包括输出的基地址和一系列其它输出相关的细节.其它的链接器定义了可以控制链接器输出的脚本语言.GNU 链接器,也定义了这么一种具有一长串命令行参数的语言.图 7 所示为可以在系统 5 版本 3.2(System V Release 3.2)的系统上(如 SCO UNIX)产生 COFF 可执行程序的一个简单链接脚本示例 .-图 4-7:生
35、成 COFF 可执行程序的 GNU 链接器控制脚本OUTPUT_FORMAT(“coff-i386“)SEARCH_DIR(/usr/local/lib);ENTRY(_start)SECTIONS.text SIZEOF_HEADERS : *(.init)*(.text)*(.fini)etext = .;.data 0x400000 + (. .bss SIZEOF(.data) + ADDR(.data) :*(.bss)*(COMMON)end = .;.stab 0 (NOLOAD) : .stab .stabstr 0 (NOLOAD) : .stabstr -开始的几行描述了输
36、出的格式(必须是编译进链接器的格式表中存在的), 查找目标代码库的位置,和缺省入口点的名称(本示例中为_start).然后它列出了输出文件中的区段.在区段名后面是一个指明区段开始地址的可选数值.因此可以看出,.text 区段紧跟在文件头部后面,输出文件中的.text 区段包含了所有输入文件中的.init 区段,所有的.text区段和所有的.fini 区段.链接器定义了符号 etext 作为.fini 区段后面的地址.然后脚本设置了.data 区段,强制将其起始地址设置为文本区段后面的 4KB 对齐的地址 0x400000,该区段中包含了所有输入文件中的.data 区段,并紧跟其后定义了 eda
37、ta 符号.然后是紧跟在数据段后面的.bss 区段,它包括了所有输入文件中的.bss 区段和公共块,并将 bss 段的末尾用符号 end 标识(COMMON 是该链接语言的一个关键字).在那之后的两个区段是从输入文件相应位置收集的众多符号表项,但只有调试器会查看这些符号,因此在运行时不会被加载.链接器脚本语言比这个简单的例子要复杂得多,足以描述从简单的 DOS 可执行程序到Windows PE 可执行程序以至到复杂的重叠管理的各种类型.嵌入式系统的存储分配嵌入式系统的存储分配与我们到现在为止已看到的策略相近,只是由于程序所运行的复杂的地址空间而复杂了一些.嵌入式系统链接器提供的脚本语言可以让程
38、序员地址空间的区域,并将特定的段或目标文件分配到这些区域中,并可以指明各区域中每个段的地址对齐要求.诸如 DSP 这样的专用处理器的链接器还要支持各处理器的特殊特性.例如,Motorola 5600X DSP 系列支持循环缓冲区必须对齐在不小于缓冲区大小的 2 的幂次的地址上.56K 目标格式为这些缓冲区有一个特殊的段类型,链接器会自动的将它们分配到正确的边界上,并尽量减小(作者笔误 )未使用的空间.实际中的存储分配现在我们看看几种流行链接器的存储分配策略,作为本章的结束.Unix a.out 链接器的存储分配策略ELF 之前的 UNIX 链接器的存储分配策略只比本章开头的理想实例稍微复杂一点
39、,这是因为各个段在链接之前已经知道了,如图 8 所示.每个输入文件具有文本,数据和 BSS 段,也可能有伪装为外部符号的公共块.链接器从每个输入文件和库目标文件中收集文本,数据和 BSS 的大小.在读取了所有的目标文件之后,任何未解析的具有非零值外部符号都被放入公共块中,并在 BSS 尾部分配空间.-图 4-8:a.out 链接从输入目标代码和库目标代码中各文本,数据和 BSS/公共块合并而成的三个大段-这里,链接器可以为各个段直接赋予地址.文本段根据所创建的不同 a.out 格式起始于一个固定的位置,或者是 0 位置(最老的格式), 或者是 0 位置的下一页 (NMAGIC 格式),或者是一
40、页再加上 a.out 头部(QMAGIC).数据段可以直接跟在文本段后面(旧的非共享 a.out 格式),或起始于文本段后下一页的边界处(NMAGIC 格式).在每种格式中,BSS 都紧跟在数据段后面.在每一个段内部,将各输入文件中的段排列在前一个段后面字对齐的边界处.ELF 中的存储分配策略ELF 链接要比 a.out 复杂一些,因为输入文件中的各个段可以是任意大小的,链接器必须将输入段(ELF 术语中的段)转换为可加载的段(ELF 术语中的段). 链接器还要创建程序加载器需要的程序头部,和动态链接所需的一些特殊区段,如图 9 所示.-图 4-9:ELF 链接摘自 TIS ELF 文档页 2
41、-7 和 2-8所示为输入中的段转换到输出中的段-ELF 目标文件具有传统的文本 ,数据和 BSS 区段,现在拼写为.text,.data 和.bss.经常还会包含.init 和.fini(启动和退出时的代码),和其它一些琐碎的东西rodata 和.data1 在某些编译器中被用来表示只读数据和 out-of-line 数据( 有些编译器也有对应只读 out-of-line 数据的.rodata1 区段).在诸如 MIPS 这样地址偏移量受限的 RISC 系统中,还有.sbss 和.scommon 区段,即小的 BSS 和公共块,有利于小的对象组合到单个可以直接寻址的区域,就像我们在上面讨论伪
42、寄存器时说到的那样.在 GNU C+系统中,还可以会有可以被括入文本,只读数据和数据段中的 linkonce 区段.如果不考虑众多的区段类型,那么链接过程都是一样的.链接器将各个输入文件和库目标文件中的同类型区段收集在一起.链接器还会标注出哪些符号会在运行时从共享库中解析,并创建.interp,.got,.plt 和符号表区段来支持运行时链接 (我们将细节的讨论推迟到第 9 章).一旦这些都完成了 ,链接器会按照传统的顺序来分配空间.与 a.out 不同,ELF 格式不会从 0 位置加载任何东西,而是从地址空间的中间部位来加载,这样栈可以在文本段以下向下增长,堆可以在数据段末尾以上向上增长,以
43、更加紧凑的利用地址空间.在 386系统上,文本的基地址是 0x08048000,这样既可以允许位于文本以下的合理大的栈空间,同时将 0x08000000 以上的空间留出来,允许多数程序将它用来创建单一的二级页表(回想一下在 386 上,每一个二级页表可以映射大小为 0x00400000 的地址空间).ELF 使用 QMAGIC的技巧将头部包括到文本段内,所以实际的文本段起始于 ELF 头部和程序头部表之后,典型的位于文件偏移量 0x100 处.然后再将.interp(动态链接器的逻辑链接,需要首先被运行),动态链接器符号表区段,.init,.text,以及 link-once 文本和只读数据分
44、配到文本段中.接下来是数据段,逻辑上起始于文本段末尾的下一个页(因为在运行时该页会同时被映射为文本段的最后一页和数据段的第一页).链接器分配各种.data 区段和 link-once 数据和.got 区段,以及一些平台上会用到的 .sdata 小数据和.got 全局偏移量表.最后是 BSS 区段,逻辑上紧跟在数据的后面,由.sbss 开始(如果有的话,将它放在.sdata 和.got 的后面),然后是 BSS 段和公共块.Windows 链接器的存储分配策略Windows Pe 文件的存储分配策略比 ELF 文件还要简单一点,这是因为 PE 的动态链接模式需要链接器的支持较少,作为代价编译器承担了更多的工作,如图 10 所示.-图 4-10:PE 文件的存储分配摘自微软网站-Pe 可执行文件传统上从地址 0x400000 处加载,即文本段开始的位置.文本段包括输入文件中的文本段,还有初始化和终结代码段.接下来是数据段,对齐于逻辑磁盘块的边界