程序环境和预处理

目录

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

2. C语言程序的编译+链接

2.1. 预处理

2.2. 编译

2.3. 汇编

2.4. 链接

 3. 运行环境的简单介绍

4. 预定义符号介绍

5. 预处理指令 #define

5.1. #define定义标识符

5.2. #define定义宏

5.3. #define替换规则

6. 宏和函数的对比

1. 宏的优点

2. 宏的缺点 

3. 宏和函数的对比

7. 预处理操作符#和##的介绍

7.1. #的作用

7.2. ##的作用

8. 命令约定

9. 预处理指令 #undef

10. 命令行定义 

11. 条件编译

12. 文件包含

 12.1. 头文件被包含的方式

1. 本地文件被包含的方式

2. 库文件被包含的方式

12.2. 嵌套文件包含


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

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

第一种是翻译环境,在这个环境中,我们的源代码会被转化为可执行的机器指令(即二进制指令),翻译环境是由编译器提供的(在vs下,编译器为cl.exe,链接器为link.exe)

第二种是执行环境,它用来实际执行我们的代码(执行环境通常是操作系统提供的)

而我们在这里主要介绍一下翻译环境。

2. C语言程序的编译+链接

不知道各位有没有好奇过,我们写的C源文件是如何变成一个可以执行的二进制文件?其实一个C源文件要变成一个可以执行的二进制文件需要经历两个过程:编译和链接

什么叫编译呢?什么叫链接呢?

编译:将一个程序的所有C源代码经由编译器的各种处理(隔离编译)形成一个可重定位的二进制文件,在windows下具体为(*.obj)。但此时这个二进制文件是不可以直接被运行的,它还需要经过链接处理。

链接: 将一个程序中的所有可重定位的二进制文件经由链接器集中处理(集中链接),并且链接器会将程序需要的各种库(动态库、静态库)链接到程序中,最后会形成一个可执行的二进制文件。

程序环境和预处理_第1张图片

 一个程序中的每个源文件都会通过编译过程(隔离编译)分别转化为可重定位的二进制文件。

链接器会将所有的二进制文件集中处理,并将它们需要的各种库链接到一起,形成一个可执行程序。

然而,编译链接又可以被分为几个阶段:

1、预处理

2、编译

3、汇编

4、链接

接下来我们用一个实例,演示一下这几个过程:

// add.c
extern int add(int x,int y)
{
  return x + y;
}
// test.c
#include 

extern int add(int x,int y);
// 宏定义
#define LEFT_VAL 20
#define RIGHT_VAL 10
// 条件编译
#if _LEFT_VAL
#define UP_VAL 30
#else
#define DOWN_VAL 40
#endif

int main()
{
  // 注释
  //printf("haha\n");
  //printf("haha\n");
  //printf("haha\n");
  //printf("haha\n");
  printf("%d\n",add(LEFT_VAL,RIGHT_VAL));
  return 0;
}

程序环境和预处理_第2张图片

2.1. 预处理

预处理过程会将C源程序进行一些列处理。具体如下:

1、去掉注释

2、去掉条件编译

3、宏替换

4、头文件展开

// 让编译器预处理完后停下来,并把结果输出到test.i文件中
gcc -E test.c -o test.i

程序环境和预处理_第3张图片

此时我们经过对比,我们发现这个test.i文件有849行代码,而我们写的test.c仅有22行,那么多出来的代码是谁的呢?答案是:这个头文件中的内容。也就是说,预处理会将头文件的内容展开,其次我们发现,我们源文件中的宏定义被替换了(宏常量直接被替换成对应的数字),而条件编译、以及注释都没见了。也就是说,预处理不仅会展开头文件,并且会将宏定义替换,去掉注释以及条件编译。而像上面的 #include以及#define 就是预处理指令,它们完成的都是一些文本操作。

2.2. 编译

编译又会做什么呢?

//让编译器编译完后就停止,并将其结果重定向到test.s这个文件中
gcc -S test.i -o test.s

程序环境和预处理_第4张图片

可以看到,test.s其实就是一个汇编代码。也就是说,在编译阶段编译器会将其C代码翻译成了汇编代码。这个过程包括:1.语法分析 2.词法分析 3.语义分析 4.符号汇总,在这里说一下符号汇总,符号汇总就是将每个源文件(此时的源文件已经经历过了预处理)中的符号汇总到一起,注意符号不会汇总。例如,上面的两个源文件test.c和add.c。

程序环境和预处理_第5张图片

程序环境和预处理_第6张图片

2.3. 汇编

// 告诉编译器汇编完后就停下来,并将结果重定向到test.o文件中
gcc -c test.s -o test.o

