C++之编译器与链接器工作原理

http://www.cnblogs.com/kunhu/p/3629636.html

原文来自:http://blog.sina.com.cn/s/blog_5f8817250100i3oz.html

这里并没不是讨论大学课程中所学的《编译原理》,只是写一些我自己对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,以文件偏移量形式给出可能就是下面这种情况:

偏移量    内容    长度

0x0000    n       4

0x0004    FunA    ??

注意:这只是说明,与实际目标文件的布局可能不一样,??表示长度未知,目标文件的各个数据可能不是连续的,也不一定是从0x0000开始。

FunA函数的内容可能如下:

0x0004 inc DWORD PTR[0x0000]

0x00?? ret

这时++n已经被翻译成inc DWORD PTR[0x0000],也就是说把本单元0x0000位置的一个DWORD(4字节)加1。

有另外一个B.cpp文件,定义如下:

extern int n;

void FunB()

{

++n;

}

它对应的B.obj的二进制应该是:

偏移量    内容    长度

0x0000    FunB    ??

这里为什么没有n的空间呢,因为n被声明为extern,这个extern关键字就是告诉编译器n已经在别的编译单元里定义了,在这个单元里就不要定义了。由于编译单元之间是互不相关的,所以编译器就不知道n究竟在哪里,所以在函数FunB就没有办法生成n的地址,那么函数FunB中就是这样的:

0x0000 inc DWORD PTR[????]

0x00?? ret

那怎么办呢?这个工作就只能由链接器来完成了。

为了能让链接器知道哪些地方的地址没有填好(也就是还????),那么目标文件中就要有一个表来告诉链接器,这个表就是“未解决符号表”,也就是unresolved symbol table。同样,提供n的目标文件也要提供一个“导出符号表”也就是exprot symbol table,来告诉链接器自己可以提供哪些地址。

好,到这里我们就已经知道,一个目标文件不仅要提供数据和二进制代码外,还至少要提供两个表:未解决符号表和导出符号表,来告诉链接器自己需要什么和自己能提供些什么。那么这两个表是怎么建立对应关系的呢?这里就有一个新的概念:符号。在C/C++中,每一个变量及函数都会有自己的符号,如变量n的符号就是n,函数的符号会更加复杂,假设FunA的符号就是_FunA(根据编译器不同而不同)。

所以,

A.obj的导出符号表为

符号    地址

n       0x0000

_FunA   0x0004

未解决符号为空(因为他没有引用别的编译单元里的东西)。

B.obj的导出符号表为

符号    地址

_FunB   0x0000

未解决符号表为

符号    地址

n       0x0001

这个表告诉链接器,在本编译单元0x0001位置有一个地址,该地址不明,但符号是n。

在链接的时候,链接在B.obj中发现了未解决符号,就会在所有的编译单元中的导出符号表去查找与这个未解决符号相匹配的符号名,如果找到,就把这个符号的地址填到B.obj的未解决符号的地址处。如果没有找到,就会报链接错误。在此例中,在A.obj中会找到符号n,就会把n的地址填到B.obj的0x0001处。

但是,这里还会有一个问题,如果是这样的话,B.obj的函数FunB的内容就会变成inc DWORD PTR[0x000](因为n在A.obj中的地址是0x0000),由于每个编译单元的地址都是从0x0000开始,那么最终多个目标文件链接时就会导致地址重复。所以链接器在链接时就会对每个目标文件的地址进行调整。在这个例子中,假如B.obj的0x0000被定位到可执行文件的0x00001000上,而A.obj的0x0000被定位到可执行文件的0x00002000上,那么实现上对链接器来说,A.obj的导出符号地地址都会加上0x00002000,B.obj所有的符号地址也会加上0x00001000。这样就可以保证地址不会重复。

既然n的地址会加上0x00002000,那么FunA中的inc DWORD PTR[0x0000]就是错误的,所以目标文件还要提供一个表,叫地址重定向表,address redirect table。

总结一下:

目标文件至少要提供三个表:未解决符号表,导出符号表和地址重定向表。

未解决符号表:列出了本单元里有引用但是不在本单元定义的符号及其出现的地址。

导出符号表:提供了本编译单元具有定义,并且可以提供给其他编译单元使用的符号及其在本单元中的地址。

地址重定向表:提供了本编译单元所有对自身地址的引用记录。

链接器的工作顺序:

当链接器进行链接的时候,首先决定各个目标文件在最终可执行文件里的位置。然后访问所有目标文件的地址重定义表,对其中记录的地址进行重定向(加上一个偏移量,即该编译单元在可执行文件上的起始地址)。然后遍历所有目标文件的未解决符号表,并且在所有的导出符号表里查找匹配的符号,并在未解决符号表中所记录的位置上填写实现地址。最后把所有的目标文件的内容写在各自的位置上,再作一些另的工作,就生成一个可执行文件。

说明:实现链接的时候会更加复杂,一般实现的目标文件都会把数据,代码分成好向个区,重定向按区进行,但原理都是一样的。

明白了编译器与链接器的工作原理后,对于一些链接错误就容易解决了。

现在我们可以来看看几个经典的链接错误了:
    unresolved external link..
    这个很显然,是链接器发现一个未解决符号,但是在导出符号表里没有找到对应的項。
    解决方案么,当然就是在某个编译单元里提供这个符号的定义就行了。(注意,这个符号可以是一个变量,也可以是一个函数),也可以看看是不是有什么该链接的文件没有链接
    duplicated external simbols...
    这个则是导出符号表里出现了重复项,因此链接器无法确定应该使用哪一个。这可能是使用了重复的名称,也可能有别的原因。

我们再来看看C/C++语言里针对这一些而提供的特性:
    extern:这是告诉编译器,这个符号在别的编译单元里定义,也就是要把这个符号放到未解决符号表里去。(外部链接)
    
    static:如果该关键字位于全局函数或者变量的声明的前面,表明该编译单元不导出这个函数/变量的符号。因此无法在别的编译单元里使用。(内部链接)。如果是static局部变量,则该变量的存储方式和全局变量一样,但是仍然不导出符号。
    
    默认链接属性:对于函数和变量,模认外部链接,对于const变量,默认内部链接。(可以通过添加extern和static改变链接属性)

外部链接的利弊:外部链接的符号,可以在整个程序范围内使用(因为导出了符号)。但是同时要求其他的编译单元不能导出相同的符号(不然就是duplicated external simbols)

内部链接的利弊:内部链接的符号,不能在别的编译单元内使用。但是不同的编译单元可以拥有同样名称的内部链接符号。

为什么头文件里一般只可以有声明不能有定义:头文件可以被多个编译单元包含,如果头文件里有定义,那么每个包含这个头文件的编译单元就都会对同一个符号进行定义,如果该符号为外部链接,则会导致duplicated external simbols。因此如果头文件里要定义,必须保证定义的符号只能具有内部链接。

为什么常量默认为内部链接,而变量不是:
        这就是为了能够在头文件里如const int n = 0这样的定义常量。由于常量是只读的,因此即使每个编译单元都拥有一份定义也没有关系。如果一个定义于头文件里的变量拥有内部链接,那么如果出现多个编译单元都定义该变量,则其中一个编译单元对该变量进行修改,不会影响其他单元的同一变量,会产生意想不到的后果。

为什么函数默认是外部链接:
        虽然函数是只读的,但是和变量不同,函数在代码编写的时候非常容易变化,如果函数默认具有内部链接,则人们会倾向于把函数定义在头文件里,那么一旦函数被修改,所有包含了该头文件的编译单元都要被重新编译。另外,函数里定义的静态局部变量也将被定义在头文件里。

为什么类的静态变量不可以就地初始化:所谓就地初始化就是类似于这样的情况:
        class A
        {
            static char msg[] = "aha";
        };
