C语言-8-程序的编译与预处理操作
摘要
本文将详细介绍当你在VS中按下ctrl+f5
或者linux命令行下输入gcc 文件名
后计算机所执行的一系列操作,包括预处理、编译、汇编、链接,以及一些C语言的预处理指令的使用。
翻译过程
总述
在ANSI C的任意一种实现中,存在2中不同的环境。第一种是翻译环境,负责将源代码转换成可执行的机器指令;第二种是执行环境,用于实际执行代码。*[1]*
一个程序从源代码到可执行程序一共会经历四个过程,分别是预处理、编译、汇编、链接,每一步都承担了不同的任务。
以下是GCC and Make Compiling, Linking and Building C/C++ Applications 中的描述 [2]:
GCC compiles a C/C++ program into executable in 4 steps as shown in the above diagram. For example, a “gcc -o hello.exe hello.c” is carried out as follows:
Pre-processing: via the GNU C Preprocessor (cpp.exe), which includes the headers (#include) and expands the macros (#define).
cpp hello.c > hello.i
The resultant intermediate file “hello.i” contains the expanded source code.
Compilation: The compiler compiles the pre-processed source code into assembly code for a specific processor.
gcc -S hello.i
The -S option specifies to produce assembly code, instead of object code. The resultant assembly file is “hello.s”.
Assembly: The assembler (as.exe) converts the assembly code into machine code in the object file “hello.o”.
as -o hello.o hello.s
Linker: Finally, the linker (ld.exe) links the object code with the library code to produce an executable file “hello.exe”.
ld -o hello.exe hello.o …libraries…
如图一、二、三所示:
## 预处理
C预处理依赖C预处理器,C预处理器在程序执行之前查看程序(故称为预处理器)。根据程序中的预处理指令,预处理器把符号缩写替换成其表示的内容。预处理器可以包含程序所需的其他文件,可以选择让编译器选择性地查看某些代码(如#if
#endif
等)。
本质上而言,C预处理器是将一些文本转换成了另外一些的文本,所以C预处理器并不能看懂C语言。
在预处理前,编译器必须对该程序进行一些翻译处理。
a)首先,编译器把源代码中出现的字符映射到源字符集。
b)第二,编译器定位每个反斜杠 ‘\‘ 后面跟着的换行符的实例,并删除他们。也就是说,将两个物理行(physical line):printf("That is wond\
erful!\n");
转化成一个逻辑行(logical line):printf("That is wonderful!\n");
注意,在这种场合下,换行符的概念是通过按下Enter键在源代码文件中换行所生成的字符,而不是指换行符 ‘\n’。
c)编译器把文本划分为预处理记号序列、空白序列和注释序列。编译器将用一个空格字符替换每一条注释。如:int /*It's an annotation*/ a;
变为int a;
预处理指令将在下面一节详细讲解
那么现在,让我们在Linux平台下测试一下当test.c源文件只经过预处理后生成的test.i有何特点。
test.c文件中,我们定义了标识符X,宏MAX,如下:
1 |
|
只进行预处理
1 | gcc -E test.c -o test.i |
得到的test.i文件如下
1 | # 4 "test.c" 2 |
我们发现,在预处理阶段,标识符X与宏MAX所代表的一串字符被替换到了源文件中的X与MAX处。
值得注意的是,#define定义的标识符可以嵌套使用,例如上面的X作为了MAX的参数。
编译
编译的作用是将预处理完文件中的源代码转化成汇编代码
我们在Linux平台下对 test.c 进行编译操作,使test.c源文件在编译完之后停止
1 | gcc -S test.c |
得到的test.s文件如下:
1 | .file "test.c" |
这其实就是 test.c 的汇编代码。
链接
我们将汇编代码文件与库函数文件进行链接,随即便能生成a.out的可执行文件。
在Linux环境下,stdio.h头文件包括在 /usr/include 路径下。
我们执行:
1 | gcc-C test.c |
得到
1 | a.out |
运行
1 | ./a.out |
运行结果如图四:
# 执行过程
程序的执行过程如下:
- 程序载入内存
- 调用main函数
- 开始执行代码,开辟堆栈存放临时变量与返回地址,也可以使用静态内存
- 程序正常终止或意外终止
预处理指令
预定义符号
C语言内置了一些预定义符号:
1 | __FILE__ //进行编译的源文件 |
请看下面的代码:
1 | 在这里插入代码片 |
运行结果如图五所示:
## #define定义宏常量
#define能够定义标识符,在预处理阶段预处理器会将标识符的内容进行替换
如:
1 |
#define定义宏函数
#define能够定义宏,宏的声明方式为
1 |
其中,parament-list是一个由逗号隔开的参数列表,里面的参数能够出现在stuff中。
注意,参数列表的左括号必须与name紧邻。如果两者之间有任何空白存在,参数列表就会被解释成stuff的一部分。
如:
1 |
这个宏用来求两数之间的较大者
以下还有3条注意事项
- 我们在定义宏的时候,最好把每一个参数用()括起来,最后,把整个stuff用()括起来
- 宏的参数中可以出现其他#define定义的标识符,但是宏不能递归
- 当预处理器搜索#define定义的符号时,字符串常量(包含在“ ”中)的内容并不被搜索,替代。
我们来看下面一段代码解释一下第一条注意事项:
1 |
|
运行结果如图六:
我们的本意是想要对于3+2先进行加法,后进行平方,得到25
但是,实际上的结果是这样进行运算的:
3 + 2 * 2 +3
得到11
所以,我们在书写宏的时候,最好把每一个参数用()括起来,最后,把整个stuff用()括起来,避免在使用宏时参数中的操作符或临近操作符之间不可预料的相互作用
如:
1 |
这样便能得到预期的答案 25
#:将宏的参数插入到字符串中
使用#可以使一个宏参数变为对应的字符串
1 |
|
运行结果如图六:
可见,#宏参数名 == “宏参数名”,将参数名转化成了一个字符串
##:预处理器粘合剂
使用##可以将宏的参数与其中的内容粘合起来
1 |
|
上述代码中,answer便与NUM参数的值粘合了起来
值得一提的是,可以连续多次使用##将更多的参数粘合起来
宏与函数的对比
宏与函数的功能貌似有些相近,我们用表一来总结一下宏与函数的异同:
|属性|#define定义宏|函数|
|–|–|–|
|代码长度|每次使用时,宏都会被插入到代码中,除了非常小的宏之外,代码长度会大幅增加|函数的代码只出现在一个地方。每次调用函数,只调用该处的代码|
|执行速度|更快|存在函数的调用与返回的额外开销,相对慢一些|
|操作符优先级|宏的参数的求值需要结合周围表达式的上下环境,除非加上括号,否则临近操作符的优先级便会出现不可预料的后果,所以建议在宏的书写时加上括号|函数的参数只在传参时求值一次,求值结果传递给函数,不存在参数带来的优先问题级|
|带有副作用的参数|参数可能被替换到宏体的多个位置,参数带来的副作用可能会使结果难以预测,如前置++、后置++|函数参数只在传参时求值一次,将结果传递给参数|
参数类型|宏的参数与类型无关,只要对参数的操作是合法的,就可以使用任何参数|函数的参数与类型有关,参数类型不同,就要使用不同的参数,即使它们执行的任务是相同的|
|调试|宏不便于调试|函数能够逐语句、逐过程调试|
|递归|宏不能递归|函数能够递归|
## 命名约定
我们有这样一个命名约定,对于宏名,全部大写;函数名,不要全部大写
命令行定义
许多C语言编译器提供了一种能力,允许我们在命令行中定义符号,用于启动编译。
请看下面的代码:
1 |
|
我们可以看出,代码中数组大小 MAX 未定义。该源文件名为test2.c
在Linux环境下,执行:
1 | gcc -D MAX=100 test2.c |
运行结果如图七:
我们成功在命令行中对于MAx进行了定义。
移除宏定义
1 |
如果我们想对MAX重定义,一种方法是先将MAX移除,再定义
1 |
条件编译
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
这有利于我们的调试。
以下是常用的条件编译指令:
1.当 #if 之后的语句为真,执行中间的命令
1 |
|
2.多分支条件编译
1 |
|
3.判断是否被定义/未被定义
1 |
|
或
1 |
|
4.判断是否未被定义
1 |
|
或
1 |
|
5.嵌套指令
1 | if defined(OS_UNIX) |
文件包含
我们已经知道,#include 指令可以使另外一个文件被编译。
这种替换的方式很简单: 预处理器先删除这条指令,并用包含文件的内容替换。 这样一个源文件被包含10次,那就实际被编译10次。
1 |
查找策略:先从自定义目录中寻找头文件,若没找到,去库目录中寻找
1 |
查找策略:直接去库目录中寻找
那么,就会出现这样一种情况:一个程序包括了若干个源文件,而其中多个源文件都引用了头文件,这样就会导致嵌套文件的情况发生,如图八:
game1.c/game1.h与game2.c/game2/h 同时调用了lib.c/lib.h这一公共模块,而test.c/test.h又同时调用了game1.c/game1.h与game2.c/game2/h,所以当test.c编译时,会将lib.c/lib.h中的代码重复编译2次
解决方案1:
每个头文件开头写
1 |
|
解决方案2:
每个头文件开头写
1 |
#error
#error 是一种预编译器指示字,用于生成一个编译错误消息 。
用法:#error [message] //message为用户自定义的错误提示信息,可缺省。
#error 编译指示字用于自定义编译错误消息。
1 |
参考文献
[1] C Primer Plus p521-563
[2] [GCC and Make Compiling, Linking and Building C/C++ Applications