ELF 文件到底是什么:程序还没运行前,磁盘上那份可执行文件里装了什么

你在磁盘上看到一个 a.outmainmyapp,它还只是一个文件。可一旦 execve() 把它拉起来,它就变成了进程。

这中间靠的,就是 ELF。

ELF 的全名是 Executable and Linkable Format。名字已经把它说得很明白了:它既是“可执行”的,也是“可链接”的。Linux、BSD、Solaris 这类 Unix-like 系统之所以大量使用 ELF,不是因为它名字好听,而是因为它把“编译器怎么摆文件”“链接器怎么拼文件”“内核怎么把文件装进内存”这三件事拆得很清楚。

源码 -> 编译/汇编 -> .o 目标文件 -> 链接 -> ELF 可执行文件
                                      -> 动态链接器 / 内核加载

如果你只把 ELF 理解成“二进制文件格式”,还不够。它更像是一份搬运说明书:这部分要映射到内存、那部分只是给链接器看的、这部分得交给动态链接器、那部分要留给符号解析。

为什么 Linux 喜欢 ELF

先说结论:因为它分工清楚。

在 ELF 里,文件里不只是“程序代码”四个字。它把数据、代码、重定位信息、符号、动态库依赖、入口点、内存布局提示,拆成了不同层次。这样做的好处很直接:

  • 编译器和链接器知道该往哪放东西
  • 内核知道该把哪些内容映射到进程地址空间
  • 动态链接器知道该去加载哪些共享库
  • 调试器和分析工具知道该从哪读取符号和位置信息

Linux 内核里负责执行 ELF 的主路径在 fs/binfmt_elf.c。用户态执行一个 ELF 时,内核先读文件头和程序头表,再决定怎么映射各个 PT_LOAD 段;如果有解释器,也就是动态链接器,还会按 PT_INTERP 找到它,再把控制权交给它。

这就是为什么 ELF 不是单纯给机器看的,而是给一整条工具链看的。

先认识文件头

ELF 文件最开始是一小段固定格式的头部,叫 ELF header。

它最关键的作用只有一个:告诉你“这是个什么东西”。

你通常会在这里看到这些信息:

  • 这是 32 位还是 64 位
  • 字节序是小端还是大端
  • 目标架构是什么
  • 文件类型是可执行文件、共享库、目标文件,还是核心转储
  • 程序入口点在哪里
  • 程序头表和节头表分别从哪里开始

可以把它理解成书封面和目录页的结合体。你还没翻正文,就先知道这本书写给谁、从哪一页开始读、后面大概怎么排。

ELF 文件
├── ELF header
├── Program Header Table
├── file body ...
└── Section Header Table

很多人第一次看 ELF 都会直接冲着 .text.data.bss 去,但实际上内核最先盯上的不是这些节,而是程序头表。

程序头表才是内核真正爱看的部分

程序头表是 ELF 里更偏“运行时”的那一层。

它告诉内核:这些内容要映射到什么样的虚拟地址、需要什么权限、文件里的哪一段对应内存里的哪一段。Linux 加载 ELF 时,最重要的就是 PT_LOAD 条目。每个 PT_LOAD 都描述了一段需要装入内存的区域。

这就是为什么很多文章会说“段(segment)是给运行时看的,节(section)是给链接器看的”。这句话大体没错,但别背得太死。更准确一点的说法是:

  • 程序头表描述“怎么装进内存”
  • 节头表描述“文件内部怎么组织”

程序头表里除了 PT_LOAD,你还常见到:

  • PT_INTERP:动态链接器路径
  • PT_DYNAMIC:动态链接相关信息
  • PT_GNU_STACK:栈是否可执行
  • PT_NOTE:各种 note 信息

在 Linux 的用户态约定里,PT_INTERP 很重要。Documentation/userspace-api/ELF.rst 里明确说了,Linux 会拿第一个 PT_INTERP 去定位 ELF interpreter,也就是动态链接器本身,比如 /lib64/ld-linux-x86-64.so.2 之类的东西。

如果你把一个动态链接程序想成一场演出,主程序只是演员,PT_INTERP 指向的动态链接器才是后台总控。

节和段,不是一回事

这是 ELF 入门里最容易混的一对概念。

节(section)更像“给构建工具看的分类标签”。你在 readelf -S 里看到的 .text.rodata.data.bss.symtab.strtab.rela.dyn,都属于节。

