理解C语言-3:编译链接

简介

上一篇介绍了C语言如何通过语句操作数据。本篇依旧贯彻解决”为什么”的原则,以终为始,从操作系统执行程序所需的内存布局入手,反推在源代码和最终可执行文件之间,编译和链接子系统需要做哪些工作。

程序的内存镜像

如果从操作系统的角度来看问题,执行程序其实很无脑:把要执行的一堆机器指令和相关数据加载进内存,然后跳转到对应的入口执行,over!

这看似简单的一件事,背后却隐含了下列条件:

  1. 需要机器指令而不是C语言
  2. 这堆指令/数据之间的跳转和引用应该是完整和正确的
  3. 为了避免重复工作,已经得到的指令和数据应该以最方便加载进内存的方式持久化
  4. 需要指定唯一入口

这就暗示了在执行C代码前,我们需要把它翻译成一个特定格式的可执行文件。这个文件和加载进内存之后得到的内存镜像应该尽可能相似。

在讨论内存镜像前,先给虚拟内存做个简单介绍:

虚拟内存

虚拟内存是操作系统针对内存资源设计的关键机制,处于物理内存和应用程序中间。它的存在带来很多好处:

  1. 使得应用程序的地址空间保持独立
    每个应用程序对应一个独立的虚拟内存空间(32bit地址的虚拟空间为4G),大大简化了程序内存布局的设计(只需要考虑自身)。
  2. 突破物理内存的大小限制
    由于程序在同一时间点需要的内存总是自身虚拟内存空间的子集,操作系统通过调页回收页机制保证只让每个进程的热点页进驻物理内存。因此即使物理内存远远小于4G,多个程序也能正常执行(不过频繁调页会影响性能)。
  3. 节省物理内存开销
    写时复制(CoW)机制可以将相同的物理内存页映射进多个进程自身的虚拟地址空间中,大量节省了物理内存的开销。这是动态共享库技术出现的基础。

内存镜像

内存镜像是程序代码和数据在虚拟内存地址空间中的具体分布。这里借用《计算机系统: 一位程序员的视角》中的一张图(图1)来展示其大致结构:
图1 C运行时内存镜像

需要说明的重点是:

  • 分段
    操作系统将虚拟地址空间分段管理,不同的段拥有不同的权限(读/写/可执行),提高了安全性。
    段里的数据既可以是从可执行文件中加载的内容(例如代码段.text,数据段.data/.bss),也可以预留为程序执行所依赖的数据结构(堆栈),或者供程序运行时动态分配(堆)。
  • 段分布遵循平台特定ABI
    1. 整个地址空间分为普通程序可用的部分和内核使用部分(相互隔离,提升安全性)
    2. 代码段起始地址固定(不同ABI可能不同),数据段紧跟代码段分配
    3. 堆栈由高向低地址增长
    4. 堆由特定地址由低向高增长

工程效率

懒惰是人类进步的根本动力。

现在假设我们就是C程序的设计者,之前已经制定了C语言的语法,也明确了特定平台执行程序需要的内存镜像。
于是我们再接再厉,写了一个可以把C源码直接翻译成内存镜像的程序(姑且就叫终结者吧)。
终结者生成的可执行文件中包括:

  • 数据区
  • 代码区
    通过偏移量引用全局变量和函数,并拥有固定的入口地址。
  • 数据区和代码区在虚拟内存空间中的绝对起始地址
    运行程序时操作系统把它们加载至对应地址并直接执行。

一切很完美,大家很满意。

可是随着时间的推移,效率问题开始凸显:
一方面,单个C代码文件越来越大,终结者相应的处理时间也越来越长。
另一方面,在不同项目的C代码中出现越来越多功能重复的函数。

这是由软件规模增长带来的问题,因此我们在源头上寻求解决方案。

拆分策略

经过分析,很容易发现可以将项目按功能划分成不同模块,分别维护。并且在不同的项目中复用他们。
这是典型的分治思想,终结者可以单独处理发生变化的源文件,一石二鸟地解决上文提到的那些问题。

可是拆分策略也带来了新问题:
我们把变量和函数称作符号,

  1. 原本属于同一个文件的全局符号,现在其定义和引用可能分布于不同的文件中。终结者该如何在一个文件的代码段中写入另一个文件中的符号地址呢?
  2. 组合同一个项目中的不同文件时如何协调属于不同文件的代码段地址?
  3. 如何充分利用操作系统提供的写时复制(CoW)机制,减少函数库的内存占用?

这些问题都指向一个答案:
需要把终结者的工作拆分成两个阶段:

  1. 编译
    将C源文件翻译成包含符号表的可重定位文件(符号的地址为相对于section的offset)。
  2. 链接
    合并多个可重定位文件,分析并确认每个符号的定义地址,并用定义地址覆写所有对这个符号的引用。

接口策略

在演化出编译和链接之后,我们发现编译过程其实只需要外部(非本模块定义)符号的声明(提供类型信息)。
而这些声明的实质就是一种接口策略的应用,把可能发生变化的代码实现部分与不变的声明分隔开,提升了项目的开发效率。

