编译器是如何工作的

72 阅读18分钟

上一篇:C++是如何工作的

下一篇:链接器是如何工作的

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文件。

image.png

image.png

以上,我们有一个简单的Hello World应用程序

开始调试按F5或点击工具栏的本地Windows调试器

image.png

image.png

回车结束程序,我们到输出目录下的debug目录

image.png

image.png

在这里,可以看到它生成了一个HelloWorld.exe文件

image.png

然后到项目目录下的debug目录

image.png

可以看到有Main.obj和Log.obj文件。所以编译器所做的就是为每个cpp文件生成obj文件。

image.png

现在,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文件实际上是相当大的。

image.png

我们可以看到Log.obj文件45KB,main.obj文件29KB,这是因为我们包含了iostream

image.png

image.png

iostream里面有很多东西,所以生成的.obj文件才这么大。正因如此,这些obj文件实际上相当复杂。

我们开始之前,先看看文件中实际是什么。

让我们看一个简单的例子 我们在src下新建一个math.cpp文件,我们写一个非常基本的乘法函数,两个数相乘。我不会在这里包含任何文件,任何东西,我们写一个很简单的函数,保存ctrl + s

image.png

然后,我们按ctrl + F7单文件编译这个math.cpp文件,可以看到编译成功了。

image.png

我们来看下输出目录

image.png

可以看到,生成的Math.obj文件的大小是4kb。

image.png

2. 预处理阶段的处理

在我们看obj文件中到底有什么之前,我们来谈谈编译的第一阶段。

我们之前提到过,在预处理阶段的处理,编译器基本上会遍历我们所有的预处理语句并对其进行处理。我们常用的预处理语句是#include#if condition #endif#ifdef confition #endif,还有#pragma语句等,这些预处理语句可以告诉编译器到底要做什么。

1. #include 预处理

让我们来看看其中一个,常用的预处理语句#include

#include实际上很简单,它指定了你想要包含的文件,预处理器打开那个文件,阅读他的所有内容,然后把它粘贴到你写的文件中。

回到代码

我们创建一个名为EndBrace.h的头文件

image.png

image.png

其内容就是一个结束的花括号,就这样,这就是我们的整个文件。

image.png

现在回到Math.cpp文件

image.png

我们去掉乘法函数的结束花括号,然后单文件编译此文件。

image.png

image.png

可以看到编译器报错,与左侧的 大括号“{” 与文件结尾不匹配。 好了,我们现在来修复代码,只要加上我们的结束花括号即可。我们使用刚刚创建EndBrance头文件,使用include这个EndBrance头文件的方式,如下。

image.png

然后再次单文件编译此文件,可以看到编译成功了,当然了,因为编译器做的是打开EndBrance这个文件复制所有内容(}),然后粘贴到Math.cpp使用#include "EndBrace.h"的地方。

image.png

使用头文件的方式问题解决。

现在可以知道include是如何工作的,以及如何使用它。这实际是一种方法告诉编译器,输出一个文件,其中包含所有的结果,包括所有的预处理器评估情况

2. 生成预处理文件(.i)

好了,回到文件

我们右键单击我们的helloworld项目,点击属性

image.png

预处理到文件,选择"是(/P)",预处理C和C++源文件将预处理的输出写入到文件,
此选项将取消编译,因此不会生成".obj"文件,会生成".i"的预处理文件

image.png

在c++的预处理属性页C/C++ -> 预处理器,将预处理到文件设置为是(/P)

注意: 这里配置时要确保你正在编辑的`配置``平台`是当前工具栏中的`解决方案配置``解决方案平台配置`

配置好后,点击确定。

然后再次单文件编译Math.cpp文件。

image.png

image.png

我们打开输出目录

image.png

image.png

可以看到有生成一个新的后缀名为.i文件(预处理器文件),这是我们的预处理后的c++代码。

让我们使用文本编辑器(我这里使用的Notepad++,普通文本编辑也可以打开)打开它,这里可以看到预处理器实际上生成了什么。可以看到我们的预处理文件源代码中包含了EndBrace.h预处理器代码,刚好把我们的EndBrace.h文件插入到#include "EndBrace.h"位置。

image.png

image.png

image.png

3. #define 预处理

让我添加更多的预处理语句,看看发生什么

回到我们的文件中,我们将math.cpp文件还原为},然后在文件上方定义一些东西#define INTEGER int,这只是一个例子,define预处理器语句,基本上只进行搜索INTEGER,并将其替换为后面的内容,也就是int

