C/C++教程

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

本文主要是介绍C语言---程序环境和预处理,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

文章目录

  • 前言
  • 一、程序的翻译环境,执行环境
  • 二、详解:C语言程序的编译+链接
  • 三、预处理详解
  • 3.1、预定义符号介绍
  • 3.2、预处理指令 #define
  • 3.3、宏和函数的对比
  • 3.4、预处理操作符#和##的介绍
  • 3.5、命令定义
  • 3.6、预处理指令 #include
  • 3.7、预处理指令 #undef
  • 3.8、条件编译
  • 四、利用宏模拟实现offsetof函数




前言

通过本篇博客,可以帮助大家深度了解C语言中相关程序环境和预处理的相关知识,这部分知识也是面试中常考题型,大家一定要把这块知识弄明白。


提示:以下是本篇文章正文内容,下面案例可供参考

前提:在我们的正文开始前我们先考虑一个问题,我们日常编写的代码是一个文本文件(test.c),但是当运行后这样的一个文本文件却变化了一个(test.exe)二进制文件

带着这个问题下面我们开始进入今天博客的主题: 


一、程序的翻译环境

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

①.第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。

②.第2种是执行环境,它用于实际执行代码。

程序可以如期的运行是因为我们日常使用的编译器配备如下图的两个环境:

具体的翻译环境和运行环境又可以细化成如下图的几个步骤:


二、详解编译+链接





1.翻译环境

①.组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code),就是我们常见的后缀为 .obj的文件。

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

③.链接器同时也会引入标准C函数库(C语言库函数)中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库(自己编写的头文件),将其需要的函数也链接到程序中。

那我们的上期的通讯录工程作为样例:C语言----实现动态通讯录_baiyang2001的博客-CSDN博客

用我们熟悉的库函数printf去了解链接库的定义:





2.编译本身也分为几个阶段:

编译的三个阶段分别完成如下的操作:

符号表:

链接:

3.运行环境

程序执行的过程:

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

②. 程序的执行便开始。接着便调用main函数。

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

④. 终止程序。正常终止main函数;也有可能是意外终止。


三、预处理详解

1、预定义符号(这些预定义符号都是语言内置的)

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

前四个预处理符号可以实现的功能:

 第五个符号在 VS2019 中没有定义,所以我们用 Linux 中的编译器 gcc 去实现

在 VS2019 中:

 这里的报错是由于其在VS2019中没有对应的定义

在Linux中的gcc编译器下:

我们在 Linux编译器 gcc的环境下我们输入这样的代码,我们发现程序运行出来的结果是 1 ,这就证明我们 gcc 的编译器,遵循我们的ANSI C 的语法标准,并且gcc编译器很好的支持C语言的语法 

 举个例子:

#include <stdio.h>
int main()
{
	printf("file:%s line:%d\n", __FILE__, __LINE__);
	return 0;
}

 如图的代码打印出来了我们正在编写的文件的位置以及打印语句(printf语句)所在的位置。

 2.#define

①.#define 定义标识符

语法:
 #define name stuff
#define MAX 1000
#define reg register          //为 register这个关键字,创建一个简短的名字
#define do_forever for(;;)     //用更形象的符号来替换一种实现
#define CASE break;case        //在写case语句的时候自动把 break写上。
// 如果定义的 stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续行符)。
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
                          date:%s\ttime:%s\n" ,\
                     __FILE__,__LINE__ ,       \
                     __DATE__,__TIME__ )  

那么我们看到 #define 在定义标识符的时候在末尾并没有加上(;)我们知道在C语言中在每一条语句的结尾都要加上(;),所以我们思考一下当#define定义标识符的时候要不要也在结尾加上(;)

答案是:建议不要加上容易导致程序出错!

原因如下:

比如:

#define MAX 1000;
#define MAX 1000

我们看到这两个#define定义的标识符常量一个加了;另外一个没有加。这里的一个常量把1000;定义为了MAX,它把1000和;看做了一个整体,所以第一个MAX在预处理阶段被替换的时候,是把1000;一同替换进去,第二个MAX就是我们常见的定义方法不加;这时候MAX在预处理阶段替换为1000,我们看这样两种的定义方法会引发怎样的程序错误呢?

