C/C++的编译过程大致如下:
以此流程图为线索,我们的问题将依次展开。
1. 预处理
预处理的过程主要处理包括以下过程:
- 将所有的#define删除,并且展开所有的宏定义
- 处理所有的条件预编译指令,比如#if #ifdef #elif #else #endif等
- 处理#include 预编译指令,将被包含的文件插入到该预编译指令的位置。
- 删除所有注释 “//”和”/* */”.
- 添加行号和文件标识,以便编译时产生调试用的行号及编译错误警告行号。
- 保留所有的#pragma编译器指令,因为编译器需要使用它们
只有在C和C++中有头文件的概念,与其相比,在其他热门的语言(如PHP,Java,Python等)中,我们没有看见过这种设定。至于为什么要有头文件,这是C这门相对来说古老的语言对“将声明和实现分开”的一种手段,是继承了以前的使用习惯。
虽说如此,但头文件的使用确实为人所诟病。一方面会对初学者造成困惑,我刚开始接触C++的类时就对头文件的作用感到不解,不知道头文件和C文件中应该分别放什么内容。现总结一下,头文件中一般放类的声明,包括类的成员函数的声明(只是声明而不涉及具体实现)。在c文件中通过::
运算符,在外部实现具体的函数功能。这也非常符合“将声明和实现分开”这一初衷。
另一方面会带来诸多麻烦的问题。在预处理阶段,在所有引入头文件的地方,#include
原地展开,即将头文件的内容拷贝至C文件中。
这带来的第一个问题就是重复包含。直接和间接引入的头文件发生重复,使得一个头文件会被多次的复制到同一个C文件中,造成文件臃肿。该问题的解决方法就是使用#ifndef
,如:
// sum.h
#ifndef _SUM_ //如果没有定义SUM
#define _SUM_ //那么就定义SUM,并引入下面的内容,否则不引入
int sum(int m, int n)
{
return m + n;
}
#endif
第二个问题就是由于编译器所做的工作只是简单地拷贝展开,所以头文件引入内容是有先后顺序的。我们知道在C中,变量和函数都是要先声明才能使用。如此一来,很有可能导致申明的最终展开位置在使用之后,导致编译器报错。
2.编译
这一过程将C代码编译成汇编代码。
3.汇编
将汇编代码转成机器代码,汇编代码和机器代码几乎是等效的,通常一条汇编语句就对应着一条或几条机器指令,生成的文件称作目标文件(Obj文件)。
4.链接
以目标文件(全部)、库文件为输入,解析未定义的符号引用,将目标文件中的占位符替换为符号的地址,完成程序中各目标文件的地址空间的组织(重定位),输出可执行二进制文件(.exe)。
链接的工作相当于将各个对象文件之间打通,使其成为一个完整的可执行文件。
目标文件和我们所熟知的.exe文件都是PE文件,在PE的文件头中有专门的数据结构记录了该PE文件中所有的变量名和函数名以及它们对应的在文件中的相对虚拟地址(RVA),这就解释了链接工作是如何执行的。
同时也解释了C/C++库的结构是怎么一回事。头文件+动态链接库(.dll文件),通过变量名和函数名来索引RVA,以此进行重定位,链接各个目标文件。这样做的好处是库的编写者既满足了用户的使用需求,又可以防止用户查看和修改库的源代码。
进一步,在源文件中内嵌其他语言的代码(extern "C"
),利用的也是PE文件之间的相通性。不同的源文件最后生成同样的PE文件,链接在一起成为一个完整的可执行程序。这里涉及到一个问题,由于不同语言各自的编译可能会产生重名,因此各个编译器都会对变量名和函数名加以修饰,如c在函数名前面添加__
, C++添加_
和 @
符号等。
例如 ??0CP2PDownloadUIInterface@@QAE@ABV0@@Z
。