链接与重定位

静态链接与重定位

以下述c代码为例,我们讨论一下重定位的过程

1
2
3
4
5
6
7
8
9
// main.c
int sum(int a, int b);

int a = 1;

int main()
{
return sum(a, 2);
}

1
2
3
4
5
// sum.c
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
; main.asm
section .data
a dd 1

section .text
global _start
extern sum

_start:
mov edi, [a]
mov esi, 2
call sum

;exit
mov edi, eax
mov eax, 60
syscall
1
2
3
4
5
6
7
; sum.asm
section .text
global 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.o

main.o: file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <_start>:
0: 8b 3c 25 00 00 00 00 mov 0x0,%edi
7: be 02 00 00 00 mov $0x2,%esi
c: e8 00 00 00 00 call 11 <_start+0x11>
11: 89 c7 mov %eax,%edi
13: b8 3c 00 00 00 mov $0x3c,%eax
18: 0f 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
000000000000000d R_X86_64_PC32 sum-0x0000000000000004

有两个需要重定位的符号,一个是数据段的地址,另一个是 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 64

Program 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 proc

proc: file format elf64-x86-64


Disassembly of section .text:

0000000000401000 <_start>:
401000: 8b 3c 25 00 20 40 00 mov 0x402000,%edi
401007: be 02 00 00 00 mov $0x2,%esi
40100c: e8 0f 00 00 00 call 401020 <sum>
401011: 89 c7 mov %eax,%edi
401013: b8 3c 00 00 00 mov $0x3c,%eax
401018: 0f 05 syscall
40101a: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)

0000000000401020 <sum>:
401020: 67 8d 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

    1
    ENTRY(_start)

  • 这里设置了可执行文件在内存中的起始地址(包括 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
    # 默认从 0x400000 开始

    # 指定地址
    ld main.o -o proc -Ttext-segment=0x500000
    # 从 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 {
/* 设置程序起始地址为0x400000 */
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000));
/* 实际代码段起始地址需要加上头部大小 */
. = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;

/* 移动位置计数器到代码段起始位置 */
. = ALIGN(CONSTANT (MAXPAGESIZE)); /* 确保页对齐到0x401000 */

.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
; sum.asm
section .text
global sum
global 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
; main.asm
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

;exit
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
000000000000000d R_X86_64_PLT32 sum-0x0000000000000004
0000000000000014 R_X86_64_PLT32 sub-0x0000000000000004

我们发现与之前相比,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 prog

prog: file format elf64-x86-64


Disassembly of section .plt:

0000000000401000 <sum@plt-0x10>:
401000: ff 35 ea 1f 00 00 push 0x1fea(%rip) # 402ff0 <_GLOBAL_OFFSET_TABLE_+0x8>
401006: ff 25 ec 1f 00 00 jmp *0x1fec(%rip) # 402ff8 <_GLOBAL_OFFSET_TABLE_+0x10>
40100c: 0f 1f 40 00 nopl 0x0(%rax)

0000000000401010 <sum@plt>:
401010: ff 25 ea 1f 00 00 jmp *0x1fea(%rip) # 403000 <sum>
401016: 68 00 00 00 00 push $0x0
40101b: e9 e0 ff ff ff jmp 401000 <sum@plt-0x10>

0000000000401020 <sub@plt>:
401020: ff 25 e2 1f 00 00 jmp *0x1fe2(%rip) # 403008 <sub>
401026: 68 01 00 00 00 push $0x1
40102b: e9 d0 ff ff ff jmp 401000 <sum@plt-0x10>

Disassembly of section .text:

0000000000401030 <_start>:
401030: 8b 3c 25 10 30 40 00 mov 0x403010,%edi
401037: be 02 00 00 00 mov $0x2,%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 3c 00 00 00 mov $0x3c,%eax
40104f: 0f 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) # 压入 GOT[1]
401006: ff 25 ec 1f 00 00 jmp *0x1fec(%rip) # 跳转到 GOT[2]
40100c: 0f 1f 40 00 nopl 0x0(%rax) # 对齐用的 NOP

; PLT[1]:sum 函数的 PLT 项
401010: ff 25 ea 1f 00 00 jmp *0x1fea(%rip) # 跳转到 GOT[3]
401016: 68 00 00 00 00 push $0x0 # 压入重定位索引(0)
40101b: e9 e0 ff ff ff jmp 401000 # 跳转到 PLT[0]

; PLT[2]: sub 函数的 PLT 项
401020: ff 25 e2 1f 00 00 jmp *0x1fe2(%rip) # 跳转到 GOT[4]
401026: 68 01 00 00 00 push $0x1 # 压入重定位索引(1)
40102b: e9 d0 ff ff ff jmp 401000 # 跳转到 PLT[0]

查看动态重定位记录,可以看到 sum 是第一条记录,所以 sum 的重定位索引是 0, sub 是第二条记录,所以 sub 的重定位索引是 1

1
2
3
4
5
6
7
8
ubuntu:~/test$ objdump -R prog

prog: file format elf64-x86-64

DYNAMIC 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 函数时,执行流程如下:

  1. 跳转到 PLT[1],执行 jmp *0x1fea(%rip),此时 GOT[3] 中存放的是 PLT[1] 的第二条指令地址(0x401016)
  2. 跳转到 0x401016,执行 push $0x0,将重定位索引压栈
  3. 跳转到 PLT[0],执行:
    • push GOT[1] - 压入动态链接器需要的信息
    • jmp *GOT[2] - 跳转到动态链接器的函数解析例程
  4. 动态链接器的解析过程:
    • 动态链接器根据重定位索引找到对应的重定位记录
    • 查找符号,解析出 sum 函数的实际地址
    • 将该地址写入 GOT[3]
    • 跳转到解析出的地址,执行 sum 函数

后续若再次调用 sum 函数时,PLT[1] 中的 jmp *GOT[3] 会直接跳转到已解析的函数地址,不再需要经过动态链接器,提高了执行效率