【问题标题】:How does the linker handle identical template instantiations across translation units?链接器如何跨翻译单元处理相同的模板实例化?
【发布时间】:2017-06-02 18:09:47
【问题描述】:

假设我有两个翻译单元:

foo.cpp

void foo() {
  auto v = std::vector<int>();
}

bar.cpp

void bar() {
  auto v = std::vector<int>();
}

当我编译这些翻译单元时,每个都会实例化std::vector&lt;int&gt;

我的问题是:这在链接阶段是如何工作的?

  • 两个实例是否有不同的重命名?
  • 链接器是否将它们作为重复项删除?

【问题讨论】:

  • 标准规定您在每个翻译单元中都有一个单独的函数副本。但是,某些链接器可能会提供删除这些重复项的优化。
  • 我不是编译器编写者,可能已经关闭,但我相当确定构建环境(编译器 + 链接器)已经变得足够聪明,可以在链接时删除重复项。与模板相关的代码膨胀在 90 年代曾经是一个巨大的问题,但现在已经不是了。
  • “两个实例化是否有不同的重命名?” 他们为什么要 - 他们都指的是同一个类型...
  • @W.F.我不知道它是如何工作的,这就是我问的原因。如果编译器使损坏的名称唯一,那么这将是避免链接冲突的一种方法。
  • @CodyGray 该标准绝对没有说任何类似的东西。翻译单元是源文件。源文件没有模板实例化的任何副本,它是一个源文件,它只包含文本。

标签: c++ templates linker


【解决方案1】:

C++ 要求 inline function definition 存在于引用该函数的翻译单元中。模板成员 函数是隐式内联的,但默认情况下也使用外部实例化 连锁。因此,当定义重复时,链接器将可见 同一个模板用不同的模板参数实例化 翻译单位。链接器如何处理这种重复是你的问题。

您的 C++ 编译器受制于 C++ 标准,但您的链接器不受制于 任何关于如何链接 C++ 的编纂标准:它本身就是一条法律, 植根于计算历史,对对象的源语言漠不关心 代码它链接。您的编译器必须使用目标链接器 可以并且将这样做,以便您可以成功链接您的程序并看到它们 你所期望的。因此,我将向您展示 GCC C++ 编译器如何与 GNU 链接器来处理不同翻译单元中的相同模板实例化。

这个演示利用了一个事实,虽然 C++ 标准要求 - 由One Definition Rule - 同一模板的不同翻译单元中的实例化 相同的模板参数应具有相同的定义,编译器 - 当然 - 不能对不同之间的关系强制执行任何要求 翻译单位。它必须信任我们。

所以我们会用不同的参数实例化同一个模板 翻译单元,但我们会通过注入宏观控制​​的差异来作弊 随后将显示的不同翻译单元中的实现 我们链接器选择了哪个定义。

如果您怀疑此作弊使演示无效,请记住:编译器 无法知道 ODR 是否曾经在不同的翻译单元中得到尊重, 所以它不能在那个帐户上表现不同,而且没有这样的事情 作为“欺骗”链接器。无论如何,演示会证明它是有效的。

首先我们有我们的作弊模板头:

thing.hpp

#ifndef THING_HPP
#define THING_HPP
#ifndef ID
#error ID undefined
#endif

template<typename T>
struct thing
{
    T id() const {
        return T{ID};
    }
};

#endif

ID的值就是我们可以注入的tracer值。

下一个源文件:

foo.cpp

#define ID 0xf00
#include "thing.hpp"

unsigned foo()
{
    thing<unsigned> t;
    return t.id();
}

它定义了函数foo,其中thing&lt;unsigned&gt;是 实例化定义t,并返回t.id()。通过成为一个函数 实例化thing&lt;unsigned&gt;foo 的外部链接服务于目的 的:-

  • 强制编译器进行实例化
  • 在链接中公开实例化,这样我们就可以探测 链接器使用它。

另一个源文件:

boo.cpp

#define ID 0xb00
#include "thing.hpp"

unsigned boo()
{
    thing<unsigned> t;
    return t.id();
}

这就像foo.cpp,除了它定义boo代替foo和 设置ID = 0xb00

最后是程序源:

ma​​in.cpp

#include <iostream>

extern unsigned foo();
extern unsigned boo();

int main()
{
    std::cout << std::hex 
    << '\n' << foo()
    << '\n' << boo()
    << std::endl;
    return 0;
}

