编译/链接过程如何工作?


Answers:


554

C ++程序的编译涉及三个步骤:

  1. 预处理:预处理器获取C ++源代码文件,并处理#includes,#defines和其他预处理器指令。此步骤的输出是一个没有预处理程序指令的“纯” C ++文件。

  2. 编译:编译器获取预处理器的输出并从中生成一个目标文件。

  3. 链接:链接器获取由编译器生成的目标文件,并生成库或可执行文件。

前处理

预处理程序处理预处理程序指令,例如#include#define。它与C ++的语法无关,这就是为什么必须谨慎使用它的原因。

通过替换,它一次可处理一个C ++源文件 #include与相应的文件的内容(这是通常只是声明)指令,做替换宏(的#define),以及选择取决于的文本的不同部分#if#ifdef#ifndef指令。

预处理器处理预处理令牌流。宏替换定义为用其他令牌替换令牌(操作员##可以在有意义时合并两个令牌)。

在所有这些之后,预处理器产生单个输出,该单个输出是由上述转换产生的令牌流。它还添加了一些特殊的标记,它们告诉编译器每行来自哪里,以便它可以使用这些标记来产生明智的错误消息。

明智地使用此命令可能会在此阶段产生一些错误。 #if and #error指令,。

汇编

在预处理器的每个输出上执行编译步骤。编译器解析纯C ++源代码(现在没有任何预处理程序指令),并将其转换为汇编代码。然后调用底层的后端(工具链中的汇编器),该后端将代码汇编成机器代码,以某种格式(ELF,COFF,a.out等)生成实际的二进制文件。该目标文件包含输入中定义的符号的已编译代码(二进制形式)。目标文件中的符号按名称引用。

目标文件可以引用未定义的符号。使用声明而不提供定义时就是这种情况。编译器并不介意这一点,只要源代码格式正确,它就会愉快地生成目标文件。

编译器通常可以让您在此时停止编译。这非常有用,因为使用它可以分别编译每个源代码文件。这样做的好处是您不需要重新编译所有内容仅更改单个文件。

可以将生成的目标文件放在称为静态库的特殊归档中,以方便以后重用。

在此阶段,将报告“常规”编译器错误,例如语法错误或失败的重载解析错误。

连结中

链接器根据编译器生成的目标文件生成最终编译输出。此输出可以是共享(或动态)库(虽然名称相似,但与前面提到的静态库没有太多共同点)或可执行文件。

它通过使用正确的地址替换对未定义符号的引用来链接所有目标文件。这些符号中的每一个都可以在其他目标文件或库中定义。如果它们是在标准库以外的库中定义的,则需要告诉链接程序有关它们的信息。

在此阶段,最常见的错误是缺少定义或重复定义。前者意味着要么定义不存在(即未编写),要么没有将定义所驻留的目标文件或库提供给链接器。后者是显而易见的:在两个不同的目标文件或库中定义了相同的符号。


39
在转换为目标文件之前,编译阶段还会调用汇编程序。
manav mn

3
优化在哪里应用?乍一看似乎可以在编译步骤中完成,但另一方面,我可以想象只有在链接之后才能进行适当的优化。
Bart van Heukelom 2014年

6
@BartvanHeukelom传统上是在编译期间完成的,但是现代的编译器支持所谓的“链接时间优化”,它的优点是能够跨翻译单元进行优化。
R. Martinho Fernandes

3
C是否有相同的步骤?
朱Zhu文

6
如果链接程序将引用库中类/方法的符号转换为地址,是否表示库二进制文件存储在OS保持不变的内存地址中?我只是对链接器如何知道所有目标系统的stdio二进制文件的确切地址感到困惑。文件路径将始终相同,但是确切的地址可以更改,对吗?
丹·卡特

42

在CProgramming.com上讨论了此主题:https ://www.cprogramming.com/compilingandlinking.html

这是那里的作者写的:

编译与创建可执行文件并不完全相同!相反,创建可执行文件是一个分为两个部分的多阶段过程:编译和链接。实际上,即使程序“可以正常编译”,由于链接阶段中的错误,它实际上也可能无法正常工作。从源代码文件到可执行文件的整个过程可能更好地称为构建。

汇编

编译是指对源代码文件(.c,.cc或.cpp)的处理以及“目标”文件的创建。此步骤不会创建用户可以实际运行的任何内容。相反,编译器仅产生与已编译的源代码文件相对应的机器语言指令。例如,如果编译(但不链接)三个单独的文件,则将创建三个作为输出的目标文件,每个文件的名称均为.o或.obj(扩展名取决于您的编译器)。这些文件中的每一个都包含将源代码文件转换为机器语言文件的翻译-但您还不能运行它们!您需要将它们转换为操作系统可以使用的可执行文件。这就是链接器进入的地方。

连结中

链接是指从多个目标文件创建单个可执行文件。在此步骤中,链接器通常会抱怨未定义的函数(通常是main本身)。在编译期间,如果编译器找不到特定功能的定义,则仅假设该功能在另一个文件中定义。如果不是这种情况,编译器将无法知道-它不会一次查看多个文件的内容。另一方面,链接器可能会查看多个文件,并尝试查找未提及功能的引用。

您可能会问,为什么有单独的编译和链接步骤。首先,以这种方式实现事情可能更容易。编译器完成其任务,链接器完成其任务-通过将功能分开,可以降低程序的复杂性。另一个(更明显的)优点是,这允许创建大型程序,而不必在每次更改文件时都重做编译步骤。而是使用所谓的“条件编译”,仅编译那些已更改的源文件;对于其余部分,目标文件对于链接器来说是足够的输入。最后,这使实现预编译代码库变得简单:只需创建目标文件并像其他任何目标文件一样链接它们即可。

为了获得条件编译的全部好处,获得一个程序来帮助您可能比尝试记住自上次编译以来更改过的文件要容易得多。(当然,您可以重新编译时间戳大于相应目标文件时间戳的每个文件。)如果您正在使用集成开发环境(IDE),则它可能已经为您解决了。如果您使用的是命令行工具,那么大多数* nix发行版中都会附带一个名为make的漂亮实用程序。除了条件编译外,它还有其他一些不错的编程功能,例如允许对程序进行不同的编译-例如,如果您的版本产生用于调试的详细输出。

了解编译阶段和链接阶段之间的区别可以使查找错误变得更加容易。编译器错误通常本质上是语法上的-缺少分号,多余的括号。链接错误通常与缺少或多个定义有关。如果从链接器中收到多次定义函数或变量的错误,则表明该错误是两个源代码文件具有相同的函数或变量。


1
我不明白的是,如果预处理器管理诸如#includes之类的事情来创建一个超级文件,那么在那之后没有什么可链接的了吗?
binarysmacker 2013年

@binarysmacer看看我在下面写的内容对您是否有意义。我试图从内到外描述问题。
椭圆视图

3
@binarysmacker现在对此进行评论为时已晚,但其他人可能会觉得这很有用。youtu.be/D0TazQIkc8Q基本上,您包括头文件,并且这些头文件通常仅包含变量/函数的声明,而没有定义,定义可能会出现在单独的源文件中。因此,预处理器仅包含声明而不是定义,这就是链接器帮助。您可以将使用变量/函数的源文件与定义它们的源文件链接起来。
卡兰·乔许

24

在标准方面:

  • 一个翻译单元是源文件,包括标头和源文件少任何源极线通过跳过条件包含预处理器指令的组合。

  • 该标准定义了翻译的9个阶段。前四个对应于预处理,后三个对应于编译,下一个对应于模板的实例化(生成实例化单元),最后一个对应于链接。

实际上,第八阶段(模板的实例化)通常在编译过程中完成,但是一些编译器将其延迟到链接阶段,而有些则将其扩展到两个阶段。


14
您能否列出所有9个阶段?我认为,这将是对答案的很好补充。:)
杰夫


