C语言篇:预处理

概念

预处理指令是C语言里非常特殊的存在,与其说它是C语言的一部分,不如说它是编译器的一部分。C语言的其他语法都旨在生成机器码,作用在运行时;而预处理指令的目的是操控编译器修改源代码,作用在编译时(准确来说是编译前)。预处理指令的语法不仅特殊,也非常独立,跟其他语法几乎没有关联,甚至就算不是C语言源文件,也可以通过预处理器进行处理。

预处理发生在翻译阶段4,早于编译(阶段7),可以提供一定的元编程能力。当前预处理的文件(一般是源文件)连同所有依赖的文件(通过#include指令)统称为一个预处理单元。预处理的任务就是就是把一个预处理单元转换为一个翻译单元(可以输出为单个源文件),供后续翻译阶段(主要是编译)使用。

预处理指令都以#开头,并且#必须位于行首(前面可以有空白字符)。#和指令名之间也可以有换行符以外的空白字符。C语言中的大部分语法都以分号结尾,换行符没有特殊意义,而预处理指令则都以换行符结尾。这意味着预处理指令必须是单独的一行。不过根据翻译阶段的顺序,可以在预处理之前在行尾使用\把多行合并成一行。

单个#作为一行称为空指令,没有任何作用。

除非专门说明,预处理指令中的预处理标记不会进行宏替换。

宏的内容太多,所以我专门为其写了一篇文章。

C语言篇:宏

源文件包含

C语言标准乱起名字,虽然叫"源文件包含",但实际上任何文件都可以包含进来,而且主要是头文件。

该预处理指令主要有两种形式:

c 复制代码
#include <target>
#include "target"

如果用<>包围目标,则从实现定义的目录中查找指定的文件。如果用""包围目标,也是从一个实现定义的目录(可能与前面的目录不同)中查找指定的文件,如果没找到,就回退到<>再找一次。

如果找到了,则用目标文件的内容替换这条指令,否则报错。

如果#include指令不符合上面的两种格式,则对后面的预处理标记进行宏替换。如果宏替换后的指令符合上面的格式,则按上面的规则进行处理,否则报错。

比如:

c 复制代码
#define HEADER <stdio.h>
#include HEADER

被包含进来的文件会依次执行翻译的前4个阶段,如果里面还有#include指令,会递归进行包含。

C语言标准还规定了编译器必须支持的目标文件的最低要求,比如.前不超过8个字符、.后必须1个字符、不能以数字开头。但大部分编译器对目标文件基本没有限制。

这满篇的"实现定义"是不是让人头皮发麻,经典的C语言标准行为。那么我们就来看一看主流编译器gcc的情况。

如果用""包围目标,依次搜索:

  1. 源文件所在目录。注意不是编译器的工作目录
  2. 通过参数-iquote指定的目录
  3. 回退到<>的情况

如果用<>包围目标,依次搜索:

  1. 通过参数-I指定的目录
  2. 通过参数-isystem指定的目录
  3. 标准系统目录
  4. 通过参数-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参数则是添加到之后。它们都有一个被()括起来的预处理标记序列作为参数。

还是以内容为abcfile为例,对于下面的预处理指令:

c 复制代码
#embed "file" prefix(50,) suffix(,60)

预处理的结果是:

c 复制代码
50, 97,98,99 ,60

prefixsuffix都是按原样放置参数,如果要添加二进制数据,需要自己写逗号。

如果目标文件为空,则prefixsuffix不起作用。

if_empty参数用于指定当目标文件为空时的替换序列。

c 复制代码
#embed "file" if_empty(1,2,3)

如果file不为空,则if_empty不起作用。如果file为空,该指令被替换为1,2,3

这三个参数可以同时出现,由于if_emptyprefix/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参数比较特别,它必须是十进制数字序列,而不是整数常量表达式。32001是可以的,0x11+23L都不行。

#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被替换为1false被替换为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个宏分别为012。示例如下:

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
相关推荐
少陵野小Tommy3 小时前
C语言计算行列式的值
c语言·开发语言·学习·学习方法
迎風吹頭髮4 小时前
UNIX下C语言编程与实践23-模拟 UNIX ls -l 命令:lsl 程序的设计与实现全流程
服务器·c语言·unix
njxiejing5 小时前
C语言中的scanf函数(头文件、格式控制、取地址符号分析)
c语言·开发语言
学不动CV了8 小时前
C语言(FreeRTOS)中堆内存管理分析Heap_1、Heap_2、Heap_4、Heap_5详细分析与解析(二)
linux·c语言·arm开发·stm32·单片机·51单片机
头发还没掉光光14 小时前
C++STL之list
c语言·数据结构·c++·list
坚持编程的菜鸟17 小时前
LeetCode每日一题——交替合并字符串
c语言·算法·leetcode
xingke18 小时前
从C语言标准揭秘C指针:第 8 章:二维数组与指针:多维内存的访问逻辑
c语言·指针·c语言标准
迎風吹頭髮19 小时前
UNIX下C语言编程与实践22-UNIX 文件其他属性获取:stat 结构与 localtime 函数的使用
c语言·chrome·unix
迎風吹頭髮19 小时前
UNIX下C语言编程与实践21-UNIX 文件访问权限控制:st_mode 与权限宏的解析与应用
c语言·数据库·unix