zl程序教程

您现在的位置是:首页 >  其他

当前栏目

浅析编译原理基础科普:编译是什么、高级语言低级语言是什么、如何转换、为什么需要ast、编译器转译器解释器如何处理ast

转换基础语言编译器原理 如何 处理 什么
2023-09-11 14:19:56 时间

1、什么是编译?

  编译就是一种转换技术,从一门编程语言到另一门编程语言,从高级语言转换成低级语言,或者从高级语言到高级语言,这样的转换技术。

2、什么是高级语言?什么是低级语言?

  低级语言:是与机器有关的,涉及到寄存器、cpu指令等,特别“低”,描述具体在机器上的执行过程,比如机器语言、汇编语言、字节码等。

  高级语言:则没有这些具体执行的东西,主要用来表达逻辑,而且提供了条件、循环、函数、面向对象等特性来组织逻辑,然后通过编译来把这些描述好的高级语言逻辑自动转换为低级语言的指令,这样既能够方便的表达逻辑,又不影响具体执行。

  说不影响执行也不太对,因为如果直接写汇编,能写出效率最高的代码,但是如果是高级语言通过编译来自动转换为低级语言,那么就难以保证生成代码的执行效率了,需要各种编译优化,这是编译领域的难点。

3、具体怎么转换?

  要转换首先得了解转换的双方,要转换的是什么,转换到什么。

  比如高级语言到高级语言,要转换的是字符串,按照一定的格式组织的,这些格式分别叫做词法、语法,整体叫做文法,那要转换的目标呢,目标如果也是高级语言那么要了解目标语言的格式,如果目标是低级语言,比如汇编,那要了解每条指令时干啥的。

  然后就要进行语义等价的转换,注意这个“语义等价”,通过一门语言解释另一门语言,不能丢失或者添加一些语义,一定要前后一致才可以。

  知道了转换的双方都是什么,就可以进行转换了,首先得让计算机理解要转换的东西,什么叫“计算机理解”呢?就是把我们规定的那些词法、语法格式告诉计算机,怎么告诉呢?就是数据结构,要按照一定的数据结构把源码字符串解析后的结果组织起来,计算机就能处理了。这个过程叫做 parse,要先分词,再构造成语法树。

  其实不只是编译领域需要“理解”,很有很多别的领域也要“理解”:

  全文搜索引擎也要先把搜索的字符串通过分词器分词,然后根据这些词去用同样分词器分词并做好索引的数据库中去查,对词的匹配结果进行打分排序,这样就是全文搜索。

  人工智能领域要处理的是自然语言,他也要按照词法、语法、句法等等去“理解”,变成一定的数据结构之后,计算机才懂才能处理,然后就是各种处理算法的介入了。

  分词是按照状态机来分的(有限状态机 DFA),这个是干啥的,为啥分词需要它,我知道你肯定有疑问。因为词法描述的是最小的单词的格式,比如标识符不能以数字开头,然后后面加字母数字下划线等,这种,还有关键字 if、while、continue 等,这些不能再细分了,再细分没意义啊。分词就是把字符串变成一个个的最小单元的不能再拆的单词,也叫 token。

  然后要把每个单词的处理过程当成一种状态,处理到不同的单词格式就跳到不同的状态,跳转的方式自然是根据当前处理的字符来的,处理一个字符串从开始状态流转到不同的状态来处理,这样就是状态自动机,每个token识别完了就可以抛出来,最终产出的就是一个token数组。

  其实状态也不只一级的,你想想比如一个 html 标签的开始标签,可以作为一个状态来处理,但这个状态内部又要处理属性、开始标签等,这就是二级状态,属性又可以再细分几个状态来处理,这是三级状态,这是分治的思想,一层层的处理

  分词之后我们拿到了一个个的单词,之后要把这些单词进行组装,生成 ast,为啥一定要ast呢?我知道你肯定想问。其实高级语言的代码都是嵌套的,你看低级语言比如汇编,就是一条条指令,线性的结构,但是高级语言呢,有函数、if、else、while等各种块,块之间又可以嵌套。所以自然要组织成一棵树形数据结构让计算机理解,就是Abtract Syntaxt Tree,语法树、而且是抽象的,也就是忽略了一些没有含义的分隔符,比如html的<、>、</等字符,js的{ }() [] ;就是细节,不需要关心,注释也会忽略掉,注释只是分词会分出来,但是不放到ast里面。

  怎么组装呢,还是嵌套的组装,那是不是要递归组装,是的,你想的没错,需要递归,不只是这里的ast组装需要递归,后面的处理也很多递归,除非到了线性的代码的阶段,就像汇编那样,你递归啥,没嵌套的结构可以递归了。

  词法我们刚才分析了,就是一个个的字符串格式,语法呢,是组装格式,是单词之间的组合方式。这也是为啥我们刚刚要先分词了,要是直接从字符串来组装ast,那么处理的是字符串级别,而从token开始是单词级别, 这就像让你用积木造个城堡,但是积木也要你自己用泥巴造,那你怎么造呢?

  (1)可以先把一个个积木造好,然后再去组装成城堡,(2)也可以边造积木边组装。不过小汽车的话你可以边制作积木,边组装,城堡级别的边做积木边组装你能理清要造啥积木么,就很难,所以还是要看情况。

  用这两种方式来做parser的都有,简单的可以边词法分析,分析出热乎乎的单词然后马上组装到ast中, 比如html、css这种,但是像js、c++这种,如果不先分词,直接从字符串开始造ast,我只能说太生猛了。

  说了半天积木和组装,那么怎么组装呢,从左到右的处理token,遇到一个token怎么知道他是啥语法呢,这就像怎么知道一块积木是属于那个部件的。也有两种思路:

  (1)一种是你先确定这个积木是属于那个部件,然后找到那个部件的图纸,按照图纸来组装,(2)另一种是你先组装,组装完了再看看这个是啥部件。这就是两种方式,先根据一两个积木确定是哪个部件,再按照图纸组装这个部件,这种是 ll 的方式,先组装,组装完了看看是啥部件,这种是 lr 的方式。

  ll 的方式要确定组装的是啥,ast节点要往下看几个,根据要看几个来确定组装的是什么就分别是LL(1),LL(2)等算法。ll 也就是递归下降,这是最简单的组装方式,当然有人觉得 lr 的方式也挺简单。ll 有个问题还必须得用 lr 解决,那就是递归下降遇到了左边一直往下递归不到头的情况,要消除左递归,也就是你按照图纸来组装搞不定的时候,就先组装再看看组装出来的是啥吧。

  这其实和人生挺像的,一种方式是往下看两步然后决定当前怎么走,另一种方式是先走,走到哪步再说。

  经过词法、语法分析之后就产生了ast。用一棵树形的数据结构来描述源代码,从这里开始就是计算机可以理解的了,后续可以解释执行、可以编译转换。不管是解释还是编译都需要先parse,也就是要先让计算机理解他是什么,然后再决定怎么处理。

  后面把树形的ast转换为另一个ast,然后再打印成目标代码的字符串,这是转译器;把ast解释执行或者专成线性的中间代码再解释执行,这是解释器;把ast转成线性中间代码,然后生成汇编代码,之后做汇编和链接,生成机器码,这是编译器。

