关于符号链接的一些碎碎念
静态链接
在C中,编译一个程序的过程如下:
xx.c->(翻译器)->xx.o->(链接器)->xx
而在第二部将xx.o构造为一个可执行文件的过程被称为静态链接
为了完成静态链接这个目标,链接器需要做两件事:
- 知道每一个变量到底是什么,这样才能将变量的定义与引用相关联
这里的变量可以是一个全局变量,一个静态变量,一个函数……
而在之后,统一将这些变量的定义和引用称为符号
这一步被称为符号解析 - 知道每一个变量在哪,这样才能将符号和内存地址相关联,并将所有的符号修改为内存地址
这一步被称为重定位
而诸如extern、static、attribute(weak)这些关键字,则正是在符号解析中发挥作用的
可重定位目标文件
下表是一个典型的可重定位目标文件:
段 | 作用 |
---|---|
.text | 已编译的机器代码(此时符号未被替换) |
.rodata | 只读数据 |
.data | 已初始化的全局和静态变量 |
.bss | 未初始化的全局和静态变量 |
.symtab | 符号表(也就是我们要关注的部分) |
.rel.text | .text重定位信息 |
.rel.data | .data重定位信息 |
.debug | 调试符号表(-g) |
.line | 汇编代码与源代码映射(-g) |
.strtab | 字符串表 |
注意到,此时.text、.data符号其实并未被替换,也就是说我们此时并不知道那些符号到底在哪
我们不妨用以下程序作为示例:
1 | int foo=0; |
编译出来的结果如下:
1 | 0000000000000000 <bar>: |
注意到,它里面foo的地址甚至是0x0,但是foo的真实地址怎么想都不是0x0吧!
符号和符号表
编译器没有把foo的地址替换成真实地址的原因是:你有可能是多个.o文件一起,编译成一个.c文件的。这个时候,一个.o文件内部是不知道另一个.o文件内部发生了什么,所以反正最后都要再替换一次,不妨最后一起替换了。
而为了最后的替换,我们便需要维护一张表,以便告诉链接器哪个替换成哪个。
在链接的定义中,符号文件有以下三种东西:
- 由该文件定义并能被其它文件引用的全局符号(如你暴露出去的函数啊,全局变量啊等等)
- 由其它文件定义并被该文件引用的全局符号(比如printf,你在调用printf的时候其实就是用了printf的符号)
- 只在该文件中有用的局部符号(在C中就是加上static)
在此不过多涉及符号表长啥样,但是可以来想想符号表中需要存储哪些内容:
- 一个名字,以便知道这玩意是啥
- 该符号是不是静态的
- 该符号有没有被使用过?(没有被用过的话可以优化掉)
- 这玩意在哪
- 这玩意有多长
实际上,符号表并不会存储在哪(因为地址每次编译是可能不同的),而是存储其相对于某个段偏移了多少
符号解析
可以先来尝试这样一件事:
1.c:
1 | int foo = 0; |
2.c:
1 | extern int foo; |
理论上,1.c里不知道bar、baz是啥,所以会失败;2.c里不知道foo是啥,所以会失败(因为没有引入头文件嘛——
但是我们实际试一试的话:
整一套流程毫无压力
这是因为:编译时其实并不在意里面有什么没有什么,而是会生成一张表告诉链接器我有啥,我要啥;而链接时链接器会把两张表一对照:它要的我有,我要的它有,完事了(
事实上,我们可以在1.c中加入一点未曾出现的东西再试试:
1 | int foo = 0; |
编译器依然很愉快的通过了,但是链接器不高兴了,因为它找不到这个foobar是啥
在此我们便可以理解为什么不能在头文件中定义全局变量了:
#include会将.h中的东西如数的放到.c里,于是如果在.h中定义了一个变量,就相当于在编译的.o里说了一声:我有xxx
要是有多个.c文件同时引入了这个头文件,链接器一看:你有xxx,怎么它也有xxx,那这用哪个呢?然后就开摆不干了。
所以extern到底有啥作用
答案是:其实extern没啥作用,没有extern也能活(你看上面的bar和baz不都没有extern
但是:为什么foo一定要有extern呢?
这是因为编译器知道,如果这玩意是个函数的话,那好办,因为该怎么调用都是统一的
但这玩意是个变量,于是,假如你要给foo+1,但是int和float的加法它不一样啊,编译器不知道怎么办了
于是这里extern的作用便是:告诉编译器这玩意是个int,你按照int的加法来写这,剩下的交给链接器那玩意去干就行
如何解析多重定义的全局符号
但是,有些时候我们不得不用到多个同名的全局符号,这时候该怎么办呢?
对此,C标准中将符号分为强符号和弱符号,该信息包含在了符号表中。同时,其有以下规则:
- 不能有多个相同的强符号
- 如果强符号和弱符号同名,则选择强符号
- 如果有多个弱符号同名,随便选一个
而:默认情况下,初始化的符号是强符号,未初始化的符号是弱符号
我们可以将上面的代码改成这样:
1 | int foo = 0; |
1 | int foo; |
链接器毫无压力。虽然它看到了两个foo,但第二个foo是弱符号,第一个foo是强符号,选择第一个foo毫无压力。
我们还可以将代码改成这样:
1 | int foo; |
1 | int foo; |
链接器依然毫无压力。这两个foo都是弱符号了,链接器就乱选了一个上去用。
很重要的事情
由于第三条规则(多个弱符号)的存在,常常会导致很多不易察觉的恶性bug!
如我们将代码改成这样
1 | int foo; |
1 | float foo; |
它通过编译和链接依然毫无压力,压力来到了你身上。
此时,foo的类型是不确定的!因为链接器看到了两个foo,它可不管两个foo类型一不一样,而是随便选了一个上去用。于是,根据链接器的心情,foo可能是int,也可能是float
这是很讨厌的一件事,因为链接器甚至连错都不报
attribute(weak)
那要是我非得让一个是强符号的东西变成弱符号的呢?
对此,编译器提供了__attribute__((weak))的方法,能让一个符号无条件的变成弱符号
例如下面的代码:
1 | int foo=0; |
1 | int __attribute__((weak)) foo=0; |
虽然两个都被附上了初始值,但链接器依然毫无压力,因为第二个被强制定为了弱符号
这个特性已经被引入了C标准中。其本来的目的是:你可能有一些函数想让他人可以重写
如你实现了一个encrypt方法,但是别人可能不想在机密的代码中用你的encrypt,就可以重写encrypt,你的那部分代码就自动变成了他的encrypt
我们可以只使用extern而不做强声明吗?
理论上是不行的,因为这样相当于人人都喊着要xxx,但是没有人有xxx
但是链接器比较聪明:所有的符号的目的都是为了获得一块地址。既然没有人有xxx,我又知道xxx到底要多少空间(上面说了符号表里会存所占空间长度),那我直接新定义一块这么大的空间给你们不就可以了