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<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,除了它定义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的定义(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.<function_name> 被称为 function-section。
这是一个代码部分,只包含函数<function_name>的定义。
指令:
.weak _ZNK5thingIjE2idEv
至关重要。它将thing<unsigned int>::id() const 分类为weak 符号。
GNU 链接器识别 strong 符号和 weak 符号。对于一个强符号,
链接器将只接受链接中的一个定义。如果有更多,它将给出一个倍数
-定义错误。但是对于一个弱符号,它可以容忍任意数量的定义,
并选择一个。如果一个弱定义的符号在链接中也有(只有一个)强定义,那么
将选择强定义。如果一个符号有多个弱定义而没有强定义,
然后链接器可以任意选择任何一个弱定义。
指令:
.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)。
当我们在这里时,让我们注意一些可能不言而喻的事情:
一旦我们进入汇编(或目标代码),类就消失了。这里,
我们的目标是:-
所以这里没有什么特别代表实例化 thing<T> for
T = unsigned。在这种情况下,thing<unsigned> 剩下的就是
_ZNK5thingIjE2idEv a.k.a 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 并在boo.o 中调用它。
运行程序表明它说的是真话:
./prog
f00
f00
foo() 和 boo() 都返回 thing<unsigned>().id() 的值
在 foo.cpp 中实例化。
thing<unsigned int>::id() const 的其他定义变成了什么
在boo.o?地图文件向我们展示了:
prog.map
...
Discarded input sections
...
...
.text._ZNK5thingIjE2idEv
0x0000000000000000 0xf boo.o
...
...
链接器丢弃了boo.o 中的函数部分
包含另一个定义。
现在让我们再次链接prog,但这次是在foo.o 和boo.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-从头文件的任何地方使用类模板(而不是在我们这样做时宏注入任何本地编辑)的通常做法是遵守规则的一种相当稳健的方式。