梦开始的地方—— C语言预处理+编译过程

Posted 爱敲代码的三毛

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了梦开始的地方—— C语言预处理+编译过程相关的知识,希望对你有一定的参考价值。

文章目录


C语言程序的编译(预处理)

在C语言标准规定在C的任何一种实现中,存在两个不同的环境。

第一种是翻译环境,在这个环境中被转换为可执行的机器指令,第二种是执行环境,它用于实际执行代码。

1.编译和链接

假设有一个test.c的源代码,它需要经过编译——>链接——>可执行程序

如果有多个.c的源代码文件,它们每个都会单独的进行编译再通过链接器最后变成可执行程序

  • 组成一个程序的每个源文件通过编译转换成目标代码
  • 每个目标文件又链接器捆绑在一起,形成一个单一而完整的可执行程序
  • 链接器同时也会引入C库函数中任何被该程序所用到的函数,链接器还可以搜索程序员个人的程序库,将其需要的函数也链接到程序中

1) 编译的几个阶段

注意:此时我的环境是Centos7.6的gcc编译器

代码:

test.c

#include <stdio.h>
#define MAX 666666
//声明外部函数
extern int add(int x, int y);
int main()

	int a = 10;
	int b = 20;
    int tmp = MAX;
	printf("%d\\n", a + b);

	return 0;

add.c

int add(int x, int y)

	return x + y;

翻译环境中的编译又可以分为3个阶段

  • 预编译
  • 编译
  • 汇编

预编译阶段

在预编译期间编译器会做那么几件事

  1. 头文件的包含
  2. 注释的删除
  3. #define定义符号的替换
  4. 预处理指令

我们在Linux上使用gcc -E test.c > test.itest.c文件进行预编译,预编译之后立马就会停下来,预编译的解结果保存到test.i文件中方便查看

我们会发现,预编译后。我们的写的注释不见了,写的头文件也不见了,多了一堆函数声明(这只是部分截图)。

我们发现几点

  1. #include <stdio.h>头文件不见了
  2. 写的注释被删除了
  3. #define定义的MAX也被替换了

我们可以验证以下头文件的包含,在我的Linux系统中的/usr/include/stdio.h保存了stdio.h文件,查看后发现里面的函数信息的确是我们上面所看到了。所以预处理接段就会把头文件中的内容包含到源文件中。

编译阶段

在编译阶段会把C代码翻译成汇编代码,做这么几件事

  1. 语法分析
  2. 词法分析
  3. 语义分析
  4. 符号汇总

语法分析简单就是检查代码是否有语法错误

词法分析:把C语言代码一个个拆分开来,建立一个语法树之类的东西

语义分析:简单来说就是把C语言的代码怎么转换成对应的汇编代码,C语言的额一个语义

通过gcc -S test.ctest.c文件进行编译,编译完后会停下来将结果保存到test.s文件中。test.s中保存的就是汇编代码。

符号汇总是编译阶段一个非常终要的过程。

符号汇总就是把文件中重要的符号给提取出来

我们简单修改一下test.c文件

#include <stdio.h>
#define MAX 666666
//声明外部函数
extern int add(int x, int y);
int count = 0;
void print()

    

int main()

	int a = 10;
	int b = 20;
        int tmp = MAX;
	printf("%d\\n", a + b);

	return 0;

在Linux文件下通过命令gcc -c test.c生成一个test.o的目标文件对于前面所讲的windows中的.obj文件

再通过readelf -s test.o命令查看里面的内容发现,只记录另外关键的一些全局的函数和变量

再来看add.c的源文件,这个文件中只有一个add函数

把它们的符号进行汇总,把主要的符号进行汇总,就会符号汇总。

汇编阶段

再Linux环境下通过命令gcc -c test.stest.s中的汇编代码转换为二进制指令,生成一个test.o的二进制文件

再汇编阶段还会形成符号表,在前面的编译阶段只是将符号进行汇总。而这里汇编阶段会会生成一个.o的文件(windows中是.obj文件),把前面汇总的符号形成一个符号表,符号表中记录的了汇总的符号并给它们分配了一个地址。

注意:main函数里的add只是一个声明,给这个add分配的这个地址是没有任何意义的,相当于就是一个标识符,这函数有没有还是取决去前面是否定义这个add函数

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-p0rmGJpA-

2) 链接

在Linux环境下通过gcc test.o对目标文件进行链接,生成一个a.out的可执行文件(相当于windos中的.exe文件)

