目录

深入理解程序 | 静态链接的过程

静态链接过程

对于链接器来说,整个链接过程中,它就是将几个输入目标文件加工后合并成一个输出文件。现在的链接器一般采用一种叫两步链接的方法,整个链接过程分为两步:

  • 第一步 空间与地址分配

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

  • 第二步 符号解析与重定位

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

下面使用如下的示例代码来讲解静态链接过程,两个C语言文件名分别命名为a.cb.c

1
2
3
4
5
6
7
/* a.c */
extern int shared;

int main() {
	int a = 100;
	swap(&a, &shared);
}
1
2
3
4
5
6
/* b.c */
int shared = 1;

void swap(int* a, int* b) {
	*a ^= *b ^= *a ^= *b;
}

接下使用GCC将上述两个文件分别编译成目标文件

1
$ gcc -c a.c b.c

空间与地址分配

这个过程中,合并是将相同性质的段合并在一起,比如将所有输入目标文件中.text合并成一个.text段,.data段、.bss段同理。

之后链接器为目标文件分配地址和空间,这里的地址和空间可能指①输出的可执行文件中的空间,②装载后的虚拟地址中的虚拟地址空间。这两个的区别是,比如.bss段在可执行文件中的空间是不存在的,它只存在于虚拟地址空间。那么,这边的空间分配只关注于虚拟地址空间的分配, 因为这个关系到链接器后面的关于地址计算的步骤,而可执行文件本身的空间分配与链接过程关系并不是很大。

按照上述的空间分配方法进行分配之后,这时候输入文件中的各个段在链接后的虚拟地址就已经确定了。接下去**链接器开始计算各个符号的虚拟地址(**上述将所有输入文件的符号表收集起来统一放到一个全局符号表中)。

符号解析与重定位

a.c在被编译成目标文件时,编译器并不知道两个外部符号sharedswap的地址,所以编译器可能会拿0等地址值作为临时地址。下面使用objdump指令将a.o.text段的内容进行反汇编,反汇编结果如下所示

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
$ 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

反汇编的结果不一定相同

引用sharedswap的汇编内容为c7 44 24 04 00 00 00 00 movl $0x0,0x4(%esp)e8 fc ff ff ff call 21 <main+0x21>。可以看到编译器暂时把地址0看作是shared的地址,同时0xfffffffc也是一个临时的地址。

那么前面已经说过链接器在完成地址和空间分配后各个符号的虚拟地址就已经确定了,但是输入文件中相应段中的指令的地址并没有修正,就还是临时的地址。因此还需要对上述的地址部分进行调整,这个时候需要用到重定位表。

重定位表(Relocation Table):哪些指令要调整,这些指令的哪些部分要调整,以及怎么调整。这些与重定位相关的信息都存放在重定位表中。对于可重定位表的ELF文件来说,它必须包含有重定位表,用来描述如何修改相应段的内容。并且对于每个需要被重定位的ELF段都有一个对应的重定位表(重定位表往往就是ELF文件中的一个段),比如.text段中有需要被重定位的地方,那么就会有一个叫做.rel.text的重定位表。同理,.data段有要被重定位的地方,就会有一个相应的叫做.rel.data的重定位表。使用如下命令查看a.o这个目标文件的重定位表

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ 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
  • RELOCATION RECORDS FOR [.text]表示这个重定位表是.text段的重定位表。

  • 每个需要被重定位的地方叫一个重定位入口(relocation entry),上述中显示a.o.text段有两个重定位入口。

对于32位的Intel x86处理器来说,重定位表的结构是由一系列Elf32_Rel结构组成的数组,每个数组对应一个重定位入口,Elf32_Rel的定义如下

