Remusys-IR 是大框架简单、细节很复杂的语言。根据导览部分的分析,Remusys-IR Parser 使用递归下降语法分析器,在 IR 内存表示之外额外设计一层简单的 AST 来解决 IR 引用关系复杂、构建限制多的问题。

IR 总体架构

和 LLVM IR 一样, Remusys-IR 的架构可以分为 模块 - 全局对象/函数 - 基本块 - 指令 这四层. 下面的 IR 文本片段展示了 Remusys-IR 的基本结构说明.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
; name = hello.ll

; 全局对象: 全局常量
; 这里的 `sparse [...]` 是 Remusys-IR 自己的数组类型——稀疏数组.
@LONG_ARRAY = dso_local constant [100 x i32] sparse [
[0] = i32 1, [1] = i32 1, [2] = i32 2, [3] = i32 3,
..= i32 0
], align 16

; 全局对象: 全局变量
@COUNT = internal global i32 0, align 8

; 线程本地对象
@TLS_COUNT = internal thread_local(localexec) global i32 0, align 8

; 外部函数
declare i32 @getint()
declare void @putint(i32)

; 函数定义
define i32 @main(i32 %argc, ptr %argv) {
; 基本块 (Basic Block): 控制流结点,只有一个入口(开头)和一个出口(结尾的终止指令)。
; 函数体开头的第一个基本块叫“入口基本块”, 可以没有名字. 如果没有名字的话会自动给一个最小的、没人用的数字作为名字.
entry:
; 指令 (Instruction): 控制流内持有操作数的数据流结点.
; '%' 和 '=' 的含义: '%input_val' 就是右侧 call 指令的名称. call 指令本身同时代表了它的返回值, 二者是同一个东西.
%input_val = call i32 @getint()
%counter = alloca i32, align 4
store i32 0, ptr %counter, align 4
; 终止指令 (Terminator): 基本块末尾必须有且仅有一个终止指令, 控制流结点的出口代表, 决定控制流的出边.
br label %loop_start

; 除开入口以外的基本块必须有一个名字。这个名字可以是数字.
loop_start:
%current_counter = load i32, ptr %counter, align 4
%cond = icmp slt i32 %current_counter, %input_val
br i1 %cond, label %loop_body, label %loop_exit

loop_body:
call void @putint(i32 %current_counter)
%current_global_count = load i32, ptr @COUNT, align 8
%new_global_count = add i32 %current_global_count, 1
store i32 %new_global_count, ptr @COUNT, align 8
%new_counter = add i32 %current_counter, 1
store i32 %new_counter, ptr %counter, align 4
br label %loop_start

loop_exit:
%final_count = load i32, ptr @COUNT, align 8
call void @putint(i32 %final_count)
ret i32 0 ; 另一种终止指令: 终止整个函数的控制流
}

可以把它理解为「一棵语法树 + 两张图」:

  • 模块是容器,持有全局对象和函数。
  • 函数由若干基本块组成,基本块形成控制流图(CFG)。
  • 基本块内部是一串指令,指令与操作数组成数据流图(DFG)。

解释一下这个 IR 片段,方便我们接下来设计语法树.

模块布局

下面截取出来的是模块层级的内容:

1
2
3
4
5
6
7
8
9
10
11
12
@LONG_ARRAY = dso_local constant [100 x i32] ...; array content

@COUNT = internal global i32 0, align 8

@TLS_COUNT = internal thread_local(localexec) global i32 0, align 8

declare i32 @getint()
declare void @putint(i32)

define i32 @main(i32 %argc, ptr %argv) {
...
}

IR 的模块层级大概可以看成“符号表”这样的东西:

  • 模块持有全局对象和函数的列表, 每个全局对象和函数都有一个唯一的名字(符号)用来标识它们.
  • 全局对象包括全局变量、全局常量、线程本地存储对象等. 由于 IR 的值要求不可变,这种全局对象一般被看成内存容器,类型是 ptr.
  • 函数也是全局对象,可以是“声明”(declare)也可以是“定义”(define). 作为函数定义的函数体内部装有 IR 中最核心的部分:一张控制流图和一组数据流图.

基本块布局

下面截取出来的是函数体内的基本块:

1
2
3
4
5
6
7
8
9
10
define i32 @main(i32 %argc, ptr %argv) {
entry:
... ; 入口基本块
loop_start:
... ; 普通基本块
loop_body:
... ; 普通基本块
loop_exit:
... ; 普通基本块,但终止指令会结束整个函数的控制流
}

Remusys-IR 里的“基本块”跟汇编里的 label 看起来像,但本质不同。汇编是线性指令流,label 只是给“某条指令的地址”起名字,跳转直接指向那条指令;而在 SSA 风格的 IR 里,指令不会裸露在函数体里,必须被装进一个基本块。基本块不是地址的别名,而是一个“控制流结点”:它有且只有一个入口(块开头)和一个出口(块末尾的终止指令)。因此,所有跳转只会从块尾发出、跳到另一个块的开头。这样设计的用意是把控制流的结构显式化,便于把函数看成一张图来分析与优化,而不是在一串指令里到处找跳转目标。

块内指令流布局

下面截取出来的是基本块内的分区和布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
; 从 `loop_start` 跳转过来
loop_body:
; 没有 PHI 节点, 否则在指令区的上方应该是 PHI 节点区

