前言:

你每天都在用 ./a.out,但有没有想过:

这个文件里到底装了什么?操作系统拿到它,又做了哪些事,才让 main() 函数开始执行?

这篇文章,我们把 ELF(Executable and Linkable Format) 这个 Linux 下可执行文件的格式从里到外讲清楚,配合图解,让你一次彻底搞懂"一行代码是怎么跑起来的"。

一、从一行代码到可执行文件:四步走

写 C/C++ 代码到最终运行,要经过四个步骤:

图片

步骤说明

  1. 预处理(gcc -E):展开 #include#define → 生成 .i 文件

  2. 编译 + 汇编(gcc -c):翻译成机器指令 → 生成 .o 目标文件(ELF 格式)

  3. 链接(ld):合并多个 .o,重定位符号 → 生成可执行文件(ELF 格式)

  4. 加载运行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,发生了这几步:

  1. shell 调用 execve("./a.out", ...) 系统调用

  2. 内核读取 ELF Header,找到程序头表(Program Header Table)

  3. 内核把每个 LOAD Segment 用 mmap 映射到进程的虚拟地址空间

  4. 如果是动态链接程序,内核把控制权交给动态链接器 ld.so,而不是直接跳到 main()

  5. ld.so 加载所有依赖的 .so 文件,完成符号解析

  6. 最终跳到程序入口(不是 main(),是 _start,由 C 运行时初始化后才调用 main())这里有个和前几篇的重要关联:内核加载程序时,用的正是前几篇讲过的 mmap + 缺页中断机制——Segment 并不是全量复制进内存,而是建立映射,真正访问到哪个页,才通过缺页中断加载进来。

图片

四、动态链接:运行时的"最后一步"

现代程序几乎都用动态链接,调用 printfmalloc 这些函数时,代码实际在 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 世界里无处不在的格式——理解它,不只是为了应付面试,更是真正理解"程序是什么"这个根本问题的起点。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