1
2
3
4
typedef struct {
    Elf32_Addr r_offset;
    Elf32_Word r_info;
}Elf32_Rel;
  • r_offset这个成员表示重定位入口的偏移。 对于可重定位文件来说,这个值是该重定位入口所要修正的位置的第一个字节相对于段起始的偏移。 对于可执行文件或共享对象文件来说,这个值是该重定位入口所要修正的位置的第一个字节的虚拟地址。
  • r_info这个成员表示重定位入口的类型和符号。 低8位表示重定位入口的类型,对应上面的TYPE字段。 因为各种处理器的指令格式不一样,所以重定位所修正的指令地址格式不一样。每种处理器都有自己的一套重定位入口的类型。对于可执行文件和共享目标文件来说,它们的重定位入口是动态链接类型的。 高24位表示重定位入口的符号在符号表的下标,对应上面的VALUE字段,只是上面的结果是变成相应符号之后的。

在重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候链接器会去查找所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行指令修正,也就相当于重定位。

不同的处理器指令对于地址的格式和方式都不一样。直至2006年为止,Intel x86系列CPU的jmp指令有11种寻址模式,call指令有10种,mov指令则多达34种寻址模式。寻址方式有如下几方面的差别:

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

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

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

之前提到过r_info成员低8位表示重定位入口类型,那么相关的宏定义、值和修正方式如下所示

宏定义 重定位修正方法
R_386_32 1 绝对寻址修正 S+A
R_386_PC32 2 相对寻址修正 S+A-P

S = 符号的实际地址,即全局符号表中的地址值。可通过r_info的高24位找到实际地址,那么这个地址在符号解析时已经找到

A = 保存在被修正位置的值(也就相当于临时的地址值)

P = 被修正的位置(相对于段开始的偏移量或者虚拟地址)。可通过r_offset得到。

下面举例来讲解这两种指令修正方式:

假设a.ob.o中相应的段进行合并后,相应符号对应的虚拟地址如下:main函数的虚拟地址是0x1000,swap函数的虚拟地址为0x2000,shared变量的虚拟地址为0x3000。那么

  • 绝对寻址修正

    a.o的第一个重定位入口,即偏移值为0x11的那条mov指令,它的修正方式是绝对寻址修正。那么应该按照S+A的方法来进行修正。其中S的值为0x3000,A的值为0x0000,S+A的值为0x3000,那么被修正位置的值因为变为0x3000,这个值正好是shared变量的地址。

  • 相对寻址修正

    a.o的第二个重定位入口,即偏移值为0x20的那条call指令。它的修正方式是相对寻址修正。那么应该按照S+A-P的方法来进行修正。其中S的值为0x2000,A的值是0xfffffffc(-4),P的值为0x1000+0x21,S+A-P的值为0x0fdb。那么该入口处修正后的地址为0x0fdb。然而入口处这条相对位移调用指令调用的真正地址为该指令下一条指令的起始地址加上入口处的地址,即0x0fdb+(0x1000+0x25)=0x2000,这个值正好是swap函数的虚拟地址。其实S+A-P的值为S相对于修正指令的下一条指令的地址差。

上述静态链接过程总结

总的来说,针对现在的链接器,静态链接的过程如下所示:

  • 扫描所有的输入目标文件,获取它们各个段的长度、属性和位置,并将它们进行合并,这里所有输入目标文件中的符号表所有的符号定义和符号引用也都会被收集起来,统一放到一个全局符号表中。

    之后为目标文件分配地址和空间,这边的空间分配只关注于虚拟地址空间的分配。

    在分配完成之后,合并的内容对应着虚拟地址的某一块区域,这也表示着各个段在链接后的虚拟地址也就已经确定了。那么此时需要重新计算各个符号的虚拟地址。

  • 之后就是基于输入目标文件的重定位表和全局符号表来进行重定位。

    根据重定位表中的信息找到相应的重定位入口,之后再根据重定位表中的信息和全局符号表找到重定位入口处符号的地址值(这个地址值是通过上面已经更新了,这个过程相当于符号解析),根据重定位表中的重定位方式对该处的地址进行修正。

