假设我有两个翻译单元:
foo.cpp
void foo() {
auto v = std::vector<int>();
}
bar.cpp
void bar() {
auto v = std::vector<int>();
}
当我编译这些翻译单位时,每个都会实例化std::vector<int>
。
我的问题是:在链接阶段,这是如何工作的?
- 这两个实例有不同的名称吗?
- 链接器会将它们视为重复项而删除吗?
假设我有两个翻译单元:
foo.cpp
void foo() {
auto v = std::vector<int>();
}
bar.cpp
void bar() {
auto v = std::vector<int>();
}
当我编译这些翻译单位时,每个都会实例化std::vector<int>
。
我的问题是:在链接阶段,这是如何工作的?
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();
}
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.s
中thing<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
./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.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
...
...
链接器从foo.o中丢弃了函数节段.text._ZNK5thingIjE2idEv
。
这样就完成了整个过程。
编译器在每个翻译单元中发出每个实例化模板成员的弱定义,并将其放置在自己的函数节段中。当需要解析对弱符号的引用时,链接器只需在链接序列中选择第一个遇到的弱定义即可。因为每个弱符号都指向一个定义,所以任何一个 - 特别是第一个 - 都可以用来解析链接中符号的所有引用,其余的弱定义都是可有可无的。多余的弱定义必须被忽略,因为链接器只能链接给定符号的一个定义。并且多余的弱定义可以被链接器丢弃,而不会对程序造成任何影响,因为编译器将每个弱定义都放置在一个独立的链接节段中。
通过选择它看到的第一个弱定义,链接器实际上是随机选择的,因为链接目标文件的顺序是任意的。但只要我们跨多个翻译单元遵守ODR,这就没问题,因为如果我们这样做,那么所有弱定义确实是相同的。从头文件中#include
-ing类模板的惯用做法(而不是在这样做时注入任何本地编辑)是一种相当健壮的遵守规则的方式。class Bar {...};
,并且此类在头文件中定义,在多个翻译单位中包含。编译阶段后,您会得到两个对象文件和两个定义,对吗?您认为链接器会在最终二进制文件中创建两个该类的二进制定义吗?当然,在两个翻译单元中有两个定义,并且在连接阶段完成后,最终二进制文件中有一个最终定义。这称为链接折叠,它不是标准强制的,标准仅强制执行 ODR规则,该规则不说明链接器如何解决最终问题,由链接器决定,但我看到的唯一方式是折叠解决方法。当然,链接器可以保留两个定义,但我无法想象为什么,因为标准强制这些定义在语义上相同(有关详细信息,请参见上面的ODR规则链接),如果不是程序就是不正确的。现在想象一下,它不是Bar
而是std::vector<int>
。在这种情况下,模板只是代码生成的一种方式,其他一切都是相同的。