前端学编译原理(一):编译引论

6,388 阅读24分钟

作者:寒草
微信:hancao97
介绍:一个不一样的北漂程序员(工作10个月的年轻程序员),欢迎加微信批评指正交流技术或者一起玩耍约饭

引言

凡事都可以扯一扯情怀

在我在开始写文章的那一段时间,我发了这样一篇文章 我,24岁,展望一下?里面不仅提到了我的各种离谱的flag,也提到了我的母校,我怀念那段时光,也因整个大四那一年没有在母校度过,因为疫情的原因没有毕业照,没有毕业旅行而十分可惜。
但是,我还是记得我在母校度过的美好时光,而编译原理也正是我在那里求学过程中有印象的最后一个专业课。前两天时间,我也在和别人讨论我的js实现按键精灵——尝试前端实现自动化测试(一),在交流探讨过程中,我感受到其实曾经学习过的编译原理也确实在影响着我的思考方向。
于是我找回了大学的课件资料,并会结合更多的资料,去完成我的前端学编译原理这个系列:

一方面是对知识的回顾
一方面是怀念那曾经在学校里不曾认真听讲,期末突击完成的课(狗头)。

为什么要学编译原理

首先介绍一下,我是一名前端工程师,所以在此以前端的视角出发来思考这个问题:

为什么我们要学习编译原理?

首先我想去纠正一个误区,前端并不是只要去弄好HTML,CSS,JS三大金刚,了解了解各种不同的布局模式,学习一些主流的框架,用一用人家提供好的API,用一下社区成熟的脚手架快速搭建项目就可以了。是这样就能胜任很大部分的前端工作,但是深入学习我们会发现一件可怕的事情,我们会发现在前端这个领域会有层出不穷的各种库,各种工具,各种框架。技术推陈出新,可是万变不离其宗,编译原理作为一个基础理论学科,学习编译原理可以帮助你:

  • 更快更容易的学习新的技术

  • 可以帮助我们更多的了解语言背后的抽象

  • 可以帮助我们用更优雅的形式去描述复杂的模型 而且可能所谓编译器更像是一个把源语言变成目标语言黑盒子,所以我们其实对他并没有太大的感知,但是其实它已经渗透在我们日常工作的方方面面了:

  • eslint:代码检查

  • es6,ts转码工具:Babel(将采用 ECMAScript 2015+ 语法编写的代码转换为向后兼容的 JavaScript 语法),tsc(用于将TypeScript 转换为 JavaScript 代码)

  • 各种模版引擎(输入模板字符串 + 数据,得到渲染过的字符串):最早诞生于服务端动态页面的开发,如 JSP、PHP、ASP 等模板引擎,自 Node.js 快速发展以后,前端界又产出了非常多的轮子,包括 EJS、art-template、Pug 、Mustache等等。【个人对模版引擎没什么了解,此处列举的模版引擎来着万能的网友

  • CSS预处理器:sass、less等等,让我们从纯css时代的刀耕火种中解放。赋予了前端工程师们更强大的样式编写能力(虽然我可能用到的也只是他们提供的皮毛,但是支持css嵌套真的是深得我心)。

  • 主流框架中的应用:区别于模版引擎,不可将前端框架和模板引擎混为一谈,很多的主流框架都有对编译原理的应用,包括vue,react,angular。

  • markdown:比如我现在正在掘金写我的文章,左边是markdown语法,右边就可以同步预览最终的展示效果~

  • ...

总结一下
可能我对于学习编译原理的原因描述不是很专业,可能也有我能力所限的原因,也可能是因为我工作年限并没有达到某个地步,但是我希望大家了解的是以下几点:

  1. 学习编译原理可以帮助我们更好更快的学习新的技术。
  2. 在前端领域,编译原理已经有了大范围的应用,所以想成为一个更加优秀的前端工程师,编译原理也是一项必修课。
  3. 学习cs专业的基础理论学科,可以帮助你获得突破,去做一些之前只敢想象的事情。

开始前说点什么

我也不知道以我这粗浅的掌握和拙劣的语言功底能否把这样一个较大的课题讲的清楚明白,但是事在人为,我会尽我所能,让这个系列可以在保证正确性的基础之上保持更新,并以我的视角带大家与我一起感受编译之美。
之前我也出过很多系列性质的文章:
浏览器渲染机制
Promise专题
如果大家对以上内容有一丢丢兴趣,也欢迎阅读并与我交流探讨,作为刚入行不到一年的新人也期望收到各位大佬各位前辈的批评指正。

ok,闲言少叙,我们进入正题。

程序设计语言和编译程序

低级语言基本概念