这个程序将以十六进制打印foo()的返回值——我们的作弊应该做的 = f00 - 然后是 boo() 的返回值 - 我们的作弊应该做的 = b00

现在我们将编译 foo.cpp,我们将使用 -save-temps 来编译,因为我们想要 看看组装:

g++ -c -save-temps foo.cpp

这会将程序集写入foo.s,感兴趣的部分是 thing&lt;unsigned int&gt;::id() const的定义(mangled = _ZNK5thingIjE2idEv):

    .section    .text._ZNK5thingIjE2idEv,"axG",@progbits,_ZNK5thingIjE2idEv,comdat
    .align 2
    .weak   _ZNK5thingIjE2idEv
    .type   _ZNK5thingIjE2idEv, @function
_ZNK5thingIjE2idEv:
.LFB2:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movq    %rdi, -8(%rbp)
    movl    $3840, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc

顶部的三个指令很重要:

.section    .text._ZNK5thingIjE2idEv,"axG",@progbits,_ZNK5thingIjE2idEv,comdat

这个将函数定义放在它自己的链接部分中,称为 .text._ZNK5thingIjE2idEv 将被输出,如果需要,合并到 .text(即代码)链接目标文件的程序部分。一种 像这样的链接部分,即 .text.&lt;function_name&gt; 被称为 function-section。 这是一个代码部分,只包含函数&lt;function_name&gt;的定义。

指令:

.weak   _ZNK5thingIjE2idEv

至关重要。它将thing&lt;unsigned int&gt;::id() const 分类为weak 符号。 GNU 链接器识别 strong 符号和 weak 符号。对于一个强符号, 链接器将只接受链接中的一个定义。如果有更多,它将给出一个倍数 -定义错误。但是对于一个弱符号,它可以容忍任意数量的定义, 并选择一个。如果一个弱定义的符号在链接中也有(只有一个)强定义,那么 将选择强定义。如果一个符号有多个弱定义而没有强定义, 然后链接器可以任意选择任何一个弱定义。

指令:

.type   _ZNK5thingIjE2idEv, @function

thing&lt;unsigned int&gt;::id() 归类为引用一个函数——而不是数据。

然后在定义体中,在地址处组装代码 由弱全局符号_ZNK5thingIjE2idEv 标记,本地相同 标记为.LFB2。代码返回 3840 (= 0xf00)。

接下来我们用同样的方式编译boo.cpp

g++ -c -save-temps boo.cpp

再看看thing&lt;unsigned int&gt;::id()boo.s中是如何定义的

    .section    .text._ZNK5thingIjE2idEv,"axG",@progbits,_ZNK5thingIjE2idEv,comdat
    .align 2
    .weak   _ZNK5thingIjE2idEv
    .type   _ZNK5thingIjE2idEv, @function
_ZNK5thingIjE2idEv:
.LFB2:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movq    %rdi, -8(%rbp)
    movl    $2816, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc

除了我们的作弊之外,它是相同的:这个定义返回 2816 (= 0xb00)。

当我们在这里时,让我们注意一些可能不言而喻的事情: 一旦我们进入汇编(或目标代码),类就消失了。这里, 我们的目标是:-

  • 数据
  • 代码
  • 符号,可以标注数据或标注代码。

所以这里没有什么特别代表实例化 thing&lt;T&gt; for T = unsigned。在这种情况下,thing&lt;unsigned&gt; 剩下的就是 _ZNK5thingIjE2idEv a.k.a thing&lt;unsigned int&gt;::id() const 的定义。

所以现在我们知道编译器 对实例化thing&lt;unsigned&gt; 做了什么 在给定的翻译单元中。如果它必须实例化一个thing&lt;unsigned&gt; 成员函数,然后它组装实例化成员的定义 函数在一个弱全局符号上,它标识了成员函数,并且它 将此定义放入其自己的功能部分。

现在让我们看看链接器做了什么。

首先我们将编译主源文件。

g++ -c main.cpp

然后链接所有目标文件,请求在_ZNK5thingIjE2idEv 上进行诊断跟踪, 和一个链接映射文件:

g++ -o prog main.o foo.o boo.o -Wl,--trace-symbol='_ZNK5thingIjE2idEv',-M=prog.map
foo.o: definition of _ZNK5thingIjE2idEv
boo.o: reference to _ZNK5thingIjE2idEv

所以链接器告诉我们程序从 foo.o 并在boo.o调用它。