image.png

让我们来单文件编译Math.cpp文件

image.png

这个时候在去看math.i文件

image.png

可以看到,结果很正常。

如果我们做了件蠢事,比如如下,将int写成chenro

image.png

然后单文件编译,可以看到文件是编译通过了

image.png

这个时候再去看math.i文件,看到INTEGER都替换成了chenro

image.png

4. #if condition #endif 预处理

很酷的东西,我们来玩一下,将int改回来,去掉define,我们要做的就是使用#if#if预处理器语句可以让我们包含或排除基于给定条件的代码。如下

image.png

然后我们,单文件编译此文件

image.png

回到math.i文件,可以看到它的内容和源代码函数没有什么区别

image.png

如果我回到源代码,将#if条件关掉,写成#if 0,VS将淡出下面的函数,表示它是被禁用的代码。

image.png

单文件编译 math.cpp文件

image.png

在回到math.i文件,没有看到代码,因为我们的条件将这段代码排除了,这是另一个预处理语句很好的例子。

image.png

5. #include <iostream> 预处理

好的,我们在看一下include,修改math.cpp文件如下

image.png

单文件编译此文件

image.png

我们再来看math.i文件,这里有55446行,在最底部是我们自己的函数。这就是#include <iostream>所做的全部工作。当然,iostream也包括其他文件。

image.png

这有点像滚雪球,你现在可以看到为什么这些目标文件这么大。这么大是因为他们包含了iostream,而iostream包含了大量的代码。这就是预处理器。一旦这个阶段结束,我们可以继续实际编译C++代码成机器码。

3. obj文件查看(通过.asm文件查看)

回到我们的项目

1. 恢复生成obj文件

我们去掉#include <iostream>,如下

image.png

单文件编译此文件

image.png

观察math.i,现在可以在预处理器文件中看到恢复正常

image.png

接下来,去helloworld项目配置属性

image.png

预处理器配置这里,预处理到文件给它禁用掉,恢复到开始的配置,预处理到文件选择是(/P)的话,就不会生成obj文件了,所以我们需要禁用它,这样我们就可以实际构建我们的项目。

image.png

点击确定,单文件编译math.cpp文件,构建我们的math.cpp文件。

image.png

image.png

image.png

可以看到我们现在得到了最新的math.obj文件。

image.png

2. obj文件查看(二进制)

让我们看看obj文件的内部是什么,将math.obj文件拖入到VS开发工具中,可以看到math.obj是二进制的,这并没有给我们太多帮助。

image.png

但实际上这里的部分是,当我们调用这个乘法函数时,我们的CPU将运行的是机器代码,因为math.obj是二进制的,一个完全不可读的。让我们把它转换成对我们来说可能更容易读懂的方式。我们有几种方法可以做到这一点。但我将使用VS。

3. 生成后缀为.asm的文件

回到项目,打开项目属性

image.png

定位到C/C++下的输出文件,我们将汇编程序输出设为仅有程序集的列表(/FA)--英文Assembly-only listing,然后点击确定。

image.png

单文件编译math.cpp文件

image.png

image.png

去输出目录中观察

image.png

在我们的输出目录下,可以看到一个Math.asm文件.

image.png

让我们使用文本编辑器打开Math.asm这个文件。可以看到,这基本是一个可读的结果,可以看到obj文件里面的内容。

image.png

; 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

如果我们往下看,可以看到我们调用了这个乘法函数。

image.png

然后我们有一堆汇编指令,这些是CPU将要执行的实际指令,当我们运行函数时。

image.png

到这里,你会看到我们的乘法指令

image.png

实际上,我们加载了变量a(_a$)进入EAX寄存器,然后执行imul指令 --> 将变量b(_b$)与变量a(_a$)相乘的指令,使用result(_result$)变量存储结果,然后把result(_result$)返回给EAX寄存器。

image.png

这里发生了两次来回mov

image.png

mov DWORD PTR _result$[ebp], eaxmov eax, DWORD PTR _result$[ebp],原因是因为我们创建了一个名为result的变量等于a * b, 然后返回result,而不是直接返回a * b,这就是为什么我们要两次mov,最后将result movEAX寄存器。这是完全多余的。这是一个很好的例子,如果不把编译器设置为优化,你的代码会很慢,因为无缘无故的做一些额外的事情。

image.png

如果回到我们的代码,去掉result变量,直接返回a * b

image.png

单文件编译

image.png

来观察math.asm文件

image.png

