目录
[ELF 文件格式](#ELF 文件格式)
在Linux系统中,可执行文件(包括那些有 .exe 扩展名的文件,通常是由交叉编译或Wine兼容层使用的) 内部存储的并不是简单的处理器指令列表。它是一个高度结构化、包含多个关键部分的二进制文件。
理解这些内容对于系统编程、调试和安全分析至关重要。以下是其主要组成部分的详细解析:
ELF 文件格式
Linux原生可执行文件的标准格式是 ELF(Executable and Linkable Format) 。
.exe是Windows PE格式的扩展名,但在Linux下,如果它是一个Linux程序,它实际上是一个ELF文件被命名为了.exe(常见于一些跨平台游戏或工具)。当我们在Linux中谈论"可执行程序内部存储了什么"时,我们通常指的是ELF格式的内容。
一个ELF可执行文件主要由以下几部分组成:
- ELF 头部(ELF Header)
位于文件开头,是整个文件的"路线图"。它包含:
魔数(Magic Number) : 最开始的几个字节(
7f 45 4c 46)标识这是一个ELF文件。文件类型: 指明是可执行文件、共享库还是目标文件。
目标机器架构: 例如 x86-64、ARM等,确保程序能在正确的CPU上运行。
程序入口点: 程序启动时,第一条指令的逻辑地址,它通常不是 main 函数,而是 _start 函数。CPU 中有 EIP/PC 寄存器,进程在运行时,tesk_struct 记录了自己的可执行程序的 ELF 文件地址,CPU首先找到ELF 文件的头部记录的程序入口点,保存到 EIP 寄存器,然后在进程地址空间的代码段建立程序入口点的虚拟地址,发现该虚拟地址没有对应的物理地址,触发缺页中断,然后再真正的把在磁盘存储的程序入口点加载到内存,建立虚拟地址与物理地址的映射关系。
程序头表和节头表的位置信息: 指向文件中其他重要结构的指针。
可以使用 readelf -h <filename> 命令查看ELF头部信息。
- 程序头表(Program Header Table)
这是一个数组,描述了如何将文件的不同部分映射到进程的内存空间中。每个条目(称为"段",Segment)定义了:
段类型 : 例如,
LOAD(需要加载到内存的段)、DYNAMIC(动态链接信息)、INTERP(动态链接器路径)。在文件中的偏移量 和在内存中的虚拟地址。
访问权限 : 读(R)、写(W)、执行(X)。例如,代码段是
R-X,数据段是RW-。
操作系统加载器就是根据这个表来创建进程内存映像的。使用 readelf -l <filename> 查看。
- 节(Sections)
这是从编译和链接视角对文件内容的划分。节包含了链接和调试所需的各种数据。重要的节包括:
.text: 这是程序的核心,存储了所有可执行的机器指令(编译后的代码)。
.rodata: 只读数据,例如字符串常量、全局常量。
.data:已初始化的全局变量和静态变量。
.bss: 未初始化的全局和静态变量。这个节在文件中不占实际空间,只是在内存中预留位置。
.symtab/.dynsym: 符号表,存储函数和变量名及其地址,用于静态/动态链接。
.strtab/.dynstr: 字符串表,存储符号名称等字符串。
.rel.*: 重定位信息,用于在链接时修正地址。
.dynamic: 动态链接信息,包含依赖哪些共享库、符号表地址等。
.interp: 一个字符串,指定动态链接器的路径(如/lib64/ld-linux-x86-64.so.2)。
节中每个数据都有自己的地址,此时的地址称为逻辑地址 ,可以使用 readelf -S <filename> 或 objdump -h <filename> 查看节信息。
- 节头表(Section Header Table)
这是一个索引表,描述了文件中每个节的位置、大小和属性。对于调试和链接非常重要,但运行时并非必需(有些可执行文件会剥离此表以减小体积)。
重要概念:段(Segment) vs 节(Section)
节(Section) 是链接视图的单位,用于组织文件内容,供链接器和调试器使用。
段(Segment) 是执行视图 的单位,描述了内存中一个连续的、具有相同权限的区域 。
一个段(如可加载的
LOAD段)通常包含多个节 (例如,一个可读可执行的段包含.text和.rodata;一个可读可写的段包含.data和.bss)。这是为了优化内存页对齐和权限设置。
动态链接
大多数现代Linux程序都使用动态链接。这意味着:
文件不包含 其所依赖的库(如
libc.so)的完整代码。文件内部存储了依赖库的名称列表 (在
.dynamic节中)。在程序启动时,由动态链接器 (路径在
.interp节中指定)负责将这些共享库加载到内存,并完成符号地址的解析(重定位)。
工具查看实例
你可以使用以下命令深入查看一个Linux可执行文件的内容:
查看文件类型 :
file /bin/ls
- 输出:
/bin/ls: ELF 64-bit LSB shared object, x86-64, ... dynamically linked, ...查看ELF头部 :
readelf -h /bin/ls查看程序头(段) :
readelf -l /bin/ls查看节 :
readelf -S /bin/ls反汇编代码段 :
objdump -S /bin/ls(查看机器指令)查看动态依赖 :
ldd /bin/ls或readelf -d /bin/ls | grep NEEDED
总结:Linux可执行文件内部存储了
一个定义结构的ELF头部。
用于指导操作系统加载内存的"程序头表"。
包含实际CPU指令的
.text节。包含程序数据的
.data、.rodata、.bss节。用于(静态和动态)链接的元数据,如符号表、重定位信息。
动态链接信息,包括解释器路径和依赖库列表。
所有这些部分共同协作,使得操作系统能够正确地将一个静态的磁盘文件加载并运行为一个活跃的进程。
库函数的调用-以printf为例
- 编译阶段(程序编写时)
cpp
// 你的代码
printf("hello");
编译器看到printf,知道它是外部函数,生成这样的代码:
cpp
call printf@PLT # 不是直接call printf,而是call一个特殊跳板
2. 链接阶段(程序准备时)
链接器创建了两个表:
PLT(过程链接表):一段小代码,像"接线员"
GOT(全局偏移表):一个地址簿,目前是空的
cpp
PLT[1] (printf的接线员):
jmp *GOT[3] # 第一次:GOT[3]里是"问路处"地址
push 1 # 说:我要找printf(编号1)
jmp PLT[0] # 去总服务台
GOT表初始状态:
[0]: .dynamic地址
[1]: 动态链接器标识
[2]: 动态链接器的解析函数地址
[3]: 指向PLT[1]+6 # 第一次会回到接线员
3. 第一次调用的实时过程
步骤1:进入接线员(PLT)
cpp
# 你的程序执行
call printf@PLT # 跳到printf的接线员代码
# printf@PLT代码:
jmp *GOT[3] # 第一次:GOT[3]指向"去问路"的代码
# 所以跳回下一行
push 1 # 把"我要找printf"的编号1压栈
jmp PLT[0] # 跳转到总服务台
步骤2:总服务台(PLT[0])
cpp
# 你的程序执行
call printf@PLT # 跳到printf的接线员代码
# printf@PLT代码:
jmp *GOT[3] # 第一次:GOT[3]指向"去问路"的代码
# 所以跳回下一行
push 1 # 把"我要找printf"的编号1压栈
jmp PLT[0] # 跳转到总服务台
步骤3:动态链接器查找
现在控制权交给了动态链接器(ld.so):
cpp
// 动态链接器内部
1. 查看栈上的编号1:"哦,要找printf"
2. 在内存中搜索:"printf在哪个库里?"
3. 发现printf在libc.so.6里
4. 计算printf的实际内存地址:
- libc加载地址:0x7ffff7a00000
- printf在libc内的偏移:0x64f00
- printf实际地址:0x7ffff7a64f00
5. **关键一步**:把这个地址写回GOT[3]
6. 跳转到printf函数(0x7ffff7a64f00)
步骤4:真正的printf执行
cpp
// 现在终于执行真正的printf了
int printf(const char* format, ...) {
// 实际的打印逻辑...
return 0;
}
步骤5:返回
cpp
# printf执行完后
ret # 返回到你的程序
4. GOT表的状态变化
调用前:
cpp
GOT[3]: 0x555555555036 # 指向"去问路"的代码
调用后(动态链接器修改后):
cpp
GOT[3]: 0x7ffff7a64f00 # printf的实际地址!
5. 后续调用的区别
第二次调用printf时:
cpp
# 你的程序
call printf@PLT
# printf@PLT代码:
jmp *GOT[3] # 这次直接跳到0x7ffff7a64f00(printf)
# 不再经过动态链接器!
# 下面的push和jmp永远不会执行了
6. 整个过程比喻
想象去一家大公司找人:
第一次找张三:
你:前台,我找张三
前台:我不知道张三在哪,你去人事部问问
人事部:查档案...张三在3楼301
人事部把"301"写在前台的本子上
带你去301见张三
第二次找张三:
你:前台,我找张三
前台:看一眼本子"301",直接带你上三楼
不用再问人事部了
7. 关键总结
第一次慢,后续快:第一次要"查通讯录",后续直接"拨号"
GOT是通讯录:记录每个函数的实际号码
PLT是快捷拨号键:按一下,自动查通讯录并拨打
动态链接器是查号台:第一次帮你查号码并保存
这就是著名的**延迟绑定(Lazy Binding)**技术,既保证了灵活性(可以任意位置加载),又保证了效率(只解析一次)。
库的链接方式
为什么把动态库编译成 .o 文件需要 -fPIC(生成与位置无关码)选项呢?该选项又是什么呢,需要解决什么问题呢,为什么静态库不需要这个选项呢?
-fPIC(生成与位置无关码)选项是为了让动态库能够加载到进程地址空间的堆栈之间的共享区的任意位置,那为什么要这么做呢?可以先看看现实世界的对比:
动态库(任意位置)就像:
酒店入住系统
客人:我要一个房间
前台:给你308房(今天空着308)
明天同样的客人:给我一个房间
前台:给你512房(今天空着512)
客人不关心房号,只说"我要房间"
固定地址库就像:
固定座位剧院
张三永远坐A区1排1座
李四永远坐A区1排2座
问题:
如果王五也想坐1排1座?冲突!
如果有人知道张三永远坐那里?安全风险!
1排1座坏了,张三就没座位了!
| 特性 | 固定位置加载 | 任意位置加载 |
|---|---|---|
| 地址冲突 | 经常发生 | 永远不会 |
| 内存利用率 | 低(有空隙) | 高(紧凑) |
| 安全性 | 低(地址可预测) | 高(ASLR) |
| 多进程共享 | 可以,但有风险 | 安全共享 |
| 库版本兼容 | 困难 | 容易 |
| 实现复杂度 | 简单 | 复杂(需要PIC) |