文章目录
- 程序的翻译环境和执行环境
- 翻译环境
- 编译
- 预编译
- 头文件的包含
- 删除注释
- 替换#define定义的符号
- 编译
- 词法分析
- 语法分析
- 语义分析
- 符号汇总
- 汇编
- 链接
- 合并段表
- 符号表的合并和重定位
- 执行环境
程序的翻译环境和执行环境
在ANSI C的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
从 .c 到.exe的过程中需要依赖翻译环境
第2种是执行环境,它用于实际执行代码
翻译环境
编译
编译过程其实又被细分为三个环节,即预编译,编译和汇编
组成一个程序的每个源文件(以.c为后缀的文件)通过编译过程分别转换成目标代码(也就是以.obj为后缀的文件 )
每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序(.exe)。
链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人
的程序库,将其需要的函数也链接到程序中
如图:
总结:每个源文件 单独经过 编译器 处理,生成 目标文件 ,所有目标文件与链接库 一起,在 链接器的作用下生成可执行文件
先写一段代码 :
#include<stdio.h>
int main()
{
printf("hehe\n");
return 0;
}
运行后, 查看 .exe 文件 ,.exe 其实就是可执行程序
add.obj 和test.obj 这两个目标文件就是经过编译器的处理,最终生成了目标文件( .obj)
将test.c的文件预编译后生成test.i
预编译
预编译过程主要处理那些源代码文件中的以“#”开始的预编译指令。比如“#include”、“#define”等,主要处理规则如下:
-
将所有的“#define”删除,并且展开所有的宏定义。
-
处理所有条件预编译指令,比如“#if”、“#ifdef”、“#elif”、“#else”、“#endif”。
-
处理“#include”预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件。
-
删除所有的注释“//”和“/**/”。
-
添加行号和文件名标识,比如#2“hello.c”2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
-
保留所有的#pragma编译器指令,因为编译器须要使用它们。
经过预编译后的.i文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件
也已经被插入到.i文件中。所以当我们无法判断宏定义是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定问题。
在预编译这个环节,我们主要剖析三个部分 : 头文件的包含 、删除注释 、替换#define定义的符号
头文件的包含
在预编译阶段,编译器会将代码中所包含的头文件都替换成头文件的内容。例如,#include <stdio.h>这句代码会被替换成stdio.h这个头文件中的全部代码
test.c 文件里面有#include"test.h" ,#include"test.h" 的作用是将test.h的文件拷贝一份放到 test.i中
删除注释
在预编译阶段,编译器会将代码中所有的注释“//”和“/**/”
替换#define定义的符号
#define定义的标识符也好,定义的宏也罢,都是起到替换的作用,而真正进行替换的时刻便是预编译阶段
预编译阶段做的3件事,实际上都是一些文本操作,并没有运行该代码
编译
编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件,这个过程往往是我们所说的整个程序构建的核心部分,也是最复杂的部分之一。我们先简单介绍编译的具体几个步骤,这涉及编译原理等一些内容
词法分析
比如我们有一行C语言的源代码如下:
array[index] = (index+4)*(2+6)
首先源代码程序被输入到扫描器( Scanner),扫描器的任务很简单,它只是简单地进行词法分析,运用一种类似于有限状态机(Finite State Machine〉的算法可以很轻松地将源代码的字符序列分割成一系列的记号(Token)。
比如上面的那行程序,总共包含了28个非空字符,经过扫描以后,产生了16个记号,如表所示。
词法分析产生的记号一般可以分为如下几类:关键字、标识符、字面量(包含数字、字符串等)和特殊符号(如加号、等号)。在识别记号的同时,扫描器也完成了其他工作。比如将标识符存放到符号表,将数字、字符串常量存放到文字表等,以备后面的步骤使用。
有一个叫做lex的程序可以实现词法扫描,它会按照用户之前描述好的词法规则将输入的字符串分割成一个个记号。因为这样一个程序的存在,编译器的开发者就无须为每个编译器开发个独立的词法扫描器,而是根据需要改变词法规则就可以了
另外对于一些有预处理的语言,比如C语言,它的宏替换和文件包含等工作一般不归入编译器的范围而交给一个独立的预处理器。
语法分析
接下来语法分析器(Grammar Parser)将对由扫描器产生的记号进行语法分析,从而产生语法树(Syntax Tree)。
整个分析过程采用了上下文无关语法(Context-free Grammar)的分析手段,如果你对上下文无关语法及下推自动机很熟悉,那么应该很好理解。否则,可以参考一些计算理论的资料,一般都会有很详细的介绍。此处不再赘述。
简单地讲,由语法分析器生成的语法树就是以表达式(Expression)为节点的树
我们知道,C语言的一个语句是一个表达式,而复杂的语句是很多表达式的组合。
上面例子中的语句就是一个由赋值表达式、加法表达式、乘法表达式、数组表达式、括号表达式组成的复杂语句。它在经过语法分析器以后形成如图所示的语法树。
从图中我们可以看到,整个语句被看作是一个赋值表达式;
赋值表达式的左边是一个数组表达式,它的右边是一个乘法表达式;数组表达式又由两个符号表达式组成,等等。
符号和数字是最小的表达式,它们不是由其他的表达式来组成的,所以它们通常作为整个语法树的叶节点。
在语法分析的同时,很多运算符号的优先级和含义也被确定下来了。比如乘法表达式的优先级比加法高,而圆括号表达式的优先级比乘法高,等等。
另外有些符号具有多重含义,比如星号*在C语言中可以表示乘法表达式,也可以表示对指针取内容的表达式,所以语法分析阶段必须对这些内容进行区分。
如果出现了表达式不合法,比如各种括号不匹配、表达式中缺少操作符等,编译器就会报告语法分析阶段的错误
正如前面词法分析有lex一样,
语法分析也有一个现成的工具叫做yacc ( Yet AnotherCompiler Compiler )。它也像lex一样,可以根据用户给定的语法规则对输入的记号序列进行解析,从而构建出一棵语法树。
对于不同的编程语言,编译器的开发者只须改变语法规则,而无须为每个编译器编写一个语法分析器,所以它又被称为“编译器编译器(CompilerCompiler)”。
语义分析
接下来进行的是语义分析,由语义分析器(Semantic Analyzer)来完成。
语法分析仅仪是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。
比如C语言里面两个指针做乘法运算是没有意义的,但是这个语句在语法上是合法的;比如同样一个指针和一个浮点数做乘法运算是否合法等。
编译器所能分析的语义是静态语义( StaticSemantic),所谓静态语义是指在编译期可以确定的语义,与之对应的动态语义(DynamicSemantic)就是只有在运行期才能确定的语义。
经过语义分析阶段以后,整个语法树的表达式都被标识了类型,
如果有些类型需要做隐式转换,语义分析程序会在语法树中插入相应的的转换节点。
上面描述的语法树在经过语义分析阶段以后成为如图所示的形式
符号汇总
在这个环节中,会将每个源文件的全局范围的变量符号进行汇总
汇编
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了
将汇编代码翻译成二级制指令 ,这个二进制指令存放在目标文件中 ,同时会给每个源文件汇总出来的符号分配一个地址,然后分别生成一个符号表 ,编译步骤里的符号汇总就是为汇编形成的符号表服务的
test.c文件中提取的符号Add只是Add函数的声明,并不是定义,无法判断Add函数是否真正存在,所以test.c生成符号表时分配给Add符号的地址是一个无意义(非法)的地址
链接
合并段表
(vs生成的目标文件的后缀是.obj , gcc生成的目标文件后缀是.o)
汇编结束后所生成的obj文件内部会被划分为几个段,在链接过程中就会把每个obj文件对应的段通过某种规则合并起来,最后形成可执行程序(.exe为后缀)
符号表的合并和重定位
程序并不是一写好就永远不变化的,它可能会经常被修改。
比如我们在第1条指令之后、第5条指令之前插入了一条或多条指令,那么第5条指令及后面的指令的位置将会相应地往后移动,原先第一条指令的低4位的数字将需要相应地调整。
在这个过程中,我们需要人工重新计算每个子程序或跳转的目标地址。当程序修改的时候,这些位置都要重新计算,十分繁琐又耗时,并且很容易出错。这种重新计算各个目标的地址过程被叫做重定位
符号表并非无意义。如果需要调用某一函数时,编译器会在符号表中查找该符号,如果有,则调用成功,否则调用失败
符号表有来自全局变量 、函数等 ,不是所有的符号符号表都有 ,局部的变量不会存在符号表,因为局部变量只能在局部范围内使用 ,是不能跨文件使用
在一个程序被分割成多个模块以后,这些模块之间最后如何组合形成一个单一的程序是须解决的问题。
模块之间如何组合的问题可以归结为模块之间如何通信的问题,最常见的属于静态语言的C/C++模块之间通信有两种方式,
一种是模块间的函数调用,另外一种是模块间的变量访问。
函数访问须知道目标函数的地址,变量访问也须知道目标变量的地址,所以这两种方式都可以归结为一种方式,那就是模块间符号的引用。
模块间依靠符号来通信类似于拼图版,定义符号的模块多出一块区域,引用该符号的模块刚好少了那一块区域,两者一拼接刚好完美组合,模块的拼接过程就是链接(Linking)。
执行环境
exe程序执行的过程大概可以分为四个步骤:
程序首先要载入内存中。 在有操作系统的环境中,该操作一般由操作系统来完成。在独立的环境中,程序的载入可以由手工完成,也可以通过可执行代码置入只读内存来完成。
程序的执行开始。 接着便调用main函数。
开始执行程序代码。 这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留它们的值。
终止程序。 正常终止main函数,也可能是意外终止。
如果你觉得这篇文章对你有帮助,不妨动动手指给点赞收藏加转发,给鄃鳕一个大大的关注
你们的每一次支持都将转化为我前进的动力!!!