Linux x86_64 程序静态链接之重定位
文章目录
- 一、简介
- 二、链接器
- 2.1 简介
- 2.2 可重定位目标模块
- 2.3 符号解析
- 2.4 重定位
- 三、重定位 demo 演示
- 3.1 外部函数重定位
- 3.2 static 函数重定位
- 四、补充
- 参考链接
一、简介
编程的代码无非是由函数和各种变量,以及对这些变量的读、写所组成,而不管是变量还是函数,它们最终都要存储在内存里。为每个变量和函数正确地分配内存空间,记录它们的地址,并把这个地址复写回调用或引用它们的地方。
在我们使用 gcc 时,往往执行一个命令后,就能得到可执行程序,所以你可能会误以为是编译器负责为变量分配内存地址,但是实际上,这个工作是由链接器来完成的。每个变量和函数都有自己的名称,通常我们把这些名称叫做符号。简单来讲,链接器的作用就是为符号转换成地址,一般来说可以分为三种情况:
(1)生成二进制可执行文件的过程中。这种情况称为静态链接;
(2)在二进制文件被加载进内存时。这种情况是在二进制文件保留符号,在加载时再把符号解析成真实的内存地址,这种被称为动态链接;
(3)在运行期间解析符号。这种情况会把符号的解析延迟到最后不得不做时才去做符号的解析,这也是动态链接的一种。
二、链接器
2.1 简介
为了构造可执行文件,链接器必须完成两个主要目标:
(1)符号解析
目标文件定义和引用符号。每个符号对应一个函数,一个全局变量或者一个静态变量。符号解析的目的按时将每个符号引用正好和一个符号定义关联起来。
(2)重定位
链接器通过把每个符号定义和一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使用它们指向这个内存位置。
编译器和汇编器通常为每个文件创建程序地址从 0 开始的目标代码,但是几乎没有计算机会允许从地址 0 加载你的程序。如果一个程序是由多个子程序组成的,那么所有的子程序必须被加载到互不重叠的地址上。重定位就是为程序不同部分分配加载地址,调整程序中的数据和代码以反映所分配地址的过程。在很多系统中,重定位不止进行一次。对于链接器的一种普遍情景是由多个子程序来构建一个
程序,并生成一个链接好的起始地址为 0 的输出程序,各个子程序通过重定位在大程序中确定位置。当这个程序被加载时,系统会选择一个加载地址,而链接好的程序会作为整体被重定位到加载地址。
CPU 在执行程序代码的时候,并不理解符号的概念,它所能理解的只有内存地址的概念。不管是读数据,调用函数还是读指令,对于 CPU 而言都是一个个的内存地址。因此,这里就需要一个连接 CPU 与程序员之间的桥梁,把程序中的符号转换成 CPU 执行时的内存地址。这个桥梁就是链接器,它负责将符号转换为地址。
链接器的第一个作用就是把多个中间文件合并成一个可执行文件。每个中间文件都有自己的代码段和数据段等多个 section,在合并成一个可执行程序时,多个中间文件的代码段会被合并到可执行文件的代码段,它们数据段也会被合并为可执行文件的数据段。
在上述描述中,file1.o 和 file2.o 被汇编以用作链接器的输入。每个文件包含 .text、.data 和 .bss 默认节,另外还包含一个用户命名的节。可执行目标模块显示了合并的各个节。链接器将来自 file1.o 和 file2.o 的 .text 节组合成一个 .text 节,然后合并两个 .data 节和两个 .bss 节,最后将用户命名的节放置在最后。内存映射显示了要将合并的各个节放置到内存中。
但是链接器在合并多个目标文件的时候并不是简单地将各个 section 合并就可以了,它还需要考虑每个目标中的符号的地址。这就引出了链接器的第二个任务:重定位。所谓重定位,就是当被调用者的地址变化了,要让调用者知道新的地址是什么。
根据上边的分析,链接器的工作流程也主要分为两步:
(1)第一步是,链接器需要对编译器生成的多个目标(.o) 文件进行合并,一般采取的策略是相似段的合并,最终生成共享文件 (.so) 或者可执行文件。这个阶段中,链接器对输入的各个目标文件进行扫描,获取各个段的大小,并且同时会收集所有的符号定义以及引用信息,构建一个全局的符号表。当链接器构造好了最终的文件布局以及虚拟内存布局后,我们根据符号表,也就能确定了每个符号的虚拟地址了。
(2)第二步是,链接器会对整个文件再进行第二遍扫描,这一阶段,会利用第一遍扫描得到的符号表信息,依次对文件中每个符号引用的地方进行地址替换。也就是对符号的解析以及重定位过程。
这就是链接器常用的两步链接 (Two-pass linking) 的步骤。简单来讲就是进行两遍扫描:第一遍扫描完成文件合并、虚拟内存布局的分配以及符号信息收集;第二遍扫描则是完成了符号的重定位过程。
重定位是符号解析的重要步骤,是我们理解静态链接和动态链接的基础原理。
2.2 可重定位目标模块
每个可重定位目标模块有三种:
Generic object files (*.o).
Kernel object files (*.ko).
Shared object files (*.so).
(1)通用对象文件用于静态链接,它们仅在编译过程中相关,在生成的主可执行文件中,任何给定的符号定义都将成为其中的一部分。
静态链接的优点在于,不需要依赖外部依赖项使主可执行文件在不同主机上工作。另一方面,静态链接的可执行文件可能会变得特别庞大,因为给定程序的所有必需依赖项都将成为生成的二进制文件的一部分,正如前面所述。
(2)此外,另一种可重定位对象是内核对象。这种类型的对象支持作为模块加载到内核中(通常称为 LKM),无需重新启动系统。
(3)还有共享对象。这种类型的可重定位文件支持在运行时链接,并且可以在不同进程之间共享。
因此,动态依赖项的重定位必须在运行时完成。这个过程称为动态链接。
这些重定位在运行时发生的原因是,与静态链接相反,符号定义不在主二进制文件的上下文中,而在外部共享对象中。直到相应的外部依赖项加载到内存中,这些动态引用才会被填充。
动态链接的依赖项的映像基地址并不是确定的,因为它们甚至会在同一可执行文件的不同进程实例之间有所不同。
此外,作为一个有趣的事实,在 Linux 系统中,动态依赖项没有首选的基本映像基地址,进程 A 的 libc.so 不会映射到与进程 B 的 libc.so 相同的地址。
本文主要介绍第一种 Generic object files (*.o) — 静态链接。
int sum(int a, int b);
int main()
{
int a = 2;
int b = 6;
int c = sum(a, b);
return 0;
}
gcc -c main.c -o main.o
# readelf -S main.o
There are 13 section headers, starting at offset 0x280:
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
0000000000000033 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 000001e0
0000000000000018 0000000000000018 I 10 1 8
[ 3] .data PROGBITS 0000000000000000 00000073
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .bss NOBITS 0000000000000000 00000073
0000000000000000 0000000000000000 WA 0 0 1
[ 5] .comment PROGBITS 0000000000000000 00000073
000000000000002c 0000000000000001 MS 0 0 1
[ 6] .note.GNU-stack PROGBITS 0000000000000000 0000009f
0000000000000000 0000000000000000 0 0 1
[ 7] .note.gnu.propert NOTE 0000000000000000 000000a0
0000000000000050 0000000000000000 A 0 0 8
[ 8] .eh_frame PROGBITS 0000000000000000 000000f0
0000000000000038 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 000001f8
0000000000000018 0000000000000018 I 10 8 8
[10] .symtab SYMTAB 0000000000000000 00000128
0000000000000090 0000000000000018 11 3 8
[11] .strtab STRTAB 0000000000000000 000001b8
0000000000000027 0000000000000000 0 0 1
[12] .shstrtab STRTAB 0000000000000000 00000210
000000000000006c 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)
从 section-header 的信息里可以看到,.rela.text 段的类型是 RELA 类型,也就是重定位表。我们在前面讲到,链接器在处理目标文件的时候,需要对目标文件里代码段和数据段引用到的符号进行重定位,而这些重定位的信息都记录在对应的重定位表里。
重定位表是ELF文件中专门用来保存需要重定位信息的段,它的作用是指导链接器如何修改相应的段内容。对于每一个要被重定位的ELF段都有一个对应的重定位表。比如代码段.text如果有要被重定义的地方,那么就会有一个相对应的段称为.rel.text。
一般来说,重定位表的名字都是以.rela 开头,比如.rela.text 就是对.text 段的重定位表,.rela.data 是对.data 段的重定位表。因为我们的例子中并没有涉及.data 段的重定位,所以,在上面打印的信息中没有出现.rela.data 段。
好了,接下来我们具体看一下.rela.text 重定位表里的内容。你可以通过 readelf -r 选项来打印二进制文件中的重定位表信息,输出如下:
# readelf -r main.o
Relocation section '.rela.text' at offset 0x1e0 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000000025 000500000004 R_X86_64_PLT32 0000000000000000 sum - 4
Relocation section '.rela.eh_frame' at offset 0x1f8 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000000020 000200000002 R_X86_64_PC32 0000000000000000 .text + 0
.rela.text 的重定位表里存放了 text 段中需要进行重定位的每一处信息。所以,每个重定位项都会包含需要重定位的偏移、重定位类型和重定位符号。
重定位表的数据结构是这样的:
typedef struct
{
Elf64_Addr r_offset; /* Address */
Elf64_Xword r_info; /* Relocation type and symbol index */
Elf64_Sxword r_addend; /* Addend */
} Elf64_Rela;
其中,r_info 的高 32bit 存放的是重定位符号在符号表的索引,r_info 的低 32bit 存放的是重定位的类型的索引。符号表就是.symtab 段,可以把它看成是一个字典,这个字典以整数为 key,以符号名为 value。
符号sum就是需要重定位的符号。它是一个外部符号。
通过’.rela.text’内容可知:
r_offset = 0x25
r_info.symbol_index = 0x5 = sum
r_info.type = 0x4 = R_X86_64_PLT32
r_addend = -4
/* AMD x86-64 relocations. */
#define R_X86_64_NONE 0 /* No reloc */
#define R_X86_64_64 1 /* Direct 64 bit */
#define R_X86_64_PC32 2 /* PC relative 32 bit signed */
#define R_X86_64_GOT32 3 /* 32 bit GOT entry */
#define R_X86_64_PLT32 4 /* 32 bit PLT address */
x86_64 Relocation types:
Name | Value | Field | Calculation |
---|---|---|---|
R_X86_64_NONE | 0 | None | None |
R_X86_64_64 | 1 | qword | S + A |
R_X86_64_PC32 | 2 | dword | S + A – P |
R_X86_64_GOT32 | 3 | dword | G + A |
R_X86_64_PLT32 | 4 | dword | L + A – P |
剩下我的还有三十多种类型,这里不在列举。这里我们只关心两种:
(1)R_X86_64_PC32:重定位一个使用32位PC相对地址的引用。
一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移值。当CPU执行一条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行值,得到有效地址(如CALL指令的目标),PC的值通常是下一条指令在内存中的地址。
(2)R_X86_64_PLT32
R_X86_64_PC32:重定位公式为S+A-P。
R_X86_64_PLT32:重定位公式为L+A-P。
L:符号的实际虚拟地址或在PLT表中的地址。当函数符号定义在目标文件中时,L则为符号的实际虚拟地址;当函数符号定义动态库中时,L则为PLT表中的地址。
在这里属于L则为符号的实际虚拟地址:L+A-P = S+A-P
其中,S=符号的实际地址,A=附加的修正常量,P=重定位位置的地址。对比可以看出,绝对寻址方式修正和相对寻址方式修正的区别就是绝对寻址修正后的地址是该符号的实际地址,相对寻址修正后的地址为符号距离被修正位置的地址差。
备注:S是绝对地址,而S+A-P算出来的是相对地址。使用相对地址的好处是只要引用者和被引用者的相对位置不变,那么它们就可以被安排到任意的位置上。这就可以支持加载地址随机化等安全增强技术。
增加一个全局变量演示:
# cat main.c
int d = 10;
int sum(int a, int b);
int main()
{
int a = 2;
int b = 6;
d = sum(a, b);
return 0;
}
# gcc -c main.c -o main.o
# readelf -S main.o
There are 13 section headers, starting at offset 0x2c0:
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
0000000000000036 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000208
0000000000000030 0000000000000018 I 10 1 8
[ 3] .data PROGBITS 0000000000000000 00000078
0000000000000004 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 0000007c
0000000000000000 0000000000000000 WA 0 0 1
[ 5] .comment PROGBITS 0000000000000000 0000007c
000000000000002c 0000000000000001 MS 0 0 1
[ 6] .note.GNU-stack PROGBITS 0000000000000000 000000a8
0000000000000000 0000000000000000 0 0 1
[ 7] .note.gnu.propert NOTE 0000000000000000 000000a8
0000000000000050 0000000000000000 A 0 0 8
[ 8] .eh_frame PROGBITS 0000000000000000 000000f8
0000000000000038 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 00000238
0000000000000018 0000000000000018 I 10 8 8
[10] .symtab SYMTAB 0000000000000000 00000130
00000000000000a8 0000000000000018 11 3 8
[11] .strtab STRTAB 0000000000000000 000001d8
0000000000000029 0000000000000000 0 0 1
[12] .shstrtab STRTAB 0000000000000000 00000250
000000000000006c 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)
# readelf -r main.o
Relocation section '.rela.text' at offset 0x208 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000025 000600000004 R_X86_64_PLT32 0000000000000000 sum - 4
00000000002b 000300000002 R_X86_64_PC32 0000000000000000 d - 4
Relocation section '.rela.eh_frame' at offset 0x238 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000000020 000200000002 R_X86_64_PC32 0000000000000000 .text + 0
注意这里可以看到 有全局变量 d 但是没有 .rela.data 节,然后我们查看全局变量 d 得重定位项是在 .rela.text 节里。这是因为重定位表的作用是描述“需要被重定位”的位置。所以它描述的是“引用”符号的地方,不是符号所在的位置。全局变量 d 引用的地址是在 text 段。
2.3 符号解析
每个可重定位目标模块m都有一个符号表,包含了目标模块m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
(1)由模块m定义并能被其他模块引用的全局符号。全局链接器对应非静态的C函数和全局变量。
(2)由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于在其他模块定义的非静态C函数和全局变量。
(3)只被模块m定义和引用的局部符号。他们对应于带 static 属性的C函数和全局变量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用。
typedef struct
{
Elf64_Word st_name; /* Symbol name (string tbl index) */
unsigned char st_info; /* Symbol type and binding */
unsigned char st_other; /* Symbol visibility */
Elf64_Section st_shndx; /* Section index */
Elf64_Addr st_value; /* Symbol value */
Elf64_Xword st_size; /* Symbol size */
} Elf64_Sym;
# readelf -s main.o
Symbol table '.symtab' contains 6 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 51 FUNC GLOBAL DEFAULT 1 main
4: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
5: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sum
这里有一个例子包含两个文件,第一个文件是 example.c:
// example.c
extern int extern_var;
int global_var = 1;
static int static_var = 2;
extern int extern_func();
int global_func() {
return 10;
}
static int static_func() {
return 20;
}
int main() {
int var0 = extern_var;
int var1 = global_var;
int var2 = static_var;
int var3 = extern_func();
int var4 = global_func();
int var5 = static_func();
return var0 + var1 + var2 + var3 + var4 + var5;
}
第二个文件是 external.c:
// external.c
int extern_var = 3;
int extern_func() {
return 30;
}
这个例子其实涵盖了程序员在开发过程中,最常用的几种变量类型以及函数类型,分别是:
全局变量:global_var。
静态变量:static_var。
外部变量:extern_var,在 example.c 中使用 extern 关键字进行声明,定义在 external.c 里。
局部变量:var0 … var5。
全局函数:global_func。
静态函数:static_func。
外部函数:extern_func,在 example.c 中使用 extern 关键字进行声明,定义在 external.c 里。
链接器解析符号引用的方法将每个引入的与它输入的可重定位目标文件的符号表的一个确定的符号定义关联起来。对那些和引用定义在相同模块中的局部符号的引用,符号解析都是非常简单明了的。编译器只允许每个模块中的每个局部变量有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保它们有唯一的名字。
编译器在编译源代码时,会为无法在当前编译单元内找到定义的符号生成一个特定的符号表条目,同时把“为该符号寻找定义”这个重任交给链接器。而链接器在随后进行符号解析时,便会在包含有全部符号信息的全局符号表中进行搜索。
编译器遇到一个不是在当前模块中定义的符号(变量或者函数名)时,会假设该符号是在其它模块中定义的,生成一个链接器符号表条目,并把他交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用富豪的定义,就输出一条错误信息信息。
链接器的输入是一组可重定位目标模块。每个模块定义一组符号,有些是局部的(只对定义该符号的模块可见),有些是全局的(对其他模块可见)。如果多个模块定义同名的全局符号,在编译时,编译器向汇编器输出每个全局符号,或者是是强(strong)或者是弱(weak),二而汇编器把这个信息隐含地编码在可重定位目标文件地符号表里。函数和已知初始化地全局变量是强符号,未初始化地全局变量是若符号。
如果链接器在这个过程中找到了符号的多个定义,它便会按照一定的规则来进行解析。编译器在编译源代码时,会为每一个全局符号指定对应的“强弱”信息,并同时将其隐含地编码在符号对应的符号表条目中。通常来说,函数和已初始化的全局变量为强符号,而未初始化的全局变量则是弱符号。而链接器对符号定义的选择会根据如下规则进行:
根据强弱符号地定义,Linux链接器使用下面的规则来处理多重定义的符号名:
(1)规则1:不允许有多个同名的强符号。
(2)规则2:如果有一个强符号和多个若符号同名,那么选择强符号。
(3)规则3:如果有多个若符号同名,那么从这些若符号中任意选择一个。
符号之所以会有强弱之分,主要是为了做到这一点:当不确定某个符号是否被用户显式定义的情况下,链接器仍然可以选择使用对应的弱类型符号版本来编译程序。这种能力通常被用在各类框架中,以便为某类程序编译所依赖的代码部分提供默认实现。除此之外,在模块化的代码调试场景中(比如单元测试中的桩代码),当某个待测试模块的依赖模块还没有被实现时,链接器可以选用标记为弱类型的默认版本来编译程序。
比如,在 GCC 中,我们可以通过为函数显式添加 attribute((weak)) 标记的形式,来将它标记为弱符号。但需要注意的是,这种方式会对代码的可移植性产生一定影响。
2.4 重定位
通过上面的步骤,链接器已经可以为每一个外部符号引用,找到一个与之关联的符号定义了。
到这里,链接器便可以根据之前收集到的所有信息,开始将多个目标文件内相同类型的 Section 进行合并。同时,为这些 Section,以及所有输出文件内使用到的符号指定运行时的 VAS 地址。在这一步中,链接器会通过名为“重定位”的步骤来修改 .data 与 .text 两个 Section 中,对每个符号的引用信息,使得它们可以指向正确的运行时地址。
重定位的一个主要目的在于,将之前各个独立编译单元(目标文件)内,所有对外部符号的引用地址进行修正。比如在我们之前的例子中,main.o 文件内便存在有外部符号引用 sum。编译器在编译该文件时,由于尚不清楚这些符号定义的真实所在位置,因此会使用默认值(比如 0)来作为它们在机器代码中的地址占位符。
到这里,我们能够得知,链接器的另一个重要作用便是在组合各个目标文件的同时,对我们上面提到的这些值进行修正。而这一过程的正确执行,便依赖于我在前面介绍的两个特殊 Section,即 .rela.data 与 .rela.text。
# readelf -r main.o
Relocation section '.rela.text' at offset 0x208 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000025 000600000004 R_X86_64_PLT32 0000000000000000 sum - 4
00000000002b 000300000002 R_X86_64_PC32 0000000000000000 d - 4
这两个特殊的 Section 通常也被称为“重定位表”,在它们的内部,以一行行表项的形式分别保存着链接器在重定位时需要在 .text 与 .data 中修改的具体位置和方式。这里每个表项中第一列的 Offset 属性,表明该重定向目标在对应 Section 中的偏移;Type 属性表明了重定位类型,即链接器在处理该重定位表项时,需要使用的特定方式;Sym.Value 属性为当前重定位符号的值;最后的 “Sym. Name + Addend” 属性为符号的名称,外加在计算该符号地址时需要被修正的量。
一旦链接器完成了符号解析这一步,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。重定位由两步组成:
(1)重定位节和符号定义:在这一步中,链接器将所有相同类型的节合并为同一个类型的新的聚合节。例如,来自所有输入模块的.data 节被全部合并成一个节,这个节成为输出的可执行目标文件的.data 节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
(2)重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们只想正确得运行时地址。要执行这一步,链接器依赖于可重定位目标模块中被称为重定位条目(relocation entry)得数据结构。
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用得任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终未知未知得目标引用,他就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码得重定位条目放在 .rel.text 中,已初始化数据的重定位条目放在 .rel.data 中。
对于重定位主要是 全局变量(外部变量),静态变量,全局函数(外部函数)和静态函数:
其中只有静态函数不需要重定位,全局变量(外部变量),静态变量,全局函数(外部函数)都需要重定位。
这是因为变量(全局变量,外部变量,静态变量,)的位置是在 data 段,由于 text 段和 data 段分属不同的段,在链接的时候大概率会进行重新排布,变量属于data 段,但是引用变量的位置属于 text 段,所以它和引用它的地方之间的相对位置就发生变化了。所以变量的地址就需要链接器来进行重定位了。
对于编译单元内部的静态函数,可以在编译时通过相对地址的办法,生成 call 指令,因为无论将来调用者和被调用者被安置到什么地方,它们之间的相对距离不会发生变化。
而其他类型的变量和函数在编译时,编译器并不知道它们的最终地址,所以只能使用占位符(比如 0)来临时代替目标地址。
而链接器的任务是为所有变量和函数分配地址,并把被分配到的地址回写到调用者处。链接的过程主要分为两步,第一步是多文件合并,同时为符号分配地址,第二步则是将符号的地址回写到引用它的地方。其中,地址回写有一个专门的名字叫做重定位。重定位的过程依赖目标文件中的重定位表。
三、重定位 demo 演示
3.1 外部函数重定位
下面演示了重定位全局函数的过程。
实验平台:
# cat /etc/os-release
NAME="Ubuntu"
VERSION="20.04.4 LTS (Focal Fossa)"
# uname -r
5.15.0-101-generic
# gcc -v
Thread model: posix
gcc version 9.4.0 (Ubuntu 9.4.0-1ubuntu1~20.04.2)
# cat main.c
int sum(int a, int b);
int main()
{
int a = 2;
int b = 6;
int c = sum(a, b);
return 0;
}
# cat sum.c
int sum(int a, int b)
{
return a+b;
}
# gcc -c main.c -o main.o
# objdump -d main.o
main.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 10 sub $0x10,%rsp
c: c7 45 f4 02 00 00 00 movl $0x2,-0xc(%rbp)
13: c7 45 f8 06 00 00 00 movl $0x6,-0x8(%rbp)
1a: 8b 55 f8 mov -0x8(%rbp),%edx
1d: 8b 45 f4 mov -0xc(%rbp),%eax
20: 89 d6 mov %edx,%esi
22: 89 c7 mov %eax,%edi
24: e8 00 00 00 00 call 29 <main+0x29>
29: 89 45 fc mov %eax,-0x4(%rbp)
2c: b8 00 00 00 00 mov $0x0,%eax
31: c9 leave
32: c3 ret
可以看到:
24: e8 00 00 00 00 call 29 <main+0x29>
main.o这个目标文件不知道 sum 符号的地址,在无法确定变量的真实地址时,先通过 0 来进行占位。call 指令同样是通过 0 来进行占位,全局变量的处理方式也是如此。
# readelf -r main.o
Relocation section '.rela.text' at offset 0x1e0 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000000025 000500000004 R_X86_64_PLT32 0000000000000000 sum - 4
备注这里可能看到这里是R_X86_64_PLT32 而不是R_X86_64_PC32,较新的GCC版本使用R_X86_64_PLT32而不是R_X86_64_PC32标记32位PC相关分支。
而我在 centos 7 ,gcc 4.8.5:
$ cat /etc/centos-release
CentOS Linux release 7.5.1804 (Core)
$ uname -r
3.10.0-862.el7.x86_64
$ gcc -v
Thread model: posix
gcc version 4.8.5 20150623 (Red Hat 4.8.5-44) (GCC)
$ readelf -r main.o
Relocation section '.rela.text' at offset 0x1e0 contains 1 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000021 000900000002 R_X86_64_PC32 0000000000000000 sum - 4
显示的是R_X86_64_PC32。
对于ubuntu20.04 ,gcc 9.4:
# readelf -r main.o
Relocation section '.rela.text' at offset 0x1e0 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000000025 000500000004 R_X86_64_PLT32 0000000000000000 sum - 4
.rela.text 的重定位表里存放了 text 段中需要进行重定位的每一处信息。所以,每个重定位项都会包含需要重定位的偏移、重定位类型和重定位符号。重定位表的数据结构是这样的:
typedef struct
{
Elf64_Addr r_offset; /* Address */
Elf64_Xword r_info; /* Relocation type and symbol index */
Elf64_Sxword r_addend; /* Addend */
} Elf64_Rela;
其中,r_info 的高 32bit 存放的是重定位符号在符号表的索引,r_info 的低 32bit 存放的是重定位的类型的索引。符号表就是.symtab 段,可以把它看成是一个字典,这个字典以整数为 key,以符号名为 value。
通过’.rela.text’内容可知:
r_offset = 0x25
r_info.symbol_index = 0x5 = sum
r_info.type = 0x4 = R_X86_64_PLT32
r_addend = -4
尽管重新定位条目的类型是R_X86_64_PLT32,链接器仍将使用R_X86_64_PC32计算(S + A – P)来修改重新定位目标,其中:
(1)这里的 S 表示完成链接后该符号的实际地址。在链接器将多个中间文件的段合并以后,每个符号就按先后顺序依次都会分配到一个地址,这就是它的最终地址 S。
(2)A 表示 Addend 的值,它代表了占位符的长度。
(3)P 表示要进行重定位位置的地址或偏移,可以通过 r_offset 的值获取到,这是引用符号的地方,也就是我们要回填地址的地方,简单说,它就是我们上文提到的用 0 填充的占位符的地址。
r_info.symbol_index = 0x5 = sum:
# readelf -s main.o
Symbol table '.symtab' contains 6 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 51 FUNC GLOBAL DEFAULT 1 main
4: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
5: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sum
r_info.type = 0x4 = R_X86_64_PLT32:
/* AMD x86-64 relocations. */
#define R_X86_64_NONE 0 /* No reloc */
#define R_X86_64_64 1 /* Direct 64 bit */
#define R_X86_64_PC32 2 /* PC relative 32 bit signed */
#define R_X86_64_GOT32 3 /* 32 bit GOT entry */
#define R_X86_64_PLT32 4 /* 32 bit PLT address */
# gcc main.c sum.c
# objdump -d a.out
a.out: file format elf64-x86-64
0000000000001129 <main>:
1129: f3 0f 1e fa endbr64
112d: 55 push %rbp
112e: 48 89 e5 mov %rsp,%rbp
1131: 48 83 ec 10 sub $0x10,%rsp
1135: c7 45 f4 02 00 00 00 movl $0x2,-0xc(%rbp)
113c: c7 45 f8 06 00 00 00 movl $0x6,-0x8(%rbp)
1143: 8b 55 f8 mov -0x8(%rbp),%edx
1146: 8b 45 f4 mov -0xc(%rbp),%eax
1149: 89 d6 mov %edx,%esi
114b: 89 c7 mov %eax,%edi
114d: e8 0a 00 00 00 call 115c <sum>
1152: 89 45 fc mov %eax,-0x4(%rbp)
1155: b8 00 00 00 00 mov $0x0,%eax
115a: c9 leave
115b: c3 ret
000000000000115c <sum>:
115c: f3 0f 1e fa endbr64
1160: 55 push %rbp
1161: 48 89 e5 mov %rsp,%rbp
1164: 89 7d fc mov %edi,-0x4(%rbp)
1167: 89 75 f8 mov %esi,-0x8(%rbp)
116a: 8b 55 fc mov -0x4(%rbp),%edx
116d: 8b 45 f8 mov -0x8(%rbp),%eax
1170: 01 d0 add %edx,%eax
1172: 5d pop %rbp
1173: c3 ret
1174: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1)
117b: 00 00 00
117e: 66 90 xchg %ax,%ax
这里 S 与 P 所表示的地址都是文件合并之后最终的虚拟地址,由于我们无法获取链接器中间过程的文件,所以,我们需要通过查看链接完成后的可执行文件,来寻找这两个地址。
a.out 是可执行程序,已经将main.o 和 sum.o 链接在一起,因此我们可以知道 S 和 P的地址:
P的地址:
main address = 0x1129
refaddr = main_address + r_offset
= 0x1129 + 0x25 = 0x114E
S的地址:
sum address = 0x115c
*refptr = S + A – P
= 0x115c + (-4) - 0x114E
= 0x0a
算出来的 main 函数 call sum 指令的 占位符等于 0x0a,接下来看看可执行文件反汇编:
# objdump -d a.out
a.out: file format elf64-x86-64
0000000000001129 <main>:
......
114d: e8 0a 00 00 00 call 115c <sum>
1152: 89 45 fc mov %eax,-0x4(%rbp)
......
000000000000115c <sum>:
可以看到只执行程序 main call sum指令的占位符 = 0x0a ,和我们用 S + A – P 求得值相同。
在运行时,CALL指令将存放在地址 0x114d处。当CPU执行 call 指令时,PC的值等于call 指令的下一条指令的地址:0x1152。
因此要执行这条指令,CPU执行以下步骤:
(1)将 PC 的值 0x1152 压入栈中。
(2)PC = 0x1152 + 0x0a = 0x115c
0x115c 就是 sum 的绝对运行地址。
重定位一个使用32位PC相对地址的引用,一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移值。当CPU执行一条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行值,得到有效地址(如CALL指令的目标),PC的值通常是下一条指令在内存中的地址。
指令中编码的32位 = 0x0a
PC的当前运行值 = 0x1152
CALL指令的目标地址 = 0x1152 + 0x0a = 0x115c
3.2 static 函数重定位
static_func,它是唯一不需要重定位的类型。
# cat 1.c
static int sum(int a, int b)
{
return a+b;
}
int main()
{
int a = 2;
int b = 6;
int c = sum(a, b);
return 0;
}
# gcc -c 1.c -o 1.o
# objdump -d 1.o
1.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <sum>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
e: 8b 55 fc mov -0x4(%rbp),%edx
11: 8b 45 f8 mov -0x8(%rbp),%eax
14: 01 d0 add %edx,%eax
16: 5d pop %rbp
17: c3 ret
0000000000000018 <main>:
18: f3 0f 1e fa endbr64
1c: 55 push %rbp
1d: 48 89 e5 mov %rsp,%rbp
20: 48 83 ec 10 sub $0x10,%rsp
24: c7 45 f4 02 00 00 00 movl $0x2,-0xc(%rbp)
2b: c7 45 f8 06 00 00 00 movl $0x6,-0x8(%rbp)
32: 8b 55 f8 mov -0x8(%rbp),%edx
35: 8b 45 f4 mov -0xc(%rbp),%eax
38: 89 d6 mov %edx,%esi
3a: 89 c7 mov %eax,%edi
3c: e8 bf ff ff ff call 0 <sum>
41: 89 45 fc mov %eax,-0x4(%rbp)
44: b8 00 00 00 00 mov $0x0,%eax
49: c9 leave
4a: c3 ret
0000000000000000 <sum>:
0000000000000018 <main>:
......
3c: e8 bf ff ff ff call 0 <sum>
......
对 static_func 的调用,所生成的指令的二进制是 e8 bf ff ff ff。其中,e8 是 callq 指令的编码,后边 4 个字节就对应被调函数的地址。注意,这里生成的 bf ff ff ff,如果采用小端的字节序数值来表示,应该是 0xffffffbf,也就是对应十进制的 -65。
此时,当 CPU 执行到 callq 这条指令时,rip 寄存器的值指向的是下一条指令的内存地址,也就是 41 这条指令的内存地址,通过计算 0x41 – 65 可以得到 0x0。从反汇编中可以得到,0x0 刚好是 static_func 的地址。
同一个编译单元内部,static_func 与 main 函数的相对位置是固定不变的,即便链接的过程中会对不同.o 文件中的代码段进行合并,但是同一个.o 文件内部不同函数之间的位置也会保持不变,因此,我们在编译的时候,就能确定对静态函数调用的偏移。也就是说,静态函数的调用地址在编译阶段就可以确定下来。
我们可以在最终生成的可执行文件的 main 函数中,查看对应位置代码的反汇编。可以验证的是,这里确实没有进行重定位的修正:
# objdump -d a.out
a.out: file format elf64-x86-64
......
0000000000001129 <sum>:
1129: f3 0f 1e fa endbr64
112d: 55 push %rbp
112e: 48 89 e5 mov %rsp,%rbp
1131: 89 7d fc mov %edi,-0x4(%rbp)
1134: 89 75 f8 mov %esi,-0x8(%rbp)
1137: 8b 55 fc mov -0x4(%rbp),%edx
113a: 8b 45 f8 mov -0x8(%rbp),%eax
113d: 01 d0 add %edx,%eax
113f: 5d pop %rbp
1140: c3 ret
0000000000001141 <main>:
1141: f3 0f 1e fa endbr64
1145: 55 push %rbp
1146: 48 89 e5 mov %rsp,%rbp
1149: 48 83 ec 10 sub $0x10,%rsp
114d: c7 45 f4 02 00 00 00 movl $0x2,-0xc(%rbp)
1154: c7 45 f8 06 00 00 00 movl $0x6,-0x8(%rbp)
115b: 8b 55 f8 mov -0x8(%rbp),%edx
115e: 8b 45 f4 mov -0xc(%rbp),%eax
1161: 89 d6 mov %edx,%esi
1163: 89 c7 mov %eax,%edi
1165: e8 bf ff ff ff call 1129 <sum>
116a: 89 45 fc mov %eax,-0x4(%rbp)
116d: b8 00 00 00 00 mov $0x0,%eax
1172: c9 leave
1173: c3 ret
1174: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1)
117b: 00 00 00
117e: 66 90 xchg %ax,%ax
......
0000000000001129 <sum>:
0000000000001141 <main>:
......
1165: e8 bf ff ff ff call 1129 <sum>
......
对于可执行二进制文件,对 static_func 的调用,所生成的指令的二进制是 e8 bf ff ff ff与目标文件相同。
四、补充
静态链接被分为两个步骤:符号解析与重定位。
其中,符号解析是指为应用程序使用的所有符号正确匹配对应符号定义的过程。当有重名的多个符号定义存在时,链接器会按照一定规则来选择适用的版本。而在重定位过程中,链接器会将输入的多个目标文件的同类型 Section 进行合并,并为它们和所有程序使用到的符号分配运行时的 VAS 地址。紧接着,借助重定位表中的信息,链接器可以对上一步中得到的外部符号,进行地址及值上的修正。
参考链接
https://intezer.com/blog/malware-analysis/executable-and-linkable-format-101-part-3-relocations/
https://software-dl.ti.com/codegen/docs/tiarmclang/rel3_1_0_STS/compiler_manual/intro_to_object_modules/how-the-linker-handles-sections-stdz0694245.html