【问题标题】:How does the compilation/linking process work?编译/链接过程如何工作?
【发布时间】:2021-02-13 13:19:29
【问题描述】:

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

(注意:这是Stack Overflow's C++ FAQ 的一个条目。如果您想批评以这种形式提供常见问题解答的想法,那么the posting on meta that started all this 将是这样做的地方。该问题的答案在C++ chatroom,FAQ 想法最初是从这里开始的,所以你的答案很可能会被提出这个想法的人阅读。)

【问题讨论】:

    标签: c++ compiler-construction linker c++-faq


    【解决方案1】:

    一个C++程序的编译包括三个步骤:

    1. 预处理:预处理器采用 C++ 源代码文件并处理 #includes、#defines 和其他预处理器指令。此步骤的输出是没有预处理器指令的“纯”C++ 文件。

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

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

    预处理

    预处理器处理预处理器指令,例如#include#define。它与 C++ 的语法无关,因此必须小心使用。

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

    预处理器处理预处理标记流。宏替换被定义为用其他标记替换标记(运算符## 允许在有意义时合并两个标记)。

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

    在这个阶段巧妙地使用#if#error 指令可能会产生一些错误。

    编译

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

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

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

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

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

    链接

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

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

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

    【讨论】:

    • 编译阶段在转换为目标文件之前也会调用汇编器。
    • 优化应用在哪里?乍一看,它似乎是在编译步骤中完成的,但另一方面我可以想象,只有在链接之后才能进行适当的优化。
    • @BartvanHeukelom 传统上它是在编译期间完成的,但现代编译器支持所谓的“链接时优化”,其优势在于能够跨翻译单元进行优化。
    • C有相同的步骤吗?
    • 如果链接器将引用库中类/方法的符号转换为地址,这是否意味着库二进制文件存储在操作系统保持不变的内存地址中?我只是对链接器如何知道所有目标系统的 stdio 二进制文件的确切地址感到困惑。文件路径总是一样的,但确切的地址可以改变,对吧?
    【解决方案2】:

    在标准正面:

    • 翻译单元是源文件、包含的头文件和源文件的组合,减去条件包含预处理器指令跳过的任何源代码行。

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

    在实践中,第八阶段(模板的实例化)通常在编译过程中完成,但有些编译器将其延迟到链接阶段,有些则将其分散在两个阶段。

    【讨论】:

    • 您能列出所有 9 个阶段吗?我认为,这将是对答案的一个很好的补充。 :)
    • @jalf,只需在@sbi 指出的答案的最后阶段之前添加模板实例化。 IIRC 在处理宽字符时的精确措辞存在细微差别,但我认为它们不会出现在图表标签中。
    • @sbi 是的,但这应该是常见问题解答问题,不是吗?那么这些信息不应该在这里提供吗? ;)
    • @AProgrammmer:简单地按名称列出它们会很有帮助。然后人们知道如果他们想要更多详细信息要搜索什么。无论如何,无论如何,+1 了你的答案:)
    【解决方案3】:

    CProgramming.com 讨论了这个话题:
    https://www.cprogramming.com/compilingandlinking.html

    这里是作者写的:

    编译与创建可执行文件并不完全相同! 相反,创建可执行文件是一个多阶段过程,分为 两个组件:编译和链接。实际上,即使一个程序 “编译得很好”它可能实际上无法正常工作,因为在 链接阶段。从源代码文件去的全过程 将可执行文件称为构建可能会更好。

    编译

    编译是指对源代码文件(.c、.cc 或 .cpp) 和“对象”文件的创建。此步骤不创建 用户实际可以运行的任何东西。相反,编译器只是 产生对应的机器语言指令 编译好的源代码文件。例如,如果你编译(但是 不要链接)三个单独的文件,您将拥有三个目标文件 创建为输出,每个名称为 .o 或 .obj (扩展名取决于您的编译器)。这些文件中的每一个 包含您的源代码文件到机器的翻译 语言文件——但你还不能运行它们!你需要转动它们 到您的操作系统可以使用的可执行文件中。那就是 链接器进来了。

    链接

    链接是指从 多个目标文件。在这一步中,链接器通常会 抱怨未定义的函数(通常是 main 本身)。期间 编译,如果编译器找不到 特定的功能,它只是假设该功能是 在另一个文件中定义。如果不是这种情况,就没有办法 编译器会知道——它不会查看超过 一次一个文件。另一方面,链接器可能会查看 多个文件并尝试查找函数的引用 没有提到。

    您可能会问为什么有单独的编译和链接步骤。 首先,以这种方式实现事情可能更容易。编译器 做它的事情,链接器做它的事情——通过保持 功能分离,降低了程序的复杂性。其他 (更明显的)优点是这允许创建大型 程序,而不必每次文件都重做编译步骤 被改变。相反,使用所谓的“条件编译”,它是 只需要编译那些已更改的源文件;为了 其余的,目标文件是链接器的足够输入。 最后,这使得实现预编译库变得简单 代码:只需创建目标文件并像其他任何文件一样链接它们 目标文件。 (事实上​​,每个文件都是单独编译的 顺便说一下,包含在其他文件中的信息称为 “单独编译模型”。)

    要获得条件编译的全部好处,可能是 获得帮助您的程序比尝试记住哪个程序更容易 自上次编译后更改的文件。 (你当然可以, 只需重新编译时间戳大于 相应对象文件的时间戳。)如果您正在使用 它可能已经处理了集成开发环境 (IDE) 这是给你的。如果您使用的是命令行工具,那就很不错了 大多数 *nix 发行版附带的名为 make 的实用程序。沿着 通过条件编译,它还有其他几个不错的功能 编程,例如允许对您的程序进行不同的编译 -- 例如,如果您有一个版本产生详细的调试输出。

    了解编译阶段和链接的区别 阶段可以更容易地寻找错误。编译器错误通常是 语法本质上——缺少分号,额外的括号。 链接错误通常与丢失或多个有关 定义。如果你得到一个函数或变量的错误 从链接器定义了多次,这很好地表明 错误是您的两个源代码文件具有相同的功能 或变量。

    【讨论】:

    • 我不明白的是,如果预处理器管理诸如#includes 之类的东西来创建一个超级文件,那么在那之后就没有什么可以链接了吗?
    • @binarysmacer 看看我在下面写的内容对你是否有意义。我试图从里到外描述问题。
    • @binarysmacker 对此发表评论为时已晚,但其他人可能会发现这很有用。 youtu.be/D0TazQIkc8Q 基本上你包含头文件,这些头文件通常只包含变量/函数的声明而不包含定义,定义可能存在于单独的源文件中。所以预处理器只包括声明而不包括定义,这是链接器帮助的地方.您将使用变量/函数的源文件与定义它们的源文件链接起来。
    • 抱歉打断:“从源代码文件到可执行文件的整个过程最好称为构建。”,最终输出是静态库的情况怎么样还是动态库而不是可执行文件? “构建”这个词还合适吗?
    【解决方案4】:

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

    首先,在我们知道每个单元格中的确切内容之前,我们尽可能地布置内存分配。我们找出字节,或单词,或任何构成指令、文字和任何数据的东西。我们只是开始分配内存并构建将创建程序的值,并记下我们需要返回并修复地址的任何地方。在那个地方,我们放了一个假人来填充这个位置,这样我们就可以继续计算内存大小。例如,我们的第一个机器代码可能需要一个单元格。下一个机器代码可能需要 3 个单元,涉及一个机器代码单元和两个地址单元。现在我们的地址指针是 4。我们知道机器单元中的内容,即操作码,但是我们必须等待计算地址单元中的内容,直到我们知道该数据将位于何处,即将是什么该数据的机器地址。

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

    链接器允许单独编译代码块。这可以加快构建代码的整个过程,并允许以后如何使用块具有一定的灵活性,换句话说,它们可以在内存中重新定位,例如将 1000 添加到每个地址以将块向上移动 1000 个地址单元。

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

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

    很明显,我把它过分简化了,所以你可以试着理解它,而且我故意不使用目标文件、符号表等术语,这对我来说是混乱的一部分。

    【讨论】:

      【解决方案5】:

      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 ...库...

      【讨论】:

      • ld:警告:找不到入口符号 main;默认为 0000000000400040 - 使用 ld 时出错。我的代码是一个helloworld。该过程在 Ubuntu 中完成。
      猜你喜欢
      • 2014-11-14
      • 2013-05-03
      • 1970-01-01
      • 1970-01-01
      • 2012-09-30
      相关资源
      最近更新 更多