运行程序表明它说的是真话:

./prog

f00
f00

foo()boo() 都返回 thing&lt;unsigned&gt;().id() 的值 foo.cpp 中实例化。

thing&lt;unsigned int&gt;::id() const其他定义变成了什么 在boo.o?地图文件向我们展示了:

prog.map

...
Discarded input sections
 ...
 ...
 .text._ZNK5thingIjE2idEv
                0x0000000000000000        0xf boo.o
 ...
 ...

链接器丢弃了boo.o 中的函数部分 包含另一个定义。

现在让我们再次链接prog,但这次是在foo.oboo.o 逆序:

$ g++ -o prog main.o boo.o foo.o -Wl,--trace-symbol='_ZNK5thingIjE2idEv',-M=prog.map
boo.o: definition of _ZNK5thingIjE2idEv
foo.o: reference to _ZNK5thingIjE2idEv

这一次,程序从boo.o获取_ZNK5thingIjE2idEv的定义,并且 在foo.o 中调用它。该程序确认:

$ ./prog

b00
b00

地图文件显示:

...
Discarded input sections
 ...
 ...
 .text._ZNK5thingIjE2idEv
                0x0000000000000000        0xf foo.o
 ...
 ...

链接器丢弃了函数部分.text._ZNK5thingIjE2idEv 来自foo.o

这幅画就完成了。

编译器在每个翻译单元中发出一个弱定义 每个实例化的模板成员都在其自己的函数部分中。链接器 然后只选择它遇到的那些弱定义中的 first 在链接序列中需要解析弱引用时 象征。因为每个弱符号都针对一个定义,所以任何 其中一个 - 特别是第一个 - 可用于解析所有引用 链接中的符号,其余的弱定义是 消耗品。多余的弱定义必须被忽略,因为 链接器只能链接给定符号的一个定义。还有剩余 链接器可以丢弃弱定义,没有抵押 对程序造成损害,因为编译器将每一个都单独放在一个链接部分中。

通过选择它看到的 first 弱定义,链接器实际上是 随机选择,因为目标文件的链接顺序是任意的。 但这很好,只要我们遵守跨多个翻译单元的 ODR, 因为我们这样做了,所以所有的弱定义确实是相同的。 #include-从头文件的任何地方使用类模板(而不是在我们这样做时宏注入任何本地编辑)的通常做法是遵守规则的一种相当稳健的方式。

【讨论】:

  • 优秀的答案!
  • 在 Stack Overflow 上的信息量要少得多,而赞成票要多得多。我做了我能做的,但这还不够。
  • 是否可以让模板特化成为强符号?
【解决方案2】:

不同的实现为此使用不同的策略。

例如,GNU 编译器将模板实例标记为weak symbols。然后在链接时,链接器可以丢弃所有定义,但有一个相同的弱符号。

另一方面,Sun Solaris 编译器在正常编译期间根本不实例化模板。然后在链接时,链接器收集完成程序所需的所有模板实例化,然后继续并以特殊的模板实例化模式调用编译器。因此,为每个模板生成了一个实例。没有要合并或删除的重复项。

每种方法都有其优点和缺点。

【讨论】:

    【解决方案3】:

    当您有一个非模板类定义时,例如class Bar {...};,并且此类定义在标题中,即包含在多个翻译单元中。在编译阶段之后,您有两个具有两个定义的目标文件,对吧?你认为链接器会在你的最终二进制文件中为类创建两个二进制定义吗?当然,在链接阶段完成后,您在两个翻译单元中有两个定义,在最终二进制文件中有一个最终定义。这被称为链接崩溃,它不是标准强制的,标准只强制the ODR rule,这并没有说明链接器如何解决最终问题,这取决于链接器,但我见过的唯一方法是崩溃的解决方式。当然,链接器可以保留这两个定义,但我无法想象为什么,因为标准强制这些定义在语义上相同(有关更多详细信息,请参阅上面的 ODR 规则链接),如果不是,则程序格式错误.现在成像它不是Bar,而是std::vector&lt;int&gt;。在这种情况下,模板只是一种代码生成方式,其他一切都一样。

    【讨论】:

      猜你喜欢
      • 2011-12-01
      • 2015-06-04
      • 1970-01-01
      • 2016-02-16
      • 1970-01-01
      • 2017-06-19
      • 1970-01-01
      • 2015-08-11
      相关资源
      最近更新 更多