目录

  1. 概述
  2. 预处理案例
  3. #include 用法详解
  4. 头文件重复引用
  5. 变量的重复定义
  6. #define 用法
    1. 无参数宏定义
    2. 带参数宏定义
  7. 条件编译
    1. #if
    2. #ifdef
    3. #ifndef
    4. 条件编译总结
  8. 总结
  9. 附录

概述

Java 语言中的 导包 import 语句在 C 中等价于 #include 语句,那么在 C 中这条语句有什么用尼?

使用库函数之前,应该用 #include 引入对应头文件,这种 # 号开头的命令称为预处理命令

C 语言作为经典的编译型语言,想要执行一个 C 程序,需要通过编译、链接形成一个可执行程序,常说的运行就是执行这个可执行程序文件

  • 编译(Compile)会将源文件(.c 文件)转换为目标文件。对于 VC/VS,目标文件后缀为.obj;对于 GCC,目标文件后缀为 .o 。编译是针对单个源文件的,一次编译操作只能编译一个源文件,如果程序中有多个源文件,就需要多次编译操作
  • 链接(Link)是针对多个文件的,它会将编译生成的多个目标文件以及系统中的库、组件等合并成一个可执行程序

但是在实际开发中,文件在编译之前还需要对源文件进行简单的处理。例如,对于相同的功能,在不同操作系统可能具体实现是不同的,在 windows 上需要使用 a() 在 Linux 上需要使用 b(),这就需要在编译之前先对源文件进行处理,保证根据检测结果,执行不同的实现方式。以上这些在编译之前先对源文件进行简单加工的过程,就是预处理过程

预处理主要是处理以#开头的命令,例如#include <stdio.h>等。预处理命令要放在所有函数之外,而且一般都放在源文件的前面

处理是 C 语言的一个重要功能,由预处理程序完成。当对一个源文件进行编译时,系统将自动调用预处理程序对源程序中的预处理部分作处理,处理完毕自动进入对源程序的编译。

编译器会将预处理的结果保存到和源文件同名的.i 文件中,例如 main.c 的预处理结果在 main.i 中。和.c 一样,.i 也是文本文件,可以用编辑器打开直接查看内容。

C 语言提供了多种预处理功能,如宏定义、文件包含、条件编译等,合理地使用它们会使编写的程序便于阅读、修改、移植和调试,也有利于模块化程序设计

预处理案例

由于 C 语言开发的程序和操作系统强相关(需要依赖操作系统提供的需要函数调用),所以如果现在想要实现一个简单的功能:让程序暂停 5 秒,要求在全平台都可以运行。这就是 C 语言遇到的最大障碍,跨平台苦难。因为不同操作系统提供的系统调用是不同的,这就造成了想要实现相同的功能,但是在不同环境下却需要导入不同的头文件,并且使用不同的 API。

  • Windows 平台下的暂停函数的原型是void Sleep(DWORD dwMilliseconds),参数的单位是毫秒,位于 <windows.h> 头文件。

  • Linux 平台下暂停函数的原型是unsigned int sleep (unsigned int seconds),参数的单位是,位于 <unistd.h> 头文件。

预处理程序就可以帮助我们解决这种问题,具体的做法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

//不同的平台下引入不同的头文件
#if _WIN32 //识别windows平台
#include <windows.h>
#elif __linux__ //识别linux平台
#include <unistd.h>
#endif

int main() {
//不同的平台下调用不同的函数
#if _WIN32 //识别windows平台
Sleep(5000);
#elif __linux__ //识别linux平台
sleep(5);
#endif

puts("http://c.biancheng.net/");

return 0;
}

其中#if,#elif,#endif就是预处理命令,它们都是在编译之前由预处理程序来执行的。上面的代码通过预处理以后,在 windows 操作系统上会变成

1
2
3
4
5
6
7
8
9
#include <stdio.h>
#include <windows.h>

int main() {
Sleep(5000);
puts("http://c.biancheng.net/");

return 0;
}

