基本名词
首先给出一些基本的名词概念,在后续章节会解释这些名词。
ELF文件
ELF(Executable and Linkable Format) 文件,看名字意为可执行/可链接格式的文件。
它有三种类型:
- 可执行文件(Executable File):可直接被操作系统加载和执行。
- 可重定位文件(Relocatable File):编译器生成的代码和数据,包含程序代码和数据等,用于被连接器链接。
- 共享对象文件(Shared Object File):动态链接库或位置无关可执行文件。
链接
程序的链接是指将程序中未解析的符号对象做解析和填充的过程。
我们在实现程序逻辑时,往往希望将程序功能进行模块化拆分,而最终的程序由多个模块“拼接”而成,这个程序的“拼接”过程我们就称之为程序的链接。而程序的链接又分为动态链接和静态链接两种方式:
- 静态链接:在编译阶段将程序中未解析的符号搁置,输出可重定位 (Relocatable File ) 的目标文件。在链接阶段,静态链接器( Static Linker )解析和搜索所有输入的可重定位文件,处理其中所有搁置的未解析符号。最终输出一个完全链接的可执行文件 (Executable File) 。静态链接输出的可执行文件在运行时是与链接阶段输入的可重定位文件完全无关的。
- 动态链接:在编译阶段将程序中未解析的符号搁置,并且记录程序所依赖的共享库( Shared Object File )(共享库中必须定义和实现了程序中未解析的符号),输出一个动态链接的可执行文件。其中未解析的符号会在运行时由动态链接器( Dynamic Linker )将依赖的共享库映射到进程的地址空间,并处理程序中所有未解析的符号。
加载
程序的加载是指操作系统创建或扩充进程镜像的过程。
程序文件可以看作是一串二进制数据(包括程序指令序列、程序数据及各类描述信息),这串二进制数据是静态存储在存储介质(磁盘)上的。
而程序的加载指的是操作系统创建出一个进程,并为这个进程分配CPU时间片和虚拟内存并将静态的程序文件以某种方式拷贝(映射)到进程的虚拟内存的过程。
静态分析—链接阶段
从前一章节的名词解释中,我们可以大体上了解:程序的链接是为了解决程序解耦合或者说模块化的需求。
代码示例
那么我们先引入一个简单的例子来构造这种场景。
这是我们的程序入口 calc.c。它需要依赖外部的 数据 int extern_init_data 和 函数 int add(int a, int b)。 它本身代码空间中也有 已初始化数据 int* init_data_ptr,int init_data ,和 未初始化数据 int not_init_data :
因为希望尽可能避免其它的库依赖,代码中不使用任何c库,包括glibc。所以我们手写入口函数_start,并使用内联汇编触发退出中断,程序以123退出码退出。
1 | extern int extern_init_data; |
接下来,在模块libcalc.c中实现主程序中需要的数据和函数定义:
1 | int extern_init_data = 1; |
前期的代码准备已经完成,我们预期将两个模块化的文件合并关联起来,使 calc.c 可以正确调用libcalc.c中的数据和代码。
静态链接
首先很直观的可以想到在完成多个模块的最终合并时,可以将子模块中的数据和代码直接拷贝到主模块中,让主模块看起来像是直接从一个 “All In One” 单个源代码文件编译而来的。而这,就是我们所说的静态链接。
先将两个C源代码文件编译为可重定位ELF文件:
1 | # 编译为可重定位文件 |
要完成多个可重定位文件的合并,静态链接往往要经历如下三个步骤:
graph TD B(module_a.c) --> D[compilation] C(main.c) --> E[compilation] D --> F[Relocatable Object File: module_a.o] E --> G[Relocatable Object File: main.o] F --> H[linking] G --> H H --> I[Address Allocation] I --> J[Symbol Resolution] J --> K[Relocation] K --> L[Static executable File: main]
!!!现代静态链接器对可重定位文件的链接过程做了高度优化,往往在一次遍历中完成这三步。
为了理解其中原理性的过程,我们将这一过程拆分,将文件先部分链接,再完成全链接。(并非真实的)
1 | # 部分链接 |
那么这部分链接和全链接这两个步骤中,静态链接器都做了哪些工作?
地址与空间分配(Address Allocation)
- 扫描多个输入文件的节(section),获取各节的地址、长度和属性等信息,并计算出合并后可执行文件的各节长度及位置;
- 将多个目标文件的 相同节合并,使其位于同一地址空间;
- 维护一个 全局符号表,用于在合并后的文件地址空间中进行符号寻址;
- 将 相似节合并 生成 段 结构。(在部分链接时,不会将节合并为段)
block-beta columns 5 elfhdr1["ET_REL ELF Header"] space:1 elfhdr2["ET_EXEC ELF Header"] space:1 elfhdr3["ET_REL ELF Header"] block:text_group:1 columns 1 text[".text
代码节"] rel_text[".rel.text
代码重定位表"] rodata[".rodata
只读数据节"] end space:1 block:load1:1 columns 1 placeholder["LOAD Segment #1
R+X"] text_section[".text"] plt_section[".plt"] rodata_section[".rodata"] end space:1 block:text_group2:1 columns 1 text2[".text
代码节"] rel_text2[".rel.text
代码重定位表"] rodata2[".rodata
只读数据节"] end text -- "combines" --> text_section text2 -- "combines" --> text_section rodata -- "combines" --> rodata_section rodata2 -- "combines" --> rodata_section block:data_group:1 columns 1 data[".data
已初始化数据节"] bss[".bss
未初始化数据节"] rel_data[".rel.data
数据重定位表"] end space:1 block:load2:1 columns 1 placeholder3["LOAD Segment #2
RW"] init_array_section[".init_array"] fini_array_section[".fini_array"] data_seg[".data"] bss_seg[".bss"] got_section[".got"] end space:1 block:data_group2:1 columns 1 data2[".data
已初始化数据节"] bss2[".bss
未初始化数据节"] rel_data2[".rel.data
数据重定位表"] end data -- "combines" --> data_seg data2 -- "combines" --> data_seg bss -- "combines" --> bss_seg bss2 -- "combines" --> bss_seg block:table_group:1 columns 1 symtab[".symtab
符号表"] strtab[".strtab
字符串表"] end space:1 block:note_seg:1 columns 1 placeholder4["Note Segment
R"] note_gnu_property[".note.gnu.property"] note_others["......"] note_gnu_buildid[".note.gnu.build-id"] end space:1 block:table_group2:1 columns 1 symtab2[".symtab
符号表"] strtab2[".strtab
字符串表"] end block:sht_group:1 sht["Section Header
节头表"] end space:1 block:pht_group:1 pht["Program Header
程序头表"] end space:1 block:sht_group2:1 sht2["Section Header
节头表"] end classDef headerStyle fill:#e3f2fd,stroke:#0277bd,stroke-width:3px classDef execStyle fill:#e8f5e8,stroke:#388e3c,stroke-width:2px classDef dataStyle fill:#fff3e0,stroke:#f57c00,stroke-width:2px classDef metaStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px classDef tableStyle fill:#fce4ec,stroke:#c2185b,stroke-width:3px classDef titleStyle fill:#f8f9fa,stroke:#343a40,stroke-width:2px classDef segmentStyle fill:#e1f5fe,stroke:#01579b classDef noteSectionStyle fill:#80808030 classDef optionalStyle stroke-dasharray: 3 3 classDef placeholderStyle fill:#00000000,stroke:#00000000 classDef sectionGroupStyle fill:#00000000,stroke:#00000000 class placeholder,placeholder2,placeholder3,placeholder4 placeholderStyle class linktitle,linktitle2,runtitle titleStyle class elfhdr1,elfhdr2,elfhdr3 headerStyle class text,text2,text_section,init_section,plt_section,fini_section execStyle class rodata,rodata2,rodata_seg,rodata_section,data,data2,data_seg,bss,bss2,bss_seg,init_array_section,fini_array_section,got_section dataStyle class rel_text,rel_text2,rel_data,rel_data2,symtab,symtab2,strtab,strtab2 metaStyle class load1,load2,dynamic_seg,note_seg segmentStyle class sht,sht2,pht tableStyle class note_gnu_property,note_gnu_buildid,note_others,note_abi_tag noteSectionStyle class rel_header_group,rel_header_group2,dyn_header_group,text_group,text_group2,data_group,data_group2,table_group,table_group2,sht_group,sht_group2,pht_group sectionGroupStyle class plt_section,rodata_section,rel_text2,rel_data2,got_section,init_array_section,fini_array_section,note_gnu_property,note_gnu_buildid,note_others,note_abi_tag optionalStyle
这里我们用上面的示例来理解一下这些概念,先查看文件的节信息:
1 | > readelf --sections calc.o |
可以看到部分链接的文件中,分别只有一个.text,.data,.bss节,输入的可重定位文件对应节被合并了。
符号搜索与解析(Symbol Resolution)
输入的可重定位的目标文件中包含编译阶段搁置的未解析符号,那么符号被搁置后,在程序中是如何存储的呢?
1 | > objdump -S merged.o |
我们将部分链接的代码反汇编后观察到,对于被搁置的符号,程序会将其地址偏移值置为0(相对于 rip 指针,即下一条指令的地址),因为calc.o中未定义这些符号,无法设置正确的偏移值。
所以,为了在运行时能够正确地完成数据符号的寻址以及函数跳转,我们必须先找到程序中所有被搁置的符号,然后搜索该符号的真实地址。
静态链接器通过查表来完成这两步:
- 解析输入目标文件中的.rel.text(代码重定位表)、.rel.data(数据重定位表),获取当前待解决的未解析符号;
1 | > readelf -r merged.o |
根据重定位表,我们需要解决以下符号的重定位:
符号名称 | 重定位类型 | 符号表索引 |
---|---|---|
not_init_data | 32位PC相对重定位 | 0x10 |
init_data_ptr | 32位PC相对重定位 | 0xb |
add | 32位PLT间接重定位 | 0xd |
init_data | 32位PC相对重定位 | 0xc |
extern_init_data | 64位直接重定位 | 0xf |
解析出搁置符号后,我们在merged.o的.symtab(符号表)中检索这些符号的信息:
1
2
3
4
5
6
7
8
9
10
11
12> readelf -s merged.o
Symbol table '.symtab' contains 17 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
......
11: 0000000000000000 8 OBJECT GLOBAL DEFAULT 7 init_data_ptr
12: 0000000000000000 4 OBJECT GLOBAL DEFAULT 6 init_data
13: 000000000000004d 24 FUNC GLOBAL DEFAULT 2 add
14: 0000000000000000 77 FUNC GLOBAL DEFAULT 2 _start
15: 0000000000000004 4 OBJECT GLOBAL DEFAULT 6 extern_init_data
16: 0000000000000000 4 OBJECT GLOBAL DEFAULT 9 not_init_data注:Ndx指该符号所在节的索引号,例如calc.o的 2号节是.txt节,6号节是.data节;Value指该符号在其所在节的节内偏移。
根据解析符号表的结果,获取到对应的符号表项,我们就可以扩充重定位表解析的表格了:
符号名称 | 重定位类型 | 符号表索引 | Type | Section | Offset in Section |
---|---|---|---|---|---|
not_init_data | 32位PC相对重定位 | 0x10 | OBJECT | .bss | 0x0 |
init_data_ptr | 32位PC相对重定位 | 0xb | OBJECT | .data.rel | 0x0 |
add | 32位PLT间接重定位 | 0xd | FUNC | .text | 0x4d |
init_data | 32位PC相对重定位 | 0xc | OBJECT | .data | 0x0 |
extern_init_data | 64位直接重定位 | 0xf | OBJECT | .data | 0x4 |
重定位(Relocation)
现在只差临门一脚,链接器在 符号搜索与解析 阶段解析出了所有搁置的符号,结合在 地址与空间分配 阶段合并而来的全局符号表,一次遍历可以将这些搁置符号设置正确的地址(R_X86_64_PC32,R_X86_64_PLT32 或 R_X86_64_64)。
至此,当前ELF文件中的代码和数据等信息均已完整,段结构也被正确初始化,规划分配了适用于运行时的虚拟地址空间。该文件可以被加载到进程并被执行。
动态链接
上述的静态链接方案看起来已经解决了代码模块化拆分的问题。但在很多场景中,这个方案并不完美。
设想,有一个封装了底层API的公共库A(例如glibc),下游成千上万的工程代码均需要依赖A:
- 那么所有下游代码在发布时,都需要将公共库A编译并链接到自己的ELF文件中;
- 当公共库A版本迭代时,所有的下游代码都需要重新编译A,并静态链接。
这简直就是灾难,我们的设备上将存储无数份公共库A的副本,并且每次公共库A的版本迭代都将导致我们设备上所有软件的更新。
所以这驱动我们探索一个新方案,能够让所有依赖公共库A的下游软件在设备上共享同一份A的代码,并且能够在运行时链接A。这样一来,上面的问题就迎刃而解。
将这个问题拆解:
首先,所有下游软件共享同一份公共库A的代码,那么A的发布形式必须满足A模块内部不能够使用绝对地址寻址,因为无法预测下游软件会将A的代码放在哪段地址空间。所以为了能够保证A模块内部的指令跳转和数据寻址逻辑正确,则A必须全部使用间接寻址(指令指针寄存器寻址),这就是 地址无关代码 。在linux中,我们一般将源代码编译为 Shared Object File(共享对象文件,后缀为 .so) 来实现这一目的;
其次,模块A在运行时被链接到另一个进程,这个过程由于要处理目标文件的搁置符号,并完成符号的重定位,我们一定需要一个链接器,这个链接器将把模块A的代码和数据放到目标文件的进程内存空间中,并完成符号解析和重定位。这个链接器一般是 ld-linux-xxx.so ,也称之为解释器(后文称之为解释器)。而将模块A加载到目标文件的进程空间我们需要依赖 mmap 系统调用。
linux的系统调用中,有一个常用的接口:
void * mmap(void *start, size_t length, int prot , int flags, int fd, off_t offset)
。mmap用于将一个linux对象映射到用户空间内存中。我们在用户空间对该段内存映射的所有操作都会反映到映射的linux对象上。
我们沿用上面的示例来完成这一过程:
1 |
|
在示例中,我们在运行前做了一次静态链接。但其实这次静态链接与前文的完全静态链接有所差异:
graph LR A[module_a.c] --> B["compilation"] C[main.c] --> D["compilation"] B --> E["Shared Object File: libmodule_a.so"] D --> F["Relocatable Object File: main.o"] E --> G["Symbol Resolution"] G --> G3["Mark Extern Symbol"] G3 --> G1[".dynsym"] G --> G4["Combine Global Symbol Table"] G4 --> G2[".symtab"] F --> G E --> I["Relocation Table Generation"] I --> I3["Data Relocation Section Generation"] I3 --> I1[".rela.dyn"] I --> I4["Text Relocation Section Generation"] I4 --> I2[".rela.plt"] E --> H["Dependencies Collection"] H --> H1["Add DT_NEEDED: libmodule_a.so"] E --> J["Dynamic Section Generation"] J --> J1[".dynamic"] H1 --> J1 E --> L["Inject Interpreter"] L --> L1[".interp"] G1 --> K["Dynamic Linked Executable File: main"] G2 --> K I1 --> K I2 --> K J1 --> K L1 --> K classDef compile fill:#e6f7ff,stroke:#1890ff; classDef link fill:#ffe58f,stroke:#faad14; classDef section fill:#ffd591,stroke:#fa541c,stroke-width:2px; classDef file fill:#ffa0a0,stroke:#ff2020,stroke-width:2px; class B,D compile; class A,C,E,F,K file; class G,G1,G2,G3,G4,H,H1,H2,I,I1,I2,I3,I4,J,J1,J2,L link; class G1,G2,J1,I1,I2,L1 section;
在这一链接过程中,链接器事实上 不拷贝代码 ,不重定位符号 。仅将目标文件依赖的模块信息记录,解析所有输入文件,维护一个全局符号表,并标记所有当前搁置的符号。同时注入 解释器 路径。