ZeroOne AI
← 返回文章列表

Linux系统篇(二十二)——文件(六):目标文件与 ELF 深度解析:从编译到加载的全景揭秘

👁 5
分类:工业互联网

从目标文件出发,逐层解剖ELF头部、节头表与程序头表,讲透静态链接重定位、动态链接GOT/PLT以及程序从编译到加载的完整过程。

项目背景

上一篇讲完了动静态库的原理与用法,但很多人的疑问并没有结束:gcc -c 产出的 .o 文件到底是什么?它和 .soa.out 有什么区别?ELF 为什么被称作"统一一切的二进制格式"?链接器凭什么能修正函数地址?

这些问题直接关系到"源码 → 编译 → 链接 → 加载 → 运行"的完整链路。本文从目标文件讲起,逐层解剖 ELF 的四大组成、两个视图,并用 readelfobjdumpsize 等工具实操演示,最后对比静态链接与动态链接的本质差异。

技术方案

目标文件:编译的"半成品"

假设项目有几十个源文件,只改动了其中一个。如果没有中间产物,每次都要全量重编,效率极低。引入目标文件后,改动 hello.c 只需重新编译 hello.c,再重新链接即可——这就是增量编译思想:编译是单个文件的事,链接才是全局的事。

# hello.c 与 code.c 各自编译,互不链接
gcc -c hello.c    # → hello.o
gcc -c code.c     # → code.o

gcc -c 的含义是"只编译(compile),不链接(no link)"。用 file 命令查看 hello.o:

file hello.o

输出会显示 ELF ... relocatablerelocatable(可重定位) 是目标文件的灵魂属性:其中的地址尚未固定,等待链接时重新定位。

ELF:统一一切的二进制格式

ELF(Executable and Linkable Format,可执行与可链接格式)是 Linux 下可执行文件、目标文件、共享库、核心转储的统一格式标准。以下四类文件都是 ELF:

文件类型扩展名/名称说明
可重定位文件.o适合与其他目标文件链接,进而生成可执行文件或共享库
可执行文件无扩展名 / a.out经过完整链接,可直接加载运行
共享目标文件.so动态链接库,由运行时动态链接器加载
核心转储core进程崩溃时的执行上下文,供事后调试

核心思想:无论 .o.so 还是 a.out,骨子里都是 ELF

ELF 的四大组成

一个 ELF 文件由四部分构成:

常见节一览:

节名称作用
.text代码节,保存机器指令
.data已初始化的全局变量与静态变量
.rodata只读数据,如字符串常量
.bss未初始化数据,加载时清零,不占磁盘空间
.symtab符号表,函数名/变量名与地址的对应关系
.strtab符号名称字符串表
.got / .got.plt全局偏移表,提供共享库函数的访问入口

系统架构

ELF 头:文件的身份证

readelf -h hello.o    # 查看目标文件的 ELF 头
readelf -h a.out      # 对比可执行文件的 ELF 头

关键字段对比:

字段hello.oa.out
TypeREL(可重定位文件)EXEC(可执行文件)
Entry point address0x0(无入口)0x401040(有效入口)
Program headers0 个(没有程序头表)11 个(11 个段)
Section headers13 个30 个

.o 文件是 REL 类型,没有程序头表、入口地址为 0,只提供节信息供链接器使用;a.out 是 EXEC 类型,同时具备节与段信息,可被操作系统直接加载。ELF 头中最核心的信息是:文件类型 + 入口点地址 + 程序头表与节头表的位置

两个视图:链接器看节,加载器看段

视图对应表使用场景粒度作用
链接视图节头表(Section)编译/链接时按功能模块划分,链接器分析符号依赖
执行视图程序头表(Segment)加载/运行时告诉 OS 如何加载、设置权限

查看命令三件套:

readelf -h 文件名    # 查看 ELF 头部(基础元信息)
readelf -S 文件名    # 大写 S:查看节头表 Section(链接器视角)
readelf -l 文件名    # 小写 l:查看程序头表 Segment(加载器视角)

注意 readelf -s(小写)是查看符号表,-S(大写)是查看节头表。

Segment 的合并:为什么需要段

合并原则:相同属性合并(可读、可写、可执行、是否需要申请空间)。原因有二:

原因说明
减少内存碎片页大小 4096 字节,.text 为 4097 字节、.init 为 512 字节时,不合并用 3 页,合并后只用 2 页
统一权限控制所有只读/可执行节合并为 RE,所有可读写节合并为 RW,简化内存管理与权限管控

合并规则在链接时确定,记录在程序头表的 Section to Segment mapping 中,可用 readelf -l a.out 查看:

Program Headers:
  Type     Offset  VirtAddr  ...  Flags  Align
  INTERP   ...      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD     ...      R     0x1000     # 可读段
  LOAD     ...      R E   0x1000     # 代码段:.init .plt .text .fini
  LOAD     ...      R     0x1000     # 只读常量段:.rodata
  LOAD     ...      RW    0x1000     # 数据段:.data .bss(MemSiz > FileSiz 对应 .bss)
  DYNAMIC  ...      RW    0x8        # 动态链接信息
  GNU_STACK...      RW    0x10       # 栈不可执行(防缓冲区溢出)
  GNU_RELRO ...      R     0x1       # 重定位完成后只读(安全防护)

