17. 预处理与翻译
预处理指令在源文件进入完整的语法分析前生效,处理结果随后参与其余翻译阶段。本章研究单个源文件中的翻译阶段、宏替换、条件编译和其他标准预处理指令;自建头文件及跨文件接口在第二部分学习。
1. 本章内容
- 翻译阶段
#define:定义和取消定义宏。- 条件编译:按条件保留或排除源码。
#error与#warning:在翻译阶段产生诊断。#embed:把资源内容嵌入源码。#pragma:使用实现定义的预处理指令。#line:改变后续诊断使用的行号和文件名。
阅读宏代码时,应先确认展开后的预处理记号,而不是把宏当作普通 C 函数。
2. 习题
下面的程序把一层宏套在另一层宏外面,先不运行,写出每一行的输出:
#include <stdio.h>
#define STR(x) #x
#define XSTR(x) STR(x)
#define CAT(a, b) a##b
#define XCAT(a, b) CAT(a, b)
int main(void) {
puts(STR(CAT(1, 2)));
puts(XSTR(CAT(1, 2)));
printf("%d\n", CAT(1, 2));
printf("%d\n", XCAT(1 + 1, 2));
puts(STR(a/**/b));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
- 逐行写出输出。
- 说明
STR(CAT(1, 2))与XSTR(CAT(1, 2))结果不同的原因,并指出这条规则在写“把宏的值变成字符串”时意味着什么。 XCAT(1 + 1, 2)得到什么值?左实参有三个记号,##只把其中哪一个与右实参拼接?剩下的记号去哪了?- 最后一行为什么输出
a b而不是ab?把a/**/b换成a b或ab分别会怎样? - 另写一个小程序,用
#define APP_NAME "Mdr"、#define APP_VER " C23"和puts(APP_NAME APP_VER);,先运行gcc -E -P看预处理输出,再编译运行。预处理输出里两个字符串字面量是分开的,程序却把它们当成一个;对照 17.1 的八个阶段,说明拼接发生在第几阶段,#define的替换又属于哪一阶段。 - 用
#undef把STR取消定义后,再写一次STR(x),会得到什么?如果紧接着重新定义STR为另一个替换列表,同一份源码里前后的调用各自用哪一版?
写一个配置文件 config.h,只用条件编译完成下面几件事:
- 在
__STDC_VERSION__未定义或低于201112L时用#error中止编译,消息写明需要 C11。 - 定义
MODEV:C23 及以上为3,C17 为2,C11 为1。 - 未定义
NDEBUG时用#warning提示这是调试构建。 - 在
__STDC_VERSION__ >= 202311L时定义HAVE_NULLPTR 1,否则定义为0。 - 实现支持
__has_embed时,用#embed把工作目录下的icon.bin读进一个unsigned char数组;不支持时退回一个手写的默认数组。 - 再写一个
main.c包含它,按MODEV选择初始化空指针的写法(nullptr或NULL),打印版本、指针是否为空、是否为调试构建,最后打印资源数组的字节数。
第 5 条要写成嵌套的条件块,下面这个骨架把三层分支标了出来:
#if defined(__has_embed)
#if __has_embed("icon.bin") == __STDC_EMBED_FOUND__
/* 用 #embed 引入资源 */
#else
/* 资源为空或不存在时的替代 */
#endif
#else
/* 实现不认识 __has_embed 时的替代 */
#endif2
3
4
5
6
7
8
9
用 gcc -std=c11、-std=c17、-std=c2x、加 -DNDEBUG 四种方式编译,把每次的诊断与输出记下来。本机 GCC 13.2 只认 -std=c2x,-std=c23 会报参数错误;而且在 -std=c2x 下它把 __STDC_VERSION__ 定义为 202000L。所以 HAVE_NULLPTR 会算成 0,可 nullptr 照样能用。记录这一现象,并说明用版本号当门槛与按特性本身探测各适合什么场合(后者见 18.3)。
最后回答两个问题:__has_embed 的探测为什么必须再套一层 defined(__has_embed)?去掉外层两行、直接写 #if __has_embed("icon.bin") == __STDC_EMBED_FOUND__ 再编译一次,抄下报错,说明预处理阶段把不认识的 __has_embed 当成了什么。
某二进制文件格式的记录头如下:tag 为 2 字节、len 为 4 字节、flags 为 2 字节、crc 为 4 字节,全部按小端存放,记录头总共 12 字节。
- 用
#pragma pack定义struct rec_hdr,用_Static_assert约束它的大小为 12。 - 再定义一个不加
pack的同字段结构体,打印两者的大小,并打印offsetof得到的len、crc偏移。解释两者的差别从何而来。 - 把
#pragma pack的成对写法封装成PACK_BEGIN、PACK_END两个宏,用_Pragma实现,并说明这里为什么不能用注释里的#pragma直接写进宏。 - 写一个
struct rec_hdr对象,用fwrite写入文件,读回后用fread打印四个字段,核对字节数。 - 把每个成员的偏移与手算结果对照:
pack之后len应该在第 2 字节、crc在第 8 字节。再回答:#pragma pack改变的是成员的布局还是成员的类型?如果读文件的翻译单元漏写了#pragma pack,而写文件的翻译单元写了,读回来的字段会错在哪里?用一个实验验证你的结论。
本机期望输出(字段为 0x1234、100、0x0001、0xDEADBEEF):
packed=12 natural=16 off_len=2 off_crc=8
1234 100 0001 DEADBEEF2
写一个代码生成器 gen,把数据文件里的一批传感器读数转成完整的 .c 源文件,并用 #line 把数据区的诊断映射回数据文件。生成的源文件要能直接编译运行,不依赖别的工程文件。
数据文件 sensors.dsl 每行是 名称 数值:
temp 25
humidity 60
pressure 10132
3
gen从命令行接收输入文件与输出文件两个参数,向输出文件写出一个完整的程序:结构体struct sensor、数组SENSORS[]、遍历打印的main。格式不对的行向标准错误报告数据文件名与行号后跳过。- 每条记录写成
{"名称", 数值},,前面各加一行#line指令,使该行引发的诊断指向sensors.dsl中的原行号。 - 数据写完后、
main开始之前,再写一条#line,把逻辑位置改回生成文件自身。删掉这一条重新生成一次,在main里故意引入一个语法错误,比较两次诊断报出的文件名与行号,说明生成器为什么要在数据区结束时复位。 - 编译生成的文件并运行,期望输出为三行
名称 = 数值。 - 故意把某条记录的数值改成字符串字面量,重新生成、编译,记录报出的文件名与行号,确认它指向数据文件。
- 若某条
#line只写行号、不写文件名,诊断会显示成什么?用一次实验确认。