[Note] 程序员的自我修养——第 2 部分 静态链接

第 2 章 编译和链接

被隐藏了的过程

  • 使用 GCC 编译 Hello World 程序 gcc hello.c,可以分解为 4 个步骤,分别是预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)

  • 预编译

    • 源代码文件 hello.c 和相关的头文件(如 stdio.h 等)被预编译器 cpp 预编译成一个 .i 文件

    • 第一步预编译的过程相当于如下命令(-E 表示只进行预编译):

      1
      gcc -E hello.c -o hello.i

      或者:

      1
      cpp hello.c > hello.i

    • 预编译过程主要处理那些源代码文件中的以 # 开始的预编译指令,主要处理规则如下:

      • 将所有的 #define 删除,并且展开所有的宏定义
      • 处理所有条件预编译指令,比如 #if#ifdef#elif#else#endif
      • 处理 #include 预编译指令,将被包含的文件插入到该预编译指令的位置,这个过程是递归进行的
      • 删除所有的注释 ///* */
      • 添加行号和文件名标识,比如 #2 “hello.c” 2,以便于编译时编译器产生调试用的行号信息,及用于编译时产生编译错误或警告时能够显示行号
      • 保留所有的 #pragma 编译器指令,因为编译器须要使用它们
    • 经过预编译后的 .i 文件不包含任何宏定义,并且包含的文件也己经被插入到 .i 文件中

    • 无法判断宏定义是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定问题

  • 编译

    • 把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件

    • 编译过程相当于如下命令:

      1
      gcc -S hello.i -o hello.s

    • 现在版本的 GCC 使用一个叫做 cc1 的程序,把预编译和编译两个步骤合并成一个步骤:

      1
      gcc -S hello.c -o hello.s

      可以直接调用 cc1:

      1
      /usr/lib/gcc/i486-linux-gnu/4.1/cc1 hello.c

    • 对于 C 语言,这个预编译和编译的程序是 cc1;对于 C++,对应的程序是 cc1plus;Objective-C 是 cc1obj;fortran 是 f771;Java 是 jc1。gcc 这个命令只是这些后台程序的包装,它会根据不同的参数要求去调用预编译编译程序 cc1、汇编器 as、 链接器 ld

  • 汇编

    • 将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令

    • 汇编过程可以调用汇编器 as 来完成:

      1
      as hello.s -o hello.o

      或者:

      1
      gcc -c hello.s -o hello.o

      或者使用 gcc 命令从 C 源代码文件开始,经过预编译、编译和汇编直接输出目标文件(Object File):

      1
      gcc -c hello.c -o hello.o

  • 链接

    • 怎样调用 ld 才可以产生一个能够正常运行的 HelloWorld 程序:

      1
      ld -static /usr/lib/crt1.o /usr/lib/crti.o /usr/lib/gcc/i486-linux-gnu/4.1.3/crtbeginT.o -L /usr/lib/gcc/i486-linux-gnu/4.1.3 -L /usr/lib -L /lib hello.o --start-group -lgcc -lgcc_eh -lc --end-group /usr/lib/gcc/i486-linux-gnu/4.1.3/crtend.o /usr/lib/crtn.o

