在学习预编译之前我们有必要先大致了解一下一个程序从开始到结束的过程,这样有利于我们加深对程序运行的理解。
一、程序的编译环境
在ANSI C的任意一种实现中,存在两个不同的环境。
1.翻译环境 : 在这个环境中源代码转换为可执行的机器指令。(把C语言的代码转化为二进制指令 即可执行程序)
2.执行环境 : 它用于执行实际的代码。(执行二进制代码)
二、运行环境
1.程序必须载入内存当中,再有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排
,也可能是通过可执行代码置入只读内存来完成。
2.程序执行便开始,随后调用main函数。
3.开始执行程序代码,这时程序员将使用一个运行时堆栈(Stack即函数栈帧),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储与静态内存的变量在程序的整个执行过程中一直保留他们的值。
4.终止程序,正常终止main函数,也肯能是意外终止。
如图所示,多个源文件(.c文件)单独经过编译器,进行编译生成目标文件(obj文件),这个过程为编译。多个目标文件与库函数中的链接库共同在链接器的作用下生成可执行程序(exe文件),这个过程为链接过程。
如图所示,翻译环境 可以继续细分为编译和链接,编译还可以继续细分为预处理,编译,汇编,其中在翻译过程中首先进行的是预处理过程,在预处理过程中首先会把test.c源文件中的注释删除以及#include头文件包含和#define 符号的替换,在之后就会生成test.i文件为编译阶段做准备。
到了编译阶段会进行对test.i文件的解读(包含 :语法分析,词法分析,语义分析,符号汇总)其中符号汇总为下阶段的符号表做准备,最后将test.i文件转化为汇编指令文件即test.s文件。
接下来到了汇编阶段在linux环境下,test.s文件会被转化为存放二进制test.o的目标文件文件(在win下转化为test.obj文件),这些二进制文件是以elf(linux环境下)文件格式存放的,elf文件又把二进制文件分为不同的数据段,最后在把前面编译的符号的汇总整理成符号表。
编译阶段结束,接下来就是链接阶段了,链接阶段首先把不同文件的相同段进行合并,形成新的数据段表,其次在对不同文件的的相同符号进行合并,合并为新的符号表,值得注意的是在形成符号表的过程总中有些单独文件的虚拟地址会被分配有效地址(重定位)加入新的符号表。
以上就是程序从开始到结束的大致过程了,如果想了解更多的编译链接过程可以参考《程序员的自我修养》。
三、预编译详解
3.1预定义符号
__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
我们不妨打印出来这些预定义符号
#include<stdio.h> int main() { printf("%s\n",__FILE__); printf("%d\n",__LINE__); printf("%s\n",__DATE__); printf("%s\n",__TIME__); printf("%d\n",__STDC__); return 0; }
可以发现,打印出来的结果跟预期一样,由(__STDC__)的结果看,dev C++遵循ANSIC。
3.2.1 #define 定义标识符
用法:#define name stuff
在有了#define预处理命令后我们可以进一步对上面的预定义符号进行更加方便的表示,在main函数外使用#define+名字+要替换的内容,就可以在全局范围内使用这个宏,例如下面的代码:
#include<stdio.h> #define DEBUG_PRINT printf("file:%s\tline:%d\tdata:%s\t \ time:%s\n",__FILE__, __LINE__, \ __DATE__, __TIME__) /*换行加'\'(转义字符,转义了回车)为了消除define的影响*/ int main() { DEBUG_PRINT; return 0; }
值得注意的是在C语言中,#define预处理指令使用了printf函数只能处理单行内容,如果想换行必须在每一行的末尾加上'\'转义字符才能把换行表示成字符来处理,否则会报错。
代码执行结果如下:
注意:在#define后面最好是不要加上分号,因为这样可能会造成歧义。
3.2.2 #define 定义宏
#define 机制包括了了一个规定,允许把参数替换到文本当中,这种实现通常称为宏(macro) 或者定义宏(define macro)。
宏的申明方式:#define name(parament-list) stuff , 其中parament-list是一个由逗号隔开的符号表,他们可能出现在stuff中。
注意:1.参数列表的左括号必须与name紧邻。2.如果两者之间有任何空白的存在,参数列表就会被解释为stuff中的一部分。
看看下面的例子:
#define Add(x,y) (x+y) int main() { int a = 3; int b = 7; int c = Add(a,b); printf(“%d”,c); return 0; }
注意:这里替换文本的时候,参数x,y要格外注意,#define是整体替换,不会给你添加括号,例如(还是上面的例子,只不过c变了):
c = a * b * Add;的时候其实是c = a * b * a + b;所以在复杂宏当中各个参数最好加上括号。
3.2.3#define替换规则
在程序中扩展#define定义符号和宏时, 需要涉及这几个步骤:
1.在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号,如果是,他们首先被替换。
2.替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
3.最后,再次对结果文件进行扫描,看着他是否包含任何由#define 定义的符号,如果是就重复上述处理过程。
注意:
1.宏参数和#define定义中可以出现其他的#define定义符号,但是对于宏,不能出现递归。
2.当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
3.2.4 #和##
1)#的作用:
思考这样一个问题:如何把参数插入到字符串当中呢?
#include<stdio.h> int main() { int a = 10; printf("The value a is %d\n",a); int b = 20; printf("The value b is %d\n",b); return 0; }
例如:我想要The value a is ... The value b is... The value c is...这样类似的输出如果用printf函数,少量的字符串CV一下就行,但是
如果需要特别多行类似的语句printf函数是做不到的。那么宏做不做得到呢?其实宏有种方法是可以做到的,就是符号'#'。
#include<stdio.h> #define PRINT(n) printf("The value "#n" is %d\n",n) int main() { int a = 10; PRINT(a); int b = 20; PRINT(b); return 0; }
把一个字符串从要替换的字符串的中点分成两个字符串,除了想要替换的字符串以外,另外两个字符串都需要完整的"",在要替换的文本前加上#,这样就可以轻松替换了。
实质上这个宏其实是PRINT(n) printf("The value ""n"" is %d\n",n),相当于在'#'后面部分的字符串改变后又被重新拼接起来形成一个新的完整的字符串。
我们来思考另一个问题:如果两个参数的类型不一样,如何能用一条语句实现呢,比如,我想要一个a为int 型,b 为float型,这样看来printf函数还是不能实现,难道宏还可以吗,没错,宏就是能一劳永逸!我们来看下面代码:
#include<stdio.h> #define PRINT(n,format) printf("The value "#n" is " format "\n",n) int main() { int a = 10; PRINT(a,"%d"); float b = 10.5f; PRINT(b,"%f"); return 0; }
在前面代码的基础上,加上了format类型格式,把输出控制符(%d,%f...)用format代替,且format需要单独的一个双引号,这样在传参的时候只需要传数据类型和输出控制符就可以实现把不同的输出控制符插入到字符串当中,怎么样,是不是很方便呢?
2)##的作用:
##可以把位于它两边的符号合成一个符号,它允许宏定义从分离的文本片段创建标识符。
这句话是什么意思呢?我们先来看一下下面的代码:
#include<stdio.h> #define CRT(x,y) x##y int main() { int DataSum = 100; printf("%d\n", CRT(Data, Sum)); return 0; }
结果为:发现了打印的值和DataSum的值相同,这也就说明了这个宏能将两个片段合并成一个片段,这就是##的作用了。
3.2.5宏和函数的对比
宏通常被应用于执行简单的运算,就像计算两个数的加法:
#include<stdio.h> #define Add(x,y) (x + y); int Add_Fun(int x, int y) { return x + y; } int main() { int x = 3, y = 2; printf("%d\n", Add_Fun(x, y)); int c = Add(x, y); printf("%d",c); return 0; }
为什么不用函数来完成这个任务呢?
原因有两点:
1.用于调用函数和函数返回的代码可能比实际执行这个小型计算机工作所需要的时间更多,所以宏比函数在程序的规模和速度方面更胜一筹。
2.更为重要的是,函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用,反之这个宏可以适用于整形长整型浮点型等可以用于>来比较的类型。宏与类型无关。
宏的缺点:
1.每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则大幅度增加程序长度。
2.宏是没办法调试的。
3.宏由于类型无关,也就不够严谨。
4.宏有时候会带来运算符优先级问题,导致程序发生错误。
所以根据不同的情况进行选择使用宏还是函数有各自的优势。
宏和函数的对比:
属 性 |
#define定义宏 | 函数 |
代 码 长 度 |
每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序长度会大幅组增长。 | 函数代码只出现于一个地方;每 次使用这个函数时,都调用那个 地方的同一份代码 |
执 行 速 度 |
更快 |
存在函数的调用和返回的额外开 销,所以相对慢一些 |
操 作 符 优 先 级 |
宏参数的求值是在所有周围表达式的上下文环境里, 除非加上括号,否则邻近操作符的优先级可能会产生 不可预料的后果,所以建议宏在书写的时候多些括 号。 |
函数参数只在函数调用的时候求 值一次,它的结果值传递给函 数。表达式的求值结果更容易预 测。 |
带 有 副 作 用 的 参 数 |
参数可能被替换到宏体中的多个位置,所以带有副作 用的参数求值可能会产生不可预料的结果。 |
函数参数只在传参的时候求值一 次,结果更容易控制。 |
参 数 类 型 |
宏的参数与类型无关,只要对参数的操作是合法的, 它就可以使用于任何参数类型。 |
函数的参数是与类型有关的,如 果参数的类型不同,就需要不同 的函数,即使他们执行的任务是 相同的。 |
调 试 |
宏是不方便调试的 |
函数是可以逐语句调试的。 |
递 归 |
宏是不能递归的 |
函数是可以递归的 |
3.2.6宏的命名约定和#undef指令
一、命名约定:
一般来说,函数与宏的使用语法很相似,所以语言本身没办法帮我们区分二者,大部分的C程序员都遵循一个默认的习惯:
1、把宏名全部大写。 2、函数名不要全部大写。
二、#undef
这条语句用于移除一个宏定义。
#include<stdio.h> #define MAX(x,y) ((x) + (y)) int main() { int a = 5, b = 6; printf("%3d",MAX(a, b)); #undef MAX //printf("%3d", MAX(a, b)); return 0; }
在使用undef后,再次打印时会发现:
已经将这个宏给删除了。
3.3条件编译
在编译一条语句的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
那么条件编译指令有哪些?
#if 常量表达式 //... #elif 常量表达式 //... #else //... #endif |
多个分支条件编译,也可以只有 #if ...#endif |
if defined() if !defined() 或者 #ifdef ... #ifndef ... |
判断某个宏是否被定义,与宏的值 无关,只与宏是否被定义有关。 |
其中,条件编译语句在程序中只能存在一次,因为在预编译阶段就会进行宏替换,所以在程序中只能起一次的作用。
3.4文件包含
我们不论写C语言还是写C++语言,我们都会用到头文件,像等,其实,#include指令可以使另外一个文件被编译。就像他实际出现于#include指令的地方一样。
这种替换的方式很简单:
预处理器先删除这条指令,并用包含文件的内容替换。
这样一个源文件被包含10次,那就实际被编译10次。
1)本地文件包含:
#include"filename"
查找方式:
先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。 如果找不到就提示编译错误。
2)Linux环境的标准头文件的路径:
为/usr/include
3)库文件包含:
#include<filename.h>
查找方法:
查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。 这样是不是可以说,对于库文件也可以使用 “” 的形式包含? 答案是肯定的,虽然 可以。 但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。