从源代码到可执行

根据上文的铺垫,图2展示了现实中C语言源代码在执行前所需要经历的阶段:
图2 C编译链接流程

阶段说明

接下来一步步说明图2中的每个阶段,为了方便,我们使用以下的源码作为例子:

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
35
36
37
38
39
40
41
42
43
// compile_link.c
#include "compile_link2.h"
#include "compile_link3.h"

int global_a = 1;
int global_b;
int global_c;
int global_d;
static int static_e = 1;

int global_func(int a);

int global_func(int a) {
return 1;
}

int main() {
int x = 1;
int y = global_func(1);
int z = extern_func(4);

global_b = extern_func3(0);
global_c = extern_func(3);
global_d = extern_f;
return static_e;
}

// compile_link2.h
int extern_func(int a) {
return a;
}

int extern_f = 10;

// compile_link3.h
int extern_func3(int a);

// compile_link3.c
extern int global_b;

int extern_func3(int a) {
return a + global_b;
}

预处理

预处理阶段是针对源文件进行文本替换的操作,输入是C源码,输出还是C源码。

include指令

1
#include <a.h>

通过include指令可以将a.h文件包含的内容完整插入当前位置。
上文提到的接口策略鼓励人们把模块对应的声明和常量单独放入头文件中(一般以.h结尾)并发布。

执行

1
gcc -E compile_link.c -o compile_link.i

得到

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
35
36
37
38
39
# 1 "compile_link.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 1 "<command-line>" 2
# 1 "compile_link.c"
# 1 "compile_link2.h" 1
int extern_func(int a) {
return a;
}

int extern_f = 10;
# 2 "compile_link.c" 2
# 1 "compile_link3.h" 1
int extern_func3(int a);
# 3 "compile_link.c" 2

int global_a = 1;
int global_b;
int global_c;
int global_d;
static int static_e = 1;

int global_func(int a);

int global_func(int a) {
return 1;
}

int main() {
int x = 1;
int y = global_func(1);
int z = extern_func(4);

global_b = extern_func3(0);
global_c = extern_func(3);
global_d = extern_f;
return static_e;
}

include如我们预期正常工作,可以发现include包含的文件内容可以不局限于声明。

define指令

1
2
#define SYM a+b // replace all SYM with a+b
#define MACRO(a, b) a+b // replace all MACRO() with a+b

define指令定义宏替换,可以简单理解为文本的正则替换。
除了常量,还可以定义宏函数,消除函数的调用开销:

1
2
3
4
5
#define MACRO(a, b) a+b

int main() {
int c = MACRO(1 ,2);
}

经过预处理后得到:

1
2
3
int main() {
int c = 1 +2;
}

注意1之后有一个空格(a=”1 “)。因此为了保证语义正确,一般将宏定义用括号括起:

1
#define MACRO(a, b) ((a)+(b))

由于宏处理的是代码,还可以用宏函数当作代码模版来生成C源码。这里展示一种初始化数组的技巧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// data.def
DEFINEDATA (data1_attr1, data1_attr2, data1_attr3)
DEFINEDATA (data2_attr1, data2_attr2, data2_attr3)

// enum.c
#define DEFINEDATA(a1, a2, a3, a4) a1
attr1_type attr1_array[] = {
#include data.def
};

#define DEFINEDATA(a1, a2, a3, a4) a2
attr2_type attr2_array[] = {
#include data.def
};

预处理还有其它一些指令,例如条件编译等,这里不再一一赘述。

编译

编译阶段负责将C源码转换成目标平台的汇编代码。不同的目标平台会得到不同的结果。

1
2
3
4
5
6
// compile_link3.c
extern int global_b;

int extern_func3(int a) {
return a + global_b;
}

执行

1
gcc -S compile_link3.c -o compile_link3.s

编译得到的汇编代码(后缀s代表source)如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
	.file	"compile_link3.c" ; logical filename
.text ; text section starts
.globl extern_func3 ; define global symbol
.type extern_func3, @function ; type of global symbol is function
extern_func3: ; label
.LFB0: ; local function start label
.cfi_startproc ; beginning of function that should have an entry in .eh_frame
pushq %rbp
.cfi_def_cfa_offset 16 ; CFA = current_location + 16 (previous rip + rbp)
.cfi_offset 6, -16 ; Previous value of register 6(rbp) is saved at CFA -16
movq %rsp, %rbp
.cfi_def_cfa_register 6 ; register 6(rbp) will be used for computing CFA
movl %edi, -4(%rbp)
movl global_b(%rip), %edx ; gloabl_b is symbol with value of offset address
movl -4(%rbp), %eax
addl %edx, %eax
popq %rbp
.cfi_def_cfa 7, 8 ; CFA = register 7(rsp) + 8 after pop rbp
ret
.cfi_endproc ; end of function
.LFE0: ; local function end label
.size extern_func3, .-extern_func3 ; size = current_location - label_extern_func3
.ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-39)" ; assembler tags
.section .note.GNU-stack,"",@progbits ; add section .note.GNU-stack progbits means contains data

