C++编译链接过程

GCC的编译过程

总体来说,C/C++源代码要经过:预处理编译汇编链接,四步才能变成相应平台下的可执行文件。

File: hw.c

#include <stdio.h>

int main(int argc, char *argv[])
{
  printf("Hello World!\n");
  return 0;
}
如果用gcc编译,只需要一个命令就可以生成可执行文件hw:

gcc -o hw.exe  hw.c

接下来我们按照编译顺序看看编译器每一步都做了什么:

cpp hw.-o hw.i  // 预处理 gcc -E hello.c -o hello.i

cc1 hw.i -o hw.s    // 编译 gcc -S hello.i -o hello.s

as hw.-o hw.o     // 汇编 gcc -c hello.s -o hello.o

ld hw.-o hw.exe   // 链接 gcc hello.o -o hello.exe


第一步,预处理,主要处理以下指令:宏定义指令,条件编译指令,头文件包含指令。 预处理所完成的基本上是对源程序的“替代”工作。经过此种替代,生成一个没有宏定义、没有条件编译指令,头文件都被展开(递归展开)的文件。

第二步,编译,就是把C/C++代码“翻译”成汇编代码。

第三步,汇编,就是将生成的汇编代码翻译成符合一定格式的机器代码,在Linux上一般表现为ELF目标文件。

第四步,链接,将生成的目标文件和系统库文件进行链接,最终生成了可以在特定平台运行的可执行文件。为什么还要链接系统库中的某些目标文件(crt1.o, crti.o等)呢?这些目标文件都是用来初始化或者回收C运行时环境的,比如说堆内存分配上下文环境的初始化等,实际上crt也正是C RunTime的缩写。这也暗示了另外一点:程序并不是从main函数开始执行的,而是从crt中的某个入口开始的,在Linux上此入口是_start。而且默认情况下,ld是将这些系统库文件(本身也是动态库)都是以动态链接方式加入应用程序的,如果要以静态连接的方式进行,需要显示的指定ld命令的参数-static

此外,还有一个优化阶段。优化处理是编译系统中一项比较艰深的技术。它涉及到的问题不仅同编译技术本身有关,而且同机器的硬件环境也有很大的关系。优化一部分是对中间代码的优化。 这种优化不依赖于具体的计算机。另一种优化则主要针对目标代码的生成而进行的。对于前一种优化,主要的工作是删除公共表达式、循环优化(代码外提、强度削弱、变换循环控制条件、已知量的合并等)、复写传播,以及无用赋值的删除,等等。 后 一种类型的优化同机器的硬件结构密切相关,最主要的是考虑是如何充分利用机器的各个硬件寄存器存放的有关变量的值,以减少对于内存的访问次数。另外,如何 根据机器硬件执行指令的特点(如流水线、RISC、CISC、VLIW等)而对指令进行一些调整使目标代码比较短,执行的效率比较高。

目标文件的三个表:未解决符号表,导出符号表和地址重定向表

1、编译:编译器对源文件进行编译,就是把源文件中以文本形式存在的源代码翻译成机器语言形式的目标文件的过程,在这个过程中,编译器会进行一系列的语法检查。如果编译通过,就会把对应的CPP转换成OBJ文件。

2、目标文件:由编译所生成的文件,以机器码的形式包含了编译单元里所有的代码和数据,还有一些其它信息,如未解决符号表,导出符号表和地址重定向表等。目标文件是以二进制的形式存在的。

目标文件由段组成。通常一个目标文件中至少有两个段:
代码段:该段中所包含的主要是指令,该段一般是可读和可执行的,但一般却不可写。
数据段:主要存放程序中要用到的各种全局变量或静态数据。一般数据段都是可读,可写,可执行的。 

      根据C++标准,一个编译单元(Translation Unit)是指一个.cpp文件以及它所include的所有.h文件,.h文件里面的代码将会被扩展到.cpp文件里,然后编译器编译该.cpp文件生成一个.obj文件。当编译器将一个工程里的所有.cpp文件都编译完毕后,再由链接器进行链接,成为一个.exe或库文件。

下面让我们来分析一下编译器的工作过程,假设我们有一个A.cpp文件,如下定义:

    int n =1;

    void FunA()

    {

       ++n;

    }

   它编译出来的目标文件A.obj就会有一个区域(或者说是段),包含以上的数据和函数,其中就有n、FunA,以文件偏移量形式给出可能就是下面这种情况:

   偏移量   内容    长度

   0x0000         4

   0x0004   FunA    ??

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

   FunA的内容可能如下:

    0x0004 inc DWORD PTR[0x0000]

    0x00?? ret

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

    externint n;

    voidFunB()

    {

       ++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

   未解决符号表为

   符号    地址

          0x0001

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

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

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

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

   总结一下:

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

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

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

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

链接器的工作顺序:

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

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

重温C/C++中的特性:

   extern:这就是告诉编译器,这个变量或函数在别的编译单元里定义了,也就是要把这个符号放到未解决符号表里面去。

   static:如果static位于全局函数或者全局变量的声明前面,表明该编译单元不导出这个函数或变量,因此这个符号不能在别的编译单元中使用。

默认链接属性:对于函数和变量,默认链接是外部链接,对于const变量,默认内部链接。

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

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

 为什么常量默认为内部链接,而变量不是?

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

为什么头文件里一般只可以有声明不能有定义?

    头文件可以被多个编译单元包含,如果头文件里面有定义的话,那么每个包含这头文件的编译单元都会对同一个符号进行定义,如果该符号为外部链接,则会导致duplicatedexternal symbols链接错误。因此如果头文件里要定义,必须保证定义的符号只能具有内部链接。

为什么类的静态成员变量不可以就地初始化?

    由于class的声明通常是在头文件里,如果允许这样做,其实就相当于在头文件里定义了一个非const变量。

头文件里内联函数被拒绝会怎样?

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

如果被拒绝的内联函数里定义了静态局部变量,这个变量会被定义于何处?

     早期的编译器会在每个编译单元里定义一个,并因此产生错误的结果,较新的编译器会解决这个问题,手段未知。

在C++环境下使用C函数的时候,常常会出现编译器无法找到C函数定义,从而导致链接失败的情况,应该如何解决这种情况呢?   C++语言在编译的时候为了解决函数的多态问题,会将函数名和参数联合起来生成一个中间的函数名称,而C语言则不会,因此会造成链接时找不到对应函数的情况,此时C函数就需要用extern “C”进行链接指定,这告诉编译器,请保持我的名称,不要给我生成用于链接的中间函数名。例如,假设某个函数的原型为:   void foo( int x, int y );   该函数被C编译器编译后在符号表中的名字为_foo,而C++编译器则会产生像_foo_int_int之类的名字(不同的编译器可能生成的名字不同,但是都采用了相同的机制,生成的新名字称为“mangled name”)。   _foo_int_int这样的名字包含了函数名、函数参数数量及类型信息,C++就是靠这种机制来实现函数重载的。

相关文章
相关标签/搜索