静态链接与重定位
以下述c代码为例,我们讨论一下重定位的过程
1 2 3 4 5 6 7 8 9 int sum (int a, int b) ;int a = 1 ;int main () { return sum(a, 2 ); }
1 2 3 4 5 int sum (int a, int b) { return a + b; }
我们写一个最小可执行文件,程序入口地址是 _start
而不是
main
,没有用栈来存储数据,所以没有写保存栈帧等的部分,退出程序采用系统调用,sys_exit
的系统调用号是 60,rdi 为退出程序的返回值,汇编代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 section .data a dd 1 section .text global _start extern sum _start: mov edi , [a] mov esi , 2 call sum mov edi , eax mov eax , 60 syscall
1 2 3 4 5 6 7 section .textglobal sum sum: lea eax , [edi + esi ] ret
当操作数是变量/标签时,LEA计算的是该变量在内存中的地址
当操作数是寄存器时,LEA计算的是寄存器中的值
编译链接过程如下: 1 2 3 nasm -f elf64 main.asm -o main.o nasm -f elf64 sum.asm -o sum.o ld main.o sum.o -o proc
查看 main.o 的反汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ubuntu :~/test$ objdump -d main.omain .o: file format elf64-x86-64 Disassembly of section .text:0000000000000000 <_start>: 0 : 8 b 3 c 25 00 00 00 00 mov 0 x0,%edi 7 : be 02 00 00 00 mov $0 x2,%esi c : e8 00 00 00 00 call 11 <_start+0 x11> 11 : 89 c7 mov %eax,%edi 13 : b8 3 c 00 00 00 mov $0 x3c,%eax 18 : 0 f 05 syscall
查看 main.o 的符号表 :
1 2 3 4 5 6 7 8 ubuntu:~/test$ objdump -r main.o main.o: file format elf64-x86-64 RELOCATION RECORDS FOR [.text]: OFFSET TYPE VALUE 0000000000000003 R_X86_64_32S .data 000000000000000 d R_X86_64_PC32 sum -0 x0000000000000004
有两个需要重定位的符号,一个是数据段的地址,另一个是 sum
函数的地址,其中的 OFFSET(偏移)的查看方式是: 1 2 地址: 0 1 2 3 4 5 6 机器码 8b 3c 25 00 00 00 00 mov 0x0,%edi
这一句原本的汇编是:mov edi, [a]
即将变量a的值存入寄存器edi,所以我们需要知道变量a的地址,即数据段的起始地址。但这在编译时是未知的,因此用
0 来填充,即地址 3~6
的部分。所以OFFSET是3,因为这是链接器需要修改的指令的部分的起始地址
类型 R_X86_64_32S 是32位绝对地址,值为 .data,即告诉链接器将标签
.data 的地址写入偏移为 0x3 的地方
R_X86_64_PC32 是32位PC相对寻址,值为 sum -
4,即告诉链接器将(标签sum的地址 - (.text + OFFSET +
4))写入偏移为0xd的地方,其中 4 为
sum函数地址的字节数,因为运行到这条指令时,PC已经指向下一条指令,而
.text + OFFSET + 4 =
下一条指令的起始地址,这样运行到这条指令时,就会跳转到 PC + sum - (.text
+ OFFSET + 4) = PC + sum - PC = sum函数的地址,从而正确跳转
查看链接后的程序符号表,和段地址:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 ubuntu:~/test$ readelf -l -s proc Elf file type is EXEC (Executable file) Entry point 0x401000 There are 3 program headers, starting at offset 64Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 0x00000000000000e8 0x00000000000000e8 R 0x1000 LOAD 0x0000000000001000 0x0000000000401000 0x0000000000401000 0x0000000000000025 0x0000000000000025 R E 0x1000 LOAD 0x0000000000002000 0x0000000000402000 0x0000000000402000 0x0000000000000004 0x0000000000000004 RW 0x1000 Section to Segment mapping: Segment Sections... 00 01 .text 02 .data Symbol table '.symtab' contains 9 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS main.asm 2: 0000000000402000 0 NOTYPE LOCAL DEFAULT 2 a 3: 0000000000000000 0 FILE LOCAL DEFAULT ABS sum.asm 4: 0000000000401020 0 NOTYPE GLOBAL DEFAULT 1 sum 5: 0000000000401000 0 NOTYPE GLOBAL DEFAULT 1 _start 6: 0000000000402004 0 NOTYPE GLOBAL DEFAULT 2 __bss_start 7: 0000000000402004 0 NOTYPE GLOBAL DEFAULT 2 _edata 8: 0000000000402008 0 NOTYPE GLOBAL DEFAULT 2 _end
我们可以看到,链接时
代码段(.text ) 的起始地址是 0x401000,从这一行
LOAD 0x0000000000001000 0x0000000000401000 0x0000000000401000
数据段(.data) 的起始地址是 0x402000,从这一行
LOAD 0x0000000000002000 0x0000000000402000 0x0000000000402000
sum 的地址是 0x401020,从这一行
4: 0000000000401020 0 NOTYPE GLOBAL DEFAULT 1 sum
其中第一段(没有名字的那一段)是 ELF头和其他信息
依据之前的分析,OFFSET为 0x3 的机器码处应该修改为 a 的地址,即
0x402000
OFFSET为 0xd 的机器码处应该修改为 sum 的相对PC偏移地址,即 0x401020 -
(0x401000 + 0xd + 0x4) = 0xf
我们查看一下 proc 的反汇编来验证一下结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ubuntu :~/test$ objdump -d procproc : file format elf64-x86-64 Disassembly of section .text:0000000000401000 <_start>: 401000 : 8 b 3 c 25 00 20 40 00 mov 0 x402000,%edi 401007 : be 02 00 00 00 mov $0 x2,%esi 40100c : e8 0 f 00 00 00 call 401020 <sum> 401011 : 89 c7 mov %eax,%edi 401013 : b8 3 c 00 00 00 mov $0 x3c,%eax 401018 : 0 f 05 syscall 40101a : 66 0 f 1 f 44 00 00 nopw 0 x0(%rax,%rax,1 ) 0000000000401020 <sum>: 401020 : 67 8 d 04 37 lea (%edi,%esi,1 ),%eax 401024 : c3 ret
可以看到偏移为 3、4、5、6 处的机器码为 00 20 40 00
,小端序(高高低低),读取后为 00 40 20 00
偏移为 d、e、f、0x10处的机器码为 0f 00 00 00,小端序,读取后为 00 00 00
0f
均正确
那么为什么代码段加载至 0x401000 呢?我们可以用
ld --verbose
命令查看链接器的默认脚本内容,其中有如下内容(只截取部分):
这里指定了程序入口点是 _start
而不是我们熟知的
main
这里设置了可执行文件在内存中的起始地址(包括 ELF
头),从0x400000开始 1 2 PROVIDE (__executable_start = SEGMENT_START("text-segment" , 0x400000 ));. = SEGMENT_START("text-segment" , 0x400000 ) + SIZEOF_HEADERS;
PROVIDE
创建一个符号,但仅当该符号未被其他地方定义时才生效,基本语法
PROVIDE(symbol = expression);
SEGMENT_START("segment-name",
default-value),如果通过 -Ttext-segment=addr
等链接器选项指定了地址,返回指定的地址,否则返回 default-value 参数值
1 2 3 4 5 6 7 ld main.o -o proc ld main.o -o proc -Ttext-segment=0x500000
这里指定了各个段的对齐方式为页对齐(4KB对齐) 1 . = ALIGN(CONSTANT (MAXPAGESIZE))
ALIGN(value)返回下一个按value字节对齐的地址
由于我们是最小可执行文件,没有链接动态库,且是静态链接,所以很多段都不存在,简化版的大致结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 SECTIONS { PROVIDE (__executable_start = SEGMENT_START("text-segment" , 0x400000 )) . = SEGMENT_START("text-segment" , 0x400000 ) + SIZEOF_HEADERS . = ALIGN (CONSTANT (MAXPAGESIZE)) .text : { *(.text ) } . = ALIGN (CONSTANT(MAXPAGESIZE)) .data : { *(.data ) } }
上述过程虽实现简单,运行时无额外开销,但是调用外部函数时需要在链接时就确定函数地址,我们在正常写程序的过程中,经常会调用c标准库的一些函数如
printf
,按照上述方式的静态链接,printf
的代码会被复制到每个运行进程的代码段当中,浪费系统的内存资源,因此我们考虑能否共享
动态链接与重定位
共享库是致力于解决静态库缺陷的一个现代创新产物,共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器的程序来执行的。共享库在Linux系统中通常用.so后缀来表示,微软的操作系统也大量地使用了共享库,它们称为DLL(动态链接库)。
在任何给定的文件系统中,对于一个库只有一个.so文件,所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行文件中。链接器会复制一些重定位和符号表的信息,使得运行时可以解析对.so文件中代码和数据的引用。
为了更好的展示,我们在 sum 里多加几个函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 section .textglobal sumglobal mul global sub sum: lea eax , [edi + esi ] ret mul: mov eax , edi imul eax , esi ret sub: mov eax , edi sub eax , esi ret
重新编译,并将 sum.o
链接成一个共享库:
1 2 ubuntu:~/test $ nasm -f elf64 sum .asm -o sum .o ubuntu:~/test $ ld -shared sum .o -o libsum.so
修改 main.asm
为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 section .data a dd 1 section .text global _start extern sum extern sub _start: mov edi , [a] mov esi , 2 call sum wrt ..plt mov edi , eax call sub wrt ..plt mov edi , eax mov eax , 60 syscall
链接时需指定动态链接器,Linux系统在运行时查找共享库有几个默认的位置(如/lib,
/usr/lib等),但当前目录(.)不在默认搜索路径中,使用 rpath 添加路径,
$ORIGIN 表示可执行文件所在的目录
1 2 ubuntu:~/test$ nasm -f elf64 main .asm -o main .o ubuntu:~/test$ ld main .o libsum.so -o prog --dynamic-linker /lib64/ld-linux-x86-64 .so .2 -rpath \$ORIGIN
查看 main.o 的重定位表
1 2 3 4 5 6 7 8 9 ubuntu:~/test$ objdump -r main.o main.o: file format elf64-x86-64 RELOCATION RECORDS FOR [.text]: OFFSET TYPE VALUE 0000000000000003 R_X86_64_32S .data 000000000000000 d R_X86_64_PLT32 sum -0 x00000000000000040000000000000014 R_X86_64_PLT32 sub-0 x0000000000000004
我们发现与之前相比,sum的重定位条目的类型从 R_X86_64_PC32 变成了
R_X86_64_PLT32
查看 prog 的反汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 ubuntu :~/test$ objdump -d progprog : file format elf64-x86-64 Disassembly of section .plt:0000000000401000 <sum@plt-0 x10>: 401000 : ff 35 ea 1 f 00 00 push 0 x1fea(%rip) # 402 ff0 <_GLOBAL_OFFSET_TABLE_+0 x8> 401006 : ff 25 ec 1 f 00 00 jmp *0 x1fec(%rip) # 402 ff8 <_GLOBAL_OFFSET_TABLE_+0 x10> 40100c : 0 f 1 f 40 00 nopl 0 x0(%rax) 0000000000401010 <sum@plt>: 401010 : ff 25 ea 1 f 00 00 jmp *0 x1fea(%rip) # 403000 <sum> 401016 : 68 00 00 00 00 push $0 x0 40101b : e9 e0 ff ff ff jmp 401000 <sum@plt-0 x10> 0000000000401020 <sub@plt>: 401020 : ff 25 e2 1 f 00 00 jmp *0 x1fe2(%rip) # 403008 <sub> 401026 : 68 01 00 00 00 push $0 x1 40102b : e9 d0 ff ff ff jmp 401000 <sum@plt-0 x10> Disassembly of section .text:0000000000401030 <_start>: 401030 : 8 b 3 c 25 10 30 40 00 mov 0 x403010,%edi 401037 : be 02 00 00 00 mov $0 x2,%esi 40103c : e8 cf ff ff ff call 401010 <sum@plt> 401041 : 89 c7 mov %eax,%edi 401043 : e8 d8 ff ff ff call 401020 <sub@plt> 401048 : 89 c7 mov %eax,%edi 40104a : b8 3 c 00 00 00 mov $0 x3c,%eax 40104f : 0 f 05 syscall
PLT表的结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ; PLT[0]:PLT 表的第一项,也就是 sum@plt-0x10 401000: ff 35 ea 1f 00 00 push 0x1fea(%rip) 401006: ff 25 ec 1f 00 00 jmp *0x1fec(%rip) 40100c: 0f 1f 40 00 nopl 0x0(%rax) ; PLT[1]:sum 函数的 PLT 项 401010: ff 25 ea 1f 00 00 jmp *0x1fea(%rip) 401016: 68 00 00 00 00 push $0x0 40101b: e9 e0 ff ff ff jmp 401000 ; PLT[2]: sub 函数的 PLT 项 401020: ff 25 e2 1f 00 00 jmp *0x1fe2(%rip) 401026: 68 01 00 00 00 push $0x1 40102b: e9 d0 ff ff ff jmp 401000
查看动态重定位记录,可以看到 sum 是第一条记录,所以 sum
的重定位索引是 0, sub 是第二条记录,所以 sub 的重定位索引是 1
1 2 3 4 5 6 7 8 ubuntu:~/test $ objdump -R prog prog : file format elf64-x86-64DYNAMIC RELOCATION RECORDS OFFSET TYPE VALUE 0000000000403000 R_X86_64_JUMP_SLOT sum 0000000000403008 R_X86_64_JUMP_SLOT sub
上面的 OFFSET (0x403000等) 是 GOT
表项的地址,也就是需要被修改的位置
查看 GOT 表的内容:
1 2 3 4 5 6 7 ubuntu:~/test$ readelf -x .got.plt prog Hex dump of section '.got.plt' : NOTE: This section has relocations against it, but these have NOT been applied to this dump. 0x00402fe8 b82e4000 00000000 00000000 00000000 ..@... ... ... ... . 0x00402ff8 00000000 00000000 16104000 00000000 ... ... ... .@... .. 0x00403008 26104000 00000000 &.@... ..
GOT[0](0x402fe8): .dynamic 段的地址(b82e4000 00000000 (小端序)
=> 0x402eb88)
GOT[1](0x402ff0): 包含动态链接器标识信息,初值为(00000000
00000000),加载时动态链接器会填充为正确信息
GOT[2](0x402ff8):
动态链接器用于解析函数地址的入口点,初值为(00000000
00000000),加载时动态链接器会填充为正确信息
GOT[3](0x403000): 初值为 PLT[1] 的第二条指令的地址(16104000
00000000 => 0x401016),解析后为 sum 函数的地址
GOT[4](0x403008): 初值为 PLT[2] 的第二条指令的地址(26104000
00000000 => 0x401026),解析后为 sub 函数的地址
所以当程序首次调用 sum 函数时,执行流程如下:
跳转到 PLT[1],执行 jmp *0x1fea(%rip),此时 GOT[3] 中存放的是 PLT[1]
的第二条指令地址(0x401016)
跳转到 0x401016,执行 push $0x0,将重定位索引压栈
跳转到 PLT[0],执行:
push GOT[1] - 压入动态链接器需要的信息
jmp *GOT[2] - 跳转到动态链接器的函数解析例程
动态链接器的解析过程:
动态链接器根据重定位索引找到对应的重定位记录
查找符号,解析出 sum 函数的实际地址
将该地址写入 GOT[3]
跳转到解析出的地址,执行 sum 函数
后续若再次调用 sum 函数时,PLT[1] 中的 jmp *GOT[3]
会直接跳转到已解析的函数地址,不再需要经过动态链接器,提高了执行效率