浅析编译原理

编译对大部分开发人员来说算是"最熟悉的陌生人"吧,我们每天的工作都会接触到它,但是绝大多数情况下编译对于我们来说就是个黑盒子,我们又对它知之甚少,本文就来聊一聊编译原理,让大家能初步了解编译大致的过程和一些相关的点。

编程语言的发展历程

编程语言的发展历程大概可以分为4个阶段:

第一代语言:机器语言

第一代编程语言比计算机还要早出现,早在1804年先人就发明了提花织布机(Jacquard loom),运用打孔卡上的坑洞来代表缝纫织布机的手臂动作,以便自动化产生装饰的图案。

机器语言就是一串0/1的组合,纯粹是给机器使用的。早期的打孔卡的编程方式就是专门为编写机器语言而发明的。

shell 复制代码
## 寄存器BX的内容送到AX中
1000100111011000

第二代语言:汇编语言

1950年左右被发明的一种用于电子计算机、微处理器、微控制器或其他可编程器件的低级语言(符号语言)。汇编把机器语言做了一些提升,将一串01的组合变成有意义的英文单词,用这些容易理解和记忆的字母,单词来代替一个特定的指令,减少机器语言给工程师带来的不适。不过汇编语言始终还是面向机器编程的,对工程师来说还是很费脑力而且容易出错。

shell 复制代码
## 寄存器BX的内容送到AX中
mov ax,bx

第三代语言:高级语言

1950-1960年间,逐渐有现代编程语言被设计出来,而在1967年之后,基础的编程范式开始被确立,编程语言开始有重大的发展,现在大多数的范式都是在这个时期被发明的。一般我们以1980年为分界线,1980以前出现的高级语言基本都是面向过程的语言,以c语言为代表,而1980年以后出现的基本都是面向过程的语言,以c++、java为代表。

第四代语言:智能语言

第四代语言是基于"范式开发"设计出来的,只需要告诉计算机要做什么,而不需要告诉计算机如何一步一步实现。

例如: SQL语句是就具有第四代的特征,只需要告诉DBMS要查那些表哪些字段,怎么找到这些表,怎么使用索引都是由DBMS自行完成。

纵观整个编程语言的发展过程,编程语言就是从对机器友好对人类友好 的方向来发展的,这时候矛盾就出现了(从汇编语言开始),对人类越友好对机器就越不友好,因此我们需要一个东西来消除这个矛盾。这个东西就是编译

编译是什么?

所以编译是什么呢? 编译其实就是一个转换的过程,就像我们把"how old are you"经过我们的大脑转换成"怎么老是你"一样

从广义上讲,将一种语言转换成另一种语言的过程都可以称为编译,不过我们一般说的编译都是特指高级语言转换成低级语言过程,其源代码是C/C++、java等高级语言,目标语言是机代码或虚拟机的字节码。其他的编译一般都有自己特定的名称,比如低级语言转成高级语音的我们称为反编译。

那么问题来了:为什不是直接编译成机器代码呢?中间代码又是什么东西? 这要从编译的方式说起。

编译的方式

一般来说我们把语言根据编译方式的不同来分类:

编译型

编译型语言的源代码要运行,需要先通过编译器将代码装换成机器码(二进制编码),然后再运行。
例如: c/c++,要在指定的环境运行c++的程序,需要通过指定的环境的编译器来编译源码(比如linux下的gcc编译),编译成功后才可以运行。

优点: 编译器一般都会对编译的源码进行优化,执行效率高,可以脱离语言独立运行

缺点: 每次修改都要重新编译,需要根据不同的环境进行编译,而且在不同的操作系统之间移植容易出现问题

翻译型

解释型语言的源码运行的时候不需要编译成机器码,而是通过解释器动态的将代码一句一句的解释成机器码。
例如: VB语言,在执行的时候,专门有一个解释器能够将VB语言翻译成机器语言,每个语句都是执行的时候才翻译

优点: 强大的兼容性,只要安装了解释器就可以运行,可以做到不停机维护

缺点: 边编译边运行导致执行效率低下

混合型

