1.翻译环境和执行环境
1.1翻译环境
翻译环境又可以分为编译和链接,形成的可执行程序test.exe通过执行环境显示运行结果。
把源代码转换为可执行的机器指令(二进制指令),由编译器完成。
每个源文件经过编译器生成目标文件(windows下命名为xxx.obj,Linux下命名为xxx.o),目标文件生成后由链接器统一处理,并且会加上一些链接来的库,最后由链接器经过链接过程生成可执行程序。
库函数以来的库文件,都属于第三方库;如我们经常使用的printf、scanf函数都属于库函数。
1.2执行环境
程序执行的过程:
- 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
- 程序的执行便开始。接着便调用main函数。
- 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
- 终止程序。正常终止main函数;也有可能是意外终止。
实际执行代码,由操作系统完成。我经常使用的VS2019是一个集成开发环境,其中既有翻译环境,又有执行环境;
VS2019中的编译器是cl.exe,链接器是link.exe;这里我们可以看一下VS2019中的链接器(link.exe)是确实存在的:
2.编译本身的几个阶段
VS2019是集成开发环境,不方便观察每个细节;这里我们使用linux gcc来演示编译和链接的过程。
2.1 预处理(预编译)
在Linux环境下,创建两个文件test.c和add.c。
//test.c
#include <stdio.h>
extern int Add(int a, int b);
//定义一个宏NUM
#define NUM 100
int main()
{
int n = NUM;
int a = 10;
int b = 20;
int c = Add(a, b);
printf("%d\n", c);
#ifdef __DEBUG__
printf("这是一个条件编译,debug下才会执行\n");
#endif
return 0;
}
//add.c
int Add(int x, int y)
{
return x + y;
}
想要知道预处理之中做了什么,就需要在预处理之后停下来,Linux下指令 gcc test.c -E -o test.i,gcc add.c -E -o add.i可以生成的test.i,add.i就是预处理之后的文件;下面通过观察test.i来看预处理阶段做了什么。
下面在Linux环境下进行操作:
下面打开test.i文件进行观察:
1.int n = NUM直接进行了替换,说明预处理阶段进行了宏替换。
2.注释在预处理阶段之后就没了,说明预处理阶段去掉了注释。
3.debug条件编译,因为gcc默认是release,所以看不到,说明预处理阶段处理了条件编译。
还有一点就是我们#include <stdio.h>包含的头文件这里没有了,但是上面多了几百行的代码:
Linux环境下,头文件放在/usr/include中,那么我们对比一下库中的stdio.h:
对比可以得出预处理阶段的第四个处理功能:
4.头文件包含。
2.2 编译
想要知道编译阶段做了什么,同样需要在编译阶段后停下来;Linux下指令:gcc test.i -S -o test.s,gcc add.i -S -o add.s;生成的test.s,add.s就是编译阶段之后的文件。
下面来看一下test.s文件:
test.s中是一些汇编指令,需要进行语法分析、词法分析、语义分析、符号汇总; 《编译原理》-编译器的工作原理。
编译的主要作用就是把C语言代码转化为汇编代码。
编译的过程还是很复杂的,在这里给大家简单的介绍一下符号汇总(链接时需要用到):
汇总时只汇总全局的符号:main Add
2.3 汇编
在汇编阶段后停下来;Linux下指令:gcc test.s -c -o test.o,gcc add.s -c -o add.o;生成的test.o,add.o就是汇编阶段之后的文件。
下面观察下test.o文件:
可以发现,test.o中全是一些二进制指令,所以汇编阶段是把汇编指令转化为二进制指令,供计算机识别。
其中一个重要的阶段是形成符号表,每一个文件都形成自己的符号表。
Linux环境下:test.o 可执行程序的格式:elf,可以使用readelf工具进行读取。
readelf test.o -s (-s选项的作用就是显示符号表)
readelf add.o -s
形成的符号表(在链接时会使用到):
3.链接过程
1.合并段表
二进制文件,会被分成很多段;test.o,add.o都会被分为很多段,在合并段表的时候会把相同的段合并到一起。
2.符号表的合并和重定位
test.o中Add的地址是一个无效地址,add.o中Add的地址0x300是一个有效地址,合并的时候Add会合并为有效地址,而main函数是一个有效地址,不需要改变。
4.预处理详解
4.1#define定义标识符
#define NUM 100
#define STR "abcdef"
#define定义标识符还是很容易理解的,但是在写的时候需要注意:后面不要加分号,加分号可能会出现语法错误。
#defien NUM 100;
int main()
{
int num = 0;
if(1)
num = NUM;
else
num = -1;
return 0;
}
这样写在编译的时候就会报错:
if语句中没有{}时只能有一条语句,NUM带上分号之后if语句中是两条语句,出现了语法错误。
建议:在定义宏的时候后面不要加分号。
4.2#define定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
//#define定义宏的语法:
#define name( parament-list ) stuff
#define MAX(x, y) (x>y?x:y)
注意:参数列表的左括号必须与name紧邻,如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分 。
下面看这样一段代码:
#define SQUARE(x) x*x
int main()
{
int a = 9;
int r = SQUARE(a+1);
printf("%d\n", r);//19
return 0;
}
这里有很多人就会有疑问了,为什么结果不是100,而是19呢?
这里我们通过得到预处理之后的代码来看一下:
可以看到的是在参数a+1传入宏中时,直接进行了替换。
在定义的时候可以改进一下:
#define SQUARE(x) (x)*(x)
下面再举一个例子:
#define DOUBLE(x) (x)+(x)
int main()
{
int ret = 3*DOUBLE(20);//替换为3*(20)+(20) 80
return 0;
}
总结一下:在使用#define定义宏的时候一定要检查一下括号,不使用括号得到的结果可能就和期望的结果不一样。
4.3#define的替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
1.在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先
被替换。
2.替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
3.最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上
述处理过程。
注意:
1.宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
2.当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。