强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

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_mainmain 调用链

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 进行增量编译,避免每次全量重编译。


扩展阅读


下一步

04 - 常用编译选项:掌握 GCC 最常用的编译选项,包括优化、警告、标准选择、路径指定等。