结合解释型和编译型的优点,将两者整合就出现了一种的半编译半翻译的模式,也就是说源码在编译的时候不是编译成机器码,而是编译成中间码,运行的时候再通过翻译器将中间码一行一行翻译成机器码。

例如: java语言,java代码编译的时候编译成字节码(bytecode),这些字节码会在jvm虚拟机中运行,通过jvm将字节码解析成机器代码。

优点: 既有编译型的效率,又有翻译型的兼容性

缺点: 需要依赖翻译器翻译,依然没有编译型那么快

为什么需要编译

前面讲到编译是来消除对机器友好对人类友好 的矛盾的。这个矛盾是怎么产生的呢?

这个要从编程的目的聊起,我们编写代码的目的就是想让计算机帮我们完成一些事情,实际上就是人类跟计算机的一种"交流",我们写的代码是给机器"看"的,但是机器"太笨了",它看不懂我们的代码。

这其实跟你跟一个不懂中文的老外讲中文一样,他肯定不知道你要表达什么东西。

同理,机器拿到我们的代码,也不知道我们要它做什么。因为人类在阅读的时候,是以"词"为单位的,同样编写的代码也是以"词"为单位的。例如:how old are you 我们会将其分为4个单词来读。

但是机器没办法这么做,在机器眼中,这句话是这样的:

011010000110111101110111001000000110111101101100011001000010000001100001011100100110010100100000011110010110111101110101

在计算机得到这串二进制字符流后,它只能一个一个字符来读,按这种方式读完之后,计算机得到一堆零碎,它也不知道怎么处理,所以需要编译器来帮助计算机(翻译程序)。编译器会将源代码转换成机器代码,给计算机执行。

代码执行

我们的代码最终的执行是通过cpu来完成的,cpu的一个主要组成就是"指令集",每个指令对应一个操作,编译器将代码转换成机器代码后,计算机通过指令集将机器代码映射成cpu操作,完成运行。

大致的流程如下:

怎么实现编译

编译器

编译器不是一个什么神秘的东西,它其实就是一堆代码。一般我们要把A语言编译成B语言的话,我们会通过C语言来实现一个编译器:

编译器

下图一个比较完整的编译流程

上图描述了源代码到目标机器代码转换过程所需的步骤:

  1. 词法分析器读取源代码的字符流,将字符流转换成符号流
  2. 语法分析器读取符号流,根据语法转换成对应的语法树
  3. 语义分析器会对语法分析的结果进行校验,通过后输出语法树
  4. 中间代码生成器将语法树转换成中间表示形式
  5. 中间代码优化器针对中间代码进行优化
  6. 机器代码生成器将中间代码翻译机器指令
  7. 机器代码优化器会对指令进行优化

在这过程中有两个贯穿全局模块,分别是符号表错误处理

错误处理 是每个步骤自己处理的,不同的步骤有不同的错误判断的规则,比如词法分析,只会处理不符合词法的,但不会处理不符合语法的错误。
符号表是一个数据结构的表示,存储分析阶段收集到有关源程序的信息:名称、类型等等,如果是静态语言的话,还会记录对应的词法作用域。

我们来看看一个表达式的转换过程:y = x * 2 + 5;

  1. y = x * 2 + 5会被转换成字符流:

    01111001001000000011110100100000011110000010000000101010001000000011001000100000001010110010000000110101

  2. 词法分析器将字符流转换成符号流:
    <id,1><=><id,2><*><2><+><5><;>

  3. 语法分析将符号流转换成赋值的语法树,语义分析校验是否符合语法:

  4. 生成中间代码:

ini 复制代码
 t1 = 2;
 t2 = 5;
 t3 = id2 * t1;
 t4 = t3 + t2;
 id1 = t4;
  1. 优化中间代码:
ini 复制代码
 t1 = id2 * 2;
 id1 = t1 + 5;
  1. 生成目标机器代码:
bash 复制代码
 LDF R1, id2
 MULF R1, R1, #2.0
 ADDF R1, R1, #5.0
 STF id1, R1

仔细研究下编译的过程,每个步骤都是把上一个步骤的表示方式转换成另一个表示方式。每个步骤只要按照要求来输入和输出就行了,不同步骤之间的代码都是隔离的,这样做的好处就是随时可以调整步骤。