在 Linux 平台上,会变成

1
2
3
4
5
6
7
#include <stdio.h>
#include <unistd.h>
int main() {
sleep(5);
puts("http://c.biancheng.net/");
return 0;
}

在不同的平台下,编译之前(预处理之后)源代码会变成不一样,使得程序做到与具体平台的绑定。

简而言之,预处理阶段的工作,就是把代码当成普通文本,根据设定的条件进行一些简单的文本替换,将替换后的结果再交给编译器处理。

#include 用法详解

#include叫做文件包含命令,用来引入对应的头文件(.h 文件)。#include 也是 C 语言预处理命令的一种.#include 的处理过程很简单,就是将头文件的内容插入到该命令所在的位置,从而把头文件和当前源文件连接成一个源文件,这与复制粘贴的效果相同。

#include 的用法有两种,如下所示:

1
2
#include <stdHeader.h>
#include "myHeader.h"

使用尖括号< >和双引号" "的区别在于头文件的搜索路径不同:

  • 使用尖括号< >,编译器会到系统路径下查找头文件;
  • 使用双引号"",编译器首先会在当前目录下查找头文件,如果没有找到,再到系统路径下查找

之前一直使用尖括号来引入标准头文件,现在也可以使用双引号

1
2
#include "stdio.h"
#include "stdlib.h"

stdio.h 和 stdlib.h 都是标准头文件,它们存放于系统路径下,所以使用尖括号和双引号都能够成功引入;而我们自己编写的头文件,一般存放于当前项目的路径下,所以不能使用尖括号,只能使用双引号

👴 建议养成使用尖括号来引入标准头文件,使用双引号来引入自己编写的头文件,这样一眼就能看出头文件的区别

🎶 关于 #include 用法的注意事项:

  1. 一个 #include 命令只能包含一个头文件,多个头文件需要多个 #include 命令
  2. 同一个头文件可以被多次引入,多次引入的效果和一次引入的效果相同,因为头文件在代码层面有防止重复引入的机制
  3. 文件包含允许嵌套,也就说一个被包含的文件中又可以包含另一个文件

📓 最好不要在头文件中定义函数和全局变量,不管是标准头文件,还是自定义头文件,都只能包含变量和函数的声明,不能包含定义,否则在多次引入时会引起重复定义错误

头文件重复引用

多重包含在绝大多数情况下出现在大型程序中,它往往需要使用很多头文件,因此要发现重复包含并不容易。要解决这个问题,我们可以使用条件编译。如果所有的头文件都像下面这样编写:

1
2
3
4
5
6
#ifndef _HEADERNAME_H
#define _HEADERNAME_H

...

#endif

那么多重包含的危险就被消除了。当头文件第一次被包含时,它被正常处理,符号_HEADERNAME_H 被定义为 1。如果头文件被再次包含,通过条件编译,它的内容被忽略。符号_HEADERNAME_H 按照被包含头文件的文件名进行取名,以避免由于其他头文件使用相同的符号而引起的冲突。

变量的重复定义

由于工程中的每个.c 文件都是独立的解释的,即使头文件有下面的预处理指令

1
2
3
4
#ifndef _TEST_H_
#define _TEST_H_
....
#endif

在其他文件中只要包含了 global.h 就会独立的解释,然后每个.c 文件生成独立的标示符。在编译器链接时,就会将工程中所有的符号整合在一起,由于文件中有重名变量,于是就出现了重复定义的错误。使用下列方法就可以解决这个问题:

在.c 文件中声明变量,然后建一个头文件(.h 文件)在所有的变量声明前加上 extern,注意这里不要对变量进行的初始化。然后在其他需要使用全局变量的.c 文件中包含.h 文件。编译器会为.c 生成目标文件,然后链接时,如果该.c 文件使用了全局变量,链接器就会链接到此.c 文件 。

#define 用法