实施过程

从源代码到可执行文件:两步走

  1. 编译:把多份 C/C++ 源码翻译成目标 .o 文件(以及动静态库,均为 ELF);
  2. 链接:把多份 .o 的 Section 合并并重定位,生成可执行文件。

静态链接的全过程

第一步,目标文件"互不相识"。对 hello.o 反汇编:

objdump -d hello.o

可以看到 call 指令指向的 printfrun 地址全是 0x00000000——编译器编译 hello.c 时,根本不知道这些函数在哪,甚至没见过它们的代码。

第二步,符号表标记未定义符号:

readelf -s hello.o    # 小写 s:查看符号表

hello.o 中的 run 标记为 UND(undefine,未定义);而 code.o 中的 run 是已定义符号。链接器在链接时会把二者匹配起来。

第三步,链接时重定位。链接完成后再看 readelf -s a.outobjdump -d a.out:

符号链接前(hello.o)链接后(a.out)
run00 00 00 000x401145
printf00 00 00 000x401130(puts@plt)
Section 编号各自的 .text(Nr 1)合并后的 .text(Nr 13)

静态链接的本质 = 合并所有 .o 的 Section + 地址修正。链接器依据重定位表,找到需要重定位的函数与全局变量并修正其地址。

虚拟地址:链接时就已编好址

一个反直觉的事实:ELF 程序在加载进内存之前就已经有地址了readelf -h a.out 显示的入口地址(如 0x1060)是链接时确定的虚拟地址,而非物理地址。现代 CPU 工作于平坦模式,ELF 在链接阶段就对自己的代码和数据统一编址,反汇编最左侧一列即为虚拟地址(逻辑地址 = 起始地址 + 偏移量)。

进程地址空间的数据从哪来?答案是从 ELF 的各 Segment 来。每个 Segment 有自己的起始地址与长度,操作系统据此初始化内核结构中的 [start, end] 范围,并用更细的地址填充页表。所以说:虚拟地址机制不仅 OS 要支持,编译器也要支持

动态链接:运行时才见真章

静态链接会产生巨大的可执行文件,多个程序若都包含同一份库代码(如 printf),内存里会有大量副本。动态链接把符号解析与地址重定位从编译时推迟到程序加载运行时,是性价比极高的取舍。

程序真正的启动流程并不是从 main 开始:

阶段说明
_startglibc/链接器提供的特殊函数,程序的真正入口
动态链接器ld-linux.so,解析程序依赖的所有动态库并加载进内存
__libc_start_mainglibc 提供,完成额外初始化后调用 main
main程序员编写的入口函数

PIC 原理:动态库可能被映射到任意进程的任意地址,必须采用相对寻址——所有地址都是相对当前指令指针的偏移,这就是为什么编译动态库必须加 -fPIC

GOT(全局偏移表):动态链接面临一个核心矛盾——代码段只读不能改,但函数地址又需要在加载时修正。解法是在可读写的 .data 区预留一块区域存放函数跳转地址,即 GOT。要点:

  1. 代码段只读不能直接修改,GOT 位于可读写段,运行时可更新;
  2. 单个 .so 内 GOT 与 .text 相对位置固定,CPU 可用相对寻址找到 GOT;
  3. 调用函数时先查 GOT,按表中地址跳转;
  4. 这套机制就是 PIC = 相对编址 + GOT。

PLT(过程链接表)与延迟绑定:如果程序启动时对所有库函数都做重定位,启动会非常慢。PLT 把重定位推迟到函数第一次被调用时——因为大多数动态库函数在程序运行期间可能一次都用不到。首次调用时通过 PLT 触发动态链接器解析真实地址并回填 GOT,后续调用直接命中。

应用价值

维度静态链接动态链接
链接时机编译时运行时
可执行文件大小大(含全部库代码)小(仅含引用信息)
内存占用每程序独享一份库代码物理内存一份,多进程共享
运行时依赖无,独立运行依赖系统存在对应 .so
更新维护替换库需重新链接替换 .so 即可,无需重编
地址修正编译重定位加载重定位
性能无额外加载开销启动需动态链接器,有额外开销
核心机制重定位表 + Section 合并GOT + PLT + 相对编址(PIC)

无论是 .o 目标文件、.a 静态库、.so 动态库,还是 a.out 可执行文件——它们全部都是 ELF 文件。同一份 ELF 格式,通过链接视图(节头表)服务编译器和链接器,通过执行视图(程序头表)服务操作系统加载器。掌握了 ELF,就掌握了 Linux 下程序从源码到运行的全景链路,对排查链接错误、分析崩溃转储、理解逆向工程都大有裨益。

SEO关键词

Linux,ELF文件,目标文件,可重定位,程序头表,节头表,静态链接,动态链接,重定位,GOT,PLT,位置无关码,readelf

评论(0