Linux系统:ELF文件的定义与加载以及动静态链接
本节重点
- ELF文件的概念与结构
- 可执行文件,目标文件ELF格式的区别
- ELF文件的形成过程
- ELF文件的加载
- 动态链接与静态链接
- 动态库的编址与方法调用
一、ELF文件的概念与结构
1.1 文件概述
ELF(Executable and Linkable Format)即“可执行与可链接格式”,是类Unix系统(如Linux)中用于存储可执行程序、目标代码、共享库和核心转储的标准二进制文件格式。它替代了早期的a.out和COFF格式,具备更好的可扩展性和灵活性,成为现代Unix-like系统(包括Linux、FreeBSD等)的主流文件格式。
1.2 文件结构
ELF文件由ELF头(ELF Header)、程序头表(Program Header Table)、节头表(Section Header Table)和数据区域四部分组成,各部分通过偏移量和大小关联。
1. ELF Header
位置:文件开头(固定偏移量0x00)。
作用:描述文件的基本属性,包括:
- 文件类型:可执行文件(
ET_EXEC
)、共享库(ET_DYN
)、目标文件(ET_REL
)等。 - 目标机器架构:如x86、ARM等(通过
e_machine
字段标识)。 - 入口地址:可执行文件的起始执行地址(
e_entry
)。 - 程序头表和节头表的位置:通过
e_phoff
和e_shoff
字段指定。
在命令行中我们可以通过如下命令来查看某一可执行文件的ELF Header:
readelf -h 可执行文件的路径
2. 程序头表(Program Header Table)
位置:由ELF头中的e_phoff
字段指定。
作用:定义文件在内存中的布局,用于程序加载。
组成:由多个程序头(Program Header)条目组成,每个条目描述一个段(Segment),包括:
- 段类型:如
PT_LOAD
(可加载段)、PT_DYNAMIC
(动态链接信息)等。 - 虚拟地址:段在内存中的起始地址。
- 文件偏移量:段在文件中的起始位置。
- 段大小:段在文件和内存中的大小。
- 关联的节:通过程序头表的条目,可以间接或直接知道哪些节被合并到该段中。
3. 节头表(Section Header Table)
位置:由ELF头中的e_shoff
字段指定。
作用:描述文件的逻辑结构,用于链接和调试。
组成:由多个节头(Section Header)条目组成,每个条目描述一个节(Section),包括:节名称、节类型、节地址、节偏移量、节大小。
4. 数据区域
内容:实际存储代码、数据、符号表等信息的区域。
布局:由程序头表和节头表共同描述,但数据本身是连续存储的。
在ELF文件中,ELF Header记录了文件的整体信息,其中包含两个关键字段e_phoff和e_shoff,分别指向程序头表(Program Header Table)和节头表(Section Header Table)。程序头表由多个条目组成,每个条目描述一个段的信息;节头表同样由多个条目构成,每个条目对应一个节的信息。节和段是数据组织的两种方式:节主要用于链接和调试时的逻辑划分,段则对应程序加载到内存时的物理单元。
附言:可执行文件ELF格式与目标文件ELF格式的区别与关联
目标文件和可执行文件均采用ELF格式,但目标文件是编译阶段的中间产物,包含未链接的代码和数据;可执行文件是链接阶段的最终产物,包含已链接的代码和数据,可直接加载到内存执行。两者通过链接器关联,目标文件是可执行文件的“零件”,可执行文件是目标文件的“组装成品”。
维度 目标文件(.o) 可执行文件 文件内容 包含未链接的代码、数据、符号表等。 包含已链接的代码、数据、动态链接信息等。 符号表 包含未解析的外部符号引用(如未定义的函数)。 所有符号均已解析,可能包含导出符号(供共享库使用)。 程序头表(PHDR) 无程序头表(或仅部分信息)。 包含完整的程序头表,描述段(Segment)的内存布局。 节头表(SHDR) 包含完整的节头表,描述节(Section)的逻辑布局。 保留节头表,但主要用于调试,程序加载依赖程序头表。 可执行性 不可直接执行。 可直接执行。 动态链接信息 无(或仅部分信息)。 可能包含动态链接信息(如 .dynamic
节)。
二、ELF文件的形成
ELF文件的形成是一个涉及编译、汇编、链接等多个阶段的复杂过程:
2.1 编译与汇编阶段:
首先程序员使用C/C++等高级语言编写源代码,之后通过编译器(如gcc)将源代码翻译成汇编语言代码,生成.i和.s等中间文件。
之后汇编器将汇编语言代码翻译成机器指令,生成目标文件(.o文件)。目标文件是编译后的中间产物(如 gcc -c
生成),其 ELF 格式包含以下关键部分:
-
节(Sections):存储代码、数据、符号表等(如
.text
、.data
、.rodata
、.bss
)。 -
重定位表(Relocation Tables):记录需要后续链接阶段修正的地址(如
.rel.text
、.rel.data
)。 -
符号表(Symbol Table):记录全局符号(函数、变量)的临时地址和属性。
-
节头表(SHDR):包含完整的节头表,描述节(Section)的逻辑布局。
-
程序头表(PHDR):无程序头表(或仅部分信息)。
-
ELF Header:描述文件的基本属性。
之后编译器会对目标文件进行相对编址,其方式会有以下特点:
1> 节地址从零开始
编译器在生成目标文件时,会将每个节(Section)的代码或数据从地址 0 开始布局。例如:
.text
节中的第一条指令的地址是0
,第二条指令的地址是0 + 指令长度
,依此类推。.data
节中的第一个全局变量的地址是0
,第二个变量的地址是0 + 变量大小
。
因为目标文件是未链接的中间产物,编译器无法预知最终内存布局,比如后续链接器会将多个目标文件的节合并,并分配具体的虚拟地址(如 .text
从 0x08048000
开始)。因此,编译器采用“零基地址”作为占位符,后续由链接器通过重定位修正为真实地址。
2> 依赖重定位表修正地址
目标文件中所有需要后续修正的地址(如跨节引用、外部符号)会记录在重定位表中,以便于后续链接阶段链接器进行地址修正。
2.2 链接阶段
步骤1:节合并与段形成
链接器将不同目标文件中的相同类型的节(如.text
、.data
)合并成一个大的节,之后链接器会根据节的属性(如代码、数据、只读/可写)和用途(如执行、存储变量),将相关节合并为段。例如,所有可执行代码的节(如.text
)会被合并为代码段;已初始化数据(.data
)和未初始化数据(.bss
)会被合并为数据段;只读数据(如.rodata
)可能会被合并为只读数据段。
链接器完成节到段的合并后生成一张Section to Segment mapping ,用来表示节与段的映射结果,我们可以通过以下命令来查看:
readelf -l 可执行文件
Section(节)组织成段(Segments)的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行组织, 假设页面大小为4096字节(内存块基本大小,加载,管理的基本单位),如果.text部分为4097字节,.init部分为512字节,那么它们将占用3个页面,而合并后,它们只需2个页面。
步骤2:分配虚拟地址(VMA)
链接器通常采用平坦模式对整个ELF文件进行统一编址,其方法是0.(0地址处)+偏移量:
步骤3:符号解析与重定位
符号解析:链接器检查所有未定义符号(如 printf
)是否在其它目标文件或库中定义。
重定位修正:根据重定位表(.rel.text
、.rel.data
)修正代码和数据中的临时地址。
步骤4:生成可执行文件
链接器将合并后的段和相关的头部信息(如ELF头、程序头表、节头表)写入可执行文件,其中程序头表(PHDR)描述段如何加载到内存。
readelf -l a.out
输出示例:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x08048000 0x08048000 0x00100 0x00100 R E 0x1000
LOAD 0x000100 0x0804a000 0x0804a000 0x00050 0x00060 RW 0x1000
VirtAddr
字段即为链接阶段分配的虚拟地址,用来加载时初始化程序地址空间。
三、ELF文件的加载
3.1 可执行程序的加载
3.1.1 初始化虚拟地址空间
在之前我们知道,程序的虚拟地址空间本质上就是一个结构体mm_struct,其在内核层面的定义如下:
struct mm_struct {struct vm_area_struct *mmap; // 指向VMA链表的头节点pgd_t *pgd; // 页全局目录(PGD,即顶级页表)atomic_t mm_users; // 用户空间引用计数atomic_t mm_count; // 内核空间引用计数unsigned long start_code, end_code; // 代码段起止地址unsigned long start_data, end_data; // 数据段起止地址unsigned long start_brk, brk; // 堆的起止地址unsigned long start_stack; // 栈起始地址unsigned long arg_start, arg_end; // 命令行参数起止unsigned long env_start, env_end; // 环境变量起止// ... 其他字段(如内存映射统计、ASLR标记等)
};
mm_struct中记录了各种段的边界,如代码段、堆、栈等。除此之外,mm_struct内部还指向了一个元素为vm_area_struct的链表,每个vm_area_struct描述一段连续的虚拟地址空间(如ELF的.text
段、堆、栈、共享库等)。
struct vm_area_struct {unsigned long vm_start; // 区域起始地址(虚拟地址)unsigned long vm_end; // 区域结束地址struct mm_struct *vm_mm; // 所属的mm_structpgprot_t vm_page_prot; // 访问权限(读/写/执行)unsigned long vm_flags; // 标志位(如VM_READ、VM_WRITE、VM_EXEC)struct file *vm_file; // 关联的文件(若为文件映射)unsigned long vm_pgoff; // 文件偏移(以页为单位)struct vm_area_struct *vm_next; // 链表中的下一个VMA// ... 其他字段(如反向映射、匿名映射等)
};
在ELF文件的链接阶段,链接器已经将整个文件统一编址。这些地址信息会在加载阶段直接初始化程序的虚拟地址空间,过程如下:
步骤1:程序头表解析
内核通过解析ELF文件的程序头表(Program Header Table)确定需要加载的段(如PT_LOAD
类型),包括代码段(.text
)、数据段(.data
)、只读数据段(.rodata
)等。然后提取每个段的关键信息,比如偏移量、链接阶段确定的虚拟地址、大小和权限等等。因为编址时采用平坦模式,所以偏移量就是虚拟地址。
步骤2:映射虚拟内存
内核为每个 PT_LOAD
段调用 mmap
,将文件内容映射到进程的虚拟地址空间:
void *addr = mmap((void *)p_vaddr, // 请求的虚拟地址(链接时确定)p_memsz, // 内存中段的大小prot_flags, // 权限(如 PROT_READ | PROT_EXEC)MAP_PRIVATE | MAP_FIXED, // 私有映射+固定地址(若未启用ASLR)fd, // ELF文件描述符p_offset // 段在文件中的偏移
);
MAP_FIXED
:强制按 p_vaddr
分配地址,也就是链接时确定的虚拟地址。
权限转换:ELF的 p_flags
(如 PF_X
)转换为 mmap
的 prot_flags
(如 PROT_EXEC
)。
步骤3:填写页表
虚拟地址分配后,实际物理内存的分配是惰性的:CPU访问未映射的虚拟地址时,触发缺页异常(Page Fault)。
内核处理异常:
- 检查VMA权限是否合法。
- 分配物理页,填充文件内容(若为文件映射),或清零(匿名映射)。
- 更新页表:建立虚拟地址到物理地址的映射。
简单来讲,程序的虚拟地址空间的初始化数据从ELF的每个segment来,链接时ELF统一编址后每个segment的地址与大小用来初始化内核空间中的vm_area_struct,再填写mm_struct。之后内核会通过缺页异常产生虚拟地址到物理地址的映射。
3.2静态链接
静态链接(Static Linking)是指在编译阶段将程序依赖的所有外部库(函数、数据等)直接合并到最终的可执行文件中。这样生成的可执行文件是自包含的,运行时不再依赖外部的共享库(如 .dll
、.so
文件)。
静态链接的基本流程:
- 编译阶段:源代码(
.c
、.cpp
)被编译成目标文件(.o
或.obj
)。 - 链接阶段:链接器(如
ld
、link.exe
)将目标文件与静态库(.a
或.lib
)合并,生成独立的可执行文件(如a.out
、.exe
)。 - 运行时:程序直接运行,无需加载额外的动态库。
这里我们举个例子:
首先我们创建两个.c源文件并编写代码,之后将其编译为重定向文件。
//rush.c
#include<stdio.h>void run()
{printf("running...\n");
}
//code.c
#include <stdio.h>
void run();
int main() {printf("Hello, world!\n");run();return 0;
}
gcc -c *.c
我们将两个重定向文件进行反汇编后可以发现,编译器并不知道两个源文件中printf和run函数是什么,所以编译器将两个函数的跳转地址暂时设为0。
objdump -d code.o
objdump -d rush.o
除了将两个重定向文件反汇编,我们还可以通过查看两个文件的符号表,我们也可以看到printf与run都是未定义的:
#读取code.o的符号表
readelf -s code.o
#读取rush.o的符号表
readelf -s rush.o
前文我们讲到,在链接阶段链接器会检查所有未定义符号(如 printf
)是否在其它目标文件或库中定义,并根据重定位表修正代码和数据中的临时地址。所以在链接阶段函数的检查和跳转地址会最终确定。
这里我们将两个.o文件链接形成可执行文件,再次反汇编时我们会发现函数的跳转地址最终确定。
读取该可执行文件的符号表:
最终两个.o的代码段被合并到了一起,并进行了统一的编址。
所以静态链接其实就是将编译之后的所有目标文件连同用到的⼀些静态库在运行时组合,拼装成⼀个独立的可执行文件。其中就包括我们之前提到的地址修正,当所有模块组合在⼀起之后,链接器会根据我 们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。
3.3 动态链接
在软件开发中,程序通常由主程序代码和各种库函数组成。库函数可以是由操作系统或第三方提供的通用功能模块,如数学计算、文件操作、网络通信等。传统的静态链接方式是在编译时将所有需要的库函数代码直接嵌入到主程序的可执行文件中,导致可执行文件体积较大,且多个程序如果使用相同的库函数,会在内存中存在多份副本,造成资源浪费。
动态链接则打破了这种模式,它允许库函数以独立的动态链接库或共享对象的形式存在。这些库文件在程序运行时被加载到内存中,主程序通过动态链接机制调用其中的函数,实现了代码的共享和复用。
#查看一个可执行文件依赖的动态库
ldd 可执行文件路径
3.3.1 工作时期
在C/C++的程序运行时不会直接跳转到main函数位置开始执行,实际上程序的入口是一个_start函数,它通常是一个C标准库或者链接器提供的一个特殊的函数用来执行一系列初始化操作,这些操作包括:
- 设置栈指针
- 初始化寄存器状态
- 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位 置,并清零未初始化的数据段
- 动态链接:这是关键的⼀步, _start 函数会调用动态链接器的代码来解析和加载程序所依赖的 动态库(sharedlibraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。
- 调用 __libc_start_main :⼀旦动态链接完成, _start 函数会调用 __libc_start_main (这是glibc提供的⼀个函数)。 __libc_start_main 函数负责执行 ⼀些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等。
- 调用main 函数:最后,__libc_start_main 函数会调用程序的控制权才正式交给用户编写的代码。
- 处理 main 函数的返回值:main 函数返回时, __libc_start_main 会负责处理这个返回 _exit 函数来终止程序。
动态链接器
在动态链接阶段动态链接器(如ld-linux-x86-64.so.2 )负责在程序运行阶段解析动态库依赖并把动态库加载到内存中。
环境变量与配置文件:
Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置文件(如/etc/ld.so.conf及其子配置文件)来指定动态库的搜索路径这些路径会被动态链接器在加载动态库时搜索。
缓存文件:
为了提高动态库的加载效率,Linux系统会维护⼀个名为/etc/ld.so.cache的缓存文件,该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件
3.3.2 动态库的加载与调用
动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法统⼀编址, 我们也可以认为动态库是从0地址处开始编址的。
将动态库映射到程序虚拟地址空间:
与可执行程序ELF文件加载时类似,动态链接器会通过访问程序头表(Program Header Table)提取每个段的关键信息,比如偏移量、链接阶段确定的虚拟地址、大小和权限等等。之后内核会在程序虚拟地址空间的共享区为动态库开辟一段内存空间然后用前面提取的动态库的关键信息初始化这片虚拟地址空间。
在将动态库加载到虚拟地址空间后,还需要进行重定位操作。因为动态库在链接阶段确定的虚拟地址可能与实际加载到进程虚拟地址空间中的地址不同,重定位过程就是修改动态库中的相关指令和数据,使其能够正确地引用到实际的内存地址。
之后内核会填写页表建立虚拟与物理内存之间的映射关系。
最后我们需要明白的是,动态库在程序虚拟地址空间中仅占据部分区域,内核会为其分配一个基地址。由于动态库内部采用统一编址(0x00000000至0xFFFFFFFF),访问库内方法或内容只需计算基地址与内部偏移量之和即可。
动态库的调用:
在动态链接阶段动态链接器会在 .data (可执行程序或者库自己)中专门预留⼀片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运行模块要引用的⼀个全局变量或函数的地址,这些地址本质上就是上述讲到的库起始虚拟地址+ 偏移量,每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。
重要的是因为.data区域是可读写的,所以可以支持动态进行修改。
此时在代码区调用动态库中某一个方法本质上就是跳转到GOT表查找然后根据表中地址进行跳转,这些地址在动态库加载阶段会被修改为真正的地址(因为只有库加载完成后才能确定起始地址)。
这种方式实现的动态链接就被叫做 PIC 地址无关代码。换句话说,我们的动态库不需要做任何修 改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给 编译器指定-fPIC参数的原因, PIC=相对编址+GOT。
总结:
- 静态链接的出现,提高了程序的模块化水平。对于⼀个大的项目,不同的人可以独立地测试和开发 自己的模块。
- 通过静态链接,生成最终的可执行文件。我们知道静态链接会将编译产生的所有目标文件,和用到的各种库合并成⼀个独立的可执行文件, 其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
- 而动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行⼀个程序,操作系统会首先将程序的数据代码连同它用到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过.GOT方式进行调用(运行重定位,也叫做动态地址重定位)。