c++编译过程简述

这里只是简要的概述,概念性的理解。深入理解,望君研读《编译原理》。此处广告:当当有售!

一、何为编译

       编译就是把 .h和.cpp文件翻译成计算机能够执行的二进制文件(比如:windows中的一种,exe,还有.dll),该文件格式由操作系统定义。这和.doc格式的word文档没什么本质分别,里面有对应的二进制告诉你,哪里换行,哪里加粗,哪里开始,哪里是文档的结尾。

二、从代码到EXE之路

        从一堆 .h和.cpp到.exe,要经过预编译、编译、链接三道关卡。

        (一) 预编译(precompile):主要进行“宏展开”操作,就是对那些#***的命令的一种展开

      
#ifndef MAIN_HEADER
#define MAIN_HEADER

#include "stdafx.h"
#define PI  3.1415926

struct xx
{
};

class XX
{
};

#endif

      例1:define PI 3.1415926 就是建立起PI和3.1415926之间的映射关系,好在编译阶段将PI替换位3.1415926。

     例2:ifndef/endif就是从一个文件中有选择性的挑出一些符合条件的代码来交给下一步的编译阶段来处理。这里,MAIN_HEADER就是该段代码的一个标识符(自定义,不可重复,常与文件名相关)。如果定义了这个宏标识符,这下面的代码就不用编译了,如果没有则编译。保证该段代码只编译一次。这个宏命令用法强大,用处多多。详解可以参看:https://blog.csdn.net/loop_k/article/details/4972811。

      例3:啊,include!相当于把xxx.h文件里面的内容复制一份到这条include "xxx.h"语句的地方来。.h里面还有include怎么办?那就嵌套式继续复制替换呗!假如下面这种情况:AB.cpp中包含A和B两个头文件,而AB都包含C头文件,那就通过例2的方法保证C头文件的内容只复制一次。如果在两个不同的.cpp中同时包含F.h怎么办?答:各复制各的,互不干扰。C++的铁律就是先声明后使用,不声明就想使用,想上天呀!哈哈!

// AB.CPP:
#include "A.h"
#include "B.h"

D::D()
{
}

// A.h:
#include "C.h"
struct A
{
}

// B.h:
#include "C.h"
struct B
{
}

         (二) 编译(compile):编译器对源文件进行编译,把源文件中的文本形式存在的源代码翻译成机器语言形式的目标文件。

             重点来啦! 根据C++标准,每一个CPP文件就是一个编译单元。每个编译单元之间是相互独立并且互相不可知。编译后,可以用记事本打开的文本形式的代码将变成.obj格式的二进制形式,这时记事本打开就是乱码啦!每一个.cpp源文件对应一个.obj目标文件。目标文件以机器码的形式包含了编译单元里所有的代码和数据,还有一些其他信息,如未解决符号表,导出符号表和地址重定向表等。注意:目标文件是以二进制的形式存在的。从.cpp到.obj,主要干的活:1.检验函数或者变量是否存在它们的声明;2.检查语句是否符合C++语法;3.生成未解决符号表,导出符号表和地址重定向表等。

            所以这时就会产生waring和erro:中文的分号、小括号不够对儿、找不到函数(函数没有声明<——没有包含相应的头文件)等等。

         特别注意:未解决符号表,导出符号表和地址重定向表是非常非常非常重要的!!!要想把.obj链接为一个整体,就得靠这个啦!每个.cpp的.obj,就是通过这三个东东产生联系的。

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

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

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

           摘自https://blog.csdn.net/yfmmtz/article/details/48288863的一个例子

面让我们来分析一下编译器的工作过程: 
我们跳过语法分析,直接来到目标文件的生成,假设我们有一个A.cpp文件,如下定义:

int n = 1;
void FunA()
{
    ++n;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

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

偏移量    内容    长度

0x0000    n       4

0x0004    FunA    ??
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

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

0x0004 inc DWORD PTR[0x0000]
0x00?? ret
  • 1
  • 2
  • 3

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

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

extern int n;
void FunB()
{
    ++n;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

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

偏移量    内容    长度

0x0000    FunB    ??
  • 1
  • 2
  • 3
  • 4

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

0x0000 inc DWORD PTR[????]

0x00?? ret
  • 1
  • 2
  • 3
  • 4

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

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

符号    地址

n       0x0000

_FunA   0x0004
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

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

符号    地址

_FunB   0x0000
  • 1
  • 2
  • 3
  • 4

未解决符号表

符号    地址

n       0x0001
  • 1
  • 2
  • 3
  • 4

   这个表告诉链接器,在本编译单元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。

        (三) 链接(build):把所有编译好的单元全部链接为一个整体文件,这一步可以比作一个“连线”的过程,比如A文件用了B文件中的函数,那么链接的这一步会建立起这个关联。

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

       特别注意:链接时最重要的一项工作就是检查全局空间里面是不是有重复定义或者缺失定义,也就是:各.obj的导出符号表中是否有重名的;各.obj的未解决符号表中存在各导出符号表中没有的符号。这也为什么我们一般不在头文件中出现定义,而在.cpp中定义全局变量。因为头文件有可能被多个源文件包含,每个源文件都会单独编译,链接时就会发现全局空间中有多个定义了。


附录一: 标准C和C++将编译过程定义的9个阶段(Phases of Translation):

1.字符映射(Character Mapping)

      文件中的物理源字符被映射到源字符集中,其中包括三字符运算符的替换、控制字符(行尾的回车换行)的替换。许多非美式键盘不支持基本源字符集中的一些字符,文件中可用三字符来代替这些基本源字符,以??为前导。但如果所用键盘是美式键盘,有些编译器可能不对三字符进行查找和替换,需要增加-trigraphs编译参数。在C++程序中,任何不在基本源字符集中的字符都被它的通用字符名替换。

 

2.行合并(Line Splicing)

    以反斜杠/结束的行和它接下来的行合并。

 

3.标记化(Tokenization)

    每一条注释被一个单独的空字符所替换。C++双字符运算符被识别为标记(为了开发可读性更强的程序,C++为非ASCII码开发者定义了一套双字符运算符集和新的保留字集)。源代码被分析成预处理标记。

 

4.预处理(Preprocessing)

    调用预处理指令并扩展宏。使用#include指令包含的文件,重复步骤1到4。上述四个阶段统称为预处理阶段。

 

5.字符集映射(Character-set Mapping)

    源字符集成员、转义序列被转换成等价的执行字符集成员。例如:'/a'在ASCII环境下会被转换成值为一个字节,值为7。

 

6.字符串连接(String Concatenation)

    相邻的字符串被连接。例如:"""hahaha""huohuohuo"将成为"hahahahuohuohuo"。

 

7.翻译(Translation)

    进行语法和语义分析编译,并翻译成目标代码。

 

8.处理模板

    处理模板实例。

 

9.连接(Linkage)

    解决外部引用的问题,准备好程序映像以便执行。

附录二:

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

  • static:如果该关键字位于全局函数或者变量的声明前面,表明该编译单元不导出这个函数或变量,因些这个符号不能在别的编译单元中使用(内部链接)。如果是static局部变量,则该变量的存储方式和全局变量一样,但是仍然不导出符号。

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

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

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

附录三:using namespace

详见:https://blog.csdn.net/duanboqiang/article/details/53036389





猜你喜欢

转载自blog.csdn.net/fl2_pigy/article/details/80640352