例:

#define MAX 1000
int main()
{
	int max = 0;
	if (1)
		max = MAX;
	else
		max = 0;
}

我们看到当不加;的时候这时候的max顺利的被赋值为1000

同样这样一段代码在加上;的时候就会导致程序出错

#define MAX 1000;
int main()
{
	int max = 0;
	if (1)
		max = MAX;
	else
		max = 0;
}

 我们看到这里程序要else的位置报错了,具体信息是由于在else前缺少语句,这是为什么呢?

原因:


②. #define 定义宏 

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

下面是宏的申明方式:

#define name( parament-list ) stuff

其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中。

注意: 参数列表的左括号必须与name紧邻。

如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。

这里在DOUBLE的后面加上了空格在下面使用的时候就会出错

 正确的编写方式:(列表的左括号必须与name紧邻)

宏的使用:

例:首先定义一个宏

#define SQUARE( x ) x * x

这个宏接收一个参数 x . 在主函数中调用宏,并且传一个参数

SQUARE( 5 );

置于程序中,预处理器就会用下面这个表达式替换上面的表达式:

5 * 5

警告:

这个宏存在一个问题:

观察下面的代码段:

#include <stdio.h>
#define SQUARE(x) x*x
int main()
{
	int a = 5;
	printf("%d\n", SQUARE(a + 1));
}

我们看到括号里面的参数是 a+1 我们定义的 a 是数值 5 ,所以 5+1 后数值为 6 ,替换后应该是6*6,所以程序运行出来的结果应该是 36,但我们看下实际的运行结果:

我们看到实际的结果为 11 为什么会是 11 呢?

这就需要我们知道宏在进行计算的时候是先进行替换后进行运算的

 程序运算不出来我们需要的结果,应该怎么进行对它改进呢?

答案:在宏定义上加上两个括号,这个问题便轻松的解决了

 改进的代码:

#include <stdio.h>
#define SQUARE(x) ((x)*(x))
int main()
{
	int a = 5;
	printf("%d\n", SQUARE(a + 1));
}

 

我们知道上图代码的错误后我们再看一个代码:

这里还有一个宏定义: 

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

定义中我们使用了括号,想避免之前的问题,但是这个宏可能会出现新的错误。

int a = 5;
printf("%d\n" ,10 * DOUBLE(a));

warning:

看上去,好像打印100,但事实上打印的是55.

我们发现替换之后:

printf ("%d\n",10 * (5) + (5));

乘法运算先于宏定义的加法,所以出现了如图的程序运算结果 55

这个问题,的解决办法是在宏定义表达式两边加上一对括号就可以了。 

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

提示:

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

③.#define 替换规则

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

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

2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值替换。

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

注意:

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

宏里嵌套宏的定义:

#include <stdio.h>
#define DOUBLE(x) (x)*(x)
#define ADD(x) x+x//10
int main()
{
	int a = 5;
	printf("%d\n", DOUBLE(ADD(a)));
}

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

#define pow(x) x*x
#include <stdio.h>
int main()
{
	printf("pow:%d", pow(5));
	return 0;
}

 这里在双引号里的 pow 没有被替换,所以字符串常量的内容并不被搜索。

④.#和##

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

首先我们看看这样的代码:

char* p = "hello ""bit\n";
printf("hello"," bit\n");
printf("%s", p);

这里输出的是不是:

hello bit ?

答案是确定的:是。

我们发现字符串是有自动连接的特点的。

1. 那我们是不是可以写这样的代码?:

#define PRINT(FORMAT, VALUE)\
 printf("the value is "FORMAT"\n", VALUE);
...
PRINT("%d", 10);

这里只有当字符串作为宏参数的时候才可以把字符串放在字符串中。

1. 另外一个技巧是: 使用 # ,把一个宏参数变成对应的字符串。

比如:

