我们在学习和开发C++程序中,理解编译和链接的原理至关重要。下面将学习一下C++程序是如何从源代码转换为可执行文件的过程,并结合示例代码进行说明。也是为了解开自己在刚学习C++的时候,编译时间长的疑惑。

  为了不让自己的学习之路这么枯燥,我按照一个正常的开发流程梳理一下。这样不但学习了如何写代码,更明白了自己的代码为什么是这样的运行的。

1,程序员编写C++源代码

  首先,程序员会编写C++源代码文件,这些文件通常具有扩展名为 .cpp 或者 .cc。

  以下是一个简单的C++代码示例:

#include <iostream>

int main() {
    std::cout << "Hello, World!" << std::endl;
    return 0;
}

  要编译这个代码,需要按照下面步骤:

  step1:将上面代码保存到一个文件中,比如 `hello.cpp`。

  step2:打开命令行终端。

  step3:在终端中,进入到保存代码的文件所在的目录。

  step4:使用C++编译器(如g++)编译代码。在终端中输入下面命令:

g++ hello.cpp -o hello

  这将把 `hello.cpp` 编译成一个可执行文件 `hello` ‘-o’ 选项指定输出文件的名称。

  step5:如果编译没有错误,你可以允许生成一个可执行文件。在终端输入以下命令:

./hello

  这将执行程序,并输出 `Hello World`。

  注意 gcc 和 g++ 都属于GCC的调用指令,gcc会根据文件后缀自动推断语言类型,而g++不管后缀名是什么都按照C++ 语言去编译。通常情况下我们使用gcc编译C程序,使用g++编译C++程序。

  实际上当程序员写完代码后,编译过程通常包含以下几个步骤:预处理,编译,汇编和链接。

  下面先简单的解释一下:

  预处理:在这个阶段,预处理器将对代码进行处理,主要包括处理以“#”开头的预处理指令,如#include,#define 等。预处理器会将头文件包含进来,展开宏定义等,生成一个经过预处理的源代码文件。

  编译:在这个阶段,编译器将预处理后的源代码翻译成中间代码(通常是汇编代码),这个中间代码仍然是针对特定的硬件平台的抽象代码。

  汇编:在这个阶段,汇编器将编译器生成的中间代码翻译成目标机器的机器码,即汇编语言。汇编语言是特定于计算机体系结构的低级语言。

   链接:在这个阶段,链接器将编译后的目标文件与所需的库文件链接在一起,生成可执行文件。这些库文件可能包含标准库,第三方库。

  下面让我们结合代码来说明一下具体的步骤。

2 预处理

  预处理主要包含如下几步:展开头文件,宏替换,去掉注释,条件编译。但是这些步骤我们是看不见的。

  我们可以看一下展开的编译命令:

g++ -E hello.cpp -o hello.i

  这将把预处理后的代码保存到 `hello.i` 文件中。预处理的代码会包含 <iostream>中的内容,并且会展开 std::cout, std::endl等。

2.1 展开头文件

  函数声明与定义:C/C++ 中规定使用函数之前,必须先声明函数原型,编译器根据函数声明确定函数的传入参数和返回值。函数可以有多个声明,但是只能有一个定义,C/C++通常将函数声明放在头文件中,函数定义放在源文件中。

  预编译阶段,会将 #include 包含的头文件展开到所包含的文件中,这样当前文件就拥有了所引用头文件内部函数的声明,可以正常使用头文件声明的函数了。

  (注意:头文件中为什么不能有函数和变量定义?因为头文件会被多个其他源文件包含,这样会导致函数和变量的重定义错误。)

  头文件重复包含:假设文件 A 包含 B 和 C,而 B 也包含了 C,则头文件展开后,A 中会有两份 C 的内容,这就是重复包含。如果 C 中包含了变量和函数的定义,则会产生重定义错误。

  通常在头文件中使用 #ifndef 来解决头文件的重复包含问题。

2.2 宏替换

  宏的作用:可以定义常量,实现一处修改,全部生效。减少函数调用开销:带参数的宏在预处理阶段就进行了宏展开,提供执行效率。

