-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathsearch_plus_index.json
1 lines (1 loc) · 127 KB
/
search_plus_index.json
1
{"./":{"url":"./","title":"实验简介","keywords":"","body":"MiniDecaf 编译实验 实验概述 MiniDecaf 1 是一个 C 的子集,去掉了如 include/define/多文件/struct 等特性。 这学期的编译实验要求同学通过多次“思考-实现-重新设计”从简单到相对复杂的Minidecaf语言的完整编译器, 能够把 MiniDecaf 代码编译到 RISC-V 汇编。 从而能够理解并解决编译真实的程序设计语言时遇到的问题,并能与编译的原理进行对照。 下面是 MiniDecaf 的快速排序,和 C 是一样的 int qsort(int *a, int l, int r) { int i = l; int j = r; int p = a[(l+r)/2]; while (i p) j = j - 1; if (i > j) break; int u = a[i]; a[i] = a[j]; a[j] = u; i = i + 1; j = j - 1; } if (i l) qsort(a, l, j); } 如目录所示,MiniDecaf 实验分为六大阶段,由十二个小步骤组成。 每个步骤,你的任务都是把 MiniDecaf 程序编译到 RISC-V 汇编,并能在QEMU硬件模拟器上运行。 每步做完以后,你都有一个完整能运行的编译器。 随着实验一步一步进行,MiniDecaf 语言会从简单变复杂,每步都会增加部分的语言特性。 实验的关键目标是理解和掌握编译器的设计与实现方法,并能与编译原理课程的知识互补与相互印证。 我们提供一系列的参考实现,包含 Python/Rust/Java/C++ 的。 同学遇到困难可以分析了解参考实现、也可以复用他们的代码。不论同学采用那种方式,都希望能达到实验目标。 编译器边边角角的情况很多,所以你的实现只要通过我们的测例就视为正确。 实验提交 你需要使用 git 对你的实验做版本维护,然后提交到 git.tsinghua.edu.cn。 大家在网络学堂提交帐号名后,助教给每个人会建立一个私有的仓库,作业提交到那个仓库即可。 关于 git 使用,大家也可以在网上查找资料。 每次除了实验代码,你还需要提交 实验报告,其中包括 指导书里面思考题的回答 声明你参考以及复用了谁的代码 晚交扣分规则 是: 晚交 n 天,则扣除 n/15 的分数,扣完为止。例如,晚交三天,那你得分就要折算 80%。 备注 1. 关于名字由来,往年实验叫 Decaf,所以今年就叫 MiniDecaf 了。不过事实上现在的 MiniDecaf 和原来的 Decaf 没有任何关系。 ↩ "},"docs/log.html":{"url":"docs/log.html","title":"更新日志","keywords":"","body":"更新日志 2020.08.29:讨论准备实验帮助文档的改进,开始进行实验帮助文档v0.2 2020.08.26:完成实验帮助文档v0.1,进行文档review 2020.08.19:讨论准备实验帮助文档和分工 2020.08.15:各位助教基于不同的编程语言进一步完善改进基于v2的step1-12 2020.08.05:大致确定实验方案,确定目标语言的语法规范v2,设计基于v2的step1-12 2020.08.01:开始进行基于目标语言的语法规范v1的step,部分助教基于不同编程语言完成基于v1的step1~15的大部分 2020.07.29:大致确定实验方案,不限定实现编译器的编程语言和词法/语法解析方法,确定目标语言的语法规范v1,设计基于v1的step1-15,设定汇编语言 2020.07.20:minidecaf实验准备,方案设计 "},"docs/lab0/env.html":{"url":"docs/lab0/env.html","title":"环境配置","keywords":"","body":"环境配置 必做:RISC-V 的 gcc 和 qemu 我们的编译器只生成 RISC-V 汇编,但是提供预编译的 gcc 和 qemu 模拟器。 gcc 用来把 C 编译到汇编、以及把汇编变成 RISC-V 可执行文件;qemu 用来运行 RISC-V 可执行文件。 不过我们提供的 gcc 和 qemu 只能在 Linux/Mac 下运行,Windows 的同学 可以使用 WSL,或者运行一个虚拟机。 关于 WSL / 虚拟机使用,以及 Linux 基础操作,大家可以自己在网上查找资料。 你的编译器 gcc qemu MiniDecaf 源文件 ------------> RISC-V 汇编 -----> 可执行文件 --------> 输出 这一步的环境配置指南 Windows 用户 暂不支持 Windows,请自行配置好 WSL / 虚拟机。 Linux 用户 从网络学堂下载 riscv-prebuilt.tar.gz 压缩包并解压(命令是 tar xzf riscv-prebuilt.tar.gz) 安装工具链 cp riscv-prebuilt/* /usr/ -r 在第 2. 步,你可以选择不安装到系统目录下。相应的,你需要设置环境变量: export PATH=$PATH:/path/to/riscv-prebuilt/bin,把 /path/to 替换为你的解压目录 执行下面命令测试你 gcc 和 qemu 是否成功安装 1: 创建 test.c 文件,其中写入如下内容 #include int main() { printf(\"Hello world!\\n\"); } 编译 test.c 文件,gcc 应该输出一个可执行文件 a.out。但 a.out 是 RISC-V 可执行文件,所以我们的 X86 计算机无法运行。 $ riscv64-unknown-elf-gcc -march=rv32im -mabi=ilp32 -O3 -S test.c $ ls a.out a.out $ ./a.out bash: ./a.out: cannot execute binary file: Exec format error 使用 qemu 执行 a.out $ qemu-riscv32 a.out Hello world! macOS 用户 从这里下载预编译好的 RISC-V 工具链并解压。 由于 macOS 不支持 QEMU 的用户态模式,我们使用 Spike 模拟器和一个简易内核 riscv-pk 提供用户态程序的运行环境。网络学堂上提供了我们预编译的二进制程序包 spike-pk-prebuilt-x86_64-apple-darwin.tar.gz。你也可以使用 Homebrew 安装 Spike 2: $ brew tap riscv/riscv $ brew install riscv-isa-sim (可选)设置环境变量,以便每次使用时不需要输入完整路径。 测试你 GCC 和 Spike 是否成功安装,详见RISC-V 的工具链使用。 推荐:参考实现的环境 我们强烈推荐你选择一个参考实现,并且先测试运行(见下一节)一下,为此你需要配置参考实现的环境。 现在已有如下的参考实现,请根据自己的喜好选择一个,git clone 到本地,然后按照它的 README 配置好它的环境。 Python-ANTLR 地址 https://github.com/decaf-lang/minidecaf/tree/md-dzy clone 命令:git clone [email protected]:decaf-lang/minidecaf.git -b md-dzy Rust-lalr1 地址 https://github.com/decaf-lang/minidecaf/tree/mashplant clone 命令:git clone [email protected]:decaf-lang/minidecaf.git -b mashplant Rust-manual 地址 https://github.com/decaf-lang/minidecaf/tree/md-cy clone 命令:git clone [email protected]:decaf-lang/minidecaf.git -b md-cy Java-ANTLR 地址 https://github.com/decaf-lang/minidecaf/tree/md-xxy clone 命令:git clone [email protected]:decaf-lang/minidecaf.git -b md-xxy C++-ANTLR 有两个,第一个: 地址 https://github.com/decaf-lang/minidecaf/tree/md-tsz clone 命令:git clone [email protected]:decaf-lang/minidecaf.git -b md-tsz 第二个: 地址 https://github.com/decaf-lang/minidecaf/tree/md-zj clone 命令:git clone [email protected]:decaf-lang/minidecaf.git -b md-zj C++-manual 地址 https://github.com/decaf-lang/minidecaf/tree/md-zyr clone 命令:git clone [email protected]:decaf-lang/minidecaf.git -b md-zyr 备注 1. 开头的 $ 表示接下来是一条命令,记得运行的时候去掉 $。例如,让你运行 $ echo x,那你最终敲到终端里的是 echo x(然后回车)。如果开头没有 $,那么这一行是上一条命令的输出(除非我们特别说明,这一行是你要输入的内容)。 ↩ 2. Homebrew 也提供了 riscv-pk,不过那是 64 位的,而我们预编译的是 32 位的。 ↩ "},"docs/lab0/testing.html":{"url":"docs/lab0/testing.html","title":"运行测试样例","keywords":"","body":"运行测试样例 测试相关的文件在 minidecaf-tests 里面,其中 examples/ 是各个步骤的输入输出,测试脚本是 check.sh。 测试的运行步骤 如下 用 git clone 把 minidecaf-tests 和一个参考实现克隆到同一个目录下面。 进入 minidecaf-tests/,修改 check.sh 的 gen_asm,根据你选择的参考代码反注释某条命令 [可选] sudo apt install parallel 安装 parallel 以便并行测试,测试时间可缩短百分之七八十 [可选] 修改 check.sh 里面的 JOBS,控制要运行哪些测试点 运行 ./check.sh 即可。 测试运行的 输出结果 如下,OK 表示通过,FAIL 表示输出不对,ERR 表示编译错误。 $ ./check.sh gcc found qemu found parallel found OK testcases/step1/multi_digit.c OK testcases/step1/newlines.c ...... 其他测试点,太长省略 OK testcases/step12/matmul.c OK testcases/step12/quicksort.c "},"docs/lab0/riscv.html":{"url":"docs/lab0/riscv.html","title":"RISC-V 的工具链使用","keywords":"","body":"RISC-V 相关信息 RISC-V 是一个 RISC 指令集架构,你实现的编译器要编译到 RISC-V 汇编。 指令集文档在这里,我们只需要其中的 \"Unprivileged Spec\"。 RISC-V 工具使用 我们提供预先编译好的 RISC-V 工具,在环境配置中已经叙述了安装和使用方法。 下面汇总一下。 注意,我们虽然是用的工具前缀是 riscv64, 但我们加上参数 -march=rv32im -mabi=ilp32 以后就能编译到 32 位汇编 1。 使用时记得加这个参数,否则默认编译到 64 位汇编。 gcc 编译 input.c 到汇编 input.s,最高优化等级 # input.c 的内容 $ cat input.c int main(){return 233;} # 编译到 input.s $ riscv64-unknown-elf-gcc -march=rv32im -mabi=ilp32 -O3 -S input.c # gcc 的编译结果 $ cat input.s .file \"input.c\" .option nopic .attribute arch, \"rv32i2p0_m2p0\" .attribute unaligned_access, 0 .attribute stack_align, 16 .text .section .text.startup,\"ax\",@progbits .align 2 .globl main .type main, @function main: li a0,233 ret .size main, .-main .ident \"GCC: (SiFive GCC 8.3.0-2020.04.0) 8.3.0\" gcc 编译 input.s 到可执行文件 a.out # input.s 的内容,就是上面汇编输出的简化版本 $ cat input.s .text .globl main main: li a0,233 ret # 编译到 a.out $ riscv64-unknown-elf-gcc -march=rv32im -mabi=ilp32 input.s # 输出结果,能看到是 32 位的 RISC-V 可执行文件 $ file a.out a.out: ELF 32-bit LSB executable, UCB RISC-V, version 1 (SYSV), statically linked, not stripped *【Linux 用户】qemu 运行 a.out,获取返回码 # 运行 a.out $ qemu-riscv32 a.out # $? 是 qemu 的返回码,也就是我们 main 所 return 的那个值 $ echo $? 233 *【macOS 用户】Spike 模拟器运行 a.out,获取返回码 # 运行 a.out $ spike --isa=RV32G /path/to/pk a.out bbl loader # $? 是 spike 的返回码,也就是我们 main 所 return 的那个值 $ echo $? 233 1. 这里的 rv32im 表示使用 RV32I 基本指令集,并包含 M 扩展(乘除法)。本实验中我们不需要其他扩展。 ↩ "},"docs/lab1/part1.html":{"url":"docs/lab1/part1.html","title":"从零开始的 lexer、parser 以及汇编生成","keywords":"","body":"实验指导 step1:词法分析、语法分析、目标代码生成 第一个步骤中,MiniDecaf 语言的程序就只有 main 函数,其中只有一条语句,是一条 return 语句,并且只返回一个整数(非负常量),如 int main() { return 233; }。 第一个步骤,我们的任务是把这样的程序翻译到汇编代码。 不过,比起完成这个任务,更重要的是你能 知道编译器包含哪些阶段,并且搭建起后续开发的框架 了解一大堆基本概念、包括 词法分析、语法分析、语法树、栈式机模型、中间表示 学会开发中使用的工具和设计模式,包括 gcc/qemu、词法语法分析工具 和 Visitor 模式* 词法分析 读内容 *词法分析* MiniDecaf 源文件 --------> 字节流 ----------> Tokens --> ...... --> RISC-V 汇编 词法分析(lexical analysis) 是我们编译器的第一个阶段,实现词法分析的代码称为 lexer , 也有人叫 scanner 或者 tokenizer。 它的输入 是源程序的字节流 如 \"\\x69\\x6e\\x74\\x20\\x6d\\x61\\x69\\x6e\\x28\\x29\\x7b\\x72\\x65\\x74\\x75\\x72\\x6e\\x20\\x30\\x3b\\x7d\"。 上面的其实就是 \"int main(){return 0;}\"。 它的输出 是一系列 词(token) 组成的流(token stream)1 上面的输入,经过 lexer 以后输出如 [关键字(int),空白、标识符(main),左括号,右括号,左花括号,关键字(return),空白、整数(0),分号,右花括号]。 如果没有词法分析,编译器看到源代码中的一个字符 '0',都不知道它是一个整数的一部分、还是一个标识符的一部分,那就没法继续编译了。 为了让 lexer 完成把字节流变成 token 流的工作,我们需要告诉它 有哪几种 token 如上,我们有:关键字,标识符,整数,空白,分号,左右括号花括号这几种 token token 种类 和 token 是不一样的,例如 Integer(0) 和 Integer(222) 不是一个 token,但都是一种 token:整数 token。 对于每种 token,它能由哪些字节串构成 例如,“整数 token” 的字节串一定是 “包含一个或多个 '0' 到 '9' 之间的字节串”。 不过这没考虑负数,后面 “语义检查” 会继续讨论。 词法分析的正经算法会在理论课里讲解,但我们可以用暴力算法实现一个 lexer。 例如我们实现了一个 minilexer(代码)当中, 用一个包含所有 token 种类的列表告诉 lexer 有哪几种 token(上面第 1. 点), 对每种 token 用正则表达式描述它能被那些字节串构成(上面第 2. 点)。 细化到代码,Lexer 的构造函数的参数就包含了所有 token 种类。 例如其中的 TokenType(\"Integer\", f\"{digitChar}+\", ...) 就定义了 Integer 这种 token, 并且要求每个 Integer token 的字符串要能匹配正则表达式 [0-9]+,和上面第 2. 点一样。 你可尝试运行 minilexer,运行结果如下(我们忽略了空白) $ python3 minilexer.py token kind text Int int Identifier main Lparen ( Rparen ) Lbrace { Return return Integer 123 Semicolon ; Rbrace } 本质上,token 是上下文无关语法的终结符,词法分析就是把一个字节串转换成上下文无关语法的 终结符串 的过程。 不过 token 比单纯的终结符多一个属性,就是它的字符串(如 Identifier(main) 的 main),你可以说 token 是有标注的终结符。 语法分析 词法分析 *语法分析* 字节流 ----------> Tokens ----------> 语法树 --> ...... --> RISC-V 汇编 语法分析(syntax analysis) 是紧接着词法分析的第二个阶段,实现语法分析的代码称为 parser 。 它的输入 是 token 流 就是 lexer 的输出,例子上面有 如果输入没有语法错误,那么 它的输出 是一棵 语法树(syntax tree) 比如上面的程序的语法树类似 编译原理的语法树就类似自动机的 语法分析树,不同的是语法树不必表示出实际语法中的全部细节。 例如上图中,几个表示括号的结点在语法树中是可以省略的。 语法分析在词法分析的基础上,又把程序的语法结构展现出来。 有了语法分析,我们才知道了一个 Integer(0) token 到底是 return 的参数、if 的条件还是参与二元运算。 为了完成语法分析,肯定要描述程序语言的语法,我们使用 上下文无关语法 描述 MiniDecaf。 就这一步来说,MiniDecaf 的语法很简单,产生式大致如下,起始符号是 program。 program : function function : type Identifier Lparen Rparen Lbrace statement Rbrace type : Int statement : Return expression Semicolon expression : Integer 一些记号的区别: 形式语言与自动机课上,我们用大写字母表示非终结符,小写字母表示终结符。 这里正好相反,大写字母开头的是终结符,小写字母开头的是非终结符。 并且我们用 : 而不是 -> 隔开产生式左右两边。 同样的,语法分析的正经算法会在课上讲到。 但我们实现了一个暴力算法 miniparser(代码)。 这个暴力算法不是通用的算法,但它足以解析上述语法。 你可尝试运行,运行结果如下(下面输出就是语法树的先序遍历) $ python3 miniparser.py program(function(type(Int), Identifier(main), Lparen, Rparen, Lbrace, statement(Return, expression(Integer(123)), Semicolon), Rbrace)) 前面提到,语法树可以不像语法分析树那样严格。 如果语法树里面抽象掉了程序的部分语法结构,仅保留底下的逻辑结构,那样的语法树可以称为 抽象语法树(AST, abstract syntax tree);而和语法完全对应的树称为 具体语法树。 当然,AST 和语法树的概念没有清楚的界限,它们也常常混用,不必扣概念字眼。 上面 miniparser 的输出就是一棵具体语法树,而它的抽象语法树可能长成下面这样(取决于设计) $ python3 miniparser-ast.py # 假设有个好心人写了 miniparser-ast.py Prog(funcs=[ Func(name=\"main\", type=(\"int\", []), body=[ ReturnStmt(value=Integer(123)) ]) ]) 语义检查 有时我们会用 语法检查 这个词,因为语法分析能发现输入程序的语法错误。 对应语法检查,还有一个词叫 语义检查。 它检查源程序是否满足 语义规范,是否有 语义错误,例如类型错误、使用未定义变量、重复定义等等。 就 step1 来说,我们的语义规范如下。显然,我们要检查的就只是 Integer 字面量没有越界。 1.1. MiniDecaf 的 int 类型具体指 32 位有符号整数类型,范围 [-2147483648, 2147483647],补码表示。 1.2. 编译器应当只接受 [0, 2147483647] 范围内的整数(step2 会添加负数支持)。 如果整数超过此范围,编译器应当报错。 1.3. 因为只有一个函数,故函数名必须是 main。 完整的语义规范应包含如下几点。指导书只会包含关键点,避免叙述太冗长。 什么样的代码是 不合法 的。对于不合法的代码,编译器必须报错而不是生成汇编。 例如 step1 中,如果程序中 int 字面量超过上面的范围,那编译器就应该报错 \"int too large\"。 如果函数名不是 main,也应该报错。 合法程序中,每个操作的行为应该是什么样的。 例如 return 执行结果是:对操作数求值并作为返回值,然后终止当前函数执行、返回 caller 或完成程序执行。 对于合法程序,你生成的汇编须和 gcc 生成的汇编运行结果一致。 什么样的行为是 未定义 的。如果代码在运行时展现未定义行为,编译器不用报错,但它后果是不确定的。 例如有符号整数溢出、数组越界、除以零都是未定义行为。 测例代码不会有未定义行为,不必费心考虑。 语义检查的实现方式很灵活,可以实现成单独的一个阶段,也可以嵌在其他阶段里面。 第一种方式在后面的实验中有,但就 step1 而言,检查 int 范围的工作直接放进 parser 或 lexer 中就行了。 例如我们就把他放到了下一个阶段:目标代码生成里。 目标代码生成 词法分析 语法分析 *目标代码生成* 字节流 ----------> Tokens ----------> 语法树 ----------------> RISC-V 汇编 生成 AST 以后,我们就能够生成汇编了,所以 目标代码生成(target code emission) 是第三也是最后一个步骤,这里目标代码就指 RISC-V 汇编。 它的输入 是一棵 AST 它的输出 是汇编代码 这一步中,为了生成代码,我们只需要 遍历 AST,找到 return 语句对应的 stmt 结点,然后取得 return 的值, 设为 X 2 [可选] 语义检查,若 X 不在 [-2147483648, 2147483647] 中则报错;并且检查函数名是否是 main。 打印一个返回 X 的汇编程序 针对第 1. 点,我们使用一个 Visitor 模式来完成 AST 的遍历。 同样,我们有一个 minivisitor(代码)作为这个阶段的例子。 Visitor 模式比简单的递归函数更强大,用它可以让以后的步骤更方便。 Visitor 模式速成请看 这里 针对第 2. 点,我们用 (RISC-V) gcc 编译一个 int main(){return 233;} 就能知道这个汇编程序什么样。 gcc 的输出可以简化,去掉一些不必要的汇编指令以后,这个汇编程序长成下面这样。 编译方法请看 工具链使用。 汇编代码中,li 加载常数 X 到 a0 寄存器。RISC-V 约定 a0 保存返回值,之后 ret 就完成了 return X 的工作。 .text .globl main main: li a0,X ret 运行 minivisitor,输出就是模板中的 X 被替换为了一个具体整数 $ python minivisitor.py .text .globl main main: li a0,123 ret 至此,我们的编译器就完成了,它由三个阶段构成:词法分析、语法分析、目标代码生成。 每个阶段都有自己的任务,并且阶段和阶段之间的接口很明确:字节流、token 流、AST、汇编代码。 任务 在不同输入上,运行 minilexer, miniparser 和 minivisitor。 浏览它们的代码(不用完全看懂) 思考题 以下思考题六选四,在实验报告中回答。 minilexer 是如何使得 int 被分析成一个关键字而非一个标识符的? 修改 minilexer 的输入(lexer.setInput 的参数),使得 lex 报错,给出一个简短的例子。 miniparser 的算法,只有当语法满足什么条件时才能使用? 修改 minilexer 的输入,使得 lex 不报错但 parser 报错,给出一个简短的例子。 一种暴力算法是,只做 lex,然后在 token 流里面寻找连续的 Return,Integer 和 Semicolon,找到以后取得 Integer 的常量 a,然后类似上面目标代码生成。这个暴力算法有什么问题? 除了我们的暴力 miniparser,形式语言与自动机课中也描述了一种算法,可以用来计算语法分析树。请问它是什么算法,时间复杂度是多少? 总结 本节引入了很多概念,请仔细消化 Lexer Token Parser 抽象语法树 语义检查 目标代码生成 Visitor 备注 1. 之所以说“流”而不是“列表”,是因为不一定 lexer 一下就把所有的 token 都拿出来,还可以按照后续阶段的需要按需返回 token。 ↩ 2. 当然,就第一个步骤来说,你直接找到 Integer 节点也可以 ↩ "},"docs/lab1/part2.html":{"url":"docs/lab1/part2.html","title":"词法语法分析工具","keywords":"","body":"实验指导 step1:词法语法分析工具 第一部分中,我们已经自己从零开始暴力实现了一个编译器,接下来我们就来改进它。 第一个方向是: 使用工具完成词法语法分析,而不是自己手写 。 当然你可以自己写 lexer 和 parser,但你就需要理解 lexer 和 parser 的算法,并且代码量更大。 请直接看手写 lexer 和 parser。 工具概述 从 minilexer/miniparser 的代码可以看出,lexer 和 parser 包含两部分: 被分析的词法/语法的描述。例如 minilexer 的那个 TokenType 列表,以及 miniparser 的 rules 字符串; lexer 和 parser 的驱动代码。例如 lex 和 parse 函数。 使用工具,我们只需要完成第一步,描述被分析的词法或者语法。 然后工具从我们的描述,自动生成 lexer 或者 parser 供你使用,十分方便。 所以这类工具被称为 lexer/parser generator,例子有:C 的 lex/yacc、往届使用的 JFlex / Jacc、mashplant 助教自己写的 lalr1。 对有兴趣的同学:除了这类工具以外,还有一类工具称为 parser combinator,多在函数式语言中使用。 最有名的如 Haskell 的 parsec、scala 的 fastparse,rust 的 nom。课程不涉及其中内容。 下面是助教写的一些工具的速成介绍,你可从中选择一个学习使用, 你也可以自己另找其他工具自学使用。 ANTLR ANTLR 是一个比较易用的 parser generator,速成文档在这里。 LALR1 TODO:如果较长,写在单独的文档里面 手写 lexer 和 parser TODO:如果较长,写在单独的文档里面 任务 如果你选择使用工具:按照你选择的工具,描述 step1 的 MiniDecaf 词法语法,并从 AST 生成汇编。 如果你不选择使用工具:实现你自己的 lexer 和 parser,并生成汇编。 "},"docs/lab1/part3.html":{"url":"docs/lab1/part3.html","title":"使用中间码","keywords":"","body":"实验指导 step1:使用中间码 我们继续改进上一步我们得到的编译器,这次要做的是: 使用中间码让编译器更模块化。 栈机器和中间表示 词法分析 语法分析 IR生成 目标代码生成 字节流 ----------> Tokens ----------> 语法树 --------> *IR* --------------> RISC-V 汇编 中间表示(也称中间代码,intermediate representation / IR)是位于语法树和汇编之间的一种程序表示。 它不像语法树一样保留了那么多源程序的结构,也不至于像汇编一样底层。 我们的实验中,使用简单的栈式机 IR,这里是它的一个详细描述。 容易看出,IR 的好处有如下几点 缩小调试范围,通过把 AST 到汇编的步骤一分为二。 通过观察 IR 是否正确生成就能知道:到底是 IR 生成这一小步有问题,还是 IR 到汇编这一小步有问题。 比起 AST 到汇编当成一整个大步骤,分成两个小步,每步代码更少,更容易调试。 更容易适配不同指令集(RISC-V, x86, MIPS, ARM...)和源语言(MiniDecaf, Decaf, C, Java...)。 不同源语言的 AST 不同,直接从 AST 生成汇编的话,为了支持 N 个源语言和 M 个目标指令集,需要写 N * M 个目标代码生成模块: 如果有了 IR,只需要写 N 个 IR 生成和 M 个汇编生成,一共 N + M 个模块: 我们使用栈式机 IR 因为它很简单,IR 生成很简单、翻译到汇编也很简单。 当然,就课程实验来说,你不一定非要显式地生成 IR,可以直接从 AST 生成汇编。 但只要按照实验指导书的思路,你一定会使用栈机器的思路思考,“眼前无 IR 心中有栈式机”。 所以指导书中,会把 IR 单独拿出来,不会直接讨论 AST 如何翻译到汇编。 从 AST 到 IR 词法分析 语法分析 *IR生成* 目标代码生成 字节流 ----------> Tokens ----------> 语法树 ---------> IR ---------------> RISC-V 汇编 显然,这一步的 输入 是 AST, 输出 是一个 IR 1 序列。 例如前面的 int main(){return 0;} 例子,输出如 [const 0, ret] 每步我们只介绍必须的 IR,而不是一股脑全整完。 对于第一步,我们只需要两个 IR 指令:const、ret,如下表。 指令 参数 含义 IR 栈大小变化2 const 一个整数常数 把一个常数压入栈中 增加 1 ret 无参数 弹出栈顶元素,将其作为返回值返回当前函数 减少 1 并且我们有如下的假设: 考虑源代码中某个表达式被翻译成了一系列 IR 指令,那么从任何初始状态出发执行这些 IR 指令, 完成后 IR 栈大小增加 1,栈顶就是表达式的值。 执行任何 n 元操作之前,栈顶的 n 个元素就是操作数。 n 元操作将这 n 个元素弹出,进行操作,再把结果压回栈中。 由此,step1 中 AST 翻译到 IR 就很简单了。只需要 Visitor 遍历 AST,然后 遇到 Integer(X):生成一条 const X,栈大小加 1。 遇到 Return expr ;:先生成 expr 对应的 IR,栈大小加 1;然后生成一条 ret,栈大小和原来相同。 IR 翻译到汇编 词法分析 语法分析 IR生成 *目标代码生成* 字节流 ----------> Tokens ----------> 语法树 ---------> IR ---------------> RISC-V 汇编 栈机器 IR 翻译到汇编非常简单,如下表,多条汇编指令用分号隔开: IR 汇编 push X addi sp, sp, -4 ; li t1, t1, X ; sw t1, 0(sp) ret lw a0, 0(sp) ; addi sp, sp, 4 ; jr ra 简要解释:li t1 X 表示加载立即数 X 到寄存器 t1;RISC-V 和 x86 一样栈顶比栈底的地址低,所以压栈 4 字节是栈指针 sp 减 4。 (RISC-V 的栈顶指针是 sp,类似 MIPS 的 $sp 和 x86 的 %esp 或 %rsp。) a0 存放返回值,ra 存了调用者地址,jr ra 就是子函数返回。 t1 没有什么特殊的含义,你可以换成 t0 / t2 / s1 等。 但不要用 s0,因为它保存了栈帧基地址(同 %ebp 和 $fp),后面要用到。 IR 栈的每个元素都是 32 位整数,所以 push 使得 IR 栈大小加 1 在我们这里就体现为 sp 减 4。 完成后,你对于 int main(){return 0;} 应该生成如下汇编 .text .globl main main: addi sp, sp, -4 li t1, 233 sw t1, 0(sp) lw a0, 0(sp) addi sp, sp, 4 jr ra 任务 (可选,推荐)改进你上一步的代码,先生成 IR,再从 IR 生成汇编,通过测例。 (和 1. 二选一)改进你上一步的代码,不显示生成 IR,但使用栈机器的思路生成汇编,通过测例。 思考题 ANTLR 栈机器 总结 备注 1. 实际上 const 和 ret 是 IR 的 指令。我们为了简便,有时直接用 IR 代指 IR 指令。 ↩ 2. 注意区分 IR 栈和汇编中的栈。IR 的栈中包含的元素是整数,IR 栈的大小指栈中有多少个整数。对于 IR 的栈不存在“字节”这一概念。 ↩ "},"docs/lab1/antlr.html":{"url":"docs/lab1/antlr.html","title":"ANTLR 使用","keywords":"","body":"ANTLR 使用——以表达式语法为例 使用 ANTLR 工具,我们只需要写出词法和语法分析的 规范(specification), 然后它会帮我们生成 lexer 和 parser 乃至 visitor,非常方便。 我们用一个简单的表达式语法 1 来介绍 ANTLR,表达式由一系列整数通过加减乘除以及括号构成,例如 (1+3)*4-3-3。 对于 ANTLR,词法和语法分析的规范都写在 .g4 2 文件中,例如我们的表达式的规范是文法: ExprLex.g4和语法: Expr.g4。 无论是词法规范还是语法规范,它们的规范文件结构是一样的,如下。 规范文件中,// 表示注释,规范是大小写敏感的,字符串常量用单引号括起。 开头声明 规范名,需要和文件名一致: // [ExprLex.g4] 词法规范,用 lexer grammar 标识,行尾有分号。 lexer grammar ExprLex; // [Expr.g4] 语法规范,用 grammar 标识,行尾有分号。 grammar Expr; 然后可能有一些 规范自身的设置,见后面 “语法规范” 然后是 一系列规则,规则类似上下文无关语法的产生式。 每条规则包含左右两边,用冒号隔开, 左边 是一个符号,可以由 右边 规约而来。 符号分为 终结符 和 非终结符 ,终结符用大写字母打头,非终结符用小写字母。 类似产生式,如果多条规则的左边相同,它们可以合并写在一起,它们的右手边用竖线隔开。 // [ExprLex.g4] 词法规则,规则末尾有分号。 Integer: [0-9]+; // [Expr.g4] 语法规则,规则末尾有分号 atom : '(' expr ')' // 一个括号括起来的表达式,它可以规约到 atom | Integer // 整数 token 可以规约到 atom ; 词法规范 词法规范描述了 lexer 应该怎么生成,显然词法规范中规则的左边只能是终结符。 除了上面所说的,词法规范还有一点是:规则的右手边是一个正则表达式。 详细用法在这里,一些常见用法如下: // 1. 为了匹配字符串常量,用单引号把它括起来 Lparen: '('; // 2. [0-9] 匹配 (char)'0' 到 (char)'9' 之间任何一个字符,类似其他 regex 的 \\d 或者 [[:digit:]] // 3. 加号 + 表示它前面的片段可以匹配一次或多次,类似有 * 的零次或多次,? 的零次或一次。 // 它们都是贪婪的,会匹配尽量多的次数。和其他 regex 一样,片段可以用 ( ) 分组。 Integer: [0-9]+; // 4. fragment 表示 WhitespaceChar 本身不是一个符号,它只是一个 regex 的片段,lexer 不会产生它的 token。 // 它和 minilexer 中的 whitespaceChar 是一样的。 // 5. [ \\t\\n\\r] 匹配一个空格 (ascii 码 0x20),或者一个制表符 (0x9),或者一个换行符 (0xa) 或者一个回车 (0xd) fragment WhitespaceChar: [ \\t\\n\\r]; // 6. Whitespace 匹配输入中的空白。类似 minilexer,\"-> skip\" 表示忽略此终结符,也就是匹配以后不产生对应的 token。 Whitespace: WhitespaceChar+ -> skip; 语法规范 语法规范描述了 parser 应该怎么生成。除了上面说的,还需注意: parser 依赖于 lexer,所以语法规范中需要 导入词法规范 // 导入词法规范 import ExprLex; 其实 ANTLR 不要求你分开 lexer 和 parser,你可以直接把 import 语句换成 ExprLex 里面的所有规则, 效果是一样的。 但分开 lexer 和 parser 更干净,并且也方便 lexer 复用。 各种语言虽然语法差别很大,词法(空白、整数、标识符、标点符号等)却没太大差别。 parser 规则的右手边除了符号以外,还可以有 字符串常量。 如果它能被规约到词法规范里某个符号,那它就等价于那个符号; 否则 ANTLR 内部会生成一个临时终结符 T__xxx,它的规则的右边是那个字符串常量。 mulOp : '*' | '/' ; // 等价于 mulOp : Mul | Div ; 你可以手动给 规则命名。 在生成的 AST 里,atom 对应的结点会被分为两类:atomParen 和 atomInteger, 它们拥有的字段不同,也对应不同的 visit 函数。 atom : '(' expr ')' # atomParen | Integer # atomInteger ; 规则其实是用 EBNF (extended Barkus-Naur form) 记号书写的,EBNF 也是描述上下文无关语法的一种方式。 相对普通的上下文无关语法记号,EBNF 允许你在规则内部使用 | 描述选择、* 或 ? 或 + 描述重复,(和) 分组 3。 例如下面的用法: add // 1. 使用括号分组,分组内部使用 | 描述选择 // 2. 和 EBNF 无关,但 op 是给这个符号的命名,然后 add 的 AST 结点会有一个 op 字段。 : add op=(Add|Sub) mul | mul ; mul // 3. 使用 * 描述零次或多次的重复。+ 和 ? 类似。 : atom (mul atom)* ; 关于 EBNF,再举一个例子:描述零个或多个用逗号隔开的 expr 列表,下面两种写法是等价的,但 EBNF 记号更简短。 // 传统写法 exprList : # emptyExprList | exprList2 # nonemptyExprList ; exprList2 : expr | expr ',' exprList2 ; // EBNF 写法 exprList : (expr (',' expr)*)? ; 运行 ANTLR 安装 ANTLR,设置 CLASSPATH 环境变量,配置 antlr4 和 grun 的 alias 后,运行以下命令 4: $ antlr4 Expr.g4 # 会自动拉取 import 的 ExprLex.g4 $ ls ExprLexer.java ExprParser.java # 默认生成 Java 的 lexer 和 parser,其他文件不用管 ExprLexer.java ExprParser.java $ javac *.java $ echo \"(1+3)*4-3-3\" > input # 输入文件内容是 (1+3)*4-3-3 $ grun Expr expr -gui input # 输出如下图 你可以尝试把最后一步的 -gui 换成 -tokens、-tree 看看。 接下来,我们给出示例代码,叙述如何使用生成的 lexer 和 parser。 Main.java 是 Java 的示例代码。做完上面步骤后,运行 Main: $ java Main main.py 是 Python 的示例代码。为了运行它,除了安装 ANTLR 你还需要安装 Python 的 ANTLR API,见这里。运行方法如下 $ antlr4 Expr.g4 -Dlanguage=Python3 $ ls ExprParser.py ExprLexer.py # 生成了 Python 的 lexer 和 parser ExprLexer.py ExprParser.py $ python3 main.py (expr (add (add (add (mul (atom ( (expr (add (add (mul (atom 1))) + (mul (atom 3)))) )) (mulOp *) (atom 4))) - (mul (atom 3))) - (mul (atom 3)))) Visitor 的使用 ANTLR 默认生成 listener,它允许你在遍历 AST 过程进入结点和离开结点的时候运行一些代码,但我们不用 listener,我们使用 visitor。 首先用参数 -visitor 告诉 ANTLR 生成 visitor 代码。 $ antlr4 Expr.g4 -visitor visitor 代码在 ExprVisitor.java 和 ExprBaseVisitor.java 中。 前者定义接口,后者是默认实现:只遍历、不做其他事。 public class ExprBaseVisitor extends AbstractParseTreeVisitor implements ExprVisitor { @Override public T visitExpr(ExprParser.ExprContext ctx) { return visitChildren(ctx); } // ... } 从上可以看出,ANTLR 的 visitor 和我们的基本一致: visit 函数返回值的类型是 T 他所谓 context 就是 AST 的结点,每个 context 也有一个 accept 函数接受 visitor 但他的 visitor 还自带一个方法 visitChildren:遍历所有子结点。返回最后一个子结点的返回值。 ANTLR 生成的 python visitor 也差不多 $ antlr4 Expr.g4 -visitor -Dlanguage=Python3 visitor 在 ExprVisitor.py 里。 # ExprVisitor.py class ExprVisitor(ParseTreeVisitor): def visitExpr(self, ctx:ExprParser.ExprContext): return self.visitChildren(ctx) # ... MainEval.java 和 maineval.py 通过表达式求值展现了 visitor 的用法,如上编译后如下运行即可。 输出的 10 就等于 (1+3)*4-3-3。 $ python3 mainvisitor.py 常见问题 javac 报错一堆 cannot find symbol 没有设置 CLASSPATH grun 报错 Can't load Expr as lexer or parser 你 antlr4 以后没有编译 java 文件 我的输入是 1+2 ((( 它竟然不报错 ANTLR 不强制消耗整个输入,所以 parser 做完 1+2 就停了。 可以把 expr: add; 改成 expr: add EOF; antlr4 报错 error(31): ANTLR cannot generate python3 code as of version 4.8 -Dlanguage=Python3 的 P 要大写 备注 1. step1 的 MiniDecaf 语法太简单,不能体现很多 ANTLR 的特性。 ↩ 2. g 是 grammar,4 是 ANTLR 的版本号 4。 ↩ 3. EBNF 本身又有很多记号,有的使用 { ... } 表示重复。我们描述的是 ANTLR 的 EBNF 记号。 ↩ 4. 命令从 https://www.antlr.org/ 中 Samples 的内容修改而来 ↩ "},"docs/lab1/visitor.html":{"url":"docs/lab1/visitor.html","title":"Visitor 模式","keywords":"","body":"Visitor 模式速成 编译器的构造中会使用到很多设计模式,Visitor 模式就是常见的一种。 基础的设计模式都在 OOP 课程中覆盖,这里重提一下 Visitor 模式,顺带介绍一些参考代码用到的 python 技巧。 我们知道,编译器里有很多的树状结构。 最典型的就是,源程序通过上下文无关文法解析后,得到的语法分析树。 Visitor 模式的目的,就是遍历这些树状结构,本质就是一个 DFS 遍历。 下面通过一个例子说明 Visitor 模式。 表达式语法、语法树定义 我们有一个很简单的表达式文法,终结符包括整数和加减乘除模操作符,起始符号是 expr,大致如下 expr -> int | binary int -> Integer binary -> expr '+' expr | expr '-' expr | expr '*' expr | expr '/' expr | expr '%' expr 这个文法有二义性,同样的字符串可能有多个语法分析树。 不过解析字符串、生成语法分析树不是 Visitor 模式的工作。 Visitor 模式只考虑某个确定的语法分析树。 如下面是 20-13*3 的一棵语法分析树 我们在代码里这样定义这个语法分析树(python 3.6): class Node: pass class IntNode(Node): def __init__(self, v:int): # 类型标注是给人看的,python 不检查 self.v = v def __str__(self): return f\"({self.v})\" # f-string 特性 class BinopNode(Node): _legalOps = { *\"+-*/%\" } # 使用 unpacking operator,等价于 set('+', '-', '*', '/', '%') def __init__(self, op:str, lc:Node, rc:Node): assert op in BinopNode._legalOps self.op, self.lc, self.rc = op, lc, rc def __str__(self): return f\"({self.lc} {self.op} {self.rc})\" # 我们通过某种手段,得到了这么一个语法分析树 expr1 = BinopNode('*', BinopNode('-', IntNode(20), IntNode(13)), IntNode(3)) print(expr1) # (((20) - (13)) * (3)) 我们忽略了 Expr,不过显然这无伤大雅。 表达式求值 显然,每个语法分析树都对应一个(加好括号)的表达式,比如上面的树就对应 (20-13)*3。 那么我们考虑一个问题:如何对这个表达式求值? 当然,我们可以让 python 帮我们做 print(eval(str(expr1), {}, {})), 不过我们下面会用 Visitor 模式实现表达式求值。 写 Visitor 之前,我们看自己实现表达式求值的最简单的方法,一个递归遍历: def dfs(node:Node): if isinstance(node, IntNode): return node.v if isinstance(node, BinopNode): lhs = dfs(node.lc) rhs = dfs(node.rc) if node.op == \"+\": return lhs + rhs if node.op == \"-\": return lhs - rhs if node.op == \"*\": return lhs * rhs if node.op == \"/\": return lhs / rhs if node.op == \"%\": return lhs % rhs print(dfs(expr1)) # 21 dfs 函数接受一个结点,然后对这个结点代表的子树进行求值,返回求值结果。 容易看出,dfs 函数根据被遍历的结点类型不同,执行不同的求值逻辑。 那么我们把这些求值逻辑封装到一个类里面,就得到了一个最简单的 Visitor。 class EvaluationVisitor: def visit(self, node:Node): if isinstance(node, IntNode): return self.visitIntNode(node) if isinstance(node, BinopNode): return self.visitBinopNode(node) def visitIntNode(self, node:IntNode): return node.v def visitBinopNode(self, node:BinopNode): # 不确定子结点的类型,所以只能调用 visit 而非 visitIntNode 或者 visitBinopNode lhs = self.visit(node.lc) rhs = self.visit(node.rc) if node.op == \"+\": return lhs + rhs if node.op == \"-\": return lhs - rhs if node.op == \"*\": return lhs * rhs if node.op == \"/\": return lhs / rhs if node.op == \"%\": return lhs % rhs print(EvaluationVisitor().visit(expr1)) # 21 上面就是 Visitor 的核心思想,实际使用中我们一般会有两点改进 不使用 isinstance 来判断结点类型,而是调用结点自身的一个 accept 函数 把几个 visitXXX 函数抽象到一个接口里,各种具体的 Visitor 来实现这个接口 改进后的 Visitor 如下。 class Node: def accept(self, visitor): pass class IntNode(Node): # ... 同上 def accept(self, visitor): return visitor.visitIntNode(self) class BinopNode(Node): # ... 同上 def accept(self, visitor): return visitor.visitBinopNode(self) class Visitor: # 默认行为是遍历一遍,啥也不做,这样比较方便 def visitIntNode(self, node:IntNode): pass def visitBinopNode(self, node:BinopNode): node.lc.accept(self) node.rc.accept(self) class EvaluationVisitor(Visitor): def visitIntNode(self, node:IntNode): # ... 同上 def visitBinopNode(self, node:BinopNode): lhs = node.lc.accept(self) rhs = node.rc.accept(self) # ... 同上 总结 从上面可以看到,Visitor 模式的要素有 被访问的对象。例如上面的 Node。 Visitor 封装的 visitXXX,表示对上述对象实施的操作。例如 EvaluationVisitor。 每种被访问的对象在自己的定义中都有一个 accept 函数,并且在 Visitor 里面也对应一个 visitXXX 函数。 有状态的 Visitor subexpr = BinopNode('-', IntNode(20), IntNode(13)) expr1 = BinopNode('*', subexpr, IntNode(3)) 显然,表达式求值的过程中,所有子表达式也都会被求值。 如上,求值 expr1 的过程中,subexpr 也也会被求值。 我们想把子表达式的值记录下来,以后直接使用,就不需要对子表达式重新求值了。 为了实现这点,还是使用上面的 EvaluationVisitor,但我们用一个字典 Node -> int 记录求值结果,并且把字典作为 Visitor 的状态。 class EvaluationVisitor2(Visitor): def __init__(self): self.value = {} # Node -> int 每次 EvaluationVisitor2.visitXXX(self, node) 返回的时候,我们都记录一下 self.value[node] = value,其中 value 是返回值。 我们用一个函数修饰器来完成记录的动作,如下 class EvaluationVisitor2(Visitor): def __init__(self): self.value = {} # Node -> int def SaveValue(visit): # decorator def decoratedVisit(self, node): value = visit(self, node) self.value[node] = value return value return decoratedVisit @SaveValue def visitIntNode(self, node:IntNode): return node.v @SaveValue def visitBinopNode(self, node:BinopNode): lhs = node.lc.accept(self) rhs = node.rc.accept(self) if node.op == \"+\": return lhs + rhs if node.op == \"-\": return lhs - rhs if node.op == \"*\": return lhs * rhs if node.op == \"/\": return lhs / rhs if node.op == \"%\": return lhs % rhs subexpr = BinopNode('-', IntNode(20), IntNode(13)) expr1 = BinopNode('*', subexpr, IntNode(3)) visitor = EvaluationVisitor2() expr1.accept(visitor) print(visitor.value[subexpr]) # 7 print(visitor.value[expr1]) # 21 "},"docs/lab1/ir.html":{"url":"docs/lab1/ir.html","title":"IR 简明介绍","keywords":"","body":"中间代码 中间代码(也称中间表示,Intermediate Representation, IR)是表示程序结构的一种方式,在后续的实验中,我们会先由AST生成IR,再由IR生成汇编代码。尽管直接由AST生成汇编代码在我们的实验中也是完全可行的,但是保留这个中间步骤更加符合真实的编译器的工作流程。一般真实的编译器都有IR这个中间步骤,这是因为IR一般比AST更加接近汇编,同时仍然保存了一些程序中的高级信息,更加适合进行各种优化。 IR有很多种类,包括三地址码(Three Address Code, TAC),静态单赋值形式(Static Single Assignment Form, SSA),基于栈的IR,等等。如果你感兴趣的话可以自行查阅了解,这里不做要求。 我们的教程选择使用基于栈的IR。这种IR的最大特点是中间代码生成和汇编代码生成(不追求性能的话)非常容易编写,但是一般实际的编译器都不会使用它,因为它并不适合进行优化1,这样其实也就失去了IR存在的根本意义之一了。尽管如此,我们这个教学用的编译器还是选择使用基于栈的IR,主要目的是希望体现IR这个结构在实际的编译器中的地位,尽量让大家体会感受编译器的工作流程,只是限于课程的工作量的限制还是没法和实际的编译器做到真正的一致。 基于栈的IR顾名思义需要维护一个运算栈,它最主要的特点在于它的运算指令,例如加法和减法指令这些,是没有显式的操作数的。例如在编程语言中常常会写a = b + c,这里的b和c就是加法操作的操作数,而基于栈的IR中则不存在这样的结构,相当于只用一个加号来表示加法,不给出这个加法的操作数。这样的的运算指令的语义都是从这个运算栈的顶部弹出操作数,进行运算后再把结果压回栈中。 例子:一加到一百 在之后的每个step中,我们都会介绍(我们推荐的)加入IR的新指令。尽管如此,这里为了给大家留下一些直观的印象,还是先定义一套简单的基于栈的IR,并且用它表示一个简单的例子:计算一加到一百的和。 定义如下指令: PUSH x: 往运算栈中压入常数x LOAD var: 将变量var的值读出,压入栈中 STORE var: 从栈顶弹出一个值,写入变量var LABEL l: 定义一个名为l的标号 BZ l: 从栈顶弹出一个值,如果该值等于0,则跳转到标号l执行,否则继续执行下一条指令 B l: 无条件跳转到标号l执行 CMP_LE/ADD: 两条二元运算指令,从栈上依次弹出两个值,分别作为右操作数和左操作数,执行整数二元运算/+,将结果压入栈中 有几点可能是比较容易引起疑惑的,这里简单解释一下: 很多指令(其实是除了CMP_LE/ADD之外的所有指令)都有额外的参数,看起来不符合上面说的\"运算指令没有显式的操作数\"的特点。可以理解成\"运算指令\"指的就是CMP_LE/ADD这样的狭义地进行计算操作的指令,其他的都不属于运算指令。 上面提到了\"变量\"的概念,变量是保存在哪里的呢?假如要把这个IR最终翻译成汇编,运算栈显然会用栈来实现,而局部变量其实也只能保存在栈上,虽然保存在很接近的物理区域,但是它们逻辑上并不是运算栈的一部分,对局部变量的写入不应该影响到运算栈,在运算栈上进行的弹栈/压栈操作也不应该影响到局部变量。 上面提到的var,l这样的名字,实际实现的时候基本都是用整数来表示,而下面的程序中为了清晰起见,还是用人可读的名字来表示。 下面我们用这个IR来表示如下的C程序: int sum = 0; int i = 1; while (i 转化的结果如下(#后的是注释): PUSH 0 STORE sum # int sum = 0; PUSH 1 STORE i # int i = 0; LABEL loop LOAD i PUSH 100 CMP_LE # 计算i 我们有一个 ir.py(代码)能运行上面程序,结果的确是 1+2+...+100=5050. $ python3 ir.py 5050 标有*和**的两条指令在i = 50时执行前后的状态变化如下: 这里局部变量sum和i的保存位置就和上面描述的差不多,与运算栈保存在接近的物理区域,但是二者互不干扰。 执行ADD前,运算栈上恰好有两个元素,也就是前两条指令依次压入栈中的sum和i的值,当前栈顶的值是i的值50。执行ADD时,将这两个值依次弹出,栈顶的值作为右操作数,栈顶下的一个值作为左操作数,执行加法得到1226,再把1226压回栈中,执行完后运算栈上恰好有一个元素1226。 执行STORE sum时,将栈顶的1226弹出,存入sum所在的位置,执行完后sum的值被更新为1226,运算栈为空。 备注 1. 类似Java Bytecode这样的,虽然也属于基于栈的IR,但是实际的Java虚拟机中都会先把它转化成其它容易优化的形式,所以它的意义仅仅是便于生成和传输,几乎不会用于优化。这也启示我们,尽管我们选择了不容易优化的基于栈的IR,但未来还是有拓展的空间,可以把它转化成其他形式再进行优化。 ↩ "},"docs/lab1/spec.html":{"url":"docs/lab1/spec.html","title":"规范","keywords":"","body":"规范 每个步骤结尾的 规范 一节都会对这个步骤中的新特性给出规范,方便大家查阅。 step1 语法规范 我们采用 EBNF (extended Barkus-Naur form) 记号书写语法规范,采用类似 ANTLR 的记号: 小写字母打头的是非终结符(如 program),大写字母打头的是终结符(如 Identifier),可以用字符串字面量表示终结符(如 'int') 后面会用到:( 和 ) 表示分组,| 表示选择,* 零或多次,+ 一或多次,? 零或一次。 很容易通过增加新的非终结符,去掉这些符号。例如 x+ 就可以被替换成新的非终结符 y,并且 y : x | x y。 EBNF 也有很多写法,另一种是用尖括号表示非终结符 ::= 等。 program : function function : type Identifier '(' ')' '{' statement '}' type : 'int' statement : 'return' expression ';' expression : Integer step1 语义规范 1.1. MiniDecaf 的 int 类型具体指 32 位有符号整数类型,范围 [-2^31, 2^31-1],补码表示。 1.2. 编译器应当只接受 [0, 2^31-1] 范围内的整数, 我们未来会支持负数。 如果整数超过此范围,编译器应当报错。 1.3. 如果输入程序没有 main 函数,编译器应当报错。 "},"docs/lab2/intro.html":{"url":"docs/lab2/intro.html","title":"任务概述","keywords":"","body":"实验指导 step2:一元运算符 step2 中,我们要给整数常量增加一元运算:取负 -、按位取反 ~ 以及逻辑非 !。 语法上,我们需要修改 expression 的定义,从 expression : Integer 变成: expression : unary unary : Integer | ('-'|'!'|'~') unary 三个操作的语义和 C 以及常识相同,例如 ~0 == -1,!!2 == 1。 稍微一提,关于按位取反,我们使用补码存储 int;关于逻辑非,只有 0 表示逻辑假,其他的 int 都是逻辑真。 "},"docs/lab2/guide.html":{"url":"docs/lab2/guide.html","title":"实验指导","keywords":"","body":"step2 实验指导 我们按照上一节划分的编译器阶段,分阶段给出 step2 实验指导。 词法语法分析 如果你使用工具完成词法语法分析,修改你的规范以满足要求,剩下的交给工具即可。 语法规范已经给出,词法规范的变化也很简单,新增三个 token:-、~ 和 !。 你的规范和我们的要求等价、能通过测试即可,不用完全一样。 语义检查无需修改。 如果你是手写分析,TODO IR 生成 显然,我们要引入一类 IR 表示一元操作。 一元操作 IR 的含义是:弹出栈顶,对弹出的值做某个一元操作,再把操作的结果值压入栈顶。 换言之,就是直接对栈顶做某个操作。 指令 参数 含义 IR 栈大小变化 neg 无参数 栈顶取负 不变 not 同上 栈顶按位取反 不变 lnot 同上 栈顶取逻辑非 不变 和 step1 一样,这一节所讲的领悟意思即可。 你不用照着实现。 例如你可以把三条指令变成一条 Unary(op),其中 op 是 \"-\"、\"~\" 或 \"!\"。 你甚至也不必显式转成 IR。 和 step1 一样,采用 Visitor 模式遍历 AST 来生成 IR。除了 step1 的要求,step2 还要求你遍历 AST 时, 遇到一元表达式的时候,先生成子表达式的 IR,然后再根据操作类型生成一个 neg 或 not 或 lnot 所以,~!--3 会翻译成 push 3 ; neg ; neg ; lnot ; not 五条 IR 指令。 汇编生成 很简单,如下表。 IR 汇编 neg lw t1, 0(sp) ; neg t1, t1 ; sw t1, 0(sp) not …… lnot …… 要知道每个操作生成什么样的汇编,可以参考 gcc 的输出。 例如我们想知道取负的汇编,那我们用 gcc 编译 int foo(int x) { return -x; }, 结果如下(记得加 -O3),我们就知道取负是 neg 目标寄存器, 操作数寄存器。 foo: neg a0,a0 ret 仿照上面,自己确定 not 和 lnot 的汇编。 任务 改进你的编译器,支持本节引入的新特性,通过相关测试。 实验报告中回答思考题。 总结 本节内容不多,也很简单。 "},"docs/lab2/spec.html":{"url":"docs/lab2/spec.html","title":"规范","keywords":"","body":"规范 每个步骤结尾的 规范 一节都会对这个步骤中的新特性给出规范,方便大家查阅。 step2 语法规范 灰色部分表示相对上一节的修改。 program : function function : type Identifier '(' ')' '{' statement '}' type : 'int' statement : 'return' expression ';' expression : unary unary : Integer | ('-'|'!'|'~') unary step2 语义规范 2.1. MiniDecaf 中,负数字面量不被整体作为一个 token。它被看成是一个取负符号、后面是它的绝对值。 所以我们无法用字面量表示 -2147483648,但可以写成 -2147483647-1(待我们加上四则运算后)。 2.2. 运算越界是未定义行为。例如 -(-2147483647-1) 是未定义行为。 2.3. 除非特别声明,子表达式求值顺序是不确定的。 例如:执行 int a=0; (a=1)+(a=a+1); 之后 a 的值是不确定的。 "},"docs/lab3/intro.html":{"url":"docs/lab3/intro.html","title":"任务概述","keywords":"","body":"实验指导 step3:加减乘除模 step3 我们要增加的是:加 +、减 -、乘 *、整除 /、模 % 以及括号 ( )。 语法上我们继续修改 expression,变成 expression : additive additive : multiplicative | additive ('+'|'-') multiplicative multiplicative : unary | multiplicative ('*'|'/'|'%') unary unary : primary | ('-'|'~'|'!') unary primary : Integer | '(' expression ')' 新特性的语义、优先级、结合性和 C 以及常识相同,例如 1+2*(4/2+1) == 7。 "},"docs/lab3/guide.html":{"url":"docs/lab3/guide.html","title":"实验指导","keywords":"","body":"step3 实验指导 词法语法分析 如果你使用工具完成词法语法分析,修改你的语法规范以满足要求,自行修改词法规范,剩下的交给工具即可。 语义检查无需修改。 如果你是手写分析,TODO IR 生成 我们同样引入一类 IR 表示二元操作。 执行二元操作的 IR 时,两个操作数需要位于栈顶,然后它们被弹出、进行相应操作,再把结果压入栈顶。 不妨规定二元操作的右操作数在栈顶,左操作数在右操作数下面,那么有如下例子: 3-10 翻译成 push 3 ; push 10 ; sub 三条指令。 +------------+ 栈顶 | 10 | +------------+ ----------> +------------+ 栈顶 | 3 | 执行 sub | -7 | +------------+ +------------+ | ...... | | ...... | 指令 参数 含义 IR 栈大小变化 add 无参数 弹出栈顶两个元素,压入它们的和 减少 1 sub 无参数 弹出栈顶两个元素,压入它们的差,顺序如上 减少 1 mul、div、rem 无参数 ……乘除模 减少 1 类比 step2,生成 IR 时 Visitor 遍历 AST 遇到二元操作,需要(注意 1. 和 2. 的顺序) 首先生成左操作数的 IR(左操作数入栈,栈顶是左操作数) 然后生成右操作数的 IR(右操作数入栈,栈顶是右操作数) 根据操作不同生成对应的二元 IR 上面的 3 步执行完后,栈大小比执行第 1. 步以前增加 1,栈顶就是二元操作的结果。 这符合我们在 step1 中的假设: 考虑源代码中某个表达式被翻译成了一系列 IR 指令,那么从任何初始状态出发执行这些 IR 指令, 完成后 IR 栈大小增加 1,栈顶就是表达式的值。 汇编生成 仿照 step2 所说,用 gcc 自己确定 sub/mul/div/rem 的汇编。 IR 汇编 add lw t1, 4(sp) ; lw t2, 0(sp) ; add t1, t1, t2 ; addi sp, sp, 4 ; sw t1, 0(sp) sub,mul,div,rem …… 任务 改进你的编译器,支持本节引入的新特性,通过相关测试。 实验报告中回答思考题。 总结 本节重点是执行过程中栈的变化,以及上面提到的 step1 的假设,参见上面 IR 生成一节。 "},"docs/lab3/spec.html":{"url":"docs/lab3/spec.html","title":"规范","keywords":"","body":"规范 每个步骤结尾的 规范 一节都会对这个步骤中的新特性给出规范,方便大家查阅。 step3 语法规范 灰色部分表示相对上一节的修改。 program : function function : type Identifier '(' ')' '{' statement '}' type : 'int' statement : 'return' expression ';' expression : additive additive : multiplicative | additive ('+'|'-') multiplicative multiplicative : unary | multiplicative ('*'|'/'|'%') unary unary : primary | ('-'|'~'|'!') unary primary : Integer | '(' expression ')' step3 语义规范 3.1. 除以零、模零都是未定义行为。 "},"docs/lab4/intro.html":{"url":"docs/lab4/intro.html","title":"任务概述","keywords":"","body":"实验指导 step4:比较和逻辑表达式 step4 我们要增加的是: 比较大小和相等的二元操作:、、>=, >, ==, != equality : relational | equality ('=='|'!=') relational relational : additive | relational ('|'>'|'|'>=') additive 逻辑与 &&、逻辑或 || expression : logical_or logical_or : logical_and | logical_or '||' logical_and logical_and : equality | logical_and '&&' equality 新特性的语义、优先级、结合性和 C 以及常识相同,例如 1=2 是逻辑真(int 为 1)。 但特别注意,C 中逻辑运算符 || 和 && 有短路现象,我们不要求。 "},"docs/lab4/guide.html":{"url":"docs/lab4/guide.html","title":"实验指导","keywords":"","body":"step4 实验指导 词法语法分析 如果你使用工具完成词法语法分析,修改你的语法规范以满足要求,自行修改词法规范,剩下的交给工具即可。 语义检查无需修改。 如果你是手写分析,TODO IR 生成 沿用 step3 加入的二元操作 IR(以及左右操作数的位置),新的 IR 如下。 指令 参数 含义 IR 栈大小变化 eq 无参数 ==(弹出栈顶两个元素,如果相等压入 1,否则压入 0) 减少 1 ne 无参数 ……!= 减少 1 le 无参数 …… 减少 1 ge 无参数 ……>= 减少 1 lt 无参数 …… 减少 1 gt 无参数 ……> 减少 1 land 无参数 ……&& 减少 1 lor 无参数 弹出栈顶两个元素,将其逻辑或压入栈 减少 1 当然,IR 设计很灵活。参见思考题 3。 汇编生成 对于比较大小和相等的操作,参照 gcc 结果,自行完成汇编生成。 逻辑表达式会麻烦一点,因为 gcc 可能生成跳转,所以下面给出 land 和 lor 对应的汇编。 表格中,我们省略了汇编的前缀 lw t1, 4(sp) ; lw t2, 0(sp) 和后缀 addi sp, sp, 4 ; sw t1, 0(sp)。 注意 RISC-V 汇编中的 and 和 or 都是位运算指令,不是逻辑运算指令。 IR 汇编 lor or t1,t1,t2 ; snez t1,t1 land snez t1,t1 ; snez t2,t2 ; and t1,t1,t2 任务 改进你的编译器,支持本节引入的新特性,通过相关测试。 实验报告中回答思考题。 总结 step4 和 step3 差别不大。 "},"docs/lab4/spec.html":{"url":"docs/lab4/spec.html","title":"规范","keywords":"","body":"规范 每个步骤结尾的 规范 一节都会对这个步骤中的新特性给出规范,方便大家查阅。 step4 语法规范 灰色部分表示相对上一节的修改。 program : function function : type Identifier '(' ')' '{' statement '}' type : 'int' statement : 'return' expression ';' expression : logical_or logical_or : logical_and | logical_or '||' logical_and logical_and : equality | logical_and '&&' equality equality : relational | equality ('=='|'!=') relational relational : additive | relational ('|'>'|'|'>=') additive additive : multiplicative | additive ('+'|'-') multiplicative multiplicative : unary | multiplicative ('*'|'/'|'%') unary unary : primary | ('-'|'~'|'!') unary primary : Integer | '(' expression ')' step4 语义规范 4.1. 如果 || 和 && 的子表达式有副作用,那么这就是一个未定义行为。 换言之,不对逻辑表达式的短路求值做要求。 4.2. 比较大小是有符号数的比较大小,因此 0xFFFFFFFF == -1 。 "},"docs/lab5/intro.html":{"url":"docs/lab5/intro.html","title":"任务概述","keywords":"","body":"实验指导 step5:局部变量和赋值 这一步我们终于要增加变量了,包括 变量的声明 变量的使用(读取/赋值) 并且,虽然还只有一个 main 函数,但 main 函数可以包含多条语句和声明了。 为了加入变量,我们需要确定:变量存放在哪里、如何访问。 为此,我们会引入 栈帧 的概念,并介绍它的布局。 语法上,step5 的改动如下: function : type Identifier '(' ')' '{' statement* '}' statement : 'return' expression ';' | expression? ';' | declaration declaration : type Identifier ('=' expression)? ';' expression : assignment assignment : logical_or | Identifier '=' expression primary : Integer | '(' expression ')' | Identifier 并且我们也要增加语义检查了:变量不能重复声明,不能使用未声明的变量。 "},"docs/lab5/guide.html":{"url":"docs/lab5/guide.html","title":"实验指导","keywords":"","body":"step5 实验指导 词法语法分析 如果你使用工具完成词法语法分析,修改你的语法规范以满足要求,自行修改词法规范,剩下的交给工具即可。 语义检查部分,我们需要检查是否(一)使用了未声明的变量、(二)重复声明变量。 为此,我们在生成 IR 的 Visitor 遍历 AST 时,维护当前已经声明了哪些变量。 遇到变量声明(declaration)和使用(primary 和 assignment)时检查即可。 可以把这个要求和后面提到的符号表结合,放到 IR 生成去做。 如果你是手写分析,TODO IR 生成 为了完成 step5 的 IR 生成,我们需要确定 IR 的栈帧布局,请看 这里。 局部变量被放在栈上,但我们不能直接弹栈访问它们。 因此我们需要加入访问栈内部的 load/store,以及生成栈上地址的 frameaddr 指令。 并且我们再加入一个 pop 指令。 指令 参数 含义 IR 栈大小变化 frameaddr 一个非负整数常数 k 把当前栈帧底下开始第 k 个元素的地址压入栈中 增加 1 load 无参数 将栈顶弹出,作为地址 1 然后加载该地址的元素(int),把加载到的值压入栈中 不变 store 无参数 弹出栈顶作为地址,读取新栈顶作为值,将值写入地址开始的 int 减少 1 pop 无参数 弹出栈顶,忽略得到的值 减少 1 IR 生成还是 Visitor 遍历,并且 遇到读取变量 primary: Identifier 的时候,查符号表确定变量是第几个,然后生成 frameaddr 和 load。 如果查不到同名变量,应当报错:变量未定义 遇到变量赋值的时候,先生成等号右手边的 IR,同上对等号左手边查符号表,生成 frameaddr 和 store。 注意赋值表达式是有值的,执行完它的 IR 后栈顶还保留着赋值表达式的值。这就是为什么 store 只弹栈一次。 遇到表达式语句时,生成完表达式的 IR 以后记得再生成一个 pop,保证栈帧要满足的第 1. 条性质(这里有说) 遇到声明时,除了记录新变量,还要初始化变量。 为了计算 prologue 中分配栈帧的大小,IR 除了一个指令列表,还要包含一个信息:局部变量的个数。 main 有多条语句了,所以它的 IR 是其中语句的 IR 顺序拼接。 例如 int main(){int a=2; a=a+3; return a;},显然 a 是第 0 个变量。 那它的 IR 指令序列是(每行对应一条语句): frameaddr 0 ; push 2 ; store ; pop ; frameaddr 0 ; load ; push 3 ; add ; frameaddr 0 ; store ; pop ; frameaddr 0 ; load ; ret ; 汇编生成 IR 指令到汇编的对应仍然很简单,如下表。 IR 汇编 frameaddr k addi sp, sp, -4 ; addi t1, fp, -12-4*k ; sw t1, 0(sp) load lw t1, 0(sp) ; lw t1, 0(t1) ; sw t1, 0(sp) store lw t1, 4(sp) ; lw t2, 0(sp) ; addi sp, sp, 4 ; sw t1, 0(t2) pop addi sp, sp, 4 但除了把 IR 一条一条替换成汇编,step5 还需要生成 prologue 和 epilogue,并且 ret 也要修改了, 参见栈帧文档。 IR 汇编 ret lw a0, 0(sp) ; addi sp, sp, 4 ; j FUNCNAME_epilogue 另外我们还要求 main 默认返回 0: 5.4. 执行完 main 函数但没有通过 return 结束时,返回值默认为 0。 显然,如果 main 是通过 return 结束的,按照上面的修改一定是跳到 main_epilogue,否则是顺序执行到 main_epilogue 的。 因此我们在 main_epilogue 之前,所有语句之后,加上 push 0 的汇编即可,表示默认返回 0。 备注 1. 我们规定 load 的地址必须对齐到 4 字节,生成 IR 时需要保证。store 也是。 ↩ "},"docs/lab5/stackframe.html":{"url":"docs/lab5/stackframe.html","title":"栈帧","keywords":"","body":"栈帧 我们曾经在step1提到,“局部变量其实也只能保存在栈上”。 所以我们需要确定栈(包括 IR 的栈和汇编的栈)上面到底有那些元素,这些元素在栈上的布局如何。 汇编语言课上提到过 栈帧(stack frame) 的概念,简单回想一下: 每次调用和执行一个函数,都会在栈空间上开辟一片空间,这篇空间就叫“栈帧”。 栈帧里存放了执行这个函数需要的各种数据,包括局部变量、callee-save 寄存器等等。 当然,既然汇编有栈帧, 栈式机 IR 也有栈帧。 我们只有一个函数 main,直到 step9 我们才会有多函数支持。 所以现在关于栈帧的讨论,我们就只考虑一个栈帧。 后面的 step 会深入讨论。 关于栈帧,有两个问题需要说明 栈帧长什么样?即、栈帧上各个元素的布局如何? 栈帧是如何建立与销毁的? 第 1. 点,我们规定,程序执行的任何时刻,栈帧分为三块: 栈顶是计算表达式用的运算栈,它可能为空(当前不在计算某表达式的过程中) 然后一片空间存放的是当前可用的所有局部变量 返回地址、老的栈帧基址等信息 下图展现了汇编栈的栈帧结构,以及执行过程中栈中内容的变化。 栈左上方是源代码,右上方是 IR。 粉色背景表示已经执行完的汇编对应的源代码/IR。 假设用户给的输入是 24 12。 从中可以看出,栈帧满足如下性质 每条语句开始执行和执行完成时,汇编栈帧大小都等于 8 + 4 * 局部变量个数 个字节,其中 4 == sizeof(int) 是一个 int 变量占的字节数 (就是 step1 中的假设)任何表达式对应的 IR 序列执行结果一定是:栈帧大小增加 4,栈顶四字节存放了表达式的值。 汇编栈帧底部还保存了 fp 和返回地址,使得函数执行完成后能够返回 caller 继续执行。 把栈帧设计成这样,访问变量就可以直接用 fp 加上偏移量来完成。 例如第 1. 小图中,“读取 a” 就是加载 -12(fp);第 3. 小图中,“保存到 c” 就是保存到 -20(fp)。 我们只叙述了汇编的栈帧,但 IR 的和汇编的一样(就我们的设计而言),也是三个部分,也要有 old fp 和返回地址。 建立栈帧 进入一个函数后,在开始执行函数体语句的汇编之前,要做的第一件事是:建立栈帧。 每个函数最开始、由编译器生成的用于建立栈帧的那段汇编被称为函数的 prologue。 就 step5 而言,prologue 要做的事情很简单 分配栈帧空间 保存 fp 和返回地址(在寄存器 ra 里) 举个例子,下面是一种可能的 prologue 写法。 其中 FRAMESIZE 是一个编译期已知的常量,等于 8 + 4 * 局部变量个数(这名字不太准确,因为有运算栈,栈帧大小其实不是常量) addi sp, sp, -FRAMESIZE # 分配空间 sw ra, FRAMESIZE-4(sp) # 储存返回地址 sw fp, FRAMESIZE-8(sp) # 储存 fp addi fp, sp, FRAMESIZE # 更新 fp 当然,开始执行函数时需要建立栈帧,结束执行时就需要销毁栈帧。 函数末尾、用于销毁栈帧的一段汇编叫函数的 epilogue,它要做的是: 设置返回值 回收栈帧空间 恢复 fp,跳回返回地址(ret 就是 jr ra) 返回值我们可以直接放在 a0 中,也可以放在栈顶让 epilogue 去加载。 如果是后者,那么上面“栈帧满足如下性质”的 1. 要把 return 作为例外了。 把返回值放在栈顶的话,下面是 epilogue 一种可能的写法。 前缀 FUNCNAME 是当前函数函数名,例如 main,加上前缀以区分不同函数的 epilogue。 FUNCNAME_epilogue: # epilogue 标号,可作为跳转目的地 lw a0, 0(sp) # 从栈顶加载返回值,此时 sp = fp - FRAMESIZE - 4 addi sp, sp, 4 # 弹出栈顶的返回值 lw fp, FRAMESIZE-8(sp) # 恢复 fp lw ra, FRAMESIZE-4(sp) # 恢复 ra addi sp, sp, FRAMESIZE # 回收空间 jr ra # 跳回 caller 就 step5,保存恢复 fp/ra 的确不必要。但是加上会让后面步骤更方便。 需要注意的是,IR 的 ret 指令不能直接 jr ra 了,而是要跳转执行 epilogue,即 j FUNCNAME_epilogue。 变量声明 对于每个变量声明,我们需要 设定变量相对 fp 的偏移量。 在栈帧上预留保存变量空间 第 2. 点已经在 prologue 中完成了,所以重点是第 1. 点。 对每个变量用一个数据结构维护其信息,如:名称、类型(step11)、声明位置、初始值等。 目前阶段,你可以简单的使用一个简单的链表或者数组来保存变量的信息。 这个保存变量符号的表被称为 符号表(symbol table)。 那偏移量可以(一)作为变量数据结构的一个字段、(二)也可以维护一个变量到偏移量的映射、(三)像下面通过某种方法计算得到。 当然,不能用一张符号表覆盖整个程序,程序中不同位置的符号表是不同的。 例如,符号表只会包含被声明的变量的信息,因此在 int a=0; 之后的符号表比之前的多了一个条目表示 a 对应的变量数据结构。 确定变量的偏移量本身倒很容易:从前往后每一个声明的变量偏移依次递减,从 -12(fp) 开始,然后是 -16(fp)、-20(fp) 以此类推。 所以对于每个变量,我们只需在其数据结构中记录:它是从前往后第几个变量。 第 k>=0 个变量的偏移量就是 -12-4*k。 目前我们没有作用域,这样做是没问题的,但在第7章引入作用域后,这种暴力的实现会出现一些问题。 "},"docs/lab5/spec.html":{"url":"docs/lab5/spec.html","title":"规范","keywords":"","body":"规范 每个步骤结尾的 规范 一节都会对这个步骤中的新特性给出规范,方便大家查阅。 step5 语法规范 灰色部分表示相对上一节的修改。 program : function function : type Identifier '(' ')' '{' statement* '}' type : 'int' statement : 'return' expression ';' | expression? ';' | declaration declaration : type Identifier ('=' expression)? ';' expression : assignment assignment : logical_or | Identifier '=' expression logical_or : logical_and | logical_or '||' logical_and logical_and : equality | logical_and '&&' equality equality : relational | equality ('=='|'!=') relational relational : additive | relational ('|'>'|'|'>=') additive additive : multiplicative | additive ('+'|'-') multiplicative multiplicative : unary | multiplicative ('*'|'/'|'%') unary unary : primary | ('-'|'~'|'!') unary primary : Integer | '(' expression ')' | Identifier step5 语义规范 5.1. 变量重复声明是错误,使用未声明的变量也是错误。 5.2. 被声明的变量在声明语句完成后,可以被使用。 不能写 int a = a。 5.3. (局部)变量初始值不确定,使用不确定的值是未定义行为。 5.4. 执行完 main 函数但没有通过 return 结束时,返回值默认为 0。 5.5. 只有左值能出现在赋值号 = 的左边。 表达式被称为左值(lvalue)当且仅当它能被下面两条规则构造出来: 被声明过的变量是左值; 如果 e 是左值,那么括号括起的 (e) 也是左值。 就 step5 来说,这一点已经被语法保证,无须语义检查。 5.6. 规定赋值表达式的值为,赋值完成后左手边的值。例如 a=(1+3) 的值是 4。 "},"docs/lab6/intro.html":{"url":"docs/lab6/intro.html","title":"任务概述","keywords":"","body":"实验指导 step6: step6 我们要支持 if 语句和条件表达式(又称三元/三目表达式,ternary expression)。 语法上的改动是: if 表达式 statement : 'return' expression ';' | expression? ';' | 'if' '(' expression ')' statement ('else' statement)? 条件表达式 assignment : conditional | Identifier '=' expression conditional : logical_or | logical_or '?' expression ':' conditional block_item:为了下一阶段做准备 function : type Identifier '(' ')' '{' block_item* '}' block_item : statement | declaration if 语句的语义和 C 以及常识相同,条件表达式优先级只比赋值高。 "},"docs/lab6/guide.html":{"url":"docs/lab6/guide.html","title":"实验指导","keywords":"","body":"step5 实验指导 词法语法分析 如果你使用工具完成词法语法分析,修改你的语法规范以满足要求,自行修改词法规范,剩下的交给工具即可。 语义检查无需修改。 如果你是手写分析,TODO 注意 step6 引入 block_item 后,declaration 不再是语句,所以 if (a) int b; 也不是合法代码了。 这是 C 标准中一个设定。 悬吊 else 问题 这一节引入的 if 语句既可以带 else 子句也可以不带,但这会导致语法二义性:else 到底和哪一个 if 结合? 例如 if(a) if(b) c=0; else d=0;,到底是 if(a) {if(b) c=0; else d=0;} 还是 if(a) {if(b) c=0;} else d=0;(其中有大括号,现在还不支持,因为它是 step7 内容)? 这个问题被称为 悬吊 else(dangling else) 问题。 如果程序员没有加大括号,那么我们需要通过一个规定来解决歧义。 我们人为规定:else 和最近的 if 结合,也就是说上面两种理解中只有前者合法。 为了让 parser 能遵守这个规定,一种方法是设置产生式的优先级,优先选择没有 else 的 if。 按照这个规定,parser 看到 if(a) if(b) c=0; else d=0; 中第一个 if 时,选择没有 else 的 if; 而看到第二个时只能选择有 else 的 if 1,也就使得 else d=0; 被绑定到 if(b) 而不是 if(a) 了。 IR 生成 显然,我们需要跳转指令以实现 if,同时还需要作为跳转目的地的标号(label)。 我们的跳转指令和汇编中的类似,不限制跳转方向,往前往后都允许。 指令 参数 含义 IR 栈大小变化 label 一个字符串 什么也不做,仅标记一个跳转目的地,用参数字符串标识 不变 beqz 标号字符串 弹出栈顶元素,如果它等于零,那么跳转到参数标识的 label 开始执行 减少 1 bnez 标号字符串 弹出栈顶元素,如果它不等于零,那么跳转到参数标识的 label 开始执行 减少 1 br 标号字符串 无条件跳转到参数标识的 label 开始执行 不变 注意一个程序中的标号,也就是 label 的参数,必须唯一,否则跳转目的地就不唯一了。 简单地后缀一个计数器即可,例如 label l1, label l2, label l3 ... Visitor 遍历 AST 遇到一个有 else 的 if 语句,为了生成其 IR,要生成的是 首先是 条件表达式的 IR:计算条件表达式。 beqz ELSE_LABEL:判断条件,若条件不成立则执行 else 子句 跳转没有执行,说明条件成立,所以之后是 then 子句的 IR br END_LABEL:条件成立,执行完 then 以后就结束了 label ELSE_LABEL,然后是 else 子句的 IR label END_LABEL:if 语句结束。 例子:if (a) return 2; else a=2+3; 的 IR 是 frameaddr k ; load,其中 k 是 a 的 frameaddr beqz else_label1,数字后缀是避免标号重复的 push 2 ; ret br end_label1 label else_label1,然后是 push 2 ; push 3 ; add ; frameaddr k ; store ; pop label end_label1 仿照上面,容易写出条件表达式的 IR 应该如何生成,并且同时也能保证满足语义规范 6.4. 和 2.3 不同,条件表达式规定了子表达式的求值顺序。 首先对条件求值。如果条件值为真,然后仅对 ? 和 : 之间的子表达式求值,作为条件表达式的值, 不得对 : 之后的子表达式求值。 如果条件为假,类似仅对 : 之后的子表达式求值。 类似,无 else 的 if 语句的 IR 包含 条件表达式的 IR beqz END_LABEL then 子句的 IR label END_LABEL 汇编生成 如下表: IR 汇编 label LABEL_STR LABEL_STR: br LABEL_STR j LABEL_STR2 beqz LABEL_STR lw t1, 0(sp) ; addi sp, sp, 4 ; beqz t1, LABEL_STR bnez LABEL_STR lw t1, 0(sp) ; addi sp, sp, 4 ; bnez t1, LABEL_STR 任务 改进你的编译器,支持本节引入的新特性,通过相关测试。 实验报告中回答思考题。 总结 本节主要就是引入了跳转,后面 step8 循环语句还会使用。 备注 1. 见思考题 ↩ 2. 如果 LABEL_STR 在当前函数内,j LABEL_STR 就等于 beqz x0, LABEL_STR ↩ "},"docs/lab6/spec.html":{"url":"docs/lab6/spec.html","title":"规范","keywords":"","body":"规范 每个步骤结尾的 规范 一节都会对这个步骤中的新特性给出规范,方便大家查阅。 step6 语法规范 灰色部分表示相对上一节的修改。 program : function function : type Identifier '(' ')' '{' block_item* '}' type : 'int' block_item : statement | declaration statement : 'return' expression ';' | expression? ';' | 'if' '(' expression ')' statement ('else' statement)? declaration : type Identifier ('=' expression)? ';' expression : assignment assignment : conditional | Identifier '=' expression conditional : logical_or | logical_or '?' expression ':' conditional logical_or : logical_and | logical_or '||' logical_and logical_and : equality | logical_and '&&' equality equality : relational | equality ('=='|'!=') relational relational : additive | relational ('|'>'|'|'>=') additive additive : multiplicative | additive ('+'|'-') multiplicative multiplicative : unary | multiplicative ('*'|'/'|'%') unary unary : primary | ('-'|'~'|'!') unary primary : Integer | '(' expression ')' | Identifier step6 语义规范 6.1. int 类型的表达式作为 if 或条件表达式的条件时,非 0(例如 1、-1、1000000007)表示真,0 表示假。 6.2. 如果出现悬吊 else(dangling else),要求 else 优先和内层 if 结合。 例如 if (0) if (0) ; else ; 等价于 if (0) { if (0) ; else; } 而非 if (0) { if (0) ; } else ;。 6.3. if 的 then 子句和 else 子句不能仅是一个声明,编译器应当报错错误。 例如 if (1) int a; 是不合法的输入 6.4. 和 2.3 不同,条件表达式规定了子表达式的求值顺序。 首先对条件求值。如果条件值为真,然后仅对 ? 和 : 之间的子表达式求值,作为条件表达式的值, 不得对 : 之后的子表达式求值。 如果条件为假,类似仅对 : 之后的子表达式求值。 "},"docs/lab7/intro.html":{"url":"docs/lab7/intro.html","title":"任务概述","keywords":"","body":"实验指导 step7:作用域和块语句 step7 我们要增加块语句的支持。 虽然块语句语义不难,就是把多个语句组成一个块,每个块都是一个作用域。 随之而来一个问题是:不同变量可以重名了。 重名的情况包括作用域内部声明覆盖(shadowing)外部声明,以及不相交的作用域之间的重名变量。 因此,变量名不能唯一标识变量了,同一个变量名 a 出现在代码不同地方可能标识完全不同的变量。 我们需要进行 名称解析(name resolution),确定 AST 中出现的每个变量名分别对应那个变量。 语法上改动不大 function : type Identifier '(' ')' compound_statement compound_statement : '{' block_item* '}' statement : 'return' expression ';' | expression? ';' | 'if' '(' expression ')' statement ('else' statement)? | compound_statement 语义检查我们也要修改了,只有在同一个作用域里,变量才不能重复声明。 当然,如果变量在使用前还是必须先被声明。 "},"docs/lab7/guide.html":{"url":"docs/lab7/guide.html","title":"实验指导","keywords":"","body":"step7 实验指导 词法语法分析 如果你使用工具完成词法语法分析,修改你的语法规范以满足要求,自行修改词法规范,剩下的交给工具即可。 至于变量相关的语义检查,因为它们和名称解析密切相关,所以可以放到那里面去,参见后文。 如果你是手写分析,TODO 名称解析 step7 我们需要给自己的编译器新增一个阶段:名称解析,它位于语法分析和 IR 生成之间。 这个词广义上的含义就是:把名称关联到对应的实体。例如网络原理中的 DNS 也是名称解析。 并且,我们所谓 “阶段” 也只是逻辑上的。就 MiniDecaf 的实现而言,名称解析的代码也可以嵌入 IR 生成里。 名称解析的阶段任务就是把 AST 中出现的每个变量名关联到对应的变量,它需要遍历 AST,所以实现为 AST 上的一个 Visitor 它的输入 是 parser 给的 AST 它的输出 是上面那棵 AST,但 AST 中所有涉及变量名的结点都增加一个属性,表示此处的变量名到底标识哪个变量 这样的结点有:primary、assignment 和 declaration。 代码中,这样的属性可以实现为指向 变量数据结构 的一个指针。 也可以实现为一个从 AST 结点到变量的映射。 下面是一个例子: 考虑我们有一段代码: { int a=0; a= a+1; { int a= a+1; return a; } return a; { int b=12; return a +b; } } 显然其中有三个变量,两个的名字是 a 一个的是 b。不妨把这三个变量记为 a0, a1, b0。 名称解析应当发现这点,并且还要把每个变量名关联到变量,所以它提供的信息类似: { int a=0; // a0 a= // a0 a+1; // a0 { int a= // a1 a+1; // a0 return a; // a1 } return a; // a0 { int b=12; // b0 return a // a0 +b; // b0 } } 如果按照定义把这个结果画在语法树上,那么大致如(省略了一些不重要的中间结点) 用于储存变量信息的 符号表 的结构也需要改进,以支持作用域。具体的,它需要支持 符号表中,区分不同作用域的变量:支持声明覆盖(shadowing)、检查重复声明 离开某作用域时,要把其中的变量从符号表中删除 为此,我们把符号表改造为一个栈。 (对应上面 1.)栈中每个元素都对应一个开作用域,是一个列表,包含该作用域中至今声明过的所有变量。 程序中不同位置的符号表是不同的;某位置的 开作用域(open scope) 指的是包含该位置的所有作用域。 例如上图中 return a+b; 处,有两个开作用域(声明 a0 和 b0 的),而声明 a1 的作用域不是开作用域。 (对应上面 2.) 每进入一个作用域,就压栈一个空列表;离开作用域就弹栈 在符号表中查找变量名,从栈顶往下查找(所以内层声明才能覆盖外层声明)。 另外, 变量偏移量 的确定方法也需要修改。 如果我们还假设偏移量是 -12-4*frameaddr,那变量的 frameaddr 就不单纯是第几个变量了。 为了保证 step5 中叙述的栈帧性质,其 frameaddr 是 “在此变量刚声明之前,所有开作用域中的变量总数”。 例如上图中,frameaddr(a1) == frameaddr(b0) == frameaddr(a0)+1。 最后,名称解析 Visitor 需要 维护符号表,进入块语句(compound_statement)时压栈、离开时弹栈 每次遇到变量名(Identifier)时查找符号表,将其关联到具体的变量,或者报错变量未声明 每次遇到声明(declaration),确定 frameaddr、建立变量并插入符号表,或者报错变量重复声明 IR 生成 无须新增 IR 语句。 块语句的 IR 由其中子语句 IR 顺序拼接而成即可。 汇编生成 无须修改。 任务 改进你的编译器,支持本节引入的新特性,通过相关测试。 实验报告中回答思考题。 总结 本节最重要的内容是名称解析。 "},"docs/lab7/spec.html":{"url":"docs/lab7/spec.html","title":"规范","keywords":"","body":"规范 每个步骤结尾的 规范 一节都会对这个步骤中的新特性给出规范,方便大家查阅。 step7 语法规范 灰色部分表示相对上一节的修改。 program : function function : type Identifier '(' ')' compound_statement type : 'int' compound_statement : '{' block_item* '}' block_item : statement | declaration statement : 'return' expression ';' | expression? ';' | 'if' '(' expression ')' statement ('else' statement)? | compound_statement declaration : type Identifier ('=' expression)? ';' expression : assignment assignment : conditional | Identifier '=' expression conditional : logical_or | logical_or '?' expression ':' conditional logical_or : logical_and | logical_or '||' logical_and logical_and : equality | logical_and '&&' equality equality : relational | equality ('=='|'!=') relational relational : additive | relational ('|'>'|'|'>=') additive additive : multiplicative | additive ('+'|'-') multiplicative multiplicative : unary | multiplicative ('*'|'/'|'%') unary unary : primary | ('-'|'~'|'!') unary primary : Integer | '(' expression ')' | Identifier step7 语义规范 7.1. 不得声明当前作用域已经声明过的同名变量。 7.2. 声明某变量时,只要当前作用域没有同名变量,那么声明即合法。 如果更早的作用于中有同名变量,那么从此声明开始,到此声明所在作用域结束,更早的那个变量声明都被此声明覆盖。 7.3. 使用不在当前开作用域中的变量名是不合法的。 "},"docs/lab8/intro.html":{"url":"docs/lab8/intro.html","title":"任务概述","keywords":"","body":"实验指导 step8:循环语句 step8 我们要增加对循环语句,以及 break/continue 的支持: statement : 'return' expression ';' | expression? ';' | 'if' '(' expression ')' statement ('else' statement)? | compound_statement | 'for' '(' expression? ';' expression? ';' expression? ')' statement | 'for' '(' declaration expression? ';' expression? ')' statement | 'while' '(' expression ')' statement | 'do' statement 'while' '(' expression ')' ';' | 'break' ';' | 'continue' ';' 循环语句的语义和 C 的也相同,并且我们要检查 break/continue 不能出现在循环外。 "},"docs/lab8/guide.html":{"url":"docs/lab8/guide.html","title":"实验指导","keywords":"","body":"step8 实验指导 词法语法分析 如果你使用工具完成词法语法分析,修改你的语法规范以满足要求,自行修改词法规范,剩下的交给工具即可。 值得一提的是,四种循环大同小异,都可以写成 Loop(pre, cond, body, post),AST 中可以用一个统一的节点表示。 Loop(pre, cond, body, post) AST 结点表示如下的一个循环 { // pre 里面可能有声明,所以需要这个作用域 pre; // 可能是空、也可能是一个 declaration 或者 expression while (cond) { // 可能是空、也可能是一个 expression body; // body 里的 continue 会跳转到这里 post; // 是一个 expression } // break 跳转到这里 } 如果你是手写分析,TODO 名称解析 变量名相关的解析不变,但注意按照语义规范 8.2,for 要自带一个作用域。 另外,我们需要确定:每个 break 和 continue 跳转到的标号是哪个。 实现很容易,类似符号表栈维护 break 标号栈和 continue 标号栈。 遇到 Loop(...) 就(一)创建这个循环的 break 标号和 continue 标号(以及起始标号); (二)把两个标号压入各自栈里; (三)离开 Loop 的时候弹栈。 和 step6 一样,各个循环的标号需要唯一,简单地后缀一个计数器即可。 每次遇到 break 语句,其跳转目标就是 break 标号栈的栈顶,如果栈为空就报错。continue 语句类似。 IR 生成 无新增 IR。 这一阶段 Visitor 遍历 AST 时,遇到 Loop(pre, cond, body, post),生成的 IR 如 pre 的 IR label BEGINLOOP_LABEL:开始下一轮迭代 cond 的 IR beqz BREAK_LABEL:条件不满足就终止循环 body 的 IR label CONTINUE_LABEL:continue 跳到这 post 的 IR br BEGINLOOP_LABEL:本轮迭代完成 label BREAK_LABEL:条件不满足,或者 break 语句都会跳到这儿 其中 XXX_LABEL 要和上一步名称解析生成的标号名一样。 遇到 break 语句的 AST 结点时,生成一条 br BREAK_LABEL,其中 BREAK_LABEL 是名称解析确定的标号。 汇编生成 不变。 任务 改进你的编译器,支持本节引入的新特性,通过相关测试。 实验报告中回答思考题。 总结 step8 相对容易。 "},"docs/lab8/spec.html":{"url":"docs/lab8/spec.html","title":"规范","keywords":"","body":"规范 每个步骤结尾的 规范 一节都会对这个步骤中的新特性给出规范,方便大家查阅。 step8 语法规范 灰色部分表示相对上一节的修改。 program : function function : type Identifier '(' ')' compound_statement type : 'int' compound_statement : '{' block_item* '}' block_item : statement | declaration statement : 'return' expression ';' | expression? ';' | 'if' '(' expression ')' statement ('else' statement)? | compound_statement | 'for' '(' expression? ';' expression? ';' expression? ')' statement | 'for' '(' declaration expression? ';' expression? ')' statement | 'while' '(' expression ')' statement | 'do' statement 'while' '(' expression ')' ';' | 'break' ';' | 'continue' ';' declaration : type Identifier ('=' expression)? ';' expression : assignment assignment : conditional | Identifier '=' expression conditional : logical_or | logical_or '?' expression ':' conditional logical_or : logical_and | logical_or '||' logical_and logical_and : equality | logical_and '&&' equality equality : relational | equality ('=='|'!=') relational relational : additive | relational ('|'>'|'|'>=') additive additive : multiplicative | additive ('+'|'-') multiplicative multiplicative : unary | multiplicative ('*'|'/'|'%') unary unary : primary | ('-'|'~'|'!') unary primary : Integer | '(' expression ')' | Identifier step8 语义规范 为方便,我们称 for 括号中的三个表达式/声明为:init(或 pre)、ctrl、post。 例如 for (i=0; i 中,i=0 是 init,i 是 ctrl,i=i+1 是 post。 8.1. for 循环的控制表达式可以为空,表示循环条件永远为真。 8.2. for 语句自身带一个作用域,给作为 init 的声明用。 如果 for 的循环体是 block 语句块,那么循环体再带一个作用域。 因此 for (int i=0;;i=i+1) { int i=1; return i; } 是合法代码。 8.3. 在循环外使用 break 和 continue 是错误行为。 8.4. break 跳转至最近的循环的结束后第一条语句。 8.5. 如果最近的循环语句是 do 或 while,continue 跳转到执行条件判断; 如果是 for,continue 跳转到执行 post(然后再计算循环条件、结束/继续循环……)。 因此 for (int i=0;i 就等于 for (int i=0;i "},"docs/lab9/intro.html":{"url":"docs/lab9/intro.html","title":"任务概述","keywords":"","body":"实验指导 step9:函数 step9 开始,我们要支持多函数了。 我们需要支持函数的声明和定义: program : function* function : type Identifier '(' parameter_list ')' (compound_statement | ';') parameter_list : (type Identifier (',' type Identifier)*)? 我们还需要支持函数调用: expression_list : (expression (',' expression)*)? unary : postfix | ('-'|'~'|'!') unary postfix : primary | Identifier '(' expression_list ')' 语义检查部分,我们需要检查函数的重复定义、检查调用函数的实参(argment)和形参(parameter)的个数类型一致。 我们不支持 void 返回值,直接忽略 int 返回值即可。 "},"docs/lab9/guide.html":{"url":"docs/lab9/guide.html","title":"实验指导","keywords":"","body":"step9 实验指导 词法语法分析 如果你使用工具完成词法语法分析,修改你的语法规范以满足要求,自行修改词法规范,剩下的交给工具即可。 如果你是手写分析,TODO 名称解析 类似符号表,我们需要一张表维护函数的信息。 当然,函数不会重名,所以不用解析名称。 这张表主要目的是记录函数本身的信息,方便语义检查。 就 step9 而言,这个信息包括 参数个数(step11 开始还需要记录参数和返回值类型)。对应的语义检查:9.4 是否已经有定义,还是只有声明。对应的语义检查:9.2 IR 生成 step9 之前因为只有一个函数,所以一个 MiniDecaf 程序的 IR 就只是一个指令序列。 现在有了函数了,一个 MiniDecaf 程序的 IR 应当包含一系列 IR 函数,源代码中每个函数都对应一个 IR 函数。 而一个 IR 函数 需要包含 函数自身的信息:函数名、需要 prologue 中分配的“栈帧”大小 3 等; 函数体对应的 IR 指令序列。 对于函数声明和定义,IR 生成的 Visitor 遍历 AST 时, 函数声明结点:没有函数体,无须生成任何 IR 函数定义结点:继续遍历子结点,拿到上面的两种信息,然后创建一个 IR 函数 函数调用是一个比较复杂的操作,见 这里。 为了支持它,我们需要引入 call 指令,并且修改 ret 指令让它不要把栈顶返回值弹出。 指令 参数 含义 IR 栈大小变化 call 一个字符串表示函数名 调用作为参数的函数1,调用完后栈顶是 callee 的返回值 增加 12 ret 无参数 (返回值已经在栈顶了)终止当前函数执行,返回 caller 不变 汇编生成 ret 的汇编不变,call 的如下表。 | IR | 汇编 | | --- | --- | | call FUNC | call FUNC,然后有几个参数就执行几次 pop,然后 addi sp, sp, -4 ; sw a0, 0(sp) | 我们已经在 IR 处理了传参,所以汇编生成时不用再考虑传参。 如果你采用非标准的调用约定,prologue 和 epilogue 也不用改,也不用处理 caller-save 寄存器。 否则你可能还需要增加 caller/callee-save 寄存器保存与恢复的代码。 另外,虽然语义规范说是未定义行为,但除了 main 其他函数也可以默认返回 0。 (其实这样做反而实现更简便清楚) 总结 引入了概念 调用约定,并且描述了栈帧的变化。 备注 1. call 指令不包含准备参数。 ↩ 2. call 的变化是指,整个 callee 执行完成返回 call 指令后,IR 栈大小相对执行 call 前的大小变化。 ↩ 3. 这个栈帧加了引号,因为它没有包含运算栈 ↩ "},"docs/lab9/calling.html":{"url":"docs/lab9/calling.html","title":"函数调用","keywords":"","body":"函数调用 函数调用是最复杂的一种表达式结构了。 源代码里的一个函数调用,其实包含了下面几个步骤 准备参数,完成传参 (汇编)保存 caller-save 寄存器 真正执行 call 指令(汇编上是 jalr 指令) 执行 call 然后是子函数执行的时间, 直到子函数 ret(汇编上是 jr ra)返回 (汇编)恢复 caller-save 寄存器 拿到返回值,作为函数调用这个表达式的值 这几步操作有时又被称为调用序列(calling sequence) 上面几步都需要我们确定 调用约定(calling convention): (第 1.、5. 步)参数和返回值都如何准备、该放哪儿? (第 2.、4. 步)哪些寄存器是 caller-save 的? (在 prologue/epilogue 中)那些寄存器是 callee-save 的? 调用约定通常是在汇编层级用到的,汇编语言课上也讲过。 因为汇编语言很底层,没有函数/参数的语言支持,只有标号/地址/寄存器,所以需要规定如何用汇编的语言机制模拟函数调用。 我们为了简单,IR 不提供对函数的语言支持,所以我们同样需要有 IR 的调用约定。 需要注意的是,调用约定只是一种约定,它不唯一。 x86 上常见的就有默认的 cdecl(汇编课讲过)、stdcall、fastcall 等好几种。 只要 caller 和 callee 的调用约定相同,那么函数调用就不会出问题。 RISC-V 的调用约定 32 位 RISC-V 的标准(指 gcc 使用的)的调用约定中,和我们相关的是: caller-save 和 callee-save 寄存器在 \"Unprivileged Spec\" 的 109 页。 返回值(32 位 int)放在 a0 寄存器中 参数(32 位 int)从左到右放在 a0、a1……a7 中。如果还有,则从右往左压栈,第 9 个参数在栈顶。 自己使用 gcc 编译一个有很多参数的函数调用即可验证。 为了简便和方便描述,我们下面使用一种非标准的调用约定。 callee 只需要保存 fp 和 ra,caller 无须保存寄存器 callee 把返回值放在 a0 中,caller 看到返回之后把返回值压入运算栈 参数不用寄存器传递,所有参数从右往左压栈,第 1 个参数在栈顶。 这个调用约定的优点是叙述和实现简单,但不标准。 你当然可以选择实现标准的调用约定,这样你的汇编能够和 gcc 的汇编互相调用。 采用这个非标准的调用约定,仿照 step5 我们可以画出函数调用过程中栈帧的变化图。 可见现在栈帧包含四块,从顶向下依次是运算栈、实参、局部变量、fp 和 ra(下图 1.)。 其中还有一个问题就是形参的处理,例如上面 3. 到 4. 过程中,bar 要访问 a,那 a 放在哪儿? 可以直接使用 foo 栈帧上的实参,那么 a 相对 fp 的偏移量为 0,同理 b 偏移量为 4。 因此 step7 中的偏移量计算方法仅限非参数的局部变量,而第 k>=0 个参数相对于 fp 的偏移量是 4*k。 还有一种方法是把参数当成普通局部变量,在 prologue 中复制到栈帧中局部变量的区域。 IR 的调用约定 对于 IR 类似上面简便的约定: 传参从右到左压栈 返回值放在栈顶 另外,IR 也需要保存返回地址,如果它要作为一门独立的语言,需要被执行的话。 但我们暂时没有这个需求,可以不管它。可以假设 call 指令会把返回地址保存到其他地方,并且同时把当前栈帧设为新函数的。 汇编的 call(就是 jalr)会保存返回地址到 ra,然后 prologue 里会保存 ra 到 callee 栈帧中。 "},"docs/lab9/spec.html":{"url":"docs/lab9/spec.html","title":"规范","keywords":"","body":"规范 每个步骤结尾的 规范 一节都会对这个步骤中的新特性给出规范,方便大家查阅。 step9 语法规范 灰色部分表示相对上一节的修改。 program : function* function : type Identifier '(' parameter_list ')' (compound_statement | ';') type : 'int' parameter_list : (type Identifier (',' type Identifier)*)? compound_statement : '{' block_item* '}' block_item : statement | declaration statement : 'return' expression ';' | expression? ';' | 'if' '(' expression ')' statement ('else' statement)? | compound_statement | 'for' '(' expression? ';' expression? ';' expression? ')' statement | 'for' '(' declaration expression? ';' expression? ')' statement | 'while' '(' expression ')' statement | 'do' statement 'while' '(' expression ')' ';' | 'break' ';' | 'continue' ';' declaration : type Identifier ('=' expression)? ';' expression_list : (expression (',' expression)*)? expression : assignment assignment : conditional | Identifier '=' expression conditional : logical_or | logical_or '?' expression ':' conditional logical_or : logical_and | logical_or '||' logical_and logical_and : equality | logical_and '&&' equality equality : relational | equality ('=='|'!=') relational relational : additive | relational ('|'>'|'|'>=') additive additive : multiplicative | additive ('+'|'-') multiplicative multiplicative : unary | multiplicative ('*'|'/'|'%') unary unary : postfix | ('-'|'~'|'!') unary postfix : primary | Identifier '(' expression_list ')' primary : Integer | '(' expression ')' | Identifier step9 语义规范 9.1. main 默认返回 0,但如果其他函数也没有 return,则它们的返回值未定义。 如果程序尝试使用这个未定义的返回值,那么产生一个未定义行为。 当然,如果程序忽略它,那是合法的。 我们没有支持 void 返回值,但可以忽略返回值达到类似的效果。 9.2. 每个函数只能被定义一次,在定义之前可能有一次前置声明。 多次声明一个函数、以及定义后再声明,均是未定义行为。 多次定义一个函数是错误。 9.3. 函数声明和定义的参数个数、同一位置的参数类型、以及返回值类型必须相同。 现在只有 int 类型,不过以后会有更多类型。 9.4. 调用某函数时,实参和形参的参数个数必须相同,同一位置的参数类型也必须相同。 "},"docs/lab10/part0-intro.html":{"url":"docs/lab10/part0-intro.html","title":"摘要","keywords":"","body":"labX 介绍 "},"docs/lab10/part1-parser.html":{"url":"docs/lab10/part1-parser.html","title":"词法语法分析","keywords":"","body":"词法语法分析 本步骤引入了全局变量的概念。这意味着你可以将变量声明和初始化放在函数外进行,在本步骤中你将修改之前的文法规定,支持全局变量的声明和初始化。 注:因词法分析部分需要解释的东西较少,我们将词法和语法分析合为一个文件,但需要注意的是,它们是相互独立的操作流程。 词法分析 本步骤无新增 Token 定义,目前的 Token 列表为: { } ( ) ; int return Identifier [a-zA-Z_][a-zA-Z0-9_]* Integer literal [0-9]+ - ~ ! + * / && || == != >= = if else : ? for while do break continue , 语法分析 本步骤新增了对于全局变量的支持,因此现在位于最顶层的声明有函数声明和变量声明两种,之前的顶层声明需要加入对变量声明的支持,我们修改文法如下: ::= { } // ::= { | } // 在修改了文法定义之后,我们的顶层 AST 会发生一些变化: // 修改前 AST program = Program(function_declaration list) // 修改后 AST toplevel_item = Function(function_declaration) | Variable(declaration) toplevel = Program(toplevel_item list) program = Program(function_declaration list) "},"docs/lab10/part1-1-task.html":{"url":"docs/lab10/part1-1-task.html","title":"任务","keywords":"","body":"☑任务: 词法分析 本次实验涉及到了全局变量的定义,没有新的 Token 出现,因此无需修改词法分析部分。 语法分析 更新你的parse函数,使其可以为所有有效的step10测试用例建立正确的AST,并保证之前的测试用例不被影响。 "},"docs/lab10/part4-codegen.html":{"url":"docs/lab10/part4-codegen.html","title":"代码生成","keywords":"","body":"代码生成 全局变量需要保存在内存中的某个地方。它们不能被保存在栈上,因为栈可认为是一个函数的私有数据,其他函数不能进行访问。因此它们需要被保存在一块公共的内存中,即数据段(data section)。我们知道一个程序在运行时有一个自己的地址空间,并被划分为了多段内存区域,不同的段有着不同的读、写、可执行权限,下图给出了不同内存段的布局: 我们之前一直在处理的 RISC-V 指令都在代码段(text section),具有可执行权限,并且是只读的;而全局变量则在数据段(data section),一般具有可读写权限,而无可执行权限。我们可将其进一步细分为初始化、未初始化、以及只读的数据段,其中未初始化的数据段通常称为 BSS,会在程序加载时被初始化为 0;只读数据段(rodata)用于保存程序中定义的常量,在本实验中不会用到。 在编写自己的汇编码生成器之前,我们先来看看 GCC 是如何生成全局变量的代码的。 int N = 2333; int main() { return N; } 使用以下命令编译出汇编代码: $ riscv64-unknown-elf-gcc test.c -O3 -S -o test.S $ cat test.S .file \"test.c\" .option nopic .text .section .text.startup,\"ax\",@progbits .align 1 .globl main .type main, @function main: lui a5,%hi(N) lw a0,%lo(N)(a5) ret .size main, .-main .globl N .section .sdata,\"aw\" .align 2 .type N, @object .size N, 4 N: .word 2333 .ident \"GCC: (SiFive GCC 8.2.0-2019.05.3) 8.2.0\" 全局变量定义 从上述结果中,很容易找到全局变量 N 的定义部分,去掉一些无关内容并简化后如下: .data # 即 .section .sdata,\"aw\",表示接下来是数据段,内容可写 .globl N # 让符号 N 对链接器可见 .align 2 # 接下来的数据需要 4 字节对齐 N: .word 2333 # 在数据段分配一个字(4 字节)大小的整数,值为 2333 这里有几件事要注意: .data 指示符告诉汇编器我们在数据段。我们还需要一个 .text 指示符来告诉我们何时切换回代码段。 像 N 这样的标签可以标注一个内存地址。汇编器和链接器并不关心这个地址是指代码段的指令还是数据段的变量,它们会以同样的方式处理它。 .align n 的意思是“下一个东西的起始地址应为 2n2^n2n 字节的倍数”。 有关 RISC-V 汇编指示符(directives)的更多内容 ,详见 https://github.com/decaf-lang/minidecaf/blob/master/doc/riscv-assembly-directives.md 在具体实现时,我们每遇到一个全局变量,就生成类似以上的代码。对于未初始化的全局变量,简单起见我们无需考虑 BSS 段,直接将其初始化为 0 即可。 对于全局变量的初值,需要在编译时就进行确定,因此其初始化器需要是一个常量表达式。为了方便,我们的测试集只包含该常量表达式是一个整数的情况,你可以无需计算表达式。 全局变量引用 从 GCC 的结果中我们也可以得到引用一个全局变量的方法: lui a5,%hi(N) # 将 N 地址的高 20 位作为立即数加载到 a5,低 12 位设为 0 lw a0,%lo(N)(a5) # 从内存中读出数据保存到 a0,内存地址为 a5 加上 N 地址的低 12 位 注意到这里用了两条指令,而不是类似 lw a0, 0(N) 的一条,这是因为 N 在这里是个标签,其实际值是一个 32 位的地址,这超过了一条长度为 4 字节的指令的表示能力。 对于给全局变量赋值的情况也类似,只不过是把 lw 换成了 sw。 "},"docs/lab10/part4-1-task.html":{"url":"docs/lab10/part4-1-task.html","title":"任务","keywords":"","body":"☑任务: 更新汇编代码生成过程,以正确处理全局变量的定义与引用。并通过 step[1-10] 的测试用例。 ☑任务(可选): 将未初始化的全局变量放到 BSS 段。 ☑任务(可选): 大多数编译器允许用常量表达式来初始化全局变量,比如: int foo = 2 + 3 * 5; 这需要你在编译时就算出 2 + 3 * 5 的值。改进你的编译器以支持这一点。 "},"docs/lab10/summary.html":{"url":"docs/lab10/summary.html","title":"小结","keywords":"","body":"小结 "},"docs/lab10/spec.html":{"url":"docs/lab10/spec.html","title":"规范","keywords":"","body":"规范 每个步骤结尾的 规范 一节都会对这个步骤中的新特性给出规范,方便大家查阅。 step10 语法规范 灰色部分表示相对上一节的修改。 program : (function | declaration)* function : type Identifier '(' parameter_list ')' (compound_statement | ';') type : 'int' parameter_list : (type Identifier (',' type Identifier)*)? compound_statement : '{' block_item* '}' block_item : statement | declaration statement : 'return' expression ';' | expression? ';' | 'if' '(' expression ')' statement ('else' statement)? | compound_statement | 'for' '(' expression? ';' expression? ';' expression? ')' statement | 'for' '(' declaration expression? ';' expression? ')' statement | 'while' '(' expression ')' statement | 'do' statement 'while' '(' expression ')' ';' | 'break' ';' | 'continue' ';' declaration : type Identifier ('=' expression)? ';' expression_list : (expression (',' expression)*)? expression : assignment assignment : conditional | Identifier '=' expression conditional : logical_or | logical_or '?' expression ':' conditional logical_or : logical_and | logical_or '||' logical_and logical_and : equality | logical_and '&&' equality equality : relational | equality ('=='|'!=') relational relational : additive | relational ('|'>'|'|'>=') additive additive : multiplicative | additive ('+'|'-') multiplicative multiplicative : unary | multiplicative ('*'|'/'|'%') unary unary : postfix | ('-'|'~'|'!') unary postfix : primary | Identifier '(' expression_list ')' primary : Integer | '(' expression ')' | Identifier step10 语义规范 10.1. 全局变量可以被初始化,但初始值(initializer)只能是整数字面量。 10.2. 如果没有显式给出初始值,全局变量初始值为 0。 10.3. 不允许重复声明全局变量。 "},"docs/lab11/part0-intro.html":{"url":"docs/lab11/part0-intro.html","title":"摘要","keywords":"","body":"labX 介绍 "},"docs/lab11/typeck.html":{"url":"docs/lab11/typeck.html","title":"类型检查","keywords":"","body":"类型检查 …… 类型检查是语义检查 …… 不能 int + int …… …… 类型上下文:变量 -> 类型的映射 …… …… 类型规则:参数类型有哪些 -> 返回值类型 …… "},"docs/lab11/part4-codegen.html":{"url":"docs/lab11/part4-codegen.html","title":"代码生成","keywords":"","body":"代码生成 本阶段代码生成的重点是处理 * 和 & 这两个运算符,而对于类型系统和强制类型转换属于语义检查,无需生成代码。 首先,对于 * 运算,只需一条 Load 指令即可完成解引用这一操作。但对于取地址符 & 就不是很直观了,因为 & 号后面可能不会立即跟一个变量如 &a,而是会有形如 &*p 这样的,虽然两个操作可以相互抵消,但如果将其解释为“先解引用,再取地址”,就难以生成对应的汇编代码,因为变量的地址信息会在解引用后丢失。此外,对于像 * 号出现在赋值语句左边的情况,也不是很好处理。因此,在讨论如何生成代码之前,我们先来讨论一下“左值”的概念。 左值 我们都知道,赋值语句可以写成 a = 123 的形式,而不能是 123 = a,这是因为赋值号左边的是一个左值(lvalue),要求与一个地址相关联;而右边的 123 是一个右值(rvalue),并不需要关联一个地址。通俗地讲,左值是一个有地址的值,例如: int a; int* p; a = 1; // a 是左值 *&a = 2; // *&a 是左值 p = &a; // p, a 是左值 *p = 3 + a; // *p 是左值 从上述代码可以看出,赋值号左边的以及 & 后面的都要求是左值,需要知道它们的地址才能进行计算;而像几个常量和最后一行的 a 这样的是右值,只需知道其值就行了。 有了左值的概念,就比较容易处理本节一开始提到的哪些问题了。例如对于 &a 和 &*p,可看做求左值 a 和 *p 的地址(不考虑空指针,*p 的地址就是 p 的值),对于赋值 *p = 1,可以看做先求左值 *p 的地址,再向该地址写入 1。 因此,在生成代码的过程中,对于左值,我们不能生成其具体的值,而要先生成它们的地址。对于一个表达式,我们需要先确定那些部分是左值,并求得它们的地址作为中间结果,然后才能生成正确的代码。通过观察,我们可以归纳出左值的判定方法: 出现在赋值号 = 的左边,或是取地址符 & 的右边; 是一个标识符,或是 * 开头的表达式((*p) 等有括号的形式也算)。 在建立了 AST 后,我们可以很容易求出哪些节点是左值,可将其作为节点的属性,以供之后使用。 此外,我们还需要进行左值检查,以拒绝编译形如 123 = a 的错误输入。根据上述左值的判定方法,如果表达式的某一部分满足第 1 条,但不满足第 2 条,就是不合法的。 基于左值的代码生成框架 现在,我们假设已经求出了 AST 的哪些节点是左值。在之后的遍历 AST 生成汇编码时,只需对左值生成地址,对右值生成值。在我们的文法中,左值一定出现在 非终结符,其文法为: ::= Identifier | '*' | '&' | ... 下面给出了用于生成汇编码的伪代码: function visitFactor(ctx) { if (ctx ::= Ident) { // ::= Identifier emitAddress(Ident); // 计算变量 Ident 的地址 if (ctx.isLValue) { // do nothing // 如果是左值,直接返回其地址 } else { emitLoad(); // 否则,再生成一条 Load 指令来取得变量的值 } } else if (ctx ::= '*' factor) { // ::= '*' visitFactor(factor); // 访问子 factor 计算其值 if (ctx.isLValue) { // do nothing // 如果是左值,直接返回该值 } else { emitLoad(); // 否则,再生成一条 Load 指令来解引用 } } else if (ctx ::= '&' factor) { // ::= '&' visitFactor(factor); // 子 factor 一定是左值,直接访问以得到其地址 } else { // ... } } 生成左值的地址 如上一节所述,左值只可能出现在两种地方: 标识符:地址即该变量的地址; 解引用符 * 之后:地址即之后那部分的值; 第二种情况可直接忽略,我们只需考虑如何计算一个变量的地址。在上一个 lab 我们引入了全局变量,所以变量可分为两种: 局部变量:保存在栈上,使用 lw t0, offset(fp) 指令获取其值。由于我们使用栈来传参,所以参数也可认为是局部变量。 全局变量:保存在数据段,使用 lui t1, %hi(N); lw t0, %lo(N)(t1) 指令获取其值。 要从取值变成取地址,只需将 lw 改成 addi: addi t0, fp, offset # 获取局部变量的地址,保存到 t0 lui t1, %hi(N) addi t0, t1, %lo(N) # 获取全局变量 N 的地址,保存到 t0 之后,可再用一条 Load 或 Store 指令来实现获取变量的值或给变量赋值: lw t1, 0(t0) # 读取内存地址 t0 中的内容到 t1 sw t1, 0(t0) # 将 t1 写入内存地址 t0 赋值语句 最后,我们还需要修改赋值语句的生成过程。在之前的语法分析阶段,赋值语句的文法已经改为了 - ::= Identifier \"=\" | + ::= \"=\" | 其中要求 是一个左值。 我们在之前生成赋值语句时是这么做的: 生成 expr 的代码 # 结果存在 t0 sw t0, offset(fp) # offset 为变量在栈帧中的位置 现在,已经没有变量了,而是变成了左值。我们就先生成计算左值地址的代码,然后再生成一条 Store 指令即可: 生成 factor 的代码 # 结果(左值的地址)存在 t0 生成 expr 的代码 # 结果存在 t1 sw t1, 0(t0) # 将 t1 写入内存地址 t0 "},"docs/lab11/part4-1-task.html":{"url":"docs/lab11/part4-1-task.html","title":"任务","keywords":"","body":"☑任务: 更新汇编代码生成过程,以正确处理指针运算符。并通过 step[1-11] 的测试用例。 "},"docs/lab11/summary.html":{"url":"docs/lab11/summary.html","title":"小结","keywords":"","body":"小结 "},"docs/lab11/spec.html":{"url":"docs/lab11/spec.html","title":"规范","keywords":"","body":"规范 每个步骤结尾的 规范 一节都会对这个步骤中的新特性给出规范,方便大家查阅。 step11 语法规范 灰色部分表示相对上一节的修改。 program : (function | declaration)* function : type Identifier '(' parameter_list ')' (compound_statement | ';') type : 'int' | type '*' parameter_list : (type Identifier (',' type Identifier)*)? compound_statement : '{' block_item* '}' block_item : statement | declaration statement : 'return' expression ';' | expression? ';' | 'if' '(' expression ')' statement ('else' statement)? | compound_statement | 'for' '(' expression? ';' expression? ';' expression? ')' statement | 'for' '(' declaration expression? ';' expression? ')' statement | 'while' '(' expression ')' statement | 'do' statement 'while' '(' expression ')' ';' | 'break' ';' | 'continue' ';' declaration : type Identifier ('=' expression)? ';' expression_list : (expression (',' expression)*)? expression : assignment assignment : conditional | unary '=' expression conditional : logical_or | logical_or '?' expression ':' conditional logical_or : logical_and | logical_or '||' logical_and logical_and : equality | logical_and '&&' equality equality : relational | equality ('=='|'!=') relational relational : additive | relational ('|'>'|'|'>=') additive additive : multiplicative | additive ('+'|'-') multiplicative multiplicative : unary | multiplicative ('*'|'/'|'%') unary unary : postfix | ('-'|'~'|'!'|'&'|'*') unary | '(' type ')' unary postfix : primary | Identifier '(' expression_list ')' primary : Integer | '(' expression ')' | Identifier step11 语义规范 11.1. 左值表达式除了能通过 5.5 中两条规则得到,新增一条规则: 如果 e 是类型为 T* 的表达式,那么 *e 是类型为 T 的左值。 因此 int a; *&a=2; 中 *&a 是左值。 11.2. step11 中类型只有 int 和指针类型。禁止隐式类型转换,但允许显式类型转换,只要不违反其他几条规范。 11.3. & 的操作数必须是左值。 所以 &*e 等价于 e,但它不是左值了 11.4. * 的操作数类型必须是指针类型。 11.5. 指针类型的表达式仅能参与如下运算:类型转换、(一元)&、*、(二元)==、!=。 指针不得参与乘除模和、一元、比较大小、逻辑运算。step12 会支持算术。 11.6. 空指针是值为 0 的指针。 因为禁止隐式类型转换,所以空指针字面量必须由 0 显示转换而来,例如 (int*) 0。 判断空指针类似:if (p == (int**)0) ; 或 if ((int)p = 0) ;。 11.7. 未对齐的指针是未定义行为。就 step11 而言,指针必须对齐到 4 字节边界。 11.8. 只要指针类型和被指向的对象的类型不匹配,空指针除外,就是未定义行为,哪怕没有解引用。 所以 int a; int *p = (int*)a; 包含了未定义行为。 "},"docs/lab12/part0-intro.html":{"url":"docs/lab12/part0-intro.html","title":"摘要","keywords":"","body":"labX 介绍 "},"docs/lab12/part4-codegen.html":{"url":"docs/lab12/part4-codegen.html","title":"代码生成","keywords":"","body":"代码生成 数组定义 数据的内存空间是连续的,因此无论数组的原型是几维的,都可以看做是一个一维的大数组。例如,对于一个数组 int a[d1][d2]⋯[dn]\\mathtt{int}~a[d_1][d_2]\\cdots[d_n]int a[d1][d2]⋯[dn],可看做是 int a′[d1d2⋯dn]\\mathtt{int}~a'[d_1d_2\\cdots d_n]int a′[d1d2⋯dn]。访问 a[i1][i2]⋯[in]a[i_1][i_2]\\cdots[i_n]a[i1][i2]⋯[in],就是访问 a′[i1d2d3⋯dn+i2d3d4⋯dn+⋯+in]a'[i_1d_2d_3\\cdots d_n + i_2d_3d_4\\cdots d_n + \\cdots + i_n]a′[i1d2d3⋯dn+i2d3d4⋯dn+⋯+in]。 我们需要考虑定义数组时,是作为局部变量还是全局变量: 当某个局部变量定义为数组时,数组空间分配在栈上。这与普通变量的定义基本没区别,同样需要算出在栈帧上的偏移量,只不过大小不再是固定的 4 字节,而是数组的元素个数再乘 4 (字节/元素),因此计算其他变量的偏移量时也要做相应修改。由于不支持数组的初始化,分配完栈帧后不用管它就行,即使这段栈空间保留了之前变量的信息,因此数组还有其他未初始化的局部变量的初值都是不确定的。 当某个全局变量定义为数组时,数组空间分配在程序的数据段。同样我们不考虑 BSS 段,直接将其放到 .data 中。作为全部变量时,数组中每个元素默认初始化为 0,可用如下汇编码来将连续一段内存初始化为 0: .align 2 a: .zero 400 其中 .zero 后面的数字即这段内存的大小(单位字节)。上述汇编码可由定义在全局的 int a[100]; 或 int a[10][10]; 生成。 在 RISC-V 指令集中,addi、lw、sw 等 I 型和 S 型指令的立即数大小只有 12 位,因此我们不能定义一个很大的数组作为局部变量,否则那些分配栈帧、Load/Store 局部变量的指令的偏移量就会超出 12 位。你可以实现对这种情况的特殊处理,使用其他指令进行代替,不过我们的测试集保证了不会出现这种情况。 下标运算 指针下标 我们允许对指针和数组类型进行下标运算。首先来看指针的下标运算。指针的下标运算非常类似于解引用运算 *,只是需要加上一个偏移,大小为下标乘上指针基类型的大小,即 p[i] 等价于 *(p + i * size)。另外需要注意,我们在 lab11 中提到了左值的概念,* 运算后的结果可以是左值,类似地,指针的下标运算也可以是左值: int *p; p[0] = 1; // p[0] 是左值 int a = p[1]; // p[1] 是右值 int *q = &p[2]; // p[2] 是左值 于是,我们按照实现解引用运算的方法,即可实现指针的下标运算。 数组下标 然后再来看数组的下标运算。数组的下标运算可以分为两类: 当下标运算的次数等于数组的维度时,结果是数组中的元素。这种情况与指针的下标运算类似,可以作为右值,结果是数组中元素的值;也可以作为左值,结果是数组中元素的地址。 当下标运算的次数小于数组的维度时,结果是一个数组。由于任何数组可看做一个一维大数组,此时的结果也可以看做是原数组中的一个子数组。当之后对该结果进行转指针、取下标等运算时,都是相对于这个子数组来说的。之后要取子数组中的哪个元素现在还是未知的,因此无论之后是否有可能成为左值,这一步都应该返回子数组的地址。 对于第 1 种情况,与指针的下标运算类似处理;对于第 2 种情况,关键是要求出子数组的地址。由于下标运算在文法中是递归定义的,我们可以自然地得出地址的计算方法: 对于数组类型的变量 Ident,地址即 Ident 在栈或数据段的地址; 对于子数组 array[i],地址是 array 的地址加上 i * sizeof(array[i])。 例如,对于数组 int a[3][4][5],有: a[i] 的地址是 a + (i * 4 * 5) * sizeof(int); a[i][j] 的地址是 a + [(i * 4 * 5) + (j * 5)] * sizeof(int); a[i][j][k] 的地址是 a + [(i * 4 * 5) + (j * 5) + k] * sizeof(int)。 总结一下,无论是指针还是数组,无论是左值还是右值,我们都可以用同一套代码框架来生成汇编码: function visitSubscript(ctx) { // ::= '[' expr ']' visitPostfix(postfix); // 计算要取下标的表达式的值,设结果保存在 t0 visitExpr(expr); // 计算下标的值,设结果保存在 t1 emitMul(\"t1\", \"t1\", ctx.type.size); // t1 = t1 * sizeof(结果的类型) emitAdd(\"t0\", \"t0\", \"t1\"); // t0 = t0 + t1,此时的结果即下标运算后子数组或元素的地址 if (ctx.type == Array || ctx.isLValue) { // do nothing // 如果结果是数组类型,或是左值,直接返回其地址 } else { emitLoad(); // 否则,再生成一条 Load 指令来取出该地址中保存的值 } } function visitPrimary(ctx) { if (ctx ::= Ident) { // ::= Identifier emitAddress(Ident); // 计算变量 Ident 的地址 if (ctx.type == Array || ctx.isLValue) { // do nothing // 如果变量是数组类型,或是左值,直接返回其地址 } else { emitLoad(); // 否则,再生成一条 Load 指令来取得变量的值 } } else { // ... } } 指针算术运算 这一步非常简单,我们要支持的所有指针算术运算就这么多: 指针加整数,或整数加指针:只需增加一步,将整数操作数乘上指针基类型的大小。 指针减整数:与指针加法一样,只需增加一步,将整数操作数乘上指针基类型的大小。 指针减指针:先直接做减法,然后除以指针基类型的大小(要求两指针基类型相同)。 由于指针的基类型只能是 int 或指针,其大小都是 4 字节,你可以用 slli、srai 指令进行左移、右移来代替乘除运算。 "},"docs/lab12/part4-1-task.html":{"url":"docs/lab12/part4-1-task.html","title":"任务","keywords":"","body":"☑任务: 更新汇编代码生成过程,以正确处理数组类型、下标运算以及指针算术运算。并通过 step[1-12] 的测试用例。 恭喜你通过自己的努力,完成了 minidecaf lab1-lab12,从零开始实现了一个自己的编译器! "},"docs/lab12/summary.html":{"url":"docs/lab12/summary.html","title":"小结","keywords":"","body":"小结 "},"docs/lab12/spec.html":{"url":"docs/lab12/spec.html","title":"规范","keywords":"","body":"规范 每个步骤结尾的 规范 一节都会对这个步骤中的新特性给出规范,方便大家查阅。 step12 语法规范 灰色部分表示相对上一节的修改。 program : (function | declaration)* function : type Identifier '(' parameter_list ')' (compound_statement | ';') type : 'int' | type '*' parameter_list : (type Identifier (',' type Identifier)*)? compound_statement : '{' block_item* '}' block_item : statement | declaration statement : 'return' expression ';' | expression? ';' | 'if' '(' expression ')' statement ('else' statement)? | compound_statement | 'for' '(' expression? ';' expression? ';' expression? ')' statement | 'for' '(' declaration expression? ';' expression? ')' statement | 'while' '(' expression ')' statement | 'do' statement 'while' '(' expression ')' ';' | 'break' ';' | 'continue' ';' declaration : type Identifier ('[' Integer ']')* ('=' expression)? ';' expression_list : (expression (',' expression)*)? expression : assignment assignment : conditional | unary '=' expression conditional : logical_or | logical_or '?' expression ':' conditional logical_or : logical_and | logical_or '||' logical_and logical_and : equality | logical_and '&&' equality equality : relational | equality ('=='|'!=') relational relational : additive | relational ('|'>'|'|'>=') additive additive : multiplicative | additive ('+'|'-') multiplicative multiplicative : unary | multiplicative ('*'|'/'|'%') unary unary : postfix | ('-'|'~'|'!'|'&'|'*') unary | '(' type ')' unary postfix : primary | Identifier '(' expression_list ')' | postfix '[' expression ']' primary : Integer | '(' expression ')' | Identifier step12 语义规范 12.1. 支持多维数组,但每一维长度只能是正整数常数,不能是零或负数。 所以也没有变长数组 int a[n]; 也没有不定长数组 int a[];。 12.2. 对数组取地址是错误,也不会有指向数组的指针。 这是为了简化实验,否则需要引入 C 中一堆繁复的记号,像 int *a[10] 和 int (*a)[10],对于实验意义不大。 12.3. 数组声明不能有初始值。局部变量数组初始值未定,全局变量初始值为零。 C 中可以写 int a[2]={1, 2} 但 MiniDecaf 不行。 12.4. 数组首地址对齐要求同元素的对齐要求(4 字节)。 数组的各个元素在内存中是连续的,并且多维的情况下排在前面的维度优先。 例如 int a[3][4][5] 占用了 60 个 int(240 字节)的连续内存,和 int b[60] 一样。 a[1][2][3] 的偏移量是 (1*20+2*5+3) * 4 字节,和 b[33] 一样。 12.5. 下标运算优先级高于一元运算符。 因此 -a[0] 即 -(a[0])。 12.6. 下标运算 a[b] 的操作数类型必须是:a 为指针或数组,b 为 int。 12.7. 下标运算越界是未定义行为。 12.8. 可以将数组的前几维单独提出,类型还是数组类型,可转换以后赋给一个指针。 例如 int a[2][2][2],那么 int *p = (int*) a[0]; int *q = (int*) a[1][1]; 是合法的。 但 int x=a[1] 和 int *r=a[0][0][0] 是不合法的。 12.9. 左值表达式除了能通过 11.1 中三条规则得到,新增一条规则: 通过下标运算,如果结果类型不是数组类型,那么结果表达式是左值。 例如 int a[2][2],那么 a[1] 不是左值,但 a[1][1] 是左值,a[1][1]=2 和 &a[1][1] 都是合法操作。 12.10. 数组类型的表达式仅能参与如下运算:类型转换、下标。 12.11. 函数形参不能被声明为数组,传参只能传指针。 C 允许 int foo(int a[]); 和 int bar(int b[5]); 但我们不允许。 12.12. (更新 11.5)指针可参加加减运算,允许 int 加指针和指针加 int,以及指针减 int(不允许 int 减指针)。 运算结果的类型和指针操作数的类型相同。 实际运算时,int 操作数需要乘上指针基类型的大小。 例如 int **p(考虑到 sizeof(int*) == 4),那么 p-2 等价于 p+-2,其汇编类似 addi result, p, -8。 12.13.(更新 11.5)允许两个指针相减,但两个指针类型必须相同。 a 和 b 是同类型的指针,那么 a-b 的结果 c 是一个 int,且满足 a == b+c。 12.14. 空指针参与任何指针算术都是未定义行为。 12.15. 指针运算越界,按照 11.8 是未定义行为。但有特例:允许越界一个元素。 例如如下代码是合法的 int a[10]; int *p=(int*) a; for (int *q=p; q != p+10; q=1+q) *q=0; 哪怕其中 p+10 已经越界了。 "},"REFERENCE.html":{"url":"REFERENCE.html","title":"参考资料","keywords":"","body":"参考资料 Writing a C Compiler: by Nora Sandler An Incremental Approach to Compiler Construction : by Abdulaziz Ghuloum "}}