程序环境和预处理_第7张图片

此时这个文件就是一个可重定位的二进制文件,在windows环境下可重定位的二进制文件类型为(*.obj),而在Linux环境下,可重定位的二进制文件文件类型(*.o); 也就是说,汇编过程会将汇编指令翻译成为我们的二进制指令。这个过程还会形成一个符号表。那么如何查看呢?要知道,二进制文件我们直接看是看不懂的,在Linux下,它的文件格式是elf(可执行程序和可重定位的二进制文件的格式都是elf),而在Linux下我们可以借助readelf工具来查看这个文件。如下:readelf -s 可重定位的目标文件  ,就可以查看格式为elf文件的符号表

程序环境和预处理_第8张图片

test.o的符号表如下: 

程序环境和预处理_第9张图片

add.o的符号表如下:

程序环境和预处理_第10张图片

程序环境和预处理_第11张图片

因为add的定义是在add.o文件中,test.o中只有其声明,故这里地址为NULL;printf同理,在这个头文件中只有其声明,没有定义,故地址为NULL。这就是汇编过程每个源文件形成的符号表。

而此时由汇编形成的这些可重定位的二进制文件是不可以运行的。例如上面,test.o这个二进制文件,它只有add和printf的声明,没有其定义,这两个的函数地址为NULL,此时无法运行这个文件。那么如何解决呢?此时我们就需要进行链接,链接会通过链接器将所有可重定位的二进制文件中的符号表进行汇总,同时会将这些文件需要的库函数所在的各种库(例如C标准库,第三方库等等)也链接进来,形成一个可执行程序。

2.4. 链接

1. 合并段表

2. 将由汇编形成的符号表进行合并以及符号表的重定位。

3. 链接库

例如,上面的编译过程中形成了两个符号表,此时它们会进行合并以及重定位:

程序环境和预处理_第12张图片

经由链接过程,此时这个可重定位的二进制文件就会形成一个可执行的二进制文件。

// 此时这个my_test就是一个可执行的二进制文件
gcc test.o add.o -o my_test

 3. 运行环境的简单介绍

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

4. 预定义符号介绍

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

gcc编译器:

void Test1(void)
{
  printf("file_name:%s\n",__FILE__);
  printf("file_line:%d\n",__LINE__);
  printf("file_date:%s\n",__DATE__);
  printf("file_time:%s\n",__TIME__);
  printf("STDC:%d\n",__STDC__);
}

程序环境和预处理_第13张图片

vs2013的sl.exe编译器:

程序环境和预处理_第14张图片

相比之下, gcc编译器是对ANSI C的标准遵守的更全面一些的。

5. 预处理指令 #define

5.1. #define定义标识符

#define可以定义标识符,本质上是一种替换机制。这个标识符会在预处理阶段就被替换掉。例如:

#define COUNT 10
#define STR "hehe"

void Test2(void)
{
    // 这些宏定义的标识会在预处理阶段被替换掉
    // 例如这里本质上是:
    // int i = 10;
	int i = COUNT;
    // 这里是:
    const char* str = "hehe";
	const char* str = STR; 
	printf("%d\n%s\n", i, str);
}

注意:由于#define定义标识符本质上是一种替换机制,因此一般情况下,不要在定义符号时加上 ; 例如下面的场景:

// 如果你在这里添加了';'
#define CAREFUL "Don't add ';'";

void Test3(void)
{
    //那么本质上这个CAREFUL 就是 Don't add ';';,此时printf就会报错
	printf("%s\n", CAREFUL);
}

程序环境和预处理_第15张图片

#define在符合语法的前提下,其用发还是蛮多的,例如:

#define stc static

void Test4(void)
{
	// 此时这个stc就是static
	stc int i = 10;
}

又比如:

#define PRINTF printf("hehe\n")
// 注意: #defin的机制是一种替换机制
void Test5(void)
{
    // 此时这个PRINTF就是一个函数调用
	PRINTF;
}

5.2. #define定义宏

#define不仅可以定义标识符,还可以定义宏;宏是什么呢?#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(definemacro)。

// 定义格式:
#define name( parament-list ) stuff 
// 其中的parament-list是一个由逗号隔开的符号表, 它们可能出现在stuff中。
// 注意:参数列表的左括号必须与name紧邻.
// 如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分

我们可以实现一个乘法的宏

#define MUL(x) x * x

void Test6(void)
{
	int x = 10;
	printf("ret: %d\n", MUL(x));
}

程序环境和预处理_第16张图片

诶,结果正确的,但是如果是这样的呢? 

