2023-详解 C C++ 代码的预处理、编译、汇编、链接全过程
详解 C/C++ 代码的预处理、编译、汇编、链接全过程
1. C/C++运行的四个步骤
编写完成一个 C/C++程序后,想要运行起来,必须要经过四个步骤:预处理、编译、汇编和链接。每个步骤都会生成对应的文件,如下图所示(注意后缀名): 第 3 节将通过一个简易 C++工程演示图中的全过程,并解释细节。
2.名词解释
为了后面过程的介绍更方便,这里对 C++编译过程中涉及的一些常用名词进行解释。
2.1 GCC、GNU、gcc 与 g++
- GNU:一个操作系统,具体内容不重要,感兴趣可以参考:GCC、GNU 到底啥意思?
- GCC:GNU Compiler Collection(GNU 编译器集合)的缩写,可以理解为一组 GNU 操作系统中的编译器集合,可以用于编译 C、C++、Java、Go、Fortan、Pascal、Objective-C 等语言。
- gcc:GCC(编译器集合)中的 GNU C Compiler(C 编译器)
- g++:GCC(编译器集合)中的 GNU C++ Compiler(C++ 编译器)
简单来说,gcc 调用了 GCC 中的 C Compiler,而 g++调用了 GCC 中的 C++ Compiler。
- 对于
*.c
和*.cpp
文件,gcc 分别当作 c 和 cpp 文件编译,而 g++则统一当作 cpp 文件编译。
2.2 代码编译命令
gcc/g++常用命令:
指令选项 | 功能 |
---|---|
-E(注意大写) | 预处理(Preprocess)指定的源文件,但不进行编译(Compile),这一步生成 *.i 文本文件 |
-S(注意大写) | 编译指定的源文件,但不进行汇编(Assemble),这一步生成 *.s 汇编文件 |
-c | 编译、汇编指定的源文件,但不进行链接(Link),这一步生成 *.o 目标文件 |
-o | 指定生成文件的文件名 |
-I lib | lib 表示库文件或头文件目录,该指令选项用于手动链接程序可以调用的库文件、头文件 |
-std= | 手动指定编程语言标准,如 -std=c++98、-std=c++11 等 |
2.3 GDB(gdb)
GDB(gdb)全称“GNU symbolic debugger”,是 Linux 下常用的程序调试器。
为了能够使用 gdb 调试,需要在代码编译的时候加上-g
,如
g++ -g -o test test.cpp
1
本文中只演示从源代码生成可执行二进制文件的过程,暂不涉及调试过程。调试的配置会在另一篇文章中专门介绍。
3. C++编译过程详解
主要参考:
- C++ 预编译,编译,汇编,链接
- [C/C++语言编译链接过程](https://zhuanlan.zhihu.com/p/88255667#:~:text=C/C++语言编译链接过程 1 1.预处理 (Preprocessing) 预处理用于将所有的#include 头文件以及宏定义替换成其真正的内容 ,预处理之后得到的仍然是文本文件,但文件体积会大很多。 …,成最终的 可执行文件 (executable file)。 … 5 5.数据和指令 )
本节内容用下面的简单 C++工程做演示。示例的文件结构如下:
|—— include
|—— func.h
|—— src
|—— func.cpp
|—— main.cpp
12345
其中,main.cpp
是主要代码,include/func.h
是自定义函数的头文件,src/func.cpp
是函数的具体实现
各个文件的内容如下:
// main.cpp
#include <iostream>
#include "func.h"
using namespace std;
int main(){
int a = 1;
int b = 2;
cout << "a + b = " << sum(a, b) << endl;;
return 0;
}
12345678910111213
// func.h
#ifndef FUNC_H
#define FUNC_H
int sum(int a, int b);
#endif
1234567
// func.cpp
#include "func.h"
int sum(int a, int b) {
return a + b;
}
123456
3.1 预处理(Preprocess)
预处理,顾名思义就是编译前的一些准备工作。
预编译把一些#define
的宏定义完成文本替换,然后将#include
的文件里的内容复制到.cpp
文件里,如果.h
文件里还有.h
文件,就递归展开。在预处理这一步,代码注释直接被忽略,不会进入到后续的处理中,所以注释在程序中不会执行。
gcc/g++的预处理实质上是通过预处理器cpp
(应该是 c preprocess 的缩写?)来完成的,所以我们既可以通过g++ -E
,也可以通过cpp
命令对main.cpp
进行预处理:
g++ -E -I include/ main.cpp -o main.i
# 或者直接调用 cpp 命令
cpp -I include/ main.cpp -o main.i
123
上述命令中:
g++ -E
是让编译器在预处理之后就退出,不进行后续编译过程,等价于cpp
指令-I include/
用于指定头文件目录main.cpp
是要预处理的源文件-o main.i
用于指定生成的文件名
预处理之后的程序格式为 *.i
,仍是文本文件,可以用任意文本编辑器打开。
执行完预处理后的文件结构如下:
|—— include
|—— func.h
|—— src
|—— func.cpp
|—— main.cpp
|—— main.i
123456
3.2 编译(Compile)
编译只是把我们写的代码转为汇编代码,它的工作是检查词法和语法规则,所以,如果程序没有词法或则语法错误,那么不管逻辑是怎样错误的,都不会报错。
编译不是指程序从源文件到二进制程序的全部过程,而是指将经过预处理之后的程序转换成特定汇编代码(assembly code)的过程。
编译的指令如下:
g++ -S -I include/ main.cpp -o main.s
1
与预处理类似,上述命令中:
g++ -S
是让编译器在编译之后停止,不进行后续过程-I include/
用于指定头文件目录main.cpp
是要编译的源文件-o main.s
用于指定生成的文件名
编译完成后,会生成程序的汇编代码main.s
,这也是文本文件,可以直接用任意文本编辑器查看。
执行完编译后的文件结构如下:
|—— include
|—— func.h
|—— src
|—— func.cpp
|—— main.cpp
|—— main.i
|—— main.s
1234567
3.3 汇编(Assemble)
汇编过程将上一步的汇编代码(
main.s
)转换成机器码(machine code),这一步产生的文件叫做目标文件(main.o
),是二进制格式。
gcc/g++的汇编过程通过 as
命令完成,所以我们可以通过g++ -c
或as
命令完成汇编:
g++ -c -I include/ main.cpp -o main.o
# 或者直接调用 as 命令
as main.s -o main.o
123
上述指令中:
g++ -c
让编译器在汇编之后退出,等价于as
-I include/
仍是用于指定头文件目录main.cpp
是要汇编的源文件-o main.o
用于指定生成的文件名
汇编这一步需要为每一个源文件(本文示例代码中为main.cpp
、func.cpp
)产生一个目标文件。因此func.cpp
也需要执行一次这个汇编过程产生一个func.o
文件:
# 可以用 g++ -c 命令一步生成 func.o
g++ -c -I include/ src/func.cpp -o src/func.o
# 当然也可以按照上面的预处理、编译、汇编三个步骤生成func.o
123
到了这一步,代码的文件结构如下:
|—— include
|—— func.h
|—— src
|—— func.cpp
|—— func.o
|—— main.cpp
|—— main.i
|—— main.s
|—— main.o
123456789
3.4 链接(Link)
C/C++代码经过汇编之后生成的目标文件(\*.o
)并不是最终的可执行二进制文件,而仍是一种中间文件(或称临时文件),目标文件仍然需要经过链接(Link)才能变成可执行文件。
既然目标文件和可执行文件的格式是一样的(都是二进制格式),为什么还要再链接一次呢?
因为编译只是将我们自己写的代码变成了二进制形式,它还需要和系统组件(比如标准库、动态链接库等)结合起来,这些组件都是程序运行所必须的。
链接(Link)其实就是一个“打包”的过程,它将所有二进制形式的目标文件(.o)和系统组件组合成一个可执行文件。完成链接的过程也需要一个特殊的软件,叫做链接器(Linker)。
此外需要注意的是:C++程序编译的时候其实只识别
.cpp
文件,每个 cpp 文件都会分别编译一次,生成一个.o
文件。这个时候,链接器除了将目标文件和系统组件组合起来,还需要将编译器生成的多个.o
或者.obj
文件组合起来,生成最终的可执行文件(Executable file)。
以本文中的代码为例,将func.o
和main.o
链接成可执行文件main.out
,指令如下:
g++ src/func.o main.o -o main.out
1
-o main.out
用于指定生成的可执行二进制文件名- 由于
g++
自动链接了系统组件,所以我们只需要把自定义函数的目标文件与main.o
链接即可。
运行main.out
,结果如下:
./main.out
a + b = 3
12
3.5 小结
从上面的介绍可以看出,从 C++源代码到最终的可执行文件的中间过程并不简单。了解预处理、编译、汇编、链接各个步骤的作用有助于我们处理更加复杂的项目工程。
不过也不必被这么麻烦的编译过程劝退,当我们编译简单.cpp
代码时,
// hello.cpp
#include <iostream>
using namespace std;
int main(){
cout << "Hello, world!" << endl;
return 0;
}
12345678
仍然可以直接使用g++
命令生成可执行文件,而不必考虑中间过程:
g++ hello.cpp -o hello
./hello
Hello, world!