C++编译基本过程

上图展示了一个源代码文件通过编译器生成一个可执行文件的大致过程。基本包含源文件的预处理,编译,汇编和最终链接目标文件生成可执行文件。本篇文章就是逐步介绍从源代码生成最终可执行文件的过程。在知道每一步的原理之后,介绍在编写大型项目时,如果通过cmake工具来帮助我们构建项目最终的可执行文件。

graph TD
    A[源文件test.c] -->|gcc -E test.c -o test.i| B(预处理后文件test.i)
    B -->|gcc -S test.c -o test.s| C[汇编文件test.s全部为汇编指令]
    C --> |as test.s -o test.o| D[目标文件test.o全部为二进制机器指令]
    D --> |ld test.o -o test.out| F[可执行文件test.out]

预处理

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

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

    编译

编译过程就是对预处理完的文件进行一系列的词法分析,语法分析,语义分析及优化后生成相应的汇编代码。对于不同架构的CPU汇编代码不相同(X86_32和X64的汇编指令就不同相同),同样在编译时选择的优化等级也会导致不同的汇编代码。

例如以下这个函数。

int task() {
    int a = 0;
    while(a > 2){
        //do something
    }
    return 1;
}

未经优化的汇编代码为

task():
        push    rbp
        mov     rbp, rsp
        mov     [rbp-4], 0
        nop
.L2:
        cmp     [rbp-4], 2
        jg      .L2
        mov     eax, 1
        pop     rbp
        ret

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

task():
        mov     eax, 1
        ret

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

汇编

汇编的主要功能就是将汇编指令根据CPU所支持的指令集体系结构(Instruction-Set Architecture) 生成二进制指令。
目前的CPU指令集主要可以归类为RISC(精简指令集计算机)和CISC(复杂指令集计算机)。其中前者的代表为ARM架构,后者的代表为Intel架构。不过目前这两种指令集也在互相吸收对方的特点,两种指令集的边界也在逐渐模糊。

链接

链接的本质是将源代码中使用的外部变量,外部类,外部函数的可执行代码融合到最终生成的目标文件中。

在Linux中目标文件可以分为3类:

  1. 可重定位目标文件:简单来说就是静态库,或者是单独的目标文件。在例子中我们将test.cpp编译得到的test.o就是单独的目标文件,而静态库就是一些目标文件的集合。
  2. 可执行目标文件。
  3. 共享目标文件:简单来说就是动态库。

对于一个编译任务最终的目标的是获取可执行文件。在链接过程中,链接器会解析外部符号,对于所链接的静态库,会将所需要的静态库中的外部符号对应的二进制内容(可能是函数,或者是全局变量)插入到可执行文件中,而对于动态库,则会在运行时,加载并执行动态库中的相应指令。

头文件和库之间的关系

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

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

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

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

https://gitee.com/hlinbit/cpp...为例,我们的目标是生成main.cpp对应的可执行文件。

.
├── 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.cpp和add.cpp编译为依赖库libsort.a和libadd.a。

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.sh,main.cpp的依赖库被输出到library中。接下来就可以运行build.sh完成可执行文件的生成。

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