GCC 完全指南 / 03 - 编译基础流程
03 - 编译基础流程
深入理解 GCC 的四阶段编译流程——预处理、编译、汇编、链接,掌握每个阶段的输入输出和控制方法。
3.1 编译流程概览
GCC 将源代码转换为可执行文件的过程分为四个阶段,每个阶段都可以独立执行和检查。
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 预处理 │──▶│ 编译 │──▶│ 汇编 │──▶│ 链接 │
│(Preprocess)│ │(Compile) │ │(Assemble)│ │ (Link) │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
.c → .i .i → .s .s → .o .o → a.out
四阶段详解
| 阶段 | 程序 | 输入 | 输出 | 主要工作 |
|---|---|---|---|---|
| 预处理 | cpp | .c / .h | .i | 宏展开、头文件包含、条件编译 |
| 编译 | cc1 / cc1plus | .i | .s | 词法/语法分析、优化、生成汇编 |
| 汇编 | as | .s | .o | 将汇编代码转为机器码(ELF 目标文件) |
| 链接 | ld | .o + 库 | a.out / ELF | 符号解析、重定位、生成最终可执行文件 |
3.2 实例:逐步编译
示例源文件
创建两个文件来演示多文件编译:
// greet.h
#ifndef GREET_H
#define GREET_H
#define GREETING "Hello"
void greet(const char *name);
#endif
// greet.c
#include <stdio.h>
#include "greet.h"
void greet(const char *name) {
printf("%s, %s!\n", GREETING, name);
}
// main.c
#include "greet.h"
int main(void) {
greet("World");
return 0;
}
阶段一:预处理
# 仅执行预处理(-E)
gcc -E main.c -o main.i
# 查看预处理输出(非常长)
wc -l main.i
# main.i: 可能有数百行(所有头文件内容都被展开)
# 只预处理,不包含默认头文件搜索路径
gcc -E -nostdinc main.c -o main.i
预处理后的 main.i 内容(简化):
# 1 "main.c"
# 1 "<built-in>"
... (编译器内置定义) ...
# 1 "/usr/include/stdio.h" 1 3 4
... (stdio.h 的完整内容,可能数百行) ...
# 3 "main.c" 2
void greet(const char *name);
int main(void) {
greet("World");
return 0;
}
预处理阶段的关键操作
| 操作 | 示例 |
|---|---|
| 头文件包含 | #include <stdio.h> 被替换为文件内容 |
| 宏展开 | GREETING 被替换为 "Hello" |
| 条件编译 | #ifdef / #ifndef 根据条件决定保留或删除代码段 |
| 注释删除 | 所有 // 和 /* */ 注释被替换为空格 |
| 行号标记 | # linenum "filename" 指示原始行号和文件名 |
查看预处理器宏定义
# 查看所有预定义宏
gcc -dM -E - < /dev/null
# 查看某个标准的预定义宏
gcc -dM -E -std=c11 - < /dev/null
# 查看特定宏
gcc -dM -E - < /dev/null | grep __VERSION__
# #define __VERSION__ "13.2.0"
# 查看特定源文件的宏(包括头文件定义的)
gcc -dM -E main.c
阶段二:编译
# 预处理 + 编译到汇编(-S)
gcc -S main.i -o main.s
# 或直接从源文件开始
gcc -S main.c -o main.s
# 查看生成的汇编代码
cat main.s
生成的 main.s(x86-64,AT&T 语法,简化):
.file "main.c"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
leaq .LC0(%rip), %rax
movq %rax, %rdi
call greet@PLT
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.section .rodata
.LC0:
.string "World"
.ident "GCC: (Ubuntu 13.2.0-23ubuntu4) 13.2.0"
.section .note.GNU-stack,"",@progbits
阶段三:汇编
# 汇编为目标文件(-c)
gcc -c main.s -o main.o
gcc -c greet.c -o greet.o
# 或直接从源文件开始
gcc -c main.c -o main.o
gcc -c greet.c -o greet.o
# 查看目标文件信息
file main.o
# main.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
# 查看目标文件的符号表
nm main.o
# U greet
# 0000000000000000 T main
nm greet.o
# U printf
# 0000000000000000 T greet
目标文件的内容
ELF 目标文件结构:
┌──────────────────────┐
│ ELF Header │ ← 文件类型、架构、入口点
├──────────────────────┤
│ .text │ ← 机器代码(函数体)
├──────────────────────┤
│ .data │ ← 已初始化全局变量
├──────────────────────┤
│ .bss │ ← 未初始化全局变量(零初始化)
├──────────────────────┤
│ .rodata │ ← 只读数据(字符串常量等)
├──────────────────────┤
│ .symtab │ ← 符号表(函数名、变量名)
├──────────────────────┤
│ .rela.text │ ← 重定位表(待链接时修正的地址)
├──────────────────────┤
│ .debug_* │ ← 调试信息(如有 -g)
├──────────────────────┤
│ .strtab │ ← 字符串表
└──────────────────────┘
阶段四:链接
# 链接为目标可执行文件
gcc main.o greet.o -o hello
# 运行
./hello
# Hello, World!
# 查看可执行文件
file hello
# hello: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2
链接器的主要工作
| 工作 | 说明 |
|---|---|
| 符号解析(Symbol Resolution) | 将符号引用(如 greet 函数调用)关联到定义 |
| 重定位(Relocation) | 将每个 .o 文件的段合并,修正地址 |
| 库链接 | 链接 libc(C 运行时)等系统库 |
| 入口点设置 | 设置 _start → __libc_start_main → main 调用链 |
3.3 一步编译 vs 分步编译
一步完成
# 最常见的用法——一步到位
gcc main.c greet.c -o hello
# 等价于:
# 1. 预处理、编译、汇编 main.c → main.o
# 2. 预处理、编译、汇编 greet.c → greet.o
# 3. 链接 main.o greet.o → hello
分步编译的好处
# 分步编译适用于:
# 1. 大型项目:只重新编译修改过的文件
# 2. 调试:检查每个阶段的中间输出
# 3. 交叉编译:不同阶段可能需要不同工具
# 典型的 Makefile 工作流
# make 只重新编译修改过的 .c 文件
3.4 链接阶段详解
静态链接
# 使用 -static 进行静态链接
gcc -static main.o greet.o -o hello_static
# 对比文件大小
ls -lh hello hello_static
# hello 约 16K(动态链接)
# hello_static 约 800K(静态链接,包含 libc)
# 查看动态链接依赖
ldd hello
# linux-vdso.so.1 (0x00007ffc...)
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)
# /lib64/ld-linux-x86-64.so.2 (0x00007f...)
# 静态链接的文件没有动态依赖
ldd hello_static
# not a dynamic executable
链接器搜索路径
# 查看默认链接器搜索路径
ld --verbose | grep SEARCH_DIR | tr -s ';' '\n'
# 查看 gcc 的默认链接搜索路径
gcc -print-search-dirs
# 指定库搜索路径
gcc main.c greet.o -L/path/to/libs -o hello
# 指定运行时库路径(RPATH)
gcc main.c greet.o -Wl,-rpath,/path/to/libs -o hello
3.5 各阶段的 GCC 选项
| 选项 | 停在哪个阶段 | 输出文件后缀 | 说明 |
|---|---|---|---|
-E | 预处理 | .i / .ii | 仅预处理,输出到标准输出或文件 |
-S | 编译 | .s | 预处理 + 编译,输出汇编代码 |
-c | 汇编 | .o | 预处理 + 编译 + 汇编,输出目标文件 |
| (无选项) | 链接 | a.out 或指定名称 | 四阶段全部执行 |
常用技巧
# 预处理输出到标准输出
gcc -E main.c | head -20
# 编译输出到标准输出(汇编代码)
gcc -S -o - main.c | head -40
# 只编译修改过的文件,最后统一链接
gcc -c main.c
gcc -c greet.c
gcc main.o greet.o -o hello
# 指定输出文件名
gcc -o hello main.c greet.c
# 生成位置无关代码(PIC),用于共享库
gcc -fPIC -c greet.c -o greet_pic.o
# 生成位置无关可执行文件(PIE,默认开启)
gcc -fPIE -c main.c -o main_pie.o
3.6 编译过程中的中间文件管理
清理中间文件
# 手动清理
rm -f *.o *.i *.s
# Makefile 中的 clean 目标
clean:
rm -f *.o *.i *.s hello
# 使用 GCC 临时文件
# GCC 默认使用 /tmp 下的临时文件
# -save-temps 保留所有中间文件
gcc -save-temps -o hello main.c greet.c
# 生成: main.i main.s main.o greet.i greet.s greet.o hello
指定临时目录
# 指定临时文件目录
gcc -B/path/to/tmp -o hello main.c
# 使用环境变量
export TMPDIR=/path/to/tmp
gcc -o hello main.c
3.7 交叉编译时的四阶段
交叉编译时,每一步都使用目标架构的工具:
# 使用 ARM64 工具链进行四阶段编译
aarch64-linux-gnu-gcc -E main.c -o main.i # 预处理
aarch64-linux-gnu-gcc -S main.i -o main.s # 编译
aarch64-linux-gnu-as main.s -o main.o # 汇编
aarch64-linux-gnu-ld main.o greet.o -o hello_arm64 -lc # 链接
# 更常见的是一步完成
aarch64-linux-gnu-gcc main.c greet.c -o hello_arm64
# 检查产物
file hello_arm64
# hello_arm64: ELF 64-bit LSB executable, ARM aarch64 ...
3.8 编译过程可视化
使用 -### 查看完整编译命令
# 显示 GCC 内部执行的所有命令(不实际执行)
gcc -### main.c greet.c -o hello 2>&1
# 输出示例:
# "/usr/lib/gcc/x86_64-linux-gnu/13/cc1" "-quiet" "main.c" ...
# "/usr/bin/as" "--64" "-o" "/tmp/ccXXXXXX.o" "/tmp/ccXXXXXX.s" ...
# "/usr/lib/gcc/x86_64-linux-gnu/13/collect2" ...
# "/usr/bin/ld" ...
使用 -v 查看详细过程
# 显示详细编译过程
gcc -v main.c greet.c -o hello 2>&1
# 包含:
# 1. 配置参数
# 2. 搜索路径
# 3. 各阶段的具体命令和参数
# 4. 链接器的搜索路径
3.9 多目标编译
同一源码编译多个目标
# 同时编译优化版和调试版
gcc -O2 -DNDEBUG -o hello_release main.c greet.c
gcc -g -O0 -o hello_debug main.c greet.c
# 对比大小
ls -lh hello_release hello_debug
使用 Makefile 管理多文件编译
# Makefile
CC = gcc
CFLAGS = -Wall -Wextra -std=c11
LDFLAGS =
SRCS = main.c greet.c
OBJS = $(SRCS:.c=.o)
TARGET = hello
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(LDFLAGS) -o $@ $^
%.o: %.c greet.h
$(CC) $(CFLAGS) -c -o $@ $<
clean:
rm -f $(OBJS) $(TARGET)
.PHONY: all clean
# 使用 make 构建
make
make clean
要点回顾
| 要点 | 核心内容 |
|---|---|
| 四阶段 | 预处理(.i) → 编译(.s) → 汇编(.o) → 链接(可执行) |
-E | 仅预处理:宏展开、头文件包含、条件编译 |
-S | 编译到汇编:可检查生成的汇编代码 |
-c | 编译到目标文件:大型项目常用,增量编译 |
| 链接 | 符号解析 + 重定位,静态链接 vs 动态链接 |
-### / -v | 查看 GCC 内部实际执行的编译命令 |
注意事项
头文件不是编译单元: C/C++ 编译以
.c/.cpp文件为编译单元,头文件只是被#include预处理展开到源文件中。不要在头文件中定义变量或实现函数(会导致多重定义错误)。
顺序敏感: 链接时目标文件的顺序可能有影响——被依赖的库放在后面(
gcc main.o -lm而非gcc -lm main.o)。
保留中间文件调试: 遇到编译问题时,使用
-save-temps保留中间文件,便于排查是哪个阶段出了问题。
增量编译: 大型项目务必使用 Makefile 或 CMake 进行增量编译,避免每次全量重编译。
扩展阅读
- GCC Manual: Overall Options —
-E,-S,-c等选项 - Linkers and Loaders(John Levine)— 链接器原理深入
- ELF 格式规范 — ELF 文件格式详解
- System V ABI — x86-64 调用约定
下一步
→ 04 - 常用编译选项:掌握 GCC 最常用的编译选项,包括优化、警告、标准选择、路径指定等。