int i = 10;
#define PRINT(FORMAT, VALUE)\
 printf("the value of " #VALUE "is "FORMAT "\n", VALUE);
...
PRINT("%d", i+3);//产生了什么效果?

代码中的 #VALUE 会预处理器处理为:

"VALUE" .

最终的输出的结果应该是:

the value of i+3 is 13

实例:

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

我们看到这里“#n”是可以把a替换成一个字符串“a”,并把转换后的字符串插入到 “the value of……”

字符串中


## 的作用

##可以把位于它两边的符号合成一个符号。

它允许宏定义从分离的文本片段创建标识符。

比如:

#include <stdio.h>
#define CAT(X,Y) X##Y
int main()
{
	int class103 = 100;
	printf("%d", CAT(class, 103));
	return 0;
}

我们看到这里打印的是 100 这里的##会把两端的符号合并成一个符号

注:

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

⑤.带副作用的宏参数

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

例如:

x+1;//不带副作用

x++;//带有副作用

x的自增会对x本身的数值造成改变,可能会影响我们最终的运行结果

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

#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
...
x = 5;
y = 8;
z = MAX(x++, y++);
printf("x=%d y=%d z=%d\n", x, y, z);//输出的结果是什么?

我们看这样一个代码,最后输出x,y,z的数值会是多少呢?

最终的运行结果:

代码分析:

 

⑥.宏和函数对比

 宏通常被应用于执行简单的运算。比如在两个数中找出较大的一个。

#define MAX(a, b) ((a)>(b)?(a):(b))

 那为什么不用函数来完成这个任务? 原因有二:

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

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

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

1. 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序 的长度。

2. 宏是没法调试的。

3. 宏由于类型无关,也就不够严谨。

4. 宏可能会带来运算符优先级的问题,导致程容易出现错。

宏有时候可以做函数做不到的事情。

比如:宏的参数可以出现类型,但是函数做不到。这里用宏的去实现了动态内存分配

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

命名约定:

一般来讲函数的宏的使用语法很相似。

所以语言本身没法帮我们区分二者。 那我们平时的一个习惯是:

把宏名全部大写

函数名不要全部大写

⑦.#undef

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

#undef NAME //如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。

我们看到这里的 #undef 的使用是把定义好的MAX移除,我们看到首句的MAX成功赋值给了ret,然后当我们除去MAX后再次对ch的赋值,显然这里程序已经存在问题

这里显示的问题没有定义MAX,我们就可以了解到这里#undef的作用了

⑧.命令行定义 

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

#include <stdio.h>
int main()
{
    int array [SZ];
    int i = 0;
    for(i = 0; i< SZ; i ++)
   {
        array[i] = i;
   }
    for(i = 0; i< SZ; i ++)
   {
        printf("%d " ,array[i]);
   }
    printf("\n" );
    return 0;
}

由于VS2019无法实现命令行的定义所以这里采用Linux去演示实现

我们先将代码进行编译,会发现:

因为代码中没有定义SZ变量 

 我们在命令行中去对SZ进行定义,这就是我们命令行的定义

⑨.条件编译

在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。

比如说:

调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。

#include <stdio.h>
#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 //__DEBUG__
	}
	return 0;
}

这个代码是判断宏有没有被成功定义如果存在这样的宏就打印出来数组的数值。

常见的条件编译指令:

1.
#if 常量表达式
 //...
#endif
//常量表达式由预处理器求值。
如:
#define __DEBUG__ 1
#if __DEBUG__
 //..
#endif
2.多个分支的条件编译
#if 常量表达式
 //...
#elif 常量表达式
 //...
#else
 //...
#endif
3.判断是否被定义
#if defined(symbol)
#ifdef symbol
#if !defined(symbol)
#ifndef symbol
4.嵌套指令
#if defined(OS_UNIX)
 #ifdef OPTION1
 unix_version_option1();
 #endif
 #ifdef OPTION2
 unix_version_option2();
 #endif
#elif defined(OS_MSDOS)
 #ifdef OPTION2
 msdos_version_option2();
 #endif