所有以”:”结尾的字符串都是标签,其中以”.L”开头的是汇编器使用的本地标签。
所有以”.”开头的指令都是用来指导汇编器和链接器后续操作的指令

需要特别说明的是,以.cfi开头的指令记录了符合DWARF格式的调用栈调试信息(Call Frame Information),这些信息将被汇编器收集进.eh_frame区,用于错误处理(C++ ABI引入),异或收集进.debug开头的区中供调试器使用。

另一个源文件编译得到的汇编代码如下:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
// compile_link
.file "compile_link.c"
.text ; text section starts
.globl extern_func
.type extern_func, @function
extern_func:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -4(%rbp)
movl -4(%rbp), %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size extern_func, .-extern_func
.globl extern_f
.data ; data section starts
.align 4 ; align by 4 bytes
.type extern_f, @object ; type of extern_f symbol is data
.size extern_f, 4
extern_f:
.long 10 ; value of extern_f
.globl global_a
.align 4
.type global_a, @object
.size global_a, 4
global_a:
.long 1 ; value of extern_f
.comm global_b,4,4 ; uninitialized data with 4 bytes size aligned by 4 bytes
.comm global_c,4,4
.comm global_d,4,4
.align 4
.type static_e, @object
.size static_e, 4
static_e:
.long 1
.text ; text section starts
.globl global_func
.type global_func, @function
global_func:
.LFB1:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -4(%rbp)
movl $1, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1:
.size global_func, .-global_func
.globl main
.type main, @function
main:
.LFB2:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp // allocate local variables
movl $1, -4(%rbp) // x = 1
movl $1, %edi // set argument a = 1 using edi
call global_func
movl %eax, -8(%rbp) // y = global_func(1)
movl $4, %edi // set argument a = 4 using edi
call extern_func
movl %eax, -12(%rbp) // z = extern_func(4)
movl $0, %edi // set argument a = 0 using edi
call extern_func3
movl %eax, global_b(%rip) // global_b = extern_func3(0)
movl $3, %edi // set argument a = 3 using edi
call extern_func
movl %eax, global_c(%rip) // global_c = extern_func(3)
movl extern_f(%rip), %eax
movl %eax, global_d(%rip) // global_d = extern_f
movl static_e(%rip), %eax // return static_e
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE2:
.size main, .-main
.ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-39)"
.section .note.GNU-stack,"",@progbits

可以发现,全局变量(初始化/未初始化)和函数都是符号,并且全局变量在代码段中通过相对rip的offset引用。

汇编

汇编阶段的目标是为每份源文件生成对应的可重定位目标文件。目标文件有多种格式,Linux最常用的是ELF。
在ELF目标文件的典型结构如图3所示:
图3 ELF结构

其中最重要的是区块表(section header table),符号表(.symtab)和重定向条目(.rel.text/.rel.data),它们直接服务于链接。
.data/.bss包含了对应的数据,.text区块包含了上一阶段汇编代码直接翻译得到的机器码。

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
// compile_link3.o
0000000000000000 <extern_func3>:
push %rbp
mov %rsp,%rbp
mov %edi,-0x4(%rbp)
mov 0x0(%rip),%edx # d <extern_func3+0xd>
mov -0x4(%rbp),%eax
add %edx,%eax
pop %rbp
retq

// compile_link.o
000000000000001a <main>:
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
movl $0x1,-0x4(%rbp)
mov $0x1,%edi
callq 33 <main+0x19>
mov %eax,-0x8(%rbp)
mov $0x4,%edi
callq 40 <main+0x26>
mov %eax,-0xc(%rbp)
mov $0x0,%edi
callq 4d <main+0x33>
mov %eax,0x0(%rip) # 53 <main+0x39>
mov $0x3,%edi
callq 5d <main+0x43>
mov %eax,0x0(%rip) # 63 <main+0x49>
mov 0x0(%rip),%eax # 69 <main+0x4f>
mov %eax,0x0(%rip) # 6f <main+0x55>
mov 0x0(%rip),%eax # 75 <main+0x5b>
leaveq
retq

.eh_frame区块包括了调用栈调试信息。

区块表

区块表记录了每个区块的大小和位置(本文件中的相对位置)。

执行

1
2
gcc -c compile_link3.c -o compile_link3.o
objdump -h compile_link3.o

编译并查看区块表:

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000014 0000000000000000 0000000000000000 00000040 20
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 00000054 2
0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000054 20
ALLOC
3 .comment 0000002e 0000000000000000 0000000000000000 00000054 2
0
CONTENTS, READONLY
4 .note.GNU-stack 00000000 0000000000000000 0000000000000000 00000082 20
CONTENTS, READONLY
5 .eh_frame 00000038 0000000000000000 0000000000000000 00000088 2
3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA

符号表