例如:ES6新增那么多语法,我们只要修改前三步的代码就行了,其他的可以不用调整。

基于编译器的这个特点,针对不同的高级语言和不同的编译方式,会有不同的增删,比如一些语法比较简单的编译器会在去掉语义分析的步骤,有的编译器会去掉优化的步骤,追求更快的编译速度。但是有两个步骤是基本不变的:词法分析语法分析,后面我们会重点讨论这两个步骤。

词法分析

在词法分析阶段,词法分析器会读入源代码的字符流,并将它们组成有意义的词素(lexeme)

针对每个词素,词法分析器会生成对应的词法单元(token): <token-name, attribute-value>
token-name是给语法分析步骤使用的抽象符号
attribute-value指向符号表中关于这个词法单元的记录

这一个过程也被称为token化。这个跟我们阅读的时候是以"词"为单位的一样,编译器是以"token"为单位的(以字符为单位没意义)。

抽象符号

在词法上,编程语言跟我们的自然语言其实是一样的,我们可以把一句话分成主语,宾语,谓语,同样源代码在词法上可以被分为: 标识符,关键字,字面量,运算符,注释,分号等等。而token-name就是这一词法类型的抽象表示(抽象符号)。例如:标识符(identifier)的抽象符号就是id

切分词素

看一段简单的代码:

ini 复制代码
val2 = val1 + 50

这段代码有5个词素:

  1. val2 映射成词法单元<id, 1>id表示标识符(identifier)的抽象符号,而1则是指向符号表中val2的记录,这记录存放该标识符的相关信息:名字、类型等
  2. = 被映射成词法单元<=>,这是一个赋值运算符,没有属性,所以不需要第二个分量。
  3. val1 映射成词法单元<id, 2>
  4. + 映射成词法单元<+>
  5. 50 映射成词法单元<60>
    于是我们得到一个词法单元的序列:
    <id, 1> <=> <id, 2> <+> <50>

这个词法单元序列会传递给语法分析器,由语法分析器对其进行解析并生成对应的语法树。

如何生成token

一般的思路应该是先从字符流中提取词素,再判断词素所属的词法类型。不过前面我们也说了,机器没办法像我们这样一眼就把词提取出来,只能一个字符一个字符的读取,如果是先识别词素,再判断类型其实就复杂了,可以在提取词素的同时将其转换成词法单元。所以词法分析器一般用有限状态机(FSM,Finite State Machine)来实现。

如下图是标识符的状态机:

基本原理

有限状态机将词法类型当成各种状态,例如:标识符、数字、注释等,都归类为一个个的状态规则(如:十进制数字:以-0-9开头的,后续带一个.或多个0-9的字符串),词法分析器读入一个字符,判断字符符合哪个状态的规则,然后读入第二个字符,判断改字符的状态,如果状态与上一个字符的状态是一致的,则判断这俩个字符属于同一个词素,如果不是,则判断当前词素以上一个字符为终止,当前字符属于新的词素。

为什么这么操作呢?

因为每次只读入一个字符,并没有办法完全判断这个字符的状态,如:字符是;,只要不是包裹在""''中就可以直接判断是终结符分号。但是对于一些字符,没办法直接判断其所在的词素是何种状态,必须结合上下文来判断,比如:读入一个+,它可能是单纯的运算符+,也可能是+++=,必须结合上下文的状态才能切分(像mun+num的情况,+之后是n前面是运算符而后面是标识符,所以此处的+是加法运算符),而对于像90dd这种,数字和字母之间没有操作符或分隔符,而且也不是被''""包裹,那就是词法错误。

实操一下

上面的描述可能不是很清楚,来实际演练一下,看看这段C语言的代码

ini 复制代码
int var2= 2;
int var3 = 5;
int var1 = var2 * 2 + var3;

机器获取到的是这么一串(其实应该是二进制,为了方便展示将其转换成16进制)

词法分析器从第一个字符开始遍历
识别出第一个字符 i,符合标识符的规则(以_或字母开头的,由_、字母、数字组成的字符串),根据i可以判断当前分析的词素是标识符,但是这个标识符的全部内容是什么,还无法确定,需要继续向后遍历。
识别出第二个字符 n,符合标识符的后续字符的规则,属于当前这个词素,继续向后。