编译器做了什么

  • 编译过程一般可以分为 6 步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化
  • 词法分析
    • 首先源代码程序被输入到扫描器(Scanner)进行词法分析,运用一种类似于有限状态机(Finite State Machine)的算法将源代码的字符序列分割成一系列的记号(Token)
    • 词法分析产生的记号一般可以分为:关键字、标识符、字面量(包含数字、字符串等)和特殊符号(如加号、等号)
    • 同时,将标识符存放到符号表,将数字、字符串常量存放到文字表等,以备后面的步骤使用
    • lex 程序按照用户描述好的词法进行扫描,由此编译器的开发者无须为每个编译器开发一个独立的词法扫描器,只需改变词法规则即可
  • 语法分析
    • 接下来语法分析器(Grammar Parser)将对由扫描器产生的记号进行语法分析,采用上下文无关语法(Context-free Grarmar)的分析手段,生成语法树(Syntax Tree),即以表达式(Expression)为节点的树
    • 在语法分析的同时,很多运算符号的优先级和含义也被确定下来。如果出现了表达式不合法,编译器就会报告语法分析阶段的错误
    • 类似 lex,yacc ( Yet Another Compiler Compiler)根据用户给定的语法规则对输入的记号序列进行解析,从而构建出一棵语法树。对于不同的编程语言,编译器的开发者只须改变语法规则,而无须为每个编译器编写一个语法分析器
  • 语义分析
    • 由语以分析器(Semantic Analyzer)完成,分析的是静态语义(Static Semantic),通常包括声明和类型的匹配,类型的转换
    • 经过语义分析后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转换,会在语法树中插入相应的转换节点
    • 语义分析器还对符号表里的符号类型也做了更新
  • 中间语言生成
    • 源码级优化器(Source Code Optimizer)往往将整个语法树转换成中间代码(Intermediate Code),它是语法树的顺序表示,非常接近目标代码,但一般跟目标机器和运行时环境无关
    • 比较常见的中间代码类型:三地址码(Three-address Code)和 P-代码(P-Code)
    • 中间代码使得编译器可以被分为前端和后端,前端负责产生机器无关的中间代码,后端将中间代码转换成目标机器代码
    • 对于一些可以跨平台的编译器而言,它们可以针对不同的平台使用同一个前端和针对不同机器平台的数个后端
  • 目标代码生成与优化
    • 中间代码之后的过程都属于编译器后端,主要包括代码生成器(Code Generator)和目标代码优化器(Target Code Optimizer)
    • 代码生成器将中间代码转换成目标机器代码,这个过程十分依赖于目标机器
    • 目标代码优化器对目标代码进行优化,比如选择合适的寻址方式、使用位移来代替乘法运算、删除多余的指令等

链接的历史

  • 定义其他模块的全局变量和函数在最终运行时的绝对地址都要在最终链接的时候才能确定
  • 由于程序被修改,重新计算各个目标的地址过程被叫做重定位(Relocation)
  • 汇编语言使用接近人类的各种符号和标记来帮助记忆,使用符号来标记位置,使得人们从具体的指令地址中逐步解放出来
  • 汇编器在每次汇编程序的时候会重新计算符号(Symbol)地址,然后把所有引用到该符号的指令修正到正确的地址
  • 一个程序被分割成多个模块,模块间依靠符号来通信(函数调用、变量访问),模块的拼接过程就是链接

模块拼接——静态链接

  • 链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地衔接
  • 链接器的工作就是把一些指令对其他符号地址的引用加以修正
  • 链接过程主要包括地址和空间分配(Address and Storage Allocation)、符号决议(Symbol Resolution)和重定位(Relocation)等
  • 地址修正的过程也被叫做重定位,每个要被修正的地方叫一个重定位入口(Relocation Entry),重定位所做的就是给程序中每个绝对地址引用的位置“打补丁”,使它们指向正确的地址
  • 每个模块的源代码文件文件经过编译器编译成目标文件,目标文件和库(Library)一起链接形成最终可执行文件
  • 库是一组目标文件的包,最常见的库就是运行时库(Runtime Library),它是支持程序运行的基本函数的集合

第 3 章 目标文件里有什么

目标文件的格式

  • 现在 PC 平台流行的可执行文件格式主要是 Windows 下的 PE(Portable Executable)和 Linux 的 ELF(Executable Linkable Format),它们都是 COFF (Common file format)格式的变种
  • 目标文件就是源代码编译后但未进行链接的那些中间文件(Windows 的.obj 和 Linux 的 .o),它跟可执行文件的内容与结构很相似,所以一般跟可执行文件格式一起采用一种格式存储
  • 可执行文件(Windows 的.exe 和 Linux 下的 ELF 可执行文件)、动态链接库(DLL,Dynamic Linking Library,Windows 的 .dll 和 Linux 的 .so)及静态链接库(Static Linking Library,Windows 的 .lib 和 Linux 的 .a)文件都按照可执行文件格式存储
  • 静态链接库稍有不同,它把很多目标文件捆绑在一起形成一个文件,再加上一些索引
  • ELF 格式的文件可归为 4 类:
ELF 文件类型 说明 实例
可重定位文件
Relocatable File
包含代码和数据,可以被用来链接成可执行文件或共享目标文件,静态链接库也可归为此类 Linux 的 .o
Windows 的 .obj
可执行文件
Executable File
包含可以直接执行的程序,它的代表就是 ELF 可执行文件,一般都没有扩展名 如 /bin/bash 文件
Windows 的 .exe
共享目标文件
Shared Object File
包含代码和数据,可以在两种情况下使用:(1) 链接器可以使用这种文件跟其他的可重定位文件和共享目标文件链接,产生新的目标文件;(2) 动态链接器可以将几个这种共享目标文件与可执行文件结合,作为进程映像的一部分来运行 Linux 的 .so
Windows 的 DLL
核心转储文件
Core Dump File
当进程意外终止时,系统可以将该进程的地址空间的内容及终止时的一些其他信息转储到核心转储文件 Linux 下的 core dump

目标文件是什么样的

  • 目标文件中包括编译后的机器指令代码、数据,以及链接时所须要的一些信息,比如符号表、调试信息、字符串等
  • 一般目标文件将这些信息按不同的属性,以“节”(Section)的形式存储,有时候也叫“段”(Segment)
  • ELF 文件的开头是一个“文件头”,它描述了整个文件的属性,包括文件是否可执行、是静态链接还是动态链接及入口地址(如果是可执行文件)、目标硬件、目标操作系统等信息,此外还包括一个段表(Section Table),描述各个段在文件中的偏移位置及段的属性等
  • 一般 C 语言编译后的机器代码保存在 .text 段,已初始化的全局变量和局部静态变量都保存在 .data 段,未初始化的全局变量和局部静态变量放在 .bss 段
  • .bss 段记录所有未初始化的全局变量和局部静态变量的大小总和,只是为其预留位置,并没有内容,所以在文件中也不占据空间(但程序运行时要占内存空间)
  • 数据和指令分段存放的好处有:
    • 数据区域可读写,而指令区域只读,二者分别设置权限可以防止程序的指令被有意或无意地改写
    • 现代 CPU 的缓存一般都被设计成数据缓存和指令缓存分离,分开存放有利于提高程序的局部性,提高 CPU 缓存命中率
    • 当系统中运行着多个该程序的副本时,可以共享指令部分以及其他只读数据,在有动态链接的系统中,可以节省大量内存;而每个副本的数据区域是不一样的,为进程所私有

挖掘 SimpleSection.o

1
gcc -c SimpleSection.c
  • binutils 的工具 objdump 可以用来查看各种目标文件的结构和内容,可被移植到各种平台上;Linux 下的 readelf 是专门针对 ELF 文件格式的解析器

  • 除了最基本的代码段、数据段和 BSS 段以外,还有 3 个段分别是只读数据段(.rodata)、注释信息段(.comment)和堆栈提示段(.note.GNU-stack)

  • 打印各个段的基本信息及更多信息:

    1
    2
    objdump -h SimpleSection.o
    objdump -x SimpleSection.o

  • 查看代码段、数据段和 BSS 段的长度(dec 表示 3 个段长度的和的十进制,hex 表示长度和的十六进制):

    1
    size SimpleSection.o

  • objdump 的 -s 参数可以将所有段的内容以十六进制的方式打印出来,-d 参数可以将所有包含指令的段反汇编:

    1
    objdump -s -d SimpleSection.o

  • .rodata 段存放只读数据,包括只读变量(如 const 修饰的变量)和字符串常量,单独设立 .rodata 段的好处:

    • 在语义上支持了 C++的 const 关键字

    • 操作系统在加载的时候可以将 .rodata 段的属性映射成只读,任何修改操作都会作为非法操作处理,保证程序的安全性

    • 在某些嵌入式平台下,有些存储区域采用只读存储器(如 ROM),将 .rodata 段放在该存储区域中可以保证访问正确的存储器

  • 有时候编译器会把字符串常量放到 .data 段,而不会单独放在 .rodata 段

  • 有些编译器会将全局的未初始化变量存放在目标文件 .bss 段,有些则不存放,只是预留一个未定义的 COMMON 符号,等到最终链接成可执行文件的时候再在 .bss 段分配空间;而编译单元内部可见的静态变量(带有 static 修饰)的确是存放在 .bss 段的

  • 初值为 0 的静态变量可以认为是未初始化的,会被编译器优化放在 .bss 段,从而节省磁盘空间,因为 .bss 不占磁盘空间

  • 其他段:

    常用的段名 说明
    .rodata1 Read only Data,存放只读数据,比如宇符串常量、全局 const变量,跟 .rodata 一样
    .comment 存放编译器版本信息,比如字符串:“GCC:(GNU) 4.2.0”
    .debug 调试信息
    .dynamic 动态链接信息
    .hash 符号哈希表
    .line 调试时的行号表,即源代码行号与编译后指令的对应表
    .note 额外的编译器信息,比如程序的公司名、发布版本号等
    .strtab String Table,字符串表,用于存储 ELF 文件中用到的各种字符串
    .symtab Symbol Table,符号表
    .shstrtab Section String Table,段名表
    .plt
    .got
    动态链接的跳转表和全局入口表
    .init
    .fini
    程序初始化与终结代码段
  • 系统保留的段都以“.”作为前缀,应用程序也可以使用一些非系统保留的名字作为段名,但是不能使用“.”作为前缀

  • 一个 ELF 文件可以拥有几个相同段名的段

  • 可以使用 objcopy 工具将一个二进制文件(比如图片、MP3 音乐、词典等)作为目标文件中的一个段:

    1
    2
    objcopy -I binary -O elf32-i386 -B i386 image.jpg image.o
    objdump -ht image.o

  • GCC 提供了一个扩展机制,使得程序员可以指定变量或函数所处的段:

    1
    2
    __attribute__((section("FOO"))) int global = 42;
    __attribute__((section("BAR"))) void foo() {}