@jalf,只需在@sbi指向的答案的最后阶段之前添加模板实例化。IIRC在处理宽字符时,在精确的措词上存在细微的差异,但我认为它们不会出现在图表标签中。
AProgrammer

2
@sbi是的,但这应该是FAQ问题,不是吗?所以,不应该将此信息可在这里?;)
杰夫

3
@AProgrammmer:简单地按名称列出它们会有所帮助。这样,人们便知道要获取更多详细信息的搜索内容。无论如何,无论如何都为您的答案+1 :)
jalf

14

瘦弱的是,CPU从内存地址加载数据,将数据存储到内存地址,并从内存地址中顺序执行指令,并在处理的指令序列中有一些条件性的跳转。这三种指令中的每一种都涉及计算要在机器指令中使用的存储单元的地址。由于机器指令的长度取决于所涉及的特定指令,并且由于我们在构建机器代码时将它们的可变长度串在一起,因此在计算和构建任何地址时都涉及两个步骤。

首先,在我们知道每个单元中到底发生了什么之前,我们会尽最大可能布置内存分配。我们找出字节,字或构成指令,文字和任何数据的任何东西。我们只是开始分配内存并建立将在运行时创建程序的值,并记下需要返回并固定地址的任何地方。在那个地方,我们放置了一个虚拟对象来填充该位置,以便我们可以继续计算内存大小。例如,我们的第一个机器代码可能占用一个单元格。下一个机器代码可能需要3个单元,其中包括一个机器代码单元和两个地址单元。现在我们的地址指针是4。我们知道机器单元中的内容,即操作码,但是我们必须等待计算地址单元中的内容,直到我们知道数据将位于何处,即

