C语言程序环境中的预处理详解

 更新时间:2022年2月28日 10:22  点击:304 作者:蔡欣致

一、翻译环境

整个翻译环境大致就可以画成这样一张图。

下列有几点需要说明:

1. 组成一个程序的每一个源文件通过编译过程分别转换成目标文件(在Linux中目标文件的后缀为.o;而在Windows中目标文件后缀为.obj)

2. 每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序

3. 链接器同时也会引入标准C函数库(链接库)中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中

接下来介绍每一步在Linux系统下整个翻译环境的实现方法,以及每一个步骤的作用。

编译可分为三个部分:

(1)预处理:输入指令gcc -E test.c -o,就会将test.c文件变为test.i文件。这一步的作用是是对头文件(#include)的包含、删除注释、#define定义符号的替换等文本操作(下文会对预处理这一个步骤展开详细的介绍)

(2)编译:输入指令gcc -S test.i,就会将test.i文件变为test.s文件,这一步主要作用是把C语言代码转换成汇编代码,其中包含4步:1. 语法分析;2. 词法分析;3. 语义分析;4. 符号汇总

(3)汇编:输入指令gcc -c test.s,就会将test.s文件变为test.o文件,这一步是把汇编代码转换成二进制的指令,这一步是会形成符号表,此时的符号表为接下来的链接操作做出了准备

多个.c文件通过编译过程后形成.o目标文件,在要执行链接的时候,输入指令gcc test.o add.o -o test,就会将.o文件变成可执行文件,这其中的操作包括合并段表和符号表的合并和重定位,这一步主要就是将多个目标文件进行连接的时候通过符号表查看来自外部的符号是否真实存在,这样就完成了整个翻译环境的操作。

二、执行环境

对于程序的执行过程可分为以下几个步骤:

1. 程序必须载入内存中。在有操作系统的环境中:一般由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成

2. 程序的执行开始。之后就会调用main函数

3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址;程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值

4. 终止程序。正在终止main函数,也有可能是意外终止的情况

三、预处理

1. 预处理符号

在C语言中,有些预处理符号是语言内置的,就比如:

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

2. #define定义标识符

#define定义的标识符可以是常量、简化关键字、一些符号等,例如:

#define M 10   //定义常量
#define reg register   //将关键字简化
#define do_forever for(;;)   //用形象的符号来替换一种实现
#define CASE break;case   //在写case语句的时候会自动地把break写上

对于#define定义标识符来说,如果定义的东西过长,还可以分几行来写,除最后一行外,其他每行都加上'\',例如:

#define DEBUG_PRINT printf("file:%s\tline:%d\t \
							date:%s\ttime:%s\n",\
							__FILE__,__LINE__, \
							__DATE__,__TIME__)

3. #define定义宏

在#define定义标识符外,#define还有一个规定,就是允许把参数替换到文本中,进而就形成了#define定义宏。声明的方式如下:

#define name(parament-list) stuff

这里的parament-list是由一个逗号隔开的符号表,在实际的代码中他们也会存在于stuff中。

其中值得注意的是:

1. 参数列表的左括号必须与name相邻

2. 如果parament-list与stuff两者之间有任何空白存在,参数列表就会被注释为stuff的一部分

了解了#define定义宏是如何写后,接下来就是#define定义宏的替换规则:

1. 在调用宏的时候,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换

2. 替换文本随后被插入到程序中原来的文本位置,参数名被它们的值所替换

3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程

所以,总结以上规则后得出的结论就是:如果是#define定义宏用于对数值表达式进行求值的宏定义都应该加上括号,避免在使用宏时由于参数中的操作符或者邻近操作符之间不可预料的相互作用。

当然,对于#define的使用还有几个注意的点:

1. 宏参数和#define定义中可以出现其他#define定义的符号,但是对于宏,不能出现递归

2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索

4. #和##

对于一些想要把参数插入到字符串中的情况,我们会使用#来把一个宏参数变成对应的字符串,下面举个例子:

如果是直接打印出来的话,因为字符串是可以拼接的,所以就如这样:

#include <stdio.h>
int main()
{
	int a = 10;
	printf("the value of ""a"" is %d\n", a);
	return 0;
}

那么,对于定义宏参数来说,就应该这样:

#include <stdio.h>
#define PRINT(n) printf("the value of "#n" is %d\n", n)
int main()
{
	int a = 10;
	PRINT(a);
	return 0;
}

这样字符串中的n才会根据跟着宏参数的值变化而变化。

而##的作用是可以把位于它两边的符号合成一个符号。它允许宏定义从分离的文本段创建标识符。但是这样连接必须产生一个合法的标识符,否则会报错说未定义标识符。

5. 宏和函数的对比

宏的优势:1. 在执行一些小型计算工作的时候,定义宏比调用函数和从函数返回的代码执行所需要的时间会更短;2. 函数的参数必须声明为特定的类型,二宏参数不用

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

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

6. 条件编译

下面列举一些编译指令:

1. #undef 该指令用于移除一个宏定义

2. 该指令是判断应该执行哪一个语句块

#if 常量表达式
    执行语块
#elif 常量表达式
    执行语块
#else
    执行语块
#endif

3. 该指令是判断是否被定义

#if define(symbol)
    如果有定义,执行此语句块
or
#ifdef symbol
    如果有定义,执行此语句块
or
#if !define(symbol)
    如果没有定义,执行此语句块
or
#ifndef symbol
    如果没有定义,执行此语句块

4. 对于条件编译指令来说,其实还可以对其进行嵌套,称为嵌套指令

7. 文件包含

我们在一些较大工程进行编译的时候、在多人合作同一块项目工程的时候,可能会出现头文件重复包含的情况,如果真是这样,则会导致整个代码运行时的效率大大降低,所以对头文件避免重复包含就显得十分重要了。那么,如何避免呢?下面就有一段代码可以用来避免这种情况:

#ifndef __TEST_H__
#define __TEST_H__
    写头文件内容
#endif

这段代码就可以很好地解决了头文件重复包含的问题,但是实际上,如果是在VS的环境下进行编译,会自动在最开始的地方写上:#pragma once,这句代码一样也是可以解决重复包含的问题。

那么,解决完头文件重复包含的问题后,就来介绍两种头文件包含的方式:

1. 用引号包含的头文件,例如:#include "test.h"。这种包含方式头文件的查找策略是先在源文件所在的目录下查找,如果该头文件未被找到,编译器就像查找库函数头文件一样在标准位置查找头文件,如果还找不到,则会直接报错。

2. 用尖括号包含头文件,例如:#include 。这种包含方式则是未有第一步,直接进行第二步。

但是不能说为了保证万无一失,直接把全部头文件的包含都用引号进行包含,这样的话有些时候其实是用尖括号的情况而错用引号导致程序的执行速度下降、效率下降等。

总结

本篇文章就到这里了,希望能够给你带来帮助,也希望您能够多多关注猪先飞的更多内容!     

原文出处:https://blog.csdn.net/Faith_cxz/article/details/123141154

[!--infotagslink--]

相关文章