同步文章使用

4. 静态链接

当我们有两个目标文件时,如何将它们链接起来形成一个可执行文件?这个过程中发生了什么?这个基本上就是链接的核心内容:静态链接。

GCC版本: 4.8.5 20150623

// a.c
extern int shared;

int main()
{
    int a = 100;
    swap(&a, &shared);
}
// b.c
int shared = 1;

void swap(int *a, int *b)
{
    *a ^= *b ^= *a ^= *b;
}
[root@ggy 03_demo]# gcc -c a.c b.c -m32

​ 经过编译之后,可以得到“a.o”,”b.o“两个目标文件。从代码里面可以看到”b.c“定义了两个全局符号,一个是变量”shared“,另一个是函数”swap“;”a.c“里面定义了一个全局符号就是"main"。模块"a.o"和b.o这两个目标文件链接在一起并最终形成一个和可执行文件ab。

[root@ggy 03_demo]# readelf -s a.o b.o 

File: a.o

Symbol table '.symtab' contains 11 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS a.c
     2: 00000000     0 SECTION LOCAL  DEFAULT    1 
     3: 00000000     0 SECTION LOCAL  DEFAULT    3 
     4: 00000000     0 SECTION LOCAL  DEFAULT    4 
     5: 00000000     0 SECTION LOCAL  DEFAULT    6 
     6: 00000000     0 SECTION LOCAL  DEFAULT    7 
     7: 00000000     0 SECTION LOCAL  DEFAULT    5 
     8: 00000000    39 FUNC    GLOBAL DEFAULT    1 main
     9: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND shared
    10: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND swap

File: b.o

Symbol table '.symtab' contains 10 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS b.c
     2: 00000000     0 SECTION LOCAL  DEFAULT    1 
     3: 00000000     0 SECTION LOCAL  DEFAULT    2 
     4: 00000000     0 SECTION LOCAL  DEFAULT    3 
     5: 00000000     0 SECTION LOCAL  DEFAULT    5 
     6: 00000000     0 SECTION LOCAL  DEFAULT    6 
     7: 00000000     0 SECTION LOCAL  DEFAULT    4 
     8: 00000000     4 OBJECT  GLOBAL DEFAULT    2 shared
     9: 00000000    58 FUNC    GLOBAL DEFAULT    1 swap

image-20230409140220235

image-20230409140230001

​ 下面将是a.o,b.o链接成可执行文件的详细过程。


4.1 空间与地址分配

​ 对于链接器来说,整个链接过程中,他就是将几个输入目标文件加工后合并成一个输出文件。

​ 在前面几个章节,我们知道,可执行文件中的代码段和数据段都是由输入的目标文件合并而来的,那么链接过程就很明显产生了第一个问题:

  • 对于多个输入目标文件,链接器如何将他们的各个段合并到输出文件?或者说,输出文件中的空间如何分配给输入文件?

4.1.1 按序叠加

image-20230409140409921

​ 直接将各个目标文件依次合并。但是在有很多输入文件的情况下,输出文件将会有很多零散的段。比如sonia会有数百个目标文件,如果每个目标文件都分别有.text段、.data段和.bss段,那最后的输出文件将会有成百上千个零散的段。

​ 这样的做法非常浪费空间,因为每个段都需要有一定的地址和空间对齐要求,比如对于X86的硬件来说,段的装载地址和空间的对其单位是页,也就是4096字节。即:如果一个段的长度只有1个字节,它也要在内存中占用4096字节。这样会造成内存空间大量的内部碎片,所以不是一个很好的方案。

怎么查看自己的系统内存页的大小?

[root@ggy 01_demo]# getconf PAGESIZE
4096

4.1.2 相似段合并

image-20230409141700753

即:将所有输入文件的相同性质的段合并到一起。

​ .bss段在目标文件和可执行文件中并不占用文件的空间,但是他在装载时占用地址空间。所以链接器在合并各个段的同时,也将“.bss”合并,并且分配虚拟空间????。

从“.bss”段的空间分配上,思考一个问题,那就是这里的所谓的“空间分配”到底是什么空间?

“链接器为目标文件分配地址和空间”这句话中的“地址和空间”其实有两个含义:

  1. 是在输出的可执行文件中的空间。
  2. 是在装载后的虚拟地址中的虚拟地址空间。

对于有实际数据的段,比如“.text”和".data"来说,他们在文件中和虚拟地址中都要分配空间,因为他们在这两者中都存在。对于“.bss”来说,分配空间的意义只局限于虚拟地址空间,因为它在文件中并没有内容。(保存在哪里?怎么查看???)

现在的链接器空间分配的策略基本上都采用“相似段合并”,使用这种方法的链接器一般都采用一种叫“两步链接(Two-pass Linking)”的方法。就是说整个链接过程分为两步。

第一步:空间地址分配

扫描所有的输入目标文件,并且获得他们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中的所有符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段长度,并且将他们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系

第二步:符号解析与冲定位

使用上面第一步中收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。事实上第二步是链接过程的核心,特别是重定位过程。

使用ld链接器将"a.o"和"b.o"链接起来:

编译命令

[root@ggy 03_demo]# gcc -c a.c b.c -m32
[root@ggy 03_demo]# ld a.o b.o -e main -o ab -melf_i386
  • -e main:表示将main函数作为程序的入口,ld链接其默认的程序入口为_start.
  • -o ab:表示链接输出文件名为ab,默认为a.out
  • -fno-stack-protector:禁用堆栈保护。

链接前后各个段的属性

[root@ggy 03_demo]# objdump -h a.o b.o 

a.o:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00000027  00000000  00000000  00000034  2**0
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000000  00000000  00000000  0000005b  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  00000000  00000000  0000005b  2**0
                  ALLOC
  3 .comment      0000002e  00000000  00000000  0000005b  2**0
                  CONTENTS, READONLY
  4 .note.GNU-stack 00000000  00000000  00000000  00000089  2**0
                  CONTENTS, READONLY
  5 .eh_frame     00000038  00000000  00000000  0000008c  2**2
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA

b.o:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         0000003a  00000000  00000000  00000034  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         00000004  00000000  00000000  00000070  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  00000000  00000000  00000074  2**0
                  ALLOC
  3 .comment      0000002e  00000000  00000000  00000074  2**0
                  CONTENTS, READONLY
  4 .note.GNU-stack 00000000  00000000  00000000  000000a2  2**0
                  CONTENTS, READONLY
  5 .eh_frame     0000003c  00000000  00000000  000000a4  2**2
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
[root@ggy 03_demo]# objdump -h ab

ab:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00000061  08048094  08048094  00000094  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .eh_frame     0000005c  080480f8  080480f8  000000f8  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .data         00000004  0804a000  0804a000  00001000  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  3 .comment      0000002d  00000000  00000000  00001004  2**0
                  CONTENTS, READONLY

VMA(Virtual Memory Address):虚拟地址.

LMA(Load Memory Addres):加载地址

正常情况下这两个值应该是一样的,但是在嵌入式系统中,特别是在那些存放在ROM(Read Only Memory, 掉电不丢失数据)的系统中,LMA和VMA是不相同的,所以这里只需要关注VMA即可.

