接上一节:位操作之二进制、字节、按位操作和位字段
本节详解讨论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 == 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 6
struct 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。
完整实例代码如下:
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);
}