了解程序的运行环境可以让我们更加清楚的程序的底层运行的每一个步骤和过程,做到心中有数,预处理阶段是在预编译阶段完成,掌握常用的预处理命令语法,可以让我们正确的使用预处理命令,从而提高代码的开发能力和阅读别人代码的能力,本篇博客详细总结C语言中的程序环境和预处理,达到理解并运用的目的!
一、程序的翻译环境和执行环境
在ANSI C的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境,它用于实际执行代码。
二、详细介绍编译+链接
2.1 翻译环境
- 组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。
- 每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
- 链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人 的程序库,将其需要的函数也链接到程序中。
2.2 编译本身划分的阶段
看代码: sum.c文件
int g_val = 2016;
void print(const char *str)
{
printf("%s\n", str);
}
test.c文件
#include <stdio.h>
extern void print(char *str);
extern int g_val;
int main()
{
printf("%d\n", g_val);
print("hello bit.\n");
return 0;
}
VIM学习资料:
- 简明VIM练级攻略: https://coolshell.cn/articles/5426.html
- 给程序员的VIM速查卡 https://coolshell.cn/articles/5479.html
2.3 运行环境
程序执行的过程:
- 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序 的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
- 程序的执行便开始。接着便调用main函数。
- 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回 地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程 一直保留他们的值。
- 终止程序。正常终止main函数;也有可能是意外终止。
三、预处理详细介绍
3.1 预定义符号
顾名思义,预定义宏就是已经预先定义好的宏,我们可以直接使用,无需再重新定义。
举例
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("Date : %s\n", __DATE__);
printf("Time : %s\n", __TIME__);
printf("File : %s\n", __FILE__);
printf("Line : %d\n", __LINE__);
system("pause");
return 0;
}
3.2 #define
#define 叫做宏定义命令,它也是C语言预处理命令的一种。所谓宏定义,就是用一个标识符来表示一个字符串,如果在后面的代码中出现了该标识符,那么就全部替换成指定的字符串。
这里所说的字符串是一般意义上的字符序列,不要和C语言中的字符串等同,它不需要双引号。
3.2.1 #define 定义标识
语法格式:#define 宏名 字符串
解释:#
表示这是一条预处理命令,所有的预处理命令都以 # 开头。宏名
是标识符的一种,命名规则和变量相同。注意两点:
1.字符串
可以是数字、表达式、if 语句、函数等。2.宏定义不是说明或语句,在行末不必加分号,如加上分号则连分号也一起替换。建议不要加分号。
#include <stdio.h>
#define N 100
int main()
{
int sum = 20 + N;
printf("%d\n", sum);
return 0;
}
注意第 6 行代码int sum = 20 + N
,N
被100
代替了。#define N 100
就是宏定义,N
为宏名,100
是宏的内容(宏所表示的字符串)。在预处理阶段,对程序中所有出现的“宏名”,预处理器都会用宏定义中的字符串去代换,这称为“宏替换”或“宏展开”。宏定义是由源程序中的宏定义命令#define
完成的,宏替换是由预处理程序完成的。
3.2.2 #define 定义宏(带参数的宏定义)
程序中反复使用的表达式就可以使用宏定义,#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
下面是宏的申明方式: #define name( parament-list ) stuff
其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中。注意: 参数列表的左括号必须与name紧邻。 如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
如:#define SQUARE( x ) x * x
这个宏接收一个参数 x,如果在上述声明之后,你把SQUARE( 5 );
置于程序中,预处理器就会用下面这个表达式替换上面的表达式:5 * 5
警告!!!上面这个宏存在一个问题!
观察下面的代码段:
int a = 5;
printf("%d\n" ,SQUARE( a + 1) );
乍一看,你可能觉得这段代码将打印36这个值。
事实上,它将打印11.
为什么?
替换文本时,参数x被替换成a + 1,所以这条语句实际上变成了:
printf ("%d\n",a + 1 * a + 1 );
这样就比较清晰了,由替换产生的表达式并没有按照预想的次序进行求值。
在宏定义上加上两个括号,这个问题便轻松的解决了:#define SQUARE(x) (x) * (x)
这样预处理之后就产生了预期的效果:printf ("%d\n",(a + 1) * (a + 1) );
还有另一个宏定义:
#define DOUBLE(x) (x) + (x)
定义中我们使用了括号,想避免之前的问题,但是这个宏可能会出现新的错误。
int a = 5;
printf("%d\n" ,10 * DOUBLE(a));这将打印什么值呢?
看上去,好像打印100,但事实上打印的是55.
我们发现替换之后:printf ("%d\n",10 * (5) + (5));
乘法运算先于宏定义的加法,所以出现了55
这个问题,的解决办法是在宏定义表达式两边加上一对括号就可以了。
#define DOUBLE(x) ( ( x ) + ( x ) )
总结:提示: 所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中 的操作符或邻近操作符之间不可预料的相互作用。
3.2.3 #define 替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
- 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
- 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上 述处理过程。
注意事项:
1. 宏定义是用宏名来表示一个字符串,在宏展开时又以该字符串取代宏名,这只是一种简单粗暴的替换。字符串中可以含任何字符,它可以是常数、表达式、if 语句、函数等,预处理程序对它不作任何检查,如有错误,只能在编译已被宏展开后的源程序时发现。
2. 宏定义必须写在函数之外,其作用域为宏定义命令起到源程序结束。如要终止其作用域可使用
#undef
命令3. 代码中的宏名如果被引号包围,那么预处理程序不对其作宏代替,而作为字符串处理。
4. 宏定义允许嵌套,在宏定义的字符串中可以使用已经定义的宏名,在宏展开时由预处理程序层层代换。
5. 习惯上宏名用大写字母表示,以便于与变量区别。
6. 可用宏定义表示数据类型,使书写方便。但是需要注意用宏定义表示数据类型和用typedef定义数据说明符的区别。宏定义只是简单的字符串替换,由预处理器来处理;而 typedef 是在编译阶段由编译器处理的,它并不是简单的字符串替换,而给原有的数据类型起一个新的名字,将它作为一种新的数据类型。(易出错点!)
#define UINT unsigned int 在程序中可用 UINT 作变量说明: UINT a, b;
请看下面表示整型指针类型的两种方式: #define PIN1 int * typedef int *PIN2; //也可以写作typedef int (*PIN2); 下面用 PIN1,PIN2 说明变量时就可以看出它们的区别: PIN1 a, b; 在宏代换后变成:int * a, b;表示 a 是指向整型的指针变量,而 b 是整型变量。 然而: PIN2 a,b; 表示 a、b 都是指向整型的指针变量。因为 PIN2 是一个新的、完整的数据类型。 由这个例子可见,宏定义虽然也可表示数据类型, 但毕竟只是简单的字符串替换。 在使用时要格外小心,以避出错。
3.2.4 #和##
在宏定义中,有时还会用到
#
和##
两个符号,它们能够对宏参数进行操作。
1. #的用法
#
用来将宏参数转换为字符串,也就是在宏参数的开头和末尾添加引号。#define STR(s) #s 那么: printf("%s", STR(c.biancheng.net)); printf("%s", STR("c.biancheng.net")); 分别被展开为: printf("%s", "c.biancheng.net"); printf("%s", "\"c.biancheng.net\""); 可以发现,即使给宏参数“传递”的数据中包含引号, 使用#仍然会在两头添加新的引号,而原来的引号会被转义。
#include <stdio.h> #define STR(s) #s int main() { printf("%s\n", STR(c.biancheng.net)); printf("%s\n", STR("c.biancheng.net")); return 0; }
运行结果:
c.biancheng.net
"c.biancheng.net"
2. ##用法
##
称为连接符,用来将宏参数或其他的串连接起来。#define CON1(a, b) a##e##b #define CON2(a, b) a##b##00 那么: printf("%f\n", CON1(8.5, 2)); printf("%d\n", CON2(12, 34)); 将被展开为: printf("%f\n", 8.5e2); printf("%d\n", 123400);
#include <stdio.h> #define CON1(a, b) a##e##b #define CON2(a, b) a##b##00 int main() { printf("%f\n", CON1(8.5, 2)); printf("%d\n", CON2(12, 34)); return 0; }
运行结果:
850.000000
123400
3.2.5 带副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能 出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。
3.2.6 宏和函数对比
宏通常被应用于执行简单的运算。比如在两个数中找出较大的一个。
#define MAX(a, b) ((a)>(b)?(a):(b)) 那为什么不用函数来完成这个任务?
原因有二:
1. 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。 所以宏比函数在程序的规模和速度方面更胜一筹。
2. 更为重要的是函数的参数必须声明为特定的类型。 所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以 用于>来比较的类型。 宏是类型无关的。
宏的缺点:当然和函数相比宏也有劣势的地方:
1. 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序 的长度。
2. 宏是没法调试的。
3. 宏由于类型无关,也就不够严谨。
4. 宏可能会带来运算符优先级的问题,导致程容易出现错。
本质上的区别:宏展开仅仅是字符串的替换,不会对表达式进行计算;宏在编译之前就被处理掉了,它没有机会参与编译,也不会占用内存。而函数是一段可以重复使用的代码,会被编译,会给它分配内存,每次调用函数,就是执行这块内存中的代码。
3.2.7 命名约定
一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者。
那我们平时的一个习惯是: 把宏名全部大写 函数名不要全部大写
3.3 #undef
这条指令用于移除一个宏定义。
#undef NAME //如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。
3.4 命令行定义
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。 例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性有点用处。(假定某个 程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器 内存大些,我们需要一个数组能够大些。)
#include <stdio.h>
int main()
{
int array [ARRAY_SIZE];
int i = 0;
for(i = 0; i< ARRAY_SIZE; i ++)
{
array[i] = i;
}
for(i = 0; i< ARRAY_SIZE; i ++)
{
printf("%d " ,array[i]);
}
printf("\n" );
return 0;
}
编译指令:
//linux 环境演示
gcc -D ARRAY_SIZE=10 programe.c
3.5 条件编译
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。 比如说: 调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
这些操作都是在预处理阶段完成的,多余的代码以及所有的宏都不会参与编译,不仅保证了代码的正确性,还减小了编译后文件的体积。这种能够根据不同情况编译不同代码、产生不同目标文件的机制,称为条件编译。条件编译是预处理程序的功能,不是编译器的功能。
常见的条件编译指令:
1. 单分支条件编译 :
#if 整型常量表达式
#endif如进行注释掉:#if 0
#endif
2.多分支条件编译
#if 整型常量表达式1
程序段1
#elif 整型常量表达式2
程序段2
#elif 整型常量表达式3
程序段3
#else
程序段4
#endif它的意思是:如常“表达式1”的值为真(非0),就对“程序段1”进行编译,否则就计算“表达式2”,结果为真的话就对“程序段2”进行编译,为假的话就继续往下匹配,直到遇到值为真的表达式,或者遇到 #else。这一点和 if else 非常类似。
需要注意的是,#if 命令要求判断条件为“整型常量表达式”,也就是说,表达式中不能包含变量,而且结果必须是整数;而 if 后面的表达式没有限制,只要符合语法就行。这是 #if 和 if 的一个重要区别。
3.判断是否被定义
#ifdef 宏名
程序段1
#else
程序段2
#endif也可以省略 #else:
#ifdef 宏名
程序段
#endif它的意思是,如果当前的宏已被定义过,则对“程序段1”进行编译,否则对“程序段2”进行编译。
4.判断是否未被定义
#ifndef 宏名
程序段1
#else
程序段2
#endif与 #ifdef 相比,仅仅是将 #ifdef 改为了 #ifndef。它的意思是,如果当前的宏未被定义,则对“程序段1”进行编译,否则对“程序段2”进行编译,这与 #ifdef 的功能正好相反。
总结:#if、#ifdef、#ifndef的用法区别在哪里?
#if 后面跟的是“整型常量表达式”,而 #ifdef 和 #ifndef 后面跟的只能是一个宏名,不能是其他的。
3.6文件包含
3.6.1头文件被包含的方式
#include
叫做文件包含命令,用来引入对应的头文件(.h
文件)。#include 也是C语言预处理命令的一种。#include 的处理过程很简单,就是将头文件的内容插入到该命令所在的位置,从而把头文件和当前源文件连接成一个源文件,这与复制粘贴的效果相同。这种替换的方式很简单: 预处理器先删除这条指令,并用包含文件的内容替换。 这样一个源文件被包含10次,那就实际被编译10次。头文件分为两种:标准头文件和自己编写的头文件。#include 的用法有两种:
#include <stdHeader.h> #include "myHeader.h"
关于 #include 用法的注意事项:
- 一个 #include 命令只能包含一个头文件,多个头文件需要多个 #include 命令。
- 同一个头文件可以被多次引入,多次引入的效果和一次引入的效果相同,因为头文件在代码层面有防止重复引入的机制。
- 文件包含允许嵌套,也就是说在一个被包含的文件中又可以包含另一个文件。
编程习惯:习惯是使用尖括号来引入标准头文件,使用双引号来引入自定义头文件(自己编写的头文件),这样一眼就能看出头文件的区别。
3.6.2 嵌套文件包含
如果出现这样的场景:
comm.h和comm.c是公共模块。
test1.h和test1.c使用了公共模块。
test2.h和test2.c使用了公共模块。
test.h和test.c使用了test1模块和test2模块。 这样最终程序中就会出现两份comm.h的内容。这样就造成了文件内容的重复。
如何解决这个问题?—条件编译
每个头文件的开头写:
第一种:
#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif //__TEST_H__
第二种:
#pragma once
就可以避免头文件的重复引入。
四、其他预处理指令
以上便是程序环境和预处理全部内容,认真理解消化,一定会有极大的收获,至此,C语言所有理论技术已经全部总结完,认真复习消化练习,一定会取得不错的效果。可以留下你们点赞、关注、评论,您的支持是对我极大的鼓励,下期再见!