本篇将主要结合CSAPP介绍ELF文件的格式,常用的分析工具如objdump和readelf,以及Linux程序链接和装载的流程。
工具介绍
readelf
参数
a
h,–file-header
显示elf文件头,一个elf文件头通常如下所示1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20ELF Header:
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
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x1070
Start of program headers: 64 (bytes into file)
Start of section headers: 15000 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 11
Size of section headers: 64 (bytes)
Number of section headers: 29
Section header string table index: 28l,–program-headers/–segments
显示段头部表(program header table),通常包含Program Headers和Section to Segment mappingS,–section-headers/–sections
显示节头部表(section header table),通常如下所示g,–section-groups
t,–section-details
更具体的Se
等价于h+l+S,显示所有的头部s,–syms/–symbols
可以组合其它选项:
-sD表示显示dynamic的symbol–dyn-syms
这个应该也是显示.dynsym的,它和-sD的区别是什么呢?n
r
u
d,–dynamic
展示.dynamic的内容V
A
c
D
见-sx,–hex-dump=<number|name>
将某个number/name的Section dump下来p
R
z
–dwarf-depth=N
–dwarf-start=N
–ctf=<number|name>
–ctf-parent=<number|name>
–ctf-symbols=<number|name>
–ctf-strings=<number|name>
-W
objdump
参数:
- -r
查看重定向表 - -R
查看动态重定向表
一个Demo
main.c
1 | void swap(); |
swap.c
1 | extern int buf[]; |
构建项目,注意为了吻合 CSAPP 的 Demo,需要指定 32 位模式,在一些编译器上还需要指定 -no-pie
,见后文说明
1 | gcc -m32 -g -o p main.c swap.c |
附上 readelf -a p
记录
注意,里面有很多省略号,代表大块的 0,这个可以通过 -z
来强制显示。
附上 objdump -x -D -s p
记录
Demo 的系统和编译器是
1 | DISTRIB_ID=Ubuntu |
为了支持 m32,需要安装 gcc-multilib
1 | apt install gcc-multilib |
gcc-multilib
的安装时如果出现下面的错误,查看是否自己的 apt 版本有问题,我是因为在 Ubuntu16 上面用来了 Ubuntu14 的 /etc/apt/sources.list
1 | The following packages have unmet dependencies: |
编译,这里 C 库默认是动态链接的,如果要静态链接需要指定 -static
。此外,某些版本会自动开启 PIE,这样 readelf 会显示是 DYN 而不是 EXEC,objdump 无法得到绝对地址,这样就无法调试了,所以要指定-no-pie
。这么做的一个原因是地址空间配置随机加载(ASLR),让一个程序加载到随机而不是固定的 0x400 地址。可以通过下面的代码来验证。
1 |
|
ELF 和 Linux
权限
当你对某个 ELF 做写操作(例如 >
重定向写入、以写方式打开并 truncate、直接覆盖二进制)而该文件正被系统当作程序 text 部分使用(被某个进程执行或以可执行方式 mmap)时,内核通常会返回 ETXTBSY,拒绝写入以避免破坏运行中的程序代码。
主要是为了一致性与安全:
- 如果在磁盘上修改正在执行的代码,已经映射到内存的进程可能会出现不可预测的行为或崩溃(映射和磁盘内容不同步)
- 传统 Unix 会锁定 text 区以防写入,避免同时修改正在运行的程序
注意,我们是可以对这个文件进行重命名的,例如
1 | # 编译到 bin.new |
原因是重命名后,会得到一个新的 inode,运行的进程继续持有老的 inode。
ELF 结构
历史
Unix 首先提出了 COFF 格式,该格式后来演化为了 PE 和 ELF 格式。
COFF 格式的特点是引入了段的机制。
Header
可以通过 readelf -h
读取 ELF 头部。
其中:
- REL 一般是 .o 等待重定位的文件
- DYN 一般是动态库
但可执行文件可能也是 DYN 而不是 EXEC。例如指定 pie 编译。 - EXEC 一般是可执行文件
两个头部表
段(segment)和节(section)
段(segment),这里指的比如代码段(.text)、数据段(.data)、只读数据段(.rodata)、Block Started by Symbol段(.bss)等。
【需要注意的是,这里段头部表和节头部表的中文语义似乎不明确,比如在自我修养一书中就称 Section Header Table 为段表,因此下面尽量避免使用这两个词,而是用 program 表和 section 表代替】
section header table 描述了 ELF 二进制文件的布局。可以通过 readelf -S
指令查看节头部表,注意不要和 -s
选项混用,后者是查询符号表的。
program header table 描述了 ELF 内存中的布局。可以通过 readelf -l
指令查看段头部表。
Program 表
移动到“装载”章节中。
Section 表
查看 section 表。需要注意的是这里是静态 ELF 的表。动态 ELF,例如 SO 文件的表会多一些字段。
Section 表的各个列
section 表被实现为 Elf32_Shdr 数组,其中的各个列的说明如下:
- Nr
- Name
表示 section 的名字,它实际位于.shstrtab
这个字符串表中,在Elf32_Shdr
结构中实际记录的是一个偏移。 - Type
专门讨论。 - Addr
如果该段可以被加载,那么 Addr 是加载后在进程地址空间中的虚拟地址。
如果该段不能被加载,则为 0。 - Off
对应于 Addr,这里指的是相对于文件中的位置。 - Size
- ES(Section Entry Size)
如果这个段中包含了一些固定大小的项目,ES 表示每个项目的大小。
例如.symtab
的ES
就是 10,表示每个项是 10 字节。 - Flg
- Lk(Link)/Inf(Info)
对于 RELA/REL 段,Lk 表示该段使用的符号表对应到 section 表中的 Nr,一般是.symtab
或者.dynsym
的 Nr。
Inf 表示这个重定位信息为哪个段服务的。例如.ref.text
是为.text
服务的,所以它的 Inf 就是.text
的 Nr。 - Al(Section Address Alignment)
Section Headers 的 Type
Section Headers 的 Type 具有下面的几种情况:
- NULL:
一般是 Nr=0 端独有的 Type - PROGBITS:代码段和数据段
.interp、.init、.fini
.plt、.plt.got、.got
.text
.data、.rodata - SYMTAB:符号表
.symtab 段 - STRTAB:字符串表
.strtab 和 .dynstr 段等 - RELA/REL:重定位表
静态链接时,.rel.text
和.rel.data
用来重定位.text
和.data
。
动态链接时,.rel.dyn
和.rel.plt
用来重定位.rel.data
和.rel.text
。 - HASH:符号表的哈希表
- DYNAMIC:动态链接信息
.dynamic
段 - NOTE:
- NOBITS:
.bss
段 - SHLIB
- DYNSYM:动态符号表
.dynsym
段
示例
1 | $ readelf -S p |
具体段名介绍
介绍一下几个段名:
.symtab
是一个符号表,包含引用的函数和全局变量的信息。符号表并不一定需要-g
选项才会生成。
可是上面的示例中一开始执行readelf -l p
并没有看到有.symtab
。其实执行readelf -S p
就能看到了。我觉得可能是因为readelf -l
只显示会被装载到内存中的段,而.symtab
是静态符号表,不需要被装载。反观.dynsym
就会被装载到内存中。.dynsym
是动态符号表。可以通过readelf -sD
查看动态符号表。
符号表.symtab
中往往也会包括动态符号,但动态符号表中只有动态符号。.dynamic
如果程序连 C 库和 C++ 库都是静态链接的,例如开启了-static
,那么它就不会有 dynamic 段。
否则,它就会具有 .dynamic 段,表示动态链接相关信息。.rel.text
和.rel.data
用来记录在.text
中遇到的外部符号,例如printf
函数的位置;对应的还有.rel.data
服务于.data
。
容易看出:- 目标文件中有这两个段
- 静态的可执行文件来中不存在这两个字段,因为已经没有什么需要重定位的了
- 动态的可执行文件,实验也没发现有这样的段。我理解它们的作用被
.got
、.got.plt
和.plt
替代了。
.strtab
字符串表,里面是普通的字符串。.shstrtab
是段表字符串表。包含段名等信息。可以通过下面命令来查看字符串表的内容。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22$ readelf -x33 p
Hex dump of section '.shstrtab':
0x00000000 002e7379 6d746162 002e7374 72746162 ..symtab..strtab
0x00000010 002e7368 73747274 6162002e 696e7465 ..shstrtab..inte
0x00000020 7270002e 6e6f7465 2e414249 2d746167 rp..note.ABI-tag
0x00000030 002e6e6f 74652e67 6e752e62 75696c64 ..note.gnu.build
0x00000040 2d696400 2e676e75 2e686173 68002e64 -id..gnu.hash..d
0x00000050 796e7379 6d002e64 796e7374 72002e67 ynsym..dynstr..g
0x00000060 6e752e76 65727369 6f6e002e 676e752e nu.version..gnu.
0x00000070 76657273 696f6e5f 72002e72 656c2e64 version_r..rel.d
0x00000080 796e002e 72656c2e 706c7400 2e696e69 yn..rel.plt..ini
0x00000090 74002e70 6c742e67 6f74002e 74657874 t..plt.got..text
0x000000a0 002e6669 6e69002e 726f6461 7461002e ..fini..rodata..
0x000000b0 65685f66 72616d65 5f686472 002e6568 eh_frame_hdr..eh
0x000000c0 5f667261 6d65002e 696e6974 5f617272 _frame..init_arr
0x000000d0 6179002e 66696e69 5f617272 6179002e ay..fini_array..
0x000000e0 64796e61 6d696300 2e646174 61002e62 dynamic..data..b
0x000000f0 7373002e 636f6d6d 656e7400 2e646562 ss..comment..deb
0x00000100 75675f61 72616e67 6573002e 64656275 ug_aranges..debu
0x00000110 675f696e 666f002e 64656275 675f6162 g_info..debug_ab
0x00000120 62726576 002e6465 6275675f 6c696e65 brev..debug_line
0x00000130 002e6465 6275675f 73747200 ..debug_str..got
、.got.plt
和.plt
【相关详细说明,见后文】
这些表是用来在装载动态链接库的时候寻找动态符号实际的位置的。其中:.got
表记录了每个对象(例如函数、全局变量)装载后的实际地址.got.plt
可以理解为 GOT 的一个子集。.got
、.got.plt
共同构成了 GOT 表。.got.plt
表记录了每个函数装载后的实际地址.plt
中列出了所有的@plt
为后缀的“函数”,这个段也是可执行的,用来实现 PLT 延时绑定的机制。
可以通过
objdump -D
查看它们,注意这里是-D
而不是-d
,因为-D
能够反汇编所有 Section。可以通过objdump -R
查看 GOT 中的重定位项。.rel.dyn
和.rel.plt
如果动态链接库还依赖于其他动态链接库,那它里面的符号也需要重定位。无论是否是 PIC 模式,都需要重定位。我们在后文探讨。
对应于.rel.data
和.rel.text
,相当于动态链接的重定位表。.init
和.fini
可以用来执行 C++ 中全局/静态变量的构造或者析构操作。.line
是 C 中的行号和机器地址的对照表。debug 相关段
.debug_*
目前 ELF 下 debug 相关的段的格式称为 DWARF(Debug With Arbitrary Record Format)。PE 下的是 CodeView。
调试信息可以在编译时通过-g
得到,对应用程序运用strip
消除。
当程序真正被加载到 Linux 内存中时,呈现下面的布局。可以看到,栈是从高往低增长的,ES P是栈顶;堆是由低往高增长的,由 brk 限制。
符号表
结构
符号表是目标文件/可执行文件中的一个或者多个 section,记录了里面用到的所有符号。
首先介绍一下结构。
- Ndx
表示符号所在的 Section。取值为- ABS,一个数值
可以通过查readelf -S
,对应到的 Nr 序号。 - COM
表示是一个 COMMON 块类型的符号,例如未初始化的全局符号。参考下面的bufp1
。
COMMON 块很熟悉,其实也确实来自 Fortran 的一个概念。 - UND
表示是一个未定义的符号,通常说明是在这个文件中被引用,但定义在其他文件中。
- ABS,一个数值
- Value
需要讨论:- 如果是目标文件,且符号不是 COMMON 类型
Value 为该符号在序号为 Ndx 的段 Section 中的偏移。 - 如果是目标文件,且符号是 COMMON 类型
表示对齐属性 - 如果是可执行文件
Value 表示符号的虚拟地址,对动态链接器来说很有用。
- 如果是目标文件,且符号不是 COMMON 类型
- Bind
GLOBAL 表示全局符号。
LOCAL 表示局部符号。例如 static 变量只在当前编译单元内可见,所以它是 LOCAL 的。
WEAK 表示弱引用。
注意,弱引用和弱符号的概念有区别:强符号包含函数和已初始化的全局变量;弱符号包括未初始化的全局变量。可以通过__attribute__((weak))
的 annotate(自我修养里面用的是定义)一个符号是弱符号。
可以通过__attribute__((weakref))
定义对一个外部函数的引用是弱引用。对于弱引用,在链接期如果找不到它的定义,不会报错,而是赋一个0或者其他便于识别的值,如果在运行期还找不到定义,才会出错。
通过弱引用,可以声明比如pthread_create
函数为弱引用,然后在运行期判定pthread_create
的地址是否为0,来判断是否是多线程程序。 - Type
- NOTYPE,未知类型
- OBJECT,数据对象
例如一个 scalar,或者数组 - FUNC,函数或者其他可执行代码
容易想到,FUNC 对应.text
的 Ndx - SECTION,表示一个 Section
没错,Section 也会体现在符号表中。Session 符号的 Bind 一定是 LOCAL。
readelf 中这些条目并不包含具体 Section 的名字,可以通过 Ndx 去readelf -S
的结果中对应寻找,或者通过objdump -t p
打印出符号表来看到。 - FILE,文件名,此时这个符号的 Bind 一定是 LOCAL,并且 Ndx 一定是一个 ABS 序号
- Vis
可见性,一般是 DEFAULT。诸如__x86.get_pc_thunk.ax
的是HIDDEN。
可以总结得到下面的规律:
- 初始化的全局变量
Type 为 Object、Bind 为 Global、Ndx 通常为.data
或者.bss
,取决于是否初始化为0。 - 未初始化的全局变量
Type 为 Object、Bind 为 Global、Ndx 为 COM。 - 静态变量
Type 为 Object、Bind 为 Local、Ndx 为 .data 或者 .bss。 - 不在当前编译单元内定义的函数
Type 为 NOTYPE、Bind 为 Global、Ndx 为 UND。 - 在当前编译单元内定义的函数
Type 为 FUNC、Bind 为 Global、Ndx 为 .text。 - 文件名
Type为FILE、Bind为Local、Ndx为ABS。 - Section
Type为SECTION、Bind为Local、Ndx为对应Section的Nr、Name为空,但可以由Ndx查到。
Demo
查看 main.o 的符号表,发现其中的 swap
符号对应是 UND
,表示是在本模块中引用了,但是未定义的符号。我们知道在另一个编译单元 swap.o 中定义的这个符号。
1 | $ readelf -s main.o |
而 buf 对应了 Ndx
为 3,查看 Section 表,3 表示 .data
段。在源码中有 int buf[2] = {1, 2};
,因此 buf 被分到 .data
是容易理解的。
1 | $ readelf -S main.o |
下面再看 swap.o 的符号表,这里 buf 是 UND 容易理解,因为它是一个外部变量 extern int buf[]
。
关于 bufp1 值得重点说下,这是一个全局未初始化的变量,我觉得应该是走 zero initialization 而不是 default initialization,所以 bufp1
的应该是在 bss 段,即 Ndx=4,但是这边给出的是 Ndx=COM。其实两者都没有错,如果按照 C++ 的方式编译 g++ --std=c++14 -m32 -g -c -o swap.o swap.cpp
,就可以发现 bufp1 确实就在 bss 段了。所以在处理全局未初始化变量上,C 和 C++ 的标准是不一样的。
1 | Num: Value Size Type Bind Vis Ndx Name |
Name Mangling
GCC
1 | _Z [N 名字空间1长度 名字空间1 名字空间2长度 名字空间2 ...] 函数名长度 函数名 E 参数列表 |
MSVC
链接
两步链接(Two-phase linking):
- 分配空间和地址
收集每个目标文件各个段的信息。
构建全局符号表。 - 解析符号和重定位符号
Section的地址
可以通过 objdump -h
或者 readelf -S
查看每个 Section 的地址。通过 readelf -l
查看 Section 和 Segment 的对应关系。其中:
- VMA(Virtual Memory Address)
表示虚拟地址,也就是这个段在程序运行时候的地址。 - LMA(Load Memory Address)
表示加载地址,也就是我们把这些段里面的内容复制到内存的某个地址处。
容易想到,程序加载到哪里,程序就在哪里运行,所以 VMA 应该等于 LMA。
但是这个有特例。在一些嵌入式系统中,程序被加载在 ROM 中。即使可以在 ROM 中执行.text
段的代码,也不能在 ROM 中修改.data
段的数据,更何况 ROM 的读取速度要慢于 RAM。因此不可避免地需要将程序从 ROM 中的 LMA,复制到 RAM 中的 VMA 上。 - File Off
(下面展示了objdump -h
的部分结果)
1 | p: file format elf32-i386 |
刚说过 VMA 和 LMA 不一定相等,其实 VMA 和 File Off 也并不一定相等,比如:
对于可执行文件
事实上,如果readelf -x14 p
,查看.text
的段的内容的话,也能看到它起始地址0x080482e0
。1
2Hex dump of section '.text':
0x080482e0 31ed5e89 e183e4f0 50545268 a0840408 1.^.....PTRh....对应地,查看 Section 表,发现地址也已经被确定了。
1
2
3$ readelf -S p
Section Headers:
[14] .text PROGBITS 080482e0 0002e0 0001a2 00 AX 0 0 16对于目标文件
objdump -h
一下 main.o,可以看到它的起始地址是0x0
,而 File Off 是0x0000003c
。这是因为程序代码中使用的都是虚拟地址,在没有实际分配空间前,目标文件中代码段的起始地址为0。1
2
3
4Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000024 00000000 00000000 00000034 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE对应地,观察 main.o 里面的 Section 表,发现代码段 .text 的 Addr 仍然为 0;
1
2
3
4
5
6$ readelf -S main.o
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 00000000 000034 000024 00 AX 0 0 1
[ 2] .rel.text REL 00000000 0003a0 000008 08 I 18 1 4使用
readelf -x1 main.o
看一下,发现起始地址是0,因此不是以 File Off 为基准的。1
2
3Hex dump of section '.text':
NOTE: This section has relocations against it, but these have NOT been applied to this dump.
0x00000000 8d4c2404 83e4f0ff 71fc5589 e55183ec .L$.....q.U..Q..
目标文件的地址
其实,不仅目标文件中 .text
的初始地址为 0,具体代码中引用的地址也是 0。打开 main.c 中的 b 注释,其反编译如下(AT&T格式)
1 | 00000000 <main>: |
可以发现:
- 函数调用的地址是
fc ff ff ff
,即-4,也就是转移到 IP-4 即 15-4=11 的位置。这个位置是fc
自己,而不是一个函数地址。 - 赋值的地址是 0。
总而言之,这两个都不是一个合法的地址,在链接器最终装配成可执行文件后,这些地址才有可能被确定。现在问题来了,并不能从反汇编的代码中看到 0x0
和 fc ff ff ff
究竟是什么变量,这两个 placeholder 并没有什么意义。如果我来设计,我会给这个目标文件里面的变量/函数分配一个序号,然后在这里把序号填上去。但不管怎么样,ELF的实际做法,在“重定向”章节中进行介绍。
【Q】在WSL2上,反编译的结果不太一样,这是为什么?
重定向(静态链接)
本节中讨论静态链接的重定向问题。为什么存在这样的问题呢?原因有两个:
- 编译期并不知道所有引用符号的地址,所以需要重定向。我们每引用一次外部符号,就会在重定位表中产生一个条目。
- 代码跳转,需要指定对于当前IP的一个相对地址,这导致要花一章时间来讲是怎么算的。
对于静态链接而言,重定向只发生在静态链接期。例如,swap.o 或者 main.o 是存在重定位表 .rel.text
,但是在最终形成的被静态链接的可执行文件p中就不存在上述字段了。
观察目标文件和可执行文件的地址变化
观察 p 文件(可执行文件)
观察最终结果 ./p 的汇编。注意,在我的 Ubuntu 18.04.5 和 GCC 7.5.0 上面实际上会看到 main 的地址并不是 8028 开头的,这个可能是因为开启了 PIE,可以通过 -no-pie
禁用掉。
下面介绍一下 call
指令,它通过 e8
来识别,具有一个操作数 0a 01 00 00
即 0x010a
。这个操作数的地址就是 0x80482f2
。
call 的行为是将下一条地址 0x80482f6
压栈,然后跳转到被调用的函数 swap
的第一条命令 0x8048400
处。
0x8048400
这个地址是怎么算出来的呢?注意到 0x80482f6+0x010a=0x8048400
,所以 call
命令的操作数 0x010a
是相对于 EIP 寄存器的偏移,给的是相对地址而不是绝对地址。并且,此时 EIP 指向的是下一条指令的地址而不是 0x80482f1
,这是因为取指之后就会更新 EIP 了。
1 | $ objdump -d p |
从 objdump 中可以看到,0x8048400
处确实是 swap 的代码
1 | $ objdump -d p |
观察 main.o 文件(目标文件)
接着观察 main.o 的汇编,这个在上面“目标文件的地址”中已经讲解过了,但由于源程序和编译器稍有不一样,所以这里再保留代码。
这里 call
的偏移是 0xfffffffc(-4)
,经过计算就是 16-4=12
,对应的是 fc ff ff ff
的开头。
1 | $ objdump -d main.o |
现象总结
可以看到,从不可执行的 main.o 到可执行的 ./p,发生了两个显著的变化。
- 左边的地址对应了 main.o 的代码实际被加载的地址
32 位程序默认会被加载到0x8048000
处,而如之前提到的,在目标文件中这里是 0。 - 右边 call 的地址,从
0xfffffffc(-4)
变为了0x8048400
,这个对应了swap
的实际装载地址
原理
重定位表
对于每个需要被重定位的 ELF 段,都需要有一个重定位表,这个表也是一个 Section,因此重定位表也叫做重定位段。
- 如果
.text
中有重定位的地方,重定位表就是.rel.text
段 - 如果
.data
中有重定位的地方,它的重定位表就是.rel.data
段
可以通过 readelf -r
查看重定位表,通过 objdump -r
或 objdump -R
分别查看静态重定位表和动态重定位表。
什么是动态重定位表呢?对于静态链接的可执行文件来说,是没有 .rel.text
和 .rel.data
段的,因为他们需要的符号都被静态链接了。但对于一般的可执行文件,可能还存在.rela.dyn
(rel.dyn
)和.rela.plt
(rel.plt
)的动态重定位表。这是因为动态链接中需要的符号,可能在装载时才会被加载。
每个需要重定位的符号的出现,都会在重定位表中产生一个条目,例如调用一个外部函数 swap 多次,或者访问一个外部变量多次,那么会产生对应数量的条目。
介绍重定位表中各列的含义:
Offset
这个重定位条目需要修改的地址相对于所处 Section 段头的 offset。也就是后面看到的r.offset
。
另外,当被用来查看可执行文件或者 so 文件时,这个值表示需要修改的位置的虚拟地址。Info
低 8 位:重定位条目的类型。例如下面的0x02
。
高 24 位:重定位条目的符号在符号表中的下标。1
2
3
4
5$ readelf -r main.o
Relocation section '.rel.text' at offset 0x3a0 contains 1 entries:
Offset Info Type Sym.Value Sym. Name
00000012 00000f02 R_386_PC32 00000000 swap例如重定位表中的
0x00000f02
,高 24 位的值是0x00000f
也就是 15,可以对应查到 swap 在符号表中的位置。
与此同时可以看到,符号表中 swap 的 Ndx 是 UND 的,也正说明需要借助于重定位表才能确定具体的位置。1
2
3
4
5$ readelf -s main.o
Num: Value Size Type Bind Vis Ndx Name
...
15: 00000000 0 NOTYPE GLOBAL DEFAULT UND swapType
对于动态链接,还有更多的重定位类型,先不提。目前包含两种,在后面细讲:R_386_PC32=2
:相对寻址
这里相对的是那个call
命令的地址。R_386_32=1
:绝对寻址
Sym.Value
定义两个关键指标:
refaddr
refaddr
表示需要修改的call
的操作数位置。即上面 call 命令的地址0x80482f1
再加上 1。*refptr
*refptr
表示call
参数的最新值。
查看 main.o 的重定位条目表 .rel.text
,它提供了下列信息:
- 需要修改的重定位地址
refaddr
相对于main
的段头的偏移量是00000012
,0x080482e0 + 0x12
为0x80482f2
。
这个00000012
,就是call
的操作数的位置。 - 把重定位地址
refaddr
处的值改成程序swap
处的地址
计算过程讲解
如下图所示,重定位计算主要的一个过程是*refaddr=*refptr
。对应到重定位 swap,就是给 call
指令填上合适的参数。
寻址过程:
R_386_PC32=2
:相对寻址1
S + A - P
其中:
- S 是
swap
的绝对地址,即0x08048400
。 - A 是
0xfffffffc(-4)
,也就是*refptr
的旧值0xfffffffc(-4)
。 - P 是被修正位置的地址,也就是 call 操作数的位置,即
0x80482f1
。
- S 是
R_386_32=1
:绝对寻址1
S + A
相对寻址:函数 swap
下面进行具体计算,从重定位表看出,这是一个相对寻址,即R_386_PC32
,因此:
计算
refaddr
它等于ADDR(s) + r.offset
等于ADDR(main) + r.offset
,从前面的 objdump 的结果可以看到0x080482e0 + 0x12
为0x80482f2
,与实际结果吻合。计算
*refptr
因为call
是相对偏移,所以用swap
的绝对地址减去EIP的值得到。
swap
绝对地址可以从 objdump 看到,是0x8048400
,而call
命令的地址是0x80482f1
,所以*refptr
的值是1
0x8048400 - 0x80482f1 = 0x10f
不对啊,之前看到实际的命令的参数是
0x10a
啊?没错,还需要减去 A。
因为基址是0x80482f2+4=0x80482f6
,正好是下一条指令的开头。而这又是因为 EIP(即 PC)始终是指向下一条待执行的指令,所以计算偏移需要以此时 EIP 指向的位置来加上 call 指定的偏移。而0xfffffffc=-4
这个值正好帮助 EIP 跳过自己占用的 4 个字节。
绝对寻址:变量 buf
绝对寻址比较简单,一般用于变量。
此时 S 就是变量的实际地址,A 为 0x0。
重定位中某符号多次定义的类型不一致
容易看出,重定位中并不考虑符号的类型。因此会有下面的情况:
某强符号多次定义的类型不一致
因为强符号不能多次定义,所以一定会报错。某强符号和其他的弱符号定义的类型不一致
按照强符号为准。但如果发现有弱符号的size更大,则会产生一条警告1
alignment x of symbol `xxx` in yyy is smaller than y in zzz
某弱符号的多个定义的类型不一致
这里采用COMMON类型的链接规则,也就是按照多个定义中,size最大的为准。
通过实验可以得到下面的结果
1 | // 在Ubuntu 18.04下 |
进一步看一下之前的未初始化的全局变量在Common端的情况。我们nm一下,这里的ccc
确实是一个C标记,表示在Common端中。
1 | $ nm bss.o |
但是当我们将这个bss.c生成可执行文件(可能要去掉诸如f
等未定义符号,并且加上main)后,在看ccc
的标记,发现变成了B。
1 | $ nm bss |
所以可以看出,Common 段实际上是一个中间状态。链接器会按照取 size 最大的原则去 merge 同名的弱符号,或者取强符号。对于 C 语言而言,这个中间状态是必要的,因为编译器无法知道符号的类型,所以即使说要在编译期在 bss 段分配空间,也不知道要分配多大的。只有当链接器找到弱符号的所有出现之后,才能确切知道。
至于 C++ 为什么变了,可能是因为未初始化的全局变量也变成强符号了吧。实验在两个 c 文件中写 int i;
,然后用 gcc 编译能过,但是用 g++ 编译就过不了,显示下面的错误了。
1 | /tmp/ccPeXBAb.o:(.bss+0x0): multiple definition of `i' |
装载
装载流程
简介 Linux 的装载流程:
- 分配虚拟地址空间
- 读取并校验 ELF 头
- 寻找 .interp 段,设置动态链接器路径
- 将文件映射到虚拟地址空间中
- 初始化进程
- 将系统调用的返回地址修改为可执行文件的入口点
对于静态链接的可执行文件,入口点是 ELF 文件头中 e_entry 指向的地址。
对于动态链接的可执行文件,入口点是动态链接器。
Program表
program 表展示了从文件的 Section,到虚拟地址的 Segment之间的映射关系。内存中的一个 Segment 通常包含多个文件中的Section,属性相同的 Section 会被链接器聚集在一起,将来可以映射到一个 Segment 中。
查看 program 表,发现有两个 LOAD 段,分别对应了下面的 02 和 03 两个段,一般来说只有这两个段会被加载到内存。第一个段包含了 .text
、.rodata
等,这些段是只读的,所以 Flags
有 R,并且代码段 .text
是可执行的,所以 Flags
还有 E。这两个 LOAD 段都是按照 0x1000,即 4096 字节对齐的。
Entry point 对应程序入口,即 _start
函数,可以通过 ld -e
来修改。
1 | $ readelf -l p |
介绍一下列名:
- Type
- Offset
表示这个 Segment 在文件中的偏移。 - VirtAddr
表示 Segment 的第一个字节在进程虚拟地址空间中的起始位置,应该就是 VMA。
对于动态链接的共享库,可以在运行时通过dl_iterate_phdr
函数来计算当前库,和库里面的每个 Segment 被加载到的真实地址。 - PhysAddr
就是 LMA,一般和 VirtAddr 是一样的。 - FileSiz
表示 Segment 在文件中占用的长度,它是可能为 0。 - MemSiz
表示 Segment 在内存中占用的长度,它也可能为 0。 - Align
表示对齐。
对于 LOAD 类型来说,内存大小 MemSiz 不可能小于文件大小 FileSiz,否则这个 Segment 都无法装载到虚拟空间里面。
但是有时候在内存中会分配多于文件大小的空间,例如 BSS 段所占用的空间。可以结合 Section 表的来观察,首先找到 .bss
的大小是 0x8,然后找到第二个 LOAD 段的 MemSiz 是 0x124,而 0x124-0x8=0x11c
就是 LOAD 段的 FileSiz。Linux 对这个机制的实现会导致包含 .data 的 LOAD 段的结束地址比期望要小。
在进程运行时,可以通过 cat proc/.../maps
(macOS中使用vmmap)来看到进程 p 的其他 VMA。通过下面的语句,可以看到 ./p 在运行时的所有 VMA,例如[heap]
、[stack]
、[vdso]
等。
1 | cat /proc/`ps aux | grep -w p | grep -v grep | grep -v gdb | awk '{print $2}'`/maps |
对应下面的例子解读:
- 第一列表示 VMA 的地址范围
- rwx 表示读写执行权限,p 表示私有,s 表示共享
- 后面一列是用来索引到映像文件(比如被装载的可执行文件)
- 诸如
00:00 0
的字段表示主设备号、次设备号和文件节点号
对于 [stack] 而言它们都是 0,表示这些 Segment 没有被映射到文件中。这种 VMA 称为 Anonymous Virtual Memory Area。
1 | 555ad5321000-555ad5342000 rw-p 00000000 00:00 0 [heap] |
通过比较,可以简单进行划分:
- 代码 VMA
rx - 数据 VMA
rwx - 堆 VMA
rwx,可以往更小的地址扩展 - 栈 VMA
rw,可以往更大的地址扩展
段页映射
段地址对齐
PAE技术
PAE 技术允许在最大 64G 的内存中同时跑多个进程,但每个进程最大还是 4GB。
动态链接
相比静态链接的重定位,动态链接过程因为很多符号的地址要在加载时才能确定,所以更加复杂。
下面我们将讨论一些方案,包括使用 PLT 的延迟加载。
动态链接程序的加载
动态链接程序在装载时必须重定位。否则,可以考虑下面的情况:
- 程序 A 需要地址 1000-1999
- 程序 B 需要地址 2000-2999
- 程序 C 需要地址 3000-3999
- 程序 D 需要动态加载程序 A 和 B
那么 3000-3999 的空间会被浪费 - 程序 E 需要动态加载程序 A 和 C
那么 2000-2999 的空间会被浪费
有人说,在链接期强行把程序 E 里面的程序 C 挪到 2000-2999 就行了,这种手动为每个动态链接库分配的方式在大型程序维护上会非常痛苦。例如,链接后可执行文件都固定了,如果后来动态链接库升级,导致空间增长,原来的虚拟地址空间放不下了,这就很麻烦。
类似于目标文件的地址都是在链接期才确定一样,可以让动态对象在任意地址加载,而真正被加载的地址,从链接期推迟到运行期再决定。
在确定动态符号的地址时,动态链接器不能直接模仿静态链接器一样修改代码,因为 .text
段是只读的。根据thegreenplace上的这篇文章,在解决加载动态链接库中的重定向问题时,通常有两种办法:
- 基于 Load-time Relocation 机制
这个机制对应了-shared
的编译模式。
可以理解为将重定向从链接期delay到了执行期。主要思路是模块被装载到地址 A,而模块中代码段的 Offset 为 b,函数 f 对于代码段的 Offset 为 c,则遍历模块中的所有重定位表,将所有对 f 的引用都改为A+b+c
。
这个方案的缺点是同一个库被加载到不同进程时,指令是一样的,不同的只是地址。所以原则上可以将这些不同的地址的相同代码提出来放一起,这样同一个库在内存中只会有一个副本了。这思路可以节省内存,对应于下面的 PIC 机制。
当然,也有优点,因为在装载后地址全部被重定位了,因此在每次访问对象或者函数时,会少一层间接,所以速度可能快。 - 基于地址无关代码(PIC, Position-independent Code)机制
这个机制是目前主流且通用的,对应了-fPIC -shared
的编译模式。
这个方案将代码中需要修改地址的部分分离出来,放到数据段中。这样剩余的代码是地址无关的,因此在整个系统中只有一份,而每个进程维护自己的地址相关的副本。
PIC
先不考虑动态库的事情。首先,一个模块,要实现为地址无关的,也就是要将地址相关的代码剥离掉。有什么代码是位置相关的呢?这包含两部分:
- 调用外部过程
- 引用全局变量
据此,可以将模块中的地址引用分为下面四种方式:
模块内函数调用
call
命令肯定是位置无关的,因为它的参数是相对 EIP 的偏移。所以不需要重定位。模块内数据访问
因为要做到地址无关,所以在代码中不能出现绝对地址(类似于 R_386_32 的方案)了。剩下的一条路是相对寻址,这是因为同一个模块的布局导致代码和数据所在页的相对位置是固定的,所以根据代码的位置,通过一些偏移就能算出数据相对于代码存放的位置。因此,类似“模块内函数调用”,继续选择 PC 作为了标杆。
但是数据寻址没有像call
一样直截了当,使用相对偏移做参数。换句话说,32 位程序中数据的相对寻址没有相对于 PC 的寻址方式。需要通过get_pc_thunk
函数(实际上可能叫__x86.get_pc_thunk.ax
)来获得 PC 的地址,然后对它加上偏移量来访问。对应汇编类似于1
2
3call get_pc_thunk.ax
add xxx, %ecx
movl $val, yyy(%ecx)get_pc_thunk
的原理是调用 call 会把下一条指令的地址压到栈顶,即 esp 指向位置。模块间数据访问
相比于“模块内数据访问”,其难点在于要到运行期加载完对应模块才知道偏移量。所以不能按照之前的方案算一个偏移量出来。那还能怎么办?查表呗。
因此引入全局偏移表 GOT 存放每个符号的地址。例如,当需要访问变量 b 时,首先查找 GOT 中对应的条目,再根据条目列出的地址来索引到变量正确的位置。
在链接时,虽然每个模块的位置确定不下来,但 GOT 表的位置是可以确定下来的。所以链接器实际上使用“模块内数据访问”的方案确定 GOT 相对于 PC 的偏移,然后再从 GOT 表找到变量的位置。可以看出,多了层间接。
GOT 位于 .got 段,可以通过objdump -R
查看 GOT 中的重定位项。在有了延迟绑定(PLT)机制后,GOT 表会分别存储在 .got 和 .got.plt 段中。模块间函数调用
实际上也可以通过类似3的办法来解决,即call *(%eax)
这样。但考虑到函数的加载数量是比较大的,每次多一层中转,会影响性能。实际采用了延迟绑定的技术,通过PLT 表来实现。Global Symbol Interposition问题
在模块内部被引用的变量,除了模块内部的静态变量(比如局部变量、具有内部链接性的 static 变量等)之外,还需要处理模块内部的全局变量的情况。比如在 module.c 中对extern int global
这样的引用,其中global
可能被定义在共享对象中,也可能被定义在同模块的另一个 .o 里面。
如果 module.c 是可执行文件的一部分,因为可执行文件不是 PIC 的,那么必然会在 bss 段给global
创建一个副本。对这种情况,就需要将共享对象中的global
指向这个副本。
如果 module.c 在共享对象中,那么它肯定是按照 PIC 编译的,此时就会按照模块间引用的方式生成代码。
从 Global Symbol Interposition 问题引申开去,可以发现,如果在 so 里面定义一个全局变量 G
,并且进程 A
和 B
都使用了该 so
,那么这两个程序也都是访问的自己独立的副本。
在下面的代码中,其实无法确定 b 和 ext 是在同模块的其他 .o 中,还是在其他的共享库中。对于这种情况,只能全部当做跨模块的情况来处理。
1 | static int a; |
之前提到,地址无关代码里面并不能做到绝对地址。但 C++ 中有个叫指针的东西,它就是绝对地址。对于这种数据段中出现的绝对地址引用,将在后面介绍 R_386_RELATIVE
机制。
1 | static int a; |
如何判断一个ELF文件是PIC的呢?
根据爆栈网,可以用下面的办法判断一个目标文件是不是PIC的。但下面的评论也指出了对SO,以及对-m32
编译选项是不适用的。
1 | readelf --relocs foo.o | egrep '(GOT|PLT|JU?MP_SLOT)' |
另外,在程序员的自我修养中提到,执行下面的命令如果有输出,则不是PIC代码。这是因为PIC代码不包含重定向表。
1 | readelf -d foo.so | grep TEXTREL |
动态链接器
考虑动态链接的场景,程序依赖的外部程序并没有在静态链接期链接到可执行文件中,而需要在运行期动态加载。负责这个过程的称为动态链接器,负责在装载完可执行文件后,实际执行动态链接工作。它也是一个共享对象,由INTERP
段指定,Linux上的默认值为/lib/ld-linux.so.2
。
动态加载器是GLIBC的一部分,它的版本号往往和GLIBC的版本号一样,当GLIBC升级时,需要手动指定/lib/ld-linux.so.2
这个文件到新的路径。为什么能够保证兼容性,可以查看后面的论述。
1 | $ readelf -l p |
- ld-linux 是静态链接的
动态链接库的版本和 SO-NAME
为什么依赖的 so 文件通常都是 libname.so.6
,但是实际上它会指向 libname.so.6.0.23
这样的库呢?下面将解答。
动态链接库的版本如下所示
1 | /libname.so.x.y.z |
其中:
- 主版本号
x
不同的主版本号的动态链接库是不兼容的。这通常暗示着在系统中需要保留旧版本的 so 库,否则依赖旧版本的 libname 的程序可能无法运行。 - 次版本号
y
表示增量升级。会增加新符号,但不会改变旧符号。因此,高的 y 会自然兼容低版本的 y。正因为如此,我们不需要指定次版本号,我们升级系统的so到最新版本就行,反正不会影响老版本。 - 发布版本号
当然,诸如 libc 和 ld 并不采用上面的命名方式。
rpath(-R)和-L
添加目录到 runtime library search path。通常是把 ELF 可执行文件和共享对象一起链接的时候会遇到。所有的 rpath 参数会被一起传给 runtime linker,并且被用来在运行期寻找共享对象。
rpath 也被用来用来定位在链接时显式指定的共享对象,参考后面的 rpath-link 参数,
如果 rpath 没有被指定,那么就使用 LD_RUN_PATH。
搜索顺序(gcc)
The linker uses the following search paths to locate required shared libraries:
- 用
-rpath-link
指定的所有目录 - 用
-rpath
指定的所有目录。和上面的区别在于,-rpath
在运行期起作用,而-rpath-link
在编译期起作用。
Searching -rpath in this way is only supported by native linkers and cross linkers which have been configured with the--with-sysroot
option. - On an ELF system, for native linkers, if the
-rpath
and-rpath-link
options were not used, search the contents of the environment variable “LD_RUN_PATH”. - On SunOS, if the -rpath option was not used, search any directories specified using -L options.
- 使用
LD_LIBRARY_PATH
. - For a native ELF linker, the directories in “DT_RUNPATH” or “DT_RPATH” of a shared library are searched for shared libraries needed by it. The “DT_RPATH” entries are ignored if “DT_RUNPATH” entries exist.
- The default directories, normally /lib and /usr/lib.
- For a native linker on an ELF system, if the file /etc/ld.so.conf exists, the list of directories found in that file.
如何确定使用的编译器,比如 clang 或者 gcc 的版本呢?用 strings 判断 binary 的编译器,搜 gcc 和 clang。如下所示
1 | > strings bin/tiflash/tiflash | grep clang |
动态链接库依赖其他的库
动态链接库依赖其他的库时,是一个递归的过程。
但其中有一个陷阱,首先给出下面的代码:
- foo1.cpp 中定义了 foo,返回 1
- foo2.cpp 中也定义了 foo,返回 2
- libfoo.cpp 中定义了 test 函数,返回
foo()
- main.cpp 中调用 test 和 foo
1 | // libfoo.cpp |
然后进行如下的编译和链接操作:
- 分别将 foo1.cpp 和 foo2.cpp 编译为 foo1 和 foo2,把它们分别打包为 libfoo1.a 和 libfoo2.a
- 把 libfoo.cpp 编译为动态库 libfoo.so,并链接 foo1,不链接 foo2
- 编译 main,按顺序链接 foo2 和 libfoo.so
1 | g++ foo1.cpp -c -o foo1 |
考虑执行 main,test 和 foo 的调用分别输出什么呢?
朴素的思想是,因为编译动态库的时候用的是 foo1,那么 test 就应该输出 1。
但实际上 test 输出的是 2。原因是编译 main 时,查找符号的顺序是 foo2 然后是 libfoo.so。而在 foo2 中就找到了返回 2 的 foo 函数。
查找丢失的符号
有的时候,可能加载了错误版本的库,这导致找不到一些符号。可以通过 ldd 诊断这一类的问题。
1 | $ ldd -r /usr/lib/libatlas.so |
动态链接程序的构建:重定位机制
GOT 机制概述
动态链接程序怎么重定位呢?答案很简单,查表呗。这个表就是GOT表。编译和链接的时候,预先分配好这个表,加载的时候把实际的变量/函数位置给填上去就行。
一个程序中会维护一个 GOT 表。因为它是唯一的,所以在构建时就可以确定其地址,这样可以通过类似静态连接的重定位的方法,根据 IP 算出 GOT 相对于当前指令的偏移。GOT 表中记录了所有跨模块函数和变量的地址,包含 .got
和 .got.plt
两个段。
因此 .got
中保存全局变量引用的地址,.got.plt
保存全局函数引用的地址。拆成两个段的原因是:
- 为了实现后面的 PLT 机制,需要将对于函数引用的实际地址从
.got
中拆出 .got.plt
需要有执行权限。
通过 GOT 表访问变量的方法很简单,就是在 GOT 表中找到对应变量的偏移。
通过 GOT 访问函数的是使用最多的场景,如果每次都去检查 GOT 表有没有被初始化,显然比较浪费。为此,有 PLT 机制。
PLT机制概述
.got.plt
的前三个条目有特殊意义:
-
.dynamic
段地址 - 本模块 ID
_dl_runtime_resolve
函数地址
这个函数负责绑定模块 A 中的 f 函数。即给定模块名 A 和函数名 f,它能找到对应的地址。
.got.plt
后面的条目就是每个被 import 的函数的实际地址。
PLT 机制的过程是如下代码(来自自我修养一书)所示,这代码位于.plt
段。
在调用某跨模块的函数例如 bar@plt
时,首先通过 GOT 中的索引进行跳转。如果这个索引条目已经加载了 bar 的正确地址,那么就会自然跳转到对应位置。
但如下所示,在一开始,*(bar@GOT)
指向的是一个 push n
指令,而不是函数加载的地址。这里的 n 就是需要查找的符号 bar 在重定位表 .rel.plt
中的下标。
从 push
到 jump _dl_runtime_resole
的这一小段代码实际上就是去找 bar 的正确地址。_dl_runtime_resole
的参数就是刚 push 进去的两个值。该函数会将结果填写到 GOT 表 .got.plt
中的 *(bar@GOT)
条目中,这样后续调用就不走 push n
这套逻辑了。
简单来说,第一次执行 jmp *(bar@GOT)
会跳到它的下一行,也就是 push
和 jump _dl_runtime_resolve
。在 resolve 到了真的地址之后,再执行 jmp *(bar@GOT)
就会指向真正的地址了。
1 | bar@plt: |
现在还有个问题,什么是 .rel.plt
呢?它类似于的静态连接的 .rel.text
,但它不是修正 .text
,而是修正 GOT 表的 .got.plt
段中的地址,实际上是对函数引用的修正。我们在后面详细讲解。
为什么跨模块访问变量不使用 PLT?
首先,全局变量往往比较小。一个高内聚低耦合的程序会尽量减少全局变量的使用。
动态链接程序的构建:实际构造
在这一章节中,主要讲解在编译器设计地址无关(PIC)动态链接程序的编译结果,使得后续的加载过程中的动态链接过程更有效率。
尽管如此,还是会和基于 Load-time Relocation 机制进行比较,并介绍一些共同的部分。
编译过程
虽然动态链接的过程中不会把 so 文件真的打包到可执行文件中。但需要 so 文件来了解某个符号是否定义在 so 中。如果是,则会标记不对它在编译期进行重定位。
.dynamic 段
.dynamic 段看上去是这个 so 文件的“ELF文件头”,可以通过 readelf -d swap.so
来查看 .dynamic 段的信息:
- (INIT)
.init 地址 - (FINI)
.fini 地址 - (INIT_ARRAY)
- (INIT_ARRAYSZ)
- (FINI_ARRAY)
- (FINI_ARRAYSZ)
- (GNU_HASH)
动态的.hash - (STRTAB)
.dynstr 地址,可以对应到readelf -S
的结果。 - (SYMTAB)
.dynsym 地址,可以对应到readelf -S
的结果。 - (STRSZ)
.dynstr大小 - (SYMENT)
- (PLTGOT)
- (RELA)
动态重定位表.rel.dyn
地址。 - (RELASZ)
readelf -S
里面.rela.dyn
项目的 Size。 - (RELAENT)
readelf -S
里面.rela.dyn
项目的 EntSize。 - (RELACOUNT)
动态重定位表数量 - (NULL)
动态链接重定位表
介绍 .rel.dyn 和 .rel.plt
在静态链接中,重定位表可以用来在链接时决定编译时尚未确定的导入符号的地址。
在动态链接时,很多符号的地址在甚至在链接期都无法确定。当可执行文件/共享库中一旦依赖于在其他的共享对象中的符号,就需要动态重定位表。
如果是基于 Load-time Relocation 机制实际上相当于把重定位符号直接从链接期移到执行期了,这很容易理解。但对于 PLT 机制,同样需要重定位表,这是因为:
- 代码段地址无关,因此不需要重定位
- 数据段中还包含对绝对地址的引用,例如 GOT 表
没错,这些绝对地址已经在装载时由动态链接器填到 GOT 表的对应位置了。但总得想办法让代码去访问对应的 GOT 表的条目啊。 - 数据段中还包含对绝对地址的引用,例如指针
对应于R_386_RELATIVE
类型。
总结一下,动态链接重定位表 .rel.dyn
和 .rel.plt
对应于静态链接的 .rel.data
和 .rel.text
,分别修正数据和函数引用:
.rel.dyn
修正位于.got
段和数据段中的地址。它对应R_386_GLOB_DAT
类型和R_386_RELATIVE
类型。.rel.plt
修正.got.plt
的地址。它对应R_386_JUMP_SLOT
。
当然事情也不绝对,事实上导入函数的重定位入口也可能出现在 .rel.dyn
中。这发生在不使用 PIC 编译时,导入的外部函数,比如 printf
就会在 .rel.dyn
中,并且类型也变成了 R_386_PC32
。在后面的实验中将进行验证。
可以用 readelf -r
去查询动态链接重定位表。
相比静态链接,动态链接重定位表多了一些重定位类型:
R_386_JUMP_SLOT
这个类型的条目,重定位表中 Offset 字段指向.got.plt
中对应函数条目的地址。
例如在动态加载时,查到printf
的地址为 X,然后就去动态重定位表中找Sym.Name
为printf
的条目,将 X 填到它 Offset 所指向的位置上。
这个位置位于.got.plt
表中。回想.got.plt
表的结构,前三个项目分别是.dynamic
段地址、模块 ID 和_dl_runtime_resolve
地址,所以这个 Offset 应该至少是从第四个条目开始的。
TODO 补充一下 printf 的例子R_386_GLOB_DAT
这种类型的条目,对应的重定位 Offset 指向的位置在.got
中,与R_386_JUMP_SLOT
很类似。R_386_RELATIVE
这种类型的重定位即 Rebasing。出现这种方式,是因为上面论述的四种动态重定位方式还不够周全。考虑某个共享对象中有如下的代码:1
2static int a;
static int* p = &a;这段代码中,它是一个模块内的数据访问。比如如果仅仅去访问
a
,那么按照上面的论述,是可以通过相对地址来访问的。
但现在问题复杂了,p
持有a
的指针,这是一个绝对地址。容易发现,当共享对象被加载到不同的程序中时,a
的地址是会变化的,但必须在装载时把这个地址算出来,这个就是R_386_RELATIVE
重定位要做的事情。
在编译时,共享对象的地址是从0开始的,记录此时a
的偏移是B
,此时p
的值应该是B
;当共享对象被装载到A
处时,此时p
的值应该是A+B
了。因此,R_386_RELATIVE
标记出来的这些符号在装载时都需要加上一个A
的值,才成为最终结果。
实验
可以通过 readelf -r xxx.so
查看动态重定位表。
在 Ubuntu 16.04.7,使用 gcc (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609,去编译 SO,指令
1 | gcc -shared -fPIC -m32 -g -c -o test_printf.so test_printf.c |
如果用了 -fPIC
,有
1 | Relocation section '.rel.text' at offset 0x4bc contains 4 entries: |
而不使用,有
1 | Relocation section '.rel.text' at offset 0x414 contains 2 entries: |
可以看出,printf
确实从 R_386_PLT32
变成了R_386_PC32
。
但是 SO 文件里面似乎都没有 .rel.dyn
段,并且也没 .got
或者 .got.plt
段,也许这两个段只会在可执行文件中出现,而不会在动态链接库中出现?这是因为错误加了 -c
,去掉 -c
重新看一下
1 | gcc -shared -fPIC -m32 -g -o test_printf.so test_printf.c |
如果用了 -fPIC
有
1 | Relocation section '.rel.dyn' at offset 0x35c contains 9 entries: |
否则有
1 | Relocation section '.rel.dyn' at offset 0x35c contains 12 entries: |
实验
Load-time Relocation 实验
实验设置
1 | gcc -shared -m32 -g -o swap_no_pic.so swap.c |
PIC 和 PLT实验
实验设置
下面编译一个最简单的程序
1 |
|
添加下面两个选项,以得到一个位置无关的swap.so。
-fPIC
创建位置无关代码-shared
创建共享库
1 | gcc -fPIC -m32 -g -o swap_no_shared.so swap.c |
生成动态链接库
1 | gcc -shared -fPIC -m32 -g -o swap.so swap.c |
生成可执行程序
1 | gcc -fPIC -m32 -g -o p_pic main.c swap.c |
注意,在编译动态库的时候,不能加-c
,否则链接器就不工作,会导致一系列问题。例如一些重定位项在.rel.text
中,而不是在.rel.dyn
中。
执行下面语句的编译结果
1 | gcc -shared -fPIC -m32 -g -o swap.so swap.c |
readelf -a swap.so
objdump -x -D -s swap.so
实验过程
下面我们以具体的实验来学习PLT机制。
反编译动态链接的结果,发现实际调用的是puts@plt
这个函数
1 | (gdb) disass main |
附上main.s的代码用来对照
1 | $ cat main_pic.s |
看看puts@plt
的实现,看起来是两个jmp和一个push,这个很奇怪。
1 | (gdb) disass puts |
这些地址都是啥呢?检查Section表,发现第一个jmp落在.got.plt
段上,而第二个jmp落在.plt
段上。【结合上面的介绍,第一个jmp应该是@plt
的到吗】
1 | (gdb) info symbol 0x804a00c |
这.plt
所在的段是可读可执行不可写的,可以认为是一段代码。而.got.plt
所在的段是可写的,也就是在运行时会被更新。
1 | $ readelf -S pic |
第一个jmp
查看第一个jmp的代码,发现没有啥意义,其实这个只是一个地址查询表,我们打印出来是一个要跳转的地址0x8049f14
1 | (gdb) disass 0x804a00c |
而这个地址恰好对应了puts@plt
的下一行代码
1 | (gdb) info symbol 0x80482e6 |
第二个jmp
接着,我们入栈了一个0x0
,接着jmp到0x80482d0
,这个似乎不好直接disass,于是我们采用下面的办法。
1 | (gdb) disass 0x80482d0 |
发现这边没有函数信息,于是强行disass一波,现在有了。看起来,我们入栈了一个0x804a004
之后,又jmp到了*0x804a008
这个位置。
1 | (gdb) disass 0x80482d0,0x080482f0 |
我们发现*0x804a008
居然是0。很奇怪,难道是没有运行的缘故?于是我们打断点,并执行
1 | (gdb) |
发现执行完之后,这里竟然有值了!但依然没有函数名,对这种情况,我们可以安装一个debug版本的glibc来解决
1 | ldd ./pie # 确定版本 |
这样在gdb里面就可以add symbol上面
1 | add-symbol-file /usr/lib/debug/lib/x86_64-linux-gnu/ld-2.27.so 0xf7fd6ab0 |
这个值实际上是_dl_runtime_resolve
的函数。这个函数主要内容就是入栈5个参数,然后调用_dl_fixup
,最后再弹出栈。
1 | (gdb) disass 0xf7fee000 |
ret的操作数指定了在弹出返回地址后,需要释放的字节/字数量。通常,这些字节/字被是作为调用的输入
1 | 0xf7fee01b <+27>: ret $0xc |
从重定向表中,还可能找到 0x0804a00c
这偏移对应的表项。
1 | $ readelf -r pic |
dlopen
实验
如何获得constexpr值?
1 |
|
1 | $ readelf -s t | grep MAGIC |
我的so有问题?
注意先 ldd 看下动态库的路径