上面只是对两个简单的目标文件进行链接,那么实际情况下,要想链接出一个最终可执行文件,整个链接过程中还会涉及到对库的链接,涉及到对弱符号多出定义问题的处理。而整个链接过程最终是受链接过程控制的,如是否在最终的可执行文件中保留调试信息、输出文件格式等。那么,下面看一下链接过程中会涉及到的其他内容。

静态链接过程相关

COMMON块—用于处理弱符号的多定义

由于弱符号机制允许同一个符号的定义存在于多个文件中,所以可能会导致的一个问题是:一个弱符号定义在多个目标文件中,但是他们的符号类型不同。而目前的链接器本身并不支持符号的类型,即变量类型对于链接器来说是透明的。它只知道一个符号的名字,并不知道类型是否一致。那么针对这种情况,链接器该怎么处理呢?

现在的编译器和链接器都支持一种叫COMMON块(common block)的机制。编译器将未初始化的全局变量定义作为弱符号处理,而链接器机制在处理弱符号时,采用COMMON块的机制。

  • 比如SimpleSection.c中的global_uninit_var就是典型的弱符号。如果我们在另一个文件中也定义了global_uninit_var变量,且未初始化,它的类型是double,占8个字节。按照COMMON类型的链接规则,原则上讲最终链接后输出文件中,global_uninit_var的大小以输入文件中最大的那个为准。当然COMMON类型的链接规则是针对符号都是弱符号的情况
  • 如果其中有一个符号为强符号,那么最终输出结果中的符号所占空间与强符号相同。但是链接过程中弱符号大小大于强符号,那么ld链接器会报警告。

直接导致需要COMMON这种机制的原因是编译器和链接器允许不同类型的弱符号存在,但最本质的原因还是链接器无法判断各个符号的类型是否一致。

COMMON BLOCK这种机制最早来源于Fortran,早期的Fortran没有动态分配空间的机制,程序员必须事先声明它所需要的临时使用空间的大小。Fortran把这种空间叫COMMON块,当不同的目标文件需要的COMMON块空间大小不一致时,以最大的那块为准。

编译器在将一个编译单元编译成目标文件的时候,编译器会将该编译单元内未初始化的全局变量等弱符号标记为一个COMMON类型,而不是在BSS中分配空间。这个主要是因为在编译阶段,弱符号最终所占用的大小是未知的,因为有可能其他编译单元内该符号所占的空间比本编译单元内该符号所占的空间要大。但是链接器在链接过程中可以确定弱符号的大小,因为它读取所有输入目标文件以后,任何一个弱符号的最终大小都可以确定,那么它可以在最终输出文件的BSS段为其分配空间。所以最终,未初始化的全局变量还是被放入BSS段。

当然可以通过以下两种方式将所有未初始化的全局变量不以COMMON块的方式处理,

  • 使用GCC的 -fno-common选项
  • 使用__attribute__扩展,如int global __attribute__((nocommon));

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

静态库链接

静态库概述

一个静态库可以简单地看成一组目标文件的集合,是很多目标文件经过压缩打包后形成的一个文件。比如在Linux中最常见的C语言静态库libc位于/usr/lib/libc.a,它属于glibc项目的一部分。Windows的平台上,最常使用的C语言库是集成开发环境所附带的运行库,这些库一般由编译器厂商提供。

在一个C语言的运行库中,包含了很多跟系统功能相关的代码,比如输入输出、文件操作、时间日期、内存管理等。glibc本身就是用C语言开发的,它由成千上万个C语言源代码文件组成,那么相应的,编译之后也会有相同数量的目标文件,比如输入输出文有printf.o,scanf.o;文件操作有fread.o,fwrite.o;时间日期有date.o,time.o等。假如把这些零散的目标文件直接提供给库的使用者,很大程度上会造成文件传输、管理等不遍。所以人们使用ar压缩程序将这些目标文件压缩到一起,并且对其编号和索引,就形成了最终的libc.a这个静态文件。

可以使用ar命令来查看libc.a这个静态文件中包含了哪些目标文件,如下所示:

