C/C++ 文件编译过程

image.png

上图展示了一个C/C++文件通过编译器生成一个可执行文件的大致过程。包含源文件的预处理,编译,汇编和最终链接目标文件生成可执行文件的四个步骤。

预处理 ( 得到 .i or .ii )

预处理的本质是进行内容的插入和替换,主要包含以下几项工作。

  • 将所有的 #define 删除,并且展开所有的宏定义。说白了就是字符替换
  • 处理所有的条件编译指令,#ifdef #ifndef #endif 等,就是带#的那些
  • 处理 #include,将 #include 指向的文件插入到该行处
  • 删除所有注释
  • 添加行号和文件标示,这样的在调试和编译出错的时候才知道是是哪个文件的哪一行
  • 保留 #pragma 编译器指令,因为编译器需要使用它们。

编译 ( 得到 .s 文件 )

编译过程就是对预处理完的文件进行一系列的词法分析,语法分析,语义分析,并且将代码优化后产生相应的汇编代码文件(ASCLL文件),即 .s 文件。这个过程是整个程序构建的核心部分,也是最复杂的部分之一。对于不同架构的CPU汇编代码不相同(X86_32X64的汇编指令就不同相同),同样在编译时选择的优化等级也会导致不同的汇编代码。

例如以下这个函数:

1
2
3
4
5
6
7
int task() {
int a = 0;
while(a > 2){
//do something
}
return 1;
}

未经优化的汇编代码为:

1
2
3
4
5
6
7
8
9
10
11
task():
push rbp
mov rbp, rsp
mov [rbp-4], 0
nop
.L2:
cmp [rbp-4], 2
jg .L2
mov eax, 1
pop rbp
ret

而经过优化之后汇编代码变为:

1
2
3
task():
mov eax, 1
ret

可以看到,编译器将没必要的汇编指令进行了优化,生成了全新的代码。这从侧面也提醒了我们,在大型工业项目中,要谨慎使用编译器的优化功能,尽量在开发环境中应用与生产环境相同的编译器和相同的优化等级进行测试,防止因为编译器优化导致的bug。

汇编 ( 得到 .o 文件 )

汇编的主要功能就是将汇编指令根据CPU所支持的指令集体系结构(Instruction-Set Architecture) 生成二进制指令,即生成二进制可重定向文件(.o)。

任何一个源文件在进行编译阶段的时候会去产生符号表,符号表中存放的就是程序所产生的符号(例如:函数名,变量名等),我们的编译阶段是不会去给符号分配正确的地址。这些符号都没有被分配地址,因此 .o 文件没有经过链接是无法执行的。

目前的CPU指令集主要可以归类为RISC(精简指令集计算机)和CISC(复杂指令集计算机)。其中前者的代表为ARM架构,后者的代表为Intel架构。不过目前这两种指令集也在互相吸收对方的特点,两种指令集的边界也在逐渐模糊。

链接

链接阶段的两个步骤

  1. 由于每个 .o 文件都有都有自己的代码段、bss段等,所以链接器首先将多个 .o 文件相应的段进行合并,建立映射关系并且去合并符号表。进行符号解析,符号解析完成后就是给符号分配虚拟地址。

  2. 将分配好的虚拟地址与符号表中的定义的符号一一对应起来,使其成为正确的地址,使代码段的指令可以根据符号的地址执行相应的操作,最后由链接器生成可执行文件。

动态链接和静态链接

  1. 静态链接

要了解静态链接,我们得先了解静态库,静态库(static library)是“库”最典型的使用方式。在UNIX系统中,一般使用 ar 命令生成静态库,并以 .a 作为文件扩展名,”lib” 作为文件名前缀。在 Windows 平台上,静态库的扩展名为 .LIB。链接器在将所有目标文件集链接到一起的过程中,会为所有当前未解决的符号构建一张“未解决符号表”。当所有显示指定的目标文件都处理完毕时,链接器将到“库”中去寻找“未解决符号表”中剩余的符号。如果未解决的符号在库里其中一个目标文件中定义,那么这个文件将加入链接过程,这跟用户通过命令行显示指定所需目标文件的效果是一样的,然后链接器继续工作直至结束。

总的来说,静态链接就是在链接阶段把 .o 文件中所依赖的静态库链接到一起,最终生成的可执行文件当中包含 lib 中的函数,类等等。

  1. 动态链接

相对应的,动态链接所对应的库叫做动态链接库(Dynamic Linkable Library,缩写为 DLL)。

对于像 C 标准库这类常用库而言,如果用静态库来实现存在一个明显的缺点,即所有可执行程序对同一段代码都有一份拷贝。如果每个可执行文件中都存有一份如 printf, fopen 这类常用函数的拷贝,那将占用相当大的一部分硬盘空间,这完全没有必要。所以我们使用动态链接的方法来进行优化。

它是这样进行链接的,当链接器发现某个符号的定义在 DLL 中,那么它不会把这个符号的定义加入到最终生成的可执行文件中,而是将该符号与其对应的库名称记录下来(保存在可执行文件中)。当程序开始运行时,操作系统会及时地将剩余的链接工作做完以保证程序的正常运行。在 main 函数开始之前,有一个小型的链接器(链接器隶属于系统)将负责检查贴过标签的内容,并完成链接的最后一个步骤:导入库里的代码,并将所有符号都关联在一起。

