c++编译器的工作

来源:互联网 发布:淘宝开店培训骗局 编辑:程序博客网 时间:2024/05/29 14:12

Linux下,用GCC来编译程序(如gcc test.c )可以分解为4个步骤,分别是预处理、编译、汇编和链接。

具体如下图所示:



1、预处理

 首先是源代码文件test.c和相关的头文件,如stdio.h等被预处理器cpp预处理成一个.i文件。第一步预处理的过程相当于如下命令(-E表示只进行预处理):

 gcc -E test.c -o test.i

    预处理过程主要处理那些源代码文件只能够的以"#"开始的预编译指令。比如“#include”、“#define”等,主要处理规则如下:
    (1)将所有的“#define“删除,并且展开所有的宏定义;
    (2)处理所有条件预编译指令,比如”#if“、”#ifdef“、”#elif“、”#else“、#endif;
    (3)处理”#include“预编译指令,将被包含的文件插入到该预编译指令的位置。注意:这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件;
    (4)标记化 。每一条注释被一个单独的空字符所替换。C++双字符运算符被识别为标记(为了开发可读性更强的程序,C++为非ASCII码开发者定义了一套双字符运算符集和新的保留字集)。源代码被分析成预处理标记。
    (5)添加行号和文件名标识,比如#2 "test.c" 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号;
    (6)保留所有的#pragma编译器指令,因为编译器需要使用它们;

    (7)字符映射 。文件中的物理源字符被映射到源字符集中,其中包括三字符运算符的替换、控制字符(行尾的回车换行)的替换。 
    (8)行合并 。以反斜杠\结束的行和它接下来的行合并。
    经过预编译后的.i文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到.i文件中。所以当我们无法判断宏定义是否正确或头文件包含是否正确时,可以查看预处理后的文件来确定问题。

2、编译 

 编译过程就是把预处理完的文件进行一系列的词法分析、语法分析、语义分析以及优化后产生相应的汇编代码文件,这个过程往往是我们所说的整个程序的构建的核心部分,也是最复杂的部分之一。上面的编译过程相当于如下命令:

 gcc -S test.-o test.s

    现在版本的GCC把预处理和编译两个步骤合并成一个步骤,使用一个叫ccl的程序来完成两个步骤。我们可以直接使用如下命令:

 gcc -S test.-o test.s

    都可以得到汇编输出文件test.s。对于C语言代码来说,这个预处理和编译的程序是ccl,对于C++来说,有对应的程序叫做cclplus。
    所以实际上gcc这个命令只是这些后台程序的包装,它会根据不同的参数要求取调用预处理编译程序ccl、汇编器as、链接器ld。

(1)编译步骤

    从最直观的角度来讲,编译器就是将高级语言翻译成机器语言的一个工具。比如我们可以用C/C++语言写的一个程序可以使用编译器将其翻译成机器可以执行的指令及数据。

    编译的过程一般分为6步:

词法分析、语法分析、语义分析、源代码优化、代码生成和目标代码优化。

整个过程如下图所示:

我们将结合上图来简单描述从源代码到最终目标代码的过程。以一段很简单的C语义的代码为例子来讲述这个过程。比如我们有一行C语义的源代码如下:

 array[index] = (index + 4) *( 2 + 6)

 CompilerExpression.c

(1)词法分析

    源代码程序被输入到扫描器(如叫lex的工具程序),扫描器的任务是进行词法分析,运用一种类似于有限状态机的算法可以将源代码的字符序列分割成一系列的记号。
   比如上面的那行程序,总共包含了28个非空字符,经过扫描后,产生了16个记号,如下表所示。


词法分析产生的记号一般可分为如下几类:关键字、标识符、字面量(包含数字、字符串等)和特殊记号(如加好、等号)。在识别记号的同时,扫描器也完成了其他工作。比如将标识符存放到符号表中,将数字、字符串常量存放到文字表等,以备后面的步骤使用。

例如:

1)字符集映射
源字符集成员、转义标识被转换成等价的执行字符集成员。例如:'\a'在ASCII环境下会被转换成值为一个字节,值为97。

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

(2)语法分析

     语法分析器将对由扫描器产生的记号进行语法分析,从而产生语法树。由语法分析器生成的语法树就是以表达式为节点的树。C语言的一个语句是一个表达式,而复杂的表达式由很多表达式组合。它在经过语法分析器以后形成如下图所示的语法树。


从上图可以看到,整个语句被看作一个赋值表达式:赋值表达式的左边是一个数组表达式,它的右边是一个乘法表达式;数组表达式又由两个符号表达式组成,等等。符号和数字是最小的表达式,它们不是由其他的表达式来组成的,所以通常为整个语法树的叶节点。在语法分析的同时,很多运算符的优先级和含义也被确定下来。比如乘法表达式的优先级比加法高,而圆括号表达式的优先级比乘法高等等。如果出现了表达式不合法,比如各种括号不匹配、表达式中缺少操作符等等,编译器就会报告语法分析阶段的错误。

    语法分析也有一个工具叫yacc。它可以根据用户给定的语法规则对输入的记号序列进行解析,从而构建一棵语法树。