1
2
3
4
5
6
7
8
$ar -t libc.a
......
gethostname.o
sethostname.o
getdomain.o
setdomain.o
select.o
......

因为libc.a这个文件是一系列目标文件的集合,所以也可以使用objdumpreadelf这两个文件来查看相应的内容。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$objdump -t libc.a
......
tlsdesc.o:     file format elf32-i386

SYMBOL TABLE:
00000000 l    d  .text	00000000 .text
00000000 l    d  .data	00000000 .data
......

dl-tlsdesc.o:     file format elf32-i386

SYMBOL TABLE:
00000000 l    d  .text	00000000 .text
00000000 l    d  .data	00000000 .data
......

可能不知道libc.a的位置,可以使用如下命令来查看一下

1
2
$ gcc --print-file-name=libc.a
/usr/lib/gcc/i686-linux-gnu/4.6/../../../i386-linux-gnu/libc.a

静态运行库里面的一个目标文件通常只包含一个函数。比如printf.o只有printf()函数,strlen.o只有strlen()函数,这是因为链接器在链接静态库的时候是以目标文件为单位的。比如引用了静态库中的printf()函数,那么就会把库中包含printf()函数的那个目标文件链接进来。那么,如果很多函数都放在一个目标文件中,那么很多没有用到的函数都会被一起链接进来。所以为了尽量减少空间的浪费,就尽量让每个函数独立放在一个目标文件中,这样没有用到的函数就会尽可能少的被链接进来了。

静态库的链接

下面以最简单的hello world程序为例,说说一个程序的目标文件是如何与C语言运行库链接形成一个可执行文件的。hello world程序代码如下所示

1
2
3
4
5
6
#include <stdio.h>

int main(){
	printf("hello world!\n");
	return 0;
}
  • 只将 hello.o和printf.o进行链接

hello world中所需要的printf函数被定义在libc.a的printf.o这个目标文件中,那么假如将hello world编译出来的目标文件和printf.o进行链接应该就可以形成一个可用的可执行文件了。下面按照这个方式尝试一下,首先将hello world程序(hello.c)编译成目标文件

1
$ gcc -c -fno-builtin hello.c

-fno-builtin参数是因为GCC编译器提供了很多内置函数(built-in function),它会把一些常用的C库函数替换成编译器的内置函数,以达到优化的功能。比如GCC会将只有字符串参数的printf函数替换成puts,来节省格式解析时间。为了关闭内置函数优化选项,所以需要使用-fno-builtin这个参数。

之后通过ar工具解压出printf.o,下面这个命令会将所有目标文件解压至当前目录。

1
$ ar -x libc.a

最后尝试将printf.o与hello.o链接在一起

1
2
3
4
5
6
$ ld hello.o printf.o 
ld: warning: cannot find entry symbol _start; defaulting to 0000000008048080
printf.o: In function `_IO_printf':
(.text+0x14): undefined reference to `stdout'
printf.o: In function `_IO_printf':
(.text+0x1c): undefined reference to `vfprintf'

结果发现链接失败了,原因是printf.o中有两个未定义的符号,而这两个未定义的符号又是定义在其他目标文件中的,也就是说printf.o依赖于其他的目标文件。那么之后就算找到stdout和vfprintf这两个符号所在的目标文件,并将它们链接进来。结果还会发现这两个文件依赖于其他的目标文件,因为这两个符号所在的目标文件中又有未定义的符号。这些符号分布在glibc的各个目标文件之中,假如把所需要的目标文件都找到,并进行链接,那么理论上可以得到可执行文件。

  • hello.o和libc.a进行链接

