C++编译器与链接器工作原理
看到一篇觉得写的很好的文章,无奈好像找不到原始作者发布的链接了(貌似已经失效),这里转载一下。下面是找到的该文章的其他副本:
- 发现的标注日期最早的高相似度副本:[yc]详解link - shifan3 - C++博客 (cppblog.com)
- C++编译器与链接器工作原理 | Software MyZone (firedragonpzy.com.cn)
- C++之编译器与链接器工作原理 - 拦云 - 博客园 (cnblogs.com)
- C++编译器与链接器工作原理_永远即等待的专栏-CSDN博客
- C++编译器与链接器工作原理 + Link错误_u013321328的专栏-CSDN博客
有几篇同样觉得不错的文章,在这里推荐一下:
这里并不是讨论大学课程中所学的《编译原理》,只是写一些我自己对C++编译器及链接器的工作原理的理解和看法吧,以我的水平,还达不到讲解编译原理(这个很复杂,大学时几乎没学明白)。
要明白的几个概念:
1、编译:编译器对源文件进行编译,就是把源文件中的文本形式存在的源代码翻译成机器语言形式的目标文件的过程,在这个过程中,编译器会进行一系列的语法检查。如果编译通过,就会把对应的CPP转换成OBJ文件。
2、编译单元:根据C++标准,每一个CPP文件就是一个编译单元。每个编译单元之间是相互独立并且互相不可知。
3、目标文件:由编译所生成的文件,以机器码的形式包含了编译单元里所有的代码和数据,还有一些期他信息,如未解决符号表,导出符号表和地址重定向表等。目标文件是以二进制的形式存在的。
根据C++标准,一个编译单元(Translation Unit)是指一个.cpp文件以及这所include的所有.h文件,.h文件里面的代码将会被扩展到包含它的.cpp文件里,然后编译器编译该.cpp文件为一个.obj文件,后者拥有PE(Portable Executable,即Windows可执行文件)文件格式,并且本身包含的就是二进制代码,但是不一定能执行,因为并不能保证其中一定有main函数。当编译器将一个工程里的所有.cpp文件以分离的方式编译完毕后,再由链接器进行链接成为一个.exe或.dll文件。
下面让我们来分析一下编译器的工作过程:
我们跳过语法分析,直接来到目标文件的生成,假设我们有一个A.cpp
文件,如下定义:
int n = 1;
void FunA()
{
++n;
}
它编译出来的目标文件A.obj
就会有一个区域(或者说是段),包含以上的数据和函数,其中就有n、FunA,以文件偏移量形式给出可能就是下面这种情况:
偏移量 | 内容 | 长度 |
---|---|---|
0×0000 | n | 4 |
0×0004 | FunA | ?? |
注意:这只是说明,与实际目标文件的布局可能不一样,??表示长度未知,目标文件的各个数据可能不是连续的,也不一定是从0×0000开始。
FunA函数的内容可能如下:
0×0004 inc DWORD PTR[0x0000]
0×00?? ret
这时++n
已经被翻译成inc DWORD PTR[0x0000]
,也就是说把本单元0×0000位置的一个DWORD(4字节)加1。
有另外一个B.cpp
文件,定义如下:
extern int n;
void FunB()
{
++n;
}
它对应的B.obj
的二进制应该是:
偏移量 | 内容 | 长度 |
---|---|---|
0×0000 | FunB | ?? |
这里为什么没有n的空间呢,因为n被声明为extern,这个extern关键字就是告诉编译器n已经在别的编译单元里定义了,在这个单元里就不要定义了。由于编译单元之间是互不相关的,所以编译器就不知道n究竟在哪里,所以在函数FunB就没有办法生成n的地址,那么函数FunB中就是这样的:
0×0000 inc DWORD PTR[????]
0×00?? ret
那怎么办呢?这个工作就只能由链接器来完成了。
为了能让链接器知道哪些地方的地址没有填好(也就是还???),那么目标文件中就要有一个表来告诉链接器,这个表就是“未解决符号表”,也就是unresolved symbol table。同样,提供n的目标文件也要提供一个“导出符号表”也就是exprot symbol table,来告诉链接器自己可以提供哪些地址。
好,到这里我们就已经知道,一个目标文件不仅要提供数据和二进制代码外,还至少要提供两个表:未解决符号表和导出符号表,来告诉链接器自己需要什么和自己能提供些什么。那么这两个表是怎么建立对应关系的呢?这里就有一个新的概念:符号。在C/C++中,每一个变量及函数都会有自己的符号,如变量n的符号就是n,函数的符号会更加复杂,假设FunA的符号就是_FunA(根据编译器不同而不同)。
所以,A.obj的导出符号表为:
符号 | 地址 |
---|---|
n | 0×0000 |
_FunA | 0×0004 |
未解决符号为空(因为他没有引用别的编译单元里的东西)。
B.obj的导出符号表为
符号 | 地址 |
---|---|
_FunB | 0×0000 |
未解决符号为
符号 | 地址 |
---|---|
n | 0×0001 |
这个表告诉链接器,在本编译单元0×0001位置有一个地址,该地址不明,但符号是n。
在链接的时候,链接在B.obj中发现了未解决符号,就会在所有的编译单元中的导出符号表去查找与这个未解决符号相匹配的符号名,如果找到,就把这个符号的地址填到B.obj的未解决符号的地址处。如果没有找到,就会报链接错误。在此例中,在A.obj中会找到符号n,就会把n的地址填到B.obj的0×0001处。
但是,这里还会有一个问题,如果是这样的话,B.obj的函数FunB的内容就会变成inc DWORD PTR[0x000](因为n在A.obj中的地址是0×0000),由于每个编译单元的地址都是从0×0000开始,那么最终多个目标文件链接时就会导致地址重复。所以链接器在链接时就会对每个目标文件的地址进行调整。在这个例子中,假如B.obj的0×0000被定位到可执行文件的0×00001000上,而A.obj的0×0000被定位到可执行文件的0×00002000上,那么实现上对链接器来说,A.obj的导出符号地地址都会加上0×00002000,B.obj所有的符号地址也会加上0×00001000。这样就可以保证地址不会重复。
既然n的地址会加上0×00002000,那么FunA中的inc DWORD PTR[0x0000]就是错误的,所以目标文件还要提供一个表,叫地址重定向表,address redirect table。
总结一下:
目标文件至少要提供三个表:未解决符号表,导出符号表和地址重定向表。
-
未解决符号表:列出了本单元里有引用但是不在本单元定义的符号及其出现的地址。
-
导出符号表:提供了本编译单元具有定义,并且可以提供给其他编译单元使用的符号及其在本单元中的地址。
-
地址重定向表:提供了本编译单元所有对自身地址的引用记录。
链接器的工作顺序:
当链接器进行链接的时候,首先决定各个目标文件在最终可执行文件里的位置。然后访问所有目标文件的地址重定义表,对其中记录的地址进行重定向(加上一个偏移量,即该编译单元在可执行文件上的起始地址)。然后遍历所有目标文件的未解决符号表,并且在所有的导出符号表里查找匹配的符号,并在未解决符号表中所记录的位置上填写实现地址。最后把所有的目标文件的内容写在各自的位置上,再作一些另的工作,就生成一个可执行文件。
说明:实现链接的时候会更加复杂,一般实现的目标文件都会把数据,代码分成好向个区,重定向按区进行,但原理都是一样的。
明白了编译器与链接器的工作原理后,对于一些链接错误就容易解决了。
现在我们可以来看看几个经典的链接错误了:
unresolved external link…
这个很显然,是链接器发现一个未解决符号,但是在导出符号表里没有找到对应的項。
解决方案么,当然就是在某个编译单元里提供这个符号的定义就行了。(注意,这个符号可以是一个变量,也可以是一个函数),也可以看看是不是有什么该链接的文件没有链接
duplicated external symbols…
这个则是导出符号表里出现了重复项,因此链接器无法确定应该使用哪一个。这可能是使用了重复的名称,也可能有别的原因。
我们再来看看C/C++语言里针对这一些而提供的特性:
extern:这就是告诉编译器,这个变量或函数在别的编译单元里定义了,也就是要把这个符号放到未解决符号表里面去(外部链接)。
static:如果该关键字位于全局函数或者变量的声明前面,表明该编译单元不导出这个函数或变量,因些这个符号不能在别的编译单元中使用(内部链接)。如果是static局部变量,则该变量的存储方式和全局变量一样,但是仍然不导出符号。
默认链接属性:对于函数和变量,默认链接是外部链接,对于const变量,默认内部链接。(可以通过添加extern和static改变链接属性)
外部链接的利弊:外部链接的符号在整个程序范围内都是可以使用的,这就要求其他编译单元不能导出相同的符号(不然就会报duplicated external symbols)。
内部链接的利弊:内部链接的符号不能在别的编译单元中使用。但不同的编译单元可以拥有同样的名称的符号。
为什么头文件里一般只可以有声明不能有定义:
头文件可以被多个编译单元包含,如果头文件里面有定义的话,那么每个包含这头文件的编译单元都会对同一个符号进行定义,如果该符号为外部链接,则会导致duplicated external symbols链接错误。
为什么常量默认为内部链接,而变量不是:
这就是为了能够在头文件里如const int n = 0这样的定义常量。由于常量是只读的,因此即使每个编译单元都拥有一份定义也没有关系。如果一个定义于头文件里的变量拥有内部链接,那么如果出现多个编译单元都定义该变量,则其中一个编译单元对该变量进行修改,不会影响其他单元的同一变量,会产生意想不到的后果。
为什么函数默认是外部链接:
虽然函数是只读的,但是和变量不同,函数在代码编写的时候非常容易变化,如果函数默认具有内部链接,则人们会倾向于把函数定义在头文件里,那么一旦函数被修改,所有包含了该头文件的编译单元都要被重新编译。另外,函数里定义的静态局部变量也将被定义在头文件里。
为什么类的静态变量不可以就地初始化:
所谓就地初始化就是类似于这样的情况:
class A
{
static char msg[] = "aha";
};
不允许这样做的原因是,由于class的声明通常是在头文件里,如果允许这样做,其实就相当于在头文件里定义了一个非const变量。
在C++里,头文件定义一个const对象会怎么样:
一般不会怎么样,这个和C里的在头文件里定义const int一样,每一个包含了这个头文件的编译单元都会定义这个对象。但由于该对象是const的,所以没什么影响。但是,有2种情况可能破坏这个局面:
- 如果涉及到对这个const对象取地址并且依赖于这个地址的唯一性,那么在不同的编译单元里,取到的地址可以不同。(但一般很少这么做)
- 如果这个对象具有mutable的变量,某个编译单元对其进行修改,则同样不会影响到别的编译单元。
为什么类的静态常量也不可以就地初始化:
因为这相当于在头文件里定义了const对象。作为例外,int/char等可以进行就地初始化,是因为这些变量可以直接被优化为立即数,就和宏一样。
内联函数:
C++里的内联函数由于类似于一个宏,因此不存在链接属性问题。
为什么公共使用的内联函数要定义于头文件里:
因为编译时编译单元之间互不知道,如果内联被定义于.cpp文件中,编译其他使用该函数的编译单元的时候没有办法找到函数的定义,因些无法对函数进行展开。所以如果内联函数定义于.cpp里,那么就只有这个.cpp文件能使用它。
头文件里内联函数被拒绝会怎样:
如果定义于头文件里的内联函数被拒绝,那么编译器会自动在每个包含了该头文件的编译单元里定义这个函数并且不导出符号。
如果被拒绝的内联函数里定义了静态局部变量,这个变量会被定义于何处:
早期的编译器会在每个编译单元里定义一个,并因此产生错误的结果,较新的编译器会解决这个问题,手段未知。
为什么export关键字没人实现:
export要求编译器跨编译单元查找函数定义,使得编译器实现非常困难。
摘自:http://blog.sina.com.cn/s/blog_5f8817250100i3oz.html