Hello, Saya Lang¶
extern fn puts(s: *u8) -> i64;
pub fn main() -> i64 {
puts(c"Hello, Saya!");
0
}
我真正意义上的第一个编译型玩具语言,编译到 QBE IL。
语法和关键字与 Rust 差不太多,基于表达式。编译器内部结构参考了 Hare 语言的编译器 harec,一些数据结构设计参考了 rustc。
Saya 是圣诞节项目,想当做某种礼物送给自己,后来拖延成了跨年项目,再最后,要变成新年项目了……
总之,向您送出诚挚的圣诞祝福。
语法定义¶
Saya 的 ABNF 在:saya.abnf。
与 Rust 不同的地方是,extern 作为函数修饰符而不是块修饰符,模块也不用 mod 定义。
extern fn DrawText(text: *u8, posX: i64, posY: i64, fontSize: i64, color: Color);
顺带我发现,Rust 还没有 BNF 定义,也许是太复杂了?
解析器¶
解析器部分选择手写递归下降解析器,用了 Pratt Parsing 处理表达式优先级。
给每个运算符定义 binding power,数字越大优先级越高:
fn infix_binding_power(token: &TokenKind) -> Option<(u8, u8)> {
let bp = match token {
TokenKind::OrOr => (10, 11), // ||
TokenKind::AndAnd => (20, 21), // &&
TokenKind::Or => (30, 31), // |
TokenKind::And => (40, 41), // &
TokenKind::EqEq | TokenKind::Ne => (50, 51), // == !=
TokenKind::Lt | TokenKind::Le | TokenKind::Gt | TokenKind::Ge => (60, 61), // < <= > >=
TokenKind::Plus | TokenKind::Minus => (70, 71), // + -
TokenKind::Star | TokenKind::Slash | TokenKind::Percent => (80, 81), // * / %
_ => return None,
};
Some(bp)
}
返回的元组 (left, right) 用于处理结合性,左结合用 left < right。
fn parse_expr_bp(&mut self, min_bp: u8) -> Result<Expr, ParseError> {
// ...
if let Some((left_bp, right_bp)) = Self::infix_binding_power(op_token) {
if left_bp < min_bp {
break; // 优先级不够,停止解析为 Binary 表达式
}
let op = match op_token {
TokenKind::Plus => BinaryOp::Add,
// ...
_ => unreachable!(),
};
let span = lhs.span;
self.advance()?;
let rhs = self.parse_expr_bp(right_bp)?; // 用 right_bp 递归解析右侧
lhs = Expr {
kind: ExprKind::Binary(op, Box::new(lhs), Box::new(rhs)),
span,
};
continue;
}
}
类型检查器¶
类型检查器接收 AST,输出 HIR(High-level IR)。虽然叫 HIR,但并不像 Rust 的 HIR 那样接近机器表示,更像是经过语义分析的 AST。
HIR 不只是给 AST 节点附加类型信息,还完成了:
- 名称解析:将
Path解析为具体的Place - 常量求值:将编译期表达式折叠为字面量
- 去语法糖:
Use和StructDef被消化进类型系统
AST 和 HIR 的结构大体相似(Expr、Stmt、Block 等),主要区别在于类型信息和一些语义处理。用 Tree that Grow 模式可以让两者共享结构定义,只在类型参数上有所不同。Rust 可以用 Trait + 关联类型模拟,但实现起来泛型传染严重,不如分开定义清晰。
Tree that Grow in Rust 相关项目:
代码生成¶
QBE¶
QBE 是一个轻量级的编译器后端,一年前从 Hare 那认识的,它的中间语言长这样:
function w $add(w %a, w %b) { # Define a function add
@start
%c =w add %a, %b # Adds the 2 arguments
ret %c # Return the result
}
export function w $main() { # Main function
@start
%r =w call $add(w 1, w 1) # Call add(1, 1)
call $printf(l $fmt, ..., w %r) # Show the result
ret 0
}
data $fmt = { b "One and one make %d!\n", b 0 }
类型映射¶
Saya 类型映射到 QBE 类型:
i64,pointer-> long (l)u8,bool-> word (w) / byte(b)struct/array-> aggregate type
u8 和 bool 比较特殊:QBE 的函数参数、返回值和临时变量只能是 word 或 long,不支持 byte。所以这两个类型在大多数场景用 word,只有 load / store 指令才用 byte 来确保只读写一个字节。
聚合类型¶
标量类型(i64、bool、指针等)可以直接用 load / store 操作整个值,但聚合类型(struct、array、slice)不行——它们按地址传递,赋值时需要逐字段复制。
fn load_value(
&mut self,
qfunc: &mut qbe::Function<'static>,
addr: qbe::Value,
type_id: TypeId,
) -> GenValue {
if self.types.get(type_id).is_aggregate() {
// 聚合类型:直接返回地址,不生成 load
match addr {
qbe::Value::Temporary(name) => GenValue::Temp(name, type_id),
qbe::Value::Global(name) => GenValue::Global(name, type_id),
qbe::Value::Const(_) => unreachable!("cannot load from a constant address"),
}
} else {
// 标量类型:生成 load 指令
let load_type = self.qbe_load_type(type_id);
self.assign_to_temp(qfunc, type_id, qbe::Instr::Load(load_type, addr))
}
}
fn store_value(
&mut self,
qfunc: &mut qbe::Function<'static>,
dest_addr: qbe::Value,
src: GenValue,
type_id: TypeId,
) {
if self.types.get(type_id).is_aggregate() {
// 聚合类型:根据具体类型展开复制
self.copy_aggregate(qfunc, dest_addr, src.into(), type_id);
} else {
// 标量类型:生成 store 指令
let store_type = self.qbe_store_type(type_id);
qfunc.add_instr(qbe::Instr::Store(store_type, dest_addr, src.into()));
}
}
控制流¶
if-else 表达式需要合并两个分支的值,用了 QBE 的 phi 指令:
let zero: i64 = if true { 0 } else { 1 };
生成:
%zero =l alloc8 8
@if.0.cond
jnz 1, @if.0.then, @if.0.else
@if.0.then
jmp @if.0.end
@if.0.else
jmp @if.0.end
@if.0.end
%temp.0 =l phi @if.0.then 0, @if.0.else 1
storel %temp.0, %zero
QBE IL 生成用了 qbe-rs crate,它的 phi 只支持两个节点,但我手写 IL 测试出 QBE 是支持多 phi 节点的。
如果未来要实现 match 或 loop + 多个 break value 的话,可能要给 qbe-rs 提个 PR。不过目前够用,else if 会解析成嵌套的 if,每层只需要两个 phi 节点。
短路求值¶
&& 和 || 需要短路求值——左侧结果确定时不求值右侧。不能用简单的位运算实现,得用控制流。
let t: bool = true;
let f: bool = false;
let result: bool = t && f;
生成:
%t =l alloc4 1
storeb 1, %t
%f =l alloc4 1
storeb 0, %f
%result =l alloc4 1
%temp.0 =w loadub %t
jnz %temp.0, @land.0.rhs, @land.0.false
@land.0.rhs
%temp.1 =w loadub %f
%temp.2 =w copy %temp.1
jmp @land.0.end
@land.0.false
%temp.3 =w copy 0
jmp @land.0.end
@land.0.end
%temp.4 =w phi @land.0.rhs %temp.2, @land.0.false %temp.3
storeb %temp.4, %result
模块¶
Saya 的模块系统照抄了 Hare,use 只导入类型信息,实际链接交给链接器。
use vec2;
pub fn main() {
let a: vec::Vec2 = vec2::new(1, 2);
}
编译时用 -M 指定模块的类型定义文件:
saya main.saya -M vec2=build/vec2.td
typedef 文件(.td)由编译器用 -t 和 -N 参数生成:
saya vec2.saya -o build/vec2.ssa -t build/vec2.td -N vec2
typedef 实际就是合法的 Saya 源码,只包含公开的类型、函数签名:
pub struct vec2::Vec2 {
x: i64,
y: i64,
} // size: 16, align: 8
pub fn vec2::new(x: i64, y: i64) -> vec2::Vec2;
pub fn vec2::add(a: *vec2::Vec2, b: *vec2::Vec2) -> vec2::Vec2;
pub fn vec2::dot(a: *vec2::Vec2, b: *vec2::Vec2) -> i64;
接下来¶
自举¶
想要舒服地自举还需要一些语言特性,不想这么快。
构建工具¶
Hare 做了一个构建工具来简化和加速模块编译过程,但我想参考 nob.h 和 build.zig,至少在用户层面上使用 Saya 语言来自己完成构建工作。
Raylib Speedrun¶
完成了,或者说很早就完成了,用 Raylib 写了个贪吃蛇:github.com/13m0n4de/snake-saya。
实际上,Saya 参考 北大编译实践在线文档 的目录顺序实现了前期的功能,之后就一直以运行 Raylib 为目标。
哦……¶
写完整理参考资料时,才回想起 Saya 最开始的意义——为了做 Reflections on Trusting Trust 的实验,我准备造一个编译型语言……然后再自举……再插入后门……再……。
跳入了深不见底的兔子洞。
参考¶
- https://astexplorer.net/
- https://dontcallmedom.github.io/rfcref/abnf/web/
- https://git.sr.ht/~sircmpwn/harec/
- https://doc.rust-lang.org/beta/nightly-rustc/rustc_ast/ast/
- https://pku-minic.github.io/online-doc/#/
- https://www.reddit.com/r/Compilers/comments/11vhl8r/storing_type_information/
- https://www.microsoft.com/en-us/research/uploads/prod/2016/11/trees-that-grow.pdf
- https://github.com/garritfra/qbe-rs