链接前后的程序中所使用的地址已经是程序在进程中的虚拟地址,即上面各个段的VMA(Virtual Memory Address)和Size,而忽略文件偏移(File off)。 我们可以看到, 在链接之前,目标文件中的所有段的VMA都是0, 因为虚拟空间还没有被分配,所以他们默认都是0。等到链接之后,可执行文件"ab"中的各个段都被分配到了相应的虚拟地址。以程序ab为例, ".text"段被分配到了地址0x08048094,大小为0x61字节。 ".data"段从地址0x0804a000开始,大小为0x4字节,整个链接过程前后,目标文件各段的分匹、程序虚拟地址如图所示。

image-20230409143041092

从上图可以看到,“a.o”和“b.o”的代码段被先后叠加起来,合并成“ab”的一个.text段,加起来长度为0x61(0x27+0x3a=0x61),所以“ab”的代码段里面肯定包含了main函数和swap函数的指令代码。

查看ab中代码段包含的函数

[root@ggy 03_demo]# objdump -dx ab
...

Disassembly of section .text:

08048094 <main>:
 ...

080480bb <swap>:
...

在Linux下,ELF可执行文件默认从地址0x08048000开始分配。(待考究)???

4.1.3 符号地址的确定

在上面一步的扫描和空间分配阶段,链接器按照前面介绍的空间分配方法进行分配,这时候输入文件的各个段在链接后的虚拟地址就已经确定了,比如如“.text”段的起始地址为0x08048094,“.data”段的起始地址为0x0804a000。

当前面一步完成之后,链接器开始计算各个符号的虚拟地址。因为各个符号在段内的相对位置是固定的。所以这个时候,其实“main”、“shared”和”swap“的地址也已经是正确的了,只不过链接器需要给每个符号加上一个偏移量,是他们能够调整到正确的虚拟地址。

比如我们假设a.o中的main函数相对于a.o的.text段的偏移是X,但是链接后a.o的.text段位于虚拟地址0x08048094,那么“main”的地址应该是0x08068094+X。从前面objdump的输出看到,main位于a.o的.text段的最开始,也就是偏移量为0,所以main这个符号在最终的输出文件中的地址应该是0x08048094+0,即0x08048094。可以通过完全一样的计算方法得知所有符号的地址。???

[root@ggy 03_demo]# objdump -dx a.o
  0 .text         00000027  00000000  00000000  00000034  2**0
Disassembly of section .text:
00000000 <main>:

[root@ggy 03_demo]# objdump -dx b.o
  0 .text         0000003a  00000000  00000000  00000034  2**0
Disassembly of section .text:
00000000 <swap>:

[root@ggy 03_demo]# objdump -dx ab
08048094 l    d  .text  00000000 .text
0804a000 l    d  .data  00000000 .data
Disassembly of section .text:
08048094 <main>:
080480bb <swap>:

main = 08048094 + 0

swap = 0x08048094 + 0x27 = 0x80480BB

shared = 0804a000

4.2 符号解析与重定位

4.2.1 重定位

[root@ggy 03_demo]# objdump -d a.o

a.o:     file format elf32-i386


Disassembly of section .text:

00000000 <main>:
   0:   55                      push   %ebp
   1:   89 e5                   mov    %esp,%ebp
   3:   83 e4 f0                and    $0xfffffff0,%esp
   6:   83 ec 20                sub    $0x20,%esp
   9:   c7 44 24 1c 64 00 00    movl   $0x64,0x1c(%esp)
  10:   00 
  11:   c7 44 24 04 00 00 00    movl   $0x0,0x4(%esp)
  18:   00 
  19:   8d 44 24 1c             lea    0x1c(%esp),%eax
  1d:   89 04 24                mov    %eax,(%esp)
  20:   e8 fc ff ff ff          call   21 <main+0x21>
  25:   c9                      leave  
  26:   c3                      ret  
[root@ggy 03_demo]# objdump -d ab 

ab:     file format elf32-i386


Disassembly of section .text:

08048094 <main>:
 8048094:       55                      push   %ebp
 8048095:       89 e5                   mov    %esp,%ebp
 8048097:       83 e4 f0                and    $0xfffffff0,%esp
 804809a:       83 ec 20                sub    $0x20,%esp
 804809d:       c7 44 24 1c 64 00 00    movl   $0x64,0x1c(%esp)
 80480a4:       00 
 80480a5:       c7 44 24 04 00 a0 04    movl   $0x804a000,0x4(%esp)
 80480ac:       08 
 80480ad:       8d 44 24 1c             lea    0x1c(%esp),%eax
 80480b1:       89 04 24                mov    %eax,(%esp)
 80480b4:       e8 02 00 00 00          call   80480bb <swap>
 80480b9:       c9                      leave  
 80480ba:       c3                      ret    

080480bb <swap>:
 80480bb:       55                      push   %ebp
 80480bc:       89 e5                   mov    %esp,%ebp
 80480be:       53                      push   %ebx
 80480bf:       8b 45 08                mov    0x8(%ebp),%eax
 80480c2:       8b 10                   mov    (%eax),%edx
 80480c4:       8b 45 0c                mov    0xc(%ebp),%eax
 80480c7:       8b 08                   mov    (%eax),%ecx
 80480c9:       8b 45 08                mov    0x8(%ebp),%eax
 80480cc:       8b 18                   mov    (%eax),%ebx
 80480ce:       8b 45 0c                mov    0xc(%ebp),%eax
 80480d1:       8b 00                   mov    (%eax),%eax
 80480d3:       31 c3                   xor    %eax,%ebx
 80480d5:       8b 45 08                mov    0x8(%ebp),%eax
 80480d8:       89 18                   mov    %ebx,(%eax)
 80480da:       8b 45 08                mov    0x8(%ebp),%eax
 80480dd:       8b 00                   mov    (%eax),%eax
 80480df:       31 c1                   xor    %eax,%ecx
 80480e1:       8b 45 0c                mov    0xc(%ebp),%eax
 80480e4:       89 08                   mov    %ecx,(%eax)
 80480e6:       8b 45 0c                mov    0xc(%ebp),%eax
 80480e9:       8b 00                   mov    (%eax),%eax
 80480eb:       31 c2                   xor    %eax,%edx
 80480ed:       8b 45 08                mov    0x8(%ebp),%eax
 80480f0:       89 10                   mov    %edx,(%eax)
 80480f2:       5b                      pop    %ebx
 80480f3:       5d                      pop    %ebp
 80480f4:       c3                      ret  

计算swap函数的地址:

  • e8 02 00 00 00 call 80480bb <swap>calle8,后面02 00 00 00为调用偏移为x02的指令。
    • 0x80480b9+0x02=0x080480bb,函数swap的地址。

4.2.2 重定位表

链接器是怎么知道哪些指令是要调整的?

这些指令的哪部分要被调整?

怎么调整?

ELF文件中,有一个重定位表(Relocation Table)的结构专门用来保存这些与重定位相关的信息。他在ELF文件中往往是一个或多个段。

对于可重定位的ELF文件来说,它必须包含有重定位表,用来描述如何修改相应的段里的内容。对于每个要被重定位的ELF段都有一个对应的重定位表,而一个重定位表往往就是ELF文件中的一个段,所以其实重定位表也可以叫重定位段,我们在这里统一称作重定位表。如果代码段“.text”如果有被重定位的地方,那么会有一个相对应叫“.rel.text”的段保存了代码段的重定位表;以此类推。

