1. 概述
C++编译器是将C++源代码转换为机器码的工具。它执行以下主要步骤来完成编译过程:
阶段 | 内容 |
---|---|
1. 词法分析(Lexical Analysis) | 编译器首先对源代码进行词法分析,将源代码分解为一个个的词法单元(tokens),如关键字、标识符、运算符和常量。词法分析器将源代码逐个字符扫描,并根据预定义的词法规则识别和生成词法单元。 |
2. 语法分析(Syntax Analysis) | 在语法分析阶段,编译器使用词法单元构建语法树(parse tree)或抽象语法树(abstract syntax tree,AST)。语法分析器根据语法规则检查词法单元的排列顺序和结构,以验证源代码的语法正确性。 |
3. 语义分析(Semantic Analysis) | 在语义分析阶段,编译器对语法树或AST进行进一步的分析。它检查标识符的声明和使用是否匹配,执行类型检查,处理表达式的类型转换和推断,以及检查其他语义规则的合法性。 |
4. 代码生成(Code Generation) | 在代码生成阶段,编译器将语法树或AST转换为目标机器的机器码。这个过程包括将高级语言的抽象概念映射到底层的机器指令。编译器生成的机器码可以是汇编语言或二进制机器码,具体取决于编译器的实现和目标平台。 |
5. 优化(Optimization) | 在代码生成之前或之后,编译器通常会执行优化步骤,以改善生成的机器码的质量和性能。优化技术包括常量折叠、循环展开、内联函数、代码消除等。优化过程旨在提高程序的执行效率和资源利用率。 |
总的来说,C++编译器通过词法分析、语法分析、语义分析、代码生成和优化等步骤,将源代码转换为可执行的机器码。这个过程是复杂而关键的,它使得我们能够用高级语言编写程序,并在不同的平台上运行。
1. C++编译器实际上是负责什么?
我们把C++代码写成文本文件,它只是一个文本文件,然后我们需要将这些文本文件转换为计算机可运行的实际应用程序。
源文件从文本形式到实际可执行的二进制文件,会经历两大步骤,一个叫做编译,另外一个被称为链接。这里主要讨论编译。
编译器实际上只需要做的唯一的一件事,就是将我们的文本文件转换成一种名为目标文件的中间格式(.obj)文件,后续便是链接器要做的事情了,然后链接器会链接这些obj文件。
首先,它会预处理我们的代码,这意味着所有的预处理器语句都会优先被处理,一旦我们的代码被预处理,接下来我们的代码将或多或少的会被记号化和被解析,把代码整理成编译器能够真正理解和推理的格式,这个过程基本上生成了所谓的抽象语法树。一旦编译器创建了这个抽象语法树,它便可以开始实际生成代码,生成的代码就是实际我们的CPU将执行的代码。编译器的工作就是转换我们所有的代码,转换成常量数据或指令等。在这个过程中,我们还会得到了其他各种数据,如一个存储所有常量、变量的地方,这基本上就是编译器所做的一切。这并不复杂,当然,它也会随着代码复杂性的增长而变得非常复杂,但这就是要点,也是它的作用。
以下将以VS案例的方式继续深入这个问题,看一下,编译器每个阶段都干些什么,以便可以看到,它是如何运作的。
2.案例
开始案例,创建项目过程省略,可参考C++是如何工作的。
1. obj文件生成
如下项目内容,一个Main.cpp文件和一个Log.cpp文件。
以上,我们有一个简单的Hello World应用程序
开始调试按F5
或点击工具栏的本地Windows调试器
回车结束程序,我们到输出目录下的debug目录
在这里,可以看到它生成了一个HelloWorld.exe文件
然后到项目目录下的debug目录
可以看到有Main.obj和Log.obj文件。所以编译器所做的就是为每个cpp文件生成obj文件。
现在,VS告诉编译器,每一个cpp文件都将产生一个目标文件,这些cpp文件被称为翻译单元。本质上,你必须意识到C++并不关心文件,文件不是存在于C++中的东西。例如,在java中,类名必须是和你的文件名称一样,你的文件夹层次需要和package一样,这是因为java的要求。c++不是这样的情况,没有所谓的文件,文件只是提供给编译源代码的一种方式。是你负责告诉编译器你输入的是什么类型的文件,以及编译器应该如何处理它。当然,如果您创建一个文件.cpp,编译器会把它当做c++文件,类似的,如果我创建一个扩展名为.c或者.h文件,编译器会将.c文件当成C语言文件,而不是C++文件,而编译器对待.h文件,会当成头文件处理,这些基本上都是默认的约定。你也可以更改它,这就是编译器处理它们的方式。我也可以制作后缀为.cherno的文件(后缀是一个国外的c++大佬),只要我告诉编译器,这个文件是一个C++文件,请像编译C++文件一样编译它,编译器便会像编译C++文件一样处理它。请记住,文件没有意义。我们提供给编译器的每个C++文件,是VS告诉编译器这是一个C++文件,请编译它。编译器将文件变成一个翻译单元,翻译单元会生成一个.obj文件。
实际上,有时在cpp文件中也会包含其他的cpp文件,从而变成一个大的cpp文件,这是很常见的。如果你做了这样的事情,编译这样一个cpp文件,你基本上会得到一个编译单元,然后得到一个obj文件。这就是术语上有区别,什么是翻译单元,什么是cpp文件,一个cpp文件不一定要等于一个翻译单元。然而,如果你只是做一个个人项目,cpp文件,你没有把他们放在一起,那么,每个cpp文件都是一个翻译单元,每个cpp文件将生成一个目标文件。
可以看到,本项目中这些生成的obj文件实际上是相当大的。
我们可以看到Log.obj文件45KB,main.obj文件29KB,这是因为我们包含了iostream
iostream
里面有很多东西,所以生成的.obj文件才这么大。正因如此,这些obj文件实际上相当复杂。
我们开始之前,先看看文件中实际是什么。
让我们看一个简单的例子
我们在src下新建一个math.cpp文件,我们写一个非常基本的乘法函数,两个数相乘。我不会在这里包含任何文件,任何东西,我们写一个很简单的函数,保存ctrl + s
。
然后,我们按ctrl + F7
单文件编译这个math.cpp文件,可以看到编译成功了。
我们来看下输出目录
可以看到,生成的Math.obj文件的大小是4kb。
2. 预处理阶段的处理
在我们看obj文件中到底有什么之前,我们来谈谈编译的第一阶段。
我们之前提到过,在预处理阶段的处理,编译器基本上会遍历我们所有的预处理语句并对其进行处理。我们常用的预处理语句是#include
、#if condition #endif
、#ifdef confition #endif
,还有#pragma
语句等,这些预处理语句可以告诉编译器到底要做什么。
1. #include 预处理
让我们来看看其中一个,常用的预处理语句#include
。
#include
实际上很简单,它指定了你想要包含的文件,预处理器打开那个文件,阅读他的所有内容,然后把它粘贴到你写的文件中。
回到代码
我们创建一个名为EndBrace.h的头文件
其内容就是一个结束的花括号,就这样,这就是我们的整个文件。
现在回到Math.cpp文件
我们去掉乘法函数的结束花括号,然后单文件编译此文件。
可以看到编译器报错,与左侧的 大括号“{” 与文件结尾不匹配
。
好了,我们现在来修复代码,只要加上我们的结束花括号即可。我们使用刚刚创建EndBrance
头文件,使用include
这个EndBrance
头文件的方式,如下。
然后再次单文件编译此文件,可以看到编译成功了,当然了,因为编译器做的是打开EndBrance
这个文件复制所有内容(}
),然后粘贴到Math.cpp使用#include "EndBrace.h"
的地方。
使用头文件的方式问题解决。
现在可以知道include
是如何工作的,以及如何使用它。这实际是一种方法告诉编译器,输出一个文件,其中包含所有的结果,包括所有的预处理器评估情况
2. 生成预处理文件(.i)
好了,回到文件
我们右键单击我们的helloworld项目,点击属性
预处理到文件,选择"是(/P)",预处理C和C++源文件将预处理的输出写入到文件,
此选项将取消编译,因此不会生成".obj"文件,会生成".i"的预处理文件
在c++的预处理属性页C/C++
-> 预处理器
,将预处理到文件
设置为是(/P)
,
注意: 这里配置时要确保你正在编辑的`配置`和`平台`是当前工具栏中的`解决方案配置`和`解决方案平台配置`
配置好后,点击确定。
然后再次单文件编译Math.cpp文件。
我们打开输出目录
可以看到有生成一个新的后缀名为.i
文件(预处理器文件),这是我们的预处理后的c++代码。
让我们使用文本编辑器(我这里使用的Notepad++,普通文本编辑也可以打开)打开它,这里可以看到预处理器实际上生成了什么。可以看到我们的预处理文件源代码中包含了EndBrace.h
预处理器代码,刚好把我们的EndBrace.h
文件插入到#include "EndBrace.h"
位置。
3. #define 预处理
让我添加更多的预处理语句,看看发生什么
回到我们的文件中,我们将math.cpp文件还原为}
,然后在文件上方定义一些东西#define INTEGER int
,这只是一个例子,define
预处理器语句,基本上只进行搜索INTEGER
,并将其替换为后面的内容,也就是int
。
让我们来单文件编译Math.cpp文件
这个时候在去看math.i文件
可以看到,结果很正常。
如果我们做了件蠢事,比如如下,将int
写成chenro
然后单文件编译,可以看到文件是编译通过了
这个时候再去看math.i文件,看到INTEGER
都替换成了chenro
。
4. #if condition #endif 预处理
很酷的东西,我们来玩一下,将int
改回来,去掉define
,我们要做的就是使用#if
,#if
预处理器语句可以让我们包含或排除基于给定条件的代码。如下
然后我们,单文件编译此文件
回到math.i
文件,可以看到它的内容和源代码函数没有什么区别
如果我回到源代码,将#if
条件关掉,写成#if 0
,VS将淡出下面的函数,表示它是被禁用的代码。
单文件编译 math.cpp文件
在回到math.i
文件,没有看到代码,因为我们的条件将这段代码排除了,这是另一个预处理语句很好的例子。
5. #include <iostream> 预处理
好的,我们在看一下include
,修改math.cpp文件如下
单文件编译此文件
我们再来看math.i
文件,这里有55446行,在最底部是我们自己的函数。这就是#include <iostream>
所做的全部工作。当然,iostream
也包括其他文件。
这有点像滚雪球,你现在可以看到为什么这些目标文件这么大。这么大是因为他们包含了iostream
,而iostream
包含了大量的代码。这就是预处理器。一旦这个阶段结束,我们可以继续实际编译C++代码成机器码。
3. obj文件查看(通过.asm文件查看)
回到我们的项目
1. 恢复生成obj文件
我们去掉#include <iostream>
,如下
单文件编译此文件
观察math.i
,现在可以在预处理器文件中看到恢复正常
接下来,去helloworld项目配置属性
预处理器
配置这里,预处理到文件
给它禁用掉,恢复到开始的配置,预处理到文件选择是(/P)
的话,就不会生成obj文件了,所以我们需要禁用它,这样我们就可以实际构建我们的项目。
点击确定,单文件编译math.cpp文件,构建我们的math.cpp文件。
可以看到我们现在得到了最新的math.obj文件。
2. obj文件查看(二进制)
让我们看看obj文件的内部是什么,将math.obj文件拖入到VS开发工具中,可以看到math.obj是二进制的,这并没有给我们太多帮助。
但实际上这里的部分是,当我们调用这个乘法函数时,我们的CPU将运行的是机器代码,因为math.obj是二进制的,一个完全不可读的。让我们把它转换成对我们来说可能更容易读懂的方式。我们有几种方法可以做到这一点。但我将使用VS。
3. 生成后缀为.asm的文件
回到项目,打开项目属性
定位到C/C++
下的输出文件
,我们将汇编程序输出设为仅有程序集的列表(/FA)
--英文Assembly-only listing
,然后点击确定。
单文件编译math.cpp文件
去输出目录中观察
在我们的输出目录下,可以看到一个Math.asm
文件.
让我们使用文本编辑器打开Math.asm
这个文件。可以看到,这基本是一个可读的结果,可以看到obj文件里面的内容。
; Listing generated by Microsoft (R) Optimizing Compiler Version 19.00.24215.1
TITLE G:\LHY\C\demo\demoOne\HelloWorld\HelloWorld\src\Math.cpp
.686P
.XMM
include listing.inc
.model flat
INCLUDELIB MSVCRTD
INCLUDELIB OLDNAMES
PUBLIC ?Multiplay@@YAHHH@Z ; Multiplay
EXTRN __RTC_InitBase:PROC
EXTRN __RTC_Shutdown:PROC
; COMDAT rtc$TMZ
rtc$TMZ SEGMENT
__RTC_Shutdown.rtc$TMZ DD FLAT:__RTC_Shutdown
rtc$TMZ ENDS
; COMDAT rtc$IMZ
rtc$IMZ SEGMENT
__RTC_InitBase.rtc$IMZ DD FLAT:__RTC_InitBase
rtc$IMZ ENDS
; Function compile flags: /Odtp /RTCsu /ZI
; COMDAT ?Multiplay@@YAHHH@Z
_TEXT SEGMENT
_result$ = -8 ; size = 4
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
?Multiplay@@YAHHH@Z PROC ; Multiplay, COMDAT
; File g:\lhy\c\demo\demoone\helloworld\helloworld\src\math.cpp
; Line 2
push ebp
mov ebp, esp
sub esp, 204 ; 000000ccH
push ebx
push esi
push edi
lea edi, DWORD PTR [ebp-204]
mov ecx, 51 ; 00000033H
mov eax, -858993460 ; ccccccccH
rep stosd
; Line 3
mov eax, DWORD PTR _a$[ebp]
imul eax, DWORD PTR _b$[ebp]
mov DWORD PTR _result$[ebp], eax
; Line 4
mov eax, DWORD PTR _result$[ebp]
; Line 5
pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
ret 0
?Multiplay@@YAHHH@Z ENDP ; Multiplay
_TEXT ENDS
END
如果我们往下看,可以看到我们调用了这个乘法函数。
然后我们有一堆汇编指令,这些是CPU将要执行的实际指令,当我们运行函数时。
到这里,你会看到我们的乘法指令
实际上,我们加载了变量a(_a$
)进入EAX
寄存器,然后执行imul
指令 --> 将变量b(_b$
)与变量a(_a$
)相乘的指令,使用result(_result$
)变量存储结果,然后把result(_result$
)返回给EAX
寄存器。
这里发生了两次来回mov
mov DWORD PTR _result$[ebp], eax
和mov eax, DWORD PTR _result$[ebp]
,原因是因为我们创建了一个名为result
的变量等于a * b
, 然后返回result
,而不是直接返回a * b
,这就是为什么我们要两次mov
,最后将result mov
给EAX
寄存器。这是完全多余的。这是一个很好的例子,如果不把编译器设置为优化,你的代码会很慢,因为无缘无故的做一些额外的事情。
如果回到我们的代码,去掉result变量,直接返回a * b
来观察math.asm
文件
你会看到汇编代码看起来有点不同,因为我们通过imul指令,直接将变量b(_b$
)与EAX相乘,EAX实际上包含了我们的返回值。
现在,看起来有很多代码,那是因为我们实际上是在调试模式(debug)中编译,它没有做任何优化。且做了很多额外的事情,使得我们的代码尽量的丰富多样,尽可能更容易调试。
4. 开启优化
如果,回到我们的项目,编辑项目属性
在C/C++
下的优化
选项的优化
配置中选择使速度最大化(/O2)
,确定。
单文件编译math.cpp文件
它会给你一个错误,因为你会看到O2
和RTC
命令是不兼容的。
5. 基本运行时检查配置
所以我们需要回到代码生成选项,C/C++
下代码生成
选项里面的基本运行时检查
配置为Default
,VS默认是两者(/RTC1,等同于/TRCsu)(/RTC1)
,如果设置为Default
,它基本上不会执行运行时检查,这基本上就是代码,编译器会帮助我们进行调试。
然后再来单文件编译math.cpp文件,发现编译通过了。
现在来观察math.asm
文件,发现,哇,看起来小多了。
我们只是把变量a(_a$
)加载到了EAX
寄存器中,然后是乘法,就这样,很简单。
到现在,你应该有了一个基本的概念,当你告诉编译器优化时,编译器实际上会做很多处理。
6. 常量折叠
这是一个简单的例子,接下来,让我们再看一些高级的东西。我们来看一个稍微不同的例子。我们去掉变量,直接返回5 * 2
,保存该文件。
进入项目属性,确保禁止优化
单文件编译math.cpp文件
观察最新生成的math.asm
文件
可以看到所做的实际上非常简单,只需将10移到EAX寄存器,这个EAX寄存器会存储我们的返回值
如果我们在看一遍代码,这就是5 * 2
的结果等于10。因为当然没有必要做一些像5 * 2
这样的事情,这叫常数折叠。任何常数都可以在编译时算出来。
7. 优化深入
让我们通过引入另一个函数来让事情变得更有趣。
举个例子,我要写一个log,将记录特定消息的函数,当然,我并不想把它真的写成记录打印任何东西,因为那意味着我必须包括iostream
,这将使事情变得非常复杂。我要让他返回接收到的message,在乘法函数中来调用log函数。参数是"Multiply"
,如下保存。
让我们看看编译器生成了什么
; Listing generated by Microsoft (R) Optimizing Compiler Version 19.00.24215.1
TITLE G:\LHY\C\demo\demoOne\HelloWorld\HelloWorld\src\Math.cpp
.686P
.XMM
include listing.inc
.model flat
INCLUDELIB MSVCRTD
INCLUDELIB OLDNAMES
PUBLIC ?Log@@YAPBDPBD@Z ; Log
PUBLIC ?Multiplay@@YAHHH@Z ; Multiplay
PUBLIC ??_C@_08EOBDLMOI@Multiply?$AA@ ; `string'
; COMDAT ??_C@_08EOBDLMOI@Multiply?$AA@
CONST SEGMENT
??_C@_08EOBDLMOI@Multiply?$AA@ DB 'Multiply', 00H ; `string'
CONST ENDS
; Function compile flags: /Odtp /ZI
; COMDAT ?Multiplay@@YAHHH@Z
_TEXT SEGMENT
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
?Multiplay@@YAHHH@Z PROC ; Multiplay, COMDAT
; File g:\lhy\c\demo\demoone\helloworld\helloworld\src\math.cpp
; Line 7
push ebp
mov ebp, esp
sub esp, 64 ; 00000040H
push ebx
push esi
push edi
; Line 8
push OFFSET ??_C@_08EOBDLMOI@Multiply?$AA@
call ?Log@@YAPBDPBD@Z ; Log
add esp, 4
; Line 9
mov eax, DWORD PTR _a$[ebp]
imul eax, DWORD PTR _b$[ebp]
; Line 10
pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
ret 0
?Multiplay@@YAHHH@Z ENDP ; Multiplay
_TEXT ENDS
; Function compile flags: /Odtp /ZI
; COMDAT ?Log@@YAPBDPBD@Z
_TEXT SEGMENT
_message$ = 8 ; size = 4
?Log@@YAPBDPBD@Z PROC ; Log, COMDAT
; File g:\lhy\c\demo\demoone\helloworld\helloworld\src\math.cpp
; Line 2
push ebp
mov ebp, esp
sub esp, 64 ; 00000040H
push ebx
push esi
push edi
; Line 3
mov eax, DWORD PTR _message$[ebp]
; Line 4
pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
ret 0
?Log@@YAPBDPBD@Z ENDP ; Log
_TEXT ENDS
END
定位到53行,我们可以看到这个log函数,他做了太多事情,并且返回了我们的message,你可看到它把我们的message指针移动到EAX寄存器,这是我们用来返回值的寄存器。
继续往上看,定位到24行,可以看到乘法函数,然后在35行这里有一个log函数的调用,在我们做imul
乘法指令之前,我们实际上先调用了这个log函数。
现在你可能想知道为什么log函数名称?Log@@YAPBDPBD@Z
像是被一堆随机字符装饰了,这实际上是函数的签名,因为链接器需要唯一的定义到你的函数,这将这下一篇链接器是如何工作的
中讲到。本质上,当我们有多个obj文件时,函数也被定义在多个obj文件中,链接器的工作就是把所有的函数链接在一起,这样做是为了查找这个函数的签名。所以,你只需要知道我们调用这个log函数,实际做的事情就是编译器在你调用函数时生成call指令。
在这种情况下,它可能有一点蠢,因为我们只是调用log函数,我们甚至没有要求存储返回值,但是它将我们的message指针移动到EAX
寄存器,实际上,这里是可以被优化。
回到项目属性配置
单文件编译math.cpp文件
观察math.asm文件,
你会看到它完全消失了
; Line 8
push OFFSET ??_C@_08EOBDLMOI@Multiply?$AA@
call ?Log@@YAPBDPBD@Z ; Log
add esp, 4
新生成的math.asm文件的乘法函数中已经没有那个call指令,被优化掉了。
是的,编译器决定什么都不做,我要删除那个代码,到现在你应该基本上了解了它的要点。
编译器的工作原理,它将获取源文件并输出一个obj文件,obj文件时包含机器代码的文件,以及其它我们定义的常数数据等,基本上是这样。现在我们有了这些obj文件,我们可以将他们链接成一个包含所有内容的可执行文件中,可执行文件是包含了需要运行的机器代码,这就是我们如何让C++程序跑起来的流程。