ELF 文件结构描述

  • 用 readelf 命令来详细查看 ELF 文件头:

    1
    readelf -h SimpleSection.o

  • ELF 的文件头中定义了 ELF 魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI 版本、ELF 重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等

  • 入口地址规定 ELF 程序的入口虚拟地址,操作系统在加载完该程序后从这个地址开始执行进程的指令;可重定位文件一般没有入口地址,则这个值为 0

  • ELF 最前面 16 字节用来标识平台属性,如字长(32/64 位)、字节序、ELF 文件版本,其中前 4 字节是所有 ELF 文件都必须相同的标识码(0x7F, 0x45, 0x4c, 0x46),称为魔数,用来确认文件的类型,操作系统在加载可执行文件时会确认魔数是否正确

  • 段表(Section Header Table)描述了 ELF 各个段的信息,比如段名、长度、偏移、读写权限及其他属性;编译器、链接器和装载器依靠段表来定位和访问各个段的属性

  • objdump -h 命令只显示关键的段,省略了其他辅助性的段;可以使用 readelf 工具来查看真正的段表结构:

    1
    readelf -S SimpleSection.o

  • 链接器在处理目标文件时,需要对代码段和数据段中对绝对地址引用的位置进行重定位,这些重定位信息都记录在重定位表里,对于每个需要重定位的代码段或数据段,都会有一个相应的重定位表,比如 .rel.text 就是针对 .text 段的重定位表

  • ELF 将字符串集中起来存放到一个表,使用字符串在表中的偏移来引用,常见的段名为;

    • .strtab:字符串表(String Table),用来保存普通的字符串,如符号的名字
    • .shstrtab:段表字符串表(Section Header String Table),用来保存段表中用到的字符串,最常见的就是段名

