11.C 预处理器
C 预处理器
C 预处理器不是编译器的组成部分,但是它是编译过程中一个单独的步骤。简言之,C 预处理器只不过是一个文本替换工具而已,它们会指示编译器在实际编译之前完成所需的预处理。这些在编译之前对源文件进行简单加工的过程,就称为预处理(即预先处理、提前处理)。
预处理是C语言的一个重要功能,由预处理程序完成。当对一个源文件进行编译时,系统将自动调用预处理程序对源程序中的预处理部分作处理,处理完毕自动进入对源程序的编译。
编译器会将预处理的结果保存到和源文件同名的.i文件中,例如 main.c 的预处理结果在 main.i 中。和.c一样,.i也是文本文件,可以用编辑器打开直接查看内容。
所有的预处理器命令都是以井号(#)开头,预处理命令要放在所有函数之外,而且一般都放在源文件的前面。它必须是第一个非空字符,为了增强可读性,预处理器指令应从第一列开始。下面列出了所有重要的预处理器指令:
| 指令 | 描述 |
|---|---|
| #define | 定义宏 |
| #include | 包含一个源代码文件 |
| #undef | 取消已定义的宏 |
| #ifdef | 如果宏已经定义,则返回真 |
| #ifndef | 如果宏没有定义,则返回真 |
| #if | 如果给定条件为真,则编译下面代码 |
| #else | #if 的替代方案 |
| #elif | 如果前面的 #if 给定条件不为真,当前条件为真,则编译下面代码 |
| #endif | 结束一个 #if……#else 条件编译块 |
| #error | 当遇到标准错误时,输出错误消息 |
| #pragma | 使用标准化方法,向编译器发布特殊的命令到编译器中 |
宏延续运算符(\)
一个宏通常写在一个单行上。但是如果宏太长,一个单行容纳不下,则使用宏延续运算符(\)。例如:
#define message_for(a, b) \
printf(#a " and " #b ": We love you!\n")
1.#include的用法
#include叫做文件包含命令,用来引入对应的头文件(.h文件)。#include 的处理过程很简单,就是将头文件的内容插入到该命令所在的位置,从而把头文件和当前源文件连接成一个源文件,这与复制粘贴的效果相同。
C 或 C++ 程序中,建议把所有的常量、宏、系统全局变量和函数原型写在头文件中,在需要的时候随时引用这些头文件。
include 的用法有两种,如下所示:
#include <stdHeader.h>
#include "myHeader.h"
使用尖括号< >和双引号" "的区别在于头文件的搜索路径不同:
- 使用尖括号
< >,编译器会到系统路径下查找头文件; - 而使用双引号
" ",编译器首先在当前目录下查找头文件,如果没有找到,再到系统路径下查找。
关于 #include 用法的注意事项:
- 一个 #include 命令只能包含一个头文件,多个头文件需要多个 #include 命令。
- 同一个头文件可以被多次引入,多次引入的效果和一次引入的效果相同,因为头文件在代码层面有防止重复引入的机制。
- 文件包含允许嵌套,也就是说在一个被包含的文件中又可以包含另一个文件。
2.#define的用法
#define 叫做宏定义命令,它也是C语言预处理命令的一种。所谓宏定义,就是用一个标识符来表示一个字符串,如果在后面的代码中出现了该标识符,那么就全部替换成指定的字符串。
宏定义的一般形式为:
#define 宏名 字符串
#表示这是一条预处理命令,所有的预处理命令都以 # 开头。宏名是标识符的一种,命名规则和变量相同。字符串可以是数字、表达式、if 语句、函数等。
#include <stdio.h>
#define N 100
int main(){
int sum = 20 + N;
printf("%d\n", sum);
return 0;
}
//运行结果:120
//第 6 行代码int sum = 20 + N,N被100代替了。
#define N 100就是宏定义,N为宏名,100是宏的内容(宏所表示的字符串)。在预处理阶段,对程序中所有出现的“宏名”,预处理器都会用宏定义中的字符串去代换,这称为“宏替换”或“宏展开”。
程序中反复使用的表达式可以使用宏定义,例如:
#define M (n*n+3*n)
它的作用是指定标识符M来表示(y*y+3*y)这个表达式。在编写代码时,所有出现 (yy+3y) 的地方都可以用 M 来表示,而对源程序编译时,将先由预处理程序进行宏代替,即用 (yy+3y) 去替换所有的宏名 M,然后再进行编译。
#include <stdio.h>
#define M (n*n+3*n)
int main(){
int sum, n;
printf("Input a number: ");
scanf("%d", &n);
sum = 3*M+4*M+5*M;
printf("sum=%d\n", sum);
return 0;
}
//sum=3*(n*n+3*n)+4*(n*n+3*n)+5*(n*n+3*n);
//运行结果:
///Input a number: 10↙
//sum=1560
需要注意的是,在宏定义中表达式(n*n+3*n)两边的括号不能少,否则在宏展开以后可能会产生歧义。下面是一个反面的例子:
#difine M n*n+3*n
在宏展开后将得到下述语句:
s=3*n*n+3*n+4*n*n+3*n+5*n*n+3*n;
这显然是不正确的。所以进行宏定义时要注意,应该保证在宏替换之后不发生歧义。
宏定义使用注意事项:
-
宏定义是用宏名来表示一个字符串,在宏展开时又以该字符串取代宏名,这只是一种简单粗暴的替换。字符串中可以含任何字符,它可以是常数、表达式、if 语句、函数等,预处理程序对它不作任何检查,如有错误,只能在编译已被宏展开后的源程序时发现。
-
宏定义不是说明或语句,在行末不必加分号,如加上分号则连分号也一起替换。
-
宏定义必须写在函数之外,其作用域为宏定义命令起到源程序结束。如要终止其作用域可使用
#undef命令 -
代码中的宏名如果被引号包围,那么预处理程序不对其作宏代替,作为字符串处理
-
宏定义允许嵌套,在宏定义的字符串中可以使用已经定义的宏名,在宏展开时由预处理程序层层代换。
#define PI 3.1415926
#define S PI*y*y /* PI是已定义的宏名*/
- 习惯上宏名用大写字母表示,以便于与变量区别。但也允许用小写字母。
- 可用宏定义表示数据类型,使书写方便。注意,宏定义只是简单的字符串替换,由预处理器来处理;而 typedef 是在编译阶段由编译器处理的,它并不是简单的字符串替换,而给原有的数据类型起一个新的名字,将它作为一种新的数据类型。
3.带参数的宏定义
C语言允许宏带有参数。在宏定义中的参数称为“形式参数”,在宏调用中的参数称为“实际参数”,这点和函数有些类似。对带参数的宏,在展开过程中不仅要进行字符串替换,还要用实参去替换形参。
带参宏定义的一般形式为:
#define 宏名(形参列表) 字符串
带参宏调用的一般形式为:
宏名(实参列表);
例如:
#define M(y) y*y+3*y //宏定义
// TODO:
k=M(5); //宏调用
在宏展开时,用实参 5 去代替形参 y,经预处理程序展开后的语句为k=5*5+3*5。
带参宏定义使用注意事项:
- 带参宏定义中,形参之间可以出现空格,但是宏名和形参列表之间不能有空格出现。例如把:
#define MAX(a,b) (a>b)?a:b //正确
#define MAX (a,b) (a>b)?a:b //错误
-
在带参宏定义中,不会为形式参数分配内存,因此不必指明数据类型。而在宏调用中,实参包含了具体的数据,要用它们去替换形参,因此实参必须要指明数据类型。这一点和函数是不同的:在函数中,形参和实参是两个不同的变量,都有自己的作用域,调用时要把实参的值传递给形参;而在带参数的宏中,只是符号的替换,不存在值传递的问题。
-
在宏定义中,字符串内的形参通常要用括号括起来以避免出错。对于带参宏定义不仅要在参数两侧加括号,还应该在整个字符串外加括号。
#include <stdio.h>
#define SQ(y) ((y)*(y))
int main(){
int a,sq;
printf("input a number: ");
scanf("%d", &a);
sq = 200 / SQ(a+1);
printf("sq=%d\n", sq);
return 0;
}
4.条件编译详解
能够根据不同情况编译不同代码、产生不同目标文件的机制,称为条件编译。条件编译是预处理程序的功能,不是编译器的功能。
1.#if 的用法
//判断表达式是否成立
#if 整型常量表达式1
程序段1
#elif 整型常量表达式2
程序段2
#elif 整型常量表达式3
程序段3
#else
程序段4
#endif
它的意思是:“表达式1”的值为真(非0),就对“程序段1”进行编译,否则就计算“表达式2”,结果为真的话就对“程序段2”进行编译,为假的话就继续往下匹配,直到遇到值为真的表达式,或者遇到 #else。
需要注意的是,#if 命令要求判断条件为“整型常量表达式”,也就是说,表达式中不能包含变量,而且结果必须是整数;而 if 后面的表达式没有限制,只要符合语法就行。这是 #if 和 if 的一个重要区别。
2.#ifdef 的用法
//判断宏名是否存在
#ifdef 宏名
程序段1
#else
程序段2
#endif
它的意思是,如果当前的宏已被定义过,则对“程序段1”进行编译,否则对“程序段2”进行编译。
3.#ifndef 的用法
//判断宏名是否不存在
#ifndef 宏名
程序段1
#else
程序段2
#endif
与 #ifdef 相比,仅仅是将 #ifdef 改为了 #ifndef。它的意思是,如果当前的宏未被定义,则对“程序段1”进行编译,否则对“程序段2”进行编译,这与 #ifdef 的功能正好相反。
最后需要注意的是,#if 后面跟的是“整型常量表达式”,而 #ifdef 和 #ifndef 后面跟的只能是一个宏名,不能是其他的
4.条件编译代码
在编译命令行中使用 -D 选项定义宏,或者在源代码中使用 #define 定义宏。这些宏将成为条件编译判断的基础。例如:
-
命令行定义:
gcc source.c -DMY_CONDITION=my_value -
源代码定义:
#define MY_CONDITION my_value
示例1:基于宏值的条件编译
#if MY_CONDITION == my_value
// 当 MY_CONDITION 宏的值等于 "my_value" 时,编译这部分代码
// ...
#else
// 当 MY_CONDITION 宏的值不等于 "my_value" 时,编译这部分代码
// ...
#endif
示例2:基于宏是否定义的条件编译
#ifdef DEBUG_MODE
// 当 DEBUG_MODE 宏已定义时,编译调试代码(如打印语句、额外检查等)
printf("Debugging information...\n");
#else
// 当 DEBUG_MODE 宏未定义时,编译生产代码(去除调试信息)
// ...
#endif
//-------------------------------------------------------
#ifndef DEBUG_MODE
// 当 DEBUG_MODE 宏未定义时,编译生产代码(去除调试信息)
// ...
#else
// 当 DEBUG_MODE 宏未定义时,编译调试代码(如打印语句、额外检查等)
printf("Debugging information...\n");
#endif
示例3:多条件分支的条件编译
#if defined(OS_WINDOWS)
// 当 OS_WINDOWS 宏已定义时,编译针对 Windows 平台的代码
// ...
#elif defined(OS_LINUX)
// 当 OS_LINUX 宏已定义时,编译针对 Linux 平台的代码
// ...
#else
// 当以上宏均未定义时,编译通用代码或给出错误提示
#error "Unsupported operating system"
#endif
5.条件编译防止头文件重复包含
这是最常用且广泛支持的方法,通过在头文件内部使用预处理器指令 #ifndef, #define, 和 #endif 来实现。具体做法如下:
头文件(如:myheader.h):
1#ifndef MYHEADER_H // 如果 MYHEADER_H 这个宏还没有被定义
2#define MYHEADER_H // 定义 MYHEADER_H 这个宏,用于标记头文件已被包含
3
4// 头文件的实际内容,如声明、定义等
5// ...
6
7#endif // 结束条件编译块
源文件(如:main.c):
1#include "myheader.h" // 引入头文件
2
3// ...
在这个方法中,当一个源文件首次包含该头文件时,由于 MYHEADER_H 宏尚未定义,预处理器会执行宏定义语句并继续处理头文件中的其他内容。如果后续再次包含该头文件,由于 MYHEADER_H 宏已经定义,预处理器会跳过整个条件编译块,避免了头文件内容的重复包含。
5.预定义宏
ANSI C 定义了许多宏。在编程中您可以使用这些宏,但是不能直接修改这些预定义的宏。
| 宏 | 描述 |
|---|---|
| DATE | 当前日期,一个以 "MMM DD YYYY" 格式表示的字符常量。 |
| TIME | 当前时间,一个以 "HH:MM:SS" 格式表示的字符常量。 |
| FILE | 这会包含当前文件名,一个字符串常量。 |
| LINE | 这会包含当前行号,一个十进制常量。 |
| STDC | 当编译器以 ANSI 标准编译时,则定义为 1。 |
让我们来尝试下面的实例:
#include <stdio.h>
main()
{
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__ );
}
//当上面的代码(在文件 test.c 中)被编译和执行时,它会产生下列结果:
File :test.c
Date :Jun 2 2012
Time :03:36:24
Line :8
ANSI :1
6.动态库和静态库
静态库与动态库是两种不同的代码库组织形式,它们都是为了实现代码重用和模块化编程。以下是关于静态库和动态库的详细说明:
1.静态库 (Static Library)
定义与特点:
静态库是一组预先编译好的目标文件(通常扩展名为.o或.obj)的集合,经过打包后形成一个单一文件(通常在Windows上扩展名为.lib,在Unix/Linux上扩展名为.a)。在编译链接应用程序时,链接器会把静态库中被应用程序引用的函数和数据的副本直接合并到生成的可执行文件中。
优点:
- 独立运行: 由于静态库的内容已经完全嵌入到可执行文件中,应用程序在运行时不需要依赖外部库文件,可以直接在没有库文件支持的环境中运行。
- 性能: 静态链接消除了运行时查找符号和加载库的开销,通常使得程序启动更快,运行时访问库函数的效率更高。
- 版本稳定性: 一旦应用程序编译完成,其内部的库代码就不会受到库后续更新的影响,保证了程序行为的确定性和一致性。
缺点:
- 体积增大: 可执行文件中包含了库的所有相关代码,导致文件尺寸变大,尤其是在使用大型库时,这种影响更为显著。
- 资源浪费: 如果多个应用程序都使用了同一静态库,那么每个应用程序都会包含库的副本,造成磁盘空间和内存资源的重复占用。
- 更新困难: 当静态库更新时,依赖它的所有应用程序都需要重新编译链接,以获取新版本库的功能或修复。
2.静态库制作
步骤1:源代码编译
将库中包含的各个源文件(.c或.cpp)编译成目标文件(.o或.obj)。例如,使用gcc编译器:
gcc -c source_file1.c -o source_file1.o
gcc -c source_file2.c -o source_file2.o
...
步骤2:目标文件归档
使用ar工具将目标文件打包成静态库文件。静态库通常以lib为前缀,后跟库名,扩展名为.a。例如,创建名为libmylib.a的静态库:
ar rcs libmylib.a source_file1.o source_file2.o ...
其中:
r:插入或替换归档文件中的文件。c:创建新的归档文件(如果已存在则覆盖)。s:创建或更新归档文件的符号表。
3.静态库使用
步骤1:包含头文件
在使用静态库的源文件中,通过#include指令包含相应的头文件,以便访问库中定义的数据结构、常量和函数声明。
步骤2:链接静态库
在编译链接应用程序时,通过链接器选项指定静态库。例如,使用gcc编译器链接libmylib.a:
gcc -o my_program main.c other_source_files.c -L/path/to/library -lmylib
其中:
-
-o my_program:-o是一个编译选项,表示 "output",用于指定编译后的输出文件名。my_program是指定的输出文件名。编译成功后,生成的可执行文件将命名为my_program。
-
main.c other_source_files.c:- 这些是待编译的源代码文件列表。
main.c通常是程序的主入口点,而other_source_files.c列出了其他参与编译的 C 源文件。这些源文件会被编译成目标文件(.o),然后链接在一起生成最终的可执行文件。
- 这些是待编译的源代码文件列表。
-
-L/path/to/library:-L是一个链接选项,用于指定库搜索路径。/path/to/library是具体的目录路径,编译时链接器会在该目录下查找所需的库文件。这有助于找到不在系统标准库路径下的第三方库或用户自定义库。
-
-lmylib:-l是另一个链接选项,用于指定需要链接的库。mylib是库的名字,不包括前缀(如lib)和扩展名(如.a或.so)。例如,如果实际库文件名为libmylib.a或libmylib.so,这里只需写mylib。
4.动态库 (Dynamic Library)
定义与特点:
动态库(也称为共享库或DLL)是一种独立于应用程序的文件(Windows上扩展名为.dll,Unix/Linux上扩展名为.so),其中包含可被多个进程同时加载并使用的代码和数据。当应用程序运行时,操作系统负责在需要时将动态库加载到内存,并将库函数的地址映射到应用程序的地址空间中。
优点:
- 资源共享: 多个应用程序可以共享同一份动态库文件,从而节省磁盘空间和内存资源。只需在内存中保留一份库代码,所有依赖它的进程都可以共享。
- 易于更新: 更新动态库时,无需重新编译链接使用它的应用程序。只要替换旧版库文件为新版,重启应用程序即可使用库的新功能或修复,实现了“热插拔”式的升级。
- 模块化与灵活性: 动态库使得功能模块化更加明显,开发者可以单独维护和升级库,而不必改动应用程序主体。此外,用户可以根据需要安装或卸载特定的库来扩展或定制应用程序功能。
缺点:
- 依赖管理: 应用程序运行时需要能够找到对应的动态库文件,这增加了部署复杂性,需要妥善处理库的路径设置、版本兼容性等问题。
- 加载时间: 第一次加载动态库时,需要花费额外的时间进行文件加载和符号解析,可能影响程序启动速度。后续访问库函数的速度通常与静态库相当。
- 稳定性风险: 如果动态库出现问题(如损坏、版本不匹配、缺失等),可能导致依赖它的应用程序无法正常运行。此外,库的更新可能引入兼容性问题,影响已有应用程序的稳定运行。
5.动态库制作
步骤1:源代码编译
同样需要编译源文件,但需添加-fPIC选项以生成位置无关代码,以便在内存中任意位置加载。当目标文件将被用于构建共享库(动态链接库,.so 文件)时,这个选项非常重要。PIC 使得代码能够在内存中的任意地址正确运行,而不是依赖于固定的加载地址。这对于动态链接库至关重要,因为它们在加载时可能被操作系统分配到不同的地址空间,且可能被多个进程共享。
例如:
gcc -fPIC -c source_file1.c -o source_file1.o
gcc -fPIC -c source_file2.c -o source_file2.o
...
步骤2:生成动态库文件
使用编译器的链接器功能,将目标文件链接成动态库。动态库文件名以lib为前缀,后跟库名,扩展名为.so。同时指定版本信息和链接选项:
gcc -shared -Wl,-soname,libmylib.so.1 -o libmylib.so source_file1.o source_file2.o ...
其中:
-
-shared:- 这是一个链接选项,指示 GCC 在链接阶段生成一个共享库(动态链接库,
.so文件)而非可执行文件。 - 使用
-shared选项时,GCC 会收集所有指定的目标文件(如source_file1.o、source_file2.o等),并将其链接成一个动态库,该库可以在运行时被其他程序动态加载。
- 这是一个链接选项,指示 GCC 在链接阶段生成一个共享库(动态链接库,
-
-Wl,-soname,libmylib.so.1:-Wl是一个 GCC 选项,它告诉 GCC 将后面的参数传递给链接器(ld)。-soname,libmylib.so.1是传递给链接器的一个特定选项,用于指定生成的共享库的 SONAME(Short for Shared Object Name)。- SONAME 是库的一个重要属性,它不仅出现在库文件内部(存储在 ELF 头部),而且通常在安装时作为符号链接(如
libmylib.so.1->libmylib.so)创建,以便应用程序在链接时引用。SONAME 代表了库的主要接口版本,当库的 ABI(Application Binary Interface)发生变化但保持向后兼容时,SONAME 通常保持不变,而库的实际文件名(如libmylib.so)随着每次发布新版本而更新。
-
-o libmylib.so:-o仍然是 "output" 选项,用于指定最终生成文件的名称。libmylib.so是指定的输出文件名,即所要创建的共享库的完整文件名,包含了具体的版本信息。
-
source_file1.o source_file2.o ...:- 这些是已经经过编译的目标文件,通常由前面使用类似
gcc -fPIC -c source_file1.c -o source_file1.o等命令生成。 - GCC 将把这些目标文件链接在一起,形成一个单一的共享库
libmylib.so。
- 这些是已经经过编译的目标文件,通常由前面使用类似
总结来说,这条 gcc 命令行编译并链接了一系列编译好的目标文件(如 source_file1.o、source_file2.o 等),生成名为 libmylib.so 的共享库。同时,通过 -Wl,-soname,libmylib.so.1 选项指定了库的 SONAME 为 libmylib.so.1,这有助于应用程序在链接时识别和使用正确的库版本,并支持库版本管理和升级。
步骤3:符号版本管理(可选)
对于公共库或系统库,可能需要使用version-script或-version-info选项管理符号版本。
步骤4:安装与软链接(可选)
将动态库文件安装到系统标准库目录(如/usr/lib或/usr/local/lib),并创建指向最新版本的软链接:
sudo cp libmylib.so /usr/local/lib/
sudo ln -sf /usr/local/lib/libmylib.so /usr/local/lib/libmylib.so.1
sudo ln -sf /usr/local/lib/libmylib.so.1 /usr/local/lib/libmylib.so
6.动态库使用
步骤1:包含头文件
同静态库,需要在源文件中包含相应的头文件。
步骤2:链接动态库
在编译应用程序时,链接器仅需知道动态库的接口信息,无需实际链接库文件。通常通过-l选项指定库名:
gcc -o my_program main.c other_source_files.c -L/path/to/library -lmylib
步骤3:运行时加载
为了让程序运行时能够找到动态库,需要确保库路径包含在系统库搜索路径中。方法有:
-
设置环境变量:如在Linux上设置
LD_LIBRARY_PATH:export LD_LIBRARY_PATH=/path/to/library:$LD_LIBRARY_PATH ./my_program -
安装库至标准路径:如果库已按照上述动态库制作的最后一步安装到系统标准库目录,则通常无需额外设置环境变量。
-
硬编码库路径:在程序中使用
dlopen()函数动态加载库时,直接指定库的完整路径。
综上所述,静态库与动态库的制作涉及源代码编译、目标文件归档或链接等步骤,使用时则需正确包含头文件、链接库文件(静态库需实际链接,动态库仅需接口信息),并在运行时确保动态库的可访问性。
7.选择与应用场景
-
静态库适用于:
- 对程序体积敏感,需要最小化部署包的场合。
- 独立运行环境,无法或不希望依赖外部库文件的场合。
- 需要严格控制库版本,避免因库更新导致程序行为变化的场合。
-
动态库适用于:
- 需要频繁更新库功能,且希望用户无须重新编译应用程序即可获得更新的场合。
- 多个应用程序共享相同功能,希望减少磁盘和内存占用的场合。
- 开发模块化、插件化的软件架构,便于功能扩展和维护的场合。
综上所述,静态库和动态库各有优势和适用场景,选择使用哪种类型库取决于项目的具体需求、部署环境、维护策略等因素。实际开发中,开发者往往会结合两者,根据功能的重要性和更新频率等因素,分别采用静态或动态链接不同的库。

浙公网安备 33010602011771号