从符号表深入理解C++
对于编译器而言,符号的全部相关数据都在Elf32_Sym或Elf64_Sym,以Elf32_Sym为例了解一些编译器的知识,然后再分析C++的语法
1 | typedef struct |
st_info这个结构是用于区分各种类型的,也是这一篇博文的重点,这个char结构8位
高 4 位用于表示
st_bind
:值 静态链接行为 STB_LOCAL
允许在不同的目标文件中,存在多个相同名字的符号,符号之间相互隔离(无关联)。 STB_GLOBAL
只允许一个目标文件存在 GLOBAL
符号定义,其它目标文件允许相同名字的未定义的符号引用,即不允许多个定义。STB_WEAK
允许在不同的目标文件中存在多个相同名字的符号。具体来说,如果同时存在一个 GLOBAL
符号和其它同名WEAK
符号的定义,那就挑选GLOBAL
符号(ignores the weak ones);如果存在多个同名WEAK
符号的定义,事实标准是挑选 linker 解析过程中接触到的第一个符号。
我这里特意强调了静态链接行为,因为静态链接和动态链接虽然对LOCAL的逻辑是一致的,但是动态链接器在符号重定位的时候,对GLOBAL和WEAK是一视同仁的
低4位用于表示
st_type
:STT_FUNC
: The symbol is associated with a function or other executable code.STT_OBJECT
: The symbol is associated with a data object.STT_SECTION
: The symbol is a section.
这就比较好理解:函数,变量或者段
全局变量和函数
实验代码包含了4个变量和5个函数(变量没有inline):
1 | int globalValue = 1; |
C的编译结果如下:
1 | ~ gcc -c test.c |
在C语法中:
- static类型的函数和变量会变成LOCAL(多个定义不会有问题),啥都不写的函数和变量是GLOBAL(多个定义会冲突)
- inline类型的函数对C来说和extern含义是一样的,会产生UND标记
- 正常情况下不会产生weak符号,除非使用
__attribute__((weak))
C++的编译结果如下:
1 | ~ g++ -c test.c |
在C++语法中:
首先函数符号的样式全变了,可以使用
c++filt
还原,但是变量除了static还保持原样(没什么卵用的冷知识+1)inline函数的符号不再和extern一样,而是和使用
__attribute__((weak))
声明是一样的和
__attribute__((weak))
声明的区别是使用场景上,必须调用才会出现符号
类
实验代码包含了2种变量和4种函数,非static变量没有inline属性所以少一个用例,函数多一个定义在类外的主动声明inline,所以多一个:
1 | struct foo { |
编译结果如下:
1 | ~ g++ -c testClass.cpp |
- 变量上只有static类型的,才会产生GLOBAL符号,否则作为局部变量会在堆栈上创建
- 类内声明和定义的函数自带inline(包括static成员函数),因此也需要调用才会出现符号,性质和普通全局inline函数一致
函数内静态变量
testFuncStaticValue.cpp:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void globalFunc() {
static int value = 1;
value = 2; //静态变量必须使用一次才会有符号
}
inline void inlineFunc() {
static int value = 1;
value = 2;
}
static void staticFunc() {
static int value = 1;
value = 2;
}
void test() {
inlineFunc(); //inline属性必须调用才会有符号
}main.cpp:
1
2
3
4
5
6
7
8
9
10
11
12
13//void globalFunc() 不能定义,会发生重定义报错
inline void inlineFunc() {
static int value = 1;
value = 2; //静态变量必须使用一次才会有符号
}
static void staticFunc() {
static int value = 1;
value = 2;
}
int main() {
inlineFunc(); //inline属性必须调用才会有符号
}
编译结果如下:
1 | ~ g++ -c testFuncStaticValue.cpp |
函数内静态变量的属性是和函数本身相关的:
对于默认情况(没有inline或static)的函数而言,会生成LOCAL属性的变量,由于函数本身不能重定义,所以虽然是LOCAL,也只会有一个实例
对于static的函数而言,会生成LOCAL属性的变量,编译后会有多个实例
对于inline的函数而言,会生成UNIQUE属性的变量,全称STB_GNU_UNIQUE
这个变量类型作为GNU独属,是之前没有讨论过的,它的争议很大,作为全局唯一的标识,在下面一节介绍
这里还带来一个关于静态库和动态库的问题:
LOCAL属性也意味着动态或静态链接库中的默认情况函数静态变量是不能对外导出的,只有通过设置inline属性,将会设定为跨库唯一的符号
- 要么inline,对外导出并全局唯一
- 要么static或者空着,不对外导出并全局多个副本
不存在既不导出,又全局唯一的情况,一旦选择了inline,使用
RTLD_LOCAL
方式装载也只允许一个实例!
STB_GNU_UNIQUE
搜索这个关键词可以看到很多吐槽,例如
Patching STB_GNU_UNIQUE of Buggy Binaries
STB_GNU_UNIQUE
就是 ELF 中一个不太好的设计, 带来了不少语义冲突. 拥有STB_GNU_UNIQUE
binding 的符号, 即使在被用RTLD_LOCAL
方式装载的时候, 也会拥有 global linkage. 另外它还会导致 dlclose 无效这个 binding 最初的引入似乎是由于一些全局符号的内在状态不能重复多次, 因此把这些符号标记为 unique, 即使从多个 plugins 里装载了多次, 符号也只有一个定义. 但是另一方面, 程序也会有一些全局符号的状态必须是 local 的. 到底哪种行为是用户需要的, 编译器是不知道的. 结果是, gcc "聪明" 的自动把 template function & inline function 里的 static variable 标记为了 unique
static link stdc++ without STB_GNU_UNIQUE cause memory leak when dlclose
https://news.ycombinator.com/item?id=21555752
The GNU executable and linking model has a ton of warts and I wish we could start from scratch. I recently found out about another ugly wart: STB_GNU_UNIQUE. Over a decade ago, someone noticed programs crashing due to symbol collisions causing use-after-free bugs in std::string, so instead of fixing the symbol collision, this person added a flag (STB_GNU_UNIQUE) that would force the colliding symbols ("vague linkage" ones) to be merged even among different libraries loaded with RTLD_LOCAL. Later, someone noticed that due to this merging, if you unloaded a library that exported a STB_GNU_UNIQUE symbol, other, unrelated libraries would crash. The awful solution to that problem was just to make these STB_GNU_UNIQUE libraries immune to dlclose.
GNU 可执行文件和链接模型有很多缺陷,我希望我们可以从头开始。我最近发现了另一个丑陋的疣:STB_GNU_UNIQUE。十多年前,有人注意到由于符号冲突导致程序崩溃,导致 std::string 中的释放后使用错误,因此该人没有修复符号冲突,而是添加了一个标志(STB_GNU_UNIQUE)来强制冲突符号(“模糊链接”)甚至可以在加载 RTLD_LOCAL 的不同库之间进行合并。后来,有人注意到,由于这种合并,如果您卸载导出 STB_GNU_UNIQUE 符号的库,其他不相关的库将会崩溃。这个问题的糟糕解决方案就是让这些 STB_GNU_UNIQUE 库不受 dlclose 的影响。
总而言之,为了让inline属性的函数静态变量在多个动态库中依然只有一个实例,新增了STB_GNU_UNIQUE,这个特性会导致动态库无法被卸载
解决办法是-fno-gnu-unique
,或者自己对库进行符号替换
-fno-gnu-unique On systems with recent GNU assembler and C library, the C++ compiler uses the "STB_GNU_UNIQUE" binding to make sure that definitions of template static data members and static local variables in inline functions are unique even in the presence of "RTLD_LOCAL"; this is necessary to avoid problems with a library used by two different "RTLD_LOCAL" plugins depending on a definition in one of them and therefore disagreeing with the other one about the binding of the symbol. But this causes "dlclose" to be ignored for affected DSOs; if your program relies on reinitialization of a DSO via "dlclose" and "dlopen", you can use -fno-gnu-unique.
模板
同一个模板在每个用到的编译单元都会进行实例化,最后重复的实例会在链接阶段去重
标准中设定模板函数默认为inline属性,虽然可以设置为static,但是将其设置为static没有任何意义。
Why use static function template?
For template functions, I'd say that using
static
,inline
or no keyword produce the same result; in fact, in all cases the function will be inlined and no multiple definition error will be raised. 对于模板函数,我想说使用static
、inline
或不使用关键字会产生相同的结果;事实上,在所有情况下,函数都会被内联,并且不会引发多重定义错误。模板的每个实例都会有自己的static静态变量
Static variable inside template function
The ODR (One Definition Rule) says at 3.2/5 in the Standard, where D stands for the non-static function template (cursive font by me)
你可以依赖这个。ODR(One Definition Rule,一次定义规则)在标准的3.2/5处提到,其中D代表非静态的函数模板
If D is a template, and is defined in more than one translation unit, then the last four requirements from the list above shall apply to names from the template’s enclosing scope used in the template definition (14.6.3), and also to dependent names at the point of instantiation (14.6.2). If the definitions of D satisfy all these requirements, then the program shall behave as if there were a single definition of D. If the definitions of D do not satisfy these requirements, then the behavior is undefined.
如果D是一个模板,并且在多于一个的翻译单元中定义,那么上述列表中的最后四个要求应应用于在模板定义中用到的来自模板封装作用域的名字(14.6.3),以及在实例化点的依赖名字(14.6.2)。如果D的定义满足所有这些要求,那么程序将表现得好像D只有一个定义。如果D的定义不满足这些要求,那么行为是未定义的。
Of the last four requirements, the two most important are roughly
在最后四个要求中,最重要的两个大概是:
each definition of D shall consist of the same sequence of tokens
模板的每个定义应该由相同的代码组成。也就是说,不同代码文件中的模板定义应该完全相同。
names in each definition shall refer to the same things ("entities")
定义中使用的名字,例如变量名、类型名等,应该指向相同的事情。例如,如果你在一个文件中的模板定义中使用了名字
foo
来指向一个变量,在另一个文件中的定义也应该使用foo
来指向同一个变量,而不是一个不相关的变量或函数。
编译器实现(静态链接)
具体来说:C++编译采用包含模型(inclusion
model),大多数编译器在进行模板具现化时采用贪婪实例化(greedy
instantiaion)。这将导致一个模板的相同具现化可能出现在多个翻译单元。如果将这些模板具现化产生的符号都认为是GLOBAL
类型的符号的话,会导致出现multiple
definition 的链接错误。
现在主流编译器采用的策略是:将每个模板的具现化都单独放在一个segment中。当链接器在链接过程中遇到多个相同的因模板具现化而产生的段时,选择其中一个,丢弃其他的。
简单编写代码来验证编译器的具体实现,在两个cpp文件里面引用同一个模板,编译成目标文件后,看下符号表
本文没有讨论动态链接,因为模板第三方库大多是headonly的
代码如下:
common.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T>
void foo() {
static char s[] = "";
puts(s);
}
template <typename>
struct test {
void hello() {
}
};main.cpp
1
2
3
4
5
6
7
int main() {
foo<int>();
test<int> t;
t.hello();
}other.cpp
1
2
3
4
5
6
7
void other() {
foo<int>();
test<int> t;
t.hello();
}
编译目标文件
1 | gcc -c main.cpp other.cpp |
分别查看符号表,可以发现模板函数是一个弱符号,相当于自带了inline,根据函数内静态变量,带上inline后,它的静态变量是UNIQUE属性,并且函数和静态变量值都为0:
1 | ~ readelf -s other.o |
分别查看段表,可以发现这些符号被独立出来了section,并且标记了Flags
1 | ~ readelf -S other.o |
查阅文档的含义
A
(Alloc): 表示段将被加载到内存中。X
(Execute): 可执行代码段。I
(INFO LINK): 表示这个重定位段与一个符号表关联(可能是包含符号表索引的段)。G
(Group): 属于一个特定的段分组。
和其他段相比,他多了一个组属性,readelf有专门的指令可以查看组
1 | ~ readelf -g other.o |
其中COMDAT是GROUP段的Flag,目前有且仅有一种,也就是COMDAT。COMDAT表示这个group可能和另一个目标文件中的COMDAT group重复。当重复发生时,只有其中一个group可以被留下,其他都会被丢弃。如果链接器决定丢弃一个group,那么group中的所有成员就也将被丢弃。
.text._Z3fooIiEvv
和.rela.text._Z3fooIiEvv
在同一个组,这是因为根据汇编代码(main.o也一样,省略了),因为一方需要另外一个的重定位静态变量信息:
1 | ~ objdump -S other.o |
当.text._Z3fooIiEvv
这个段不在了,那么重定位段也没必要存在了
链接可执行文件
1 | ~ gcc other.o main.o -o main |
观察符号表
1 | ~ readelf -s main|grep foo |
可以发现此时每个符号只出现了一次,并且值都有了,而group段此时已经不见了
1 | ~ readelf -g main |
特化模板编译
特化模板函数并不是一个模板,而是一个实际的函数,需要手动添加inline才可以防止重定义
根据类可知,类内定义的成员函数自带inline,不会重定义,类外的则需要
实验代码如下:
common.h
1
2
3
4
5
6
7
template <typename>
void foo() {}
template <typename>
struct test {};main.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <>
void foo<int>() {}
template <>
struct test<int> {
void hello() {}
};
int main() {
test<int> t;
t.hello(); //类内定义的成员函数inline,因此需要使用才会有符号
//test<int>(); //特化普通函数不用
}other.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <>
void foo<int>() {}
template <>
struct test<int> {
void hello() {}
};
void test_foo() {
test<int> t;
t.hello(); //类内定义的成员函数inline,因此需要使用才会有符号
//test<int>(); //特化普通函数不用
}
编译和链接报错:
1 | ~ gcc -c main.cpp other.cpp |
可以发现,对模板类特化的inline属性的foo函数是没有任何报错信息的
相反,对于特化普通函数foo<int>
来说,出现了报错,然后看看符号
1 | ~ readelf -s other.o |
可以发现对模板函数foo<int>
声明为了GLOBAL,因此报错;而对模板类函数声明成了WEAK,因此没有报错
并且结合在上一节编译目标文件的内容,可以发现test<int>::hello()
特化模板类它依然是普通模板去重逻辑,会产生组section
1 | ~ readelf -g other.o |
加速编译
根据上面的测试,可以发现编译缓慢的最大原因是对模板类,每个文件都需要实例化一次,那么有没有办法只在一个目标文件中实例化,其他目标文件等链接的时候直接使用呢
声明和定义分离
很容易想到将声明和定义分离来解决这个问题
common.h
1
2
3
4
5
6
7
8
9
template <typename>
void foo();
template <typename>
struct test {
void hello();
};main.cpp
1
2
3
4
5
6
7
int main() {
foo<int>();
test<int> t;
t.hello();
}other.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename>
void foo() {
}
template <typename T>
void test<T>::hello() {
}
void other() {
foo<int>();
test<int> t;
t.hello();
}
编译和链接:
1 | ~ gcc -c main.cpp other.cpp |
查看符号表:
1 | ~ readelf -s other.o |
可以发现main.o中这两个符号在Ndx中都标记了UND,而other.o中是存在的
再看下分组信息确定模板有没有实例化,main.o确实不存在分组信息了:
1 | ~ readelf -g other.o |
很明显,加速编译的目标达成了,分析可执行文件符号都找到了
1 | ~ readelf -s main |
extern模板
extern模板是C++11引入的功能,正常的模板代码的目标文件,进行extern声明以后,就不再会进行模板的实例化了
common.h
1
2
3
4
5
6
7
8
9
10
11
template <typename>
void foo() {
}
template <typename>
struct test {
void hello() {
}
};main.cpp
1
2
3
4
5
6
7
8
9
10
extern template void foo<int>();
extern template struct test<int>;
int main() {
foo<int>();
test<int> t;
t.hello();
}other.cpp
1
2
3
4
5
6
7
void other() {
foo<int>();
test<int> t;
t.hello();
}
编译和链接:
1 | ~ gcc -c main.cpp other.cpp |
查看符号表:
1 | ~ readelf -s other.o |
和声明定义分离中一样,可以发现main.o中这两个符号在Ndx中也都标记了UND,而other.o中是存在的
再看下分组信息确定模板有没有实例化,main.o确实不存在分组信息了:
1 | ~ readelf -g other.o |
和声明定义分离中一样,加速编译的目标也达成了,分析可执行文件符号都找到了
1 | ~ readelf -s main |
总结
首先只有全局可见的东西才会在符号表中存在,否则只是局部变量
符号表有如下类型:
值 | 静态链接行为 |
---|---|
STB_LOCAL |
允许在不同的目标文件中,存在多个相同名字的符号,符号之间相互隔离(无关联)。 |
STB_GLOBAL |
只允许一个目标文件存在 GLOBAL
符号定义,其它目标文件允许相同名字的未定义的符号引用,即不允许多个定义。 |
STB_WEAK |
允许在不同的目标文件中存在多个相同名字的符号。具体来说,如果同时存在一个
GLOBAL 符号和其它同名 WEAK
符号的定义,那就挑选 GLOBAL 符号(ignores the weak
ones);如果存在多个同名 WEAK 符号的定义,事实标准是挑选
linker 解析过程中接触到的第一个符号。 |
静态链接和动态链接虽然对LOCAL的逻辑是一致的,但是动态链接器在符号重定位的时候,对GLOBAL和WEAK是一视同仁的
重定位细节参考hook的妙用
全局变量和函数:
- static作用域是LOCAL类型,在静态和动态编译中只对当前编译单元可见
- inline
- 对C来说和extern是一样的含义
- 对C++来说是WEAK类型,并且需要调用才会产生符号
- 默认情况(没有static或inline)则是GLOBAL属性
类:
- 类内声明和定义的函数自带inline(包括static成员函数)
函数内静态变量
静态变量的符号和函数本身的前缀有关,和普通全局变量的区别是对外导出和全局唯一是绑定的
static,会生成LOCAL属性的变量,编译后会有多个实例
inline,会生成UNIQUE属性的变量,全称STB_GNU_UNIQUE
UNIQUE属性全局唯一,即使跨动态库,因此会造成dlclose失效
默认情况(没有static或inline),会生成LOCAL属性的变量,由于函数本身不能重定义,所以虽然是LOCAL,也只会有一个实例
模板
本文没有讨论动态链接,因为模板第三方库大多是headonly的
同一个模板在每个用到的编译单元都会进行实例化生成group信息帮助重定位,最后重复的实例会在链接阶段去重,所以inline和static或者默认都一样
每个模板类型实例化以后都会拥有自己的static变量
特化函数模板必须标记inline才不会造成重定义(对全局函数而言,标static也行,就是浪费了)
加速编译可以使用声明和定义分离,也可以使用extern模板
extern模板
优点:对代码侵入性小,加一行代码就行
缺点:新增编译单元以后容易忘记加
声明和定义分离
优点:不用额外加一行代码,不存在忘记的问题,因为编译单元include的只是一个声名
缺点:
对代码侵入性大,要把所有的函数都拆到单独的cpp文件里面去
需要在定义的cpp文件里面实例化好类型,因此只适用于自己的代码,如果是作为模板库对外暴露,那么无法预先知道会被实例化成什么类型
综合考虑extern模板肯定是更方便的,为了解决extern模板的缺陷,可以考虑把extern声明和模板本身放在一起,然后通过宏定义在某个文件中屏蔽掉extern声明,从而集中在这个文件中实例化