可执行文件¶
System V ABI: Program Loading and Dynamic Linking: ELF 通用 ABI 规范, 介绍程序头、加载和动态链接.
System V AMD64 ABI (x86-64 psABI): x86-64 平台的 ABI 补充规范.
execve(2) | Linux man-pages 与 ld.so(8) | Linux man-pages: Linux 程序替换和动态链接器的手册.
ELF | Linux kernel documentation: Linux 内核对 ELF 的说明.
Abstract
ELF (Executable and Linkable Format) 是 Linux 等系统常用的目标文件格式. 它既描述代码和数据如何参与链接, 也描述操作系统如何把程序映射到进程地址空间.
理解 ELF 时要区分两个阶段: 静态链接器在构建时组合目标文件并处理重定位; execve() 和动态链接器在运行时装入程序及其共享库, 再把控制权交给程序入口.
学习完汇编基础后, 接下来进一步了解x86_64架构下的可执行文件及其链接、加载和执行过程. 本文以 Linux ELF 为例介绍可执行文件的结构和加载过程. ELF 是文件格式, 具体加载规则还取决于操作系统和处理器 ABI.
从源文件到进程¶
C/C++ 源文件
| 编译、汇编
v
可重定位文件 (.o)
| 静态链接器 ld
v
可执行文件 / 共享库
| execve() 触发内核装载
v
进程地址空间
| 动态链接器装入依赖库、完成动态重定位
v
程序入口 _start -> 运行时初始化 -> main()
汇编器生成的 .o 文件通常是 ET_REL 类型, 其中仍可能有未定义符号和重定位项. 静态链接器根据符号表和重定位信息组合各个目标文件, 并生成可执行文件或共享库. “静态链接”描述的是构建时的链接方式, 不代表所有可执行文件都不需要运行时装载器.
ELF文件的基本结构¶
ELF 文件由文件头以及若干表和数据区域组成. 文件头位于开头, 描述文件的类别、字节序、目标架构, 并记录程序头表和节区头表的位置、表项大小及数量. 两张表本身是目录: 表项分别描述运行时装载所需的段, 以及链接、调试和分析所用的节区.
下面是简化后的逻辑示意:
┌──────────────────────────────┐
│ ELF Header │
│ class, machine, table offsets│
├──────────────────────────────┤
│ Program Header Table (PHT) │
│ PT_LOAD, PT_INTERP, ... │
├──────────────────────────────┤
│ File data │
│ code, data, linking metadata │
├──────────────────────────────┤
│ Section Header Table (SHT) │
│ .text, .data, .symtab, ... │
└──────────────────────────────┘
ELF Header¶
ELF Header (ELF 头部) 描述整个文件的基本属性和关键表的位置. 其中 e_ident 开头的 16 字节包含 ELF 魔数 0x7f 'E' 'L' 'F', 文件类别 (32 位或 64 位) 和数据编码 (小端或大端) 等识别信息. 其余字段包括目标机器、文件类型、入口地址, 以及下列表格元数据:
| 字段 | 含义 |
|---|---|
e_phoff | 程序头表在文件中的偏移 |
e_phentsize, e_phnum | 程序头表每项的大小及表项数量 |
e_shoff | 节区头表在文件中的偏移 |
e_shentsize, e_shnum | 节区头表每项的大小及表项数量 |
e_shstrndx | 节区名称字符串表在节区头表中的索引 |
文件类型不同, 两张表的用途也不同.
-
可执行文件和共享目标文件的程序头表描述运行时映射所需的段
-
可重定位目标文件主要依靠节区头表组织待链接的节区
-
表的位置由头部中的偏移字段指定
不能仅凭某一张表在文件里的先后位置推断其含义.
程序头表与节区头表¶
-
程序头表 (Program Header Table): 面向装载器. 每个表项描述一个段, 例如
PT_LOAD指定文件内容映射到进程地址空间的位置、大小和权限,PT_INTERP指定动态链接器路径. 它是运行时视图的主要索引. -
节区头表 (Section Header Table): 面向链接器和分析工具. 每个表项描述一个节区的名称、类型、标志、文件偏移、大小及相关信息, 例如
.text、.rodata、.symtab和.rela.dyn. 节区名称由e_shstrndx指向的节区名称字符串表提供.
运行时装载依赖程序头表而不是节区头表, 因此 ELF 可执行文件即使没有节区头表, 仍可能正常运行; 但这会使依赖节区信息的静态分析不便. ELF 头部中的偏移字段允许表和数据区域采用不同布局, 上面简化示意图只展示组件关系, 不代表固定文件布局.
Linux下可用 readelf -hW 查看 ELF Header, readelf -lW 查看程序头表, readelf -SW 查看节区头表(-W 表示宽输出, 允许输出宽度超过 80 个字符). 下一节从链接视图和执行视图的角度对照这些结构.
ELF的文件类型和两种视图¶
ELF 头部以 0x7f E L F 魔数开头, 并记录文件类别、字节序、目标机器、文件类型、入口地址以及程序头表和节区头表的位置. 常见文件类型如下:
| 类型 | 常见用途 |
|---|---|
ET_REL | 可重定位目标文件, 如编译生成的 .o |
ET_EXEC | 可执行文件 |
ET_DYN | 共享目标文件; 现代 Linux 上以 PIE 方式构建的可执行文件也常使用此类型 |
ET_CORE | 进程崩溃或转储时生成的 core 文件 |
ELF 同时提供链接视图和执行视图:
-
节区 (section)主要服务于链接、调试和分析, 例如
.text、.rodata、.data、.bss、.symtab和.rela.dyn. -
段 (segment)由程序头表描述, 面向装载器, 指定运行时需要映射的文件范围、内存大小、权限和对齐方式. 一个可装载段可以覆盖多个节区, 也可能包含不对应具名节区的数据.
ELF 文件
|
+-----------+-----------+
| |
链接视图 执行视图
(Section Header) (Program Header)
| |
+-----+-----+ +--------+---------------+
| | | | | |
.text .data .symtab PT_LOAD PT_INTERP PT_DYNAMIC
| | | | | |
指令 全局数据 符号表 段映射 动态链接器路径 动态链接元信息
运行程序主要依赖程序头表. 即使节区头表被剥离, 装载器仍可通过程序头表和动态链接信息工作.
常见程序头类型:
| 类型 | 作用 |
|---|---|
PT_LOAD | 描述需要映射到进程地址空间的段. p_filesz 是文件中的字节数, p_memsz 是内存中的字节数; 当 p_memsz > p_filesz 时, 多出的部分初始化为零, 常用于 .bss |
PT_INTERP | 指定动态链接器的路径, 例如 glibc 系统上的 ld-linux-x86-64.so.2 |
PT_DYNAMIC | 指向动态链接所需的信息, 如依赖库、符号表、重定位表等 |
文件偏移和运行时虚拟地址不是同一个概念. 对于 PT_LOAD, 程序头表把文件中的一段内容映射到指定的虚拟地址范围; 动态装载时还可能根据装载基址 (load bias) 调整地址.
链接: 符号与重定位¶
编译一个源文件时, 编译器通常不知道其他目标文件或共享库中函数、变量的最终地址. 它会在符号表中记录定义或未定义符号, 并在重定位表中记录需要修正的位置及计算方式.
链接器处理目标文件时, 大致会:
-
收集输入文件中的节区, 按规则布局输出文件.
-
解析符号引用, 将未定义引用与某个定义关联.
-
根据重定位类型计算地址或偏移, 修正指令和数据中的占位值.
-
输出可执行文件或共享库, 并保留运行时仍需处理的动态链接信息.
┌──────────────────────────┐ ┌──────────────────────────┐
│ a.o │ │ b.o / libc.so.6 │
│ (.text, .data, .symtab, │ │ (defines external sym) │
│ .rela.text, ...) │ │ │
└──────────────┬───────────┘ └────────────┬─────────────┘
│ │
└───────────────┬─────────────────┘
▼
┌─────────────────────────────┐
│ linker (ld) │
│ 1) merge sections/layout │
│ 2) resolve UND -> definition│
│ 3) apply relocations │
└──────────────┬──────────────┘
│
┌─────────────┴─────────────┐
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ static-linked output │ │ dynamic-linked output │
│ code merged into binary │ │ keeps DT_NEEDED / UND / │
│ no runtime symbol lookup│ │ .rela.dyn / .rela.plt │
└─────────────────────────┘ └──────────┬──────────────┘
▼
runtime binding by ld.so
静态链接会把所需目标代码并入输出文件, 通常不需要在运行时查找这些符号对应的共享库定义. 动态链接则在可执行文件中保留 DT_NEEDED 等依赖信息, 由动态链接器在运行时装入共享库并完成剩余的重定位和符号绑定.
在 x86-64 位置无关代码中, GOT (Global Offset Table)保存运行时需要访问的地址, PLT (Procedure Linkage Table)为外部函数调用提供跳转入口. 动态链接器可在启动时完成函数绑定, 也可能按需在首次调用时解析 PLT 项; 是否采用延迟绑定取决于 ABI、链接选项和运行环境, 不应假定每个程序都会延迟解析.
延迟绑定时, 对外部函数 (以 puts 为例) 的首次与后续调用大致如下:
first call path next call path
─────────────── ──────────────
call puts@plt call puts@plt
│ │
▼ ▼
puts@plt: jmp *GOT[puts] puts@plt: jmp *GOT[puts]
│ (GOT entry still points to PLT stub) │ (GOT already patched)
▼ ▼
push reloc_index; jmp PLT0 libc puts
│
▼
PLT0: push link_map; jmp *GOT[2]
│
▼
dynamic linker (_dl_runtime_resolve)
│ resolve symbol + write real addr to GOT[puts]
▼
jump to libc puts
ELF的装入与启动¶
execve(path, argv, envp) 成功时会用新程序替换当前进程的程序映像, 它本身不会创建一个新的进程. 对于 ELF 可执行文件, Linux 的装载过程可以概括为:
-
内核识别 ELF 头部并检查目标架构等信息, 根据程序头表处理
PT_LOAD段. -
内核建立新地址空间, 映射程序代码和数据, 并按
p_memsz与p_filesz的差额补零. -
内核准备初始栈, 放入参数、环境变量及辅助向量 (auxiliary vector). 辅助向量向运行时提供入口地址、页面大小等信息.
-
若文件含
PT_INTERP, 内核还会装入指定的动态链接器, 并先把控制权交给动态链接器; 静态可执行文件通常没有这一步. -
动态链接器读取动态段中的依赖信息, 装入共享库, 完成所需重定位和符号绑定, 再把控制权交给主程序入口.
-
在常见的 C 运行时环境中, 程序入口通常是
_start; 运行时完成初始化后再调用main().
shell / parent process
│ execve(path, argv, envp)
▼
┌──────────────────────────────────────────────┐
│ kernel (binfmt_elf) │
│ - validate ELF header + map PT_LOAD │
│ - zero-fill .bss (p_memsz - p_filesz) │
│ - build initial stack (argc/argv/envp/auxv) │
│ - if PT_INTERP exists, map ld.so too │
└───────────────────────┬──────────────────────┘
│
┌─────────┴─────────┐
│ dynamic-linked ? ├────────────────┐
└─────────┬─────────┘ │
│yes │no
▼ │
┌──────────────────────────────┐ │
│ ld.so (ELF interpreter) │ │
│ - load DT_NEEDED shared libs │ │
│ - apply relocations/binding │ │
└───────────────┬──────────────┘ │
├──────────────────────────┘
▼
e_entry: _start
│
▼
__libc_start_main
│
▼
main() ──> exit
动态链接器是 ELF 解释器, 不是内核本身. 内核负责建立初始进程映像并启动解释器; 共享库装入、动态符号解析等工作主要由解释器完成. 对于 PIE, 主程序可装入不同的虚拟地址; 地址随机化是否生效还取决于系统配置和进程执行环境.
注意区分 ELF 头部的 e_entry 和动态链接器的初始入口: e_entry 描述主程序的入口地址; 动态链接程序启动时, 内核先把控制权交给 PT_INTERP 指定的解释器, 由解释器完成准备后再跳到主程序入口.
观察ELF文件¶
可以用 GNU Binutils 查看一个可执行文件的不同部分:
file ./program
readelf -hW ./program # ELF 头部: 类型、架构、入口地址
readelf -lW ./program # 程序头表: PT_LOAD、PT_INTERP 等
readelf -SW ./program # 节区头表
readelf -dW ./program # 动态段和 DT_NEEDED 依赖
readelf -sW ./program # 符号表
readelf -rW ./program # 重定位项
objdump -d ./program # 反汇编
推荐按这个顺序观察:
-
先看
file和readelf -hW确认文件类型与架构 -
再看
readelf -lW理解装载段和解释器 -
最后对照
readelf -dW、-sW、-rW分析动态依赖、符号和重定位 -
objdump -d则把机器码反汇编, 可与此前的汇编知识对应起来.
示例¶
以 hello.c 为例:
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("Hello World!\n");
return 0;
}
编译后生成的可执行文件可以用前面介绍的工具观察其 ELF 结构:
-
文件类型与架构信息
file ./hello.out readelf -hW ./hello.outfile显示ELF 64-bit LSB pie executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2.readelf -hW中Type为DYN (Position-Independent Executable file), 印证前文所说 PIE 使用ET_DYN; 入口Entry point address为0x1040, 它是_start而不是main(0x1139). -
程序头表
readelf -lW ./hello.outINTERP(0x3ac) 指向ld-linux-x86-64.so.2, 另有 4 个权限各不相同的PT_LOAD:段 文件偏移 / 虚拟地址 权限 包含的节区 LOAD 1 0x0R.interp,.dynsym,.dynstr,.rela.dyn,.rela.plt等动态链接元数据LOAD 2 0x1000R E.init,.plt,.text,.finiLOAD 3 0x2000R.rodata,.eh_frame等LOAD 4 0x2dd0->0x3dd0RW.init_array,.dynamic,.got,.got.plt,.data,.bssLOAD 4 的
FileSiz为0x248,MemSiz为0x250, 多出的 8 字节就是.bss(NOBITS), 装载时补零, 与前文p_memsz > p_filesz的说明一致. 它的文件偏移 (0x2dd0) 与虚拟地址 (0x3dd0) 也不相同, 说明二者是两个概念.GNU_RELRO覆盖.init_array,.dynamic,.got, 动态链接器完成重定位后会将它们改为只读, 而.got.plt不在其中. -
节区头表
readelf -SW ./hello.out节区与上表的段一一对应:
-
.text(0x1040,AX) 位于可执行的 LOAD 2 -
.rodata(0x2000,A) 位于只读的 LOAD 3, 字符串Hello World!就存放在.rodata中 -
.got/.got.plt/.data(WA) 位于可写的 LOAD 4 -
.bss为NOBITS, 不占文件空间
-
-
动态段
readelf -dW ./hello.outNEEDED只有libc.so.6,FLAGS_1为PIE, 动态链接器会据此装入libc.so.6并处理重定位. 输出中没有BIND_NOW, 因此函数调用采用延迟绑定. -
符号表
readelf -sW ./hello.outmain与_start是本文件定义的GLOBAL FUNC;puts@GLIBC_2.2.5与__libc_start_main@GLIBC_2.34的Ndx为UND, 即未定义符号, 需由libc.so.6提供.另外值得注意的是, 源码调用的是
printf, 符号表里却是puts. 这是因为编译器把只含字符串常量且以换行结尾的printf优化成了puts. -
重定位项
readelf -rW ./hello.out重定位分两张表:
重定位表 类型 目标 含义 .rela.dynR_X86_64_GLOB_DAT0x3fc0等.got项装载时填入 __libc_start_main,__cxa_finalize等外部符号地址.rela.dynR_X86_64_RELATIVE0x3dd0,0x3dd8,0x4010不依赖符号, 只需加上装载基址 (load bias), 例如 .init_array中frame_dummy和__do_global_dtors_aux的地址.rela.pltR_X86_64_JUMP_SLOT0x4000puts对应的.got.plt项, 首次调用时才被解析 -
反汇编
objdump -d ./hello.out > disasm.s结合上述信息, 可以把一次启动过程在代码里对应起来. 由于完整的反汇编代码篇幅较长, 这里没有贴出, 可在仓库中查看它.
-
_start(0x1040) 按 System V AMD64 ABI 准备参数:-
pop %rsi取出argc,mov %rsp,%rdx得到argv -
lea 0xda(%rip),%rdi把main的地址 (0x1139) 放入%rdi -
然后
call *0x2f5b(%rip)通过0x3fc0处的 GOT 项调用__libc_start_main这里的 GOT 项正是
.rela.dyn中R_X86_64_GLOB_DAT所填写的.__libc_start_main不会返回, 所以其后是hlt.
-
-
main(0x1139) 先建立栈帧, 把argc(%edi) 和argv(%rsi) 存入栈; 再用lea 0xeb5(%rip),%rax取得字符串地址0x2004, 它位于只读的.rodata, 内容为Hello World!; 最后call 1030 <puts@plt>并通过mov $0x0,%eax设置返回值0. -
puts@plt(0x1030) 是延迟绑定的典型结构:1030: jmp *0x2fca(%rip) # 跳到 GOT[puts] (0x4000) 1036: push $0x0 # 重定位项索引 103b: jmp 1020 # 跳到 PLT0 1020: push 0x2fca(%rip) # 压入 link_map (GOT+0x8) 1026: jmp *0x2fcc(%rip) # 跳到 _dl_runtime_resolve (GOT+0x10)首次调用时,
GOT[puts]里存的是0x1036(即 PLT 项的下一条指令), 控制流经 PLT0 进入动态链接器解析puts, 并把真实地址写回0x4000. 之后再次调用puts@plt时, 第一条jmp就会直接跳到libc中的puts. -
其余函数 (
_init、deregister_tm_clones、frame_dummy等) 是 C 运行时的初始化/清理代码, 不属于hello.c.frame_dummy与__do_global_dtors_aux的地址被放入.init_array/.fini_array, 即上面R_X86_64_RELATIVE修正的对象.
-
由此, 一个只有三行的程序在 ELF 中就能完整对应前文的链接与装载流程:
-
内核根据
PT_LOAD映射文件并启动PT_INTERP指定的解释器 -
解释器依据
DT_NEEDED装入libc.so.6并处理.rela.dyn, 再跳转到e_entry指向的_start, 最终进入main -
首次调用
puts时才通过.rela.plt与 PLT/GOT 完成绑定.