介绍:

包括机器语言和汇编语言
机器语言:指的是机器能直接识别的程序语言。无需经过翻译,每一操作码在计算机内部都有相应的电路来完成它,或指不经翻译即可为机器直接理解和接受的程序语言或指令代码。计算机硬件只能识别“断开”和“闭合”两种物理状态,也就是0和1。机器语言使用绝对地址和绝对操作码。不同的计算机都有各自的机器语言,即指令系统,不同型号的计算机其机器语言是不相通的,按着一种计算机的机器指令编制的程序,不能在另一种计算机上执行。从使用的角度看,机器语言是最低级的语言。

  • 操作码:操作码给出指令完成的功能
  • 地址码:地址码给出与操作数相关的地址或者操作数本身
  • 指令 10110110【操作码】 00000000【地址码】 表示进行一次加法操作
  • 指令 10110101【操作码】 00000000【地址码】 表示进行一次减法操作 汇编语言:是任何一种用于电子计算机、微处理器、微控制器或其他可编程器件的低级语言,亦称为符号语言。在汇编语言中,用助记符(Mnemonics)代替机器指令的操操作码,用地址符号(Symbol)或标号(Label)代替指令或操作数的地址。在不同的设备中,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。普遍地说,特定的汇编语言和特定的机器语言指令集是一一对应的,不同平台之间不可直接移植。举个例子,a = a + b的表达方式
  • MOV AX,1
  • MOV BX,2
  • ADD AX,BX ps:是不是相较于之前的0和1,现在已经可以大致看懂了? 优点:
  • 速度快
    缺点:
  • 难理解,出错率高,难维护
  • 依赖于具体机器,移植性差 发现:
  • 越是低级的语言对机器越是友好,越是符合机器的思考方式,因此执行效率高。
  • 越是高级的语言对人类越是友好,越是符合人类的思考方式,因此开发效率高。

高级语言基本概念

形式语言

介绍:

用精确的数学或机器可处理的公式定义的语言。与自然语言对应,自然语言就是人类讲的语言,这类语言不是认为设计,而是自然进化的。形式语言是为了特定的应用而人为设计的语言,存在领域之分,例如数学家用的各种运算符号,化学家用的各种分子式,而编程语言也是一种形式语言,是专门设计用来表达计算过程的形式语言

特点:

  • 高度的抽象化:采用形式化的手段-专用符号,数学公式-来描述语言的结构关系,这种结构关系是抽象的
  • 是一套演绎系统:形式语言本身的目的就是要用有限的规则来推导语言中无限的句子,提出形式语言的哲学基础也是想用演绎的方法来研究自然语言
  • 具有算法的特点

高级语言

介绍:

高度封装了的编程语言,与低级语言相对,它更接近于我们平时正常的人思维,其最大的特点是编写容易,代码可读性好。实现同样的功能,使用高级语言耗时更少,程序代码量更短,更容易阅读。其次,高级语言是可移植的,也就是说,仅需稍作修改甚至不用修改,就可将一段代码运行在不同类型的计算机上。现在大多数人使用的语言,如C、C++、Python、Java、Javascript等等,都属于高级语言。 优点:

  • 面向自然表达
  • 更易于学习,易于理解,易于修改
  • 可移植性高 缺点:
  • 运行需要其他程序支撑(编译程序等)
  • 运行速度相比汇编要慢
  • 占用空间相对较多

高级语言与汇编语言程序的执行

翻译程序

翻译程序可以将一种计算机编程语言编写的程序翻译成另一种计算机语言。输入对象是源程序,一般是由高级语言编写的程序,输出对象是目标程序,可以是机器语言,汇编语言,或者是用户自定义的某种中间语言程序或者是其他的高级语言。翻译程序可以包括:

  • 汇编器(Assembler)
  • 编译器(Compiler)
  • ...

image.png

Linker(链接器):将 目标文件内容 连同 运行时库程序 合成到一个 计算机能够加载和执行的目标程序

执行方式

执行高级语言程序的方式分为三种:

  • 编译方式
  • 解释方式
  • 转换方式 下面我来具体介绍~

编译方式和解释方式

编译方式 image.png
解释方式 image.png 详细介绍
这里我先借助知乎网友的例子,这个过程的区别很像是:

A是英语演讲者,B是台下的听众,C是翻译官。那么,编译器就是:翻译官把A的演讲的所有内容(等A演讲完)一次性整理好成一份翻译后的文件,发给听众B看。 翻译器就是:翻译官C在A演讲的时候,A讲一句,C翻一句给听众B。

