C语言简明教程(十八)(预处理指令和C函数库完整详解)

接上一节:位操作之二进制、字节、按位操作和位字段
本节详解讨论C预处理指令和相关的函数库,例如符号常量和条件编译都是预处理指令,C库函数中有不少函数都是使用库函数的方式调用的,使用C库函数需要对C标准库的有个清楚的了解。在一些地方例如Linux内核中的预处理指令一般比较复杂,本文尽可能简单地说明白其中的原理,以Linux内核源码为目标是提升C语言技术的一个好方式。
一、编译器翻译处理一般来说,我们常说的编译器是包含翻译处理、预处理器的,翻译处理在进行预处理之前进行,翻译处理的任务主要是对源码文本做一些简单的转换和划分,翻译的任务包括:
1、将源码中的字符映射到C源字符集,这个主要是应对C国际化,C语言使用美国键盘上的标准字符,但可能本地键盘有所差别,一种是相对缺少字符,这时候可能使用三字符序列表示C的标准字符,例如??=表示#,??)表示],另一种是使用了多字节字符,例如中文字符,中文是多字节字符,一个中文gbk编码为2字节,utf-8编码为3字节。,
2、转换反斜杠内容换行符\,使用\内容换行连接符可以将一个或多个物理行转为一个逻辑行,物理行也就是形式上是多行的,逻辑行也就是翻译器转换的行形式,如下代码:

#define EXP 2.71828 #define PX(x, result) exp = (x) * (x); \ result = exp * EXP; \ printf("%d\n", result); // 使用\内容连接符,将一个或多个物理行转为一个逻辑行

3、划分源码文本,作三种划分,第一种是将文本划分成预处理记号(token)序列,根据空格、换行符和制表符进行分隔划分,划分出来的每一项又称为一个词,每个词都是一个记号(token),宏的替换体是一个记号(token)序列,记号型字符串。
第二种是划分空白序列,每个空白序列替换成一个空格,第三种是划分注释序列,每个注释序列会被替换成一个空格。翻译处理完成即进入预处理阶段,预处理阶段处理#开头的预处理指令,本文讨论的预处理指令就是在这个阶段的,注意,预处理阶段还不是编译阶段。
另外说一下程序标识符的生命周期,按先后顺序包括:翻译处理、预处理、编译、程序启动、程序执行、程序释放。上面说到的/连接符在翻译处理阶段,#define定义的标识符在预处理阶段,static变量的初始化在编译阶段确定,一般const常量在程序执行时才初始化,所以static变量初始化可以使用#define符号变量,但是不能使用const常量。
二、预处理指令预处理的实质和预处理的任务是:将一些文本转换成另一些文本,预处理得到的仍然是一般的源码文本(若需要了解编译链接详细过程,可以参考本系列教程的第二节)。预处理指令以#开头,#左右可以有空格或制表符,但一般直接和指令标识符相连#cmd,预处理指令可以出现在源文件的任何地方,#define和#include指令有效范围为:从定义开始到文件结束,其它一些指令有效范围视情况而定。
1、#define符号常量
(1)定义符号常量(宏定义)宏又可分为类对象宏和类函数宏,宏类似一个数据模板,宏实例会占用空间。宏的定义格式为:#define指令+宏(宏标识符)+替换体(宏值),预处理器处理宏时会将替换体替换函数宏标识符,将宏变成替换体文本的过程又称为宏展开,宏替换不会计算表达式,宏定义可以嵌套其它宏。另外使用#define重定义常量时,只有两者替换体完全相同才允许重定义,定义不同的替换体可以先使用#undef取消宏定义。
对于替换体的解释,有两种方式:字符型字符串和记号型字符串。字符型字符串解释将分隔符作为字符串的一部分,所以#define宏定义又称为符号常量,一般编译器采用此方式。记号型字符串解释使用空格作为分隔符,例如#define result a * b的替换体中有三个记号。
注意:宏定义在预处理中只作字符序列替换,不计算表达式,a*b和(a)*(b)不同,a*b和(a*b)也不同,具体的分析需要将替换体严格替换到指定的代码中,必要时需要使用足够多的圆括号来保证计算的准确性,下面是一些示例代码:
// 定义符号常量 // 对象宏的一般定义:宏标识符 + 替换体 #define PI 3.1415926 #define SIZE 36 #define PROCESS int a = 9; \ printf("%d\n", a) // 函数宏 #define sqrt(x) (x)*(x) #define PS(s) printf("%s\n", s)

