技术知识库

GCC 从源文件到 ELF 的四个阶段

GCC 从源文件到 ELF 的四个阶段

摘要

GCC 驱动程序把 C 源码依次交给预处理器、编译器、汇编器和链接器,最终生成可由目标系统装载的 ELF。gcc test.c -o test 会隐藏这些中间步骤;分别使用 -E、-S 和 -c 可以保留 .i、.s 和 .o,从而观察宏展开、目标指令生成、符号与重定位以及最终链接过程。

总体流程

test.c
  │ 预处理:头文件包含、宏展开、条件编译
  │ gcc -E
  ▼
test.i
  │ 编译:语义检查、优化、生成目标架构汇编
  │ gcc -S
  ▼
test.s
  │ 汇编:机器指令编码、符号和重定位信息
  │ gcc -c
  ▼
test.o
  │ 链接:解析符号、合并段、加入启动代码和库
  │ gcc test.o -o test
  ▼
test(ELF 可执行文件)

1. 预处理不是编译成机器码

aarch64-linux-gnu-gcc -E test.c -o test.i

预处理器主要完成:

  • 展开 #include 引入的头文件。
  • 替换 #define 宏。
  • 根据 #if 等条件选择代码。
  • 保留供诊断使用的源文件与行号标记。

因此一个很短的 test.c 可能产生数万字节的 test.i。本轮 test.i 为 65,438 字节;MAX_SIZE 在后续汇编中已经表现为常量 409600,运行时不再存在名为 MAX_SIZE 的变量。

2. 编译阶段决定目标 ISA

aarch64-linux-gnu-gcc -S test.i -o test.s

这一阶段把经过预处理的 C 翻译成 AArch64 汇编。生成文件中的典型证据包括:

.arch armv8-a
stp x29, x30, [sp, -32]!
bl malloc
bl memset
bl printf
bl free
ret

x0~x30 和 stp、bl、ret 都属于 AArch64,而非宿主机 x86-64。交叉编译器运行在 x86-64 主机上,但生成的是目标架构指令。

409600 被拆成两条指令构造:

mov  x0, 16384
movk x0, 0x6, lsl 16

其值为 0x64000,也就是 100 × 4096。

3. 目标文件不是可直接运行的完整程序

aarch64-linux-gnu-gcc -c test.s -o test.o

汇编器把助记符编码为 AArch64 机器指令,并建立 ELF 段、符号表和重定位项。此时 malloc、printf 等外部符号还没有最终实现地址,必须由链接器解析。

可以用这些工具观察:

file test.o
aarch64-linux-gnu-readelf -h test.o
aarch64-linux-gnu-readelf -S test.o
aarch64-linux-gnu-readelf -r test.o
aarch64-linux-gnu-nm test.o

4. 链接产生最终 ELF

aarch64-linux-gnu-gcc test.o -o test --static

链接器完成:

  • 合并目标文件与库中的段。
  • 解析 malloc、memset、printf、free 等符号。
  • 加入 C 运行时启动代码,建立真正的进程入口。
  • 应用重定位并生成 ELF program headers。

使用 GCC 驱动链接而不是直接调用 ld,是因为 GCC 会自动选择正确的启动文件、libc、libgcc 和链接参数。

静态链接为什么便于跨机器实验

静态 ELF 把运行所需的库代码合入程序,不依赖目标根文件系统中恰好存在匹配的动态加载器和 libc:

静态链接:ELF 内含所需库代码,文件较大、部署简单
动态链接:ELF 记录解释器与共享库依赖,文件较小、运行环境必须匹配

这不是说静态程序完全不依赖内核:它仍通过目标架构的系统调用 ABI 请求 Linux 提供内存、文件和进程等服务。

原生编译与交叉编译

同一个 test.c 在当前实验中产生两种 ELF:

宿主 gcc                 → x86-64、PIE、动态链接
aarch64-linux-gnu-gcc   → ARM aarch64、静态链接

x86-64 宿主不能直接执行 AArch64 指令;AArch64 ELF 被复制到 ARM64 QEMU Guest 后才能由 ARM64 Linux 正常执行。编译器所在架构称为 host,生成程序运行的架构称为 target。

为什么每次打印的堆地址不同

malloc() 返回的是当前进程的虚拟地址。Linux 的 ASLR 和 libc 的内存分配策略会改变堆或匿名映射的位置,因此多次运行可能得到不同地址;只要返回非空、能够写入和释放,就不表示错误。

关联