2023-详解 C C++ 代码的预处理、编译、汇编、链接全过程

详解 C/C++ 代码的预处理、编译、汇编、链接全过程

1. C/C++运行的四个步骤

编写完成一个 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++工程做演示。示例的文件结构如下:

|—— 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++ -cas命令完成汇编:

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.cppfunc.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

C/C++代码经过汇编之后生成的目标文件(\*.o)并不是最终的可执行二进制文件,而仍是一种中间文件(或称临时文件),目标文件仍然需要经过链接(Link)才能变成可执行文件

既然目标文件和可执行文件的格式是一样的(都是二进制格式),为什么还要再链接一次呢?

因为编译只是将我们自己写的代码变成了二进制形式,它还需要和系统组件(比如标准库、动态链接库等)结合起来,这些组件都是程序运行所必须的

链接(Link)其实就是一个“打包”的过程,它将所有二进制形式的目标文件(.o)和系统组件组合成一个可执行文件。完成链接的过程也需要一个特殊的软件,叫做链接器(Linker)

此外需要注意的是:C++程序编译的时候其实只识别.cpp文件,每个 cpp 文件都会分别编译一次,生成一个.o文件。这个时候,链接器除了将目标文件和系统组件组合起来,还需要将编译器生成的多个.o或者.obj文件组合起来生成最终的可执行文件(Executable file)

以本文中的代码为例,将func.omain.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!