符号表中记录了所有出现在本文件中的符号(不包括本地变量符号,它们不需要重定向),其重要属性包括:

  • 符号名称
    注意section名称以及文件名也包括在符号表中。

  • 类型
    数据/函数/文件名/区块名

  • 所属区块
    指向该符号所属区块,另外包括三个虚拟区块:

    • ABS(无需重定向)
    • COMM(未初始化)
    • UNDEF(外部引用)
  • 值(地址)
    符号相对于区块的offset

    compile_link3.o的符号表如下

    Symbol table ‘.symtab’ contains 10 entries:
    Num: Value Size Type Bind Vis Ndx Name
    0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
    1: 0000000000000000 0 FILE LOCAL DEFAULT ABS compile_link3.c
    2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
    3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
    4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
    5: 0000000000000000 0 SECTION LOCAL DEFAULT 6
    6: 0000000000000000 0 SECTION LOCAL DEFAULT 7
    7: 0000000000000000 0 SECTION LOCAL DEFAULT 5
    8: 0000000000000000 20 FUNC GLOBAL DEFAULT 1 extern_func3
    9: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND global_b

compile_link.o的符号表如下

Symbol table ‘.symtab’ contains 18 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS compile_link.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000008 4 OBJECT LOCAL DEFAULT 3 static_e
6: 0000000000000000 0 SECTION LOCAL DEFAULT 6
7: 0000000000000000 0 SECTION LOCAL DEFAULT 7
8: 0000000000000000 0 SECTION LOCAL DEFAULT 5
9: 0000000000000000 12 FUNC GLOBAL DEFAULT 1 extern_func
10: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 extern_f
11: 0000000000000004 4 OBJECT GLOBAL DEFAULT 3 global_a
12: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM global_b
13: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM global_c
14: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM global_d
15: 000000000000000c 14 FUNC GLOBAL DEFAULT 1 global_func
16: 000000000000001a 93 FUNC GLOBAL DEFAULT 1 main
17: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND extern_func3

可以发现未初始化符号的值为对齐边界。

重定向条目

由于各个区块在链接过程中才确定最终的绝对地址,需要对应修改各个区块中的符号定义和引用。
重定向条目即列出了所有这些需要定义和引用的条目,注意一个区块中可能存在同一个符号的多个引用。

compile_link.o的重定向条目如下:

RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000009 R_X86_64_PC32 global_b-0x0000000000000004
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
0000000000000020 R_X86_64_PC32 .text

compile_link3.o的重定向条目如下:

RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
000000000000002f R_X86_64_PC32 global_func-0x0000000000000004
000000000000003c R_X86_64_PC32 extern_func-0x0000000000000004
0000000000000049 R_X86_64_PC32 extern_func3-0x0000000000000004
000000000000004f R_X86_64_PC32 global_b-0x0000000000000004
0000000000000059 R_X86_64_PC32 extern_func-0x0000000000000004
000000000000005f R_X86_64_PC32 global_c-0x0000000000000004
0000000000000065 R_X86_64_PC32 extern_f-0x0000000000000004
000000000000006b R_X86_64_PC32 global_d-0x0000000000000004
0000000000000071 R_X86_64_PC32 .data+0x0000000000000004
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
0000000000000020 R_X86_64_PC32 .text
0000000000000040 R_X86_64_PC32 .text+0x000000000000000c
0000000000000060 R_X86_64_PC32 .text+0x000000000000001a

字段解释:

  • OFFSET
    本条目对应区块的offset。

  • TYPE

    • R_X86_64_PC32
      使用针对PC的相对地址。
    • R_X86_64_32
      使用绝对地址。
  • VALUE
    具体地址的计算表达式。

    链接

    链接将上一阶段得到的一系列目标文件作为输入,得到最终的可执行文件或者库文件。

链接成可执行文件

可执行文件包括重定向格式中的大部分区块,同时增加段(每个段打包目标文件中的部分区块)的概念,并引入了操作系统提供的glibc胶水代码(用于启动/结束)。

链接要处理的工作分为三步:

  1. 合并各个源目标文件(包括引用的静态/动态库)中的相同类型的区块,得到对应的段,并按照平台ABI确定各段的起始地址
  2. 分析并排重合并各个源目标文件中的符号表,修改符号的值为其定义所在的绝对地址
  3. 遍历所有重定向条目,修改数据段/代码段中所有的地址引用

通过执行

1
gcc compile_link3.c compile_link.c -o compile_link

