浅谈程序环境和预处理

文章目录

  • 前言
  • 1. 程序的翻译环境和执行环境
  • 2.预处理指令
    • 1.预定义符号
    • 2.define定义标识符
    • 3.define定义宏
    • 4.define替换规则
    • 5.#的特殊作用
    • 6.带副作用的宏参数
    • 7.宏和函数的比较
    • 8. 命令行定义和条件编译
    • 9.文件包含
  • 3.宏的简单应用
    • 1.malloc简单封装
    • 2.offsetof模拟
    • 3.一个整数的二进制位的奇数位和偶数位交换
  • 4.总结


前言

在学习C语言时,可能会用到#define预处理指令,预处理是一个C语言中一个重要的知识点,本文将对预处理进行介绍,同时也会简单介绍C语言源文件在执行过程中发生了哪些事。了解这些将会对C语言认识得更加深刻


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

在介绍预处理之前我们先简单了解一下C语言的执行过程。首先要明白在ANSI C的任何一种实现中,存在两个不同的环境。第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。第2种是执行环境,它用于实际执行代码。

浅谈程序环境和预处理_第1张图片
组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。

int add(int a, int b)
{
	return a + b;
}
#include
extern int add(int, int);
int main()
{
	int n = 10;
	int m = 20;
	int ret=add(n, m);
	printf("%d\n", ret);
	return 0;

}

当我们在一个工程项目中建立两个.c源文件,一个add.c文件中声明定义add函数,另一份源文件写下上述第二份代码。当我们调用main函数时程序运行成功打印30,extern关键字声明了来自外部的符号add,在main函数中成功调用了add函数,那么是不是说明在程序运行期间会对源文件中的符号进行识别呢?识别符号是发生在运行的哪个阶段呢?

其实在翻译环境中程序的执行大致可以分为如下过程 :预处理(预编译) 编译 汇编 链接。在预处理阶段会进行 头文件的包含 注释的删除 预处理指令defined的替换。在编译阶段会将C语言代码转化成汇编代码,经行相关的语法语义词法分析和符号汇总。 在汇编阶段会把汇编指令转成二进制指令,同时形成对应的符号表 。 在链接阶段会合并段表和符号标的合并重新定位。
刚才提到的add符号识别就是发生在编译阶段。每个函数符号会与函数地址相对应。

浅谈程序环境和预处理_第2张图片
在图中我使用了没有声明的add符号,在运行程序时报错,观察报错信息LINK的意思就是链接,在链接时出现了错误。

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

关于编译时的具体情况可以在Lunix中观察到,有兴趣的同学可以自己尝试观察。关与编译链接只是介绍了简单大概没有深究,相关的具体细节可以去看《程序员的自我修养》这本书。


2.预处理指令

1.预定义符号

在介绍define预处理之前,先简单介绍语言自带的预定义符号。

FILE //进行编译的源文件。
LINE //文件当前的行号。
DATE //文件被编译的日期。
TIME //文件被编译的时间。
STDC //如果编译器遵循ANSI C,其值为1,否则未定义。

这些符号都是这些预定义符号都是语言内置的,设置好了的

代码示例

#include 
int main()
{
    printf("%s\n", __FILE__);
    printf("%d\n", __LINE__);
    printf("%s\n", __DATE__);
    printf("%s\n", __TIME__);
    return 0;
}

在这里插入图片描述
通过打印结果可以看到相关源文件相关信息都打印出来了

2.define定义标识符

define预处理指令可以对符号重新定义,根据使用者的要求,由使用者自己决定符号的具体含义。

代码示例

#include 
#define MAX 100
int main()
{
    int n = MAX;
    printf("%d", n);
    return 0;
}

在这里插入图片描述
用define预处理指令将 符号MAX定义成100,凡是出现MAX的地方都会被替换成MAX。通过打印结果来看,n确实是被赋值成了100。
那我们在调用标识符时需要加上;吗?
通过代码我们也可以看到,100后面是没有加上;的,因为define会对相应的标识符经行替换,如果加上;出现MAX的地方都会被替换成100;这样就可能在使用时造成程序错误。所以在用define定义标识符时不用加上;

3.define定义宏

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

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

代码示例

#include 
#define MUL(x) (x*x)
int main()
{
	int ret = MUL(5);
	printf("%d\n", ret);
	return 0;
}

在这里插入图片描述
定义了一个宏MUL用来计算一个数平方,打印结果是25确实是5的平方。但是实际上这个宏存在一个问题

