c语言修炼秘籍 - - 禁(进)忌(阶)秘(技)术(巧)【第七式】程序的编译
【心法】
【第零章】c语言概述
【第一章】分支与循环语句
【第二章】函数
【第三章】数组
【第四章】操作符
【第五章】指针
【第六章】结构体
【第七章】const与c语言中一些错误代码
【禁忌秘术】
【第一式】数据的存储
【第二式】指针
【第三式】字符函数和字符串函数
【第四式】自定义类型详解(结构体、枚举、联合)
【第五式】动态内存管理
【第六式】文件操作
【第七式】程序的编译
文章目录
- [c语言修炼秘籍 - - 禁(进)忌(阶)秘(技)术(巧)【第七式】程序的编译](#c语言修炼秘籍 - - 禁(进)忌(阶)秘(技)术(巧)【第七式】程序的编译)
- 前言
- 一、程序的翻译环境和执行环境
- 二、详解编译和链接
- 三、预处理详解
-
- [1. 预定义符号](#1. 预定义符号)
- 2. #define
-
- [2.1 #define 定义标识符](#define 定义标识符)
- [2.2 #define 定义宏](#define 定义宏)
- [2.3 #define 替换规则](#define 替换规则)
- 2.4 #和##
- [2.5 带副作用的宏参数](#2.5 带副作用的宏参数)
- [2.6 宏和函数的对比](#2.6 宏和函数的对比)
- 3. #undef
- [4. 命令行定义](#4. 命令行定义)
- [5. 条件编译](#5. 条件编译)
- [6. 文件包含](#6. 文件包含)
- 总结
前言
在本章会对程序的编译过程,进行详细的讲解,重点包括:
- 程序的翻译环境
- 程序的执行环境
- c语言程序的编译+链接
- 预定义符号介绍
- 预处理指令#define
- 宏和函数的对比
- 预处理操作符#和##的介绍
- 命令定义
- 预处理指令#include
- 预处理指令#undef
- 条件编译
一、程序的翻译环境和执行环境
在ANSI C的任何一种实现中,都存在两种不同的环境
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令;
第2种是执行环境,它用于执行代码;
下图简单的表示了这两个环境的作用:
二、详解编译和链接
1. 翻译环境

- 组成程序的每个源文件通过编译过程分别转换成目标代码(object code);
- 每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序;
- 链接器同时也会引入标准c语言库中任何被该程序使用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中;
以之前写过的通讯录程序为例子:
该程序有2个源文件:contact.c、test.c,这两个源文件在翻译环境中单独
进行翻译生成对应的目标文件(后缀为.obj);
即生成contact.obj和test.obj
之后这些生成的目标文件将会一起经过链接器,链接在一起生成一个可执行程序,在这个过程中,还会将程序会使用到的库函数一起链接进来。
所以翻译环境中,代码经历的过程又可以分为两步,编译和链接:
在VS使用的编译器是cl.exe,使用的链接器是link.exe;
2. 编译本身也分为几个阶段
编译过程还可以详细的分为3个步骤:预编译(预处理),编译,汇编;
接下来我们会对这三个步骤逐个的详细讨论(在linux环境下进行):
编译
示例代码如下:
预编译:
gcc test.c -E
这个命令的作用是让源文件在预处理之后就停下;这条命令不生成文件,可以使用重定向将命令执行结果输出到一个文件中;这里使用gcc test.c -E > test.i
将该命令等到的信息输出到test.i这个文件中;后缀为.i
的文件是预处理后得到的文件;
打开得到的文件可以看到如下的结果:
在此之前还有很多内容,仅截取部分;可以发现我们并不能看懂这部分的内容;
在它之后的内容我们就能看懂了,这是我们的代码:
可以看到在我们的代码之前多出来了一大堆东西,但是少了一条语句 - #include <stdio.h>
在我们的心法篇中介绍了#include包含的头文件会以替换的形式将头文件中的内容包含进行源代码中,所以上面那一大堆代码其实就是头文件stdio.h中的内容。
那么我们就来验证一下,看看上面的内容到底是不是stdio.h中的内容,可以看到stdio.h的地址是/usr/include
,我们打开这个文件比较一下,两者是否相同。
此目录下确实存在这个文件,接下来看看文件的内容:
可以看到之前的确实是stdio.h拷贝到test.c中的内容;
接下来我们就来看看,预处理过程究竟做了什么?
从上图中我们可以看出,预处理
-
完成了头文件的包含#include;
-
完成了#define定义的符号和宏的替换
-
删除了所有注释;
总结一下,预处理过程完成了源文件到目标文件过程中所有的文本操作;
编译:
gcc test.c -S
,此命令的作用是对test.c这个文件进行预处理和编译;也可以使用gcc test.i -S
,这个命令可以得到同样的结果;命令执行之后会得到一个test.s
大概看一下test.s中有什么内容:
在预处理阶段,得到的文件仍是一个c语言代码,经过编译之后就得到了像上面一样的汇编代码;
这个过程中会进行语法分析、词法分析、语义分析、符号汇总;这4个步骤分别会做些什么,这里就不详细介绍,具体内容请自行学习编译原理 ;这里仅作简单介绍;
从上面的图片中我们得知,编译的过程就是将c语言代码转换成汇编代码的过程;这个转换的过程是由计算机来完成的,这也意味着计算机需要能够"读懂"c语言代码;那么要读懂c语言代码需要做到什么呢?首先需要知道每个语句表示什么,比如,这个东西是一个变量,那个是一个for循环,这就是语法分析大概要完成的事情;除此之外,还需要对每个语句进行拆分,比如,将一个for循环语句中拆分出for关键字,变量名等等;在知道了语法和词法之后,还需要知道一个语句表示的是什么意思,这就是语法分析要做的事;至于符号汇总,我们放在汇编中再作介绍;
汇编:
使用gcc test.c -c
可以将对源文件进行预编译、编译和汇编,生成test.o
文件,相当于windows系统中的.obj文件,也就是目标文件;
那么我们打开这个test.o文件看看里面保存的是什么;
test.o文件打开之后就是一堆乱码,也就是test.o是一个二进制文件;
所以汇编过程的作用是将汇编代码转换成了机器指令(二进制指令);
在这个过程中除了上面我们能看出来的将汇编代码转换为机器指令之外,还有一件非常重要的事情:生成符号表
;
在这里需要生成的符号表和上一步中的符号汇总有什么联系呢?
在说明上面的联系之前,我们要先知道test.o文件的是一种elf
类型的文件,这是一种分段的文件,代码的符号表就保存在这种文件的符号段中,使用readelf test.o --syms
就可以获取到这个目标文件的符号表:
对其进行分析:
从上图中可以看到,汇编过程中得到的符号表中仅有一些全局的符号,而这些符号又是在编译过程的符号汇总
中汇聚在一起的,这一步会将整个程序中所有源文件中存在的全局符号全部汇总在一起,之后再进行处理;
为了能更加清楚的展示编译过程会将符号汇总,在汇编过程中会生成对应的符号表,下面重新在对test.c和add.c进行编译:
分别对它们进行编译:
查看它们生成的符号表:
可以看到在test.o的符号表中也有Add这个符号,但是实际上它并知道这个符号的地址是什么,只是给它填充一个没有意义的地址;
可以看到单独生成的test.o和add.o中都包含有符号Add,只是一个的地址是有效的,一个是无效的;但是这两个符号在生成可执行程序时,到底使用哪一个呢?这一步就交到链接器来完成;
链接
在链接阶段,链接器会将多个目标文件和链接库进行链接,生成一个可执行文件;在上面的例子中,仅有两个目标文件,test.o和add.o,没有链接库,所以在此程序中,链接操作的作用就是将这两个目标链接成一个可执行文件a.out(可以通过参数设置,修改生成的可执行文件的名字),linux系统中;命令:gcc test.o add.o -o test.out
生成一个名为test.out可执行文件;(注意,linux系统中并不以后缀名来区分文件是否可执行,而通过文件权限和文件格式(必须是elf
格式)来区分,可以看到test.out文件是有执行的权限的X
标志);

该阶段会进行的具体操作可分为:
- 合并段表;
- 符号表的合并和重定位;
因为,可执行文件的格式也是elf,所以在由多个目标文件生成可执行文件过程中,需要将这些文件中相同段中的内容进行合并,这就是合并段表;
而符号表的合并和重定位就更容易理解了,在介绍汇编时,我们提到了test.o和add.o这两个文件和符号表中都有Add
这个符号,在两个目标文件合并成一个可执行文件的过程中,这两个Add
肯定只能保留一个,也就是保留那个有着有效信息的Add,将另一个无效的Add删除,并将Add的地址保存为add.o中的地址;简单来说就是将多个目标文件的符号进行合并,并将有效的符号进行保留;
看到这里的同学肯定会疑惑,这个符号表有什么用呢?
这个符号表可以使得程序能够通过它们找到并使用对应的符号,比如,程序要调用Add函数时,就会到符号表中找Add这个符号的地址,也就是0x1008,之后程序通过这个地址调用了Add函数,完成了功能;
下面我们在VS环境中举个例子帮助大家更好的理解这点。
将add.c中所有内容注释掉,此时add.c变成了一个空文件,在汇编之后会生成一个空的符号表,test.c文件仍会生成包含两个符号的符号表,其中符号Add的地址是无效地址;
此时进行编译就会出现以下的错误:
在链接时,通过合并的符号表寻找Add函数时,链接器去往0x0000这个地址会发现并没有Add这个函数存在,也就发生了错误;
正是因为有了符号表的存在,跨文件的函数调用才得以实现;
总结
翻译过程可以分为编译和链接两个步骤,其中编译又可以分为,预编译、编译、汇编三个步骤;
它们使用的命令分别是:gcc -test.c -E
、gcc -test.c -S
、gcc -test.c -c
;
预编译过程进行文本操作(test.i),编译过程将c语言代码转换成汇编代码(test.s),汇编过程将汇编代码转换成机器指令(目标文件,test.o);最后由链接器将这些目标文件链接成一个可执行文件;
一图流:
3. 运行环境
程序运行的过程:
- 程序必须载入内存中才可以运行;在有操作系统的环境中,该步骤一般由操作系统来完成;在独立的环境中,程序的载入要么手动操作,要么通过可执行代码置入只读内存来实现;
- 程序的执行便开始。接着调用main函数;
- 开始执行程序代码。此时程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态内存(static),存储于静态内存中的变量在程序执行整个过程中一直保留它们的值;
- 终止程序。正常终止main函数;有时候也可能是意外终止;
关于运行时堆栈,这里仅简单介绍,想要详细了解,大家可以去看之前的文章内容,里面有详细介绍;在心法篇中的函数和秘术篇的动态内存分配里都有介绍;
注意:堆栈指的就是栈,并不是堆+栈
;
三、预处理详解
1. 预定义符号
FILE // 进行编译的源文件
LINE // 文件当前行号
DATE // 文件被编译的日期
TIME // 文件被编译的时间
STDC // 如果编译器遵循ANSI C,其值为1,否则未定义
FUNCTION // 当前函数
这些预定义符号都是语言内置的。
举个例子:
c
#include <stdio.h>
int main()
{
printf("file:%s\n", __FILE__);
printf("line:%d\n", __LINE__);
printf("date:%s\n", __DATE__);
printf("time:%s\n", __TIME__);
printf("function:%s\n", __FUNCTION__);
// printf("STDC:%s\n", __STDC__); // VS 2022 未定义
return 0;
}
运行结果:
这些内置符号具体有什么用呢?
可以用来写程序日志,在日志中记录时间和代码行号,在出错时,就可以非常方便的定位错误原因;就算是正常运行过程中,写日志也是一个非常重要的事,它可以让你可以了解程序当前是运行状态,检查可能出现的一些问题;
下面我们给出一个记录日志的例子供大家参考:
c
#include <stdio.h>
int main()
{
// 记录日志,就是在写文件
// 将程序执行的信息输出到log.txt中
FILE* pf = fopen("log.txt", "a");
if (pf == NULL)
{
perror(pf);
return ;
}
int i = 0;
for (i = 0; i < 10; i++)
{
// 记录每行的信息
fprintf(pf, "%s %d %s %s %d\n", __FILE__, __LINE__, __DATE__, __TIME__, i);
}
fclose(pf);
pf = NULL;
return 0;
}
文件内容:
2. #define
2.1 #define 定义标识符
语法:
#define name stuff
示例:
c
// #define 定义标识符
#define MAX 100 // 定义一个数字
#define uint unsigned int // 有时一个关键字较长,可以创建一个更简短的方式
#define do_forever for(;;) // 用更形象的符号来替换一个种实现,这里实现的是一个死循环
#define CASE break;case // 在写case语句的时候自动把 break写上;
// 因为有此语言中switch语句不用加break,习惯了这些语言的程序员可能在使用c语言时,
// 害怕自己忘记添加break导致程序出错,就可以使用这种标识符
// 如果定义的stuff过长,可以分成几行来写,除了最后一行外,每一行的后面都加上一个反斜杠 \ (续行符)
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
date:%s\ttime:%s\n", \
__FILE__, __LINE__, \
__DATE__, __TIME__)
#include <stdio.h>
int main()
{
uint i = MAX;
// do_forever; // 相较于,for(;;);,该语句以一个更清楚的方式,实现了一个死循环
int input = 0;
scanf("%d", &input);
// 代码1
switch (input)
{
case 1:
CASE 2:
CASE 3:
}
// 代码2
switch (input)
{
case 1:
break;
case 2:
break;
case 3:
}
// 代码1和代码2完全等价
DEBUG_PRINT;
return 0;
}
大家可能在使用#define定义标识符时,会产生一个疑问,在标识符后面需不需要加上一个分号;
呢?
比如:
c
#define MAX 1000;
#define MAX 1000
这两种定义方式有什么不同呢?
我们都知道,#define定义的标识符是在预处理阶段直接进行文本替换的;
放在代码中:
c
int main()
{
int a = MAX; // 使用第一种定义方式时,这个代码会变成 int a = 1000;;
// 赋值语句之后还有一个空语句,在这里语法是没有什么问题的,
// 但是换一种情况就会产生问题
if(condition)
max = MAX; // 在这里一条if语句只能对应一条语句,但是这里有了两条语句,产生了语法错误
else
max = 0;
}
所以我们在使用#define定义标识符时,一般都不会在后面添加;
2.2 #define 定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro);
下面是宏的申明方式:
c
#define name(parament-list) stuff
// parament-list是一个逗号隔开的符号表,它们可能出现 stuff中
注意,参数列表的左括号必须与name紧邻;如果两者之间存在有任何空白存在,参数列表就会被解释为stuff的一部分,就变成了#define定义的标识符了
使用示例:
c
// 定义一个宏来计算平方
#define SQUARE(x) ((x) * (x))
// 这个宏接受一个参数,x
// 在程序中使用
SQUARE(8);
// 该宏会像#define定义的标识符一样,直接在预处理阶段进行替换
// 即程序中的代码变成
((8) * (8));
易错点:
大家初次使用宏可能会疑惑,为什么上面定义的这个宏要有那么多的括号呢?
下面我们就来看看如果没有括号会发生什么:
c
#include <stdio.h>
#define SQUARE(x) x * x
int main()
{
int a = 2;
int ret = SQUARE(a + 1);
printf("%d\n", ret);
return 0;
}
这段代码会如预期一样输出9
吗?
可以看到输出结果是5
;
这是为什么呢?
宏的本质是在文本处理阶段直接替换,所以上面的代码在预处理之后变成了:
c
#include <stdio.h>
int main()
{
int a = 2;
int ret = a + 1 * a + 1;
printf("%d\n", ret);
return 0;
}
可以看到,该程序是将a + 1 * a + 1赋值给了ret,也就是 2 + 1 * 2 + 1,结果就是5;
当我们的宏定义成#define SQUARE(x) ((x) * (x))
时,上面代码在替换之后为,((2 + 1) * (2 + 1)),结果与预期相同;
这时有人可能还有疑问,在x外部加上括号不就行了,为什么在计算结果外面也要加上括号呢;
我们再看一个例子:
c
#define DOUBLE(x) (x) + (x)
int main()
{
int a = 5;
int ret = 10 * DOUBLE(a);
printf("%d\n", ret);
return 0;
}
这段代码在替换之后变成了int ret = 10 * (5) + (5)
这与预期的结果也是不符的,当宏的结果外面有了括号,此时int ret = 10 * ((5) * (5))
才符合预期;
结论:
在使用宏来对数值表达式求值时,宏的定义都应该在参数和总体外面加上括号,这样就可以避免在使用宏时出现预期之外的错误;
2.3 #define 替换规则
在程序中扩展#define定义的符号和宏时,需要涉及以下的几个步骤:
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,首先替换掉它们;
- 替换文本随后被插入到程序原来文本的位置。对于宏,宏的参数名被它的值替换;
- 最后,再对结果文件进行扫描,看看是否还有包含任何由#define定义的符号。如果是,就重复上述步骤;
示例:
c
#include <stdio.h>
#define M 20
#define MAX(x, y) (((x) > (y))? (x) : (y))
int main()
{
int a = 10;
printf("%d\n", MAX(a, M));
return 0;
}
这段代码中,MAX这个宏中第二个参数是一个#define定义的符号,所以此时先替换M
,变成printf("%d\n", MAX(a, 20));
;
之后替换文本,printf("%d\n", (((10) > (20)) ? (10) : (20)));
;
注意
- 宏参数和#define定义中可以出现其他#define定义的符号,但对于宏,不能出现递归;
- 当预处理器搜索#define定义的符号时,字符串常量的内容并不被搜索;
举个例子说明第二点:
c
printf("M = %d\n", M);
这段代码中的字符串中的M不会被替换;
输出结果为:M = 20
2.4 #和##
#的作用
如何把参数插入到字符串中?
在介绍这个内容之前,我们先看下面的代码:
c
#include <stdio.h>
int main()
{
printf("hello world\n");
printf("hello ""world\n");
return 0;
}
这两个输出语句,输出的结果相同吗?
由上图得知,这两句代码效果相同;
在了解了上面的printf函数的使用之后,再来看下面的代码:
c
#include <stdio.h>
int main()
{
int a = 10;
// 需要得到 the value of a is 10 这样的输出
int b = 20;
// 需要得到 the value of b is 20 这样的输出
int c = 30;
// 需要得到 the value of c is 30 这样的输出
return 0;
}
要完成上面的需求,应该怎么解决呢?函数?
试试看:
c
void print(int x)
{
printf("the value of x is %d\n", x);
}
能这样写吗?显然不行,写成函数的话,输出结果就被写死了,只有值能随着参数的变化而变化,字符串内容无法改变;只能写成3个不同的函数来实现;
想到上面提到的printf
函数的特性,能写成一个宏来实现这个功能吗?
c
#define PRINT(X) printf("the value of " X "is %d\n", X);
这样写正确吗?预想中,先输出字符串the value of
,再输出X,再输出字符串is %d\n
,%d
对应X的值;
实际上:
这里需要第一个a
变成一个字符串,c语言规定,使用#
修饰宏的参数,在预处理阶段,该参数会作为字符串进行替换;即,#X
会变成"X"
;
将代码改成:
c
#include <stdio.h>
#define PRINT(X) printf("the value of " #X " is %d\n", X);
int main()
{
int a = 10;
// 需要得到 the value of a is 10 这样的输出
PRINT(a);
int b = 20;
// 需要得到 the value of b is 20 这样的输出
PRINT(b);
int c = 30;
// 需要得到 the value of c is 30 这样的输出
PRINT(c);
return 0;
}
运行结果:
可能有人会想可以用3个引号吗?也就是写下面这样:
c
#define PRINT(X) printf("the value of " "X" " is %d\n", X);
很显然不行,这个与上面提示的预处理器处理时不会替换字符串常量中的符号冲突,此时的输出变成:
接下来我们再对这个宏进行优化,此时该宏只能处理整型数据,能否让它可以处理任何类型的变量呢?当然是可以的,要处理什么类型的数据,这个问题使用者是清楚的,所以我们的宏在增加一个参数,接收数据的类型;
c
#include <stdio.h>
#define PRINT(X, FORMAT) printf("the value of " #X " is " FORMAT "\n", X);
int main()
{
int a = 10;
// 需要得到 the value of a is 10 这样的输出
PRINT(a, "%d");
int b = 20;
// 需要得到 the value of b is 20 这样的输出
PRINT(b, "%d");
int c = 30;
// 需要得到 the value of c is 30 这样的输出
PRINT(c, "%d");
float f = 3.14f;
PRINT(f, "%f");
return 0;
}

##的作用
##可以将位于它两边的符号连成一个符号;
它允许宏定义以分离的文本片段创建标识符;
示例:
c
#include <stdio.h>
#define STR "要你命"
#define CAT(X, Y) X##Y
int main()
{
// 达文西现在除了要你命3000之外,还有了许多其他的要你命系列的武器
// 现在需要根据提供的型号,产生对应的字符串
printf("%s\n", CAT(STR, "4000"));
// 譬如此时有一个变量名就是Annihilator3000
int Annihilator3000 = 100; // 这里为了演示方便使用int类型,也可以使用其他类型
printf("%d\n", CAT(Annihilator, 3000));
return 0;
}
运行结果:
注意
:
使用##
时,除了#define定义的标识符会进行替换之外,符号两边是什么,就用什么进行拼接,使用变量时,不会使用变量指代的值,而是直接使用变量名本身;
2.5 带副作用的宏参数
什么叫做带副作用的宏参数呢?
副作用就是表达式求值时出现的永久性效果。比如,x+1
这个表达式就没有副作用,x++
这个表达式就有副作用,它会永久性的改变x的值;
当宏参数在宏的定义中出现超过一次时,如果参数带有副作用,那么你在使用这个宏时可能出现不可预测的结果;
示例:
c
#include <stdio.h>
#define MAX(X, Y) ((X) > (Y)? (X) : (Y))
int main()
{
int a = 5;
int b = 8;
int c = MAX(a++, b++);
// 这里的结果会是什么呢?
// 会是6, 9, 9吗
printf("%d, %d, %d\n", a, b, c);
return 0;
}
运行结果:
可以看到实际结果与预期不符,这是因为条件表达式在替换之后为int c = ((a++) > (b++)? (a++) : (b++))
,两个参数比较之后,还执行了一次b++
;所以b
变成了10;
所以在使用宏时,尽量不要使用有副作用的参数;
2.6 宏和函数的对比
比较下面两种实现哪种更好
c
// 代码1
#define Add1(X, Y) ((X) + (Y))
// 代码2
int Add2(int x, int y)
{
return (x + y);
}
int main()
{
int a = 10;
int b = 20;
int c = Add1(a, b);
int d = Add2(a, b);
return 0;
}
结论是代码1更好;
原因如下:
这是宏的汇编代码,仅有三句;
下面的是函数实现的汇编执行过程:
很显然,函数调用比宏的实现要复杂得多;
所以简单的运算使用宏是更优的;
原因有二:
- 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹;
- 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。而宏可以适用于整型、长整型、浮点数等可以使用
>
进行比较的类型,使用更灵活;宏与类型无关
当然宏与函数相比也存在有劣势的地方:
- 每次使用宏时,都是通过代码替换的方式实现的。除非一个宏较短,否则,这会大大增加程序的长度;
- 宏是无法调试的;因为宏的替换是在预处理阶段,而调试则是发生在可执行程序运行中;
- 宏与类型无关,既是优点也是缺点,因为没有类型,所以不够严谨;
- 宏可能存在运算符优先级的问题,导致程序更容易出错;
宏也能做到一些函数无法做到的事,比如#
中的例子,还有宏的参数可以是类型,函数不行;
c
#include <stdlib.h>
#define MALLOC(x, type) \
((type*)malloc(sizeof(type) * x))
int main()
{
// 开辟10个整型的空间
int *p = MALLOC(10, int);
return 0;
}
属性 | #define定义宏 | 函数 |
---|---|---|
代码长度 | 每次使用时,宏代码都会插入程序,程序长度会增加 | 函数代码只出现一次,每次调用都是使用同一份代码 |
执行速度 | 更快 | 存在函数调用和返回的额外开销,较慢 |
操作符优先级 | 宏参数的求值是在所有周围上下方环境中,除非加上括号,否则邻近操作符的优先级可能会对求值产生不可预料的影响 | 函数参数只在传参时求值一次,将求值结果传递给函数,结果更容易预测 |
带副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预期的结果 | 函数参数只在传参时求值一次,结果更容易控制 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用任何参数 | 函数的参数确定类型的,不同的参数需要不同的函数,即使它们执行的任务相同 |
调试 | 宏无法调试 | 函数可以逐语句调试 |
递归 | 宏不可递归 | 函数可以递归 |
命名约定
一般来说,函数和宏使用的语法非常相似;所以语言本身无法帮助我们区分它们。
所以平时,我们习惯于将:
宏名全部大写
函数名不要全部大写
3. #undef
这条语句用于移除一个宏的定义
c
#include <stdio.h>
#undef NAME
// 如果存在一个名为 NAME的宏,当你不再想要它时,可以使用#undef来将它移除
#define M 100
int main()
{
int a = M;
#undef M
printf("%d %d\n", a, M);
return 0;
}

4. 命令行定义
对于许多c语言的编译器提供了在命令行中定义符号的功能;
假如对于一个可移植的代码,代码中有一个数组,有些机器的内存空间有限,所以这个数组的长度就小,对于另一些内存较大的机器,数组的长度就可以更长一点;
此时就可以使用命令行定义:
示例:
可以看到直接编译时会出现错误,提示M
没有定义;此时可以使用命令行命令定义变量M的值为10,之后再进行编译就可以通过,并且成功运行;
5. 条件编译
c语言中语句的编译可以像条件语句一样,选择性的进行编译;
比如:
c
#include <stdio.h>
#define __PRINT__
int main()
{
// 如果已经定义了__PRINT__,则#ifdef和#endif之间的代码参与编译
#ifdef __PRINT__
printf("hehe\n");
#endif
return 0;
}
常见的条件编译指令:
c
// 1.
#if 常量表达式
// ....
#endif
// 常量表达式为真时,被包括的内容参与编译;为假则反之
// 常量表达式由预处理器求值
// 如:
#define __DEBUG__ 1
#if __DEBUG
printf("hehe");
#endif
// 2. 多个分支的条件编译
#if 常量表达式
// ...
#elif 常量表达式
// ...
#else
// ...
#endif
// 可以看到条件编译语句和条件分支语句很像
// 3. 判断是否被定义
#if defined(symbol)
// ...
#endif
// 写法1
#ifdef symbol
// ...
#endif
// 写法2
// 这两种写法等价,意思是如果symbol已经被定义,就编译它们包含的内容
#if !defined(symbol)
// ...
#endif
// 写法1
#ifndef symbol
// ...
#endif
// 写法2
// 与上面相反,如果symbol已经被定义,它们包含的内容就不编译
// 4. 嵌套指令
#if defined(HELLO)
#ifdef HEHE
printf("HEHE\n");
#endif
#ifdef HAHA
printf("HAHA\n");
#elif HEIHEI
printf("HEIHEI\n");
#else
printf("HELLO\n");
#endif
#elif WORLD
printf("WORLD\n");
#endif
// 嵌套指令和嵌套使用条件语句一样
6. 文件包含
我们已经知道,#include
指令可以使另一个文件也被编译进程序。就像直接将它的内容替换到这个地方一样;
这种替换非常简单:
预处理器会先删除这条指令,将用被包含文件的内容来替换;
如果这个文件被包含了10次,那么它的内容就被包含了10次;
因为这个性质在包含头文件时就会产生问题;
如果一个头文件在多个文件中都被包含,那么这个头文件的内容就被重复包含了多次,这使得程序代码变得冗余臃肿;
比如:
此时有4个源文件,common.c, test1.c, test2.c, test.c
它们都有对应的头文件,common.h, test1.h, test2.h, test.h
test1.h 包含了common.h, test2.h 也包含了common.h, test.h 包含了test1.h和test2.h

可以看到test.h中包含了两次的common.h的内容;
为了解决这种问题就可以使用条件编译语句:
c
// test1.h
#ifndef COMMON
#define COMMON
#include "common.h"
#endif
// test2.h
#ifndef COMMON
#define COMMON
#include "common.h"
#endif
// test.h
#ifndef TEST1
#define TEST1
#include "test1.h"
#endif
#ifndef TEST2
#define TEST2
#include "test1.h"
#endif
上面的条件编译语句只有在目标头文件没有被编译到文件中时才会参与编译,在编译之后就设置一个标志,使得之后不会再重复包含这个文件;
或者是使用
c
#pragma once // 文件内容只会被包含一次
在上面的代码中,包含头文件时,使用的是""
并不是之前使用过的<>
;这两者有什么区别呢?
- 本地文件包含:
#include "name"
查找策略:先在源文件所在目录下查找,如果找不到,编译器就会在标准位置(库函数头文件所在位置)查找;此时再找不到就编译错误;
linux系统中的标准位置:/usr/include
VS环境下的标准位置:C:\Program Files (x86)\Windows Kits\10\Include 这个地址以自己的机器为准
- 库文件包含:
#include <name>
查找头文件时,直接到标准位置查找,找不到就编译错误;
可能有的同学会有疑问,那么包含库函数头文件时,是否可以使用#include "name"
来包含呢?
可以,但是不推荐,因为使用这种方法,会先去当前目录查找,这肯定是找不到的,浪费了系统资源,降低了查找效率;
总结
这是c语言学习的最后一个部分了,此章节介绍了一个c语言程序从源文件变成可执行文件的过程,还详解了c语言中使用的预处理指令;以及一些宏的使用方法;
希望这系列文章对大家的c语言学习有帮助;