得到最终的可执行文件,让我们仔细观察它的各个部分发生了什么变化

  • 首先验证各个区块被分配了新的地址
    Program Header重要内容如下:

    PHDR off 0x0000000000000040 vaddr 0x0000000000400040 paddr 0x0000000000400040 align 23
    filesz 0x00000000000001f8 memsz 0x00000000000001f8 flags r-x
    LOAD off 0x0000000000000000 vaddr 0x0000000000400000 paddr 0x0000000000400000 align 2
    21
    filesz 0x00000000000007b4 memsz 0x00000000000007b4 flags r-x
    LOAD off 0x0000000000000e10 vaddr 0x0000000000600e10 paddr 0x0000000000600e10 align 2**21
    filesz 0x0000000000000228 memsz 0x0000000000000238 flags rw-

    Section Table重要内容如下:

    Idx Name Size VMA LMA File off Algn
    0 .interp 0000001c 0000000000400238 0000000000400238 00000238 2**0

                CONTENTS, ALLOC, LOAD, READONLY, DATA
    


    4 .dynsym 00000048 00000000004002b8 00000000004002b8 000002b8 2**3

                CONTENTS, ALLOC, LOAD, READONLY, DATA
    

    5 .dynstr 00000038 0000000000400300 0000000000400300 00000300 2**0

                CONTENTS, ALLOC, LOAD, READONLY, DATA
    


    10 .init 0000001a 00000000004003a8 00000000004003a8 000003a8 2**2

                CONTENTS, ALLOC, LOAD, READONLY, CODE
    

    11 .plt 00000030 00000000004003d0 00000000004003d0 000003d0 2**4

                CONTENTS, ALLOC, LOAD, READONLY, CODE
    

    12 .text 000001f2 0000000000400400 0000000000400400 00000400 2**4

                CONTENTS, ALLOC, LOAD, READONLY, CODE
    

    13 .fini 00000009 00000000004005f4 00000000004005f4 000005f4 2**2

                CONTENTS, ALLOC, LOAD, READONLY, CODE
    

    14 .rodata 00000010 0000000000400600 0000000000400600 00000600 2**3

                CONTENTS, ALLOC, LOAD, READONLY, DATA
    

    15 .eh_frame_hdr 0000004c 0000000000400610 0000000000400610 00000610 2**2

                CONTENTS, ALLOC, LOAD, READONLY, DATA
    

    16 .eh_frame 00000154 0000000000400660 0000000000400660 00000660 2**3

                CONTENTS, ALLOC, LOAD, READONLY, DATA
    

    17 .init_array 00000008 0000000000600e10 0000000000600e10 00000e10 2**3

                CONTENTS, ALLOC, LOAD, DATA
    

    18 .fini_array 00000008 0000000000600e18 0000000000600e18 00000e18 2**3

                CONTENTS, ALLOC, LOAD, DATA
    

    19 .jcr 00000008 0000000000600e20 0000000000600e20 00000e20 2**3

                CONTENTS, ALLOC, LOAD, DATA
    

    20 .dynamic 000001d0 0000000000600e28 0000000000600e28 00000e28 2**3

                CONTENTS, ALLOC, LOAD, DATA
    

    21 .got 00000008 0000000000600ff8 0000000000600ff8 00000ff8 2**3

                CONTENTS, ALLOC, LOAD, DATA
    

    22 .got.plt 00000028 0000000000601000 0000000000601000 00001000 2**3

                CONTENTS, ALLOC, LOAD, DATA
    

    23 .data 00000010 0000000000601028 0000000000601028 00001028 2**2

                CONTENTS, ALLOC, LOAD, DATA
    

    24 .bss 00000010 0000000000601038 0000000000601038 00001038 2**2

                ALLOC
    

    25 .comment 0000002d 0000000000000000 0000000000000000 00001038 2**0

                CONTENTS, READONLYE
    

    可以确认.text被分配在0x400400,.data被分配在0x601028,.bss紧跟其后。除了这些我们眼熟的,还多出来很多不认识的区块(例如.init/.got/.got.plt等等),这些我们暂且按下不表。

  • 其次确认符号表已被合并,符号的值已被改写:
    符号表重要内容如下:

    Symbol table ‘.dynsym’ contains 3 entries:

       Num:    Value          Size Type    Bind   Vis      Ndx Name
         0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
         1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.2.5 (2)
         2: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
    

    Symbol table ‘.symtab’ contains 72 entries:

       Num:    Value          Size Type    Bind   Vis      Ndx Name
        ...
        27: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS crtstuff.c
        28: 0000000000600e20     0 OBJECT  LOCAL  DEFAULT   20 __JCR_LIST__
        29: 0000000000400430     0 FUNC    LOCAL  DEFAULT   13 deregister_tm_clones  // 2nd in .text
        30: 0000000000400460     0 FUNC    LOCAL  DEFAULT   13 register_tm_clones  // 3rd in .text
        31: 00000000004004a0     0 FUNC    LOCAL  DEFAULT   13 __do_global_dtors_aux  // 4th in .text
        32: 0000000000601038     1 OBJECT  LOCAL  DEFAULT   25 completed.6355
        33: 0000000000600e18     0 OBJECT  LOCAL  DEFAULT   19 __do_global_dtors_aux_fin
        34: 00000000004004c0     0 FUNC    LOCAL  DEFAULT   13 frame_dummy  // 5th in .text
        35: 0000000000600e10     0 OBJECT  LOCAL  DEFAULT   18 __frame_dummy_init_array_
        36: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS compile_link.c
        37: 0000000000601034     4 OBJECT  LOCAL  DEFAULT   24 static_e
        38: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS compile_link3.c
        39: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS crtstuff.c
        40: 00000000004007b0     0 OBJECT  LOCAL  DEFAULT   17 __FRAME_END__
        41: 0000000000600e20     0 OBJECT  LOCAL  DEFAULT   20 __JCR_END__
        42: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS
        43: 0000000000600e18     0 NOTYPE  LOCAL  DEFAULT   18 __init_array_end
        44: 0000000000600e28     0 OBJECT  LOCAL  DEFAULT   21 _DYNAMIC
        45: 0000000000600e10     0 NOTYPE  LOCAL  DEFAULT   18 __init_array_start
        46: 0000000000400610     0 NOTYPE  LOCAL  DEFAULT   16 __GNU_EH_FRAME_HDR
        47: 0000000000601000     0 OBJECT  LOCAL  DEFAULT   23 _GLOBAL_OFFSET_TABLE_
        48: 00000000004005f0     2 FUNC    GLOBAL DEFAULT   13 __libc_csu_fini
        49: 000000000060103c     4 OBJECT  GLOBAL DEFAULT   25 global_b
        50: 0000000000601040     4 OBJECT  GLOBAL DEFAULT   25 global_d
        51: 0000000000601028     0 NOTYPE  WEAK   DEFAULT   24 data_start
        52: 0000000000601030     4 OBJECT  GLOBAL DEFAULT   24 global_a
        53: 0000000000601038     0 NOTYPE  GLOBAL DEFAULT   24 _edata
        54: 00000000004004ed    12 FUNC    GLOBAL DEFAULT   13 extern_func  // 6th in .text
        55: 00000000004005f4     0 FUNC    GLOBAL DEFAULT   14 _fini
        56: 000000000060102c     4 OBJECT  GLOBAL DEFAULT   24 extern_f
        57: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@@GLIBC_
        58: 0000000000601028     0 NOTYPE  GLOBAL DEFAULT   24 __data_start
        59: 0000000000400564    20 FUNC    GLOBAL DEFAULT   13 extern_func3  // 9th in .text
        60: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
        61: 0000000000400608     0 OBJECT  GLOBAL HIDDEN    15 __dso_handle
        62: 0000000000400600     4 OBJECT  GLOBAL DEFAULT   15 _IO_stdin_used
        63: 0000000000601044     4 OBJECT  GLOBAL DEFAULT   25 global_c
        64: 0000000000400580   101 FUNC    GLOBAL DEFAULT   13 __libc_csu_init
        65: 0000000000601048     0 NOTYPE  GLOBAL DEFAULT   25 _end
        66: 0000000000400400     0 FUNC    GLOBAL DEFAULT   13 _start  // 1st in .text
        67: 0000000000601038     0 NOTYPE  GLOBAL DEFAULT   25 __bss_start
        68: 0000000000400507    93 FUNC    GLOBAL DEFAULT   13 main  // 8th in .text
        69: 0000000000601038     0 OBJECT  GLOBAL HIDDEN    24 __TMC_END__
        70: 00000000004003a8     0 FUNC    GLOBAL DEFAULT   11 _init
        71: 00000000004004f9    14 FUNC    GLOBAL DEFAULT   13 global_func  // 7th in .text
    

    抛开那些不认识的符号,可以发现之前目标文件符号表中的符号现在都拥有了绝对地址:我们自己定义的符号已经不存在UNDEF的情况。

  • 最后确认代码和数据段中的引用已被改写:
    main代码段的反编译结果如下:

    main:

      400507:    55                       push   %rbp
      400508:    48 89 e5                 mov    %rsp,%rbp
      40050b:    48 83 ec 10              sub    $0x10,%rsp
      40050f:    c7 45 fc 01 00 00 00     movl   $0x1,-0x4(%rbp)
      400516:    bf 01 00 00 00           mov    $0x1,%edi
      // 0xffffffd9 is complement of -39; 0x400520 - 39 = 0x4004f9
      40051b:    e8 d9 ff ff ff           callq  4004f9 <global_func> 
      400520:    89 45 f8                 mov    %eax,-0x8(%rbp)
      400523:    bf 04 00 00 00           mov    $0x4,%edi
      400528:    e8 c0 ff ff ff           callq  4004ed <extern_func>
      40052d:    89 45 f4                 mov    %eax,-0xc(%rbp)
      400530:    bf 00 00 00 00           mov    $0x0,%edi
      400535:    e8 2a 00 00 00           callq  400564 <extern_func3>
      40053a:    89 05 fc 0a 20 00        mov    %eax,0x200afc(%rip)        # 60103c <global_b>
      400540:    bf 03 00 00 00           mov    $0x3,%edi
      400545:    e8 a3 ff ff ff           callq  4004ed <extern_func>
      40054a:    89 05 f4 0a 20 00        mov    %eax,0x200af4(%rip)        # 601044 <global_c>
      400550:    8b 05 d6 0a 20 00        mov    0x200ad6(%rip),%eax        # 60102c <extern_f>
      400556:    89 05 e4 0a 20 00        mov    %eax,0x200ae4(%rip)        # 601040 <global_d>
      // 0x200ad2 = 0x601034-0x400562
      40055c:    8b 05 d2 0a 20 00        mov    0x200ad2(%rip),%eax        # 601034 <static_e> 
      400562:    c9                       leaveq
      400563:    c3                       retq
    
  • 观察胶水代码
    查看ELF头我们得知可执行文件的入口是0x400400,这里正是链接程序植入的入口代码_start:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    0000000000400400 <_start>:
    400400: 31 ed xor %ebp,%ebp ; cleanup ebp
    400402: 49 89 d1 mov %rdx,%r9 ; 6th arg: rtld_fini
    400405: 5e pop %rsi ; 2nd arg: count of arguements
    400406: 48 89 e2 mov %rsp,%rdx ; 3rd arg: list of argument
    400409: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp ; align stack pointer by 16
    40040d: 50 push %rax ; save %rax
    40040e: 54 push %rsp ; 7th arg: stack_end
    40040f: 49 c7 c0 f0 05 40 00 mov $0x4005f0,%r8 ; 5th arg: __libc_csu_fini
    400416: 48 c7 c1 80 05 40 00 mov $0x400580,%rcx ; 4th arg: __libc_csu_init
    40041d: 48 c7 c7 07 05 40 00 mov $0x400507,%rdi ; 1st arg: main
    400424: e8 b7 ff ff ff callq 4003e0 <__libc_start_main@plt>
    400429: f4 hlt
    40042a: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)

    在执行这段汇编代码前,操作系统负责初始化程序堆栈结构如图4所示:
    图4 堆栈初始化

    _start最终调用__libc_start_main,__libc_start_main的源码在glibc(csu/libc-start.c)中,它围绕main函数执行各种事前事后的准备和清理工作,包括:

    1. 注册动态链接清理函数rtld_fini

      1
      __cxa_atexit ((void (*) (void *)) rtld_fini, NULL, NULL);
    2. 注册退出清理函数__libc_csu_fini

      1
      __cxa_atexit ((void (*) (void *)) fini, NULL, NULL);
    3. 执行初始化函数__libc_csu_init

      1
      (*init) (argc, argv, __environ MAIN_AUXVEC_PARAM);
    4. 执行main函数

      1
      result = main (argc, argv, __environ MAIN_AUXVEC_PARAM);
    5. 执行退出函数

      1
      exit (result);

      链接成静态库文件

      可以使用命令

      1
      ar acs name.a a.o b.o

      将包含通用函数的多个模块(这些模块都不包含入口函数main)打包成静态库libname.a,并通过链接该静态库生成可执行文件。这样可以免去重复编译静态库代码的开销。

