《程序员的自我修养——链接、装载和库》读书笔记01

255 阅读7分钟

最近在看《程序员的自我修养——链接、装载与库》这本书,目前只看完了静态链接的部分,了解了自己写的程序如何从源代码到目标文件,再到静态库,最后供其它程序链接的过程。这里只记录Linux环境下的编译链接过程。

编译过程

程序从源代码文件到可执行文件需要经过预处理、编译、汇编和链接4个过程。

  • 预处理过程会把源文件中以#开头的预处理指令给处理掉,也就是我们常见的#include、#define指令等;
  • 编译过程会将预处理完的文件经过词法分析、语法分析、语义分析及优化后生成汇编代码文件;
  • 汇编过程会使用汇编器将汇编代码转换成机器指令,生成目标文件;
  • 链接过程则会将目标文件和相关的库进行链接,最后生成可执行文件。

我们平常使用不添加任何参数的gcc命令编译一个C程序时,gcc会将上述4个步骤全部完成后直接生成一个可执行文件,隐藏了其中的过程,但是我们可以在输入命令时指定参数来控制gcc的编译过程。

目标文件

在使用gcc命令编译程序时指定-c选项能够获得程序的目标文件,在Linux中,目标文件的格式是ELF文件格式类型,可执行文件也是ELF格式类型,在windows中则是PE文件格式。下面以书上的代码SimpleSection.c编译得到的目标文件进行说明,代码如下:

// SimpleSection.c
int printf(const char *format, ...);

int global_init_var = 84;
int global_uninit_var;

void func1(int i) {
    printf("%d\n", i);
}

int main(void) {
    static int static_var = 85;
    static int static_var2;
    int a = 1;
    int b;

    func1(static_var + static_var2 + a + b);
    return a;
}

使用gcc -c SimpleSection.c命令得到SimpleSection.o文件,SimpleSection.c编译后生成的内容以Section的形式存储在目标文件SimpleSection.o中,也就是通常所说的段,其实Section应该翻译称节,否则很容易和后面装载时真正的Segment概念搞混,书上也强调了这点,但还是采用段来对目标文件进行说明。我们无法直接查看SimpleSection.o文件中的内容,但是我们可以借助GNU binutils中提供的readelf工具来查看,这是一个专门查看ELF格式文件的工具。

目标文件结构中最开始的部分是ELF文件头,其中指明了程序的入口地址、段表在文件中的偏移、程序头表(装载时用到)在文件中的偏移等信息。通过ELF文件头中的段表偏移能够找到ELF文件的段表,其中存储了ELF文件中各个段的一些基本信息,包括大小、在文件中的偏移值等。使用readelf -S SimpleSection.o命令打印目标文件中段表的内容如下:

image.png

下面简要的介绍一下一些重要的段:

  • 代码段(.text):存储机器指令,使用objdump -d SimpleSection.o命令可以查看反汇编后的汇编代码。
  • 数据段(.data):存储已经初始化的全局变量和静态变量,从图中可以看到数据段的大小为8字节,刚好对应global_init_var和static_var两个int类型的变量。
  • BSS段(.bss):存储未初始化的全局变量和静态变量,由于未初始化的全局变量和静态变量的值为0,因此为了节省存储空间,没有必要对它们进行存储,只需在段表中指明它们所占空间大小就行,从上图的offset字段可以看出,.bss和.radata的起始位置在文件中的偏移值都是0xa8,因此可以断定BSS段并未占用存储空间。实际上未初始化的全局变量global_uninit_var在链接前还无法确定具体大小(这里牵扯到强符号和弱符号相关的概念,后面会提到),因此无法得知需要分配的空间大小,所以图中BSS段的size只有4字节而不是8字节。
  • 符号表(.symtab):记录代码中各种符号的类型、所在段以及偏移等信息,具体可以通过readelf -s命令进行查看。
  • 字符串表(.strtab):字符串表将ELF文件中的符号名、段名集中起来存储,通过偏移值来获取对应的字符串。
  • 重定位表(.rela.xxx):图中只有代码段的重定位表.rela.text,由于在源代码中引用了外部函数printf,而该函数在本文件中并未定义,因此需要在链接过程中对其进行重定位,可以通过readelf -r命令查看。
  • 段表字符串表(.shstrtab):存储了段名在字符串表中的偏移。

强符号和弱符号

C/C++中多个文件可能会出现全局的同名符号,而且同名的符号类型还有可能不同,因此C/C++中将符号分为强符号和弱符号。函数和初始化了的全局变量是强符号,未初始化的全局变量为弱符号。链接器在处理强、弱符号时有如下规则:

  • 如果链接的多个目标文件出现了多个同名强符号,则会报重复定义的错误;
  • 如果同名的符号中只有一个强符号,多个弱符号,则以强符号为准;
  • 如果多个同名符号都是弱符号,则以占用空间最大的为准。

之前所说的BSS段为什么没有确定未初始化全局变量的所占空间就是基于这个原因,在链接过程之前,我们无法确定未初始化的全局变量到底是什么类型,有可能其他文件就存在一个同名符号或是占用空间更大的弱符号。

静态链接

经过编译得到目标文件后,我们可以使用ld命令将目标文件链接成可执行文件。链接器将输入的目标文件链接成可执行文件的方式分为两种,一种是按序叠加,就是将输入的目标文件按次序叠加起来,这样的结果就是获得的目标文件会有非常多的段;另一种方式是合并相似段,这种方法是将相同的段合并到一起,也是现在的链接器采用的方法。使用第二种方法链接器将链接的过程分为两步:

  • 第一步是空间与地址的分配,这一步中链接器将所有的目标文件的各个相同的段进行合并,并建立起新的段表、符号表;
  • 第二步是符号的解析和重定位,使用上一步收集到的所有信息,根据重定位表和符号表对符号地址进行重定位。

采用静态链接生成的可执行文件相对来说会比较大,因为它将所有用到的目标文件都加进可执行文件当中去,会造成冗余,比如说我们有可能只用到了某个目标文件中的一个函数,但是链接的时候会将整个目标文件都链接进来,这就是为什么现在大部分程序编译时都采用动态链接的原因。在静态链接中为了减少无关代码的引入,我们可以使用函数级别的链接,需要在使用gcc命令时指明参数-ffuction-sections,这个选项会在生成目标文件时将每个函数单独放到独立的段中,在连接过程中会将用到的函数的段添加到可执行文件中,但是这种方法会使编译链接的过程变慢,目标文件也会随着段增加而变大。另一种方法是将每个函数单独放在一个文件中,GNU C标准库就是用这种方法。