void Test7(void)
{
	printf("ret: %d\n", MUL(9 + 1));
}

 

此时看到这个结果,有人就懵逼了,什么情况,我只是换了一种表现形式,结果就不一样了?注意:我们应该牢记宏的机制是一种替换机制,也就是说这个宏会被替换成如下的场景:

printf("ret: %d\n", 9 + 1 * 9 + 1);

此时结果当然是19,那么为了防止产生这种问题,我们应该如何解决呢?解决方案:宏的实现我们要多用(),如下: 

// 不要吝啬括号
#define MUL(x) (x) * (x)
void Test8(void)
{
	printf("ret: %d\n", MUL(9+1));
	// 上面的代码会被替换成如下代码:
	//printf("ret: %d\n", (9 + 1) * (9 + 1));
}

程序环境和预处理_第17张图片 

此时才符合我们的预期 ,但有时候我们可能会遇到这种情况,如下:

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

void Test9(void)
{
	int x = 5;
	int ret = 2 * DOUBLE(10) * DOUBLE(10);
	// 我们预期的结果应该是 2 * 20 * 20 = 800
	printf("ret: %d\n", ret);
}

程序环境和预处理_第18张图片

为什么呢会出现这种现象呢?原因是我们的宏写的有问题,上面的这个表达式会被替换成如下形式: 

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

void Test9(void)
{
	int x = 5;
	int ret = 2 * DOUBLE(10) * DOUBLE(10);
	// ret = 2 * (10) + (10) * (10) + (10)  刚好是130
	printf("ret: %d\n", ret);
}

因此,我们的宏应该做出如下改变:

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

void Test9(void)
{
	int x = 5;
	int ret = 2 * DOUBLE(10) * DOUBLE(10);
	// 此时会被替换成如下形式
	// int ret = 2 * ((10) + (10)) * ((10) + (10));  此时才是800
	printf("ret: %d\n", ret);
}

总结,在写宏的时候,不要吝啬括号的使用

5.3. #define替换规则

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

6. 宏和函数的对比

1. 宏的优点

1. 宏的方式是替换,不做计算,也不做表达式的求解,不需要建立函数栈帧,对于某些短小的逻辑处理,如果使用函数,那么可能会存在函数栈帧的建立和销毁函数逻辑处理更为复杂;因此,在某些情况下,宏比函数在速度方面更胜一筹,其处理过程也更为简单。例如:

#define ADD(x,y) ((x) + (y))

int add(int x, int y)
{
	return x + y;
}

void Test16(void)
{
	int x = 10;
	int y = 20;
	int c = ADD(x, y);

	c = add(x, y);
}

1. 宏的处理:

程序环境和预处理_第19张图片

2. 函数的处理:

程序环境和预处理_第20张图片

程序环境和预处理_第21张图片 

可以看到,函数的处理是十分复杂的,其函数栈帧的建立与销毁比实际执行代码逻辑更为复杂。但是宏却的处理就更为简单。

2. 函数必须要求传参的时候要有类型,但是宏没有这个要求。 例如上面我们写的ADD这个宏和add函数,add函数已经限制了参数类型,在不发生类型转换的前提下,只能比较整形,但是我的宏没有这个要求啊,你传什么,我就比较什么,相比之下,函数更为灵活。

3. 其次宏还可以传递类型,但是函数是做不到的。例如:

// type 就是你需要的数据类型
// size 代表多少个字节
#define GETMEMORY(type,size) (type*)malloc(size)

2. 宏的缺点 

上面就是一些宏的优点,但是它也有缺点,例如:

1. 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
2. 宏是没法调试的,因为在预处理阶段,宏就被替换了,调试的是替换之后的代码。
3. 宏由于类型无关,没有类型安全检查,也就不够严谨。
4. 宏可能会带来运算符优先级的问题,导致程序容易出现错。

3. 宏和函数的对比

#define定义宏 函数
代码长度
每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长
函数代码只出现于一个地方;每次使
用这个函数时,都调用那个地方的同
一份代码
执行速度 更快
存在函数的调用和返回的额外开销,
所以相对慢一些
操作符优先级
宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,建议不要吝啬括号的使用
函数参数只在函数调用的时候求值一
次,它的结果值传递给函数。表达式
的求值结果更容易预测
带有副作用的参数
参数可能被替换到宏的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果
函数参数只在传参的时候求值一次,
结果更容易控制
参数类型
宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型
函数的参数是与类型有关的,如果参
数的类型不同,就需要不同的函数,
即使他们执行的任务是相同的
调试 宏是不支持调试的 函数是可以调试的
递归 宏是不能递归的 函数是可以递归的