其实这个例子还是很生动形象的,总结起来就是:

  • 编译器是在代码运行之前生成目标平台指令,可脱离编译器独立运行。
  • 解释器在代码运行过程中生成目标平台指令,所以不可以脱离解释器独立运行。

于是我们看到的现象是,编译型语言要先编译再运行,而解释性语言直接“运行”源代码。

即他们的根本区别就是运行时,解释型需要将程序解释成目标平台指令来运行,费了一道手续,而编译型在运行之前就已经让编译器给程序编译成目标平台指令了,所以更快。
ps:此处可以结合后面辩证的看 那个段落效果更佳~

解释器与编译器不同角度对比

  • 着眼点:解释器是执行系统,编译器是转换系统
  • 程序动态修改:解释执行更胜任,编译执行需要动态编译技术,难度较大
  • 速度:解释器较慢
  • 空间开销:解释器开销大
  • 错误诊断:解释器更强,因为解释器会逐个语句的执行源程序

辩证的看

我们不妨换个角度来看,其实如果单纯从编译方式和解释方式的定义出发,他们是那么的水火不相容,然而编译和解释的界限却并没有如此的清晰,举个例子(下文有流程图):Java需要预先把代码编译成虚拟机指令的,然后在运行这些虚拟机指令,有的教科书上会成为混合型或者半编译型,这样的好处之一是在一台机器上编译得到的字节码可以在另一台机器上解释执行,通过网络就可以完成机器之间的迁移。所以其实我们把他们撕裂着看又有一点点不合理,我查阅资料也发现有很多人对此有不同的见解,其实我眼里,我们高级代码执行,无非是从高级的抽象转化为低层级抽象的一个过程,我们在这个过程中,可能用到了解释器,也可能用到了编译器,这两者的使用并无冲突,就像上面的例子一样,无非是我们可能在降低代码抽象层级的过程中分出了不同的阶段,用了不同的方案而已,无论是编译执行还是解释执行,应该与语言无关,只是使用了什么样的方案把代码让机器看的懂而已,所以纠结一个语言是解释型还是编译型语言在我的认知中是不必要上一段Java的流程图:
image.png

转换方式

转换方式我放在最后单独说,因为可能和上述内容有一点点不同:

  • 假如我们要实现L语言
  • 我们现在已经有了L1语言的编译程序
  • 那么我们可以先把用L语言编写的程序转换成等价的L1语言程序
  • 再去利用L1语言的编译程序去实现L语言 说白了我们就是利用转换器将没有编译程序的L程序转换成已经存在编译程序的L1语言再去将其转换为目标程序 。 image.png

编译器的结构

引言——没有谁是一座孤岛,编译器也一样

程序设计语言是向人以及机器描述计算过程的记号,这个世界依赖于程序设计语言,因为在所有计算机上运行的所有软件都是用某种程序设计语言编写的,但是,在一个程序可以运行之前,他需要先被翻译成计算机可以执行的形式。做这个翻译工作的就是编译器。简单来讲,编译器就是一个程序,他可以阅读某一种语言编写的程序,并把该程序翻译成为一个等价的、用另一种语言编写的程序。
——引自《编译原理(第二版)》第一章引论部分

编译器的基本任务:将源语言程序翻译成等价的目标语言程序 image.png 上文中引言可能也会造成些许理解误差,把源语言程序转换成计算机可执行文件有可能不完全是编译器独自完成的,编译器并不是孤军奋战,他也有很多同伴与之携手一同把源代码转换成机器上的可执行文件,在上一章对翻译程序的介绍中我们说过,翻译程序可能有很多部分组成,编译器可能只是其中一部分。创建一个可执行的目标程序可能还需要一些其他程序:

  • 预处理器:一个源程序很可能被分成多个模块,并存放在独立的文件中,预处理器可以把源程序聚合在一起,同时还负责把那些称为宏的缩写转换为源语言的语句。
  • 编译器:将源语言程序翻译成等价的目标语言程序(可能产生汇编程序作为输出,因为汇编语言比较容易输出和调试)
  • 汇编器:将汇编程序转换为可重定位机器代码
  • 链接器:大型程序经常被分成多个部分进行编译,因此可重定位的机器代码有必要和其他可重定位的目标文件以及库文件链接在一起,形成真正的机器上运行的代码。链接器就是解决外部内存地址引用的问题。
  • 加载器:把所有可执行目标文件放在内存中执行 image.png OK,我们介绍了编译器和编译器的伙伴,那么接下来一起窥探一下编译器神秘表象下的内部结构吧。

编译器内部结构概览