在链接期间主要会做那么两件事情

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

简单来说就是它会把多个.o的目标文件进行链接,因为一个项目编译后会有多个目标文件,这些文件又没有任何关系,通过它们的函数声明进行链接,把这些文件都关联起来。

如果一个函数没有被定义就会出现的链接错误(无法解析的外部命令)

和并段表和符号表,简单理解就是多个目标文件中相同的段只保留一个,比如合并符号表保留add函数的符号和地址。链接期间就是检查外部的一些函数和符号定义是否合法。

链接完毕后就生成了可执行程序。

图解过程

运行环境

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

2. 预处理

1) 预定义符号

C语言中由一些预定义的符号,它们分别保存这一些信息,它们也是在预处理阶段被直接替换的。

__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__);

	return 0;

在vs2019中没有__STDC__没有定义整个符号,说明vs2019对ANSI C的支持是不好的,而我在Linux环境下正常输出1说明在LInux环境下是严格遵循C语言标准的。

2) #define

通过#define可以定义标识符,也可以定义宏

#fefine定义标识符

#include <stdio.h>
#define MAX 100000
#define STR "hello"
#define PRINTLN printf("\\n")
int main()

	printf("%d", MAX);
	PRINTLN;
	printf("%s", STR);
	
	

	return 0;

运行结果

100000
hello

#define定义宏的时候,后面要不要加分号;?

建议是不加,加上分号分号也会被替换过去,需要的时候加就可以了。

#define 定义宏

#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。

写一个宏来计算两个数的和

#include <stdio.h>
#define ADD(x,y) x+y
int main()

	int a = 10;
	int b = 20;
	printf("%d\\n", add(a, b));
	
	

	return 0;

这中写法是存在问题的,当写出这样的代码的时候就会出现问题

#include <stdio.h>
#define ADD(x,y) x+y
int main()

	int a = 10;
	int b = 20;
	printf("%d\\n", add(a, b)*add(a,b));



	return 0;

打印结果

230

这并不是我们想要的结果,因为在替换后发生了优先级的问题

printf("%d\\n", ADD(a, b)*ADD(a,b));
	//等价于printf("%d\\n", 10+20*10+20);

解决方法就是给宏的每一个参数加上括号,整体再加上括号

#include <stdio.h>
#define ADD(x,y) ((x)+(y))
int main()

	int a = 10;
	int b = 20;
	printf("%d\\n", ADD(a, b)*ADD(a,b));


	return 0;

所以以后用宏求这种数值表达式的时候,最后把每一个参数加上括号,避免再使用宏。

define替换宏的规则

在程序中进行宏替换的时候,需要涉及到以下几个步骤

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

注意

  1. 宏参数和#define 定义中可以出现其他#define定义的变量。但是对于宏,不能出现递归
  2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索

比如下面这种写法是没有问题的

#define MAX 1000
#define add(x,y) ((x)+(y))*MAX

这种宏里写#define定义的符号没有问题,但宏是不支持自己调用自己的

3) #和##

如何把参数插入到字符串中?

解答这个问题前先来看一下C语言的另外一种字符串写法


#include <stdio.h>

int main()

	char* str = "hello" "world;";
	printf("%s\\n", str);
	printf("123" "abc\\n");
	return 0;

打印结果

helloworld;
123abc

把两个字符串写一起,在编译阶段它们会自动拼接成一个字符串。

现想完成这么一个打印,把一个变量的变量名和值打印出来,且插入在字符串中,我们发现这并不好实现。这个时候就可以用到宏。

#include <stdio.h>

int main()

	float f = 4.5f;
	printf("the value of f is %f\\n", f);

	int a = 10;
	printf("the value of a is %d\\n", a);

	int b = 20;
	printf("the value of b is %d\\n", b);

	return 0;

通过宏定义可以把代码写成这样,也能达到上面代码的效果,避免了代码的冗余。

#include <stdio.h>
#define PRINT(data, format) printf("the value of "#data" is %"#format"\\n",data)
int main()

	float f = 4.5f;
	PRINT(f, f);

	int a = 10;
	PRINT(a, d);


	int b = 20;
	PRINT(b,d);

	return 0;

#data等价于“data”,在预编译期间就会被替换成对于的字符。

##的作用

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

#include <stdio.h>
#define APPEND(str,number) str##number
int main()

	int day100 = 2022;
	printf("%d\\n", APPEND(day,100));

	return 0;

