目录
🌳前言
🌳正文
🌲1、程序环境
🌱1.1、翻译环境
🪴1.1.1、预编译
🪴1.1.2、编译
🪴1.1.3、汇编
🪴1.1.4、链接
🪴1.1.5、关于操作指令
🌱1.2、运行环境
🪴1.2.1、执行
——————分割线——————
🌲2、预编译
🌱2.1、预定义符号
🌱2.2、#define
🪴2.2.1、定义标识符常量
🪴2.2.2、定义宏
🪴2.2.3、#define 替换规则
🪴2.2.4、# 与 ##
🪴2.2.5、带有副作用的宏参数
🪴2.2.6、宏定义命名约定
🌱2.3、宏与函数的比较
🪴2.3.1、代码长度
🪴2.3.2、执行速度
🪴2.3.3、操作符优先级
🪴2.3.4、带有副作用的参数
🪴2.3.5、参数类型
🪴2.3.6、能否调试
🪴2.3.7、能否递归
🪴2.3.8、结论
🌱2.4、#undef 移除宏定义
🌱2.5、命令行定义
🌱2.6、条件编译
🪴2.6.1、单分支条件编译
🪴2.6.2、多分支条件编译
🪴2.6.3、判断是否定义过宏
🪴2.6.4、嵌套使用条件编译
🌱2.7、文件包含
🪴2.7.1、自定义头文件的包含
🪴2.7.2、库函数头文件的包含
🪴2.7.3、避免多次展开同一头文件
🌳总结
🌳前言
在C/C++中,所有的代码在输出结果前都需要经过这五个阶段:预编译—>编译—>汇编—>链接—>执行代码。其中前四个阶段是在翻译环境下进行,因为在翻译环境中有编译器和链接器这两个重要工具,二者配合能将文本形式的代码转化为对应的二进制代码和可执行文件;而最后一个阶段是在执行环境中进行的,代码在这个阶段已经打包好了,只需要执行器运行此代码,结果就能很好的输出。可以看出,整个代码运行逻辑是极其严谨和巧妙的。除程序环境外,C/C++在预处理阶段还有各式各样的预处理指令等着我们去发掘,一起来看看吧!
本文主要分为两部分:程序环境讲解和预处理指令详解,其中程序环境需要在Linux环境下用gcc编译器展示,光是环境配置就比较麻烦,因此这部分会偏向于理论知识,不需要去实践,理解性记忆就好了;预处理指令在VS上就能展示,这部分知识偏向于实践,篇幅会比较长。
🌳正文
🌲1、程序环境
🌱1.1、翻译环境
翻译环境的主要目标是把 test.c 转化为 test.exe,其中 test.c 需要先经过预编译转为 test.i ,然后再经过编译转为 test.s ,接着经过汇编翻译为 test.o ,最后再由链接器将目标文件 test.o 与其他目标文件、库函数文件等链接后生成可执行程序 test.exe。其中前三步由编译器完成,最后一步由链接器完成(这两个工具已经集成于VS中了),每个不同的源文件都需要分开编译,最后由链接器合并,下图很好的演示了整个翻译过程,当然更详细的在后面。
🪴1.1.1、预编译
预编译阶段会干这些事:
1.包含头文件
2.删除注释
3.替换 #define 定义的符号
干完这些事后会生成一个 .i 文件,此时的文件仍然是C语言形式的文本文件,举个例子(通过其他手段在VS中演示,相关链接:VS 如何查看预处理后的文件?)
下面是源代码
#define _CRT_SECURE_NO_WARNINGS 1 #include<stdio.h> #define MAX 100 //测试预编译阶段 int Add(int x, int y) { return x + y; } int main() { int x = 10; int y = 20; int z = Add(x, y); printf("%d\n", z + MAX); return 0; }
下面是经过预编译后生成的 .i 文件
此时的代码我们还能看懂 ,还是普通C语言代码
🪴1.1.2、编译
编译阶段干的事比较多:
1.语法分析
2.词法分析
3.语义分析
4.符号汇总
经过上述步骤后,可以把文本代码转成成汇编代码,即后缀为 .s 的汇编文件,此时代码我们已经看不懂了,文件格式为 elf,需要用其他工具来解析查看此文件,这里就不展示了。
如果想要深究,推荐《编译原理》这本书
编译阶段需要注意的是符号汇总这个操作,此操作会把各种符号汇总,方便后续符号表的形成。
🪴1.1.3、汇编
汇编阶段:
把已经生成的汇编指令转换成二进制指令
形成符号表
最终生成 .o 目标文件,此时的文件格式仍然为 elf
比如上面的代码,会生成这两个符号表:
🪴1.1.4、链接
在链接阶段,会干这两件事:
1.合并段表
2.将符号表进行合并和重定位
如果在合并符号表后,发现信息不匹配,就会报错,原因为某些函数不存在。链接完成后,会生成一个.exe 可执行文件,最终交给执行器运行代码就行了。
🪴1.1.5、关于操作指令
在 Linux 环境下使用 gcc 编译代码(假设源文件为 test.c ):
1.输入 gcc -E test.c -o test.i 可以把预编译阶段生成的代码放到 test.i 这个文件中
2.输入 gcc -S test.c -o test.s 可以将编译阶段生成的汇编代码放到 test.s 中
3.输入 gcc -c test.c -o test.o 可以把汇编阶段生成的二进制代码放到 test.o 中
关于查看利用 VIM 查看 elf 格式的文件
VIM学习资料
简明VIM练级攻略(酷壳网)
给程序员的VIM速查卡
🌱1.2、运行环境
🪴1.2.1、执行
此时的代码已经变成了一个.exe 可以执行程序 ,在 Windows 下双击也能直接打开
运行环境中需要注意:
1.程序必须载入到内存中
2.找 main 函数后,开始执行程序
3.程序运行时,会调用一个运行堆栈,存储局部变量和返回地址等信息,主函数在堆栈中
4.程序终止后,有两种情况:正常结束和异常终止
5.推荐优质书籍《程序员的自我修养》
——————分割线——————
🌲2、预编译
下面来介绍一下本文的重头戏:各种预编译指令,预编译是一个强大的工具,要学会使用。
🌱2.1、预定义符号
首先介绍下C语言中内置的几个预定义符号
__FILE__ 当前进行编译的源文件
__LINE__ 当前代码所在的行数
__DATE__ 当前代码被编译的日期
__TIME__ 当前代码被编译的时间
__STDC__ 如果遵循ANSI C 标准,为1
printf("%s\n", __FILE__);//打印当前编译源文件信息 printf("%d\n", __LINE__);//打印当前的行数,为24 printf("%s\n", __DATE__);//打印当前的日期,现在是10月25日 printf("%s\n", __TIME__);//打印当前时间,为20:39 //printf("%d\n", __STDC__);//这个用不了,VS中没定义
🌱2.2、#define
#define ADD(x,y) ((x) + (y)) //#define 定义两个数加法宏
在三子棋和扫雷中,还见过 #define 定义标识符常量,有效避免了大小固定的问题
#define ROW 3 #define COL 3 //#define 定义标识符常量
这两个功能是 #define 最基础的功能,除此之外,#define 还能干很多事,一起来看看吧:
🪴2.2.1、定义标识符常量
语法:
#define name stuff
// name 是定义的标识符
// stuff 是待定义的常量
举个栗子:
//#define 定义标识符常量 #define YEAR 2022 #define MONTH 10 #define DAY 15 int main() { printf("今天是%d年%d月%d号\n", YEAR, MONTH, DAY); return 0; }
最终输出结果为:今天是2022年10月15号
错误示例
//#define 定义标识符常量 #define YEAR 2022; //错误示范,在定义后加 ; 号 #define MONTH 10; //除非是特殊需求,否则是不会加 ; 号的 #define DAY 15; //现在代码连编译阶段都过不去 int main() { printf("今天是%d年%d月%d号\n", YEAR, MONTH, DAY); return 0; }
结果: 没有结果,代码编译错误
此时的代码相当于
printf("今天是%d年%d月%d号\n", 2022;, 10;, 15;); //莫名其妙多了几个分号
这代码能运行,那肯定编译器睡着了~
注意事项:
#define 定义标识符常量时,顺序不要写反了,先写标识符,再写常量值
#define 定义标识符常量时,不能在后面加 ; 号,这是非常坑爹的写法!
🪴2.2.2、定义宏
#define 定义符号时,不带参数时是在定义标识符常量,带参数时就是在定义宏(有点像函数),关于宏和函数的比较,后面会专门讲(很详细!)
语法:
#define name( parament-list ) stuff
//name 是宏名
//parament-list 是参数表,可以是单个或多个,多个需要用 , 号隔开
//stuff 是宏功能实现的主体
注意事项:
name 旁边的 ( 必须与 name 紧紧相连,如果有空格,那么(parament-list )stuff 会被解释为一个 struff
来看个问题例子
//#define 定义宏 #define MUL(x,y) x * y //宏定义乘法,有瑕疵 int main() { printf("%d\n", MUL(1 + 2, 3 + 4)); return 0; }
结果: 11
并不是预想中的7,原因很简单,宏定义时,是直接替换的,此时代码是这个样子
printf("%d\n", 1 + 2 * 3 + 4); //直接替换后的样子
如何避免使用宏时出现类似问题呢?
答:勤加括号
此时的宏定义可以优化为:
#define MUL(x,y) ((x) * (y)) //宏定义乘法,完美版
注意事项:
所有用于对数值表达式进行求值的宏定义都应该勤加括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用
🪴2.2.3、#define 替换规则
来简单总结一下 #define 的替换规则
1.当宏在进行替换时,会对其中的参数进行检查,看是否有 #define 定义的符号,如果有的话,先优先替换参数
2.替换文本会被插入到程序中原来文本的位置;对于宏,参数名被他们的值所替换
3.最后,再对结果文件进行扫描,看看是否还有 #define 定义的符号,如果有的话,就重复上述步骤
注意:
1. 宏的参数和 #define 定义中可以出现其他 #define 定义的符号,也就是说 #define 可以嵌套使用,但要合法。对于宏,不能使用递归。
#define ADD(x,y) ((ADD(x, y)), y) //定义宏时,不允许出现递归!
结果: 运行出错,显示第二个 ADD 未定义
2. 当预处理器搜索 #define 定义的符号的时候,字符串常量的内容并不会被搜索。
#define ABC abcdefg //预编译搜索替换时,只会搜索标识符 ABC
🪴2.2.4、# 与 ##
这是两个比较奇葩的预编译指令,在实际中很少用,但是真实存在的。
#
这个东西比较有意思,就是在宏定义中,把某个参数变成对应的字符串,再配合上 " " 号,就能 插入到后面的字符串中,比如下面这个例子,实现了全数据类型的打印
//奇葩预定义指令 # //实现全类型数据打印 #define PRINT(format,value) printf("the value of "#value" is "#format"\n",value) int main() { int i = 10; PRINT(%d, i); char c = 'a'; PRINT(%c, c); float f = 5.5f; PRINT(%.2f, f); return 0; }
结果:the value of i is 10
the value of c is a
the value of f is 5.50
# 这个东西配合上宏定义和字符串插入的特征,完成了一个函数无法实现的任务
tips:对于原字符串 "abc" ,直接在 a 后插入 "123" ,原字符串就变成了 "a123bc"
##
## 能把两个互不相干的符号合成一个符号,然后就能使用这个符号了,但前提是这个符号必须合法(存在且可用),比较奇怪,但也能使用:
//奇葩预处理指令 ## //实现间接加法,有些费解………… #define ADD_TO_SUM(num, value) sum##num += value int main() { int sum1 = 0; ADD_TO_SUM(1, 5); printf("%d\n", sum1); return 0; }
结果: 5
代码经过预处理后,变成了这样:
//ADD_TO_SUM(1, 5); sum1 += 5; printf("%d\n", sum1);
结果肯定是 5,其实这个程序就是在计算 sumn 自加某个数后的和,n 指第一个宏参数
小结
这两个符号一看还挺唬人,但实际也就那样,属于纸老虎类型,理解知道怎么用就行了,因为在实际开发中,是很少有人会这样写代码的,不过当别人写出这样的代码时,我们要能读懂。
🪴2.2.5、带有副作用的宏参数
所谓副作用就是指经过宏运算后,会对宏参数本身造成影响,所造成的效果是难以预料的
//带有副作用的宏参数 #define MAX(x,y) ((x) > (y) ? (x) : (y)) int main() { int x = 1; int y = 2; int z = MAX(x++, y++); //求两数+1后的较大值,有副作用 printf("x = %d y = %d z = %d\n", x, y, z); //x、y的值也发生了改变 return 0; }
结果: x = 2, y = 3, z = 3
仅仅是通过一个宏计算,变量 x、y 的值就发生了改变,如果后续使用这两个变量进行运算时,极有可能会运算错误。为避免出现这种副作用,我们可用将宏传参修改为:
int z = MAX(x + 1, y + 1); //求两数+1后的较大值,无副作用
注意:
在使用传递宏参数时,不要使用自增/自减的方式传递(函数传参时也不推荐),这样会导致不可预料的后果
当然,在设计宏时也不要出现自增/自减,因为这样也是带有副作用的
#define ADD(x, y) ((x++) + (y++)) //计算两数+1后的和,有副作用
🪴2.2.6、宏定义命名约定
宏和函数都能实现简单逻辑运算,为了将两者区分开,有这样的语法规定:定义宏时,宏名要全部大写,定义函数时,函数名不要全部大写
这是一个宏
#define ADD(x, y) ((x) + (y)) //宏,实现两数相加
这是一个函数
//函数,实现两个整型相加 int add(int x, int y) { return x + y; }
可以看出,宏名和函数名是很容易区分的。在实现同一功能时,宏比函数简洁得多,并且宏能适用于所有数据,那么宏与函数究竟有哪些区别?该如何选择呢?下面会告诉你答案:
🌱2.3、宏与函数的比较
这两个东西可以从多个维度进行比较,综合比较结束后,我们就能清楚宏和函数的使用场景了
🪴2.3.1、代码长度
首先是代码长度方面,函数会好一些
宏:宏的原理是直接替换,每次都是直接将宏插入到程序中。除了很短的宏,否则每次调用都会大幅度增加代码的长度
示例:求三数较大值
//宏定义,求三数中较大值 #define MAX(x, y, z) (((x) > (y) ? (x) : (y)) > (z) ? ((x) > (y) ? (x) : (y)) : (z)) int main() { int a = MAX(1, 2, 3); int b = MAX(4, 5, 6); int c = MAX(7, 8, 9); printf("%d %d %d\n", a, b, c); return 0; }
下面是经过预编译后的代码长度
代码还是比较长的(横向长度),这仅仅是替换了三次,如果替换100次,那就更长了
函数:函数只需要一份代码,就能被其他函数随意调用,对代码长度影响不大
示例:求三数较大值
//函数求三数中较大值 int max(int x, int y, int z) { int max = x; if (max < y) max = y; return max > z ? max : z; } int main1() { int a = max(1, 2, 3); int b = max(4, 5, 6); int c = max(7, 8, 9); printf("%d %d %d\n", a, b, c); return 0; }
下面是经过预编译后的代码长度
可以看到,在调用函数时,都是直接使用的,即使调用100次,代码也不会很长。其实在长度方面一直是函数的强项,毕竟函数的作用就是功能定义,代码复用
🪴2.3.2、执行速度
其次是代码执行速度方面,宏比函数快得多!
宏:宏在预编译阶段就已经完成了代码的替换,在后面无需进行操作
因此对运行速度有追求的程序会大量使用宏
函数:函数在使用时,存在调用和返回这两个操作,会造成额外的开销
C语言中函数调用需要经过一系列的操作,比如记录当前位置、传递参数、进入函数、计算后将返回值带回起始位置,这就比较浪费时间了
🪴2.3.3、操作符优先级
受优先级影响,宏相对于函数,计算结果不容易预料,并且宏在设计时需要大量括号
宏:宏的直接替换属性很快,但也可能会因为优先级问题带来错误,比如下面段代码
#define TEST(x,y) x + y int main() { int z = 2; z = z * TEST(2, 5); printf("%d\n", z); return 0; }
结果: 9
因为操作优先级导致的运算错误,当替换完成后,计算部分代码变成了这样:
z = z * 2 + 3; //替换后
因此宏在设计时,要注意潜在的优先级问题,如果不放心,可以多用括号解决
#define TEST(x,y) ((x) + (y)) //改进后,有效避免了优先级问题
函数:函数在参数优先级上不需要考虑太多,另外函数的计算相对来说比宏清晰,返回值就是预料值,就拿上面的代码来举例:
int test(int x, int y) { return x + y; } int main() { int z = 2; z = z * test(2, 5); printf("%d\n", z); return 0; }
结果: 14