首先来看一个简单的程序。下面是是两段程序,分别放在link.c
和bar.c
中。
/* link.c */
#include<stdio.h>
void f(void);
int x = 13;
int main()
{
f();
printf("x=%d\n", x);
return 0;
}
/* bar.c */
int x;
void f()
{
x = 12;
}
现在我们使用命令gcc -o linkbar link.c bar.c
将其编译为可执行文件,然后用./linkbar
运行文件,运行结果为:x=12
。
有趣的事情就发生了,在link.c
中分明定义的是x = 13
,而且打印语句使用的也是自己模块的x
变量,怎么就变成了 12 了呢?
那问题是不是就出在bar.c
文件中呢?这个文件中也定义了x
变量,并且在f()
函数中将其赋值了,但是这个x
是在bar.c
中,link.c
中也有自己的x
变量,按理来说它们应该是相互不影响的,让人疑惑!!!!
实际上这都是链接器搞的鬼,上面场景在工作中遇到的可能性不小,这种错误引入程序后,并不会立即表现出来,而是可能在其它你想不到的地方报错,试想一下,在一个拥有成百上千个模块的大型系统中,发生了这样的错误,而你也不知道错误的源头,让你定位出这个错误,其困难程度可想而知。
要理清这个问题,需要去了解链接器是怎么工作的。我们都知道,现在的系统越来越大,我们将其分解成为更小的、更好管理的模块,可以独立地修改和编译这些模块(像不像微服务?),这样协作让我们不必将整个应用程序组织成一个巨大的源文件。
为了构造可执行文件,链接器需要完成符号解析
和重定位
两个主要任务,这里我们主要看看符号解析。
每个符号都对应一个函数、一个全局变量或一个静态变量(C 中以static
声明的变量),符号解析就是要把每个符号引用与符号定义关联起来,注意不包括局部变量哦。
C 语言中 static 声明的变量和 Java、C++ 中的 private 声明一样,不带 static 的就是 public 类型的。
你肯定听说过可重定位
这个词,源程序在经过预处理、编译、汇编之后产生的就是可重定位目标文件
,怎么理解可重定位呢?简单来说,就是说文件里面的代码段和数据的地址还没有最终确定。
在每个可重定位目标文件中都有一个符号表,这个符号表包含了可重定位目标文件自己定义和引用的符号信息。共有三种不同的符号:1、由自己定义并能被其它模块引用的全局符号;2、由其它模块定义并被自己引用的全局符号;3、只能被自己引用的局部符号。
链接器解析符号引用是将每个引用与它的输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来,但是不同的可重定位目标文件可能有多个同名的全局符号,即多重定义的全局符号。
函数和已经初始化的全局变量是强符号
,未初始化的全局变量是弱符号
。根据强弱符号的定义,Linux 链接器使用三条规则来处理多重定义的符号。
- 不允许有多个同名的强符号;
- 如果有多个弱符号和一个强符号同名,那么选择强符号;
- 如果有多个弱符号同名,那么选择其中任意一个。
看到这里,就明白为什么会有开篇程序出现的那个错误了,因为它正好满足规则二,所以bar.c
中的x
变量实际上还是link.c
中的x
变量。
尤其规则 2 和 3 的应用会带来一些不易察觉的运行时错误,这是非常难理解的,尤其是重复的符号中还有不同的类型时,比如下面这个例子。
/* link.c */
#include<stdio.h>
void f(void);
int y = 12;
int x = 13;
int main()
{
f();
printf("x=0x%x y=0x%x \n", x, y);
return 0;
}
/* bar.c */
double x;
void f()
{
x = -0.0;
}
在一台 x86-64/Linux 机器上,double
类型是 8 个字节,而int
类型是 4 个字节,假设系统中x
的地址是0x601020
,y
的地址是0x601024
,而bar.c
的赋值x = -0.0;
将会用负零的双精度浮点表示覆盖内存中x
和y
的位置。