(2)使用宏参数类函数宏可以拥有一个或多个参数,执行大量函数宏时,速度比普通函数块,但是使用更多的内存空间,执行快主要是因为内联代码,内联代码是其实就是形式上的代码是分开写的,但是预处理器或编译器会将实际的代码替换函数调用(相当于将函数的实际代码写到调用处了)。普通函数多次调用仅使用一个函数副本,调用完即可释放,另外类函数宏相对于普通函数比较复杂,函数宏有以下三点重要内容:
参数字符串化,在字符串中包含宏参数,意思就是将参数作为字符串处理,使用#运算符,最基本形式是#arg=”arg”,注意右边是带双引号的,也就是说替换过程中无论如何都会带有双引号,在字符串中使用#,需要使用双引号进行拼接字符串,例如”a “#arg” b”,替换成”a “”arg”” b”,注意到两两””构成一个字符串,编译器会进行实际拼接,但在形式上需要两两的””,示例代码如下:
// 函数宏参数字符串化 // 一般形式的字符串化参数,默认就是带双引号的 #define Pint(x) printf("%s\n", #x) // "x"//在字符串中字符串化参数记得要拼成两两双引号""的形式 #define PIN(position, message) printf("position: "#position"\tmessage: "#message"\n")

参数连接,将参数和其它记号连成一个记号,先将参数字符串化,再和其它记号拼成一个字符串,使用##运算符,例如arg1和arg2是两个参数,arg1##arg2等于arg1arg2,示例代码如下:
// 函数宏参数连接## #define DV(x) int x##_01 #define AS(x) x##_01 = 96 #define PD(x) printf("%d\n", x##_01) //调用如下: DV(num); AS(num); PD(num);

变参宏,由头文件stdvar.h提供相关功能,函数的参数数量可变即为变参,使用…表示,在替换体中使用__VA_ARGS__,在printf形式时__VA_ARGS__等于字符串+变量列表,字符串带双引号,例如这个宏定义和printf效果一样:#define print(…) printf(__VA_ARGS__),使用更复杂的形式时注意使用””字符串拼接,如下代码:
#define PRINT(x, ...) printf(#x": "__VA_ARGS__) // 调用如下: PRINT(MARK, "%s\n", "hello");

2、#include文件包含
预处理器会将#include后面的文件包含进指令的所在位置,#include< stdio.h> 相当于将stdio.h文件的所有内容复制到该位置。#include包含的形式有两种:尖括号< > 和双引号””,尖括号< > 的形式表示从标准系统目录中查找头文件,双引号””表示先查找当前目录(或指定的其它目录),未找到再查找标准系统目录,本地目录包括:文件所在目录、项目文件目录和工作目录,示例代码如下:
#include < stdio.h> // 从标准系统目录查找 #include "socket/socket.h" // 从指定位置查找,当前文件目录的socket目录 #include "usr/sys/types.h" // 从系统指定位置查找 #include "pro_10.h" // 从当前文件目录中查找

