动手实现
之前章节介绍了编译原理基础知识,并定义了两个最重要的概念字节码ByteCode和值Value。接下来就可以动手编码实现我们的解释器了!
这系列文章对应的代码全部使用Rust自带的Cargo来管理。目前采用二进制类型的项目,后续会改成库类型。
目前要实现的极简解释器是非常简单的,代码很少,我当初也是把代码都写在一个文件里。不过可以预见的是,这个项目的代码量会随着功能的增加而增加。所以为了避免后续再拆文件的改动,我们直接创建多个文件:
- 程序入口:
main.rs
; - 三个组件:词法分析
lex.rs
、语法分析parse.rs
、和虚拟机vm.rs
; - 两个概念:字节码
byte_code.rs
、值value.rs
。
后面的两个概念和其代码之前已经介绍了。下面介绍其他4个文件。先从程序入口开始。
程序入口
简单起见,我们的解释器只有一种工作方式,即接受一个参数作为Lua源码文件,然后解析并执行。代码如下:
use std::env;
use std::fs::File;
mod value;
mod bytecode;
mod lex;
mod parse;
mod vm;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() != 2 {
println!("Usage: {} script", args[0]);
return;
}
let file = File::open(&args[1]).unwrap();
let proto = parse::load(file);
vm::ExeState::new().execute(&proto);
}
开头2行引用了两个标准库。env
用于获取命令行参数,可参考这里。fs::File
用来打开Lua源文件。
中间几行通过use
引用其他文件的模块。
然后看main()
函数。前面几行是读取参数并打开源文件。在打开源文件时使用了unwrap()
,如果打开失败则终止程序。简单起见,接下来几章对所有错误的处理方式都是直接终止程序,之后再统一引入规范的错误处理。
最后2行是核心功能:
- 首先语法分析模块
parse
(内部调用词法分析lex
)解析文件,并返回解析结果proto
; - 然后创建一个虚拟机,并执行
proto
。
这个流程跟Lua官方实现的API调用方式不一样。Lua官方实现的主要流程如下(完整示例):
lua_State *L = lua_open(); // 创建lua_State
luaL_loadfile(L, filename); // 解析,并把解析结果放在栈顶
lua_pcall(L, 0, 0, 0); // 执行栈顶
这是因为Lua官方实现是“库”,API对外只暴露lua_State
这一个数据结构,负责解析和执行两部分的功能,所以要先创建lua_State
,再以其为基础去调用解析和执行,解析结果也是通过Lua_state
的栈来传递。而我们目前没有类似的统一的状态数据结构,所以只能先分别调用解析和执行两部分的功能。
下面分别看解析和执行过程。
词法分析
虽然上面的main()
函数里是直接调用的语法分析parse
模块,但语法分析内部是调用了词法分析lex
模块。先看词法分析。
词法分析的输出是Token流。对于"hello, world!"程序,只需用到“标识print
”和“字符串"hello, world!"
”这两个Token,简单起见我们也暂时只支持这两个。另外我们还定义一个Eos
用于表示文件结束:
#[derive(Debug)]
pub enum Token {
Name(String),
String(String),
Eos,
}
我们并没有一次性把输入文件解析完毕,返回一个Token数组,而是提供一个类似迭代器的功能,以便让语法分析模块按需调用。为此先定义一个词法分析器:
#[derive(Debug)]
pub struct Lex {
input: File,
}
现在暂时只包含一个成员,即输入文件。
对外提供2个API:new()
基于输入文件创建语法分析器;next()
返回下一个Token。
impl Lex {
pub fn new(input: File) -> Self ;
pub fn next(&mut self) -> Token ;
}
具体的解析过程就是单纯的字符串处理了,代码略过。
按照Rust的惯例,这里的next()
函数的返回值应该是Option<Token>
类型,Some<Token>
表示读到新Token,None
表示文件结束。但是既然Token
本身就是一个enum
了,直接在里面加入一个Eos
似乎更方便些。而且如果改成Option<Token>
类型,那么在下一次语法分析调用的地方,也会需要多一层判断,如下代码。所以还是选择了新增Eos
类型。
loop {
if let Some(token) = lex.next() { // extra check
match token {
... // parse
}
} else {
break
}
}
语法分析
上面main()
函数中的解析结果proto
是解析和执行两个过程的中间纽带。但是鉴于Rust强大的类型机制,在上述代码中proto
并没有展示出具体的类型。现在来看其类型定义。在字节码一节中已经介绍过,解析结果需要包含2部分:字节码序列和常量表。于是可以定义解析结果的格式如下:
#[derive(Debug)]
pub struct ParseProto {
pub constants: Vec::<Value>,
pub byte_codes: Vec::<ByteCode>,
}
常量表constants
是包含Value
类型的Vec
,字节码序列byte_codes
是包含ByteCode
类型的Vec
。他们都是Vec
结构,具有相同的功能,但包含类型不一样。在古老的C语言里要包含Value
和ByteCode
这两种类型,要么针对每种类型都编写一套代码,要么就要用到宏或函数指针等复杂特性。Rust语言中的泛型就可以针对不同类型抽象出同一套逻辑。后续代码中会用到泛型的更多特性。
在定义好ParseProto
后,接下来看语法分析流程。我们目前只支持print "hello, world!"
这一个类型的语句,也就是Name String
的格式。首先从词法分析中读取Name,然后再读取字符串常量。如果不是这个格式则报错。具体代码如下:
pub fn load(input: File) -> ParseProto {
let mut constants = Vec::new();
let mut byte_codes = Vec::new();
let mut lex = Lex::new(input);
loop {
match lex.next() {
Token::Name(name) => { // `Name LiteralString` as function call
constants.push(Value::String(name));
byte_codes.push(ByteCode::GetGlobal(0, (constants.len()-1) as u8));
if let Token::String(s) = lex.next() {
constants.push(Value::String(s));
byte_codes.push(ByteCode::LoadConst(1, (constants.len()-1) as u8));
byte_codes.push(ByteCode::Call(0, 1));
} else {
panic!("expected string");
}
}
Token::Eos => break,
t => panic!("unexpected token: {t:?}"),
}
}
dbg!(&constants);
dbg!(&byte_codes);
ParseProto { constants, byte_codes }
}
输入是源文件File
,输出是刚才定义的ParseProto
。
函数主体是个循环,通过在函数开头创建的词法分析器lex
提供的next()
函数,循环读取Token。我们目前只支持一种类型的语句,即Name LiteralString
,并且语义是函数调用。所以分析逻辑也很简单:
- 遇到
Name
,认为是语句开始:- 把
Name
作为全局变量,存入常量表中; - 生成
GetGlobal
字节码,把根据名字把全局变量加载到栈上。第1个参数是目标栈索引,由于我们目前只支持函数调用语言,栈只用来函数调用,所以函数一定是在0的位置;第2个参数是全局变量名在全局变量中的索引; - 读取下一个Token,并预期是字符串常量,否则panic;
- 把字符串常量加入到常量表中;
- 生成
LoadConst
字节码,把常量加载到栈上。第1个参数是目标栈索引,排在函数的后面,为1;第2个参数是常量在常量表中的索引; - 准备好了函数和参数,就可以生成
Call
字节码,调用函数。目前2个参数,分别是函数位置和参数个数,分别固定为0和1。
- 把
- 遇到
Eos
,退出循环。 - 遇到其他Token(目前只能是
Token::String
类型),则panic。
函数后面通过dbg!
输出常量表和字节码序列,用于调试。可以跟luac
的输出做对比。
最后返回ParseProto
。
虚拟机执行
解析生成ParseProto
后,就轮到虚拟机执行了。按照之前分析,虚拟机目前需求2个组件:栈和全局变量表。所以定义虚拟机状态如下:
pub struct ExeState {
globals: HashMap<String, Value>,
stack: Vec::<Value>,
}
创建虚拟机时,需要提前在全局变量表里添加print
函数:
impl ExeState {
pub fn new() -> Self {
let mut globals = HashMap::new();
globals.insert(String::from("print"), Value::Function(lib_print));
ExeState {
globals,
stack: Vec::new(),
}
}
其中print
函数的定义如下:
// "print" function in Lua's std-lib.
// It supports only 1 argument and assumes the argument is at index:1 on stack.
fn lib_print(state: &mut ExeState) -> i32 {
println!("{:?}", state.stack[1]);
0
}
目前print
函数只支持一个参数,并假设这个参数在栈的1位置。函数功能就是打印这个参数。因为这个函数不需要向调用者返回数据,所以返回0。
完成初始化后,下面就是最核心的虚拟机执行功能,也就是字节码分发的大循环:依次读取字节码序列并执行对应的预定义功能。具体代码如下:
pub fn execute(&mut self, proto: &ParseProto) {
for code in proto.byte_codes.iter() {
match *code {
ByteCode::GetGlobal(dst, name) => {
let name = &proto.constants[name as usize];
if let Value::String(key) = name {
let v = self.globals.get(key).unwrap_or(&Value::Nil).clone();
self.set_stack(dst, v);
} else {
panic!("invalid global key: {name:?}");
}
}
ByteCode::LoadConst(dst, c) => {
let v = proto.constants[c as usize].clone();
self.set_stack(dst, v);
}
ByteCode::Call(func, _) => {
let func = &self.stack[func as usize];
if let Value::Function(f) = func {
f(self);
} else {
panic!("invalid function: {func:?}");
}
}
}
}
}
现在只支持3个字节码。每个功能都很明确,无须多言。
测试
至此,我们实现了一个完整流程的Lua解释器!看下运行效果:
$ cargo r -q -- test_lua/hello.lua
[src/parse.rs:39] &constants = [
print,
hello, world!,
]
[src/parse.rs:40] &byte_codes = [
GetGlobal(
0,
0,
),
LoadConst(
1,
1,
),
Call(
0,
),
]
hello, world!
输出分3部分。第1部分是常量表,包含2个字符串常量。第2部分是字节码,可以跟字节码一节里的luac
的输出对比下。最后1行就是我们期待的结果:hello, world!
还有个额外的功能。语法分析部分并非只支持一条语句,而是一个循环。所以我们可以支持多条print
语句,比如:
print "hello, world!"
print "hello, again..."
执行会发现有个小问题,就是在常量表里print
出现了两次。这里可以优化为,每次向常量表里添加值时可以先判断是否已经存在。在下一章处理。
总结
本章的目的是实现一个完整流程的Lua解释器,以熟悉解释器结构。为此,我们首先介绍了编译原理基础知识,然后介绍了Lua的字节码和值这两个核心概念,最后编码实现!
我们一直在强调“完整流程”,是因为后续只需要基于这个框架上,加上亿点点细节,就能完成一个“完整功能”的Lua解释器。继续前行。