7. 预处理操作符#和##的介绍

7.1. #的作用

#的作用:使用 # ,可以将一个宏参数变成对应的字符串。不过在这之前,我们需要见一个东西:
void Test11(void)
{
	printf("cowsay hello\n");
	// 下面打印的结果是什么呢?
	printf("cowsay " "hello" "\n");
}

程序环境和预处理_第22张图片

我们发现,在printf中,字符串是有自动连接的功能的。上面两个的打印其实是等价的。有了这个认识,我们在来了解#的作用。

void Test12(void)
{
	int x = 10;
	printf("the val of x is %d\n", x);

	int y = 20;
	printf("the val of y is %d\n", y);
}

上面的两条打印是不是差异很小,如果我想将其封装成一个函数呢?即如果是x,则打印上面的语句,如果是y打印下面的语句。可是我们发现,函数是很难做到这件事情的。例如:

void print(int val)
{
    // 函数很难达到预期目的,中间这个val限制死了  
	printf("the val of val is %d\n", val); 
}

但是我们的宏可以做到:

//#val 的作用就是: 将这个宏参数不经过任何替换,直接把它转换为一个字符串,即 "val"
//此时如果宏参数是x,那么 #val 就相当于 "x"
//同理,如果宏参数是y,那么 #val 就相当于 "y"
//而我们之前说过,printf可以将多个字符串连接起来
#define PRINT(val) printf("the val of " #val " is %d\n",val);

void Test13(void)
{
	int x = 10;
	int y = 20;
	PRINT(x);
	PRINT(y);
}

程序环境和预处理_第23张图片

其实我们还可以这样玩,上面的这个宏其实也很有限制,它局限于打印整形,但如果此时我想根据类型打印呢,即你是整形,我就用"%d"打印,你是浮点型,我就用"%lf"打印,该如何实现呢?

// 其中printf会将这五个字符串连接起来
// 此时我们就可以达到目的: 根据显示传递的打印格式,打印特定值
#define PRINT(val,format) printf("The val of " #val " is " format "\n",val);

void Test14(void)
{
	int x = 10;
	double PAI = 3.14;

	PRINT(x,"%d"); 
	PRINT(PAI, "%lf");
}

程序环境和预处理_第24张图片

总结:#的作用:可以把一个宏参数转换为对应的字符串

7.2. ##的作用

##的作用:可以将位于##两边的符号合成一个符号。它允许宏定义从分离的文本片段创建标识符。例如:

void printhehe()
{
	printf("hehe\n");
}

// 这个宏的作用: 将x和y合成为一个符号
#define CAT(x,y) x##y

void Test15(void)
{
    // 此时CAT(print,hehe)的结果就是 printhehe
    // 而printfhehe是一个函数,调用这个函数
	CAT(print, hehe)();
}

程序环境和预处理_第25张图片

注意:这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。 例如:

程序环境和预处理_第26张图片

8. 命令约定

通常情况下,宏名我们全部大写,函数名不要全部大写。

9. 预处理指令 #undef

#undef这条指令可以移除一个宏定义。例如:

#define GETMEMORY(type,size) (type*)malloc(size)

void Test17(void)
{
	int* ptr = GETMEMORY(int, sizeof(int)*10);

#undef GETMEMORY   // 移除GETMEMORY这个宏

	int* str = GETMEMORY(char, 5);  // 此时就不认识这个GETMEMORY,编译报错
	int tmp = 0;
}

10. 命令行定义 

许多C的编译器提供了一种能力,允许在命令行中定义符号。例如gcc编译器:

#include 

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

  for(size_t i = 0; i < sz; ++i)
  {
    arr[i] = i;
  }

  for(size_t i = 0; i < sz; ++i)
  {
    printf("%d ",arr[i]);
  }
  printf("\n");
}

可以看到,上面的代码中的sz没有定义,如果此时直接编译,是会报错的。

程序环境和预处理_第27张图片

 但是我们可以利用命令行定义,用于启动编译过程。例如:

gcc -o my_test test.c -std=c99 -D sz=10   //命令行定义

程序环境和预处理_第28张图片

11. 条件编译

条件编译顾名思义,满足条件就编译,不满足条件就不编译。常见的条件编译指令如下:

1. 格式如下:

#if   常量表达式

        如果符合条件,就进行编译

#endif 

例如:

void Test18(void)
{
#if 5
	printf("hehe\n");   // 满足条件,进行编译
#endif

#if 0
	printf("haha\n");   // 不满足条件,不编译
#endif
}

注意哦,每个#if都要有#endif与之配对。

