技术知识库
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 的内存分配策略会改变堆或匿名映射的位置,因此多次运行可能得到不同地址;只要返回非空、能够写入和释放,就不表示错误。