C

C学习笔记-编译预处理指令

Posted by zihengCat on 2017-05-19

摘要

本篇讲解C语言中的编译预处理指令。

前言

C代码的编译过程大致需要经过如下几个阶段:

源代码 --> 编译预处理 --> 编译 --> 汇编 --> 链接 --> 可执行文件

其中,编译预处理是第一阶段,由编译预处理器完成。
C语言在设计之初,并没有考虑设计机制满足如下需求:

  • 包含外部文件至源代码
  • 定义宏
  • 根据条件编译代码

为了满足这些需求,诞生了编译预处理指令。编译预处理指令是为了补足C的不足,如今,编译预处理指令已经成为了C不可或缺的一部分。
编译预处理过程一般由编译预处理器完成,如今的C编译器一般都集成了编译预处理器。
编译预处理器读入程序员写的源代码文件,检查编译预处理指令,对源代码进行文本级的替换,同时,对源文件中多余空白符的删减和注释的删除,也是在编译预处理阶段完成的。
编译预处理指令是以#号开头的代码行。#号必须是该行除了任何空白字符外的第一个字符。#后跟随指令关键字,在关键字和#号之间允许存在任意个数的空白符。整行语句构成了一条完整的编译预处理指令。

文件包含(#include)

#include编译预处理指令可以方便的将外部文件包含进源代码。
注意,文件包含指令只是简单的复制粘贴而已,将目标文件中的所有内容复制粘贴到源文件#include所在行。#include指令不是将某某库引入,只是简单的复制粘贴。#include也不只是可以包含.h头文件,它可以包含任意外部文件。
#include预处理指令是支持嵌套的,也就是说,我们可以在被包含的文件中继续使用#include包含其他的文件,标准C预处理器至少支持8重嵌套包含。

头文件

在C中,头文件header file的使用是非常普遍的。所谓头文件,就是一般放在源代码开头位置的文件。C严格区分声明declaration与定义definition

  • 全局函数声明
  • 外部变量声明(extern)
  • 自定义数据结构(枚举, 结构体)

一般来说,我们将上述声明内容放入头文件,并在需要用到的时候使用#include预处理指令将它们包含进来。
在源代码中包含头文件一般有两种形式:

#include <header.h>
#include "header.h"

一种是使用尖括号<...>,一种是使用双引号"..."  。它们的区别是,使用尖括号包含,编译预处理器会去编译器默认头文件目录中搜寻目标文件;使用双引号包含,编译预处理器会先在当前目录源代码目录中搜寻目标文件,如果找不到,再去系统头文件目录中搜寻。

头文件包含可以使用路径,绝对路径或相对路径:

#include <sys/header.h>          /* 包含系统头文件目录下某头文件 */
#include "./header/header.h"     /* 包含当前目录下header目录中的某头文件 */

使用两种形式的包含指令,可以让程序员们明确的分清,使用的是系统头文件,还是自己写的头文件。可以使预处理器工作得更加高效。

标准头文件结构

由于#include 包含替换不检查在被包含文件中是否已经包含了某个文件,不阻止对某文件的多次包含。重复包含头文件,在编译阶段有可能出现重复定义type redefination的错误。
所以,我们需要有某种机制来避免文件重复包含的问题: 标准头文件结构

#ifndef _HEADER_H_
#define _HEADER_H_
/* ... */  //头文件内容
#end if

上述代码表示,如果没有定义*_HEADER_H_*这个宏的话,就定义这个宏,并包含相应头文件内容。如果已经定义了(说明已经包含过了),就不会包含头文件内容啦。
宏名前后加下划线,并带上H,是为了避免重复。
使用标准头文件结构可以避免文件重复包含的问题,我们在写代码时要注意使用它。

宏定义

macro: 根据一系列预定义的规则替换文本

通俗的讲,宏就是一段简单的文本可以扩展出大量的代码。
宏名的定义规则与C标识符的定义规则是相同的。一般我们习惯将宏名全部大写,这样可以醒目的辨识出这是一个宏。

宏定义指令(#define)

使用宏定义指令#define可以定义一个宏:

#define MACRO   /* 定义宏MACRO */

虽然这看起来没啥用,只是告诉预处理器我定义了这样一个宏。但是有时候,这是很有用的,比如用在标准头文件结构中。

使用宏定义指令#undef可以取消定义一个宏:

...
#undef MACRO    /* 取消定义宏 MACRO */

使用#undef可以取消定义一个宏,一个宏的作用域范围是从#define定义开始至#undef定义结束之间的代码段。如果没有#undef,那么一个宏的作用范围就是从它定义开始至该源文件结束。

宏替换(#define)

宏定义#define定义一个标识符。预处理过程会把源代码中出现的宏标识符替换成宏定义时的值。宏最常见的用法是定义常量

#define PI        3.14       /* 定义宏PI 值为3.14      */
#define MAX_NUM   128        /* 定义宏MAX_NUM 值为128  */
#define STR       "string"   /* 定义宏STR 值为"string" */
...
int array[MAX_NUM];    /* MAX_NUM被替换成128 */
...
printf("MAX_NUM");     /* 不会进行宏替换 */
...

使用宏定义常量非常简单,#define指令 + 空白符(不包括换行符) + 替换值。
使用宏定义常量有诸多好处:

  • 程序少些幻数(magic number)
  • 一处更改,处处更改
  • 货真价实的常量(const是变量)

需要注意的是,宏替换只是文本级别的替换,想成findreplace是很合适的。许多初学者容易在这里犯糊涂。此外,在字符串中,宏替换是不会发生的,如上面的printf语句。

带参数的宏(#define函数)

我们也可以定义出带参数的宏:

#define SQUARE(x)   ((x)*(x))
...
int num = 10;
int sq_num = SQUARE(num);
/* 宏展开后变成:
   int sq_num = ((num)*(num)); */
...

这种带参数的宏看着与函数非常相似,但带参数宏与C函数还是有很大区别的:

  • 带参数宏只是将代码粘贴替换到指定位置,不存在函数堆栈开辟与参数返回的过程
  • 宏不进行参数类型检查(只是文本替换嘛,参数类型检查是编译时做的)
  • 宏函数数很不安全

为了避免参数宏展开后出现各种奇葩的问题,前辈们建议我们: 如果要使用宏函数,请把函数参数,还有整个函数体,都用括号括起。
但是啊,即使是这样,宏函数还是很不安全的…

#define SQUARE(x)   ((x)*(x))
...
int num = 10;
int sq_num = SQUARE(num++);    /* undefined */

char *s = SQUARE("hello");     /* error */
...

上面的代码,宏展开之后,编译就过不了啦。
还有一点需要注意的地方,宏替换文本的末尾,不应该加分号;。这并不是说不能加个分号,只是,每条C语句的结束要以分号标识, 如果在宏定义末尾加分号,宏展开之后就会有两个分号,编译会报错…所以我们习惯不在宏定义中加分号,这样宏展开后可以构成符合标准的C语句。

字符串化操作符(#)

在带参数的宏中,使用#符号可以字符串化宏参数。
例如,如果我们想打印出变量名,可以怎么做?似乎不容易做到吧。
使用字符串化操作符就可以很方便的做到。

#include <stdio.h>
#define PRT(x)  \
        printf(#x)  /* 将参数x字符串化 */
int main(void){
    int var_name = 0;
    PRT(var_name);  /* 宏展开后变成 printf("var_name"); */
    return 0;
}

如果参数本身带有双引号",宏替换过程中会自动转义引号。
如果参数带空白符,宏替换会忽略掉空白符。

标记粘连操作符(##)

标记粘连操作符##可以将两个宏标记粘连成一个。

#include <stdio.h>
#define FUNC_STR(name, str)\
        func##name(str)

void func1(char* s);    /* do some things */
void func2(char* s);    /* do other things */
int main(void){
    char *s = "hello";
    FUNC_STR(1, s);     /* 宏展开后变成 func1(s); */
    FUNC_STR(2, s);     /* 宏展开后变成 func2(s); */
    return 0;
}

##操作符还可以用来生成函数,非常强大哦。

条件编译(#if)

条件编译宏指令的产生主要是为了解决根据条件编译代码的问题。程序员可以通过定义宏来决定在编译过程中对源代码有选择的进行处理。通俗的说,就是编译器能不能看到某些代码。条件编译指令将决定那些代码会被编译,而哪些不会被编译的。编译条件就是宏表达式的值。

#if <expression>
/* ... */
#elif <expression>
/* ... */
#else 
/* ... */
#endif

其中,<expression>是可以一个常量表达式或是一个标识符。根据表达式的值true, flase, 预处理器保留相应的代码段,清除其他代码段,最终编译器看到的代码文件不会被删除掉的代码段。

求值规则:

  • 常量表达式(标准C常量表达式)
    • 0为false,其余为true
  • 标识符(标准宏标识符)
    • 依据defined()求值
    • 其余均为0

这里有个依据defined()求值,其实就是判断这个宏有没有定义过。

#if defined(A)
/* ... */
#else
/* ... */
#endif

上述代码,如果宏A被定义过,保留if下的代码段,如果没有被定义过,保留else下的代码段。
还有一种更加简洁明了的写法: #ifdef, 以及#ifndef, 我们在标准头文件结构中已经见过它们啦。

  • #if
  • #elif
  • #ifdef
  • #ifndef
  • #endif

需要注意的是,条件编译#if指令结束不要忘记加上#endif哦。

预定义宏

ANSI C标准定义了许多预定义宏。我们可以在编程中使用这些宏,但是不应该修改这些预定义宏。

预定义宏 功能描述
__DATE__ 当前日期,一个以 “MMM DD YYYY” 格式表示的字符常量。
__TIME__ 当前时间,一个以 “HH:MM:SS” 格式表示的字符常量。
__FILE__ 当前文件名,一个字符串常量。
__LINE__ 当前代码行号,一个十进制常量。
__STDC__ 当前编译器以ANSI C标准编译时,整型值为1
#include <stdio.h>

int main(void){

    printf("File :%s\n", __FILE__ );
    printf("Date :%s\n", __DATE__ );
    printf("Time :%s\n", __TIME__ );
    printf("Line :%d\n", __LINE__ );
    printf("ANSI :%d\n", __STDC__ );

    return 0;
}

编译执行,结果为:

File :test.c
Date :Jun  9 2017
Time :20:53:42
Line :8
ANSI :1

参考文档

更新日志

  • 2017-05-19 –> 完成[初稿]
  • 2017-06-09 –> 增添[预定义宏]
  • 2017-06-13 –> 增添[#, ##内容]