在链接静态库时需要注意:

  • 源文件引用到的模块被复制进可执行文件中,引用同一个静态库的多个可执行文件各自拥有该模块代码的一份拷贝
  • 复制以模块为单位,即使源文件只引用静态库中的一个函数,该模块也会被完整复制

链接成动态库文件

我们还可以使用命令

1
gcc -shared -fPIC -o liba.so a.o b.o

将模块打包成动态库。
通过链接动态库生成可执行文件时,可执行文件中只记录了使用到的链接库文件名(便于后续执行时加载实际代码),并生成包含所引用函数库函数的间接跳转表(.got/.plt)。完整的链接将延迟到加载时完成。

使用动态库有很多额外好处:

  • 节省内存空间
    同一份动态库代码在内存中只存在一份,加载时各个可执行文件都将这一份代码映射进自己的虚拟地址空间。
  • 可实现代码升级
    只要函数原型不变,可以通过提供更新的动态库完成代码更新而不需要重新编译链接使用函数库的可执行文件。

动态库会被加载到不同位置的特点要求我们要以PIC(Position-Independent Code)的模式生成它:代码段中引用全局符号的位置被设置为指向间接跳转表(.got)中对应条目的偏移量,加载前链接会将符号最终地址填写到该跳转表条目中。这种间接引用全局变量的方式会降低效率,但是能保证动态库代码段加载进内存后不用修改(修改即会触发写实复制,退回到多份拷贝的状态)。