识别出第三个字符 t,符合标识符的后续字符的规则,属于当前这个词素,继续向后。

识别出第四个字符"空格"

不再是 _、字母、数字了,标识符的状态中断了,表示当前这个词素的内容是int,它是一个标识符,同时这个标识符属于关键字,所以将其记录为关键字。

这时候我们获得token:<kw, 1>kw是关键字的抽象表示(有的编译器会记录成kw_int,这个看具体实现),1指向符号表的第一个条目。

分析器继续往下走,识别第四个字符"空格"

为什么空格会被是别两次? 因为在第一次识别到空格的时候是int这个标识符的后续,确定了int是标识符的完整内容,完成int的识别后,需要进入到对下一个词素的识别,识别的起始位置是上一个词素的终止位置的下一个字符。所以空格的两次识别,分别是结束和起始,并不冲突。

根据C语言的词法,空格是间隔符,不是任何词法单元的起始,直接跳过。继续向后遍历。
识别第五个字符 v:

符合标识符的起始字符的规则,识别为标识符,继续向后遍历,分别得到ar2=,当获取到=的时候,标识符的状态结束,获取到第二个词素var2,是一个标识符,于是我们得到词法单元<id, 2>

这时候符号表的表现如下:

条目 内容 类型
1 int 整形关键字
2 var2 标识符

按照上面的步骤,以此类推,最开始的代码会被转换成这样的词法单元序列:

bash 复制代码
<kw,1><id,2><=><2><;>
<kw,3><id,4><=><5><;>
<kw,5><id,6><=><id,2><*><2><+><id,4><;>

这时候我们完成词法分析的这个过程。在这一过程中,如果发现出现不符合词法规则的情况,词法分析器会抛出错误,例如:20abc前半段是数字,中间没有分隔符,后半段是标识符,不符合词法规则,抛出错误。

语法分析

语法分析阶段主要是将词法分析的结果解析成对应的语法树,其实就是把词法单元序列转换成对应语法的表示。比如上述的int var1 = var2 * 2 + var3;,得到的词法单元是:<kw,5><id,6><=><id,2><*><2><+><id,4><;>,经过语法分析后,可以得到类似的树:

scss 复制代码
graph TD
A(变量声明) --> B("< kw,1 >")
A --> C("< id,6 >")
A --> D("初始值: expr")
D --> E(+)
E --> F(*)
E --> G("< id,4 >")
F --> H("< id,2 >")
F --> I("< 2 >")

语法分析器是怎么实现这一操作的呢?

其实是通过一个很简单的方法:模板匹配

模板匹配

模板匹配的原理很简单,拿到一个词法单元序列后,与内置的每个模板逐一比较,得到符合的语法,如果没有符合的,则属于语法错误。
来看一个简单的示例:

变量声明:int var2 = 2;,转换成词法单元序列:<kw,1><id,2><=><2><;>

三个简单的模板:

语法分析器从第一个token开始遍历,取到<kw,1>,符号表的内容是int,首先判断这个token,与所有模板的第一个符号是否匹配。

语法分析器发现,所有模板的第一个符号都是类型,三个目标都符合,保留所有模板,继续向后遍历,得到第二个token<id,2>,是一个标识符,既可以是变量名,也可以是函数名:

第二个token也命中所有模板的第二个符号。

第三个token<=>,是赋值运算符,这时候只有变量声明的模板匹配,那么保留变量声明模板,过滤掉其他模板。

匹配最后一个token<2>,是一个常量,与变量声明模板的第四个符号一致。

所以这个词法单元序列是一个变量声明。然后可以根据这个模板将其转换成对应的抽象语法树:

less 复制代码
graph TD
A(变量声明) --> B("< kw,1 >")
A --> C("< id,2 >")
A --> D("< 2 >")

固定模板的问题

语法分析的思路就是模板匹配,就上面的流程一样,但是那是简单的声明语句,我们的代码是可以这么写的:

ini 复制代码
int m = 10;
int a, b, c = 9;