[root@ggy 03_demo]# objdump -r a.o 

a.o:     file format elf32-i386

RELOCATION RECORDS FOR [.text]:
OFFSET   TYPE              VALUE 
00000015 R_386_32          shared
00000021 R_386_PC32        swap


RELOCATION RECORDS FOR [.eh_frame]:
OFFSET   TYPE              VALUE 
00000020 R_386_PC32        .text
  • 确定.eh_frame的作用。

每个要被冲定位的地方叫一个重定位入口(Relocation Entry),可以看到“a.o”有两个重定位入口。重定位入口的偏移(OFFSET)表示该入口在被重定位的段中的位置,“RELOCATION RECORDS FOR [.text]”表示这个重定位表是代码段的重定位表,所以偏移表示代码段中需要被调整的位置。对照夏敏的反汇编结果可知道,0x15和0x21分别就是代码段中mov指令和callq指令的地址部分。

[root@ggy 03_demo]# objdump -d a.o 
a.o:     file format elf32-i386
Disassembly of section .text:

00000000 <main>:
   0:   55                      push   %ebp
   1:   89 e5                   mov    %esp,%ebp
   3:   83 e4 f0                and    $0xfffffff0,%esp
   6:   83 ec 20                sub    $0x20,%esp
   9:   c7 44 24 1c 64 00 00    movl   $0x64,0x1c(%esp)
  10:   00 
  11:   c7 44 24 04 00 00 00    movl   $0x0,0x4(%esp)
  18:   00 
  19:   8d 44 24 1c             lea    0x1c(%esp),%eax
  1d:   89 04 24                mov    %eax,(%esp)
  20:   e8 fc ff ff ff          call   21 <main+0x21>
  25:   c9                      leave  
  26:   c3                      ret    

对于32位的Intel X86系列处理器来说,重定位表的结构也很简单,他是一个Elf32_Rel结构的数组,每个数组元素对应一个重定位入口。Elf32_Rel的定义如下:

image-20230409152114256

4.2.3 符号解析

通常认为,之所以要链接是因为我们目标文件中用到的符号被定义在其他的目标文件,所以要将他们链接起来。比如我们直接使用ld来链接a.o,而不将b.o作为输入。链接器就会发现shared和swap两个符号没有定义,没有办法完成链接工作:

[root@ggy 03_demo]# ld -e main a.o  -melf_i386
a.o: In function `main':
a.c:(.text+0x15): undefined reference to `shared'
a.c:(.text+0x21): undefined reference to `swap'

这是我们最常碰到的问题之一,就是链接时符号未定义。导致这个问题的原因有很多,最常见的一般都是链接时缺少了某个库,或者输入目标文件路径不正确或符号的声明与定义不一样。

通过指令重定位的介绍,可以更加深层次的理解为什么缺少符号的定义会导致链接错误。其实重定位过程也伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号。重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器需要对某个符号的引用进行重定向时,他就要确定这个符号的目标地址。这时候连接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。

[root@ggy 03_demo]# readelf -s a.o 

Symbol table '.symtab' contains 11 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS a.c
     2: 00000000     0 SECTION LOCAL  DEFAULT    1 
     3: 00000000     0 SECTION LOCAL  DEFAULT    3 
     4: 00000000     0 SECTION LOCAL  DEFAULT    4 
     5: 00000000     0 SECTION LOCAL  DEFAULT    6 
     6: 00000000     0 SECTION LOCAL  DEFAULT    7 
     7: 00000000     0 SECTION LOCAL  DEFAULT    5 
     8: 00000000    39 FUNC    GLOBAL DEFAULT    1 main
     9: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND shared
    10: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND swap
    
    
[root@ggy 03_demo]# objdump -r a.o 
a.o:     file format elf32-i386
RELOCATION RECORDS FOR [.text]:
OFFSET   TYPE              VALUE 
00000015 R_386_32          shared
00000021 R_386_PC32        swap


RELOCATION RECORDS FOR [.eh_frame]:
OFFSET   TYPE              VALUE 
00000020 R_386_PC32        .text

GLOBAL类型的符号,除了main函数是定义在代码段之外,shared和swap都是UND,即undefined未定义类型,这种未定义的符号都是因为该目标文件中有关于他们的重定位项。所以在连接器扫描完所有的输入目标文件之后,所有这些未定义的符号都应该能够在全局符号表中找到,否则链接器就会报未定义的错误。

4.2.4 指令修正方式

不同处理器指令对于地址的格式和方式都不一样。
x86系寻址方式有如下几方面区别:

  • 近址寻址或远址寻址
  • 绝对寻址或相对寻址
  • 寻址长度为8位、16位、32位、64位

但是在32位x86平台下的ELF文件的重定位入口所修正的指令寻址方式只有两种:

  • 绝对近地址32位寻址。
  • 相对近地址32位寻址。

这两种重定位方式指令修正方式每个被修正的位置的长度都为32位,即4个字节。而且都是近址寻址,不用考虑Intel的段间远址寻址。唯一的区别就是绝对寻址和相对寻址。

image-20230409153920983

A = 保存在被修正位置的值

P = 被修正的位置(相对于断开时的偏移量或者虚拟地址),该值可以通过r_offset计算得到

S = 符号的实际地址,即由r_info的高24位指定的符号的实际地址

绝对寻址修正

image-20230409203237832

相对寻址修正

image-20230409203950767

从这两个例子可以看出来,绝对寻址修正和相对寻址修正的区别就是绝对寻址修正后的地址位该符号的实际地址。

相对寻址修正后的地址为符号距离被修正位置的地址差。

4.3 COMMON 块

由于弱符号机制允许同一个符号的定义存在多个文件,所以如果一个弱符号定义再多个目标文件中,而他们的类型又不同,怎么办?

目前链接器本身并不支持符号的类型,即变量类型对于链接器是透明的,他只知道一个符号的名字,并不知道类型是否一致。那么当我们定义的多个符号定义类型不一致时,链接器如何处理:

  • 两个或两个以上强符号类型不一致。
  • 有一个强符号,其他都是弱符号,出现类型不一致;
  • 两个或两个以上弱符号类型不一致。

第一种情况:由于多个强符号定义本身就是非法的,链接器会报符号多重定义的错误,链接器要处理的下面两种情况。

现在的编译器和链接器都支持一种叫COMMON块(Common Block)的机制,这种机制最早来源于Fortran,早期Fortran没有动态分配空间的机制,程序员必须实现声名它所需要的临时使用空间大小。Forrtran把这种空间叫COMMON块,当不通的目标文件需要的COMMON块空间大小不一致的时候,以最大的那块为准。

现代的链接机制在处理弱符号的时候,采用的就是与COMMON块一样的机制。编译器将未初始化的全局变量定义作为弱符号处理。例如下面的g_uninit,他在符号表中的类型为COM

int g_uninit;
int g_init = 0;
int main()
{
	return 0;
}
[root@ggy 02_demo]# readelf -s main.o 

Symbol table '.symtab' contains 11 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS main.c
     2: 00000000     0 SECTION LOCAL  DEFAULT    1 
     3: 00000000     0 SECTION LOCAL  DEFAULT    2 
     4: 00000000     0 SECTION LOCAL  DEFAULT    3 
     5: 00000000     0 SECTION LOCAL  DEFAULT    5 
     6: 00000000     0 SECTION LOCAL  DEFAULT    6 
     7: 00000000     0 SECTION LOCAL  DEFAULT    4 
     8: 00000004     4 OBJECT  GLOBAL DEFAULT  COM g_uninit
     9: 00000000     4 OBJECT  GLOBAL DEFAULT    3 g_init
    10: 00000000    10 FUNC    GLOBAL DEFAULT    1 main

由上述可以看到,一个未初始化的全局变量,它的类型为COM类型,这是一个典型的弱符号。如果我们在另一个文件中也定义了g_uninit变量,且未初始化,他的类型为double,占8个字节,情况会怎么样呢?按照COMMON类型的链接规则,原则上讲最终链接后输出文件中,g_uninit的大小以输入文件中最大的那个为准,即这两个文件连接后输出文件中的g_uninit所占用的空间为8个字节。(SIZE是宏定义,打印出来字节的大小为4、8)。

当然COMMON类型的链接规则是针对符号都是弱符号的情况,如果其中有一个符号为强符号,那么最终输出结果中的符号所占空间与强符号相同。值得注意的是,如果链接过程中有符号大小大于强符号,那么ld链接器会报如下警告:

// a.c
int g_uninit;
int g_init = 0;
int main()
{
	return 0;
}
// b.c
double g_init;
[root@ggy 02_demo]# gcc main.c tmp.c 
/usr/bin/ld: Warning: alignment 4 of symbol `g_init' in /tmp/ccfc1vUU.o is smaller than 8 in /tmp/ccakSyG6.o