但是通过这种方式太麻烦了,所幸ld链接器会处理这一切繁琐的事务,它会寻找所有需要的符号以及它们所在的目标文件,将这些目标文件从libc.a中解压出来,最终将它们链接在一起成为一个可执行文件。那么是不是讲hello.o和libc.a进行链接起来就可以得到可执行文件了呢?下面我们尝试一下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ ld hello.o libc.a
ld: warning: cannot find entry symbol _start; defaulting to 0000000008048250
libc.a(vfprintf.o): In function `vfprintf':
(.text+0x386e): undefined reference to `_Unwind_Resume'
libc.a(vfprintf.o):(.eh_frame+0x1fb): undefined reference to `__gcc_personality_v0'
libc.a(syslog.o): In function `__vsyslog_chk':
(.text+0x7b8): undefined reference to `_Unwind_Resume'
libc.a(syslog.o): In function `__vsyslog_chk':
(.text+0x7ca): undefined reference to `_Unwind_Resume'
......

结果发现还是不行,现在Linux系统上的库比我们想象的要复杂。当链接一个普通C程序编译生成的目标文件的时候,不仅要用到C语言库libc.a,还要其他一些辅助性质的目标文件和库。

  • 链接过程的查看

下面来详细的查看一下整个编译链接的过程

1
2
3
4
5
6
7
$ gcc -static --verbose -fno-builtin hello.c
	......
/usr/lib/gcc/i686-linux-gnu/4.6/cc1 -quiet -v -imultilib . -imultiarch i386-linux-gnu hello.c -quiet -dumpbase hello.c -mtune=generic -march=i686 -auxbase hello -version -fno-builtin -fstack-protector -o /tmp/cckRccEG.s
	......
as --32 -o /tmp/ccIJuf3l.o /tmp/cckRccEG.s
	......
/usr/lib/gcc/i686-linux-gnu/4.6/collect2 --sysroot=/ --build-id --no-add-needed --as-needed -m elf_i386 --hash-style=gnu -static -z relro /usr/lib/gcc/i686-linux-gnu/4.6/../../../i386-linux-gnu/crt1.o /usr/lib/gcc/i686-linux-gnu/4.6/../../../i386-linux-gnu/crti.o /usr/lib/gcc/i686-linux-gnu/4.6/crtbeginT.o -L/usr/lib/gcc/i686-linux-gnu/4.6 -L/usr/lib/gcc/i686-linux-gnu/4.6/../../../i386-linux-gnu -L/usr/lib/gcc/i686-linux-gnu/4.6/../../../../lib -L/lib/i386-linux-gnu -L/lib/../lib -L/usr/lib/i386-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/i686-linux-gnu/4.6/../../.. /tmp/ccIJuf3l.o --start-group -lgcc -lgcc_eh -lc --end-group /usr/lib/gcc/i686-linux-gnu/4.6/crtend.o /usr/lib/gcc/i686-linux-gnu/4.6/../../../i386-linux-gnu/crtn.o

从上面的链接过程中可以看到:

第一步是调用cc1程序,这个程序实际上是GCC的C编译器,它将“hello.c”编译成一个临时的汇编文件/tmp/cckRccEG.s;

然后调用as程序,as程序是GNU的汇编器,它将/tmp/cckRccEG.s汇编成临时目标文件/tmp/ccIJuf3l.o

最后一步是调用collect2程序来完成最后的链接工作。collect2可以看做是ld链接器的一个包装,它会调用ld链接器来完成对目标文件的链接,然后再对链接结果进行一些处理,主要是收集所有与程序初始化相关的信息并且构造初始化的结构。从最后一步中我们可以看到,至少有下列几个库和目标文件被链接入了最终可执行文件:ctr1.octri.octrbeginT.olibgcc.alibgcc_eh.alibc.actrend.octrn.o。这些库的内容后面会讲到。

链接过程控制

整个链接过程中除了需要确定链接的目标文件、静态库之外,还需要确定链接的规则和结果。大部分情况下,链接器使用默认的链接规则对目标文件进行链接的。但对于一些特殊要求的程序,比如内核、BIOS或者boot loader、嵌入式程序等,往往受限于一些特殊的条件,如需要指定输出文件的各个段虚拟地址、段的名称、段存放的顺序。其实除了上面这些信息之外,链接过程可能还需要确定,是否在可执行文件中保留调试信息、输出文件格式(动态链接库还是可执行文件)、是否导出某些符号以供调试器、程序本身或者其他程序使用等。这些都是可控制的。那么,链接器一般提供多种方法来控制整个链接过程,以产生用户所需要的文件。一般有以下这三种方法:

  • 使用命令行来给链接器指定参数,比如使用ld的-o-e等参数就是这种方法。
  • 将链接指令存放到目标文件里面,编译器经常会通过这种方法向链接器传递指令。比如VISUAL C++编译器会把链接参数放在PE目标文件的.drectve段来传递参数。
  • 使用链接控制脚本,这种方法最为灵活、最为强大。

链接控制脚本详解

下面就针对链接控制脚本进行讲解。然而因为各个链接器平台的链接控制规则各不相同,下面我们使用ld这个链接器来讲解。ld在用户没有指定链接脚本的时候会使用默认链接脚本,使用下面命令来查看ld默认的链接脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ ld -verbose
	......
using internal linker script:
==================================================
/* Script for -z combreloc: combine and sort reloc sections */
OUTPUT_FORMAT("elf32-i386", "elf32-i386",
	      "elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(_start)
SEARCH_DIR("/usr/i686-linux-gnu/lib32"); SEARCH_DIR("=/usr/local/lib32"); SEARCH_DIR("=/lib32"); SEARCH_DIR("=/usr/lib32"); SEARCH_DIR("=/usr/local/lib/i386-linux-gnu"); SEARCH_DIR("=/usr/local/lib"); SEARCH_DIR("=/lib/i386-linux-gnu"); SEARCH_DIR("=/lib"); SEARCH_DIR("=/usr/lib/i386-linux-gnu"); SEARCH_DIR("=/usr/lib");
SECTIONS
{
  /* Read-only sections, merged into text segment: */
  PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x08048000)); . = SEGMENT_START("text-segment", 0x08048000) + SIZEOF_HEADERS;
  .interp         : { *(.interp) }
  	......

默认的脚本存放在/usr/lib/ldscripts/,不同的机器平台、不同的输出文件格式都有相应的脚本,比如Intel IA32下的普通可执行ELF文件链接脚本文件为elf_i386.x,该机器平台下共享库的链接脚本文件为elf_i386.xs。具体的可以看脚本文件中的注释内容。ld会根据命令行要求使用相应的链接脚本文件来控制链接过程。假如想要使用其他链接脚本,如自己编写的链接脚本,那么可以使用ld的-T参数,如下

1
$ ld -T link.script

下面我们整一个最小程序的例子,来讲解整个链接的控制过程。这个程序其实就是输出hello world,如下所示

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
char* str1 = "Hello world!\n";

void print() {
	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"(str1):"edx","ecx","ebx");
}

void exit() {
    asm(    "movl $42, %ebx \n\t"
            "movl $1, %eax          \n\t"
            "int $0x80      \n\t");
}

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

我们不使用printf函数,因为它需要链接各种库;同时将nomai()函数作为整个程序的入口;接下来我们让上述程序的编译生成的各种段都合并到一个叫tinytext的段(这个段是任意命名的,由链接脚本控制链接过程生成的),而不是像经典的hello world那样会有.text段等。编写如下的链接脚本来控制整个链接过程,链接过程其实就是控制输入文件中的段(输入段)使其变成输出文件中的段(输出段),比如哪些段合并成一个输出段,哪些段要丢弃,指定输出段的名字、装载地址、属性等

1
2
3
4
5
6
7
8
ENTRY(nomain)

SECTIONS
{
        . = 0x08048000 + SIZEOF_HEADERS;
        tinytext : { *(.text) *(.data) *(.rodata) }
        /DISCARD/ : { *(.comment) }
}
  • 第一行ENTRY(nomain)指定了程序的入口为nomain;

  • 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的段丢弃,不保存到输出文件中。

使用下面的命令来编译上述代码,并使用上述的链接脚本,最终得到一个ELF可执行文件

1
2
$ gcc -c -fno-builtin TinyHelloWorld.c
$ ld -static -T TinyHelloWorld.lds -o TinyHelloWorld TinyHelloWorld.o

执行这个可执行文件,可以正确输出“Hello world!”。

当使用readelf工具查看生成的可执行文件时,会发现除tinytext段之外还有其他3个段:.shstrtab(段名字符串表)、.symtab(符号表)、.strtab(字符串表)。虽然链接脚本中没有指定这三个段,但是在默认情况下,ld链接器在产生执行文件时会产生这三个段。当然可能查看的时候还会有.eh_frame这个段,这个段的作用主要是跟异常处理相关。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ readelf -S TinyHelloWorld
There are 6 section headers, starting at offset 0x170:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .eh_frame         PROGBITS        08048074 000074 00007c 00   A  0   0  4
  [ 2] tinytext          PROGBITS        080480f0 0000f0 000052 00 WAX  0   0  4
  [ 3] .shstrtab         STRTAB          00000000 000142 00002e 00      0   0  1
  [ 4] .symtab           SYMTAB          00000000 000260 000080 10      5   4  4
  [ 5] .strtab           STRTAB          00000000 0002e0 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), T (TLS), E (exclude), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

然而对于可执行文件来说,符号表和字符串表是可选的,但是段名字符串表为用户保存段名,是必不可少的。可以使用ld的-s参数禁止链接器产生符号表,或者使用strip命令去除可执行文件中的符号表。去除之后,发现这个可执行文件还是有效的ELF可执行文件,能够正确执行并输出结果。

上述内容小结

  • 在链接过程中,对于处理弱符号的多定义问题即在多个C语言文件中定义了同名的全局变量但是未初始化,采用的是COMMON机制,COMMON机制就是比较这些变量的大小,然后最终选择最大那个到输出文件中。而导致采用COMMON机制的本质原因是因为链接器无法比较判断各个符号的类型是否一致。对于目标文件来说,该编译单元内未初始化的全局变量等弱符号都会被标记为COMMON类型,因为其他编译单元内可能还会有相同的符号。但是,最终这些未初始化的全局变量在最终的输出文件中,如可执行文件中,都会被放到BSS段中,因为这些弱符号在链接过程可以确定其大小了。
  • 静态库是一系列目标文件压缩打包之后的,里面是一个个目标文件,而且为了节省链接之后的空间一般都是一个函数一个目标文件。 一个程序的目标文件在进行静态链接的时候,除了libc.a这个静态库需要链接之外,还可能需要链接其他静态库或目标文件,比如ctr1.octri.o等。
  • 除了确定链接的目标文件、静态库之外,有时候还需要确定链接的规则和链接的结果,比如指定输出文件的各个段虚拟地址、段的名称、段存放的顺序、输入段到输出段的关系,以及是否在可执行文件中保留调试信息、输出文件格式是哪种(动态链接库还是可执行文件)、是否导出某些符号以供调试器、程序本身或者其他程序使用等。那么对于链接过程地控制,一般采用下面三种方法:
    • 命令行方式
    • 目标文件的特殊段中存放链接指令
    • 链接控制脚本

ALL IN ALL

整个链接过程需要先确定参与链接的目标文件、静态库。之后在使用链接器链接的时候,会按照上述静态链接过程的整体流程,并根据链接脚本中的内容和命令行参数进行链接。当然这个过程也会涉及到COMMON块的机制,最终是生成一个可执行文件。所以上述的链接过程控制和上述的静态链接过程是一种相互辅助的关系,可以理解为链接脚本是配置文件,静态链接过程是链接器的执行顺序,这个执行顺序会用到链接脚本。比如在进行段合并的过程中会根据链接脚本的内容进行合并,而之后分配目标文件的地址和空间等,再是计算各个虚拟地址。个人更加倾向于把链接脚本当成一个配置内容,只是在链接的某个环节中会用到这个脚本。

巨人的肩膀

  1. 《程序员的自我修养—链接、装载与库》.俞甲子,石凡,潘爱民