C语言进阶--程序环境和预处理

目录

一.程序的翻译环境和执行环境

1.1.翻译环境

​编辑    ​编辑

预编译(预处理):

编译:

汇编:

链接:

1.2.运行环境

二.预处理详解

2.1.预定义符号

2.2.#define

#define定义标识符

#define定义宏

#define替换

#和##

带副作用的宏参数

宏和函数的对比

命名约定

#undef

命令行定义

三.条件编译

四.文件包含

4.1.本地文件包含

4.2.函数库文件包含

4.3.嵌套文件包含


一.程序的翻译环境和执行环境

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

  1. 翻译环境:在这个环境中源代码被转换为可执行的机器指令;
  2. 执行环境:它用于实际执行代码。

1.1.翻译环境

翻译阶段由几个步骤组成,组成一个程序的每个(有可能有多个)源文件通过编译过程分别转换为目标代码(object code)。然后,各个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序链接器同时也会引入标准C函数库的任何被该程序所用到的函数,而且它也可以搜索程序员个人的程序库,将其中需要使用的函数也链接到程序中

C语言进阶--程序环境和预处理_第1张图片

编译过程本身也由几个阶段组成,首先是预处理器(preprocessor)处理。在这个阶段,预处理器在源代码上执行一些文本操作。例如,用实际值代替由#define指令定义的符号以及读入由#define指令包含的文件的内容。然后,源代码经过解析(parse),判断它的语句的意思。第2个阶段是产生绝大多数错误和警告信息的地址随后,便产生目标代码目标代码是机器指令的初步形式,用于实现程序的语句。如果我们在编译程序的命令中加入了要求进行优化的选项,优化器(optimizer)就会对目标代码进一步进行处理,使它效率更高。优化过程需要额外的时间,所以在程序调试完毕并准备生成正式产品之前一般不进行这个过程。至于目标代码是直接产生的,还是先以汇编语句的形式存在,然后再经过一个独立的阶段编译成目标文件,对我们来说并不重要。

C语言进阶--程序环境和预处理_第2张图片

接下来,我们将在Linux环境下的gcc编译器进行翻译环境的讲解:

C语言进阶--程序环境和预处理_第3张图片        C语言进阶--程序环境和预处理_第4张图片

预编译(预处理):

gcc -E test.c -o test.i  
gcc -E add.c -o add.i

预处理完成之后就停下,预处理产生的结果都放在test.i和add.i中。

  1. 完成头文件的包含;
  2. 完成#define定义的符号和宏的替换;
  3. 删除注释;
  4. 文件操作

编译:

gcc test.i -S 
gcc add.i -S

把C语言代码转换为汇编代码,编译完成之后就停下,编译产生的结果都放在test.s和add.s中。

  1. 语法分析;
  2. 词法分析;
  3. 语义分析;
  4. 符号汇总(只会汇总一些全局的符号)。

汇编:

gcc test.s -c
gcc add.s -c

把汇编指令翻译成二进制指令,形成符号表。汇编完成之后就停下,汇编产生的结果保存在test.o和add.o中。在Windows环境下,目标文件后缀名为.obj,在Linux gcc编译环境下后缀名为.o。其中Windows环境下文件是elf格式文件,elf格式文件是以段表的形式组织的,应使用工具readelf阅读elf格式的文件。

  1. 将汇编指令转换为二进制指令;
  2. 产生符号表 。 

符号表

将编译阶段汇总的符号以及符号所对应的地址进行汇总,一张符号表包含符号名和符号所对应的存储地址

源文件中可能存在无法通过当前符号所在的文件找到其对应的存储地址,如源文件test.c中的符号add,由于是外部符号,add函数并没有在源文件test.c中定义,因此在编译阶段无法获取符号Add的地址。对于此类符号,符号表中会暂时存储一个虚拟地址,等待链接阶段进行符号表合并及重定位时进行统一处理。

C语言进阶--程序环境和预处理_第5张图片

链接:

在链接阶段主要完成如下俩个任务:

  1. 合并段表
  2. 符号表的合并和重定位

合并段表

生成的.o文件是elf格式的,这种elf格式的文件是由一些段表组成,在链接阶段要把这些目标文件中的段表合并成一个段表。

符号表的合并和重定位

在汇编阶段会生成多张符号表,在链接阶段会多张符号表会被合并为一张。同时,同时会将对应符号的虚拟地址通过重定位转换成对应的实际地址。

C语言进阶--程序环境和预处理_第6张图片

1.2.运行环境