2.3 条件编译

  条件编译的指令包括:#ifdef、#ifndef、else、elif 和 #endif 等。

  条件编译类似于程序中 if else 语句,会选择性执行对应的代码。不过前者是在预编译阶段执行的条件选择,后者是在程序运行时执行的条件选择。

   假设代码中有如下的预编译指令:

#include <iostream>

#define PI 3.14159

int main() {
    std::cout << "Hello, World!" << std::endl;
    return 0;
}

  执行预处理后的代码将是:

// Content of iostream included here...

int main() {
    std::cout << "Hello, World!" << std::endl;
    return 0;
}

  

3  编译

  编译的命令是:

g++ -S hello.i -o hello.s

  这将把编译后的汇编代码保存到 hellp.s 文件中。这些汇编代码是针对特定的硬件平台的抽象代码。

  执行的编译后的代码将是汇编代码:

.section .text
.globl main
main:
    ; Code to print "Hello, World!"
    ; Code to return 0

  

  编译是将源代码文件(.cpp或.c文件)转换为目标文件(.o或.obj文件)的过程,编译器(如g++、clang++、MSVC)将C++源代码文件翻译成中间代码(中间代码通常称为汇编语言),这些中间代码存储在目标文件中。源文件中的每个函数通常会生成一个目标文件。编译过程中会检查代码中的语法错误和类型错误,生成目标文件,并对代码进行优化。生成的目标文件包含了函数的二进制代码和一些元数据,但它们通常还没有被链接到最终可执行文件中。

  编译阶段可以分为两步:1,检查函数和变量是否存在声明; 2, 检查语句是否符合C++语法。

  (注意:内联函数如果定义在源文件中,由于在编译阶段只引用声明,这样内联函数就不能展开,也就达不到内联的效果,所以内联函数要定义在头文件中。)

3.1 编译优化

  编译器提供了 -O 优化选项,对应不同的优化方案。

  -O0:关闭所有优化选项,等价于编译时不带 -O 选项。

  -O1:提供基础级别的优化,主要内容包括:

    1. 延迟栈的弹出时间,在多个函数被调用后,一次性弹出

    2. 合并常量,将多个编译单元中相同的常量合并

    3. 分支优化,对判断条件有关联的分支重定向

    4. 循环优化,将常量表达式从循环中移除,简化判断循环的条件

    5. 指令重排,根据指令周期时间重新安排指令。

  -O2:比 O1 高级的优化,将执行几乎所有不包含时间和空间折中的优化,与 O1 比较而言,O2 优化增加了编译时间,但是提高了代码的执行效率,推荐编译线上代码时使用。

  除了打开所有的 O1 选项,并打开以下选项:

    1. 在编译函数的时候重新安排基本的块,目的在于减少分支的个数

    2. 编译器尝试重新排列指令,用以消除由于等待未准备好的数据而产生的延迟

    3. 优化相关的以及末尾递归的调用

    4. 使函数对准内存中特定边界的开始位置,确保全部函数代码位于单一内存页面内。

  -O3:最高级的代码优化,除了执行 -O2 的选项,一般都是采取很多向量化算法,提高代码的并行执行程度,利用现代CPU中的流水线,Cache等。

    1. 构建用于保存变量的伪寄存器网络

    2. 普通函数的内联

    3. 针对循环的更多优化

  -Og:当编译选项包含 -g 时,该选项会选取不影响调试逻辑的优化选项进行优化。

3.2 编译优化带来的问题

  调试问题:任何优化都将带来代码结构的改变,例如:对分支的合并和消除,对公用子表达式的消除,对循环内load/store操作的替换和更改等,都将会使目标代码的执行顺序变得面目全非,导致调试信息严重不足。

  内存操作顺序改变所带来的问题:在 O2 优化后,会影响内存操作的执行顺序。例如:-fschedule-insns 允许数据处理时先完成其他的指令;-fforce-mem有可能导致内存与寄存器之间的数据产生类似脏数据的不一致等。对于某些依赖内存操作顺序而进行的逻辑,需要做严格的处理后才能进行优化。例如,采用volatile 关键字限制变量的操作方式,或者利用 barrier 迫使 cpu 严格按照指令序执行的。

  (注意:在调试时要关闭所有优化选项,而编译线上运行版本时,则打开 -O2 选项,尽量不要采用 -O3 选项优化,因为 -O3 比较激进,可能会出现改变程序行为的情况。)

   