理论上声明是可加无限个变量的。总不能为每种情况都提供模板吧?

语言的设计

也许你也想到了,可以简单处理嘛,限制语言,不允许这种扩展性的写法,这样模板的数量就只有一些了。但是这么处理的话,语言就会变得简单,能够处理的逻辑会变得单一,那么我们实现一个复杂功能就非常麻烦。

要提高语言的适用范围和解决问题的能力,就要提高语言的表现力,一种方法就是加很多东西,把语言变复杂,另一种就是引入少量的规则进行不受限制的组合和拓展,看看这两种方式的对比:

scss 复制代码
//if的复杂规则
if(true)
ifexpr(2+3 > 0)
iffunc(check())
scss 复制代码
//if的扩展
if(true)
if((2+3) > 0)
if(check())

相对来说,第二种方式更符合"对人类友好"的原则。基本上所有的高级语言都是使用第二种方式。那么基于这种方式,我们怎么设计模板呢???

产生式

不管怎么说,不受限制的组合和拓展也是基于基础规则的,所有的表达方式都是有迹可循的,那么只要基于基础规则采用动态的、可以按照规则不断变化和增长的模板,就可以解决匹配的问题,这个解决方案就是产生式

产生式 是表示程序性知识的最小单位,通常用于表示具有因果关系的知识,其基本形式为:P→Q 或者 IF P THEN Q。

好吧,上面的描述肯定让你一脸懵逼,来看看一个例子,看看我们怎么用产生式来描述一只"老虎":

假设一个东西满足"哺乳动物"、"肉食动物"、"有柔毛"三个条件就是老虎的话,那么在这个产生式中Q是老虎,而P是三个组合的条件,那么老虎的产生式如下:

在这里面"哺乳动物"又是另一个产生式:

"肉食动物"也是一个产生式:

把条件铺开,我们可以得到一个老虎的最终描述:

这样这三个产生式就构成了"老虎"的描述,那么通过这种方式,我们也可以让计算机明白老虎是个什么鬼:放进来描述动物的条件,如果这些描述满足老虎的产生式,那么我们就可以确定这个动物是老虎。

但是如果条件很多,层级很深,也会出现平铺开来太大很难匹配的情况,所以我们要让它能够动态匹配,让"老虎"的匹配变成接收是三个条件的组合(a, b, c),然后判断条件a是不是"哺乳动物",同时a也可以是一个条件组合(a1, a2),只要a这个条件组合可以和"哺乳动物"匹配,那就认为a满足条件。

这么做会有另一个问题:我们没办法保证传进来的第一个条件就是属于"哺乳动物",所以需要我们定义规则,在语法层面我们就定义了规则,必须按照语法的规则写才行,这样子就能控制条件的顺序。

终止符和非终止符

ok,产生式的匹配的方法我们大致明白了,在整个语法的推导过程中,无法再向下推导的条件被称为终止符 ,反之,可以继续向下推导的就是非终止符 。比如在上述的"老虎"的产生式中"有柔毛"是一个终止符,而"哺乳动物"则是非终止符。在生成语法树的时候终止符会被填入对应的词法单元。

我们看看一个声明:
int var1 = var2 * 2 + var3;

scss 复制代码
graph TD

A((变量声明)) --> B(int)
A --> C(var1)
A --> D[表达式]
D --> E[表达式]
D --> F(+)
D --> G(var3)
E --> H(var2)
E --> I(*)
E --> J(2)

在上面生成的语法树中,圆角的节点都是终止符,而方角的节点就是非终止符。也就是说在语法的产生式模板匹配中,最终条件P的内容都会被转换成终止符。如果存在非终止符,那么表示未解析完成或者解析失败。

声明语句的产生式

下面我们来看看C语言中怎么去实现这个声明语句的匹配的。

下图是声明的产生式:

示例的代码:

ini 复制代码
int m = 10;
int a,b,c;

我们一步一步来拆解:
声明说明符

首先,声明的第一个条件是声明说明符。在C语言中声明说明符大概有以下几种:

那么就可以推导出声明说明符的产生式:

