连接器如何处理跨翻译单位的相同模板实例化?

31

假设我有两个翻译单元:

foo.cpp

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

bar.cpp

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

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

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

  • 这两个实例有不同的名称吗?
  • 链接器会将它们视为重复项而删除吗?

标准规定每个翻译单元中都有函数的单独副本。然而,一些链接器可能提供优化功能以删除这些重复项。 - Cody Gray
2
我不是一名编译器的开发者,可能会有所偏差,但我相当确信构建环境(编译器+链接器)已经变得足够智能化,在链接时删除重复项。与模板相关的代码膨胀曾经是90年代的一个巨大问题,但现在不再是了。 - R Sahu
这两个实例有不同的名称吗?为什么会有呢 - 它们都指向相同的类型... - W.F.
1
@W.F. 我不知道它是如何工作的,这就是为什么我在问的原因。如果编译器使混淆名称唯一,那将是避免链接冲突的一种方式。 - sdgfsdh
查找COMDAT节。 - user784668
2
@CodyGray 标准绝对没有任何类似的说法。翻译单元是一个源文件。源文件没有任何模板实例的副本,它只是一个源,只包含文本。 - n. m.
3个回答

77
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宏的值是我们可以注入的跟踪器值。
接下来是一个源文件:

foo.cpp

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

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

这段代码定义了一个名为foo的函数,其中实例化了thing<unsigned>来定义t,并返回t.id()。作为具有外部链接的函数,实例化了thing<unsigned>foo的目的是:

  • 强制编译器进行实例化
  • 在链接中公开实例化,以便我们可以探测链接器对其的处理。

另一个源文件:

boo.cpp

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

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

这段文字是关于编程的,其中提到了一个名为“foo.cpp”的文件,而现在有一个类似于“foo.cpp”的文件,不同之处在于它定义了“boo”而非“foo”,并将“ID”设置为“0xb00”。下面还有一段程序源代码。

main.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分类为weak符号。 GNU链接器识别符号和符号。对于强符号,链接器只接受一个定义的连接。如果有多个,则会出现多重定义错误。但是对于弱符号,它将容忍任意数量的定义,并选择其中一个。如果一个弱定义符号在连接中也有(只有一个)强定义,则会选择强定义。如果一个符号有多个弱定义和没有强定义,则链接器可以任意选择一个弱定义。

指令:

.type   _ZNK5thingIjE2idEv, @function

thing<unsigned int>::id()分类为函数,而不是数据。

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

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

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

请再次查看boo.sthing<unsigned int>::id()的定义

    .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<T> 的实例化T = unsigned。在这个示例中,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

因此,链接器告诉我们程序从foo.o获取了_ZNK5thingIjE2idEv的定义,并在boo.o中调用它。
运行程序后,显示它是说真话:
./prog

f00
f00

“foo()”和“boo()”都返回thing<unsigned>().id()的值,foo.cpp中实例化。

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

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

链接器从foo.o中丢弃了函数节段.text._ZNK5thingIjE2idEv

这样就完成了整个过程。

编译器在每个翻译单元中发出每个实例化模板成员的弱定义,并将其放置在自己的函数节段中。当需要解析对弱符号的引用时,链接器只需在链接序列中选择第一个遇到的弱定义即可。因为每个弱符号都指向一个定义,所以任何一个 - 特别是第一个 - 都可以用来解析链接中符号的所有引用,其余的弱定义都是可有可无的。多余的弱定义必须被忽略,因为链接器只能链接给定符号的一个定义。并且多余的弱定义可以被链接器丢弃,而不会对程序造成任何影响,因为编译器将每个弱定义都放置在一个独立的链接节段中。

通过选择它看到的第一个弱定义,链接器实际上是随机选择的,因为链接目标文件的顺序是任意的。但只要我们跨多个翻译单元遵守ODR,这就没问题,因为如果我们这样做,那么所有弱定义确实是相同的。从头文件中#include-ing类模板的惯用做法(而不是在这样做时注入任何本地编辑)是一种相当健壮的遵守规则的方式。

1
优秀的回答! - aschepler
1
在 Stack Overflow 上,很多缺乏详细信息的答案却获得了更多的赞数。我已经尽力了,但还不够。 - user4581301
1
能否将模板特化作为强符号? - Amos
1
很棒的回答!除了得到一个即时的答案,我还从这篇帖子中学到了一些关于使用g++的技巧。 - Ziqi Fan
这可能意味着对于非模板函数,编译器会发出强定义? - FreelanceConsultant

6
不同的实现采用不同的策略来处理这个问题。例如,GNU编译器将模板实例标记为弱符号。然后在链接时,链接器可以丢弃所有定义但具有相同弱符号的内容,只保留一个。另一方面,Sun Solaris编译器在正常编译期间根本不实例化模板。然后,在链接时,链接器会收集完成程序所需的所有模板实例,并以特殊的模板实例化模式调用编译器。因此,每个模板都只生成一个实例。没有重复项需要合并或去除。每种方法都有其优点和缺点。

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

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