4  汇编

  汇编是将编译阶段得到的 .s 汇编文件翻译成 .o 二进制机器指令文件的过程。

  命令如下:

g++ -c hello.s -o hello.o

  这将把汇编代码转换为目标机器的机器码,并保存在 hello.o 文件中。

5  链接

  链接命令如下:

g++ hello.o -o hello

  这将把 hello.o 文件与所需的库文件链接在一起,生成可执行文件hello。

  编译和汇编后得到的是 .o 的二进制文件,但是不能独立允许。链接是编译的最后一步,会将之前编译好的 .o文件,系统库的 .o 文件和库文件彼此相连接,把某个目标文件中引用的符号同另一个文件中的定义链接起来,将所有编译好的单元组成一个可执行文件。

  链接是将多个目标文件和库文件组合成一个可执行文件的过程。在C++中,通常使用链接器来完成这个任务,链接器(如ld、linker)将解析目标文件中的符号和库文件合并,并解析他们之间的符号引用。然后将他们解析到可执行程序中。在链接过程中,包含将函数,全局变量,库函数,静态变量等符号的引用与其定义进行匹配,以确保代码可以正确执行。链接器生成可执行文件,其中包括二进制代码和各种元数据,如程序入口点和动态链接库引用。

5.1  符号解析

  在链接中,将函数和变量统称为符号,函数名和变量名统称为符号名,每个目标文件要提供两个符号表给链接器使用。

  符号解析时,链接器根据目标文件提供的未解决符号表,去所有的编译单元的导出符号表中去查找与这个未解决符号相匹配的符号名,如果找到,就把这个符号的地址填到未解决符号的地址处,如果没有找到,就会报链接错误。

5.2  重定位

  多个编译单元的符号地址可能是相同的,比如都从 (0x0000) 开始,那么最终多个目标文件链接时就会导致地址重复。所以链接器在链接时就会对每个目标文件的地址进行调整,这个调整的过程就是重定位。

5.3  链接过程

  链接就是进行符号解析和重定位的过程。

  链接器首先决定各个目标文件在最终可执行文件里的位置。然后访问所有目标文件的地址重定义表,对其中记录的地址进行重定向(加上一个偏移量,即该编译单元在可执行文件上的起始地址)。遍历所有目标文件的未解决符号表,并且在所有的导出符号表里查找匹配的符号,并在未解决符号表中所记录的位置上填写实现地址。

  最后把所有的目标文件的内容写在各自的位置上,就生成一个可执行文件。

5.4  静态链接

  静态链接在编译阶段就将库文件的所有代码加到可执行文件中,因此生成的程序体积更大,其后缀名一般为 .a,静态库的优点:

  • 代码装载速度比动态库快,执行效率也略高。
  • 不依赖于外部库安装环境,部署方便。

  静态库链接顺序:gcc 链接库的顺序是从右到左的,假设 main.cpp 依赖 liba.a,liba.a依赖libb.a,则链接顺序为 g++ main.cpp liba.a libb.a,如果修改顺序就会链接报错。

5.5 动态链接

动态链接在编译链接时并不会把库文件的代码加到可执行文件中,而是在运行时加载所需的动态库,后缀名一般为 .so,动态库的优点:

  • 生成的可执行程序更小。
  • 共享库是通过mmap映射的方式实现文件共享,多进程运行时更加节省内存。
  • 库文件修改时,可执行文件不需要重新编译,只需要重启即可。

 

5.6  库文件符号重名

  可执行文件如果依赖的多个库文件中,如果有符号重名时,静态库和动态库分别是

  • 静态库间符号重名:链接失败,编译报错。
  • 动态库间符号重名:根据链接顺序,先被链接的动态库符号占用,后被链接的忽略。

 

6 生成可执行文件

  在链接完成后,你将获得一个可执行文件,你可以运用 `./hello` 来执行你的C++程序。

  总结一下,编译是将源代码转换为目标文件,而链接是将多个目标文件和库文件组合成最终的可执行文件。在链接阶段,解析符号引用,将函数和变量链接在一起,以创建一个可执行文件,以便执行。