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

518

编译和链接过程是如何工作的?

(注:此文旨在成为Stack Overflow C++ FAQ的一部分。如果您想批评提供这种形式的FAQ的想法,那么meta上的这篇发帖就是该去的地方。那个问题的答案将在C++ chatroom中进行监控,FAQ的想法最初就是从那里开始的,所以您的回答很可能会被那些提出这个想法的人阅读。)

5个回答

690
C++程序的编译涉及三个步骤:
  1. 预处理:预处理器处理C++源代码文件,处理#include#define和其他预处理指令。此步骤的输出是一个"纯"的C++文件,不含预处理指令。

  2. 编译:编译器使用预处理器的输出生成目标文件。

  3. 链接:链接器使用编译器生成的目标文件生成库文件或可执行文件。

预处理

预处理器处理预处理指令,如#include#define。它对C++语法不加区分,因此必须小心使用。

它逐个处理C++源文件,在其中用相应文件(通常只包含声明)替换#include指令,替换宏(#define),并根据#if#ifdef#ifndef指令选择不同的文本部分。

预处理器操作的是一系列预处理令牌。宏替换定义为将一个令牌替换为另一个令牌(运算符##使两个令牌合并成一个有意义的令牌)。

在上述转换后,预处理器会生成一个单一的输出,其中包含一系列令牌。此外,它还添加了一些特殊标记,以告诉编译器每行代码的来源,以便编译器使用这些信息生成有意义的错误消息。

使用#if#error指令可以在此阶段产生某些错误。

编译

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

对象文件可以引用未定义的符号。当您使用声明但没有提供定义时,就会出现这种情况。编译器不会介意这一点,并愉快地生成对象文件,只要源代码格式良好即可。

编译器通常允许您在此时停止编译。这非常有用,因为您可以单独编译每个源代码文件。它提供的优势是,如果您只更改一个文件,则无需重新编译所有内容

生成的对象文件可以放入称为静态库的特殊存档中,以便稍后更轻松地重复使用。

在这个阶段,“常规”编译器错误,例如语法错误或失败的重载分辨率错误,将被报告。

链接

链接器是从编译器生成的对象文件中产生最终编译输出的工具。此输出可以是共享(或动态)库(虽然名称类似,但与前面提到的静态库没有太多共同之处),也可以是可执行文件。

它通过用正确的地址替换未定义符号的引用来链接所有对象文件。这些符号可以在其他对象文件或库中定义。如果它们在标准库以外的库中定义,您需要告诉链接器有关它们的信息。

在此阶段,最常见的错误是缺少定义或重复定义。前者意味着不存在定义(即它们没有写入),或者未将包含它们的对象文件或库提供给链接器。后者很明显:相同的符号在两个不同的对象文件或库中被定义。


50
编译阶段在转换为目标文件之前还会调用汇编器。 - manav m-n
4
优化应用在哪里?乍一看似乎应该在编译步骤中完成,但另一方面,我可以想象只有在链接之后才能进行适当的优化。 - Bart van Heukelom
8
传统上,这是在编译期间完成的,但现代编译器支持所谓的“链接时优化”,它具有跨翻译单元进行优化的优点。 - R. Martinho Fernandes
3
C语言有相同的步骤吗? - Kevin Zhu
7
如果链接器将指向库中类/方法的符号转换为地址,这是否意味着库二进制文件存储在操作系统保持不变的内存地址中?我对链接器如何知道所有目标系统的stdio二进制文件的精确地址感到困惑。文件路径始终相同,但确切的地址可能会改变,是吗? - Dan Carter
显示剩余7条评论

60
GCC 将 C/C++ 程序编译成可执行文件需要 4 个步骤。例如,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 ...libraries...

ld: 警告:找不到入口符号main;默认为0000000000400040 - 使用ld时出错。我的代码是一个helloworld。该过程在Ubuntu中完成。 - Amal lal T L

59

这个主题在CProgramming.com上有讨论:
https://www.cprogramming.com/compilingandlinking.html

以下是作者的原话:

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

编译

编译指的是处理源代码文件(.c、.cc或.cpp)并创建“对象”文件的过程。此步骤不会创建用户实际可以运行的任何东西。相反,编译器仅会生成与已编译的源代码文件相对应的机器语言指令。例如,如果您编译(但不链接)三个不同的文件,则将产生三个对象文件作为输出,每个文件的名称都为.o或.obj(扩展名取决于您的编译器)。这些文件中的每一个都包含源代码文件的翻译成机器语言文件的内容——但您现在还无法运行它们!您需要将它们转换为操作系统可以使用的可执行文件。这就是链接器的作用。

链接

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

您可能会问为什么有单独的编译和链接步骤。实际上,这种分离允许您更改源代码后进行部分重新编译,而不必重新编译整个程序。如果没有分开,每次更改都需要重新编译整个程序,这会花费大量时间。

首先,这种实现方式可能更容易。编译器进行编译操作,链接器进行链接操作——通过保持函数的独立性,减少了程序的复杂度。另一个(更显然的)优点是,这允许创建大型程序而无需每次更改文件时重新编译。相反,使用所谓的“条件编译”,只需要编译那些已更改的源文件;对于其余文件,目标文件就足够作为链接器的输入。最后,这使得实现预编译代码库非常简单:只需创建对象文件并像任何其他对象文件一样链接它们。(顺便说一下,每个文件都是从其他文件包含的信息中单独编译的,这被称为“分离式编译模型”)。
为了充分利用条件编译的好处,最好使用程序来帮助你,而不是尝试记住自上次编译以来更改了哪些文件。(当然,可以重新编译那些时间戳大于相应对象文件时间戳的文件。)如果您正在使用集成开发环境(IDE),它可能已经为您处理了这个问题。如果您正在使用命令行工具,则有一个很棒的实用程序叫做make,它附带在大多数*nix发行版中。除了条件编译外,它还具有编程的其他一些不错功能,例如允许编译您程序的不同版本——例如,如果您有一个版本用于调试生成详细输出。
了解编译阶段和链接阶段之间的区别可以更容易地查找错误。编译器错误通常是语法上的——缺少分号、多余的括号。链接错误通常与缺少或多个定义有关。如果您收到来自链接器的函数或变量定义多次的错误,则这表明您的两个源代码文件具有相同的函数或变量。

2
我不理解的是,如果预处理器管理 #include 之类的东西来创建一个超级文件,那么在此之后肯定就没有什么需要链接的了吧? - binarysmacker
@binarysmacer 请看看我下面写的是否有意义。我试图从内部描述问题。 - Elliptical view
8
@binarysmacker 这个评论已经太晚了,但其他人可能会发现这很有用。https://youtu.be/D0TazQIkc8Q 基本上,您需要包含头文件,这些头文件通常只包含变量/函数的声明而不是定义,定义可能存在于单独的源文件中。因此,预处理器仅包括声明而不包括定义,这就是链接器的作用所在。您需要将使用变量/函数的源文件与定义它们的源文件进行链接。 - Karan Joisher
抱歉打扰,从源代码文件到可执行文件的整个过程最好被称为“构建”。如果最终输出是静态库或动态库而不是可执行文件,术语“构建”仍然适用吗? - Second Person Shooter

27

在标准前沿:

  • 翻译单元是源文件、已包含的头文件以及通过条件预处理指令跳过的源代码行之间的组合。

  • 标准规定了9个翻译阶段。前4个对应预处理,接下来3个是编译,下一个是模板实例化(产生实例化单元),最后一个是链接。

实际上,第8个阶段(模板实例化)通常在编译过程中完成,但有些编译器会将其延迟到链接阶段,并且有些编译器会将其分布在这两个阶段中。


15
你能列出所有九个阶段吗?我认为这将是回答中很好的补充。 :) - jalf
2
@sbi 是的,但这应该是常见问题解答,不是吗?所以这些信息不应该在这里提供吗? ;) - jalf
@jalf,等我有时间的时候,我会尝试放上一个类似的图表(这将更好地突出多编译单元情况和模板处理)。但不要抱太大希望,用ASCII艺术清晰表达是我并不真正掌握的一种艺术。 - AProgrammer
@jalf:我同意。你为什么不发一篇详细解释的答案呢? - sbi
3
简单列出它们的名称会很有帮助。这样人们就知道如果想要更多细节,该搜索什么。无论如何,我已经给你的回答点赞了 :) - jalf
显示剩余2条评论

