静态链接

在C中,编译一个程序的过程如下:
xx.c->(翻译器)->xx.o->(链接器)->xx

而在第二部将xx.o构造为一个可执行文件的过程被称为静态链接

为了完成静态链接这个目标,链接器需要做两件事:

  1. 知道每一个变量到底是什么,这样才能将变量的定义与引用相关联
    这里的变量可以是一个全局变量,一个静态变量,一个函数……
    而在之后,统一将这些变量的定义和引用称为符号
    这一步被称为符号解析
  2. 知道每一个变量在哪,这样才能将符号和内存地址相关联,并将所有的符号修改为内存地址
    这一步被称为重定位

而诸如extern、static、attribute(weak)这些关键字,则正是在符号解析中发挥作用的

可重定位目标文件

下表是一个典型的可重定位目标文件:

作用
.text 已编译的机器代码(此时符号未被替换)
.rodata 只读数据
.data 已初始化的全局和静态变量
.bss 未初始化的全局和静态变量
.symtab 符号表(也就是我们要关注的部分)
.rel.text .text重定位信息
.rel.data .data重定位信息
.debug 调试符号表(-g)
.line 汇编代码与源代码映射(-g)
.strtab 字符串表

注意到,此时.text、.data符号其实并未被替换,也就是说我们此时并不知道那些符号到底在哪

我们不妨用以下程序作为示例:

1
2
3
4
5
6
int foo=0;

void bar()
{
foo+=3;
}

编译出来的结果如下:

1
2
3
4
0000000000000000 <bar>:
0: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 6 <bar+0x6>
6: 83 c0 03 add $0x3,%eax
9: 89 05 00 00 00 00 mov %eax,0x0(%rip) # f <bar+0xf>

注意到,它里面foo的地址甚至是0x0,但是foo的真实地址怎么想都不是0x0吧!

符号和符号表

编译器没有把foo的地址替换成真实地址的原因是:你有可能是多个.o文件一起,编译成一个.c文件的。这个时候,一个.o文件内部是不知道另一个.o文件内部发生了什么,所以反正最后都要再替换一次,不妨最后一起替换了。

而为了最后的替换,我们便需要维护一张表,以便告诉链接器哪个替换成哪个。

在标准C的定义中,符号文件有以下三种东西:

  • 由该文件定义并能被其它文件引用的全局符号(如你暴露出去的函数啊,全局变量啊等等)
  • 由其它文件定义并被该文件引用的全局符号(比如printf,你在调用printf的时候其实就是用了printf的符号)
  • 只在该文件中有用的局部符号(在C中就是加上static)

在此不过多涉及符号表长啥样,但是可以来想想符号表中需要存储哪些内容:

  • 一个名字,以便知道这玩意是啥
  • 该符号是不是静态的
  • 该符号有没有被使用过?(没有被用过的话可以优化掉)
  • 这玩意在哪
  • 这玩意有多长

实际上,符号表并不会存储在哪(因为地址每次编译是可能不同的),而是存储其相对于某个段偏移了多少

符号解析

可以先来尝试这样一件事:

1.c:

1
2
3
4
5
6
7
int foo = 0;
int mian()
{
bar();
baz();
bar();
}

2.c:

1
2
3
4
5
6
7
8
9
extern int foo;
void bar()
{
foo += 3;
}
void baz()
{
foo -= 2;
}

理论上,1.c里不知道bar、baz是啥,所以会失败;2.c里不知道foo是啥,所以会失败(因为没有引入头文件嘛——

但是我们实际试一试的话:

整一套流程毫无压力

这是因为:编译时其实并不在意里面有什么没有什么,而是会生成一张表告诉链接器我有啥,我要啥;而链接时链接器会把两张表一对照:它要的我有,我要的它有,完事了(

事实上,我们可以在1.c中加入一点未曾出现的东西再试试:

1
2
3
4
5
6
7
8
int foo = 0;
int mian()
{
bar();
baz();
bar();
foobar();
}

编译器依然很愉快的通过了,但是链接器不高兴了,因为它找不到这个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
2
3
4
5
6
7
int foo = 0;
int mian()
{
bar();
baz();
bar();
}
1
2
3
4
5
6
7
8
9
int foo;
void bar()
{
foo += 3;
}
void baz()
{
foo -= 2;
}

链接器毫无压力。虽然它看到了两个foo,但第二个foo是弱符号,第一个foo是强符号,选择第一个foo毫无压力。

我们还可以将代码改成这样:

1
2
3
4
5
6
7
8
int foo;
int mian()
{
foo = 0;
bar();
baz();
bar();
}
1
2
3
4
5
6
7
8
9
int foo;
void bar()
{
foo += 3;
}
void baz()
{
foo -= 2;
}

链接器依然毫无压力。这两个foo都是弱符号了,链接器就乱选了一个上去用。

很重要的事情

由于第三条规则(多个弱符号)的存在,常常会导致很多不易察觉的恶性bug!
如我们将代码改成这样

1
2
3
4
5
6
7
8
int foo;
int mian()
{
foo = 1;
bar();
baz();
bar();
}
1
2
3
4
5
6
7
8
9
float foo;
void bar()
{
foo += 3.1;
}
void baz()
{
foo -= 2.5;
}

它通过编译和链接依然毫无压力,压力来到了你身上。

此时,foo的类型是不确定的!因为链接器看到了两个foo,它可不管两个foo类型一不一样,而是随便选了一个上去用。于是,根据链接器的心情,foo可能是int,也可能是float

这是很讨厌的一件事,因为链接器甚至连错都不报

attribute(weak)

那要是我非得让一个是强符号的东西变成弱符号的呢?

对此,编译器提供了__attribute__((weak))的方法,能让一个符号无条件的变成弱符号

例如下面的代码:

1
2
3
4
5
6
7
int foo=0;
int mian()
{
bar();
baz();
bar();
}
1
2
3
4
5
6
7
8
9
int __attribute__((weak)) foo=0;
void bar()
{
foo += 3.1;
}
void baz()
{
foo -= 2.5;
}

虽然两个都被附上了初始值,但链接器依然毫无压力,因为第二个被强制定为了弱符号

这个特性已经被引入了C标准中。其本来的目的是:你可能有一些函数想让他人可以重写

如你实现了一个encrypt方法,但是别人可能不想在机密的代码中用你的encrypt,就可以重写encrypt,你的那部分代码就自动变成了他的encrypt

我们可以只使用extern而不做强声明吗?

理论上是不行的,因为这样相当于人人都喊着要xxx,但是没有人有xxx

但是链接器比较聪明:所有的符号的目的都是为了获得一块地址。既然没有人有xxx,我又知道xxx到底要多少空间(上面说了符号表里会存所占空间长度),那我直接新定义一块这么大的空间给你们不就可以了