深入理解ELF
前言:
你每天都在用 ./a.out,但有没有想过:
这个文件里到底装了什么?操作系统拿到它,又做了哪些事,才让 main() 函数开始执行?
这篇文章,我们把 ELF(Executable and Linkable Format) 这个 Linux 下可执行文件的格式从里到外讲清楚,配合图解,让你一次彻底搞懂"一行代码是怎么跑起来的"。
一、从一行代码到可执行文件:四步走
写 C/C++ 代码到最终运行,要经过四个步骤:

步骤说明:
-
预处理(gcc -E):展开
#include、#define→ 生成.i文件 -
编译 + 汇编(gcc -c):翻译成机器指令 → 生成
.o目标文件(ELF 格式) -
链接(ld):合并多个
.o,重定位符号 → 生成可执行文件(ELF 格式) -
加载运行:
execve()系统调用,内核把 ELF 映射到内存,启动进程
四个阶段下来,每一步的产物都值得关注。其中目标文件 .o、可执行文件 a.out、动态库 .so 这三种文件,格式都是 ELF——这是理解后续所有内容的基础。
二、ELF 文件内部结构:它里面装了什么?
用 file 命令看任何 Linux 可执行文件,都会看到"ELF"字样。ELF 是 Linux(以及大多数 Unix 系统)可执行文件、目标文件、共享库的统一格式。
ELF 文件从内部结构看分两个视角:
-
Section 视图(链接器视角):编译器和链接器关心的是
.text、.data、.bss这些节 -
Segment 视图(运行时视角):操作系统加载时关心的是 Segment,按权限(读/写/执行)分组

几个重要 Section 值得重点记忆:
.text:机器指令,只读可执行,int main() 的代码就在这里。
.data:有初始值的全局/静态变量,如 int x = 42;,在文件里占空间。
.bss:未初始化的全局/静态变量,如 int y;,在文件里不占空间(只记录大小),程序加载时由操作系统清零。这是一个聪明的设计,节省了文件体积。
.rodata:只读数据,如字符串常量 "hello world" 就住在这里。
.plt 和 .got:动态链接的关键,后面重点讲。
多个 Section 按权限合并成一个 Segment,操作系统以 Segment 为单位做内存映射:
-
LOAD Segment 1:.text+.rodata,权限 r-x(读 + 执行) -
LOAD Segment 2:.data+.bss+.got,权限 rw-(读 + 写)
可以用工具直接查看 ELF 文件:
$ readelf -S a.out # 查看所有 Section
$ readelf -l a.out # 查看所有 Segment
$ objdump -d a.out # 反汇编 .text 段
$ nm a.out # 查看符号表
三、程序是怎么被加载进内存的?
当你执行 ./a.out,发生了这几步:
-
shell 调用
execve("./a.out", ...)系统调用 -
内核读取 ELF Header,找到程序头表(Program Header Table)
-
内核把每个
LOAD Segment用mmap映射到进程的虚拟地址空间 -
如果是动态链接程序,内核把控制权交给动态链接器
ld.so,而不是直接跳到main() -
ld.so加载所有依赖的.so文件,完成符号解析 -
最终跳到程序入口(不是
main(),是_start,由 C 运行时初始化后才调用main())这里有个和前几篇的重要关联:内核加载程序时,用的正是前几篇讲过的mmap+ 缺页中断机制——Segment 并不是全量复制进内存,而是建立映射,真正访问到哪个页,才通过缺页中断加载进来。

四、动态链接:运行时的"最后一步"
现代程序几乎都用动态链接,调用 printf、malloc 这些函数时,代码实际在 libc.so 里,而不是静态嵌入可执行文件。
这带来一个问题:编译时不知道 printf 运行时在内存哪个地址,怎么调用它?
答案是 PLT(Procedure Linkage Table)+ GOT(Global Offset Table) 机制,实现惰性绑定(Lazy Binding)——第一次调用时才真正解析函数地址。

