/* a.c */
extern int shared;
void exit()
{
asm( "movl $42, %ebx \n\t"
"movl $1, %eax \n\t"
"int $0x80 \n\t");
}
int main()
{
int a = 100;
swap(&a, &shared);
exit();
}
/* b.c */
int shared = 1;
void swap(int *a, int *b)
{
*a ^= *b ^= *a ^= *b;
}
gcc -g -fno-stack-protector -c -m32 a.c -o a.o
gcc -g -fno-stack-protector -c -m32 b.c -o b.o
ld -static -m elf_i386 -e main a.o b.o -o ab
对于链接器来说,整个链接的过程,就是将几个输入目标文件加工后合并成一个输出文件。
那么对于多个输入的文件,链接器是如何将他们的段合并到输出文件 。有以下2种方法:
按序叠加
按序叠加的做法很简单,就是直接将各个目标文件依次合并,但是这样做法非常浪费空间。因为每个段都有一定的地址和空间对齐要求,比如对于X86平台,段的装载地址和空间的对齐要求是页,也就是4096字节。那么就是说如果一个段的长度只有1个字节,它要在内存中占用一个段,这样会造成内存空间的大量碎片。
相似段合并
一个更实际的方法是将相同性质的段合并到一起。比如将所有输入文件的".text"合并到输出文件的".text"段,接着是".data"段,".bss段"。
现在的链接器空间分配基本上采用上述第二种方案。使用这种方法的链接器一般都采用两步链接的方法:
第一步:空间与地址分配
扫描所有的输入目标文件,获得它们的各个段的长度,属性和位置,并且将输入目标文件中的符号表中所有符号定义和符号引用收集起来,统一放到一个全局符号表中。第二步:符号解析与重定位
使用上面第一步中收集到的所有信息,读取输入文件中段的数据,重定位信息,并且进行符号解析与重定位,调整代码中的地址。这一步是链接的核心,特别是重定位过程。
$ objdump -h a.o # a.o是可重定位文件
$ objdump -h b.o # b.o是可重定位文件
$ objdump -h ab # ab是可执行文件
空间与地址分配
从上面看出,链接器已经按照前面介绍的空间分配方法进行分配,这时候各段在链接后的虚拟地址已经确定。
当前面一步完成之后,链接器开始计算各个符号的虚拟地址,因为各个符号在段内的相对位置是固定的,所以这时main
,shared
和 swap
地址已经确定了。
符号解析与重定位
在完成空间和地址的分配之后,每个段在链接后的虚拟地址就已经确定了。
这时链接器就进入了符号解析与重定位的步骤,这也是静态链接的核心内容。
重定位
$ objdump -d a.o
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
25: c9 leave
26: c3 ret
在c7 44 24 04 00 00 00 00
中00 00 00 00
是shared
的地址。
因为当a.c被编译为目标文件时,编译器并不知道shared
和swap
的地址。因为,它们定义在其他文件中, 所以编译器暂时把地址0看作是shared的地址。
在e8 fc ff ff ff
中ff ff ff
是swap
函数的地址。
e8 是近址相对位移调用指令,后面4个字节就是被调用函数的相对于调用指令的下一条指令的偏移量。 0xFFFFFFFC(小端法已转换),它是-4的补码。因为下一条指令的地址是0x25,所以 0x25 - 4 = 0x21,
所以call指令的实际调用地址是0x21,但是0x21并不是存放swap的函数地址。
编译器把这两条指令的暂时用两个假地址代替,把真正的地址计算工作留给了链接器。
$ objdump -d ab # ab是可执行文件
08048094 :
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 54 91 04 movl $0x8049154,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
80480b9: c9 leave
80480ba: c3 ret
080480bb :
80480bb: 55 push %ebp
......
从链接后的结果来看,shared
地址已被更新为0x08049154,swap
的地址等于0x80480b9 + 0x2 = 0x80480bb。
重定位表
那么链接器是怎么知道哪些指令要被调整的呢?在可重定位的ELF文件中,有一个重定位表的结构专门用来保存这些与重定位相关的信息。对于每一个要被重定位的ELF段都有一个对应的重定位表,而一个重定位表往往就是ELF文件中的一个段。比如代码段".txt"有要被重定位的地方,那么就又一个相应的叫".rel.text"的段保存了代码段的重定位表。
objdump -r a.o # -r 显示重定位信息
对于32位的Intel x86系列处理器来说,重定位表的结构很简单,它是一个Elf32_Rel
结构的数组,每个数组元素对应一个重定位入口。
typedef struct {
Elf32_Addr r_offset;
Elf32_Word r_info;
} Elf32_Rel;
r_offset
重定位入口的偏移,对于可重定位文件来说,这个值是该重定位入口所要修正的位置的第一个字节相对于段起始的偏移,r_info
指令修正方式
32位平台x86平台下的ELF文件的重定位入口所修正的指令寻址方式只有两种:
- 绝对近32位寻址
- 相对近32位寻址
这两种重定位方式修正指令方式每个被修正的位置的长度都为32位,即4字节,
COMMON块
强符号和弱符号
经常在编程中碰到一种情况叫做符号重定义,在什么情况下会报这那个错误呢?
对于 C/C++ 来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。
注意强弱符号都是通过定义而言的,不是针对符号引用。
比如下面代码中:weak
和 weak2
是弱符号,strong
和 main
是强符号。
extern int ext;
int week;
int strong = 1;
__attribute__((week)) week2 = 2;
int main()
{
return 0;
}
- 规则一:不允许强符号被多次定义,有多个强符号被多次定义,则链接器报符号重定义的错误。
- 规则二:如果一个符号在某个目标文件中是强符号,其他文件中都是弱符号,选择强符号。
- 规则三:如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。