加载

加载阶段负责把可执行文件和需要的动态库加载进内存,完成缺失的链接并执行。

一个程序需要链接哪些动态库可以通过ldd命令查看:

1
2
3
4
ldd compile_link
linux-vdso.so.1 => (0x00007ffbffffe000) // this lib is virtual
libc.so.6 => /lib64/libc.so.6 (0x00007efbf7a0f000)
/lib64/ld-linux-x86-64.so.2 (0x00007efbf7ddd000) // dynamic loader

实践中所有C程序都需要链接动态库,因为上文提到的_start入口处的__libc_start_main函数位于动态库libc.so.6中。

通过以下步骤加载具体的动态库:

  1. 加载动态链接器(dynamic loader)
    动态链接器负责加载其它动态库,具体位置由.interp内容指定:

    1
    2
    3
    4
    readelf -p .interp compile_link

    String dump of section '.interp':
    [ 0] /lib64/ld-linux-x86-64.so.2
  2. 动态链接器依次加载其它需要的动态库代码
    这个过程中,动态链接器代码所引用的全局符号/区块的绝对地址被保存在dynamic区块中,并通过.got[0]间接引用:

    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
    readelf -d compile_link

    Dynamic section at offset 0xe28 contains 24 entries:
    Tag Type Name/Value
    0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
    0x000000000000000c (INIT) 0x4003a8 // _init
    0x000000000000000d (FINI) 0x4005f4 // _fini
    0x0000000000000019 (INIT_ARRAY) 0x600e10 // __frame_dummy_init_array_entry
    0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
    0x000000000000001a (FINI_ARRAY) 0x600e18 // __do_global_dtors_aux_fini_array_entry
    0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)
    0x000000006ffffef5 (GNU_HASH) 0x400298
    0x0000000000000005 (STRTAB) 0x400300 // string used by dynamic loader
    0x0000000000000006 (SYMTAB) 0x4002b8 // symbol used by dynamic loader
    0x000000000000000a (STRSZ) 56 (bytes)
    0x000000000000000b (SYMENT) 24 (bytes)
    0x0000000000000015 (DEBUG) 0x0
    0x0000000000000003 (PLTGOT) 0x601000
    0x0000000000000002 (PLTRELSZ) 48 (bytes)
    0x0000000000000014 (PLTREL) RELA
    0x0000000000000017 (JMPREL) 0x400378
    0x0000000000000007 (RELA) 0x400360
    0x0000000000000008 (RELASZ) 24 (bytes)
    0x0000000000000009 (RELAENT) 24 (bytes)
    0x000000006ffffffe (VERNEED) 0x400340
    0x000000006fffffff (VERNEEDNUM) 1
    0x000000006ffffff0 (VERSYM) 0x400338
    0x0000000000000000 (NULL) 0x0
  3. 动态链接器修正可执行文件和动态库中got表各条目的绝对地址