不允许这样做得原因是,由于class的声明通常是在头文件里,如果允许这样做,其实就相当于在头文件里定义了一个非const变量。

在C++里,头文件定义一个const对象会怎么样:
        一般不会怎么样,这个和C里的在头文件里定义const int一样,每一个包含了这个头文件的编译单元都会定义这个对象。但由于该对象是const的,所以没什么影响。但是:有2种情况可能破坏这个局面:
        1。如果涉及到对这个const对象取地址并且依赖于这个地址的唯一性,那么在不同的编译单元里,取到的地址可以不同。(但一般很少这么做)
        2。如果这个对象具有mutable的变量,某个编译单元对其进行修改,则同样不会影响到别的编译单元。

为什么类的静态常量也不可以就地初始化:
        因为这相当于在头文件里定义了const对象。作为例外,int/char等可以进行就地初始化,是因为这些变量可以直接被优化为立即数,就和宏一样。

内联函数:
        C++里的内联函数由于类似于一个宏,因此不存在链接属性问题。

为什么公共使用的内联函数要定义于头文件里:
        因为编译时编译单元之间互相不知道,如果内联函数被定义于.cpp文件中,编译其他使用该函数的编译单元的时候没有办法找到函数的定义,因此无法对函数进行展开。所以说如果内联函数定义于.cpp文件里,那么就只有这个cpp文件可以是用这个函数。

头文件里内联函数被拒绝会怎样:
        如果定义于头文件里的内联函数被拒绝,那么编译器会自动在每个包含了该头文件的编译单元里定义这个函数并且不导出符号。

如果被拒绝的内联函数里定义了静态局部变量,这个变量会被定义于何处:
        早期的编译器会在每个编译单元里定义一个,并因此产生错误的结果,较新的编译器会解决这个问题,手段未知。

为什么export关键字没人实现:
        export要求编译器跨编译单元查找函数定义,使得编译器实现非常困难。

时间: 2024-10-13 15:26:45

C++之编译器与链接器工作原理的相关文章

浅谈C++编译原理 ------ C++编译器与链接器工作原理

原文:https://blog.csdn.net/zyh821351004/article/details/46425823 第一篇:     首先是预编译,这一步可以粗略的认为只做了一件事情,那就是“宏展开”,也就是对那些#***的命令的一种展开. 例如define MAX 1000就是建立起MAX和1000之间的对等关系,好在编译阶段进行替换. 例如ifdef/ifndef就是从一个文件中有选择性的挑出一些符合条件的代码来交给下一步的编译阶段来处理.这里面最复杂的莫过于include了,其实

C++编译器与链接器工作原理

http://blog.csdn.net/success041000/article/details/6714195 1. 几个概念 1)编译:把源文件中的源代码翻译成机器语言,保存到目标文件中.如果编译通过,就会把CPP转换成OBJ文件. 2)编译单元:根据C++标准,每一个CPP文件就是一个编译单元.每个编译单元之间是相互独立并且互相不可知. 3)目标文件:编译所生成的文件,以机器码的形式包含了编译单元里所有的代码和数据. 还有一些其他信息,如未解决符号表,导出符号表和地址重定向表等.目标文

调试器工作原理

调试器工作原理(3):调试信息 本文是调试器工作原理探究系列的第三篇,在阅读前请先确保已经读过本系列的第一和第二篇. 本篇主要内容 在本文中我将向大家解释关于调试器是如何在机器码中寻找C函数以及变量的,以及调试器使用了何种数据能够在C源代码的行号和机器码中来回映射. 调试信息 现代的编译器在转换高级语言程序代码上做得十分出色,能够将源代码中漂亮的缩进.嵌套的控制结构以及任意类型的变量全都转化为一长串的比特流--这就是机器码.这么做的唯一目的就是希望程序能在目标CPU上尽可能快的运行.大多数的C代

Java类加载器工作原理