链接的接口——符号

  • 函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)

  • 每一个目标文件都会有一个符号表(Symbol Table),每个定义的符号有一个对应的符号值(Symbol Value),对于变量和函数,符号值就是它们的地址;除此之外,还存在其他几种不常用到的符号:

    • 定义在本目标文件的全局符号,可以被其他目标文件引用
    • 在本目标文件中引用的全局符号,却没有定义在本目标文件,一般叫做外部符号(External Symbol)
    • 段名,往往由编译器产生,它的值就是该段的起始地址
    • 局部符号,只在编译单元内部可见,对于链接过程没有作用,链接器往往也忽略它们;调试器可以使用这些符号来分析程序或崩溃时的核心转储文件
    • 行号信息,即目标文件指令与源代码中代码行的对应关系(可选)
  • 可以使用 readelf、 objdump、nm 等工具来查看 ELF 文件的符号表:

    1
    2
    nm SimpleSection.o
    readelf -s SimpleSection.o

  • 符号表主要包括:

    • 符号名,包含了该符号名在字符串表中的下标
    • 符号相对应的值
      • 在目标文件中,如果是符号的定义并且该符号不是“COMMON 块”类型的,则表示该符号在段中的偏移
      • 在目标文件中,如果符号是“COMMON 块”类型的,则表示该符号的对齐属性
      • 在可执行文件中,表示符号的虚拟地址,对于动态链接器来说十分有用
    • 符号大小,对于包含数据的符号,这个值是该数据类型的大小;如果该值为 0,则表示该符号大小为 0 或未知
    • 符号类型(数据对象、函数、段、文件名)和绑定信息(局部符号、全局符号、弱引用)
    • 符号所在段,如果定义在本目标文件中,则表示符号所在的段在段表中的下标,否则表示:
      • 文件名符号
      • COMMON 块类型的符号(未初始化的全局符号定义)
      • 未定义,表示该符号在本目标文件被引用到,但是定义在其他目标文件中
  • ld 链接器在链接生产可执行文件时会定义很多特殊符号,这些符号并没有在程序中定义,但是可以直接声明并且引用,链接器会在将程序最终链接成可执行文件的时候将其解析成正确的值。几个具有代表性的特殊符号:

    • __executable_start:程序起始地址,注意不是入口地址,是程序最开始的地址
    • __etext_etextetext:代码段结束地址,即代码段最末尾的地址
    • _edataedata:数据段结束地址,即数据段最末尾的地址
    • _endend:程序结束地址

    以上地址都为程序被装载时的虚拟地址

  • C++ 通过符号修饰(Name Decoration)或符号改编(Name Mangling)机制来区分重载的函数名

  • 函数签名(Function Signature)包含函数名、参数类型、所在的类和名称空间及其他信息,编译器及链接器处理符号时使用某种名称修饰的方法,使得每个函数签名对应一个修饰后名称(Decorated Name)

  • GCC 的基本 C++ 名称修饰方法:所有的符号都以 _Z 开头,对于嵌套的名字(在名称空间或在类里面的),后面紧跟 N,然后是各个名称空间和类的名字,每个名字前是名字字符串长度,再以 E 结尾。对于函数,它的参数列表紧跟在 E 后面;对于变量,其类型并没有被加入到修饰后名称中

  • binutils 里面提供了一个叫 c++filt 的工具用来解析被修饰过的名称:

    1
    2
    c++filt _ZN1N1C4funcEi
    # N::C::func(int)

  • Microsoft 提供了一个 UnDecorateSymbolName() 的 API,可以将修饰后名称转换成函数签名

  • C++ 编译器会将在 extern "C" 的大括号内的代码当作 C 语言代码处理,因此 C++ 的名称修饰机制将不会起作用

  • C++ 编译器会在编译 C++ 程序时默认定义宏 __cplusplus,用来判断当前编译单元是不是 C++ 代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    #ifdef __cplusplus
    extern "C" {
    #endif

    void *memset(void *, int, size_t);

    #ifdef __cplusplus
    }
    #endif

    该技巧几乎在所有的系统头文件里面都被用到

  • 对于 C/C++,编译器默认函数和初始化了的全局变最为强符号(Strong Symbol),未初始化的全局变量为弱符号(Weak Symbol),也可以通过 GCC 的 __attribute__((weak)) 来定义任何一个强符号为弱符号。注意,强弱符号都是针对定义来说的,不是针对符号的引用

  • 针对强弱符号的概念,链接器会按如下规则处理与选择被多次定义的全局符号:

    1. 不允许强符号被多次定义(即不同的目标文件中不能有同名的强符号),否则链接器报符号重复定义错误
    2. 如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号
    3. 如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个

    尽量不要使用多个不同类型的弱符号,否则容易导致很难发现的程序错误

  • 引用外部目标文件的符号时,如果没有找到该符号的定义:

    • 强引用(Strong Reference):链接器报符号未定义错误
    • 弱引用(Weak Reference):链接器对于该引用不报错,一般默认其为 0 或一个特珠值,以便识别
  • 在 GCC 中,可以使用扩展关键字 __attribute__((weakref)) 来声明对一个外部函数的引用为弱引用

  • 这种弱符号和弱引用对于库来说十分有用,比如:

    • 库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数
    • 程序可以对某些扩展功能模块的引用定义为弱引用,这样将扩展模块与程序链接在一起时就可以正常使用;如果去掉某些模块,程序也可以正常链接,只是缺少了相应的功能,这使得程序的功能更加容易裁剪和组合

    在 Linux 程序的设计中,如果一个程序被设计成可以支持单线程或多线程的模式,就可以通过弱引用的方法来判断当前的程序是链接到了单线程的 Glibc 库还是多线程的 Glibc 库(是否在编译时有 -lpthread 选项),从而执行单线程版本的程序或多线程版本的程序。