你会看到汇编代码看起来有点不同,因为我们通过imul指令,直接将变量b(_b$)与EAX相乘,EAX实际上包含了我们的返回值。

image.png

现在,看起来有很多代码,那是因为我们实际上是在调试模式(debug)中编译,它没有做任何优化。且做了很多额外的事情,使得我们的代码尽量的丰富多样,尽可能更容易调试。

4. 开启优化

如果,回到我们的项目,编辑项目属性

image.png

C/C++下的优化选项的优化配置中选择使速度最大化(/O2),确定。

image.png

单文件编译math.cpp文件

image.png

它会给你一个错误,因为你会看到O2RTC命令是不兼容的。

image.png

5. 基本运行时检查配置

所以我们需要回到代码生成选项,C/C++代码生成选项里面的基本运行时检查配置为Default,VS默认是两者(/RTC1,等同于/TRCsu)(/RTC1),如果设置为Default,它基本上不会执行运行时检查,这基本上就是代码,编译器会帮助我们进行调试。

image.png

然后再来单文件编译math.cpp文件,发现编译通过了。

image.png

现在来观察math.asm文件,发现,哇,看起来小多了。

image.png

我们只是把变量a(_a$)加载到了EAX寄存器中,然后是乘法,就这样,很简单。

image.png

到现在,你应该有了一个基本的概念,当你告诉编译器优化时,编译器实际上会做很多处理。

6. 常量折叠

这是一个简单的例子,接下来,让我们再看一些高级的东西。我们来看一个稍微不同的例子。我们去掉变量,直接返回5 * 2,保存该文件。

image.png

进入项目属性,确保禁止优化

image.png

image.png

单文件编译math.cpp文件

image.png

观察最新生成的math.asm文件

image.png

可以看到所做的实际上非常简单,只需将10移到EAX寄存器,这个EAX寄存器会存储我们的返回值

image.png

如果我们在看一遍代码,这就是5 * 2的结果等于10。因为当然没有必要做一些像5 * 2这样的事情,这叫常数折叠。任何常数都可以在编译时算出来。

7. 优化深入

让我们通过引入另一个函数来让事情变得更有趣。

举个例子,我要写一个log,将记录特定消息的函数,当然,我并不想把它真的写成记录打印任何东西,因为那意味着我必须包括iostream,这将使事情变得非常复杂。我要让他返回接收到的message,在乘法函数中来调用log函数。参数是"Multiply",如下保存。

image.png

单文件编译

image.png

让我们看看编译器生成了什么

image.png

; 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寄存器,这是我们用来返回值的寄存器。

image.png

继续往上看,定位到24行,可以看到乘法函数,然后在35行这里有一个log函数的调用,在我们做imul乘法指令之前,我们实际上先调用了这个log函数。

image.png

现在你可能想知道为什么log函数名称?Log@@YAPBDPBD@Z像是被一堆随机字符装饰了,这实际上是函数的签名,因为链接器需要唯一的定义到你的函数,这将这下一篇链接器是如何工作的中讲到。本质上,当我们有多个obj文件时,函数也被定义在多个obj文件中,链接器的工作就是把所有的函数链接在一起,这样做是为了查找这个函数的签名。所以,你只需要知道我们调用这个log函数,实际做的事情就是编译器在你调用函数时生成call指令。

在这种情况下,它可能有一点蠢,因为我们只是调用log函数,我们甚至没有要求存储返回值,但是它将我们的message指针移动到EAX寄存器,实际上,这里是可以被优化。

image.png

回到项目属性配置

image.png

image.png

单文件编译math.cpp文件

image.png

image.png

观察math.asm文件,

image.png

你会看到它完全消失了

; Line 8
	push	OFFSET ??_C@_08EOBDLMOI@Multiply?$AA@
	call	?Log@@YAPBDPBD@Z			; Log
	add	esp, 4

新生成的math.asm文件的乘法函数中已经没有那个call指令,被优化掉了。

image.png

是的,编译器决定什么都不做,我要删除那个代码,到现在你应该基本上了解了它的要点。

编译器的工作原理,它将获取源文件并输出一个obj文件,obj文件时包含机器代码的文件,以及其它我们定义的常数数据等,基本上是这样。现在我们有了这些obj文件,我们可以将他们链接成一个包含所有内容的可执行文件中,可执行文件是包含了需要运行的机器代码,这就是我们如何让C++程序跑起来的流程。

上一篇:C++是如何工作的

下一篇:链接器是如何工作的