如果我们有两个目标文件a.o
和b.o
,我们用链接器将两个文件链接后输出到可执行文件中ab.o
中,输出文件中的空间如何分配给输入文件?
最简单的方案就是将输入的目标文件按次序叠加起来。但这样会造成空间的浪费。
比较贴近事实的方法是将相同性质的段合并到一起。
“地址和空间“有两个含义,第一个是在输出的可执行文件中的空间;第二个是在装载后的虚拟地址中的虚拟地址空间。对于有实际数据的段,如”.data“,文件中和虚拟地址中都要分配2空间。对于”.bss“这样的段来说,分配空间的意义只是局限于虚拟地址空间,它在文件中并没有内容。事实上,我们在这里谈到的空间分配只关注虚拟地址空间的分配。
现在的链接器空间分配的策略基本上都采用相似段合并的方法,这种链接器一般都采用一种叫两步链接(Two-pass Linking)的方法。
第一步 空间与地址分配 扫描所有的输入目标文件,并且获得它们各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段的长度,并且将它们合并,计算出文件中各个段合并后的长度与位置,并建立映射关系
第二步 符号解析与重定位 使用上面第一步中收集到的所有信息,读取输入文件中段的数据、重定位信息,并进行符号解析与重定位、调整代码中的地址等。事实上第二步是链接过程的核心,特别是重定位过程。
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;
}
用gcc将”a.c”、”b.c”编译成目标文件”a.o“和”b.o“
$gcc -c a.c b.c
我们使用ld链接器将”a.o“和”b.o“链接起来:
$ld a.o b.o -s main -o ab
-e main
表示将main 函数作为程序入口,ld链接器默认的程序入口为_start-o ab
表示链接输出文件名为ab,默认为a.out objdump -h a.o
Idx Name Size VMA LMA File off Algn
0 .text 00000027 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 00000067 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000067 2**0
objdump -h b.o
b.o输出
b.o: 文件格式 elf64-x86-64
节:
Idx Name Size VMA LMA File off Algn
0 .text 0000004a 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000004 0000000000000000 0000000000000000 0000008c 2**2
可执行文件ab输出:
ab: 文件格式 elf64-x86-64
节:
Idx Name Size VMA LMA File off Algn
0 .text 00000071 00000000004000e8 00000000004000e8 000000e8 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .eh_frame 00000058 0000000000400160 0000000000400160 00000160 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
VMA表示Virtual Memory Address,即虚拟地址,LMA表示Load Memory Address,即加载地址,正常情况下这两个這应该是一样的,但是在有些嵌入式系统中,特别是在那些程序放在ROM的系统中时,LMA和VMA是不相同的。这里我们只要关注VMA即可。
链接后程序所使用的地址是程序在进程中的虚拟地址。我们关系上面各个段中的VMA(Virtual Memory Address)和Size,而忽略文件偏移(File off)。 链接前各个段的VMA都是0,链接后LMA分配了地址。
链接器将各个段进行了叠加,在Linux下,ELF可执行文件默认从地址0x0804800开始分配。
以a.o
和b.o
作为例子,来分析这两个步骤中链接器的工作过程。在第一步的扫描和空间分配阶段,链接器按照前面的空间分配方法进行分配,这时候输入文件中的各个段在链接后的虚拟地址就已经确定了。
完成段的虚拟地址后,链接器开始计算各个符号的虚拟地址。各个符号在原段内的相对地址是固定的,所以只要知道段的虚拟地址,加上原来的偏移地址,就能得到符号正确的虚拟地址。比如我们假设“a.o”中的“main”函数相对于“a.o”的“.text”段的偏移是X,但是经过链接合并以后,“a.o”的”.text”段位于虚拟地址0x08048094,那么“main”的地址应该是0x08048094+X。
在完成空间和地址的分配步骤以后,链接器就进入了符号解析与重定位的步骤。这也是静态链接的核心内容。
我们看a.c
中编译成指令时,它如何访问“shared“变量,如何调用”swap“函数
32位下:
当源代码“a.c”在被编译成目标文件时,编译器并不知道“shared”和“swap”的地址,我们可以看到这条“mov”指令中,关于“shared”的地址部分为“0x00000000”
另外一个是偏移为0x26的指令的一条调用指令,它其实就表示对swap函数的调用
前面0xE8是操作吗(Operation Code),这条指令是一个近址相对位移调用指令(Call near,relative,displacement relative to next instruction),后面4字节就是被调用函数的相对于调用指令的下一条指令的偏移量。在没重定位前,相对偏移被置位0xFFFFFFFFC(小端),它是常量“-4”的补码形式。
编译器把这两条指令的地址部分暂时用地址“0x00000000”和“0xFFFFFFFC”代替着,把真正的地址计算工作留给了链接器。
当链接完成后,链接器就可以根据符号的地址对每个需要重定位的指令进行地址修正。
修正后:
“call”指令是一条近址相对位移调用指令,它后面跟的是调用指令的下一条指令的偏移量。”call”指令的下一条指令是“add”,它的地址是0x080480bf,所以“相对于add指令偏移量为0x00000009“的地址为0x080480bf+9=0x080480c8,即刚好是”swap”函数的地址。
链接器根据重定位表(Relocation Table)提供的指令位置和调整方式进行指令重定位。
每个要被重定位的ELF段都有一个对应的重定位表,而一个重定位表往往就是ELF文件中的一个段,所以其实重定位表也可以叫重定位段,我们在这里统一称重定位表。
我们可以用objdump 来查看目标文件的重定位表:
objdump -r a.o
即“a.o”所有引用到外面符号的地址。,每个要被重定位的地方叫一个重定位入口(Relocation Entry)。 重定位入口的偏移(Offset)表示该入口在要被重定位的段中的位置,RELOCATION RECORDS FOR [.text]
表示这个重定位表是代码段的重定位表。
/* Relocation table entry without addend (in section of type SHT_REL). */
typedef struct
{
Elf32_Addr r_offset; /* Address */
Elf32_Word r_info; /* Relocation type and symbol index */
} Elf32_Rel;
/* Relocation table entry with addend (in section of type SHT_RELA). */
typedef struct
{
Elf32_Addr r_offset; /* Address */
Elf32_Word r_info; /* Relocation type and symbol index */
Elf32_Sword r_addend; /* Addend */
} Elf32_Rela;
typedef struct
{
Elf64_Addr r_offset; /* Address */
Elf64_Xword r_info; /* Relocation type and symbol index */
Elf64_Sxword r_addend; /* Addend */
} Elf64_Rela;
/* How to extract and insert information held in the r_info field. */
#define ELF32_R_SYM(val) ((val) >> 8)
#define ELF32_R_TYPE(val) ((val) & 0xff)
#define ELF32_R_INFO(sym, type) (((sym) << 8) + ((type) & 0xff))
#define ELF64_R_SYM(i) ((i) >> 32)
#define ELF64_R_TYPE(i) ((i) & 0xffffffff)
#define ELF64_R_INFO(sym,type) ((((Elf64_Xword) (sym)) << 32) + (type))
成员 | 含义 |
---|---|
r_offset | 重定位入口的偏移。对于可重定位文件来说,这个值是该重定位入口所要修正的位置的第一个字节相对于于段起始的偏移;对于可执行文件或共享对象文件来说,这个值是该重定位入口所要修正的位置的第一个字节的虚拟地址。 |
r_info | 重定位入口的类型和符号。这个成员的低8位表示重定位入口的类型,高24位表示重定位入口的符号在符号表中的下标 |
缺少符号的定义会导致连接错误。重定位过程中,每个重定位的入口都是对一个符号的引用,那么当链接器需对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。
查看“a.o”的符号表:
$ readelf -s a.o
“GLOBAL”类型的符号,除了“main”函数是定义在代码段外,其他两个“shared”和“swap”都是“UND”,即”undefined”未定义类型,这种未定义的符号都是因为该目标文件中有关于它们的重位项。所以在链接器扫描完所有的输入目标文件之后,所有这些未定义的符号都应该能够在全局符号表中找到,否则连接器就报符号未定义错误。
对于32位x86平台下的ELF文件的重定位入口所修正的指令寻址方式只有两种:
绝对寻址修正 mov 指令的修正方式为R_366_32
修正后的结果应该是 S+A
- S 是符号shared 的实际地址,即0x3000。
- A是被修正位置的值,即0x00000000。
所以重定位入口修正后地址为:0x3000+0x00000000=0x3000
相对寻址修正 call指令修正方式是R_386_PC32
,修正后的结果应该是S+A-P。
弱符号机制允许同一符号存在于多个文件中。多符号定义类型不一致有以下几种情况:
- 两个或两个以上强符号类型不一致;(链接器直接报错)
- 一个强符号,其它都是弱符号;(以强符号为准)
- 两个或两个以上弱符号类型不一致;(以所占空间最大的符号为准)
现代链接机制处理弱符号时候,采用COMMON块一样的机制,即类型不一致时,以最大空间的类型为准。
目标文件中未初始化的全局变量不与未初始化的局部静态变量一样在BSS段分配空间而是标记成一个COMMON类型变量是因为该弱符号最终大小所在空间未知,在输出文件中,它最终会被放在BSS段