段(segment)更像“给运行时装载看的打包结果”。你在 readelf -l 里看到的 LOADINTERPDYNAMIC,都是程序头表里的项目。

一个简单对照表大概是这样:

| 维度 | section | segment | | --- | --- | --- | | 面向谁 | 链接器、调试器、构建工具 | 内核、动态链接器 | | 关心什么 | 文件内部分类 | 运行时映射和权限 | | 常见例子 | .text.data.symtab | PT_LOADPT_INTERPPT_DYNAMIC |

为什么要分两套?

因为它们解决的问题不一样。节是“文件怎么写”;段是“内存怎么摆”。编译器和链接器需要更细颗粒度的信息,内核装载时却不想被这些细碎分类拖住,所以它只看对自己有用的那层。

符号表到底在干什么

如果你想知道一个 ELF 里到底有哪些函数、全局变量、未定义引用,符号表就是关键。

符号表里记录的是名字和位置之间的关系。它告诉工具链:

  • main 在哪里
  • printf 是外部未定义符号,得去别的库里找
  • 某个全局变量的地址和大小是多少
  • 哪些符号对外可见,哪些只是本文件内部使用

你常见的有两类:

  • .symtab:完整符号表,通常给调试和分析工具用
  • .dynsym:动态符号表,给运行时动态链接器和共享库解析用

这两个不是重复劳动。.symtab 往往更完整,但很多发行版在 strip 之后会把它去掉;.dynsym 则要保留,因为运行时还得靠它找外部符号。

这也是为什么你用 nmreadelf -sobjdump -t 看一个程序时,看到的内容会比程序真正运行时更多。

动态链接那部分,最像“把戏台搭起来”

ELF 最有意思的地方,往往是动态链接。

一个动态链接程序并不是把所有库代码都拷进自己的文件里。它只在 ELF 里留下“我依赖谁”的线索,真正用到的时候再交给动态链接器去装载。

这几个名字很常见:

  • .interp:记录解释器路径,对应 PT_INTERP
  • .dynamic:动态链接信息,对应 PT_DYNAMIC
  • DT_NEEDED:我需要哪些共享库
  • DT_RPATH / DT_RUNPATH:库搜索路径线索
  • .plt / .got:延迟绑定和地址跳转常用的配套结构

你可以把它理解成:

主程序 ELF
  -> 告诉内核先把动态链接器装起来
  -> 动态链接器读取 .dynamic
  -> 找到 DT_NEEDED 对应的共享库
  -> 把符号地址解析好
  -> 跳到程序入口

所以你看到一个程序“还没运行前”的文件,其实已经把未来运行时会用到的很多信息提前写好了。

用一个小图把它串起来

磁盘上的 ELF
├── ELF header
├── Program Header Table
│   ├── PT_LOAD -> 代码段、数据段映射
│   ├── PT_INTERP -> 动态链接器路径
│   └── PT_DYNAMIC -> 动态链接信息
├── sections
│   ├── .text
│   ├── .rodata
│   ├── .data
│   ├── .bss
│   ├── .symtab / .dynsym
│   └── .rela.* / .plt / .got
└── Section Header Table

如果你用 readelf -hWreadelf -lWreadelf -SW 去看一个程序,这三层会很直观:

  • -h 看头
  • -l 看程序头表
  • -S 看节头表

第一次看会有点乱,但一旦把“谁负责运行时,谁负责构建时”分开,很多字段就突然有意义了。

为什么你不该把 ELF 只当成“二进制文件”

ELF 的价值不只是“能执行”。

它把一整个软件交付流程串了起来:

  • 编译器生成目标文件
  • 链接器合并和重定位
  • 内核负责装载
  • 动态链接器负责补齐共享库
  • 调试器和分析工具读取符号和段信息

这也是为什么 ELF 文件看起来像一堆冷冰冰的字段,实际上却是程序从磁盘走到内存、从静态文件变成进程的桥梁。

如果你以后再看到 ELF,别只把它当成一个缩写。它是“程序在运行前,已经把自己怎么装进去、怎么找库、怎么被定位”这整件事提前写在磁盘上的说明书。

而内核和链接器,只是照着说明书把它一步步变成可运行的进程。

吉ICP备2024014750号-1 备案图标 京公网安备11011202100605号