但是,在C的语法中,声明说明符是可以组合使用的,上述的产生式并不满足需求,所以我们要对上面的产生式做扩展:

我们在说明符后面加上一个声明说明符, [可选] 的标识表示这个声明说明符可有可无,比如:int a = 0; 声明说明符就是类型说明符int没有后续。但是出现这种情况:long long a;,那么声明说明符就等于 类型说明符 + 类型说明符 ,这时候第二个long就需要后面的可选说明符来匹配。同理,余下的说明符匹配也是类似的:

在示例的代码

ini 复制代码
int m = 10;
int a,b,c;

中,两个语句的声明说明符都是int;

初始声明符列表

初始声明符列表是由一个或多个初始声明符组成的,那么初始声明符列表的产生式如下:

初始声明符是什么鬼??其实就是带初始器的声明符,声明符命名了一个变量,初始器为其指定该变量的与其数据类型相符的值。

我们把示例代码替换进去看看:

第一个语句int m = 10;,它的初始声明符是m = 10,没有后续的。

第二个语句int a,b,c;,它的第一个初始声明符是a,后续还有b,c,所以继续匹配初始声明符列表,第二个初始声明符是b,第三个是c

初始声明符

当然初始声明符也是一个非终止符,它由声明符和初始器组成。它的产生式是:

这个理解起来应该比较简单,单个声明符就是不带初始值的,例如示例的a

剩下的一种就是带初始值的:m = 10

当然在这个产生式里面,初始化内容也是一个产生式:常量、表达式等等。这里就不详述了。

结束

声明的产生式的最后是;表示语句结束。

至此,声明的语法匹配完成,我们的示例代码可以得到对应的抽象语法树:

int m = 10;

int a,b,c;

scss 复制代码
graph TD

A(变量声明) --> B("< kw, 3 >")
A --> C(变量列表)
C --> D("< id, 4 >")
C --> E("< id, 5 >")
C --> F("< id, 6 >")

语义分析

语义分析主要是对语法分析的结果进行分析,使用语法树和符号表的信息来检查源程序是否和语言定义的一致,例如:变量是否定义、类型是否正确等等。同时也会收集类型信息,将这些信息存到符号表中。

总结

  • 编译:一种语言转换成另一种语言的过程(高级语言->低级语言)
  • 编译的原因:机器太笨(源代码 -> 机器代码)
  • 词法分析:使用有限状态机,识别一个个字符生成词法单元
  • 语法分析:使用产生式的方式将词法分析的结果转换成对应的语法树
  • 语义分析:检查语法分析的结果
  • 符号表:记录编译过程分析得到词法单元的属性(名称、类型、作用域等)的数据结构

PS:本文为个人对编译原理的一些理解和总结,如有错漏的地方,请指正。

最后的最后,推荐一本书:龙书第三版

相关推荐
Trouvaille ~1 天前
【C++篇】C++类与对象深度解析(六):全面剖析拷贝省略、RVO、NRVO优化策略
c++·c++20·编译原理·编译器·类和对象·rvo·nrvo
无名之逆16 天前
B树和B+树
javascript·数据结构·b树·算法·编译原理·期末速成
嚯呀怪怪怪23 天前
从零基础学Go(九)——Go的Goroutine
golang·线程·多线程·并发·编译原理·协程·gorountine
Beginner x_u3 个月前
编译原理 第二章下: 推导,规约,句型句子,语言,文法分类,二义性
编译原理·语言·推导·文法
codists3 个月前
《编译原理》阅读笔记:p19-p24
编译原理
Beginner x_u3 个月前
编译原理 第一章: 编译概述 重点总结(翻译程序的过程,解释程序,编译程序的结构)
编译原理·解释程序·翻译程序·编译概述
Beginner x_u3 个月前
编译原理 第三章上 :词法分析 状态图的画法与检验
编译原理·词法分析·状态图
codists3 个月前
《编译原理》阅读笔记:p18
编译原理
岁岁岁平安3 个月前
编译原理大题自解(活前缀DFA、LR(0)分析表)
笔记·编译原理
不规则前端4 个月前
一文学会编译原理,妈妈再也不用担心我失业了
程序员·编程语言·编译原理