自定义头文件
1、头文件的标准信息:符号常量(宏定义,对象宏),宏函数,函数声明(extern函数原型),结构体数据模板定义(结构体类型声明),类型定义(类型别名,使用#define和typedef定义)。
2、自定义头文件注意:头文件的主要任务是声明程序的所需信息,并不是可执行代码(代码实现),包括声明和预处理指令,不过内联函数可以放在头文件中,内联函数也是一种函数原型声明,源代码文件提供可执行代码(声明的实现),数据或函数的实现。防止重包含使用#ifndef #define #endif,下面是头文件user.h的代码示例:
// 防止文件重包含 #ifndef CPRO_USER_H #define CPRO_USER_H// 宏定义:对象宏和函数宏 #define PI 3.14 #define SIZE 36 #define PS(x) printf("string: "#x"\n")// 结构体数据模板定义 struct user{ int id; int age; char email[256]; char name[256]; char description[1024]; char password[256]; }; // 类型定义 typedef struct user User; #define PUser struct user *// 函数声明 void addUser(User *); void deleteById(int id); void updateById(User *user, int id); User * getById(int id); #endif //CPRO_USER_H

3、其它预处理指令
(1)#undef取消#define已定义指令,或取消可能存在的指令,如#undef PI表示如果之前使用#define已定义PI,那么取消PI的定义,此后不再有作用,除非重定义。
(2)条件编译条件编译可以让程序更容易移植,或选择不同的实现,涉及的预编译指令有如下三个:
#ifdef、#else、#endif,可嵌套使用,和if else else if的意思一样,但主要用于检查标识符,如下示例:
#define SYS_ANDROID #define SYS_LINUX #define SYS_MAC #define SYS_WINDOWS#define SYS SYS_LINUX#ifdef SYS_LINUX #pragma message("sys: linux") struct linux{ char version[64]; char password[256]; }; #else #pragma message("sys: android") struct os{}; #endif

#ifndef,该指令一般用于避免文件重复包含,宏标识符通常使用文件名的大写,使用下划线代替点,但避免使用下划线开头,下划线开头是系统使用的前缀,可以使用这种命名方式:项目名_文件名_H,例如:
#ifndef LION_SOCKET_H #define LION_SOCKET_H struct socket{ char *iobuf; }; int socket(int, int); #endif

#if、#elif和#else、#endif,用于检查值,例如:#if value =http://www.srcmini.com/= 1,检查宏标识符可结合defined(),例如:#if defined(N),示例代码如下:
#define N 3 #if N == 1 #pragma message("N == 1") #elif N == 2 #pragma message("N == 2") #elif N == 3 #pragma message("N == 3") #endif#undef N #define N #if defined(N) #pragma message("defined N") #endif

(3)C标准预定义宏标准定义宏是C本身有的,有的是C99和C11新增的,标准宏如下表所示:
宏名称 说明
__DATE__ 预处理的日期字符串(格式为:M dd yyyy)
__FILE__ 当前源码文件的文件名(绝对路径的形式)
__LINE__ 当前文件的整型行号
__STDC__ C标准标记,1遵循C标准,0不遵循
__STDC_HOSTED__ 本机环境为1,否则为0
__STDC_VERSION__ C99标准=199901L,C11标准=201112L
__TIME__ 翻译代码的时间字符串(hh:mm:ss)
另外,__func__不是预定义宏,是一个预定义的标识符,代表当前函数名的字符串,需要具有函数作用域,示例代码如下:
#define PINT(x) printf(""#x": %d\n", x) #define PLONG(x) printf(""#x": %ld\n", x) #define PSTR(x) printf(""#x": %s\n", x)void print_13_01(void){ PSTR(__DATE__); // 当前日期:May 24 2019 PSTR(__FILE__); // 当前文件名:C:\cpro\pro_13.c PINT(__LINE__); // 当前行号:15 PINT(__STDC__); // 当前C标准:1 PINT(__STDC_HOSTED__); // 本机环境:1 PLONG(__STDC_VERSION__); // 当前C版本号:199901 PSTR(__TIME__); // 当前项目编译时间:13:10:17 PSTR(__func__); // 当前函数名:print_13_01 }

(4)#line和#error#line用于重置当前行号__LINE和文件名__FILE__,使用方式为:#line < LINE> < FILE> ,例如#line 100 “file.c”。#error让预处理器报错,停止编译,使用方式为:#error < message> ,message为错误信息。
(5)#pragma用于编译提示,设置编译器编译参数,C99提供同样功能的_Pragma(“”),常见的有#pragma message(“”)输出编译信息,#pragma once只能被编译一次,#pragma pack指定内存对齐字节数大小。
(6)泛型选择表达式_Generic使用形式为:_Generic(x, type: value, default: “”),type为x的数据类型,value为type对应的值,default为默认值,_Generic类似于switch的使用,示例代码如下:
#define TYPE(x) _Generic(x, int: "int", double: "double", char *: "string", default: "none") char *typeStr = TYPE("STD"); PSTR(typeStr);

4、函数说明符和特别函数
(1)内联函数前面提到的宏函数也是一种可进行代码内联的宏函数,内联函数使用inline修饰函数,但是和宏函数不同,内联函数不一定是代码内联的,它只是建议尽快调用函数,编译器可能会将内联代码代替函数调用,或进行其它优化,但也可能不起作用,内联函数的作用是尽可能加快执行速度,所以到目前为止,进行代码内联的方式有:函数宏和内联函数。
内联函数的标准要求有:该函数具有内部链接,使用static修饰函数;定义和调用都只能在一个文件中,因为编译器需要知道函数的具体内容;内联函数相当于函数原型,一般在头文件中定义;函数体的代码应该尽量少。
内联函数的限制有:无函数地址,没有函数代码块,若获取地址,会尝试非内联函数;不会在调试器中显示。
另外允许混合使用内联函数和外链接函数,即static和inline可不同时使用,示例代码如下:
static inline int add(int a, int b){ return a + b; } PINT(add(3, 4));

(2)_Noreturn函数_Noreturn同样是一个函数说明符,表示方式无返回值,执行完函数后不返回主调函数。
三、函数库1、C标准库
ANSI C标准库基于UNIX开发而来,访问C库的方式有:自动访问,手动声明函数原型,即使用extern作引用式声明;文件包含,使用#include指令;库包含,编译包含,链接文件,编译时链接,链接静态库文件和动态库文件。
函数库的描述主要有头文件,例如#include < stdio.h> ,函数原型size_t fread(void *ptr, size_t, size_t, FILE*),及其参数返回值说明,在查看手册或相关参考文档时都需要注意这几点的说明。
2、C常用函数库
(1)数学库math.h,三角函数相关需要使用弧度单位,tgmath.h泛型类型宏函数形式的函数。
(2)通用工具库stdlib.h,其中atexit和exit提供程序退出的相关功能,atexit注册退出时执行的函数,执行顺序按照后进先执行,exit执行刷新输入输出缓冲区,并返回主机环境。
qsort,快速排序,一共有四个参数,第一个参数是数组的指针,第二个参数是元素的数量,第三个参数是元素的大小,第四个参数是一个排序比较函数指针,下面是一些示例代码:
void task_01(void); void task_02(void); #define SIZE 6struct apple{ int color; int size; }; int compare_size(const void *a, const void *b); void print_13_02(void){ struct apple apples[SIZE] = { {34, 12}, {20, 21}, {57, 8}, {14, 64}, {13, 49}, {67, 34} }; qsort(apples, SIZE, sizeof(struct apple), compare_size); for (int i = 0; i < SIZE; ++i) { if(i != SIZE - 1) printf("{%d, %d} ", apples[i].color, apples[i].size); else printf("{%d, %d}\n", apples[i].color, apples[i].size); }atexit(task_02); atexit(task_01); exit(EXIT_SUCCESS); // 执行顺序为:task_01 => task_02 }// 升序排序 int compare_size(const void *a, const void *b){ struct apple *left = (struct apple *)a; struct apple *right = (struct apple *)b; if(left->size > right->size) return 1; else if(left->size < right->size) return -1; else return 0; }void task_01(void){ PSTR("task 01"); }void task_02(void){ PSTR("task 02"); }

(3)断言库断言库是用于程序测试的,由头文件assert.h提供相关功能,使用assert(int)函数宏进行运行时测试数据的正确性,参数为布尔值,测试结果为假则将错误信息写出stderr,并调用abort()终止调用。
另外C11提供_Static_assert(int, char*)声明,用于编译时检查程序,错误则终端编译,该声明可以出现在任何地方,int,布尔值,char*,错误信息。
(4)字符串处理库头文件为string.h,这个库之前已经详细讨论过了,这里稍微指出两个函数,memcpy和memmove函数,两个函数都产生内存复制,其中memcpy建议两个内存不重复,而memmove两个内存可重复。
(5)可变参数库头文件为stdarg.h,提供函数可变参数的相关处理,其实现比较复杂,详细实现步骤如下:
1)使用省略号…作为参数可变参数,至少有一个形参,并且省略号在最后,例如:void run(int x, …);
2)创建va_list类型的变量A,储存可变参数的数据对象,如va_list A;
3)使用宏初始化A为一个参数列表,如:va_start(A, x);
4)使用宏访问参数列表,如:int a = va_arg(A, int),type: int, double, …,这会逐个访问,不能回退,若需要回退可使用va_copy(c, A)对A进行复制;
5)使用宏清理,如:va_end(A),释放A内存,不能再继续使用A,除非重新va_start。
【C语言简明教程(十八)(预处理指令和C函数库完整详解)】完整实例代码如下:
void pin(int count, ...); void print_13_03(void){ pin(2, 88, 3.1415926); }void pin(int count, ...){ va_list list; va_start(list, count); int a = va_arg(list, int); double b = va_arg(list, double); printf("%d\n", a); printf("%f\n", b); va_end(list); }

    推荐阅读