4、编译器是怎么处理ast的?

  有了ast之后,计算机就能理解高级语言代码了,但是编译器要产生低级语言,比如汇编代码,直接从ast开始距离比较远。因为一个是嵌套的、树形的,一个是线性的、顺序的,所以啊,需要先转成一种线性的代码,再生成低级代码。我觉得ast也可以算一种树形IR,IR是immediate representation中间表示的意思。要先把AST转成线性IR,然后再生成汇编、字节码等。

  咋翻译,树形的结构咋变成线性的呢?明显要递归啊,按照语法结构递归ast,进行每个节点的翻译,这叫做语法制导翻译,用线性IR中的指令来翻译AST节点的属性。每个节点的翻译方式,if咋翻译、while咋翻译等可以去看下相关资料,搜中间代码生成就好了。

  但是ast不能上来就转中间代码。ast不就能表示源码信息了么,为什么不能直接翻译成线性ir?

  是因为还没做语义检查啊,结构对不一定意思对,就像“昊昊是只猪”,这个符合语法吧,但是语义明显不对啊,这不是骂人么,所以要先做语义检查。还有就是要推导出一些信息来,才能做后续的翻译

  语义分析要检查出语义的错误,比如类型是否匹配、引用的变量是否存在、break是否在while中等,主要要做作用域分析引用消解类型推导和检查正确性检查等。

  作用域分析就是分析函数、块等,这些作用域内的变量都有啥,作用域之间的联系是怎样的,其实作用域是一棵树,从顶层作用域到子作用域可以生成一个树形数据结构。我记得有个做scope分析的webpack插件,他是把模块也给链接起来了,形成了一个大的 scope graph,然后做分析。

  作用域中有各种声明,要把它们的类型、初始值、访问修饰符等信息记录下来,保存这个信息的结构叫符号表,这相当于是一个缓存,之后处理这个符号的时候直接去查符号表就行,不用再次从ast来找。

  引用消解呢就是对每个符号检查下是否都能查找到定义,如果查找不到就报错。类型方面你比较熟,js的源码中肯定不可能都写类型,很多地方可以直接推导出来,根据ast可以得出类型的声明,记录到符号表中,之后遍历ast,对各种节点取出声明时的类型来进行检查,不一致就报错。还有其他一些琐碎的检查,比如continue、break只能出现在while中等等一些检查。

  语义分析之后就代表着程序已经没有语法和语义的错误了,可以放心进行各种后续转换,不会再有开发者的错误。之后先翻译成线性IR,然后对线性IR进行优化,需要优化就是因为自动生成的代码难免有很多冗余,需要把各种没必要的处理去掉。但是要保证语义不变。比如死代码删除、公共子表达式删除、常量传播等等。

  线性IR的分析要建立流图,就是控制流图,控制流就是根据if、while、函数调用等导致的程序跳转,把顺序执行的代码和跳转到的代码之间连接起来就是一个图,顺序执行的代码看成一个整体,叫做基本快。之后根据这个流图做数据流分析,也就是分析一个变量流经了那些代码,然后基于这些做各种优化。

  这个部分叫做程序分析,或者静态分析,是一个专门的方向,可以用于代码漏洞的静态检查,可以用于编译优化,这个是比较难的。研究这个的博士都比较少。国内只有北大和南大开设程序分析课程。

  优化之后的线性IR就可以生成汇编代码了,然后通过汇编器转成机器码,再链接一些标准库,比如v8目录下可以看到builtins目录,这里就是各种编译好的机器码文件,可以静态链接成一个可执行文件。

  前端领域基本不需要汇编和链接,就算是wasm,也是生成wasm 字节码,之后解释执行。前端主要还是转译器。