在我么开发者视角上,我们写完代码,之后之间编译运行,其实更多的时候对于编译器是无感知,可能更多的时候点击运行,之后呐喊link start!代码就跑起来了,当然估计没什么人会像这样有仪式感(其实正常人称呼这样的行为是中二)。所以编译器对于我们就像是一个黑盒子,我们把这个盒子打开一点,就可以看到里面包含两个部分:

  • 分析部分
  • 综合部分 经常分析部分被称为前端,综合部分被称作后端

分析部分(前端)

分析部分把源程序分解成多个组成要素,并在这些要素之上加上语法结构。然后,它使用这个结构创建该程序的中间表示。当然在这个阶段如果检查出程序的语法错误,或者语义不一致,就必须提供有用的信息,使得用户可以将其改正。

分析部分还会收集有关源程序的信息,并把信息存放在一个称为符号表的数据结构中(后期寒草:“符号表在后面也会多次提到哦~”)。符号表和中间代码表示会作为综合部分的输入。

综合部分(后端)

综合部分根据中间表示和符号表中的信息来构造用户期待的目标程序。

编译器分步骤介绍

在上一段我们把编译器分成了两个阶段:前端和后端。但是如果我们能加详细的研究编译过程,会发现他会顺序执行一组步骤,一个典型的把编译原理分解成多个步骤的方式下面两个图片。当然在实践中,多个步骤可能会被组合在一起,而组合在一起的步骤之间的中间表示不需要被明确构造。存放整个源程序的信息的符号表可以由编译器的各个步骤使用

image.png 其中为了综合部分(后端程序)可以生成更好的目标程序,如果基于未经过优化的中间表示来生成代码,则代码质量会受到影响,所以可能会加入代码优化阶段。图中的两个代码优化阶段可以被省略。

所以我们进行一个简要的总结,其实整个步骤可以大致描述为以下几个阶段:

  • 词法分析
  • 语法分析
  • 语义分析
  • 中间代码生成
  • 中间代码优化
  • 目标代码生成

词法分析

介绍

编译器的第一个步骤是词法分析,词法分析的输入是源程序的字符序列。识别每一个单词及其种类,并将其表示成TOKEN形式: image.png tip:

  • 词法分析阶段不依赖于语言的语法定义
  • 词法分析的结果是语法分析的输入

词法分析还可以做到

  • 检测标识符拼写错误
  • 去掉代码中的注释

举例

举个例子大家可能就懂了:

float sum, first;
sum = first + 10;

上面是一段简单的代码,那么我们进行词法分析:

image.png 可能实现的话有差异,此处为了大家可以理解的更加清楚,所以一切从简,但是我们依然可以按照这个来理解,我想大家可能看完例子,就大概明白了这个过程。

为什么第一个sum变成了标识符 1 呢,其实标识符后面对应的值其实指向了符号表的引用,后面first对应2也是这个道理

词素:语法功能的最小单位,上面 float,sum,first,+ 等都是一个词素。

语法分析

介绍

语法分析是编译器的第二个步骤,语法分析会使用词法分析生成的TOKEN序列,并依据源语言的语法规则生成树形的中间表示。该中间表示给出了词法分析产生的词法单元流的语法结构。

tip:

  • 分析时如果发现错误,则输出错误的位置以及类型
  • 未发现错误则将语法分析的输入(词法分析的输出)转换为树形中间形式,常用的方法是语法树

语法树:树中的每个内部节点表示一个运算,而该节点的子节点表示该节点的子节点表示该运算的分量。 输入:

  • 当词法分析程序时语法分析程序的子程序时:输入为源程序的字符序列
  • 当词法分析是独立的:输入是TOKEN序列

举例

继续前面词法分析的例子来看。 首先我们的例子还是sum=first+10。他所对应的词法分析TOKEN序列是:
<标识符,1> <运算符,=> <标识符,2> <运算符,+> <整形常量,10> <分隔符,;>

其中 1对应sum,2对应first。

image.png

语义分析

介绍

编译器的第三个步骤是语义分析,该能力由语义分析器提供,语义分析器使用语法树和符号表中的信息来检查源程序是否和语言定义的语义一致。它同时收集类型信息,并把这些信息存放在语法树或者符号表中,以便在随后的中间代码生成过程中使用。于是语义分析器的作用为:

  • 审查源程序是否有语义错误
  • 为代码生成收集类型信息

类型检查:类型检查是语义分析的重要组成部分,编译器检查每个运算符是否具备匹配的运算分量,比如:

  1. 数组的下标必须是整数,浮点数作为下标会报错。
  2. 条件表达式必须是布尔型
  3. 赋值语句左右两边类型是否相融
  4. ...

