Skip to content

the C Pre-Processor(CPP) usage

这篇文章基于 GNU CPP 手册,对应的 gcc 版本为 15.0,主要是想重温一下 CPP 的功能,于是进行了手册的阅读。下面的文章只对我自己注意到的几个关键点进行提及,并不涵盖全部的手册内容。

可以参考的文章:

  1. C/C++ 宏编程的艺术
  2. C/C++ 宏详解
  3. 宏定义的黑魔法 - 宏菜鸟起飞手册

预处理器功能

简单的来讲 CPP 就是先于编译器之前对源文件进行相关的处理,进行处理之后方便后续的编译器进行编译。对于 CPP 而言,其主要的操作包括:

  1. 对字符集的识别和处理
  2. 对注释的相关处理
  3. 进行词法分析(tokenize),生成的结果将交给后续的编译器使用。
  4. 对于语言的预处理,这又包括:
    1. 进行头文件的查找和包含。
    2. 宏定义和宏定义展开。
    3. 条件编译。
    4. 行控制。
    5. 相关的诊断。

对于上面的第 4 点可能才是我们比较熟悉的预处理的过程。对于第 1、2 两点,其很重要的工作就是将接收到的源文件进行简化,比如说将多个空格消除为 1 个空格,比如各种注释也被消除为 1 个空格,简化后的文本方便进行词法分析。

上面提到的预处理的功能是 gcc 内部预处理器的功能,包括文档中提及的功能也是 gcc 内部预处理器 CPP 实现的,与 m4 无关。gnu m4 是一个更加强大和可定制的预处理器,是另外一套独立的预处理器系统。

头文件处理

CPP 对于头文件的处理就是找到头文件并将其简单包含到源文件中。这里比较重要的是找头文件相关的内容。

头文件查找

对于头文件的查找,如果在 include 中头文件是以相对路径方式给出,直接先以当前目录为开始点开始寻找;如果以绝对路径的方式给出,就按照绝对路径的方法找,找不到报错。如果是以相对路径的方式找不到、还有给出的方式就是单个文件名(也可以理解成特殊的相对路径方式吧),头文件的查找会按照编译器中指定的路径往下查找,编译器指定的查找路径我们已经再熟悉不过了。在编译的时候,也可以通过-I的选项将相关的路径添加到头文件的查找路径中,-I添加路径的优先级高于编译器指定的查找路径优先级。

面对多个头文件中的相同定义

有时候你 #include"a.hh" 在查找的路径下存在多个 a.hh,根据规则,优先被找到的会被包含,后面的都会被忽略,但是有的时候,你就是想要包含后面的那个头文件,但是由于头文件查找顺序的问题,它无法被包含。CPP 提供了 #include_next 预处理指令来解决这个问题,通过这个指令,被包含的头文件是所有查找路径中第二个被找到的头文件。一般不建议使用这个指令,因为不同系统的查找路径不尽相同,使用这个指令可能会带来错误。

宏处理

预处理器大部分的篇章都集中在宏处理上,宏处理的部分相当的复杂,不细读文档无法真正理解,这里只写一点我浅读文档带来的理解。

对象式的宏

对象式的宏的定义为 #define BUFFER_SIZE 1024 类似这样的定义。这种宏的展开发生在预处理器扫描到某一行的时候,当预处理器扫描到某一行的时候,其对这个宏进行展开,展开成相关的宏定义。由于一个宏在文件中可能会被重复的undefdefine,因此其在文件中不同行的范围可能又不同的值,宏的扫描到才展开的机制就表示以扫描到某一行的时候其定义为准。

函数式的宏

函数式的宏定义如 #define min(X, Y) ((X) < (Y) ? (X) : (Y)),很像执行某个函数,但是实际上进行的是文本替换。函数式的宏只有在宏名称后紧跟着双括号(括号内可以有参数)的情况下才会进行宏的展开,单单给出宏的名称或者宏的名称加上一些非括号的其他符号是不会进行展开的,最典型的例子如下:

