zoukankan      html  css  js  c++  java
  • 链接器和加载器

    chapt1:链接和加载

    链接与加载
    链接器和加载器完成几个相关但概念上不同的动作。

     程序加载:在某些情况下,加载仅仅是将数据从磁盘拷入内存;在其他情况下,还
    包括分配存储空间,设置保护位或通过虚拟内存将虚拟地址映射到磁盘内存页上。
     重定位:编译器和汇编器通常为每个文件创建程序地址从 0 开始的目标代码,但是
    几乎没有计算机会允许从地址 0 加载你的程序。如果一个程序是由多个子程序组成
    的,那么所有的子程序必须被加载到互不重叠的地址上重定位就是为程序不同部
    分分配加载地址,调整程序中的数据和代码以反映所分配地址的过程。在很多系统
    中,重定位不止进行一次。对于链接器的一种普遍情景是由多个子程序来构建一个
    程序,并生成一个链接好的起始地址为 0 的输出程序,各个子程序通过重定位在大
    程序中确定位置。当这个程序被加载时,系统会选择一个加载地址,而链接好的程
    序会作为整体被重定位到加载地址。
     符号解析:当通过多个子程序来构建一个程序时,子程序间的相互引用是通过符号
    进行的;主程序可能会调用一个名为 sqrt 的计算平方根例程,并且数学库中定义了
    sqrt 例程。链接器通过标明分配给 sqrt 的地址在库中来解析这个符号,并通过修
    改目标代码使得 call 指令引用该地址。
    链接器和加载器共有的一个重要特性就是他们都会修改目标代码


    两遍链接
    链接器将一系列的目标文件、库、及可能的命令文件作为它的输入,然后将输出的
    目标文件作为产品结果,此外也可能有诸如加载映射信息或调试器符号文件的副产品。


    每个输入文件都包含一系列的段(segments),即会被连续存放在输出文件中的代码
    或数据块每一个输入文件至少还包含一个符号表(symbol table)。有一些符号会作为导
    出符号,他们在当前文件中定义并在其他文件中使用,通常都是可以在其它地方被调用的当
    前文件内例程的名字。其它符号会作为导入符号,在当前文件中使用但不在当前文件中定义,
    通常都是在该文件中调用但不存在于该文件中的例程的名字。
    当链接器运行时,会首先对输入文件进行扫描,得到各个段的大小,并收集对所有符
    号的定义和引用。它会创建一个列出输入文件中定义的所有段的段表,和包含所有导出、导
    入符号的符号表
    利用第一遍扫描得到的数据,链接器可以为符号分配数字地址,决定各个段在输出地
    址空间中的大小和位置,并确定每一部分在输出文件中的布局
    第二遍扫描会利用第一遍扫描中收集的信息来控制实际的链接过程。它会读取并重定
    位目标代码,为符号引用替换数字地址,调整代码和数据的内存地址以反映重定位的段地址,
    并将重定位后的代码写入到输出文件中。通常还会再向输出文件中写入文件头部信息,重定
    位的段和符号表信息。如果程序使用了动态链接,那么符号表中还要包含运行时链接器解析
    动态符号时所需的信息。在很多情况下,链接器自己将会在输出文件中生成少量代码或数据,
    例如用来调用覆盖中或动态链接库中的例程的“胶水代码”,或在程序启动时需要被调用的
    指向各初始化例程的函数指针数组。

    有些目标代码的格式是可以重链接的,也就是一次链接器运行的输出文件可以作为下
    次链接器运行的输入。这要求输出文件要包含一个像输入文件中那样的符号表,以及其它会
    出现在输入文件中的辅助信息。
    几乎所有的目标代码格式都预备有调试符号,这样当程序在调试器控制下运行时,调
    试器可以使用这些符号让程序员通过源代码中的行号或名字来控制程序。根据目标代码格式
    细节的不同,调试符号可能会与链接器需要的符号混合在一个符号表中,也可能独立于链接
    器需要的符号表为链接器建立单独建立一个有些许冗余的符号表。


    当链接器处理完所有规则的输入文件后,如果还存在未解析的导入名称
    (imported name),它就会查找一个或多个库,然后将输出这些未解析名字的库中的任何
    文件链接进来。

    由于链接器将部分工作从链接时推迟到了加载时,使这项任务稍微复杂了一些。在链
    接器运行时,链接器会识别出解析未定义符号所需的共享库,但是链接器会在输出文件中标
    明用来解析这些符号的库名称,而不是在链接时将他们链入程序,这样可以在程序被加载时
    进行共享库绑定


    重定位和代码修改

    链接器和加载器的核心动作是重定位和代码修改。当编译器或汇编器产生一个目标代
    码文件时,它使用文件中定义的未重定位代码地址和数据地址来生成代码,对于其它地方定
    义的数据或代码通常就是 0。作为链接过程的一部分,链接器会修改目标代码以反映实际分
    配的地址。

    有些系统需要无论加载到什么位置都可以正常工作的位置无关代码。链接器需要提供
    额外的技巧来支持位置无关代码,与程序中无法做到位置无关的部分隔离开来,并设法使这
    两部分可以互相通讯


    编译器驱动

    很多情况下,链接器所进行的操作对程序员是几乎或完全不可见的,因为它会做为编
    译过程的一部分自动进行。多数编译系统都有一个可以按需自动执行编译器各个阶段的编译
    器驱动。例如,若一个程序员有两个 C 源程序文件(简称 A,B),那么在 UNIX 系统上编译
    器驱动将会运行如下一系列的程序:
     C 语言预处理器处理 A,生成预处理的 A
     C 语言编译预处理的 A,生成汇编文件 A
     汇编器处理汇编文件 A,生成目标文件 A
     C 语言预处理器处理 B,生成预处理的 B
     C 语言编译预处理的 B,生成汇编文件 B
     汇编器处理汇编文件 B,生成目标文件 B
     链接器将目标文件 A、B 和系统 C 库链接在一起
    也就是说,编译器驱动首先会将每个源文件编译为汇编语言,然后转换为目标代码,
    接着链接器会将目标代码链接器一起,并包含任何需要的系统 C 库例程

    链接器命令语言
    每个链接器都有某种形式的命令语言来控制链接过程
    有四种常见技术向链接器传送指令:
     命令行
     与目标文件混在一起
     嵌入在目标文件中: 有一些目标代码格式,特别是微软的,允许将链接器命令嵌入
    到目标文件中。这就允许编译器将链接一个目标文件时所需要的任何选项通过文件
    自身来传递。
     单独的配置语言: 极少有链接器拥有完备的配置语言来控制链接过程。可以处理众
    多目标文件类型、机器体系架构和地址空间规定的 GNU 链接器,拥有可以让程序员
    指定段链接顺序、合并相近段规则、段地址和大量其它选项的一套复杂的控制语言。
    其它链接器一般拥有诸如支持程序员可定义的重叠技术等特性的稍简单一些的配置
    语言。



    第 2 章 体系结构的问题

    硬件体系结构的两个方面影响到链接器:程序寻址和指令格式。链接器需要做的事情
    之一就是对数据和指令中的地址及偏移量都要进行修改。两种情况下链接器都必须确保所做
    的修改符合计算机使用的寻址方式;当修改指令时还需要进一步确保修改结果不能是无效指
    令。

    应用程序二进制接口
    每个操作系统都会为运行在该系统下的应用程序提供应用程序二进制接口(Applicatio
    n Binary Interface)。ABI 包含了应用程序在这个系统下运行时必须遵守的编程约定。ABI
    总是包含一系列的系统调用和使用这些系统调用的方法,以及关于程序可以使用的内存地址
    和使用机器寄存器的规定


    寄存器的容量几乎都是和程序地址的大小相同,就是说在一个 32 位地址的
    系统中寄存器是 32 位的,而在具有 64 位地址的系统上,寄存器就是 64 位的了。

    地址构成
    当计算机程序执行时,会根据程序中的指令来读写内存。程序的指令本身也存储在内
    存中,但通常和程序的数据位于内存中不同的部分。

    过程调用和可寻址性

    在没有采用直接寻址的体系结构中,程序在进行数据寻址时存
    在一个“自举”的问题:一个例程要使用寄存器中的基地址来计算数据地址,但是将基址从
    内存中加载到寄存器中的标准方法是从存有另一个基址的寄存器中寻址。
    自举问题就是如何在程序开始时将第一个基地址载入到寄存器中,随后再确保每一个
    例程都拥有它需要的基地址来寻址它要使用的数据。


    过程调用
    每种 ABI 都通过将硬件定义的调用指令与内存、寄存器的使用约定组合起来定义了一个
    标准的过程调用序列。硬件的调用指令保存了返回地址(调用执行后的指令地址)并跳转到
    目标过程。在诸如 x86 这样具有硬件栈的体系结构中返回地址被压入栈中,而在其它体系结
    构中它会被保存在一个寄存器里,如果必要软件要负责将寄存器中的值保存在内存中。具有
    栈的体系结构通常都会有一个硬件的返回指令将返回地址推出栈并跳转到该地址,而其它体
    系结构则使用一个“跳转到寄存器中地址”的指令来返回。
    在一个过程的内部,数据寻址可分为 4 类:
     调用者可以向过程传递参数。
     本地变量在过程中分配,并在过程返回前释放。
     本地静态数据保存在内存的固定位置中,并为该过程私有。
    全局静态数据保存在内存的固定位置中,并可被很多不同过程引用
    为每个过程调用分配的一块栈内存称为“栈框架(stack frame)”


    参数和本地变量通常在栈中分配空间,某一个寄存器可以作为栈指针,它可以基址寄
    存器来使用。SPARC 和 x86 中使用了该策略的一种比较普遍的变体,在一个过程开始的时候,
    会从栈指针中加载专门的框架指针或基址指针寄存器。这样就可以在栈中压入可变大小的对
    象,将栈指针寄存器中的值改变为难以预定的值,当仍使过程的参数和本地变量们仍然位于
    相对于框架指针在整个过程执行中都不变的固定偏移量处。如果假定栈是从高地址向低地址
    生长的,而框架指针指向返回地址保存在内存中的位置,那么参数就位于框架指针较小的正
    偏移量处,本地变量在负偏移量处。由于操作系统通常会在程序启动前为其初始化栈指针,
    所以程序只需要在将输入压栈或推栈时更新寄存器即可。
    对于局部和全局静态数据,编译器可以为一个例程引用的所有静态变量创建一个指针
    。如果某个寄存器存有指向这个表的指针,那么例程可以通过使用表指针寄存器将对象在
    表中的指针读取出来,加载到另一个使用表指针寄存器作为基址的寄存器中,并将第二个寄
    存器做为基址寄存器来寻址任何想要访问的静态目标。因此,关键技巧是表的地址存入到第
    一个寄存器中。在 SPARC 上,例程可以通过带有立即操作数的一系列指令来加载表地址,同
    时在 SPARC 或者 370 上例程可以通过一系列子例程调用指令将程序计数器(保存当前指令地
    址的寄存器)加载到一个基址寄存器,虽然后面我们还会讨论这种方法在对待库代码时会遇
    到问题。一个更好的解决方法是将提取表指针的工作交给例程的调用者,因为调用者已经加
    载了自己的表指针,并可以从自己的表中获取被调用例程的表的指针。
    表指针的链最初是怎么开始的呢?如果每一个例程都
    从前面例程中获取它的表指针,那么最初的例程从哪里获得呢?答案不是固定的,但是总会
    涉及到一些特殊代码。主例程的表可能存储在一个固定的位置,或初始指针值被标注在可执
    行文件中这样操作系统可以在程序开始前加载它。无论使用的是什么技术,都是需要链接器
    的帮助的。

    数据和指令引用

    第 3 章 目标文件
    编译器和汇编器创建了目标文件(包含由源程序生成的二进制代码和数据)。链接器
    将多个目标文件合并成一个,加载器读取这些目标文件并将它们加载到内存中

    目标文件中都有什么?
    一个目标文件包含五类信息

    头信息:关于文件的整体信息,诸如代码大小,翻译成该目标文件的源文件名称,
    和创建日期等。
    ● 目标代码:由编译器或汇编器产生的二进制指令和数据。
    ● 重定位信息:目标代码中的一个位置列表,链接器在修改目标代码的地址时会对它
    进行调整
    ● 符号:该模块中定义的全局符号,以及从其它模块导入的或者由链接器定义的符号。
    ● 调试信息:目标代码中与链接无关但会被调试器使用到的其它信息。包括源代码文
    件和行号信息,本地符号,被目标代码使用的数据结构描述信息(如 C 语言数据结
    构定义)。(某些目标文件甚至包含比这更多的信息,但上面这些对于我们在本章
    所需关注的已经足够了)
    并不是所有的目标文件格式都包含这几类信息,一个很有用的目标文件格式很少或不
    包含以上任何信息,都是可能的。
    设计一个目标文件格式
    对一个目标文件格式的设计实际上是对目标文件所处的各种用途导致的折衷方案。一
    个文件可能是可链接的,能够作为链接编辑器或链接加载器的输入;它也可能是可执行的,
    可以加载到内存中作为一个程序运行;或者是可加载的,作为库同程序一起被加载到内存中;
    或者它是以上几种情况的组合。某些格式只支持上面的一到两种用法,而另一些格式则支持
    所有的用法。
    一个可链接文件还包含链接器处理目标代码时所需的扩展符号和重定位信息。目标代
    码经常被划分为多个会被链接器区别对待的小逻辑段。一个可执行程序中会包含目标代码
    (为了能让文件被映射到地址空间中它通常是页对齐的),但是可以不需要任何符号(除非
    它要进行运行时动态链接)以及重定位信息。目标代码可以是一个单独的大段,或反映了硬
    件执行环境的一组小段(多数是只读或可读写的页)。根据系统运行时环境细节的不同,一
    个可加载文件可以仅包含目标代码,或为了进行运行时链接还包含了完整的符号和重定位信
    息。
    在应用中会存在某些冲突。面向逻辑的可链接段分组策略很少能够与面向硬件的可执
    行段分组策略相匹配。尤其是在一些较小的计算机上,链接器每次只会对可链接文件的一小
    片进行读写,但可执行程序会被整体的加载到内存中
    空目标文件格式: MS-DOS 的.COM 文件
    碰到一个仅有可运行二进制代码而没有其它信息的能够使用的目标代码文件是可能的
    MS-DOS 的.COM 就是最有名的例子。一个.COM 文件中除了二进制代码外没有别的。当操作系
    统运行一个.COM 文件时,它只需将文件的内容加载到一块空闲内存中,从偏移量 0x100 处开
    始执行(0-0xFF 存放的是程序的命令行参数和其它参数,称为程序段前缀 PSP),将所有的
    x86 段寄存器设置为指向 PSP,将 SP(栈指针)寄存器指向该段的末尾(由于栈是向下生长
    的),然后跳转到被加载程序的入口处
    x86 的分段架构使得这种文件格式可以工作。因为所有的 x86 程序地址都被解释为是相
    对于当前段基地址的,所有的段寄存器都指向该段的基址,而程序总是以相对段位置为 0x1
    00 的方式被加载。因此,对于可以放入单个段的程序而言,由于段相对地址可以在链接时
    确定而不需要再进行调整。
    对于那些不能放入单一段的程序来说,对地址的调整工作是程序员的事情。而且确实
    存在一些程序是在启动时读取某个段寄存器然后将它的值与保存在程序中的某个地方的段值
    相加。当然这类繁琐的工作趋向于由链接器和加载器来自动完成,MS-DOS 通过.EXT 文件来
    完成这些(在本章稍后部分会讲述到)。

    代码区段: Unix a.out 文件
    具有硬件内存重定位部件的计算机系统(今天几乎所有的计算机都有)通常都会为
    运行的程序创建一个具有空地址空间的新进程,这种情况下程序就可以按照从某个固定地址
    开始的方式被链接,而不需要加载时的重定位。UNIX 的 a.out 目标文件格式就是针对这种情
    况的。
    最简单的情况下,一个 a.out 文件包含一个小文件头,后面接着是可执行代码(由于历
    史的原因被称为文本段),然后是静态数据的初始值,如图 1 所示。PDP-11 只有 16 位寻址,
    将程序的地址空间限制为 64K。这个限制很快就变得太小了,所以 PDP-11 产品线的后续型号
    为代码(称为指令空间 I)和数据(称为数据空间 D)提供了独立的地址空间,这样一个程
    序可以拥有 64K 的代码空间和 64K 的数据空间。为了支持这个特性,编译器、汇编器、链接
    器都被修改为可以创建两个段的目标文件(代码放入第一个段中,数据放入第二个段中,程
    序加载时先将第一个段载入进程的 I 空间,再将第二个段载入进程的 D 空间)。
    独立的 I 和 D 空间还有另一个性能上的优势:由于一个程序不能修改自己的 I 空间,因
    此一个程序的多个实体可以共享一份程序代码的副本


    a.out 头部
    a.out 的头部根据 UNIX 版本的不同而略有变化,但最典型的是 BSD UNIX 的版本,如图
    2 所示(在本章的示例中,int 类型为 32 位,short 类型为 16 位)。
    ---------------------------------------------------------------------------------------------
    图 3-2: a.out 头部
    int a_magic; // 幻数
    int a_text; // 文本段大小
    int a_data; // 初始化的数据段大小
    int a_bss; // 未初始化的数据段大小
    int a_syms; // 符号表大小
    int a_entry; // 入口点
    int a_trsize; // 文本重定位段大小
    int a_drsize; // 数据重定位段大小
    ---------------------------------------------------------------------------------------------
    1
    幻数 a_magic 说明了当前可执行文件的类型 。不同的幻数告诉操作系统的程序加载器
    以不同的方式将文件加载到内存中;我们将在下面讨论这些区别。文本和数据段大小 a_text
    和 a_data 以字节为单位标识了头部后面的只读代码段和可读写数据段的大小。由于 UNIX 会
    自动将新分配的内存清零,因此初值无关紧要或者为 0 的数据不必在 a.out 文件中存储。
    初始化数据大小 a_bss 说明了在 a.out 文件中的可读写数据段后面逻辑上存在多少未初始化
    的数据(实际上是被初始化为 0)
    a_entry 域指明了程序的起始地址,同时 a_syms,a_trsize 和 a_drsize 说明了在文件数据
    段后的符号表与重定位信息的大小已经被链接好可以运行的程序中既不需要符号表也不需
    要重定位信息,所以除非链接器为了调试器加入符号信息,否则在可运行文件中这些域都是
    0。

    与虚拟内存的交互
    操作系统加载和启动一个简单的双段文件的过程非常简单,如图 3 所示:
    ---------------------------------------------------------------------------------------------

     读取 a.out 的头部获取段的大小。
    检查是否已存在该文件的可共享代码段。如果是的话,将那个段映射到该进程的地
    址空间。如果不是,创建一个并将它映射到地址空间中,然后从文件中读取文本段
    放入这个新的内存区域。
     创建一个足够容纳数据段和 BSS 的私有数据段,将它映射到进程的地址空间中,然
    后从文件中读取数据段放入内存中的数据段并将 BSS 段对应的内存空间清零。
     创建一个栈的段并将其映射到进程的地址空间(由于数据堆和栈的增长方向不同,
    因此栈段通常是独立于数据段的)将命令行或者调用程序传递的参数放入栈中
     适当的设置各种寄存器并跳转到起始地址。

    ---------------------------------------------------------------------------------------------

    在一个分页系统中,上述的简单机制会为每一个文本段和数据段分配新的虚拟内存。
    由于 a.out 文件已经存储在磁盘中了,所以目标文件本身可以被映射到进程的地址空间中。
    虚拟内存只需要为程序写入的那些页分配新的磁盘空间,这样可以节省磁盘空间。并且由于
    虚拟内存系统只需要将程序确实需要的那些页从磁盘加载到内存中(而不是整个文件),这
    样也加快了程序启动的速度

    对 a.out 文件格式进行少许修改就可以做到这一点,如图 4 所示,这就够成了被称为 Z
    MAGIC 的格式。这些变化将目标文件中的段对齐到页的边界。在页大小为 4K 的系统上,a.ou
    t 头部扩展为 4K,文本段的大小也要对齐到下一个 4K 的边界。由于 BSS 段逻辑上跟在数据段
    的后面并在程序加载时被清零,所以没有必要对数据段进行页边界对齐的填充

    ZMAGIC 格式的文件减少了不必要的换页,对应付出的代价是浪费了大量的磁盘空间。a.
    out 的头部仅有 32 字节长,但是仍需要分配 4K 磁盘空间给它。文本和数据段之间的空隙平
    均浪费了 2K 空间,即半个 4K 的页。上述这些问题都在被称为 ZMAGIC 的压缩可分页格式中被
    修正了
    由于并没有什么特别的原因要求文本段的代码必须从地址 0 处开始运行,因此压缩可分
    页文件将 a.out 头部当成是文本段的一部分(实际上由于未初始化的指针变量经常为 0,位
    置 0 绝对不是一个程序入口的好地方)。代码紧跟在头部的后面,并将整个页映射为进程的
    第二个页,而不映射进程地址空间的第一个页,这样对位置 0 的指针引用就会失败,如图 5
    所示。它也产生了一个无害的副作用就是将头部映射到进程的地址空间中了。

    QMAGIC 格式的可执行文件中文本和数据段都各自扩充到一个整页,这样系统就可以很
    容易的将文件中的页映射到地址空间中的页。数据段的最后一页由值为零的 BSS 数据填充补
    齐;如果 BSS 数据大于可以填充补齐的空间,那么 a.out 的头部中会保存剩余需要分配的 BS
    S 空间大小。
    尽管 BSD UNIX 将程序加载到位置 0(或 QMAGIC 格式的 0x1000)处,其它版本的 UNIX 会
    将程序加载到不同的位置。例如 Motorola 68K 系列上的系统 5(System V)会将程序加载到
    0x80000000 处,在 386 上会加载到 0x8048000 处。只要地址是页对齐的,并且能够与链接器
    和加载器达成一致,加载到哪里都没有关系

    符号和重定位

    目前我们讨论过的目标文件格式都是可加载的,即可以加载到内存中并直接运行。多
    数目标文件并不是可加载的,但相当一部分是由编译器或汇编器生成传递给链接器或库管理
    器的中间文件。这些可链接文件比起那些可运行文件来说,要复杂的多。由于可运行文件要
    运行在计算机的底层硬件上因此必须要足够简单,但可链接文件的处理属于软件层面,因此
    可以做很多非常高级的事情。原则上,一个支持链接的加载器可以在程序被加载时完成所有
    链接器必须完成的功能,但由于效率原因加载器通常都尽可能的简单,以提高程序启动的速
    度(动态链接(我们将在第 10 章涉及),将很多工作由链接器转移到加载器(由此在性能
    上有一些损失),但由于现代计算机的速度足够快了,所以采用动态链接的利大于弊)。
    现在我们来看看五种逐步复杂的格式:BSD UNIX 系统采用的 a.out 可重定位格式,系
    统五(System V)使用的 ELF 格式,IBM 360 目标文件格式,32 位 Windows 上使用的扩展的 C
    OFF 可链接和 PE 可执行格式,以及 COFF 格式 Windows 系统之前的 OMF 可链接格式。

    可重定位的 a.out 格式

    UNIX 系统对于可运行文件和可链接文件都使用相同的一种目标文件格式,其中可运行
    文件省略掉了那些仅用于链接器的段。我们在图 2 中看到的 a.out 格式包含了链接器使用的
    一些域。文本和数据段的重定位表的大小保存在 a_trsize 和 a_drsize 中,符号表的尺寸保
    存在 a_syms 中。这三个段跟在文本和数据段后,如图 7 所示。
    ---------------------------------------------------------------------------------------------
    图 3-7:简化的 a.out
    a.out 头部
    文本段
    数据段
    文本重定位表
    数据重定位表
    符号表
    字串表

    重定位项
    重定位项有两个功能。当一个代码段被重定位到另一个不同的段基址时,重定位项标
    注出代码中需要被修改的地方。在一个可链接文件中,同样也有用来标注对未定义符号引用
    的重定位项,这样链接器就知道在最终解析符号时应当向何处补写符号的值。


    每一个重定位项包含了在文本或数据段中需被重定位
    的地址,以及定义了要做什么的信息。该地址是一个需要进行重定位的项目到文本段或数据
    段起始位置的偏移量。长度域说明了该重定位项目的长度,从 0 到 3 依次对应 1、2、4 或者
    (在某些体系结构上)8 个字节。pcrel 标志表示这是一个“PC(程序计数器,即指令寄存
    器)相对的”重定位项目,如果是的话,它会在指令中被作为相对地址使用
    ---------------------------------------------------------------------------------------------
    图 3-8:重定位项格式
    4 个 byte 的地址,3 个 byte 的索引,1 个 bit 的 pcrel 标志,2 个 bit 的长度域,1 个 b
    it 的外部标志,4 个 bit 的空闲位

    外部标志域控制对 index 域的解释,确定该重定位项目是对某个段或符号的引用。如果
    外部标志为 off,那这是一个简单的重定位项目,index 就指明了该项目是基于哪个段(文
    本、数据或 BSS)寻址的。如果外部标志为 on,那么这是一个对外部符号的引用,则 index
    是该文件符号表中的符号序号。

    符号和字串

    a.out 文件的最后一个段是符号表。每个表项长度为 12 字节,描述一个符号,如图 9
    所示。
    ---------------------------------------------------------------------------------------------
    图 3-9:符号格式
    4 个 byte 的名字偏移量,1 个 byte 的类型,1 个 byte 的空闲字节,2 个 byte 的调试信
    息,4 个 byte 的值
    UNIX 编译器允许任意长度的标识符,所以名字字串全部都在符号表后面的字串表中。
    符号表项的第一个域是该符号以空字符结尾的名字字串在字串表中的偏移量。在类型字节中,
    若低位被置位则该符号是外部的(用词不当,该符号实际是可以被其它模块看到的符号,所
    以称为全局符号更合适)。非外部符号对于链接是没有必要的,但是会被调试器用到。其余
    的位是符号类型。最重要的类型包括:
    文本、数据或 BSS:模块内定义的符号。外部标志位可能设置或没有设置。值为与
    该符号对应的模块内可重定位地址。
    abs:绝对非可重定位符号(absolute non-relocatable symbol)。很少在调试信
    息以外的地方使用。外部标志位可能设置或没有设置。值为该符号的绝对地址。
    undefined:在该模块中未定义的符号。外部标志位必须被设置。值通常为 0,但下
    面会讲到的“公共块技巧”中的内容是例外。这些符号类型对于诸如 C、Fortran
    这样的老一些语言是足够了,但对于 C++等而言,几乎不够。
    作为一种特例,编译器可以使用一个未定义的符号来要求链接器为该符号的名字预留
    一块存储空间如果一个外部符号的值不为零,则该值是提示链接器程序希望该符号寻址存
    储空间的大小。在链接时,若该符号的定义不存在,则链接器根据其名字在 BSS 中创建一块
    存储空间,大小为所有被链接模块中该符号提示尺寸中的最大值。如果该符号在某个模块中
    被定义了,则链接器使用该定义而忽略提示的空间大小。这种“公共块技巧(common block
    hack)”支持 Fortran 公共块和未初始化的 C 外部数据的典型用法(尽管是非标准的)。


    Unix ELF 格式

    可执行和链接格式(Executable and Linking Format)

    ELF 格式有三个略有不同的类型:可重定位的,可执行的,和共享目标(shared objec
    ts)。可重定位文件由编译器和汇编器创建,但在运行前需要被链接器处理。可执行文件完
    成了所有的重定位工作和符号解析(除了那些可能需要在运行时被解析的共享库符号),共
    享目标就是共享库,即包括链接器所需的符号信息,也包括运行时可以直接执行的代码。
    ELF 格式具有不寻常的双重特性,如图 10 所示。编译器、汇编器和链接器将这个文件
    看作是被区段(section)头部表描述的一系列逻辑区段的集合,而系统加载器将文件看成
    是由程序头部表描述的一系列段(segment)的集合。一个段(segment)通常会由多个区段
    (section)组成。例如,一个“可加载只读”段可以由可执行代码区段、只读数据区段和
    动态链接器需要的符号组成。可重定位文件具有区段表,可执行程序具有程序头部表,而共
    享目标文件两者都有区段(section)是用于链接器后续处理的,而段(segment)会被映
    射到内存中

    ELF 文件都是以 ELF 头部起始的,如图 11 所示。头部被设计为即使在那些字节顺序与
    文件的目标架构不同的机器上也可以被正确的解码。头 4 个字节是用来标识 ELF 文件的幻数,
    接下来的 3 个字节描述了头部其余部分的格式。当程序读取了 class 和 byteorder 标志后,
    它就知道了文件的字节序和字宽度,就可以进行相应的字节顺序和数据宽度的转换。其它的
    域描述了区段头部或程序头部的大小和位置(如果它们存在的话)。
    ---------------------------------------------------------------------------------------------
    图 3-11:ELF 头部
    char magic[4] = "177ELF";//幻数
    char class;
    //地址宽度, 1 = 32 位, 2 = 64 位
    char byteorder; //字节序, 1 = little-endian,2 = big-endian
    char hversion; //头部版本,总是 1
    char pad[9];
    //填充字节
    short filetype; //文件类型:1 = 可重定位,2 = 可执行,
    //3 = 共享目标,4 = 转储镜像(core image)
    short archtype; //架构类型,2 = SPARC,3 = x86,4 = 68K,等等.
    int fversion; //文件版本,总是 1
    int entry; //入口地址(若为可执行文件)
    int phdrpos; //程序头部在文件中的位置(不存在则为 0)
    int shdrpos; //区段头部在文件中的位置(不存在则为 0)

    int flags; //体系结构相关的标志,总是 0
    short hdrsize; //该 ELF 头部的大小
    short phdrent; //程序头部表项的大小
    short phdrcnt; //程序头部表项个数(不存在则为 0)
    short shdrent; //区段头部表项的大小
    short phdrcnt; //区段头部表项的个数(不存在则为 0)
    short strsec;
    //保存有区段名称字串的区段的序号
    ---------------------------------------------------------------------------------------------
    可重定位文件
    区段类型包括:(注:此处表示的是Segment)
     PROGBITS:程序内容,包括代码,数据和调试器信息。
     NOBITS:类似于 PROGBITS,但在文件本身中并没有分配空间。用于 BSS 数据,在程
    序加载时分配空间
     SYMTAB 和 DYNSYM:符号表,后面会有更加详细的描述。SYMTAB 包含所有的符号并
    用于普通的链接器,DYNSYM 包含那些用于动态链接的符号(后一个表需要在运行时
    被加载到内存中,因此要让它尽可能的小)。
     STRTAB:字串表,与 a.out 文件中的字串表类似。与 a.out 文件不同的是,ELF 文
    件能够而且经常为不同的用途创建不同的字串表,例如全段名称、普通符号名称和
    动态链接符号名称。
     REL 和 RELA:重定位信息。REL 项将其中的重定位值加到存储在代码和数据中的基
    地址值,而 RELA 将重定位需要的基地址也保存在重定位项自身中(由于历史原因,
    x86 目标文件使用 REL 重定位类型,68k 使用 RELA 重定位类型)。每种体系结构下
    都有多种重定位类型,但它们类似于(也起源于)a.out 的重定位类型。
     DYNAMIC 和 HASH:动态链接信息和运行时符号 hash 表。这里用到了 3 个标志位:AL
    LOC,意味着在程序加载时该区段要占用内存空间;WRITE 意味着该区段被加载后是
    可写的;EXECINSTR 即表示该区段包含可执行的机器代码。


    一个典型的可重定位可执行程序会有十多个区段。很多区段的名称对于链接器在根据
    它所支持的区段类型来进行特定的处理(同时根据标志位将不支持的区段忽略或原封不动的
    传递下去)时,都是有意义的
    区段的类型包括:
     .text 是具有 ALLOC 和 EXECINSTR 属性的 PROGBITS 类型区段。相当于 a.out 的文本
    段。
     .data 是具有 ALLOC 和 WRITE 属性的 PROGBITS 类型区段。对应于 a.out 的数据段。
     .rodata 是具有 ALLOC 属性的 PROGBITS 类型区段。由于是只读数据,因此没有 WRI
    TE 属性。
     .bss 是具有 ALLOC 和 WRITE 属性的 NOBITS 类型区段。BSS 区段在文件中没有分配
    空间,因此是 NOBITS 类型,但由于会在运行时分配空间,所以具有 ALLOC 属性
     .rel.txt,.rel.data 和.rel.rodata 每个都是 REL 或 RELA 类型区段。是对应文本
    或数据区段的重定位信息
    .init 和.fini,都是具有 ALLOC 和 EXECINSTR 属性的 PROGBITS 类型区段。与.text
    区段相似,但分别为程序启动和终结时执行的代码。C 和 Fortran 不需要这个,但
    是对于具有初始和终结函数的全局数据的 C++语言来说是必须的。
     .symtab 和.dynsym 分别是 STMTAB 和 DNYSYM 类型的区段,对应为普通的和动态链
    接器的符号表。动态链接器符号表具有 ALLOC 属性,因为它需要在运行时被加载
     .strtab 和.dynstr 都是 STRTAB 类型的区段,这是名称字串的表,要么是符号表,
    要么是段表的段名称字串.dynstr 区段保存动态链接器符号表字串,由于需要在
    运行时被加载所以具有 ALLOC 属性。此外还有一些特殊的区段诸如.got 和.plt,分
    别是全局偏移量表(Global Offset Table)和动态链接时使用的过程链接表(Pro
    cedure Linkage Table),PLT 将在第 10 章中涉及。.debug 区段包含调试器所需的
    符号,.line 区段也是用于调试器的,它保存了从源代码的行号到目标代码位置的
    映射关系。而.comment 区段包含着文档字串,通常是版本控制中的版本序号。

    还有一个特殊的区段类型.interp,它包含解释器程序的名字。如果这个区段存在,系
    统不会直接运行这个程序,而是会运行对应的解释器程序并将该 ELF 文件作为参数传递给解
    释器。例如 UNIX 上多年以来都有可以解释型的自运行文本文件,只需要在文件的第一行加
    上:
    #!/path/to/interpreter
    ELF 将这种功能扩展到那些可以运行非文本程序的解释器上。实际使用中它被用来调用
    运行时动态链接器以加载程序并将任何需要的共享库链接进来
    ELF 符号表与 a.out 符号表相似,包含一个由表项组成的数组,如图 13 所示。
    ---------------------------------------------------------------------------------------------
    图 3-13:ELF 符号表
    int name; //名称字串在字串表中的位置
    int value; //符号值,在可重定位文件中是段相对地址,
    //在可执行文件中是绝对地址
    int size;
    //目标或函数的大小
    char type:4; //符号类型:数据目标,函数,区段,或特殊文件
    char bind:4; //符号绑定类型:局部,全局,或弱符号
    char other; //空闲
    short sect; //段基址,ABS,COMMON 或 UNDEF
    --------------------------------------------------------------------------------

    ELF 符号表增加了少许新的域。size 域指明了数据目标(尤其是未定义的 BSS,又使用
    了公共块技巧)的大小,一个符号的绑定可以是局部的(仅模块内可见),全局的(所有地
    方均可见),或者弱符号。
    弱符号是半个全局符号:如果存在一个对未定义的弱符号的有效定义,则链接器采用
    该值,否则符号值缺省为 0
    符号的类型通常是数据或者函数。对每一个区段都会有一个区段符号,通常都是使用
    该区段本身的名字,这对重定位项是有用的(ELF 重定位项的符号都是相对地址,因此就需
    要一个段符号来指明某一个重定位项目是相对于文件中的哪一个区段)。文件入口点是一个
    包含源代码文件名称的伪符号

    区段号(即段基址)是相对于该符号的定义所在的那个段的,例如函数入口点都是相
    对于.text 段定义的。这里还可以看到三个特殊的伪区段,UNDEF 用于未定义符号,ABS 用于
    不可重定位绝对符号,COMMON 用于尚未分配的公共块(COMMON 符号中的 value 域提供了所需
    的对齐粒度,size 域提供了尺寸最小值。一旦被链接器分配空间后,COMMON 符号就会被转
    移到.bss 区段中)。
    如图 14 所示,是一个典型的完整的 ELF 文件,包含代码、数据、重定位信息、链接器
    符号、和调试器符号等若干区段。如果该文件是一个 C++程序,那可能还包含.init、.fin
    i、.rel.init 和.rel.fini 等区段
    ---------------------------------------------------------------------------------------------
    图 3-14:可重定位 ELF 文件示例
    ELF 文件头部
    .text
    .data
    .rodata
    .bss
    .sym
    .rel.text
    .rel.data
    .rel.rodata
    .line
    .debug
    .strtab
    (区段表,但不将其作为一个区段来考虑)


    ELF 可执行文件
    一个 ELF 可执行文件具有与可重定位 ELF 文件相同的通用格式,但对数据部分进行了调
    整以使得文件可以被映射到内存中并运行。文件中会在 ELF 头部后面存在程序头部。程序头
    部定义了要被映射的段。如图 15 所示为程序头部,是一个由段描述符组成的数组。
    ---------------------------------------------------------------------------------------------
    图 3-15:ELF 程序头部
    int type; //类型:可加载代码或数据,动态链接信息,等
    int offset; //段在文件中的偏移量
    int virtaddr; //映射段的虚拟地址
    int physaddr; //物理地址,未使用
    int filesize; //文件中的段大小
    int memsize; //内存中的段大小(如果包含 BSS 的话会更大些)
    int flags; //读,写,执行标志位
    int align; //对齐要求,根据硬件页尺大小不同有变动
    ---------------------------------------------------------------------------------------------
    一个可执行程序通常只有少数几种段,如代码和数据的只读段,可读写数据的可读写
    段。所有的可加载区段都归并到适当类型的段中以便系统可以通过少数的一两个操作就可以
    完成文件映射

    ELF 格式文件进一步扩展了 QMAGIC 格式的 a.out 文件中使用的“头部放入地址空间”的
    技巧,以使得可执行文件尽可能的紧凑,相应付出的代价就是地址空间显得凌乱了些。一个
    段可以开始和结束于文件中的任何偏移量处,但是段的虚拟起始必须和文件中起始偏移量具
    有低位地址模对齐的关系,例如,必须起始于一页的相同偏移量处。系统必须将段起始所在
    页到段结束所在页之间整个的范围都映射进来,哪怕在逻辑上该段只占用了被映射的第一页
    和最后一页的一部分。

    被映射的文本段包括 ELF 头部,程序头部,和只读文本,这样 ELF 头部和程序头部都会
    在文本段开头的同一页中。文件中仅有的可读写数据段紧跟在文本段的后面。文件中的这一
    页会同时被映射为内存中文本段的最后一页和数据段的第一页(以 copy-on-write 的方式)。
    如果计算机具有 4K 的页,并在可执行文件中文本段结束于 0x80045ff,然后数据段起始于 0
    x8005600。文件中的这一页(即同时存有文本和数据段的页)在内存 0x8004000 处被映射为
    文本段的最后一页(头 0x600 个字节包含文本段中 0x8004000 到 0x80045ff 之间的内容),
    并在 0x8005000 处被映射为数据段(0x600 以后的部分包含数据段从 0x8005600 到 0x80056ff
    的内容)。(注:4k即0x1000)
    BSS 段也是在逻辑上也是跟在数据段的可读写区段后,在本例中长度为 0x1300 字节,
    即文件中尺寸与内存中尺寸的差值。数据段的最后一页会从文件中映射进来,但是在随后操
    作系统将 BSS 段清零时,copy-on-write 系统会该段做一个私有的副本。
    如果文件中包含.init 或.fini 区段,这些区段会成为只读文本段的一部分,并且链接
    器会在程序入口点处插入代码,使得在调用主程序之前会调用.init 段的代码,并在主程序
    返回后调用.fini 区段的代码
    ELF 共享目标包含了可重定位和可执行文件的所有东西。它在文件的开头具有程序头部
    表,随后是可加载段的各区段,包括动态链接信息。在构成可加载段的各区段之后的,是重
    定位符号表和链接器在根据共享目标创建可执行程序时需要的其它信息,最后是区段表。

    ELF 格式小结
    ELF 是一种较为复杂的格式,但它的表项和预期的一样好。它既是一个足够灵活的格式
    (可以支持 C++),又是一种高效的可执行格式(对于支持动态链接的虚拟内存系统),同
    时也可以很方便的将可执行程序的页直接映射到程序的地址空间。它还允许从一个平台到另
    一个平台的交叉编译和交叉链接,并在 ELF 文件内包含了足以识别目标体系结构和字节序的
    信息。

    第 4 章 存储空间分配

    链接器或加载器的首要任务是存储分配。一旦分配了存储空间后,链接器就可以继续
    进行符号绑定和代码调整。在一个可链接目标文件中定义的多数符号都是相对于文件内的存
    储区域定义的,所以只有存储区域确定了才能够进行符号解析。

    段和地址

    每个目标或可执行文件都会采用目标地址空间的某种模式。通常这里的目标是目标计
    算机的应用程序地址空间,但某些情况下(例如共享库)也会是其它东西。在一个重定位链
    接器或加载器中的基本问题是要确保程序中的所有段都被定义并具有地址,并且这些地址不
    能发生重叠(除非有意这样)。
    一个链接器输入文件都包含一系列各种类型的段。不同类型的段以不同的方式来处
    。通常,所有相同类型的段,诸如可执行代码段,会在输出文件中被合并为一个段。有时
    候段是在其它段的基础上合并得到的(如 Fortran 的公共块),以及在越来越多的情况下
    (如共享库和 C++专有特性),链接器本身会创建一些段并将其放置在输出中。
    存储布局是一个“两遍”的过程,这是因为每个段的地址在所有其它段的大小未确定
    前是无法分配的

    简单的存储布局

    多数体系结构要求数据必须对齐于字边界,或至少在对齐时运行速度会更快些。因此
    链接器通常会将 Li 扩充到目标体系结构最严格的对齐边界(通常是 4 或 8 个字节)的倍数。
    多种段类型

    除最简单格式外所有的目标格式,都具有多种段的类型,链接器需要将所有输入模块
    中相应的段组合在一起。在具有文本和数据段的 UNIX 系统上,被链接的文件需要将所有的
    文本段都集中在一起,然后跟着的是所有的数据,在后面是逻辑上的 BSS(即使 BSS 在输出
    文件中不占空间,它仍然需要分配空间来解析 BSS 符号,并指明当输出文件被加载时要分配
    的 BSS 空间尺寸)。这就需要两级存储分配策略。

    段与页面的对齐

    如果文本和数据被加载到独立的内存页中,这也是通常的情况,文本段的大小必须扩
    充为一个整页,相应的数据和 BSS 段的位置也要进行调整。很多 UNIX 系统都使用一种技巧
    来节省文件空间,即在目标文件中数据紧跟在文本的后面,并将那个(文本和数据共存的)
    页在虚拟内存中映射两次,一次是只读的文本段,一次是写时复制(copy-on-write)的数
    据段。这种情况下,数据段在逻辑上起始于文本段末尾紧接着的下一页,这样就不需扩充文
    本段,数据段也可对齐于紧接着文本段后的 4K(或者其它的页尺寸)页边界。

    公共块和其它特殊段
    上面这种简单的段分配策略在链接器处理的 80%的存储分配中都工作的很好,但剩下的
    那些情况就需要用特殊的技巧来处理了。这里我们来看看比较常见的几个。
    公共块

    C++重复代码消除
    。。。。。

    链接器控制脚本

    传统上链接器可以允许用户对输出数据进行有限的控制。由于链接器已经开始要面对
    内存组织非常复杂的目标环境,诸如众多的嵌入式处理器和目标环境,因此就非常必要对目
    标地址空间和输出文件中的数据提供更加精确的控制。具有一系列固定段的简单链接器通常
    具有可以指定各个段基地址的开关参数,这样程序就可以被加载到非标准的应用环境中(操
    作系统内核通常会用到这些开关参数)。有一些链接器具有数量庞大的命令行开关参数,由
    于系统经常会限制命令行的长度,因此经常将这些命令行逻辑上连续的放置在一个文件中。
    例如,微软的链接器在文件中为每个区段设置特性时最多可以采用大约 50 个命令行开关选
    项,包括输出的基地址和一系列其它输出相关的细节。
    其它的链接器定义了可以控制链接器输出的脚本语言

    第 5 章 符号管理
    绑定和名字解析
    链接器要处理各种类型的符号。所有的链接器都要处理各模块之间符号化的引用。每
    个输入模块都有一个符号表。其中的符号包括:
    当前模块中被定义(和可能被引用)全局符号
    在被模块中被引用但未被定义的全局符号(通常成为外部符号)。
    段名称,通常被当作定义在段起始位置的全局符号。
    非全局符号,调试器或崩溃转储(crash dump)分析通常会用到它们。这些符号几乎
    不会被链接过程用到,但有时候它们经常会和全局符号混在一起,所以链接器至少要能够跳
    过它们。在另一些情况中它们会在文件中一个单独的表中,或在一个单独的调试信息文件中
    (可选的)。

    链接器读入输入文件中所有的符号表,并提取出有用的信息,有时就是输入的信息,
    通常都是关于需要链接哪些东西的。然后它会建立链接时符号表并使用该表来指导链接过程
    根据输出文件格式的不同,链接器会将部分或全部的符号信息放置在输出文件中。

    某些格式会在一个文件中存在多个符号表。例如 ELF 共享库会有一个动态链接所需信息
    的符号表,和一个单独的更大的用来调试和重链接的符号表。这个设计不见得糟糕。动态链
    接器所需的表比全部的表通常要小得多,将它独立出来可以加快动态链接的速度,毕竟调试
    或重链接一个库的机会(相比运行这个库)还是很少的。


    符号表格式
    链接器中的符号表与编译器中的相近,由于链接器中用到的符号一般没有编译器中的
    那么复杂,所以符号表通常也更简单一些。在链接器内,有一个列出输入文件和库模块的符
    号表,保留了每一个文件的信息。第二个符号表处理全局符号,即链接器需要在输入文件中
    进行解析的符号第三个表可以处理模块内调试符号,尽管少数情况下链接器也会为调试符
    号建立完整的符号表,但通常都只需将输入的调试符号传递到输出文件。
    在链接器本身内部,符号表通常以表项组成的数组形式来保存,并通过一个 hash 函数
    来定位表项,或者是由指针组成的数组,并通过 hash 函数来索引,相同 hash 的表项以链表
    的形式来组织。当需要在表中定位一个符号时,链接器根据符号名计算 hash 值,将该值用
    桶的个数来取模,以定位某一个 hash 桶(图中的 symhash[h%NBUCKET],h 为 hash 值),然
    后遍历其中的符号链表来查找符号。


    模块表
    链接器需要跟踪整个链接过程中出现的每一个输入模块,即包括明确链接的模块,也
    包括从库中提取出来的模块。图 2 所示可以产生 a.out 目标文件的 GNU 链接器的简化版模块
    表结构。由于每个 a.out 文件的关键信息大部分都在文件头部中,该表仅仅是将文件头部复
    制过来。
    ------------------------------------------------------------------------------------------
    图 5-2:模块表
    /* 该文件名称 */
    char *filename;
    /* 符号名字串起始地址 */
    char *local_sym_name;
    /* 描述文件内容的布局 */
    /* 文件的 a.out 头部 */
    struct exec header;
    /* 调试符号段在文件内的偏移量,如果没有则为 0 */
    int symseg_offset;
    /* 描述从文件中加载到内核的数据 */
    /* 文件的符号表 */
    struct nlist *symbols;
    /* 字串表大小,以字节为单位 */
    int string_size;
    /* 指向字串表的指针 */
    char *strings;
    /* 下面两个只在 relocatable_output 为真,或输出未定义引用的行号时使用 */
    /* 文本和数据的重定位信息 */
    struct relocation_info *textrel;
    struct relocation_info *datarel;
    /* 该文件的段与输出文件的关系 */
    /* 该文件中文本段在输出文件核心镜像中的起始地址 */
    int text_start_address;
    /* 该文件中数据段在输出文件核心镜像中的起始地址 */
    int data_start_address;
    /* 该文件中 BSS 段在输出文件核心镜像中的起始地址 */
    int bss_start_address;
    /* 该文件中第一个本地符号在输出文件中符号表中的偏移量,以字节为单位 */
    int local_syms_offset;
    ------------------------------------------------------------------------------------------
    该表中还包含了指向符号表、字串表(在一个 a.out 文件中,符号名称字串是在符号表
    外另一个单独的表中)和重定位表在内存中副本的指针,同时还有计算好的文本、数据和 B
    SS 段在输出中的偏移量。如果该文件是一个库,每一个被链接的库成员还有它自己的模块
    表表项(细节在此略去)。
    第一遍扫描中,链接器从每一个输入文件中读入符号表,通常是将它们一字不差的复
    制到内存中。在将符号名放入单独的字串表的符号格式中,链接器还要将符号表读入,并且
    为了后续处理更容易一些,还要遍历符号表将每一个的名称字串偏移量转换为指向内存中名
    称字串的指针


    全局符号表
    链接器会保存一个全局符号表,在任何输入文件中被引用或者定义的符号都会有一个
    表项,如图 3 所示。每次链接器读入一个输入文件,它会将该文件中所有的全局符号加入到
    这个符号表中,并将定义或引用每个符号的位置用链表组织起来。当第一遍扫描完成后,每
    一个全局符号应当仅有一个定义,0 或多个引用(这里稍微简化了一些,因为 UNIX 目标文件
    会将公共块伪装成具有非零值的未定义符号,但这是一个链接器很容易处理的特殊情况)。
    ------------------------------------------------------------------------------------------
    图 5-3:全局符号表
    /* 摘自 GNU ld a.out */
    struct glosym
    {
    /* 指向该符号所在 hash 桶中下一个符号的指针 */
    struct glosym *link;
    /* 该符号的名称 */
    char *name;
    /* 作为全局符号的符号值 */
    long value;
    /* 该符号在文件中的外部 nlist 链表,包括定义和引用 */
    struct nlist *refs;
    /* 非零值则意味该符号被定义为公共块,该数值即各公共块中的最大尺寸 */
    int max_common_size;
    /* 非零意味着该全局符号是存在的。库程序不能根据该数值加载 */
    char defined;
    /* 非零则意味着一个确信被加载的文件中引用了该全局符号。大于 1 的数值是该
    符号定义的 n_type 编码
    */
    char referenced;
    /* 1 表示该符号具有多个定义
    2 表示该符号具有多个定义,其中一些是集合元素,并且有一个已经被打印出
    来了
    */
    unsigned char multiply_defined;
    }
    ------------------------------------------------------------------------------------------
    由于每个输入文件中的全局符号都被加入到全局符号表中,链接器会将文件中每一个
    项链接到它们在全局符号表中对应的表项中,如图 4 所示。重定位项一般通过索引模块自己
    的符号表来指向符号,因此对于每一个外部引用,链接器必须要对此很清楚,例如模块 A 中
    的符号 15 名为 fruit,模块 B 中的符号 12 同样名为 fruit,也就是说,它们是同一个符号。
    每一个模块都有自己的索引集,相应也要用自己的指针向量。
    ------------------------------------------------------------------------------------------
    图 5-4:通过全局符号表来解析文件中的符号
    每一个模块项指向输入文件的符号向量,向量中的每一项均指向全局符号表表项

    符号解析

    在链接的第二遍扫描过程中,链接器在创建输出文件时会解析符号引用。解析的细节
    与重定位(见第七章)是有相互影响的,这是因为在多数目标格式中,重定位项标识了程序
    中对符号的引用。在最简单的情况下,即链接器使用绝对地址来创建输出文件(如 UNIX 链
    接器中的数据引用),解析仅仅是用符号地址来替换符号的引用。如果符号被解析到地址 2
    0486 处,则链接器会将相应的引用替换为 20486。
    实际情况要复杂得多。诸如,引用一个符号就有很多种方法,通过数据指针,嵌入到
    指令中,甚至通过多条指令组合而成。此外,链接器生成的输出文件本身经常还是可以再次
    链接的。这就是说,如果一个符号被解析为数据区段中的偏移量 426,那么在输出中引用该
    符号的地方要被替换为可重定位引用的[数据段基址+426]。
    输出文件通常也拥有自己的符号表,因此链接器还要新创建一个在输出文件中符号的
    索引向量,然后将输出重定位项中的符号编号映射到这些新的索引中
    特殊符号
    很多系统还会使用少量链接器自己定义的特殊符号。所有的 UNIX 系统都要求链接器定
    etext、edata 和 end 符号依次作为文本、数据和 BSS 段的结尾系统调用 sbrk()将 end 的
    地址作为运行时内存堆的起始地址,所以堆可以连续的分配在已经存在的数据和 BSS 的后面
    对于具有构造和析构例程的程序,很多链接器会为每一个输入文件创建指向这些例程
    的指针表,并通过链接器创建的诸如__CTOR_LIST__这样的符号让该语言的启动代码可以找
    到这个表并依次调用其中所有的例程。

    名称修改
    在目标文件符号表和链接中使用的名称,与编译目标文件的源代码程序中使用的名称
    往往是有差别的。主要原因有 3:避免名称冲突,名称超载,和类型检查。将源代码中的名
    称转换为目标文件中的名称的过程称为名称修改(name mangling)。
    C++类型编码:类型和范围
    C++类之外的数据变量名称不会进行任何的修改。一个名为 foo 的数组修改后的名称仍
    为 foo。与类无关的函数名称修改后增加了参数类型的编码,通过前缀__F 后面跟表示参数
    类型的字母串来实现。图 5 列出了各种可能的类型表示。例如,函数 func(float, int, uns
    igned char)变成了 func__FfiUc。类的名称会被当作是各种类型来对待,编码为类名称长度
    数字后面跟类的名称,例如 4Pair。类还可以包含内部多级子类的名称,这种限定性(quali
    fied)名称被编码为 Q,还有一个数字标明该成员的级别,然后是编码后的类名称。因此 Fi
    rst::Second::Third 就变成了 Q35First6Second5Third。这意味着采用两个类做为参数的函
    数 f(Pair, First::Second::Third)就变成了 f__F4PairQ35First6Second5Third。
    图 5-5:C++中调整后的名字的类型
    类型
    字母
    ----------------------------------------
    void v
    char c
    short s
    int i
    long l
    float f
    double d
    long double r
    varargs e
    ---------------------------------
    unsigned U
    const C
    volatile V
    signed S
    ---------------------------------
    pointer P
    reference R
    array of length n An_
    function F
    pointer to nth member MnS
    ------------------------------------------------------------------------------------------
    类的成员函数编码为:先是函数名,然后是两个下划线,接着是编码后的类名称,然
    后是 F 和参数,所以 cl::fn(void)就变成了 fn__2clFv。所有的操作符都具有 4 到 5 个字符
    的编码后名称,诸如“*”对应__ml,“|=”对应__aor。包括构造、析构、new 和 delete 在
    内的特殊函数编码为__ct、__dt、__nw 和__dl。因此具有两个字符指针参数的类 Pair 的构
    造函数 Pair(char *, char*)的名称就变成了__ct__4PairFPcPc.
    最后,由于修改后的名称会变得很长,因此对具有多个相同类型参数的函数有两种简
    捷编码。代码 Tn 表示“与第 n 个参数类型相同”,Nnm 表示“n 个参数与第 m 个参数的类型
    相同”。因此函数 segment(Pair, Pair)的名称就成了 segment__F4PairT1,而函数 trapezoi
    d(Pair, Pair, Pair, Pair)的名称就是 trapezoid__F4PairN31。

    弱外部符号和其它类型符号
    目前为止,我们一直认为所有链接器全局符号的工作方式都是相同的,每次提到的名
    称要么是定义,要么是对符号的引用。很多目标格式都会将引用分为弱或者是强。强引用必
    须被解析,而弱引用存在定义则解析,如果不存在定义也不认为是错误。链接器处理弱引用
    的方式与强引用很相似,除了第一次扫描结束后没有定义的弱引用不会报错。通常链接器会
    将未定义的弱符号定义为 0,这是一个应用程序代码可以检查的数值。弱符号在链接库的时
    候是非常有用的,因此我们将在第 6 章进行讨论。

    第6章 库
    库的格式
    最简单的库格式就是仅仅将目标模块顺序排列。在诸如磁带和纸带这样的顺序访问介
    质上,对于增加目录要注意的是,由于链接器不得不将整个库读入,因此跳过库成员和将他
    们读入的速度差不多。但在磁盘上,目录可以相当显著的提高库搜索速度,现在已经成为了
    标准组件。

    UNIX 和 Windows 的 Archive 文件

    UNIX 链接器库使用一种称为“archive”的格式,它实际上可以用于任何类型文件的聚
    合,但实践中很少用于其它地方。库的组成,首先是一个 archive 头部,然后交替着是文件
    头部和目标文件。最早的 archive 没有符号目录,只有一系列的目标文件,但后续版本就出
    现了多种类型的目录,最后沉淀为一个类型并在 BSD 版本(文本 archive 头部和一个称为__.
    SYMDEF 的目录)和 System V.4 的 COFF 和 ELF 库当前版本中使用了将近十年,BSD 的后期版
    本,Linux,Windows 的 ECOFF 库使用和 COFF 库相同的 archive 格式,但是对于目录,虽然仍
    然称为/,但是格式是不同的。
    UNIX archive
    所有的现代 UNIX 系统都采用大同小异的 archive 格式,如图 1 所示。该格式在 archive
    头部中只使用文本字符,这意味着文本文件的 archive 文件本身就是文本的(尽管在实践中
    可以知道这个特性并没有太大的用处)。archive 文件都是以 8 字符的标志串!<arch> 开头,
    其中 是换行符。在每一个 archive 成员之前是一个 60 字节的头部,包含有:
    该成员名称,补齐到 16 个字符(下面会讲到)。
    修改时间,由从 1970 年到当时的十进制秒数表示
    十进制数字表示的用户和组 ID。
    一个八进制数表示的 UNIX 文件模式。
    以字节为单位的十进制数表示的文件尺寸。如果该尺寸为奇数,那么文件的内容中会
    补齐一个换行符使得总长度为偶数,但这个补齐的字符不会计算在文件尺寸域中。
    保留的两个字节,为引号和换行符。这样就可以让头部成为一行文本,并可用来简单
    的验证当前头部的有效性
    每一个成员头部都会包含修改时间、用户和组 ID、文件模式,尽管链接器会将它们忽
    略。
    ---------------------------------------------------------------------------
    图 6-1 Unix 档案文件格式
    File header:
    !<arch>
    Member header:
    char name[16];/* 成员名称 */
    char modtime[12]; /* 修改时间 */
    char uid[6]; /* 用户 ID */
    char gid[6]; /* 组 ID */
    char mode[8]; /* 8 进制文件模式 */
    char size[10]; /* 成员大小 */
    char eol[2]; /* 保留空间,一对引号/换行符 */
    ---------------------------------------------------------------------------
    a.out 档案文件将目录存储在一个称为__.SYMDEF 的成员中,如图 2 所示,它必须是档
    案文件的首个成员。该成员起始的第一个字包含了以字节为单位表示的随后符号表的大小,
    因此符号表中的表项个数应当是该字数值的 1/8。紧随符号表后的一个字表示了随后的字串
    表大小,然后接着是字串表,每个字串都以空字节结尾。每个符号表项都包含一个以 0 为起
    始的偏移量,它指示了该符号名称在字串表中的位置,以及定义了该符号的成员的头部在文
    件中的位置。符号表项的顺序通常与文件中各成员的顺序相同。
    ---------------------------------------------------------------------------
    图 6-2 SYMDEF 目录格式
    int tablesize;
    /* 表示随后符号表的大小,以字节为单位*/
    struct symtable {
    int symbol; /* 在字串表中的偏移量 */
    int member; /* 成员指针 */
    } symtable [];
    int stringsize; /* 表示随后字串表的大小,以字节为单位 */
    char strings[]; /* 多个以空字符结尾的字串 */
    ----------------------------------------------------------------------------
    COFF 和 ELF 档案文件格式使用了另一个不可能出现在文件名中的符号/作为符号目录
    名称(而不是采用__.SYMDEF)并使用了一种更简单的格式,如图 3 所示。最初 4 个字节的
    数值为符号个数。随后是由档案文件成员在文件中的偏移量构成的数组,然后是一系列由空
    字符结尾的字串。第一个偏移量指向的成员定义了由字串表中第一个字串命名的符号,以此
    类推。COFF 档案文件通常会忽略当前体系结构的字节序而符号表中采用 big-endian 的字节
    序。
    ----------------------------------------------------------------------------
    图 6-3:COFF/ELF 目录格式
    int nsymbols; /* 符号个数 */
    int member[]; /* 成员偏移量数组 */
    char strings[]; /* 多个以空字符结尾的字串 */
    ----------------------------------------------------------------------------
    微软的 ECOFF 档案文件格式增加了第二个符号目录成员,如图 4 所示。跟在第一个的后
    面并且莫名其妙的也称为/。
    ----------------------------------------------------------------------------
    图 6-4:ECOFF 的第二个符号目录
    int nmembers; /* 成员偏移量的个数 */
    int members[]; /* 成员偏移量数组 */
    int nsymbols; /* 符号的个数 */
    ushort symndx[]; /* 成员偏移量的指针 */
    char strings[];
    /* 符号名称,以字母顺序排列 */
    ----------------------------------------------------------------------------

    ECOFF 目录由一个成员项个数和跟在其后的成员偏移量数组组成,数组的每个元素对应
    一个档案文件成员。后面依次是符号项个数、两字节的成员偏移量指针构成的数组,以及相
    应数量的按照字母顺序排列、以空字符结尾的符号字串。成员偏移量指针包含从 1 开始的成
    员偏移量表的索引,该表属于定义了相对应符号的成员。例如,如果要定位与第 5 个符号相
    对应的成员,就可以去查找指针数组中的第 5 项,它包含了在成员偏移量数组中对应该符号
    定义的索引。理论上经过排序的符号可以进行快速查找,但在实际中速度的提升并没有(预
    计的)那么大,因此链接器通常会扫描整个表来查找要加载的符号。


    建立库文件
    每种档案文件格式都有它自己建立库文件的方法。根据操作系统对档案格式支持程度
    的不同,库的创建会涉及包括从标准系统文件管理程序到库特定工具在内的任何东西
    做为一个极端,IBM MVS 库可以通过标准的 IEBCOPY 工具来创建,该工具可以创建分区
    的数据集。做为中间的一种情况,UNIX 库由 ar 命令来创建,它可以将多个文件合并为档案
    文件。对于 a.out 格式的档案文件,有一个名为 ranlib 的独立程序来添加符号目录,从每
    个成员中读取符号,创建__.SYMDEF 成员并将其放入文件中。原则上说 ranlib 也可以将符号
    目录创建为一个单独的文件然后调用 ar 命令将该文件加入到档案文件中,但实际中 ranlib
    会直接操作档案文件。对于 COFF 和 ELF 档案文件,ranlib 创建符号目录(如果有成员是目
    标代码模块的时候)的功能被转移到了 ar 中,尽管 ar 也可以创建没有目标代码模块的档案
    文件。

    库创建中有一个小问题,是目标文件的顺序,尤其是对那些不具有符号目录的古老格
    式。在 ranlib 出现之前的 UNIX 有一对叫做 lorder 和 tsort 的程序来帮助创建档案文件。lo
    rder 程序的输入是一系列的目标文件(而不是库),输出是一个依赖性列表记录了一个文
    件依赖于其它文件中的哪些符号(这并不难,经典的 lorder 代码实现,曾经而且现在也仍
    然是 shell 脚本,它使用一个符号显示工具将符号都提取出来,对这些符号进行少许的文字
    处理,然后使用标准的 sort 和 join 程序来创建自己的输出)。tsort 对 lorder 的输出进行
    拓扑排序,产生一个排序后的文件的列表,这样符号可以在所有对它的引用后面来定义,这
    就可以通过对该文件的一次顺序扫描来解析所有的未定义引用。lorder 的输出会被用来控制
    ar。
    虽然现代的库中的符号目录允许链接过程在工作时可以忽略一个库中各个目标模块的顺序,
    但大多数库仍然会由 lorder 和 tsort 来创建以提高链接过程的速度

    搜索库文件
    一个库文件在创建后,链接器还要能够对它进行搜索。库的搜索通常发生在链接器的
    第一遍扫描时,在所有单独的输入文件都被读入之后。如果一个或多个库具有符号目录,那
    么链接器就将目录读入,然后根据链接器的符号表依次检查每个符号。如果该符号被使用但
    是未定义,链接器就会将符号所属文件从库中包含进来。仅将文件标识为稍后加载是不够的,
    链接器必须像处理那些在显式被链接的文件中的符号那样,来处理库里各个段中的符号。段
    会记入段表,而符号,包括定义的和未定义的,都会记入全局符号表。
    库符号解析是一个迭代的过程,在链接器对目录中的符号完成一遍扫描后,如果在这
    遍扫描中它又从该库中包括进来了任何文件,那么就还需要再进行一次扫描来解析新包括进
    来的文件所需的符号,直到对整个目录彻底扫描后不再需要括入新的文件为止。并不是所有
    的链接器都这么做的,很多链接器只是对目录进行一次连续的扫描,并忽略在库中一个文件
    对另一个更早扫描的文件的向后依赖。像诸如 tsort 和 lorder 这样的程序可以尽量减少由
    于一遍扫描给链接器带来的困难,不过并不推荐程序员通过显式的将相同名称的库在链接器
    命令行中列出多次来强制进行多次扫描并解析所有符号

    NIX 链接器和很多 Windows 链接器在命令行或者控制文件中会使用一种目标文件和库
    混合在一起的列表,然后依次处理,这样程序员就可以控制加载目标代码和搜索库的顺序了。
    虽然原则上这可以提供相当大的弹性并可以通过将同名私有例程列在库例程之前而在库例程
    中插入自己的私有同名例程,在实际中这种排序的搜索还可以提供一些额外的用处。程序员
    总是可以先列出所有他们自己的目标文件,然后是任何应用程序特定的库,然后是和数学、
    网络等相关的系统库,最后是标准系统库
    当程序员们使用多个库的时候,如果库之间存在循环依赖的时候经常需要将库列出多
    。就是说,如果一个库 A 中的例程依赖一个库 B 中的例程,但是另一个库 B 中的例程又依
    赖了库 A 中的另一个例程,那么从 A 扫描到 B 或从 B 扫描到 A 都无法找到所有需要的例程。
    当这种循环依赖发生在三个或更多的库之间时情况会更加糟糕。告诉链接器去搜索 A B A 或
    者 B A B,甚至有时为 A B C D A B C D,这种方法看上去很丑陋,但是确实可以解决这个
    问题。由于在库之间几乎不会有重复的符号,如果链接器可以像 IBM 的大型主机系统链接器
    或者 AIX 链接器那样,简单的将它们作为一个组一起搜索,那程序员就很舒服了。
    该规则的一个主要例外是应用程序有时候会对少许例程定义自己的私有版本,尤其是
    对 malloc 和 free,为了进行堆存储管理往往想采用自己的私有版本而不是标准的系统库版
    。在这种情况下,比使用一个链接器标志注明“不要在库中搜寻这些符号”(效果相同但)
    更好的方法是在搜索顺序中将私有的 malloc 放在公共版本之前


    性能问题
    和库相关的主要性能问题是花费在顺序扫描上的时间。一旦符号目录成为标准之后,
    从一个库中读取输入文件的速度就和读取单独的输入文件没有什么明显差别了,而且只要库
    是拓扑排序的,那链接器在基于符号目录进行扫描时很少会超过一遍

    弱外部符号

    符号解析和库成员选择中所采用的简单的定义引用模式对很多应用而言显得灵活有余
    效率不足。例如,大多数 C 程序会调用 printf 函数族中的例程来格式化输出数据。printf
    可以格式化各种类型的数据,包括浮点类型。这就意味着任何使用 pringf 的程序都会将浮
    点库链接进来,即便它根本不使用浮点数
    解决这个困境的方法就是弱外部符号,就是不会导致加载库成员的外部符号。如果该
    符号存在一个有效的定义,无论是从一个显式链接的文件还是普通的外部引用而被链接进来
    的库成员中,一个弱外部符号会被解析为一个普通的外部引用。但是如果不存在有效的定义,
    弱外部符号就不被定义而实际上解析为 0,这样就不会被认为是一个错误。在上面这个例子
    中,I/O 模块将会产生一个对 fcvt 的弱引用,真正的浮点模块在库中跟在 I/O 模块后面,并
    且不再需要伪例程。现在如果有一个对 fltused 的引用,则链接浮点例程并定义 fcvt。否则,
    对 fcvt 的引用保持未定义。这将不再依赖于库的顺序,即使对于对库进行多次扫描解析也
    没有问题。
    ELF 还添加了另一种弱符号,和弱引用(weak reference)等价的弱定义(weak defini
    tion)。“弱定义”定义了一个没有有效的普通定义的全局符号。如果存在有效的普通定义,
    那么就忽略弱定义。弱定义并不经常使用,但在定义错误伪函数 而无须将其分散在独立的
    模块中的时候,是很有用的。



    第 7 章 重定位
    为了决定段的大小、符号定义、符号引用,并指出包含那些库模块、将这些段放置在
    输出地址空间的什么地方,链接器会将所有的输入文件进行扫描。扫描完成后的下一步就是
    链接过程的核心,重定位。由于重定位过程的两个步骤,判断程序地址计算最初的非空段,
    和解析外部符号的引用,是依次、共同处理的,所以我们讲重定位即同时涉及这两个过程。
    链接器的第一次扫描会列出各个段的位置,并收集程序中全局符号与段相关的值。一
    旦链接器确定了每一个段的位置,它需要修改所有的相关存储地址以反映这个段的新位置
    在大多数体系结构中,数据中的地址是绝对的,那些嵌入到指令中的地址可能是绝对或者相
    对的。链接器因此需要对它们进行修改,我们稍后会讨论这个问题。
    第一遍扫描也会建立第五章中所讲的全局符号表。链接器还会将符号表中的地址解析
    为引用全局符号时所存储的地址。
    硬件和软件重定位
    由于几乎所有的现代计算机都具有硬件重定位,可能会有人疑问为什么链接器或加载
    器还需要进行软件重定位答案部分在于性能的考虑,部分在于绑定时间
    硬件重定位允许操作系统为每个进程从一个固定共知的位置开始分配独立的地址空间,
    这就使程序容易加载,并且可以避免在一个地址空间中的程序错误破坏其它地址空间中的程
    序。软件链接器或加载器重定位将输入文件合并为一个大文件以加载到硬件重定位提供的地
    址空间中,然后就根本不需要任何加载时的地址修改了

    链接时重定位和加载时重定位
    很多系统即执行链接时重定位,也执行加载时重定位。链接器将一系列的输入文件合
    并成一个准备加载到特定地址的单一输出文件。当这个程序被加载后,所存储的那个地址是
    无效的,加载器必须重新定位被加载得程序以反应实际的加载地址。在包括 MS-DOS 和 MVS
    在内的一些系统上,每一个程序都按照加载到地址 0 的位置而被链接。实际的地址是跟据有
    效的存储空间而定的,这个程序在被加载时总是会被重定位的。在其它的一些系统上,尤其
    是 MS Windows,程序按照被加载到一个固定有效地址的方式来链接,并且一般不会进行加载
    时重定位,除非发生该地址已被别的程序所占用之类的异常情况(当前版本的 Windows 实际
    上从不对可执行程序进行加载时重定位,但是对 DLL 共享库会进行重定位。相似的,UNIX 系
    统从不对 ELF 程序进行重定位,虽然它们对 ELF 共享库会进行重定位)。
    加载时重定位和链接时重定位比起来就颇为简单了。在链接时,不同的地址需要根据
    段的大小和位置重定位为不同的位置。在加载时,整个程序在重定位过程中会被认为是大的
    单一段,加载器只需要判断名义上的加载地址和实际加载地址的差异即可


    符号和段重定位



  • 相关阅读:
    Linux下使用ps命令来查看Oracle相关的进程
    虚继承、虚基类
    C++/C小知识点(2)
    C++中的运行中动态类型识别RTTI
    什么应该放在头文件?何为外连接?
    Placement new、operator new、new operator 完全释疑
    二叉搜索树
    Linux C编程学习资料,学习过程
    独立对象(非附属对象)大小
    编程珠玑第一章、第二章
  • 原文地址:https://www.cnblogs.com/jingzhishen/p/3527407.html
Copyright © 2011-2022 走看看