运行结果

2022

4) 带副作用的宏参数

当宏参数的定义出现超过一次的时候,如果参数带有副作用,那么在使用这个宏的时候就可能出现危险,导致不可预测的后果。

比如下面这个代码救会出现副作用

#include <stdio.h>
#define MAX(x,y) ((x)>(y)?(x):(y))
int main()

	int a = 10;
	int b = 20;
	printf("%d\\n", MAX(a++, b++));
	printf("a=%d b=%d\\n", a, b);

	return 0;

这里的**b++**被执行了两次,相当于替换后的表达式就是

printf("%d\\n", ((a++) > (b++) ? (a++) : (b++)));

这就是带有副作用的宏参数

5) 宏和函数对比

宏通常用来做一些简单的运算,比如我们求两个数的和

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

那为什么不用函数来完成这个任务呢?

int add(int x, int y)

    return x + y;

宏对比函数的优势

  1. 用调用函数和从函数返回的代码可能比实际执行这么一个小型计算工作所需要的时间更多,所以宏比函数的规模和速度上更胜一筹

    来看一段代码对比

    这是通过宏来计算两数之和的汇编代码

然后再看下通过函数计算两数之和代码转换为汇编代码的代码量

我们发现通过宏实现代码量只有7条,而通过函数实现则由十几行汇编代码。

宏在预编译期间就把定义的代码进行替换后面进行运算就可以了,而函数则存在一个调用+运算+返回三个过程。

  1. 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏怎可
    以适用于整形、长整型、浮点型等可以用于>来比较的类型。宏是类型无关的

    上面的代码宏能计算各种类型的和,而函数只能只能计算整形的和。

    再举个列子,我们常用的malloc函数用来开辟空间,我们可以写一个宏来开辟空间,而传递类型函数是做不到的。

    #include <stdio.h>
    #include <stdlib.h>
    #define MALLOC(size,type) (type*)(malloc(sizeof(type)*size)) 
    
    int main()
    
    	int* arr = MALLOC(10, int);
    	int i = 0;
    	for (i = 0; i < 10; i++)
    	
    		arr[i] = i;
    	
    	for (i = 0; i < 10; i++)
    	
    		printf("%d ", arr[i]);
    	
    	return 0;
    
    

宏对比函数的劣势

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

对比总结

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

3. 常见预处理命令

1) #undef

这条指令用来一处一个宏定义

#include <stdio.h>
#define MAX 1000

int main()

	int tmp = MAX;
#undef MAX
	int ret = MAX;//报错

	
	return 0;

2) 命令行定义

许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。 例如:当我们根据同一个源文件要编译出不同的一个程序的不同版本的时候,这个特性有点用处。

#include <stdio.h>


int main()

	int arr[SIZE] =  0 ;
	int i = 0;
	for (i = 0; i < SIZE; i++)
	
		arr[i] = i;
	
	for (i = 0; i < SIZE; i++)
	
		printf("%d ", arr[i]);
	

	
	return 0;

再Linux64位环境下通过命令gcc -D SIZE=10 test.ctest.c文件进行编译,生成a.out文件,运行就是一个大小为10的数组

[root@aliyun code]# ./a.out 
0 1 2 3 4 5 6 7 8 9

3) 条件编译

在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
比如说:
调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译

#include <stdio.h>
#define DEBUG 1

int main()

	printf("hello world!\\n");
#ifdef DEBUG
	printf("test");
#endif // DEBUG


	
	return 0;

如果把DEBUG设置为0,打印test的那一行代码就不会进行编译

当然也可以多个分支

#include <stdio.h>
#define DEBUG 0

int main()

	int a = 0;
	printf("hello world!\\n");
#if DEBUG
	printf("test");
#elif a
	printf("false");
#else
	printf("haha");
#endif // DEBUG

	return 0;

嵌套定义

#include <stdio.h>
#define DEBUG 0

int main()

	int a = 0;
	printf("hello world!\\n");
#if defined(DEBUG)
	#if 0
	printf("0");
	#elif a-1
	printf("0");
	#else a+1
	printf("1");
	#endif

#endif



	
	return 0;

4) 文件包含

我们已经知道, #include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样。
这种替换的方式很简单: 预处理器先删除这条指令,并用包含文件的内容替换。 这样一个源文件被包含10次,那就实际被编译10次 。

头文件被包含方式