5、转译器在ast之后又做了哪些处理呢?

  转译器的目标代码也是高级语言,也是嵌套的结构,所以从高级语言到高级语言是从树形结构到树形结构,不像翻译成低级的指令方式组织的语言,还得先翻译成线性IR,高级到高级语言的转换,只需要ast,对ast做各种转换之后,就可以做代码生成了。

  所以 babel 中就没有线性 IR 的概念。不管是跨语言的转换,比如 ts 转 rust,还是同语言的转换js转js都不需要线性结构,两棵树的转换要啥线性中间代码啊。所以一般转译器都是 parsetransformgenerate 这3个阶段。

  parse 广义上来说包含词法、语法和语义的分析,狭义的parse单指语法分析。这个不必纠结。

  transform 就是对ast的增删改,之后generator再把ast打印成字符串,我们解析ast的时候把[]{} () 等分隔符去掉了,generate的时候再把细节加回来。

  其实前端领域主要还是转译器,因为主流js引擎执行的是源代码,但是这个源代码和我们写的源代码还不太一样,所以前端很多源码到源码的转译器来做这种转换,比如babel、typescript、terser、eslint、postcss、prettier等。

  babel 是把高版本es代码转成低版本的,并且注入polyfill。typescript是类型检查和转成js代码。eslint是根据规范检查,但--fix也可以生成修复后的代码。prettier也是用于格式化代码的,比eslint处理的更多,不只限于js。postcss主要是处理css的,posthtml用于处理html。相信你也用过很多了。taro这种小程序转译器就是基于babel封装的。