如果只有一个源文件,则编译器理论上可以在没有链接器的情况下生成完全可执行的机器代码。在两次通过过程中,它可以计算任何机器加载或存储指令所引用的所有数据单元的所有实际地址。它可以计算任何绝对跳转指令引用的所有绝对地址。这就是没有链接器的更简单的编译器(如Forth中的编译器)的工作方式。

链接器是允许单独编译代码块的工具。这可以加快构建代码的整个过程,并为以后使用这些块提供一些灵活性,换句话说,可以将它们重新放置在内存中,例如,将1000个添加到每个地址,以将块划分为1000个地址单元。

因此,编译器输出的是粗糙的机器代码,该机器代码尚未完全构建,但已被布局,因此我们知道所有内容的大小,换句话说,我们可以开始计算所有绝对地址的位置。编译器还会输出一个符号列表,这些符号是名称/地址对。这些符号将模块中机器代码中的内存偏移与名称相关联。偏移量是到模块中符号存储位置的绝对距离。

那就是我们到达链接器的地方。链接器首先将所有这些机器代码块首尾相接,并记下每个块的开始位置。然后,通过将模块内的相对偏移量与模块在较大布局中的绝对位置相加在一起,计算出要固定的地址。

显然,我对此进行了简化,因此您可以尝试掌握它,而我故意不使用目标文件,符号表等行话,这对我来说是造成混淆的一部分。


13

GCC通过4个步骤将C / C ++程序编译为可执行文件。

例如,gcc -o hello hello.c执行如下:

1.预处理

通过GNU C预处理程序(cpp.exe)进行预处理,其中包括标头(#include)和扩展宏(#define)。

cpp hello.c > hello.i

生成的中间文件“ hello.i”包含扩展的源代码。

2.编译

编译器将预处理的源代码编译为特定处理器的汇编代码。

gcc -S hello.i

-S选项指定产生汇编代码,而不是目标代码。生成的程序集文件为“ hello.s”。

3.组装

汇编器(as.exe)在目标文件“ hello.o”中将汇编代码转换为机器代码。

as -o hello.o hello.s

4.连结器

最后,链接器(ld.exe)将目标代码与库代码链接起来,以生成可执行文件“ hello”。

    ld -o hello hello.o ...图书馆...

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.