编译和链接过程如何工作?
(注意:这是对Stack Overflow 的 C++ FAQ 的一个条目。如果您想批评以这种形式提供 FAQ 的想法,那么开始这一切的 meta 上的帖子就是这样做的地方。该问题在C++ 聊天室中进行监控,FAQ 想法最初是从那里开始的,因此您的答案很可能会被提出该想法的人阅读。)
编译和链接过程如何工作?
(注意:这是对Stack Overflow 的 C++ FAQ 的一个条目。如果您想批评以这种形式提供 FAQ 的想法,那么开始这一切的 meta 上的帖子就是这样做的地方。该问题在C++ 聊天室中进行监控,FAQ 想法最初是从那里开始的,因此您的答案很可能会被提出该想法的人阅读。)
C++程序的编译包括三个步骤:
预处理:预处理器获取一个 C++ 源代码文件并处理#include
s、#define
s 和其他预处理器指令。此步骤的输出是没有预处理器指令的“纯”C++ 文件。
编译:编译器获取预处理器的输出并从中生成一个目标文件。
链接:链接器获取编译器生成的目标文件并生成库或可执行文件。
预处理器处理预处理器指令,例如#include
和#define
。它与 C++ 的语法无关,这就是为什么必须小心使用它的原因。
它一次处理一个 C++ 源文件,方法是#include
用相应文件的内容(通常只是声明)替换指令,替换宏 ( ) ,并根据和指令#define
选择文本的不同部分。#if
#ifdef
#ifndef
预处理器处理预处理令牌流。宏替换被定义为用其他标记替换标记(运算符##
在有意义时允许合并两个标记)。
毕竟,预处理器产生一个单一的输出,它是由上述转换产生的标记流。它还添加了一些特殊的标记,告诉编译器每一行的来源,以便它可以使用这些标记来产生合理的错误消息。
通过巧妙地使用#if
and#error
指令,在这个阶段可能会产生一些错误。
编译步骤在预处理器的每个输出上执行。编译器解析纯 C++ 源代码(现在没有任何预处理器指令)并将其转换为汇编代码。然后调用底层后端(工具链中的汇编器),将代码组装成机器代码,以某种格式(ELF、COFF、a.out、...)生成实际的二进制文件。该目标文件包含输入中定义的符号的编译代码(二进制形式)。目标文件中的符号按名称引用。
目标文件可以引用未定义的符号。当您使用声明并且不为其提供定义时就是这种情况。编译器不介意这一点,只要源代码格式正确,它就会愉快地生成目标文件。
编译器通常会让您在此时停止编译。这非常有用,因为您可以使用它单独编译每个源代码文件。这样做的好处是,如果您只更改单个文件,则无需重新编译所有内容。
生成的目标文件可以放在称为静态库的特殊存档中,以便以后重用。
正是在这个阶段报告了“常规”编译器错误,如语法错误或失败的重载解析错误。
链接器是从编译器生成的目标文件产生最终编译输出的东西。此输出可以是共享(或动态)库(虽然名称相似,但它们与前面提到的静态库没有太多共同点)或可执行文件。
它通过用正确的地址替换对未定义符号的引用来链接所有目标文件。这些符号中的每一个都可以在其他目标文件或库中定义。如果它们是在标准库以外的库中定义的,则需要告诉链接器它们。
在这个阶段,最常见的错误是缺少定义或重复定义。前者意味着定义不存在(即它们没有被写入),或者它们所在的目标文件或库没有提供给链接器。后者很明显:在两个不同的目标文件或库中定义了相同的符号。
该主题在 CProgramming.com 进行了讨论:
https ://www.cprogramming.com/compilingandlinking.html
这是那里的作者写的:
编译与创建可执行文件并不完全相同!相反,创建可执行文件是一个多阶段过程,分为两个部分:编译和链接。实际上,即使一个程序“编译得很好”,它也可能因为链接阶段的错误而无法真正工作。从源代码文件到可执行文件的整个过程最好称为构建。
汇编
编译是指源代码文件(.c、.cc 或 .cpp)的处理和“目标”文件的创建。此步骤不会创建用户可以实际运行的任何内容。相反,编译器仅生成与已编译的源代码文件相对应的机器语言指令。例如,如果您编译(但不链接)三个单独的文件,您将创建三个目标文件作为输出,每个文件的名称为 .o 或 .obj(扩展名取决于您的编译器)。这些文件中的每一个都包含将您的源代码文件翻译成机器语言文件的过程——但您还不能运行它们!您需要将它们转换为您的操作系统可以使用的可执行文件。这就是链接器的用武之地。
链接
链接是指从多个目标文件创建单个可执行文件。在这一步中,链接器通常会抱怨未定义的函数(通常是 main 本身)。在编译期间,如果编译器找不到特定函数的定义,它只会假设该函数是在另一个文件中定义的。如果不是这种情况,编译器就无法知道——它一次不会查看多个文件的内容。另一方面,链接器可能会查看多个文件并尝试查找未提及的函数的引用。
您可能会问为什么有单独的编译和链接步骤。首先,以这种方式实现事情可能更容易。编译器做它的事情,而链接器做它的事情——通过保持函数分离,程序的复杂性降低了。另一个(更明显的)优点是,这允许创建大型程序,而不必在每次更改文件时重做编译步骤。相反,使用所谓的“条件编译”,只需要编译那些已经改变的源文件;其余的,目标文件是链接器的足够输入。最后,这使得实现预编译代码库变得简单:只需创建目标文件并将它们链接起来,就像任何其他目标文件一样。
要获得条件编译的全部好处,获得帮助您的程序可能比尝试记住自上次编译后更改了哪些文件更容易。(当然,您可以只重新编译时间戳大于相应目标文件的时间戳的每个文件。)如果您使用的是集成开发环境 (IDE),它可能已经为您解决了这个问题。如果您使用的是命令行工具,那么大多数 *nix 发行版都有一个名为 make 的漂亮实用程序。除了条件编译之外,它还有其他几个很好的编程特性,例如允许对程序进行不同的编译——例如,如果您有一个版本产生详细的调试输出。
了解编译阶段和链接阶段之间的区别可以更容易地寻找错误。编译器错误通常在本质上是语法错误——缺少分号、额外的括号。链接错误通常与缺少或多个定义有关。如果您从链接器中收到一个函数或变量被多次定义的错误,这很好地表明该错误是您的两个源代码文件具有相同的函数或变量。
GCC 分 4 步将 C/C++ 程序编译成可执行文件。
例如,gcc -o hello hello.c
执行如下:
通过 GNU C 预处理器 ( cpp.exe
) 进行预处理,其中包括标头 ( #include
) 并扩展宏 ( #define
)。
cpp hello.c > hello.i
生成的中间文件“hello.i”包含扩展的源代码。
编译器将预处理的源代码编译为特定处理器的汇编代码。
gcc -S hello.i
-S 选项指定生成汇编代码,而不是目标代码。生成的程序集文件是“hello.s”。
汇编器 ( as.exe
) 将汇编代码转换为目标文件“hello.o”中的机器代码。
as -o hello.o hello.s
最后,链接器 ( ld.exe
) 将目标代码与库代码链接起来,生成一个可执行文件“hello”。
ld -o hello hello.o ...库...
在标准正面:
翻译单元是源文件、包含的头文件和源文件的组合,减去条件包含预处理器指令跳过的任何源代码行。
该标准定义了翻译的 9 个阶段。前四个对应预处理,后三个是编译,下一个是模板的实例化(产生实例化单元),最后一个是链接。
在实践中,第八阶段(模板的实例化)通常在编译过程中完成,但有些编译器将其延迟到链接阶段,有些则将其分散在两个阶段。
瘦是 CPU 从内存地址加载数据,将数据存储到内存地址,并从内存地址顺序执行指令,在处理的指令序列中进行一些条件跳转。这三类指令中的每一个都涉及计算要在机器指令中使用的存储单元的地址。因为机器指令的长度取决于所涉及的特定指令,并且因为我们在构建机器代码时将它们的可变长度串在一起,所以计算和构建任何地址都涉及两步过程。
首先,我们尽可能地布置内存分配,然后才能知道每个单元格中的确切内容。我们找出字节,或单词,或任何构成指令、文字和任何数据的东西。我们只是开始分配内存并构建将创建程序的值,并记下我们需要返回并修复地址的任何地方。在那个地方,我们放了一个假人来填充这个位置,这样我们就可以继续计算内存大小。例如,我们的第一个机器代码可能需要一个单元格。下一个机器代码可能需要 3 个单元,涉及一个机器代码单元和两个地址单元。现在我们的地址指针是 4。我们知道机器单元中的内容,也就是操作码,但是我们必须等待计算地址单元中的内容,直到我们知道数据的位置,即
如果只有一个源文件,理论上编译器可以在没有链接器的情况下生成完全可执行的机器代码。在两遍过程中,它可以计算任何机器加载或存储指令引用的所有数据单元的所有实际地址。它可以计算任何绝对跳转指令引用的所有绝对地址。这就是没有链接器的更简单的编译器(如 Forth 中的编译器)的工作方式。
链接器是允许单独编译代码块的东西。这可以加快构建代码的整体过程,并允许以后如何使用块具有一定的灵活性,换句话说,它们可以在内存中重新定位,例如向每个地址添加 1000 以将块向上移动 1000 个地址单元。
因此,编译器输出的是尚未完全构建的粗略机器代码,但经过布局后,我们可以知道所有内容的大小,换句话说,我们可以开始计算所有绝对地址的位置。编译器还输出名称/地址对的符号列表。这些符号将模块中机器代码中的内存偏移与名称相关联。偏移量是到模块中符号的内存位置的绝对距离。
这就是我们到达链接器的地方。链接器首先将所有这些机器代码块首尾相连,并记下每个代码块的开始位置。然后它通过将模块内的相对偏移量和模块在更大布局中的绝对位置相加来计算要固定的地址。
显然我已经把它过度简化了,所以你可以试着掌握它,而且我故意不使用目标文件、符号表等的术语,这对我来说是混乱的一部分。