调试信息

  • 在 GCC 编译时加上 -g 参数,编译器就会在产生的目标文件里面加上调试信息
  • 调试信息会占用很大的空间,往往比程序的代码和数据本身大好几倍
  • 在 Linux 下,可以使用 strip 命令来去掉 ELF 文件中的调试信息

第 4 章 静态链接

空间与地址分配

1
ld a.o b.o -e main -o ab
  • -e main 表示将 main 函数作为程序入口,ld 链接器默认的程序入口为 _start
  • -o ab 表示链接输出文件名为 ab,默认为 a.out
  • 按序叠加:直接将各个目标文件依次合并,在有很多输入文件的情况下,输出文件将会有很多零散的段,由于每个段都有地址和空间对齐要求,因此非常浪费空间
  • 相似段合并:将相同性质的段合并到一起,现在的链接器基本上都采用该方法
  • 两步链接(Two-pass Linking)
    1. 空间与地址分配
    2. 符号解析与重定位
  • 在链接之前,目标文件中的所有段的 VMA(Virtual Memory Address)都是 0,因为虚拟空间还没有被分配,等到链接之后,可执行文件 ab 中的各个段都被分配到了相应的虚拟地址
  • 在 Linux 下, ELF 可执行文件默认从地址 0x08048000 开始分配
  • 由于各个符号在段内的相对位置是固定的,确定各个段的起始地址后,加上符号的偏移量即可计算出它们的虚拟地址

符号解析与重定位

  • 使用 objdump -d 进行反汇编
  • 近址相对位移调用指令(Call near, relative, displacement relative to next instruction):前面的 0xE8 是操作码,后 4 字节是被调用函数相对于调用指令的下一条指令的偏移量,在编译时是一个临时的假地址
  • 链接器在完成地址和空间分配之后就确定了所有符号的虚拟地址,据此对每个需要重定位的指令进行地址修正
  • 对于可重定位的 ELF 文件,必须包含重定位表(Relocation Table),用来描述如何修改相应段里的内容;对于每个要被重定位的 ELF 段都有一个对应的重定位表,而一个重定位表往往就是 ELF 文件中的一个段
  • 可以使用 objdump -r 来查看目标文件的重定位表,其中每个重定位入口包含一个 offset:
    • 对于可重定位文件,这个值是该重定位入口所要修正的位置的第一个字节相对于段起始的偏移
    • 对于可执行文件或共享对象文件,这个值是该重定位入口所要修正的位置的第一个字节的虚拟地址
  • 每个重定位的入口都是对一个符号的引用,当链接器须要对某个符号的引用进行重定位时,就会去查找由所有输入目标文件的符号表组成的全局符号表,从而确定这个符号的目标地址;若找不到则会报符号未定义错误
  • 32位 x86 平台下 ELF 文件的重定位入口所修正的指令寻址方式只有两种:
    • 绝对近址 32 位寻址:修正后的地址为该符号的实际地址
    • 相对近址 32 位寻址:修正后的地址为符号距离被修正位置的地址差