运行时链接

函数延时绑定

通过精巧地配合使用.got/.plt可以实现一种函数地址的延时绑定策略。
在生成可执行文件时,链接过程生成对应的.got/.plt条目和预设值,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Disassembly of section .plt:

00000000004003d0 <.plt>:
4003d0: ff 35 32 0c 20 00 pushq 0x200c32(%rip) # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>
4003d6: ff 25 34 0c 20 00 jmpq *0x200c34(%rip) # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>
4003dc: 0f 1f 40 00 nopl 0x0(%rax)

00000000004003e0 <__libc_start_main@plt>:
4003e0: ff 25 32 0c 20 00 jmpq *0x200c32(%rip) # 601018 <__libc_start_main@GLIBC_2.2.5>
4003e6: 68 00 00 00 00 pushq $0x0
4003eb: e9 e0 ff ff ff jmpq 4003d0 <.plt>

00000000004003f0 <__gmon_start__@plt>:
4003f0: ff 25 2a 0c 20 00 jmpq *0x200c2a(%rip) # 601020 <__gmon_start__>
4003f6: 68 01 00 00 00 pushq $0x1
4003fb: e9 d0 ff ff ff jmpq 4003d0 <.plt>

Contents of section .got.plt:
601000 280e6000 00000000 ; got[0]: address of dynamic section
601008 00000000 00000000 ; got[1]: identifier for dynamic loader
601010 00000000 00000000 ; got[2]: entry for dynamic loader
601018 e6034000 00000000 ; got[3]: entry for __libc_start_main
601020 f6034000 00000000 ; got[4]: entry for __gmon_start__

代码段中调用__libc_start_main会跳转至.got[3]指向的地址。当程序执行加载时会将.got[1]和.got[2]改写为正确值含义见注释)。

当函数__libc_start_main第一次被执行时,触发绑定逻辑:

  1. 跳转至.got[3]设置的地址4003e6,这里的指令push一个标识自己的参数$0x0
  2. 跳转至.plt处,push自己模块的标识值.got[1],并跳转至.got[2]的dynamic loader入口
  3. dynamic loader确认函数定义的地址,并改写.got[3]为该地址
  4. 执行__libc_start_main函数

之后如果再次执行__libc_start_main,不再需要绑定,因为其地址已经在got[3]中。

运行时解析符号

动态链接是最强大的黑魔法,你甚至使用dlfcn标准库提供的API在程序运行中更改某个符号的地址,变更函数的逻辑。

思考

到此为止,我们完整展示了一个程序从源码到执行的一生,也尽可能说明了它一步步演化的过程。

动态链接是如此令人着迷的特性,编写能够在运行时动态链接的代码,可以使静态编译的程序拥有部分解释性语言的特征。另一方面,注入动态库也成为一种可行的攻击方式,值得深入研究。

参考文献

  1. Randal E. Bryant, David R. O’Hallaron, (2015). 计算机系统: 一位程序员的视角
  2. x86_64-abi-0.99.pdf
  3. Robert W. Sebesta, (2016). Concepts of Programming Languages

系列推荐

  1. 理解C语言-1:内存数据对象
  2. 理解C语言-2:指令执行