c
extern void foo(void); 
#define foo() /* optimized inline version */ 
//... 
foo(); 
funcptr = foo;

这段代码中由于第四行 foo() 紧跟着双括号,这里会被进行宏展开。但是第五行的 funcptr = foo 由于后面没有跟双括号,因此不会进行宏展开,他进行的是函数指针的获取。

还有需要注意的点是函数式的宏在定义的时候宏名称后面需要紧跟括号,不能有空格,像这种 #define lang_init () c_init() 宏名称后面空了一格,这个宏直接被认为是对象式的宏,会被展开成 () c_init()

函数式宏的传参

函数式的宏传参数的时候涉及到传入的参数可能也会进行宏展开,因此这部分比较复杂,暂时不考虑这种情况。针对普通的函数式宏的传参数,参数之间需要用逗号间隔,参数之间可以有空格。如果传入的参数个数不同于宏定义时候的个数会报错。

字符串化与拼接

在函数式的宏定义中,使用 # 进行字符串化:

c
#define f(x) {printf(#x);}

这样在对 f 这个函数式的宏进行展开的时候,x被字符串化,比如 f(hello) 被展开成 {print("hello")},实际上字符串化就是对传入的参数加上双引号。

使用 ## 可以进行拼接:

c
#define f(x) x ## _temp

这样在对这个宏进行展开的时候,x会与后面的部分产生拼接,比如 f(aa) 被展开成 aa_temp,实际上就是进行前后的拼接 ## 前后的空格会被忽略。

宏中的可变参数

宏中的可变参数有两种形式:

c
#define eprintf_1(...) fprintf (stderr, __VA_ARGS__) // format1
#define eprintf_2(args...) fprintf (stderr, args)    // format2

两种形式都用 ... 指名了宏当中有可变的参数,第一种形式用 __VA_ARGS__ 在宏定义中使用了可变参数,第二种形式用自定义的名称 args 指代了可变参数,第一种形式更加常见。在可变参数的情况下,在可变参数的前面也能有一些正常的参数:

c
#define f(a1, ...) func(a1, __VA_ARGS__)

这种情况下在进行宏展开的时候,第一个参数会给到 a1,后续的参数应该会被放在可变参数中。

在使用可变参数的过程中可能会遇到宏定义中多余逗号的问题,考虑以下的宏定义:

c
#define eprintf(format, ...) fprintf (stderr, format, __VA_ARGS__)

如果传入的可变参数为 0 个,那展开就变成了 fprintf (stderr, format,),多出来了一个逗号,为了解决这个问题,修改宏定义如下:

c
#define eprintf(format, ...) fprintf (stderr, format, ##__VA_ARGS__)

即在 __VA_ARGS__ 加上两个井号解决逗号的问题,这两个井号并没有特殊的含义,在这里只是用来解决这个问题。

预处理器相关的命令行参数和环境变量

由于预处理器执行的工作多于宏展开和头文件查找相关,因此其命令行参数和环境变量同样如此。

环境变量

值得关注的环境变量是 CPATHC_INCLUDE_PATHCPLUS_INCLUDE_PATH 环境变量,这几个环境变量实际上就是指定头文件的查找路径,设置这几个环境变量相当于把一些路径加入到 -I 指定的路径中,其他通过命令行指定的搜索路径和系统原先的搜索路径都排在这几个环境变量设置的路径后面。

命令行参数

  1. -nostdinc:忽略系统的头文件搜索路径,只搜 -I 参数指定的路径。
  2. -nostdinc++:忽略系统c++头文件的搜索路径。
  3. -I:添加头文件的搜索路径。
  4. -pthread:开启posix线程支持。
  5. -undef:消除某个宏的定义。
  6. -D name:定义某个宏,将其值设定为1.
  7. -D name=definition:定义某个宏,更加精细的设定。