当我们编写完C源代码并执行编译命令(如 gcc hello.c -o hello)后,看似简单的一步,实际上经历了四个阶段:
预处理(Preprocessing)、编译(Compilation)、汇编(Assembly) 和 链接(Linking)。
理解这些阶段有助于我们深入理解程序的构建过程、排查编译链接错误,以及优化项目构建流程。本章将详细讲解每个阶段的工作内容、输入输出及常用工具。
.c源文件 → [预处理] → .i文件 → [编译] → .s汇编文件 → [汇编] → .o目标文件 → [链接] → 可执行文件
(gcc -E) (gcc -S) (gcc -c) (gcc或ld)
下面以GCC编译器为例,逐一解析每个阶段。
预处理是编译的第一个阶段,由预处理器(cpp,C Preprocessor)完成。它处理所有以 # 开头的指令,主要包括:
#include 指定的文件内容插入到源文件中。#define 定义的宏,并处理 #if、#ifdef 等条件编译指令。预处理后的文件通常以 .i(C语言)或 .ii(C++)为扩展名,仍是文本文件,但已经没有宏和头文件包含。
#include <stdio.h>
#define MSG "Hello, World!"
int main() {
printf("%s\n", MSG);
return 0;
}
使用 -E 选项让GCC仅执行预处理,输出到 hello.i。打开 hello.i,可以看到 stdio.h 的内容被插入,宏 MSG 被替换为字符串,注释被删除,文件变得很长。
编译阶段将预处理后的 .i 文件(或直接对 .c 文件)转换为汇编代码(.s 文件)。
编译器会进行词法分析、语法分析、语义分析、中间代码生成、优化等操作,最终生成汇编语言程序。汇编代码仍是人类可读的文本,但依赖于具体的CPU架构(如x86、ARM)。
或者直接从源文件生成汇编:
生成的 hello.s 内容示例(x86-64架构,可能略有差异):
.file "hello.c"
.text
.section .rodata
.LC0:
.string "Hello, World!"
.text
.globl main
.type main, @function
main:
pushq %rbp
movq %rsp, %rbp
leaq .LC0(%rip), %rdi
call puts@PLT
movl $0, %eax
popq %rbp
ret
-S 选项可以查看编译器的优化效果。
汇编器(as,assembler)将汇编代码转换为目标文件(object file),通常扩展名为 .o(Linux/macOS)或 .obj(Windows)。
目标文件是二进制格式,包含机器指令、数据、符号表、重定位信息等,但尚未进行地址绑定和外部符号解析。
或直接从源文件生成目标文件:
使用 file 命令查看目标文件类型:
可以使用 objdump 或 nm 工具查看目标文件的内容:
objdump、nm、readelf 是分析目标文件和可执行文件的有力工具。
链接器(ld,linker)将一个或多个目标文件以及静态库/动态库组合成最终的可执行文件或共享库。 主要工作包括:符号解析(将每个符号引用与一个符号定义关联)和重定位(将各个目标文件的代码段和数据段合并,并为符号分配运行时地址)。
对于简单的单文件程序,链接阶段主要将 hello.o 与C标准库(如 libc.a 或动态库)链接,生成可执行文件。
或直接生成可执行文件:
链接后生成的可执行文件通常为 ELF(Linux)、Mach-O(macOS)或 PE(Windows)格式。
假设有两个源文件:main.c 和 math.c。
// math.c
int add(int a, int b) {
return a + b;
}
// main.c
#include <stdio.h>
int add(int, int); // 函数声明
int main() {
int result = add(3, 5);
printf("Result: %d\n", result);
return 0;
}
在链接阶段,链接器会解析 main.o 中对 add 函数的引用,并在 math.o 中找到该函数的定义,将两个目标文件合并,并处理标准库的链接。
.so、.dylib、.dll)。优点是节省磁盘和内存,库可以独立更新;缺点是存在依赖性问题(“DLL Hell”)。GCC默认使用动态链接(除非指定 -static 选项)。可以通过 ldd 命令查看可执行文件依赖的动态库:
| 选项 | 作用 |
|---|---|
-E | 仅执行预处理,不编译、汇编、链接 |
-S | 生成汇编代码,不汇编、链接 |
-c | 编译和汇编,但不链接,生成目标文件(.o) |
-o file | 指定输出文件名 |
-Wall | 启用大部分警告信息 |
-g | 生成调试信息(供gdb使用) |
-O0, -O1, -O2, -O3 | 优化级别,O0不优化,O3最高优化 |
-I dir | 添加头文件搜索路径 |
-L dir | 添加库文件搜索路径 |
-l name | 链接名为libname.a或libname.so的库(例如 -lm 链接数学库) |
-static | 静态链接(不适用动态库) |
-shared | 生成共享库(.so) |
下面是一个简单的Makefile,分别演示了预处理、编译、汇编、链接各阶段的独立命令:
# Makefile for demonstrating compilation stages
CC = gcc
CFLAGS = -Wall -g
TARGET = program
# 分别生成.i, .s, .o
all: $(TARGET)
$(TARGET): main.o math.o
$(CC) main.o math.o -o $(TARGET)
main.o: main.c
$(CC) $(CFLAGS) -c main.c -o main.o
math.o: math.c
$(CC) $(CFLAGS) -c math.c -o math.o
# 展示各阶段中间文件
preprocess:
$(CC) -E main.c -o main.i
$(CC) -E math.c -o math.i
assembly: preprocess
$(CC) -S main.i -o main.s
$(CC) -S math.i -o math.s
clean:
rm -f *.i *.s *.o $(TARGET)
.PHONY: all preprocess assembly clean
运行 make preprocess 生成 .i 文件,make assembly 生成 .s 文件,make 生成最终的可执行文件。
.c 或 .o 文件,以及库链接选项是否正确。extern 声明,在唯一一个源文件中定义;或将函数/变量声明为 static(每个文件有独立副本)。-I 添加头文件路径。-L 指定库路径,-l 指定库名(如 -lm 表示 libm.a 或 libm.so)。-S 生成的汇编代码依赖于编译时指定的目标架构(可以通过 -m32、-m64 等选项指定)。-O2、-O3)会重排代码、内联函数等,使得调试时源码与指令对应关系混乱。调试时应使用 -O0 -g。
理解编译链接过程可以帮助我们更好地配置构建系统、解决链接错误、优化程序性能,也是深入理解计算机系统的重要基础。
建议读者亲自动手,使用 -E、-S、-c 等选项生成中间文件,观察每个阶段的变化。