Java类加载器是用来在运行时加载类(*.class文件).Java类加载器基于三个原则:委托.可见性.唯一性.委托原则把加载类的请求转发给父 类加载器,而且仅加载类当父 类加载器无法找到或者不能加载类时.可见性原则允许子类加载器查看由父类加载器加载的所有的类,但是父类加载器不能查看由子类加载器加载的类.唯一性原则只允许加载一次类文件,这基本上是通过委托原则来实现的并确保子类加载器不重新加载由父类加载器加载过的类.正确的理解类加载器原理必须解决像 NoClassDefFoundError in

C编译器、链接器、加载器详解

摘自http://blog.csdn.net/zzxian/article/details/16820035 C编译器.链接器.加载器详解 一.概述 C语言的编译链接过程要把我们编写的一个c程序(源代码)转换成可以在硬件上运行的程序(可执行代码),需要进行编译和链接.编译就是把文本形式源代码翻译为机器语言形式的目标文件的过程.链接是把目标文件.操作系统的启动代码和用到的库文件进行组织形成最终生成可加载.可执行代码的过程. 过程图解如下: 预处理器:将.c 文件转化成 .i文件,使用的gcc命令是

你好,C++(4)2.1.3 我的父亲母亲:编译器和链接器 2.1.4 C++程序执行背后的故事

2.1.3  我的父亲母亲:编译器和链接器 从表面上看,我是由Visual Studio创建的,而实际上,真正负责编译源代码创建生成可执行程序HelloWorld.exe的却是Visual Studio中集成的C++编译器cl.exe和链接器link.exe.他们二老,才是我的亲生爹妈. 为了便于人们的编写.阅读和维护,我们的源文件是使用C++这种人们可以理解的高级程序设计语言编写的.然而,计算机却并不理解这种高级语言,也就无法直接执行高级语言编写而成的源文件.所以,这里就需要一个翻译的工作,将

自己动手写编译器、链接器作者自序

<自己动手写编译器.链接器> 纸上得来终觉浅,绝知此事要躬行. --陆游 编译原理与技术的一整套理论在整个计算机科学领域占有相当重要的地位,学习它对程序设计人员有很大的帮助.我们考究历史会发现那些人人称颂的程序设计大师都是编译领域的高手,像写出BASIC语言的比尔·盖茨,Sun公司的Java之父等,在编译领域都有很深的造诣.曾经在世界首富宝座上稳坐多年的比尔·盖茨也是从给微机编写BASIC语言编译器起家的,也正是这个BASIC编译器为比尔·盖茨和保罗·艾伦的微软帝国奠定了基础.这个编写BASI

自己动手写编译器、链接器一书作者自序

<自己动手写编译器.链接器>  购买网址 纸上得来终觉浅,绝知此事要躬行. --陆游 编译原理与技术的一整套理论在整个计算机科学领域占有相当重要的地位,学习它对程序设计人员有很大的帮助.我们考究历史会发现那些人人称颂的程序设计大师都是编译领域的高手,像写出BASIC语言的比尔·盖茨,Sun公司的Java之父等,在编译领域都有很深的造诣.曾经在世界首富宝座上稳坐多年的比尔·盖茨也是从给微机编写BASIC语言编译器起家的,也正是这个BASIC编译器为比尔·盖茨和保罗·艾伦的微软帝国奠定了基础.这个

(转载)你好,C++(4)2.1.3 我的父亲母亲:编译器和链接器 2.1.4 C++程序执行背后的故事

你好,C++(4)2.1.3 我的父亲母亲:编译器和链接器 2.1.4 C++程序执行背后的故事 2.1.3  我的父亲母亲:编译器和链接器 从表面上看,我是由Visual Studio创建的,而实际上,真正负责编译源代码创建生成可执行程序HelloWorld.exe的却是Visual Studio中集成的C++编译器cl.exe和链接器link.exe.他们二老,才是我的亲生爹妈. 为了便于人们的编写.阅读和维护,我们的源文件是使用C++这种人们可以理解的高级程序设计语言编写的.然而,计算机却