C语言第十六章程序的环境和预处理
目录
- 一.程序的翻译环境和执行环境
- 二.编译+链接详解
- 1.翻译环境
- 2.编译阶段分析
- 3.运行环境
- 三.预处理详解
- 1.预定义符号
- 2.#define
- (1)#define定义标识符
- (2)#define定义宏
- (3)宏定义的规则
- (4)#和##
- <1>#的运用
- <2>##的运用
- (5)宏参数的副作用
- (6)宏和函数的对比
- (7)命名约定
- 3.#undef
- 4.命令行定义
- 5.条件编译
- 6.文件包含
- (1)头文件的包含
- <1>本地文件的包含
- <2>库文件的包含
- (2)嵌套文件包含
一.程序的翻译环境和执行环境
在ANSI C的任何一种实现中,存在两个不同的环境。
第一种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
第二种是执行环境,它用于实际执行代码。
二.编译+链接详解
1.翻译环境
组成一个程序的每个源文件通过编译过程分别转换成目标代码。
每个目标文件由编译器捆绑在一起,形成一个单一而完整的可执行程序。
链接器同时也会引用标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接在程序中。
2.编译阶段分析
//sum.c文件
int g_val = 2016;
void print(const char *str)
{printf("%s\n", str);
}
//test.c文件
#include <stdio.h>
int main()
{extern void print(char *str);extern int g_val;printf("%d\n", g_val);print("hello bit.\n");return 0;
}
根据上面的图片可以得出:程序处理的顺序是:预处理,编译,汇编,链接。
预处理的作用是文本的替换和处理。展开类似于#include包含的文件。替换#define定义的宏。处理#if和#else等条件编译指令,保留满足条件的代码段。删除注释、多余空格等。
编译是预处理后的核心步骤,主要作用是进行预处理后源代码转化为汇编语言代码的工作。步骤是:词法分析:把代码拆为关键字、标识符和运算符,语法分析:根据语法规则,将拆出的东西合成语法树(如表达式、函数),检查是否符合语法规范,语义分析:检查语法树的逻辑合理性,比如变量未定义就使用、类型不匹配等,中间代码生成与优化:生成一种简洁的中间代码并优化,为后续生成汇编代码做好准备,目标代码的生成:将优化后的中间代码翻译成对应机器的汇编语言。
汇编阶段的核心是将汇编语言代码翻译成计算机能直接识别的机器码(二进制指令),生成目标文件(以.o或者.obj为后缀的文件)。此阶段由汇编器完成,它会一一对应地将汇编指令(如mov,add等)转化为特定CPU架构的二进制编码,并为变量和函数标记符号表(供后续链接使用)。
链接阶段的核心是将多个目标文件和所需的库文件进行整和,生成可直接运行的可执行文件(.exe为后缀的文件)。其主要的步骤为:地址重定位:修正目标文件中符号表上的最终地址,符号解析:找到不同文件中引用的函数、变量的实际定义,建立关联,合并文件:将所有的目标文件和库文件中的代码、数据段合并成统一的程序结构。
在学习这一节内容时,我们想要知道每一步骤处理之后代码的情况,那么如何查看编译期间的每一步发生了什么呢?
#include <stdio.h>
int main()
{int i = 0;for(i=0; i<10; i++) {printf("%d ", i);}return 0;
}
- 预处理:选项 gcc -E test.c -o test.i。预处理完成之后就停下来,预处理之后产生的结果都放在test.i文件中。
- 编译:选项 gcc -S test.c。编译完成之后就停下来,结果保存在test.s中。
- 汇编:选项gcc -c test.c。汇编完成之后就停下来,结果保存在test.o中。
以上的操作在VS code上执行就可以得出相应的结果。
3.运行环境
下面我们介绍一下程序运行的过程:
1.程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手动安排,也可能是通过可执行代码置入只读内存来完成。
2.程序的执行于是就开始了,接着便调用main函数。
3.开始执行程序代码,这个时候程序将使用运行时堆栈,存储函数的局部变量和返回地址。程序同时也可以使用静态内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
4.终止程序,正常终止main函数。也有可能是意外的终止。
三.预处理详解
1.预定义符号
_FILE_ //进行编译的源文件
_LINE_ //文件当前的行号
_DATE_ //文件被编译的日期
_TIME_ //文件被编译的时间
_STDC_ //如果编译器支持ANSI C,该值为1,否则未定义。
下面来举一个使用上述符号的代码例子:
printf("file:%s line:%d\n",_FILE_,_LINE_);
上述代码会打印这行代码进行编译的源文件和代码所处的行号。上述预定义符号是#define定义的特殊宏,用于获取编译的相关信息。
2.#define
(1)#define定义标识符
语法:#define name stuff
举个例子:
#define MAX 1000
#define reg register
//为 register这个关键字,创建一个简短的名字
#define do_forever for(;;)
//用更形象的符号来替换一种实现
#define CASE break;case
//在写case语句的时候自动把 break写上。
// 如果定义的stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续行符)。
#define DEBUG_PRINT printf("file:%s\tline:%d\t \date:%s\ttime:%s\n" ,\__FILE__,__LINE__ , \__DATE__,__TIME__ )
上述代码,体现了define宏定义的具体作用:将C语言的关键字或者某些数据替换成其他形式。那么这里就需要解决一个问题?
在用define定义标识符时,需要在最后加上“;”吗?
正确的答案是不需要,也不能加分号。下面进行问题的具体回答。
#define MAX 1000;
#define MAX 1000
if(condition)max = MAX;
elsemax = 0;
上述代码中,利用了define定义MAX为1000这个数据。但是第一个定义在末尾加了分号。我们了解到define宏定义,在代码预处理阶段,会将宏定义替换进代码中。所以第一种宏定义就会转换为下方代码:
if(condition)max = 1000;;
else max = 0;
可以看出来因为1000;替换了MAX,所以变成了上方的代码,但是因为if语句后只能跟一条语句(跟多条语句需要带上大括号)。最终就会因为两个分号的缘故,导致if语句后面跟了两天语句(并且没有带大括号)而出现语句的错误。
(2)#define定义宏
#define规定了:允许把参数替换到文本中,这种实现通常称为宏或者定义宏。下面是宏的定义方法:
# define SQUARE (x) x*x
上述宏接收了一个参数x,将这个参数x替换为x*x。下面举一个简单的例子。
#define SQUARE (x) x*x
SQUARE (5);
像上述代码那样,宏会把5替换成5*5。那么最终经过宏替换之后,就变成了25。但是宏存在一个潜在的问题:
int a = 5;
printf("%d\n",SQUARE(a + 1));
貌似上述代码没有问题,但是根据宏替换的规定,将.里面的参数x替换成xx。你就会发现潜在的问题:5+1就会被替换为:5+15+1并不是我们希望的(5+1)*(5+1)。这样就比较清晰了,由替换产生的表达式并没有按照预想的次序进行求值。在宏定义上加上两个括号,这个问题便轻松的解决了:
#define SQUARE(x) (x) * (x)
所以最终我们得出结论:用于对数值表达式进行求值的宏定义都应该用上述方式加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用。在宏定义期间不要吝啬小括号的使用。
(3)宏定义的规则
在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
替换文本会被插入到程序中原来文本的位置。对于宏,参数名会被他们的值直接替换。
最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。
注意:
宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
(4)#和##
<1>#的运用
如何把参数插入到字符串中?
char *p="hello""bit";
printf("hello bit");
printf("%s",p);
上述代码输出的是hello bit。我们发现字符串是有自动连接的特点的。所以我们就可以利用这个特点来写代码:
#define PRINT(FORMAT ,VALUE)\printf("the value is ""FORMAT""\n",VALUE);printf("%d",10);
这里只有当字符串作为宏参数的时候才可以把字符串放在字符串中。
另外一个技巧是:使用#,把一个宏参数变成对应的字符串。比如:
int i = 10;
#define PRINT(FORMAT, VALUE)\printf("the value of " #VALUE "is "FORMAT "\n", VALUE);
...
PRINT("%d", i+3);//产生了什么效果?
代码中的#VALUE会预处理为“VALUE”这个字符串。最终的输出的结果为:the value of i+3 is 13。
<2>##的运用
##可以把位于该符号两边的符号合成一个符号。他允许宏定义从分离的文本片段创建标识符。
#define ADD_TO_SUM(num, value) \
sum##num += value;
...
ADD_TO_SUM(5, 10);
//作用是:给sum5增加10
注意:这样的连续必须产生一个合法的标识符,否则其结果就是未定义的。
(5)宏参数的副作用
当宏参数在宏的定义中出现超过一次的时候,如果参数有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。宏参数的副作用:表达式求值时候出现的永久性效果。例如:
x+1;//不带副作用
x++;//带副作用
MAX宏可以看出有副作用的参数所引起的问题:
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
...
x = 5;
y = 8;
z = MAX(x++, y++);
printf("x=%d y=%d z=%d\n", x, y, z);
//输出的结果是什么?
上述代码预处理后的结果为:
z=((x++)>(y++)?(x++):(y++));
因为自增符号会影响后续比较,最后会产生不可预测的后果。
所以输出的结果为:x=6,y=10,z=9。
(6)宏和函数的对比
宏通常被应用于执行简单的运算。比如两个数的较大值:
#define MAX (a,b) ((a)>(b)?(a):(b))
那为什么不用函数完成上述操作呢?
优点1:用于调用函数和从函数返回的代码可能比实际执行这个小型运算工作所需要的时间更多。所以宏比函数在程序的规模和运行速度方面更胜一筹。
优点2:函数的参数必须声明为特定的类型,所以函数只能在类型合适呢表达式上使用。而宏可以适用于合个类型。
缺点1:每次使用宏时,宏定义的代码将插入程序中。会大幅度增加程序的长度。
缺点2:宏没有调试的功能,出现问题会很难找到问题所在。
缺点3:宏由于与类型无关,也就不够严谨。这个即使优点,也是缺点。是一把双刃剑。
缺点4:宏可能会带来运算符优先级的问题,导致程序出现错误。
宏有时候可以做函数做不到的事情,比如:宏的参数可以出现类型,而函数做不到。
#define MALLOC(num, type)\
(type *)malloc(num * sizeof(type))
...
//使用
MALLOC(10, int);
//类型作为参数
//预处理器替换之后:
(int *)malloc(10 * sizeof(int));
下面是宏与函数的对比图:
属性 | 宏 | 函数 |
---|---|---|
代码长度 | 每次使用时,宏代码都会被插入到程序中。程序的长度会大幅度变长 | 函数代码只出现于一个地方,每次使用函数时,都会调用那个地方的同一份代码。 |
执行速度 | 更快 | 存在函数的调用和返回的额外时间开销,所以慢一点。 |
操作符的优先级 | 宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则临近操作符的优先级会产生不可预测的后果。 | 函数参数只在函数调用时求值一次,它的结果值传递给函数,表达式的求值结果更容易被预测。 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值会产生不可预测的后果 | 函数参数只在传参时求值一次,结果更容易被控制。 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的,它就可以适用于任何参数。 | 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是不同的。 |
调试 | 宏是不方便调试的 | 函数是可以逐个语句调试的 |
递归 | 宏不可以递归 | 函数可以递归 |
(7)命名约定
函数和宏的使用语法很相似,所以语法没法区分两者。所以这时候命名的区分显得格外重要。我们一般将宏名全部大写,函数名不要全部大写。这样在以后写代码时候,就可以明确宏和函数。
3.#undef
这条预处理指令用于移出一个宏定义,当我们以后写足够大的项目时候,需要移除宏定义,我们可以选择一个一个注释掉。但是那样显然很麻烦。我们可以用#undef指令移除一个宏定义。具体用法:
# undef NAME
//如果现存的一个名字需要被重新定义,那么旧的名字首先要被移除
4.命令行定义
许多C语言编译器提供了一个功能,允许在命令行中定义符号。用于启动编译过程。例如:当我们根据同一个源文件要编译出一个不同的一个程序的不同版本的时候,这个特性有点用处(假如某个程序中声明了某数组的长度,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大一些,我们需要一个数组能够大些)
#include <stdio.h>
int main()
{int array [ARRAY_SIZE];int i = 0;for(i = 0; i< ARRAY_SIZE; i ++) {array[i] = i; }for(i = 0; i< ARRAY_SIZE; i ++) {printf("%d " ,array[i]); }printf("\n" );return 0;
}
编译指令:
//linux 环境演示
gcc -D ARRAY_SIZE=10 programe.c
5.条件编译
在编译一个程序的时候,如果要将一条语句编译或者放弃是很方便的。因为我们有条件编译指令。比如说:调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
#include <stdio.h>
#define __DEBUG__
int main()
{int i = 0;int arr[10] = {0};for(i=0; i<10; i++) {arr[i] = i;#ifdef __DEBUG__printf("%d\n", arr[i]);//为了观察数组是否赋值成功。 #endif //__DEBUG__ }return 0;
}
上述代码的条件编译指令的意思是:如果_DEBUG_被定义就执行printf("%d\n", arr[i]);该语句。否则就不执行。
常见的条件编译的指令:
1.
#if 常量表达式//...
#endif
//常量表达式由预处理器求值。
如:
#define __DEBUG__ 1
#if __DEBUG__//..
#endif2.多个分支的条件编译
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
3.判断是否被定义
#if defined(symbol)
#ifdef symbol#if !defined(symbol)
#ifndef symbol4.嵌套指令
#if defined(OS_UNIX)#ifdef OPTION1unix_version_option1();#endif#ifdef OPTION2unix_version_option2();#endif
#elif defined(OS_MSDOS)#ifdef OPTION2msdos_version_option2();#endif
#endif
6.文件包含
我们已经知道,#include指令可以使另一个文件编译,就像实际出现在#include指令的地方一样。这种替换的方式很简单:预处理器先删除这条指令,并用包含文件的内容替换。这样一个源文件被包含10次,那就实际被编译10次。
(1)头文件的包含
<1>本地文件的包含
# include "filename"
查找策略:先在源文件所在的目录下查找,如果该文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果还找不到,就提示编译错误。
<2>库文件的包含
# include <filename.h>
查找策略:查找头文件直接去标准路径下查找,如果没有找到就会提示编译错误。所以库文件也可以使用本地文件包含的方式(双引号)包含。但是这样做查找效率就低一些,并且这样也不容易区分库文件和本地文件。
(2)嵌套文件包含
如果出现一下情况:
comm.h和comm.c是公共模块。
test1.h和test1.c使用了公共模块。
test2.h和test.2.c使用了公共模块。
test.h和test.c使用了test1和test2模块。
这样最终的程序中就会出现两份comm.h的内容,造成文件内容的重复包含。这样情况就可以用条件编译解决问题:
# pragma once
上述条件编译指令放在需要避免问题的程序最上方,就可以避免头文件的重复包含。