编译
为了编译 C++ 程序,我们使用 C++ 编译器。C++ 编译器按顺序遍历程序中的每个源代码 (.cpp) 文件。
它会检查您的代码以确保它遵循 C++ 语言的规则。如果没有,编译器会给你一个错误(和相应的行号)以帮助确定需要修复的内容。编译过程也将中止,直到错误被修复。
将源代码转变为可执行的二进制主要需要两个步骤,第一是编译,第二是链接。编译主要是将源代码转变为中间形式,obj,目标文件,然后目标文件经由链接器连接,生成了可执行代码。
对于编译过程而言,首先要做的事情是预处理,预处理对所有的预处理项进行相应处理,然后进行分词和语法处理,将人比较容易看懂的代码生成编译器比较容易处理的语法树,然后将所有的代码要么生成常量,要么生成指令。一旦生成了语法树,就可以进行代码生成了。
对于C++语言,每个.cpp文件就是一个翻译单元,每个.cpp文件都会被翻译成一个.obj文件。
Main.cpp
#include <iostream>
void Log(const char* message);
int main()
{
Log("hello,wrold");
std::cin.get();
return 0;
}
Log.cpp
#include <iostream>
void Log(const char* message)
{
std::cout << message << '\n';
}
例子中的两个源代码log.cpp和main.cpp大小都不大,但是生成的.obj文件体积较大.
这个原因主要是#include 所导致的。如何理解#include这个预处理,创建一个新的小的cpp文件,math.cpp
int Multiply(int a, int b) {
int result = a * b;
return result;
}
这个cpp文件没有任何的#include或者其他的预处理项。来看下编译的第一步,预处理。常见的预处理项包括#define,#include,#if, #endif等。先看一下最常见的#include。#include非常简单,它指定了你想要包含的文件,预处理器打开那个文件,阅读里面内容然后要包含的内容贴贴到文件中。看一个例子,创建一个新的头文件,EndBrace.h,在这个文件中就只有一个右大括号
按ctrl+F7进行编译报错
左边的大括号没有被匹配。因为我们确实将右括号给删了。
接下来,用#include预处理来处理
现在编译的话,完全没问题。原因就是预处理的时候,编译器就是取打开了EndBrace.h,然后将它里边的内容拷贝到了math.cpp,这样的话,大括号就匹配完成了。
接下来看的更细致一点,修改一下VS的配置,让它输出编译的过程中产生的预处理的结果。右键点击项目,选择属性,将预处理到文件的“否“,改成“是“。
ctrl+f7重新编译,此时可以在debug目录下,发现math.i。打开math.i,可以发现内容如下:
通过上面的操作,应该能够理解#include的作用。
最后看一下#include 的效果。可以看到Math.i多达五万多行,而这么多代码,主要就是iostream的内容。
接下来,恢复配置,不再输出预处理代码,预处理代码都删去;剩下的cpp代码。
int Multiply(int a, int b) {
int result = a * b;
return result;
}
观察一下目标文件。如果直接使用VS打开math.obj,那么可以看到都是二进制。这时候可以修改一下配置,让生成obj的同时输出asm,好看一点。可以通过项目 -> 属性 -> c/c++ -> 输出文件
此时,可以生成.asm文件,打开ams文件
先不要太在意这些汇编代码细节,这个例子讲编译器优化。在上面的Line2的代码中,可以看到首先将变量a保存在寄存器eax中,接下来看到 imul 指令,这里就是将变量a和变量b进行相乘,并且将结果保存在eax中。
然后将eax中的值放在变量result中,最后将result的中又放回了eax。因为eax存放的是函数的返回值。
上面看上去重复的动作主要是因为math.cpp中声明并返回了result变量。这就是如果我们不要求编译器进行优化,那么,编译器产生的代码会很慢。
可以修改一下math.cpp,看一下生成的asm,进行一下对比:
int Multiply(int a, int b) {
return a*b;
}
生成的汇编代码会得到精简但是不明显。
再看一个例子,如果如下修改math.cpp:
int Multiply() {
return 2 * 5;
}
此时可以看到,直接将10移入了eax。这里主要是想说明 常量折叠 (constant folding)的概念。也即,如果代码中有常量,那么在编译的时候就已经计算出了这个常量,并不会将计算遗留到运行时。
下面看一个函数调用的例子
const char* Log(const char* message) {
return message;
}
int Multiply(int a, int b) {
Log("multiply");
return a * b;
}
这里的Log并没有什么用,只是用来演示一下函数调用。当查看汇编码时,可以看到在对应于multiply函数的汇编码中,有call指令,以及之下有imul指令
Log函数名字之后跟着的乱码和@@ 称为函数的签名,便于链接器定位到函数。这里的代码其实有冗余,Log函数基本没有任何作用。我们可以试一下优化
如果遇到和RTC1冲突,可以修改
这样的话, 可以看到生成的汇编码将 call Log完全地给优化掉了
通过以上的例子,对编译器的工作应该有了基本的了解
|