26
简单来说,CPU会从内存地址中加载数据、将数据存储到内存地址中,并按顺序执行内存地址中的指令,其中有些指令会有条件跳转。这三类指令都需要计算一个内存单元的地址,以便用于机器指令。
由于机器指令的长度取决于具体的指令类型,而我们在构建机器代码时需要将它们串联起来,因此计算和构建任何地址都需要进行两个步骤。
首先,我们尽可能地确定内存的分配情况,然后才能知道每个单元格究竟应该存储什么内容。我们要找出构成指令、字面量和任何数据的字节数或字数等内容,并不断分配内存并构建值,以创建程序,同时记录需要返回并修复地址的任何位置。在那个位置,我们将放置一个虚拟占位符来填充它,以便我们可以继续计算内存大小。例如,我们的第一个机器代码可能只占用一个单元格。下一个机器代码可能占用3个单元格,其中包括一个机器代码单元格和两个地址单元格。现在我们的地址指针为4。我们知道应该放入机器单元格的内容是操作码,但我们必须等到知道数据位于哪里(即该数据的机器地址是什么)才能计算应该放入地址单元格中的内容。
如果只有一个源文件,编译器理论上可以生成完全可执行的机器代码,而无需链接器。在两个步骤的过程中,它可以计算任何机器加载或存储指令引用的所有数据单元的实际地址。它还可以计算任何绝对跳转指令引用的所有绝对地址。这就是像Forth中的简单编译器如何工作的,没有链接器。
链接器是一种允许将代码块分别编译的东西。这可以加快构建代码的整个过程,并允许一些灵活性,例如可以重新定位内存中的代码块,例如将每个地址增加1000,以将块向上挪动1000个地址单元。因此,编译器输出的是粗糙的机器码,尚未完全构建,但已布置好,以便我们可以开始计算所有绝对地址的位置。编译器还会输出符号列表,其中包含名称/地址对。这些符号将模块中的内存偏移与名称相关联,偏移量是模块中符号内存位置的绝对距离。 这就是我们需要链接器的地方。链接器首先将所有这些机器代码块拼接在一起,并记录每个代码块的起始位置。然后通过将模块内部的相对偏移和模块在更大布局中的绝对位置相加来计算要修正的地址。 显然,我为了让你理解,已经将其过度简化,并故意没有使用目标文件、符号表等术语,因为它们是混淆的一部分。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接