; 指令区
call void @putint(i32 %current_counter)
%current_global_count = load i32, ptr @COUNT, align 8
%new_global_count = add i32 %current_global_count, 1
store i32 %new_global_count, ptr @COUNT, align 8
%new_counter = add i32 %current_counter, 1
store i32 %new_counter, ptr %counter, align 4

; 终止指令区, 有且只有一个终止指令
; 终止指令不是独立的指令, 而是基本块的一部分.
; 只不过为了方便阅读, 一起放在指令列表里了.
br label %loop_start

当然在语法分析阶段我们不需要关注这么多分区, 甚至不需要关注有没有终止指令——只要把块内的指令都 parse 出来, 后续在构建 IR 的时候再检查基本块的合法性即可.

AST 与语法分析

与以往编写的自顶向下 Parser 不同的是, 这次 Remusys-IR 采用 syn 同款的 AST 驱动解析逻辑. 可以粗略理解为两步:

  1. 先把文本解析成“结构稳定但语义尚未绑定”的 AST。
  2. 再把 AST 映射到真正的 IR 内存表示,并在这个过程中完成符号绑定、类型检查等约束。

这样做的核心原因是:IR 里存在大量引用(值、块、全局对象之间互相指),而直接构建 IR 往往需要前向声明、回填和跨对象检验。AST 层把这些构建限制外提,解析器只需要“按语法把树搭起来”,后续再集中处理引用关系。

具体来说,AST 相关的 trait 是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
pub trait AstNode: Debug {
fn get_span(&self) -> logos::Span;

fn parse(parser: &mut IRParser<'_>) -> IRParseRes<Self>;

/// 用于需要回溯解析的场景.
/// 不过在实际的 parse 过程中几乎没用到.
fn try_parse(parser: &mut IRParser<'_>) -> IRParseRes<Option<Self>>;
}

pub struct IRParser<'src> {
... // 只提供 token 缓存、行映射之类的通用功能
}

这么做主要是因为 Remusys-IR Text 的总体结构非常简单 —— 只有很少的嵌套, 指令/操作数等少数比较复杂的非终结符出现位置也很规整,语法分析器常常只要往前看 2-3 个 token 就能决定接下来要 parse 什么. 这使得 AST 驱动的解析逻辑非常清晰, 每个 AST 节点都能独立负责自己的解析逻辑, 互相之间耦合度很低.

最后给出我心里的职责边界:

  • 语法层(AST):负责“形状正确”。只解析、只记录位置与字面量,不做复杂约束。
  • IR 构造层:负责“语义正确”。处理引用绑定、类型推导/检查、块与指令的一致性。

这样写的好处是解析简单可测试,也方便以后扩展语法而不牵动 IR 构建逻辑。

模块层级的 AST

模块层级的 AST 主要负责持有全局对象和函数的列表. 由于模块内的全局对象和函数都是“平铺”的, 解析器只需要不停地尝试 parse 全局对象或函数定义即可.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// 这里选择把模块内的不同对象拆分开来, 方便后续处理.
/// parse 的基本逻辑就是: 取一个下面的 ModuleItem, 根据它的类型放到对应的 Vec 里.
#[derive(Debug)]
pub struct ModuleAst {
pub funcs: Vec<FuncAst>,
pub global_vars: Vec<GlobalVarAst>,
pub type_aliases: Vec<Arc<TypeAliasItem>>,
pub span: logos::Span,
}

#[derive(Debug)]
pub enum ModuleItem {
Func(FuncAst),
GlobalVar(GlobalVarAst),
TypeAlias(TypeAliasItem),
Finish(usize /* finish num-bytes */),
}

函数层级的 AST

Remusys-IR 要求指令必须装在基本块里, 因此函数体就是基本块列表 x 块内指令列表. 但解析逻辑要这么做的话会引发不少回溯, 因此我们在解析时把基本块打散成“指令”和“标签”混合在一起的 FuncLine, 构建 IR 时再把它们重新组合成基本块.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 结构件
pub struct BlockAst {
pub label: Ident,
pub insts: Vec<InstAst>,
}
pub struct FuncBodyAst {
pub span: logos::Span,
pub blocks: Vec<BlockAst>,
}

// 逻辑件: 这就不需要回溯了, 往前判断两个 token 就能知道是标签还是指令.
pub enum FuncLine {
Label(Ident),
Inst(Box<InstAst>),
}

impl AstNode for FuncBodyAst {
fn get_span(&self) -> logos::Span {
self.span.clone()
}

fn parse(parser: &mut IRParser<'_>) -> IRParseRes<Self> {
let begin_pos = parser.advance_exact(&[FinalToken::LBrace])?.start;
let mut func_body = FuncBodyAst {
span: begin_pos..begin_pos,
blocks: Vec::new(),
};
loop {
if parser.peek0_match(FinalToken::RBrace)?.0 {
break;
}
// 这里做新块的拆分和指令添加的逻辑
func_body.add_line(FuncLine::parse(parser)?);
}
let end_pos = parser.advance_exact(&[FinalToken::RBrace])?.end;
func_body.span.end = end_pos;
Ok(func_body)
}
}

指令和操作数层级的 AST

指令层级的 AST 设计比较常规, 每条指令对应一个 InstAst 变体, 就不展开了. 操作数层级的 AST 也类似, 每种操作数类型对应一个 Operand 变体.