COMMON 块

  • 多个符号定义类型不一致,主要分三种情况:

    • 两个或两个以上强符号类型不一致(非法,报符号多重定义错误)
    • 有一个强符号,其他都是弱符号,出现类型不一致(最终输出结果中的符号所占空间与强符号相同)
    • 两个或两个以上弱符号类型不一致(以输入文件中最大的那个为准)
  • 直接导致需要 COMMON 机制的原因是编译器和链接器允许不同类型的弱符号存在,但最本质的原因还是链接器不支持符号类型,即链接器无法判断各个符号的类型是否一致

  • 在目标文件中,编译器为什么不直接把未初始化的全局变量也当作未初始化的局部静态变量一样处理,为它在 BSS 段分配空间,而是将其标记为一个 COMMON 类型的变量?

    当编译器将一个编译单元编译成目标文件时,如果该编译单元包含了弱符号,那么该弱符号最终所占空间的大小在此时是未知的,因为有可能其他编译单元中该符号所占的空间比本编译单元该符号所占的空间要大,所以编译器此时无法为其在 BSS 段分配空间。但是链接器在链接过程中可以确定弱符号的大小,因为当链接器读取所有输入目标文件以后,任何一个弱符号的最终大小都可以确定了,所以它可以在最终输出文件的 BSS 段为其分配空间。所以总体来看,未初始化全局变量最终还是被放在 BSS 段的。

  • GCC 的 -fno-common 也允许我们把所有未初始化的全局变量不以 COMMON 块的形式处理,或者使用 __attribute__ 扩展,那么它就相当于一个强符号。如果其他目标文件中还有同一个变量的强符号定义,链接时就会发生符号重复定义错误。

1
int global __attribute__((nocommon));

C++ 相关问题

  • 重复代码消除
    • 模板、外部内联函数和虚函数表都有可能在不同的编译单元里生成相同的代码
    • 保留重复代码的问题:
      • 空间浪费
      • 地址较易出错,有可能两个指向同一个闲数的指针会不相等
      • 指令运行效率较低,如果同样一份指令有多份副本,那么指令 Cache 的命中率就会降低
    • 一个比较有效的做法:将每个模板的实例代码都单独地存放在一个段里,每个段只包含一个模板实例,当别的编译单元以相同类型实例化该模板后,也会生成同样的名字,这样链接器在最终链接的时候可以区分这些相同的模板实例段,然后将它们合并入最后的代码段。GNU GCC 和 VISUAL C++ 编译器都采用类似的方法
    • 对于外部内联函数、虚函数表的做法也类似
    • VISUAL C++ 提供了一个编译选项叫函数级别链接,让所有的函数都像模板函数一样,独保存到一个段里,当链接器须要用到某个函数时,它就将它合并到输出文件中,对于那些没有用的函数则将它们抛弃。这种做法可以很大程度上减小输出文件的长度,减少空间浪费,但会减慢编译和链接过程,目标文件随着段数目的增加也会变得相对较大
    • GCC 也提供了类似的机制,它有两个选择 -ffunction-sections-fdata-sections,分别将每个函数或变量保持到独立的段中
  • 全局构造与析构
    • C++ 的全局对象的构造函数在 main 之前被执行,析构函数在 main 之后被执行
    • Linux 系统下一般程序的入口是 _start,这个函数是 Linux 系统库(Glibc)的一部分
    • 因此 ELF 文件定义了两种特殊的段:
      • .init 保存进程初始化代码指令,在 main 函数被调用之前,Glibc 会安排执行这个段中的代码
      • .fini 保存进程终止代码指令,当 main 函数正常退出时,Glibc 会安排执行这个段中的代码
  • C++ 与 ABI
    • 要使两个编译器编译出来的目标文件能够相互链接,这两个目标文件必须满足:采用同样的目标文件格式、拥有同样的符号修饰标准、变量的内存分布方式相同、函数的调用方式相同,等等
    • 其中符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为 ABI(Application Binary Interface)
    • 对于 C 语言的目标代码,以下几个方面会决定目标文件之间是否二进制兼容:
      • 内置类型(如 int、float、char 等)的大小和在存储器中的放置方式(大端、小端、对齐方式等)
      • 组合类型(如 struct、union、数组等)的存储方式和内存分布
      • 外部符号(exteral-linkage)与用户定义的符号之间的命名方式和解析方式
      • 函数调用方式,比如参数入栈顺序、返回值如何保持等
      • 堆栈的分布方式,比如参数和局部变量在堆栈里的位置、参数传递方法等
      • 寄存器使用约定,函数调用时哪些寄存器可以修改,哪些须要保存,等等
    • C++ 要做到二进制兼容比 C 来得更为不易:
      • 继承类体系的内存分布,如基类、虚基类在继承类中的位置等
      • 指向成员函数的指针的内存分布,如何通过指向成员函数的指针来调用成员函数,如何传递 this 指针
      • 如何调用虚函数,vtable 的内容和分布形式,vtable 指针在 object 中的位置等
      • template 如何实例化
      • 外部符号的修饰
      • 全局对象的构造和析构
      • 异常的产生和捕获机制
      • 标准库的细节问题,RTTI 如何实现等
      • 内嵌函数访问细节