第一次调用 printf 的完整流程:
1、你的代码执行 call printf@PLT
2、跳到 PLT 桩代码,PLT 执行 jmp *GOT[printf]
3、GOT 初始值指回 PLT 的"resolver"代码
4、触发 ld.so 解析:在 libc.so 中查找 printf 真实地址
5、ld.so 把真实地址填入 GOT[printf]
6、执行 printf
PLT/GOT 惰性绑定的精妙之处:
-
程序启动时不解析所有函数,只有第一次调用某函数时才解析,加快启动速度
-
GOT 在
.data段(可写),解析后填入真实地址 -
第二次调用直接通过 GOT 跳转,几乎没有额外开销
这也是为什么 LD_PRELOAD 可以注入自定义函数——通过替换 GOT 表项,让函数调用跳转到你的实现。这是很多性能分析工具和 Hook 框架的底层原理。
五、静态链接 vs 动态链接:各有取舍
静态链接(gcc -static)
├─ 优点:一个文件,无依赖,部署简单,启动快
├─ 缺点:体积大,多个程序都包含 libc 副本,浪费内存
└─ 适用:容器镜像、嵌入式、跨环境部署
动态链接(默认)
├─ 优点:体积小,多进程共享 .so 物理页(节省内存),升级库无需重新编译
├─ 缺点:依赖环境,有"DLL hell"风险,启动需要 ld.so 解析
└─ 适用:桌面/服务端程序,绝大多数场景
# 查看程序依赖哪些动态库
$ ldd /bin/ls
linux-vdso.so.1 (0x00007ffc...)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
# 查看 ELF 入口点地址
$ readelf -h a.out | grep Entry
Entry point address: 0x401050 ← 这是 _start,不是 main()
六、_start 到 main():中间还有谁?
很多人以为程序从 main() 开始,其实不是。ELF 的真正入口是 _start,由 C 运行时(CRT)提供:
_start(ELF 入口,由 crt0.o 提供)
│
├─ 初始化栈(传入 argc、argv、envp)
├─ 调用 __libc_start_main()(libc 提供)
│ ├─ 调用 .init_array 中的全局构造函数(C++ 全局对象的构造)
│ ├─ 调用 main()
│ ├─ main() 返回后调用 .fini_array 中的全局析构函数
│ └─ 调用 exit()
└─ 进程结束
这解释了为什么 C++ 的全局对象构造函数会在 main() 之前执行——它们在 .init_array 段里注册,由 __libc_start_main 在调用 main() 前逐一执行。
七、一些有趣的实战技巧
查看程序的符号表:
$ nm a.out | grep -i main
0000000000401139 T main # T = text段的全局符号
查看反汇编代码,理解编译器在做什么:
$ objdump -d -M intel a.out | grep -A 20 "<main>"
用 strace 看程序启动时的系统调用序列:
$ strace ./a.out 2>&1 | head -20
execve("./a.out", ...) # 启动
openat(..., "libc.so.6", ...) # ld.so 打开动态库
mmap(...) # 映射 Segment 到内存
mprotect(...) # 设置内存权限
...
用 readelf 直接查看 ELF 结构:
$ readelf -S a.out # 所有 Section 及其大小、偏移
$ readelf -l a.out # 所有 Segment 及权限
$ readelf -d a.out # Dynamic Section(依赖的动态库)
八、高频面试题精析
Q:.bss 段在 ELF 文件中为什么不占空间?
.bss 存的是未初始化全局变量,初始值全是 0。ELF 只在文件头记录它的大小,运行时由操作系统负责分配并清零对应内存。这样设计节省了文件体积——如果程序有一个 int arr[1000000]; 全局数组,文件里只需记录"需要 4MB 的零初始化内存",而不需要真的存 4MB 的 0。
Q:动态链接和静态链接哪个快?
启动速度:静态链接更快(无需 ld.so 解析动态库)。 运行速度:差别极小(PLT/GOT 的间接跳转只多一次内存访问)。 内存效率:动态链接更优(多个进程共享同一 .so 的物理页)。
Q:为什么 C++ 全局对象的构造函数会在 main() 前执行?
编译器把全局对象的构造函数地址存入 .init_array 段,__libc_start_main() 在调用 main() 之前会遍历 .init_array,依次调用这些构造函数。
Q:LD_PRELOAD 是怎么工作的?
LD_PRELOAD 告诉 ld.so 在加载其他动态库之前先加载指定的 .so。因为符号解析时先找到的符号会被使用,LD_PRELOAD 里的 .so 优先级最高。配合 PLT/GOT 机制,可以实现在不修改程序源码的情况下替换任意函数调用,常用于性能分析(如 gperftools)、内存分析(如 jemalloc)等。
九、结语
顺着这篇文章的主线走一遍:
.c 源文件
└─ 预处理 + 编译 + 汇编 → .o(ELF 目标文件)
└─ 链接 → a.out(ELF 可执行文件)
└─ execve() → 内核读 ELF Header
└─ mmap LOAD Segment → 建立虚拟内存映射
└─ ld.so 加载 .so + PLT/GOT 惰性绑定
└─ _start → __libc_start_main → main()
ELF 是 Linux 世界里无处不在的格式——理解它,不只是为了应付面试,更是真正理解"程序是什么"这个根本问题的起点。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)