0
点赞
收藏
分享

微信扫一扫

预处理和程序的编译与链接详解

dsysama 2022-03-11 阅读 98

我们知道计算机只能识别二进制的机器指令,也就是我们所写的C语言文件最终都会通过某种防止转变成可被机器理解的二进制指令!而这样的一份工作就是由编译器来为我们实现的。那么编译器的整个工作流程是怎样的呢?一份C语言代码又经历了什么样的变化最终成为机器可识别的二进制文件呢?别急,这篇文章就带你来了解一下C语言文件的前世今生!

本篇博客重点:

1.  程序的翻译环境
2.  程序的执行环境
3.  详解:C语言程序的编译+链接
4.  预定义符号介绍
5.  预处理指令 #define
6.  宏和函数的对比
7.   预处理指令 #include
8.  预处理指令 #undef
9.  条件编译

在ANSI C的任何一种实现中,存在两个不同的环境

1.翻译环境:顾名思义就是在这个环境下,一份源代码文件会被翻译成可执行的机器指令。

2.执行环境:在这个环境下,翻译好的代码将会被执行。

那么整个程序较为粗略的编译和运行的情况如下:

 而翻译环境的执行流程可以用如下的一张图来展示:

 首先,每一份源代码都会通过编译器的编译生成目标文件(.obj),接着每个目标文件会被链接器捆绑在一起形成统一的一个目标文件,如果在源文件中使用了库函数,那么在链接器在链接目标

文件的同时也会链接到存放库函数代码的静态库,最后生成可执行程序。

2.编译也分很多阶段:

对于编译这个动作也分为好几个阶段,以两个源文件的程序为例:

sum.c源文件的内容:

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<stdlib.h>
int g_val = 100;
void print(const char* str)
{
	printf("%s\n",str);
}

test.c源文件的内容:

define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<stdlib.h>
extern int g_val;
extern void print(const char* str);
int main()
{  
	printf("%d\n", g_val);
	print("hehe\n");
	return 0;
}

这个两个源文件的处理流程大致如下图:

因为vs对文件的编译的封装做得很好,我们不太能直观地观察整个编译地过程,所以接下来我们在Linux系统下进行观察,这里先介绍几个简单的Linux指令。

介绍完这几个Linux的指令以后,我们使用Centos系统进行代码的书写和观察编译的过程:

接下来我们看一看test.i文件中的内容:

 我们看到test.i文件有将近1万行的代码,但是我们写的源文件仅仅只有10行,为什么会出现这样的情况呢?这是因为在这一步预处理的时候处理了我们的#include<stdio.h>这条预处理指令,编译器将这样一个头文件进行了展开!

接下来我们再看执行第二条指令生成test.s文件进行进一步的观察:

 我们可以看到,test.s文件出现了movl等等一系列的汇编指令,这说明到了这一步程序已经从一份C语言的源代码文件转变成了汇编语言的机器指令

 我们打开test.o文件可以发现里面的内容我们基本已经看不懂了,也就是说这时候这份C语言文件 已经彻彻底底变成了一份二进制的指令文件,这时候整个程序的编译已经完成了!

 2.3 运行环境:

程序执行的过程:
1. 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序
的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
2. 程序的执行便开始。接着便调用main函数。

3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回
地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程
一直保留他们的值。
4. 终止程序。正常终止main函数;也有可能是意外终止。

3.预处理详解:

3.1预定义符号

在C语言中,内置了一些预定义的符号:

例如你可以打印文件的名字:

3.2#define定义标识符

语法:

#define这是一个预处理指令,它的作用是直接将标识符替换成你指定的内容,如:

#define N 100
int main()
{

  int a=N;
  return 0;
}

那么在liinux下通过使用gcc -E  test.c -o test.i预处理文件后查看文件的内容

 这是main.c文件预处理完后的文件内容,可以看见确实N被替换成了100.

注意:#define语句不要加分号! 因为分号会被当做一部分内容直接被替换导致编译出错!

3.2.2#define定义宏

举个例子:

注意:要想达到你预期宏的实际效果,那么你就必须要正确的使用括号,否则可能因为宏直接替换的规则导致产生运算优先级问题从而导致结果发生偏差!

举个例子:

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<stdlib.h>
//宏的使用
#define SQRT(x) x*x
int main()
{
	int a = 5;
	printf("%d\n", SQRT(a+ 1));
	return 0;
}

 我们可以看到真实运行出的结果是11而不是36,之所以会运行出11,就是因为宏并没有真实计算a+1的结果,而是直接将a+1的结果替换进去了,我们可以在linux环境下观察这段代码:

 可以看到表达式的结果是a+1*a+1那么这里的运算就会收到优先级的影响:

那么为了处理优先级的问题,我们不仅要在每一个替换的标识符加括号,更需要对整体的内容进行括号来确保达到我们最终想要的结果

 3.2.3 #define 替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换
2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程
注意:
宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。

3.2.4带副作用的宏定义

在一个宏定义中,如果有一个参数被宏多次使用,假如这个参数参与了有副作用的表达式,那么宏在接下来使用这个参数的时候就可能会产生危险,出现不可预测的后果!所谓的副作用就是在表达式结束以后会改变参与这个表达式变量的值,举个例子:

接下来我们就通过一个MAX宏来看看这样的一个案例:

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<stdlib.h>
#define MAX(x,y) ((x)>(y) ? (x):(y))
int main()
{
	int a = 3;
	int b = 4;
	int c=MAX(a++, b++);
	printf("a=%d,b=%d,c=%d\n", a, b, c);
	return 0;
}

通过前面的案例我们知道MAX语句的执行情况是这样的:

运行结果如下:

 可见程序的运行结果和我们预期的是一样的,我们并不推荐这样做,这样会带来一些意想不到的错误的结果!

3.2.4 宏和函数的对比

宏通常被应用于执行简单的运算。比如找出两个数较大的一个:

 那么这样的一句代码为什么不用函数呢?原因有二:

1.函数在执行的时候需要创建栈帧,并且将参数压栈等一系列操作,相对于宏的直接替换,函数的执行效率会比较慢一些

2.更重要的是函数的参数只能声明为特定的类型,这使得函数不具备很好的灵活性!而由于宏定义不受类型的影响,传来的数据类型只要可以使用‘>’进行比较都是可以的,也就是说宏是类型无关的

当然万物都有双面性,宏在某些方面也存在一定的缺陷:

1. 由于宏是直接替换,所以如果宏定义的内容太长,那么就会大幅度增加代码量!
2.宏没有办法进行调试(预处理阶段直接进行了替换,实际看到的调试代码是替换后的结果!)

3.宏没有类型检查,相对于函数而言不够严谨。

4.宏因为直接替换的原因可能会带来优先级的问题而导致结果出错

当然,宏也可以做到函数根本做不到的事情,比如接受一个类型作为参数,举个例子:

3.2.5命名规范 

一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者。
所以我们通常使用的命名规范是:

宏名全大写

函数名不要全部大写

3.3#undef----->移除宏定义

这条指令用于移除一条宏定义

3.4.条件编译

有的时候一些方便调试的代码我们不需要用,但是删除它有不太恰当,这时候我们可以用条件编译的方式来对这些代码进行选择性的编译

#define  _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<stdlib.h>
#define __DEBUG__
int main()
{
	int arr[10] = { 0 };
	int i = 0;
	for (i = 0; i < 10; i++)
	{  
		arr[i] = i; 
#ifdef __DEBUG__
		printf("%d ", arr[i]);//观察是否赋值成功
#endif
	}
	return 0;
}

程序运行结果如下:

可见确实是打印出来了0-9,接下来我们去掉#define __DEBUG__

可以看到这里就没有打印出0-9,这就是条件编译的作用:条件成立,内部的代码才会被编译执行

以下是常见的编译指令:

3.5 文件的包含

我们已经知道了,#include指令可以使得另外一个文件的内容被编译。在实际的编译过程中:

编译器首先会删除这条机器指令,并将这条指令里的头文件的内容进行对应的展开!如果这样的一个头文件被包含10次,那么它就会对应地展开10次!

3.5.1头文件的查找方式----><>和" "的区别

1.本地头文件包含

这条指令的作用就是在当前目录下查找对应的文件,如果当前目录没有同名文件,则会去系统放置的头文件的目录下去寻找,如果还找不到就会报错。

具体路径按照具体的安装时的路径而定。

库文件包含:

这种查询方式会优先从系统头文件的目录下寻找对应的文件,如果找不到就会报错。当然,你也可以用“”的方式去寻找头文件,那么就会优先从当前目录下寻找头文件后再从系统头文件所在目录下寻找,但是这样做的方式不仅效率低,而且难以区分库的头文件还是本地文件了!

3.5.2 头文件的嵌套包含

如果有看过我之前写过三子棋和扫雷的博客的话就知道,game.h头文件同时被game.c和test.c

文件分别包含,也就是说实际的环境会存在这样一种情况:同一份文件的内容被不同的文件重复引用!那么这个工程中就会有重复的内容,为了解决冗余的问题,我们使用条件编译!

举个例子:为了放置game.h的文件重复包含,我们可以这样写:

当然也可以用这样的一条预处理指令达到同样的效果

4.其他的预处理指令

参考《C语言深度解剖》学习
这就是这篇博客的所有内容,希望能够和大家共勉继续努力

举报

相关推荐

0 条评论