程序执行的过程

  1. 首先,程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
  2. 然后,程序的执行便开始,接着便调用main函数。
  3. 现在,便开始执行程序代码。这个时候程序将使用一个运行时堆栈 (stack),存储函数的局部变量和返回地址。程序同时也可以使用静态 (static) 内存,存储于静态内存中的变量在程序的整个执行过程一直保留它们的值。
  4. 程序执行的最后一个阶段就是程序的终止。正常终止main函数返回;也可能是意外终止。

二.预处理详解

编译一个C程序涉及很多步骤。其中第一个步骤被称为预处理阶段。C预处理器在源代码编译之前对其进行一些文本性质的操作。它的主要任务包括删除注释,插入被#include指令包含的文件的内容,定义和替换由#define指令定义的符号以及确定代码的部分内容是否应该根据一些条件编译指令进行编译。

2.1.预定义符号

下表总结了由预处理器定义的符号。它们的值或者是字符串常量,或者是十进制数字常量。

        符号                                 含义
    __FILE__                     进行编译的源文件名
    __LINE__                      文件当前行的行号
   __DATE__                      文件被编译的日期
   __TIME__                      文件被编译的时间
  __STDC__ 如果编译器遵循ANSI C,其值就为1,否则未定义

案例:

int main()
{
	FILE* pf = fopen("log.txt", "a");
	if (pf == NULL)
	{
		perror("fopen");
		return 1;
	}

	int i = 0;
	for (i = 0; i < 10; i++)
	{
		//printf("file:%s line:%d date:%s time:%s i=%d\n", __FILE__, __LINE__, __DATE__, __TIME__, i);
		//printf("name:%s file:%s line:%d date:%s time:%s i=%d\n",__func__, __FILE__, __LINE__, __DATE__, __TIME__, i);
		fprintf(pf,"name:%s file:%s line:%d date:%s time:%s i=%d\n", __func__, __FILE__, __LINE__, __DATE__, __TIME__, i);
	}

	fclose(pf);
	pf = NULL;

	return 0;
}

2.2.#define

#define定义标识符

语法:

#define name stuff

有了这条指令以后,每当有符号name出现在这条指令后面时,预处理器就会把它替换成stuff。

替换文本并不仅限于数值字面值常量。使用#define指令,可以把任何文本替换到程序中。比如:

#define reg register  //为关键字register创建一个简短的名字reg
#define do_forever for(;;)  //用一个更具描述性的符号来代替一种用于实现无限循环的for语句类型
#define CASE break;case  //自动地把一个break放在每个case之前

int main()
{
	reg int num = 0;//寄存器变量

	do_forever;

	int n = 0;
	switch (n)
	{
	case 1:
		CASE 2 ://同break;case 2
			CASE 3 :
	}

	return 0;
}

提问:在define定义标识符的时候,要不要在最后加上 ; ?

#define NUM 100;

int main()
{
	int num = 0;

	if (1)
		num = NUM;//num=100;;
	else
		num = -1;

	return 0;
}

总结:在define定义标识符的时候,不建议加上 ; ,这样容易导致问题。

#define定义宏

#define机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏或定义宏。下面是宏的声明方式:

#define name(parameter-list) stuff

其中,parameter-list(参数列表)是一个由逗号分隔的符号列表,它们可能出现在stuff中。参数列表的左括号必须与name紧邻。如果两者之间由任何空白存在,参数列表就会被解释为stuff的一部分。

案例一:

#define SQUARE(x) x*x

int main()
{
	int a = 10;

	int r = SQUARE(a);

	printf("%d\n", r);

	return 0;
}

当a=10时,此时的ret=10*10=100;

但是这个宏存在一个问题,当x取a+1时,也就是SQUARE(a+1)。乍一看,你可能觉得这段代码将打印121。事实上,它将打印21。参数x被文本a+1替换,所以这条语句实际上变成了:a+1*a+1。现在问题清楚了:由替换产生的表达式并没有按照预想的次序进行求值。在宏定义中加上两个括号,这个问题便很轻松地解决了:

#define SQUARE(x) (x)*(x)

案例二:

#define DOUBLE(x) (x)+(x)

int main()
{
	int b = 20;

	int ret = 3 * DOUBLE(b);

	printf("%d\n",ret);

	return 0;
}

定义中使用了括号,用户避免前面出现的问题。但是,使用这个宏,可能会出现另外一个不同的错误。当b=5时,看上去,它好像将打印30,但事实上它打印的是20.再一次,通过观察宏替换产生的文本,我们能够发现问题所在:3*(b)+(b)。乘法运算在宏所定义的加法运算之前执行。这个错误很容易修正:在定义宏时,你只要在整个表达式两边加上一对括号就可以了。

提示:

所有用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时,由于参数中的操作符或邻近的操作符之间不可预料的相互作用。

