- 一、C语言编译的预处理阶段
- 1.1 C语言的编译过程
- 1.2 C语言编译的预处理
- 二、C语言 宏
- 2.1替换常量
- 2.2函数宏
- 2.3 字符串化和连接:#和##
- 2.4 变参宏
- 三、文件包含:#include
- 3.1 写法
- 3.2 头文件的作用——声明
- 3.3 头文件和extern 、static
- 四、 其他指令
- 4.1 #undef
- 4.2 条件编译
- 4.3 预定义宏
- 4.4 #line和#error
- 4.5 #praga
- 4.6 头文件如何命名
一、C语言编译的预处理阶段
1.1 C语言的编译过程
C语言的编译过程是将高级的C语言代码转换为可执行的机器代码的过程。
C语言的翻译过程包括预处理、编译、汇编、链接、加载和执行阶段。每个阶段都有特定的任务和目标
-
预处理(Preprocessing):
在翻译过程的第一阶段,称为预处理阶段,预处理器将对源代码进行处理。预处理器会根据预处理指令(以#
开头的命令)执行相应的操作。这些指令可以用于包含其他文件、宏替换、条件编译等。预处理器会生成一个扩展后的源代码文件,供下一阶段使用。 -
编译(Compilation):
在编译阶段,编译器将预处理后的源代码转换为汇编语言代码。编译器会进行词法分析和语法分析,检查代码的语法和语义是否正确。如果发现错误,编译器会生成相应的错误信息。如果没有错误,编译器会生成中间代码(如汇编语言代码或机器无关的代码)。 -
汇编(Assembly):
在汇编阶段,汇编器将中间代码(汇编语言代码)转换为可重定位的机器代码,也就是目标文件。目标文件包含了机器指令和符号信息。符号信息用于在链接阶段解析函数和变量的引用。 -
链接(Linking):
在链接阶段,链接器将多个目标文件和库文件(如标准库)合并为一个可执行文件。链接器会解析目标文件中的符号引用,将其与符号定义进行匹配。如果找不到符号定义,链接器会生成未解析的符号错误。链接器还会处理重定位,将符号引用替换为正确的地址。 -
加载(Loading):
在加载阶段,操作系统将可执行文件加载到内存中,并为其分配内存空间。加载器会解析可执行文件的头部信息,确定程序入口点,并建立程序的执行环境。加载器还会处理动态链接,将程序与共享库进行链接。 -
执行(Execution):
在执行阶段,处理器按照指令逐条执行可执行文件中的机器指令。程序的执行顺序和行为取决于代码的逻辑和算法。程序执行期间会访问内存、执行算术和逻辑操作,以及与外部设备进行交互。最终,程序会产生所期望的结果或效果。
本文介绍C语言的预处理及预处理指令。
1.2 C语言编译的预处理
预处理是C语言翻译过程中的第一阶段,它负责对源代码进行处理和转换,以生成供编译器使用的扩展后的源代码。
预处理器根据以#
开头的预处理指令执行相应的操作,下面将详细介绍预处理的过程和常用的预处理指令。
- 宏替换(Macro substitution):
宏是一种预处理指令,用于将代码中的标识符替换为相应的代码片段。定义宏使用#define
指令,例如:
#define PI 3.14159
在预处理阶段,所有的PI
标识符都会被替换为3.14159
。宏还可以带有参数,被称为函数宏,例如:
#define SQUARE(x) ((x) * (x))
这个宏可以计算一个数的平方,在预处理阶段,SQUARE(5)
会被替换为((5) * (5))
,即25
。
- 文件包含(File inclusion):
预处理器提供了文件包含的功能,可以将其他文件的内容插入到当前源文件中。使用#include
指令,例如:
#include <stdio.h>
这个指令会将标准库头文件stdio.h
的内容包含到当前源文件中。文件包含可以是尖括号<>
形式或双引号""
形式,区别在于搜索头文件的路径不同。
- 条件编译(Conditional compilation):
条件编译指令用于根据条件选择性地编译代码块。常用的条件编译指令有#ifdef
、#ifndef
、#if
、#elif
和#endif
。例如:
#ifdef DEBUG
printf("Debug mode\n");
#else
printf("Release mode\n");
#endif
如果定义了DEBUG
宏,编译器会编译第一行代码,否则会编译第三行代码。
-
注释删除(Comment removal):
预处理器会删除源代码中的注释,以提高编译速度和减少生成的中间代码的大小。C语言有两种注释形式:/* ... */
多行注释和//
单行注释。 -
其他预处理指令:
预处理器还提供了其他一些常用的指令,如#undef
用于取消宏定义,#error
用于生成错误信息,#pragma
用于向编译器发出特定指令等。
预处理阶段的输出是经过预处理后的源代码,它将作为编译器的输入进行下一阶段的处理。预处理可以扩展源代码、处理宏替换、包含其他文件、条件编译等。
二、C语言 宏
宏(Macro)是C语言中的一种预处理指令,用于将代码中的标识符替换为相应的代码片段。宏定义使用#define
指令。
宏的替换是简单的文本替换,它在预处理阶段进行。当编译器遇到宏名称时,会将其替换为定义中指定的代码片段。宏替换是直接替换,没有类型检查或语法分析。
宏的主要目的是提供一种简便的方式来定义和使用可重复的代码块。通过使用宏,可以在代码中使用自定义的符号来表示一段代码,从而增加代码的可读性和灵活性。
宏可以具有不同的形式和功能。一些常见的宏使用包括:
- 替换常量:使用宏定义来表示常量值,以方便修改和维护。
- 函数宏:使用宏定义来表示一段代码片段,类似于函数的调用。函数宏可以带有参数,用于在代码中执行一系列操作。
- 条件编译:使用宏定义来选择性地编译代码块,以便在不同的编译条件下执行不同的代码。(放到第三节将)
- 字符串化和连接操作:通过使用特殊的预处理运算符
#
和##
,可以在宏定义中对参数进行字符串化和连接操作。
2.1替换常量
如:
#define PI 3.14159
#define IP "10.0.0.1"
有人说:宏定义时,不用关心类型,只是简单的文本替换。它的类型在调用这个宏的地方的参数类型决定。
但事实上,宏的类型与后面他要替换的内容有关,比如上面的第一个宏,默认是double
型,第二个宏默认是char *
。在使用的时候应该根据宏替代的文本的默认类型来正确使用。
printf("%s\n",IP); //正确
printf("%s\n",PI); //错误
2.2函数宏
#define SQUARE(x) ((x) * (x))
这个宏定义了一个函数宏,用于计算一个数的平方。在预处理阶段,所有的SQUARE(x)
会被替换为((x) * (x))
,然后编译器将继续处理替换后的代码。
它的返回值类型与传递的实参值相同(当然这个函数宏的返回值不能是个字符串,关于字符串,见2.3小节),传给它整数类型,返回值就是整数类型,传浮点数,返回值就死浮点型。这一点似乎比写一个相同功能的函数更有效。
应该使用函数、还是函数宏,要注意:
-
性能:函数宏在编译时展开,以文本替换的方式直接插入代码,因此没有函数调用的开销。这使得函数宏在一些性能敏感的场景中可能更有效率。然而,这也可能导致代码膨胀,增加可执行文件的大小。
-
参数求值:函数宏的参数是在宏展开时进行替换的,而函数的参数是在运行时求值的。如果参数求值具有副作用或对性能有重要影响,函数宏可能更合适。然而,如果需要确保参数只求值一次或需要动态计算参数,函数可能更适合。
-
可读性和维护性:函数宏可能使代码更简洁,但过度使用宏可能会降低代码的可读性和维护性。函数通常具有更明确的语义,并且可以在编译时进行类型检查,使得代码更易于理解和调试。如果可读性和可维护性对项目至关重要,函数可能更可取。
-
代码重用:函数可以被多次调用,实现代码的重用性。如果有多个地方需要执行相同的操作,函数通常更适合。函数宏在每个使用处都会进行代码展开,可能导致重复的代码。
综上所述,使用函数宏还是函数应根据具体情况进行权衡。在性能敏感、参数简单且无副作用的情况下,函数宏可能更合适。而在需要明确语义、可读性和维护性重要的情况下,函数可能更适合。
2.3 字符串化和连接:#和##
在C语言的宏定义中,#
(井号)和##
(双井号)是两个特殊的预处理运算符(不是define前面的那个#),它们在宏展开过程中具有不同的作用。下面将详细介绍它们的用途和功能。
(1)#
运算符(字符串化操作符):
在宏定义中,#
运算符将宏参数转换为字符串。它可以用于将宏参数的值转换为对应的字符串常量。例如:
#define STR(x) #x
printf("%s\n", STR(Hello)); // 输出 "Hello"
在上述示例中,宏定义了一个STR
宏,它将传递给宏的参数x
转换为字符串常量。当调用STR(Hello)
时,宏展开为"Hello"
,最终在printf
函数中输出字符串Hello
。
注意,在使用#
运算符时,宏参数在宏定义中必须通过另一个宏进行替换。
再来一个例子:
#define PRINT_a(x) printf("x * x = %d\n",(x)*(x));
#define PRINT_b(x) printf(""#x" * "#x" = %d\n",(x)*(x));
...
PRINT_a(4);
PRINT_b(4);
...
输出为:
(2) ##
运算符(连接操作符):
##
运算符用于连接两个标识符,使它们成为一个单独的标识符。它可以在宏定义中用于生成新的标识符。例如:
#define CONCAT(a, b) a##b
int xy = CONCAT(10, 20); // 展开为 int xy = 1020;
在上述示例中,宏定义了一个CONCAT
宏,它将两个参数a
和b
连接为一个新的标识符。当调用CONCAT(10, 20)
时,宏展开为10
和20
的连接,最终生成一个名为xy
的整型变量。
使用##
运算符时,注意要确保连接的两个标识符合法并且能够正确组合在一起。否则,可能会导致语法错误或意外的结果。
总结来说,#
运算符用于将宏参数转换为字符串,而##
运算符用于连接标识符。它们是宏定义中的特殊预处理运算符,为宏的灵活性和功能增添了更多的可能性。然而,在使用它们时需要小心,确保正确使用并避免潜在的问题。
2.4 变参宏
变参宏允许宏在不同的调用中接受可变数量的参数。变参宏提供了一种灵活的方式来处理不确定数量的参数,并在预处理阶段展开为相应的代码。
在传统的宏定义中,我们只能指定一个固定数量的参数。但是,通过使用特殊的预处理运算符__VA_ARGS__
,我们可以定义接受可变数量参数的宏。__VA_ARGS__
表示宏的参数列表中的可变部分。
常见的2中形式:
#include <stdio.h>
#define PR(...) printf(__VA_ARGS__)
#define PP(x,...) printf("第 " #x " 个调用:"__VA_ARGS__)
int main() {
int x = 888;
PR("helo\n");
PR("x=%d\n", x);
PP(1, "x = %d\n", x);
PP(2, "x^2 = %d\n", x*x);
return 0;
}
输出:
helo
x=888
第 1 个调用:x = 888
第 2 个调用:x^2 = 788544
一个实际应用:打印系统状态
[20:37:16:118] Wifi state changed! Now control_flag=0
[20:37:16:168]
[20:37:16:168] ===============0============
[20:37:16:177]
[20:37:16:371] [6986] INFO Wifista: sysTicks: 1117762488, kernelTicks: 6986
[20:37:16:382] [6988] INFO Wifista: IsWifiActive: 0
[20:37:16:382] [6990] INFO Wifista: EnableWifi: 0
[20:37:16:382] [6992] INFO Wifista: IsWifiActive: 1
[20:37:16:405] [7003] INFO Wifista: GetDeviceMacAddress: errCode:0, FormatMacAddress:28:
[20:37:16:410] [7006] INFO Wifista: OnWifiScanStateChanged 101, state = 0, size = 0
[20:37:18:021] [wifi_service]: dispatch scan event.
[20:37:18:023] [7812] INFO Wifista: OnWifiScanStateChanged 101, state = 1, size = 9
三、文件包含:#include
#include
是一个预处理指令,用于将外部文件的内容包含到源代码中。它通常用于包含头文件,以便在程序中可以访问其他文件中定义的函数、变量和宏等。
使用#include
时,会将对应的头文件的所有内容包含到你的源文件中,位置是你的include的位置,所以,include通常放在源文件开头。
不过,编译的时候,只对你实际使用到的额代码进行编译和链接。
使用include包含头文件的好处是:简化了多个文件的管理,但是会增加编译时间。
3.1 写法
#include
指令后面紧跟着文件名,可以使用双引号或尖括号将文件名括起来,具体取决于你要包含的文件类型和位置。有两种常见的用法:
-
使用双引号
""
:#include "filename.h" #include "/usr/xxx/xx.h"
这种用法通常用于包含项目内部的头文件。编译器首先在当前目录中(或指定的路径)查找文件,如果找不到,则在其他指定的搜索路径中查找。
-
使用尖括号
<>
:#include <filename.h>
这种用法通常用于包含系统或标准库的头文件。编译器将根据预定义的搜索路径来查找文件。
3.2 头文件的作用——声明
头文件用于存储函数、变量、宏定义和结构体等的声明和定义。头文件通常使用.h
作为文件扩展名,并且包含在源代码文件中,以便在程序中可以引用和使用其中定义的内容。
头文件的作用:
- 函数声明;
- 全局变量声明;
- 结构体定义;
- 宏定义。
不要在头文件中定义函数和变量,这样会导致重复定义变量的错误。
- 当多个源代码包含同一个头文件时,每个源文件都会复制头文件中的内容,这会导致编译链接阶段的多个变量、函数同名。
为什么可以定义结构体呢:
- 结构体的定义只是定义了结构体的模板,有哪些成员,及其类型;而不是结构体变量的定义。
为什么宏定义可以放在头文件呢:
- 宏定义是文本的替换,在预处理阶段就在调用宏的地方进行文本替换了,不存在什么重复定义的问题。
- 宏定义和结构体定义可以说是一种“声明”,并没有定义实际的内容。
展开说:
-
函数声明:函数声明描述了函数的名称、参数列表和返回类型,但不包含函数的实际实现代码。它的目的是为了告诉编译器有关函数的信息,以便在编译阶段进行类型检查和符号解析。函数声明的一般形式为:
返回类型 函数名(参数列表);
-
变量声明:变量声明描述了变量的名称和类型,用于告诉编译器有关变量的信息。变量声明使得其他源文件可以访问这些变量,而不需要了解其具体的定义。变量声明的一般形式为:
extern 数据类型 变量名;
-
宏定义:宏定义使用预处理指令
#define
定义一个标识符代表某个值或代码片段。头文件中的宏定义可以用于定义常量、简化代码、创建函数宏等。它的一般形式为:#define 标识符 替换文本
-
结构体定义:结构体定义描述了一种自定义的数据类型,可以包含多个不同类型的成员变量。头文件中的结构体定义使得其他源文件可以使用这个结构体类型。结构体定义的一般形式为:
typedef struct { 数据类型 成员1; 数据类型 成员2; // ... } 结构体类型名;
3.3 头文件和extern 、static
【C语言】存储类别(作用域、链接、存储期)、内存管理和类型限定符(主讲const)
四、 其他指令
4.1 #undef
#undef
是一个预处理指令,用于取消定义(undefine)先前通过 #define
指令定义的宏。
但有时候可能需要取消对某个标识符的定义,这时就可以使用 #undef
来实现。
下面是 #undef
的使用示例:
#include <stdio.h>
#define MAX_VALUE 100 // 定义一个宏
int main() {
printf("Max value: %d\n", MAX_VALUE); // 输出 Max value: 100
#undef MAX_VALUE // 取消对宏的定义
// 下面这行代码会导致编译错误,因为 MAX_VALUE 没有定义
// printf("Max value: %d\n", MAX_VALUE);
return 0;
}
在上述示例中,首先通过 #define
定义了一个宏 MAX_VALUE
,它被赋值为 100。然后在 main
函数中使用了该宏。但是,通过 #undef MAX_VALUE
指令取消了对宏 MAX_VALUE
的定义。因此,如果在取消定义之后尝试使用 MAX_VALUE
,会导致编译错误。
使用 #undef
可以有效地取消宏的定义,使其在取消定义之后不再可用。这样可以提供更大的灵活性,在需要时可以取消宏的定义,以便在后续代码中使用不同的定义或避免命名冲突。
4.2 条件编译
条件编译是一种在源代码中根据条件选择性地编译特定部分的机制。它使用预处理指令来指定编译时应该包含或排除哪些代码。
条件编译通常用于处理不同平台、不同编译器或不同配置下的代码差异。它允许在同一份源代码中包含多个代码分支,每个分支根据不同的条件进行选择性编译。
在 C 和 C++ 中,常用的条件编译指令是 #ifdef
、#ifndef
、#if
、#elif
、#else
和 #endif
。
下面是一些常见的条件编译指令及其使用示例:
-
#ifdef
和#ifndef
:#ifdef
指令检查某个标识符是否已经定义,#ifndef
指令检查某个标识符是否未定义。如果条件为真,则执行对应的代码块。#ifdef DEBUG // 在 DEBUG 模式下执行的代码 #endif #ifndef DEBUG // 在非 DEBUG 模式下执行的代码 #endif
-
#if
、#elif
和#else
:#if
指令允许在编译时进行条件判断,根据表达式的值来选择性地编译代码。#elif
指令用于指定额外的条件,#else
指令用于指定除前面条件外的默认情况。#if defined(PLATFORM_WINDOWS) // Windows 平台下的代码 #elif defined(PLATFORM_LINUX) // Linux 平台下的代码 #else // 默认情况下的代码 #endif
条件编译指令允许根据条件来选择性地编译代码,从而在不同情况下实现不同的代码逻辑。这对于处理跨平台开发、调试代码或根据不同的配置构建不同的版本非常有用。但应注意,过度使用条件编译可能导致代码可读性降低和维护困难,因此应谨慎使用,并根据实际需要进行合理的代码组织。
4.3 预定义宏
预定义宏(Predefined Macros)是在编译器中预先定义的一组宏,它们提供了有关编译环境、编译选项和代码特性的信息。预定义宏在编译过程中自动可用,可以在源代码中使用或通过条件编译进行判断。
预定义宏的名称通常以双下划线开头和结尾,以区分它们与用户定义的宏。预定义宏的具体定义和可用性取决于编译器和平台,以下是一些常见的预定义宏及其含义:
-
__FILE__
:表示当前源文件的文件名(字符串常量)。 -
__LINE__
:表示当前代码行号的整数值。 -
__DATE__
:表示当前编译的日期,格式为"MMM DD YYYY"(字符串常量)。 -
__TIME__
:表示当前编译的时间,格式为"HH:MM:SS"(字符串常量)。 -
__cplusplus
:仅在 C++ 编译环境中定义,表示当前编译器正在编译 C++ 代码。 -
__STDC__
:表示编译器遵循 C 标准的版本号。如果编译器遵循 ANSI C 标准,则定义为 1。 -
__STDC_HOSTED__
:表示编译器是否在宿主环境中运行。如果是宿主环境(如完整的操作系统),则定义为 1;如果是嵌入式环境,则未定义。 -
__STDC_VERSION__
:表示编译器遵循 C 标准的版本号。对于 C89/C90 标准,该宏的值为 199409L;对于 C99 标准,该宏的值为 199901L;对于 C11 标准,该宏的值为 201112L。
通过使用预定义宏,可以在源代码中获取有关编译环境和代码特性的信息,以及根据不同的条件执行不同的代码逻辑。预定义宏在编写跨平台代码、条件编译和调试代码时非常有用。
4.4 #line和#error
#line
和 #error
用于在编译过程中进行控制和错误处理。
-
#line
:#line
指令用于修改编译器生成的行号和文件名。它可以在代码中模拟指定不同的行号和文件名,有时在代码生成工具或宏展开过程中会使用到。语法:
#line line_number "file_name"
line_number
表示要设置的行号。file_name
表示要设置的文件名。
示例:
#line 42 "custom_file.c" // 后续的代码将模拟在文件 custom_file.c 中的第 42 行
在上述示例中,
#line
指令将设置代码的行号为 42,并将文件名设置为 “custom_file.c”。这在某些情况下可能有用,例如在代码生成工具中生成特定的行号和文件名。 -
#error
:#error
指令用于在预处理阶段生成一个编译错误。它允许在预处理期间根据特定的条件或要求生成自定义的错误消息,以防止代码继续编译。语法:
#error error_message
error_message
表示要生成的错误消息。
示例:
#if !defined(VERSION) #error "VERSION 宏未定义,请定义 VERSION 宏" #endif
在上述示例中,如果宏
VERSION
未定义,则预处理阶段将生成一个编译错误,提示用户定义VERSION
宏。这可以用于强制要求在编译时提供必要的宏定义或进行条件检查。
使用 #line
和 #error
可以在预处理阶段对代码进行控制和错误处理。 #line
指令允许修改行号和文件名,而 #error
指令允许生成自定义的编译错误。
4.5 #praga
#pragma
用于向编译器传递特定的指示或命令。它可以用于控制编译器的行为、设置编译选项、调整编译环境或提供特定的编译器指示。
#pragma
的具体使用方式和支持的功能取决于编译器和平台。不同的编译器可能支持不同的 #pragma
指令,并提供不同的功能。下面是一些常见的 #pragma
用途:
-
编译器警告抑制:
#pragma warning
或#pragma GCC diagnostic
可以用于控制编译器的警告行为。通过指定警告的级别或警告的开启/关闭状态,可以对特定的警告进行抑制或启用。#pragma warning(disable: 1234) // 抑制警告编号为 1234 的警告 #pragma GCC diagnostic ignored "-Wformat" // 忽略 "-Wformat" 警告
-
对齐设置:
#pragma pack
可以用于指定结构体或数据的对齐方式。通过设置对齐方式,可以控制数据在内存中的布局。#pragma pack(1) // 按 1 字节对齐
-
扩展和特定功能:某些编译器可能提供特定的
#pragma
扩展,用于启用或配置特定的功能。例如,#pragma omp
用于指定 OpenMP 并行化的指令。#pragma omp parallel for // OpenMP 并行化指令
#pragma
是编译器特定的指令,不属于 C 或 C++ 的标准。因此,使用 #pragma
指令可能会导致代码在不同的编译器上产生不同的行为或不可移植性。为了确保代码的可移植性,应谨慎使用 #pragma
并尽可能遵循标准的语言功能。
4.6 头文件如何命名
假如有个头文件:example.h
。一般它的内容如下:
#ifndef EXAMPLE_H
#define EXAMPLE_H
// 函数声明
void myFunction(int arg1, float arg2);
// 宏定义
#define MAX_VALUE 100
// 类型定义
typedef struct {
int x;
int y;
} Point;
#endif
在这里,EXAMPLE_H
是一个宏定义,用于防止头文件的多次包含。这个名称可以根据头文件的名称来选择,通常使用大写字母和下划线的方式来表示宏定义。在这种情况下,EXAMPLE_H
可以理解为是一个与头文件 example.h
相关的唯一标识符。
使用大写字母和下划线的命名方式有助于提高宏定义的可读性,并且与其他变量或函数名的命名方式有所区别,以避免命名冲突。请注意,这种命名约定是一种常见的做法,并不是硬性规定,你可以根据自己的喜好和项目的约定进行调整。关键是保持一致性,并确保在整个项目中使用唯一的宏定义名称。
但是,#define example.h
肯定不行,宏定义不能有扩展名和路径。
把 永 远 爱 你 写 进 诗 的 结 尾 ~