BUAA 2025编译实验
编译-系列:
BUAA 2025 编译器设计说明
实现词法分析 + 递归下降语法分析 + 语义分析(符号表与约束检查)+ LLVM IR 中间代码生成 + 中端优化 + Mips 目标代码生成
1. 总体目标
该项目实现一个精简 C / SysY 风格语言的编译器五个核心阶段:
- 词法分析—— 将源代码字符流转换为 Token 序列,并进行最基本的错误分类。
- 语法分析—— 基于递归下降结构 + FIRST 集判断与有限回溯,将 Token 序列还原为抽象/混合语法树(目前更接近具体语法树 )。
- 语义分析—— 基于作用域栈构建符号表,完成重定义、未定义引用、函数调用实参与形参检查、左值可赋性、return 规则、循环控制语句使用、printf 占位符数量等检查。
- 中间代码生成和优化—— 遍历语法树生成 LLVM IR 中间代码,包含基本块管理、寄存器分配、控制流处理、优化 pass 框架等。
- 目标代码生成—— 将 LLVM IR 转换为 MIPS 汇编代码。
设计强调:
- 模块化:
frontend下按阶段与节点类型分类,midend下按 IR 生成与优化分类。 - 可扩展:BNF 规则集中于
frontend/BNF/Gram.java,IR 生成逻辑集中于midend/LLVMGenerator.java,优化 pass 可插拔式添加,Mips 生成逻辑集中于backend/MIPSGenerator.java。 - 错误恢复策略:仅在缺失分号、右括号、右中括号时报错,节点仍然添加并继续。
- 输出:词法结果
lexer.txt,语法树 + 规约序列parser.txt,符号表symbol.txt,所有阶段合并错误error.txt,LLVM IR 代码llvm_ir.txt,Mips 目标代码mips.txt。 - 近期修复:后端在
icmp/zext的布尔结果生成后立即落栈并标记为 clean,防止寄存器复用导致布尔打印/逻辑判断拿到旧值(触发场景:开启 Mem2Reg + Phi 展开 + StrengthReduction + GVN + DCE 时的not输出偏差)。
2. 目录结构与职责
1 | README.md // 本文档 |
3. 词法分析设计
3.1 处理流程
Compiler逐行读取testfile.txt,按照行存入lines列表,然后调用preprocess进行注释预处理,存入preprocessedLines列表。Lexer调用analyze方法,传入preprocessedLines列表。- 注释预处理后的非空行传递给
analyzeLine方法:- 基于单次线性扫描:
- 维护临时
StringBuilder token累积潜在标识符 / 数字 / 字符串常量。 - 每遇到:
- 双字符候选(如
<=,>=,==,!=,&&,||)优先匹配。 - 单字符分隔符 / 运算符立即截断并归约前一 Token。
"进入字符串提取直到下一个"(无转义支持,缺失闭合时报错)。
- 双字符候选(如
- 行尾人为补一空格触发最后一个 token flush。
- 维护临时
- 基于单次线性扫描:
3.2 关键数据结构
- 关键字映射
KEYWORDS:Map<String, TokenType> - 符号映射
SYMBOLS:包含单双字符运算符,提前硬编码。 - Token 分类优先级:符号 > 关键字 > 字符串常量 > 标识符 > 整数常量 > 错误。
3.3 正则与最小化判断
| 类别 | 正则 / 判定 | 说明 |
|---|---|---|
| 标识符 | [a-zA-Z_][a-zA-Z0-9_]* |
兼容下划线开头 |
| 整数常量 | \d+ |
仅十进制 |
| 字符串常量 | "[^\"]*" |
不含内部引号(无转义机制) |
3.4 错误处理策略
- 非法字符输出报错,不产生“a”错误,修改
Success为false。 - 针对孤立
|或&容错:token仍然映射为OR或AND(降低级联错误),输出错误信息“a”
3.5 设计亮点
- 使用函数
handleToken()统一 flush 逻辑,避免重复 if 栈。 - 双字符运算符通过前瞻
line.substring(i,i+2)零回溯匹配。 - 将错误与成功 token 均写入序列,以定位后续语法文法更多错误。
4. 语法分析设计
4.1 方法论
采用手写递归下降解析器,不借助自动生成器,优势:
- 代码即文法:
Gram内部类一一对应产生式,易维护。 - 通过 FIRST 集 + 适度向前看消除二义性(函数定义 vs 变量声明、Unary/Call 分支、Stmt的EXP和Lval等)。
- 运行期构建语法树节点
Unit(非终结符)与Token(终结符)混合结构,可直接用于后继语义/IR。
4.2 文法(简化 BNF 摘要)
(与代码保持一致,省略空产生式 [] 与重复 {} 的括号说明)
1 | CompUnit -> {Decl} {FuncDef} MainFuncDef |
4.3 关键消除二义性/左递归策略
| 场景 | 冲突来源 | 解决手法 | 代码位置 |
|---|---|---|---|
| Decl vs FuncDef vs MainFuncDef | int Ident ( ... 既可能是函数(主函数)定义也可能是变量声明(数组) |
预读 INTTK IDENFR LPARENT 判定 |
CompUnit.parse while 循环条件内部 |
| 赋值语句 vs 表达式语句 | Ident ... 开头既可能是 LVal 或普通EXP |
调用 LVal.parse_back 预读判断后跟 ASSIGN |
Stmt.parse 第一分支 |
| UnaryExp 分支冲突 | Ident 既可能是函数调用也可能是 LVal/PrimaryExp |
向前看后一个 token 是否 LPARENT |
UnaryExp.parse |
| 左递归(E -> E op T) | 直接写递归会无限 | 改写为迭代收集,再“回填”成左结合树 | MulExp, AddExp, RelExp, EqExp, LAndExp, LOrExp |
左结合树重构技巧
解析形如 A op B op C op D:
- 线性收集为节点序列
[A, op1, B, op2, C, op3, D]。 - 逆向折叠构造:最右
(C op3 D)作为最里层,逐步外包,保证遍历输出次序仍符合递归下降生成顺序。代码以for (int i = children.size()-1; i>0; i-=2)反向建新父节点。
4.4 FIRST 集与可选/重复部分
- 每个内部类维护
FirstToken静态列表;在构造块中组合所依赖子产生式 FIRST 集。 - 可选部分
[X]统一通过if (X.FirstToken.contains(currentType))判定。 - 重复部分
{X}使用 while,并在体内推进pos。
4.5 错误检测与恢复
expectToken(result, parent, tokens, pos, expectedType):
- 若匹配失败:
- 针对
;,),]追加错误 - 其他情况控制台输出dubug信息
- 不回退、不跳过(当前实现直接返回原 pos,后续可能导致级联,但保留树结构)。
- 针对
- 成功:将终结符挂入父节点。
对于报错i,即缺少;,对于stmt->[exp] ';',在块的最后可能缺失语句,这时仍然需要报错缺少i,需要将}纳入lval和exp的判断分支,然后进行预读回溯。
LVal.parse_back
左值和表达式的预读判断。
不实际移动pos,虚假处理一个lval,但是注意最后一个“]”不能改变temp的success判断,只做pos移动,同时语法部分success的修改只发生在除去)]}以外的情况下,如果缺少右括号不修改success。
4.6 语法树输出策略
Node.output()对非终结符采用后序遍历(children 先输出到AST列表,再输出自身<Type>,对于题目要求的<BlockItem>,<Decl>,<BType>实际添加节点,但是输出时删去,最后统一输出AST列表到文件)。
4.7 设计亮点总结
| 点 | 价值 |
|---|---|
统一 Unit 节点枚举 |
避免字符串判断,提升类型安全与可维护性 |
| 局部 FIRST 集缓存 | 减少重复分支判断逻辑,可做 LL(1) 验证扩展 |
lval预读函数 parse_back |
将模糊前缀解析与正式构建分离,降低副作用 |
| 左结合逆向折叠算法 | 保持语义正确 + 最小化栈深度 |
错误类型内聚 expectToken |
单点维护错误策略,方便扩展更多错误码 |
4.8 FIRST集列表
1 | CompUnit: [CONSTTK, INTTK, STATICTK] |
5. 语义分析设计
5.1 核心思路与职责
- 采用“按语法树语义遍历”的方式:
Symboler.analyze(root, result)入口调用Sym.CompUnit.symbolize,以与语法产生式一致的内部类分发,逐节点处理。 - 建立“作用域栈”(
SymbolResult.scopes),每进入 Block/函数体等新作用域时pushnumber(type),离开时popnumber();每个作用域拥有一个符号表节点(Symbol(SymbolTable)),编号自增并记录在符号条目的number字段;记录深度depth,以便中间代码生成使用。 symbolize()每层传入上一层type,block进入后读取,然后重置为普通类型。for对于stmt传入for类型,用于判断单句for语句。- 符号输出为“扁平列表”
symbols,但作用域查询基于栈顶至全局逐层查找,支持同名屏蔽。 - 语义检查与建表同步进行,尽量沿语法树遍历顺序定位并报告错误。
5.2 符号模型与条目类型
frontend/nodes/Symbol.SymbolType 支持:
- 变量:
Int、StaticInt - 数组:
IntArray、StaticIntArray - 常量:
ConstInt、ConstIntArray - 函数:
IntFunc、VoidFunc(含形参列表parameterlist) - 特殊:
SymbolTable(仅作为作用域内的符号表根节点)
函数符号的形参列表在函数体建表时补全:先插入函数符号(参数列表为空),解析 FuncFParams 到临时 SymbolResult,注意此时也有可能报错重定义,但是报错存储在temp,需要之后读取存入真正的result,进入函数体 Block 前将形参迁入当前作用域并回填到函数符号的 parameterlist。
内建函数:在编译单元开始时注入 getint(IntFunc,无参数),用于通过语义检查;落盘到 symbol.txt 前会移除该内建条目(仅用于检查与查询)。
5.3 作用域类型与返回值约束
Scopetype:RETURNFUNC、NORETURNFUNC、NORMALBLOCK、PARAMS、FOR。
- 仅当作用域类型为
RETURNFUNC时,进入作用域时将returned=false,return时将当前作用域修改为true(文法要求只判断结尾处的return) - 如果向上层找,
inreturn()==false且return后方有数值,报错不匹配return(错误码f)。 block结束时候判断是否为true,离开作用域若仍为false,报告缺失 return(错误码 g),报错行号定位在函数}行。MainFuncDef按返回值函数处理(RETURNFUNC),要求显式return。FOR用于标识循环体,配合infor()和上一层传入的type(单语句for) 检查break/continue的合法性。
5.4 主要检查点(与触发位置)
- 重定义(b):在当前作用域
localhasSymbol命中即报错。位置:ConstDef、VarDef、FuncDef、FuncFParam。 - 未定义/非法引用(c):
- 标识符用作变量/数组:
LVal未在任一可见作用域出现globalhasSymbol未命中则报错。 - 标识符用作函数调用:不存在或非函数同名符号时报错。
- 标识符用作变量/数组:
- 函数调用实参与形参:
- 数量不匹配(d)。
- 维度不匹配(e):仅区分“标量 vs 数组”。当前实现通过实参与形参的“是否数组”判定;实参侧以表达式原样字符串去符号表查询是否为数组,未做常量折叠/类型推导。
- return 规则:
- 无返回值函数含有带返回值的
return(f)。 - 有返回值函数末尾缺少
return(g)。
- 无返回值函数含有带返回值的
- 左值可赋性(h):赋值语句左侧为常量、函数名或仅为数组名(缺少下标)时非法。
- 循环控制语句位置(m):不在
for语义区域内使用break/continue。 - printf 校验(l):格式串中
%d的数量须与后随表达式个数一致,仅统计“%d”模式,不解析其他转义。
限制与约定:
- 数组”大小与初始化值”的表达式未进行常量折叠与语义校验,当前仅登记名称与数组属性。
- 实参数组判断采用表达式字符串级别的符号表查询,未进行更深的类型推断。
- static 变量作用域处理:
static关键字修饰的变量在语义分析阶段仍按局部变量处理,登记在当前作用域的符号表中,类型标记为StaticInt/StaticIntArray。与普通局部变量的区别在中间代码生成阶段体现:static 变量会被提升为全局变量(但仅在声明函数内可见),生成全局声明而非栈上alloca,从而保证生命周期跨函数调用。
5.5 符号表输出
symbol.txt 输出经作用域编号排序后的条目,格式为:<scopeNumber> <name> <SymbolType>。内建 getint 不输出。该文件用于辅助评测与调试。
5.6错误与日志
| 错误码 | 触发条件 | 说明 |
|---|---|---|
| a | 词法非法 token | 含未识别字符(如孤立 |、& 等) |
| i | 缺少 ; |
语句/声明结束分号缺失(语法阶段) |
| j | 缺少 ) |
括号未闭合(语法阶段) |
| k | 缺少 ] |
数组下标/形参方括号未闭合(语法阶段) |
| b | 符号重定义 | 同一作用域内重复定义变量/常量/函数(语义阶段) |
| c | 未定义引用/非法使用 | 变量或函数未定义,或以非函数实体进行函数调用(语义阶段) |
| d | 实参与形参数量不匹配 | 函数调用(语义阶段) |
| e | 实参与形参类型不匹配 | 仅区分数组与标量(语义阶段) |
| f | 无返回值函数带返回值 | return exp; 出现在 void 函数内(语义阶段) |
| g | 有返回值函数缺少 return | 函数末尾未返回(报 } 行)(语义阶段) |
| h | 非法左值赋值 | 对常量/函数名/数组名(无下标)赋值(语义阶段) |
| l | printf 占位符不匹配 | %d 数量与实参个数不一致(语义阶段) |
| m | 非循环内使用 break/continue | 语句出现在非 for 区域(语义阶段) |
| (预留) | 其他可扩展 | 如缺少 }、函数重定义细化等 |
6. 中间代码生成设计
6.1 核心思路与架构
中间代码生成阶段将语法树转换为 LLVM IR(中间表示),采用以下设计原则:
- 单遍遍历:基于语法树结构,对每个节点递归生成对应的 IR 指令
- 静态单赋值(SSA)形式:每个虚拟寄存器只赋值一次,通过寄存器计数器自动生成唯一标识
- 基本块管理:自动插入标签、维护控制流跳转
- 类型系统:支持 i32(整数)、i32(指针/数组)、i8(字符串指针)
- 优化框架:可插拔式 pass 管理器,支持后续添加各类优化
6.2 关键组件
6.2.1 ModuleBuilder(全局管理器)
职责:
- 管理全局变量声明(
globalDecls) - 字符串常量池(
stringPool+stringCount) - 常量数组与初始化值映射(
constArrays、constArraysInit) - 函数形参信息缓存(
funcParamIsArray、funcParamNumber) - 函数返回值类型映射(
funcRet)
关键方法:
addGlobalDecl(String):添加全局声明addStringConst(String):注册字符串常量并返回全局变量名(如@.str.0)addConstArray(String, ArrayList<Integer>):登记常量数组build():输出完整的 LLVM IR 模块(声明 + 全局变量 + 函数定义)
附加说明(局部 const 缓存):
- 为了支持函数内部常量(const 标量与 const 数组)在常量表达式中的求值,生成器在生成局部 const 定义时会把其常量值写入
ModuleBuilder的常量映射;键使用funcName::varName的复合形式,以区分同名的全局与局部常量。这样evalConst在遇到 LVal 时可以优先查找当前函数作用域的常量缓存,再回退到模块级全局常量缓存。该策略为低侵入实现,后续可考虑把常量缓存改为显式的 per-function map 以提升可维护性。
6.2.2 FunctionContext(函数上下文)
职责:
- 虚拟寄存器计数(
regCounter):生成%1, %2, ... - 基本块标签计数(
labelCounter):生成label1, label2, ... - 局部变量表(
localVars):变量名 →LocalVar(地址、类型、维度) - 作用域深度管理(
depth)
关键方法:
nextReg():分配新寄存器nextLabel():分配新标签addLocalVar(String, Addr, ...):登记局部变量lookupVar(String):查找变量(支持作用域穿透)enterScope()/exitScope():维护作用域深度
6.2.3 LLVMGenerator(IR 生成核心)
主要职责与设计要点:
- 目标:从前端语法树生成语义正确、风格统一且可被后端优化器(text-based passes)消费的 LLVM IR 文本。
- 原则:生成的 IR 要满足下列条件:
- 可读性:每个基本块、寄存器、全局常量都有可预测命名(便于调试与测试)。
- 可重构性:避免把语义嵌入注释或不可解析的格式,保证优化 pass 能通过文本正则或行级解析可靠工作。
- 语义等价:生成的指令序列在语义上等价于源程序(符合 SysY/C 子集语义),并遵守左到右求值约定(printf 等处明确实现)。
主要流程(简要):
- 预处理:收集全局/函数签名、常量数组与字符串常量表(ModuleBuilder)。
- 生成运行时声明:输出
declare/内建 runtime(getint,putint,putch,putstr等)。 - 遍历 AST:按产生式分发到对应
gen*方法(genCompUnit,genFuncDef,genBlock,genStmt,genExp等)。 - 每个函数:创建
FunctionContext(寄存器/标签计数器、局部变量表),生成入口块、指令与控制流,确保终结ret。 - 可选:将生成的 IR 传入
PassManager.runAll(...)进行一轮或多轮优化。 - 输出:将最终 IR 写入
llvm_ir.txt(并可同时输出模块级别声明文件)。
关键生成接口(示例):
String genCompUnit(Unit root):生成整个模块的 IR 文本。String genFuncDef(Unit funcNode, FunctionContext ctx):在给定上下文中生成函数体。Value genExp(Unit expNode, FunctionContext ctx):生成表达式并返回 Value 抽象(立即数/寄存器/全局)。Addr genLVal(Unit lvalNode, FunctionContext ctx):返回可以被load/store操作使用的地址表达式。
实现细节与注意点:
- 寄存器与标签命名使用
FunctionContext.nextReg()/nextLabel()保证在一个函数内唯一,并在模块级通过函数名前缀避免冲突。 - 对于
printf/格式化输出,采用两阶段生成:先按左到右顺序评估所有实参并保存临时寄存器,然后按格式串生成putstr/putint调用,保证有副作用的表达式按语言语义执行顺序。 - 对数组、getelementptr 的生成要保留一致的格式(例如
getelementptr [N x i32], [N x i32]* @arr, i32 0, i32 %idx),便于后续 pass 的正则匹配与模式识别。 - 在生成
alloca/load/store时保留标准缩进与行分割,优化 pass 依赖这些约定进行文本级分析(本项目中的多数学流变换基于文本匹配实现)。
局部 const 求值实现说明:
- 在函数生成阶段,
LLVMGenerator会维护一个CURRENT_FUNCTION(类型为FunctionContext)用于标记正在处理的函数;当进入函数生成时设置该变量,离开时清除。 - 当遇到局部 const 定义(
genConstDef)时,生成器会把常量值(标量或数组)写入ModuleBuilder的常量映射,并以funcName::varName作为键;同时仍会在函数上下文中为变量分配alloca并存储初始值。 常量求值函数
evalConst在解析到LVal(PrimaryExp -> LVal)时会:- 若存在
CURRENT_FUNCTION,先以funcName::name的键查找ModuleBuilder的局部常量缓存; - 若未命中,再查找模块级的全局常量缓存(
constInts/constArrays); - 若均未命中或索引越界,则返回空或按防御策略返回 0。
该实现可让局部 const 与全局 const 在常量表达式求值中正确区分并优先使用局部定义,修复了先前只能读取模块级常量的问题。
- 若存在
示例(函数片段):
1 | define i32 @add_one(i32 %x) { |
可扩展点:
- 如果将来迁移到更结构化的 IR(AST/IR 对象图),LLVMGenerator 的
gen*接口可直接输出中间数据结构,再由一个独立的后端序列化为文本;当前实现优先保证可调试性与 pass 的可工作性。
6.3 关键技术点
6.3.1 变量与数组处理
全局变量:
1
@globalVar = dso_local global i32 0
全局数组:
1
@arr = dso_local global [10 x i32] zeroinitializer
初始化值存储在
constArraysInit,生成时展开局部变量:
1
2%1 = alloca i32
store i32 %initVal, i32* %1局部数组:
1
%1 = alloca [10 x i32]
通过
getelementptr计算元素地址static 变量处理:
语义分析阶段:标记为
StaticInt/StaticIntArray,登记在函数作用域符号表中IR 生成阶段:提升为全局变量,生命周期跨函数调用
命名策略:使用函数名前缀避免冲突,如
@func_staticVar可见性:仅在声明函数内可访问(通过符号表查询控制)
初始化:
- 全局声明时初始化(与普通全局变量相同)
- 初始化表达式在首次执行时计算(需要运行时初始化则使用构造函数或入口块)
示例:
1
2
3
4
5void func() {
static int counter = 0;
counter++;
putint(counter);
}生成的 IR:
1
2
3
4
5
6
7
8
9
10@func_counter = dso_local global i32 0
define void @func() {
entry:
%1 = load i32, i32* @func_counter
%2 = add i32 %1, 1
store i32 %2, i32* @func_counter
call void @putint(i32 %2)
ret void
}优势:
- 保证变量值在函数调用间保持(持久化状态)
- 避免栈上分配,减少栈帧大小
- 符合 C 语言 static 变量语义
数组元素访问:
1
2%addr = getelementptr [10 x i32], [10 x i32]* %arr, i32 0, i32 %index
%val = load i32, i32* %addr
6.3.2 控制流处理
if-else 语句:
1
2
3
4
5
6
7
8br i1 %cond, label %then, label %else
then:
...
br label %end
else:
...
br label %end
end:for 循环:
1
2
3
4
5
6
7
8
9
10
11br label %cond
cond:
%c = ...
br i1 %c, label %body, label %end
body:
...
br label %step
step:
...
br label %cond
end:短路求值(
&&/||):实现策略:基于条件分支的惰性求值,避免不必要的计算
&&短路逻辑:- 计算左操作数,结果存入
%left - 创建三个基本块:
evalRight(计算右操作数)、shortCircuit(短路跳过)、end(合并结果) - 若
%left为 false,直接跳转到end,结果为 0(false) - 若
%left为 true,跳转到evalRight计算右操作数 - 使用 phi 节点在
end块合并两条路径的结果
- 计算左操作数,结果存入
||短路逻辑:- 计算左操作数,结果存入
%left - 若
%left为 true,直接跳转到end,结果为 1(true) - 若
%left为 false,计算右操作数 - 使用 phi 节点合并结果
- 计算左操作数,结果存入
示例(
a && b):1
2
3
4
5
6
7
8
9
10%left = <evaluate a>
%cond = icmp ne i32 %left, 0
br i1 %cond, label %evalRight, label %end
evalRight:
%right = <evaluate b>
%rcond = icmp ne i32 %right, 0
br label %end
end:
%result = phi i1 [0, %entry], [%rcond, %evalRight]
%final = zext i1 %result to i32优势:
- 避免无效计算:当
a && b中 a 为 false 时,不计算 b - 支持副作用控制:如
p != NULL && *p == 0,防止空指针解引用 - 符合 C 语言语义标准
- 避免无效计算:当
6.3.3 函数调用
参数传递:
- 标量参数:直接传值(
i32 %val) - 数组参数:传递指针(
i32* %arr) - 数组首地址通过
getelementptr提取
- 标量参数:直接传值(
返回值处理:
1
%ret = call i32 @func(i32 %arg1, i32* %arg2)
内建函数:
getint():读取整数putint(i32):输出整数putch(i32):输出字符putstr(i8*):输出字符串
6.3.4 printf 转换
将 printf 转换为 putint、putch、putstr 的组合:
解析格式串:提取
%d占位符位置分段输出:普通字符串与整数值交替输出
参数求值顺序处理:
问题:C 语言中函数参数的求值顺序是未定义的(unspecified),但 SysY 要求从左到右求值
解决方案:在生成
putint调用前,预先按顺序计算所有实参表达式,将结果存入临时寄存器具体实现:
- 第一遍遍历:按顺序计算所有实参表达式(包括可能包含函数调用的复杂表达式),存储到
ArrayList<Value> evaluatedArgs - 第二遍遍历:按格式串解析结果,依次生成
putstr和putint调用,使用预先计算好的参数值 - 确保即使实参中包含有副作用的函数调用(如
getint()),也能保证从左到右的求值顺序
- 第一遍遍历:按顺序计算所有实参表达式(包括可能包含函数调用的复杂表达式),存储到
示例说明:
1
2// 源代码
printf("%d %d\n", getint(), getint());生成的 IR(简化):
1
2
3
4
5
6
7
8
9; 第一步:按顺序求值所有实参
%arg1 = call i32 @getint() ; 第一个实参先求值
%arg2 = call i32 @getint() ; 第二个实参后求值
; 第二步:按格式串输出
call void @putint(i32 %arg1) ; 输出第一个整数
call void @putch(i32 32) ; 输出空格
call void @putint(i32 %arg2) ; 输出第二个整数
call void @putch(i32 10) ; 输出换行关键点:
- 避免了参数求值顺序不确定导致的输出错误
- 支持实参表达式中包含函数调用、数组访问等复杂操作
- 保证与 SysY 语义一致
完整示例:
1
printf("x=%d, y=%d\n", x, y);
转换为:
1
2
3
4
5call void @putstr(i8* getelementptr([3 x i8], [3 x i8]* @.str.0, i32 0, i32 0)) ; "x="
call void @putint(i32 %x)
call void @putstr(i8* getelementptr([4 x i8], [4 x i8]* @.str.1, i32 0, i32 0)) ; ", y="
call void @putint(i32 %y)
call void @putch(i32 10) ; '\n'
6.4 类型系统与值表示
Value 抽象
IMMEDIATE:立即数(如42)REGISTER:虚拟寄存器(如%1)GLOBAL:全局变量(如@arr)
Addr 抽象
- 封装变量地址(寄存器或全局符号)
- 支持
load/store操作
LocalVar
- 变量名、地址、类型、数组维度
- 数组大小信息(用于
getelementptr计算)
6.5 设计亮点
| 亮点 | 价值 |
|---|---|
| SSA 形式 | 简化数据流分析,便于后续优化 |
| 作用域深度追踪 | 支持变量遮蔽与生命周期管理 |
| 字符串常量池 | 避免重复字符串,减少代码体积 |
| 短路求值优化 | 减少不必要的计算,提升运行效率 |
| printf 智能转换 | 将格式化输出转换为高效的运行时库调用 |
| 可插拔优化框架 | 支持灵活添加各类优化 pass |
| 统一的值抽象(Value) | 简化表达式生成逻辑,支持立即数与寄存器统一处理 |
6.6 优化框架设计
本项目的中端优化采用可插拔的 pass 框架,源码位于 src/midend/opt。核心部件包括:
OptimizationPass.java:优化 pass 的接口契约。每个 pass 至少应实现String run(String ir)(对整个模块文本 IR 做变换并返回新 IR),并可使用normalizeIndent(String)做行级缩进规范化。PassManager.java:负责按OptimizationOptions中的开关构建 pass 列表,并按序执行(runAll(String ir))。在执行前以及所有 pass 执行完后各调用一次PassManager.normalizeIr(String)做统一预处理(即只在开始和结束各调用一次,而不是在每个 pass 之后):移除行内注释(保留字符串常量内的分号)、合并多余空行,并统一基本块标签与指令缩进,以避免注释或格式差异干扰基于行/正则的匹配。OptimizationOptions.java:开关配置(例如enableMem2Reg,enableInstSimplify,enableConstProp,enableStrengthReduction,enableGVN,enableLoopUnrolling,enableFunctionInlining,enableDCE,enableCFGSimplify,enableTailCallOptimization等),用于在PassManager构建时决定哪些 pass 被注入以及是否重复插入第二轮。- 若要运行并验证效果,请使用测试驱动
src/midend/opt/OptimizationTest.java,它包含若干最小复现用例和testCombined()用于做多轮/组合优化验证并打印每步差异。最近新增了针对InstSimplifyPass中 icmp/zext 链折叠的单元用例,以及针对PassManager.normalizeIr的格式化/注释移除验证用例。
主要设计原则
- 文本友好与可观测:当前 pass 以“文本 + 行级正则/模式”方式实现,便于调试与快速迭代,但对 IR 格式(缩进、空行、注释)敏感。因此约定使用
PassManager.normalizeIr(集中处理行内注释、空行与缩进)与OptimizationPass.normalizeIndent做简单规范化。 - 可插拔与配置化:通过
OptimizationOptions控制每个 pass 的启用,PassManager按顺序将所选 pass 串联在一起;新增或删除 pass 只需实现/移除对应的OptimizationPass实现并更新PassManager注册或测试中的局部注册点。 - 多轮与收敛:部分 pass(如常量传播、指令简化、强度削减、GVN)可能需要多轮交替运行才能收敛。
PassManager已在构造器中为这些 pass 预置了两轮运行的位置(第一轮与第二轮),但也支持在测试/工具中按需做“迭代直到不变点”的策略。
推荐的默认执行顺序(实现中顺序)
Pass 的实际注入顺序由 PassManager(OptimizationOptions) 构建,当前实现的经验顺序如下(亦见 PassManager 源码):
- SSA 可选转换(
SSATransformPass,llvm默认已经是SSA的,置空) Mem2RegPass(内存到寄存器提升,插入 phi 节点)PhiEliminationPass(Phi 删除,基于内存展开 phi 节点)FunctionInliningPass(函数内联)InstSimplifyPass(指令级简化、常量折叠、代数规则)ConstantPropagationPass(常量传播)StrengthReductionPass(强度削减,乘除→移位等)GVNPass(全局值编号,公共子表达式消除)LoopUnrollingPass(循环展开)- 第二轮
InstSimplify/ConstantPropagation/StrengthReduction/GVN(收敛清理) DeadCodeEliminationPass(删除不可达/无用指令)CFGSimplifyPass(控制流简化:常量分支折叠、死块删除)TailCallOptimizationPass(尾调用/尾递归转换)- 最终
DeadCodeEliminationPass(再次清理)
各 Pass 简短说明(与源码一一对应)
SSATransformPass:可选的 SSA 重写(项目内前端已做部分 SSA,运行此 pass 为可选)。Mem2RegPass:识别可提升的alloca并把栈上变量提升为寄存器(使用 phi 合并),以便减少 load/store 干扰。PhiEliminationPass:单独实现的 Phi 删除 Pass。该 Pass 将 SSA 中的phi节点通过基于内存的方式展开为普通指令序列,便于后续基于文本的优化(如InstSimplify、GVN等)更可靠地识别和处理值流。FunctionInliningPass:内联小函数(含递归检测、寄存器重命名与阈值),内联会产生更多的常量/局部化机会,需紧随清理 pass。内联实现已改进以不输出以;开头的行内注释,从而避免把调试注释混入最终 IR。InstSimplifyPass:做局部指令简化与常量折叠(例如消除无意义的 zext、把 add 0/eliminate、常量表达式直接折叠成立即数)。近期增强:识别并折叠连续的zext/icmp链(例如zext i1 -> i32后接icmp eq/ne i32, 0),将分支条件恢复为原始 i1 并在必要时交换分支目标,从而显著简化常见的逻辑序列。ConstantPropagationPass:跨指令传播常量并替换寄存器为常量立即数,常与 GVN/InstSimplify 联合提升效果。StrengthReductionPass:将高成本算术(乘法/除法)转换为低成本等价(移位/加法)当满足模式时。GVNPass:全局值编号以检测并消除基本块间的公共子表达式,复用先前计算的值。LoopUnrollingPass:对小循环做完全或部分展开以提高后续优化与并行机会,同时受展开阈值控制以避免代码爆炸。DeadCodeEliminationPass:删除无用指令与不可达代码,是收敛流程中频繁使用的清理工具。近期改进:在删除死赋值前,先在函数内部执行基于标签可达性分析的不可达基本块删除(removing unreachable blocks),从而能移除包含unreachable或从入口不可达的整块代码及其内部不再被引用的定义。CFGSimplifyPass:基于分支常量化和基本块可达性分析做控制流层面的简化。近期增强:对无显式标签的函数,也会在必要时插入保守的unreachable终结指令,保证每个函数在语法层面具有终结器。TailCallOptimizationPass:将尾递归或可转化的尾调用改写为循环形式以节省栈深度。
7. 目标代码生成设计
7.1 总体架构
目标代码生成阶段将优化后的LLVM IR转换为MIPS汇编代码。主要组件:
- MIPSGenerator.java:主生成器,负责解析IR并生成MIPS指令
- MIPSContext.java:上下文管理器,维护寄存器分配、栈偏移、符号映射等状态
7.2 自定义栈帧布局
栈帧结构
本项目采用帧指针($fp)为基准的栈帧布局,兼容MARS模拟器的调用约定:
1 | 高地址 |
设计要点
- 参数传递:
- 前4个参数通过
$a0-$a3传递 - 第5个及以上参数通过栈传递,位于$fp正偏移
- 即使参数在寄存器传递,函数入口也会将其备份到栈上(
+0($fp)到+12($fp))
- 前4个参数通过
- 临时寄存器区:
- 固定预留20个字(80字节)用于保存
$t0-$t19(即MIPS的$8-$27) - 函数调用前保存,返回后恢复
- 仅保存已使用的临时寄存器(通过
usedTempRegs跟踪)
- 固定预留20个字(80字节)用于保存
- 局部变量区:
- 所有
alloca指令分配的变量/数组 - Phi消除后产生的
%phi_slot*变量 - 动态计算栈空间大小,避免重叠
- 所有
栈帧大小计算
栈帧大小由以下部分组成,在函数解析前通过两遍扫描精确计算:
第一遍:活跃变量峰值分析
1 | // 扫描函数体,记录每个变量的定义位置和最后使用位置 |
第二遍:统计栈使用情况
1 | // 统计各类资源使用 |
最终大小计算
1 | private void calculateStackSize(FunctionInfo funcInfo) { |
示例计算
假设函数 foo(a, b, c) 有:
- 2个局部变量(8字节)
- 1个数组
[10 x i32](40字节) - 活跃变量峰值 5 个
- 调用其他函数传递 3 个参数
1 | 栈帧大小 = |
优化亮点:
- 按需分配:未使用的
alloca变量不占用栈空间。 - 精确预估:通过活跃变量分析避免过度预留 Spill 槽。
- 安全余量:预留 40 字节防止优化导致的低估。
7.3 寄存器分配与管理策略
本项目实现了基于引用计数的寄存器分配与Lazy Spill(延迟溢出)策略,并引入了 Dirty Bit(脏位)机制、Store-to-Load Forwarding等优化,旨在最大化寄存器利用率并减少内存访问。寄存器分配的核心流程由以下几个关键函数协同完成。
7.3.1 寄存器资源概览
可用寄存器池:
- 临时寄存器:
$8-$27(20个),用于变量与临时计算 - 参数寄存器:
$a0-$a3(仅用于参数传递) - 返回值寄存器:
$v0(仅用于返回值) - 上下文维护:双向映射
varToReg/regToVar,引用计数表refCountMap
7.3.2 底层寄存器分配:nextTempReg()
职责:从寄存器池中选择一个物理寄存器,基于引用计数策略优先选择”最不活跃”的寄存器。
实现逻辑:
1 | // MIPSContext.nextTempReg() |
关键点:
- 引用计数驱动:优先淘汰
refCount == 0的变量(已无后续使用) - 避免频繁 Spill:通过统计活跃度,减少热点变量被驱逐的概率
- 引用计数更新:每次
loadOperand使用变量时递减计数(见 7.3.5)
7.3.3 临时寄存器获取:getTempRegister()
职责:在需要临时寄存器时调用,处理旧变量的 Spill(如果需要)并返回可用寄存器。
实现逻辑:
1 | // MIPSGenerator.getTempRegister() |
关键点:
- Lazy Spill:仅在寄存器需要复用时 Spill,而非每次指令后
- Dirty Bit 优化:Clean 变量直接丢弃,减少 95% 的不必要
sw - Forwarding 失效:防止使用过期的 Store-to-Load 缓存
7.3.4 变量寄存器分配:allocateRegister(varName)
职责:为新生成的变量(如计算结果)分配寄存器,并建立映射关系。
实现逻辑:
1 | // MIPSGenerator.allocateRegister() |
关键点:
- 自动 Dirty 标记:新计算的结果默认为 Dirty,确保后续会被 Spill
- 映射管理:通过
context.setRegister更新双向映射表 - 防止 Forwarding 错误:新变量分配后,该寄存器的旧 forwarding 信息失效
7.3.5 操作数加载:loadOperand(operand)
职责:将操作数(常量、变量、全局符号)加载到寄存器,是寄存器分配的”消费端”。
实现逻辑:
1 | // MIPSGenerator.loadOperand() |
关键点:
- 寄存器复用:优先返回已分配的寄存器,避免重复加载
- 引用计数递减:每次使用后递减,为后续
nextTempReg()提供淘汰依据 - Clean 标记:从栈加载的变量不标记为 Dirty(栈上已有副本)
- getelementptr 语义修正:区分”地址”和”值”,防止输出地址而非元素值
7.3.6 寄存器 Spill 策略:spillAllRegisters()
职责:在基本块边界或函数调用前,将所有活跃的 Dirty 变量写回栈。
实现逻辑:
1 | // MIPSGenerator.spillAllRegisters() |
优化效果:
- Dirty Bit 过滤:仅保存修改过的变量,减少 95% 的无效
sw - 批量排序:按内存地址顺序写入,提升缓存命中率
7.3.7 基本块边界处理
触发时机:
- 进入新基本块(标签
label:) - 分支跳转前(
br/j) - 函数调用前(
call)
处理流程:
1 | // 进入新基本块 |
原因:
- 新基本块可能有多个前驱,寄存器状态不确定
- 必须通过栈传递变量值,确保语义正确
- Forwarding 缓存仅在单个基本块内有效
7.3.8 避开指定寄存器的分配:getTempRegisterAvoid(avoidReg)
场景:同一指令的两个操作数如果落在同一物理寄存器会相互覆盖,需要拿到一个“不是 avoidReg” 的寄存器。常见于交换/比较指令左、右操作数冲突时的兜底分配。
实现要点:
- 尝试两次
nextTempReg(),遇到与avoidReg相同则跳过;否则按常规流程复用/Spill 脏变量、清 forwarding。 - 如两次都撞上或没有更优选择,退回
getTempRegister(),极端情况下允许返回avoidReg以保证可用性。 - Spill 时同样仅对脏变量落栈,并在复用后清除寄存器映射与 forwarding 信息,保持 Lazy Spill 语义。
7.3.9 强制重新加载操作数:reloadOperandFresh(operand, avoidReg)
目的:当两个不同操作数被分配到同一寄存器(例如共用一条寄存器映射或立即数装载到冲突寄存器)时,强制把 operand 重新装入一个避开 avoidReg 的新寄存器,消除覆盖风险。
处理分支:
- 立即数:直接用
getTempRegisterAvoid拿新寄存器,li装载。 %var:优先从栈偏移重新加载;若有寄存器映射则复制一份;全局符号回退为la,否则默认li 0兜底。重新加载后会通过context.setRegister更新映射,使后续使用指向最新副本。@global:la到新寄存器即可。
关键收益:
- 保证二元指令左右操作数的寄存器互不覆盖,避免
sw/add等写回破坏另一操作数。 - 与
getTempRegisterAvoid协同工作,在保持 Lazy Spill 的同时,为冲突场景提供低侵入的安全修正。
7.4 内存分配与访问
7.4.1 变量访问模式
全局变量访问:
1 | la $t0, global_var # 加载地址 |
局部变量访问:
1 | lw $t0, -100($fp) # 从栈加载 |
数组元素访问:
1 | # 全局数组:array[i] |
7.4.2 getelementptr 处理优化
情况1:全局数组常量索引
1 | // 优化前(3条指令): |
情况2:局部数组常量索引
1 | // 优化前(4条指令): |
情况3:指针参数传递修正
问题:getelementptr 结果是地址,但 processLoad / processStore 需要区分”地址”和”值”。
解决方案:
1 | // processLoad 中检查 pointerOrigin |
7.5 计算指令处理
7.5 函数调用处理:processCall() 详解
函数调用是寄存器分配中最复杂的场景,涉及参数传递、寄存器保护、调用约定等多个环节。
7.5.1 processCall() 完整流程
输入:LLVM IR 指令 %result = call i32 @func(i32 %a, i32 %b, ...)
处理流程:
1 | 1. 解析调用信息 |
7.5.2 参数传递详解
参数传递约定(MIPS O32 calling convention):
- 前4个参数:通过
$a0-$a3传递 - 第5+个参数:通过栈传递(相对
$sp的正偏移)
实现代码:
1 | // 解析参数列表 |
特殊情况:指针参数修正
如果参数是 getelementptr 的结果(地址),需传递地址而非值:
1 | if (context.getPointerOrigin(param) != null) { |
7.5.3 寄存器保护策略
保护时机:在参数传递前调用 spillAllRegisters()。
保护对象:
- 仅保护 Dirty 变量(Dirty Bit 过滤)
- Clean 变量(从栈加载且未修改)直接丢弃
- 减少 95% 的不必要
sw指令
实现:
1 | // 函数调用前保存寄存器 |
为何清除映射?
- 被调用函数可能修改
$8-$27的值(临时寄存器) - 调用返回后,寄存器内容不可信,必须从栈重新加载
7.5.4 IO 函数优化
IO 函数特征:
- 函数名:
getint/putint/putch/putstr - 无需保存寄存器(系统调用不破坏
$8-$27) - 直接使用 MIPS syscall
实现:
1 | if (funcName.equals("putint")) { |
优化效果:
- IO 函数无需
spillAllRegisters(),减少大量sw指令 - 直接使用 syscall,避免
jal跳转开销
7.5.5 返回值处理
返回值约定:函数返回值存放在 $v0。
处理流程:
1 | // 调用后获取返回值 |
关键点:
allocateRegister(result)可能复用寄存器(触发 Spill)- 新结果自动标记为 Dirty,确保后续会写回栈
7.5.6 Forwarding 缓存清除
原因:函数调用可能修改内存(例如修改全局变量、数组),导致旧的 Store-to-Load 缓存失效。
实现:
1 | // 调用后清除所有 forwarding 信息 |
示例:
1 | // 调用前 |
7.6 跳转分支指令处理
7.6.1 基本块管理
进入新基本块时:
1 | if (line.endsWith(":")) { |
原因:新基本块可能有多个前驱,寄存器状态不确定,必须从栈重新加载。
7.6.2 条件分支
无条件跳转:
1 | br label %target |
7.7 指令解析健壮性(避免命名碰撞)
问题场景:后端若用 line.contains("store") / line.contains("call") 这类子串匹配来分派指令,会在指令名或寄存器名包含关键字时误判,例如 @compute_and_store 被当作 store 指令,导致调用不生成 jal。
解决方案:统一使用 extractOpcode():
1) 用正则剥离可选前缀 `%tmp = `,再取首个 token 作为 opcode。
2) 预扫描(栈大小估计)和正式分派都复用该函数,保证一致。
核心代码:
1 | private String extractOpcode(String line) { |
实践提示:凡是“识别是否为某指令”或“统计指令类型”的地方,都应依赖 opcode 而非字符串包含匹配。
条件跳转:
1 | br i1 %cond, label %true, label %false |
关键点:
- 使用独立寄存器
$t0保存条件,防止spillAllRegisters覆盖 - 分支前必须 Spill,确保目标块能正确加载变量
7.6.3 循环优化
for 循环结构:
1 | for_cond: |
优化点:
- 循环变量尽量保持在寄存器中(减少
lw/sw) for_post块内的自增操作直接用addiu
7.7 函数序言与尾声
7.7.1 函数序言(Prologue)
职责:初始化函数栈帧,保存调用上下文,准备参数访问。
1 | func: |
栈帧布局:
1 | 高地址 |
7.7.2 函数尾声(Epilogue)
职责:恢复调用者上下文,释放栈帧,返回调用点。
1 | # 1. 恢复 $ra 和 $fp |
关键点:
$ra保存返回地址,必须在jr前恢复$fp必须在$sp恢复前先恢复(因为$fp是栈帧基址)- 栈帧大小在编译时确定(见 7.2 节)
7.8 设计亮点
7.8.1 核心优化策略
| 优化技术 | 实现方式 | 效果 |
|---|---|---|
| Dirty Bit 机制 | 区分 Clean/Dirty 变量,Spill 时仅保存 Dirty | 减少 95% 的函数调用寄存器保存 |
| 引用计数分配 | 优先分配引用计数小的寄存器,减少 Spill | 减少 10-20% 的 Spill 操作 |
| Store-to-Load Forwarding | sw 后立即 lw 改为 move |
减少 5-10% 的 lw 指令 |
| Copy Propagation | 消除 la/li 后的冗余 move |
减少 15-25% 的 move 指令 |
| 立即数指令 | 使用 addiu/slti 替代 li + add/slt |
减少 15-20% 的 li 指令 |
| 常量索引优化 | 数组访问时直接计算偏移,避免 sll+addu |
减少 30-40% 的数组访问指令 |
| IO 函数快速路径 | IO 函数跳过寄存器保存/恢复 | 减少 100% 的 IO 调用开销 |
7.8.2 内存访问优化
批量 Spill:
1 | // 按栈偏移排序,利用缓存局部性 |
getelementptr 多级优化:
- 常量索引直接计算偏移(减少2-3条指令)
- 全局数组
la直接到目标寄存器(减少1条move) - 指针参数区分”地址”和”值”(修复语义错误)
7.8.3 控制流优化
基本块边界优化:
- 仅在必要时 Spill(Lazy Spill)
- 清除 forwarding 缓存防止跨块错误
- 重置寄存器映射确保语义正确
条件分支优化:
- 独立寄存器保存条件,防止 Spill 覆盖
- 短路求值生成高效的跳转序列





