链接器如何处理跨翻译单元的相同模板实例化

How does the linker handle identical template instantiations across translation units?

本文关键字:单元 实例化 翻译 何处理 处理 链接      更新时间:2023-10-16

假设我有两个翻译单元:

傅.cpp

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

酒吧.cpp

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

当我编译这些翻译单元时,每个单元都会实例化std::vector<int>.

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

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

C++要求内联函数定义 出现在引用函数的翻译单元中。模板成员 函数是隐式内联的,但默认情况下也使用外部实例化 联动。因此,链接器在以下情况下可以看到的重复定义 同一模板在不同的模板参数中实例化 翻译单位。链接器如何处理这种重复是您的问题。

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

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

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

如果您怀疑此作弊使演示无效,请记住:编译器 无法知道 ODR 是否在不同翻译单位中得到遵守, 所以它不能在这个帐户上表现不同,也没有这样的事情 作为"欺骗"链接器。无论如何,演示将证明它是有效的。

首先,我们有作弊模板标头:

东西.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的值是我们可以注入的示踪剂值。

接下来是源文件:

傅.cpp

#define ID 0xf00
#include "thing.hpp"
unsigned foo()
{
thing<unsigned> t;
return t.id();
}

它定义了函数foo,其中thing<unsigned>是 实例化以定义t,并返回t.id()。通过成为具有以下功能的函数 实例化thing<unsigned>的外部链接,foo服务于目的 之:-

  • 强制编译器完全实例化
  • 在链接中公开实例化,以便我们可以探测 链接器用它做。

另一个源文件:

嘭.cpp

#define ID 0xb00
#include "thing.hpp"
unsigned boo()
{
thing<unsigned> t;
return t.id();
}

这就像foo.cpp一样,只是它定义了boo代替foo和 集合ID=0xb00

最后是程序源:

主.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<unsigned int>::id() const的定义(损坏 =_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.<function_name>称为函数部分。 这是一个代码部分,包含函数<function_name>的定义。

该指令:

.weak   _ZNK5thingIjE2idEv

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

该指令:

.type   _ZNK5thingIjE2idEv, @function

thing<unsigned int>::id()归类为引用函数- 而不是数据。

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

接下来,我们将以相同的方式编译boo.cpp

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

再看看thing<unsigned int>::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)。

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

  • 数据
  • 法典
  • 符号,可以标记数据或标记代码。

所以这里没有什么具体代表T = unsignedthing<T>实例化。在这种情况下,thing<unsigned>剩下的就是_ZNK5thingIjE2idEv又名thing<unsigned int>::id() const的定义。

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

现在让我们看看链接器的作用。

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

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

因此,链接器告诉我们程序从中获取_ZNK5thingIjE2idEv的定义foo.o并在boo.o调用它.

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

./prog
f00
f00

foo()boo()都返回thing<unsigned>().id()的值如实例化foo.cpp.

thing<unsigned int>::id() const的另一个定义变成了什么 在boo.o?地图文件向我们展示了:

进度地图

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

这一次,程序_ZNK5thingIjE2idEvboo.o和 用foo.o称呼它。该计划确认:

$ ./prog
b00
b00

地图文件显示:

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

链接器删除了功能部分.text._ZNK5thingIjE2idEvfoo.o.

这幅画就完成了。

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

通过选择它看到的第一个弱定义,链接器有效地 随机选取,因为对象文件的链接顺序是任意的。 但这很好,只要我们遵守多个翻译单元的 ODR, 因为我们这样做,那么所有弱定义确实是相同的。通常的做法是从头文件的所有位置#include类模板(并且在我们这样做时不宏注入任何本地编辑)是一种相当健壮的遵守规则的方式。

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

例如,GNU 编译器将模板实例化标记为弱符号。然后在链接时,链接器可以抛弃除除相同弱符号之一之外的所有定义。

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

每种方法都有自己的优点和缺点。

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