(3)语义分析

    语义分析,由语义分析器完成。语法分析仅仅是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。比如C语言里面两个指针做乘法运算是没有意义的,但是这个语句在语法上是合法的。编译器所能分析的语义是静态语义所谓静态语义是指在编译期间可以确定的语义。与之对应的动态语义就是只有在运行期间才能确定的语义。
1)静态语义

   通常包括声明和类型的匹配,类型的转换。比如当一个浮点型的表达式赋值给一个整型的表达式时,其中隐含了一个浮点型赋值给一个指针的时候,语义分析程序会发现这个类型不匹配,编译器将会报错。
 2)
动态语义

一般指在运行期间出现的语义相关的问题,比如将0作为除数是一个运行期语义错误。
    
经过语义分析阶段后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转化,语义分析程序会在语法树中插入相应的转换节点。上面描述的语法树在经过语义分析阶段以后成为了如下图所示的形式。

可以看到,每个表达式(包括符号和数字)都被标识了类型。该例子中几乎所有的表达式都是整型的,所以无需做转换。
语义分析器还对符号表里的符号类型也做了更新。

(4) 中间语言生成

    现在的编译器有着很多层次的优化,往往在源代码级别会有一个优化过程,该工作由源代码级别优化器实现。 

在上例中,可以发现,(2+6)这个表达式可以被优化掉,因为它的值在编译期间就可以被确定。

经过优化的语法树如下图所示。


可以看到(2 + 6)这个表达式被优化成8.其实直接在语法树上作优化比较困难,所以源代码优化器往往将整个语法树转换成中间代码,它是语法树的顺序表示,其实它已经非常接近目标代码了。但是它一般跟目标机器和运行时环境是无关的,比如不包含数据的尺寸、变量的地址和寄存器的名字等。中间代码有很多种类型,在不同的编译器中有着不同的形式,比如三地址码等。

    中间代码使得编译器可以被分成前端和后端。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。这样对于一些可以跨平台的编译器而言,它们可以针对不同的平台使用同一个前端和不同的后端。

(5)目标代码生成

    编译器后端主要包括代码生成器和目标代码优化器。
   
    代码生成器将中间代码转换成目标代码,这个代码十分依赖于目标机器,因为不同的机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型等。
    
对于上面例子的中间代码,代码生成器可能生成下面的代码序列:

(6)目标代码优化

目标代码优化器对上面目标代码进行优化,比如选择合适的寻址方式、使用位移来代替乘法等。

(7)目标文件提供的表

    编译器经过词法分析、语法分析、语义分析、源代码优化、目标代码生成和目标代码优化,将源代码编译成了目标代码。
但是这个目标代码中有一个问题:indexarray的地址还没有确定。如果我们把目标代码使用汇编器编译成真正能够在机器上执行的指令,那么indexarray的地址从哪里得的呢?如果indexarray定义在跟上面的源代码同一个编译单元里面,那么编译器可以为indexarray分配空间,确定它们的地址,否则他们会成为未解决的符号,在连接阶段再进行处理,这就需要将符号保存到目标文件的表中(在C/C++中,每一个变量及函数都会有自己的符号,如变量n的符号就是n,函数的符号会更加复杂,假设FunA的符号就是_FunA,函数符号根据编译器不同而不同)。

目标文件除了提供数据和二进制代码外,要提供三个表:未解决符号表,导出符号表和地址重定向表。
未解决符号表:列出了本编译单元里有引用但是不在本编译单元定义的符号及其出现的地址。
导出符号表:提供了本编译单元中定义,并且可以提供给其他编译单元使用的符号及其在本编译单元中的地址。

地址重定向表:提供了所有编译单元在可执行文件中的位置。

代码分析:

A.cpp文件,如下定义:
int n = 1;void FunA(){  ++n;}
目标文件A.o 包含以上的数据和函数的符号,包括n、FunA。
以文件偏移量形式展示如下(??表示长度未知):
偏移量    内容    长度
0x0000    n       4
0x0004    FunA    ??
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.o的符号展示如下:
偏移量    内容    长度
0x0000    FunB    ??
因为n被声明为extern,则是告诉编译器n在别的编译单元里定义,在这个单元里就不需要定义和分配空间。
由于编译单元之间是互不相关的,所以编译器在编译阶段不知道n的地址,函数FunB就在目标代码中没有办法生成n的地址,其目标代码如下:
0x0000 inc DWORD PTR[????]
0x00?? ret

导出符号表

A.o的导出符号表

符号    地址
n       0x0000
_FunA   0x0004
B.o的导出符号表
符号    地址
_FunB   0x0000