这种使用COMMON块的方法实际上是一种类似“黑客”的取巧办法,直接导致需要COMMON机制的原因是编译器和链接器允许不通类型的弱符号存在,但是本质上的原因还是链接器不支持符号类型,即链接器无法判断各个符号的类型是否一致。

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

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

关于多个文件中出现同一个变量的多个定义的原因,还有一种说法是由于早期C语言程序员粗心大意,经常忘记在声明变量时在前面加上“extern”关键字,使得编译器会多个目标文件中产生同一个变量的定义。为了解决这个问题,编译器和链接器干脆就把未初始化的变量都当作COMMON类型的初始。

GCC的“-fno-common”也允许我们把所有未初始化的全局变量不以COMMON块的形式处理,或者使用__attribute__扩展。

int global __attribute__((nocommon));

一旦一个未初始化的全局变量不是以COMOON块的形式存在,那么他就相当于一个强符号,如果其他目标文件中还有同一个变量的强符号定义,链接时就会发生符号重定义的错误。

int g_uninit;
int main()
{
	return 0;
}
[root@ggy 02_demo]# gcc -c main.c -fno-common main.c 
[root@ggy 02_demo]# readelf -s main.o

Symbol table '.symtab' contains 10 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS main.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    2 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     8: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 g_uninit
     9: 0000000000000000    11 FUNC    GLOBAL DEFAULT    1 main

int g_uninit __attribute__((nocommon));
int main()
{
	return 0;
}
[root@ggy 02_demo]# gcc -c main.c -fno-common main.c 
[root@ggy 02_demo]# readelf -s main.o

Symbol table '.symtab' contains 10 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS main.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    2 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     8: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 g_uninit
     9: 0000000000000000    11 FUNC    GLOBAL DEFAULT    1 main

  • 其他目标文件中还有同一个变量的强符号定义