静态库链接

  • 静态库可以简单地看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件
  • 使用 ar -t libc.a 来查看这个文件包含了哪些目标文件
  • 使用 objdump -t libc.a 查看符号
  • 编译和链接一个普通 C 程序,不仅要用到 C 语言库 libc.a,还需要其他一些辅助性质的目标文件和库
  • collect2 可以看作是 ld 链接器的一个包装,它会调用 ld 来完成对目标文件的链接,然后再对链接结果进行一些处理,主要是收集所有与程序初始化相关的信息并且构造初始化的结构

链接过程控制

  • 链接器一般都提供多种控制整个链接过程的方法,以用来产生用户所须要的文件:
    • 使用命令行来给链接器指定参数
    • 将链接指令存放在目标文件里面,编译器经常会通过这种方法向链接器传递指令
    • 使用链接控制脚本,也是最为灵活、最为强大的链接控制方法
  • 使用脚本控制链接过程使得输出的可执行文件能够满足某些特殊的需求,比如不使用默认 C 语言运行库的程序、运行于嵌入式系统的程序,甚至是操作系统内核、驱动程序等等
  • ld 在用户没有指定链接脚本的时候会使用默认链接脚本,可以使用 ld -verbose 来查看
  • 为了更加精确地控制链接过程,可以自己写一个脚本然后指定为链接控制脚本:ld -T link.script
  • 对于可执行文件来说,符号表和字符串表是可选的,但是段名字符串表用于保存段名,是必不可少的
  • 可以通过 ld 的 -s 参数禁止链接器产生符号表,或者使用 strip 命令来去除程序中的符号表

BFD 库

  • BFD 库(Binary File Descriptor library)把目标文件抽象成一个统一的模型,希望通过一种统一的接口来处理不同的目标文件格式
  • 现在 GCC(更具体地讲是 GNU 汇编器 GAS, GNU Assembler)、链接器 ld、调试器 GDB 及 binutils 的其他工具都通过 BFD 库来处理目标文件,而不是直接操作目标文件,这样做最大的好处是将编译器和链接器本身同具体的目标文件格式隔离开来,一旦须要支持一种新的目标文件格式,只须要在 BFD 库里面添加一种格式即可,而不须要修改编译器和链接器

第 5 章 Windows PE/COFF

Windows 的二进制文件格式 PE/COFF

  • Windows 下的可执行文件和动态链接库采用 PE 格式,VISUAL C++ 编译器产生的目标文件使用 COFF 格式
  • PE 是 COFF 的一种扩展,它们的结构在很大程度上相同,增加了 PE 文件头、数据目录等一些结构
  • 与 ELF 文件相同,PE/COFF 格式也采用基于段的格式
  • 在 VISUAL C++ 中可以使用 #pragma 编译器指示,将变量或函数放到自定义的段:
1
2
3
#pragma data_seg ("FOO")
int global = 1;
#pragma data_seg (".data") // 恢复到 .data

PE 的前身——COFF

ELF 文件中不存在的段

  • .drectve 段: 编译器传递给链接器的指令(Directive),即编译器希望告诉链接器应该怎样链接这个目标文件
  • COFF 文件中所有以 .debug 开始的段都包含着调试信息
    • .debug$S 表示包含符号(Symbol)相关的调试信息段
    • .debug$P 表示包含预编译头文件(Precompiled Header Files)相关的调试信息段
    • .debug$T 表示包含类型(Type)相关的调试信息段

Windows 下的 ELF——PE

  • 数据目录(Data Directory)保存了一些常用的数据结构(如导入表、导出表、资源、重定位表等),便于系统装载 PE 可执行文件时快速查找