未解决符号表

A.o的未解决符号表

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

B.o的未解决符号表

符号    地址
n       0x0001
该表告诉链接器,在本编译单元0x0001位置有一个符号n,该地址不明。
在链接时,在B.o中发现了未解决符号的定义,就会在所有的编译单元中的导出符号表去查找与这个未解决符号相匹配的符号名,如果找到,就把这个符号的地址填到B.o的未解决符号的地址处。
如果没有找到,就会报链接错误。在此例中,会在A.o的导出符号表中找到符号n,并把其地址填到B.o的0x0001处。

地址重定向表:

由于每个编译单元的地址都是从0x0000开始,如n在A.o中的地址是0x0000,函数FunB在B.o中的地址是0x0000,那么多个目标文件链接时就会地址重复。所以链接器在链接时就会根据地址重定向表对每个目标文件的地址进行调整。地址重定向表记录每个目标文件在可执行文件的地址的。

比如B.o的0x0000被定位到可执行文件的0x00001000上,而A.o的n的地址0x0000被定位到可执行文件的0x00002000上,在连接阶段,A.o的导出符号地地址都会加上0x00002000,B.o所有的符号地址也会加上0x00001000。则每个编译单元的导出符号的地址就不会重复。

3、汇编

汇编器是将目标代码转变成机器码,一般每一个汇编语句对应一条机器指令。 

操作方式如下:
调用汇编器as来进行汇编

 as test.-o test.o

 或者使用gcc调度汇编器as

 gcc -c test.s -o test.o

或者使用gcc命令从C源代码文件开始,经过预处理、编译和汇编直接输出目标文件:

 gcc -g test.-o test.o

4、链接

    链接的工作就是加入静态库的导出符号表,把目标代码中重定位导出符号表的符号地址、填充未解决符号表中的符号地址,汇编各个最终的目标代码,生成可执行文件。

(1)工作顺序

1)当链接器进行链接的时候,首先决定各个目标文件在最终可执行文件里的位置。

2)访问所有目标文件的地址重定向表,对其中记录的地址进行重定向(加上一个偏移量,即该编译单元在可执行文件上的起始地址)。

3)遍历所有目标文件的未解决符号表,并且在所有的导出符号表里查找匹配的符号,并在未解决符号表中所记录的位置上填写实际地址。

4)把所有的目标文件的内容写在各自的位置上,就生成一个可执行文件。

    静态链接过程如下图所示,目标文件和静态库一起链接形成最终可执行文件: 

 

汇编过程中的符号重定位:

结合具体的CPU指令来了解这个过程。假设有个全局变量叫做var,定义在目标文件A中。在目标文件B里面要访问这个全局变量,比如在目标文件B里面有这么一条指令:

  movl$0x2a, var

这条指令就是给这个var变量赋值0x2a,相当于C语言中的语句var =42。然后汇编目标文件B,得到这条指令机器码,如下图所示。


由于在汇编目标文件B的时候,编译器并不知道变量var的目标地址,所以编译器在没法确定地址的情况下,将这条mov指令的目标地址设为0,等待链接器在将目标文件AB链接起来的时候再将其修正。假设AB链接后,变量var的地址确定下来为0x1000,那么链接器将会把这个指令的目标地址部分修改成0x10000。这个地址修正的过程也叫做重定位,每个要被修正的地方叫一个重定位入口,依据是各个目标代码的导出符号表和地址重定向表。

(2) 外部链接和内部链接

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

1)extern

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

2)static

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

4)const变量

使用内部链接。

4)函数和其变量

对于函数和变量,默认链接是外部链接.被置入导出符号表

 (3)声明和定义

1)头文件里一般只声明

头文件可以被多个编译单元包含,如果头文件里面有定义的话,那么每个包含这头文件的编译单元都会对同一个符号进行定义,如果该符号为外部链接,则会导致duplicated external symbols链接错误。

2)内联函数需定义于头文件

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

(4)重载处理

函数经过编译系统的翻译成汇编,函数名对应着汇编标号。

因为C编译函数名与得到的汇编代号基本一样,如:fun()=>_fun, main=>_main但是C++中函数名与得到的汇编代号有比较大的差别。

如:由于函数重载,函数名一样,但汇编代号绝对不能一样。为了区分,编译器会把函数名和参数类型合在一起作为汇编代号,这样就解决了重载问题。

具体如何把函数名和参数类型合在一起,要看编译器的帮助说明了。

(5)c函数调用声明

如果C++调用C,如fun(),则调用名就不是C的翻译结果_fun,而是带有参数信息的一个名字,因此就不能调用到fun(),为了解决这个问题,加上extern "C"表示该函数的调用规则是C的规则,则调用时就不使用C++规则的带有参数信息的名字,而是_fun,从而达到调用C函数的目的。

 
 

0 0
原创粉丝点击