Contents

详解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程序的核心脉络:

  1. 编译:首先,cc1 程序(C编译器)被调用,它负责将 1.c 这个C源文件转换成汇编语言文件(.s 文件)。这个阶段实际上已经包含了我们后文将要详述的 预处理编译 两个步骤。
  2. 汇编:接着,as 程序(汇编器)登场,它将前一步生成的汇编文件转换成机器可以理解的二进制目标文件(.o 文件)。
  3. 链接:最后,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.hmymath.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.c188B9
预处理后test.i21459B829

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.o
test.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                   	ret

4. 链接 (Linking)

链接是生成可执行文件的最后一步。链接器 (ldcollect2) 的主要工作是:

  • 合并目标文件:将所有 .o 文件中的代码段、数据段合并在一起。
  • 符号解析与重定位:在 test.o 中,add 函数的调用地址是不确定的。链接器会在 mymath.o 中找到 add 函数的实现,并将其真实地址填入 test.o 中调用 add 的地方。同样,printf 函数的地址会从C标准库中寻找并填入。
  • 链接系统库:将代码中使用到的库函数(如 printf)的实现代码从系统的静态库或动态库中链接进来。

操作命令:

gcc test.o mymath.o -o bin

gcc 会自动调用链接器,并将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语言程序就从源代码成功地转变成了可以在操作系统上执行的程序。

参考文献