C生万物 | 详解程序环境和预处理【展示程序编译+链接全过程】
一、程序的翻译环境和执行环境
在ANSI C的任何一种实现中,存在两个不同的环境
第1种是
翻译环境
,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境
,它用于实际执行代码。
我们先来笼统地讲一下这两个环境,在第二模块再进行细讲
- 首先对于一个【test.c】的源文件来收,我们要将代码执行的结果输出到屏幕上,就需要有一个可执行程序【.exe】
- 在【.c】文件转变为【.exe】文件的这段过程叫做==翻译环境==,翻译环境分为编译和链接两部分,而对于编译来说,又可以进行细分为【预编译】、【编译】和【汇编】三个组成部分;当经过翻译环境之后,就会生成一个
test.exe
的可执行文件
- 此时再到==运行环境==,通过将程序读入内存,调用堆栈【stack】,存储函数的局部变量和返回地址,来计算出程序的运行结果,若是有打印语句就将结果打印在屏幕上💻
二、详解编译+链接
接下去我们来详细说说翻译翻译环境,也就是【编译】+【链接】的部分
1、前言小知识🔍
==上一模块说到过。每个源文件【.c】都会经过编译器处理生成目标文件。多个目标文件又会经过链接器的处理以及链接库链接生成可执行程序==
- 但是一定有同学对这个链接库有所疑问,我们来看一段代码
- 可以看到,对于这个我们写C语言时经常使用的printf(),它就被称为是库函数,包含在
stdio.h
这个头文件中 - 而对于库函数来说是存放在链接库里的。当程序里要使用来自外部的函数时,在链接时就应该把他们所依赖的链接库链接进来
- 若是要讲到【编译】【链接】这两块内容,有一点知识你一定要知道,也就是我们日常编写C/C++代码所使用的IDE——VS2019,它叫做【集成开发环境】,通常是包含了
编辑
、编译
、链接
、调试
这些功能,可能你会认为它就是一个软件,带有这些功能,这样理解其实是不够细致的 - [x] 对于
编辑
功能来说有【编辑器】 - [x] 对于
编译
功能来说有【编译器】,VS2019为cl.exe
- [x] 对于
链接
功能来说有【链接器】,VS2019为link.exe
- [x] 对于
调试
功能来说有【调试器】
==所以可以直接用【cl.exe】和【link.exe】进行编译和链接==
2、翻译环境【important】
接下去我们正式来说说翻译环境中的【编译】和【链接】。在这一小节我,我将使用到Linux下的CentOS7这个操作系统进行讲解会使用到Linux中的编辑器vim和编译器gcc
不在VS演示的原因是VS这个集成开发环境已经封装得足够完善了,需要通过一些调试窗口的调用才能观察到一些底层的细节,所以我打算在Linux的环境下进行讲解。若是没有学习过Linux的同学可以来我的Linux专栏了解一下
2.1 编译
下面两个【.c】文件是我们讲解中需要使用到的
==add.c==
#include <stdio.h>
int Add(int x, int y)
{
return x + y;
}
==test.c==
#include "add.c"
int main(void)
{
int a = 10;
int b = 20;
int c = Add(a, b);
printf("c = %d\n", c);
return 0;
}
- 首先来Linux中看一下这两个文件
- 然后我们可以通过vim来观看一下这两个文件中的内容
- 接下去可以通过gcc编译一下【test.c】这个源文件,就能生成【a.out】的可执行文件
① 预编译【进行宏替换】
- 好,接下去我们来说说预编译阶段,预编译也叫预处理。上面的【a.out】这个可执行文件是链接之后产生的文件,但是我们不想让它这么快到链接阶段,到预编译阶段就可以停下来了,因为在使用gcc进行编译的时候要进行一个改变
- 在gcc编译的时候,后面加上一个
-E
的选项,就可以使文件在编译的过程中预编译完就可以停下来了。后面的-o
选项表示output输出的意思,也就是将预编译结束后代码所呈现的内容放到【test.i】这个文件中去
gcc -E test.c -o test.i
- 然后我们通过vim打开看一下。但是进去之后你会看到一对很奇怪的路径。此时不用害怕,在vim的【命令模式】下我们可以直接按
G
,就可以直接跳到文件的末尾
- 然后就可以看到我们熟悉的
main
函数了
- 此时往上滑就可以看到这个预编译后的文件中有一堆的代码。其实这些代码都是头文件
stdio.h
中的内容,这里的【test.i】只是将这个头文件展开了而已
- 我们可以去
usr/include/stdio.h
这个文件中看看。从下方图中确实可以看到很熟悉的一些东西,如果你晚上滑就可以看到我们在【test.i】中有看到过他们,所以就可以确定了这些确实就是stdio.h
的展开
- 但是预编译就只是将头文件展开吗?当然不是,其实这个阶段还做了其他的事:stuck_out_tongue_winking_eye:
- 现在我在原先【test.c】的文件中新增一些内容,加上一些注释和一个宏定义(后面讲)
1 #include "add.c"
2
3 //下面是一个宏定义
4 #define MAX 100;
5
6 int main(void)
7 {
8 int m = MAX;
9 int a = 10;
10 int b = 20;
11
12 int c = Add(a,b);
13 printf("ret = %d\n",c + m);
14
15 return 0;
16 }
- 然后再对这个文件进行预编译然后打开就可以看到我们在编译之前加的注释就没有了,又可以观察到在main函数中的·
m = MAX
就被替换成了m = 100
,因为我们在前面定义了MAX为100
所以我们可以得出在预编译阶段编译器会执行的事情
- [x] 展开头文件
- [x] 注释的删除
- [x] 宏定义的符号替换
② 编译【生成汇编】
- 接下去我们来看看编译阶段会做什么事情。既然gcc在编译的时候可以在【预编译】之后停下来,那也可以在编译之后停下来,只要在gcc后加一个
-S
即可。这里我们对上面预编译之后产生的【test.i】去进行一个编译
gcc -S test.i
- 在编译之后就可以发现多出了一个【.s】为后缀的文件,我们用vim打开看看
- 可以看到,是一对我们看不懂的东西,但是这相比二进制文本来其实仔细看是可以看出点猫腻,如果你学过《编译原理》这门课程的话其实就完全看得懂,因为很多都是一些基本指令和寄存器,所以就可以看出这是【汇编代码】
- 所以在程序进行==编译==的的时候就会进行如下四步操作。也就是将
C语言的代码
转换为汇编代码
- [x] 语法分析
- [x] 词法分析
- [x] 语义分析
- [x] 符号汇总
上面这些东西你可以不用知道,这些都是在《汇编原理》中进行学习的,比较偏向底层
- 不过对于【符号汇总】这一小块我可以在这里讲一讲。我们在看完汇编这一过程后再来看看:point_down:
③ 汇编【生成机器可识别代码】
- 程序在经过
预编译
、编译
之后,就来到了【汇编】阶段,在这个阶段中结束后就会生成一个【test.o】的目标文件,对于这个目标文件来说我们在VS中进行编译之后也是可以看得到的,它叫做【test.obj】
要如何生成这个【test.o】呢?也是一样,修改gcc的编译模式即可。这次是加上-c
选项哦:smile:
gcc -c test.s
- 然后就生成了这个【test.o】的目标文件
- 我们还是一样去打开看看.。但是可以看到,都是一堆乱码(其实这是二进制代码,看不懂很正常)
- 其实对于之前生成过的【a.out】我们也可以进行一个浏览
- 可以看到,对于【a.out】来说出现的也是一对二进制代码,那我们是都可以对它们做一个联系呢?
- 这个时候就可以讲讲我上面说到过的
符号汇总
了,其实对于【test.o】和【a.out】这两个文件来说都属于可执行文件,而对于可执行文件来说都是由二进制代码组成的,因为编译器对我们上面编译
时产生的汇编代码进行了一个符号汇总,那对于汇编代码来说我们已经是有点心生忌惮了,那将它们进行汇总之后其实就变成了上面这个模样╮(╯▽╰)╭ - 但是你看不懂不一定代表计算机看不懂,不要忘了计算机能够识别就是【二进制底代码】,所以在Linux中我们有特定的一个软件可以查看它,叫做==readelf==,即阅读文件格式为elf的,所以可以看出这两个文件的格式其实为elf,这种文件格式会将会将文件中的内容分成一个个的段,这么一段一段的组成其实就变成了一张【表】的形式,这就是所谓的
符号表
,对符号进行汇总也就会形成一个表格的样子 - 那现在我们就可以使用==readelf==来读取解析一下这个二进制文件了
- 可以看到,对于这个软件来说和gcc一样,也是需要带一些命令选项的,其实在Linux中绝大多数的命令都是有着很多的命令选项的,带上不同的命令选项就可以呈现出不同的效果,如果想了解的可以看看我的这篇文章——>Linux常见指令汇总
- 接着仔细观察就可以发现里面有一个选项为
-s
,后面对这个选项的描述是Display the symbol table 显示符号表
,因此果断选择它
readelf -s test.o
最后我们就可以得出在汇编阶段编译器会完成的工作
- [x] 将汇编指令转换为二进制指令(需要特定的文本阅读器)
- [x] 形成符号表(没错,就这个功能)
2.2 链接【生成可执行文件或库文件】
终于是到链接链接阶段了,结束了上面的编译阶段后,我们再来看看链接阶段会做些什么
- 在这一块,我们要将上面的代码做一个修改,现在要在加一个【.h】的头文件,将【add】函数进行一个分割
==add.h==
#pragma once
#include <stdio.h>
//以下是一个宏定义
#define MAX 100
int Add(int x, int y);
==add.c==
int Add(int x, int y)
{
return x + y;
}
==test.c==
#include "add.h"
int main(void)
{
int a = 10;
int b = 20;
int c = Add(a, b);
printf("c = %d\n", c);
return 0;
}
- 在上面我们都是对一个源文件进行编译,因为在【test.c】中我包含了【add.c】的文件,但是现在相当于是Add()这个函数已经独立出去了,它有自己的专属【.h】头文件,因此我们在使用gcc进行编译的时候要带上两个源文件,因为我们在使用gcc进行编译的时候,需要告诉它我们的代码都写在哪里了
gcc add.c test.c
- 若是写成下面这样,gcc便识别不出Add()函数!!!
gcc test.c
- 来看一下运行结果,产生的a.out。接着执行一下这个可执行文件,就是我们最终的结果了
- 当然,除了对【add.c】和【test.c】进行编译外,我们也可以对【add.o】和【test.o】这两个目标文件进行编译。一样对这两个文件进行上一模块的【编译】工作
- 通过如上的一步步设置操作,就可以看到我们执行【a.out】文件和【my_out】文件的输出结果都是一样的,均为
130
,因为他们都是经过链接之后的可执行文件
-
看完了这些,相信你一定也想知道在链接阶段gc编译器对两个目标文件做了什么
-
上面这些其实就是在进行一个
合并段表
的操作,并且将两个目标文件中的符号表进行一个重定位的操作,假设【add.o】这个目标文件中的Add函数名的地址为0x100
,【test.o】的目标文件中从【add.h】中获取到Add的函数名为0x000
,然后还有main函数的函数名的地址为0x200
-
因为两个目标文件中的有重复的函数名Add,所以会进行一个==符号表的重定位操作==,取那个有效的地址
0x100
,当所有段表都合并完后便形成了一个【可执行文件】。
—— 这就是完整的编译 + 链接过程💻
3、运行环境
接着我们来聊聊程序的运行环境,这一块的话因为内容过于复杂,有太多底层的细节,因此不在这里讲解
程序执行的过程:running:
- 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序
的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。 - 程序的执行便开始。接着便调用main函数。
- 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回
地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程
一直保留他们的值。可以看看我的这篇文章——> 函数栈帧的建立和销毁全过程 - 终止程序。正常终止main函数;也有可能是意外终止
==说了这么多,我们来梳理一下==
三、预处理详解
1、预定义符号
在C语言中,有一些预定义的符号,当我们需要查询当前文件的相关信息时,就可以使用这个预定义符号
__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
- 我们可以到VS中来看看
int main(void)
{
printf("%s\n", __FILE__); //进行编译的源文件
printf("%d\n", __LINE__); //文件当前的行号
printf("%s\n", __DATE__); //被编译的日期
printf("%s\n", __TIME__); //被编译的时间
//printf("%s\n", __STDC__); //因VS2019没有遵循ANSI C --> 报错
system("pause");
return 0;
}
- 另外的STDC我们可以到Linux中来瞧瞧
- 这里就可以看出对于Linux来说是遵循ANSI C,所以打印出来的值就为1
2、#define【⭐】
讲预处理,那#define肯定要将,这个相信大家都用到过
2.1 #define定义标识符
- 首先来说说#define如何去定义标识符
语法:
#define name stuff
- 首先这个应该是大家最熟悉的,那就是定义一个MAX,其值为1000
#define MAX 1000
- 这个是为 register这个关键字,创建一个简短的名字、
#define reg register
- 下面这个可能你就没见过了,这里是定义一个死循环。也就是我们在写代码的时候,直接写
do_forever;
那就表示此为一个死循环
#define do_forever for(;;)
- 下面这个可谓是大家的福音,我们在写switch语句时候,都要写case子句,但是老会忘了写
break;
,从而造成了一个case穿透的效果。所以下面这个标识符的定义就使我们在写case子句的时候,自动就可以把break
语句加上,此时就方便了许多
#define CASE break;case
- 当然,如果我们要替换的内容过长,也是可以的,比如说写个printf语句时,若是一行写不下了,可以在每行后面都假一个反斜杠【\】
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
date:%s\ttime:%s\n" ,\
__FILE__,__LINE__ , \
__DATE__,__TIME__ )
注意:在#define定义标识符的时候,后面不要加;
- 举个很简单的例子,若是下面的1000后面加上了
;
,那么在程序使用的时候就会出现错误
#define MAX 1000;
#define MAX 1000
- 比如我们来看看下面的场景
- 可以看到,对于加还是没有分号【;】的情况,还是很明显的,加了分号就会报错
- 为什么呢?因为对于分号【;】而言,表示一条语句的结束,此时在预编译结束后,MAX就会被替换成了1000;那此时再加上MAX后面的【;】,此时就会出现两个分号,那就是两条语句,但是在这个if语句中我们只是将其当做一句话来执行,所以没有加大括号
{}
,所以这才产生了报错 - 我们可以到Linux中来详细看看。可以看到,确实是被替换成了
1000;;
2.2 #define定义宏
#define除了定义标识符之外,还可以定义【宏】,它和函数很类似,也就是将==参数替换到文本中==
下面是宏的申明方式:
#define name( parament-list ) stuff
//其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中
注:① 参数列表的左括号必须与name紧邻,如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
- 然后我们来看看具体的【宏】该如何去定义
/*宏*/
#define SQUARE(x) x * x
/*函数*/
int GetSquare(int x)
{
return x * x;
}
int main(void)
{
int x = 5;
//int ret = GetSquare(x);
int ret = SQUARE(x);
printf("ret = %d\n", ret);
return 0;
}
- 可以看到,对于求解一个数的平方,我们若是使用函数去完成的话就是将需要求解的数字作为参数传入进入,然后在做一个返回值接受即可;
- 但是对于宏定义而言,我们不是这么去做的,这么我们不需要指定返回值,不过函数名称还是需要的, 对于形参中变量也无需定义类型,直接
SQUARE(x)
即可,而后面你只要记住如何去运算就可以了吗,也就相当于我们的函数体。在【预处理】结束之后,就会进行一个宏的替换
- 可以看到确实是进行了宏替换,最后算出来的结果和函数算出来的结果也是一样的,均为25
- 但是这么去写宏定义其实是不对的,因为会存在一个==问题==
若是我在传值的时候这么写呢int ret = SQUARE(5 + 1);
此时在进行宏替换的时候就会替换成这样int ret = 5 + 1 * 5 + 1;
此时中间的1 * 5就会先进行一个运算,然后再和两边去进行一个相加,最后算出来的结果便是【11】,而不是我们想要的【36】
- 这其实是运算的优先级问题,所以我们在定义宏时面对这样的优先级问题应该对需要运算的内容外面加上括号
#define SQUARE(x) (x) * (x) //加上括号避免运算优先级
- 我们来看看加上括号后的结果。可以看到确实就变成了我们想要的【36】
- 但是别高兴得太早,对于这种加括号的行为可不能一劳永逸,我们再来看看下面这个宏定义
#define DOUBLE(x) (x) + (x) //计算一个数的两倍
- 此时我这样去传参的时候就会出现问题
int ret = 10 * DOUBLE((5)); //10 * (5 + 5) = 100
- 可以看到运行结果算出来并不是我们想要的100,而是在进行了宏替换之后运算出来为55
- 我们通过查看一下【预编译】后的
test.i
文件来看看是如何进行宏替换的
- 很明显可以看到,在进行【预编译】后进行了宏替换,但是前面的10却和5先进行了一个相乘,算出来50后加上一个5,此时就可以知道为什么算出来的值为【55】了
#define DOUBLE(x) ((x) + (x)) //在外面再加一个括号即可
2.3 #define替换规则
在上面说完了#define去定义【标识符】和【宏】,我们来总结一下#define的定义规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先
被替换。
-
替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
-
最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程
==注意:==
-
宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
-
当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
2.4 # 和 双#
讲到#define,正好我再来补充一点很奇怪的小知识,也就是# 和## 这两个用来辅助字符串进行连接的
📚【#】 :把参数插入到字符串中
📚【##】 :把两个字符串拼接在一起
- 首先我们来看看最简单的字符串拼接操作。可以看到下面两种操作都可以打印出
hello world
- 所以我们就发现对于字符串而言是具有自动拼接的功能。接下去我们进入正题
8 int a = 10;
9 printf("the value of a is %d\n", a);
10
11 char b = 'x';
12 printf("the value of b is %c\n", b);
13
14 float c = 3.14f;
15 printf("the value of c is %f\n", c);
- 对于上面这段代码,我们可以分别打印出下面的三条语句,对于a, b, c三个变量分别有不同的数据类型
- 现在我们要将其转换为【宏】来进行操作,该怎么做呢?对该如何进行传参呢?
#define PRINT(value, format) printf("the value is " format "\n", value);
PRINT(a, "%d");
- 可以看到,通过字符串的拼接,我们实现了格式化打印的效果
- 但是可以看到少了点什么?是的,中间的【of a】不见了,但是我们通过宏传参的值是一个整型,无法和字符串进行一个拼接,那此时我们就要使用到【#】,将==参数插入到字符串中==
#define PRINT(value, format) printf("the value of "#value" is " format "\n", value);
- 可以看到,原先的数值a,确实变成了字符串的形式与两边的
"the value of "
和" is "
进行了一个拼接,达到了我们需要的效果
- 接下去我们来说说【##】的用法,它可以直接将把两个字符串拼接在一起
#define CAT(A, B) A##B
int CentOS = 7;
printf("%d\n", CAT(Cent, OS));
- 可以看到,我们使用##将
Cent
和OS
拼接在了一起,而且我定义了CentOS = 7
,因此打印出来便是【7】
2.5 带副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能
出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果
- 上面我们学习了如何使用#define去定义宏,在学习的过程中你应该也能感受到,虽然宏比函数来的方便许多,但是在使用的时候却有很多要注意的小细节,就像加括号的问题,若是疏忽的话就会导致最终的结果出现问题
- 我们之前在学习变量递增的时候有说到过
a++
和++a
的区别,一个是后置++,另一个则是前置++,而它们与a+1
又有所不同:point_down:
x+1;//不带副作用
x++;//带有副作用
- 我们通过具体的案例来看看。可以观察到,对于
a + 1
执行完后,a自身的值不会发生变化;但是在++c
执行完后,c的值却发生了变化。
- 不仅如此,对于
char ch = getchar()
来说,会从缓冲区读取一个字符给到ch,但此时缓冲区也会少了一个字符,这就造成了问题;对于fgetc()
这样的文件操作来说在获取文件中一个字符后,文件指针就会往后偏移一位,此时文件指针的变化就会导致我们下一次读取的时候位置就会进行概念
所以我们再来说说这种代码对于宏的危害❗
3 #define MAX(a, b) ((a) > (b) ? (a) : (b))
4 int main(void)
5 {
6
7 int a = 3;
8 int b = 4;
9 int max = 0;
10
11 max = MAX(++a, ++b);
12 printf("max = %d, a = %d, b = %d\n", max, a, b);
- 你可以算出上面这段代码的执行结果是多少吗?【5 4 5】【5 5 4】【6 5 4】【6 4 6】到底是哪个呢?
- 我们可以进入【预编译】阶段看看宏定义是如何替换的
- 可以看出,本身就会造成结果变化的前置++,若是在放入宏中,就会造成更多不变的因素。所以我们平时在使用宏的时候一定要小心谨慎
2.6 宏和函数的对比
在学习了【宏】之后,你一定会疑惑我们该何时去使用
宏
,又该何时去使用函数呢?我们再来对比一下它与函数之间的区别
- 可以看到,对于下面这段代码,我们去求解一个数的最大值使用了【函数】和【宏】两种形式,可以看到对于宏来说要写很多的括号,函数看起来更加清晰美观一些,那为什么还要去使用函数呢?而要去使用【宏】
1 #include <stdio.h>
2
3 #define MAX(a, b) ((a) > (b) ? (a) : (b))
4
5 int Max(int x, int y)
6 {
7 return (x > y ? x :y);
8 }
9
10 int main(void)
11 {
12 int a = 10;
13 int b = 20;
14
15 int max1 = MAX(a, b);
16 printf("宏求解的最大值为:%d\n", max1);
17
18 int max2 = Max(a, b);
19 printf("函数求解的最大值为:%d\n", max2);
20 return 0;
21 }
原因主要有以下三点Ⅲ
-
宏比函数在程序的规模和速度方面更胜一筹。
函数需要调用、计算、然后再返回,宏只需要进行计算即可
- 我们可以通过【反汇编】来看看其实就很明显可以看出【宏】在计算的时候确实比函数要来的快多了
-
更为重要的是函数的参数必须声明为特定的类型。宏则与类型无关的。
函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以用于>来比较的类型。
- 可以看到当我们要修改宏的参数时,写字符型也可以可以的,甚至是一个表达式;但是对于函数来讲,就已经定死了,若是要进行一个修改,那么需要调用的函数形参类型也必须进行一个修改
- 宏有时候可以做函数做不到的事情
- 我们在学习动态内存开辟的时候知道了如何使用
malloc()
去动态申请内存。 - 在申请整型数组的时候就要使用
sizeof(int)
;在使用申请字符型数组的时候就要使用sizeof(char)
;在使用申请浮点型数组的时候就要使用sizeof(float)
;每次都要重新去写一下,其实是降低了开发效率。于是就有同学想到使用函数去进行一个封装,这样就可以做到只是修改一下就好了,可是呢又想到了函数无法传类型,于是又束手无策了。但是呢,此时我们的【宏】就可以实现这一块逻辑
- 这么看下来宏好像真的蛮好的,但是在日常的开发中,大家为什么还是会使用函数呢?因为宏也具有它的缺陷╮(╯▽╰)╭
宏的缺点
-
每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。【宏可以使用反斜杠换到下一行继续写,可以像函数一样写很多】
-
宏是没法调试的。【这点是致命的】
-
宏由于类型无关,也就不够严谨。 【任何类型都可以传入】
-
宏可能会带来运算符优先级的问题,导致程容易出现错。【加括号太麻烦了!!!】
讲完了函数和宏之后,感觉有点散乱,我们通过表格来对比一下
- 通过这张表格,相信你对
宏
和函数
一定有了自己的一个理解
2.7 命名规则
接下去我们来讲讲对于【宏】和【函数】的一些命名规则。因为对于函数和宏虽然存在很多的差别,但是呢在整体上还来还是比较类似,在开发的过程中也可能会存在混淆。所以我们在对它们进行命名的时候应该做一个规定
- [x] 把宏名全部大写
- [x] 函数名不要全部大写
3、#undef
功能:移除一个宏定义
语法:
#define NAME
//... 代码 —— 可以使用NAME
#undef NAME
//... 代码 —— 无法使用NAME
- 来看看具体例子
4、命令行定义
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。
例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性有点用处。(假设一个地方需要一个正常大小的数组,我们给正常的即可,但是另一个地方却需要很大的空间,此时就不够用了)
==我们来具体的案例中看看==
1 #include <stdio.h>
2
3 int main(void)
4 {
E> 5 int a[sz];
6 int i = 0;
E> 7 for(i = 0; i < sz; ++i)
8 {
9 a[i] = i;
10 }
11
E> 12 for(i = 0; i < sz; ++i)
13 {
14 printf("%d ", a[i]);
15 }
16 printf("\n");
17
18 return 0;
19 }
- 对于上面这段代码,很明显的错误可以看到程序发现了我没有定义这个sz,学习了宏定义后相信你应该知道该如何去作了。但是呢经过上面我讲到的情景,若是我们定义一个数组的大小,在这个程序中已经声明好了,那可能放到一些数据量大的地方就跑不过了(不考虑动态开辟)
- 所以此时就可以使用到【命令行定义】这个东西了,也就是这个sz我不在这里定义,而是放在命令行进行编译的时候去定义,命令如下所示
gcc -D sz=10 源文件 //这里注意不能写成sz = 10,不能加空格
- 然后我们就可以在编译的时候为
sz
定义不同的值了,使程序变得很有弹性
5、条件编译【✔】
接下去我们来聊聊条件编译,对于这一块虽然我们平时不怎么用,但是在实际的开发中用得还是比较多的,因此需要有一些了解
- 日常我们在编写程序的时候,都会写一些调试类的代码去检查自己的代码是否正确,在检查完后当这份代码用不到时你就会觉得 ——> 删除可惜,保留又碍事
于是就有了我们现在所讲的条件编译,一起来看看
#if 常量表达式
//...
#endif
//常量表达式由预处理器求值
//多个分支的条件编译
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
//判断是否被定义
#if defined(symbol)
#ifdef symbol
#if !defined(symbol)
#ifndef symbol
#ifdef
和#ifndef
也是同理。其实你用#define MAX
也是一样的,也算作定义了宏,不一定要给他赋值
//嵌套指令
#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
- 这里因为嵌套的种类太多,因此展示两个
看完了上面这四种条件编译的形式,相信你对此应该有了一定的了解。其实我们在库中的一些源码里,也可以看到他们的身影
6、文件包含
我们已经知道, #include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样
这种替换的方式很简单:
- 预处理器先删除这条指令,并用包含文件的内容替换。这样一个源文件被包含10次,那就实际被编译10次
6.1 头文件被包含的方式
==本地文件包含==
#include "filename"
- 【查找策略】:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果还是找不到,就提示编译错误 —— 简单来说,会查找两次
- 这种包含一般都是我们自己写的头文件
Linux环境的标准头文件的路径:
/usr/include
VS环境的标准头文件的路径:
C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include
//这是VS2013的默认路径
注意按照自己的安装路径去找
==库文件包含==
#include <filename.h>
- 这种头文件的我们用的应该是最多的,例如
stdio.h
和stdlib.h
等等这种标准库中的头文件
那这个时候一定就会有同学疑问说:既然第一种方式会查找两次,对于库文件也可以使用 “” 的形式包含?
👉答案是:可以,但是没必要,这样做查找的效率就低了一些,对于任何头文件都要去查找两次,而且也不容易区分是库文件还是本地文件了
【总结一下】:
:dart:用 #include <filename.h>
格式来引用标准库的头文件(编译器将从标准库目录开始搜索,只查找一次)
:dart:用 #include “filename.h”
格式来引用非标准库的头文件(编译器将从用户的工作目录开始搜索,会查找两次)
6.2 嵌套文件包含
接下来说说对于嵌套文件的包含
- 在我们进行开发的时候,那代码都是上万行的,很这许多的
.h
和.c
文件,所以有很多.h
的头文件就可能会被大家重复包含,就像是下面这种情况
-
comm.h和comm.c是公共模块。
-
test1.h和test1.c使用了公共模块。
-
test2.h和test2.c使用了公共模块。
-
test.h和test.c使用了test1模块和test2模块。
这样最终程序中就会出现两份comm.h的内容。这样就造成了文件内容的重复
- 那我们要如何去解决这个问题呢? 答:使用上面所学的【条件编译】
// test13.c
1 #include "add.h"
2 #include "add.h"
3 #include "add.h"
4 #include "add.h"
5 #include "add.h"
6
7 #include <stdio.h> 8 int main(void)
9 {
10 printf("haha\n");
11 return 0;
12 }
- 在于上面的
test13.c
文件中可以看到我包含了五次add.h
这个头文件。但是我在头文件中用到了条件编译,只要这个【TEST_H】被#define定义过了之后,那这个头文件就不会再被包含了
//add.h
1#ifdef __TEST_H__
2 #define __TEST_H_
3 ////////////////// 4 int Add(int x, int y);
5 //////////////////
6 #endif
- 我们可以来看一下【预处理】后的结果
- 或者我们还有另外一种方式可以使头文件被重复包含
#pragma once //用得较多
四、其他预处理指令
不做介绍,自己去了解一下即可。
①error
②pragma
③line
五、总结与提炼
来总结一下本文所学习的知识
- 首先我们了解了要生成一个程序需要经过【翻译环境】和【执行环境】,重点讲解了一下翻译环境的整体过程,分为
预编译
、编译
、汇编
和链接
四部分,在每个小模块中,我们都做了深入的了解和剖析,知道了在每个环节会做什么,会发生什么,会为下一个模块预备设么 - 然后就是进入我们【预处理】的学习,首先说到预定义符号,接着就是#define的各种展开,这一模块要重点掌握,尤其是对于宏和函数的区别,还记得我列了一张表格嘛:smile:;接着我们又说到了#unde和命令行定义,这两个做一个了解即可。然后就是开发过程中被大量使用的
条件编译
,这一模块也是要重点掌握。最后又讲了讲头文件的包含方式、如何防止头文件被重复包含
以上就是本文所要讲述的所有内容,感谢您的阅读,如果疑问请于评论区留言或者私信我:four_leaf_clover:
- 点赞
- 收藏
- 关注作者
评论(0)