深入浅出编译链接

一、编译和链接
本章对编译、链接相关基础知识进行回顾,温故而知新,可以为师矣。下面是两段示例代码:
// foo1.c
#include
int x = 520;
int y = 0;
extern int z;
sum();
int main()
{
static int i = 233;
static int j;
printf("x=%d y=%d z=%d\n", x, y, z);
printf("i=%d j=%d\n", i, j);
auto s = sum(x,y);
printf("%d + %d=%d\n", x, y, s);
return 0;
}
// foo2.c
#include
int z = 1314;
(int x, int y)
{
return x + y;
}
当执行如下命令时,编译器编译foo1.c 和foo2.c 生成可执行文件foo
gcc -Og -o foo foo1.c foo2.c
最终执行 ./foo 输出如下:
x=520 y=0 z=1314
=233 j=0
520 + 0=520
众所周知 gcc 编译生成可执行文件的过程中经历了预处理、编译、汇编和链接四步,示意图如下:
下面进行详细分析:
1.预处理
预处理阶段针对源代码中"#"开头的预编译指令进行处理,主要包括如下:
(1)删除所有 #define 展开宏定义
(2)递归处理所有 #include 的文件 插入到源代码中
(3)对#if #elif #ifdef 等条件编译指令进行处理
(4)删除所有注释
使用如下命令 可以只对源代码进行预处理 生成对应的预处理文件(一般以.i为后缀)
gcc -E foo1.c -o foo1.i
2.编译
编译是对预处理以后的文件进行 语法分析 词法分析 语义分析 源代码优化 目标代码生成和优化以后,生成汇编代码的过程。下面以如下赋值代码为例进行简单分析:
constexpr int num = 700;
vec[i] = x * y + (4 + num);
(1)词法分析
词法分析阶段会对预处理文件中的源代码文件进行逐字扫描,采用有限状态机等算法分析出源代码中字符属于 标识符 关键字 字面量 和特殊符号中的那一类。上述代码经过词法分析后结果示意如下:
(2) 语法分析
语法分析基于词法分析得到的记号,一般采用上下文无关语法进行分析,最终解析得到一棵语法表达式树,如下所示:
(3) 语义分析
语义分析阶段针对代码中的静态语义进行分析,主要进行声明和定义的类型匹配性检查,类型转换的合法性检查等。如将int类型指针赋值给double类型指针时,编译器会提示类型非法。经过语义分析以后,整个表达式树都有了具体的类型对应,如下所示。
(4)源代码优化
现代编译器会在源代码层级进行一定优化,以提升程序运行时性能,不同编译器的优化方法各不相同。以上述代码为例,源代码优化时可以将4+num(编译期常量) 优化为704,整个表达式树简化为以下形式。
(5)目标代码生成和优化
源代码层级进行优化后,编译器生成对应的汇编语言组成的汇编目标代码。并且在汇编层级也会进行一定的优化,比如使用位移代替乘法指令,汇编指令合并,删除多余指令等。
gcc通过以下命令生成对应的汇编文件(一般以.s为后缀)
gcc -S foo1.i -o foo1.s
可以通过gcc -Og -O1 -O2 -O3等指定编译器采用不同等级的优化方式,详细信息可参考:Optimize Options (Using the GNU Compiler Collection (GCC))
编译器优化的能力也并非万能的,具有其局限性,具体可以看下面两个小例子。
(6)优化局限性案例1
void func1(int* xp, int* yp)
{
*xp += *yp; // 读*xp 读*yp 写*xp
*xp += *yp; // 读*xp 读*yp 写*xp
}
void func2(int* xp, int* yp)
{
*xp += 2 * *yp; // 读*xp 读*yp 写*xp
}
上例两个函数func1 和 func2 看似有两个相同行为,均是xp指向值加上yp指向值的2倍,但是func1内存读写次数是func2的两倍。那么编译器在只进行安全的优化时,能否自动将func1优化为func2的实现呢? 答案是不能。
因为xp yp 有可能指向相同地址,此时两个函数将具有不一致的行为,此时func1将xp指向值变为原来4倍, func2则是3倍。
在只进行安全优化时,编译器会假设不同指针会指向同一个地址。因此,程序设计时,若明确指针指向不同的地址,就需要显氏的定义func2而非func1。
(7)优化局限性案例2
int f();
int func1()
{
return f() + f();
}
int func2()
{
return 2 *f();
}
上例中的func1 和 func2看似实现相同效果,但在只进行安全优化时,编译器也不会将func1优化为func2。原因时编译器并不知道函数f()执行是否有副作用,若f定义如下所示,则执行一次和两次时,内部的静态变量值将不同,最终结果也不相同。
int f();
{
static int count = 0;
return ++count;
}
3.汇编简介
汇编阶段编译器会将汇编代码翻译为对应的机器代码,生成可重定位目标文件(一般.o后缀)。生成命令如下所示:
gcc -c foo1.s -o foo1.o
linux下的目标文件为ELF格式,主要有三种类型:
可重定位目标文件:汇编阶段生成 包含二进制和代码 可在链接阶段和其它可重定位目标文件链接 生成可执行目标文件。
可执行目标文件: 最终链接生成的文件 可加载到内存执行。(见一.4链接小节)
共享目标文件:特殊的目标文件 可以在运行时被动态链接加载到内存运行。(见第三部分动态链接)
可基于file命令查看目标文件的具体格式:relocatable 或者 executable等
$ file foo1.o
foo1.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV)
file foo
foo: ELF 64-bit LSB executable, x86-64, version 1 (SYSV)
目标文件虽然细分了具体类型,但文件格式基本类似,下面为可重定位目标文件的具体格式示意图如下,主要有ELF头和若干段组成。
(1)ELF头
保存了系统的字节序 ELF头的长度 目标文件的类型(重定位 可执行 共享等)和一个段表数组,段表中每个条目表示后续介绍的.text段 .data段等,它描述了各个段的偏移位置和属性等信息。
(2).text段
保存程序对应的机器代码
(3).data段
保存已经初始化(非0)的全局变量和静态变量
(4).rodata段
保存只读数据,如printf中的格式串。
(5).bss段
保存未初始化的全局变量和静态变量以及初始化为0的全局变量和静态变量。为了提升空间效率,这些变量只是在目标文件中进行占位,并不会占用额外的磁盘空间。只有在运行时,才会在内存中分配空间。
(6).symtab段
保存符号表信息,包括在程序中定义和引用的函数和变量等相关信息。
(7).rel.text段
保存了.text段中需要重定位的符号的位置,连接时会修改这些位置。
(8).rel.data段
保存可重定位文件定义或者引用的所有全局变量的重定位信息。链接时,需要进行修改。
(9) .debug段
保存调试符号表 指定-g选项时生成,
(10).strtab段
ELF文件中使用了很多字符串 比如变量名、段名等,.strtab段这些字符串名称,使用处通过名称在表中的偏移来引用字符串。
4.可重定位目标文件详解
foo1.o文件为我们生成的可重定位目标文件 属于目标文件的一种,linux系统中目标文件采用ELF格式,内容格式大体相同。
ELF文件的相关数据结构定义位于/usr/include/elf.h 中,本节结合数据结构定义,基于foo1.o文件进行详细分析。
(1)ELF头查看
基于以下命令可以查看ELF文件的头部内容信息:
readelf -h foo1.o
摘取其主要部分,汇总如下:
ELF 头:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Type: REL (可重定位文件)
Machine: Advanced Micro Devices X86-64
其中Magic前4个字节 7f 45 4c 46是ELF文件魔数,校验文件是否合法。
第5个字节02表示ELF文件类型, 01位32位版本 02位64位版本。Class字段也表明了ELF文件类型为64位版本。
第6个字节01表示ELF文件字节序类型, 01位小端 02大端。Data字段同样可以看出ELF文件采用小端字节序。
第7个字节01表示ELF文件版本号,一般均为01.
Type字段表示ELF文件的子类型,foo1.o为可重定位目标文件。
Machine表示ELF可使用的平台类型。
(2)段表简介
64位版本ELF文件的段表定义结构体如下所示,包括段名称 类型 地址等信息。
typedef struct
{
Elf64_Word sh_name; /* Section name (string tbl index) */
Elf64_Word sh_type; /* Section type */
Elf64_Xword sh_flags; /* Section flags */
Elf64_Addr sh_addr; /* Section virtual addr at execution */
Elf64_Off sh_offset; /* Section file offset */
Elf64_Xword sh_size; /* Section size in bytes */
Elf64_Word sh_link; /* Link to another section */
Elf64_Word sh_info; /* Additional section information */
Elf64_Xword sh_addralign; /* Section alignment */
Elf64_Xword sh_entsize; /* Entry size if section holds table */
} Elf64_Shdr;
可以基于objdump -h查看foo1.o的简要段表信息,可以看到显示信息和Elf64_Shdr结构体中的部分字段存在一一对应关系。
$ objdump -h foo1.o
foo1.o: 文件格式 elf64-x86-64
节:
Idx Name Size VMA LMA File off Algn
0 .text 0000007f 0000000000000000 0000000000000000 00000040 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 0000000000000000 0000000000000000 000000c0 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000008 0000000000000000 0000000000000000 000000c8 2**2
ALLOC
3 .rodata 00000025 0000000000000000 0000000000000000 000000c8 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
Idx:ELF文件的头部有一个段表 每个段在段表中存在唯一的index
Name:段名称
Size:表示段在虚拟内存空间中的大小。可以看出.text段长度为0x7f .data段长度为8个字节 .bss段长度为8个字节
VMA:段对应的虚拟地址 可重定位目标文件中还未进行链接重定位 均为0000000000000000
LMA:段对应的加载地址 可重定位目标文件中还未进行链接重定位 均为0000000000000000 一般和VMA相同
File off:段在ELF磁盘文件中的偏移。可以看出.bss段 和 .rodata段在文件中的偏移均为0xc8 说明.bss段并不占用磁盘空间。
Algn:段对齐要求
CONTENTS ALLOC LOAD RELOC READONLY CODE DATA:表示段的一些属性,CONTENTS 表示段在磁盘文件中存在 ALLOC表示段需要分配内存 RELOC 表示段需要重定位等。 可以看到.bss段没有CONTENTS属性 进一步验证了.bss段并不占用磁盘空间。
如果需要查看段表的详细信息,可以基于readelf -S指令
$ readelf -S foo1.o
共有 13 个节头,从偏移量 0x1c0 开始:
节头:
[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
000000000000007f 0000000000000000 AX 0 0 4
[ 2] .rela.text RELA 0000000000000000 000006c8
0000000000000180 0000000000000018 11 1 8
[ 3] .data PROGBITS 0000000000000000 000000c0
0000000000000008 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 000000c8
0000000000000008 0000000000000000 WA 0 0 4
[ 5] .rodata PROGBITS 0000000000000000 000000c8
0000000000000025 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 000000ed
000000000000002e 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 0000011b
0000000000000000 0000000000000000 0 0 1
[ 8] .eh_frame PROGBITS 0000000000000000 00000120
0000000000000038 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 00000848
0000000000000018 0000000000000018 11 8 8
[10] .shstrtab STRTAB 0000000000000000 00000158
0000000000000061 0000000000000000 0 0 1
[11] .symtab SYMTAB 0000000000000000 00000500
0000000000000198 0000000000000018 12 11 8
[12] .strtab STRTAB 0000000000000000 00000698
000000000000002c 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), l (large)
I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
其中部分信息和Objdump 输出相似,对新增字段进行简要介绍:
Type:表示段的类型 主要有PROGBITS(程序段) STRTAB(字符串表段) RELA(重定位段) SYMTAB(符号表段)等
Flags:表示段的属性 主要要W(写数据) A(段会分配内存) X(段会被执行)等。
Link Info:表示段的重定位相关信息 Info表示重定义作用的段的段表下标 Link表示要重定位的符号所在符号表段的下标 。如.rela.text 的info字段为1 表示作用的段为[1] .text段,Link字段为11表示要重定位的符号的符号表为[11].symtab。
一个ELF段的具体作用由Type和Flags决定 和名称无关。我们也可以自定义某些变量和函数位于某些自定义段中,示例如下:
__attribute__((section("FOO"))) int x = 520; //x放入FOO段中
(3).text段详解
.text段位代码段 基于 objdump -s -d 命令可以查看代码段对应的二进制和汇编实现。
$ objdump -s -d foo1.o
foo1.o: 文件格式 elf64-x86-64
Contents of section .text:
0000 554889e5 8b0d0000 00008b15 00000000 UH..............
0010 8b050000 000089c6 bf000000 00b80000 ................
0020 0000e800 0000008b 15000000 008b0500 ................
0030 00000089 c6bf0000 0000b800 000000e8 ................
0040 00000000 8b150000 00008b05 00000000 ................
0050 89d689c7 e8000000 0089c18b 15000000 ................
0060 008b0500 00000089 c6bf0000 0000b800 ................
0070 000000e8 00000000 b8000000 005dc3 .............].
Disassembly of section .text:
0000000000000000
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 8b 0d 00 00 00 00 mov 0x0(%rip),%ecx # a
a: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 10
10: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 16
16: 89 c6 mov %eax,%esi
18: bf 00 00 00 00 mov $0x0,%edi
1d: b8 00 00 00 00 mov $0x0,%eax
22: e8 00 00 00 00 callq 27
27: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 2d
2d: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 33
33: 89 c6 mov %eax,%esi
35: bf 00 00 00 00 mov $0x0,%edi
3a: b8 00 00 00 00 mov $0x0,%eax
3f: e8 00 00 00 00 callq 44
44: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 4a
4a: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 50
50: 89 d6 mov %edx,%esi
52: 89 c7 mov %eax,%edi
54: e8 00 00 00 00 callq 59
59: 89 c1 mov %eax,%ecx
5b: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 61
61: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 67
67: 89 c6 mov %eax,%esi
69: bf 00 00 00 00 mov $0x0,%edi
6e: b8 00 00 00 00 mov $0x0,%eax
73: e8 00 00 00 00 callq 78
78: b8 00 00 00 00 mov $0x0,%eax
7d: 5d pop %rbp
7e: c3 retq
其中 Contents of section .text 为代码段对应二进制,第一列为偏移量,可以看到整个代码段Size为0x7f 和(2)通过Objdump查看的.text段大小匹配。进一步可以看出二进制第一个字节0x55 对应指令即为汇编push %rbp 最后一个字节0xc3对应指令ret。
(4)数据相关段详解
.data段存放被初始化为非零值的全局变量和静态变量,对应于foo1.c中的x 和i,均为int类型,共占用8字节大小。可以从(2)中Objdump信息看出.data size为8.
.bss段存放未初始化或者初始化为零的全局变量和静态变量,对应foo1.c中的y 和j,均为int类型,共占用8字节大小。可以从(2)中Objdump信息看出.bss size为8.
.rodata段存放只读数据,对应本例中printf中的字符串信息,共存在37个字符,对应size为0x25字节。可以从(2)中Objdump信息看出.rodata size为0x25.
(5).symtab段
符号表段保存了本ELF文件定义的全局符号、引用的外部全局符号、本ELF文件的所有段表段名以及只在本文件可见的局部符号等。每个符号对应的数据结构定义如下:
typedef struct
{
Elf64_Word st_name; /* 符号名称 */
unsigned char st_info; /* 符号类型及绑定信息 */
unsigned char st_other; /* 暂不使用 */
Elf64_Section st_shndx; /* 符号所在段下标 */
Elf64_Addr st_value; /* 符号值 不同类型符号解释不同 */
Elf64_Xword st_size; /* 符号的大小 */
} Elf64_Sym;
st_info包括符号的类型及绑定信息,符号常见类型有SECTION(表示是一个段)FUNC(表示是函数名) OBJECT(表示对象名)NOTYPE (未知类型)
符号常见绑定类型有LOCAL(表示文件内局部符号) GLOBAL(表示本文件全局符号) UNDEF(表示本文件未定义,引用的外部全局符号)
st_shndx 表示符号为于段表中的第几段 若是外部符号 则为UND(值为0)由于0已经被占用,所以下标从1开始
可以基于readelf -s 查看foo1.o的符号表信息,如下所示:
$ readelf -s foo1.o
Symbol table '.symtab' contains 17 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS foo1.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 5
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 4 j.2185
7: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 i.2184
8: 0000000000000000 0 SECTION LOCAL DEFAULT 7
9: 0000000000000000 0 SECTION LOCAL DEFAULT 8
10: 0000000000000000 0 SECTION LOCAL DEFAULT 6
11: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 x
12: 0000000000000000 4 OBJECT GLOBAL DEFAULT 4 y
13: 0000000000000000 127 FUNC GLOBAL DEFAULT 1 main
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND z
15: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
16: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sum
可以看到:x y为foo1.c中定义的全局变量 绑定类型为GLOBAL i j为局部静态变量 绑定类型为LOCAL sum printf z为引用的外部符号,绑定类型为GLOBAL
另外 x i 位于段表中第3段 即.data段 y j位于段表中第4段 即.bss段 sum printf z为外部符号 下标为UND
(6)重定位表段
ELF文件中 如果需要对某个段进行重定位,则会有对应的.rela段保存重定位表信息。本例中.rela.text段为.text段的重定位表段。重定位表详细内容在链接部分进行介绍。
5.链接
链接是将多个可重定位文件进行空间和地址重分配,符号解析和重定位最终组成一个可执行目标文件的过程。
(1) 空间和地址重分配
本例中foo1.o和foo2.o中均含有.text .data等对应段,链接时会将多个可重定位文件的相似段进行合并,如多个.text段合并为一个.text段 示意图如下:
基于objdump -h 输出foo1.o foo2.o 和可执行文件foo的部分段信息如下:
$ objdump -h foo1.o
foo1.o: 文件格式 elf64-x86-64
节:
Idx Name Size VMA LMA File off Algn
0 .text 0000007f 0000000000000000 0000000000000000 00000040 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 0000000000000000 0000000000000000 000000c0 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000008 0000000000000000 0000000000000000 000000c8 2**2
ALLOC
$ objdump -h foo2.o
foo2.o: 文件格式 elf64-x86-64
节:
Idx Name Size VMA LMA File off Algn
0 .text 00000014 0000000000000000 0000000000000000 00000040 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000004 0000000000000000 0000000000000000 00000054 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000058 2**2
$ objdump -h foo
foo: 文件格式 elf64-x86-64
节:
Idx Name Size VMA LMA File off Algn
12 .text 000001e4 0000000000400440 0000000000400440 00000440 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
23 .data 0000000c 0000000000601030 0000000000601030 00001030 2**2
CONTENTS, ALLOC, LOAD, DATA
24 .bss 0000000c 000000000060103c 000000000060103c 0000103c 2**2
ALLOC
可以看到:
可重定位文件中每个段的VMA地址均为0000000000000000 可执行文件foo中已经进行了空间和地址的重新分配 VMA字段变为对应虚拟内存地址。
可执行文件中每个段由可重定位文件的相似段拼接而来,如foo1.o中 .data段 Size大小为0x8 foo2.o中 .data段 Size大小为0x4
最终foo中Size大小为0xc。最终文件中段Size大小也会受到段Algn对齐值的影响进行取整。
(2)符号解析和重定位
在空间和地址分配以后,我们确定了可执行文件中各个段以及各个符号的具体虚拟地址。接下来需要原来可重定位文件中的符号进行解析以及重定位。foo1.o中使用了外部定义的符号z 那么链接是按照什么规则从其它文件中解析符号的呢? 链接时存在的重定义的符号 或者未定义的引用是如何产生的呢? 本节进行介绍。
对于C/C++语言来说,链接时对于每个文件中定义的符号分为两种类型,强符号和弱符号。
强符号:函数和已经初始化(包括零值)的全局变量 如foo1.c中的x y sum main等
弱符号:未初始化的全局变量
注意:符号的强弱是针对符号定义来说的 对于foo1.c中 extern int z; 只是符号的引用,并不存在强弱之分。
如果将foo1.c进行如下临时调整:此时y将从强符号变为弱符号。
int x = 520;
int y; // 移除y的初始化
extern int z;
另外 我们也可以显式修改一个强符号变为弱符号,如下所示:
__attribute__((weak)) int y = 0;
在链接时,针对如下规则处理强弱符号:
a.不允许存在两个同名的强符号,不然产生重定义错误
b.同时存在同名强符号和弱符号时,直接使用强符号
c.同时存在多个同名若符号时,从弱符号中任选一个 一般选择类型较大的符号。
对于强弱符号的规则处理可能带来预料之外的错误 下面看一个典型例子:
// foo3.c
#include
void f();
int x = 520;
int y = 1314;
int main()
{
f();
printf("x=%d y=%d\n", x, y);
}
// foo4.c
double x;
void f()
{
x = 0.0;
}
通过gcc 生成可执行文件 可以看到会有Warning提示:
$ gcc -o foobar foo3.c foo4.c
/usr/bin/ld: Warning: alignment 4 of symbol `x' in /tmp/ccUjXTSc.o is smaller than 8 in /tmp/ccb6Wk5a.o
忽略Warning 运行程序结果为:
$ ./foobar
x = 0 y = 0
详细分析可以发现foo3中x为强符号 foo4中x为弱符号, 最终采用foo3中的定义。但在foo4中将一个64位的浮点数赋值给了32位的x 导致x及后续y均被零值覆盖。为了避免同名符号覆盖的情形,GCC编译时可以指定-fno-common选项 在遇到同名全局符号时,抛出ERROR。
除了符号的定义分为强弱之外,对于符号的引用也可以分为强弱引用。一般声明的外部变量或者函数均为强引用,如foo1.c中的变量z和函数sum。对于强引用,如果在链接时在其它模块找不到对应符号的定义,则会产生未定义的引用错误。对于弱引用,链接时找不到符号定义,编译器并不会报错,而是在运行时进行判断。具体示例如下所示:
// foo1.c 强引用
extern int z;
int sum(int x, int y);
// 显式定义为弱引用
extern int z __attribute__((weak));
__attribute__((weak)) int sum(int x, int y);
// 弱引用使用示例
if(sum)
{
return sum(x,y)
}
介绍了强弱符号及强弱引用相关规则以后,继续回到foo1.c和foo2.c的链接过程,在第一步中进行空间划分和地址分配以后。接下来会对foo1 foo2中的强弱符号 强弱引用进行解析处理。正常解析之后,已经可以确定foo1中z和sum的实际虚拟地址了。
在第4.(3)小节foo1.o的符号表中 可以看到sum z printf符号所在段下标index为UNDEF,同样查看最终可执行文件foo中的符号表,可以看到sum z printf等符号已经有正确的段index。
Num: Value Size Type Bind Vis Ndx Name
52: 000000000060103c 4 OBJECT GLOBAL DEFAULT 24 z
60: 00000000004005b8 20 FUNC GLOBAL DEFAULT 13 sum
在玩家符号解析之后,接下来需要进行重定位工作。我们伴随一下几个问题梳理一下重定位的流程。
问题1:既然在foo1.o中 z sum 和printf等符号的地址并未确定,那么在foo1.o的代码段中是如何使用这些变量和函数的呢?前面通过objdump -s -d查看了.text段的汇编代码,下面进一步进行研究。
Disassembly of section .text:
0000000000000000
0: 48 83 ec 08 sub $0x8,%rsp
4: 8b 0d 00 00 00 00 mov 0x0(%rip),%ecx # a
a: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 10
10: 8b 35 00 00 00 00 mov 0x0(%rip),%esi # 16
16: bf 00 00 00 00 mov $0x0,%edi
1b: b8 00 00 00 00 mov $0x0,%eax
20: e8 00 00 00 00 callq 25
25: ba 00 00 00 00 mov $0x0,%edx # 保存j
2a: be e9 00 00 00 mov $0xe9,%esi # 保存i
2f: bf 00 00 00 00 mov $0x0,%edi
34: b8 00 00 00 00 mov $0x0,%eax
39: e8 00 00 00 00 callq 3e
3e: 8b 35 00 00 00 00 mov 0x0(%rip),%esi # 44
44: 8b 3d 00 00 00 00 mov 0x0(%rip),%edi # 4a
4a: e8 00 00 00 00 callq 4f
4f: 89 c1 mov %eax,%ecx # 保存s
51: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 57
57: 8b 35 00 00 00 00 mov 0x0(%rip),%esi # 5d
5d: bf 00 00 00 00 mov $0x0,%edi
62: b8 00 00 00 00 mov $0x0,%eax
67: e8 00 00 00 00 callq 6c
6c: b8 00 00 00 00 mov $0x0,%eax
71: 48 83 c4 08 add $0x8,%rsp
75: c3 retq
通过上述汇编代码可以发现,foo1.o中对于全局变量 x y z的访问均采用了间接寻址(地址为相对下一条指令地址的偏移量)的形式,并且寻址偏移量均为0x0;同样,对于sum printf的寻址也采用了间接寻址,偏移量为00 00 00 00。综上,可见在重定位目标文件中,对于需要重定位的变量和函数的地址,均先用0x00000000等特殊值进行替代,等到链接时再修正为正确地址。
问题2:在链接过程中,链接器是如何找到可重定位目标文件中的这些需要重定位的变量和符号的呢?重定位表在这个过程中发挥了重要作用。
前面通过readelf -s 我们已经看到foo1.o的段表中存在.rela.text段 它负责对.text段进行重定位工作。下面通过objdump -r详细查看重定位段内容:
$ objdump -r foo1.o
foo1.o: 文件格式 elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000006 R_X86_64_PC32 z-0x0000000000000004
000000000000000c R_X86_64_PC32 y-0x0000000000000004
0000000000000012 R_X86_64_PC32 x-0x0000000000000004
0000000000000017 R_X86_64_32 .rodata.str1.1
0000000000000021 R_X86_64_PC32 printf-0x0000000000000004
0000000000000030 R_X86_64_32 .rodata.str1.1+0x0000000000000010
000000000000003a R_X86_64_PC32 printf-0x0000000000000004
0000000000000040 R_X86_64_PC32 y-0x0000000000000004
0000000000000046 R_X86_64_PC32 x-0x0000000000000004
000000000000004b R_X86_64_PC32 sum-0x0000000000000004
0000000000000053 R_X86_64_PC32 y-0x0000000000000004
0000000000000059 R_X86_64_PC32 x-0x0000000000000004
000000000000005e R_X86_64_32 .rodata.str1.1+0x000000000000001b
0000000000000068 R_X86_64_PC32 printf-0x0000000000000004
OFFSET:表示需要重定位的符号在对应段中地址偏移量。以第一行的z为例,可以看到偏移量为0x6 对应于问题1中foo1.o的汇编代码,正好发现0x6偏移量指向的地址(下面加粗部分)为变量z的位置。
4: 8b 0d 00 00 00 00 mov 0x0(%rip),%ecx # a
TYPE:地址重定位方式,R_X86_64_PC32表示采用相对地址进行重定位;R_X86_64_32 表示采用绝对地址进行重定位;
基于重定位表,链接器就可以确认需要重定位的符号位置及重定位方式了。
问题3:对于某个需要重定位的符号,如何进行重定位?
如果采用R_X86_64_32进行绝对地址重定位,则较为简单。在前面的流程中我们已经确定了各个段在最终可执行文件中的地址,进而确定了各个符号的具体虚拟地址。采用绝对地址重定位时,只需用实际虚拟地址代替之前的特殊零值地址即可。
对于 x y z sum printf等符号,均采用了R_X86_64_PC32基于相对地址进行重定位。在重定位之前,我们已经确定了最终可执行文件中sum函数的地址,也确定了foo1.o中.text段的地址,进而确认了 .text段中偏移量为0x4b 的sum函数调用处的地址。而相对地址重定位时,需要计算的是sum实际地址相对于当前指令的下一条指令的偏移量。计算公式如下:
最终相对地址偏移量=sum实际地址 - .text段中sum调用处地址 - 下一条指令和sum函数调用处偏移量
假设可执行文件foo中.text段main函数地址为0x00400530, 则sum函数调用处地址为0x00400530 + 0x4b = 0x0040057b
从foo1.o中汇编代码可以看到,sum函数调用处的下一个指令地址偏移量为0x4f。sum函数调用处和它的距离为0x4;
4a: e8 00 00 00 00 callq 4f
4f: 89 c1 mov %eax,%ecx # 保存s
假设可执行文件foo中sum函数实际地址为0x004005a8,则最终可执行文件中的相对地址偏移量为:
0x004005a8 - 0x0040057b- 0x4 = 0x29。在对应callq指令后方,只需用0x29 00 00 00 替换0x00 00 00 00即可。
解决了三个问题以后,最后看一下最终可执行文件foo对应的反汇编代码,可以看到相应符号均已正确重定位。
0000000000400530
400530: 48 83 ec 08 sub $0x8,%rsp
400534: 8b 0d fe 0a 20 00 mov 0x200afe(%rip),%ecx # 601038
40053a: 8b 15 00 0b 20 00 mov 0x200b00(%rip),%edx # 601040 <__TMC_END__>
400540: 8b 35 ee 0a 20 00 mov 0x200aee(%rip),%esi # 601034
400546: bf 40 06 40 00 mov $0x400640,%edi
40054b: b8 00 00 00 00 mov $0x0,%eax
400550: e8 bb fe ff ff callq 400410
400555: ba 00 00 00 00 mov $0x0,%edx
40055a: be e9 00 00 00 mov $0xe9,%esi
40055f: bf 50 06 40 00 mov $0x400650,%edi
400564: b8 00 00 00 00 mov $0x0,%eax
400569: e8 a2 fe ff ff callq 400410
40056e: 8b 35 cc 0a 20 00 mov 0x200acc(%rip),%esi # 601040 <__TMC_END__>
400574: 8b 3d ba 0a 20 00 mov 0x200aba(%rip),%edi # 601034
40057a: e8 29 00 00 00 callq 4005a8
40057f: 89 c1 mov %eax,%ecx
400581: 8b 15 b9 0a 20 00 mov 0x200ab9(%rip),%edx # 601040 <__TMC_END__>
400587: 8b 35 a7 0a 20 00 mov 0x200aa7(%rip),%esi # 601034
40058d: bf 5b 06 40 00 mov $0x40065b,%edi
400592: b8 00 00 00 00 mov $0x0,%eax
400597: e8 74 fe ff ff callq 400410
40059c: b8 00 00 00 00 mov $0x0,%eax
4005a1: 48 83 c4 08 add $0x8,%rsp
4005a5: c3 retq
4005a6: 66 90 xchg %ax,%ax
00000000004005a8
4005a8: 8d 04 37 lea (%rdi,%rsi,1),%eax
4005ab: c3 retq
4005ac: 0f 1f 40 00 nopl 0x0(%rax)
二、静态库链接
前面第一部分已经详细介绍了编译链接的具体流程,在foo1.c中我们除了使用foo2.c中定义的函数sum 还使用了标准库函数printf,那么printf等库函数是如何链接的呢?
C99 定义了printf scanf等一系列标准IO函数,编译器为了便于编译链接,提供了对应的静态库libc.a和动态库libc.so。
动态库在下一章进行分析,首先带着问题认识一下静态库libc.a。
思考一个问题:为什么编译器不是将printf scanf等经过预处理、编译、汇编等流程生成一个libc.o文件用于链接,而是提供静态库文件呢?
原因1:众多的printf scanf等IO函数均放在一个libc.o文件中,使用到其中部分函数的程序都需要和它静态编译链接,最终导致每个程序都有一份libc.o等副本,极度浪费磁盘空间。
原因2:有部分标准库函数改动时,所有的程序都需要重新编译。
静态库是如何解决这个问题的呢?它首先将printf scanf等单独生成对应的printf.o scanf.o等重定位目标文件,然后将其封装为一个静态库文件,链接时编译器将只对使用到的模块进行链接。
linux系统中,静态库文件以存档(archive)文件的特殊格式存储,存档文件是一组可重定位目标文件的集合,并且在文件头部记录了每个可重定位目标文件的大小和位置信息。
下面通过一个小例子展示如何创建自己的静态库文件,代码示例如下:
// addvec.c
int addCount = 0;
void addVec(int* x, int* y, int* z, int n)
{
addCount++;
int i;
for (i = 0; i < n; i++) {
z[i] = x[i] + y[i];
}
}
// mulvec.c
int mulCount = 0;
void mulVec(int* x, int* y, int* z, int n)
{
mulCount++;
int i;
for (i = 0; i < n; i++) {
z[i] = x[i] * y[i];
}
}
// vector.h
#pragma once
void addVec(int* x, int* y, int* z, int n);
void mulVec(int* x, int* y, int* z, int n);
// main.c
#include "vector.h"
#include
int x[2] = { 3, 4 };
int y[2] = { 5, 2 };
int z[2];
int main()
{
addVec(x, y, z, 2);
printf("z[0] = %d z[1] = %d\n", z[0], z[1]);
return 0;
}
addvec.c 和mulvec.c中分别提供了addVec 和mulVec来对两个静态数组进行相加和相乘操作。通过下面指令编译生成可重定位目标文件 并打包生成静态库文件libvector.a。
gcc -c addvec.c mulvec.c
ar rcs libvector.a addvec.o mulvec.o
可以通过ar -t 命令查看最终生成的静态库存档文件中的.o文件。
$ ar -t libvector.a
addvec.o
mulvec.o
最后在main函数中使用addVec函数,进行静态编译链接。-L 指定静态库目录为当前目录 -lvector使用使用目录下的libvector.a文件
$ gcc -c main.c
$ gcc -static -o vecFoo main.o -L . -lvector
$ ./vecFoo
z[0] = 8 z[1] = 6
整个编译链接过程除了使用libvector.a中的addVec以外,还使用了libc.a中的printf。示意图如下所示:
最后 介绍一下linux静态链接时进行符号解析的规则,首先编译器会按照命令行中的顺序从左到右处理可重定位目标文件以及存档文件,具体处理规则如下所示。
1.编译维护一个可重定位目标文件集合E 一个带解析的外部引用的符号集合U 以及已经定义的符号集合D,最开始三个集合均为空。
2.解析时遇到一个可重定位目标文件时,将其加入集合E,将其需要解析的符号加入集合U 已经定义的符号加入集合D
3.解析时遇到一个存档文件(静态库文件)时, 则遍历其每个重定位目标文件成员,尝试从其成员中查找是否有集合U中符号的定义。若成员m存在待解析符号定义,则将m加入集合E, 将m中的已经定义符号加入集合D,将m中需要解析符号加入集合U。若成员不存在待解析符号定义,将被丢弃。
4.重复以上过程,若最终文件处理完毕,仍然存在未解析符号,则根据强弱引用决定是否抛出未定义的引用错误。若解析过程出现同名符号,则按照之前规则进行处理或抛出错误。
正是由于上述规则,在静态链接时静态库和可重定位目标文件的顺序显得尤为重要。如果调整上述例子中链接静态库指令如下,则抛出为定义引用错误:
$ gcc -static -o vecFoo -L . -lvector main.o
main.o:在函数‘main’中:
main.c:(.text+0x19):对‘addVec’未定义的引用
collect2: 错误:ld 返回 1
三、动态库链接
前两部分已经详细介绍了编译链接过程以及静态库编译、链接规则。既然已经有了静态库、静态链接方式?那么为什么还需要动态库和动态链接呢?
原因1:在静态链接时 当多份可执行文件链接同一个重定位文件时,均拥有一份重定位文件的副本,对磁盘以及内存产生极大浪费。
原因2:静态库文件发生变更时,需要重新编译和链接
基于静态库和静态链接的以上问题 编译器提供了动态库及动态链接的方案,在linux系统中动态库一般以.so文件形式存在,windows系统中以dll形式存在。.so文件也称为共享目标文件,属于前面介绍的三种目标文件中的一种。
linux系统实现动态共享库方式:
1.每个文件系统中,每个库只有唯一的一个.so文件 使用这个库的程序可以共享.so文件的代码和只读数据 而非将.so文件复制一份
2.可执行文件启动时,先将控制权交给动态链接器,动态链接器会将可执行文件依赖的共享库文件加载进内存(也可延迟加载),等共享库加载完毕后将控制权交给可执行文件的入口函数,开始运行。
1.动态共享库示例
继续使用第二章静态库中的代码示例,我们将addvec.c和mulvec.c编译为共享库libvector.so,并链接生成可执行文件:
gcc -fPIC -shared -o libvector.so addvec.c mulvec.c
gcc -o vecFoo2 main.c ./libvector.so
其中-shared表示生成共享目标文件 -fPIC表示生成地址无关代码,后续详细介绍。 既然采用了动态链接,为什么还需要在编译时指定 ./libvector.so呢?
原因:编译器在编译时需要知道main.c中使用的addVec函数的性质,如果是静态链接函数,则采用静态链接方式进行符号解析和地址重定位;如果是动态链接方式,则标记为动态链接符号,并且记录依赖的.so信息,便于运行时加载。
对比之前静态链接产生的vecFoo 和vecFoo2 大小,可以看出vecFoo2 显著小于vecFoo。
$ ll -h|grep vecFoo
-rwxr-xr-x 1 user00 user00 825K 11月 23 19:10 vecFoo
-rwxr-xr-x 1 user00 user00 8.5K 11月 23 19:06 vecFoo2
在静态链接时,可执行文件中的段是由每个可重定位文件的段拼接而来的。在动态链接时,共享库在运行时进行加载,加载时linux系统会按照内存映射文件的形式将共享库文件整个映射到内存中堆栈之间的某个起始地址(一般为0x40000000),并不会进行段的拆分和合并工作,示意图如下。
2.地址无关代码
前面介绍过,共享库是在运行时被动态加载的,并且共享库文件的代码段和只读数据需要在多个程序之前共享。一种实现办法是所有程序都从某个固定地址加载共享库文件,但是这样对于进程虚拟地址空间的分配效率以及内存分配管理带来了很多问题。另一种解决办法是编译库文件时指定-fPIC选项,生成地址无关代码。
地址无关代码是如何实现的呢?下面针对四种情形进行讨论。
(1) 共享库内部的函数调用
如果是共享库内部的函数访问,可以采用相对地址调用形式,基于调用处和被调函数的地址偏移量进行访问即可。这样即使共享库被加载到不同地址,相对偏移量仍然保持不变,访问仍然生效。
(2) 共享库内部的数据访问
如果是共享库内部的数据访问,由于共享库是一整个模块被装载进内存,代码和数据的相对地址偏移保持不变,也可以采用相对地址调用形式进行访问。
(3) 共享库对其它模块的数据访问
对于共享库对其它模块的数据访问,由于共享库在运行时才被加载,自身地址需要等到运行时才可以确定,也无法通过相对地址对其它模块数据进行访问。此时,编译器抽象了一个中间层,将这些对其它模块访问的数据放在一个全局偏移量表GOT(global offset table)中,GOT表中每一项为数据实际的访问地址,会在共享库加载时被重定位,每个程序加载后各自GOT表内元素的地址并不相同。共享库的代码段指令只是对GOT表中对应项进行访问,由于GOT表和代码段的相对地址固定,因而可以通过相对地址调用的形式进行调用。
(4) 共享库对其它模块的函数调用
对于共享库对其它模块的函数访问,也和数据访问采用类似机制,将访问函数地址存放于GOT表中,在加载时进行重定位,共享库代码中只基于相对地址调用对GOT表对应项进行访问。
基于以上处理方式,可以在编译共享库时生成地址无关代码,尽最大程度实现动态库的共享。
3.共享库文件相关段介绍
基于objdump -h 可以看到libvector.so的相关段,相比静态库文件libvector.a 主要有以下段不同:
$ objdump -h libvector.so
libvector.so: 文件格式 elf64-x86-64
节:
Idx Name Size VMA LMA File off Algn
2 .dynsym 00000180 0000000000000238 0000000000000238 00000238 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .dynstr 000000c3 00000000000003b8 00000000000003b8 000003b8 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
6 .rela.dyn 000000f0 00000000000004c0 00000000000004c0 000004c0 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
7 .rela.plt 00000030 00000000000005b0 00000000000005b0 000005b0 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
18 .dynamic 000001c0 0000000000200e08 0000000000200e08 00000e08 2**3
CONTENTS, ALLOC, LOAD, DATA
19 .got 00000038 0000000000200fc8 0000000000200fc8 00000fc8 2**3
CONTENTS, ALLOC, LOAD, DATA
20 .got.plt 00000028 0000000000201000 0000000000201000 00001000 2**3
CONTENTS, ALLOC, LOAD, DATA
(1).dynsym段
静态库文件中有.symtab段用以保存所有的局部和全局符号信息。对于共享库文件,除了拥有.symtab段以外,还具有.dynsym段,它保存了该共享库文件定义(导出) 和引用(导入)的全局符号信息。
基于readelf -sD 可以查看动态链接符号段内容,内容格式和.symtab段相同
$ readelf -sD libvector.so
Symbol table of `.gnu.hash' for image:
Num Buc: Value Size Type Bind Vis Ndx Name
7 0: 0000000000201030 4 OBJECT GLOBAL DEFAULT 22 mulCount
8 0: 0000000000201028 0 NOTYPE GLOBAL DEFAULT 21 _edata
9 0: 0000000000201038 0 NOTYPE GLOBAL DEFAULT 22 _end
10 1: 000000000020102c 4 OBJECT GLOBAL DEFAULT 22 addCount
11 1: 00000000000007a0 132 FUNC GLOBAL DEFAULT 11 mulVec
12 1: 0000000000201028 0 NOTYPE GLOBAL DEFAULT 22 __bss_start
13 1: 00000000000005e0 0 FUNC GLOBAL DEFAULT 9 _init
14 2: 0000000000000824 0 FUNC GLOBAL DEFAULT 12 _fini
15 2: 0000000000000718 133 FUNC GLOBAL DEFAULT 11 addVec
2).dynstr段
与.strtab类似,存放动态链接相关的字符串名称。
(3).got段 .got.plt段
动态链接时生成GOT表同时也会生成PLT表(过程链接表) .got段是外部数据和函数引用的全局偏移量表 .got.plt段是外部函数引用的跳转表 每个引用的外部函数均在PLT表有对应一个条目信息。
(4).rela.dyn段 .rela.plt段
与.rela.text段 .rela.data段类似,存放动态链接所需的重定位信息。.rela.dyn 存放数据引用的信息,.rela.plt 存放函数引用的信息。
基于readelf -r 可以查看动态链接重定位表信息:
$ readelf -r libvector.so
重定位节 '.rela.dyn' 位于偏移量 0x4c0 含有 10 个条目:
Offset Info Type Sym. Value Sym. Name + Addend
000000200de8 000000000008 R_X86_64_RELATIVE 6e0
000000200df0 000000000008 R_X86_64_RELATIVE 6a0
000000200e00 000000000008 R_X86_64_RELATIVE 200e00
000000200fc8 000200000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_deregisterTMClone + 0
000000200fd0 000700000006 R_X86_64_GLOB_DAT 0000000000201030 mulCount + 0
000000200fd8 000a00000006 R_X86_64_GLOB_DAT 000000000020102c addCount + 0
000000200fe0 000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
000000200fe8 000400000006 R_X86_64_GLOB_DAT 0000000000000000 _Jv_RegisterClasses + 0
000000200ff0 000500000006 R_X86_64_GLOB_DAT 0000000000000000 _ITM_registerTMCloneTa + 0
000000200ff8 000600000006 R_X86_64_GLOB_DAT 0000000000000000 __cxa_finalize + 0
重定位节 '.rela.plt' 位于偏移量 0x5b0 含有 2 个条目:
Offset Info Type Sym. Value Sym. Name + Addend
000000201018 000300000007 R_X86_64_JUMP_SLO 0000000000000000 __gmon_start__ + 0
000000201020 000600000007 R_X86_64_JUMP_SLO 0000000000000000 __cxa_finalize + 0
重定位表格式和之前静态链接重定位表类似,主要介绍一下几种重定位类型。
R_X86_64_GLOB_DAT R_X86_64_JUMP_SLO: 共享库在加载时 重定位表指向的GOT表字段地址直接被替换为该符号的实际地址。
R_X86_64_RELATIVE:一般用于模块内的静态数据访问。示例如下:
static int num = 10;
static int *numPtr = #
numPtr指向num的地址 但共享库装载到不同进程时 num的地址各不相同,此时共享库重定位numPtr值时一般采用R_X86_64_RELATIVE方式,在编译共享库时,numPtr值为num在共享库地址空间中的偏移量A;加载共享库时,基于加载的位置B 重置numPtr值为A+B;
4.动态链接过程
(1)动态链接器自举
前面已经简单介绍过,可执行文件在执行时首先会将控制权交给动态链接器。那么动态链接器位于系统的那个位置呢?不同操作系统中链接器位置可能不同,在可执行文件的.interp段中保存了动态链接器的位置信息。如下所示:
$ objdump -s vecFoo2
vecFoo2: 文件格式 elf64-x86-64
Contents of section .interp:
400238 2f6c6962 36342f6c 642d6c69 6e75782d /lib64/ld-linux-
400248 7838362d 36342e73 6f2e3200 x86-64.so.2.
$ ll /lib64/|grep ld-linux
lrwxrwxrwx 1 root root 10 6月 19 2018 ld-linux-x86-64.so.2 -> ld-2.17.so
可以看到保存的动态链接器为软链接文件 指向了实际的动态链接器ld-2.17.so。动态链接器是一个特殊的共享库文件,它主要负责其它共享库文件的加载和重定位工作,因此不会使用全局变量和静态变量,也不会调用函数,避免产生无穷递归。
(2)装载共享对象
动态链接器自举完成后,会将可执行文件符号和动态链接器符号合并到一个全局符号表中,然后开始加载依赖的共享库文件。
那么如何知道一个可执行文件依赖了那些共享库文件呢?在可执行文件的.dynamic段保存了动态链接器需要的基本信息。可以基于readelf -d 命令查看:
$ readelf -d vecFoo2
Dynamic section at offset 0xe18 contains 25 entries:
标记 类型 名称/值
0x0000000000000001 (NEEDED) 共享库:[./libvector.so]
0x0000000000000001 (NEEDED) 共享库:[libc.so.6]
0x000000000000000c (INIT) 0x400580
0x000000000000000d (FINI) 0x4007a4
0x0000000000000019 (INIT_ARRAY) 0x600e00
0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
0x000000000000001a (FINI_ARRAY) 0x600e08
0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)
0x000000006ffffef5 (GNU_HASH) 0x400298
0x0000000000000005 (STRTAB) 0x400408
0x0000000000000006 (SYMTAB) 0x4002d0
0x000000000000000a (STRSZ) 195 (bytes)
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000015 (DEBUG) 0x0
0x0000000000000003 (PLTGOT) 0x601000
0x0000000000000002 (PLTRELSZ) 96 (bytes)
0x0000000000000014 (PLTREL) RELA
0x0000000000000017 (JMPREL) 0x400520
0x0000000000000007 (RELA) 0x400508
0x0000000000000008 (RELASZ) 24 (bytes)
0x0000000000000009 (RELAENT) 24 (bytes)
0x000000006ffffffe (VERNEED) 0x4004e8
0x000000006fffffff (VERNEEDNUM) 1
0x000000006ffffff0 (VERSYM) 0x4004cc
0x0000000000000000 (NULL) 0x0
STRTAB:动态链接字符串表地址
SYMTAB:动态链接符号表地址
RELA:动态链接重定位表地址
NEEDED:动态链接依赖的共享库路径。
基于NEEDED字段可以看到vecFoo2依赖的共享库为libvector.so 和 libc.so。
也可以基于ldd查看依赖的共享库文件:
$ ldd vecFoo2
./libvector.so (0x00007fa16fc4c000)
libc.so.6 => /lib64/libc.so.6 (0x00007fa16f888000)
确定了可执行文件依赖的共享库文件以后,动态链接器会将共享库文件加载到内存空间,如果该共享库还依赖其它共享库文件,则继续加载。加载过程类似图遍历算法,动态链接器可以按照广度优先或者深度优先等方式进行加载。如果在加载过程中多个共享库文件存在全局同名符号,则只将第一个全局符号加入全局符号表,后续的全局符号将被忽略,后续的共享库中对该符号的使用也将使用第一个符号。
上述全局符号在链接动态链接过程的覆盖现象也称为全局符号介入,正是由于该现象存在,在共享库文件中定义的非静态全局函数和非静态全局变量,我们不能按照地址无关代码中模块内函数访问或者数据访问规则处理,而是应该按照共享模块对其它模块的函数调用以及数据访问来处理,因为这些全局对象可能被其它模块的全局对象覆盖。
所以 对于我们addvec.c中的全局变量addCount mulvec.c中的mulCount 编译器均是按照GOT表进行间接跳转的形式访问数据。通过objdump验证如下:
$ objdump -s -d libvector.so
libvector.so: 文件格式 elf64-x86-64
Contents of section .got:
200fc8 00000000 00000000 00000000 00000000 ................
200fd8 00000000 00000000 00000000 00000000 ................
200fe8 00000000 00000000 00000000 00000000 ................
200ff8 00000000 00000000 ........
0000000000000718
718: 55 push %rbp
719: 48 89 e5 mov %rsp,%rbp
71c: 48 89 7d e8 mov %rdi,-0x18(%rbp)
720: 48 89 75 e0 mov %rsi,-0x20(%rbp)
724: 48 89 55 d8 mov %rdx,-0x28(%rbp)
728: 89 4d d4 mov %ecx,-0x2c(%rbp)
72b: 48 8b 05 a6 08 20 00 mov 0x2008a6(%rip),%rax # 200fd8 <_DYNAMIC+0x1d0> 取addCount
732: 8b 00 mov (%rax),%eax
734: 8d 50 01 lea 0x1(%rax),%edx
.......
00000000000007a0
7a0: 55 push %rbp
7a1: 48 89 e5 mov %rsp,%rbp
7a4: 48 89 7d e8 mov %rdi,-0x18(%rbp)
7a8: 48 89 75 e0 mov %rsi,-0x20(%rbp)
7ac: 48 89 55 d8 mov %rdx,-0x28(%rbp)
7b0: 89 4d d4 mov %ecx,-0x2c(%rbp)
7b3: 48 8b 05 16 08 20 00 mov 0x200816(%rip),%rax # 200fd0 <_DYNAMIC+0x1c8> 取mulCount
7ba: 8b 00 mov (%rax),%eax
7bc: 8d 50 01 lea 0x1(%rax),%edx
7bf: 48 8b 05 0a 08 20 00 mov 0x20080a(%rip),%rax # 200fd0 <_DYNAMIC+0x1c8>
7c6: 89 10 mov %edx,(%rax)
........
可以看到addCount mulCount地址为200fd8 200fd0 Got表起始地址为200fc8 两者均位于GOT表中。并且看到GOT表当前地址均为0x00000000 因为具体地址需要在加载时进行重定位。
(3)符号重定位
上面步骤完成后 我们已经建立了一个完整的全局符号表。接着动态链接器会遍历可执行文件和共享库文件的重定位表,对GOT PLT表相关地址进行重定位修正。重定位修正结束后,动态链接器的主要工作基本完成,将控制权交还给可执行程序入口,开始执行。
5.延迟绑定
动态链接相比静态链接具有节省磁盘 内存 支持插件式编程 更加灵活等优势,但是由于需要在运行时加载共享库 进行符号解析,因而运行性能会稍差一些。 特别是程序启动时 统一进行共享库符号的解析和重定位,会导致启动变慢。
延迟绑定是优化启动性能的实现方式,基本思想是在加载共享库时并不进行符号重定位工作,而是在符号第一次运行时进行重定位。延迟绑定基于前面介绍的GOT表和PLT表进行工作,简要流程如下:
(1)调用某共享库函数时,指令跳转到PLT表对应条目
(2)PLT表判断函数对应的 GOT 表项是否已经被重定位
(3)如果重定位完成,PLT表直接将代码跳转到目标地址执行
(4)如果未重定位,调用动态链接器为当前的函数进行重定位,重定位完成之后再跳转。
6.显式加载共享库
除了基于共享库编译可执行文件 运行时加载共享库以外,linux系统还提供了以下API用于显式在运行时加载 卸载一个共享库文件,根据名称查找共享库中的某个函数或者变量地址 并进行执行或者读写等操作。
// 加载磁盘中的共享库文件 也可支持延迟绑定
void *dlopen (const char *__file, int __mode);
// 关闭某个共享库文件 只有加载该库的所有进程均关闭后 共享库才会被卸载
int dlclose (void *__handle);
// 根据名称查找共享库中的某个符号的运行时地址
void *dlsym (void *__restrict __handle, const char *__restrict __name);
// 上述函数执行失败后 可以通过dlerror输出错误信息
char *dlerror (void);
7.linux共享库组织
(1) ABI兼容性问题
基于共享库的动态链接技术的另一个问题是保证可执行文件与共享库文件的二进制接口(ABI)兼容性较为困难。以C语言为例,共享库开发者的以下修改可能产生ABI兼容性问题:
a.导出函数的行为发生改变 调用此函数后功能产生变化
b.导出函数被删除
c.导出数据的结构发生变化,如果成员删除 顺序改变 大小改变等
d.导出函数的接口变化 如返回值 参数变化
除此以外,编译器或者编译器版本的不同 操作系统 硬件平台的差异 都可能导致ABI兼容性问题产生。
(2)共享库路径及名称
linux系统中 共享库一般位于以下目录中:
/lib 系统运行时需要的关键 基础共享库
/user/lib 系统运行时非关键性共享库
/user/local/lib 依赖的一些非系统性的第三方应用程序库等
linux系统中 一般按照libname.so.x.y.z的形式命名共享库:
x:主版本号 库有重大升级 不同x不兼容
y:次版本号 增量升级 原来符号不变 新增符号 兼容旧版本
z:发布版本号 库的错误修正 性能改进 接口不变
由于共享库的名称会随着版本更新而进行更新变动,而可执行文件中会记录了依赖的共享库名称,为了避免在次版本号 发布版本号变更时重新编译可执行文件,linux系统采用SO-NAME的方式对于共享库文件进行间接命名处理。
(3)SO-NAME
SO-NAME命名机制如下:
a.每个共享库so文件都在同目录下有一个只保留主版本号的软链接文件指向该so 如libfoo.so.2 指向libfoo.so.2.6.1
b.共享库升级时 若主版本不变则该链接文件时刻指向该版本的最新so
c.编译器编译可执行文件时只需通过-l libfoo指定链接的共享库 linux系统在指定路径下查找到该链接文件进行编译
d.最终编译生成的可执行文件中.dynamic字段保存的也是链接文件的名称 运行时根据链接文件加载最新该版本共享库
(4)共享库查找过程
在加载共享库时,一般按照如下路径进行查找:
a.如果elf文件 .dynamic字段保存的依赖so是绝对路径 则去绝对路径查找 如果是相对路径 则去/lib /user/lib 以及/etc/ld.so.conf配置文件指定的目录查找
b.如果每次elf文件运行时都查找一遍 比较耗时。linux系统中有一个ldconfig程序建立了每个共享库SO-NAME到具体路径的cache 这样会先在cache查找 找不到再去以上路径
c.LB_LIBRARY_PATH可以修改某个应用程序的共享库路径 查找时先从此路径查找 然后才按照上述方式查找。对于我们测试新的so共享库较为方便。
d.LD_PRELOAD 可以指定预先装载一些共享库 会在动态链接器搜索共享库之前就装载 比上述搜索过程更早。由于全局符号介入规则,可以用它指向一些测试共享库,比如改写C标准库的部分函数编译成so进行测试
参考:
1.深入理解计算机系统
2.程序员的自我修养