在学习链接之前,我们先看看链接的输入“目标文件Object File”里有什么。我们应该可以猜到,既然链接ld的输入是多个目标文件,那么这些目标文件应该包含的信息至少有以下内容(尚未确定值、地址的函数或者变量):

目标文件的格式

目标文件从结构上,和可执行文件的格式基本一致的,它是已经编译后的可执行文件的格式,只是还没有经过链接过程,其中可能有些符号或者有些地址还没有被调整。如果我们执行尚未链接的目标文件,操作系统会返回“文件尚未链接完成”之类的错误,这说明操作系统执行程序的模块是能够识别目标文件的,也表明目标文件和可执行文件是同一类型的文件。

Linux下的目标文件、可执行文件、动态链接库ddl都是按照ELF格式存储的,静态链接库(.a)稍有不同,它是将多个目标文件捆绑形成一个文件,再加上索引。我们可以通过file命令查看一个文件的格式:

$ file a.out
a.out: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=c047100bad48ae5d82e0e8fc3e04e31b33f89bbe, not stripped

可见这是一个共享(shared)目标文件。

目标文件按照数据的类型,以节section的形式存储,有时候也叫段segment。程序编译后的指令一般被存放在代码段,代码段常见的名字为.code或者.text;已初始化全局变量和局部静态变量经常被放在数据段.data;未初始化的全局变量和局部静态变量被存放在.bss段内。由于未初始化的全局变量和局部静态变量默认值都是0,因此为他们在.data段中分配空间并存放数据0是没有必要的。程序运行时他们的确是需要占用空间,并且可执行文件必须记录未初始化的全局变量和局部静态变量的大小总和(用于为这些变量分配空间),记为.bss段,所以.bss仅仅是为这些变量预留位置而已,它并没有内容。

ELF文件的开头是一个文件头,它描述的整个文件的文件属性(是否可执行、是静态链接还是动态链接、入口地址、目标硬件、目标操作系统等),还包含一个段表,用于描述文件各个段在文件中的偏移位置及段的属性。

总的来说,代码被编译后主要被分为两种段:代码段和数据段。

面试问题:为什么指令和数据要分开存储?1. 安全:当程序被装载后,数据和指令分别被映射到两个虚拟区域,可以为不同段设置不同的读写权限,可以防止程序的指令被有意或者无意改写。2. 有利于提高程序缓存的局部性;3. 方便不同的进程共享同一个指令副本,节省内存。

样例

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;
}

我们可以通过objdump查看各段

$ gcc -c SimpleSection.c
$ objdump -h SimpleSection.o

SimpleSection.o:     file format elf64-x86-64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000057  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000008  0000000000000000  0000000000000000  00000098  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000004  0000000000000000  0000000000000000  000000a0  2**2
                  ALLOC
  3 .rodata       00000004  0000000000000000  0000000000000000  000000a0  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .comment      0000002e  0000000000000000  0000000000000000  000000a4  2**0
                  CONTENTS, READONLY
  5 .note.GNU-stack 00000000  0000000000000000  0000000000000000  000000d2  2**0
                  CONTENTS, READONLY
  6 .eh_frame     00000058  0000000000000000  0000000000000000  000000d8  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA

除了基本的代码、数据、bss外,还有三个段,分别是只读数据段.rodata、注释信息段.comment和堆栈提示段.note.GNU-stack。

我们也可以通过size命令查看各段的大小:

$ size SimpleSection.o
   text    data     bss     dec     hex filename
    179       8       4     191      bf SimpleSection.o

数据段和只读数据段

$ objdump -x -d -s SimpleSection.o|less
  ...
Contents of section .data:
 0000 54000000 55000000                    T...U...
Contents of section .rodata:
 0000 25640a00                             %d..
Contents of section .comment:
 0000 00474343 3a202844 65626961 6e20362e  .GCC: (Debian 6.
 0010 332e302d 31382b64 65623975 31292036  3.0-18+deb9u1) 6
 0020 2e332e30 20323031 37303531 3600      .3.0 20170516.
  ...

