概念
预处理指令是C语言里非常特殊的存在,与其说它是C语言的一部分,不如说它是编译器的一部分。C语言的其他语法都旨在生成机器码,作用在运行时;而预处理指令的目的是操控编译器修改源代码,作用在编译时(准确来说是编译前)。预处理指令的语法不仅特殊,也非常独立,跟其他语法几乎没有关联,甚至就算不是C语言源文件,也可以通过预处理器进行处理。
预处理发生在翻译阶段4,早于编译(阶段7),可以提供一定的元编程能力。当前预处理的文件(一般是源文件)连同所有依赖的文件(通过#include
指令)统称为一个预处理单元。预处理的任务就是就是把一个预处理单元转换为一个翻译单元(可以输出为单个源文件),供后续翻译阶段(主要是编译)使用。
预处理指令都以#
开头,并且#
必须位于行首(前面可以有空白字符)。#
和指令名之间也可以有换行符以外的空白字符。C语言中的大部分语法都以分号结尾,换行符没有特殊意义,而预处理指令则都以换行符结尾。这意味着预处理指令必须是单独的一行。不过根据翻译阶段的顺序,可以在预处理之前在行尾使用\
把多行合并成一行。
单个#
作为一行称为空指令,没有任何作用。
除非专门说明,预处理指令中的预处理标记不会进行宏替换。
宏
宏的内容太多,所以我专门为其写了一篇文章。
源文件包含
C语言标准乱起名字,虽然叫"源文件包含",但实际上任何文件都可以包含进来,而且主要是头文件。
该预处理指令主要有两种形式:
c
#include <target>
#include "target"
如果用<>
包围目标,则从实现定义的目录中查找指定的文件。如果用""
包围目标,也是从一个实现定义的目录(可能与前面的目录不同)中查找指定的文件,如果没找到,就回退到<>
再找一次。
如果找到了,则用目标文件的内容替换这条指令,否则报错。
如果#include
指令不符合上面的两种格式,则对后面的预处理标记进行宏替换。如果宏替换后的指令符合上面的格式,则按上面的规则进行处理,否则报错。
比如:
c
#define HEADER <stdio.h>
#include HEADER
被包含进来的文件会依次执行翻译的前4个阶段,如果里面还有#include
指令,会递归进行包含。
C语言标准还规定了编译器必须支持的目标文件的最低要求,比如.
前不超过8个字符、.
后必须1个字符、不能以数字开头。但大部分编译器对目标文件基本没有限制。
这满篇的"实现定义"是不是让人头皮发麻,经典的C语言标准行为。那么我们就来看一看主流编译器gcc的情况。
如果用""
包围目标,依次搜索:
- 源文件所在目录。注意不是编译器的工作目录
- 通过参数
-iquote
指定的目录 - 回退到
<>
的情况
如果用<>
包围目标,依次搜索:
- 通过参数
-I
指定的目录 - 通过参数
-isystem
指定的目录 - 标准系统目录
- 通过参数
-idirafter
指定的目录
所谓"标准系统目录"就是C语言标准库提供的头文件目录,系统调用还有第三方库一般也会把头文件放在这里。标准系统目录一般有多个位置,gcc的目标系统、构建配置和安装路径都会影响其具体位置。
参数-I
和-isystem
的区别是通过-isystem
指定的目录会被当成系统目录,从里面找到的文件在编译时一般不会触发警告。
人们习惯上用-I
参数指定C语言项目目录,在源文件中使用""
包含自己的头文件,使用<>
包含标准头文件。
不管""
还是<>
,目标不仅可以是文件名,也可以加上路径。比如gcc会搜索系统目录/usr/include
,如果我写#include <net/route.h>
,那么文件/usr/include/net/route.h
就是匹配的。我甚至可以写绝对路径,不过不推荐。
被括起来的目标字符串不会进行任何扩展,所以\
会按原样保留,不会被当成转义符。因此Windows路径直接复制过来也能照常查找,不过gcc可以处理/
分隔的windows路径,所以都写/
更加可移植。
二进制资源包含
这是C23引入的重要功能,可以把任何文件以二进制的形式嵌入到C语言代码中。
它的基础语法与#include
一致:
c
#embed <target>
#embed "target"
同样,<>
和""
的区别就是搜索目录不同,如果""
没找到会回退到<>
。
如果#embed
指令不符合上面的格式,也会对后面的预处理标记进行宏替换。如果宏替换后的指令符合上面的格式,则按上面的规则进行处理,否则报错。
如果找到了目标文件,则把目标文件的二进制数据映射成由逗号分隔的整数常量表达式序列,并替换掉这条指令。没找到则报错。
C语言标准还叽里咕噜说了一堆,比如单位之类的,不过说了等于没说,我们直接看gcc的实现。
对于<>
包围的目标,没有默认搜索目录,必须通过参数--embed-dir
指定。
对于""
包围的目标,先搜索源文件所在目录,如果没找到就回退到<>
的情况。
绝对路径依然是可以的。
gcc直接把目标文件的每个字节 转换成整数字面量,并用逗号分隔。
比如我有一个文件,名为file
,其内容使用ASCII编码:
abc
我在源文件中写:
c
#embed "file"
预处理的结果是:
c
97,98,99
#embed
指令最典型的用途是初始化unsigned char[]
类型的数组,这样就可以在编译时把一整个文件的数据都存入可执行文件中,并在运行时使用。举例如下:
c
static const unsigned char data[] = {
#embed "file"
}
unsigned char
大小为一个字节,并且不受符号限制,是处理二进制数据的最好选择。静态生存期 是有必要的,否则运行时内存中会出现相同数据的多份拷贝。const
根据自己是否有修改的需求可有可无。
#embed
指令还支持一些参数。
limit
参数用于限制嵌入的字节数,它自身拥有一个被()
括起来的整数常量表达式作为参数。
c
#embed "file" limit(5)
上面的预处理指令只读取file
的前5个字节。如果file
不足5个字节也不会报错,有几个就读几个。
prefix
参数用于把额外内容添加到嵌入的数据之前,suffix
参数则是添加到之后。它们都有一个被()
括起来的预处理标记序列作为参数。
还是以内容为abc
的file
为例,对于下面的预处理指令:
c
#embed "file" prefix(50,) suffix(,60)
预处理的结果是:
c
50, 97,98,99 ,60
prefix
和suffix
都是按原样放置参数,如果要添加二进制数据,需要自己写逗号。
如果目标文件为空,则prefix
和suffix
不起作用。
if_empty
参数用于指定当目标文件为空时的替换序列。
c
#embed "file" if_empty(1,2,3)
如果file
不为空,则if_empty
不起作用。如果file
为空,该指令被替换为1,2,3
。
这三个参数可以同时出现,由于if_empty
和prefix
/suffix
的条件是互斥的,所以一定情况下只有一方会起作用。注意,limit(0)
表示文件为空,从而影响其他参数的作用。
行控制
C语言标准提供了两个预定义宏__LINE__
和__FILE__
,分别替换为当前行号和文件名。
#line
指令用于修改这两个宏的值,它有两个基本形式:
c
#line number
#line number "name"
如果#line
指令不符合上面的形式,则对后面的预处理标记进行宏替换。如果宏替换后的指令符合上面的格式,则按上面的规则进行处理,否则报错。
从#line
指令的下一行开始,行号被强行修改为number
,然后递增,文件名被修改为name
。
比如:
c
#line 5 "a.c"
__LINE__
__FILE__
__LINE__
预处理的结果为:
c
5
"a.c"
7
注意,#line
指令的number
参数比较特别,它必须是十进制数字序列,而不是整数常量表达式。32
、001
是可以的,0x1
、1+2
、3L
都不行。
#line
指令主要用于调试和生成的代码中。
诊断指令
预处理器读取#error
指令后,会打印一条错误信息,之后结束翻译。错误信息包含#error
指令的参数。
比如:
c
#error a b c
gcc预处理器会打印下面的内容,然后退出。
arduino
error: #error a b c
C23新增了#warning
指令,与#error
类似,但不会结束翻译。
习惯上把诊断指令的参数写为单个字符串字面量。
比如:
c
#warning "This is a warning."
Pragma指令与操作符
#pragma
指令非常自由,它的作用就是把参数传递给编译器,如果编译器能识别这些参数,就产生相应作用,否则就忽略这条#pragma
指令。
由于它的本质只是转发编译器命令,所以这条指令的语法乱七八糟,实际作用也五花八门,完全取决于编译器。也因此几乎不可移植。
C语言标准只规定了几个通用的#pragma
指令,用于控制浮点、复数等功能,它们都以STDC
开头。
比如:
c
#pragma STDC FP_CONTRACT on-off-switch
#pragma STDC FENV_ACCESS on-off-switch
#pragma STDC CX_LIMITED_RANGE on-off-switch
C99引入了_Pragma
操作符,它的作用与#pragma
指令相同,但作为操作符可以出现在宏定义中。
下面两段代码作用相同:
c
#pragma STDC FP_CONTRACT ON
c
_Pragma("STDC FP_CONTRACT ON")
_Pragma
的参数必须是字符串字面量,然后用()
括起来。如果参数包含\
或"
,则需要转义。
因为_Pragma
可以出现在宏定义中,所以上面的代码还可以写成这样:
c
#define FP_CONTRACT(ARG) GEN_PRAGMA(STDC FP_CONTRACT ARG)
#define GEN_PRAGMA(ARG) _Pragma(#ARG)
FP_CONTRACT(ON)
有一条#pragma
指令被广泛使用,几乎得到所有编译器的支持,但至今没有被纳入C语言标准。它就是:
c
#pragma once
如果把它加入头文件中,那么该头文件就不会被重复包含。该方案比下文提到的"包含守卫"简洁优雅得多。
条件包含
C语言标准又一例乱起的名字,提到"包含"人们还以为是#include
指令,但这里指的其实是#if
、#elif
、#endif
等指令。
它最基本的语法是:
c
#if condition
...
#endif
如果condition
为真,那么#if
指令到#endif
指令之间的内容就会被保留,否则直接删除。
进阶些的语法是:
c
#if condition
...
#else
...
#endif
如果condition
为真,那么#if
指令到#else
指令之间的内容被保留,#else
指令到#endif
指令之间的内容被删除。否则,保留和删除的内容互换。
被删除的内容语法检查比较宽松,只要其中的内容没有被识别为条件包含指令,都不会检查语法错误。
比如:
c
#if 0
#aaa bbb
#include a b c
#endif
代码中存在两处明显错误的预处理指令,但因为这段代码要被删除,所以不会产生任何错误。
条件包含可以嵌套,比如:
c
#if condition1
#if condition2
...
#else
...
#endif
#endif
因为每一组条件包含都有#endif
作为结束标志,所以不会出现if
语句中else
的归属问题。
如果#else
中嵌套了#if
,比如:
c
#if condition1
...
#else
#if condition2
...
#else
...
#endif
#endif
可以简写为:
c
#if condition1
...
#elif condition2
...
#else
...
#endif
#if
指令的参数是一个整数常量表达式,如果值为0
表示假,否则为真。它可以使用C语言的所有算数运算符,但每个操作数的数据类型为当前系统支持的最大整数类型(有符号数为intmax_t
,无符号数为uintmax_t
)。
该表达式会进行宏替换,如果宏未定义,则替换为0
。由于在翻译阶段中预处理在编译之前,所以编译期的关键字、enum
常量等元素在此时都被当成是宏,typeof
在该表达式中是不能使用的。
除了宏替换以外,还有一些特殊表达式会被替换为整数。
defined
表达式有两种形式,作用相同:
c
defined argument
defined(argument)
如果argument
是已经被定义的宏,则该表达式被替换为1
,否则为0
。示例如下:
c
#if defined(__STDC_NO_VLA__)
#error "does not support VLA"
#endif
从C23开始,true
被替换为1
,false
被替换为0
。
C23还添加了__has_include()
、__has_embed()
、__has_c_attribute()
3个表达式,并且规定__has_include
、__has_embed
、__has_c_attribute
都被视为已经定义的宏,从而可以用defined
验证是否支持该功能。
__has_include(argument)
会按照#include
指令的规则查找argument
,如果找到了就替换为1
,否则替换为0
。示例如下:
c
#if !defined(__has_include)
#error "does not support __has_include"
#endif
#if __has_include(<threads.h>)
#include <threads.h>
#else
#include <pthread.h>
#endif
__has_embed(argument)
会按照#embed
指令的规则查找argument
,如果没找到就替换为宏__STDC_EMBED_NOT_FOUND__
,如果找到了且目标文件不为空则替换为宏__STDC_EMBED_FOUND__
,如果找到了但目标文件为空则替换为宏__STDC_EMBED_EMPTY__
。这3个宏分别为0
、1
、2
。示例如下:
c
#if !defined(__has_embed)
#error "does not support __has_embed"
#endif
#if __has_embed("file") == __STDC_EMBED_NOT_FOUND__
#error "file does not exist"
#elif __has_embed("file") == __STDC_EMBED_EMPTY__
#error "file is empty"
#endif
__has_c_attribute(argument)
用于判断实现是否支持某个属性,如果支持则替换为非0
整数,否则替换为0
。示例如下:
c
#if defined(__has_c_attribute)
#if __has_c_attribute(deprecated)
[[deprecated]]
#endif
#endif
void fn();
#if defined
可以简写为#ifdef
,#if !defined
可以简写为#ifndef
。C23又加了两条,#elif defined
可以简写为#elifdef
,#elif !defined
可以简写为#elifndef
。
这些简写后的指令都只能接受一个标识符,而不是一个表达式,所以表达力有所下降。比如:
c
#if defined A || defined B
#warning "blabla"
#endif
若使用缩写的指令,只能写为:
c
#ifdef A
#warning "blabla"
#elifdef B
#warning "blabla"
#endif
条件包含经常被用于防止头文件被重复包含,称为"包含守卫"。示例如下:
c
// header start
#ifndef A_UNIQUE_MACRO_NAME
#define A_UNIQUE_MACRO_NAME
...
#endif
// header end