详解C语言的编译过程
本文将以一个简单的 “Hello, World!” 程序为起点,通过
gcc -v命令揭示编译过程的神秘面纱,并进一步以一个多文件的项目为例,逐步拆解C语言编译的四大核心步骤:预处理 (Preprocessing)、编译 (Compilation)、汇编 (Assemble) 和 链接 (Linking)。
一、编译过程概览:从一行命令看起
我们从一个最简单的C语言程序 1.c 开始,来窥探编译的全过程。
// 1.c
#include <stdio.h>
int main(){
printf("Hello, World!\n");
return 0;
}在Linux环境下,我们通常使用 gcc 来编译它。如果我们为 gcc 增加一个 -v 参数,就可以观察到其详细的内部工作流程。
gcc -o bin 1.c -v执行此命令后,终端会输出一长串日志信息。
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/libexec/gcc/x86_64-linux-gnu/13/lto-wrapper
OFFLOAD_TARGET_NAMES=nvptx-none:amdgcn-amdhsa
OFFLOAD_TARGET_DEFAULT=1
Target: x86_64-linux-gnu
Configured with: ../src/configure -v --with-pkgversion='Ubuntu 13.3.0-6ubuntu2~24.04' --with-bugurl=file:///usr/share/doc/gcc-13/README.Bugs --enable-languages=c,ada,c++,go,d,fortran,objc,obj-c++,m2 --prefix=/usr --with-gcc-major-version-only --program-suffix=-13 --program-prefix=x86_64-linux-gnu- --enable-shared --enable-linker-build-id --libexecdir=/usr/libexec --without-included-gettext --enable-threads=posix --libdir=/usr/lib --enable-nls --enable-bootstrap --enable-clocale=gnu --enable-libstdcxx-debug --enable-libstdcxx-time=yes --with-default-libstdcxx-abi=new --enable-libstdcxx-backtrace --enable-gnu-unique-object --disable-vtable-verify --enable-plugin --enable-default-pie --with-system-zlib --enable-libphobos-checking=release --with-target-system-zlib=auto --enable-objc-gc=auto --enable-multiarch --disable-werror --enable-cet --with-arch-32=i686 --with-abi=m64 --with-multilib-list=m32,m64,mx32 --enable-multilib --with-tune=generic --enable-offload-targets=nvptx-none=/build/gcc-13-fG75Ri/gcc-13-13.3.0/debian/tmp-nvptx/usr,amdgcn-amdhsa=/build/gcc-13-fG75Ri/gcc-13-13.3.0/debian/tmp-gcn/usr --enable-offload-defaulted --without-cuda-driver --enable-checking=release --build=x86_64-linux-gnu --host=x86_64-linux-gnu --target=x86_64-linux-gnu --with-build-config=bootstrap-lto-lean --enable-link-serialization=2
Thread model: posix
Supported LTO compression algorithms: zlib zstd
gcc version 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04)
COLLECT_GCC_OPTIONS='-o' 'bin' '-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'bin-'
# 编译过程
/usr/libexec/gcc/x86_64-linux-gnu/13/cc1 -quiet -v -imultiarch x86_64-linux-gnu 1.c -quiet -dumpdir bin- -dumpbase 1.c -dumpbase-ext .c -mtune=generic -march=x86-64 -version -fasynchronous-unwind-tables -fstack-protector-strong -Wformat -Wformat-security -fstack-clash-protection -fcf-protection -o /tmp/ccdrqCKU.s
GNU C17 (Ubuntu 13.3.0-6ubuntu2~24.04) version 13.3.0 (x86_64-linux-gnu)
compiled by GNU C version 13.3.0, GMP version 6.3.0, MPFR version 4.2.1, MPC version 1.3.1, isl version isl-0.26-GMP
GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072
ignoring nonexistent directory "/usr/local/include/x86_64-linux-gnu"
ignoring nonexistent directory "/usr/lib/gcc/x86_64-linux-gnu/13/include-fixed/x86_64-linux-gnu"
ignoring nonexistent directory "/usr/lib/gcc/x86_64-linux-gnu/13/include-fixed"
ignoring nonexistent directory "/usr/lib/gcc/x86_64-linux-gnu/13/../../../../x86_64-linux-gnu/include"
#include "..." search starts here:
#include <...> search starts here:
/usr/lib/gcc/x86_64-linux-gnu/13/include
/usr/local/include
/usr/include/x86_64-linux-gnu
/usr/include
End of search list.
Compiler executable checksum: 38987c28e967c64056a6454abdef726e
COLLECT_GCC_OPTIONS='-o' 'bin' '-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'bin-'
as -v --64 -o /tmp/ccEaFU9r.o /tmp/ccdrqCKU.s
GNU汇编版本 2.42 (x86_64-linux-gnu) 使用BFD版本 (GNU Binutils for Ubuntu) 2.42
COMPILER_PATH=/usr/libexec/gcc/x86_64-linux-gnu/13/:/usr/libexec/gcc/x86_64-linux-gnu/13/:/usr/libexec/gcc/x86_64-linux-gnu/:/usr/lib/gcc/x86_64-linux-gnu/13/:/usr/lib/gcc/x86_64-linux-gnu/
LIBRARY_PATH=/usr/lib/gcc/x86_64-linux-gnu/13/:/usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu/:/usr/lib/gcc/x86_64-linux-gnu/13/../../../../lib/:/lib/x86_64-linux-gnu/:/lib/../lib/:/usr/lib/x86_64-linux-gnu/:/usr/lib/../lib/:/usr/lib/gcc/x86_64-linux-gnu/13/../../../:/lib/:/usr/lib/
COLLECT_GCC_OPTIONS='-o' 'bin' '-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'bin.'
/usr/libexec/gcc/x86_64-linux-gnu/13/collect2 -plugin /usr/libexec/gcc/x86_64-linux-gnu/13/liblto_plugin.so -plugin-opt=/usr/libexec/gcc/x86_64-linux-gnu/13/lto-wrapper -plugin-opt=-fresolution=/tmp/ccXhqE78.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -z relro -o bin /usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/13/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/13 -L/usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/13/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/13/../../.. /tmp/ccEaFU9r.o -lgcc --push-state --as-needed -lgcc_s --pop-state -lc -lgcc --push-state --as-needed -lgcc_s --pop-state /usr/lib/gcc/x86_64-linux-gnu/13/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu/crtn.o
COLLECT_GCC_OPTIONS='-o' 'bin' '-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'bin.'尽管其中包含了大量的环境配置和参数细节,但通过仔细甄别,我们可以从中提取出整个编译流程最核心的三个步骤:
# 1. 编译 (Compile): 将C源代码转换为汇编代码
/usr/libexec/gcc/x86_64-linux-gnu/13/cc1 ... 1.c ... -o /tmp/ccdrqCKU.s
# 2. 汇编 (Assemble): 将汇编代码转换为机器码目标文件
as ... -o /tmp/ccEaFU9r.o /tmp/ccdrqCKU.s
# 3. 链接 (Link): 将目标文件与所需的库文件链接,生成最终的可执行文件
/usr/libexec/gcc/x86_64-linux-gnu/13/collect2 ... -o bin ... /tmp/ccEaFU9r.o ...这三个步骤清晰地展示了GCC编译一个C程序的核心脉络:
- 编译:首先,
cc1程序(C编译器)被调用,它负责将1.c这个C源文件转换成汇编语言文件(.s文件)。这个阶段实际上已经包含了我们后文将要详述的 预处理 和 编译 两个步骤。 - 汇编:接着,
as程序(汇编器)登场,它将前一步生成的汇编文件转换成机器可以理解的二进制目标文件(.o文件)。 - 链接:最后,
collect2程序(链接器)进行收尾工作,它将生成的目标文件与C语言标准库等必要的系统资源整合在一起,最终产生名为bin的可执行文件。
gcc 命令如同一位总指挥,它根据输入文件的类型和指定的选项,依次调用这些底层工具,自动化地完成了整个复杂的转换过程。接下来,我们将以一个稍复杂的例子,来手动拆解并深入探讨每一步的细节。
二、编译四大步骤详解
我们使用一个包含自定义模块的项目来详细说明编译的过程。
项目结构:
.
├── inc
│ ├── mymath.c
│ └── mymath.h
└── test.c代码:
程序代码:
// test.c
#include <stdio.h>
#include "mymath.h" // 自定义头文件
int main(){
int a = 2;
int b = 3;
int sum = add(a, b);
printf("a=%d, b=%d, a+b=%d\n", a, b, sum);
return 0;
}头文件定义:
// inc/mymath.h
#ifndef MYMATH_H
#define MYMATH_H
int add(int a, int b);
int sub(int a, int b); // 注意:此处原文为 sum,为更符合实现,已修正为 sub
#endif头文件实现:
// inc/mymath.c
int add(int a, int b){
return a + b;
}
int sub(int a, int b){
return a - b;
}1. 预处理 (Preprocessing)
预处理阶段是编译的第一步。预处理器 (cpp) 会处理源代码中以 # 开头的指令,主要完成以下工作:
- 展开头文件:将
#include指定的文件内容(如stdio.h和mymath.h)插入到源代码中。 - 宏定义替换:替换代码中所有的
#define宏。 - 条件编译:处理
#if,#ifdef,#endif等指令,根据条件保留或移除代码区块。 - 删除注释:移除代码中所有的注释。
最终,预处理会生成一个不含预处理指令的、纯净的C语言源文件,通常以 .i 为扩展名。
操作命令:
你可以通过 gcc -E 或直接使用 cpp 命令来执行预处理:
# 使用 gcc
gcc -E -I./inc test.c -o test.i
# 或者直接使用 cpp
cpp -I./inc test.c -o test.i-E:让gcc在预处理结束后即停止。-I./inc:指定自定义头文件所在的目录。-o test.i:指定输出文件名。
预处理后的 test.i 文件会变得非常庞大,因为它包含了 <stdio.h> 的全部内容。文件的末尾部分会是我们的代码,头文件 mymath.h 的内容已经被插入进来:
819 # 4 "./inc/mymath.h"
820 int add(int a, int b);
821 int sum(int a, int b);
822 # 4 "test.c" 2
823 int main(){
824 int a = 2;
825 int b = 3;
826 int sum = add(a, b);
827 printf("a=%d, b=%d, a+b=%d\n", a, b, sum);
828 }| 阶段 | 文件名 | 文件大小 | 代码行数 |
|---|---|---|---|
| 预处理前 | test.c | 188B | 9 |
| 预处理后 | test.i | 21459B | 829 |
2. 编译 (Compilation)
此处的“编译”特指从预处理后的 .i 文件到汇编代码 .s 文件的转换过程。编译器 (cc1) 会对代码进行一系列分析:
- 词法分析:将代码分解成一系列的“token”(如关键字、标识符、运算符)。
- 语法分析:根据C语言的语法规则,将 token 组合成一棵抽象语法树 (AST)。
- 语义分析:检查语法树的语义是否正确(如类型匹配、变量是否声明)。
- 优化:对代码进行各种优化,以提高执行效率。
完成后,会生成对应于目标硬件平台的汇编代码。
操作命令:
gcc -S -I./inc test.c -o test.s-S:让gcc在产生汇编代码后停止。
生成的 test.s 文件是人类可读的文本文件,内容是目标架构的汇编指令:
.file "test.c"
.text
.section .rodata
.LC0:
.string "a=%d, b=%d, a+b=%d\n"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl $2, -12(%rbp)
movl $3, -8(%rbp)
movl -8(%rbp), %edx
movl -12(%rbp), %eax
movl %edx, %esi
movl %eax, %edi
call add@PLT
movl %eax, -4(%rbp)
movl -4(%rbp), %ecx
movl -8(%rbp), %edx
movl -12(%rbp), %eax
movl %eax, %esi
leaq .LC0(%rip), %rax
movq %rax, %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:3. 汇编 (Assemble)
汇编过程是将上一步生成的汇编代码(.s 文件)转换为机器可以直接执行的二进制指令(机器码)。汇编器 (as) 负责这个转换,并将结果保存在一个称为“目标文件 (Object File)”的文件中,通常以 .o 为扩展名。
目标文件包含了代码段、数据段和一些符号表等信息,但此时函数调用的地址等还是未定的。
操作命令:
# 为 test.c 生成目标文件
gcc -c -I./inc test.c -o test.o
# 为 mymath.c 生成目标文件 (注意,多文件项目需要为每个 .c 文件生成一个 .o 文件)
gcc -c ./inc/mymath.c -o mymath.o-c:让gcc执行到汇编阶段即停止,生成.o文件。
.o 文件是二进制格式,无法直接用文本编辑器查看。但我们可以使用 objdump 工具来反汇编,查看其中的机器码:
objdump -d test.otest.o: 文件格式 elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 10 sub $0x10,%rsp
c: c7 45 f4 02 00 00 00 movl $0x2,-0xc(%rbp)
13: c7 45 f8 03 00 00 00 movl $0x3,-0x8(%rbp)
1a: 8b 55 f8 mov -0x8(%rbp),%edx
1d: 8b 45 f4 mov -0xc(%rbp),%eax
20: 89 d6 mov %edx,%esi
22: 89 c7 mov %eax,%edi
24: e8 00 00 00 00 call 29 <main+0x29>
29: 89 45 fc mov %eax,-0x4(%rbp)
2c: 8b 4d fc mov -0x4(%rbp),%ecx
2f: 8b 55 f8 mov -0x8(%rbp),%edx
32: 8b 45 f4 mov -0xc(%rbp),%eax
35: 89 c6 mov %eax,%esi
37: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # 3e <main+0x3e>
3e: 48 89 c7 mov %rax,%rdi
41: b8 00 00 00 00 mov $0x0,%eax
46: e8 00 00 00 00 call 4b <main+0x4b>
4b: b8 00 00 00 00 mov $0x0,%eax
50: c9 leave
51: c3 ret4. 链接 (Linking)
链接是生成可执行文件的最后一步。链接器 (ld 或 collect2) 的主要工作是:
- 合并目标文件:将所有
.o文件中的代码段、数据段合并在一起。 - 符号解析与重定位:在
test.o中,add函数的调用地址是不确定的。链接器会在mymath.o中找到add函数的实现,并将其真实地址填入test.o中调用add的地方。同样,printf函数的地址会从C标准库中寻找并填入。 - 链接系统库:将代码中使用到的库函数(如
printf)的实现代码从系统的静态库或动态库中链接进来。
操作命令:
gcc test.o mymath.o -o bingcc 会自动调用链接器,并将C语言运行时所需的启动文件 (crt1.o 等) 和标准库 (-lc) 链接进来,最终生成名为 bin 的可执行文件。
如果直接使用链接器 ld,命令会复杂得多,因为需要手动指定所有依赖项:
# 一个简化的 ld 命令示例
ld -o bin test.o mymath.o -lc --dynamic-linker /lib64/ld-linux-x86-64.so.2 ...
# 完整命令
ld /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o \
test.o mymath.o -lc /usr/lib/x86_64-linux-gnu/crtn.o \
--dynamic-linker /lib64/ld-linux-x86-64.so.2 -o bin这也反过来证明了使用 gcc 这样的驱动程序 (driver) 是多么便捷。
至此,一个完整的C语言程序就从源代码成功地转变成了可以在操作系统上执行的程序。