#define替换

在程序中扩展#define定义符号和宏时,需要涉及几个步骤:

  1. 在调用宏时,首先对参数进行检查,看看是否包含了任何由#define定义的符号。如果是,它们首先被替换;
  2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被它们的值所替代;
  3. 最后,再次对结果文件进行扫描,看看它是否包含了任何由#define定义的符号。如果是,就重复上述处理过程。

注意:

  1. 宏参数和#define定义可以包含其它#define定义的符号。但是,宏不可以出现递归,因为宏只做简单的文本替换,且只替换一次;
  2. 当预处理器搜索#define定义的符号时,字符串常量的内容并不进行检查。

#和##

如果想把宏参数插入到字符串常量中,可以使用两种技巧。

首先,邻近字符串自动链接的特性使我们很容易把一个字符串分成几段,每段实际上都是一个宏参数。比如:

int main()
{
	char* s = "hello ""bit\n";
	printf("%s", s);

	printf("hello bit\n");

	printf("hello ""bit\n");

	return 0;
}

运行结果:

这里有一个这种技巧的例子:

 
#define PRINT(FORMAT,VALUE) printf("the value is " FORMAT "\n", VALUE)

int main()
{
	int i = 2;

	PRINT("%d", i + 3);

	return 0;
}

运行结果:

这种技巧只有当字符串常量作为宏参数给出时才能使用。

第2个技巧使用预处理器把一个宏参数转换为一个字符串。#argument这种结构被预处理器翻译为“argument”。这种翻译可以让你像下面这样编写代码:

#define PRINT(N,format) printf("the value of "#N" is "format"\n",N)

int main()
{
	int a = 20;
	double pai = 3.14;

	PRINT(a, "%d");

	PRINT(pai, "%lf");

	return 0;
}

运行结果:

##结构则执行一种不同的任务。它把位于它两边的符号链接成一个符号。作为用途之一,它允许宏定义从分离的文本片段创建标识符。比如:

#define CAT(name,num) name##num

int main()
{
	int class105 = 105;
	printf("%d\n", CAT(class, 105));//CAT(class, 105)转换为class105,而class105的值为105,所以打印输出105

	return 0;
}

运行结果:

带副作用的宏参数

当宏参数在宏定义中出现的次数超过一次时,如果这个参数具有副作用,那么当你使用这个宏时就可能出现危险,导致不可预料的结果。副作用就是在表达式求值时出现永久性效果。例如:

x+1;//不带副作用
x++;//带副作用

MAX宏可以证明具有具有副作用的参数所引起的问题,如下所示:

#define MAX(x,y) ((x)>(y)?(x):(y))

int main()
{
	int a = 5;
	int b = 8;
	int c = MAX(a++, b++);
	//int c = ((a++) > (b++) ? (a++) : (b++));

	printf("%d\n", c);//9
	printf("%d\n", a);//6
	printf("%d\n", b);//10

	return 0;
}

虽然那个较小的值只增值了一次,但那个较大的值却增值了两次:第一次是在比较时,第二次是在执行?符号后面的表达式时出现。

宏和函数的对比

宏通常被应用于执行简单的运算,比如在两个数中找出较大的一个。那为什么不用函数来完成这个任务?原因有二:

  1. 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹;
  2. 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整型、长整型、浮点型等可以用来比较的类型。宏是类型无关的。

当然和宏相比函数也有劣势的地方:

  1. 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度;
  2. 宏是没法调试的;
  3. 宏由于类型无关,也就不够严谨;
  4. 宏可能会带来运算符优先级的问题,导致程序容易出错。

宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。

#define MALLOC(num,type) (type*)malloc(num*sizeof(type))

int main()
{
	int* p = (int*)malloc(10 * sizeof(int));

	MALLOC(10, int);

	return 0;
}
        属性                     #define 宏                            函数
     代码长度 每次使用时,宏代码都被插入到程序中。除了非常小的宏之外,程序的长度将大幅度增长 函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码
     执行速度 更快 存在函数调用/返回的额外开销
  操作符优先级 宏参数的求值是在所有周围表达式的上下文环境里,除非它们加上括号,否则邻近操作符的优先级可能会产生不可预料的结果 函数参数只在函数调用时求值一次,它的结果值传递给函数。表达式的求值结果更容易预测。
     参数求值 参数每次用于宏定义时,它们都将重新求值。由于多次求值,具有副作用的参数可能会产生不可预估的结果 参数在函数被调用前只求值一次。在函数中多次使用参数并不会导致多种求值过程。参数的副作用并不会造成任何特殊的问题
     参数类型 宏与类型无关。只要对参数的操作是合法的,它可以使用任何参数类型 函数的参数是与类型有关的。如果参数的类型不同,就需要使用不同的函数,即使它们执行的任务是相同的