自动类型转换:某些程序设计语言会允许某些类型转换,那么在语义分析阶段也会进行自动类型转换,比如:

  • 一个二元运算符可以应用于一对整数或者一对浮点数。那么该运算符如果应用于一个浮点数和一个整数,那么这个整数可能被自动转换成浮点数。

举例

语义错误请看注释:

float sum, first;
sum = first + 10;
count = sum; //count无定义
first = sum % 10;//sum是浮点型,不能进行取余数运算

中间代码生成

介绍

在把一个源程序翻译成目标代码的过程中,一个编译器可能构造出一个或者多个中间表示,这个中间表示可以有多种形式。

语法树就是一种中间表示形式,在语法分析和语义分析中使用。

在语法分析和语义分析完成之后,很多编译器会生成一个明确的低级或者类机器语言的中间表示。这个中间表示应该具备两个重要的性质:

  • 易于生成
  • 可以轻松的翻译为目标机器上的语言

当然这个中间代码可能存在很多种形式:

  • 后缀式(栈式)中间代码
  • 三地址中间代码(三元式和四元式)
  • 图结构的中间代码(树,DAG)

举例

代码:

float sum, first, count;
sum = first + count * 10;

中间代码(四元式):

(int-to-float, 10, , t1)
(*, count, t1, t2)
(+, first, t2, t3)
(=, t3, , sum)

简单解释一下:

  • 真正的中间代码里其中的count,first这种会表示成符号表中的引用。
  • (*, count, t1, t2)以这个为例子含义是 t2 = t1 * count

关于三地址指令:

  • 每个三地址赋值指令右部最多只有一个运算符,因此这些指令顺序确定了运算的顺序
  • 编译器应该生成一个临时名字以存放一个三地址指令计算得到的值(t1,t2...)
  • 有些三地址指令的运算分量少于三个,比如上面中间代码的第一条和最后一条。

代码优化

介绍

目的:改进中间代码,意图生成更好的目标代码。

更好通常意味着更快,但是也可能会有其他目标,比如更短的或者能耗更低的代码。

不同的编译器在代码优化过程所做的工作量可能相差很大,那些优化工作做的很多的编译器(即所谓的优化编译器)会在优化阶段花费相当多的时间,有些简单的优化方法可以极大的提高目标程序的运行效率而不会过多的降低编译速度。

常见的优化方式

  • 常量表达式优化
  • 公共子表达式优化
  • 不变表达式的循环外提
  • 削弱运算强度
  • ...

举例

中间代码(四元式):

(int-to-float, 10, , t1)
(*, count, t1, t2)
(+, first, t2, t3)
(=, t3, , sum)

常量表达式优化:

(*, count, 10.0, t2)
(+, first, t2, t3)
(=, t3, , sum)

目标代码生成

介绍

代码生成器以源程序的中间表示形式(中间代码)为输入,并把他映射为目标语言。如果目标语言是机器代码,那么:

  1. 必须为程序使用的每个变量选择寄存器或者内存位置。
  2. 然后,中间指令被翻译成为能够完成相同任务的机器指令序列。

代码生成的一个至关重要的方面是合理分配寄存器以及存放变量的值。

举例

源程序:

float sum, first, count;
sum = first + count * 10;

汇编代码:

MOV count, R1
MULT R1, #10.0
MOV first, R2
ADD R1, R2
MOV R1, sum

章节小结

根据前文介绍,编译器大体可以分为以下几个步骤:

  • 词法分析
  • 语法分析
  • 语义分析
  • 中间代码生成
  • 中间代码优化
  • 目标代码生成 后面的文章会对各个步骤的细节进行详细介绍,也会使用小的编译器源码作为例子。下图是《编译原理(第二版)》中的图例,我想大家到这里应该已经对编译器的整个流程有了初步的了解,也可以借助这个图片进行一个简单的回顾~ image.png

结束语

image.png 文章中内容来自:

  • 《编译原理(第二版)》
  • 母校课件
  • 经过本人筛选验证的知乎问答
  • 本人的理解(本人理解可能是其中最没有权威可言的部分hhh)

课题内容较多较深,如果存在问题可能无法避免,希望大家不吝赐教,如果存在问题我会积极修正完善~

在此也希望大家感兴趣可以支持我以前的文章系列:
浏览器渲染机制
Promise专题
...
不仅如此,还有很多有趣的内容:
寒草的掘金主页
我们的github
最后!!!
少年与爱永不老去,即便披荆斩棘,丢失怒马鲜衣
我是寒草
间歇性热血,持续性沙雕,希望和大家共同成长,成为一起努力的伙伴
微信:hancao97