#define 叫做宏定义命令,它也是 C 语言预处理命令的一种。所谓宏定义,就是用一个标识符来表示一个字符串,如果在后面的代码中出现了该标识符,那么就全部替换成指定的字符串,可以理解为字符串替换操作

宏定义的一般形式为:

1
#define 宏名 字符串

#表示这是一条预处理命令,所有的预处理命令都以 # 开头。宏名是标识符的一种,命名规则和变量相同。字符串可以是数字、表达式、if 语句、函数等。

无参数宏定义

🎶 使用宏定义要点

  1. 宏定义是用宏名来表示一个字符串,在宏展开时又以该字符串取代宏名,这只是一种简单粗暴的替换。字符串中可以含任何字符,它可以是常数、表达式、if 语句、函数等,预处理程序对它不作任何检查,如有错误,只能在编译已被宏展开后的源程序时发现
  2. 宏定义不是说明或语句,在行末不必加分号,如加上分号则连分号也一起替换
  3. 宏定义必须写在函数之外,其作用域为宏定义命令起到源程序结束。如要终止其作用域可使用#undef 命令
1
2
3
4
5
6
7
8
9
#define PI 3.14159
int main(){
// Code
return 0;
}
#undef PI
void func(){
// Code
}
  1. 代码中的宏名如果被引号包围,那么预处理程序不对其作宏代替,例如:
1
2
3
4
5
6
#include <stdio.h>
#define OK 100
int main(){
printf("OK\n");
return 0;
}
  1. 宏定义允许嵌套,在宏定义的字符串中可以使用已经定义的宏名,在宏展开时由预处理程序层层代换
1
2
#define PI 3.1415926
#define S PI*y*y /* PI是已定义的宏名*/
  1. 习惯上宏名用大写字母表示,以便与变量区别,但也允许用小写字母
  2. 可用宏定义表示数据类型,使书写方便

带参数宏定义

C 语言允许宏带有参数。在宏定义中的参数称为形式参数,在宏调用中的参数称为实际参数,这点和函数有些类似,对带参数的宏,在展开过程中不仅要进行字符串替换,还要用实参去替换形参。

带参宏定义的一般形式为:

1
#define 宏名(形参列表) 字符串

在字符串中可以含有各个形参,而带参宏调用的一般形式为:

1
宏名(实参列表);

例如:

1
2
3
#define M(y) y*y+3*y  //宏定义
// TODO:
k=M(5); //宏调用

🎶 带参宏定义注意要点

  1. 带参宏定义中,形参之间可以出现空格,但是宏名和形参列表之间不能有空格出现。例如把:
1
2
3
#define MAX(a,b) (a>b)?a:b
// 参数与宏名之间增加空格将造成错误
#define MAX (a,b) (a>b)?a:b
  1. 在带参宏定义中,不会为形式参数分配内存,因此不必指明数据类型。而在宏调用中,实参包含了具体的数据,要用它们去替换形参,因此实参必须要指明数据类型。这一点和函数是不同的,在函数中,形参和实参是两个不同的变量,都有自己的作用域,调用时要把实参的值传递给形参;而在带参数的宏中,只是符号的替换,不存在值传递的问题
  2. 在宏定义中,字符串内的形参通常要用括号括起来以避免出错。例如上面的宏定义中 (y)*(y) 表达式的 y 都用括号括起来,因此结果是正确的

带参数的宏和函数很相似,但有本质上的区别:

  • 宏展开仅仅是字符串的替换,不会对表达式进行计算;宏在编译之前就被处理掉了,它没有机会参与编译,也不会占用内存。
  • 函数是一段可以重复使用的代码,会被编译,会给它分配内存,每次调用函数,就是执行这块内存中的代码。

条件编译

假如现在要开发一个 C 语言程序,让它输出红色的文字,并且要求跨平台,在 Windows 和 Linux 下都能运行,这个程序的难点在于,不同平台下控制文字颜色的代码不一样,我们必须要能够识别出不同的平台。