命名约定

一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者。那我们平时的一个习惯是:把宏名全部大写,函数名不要全部大写

#undef

这条预处理指令用于移除一个宏定义。

#define M 100
int main()
{
	int a = M;

#undef M

	printf("%d\n", M);//err:未声明的标识符

	return 0;
}

如果一个现存的名字需要被重新定义,那么它的就定义首先必须用#undef移除。

命令行定义

许多C编译器提供了一种能力,允许你在命令行中定义符号。用于启动编译过程。当我们根据同一个源文件编译一个程序的不同版本时,这个特性是很有用的。例如,假定某个程序声明了一个某种长度的数组。如果某个机器的内存很有限,这个数组必须很小,但在另一个内存充裕的机器上,你可能希望数组能够大一些。如果数组是用类似于下面的形式进行声明的,

int main()
{
	int arr[ARRAY_SIZE];

	int i = 0;
	for (i = 0; i < ARRAY_SIZE; i++)
	{
		arr[i] = i;
	}

	for (i = 0; i < ARRAY_SIZE; i++)
	{
		printf("%d ",arr[i]);
	}

	printf("\n");

	return 0;
}

那么,在编译程序时,ARRAY_SIZE的值可以在命令行中指定。在Linux编译器中,-D选项可以完成这项任务。如下面这条指令:

gcc test.c -D ARRAY_SIZE=10

三.条件编译

在编译一个程序时,如果我们可以选择某条语句或某组语句进行翻译或者被忽略。常常会显得很方便。条件编译就是用于实现这个目的。使用条件编译,你可以选择代码的一部分是被正常编译还是完全忽略。

案例一:

/*
#if 常量表达式

#endif
*/
//常量表达式由预处理器求值

int main()
{
	int arr[10] = { 0 };
	int i = 0;

	for (i = 0; i < 10; i++)
	{
		arr[i] = i;
#if 1
		printf("%d ",arr[i]);
#endif
	}

	return 0;
}

案例二:

//多个分支的条件编译
/*
#if 常量表达式

#elif 常量表达式

#else

#endif
*/

#define NUM 1

int main()
{
#if NUM==1
	printf("hehe\n");
	return 0;
#elif NUM==2
	printf("haha\n");
#else
	printf("heihei\n");
#endif

	return 0;
}

案例三:

//判断是否被定义
/*
#if defined(symbol)
#ifdef symbol

#if !defined(symbol)
#ifndef symnol
*/

#define MAX 0

int main()
{

//#if defined(MAX)
//	printf("hehe\n");
//#endif

//#if !defined(MAX)
//	printf("hehe\n");
//#endif

//#ifdef MAX
//	printf("hehe\n");
//#endif

#ifndef MAX
	printf("hehe\n");
#endif

	return 0;
}

案例四:

//嵌套指令
/*
#if defined(OS_UNIX)
    #ifdef OPTION1
	
	#endif
	#ifdef OPTION2

	#endif
#elif defined(OS_MSDOS)
    #ifdef OPTION2

	#endif  
*/

四.文件包含

我们知道,#include指令可以使另一个文件的内容被编译。就像它实际出现于#include指令出现的位置一样。这种替换执行的方式很简单︰预处理器删除这条指令,并用包含文件的内容取而代之。这样,一个头文件如果被包含到10个源文件中,它实际上被编译了10次。

4.1.本地文件包含

#include"filename"

查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果找不到就提示编译错误。Linux环境的标准头文件的路径:

/usr/include

VS环境的标准头文件的路径:

C:\Program File (x86)\Microsoft Visual Studio 9.0\VC\include

注意按照自己的安装路径去找。

4.2.函数库文件包含

#include

查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。

这样是不是可以说,对于库文件也可以使用" "的形式包含?答案是肯定的。但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地问价了。

4.3.嵌套文件包含

标准要求编译器必须支持至少8层的头文件嵌套,但它并没有限定嵌套深度的最大值。事实上,我们并没有很好的理由让#include指令的嵌套深度超过一层或两层。

嵌套文件包含的不利之处:

  1. 它使得我们很难判断源文件之间的真正依赖关系;
  2. 一个头文件可能会被多次包含。

多重包含在绝大多数情况下出现于大型程序中,它往往需要使用很多头文件,因此要发现这种情况并不容易。要解决这个问题,我们可以使用条件编译。

#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
//endif

或者使用:

#pragma once

这样就可以避免头文件的重复引入。

你可能感兴趣的:(c语言,c语言)