在系统的管理下,应用程序与相应的 DLL 之间建立链接关系。当要执行所调用 DLL 中的函数时,根据链接产生的重定位信息,系统才转去执行 DLL 中相应的函数代码。一般情况下,如果一个应用程序使用了动态链接库,Win32 系统保证内存中只有 DLL 的一份复制品。

  1. 两者的比较

    1. 动态链接库

      • 优点:
        1. 更加节省内存;
        2. DLL 文件与 EXE 文件独立,只要输出接口不变,更换 DLL 文件不会对 EXE 文件造成任何影响,因而极大地提高了可维护性和可扩展性。
      • 缺点:
        使用动态链接库的应用程序不是自完备的,它依赖的 DLL 模块也要存在,如果使用载入时动态链接,程序启动时发现DLL 不存在,系统将终止程序并给出错误信息。
    2. 静态链接库

      • 优点:
        1. 代码装载速度快,执行速度略比动态链接库快;
        2. 只需保证在开发者的计算机中有正确的 .LIB 文件,在以二进制形式发布程序时不需考虑在用户的计算机上 .LIB 文件是否存在及版本问题,可避免DLL地狱等问题。
      • 缺点:
        使用静态链接生成的可执行文件体积较大,包含相同的公共代码,造成浪费。

.h 文件与 .cpp 文件之间的关系

在工作中我们时常面临着在代码中插入别人已经写好的库,例如OpenCV,rapidjson等等。一般来说我们都会包含这些库的头文件并链接这些库。对于链接这一任务来说,头文件是调用函数的代码和提供函数的库之间沟通的桥梁。

库也是通过源代码编译生成的。既然在源代码cpp文件中就可以完成函数的声明和定义,我们为什么还要写头文件呢?

头文件对于库源代码来说,起到了声明的作用,库源代码按照头文件的声明,定义相应的函数。当库源代码编译为二进制库时,头文件就成为了这个库的说明书,当有其他程序需要调用库里面定义的函数,就可以按照库提供的头文件来编写程序,而在编译程序时,只需要将这个库的头文件加入到包含路径中,并链接该库即可。

g++/gcc 编译

使用 gcc 编译

所有的程序都始于"Hello World",从"Hello World"开始,我们去了解一个最简单的可执行程序是如何生成的。

1
2
3
4
5
6
7
8
#include <stdlib.h>
#include <stdio.h>

int main()
{
printf("Hello World!\n");
return 0;
}

在Linux下,使用GCC来编译"Hello World"程序,只要使用如下指令,就可以完成:

1
gcc -o hello hello.c

上面的指令虽然简单,但是运行的过程中,gcc 会依次调用预处理器(cpp)、编译器(cc)、汇编器(as)和链接器(ld)完成编译的整个流程,因此从本质上来讲,gcc 命令实际上是这些工具的前台包装,它会根据不同的参数要求去调用对应的工具。

在执行上述的 gcc 命令时,gcc 编译器首先运行C预处理器(cpp),它将 C 的源程序 hello.c 翻译成中间文件 hello.i

1
cpp hello.c -o hello.i

接下来,gcc 编译器运行 C 编译器(cc),它将 hello.i 翻译成汇编语言文件 hello.s

1
cc -S hello.i -o hello.s

然后,gcc 编译器运行汇编器(as),它将 hello.s 翻译成一个可重定位目标文件 hello.o

1
as hello.s -o hello.o

最后,gcc 编译器调用链接器(ld),将 hello.o 与其依赖的库文件链接成最终的可执行文件:

1
2
3
4
5
6
ld hello.o -o hello -lc \
/usr/lib/crt1.o \
/usr/lib/crti.o \
/usr/lib/crtn.o \
/usr/lib/gcc/x86_64-pc-linux-gnu/9.2.0/crtbegin.o \
/usr/lib/gcc/x86_64-pc-linux-gnu/9.2.0/crtend.o

以一个例子理解g++/gcc命令

cpp_compile_example 为例,我们的目标是生成main.cpp对应的可执行文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
.
├── build_lib.sh
├── build.sh
├── include
│ ├── add.h
│ └── sort.h
├── library
│ ├── libadd.a
│ └── libsort.a
├── main.cpp
└── src
├── add.cpp
└── sort.cpp

其中最外层的两个脚本分别用来编译依赖库(build_lib.sh)和编译可执行文件(build.sh)。接下来我们通过编译脚本中的命令来理解C++程序的编译过程。

其中build_lib.sh脚本中包含两条命令分别是将src/目录下的sort.cppadd.cpp编译为依赖库libsort.alibadd.a

1
2
3
4
5
6
g++ -c -I ./include -o ./library/libadd.a ./src/add.cpp
g++ -c -I ./include -o ./library/libsort.a ./src/sort.cpp
# -c 代表编译目标为静态库,不加编译器会检测main函数,没有main函数会报错。
# -I 代表include路径,将头文件所在的文件夹加入到包含路径中。
# -o 指定结果文件路径和名称。
# 最后的文件名为源代码文件。

运行完build_lib.shmain.cpp的依赖库被输出到library中。接下来就可以运行build.sh完成可执行文件的生成。

1
2
3
4
g++ -o main -I ./include -L ./library -lsort -ladd main.cpp
# -L 后面的参数指明了链接的库所在的文件夹。
# -l 后面紧跟着需要链接的库名,动态库和静态库皆可。需要注意的是-ladd指的是链接libadd.a,而不是add.a。
# 最后跟的是可执行文件对应的源代码文件。

-l 后面指定的库名会被编译器自动加上前缀 lib 和后缀 .a(对于静态库)或 .so(对于动态库)。这是因为在UnixLinux系统中,库文件的命名约定是以 lib 开头,后面跟上库的名称,再加上适当的后缀(.a.so)。