前言:
程序的编译和链接是计算机将我们所写的程序变成相应的计算机语言并生成可执行程序呈现在电脑上的过程,虽然对于此块内容对于C语言的知识点方面关系不大,但对于我们理解计算机如何将我们所写的程序变成计算机语言有很大的帮助。
1.计算机的两种环境:
在ANSI C中存在两种环境:
1.翻译环境:用于将我们所写的源代码转换成计算机可以识别的机器指令。
2.执行环境:用于执行我们写的代码的指令。
他们两个的关系如下:
也就是说,我们所写的文件首先要在翻译环境种翻译成对应的二进制指令,然后进入运行环境中进行执行变成可执行程序运行处理。
1.翻译环境:
翻译环境由两个部分组成。
1.编译
2.链接
其大致关系如下:
即可执行程序首先进入编译器进行编译阶段,源文件经编译器编译后变成目标文件XXX.obj(LINUX中可能为.O),然后与编译库通过链接器进行链接,这里的链接库里面就封装着一些库函数和第三方函数文件。最终形成可以进入执行环境的执行文件运行。
1.编译:
那么编译阶段是怎样执行的?
编译大致分为3个部分,第一部分为预处理(预编译),第二部分为编译,第三部分为汇编
1.预处理阶段(预编译阶段)
预处理阶段主要处理那些源⽂件中#开始的预编译指令。⽐如:#include,#define,处理的规则如下:
• 将所有的 #define 删除,并展开所有的宏定义,也就是说将#define定义的数据变成实际的数据再一次返回给变量
例如:#define 100 MAX
int a=MAX;
预处理之后就变成:
int a=100;
• 处理所有的条件编译指令,如: #if、#ifdef、#elif、#else、#endif 。
• 处理#include 预编译指令,将包含的头⽂件的内容插⼊到该预编译指令的位置。这个过程是递归进
⾏的,也就是说被包含的头⽂件也可能包含其他⽂件,注意,此时的#include库函数会展开并且是以递归的形式将全部的库函数写入程序上方,也就是说,库函数内部还包含着其他库函数,这些库函数会全部展开。
• 删除所有的注释
• 添加⾏号和⽂件名标识,⽅便后续编译器⽣成调试信息等。
• 或保留所有的#pragma的编译器指令,编译器后续会使⽤。
经过预处理后的.i⽂件中不再包含宏定义,因为宏已经被展开。并且包含的头⽂件都被插⼊到.i⽂件
中。所以当我们⽆法知道宏定义或者头⽂件是否包含正确的时候,可以查看预处理后的.i⽂件来确认。
当预处理阶段结束后,文件会变成以.i结束的文件,此时进入编译的下一个阶段——编译
2.编译阶段
编译过程就是将预处理后的⽂件进⾏⼀系列的:词法分析、语法分析、语义分析及优化,⽣成相应的汇编代码文件,为下一步的汇编做准备,这一阶段的处理方式为:
1.词法分析:即将我们所写的程序拆分成一个又一个的字符记号,然后放入一个字符表中等待下一步语法分析进行识别
2.语法分析:对扫描到的记号进行相应的语法分析,形成语法多叉树方便后续的语义分析。
例如:
3.语义分析:语义分析是在语法分析排好的基础上进行进一步的识别,进行语法层面的分析,编译器在这里是静态分析,包括声明和类型是否匹配,函数参数调用是否符合规则,数组是否存在越界情况等。故,我们可以知道,程序的报错就是在这一阶段进行分析后报告错误的语法信息的。
如图:
在编译阶段结束后,文件会从.i文件变为.s文件,然后直接进入编译的最后一个阶段,即汇编阶段。
3.汇编阶段
汇编的意思是利用汇编器将汇编代码变成计算机可以理解的可执行指令。每⼀个汇编语句⼏乎都对应⼀条机器指令。就是根据汇编指令和机器指令的对照表⼀⼀的进⾏翻译,也不做指令优化。
汇编阶段结束后,文件会从.s文件变成我们的目标文件.obj或者.o,之后整个编译阶段就全部结束了,进入下一阶段即链接阶段。
2.链接:
在我们的主程序的编译阶段结束后,便来到了下一阶段即链接阶段,链接是一个很复杂的过程,它主要是把我们的目标文件和第三方或者已经封装好的函数库进行连接,从而让主程序可以自由调用函数。链接过程主要包括:库函数地址和空间分配,符号决议和重定位等这些步骤。
链接解决的是⼀个项⽬中多⽂件、多模块之间互相调⽤的问题。
例如:
我们这里使用了两个文件,第一个test.c是我们的主程序文件,第二个是我们写的函数文件add.c。在add.c里面的g_val和Add,想要在test.c中使用,就需要进行链接,计算机首先会识别所有的函数和一些声明,然后重新按照一定次序为其分配地址,这个过程便是重定义的过程。
详解:
我们在 test.c 的⽂件中使⽤了 add.c ⽂件中的 Add 函数和 g_val 变量。
我们在 test.c ⽂件中每⼀次使⽤ Add 函数和 g_val 的时候必须确切的知道 Add 和 g_val 的地
址,但是由于每个⽂件是单独编译的,在编译器编译 test.c 的时候并不知道 Add 函数和 g_val
变量的地址,所以暂时把调⽤ Add 的指令的⽬标地址和 g_val 的地址搁置。等待最后链接的时候由
链接器根据引⽤的符号 Add 在其他模块中查找 Add 函数的地址,然后将 test.c 中所有引⽤到
Add 的指令重新修正,让他们的⽬标地址为真正的 Add 函数的地址,对于全局变量 g_val 也是类似的⽅法来修正地址。这个地址修正的过程也被叫做:重定位。
倘若add.c文件中的g_val被注释掉,则对于主程序来说,这个声明不存在,就将其放到0X0000的位置,相当于不为其分配地址。故是否分配地址主要取决于它是否被声明使用,一旦被使用,即使在主函数和它之前的函数中的地址不相同,也会经过重定义合并同类项为其安排统一的地址,即符号表合并和段表合并的过程。
经过了编译和链接的两个过程,我们的程序就已经可以运行,跑起来了
2.执行环境(运行环境):
这一部分就是我们程序跑起来的过程,大致如下:
1. 程序必须载⼊内存中。在有操作系统的环境中:⼀般这个由操作系统完成。在独⽴的环境中,程序
的载⼊必须由⼿⼯安排,也可能是通过可执⾏代码置⼊只读内存来完成。
2. 程序的执⾏便开始。接着便调⽤main函数。
3. 开始执⾏程序代码。这个时候程序将使⽤⼀个运⾏时堆栈(stack),存储函数的局部变量和返回
地址。程序同时也可以使⽤静态(static)内存,存储于静态内存中的变量在程序的整个执⾏过程
⼀直保留他们的值。
4. 终⽌程序。正常终⽌main函数;也有可能是意外终⽌。,例如return 1可能由于动态内存开辟失败或者文件打开失败中止,而return 0则是正常的中止。
我们会发现:计算程序在执行环境中主要是将其写入内存的堆区和栈区进行存储和后续的读取等操作。
总结:
以上便是我对于翻译环境和执行环境的理解,从这篇文章我们要明白,学习计算机从来不仅仅是掌握一门语言或者掌握算法那样的简单,我们要深入了解我们在写什么,为什么这么写计算机能看懂,而且,我们要养成刨根问底的习惯,掌握其底层和基础,而不是仅仅浮于表面。
对于这部分知识感兴趣的朋友,可以去读一读《程序员的自我修养》,以便更好的去理解计算机的编译原理。