Windows 有专有的宏_WIN32,Linux 有专有的宏linux,以现有的知识,使用 if else 判断一下就可以了呀

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
int main(){
if(_WIN32){
system("color 0c");
printf("http://c.biancheng.net\n");
}else if(__linux__){
printf("\033[22;31mhttp://c.biancheng.net\n\033[22;30m");
}else{
printf("http://c.biancheng.net\n");
}

return 0;
}

但是这段代码是错误的,在 Windows 下提示 linux 是未定义的标识符,在 Linux 下提示 _Win32 是未定义的标识符。对上面的代码进行改进:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
int main(){
#if _WIN32
system("color 0c");
// do something in windows os
#elif __linux__
// do something in linux os
#else
// do something in other os
#endif

return 0;
}

#if、#elif、#else 和 #endif 都是预处理命令,根据不同的宏定义,保留不同的代码片段。这些操作都是在预处理阶段完成的,多余的代码以及所有的宏都不会参与编译,不仅保证了代码的正确性,还减小了编译后文件的体积。

这种能够根据不同情况编译不同代码、产生不同目标文件的机制,称为条件编译。条件编译是预处理程序的功能,不是编译器的功能。

#if

#if 用法的一般格式为:

1
2
3
4
5
6
7
8
9
#if 整型常量表达式1
程序段1
#elif 整型常量表达式2
程序段2
#elif 整型常量表达式3
程序段3
#else
程序段4
#endif

它的意思是:如常“表达式 1”的值为真(非 0),就对程序段 1进行编译,否则就计算表达式 2,结果为真的话就对程序段 2进行编译,为假的话就继续往下匹配,直到遇到值为真的表达式,或者遇到 #else。这一点和 if else 非常类似。

🎶 #if命令要求判断条件为整型常量表达式,也就是说,表达式中不能包含变量,而且结果必须是整数;而 if 后面的表达式没有限制,只要符合语法就行。这是 #if 和 if 的一个重要区别

#ifdef

1
2
3
4
5
#ifdef  宏名
程序段1
#else
程序段2
#endif

它的意思是,如果当前的宏已被定义过,则对程序段 1 进行编译,否则对程序段 2 进行编译,其中#else片段也可以省略

#ifndef

1
2
3
4
5
#ifndef 宏名
程序段1
#else
程序段2
#endif

与 #ifdef 相比,仅仅是将 #ifdef 改为了 #ifndef。它的意思是,如果当前的宏未被定义,则对程序段 1 进行编译,否则对程序段 2 进行编译,这与 #ifdef 的功能正好相反。

条件编译总结

📓 #if 后面跟的是整型常量表达式,而 #ifdef 和 #ifndef 后面跟的只能是一个宏名,不能是其他的

总结

预处理指令是以#号开头的代码行,# 号必须是该行除了任何空白字符外的第一个字符。# 后是指令关键字,在关键字和 # 号之间允许存在任意个数的空白字符,整行语句构成了一条预处理指令,该指令将在编译器进行编译之前对源代码做某些转换。

指令说明
#空指令,无任何效果
#include包含一个源代码文件
#define定义宏
#undef取消已定义的宏
#if如果给定条件为真,则编译下面代码
#ifdef如果宏已经定义,则编译下面代码
#ifndef如果宏没有定义,则编译下面代码
#elif如果前面的#if 给定条件不为真,当前条件为真,则编译下面代码
#endif结束一个#if……#else 条件编译块

为了避免宏代换时发生错误,宏定义中的字符串应加括号,字符串中出现的形式参数两边也应加括号。使用预处理功能便于程序的修改、阅读、移植和调试,也便于实现模块化程序设计。

附录

C 语言预处理命令是什么?
C 语言#define 的用法,C 语言宏定义
C 语言#if、##ifdef、#ifndef 的用法详解,C 语言条件编译详解
多文件编程
如何进行 C 语言多文件编程
C 语言多文件编程基本格式
C 语言基础:多文件编译
头文件重复引用