一、编译器原理及其工作流程
1.1 编译器原理
编译器的功能就是对原程序通过某些规则进行转换最终得到目标程序的过程,说的详细点就是将目标程序转换成 cpu 可执行的二进制代码
编译的过程大概可以分为三个阶段,这也正是当前主流的编译器架构,即:编译前端(frontEnd)、中间代码优化(optimizer)、编译后端(backEnd)。
- 编译前端:将源代码转化成中间代码。其详细过程包括:预处理、词法分析、语法分析、生成中间代码;
- 中间代码优化:对编译器生成的中间代码进行一些优化,最终提供给编译后端;
- 编译后端:根据不同的 cpu 架构,将中间代码汇编,产生汇编代码,最后解析汇编指令,生成目标代码,也就是机器码;[1]
1.2 编译器的工作流程及细节
每每看到关于编译器这部分的文章时,都会去介绍这里的编译工作流程,这块内容快被说烂了,其实具体的步骤还是那些... 这里就不细讲了,那就简单的过一下(实现js编译器也就涉及到了前两个流程)
- 词法分析阶段
- 语法分析阶段
- 语义分析阶段
- 中间代码生成
- 代码优化器
- 代码生成(机器码)
- 机器代码优化器
- 目标机器语言
首先就是词法分析,会将程序进行分词每次分词都是按照分词规则去分词,输入的是字符串,得到的是一组被标记后的 tokens 串,比如一段 var name = 2 进过分词后就得到了
[
{ type: 'VariableDeclaration', kind: 'var', value: 'var' },
{ type: 'SpaceSymbol', value: ' ', blank: true },
{ type: 'IdentifierStatement', value: 'name' },
{ type: 'SpaceSymbol', value: ' ', blank: true },
{ type: 'EqualSignSymbol', value: '=', before: true },
{ type: 'SpaceSymbol', value: ' ', blank: true },
{ type: 'NumberStatement', value: '2' }
]
type 为类型,value 为值,当然还有一些其他的标记比如 ``blank、```before 等等,这些都要根据不同的编译器自己指定的规则
下一步就开始了语法分析,根据处理的目标程序语言的语法规则去写一套处理规则,遍历 tokens 生成语法规则树 AST 大致的就是下面这样
{
type: 'Program',
body: [
{
declarations: [
{
id: {
name: 'name',
type: 'Identifier'
},
init: {
value: '2',
raw: '2',
type: 'Literal'
},
type: 'VariableDeclaration'
}
],
kind: 'var',
type: 'VariableDeclaration'
}
]
}
根据现在得到的 AST 树进行语义分析,去处理其中的类型检查[2],处理变量及表达式,这里比较重要,在然后就是中间代码生成、中间代码表达形式、最终生成目标的机器语言。
二、如何实现JavaScript编译器
2.2 目标
最终会实现一个使用 js 语言编写的 js 编译器,其实也不算是编译器,它的流程就三步:token 阶段,parser 阶段, generator 阶段,首先是分词阶段,会完成一个专门分词的函数,用来对目标字符串进行分词,第二部就是根据 token 以及语法规则生成 语法规则树 这一部分的实现比较复杂,最后一部分去遍历 AST 树,生成 js 语言。
2.3 研究意义
我们使用 js 语言实现了一个 编译器 最终又生成一个 js 语言 看起来是脱裤子放屁,其实并不是这样的,它可以做到更多,文件打包就涉及到了这个,尤其是多文件打包的过程,这套js编译器会去将文件中的代码进过 parser 阶段生成 AST 树后,生成的过程中就取到了 import 这样的导入文件的关键词,根据这样的关键词会生成一个定位表。
当前 ast 树中使用了被引入的变量会被标记下来,会根据 定位表中的内容去解析文件持续上述内容,拿到最终值并替换 ast 树上被标记的节点,这里其实就涉及到了遍历 ast 以及多个 ast 合并的内容,最终会生成一个 ast 树,遍历此树生成一个 code 这也就是 rollup 的工作流程,这里只介绍了一种使用方式,还有其他的比如 eslint 检查 等等 都会涉及到 ast 树。
2.4 具体思路
首先要做的就是分词阶段,这部分的工作就是将字符串转换成一个个的 token ,js 中有个很方便的语法可以直接去实现 String.split('')将每字符串都切分了一个一个的字符,根据每个字符的意思最终在组合成词素,根据不同的词素来给 打上 type 标记形成 token 这些 token 组成一个 tokens 流 词法阶段就结束了,具体参考 2.4.1章节。
拿到 词法阶段的 tokens 首先会进行关键词的查找,然后进行表达式的处理,最终会将每个表达式都生成一个 node 对象将所有的对象按照父子关系去连接起来就形成了 ast 树 这一部分的细节非常多,也是最复杂、看似冗余但又避免不了的,具体参考 2.4.2章节。
2.4.1 词法分析
书结上回,前面说到可以使用 Stirng.split('') 语法来快速的去对字符进行分割得到每一个字符组成的数组
var target = 'var name = 1'
console.log(target.split(''))
// 最终得到
[
'v', 'a', 'r', ' ',
'n', 'a', 'm', 'e',
' ', '=', ' ', '1'
]
接下来就要根据每个字符进行匹配,字符串的匹配其实是一个难点,这里使用 Unicode 字符来去判断,比如 a-z的 unicode 字符区间为 97 - 122 A-Z 的字符区间为 65 - 90 根据这样的性质就可以根据 unicode 字符来去判断于匹配,
这里放一个字母的匹配函数
export function IsVariableLetter(char) {
if (char < 65) {
return char === 36 // 判断是否是 $
}
else if (char < 91) return true // a - z
else if (char < 97) {
return char === 95 // 判断是否是 下划线
}
else if (char < 123) return true // A - Z
return false // 其他字符
}
通过这样的方式避免使用正则匹配,也算是减低了实现的复杂度了。。。
如果是字母就要去循环的判断下一个是否还是 是就进行字符串的拼接,由拼接后形成单词,单词的种类大致可以分为:关键词、标识符、常量、运算符于界限符。
-
JavaScript 关键词 有
var, let, function, switch这些 根据关键词去维护一个关键词表用于匹配和获得相应的 type 这里的type 就是一词一码 每个关键词都有不同的 type 比如var的 type 为Variablefunction的 type 为FunctionStatement -
标识符: 多词一码,不在关键词表中的字母就被定义为标识符 type 类型为
Identifier -
常量:使用 一种类型一种码, 比如
truetype:TrueStatement除此之外还有 false undefiend -
运算符: 算数运算符、逻辑运算符与关系运算符 一次一码
-
界限符号:; ( ) = { }
最终会根据不同的单词生成 不同的 token 它的形式类似于这样
{
type: "var",
value: "Variable"
}
根据每个字符的所在的不同区间去判断这里还有一个名词是 有限自动机 类似于下图,出现在初始阶段会判断是否是字母然后去判断 字母后是否还是字母或者是数字,是就循环的此操作,否:进行下一个到达 3 判断是否是操作符,是就循环判断是不是 >= 符号,不是就进行下一次判断 是不是 数字是就循环判断 其实就是判断+循环的过程。
代码实现如下:
function tokenizer(input) {
input = String(input)
let current = 0, length = input.length
// 最终返回 的 tokens 数组
const tokens = []
while (current < length) {
const str = input[current]
// 这里获取 字符的 unicode 数字
const char = str.charCodeAt()
// 这里去判断
// 也就是有限自动机部分
readToken(char)
}
function readToken(char) {
// 判断字母
if (IsVariableLetter(char)) {
let value = ''
while (!IsVariableLetter(char)) {
value += value
current++
}
return {
type: "Identifier",
value: value
}
}
if (IsNumber(char)) {
// ...
}
if (IsString(char)) {
// ...
}
if (IsSymbol(char)) {
// ...
}
}
// ----------- utils ------------
function IsVariableLetter(char) {
// ...
}
// 判断数字
function IsNumber(char) {
// ...
}
// 判断字符串
function IsString(char) {
// ...
}
// 判断字符
function IsSymbol(char) {
// ...
}
// 结束
return tokens
}
函数最终会返回 tokens 数组 整个 tokens 阶段就结束了 这一部分很简单,没有过多的难点注意细节就行。
2.4.2 语法分析(关键)
其实最重要的就是这块内容了,js 编译器不需要进行语义分析,所以这里也是最终 ast 树生成的部分。
首先会拿到 依次拿到 token 对象 进行关键词解析,根据不同关键进行不同的操作,比如处理 if 关键字的 判断当前 token 的 value 是不是 if 类型 然后获取下一个 token (后面都用 next() 来代替) 去校验一下是否存在 ( 左括号如果有就调用处理 括号的规则函数,处理完成后以右括号为结束点,在判断是否有 { 存在 就会进入 花括号处理规则然后再处理其内的表达式,大致的流程如下图。
这去介绍一个简单比如 var name = 1程序获得 token 后如何进行语法分析
[
{ type: 'VariableDeclaration', value: 'var' },
{ type: 'SpaceSymbol', value: ' ' },
{ type: 'IdentifierStatement', value: 'name' },
{ type: 'SpaceSymbol', value: ' ' },
{ type: 'EqualSignSymbol', value: '=' },
{ type: 'SpaceSymbol', value: ' ' },
{ type: 'NumberStatement', value: '2' }
]
首先会拿到 当前 token {type: "VariableDeclaration", value: "var"} 进行关键词判断如果是关键词 var 那就会创建一个 node 对象 类似于下面这种对象
var node = {
start: 0,
end: null
}
然后 next() 取到下一个 token ,如果 token 的值无意义就会跳过到下一个 并调用 parserVar 这个函数主要就是对 var 关键词处理 现在的 token 为 {type: "IdentifierStatement", value: "name"}
function parserVar() {
const node = {
start: 0,
end: null
}
// 取到当前的 token value
node.id = token.value
// 拿到下一个 token
next()
// 判断一下是否为 = 符号
// 是 就会调用 parseExpression 进行表达式处理
// 否 就会使 node.init = nul
// node.init 就是 代表值的意思
node.init = test("=") ? parseExpression() : null
// 替换它的 type
node.type("VaribleDeclaration")
return node
}
其中表达式处理是语法分析阶段的关键,先看下图
它总共分为七个步骤,五个阶段去判断 这里总结了一个大致的算法来处理 JavaScript 表达式, 根据优先级的不同依次往下执行
1.0.0 一元操作符处理
1.1.0 前缀处理
1.1.1 init node
1.1.2 node{operator: value, prefix: true}
1.1.3 next()
1.1.4 node.argument => 1.0.0
1.1.5 return node
1.2.0 关键词处理
1.2.1 init node
1.2.2 this
1.2.3 Identifier
1.2.4 number、string、reg
1.2.5 null, true, false
1.2.6 (
1.2.7 [
1.2.8 {
1.2.9 function
1.2.10 new
1.2.11 default
1.2.(1~11) 处理到中间某个分支后都会返回 node
1.2.12 处理下标
1.2.12.1 处理 点语法
1.2.12.2 处理数组 []
1.2.12.3 处理括号 ()
1.2.12.(1~3) return node
1.2.12.4 return 1.2.0 Result
1.3.0 后缀处理
1.3.1 init node && resultNode = 1.2.0 返回值
1.3.2 resultNode{operator: value, prefix: true}
1.3.3 resultNode.argument => 1.3.0
1.3.5 test resultNode.argument
1.3.6 next()
1.3.7 return 1.2.12 Result
2.0.0 逻辑运算符和关系运算符处理
2.1.0 逻辑运算符 && ||
2.2.0 关系运算符
2.3.0 instateof, in
3.0.0 三元运算符
3.1.0 拿到 2.0.0 返回值
3.2.0 init node
3.3.0 type === "?"
3.3.1 node.test = 3.1.0
3.3.2 node.consequent => 1.0.0
3.4.0 type === ":"
3.4.1 node.alternate => 1.0.0
3.4.2 return node
3.5.0 return 2.0.0 Result
4.0.0 处理 += -= =
4.1.0 拿到 3.0.0 返回值
4.2.0
4.3.0 type === "= || += || -="
4.3.1 node{operator: value, left: 4.1.0}
4.3.2 next()
4.3.3 node{right: 4.0.0}
4.3.4 return node
4.4.0 return 3.0.0
5.0.0 处理逗号
5.1.0 拿到 4.0.0 返回值
5.2.0 type === ","
5.2.0 init node
5.2.1 node{expressions: [5.1.0] }
5.2.2 while(type !== ",")
5.2.2.1 node.expressions.push(1.0.0)
5.3.0 return 4.0.0 Result
不同的关键词都有不同的处理方法,需要根据实际再去细节处理,这里需要注意的是再处理逻辑运算符和关系运算符时候还需要设置不同优先级 < 和 >优先级 < + 和 - 优先级 < * 和 `` 优先级,根据不同优先级来去实现深度优先的嵌套关系,比如这样的一个2 + 3 * 5 该如何处理呢参考下面代码
// tokens
[
{ type: "", value: "2" },
{ type: "", value: '+', grad: 7 },
{ type: "", value: "3" },
{ type: "", value: "*", grad: 8 },
{ type: "", value: "5" }
]
// 逻辑运算
function parseExLogic(node) {
return logic(node, -1)
}
function logic(node, level) {
// 取出当前 token 设置好的优先级
const { grad } = token
if (grad && grad > level) {
const init = {}
init.left = node
init.operator = node.value
// 获取下一个 token
next()
// 递归处理下一个 node
node.right = this.logic(nextNode, grad)
// 判断是否还存在 优先级
// 传入 init 并判断 下一个 token 是否还存在优先级
return this.logic(init, level)
}
// 结束
// 这里返回的 node 就是保持一个深度优先的嵌套关系
return node
}
到这里 最关键的点都罗列出来了 相信大家对如何实现也应该有了头绪和思路了,具体的代码在我 github 中,这是我简单实现的一个用于处理 JavaScript 的编译器,更准确的说目前只做到了编译器中生成 ast 树阶段...
2.4.3 代码生成
这里就是去遍历 ast 树,将 ast 树上不同的 node 对象 value 进行凭借最后生成 code 就行嘞,大致是这样的思路不过这些都是细节层面,没有太大的难点
三、总结
编译器的大体实现起来非常简单,但一旦深究到细节就千难万难,方方面面都需要考虑到,并不是一个简单的工作,就比如说 你要对目标程序进行分词其中就需要涉及到匹配字符串的概念,它又可以分为 数字、字符串、符号、字母、特殊符号。符号又涉及到 逻辑运算符、关系运算符号和算数运算符。单是特殊符号就够喝一壶的了比如 \u \n \x Unicode 符号。
为了考虑到后期可能会替换 token 中 type 类型 这里需要去维护 tokenType 的常量,在 parser阶段匹配关键词时需要维护存放所有关键词的常量对象,这些看起来最不引人注意 但也决定着整体项目的成败,问题很多,干就完了。。。
当做好一个编译器后并不是代表着这就结束了,反而更是一个开始,你需要长时间的去维护去适应更新的语法去完善优化目前的代码,为了能够减少类型错误你开始对其进行 TS 重构,为了考虑通用性你还需要涉及一套插件系统,为了方便大家使用你需要设计一个合适调用接口并附加使用说明等等,重构、优化才是生活的主旋律,你说呢!
前面的实例代码都在该仓库下,目前也存在不少的问题欢迎共建!!!
参考文献
[1] 编译器原理-简书: www.jianshu.com/p/76afd7367…
[2] 编译器的语义分析-CSDN:blog.csdn.net/dc_726/arti…
开发时用到的功能网站
[1] json 在线解析 :www.zjson.net/
[2] 查看 ast 树 :astexplorer.net/
[3] 参考的代码:accon V0.1