# C 语言的编译过程
编译过程 可以被分为几个阶段,每个阶段都由不同的工具或编译器的组件执行特定的任务:
- 预处理:在这个阶段,预处理器执行文本替换,如宏展开和文件包含,同时删除注释。
- 编译:编译器进行语法和语义分析,确保代码符合语言规范,并且是有意义的。接着,编译器对代码进行优化,以提高执行效率。
- 汇编:编译器将优化后的代码转换成汇编代码,这是一种接近机器语言的低级表示形式,但仍包含一些人类可读的助记符。
- 链接:链接器将编译生成的目标文件( .o 或.obj)与其他库和目标文件链接在一起,生成最终的可执行文件或库文件。
目标文件 是编译过程中的一个中间产物,包含汇编器转换后的机器指令,已经是二进制格式,对人来说难以阅读。
编译 是将人类可读的高级语言代码转换成计算机可执行的低级机器语言代码的转换过程。这个过程涉及多个步骤,每个步骤都对最终生成的代码的性能和效率有重要影响。
在 C 语言的编译过程中,编译器将源代码转换成一个目标文件(如 .o
或 .obj
文件)。这个目标文件包含了源代码对应的机器代码,但它本身并不是一个可执行程序。原因在于,源代码中可能引用了外部的函数或变量,而这些外部定义在单独编译时尚未存在。
例如,如果源文件包含了标准库头文件 <stdio.h>
并调用了 printf
函数,它实际上只包含了 printf
的声明,而没有包含其定义。没有函数的定义,程序就无法执行该函数的调用,因此仅有目标文件是不够的。
链接过程 是编译后的下一步,它负责将目标文件与它所依赖的外部函数和变量的实现链接起来。对于标准库函数如 printf
,链接器会从标准库提供的相关文件中找到其定义,并完成链接过程。这个过程对于程序员通常是透明的,因为编译器和链接器会自动处理这些细节。
需要注意,函数和变量的声明可以在多个地方出现,但它们的定义在程序中只能有一次。多次定义会导致编译错误。
# 预处理阶段
C 语言的编译过程开始于预处理阶段,预处理器在这个阶段主要执行两项任务:
- 执行预处理指令:这些指令提供了多种编译时功能,如文件包含、宏定义等。
- 删除代码中的注释:预处理器会移除源代码中的所有注释,以便进行下一步的编译。
预处理后生成的文件被称为 “预处理后的源代码文件”,它与原始源代码文件具有相同的扩展名。
# 预处理指令
- #include:此指令用于包含头文件,头文件中通常包含了函数声明、结构体或联合体类型的定义、宏定义以及类型别名等。
- 函数声明提供了函数的原型,包括返回类型、函数名和参数列表,但不包含函数的实现。
- 函数定义包含了函数的完整实现,使得函数可以在程序中被调用和执行。
- 使用
#include
指令可以视为将头文件的内容复制到指令所在位置。
- #include <>:使用尖括号
<>
指示编译器在标准库目录中搜索头文件,通常用于包含官方提供的标准库头文件。 - #include "":使用双引号
""
指示编译器首先在当前目录下搜索头文件,如果当前目录下不存在,则在系统目录中搜索,这通常用于包含用户自定义的头文件。 - #define:此指令用于定义宏,它可以定义宏常量或宏函数。宏定义时推荐使用全部大写字母并用下划线分隔的方式命名,以增强可读性和避免与变量名冲突。
# 编译和链接错误
在 C 语言程序的编译和链接过程中,可能会遇到不同类型的错误,这些错误分别由编译器和链接器抛出:
- 编译错误:这些错误发生在预处理、编译或汇编的阶段,通常由语法错误导致。例如:
- 忘记在语句末尾添加分号;
- 拼写错误,如将关键字或标识符错误地书写;
- 包含不存在的头文件。
- 链接错误:这些错误发生在链接阶段,通常与函数调用有关。常见的原因包括:
- 函数名拼写错误,导致链接器找不到该函数的定义;
- 忘记包含必要的头文件,而这些头文件中含有所需函数的声明;
- 只包含了头文件,但未提供函数的定义,导致链接器无法找到函数的实现。
为了避免这些错误,程序员需要仔细检查代码的语法正确性,并确保所有外部函数和变量都有相应的声明和定义。此外,使用编译器的警告和错误信息可以帮助识别和修复这些问题。
# 文件包含
# 头文件
头文件(以 .h 为扩展名)通常包含了程序中所需函数的声明。这些声明为编译器提供了必要的信息,以便正确编译程序。然而,并非总是需要引入整个头文件来使用特定的函数。
例如,如果只需要使用 puts()
函数,可以将该函数的声明从 <stdio.h>
头文件中提取出来,并直接放置在源代码文件的顶部,从而避免引入整个头文件。
这种方法可以减少编译时的依赖,提高编译效率,在大型项目中,通过减少不必要的头文件包含,可以简化编译过程并减少潜在的编译错误。
int __cdecl puts(char const *_Buffer); // 不调用头文件的情况下,使用 puts 函数 | |
int main(void) { | |
puts("hello world!"); //hello world 可正常打印出来 | |
return 0; | |
} |
# 头文件展开
头文件的展开过程涉及提取头文件内的所有函数声明,并将其放置在主函数( main ()
)的上方。值得注意的是,头文件可能相互嵌套,即一个头文件中可能包含对其他头文件的引用,这使得展开过程本质上是递归的。
可以使用 GCC 编译器执行头文件的展开,。以 file_include.c
文件中包含的 #include <stdio.h>
为例(如果 stdio.h
内还包含了其他头文件,这些也会被递归展开),可以通过在终端中执行命令
gcc -E file_include.c -o file_include.i |
用来生成一个展开后的中间文件 file_include.i
。该文件包含了原本头文件中的所有内容,可以被重命名为 file_include.i.c
,并使用 MSVC 编译器进行编译,以获得与原始 .c
文件相同的执行结果。
然而,如果直接使用 GCC 编译 file_include.i.c
可能会遇到错误,具体原因尚待进一步探究。
# 自定义头文件
在 C 语言编程中,使用双引号 "" 括起来的 #include
指令用于指定相对或绝对路径来包含头文件。例如, #include"../include/factorial.h"
表示当前源文件将包含其上级目录中的 include
文件夹里的 factorial.h
头文件。这种方式允许程序员引入项目结构中任意位置的自定义头文件。
尖括号 <> 内的 #include
指令用于包含标准库或第三方库的头文件。这些头文件通常位于编译器的默认搜索路径或项目配置的库路径中。例如, #include <stdio.h>
会引入标准输入输出库的头文件,而 #include <some_external_library.h >
则可能引入项目中引用的外部库的头文件。
# 宏函数
# 宏函数的概念
宏函数是一种接受输入参数并根据这些参数计算结果的宏定义。它们通过预处理器实现,允许在编译时直接嵌入代码,从而提高了程序的效率。
在 C 语言的预处理阶段,宏定义通过 “文本替换” 机制实现,这是一种直接的文本替换过程,不涉及类型检查或范围验证。
宏常量:它们为字面值常量提供了一个别名,其本质与直接使用字面值常量相同,但提高了代码的可读性和可维护性。
在定义宏函数时,需要注意以下几点:
- 确保运算优先级:由于宏函数是文本替换,可能需要在适当的位置添加括号来确保正确的运算顺序。
- 参数括号化:每个参数在宏函数的表达式中都应该被括号包围,以避免由于宏扩展导致的运算符优先级问题。
- 整体括号化:宏函数的整个表达式也应该被括号包围,确保在宏展开后表达式的完整性和正确性。
例如,定义一个求两个数中较大值的宏函数 MAX
,可以使用以下语法: #define MAX(a, b) ((a) > (b)) ? (a) : (b)
这个宏定义使用了条件运算符(也称为三元运算符),它基于比较结果选择两个参数中的较大值。当宏函数 MAX
被调用并传入两个参数时,预处理器会将宏调用替换为相应的条件表达式。
# 宏函数的连续调用
宏函数的嵌套调用需要特别注意,因为宏函数在预处理阶段进行简单的文本替换,不会自动考虑 C 语言的运算符优先级规则。这可能导致计算结果不符合预期。
例如,定义了一个简单的比较宏函数 MAX,用于返回两个值中较大的一个:
#define MAX (a, b) a > b ? a : b} |
如果直接嵌套调用这个宏函数,如 MAX (1, MAX (3, 4))
,由于缺少必要的括号,将导致运算符优先级混乱,从而得到错误的结果。
正确的做法是在宏函数的定义中明确使用括号,以确保运算的逻辑顺序,如下所示:
#define MAX (a, b) ((a) > (b)) ? (a) : (b)} |
这样,即使宏函数被嵌套调用,每个参数的计算也将被正确地限制在括号内,从而保证得到预期的结果,例如 MAX (1, MAX (3, 4))
将会得到正确的值 4。
# 定义多行宏
若需定义一个跨越多行的宏,可以使用反斜杠 \
来实现行的连接。这种技术允许宏定义在视觉上和逻辑上分割成多行,同时确保预处理器能够正确地将它们视为一个连续的宏定义。
// 定义多行宏 | |
#define IS_HEX_CHARACTER(ch) \ | |
((ch) >= '0' && (ch) <= '9') || \ | |
((ch) >= 'A' && (ch) <= 'F') || \ | |
((ch) >= 'a' && (ch) <= 'f') | |
int main() { | |
printf("is A a hex character? %d\n", IS_HEX_CHARACTER('A')); | |
return 0; | |
} |
# 宏与函数的区别
特性 | 宏 | 函数 |
---|---|---|
代码插入方式 | 每次使用时,宏代码都被插入到程序中 | 函数代码只出现于一个地方;每次使用这个函数时,调用用那个地方的一份代码 |
代码长度 | 除了非常小的宏之外,程序的长度将大幅度增长 | 代码长度增长幅度较小 |
操作符优先级 | 操作符优先级可能会产生不可预料的结果 | 表达式的求值结果更容易预测 |
参数求值 | 参数的求值在所有周围表达式的上下文环境里,除非加上括号,否则邻近的操作符优先级可能会导致多次求值 | 参数在函数被调用前只求值一次。在函数中多次使用参数可能导致多次求值的问题 |
参数类型 | 宏与类型无关。只要对参数的操作是合法的,它可以适用于任何参数 | 函数的参数是与类型有关的。如果参数的类型不同,就需要使用不同的函数,即使它们执行的任务是相同的 |
# 条件编译
# #ifndef
, #ifdef
, #if
在 C 语言的预处理指令中, #ifndef
代表 “如果未定义”(if not defined),用于检查某个宏是否尚未定义。如果指定的宏未定义,则条件成立,后续代码块将被包含在编译过程中。
#ifdef
代表 “如果已定义”(if defined),它用于检查某个宏是否已经被定义。如果指定的宏已经定义,则条件成立,后续代码块将被包含。
#if
指令可以与 defined ()
函数一起使用,来检查宏是否已定义。 if defined (MACRO)
与 #ifdef MACRO
是等价的,都用于在宏 MACRO
已定义时包含后续代码块。
所有这些条件编译指令都需要以 #endif
指令结束, #endif
标志着条件编译块的结束。
// 头文件中必有的条件编译,以免一些头文件被重复引用 | |
#ifndef INCLUDE_FACTORIAL_H | |
#define INCLUDE_FACTORIAL_H | |
unsigned int Factorial(unsigned int n); | |
unsigned int FactorialByIteration(unsigned int n); | |
#endif // INCLUDE_FACTORIAL_H | |
// #if 的用法 | |
#ifdef DEBUG // == if defined(DEBUG) | |
puts(message); | |
#endif |
# 利用宏打印调试信息
在 C 语言程序开发中,宏可以用于控制调试信息的输出。
例如,通过宏 DEBUG
的存在与否,可以决定是否执行特定的打印操作。当宏 DEBUG
未被定义时,调用 Dump
函数将不会输出任何信息;而一旦定义了 DEBUG
,则 Dump
函数将正常输出传入的消息字符串。
以下是一个 Dump
函数的示例实现,它根据宏 DEBUG
的定义情况来决定是否执行打印操作:
#define DEBUG // 该宏定义存在的情况下,输出结果为 start! hello end | |
// 该宏定义不存在的情况下,输出结果为 hello | |
void Dump(char *message) { | |
#ifdef DEBUG | |
puts(message); | |
#endif | |
} | |
int main() { | |
Dump("start!"); | |
printf("hello\n"); | |
Dump("end"); | |
return 0; | |
} |
在 main
函数中,调用 Dump
函数前后分别打印了 "hello",但只有定义了 DEBUG
时,"start!" 和 "end" 才会被打印出来。
此外,宏 DEBUG
的定义不仅限于在源代码中进行,还可以通过构建系统如 CMake 来控制。通过修改 CMakeLists.txt
文件,可以在构建过程中定义或不定义宏 DEBUG
。
例如,以下 CMake 脚本片段展示了如何为项目中的可执行文件添加宏定义:
# 添加该句即意味着定义了宏 DEBUG | |
target_compile_definitions(${name} PUBLIC DEBUG) |
# 一段代码在 C 语言下定义还是在 C++ 语言下定义?
确定代码是在 C 语言还是 C++ 语言环境下定义,可以通过检查特定的语言特性来实现。
例如,在 C++ 中,为了确保代码能够与 C 代码兼容,可能会使用 extern "C"
链接说明符。这个说明符指示编译器在指定的代码块内使用 C 语言的链接规则,而不是 C++ 的名称修饰(name mangling)规则。因此,如果代码段包含 extern "C"
,它很可能是为了在 C++ 环境中使用而定义的。
相反,在纯 C 语言环境中编写的代码不会包含 extern "C"
,因为 C 语言不存在名称修饰的问题。C 语言的编译器默认使用 C 语言的链接规则来处理所有代码。
#ifdef __cplusplus // 如果是在 c++ 环境下,会查询是否定义了宏 __cplusplus | |
extern "C" { | |
#endif | |
// ... code | |
#ifdef __cplusplus // 相当于在代码外围添加了一层 | |
}; | |
#endif |
# 一段代码运行在哪个 C 语言版本?
要确定代码是针对哪个 C 语言标准编写的,可以通过检查编译器定义的 __STDC_VERSION__
宏来实现。这个宏在编译时包含了一个版本号,代表了编译器支持的 C 标准的具体版本。
例如,如果 __STDC_VERSION__
的值为 201710L
,则表示编译器支持 C17 标准。
需要注意的是, __STDC_VERSION__
宏在不同的编译器中可能有不同的支持情况。例如,在 MinGW 编译器中可以正常使用,而在 MSVC 编译器中可能不被识别或支持。
int main() { | |
printf("%d\n", __STDC_VERSION__); | |
#if __STDC_VERSION__ >= 201710 | |
puts("C17!!"); | |
#elif __STDC_VERSION__ >= 201112 | |
puts("C11!!"); | |
#elif __STDC_VERSION__ >= 199901 | |
puts("C99!!"); | |
#else | |
puts("maybe C90?"); | |
#endif | |
return 0; | |
} |
# 代码运行的环境?
识别代码运行的操作系统平台是跨平台软件开发中的一个重要方面。这可以通过预处理器的条件编译指令来实现,这些指令能够检测操作系统特有的宏定义。
在 Windows 平台上,通常定义了 _WIN32
或 _WIN64
宏。在 Linux 上,可能会定义 linux
或 __linux__
宏。而在 Mac OS 上, __APPLE__
宏被定义,并且通常伴随着 __MACH__
宏。利用这些宏,可以编写条件编译代码。