// main.c
int g_uninit __attribute__((nocommon));
int main()
{
	return 0;
}
// tmp.c
int g_uninit = 0;
[root@ggy 02_demo]# gcc main.c tmp.c
/tmp/ccV3wnqk.o:(.bss+0x0): multiple definition of `g_uninit'
/tmp/ccDOFbNc.o:(.bss+0x0): first defined here
collect2: error: ld returned 1 exit status

4.4 C++ 相关问题

C++的一些语言特性使之必须由编译器和链接器共同支持才能完成工作,最主要的有连个方面,一个是C++的重复代码消除,还有一个就是全局构造和析构。另外由于C++语言的各种特性,比如虚函数、函数重载、继承、异常等,使得它背后的数据结构一张复杂,这些数据结构往往在不同的编译器和链接器之间相互不能通用,使得C++程序的二进制兼容性成了一个很大的问题。

4.4.1 重复代码消除

C++编译器在很多时候会产生重复的代码,比如模块、外部内联函数、虚函数表都有可能在不同的编译单元里面生成相同的代码。最简单的情况就拿模板来说,模板从本质上来讲很像宏,当模板在一个编译单元里面被实例化,他并不知道自己是是否在别的编译单元也被实例化了。所以当一个模板在多个编译单元同时实例化成相同类型的时候,必然会产生重复的代码。当然最简单的就是将所有重复代码都保存下来,不过这样做有几个问题。

  • 空间浪费
  • 地址较易出错。有可能两个指向同一个函数的指针会不相等。
  • 指令运行效率较低。现代的CPU都会对指令和数据进行缓存,如果同样一份指令有多份副本,那么指令Cache的命中率就会降低。

一个有效的做法就是将每个模板的实例代码都单独地存放在一个段里,每个段只包含一个模板实例。比如有个模板函数是add(),某个编译单元以int类型和float类型实例化了该模板函数,那么该编译单元的目标文件中就包含了两个该模板视力的段。假设这两个段的名字分别叫.tmp.add和.tmp.add。这样子,当别的编译单元也以int或float类型实例化该模板函数后,也会生成相同的名字 ,这样链接器在最终链接的时候可以区分这些相同的模板实例段,然后将他们合入到最后的代码段。

还要补充

函数级别链接

现在的程序和库通常来讲都非常庞大,一个目标文件可能包含成千上百个函数或变量。当我们需要用到某个目标文件中的任意一个函数或变量时,就需要把它整个地链接进来,也就是说那些没有用来的函数也被一起链接进来。这样的后果是链接输出文件会变得很大,所有用到的没用到的变量和函数都一起塞到了输出文件中。

GCC编译器提供了一个编译选项叫函数级别链接,这个选项的作用就是让所有函数都像前面模板函数一样,单独保存到一个段里面。当链接器需要用到某个函数时,就将它合并到输出文件中,对于那些没有用到的函数则将他们抛弃。这种做法很大程度上减小输出文件的长度,减少空间浪费。但是这个优化选项会减慢编译和链接的过程,因为连接器需要计算各个函数之间的依赖关系,并且所有函数都保持到独立的段中,目标函数的段的数量大大郑家,重定位过程也会因为段的数目的增加而变得复杂,目标文件随着段数目的增加也会变得相对比较大。

编译参数:--ffuntion-sections-fdata-sections

这两个选项的作用就是将每个函数或变量分别保持到独立的段中。

[root@ggy src]# readelf -s alias.o 

Symbol table '.symtab' contains 71 entries:
...
    45: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND fprintf
    46: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND log_error_mesg_fatal
    47: 0000000000000000  3674 FUNC    GLOBAL DEFAULT   10 translate_uri
    48: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND send_r_bad_request
    49: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND memcmp
...
    69: 0000000000000000   105 FUNC    GLOBAL DEFAULT   12 dump_alias
    70: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND free
[root@ggy src]# readelf -S alias.o 
There are 40 section headers, starting at offset 0xf3e0:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
 ...
  [10] .text.translate_u PROGBITS         0000000000000000  00000760
       0000000000000e5a  0000000000000000  AX       0     0     16
  [11] .rela.text.transl RELA             0000000000000000  00007588
       0000000000001290  0000000000000018   I      37    10     8
  [12] .text.dump_alias  PROGBITS         0000000000000000  000015c0
       0000000000000069  0000000000000000  AX       0     0     16
  [13] .rela.text.dump_a RELA             0000000000000000  00008818
       0000000000000078  0000000000000018   I      37    12     8
  ...
[root@ggy boa-master]# ls -l exec/boa 
-rwxr-xr-x 1 root root 326984 Apr  9 20:46 exec/boa#没有加入--ffuntion-sections和-fdata-sections
[root@ggy boa-master]# ls -l src/boa
-rwxr-xr-x 1 root root 333976 Apr  9 20:50 src/boa#加入--ffuntion-sections和-fdata-sections

[root@ggy 05_demo]# gcc -c main.c  -o main_not_sections.o
[root@ggy 05_demo]# gcc -c main.c -ffunction-sections -fdata-sections -o main_sections.o
[root@ggy 05_demo]# readelf -S main_not_sections.o 
There are 11 section headers, starting at offset 0x308:

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
       0000000000000017  0000000000000000  AX       0     0     1
  [ 2] .data             PROGBITS         0000000000000000  00000058
       0000000000000008  0000000000000000  WA       0     0     4
  [ 3] .bss              NOBITS           0000000000000000  00000060
       0000000000000000  0000000000000000  WA       0     0     1
  [ 4] .comment          PROGBITS         0000000000000000  00000060
       000000000000002e  0000000000000001  MS       0     0     1
  [ 5] .note.GNU-stack   PROGBITS         0000000000000000  0000008e
       0000000000000000  0000000000000000           0     0     1
  [ 6] .eh_frame         PROGBITS         0000000000000000  00000090
       0000000000000078  0000000000000000   A       0     0     8
  [ 7] .rela.eh_frame    RELA             0000000000000000  00000268
       0000000000000048  0000000000000018   I       8     6     8
  [ 8] .symtab           SYMTAB           0000000000000000  00000108
       0000000000000138  0000000000000018           9     8     8
  [ 9] .strtab           STRTAB           0000000000000000  00000240
       0000000000000025  0000000000000000           0     0     1
  [10] .shstrtab         STRTAB           0000000000000000  000002b0
       0000000000000054  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)
[root@ggy 05_demo]# readelf -S main_sections.o 
There are 16 section headers, starting at offset 0x3d0:

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
       0000000000000000  0000000000000000  AX       0     0     1
  [ 2] .data             PROGBITS         0000000000000000  00000040
       0000000000000000  0000000000000000  WA       0     0     1
  [ 3] .bss              NOBITS           0000000000000000  00000040
       0000000000000000  0000000000000000  WA       0     0     1
  [ 4] .data.g_not_use_1 PROGBITS         0000000000000000  00000040
       0000000000000004  0000000000000000  WA       0     0     4
  [ 5] .data.g_not_use_2 PROGBITS         0000000000000000  00000044
       0000000000000004  0000000000000000  WA       0     0     4
  [ 6] .text.main        PROGBITS         0000000000000000  00000048
       000000000000000b  0000000000000000  AX       0     0     1
  [ 7] .text.not_use_1   PROGBITS         0000000000000000  00000053
       0000000000000006  0000000000000000  AX       0     0     1
  [ 8] .text.not_use_2   PROGBITS         0000000000000000  00000059
       0000000000000006  0000000000000000  AX       0     0     1
  [ 9] .comment          PROGBITS         0000000000000000  0000005f
       000000000000002e  0000000000000001  MS       0     0     1
  [10] .note.GNU-stack   PROGBITS         0000000000000000  0000008d
       0000000000000000  0000000000000000           0     0     1
  [11] .eh_frame         PROGBITS         0000000000000000  00000090
       0000000000000078  0000000000000000   A       0     0     8
  [12] .rela.eh_frame    RELA             0000000000000000  000002e0
       0000000000000048  0000000000000018   I      13    11     8
  [13] .symtab           SYMTAB           0000000000000000  00000108
       00000000000001b0  0000000000000018          14    13     8
  [14] .strtab           STRTAB           0000000000000000  000002b8
       0000000000000025  0000000000000000           0     0     1
  [15] .shstrtab         STRTAB           0000000000000000  00000328
       00000000000000a3  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)

4.4.2 全局构造与析构

在main函数调用之前,为了程序能够顺利执行,要先初始化进程执行环境,比如堆分配初始化(malloc、free)、线程子系统等。C++全局对象构造函数也是在这一时期被执行的。C++的全局对象的构造函数在main之前被执行,C++全局对象的析构函数在main之后被执行。

Linux下一般程序的入口是“_start”,这个函数是Glibc的一部分。当我们的程序与Glibc链接在一起形成最终可执行文件以后,这个函数就是程序的初始化部分的入口,程序初始化部分完成一系列初始化过程之后,会调用main函数来执行程序的主题。main函数执行完,返回到初始化部分,会进行清理工作,然后结束进程。

.init 该段里面保存的是可执行指令,它构成了进程的初始化代码。因此,当一个程序开始运行时,在main函数被调用之前,Glibc的初始化部分安排执行这个段的中的代码。

.fini该段保存着进程终止代码指令。因此,当一个程序的main函数正常退出时,Glibc会安排执行这个段中的代码。

4.4.3 C++与ABI

如果使两个编译器编译出来的目标文件能够互相链接,那么这两个目标文件必须满足下面这些条件:

  1. 采用同样的目标文件格式
  2. 拥有同样的符号修饰标准
  3. 变量的内存分布方式相同
  4. 函数的调用方式相同

我们把符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为ABI(Application Binary Interface)。

API:源码级别的接口

ABI:二进制层面的接口

影响ABI的因素非常多,硬件、编程语言、编译器、连接器、操作系统、等都会影响ABI。

对于C语言的目标来说,以下几方面会决定目标文件之间是否二进制兼容:

image-20230409181331353

4.5 静态库链接

其实一个静态库可以简单地看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。比如Linux中常用的C语言静态库libc位于/usr/lib/libc.a,它属于glibc项目的一部分;

我们知道一个C语言的运行库中,包含了很多跟系统功能相关的代码,比如输入输出、文件操作、时间日期、内存管理等。glibc本身是C语言开发的,它由成百上千个C语言源代码文件组成,也就是说,编译完成以后有相同数量的目标文件,比如输出输出有printf.o,scanf.o;文件操作有fread.o,fwrite.o,时间日期有data.o,time.o,内存管理有malloc.o等。把这些零散的目标文件直接提供给库的使用者,很大程度上会造成文件传输、管理和组织方面的不便,于是通常人们用“ar”压缩程序将这些目标文件压缩到一起,并且对其进行编号和索引,便于查找和检索,就形成了libc.a这个静态库文件。可以使用“ar”工具来查看这个文件包含了哪些目标文件:

[332640@yanfa218_centos6-jk128:weops page_4]$ ar -t /usr/lib/libc.a 
...
wcrtomb_chk.o
mbsnrtowcs_chk.o
...

libc.a里面包含了1511个目标我呢见,那么,我们如何在这么多的目标文件中找到”printf“函数所在的目标文件呢?答案是使用”objdump“或”radelf“加上文本查找工具”grep“,使用”objdump”查看libc.a的符号可以发现如下结果:

[332640@yanfa218_centos6-jk128:weops page_4]$ objdump -t /usr/lib/libc.a | grep -B 16 " printf$"
00000000  w    F .text  00000021 _IO_fprintf



printf.o:     file format elf32-i386

SYMBOL TABLE:
00000000 l    d  .text  00000000 .text
00000000 l    d  .data  00000000 .data
00000000 l    d  .bss   00000000 .bss
00000000 l    d  .comment       00000000 .comment
00000000 l    d  .note.GNU-stack        00000000 .note.GNU-stack
00000000 l    d  .eh_frame      00000000 .eh_frame
00000000 g     F .text  00000023 __printf
00000000         *UND*  00000000 stdout
00000000         *UND*  00000000 vfprintf
00000000 g     F .text  00000023 printf

可以看到“printf”函数被定义在了“printf.o”这个目标文件中。这里我们似乎找到了最终的机制,那就是“Hello world”程序编译出来的目标文件只要和libc.a里面的“printf.o”链接在一起,最后就可以形成一个和可用的可执行文件了。

使用如下方法编译:

[332640@yanfa218_centos6-jk128:weops page_4]$ gcc -c -fno-builtin 01_tmp.c 

"-fno-builtin":默认情况下,GCC会将只使用了一个字符串参数的”printf“替换成puts函数,以提高运行速度。该选项则是关闭内置函数优化选项。

使用命令”ar“将”printf.o“解压出来,该命令会将libc.a中的所有目标文件“解压”至当前目录。我们可以找到“printf.o”,然后将其与“printf.o”链接一起:

[332640@yanfa218_centos6-jk128:weops page_4]$ ar -x libc.a 

和书上不一致

[332640@yanfa218_centos6-jk128:weops page_4]$ ld 01_tmp.o printf.o -e main
printf.o: could not read symbols: File in wrong format

“--verbose”可以将整个编译链接过程的中间步骤打印出来。

[332640@yanfa218_centos6-jk128:weops page_4]$ gcc -static --verbose -fno-builtin 01_tmp.c 
Using built-in specs.
Target: x86_64-redhat-linux
Configured with: ../configure --prefix=/usr --mandir=/usr/share/man --infodir=/usr/share/info --with-bugurl=http://bugzilla.redhat.com/bugzilla --enable-bootstrap --enable-shared --enable-threads=posix --enable-checking=release --with-system-zlib --enable-__cxa_atexit --disable-libunwind-exceptions --enable-gnu-unique-object --enable-languages=c,c++,objc,obj-c++,java,fortran,ada --enable-java-awt=gtk --disable-dssi --with-java-home=/usr/lib/jvm/java-1.5.0-gcj-1.5.0.0/jre --enable-libgcj-multifile --enable-java-maintainer-mode --with-ecj-jar=/usr/share/java/eclipse-ecj.jar --disable-libjava-multilib --with-ppl --with-cloog --with-tune=generic --with-arch_32=i686 --build=x86_64-redhat-linux
Thread model: posix
gcc version 4.4.7 20120313 (Red Hat 4.4.7-23) (GCC)
COLLECT_GCC_OPTIONS='-static' '-v' '-fno-builtin' '-mtune=generic'



 /usr/libexec/gcc/x86_64-redhat-linux/4.4.7/cc1 -quiet -v 01_tmp.c -quiet -dumpbase 01_tmp.c -mtune=generic -auxbase 01_tmp -version -fno-builtin -o /tmp/ccSZ8M0N.s
ignoring nonexistent directory "/usr/lib/gcc/x86_64-redhat-linux/4.4.7/include-fixed"
ignoring nonexistent directory "/usr/lib/gcc/x86_64-redhat-linux/4.4.7/../../../../x86_64-redhat-linux/include"
#include "..." search starts here:
#include <...> search starts here:
 /usr/local/include
 /usr/lib/gcc/x86_64-redhat-linux/4.4.7/include
 /usr/include
End of search list.
GNU C (GCC) version 4.4.7 20120313 (Red Hat 4.4.7-23) (x86_64-redhat-linux)
        compiled by GNU C version 4.4.7 20120313 (Red Hat 4.4.7-23), GMP version 4.3.1, MPFR version 2.4.1.
GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072
Compiler executable checksum: f1e04b41791fd0c9eea88d5989031e7d
COLLECT_GCC_OPTIONS='-static' '-v' '-fno-builtin' '-mtune=generic'



 as -V -Qy -o /tmp/ccq9Tpw0.o /tmp/ccSZ8M0N.s
GNU assembler version 2.20.51.0.2 (x86_64-redhat-linux) using BFD version version 2.20.51.0.2-5.48.el6 20100205
COMPILER_PATH=/usr/libexec/gcc/x86_64-redhat-linux/4.4.7/:/usr/libexec/gcc/x86_64-redhat-linux/4.4.7/:/usr/libexec/gcc/x86_64-redhat-linux/:/usr/lib/gcc/x86_64-redhat-linux/4.4.7/:/usr/lib/gcc/x86_64-redhat-linux/:/usr/libexec/gcc/x86_64-redhat-linux/4.4.7/:/usr/libexec/gcc/x86_64-redhat-linux/:/usr/lib/gcc/x86_64-redhat-linux/4.4.7/:/usr/lib/gcc/x86_64-redhat-linux/
LIBRARY_PATH=/usr/lib/gcc/x86_64-redhat-linux/4.4.7/:/usr/lib/gcc/x86_64-redhat-linux/4.4.7/:/usr/lib/gcc/x86_64-redhat-linux/4.4.7/../../../../lib64/:/lib/../lib64/:/usr/lib/../lib64/:/usr/lib/gcc/x86_64-redhat-linux/4.4.7/../../../:/lib/:/usr/lib/
COLLECT_GCC_OPTIONS='-static' '-v' '-fno-builtin' '-mtune=generic'


 /usr/libexec/gcc/x86_64-redhat-linux/4.4.7/collect2 --build-id -m elf_x86_64 --hash-style=gnu -static /usr/lib/gcc/x86_64-redhat-linux/4.4.7/../../../../lib64/crt1.o /usr/lib/gcc/x86_64-redhat-linux/4.4.7/../../../../lib64/crti.o /usr/lib/gcc/x86_64-redhat-linux/4.4.7/crtbeginT.o -L/usr/lib/gcc/x86_64-redhat-linux/4.4.7 -L/usr/lib/gcc/x86_64-redhat-linux/4.4.7 -L/usr/lib/gcc/x86_64-redhat-linux/4.4.7/../../../../lib64 -L/lib/../lib64 -L/usr/lib/../lib64 -L/usr/lib/gcc/x86_64-redhat-linux/4.4.7/../../.. /tmp/ccq9Tpw0.o --start-group -lgcc -lgcc_eh -lc --end-group /usr/lib/gcc/x86_64-redhat-linux/4.4.7/crtend.o /usr/lib/gcc/x86_64-redhat-linux/4.4.7/../../../../lib64/crtn.o

第一步调用cll,实际上就是GCC的C语言编译器,将输入文件编译成汇编文件

第二部调用as,将汇编文件会变成临时目标文件。

第三步调用collect2,完成最后的链接。

实际上collect2可以看作是ld链接器的一个包装,它会调用ld链接器来完成对目标文件的链接,然后再对链接结果进行一些处理,主要是收集所有与程序初始化相关的信息并且构造初始化的结构。

为什么静态运行库里面一个目标文件只包含一个函数?比如printf.o里面只有printf

连接器在链接静态库的时候是以目标文件为单位的。如果很多函数放到一个目标文件中,很多没用的函数都被链接进了输出结果中。会造成空间的浪费。

4.6 链接过程控制

链接器一般都提供多种控制整个链接过程的方法,以用来产生用户所需要的文件。一般链接器有如下三种方法。

  • 使用命令行来给链接器指定参数,我们前面所使用的ld的-o,-e参数就属于这类。
  • 将链接指令存放在目标文件里面,编译器通常会通过这种方法向链接器传递指令。方法也比较常见。比如VISUAL C++编译器会把链接参数放在PE目标文件的.drectve段用来传递参数。
  • 使用链接控制脚本。

由于各个链接器平台的链接控制过程各不相同。ld链接器的链接脚本功能非常强大。VISUAL C++也允许使用脚本来控制整个链接过程,VISUAL C++把这种控制脚本叫做模块定义文件(Module-Definition File),它们的扩展名一般为.def。

ld在用户没有指定链接脚本的时候会使用默认链接脚本。我们可以使用下面的命令来查看ld默认的链接脚本

[root@ggy 02_demo]# ld -verboase
GNU ld version 2.27-44.base.el7

默认的ld链接脚本存放在/usr/lib/ldscripts/下,不同的机器平台、输出文件格式都有相应的链接脚本,

当然,为了更加精确地控制链接过程,我们可以自己写一个脚本,然后指定该脚本为链接控制脚本,比如可以使用-T参数

$ ld -T link.script

4.6.2 最小的程序

为了演示链接的控制过程,我们接着要做一个最小的程序:这个程序的功能是在终端上输出”Hello world!”。这个”小程序”能够脱离C语言运行库,使用nomain作为整个程序的入口,将”小程序”的所有段都合并到一个叫”tinytext”的段,这个段是我们任意命名的,是由链接脚本控制链接过程生成的。

TinyHelloWorld.c源码如下:

char *str = "hello world!\n";

void printf()
{
    asm("movl $13, %%edx \n\t"
        "movl %0, %%ecx \n\t"
        "movl $0, %%ebx \n\t"
        "movl $4, %%eax \n\t"
        "int $0x80\n\t"
        ::"r"(str):"edx", "ecx", "ebx");
}
void exit()
{
    asm("movl $42, %ebx \n\t"
        "movl $1, %eax \n\t"
        "int $0x80\n\t");
}

void nomain()
{
    printf();
    exit();
}

从源代码可以看到,程序入口为nomain()函数,然后该函数调用print()函数,打印”Hello World”,接着调用exit()函数,结束进程。这里的print函数使用了Linux的WRITE系统调用,exit()函数使用了EXIT系统调用。这里我们使用了GCC内嵌汇编。

gcc -c -fno-builtin TinyHelloWorld.c -m32
ld -static -e nomain -o TinyHelloWorld TinyHelloWorld.o -m elf_i386
./TinyHelloWorld
hello world!


[332640@yanfa218_centos6-jk128:weops 02]$ ls -lh
-rwxr-xr-x 1 332640 domain_users 1.1K 2023-04-08 16:17 TinyHelloWorld


[332640@yanfa218_centos6-jk128:weops 02]$ objdump -h TinyHelloWorld

TinyHelloWorld:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .rel.plt      00000000  08048094  08048094  00000094  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  1 .plt          00000000  08048094  08048094  00000094  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  2 .text         0000003f  08048094  08048094  00000094  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  3 .rodata       0000000e  080480d3  080480d3  000000d3  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .got.plt      00000000  080490e4  080490e4  000000e4  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  5 .data         00000004  080490e4  080490e4  000000e4  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  6 .comment      0000002d  00000000  00000000  000000e8  2**0
                  CONTENTS, READONLY

GCC和ld的参数意义如下:

(1). -fno-builtin:GCC编译器提供了很多内置函数(Built-in Function),它会把一些常用的C库函数替换成编译器的内置函数,以达到优化的功能。比如GCC会将只有字符串参数的printf函数替换成puts,以节省格式解析的时间。exit()函数也是GCC的内置参数之一,所以要使用-fno-builtin参数来关闭GCC内置函数功能。

(2). -static:这个参数表示ld将使用静态链接的方式来链接程序,而不是使用默认的动态链接的方式。

(3). -e nomain:表示该程序的入口函数为nomain,这个参数就是将ELF文件头的e_entry成员赋值成nomain函数的地址。

(4). -o TinyHelloWorld:表示指定输出可执行文件名为TinyHelloWorld。

-m elf_i386

4.6.3 使用ld链接脚

如果把整个链接过程比作一台计算机,那么ld链接器就是计算机的CPU,所有的目标文件、库文件就是输入,链接结果输出的可执行文件就是输出,而链接控制脚本正是这台计算机的”程序”,它控制CPU的运行,以”程序”要求的方式将输入加工成所需要的输出结果。链接控制脚本”程序”使用一种特殊的语言写成,即ld的链接脚本语言。

无论是输出文件还是输入文件,它们的主要的数据就是文件中的各种段,我们把输入文件中的段称为输入段(Input Sections),输出文件中的段称为输出段(Output Sections)。简单来讲,控制链接过程无非是控制输入段如何变成输出段,比如哪些输入段合并一个输出段,哪些输入段要丢弃;指定输出段的命名、装载地址、属性,等等。TinyHelloWorld的链接脚本TinyHelloWorld.lds(一般链接脚本名都以lds作为扩展名ld script)的内容如下:

ENTRY(nomain)
SECTIONS
{
	. = 0x08048000 + SIZEOF_HEADRES;
	tinytext : {*(.text) *(.data) *(.rodata)}
	/DISCARD/ : {*(comment)}
}

这是一个非常简单的链接脚本,第一行的ENTRY(nomain)指定了程序的入口为nomain()函数;后面的SECTIONS命令一般是链接脚本的主体,这个命令指定了各种输入段到输出段的变换,SECTIONS后面紧跟着的一对大括号里面包含了SECTIONS变换规则,其中有三条语句,每条语句一行。第一条是赋值语句,后面两条是段转换规则,它们的含义分别如下:

  • . = 0x08048000 + SIZEOF_HEADERS:第一条赋值语句的意思是将当前虚拟地址设置成0x08048000 + SIZEOF_HEADERS,SIZEOF_HEADERS为输出文件的文件头大小。”.”表示当前虚拟地址,因为这条语句后面紧跟着输出段”tinytext”,所以”tinytext”段的起始虚拟地址即为0x08048000 + SIZEOF_HEADERS。它将当前虚拟地址设置成一个比较巧妙的值,以便于装载时页映射更为方便。

  • tinytext: { *(.text) *(.data) *(.rodata) }:第二条是个段转换规则,它的意思即为所有输入文件中的名字为”.text”, “.data”或”.rodata”的段依次合并到输出文件的”tinytext”。

  • /DISCARD/ : { *(.comment) }:第三条规则为:将所有输入文件中的名字为”.comment”的段丢弃,不保存到输出文件中。

通过上述两条转换规则,我们就达到了TinyHelloWorld程序的第三个要求:最终输出的可执行文件只有一个叫做”tinytext”的段。通过以下命令编译并且启用该链接控制脚本。

[332640@yanfa218_centos6-jk128:weops 02]$ gcc -c -fno-builtin TinyHelloWorld.c -m32
[332640@yanfa218_centos6-jk128:weops 02]$ ld -static -e nomain -o TinyHelloWorld TinyHelloWorld.o -m elf_i386 -T TinyHelloWorld.lds
[332640@yanfa218_centos6-jk128:weops 02]$ ./TinyHelloWorld
hello world!
[332640@yanfa218_centos6-jk128:weops 02]$ ls -l
-rwxr-xr-x 1 332640 domain_users  893 2023-04-08 16:38 TinyHelloWorld
[332640@yanfa218_centos6-jk128:weops 02]$ objdump -h TinyHelloWorld

TinyHelloWorld:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 tinytext      00000052  08048074  08048074  00000074  2**2
                  CONTENTS, ALLOC, LOAD, CODE
  1 .iplt         00000000  080480c8  080480c8  000000c8  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  2 .igot.plt     00000000  080480c8  080480c8  000000c8  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  3 .rel.dyn      00000000  080480c8  080480c8  000000c8  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .comment      0000002d  00000000  00000000  000000c8  2**0
                  CONTENTS, READONLY
[332640@yanfa218_centos6-jk128:weops 02]$ readelf -S TinyHelloWorld
There are 9 section headers, starting at offset 0x13c:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] tinytext          PROGBITS        08048074 000074 000052 00 WAX  0   0  4
  [ 2] .iplt             PROGBITS        080480c8 0000c8 000000 00  AX  0   0  4
  [ 3] .igot.plt         PROGBITS        080480c8 0000c8 000000 00  WA  0   0  4
  [ 4] .rel.dyn          REL             080480c8 0000c8 000000 08   A  0   0  4
  [ 5] .comment          PROGBITS        00000000 0000c8 00002d 01  MS  0   0  1
  [ 6] .shstrtab         STRTAB          00000000 0000f5 000046 00      0   0  1
  [ 7] .symtab           SYMTAB          00000000 0002a4 0000b0 10      8   7  4
  [ 8] .strtab           STRTAB          00000000 000354 000029 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)
  
[332640@yanfa218_centos6-jk128:weops 02]$ strip TinyHelloWorld
[332640@yanfa218_centos6-jk128:weops 02]$ readelf -S TinyHelloWorld
There are 7 section headers, starting at offset 0x12c:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] tinytext          PROGBITS        08048074 000074 000052 00 WAX  0   0  4
  [ 2] .iplt             PROGBITS        080480c8 0000c8 000000 00  AX  0   0  4
  [ 3] .igot.plt         PROGBITS        080480c8 0000c8 000000 00  WA  0   0  4
  [ 4] .rel.dyn          REL             080480c8 0000c8 000000 08   A  0   0  4
  [ 5] .comment          PROGBITS        00000000 0000c8 00002d 01  MS  0   0  1
  [ 6] .shstrtab         STRTAB          00000000 0000f5 000036 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)
[332640@yanfa218_centos6-jk128:weops 02]$
[332640@yanfa218_centos6-jk128:weops 02]$ ld -static -e nomain -o TinyHelloWorld TinyHelloWorld.o -m elf_i386 -T TinyHelloWorld.lds -s
[332640@yanfa218_centos6-jk128:weops 02]$ readelf -S TinyHelloWorld
There are 7 section headers, starting at offset 0x12c:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] tinytext          PROGBITS        08048074 000074 000052 00 WAX  0   0  4
  [ 2] .iplt             PROGBITS        080480c8 0000c8 000000 00  AX  0   0  4
  [ 3] .igot.plt         PROGBITS        080480c8 0000c8 000000 00  WA  0   0  4
  [ 4] .rel.dyn          REL             080480c8 0000c8 000000 08   A  0   0  4
  [ 5] .comment          PROGBITS        00000000 0000c8 00002d 01  MS  0   0  1
  [ 6] .shstrtab         STRTAB          00000000 0000f5 000036 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

执行这个程序能够在终端上正确显示”Hello world!”。如果使用objdump查看TinyHelloWorld的段,我们达到了目的,有一个段”tinytext”。你可以通过ld的-s参数禁止链接器产生符号表,或者使用strip命令去除程序中的符号表

4.6.4 ld链接脚本语法简介

ld链接器的链接脚本语法继承与AT&T链接器命令语言的语法,风格有点像C语言。链接脚本由一系列语句组成,语句分两种,一种是命令语句,另外一种是赋值语句。如ENTRY(nomain)就是命令语句,而. = 0x08048000 + SIZEOF_HEADERS则是一个赋值语句。之所以说链接脚本语法像C语言,主要有如下几点相似之处:

(1). 语句之间使用分号”;”作为分隔符:原则上讲语句之间都要以”;”作为分隔符,但是对于命令语句来说也可以使用换行来结束该语句,对于赋值语句来说必须以”;”结束。

(2). 表达式与运算符:脚本语言的语句中可以使用C语言类似的表达式和运算操作符,比如+, -, *, /, +=, -=, *=等,甚至包括&, |, >>, <<等这些位操作符。

(3). 注释和字符引用:使用/* */作为注释。脚本文件中使用到的文件名、格式名或段名等凡是包含”;”或其它的分隔符的,都要使用双引号将该名字全称引用起来,如果文件名包含引号,则很不幸,无法处理。

命令语句一般的格式是由一个关键字和紧跟其后的参数所组成。比如ENTRY和SECTIONS

表4-4图

4.7 BFD库

BFD库(Binary File Descriptor library)是一个GNU项目,它的目标就是希望通过一种统一的接口来处理不同的目标文件格式。BFD这个项目本身是binutils项目的一个子项目。BFD把目标文件抽象成一个统一的模型,比如在这个抽象的目标文件模型中,最开始有一个描述整个目标文件总体信息的”文件头”,就跟我们实际的ELF文件一样,文件头后面是一系列的段,每个段都有名字、属性和段的内容,同时还抽象了符号表、重定位表、字符串表等类似的概念,使得BFD库的程序只要通过操作这个抽象的目标文件模型就可以实现操作所有BFD支持的目标文件格式。

现在GCC、链接器ld、调试器GDB及binutils的其它工具都通过BFD库来处理目标文件,而不是直接操作目标文件。这样做最大的好处是将编译器和链接器本身同具体的目标文件格式隔离开来,一旦我们需要支持一种新的目标文件格式,只需要在BFD库里面添加一种格式就可以了,而不需要修改编译器和链接器。

posted @ 2023-04-08 09:52  缄默先生  阅读(89)  评论(0编辑  收藏  举报