代码示例

#include 
#define MUL(x) (x*x)
int main()
{
	int ret = MUL(5+1);
	printf("%d\n", ret);
	return 0;
}

在这里插入图片描述
可以看到结果是11,但是实际上我们想算的是6的平方,为什么会这样呢?前面提到了#define会替换对应的标识符,宏也是这样的。5+1会被替换成5+1*5+1,根据算术优先级结果就是5+5+1=11。所以需要加上括号

#define MUL(x) (x)*(x)

我们再看一段带代码

#include 
#define ADD(x) (x)+(x)
int main()
{
	printf("%d\n", 10*ADD(5));
	return 0;
}

在这里插入图片描述

这个代码中的宏是加入了括号,但是我们想要的结果是100,打印结果却是55。所以还需要再加上一个括号ADD(x) ((x)+(x))
所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用。

4.define替换规则

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

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


5.#的特殊作用

在介绍#的特殊作用之前,我们看一段代码

#include
int main()
{
	char str[10] = { "abc""""efg" };
	printf("%s", str);
	return 0;
}

在这里插入图片描述
打印结果是abcefg,由此发现C语言中字符串是有自动连接的特点的。
# 有个作用,把一个宏参数变成对应的字符串

假如有这么一个场景:打印不同类型的数据。如果我们对个数据都是printf打印显得很繁琐,对于同样的功能我们应该进行简单的封装,但是如果采用函数的方式封装的话显然不太合理,因为函数的参数类型的是固定的,有什么简单有效的方法呢?其实,可以采用宏的方式来实现打印。

代码如下

#define PRINT(val, format)  printf("the value of "#val" is "format"\n", val)
#include
 int main()
{
 int a = 10;
 PRINT(a, "%d");
 int b = 20;
 PRINT(b, "%d");
 float f = 3.5f;
 PRINT(f, "%f");
 return 0;
}

#将参数直接转成字符,C语言字符串有自动连接的特点,结合这些特性定义的宏PRINT就实现打印不同类型数据的功能。这是个巧妙的处理方法。如果是函数的的话显然不能很好的处理这样的问题。

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

代码示例

#define CAT(a,b) a##b
#include
int main()
{
	int num = 10;
	printf("%d", CAT(n,um));
}

在这里插入图片描述

##将两边的字符片段合成一个字符,n和um合成为num,所以打印结果是10.但是要注意这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。


6.带副作用的宏参数

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

代码示例

#include
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
int main()
{
	int a = 6;
	int b = 5;
	int ret = MAX(a++, b++);
	printf("%d %d %d", a, b, ret);
	return 0;
}

在这里插入图片描述
打印结果是8 6 7,为什么会出现这样的结果呢?先前说了#dfine是对应符号的替换 将a和b代入宏,ret=(a++)>(b++)?(a++):(b++)。a和b先加加一次此时a=7,b=6,a大于b,执行问号后面的(a++)不执行(b++),此时a=8,b没有执行加加就还是6。同时加加操作是后置加加,就是先使用后加加,a=7时,实际上int ret=7++,ret先被赋值成7,然后a在自增一次。对于这样的传参方式,要谨慎使用。

7.宏和函数的比较

宏的定义方式和函数的比较像的,但是两者的区别还是很大的。通常宏通被应用于执行简单的运算,因为函数在使用时是需要开辟函数栈帧的,在函数被调用完后还需要销毁函数栈帧,存在函数的调用和返回的额外开销,用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。更为重要的一点是:函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏可以适用于整形、长整型、浮点型等。宏是和类型无关的。

宏的虽然有上述的优点,但是和函数相比宏也有劣势的地方: 1.每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度(这是在程序翻译环境中发生的事)。2. 宏是没法调试的。3. 宏由于类型无关,也就不够严谨。4. 宏可能会带来运算符优先级的问题,导致程容易出现错。

命名约定:一般来讲函数和宏的使用语法很相似。所以语言本身没法帮我们区分二者。那我们平时的一个习惯是:把宏名全部大写函数名不要全部大写。当然也有一些例外:在某些编译器上getchar是用宏实现而不是函数,offsetof也是宏。

补充 #undef指令用于移除宏或预定义符号。如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除
浅谈程序环境和预处理_第3张图片
在使用undef指令之前,n=MAX并没有报错,当使用undef指令以后符号MAX就被移除了,编译器无法识别这个标识符。


8. 命令行定义和条件编译

许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性有点用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大些,我们需要一个数组能够大些。)

代码示例

#include 
int main()
{
    int arr[sz];
    int i = 0;
    for (i = 0; i < sz; i++)
    {
        arr[i] = i;
    }
    for (i = 0; i < sz; i++)
    {
        printf("%d ", arr[i]);
    }
    printf("\n");
    return 0;
}

在启动编译的过程中,我们可以对sz进行自定义的赋值,这个sz的大小由自己定义。这个特性需要在Linux系统下输入对应的命令才能直观的展示出来。

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

代码示例

#include 
#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 
	}
	return 0;
}

这个条件编译指令和if else 语句有点像。这段带代码的意思是如果_DEBUG_这个标识符被定义了,就打印数组元素。#endif表示结束条件编译,这个是不能省略的。只要使用了条件编译指令在结尾处一定要加上这个。

除了这种单个条件编译,也有多个分支的条件编译

#include 
int main()
{
   #if 1<2
	printf("1\n");
   #elif 2<3
	printf("2\n");
   #else
	printf("3\n");
   #endif
	return 0;
}

在这里插入图片描述
打印结果1,这个条件编译指令和if else语句判断是类似的,当#if条件不为真时接着判断#elif条件是否为真,如果不为真就接着往后判断。上述代码中#if条件是为真,就直接打印,不在往后判断后面语句的真假了。条件编译是可以允许嵌套使用的,对每个条件依次判断。
条件编译指令和if else语句虽然是很像的,但是两者是有区别的。预处理指令在预处理阶段通过判断将不要的代码直接删掉。这个不要的代码就是不满足条件的代码。在实现一些跨平台程序时,使用条件编译指令较多。

9.文件包含

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

本地文件的包含

在我们写代码时,有时候会自己定义头文件在工程项目路径底下,当我们想在其他.c源文件中使用这个头文件内容,是直接用" "来包含头文件。这样的方式查找策略是:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果找不到就提示编译错误。

VS环境的标准头文件的路径:C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include
//这是VS2013的默认路径。

库文件包含

库文件的包含一般都是用<> 。查找策略是:查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。刚才在本地文件的包含时提到用" "包含头文件时,是先在源文件所在目录下查找,没有找到再去标准路径下查找,那么是不是说明库文件也可以使用" "来包含。答案是肯定的,可以。但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。所以在包含文件时最好还是使用正常的方法。

嵌套文件包含

思考这样一个场景:comm.h和comm.c是公共模块。test1.h和test1.c使用了公共模块。test2.h和test2.c使用了公共模块。test.h和test.c使用了test1模块和test2模块。这样最终程序中就会出现两份comm.h的内容。这样就造成了文件内容的重复。

浅谈程序环境和预处理_第4张图片
在预编译阶段会进行头文件的包含,当头文件重复嵌套包含时,预编译阶段生成的代码文件会重复拷贝头文件的内容。这样会使得在此阶段生成的代码更加冗长。为了避免重复包含有以下两种做法。

条件编译:每个头文件中写:#ifndef __TEST_H__#define __TEST_H__//头文件的内容。 #endif 这个__TEST_H__符号是随便起的,由使用者自己决定

代码示例

#ifndef __TEST_H__
#define __TEST_H__

int test(int a, int b)
 {
	return 0;
 }
#endif   

在预处理阶段会对文件进行包含也会处理一些预定义指令,条件编译指令也在预定义指令的范畴中。所以当第一次包含文件时也会处理条件编译指令,条件编译指令的意思是如果 __TEST_H__这个符号没有预定以 就对它预定义一下。然后拷贝文件内容也就是对test函数的定义。当第二次包含这个文件时遇到条件编译指令,发现__TEST_H__这个符号被预定义过,就不在往下执行,直接结束了。这样的话就只是包含一次头文件避免了重复包含。这个__TEST_H__符号是随便起的,由使用者自己决定。

第二种处理方式就是在每个头文件开头加上#pragma once这个预处理指令

代码示例

#pragma once

int test(int a, int b)
 {
	return 0;
 }

在vs中新建头文件时,这个指令会默认加入文件中。#pragma预处理指令不止这一中,在结构体的博客中就介绍了另一种指令#pragma pack改变vs默认对齐数。


3.宏的简单应用

1.malloc简单封装

宏和函数相比有个重要的优势就是参数不被数据类型所约束。思考这样一个场景:在使用malloc函数时,需要根据数据类型进行强制类型转换同时还得使用sizeof分配字节或者自己计算分配的字节数,能不能直接对其进行简单的封装,将数据类型当作参数以数据类型为单位分配空间呢?如果使用函数的话,显然不行。因为函数的参数不可能是数据类型,这样的话那就思考一下尝试用宏来解决。

代码示例

#define MALLOC(num, type)  (type *)malloc(num * sizeof(type))
#include
#include
int main()
{
	int* ps = MALLOC(10, int);
	free(ps);
	ps = NULL;
	return 0;
}

利用宏将malloc函数进行了简单的封装。这样想要分配10个整型的话,就直接传10 int,使用起来更加方便。

2.offsetof模拟

模拟实现offsetof:offsetof是用来计算结构体成员的地址相对结构体起始地址偏移量。我们知道结构体在分配内存空间时是存在内存对齐的,结构体成员地址的偏移量就相当于结构体成员的地址减去结构体的起始地址。假如结构体的起始地址是0x123,每个结构体成员的地址减去0x123就是相对起始位置的偏移量。假如说结构体的起始地址是0那么每个成员地址的偏移量实际上就是成员的地址。因为减0,就相当于没减。

代码示例

#include
#define OFFSETOF(s_type, m_name)   (int)&(((s_type*)0)->m_name)
struct S
{
int a;
char b;
}
int main()
{
	printf("%d\n", OFFSETOF(struct S, a));
	return 0}

这段宏代码我们简单分析一下。首先struct S是一种自定义的数据类型。要拿到成员的地址时,肯定是要去访问结构体成员,访问结构体成员只能用指针变量或者是用变量名加.来访问。因为这里是结构体数据类型本身,不存在使用变量名加.来访问。只能用指针来访问。当使用指针变量的时候其实就意味着为这个变量里放的是结构体地址编号。所以当以0来充当指针时,其实是相当于赤裸裸把地址编号直接拿来用了,少了一个变量符号的壳子。这个结构体类型的地址编号被强行默认成了0。然后直接拿这个地址去访问成员。假设指针ps=0x123 ps->a不就是相当于0x123->a。但是数字要想成为地址需要强转指针类型,不然数字就只是字面常量了。因为只是拿了0这个地址编号来用,没有实际访问这个编号地址中的内容所以不会造成问题。
这段宏定义代码我想了一会才真正的理解透彻得出以上的结论

3.一个整数的二进制位的奇数位和偶数位交换

这是一道很有意思的题目,类似于这样的题免不了涉及位运算。那么怎么思考这个问题呢?首先在做题以前先搞清楚哪些位是奇数位哪些位是偶数位。我们以32位2进制整数为例,从数值最低位开始到最高位结束,最低位为第一位,依次往后类推。想实现交换,我们可以把二进制位的每一个偶数位都往右移动一位,所有的奇数位都往左移动一位。在将两者相加即为所求。

浅谈程序环境和预处理_第5张图片
代码示例

#include
#define SWAP_BIT(n)  n=(((n&0xaaaaaaaa)>>1) + ((n&0x55555555)<<1))

int main()
{
	int a = 10;//00000000 00000000 00000000 00001010
	SWAP_BIT(a);
	printf("%d\n", a);//0101  5
	SWAP_BIT(a);
	printf("%d\n", a);

	return 0;
}

因为10101010 10101010 10101010 10101010转为16进制是0xaaaaaaaa 同理01010101 01010101 01010101 01010101转为16进制是0x55555555。这样的写法简略一点。其实这道题目3步走应该是先找对应的奇偶二进制序列再移动一位,最后相加。因为我们在思考时自己人脑自动区分了奇偶序列。所以在写代码要处理序列再移位。


4.总结

关于程序的编译链接只是简单的介绍了一下,预处理指令也是只是介绍了部分。如果对这些内容感兴趣的同学可以去试着研究一下。同时关于宏的使用要谨慎.

这里简单说一下typedef和define的区别:
define
1. 只是简单的字符串替换,没有类型检查
2. 是在编译的预处理阶段起作⽤
3. 可以⽤来防⽌头⽂件᯿复引⽤
4. 不分配内存,给出的是⽴即数,有多少次使⽤就进⾏多少次替换**
typedef:
1. 有对应的数据类型,是要进⾏判断的
2. 是在编译、运⾏的时候起作⽤
3. 在静态存储区中分配空间,在程序运⾏过程中内存中只有⼀个拷⻉

以上内容如有错误,欢迎指正!谢谢!

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