bss

.bss段存储的是未初始化的全局变量和局部静态变量,更准确的说,是存储了这些变量的大小总和。但是我们通过“objdump -h” 命令看到,.bss的size是4,但是我们的样例代码的global_uninit_var、static_var2一共应该占用8个字节。这是因为,有些编译器会将全局的未初始化变量存放在.bss,有些则不存放,只是预留一个未定义的全局变量符号,等到最终链接的时候再在.bss分配空间。(后文的强/弱符号会有更详细的描述)

其他段

除了上述常见的段外,还有部分段objdump没有列出来。

我们可以指定代码存放在特定的段中,以便实现某些功能,比如linux内核中用来完成一些初始化和用户空间复制时出现页错误异常等。gcc提供了一个扩展机制,可以指定变量所在的段:

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

ELF 文件结构

结合前文,我们省去一些繁琐的结构,就形成了ELF的基本结构图。

文件头

我们可以通过如下命令查看ELF文件头的内容:

$ readelf -h  SimpleSection.o
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              REL (Relocatable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          0 (bytes into file)
  Start of section headers:          1112 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           0 (bytes)
  Number of program headers:         0
  Size of section headers:           64 (bytes)
  Number of section headers:         13
  Section header string table index: 12

从上面的输出我们可以看到,ELF文件头定义了ELF魔数、文件机器字节长度、数据存储方式(大小端)、版本、运行平台、ABI白本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等。

7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00

ELF魔术一共16字节,用来标识ELF文件的平台属性:

段表

段表是ELF文件中除了文件头以外最重要的结构,它描述了ELF的各个段的信息,如每个段的名字、长度、在文件中的偏移量、读写权限等属性。编译器、链接器和装载器都是靠段表来定位和访问各个段的属性的。

我们可以通过以下命令查看段表的具体信息,如符号表、字符串表等。(objdump -h仅展示ELF文件中的关键段)

$ readelf -S SimpleSection.o
There are 13 section headers, starting at offset 0x458:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000057  0000000000000000  AX       0     0     1
  [ 2] .rela.text        RELA             0000000000000000  00000348
       0000000000000078  0000000000000018   I      10     1     8
  [ 3] .data             PROGBITS         0000000000000000  00000098
       0000000000000008  0000000000000000  WA       0     0     4
  [ 4] .bss              NOBITS           0000000000000000  000000a0
       0000000000000004  0000000000000000  WA       0     0     4
  [ 5] .rodata           PROGBITS         0000000000000000  000000a0
       0000000000000004  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  000000a4
       000000000000002e  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  000000d2
       0000000000000000  0000000000000000           0     0     1
  [ 8] .eh_frame         PROGBITS         0000000000000000  000000d8
       0000000000000058  0000000000000000   A       0     0     8
  [ 9] .rela.eh_frame    RELA             0000000000000000  000003c0
       0000000000000030  0000000000000018   I      10     8     8
  [10] .symtab           SYMTAB           0000000000000000  00000130
       0000000000000198  0000000000000018          11    11     8
  [11] .strtab           STRTAB           0000000000000000  000002c8
       000000000000007c  0000000000000000           0     0     1
  [12] .shstrtab         STRTAB           0000000000000000  000003f0
       0000000000000061  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

SimpleSection.o一共有13段,其中第一个是NULL。段表的结构是一个以“Elf32_Shdr(段描述符)”结构体为元素的数组,数组的每个元素对应一个段。段表的第一个元素是无效的段描述符,它的类型是NULL。段表描述符的属性如下:

段的名字对于编译器、链接器是有意义的,但是对于操作系统来说并没有实际意义。对于操作系统,一个段该如何处理取决于它的属性和权限,即由段类型和段标志位来决定。(链接器根据段名判断是指令还是数据;os只负责装载、判断对这些段的访问是否非法)。

段的类型sh_type在编译、链接阶段才有意义,但是它并不真正表示段的类型。我们也可以将一个数据段命名为.text。对于编译器和链接器,主要决定段的属性的是段的类型sh_type(代码、数据、字符串表、重定位表、符号表的hash表、动态链接的符号表等)和段的标志位sh_flags(可写、可执行等)。

重定位表

链接器在处理目标文件的时候,须要对目标文件的某些部位进行重定位,即代码段、数据段中那些对绝对地址的引用的位置。这些重定位的信息都记录在ELF文件的重定位表中。对于每个需要重定位的代码和数据段,都有一个相应的重定位表。

SimpleSection.o中有一个.rela.text的表,它的类型是重定位表SHT_REL,它是针对.text段的重定位表,因为.text中至少有一个绝对地址的引用,那就是对printf函数的调用;我们的样例中.data中没有对绝对地址的引用,它只包含了常量,所以没有.rela.data。

字符串表

ELF中用到了很多的字符串,如段名、变量名,它们的长度是不固定的,所以用固定的结构来表示它比较困难,因此常见的做法是把字符串集中存起来,然后使用字符串在表中的偏移来引用字符串。ELF文件引用字符串只需要给出一个数字下表即可。

字符串表常见的段名为.strtab和.shstrtab,分贝是字符串表(如符号名字)和段表字符串表(段表中用到的字符串,如段名)。

符号

在链接中,目标文件之间相互拼接实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。比如目标文件B用到了目标文件A的foo函数,那么就称目标文件A定义了函数foo,称目标文件B引用了A的函数foo。变量的定义、引用也是同理。在链接中,函数、变量被统称为符号,函数名和变量名就是符号名。

每个目标文件都以一个相应的符号表,它记录了目标文件所有用到的符号。每个定义的符号有一个对应的值,叫做符号值,对于变量和函数,即它们的地址。我们将可以被其他文件引用的符号,称为全局符号。链接过程只关心全局符号的相互“粘合”,局部符号、段名、行号都是次要的,它们对其他目标文件是“不可见”的。样例中用到的符号如下:

$ nm SimpleSection.o
0000000000000000 T func1
0000000000000000 D func1
                 U _GLOBAL_OFFSET_TABLE_
0000000000000004 C global_uninit_var
0000000000000024 T main
                 U printf
0000000000000004 d static_var.1765
0000000000000000 b static_var2.1766

U类型的符号(如_GLOBAL_OFFSET_TABLE_、printf)表示尚未定义,它们的地址尚未确定。这些符号需要等静态/动态链接完成后才能确定。

ELF符号表结构

ELF中的符号表往往是文件中的一个段,名一般为.symtab。符号表是一个Elf32_Sym的数组。数组的第一个元素为无效的符号。符号项结构如下:

每个符号都有一个对应的值。如果符号是函数或者变量,那么符号的值就是这个函数或者变量的地址:

$ readelf -s SimpleSection.o

Symbol table '.symtab' contains 17 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS SimpleSection.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5
     6: 0000000000000004     4 OBJECT  LOCAL  DEFAULT    3 static_var.1765
     7: 0000000000000000     4 OBJECT  LOCAL  DEFAULT    4 static_var2.1766
     8: 0000000000000000     0 SECTION LOCAL  DEFAULT    7
     9: 0000000000000000     0 SECTION LOCAL  DEFAULT    8
    10: 0000000000000000     0 SECTION LOCAL  DEFAULT    6
    11: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 global_init_var
    12: 0000000000000004     4 OBJECT  GLOBAL DEFAULT  COM global_uninit_var
    13: 0000000000000000    36 FUNC    GLOBAL DEFAULT    1 func1
    14: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    15: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf
    16: 0000000000000024    51 FUNC    GLOBAL DEFAULT    1 main

printf在SimpleSection.c没有被定义,所以它的Ndx是SHN_UNDEF。对于那些STT_SECTION类型的符号,它们表示下表为Ndx的段的段名。

特殊符号

当我们使用链接器来生成可执行文件,它会为我们定义很多特殊的符号,我们可以在程序中直接声明并使用它们。下文我们会介绍在“链接过程”中回顾它们:

$ cat SpecialSymbol.c
#include <stdio.h>

extern char __executable_start[];
extern char etext[], _etext[], __etext[];
extern char edata[], _edata[];
extern char end[], _end[];

int main()
{
    printf("executable start %X\n", __executable_start);
    printf("text end %X %X %X\n", etext, _etext, __etext);
    printf("data end %X %X \n", edata, _edata);
    printf("executable end %X  %X\n", end, _end);
}


$ ./SpecialSymbol
executable start 15EE000
text end 15EE7BD 15EE7BD 15EE7BD
data end 17EF030 17EF030
executable end 17EF038  17EF038

弱符号and强符号

当多个目标文件含有相同名字全局符号的定义,那么这些目标文件链接的时候会出现符号重复定义的错误。这种符号的定义可以被称为强符号。有些符号的定义可以被称为弱符号。对于c/c++来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。

编译时,如果没有找到对外部目标文件的符号引用的定义,那么会报符号未定义错误,这种被称为强引用。相对应的,在处理弱引用时,如果符号未被定义,则不报错。弱引用和弱符号主要用于库的链接过程。我们可以通过gcc的扩展关键字来声明一个外部函数的引用为弱引用:

__attribute__ ((weakref)) void foo();

int main(){
    foo();
}

当我们编译这个代码,并不会报错,但是运行文件时,会发生错误(因为foo的地址为0)。我们可以将这个代码和其他已经定义了foo的目标文件链接起来,那就能正确运行。通过这种方式实现的程序,可以方便的升级某些功能,或者基于不同的环境,链接不同的模块然后调用,而无需一次性将所有环境的模块都链接起来。

调试信息

目标文件里面还有可能保存调试信息,如断点、单步行进、目标代码中地址对应的源代码行号等。我们可以使用“gcc -g”来产生带有调试信息的目标文件。

$ gcc -c -g SimpleSection.c

$ readelf -S SimpleSection.o
There are 21 section headers, starting at offset 0x9b8:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 6] .debug_info       PROGBITS         0000000000000000  000000a4
       00000000000000e9  0000000000000000           0     0     1
  [ 7] .rela.debug_info  RELA             0000000000000000  000006e0
       00000000000001b0  0000000000000018   I      18     6     8
  [ 8] .debug_abbrev     PROGBITS         0000000000000000  0000018d
       0000000000000091  0000000000000000           0     0     1
  [ 9] .debug_aranges    PROGBITS         0000000000000000  0000021e
       0000000000000030  0000000000000000           0     0     1
  [10] .rela.debug_arang RELA             0000000000000000  00000890
       0000000000000030  0000000000000018   I      18     9     8
  [11] .debug_line       PROGBITS         0000000000000000  0000024e
       000000000000004a  0000000000000000           0     0     1
  [12] .rela.debug_line  RELA             0000000000000000  000008c0
       0000000000000018  0000000000000018   I      18    11     8
  [13] .debug_str        PROGBITS         0000000000000000  00000298
       00000000000000ba  0000000000000001  MS       0     0     1
  [14] .comment          PROGBITS         0000000000000000  00000352
       000000000000002e  0000000000000001  MS       0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

这些段中保存的就是调试信息。调试信息在目标文件、可执行文件中占用很大的空间,往往比程序的代码和数据本身大好几倍,所以当我们发布程序的时候,一般会将这些对用户没用的调试信息去掉,以节省大量的空间。在Linx下,我们可以用strip命令去掉ELF文件中的调试信息:

$ ls -l SimpleSection.o
-rw-r--r-- 1 zengjiwen zengjiwen 3832 Feb 13 14:56 SimpleSection.o

$ strip SimpleSection.o

$ ls -l SimpleSection.o
-rw-r--r-- 1 zengjiwen zengjiwen 952 Feb 13 15:00 SimpleSection.o

可见,去掉调试信息后,目标文件的大小从3832字节减少到了952字节。