6、解释器怎么处理 ast?

  首先转译器也是编译器的一种,只不过比较特殊,叫做 transpiler,一般的编译器叫做compiler。解释器和编译器的区别确实是是否生成代码,提前编译成机器代码的叫做 AOT 编译器,运行时编译成机器代码的叫做 JIT 编译器,

  解释器并不生成机器代码,那它是怎么执行的呢?知道你肯定有疑问。

  其实解释器是用一门高级语言来解释另一门高级语言,比如c++,一般都用c++来写解释器,因为可以做内存管理。用c++来写js解释器,像v8、spidermonkey等都是。我们在有了ast并且做完语义分析之后就可以遍历ast,然后用c++来执行不同的节点了,这种叫做tree walker解释器,直接解释执行ast,v8引擎在17年之前都是这么干的。但是在17年之后引入了字节码,因为字节码可以缓存啊,这样下次再直接执行字节码就不需要parse了。字节码是种线性结构,也要做ast到线性ir的转换,之后在vm上执行字节码。

  一般解释线性代码的比如汇编代码、字节码等这种的程序才叫做虚拟机,因为机器代码就是线性的,其实从ast开始就可以解释了,但是却不叫vm,我觉得就是因为这个,和机器码比较像的线性代码的解释器才叫 vm。

  不管是解释 ast 也好,还是转成字节码再解释也好,效率都不会特别高,因为是用别的高级语言来执行当前语言的代码,所以要提高效率还是得编译成机器代码,这种运行时编译就是JIT编译器,编译是耗时的,所以也不是啥代码都JIT,要做热度的统计,到达了阈值才会做JIT。然后把机器码缓存下来,当然也可能是缓存的汇编代码,用到的时候再用汇编器转成机器码,因为机器代码占的空间比较大。

  可以对比v8来理解,v8 有parser、ignation解释器、turbofan编译器,还有gc。

  ignation解释器就是把parse出的ast转成字节码,然后解释执行字节码,热度到达阈值之后会交给turbofan编译为汇编代码之后生成机器代码,来加速。gc是独立的做内存管理的。

  turbofan是涡轮增压器,这个名字就能体现出 JIT 的意义。但JIT提升了执行速度,也有缺点,比如会使得js引擎体积更大,占用内存更大,所以轻量级的js引擎不包含jit,这就是运行速度和包大小、内存空间之间的权衡。架构设计也经常要做这种两边都可以,但是要做选择的trade off,我们叫做方案勾兑。

  说到权衡,我想起rn的js引擎hermes就改成支持直接执行字节码了,在编译期间把js代码编译成字节码,然后直接执行字节码,这就是在跨端领域的js引擎的trade off。

7、明白解释器、编译器、转译器都干啥的了,那前端领域都有那些地方用到编译原理的知识呢?

  工程化领域各种转译器:babel、typescript、eslint、terser、prettier、postcss、posthtml、taro、vue template compiler等

  JS 引擎:v8、javascriptcore、quickjs、hermes等

  IDE 的 lsp:编程语言的语法高亮、智能提示、错误检查等通过language service protocol协议来通信,而lsp服务端主要是基于parser对正在编辑的文本做分析

  前端工程师不需要达到那种深度,但是眼界开阔点没啥坏处

学习文章:https://mp.weixin.qq.com/s/tQn1OQLCPvjKQO9NRZICgw