2. 多个分支的条件编译,格式如下:

#if 常量表达式

      //符合条件就编译,否则,不编译

#elif 常量表达式

     //符合条件就编译,否则,不编译

#else

     //既不符合#if的常量表达式也不符合#elif的常量表达式那么就进行编译,否则不编译

#endif

例如:

#define NUM 1

void Test19(void)
{
#if NUM == 0
	printf("haha\n");   // 当NUM == 0时,进行编译
#elif NUM == 1
	printf("hehe\n");  // 当NUM == 1时,进行编译
#else
	printf("heihei\n"); // 当NUM != 0 && NUM != 1时,进行编译
#endif   // 最后要以#endif 结束
}

3. 判断是否被定义,格式如下:

#if defined(symbol)   
     // 如果symbol被定义了,那么编译,否则不编译
#endif
#ifdef symbol
    // 如果symbol被定义了,那么编译,否则不编译
#endif
#if !defined(symbol)
    // 如果symbol没定义,那么编译,否则不编译
#endif
#ifndef symbol
    // 如果symbol没定义,那么编译,否则不编译
#endif
例如:
#define BLUESKY

void Test20(void)
{
#if defined(BLUESKY)
	printf("hehe\n");   // 如果BLUESKY定义了,那么编译
#endif

#ifdef BLUESKY
	printf("heihei\n");  //如果BLUESKY定义了,那么编译
#endif

#if !defined(BLUESKY)
	printf("haha\n");  // 如果BLUESKY没定义,那么编译
#endif

#ifndef BLUESKY
	printf("xixi\n");  // 如果BLUESKY没定义,那么编译
#endif
}

4. 嵌套定义

具体演示如下:

#define PLANT
#define FLOWER

void Test21(void)
{
#ifdef PLANT            // 如果定义了PLANT,则编译
	#ifdef FLOWER       // 如果定义了FLOWER,则编译
		printf("rose\n");
	#endif
	#ifdef GRASS       // 如果定义了GRASS,则编译
		printf("green grass\n");
	#endif

#elif defined(ANIMAL)   // 如果定义了ANIMAL,则编译
	#ifdef LION         // 如果定义了LION,则编译
		printf("lion\n");
	#endif
#endif
}

条件编译的运用非常广泛,尤其是一些库实现。

12. 文件包含

 12.1. 头文件被包含的方式

1. 本地文件被包含的方式

#include "filename"

本地文件的查找策略:

第一步:会先在源文件所在的目录下进行查找,找到了,就结束。如果没找到,那么进行第二步。

第二步:第二次查找,编译器就像查找库函数头文件一样在标准位置查找头文件。 如果找不到就提示编译错误。

// Linux下的标准库文件搜索路径:
/usr/include
//vs2013环境的标准头文件的路径:
E:\vs2013\Microsoft\VC\include

2. 库文件被包含的方式

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

对比这两种包含方式,我们发现,其实库文件也可以用 #include "filename"去查找,但是会导致效率下降,因为它首先会在源文件所在目录进行查找,找不到,然后会去标准路径下去查找;不仅如此,此时也不容易区分到底是本地文件还是库文件了,因此,我们还是建议区分开来,本地文件就用"filename"的形式,库文件就用的方式。

12.2. 嵌套文件包含

在以后的编写代码的过程中,难免会出现头文件重复出现的情况。例如下面的情况:

// add.h
int add(int x,int y)
{
  return x + y;
}
// test.c
#include "add.h"
#include "add.h"
#include "add.h"

int main()
{
  int a = 10;
  int b = 20;
  int c = add(a,b);
  return 0;
}
// 我们看一下预处理后的结果
gcc -E test.c -o test.i

程序环境和预处理_第29张图片

在预处理阶段,头文件会被展开, 如果此时相同的头文件头有多份,并且假设每份的头文件代码量很多,这样就会造成文件内容的大量重复,如何解决这个问题呢?  

这时候,我们的条件编译就派上用场了,如下:

// 方案一:
#ifndef __ADD_H_   // 如果没有定义 __ADD_H_,那么编译下列代码
#define __ADD_H_   // 定义__ADD_H_
int add(int x,int y)
{
  return x + y;
}
#endif

// 上面只是一种方式,或者可以用 #pragma once
// 方案二:
#pragma once     
int add(int x,int y)
{
  return x + y;
}

// 上面的两种方式,就可以保证一份源文件中只会有一份这个头文件
//重新查看预处理后的结果: 
gcc -E test.c -o test.i

程序环境和预处理_第30张图片

此时就只有一份add.h的内容了,这就是防止头文件的重复引入。

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