#endif

1.实例代码:

#include <stdio.h>
int main()
{
	int a = 10;
#if 0
	a = 20
#endif
		printf("%d", a);
	return 0;
}

我们看到 a=20 这条语句没有被执行 ,是因为#if后面的常量为假(0)所以不执行a=20的赋值操作,所以我们可以得出结论:根据#if后面的常量数值(真或假)就可以判断出来#if后面的语句实不实现,注意#if后一定要是常量表达式,符号两端的数字不可以是变量

 我们看到如果判断语句中存在变量a的时候#if后面的语句也是不执行的

正确的使用方法:

 

2.实例代码:

#define M 100
#include <stdio.h>
int main()
{
	
#if M==5
	printf("hehe\n");
#elif M==100
	printf("haha\n");
#else 
	printf("heihei\n");
#endif 
	return 0;
}

这里的代码如同我们的多分支语句一样,根据条件的判断打印不同的结果

3.实例代码:

根据的宏的定义是否存在去判断是否对a的数值进行重新的赋值:

当使用#ifndef的时候是看宏有没有定义,如果定义了为假,如果没定义为真,实现出来#ifndef后面的语句

4.代码实例:

⑩.文件包含 

我们已经知道, #include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方 一样。

这种替换的方式很简单: 预处理器先删除这条指令,并用包含文件的内容替换。

这样一个源文件被包含10次,那就实际被编译10次。

注意:包含自己定义的头文件使用“ ”,包含库目录下的头文件使用的是<>

头文件被包含的方式:

本地文件包含:

#include "filename"

查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。

如果找不到就提示编译错误

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

/usr/include

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

C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include

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

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

这样是不是可以说,对于库文件也可以使用 “” 的形式包含?

答案是肯定的,可以。 但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。

⑪、嵌套文件包含

这样的情况:

comm.h和comm.c是公共模块。

test1.h和test1.c使用了公共模块。

test2.h和test2.c使用了公共模块。

test.h和test.c使用了test1模块和test2模块。

这样最终程序中就会出现两份comm.h的内容。这样就造成了文件内容的重复。

实例:

在预编译阶段拷贝了三次头文件,造成程序的冗余。

如何解决这个问题? 

答案:条件编译。

每个头文件的开头写:

方法 1 :

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

第一次如果没有定义头文件,第一段代码执行,定义头文件,第二次再次进入,这时候头文件已经存在,第一句代码不执行,这样保证头文件不重复多次的包含。

方法 2:

#pragma once

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

其他预处理指令

#error
#pragma
#line
...
#pragma pack()在结构体部分介绍。

四、利用宏模拟实现offset函数

1.首先我们先看offsetof函数的基本信息以及用法:

offsetof函数可以实现的功能:

求得结构体成员相对于首地址的偏移量,这里涉及到了结构体内存对齐的知识,不清楚此概念的同学可以参考我的往期博客:一篇博客带你玩转C语言中---结构体内存对齐和位段的相关知识_baiyang2001的博客-CSDN博客

所需头文件:

参数类型及返回值:

第一个参数是结构体名,第二个是想要求得偏移量的结构体成员名,返回值是求得的偏移量,是一个无符号整型数据

实例:

#include <stdio.h>
#include <stddef.h>
struct S
{
	int a;
	char b;
};
int main()
{
	int ret = offsetof(struct S, a);
	printf("%d", ret);
	return 0;
}

这里求出来结构体成员a相当于首地址的偏移量为:0

模拟实现:

#include <stdio.h>
#define MY_OFFSETOF(structName, memberName) (int)&((structName*)0)->memberName
struct S
{
	int a;
	char c;
};
int main()
{
	int ret = MY_OFFSETOF(struct S, c);
	printf("%d", ret);
	return 0;
}

 图解代码:





总结

这是博主对C语言中预处理知识的理解,博主水平有限欢迎大家指出错误,沟通交流,谢谢大家的支持!!!!

这篇关于C语言---程序环境和预处理的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!