├── docs ├── .nojekyll ├── misc-app-ref │ ├── why.md │ ├── judging-1.png │ ├── judging-2.png │ ├── examples.md │ ├── README.md │ ├── references.md │ ├── oj.md │ ├── sysy-runtime.md │ ├── riscv-insts.md │ ├── environment.md │ └── sysy-spec.md ├── lv9p-reincarnation │ ├── go-further.md │ ├── maze.png │ ├── mandelbrot.png │ ├── perf-testing.md │ ├── README.md │ ├── awesome-compiler.md │ ├── opt.md │ ├── reg-alloc.md │ └── ssa-form.md ├── assets │ ├── icons │ │ ├── favicon.ico │ │ └── apple-touch-icon-precomposed-152.png │ ├── css │ │ └── main.css │ └── js │ │ ├── prism-koopa.js │ │ ├── sidebar.js │ │ └── giscus.js ├── lv4-const-n-var │ ├── riscv-stack-frame.png │ ├── example-stack-frame.png │ ├── testing.md │ ├── const.md │ ├── README.md │ └── var-n-assign.md ├── lv8-func-n-global │ ├── call-with-10-args.png │ ├── testing.md │ ├── lib-funcs.md │ ├── README.md │ ├── globals.md │ └── func-def-n-call.md ├── footer.md ├── lv0-env-config │ ├── README.md │ ├── riscv.md │ ├── language.md │ ├── koopa.md │ └── docker.md ├── lv7-while │ ├── testing.md │ ├── while.md │ ├── break-n-continue.md │ └── README.md ├── lv5-block-n-scope │ ├── testing.md │ ├── implementing.md │ └── README.md ├── lv6-if │ ├── testing.md │ ├── short-circuit.md │ ├── README.md │ └── if-else.md ├── lv3-expr │ ├── testing.md │ ├── arithmetic-exprs.md │ ├── comp-n-logical-exprs.md │ ├── README.md │ └── unary-exprs.md ├── lv2-code-gen │ ├── README.md │ ├── testing.md │ ├── processing-ir.md │ └── code-gen.md ├── preface │ ├── README.md │ ├── facing-problems.md │ ├── lab.md │ └── prerequisites.md ├── lv9-array │ ├── testing.md │ ├── array-param.md │ ├── nd-array.md │ ├── README.md │ └── 1d-array.md ├── README.md ├── lv1-main │ ├── testing.md │ ├── README.md │ ├── ir-gen.md │ ├── structure.md │ └── parsing-main.md ├── toc.md └── index.html ├── README.md └── .gitignore /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/misc-app-ref/why.md: -------------------------------------------------------------------------------- 1 | # 为什么学编译? 2 | 3 | ?> **TODO:** 待补充. 4 | -------------------------------------------------------------------------------- /docs/lv9p-reincarnation/go-further.md: -------------------------------------------------------------------------------- 1 | # Lv9+.6. 向着更远处进发 2 | 3 | ?> **TODO:** 待补充. 4 | -------------------------------------------------------------------------------- /docs/assets/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pku-minic/online-doc/HEAD/docs/assets/icons/favicon.ico -------------------------------------------------------------------------------- /docs/misc-app-ref/judging-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pku-minic/online-doc/HEAD/docs/misc-app-ref/judging-1.png -------------------------------------------------------------------------------- /docs/misc-app-ref/judging-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pku-minic/online-doc/HEAD/docs/misc-app-ref/judging-2.png -------------------------------------------------------------------------------- /docs/lv9p-reincarnation/maze.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pku-minic/online-doc/HEAD/docs/lv9p-reincarnation/maze.png -------------------------------------------------------------------------------- /docs/lv9p-reincarnation/mandelbrot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pku-minic/online-doc/HEAD/docs/lv9p-reincarnation/mandelbrot.png -------------------------------------------------------------------------------- /docs/lv4-const-n-var/riscv-stack-frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pku-minic/online-doc/HEAD/docs/lv4-const-n-var/riscv-stack-frame.png -------------------------------------------------------------------------------- /docs/lv4-const-n-var/example-stack-frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pku-minic/online-doc/HEAD/docs/lv4-const-n-var/example-stack-frame.png -------------------------------------------------------------------------------- /docs/lv8-func-n-global/call-with-10-args.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pku-minic/online-doc/HEAD/docs/lv8-func-n-global/call-with-10-args.png -------------------------------------------------------------------------------- /docs/assets/icons/apple-touch-icon-precomposed-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pku-minic/online-doc/HEAD/docs/assets/icons/apple-touch-icon-precomposed-152.png -------------------------------------------------------------------------------- /docs/footer.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 文档 v2.0.0 由 [MaxXing](https://github.com/MaxXSoft) 撰写, 采用 [CC BY-NC-SA 4.0 协议](http://creativecommons.org/licenses/by-nc-sa/4.0/)发布. 4 | -------------------------------------------------------------------------------- /docs/misc-app-ref/examples.md: -------------------------------------------------------------------------------- 1 | # 示例编译器 2 | 3 | 我们实现了两个基于 Koopa IR, 可以将 SysY 编译到 RISC-V 汇编的示例编译器, 供大家参考: 4 | 5 | * **Rust 实现:** [kira-rs](https://github.com/pku-minic/kira-rs), 前端基于 lalrpop. 6 | * **C++ 实现:** [kira-cpp](https://github.com/pku-minic/kira-cpp), 前端基于 Flex/Bison. (暂未完成, 404 是正常现象) 7 | 8 | !> 遵守学术诚信, 请勿直接抄袭/拷贝示例编译器的实现! 9 |

10 | 如发现同学们的代码存在雷同现象, 我们将进行严肃处理. 见[学术诚信](/preface/lab?id=学术诚信)部分的描述. 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PKU Compiler Course Online Documentation 2 | 3 | Online documentation for PKU compiler course. 4 | 5 | [Visit this documentation on GitHub Pages](https://pku-minic.github.io/online-doc/). 6 | 7 | ## Details 8 | 9 | This repository based on Docsify, you can install it by running: 10 | 11 | ``` 12 | $ npm i docsify-cli -g 13 | ``` 14 | 15 | To launch a local server for testing, run: 16 | 17 | ``` 18 | $ docsify serve docs 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/lv0-env-config/README.md: -------------------------------------------------------------------------------- 1 | # Lv0. 环境配置 2 | 3 | 工欲善其事, 必先利其器. 在开始编译实践之前, 你应该先完成相关环境的配置, 这是十分重要的. 4 | 5 | 同时, 考虑到大家需要向评测系统提交自己的编译器, 为了尽可能避免因本地环境和评测机环境不一致导致的各类问题, 我们决定采用 [Docker](https://www.docker.com/) 来统一本地环境和线上环境. 我们建议大家的开发/测试都在 Docker 中进行, 并提前为大家配置好了实验环境的 Docker 镜像, 以节省大家的时间. 6 | 7 | 本章中你将会: 8 | 9 | * 配置实验环境的 Docker 容器, 并学习 Docker 的基本使用方法. 10 | * 认识编译实践中用到的编译器中间表示: Koopa IR. 11 | * 认识编译实践中开发的编译器的目标架构: RISC-V. 12 | * 选择一门趁手的, 用来开发编译器的编程语言. 13 | -------------------------------------------------------------------------------- /docs/lv7-while/testing.md: -------------------------------------------------------------------------------- 1 | # Lv7.3. 测试 2 | 3 | 到目前为止, 你的编译器已经可以处理分支和循环这两种复杂的程序结构了, 越来越像那么回事了! 4 | 5 | 在完成本章之前, 先进行一些测试吧. 6 | 7 | ## 本地测试 8 | 9 | 测试 Koopa IR: 10 | 11 | ``` 12 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 13 | autotest -koopa -s lv7 /root/compiler 14 | ``` 15 | 16 | 测试 RISC-V 汇编: 17 | 18 | ``` 19 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 20 | autotest -riscv -s lv7 /root/compiler 21 | ``` 22 | 23 | ## 在线评测 24 | 25 | ?> **TODO:** 待补充. 26 | -------------------------------------------------------------------------------- /docs/lv5-block-n-scope/testing.md: -------------------------------------------------------------------------------- 1 | # Lv5.2. 测试 2 | 3 | 目前你的编译器已经可以处理块语句了. 同时, 你的编译器在语义分析阶段还可以处理作用域. 非常棒! 4 | 5 | 在完成本章之前, 先进行一些测试吧. 6 | 7 | ## 本地测试 8 | 9 | 测试 Koopa IR: 10 | 11 | ``` 12 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 13 | autotest -koopa -s lv5 /root/compiler 14 | ``` 15 | 16 | 测试 RISC-V 汇编: 17 | 18 | ``` 19 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 20 | autotest -riscv -s lv5 /root/compiler 21 | ``` 22 | 23 | ## 在线评测 24 | 25 | ?> **TODO:** 待补充. 26 | -------------------------------------------------------------------------------- /docs/lv6-if/testing.md: -------------------------------------------------------------------------------- 1 | # Lv6.3. 测试 2 | 3 | 你的编译器已经可以处理 `if/else` 语句了, 它能处理的程序又变得复杂了很多, 看起来也不再像个简单的计算器了, 事情变得有趣了起来! 4 | 5 | 在完成本章之前, 先进行一些测试吧. 6 | 7 | ## 本地测试 8 | 9 | 测试 Koopa IR: 10 | 11 | ``` 12 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 13 | autotest -koopa -s lv6 /root/compiler 14 | ``` 15 | 16 | 测试 RISC-V 汇编: 17 | 18 | ``` 19 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 20 | autotest -riscv -s lv6 /root/compiler 21 | ``` 22 | 23 | ## 在线评测 24 | 25 | ?> **TODO:** 待补充. 26 | -------------------------------------------------------------------------------- /docs/lv3-expr/testing.md: -------------------------------------------------------------------------------- 1 | # Lv3.4. 测试 2 | 3 | 目前你的编译器已经可以处理一些简单的表达式计算了, 就像计算器一样, 可喜可贺! 4 | 5 | 在完成本章之前, 先进行一些测试吧. 6 | 7 | ## 本地测试 8 | 9 | 测试 Koopa IR: 10 | 11 | ``` 12 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 13 | autotest -koopa -s lv3 /root/compiler 14 | ``` 15 | 16 | 测试 RISC-V 汇编: 17 | 18 | ``` 19 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 20 | autotest -riscv -s lv3 /root/compiler 21 | ``` 22 | 23 | 测试程序对编译器的要求和之前章节一致, 此处及之后章节将不再赘述. 24 | 25 | ## 在线评测 26 | 27 | ?> **TODO:** 待补充. 28 | -------------------------------------------------------------------------------- /docs/lv2-code-gen/README.md: -------------------------------------------------------------------------------- 1 | # Lv2. 初试目标代码生成 2 | 3 | 本章中, 你将在上一章的基础上, 实现一个能处理 `main` 函数和 `return` 语句的编译器, 同时输出编译后的 RISC-V 汇编. 4 | 5 | 你的编译器会将如下的 SysY 程序: 6 | 7 | ```c 8 | int main() { 9 | // 摊牌了, 我是注释 10 | return 0; 11 | } 12 | ``` 13 | 14 | 编译为对应的 RISC-V 汇编: 15 | 16 | ``` 17 | .text 18 | .globl main 19 | main: 20 | li a0, 0 21 | ret 22 | ``` 23 | 24 | 或: 25 | 26 | ``` 27 | .text 28 | .globl main 29 | main: 30 | li t0, 0 31 | mv a0, t0 32 | ret 33 | ``` 34 | 35 | 取决于你的实现方式. 36 | 37 | ## 相关规范 38 | 39 | 见 [Lv1. `main` 函数](/lv1-main/). 40 | -------------------------------------------------------------------------------- /docs/misc-app-ref/README.md: -------------------------------------------------------------------------------- 1 | # 杂项/附录/参考 2 | 3 | 本章谈论了一些杂项内容, 同时包含文档的附录和参考文献. 4 | 5 | 附录部分包括: 6 | 7 | * [实验环境使用说明](/misc-app-ref/environment). 8 | * [SysY 语言规范](/misc-app-ref/sysy-spec). 9 | * [SysY 运行时库](/misc-app-ref/sysy-runtime). 10 | * [Koopa IR 规范](/misc-app-ref/koopa). 11 | * [RISC-V 指令速查](/misc-app-ref/riscv-insts). 12 | * [在线评测使用说明](/misc-app-ref/oj). 13 | 14 | 同学们在遇到关于语言/IR 定义的问题, 或者是 OJ 使用上的问题时, 可以直接查阅附录. 15 | 16 | 本章的最后给出了几个基于 Koopa IR 的 SysY 到 RISC-V 的示例编译器实现, 供大家参考 (但请勿直接挪用示例代码, 见[学术诚信](/preface/lab?id=%e5%ad%a6%e6%9c%af%e8%af%9a%e4%bf%a1)部分). 17 | -------------------------------------------------------------------------------- /docs/lv4-const-n-var/testing.md: -------------------------------------------------------------------------------- 1 | # Lv4.3. 测试 2 | 3 | 本章的涉及的内容相对较多, 理解难度相较前几章也更难. 你能进行到这一步实属不易, 给你比一个~大母猪~大拇指! ( ´∀`)b 4 | 5 | 目前你的编译器已经可以处理常量和变量了, 能处理的程序看起来也已经有模有样了, 十分不错! 6 | 7 | 在完成本章之前, 先进行一些测试吧. 8 | 9 | ## 本地测试 10 | 11 | 测试 Koopa IR: 12 | 13 | ``` 14 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 15 | autotest -koopa -s lv4 /root/compiler 16 | ``` 17 | 18 | 测试 RISC-V 汇编: 19 | 20 | ``` 21 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 22 | autotest -riscv -s lv4 /root/compiler 23 | ``` 24 | 25 | ## 在线评测 26 | 27 | ?> **TODO:** 待补充. 28 | -------------------------------------------------------------------------------- /docs/lv8-func-n-global/testing.md: -------------------------------------------------------------------------------- 1 | # Lv8.4. 测试 2 | 3 | 到目前为止, 你的编译器已经可以处理包括函数定义和调用, SysY 库函数和全局变量的程序了, 这又是一次巨大的飞跃! 4 | 5 | 有了 SysY 库函数, 你的编译器生成的程序就能进行输入/输出字符之类的 I/O 操作了. 测试程序会通过指定标准输入以及检查标准输出的方式, 来进一步确认你的程序是否执行正确. 6 | 7 | 已经可以看到胜利的曙光了, 加油! 8 | 9 | 在完成本章之前, 先进行一些测试吧. 10 | 11 | ## 本地测试 12 | 13 | 测试 Koopa IR: 14 | 15 | ``` 16 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 17 | autotest -koopa -s lv8 /root/compiler 18 | ``` 19 | 20 | 测试 RISC-V 汇编: 21 | 22 | ``` 23 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 24 | autotest -riscv -s lv8 /root/compiler 25 | ``` 26 | 27 | ## 在线评测 28 | 29 | ?> **TODO:** 待补充. 30 | -------------------------------------------------------------------------------- /docs/lv9p-reincarnation/perf-testing.md: -------------------------------------------------------------------------------- 1 | # Lv9+.5. 性能测试 2 | 3 | 在 Lv9+.2, Lv9+.3 和 Lv9+.4 中, 你已经为你的编译器添加了很多新的实现, 来生成性能更高的代码. 编译实践的本地实验环境/在线评测系统均支持性能测试, 在完成各类优化后, 你可以进行性能测试, 来直观感受这些改进带来的性能提升. 4 | 5 | ## 本地测试 6 | 7 | ``` 8 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 9 | autotest -perf -s perf /root/compiler 10 | ``` 11 | 12 | 在运行测试前, 你需要确保你的编译器 (假设名称为 `compiler`) 能处理如下的命令行参数: 13 | 14 | ``` 15 | compiler -perf 输入文件 -o 输出文件 16 | ``` 17 | 18 | 其中, `-perf` 代表此时正在进行性能测试, 你的编译器需要输出 RISC-V 汇编文件, 并且可在此基础上启用一些优化. `输入文件` 代表输入的 SysY 源文件的路径, `输出文件` 代表 RISC-V 汇编的输出文件路径. 你的编译器应该解析 `输入文件`, 并把生成的 RISC-V 汇编输出到 `输出文件` 中. 19 | 20 | ## 在线评测 21 | 22 | ?> **TODO:** 待补充. 23 | -------------------------------------------------------------------------------- /docs/assets/css/main.css: -------------------------------------------------------------------------------- 1 | /* Theme */ 2 | :root { 3 | --theme-hue: 204; 4 | --theme-saturation: 85%; 5 | --theme-lightness: 50%; 6 | } 7 | 8 | /* Sidebar */ 9 | :root { 10 | --sidebar-nav-indent: 0.7em; 11 | --sidebar-nav-pagelink-padding: 0.6em 0 0.6em 20px; 12 | --sidebar-name-font-weight: normal; 13 | } 14 | 15 | /* Sidebar again */ 16 | .sidebar-nav { 17 | font-size: 0.9em; 18 | } 19 | 20 | /* Code block */ 21 | :root { 22 | --code-font-size: calc(var(--font-size-m) * 0.9) 23 | } 24 | 25 | /* Giscus */ 26 | .giscus { 27 | margin: 1em 0; 28 | } 29 | .giscus-frame { 30 | max-width: var(--content-max-width); 31 | display: block; 32 | margin: 0 auto; 33 | padding: 0 45px; 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/macos 2 | 3 | ### macOS ### 4 | *.DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | 12 | # Thumbnails 13 | ._* 14 | 15 | # Files that might appear in the root of a volume 16 | .DocumentRevisions-V100 17 | .fseventsd 18 | .Spotlight-V100 19 | .TemporaryItems 20 | .Trashes 21 | .VolumeIcon.icns 22 | .com.apple.timemachine.donotpresent 23 | 24 | # Directories potentially created on remote AFP share 25 | .AppleDB 26 | .AppleDesktop 27 | Network Trash Folder 28 | Temporary Items 29 | .apdisk 30 | 31 | # End of https://www.gitignore.io/api/macos 32 | 33 | # VS Code 34 | .vscode 35 | 36 | # Build & Debug 37 | build 38 | debug 39 | -------------------------------------------------------------------------------- /docs/preface/README.md: -------------------------------------------------------------------------------- 1 | # 写在前面 2 | 3 | 编译原理课程实践并不是一个难度很低的课程实践——至少之前如此. 4 | 5 | 为什么呢? 我们 (指助教 MaxXing) 认真反思过这个问题: 6 | 7 | * 一方面, 课程实践要求你从新建文件夹开始, 写一个能跑的编译器, 然而很多同学在此之前并没有编写数千行代码的程序的经验. 8 | * 另一方面, 之前的编译实践课程没有给出一个足够详细的指导文档, 导致同学们在做实验的过程中会遇到很多需要解决的问题. 加之经验欠缺, 这些问题在同学们看来会更加困难. 9 | 10 | 自2021春季学期起, 我们决定对编译实践作出一系列大刀阔斧的调整, 包括: 11 | 12 | * 重新安排实践的所有阶段. 13 | * 设计全新的适合教学的中间表示 (IR). 14 | * 设计全新的实验环境. 15 | * 编写全新的在线文档. 16 | 17 | 直至2022年春季学期, 所有调整均已进行完毕. 调整后的编译实践, 在总体目标上和原来并无二致, 同学们依然需要开发一个将 SysY 编译到 RISC-V 汇编的程序. 但我们在此过程中设置了更多的指导内容, 以求减少同学们对编译实践的困扰. 18 | 19 | 然而, 即使如此, **编译原理课程实践依然不是一个难度很低的课程实践**. 我们希望你对此做好心理准备. 20 | 21 | 不过, 相信有了这篇文档的帮助, 在学习编译的路上, 你一定不会孤单. 22 | 23 | !> 我们建议你仔细阅读文档, 跟随文档来进行实验, 且不要跳过任何内容. 24 |

25 | 我们**不负责**解答任何因不仔细读文档而产生的问题. 26 | -------------------------------------------------------------------------------- /docs/misc-app-ref/references.md: -------------------------------------------------------------------------------- 1 | # 参考文献 2 | 3 | 北京大学编译实践课程在线文档参考了如下教程文档: 4 | 5 | * [清华大学 MiniDecaf 编译实验](https://decaf-lang.github.io/minidecaf-tutorial/). 6 | * [北京航空航天大学 miniSysY 编译实验](https://buaa-se-compiling.github.io/miniSysY-tutorial/). 7 | * [南京大学计算机系统基础课程实验](https://nju-projectn.github.io/ics-pa-gitbook/ics2021/index.html). 8 | 9 | 感谢这些文档的编写者/维护者们的辛苦付出! 这些都是十分优秀的编译/计算机系统类教程, MaxXing 十分推荐大家一起学习. 10 | 11 | 此外, 在线文档还参考了其他文档: 12 | 13 | * [SysY 语言定义](https://gitlab.eduxiji.net/nscscc/compiler2021/-/blob/master/SysY%E8%AF%AD%E8%A8%80%E5%AE%9A%E4%B9%89.pdf). 14 | * [SysY 运行时库](https://gitlab.eduxiji.net/nscscc/compiler2021/-/blob/master/SysY%E8%BF%90%E8%A1%8C%E6%97%B6%E5%BA%93.pdf). 15 | * [LLVM documentation](https://llvm.org/docs/). 16 | * [Cranelift IR Reference](https://github.com/bytecodealliance/wasmtime/blob/main/cranelift/docs/ir.md). 17 | * [RISC-V Specifications](https://riscv.org/technical/specifications/). 18 | -------------------------------------------------------------------------------- /docs/assets/js/prism-koopa.js: -------------------------------------------------------------------------------- 1 | Prism.languages.koopa = { 2 | 'comment': [ 3 | { 4 | pattern: /\/\/!.*|\/\*![\s\S]*?!\*\//, 5 | alias: 'doc-comment' 6 | }, 7 | { 8 | pattern: /\/\/.*|\/\*[\s\S]*?\*\//, 9 | greedy: true 10 | } 11 | ], 12 | 'string': { 13 | pattern: /"[^"]*"/, 14 | greedy: true 15 | }, 16 | 'label': { 17 | pattern: /((?:^|[^\w@%]))(?:@|%)(?:[a-zA-Z_][a-zA-Z0-9_]*|\d+)(?=.*:)/, 18 | lookbehind: true, 19 | alias: 'function' 20 | }, 21 | 'keyword': /\b(?:alloc|load|store|getptr|getelemptr|br|jump|ret|call|fun|decl|global|zeroinit|undef)\b/, 22 | 'builtin': /\b(?:ne|eq|gt|lt|ge|le|add|sub|mul|div|mod|and|or|xor|shl|shr|sar)\b/, 23 | 'type': { 24 | pattern: /\bi32\b/, 25 | alias: 'class-name' 26 | }, 27 | 'variable': { 28 | pattern: /(?:@|%)(?:[a-zA-Z_][a-zA-Z0-9_]*|\d+)/ 29 | }, 30 | 'number': /\b\d+\b/, 31 | 'punctuation': /[{}[\](),:*=]/ 32 | }; 33 | -------------------------------------------------------------------------------- /docs/lv2-code-gen/testing.md: -------------------------------------------------------------------------------- 1 | # Lv2.3. 测试 2 | 3 | 你已经写出了一个功能简单但初具形态的编译器了, 恭喜! 之后的章节中, 我们会进一步给这个编译器添加新的特性, 直至它能处理所有符合 SysY 语言规范的程序. 但在此之前, 你应该完成编译器的测试工作. 4 | 5 | ## 本地测试 6 | 7 | 假设你已经完成了 [Docker 的配置](/lv0-env-config/docker), 你可以执行: 8 | 9 | ``` 10 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 11 | autotest -riscv -s lv1 /root/compiler 12 | ``` 13 | 14 | 你需要将 `项目目录` 替换为你的编译器项目在宿主机上的路径. 同时, 在运行测试前, 你需要确保你的编译器 (假设名称为 `compiler`) 能处理如下的命令行参数: 15 | 16 | ``` 17 | compiler -riscv 输入文件 -o 输出文件 18 | ``` 19 | 20 | 其中, `-riscv` 代表你的编译器要输出 RISC-V 汇编文件, `输入文件` 代表输入的 SysY 源文件的路径, `输出文件` 代表 RISC-V 汇编的输出文件路径. 你的编译器应该解析 `输入文件`, 并把生成的 RISC-V 汇编输出到 `输出文件` 中. 21 | 22 | ?> 为了同时兼容 Koopa IR 和 RISC-V 的测试, 你的编译器应该能够根据命令行参数的值, 判断当前正在执行何种测试, 然后决定只需要进行 IR 生成, 还是同时需要进行目标代码生成, 并向输出文件中输出 Koopa IR 或 RISC-V 汇编. 23 | 24 | 关于实验环境/测试脚本的详细使用方法, 请参考[实验环境使用说明](/misc-app-ref/environment). 25 | 26 | ## 在线评测 27 | 28 | ?> **TODO:** 待补充. 29 | 30 | 关于在线评测系统的详细使用方法, 请参考[在线评测使用说明](/misc-app-ref/oj). 31 | -------------------------------------------------------------------------------- /docs/lv9-array/testing.md: -------------------------------------------------------------------------------- 1 | # Lv9.4. 测试 2 | 3 | “咔哒!” 4 | 5 | 你按下了 `Ctrl + S` (也可能是 `Cmd + S` 或 `:w` 或 `C-x C-s`), 动作干净利落, 仿佛武士收刀入鞘. 眼前那条曾在你眼中永远都无法击败的喷火龙, 现在已经奄奄一息. 6 | 7 | 脚踏一片焦土, 眼前是黎明的曙光, 心中则是百感交集. 8 | 9 | 至此, 你的编译器已经可以处理所有合法的 SysY 程序了! 祝贺, 你是最棒的! 10 | 11 | 在完成本章之前, 先进行一些测试吧. 12 | 13 | ## 本地测试 14 | 15 | ### 只测试本章 16 | 17 | 测试 Koopa IR: 18 | 19 | ``` 20 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 21 | autotest -koopa -s lv9 /root/compiler 22 | ``` 23 | 24 | 测试 RISC-V 汇编: 25 | 26 | ``` 27 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 28 | autotest -riscv -s lv9 /root/compiler 29 | ``` 30 | 31 | ### 测试所有章节 32 | 33 | 测试 Koopa IR: 34 | 35 | ``` 36 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 37 | autotest -koopa /root/compiler 38 | ``` 39 | 40 | 测试 RISC-V 汇编: 41 | 42 | ``` 43 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 44 | autotest -riscv /root/compiler 45 | ``` 46 | 47 | ## 在线评测 48 | 49 | ?> **TODO:** 待补充. 50 | -------------------------------------------------------------------------------- /docs/lv3-expr/arithmetic-exprs.md: -------------------------------------------------------------------------------- 1 | # Lv3.2. 算术表达式 2 | 3 | 本节新增/变更的语法规范如下: 4 | 5 | ```ebnf 6 | Exp ::= AddExp; 7 | PrimaryExp ::= ...; 8 | Number ::= ...; 9 | UnaryExp ::= ...; 10 | UnaryOp ::= ...; 11 | MulExp ::= UnaryExp | MulExp ("*" | "/" | "%") UnaryExp; 12 | AddExp ::= MulExp | AddExp ("+" | "-") MulExp; 13 | ``` 14 | 15 | ## 一个例子 16 | 17 | ```c 18 | int main() { 19 | return 1 + 2 * 3; 20 | } 21 | ``` 22 | 23 | ## 词法/语法分析 24 | 25 | 词法/语法分析部分同上一节, 你需要处理新增的运算符, 根据新增的语法规范设计新的 AST, 或者修改现有的 AST, 然后让你的 parser 支持新增的语法规范. 26 | 27 | ## 语义分析 28 | 29 | 暂无需要添加的内容. 30 | 31 | ## IR 生成 32 | 33 | 示例代码可以生成如下的 Koopa IR: 34 | 35 | ```koopa 36 | fun @main(): i32 { 37 | %entry: 38 | %0 = mul 2, 3 39 | %1 = add 1, %0 40 | ret %1 41 | } 42 | ``` 43 | 44 | 按照常识, `*`/`/`/`%` 运算符的优先级应该高于 `+`/`-` 运算符, 所以你生成的代码应该先计算乘法, 后计算加法. SysY 的语法规范中已经体现了运算符的优先级, 如果你正确建立了 AST, 那么你在后序遍历 AST 时, 生成的代码自然会是上述形式. 45 | 46 | ## 目标代码生成 47 | 48 | 关键部分的 RISC-V 汇编如下: 49 | 50 | ``` 51 | li t0, 2 52 | li t1, 3 53 | mul t1, t0, t1 54 | li t2, 1 55 | add t2, t1, t2 56 | ``` 57 | -------------------------------------------------------------------------------- /docs/lv7-while/while.md: -------------------------------------------------------------------------------- 1 | # Lv7.1. 处理 `while` 2 | 3 | 本节新增/变更的语法规范如下: 4 | 5 | ```ebnf 6 | Stmt ::= ... 7 | | ... 8 | | ... 9 | | ... 10 | | "while" "(" Exp ")" Stmt 11 | | ...; 12 | ``` 13 | 14 | ## 一个例子 15 | 16 | ```c 17 | int main() { 18 | int i = 0; 19 | while (i < 10) i = i + 1; 20 | return i; 21 | } 22 | ``` 23 | 24 | ## 词法/语法分析 25 | 26 | 本节新增了关键字 `while`, 你需要修改你的 lexer 来支持它们. 同时, 你需要针对 `while` 语句设计 AST, 并更新你的 parser 实现. 27 | 28 | ## 语义分析 29 | 30 | 无需新增内容. 记得对 `while` 的各部分 (条件和循环体) 进行语义分析即可. 31 | 32 | ## IR 生成 33 | 34 | 根据 `while` 的语义, 生成所需的基本块, 条件判断和分支/跳转指令即可. 相信在理解了 `if/else` 语句 IR 生成的原理之后, 这部分对你来说并不困难. 35 | 36 | 示例程序生成的 Koopa IR 为: 37 | 38 | ```koopa 39 | fun @main(): i32 { 40 | %entry: 41 | @i = alloc i32 42 | store 0, @i 43 | jump %while_entry 44 | 45 | %while_entry: 46 | %0 = load @i 47 | %cond = lt %0, 10 48 | br %cond, %while_body, %end 49 | 50 | %while_body: 51 | %1 = load @i 52 | %2 = add %1, 1 53 | store %2, @i 54 | jump %while_entry 55 | 56 | %end: 57 | %3 = load @i 58 | ret %3 59 | } 60 | ``` 61 | 62 | 当然, 可能还存在其他生成 `while` 的方式. 63 | 64 | ## 目标代码生成 65 | 66 | 本节并未用到新的 Koopa IR 指令, 也不涉及 Koopa IR 中的新概念, 所以这部分没有需要改动的内容. 67 | -------------------------------------------------------------------------------- /docs/lv9p-reincarnation/README.md: -------------------------------------------------------------------------------- 1 | # Lv9+. 新的开始 2 | 3 | ?> 本章为可选内容, **不做不扣分**, 选做可加分. 4 | 5 | 在前几章中, 你已经完成了一个可以将 SysY 程序编译到 RISC-V 汇编的编译器, 并且通过了所有的功能测试. 本章中, 你将在这个编译器的基础上, 实现更多更丰富的功能. 6 | 7 | 本章的内容包括: 8 | 9 | * [**你的编译器超强的:**](/lv9p-reincarnation/awesome-compiler) 不要小瞧你的编译器, 它已经可以编译很多很了不起的程序了! 本节中就提供了一些示例程序. 如果你愿意的话, 可以在编译器的基础上扩展更多语法, 并且尝试用它编译更多更厉害的程序. 10 | * [**寄存器分配:**](/lv9p-reincarnation/reg-alloc) 在之前的章节中, 你的编译器只实现了很简单 (简陋) 的寄存器分配算法. 你可以尝试在你的编译器中实现更复杂的寄存器分配算法, 来改善目标代码生成的质量. 11 | * [**优化:**](/lv9p-reincarnation/opt) Koopa IR 的设计目标之一, 就是让 IR 层面的优化更易进行. 你可以在你的编译器上实现很多或简或繁的优化, 来改善 IR 生成/目标代码生成的质量, 提升编译得到的程序的性能. 12 | * [**SSA 形式:**](/lv9p-reincarnation/ssa-form) Koopa IR 支持 SSA 形式——这是一种业界广泛使用的 IR 形式, 基于此可以简化很多分析/优化的实现. 你可以选择把编译器生成的 IR 提升至 SSA 形式, 来完成更多进阶的优化. 13 | * [**性能测试:**](/lv9p-reincarnation/perf-testing) 在完成寄存器分配和各类优化后, 你可以对你的程序进行性能测试, 来直观感受这些改进带来的效果. 14 | * [**向着更远处进发:**](/lv9p-reincarnation/go-further) 编译器可谓世界上最精巧的软件, 课程实践已经带你踏入了编译的大门, 而在这个领域, 还有更多有价值的课题等待你的研究. 路漫漫其修远兮, 吾将上下而求索. 15 | 16 | 本章不同于之前的章节: 17 | 18 | 1. **本章的内容并不是必选的**, 但完成本章的内容可以获得加分. 19 | 2. 本章将不会采取类似之前章节的 “手把手指导” 的模式, 而只会对其中必要的内容给出简要指导, 或者相关参考文献. 你需要自行探索对应内容的实现方法. 20 | 21 | 本章内的所有小节并无顺序关系, 你可以从任意一节开始阅读. 各小节的难度基本按照出现顺序递增. 22 | 23 | 最后, 即便你决定不完成本章中的可选内容, **我们依然推荐你阅读本章**, 尤其是 [Lv9+.1](/lv9p-reincarnation/awesome-compiler). 24 | -------------------------------------------------------------------------------- /docs/assets/js/sidebar.js: -------------------------------------------------------------------------------- 1 | // Reference: https://github.com/iPeng6/docsify-sidebar-collapse 2 | // Modified by MaxXing. 3 | 4 | const scrollBarSyncPlugin = (hook, vm) => { 5 | hook.doneEach(() => { 6 | const activeNode = getActiveNode() 7 | syncScrollTop(activeNode) 8 | }) 9 | } 10 | 11 | const syncScrollTop = (activeNode) => { 12 | if (activeNode) { 13 | const curTop = activeNode.getBoundingClientRect().top 14 | if (curTop > window.innerHeight) { 15 | activeNode.scrollIntoView() 16 | } 17 | } 18 | } 19 | 20 | const getActiveNode = () => { 21 | let node = document.querySelector('.sidebar-nav .active') 22 | if (!node) { 23 | const curLink = document.querySelector( 24 | `.sidebar-nav a[href="${decodeURIComponent(location.hash).replace( 25 | / /gi, 26 | '%20' 27 | )}"]` 28 | ) 29 | node = findTagParent(curLink, 'LI', 2) 30 | if (node) { 31 | node.classList.add('active') 32 | } 33 | } 34 | return node 35 | } 36 | 37 | const findTagParent = (curNode, tagName, level) => { 38 | if (curNode && curNode.tagName === tagName) return curNode 39 | let l = 0 40 | while (curNode) { 41 | l++ 42 | if (l > level) return 43 | if (curNode.parentNode.tagName === tagName) { 44 | return curNode.parentNode 45 | } 46 | curNode = curNode.parentNode 47 | } 48 | } 49 | 50 | $docsify.plugins.push(scrollBarSyncPlugin) 51 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # 北大编译实践在线文档 2 | 3 | 欢迎各位同学选择编译原理课程实践! 4 | 5 | 在本课程中, 你将实现一个可将 SysY 语言编译到 RISC-V (读作 “risk-five”) 汇编的编译器. SysY 语言是一种精简版的 C 语言, RISC-V 则是一种新兴且热门的指令系统 (ISA). 而你的编译器, 则需要在这两者之间建立联系. 6 | 7 | 通常情况下, SysY 程序看起来和 C 程序类似: 8 | 9 | ```c 10 | int fib(int n) { 11 | if (n <= 2) { 12 | return 1; 13 | } else { 14 | return fib(n - 1) + fib(n - 2); 15 | } 16 | } 17 | 18 | int main() { 19 | int input = getint(); 20 | putint(fib(input)); 21 | putch(10); 22 | return 0; 23 | } 24 | ``` 25 | 26 | 上述程序可以被编译成如下的 RISC-V 汇编 (仅作示例, 实际编译得到的汇编取决于编译器的具体实现): 27 | 28 | ```asm 29 | .text 30 | .align 2 31 | 32 | .globl fib 33 | fib: 34 | sw ra, -4(sp) 35 | addi sp, sp, -16 36 | li t1, 2 37 | bgt a0, t1, .l0 38 | li a0, 1 39 | addi sp, sp, 16 40 | lw ra, -4(sp) 41 | ret 42 | .l0: 43 | addi s4, a0, -1 44 | sw a0, 0(sp) 45 | mv a0, s4 46 | call fib 47 | mv a3, a0 48 | lw a0, 0(sp) 49 | addi s4, a0, -2 50 | sw a3, 0(sp) 51 | mv a0, s4 52 | call fib 53 | mv s4, a0 54 | lw a3, 0(sp) 55 | add s4, a3, s4 56 | mv a0, s4 57 | addi sp, sp, 16 58 | lw ra, -4(sp) 59 | ret 60 | 61 | .globl main 62 | main: 63 | sw ra, -4(sp) 64 | addi sp, sp, -16 65 | call getint 66 | call fib 67 | call putint 68 | li a0, 10 69 | call putch 70 | li a0, 0 71 | addi sp, sp, 16 72 | lw ra, -4(sp) 73 | ret 74 | ``` 75 | 76 | 以上的这些新名词你可能并不熟悉, 编译器的工作原理你也许更是知之甚少. 但相信上完这门课程, 你将对这些内容, 乃至计算机系统底层的工作原理, 拥有一个崭新的认识. 77 | 78 | [让我们开始吧! Link start!](/preface/) 79 | -------------------------------------------------------------------------------- /docs/lv6-if/short-circuit.md: -------------------------------------------------------------------------------- 1 | # Lv6.2. 短路求值 2 | 3 | 本节没有任何语法规范上的变化. 4 | 5 | ## 一个例子 6 | 7 | ```c 8 | int main() { 9 | int a = 0, b = 1; 10 | if (a || b) { 11 | a = a + b; 12 | } 13 | return a; 14 | } 15 | ``` 16 | 17 | ## 词法/语法分析 18 | 19 | 因为语法规范不变, 所以这部分没有需要改动的内容. 20 | 21 | ## 语义分析 22 | 23 | 同上, 暂无需要改动的内容. 24 | 25 | ## IR 生成 26 | 27 | SysY 程序中的逻辑运算符, 即 `||` 和 `&&`, 在求值时遵循短路求值的语义. 所谓短路求值, 指的是, 求值逻辑表达式时先计算表达式的左边 (left-hand side, LHS), 如果表达式左左边的结果已经可以确定整个表达式的计算结果, 就不再计算表达式的右边 (right-hand side, RHS). 28 | 29 | 比如对于一个 `||` 表达式, 如果 LHS 的值是 1, 根据或运算的性质, 无论 RHS 求出何值, 整个表达式的求值结果一定是 1, 所以此时就不再计算 RHS 了. `&&` 表达式同理. 30 | 31 | 编译器实现短路求值的思路, 其实和上述思路没什么区别. 例如, 短路求值 `lhs || rhs` 本质上做了这个操作: 32 | 33 | ```c 34 | int result = 1; 35 | if (lhs == 0) { 36 | result = rhs != 0; 37 | } 38 | // 表达式的结果即是 result 39 | ``` 40 | 41 | 你的编译器可以按照上述思路, 在生成 IR 时, 把逻辑表达式翻译成若干分支, 跳转和赋值. 42 | 43 | 当然, 目前对逻辑表达式进行短路求值和进行非短路求值是没有任何区别的, 要想体现这一区别, RHS 必须是一个带有副作用 ([side effect](https://en.wikipedia.org/wiki/Side_effect_(computer_science))) 的表达式. 而 SysY 中, 仅有包含函数调用的表达式才可能产生副作用, 例如调用了一个可能修改全局变量的函数, 或可能进行 I/O 操作的函数, 等等. 但为了你的编译器顺利通过之后的测试, 你必须正确实现这一功能. 44 | 45 | 短路求值逻辑表达式有什么用呢? 首先它能剔除很多不必要的计算, 例如表达式的 RHS 进行了一个非常耗时的计算, 如果编程语言支持短路求值, 在求出 LHS 就能确定逻辑表达式结果的情况下, 计算机就不必劳神再把 RHS 算一遍了. 此外, 利用短路求值的性质, 你可以简化某些程序的写法, 例如在 C/C++ 中可以这么写: 46 | 47 | ```cpp 48 | void *ptr = ...; 49 | if (ptr != nullptr && check(ptr)) { 50 | // 执行一些操作 51 | // ... 52 | } 53 | ``` 54 | 55 | 编译器会保证函数 `check` 被调用时, 指针 `ptr` 一定非空, 此时 `check` 函数可以放心地解引用指针而不必担心段错误. 56 | 57 | ## 目标代码生成 58 | 59 | 本节并未用到新的 Koopa IR 指令, 也不涉及 Koopa IR 中的新概念, 所以这部分没有需要改动的内容. 60 | -------------------------------------------------------------------------------- /docs/lv7-while/break-n-continue.md: -------------------------------------------------------------------------------- 1 | # Lv7.2. `break` 和 `continue` 2 | 3 | 本节新增/变更的语法规范如下: 4 | 5 | ```ebnf 6 | Stmt ::= ... 7 | | ... 8 | | ... 9 | | ... 10 | | ... 11 | | "break" ";" 12 | | "continue" ";" 13 | | ...; 14 | ``` 15 | 16 | ## 一个例子 17 | 18 | ```c 19 | int main() { 20 | while (1) break; 21 | return 0; 22 | } 23 | ``` 24 | 25 | ## 词法/语法分析 26 | 27 | 本节新增了关键字 `break` 和 `continue`, 你需要修改你的 lexer 来支持它们. 同时, 你需要针对这两种语句设计 AST, 并更新你的 parser 实现. 28 | 29 | ## 语义分析 30 | 31 | 注意 `break` 和 `continue` 只能出现在循环内. 例如, 以下的程序存在语义错误: 32 | 33 | ```c 34 | int main() { 35 | break; 36 | return 0; 37 | } 38 | ``` 39 | 40 | ?> 其实在写编译器的时候你会发现, 在进行 IR 生成时, 你很容易判断 `break`/`continue` 是否出现在了循环内. 41 | 42 | ## IR 生成 43 | 44 | `break` 和 `continue` 本质上执行的都是跳转操作, 只不过一个会跳转到循环结尾, 一个会跳转到循环开头. 所以, 为了正确获取跳转的目标, 你的编译器在生成循环时必须记录循环开头和结尾的相关信息. 45 | 46 | 此外需要注意的是, `while` 循环是可以嵌套的, 所以, 你应该选择合适的数据结构来存储 `break`/`continue` 所需的信息. 47 | 48 | 示例程序生成的 Koopa IR **可能**为: 49 | 50 | ```koopa 51 | fun @main(): i32 { 52 | %entry: 53 | jump %while_entry 54 | 55 | %while_entry: 56 | br 1, %while_body, %end 57 | 58 | %while_body: 59 | jump %end 60 | 61 | %while_body1: 62 | jump %while_entry 63 | 64 | %end: 65 | ret 0 66 | } 67 | ``` 68 | 69 | !> 上面的 Koopa IR 程序是文档作者根据经验, 模仿一个编译器生成出来的 (事实上文档里所有的 Koopa IR 示例都是这么写的) (人形编译器 MaxXing 实锤), 仅代表一种可能的 IR 生成方式. 70 |

71 | 你会看到, 程序中出现了一个不可达的基本块 `%while_body1`. 这件事情在人类看来比较费解: 为什么会这样呢? ~怎么会事呢?~ 但对于编译器的 IR 生成部分而言, 这么做是最省事的. 你也许可以思考一下背后的原因. 72 | 73 | ## 目标代码生成 74 | 75 | 本节并未用到新的 Koopa IR 指令, 也不涉及 Koopa IR 中的新概念, 所以这部分没有需要改动的内容. 76 | -------------------------------------------------------------------------------- /docs/lv1-main/testing.md: -------------------------------------------------------------------------------- 1 | # Lv1.5. 测试 2 | 3 | 如果你已经完成了前四节的阅读, 你就可以顺利地得到一个最初级的编译器了, 接下来要做的事情是测试. 4 | 5 | ## 本地测试 6 | 7 | !> 本地测试很重要! 请务必认真完成. 8 |

9 | 编译实践不同于其他同样使用 OJ 的课程: 你向 OJ 提交的并不是单个的代码文件, 而是一个完整的编译器项目. 如果不在本地的实验环境内预先测试/调试, 提交在线评测后, 你可能会遇到很多无从下手的问题. 10 | 11 | 假设你已经完成了 [Docker 的配置](/lv0-env-config/docker), 你可以执行: 12 | 13 | ``` 14 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 15 | autotest -koopa -s lv1 /root/compiler 16 | ``` 17 | 18 | 你需要将 `项目目录` 替换为你的编译器项目在宿主机上的路径. 同时, 在运行测试前, 你需要确保你的编译器 (假设名称为 `compiler`) 能处理如下的命令行参数: 19 | 20 | ``` 21 | compiler -koopa 输入文件 -o 输出文件 22 | ``` 23 | 24 | 其中, `-koopa` 代表你的编译器要输出 Koopa IR 文件, `输入文件` 代表输入的 SysY 源文件的路径, `输出文件` 代表 Koopa IR 的输出文件路径. 你的编译器应该解析 `输入文件`, 并把生成的 Koopa IR 输出到 `输出文件` 中. 25 | 26 | 测试程序会使用你的编译器将输入编译为 Koopa IR, 然后借助 LLVM 将 Koopa IR 进一步编译成可执行文件. 最后, 测试程序执行可执行文件, 检查程序的返回值 (也就是 `main` 的返回值) 是否符合预期. 测试程序**不会**检查你输出的 Koopa IR 的形式, 你输出的 IR **只要功能正确, 即可通过测试.** 27 | 28 | 关于实验环境/测试脚本的详细使用方法, 请参考[实验环境使用说明](/misc-app-ref/environment). 关于调试编译器的相关思路, 请参考[调试你的编译器](/misc-app-ref/environment?id=调试你的编译器). 关于测试脚本的工作原理, 请 [RTFSC](https://github.com/pku-minic/compiler-dev/blob/master/autotest/autotest). 29 | 30 | ## 上传代码到评测平台 31 | 32 | 学期初, 我们会向所有选修编译原理课的同学的 PKU 邮箱中发送在线评测平台的账号, 详情请关注课上的说明, 或课程群通知. 33 | 34 | 你可以使用发放的账号登录评测平台的代码托管平台 ([eduxiji.gitlab.net](https://gitlab.eduxiji.net)), 然后新建 repo. 之后你就可以按照使用 Git 的一般流程来向代码托管平台提交代码了. 35 | 36 | !> **注意:** 请务必将你创建的 repo 的可见性设为 “Private”, 否则所有人都将在平台上看到你提交的代码! 37 |

38 | 此外, 平台的 GitLab **不支持 SSH 登录**, 在从平台 clone 仓库或向平台提交代码时, 请注意使用 HTTPS. 39 | 40 | ## 在线评测 41 | 42 | ?> **TODO:** 待补充. 43 | 44 | 关于在线评测系统的详细使用方法, 请参考[在线评测使用说明](/misc-app-ref/oj). 45 | -------------------------------------------------------------------------------- /docs/lv8-func-n-global/lib-funcs.md: -------------------------------------------------------------------------------- 1 | # Lv8.2. SysY 库函数 2 | 3 | 本节没有任何语法规范上的变化. 4 | 5 | ## 一个例子 6 | 7 | ```c 8 | int main() { 9 | return getint(); 10 | } 11 | ``` 12 | 13 | ## 词法/语法分析 14 | 15 | 因为语法规范不变, 所以这部分没有需要改动的内容. 16 | 17 | ## 语义分析 18 | 19 | 根据 SysY 的规定, SysY 库函数可以不加声明就直接使用, 所以你可能需要预先在全局符号表中添加和库函数相关的符号定义, 以防无法正确处理相关内容. 20 | 21 | 参考 [SysY 运行时库](/misc-app-ref/sysy-runtime). 22 | 23 | ## IR 生成 24 | 25 | 虽然 SysY 中可以不加声明就使用所有库函数, 但在 Koopa IR 中, 所有被 `call` 指令引用的函数必须提前声明, 否则会出现错误. 你可以使用 `decl` 语句来预先声明所有的库函数. 26 | 27 | 示例程序生成的 Koopa IR 为: 28 | 29 | ```koopa 30 | decl @getint(): i32 31 | decl @getch(): i32 32 | decl @getarray(*i32): i32 33 | decl @putint(i32) 34 | decl @putch(i32) 35 | decl @putarray(i32, *i32) 36 | decl @starttime() 37 | decl @stoptime() 38 | 39 | fun @main(): i32 { 40 | %entry: 41 | %0 = call @getint() 42 | ret %0 43 | } 44 | ``` 45 | 46 | ?> 注: `decl` 要求在括号内写明参数的类型, 某些库函数会接收数组参数, 你可以认为这种参数的类型是 `*i32`, 即 `i32` 的指针. Lv9 将讲解其中的具体原因. 47 | 48 | ## 目标代码生成 49 | 50 | RISC-V 汇编中, 函数符号无需声明即可直接使用. 关于到底去哪里找这些外部符号, 这件事情由链接器负责. 除此之外, 调用库函数和调用 SysY 内定义的函数并无区别. 51 | 52 | Koopa IR 中, 函数声明是一种特殊的函数, 它们和函数定义是放在一起的. 也就是说, 在上一节的基础上, 你需要在扫描函数时跳过 Koopa IR 中的所有函数声明. 53 | 54 | Koopa IR 的函数声明和普通函数的区别是: 函数声明的基本块列表是空的. 在 C/C++ 中, 你可以通过判断 `koopa_raw_function_t` 中 `bbs` 字段对应的 slice 的长度, 来判断函数的基本块列表是否为空. 在 Rust 中, `FunctionData` 提供的 `layout()` 方法会返回函数内基本块/指令的布局, 返回类型为 `&Layout`. 而 `Layout` 中的 `entry_bb()` 方法可以返回函数入口基本块的 ID, 如果函数为声明, 这个方法会返回 `None`. 55 | 56 | 示例程序生成的 RISC-V 汇编为: 57 | 58 | ``` 59 | .text 60 | .globl main 61 | main: 62 | addi sp, sp, -16 63 | sw ra, 12(sp) 64 | call getint 65 | sw a0, 0(sp) 66 | lw a0, 0(sp) 67 | lw ra, 12(sp) 68 | addi sp, sp, 16 69 | ret 70 | ``` 71 | -------------------------------------------------------------------------------- /docs/lv3-expr/comp-n-logical-exprs.md: -------------------------------------------------------------------------------- 1 | # Lv3.3. 比较和逻辑表达式 2 | 3 | 本节新增/变更的语法规范如下: 4 | 5 | ```ebnf 6 | Exp ::= LOrExp; 7 | PrimaryExp ::= ...; 8 | Number ::= ...; 9 | UnaryExp ::= ...; 10 | UnaryOp ::= ...; 11 | MulExp ::= ...; 12 | AddExp ::= ...; 13 | RelExp ::= AddExp | RelExp ("<" | ">" | "<=" | ">=") AddExp; 14 | EqExp ::= RelExp | EqExp ("==" | "!=") RelExp; 15 | LAndExp ::= EqExp | LAndExp "&&" EqExp; 16 | LOrExp ::= LAndExp | LOrExp "||" LAndExp; 17 | ``` 18 | 19 | ## 一个例子 20 | 21 | ```c 22 | int main() { 23 | return 1 <= 2; 24 | } 25 | ``` 26 | 27 | ## 词法/语法分析 28 | 29 | 同上一节. 但需要注意的是, 本节出现了一些两个字符的运算符, 比如例子中的 `<=`. 你需要修改 lexer 来适配这一更改. 30 | 31 | ## 语义分析 32 | 33 | 暂无需要添加的内容. 34 | 35 | ## IR 生成 36 | 37 | 示例代码可以生成如下的 Koopa IR: 38 | 39 | ```koopa 40 | fun @main(): i32 { 41 | %entry: 42 | %0 = le 1, 2 43 | ret %0 44 | } 45 | ``` 46 | 47 | !> **注意:** Koopa IR 只支持按位与或, 而不支持逻辑与或, 但你可以用其他运算拼凑出这些运算. 48 |

49 | 详见本节下一部分的描述. 50 | 51 | ## 目标代码生成 52 | 53 | 关键部分的 RISC-V 汇编如下: 54 | 55 | ``` 56 | li t0, 1 57 | li t1, 2 58 | # 执行小于等于操作 59 | sgt t1, t0, t1 60 | seqz t1, t1 61 | ``` 62 | 63 | 如果你查阅 [RISC-V 规范](https://github.com/riscv/riscv-isa-manual/releases/download/Ratified-IMAFDQC/riscv-spec-20191213.pdf)第 24 章 (Instruction Set Listings, 130 页), 你会发现 RISC-V 只支持小于指令 (`slt` 等). 而上述汇编中出现的 `sgt` 是一个伪指令, 也就是说, 这条指令并不真实存在, 而是用其他指令实现的. 64 | 65 | 已知, `slt t0, t1, t2` 指令的含义是, 判断寄存器 `t1` 的值是否小于 `t2` 的值, 并将结果 (0 或 1) 写入 `t0` 寄存器. 思考: 66 | 67 | * `sgt t0, t1, t2` (判断 `t1` 的值是否大于 `t2` 的值) 是怎么实现的? 68 | * 上述汇编中判断小于等于的原理是什么? 69 | * 如何使用 RISC-V 汇编判断大于等于? 70 | 71 | 你可以使用 [Lv2 提到的方法](/lv2-code-gen/code-gen?id=生成汇编), 看看 Clang 是如何将这些运算翻译成 RISC-V 汇编的, 比如[这个例子](https://godbolt.org/z/59bxe767c). 72 | -------------------------------------------------------------------------------- /docs/lv3-expr/README.md: -------------------------------------------------------------------------------- 1 | # Lv3. 表达式 2 | 3 | 本章中, 你将在上一章的基础上, 实现一个能够处理表达式 (一元/二元) 的编译器. 4 | 5 | 你的编译器将可以处理如下的 SysY 程序: 6 | 7 | ```c 8 | int main() { 9 | return 1 + 2 * -3; 10 | } 11 | ``` 12 | 13 | ## 语法规范 14 | 15 | ```ebnf 16 | CompUnit ::= FuncDef; 17 | 18 | FuncDef ::= FuncType IDENT "(" ")" Block; 19 | FuncType ::= "int"; 20 | 21 | Block ::= "{" Stmt "}"; 22 | Stmt ::= "return" Exp ";"; 23 | 24 | Exp ::= LOrExp; 25 | PrimaryExp ::= "(" Exp ")" | Number; 26 | Number ::= INT_CONST; 27 | UnaryExp ::= PrimaryExp | UnaryOp UnaryExp; 28 | UnaryOp ::= "+" | "-" | "!"; 29 | MulExp ::= UnaryExp | MulExp ("*" | "/" | "%") UnaryExp; 30 | AddExp ::= MulExp | AddExp ("+" | "-") MulExp; 31 | RelExp ::= AddExp | RelExp ("<" | ">" | "<=" | ">=") AddExp; 32 | EqExp ::= RelExp | EqExp ("==" | "!=") RelExp; 33 | LAndExp ::= EqExp | LAndExp "&&" EqExp; 34 | LOrExp ::= LAndExp | LOrExp "||" LAndExp; 35 | ``` 36 | 37 | ## 语义规范 38 | 39 | * 所有表达式的类型均为 `int` 型 (计算结果为整数). 40 | * 对于 `LOrExp`, 当其左右操作数有任意一个非 0 时, 表达式的值为 1, 否则为 0; 对于 `LAndExp`, 当其左右操作数有任意一个为 0 时, 表达式的值为 0, 否则为 1. 上述两种表达式**暂时不需要进行短路求值**. 41 | * SysY 中算符的功能, 优先级与结合性均与 C 语言一致, SysY 的语法规范中已体现了优先级与结合性的定义. 42 | * SysY 中的整数是 32 位有符号整数. 与 C 语言一致, 有符号整数运算溢出在 SysY 中属于未定义行为 ([undefined behavior](https://en.wikipedia.org/wiki/Undefined_behavior)). 43 | 44 | ?> 未定义行为缩写为 UB, 如果你的代码里出现了 UB, 那么程序执行时的行为就是不可定义的 (发生什么事都有可能, ~比如你系统盘被程序格了~). 除了有符号整数溢出, C/C++ 中有很多行为都属于 UB, 比如数组访问越界. [这里](https://gist.github.com/Earnestly/7c903f481ff9d29a3dd1)有一份 C99 的 UB 列表. 45 |

46 | 编译器可以利用 UB 进行一些非常激进的优化. 比如, 编译器可以假定程序永远都不会发生 UB, 然后标记 UB 出现的位置是不可达的, 最后删掉不可达代码. 47 |

48 | 经常有所谓的 “老一辈人士” 给你一些 “忠告”: 编译 C/C++ 的时候优化不能开到 `-O3`, 编译出来的程序会出问题, 因为编译器有 bug——这种说法基本就是扯淡, 因为虽然编译器确实会出现 bug, 但你遇到 bug 的概率总体来说还是很低的. 程序出问题的原因几乎都是代码写出了 UB, 然后被编译器给优化飞了. 解决这个问题的最好办法是, 呃……不要写出 UB. 49 | -------------------------------------------------------------------------------- /docs/lv1-main/README.md: -------------------------------------------------------------------------------- 1 | # Lv1. `main` 函数 2 | 3 | 本章中, 你将实现一个能处理 `main` 函数和 `return` 语句的编译器. 你的编译器会将如下的 SysY 程序: 4 | 5 | ```c 6 | int main() { 7 | // 注释也应该被删掉哦 8 | return 0; 9 | } 10 | ``` 11 | 12 | 编译为对应的 Koopa IR: 13 | 14 | ```koopa 15 | fun @main(): i32 { 16 | %entry: 17 | ret 0 18 | } 19 | ``` 20 | 21 | ## 词法规范 22 | 23 | ### 标识符 24 | 25 | SysY 语言中标识符 `IDENT` (identifier) 的规范如下: 26 | 27 | ```ebnf 28 | identifier ::= identifier-nondigit 29 | | identifier identifier-nondigit 30 | | identifier digit; 31 | ``` 32 | 33 | 其中, `identifier-nondigit` 为下划线, 小写英文字母或大写英文字母; `digit` 为数字 0 到 9. 34 | 35 | ### 数值常量 36 | 37 | SysY 语言中数值常量可以是整型数 `INT_CONST` (integer-const), 其规范如下: 38 | 39 | ```ebnf 40 | integer-const ::= decimal-const 41 | | octal-const 42 | | hexadecimal-const; 43 | decimal-const ::= nonzero-digit 44 | | decimal-const digit; 45 | octal-const ::= "0" 46 | | octal-const octal-digit; 47 | hexadecimal-const ::= hexadecimal-prefix hexadecimal-digit 48 | | hexadecimal-const hexadecimal-digit; 49 | hexadecimal-prefix ::= "0x" | "0X"; 50 | ``` 51 | 52 | 其中, `nonzero-digit` 为数字 1 到 9; `octal-digit` 为数字 0 到 7; `hexadecimal-digit` 为数字 0 到 9, 或大写/小写字母 a 到 f. 53 | 54 | ### 注释 55 | 56 | SysY 语言中注释的规范与 C 语言一致, 如下: 57 | 58 | * 单行注释: 以序列 `//` 开始, 直到换行符结束, 不包括换行符. 59 | * 多行注释: 以序列 `/*` 开始, 直到第一次出现 `*/` 时结束, 包括结束处 `*/`. 60 | 61 | ## 语法规范 62 | 63 | 开始符号为 `CompUnit`. 64 | 65 | ```ebnf 66 | CompUnit ::= FuncDef; 67 | 68 | FuncDef ::= FuncType IDENT "(" ")" Block; 69 | FuncType ::= "int"; 70 | 71 | Block ::= "{" Stmt "}"; 72 | Stmt ::= "return" Number ";"; 73 | Number ::= INT_CONST; 74 | ``` 75 | 76 | ## 语义规范 77 | 78 | * 在本章中, `IDENT` 的名称一定为 `main`. 79 | * `INT_CONST` 的范围为 $[0, 2^{31} - 1]$, 不包含负号. 80 | -------------------------------------------------------------------------------- /docs/assets/js/giscus.js: -------------------------------------------------------------------------------- 1 | const giscusPlugin = (hook, vm) => { 2 | hook.ready(() => { 3 | // Move Giscus container to content area. 4 | const content = document.querySelector('.content') 5 | const giscusContainer = document.querySelector(`.giscus`) 6 | if (content && giscusContainer) { 7 | content.appendChild(giscusContainer) 8 | } 9 | // Initial theme setup. 10 | const iframe = document.querySelector('.giscus-frame') 11 | const themeToggle = document.getElementById('docsify-darklight-theme') 12 | if (iframe && themeToggle) { 13 | toggleGiscusTheme(themeToggle, iframe) 14 | } 15 | }) 16 | hook.doneEach(() => { 17 | // Update `term` parameter. 18 | const iframe = document.querySelector('.giscus-frame') 19 | if (iframe) { 20 | setupGiscusTerm(iframe) 21 | // Add theme toggle event. 22 | const themeToggle = document.getElementById('docsify-darklight-theme') 23 | if (themeToggle) { 24 | themeToggle.addEventListener('click', () => toggleGiscusTheme(themeToggle, iframe)) 25 | } 26 | } 27 | }) 28 | } 29 | 30 | const setupGiscusTerm = (iframe) => { 31 | // Replace `term` parameter in iframe src. 32 | const src = iframe.getAttribute('src') 33 | const term = document.body.getAttribute('data-page').replace(/\.\w+$/, '') 34 | const newSrc = src.replace(/term=[^&]*/, `term=${encodeURIComponent(term)}`) 35 | iframe.setAttribute('src', newSrc) 36 | } 37 | 38 | const toggleGiscusTheme = (toggle, iframe) => { 39 | // Check if dark mode is enabled. 40 | const isDark = toggle.getAttribute('data-link-title') === 'dark' 41 | const theme = isDark ? 'dark_dimmed' : 'light' 42 | // Replace `theme` parameter in iframe src. 43 | const src = iframe.getAttribute('src') 44 | const newSrc = src.replace(/theme=[^&]*/, `theme=${theme}`) 45 | iframe.setAttribute('src', newSrc) 46 | } 47 | 48 | $docsify.plugins.push(giscusPlugin) 49 | -------------------------------------------------------------------------------- /docs/lv5-block-n-scope/implementing.md: -------------------------------------------------------------------------------- 1 | # Lv5.1. 实现 2 | 3 | 本节新增/变更的语法规范如下: 4 | 5 | ```ebnf 6 | Stmt ::= LVal "=" Exp ";" 7 | | [Exp] ";" 8 | | Block 9 | | "return" [Exp] ";"; 10 | ``` 11 | 12 | ## 一个例子 13 | 14 | ```c 15 | int main() { 16 | int a = 1; 17 | { 18 | a = 2; 19 | int a = 3; 20 | } 21 | return a; 22 | } 23 | ``` 24 | 25 | ## 词法/语法分析 26 | 27 | 本节新增了语法规则 `[Exp] ";"`, 这代表一条仅由 `Exp` 组成的语句, 比如 `1 + 2;`. 你可能需要设计新的 AST, 同时更新你的 parser 实现. 28 | 29 | 本节的 EBNF 中出现了一种新的表示: `[ ... ]`, 这代表方括号内包含的项可被重复 0 次或 1 次. 也就是说, 单个分号 (`;`) 在 SysY 程序中也是一个合法的语句. 在 AST 中, 你可以使用空指针或 `Option` 来表示这种结构. 30 | 31 | ## 语义分析 32 | 33 | Lv4 中, 你的编译器已经支持了一种简单的符号表: 这种符号表只支持单个作用域 (不支持作用域嵌套), 但可以检测在当前作用域内的符号重定义情况. 34 | 35 | 本节, 你只需对这个符号表稍加改动, 使其: 36 | 37 | * **支持作用域嵌套:** 你可以把作用域的嵌套理解为, 原先只有一个符号表, 现在可以有多个, 并且它们之间存在层次关系. 38 | * **在进入和退出代码块时更新符号表的层次结构:** 进入代码块时, 在这个结构里新建一个符号表, 这个符号表就代表当前的符号表; 退出代码块时, 删除刚刚创建的符号表, 进入代码块之前的那个符号表就代表当前的符号表. 39 | * **只在当前作用域添加符号:** 也就是说, 只在当前层次的符号表中插入符号定义. 40 | * **能够跨作用域查询符号定义:** 在查询符号定义时, 先在当前符号表中查询, 如果找不到就去上一层中查询. 如果在所有符号表中都没有找到这个符号的定义, 说明输入的 SysY 程序存在语义错误. 41 | 42 | 你可以选用合适的数据结构来实现这种符号表. 43 | 44 | ## IR 生成 45 | 46 | 语句块和作用域只影响了语义分析, IR 生成部分无需做任何修改. 47 | 48 | 示例程序生成的 Koopa IR 为: 49 | 50 | ```koopa 51 | fun @main(): i32 { 52 | %entry: 53 | @a_1 = alloc i32 54 | store 1, @a_1 55 | store 2, @a_1 56 | @a_2 = alloc i32 57 | store 3, @a_2 58 | %0 = load @a_1 59 | ret %0 60 | } 61 | ``` 62 | 63 | 注意: 64 | 65 | * Koopa IR 的函数内不能定义相同的符号. 66 | * 虽然示例程序中没有出现单个 `Exp` 表示的语句, 但在遇到这种情况时, 你必须生成 `Exp` 对应的 IR, 而不能将其跳过. 67 | 68 | ## 目标代码生成 69 | 70 | 由于 IR 生成部分未作修改, 目标代码生成部分也无需变更. 71 | 72 | 示例程序生成的 RISC-V 汇编为: 73 | 74 | ``` 75 | .text 76 | .globl main 77 | main: 78 | addi sp, sp, -16 79 | li t0, 1 80 | sw t0, 0(sp) 81 | li t0, 2 82 | sw t0, 0(sp) 83 | li t0, 3 84 | sw t0, 4(sp) 85 | lw t0, 0(sp) 86 | sw t0, 8(sp) 87 | lw a0, 8(sp) 88 | addi sp, sp, 16 89 | ret 90 | ``` 91 | -------------------------------------------------------------------------------- /docs/lv5-block-n-scope/README.md: -------------------------------------------------------------------------------- 1 | # Lv5. 语句块和作用域 2 | 3 | 本章中, 你将在上一章的基础上, 实现一个能够处理语句快和作用域的编译器. 4 | 5 | 你的编译器将可以处理如下的 SysY 程序: 6 | 7 | ```c 8 | int main() { 9 | int a = 1, b = 2; 10 | { 11 | int a = 2; 12 | b = b + a; 13 | } 14 | return b; 15 | } 16 | ``` 17 | 18 | ## 语法规范 19 | 20 | ```ebnf 21 | CompUnit ::= FuncDef; 22 | 23 | Decl ::= ConstDecl | VarDecl; 24 | ConstDecl ::= "const" BType ConstDef {"," ConstDef} ";"; 25 | BType ::= "int"; 26 | ConstDef ::= IDENT "=" ConstInitVal; 27 | ConstInitVal ::= ConstExp; 28 | VarDecl ::= BType VarDef {"," VarDef} ";"; 29 | VarDef ::= IDENT | IDENT "=" InitVal; 30 | InitVal ::= Exp; 31 | 32 | FuncDef ::= FuncType IDENT "(" ")" Block; 33 | FuncType ::= "int"; 34 | 35 | Block ::= "{" {BlockItem} "}"; 36 | BlockItem ::= Decl | Stmt; 37 | Stmt ::= LVal "=" Exp ";" 38 | | [Exp] ";" 39 | | Block 40 | | "return" [Exp] ";"; 41 | 42 | Exp ::= LOrExp; 43 | LVal ::= IDENT; 44 | PrimaryExp ::= "(" Exp ")" | LVal | Number; 45 | Number ::= INT_CONST; 46 | UnaryExp ::= PrimaryExp | UnaryOp UnaryExp; 47 | UnaryOp ::= "+" | "-" | "!"; 48 | MulExp ::= UnaryExp | MulExp ("*" | "/" | "%") UnaryExp; 49 | AddExp ::= MulExp | AddExp ("+" | "-") MulExp; 50 | RelExp ::= AddExp | RelExp ("<" | ">" | "<=" | ">=") AddExp; 51 | EqExp ::= RelExp | EqExp ("==" | "!=") RelExp; 52 | LAndExp ::= EqExp | LAndExp "&&" EqExp; 53 | LOrExp ::= LAndExp | LOrExp "||" LAndExp; 54 | ConstExp ::= Exp; 55 | ``` 56 | 57 | ## 语义规范 58 | 59 | * 单个 `Exp` 可以作为 `Stmt`. `Exp` 会被求值 (即存在副作用), 但所求的值会被丢弃. 60 | * `Block` 表示语句块. 语句块会创建作用域, 语句块内声明的变量的生存期在该语句块内. 61 | * 作用域是可以嵌套的, 因为语句块是可以嵌套的. 62 | * 语句块内可以再次定义与语句块外同名的变量或常量 (通过 `Decl` 语句), 其作用域从定义处开始到该语句块尾结束, 它覆盖了语句块外的同名变量或常量. 63 | * 对于同一个标识符, 在同一作用域中最多存在一次声明. 64 | * `LVal` 必须是当前作用域内, 该 `Exp` 语句之前曾定义过的变量或常量. 赋值号左边的 `LVal` 必须是变量. 65 | -------------------------------------------------------------------------------- /docs/lv7-while/README.md: -------------------------------------------------------------------------------- 1 | # Lv7. `while` 语句 2 | 3 | 本章中, 你将在上一章的基础上, 实现一个能够处理 `while` 语句的编译器. 4 | 5 | 你的编译器将可以处理如下的 SysY 程序: 6 | 7 | ```c 8 | int main() { 9 | int i = 0, pow = 1; 10 | while (i < 7) { 11 | pow = pow * 2; 12 | i = i + 1; 13 | } 14 | return pow; 15 | } 16 | ``` 17 | 18 | ## 语法规范 19 | 20 | ```ebnf 21 | CompUnit ::= FuncDef; 22 | 23 | Decl ::= ConstDecl | VarDecl; 24 | ConstDecl ::= "const" BType ConstDef {"," ConstDef} ";"; 25 | BType ::= "int"; 26 | ConstDef ::= IDENT "=" ConstInitVal; 27 | ConstInitVal ::= ConstExp; 28 | VarDecl ::= BType VarDef {"," VarDef} ";"; 29 | VarDef ::= IDENT | IDENT "=" InitVal; 30 | InitVal ::= Exp; 31 | 32 | FuncDef ::= FuncType IDENT "(" ")" Block; 33 | FuncType ::= "int"; 34 | 35 | Block ::= "{" {BlockItem} "}"; 36 | BlockItem ::= Decl | Stmt; 37 | Stmt ::= LVal "=" Exp ";" 38 | | [Exp] ";" 39 | | Block 40 | | "if" "(" Exp ")" Stmt ["else" Stmt] 41 | | "while" "(" Exp ")" Stmt 42 | | "break" ";" 43 | | "continue" ";" 44 | | "return" [Exp] ";"; 45 | 46 | Exp ::= LOrExp; 47 | LVal ::= IDENT; 48 | PrimaryExp ::= "(" Exp ")" | LVal | Number; 49 | Number ::= INT_CONST; 50 | UnaryExp ::= PrimaryExp | UnaryOp UnaryExp; 51 | UnaryOp ::= "+" | "-" | "!"; 52 | MulExp ::= UnaryExp | MulExp ("*" | "/" | "%") UnaryExp; 53 | AddExp ::= MulExp | AddExp ("+" | "-") MulExp; 54 | RelExp ::= AddExp | RelExp ("<" | ">" | "<=" | ">=") AddExp; 55 | EqExp ::= RelExp | EqExp ("==" | "!=") RelExp; 56 | LAndExp ::= EqExp | LAndExp "&&" EqExp; 57 | LOrExp ::= LAndExp | LOrExp "||" LAndExp; 58 | ConstExp ::= Exp; 59 | ``` 60 | 61 | ## 语义规范 62 | 63 | * `while` 循环中, 先计算条件部分, 再执行循环体. 64 | * `break` 和 `continue` 语句必须出现在循环内. 65 | * `break` 语句会终止离它最近的一层循环的执行. 66 | * `continue` 语句会使控制流转移到离它最近的一层循环的末尾. 67 | -------------------------------------------------------------------------------- /docs/lv0-env-config/riscv.md: -------------------------------------------------------------------------------- 1 | # Lv0.3. RISC-V 简介 2 | 3 | ?> 本节将带你大致认识 RISC-V 指令系统, 后续章节中将结合实践内容, 详细介绍 RISC-V 指令系统中对应部分的特性. 4 |

5 | 关于 RISC-V 的更多介绍, 请参考 [RISC-V 官网](https://riscv.org/). 关于 RISC-V 中指令的相关定义, 请参考 [RISC-V 指令速查](/misc-app-ref/riscv-insts). 6 | 7 | ## 什么是 RISC-V 8 | 9 | 在编译实践中, 你将开发一个生成 RISC-V 汇编的编译器. 那么首先, 什么是 RISC-V? 10 | 11 | RISC-V, 读作 “risk-five”, 是由加州大学伯克利分校设计并推广的第五代 RISC 指令系统体系结构 (ISA). RISC-V 没有任何历史包袱, 设计简洁, 高效低能耗, 且高度模块化——最主要的, 它还是一款完全开源的 ISA. 12 | 13 | RISC-V 的指令系统由基础指令系统 (base instruction set) 和指令系统扩展 (extension) 构成. 每个 RISC-V 处理器必须实现基础指令系统, 同时可以支持若干扩展. 常用的基础指令系统有两种: 14 | 15 | * `RV32I`: 32 位整数指令系统. 16 | * `RV64I`: 64 位整数指令系统. 兼容 `RV32I`. 17 | 18 | 常用的标准指令系统扩展包括: 19 | 20 | * `M` 扩展: 包括乘法和除法相关的指令. 21 | * `A` 扩展: 包括原子内存操作相关的指令. 22 | * `F` 扩展: 包括单精度浮点操作相关的指令. 23 | * `D` 扩展: 包括双精度浮点操作相关的指令. 24 | * `C` 扩展: 包括常用指令的 16 位宽度的压缩版本. 25 | 26 | 我们通常使用 `RV32/64I` + 扩展名称的方式来描述某个处理器/平台支持的 RISC-V 指令系统类型, 例如 `RV32IMA` 代表这个处理器是一个 32 位的, 支持 `M` 和 `A` 扩展的 RISC-V 处理器. 27 | 28 | 在课程实践中, 你的编译器将生成 `RV32IM` 范围内的 RISC-V 汇编. 29 | 30 | 一个使用 RISC-V 汇编编写的程序如下: 31 | 32 | ```asm 33 | # 代码段. 34 | .text 35 | # `main` 函数, 程序的入口. 36 | .globl main 37 | main: 38 | addi sp, sp, -16 39 | sw ra, 12(sp) 40 | sw s0, 8(sp) 41 | sw s1, 4(sp) 42 | la s0, hello_str 43 | li s1, 0 44 | 1: 45 | add a0, s0, s1 46 | lbu a0, 0(a0) 47 | beqz a0, 1f 48 | call putch 49 | addi s1, s1, 1 50 | j 1b 51 | 1: 52 | li a0, 0 53 | lw s1, 4(sp) 54 | lw s0, 8(sp) 55 | lw ra, 12(sp) 56 | addi sp, sp, 16 57 | ret 58 | 59 | # 数据段. 60 | .data 61 | # 字符串 "Hello, world!\n\0". 62 | hello_str: 63 | .asciz "Hello, world!\n" 64 | ``` 65 | 66 | ## 编译/运行 RISC-V 程序 67 | 68 | 假设你已经把一个 RISC-V 汇编程序保存在了文件 `hello.S` 中, 你可以在实验环境中将这个 RISC-V 程序汇编并链接成可执行文件, 然后运行这个可执行文件: 69 | 70 | ``` 71 | clang hello.S -c -o hello.o -target riscv32-unknown-linux-elf -march=rv32im -mabi=ilp32 72 | ld.lld hello.o -L$CDE_LIBRARY_PATH/riscv32 -lsysy -o hello 73 | qemu-riscv32-static hello 74 | ``` 75 | -------------------------------------------------------------------------------- /docs/lv6-if/README.md: -------------------------------------------------------------------------------- 1 | # Lv6. `if` 语句 2 | 3 | 本章中, 你将在上一章的基础上, 实现一个能够处理 `if/else` 语句的编译器. 4 | 5 | 你的编译器将可以处理如下的 SysY 程序: 6 | 7 | ```c 8 | int main() { 9 | int a = 1; 10 | if (a == 2 || a == 3) { 11 | return 0; 12 | } else { 13 | return a + 1; 14 | } 15 | } 16 | ``` 17 | 18 | ## 语法规范 19 | 20 | ```ebnf 21 | CompUnit ::= FuncDef; 22 | 23 | Decl ::= ConstDecl | VarDecl; 24 | ConstDecl ::= "const" BType ConstDef {"," ConstDef} ";"; 25 | BType ::= "int"; 26 | ConstDef ::= IDENT "=" ConstInitVal; 27 | ConstInitVal ::= ConstExp; 28 | VarDecl ::= BType VarDef {"," VarDef} ";"; 29 | VarDef ::= IDENT | IDENT "=" InitVal; 30 | InitVal ::= Exp; 31 | 32 | FuncDef ::= FuncType IDENT "(" ")" Block; 33 | FuncType ::= "int"; 34 | 35 | Block ::= "{" {BlockItem} "}"; 36 | BlockItem ::= Decl | Stmt; 37 | Stmt ::= LVal "=" Exp ";" 38 | | [Exp] ";" 39 | | Block 40 | | "if" "(" Exp ")" Stmt ["else" Stmt] 41 | | "return" [Exp] ";"; 42 | 43 | Exp ::= LOrExp; 44 | LVal ::= IDENT; 45 | PrimaryExp ::= "(" Exp ")" | LVal | Number; 46 | Number ::= INT_CONST; 47 | UnaryExp ::= PrimaryExp | UnaryOp UnaryExp; 48 | UnaryOp ::= "+" | "-" | "!"; 49 | MulExp ::= UnaryExp | MulExp ("*" | "/" | "%") UnaryExp; 50 | AddExp ::= MulExp | AddExp ("+" | "-") MulExp; 51 | RelExp ::= AddExp | RelExp ("<" | ">" | "<=" | ">=") AddExp; 52 | EqExp ::= RelExp | EqExp ("==" | "!=") RelExp; 53 | LAndExp ::= EqExp | LAndExp "&&" EqExp; 54 | LOrExp ::= LAndExp | LOrExp "||" LAndExp; 55 | ConstExp ::= Exp; 56 | ``` 57 | 58 | ## 语义规范 59 | 60 | * 当 `Exp` 出现在表示条件判断的位置时 (例如出现在 `if` 的条件中), 表达式值为 0 时为假, 非 0 时为真. 61 | * `Stmt` 中的 `if` 型语句遵循就近匹配的原则, 即 `else` 总和离它最近且没有匹配到 `else` 的 `if` 进行匹配. 例如: 62 | 63 | ```c 64 | if (x) if (y) ...; else ...; 65 | // 等价于 66 | if (x) { 67 | if (y) { 68 | ...; 69 | } else { 70 | ...; 71 | } 72 | } 73 | ``` 74 | -------------------------------------------------------------------------------- /docs/lv4-const-n-var/const.md: -------------------------------------------------------------------------------- 1 | # Lv4.1. 常量 2 | 3 | 本节新增/变更的语法规范如下: 4 | 5 | ```ebnf 6 | Decl ::= ConstDecl; 7 | ConstDecl ::= "const" BType ConstDef {"," ConstDef} ";"; 8 | BType ::= "int"; 9 | ConstDef ::= IDENT "=" ConstInitVal; 10 | ConstInitVal ::= ConstExp; 11 | 12 | Block ::= "{" {BlockItem} "}"; 13 | BlockItem ::= Decl | Stmt; 14 | 15 | LVal ::= IDENT; 16 | PrimaryExp ::= "(" Exp ")" | LVal | Number; 17 | 18 | ConstExp ::= Exp; 19 | ``` 20 | 21 | ## 一个例子 22 | 23 | ```c 24 | int main() { 25 | const int x = 1 + 1; 26 | return x; 27 | } 28 | ``` 29 | 30 | ## 词法/语法分析 31 | 32 | 本节增加了一些新的关键字, 你需要修改 lexer 来支持它们. 同样, 你需要根据新增的语法规则, 来设计新的 AST, 以及更新你的 parser 实现. 33 | 34 | 本节的 EBNF 中出现了一种新的表示: `{ ... }`, 这代表花括号内包含的项可被重复 0 次或多次. 在 AST 中, 你可以使用 `std::vector`/`Vec` 来表示这种结构. 35 | 36 | ## 语义分析 37 | 38 | 本章的语义规范较前几章来说复杂了许多, 你需要在编译器中引入一些额外的结构, 以便进行必要的语义分析. 这种结构叫做**符号表**. 39 | 40 | 符号表可以记录作用域内所有被定义过的符号的信息. 在本节中, 符号表负责记录 `main` 函数中, 常量符号和其值之间的关系. 具体来说, 符号表需要支持如下操作: 41 | 42 | * **插入符号定义:** 向符号表中添加一个常量符号, 同时记录这个符号的常量值, 也就是一个 32 位整数. 43 | * **确认符号定义是否存在:** 给定一个符号, 查询符号表中是否存在这个符号的定义. 44 | * **查询符号定义:** 给定一个符号表中已经存在的符号, 返回这个符号对应的常量值. 45 | 46 | 你可以选用合适的数据结构来实现符号表. 47 | 48 | 在遇到常量声明语句时, 你应该遍历 AST, 直接算出语句右侧的 `ConstExp` 的值, 得到一个 32 位整数, 然后把这个常量定义插入到符号表中. 49 | 50 | 在遇到 `LVal` 时, 你应该从符号表中查询这个符号的值, 然后用查到的结果作为常量求值/IR 生成的结果. 如果没查到, 说明 SysY 程序出现了语义错误, 也就是程序里使用了未定义的常量. 51 | 52 | 你可能需要给你的 AST 扩展一些必要的方法, 来实现编译期常量求值. 53 | 54 | !> SysY 中 “常量” 的定义和 C 语言中的定义有所区别: SysY 中, 所有的常量必须能在编译时被计算出来; 而 C 语言中的常量仅代表这个量不能被修改. 55 |

56 | SysY 中的常量有些类似于 C++ 中的 `consteval`, 或 Rust 中的 `const`. 57 | 58 | ## IR 生成 59 | 60 | 所有的常量定义均已在编译期被求值, 所以: 61 | 62 | * `Exp` 里, 所有出现 `LVal` 的地方均可直接替换为整数常量. 63 | * 因上一条, 常量声明本身不需要生成任何 IR. 64 | 65 | 综上所述, 本节的 IR 生成部分不需要做任何修改. 66 | 67 | 示例程序生成的 Koopa IR 为: 68 | 69 | ```koopa 70 | fun @main(): i32 { 71 | %entry: 72 | ret 2 73 | } 74 | ``` 75 | 76 | ## 目标代码生成 77 | 78 | 由于 IR 生成部分未作修改, 目标代码生成部分也无需变更. 79 | 80 | 示例程序生成的 RISC-V 汇编为: 81 | 82 | ``` 83 | .text 84 | .globl main 85 | main: 86 | li a0, 2 87 | ret 88 | ``` 89 | -------------------------------------------------------------------------------- /docs/lv0-env-config/language.md: -------------------------------------------------------------------------------- 1 | # Lv0.4 选择你的编程语言 2 | 3 | 自 2021 年秋季学期开始, 在编译实践中, 你可以使用 C, C++ 或者 Rust 来开发你的编译器. 4 | 5 | ## 获取项目模板 6 | 7 | 在编译实践中, 无论是在线评测系统还是本地的自动测试脚本, 都要求你的编译器仓库里具备 Make/CMake/Cargo 三者之一的配置文件. 这样, 评测工具才能利用 Make/CMake/Cargo 来编译你的编译器, 并进行后续评测. 8 | 9 | 考虑到大部分同学对这些工具并不了解, 我们制作了三个对应的项目模板, 模板里已经包含了可直接使用的 `Makefile`, `CMakeLists.txt` 或 `Cargo.toml`. 你可以在模板的 `src` 目录中新建源代码文件, 然后开始开发你的编译器. 10 | 11 | * **Make 模板:** 参考 [`sysy-make-template`](https://github.com/pku-minic/sysy-make-template), 使用 C/C++ 开发编译器的同学可以参考. 12 | * **CMake 模板:** 参考 [`sysy-cmake-template`](https://github.com/pku-minic/sysy-cmake-template), 使用 C/C++ 开发编译器的同学可以参考. 13 | * **Cargo 模板:** 你并不需要任何模板, 你只需要在安装好 Rust 工具链的情况下, 执行 `cargo new 项目名称`, 然后当前目录下就会多出一个名为 `项目名称` 的目录, 在这个目录中开发即可. 当然, 为了和 Make/CMake 模板呼应, 我们还是新建了一个叫做 [`sysy-cargo-template`](https://github.com/pku-minic/sysy-cargo-template) 的项目. 14 | 15 | 请务必仔细阅读模板中的 `README`. 如果你使用 Make/CMake 模板, 你应该根据你选择的语言 (C/C++), 更新 `Makefile`/`CMakeLists.txt` 中 `CPP_MODE` 参数的值. 16 | 17 | ## 建议的开发方式 18 | 19 | 我们建议大家: 20 | 21 | 1. 在你的宿主机 (Windows/macOS/Linux) 中完成 Docker 的配置. 22 | 2. 在你的宿主机中安装并配置合适的编辑器/IDE, 例如 VS Code 或 IDEA. 23 | 3. 选择对应的项目模板, 并在宿主机中开发你的编译器. 24 | 4. 配置在线评测平台的 GitLab, 并上传你的项目, 时刻使用 Git 管理代码. 25 | 5. 当需要本地测试时, 使用 Docker 环境中的自动测试脚本进行测试 (之后章节会介绍). 26 | 6. 当需要在线评测时, 向在线评测平台提交你的 GitLab 仓库 (之后章节会介绍). 27 | 28 | 课程结束后, 如果你什么都不想带走, 只需要: 29 | 30 | 1. 删除你的项目. 31 | 2. 删除 Docker 里的实验环境镜像, 必要时也可以删除 Docker. 32 | 33 | ## 建议的编程语言 34 | 35 | **通常情况下, 我们建议你使用 C/C++ 开发你的编译器.** 这是最稳妥的选择, 因为你一定在很多其他课程中学习/使用过这两门编程语言. 在编译实践中, 除了使用 C/C++ 编程, 你还会遇到很多其他问题, 例如: 36 | 37 | * 如何将你的项目分成多个部分, 拆分到多个文件中实现? 38 | * 如何使用 C/C++ 构造复杂的数据结构? 39 | * 如何优雅地管理内存? 40 | * 如何调试, 定位并解决一些看起来很没有头绪的问题? 41 | 42 | 等等. 如果你之前仅限于 “会使用 C/C++ 编程”, 相信在完成编译实践之后, 你会对 “如何使用 C/C++ 解决一个工程问题” 这件事有一个更为全面的认识. 43 | 44 | **我们建议之前接触过 Rust, 且对 Rust 感兴趣的同学使用 Rust 开发你的编译器:** 45 | 46 | * 一方面, 课程中的 Koopa IR 框架本身就是使用 Rust 开发的, 它在 Rust 层面提供了更丰富的接口和更合理的抽象. 47 | * 另一方面, 如果你之前并未使用 Rust 完成过较为复杂的项目, 那么使用 Rust 开发编译器会是一个很不错的开始, 并且我们相信在此之后, 你对 Rust 的理解也会更进一步. 48 | 49 | **我们不建议之前从未接触过 Rust, 对内存管理/所有权等概念不敏感, 且对自己编码水平没什么信心的同学使用 Rust 开发编译器.** 对这些同学来说, 在编译实践中上手 Rust 可能会比较痛苦. 50 | -------------------------------------------------------------------------------- /docs/lv4-const-n-var/README.md: -------------------------------------------------------------------------------- 1 | # Lv4. 常量和变量 2 | 3 | 本章中, 你将在上一章的基础上, 实现一个能够处理常量/变量定义和赋值语句的编译器. 4 | 5 | 你的编译器将可以处理如下的 SysY 程序: 6 | 7 | ```c 8 | int main() { 9 | const int x = 233 * 4; 10 | int y = 10; 11 | y = y + x / 2; 12 | return y; 13 | } 14 | ``` 15 | 16 | ## 语法规范 17 | 18 | ```ebnf 19 | CompUnit ::= FuncDef; 20 | 21 | Decl ::= ConstDecl | VarDecl; 22 | ConstDecl ::= "const" BType ConstDef {"," ConstDef} ";"; 23 | BType ::= "int"; 24 | ConstDef ::= IDENT "=" ConstInitVal; 25 | ConstInitVal ::= ConstExp; 26 | VarDecl ::= BType VarDef {"," VarDef} ";"; 27 | VarDef ::= IDENT | IDENT "=" InitVal; 28 | InitVal ::= Exp; 29 | 30 | FuncDef ::= FuncType IDENT "(" ")" Block; 31 | FuncType ::= "int"; 32 | 33 | Block ::= "{" {BlockItem} "}"; 34 | BlockItem ::= Decl | Stmt; 35 | Stmt ::= LVal "=" Exp ";" 36 | | "return" Exp ";"; 37 | 38 | Exp ::= LOrExp; 39 | LVal ::= IDENT; 40 | PrimaryExp ::= "(" Exp ")" | LVal | Number; 41 | Number ::= INT_CONST; 42 | UnaryExp ::= PrimaryExp | UnaryOp UnaryExp; 43 | UnaryOp ::= "+" | "-" | "!"; 44 | MulExp ::= UnaryExp | MulExp ("*" | "/" | "%") UnaryExp; 45 | AddExp ::= MulExp | AddExp ("+" | "-") MulExp; 46 | RelExp ::= AddExp | RelExp ("<" | ">" | "<=" | ">=") AddExp; 47 | EqExp ::= RelExp | EqExp ("==" | "!=") RelExp; 48 | LAndExp ::= EqExp | LAndExp "&&" EqExp; 49 | LOrExp ::= LAndExp | LOrExp "||" LAndExp; 50 | ConstExp ::= Exp; 51 | ``` 52 | 53 | ## 语义规范 54 | 55 | * `ConstDef` 用于定义常量. `ConstDef` 中的 `IDENT` 为常量的标识符, 在 `=` 之后是初始值. 56 | * `VarDef` 用于定义变量. 当不含有 `=` 和初始值时, 其运行时实际初值未定义. 57 | * 当 `VarDef` 含有 `=` 和初始值时, `=` 右边的 `InitVal` 和 `ConstInitVal` 的结构要求相同, 唯一的不同是 `ConstInitVal` 中的表达式是 `ConstExp` 常量表达式, 而 `InitVal` 中的表达式可以是当前上下文合法的任何 `Exp`. 58 | * `ConstExp` 内使用的 `IDENT` 必须是常量, 所有 `ConstExp` 必须在编译时被计算出来. 59 | * `Block` 内不允许声明重名的变量或常量. 60 | * `Block` 内定义的变量/常量在定义处到该语句块尾的范围内有效. 61 | * 变量/常量的名字可以是 `main`. 62 | * `Exp` 内出现的 `LVal` 必须是该 `Exp` 语句之前曾定义过的变量或常量. 63 | * 对于赋值语句, 赋值号左边的 `LVal` 必须是变量, 不能是常量. 64 | * 和 C 语言不同, SysY 中的赋值语句不会返回任何结果. 65 | * `main` 函数中必须至少存在一条 `return` 语句. 不存在 `return` 时, `main` 函数的返回值是未定义的. 66 | -------------------------------------------------------------------------------- /docs/preface/facing-problems.md: -------------------------------------------------------------------------------- 1 | # 如何面对问题 2 | 3 | !> 请仔细阅读本节, 在你遇到问题时, 这些内容可能会很有用. 4 | 5 | ## 面对问题/完成实践的态度 6 | 7 | 在完成编译实践, 乃至你日常编码的过程中, 你可能会遇到很多问题, 比如: 8 | 9 | * 不清楚某个工具的用法. 10 | * 不清楚某个功能实现的思路. 11 | * 编译/链接代码时出现警告或报错. 12 | * 运行程序时段错误. 13 | * 程序输出的结果和你的预期相去甚远. 14 | * 其他各种各样的问题…… 15 | 16 | 与其说, 遇到问题是一件很正常的事, 不如说, 没遇到问题才是一件不正常的事情. 17 | 18 | 我们希望你在完成课程实践时, 不要: 19 | 20 | * 抱着应付的态度, 遇到问题时随便改改自己的代码, 能过测试用例就万岁了. 21 | * 发现随便改改也没用, 于是直接找大佬/助教/老师求助, 不加任何思考. 22 | * 求助的时候问一些相当没价值的问题, 比如: “我的程序出错了, 怎么办”. 23 | * 想象一下你的电脑小白朋友, 三天两头来问你 “我电脑坏了, 怎么办?”, 同时完全不说到底发生甚摸事了, 你还能否保持低血压的健康生活. 24 | * 在某些国内网站上一通乱搜, 找到了另一些国内网站上的低质量内容, 大抄特抄. 25 | * ~然后丝毫没有意识到, 自己在几天后遇到的某个问题就是因为抄了垃圾代码而导致的.~ 26 | * 拖到最后才开始做课程实践, 发现自己做不完了, 疯狂求助大佬/助教/老师. 27 | 28 | 你应该: 29 | 30 | * 不放过任何一个问题, **包括编译代码时的警告**. 31 | * 遇到问题时对背后的原因多加思考, 找出它的深层原因. 32 | * 独立解决问题, 同时**不要怕尝试, 不要怕 debug**, 你会在这个过程中学到相当多的东西. 33 | * 遇到自己解决不了的问题: **STFW** (Search The F... Fantastic Web), **RTFM** (Read The Fantastic Manual), **RTFSC** (Read The Fantastic Source Code). 搜索引擎和文档/手册里通常能找到绝大部分问题的答案, 如果没有, 源码里一定会有. 34 | * 学会摆脱对百度的依赖, **在 Google/Bing 上用英文搜索问题**. 中文互联网什么样相信各位心中自有 B-tree. 35 | 36 | ?> 如果你在解决问题的过程中实在坚持不下去了, [听听尼尔叔叔的鼓励](https://www.bilibili.com/video/BV1Fi4y1t7uG). 37 | 38 | ## 如何提问 39 | 40 | 据助教 MaxXing 随口统计, 在往年的编译实践课程中, 高达 80% 的同学完全不会提问. 41 | 42 | > 助教, 我的编译器崩溃了, 怎么办? 43 | 44 | 助教听了这个问题, 也崩溃了, 怎么办? 45 | 46 | 请不要再这样提问了, 这种问题没有任何价值. 以及, 有些问题在文档里稍微搜索一下就可以解决, 或者哪怕同学们稍微亲手尝试一下也可以解决, 比如: “RISC-V 汇编里可以写 `add t0, t1, 123` 吗?” 与其花时间问别人, 你不如自己直接写个汇编文件, 然后让汇编器帮你检查. 47 | 48 | 我们希望大家: 49 | 50 | * **提问前先思考:** 自己能否做一些尝试来解决这个问题? 比如各类和语言语法, 工具行为相关的问题. 51 | * **学会如何正确提问:** 仔细阅读[提问的智慧](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md)和[别像弱智一样提问](https://github.com/tangx/Stop-Ask-Questions-The-Stupid-Ways/blob/master/README.md). 52 | * 如果你还是不知道怎么提问, 参考如下的提问模板: 53 | 54 | ``` 55 | 我的编译器在输入为 XXX 的时候出现了 YYY 问题 (附完整的报错信息或截图). 56 | 我尝试 AAA, 发现 BBB, 我认为这代表 CCC. 57 | 我还尝试 DDD, 发现 EEE, 我认为这代表 FFF. 58 | 综上所述, 我觉得问题可能出在 GGG, 但之后我就没思路了, 请问我的分析是否正确? 问题的具体原因是什么呢? 59 | ``` 60 | 61 | ## 助教不会回答/会回答的提问 62 | 63 | 助教不会回答: 64 | 65 | * 和课程无关的提问. 66 | * 任何不加思考就提出的提问. 67 | * 能在文档里找到的提问. 68 | * 可以通过你自己尝试/调试解决的提问. 69 | 70 | 助教会回答: 71 | 72 | * 关于评测平台/实验框架存在的问题的提问. 73 | * 关于反馈实验设计不合理的提问. 74 | * 详细描述出错原因, 附带解决问题的尝试, 同时带有自己思考的提问. 75 | 76 | ## 为什么要设置这一节 77 | 78 | 为什么设置这一节, 并把它放在开头? 79 | 80 | 1. 都是血的教训. 81 | 2. 以前的课不光折磨同学们, 也折磨助教啊😭. 82 | -------------------------------------------------------------------------------- /docs/lv0-env-config/koopa.md: -------------------------------------------------------------------------------- 1 | # Lv0.2. Koopa IR 简介 2 | 3 | ?> 本节将带你大致了解什么是 Koopa IR, 后续章节中将结合实践内容, 详细介绍 Koopa IR 对应部分的特性. 4 |

5 | 关于 Koopa IR 的具体定义, 请参考 [Koopa IR 规范](/misc-app-ref/koopa). 6 | 7 | ## 什么是 Koopa IR 8 | 9 | Koopa IR 是一种专为北京大学编译原理课程实践设计的教学用的中间表示 (IR), 它在设计上类似 LLVM IR, 但简化了很多内容, 方便大家上手和理解. 10 | 11 | 同时, 我们为 Koopa IR 开发了对应的框架 ([koopa](https://github.com/pku-minic/koopa) 和 [libkoopa](https://github.com/pku-minic/koopa/tree/master/libkoopa)), 大家在使用 C/C++/Rust 编程时, 可以直接调用框架的接口, 实现 Koopa IR 的生成/解析/转换. 12 | 13 | Koopa IR 是一种强类型的 IR, IR 中的所有值 (`Value`) 和函数 (`Function`) 都具备类型 (`Type`). 这种设计避免了一些 IR 定义上的模糊之处, 例如之前的教学用 IR 完全不区分整数变量和数组变量, 很容易出现混淆; 同时可以在生成 IR 之前就确定 IR 中存在的部分问题, 例如将任意整数作为内存地址并向其中存储数据. 14 | 15 | Koopa IR 中, 基本块 (basic block) 必须是显式定义的. 即, 在描述函数内的指令时, 你必须把指令按照基本块分组, 每个基本块结尾的指令只能是分支/跳转/函数返回指令之一. 在 IR 的数据结构表示上, 指令也会被按照基本块分类. 这很大程度上方便了 IR 的优化, 因为许多优化算法都是在基本块的基础上对程序进行分析/变换的. 16 | 17 | Koopa IR 还是一种 SSA 形式的 IR. 虽然这部分内容在课程实践中并非必须掌握, 但考虑到有些同学可能希望在课程实践的要求上, 做出一个更完备, 更强大的编译器, 我们将 Koopa IR 设计成了同时兼容非 SSA 形式和 SSA 形式的样子. 基于 SSA 形式下的 Koopa IR, 你可以开展更多复杂且有效的编译优化. 18 | 19 | 一个用 Koopa IR 编写的 “Hello, world!” 程序如下: 20 | 21 | ```koopa 22 | // SysY 中的 `putch` 函数的声明. 23 | decl @putch(i32) 24 | 25 | // 一个用来输出字符串 (其实是整数数组) 的函数. 26 | // 函数会扫描输入的数组, 将数组中的整数视作 ASCII 码, 并作为字符输出到屏幕上, 27 | // 遇到 0 时停止扫描. 28 | fun @putstr(@arr: *i32) { 29 | %entry: 30 | jump %loop_entry(@arr) 31 | 32 | // Koopa IR 采用基本块参数代替 SSA 形式中的 Phi 函数. 33 | // 当然这部分内容并不在实践要求的必选内容之中, 你无需过分关注. 34 | %loop_entry(%ptr: *i32): 35 | %cur = load %ptr 36 | br %cur, %loop_body, %end 37 | 38 | %loop_body: 39 | call @putch(%cur) 40 | %next = getptr %ptr, 1 41 | jump %loop_entry(%next) 42 | 43 | %end: 44 | ret 45 | } 46 | 47 | // 字符串 "Hello, world!\n\0". 48 | global @str = alloc [i32, 15], { 49 | 72, 101, 108, 108, 111, 44, 32, 119, 111, 114, 108, 100, 33, 10, 0 50 | } 51 | 52 | // `main` 函数, 程序的入口. 53 | fun @main(): i32 { 54 | %entry: 55 | %str = getelemptr @str, 0 56 | call @putstr(%str) 57 | ret 0 58 | } 59 | ``` 60 | 61 | ?> **注意:** 上述代码只是一个示例, **你暂时不需要理解它的含义.** 在之后的章节中, 我们会逐步介绍 Koopa IR 的相关内容. 62 | 63 | ## 在线体验 Koopa IR 64 | 65 | ?> **TODO:** 待补充 66 | 67 | ## 本地运行 Koopa IR 68 | 69 | 假设你已经把一个 Koopa IR 程序保存在了文件 `hello.koopa` 中, 你可以在实验环境中运行这个 Koopa IR 程序: 70 | 71 | ``` 72 | koopac hello.koopa | llc --filetype=obj -o hello.o 73 | clang hello.o -L$CDE_LIBRARY_PATH/native -lsysy -o hello 74 | ./hello 75 | ``` 76 | -------------------------------------------------------------------------------- /docs/preface/lab.md: -------------------------------------------------------------------------------- 1 | # 实验说明 2 | 3 | ## 概述 4 | 5 | 在编译原理课程实践中, 大家需要开发一个将 SysY 语言编译到 RISC-V 汇编的编译器. 当然, 这一步跨得有些太大了, 所以我们把它分成了两个步骤: 6 | 7 | 1. 将 SysY 语言编译到 Koopa IR. 8 | 2. 将 Koopa IR 生成到 RISC-V 汇编. 9 | 10 | Koopa IR 是我们为编译原理课程实践设计的一种中间表示 (IR, intermediate representation). 相比于之前编译实践课采用的 IR, Koopa IR 在设计上更为合理. 它在形式上类似于 LLVM IR, 但简化了相当多的内容, 只专注于实践需要的部分. 11 | 12 | 与此同时, 我们为 Koopa IR 设计了[配套的运行时库](https://github.com/pku-minic/koopa), 你的编译器可以借助这套运行时库轻松地解析/生成/修改/输出 Koopa IR, 你完全不需要对其他无用的细节做过多考虑. 13 | 14 | ## 提交代码 15 | 16 | 我们设置了[在线评测系统 (OJ)](https://course.educg.net) 来评测大家完成的编译器. 开发完毕后, 你应该将自己的代码上传到 Git 仓库 (OJ 自带了一个 [GitLab](https://gitlab.eduxiji.net)), OJ 会拉取仓库中的代码, 并使用你的编译器编译各类测试用例, 根据编译后测试用例的执行结果来判断你的编译器功能是否正常, 据此确定你实践部分的成绩. 17 | 18 | 考虑到实际情况: 19 | 20 | 1. 有些大佬/巨佬/超佬/神不满足于我们提供的 IR, 他们决定自己完成从 SysY 到 RISC-V 中包括 IR 设计的全部内容, 甚至在编译代码的过程中还对代码进行了优化. 21 | 2. 而有些不那么大佬的同学已经把 C/C++ 忘得差不多了, 他们即便是按照我们划分后的步骤去完成实验, 也觉得十分吃力. 22 | 23 | 对于第一点, 我们的评测系统会设置以下提交入口: 24 | 25 | * **SysY 到 Koopa:** 评测机会把 SysY 代码喂给你的编译器, 并期待你的编译器输出 Koopa IR. 评测机会检查输出的正确性. 26 | * **SysY 到 RISC-V:** 同上, 但输入和输出分别是 SysY 和 RISC-V 汇编. 不管你的编译器用什么 IR, 只要输出了正确的汇编, 就可以通过评测. 27 | * **性能测试:** 输入和输出分别是 SysY 和 RISC-V 汇编, 评测机不仅会检查输出的正确性, 还会记录输出程序的执行时间, 得到性能数据, 同时设置一个性能排行榜 ~(直接开卷)~. 28 | 29 | 文档的后续章节将进一步介绍在线评测系统的细节. 30 | 31 | 对于第二点, 我们的文档在内容组织上做了诸多考虑. 32 | 33 | ## 文档的内容组织 34 | 35 | 这篇在线文档会按照 SysY 到 Koopa IR 再到 RISC-V 汇编的思路, 将两个步骤进一步细分为十个阶段, 引导大家从零开始, 由易到难, 逐步且增量地构建一个真正的编译器: 36 | 37 | * **Lv0 - Lv2:** 熟悉实验环境和框架, 构建一个只能处理 `main` 函数和 `return` 的简易编译器. 38 | * **Lv3:** 为你的编译器添加处理表达式的功能 (加减乘除模/比较运算/逻辑运算). 39 | * **Lv4:** 为你的编译器添加处理变量/常量的功能. 40 | * **Lv5:** 为你的编译器添加处理语句块的功能, 也就是用 `{` 和 `}` 包裹起来的一系列语句, 同时引入作用域的概念. 41 | * **Lv6 - Lv7:** 为你的编译器添加处理 `if` 语句和 `while` 语句的功能, 此时你的编译器已经可以处理程序中所有的控制流了. 42 | * **Lv8:** 为你的编译器添加处理函数和全局变量的功能. 43 | * **Lv9:** 为你的编译器添加处理数组的功能, 此时你的编译器已经可以方便地写出大部分实用程序了. 44 | 45 | 当然, 如果你学有余力, 我们还在 **Lv9+** 阶段准备了更多可选内容: 46 | 47 | * **Lv9+.1:** 让你的编译器能够编译一些更复杂, 但更有趣的程序. 48 | * **Lv9+.2 - Lv9+.5:** 让你的编译器支持寄存器分配, 优化, SSA 形式等高级功能, 并进行性能测试. 49 | * **Lv9+.6:** 一些和 SysY 语言无关的, 全新的编译方向的课题. 50 | 51 | 完成可选内容可以获得加分. 52 | 53 | ## 评分规则 54 | 55 | ?> **TODO:** 待补充 56 | 57 | ## 截止时间 58 | 59 | ?> 以下规定适用于 2022 年春季学期. 60 | 61 | 1. 在线评测部分的软性截止时间为北京时间 2022 年 6 月 12 日 (周日) 23:59. 62 | 2. 在线评测部分的硬性截止时间为北京时间 2022 年 6 月 25 日 (周六) 23:59, 此后在线评测系统将不再接受任何提交. 63 | 3. 报告, 面测, 以及在线评测的性能测试部分的截止时间同样安排在 2022 年 6 月 25 日 (周六) 23:59. 64 | 65 | ## 学术诚信 66 | 67 | !> 学术诚信远比课程实践本身重要. 68 | 69 | 开始课程实践前, 我们需要明确以下原则: 70 | 71 | * **严禁**抄袭/拷贝其他同学的代码/报告. 72 | * **严禁**将自己的代码/报告提供给他人. 73 | * **允许**参考相关的开源实现, 但你必须在报告中声明参考的内容. **严禁**照搬开源实现. 74 | 75 | 我们会对同学们提交的代码/报告进行查重, 同时接受同学们的举报. 若发现雷同现象, 无论你是提供者还是参考者, **均会受到同等处罚**. 76 | 77 | 对于雷同现象严重者, 课程组在调查后有权将情况上报至教务, 并按相关规定进行进一步的严肃处理. 78 | -------------------------------------------------------------------------------- /docs/toc.md: -------------------------------------------------------------------------------- 1 | * [写在前面](/preface/) 2 | * [实验说明](/preface/lab) 3 | * [前置知识](/preface/prerequisites) 4 | * [如何面对问题](/preface/facing-problems) 5 | * [Lv0. 环境配置](/lv0-env-config/) 6 | * [Lv0.1. 配置 Docker](/lv0-env-config/docker) 7 | * [Lv0.2. Koopa IR 简介](/lv0-env-config/koopa) 8 | * [Lv0.3. RISC-V 简介](/lv0-env-config/riscv) 9 | * [Lv0.4. 选择你的编程语言](/lv0-env-config/language) 10 | * [Lv1. `main` 函数](/lv1-main/) 11 | * [Lv1.1. 编译器的结构](/lv1-main/structure) 12 | * [Lv1.2. 词法/语法分析初见](/lv1-main/lexer-parser) 13 | * [Lv1.3. 解析 `main` 函数](/lv1-main/parsing-main) 14 | * [Lv1.4. IR 生成](/lv1-main/ir-gen) 15 | * [Lv1.5. 测试](/lv1-main/testing) 16 | * [Lv2. 初试目标代码生成](/lv2-code-gen/) 17 | * [Lv2.1. 处理 Koopa IR](/lv2-code-gen/processing-ir) 18 | * [Lv2.2. 目标代码生成](/lv2-code-gen/code-gen) 19 | * [Lv2.3. 测试](/lv2-code-gen/testing) 20 | * [Lv3. 表达式](/lv3-expr/) 21 | * [Lv3.1. 一元表达式](/lv3-expr/unary-exprs) 22 | * [Lv3.2. 算术表达式](/lv3-expr/arithmetic-exprs) 23 | * [Lv3.3. 比较和逻辑表达式](/lv3-expr/comp-n-logical-exprs) 24 | * [Lv3.4. 测试](/lv3-expr/testing) 25 | * [Lv4. 常量和变量](/lv4-const-n-var/) 26 | * [Lv4.1. 常量](/lv4-const-n-var/const) 27 | * [Lv4.2. 变量和赋值](/lv4-const-n-var/var-n-assign) 28 | * [Lv4.3. 测试](/lv4-const-n-var/testing) 29 | * [Lv5. 语句块和作用域](/lv5-block-n-scope/) 30 | * [Lv5.1. 实现](/lv5-block-n-scope/implementing) 31 | * [Lv5.2. 测试](/lv5-block-n-scope/testing) 32 | * [Lv6. `if` 语句](/lv6-if/) 33 | * [Lv6.1. 处理 `if/else`](/lv6-if/if-else) 34 | * [Lv6.2. 短路求值](/lv6-if/short-circuit) 35 | * [Lv6.3. 测试](/lv6-if/testing) 36 | * [Lv7. `while` 语句](/lv7-while/) 37 | * [Lv7.1. 处理 `while`](/lv7-while/while) 38 | * [Lv7.2. `break` 和 `continue`](/lv7-while/break-n-continue) 39 | * [Lv7.3. 测试](/lv7-while/testing) 40 | * [Lv8. 函数和全局变量](/lv8-func-n-global/) 41 | * [Lv8.1. 函数定义和调用](/lv8-func-n-global/func-def-n-call) 42 | * [Lv8.2. SysY 库函数](/lv8-func-n-global/lib-funcs) 43 | * [Lv8.3. 全局变量和常量](/lv8-func-n-global/globals) 44 | * [Lv8.4. 测试](/lv8-func-n-global/testing) 45 | * [Lv9. 数组](/lv9-array/) 46 | * [Lv9.1. 一维数组](/lv9-array/1d-array) 47 | * [Lv9.2. 多维数组](/lv9-array/nd-array) 48 | * [Lv9.3. 数组参数](/lv9-array/array-param) 49 | * [Lv9.4. 测试](/lv9-array/testing) 50 | * [Lv9+. 新的开始](/lv9p-reincarnation/) 51 | * [Lv9+.1. 你的编译器超强的](/lv9p-reincarnation/awesome-compiler) 52 | * [Lv9+.2. 寄存器分配](/lv9p-reincarnation/reg-alloc) 53 | * [Lv9+.3. 优化](/lv9p-reincarnation/opt) 54 | * [Lv9+.4. SSA 形式](/lv9p-reincarnation/ssa-form) 55 | * [Lv9+.5. 性能测试](/lv9p-reincarnation/perf-testing) 56 | * [Lv9+.6. 向着更远处进发](/lv9p-reincarnation/go-further) 57 | * [杂项/附录/参考](/misc-app-ref/) 58 | * [为什么学编译?](/misc-app-ref/why) 59 | * [实验环境使用说明](/misc-app-ref/environment) 60 | * [SysY 语言规范](/misc-app-ref/sysy-spec) 61 | * [SysY 运行时库](/misc-app-ref/sysy-runtime) 62 | * [Koopa IR 规范](/misc-app-ref/koopa) 63 | * [RISC-V 指令速查](/misc-app-ref/riscv-insts) 64 | * [在线评测使用说明](/misc-app-ref/oj) 65 | * [参考文献](/misc-app-ref/references) 66 | * [示例编译器](/misc-app-ref/examples) 67 | -------------------------------------------------------------------------------- /docs/lv9-array/array-param.md: -------------------------------------------------------------------------------- 1 | # Lv9.3. 数组参数 2 | 3 | 本节新增/变更的语法规范如下: 4 | 5 | ```ebnf 6 | FuncFParam ::= BType IDENT ["[" "]" {"[" ConstExp "]"}]; 7 | ``` 8 | 9 | ## 一个例子 10 | 11 | ```c 12 | int f(int arr[]) { 13 | return arr[1]; 14 | } 15 | 16 | int main() { 17 | int arr[2] = {1, 2}; 18 | return f(arr); 19 | } 20 | ``` 21 | 22 | ## 词法/语法分析 23 | 24 | 针对本节发生变化的语法规则, 设计新的 AST, 并更新你的 parser 实现即可. 25 | 26 | ## 语义分析 27 | 28 | 函数的数组参数中, 数组第一维的长度省略不写, 后序维度的长度是常量表达式, 你需要在编译时求出它们的值. 29 | 30 | 此外, 在本节中, 数组是可以被部分解引用的, 但得到的剩余部分的数组只能用来作为参数传入函数. 如果你进行了类型相关的检查, 你应该处理这种情况. 31 | 32 | ## IR 生成 33 | 34 | 回忆一下 C 语言的相关内容: 函数形式参数中的 `int arr[]`, `int arr[][10]` 等, 实际上表示的是指针, 也就是 `int *arr` 和 `int (*arr)[10]`, 而不是数组. SysY 中的情况与之类似. 35 | 36 | 那么如何在 IR 中表示这种参数呢? 看了前几节的内容, 你不难得出结论: 在一个类型之前添加 `*` 就可以表示这个类型的指针类型. 所以 `int arr[]` 和 `int arr[][10]` 对应的类型分别为 `*i32` 和 `*[i32, 10]`. 37 | 38 | 那么现在问题来了: 如果我们想读取 `int arr[]` 的第二个元素, 即得到 `arr[1]` 的值, 对应的 Koopa IR 该怎么写? `getelemptr` 此时已经不好使了, 因为它要求指针必须是一个数组指针, 而 `arr` 是一个整数的指针. 为了应对这种情况, 我们引入了另一种指针运算指令: `getptr`. 39 | 40 | `getptr ptr, index` 指令执行了如下操作: 假设指针 `ptr` 的类型是 `*T`, 指令会算出一个新的指针, 这个指针的值是 `ptr + index * sizeof(T)`, 但类型依然是 `*T`. 在逻辑上, 这种操作和 C 语言中指针运算的操作是完全一致的. 比如: 41 | 42 | ```c 43 | int *arr; // 和 int arr[] 形式的参数等价 44 | arr[1]; 45 | ``` 46 | 47 | 翻译到 Koopa IR 就是: 48 | 49 | ```koopa 50 | @arr = alloc *i32 // @arr 的类型是 **i32 51 | %ptr1 = load @arr // %ptr1 的类型是 *i32 52 | %ptr2 = getptr %ptr1, 1 // %ptr2 的类型是 *i32 53 | %value = load %ptr2 // %value 的类型是 i32 54 | // 这是一段类型和功能都正确的 Koopa IR 代码 55 | ``` 56 | 57 | 本质上相当于: 58 | 59 | ```c 60 | int *arr; 61 | int *ptr = arr + 1; // 注意这是 C 中的指针运算 62 | *ptr; 63 | ``` 64 | 65 | 对于数组的指针也同理: 66 | 67 | ```c 68 | int (*arr)[3]; 69 | arr[1][2]; 70 | ``` 71 | 72 | 翻译到 Koopa IR 就是: 73 | 74 | ```koopa 75 | @arr = alloc *[i32, 3] // @arr 的类型是 **[i32, 3] 76 | %ptr1 = load @arr // %ptr1 的类型是 *[i32, 3] 77 | %ptr2 = getptr %ptr1, 1 // %ptr2 的类型是 *[i32, 3] 78 | %ptr3 = getelemptr %ptr2, 2 // %ptr3 的类型是 *i32 79 | %value = load %ptr3 // %value 的类型是 i32 80 | // 这是一段类型和功能都正确的 Koopa IR 代码 81 | ``` 82 | 83 | `getptr` 的规则就是如此, 你可以用它和 `getelemptr` 组合出和 SysY 数组相关的任意指针运算. 事实上, 如果你对 LLVM IR 有所了解, 你会发现 Koopa IR 中的 `getptr` 和 `getelemptr` 指令, 就是照着 LLVM IR 中的 `getelementptr` 指令设计的 (把这条指令拆成了两条指令), 但后者更为复杂, 对初学者而言很不友好. 84 | 85 | 综上所述, 示例程序生成的 Koopa IR 为: 86 | 87 | ```koopa 88 | fun @f(@arr: *i32): i32 { 89 | %entry: 90 | %arr = alloc *i32 91 | store @arr, %arr 92 | %0 = load %arr 93 | %1 = getptr %0, 1 94 | %2 = load %1 95 | ret %2 96 | } 97 | 98 | fun @main(): i32 { 99 | %entry: 100 | @arr = alloc [i32, 2] 101 | %0 = getelemptr @arr, 0 102 | store 1, %0 103 | %1 = getelemptr @arr, 1 104 | store 2, %1 105 | // 传递数组参数相当于传递其第一个元素的地址 106 | %2 = getelemptr @arr, 0 107 | %3 = call @f(%2) 108 | ret %3 109 | } 110 | ``` 111 | 112 | ## 目标代码生成 113 | 114 | 前文已经描述过 `getptr` 的含义了, 它所做的操作和 `getelemptr` 在汇编层面完全一致, 所以你不难自行得出生成目标代码的方法. 115 | 116 | 本节乃至本章的指针运算较多, 建议你在编码时时刻保持头脑清晰. 117 | -------------------------------------------------------------------------------- /docs/lv8-func-n-global/README.md: -------------------------------------------------------------------------------- 1 | # Lv8. 函数和全局变量 2 | 3 | 本章中, 你将在上一章的基础上, 实现一个能够处理函数 (包括 SysY 库函数) 和全局变量的编译器. 4 | 5 | 你的编译器将可以处理如下的 SysY 程序: 6 | 7 | ```c 8 | int var; 9 | 10 | int func(int x) { 11 | var = var + x; 12 | return var; 13 | } 14 | 15 | int main() { 16 | // putint 和 putch 都是 SysY 库函数 17 | // SysY 要求库函数不声明就可以使用 18 | putint(func(1)); 19 | var = var * 10; 20 | putint(func(2)); 21 | putch(10); 22 | return var; 23 | } 24 | ``` 25 | 26 | ## 语法规范 27 | 28 | ```ebnf 29 | CompUnit ::= [CompUnit] (Decl | FuncDef); 30 | 31 | Decl ::= ConstDecl | VarDecl; 32 | ConstDecl ::= "const" BType ConstDef {"," ConstDef} ";"; 33 | BType ::= "int"; 34 | ConstDef ::= IDENT "=" ConstInitVal; 35 | ConstInitVal ::= ConstExp; 36 | VarDecl ::= BType VarDef {"," VarDef} ";"; 37 | VarDef ::= IDENT | IDENT "=" InitVal; 38 | InitVal ::= Exp; 39 | 40 | FuncDef ::= FuncType IDENT "(" [FuncFParams] ")" Block; 41 | FuncType ::= "void" | "int"; 42 | FuncFParams ::= FuncFParam {"," FuncFParam}; 43 | FuncFParam ::= BType IDENT; 44 | 45 | Block ::= "{" {BlockItem} "}"; 46 | BlockItem ::= Decl | Stmt; 47 | Stmt ::= LVal "=" Exp ";" 48 | | [Exp] ";" 49 | | Block 50 | | "if" "(" Exp ")" Stmt ["else" Stmt] 51 | | "while" "(" Exp ")" Stmt 52 | | "break" ";" 53 | | "continue" ";" 54 | | "return" [Exp] ";"; 55 | 56 | Exp ::= LOrExp; 57 | LVal ::= IDENT; 58 | PrimaryExp ::= "(" Exp ")" | LVal | Number; 59 | Number ::= INT_CONST; 60 | UnaryExp ::= PrimaryExp | IDENT "(" [FuncRParams] ")" | UnaryOp UnaryExp; 61 | UnaryOp ::= "+" | "-" | "!"; 62 | FuncRParams ::= Exp {"," Exp}; 63 | MulExp ::= UnaryExp | MulExp ("*" | "/" | "%") UnaryExp; 64 | AddExp ::= MulExp | AddExp ("+" | "-") MulExp; 65 | RelExp ::= AddExp | RelExp ("<" | ">" | "<=" | ">=") AddExp; 66 | EqExp ::= RelExp | EqExp ("==" | "!=") RelExp; 67 | LAndExp ::= EqExp | LAndExp "&&" EqExp; 68 | LOrExp ::= LAndExp | LOrExp "||" LAndExp; 69 | ConstExp ::= Exp; 70 | ``` 71 | 72 | ## 语义规范 73 | 74 | ### 编译单元 75 | 76 | * 一个 SysY 程序由单个文件组成, 文件内容对应 EBNF 表示中的 `CompUnit`. 在该 `CompUnit` 中, 必须存在且仅存在一个标识为 `main`, 无参数, 返回类型为 `int` 的 `FuncDef` (函数定义). `main` 函数是程序的入口点. 77 | * `CompUnit` 的顶层变量/常量声明语句 (对应 `Decl`), 函数定义 (对应 `FuncDef`) 都不可以重复定义同名标识符 (`IDENT`), 即便标识符的类型不同也不允许. 78 | * `CompUnit` 的变量/常量/函数声明的作用域从该声明处开始, 直到文件结尾. 79 | 80 | ### 全局变量/作用域 81 | 82 | * 全局变量和局部变量的作用域可以重叠, 局部变量会覆盖同名全局变量. 83 | * SysY 程序声明的函数名不能和 SysY 库函数名相同. 84 | * 局部变量名可以和函数名相同. 85 | * 全局变量声明中指定的初值表达式必须是常量表达式. 86 | * 未显式初始化的全局变量, 其 (元素) 值均被初始化为 0. 87 | 88 | ### 函数 89 | 90 | * `FuncDef` 表示函数定义. 其中的 `FuncType` 指明了函数的返回类型. 91 | * 当返回类型为 `int` 时, 函数内的所有分支都应当含有带有 `Exp` 的 `return` 语句. 不含有 `return` 语句的分支的返回值未定义. 92 | * 当返回值类型为 `void` 时, 函数内只能出现不带返回值的 `return` 语句. 93 | * `FuncFParam` 定义函数的一个形式参数. 94 | * SysY 标准中未指定函数形式参数应该被放入何种作用域. 为保持和 C 语言一致, 你可以将其放入函数体的作用域; 为实现方便, 你可以将其放入一个单独的作用域. 95 | * 函数调用形式是 `IDENT "(" FuncRParams ")"`, 其中的 `FuncRParams` 表示实际参数. 实际参数的类型和个数必须与 `IDENT` 对应的函数定义的形参完全匹配. 96 | * 函数实参的语法是 `Exp`. 对于 `int` 类型的参数, 遵循按值传递的规则. 97 | * 试图使用返回类型为 `void` 的函数的返回值是未定义行为. 98 | -------------------------------------------------------------------------------- /docs/lv2-code-gen/processing-ir.md: -------------------------------------------------------------------------------- 1 | # Lv2.1. 处理 Koopa IR 2 | 3 | 本节将引导你在上一章的基础上, 建立内存形式的 Koopa IR, 并在程序中访问这些数据结构. 4 | 5 | ## 建立内存形式的 Koopa IR 6 | 7 | 上一章中, 你的编译器已经可以输出 Koopa IR 程序了. 你可能会采用两种思路完成这一操作: 8 | 9 | 1. 遍历 AST, 输出文本形式的 Koopa IR 程序. 10 | 2. 遍历 AST, 直接建立 (某种) 内存形式的 Koopa IR, 再将其转换为文本形式输出. 11 | 12 | 对于第二种思路, 无论你是通过阅读 Koopa IR 的[文档](https://docs.rs/koopa), 直接建立了内存形式 IR, 还是根据[Koopa IR 规范](/misc-app-ref/koopa), 自行设计了一套数据结构来表示 Koopa IR 程序, 你其实都已经得到了一个可被你程序处理的内存形式的 Koopa IR. 在目标代码生成阶段, 你可以直接让你的编译器遍历这些数据结构, 并生成代码. **此时, 你可以跳过本节.** 13 | 14 | 第一种思路可能是大部分同学会采用的思路, 因为它相当简单且直观, 实现难度很低. 但其缺点是, 你在生成目标代码之前, 不得不再次将文本形式的 Koopa IR 转换成某种数据结构——这相当于再写一个编译器. 否则, 你的程序几乎无法直接基于文本形式 IR 生成汇编. 15 | 16 | 不过好在, 我们为大家提供了能够处理 Koopa IR 的库, 你可以使用其中的实现, 来将文本形式的 IR 转换为内存形式. 17 | 18 | ## C/C++ 实现 19 | 20 | 你可以使用 `libkoopa` 中的接口将文本形式 Koopa IR 转换为 raw program, 后者是 C/C++ 可以直接操作的, 由各种 `struct`, `union` 和指针组成的, 表示 Koopa IR 的数据结构. 21 | 22 | 首先你需要在代码中引用 `libkoopa` 的头文件: 23 | 24 | ```c 25 | #include "koopa.h" 26 | ``` 27 | 28 | ?> **注意:** 你**只需要**在代码中引用这个头文件, 而不需要去 GitHub 上找到这个头文件, 然后把它下载下来, 放在编译器的代码目录中. 29 |

30 | 我们提供的 Make/CMake 模板会自动处理 `koopa.h` 的引用, 你不需要关心任何其他的细节. 这件事情实现的原理, 和你的编译器可以直接引用 `stdio.h`, 而不需要把这个文件放在代码目录里, 是完全一致的. 如果你对此感兴趣, 可以 RTFSC 模板的代码. 31 | 32 | 然后, 假设你生成的 Koopa IR 程序保存在了字符串 (类型为 `const char *`) `str` 中, 你可以执行: 33 | 34 | ```c 35 | // 解析字符串 str, 得到 Koopa IR 程序 36 | koopa_program_t program; 37 | koopa_error_code_t ret = koopa_parse_from_string(str, &program); 38 | assert(ret == KOOPA_EC_SUCCESS); // 确保解析时没有出错 39 | // 创建一个 raw program builder, 用来构建 raw program 40 | koopa_raw_program_builder_t builder = koopa_new_raw_program_builder(); 41 | // 将 Koopa IR 程序转换为 raw program 42 | koopa_raw_program_t raw = koopa_build_raw_program(builder, program); 43 | // 释放 Koopa IR 程序占用的内存 44 | koopa_delete_program(program); 45 | 46 | // 处理 raw program 47 | // ... 48 | 49 | // 处理完成, 释放 raw program builder 占用的内存 50 | // 注意, raw program 中所有的指针指向的内存均为 raw program builder 的内存 51 | // 所以不要在 raw program 处理完毕之前释放 builder 52 | koopa_delete_raw_program_builder(builder); 53 | ``` 54 | 55 | 其中, raw program 的结构和我们在 Lv1 中提到的 Koopa IR 程序的结构完全一致: 56 | 57 | * 最上层是 `koopa_raw_program_t`, 也就是 `Program`. 58 | * 之下是全局变量定义列表和函数定义列表. 59 | * 在 raw program 中, 列表的类型是 `koopa_raw_slice_t`. 60 | * 本质上这是一个指针数组, 其中的 `buffer` 字段记录了指针数组的地址 (类型是 `const void **`), `len` 字段记录了指针数组的长度, `kind` 字段记录了数组元素是何种类型的指针 61 | * 在访问时, 你可以通过 `slice.buffer[i]` 拿到列表元素的指针, 然后通过判断 `kind` 来决定把这个指针转换成什么类型. 62 | * `koopa_raw_function_t` 代表函数, 其中是基本块列表. 63 | * `koopa_raw_basic_block_t` 代表基本块, 其中是指令列表. 64 | * `koopa_raw_value_t` 代表全局变量, 或者基本块中的指令. 65 | 66 | 如果你的项目基于我们提供的 Make/CMake 模板, 则测试脚本/评测平台编译你的项目时, 会自动链接 `libkoopa`, 你无需为此操心. 67 | 68 | 如果你在编码时需要让编辑器/IDE 识别 `koopa.h` 文件中的声明, 你可以在 `libkoopa` 的仓库中获取到[这个头文件](https://github.com/pku-minic/koopa/blob/master/libkoopa/include/koopa.h). 同时, 头文件中包含了所有 raw program 相关的数据结构的定义 (含详细注释), 你可以通过 RTFSC 来进一步了解 raw program 的结构. 69 | 70 | ## Rust 实现 71 | 72 | 你可以使用 `koopa` 这个 crate 来处理 Koopa IR. 请根据 [crates.io](https://crates.io/crates/koopa) 上的说明, 在你的项目中添加最新版本的 `koopa` 的依赖. 73 | 74 | 假设你生成的 Koopa IR 程序保存在了字符串 `s` 中, 你可以执行: 75 | 76 | ```rust 77 | let driver = koopa::front::Driver::from(s); 78 | let program = driver.generate_program().unwrap(); 79 | ``` 80 | 81 | 来得到一个内存形式的 Koopa IR 程序. 这个程序的结构和 Lv1 中的描述完全一致, 详情请参考[文档](https://docs.rs/koopa/0.0.3/koopa/ir/entities/struct.Program.html). 82 | -------------------------------------------------------------------------------- /docs/lv8-func-n-global/globals.md: -------------------------------------------------------------------------------- 1 | # Lv8.3. 全局变量和常量 2 | 3 | 本节新增/变更的语法规范如下: 4 | 5 | ```ebnf 6 | CompUnit ::= [CompUnit] (Decl | FuncDef); 7 | ``` 8 | 9 | ## 一个例子 10 | 11 | ```c 12 | int var; 13 | 14 | const int one = 1; 15 | 16 | int main() { 17 | return var + one; 18 | } 19 | ``` 20 | 21 | ## 词法/语法分析 22 | 23 | 本节中, 语法规则 `CompUnit` 发生了变化, 你可能需要为其设计新的 AST, 并更新你的 parser 实现. 24 | 25 | ## 语义分析 26 | 27 | 本节 `CompUnit` 的定义较上一节又发生了变化, 不仅允许多个函数存在于全局范围内, 还允许变量/常量声明存在于全局范围内. 你需要把所有全局范围内的声明, 都放在全局作用域中. 28 | 29 | 此外, 全局常量和局部常量一样, 都需要在编译期求值. 你的编译器在处理全局常量时, 需要扫描它的初始值, 并直接算出结果, 存入符号表. 30 | 31 | ## IR 生成 32 | 33 | 对于所有全局变量, 你的编译器应该生成全局内存分配指令 (`global alloc`). 这种指令的用法和 `alloc` 类似, 区别是全局分配必须带初始值. 34 | 35 | 示例程序生成的 Koopa IR 为: 36 | 37 | ```koopa 38 | global @var = alloc i32, zeroinit 39 | 40 | fun @main(): i32 { 41 | %entry: 42 | %0 = load @var 43 | %1 = add %0, 1 44 | ret %1 45 | } 46 | ``` 47 | 48 | 未初始化的全局变量的值为 0, 所以我们使用 `zeroinit` 作为初始值, 初始化了全局内存分配 `@var`. 49 | 50 | 此处的 `zeroinit` 代表零初始化器 (zero initializer). `zeroinit` 是一个通用的 0 值, 它可以是多种类型的. 不管是向 `i32` 类型的 `alloc` 中写入 `zeroinit`, 还是向你将在 Lv9 中遇到的数组类型的 `alloc` 中写入 `zeroinit`, 这些 `alloc` 分配的内存都会被填充 0. 51 | 52 | 当然, 对于这个示例, 你写 `global @var = alloc i32, 0` 也完全没问题. 53 | 54 | ## 目标代码生成 55 | 56 | 在操作系统层面, 局部变量和全局变量的内存分配是不同的. 前者的内存空间在程序运行时, 由函数在栈上动态开辟出来, 或者直接放在寄存器里. 后者在程序被操作系统加载时, 由操作系统根据可执行文件 (比如 [PE/COFF](https://en.wikipedia.org/wiki/Portable_Executable), [ELF](https://en.wikipedia.org/wiki/Executable_and_Linkable_Format) 或 [Mach-O](https://en.wikipedia.org/wiki/Mach-O)) 中定义的 layout, 静态映射到虚拟地址空间, 进而映射到物理页上. 57 | 58 | 体现在 RISC-V 汇编中, 局部变量和全局变量的描述方式也有所区别. 前者的描述方式你已经见识过了, 基本属于润物细无声级别的: 你需要在函数中操作一下 `sp`, 然后搞几个偏移量, 再 `lw`/`sw`, 总体来说比较抽象. 后者就很直接了, 我们先看示例程序生成的 RISC-V 汇编: 59 | 60 | ``` 61 | .data 62 | .globl var 63 | var: 64 | .zero 4 65 | 66 | .text 67 | .globl main 68 | main: 69 | addi sp, sp, -16 70 | la t0, var 71 | lw t0, 0(t0) 72 | sw t0, 0(sp) 73 | lw t0, 0(sp) 74 | li t1, 1 75 | add t0, t0, t1 76 | sw t0, 4(sp) 77 | lw a0, 4(sp) 78 | addi sp, sp, 16 79 | ret 80 | ``` 81 | 82 | 这段汇编代码中, `.data` 是汇编器定义的一个 “directive”——你可以理解成一种特殊的语句. `.data` 指定汇编器把之后的所有内容都放到数据段 ([data segment](https://en.wikipedia.org/wiki/Data_segment)). 对操作系统来说, 数据段对应的内存里放着的所有东西都会被视作数据, 程序一般会把全局变量之类的内容都塞进数据段. 83 | 84 | `.data` 之后的 `.zero` 也同样是一个 directive——事实上, 汇编程序中所有 `.` 开头的语句基本都是 directive. `.zero 4` 代表往当前地址处 (这里代表 `var` 对应的地址) 填充 4 字节的 0, 这对应了 Koopa IR 中的 `zeroinit`. 如果 Koopa IR 是以下这种写法: 85 | 86 | ```koopa 87 | global @var = alloc i32, 233 88 | ``` 89 | 90 | 那我们就应该使用 `.word 233` 这个 directive, 这代表往当前地址处填充机器字长宽度 (4 字节) 的整数 `233`. 91 | 92 | 于是现在, 你可能已经明白 `main` 之前的 `.text` 代表什么含义了: 它表示之后的内容应该被汇编器放到代码段 ([code segment](https://en.wikipedia.org/wiki/Code_segment)). 代码段和数据段的区别是, 代码段里的 “数据” 是只读且可执行的, 数据段里的数据可读可写但不可执行. 顺便一提, 这种 “可执行/不可执行” 的特性, 是操作系统加载可执行文件到虚拟地址空间时, 通过设置页表中虚拟页的权限位来实现的, 处理器将负责保证权限的正确性. 93 | 94 | 除了代码段和数据段, 可执行文件里通常还会有很多其他的段: 比如 `bss` 段存放需要零初始化的数据, 操作系统加载 `bss` 时会自动将其清零, 所以可执行文件中只保存 `bss` 的长度而不保存数据, 可以节省一些体积; `rodata` 段存放只读的数据 (**r**ead-**o**nly **data**). 其实这么看, 示例程序里的 `var` 放在 `bss` 段是最合适的, 但为了简化编译器的实现, 你可以把它放在 `data` 段. 95 | 96 | 全局变量的内存分配完毕之后, 我们要怎么访问到这块内存呢? 你可以注意到在 `main` 里出现了一条 `la t0, var` 指令 (其实是伪指令), 这条指令会把符号 `var` 对应的地址加载到 `t0` 寄存器中. 之后的 `lw` 指令以 `t0` 为地址, 读取了 4 字节数据到 `t0`. 这两条指令共同完成了加载全局变量的操作. 97 | 98 | ?> `la` 伪指令之后的符号并不只局限在数据段, 其他段中符号的地址也是可以被加载的. 你可以尝试使用 RISC-V 汇编实现一个简单的程序, 读取 `main` 函数的第一条指令的值并输出, 然后对照 [RISC-V 规范](https://github.com/riscv/riscv-isa-manual/releases/download/Ratified-IMAFDQC/riscv-spec-20191213.pdf), 查看这条指令是否对应了你在汇编中所写的那条指令. 99 | -------------------------------------------------------------------------------- /docs/lv9-array/nd-array.md: -------------------------------------------------------------------------------- 1 | # Lv9.2. 多维数组 2 | 3 | 本节新增/变更的语法规范如下: 4 | 5 | ```ebnf 6 | ConstDef ::= IDENT {"[" ConstExp "]"} "=" ConstInitVal; 7 | ConstInitVal ::= ConstExp | "{" [ConstInitVal {"," ConstInitVal}] "}"; 8 | VarDef ::= IDENT {"[" ConstExp "]"} 9 | | IDENT {"[" ConstExp "]"} "=" InitVal; 10 | InitVal ::= Exp | "{" [InitVal {"," InitVal}] "}"; 11 | 12 | LVal ::= IDENT {"[" Exp "]"}; 13 | ``` 14 | 15 | ## 一个例子 16 | 17 | ```c 18 | int main() { 19 | int arr[2][3] = {1, 2}; 20 | return arr[0][2]; 21 | } 22 | ``` 23 | 24 | ## 词法/语法分析 25 | 26 | 针对本节发生变化的语法规则, 设计新的 AST, 并更新你的 parser 实现即可. 27 | 28 | ## 语义分析 29 | 30 | 上一节的相关注意事项可推广至这一节, 唯一需要注意的是, **多维数组的初始化列表会更复杂.** 比如示例程序中, `int[2][3]` 的数组使用了一个 `{1, 2}` 形式的初始化列表. 我们可以把它写得完整一些: 31 | 32 | ```c 33 | int arr[2][3] = {{1, 2, 0}, {0, 0, 0}}; 34 | ``` 35 | 36 | 看起来似乎很好理解: 把这个 2 乘 3 的数组展平, 前两个元素已经给出了, 后续元素填充 0 即可, 对吧? 然而, 实际情况比你想象的还要复杂, 比如以下这个初始化列表: 37 | 38 | ```c 39 | int arr[2][3][4] = {1, 2, 3, 4, {5}, {6}, {7, 8}}; 40 | ``` 41 | 42 | 这个就不太好理解了. 如果你遇到这种不好理解的例子, 可以利用我们之前提到的网站——[Compiler Explorer](https://godbolt.org/), 直接查看 C 语言代码对应的汇编. 比如以上这个例子, 如果我们把 `arr` 视作一个全局数组, 那么对应的汇编为: 43 | 44 | ``` 45 | arr: 46 | .word 1 47 | .word 2 48 | .word 3 49 | .word 4 50 | .word 5 51 | .word 0 52 | .word 0 53 | .word 0 54 | .word 6 55 | .word 0 56 | .word 0 57 | .word 0 58 | .word 7 59 | .word 8 60 | .word 0 61 | .word 0 62 | .zero 16 63 | .zero 16 64 | ``` 65 | 66 | 之前我们已经介绍过 `.word` 和 `.zero` 的含义, 你不难把上面的汇编代码复原回初始化列表: 67 | 68 | ```c 69 | int arr[2][3][4] = { 70 | {{1, 2, 3, 4}, {5, 0, 0, 0}, {6, 0, 0, 0}}, 71 | {{7, 8, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}} 72 | }; 73 | ``` 74 | 75 | 所以我们建议, 在处理 SysY 的初始化列表前, 你应该先把初始化列表转换为已经填好 0 的形式, 这样处理难度会大幅度下降. 因为用户输入的程序里未必一定会写出一个合法的初始化列表, 比如: 76 | 77 | ```c 78 | int arr[2][2][2] = {{}, 1, {}}; 79 | ``` 80 | 81 | 所以这一步应该被放在语义分析阶段, 以便你在转换的同时报告语义错误. 但考虑到由于测试输入一定是合法的, 有的同学不会做语义分析, 所以这一步放到 IR 生成阶段也是可以的. 82 | 83 | 读到此处, 我觉得你最关心的问题应该是: 到底应该如何理解 SysY 的初始化列表, 然后把它转换成一个填好 0 的形式呢? 其实, 处理 SysY (或 C 语言) 中的初始化列表时, 可以遵循这几个原则: 84 | 85 | 1. 记录待处理的 $n$ 维数组各维度的总长 $len_1, len_2, \cdots, len_n$. 比如 `int[2][3][4]` 各维度的长度分别为 2, 3 和 4. 86 | 2. 依次处理初始化列表内的元素, 元素的形式无非就两种可能: 整数, 或者另一个初始化列表. 87 | 3. 遇到整数时, 从当前待处理的维度中的最后一维 (第 $n$ 维) 开始填充数据. 88 | 4. 遇到初始化列表时: 89 | * 当前已经填充完毕的元素的个数必须是 $len_n$ 的整数倍, 否则这个初始化列表没有对齐数组维度的边界, 你可以认为这种情况属于语义错误. 90 | * 检查当前对齐到了哪一个边界, 然后将当前初始化列表视作这个边界所对应的最长维度的数组的初始化列表, 并递归处理. 比如: 91 | * 对于 `int[2][3][4]` 和初始化列表 `{1, 2, 3, 4, {5}}`, 内层的初始化列表 `{5}` 对应的数组是 `int[4]`. 92 | * 对于 `int[2][3][4]` 和初始化列表 `{1, 2, 3, 4, 1, 2, 3, 4, 1, 2, 3, 4, {5}}`, 内层的初始化列表 `{5}` 对应的数组是 `int[3][4]`. 93 | * 对于 `int[2][3][4]` 和初始化列表 `{{5}}`, 内层的初始化列表 `{5}` 之前没出现任何整数元素, 这种情况其对应的数组是 `int[3][4]`. 94 | 95 | 鉴于写文档的人的归纳能力比较捉急, 你可以在 [Compiler Explorer](https://godbolt.org/) 上多写几个初始化列表, 进一步体会上述内容的含义. 96 | 97 | !> **注意:** 当你在 Compiler Explorer 查看数组初始化的情况时, 如果你注意到编译器 (比如 GCC) 对你写的初始化列表报告了相关警告 (warning), 那么你可以认为这个初始化列表是不合法的. 你可以通过在编译选项中 (网站右侧面板的文本框) 添加 `-Werror` 来将所有警告转换为编译错误. 98 |

99 | 例如, 对于代码 `int arr[2][3][4] = {1, 2, {3}};`, GCC 会报告 `{3}` 处出现警告 “braces around scalar initializer”. 也就是说, GCC 认为 `{3}` 实际上代表了标量 (scalar) `3`, 而非聚合类型 (aggregate) `{3}`. 100 |

101 | 在 SysY 中, 你的编译器不需要像这样聪明到足以纠正用户错误的程度. 在遇到上述 `{3}` 时, 你的编译器只需认为此处出现了一个新的初始化列表, 然后按照规则报错即可. 102 | 103 | ## IR 生成 104 | 105 | 与上一节类似, 此处不再赘述. 106 | 107 | ## 目标代码生成 108 | 109 | 本节并未用到新的 Koopa IR 指令, 也不涉及 Koopa IR 中的新概念, 所以这部分没有需要改动的内容. 110 | -------------------------------------------------------------------------------- /docs/misc-app-ref/oj.md: -------------------------------------------------------------------------------- 1 | # 在线评测使用说明 2 | 3 | 为了方便我们对大家的作业进行公平公正的评分, 我们提供了一套在线评测系统, 用于评测大家的编译器实现. 4 | 5 | 在线评测系统的使用方式与其他的 Online Judging 系统 (例如 POJ) 类似, 要完成一次评测, 你需要: 6 | 7 | 1. 使用我们提供的[代码托管平台](https://gitlab.eduxiji.net), 管理你实现的编译器的源代码. 8 | 2. 使用我们发放的账号登录评测系统, 提交代码托管平台的仓库地址. 9 | 3. 等待评测系统完成下列操作: 10 | * 拉取并编译你的编译器, 11 | * 使用你的编译器编译所有测试用例, 12 | * 检测生成的测试用例的正确性. 13 | 4. 查看评测系统返回的评测结果. 你可能需要根据结果修改你的编译器, 将改动提交到你的仓库, 并回到第 2 步进行新一轮的评测. 14 | 15 | ## 提交代码 16 | 17 | 你可以使用发放的账号登录评测平台的代码托管平台 ([eduxiji.gitlab.net](https://gitlab.eduxiji.net)), 然后新建 repo. 之后你就可以按照使用 Git 的一般流程来向代码托管平台提交代码了. 18 | 19 | !> **注意:** 请务必将你创建的 repo 的可见性设为 “Private”, 否则所有人都将在平台上看到你提交的代码! 20 |

21 | 此外, 平台的 GitLab **不支持 SSH 登录**, 在从平台 clone 仓库或向平台提交代码时, 请注意使用 HTTPS. 22 | 23 | ## 登录评测系统 24 | 25 | 你可以访问 [course.educg.net](https://course.educg.net) 来登录在线评测系统. 进入系统后, 你可以选择不同阶段的提交入口. 26 | 27 | 关于编译实践的评测入口和阶段划分, 请参考文档的[实验说明](/preface/lab)部分. 你需要选择合适的提交入口, 并在其中提交你的编译器仓库. 28 | 29 | ## 提交评测 30 | 31 | 选择并进入评测入口后, 你将看到如下的界面: 32 | 33 | ![评测入口界面](judging-1.png) 34 | 35 | 界面的右侧是代码编辑区域, 但请注意, 此处应该填写你的编译器仓库的 URL, 而**不是**你编译器的源代码. 例如: 36 | 37 | ``` 38 | https://gitlab.eduxiji.net/MaxXing/pku-minic-test.git 39 | ``` 40 | 41 | 如果你需要提交仓库的某个分支, 你可以使用如下的格式: 42 | 43 | ``` 44 | https://gitlab.eduxiji.net/用户名/仓库名.git 分支名 45 | ``` 46 | 47 | 或者: 48 | 49 | ``` 50 | https://gitlab.eduxiji.net/用户名/仓库名.git --branch=分支名 51 | ``` 52 | 53 | 填写完成后, 点击 “提交” 按钮, 即可将代码提交至系统进行在线评测. 评测结果会在几分钟后展示在控制台窗口中. 54 | 55 | ![评测结果](judging-2.png) 56 | 57 | ## 项目要求/输入输出形式 58 | 59 | 在线评测平台对编译器项目的要求, 以及输入和输出的形式和本地测试完全一致, 即: 60 | 61 | * 你的编译器需要支持形如 `compiler 模式 输入文件 -o 输出文件` 的命令行参数. 62 | * 不同的评测入口会采用不同的 `模式` 调用你的编译器: 63 | * **从 SysY 生成 Koopa IR**: `-koopa`. 64 | * **从 SysY 生成 RISC-V 汇编**: `-riscv`. 65 | * **性能测试**: `-perf`. 66 | 67 | **我们强烈建议你在熟悉过本地测试之后, 再来提交在线评测**. 68 | 69 | 关于项目和输入形式的具体要求, 请参考文档中[准备你的编译器](/misc-app-ref/environment?id=准备你的编译器)一节. 70 | 71 | 评测机和目前最新的 `compiler-dev` Docker 镜像中工具链的版本保持一致. 72 | 73 | ## 检查评测结果 74 | 75 | 评测完成后, 平台会返回评测结果. 评测结果包括: 76 | 77 | * **GPE**: Git Pull Error, 评测机在拉取你的项目时出现了问题. 78 | * **CCE**: Compile Compiler Error, 评测机在编译你提交的编译器项目时出现了问题. 79 | * **CCTLE**: Compile Compiler Time Limit Exceeded, 评测机在编译你提交的编译器项目时超时 (超过 300s). 80 | * **CTE**: Compile Testcase Error, 评测机在使用你提交的编译器编译测试用例时出现了问题. 81 | * **CTTLE**: Compile Testcase Time Limit Exceeded, 评测机在使用你提交的编译器编译测试用例时超时 (编译单个用例的时间超过 120s). 82 | * **ONF**: Output Not Found, 评测机在使用你提交的编译器编译测试用例时, 未检测到你的编译器向指定文件中输出任何内容. 83 | * **AE**: Assemble Error, 评测机在汇编你的编译器生成的汇编代码时出现了问题. 84 | * **ATLE**: Assemble Time Limit Exceeded, 评测机在汇编你的编译器生成的汇编代码时超时 (汇编单个用例的时间超过 60s). 85 | * **RE**: Runtime Error, 评测机在运行你的编译器生成的测试用例时出现了问题. 86 | * **RTLE**: Runtime Time Limit Exceeded, 评测机在运行你的编译器生成的测试用例时超时 (运行单个用例的时间超过 120s). 87 | * **TIE**: Time Info Error, 评测机运行生成的测试用例时没有找到运行时间信息. 这个错误只会在进行性能测试时出现, 可能是因为你的编译器没有正确处理 `starttime`/`stoptime` 库函数. 88 | * **WA**: Wrong Answer, 评测机运行生成的测试用例时, 检测到输出结果和预期输出不符. 89 | * **AC**: Accepted, 所有功能测试的结果均正确. 90 | * **SKIPPED**: Skipped, 已跳过当前测试用例. 91 | * **SE**: System Error, 评测机出现了意料之中 (?) 的内部错误. 92 | * **UE**: Unexpected Error, 评测机出现了不可预料的内部错误. 遇到这种情况时请立即联系助教, 评测机觉得自己还能再抢救一下. 93 | 94 | ## 评测没过怎么办? 95 | 96 | 和通常意义上的, 算法竞赛类的 OJ 类似, 编译实践的在线评测系统也包含了若干**不公开**的测试用例. 97 | 98 | 如果你的编译器实现得非常严谨, 你应该能通过所有的测试用例; 而如果你的编译器写得没那么严谨, 你又不太走运——你的编译器可能会在一些隐藏测试用例上挂掉, 就像你在打算法竞赛的时候交了一发结果不幸 WA 了一样, 只不过编译实践的情况会更复杂一些. 99 | 100 | 如果遇到这种情况, 我们建议你: 101 | 102 | * 根据平台提供的测试点名称, 测试用例名称和出错详情, 推测自己的编译器在哪些地方出现了问题. 103 | * 参考[实验环境使用说明](/misc-app-ref/environment?id=使用其他测试用例), 使用更多的测试用例~折磨~调试你的编译器. 104 | -------------------------------------------------------------------------------- /docs/misc-app-ref/sysy-runtime.md: -------------------------------------------------------------------------------- 1 | # SysY 运行时库 2 | 3 | ?> SysY 官方的运行时库规范见[这里](https://gitlab.eduxiji.net/nscscc/compiler2021/-/blob/master/SysY%E8%BF%90%E8%A1%8C%E6%97%B6%E5%BA%93.pdf). 4 |

5 | 编译实践课所使用的 SysY 运行时库和官方定义略有不同: 实践课的 `getch` 定义了遇到 `EOF` 时的行为, 同时计时函数的定义比官方定义更加简单. 6 | 7 | SysY 运行时库提供一系列 I/O 函数, 计时函数等用于在 SysY 程序中表达输入/输出, 计时等功能需求. 由于 SysY 并不具备 `include` 和函数声明的语法, 这些库函数无需在 SysY 程序中声明, 即可在 SysY 的 函数中使用. 8 | 9 | ## 相关实现 10 | 11 | 你可以从 [GitHub 上](https://github.com/pku-minic/sysy-runtime-lib/)获取 SysY 运行时库的相关实现, 详情见仓库的 [README](https://github.com/pku-minic/sysy-runtime-lib/blob/master/README.md). 12 | 13 | ## I/O 函数 14 | 15 | SysY 运行时库提供一系列 I/O 函数, 支持对整数, 字符以及一串整数的输入和输出. 16 | 17 | 以下未被列出的函数将不会出现在任何 SysY 评测用例中. 18 | 19 | ### getint 20 | 21 | **函数声明**: `int getint()` 22 | 23 | **描述**: 从标准输入读取一个整数, 返回对应的整数值. 如果未能读取到任何整数 (例如遇到了 `EOF`), 则返回值未定义. 24 | 25 | **示例**: 26 | 27 | ```c 28 | int n; 29 | n = getint(); 30 | ``` 31 | 32 | ### getch 33 | 34 | **函数声明**: `int getch()` 35 | 36 | **描述**: 从标准输入读取一个字符, 返回字符对应的 ASCII 码值. 如果读取到了 `EOF`, 则返回 `-1`. 37 | 38 | **示例**: 39 | 40 | ```c 41 | int n; 42 | n = getch(); 43 | ``` 44 | 45 | ### getarray 46 | 47 | **函数声明**: `int getarray(int[])` 48 | 49 | **描述**: 从标准输入读取一串整数, 其中第一个整数代表后续出现整数的个数, 该数值通过返回值返回; 后续的整数通过传入的数组参数返回. 50 | 51 | ?> `getarray` 函数只获取传入数组的起始地址, 而不检查调用者提供的数组是否有足够的空间容纳输入的一串整数. 52 | 53 | **示例**: 54 | 55 | ```c 56 | int a[10][10]; 57 | int n; 58 | n = getarray(a[0]); 59 | ``` 60 | 61 | ### putint 62 | 63 | **函数声明**: `void putint(int)` 64 | 65 | **描述**: 输出一个整数的值. 66 | 67 | **示例**: 68 | 69 | ```c 70 | int n = 10; 71 | putint(n); 72 | putint(10); 73 | putint(n); 74 | ``` 75 | 76 | 将输出: `101010`. 77 | 78 | ### putch 79 | 80 | **函数声明**: `void putch(int)` 81 | 82 | **描述**: 将整数参数的值作为 ASCII 码, 输出该 ASCII 码对应的字符. 83 | 84 | ?> 传入的整数参数取值范围应为 0 到 255, `putch` 不检查参数的合法性. 85 | 86 | **示例**: 87 | 88 | ```c 89 | int n = 10; 90 | putch(n); 91 | ``` 92 | 93 | 将输出换行符. 94 | 95 | ### putarray 96 | 97 | **函数声明**: `void putarray(int, int[])` 98 | 99 | **描述**: 第 1 个参数指定了输出整数的个数 (假设为 `N`), 第 2 个参数指向的数组中包含 N 个整数. `putarray` 在输出时会在整数之间安插空格. 100 | 101 | ?> `putarray` 函数不检查参数的合法性. 102 | 103 | **示例**: 104 | 105 | ```c 106 | int n = 2; 107 | int a[2] = {2, 3}; 108 | putarray(n, a); 109 | ``` 110 | 111 | 将输出: `2: 2 3`. 112 | 113 | ## 计时函数 114 | 115 | SysY 运行时库提供 `starttime` 和 `stoptime` “函数”, 用于测量 SysY 中某段代码的运行时间. 在一个 SysY 程序中, 可以插入多对 `starttime`, `stoptime` 调用, 以此来获得每对调用之间的代码的执行时长, 并在 SysY 程序执行结束后得到这些计时的累计执行时长. 116 | 117 | 你需要注意: 118 | 119 | 1. `starttime` 和 `stoptime` 只会出现在课程提供的**性能测试用例**中. 120 | 121 | 2. `starttime`, `stoptime` 不支持嵌套调用的形式, 即不支持: 122 | 123 | ```c 124 | starttime(); 125 | ... 126 | starttime(); 127 | ... 128 | stoptime(); 129 | ... 130 | stoptime(); 131 | ``` 132 | 133 | 这样的调用执行序列. 134 | 135 | 下面分别介绍所提供的计时函数的访问接口. 136 | 137 | ### starttime 138 | 139 | **函数声明**: `void starttime()`. 140 | 141 | **描述**: 开启计时器. 此函数应和 `stoptime()` 联用. 142 | 143 | ### stoptime 144 | 145 | **函数声明**: `void stoptime()`. 146 | 147 | **描述**: 停止计时器. 此函数应和 `starttime()` 联用. 148 | 149 | 程序会在最后结束的时候, 整体输出每个计时器所花费的时间, 并统计所有计时器的累计值. 格式为 `Timer#编号: 时-分-秒-微秒`. 150 | 151 | **示例**: 152 | 153 | ```c 154 | void foo(int n) { 155 | starttime(); 156 | int i = 0; 157 | while (i < n) { 158 | // do something... 159 | i = i + 1; 160 | } 161 | stoptime(); 162 | } 163 | 164 | int main() { 165 | starttime(); 166 | int i = 0; 167 | while (i < 3) { 168 | // do something... 169 | i = i + 1; 170 | } 171 | stoptime(); 172 | foo(2); 173 | return 0; 174 | } 175 | ``` 176 | 177 | 输出 (仅作示例): 178 | 179 | ``` 180 | Timer#001: 0H-0M-3S-3860us 181 | Timer#002: 0H-0M-2S-2660us 182 | TOTAL: 0H-0M-5S-6520us 183 | ``` 184 | -------------------------------------------------------------------------------- /docs/lv3-expr/unary-exprs.md: -------------------------------------------------------------------------------- 1 | # Lv3.1. 一元表达式 2 | 3 | 因为本章开头的语法规范里突然多出一大堆产生式, 所以你可能会觉得有些手足无措. 那我们不如把这堆新加的内容再做一些拆分, 先来实现一元表达式的部分. 4 | 5 | 本节新增/变更的语法规范如下: 6 | 7 | ```ebnf 8 | Stmt ::= "return" Exp ";"; 9 | 10 | Exp ::= UnaryExp; 11 | PrimaryExp ::= "(" Exp ")" | Number; 12 | Number ::= INT_CONST; 13 | UnaryExp ::= PrimaryExp | UnaryOp UnaryExp; 14 | UnaryOp ::= "+" | "-" | "!"; 15 | ``` 16 | 17 | 你需要让你的编译器支持 `+`, `-` 和 `!` 运算, 同时支持括号表达式. 18 | 19 | ## 一个例子 20 | 21 | ```c 22 | int main() { 23 | return +(- -!6); // 看起来像个颜文字 24 | } 25 | ``` 26 | 27 | ## 词法/语法分析 28 | 29 | 本节新增了三个运算符: `+`, `-` 和 `!`. 对于 C/C++ 实现, 你可以对 Flex 部分稍作修改, 使其对这些运算符进行特殊处理. 但你应该还记得, Lv1 中, 我们添加了可以匹配任意单个字符的规则. 因为新增的三个运算符刚好各自只有一个字符, 所以这个规则也可以用来匹配这些新增内容, 所以你不对 Flex 部分作修改也是可以的. 30 | 31 | 语法分析部分, 你需要根据语法规则, 设计一些新的 AST. 也许你需要回顾一下 Lv1 中关于如何设计 AST 的[相关部分](/lv1-main/parsing-main?id=设计-ast). 然后修改你的语法分析器, 使其支持根据新增的语法规则生成对应的 AST. 32 | 33 | 需要注意的是, 目前的 EBNF 中出现了一些包含 `|` 的规则, 比如: 34 | 35 | ```ebnf 36 | PrimaryExp ::= "(" Exp ")" | Number; 37 | UnaryExp ::= PrimaryExp | UnaryOp UnaryExp; 38 | ``` 39 | 40 | 对于这种情况, 设计 AST 时, 你可以采取很多种处理方式, 比如: 41 | 42 | * 对 `::=` 右侧的每个规则都设计一种 AST, 在 parse 到对应规则时, 构造对应的 AST. 43 | * 或者, 只为 `::=` 左侧的符号设计一种 AST, 使其涵盖 `::=` 右侧的所有规则. 比如在 Rust 中, 你可以用 `enum` 来表达这种行为. 44 | 45 | 实现语法分析器时, 你需要使用语法分析器支持的语法实现 `|`, 详情请自行 RTFM 或 STFW. 46 | 47 | ## 语义分析 48 | 49 | 暂无需要添加的内容. 50 | 51 | ## IR 生成 52 | 53 | 查询 [Koopa IR 规范](/misc-app-ref/koopa), 你会发现, Koopa IR 并不支持一元运算, 而只支持如下的二元运算: 54 | 55 | * **比较运算:** `ne` (比较不等), `eq` (比较相等), `gt` (比较大于), `lt` (比较小于), `ge` (比较大于等于), `le` (比较小于等于). 返回整数形式的真 (非 0) 或假 (0). 56 | * **算术运算:** `add` (加法), `sub` (减法), `mul` (乘法), `div` (除法), `mod` (模运算). 57 | * **位运算:** `and` (按位与), `or` (按位或), `xor` (按位异或). 58 | * **移位:** `shl` (左移), `shr` (逻辑右移), `sar` (算术右移). 59 | 60 | 这是因为, 目前已知有意义的一元操作均可用二元操作表示: 61 | 62 | * **变补 (取负数)**: 0 减去操作数. 63 | * **按位取反**: 操作数异或全 1 (即 `-1`). 64 | * **逻辑取反**: 操作数和 0 比较相等. 65 | 66 | 所以, 示例代码可以生成如下的 Koopa IR: 67 | 68 | ```koopa 69 | fun @main(): i32 { 70 | %entry: 71 | %0 = eq 6, 0 72 | %1 = sub 0, %0 73 | %2 = sub 0, %1 74 | ret %2 75 | } 76 | ``` 77 | 78 | 你需要注意的是: 79 | 80 | * `+` 运算实际上不会生成任何 IR. 81 | * Koopa IR 中, `%` 后可以跟任意正整数, 同样表示临时符号. 82 | * Koopa IR 是 “单赋值” 的, 即: 所有符号都只能在定义的时候被赋值一次, 所以你必须保证函数内所有的符号 (包括指令和基本块) 都具备不同的名称. 如下的 IR 程序是不合法的: 83 | 84 | ```koopa 85 | fun @main(): i32 { 86 | %entry: 87 | %0 = eq 6, 0 88 | // 不能重复定义符号 89 | %0 = sub 0, %0 90 | ret %0 91 | } 92 | ``` 93 | 94 | ## 目标代码生成 95 | 96 | 对于由示例代码生成的 Koopa IR: 97 | 98 | ```koopa 99 | fun @main(): i32 { 100 | %entry: 101 | %0 = eq 6, 0 102 | %1 = sub 0, %0 103 | %2 = sub 0, %1 104 | ret %2 105 | } 106 | ``` 107 | 108 | 可以进一步生成如下的 RISC-V 汇编: 109 | 110 | ``` 111 | .text 112 | .globl main 113 | main: 114 | # 实现 eq 6, 0 的操作, 并把结果存入 t0 115 | li t0, 6 116 | xor t0, t0, x0 117 | seqz t0, t0 118 | # 减法 119 | sub t1, x0, t0 120 | # 减法 121 | sub t2, x0, t1 122 | # 设置返回值并返回 123 | mv a0, t2 124 | ret 125 | ``` 126 | 127 | 你需要注意的是: 128 | 129 | * 在文本形式的 Koopa IR 中, 你经常会看到两条先后出现的指令, 例如上述示例中的 `%0 = eq 6, 0` 和 `%1 = sub 0, %0`. 而在处理 Koopa IR 的内存形式时, 你需要注意: 130 | * 在 C/C++ 中, 并没有 `%0 = ...` 和 `%1 = ...` 这样的结构. 前一条 `eq` 指令和后一条 `sub` 指令的指针会被按照顺序, 先后存放在 `%entry` 基本块中的指令列表中. `sub` 指令中出现的 `%0`, 表示在内存形式中实际上就是一个指向前一条 `eq` 指令的指针. 131 | * 在 Rust 中, 同样不存在 `%0 = ...` 之类的结构. 指令的 ID 会按照顺序存放在基本块的指令 layout 中, 同时, 你可以在 `dfg` 中根据 ID 访问指令的数据. `sub` 指令中出现的 `%0`, 表示在内存形式中, 实际上就是前一条 `eq` 指令的 ID. 132 | * 将上述 Koopa IR 翻译到 RISC-V 汇编的方式有很多种, 你不难找到一些更简洁的翻译方式, 虽然它们实现起来可能并不那么简单. 133 | * `x0` 是一个特殊的寄存器, 它的值恒为 0, 且向它写入的任何数据都会被丢弃. 134 | * `t0` 到 `t6` 寄存器, 以及 `a0` 到 `a7` 寄存器可以用来存放临时值. 135 | 136 | ?> 你也许会注意到, 如果按照一条指令的结果占用一个临时寄存器的目标代码生成思路, 在表达式足够复杂的情况下, 所有的临时寄存器很快就会被用完. 本章出现的测试用例中会避免出现这种情况, 同时, 你可以自行思考: 用何种方式可以缓解这个问题. 在 Lv4 中, 我们会给出一种一劳永逸的思路来解决这个问题. 137 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 北京大学编译实践课程在线文档 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /docs/lv0-env-config/docker.md: -------------------------------------------------------------------------------- 1 | # Lv0.1. 配置 Docker 2 | 3 | Docker 是容器技术的一种实现, 而容器技术又是一种轻量级的虚拟化技术. 你可以简单地把容器理解成虚拟机: 容器中可以运行另一个操作系统, 它和你的宿主系统是隔离的. 4 | 5 | 当然, 容器和虚拟机实际上并不相同, 你若感兴趣可自行 STFW, 此处不做过多介绍. 6 | 7 | 基于 Docker, 我们可以很方便地完成各类 “配环境” 的操作: 8 | 9 | * 负责配置环境的人只需要写好 `Dockerfile`, 然后使用 Docker 构建镜像即可. 和环境相关的所有内容, 包括系统里的某些配置, 或者安装的工具链, 都被封装在了镜像里. 10 | * 需要使用环境的人只要拿到镜像, 就可以基于此创建一个容器, 然后在里面完成自己的工作. 开箱即用, 不需要任何多余的操作, 十分省时省力. 11 | * 如果某天不再需要环境, 直接把容器和镜像删除就完事了, 没残留也不拖泥带水, 干净又卫生. 12 | 13 | ## 安装 Docker 14 | 15 | 你可以访问 [Docker 的官方网站](https://docs.docker.com/get-docker/) 来安装 Docker. 安装完毕后, 你可能需要重启你的系统. 16 | 17 | 鉴于许多其他课程都要求使用 Linux 操作系统完成各类操作, 而很多同学的电脑都安装了 Windows 系统, 所以大家的电脑中可能都配置了装有 Linux 系统的虚拟机. 考虑到这种情况, 此处需要说明: Docker 是支持 Windows, macOS 和 Linux 三大平台的, 所以**你可以直接在你的宿主系统 (而不是虚拟机中) 安装 Docker**. 18 | 19 | 安装完毕后, 打开系统的命令行: 20 | 21 | * 如果你使用的是 macOS 或 Linux, 你可以使用系统的默认终端. 22 | * 如果你使用的是 Windows, 你可以打开 PowerShell. 23 | 24 | 执行: 25 | 26 | ``` 27 | docker 28 | ``` 29 | 30 | 你将会看到 Docker 的帮助信息. 31 | 32 | ## 获取编译实践的镜像 33 | 34 | 在系统的命令行中执行: 35 | 36 | ``` 37 | docker pull maxxing/compiler-dev 38 | ``` 39 | 40 | 如果你使用的是 Linux 系统, 则上述命令可能需要 `sudo` 才可正常执行. 41 | 42 | 编译实践的镜像较大, 但拉取镜像的速度可能并不快. 为了加快从 Docker Hub 拉取镜像的速度, 你可以自行 STFW, 为你系统中的 Docker 配置 Docker Hub Mirror. 43 | 44 | ## Docker 的基本用法 45 | 46 | 你可以使用如下命令在编译实践的 Docker 镜像中执行命令: 47 | 48 | ``` 49 | docker run maxxing/compiler-dev ls -l / 50 | ``` 51 | 52 | 你会看到屏幕上出现了 `ls -l /` 命令的输出, 内容是 `compiler-dev` 镜像根目录里所有文件的列表. 53 | 54 | 这个命令实际上会完成以下几件事: 55 | 56 | * 使用 `compiler-dev` 这个镜像创建一个临时的容器. 57 | * 启动这个临时容器. 58 | * 在这个临时容器中执行命令 `ls -l /`. 59 | * 关闭容器. 60 | 61 | 这里其实出现了两个概念: “镜像” 和 “容器”. 你可以把它们理解为: 前者是一个硬盘, 里面装好了操作系统, 但它是静态的, 你不能直接拿它来运行. 后者是一台电脑, 里面安装了硬盘, 就能运行对应的操作系统. 62 | 63 | 当然实际上, 你可以在容器里修改文件系统的内容, 比如创建或者删除文件, 而容器对应的镜像完全不受影响. 比如你在容器里删文件把系统搞挂了, 这时候你只需要删掉这个容器, 然后从镜像创建一个新的容器, 一切就会还原到最初的样子. 64 | 65 | 刚刚的命令会根据 `compiler-dev` 创建一个容器, 但 Docker 并不会删除这个容器. 我们可以查看目前 Docker 中所有的容器: 66 | 67 | ``` 68 | $ docker ps -a 69 | CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 70 | 696cbe1128ca maxxing/compiler-dev:latest ls -l / 19 seconds ago Exited (0) 19 seconds ago vibrant_tharp 71 | ``` 72 | 73 | 命令会列出刚刚我们执行 `docker run` 时创建的临时容器. 很多情况下, 我们只是想用镜像里的环境做一些一次性的工作, 比如用里面的测试脚本测试自己的编译器, 然后查看测试结果. 在此之后这个临时容器就没有任何作用了. 我们可以执行如下命令来删除这个容器: 74 | 75 | ``` 76 | docker rm 696cbe1128ca 77 | ``` 78 | 79 | 其中, `696cbe1128ca` 是 `docker ps -a` 命令输出的容器 ID. 80 | 81 | 当然我们可以简化上述操作: 82 | 83 | ``` 84 | docker run --rm maxxing/compiler-dev ls -l / 85 | ``` 86 | 87 | 这条命令会使用 `compiler-dev` 镜像创建一个临时容器, 并在其中运行 `ls -l /` 命令, 然后删除刚刚创建的临时容器. 再次执行 `docker ps -a`, 你可以看到, 刚刚创建的容器并没有留下来. 88 | 89 | 我们还可以使用另一种方式运行容器: 90 | 91 | ``` 92 | docker run -it --rm maxxing/compiler-dev bash 93 | ``` 94 | 95 | 这条命令会使用 `compiler-dev` 创建容器, 并在其中执行 `bash`——这是许多 Linux 发行版的默认 Shell, 也就是大家启动终端后看到的命令行界面. 为了能在 Shell 中操作, 我们使用了 `-it` 参数, 这个参数会开启容器的 `stdin` 以便我们输入 (`-i`), 同时 Docker 会为容器分配一个终端 (`-t`). 96 | 97 | 执行完这条命令之后, 你会发现你进入了容器的 Shell, 你可以在其中执行任何命令: 98 | 99 | ``` 100 | root@e677c2d348fe:~# ls / 101 | bin boot dev etc home lib lib32 lib64 libx32 media mnt opt proc root run sbin srv sys tmp usr var 102 | ``` 103 | 104 | 如需退出, 你可以执行 `exit`, 或者按下 `Ctrl + D`. 因为我们添加了 `--rm` 选项, Docker 会在退出后删除刚刚的容器, 所以在这种情况下请一定不要在容器里保存重要的内容. 105 | 106 | 在许多情况下, 我们需要让 Docker 容器访问宿主系统中的文件. 比如你的编译器存放在宿主机的 `/home/max/compiler` 目录下, 你希望 Docker 容器也能访问到这个目录里的内容, 这样你就可以使用容器中的测试脚本测试你的编译器了. 你可以执行: 107 | 108 | ``` 109 | docker run -it --rm -v /home/max/compiler:/root/compiler maxxing/compiler-dev bash 110 | ``` 111 | 112 | 这条命令和之前的命令相比多了一个 `-v /home/max/compiler:/root/compiler` 选项, 这个选项代表: 我希望把宿主机的 `/home/max/compiler` 目录, 挂载 (mount) 到容器的 `/root/compiler` 目录. 这样, 在进入容器之后, 我们就可以通过访问 `/root/compiler` 来访问宿主机的 `/home/max/compiler` 目录了. 113 | 114 | 关于 Docker 的其他用法, 请参考 [Docker 的官方文档](https://docs.docker.com/engine/reference/commandline/docker/), 或根据情况自行 STFW. 115 | 116 | ## 免责声明 117 | 118 | !> 请务必注意如下内容! 119 | 120 | MaxXing 在设计 `compiler-dev` 的镜像时, 只考虑了直接使用 `docker run` 命令启动容器并在其中运行程序的操作, 并且**只对这种情况进行了测试**. 121 | 122 | 如果你使用其他方式连接了 Docker 容器, 例如在容器内安装了一个 SSH 然后远程连接, 则我们**不保证**这种使用方式不会出问题! 123 | 124 | 为避免遇到更多的问题, 我们建议你按照文档的指示来使用 Docker. 125 | -------------------------------------------------------------------------------- /docs/misc-app-ref/riscv-insts.md: -------------------------------------------------------------------------------- 1 | # RISC-V 指令速查 2 | 3 | ?> 本节只提供编译实践中你可能会用到的 RISC-V 指令的简略定义. 如果你需要详细了解 RISC-V ISA 的各类细节, 请参考 [RISC-V 规范](https://github.com/riscv/riscv-isa-manual/releases/download/Ratified-IMAFDQC/riscv-spec-20191213.pdf). 4 | 5 | ## 寄存器一览 6 | 7 | | 寄存器 | ABI 名称 | 描述 | 保存者 | 8 | | - | - | - | - | 9 | | `x0` | `zero` | 恒为 0 | N/A | 10 | | `x1` | `ra` | 返回地址 | 调用者 | 11 | | `x2` | `sp` | 栈指针 | 被调用者 | 12 | | `x3` | `gp` | 全局指针 | N/A | 13 | | `x4` | `tp` | 线程指针 | N/A | 14 | | `x5` | `t0` | 临时/备用链接寄存器 | 调用者 | 15 | | `x6-7` | `t1-2` | 临时寄存器 | 调用者 | 16 | | `x8` | `s0`/`fp` | 保存寄存器/帧指针 | 被调用者 | 17 | | `x9` | `s1` | 保存寄存器 | 被调用者 | 18 | | `x10-11` | `a0-1` | 函数参数/返回值 | 调用者 | 19 | | `x12-17` | `a2-7` | 函数参数 | 调用者 | 20 | | `x18-27` | `s2-11` | 保存寄存器 | 被调用者 | 21 | | `x28-31` | `t3-6` | 临时寄存器 | 调用者 | 22 | 23 | ## 指令记法 24 | 25 | 在之后的指令描述中, 你可能会看到如下记法: 26 | 27 | * `imm12`: 12-bit 有符号立即数, 范围为 $[-2048, 2047]$. 与 32-bit 数据运算时, `imm12` 会被符号扩展到 32-bit. 28 | * `imm`: 32-bit 有符号立即数. 29 | * `rs`: 源寄存器. 30 | * `rd`: 目的寄存器. 31 | * `label`: 汇编中的标号, 例如 `main:`. 32 | 33 | ## 指令一览 34 | 35 | ### 控制转移类 36 | 37 | #### beqz/bnez 38 | 39 | * **类别:** 伪指令. 40 | * **汇编格式:** `beqz/bnez rs, label`. 41 | * **行为:** 如果 `rs` 寄存器的值等于 (`beqz`) 或不等于 (`bnez`) 0, 则转移到目标 `label`. 42 | 43 | #### j 44 | 45 | * **类别:** 伪指令. 46 | * **汇编格式:** `j label`. 47 | * **行为:** 无条件转移到目标 `label`. 48 | 49 | #### call/ret 50 | 51 | * **类别:** 伪指令. 52 | * **汇编格式:** `call label`, `ret`. 53 | * **行为:** `call` 指令会将后一条指令的地址存入 `ra` 寄存器, 并无条件转移到目标 `label`. `ret` 指令会无条件转移到 `ra` 寄存器中保存的地址处. 54 | 55 | ### 访存类 56 | 57 | #### lw 58 | 59 | * **类别:** 指令. 60 | * **汇编格式:** `lw rs, imm12(rd)`. 61 | * **行为:** 计算 `rd` 寄存器的值与 `imm12` 相加的结果作为访存地址, 从内存中读取 32-bit 的数据, 存入 `rs` 寄存器. 62 | 63 | #### sw 64 | 65 | * **类别:** 指令. 66 | * **汇编格式:** `sw rs2, imm12(rs1)`. 67 | * **行为:** 计算 `rs1` 寄存器的值与 `imm12` 相加的结果作为访存地址, 将 `rs2` 寄存器的值 (32-bit) 存入内存. 68 | 69 | ### 运算类 70 | 71 | #### add/addi 72 | 73 | * **类别:** 指令. 74 | * **汇编格式:** `add rd, rs1, rs2`, `addi rd, rs1, imm12`. 75 | * **行为:** 计算 `rs1` 寄存器和 `rs2` 寄存器 (`add`) 或 `imm12` (`addi`) 相加的值, 存入 `rd` 寄存器. 76 | 77 | #### sub 78 | 79 | * **类别:** 指令. 80 | * **汇编格式:** `sub rd, rs1, rs2`. 81 | * **行为:** 计算 `rs1` 寄存器和 `rs2` 寄存器相减的值, 存入 `rd` 寄存器. 82 | 83 | #### slt/sgt 84 | 85 | * **类别:** `slt` 为指令, `sgt` 为伪指令. 86 | * **汇编格式:** `slt/sgt rd, rs1, rs2`. 87 | * **行为:** 判断 `rs1` 寄存器是否小于 (`slt`) 或大于 (`sgt`) `rs2` 寄存器, 如果判断条件成立, 则将 1 写入 `rd` 寄存器, 否则写入 0. 88 | 89 | #### seqz/snez 90 | 91 | * **类别:** 伪指令. 92 | * **汇编格式:** `seqz/snez rd, rs`. 93 | * **行为:** 判断 `rs` 寄存器是否等于 (`seqz`) 或不等于 (`snez`) 0, 如果判断条件成立, 则将 1 写入 `rd` 寄存器, 否则写入 0. 94 | 95 | #### xor/xori 96 | 97 | * **类别:** 指令. 98 | * **汇编格式:** `xor rd, rs1, rs2`, `xori rd, rs1, imm12`. 99 | * **行为:** 计算 `rs1` 寄存器和 `rs2` 寄存器 (`xor`) 或 `imm12` (`xori`) 按位异或的值, 存入 `rd` 寄存器. 100 | 101 | #### or/ori 102 | 103 | * **类别:** 指令. 104 | * **汇编格式:** `or rd, rs1, rs2`, `ori rd, rs1, imm12`. 105 | * **行为:** 计算 `rs1` 寄存器和 `rs2` 寄存器 (`or`) 或 `imm12` (`ori`) 按位或的值, 存入 `rd` 寄存器. 106 | 107 | #### and/andi 108 | 109 | * **类别:** 指令. 110 | * **汇编格式:** `and rd, rs1, rs2`, `andi rd, rs1, imm12`. 111 | * **行为:** 计算 `rs1` 寄存器和 `rs2` 寄存器 (`and`) 或 `imm12` (`andi`) 按位与的值, 存入 `rd` 寄存器. 112 | 113 | #### sll/srl/sra 114 | 115 | * **类别:** 指令. 116 | * **汇编格式:** `sll/srl/sra rd, rs1, rs2`. 117 | * **行为:** 对寄存器 `rs1` 进行逻辑左移 (`sll`), 逻辑右移 (`srl`) 或算术右移 (`sra`) 运算, 移位的位数为 `rs2` 寄存器的值, 结果存入 `rd` 寄存器. 118 | 119 | #### mul/div/rem 120 | 121 | * **类别:** 指令. 122 | * **汇编格式:** `mul/div/rem rd, rs1, rs2`. 123 | * **行为:** 计算寄存器 `rs1` 和寄存器 `rs2` 相乘 (`mul`), 除以 (`div`) 或取余 (`rem`) 的值, 存入 `rd` 寄存器. 124 | 125 | ### 加载和移动类 126 | 127 | #### li 128 | 129 | * **类别:** 伪指令. 130 | * **汇编格式:** `li rd, imm`. 131 | * **行为:** 将立即数 `imm` 加载到寄存器 `rd` 中. 132 | 133 | #### la 134 | 135 | * **类别:** 伪指令. 136 | * **汇编格式:** `la rd, label`. 137 | * **行为:** 将标号 `label` 的绝对地址加载到寄存器 `rd` 中. 138 | 139 | #### mv 140 | 141 | * **类别:** 伪指令. 142 | * **汇编格式:** `mv rd, rs`. 143 | * **行为:** 将寄存器 `rs` 的值复制到寄存器 `rd`. 144 | -------------------------------------------------------------------------------- /docs/lv9p-reincarnation/awesome-compiler.md: -------------------------------------------------------------------------------- 1 | # Lv9+.1. 你的编译器超强的 2 | 3 | > “除了用来交作业, 我的编译器到底还能做什么?” 4 | 5 | 可能很多同学在完成编译实践之后, 都会有这样的疑问. 6 | 7 | 确实, 同学们实现的编译器看上去只能处理一些简单的不能再简单的程序. 你可以打开之前用来测试编译器的[测试用例仓库](https://github.com/pku-minic/compiler-dev-test-cases)看一眼, 里面都是一些破碎不堪的程序 (毕竟只是为了罗列并测试相关功能), 和一些完全不知所云的把 CPU 当狗遛的程序 (分支, 循环和函数的组合能测出编译器的很多 bug). 其中最复杂的程序, 只是几个排序算法的实现而已. 8 | 9 | 难道你写的编译器只能用来编译一些排序算法吗? 10 | 11 | **当然不是, 你的编译器超强的!** 12 | 13 | ## SysY 是图灵完备的 14 | 15 | 在编程语言的语境下, 图灵完备 ([Turing-complete](https://en.wikipedia.org/wiki/Turing_completeness)) 指的是, 某种编程语言可以用来模拟任何一种图灵机 ([Turing machine](https://en.wikipedia.org/wiki/Turing_machine)), 图灵机能做什么事情, 这种编程语言也可以做同样的事情. 如果某种编程语言是图灵完备的, 那它也可以用来模拟其他图灵完备的系统. 16 | 17 | 为什么说 SysY 是图灵完备的呢? 它具备各种控制流语句, 可以用来表达任何形式分支和循环; 同时它可以定义变量或者数组变量, 于是用它编写的程序可以存取各类数据. 虽然我们没有形式化地证明 SysY 是图灵完备的, 但我们不难看出, 具备上述能力之后, SysY 足以模拟一个具备纸带和读写头的图灵机. 18 | 19 | 抛开那些严谨的定义, 我们通常说某种编程语言是图灵完备的, 指的是我们可以用这种编程语言模拟任何其他的, 通常意义上的计算机系统. SysY 是图灵完备的, 所以 SysY 完全可以模拟任何现实世界中的计算机系统, 或者实现任何计算机程序. 比如你可以用 SysY 实现一个 x86-64 的软件虚拟机, 然后在上面运行 Windows 11, 再跑一个 SysY 写的坎巴拉太空计划或者 Minecraft 之类的——当然, 性能高不高另说. 20 | 21 | 你的编译器可以把任何 SysY 程序编译到 RISC-V 汇编, 于是, 你的编译器足以编译并生成任何复杂的程序. 22 | 23 | ## 一些有趣的例子 24 | 25 | 不要被 SysY 简单的外表所欺骗, 也别被 SysY 限制了想象力. 有的时候, 你只需要稍微花点时间, 就能用 SysY 写出很多有趣的东西. 26 | 27 | 为了让你认识到, 你写的编译器真的很强, 我们特地写了一些比较有趣的 SysY 程序, 详情请见 GitHub 上的 [awesome-sysy](https://github.com/pku-minic/awesome-sysy) 仓库. 28 | 29 | 这其中, 有些程序完全符合 SysY 的语法定义, 也就是说, **只要你正确实现了你的编译器, 你就可以尝试把这些程序编译到 Koopa IR 或者 RISC-V, 然后运行它们, 试玩一下.** 另一些程序使用了一些不属于 SysY 的扩展语法, 你的编译器还需要支持一些额外的 C 语言语法, 才能处理这些程序. (当然, 并不是说不扩展语法这些程序就写不出来, 只是说, 纯用 SysY 写这些程序实在太费劲了, ~助教懒得搞了~) 30 | 31 | ### maze 32 | 33 | [maze](https://github.com/pku-minic/awesome-sysy/tree/master/maze) 程序就是用纯 SysY 实现的, 它可以随机生成一个 $100 \times 100$ 的迷宫, 然后把生成结果输出到图像: 34 | 35 | ![生成的迷宫](maze.png) 36 | 37 | ### mandelbrot 38 | 39 | [mandelbrot](https://github.com/pku-minic/awesome-sysy/blob/master/mandelbrot) 程序可以绘制 [Mandelbrot 集](https://en.wikipedia.org/wiki/Mandelbrot_set), 并且把绘制的结果输出到图像中. 这个程序要求你的 SysY 编译器支持函数声明的语法: 40 | 41 | ```ebnf 42 | FuncDecl ::= FuncType IDENT "(" [FuncFParams] ")" ";"; 43 | ``` 44 | 45 | 其语义和 C 语言中的函数声明类似. 程序输出的图像如下: 46 | 47 | ![Mandelbrot](mandelbrot.png) 48 | 49 | ~太潮辣!~ 是不是很漂亮? 50 | 51 | ### lisp 52 | 53 | [lisp](https://github.com/pku-minic/awesome-sysy/tree/master/lisp) 程序**用纯 SysY 实现了一个带[引用计数 GC](https://en.wikipedia.org/wiki/Reference_counting) 的 Lisp 解释器.** 你的编译器不需要支持任何额外特性, 就可以编译这个程序. 54 | 55 | 这个解释器所支持的 [Lisp 语言](https://en.wikipedia.org/wiki/Lisp_(programming_language))也是一种图灵完备的编程语言, 如果你读过/学过 [SICP](https://en.wikipedia.org/wiki/Structure_and_Interpretation_of_Computer_Programs) 的话, 你对 Lisp 应该并不陌生. 比如你可以用解释器执行一些 Lisp 程序: 56 | 57 | ```bash 58 | # 使用你的编译器编译 lisp.c, 然后把结果生成成可执行文件 59 | # ... 60 | 61 | # 向 input.lisp 文件里写入一些 Lisp 代码 62 | cat > input.lisp < “我在编译课上, 用自己写的编译器, 编译了一个可以用来做函数式程序设计课作业的程序.” 90 | 91 | 感兴趣的话, 你可以读一读 `lisp.lisp` 的代码, 然后感受一下, (Lisp (是一种 (多么) (简洁的 (编程语言)))). 92 | 93 | ## 实现更有趣的程序 94 | 95 | 你也许会觉得, 上面的示例程序也不够有趣, 你想自己实现一些更有趣的 SysY 程序, 比如小工具, 小游戏, [demoscene](https://en.wikipedia.org/wiki/Demoscene), 甚至操作系统. 或者, 你觉得你的编译器支持的语法不够炫酷, 你希望给它添加一些更强大的特性, 无论是符合 C 语言风格的特性 (函数声明, 指针, 结构体等), 还是天马行空的特性 (匿名函数, 宏, 泛型等). 96 | 97 | 我们欢迎任何形式的, 能让你的编译器看起来更有趣的工作. 如果你实现了这些工作, 你可以把它们写进最终的实验报告, 我们会视情况加分. 98 | 99 | ?> **TODO:** 评分细则待补充. 100 | 101 | 此外, 如果你写了一些比较炫酷的 SysY 程序, 欢迎向 [awesome-sysy](https://github.com/pku-minic/awesome-sysy) 仓库发起 pull request. 也许下一届的学弟学妹们在用他们写的编译器编译你的程序的时候, 也能从中体验到满满的成就感. 102 | -------------------------------------------------------------------------------- /docs/lv9-array/README.md: -------------------------------------------------------------------------------- 1 | # Lv9. 数组 2 | 3 | 本章中, 你将在上一章的基础上, 实现一个能够处理数组的编译器. 这也是非可选章节中的最后一部分, 这部分完成后, 你的编译器将可以处理所有合法的 SysY 程序. 4 | 5 | 你的编译器将可以处理如下的 SysY 程序: 6 | 7 | ```c 8 | int main() { 9 | int arr[10], n = getarray(arr); 10 | int i = 0, sum = 0; 11 | while (i < n) { 12 | sum = sum + arr[i]; 13 | i = i + 1; 14 | } 15 | putint(sum); 16 | putch(10); 17 | return 0; 18 | } 19 | ``` 20 | 21 | ## 语法规范 22 | 23 | ```ebnf 24 | CompUnit ::= [CompUnit] (Decl | FuncDef); 25 | 26 | Decl ::= ConstDecl | VarDecl; 27 | ConstDecl ::= "const" BType ConstDef {"," ConstDef} ";"; 28 | BType ::= "int"; 29 | ConstDef ::= IDENT {"[" ConstExp "]"} "=" ConstInitVal; 30 | ConstInitVal ::= ConstExp | "{" [ConstInitVal {"," ConstInitVal}] "}"; 31 | VarDecl ::= BType VarDef {"," VarDef} ";"; 32 | VarDef ::= IDENT {"[" ConstExp "]"} 33 | | IDENT {"[" ConstExp "]"} "=" InitVal; 34 | InitVal ::= Exp | "{" [InitVal {"," InitVal}] "}"; 35 | 36 | FuncDef ::= FuncType IDENT "(" [FuncFParams] ")" Block; 37 | FuncType ::= "void" | "int"; 38 | FuncFParams ::= FuncFParam {"," FuncFParam}; 39 | FuncFParam ::= BType IDENT ["[" "]" {"[" ConstExp "]"}]; 40 | 41 | Block ::= "{" {BlockItem} "}"; 42 | BlockItem ::= Decl | Stmt; 43 | Stmt ::= LVal "=" Exp ";" 44 | | [Exp] ";" 45 | | Block 46 | | "if" "(" Exp ")" Stmt ["else" Stmt] 47 | | "while" "(" Exp ")" Stmt 48 | | "break" ";" 49 | | "continue" ";" 50 | | "return" [Exp] ";"; 51 | 52 | Exp ::= LOrExp; 53 | LVal ::= IDENT {"[" Exp "]"}; 54 | PrimaryExp ::= "(" Exp ")" | LVal | Number; 55 | Number ::= INT_CONST; 56 | UnaryExp ::= PrimaryExp | IDENT "(" [FuncRParams] ")" | UnaryOp UnaryExp; 57 | UnaryOp ::= "+" | "-" | "!"; 58 | FuncRParams ::= Exp {"," Exp}; 59 | MulExp ::= UnaryExp | MulExp ("*" | "/" | "%") UnaryExp; 60 | AddExp ::= MulExp | AddExp ("+" | "-") MulExp; 61 | RelExp ::= AddExp | RelExp ("<" | ">" | "<=" | ">=") AddExp; 62 | EqExp ::= RelExp | EqExp ("==" | "!=") RelExp; 63 | LAndExp ::= EqExp | LAndExp "&&" EqExp; 64 | LOrExp ::= LAndExp | LOrExp "||" LAndExp; 65 | ConstExp ::= Exp; 66 | ``` 67 | 68 | ## 语义规范 69 | 70 | ### 常量定义 71 | 72 | * `ConstDef` 用于定义符号常量. `ConstDef` 中的 `IDENT` 为常量的标识符, 在 `IDENT` 后, `=` 之前是可选的数组维度和各维长度的定义部分, 在 `=` 之后是初始值. 73 | * `ConstDef` 的数组维度和各维长度的定义部分不存在时, 表示定义单个常量. 此时 `=` 右边必须是单个初始数值. 74 | * `ConstDef` 的数组维度和各维长度的定义部分存在时, 表示定义数组. 其语义和 C 语言一致, 比如 `[2][8/2][1*3]` 表示三维数组, 第一到第三维长度分别为 2, 4 和 3, 每维的下界从 0 开始编号. `ConstDef` 中表示各维长度的 `ConstExp` 都必须能在编译时被求值到非负整数. SysY 在声明数组时各维长度都需要显式给出, 而不允许是未知的. 75 | * 当 `ConstDef` 定义的是数组时, `=` 右边的 `ConstInitVal` 表示常量初始化器. `ConstInitVal` 中的 `ConstExp` 是能在编译时被求值的 `int` 型表达式, 其中可以引用已定义的符号常量. 76 | * `ConstInitVal` 初始化器必须是以下三种情况之一 (注: `int` 型初始值可以是 `Number`, 或者是 `int` 型常量表达式): 77 | * 一对花括号 `{}`, 表示所有元素初始为 0. 78 | * 与多维数组中数组维数和各维长度完全对应的初始值, 如 `{{1,2},{3,4},{5,6}}`, `{1,2,3,4,5,6}`, `{1,2,{3,4},5,6}` 均可作为 `a[3][2]` 的初始值. 79 | * 如果花括号括起来的列表中的初始值少于数组中对应维的元素个数, 则该维其余部分将被隐式初始化, 需要被隐式初始化的整型元素均初始为 0. 如 `{{1,2},{3},{5}}`, `{1,2,{3},5}`, `{{},{3,4},5,6}` 均可作为 `a[3][2]` 的初始值, 前两个将 `a` 初始化为 `{{1,2},{3,0},{5,0}}`, `{{},{3,4},5,6}` 将 `a` 初始化为 `{{0,0},{3,4},{5,6}}`. 80 | 81 | ### 变量定义 82 | 83 | * `VarDef` 的数组维度和各维长度的定义部分不存在时, 表示定义单个变量; 存在时, 和 `ConstDef` 类似, 表示定义多维数组. (参见 `ConstDef` 的第 2 点) 84 | * 当 `VarDef` 含有 `=` 和初始值时, `=` 右边的 `InitVal` 和 `CostInitVal` 的结构要求相同, 唯一的不同是 `ConstInitVal` 中的表达式是 `ConstExp` 常量表达式, 而 `InitVal` 中的表达式可以是当前上下文合法的任何 `Exp`. 85 | * `VarDef` 中表示各维长度的 `ConstExp` 必须能被求值到非负整数, 但 `InitVal` 中 86 | 的初始值为 `Exp` 可以引用变量. 87 | * 对于全局数组变量, 初值表达式必须是常量表达式. 88 | 89 | ### 初值 90 | 91 | 常量或变量声明中指定的初值要与该常量或变量的类型一致. 如下形式的 `VarDef`/`ConstDef` **不满足** SysY 语义约束: 92 | 93 | ```c 94 | a[4] = 4; 95 | a[2] = {{1,2}, 3}; 96 | a = {1,2,3}; 97 | ``` 98 | 99 | ### 函数形参与实参 100 | 101 | * `FuncFParam` 定义函数的一个形式参数. 当 `IDENT` 后面的可选部分存在时, 表示定义数组类型的形参. 102 | * 当 `FuncFParam` 为数组时, 其第一维的长度省去 (用方括号 `[]` 表示), 而后面的各维则需要用表达式指明长度, 其长度必须是常量. 103 | * 函数实参的语法是 `Exp`. 对于 `int` 类型的参数, 遵循按值传递的规则; 对于数组类型的参数, 形参接收的是实参数组的地址, 此后可通过地址间接访问实参数组中的元素. 104 | * 对于多维数组, 我们可以传递其中的一部分到形参数组中. 例如, 若存在数组定义 `int a[4][3]`, 则 `a[1]` 是包含三个元素的一维数组, `a[1]` 可以作为实参, 传递给类型为 `int[]` 的形参. 105 | 106 | ### 左值表达式 107 | 108 | * 当 `LVal` 表示数组时, 方括号个数必须和数组变量的维数相同 (即定位到元素). 若 `LVal` 表示的数组作为数组参数参与函数调用, 则数组的方括号个数可以不与维数相同. 109 | * 数组访问时下标越界属于未定义行为. 110 | -------------------------------------------------------------------------------- /docs/lv6-if/if-else.md: -------------------------------------------------------------------------------- 1 | # Lv6.1. 处理 `if/else` 2 | 3 | 本节新增/变更的语法规范如下: 4 | 5 | ```ebnf 6 | Stmt ::= ... 7 | | ... 8 | | ... 9 | | "if" "(" Exp ")" Stmt ["else" Stmt] 10 | | ...; 11 | ``` 12 | 13 | ## 一个例子 14 | 15 | ```c 16 | int main() { 17 | int a = 2; 18 | if (a) { 19 | a = a + 1; 20 | } else a = 0; // 在实际写 C/C++ 程序的时候别这样, 建议 if 的分支全部带大括号 21 | return a; 22 | } 23 | ``` 24 | 25 | ## 词法/语法分析 26 | 27 | 本节新增了关键字 `if` 和 `else`, 你需要修改你的 lexer 来支持它们. 同时, 你需要针对 `if/else` 语句设计 AST, 并更新你的 parser 实现. 28 | 29 | 如果你完全按照本文档之前的内容, 例如选用了 C/C++ + Flex/Bison, 或 Rust + lalrpop 来实现你的编译器, 那么你在为你的 parser 添加 `if/else` 的语法时, 应该会遇到一些语法二义性导致的问题. 例如, Bison 会提示你发生了移进/归约冲突, lalrpop 会检测到二义性文法并拒绝生成 parser. 30 | 31 | 这个问题产生的原因是, 在 SysY (C 语言) 中, `if/else` 语句的 `else` 部分可有可无, 一旦出现了若干个 `if` 和一个 `else` 的组合, 在符合 EBNF 语法定义的前提下, 我们可以找到不止一种语法的推导 (或归约) 方法. 例如对于如下 SysY 程序: 32 | 33 | ```c 34 | if (a) if (b) x; else y; 35 | ``` 36 | 37 | 我们可以这样推导: 38 | 39 | ``` 40 | Stmt 41 | -> "if" "(" Exp ")" Stmt 42 | -> "if" "(" "a" ")" "if" "(" Exp ")" Stmt "else" Stmt 43 | -> "if" "(" "a" ")" "if" "(" "b" ")" "x" ";" "else" "y" ";" 44 | ``` 45 | 46 | 也可以这样: 47 | 48 | ``` 49 | Stmt 50 | -> "if" "(" Exp ")" Stmt "else" Stmt 51 | -> "if" "(" "a" ")" "if" "(" Exp ")" Stmt "else" "y" ";" 52 | -> "if" "(" "a" ")" "if" "(" "b" ")" "x" ";" "else" "y" ";" 53 | ``` 54 | 55 | 虽然都能抵达相同的目的地, 但我们走的路线却是不同的. 56 | 57 | 这会导致一些问题, 比如你使用 Bison 或 lalrpop 生成的 parser 会尝试根据 lexer 返回的 token 来归约得到 EBNF 中的非终结符. 你可以把归约理解成推导的逆过程, 所以对于上述 SysY 程序, parser 也能通过两种完全不同的方式进行语法的归约. 也就是说, 在这个过程中, 你可能会得到两棵完全不同的 AST——这就导致了 “二义性”, 你肯定不愿意看到这种情况的发生. 58 | 59 | 以上这个关于解析 `if/else` 的问题可以说相当之经典了, 甚至它还有一个单独的名字: 空悬 `else` 问题 ([dangling else problem](https://en.wikipedia.org/wiki/Dangling_else)). 为了避免这样的问题, SysY 的语义规定了 `else` 必须和最近的 `if` 进行匹配. 60 | 61 | 但是你可能会说: 这个问题都导致 parser 没法 “正常工作” 了, 编译器根本进行不到语义分析阶段, 在语法分析阶段就直接歇菜了, 那还怎么搞嘛. 其实这个问题是可以直接在语法层面解决的, 你只需对 `if/else` 的语法略加修改 (提示: 拆分), 就可以完全规避这个问题. 62 | 63 | ?> 你在之后的 Lv. 中还可能会遇到更多类似的语法分析冲突问题, 此时, **你需要自行思考如何解决**. 64 | 65 | ## 语义分析 66 | 67 | 无需新增内容. 记得对 `if/else` 的各部分 (条件和各分支) 进行语义分析即可. 68 | 69 | ## IR 生成 70 | 71 | 从本章开始, 你生成的程序的结构就不再是线性的了, 而是带有分支的. 在 [Lv1 中我们提到过](/lv1-main/ir-gen?id=koopa-ir-基础), Koopa IR 程序的结构按层次可以分为程序, 函数, 基本块和指令. 而你可以通过基本块和控制转移指令, 来在 Koopa IR 中表达分支的语义. 72 | 73 | Koopa IR 中, 控制转移指令有两种: 74 | 75 | 1. **`br 条件, 目标1, 目标2` 指令:** 进行条件分支, 其中 `条件` 为整数, 两个目标为基本块. 如果 `条件` 非 0, 则跳转到 `目标1` 基本块的开头执行, 否则跳转到 `目标2`. 76 | 2. **`jump 目标` 指令:** 进行无条件跳转, 其中 `目标` 为基本块. 直接跳转到 `目标` 基本块的开头执行. 77 | 78 | 在之前的 Koopa IR 程序中, 只有一个入口基本块 `%entry`. 现在, 你可以通过划分新的基本块, 来标记控制流转移的目标. 79 | 80 | 示例程序生成的 Koopa IR 为: 81 | 82 | ```koopa 83 | fun @main(): i32 { 84 | %entry: 85 | @a = alloc i32 86 | store 2, @a 87 | // if 的条件判断部分 88 | %0 = load @a 89 | br %0, %then, %else 90 | 91 | // if 语句的 if 分支 92 | %then: 93 | %1 = load @a 94 | %2 = add %1, 1 95 | store %2, @a 96 | jump %end 97 | 98 | // if 语句的 else 分支 99 | %else: 100 | store 0, @a 101 | jump %end 102 | 103 | // if 语句之后的内容, if/else 分支的交汇处 104 | %end: 105 | %3 = load @a 106 | ret %3 107 | } 108 | ``` 109 | 110 | 需要注意的是, 基本块的结尾必须是 `br`, `jump` 或 `ret` 指令其中之一 (并且, 这些指令只能出现在基本块的结尾). 也就是说, 即使两个基本块是相邻的, 例如上述程序的 `%else` 基本块和 `%end` 基本块, 如果你想表达执行完前者之后执行后者的语义, 你也必须在前者基本块的结尾添加一条目标为后者的 `jump` 指令. 这点和汇编语言中 label 的概念有所不同. 111 | 112 | ?> 上述文本形式的 Koopa IR 程序中, 四个基本块看起来都是相邻的, 但实际转换到内存形式后, 这些基本块并不存在所谓的 “相邻” 关系. 它们之间通过控制转移指令, 建立了图状的拓扑关系, 即, 这些基本块构成了一个控制流图 ([control-flow graph](https://en.wikipedia.org/wiki/Control-flow_graph)). 113 |

114 | 编译器在大部分情况下都在处理诸如控制流图的图结构, 但一旦生成目标代码, 编译器就不得不把图结构压扁, 变成线性的结构——因为处理器从内存里加载程序到执行程序的过程中, 根本不存在 “图” 的概念. 这个压缩过程必然会损失很多信息, 这也是编译优化和体系结构之间存在的 gap. 115 | 116 | ## 目标代码生成 117 | 118 | RISC-V 中也存在若干能表示分支和跳转的指令/伪指令, 你可以使用其中两条来翻译 Koopa IR 中的 `br` 和 `jump` 指令: 119 | 120 | 1. **`bnez 寄存器, 目标`:** 判断 `寄存器` 的值, 如果不为 0, 则跳转到目标, 否则继续执行下一条指令. 121 | 2. **`j 目标`:** 无条件跳转到 `目标`. 122 | 123 | 同时, 在 RISC-V 汇编中, 你可以使用 `名称:` 的形式来定义一个 label, 标记控制转移指令的目标. 124 | 125 | 示例程序生成的 RISC-V 汇编为: 126 | 127 | ``` 128 | .text 129 | .globl main 130 | main: 131 | addi sp, sp, -32 132 | li t0, 2 133 | sw t0, 0(sp) 134 | lw t0, 0(sp) 135 | sw t0, 4(sp) 136 | 137 | # if 的条件判断部分 138 | lw t0, 4(sp) 139 | bnez t0, then 140 | j else 141 | 142 | # if 语句的 if 分支 143 | then: 144 | lw t0, 0(sp) 145 | sw t0, 8(sp) 146 | lw t0, 8(sp) 147 | li t1, 1 148 | add t0, t0, t1 149 | sw t0, 12(sp) 150 | lw t0, 12(sp) 151 | sw t0, 0(sp) 152 | j end 153 | 154 | # if 语句的 else 分支 155 | else: 156 | li t0, 0 157 | sw t0, 0(sp) 158 | j end 159 | 160 | # if 语句之后的内容, if/else 分支的交汇处 161 | end: 162 | lw t0, 0(sp) 163 | sw t0, 16(sp) 164 | lw a0, 16(sp) 165 | addi sp, sp, 32 166 | ret 167 | ``` 168 | -------------------------------------------------------------------------------- /docs/lv1-main/ir-gen.md: -------------------------------------------------------------------------------- 1 | # Lv1.4. IR 生成 2 | 3 | 上一节, 我们的编译器已经可以将只包含 `main` 函数的简单 SysY 程序解析成 AST 了——这是好的, 而且没有任何坏处, 因为我们可以在此基础上做很多事情, 比如: 生成 IR. 4 | 5 | 一旦完成了 IR 的生成, 你的编译器就已经初步成型了. 因为有一些基础设施的存在, 比如 LLVM IR, 现代的很多编译器都只会进行到 IR 生成这一步, 然后把后续的工作交给这些基础设施完成. 对于编译实践来说, 完成 IR 生成部分之后, 你就可以进行在线评测, 然后拿到编译原理课程实践部分的第一桶分 (?). 6 | 7 | 本节将讲述如何将你设计的 AST 变成编译实践中使用的 IR: Koopa IR. 8 | 9 | ## 语义分析 10 | 11 | 在生成 IR 之前, 你也许还记得我们在本章的[第一节](/lv1-main/structure)讲过编译器的结构: 12 | 13 | * 编译器首先会对源代码做词法/语法分析, 生成 AST. 14 | * 然后在 AST 上进行语义分析, 建立符号表, 做类型检查, 报告语义错误, 等等. 15 | * 接着, 遍历语义分析后的 AST, 生成 IR. 16 | 17 | 我们会在每章的[开头](/lv1-main/)给出本章涉及内容的语法规范和语义规范. 一个功能完备的编译器应该能够检查输入程序是否符合语义规范, 并在发生语义错误时报错. 18 | 19 | 不过, 在课程实践中, 所有的测试用例均为符合语法/语义规范的 SysY 程序. **我们不要求你编写的编译器具备处理语法/语义错误的能力, 也不会考察这些内容.** 但我们希望学有余力的同学, 能够在自己的编译器中检查这些问题, 并对其作出合适的处理, 比如像 `clang` 或 `rustc` 一样, 给出精确到行列的错误信息, 甚至具备忽略错误继续扫描, 以及对错误给出修改建议的高级功能. 20 | 21 | 比如在本章中, 你可以在生成 IR 之前, 或生成 IR 的同时, 检查你扫描到的函数的名称是否为 `main`, 如果不是, 就向 `stderr` 输出错误信息, 并退出, 同时返回一个非零的 exit code. 22 | 23 | ## Koopa IR 基础 24 | 25 | ?> 我们建议你在完成本节的内容之前, 先阅读 [Lv0.2. Koopa IR 简介](/lv0-env-config/koopa)部分的内容. 26 | 27 | Koopa IR 中, 最大的单位是 `Program`, 它代表一个 Koopa IR 程序. `Program` 由若干全局变量 (`Value`) 和函数 (`Function`) 构成. `Function` 又由若干基本块 (`BasicBlock`) 构成, 基本块中是一系列指令, 指令也是 `Value`. 所以 Koopa IR 程序的结构如下所示: 28 | 29 | * `Program` 30 | * 全局变量列表: 31 | * `Value` 1. 32 | * `Value` 2. 33 | * ... 34 | * 函数列表: 35 | * `Function` 1. 36 | * 基本块列表: 37 | * `BasicBlock` 1. 38 | * 指令列表: 39 | * `Value` 1. 40 | * `Value` 2. 41 | * ... 42 | * `BasicBlock` 2. 43 | * ... 44 | * `Function` 2. 45 | * ... 46 | 47 | 上述 “全局变量”, “函数”, “指令” 的概念, 大家可能都理解, Koopa IR 中的这些概念也和编程语言中的同类概念一致. 那么, “基本块” 是什么? 48 | 49 | 基本块 ([basic block](https://en.wikipedia.org/wiki/Basic_block)) 是编译领域的一个很常见的概念, 它指的是一系列指令的集合, 基本块满足: 50 | 51 | * **只有一个入口点:** 所有基本块中的指令如果要执行跳转, 只能跳到某个基本块的开头, 而不能跳到中间. 52 | * **只有一个出口点:** 基本块中, 只有最后一条指令能进行控制流的转移, 也就是跳到其他基本块, 或者从函数中返回 (执行 `return` 操作). 53 | 54 | 基本块的存在可以简化很多编译过程中需要进行的分析, 所以 Koopa IR 要求函数中的指令必须预先按照基本块分类. 同时, Koopa IR 约定, 函数的第一个基本块为函数的入口基本块, 也就是执行函数时, 首先会执行第一个基本块中的指令. 55 | 56 | 现阶段, 我们可以暂时忽略全局变量, 同时我们也可以暂时认为, `Program` 的函数列表里只有一个 `Function`, `Function` 的基本块列表里只有一个 `BasicBlock` (也就是入口基本块). 57 | 58 | 接下来, 基本块中必须存在指令, 也就是 `Value`. Koopa IR 中主要有以下几种 `Value` (详见 [`ValueKind` 的文档](https://docs.rs/koopa/latest/koopa/ir/entities/enum.ValueKind.html)): 59 | 60 | * **各类常量:** 整数常量 (`Integer`), 零初始化器 (`ZeroInit`), 等等. 61 | * **参数引用:** 函数参数引用 (`FuncArgRef`) 等, 用来指代传入的参数. 62 | * **内存分配:** 全局内存分配 (`GlobalAlloc`, 所有的全局变量都是这个玩意) 和局部内存分配 (`Alloc`). 63 | * **访存指令:** 加载 (`Load`) 和存储 (`Store`). 64 | * **指针运算:** `GetPtr` 和 `GetElemPtr`. 65 | * **二元运算:** `Binary`, 比如加减乘除模/比较之类的运算都属于此类. 66 | * **控制转移:** 条件分支 (`Branch`) 和无条件跳转 (`Jump`). 67 | * **函数相关:** 函数调用 (`Call`) 和函数返回 (`Return`). 68 | 69 | 看起来有很多很多, 但其实在本章中我们只会用到函数返回指令和整数常量, 也就是 `Return` 和 `Integer`. 70 | 71 | 所以, 现在的目标很明确了: 72 | 73 | 1. 我们应该生成一个 Koopa IR 程序. 74 | 2. 程序中有一个名字叫 `main` 的函数. 75 | 3. 函数里有一个入口基本块. 76 | 4. 基本块里有一条返回指令. 77 | 5. 返回指令的返回值就是 SysY 里 `return` 语句后跟的值, 也就是一个整数常量. 78 | 79 | 这个程序写出来长这样: 80 | 81 | ```koopa 82 | fun @main(): i32 { // main 函数的定义 83 | %entry: // 入口基本块 84 | ret 0 // return 0 85 | } 86 | ``` 87 | 88 | 是不是很简单? 当然, 你可能还是会有一些疑问: 89 | 90 | * **为什么这个函数看起来叫 `@main` 而不叫 `main`?** 这是 Koopa IR 里的规定, `Function`, `BasicBlock`, `Value` 的名字必须以 `@` 或者 `%` 开头. 前者表示这是一个 “具名符号”, 后者表示这是一个 “临时符号”. 91 | * 这两者其实没有任何区别, 但我们通常用前者表示 SysY 里出现的符号, 用后者表示你的编译器在生成 IR 的时候生成的符号. 因为 `main` 是 SysY 里定义的, 所以这个函数叫 `@main`. 关于符号名称的细节见 [Koopa IR 规范](/misc-app-ref/koopa?id=符号名称). 92 | * **`i32` 是什么?** Koopa IR 是一种强类型 IR, 也就是说, 诸如函数参数, 返回值, 所有指令, 它们都是有类型的. 93 | * `i32` 指的是 32 位有符号整数 (**32**-bit signed **i**nteger), 对应 SysY 里的 `int`. 你在编译实践前期相当长的一段时间内只会见到这一种类型. 94 | * Koopa IR 的分析器可以进行类型推导, 所以我们可以省略一部分类型标注. 比如你看到程序最后出现了 `ret 0`, 这个 `0` 也是有类型的. 但分析器知道 `0` 就是个整数, 它的类型是 `i32`, 所以我们不需要写 `ret i32 0`. 这点在目前看来没啥用, 但之后能帮我们简化很多复杂的表述. 95 | * **`%entry` 是什么?** 之前解释过了, `%entry` 是这个基本块的名字. 因为基本块是你的编译器定义的, 所以它以 `%` 开头. 96 | * Koopa IR 不会约束名称的定义, 这个基本块叫什么名字都行. 你懒得起名的话, 可以设置个计数器, 把基本块和 `Value` 的名字定义成 `%0`, `%1`, ... 这样的. 只不过, 起一个有意义一些的名字能方便你 debug 编译器输出的 IR. 97 | 98 | ## 生成 Koopa IR 99 | 100 | 生成 Koopa IR 非常简单: 之前我们已经介绍过怎么输出你的 AST 了, 比如给所有 AST 都实现一个 `Dump` 方法, 然后去调用 `Dump` 方法即可. 输出字符串形式的 Koopa IR 与之类似, 此处不做过多赘述. 101 | 102 | 你可能会注意到, 我们刚刚提到了 “字符串形式的 Koopa IR”, 难道 Koopa IR 除了字符串形式还有其他形式吗? 103 | 104 | 你好, 有的. Koopa IR 目前有两种形式: 105 | 106 | 1. **文本形式:** 就是你在前文见到的字符串形式, 方便人类阅读. 107 | 2. **内存形式:** 即数据结构的形式, 你可以把它理解为另一种 “AST”, 方便程序处理. 108 | 109 | 你的编译器输出的文本形式 IR, 最终会被 Koopa IR 的相关工具 (比如 `koopac`) 读取, 变成内存形式的 IR, 然后作进一步处理. Koopa IR 的框架也提供了在两种形式的 IR 之间互相转换的接口. 110 | 111 | 所以, 考虑到上述情况, 你有以下几种生成 IR 的思路: 112 | 113 | * 遍历 AST, 输出文本形式的 IR. 这样最简单, 适用于任何语言实现的编译器. 114 | * 调用 Koopa IR 框架提供的接口. 使用 Rust 的同学可以尝试, 详见 Koopa IR 框架的 [crates.io](https://crates.io/crates/koopa) 以及[文档](https://docs.rs/koopa). 115 | * 像定义 AST 一样定义表示 Koopa IR 的数据结构 (比如指令/基本块/函数等等), 然后遍历 AST 输出这种结构, 再遍历这种结构输出字符串. 116 | * 对于使用 C/C++ 的同学, 在上一条的基础上, 你可以考虑把这种结构转换成 raw program, 然后使用 `libkoopa` 中的相关接口, 将 raw program 转换成其他形式的 Koopa IR 程序. 117 | 118 | !> 关于最后一种思路, 你可以参考 `libkoopa` 的[头文件](https://github.com/pku-minic/koopa/blob/master/libkoopa/include/koopa.h), 其中定义了 raw program 的相关结构, 以及 Koopa IR 框架对 C/C++ 暴露的相关接口. 对熟悉 C/C++ 的同学来说, 这些内容应该不难理解. 119 |

120 | 由于文档作者 MaxXing 单枪匹马持续输出, 关于 `libkoopa` 提供的基础设施, 以及如何借助这些设施生成内存形式的 Koopa IR 程序的相关内容暂未补全. 希望他有时间可以写一下. 121 | 122 | ?> 如果你基于 Make/CMake 模板, 使用 C/C++ 开发你的编译器, 那么你的编译器会和实验环境中的 `libkoopa` 库自动链接, 你无需进行任何修改. 123 | -------------------------------------------------------------------------------- /docs/lv9p-reincarnation/opt.md: -------------------------------------------------------------------------------- 1 | # Lv9+.3. 优化 2 | 3 | 在之前的章节中, 你实现的编译器只负责把 SysY 编译到 RISC-V 汇编, 而不会进行任何形式的优化. 实际上, 在业界的各类编译器实现中, 优化相关的部分所占的比重, 要远远大于其他的部分. 毕竟, 生成高性能的程序, 才是编译器的目标. 4 | 5 | 编译优化并不是一件多么高深莫测的事情. 有很多简单的优化, 你只要稍做了解, 便可以把它们添加到你的编译器中. 6 | 7 | ## 优化的分类 8 | 9 | 编译器中涉及的优化, 大体可以分为两类: 10 | 11 | * **机器无关优化:** 和目标机器 (目标指令系统) 无关的, 在 IR 层面就能进行的优化. 比如对程序结构的变换, 或者对 IR 中冗余操作的消除. 12 | * **机器相关优化:** 和目标机器 (目标指令系统) 相关的, 在目标代码上进行的优化. 比如软流水 ([software pipelining](https://en.wikipedia.org/wiki/Software_pipelining)), 或者一些目标代码层面的窥孔优化. 13 | 14 | 接下来我们为这两类优化分别举一个简单的例子. 15 | 16 | ### 死代码消除 17 | 18 | 死代码消除 ([dead code elimination](https://en.wikipedia.org/wiki/Dead_code_elimination), DCE), 顾名思义, 就是删除程序里根本用不到的代码, 也就是所谓的 “死代码”. 比如对于如下的 SysY 程序: 19 | 20 | ```c 21 | int main() { 22 | int a = 1; 23 | int b = 2; 24 | a = a + 3; 25 | b = b + a; 26 | return 0; 27 | } 28 | ``` 29 | 30 | 你的编译器可能会生成对应的 Koopa IR: 31 | 32 | ```koopa 33 | fun @main(): i32 { 34 | %entry: 35 | @a = alloc i32 36 | store 1, @a 37 | @b = alloc i32 38 | store 2, @b 39 | %0 = load @a 40 | %1 = add %0, 3 41 | store %0, @a 42 | %2 = load @b 43 | %3 = load @a 44 | %4 = add %2, %3 45 | store %4, @b 46 | ret 0 47 | } 48 | ``` 49 | 50 | 你可以发现, SysY 程序里声明了两个变量, 哼哧哼哧一顿算, 最后只是返回了一个 0, 之前的计算结果一个都没用到. 那我们为什么不干脆把这些没用的代码都删掉呢: 51 | 52 | ```koopa 53 | fun @main(): i32 { 54 | %entry: 55 | ret 0 56 | } 57 | ``` 58 | 59 | 这段程序和之前那段完全等价, 但看起来显然清爽了许多, 效率也更高. 那我们要怎么才能实现 DCE 呢? DCE 本质上是把没用的代码删掉, 那我们必须先知道, 哪些代码是没用的, 或者换句话说: 知道哪些代码是有用的. 60 | 61 | 从例子出发, 我们知道, 这个例子中只有那句 `return` 是有用的, 因为它控制了 `main` 函数的返回值, 使得我们能在外部观测到这个结果. 而 `return` 语句没有用到之前任何语句的结果, 这些语句就变成了死代码, 因为无论它们存在与否, 我们在外部都观测不到程序执行结果的任何变化. 62 | 63 | 所以道理就很简单了: 任何会产生副作用的代码都是有用的代码, 任何不产生副作用, 并且执行结果没被有用的代码用到的代码, 都是死代码. 比如: 64 | 65 | * `ret`, `br`, `jump` 都会产生副作用, 即控制流转移. 我们实现的 DCE 暂不关心控制流, 所以所有和控制流相关的代码都可以视作有副作用. 66 | * 如果 `call` 指令调用的函数存在副作用, 那 `call` 指令也会产生副作用. 一个有副作用的函数, 可能执行了 I/O 操作, 可能向指针指向的内存写入了数据 (比如写入了传入的数组参数), 可能写了全局变量, 也可能调用了其他有副作用的函数. 67 | * 如果 `store` 指令写了指针参数, 全局变量, 或者某个被带副作用的语句用到的 `alloc`, 那它也是有副作用的. 68 | 69 | ?> 关于写指针操作的判断, 其实比上面的描述复杂的多. 当然, 为了避免过于复杂的讨论, 你可以把假设定得更保守一些, 比如假定所有的 `call`/`store` 都是有副作用的. 但这样显然会导致 DCE 的效果大打折扣. 70 |

71 | 在某些编程语言里, 某个变量的指针/引用可以被到处传来传去, 也可以被任意修改, 导致这些指针/引用又指向了其他的变量. 要想分析某个指针是否指向了可能会导致副作用的那些变量, 你就必须做一些比较复杂的分析. 我们称这类分析为指针分析 ([pointer analysis](https://en.wikipedia.org/wiki/Pointer_analysis)). 这又是编译/程序分析领域的一大深坑. 72 | 73 | 然后, 你可以从这些已经确定不会被删掉的指令出发, 遍历它们用到的其他指令, 把这些指令也标记为 “有用”. 接着再依次标记和这些指令相关的其他指令……直到再也标记不出新指令为止. 此时, 函数中剩下的指令, 就都可以被删掉了. 74 | 75 | ?> 如果只是单独实现 DCE, 优化效果还是比较有限的, 毕竟一般我们不会刻意去写一些能被编译器一眼鉴定为死代码的代码. 76 |

77 | DCE 存在的价值是为其他优化擦屁股: 很多情况下, 某些优化跑完之后, 会产生一些新的死代码. 比如你实现了常量传播优化, 传播完了之后原先的变量被替换成了一个常量, 所有计算过程都被求出来了——这部分代码就变成了死代码. 如果在此之后再跑一遍 DCE, 整个 IR 就会变得清爽很多. 78 | 79 | ### 消除冗余 load 80 | 81 | 对于如下 Koopa IR: 82 | 83 | ```koopa 84 | %0 = load @a 85 | %1 = add %0, 1 86 | %2 = mul %1, 2 87 | ``` 88 | 89 | 如果你没有实现寄存器分配, 那么你的编译器很可能生成这样的目标代码: 90 | 91 | ``` 92 | lw t0, 0(sp) # %0 = load @a 93 | sw t0, 4(sp) 94 | lw t0, 4(sp) # %1 = add %0, 1 95 | li t1, 1 96 | add t0, t0, t1 97 | sw t0, 8(sp) 98 | lw t0, 8(sp) # %2 = mul %1, 2 99 | li t1, 2 100 | mul t0, t0, t1 101 | sw t0, 12(sp) 102 | ``` 103 | 104 | 注意, 这段汇编中出现了很多这样的操作: 105 | 106 | ``` 107 | sw t0, 4(sp) 108 | lw t0, 4(sp) 109 | ``` 110 | 111 | 首先把寄存器 `t0` 中的数据保存到栈上, 然后再把刚刚保存的数据重新读回 `t0`. 前一条保存指令还是有用的, 因为后续指令可能会从栈帧中读出保存的值; 但后一条加载指令可谓是一点用都没有——因为 `t0` 的值已经是我们需要的值了. 所以在遇到这种情况时, 你的编译器完全可以把之后的加载指令删掉: 112 | 113 | ``` 114 | lw t0, 0(sp) # %0 = load @a 115 | sw t0, 4(sp) 116 | li t1, 1 # %1 = add %0, 1 117 | add t0, t0, t1 118 | sw t0, 8(sp) 119 | li t1, 2 # %2 = mul %1, 2 120 | mul t0, t0, t1 121 | sw t0, 12(sp) 122 | ``` 123 | 124 | 这样, 程序里就会少做很多冗余的操作. 125 | 126 | 这种优化十分容易实现: 你只需要扫一遍生成的汇编代码, 只要发现后一条 `lw` 用到了前一条 `sw` 的结果, 并且目的寄存器和 `sw` 的源寄存器一致, 你就可以直接把 `lw` 删掉. 这类只需要扫一遍代码, 只把视线局限在刚刚扫过的一小块范围内, 并优化其中的代码的行为, 叫做窥孔优化 ([peephole optimization](https://en.wikipedia.org/wiki/Peephole_optimization)). 127 | 128 | 你还可以在此基础上更进一步: 129 | 130 | * `lw` 的目的寄存器和 `sw` 的源寄存器不一致也没关系, 你可以用一条 `mv` 伪指令替换 `lw`. 平均来看, `mv` 所花的时间要小于访存的时间, 所以这么做也是划算的. 131 | * `lw` 和 `sw` 之间还隔着其他内容也没关系, 只要这些内容不影响 `lw` 读出的数据即可. 比如中间不能出现其他写入 `sw` 寄存器的指令, `call` 指令, 转移指令或 label, 等等. 132 | 133 | 当然, 在进行上述窥孔优化的时候, 你最好能在数据结构形式的 RISC-V 指令上扫描, 而不是去处理你生成的汇编代码字符串, 因为显然前者效率更高. 也就是说, 你应该设计一种表示 RISC-V 汇编的数据结构, 然后让你的编译器从 Koopa IR 生成这种数据结构, 对其优化后, 再转换为文本形式输出. 134 | 135 | ## 优化的组织 136 | 137 | 你可能会给自己的编译器添加非常多的优化, 但作为一个有品位的程序员, 你肯定不希望这些优化的实现散落在源码的各处, 最后凑出一个堪堪能跑但看起来摇摇欲坠的编译器. 所以, 你应该思考一种组织代码的方式, 来优雅地组织你编译器中的优化. 138 | 139 | 优化所做的事情并不复杂: 我们把优化抽象成一个黑盒, 它的输入是代表程序的数据结构, 比如 IR 或者目标代码, 输出也是相同类型的数据结构, 只不过相比输入要更优化. 140 | 141 | ``` 142 | +--------------+ 143 | IR --> | Optimization | --> IR' 144 | +--------------+ 145 | ``` 146 | 147 | 基于此, 我们不难想象出应该如何在程序中表示优化: 我们可以把所有的优化都抽象成输入输出都是 IR/目标代码类型的函数. 在面向对象语言中, 我们也可以把优化抽象成一个 visitor, 利用 visitor 模式访问 IR/目标代码所代表的各类对象. 这种用来遍历程序结构并对其作出修改的函数/visitor 对象, 在编译器中一般被称作 pass. 148 | 149 | 最后, 你可以设置一个数组/列表, 用来存储所有的 pass. 编译器在执行优化的时候, 只需要遍历 pass 列表, 把输入的 IR/目标代码依次在每个 pass 上跑一遍, 用前一个 pass 的输出作为后一个的输入, 最终得到的结果就是优化的结果了. 150 | 151 | ## 更多的优化 152 | 153 | 我们挑选了一些可以实现在你的编译器中优化, 供你参考. 这些优化总体按照由易到难的顺序排列. 你可以查阅相关书籍 (龙/虎/鲸/EAC 等) 或者 STFW, 来了解这些优化的相关内容和具体实现. 154 | 155 | ### 机器无关 156 | 157 | * 常量传播. 158 | * 函数内联. 159 | * 控制流化简, 比如合并基本块, 删除不可达基本块, 化简已知分支目标的 `br` 指令等等. 160 | * 循环展开, 比如展平所有常数次的循环, 同时把循环次数未知的循环展开 $n$ 次. 161 | * 循环不变量外提 (LICM). 162 | * 归纳变量化简. 163 | 164 | ### 机器相关 165 | 166 | * 强度削弱, 比如把乘除法替换成代价更小的操作. 167 | * 各类窥孔优化, 比如传播 `mv`, load/store 的结果, 化简控制转移指令和 label 等等. 168 | * 指令调度. 169 | -------------------------------------------------------------------------------- /docs/lv1-main/structure.md: -------------------------------------------------------------------------------- 1 | # Lv1.1. 编译器的结构 2 | 3 | 编译器是如何工作的? 4 | 5 | 你之前可能完全没有思考过这个问题, 编译器对你来说只是个理所应当的工具, 只要在命令行里输入: 6 | 7 | ``` 8 | gcc hello.c -o hello 9 | ``` 10 | 11 | 编译器就会把你写的文本形式的源代码, 变成二进制形式的可执行文件. 如同魔法一般, 如同梦境一般, ~充满幻想的故事, 在世界上传染, 在愉快中蔓延.~ 12 | 13 | 当然, 你可能已经在其他课程中了解到, 编译器把源代码变成可执行文件的过程 (通常) 又分为: 14 | 15 | 1. **编译:** 将源代码编译为汇编代码 (assembly). 16 | 2. **汇编:** 将汇编代码汇编为目标文件 (object file). 17 | 3. **链接:** 将目标文件链接为可执行文件 (executable). 18 | 19 | 我们在课程中实现的编译器, 只涉及上述的第一点内容. 也就是, 我们只需要设计一个程序, 将输入的 SysY 源代码, 编译到 RISC-V 汇编即可. 在这种意义之下, 编译器通常由以下几个部分组成: 20 | 21 | * **前端:** 通过词法分析和语法分析, 将源代码解析成抽象语法树 (abstract syntax tree, AST). 通过语义分析, 扫描抽象语法树, 检查其是否存在语义错误. 22 | * **中端:** 将抽象语法树转换为中间表示 (intermediate representation, IR), 并在此基础上完成一些机器无关优化. 23 | * **后端:** 将中间表示转换为目标平台的汇编代码, 并在此基础上完成一些机器相关优化. 24 | 25 | ## 词法/语法分析 26 | 27 | 在前端中, 我们的目的是把文本形式的源代码, 转换为内存中的一种树形的数据结构. 因为相比于处理字符串, 在树形结构上进行处理显然要更方便, 且效率更高. 把文本形式的源代码变成数据结构形式的 AST 有很多种方法, 但相对科学的方法是对源代码进行词法分析和语法分析. 28 | 29 | 对于这样一段保存在文件里的源程序: 30 | 31 | ```c 32 | int main() { 33 | // 我是注释诶嘿嘿 34 | return 0; 35 | } 36 | ``` 37 | 38 | 按照常规的思路, 在程序中, 我们会打开文件, 然后逐字符读入文件的内容. 此时相当于我们在操作一个字节流 (byte stream). 39 | 40 | 但这样做并不利于我们对输入程序作进一步处理, 因为在编程语言中, 单个的字节/字符通常没什么意义, 真正有意义的是字符组成的 “单词” (token). 就像你正在读的这篇文档, 文档里的每个字单拎出来都没什么意义, 连在一起, 组成单词, 组成句子, 组成段落和文章之后, 才会有意义. 41 | 42 | 词法分析的作用, 是把字节流转换为单词流 (token stream). 词法分析器 (lexer) 会按照某种规则读取文件, 并将文件的内容拆分成一个个 token 作为输出, 传递给语法分析器 (parser). 同时, lexer 还会忽略文件里的一些无意义的内容, 比如空格, 换行符和注释. 43 | 44 | Lexer 生成的 token 会包含一些信息, 用来让 parser 区分 token 的种类, 以及在必要时获取 token 的内容. 例如上述程序可能能被转换成如下的 token 流: 45 | 46 | 1. **种类:** 关键字, **内容:** `int`. 47 | 2. **种类:** 标识符, **内容:** `main`. 48 | 3. **种类:** 其他字符, **内容:** `(`. 49 | 4. **种类:** 其他字符, **内容:** `)`. 50 | 5. **种类:** 其他字符, **内容:** `{`. 51 | 6. **种类:** 关键字, **内容:** `return`. 52 | 7. **种类:** 整数字面量, **内容:** `0`. 53 | 8. **种类:** 其他字符, **内容:** `;`. 54 | 9. **种类:** 其他字符, **内容:** `}`. 55 | 56 | 而语法分析的目的, 按照程序的语法规则, 将输入的 token 流变成程序的 AST. 例如, 对于 SysY 程序, 关键字 `int` 后跟的一定是一个标识符, 而不可能是一个整数字面量, 这便是语法规则. Parser 会通过某些语法分析算法, 例如 LL 分析法或 LR 分析法, 对 token 流做一系列的分析, 并最终得到 AST. 57 | 58 | 上述程序经分析后, 可能能得到如下的 AST: 59 | 60 | ```c 61 | CompUnit { 62 | items: [ 63 | FuncDef { 64 | type: "int", 65 | name: "main", 66 | params: [], 67 | body: Block { 68 | stmts: [ 69 | Return { 70 | value: 0 71 | } 72 | ] 73 | } 74 | } 75 | ] 76 | } 77 | ``` 78 | 79 | ?> 在这里和之前解释 token 流的部分, 我们都用了 “可能”, 是因为 token 和 AST 这类数据结构仅在编译器内部出现, 并没有固定的规范. 它们的设计可以有很多种形式, 只要能够方便程序处理即可. 80 | 81 | ## 语义分析 82 | 83 | 在语法分析的基础上, 编译器会对 AST 做进一步分析, 以期 “理解” 输入程序的语义, 为之后的 IR 生成做准备. 一个符合语法定义的程序未必符合语义定义, 例如对于如下的 SysY 程序: 84 | 85 | ```c 86 | int main() { 87 | int a = 1; 88 | int a = 2; 89 | return 0; 90 | } 91 | ``` 92 | 93 | 它在语法上是正确的 (符合 [SysY 语法定义](/misc-app-ref/sysy-spec?id=文法定义)), 能被 parser 构建得到 AST. 但我们可以看到, 程序在 `main` 函数里定义了两个名为 `a` 的变量, 这在 SysY 的语义约束上是不被允许的. 94 | 95 | 语义分析阶段, 编译器通常会: 96 | 97 | * **建立符号表**, 跟踪程序里变量的声明和使用, 确定程序在某处用到了哪一个变量, 同时也可发现变量重复定义/引用未定义变量之类的错误. 98 | * **进行类型检查**, 确定程序中是否存在诸如 “对整数变量进行数组访问” 这种类型问题. 同时标注程序中表达式的类型, 以便进行后续的生成工作. 对于某些编程语言 (例如 C++11 之后的 C++, Rust 等等), 编译器还会进行类型推断. 99 | * **进行必要的编译期计算**. SysY 中支持使用常量表达式作为数组定义时的长度, 而我们在生成 IR 之前, 必须知道数组的长度 (SysY 不支持 [VLA](https://en.wikipedia.org/wiki/Variable-length_array)), 这就要求编译器必须能在编译的时候算出常量表达式的值, 同时对那些无法计算的常量表达式报错. 对于某些支持元编程的语言, 这一步可能会非常复杂. 100 | 101 | 至此, 我们就能得到一个语法正确, 语义清晰的 AST 表示了. 102 | 103 | ## IR 生成 104 | 105 | 编译器通常不会直接通过扫描 AST 来生成目标代码 (汇编)——当然这么做也不是不可以, 因为从定义上讲, AST 也是一种 “中间表示”. 只不过, AST 在形式上更接近源语言, 而且其中可能会包含一些更为高级的语义, 例如分支/循环, 甚至结构体/类等等, 这些内容要一步到位变成汇编还是比较复杂的. 106 | 107 | 所以, 编译器通常会将 AST 转换为另一种形式的数据结构, 我们把它称作 IR. IR 的抽象层次比 AST 更低, 但又不至于低到汇编代码的程度. 在此基础上, 无论是直接把 IR 进一步转换为汇编代码, 还是在 IR 之上做出一些优化, 都相对更容易. 108 | 109 | 有了 IR 的存在, 我们也可以大幅降低编译器的开发成本: 假设我们想开发 $M$ 种语言的编译器, 要求它们能把输入编译成 $N$ 种指令系统的目标代码, 在没有统一的 IR 的情况下, 我们需要开发 $M \times N$ 个相关模块. 如果我们先把所有源语言都转换到同一种 IR, 然后再将这种 IR 翻译为不同的目标代码, 我们就只需要开发 $M + N$ 个相关模块. 110 | 111 | 现实世界的确存在这样的操作, 例如 [LLVM IR](https://llvm.org/docs/) 就是一种被广泛使用的 IR. 有很多语言的编译器实现, 例如 Rust, Swift, Julia, 都会将源语言翻译到 LLVM IR. 同时, LLVM IR 可被生成为 x86, ARM, RISC-V 等一系列指令系统的目标代码. 此时, 编译器的前后端是完全解耦的, 两部分可以各自维护, 十分方便. 112 | 113 | 此外, IR 也可以极大地方便开发者调试自己的编译器. 在编译实践中, 你的编译器对于同一个 SysY 文件的输入, 既可以输出 Koopa IR, 也可以输出 RISC-V. 你可以借助相关测试工具来测试这两部分的正确性, 进而定位你的编译器到底是在前端/中端部分出了问题, 还是在后端的部分出了问题. 114 | 115 | 当然, 和 token, AST 等数据结构一样, IR 作为编译器内部的一种表示, 其形式也并不是唯一的. 在编译实践中, 我们指定了 IR 的形式为 Koopa IR, 大概长这样: 116 | 117 | ```koopa 118 | decl @getint(): i32 119 | 120 | fun @main(): i32 { 121 | %entry: 122 | @x = call @getint() 123 | %cond = lt @x, 10 124 | br %cond, %then, %else 125 | 126 | %then: 127 | %0 = add %x, 1 128 | jump %end(%0) 129 | 130 | %else: 131 | %1 = mul %x, 4 132 | jump %end(%1) 133 | 134 | %end(%result: i32): 135 | ret %result 136 | } 137 | ``` 138 | 139 | ?> 这只是 Koopa IR 的文本形式, 在编译器运行时, Koopa IR 是一种可操作的数据结构. 我们提供的 Koopa IR 框架支持这两种形式的互相转换. 140 | 141 | 但你完全可以自行设计一种其他形式的 IR. 在业界, 编译器所使用的 IR 形式可谓百花齐放, 有的编译器 (例如 [Open64](https://en.wikipedia.org/wiki/Open64)) 甚至会同时使用多种形式的 IR, 以便于进行不同层次的优化. 142 | 143 | ## 目标代码生成 144 | 145 | 编译器进行的最后一步操作, 就是将 IR 转换为目标代码, 也就是目标指令系统的汇编代码. 通常情况下, 这一步通常要做以下几件事: 146 | 147 | 1. **指令选择:** 决定 IR 中的指令应该被翻译为哪些目标指令系统的指令. 例如前文的 Koopa IR 程序中出现的 `lt` 指令可以被翻译为 RISC-V 中的 `slt`/`slti` 指令. 148 | 2. **寄存器分配:** 决定 IR 中的值和指令系统中寄存器的对应关系. 例如前文的 Koopa IR 程序中的 `@x`, `%cond`, `%0` 等等, 它们最终可能会被放在 RISC-V 的某些寄存器中. 由于指令系统中寄存器的数量通常是有限的 (RISC-V 中只有 32 个整数通用寄存器, 且它们并不都能用来存放数据), 某些值还可能会被分配在内存中. 149 | 3. **指令调度:** 决定 IR 生成的指令序列最终的顺序如何. 我们通常希望编译器能生成一个最优化的指令序列, 它可以最大程度地利用目标平台的微结构特性, 这样生成的程序的性能就会很高. 例如编译器可能会穿插调度访存指令和其他指令, 以求减少访存导致的停顿. 150 | 151 | 当然, 课程实践中实现的编译器并不会涉及这么多内容, 你只需要重点关注第一部分. 152 | -------------------------------------------------------------------------------- /docs/lv9p-reincarnation/reg-alloc.md: -------------------------------------------------------------------------------- 1 | # Lv9+.2. 寄存器分配 2 | 3 | 你可能已经发现了, 即使输入的程序很简单, 你的编译器也经常会生成大量的代码, 而且大部分都是 load/store. 4 | 5 | 还记得这些 load/store 是怎么产生的吗? 在 [Lv4.2](/lv4-const-n-var/var-n-assign) 中, 你第一次处理了 SysY 中变量的目标代码生成, 并且意识到: 把 SysY 中的变量, 以及编译器生成的计算过程中所有的中间结果都保存在 CPU 的寄存器里, 是很不现实的, 因为寄存器的数量太少了. 要想提高生成代码的质量, 你的编译器就必须可以尽可能利用寄存器, 把那些对性能至关重要的内容保留在寄存器上, 把那些实在放不进寄存器的内容保存在内存中. 6 | 7 | 于是, 你便开始找寻, 那些散落在世间的, 传说中的[寄存器分配算法](https://en.wikipedia.org/wiki/Register_allocation). 8 | 9 | ## 不分配寄存器 10 | 11 | 在之前的章节中, 我们讲述的都是这种策略. 也就是: 把所有变量都放在内存里. 12 | 13 | 它的好处是实现简单, 坏处也显而易见: 完全没用到寄存器, 进而导致编译器生成的代码性能过低. 14 | 15 | ## 分配, 但没完全分配 16 | 17 | 另一种简单的寄存器分配策略是: 遇到一个需要被分配的变量, 就从寄存器列表里找出一个没被占用的寄存器, 把它分配个这个变量. 如果所有能用的寄存器都已经被占用了, 再退化到不分配寄存器的策略. 18 | 19 | 这种策略的特点是: 虽然编译器还是没搞懂到底怎么分配寄存器, 但它觉得直接躺平也太没面子了, 于是简单挣扎了一下. 这种策略只比不分配的策略复杂了一点点, 总体来说它的实现还是相当简单的. 并且如果很走运, 你的编译器处理的函数都比较短, 那这种方法也能产生性能表现不错的代码. 20 | 21 | 另外, 一旦你开始为变量分配寄存器, 你就必须注意 ABI/调用约定导致的寄存器生命周期的问题. 比如对于如下 Koopa IR: 22 | 23 | ```koopa 24 | %0 = add 1, 1 25 | %1 = call @func() 26 | %2 = add %0, %1 27 | ``` 28 | 29 | 我们先定义了 `%0`, 然后进行了一次函数调用, 最后才使用了 `%0`. 但 ABI 规定, 某些寄存器是 “caller-saved” 的, 某些是 “callee-saved” 的. 如果你把 `%0` 放在了 “caller-saved” 寄存器中, 那你就必须在调用函数 `@func` 前保存这个寄存器的内容, 否则 `%0` 定义时保存的结果就会丢失. 而如果你把 `%0` 放在了 “callee-saved” 寄存器中, 你就必须在进入函数之前先保存这个寄存器原有的内容, 退出函数之前再将其恢复. 30 | 31 | ## 把寄存器当缓存用 32 | 33 | 基于前一种策略, 我们还能扩展出很多其他的寄存器分配方法, 比如其中一种——把寄存器当缓存用: 34 | 35 | 1. 首先, 在栈帧上为所有变量都分配空间. 36 | 2. 当需要用到某个变量的时候, 把这个变量读出来, 放在一个临时分配的寄存器里. 37 | 3. 下次需要读写变量时, 直接操作寄存器的值, 省去内存访问的开销. 38 | 4. 如果遇到某些情况, 比如出现了函数调用, 或者发生了控制流转移, 就把寄存器里保存的所有变量写回栈帧, 下次用的时候再重新读取. 39 | 40 | ?> **思考:** 为什么需要做第 4 点? 41 | 42 | 这种方法把寄存器的生命周期局限在了基本块内, 是一种局部 (local) 的寄存器分配策略. 在编译技术的语境下, 我们通常把那些基本块级别的算法称为 “局部” 的算法, 把函数级别的算法称为 “全局” (global) 的算法, 而把程序级别的算法称为 “过程间” (interprocedural) 的算法. 这和大家所理解的, 编程语言角度上的 “全局” 和 “局部”, 还是有很大区别的. 43 | 44 | 关于更多 on-the-fly 的寄存器分配策略, 你可以参考 R 大 (RednaxelaFX) 的这篇知乎回答: [寄存器分配问题?](https://www.zhihu.com/question/29355187/answer/51935409). R 大曾经在知乎上回答过大量编译技术, 编程语言和高级语言虚拟机方向的问题, 但很可惜他现在已经不上知乎了. 如果你对以上这些话题感兴趣, 十分推荐你去翻一翻 R 大的回答和文章列表. 45 | 46 | ## 活跃变量分析 47 | 48 | 你有没有觉得, 我们介绍的前几个寄存器分配策略, 都有些太过 “小心翼翼” 了? 49 | 50 | * 要么把所有的寄存器都看成一次性用品, 分完了就完了, 转头去用内存. 51 | * 要么一出基本块, 就把所有分好的寄存器写回内存. 52 | 53 | 难道就不能检测一下, 一旦某个变量在之后的程序里再也不会被用到了, 我们就把分给它的寄存器拿来, 然后重新分给别的变量用吗? 54 | 55 | 太对了! 如果真的能做到这件事, 那寄存器就可以被及时复用, 变量会有更多的机会被分配到寄存器上. 可问题的关键是: 我们应该怎么检测, 某个变量在之后的程序中还有没有用——或者说, 检测某个变量的生命周期呢? 56 | 57 | 你可能会说: 这事简单! 我扫一遍 IR, 给每条指令编个号, 再每个变量都分一个 `pair`, 记一下它最早什么时候出现, 最晚什么时候出现, 扫描的时候更新一下最晚出现的那个变量, 这不就完事了吗? 比如下面这段 Koopa IR 程序: 58 | 59 | ```koopa 60 | %bb: 61 | %x = load %a 62 | %y = add %x, 1 63 | %z = add %x, 2 64 | %p = add %z, 3 65 | ``` 66 | 67 | 很明显, `%x` 的生命周期是第一条指令到第三条指令, `%z` 的生命周期是第三条指令到第四条, `%a`, `%y` 和 `%p` 的生命周期只在它们对应的那条指令之内. 于是, 我们可以把 `%a`, `%x`, `%z` 和 `%p` 都分配到同一个寄存器上, 然后给 `%y` 分配另一个寄存器. 68 | 69 | 看起来很美好, 是吗? 那如果我加一条指令: 70 | 71 | ```koopa 72 | %bb: 73 | %x = load %a 74 | %y = add %x, 1 75 | %z = add %x, 2 76 | %p = add %z, 3 77 | jump %bb 78 | ``` 79 | 80 | 先别管这个程序是不是死循环, 只看变量的生命周期: 之前的那个方法已经不起作用了, 因为虽然变量 `%a` 在线性展开的 IR 指令序列中再没出现过, 但由于 `jump` 指令的存在, 在控制流图中, `%a` 的生命周期并没有在第一条指令之后结束. 81 | 82 | 假如我们给 `%a`, `%x`, `%z`, `%p` 都分配了相同的寄存器, 显然 `%a` 的值会被后续变量覆盖掉. 一旦进入下一次循环, `load %a` 所得到的就会是一个错误的值. 此后, 整个程序都乱成了一锅粥. 83 | 84 | 所以, 检测变量的生命周期, 并不是一个简单的事情——至少不像看起来那么简单. 在编译技术中, 有一种专门用来做这件事的算法: 活跃变量分析 ([live-variable analysis](https://en.wikipedia.org/wiki/Live_variable_analysis)) 算法. 85 | 86 | 活跃变量分析是一种数据流分析 ([data-flow analysis](https://en.wikipedia.org/wiki/Data-flow_analysis)) 算法, 顾名思义, 这种算法分析的是程序中数据的流动. 数据流分析算法运行时, 会在程序的控制流图上不断迭代, 根据图中的节点和某些其他规则, 求解数据流方程, 直到收敛到不动点. 说人话就是, 这类算法本质上都由一个大循环构成, 里面遍历 CFG 并做一些计算, 如果计算的结果不再变化, 就退出循环, 然后把计算结果视为算法的运行结果. 87 | 88 | 关于活跃变量分析的具体介绍和实现, 你可以参考 *Engineering a Compiler 2nd Edition* 一书的 8.6.1 节和 9.2.2 节, 或者自行 STFW, 此处不再赘述. 89 | 90 | ## 线性扫描寄存器分配 91 | 92 | 基于活跃变量分析的结果, 我们就可以实现更多更有效的寄存器分配算法了, 线性扫描寄存器分配 ([linear scan register allocation](https://en.wikipedia.org/wiki/Register_allocation#Linear_scan), LSRA) 就是其中之一. 93 | 94 | 线性扫描, 顾名思义, 是一个线性的寄存器分配算法. 它需要按顺序扫描变量的活跃区间, 然后基于一些贪婪的策略, 把变量放在寄存器上或者栈上. 因为这种算法只需要进行一次扫描, 就可以得到很不错的寄存器分配结果, 所以它经常被用在某些很看重编译效率的场合中, 比如即时编译 ([just-in-time compilation](https://en.wikipedia.org/wiki/Just-in-time_compilation), JIT). 95 | 96 | 关于线性扫描的详细介绍和实现, 你可以参考论文: [*Poletto & Sarkar 1999, "Linear scan register allocation"*](https://doi.org/10.1145%2F330249.330250). 97 | 98 | ## 图着色寄存器分配 99 | 100 | 另一种广为使用的寄存器分配算法是图着色分配 ([graph-coloring allocation](https://en.wikipedia.org/wiki/Register_allocation#Graph-coloring_allocation)) 算法. 这种算法相比 LSRA 要更为重量级, 运行起来更为耗时, 实现起来也更加复杂, 但通常情况下可以达到更好的寄存器分配结果. 101 | 102 | 图着色寄存器分配的思路很简单, 就是把寄存器问题映射到[图着色问题](https://en.wikipedia.org/wiki/Graph_coloring)上: 如果两个变量的生命周期相互重叠, 那么它们就不能被放在相同的寄存器上. 于是我们可以给所有变量建个图 (学名叫 interference graph), 图的顶点代表变量. 如果两个变量的生命周期重叠, 这两个变量代表的顶点之间就连一条边. 103 | 104 | 寄存器分配的过程, 本质上就是把 $N$ 个寄存器当作 $N$ 种颜色, 然后给这个图着色, 确保两个相邻顶点的颜色不同. 如果算法运行时发现这件事情做不到, 它就会从中删掉一些顶点, 代表把这些顶点对应的变量 spill 到栈上, 然后重新着色, 直到着色完成为止. 105 | 106 | 关于图着色的详细介绍和实现, 你可以参考论文: [*Chaitin 1982, "Register allocation & spilling via graph coloring"*](https://doi.org/10.1145%2F800230.806984). 107 | 108 | ?> 寄存器分配算法的分配结果的确取决于算法本身, 但你并不能只通过编译器使用了 LSRA 还是图着色, 就断定编译器的寄存器分配结果是绝对的好或坏. 109 |

110 | LSRA 和图着色在实现上都有很多细节可以打磨. 比如, 在寄存器数量不足时, 你可以选择 spill 某些变量到栈上, 把另外的变量放入寄存器. 这个 “选择在寄存器中保留哪些变量” 的策略, 本身就可以做得很复杂. 你可以考虑很多东西: 变量的生命周期长短如何? 变量被使用的频率是高是低? 变量是否位于很多层循环之中? 等等. 一个实现的很好的 LSRA, 在分配结果上, 可能和一个实现不怎么好的 LSRA 有着天壤之别. 111 |

112 | LLVM 所主要使用的寄存器分配策略 ([Greedy](https://github.com/llvm/llvm-project/blob/main/llvm/lib/CodeGen/RegAllocGreedy.cpp)), 即是 LSRA 的一个变种: [Greedy Linear Scan](https://blog.llvm.org/2011/09/greedy-register-allocation-in-llvm-30.html), 其分配结果的质量已经足够高了. 113 | -------------------------------------------------------------------------------- /docs/lv9p-reincarnation/ssa-form.md: -------------------------------------------------------------------------------- 1 | # Lv9+.4. SSA 形式 2 | 3 | SSA 形式是 IR 可以具备的一种特性, 它的英文全称是 [Static Single Assignment form](https://en.wikipedia.org/wiki/Static_single_assignment_form), 即静态单赋值形式. Koopa IR 正是一种 SSA 形式的 IR. 4 | 5 | 在前几章的学习中, 你已经知道, Koopa IR 要求 “所有的变量只能在定义的时候被赋值一次”. 也就是说, Koopa IR 中某个符号对应的变量, 它所代表的值并不会在程序执行的过程中被突然修改. 如果你想要达到 “修改” 的效果, 你必须使用 `alloc`/`load`/`store` 指令. 6 | 7 | 但实际上, 使用 `alloc`, `load` 和 `store` 只是一种妥协, 它们并不是 “原教旨主义” 的 SSA 形式. 这么妥协的原因是, 直接让编译器前端生成 SSA 形式的 IR 是比较复杂的, 我们也不希望在课程实践中引入复杂且难以理解的内容. 而这种基于 `alloc`/`load`/`store` 的表示则要好处理得多, 虽然它只是进入 SSA 形式前的一种过渡形式, 但你: 8 | 9 | * 进可在此基础上把 Koopa IR 提升到 SSA 形式. 很多业界的编译器, 比如 LLVM, [就是这么做的](https://github.com/llvm/llvm-project/blob/main/llvm/lib/Transforms/Utils/PromoteMemoryToRegister.cpp). 10 | * 退可就这么将就着用, 这种形式的 Koopa IR 对编译课来说完全够用. 11 | 12 | ## SSA 形式初见 13 | 14 | 说了这么多, SSA 形式的 IR 应该长什么样? 我们先不讨论 Koopa IR, 只基于 SSA 的定义来看一下, 比如对于下面这段 SysY 程序: 15 | 16 | ```c 17 | int a = 1; 18 | int b = 2; 19 | a = a + b; 20 | return a; 21 | ``` 22 | 23 | 要怎么把它变成 SSA 形式的呢? 我们可以这么写: 24 | 25 | ``` 26 | a = 1 // 定义 a 27 | b = 2 // 定义 b 28 | a' = a + b // a 已经被定义过了, 所以我们得换个名字 29 | return a' // 这里实际上使用的是 a' 的值 30 | ``` 31 | 32 | 这里的 `a'` 就体现了 “单赋值” 的思想: `a'` 的定义顶替了 `a` 的定义, 因为我们试图修改 `a` 的值. 之后的 `return` 又会用到 `a`, 它用到的应该是最新的 `a`, 所以在 SSA 形式下, 它应该使用 `a'`. 33 | 34 | 相比大家印象中的变量, SSA 形式中的 “变量” 似乎并不应该叫变量, 因为它看起来根本不可变. 要想修改变量, 你就只能定义一个新的变量来顶替之前的那个. SSA 形式正是借助 “单赋值” 的思想, 简化了 “变量” 的特性, 最终使得程序变得比之前好分析得多. 比如你在 SysY 里写了这样一段代码: 35 | 36 | ```c 37 | int a = 1; 38 | a = 2; 39 | return a; 40 | ``` 41 | 42 | 之前给 `a` 的初始值 1 其实根本就没用, 因为紧接着 `a` 就被改成 2 了. 所以, 对应的给 `a` 写 1 的那个操作也就成了一个冗余操作. 如果变量可以被任意修改, 那这件事情并不好用程序来分析: 你可以想象一下在这段程序上做 [DCE](/lv9p-reincarnation/opt?id=死代码消除), 编译器其实一条指令都删不掉——当然这也是因为我们介绍的 DCE 在实现上没考虑得这么复杂, 基本是个 “青春版”. 但在 SSA 形式下, 这段代码就会变成: 43 | 44 | ``` 45 | a = 1 46 | a' = 2 47 | return a' 48 | ``` 49 | 50 | `a` 直接被架空了, `return` 只用了 `a'` 的值, 和 `a` 一点关系都没有. 如果此时我们做一次 DCE (青春版), 开头的那个 `a = 1` 很容易就被删除了. 从这一点, 你应该也能大致体会到 SSA 形式所带来的优势. 51 | 52 | 不过聪明的你可能会想到另一种情况: 53 | 54 | ```c 55 | int a = 1; 56 | if (...) { 57 | a = 2; 58 | } else { 59 | a = 3; 60 | } 61 | return a; 62 | ``` 63 | 64 | 这个程序要怎么翻译成 SSA 形式呢? 你也许可以试着做一下: 65 | 66 | ``` 67 | a = 1 // 定义最初的 a 68 | br ..., then, else // 执行条件判断 69 | 70 | then: 71 | a_1 = 2 // 修改了 a 的值, 定义新变量 a_1 72 | jump end 73 | 74 | else: 75 | a_2 = 3 // 修改了 a 的值, 定义新变量 a_2 76 | jump end 77 | 78 | end: 79 | return ??? // 等等, 这里应该用哪个 a? 80 | ``` 81 | 82 | 你会发现, 遇到这种控制流合并的情况, 一旦你在之前的两个控制流里对同一个变量做了不同的修改, 或者只在一个控制流里改了变量而另一个没改, 在控制流的交汇处再想用这个变量, 你就不知道到底该用哪一个了. 而 SSA 形式用一种近乎耍赖皮的方式解决了这个问题: 83 | 84 | ``` 85 | a = 1 86 | br ..., then, else 87 | 88 | then: 89 | a_1 = 2 // 修改了 a 的值, 定义新变量 a_1 90 | jump end 91 | 92 | else: 93 | a_2 = 3 // 修改了 a 的值, 定义新变量 a_2 94 | jump end 95 | 96 | end: 97 | // 定义一个新变量 a_3 98 | // 如果控制流是从 then 基本块流入的, 那 a_3 的值就是 a_1 99 | // 如果控制流是从 else 基本块流入的, 那 a_3 的值就是 a_2 100 | a_3 = phi (a_1, then), (a_2, else) 101 | return a_3 // 这里用 a_3 102 | ``` 103 | 104 | SSA “单赋值” 的特性一点也没受影响, 我们照样写出了正确的程序——只不过程序里多了个叫 `phi` 的奇怪玩意. `phi` 的学名叫做 $\phi$ 函数 ($\phi$ function) 或者 $\phi$ 节点 ($\phi$ node), 它存在的目的就是为了调和我们遇到的这种矛盾. 有了 $\phi$ 函数, 我们就可以在任何控制流下表示 SSA 形式了. 105 | 106 | ## SSA 形式的 Koopa IR 107 | 108 | 对于如下的 Koopa IR 程序: 109 | 110 | ```koopa 111 | @a = alloc i32 112 | store 1, @a 113 | 114 | @b = alloc i32 115 | store 2, @b 116 | 117 | %0 = load @a 118 | %1 = load @b 119 | %2 = add %0, %1 120 | store %2, @a 121 | 122 | %ans = load @a 123 | ret %ans 124 | ``` 125 | 126 | 如果让你把 `alloc`/`load`/`store` 删掉, 并且维持单赋值的特性, 直觉上, 你可能会把它改成这个样子: 127 | 128 | ```koopa 129 | @a = 1 130 | @b = 2 131 | %0 = @a 132 | %1 = @b 133 | %2 = add %0, %1 134 | @a_1 = %2 // @a 已经被定义过一次了, 所以必须改个名字 135 | %ans = @a_1 136 | ret %ans 137 | ``` 138 | 139 | 很好理解对吧, 这段程序已经和之前我们介绍的的 SSA 形式相差无几了. 不过 Koopa IR 有个要求: 指令列表里永远不会出现类似 `@符号 = 值` 的这种赋值指令, 并且 Koopa IR 的[规范](/misc-app-ref/koopa)中也没有定义这种表示赋值的指令. 实际的 Koopa IR 长这样: 140 | 141 | ```koopa 142 | %2 = add 1, 2 143 | ret %2 144 | ``` 145 | 146 | 只有两条指令吗? 是的, 把所有的赋值都删掉之后, 程序里确实只剩下两条指令了, 我相信这个结果并不难理解, 这就是 SSA 形式的 Koopa IR. 147 | 148 | 另一件事情是, Koopa IR 没有支持 $\phi$ 函数, 而是采用了另一种等价的表示方法: 基本块参数 (basic block arguments). 比如对于之前举例的那段 SSA 程序: 149 | 150 | ``` 151 | a = 1 152 | br ..., then, else 153 | 154 | then: 155 | a_1 = 2 156 | jump end 157 | 158 | else: 159 | a_2 = 3 160 | jump end 161 | 162 | end: 163 | a_3 = phi (a_1, then), (a_2, else) 164 | return a_3 165 | ``` 166 | 167 | 在 Koopa IR 中应该这样表示: 168 | 169 | ```koopa 170 | br ..., %then, %else 171 | 172 | %then: 173 | jump %end(2) 174 | 175 | %else: 176 | jump %end(3) 177 | 178 | %end(%a_3: i32): 179 | ret %a_3 180 | ``` 181 | 182 | 基本块参数就像函数参数一样, 相信对于你来说, 这并不难理解. 同时, 在 $\phi$ 函数和基本块参数这两种风格之间转换, 也是比较 trivial 的, 此处不再赘述. 至于为什么采用这样的设计, 你可以参考 MaxXing 写的某篇 blog: [SSA 形式的设计取舍: Phi 函数的新形式?](http://blog.maxxsoft.net/index.php/archives/143/). 183 | 184 | ## 进入和退出 SSA 形式 185 | 186 | 非 SSA 形式的 IR 要怎么转换到 SSA 形式呢? 看了之前的介绍, 你应该能大致感觉到: 把对变量的每一次修改都变成一次新定义看起来并不复杂, 复杂的是, 应该在哪些地方插入 $\phi$ 函数 (或者基本块参数). 187 | 188 | 另一方面, 由于在现实世界的指令系统里并不能找到和 $\phi$ 函数对应的指令, 我们通常需要在进行寄存器分配前后, 让 IR 退出 SSA 形式, 也就是把所有的 $\phi$ 函数都删掉, 转换成 ISA 中定义的寄存器或者内存. 所以, 你也需要理解退出 SSA 形式的方法. 189 | 190 | 关于进入和退出 SSA 形式的算法实现, 你可以参考 *Static Single Assignment Book*——没错, 有一群人专门给 SSA 形式写了本书, 其中总结了和 SSA 相关的种种, 包括基本介绍, 基于 SSA 的分析, SSA 形式的扩展, 面向 SSA 的机器代码生成, 等等, 可谓十分详尽. 191 | 192 | SSA Book 的仓库最初被托管在 [Inria Forge](https://gforge.inria.fr/projects/ssabook/) 上, 但 2020 年这个网站关站了, 后来有人在 GitHub 上建立了[镜像仓库](https://github.com/pfalcon/ssabook). 目前的最新消息是, SSA Book 已经转生成了另一本书: *SSA-based Compiler Design*, 并且[已经出版](https://link.springer.com/book/9783030805142). 193 | 194 | 关于进入 SSA 形式的实现, 你还可以参考论文: [*Cytron et al. 1991, "Efficiently computing static single assignment form and the control dependence graph"*](https://doi.org/10.1145%2F115372.115320), 其中介绍的算法是最经典的, 将非 SSA 形式 IR 转换到 SSA 形式的算法. 195 | 196 | 当然你可能会问, 为什么非要先生成非 SSA 形式的 IR, 然后再把它转换到 SSA 形式呢? 不能一步到位直接生成 SSA 形式吗? 答案是可以的, 你可以参考论文: [*Braun et al. 2013, "Simple and Efficient Construction of Static Single Assignment Form"*](https://doi.org/10.1007%2F978-3-642-37051-9_6), 其中介绍的算法要比 Cytron 文章中的算法简单很多, 很适合 on-the-fly. 197 | 198 | ## SSA 形式上的优化 199 | 200 | 我们挑选了一些可以实现在你的编译器中的, 基于 SSA 形式的优化, 供你参考. 这些优化总体按照由易到难的顺序排列. 你可以查阅相关书籍, 文章, 或者 STFW, 来了解这些优化的相关内容和具体实现. 201 | 202 | * 比较 naive 的, 考虑了 $\phi$ 函数的 DCE, 常量传播等优化. 203 | * 考虑 $\phi$ 函数的归纳变量强度削弱. 204 | * 各类利用 SSA 性质的寄存器分配算法. 205 | * 稀疏条件常量传播 (SCCP). 206 | * 全局值标号 (GVN). 207 | * 基于 SSA 形式的部分冗余消除 (PRE), 这个比较难实现, 不是很推荐. 208 | -------------------------------------------------------------------------------- /docs/lv1-main/parsing-main.md: -------------------------------------------------------------------------------- 1 | # Lv1.3. 解析 `main` 函数 2 | 3 | 上一节我们借助词法/语法分析器生成器实现了一个可以解析 `main` 函数的简单程序, 但它离真正的编译器还有一些差距: 这个程序只能~如蜜传如蜜~——把输入的源代码转换成, 呃, 源代码, 而不是 AST. 4 | 5 | 本节将带大家快速理解 AST 要怎么设计, 以及如何让你的编译器生成 AST. 6 | 7 | ## 设计 AST 8 | 9 | 设计 AST 这件事其实很简单, 你首先要知道: 10 | 11 | 1. AST 保留了程序语法的结构. 12 | 2. AST 是为了方便程序的处理而存在的, 不存在什么设计规范. 13 | 14 | 所以其实你自己用着怎么舒服, 就怎么设计, 这样就好了. 你学会了吗? 现在来写一个编译器吧! (bushi 15 | 16 | 好吧, 说正经的. 你确实只需要以上这两点, 更重要的是第一点: AST 需要保留一些必要的语法结构. 或者换句话说, EBNF 长什么样, AST 就可以长什么样. 比如本章需要大家处理的 EBNF 如下: 17 | 18 | ```ebnf 19 | CompUnit ::= FuncDef; 20 | 21 | FuncDef ::= FuncType IDENT "(" ")" Block; 22 | FuncType ::= "int"; 23 | 24 | Block ::= "{" Stmt "}"; 25 | Stmt ::= "return" Number ";"; 26 | Number ::= INT_CONST; 27 | ``` 28 | 29 | `CompUnit` 由一个 `FuncDef` 组成, `FuncDef` 由 `FuncType`, `IDENT` 和 `Block` 组成 (中间的那对括号暂时没什么实际意义). 所以在 C++ 中, 我们可以这么写: 30 | 31 | ```cpp 32 | struct CompUnit { 33 | FuncDef func_def; 34 | }; 35 | 36 | struct FuncDef { 37 | FuncType func_type; 38 | std::string ident; 39 | Block block; 40 | }; 41 | ``` 42 | 43 | !> **注意:** 之后我们不会再提示你对应的代码应该写在什么文件中, 你需要结合实际情况, 来设计自己项目的结构. 比如: 另开一个文件存放 AST 的定义, 等等. 44 | 45 | 当然, 考虑到 Flex/Bison 中返回指针比较方便, 我们可以用一点点 OOP 和智能指针来解决问题: 46 | 47 | ```cpp 48 | // 所有 AST 的基类 49 | class BaseAST { 50 | public: 51 | virtual ~BaseAST() = default; 52 | }; 53 | 54 | // CompUnit 是 BaseAST 55 | class CompUnitAST : public BaseAST { 56 | public: 57 | // 用智能指针管理对象 58 | std::unique_ptr func_def; 59 | }; 60 | 61 | // FuncDef 也是 BaseAST 62 | class FuncDefAST : public BaseAST { 63 | public: 64 | std::unique_ptr func_type; 65 | std::string ident; 66 | std::unique_ptr block; 67 | }; 68 | 69 | // ... 70 | ``` 71 | 72 | 其他 EBNF 对应的 AST 的定义方式与之类似, 不再赘述. 73 | 74 | ?> **建议:** 考虑到很多同学在此之前并没有使用 C/C++ 编写大型项目的经验, 对于使用 C/C++ 的同学, 我们建议你将 AST 放在一个单独的头文件中. 75 |

76 | 头文件 (header file) 通常是一种用来存放变量/函数/类声明的文件. 简而言之, 如果你需要在一个 C/C++ 文件中使用另一个文件内定义的变量或函数, 你可以使用 `#include "头文件名"` 的形式, 来引入头文件中的相关声明. 77 |

78 | 不过, 头文件在写法上和普通的 C/C++ 源文件还是存在一些区别的. 在实际的工程项目中, 头文件需要考虑被多次 `#include` 的问题. 比如你在头文件 `A` 里 `#include` 了头文件 `B`, 而在源文件 `C` 里又同时 `#include` 了 `A` 和 `B`, 此时头文件 `B` 会被 `#include` 两次. 如果你没有对 `B` 做任何处理, 那么 `B` 中的声明也会出现两次, 然后编译就会出错. 79 |

80 | 这个问题可以使用在头文件中加入 [include guard](https://en.wikipedia.org/wiki/Include_guard) 的方式解决. 更简单的方法是, 你可以在你写的所有头文件的第一行添加 [`#pragma once`](https://en.wikipedia.org/wiki/Pragma_once). 81 | 82 | Rust 实现的编译器也可以采用这种方式定义 AST, 不过一方面, lalrpop 可以很方便地给不同的语法规则定义不同的返回类型; 另一方面, 在 Rust 里用指针, 引用或者多态 (trait object) 总会有些别扭, 所以我们不如直接把不同的 AST 定义成不同的类型. 83 | 84 | ```rust 85 | pub struct CompUnit { 86 | pub func_def: FuncDef, 87 | } 88 | 89 | pub struct FuncDef { 90 | pub func_type: FuncType, 91 | pub ident: String, 92 | pub block: Block, 93 | } 94 | 95 | // ... 96 | ``` 97 | 98 | ## 生成 AST 99 | 100 | C++ 实现中, 我们可以在 `.y` 文件里添加一个新的类型声明: 101 | 102 | ```bison 103 | %union { 104 | std::string *str_val; 105 | int int_val; 106 | BaseAST *ast_val; 107 | } 108 | ``` 109 | 110 | 当然, 在此之前, 所有的 AST 定义应该被放入一个头文件, 同时你应该在 `.y` 文件中正确处理头文件的引用. 111 | 112 | 此外, 我们还需要修改参数类型的声明 (其他相关声明也应该被一并修改): 113 | 114 | ```bison 115 | %parse-param { std::unique_ptr &ast } 116 | ``` 117 | 118 | 然后适当调整非终结符和语法规则的定义即可: 119 | 120 | ```bison 121 | %type FuncDef FuncType Block Stmt 122 | %type Number 123 | 124 | %% 125 | 126 | CompUnit 127 | : FuncDef { 128 | auto comp_unit = make_unique(); 129 | comp_unit->func_def = unique_ptr($1); 130 | ast = move(comp_unit); 131 | } 132 | ; 133 | 134 | FuncDef 135 | : FuncType IDENT '(' ')' Block { 136 | auto ast = new FuncDefAST(); 137 | ast->func_type = unique_ptr($1); 138 | ast->ident = *unique_ptr($2); 139 | ast->block = unique_ptr($5); 140 | $$ = ast; 141 | } 142 | ; 143 | 144 | // ... 145 | ``` 146 | 147 | 这样, 我们就在 Bison 生成的 parser 中完成了 AST 的构建, 并将生成的 AST 返回给了 parser 函数的调用者. 148 | 149 | Rust 中, lalrpop 的操作也与之类似 (注意尖括号的用法): 150 | 151 | ``` 152 | pub CompUnit: CompUnit = => CompUnit { <> }; 153 | 154 | FuncDef: FuncDef = { 155 | "(" ")" => { 156 | FuncDef { <> } 157 | } 158 | } 159 | 160 | FuncType: FuncType = "int" => FuncType::Int; 161 | 162 | Block: Block = "{" "}" => Block { <> }; 163 | 164 | Stmt: Stmt = "return" ";" => Stmt { <> }; 165 | 166 | Number: i32 = => <>; 167 | ``` 168 | 169 | ## 检查生成结果 170 | 171 | 目前的编译器已经能够正确生成 AST 了, 不过生成得到的 AST 暂时只能保存在内存里. 如果我们能把 AST 的内容也输出到命令行, 我们就能检查编译器是否按照我们的意愿生成 AST 了. 172 | 173 | 在 C++ 定义的 AST 中, 我们可以借助虚函数的特性, 给 `BaseAST` 添加一个虚函数 `Dump`, 来输出 AST 的内容: 174 | 175 | ```cpp 176 | class BaseAST { 177 | public: 178 | virtual ~BaseAST() = default; 179 | 180 | virtual void Dump() const = 0; 181 | }; 182 | ``` 183 | 184 | ?> 当然这里你也可以给 AST 重载流输出运算符 (`operator<<`). C++ 的玩法实在是太多了, 这里只挑相对大众且便于理解的方法介绍. 185 | 186 | 然后分别为所有其他 AST 实现 `Dump`: 187 | 188 | ```cpp 189 | class CompUnitAST : public BaseAST { 190 | public: 191 | std::unique_ptr func_def; 192 | 193 | void Dump() const override { 194 | std::cout << "CompUnitAST { "; 195 | func_def->Dump(); 196 | std::cout << " }"; 197 | } 198 | }; 199 | 200 | class FuncDefAST : public BaseAST { 201 | public: 202 | std::unique_ptr func_type; 203 | std::string ident; 204 | std::unique_ptr block; 205 | 206 | void Dump() const override { 207 | std::cout << "FuncDefAST { "; 208 | func_type->Dump(); 209 | std::cout << ", " << ident << ", "; 210 | block->Dump(); 211 | std::cout << " }"; 212 | } 213 | }; 214 | 215 | // ... 216 | ``` 217 | 218 | 在 `main` 函数中, 我们就可以使用 `Dump` 方法来输出 AST 的内容了: 219 | 220 | ```cpp 221 | // parse input file 222 | unique_ptr ast; 223 | auto ret = yyparse(ast); 224 | assert(!ret); 225 | 226 | // dump AST 227 | ast->Dump(); 228 | cout << endl; 229 | ``` 230 | 231 | 运行后编译器会输出: 232 | 233 | ``` 234 | CompUnitAST { FuncDefAST { FuncTypeAST { int }, main, BlockAST { StmtAST { 0 } } } } 235 | ``` 236 | 237 | 对于 Rust, 事情变得更简单了: 我们只需要给每个 AST 的结构体/枚举 derive `Debug` trait 即可: 238 | 239 | ```rust 240 | #[derive(Debug)] 241 | pub struct CompUnit { 242 | pub func_def: FuncDef, 243 | } 244 | 245 | #[derive(Debug)] 246 | pub struct FuncDef { 247 | pub func_type: FuncType, 248 | pub ident: String, 249 | pub block: Block, 250 | } 251 | 252 | // ... 253 | ``` 254 | 255 | 然后稍稍在 `main` 函数的 `println!` 部分加三个字符: 256 | 257 | ```rust 258 | // parse input file 259 | let ast = sysy::CompUnitParser::new().parse(&input).unwrap(); 260 | println!("{:#?}", ast); 261 | ``` 262 | 263 | 运行后编译器会输出: 264 | 265 | ``` 266 | CompUnit { 267 | func_def: FuncDef { 268 | func_type: Int, 269 | ident: "main", 270 | block: Block { 271 | stmt: Stmt { 272 | num: 0, 273 | }, 274 | }, 275 | }, 276 | } 277 | ``` 278 | -------------------------------------------------------------------------------- /docs/lv9-array/1d-array.md: -------------------------------------------------------------------------------- 1 | # Lv9.1. 一维数组 2 | 3 | 本节新增/变更的语法规范如下: 4 | 5 | ```ebnf 6 | ConstDef ::= IDENT ["[" ConstExp "]"] "=" ConstInitVal; 7 | ConstInitVal ::= ConstExp | "{" [ConstExp {"," ConstExp}] "}"; 8 | VarDef ::= IDENT ["[" ConstExp "]"] 9 | | IDENT ["[" ConstExp "]"] "=" InitVal; 10 | InitVal ::= Exp | "{" [Exp {"," Exp}] "}"; 11 | 12 | LVal ::= IDENT ["[" Exp "]"]; 13 | ``` 14 | 15 | ## 一个例子 16 | 17 | ```c 18 | int x[2] = {10, 20}; 19 | 20 | int main() { 21 | int arr[5] = {1, 2, 3}; 22 | return arr[2]; 23 | } 24 | ``` 25 | 26 | ## 词法/语法分析 27 | 28 | 针对本节发生变化的语法规则, 设计新的 AST, 并更新你的 parser 实现即可. 29 | 30 | ## 语义分析 31 | 32 | 数组定义时, 数组长度使用 `ConstExp` 表示, 所以你在编译时必须求出代表数组长度的常量表达式. 同时, 对于全局数组变量, 语义规定它的初始值也必须是常量表达式, 你也需要对其求值. 33 | 34 | 需要注意的是, 常量求值时, 你应该只考虑整数类型的常量定义, **而不要求值常量数组**. 例如: 35 | 36 | ```c 37 | const int a = 10; 38 | const int arr[2] = {1, 2}; 39 | ``` 40 | 41 | 对于常量 `a`, 你的编译器应该在语义分析阶段把它求出来, 存入符号表. 对于常量 `arr`, 你的编译器只需要将其初始化表达式中的常量算出来, 但不需要再设计一种代表数组常量的数据结构并将其存入符号表. 在生成的 IR 中, `a` 是不存在的 (全部被替换成了常量); 而 `arr` 是存在的, 体现在 IR 中就是一个数组. 42 | 43 | 综上所述, 如果你在常量表达式中扫描到了对常量数组的解引用, 你可以将其视为发生了语义错误. 例如如下 SysY 程序是存在语义错误的: 44 | 45 | ```c 46 | // 允许定义常量数组, 此时数组 arr 不能被修改 47 | const int arr[2] = {1, 2}; 48 | // arr 本身不会参与编译期求值, 所以编译器无法在编译时算出 arr[1] 的值 49 | // 所以此处出现了语义错误 50 | const int a = 1 + arr[1]; 51 | ``` 52 | 53 | 此外, 由于引入了数组定义, 变量的类型将不再是单一的整数. 所以你需要考虑在语义分析阶段加入类型检查机制, 来避免某些语义错误的情况发生, 例如: 54 | 55 | ```c 56 | int arr[10]; 57 | // 类型不匹配 58 | int a = arr; 59 | ``` 60 | 61 | 当然, 再次重申, 为了降低难度, 测试/评测时我们提供的输入都是合法的 SysY 程序, 并不会出现语义错误. 你的编译器不进行类型检查也是可以的. 62 | 63 | ## IR 生成 64 | 65 | 数组的定义和变量的定义类似, 同样使用 `alloc` 指令完成, 不过之后的类型需要换成数组类型. Koopa IR 中, `[T, len]` 可以表示一个元素类型为 `T`, 长度为 `len` 的数组类型. 例如 `[i32, 3]` 对应了 SysY 中的 `int[3]`, `[[i32, 3], 2]` 对应了 SysY 中的 `int[2][3]` (注意这里的 2 和 3 是反着的). 66 | 67 | SysY 中数组相关的操作通常包含两步: 通过 `[i]` 定位数组的元素, 然后读写这个元素. 根据写 C/C++ 时积累的经验, 你不难发现, 这种操作本质上其实是指针计算. 在 Koopa IR 中, 针对数组的指针计算可以使用 `getelemptr` 指令完成. 68 | 69 | `getelemptr` 指令的用法类似于 `getelemptr 指针, 偏移量`. 其中, `指针` 必须是一个数组类型的指针, 比如 `*[i32, 3]`, 或者 `*[[i32, 3], 2]`. 我们在 Lv4 中提到, `alloc T` 指令返回的类型是 `T` 的指针, 所以 `alloc` 数组时, 你刚好就可以得到一个数组的指针. 70 | 71 | `getelemptr ptr, index` 指令执行了如下操作: 假设指针 `ptr` 的类型是 `*[T, N]`, 指令会算出一个新的指针, 这个指针的值是 `ptr + index * sizeof(T)`, 类型是 `*T`. 在逻辑上, 这种操作和 C 语言中的数组访问操作是完全一致的. 比如: 72 | 73 | ```c 74 | int arr[2]; 75 | arr[1]; 76 | ``` 77 | 78 | 翻译到 Koopa IR 就是: 79 | 80 | ```koopa 81 | @arr = alloc [i32, 2] // @arr 的类型是 *[i32, 2] 82 | %ptr = getelemptr @arr, 1 // %ptr 的类型是 *i32 83 | %value = load %ptr // %value 的类型是 i32 84 | // 这是一段类型和功能都正确的 Koopa IR 代码 85 | ``` 86 | 87 | 本质上相当于: 88 | 89 | ```c 90 | int arr[2]; 91 | // 在 C 语言的指针运算中, int 指针加 1 92 | // 就相当于对指针指向的地址的数值加了 1 * sizeof(int) 93 | int *ptr = arr + 1; 94 | *ptr; 95 | ``` 96 | 97 | 对于多维数组也是一样, 虽然本节你暂时不需要实现多维数组: 98 | 99 | ```c 100 | int arr[2][3]; 101 | arr[1][2]; 102 | ``` 103 | 104 | 翻译到 Koopa IR 就是: 105 | 106 | ```koopa 107 | @arr = alloc [[i32, 3], 2] // @arr 的类型是 *[[i32, 3], 2] 108 | %ptr1 = getelemptr @arr, 1 // %ptr1 的类型是 *[i32, 3] 109 | %ptr2 = getelemptr %ptr1, 2 // %ptr2 的类型是 *i32 110 | %value = load %ptr2 // %value 的类型是 i32 111 | // 这是一段类型和功能都正确的 Koopa IR 代码 112 | ``` 113 | 114 | `getelemptr` 得到的是一个指针, 指针既可以被 `load`, 也可以被 `store`. 所以, 你的编译器可以通过生成 `getelemptr` 和 `store` 的方法, 来处理 SysY 中写入数组元素的操作. 以此类推, 对于局部数组变量的初始化列表, 你也可以把它编译成若干指针计算和 `store` 的形式. 但是, 你可能会问: 全局数组变量的初始化要怎么处理呢? 115 | 116 | 确实, Koopa IR 的全局作用域内是不能出现 `store` 指令的. 因为对应到汇编层面, 并不存在一段可以在程序被操作系统加载的时候执行的代码, 并让它帮你执行一系列的 `store` 操作. 操作系统加载程序的时候, 会把可执行文件中各段 (segment) 对应的数据, 逐步复制到内存中. 所以, 如果我们能把数组初始化列表里的元素表示成数据的形式, 我们就可以实现全局数组初始化. 117 | 118 | 这在 SysY 中是完全可行的, 因为语义规定, 全局数组变量的初始化列表中只能出现常量表达式, 所以你的编译器一定能在编译时把初始化列表里的每个元素都确定下来. 既然你已经预先知道了所有的元素, 你的编译器就可以把它们写死成数据, 然后在生成汇编的时候用 `.word` 之类的 directive 告诉汇编器, 把这些数据塞进数据段. 119 | 120 | Koopa IR 中, 你可以使用 “aggregate” 常量来表示一个常量的数组初始化列表, 比如: 121 | 122 | ```c 123 | // 这是个全局数组 124 | int arr[3] = {1, 2, 3}; 125 | ``` 126 | 127 | 翻译到 Koopa IR 就是: 128 | 129 | ```koopa 130 | global @arr = alloc [i32, 3], {1, 2, 3} 131 | ``` 132 | 133 | Aggregate 中出现的元素必须为彼此之间类型相同的常量, 比如整数, `zeroinit`, 或者另一个 aggregate, 所以多维数组也可以用这种方式初始化. 此外, aggregate 中不能省略任何元素, 对于如下 SysY 程序: 134 | 135 | ```c 136 | // 这是个全局数组 137 | int arr[3] = {5}; 138 | ``` 139 | 140 | 你的编译器必须将其翻译为: 141 | 142 | ```koopa 143 | global @arr = alloc [i32, 3], {5, 0, 0} 144 | ``` 145 | 146 | 之前提到, Koopa IR 是强类型 IR, 且能够自动推导部分类型. 假设 aggregate 中各元素的类型为 `T`, 且总共有 `len` 个元素, 那么 aggregate 本身的类型就会被推导为 `[T, len]`. 所以: 147 | 148 | ```koopa 149 | // 如下 Koopa IR 指令不合法的原因是, alloc 的类型和初始化类型不符 150 | // 右边的 aggregate 的类型为 [i32, 1] 151 | global @arr = alloc [i32, 3], {5} 152 | ``` 153 | 154 | 最后, 以防你忘了, 多提一句: `zeroinit` 也是可以用来零初始化数组的: 155 | 156 | ```koopa 157 | // 相当于 SysY 中的全局数组 int zeroed_array[2048]; 158 | global @zeroed_array = alloc [i32, 2048], zeroinit 159 | ``` 160 | 161 | 最后的最后, 你可能会问: 既然我能在全局数组初始化的时候使用 aggregate/`zeroinit`, 那我能不能在局部变量初始化的时候也这么用呢? 答案是可以: 162 | 163 | ```koopa 164 | @arr = alloc [i32, 5] 165 | store {1, 2, 3, 0, 0}, @arr 166 | ``` 167 | 168 | 但这么搞的话, 你的编译器需要在目标代码生成的时候进行一些额外的处理. 169 | 170 | 综上所述, 示例程序生成的 Koopa IR 为: 171 | 172 | ```koopa 173 | global @x = alloc [i32, 2], {10, 20} 174 | 175 | fun @main(): i32 { 176 | %entry: 177 | @arr = alloc [i32, 5] 178 | // arr 的初始化列表, 别忘了补 0 这个事 179 | %0 = getelemptr @arr, 0 180 | store 1, %0 181 | %1 = getelemptr @arr, 1 182 | store 2, %1 183 | %2 = getelemptr @arr, 2 184 | store 3, %2 185 | %3 = getelemptr @arr, 3 186 | store 0, %3 187 | %4 = getelemptr @arr, 4 188 | store 0, %4 189 | 190 | %5 = getelemptr @arr, 2 191 | %6 = load %5 192 | ret %6 193 | } 194 | ``` 195 | 196 | ## 目标代码生成 197 | 198 | 本节生成的 Koopa IR 中出现了若干新概念, 下面我们来逐一过一下. 199 | 200 | ### 计算类型大小 201 | 202 | 要想进行内存分配, 你的编译器必须先算出应分配的内存的大小. `alloc` 指令中出现了不同于 `i32` 的数组类型, 以及 `getelemptr` 的返回值是个指针, 它们的大小自然也是需要计算出来的. 这其中, 指针的大小在 RV32I 上就是 4 字节, 毕竟 RV32I 是 32 位的指令系统. 而数组类型的大小, 我应该不用多提了, 大家不难通过几次乘法算出这个数值. 203 | 204 | C/C++ 中, Koopa IR 类型在内存中表示为 `koopa_raw_type_t`, 你可以 DFS 遍历其内容, 并按照我们提到的规则计算得到类型的大小. Rust 中, `Type` 提供了 `size()` 方法, 这个方法会用 DFS 遍历的方式帮你求出类型的大小. 205 | 206 | 但需要注意的是, 考虑到 Koopa IR 的泛用性 (比如搞不好你哪天心血来潮想给 Koopa IR 写个 x86-64 后端), 默认情况下, Rust 的 `koopa` crate 中的 `Type::size()` 会按照当前平台的指针大小来计算指针类型的大小. 因为目前大家用的基本都是 64 位平台, 所以在遇到指针类型时, `size()` 会返回 8. 为了适配 riscv32 的指针宽度, 你需要在进行代码生成前 (比如在 `main` 里), 调用 `Type::set_ptr_size(4)`, 来设置指针类型的大小为 4 字节. 207 | 208 | ### 处理 `getelemptr` 209 | 210 | 前文已经描述过 `getelemptr` 的含义了, 无非是做了一次乘法和加法. 所以, 对于如下 Koopa IR 程序: 211 | 212 | ```koopa 213 | @arr = alloc [i32, 2] 214 | %ptr = getelemptr @arr, 1 215 | ``` 216 | 217 | 假设 `@arr` 位于 `sp + 4`, 则对应的 RISC-V 汇编可以是: 218 | 219 | ``` 220 | # 计算 @arr 的地址 221 | addi t0, sp, 4 222 | # 计算 getelemptr 的偏移量 223 | li t1, 1 224 | li t2, 4 225 | mul t1, t1, t2 226 | # 计算 getelemptr 的结果 227 | add t0, t0, t1 228 | # 保存结果到栈帧 229 | # ... 230 | ``` 231 | 232 | 注意: 233 | 234 | 1. 检查 `addi` 中立即数的范围. 235 | 2. 上述 RISC-V 汇编并非是最优的. 对于偏移量是 2 的整数次幂的情况, 你可以用移位指令来替换乘法指令. 236 | 3. 对于全局数组变量的指针运算, 代码生成方式和上述类似, 只不过你需要用 `la` 加载全局变量的地址. 237 | 238 | ### 处理全局初始化 239 | 240 | 之前章节解释过如何在全局生成 `zeroinit` 和整数常量: 对于前者, 生成一个 `.zero sizeof(T)`, 其中 `sizeof(T)` 代表 `zeroinit` 的类型的大小. 对于后者, 生成一个 `.word 整数`. 241 | 242 | Aggregate 的生成方式就是把上述内容组合起来, 比如: 243 | 244 | ```koopa 245 | global @arr = alloc [i32, 3], {1, 2, 3} 246 | ``` 247 | 248 | 生成的 RISC-V 汇编为: 249 | 250 | ``` 251 | .data 252 | .globl arr 253 | arr: 254 | .word 1 255 | .word 2 256 | .word 3 257 | ``` 258 | 259 | ### 生成代码 260 | 261 | 在理解上述概念之后, 你不难自行完成目标代码的生成, 故此处不再赘述. 262 | -------------------------------------------------------------------------------- /docs/lv8-func-n-global/func-def-n-call.md: -------------------------------------------------------------------------------- 1 | # Lv8.1. 函数定义和调用 2 | 3 | 本节新增/变更的语法规范如下: 4 | 5 | ```ebnf 6 | CompUnit ::= [CompUnit] FuncDef; 7 | 8 | FuncDef ::= FuncType IDENT "(" [FuncFParams] ")" Block; 9 | FuncType ::= "void" | "int"; 10 | FuncFParams ::= FuncFParam {"," FuncFParam}; 11 | FuncFParam ::= BType IDENT; 12 | 13 | UnaryExp ::= ... 14 | | IDENT "(" [FuncRParams] ")" 15 | | ...; 16 | FuncRParams ::= Exp {"," Exp}; 17 | ``` 18 | 19 | ## 一个例子 20 | 21 | ```c 22 | int half(int x) { 23 | return x / 2; 24 | } 25 | 26 | void f() {} 27 | 28 | int main() { 29 | f(); 30 | return half(10); 31 | } 32 | ``` 33 | 34 | ## 词法/语法分析 35 | 36 | 本节新增了关键字 `void`, 你需要修改你的 lexer 来支持它们. 同时, 你需要针对发生变化的语法规则, 例如 `CompUnit`, `FuncDef` 等, 设计新的 AST, 并更新你的 parser 实现. 37 | 38 | ## 语义分析 39 | 40 | 本节 `CompUnit` 的定义发生了变化, 其允许多个不同的函数同时存在于全局范围内. 为了避免函数之间的重名, 你可以把全局范围内所有的函数 (包括之后章节中会出现的全局变量) 都放在同一个作用域内, 即全局作用域. 全局作用域应该位于所有局部作用域的外层, 你可以修改你的符号表使其支持这一特性. 41 | 42 | ## IR 生成 43 | 44 | 在 Koopa IR 中, 你可以像定义 `@main` 函数一样, 定义其他的函数. 如果函数有参数, 可以直接在函数名之后的括号内写明参数名称和类型. 使用上, 函数的形式参数变量和函数内的其他变量并无区别. 45 | 46 | Koopa IR 中, 使用 `call 函数名(参数, ...)` 指令可以完成一次函数调用. `call` 指令是否具备返回值, 以及具备什么类型的返回值, 取决于指令所调用的函数的具体类型. 如果 `call` 指令不具备返回值, 则不能写为 `%v = call ...` 的形式. 47 | 48 | 对于返回值类型为 `void` 的函数, 定义函数时省略类型标注, 函数内使用 `ret` 指令时不需要附带返回值. 即, 函数需要返回时, 直接写 `ret` 即可. 49 | 50 | 示例程序生成的 Koopa IR 为: 51 | 52 | ```koopa 53 | fun @half(@x: i32): i32 { 54 | %entry: 55 | %x = alloc i32 56 | store @x, %x 57 | %0 = load %x 58 | %1 = div %0, 2 59 | ret %1 60 | } 61 | 62 | fun @f() { 63 | %entry: 64 | ret 65 | } 66 | 67 | fun @main(): i32 { 68 | %entry: 69 | call @f() 70 | %0 = call @half(10) 71 | ret %0 72 | } 73 | ``` 74 | 75 | !> 本章引入了全局符号定义. 在 Koopa IR 中, 全局的符号不能和其他全局符号同名, 局部的符号 (位于函数内部的符号) 不能和其他全局符号以及局部符号同名. 上述规则对具名符号和临时符号都适用. 76 | 77 | 你可能会注意到, 在 `@half` 函数中, 我们为参数 `@x` 又单独分配了一块名为 `%x` 的内存空间. 直接使用 `@x` 不行吗? 比如像这样: 78 | 79 | ```koopa 80 | fun @half(@x: i32): i32 { 81 | %entry: 82 | %0 = div @x, 2 83 | ret %0 84 | } 85 | ``` 86 | 87 | 可以的, 完全正确! 但此处采取了看起来更繁琐的做法, 是为了方便目标代码生成部分的处理. 否则, 目标代码生成必须做复杂处理, 或者直接生成出错误的代码. 88 | 89 | ## 目标代码生成 90 | 91 | 目标代码生成部分无非涉及以下几个问题, 即, 在 RISC-V 汇编中: 92 | 93 | * 如何定义函数? 94 | * 如何调用函数? 95 | * 如何传递/接收函数参数? 96 | * 如何传递/接收返回值, 以及从函数中返回? 97 | 98 | 这些问题的基础, 都和 RISC-V 的调用约定, 以及栈帧的构造 (这其实也是调用约定的一部分) 相关. 建议你先回顾一下 [Lv4.2 中的相关内容](/lv4-const-n-var/var-n-assign?id=目标代码生成). 99 | 100 | ### 函数的调用和返回 101 | 102 | RISC-V 中, `call` 和 `ret` 伪指令可以实现函数的调用和返回——确切的说, 其中的 `jalr` 指令实现了函数调用和返回中的关键操作: 跳转和保存返回地址. 当然, 从这两条指令的含义中我们得知: 103 | 104 | 1. 在汇编层面, “函数调用和返回” 并不包括参数和返回值的传递. 105 | 2. 函数的返回地址保存在寄存器 `ra` 中. 106 | 107 | 第二点告诉我们, 一旦一个函数中还会调用其他函数, 这个函数 (或另外的函数) 就必须保存/恢复自己的 `ra` 寄存器. 比如本节示例中的 `main` 函数调用了 `half` 函数和 `f` 函数, `main` 必须保存自己的 `ra`. 否则在调用其他函数时, `call` 指令会修改 `ra` 的值, 而 `main` 在执行 `ret` 时, `ra` 的值就不再是进入 `main` 时的值了, `main` 就无法返回到正确的位置, 只能在自己的函数体里无限循环. 108 | 109 | 把函数之间的调用关系想象成一个图 (即调用图, [call graph](https://en.wikipedia.org/wiki/Call_graph)), 那么一个永远不会调用其他函数的函数就位于图中的叶子结点, 我们把这种函数称为叶子函数 ([leaf function](https://en.wikipedia.org/wiki/Leaf_subroutine)). 与之相对的, 还有非叶子函数. 110 | 111 | 在 RISC-V 中, 非叶子函数通常需要在 prologue 中将自己的 `ra` 寄存器保存到栈帧中. 在 epilogue 中, 非叶子函数需要先从栈帧中恢复 `ra` 寄存器, 之后才能执行 `ret` 指令. 叶子函数可以不必进行上述操作——当然进行了也不会出问题, 只是会做一些无用功导致性能变差. 112 | 113 | ### 传递/接收参数 114 | 115 | RISC-V 有 8 个寄存器: `a0`-`a7`, 它们专门用来在函数调用时传递函数的非浮点参数, 前提是函数参数可以被塞进这些寄存器 (在本课程中不会出现塞不进去的情况, 指所有函数参数数据的位宽均小于等于 32 位). 函数的前 8 个参数必须按照从前到后的顺序依次放入 `a0` 到 `a7` 寄存器. 116 | 117 | 那你可能会问: 118 | 119 | > “如果函数参数超过 8 个怎么办?” 120 | 121 | 注意: 你在课程中实现的编译器必须考虑函数参数超过 8 个的情况, 同时我们提供的测试用例中也会涉及针对该情况的测试. 122 | 123 | 对于这种情况, RISC-V 的调用约定规定, 超出部分的参数必须放在 “调用者” 函数 (caller, 也就是调用超 8 个参数函数的那个函数) 的栈帧中. 具体来说, 第 9 个参数放在 `sp + 0` 的位置, 后续参数按照从前到后的顺序依次放在 `sp + 4` 及之后——当然, 这是建立在你传的参数的长度都是 32 位的基础上来讨论的. 具体如图所示: 124 | 125 | ![调用十个参数的函数](call-with-10-args.png) 126 | 127 | 所以, 函数在建立自己栈帧的时候, 必须给函数内所有超 8 个参数的函数调用留出足够大的空间, 来传递函数参数. 128 | 129 | > “为什么这么麻烦啊?” 130 | 131 | 没办法, 写编译器就是一个入乡随俗的过程. 编译器必须得依着目标指令系统的性子, 生成格式正确的指令, 同时按照约定的规矩处理栈帧啊, 传参啊, 这啊那啊的一大堆破事. 所以, 下次编译器给你写的代码报错的时候, 记得好好哄哄编译器, 人家也不容易. 报警告的时候也得哄, 因为[警告和错误一样重要](/preface/facing-problems?id=面对问题完成实践的态度)! 132 | 133 | > “写编译器的程序员也不容易啊, 那你能好好哄哄我吗?” 134 | 135 | ——哄……哄也不是不可以啦…… 136 | 137 | 写文档的助教也不容易, 你能哄……哎, 人呢? 怎么跑了? 138 | 139 | ### 关于寄存器 140 | 141 | 目前对于大部分的 Koopa IR 变量, 包括 `alloc` 分配的内存, 以及其他指令的返回值, 你的编译器会把它们全部保存在栈帧里, 而不是寄存器上. 即便用到寄存器, 也只是诸如 `t0`-`t6` 的临时寄存器. 你有没有想过这些寄存器为什么叫 “临时寄存器”? 临时寄存器的含义又是什么? 142 | 143 | RISC-V 的 [ABI](https://en.wikipedia.org/wiki/Application_binary_interface) 中, 32 个整数寄存器的名称和含义如下表所示: 144 | 145 | | 寄存器 | ABI 名称 | 描述 | 保存者 | 146 | | - | - | - | - | 147 | | `x0` | `zero` | 恒为 0 | N/A | 148 | | `x1` | `ra` | 返回地址 | 调用者 | 149 | | `x2` | `sp` | 栈指针 | 被调用者 | 150 | | `x3` | `gp` | 全局指针 | N/A | 151 | | `x4` | `tp` | 线程指针 | N/A | 152 | | `x5` | `t0` | 临时/备用链接寄存器 | 调用者 | 153 | | `x6-7` | `t1-2` | 临时寄存器 | 调用者 | 154 | | `x8` | `s0`/`fp` | 保存寄存器/帧指针 | 被调用者 | 155 | | `x9` | `s1` | 保存寄存器 | 被调用者 | 156 | | `x10-11` | `a0-1` | 函数参数/返回值 | 调用者 | 157 | | `x12-17` | `a2-7` | 函数参数 | 调用者 | 158 | | `x18-27` | `s2-11` | 保存寄存器 | 被调用者 | 159 | | `x28-31` | `t3-6` | 临时寄存器 | 调用者 | 160 | 161 | ABI 中明确了 32 个整数寄存器的作用. 这些寄存器中, `x0` 硬件上无法被修改, `x2`-`x4` 软件上一旦被修改程序就会出问题, 剩下所有的寄存器可以用来随便存数据. 其中, `t0` 到 `t6` 这七个寄存器为临时寄存器, 这些寄存器是由 “调用者” 保存的, 即 “caller-saved”, 所以它们很适合用来存储临时数据. 162 | 163 | 在 ABI (或者说调用约定) 中, 保存寄存器的操作可以由两方来进行: 一方是负责调用函数的那个函数, 另一方是被调用的那个函数. 前者负责保存的寄存器叫 “调用者保存的寄存器” (caller-saved registers), 后者负责的叫 “被调用者保存的寄存器” (callee-saved register). 164 | 165 | 你可能听得一头雾水, 我换种说法: “调用者保存” 的寄存器, 在 “被调用” 的函数中可以随便乱写. 因为 ABI 规定这些寄存器已经被 “调用这个函数的函数” 保存在它自己的栈帧里了, “被调用” 的函数不需要操心怎么保存他们. 反之, 如果一个函数需要使用某些 “被调用者保存” 的寄存器, 在这个函数进入的时候 (即 prologue 中), 这个函数就必须把它要使用的这些寄存器保存一遍. 而对于这个函数的调用者, 在它看来, 调用函数前后, 所有 “被调用者保存” 的寄存器的内容都是不会发生变化的. 166 | 167 | 比如, 对于如下这个程序: 168 | 169 | ``` 170 | li t0, 42 171 | call func 172 | addi t0, t0, 1 173 | ``` 174 | 175 | 我们不能保证, `addi` 指令执行完成后, `t0` 一定是 43. 因为根据 ABI, `func` 不负责保存 `t0` 寄存器的值, 它可以往里随便写数据. 于是, `call` 指令执行完成后, `t0` 的值就未必是 42 了. 176 | 177 | 以上这个故事告诉我们, 你在实现编译器时, 必须按照 ABI 的要求使用 RISC-V 中的寄存器. 比如, 如果你的编译器会在生成某个函数的时候, 生成用到 `s0` 的指令, 编译器就必须同时在生成函数的 prologue/epilogue 时, 生成保存/恢复这个寄存器的指令. `t0`, `a0` 之类的寄存器可以随便用, 但你的编译器必须保证, 在需要读寄存器前不能进行函数调用, 否则寄存器的值有一定概率被破坏. 178 | 179 | ### 生成代码 180 | 181 | 由于本节引入了函数的定义和调用, 在生成代码时, 你需要考虑一些 ABI 相关的内容. 具体步骤如下: 182 | 183 | 1. 扫描 Koopa IR 程序中的每一个函数, 对所有的函数, 都像生成 `main` 一样生成代码. 184 | 2. 生成 prologue 之前, 扫描函数内的每条指令, 统计需要为局部变量分配的栈空间 $S$, 需要为 `ra` 分配的栈空间 $R$, 以及需要为传参预留的栈空间 $A$, 上述三个量的单位均为字节. 185 | * $R$ 的计算方法: 如果函数中出现了 `call`, 则为 4, 否则为 0. 186 | * $A$ 的计算方法: 设函数内有 $n$ 条 `call` 指令, $call_i$ 用到的参数个数为 $len_i$, 则 $A = \max \{ \max_{i=0}^{n-1} len_i - 8, 0 \} \times 4$ 187 | 3. 计算 $S + R + A$, 向上取整到 16, 得到 $S^\prime$. 188 | 4. 生成 prologue: 首先, 根据 $S^\prime$ 生成 `addi` 指令来更新 `sp`. 然后, 如果 $R$ 不为 0, 在 $sp + S^\prime - 4$ 对应的栈内存中保存 `ra` 寄存器的值. 189 | 5. 生成函数体中的指令. 190 | 6. 如果遇到 Koopa IR 中的 `call` 指令, 你需要先将其所有参数变量的值读出, 存放到参数寄存器或栈帧中, 然后再生成 RISC-V 的 `call`. 191 | 7. 生成 epilogue: 如必要, 从栈帧中恢复 `ra` 寄存器. 然后, 复原 `sp` 寄存器的值. 最后生成 `ret`. 192 | 193 | 示例程序生成的 RISC-V 汇编为: 194 | 195 | ``` 196 | .text 197 | .globl half 198 | half: 199 | addi sp, sp, -16 200 | sw a0, 0(sp) 201 | lw t0, 0(sp) 202 | sw t0, 4(sp) 203 | lw t0, 4(sp) 204 | li t1, 2 205 | div t0, t0, t1 206 | sw t0, 8(sp) 207 | lw a0, 8(sp) 208 | addi sp, sp, 16 209 | ret 210 | 211 | .text 212 | .globl f 213 | f: 214 | ret 215 | 216 | .text 217 | .globl main 218 | main: 219 | addi sp, sp, -16 220 | sw ra, 12(sp) 221 | call f 222 | li a0, 10 223 | call half 224 | sw a0, 0(sp) 225 | lw a0, 0(sp) 226 | lw ra, 12(sp) 227 | addi sp, sp, 16 228 | ret 229 | ``` 230 | -------------------------------------------------------------------------------- /docs/lv2-code-gen/code-gen.md: -------------------------------------------------------------------------------- 1 | # Lv2.2. 目标代码生成 2 | 3 | 之前, 你的编译器已经可以把: 4 | 5 | ```c 6 | int main() { 7 | // 阿卡林 8 | return 0; 9 | } 10 | ``` 11 | 12 | 编译成如下的 Koopa IR 程序: 13 | 14 | ```koopa 15 | fun @main(): i32 { 16 | %entry: 17 | ret 0 18 | } 19 | ``` 20 | 21 | 我们的目标是, 进一步把它编译为: 22 | 23 | ``` 24 | .text 25 | .globl main 26 | main: 27 | li a0, 0 28 | ret 29 | ``` 30 | 31 | ## 遍历内存形式的 IR 32 | 33 | ### C/C++ 实现 34 | 35 | C/C++ 中, 得到 raw program 过后, 你可以遍历它的函数列表: 36 | 37 | ```c 38 | koopa_raw_program_t raw = ...; 39 | // 使用 for 循环遍历函数列表 40 | for (size_t i = 0; i < raw.funcs.len; ++i) { 41 | // 正常情况下, 列表中的元素就是函数, 我们只不过是在确认这个事实 42 | // 当然, 你也可以基于 raw slice 的 kind, 实现一个通用的处理函数 43 | assert(raw.funcs.kind == KOOPA_RSIK_FUNCTION); 44 | // 获取当前函数 45 | koopa_raw_function_t func = (koopa_raw_function_t) raw.funcs.buffer[i]; 46 | // 进一步处理当前函数 47 | // ... 48 | } 49 | ``` 50 | 51 | 对于示例程序, `raw.funcs.len` 一定是 `1`, 因为程序里显然只有一个函数. 进一步查看 `koopa.h` 中 `koopa_raw_function_t` 的定义, 我们据此可以遍历函数中所有的基本块: 52 | 53 | ```c 54 | for (size_t j = 0; j < func->bbs.len; ++j) { 55 | assert(func->bbs.kind == KOOPA_RSIK_BASIC_BLOCK); 56 | koopa_raw_basic_block_t bb = (koopa_raw_basic_block_t) func->bbs.buffer[j]; 57 | // 进一步处理当前基本块 58 | // ... 59 | } 60 | ``` 61 | 62 | 同样, 对于示例程序, `func->bbs.len` 也一定是 `1`, 因为 `@main` 函数内只有一个名为 `%entry` 的基本块. 遍历指令的方法与之类似, 此处不再赘述. 63 | 64 | 最后你应该可以得到一个 `koopa_raw_value_t`, 我们需要对其进行进一步处理: 65 | 66 | ```c 67 | koopa_raw_value_t value = ...; 68 | // 示例程序中, 你得到的 value 一定是一条 return 指令 69 | assert(value->kind.tag == KOOPA_RVT_RETURN); 70 | // 于是我们可以按照处理 return 指令的方式处理这个 value 71 | // return 指令中, value 代表返回值 72 | koopa_raw_value_t ret_value = value->kind.data.ret.value; 73 | // 示例程序中, ret_value 一定是一个 integer 74 | assert(ret_value->kind.tag == KOOPA_RVT_INTEGER); 75 | // 于是我们可以按照处理 integer 的方式处理 ret_value 76 | // integer 中, value 代表整数的数值 77 | int32_t int_val = ret_value->kind.data.integer.value; 78 | // 示例程序中, 这个数值一定是 0 79 | assert(int_val == 0); 80 | ``` 81 | 82 | 上述代码展示了在 C/C++ 中如何读取 raw program 中函数, 基本块和 return/integer 指令的数据, 相信你从中不难举一反三, 推导出访问其他指令的方法. 当然, 要想实现 “访问 Koopa IR” 的目标, 我们最好还是定义一系列对应的访问函数, 用 DFS 的思路来访问 raw program. 以 C++ 为例: 83 | 84 | ```cpp 85 | // 函数声明略 86 | // ... 87 | 88 | // 访问 raw program 89 | void Visit(const koopa_raw_program_t &program) { 90 | // 执行一些其他的必要操作 91 | // ... 92 | // 访问所有全局变量 93 | Visit(program.values); 94 | // 访问所有函数 95 | Visit(program.funcs); 96 | } 97 | 98 | // 访问 raw slice 99 | void Visit(const koopa_raw_slice_t &slice) { 100 | for (size_t i = 0; i < slice.len; ++i) { 101 | auto ptr = slice.buffer[i]; 102 | // 根据 slice 的 kind 决定将 ptr 视作何种元素 103 | switch (slice.kind) { 104 | case KOOPA_RSIK_FUNCTION: 105 | // 访问函数 106 | Visit(reinterpret_cast(ptr)); 107 | break; 108 | case KOOPA_RSIK_BASIC_BLOCK: 109 | // 访问基本块 110 | Visit(reinterpret_cast(ptr)); 111 | break; 112 | case KOOPA_RSIK_VALUE: 113 | // 访问指令 114 | Visit(reinterpret_cast(ptr)); 115 | break; 116 | default: 117 | // 我们暂时不会遇到其他内容, 于是不对其做任何处理 118 | assert(false); 119 | } 120 | } 121 | } 122 | 123 | // 访问函数 124 | void Visit(const koopa_raw_function_t &func) { 125 | // 执行一些其他的必要操作 126 | // ... 127 | // 访问所有基本块 128 | Visit(func->bbs); 129 | } 130 | 131 | // 访问基本块 132 | void Visit(const koopa_raw_basic_block_t &bb) { 133 | // 执行一些其他的必要操作 134 | // ... 135 | // 访问所有指令 136 | Visit(bb->insts); 137 | } 138 | 139 | // 访问指令 140 | void Visit(const koopa_raw_value_t &value) { 141 | // 根据指令类型判断后续需要如何访问 142 | const auto &kind = value->kind; 143 | switch (kind.tag) { 144 | case KOOPA_RVT_RETURN: 145 | // 访问 return 指令 146 | Visit(kind.data.ret); 147 | break; 148 | case KOOPA_RVT_INTEGER: 149 | // 访问 integer 指令 150 | Visit(kind.data.integer); 151 | break; 152 | default: 153 | // 其他类型暂时遇不到 154 | assert(false); 155 | } 156 | } 157 | 158 | // 访问对应类型指令的函数定义略 159 | // 视需求自行实现 160 | // ... 161 | ``` 162 | 163 | 相信从上述代码中, 你已经基本掌握了在 C/C++ 中遍历 raw program 的方法. 164 | 165 | ### Rust 实现 166 | 167 | Rust 中对内存形式 Koopa IR 的处理方式和 C/C++ 大同小异: 都是遍历列表, 然后根据类型处理其中的元素. 比如得到 `Program` 后, 你同样可以遍历其中的函数列表: 168 | 169 | ```rust 170 | let program = ...; 171 | for &func in program.func_layout() { 172 | // 进一步访问函数 173 | // ... 174 | } 175 | ``` 176 | 177 | 但需要注意的是, 在 Koopa IR 的内存形式中, “IR 的数据” 和 “IR 的 layout” 是彼此分离表示的. 178 | 179 | ?> “Layout” 直译的话是 “布局”. 这个词不太好用中文解释, 虽然 Koopa IR 的相关代码确实是我写的, 我也確實是個平時講中文的中國大陸北方網友. 180 |

181 | 比如对于基本块的指令列表: 指令的数据并没有直接按照指令出现的顺序存储在列表中. 指令的数据被统一存放在函数内的一个叫做 `DataFlowGraph` 的结构中, 同时每个指令具有一个指令 ID (或者也可以叫 handle), 你可以通过 ID 在这个结构中获取对应的指令. 指令的列表中存放的其实是指令的 ID. 182 |

183 | 这么做看起来多套了一层, 但实际上 “指令 ID” 和 “指令数据” 的对应关系, 就像 C/C++ 中 “指针” 和 “指针所指向的内存” 的对应关系, 理解起来并不复杂. 至于为什么不直接把数据放在列表里? 为什么不用指针或者引用来代替 “指令 ID”? 如果对 Rust 有一定的了解, 你应该会知道这么做的后果... 184 | 185 | 所以, 此处你可以通过遍历 `func_layout`, 来按照程序中函数出现的顺序来获取函数 ID, 然后据此从程序中拿到函数的数据, 进行后续访问: 186 | 187 | ```rust 188 | for &func in program.func_layout() { 189 | let func_data = program.func(func); 190 | // 访问函数 191 | // ... 192 | } 193 | ``` 194 | 195 | 访问基本块和指令也与之类似, 但需要注意: 基本块的数据里没有指令列表, 只有基本块的名称之类的信息. 基本块的指令列表在函数的 layout 里. 196 | 197 | ```rust 198 | // 遍历基本块列表 199 | for (&bb, node) in func_data.layout().bbs() { 200 | // 一些必要的处理 201 | // ... 202 | // 遍历指令列表 203 | for &inst in node.insts().keys() { 204 | let value_data = func_data.dfg().value(inst); 205 | // 访问指令 206 | // ... 207 | } 208 | } 209 | ``` 210 | 211 | 指令的数据里记录了指令的具体种类, 你可以通过模式匹配来处理你感兴趣的指令: 212 | 213 | ```rust 214 | use koopa::ir::ValueKind; 215 | match value_data.kind() { 216 | ValueKind::Integer(int) => { 217 | // 处理 integer 指令 218 | // ... 219 | } 220 | ValueKind::Return(ret) => { 221 | // 处理 ret 指令 222 | // ... 223 | } 224 | // 其他种类暂时遇不到 225 | _ => unreachable!(), 226 | } 227 | ``` 228 | 229 | 如需遍历访问 Koopa IR, 你同样需要将程序实现成 DFS 的模式. 此处推荐通过为内存形式 IR 扩展 trait 来实现这一功能: 230 | 231 | ```rust 232 | // 根据内存形式 Koopa IR 生成汇编 233 | trait GenerateAsm { 234 | fn generate(&self, /* 其他必要的参数 */); 235 | } 236 | 237 | impl GenerateAsm for koopa::ir::Program { 238 | fn generate(&self) { 239 | for &func in self.func_layout() { 240 | self.func(func).generate(); 241 | } 242 | } 243 | } 244 | 245 | impl GenerateAsm for koopa::ir::FunctionData { 246 | fn generate(&self) { 247 | // ... 248 | } 249 | } 250 | ``` 251 | 252 | ## 生成汇编 253 | 254 | 生成汇编的思路和生成 Koopa IR 的思路类似, 都是遍历数据结构, 输出字符串. 此处不做过多赘述. 不过我们依然需要解释一下, 你生成的 RISC-V 汇编到底做了哪些事情. 255 | 256 | 在 SysY 程序中, 我们定义了一个 `main` 函数, 这个函数什么也没做, 只是返回了一个整数, 之后就退出了. RISC-V 程序所做的事情与之一致: 257 | 258 | 1. 定义了 `main` 函数. 259 | 2. 将作为返回值的整数加载到了存放返回值的寄存器中. 260 | 3. 执行返回指令. 261 | 262 | 所以你需要知道几件事: 263 | 264 | * **如何定义函数?** 265 | * 所谓函数, 从处理器的角度看只不过是一段指令序列. 调用函数时处理器跳转到序列的入口执行, 执行到序列中含义是 “函数返回” 的指令时, 处理器退出函数, 回到调用函数前的指令序列继续执行. 266 | * 在汇编层面 “定义” 函数, 其实只需要标注这个序列的入口在什么位置即可, 其余函数返回之类的操作都属于函数内的指令要完成的事情. 267 | * **RISC-V 中如何设置返回值?** 268 | * RISC-V 指令系统的 ABI 规定, 返回值应当被存入 `a0` 和 `a1` 寄存器中. RV32I 下, 寄存器宽度为 32 位, 所以用寄存器可以传递两个 32 位的返回值. 269 | * 在编译实践涉及的所有情况下, 函数的返回值只有 32 位. 所以我们在传递返回值时, 只需要把数据放入 `a0` 寄存器即可. 270 | * **如何将整数加载到寄存器中?** 271 | * RISC-V 的汇编器支持 `li` 伪指令. 这条伪指令的作用是加载立即数 (**l**oad **i**mmediate) 到指定的寄存器中. 272 | 273 | 所以你输出的汇编的含义其实是: 274 | 275 | ``` 276 | .text # 声明之后的数据需要被放入代码段中 277 | .globl main # 声明全局符号 main, 以便链接器处理 278 | main: # 标记 main 的入口点 279 | li a0, 0 # 将整数 0 加载到存放返回值的 a0 寄存器中 280 | ret # 返回 281 | ``` 282 | 283 | 关于 RISC-V 指令的官方定义, 请参考 [RISC-V 的规范](https://github.com/riscv/riscv-isa-manual/releases/download/Ratified-IMAFDQC/riscv-spec-20191213.pdf). 当然, 我们整理了编译实践中需要用到的 RISC-V 指令的相关定义, 你可以参考 [RISC-V 指令速查](/misc-app-ref/riscv-insts). 284 | 285 | 最后的最后, 有时你可能实在不清楚, 对于某段特定的 C/SysY 程序, 编译器到底应该输出什么样的 RISC-V 汇编. 此时你可以去 [Compiler Explorer](https://godbolt.org/) 这个网站, 该网站可以很方便地查看某种编译器编译某段 C 程序后究竟会输出何种汇编. 286 | 287 | 你可以在网站右侧的汇编输出窗口选择使用 “RISC-V rv32gc clang (trunk)” 编译器, 然后将编译选项设置为 `-O3 -g0`, 并查看窗口内的汇编输出. 288 | -------------------------------------------------------------------------------- /docs/preface/prerequisites.md: -------------------------------------------------------------------------------- 1 | # 前置知识 2 | 3 | 本节介绍了进行编译原理课程实践所需的部分前置知识. 如果你对此并不了解, 建议你先花一些时间进行学习. 4 | 5 | ## 如何使用 Linux 6 | 7 | 一些你需要知道的基本内容: 8 | 9 | * **运行当前目录下程序:** `./程序名`. 比如你在当前目录下编译得到了你的编译器, 文件名为 `compiler`, 你可以执行 `./compiler`. 10 | * **查看上一条命令的返回值:** `echo $?`. 比如你可以在 Shell 中执行 `命令; echo $?` 来运行一个命令, 同时输出它的返回值. 11 | * **重定向输出:** 你希望把某命令输出到标准输出 (`stdout`) 的内容重定向到文件里, 可以执行 `命令 > 输出文件`. 例如: `ls > output.txt`. 12 | * **重定向输入:** 你希望把某个文件的内容作为某个命令的输入内容 (`stdin`), 可以执行 `命令 < 输入文件`. 例如: `wc -l < input.txt`. 13 | * **管道:** 你希望运行多个命令, 将前一个命令的标准输出作为后一个命令的标准输入, 可以执行 `命令1 | 命令2 | ... | 命令n`. 例如: `cat hello.txt | grep "hello" | wc -l`. 14 | 15 | 一些常用命令: 16 | 17 | * `cd 目录`: 切换到目录. 18 | * `pwd`: 输出当前目录. 19 | * `ls`: 输出当前目录下所有的文件. 20 | * `mkdir 目录`: 新建目录. 21 | * `cp 源文件 目标`: 把源文件复制到目标. 22 | * `mv 源文件 目标`: 把源文件移动到目标. 23 | * `rm 文件`: 删除文件. 24 | * `man 命令`: 显示命令的使用方法. 25 | 26 | 推荐 [Linux 入门教程](https://nju-projectn.github.io/ics-pa-gitbook/ics2021/linux.html). 27 | 28 | ## 如何使用 Git 29 | 30 | Git 是一个版本控制系统 (version control system, VCS). 什么是版本控制? 为什么需要做版本控制? 31 | 32 | 想象你正在开发你的编译器, 你决定给你的编译器实现一个新的功能, 但添加这个功能需要修改大量之前的代码. 你一咬牙一狠心, 熬了个大夜, 终于把这个功能加完了, 编译时遇到的问题也都修好了. 你信心满满地打开 OJ 交了一发: 33 | 34 | *“tmd, 一个测试用例都没过.”* 35 | 36 | 你坐在宿舍的椅子上, 窗户灌进阵阵冷风, 看着 WA 声一片的提交界面, 你的心早就凉了. 37 | 38 | “要是能回到加这个功能之前就好了.” 你想着. 但你没有粉色大猫猫, 也没有 SERN 的 LHC (和助手), 更没有时之盾牌, 那些过去的时间再也回不去了. 39 | 40 | 不过, 不幸中的万幸, 你用 Git 管理了你的代码. 41 | 42 | 你熟练地在命令行里敲下 `git reset xxx`, 瞬间, 一切回到了那天之前. 43 | 44 | 一些你需要知道的基本内容: 45 | 46 | * **初始化 Git 仓库:** 在仓库目录中 `git init`. 47 | * **忽略部分文件的更改:** 在对应目录中放置 `.gitignore` 文件, 并在该文件中添加需要忽略的文件的规则. 48 | * **查看仓库状态:** `git status`. 49 | * **暂存更改:** `git add 文件名`, 或 `git add -A` 暂存全部更改. 50 | * **提交更改:** `git commit`, 此时会弹出默认编辑器并要求你输入提交信息. 也可以直接执行 `git commit -m "提交信息"`. 51 | * **添加远程仓库:** `git remote add 名称 仓库URL`. 52 | * **推送本地提交到远程:** `git push`. 53 | * **查看所有提交记录:** `git log`, 你可以从中看到某个提交的哈希值. 54 | * **把仓库复位到某个提交的状态:** `git reset 提交的哈希值`. 55 | * **从当前提交新建分支并切换:** `git checkout -b 分支名`. 56 | * **切换到分支:** `git checkout 分支名`. 57 | * **删除分支:** `git branch -D 分支名`. 58 | 59 | 上面提到了 `.gitignore` 可以让 Git 忽略目录中某些文件, 且不让它们出现在 Git 仓库中. 这有什么用呢? 60 | 61 | 你在开发过程中难免会产生一些 **“只对你自己有用”** 且 **“不值得永久保留”** 的东西. 比如你在开发的过程中希望写几个简单的输入来测试你的程序, 或者验证你程序里的某处是否写对了, 于是你新建了个名字叫 `test.txt` 的文件, 里面写了一些测试的内容, 然后你在本地调试的时候会让你的程序读取这个文件. 62 | 63 | `test.txt` 显然只是个用来存放写一些只对你自己有用的临时内容的文件, 你不希望让 Git 每次都记录这个文件的更改 (因为没意义), 所以你可以把它写进 `.gitignore` 中, 来让 Git 忽略它. 64 | 65 | 其他类似的情况还包括, 你使用 VS Code 或 IDEA 开发你的编译器, 这些代码编辑器/IDE 可能会在项目中生成一些配置文件 (`.vscode` 或 `.idea`), 这些文件通常也是不需要被 Git 记录的, 因为其中包含了你的一些个人配置. 66 | 67 | 此外, 你还可能会使用代码生成器, 生成器读取一个描述生成规则的输入文件, 据此生成对应的代码. 比如在编译实践中, 你很可能会使用 Flex/Bison, 它们会根据 `.l`/`.y` 文件的内容, 生成 lexer 和 parser 的 C/C++ 文件和头文件. 68 | 69 | 对于这种情况, 你应该**只在 Git 中保留喂给生成器的输入文件**, 而不应该保留生成器生成的代码. 因为只要你安装了代码生成器, 你总能根据这些输入文件重新生成代码, 在 Git 中记录生成的代码没什么意义, 有时还会出现问题. 比如你修改了生成器规则, 但你没更新生成后的代码, 导致提交评测后评测机拉到了错误的代码, 于是你的编译器过不了评测, 然后你看着规则描述百思不得其解, 一天就这么过去了. 70 | 71 | 推荐: 72 | 73 | * [Learn Git Branching](https://learngitbranching.js.org). 74 | * [Git 快速入门](https://nju-projectn.github.io/ics-pa-gitbook/ics2021/git.html). 75 | 76 | ## 如何使用 GDB/LLDB 77 | 78 | 调试程序有几种方法: 79 | 80 | * **硬看:** 基本没啥用. 81 | * **print 大法:** 对小问题有用, 但一旦问题复杂起来, 你将迷失在巨量的日志里, 大脑过载, 难以自拔. 82 | * **使用 IDE 提供的调试功能:** 比较有用, 但也比较复杂. 而且有些情况你没法用 IDE 的调试器解决, 比如你的编译器生成的 RISC-V 程序在 QEMU 里运行的时候出现了段错误, 你需要调试 QEMU 里的那个程序. 83 | * **使用 GDB/LLDB 等调试器:** 非常有用, 适用范围极广, 但相对较难上手. 84 | 85 | 一些你需要知道的基本内容: 86 | 87 | * **编译带调试信息的 C/C++ 程序:** `gcc/g++ -g -O0 ...`. 88 | * **用调试器载入程序:** `gdb/lldb 程序名`. 89 | * **用调试器载入程序并指定启动参数:** `gdb --args 程序名 参数 ...`, `lldb 程序名 -- 参数 ...`, 此时会进入调试器的命令行. 后续命令均需要在调试器的命令行中执行. 90 | * **添加断点:** `b 函数名`, `b 文件名:行号`. 91 | * **删除断点:** GDB: `d 断点编号`, LLDB: `br del 断点编号`. 92 | * **查看所有断点:** GDB: `info b`, LLDB: `br list`. 93 | * **执行程序:** `r`. 94 | * **单步执行, 跳过函数:** `n`. 95 | * **单步执行, 进入函数:** `s`. 96 | * **继续执行直到断点/出错:** `c`. 97 | * **查看调用栈:** `bt`. 98 | * **切换调用栈:** GDB: `frame 编号`, LLDB: `frame select 编号`. 99 | * **暂停执行:** `Ctrl + C`. 100 | * **退出:** `q` 或 `Ctrl + D`. 101 | 102 | 一个例子: 103 | 104 | 你写的编译器出现了段错误——这种问题使用 `print` 大法调试效率很低, 因为你很难知道你的编译器到底在何处出现了段错误, 进而无法得知应该在何处插入 `print`. 遇到这种情况, 不妨使用调试器载入程序并运行, 当程序出现段错误时, 调试器会停住并进入命令行供你操作. 此时你就可以使用 `bt` 查看调用栈, 定位出错的位置, 然后在合适的地方下断点, 并重新运行程序来进一步调试了. 105 | 106 | 推荐: 107 | 108 | * [GDB cheat sheet](https://darkdust.net/files/GDB%20Cheat%20Sheet.pdf). 109 | * [LLDB cheat sheet](https://www.nesono.com/sites/default/files/lldb%20cheat%20sheet.pdf). 110 | 111 | ## 如何编写简单的 Makefile 112 | 113 | 你可能很熟悉如何使用 IDE 来开发简单的 C/C++ 程序: 新建工程, 写代码, 按下运行按钮, BOOM, 程序就跑了起来 ~(当然也可能跑不起来)~. 但当使用命令行环境时, 你可能就完全不懂该如何编译/运行你写的 C/C++ 程序了. 114 | 115 | 当然, 聪明的你可能知道如何使用 `gcc`/`g++`: 116 | 117 | ``` 118 | gcc hello.c -o hello 119 | ``` 120 | 121 | 太好了, 我们可以直接通过调用编译器来编译我们的代码, 然后再直接运行生成的程序即可. 手敲命令行, 完全可以满足需求, 每次也不会很麻烦, 听起来还很酷. 122 | 123 | 然而在编译实践课中, 你写的程序通常不会由简单的一个 C/C++ 源文件构成. 甚至里面还可能包括非 C/C++ 的文件, 例如 `.l`/`.y` 文件. 这个时候, 你就无法用一条命令来编译你的编译器了: 124 | 125 | ``` 126 | flex -o lexer.c lexer.l 127 | bison -d -o parser.c parser.y 128 | gcc lexer.c parser.c ast.c ... -o compiler 129 | ``` 130 | 131 | 想象一下, 如果你每次都手敲上面的一大堆命令, 才能得到一个自己的编译器的话, 那烦都得烦死了. 当然, 更聪明的你会想到写一个 Shell 脚本: 132 | 133 | ```bash 134 | #!/bin/bash 135 | 136 | flex -o lexer.c lexer.l 137 | bison -d -o parser.c parser.y 138 | gcc lexer.c parser.c ast.c ... -o compiler 139 | ``` 140 | 141 | 这么做当然可以解决问题, 但也带来了别的问题: 你每次都必须重新编译所有的 C/C++ 源文件. 你可能觉得: 这也算问题? 如果你开发的的编译器代码量不是特别小, 你就会明显感觉到相比编译那些只有单个源文件的程序, 编译你的整个编译器的时间会更长. 而如果你写的程序的规模进一步增大 (比如你写了一个操作系统或者浏览器内核), 进行一次完整编译的时间也会大大增长. 142 | 143 | 有没有一种办法缩短编译的时间呢? 其实是有的. 大家都知道, C/C++ 源文件会经过编译, 链接的步骤才能变成可执行文件, 所以 `gcc` 在编译你的编译器的时候实际上执行了如下操作: 144 | 145 | ``` 146 | gcc lexer.c -c -o lexer.o 147 | gcc parser.c -c -o parser.o 148 | gcc ast.c -c -o ast.o 149 | ... 150 | gcc lexer.o parser.o ast.o ... -o compiler 151 | ``` 152 | 153 | 最后一步是链接, 此前所有的步骤都是编译. 我们不难发现, 在我们之前已经进行过一次编译/链接的情况下, 如果一个 `.c` 文件 (及其 `include` 的头文件) 的内容没有发生变化, 我们就没必要再把它编译成 `.o` 文件——反正之前已经编译过了, 再编译一次结果也不会变的. 如果我们不再重复编译没发生变化的源文件, 那编译的时间就可以大大减少了. 154 | 155 | 更进一步, 我们可以写一个脚本, 检查自己编译器的源文件是否发生过变化. 如果没有, 就不重新编译这个文件. 实际上, 这就是 `Makefile` 的作用. 我们可以把之前的那个 Shell 脚本写成 `Makefile` 的形式: 156 | 157 | !> 注意 Docsify 渲染代码块的时候会把里面的 tab 缩进[全部替换成空格](https://github.com/markedjs/marked/issues/1668), 但 `Makefile` 里只能使用 tab 缩进, 所以不要直接在网页上复制下面的代码. 158 | 159 | ```makefile 160 | compiler: lexer.o parser.o ast.o 161 | gcc lexer.o parser.o ast.o -o compiler 162 | 163 | lexer.o: lexer.c 164 | gcc lexer.c -c -o lexer.o 165 | 166 | parser.o: parser.c 167 | gcc parser.c -c -o parser.o 168 | 169 | ast.o: ast.c 170 | gcc ast.c -c -o ast.o 171 | 172 | lexer.c: lexer.l 173 | flex -o lexer.c lexer.l 174 | 175 | parser.c: parser.y 176 | bison -d -o parser.c parser.y 177 | ``` 178 | 179 | 在命令行中执行: 180 | 181 | ``` 182 | make compiler 183 | ``` 184 | 185 | 然后, `make` 就会根据 `Makefile` 的内容, 自动帮我们生成 `compiler` 这个文件. 186 | 187 | `Makefile` 的基本语法其实很简单. 你可以注意到上面的例子中出现了很多 `file1: file2 file3 ...` 的写法, 它的含义是: 要生成 `file1`, 必须确保 `file2`, `file3`, ... 是存在的, 如果他们不存在, 就去生成相应的文件. 在这行之后带有缩进的内容描述了应该如何生成冒号之前的那个文件. 188 | 189 | 所以, 例子中的 `Makefile` 描述了这样的事情: 要想生成 `compiler`, 就必须保证 `lexer.o`, `parser.o` 和 `ast.o` 是存在的. 如果这些文件存在, 就使用 `gcc lexer.o parser.o ast.o -o compiler` 这条命令来生成 `compiler` 这个文件; 如果这些文件不存在, 就尝试用文件里写明的其他规则生成这些文件. 之后的规则与之类似, 不再赘述. 190 | 191 | 当然, 我们其实可以直接在命令行执行: 192 | 193 | ``` 194 | make 195 | ``` 196 | 197 | 这和 `make compiler` 是等价的, 因为在不指定 `make` 什么文件的时候, `make` 会查找 `Makefile` 里出现的第一个规则, 然后去生成这个规则对应的文件. 198 | 199 | `make` 不仅会检查文件是否存在, 还会检查文件是否是最新的. 在 `compiler: lexer.o parser.o ast.o` 规则中, 如果 `lexer.o`, `parser.o` 和 `ast.o` 都存在, 但其中某个文件 “不是最新的”, 那 `Makefile` 也会重新生成这些文件, 再用更新后的文件生成 `compiler`. 200 | 201 | `make` 如何判断文件是最新的呢? 假设我们已经执行过一次 `make`, 然后修改了 `lexer.l`, 再执行一次 `make`. `make` 会做以下操作: 202 | 203 | * 检查 `compiler` 是否需要更新, 如果一个文件不存在, 或者它依赖的其他文件中, 有文件不存在或需要更新, 这个文件就需要更新. 204 | * `compiler` 存在, 依赖 `lexer.o`, `parser.o` 和 `ast.o`, 检查这三个文件是否需要更新. 205 | * `lexer.o` 存在, 依赖 `lexer.c`, 检查这个文件是否需要更新. 206 | * `lexer.c` 存在, 依赖 `lexer.l`, 检查这个文件是否需要更新. 207 | * `lexer.l` 存在, 被修改过. 这个文件没有对应的更新规则, 所以 `make` 不会去管它. 你可以理解为 `make` 认为这个文件已经被用户更新了. 208 | * `lexer.c` 需要更新, 执行 `flex -o lexer.c lexer.l` 来更新文件. 209 | * `lexer.o` 需要更新, 执行 `gcc lexer.c -c -o lexer.o` 来更新文件. 210 | * 检查 `parser.o` 和 `ast.o` 是否需要更新. 它们都不需要更新. 211 | * `compiler` 需要更新, 执行 `gcc lexer.o parser.o ast.o -o compiler` 来更新文件. 212 | * 所有需要更新的文件都被更新过了, `make` 的任务完成了, 退出. 213 | 214 | `make` 通过上述方式, 实现了 “只重新编译我们修改过的文件” 的功能, 节省了编译的时间. 215 | 216 | 当然, 你可能会觉得我们给出的这个示例 `Makefile` 写的太啰嗦了. 比如, 所有的 `.o` 文件其实都依赖于对应文件名的 `.c` 文件, 而且他们的构建方式也完全相同, 那我们为什么要把类似的规则重复写三遍呢? `make` 提供了一些语法来帮助我们简化 `Makefile` 的写法: 217 | 218 | ```makefile 219 | compiler: lexer.o parser.o ast.o 220 | gcc $^ -o $@ 221 | 222 | %.o: %.c 223 | gcc $^ -c -o $@ 224 | 225 | lexer.c: lexer.l 226 | flex -o $@ $^ 227 | 228 | parser.c: parser.y 229 | bison -d -o $@ $^ 230 | ``` 231 | 232 | 至于上面这些语法都是什么含义, 此处就不再赘述了, 你可以自行查看 `make` 手册中的相关部分 ([Pattern Rules](https://www.gnu.org/software/make/manual/make.html#Pattern-Rules) 和 [Automatic Variables](https://www.gnu.org/software/make/manual/make.html#Automatic-Variables)). 233 | 234 | 推荐: 235 | 236 | * [GNU make](https://www.gnu.org/software/make/manual/make.html). 237 | 238 | ## 为什么要设置这一节 239 | 240 | 据 MaxXing 观察, 有些同学确实完全不了解某些工具的基础使用方法. 甚至在往年的课程实践中出现过这些情况: 241 | 242 | * 一些同学不清楚在 Linux 的 Shell 中执行当前目录下的文件时, 需要在文件名前添加 `./`. 243 | * 一些同学不清楚为什么自己写的代码 (`xxx.c`) 无法 `#include `. 244 | * 一些同学不理解 `.gitignore` 文件的作用, 每次都向平台提交巨量的文件 (OJ 服务器心里苦). 245 | * 一些同学完全不懂如何调试自己的程序, 只能每次都随便改改自己的代码, 凭运气过测试点. 246 | 247 | 考虑到大学中可能并不会专门讲授这些常识性的内容 (但显然**这不合理**), 这些现象的出现也情有可原. 但在开始实验之前, 你必须对此有一个最基本的认识, 否则在你面前的, 将会是一条通往地狱的路. 248 | 249 | 我强烈推荐大家抽一些时间, 学习一下 MIT 的 [The Missing Semester of Your CS Education](https://missing.csail.mit.edu/), 这会对你的日常开发起到相当大的帮助. 250 | -------------------------------------------------------------------------------- /docs/lv4-const-n-var/var-n-assign.md: -------------------------------------------------------------------------------- 1 | # Lv4.2. 变量和赋值 2 | 3 | 本节新增/变更的语法规范如下: 4 | 5 | ```ebnf 6 | Decl ::= ConstDecl | VarDecl; 7 | ConstDecl ::= ...; 8 | BType ::= ...; 9 | ConstDef ::= ...; 10 | ConstInitVal ::= ...; 11 | VarDecl ::= BType VarDef {"," VarDef} ";"; 12 | VarDef ::= IDENT | IDENT "=" InitVal; 13 | InitVal ::= Exp; 14 | 15 | ... 16 | 17 | Block ::= ...; 18 | BlockItem ::= ...; 19 | Stmt ::= LVal "=" Exp ";" 20 | | "return" Exp ";"; 21 | ``` 22 | 23 | ## 一个例子 24 | 25 | ```c 26 | int main() { 27 | int x = 10; 28 | x = x + 1; 29 | return x; 30 | } 31 | ``` 32 | 33 | ## 词法/语法分析 34 | 35 | 同上一节, 你需要设计新的 AST, 同时修改 parser 的实现. 36 | 37 | ## 语义分析 38 | 39 | 与上一节类似, 你依然需要一个符号表来管理所有的变量定义. 与常量定义不同的是, 变量定义存储的是变量的符号, 及其对应的 `alloc`, 即变量的内存分配. 本节的 IR 生成部分将解释 `alloc` 在 Koopa IR 中的含义. 40 | 41 | 所以, 你需要修改你的符号表, 使其支持保存一个符号所对应的常量信息或者变量信息. 也就是说, 符号表里使用符号可以查询到一个数据结构, 这个数据结构既可以用来存储变量信息, 又可以用来存储常量信息. 在 C/C++ 中, 你可以使用一个 `struct`, [tagged union](https://en.wikipedia.org/wiki/Tagged_union) 或者 `std::variant` 来实现这一性质. 在 Rust 中, 使用 `enum` (本身就是个 tagged union) 来实现这一性质再合适不过了. 42 | 43 | 在遇到 `LVal` 时, 你需要从符号表中查询这个符号的信息, 然后用查到的结果作为常量求值/IR 生成的结果. 注意, 如下情况属于语义错误: 44 | 45 | * 在进行常量求值时, 从符号表里查询到了变量而不是常量. 46 | * 在处理赋值语句时, 赋值语句左侧的 `LVal` 对应一个常量, 而不是变量. 47 | * 其他情况, 如符号重复定义, 或者符号未定义. 48 | 49 | ## IR 生成 50 | 51 | 要想实现变量和赋值语句, 只使用我们之前介绍到的 Koopa IR 指令是做不到的. 比如你**不能**把本节的示例程序翻译成如下形式: 52 | 53 | ```koopa 54 | // 错误的 55 | fun @main(): i32 { 56 | %entry: 57 | %0 = 10 58 | %0 = add %0, 1 59 | ret %0 60 | } 61 | ``` 62 | 63 | 虽然它 “看起来” 很符合常识. 但你也许还记得, 在 [Lv3.1](/lv3-expr/unary-exprs?id=ir-生成) 中我们介绍过, Koopa IR 是 **“单赋值”** 的, 所有符号都只能在定义的时候被赋值一次. 而在上面的程序中, `%0` 被赋值了两次, 这是不合法的. 64 | 65 | 如果要表示变量的定义, 使用和赋值, 我们必须引入三种新的指令: `alloc`, `load` 和 `store`: 66 | 67 | ```koopa 68 | // 正确的 69 | fun @main(): i32 { 70 | %entry: 71 | // int x = 10; 72 | @x = alloc i32 73 | store 10, @x 74 | 75 | // x = x + 1; 76 | %0 = load @x 77 | %1 = add %0, 1 78 | store %1, @x 79 | 80 | // return x; 81 | %2 = load @x 82 | ret %2 83 | } 84 | ``` 85 | 86 | 三种新指令的含义如下: 87 | 88 | * `%x = alloc T`: 申请一块类型为 `T` 的内存. 在本节中, `T` 只能是 `i32`. 返回申请到的内存的指针, 也就是说, `%x` 的类型是 `T` 的指针, 记作 `*T`. 89 | * 这个操作和 C 语言中 `malloc` 函数的惯用方式十分相似: 90 | 91 | ```c 92 | // 申请一块可以存放 int 型数据的内存, 这块内存本身的类型是 int* 93 | int *x = (int *)malloc(sizeof(int)); 94 | ``` 95 | 96 | * `%x = load %y`: 从指针 `%y` 对应的内存中读取数据, 返回读取到的数据. 如果 `%y` 的类型是 `*T`, 则 `%x` 的类型是 `T`. 97 | * `store %x, %y`: 向指针 `%y` 对应的内存写入数据, 不返回任何内容. 如果 `%y` 的类型是 `*T`, 则 `%x` 的类型必须是 `T`. 98 | 99 | 当然, 以防你忘记 Koopa IR 的规则, 再次提醒: 示例中的 `alloc` 叫做 `@x`, 是因为这个 `alloc` 对应 SysY 中的变量 `x`. 实际上, 这个 `alloc` 叫什么名字都行, 比如 `@AvavaAvA`, `%x` 或者 `%0`, 叫这个名字只是为了调试方便. 100 | 101 | ## 目标代码生成 102 | 103 | 本节中出现了新的 Koopa IR 指令, 这些指令要求我们进行内存分配. 实际上, 此处提到的 “内存分配” 可能和你脑海里的 “内存分配” 不太一样——后者在其他编程语言中通常指分配堆内存 (heap memory), 而此处指的**通常**是分配栈内存 (stack memory). 104 | 105 | 为什么要说 “通常”? 我们在之前的章节提到过, RISC-V 指令系统中定义了一些寄存器, 可供我们存放一些运算的中间结果. 我们都知道 (应该吧?), 计算机的存储系统分很多层次, 每一层的访问速度都存在数量级上的差距, 处理器访问寄存器的速度要远快于访问各级缓存和内存的速度. 如果我们能找到一种方法, 把 SysY/Koopa IR 程序中的变量映射到寄存器上, 那程序的速度肯定会得到大幅度提升. 106 | 107 | 事实上, 这种办法是存在的, 我们把这种方法叫做[寄存器分配](https://en.wikipedia.org/wiki/Register_allocation). 此时, `alloc` 对应的可能就不再是一块栈内存了, 而是一个 (或多个) 寄存器. 但要实现一种真正高效的寄存器分配算法是极为困难的: 寄存器分配问题本身是一个 [NPC](https://en.wikipedia.org/wiki/NP-completeness) 问题, 编译器必须消耗大量的时间才能算出最优的分配策略. 考虑到执行效率, 业界的编译器在进行寄存器分配时, 通常会采取一些启发式算法, 但这些算法的实现依旧不那么简单. 108 | 109 | 另一方面, 指令系统中定义的寄存器的数量往往是有限的, 比如 RISC-V 中有 32 个 ISA 层面的整数寄存器, 但其中只有不多于 28 个寄存器可以用来存放变量. 如果输入程序里的某个函数相对复杂, 编译器就无法把其中所有的变量都映射到寄存器上. 此时, 这些变量就不得不被 “spill” 到栈内存中. 110 | 111 | 你当然可以选择实现一些复杂的寄存器分配算法, 详见 [Lv9+.2. 寄存器分配](/lv9p-reincarnation/reg-alloc)的相关内容. 但此时, 我建议你先实现一种最简单的寄存器分配方式: **把所有变量都放在栈上.** 什么? 这也算是寄存器分配吗? 寄存器都没用到啊喂! 正所谓大道至简——你可以认为, 寄存器分配算法要做的事情是: 决定哪些变量应该被放在寄存器中, 哪些变量应该被放在栈上. 我们的算法只不过是固执地选择了后者……而已, 你很难说它不是一种寄存器分配算法. 112 | 113 | 在此之前, 你应该了解, RISC-V 程序中的栈内存是如何分配的. 114 | 115 | ### 栈帧 116 | 117 | 程序在操作系统中运行时, 操作系统会为其分配堆内存和栈内存. 其中, 栈内存的使用方式是连续的. 程序 (进程) 执行前, 操作系统会为进程设置栈指针 (stack pointer)——通常是一个[寄存器](https://en.wikipedia.org/wiki/Stack_register). 栈指针会指向栈内存的起点. 118 | 119 | 进程内, 在执行函数调用时, 函数开头的指令会通过移动栈指针, 来在栈上开辟出一块仅供这个函数自己使用的内存区域, 这个区域就叫做栈帧 (stack frame). 函数在执行的过程中, 会通过 “栈指针 + 偏移量” 的手段访问栈内存, 所以在函数退出前, 会有相关指令负责复原栈指针指向的位置, 以防调用这个函数的函数访问不到正确的栈内存. 120 | 121 | 至于栈内存为什么叫栈内存, 想必是很容易理解的: 函数在调用和返回的过程中, 栈内存中栈帧的变化, 就符合栈 LIFO 的特性. 122 | 123 | 那函数通常会在栈帧中存放什么内容呢? 最容易想到的是函数的返回地址. 所谓 “函数调用” 和 “函数返回” 的操作, 其实可以分解成以下几个步骤: 124 | 125 | * **函数调用:** 把函数调用指令的后一条指令的地址存起来, 以便函数调用结束后返回. 然后跳转到函数的入口开始执行. 126 | * **函数返回:** 找到之前保存的返回地址, 然后跳转到这个地址, 执行函数调用之后的指令. 127 | 128 | 由于函数的调用链可能非常之长, 函数的返回地址通常会被放在内存中, 而不是寄存器中. 因为相比于内存, 寄存器能存放的返回地址个数实在是少得可怜, 更何况寄存器还要用来存储变量和其他数据. 所以, 类似 “返回地址” 这种数据就可以被放在栈帧里. 129 | 130 | 扯点别的: 说到函数调用和返回, 你肯定会好奇 RISC-V 是怎么做的. 在 RISC-V 中, 函数的调用和返回可以通过 `call` 和 `ret` 这两条伪指令来完成. 我们之前提到过, “伪指令” 并不是 ISA 中定义的指令, 这些指令通常是用其他指令实现出来的. 比如 `call func` 这条伪指令代表调用函数 `func`, 它的实际上会被汇编器替换成: 131 | 132 | ``` 133 | auipc ra, func地址的高位偏移量 134 | jalr ra, func地址的低位偏移量(ra) 135 | ``` 136 | 137 | `auipc` 指令负责加载 `func` 的一部分地址到 `ra` 寄存器中. `jalr` 指令负责执行跳转操作: 把 `func` 剩下的一部分地址和 `ra` 里刚刚加载的地址相加得到完整地址, 然后把返回地址 (`jalr` 后一条指令的地址) 存入 `ra`, 最后跳转到刚刚计算得出的地址, 来执行函数. 138 | 139 | 首先为什么地址会被拆成两半? 因为 RV32I 里的地址和指令的长度都是 32 位, 要想在指令里编码其他内容 (比如指令的操作), 就必然不可能把整个地址全部塞进指令中. 140 | 141 | 然后你会发现, `call` 指令实际上会把返回地址放到 `ra` 寄存器中, 而不是直接放到栈上. 实际上, `ra` 寄存器的全名正是 “return address register”. 这是因为 RISC-V 是一种 RISC 指令系统, RISC 中为了精简指令的实现, 通常只有加载/存储类的指令能够访存, 其他指令只会操作寄存器. 142 | 143 | `ret` 伪指令会被汇编器替换成: 144 | 145 | ``` 146 | jalr x0, 0(ra) 147 | ``` 148 | 149 | 这条指令会读出 `ra` 寄存器的值, 加一个偏移量 0 (相当于没加) 得到跳转的目标地址, 然后把后一条指令的地址放到寄存器 `x0` 里, 最后跳转到刚刚计算出来的地址处执行. 150 | 151 | 首先我们之前曾提到 `x0` 是一个特殊的寄存器, 它的值恒为 0, 任何试图向写入 `x0` 写入数据的操作都相当于什么都没干, 所以这里的 `jalr` 相当于没写任何寄存器. 其次, `ra` 中存放的是函数的返回地址, 所以这条指令实际上相当于执行了函数返回的操作. 152 | 153 | RISC-V 仅用一种指令就同时实现了函数调用和函数返回的操作 (`auipc` 只是加载了地址), 我们很难不因此被 RISC-V 的设计之精妙所折服. RISC-V 中还有很多其它这样的例子, 如你对此感兴趣, 可以查看 [RISC-V 规范](https://github.com/riscv/riscv-isa-manual/releases/download/Ratified-IMAFDQC/riscv-spec-20191213.pdf)第 139 页, 其中列举了很多伪指令和对应的实现方法. 154 | 155 | 但感慨之余, 你可能会意识到, RISC-V 的返回地址不直接保存在栈帧里, 而是被放在一个叫做 `ra` 的寄存器里——毕竟 RISC-V 有 32 个寄存器, 用掉一个也还有很多富余. 这么做其实有一个好处: 函数可以自由决定自己要不要把返回地址保存到栈帧里. 如果当前函数里没有再调用其他函数, 那 `ra` 的值就不会被覆盖, 我们就可以放心大胆地使用 `ret` 来进行函数返回, 同时节省一次内存写入的开销——事实上, 你的编译器到目前为止生成的 RISC-V 汇编都是这么处理的. 156 | 157 | 那么最后, 总结一下, 栈帧里通常会放这些东西: 158 | 159 | * **函数的返回地址:** 上文已经解释过. 160 | * **某些需要保存的寄存器:** 函数执行时可能会用到某些寄存器, 为了避免完成函数调用后, “调用者” 函数的寄存器被 “被调用者” 函数写乱了, 在执行函数调用时, 调用者/被调用者可能会把某些寄存器保存在栈帧里. 161 | * **被 spill 到栈上的局部变量:** 因为寄存器不够用了, 这些变量只能放在栈上. 162 | * **函数参数:** 在调用约定中, 函数的一部分参数会使用寄存器传递. 但因为寄存器数量是有限的, 函数的其余参数会被放到栈帧里. 163 | 164 | ### RISC-V 的栈帧 165 | 166 | 在不同的指令系统中, 栈帧的布局可能都是不同的. 指令系统的调用约定 ([calling convension](https://en.wikipedia.org/wiki/Calling_convention)) 负责规定程序的栈帧应该长什么样. 你的程序要想在 RISC-V 的机器上运行, 尤其是和其他标准 RISC-V 的程序交互, 就必须遵守 RISC-V 的调用约定. 167 | 168 | 关于栈帧的约定大致如下: 169 | 170 | * `sp` 寄存器用来保存栈指针, 它的值必须是 16 字节对齐的 ([RISC-V 规范](https://github.com/riscv/riscv-isa-manual/releases/download/Ratified-IMAFDQC/riscv-spec-20191213.pdf)第 107 页). 171 | * 函数中栈的生长方向是从高地址到低地址, 也就是说, 进入函数的时候, `sp` 的值应该减小. 172 | * `sp` 中保存的地址是当前栈帧最顶部元素的地址. 173 | * 栈帧的布局如下图所示. 其中, 栈帧分为三个区域, 这三个区域并不是必须存在的. 例如, 如果函数中没有局部变量, 那局部变量区域的大小就为 0. 174 | 175 | ![栈帧的布局](riscv-stack-frame.png) 176 | 177 | ### 生成代码 178 | 179 | Koopa IR 程序中需要保存到栈上的内容包括: 180 | 181 | * `alloc` 指令分配的内存. 目前你的编译器只会生成 `alloc i32`, 所以应为其分配的内存大小为 4 字节. 182 | * 除 `alloc` 外, 其他任何存在返回值的指令的返回值, 比如 `%0 = load @x`, `%1 = add %0, 1` 中的 `%0` 和 `%1`. 当然, 诸如 `store` 等不存在返回值的指令不需要处理. 目前你的编译器中只会使用到返回值类型为 `i32` 的指令, 所以应为其分配的内存大小为 4 字节. 183 | 184 | 生成代码的步骤如下: 185 | 186 | 1. 扫描函数中的所有指令, 算出需要分配的栈空间总量 $S$ (单位为字节). 187 | 2. 计算 $S$ 对齐到 16 后的数值, 记作 $S^\prime$. 188 | 3. 在函数入口处, 生成更新栈指针的指令, 将栈指针减去 $S^\prime$. 这个过程叫做函数的 [prologue](https://en.wikipedia.org/wiki/Function_prologue_and_epilogue#Prologue). 189 | * 你可以用 `addi sp, sp, 立即数` 指令来实现这个操作, 这条指令会为 `sp` 加上立即数. 190 | * 需要注意的是, `addi` 指令中立即数的范围是 $[-2048, 2047]$, 即 12 位有符号整数的范围. 立即数一旦超过这个范围, 你就只能用 `li` 加载立即数到一个临时寄存器 (比如 `t0`), 然后用 `add` 指令来更新 `sp` 了. 191 | 4. 使用 RISC-V 中的 `lw` 和 `sw` 指令来实现 `load` 和 `store`. 192 | * `lw 寄存器1, 偏移量(寄存器2)` 的含义是将 `寄存器2` 的值和 `偏移量` 相加作为内存地址, 然后从内存中读取一个 32 位的数据放到 `寄存器1` 中. 193 | * `sw 寄存器1, 偏移量(寄存器2)` 的含义是将 `寄存器2` 的值和 `偏移量` 相加作为内存地址, 将 `寄存器1` 中的值存入到内存地址对应的 32 位内存空间中. 194 | * `lw`/`sw` 中偏移量的范围和 `addi` 一致. 195 | 5. 对于在指令中用到的其他指令的返回值, 比如 `add %1, %2` 中的 `%1` 和 `%2`, 用 `lw` 指令从栈帧中读数据到临时寄存器中, 然后再计算结果. 196 | 6. 对于所有存在返回值的指令, 比如 `load` 和 `add`, 计算出指令的返回值后, 用 `sw` 指令把返回值存入栈帧. 197 | 7. 函数返回前, 即 `ret` 指令之前, 你需要生成复原栈指针的指令, 将栈指针加上 $S^\prime$. 这个过程叫做函数的 [epilogue](https://en.wikipedia.org/wiki/Function_prologue_and_epilogue#Epilogue). 198 | 199 | 如何判断一个指令存在返回值呢? 你也许还记得 Koopa IR 是强类型 IR, 所有指令都是有类型的. 如果指令的类型为 `unit` (类似 C/C++ 中的 `void`), 则这条指令不存在返回值. 200 | 201 | 在 C/C++ 中, 每个 `koopa_raw_value_t` 都有一个名叫 `ty` 的字段, 它的类型是 `koopa_raw_type_t`. `koopa_raw_type_t` 中有一个字段叫做 `tag`, 存储了这个类型具体是何种类型. 如果它的值为 `KOOPA_RTT_UNIT`, 说明这个类型是 `unit` 类型. 202 | 203 | 在 Rust 中, 每个 `ValueData` 都有一个名叫 `ty()` 的方法, 这个方法会返回一个 `&Type`. 而 `Type` 又有一个方法叫做 `is_unit()`, 如果这个方法返回 `true`, 说明这个类型是 `unit` 类型. 204 | 205 | 或者你实在懒得判断的话, 给所有指令都分配栈空间也不是不行, 只不过这样会浪费一些栈空间. 206 | 207 | 示例程序生成的 RISC-V 汇编为: 208 | 209 | ``` 210 | .text 211 | .globl main 212 | main: 213 | # 函数的 prologue 214 | addi sp, sp, -16 215 | 216 | # store 10, @x 217 | li t0, 10 218 | sw t0, 0(sp) 219 | 220 | # %0 = load @x 221 | lw t0, 0(sp) 222 | sw t0, 4(sp) 223 | 224 | # %1 = add %0, 1 225 | lw t0, 4(sp) 226 | li t1, 1 227 | add t0, t0, t1 228 | sw t0, 8(sp) 229 | 230 | # store %1, @x 231 | lw t0, 8(sp) 232 | sw t0, 0(sp) 233 | 234 | # %2 = load @x 235 | lw t0, 0(sp) 236 | sw t0, 12(sp) 237 | 238 | # ret %2, 以及函数的 epilogue 239 | lw a0, 12(sp) 240 | addi sp, sp, 16 241 | ret 242 | ``` 243 | 244 | 栈帧的分配情况如下图所示: 245 | 246 | ![示例程序栈帧的分配情况](example-stack-frame.png) 247 | 248 | 在这个示例中, $S$ 恰好对齐了 16 字节, 所以 $S = S^\prime$. 此外, RISC-V 的调用约定中没有规定栈帧内数据的排列方式, 比如顺序, 或者对齐到栈顶还是栈底 (除了函数参数必须对齐到栈顶之外), 所以你想怎么安排就可以怎么安排, 只要你的编译器内采用统一标准即可. 249 | -------------------------------------------------------------------------------- /docs/misc-app-ref/environment.md: -------------------------------------------------------------------------------- 1 | # 实验环境使用说明 2 | 3 | ## 配置实验环境 4 | 5 | 见 [Lv0.1 配置 Docker](/lv0-env-config/docker). 6 | 7 | ## 进入实验环境的命令行 8 | 9 | 你可以执行如下命令来进入实验环境的命令行: 10 | 11 | ``` 12 | docker run -it --rm maxxing/compiler-dev bash 13 | ``` 14 | 15 | 如果你希望把自己的编译器项目目录挂载到容器中 (大部分情况下都需要这么做), 你可以执行: 16 | 17 | ``` 18 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev bash 19 | ``` 20 | 21 | 此时你本地的项目目录会出现在容器的 `/root/compiler` 目录中. 22 | 23 | 如果你希望在容器中使用调试器 (例如容器内的 `lldb`) 调试程序, 你应该: 24 | 25 | ``` 26 | docker run -it --rm -v 项目目录:/root/compiler \ 27 | --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ 28 | maxxing/compiler-dev bash 29 | ``` 30 | 31 | ## 准备你的编译器 32 | 33 | 为了保证测试的正常进行, 你的编译器必须符合下列要求: 34 | 35 | ### 项目要求 36 | 37 | 你可以使用 C/C++/Rust 来实现你的编译器, 同时为了方便测试程序在测试时编译你的编译器项目, 项目的根目录中必须存在下列三种文件之一: 38 | 39 | * **`Makefile` 或 `makefile` 文件:** 40 | * 编译命令行: `make BUILD_DIR="build目录" LIB_DIR="libkoopa目录" INC_DIR="libkoopa头文件目录" -C "项目目录"`. 41 | * 要求: 你的 `Makefile` 必须依据 `BUILD_DIR` 参数的值, 将生成的可执行文件输出到该目录中, 并命名为 `compiler`. 如果你的编译器依赖 `libkoopa`, 你可以在链接时使用 `LIB_DIR` 和 `INC_DIR` 参数获取 `libkoopa` 及其头文件所在的目录. 42 | * **`CMakeLists.txt` 文件:** 43 | * 生成编译脚本: `cmake -S "项目目录" -B "build目录" -DLIB_DIR="libkoopa目录" -DINC_DIR="libkoopa头文件目录"`. 44 | * 编译命令行: `cmake --build "build目录"`. 45 | * 要求: 你的 `CMakeLists.txt` 必须将可执行文件直接输出到所指定的 `build` 目录的根目录, 且将其命名为 `compiler`. 如果你的编译器依赖 `libkoopa`, 你可以在链接时使用 `LIB_DIR` 和 `INC_DIR` 参数获取 `libkoopa` 及其头文件所在的目录. 46 | * **`Cargo.toml` 文件:** 47 | * 编译命令行: `cargo build --manifest-path "Cargo.toml的路径" --release`. 48 | * 要求: 你无需担心任何其他内容. 49 | 50 | ### 输入形式 51 | 52 | 编译器必须能够读取如下格式的命令行参数 (**假设**你的编译器名字叫做 `compiler`): 53 | 54 | ``` 55 | compiler 阶段 输入文件 -o 输出文件 56 | ``` 57 | 58 | 其中, `输入文件` 为 SysY 文件的路径, `输出文件` 为编译器的输出文件路径, `阶段` 选项可以为: 59 | 60 | * `-koopa`: SysY 到 Koopa IR 阶段的功能测试, 此时你的编译器必须向输出文件中输出输入的 SysY 程序编译后的 Koopa IR. 61 | * `-riscv`: SysY 到 RISC-V 阶段的功能测试, 此时你的编译器必须向输出文件中输出输入的 SysY 程序编译后的 RISC-V 汇编. 62 | * `-perf`: 性能测试, 此时你的编译器必须向输出文件中输出输入的 SysY 程序编译后的 RISC-V 汇编. 63 | 64 | 例如, 你希望使用你的编译器 `compiler` 把文件 `hello.c` 中的 SysY 程序编译成 Koopa IR, 并且保存到文件 `hello.koopa` 中, 你应该执行: 65 | 66 | ``` 67 | ./compiler -koopa hello.c -o hello.koopa 68 | ``` 69 | 70 | 其他选项类似, 此处不再举例. 71 | 72 | ## 实验环境中的工具 73 | 74 | 实验环境中已经配置了如下工具: 75 | 76 | * **必要的工具:** `git`, `flex`, `bison`, `python3`. 77 | * **构建工具:** `make`, `cmake`. 78 | * **运行工具:** `qemu-user-static`. 79 | * **编译工具链:** Rust 工具链, LLVM 工具链. 80 | * **Koopa IR 相关工具:** `libkoopa` (Koopa 的 C/C++ 库), `koopac` (Koopa IR 到 LLVM IR 转换器). 81 | * **测试脚本:** `autotest`. 82 | 83 | 举例: 84 | 85 | 使用你的编译器生成 Koopa IR, 并运行生成的 Koopa IR: 86 | 87 | ``` 88 | ./compiler -koopa hello.c -o hello.koopa 89 | koopac hello.koopa | llc --filetype=obj -o hello.o 90 | clang hello.o -L$CDE_LIBRARY_PATH/native -lsysy -o hello 91 | ./hello 92 | ``` 93 | 94 | 使用你的编译器生成 RISC-V 汇编代码, 将其汇编为二进制, 并运行生成的二进制: 95 | 96 | ``` 97 | ./compiler -riscv hello.c -o hello.S 98 | clang hello.S -c -o hello.o -target riscv32-unknown-linux-elf -march=rv32im -mabi=ilp32 99 | ld.lld hello.o -L$CDE_LIBRARY_PATH/riscv32 -lsysy -o hello 100 | qemu-riscv32-static hello 101 | ``` 102 | 103 | 如果需要在实验环境中查看程序的返回值, 你可以在运行程序之后, 紧接着在命令行中执行: 104 | 105 | ``` 106 | echo $? 107 | ``` 108 | 109 | 比如: 110 | 111 | ``` 112 | qemu-riscv32-static hello; echo $? 113 | ``` 114 | 115 | 需要注意的是, 程序的 `main` 函数的返回值类型是 `int`, 即支持返回 32 位的返回值. 但你也许会发现, 你使用 `echo $?` 看到的返回值永远都位于 $[0, 255]$ 的区间内. 这是因为 Docker 实验环境内实际上运行的是 Linux 操作系统, Linux 程序退出时会使用 `exit` 系统调用传递返回值, 但接收返回值的一方可能会使用 `wait`, `waitpid` 等系统调用处理返回值, 此时只有返回值的低 8 位会被保留. 116 | 117 | ## 测试你的编译器 118 | 119 | 实验环境内附带了自动测试脚本, 名为 `autotest`. 进入命令行后, 你可以执行如下命令来自动编译并测试项目目录中的编译器: 120 | 121 | ``` 122 | autotest 阶段 编译器项目目录 123 | ``` 124 | 125 | 其中, `阶段` 选项和编译器命令行中的 `阶段` 选项含义一致. 126 | 127 | 例如, 你的编译器项目 (只保留项目本身即可, 不需要提前编译) 位于 `/root/compiler`, 你希望测试你的编译器输出的 Koopa IR 的正确性, 你可以执行: 128 | 129 | ``` 130 | autotest -koopa /root/compiler 131 | ``` 132 | 133 | 自动测试脚本还支持一些其他的选项, 例如指定使用何种测试用例来测试编译器, 详见自动测试脚本的 [README](https://github.com/pku-minic/compiler-dev/blob/master/autotest/README.md), 或者 `autotest --help` 命令的输出. 134 | 135 | 此外, 如果只是测试自己的编译器, 你并不需要进入容器的命令行, 你可以直接在宿主机执行: 136 | 137 | ``` 138 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev \ 139 | autotest 阶段 /root/compiler 140 | ``` 141 | 142 | MaxXing 在开发这套环境时, 常用的测试方法为: 143 | 144 | ```bash 145 | # 先进入 Docker 容器 146 | docker run -it --rm -v 项目目录:/root/compiler maxxing/compiler-dev bash 147 | # 在容器中运行 autotest, 但指定工作目录 (-w 选项) 148 | # 同时, 使用 tee 命令将输出保存到文件 149 | autotest -w wd compiler 2>&1 | tee compiler/out.txt 150 | # 测试完毕后, 不要退出容器, 回到宿主机修复 bug 151 | # 因为编译器目录已经被挂到了容器里, 所以你能在宿主机看到刚刚生成的 out.txt 152 | # 同时你在宿主机对代码做出的更改也能反映到容器内 153 | # ... 154 | # 修改完成后, 回到容器运行同样的 autotest 命令 155 | # 因为我们指定了工作目录, autotest 会在上次编译的基础上增量编译你的编译器 156 | # 这样能节省很多时间 157 | autotest -w wd compiler 2>&1 | tee compiler/out.txt 158 | ``` 159 | 160 | ## 调试你的编译器 161 | 162 | 在测试编译器的过程中, `autotest` 可能会报告一些错误, 例如 “WRONG ANSWER”, “CASE ASSEMBLE ERROR” 等等. 出现这些错误的原因, 通常是你的编译器在实现上存在问题. 163 | 164 | 首先你需要知道, `autotest` 在测试你的编译器的过程中, 实际上执行了如下操作: 165 | 166 | 1. 使用 C/C++/Rust 工具链编译你的编译器. 167 | 2. 使用你的编译器编译测试用例. 168 | 3. 把你的编译器输出的测试用例汇编并链接成可执行文件. 169 | 4. 执行这个可执行文件, 收集执行结果 (程序的 `stdout` 和返回值), 并且和预期结果进行比较. 170 | 5. 输出测试结果. 171 | 172 | 基于上述流程, `autotest` 可能会报告如下错误: 173 | 174 | * **输出 C/C++/Rust 的编译错误信息:** 测试脚本未能成功编译你的编译器. 175 | * **CASE COMPILE ERROR:** 测试脚本在调用你的编译器编译测试用例时, 检测到你的编译器发生了错误 (编译器的返回值不为 0), 例如你的编译器崩溃了. 176 | * **CASE COMPILE TIME EXCEEDED:** 测试脚本在调用你的编译器编译测试用例时, 发现你的编译器运行时间过长 (超过 5 分钟). 177 | * **OUTPUT NOT FOUND:** 测试脚本调用你的编译器编译了测试用例, 但发现你的编译器没有把编译结果输出到命令行中指定的文件. 178 | * **CASE ASSEMBLE ERROR:** 测试脚本在把你的编译器编译得到的测试用例, 汇编到可执行文件的过程中, 发生了错误, 通常是因为你的编译器输出了错误的 Koopa IR/RISC-V 汇编. 179 | * **CASE ASSEMBLE TIME EXCEEDED:** 测试脚本在把你的编译器编译得到的测试用例, 汇编到可执行文件的过程中, 发现该流程执行时间过长 (超过 1 分钟), 通常是因为你的编译器输出了一个巨大的文件. 180 | * **TIME LIMIT EXCEEDED:** 测试脚本在运行你的编译器编译得到的测试用例时, 检测到运行时间过长 (超过 2 分钟), 通常是因为你的编译器生成的程序中出现了死循环. 181 | * **WRONG ANSWER:** 测试脚本运行了你的编译器编译得到的测试用例, 但检测到运行结果和预期结果不符. 182 | 183 | 你可以根据错误的类型, 对你的编译器进行针对性的调试. 184 | 185 | 需要额外说明的是, `autotest` 在测试你的编译器时所使用的测试用例, 位于 `compiler-dev` 镜像中的 `/opt/bin/testcases` 目录中. 在调试时, 你可以使用你的编译器编译其中的测试用例, 来进一步定位问题发生的原因. 测试用例通常包含以下三类文件: 186 | 187 | * **`.c` 文件:** 测试用例本体. 188 | * **同名的 `.out` 文件:** 测试用例的参考输出. 如果该文件只有一行, 则其对应的是程序的预期返回值; 如果文件有多行, 则最后一行是程序的预期返回值, 之前的内容为程序的预期标准输出. 189 | * **同名的 `.in` 文件:** 测试用例的标准输入. 如果测试用例程序中调用了读取标准输入的库函数, 例如 `getint`, `getch` 等, 程序的标准输入会由该文件指定. 如果测试用例程序不会读取标准输入, 则可以不提供 `.in` 文件. 190 | 191 | 除此之外, `compiler-dev` 镜像中内置的本地测试用例可以在 [GitHub 上找到](https://github.com/pku-minic/compiler-dev-test-cases). 192 | 193 | ## 调试 RISC-V 程序 194 | 195 | 编译器确实是个神奇而复杂的东西: 它本身是一个程序, 同时, 它接受一个程序的输入, 并输出另一个程序. 这就使得调试编译器工程变成了一件非常复杂的事情: 你不仅需要调试编译器本身, **还可能需要调试编译器生成的那个程序**——因为你的编译器如果出了问题, 它很可能会生成一个错误的程序. 196 | 197 | 然后, 可以预见, 你会手忙脚乱. 当然这并不开心. 198 | 199 | 在 `compiler-dev` 中, 你可以使用调试器来调试编译器生成的 RISC-V 程序. 确切的说, 是调试由编译器生成的 RISC-V 汇编, 经过汇编器和链接器处理后生成的可执行文件, 再使用 `qemu-riscv32-static` 运行后的那个进程. 200 | 201 | 在此之前, 你需要使用如下方式启动一个 ` compiler-dev` 容器: 202 | 203 | ``` 204 | docker run -it --rm -v 项目目录:/root/compiler \ 205 | --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ 206 | maxxing/compiler-dev bash 207 | ``` 208 | 209 | 然后在容器内安装 `gdb-multiarch`——在调试 QEMU 里跑着的 RISC-V 程序这件事上, `lldb` 并不是那么好用: 210 | 211 | ``` 212 | apt install gdb-multiarch 213 | ``` 214 | 215 | ?> 考虑到你在这个容器里安装了额外的软件, 你可以在启动容器时, 删掉 `--rm` 选项, 以防你不小心退出了容器, 导致容器里的更改全部木大. 216 | 217 | 此后, 你需要使用你的编译器生成 RISC-V 汇编, 然后借助之前提到的方式, 用 `clang` 和 `ld.lld` 生成 RISC-V 的 ELF 可执行文件. 之后, 用如下方式运行这个程序 (假设生成的 RISC-V 程序叫做 `hello`): 218 | 219 | ``` 220 | qemu-riscv32-static -g 1234 hello & 221 | ``` 222 | 223 | `-g 1234` 选项告诉 QEMU 启用调试模式, 并且在本地的 `1234` 端口开一个远程调试服务 (gdbstub), 方便调试器接入. 命令最后的 `&` 告诉 Shell 在后台运行这条命令, 不这么做的话, 这条命令会在前台占用 Shell 的输入, 导致你无法执行后续操作. 224 | 225 | 接着, 你就可以启动 GDB, 加载 RISC-V 程序, 然后接入 QEMU 进行调试了: 226 | 227 | ``` 228 | gdb-multiarch hello 229 | ``` 230 | 231 | 在 GDB 的 Shell 中执行 (前面的 `(gdb)` 是提示符, 请勿作为命令执行): 232 | 233 | ``` 234 | (gdb) target remote :1234 235 | ``` 236 | 237 | 这条命令告诉 GDB 连接到 QEMU 开启的 `1234` 端口进行调试. 在此之后, 所有操作都和本地调试别无二致. 比如你可以: 238 | 239 | ``` 240 | (gdb) layout asm # 打开反汇编窗口 241 | (gdb) focus cmd # 将焦点切换到 GDB 命令行 242 | (gdb) b main # 在 hello 的 main 函数处添加断点 243 | (gdb) c # 继续执行, 或者 244 | (gdb) si # 单步执行指令 245 | ``` 246 | 247 | GDB 会在 `main` 函数的入口处停下来, 同时你可以看到, 反汇编窗口正在显示 `hello` 程序中 `main` 函数的汇编代码. 248 | 249 | 在调试的过程中, 如果程序退出, 或者你操作 GDB 主动杀掉了程序, `qemu-riscv32-static` 也会随之退出. 此时你无法再在调试器里执行 `r` 或者什么其他命令来重新启动程序, 进行第二轮调试. 除非你退出调试器, 回到 Shell, 再执行一次之前的 QEMU 命令. 250 | 251 | 这么看还是挺麻烦的, 你可能就感慨: Docker 为什么只为你开了一个终端窗口, 如果它能启动多个终端的话, 你就可以在一个终端里执行 QEMU, 在另一个里调试了. 你意识到了这个问题, 非常好! 在容器内安装 `screen`, `tmux` 等程序也许可以解决这个问题, 或者你可以借助 `docker exec -it 容器ID bash` 命令, 再在容器内启动一个 Shell. 具体解决方法你可以自行 STFW, 此处不再赘述. 252 | 253 | ## 使用其他测试用例 254 | 255 | `autotest` 支持指定测试用例所在的目录: 256 | 257 | ``` 258 | autotest -t 测试用例目录 编译器项目目录 259 | ``` 260 | 261 | 如果你不满足于实验环境内附带的测试用例, 你可以自己编写一些测试用例来测试自己的编译器. 当然, 你可以使用一些现成的第三方的测试用例: 262 | 263 | * [**compiler2021**](https://gitlab.eduxiji.net/nscscc/compiler2021/-/tree/master/%E5%85%AC%E5%BC%80%E7%94%A8%E4%BE%8B%E4%B8%8E%E8%BF%90%E8%A1%8C%E6%97%B6%E5%BA%93): 编译系统设计赛官方测试用例. 264 | * [**minic-test-cases-2021s**](https://github.com/pku-minic/minic-test-cases-2021s): 北大编译实践课程 2021 年春季学期使用的测试用例. 265 | * [**minic-test-cases-2021f**](https://github.com/pku-minic/minic-test-cases-2021f): 北大编译实践课程 2021 年秋季学期使用的测试用例. 266 | * [**segviol/indigo**](https://github.com/segviol/indigo/tree/develop/test_codes/upload): 2020 年第一届编译系统设计赛北航参赛队开发的 indigo 编译器的内部测试用例. 267 | * [**TrivialCompiler/TrivialCompiler**](https://github.com/TrivialCompiler/TrivialCompiler/tree/master/custom_test): 2020 年第一届编译系统设计赛清华参赛队开发的 TrivialCompiler 编译器的内部测试用例. 268 | * [**ustb-owl/lava-test**](https://github.com/ustb-owl/lava-test): 2021 年第二届编译系统设计赛北科参赛队开发的 Lava 编译器的内部测试用例. 269 | * [**jokerwyt/sysy-testsuit-collection**](https://github.com/jokerwyt/sysy-testsuit-collection): 北京大学编译原理课程 2024 年春季选课学生所整理的 467 个测试用例。 270 | 271 | **注意:** 272 | 273 | * 测试用例的输出文件 (`.out`) 中的换行符必须是 LF, 但 Windows 上的 Git 可能会自动把所有换行符转换为 CRLF. 如果你正在使用 Windows, 在 clone 上述仓库之前, 请确保你关闭了 Git 的换行符自动转换 (`git config --global core.autocrlf false`). 274 | * 上述测试用例中可能出现不符合编译实践中用到的 SysY 语言的语义定义的情况, 例如出现了不在 $[0, 2^{31} - 1]$ 范围内的整数字面量, 你可以忽略这些测试用例. 275 | -------------------------------------------------------------------------------- /docs/misc-app-ref/sysy-spec.md: -------------------------------------------------------------------------------- 1 | # SysY 语言规范 2 | 3 | ?> SysY 官方的语言定义见[这里](https://gitlab.eduxiji.net/nscscc/compiler2021/-/blob/master/SysY%E8%AF%AD%E8%A8%80%E5%AE%9A%E4%B9%89.pdf). 4 |

5 | 编译实践课所使用的 SysY 语言和官方定义略有不同: 实践课的 SysY 向下兼容官方定义. 6 | 7 | ## 文法定义 8 | 9 | SysY 语言的文法采用扩展的 Backus 范式 (EBNF, Extended Backus-Naur Form) 表示, 其中: 10 | 11 | * 符号 `[...]` 表示方括号内包含的项可被重复 0 次或 1 次. 12 | * 符号 `{...}` 表示花括号内包含的项可被重复 0 次或多次. 13 | * 终结符是由双引号括起的串, 或者是 `IDENT`, `INT_CONST` 这样的大写记号. 其余均为非终结符. 14 | 15 | SysY 语言的文法表示如下, `CompUnit` 为开始符号: 16 | 17 | ```ebnf 18 | CompUnit ::= [CompUnit] (Decl | FuncDef); 19 | 20 | Decl ::= ConstDecl | VarDecl; 21 | ConstDecl ::= "const" BType ConstDef {"," ConstDef} ";"; 22 | BType ::= "int"; 23 | ConstDef ::= IDENT {"[" ConstExp "]"} "=" ConstInitVal; 24 | ConstInitVal ::= ConstExp | "{" [ConstInitVal {"," ConstInitVal}] "}"; 25 | VarDecl ::= BType VarDef {"," VarDef} ";"; 26 | VarDef ::= IDENT {"[" ConstExp "]"} 27 | | IDENT {"[" ConstExp "]"} "=" InitVal; 28 | InitVal ::= Exp | "{" [InitVal {"," InitVal}] "}"; 29 | 30 | FuncDef ::= FuncType IDENT "(" [FuncFParams] ")" Block; 31 | FuncType ::= "void" | "int"; 32 | FuncFParams ::= FuncFParam {"," FuncFParam}; 33 | FuncFParam ::= BType IDENT ["[" "]" {"[" ConstExp "]"}]; 34 | 35 | Block ::= "{" {BlockItem} "}"; 36 | BlockItem ::= Decl | Stmt; 37 | Stmt ::= LVal "=" Exp ";" 38 | | [Exp] ";" 39 | | Block 40 | | "if" "(" Exp ")" Stmt ["else" Stmt] 41 | | "while" "(" Exp ")" Stmt 42 | | "break" ";" 43 | | "continue" ";" 44 | | "return" [Exp] ";"; 45 | 46 | Exp ::= LOrExp; 47 | LVal ::= IDENT {"[" Exp "]"}; 48 | PrimaryExp ::= "(" Exp ")" | LVal | Number; 49 | Number ::= INT_CONST; 50 | UnaryExp ::= PrimaryExp | IDENT "(" [FuncRParams] ")" | UnaryOp UnaryExp; 51 | UnaryOp ::= "+" | "-" | "!"; 52 | FuncRParams ::= Exp {"," Exp}; 53 | MulExp ::= UnaryExp | MulExp ("*" | "/" | "%") UnaryExp; 54 | AddExp ::= MulExp | AddExp ("+" | "-") MulExp; 55 | RelExp ::= AddExp | RelExp ("<" | ">" | "<=" | ">=") AddExp; 56 | EqExp ::= RelExp | EqExp ("==" | "!=") RelExp; 57 | LAndExp ::= EqExp | LAndExp "&&" EqExp; 58 | LOrExp ::= LAndExp | LOrExp "||" LAndExp; 59 | ConstExp ::= Exp; 60 | ``` 61 | 62 | 其中, 各符号的含义如下: 63 | 64 | | 符号 | 含义 | 符号 | 含义 | 65 | | --- | --- | --- | --- | 66 | | CompUnit | 编译单元 | Decl | 声明 | 67 | | ConstDecl | 常量声明 | BType | 基本类型 | 68 | | ConstDef | 常数定义 | ConstInitVal | 常量初值 | 69 | | VarDecl | 变量声明 | VarDef | 变量定义 | 70 | | InitVal | 变量初值 | FuncDef | 函数定义 | 71 | | FuncType | 函数类型 | FuncFParams | 函数形参表 | 72 | | FuncFParam | 函数形参 | Block | 语句块 | 73 | | BlockItem | 语句块项 | Stmt | 语句 | 74 | | Exp | 表达式 | LVal | 左值表达式 | 75 | | PrimaryExp | 基本表达式 | Number | 数值 | 76 | | UnaryExp | 一元表达式 | UnaryOp | 单目运算符 | 77 | | FuncRParams | 函数实参表 | MulExp | 乘除模表达式 | 78 | | AddExp | 加减表达式 | RelExp | 关系表达式 | 79 | | EqExp | 相等性表达式 | LAndExp | 逻辑与表达式 | 80 | | LOrExp | 逻辑或表达式 | ConstExp | 常量表达式 | 81 | 82 | 需要注意的是: 83 | 84 | * `Exp`: SysY 中表达式的类型均为 `int` 型. 当 `Exp` 出现在表示条件判断的位置时 (例如 `if` 和 `while`), 表达式值为 0 时为假, 非 0 时为真. 85 | * `ConstExp`: 其中使用的 `IDENT` 必须是常量. 86 | 87 | ## SysY 语言的终结符特征 88 | 89 | ### 标识符 90 | 91 | SysY 语言中标识符 `IDENT` (identifier) 的规范如下: 92 | 93 | ```ebnf 94 | identifier ::= identifier-nondigit 95 | | identifier identifier-nondigit 96 | | identifier digit; 97 | ``` 98 | 99 | 其中, `identifier-nondigit` 为下划线, 小写英文字母或大写英文字母; `digit` 为数字 0 到 9. 100 | 101 | 关于其他信息, 请参考 [ISO/IEC 9899](http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1124.pdf) 第 51 页关于标识符的定义. 102 | 103 | 对于同名**标识符**, SysY 中有以下约定: 104 | 105 | * 全局变量和局部变量的作用域可以重叠, 重叠部分局部变量优先. 106 | * 同名局部变量的作用域不能重叠. 107 | * 变量名可以和函数名相同. 108 | 109 | ### 数值常量 110 | 111 | SysY 语言中数值常量可以是整型数 `INT_CONST` (integer-const), 其规范如下: 112 | 113 | ```ebnf 114 | integer-const ::= decimal-const 115 | | octal-const 116 | | hexadecimal-const; 117 | decimal-const ::= nonzero-digit 118 | | decimal-const digit; 119 | octal-const ::= "0" 120 | | octal-const octal-digit; 121 | hexadecimal-const ::= hexadecimal-prefix hexadecimal-digit 122 | | hexadecimal-const hexadecimal-digit; 123 | hexadecimal-prefix ::= "0x" | "0X"; 124 | ``` 125 | 126 | 其中, `nonzero-digit` 为数字 1 到 9; `octal-digit` 为数字 0 到 7; `hexadecimal-digit` 为数字 0 到 9, 或大写/小写字母 a 到 f. 127 | 128 | 数值常量的范围为 $[0, 2^{31} - 1]$, 不包含负号. 129 | 130 | 关于其他信息, 请参考 [ISO/IEC 9899](http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1124.pdf) 第 54 页关于整数型常量的定义, 在此基础上忽略所有后缀. 131 | 132 | ### 注释 133 | 134 | SysY 语言中注释的规范与 C 语言一致, 如下: 135 | 136 | * 单行注释: 以序列 `//` 开始, 直到换行符结束, 不包括换行符. 137 | * 多行注释: 以序列 `/*` 开始, 直到第一次出现 `*/` 时结束, 包括结束处 `*/`. 138 | 139 | 关于其他信息, 请参考 [ISO/IEC 9899](http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1124.pdf) 第 66 页关于注释的定义. 140 | 141 | ## 语义约束 142 | 143 | 符合[文法定义](/misc-app-ref/sysy?id=%e6%96%87%e6%b3%95%e5%ae%9a%e4%b9%89)的程序集合是合法的 SysY 语言程序集合的超集. 下面, 我们进一步给出 SysY 语言的语义约束. 144 | 145 | ### 编译单元 146 | 147 | ```ebnf 148 | CompUnit ::= [CompUnit] (Decl | FuncDef); 149 | Decl ::= ConstDecl | VarDecl; 150 | ``` 151 | 152 | 1. 一个 SysY 程序由单个文件组成, 文件内容对应 EBNF 表示中的 `CompUnit`. 在该 `CompUnit` 中, 必须存在且仅存在一个标识为 `main`, 无参数, 返回类型为 `int` 的 `FuncDef` (函数定义). `main` 函数是程序的入口点. 153 | 2. `CompUnit` 的顶层变量/常量声明语句 (对应 `Decl`), 函数定义 (对应 `FuncDef`) 都不可以重复定义同名标识符 (`IDENT`), 即便标识符的类型不同也不允许. 154 | 3. `CompUnit` 的变量/常量/函数声明的作用域从该声明处开始, 直到文件结尾. 155 | 156 | ### 常量定义 157 | 158 | ```ebnf 159 | ConstDef ::= IDENT {"[" ConstExp "]"} "=" ConstInitVal; 160 | ``` 161 | 162 | 1. `ConstDef` 用于定义符号常量. `ConstDef` 中的 `IDENT` 为常量的标识符, 在 `IDENT` 后, `=` 之前是可选的数组维度和各维长度的定义部分, 在 `=` 之后是初始值. 163 | 2. `ConstDef` 的数组维度和各维长度的定义部分不存在时, 表示定义单个常量. 此时 `=` 右边必须是单个初始数值. 164 | 3. `ConstDef` 的数组维度和各维长度的定义部分存在时, 表示定义数组. 其语义和 C 语言一致, 比如 `[2][8/2][1*3]` 表示三维数组, 第一到第三维长度分别为 2, 4 和 3, 每维的下界从 0 开始编号. `ConstDef` 中表示各维长度的 `ConstExp` 都必须能在编译时被求值到非负整数. SysY 在声明数组时各维长度都需要显式给出, 而不允许是未知的. 165 | 4. 当 `ConstDef` 定义的是数组时, `=` 右边的 `ConstInitVal` 表示常量初始化器. `ConstInitVal` 中的 `ConstExp` 是能在编译时被求值的 `int` 型表达式, 其中可以引用已定义的符号常量. 166 | 5. `ConstInitVal` 初始化器必须是以下三种情况之一 (注: `int` 型初始值可以是 `Number`, 或者是 `int` 型常量表达式): 167 | 1. 一对花括号 `{}`, 表示所有元素初始为 0. 168 | 2. 与多维数组中数组维数和各维长度完全对应的初始值, 如 `{{1,2},{3,4},{5,6}}`, `{1,2,3,4,5,6}`, `{1,2,{3,4},5,6}` 均可作为 `a[3][2]` 的初始值. 169 | 3. 如果花括号括起来的列表中的初始值少于数组中对应维的元素个数, 则该维其余部分将被隐式初始化, 需要被隐式初始化的整型元素均初始为 0. 如 `{{1,2},{3},{5}}`, `{1,2,{3},5}`, `{{},{3,4},5,6}` 均可作为 `a[3][2]` 的初始值, 前两个将 `a` 初始化为 `{{1,2},{3,0},{5,0}}`, `{{},{3,4},5,6}` 将 `a` 初始化为 `{{0,0},{3,4},{5,6}}`. 170 | 171 | 例如, 下列常量 `a` 到 `e` 的声明和初始化都是合法的: 172 | 173 | ```c 174 | const int a[4][2] = {}; 175 | const int b[4][2] = {1, 2, 3, 4, 5, 6, 7, 8}; 176 | const int c[4][2] = {{1, 2}, {3, 4}, 5, 6, 7, 8}; 177 | const int d[4][2] = {1, 2, {3}, {5}, 7 , 8}; 178 | const int e[4][2] = {{1, 2}, {3, 4}, {5, 6}, {7, 8}}; 179 | ``` 180 | 181 | !> SysY 中 “常量” 的定义和 C 语言中的定义有所区别: SysY 中, 所有的常量必须能在编译时被计算出来; 而 C 语言中的常量仅代表这个量不能被修改. 182 |

183 | SysY 中的常量有些类似于 C++ 中的 `consteval`, 或 Rust 中的 `const`. 184 | 185 | ### 变量定义 186 | 187 | ```ebnf 188 | VarDef ::= IDENT {"[" ConstExp "]"} 189 | | IDENT {"[" ConstExp "]"} "=" InitVal; 190 | ``` 191 | 192 | 1. `VarDef` 用于定义变量. 当不含有 `=` 和初始值时, 其运行时实际初值未定义. 193 | 2. `VarDef` 的数组维度和各维长度的定义部分不存在时, 表示定义单个变量; 存在时, 和 `ConstDef` 类似, 表示定义多维数组. (参见 `ConstDef` 的第 2 点) 194 | 3. 当 `VarDef` 含有 `=` 和初始值时, `=` 右边的 `InitVal` 和 `CostInitVal` 的结构要求相同, 唯一的不同是 `ConstInitVal` 中的表达式是 `ConstExp` 常量表达式, 而 `InitVal` 中的表达式可以是当前上下文合法的任何 `Exp`. 195 | 4. `VarDef` 中表示各维长度的 `ConstExp` 必须能被求值到非负整数, 但 `InitVal` 中 196 | 的初始值为 `Exp` 可以引用变量. 例如下列变量 `e` 的初始化表达式 `d[2][1]`. 197 | 198 | ```c 199 | int a[4][2] = {}; 200 | int b[4][2] = {1, 2, 3, 4, 5, 6, 7, 8}; 201 | int c[4][2] = {{1, 2}, {3, 4}, 5, 6, 7, 8}; 202 | int d[4][2] = {1, 2, {3}, {5}, 7 , 8}; 203 | int e[4][2] = {{d[2][1], c[2][1]}, {3, 4}, {5, 6}, {7, 8}}; 204 | ``` 205 | 206 | ### 初值 207 | 208 | ```ebnf 209 | ConstInitVal ::= ConstExp | "{" [ConstInitVal {"," ConstInitVal}] "}"; 210 | InitVal ::= Exp | "{" [InitVal {"," InitVal}] "}"; 211 | ``` 212 | 213 | 1. 全局变量声明中指定的初值表达式必须是常量表达式. 214 | 2. 未显式初始化的局部变量, 其值是不确定的; 而未显式初始化的全局变量, 其 (元素) 值均被初始化为 0. 215 | 3. 常量或变量声明中指定的初值要与该常量或变量的类型一致. 如下形式的 `VarDef`/`ConstDef` 不满足 SysY 语义约束: 216 | 217 | ```c 218 | a[4] = 4; 219 | a[2] = {{1,2}, 3}; 220 | a = {1,2,3}; 221 | ``` 222 | 223 | ### 函数形参与实参 224 | 225 | ```ebnf 226 | FuncFParam ::= BType IDENT ["[" "]" {"[" ConstExp "]"}]; 227 | FuncRParams ::= Exp {"," Exp}; 228 | ``` 229 | 230 | 1. `FuncFParam` 定义函数的一个形式参数. 当 `IDENT` 后面的可选部分存在时, 表示定义数组类型的形参. 231 | 2. 当 `FuncFParam` 为数组时, 其第一维的长度省去 (用方括号 `[]` 表示), 而后面的各维则需要用表达式指明长度, 其长度必须是常量. 232 | 3. 函数实参的语法是 `Exp`. 对于 `int` 类型的参数, 遵循按值传递的规则; 对于数组类型的参数, 形参接收的是实参数组的地址, 此后可通过地址间接访问实参数组中的元素. 233 | 4. 对于多维数组, 我们可以传递其中的一部分到形参数组中. 例如, 若存在数组定义 `int a[4][3]`, 则 `a[1]` 是包含三个元素的一维数组, `a[1]` 可以作为实参, 传递给类型为 `int[]` 的形参. 234 | 235 | ### 函数定义 236 | 237 | ```ebnf 238 | FuncDef ::= FuncType IDENT "(" [FuncFParams] ")" Block; 239 | ``` 240 | 241 | 1. `FuncDef` 表示函数定义. 其中的 `FuncType` 指明了函数的返回类型. 242 | * 当返回类型为 `int` 时, 函数内的所有分支都应当含有带有 `Exp` 的 `return` 语句. 不含有 `return` 语句的分支的返回值未定义. 243 | * 当返回值类型为 `void` 时, 函数内只能出现不带返回值的 `return` 语句. 244 | 2. `FuncDef` 中形参列表 (`FuncFParams`) 的每个形参声明 (`FuncFParam`) 用于声明 `int` 类型的参数, 或者是元素类型为 `int` 的多维数组. `FuncFParam` 的语义参见前文. 245 | 246 | ### 语句块 247 | 248 | ```ebnf 249 | Block ::= "{" {BlockItem} "}"; 250 | BlockItem ::= Decl | Stmt; 251 | ``` 252 | 253 | 1. `Block` 表示语句块. 语句块会创建作用域, 语句块内声明的变量的生存期在该语句块内. 254 | 2. 语句块内可以再次定义与语句块外同名的变量或常量 (通过 `Decl` 语句), 其作用域从定义处开始到该语句块尾结束, 它覆盖了语句块外的同名变量或常量. 255 | 256 | ### 语句 257 | 258 | ```ebnf 259 | Stmt ::= LVal "=" Exp ";" 260 | | [Exp] ";" 261 | | Block 262 | | "if" "(" Exp ")" Stmt ["else" Stmt] 263 | | "while" "(" Exp ")" Stmt 264 | | "break" ";" 265 | | "continue" ";" 266 | | "return" [Exp] ";"; 267 | ``` 268 | 269 | 1. `Stmt` 中的 `if` 型语句遵循就近匹配的原则. 270 | 2. 单个 `Exp` 可以作为 `Stmt`. `Exp` 会被求值, 所求的值会被丢弃. 271 | 272 | ### 左值表达式 273 | 274 | ```ebnf 275 | LVal ::= IDENT {"[" Exp "]"}; 276 | ``` 277 | 278 | 1. `LVal` 表示具有左值的表达式, 可以为变量或者某个数组元素. 279 | 2. 当 `LVal` 表示数组时, 方括号个数必须和数组变量的维数相同 (即定位到元素). 若 `LVal` 表示的数组作为数组参数参与函数调用, 则数组的方括号个数可以不与维数相同 (参考 [函数形参与实参](/misc-app-ref/sysy-spec?id=函数形参与实参)). 280 | 3. 当 `LVal` 表示单个变量时, 不能出现后面的方括号. 281 | 282 | ### 表达式 283 | 284 | ```ebnf 285 | Exp ::= LOrExp; 286 | ... 287 | ``` 288 | 289 | 1. `Exp` 在 SysY 中代表 `int` 型表达式. 当 `Exp` 出现在表示条件判断的位置时 (例如 `if` 和 `while`), 表达式值为 0 时为假, 非 0 时为真. 290 | 2. 对于 `LOrExp`, 当其左右操作数有任意一个非 0 时, 表达式的值为 1, 否则为 0; 对于 `LAndExp`, 当其左右操作数有任意一个为 0 时, 表达式的值为 0, 否则为 1. 上述两种表达式均满足 C 语言中的短路求值规则. 291 | 3. `LVal` 必须是当前作用域内, 该 `Exp` 语句之前曾定义过的变量或常量. 赋值号左边的 `LVal` 必须是变量. 292 | 4. 函数调用形式是 `IDENT "(" FuncRParams ")"`, 其中的 `FuncRParams` 表示实际参数. 实际参数的类型和个数必须与 `IDENT` 对应的函数定义的形参完全匹配. 293 | 5. SysY 中算符的优先级与结合性与 C 语言一致, 上一节定义的 SysY 文法中已体现了优先级与结合性的定义. 294 | 295 | ### 求值顺序 296 | 297 | 在 SysY 中, 可能出现如下三种求值顺序影响运行结果的例子 (如果你想到了其它可能的情况,请告知助教): 298 | 299 | 类型 1, 表达式操作数/函数参数的求值顺序影响结果: 300 | 301 | ```c 302 | int f(int x) { 303 | putint(x); 304 | return x; 305 | } 306 | 307 | void g(int x, int y) {} 308 | 309 | int main() { 310 | g(f(1), f(2)); // 可能输出 12 或 21 311 | return f(3) + f(4); // 可能输出 34 或 43 312 | } 313 | ``` 314 | 315 | 类型 2, 数组下标的求值顺序影响结果: 316 | 317 | ```c 318 | int i = 0, a[10][10]; 319 | 320 | int g() { 321 | i = i + 1; 322 | return i; 323 | } 324 | 325 | int main() { 326 | a[i][g()] = 2; 327 | putint(a[0][1]); 328 | putint(a[1][1]); 329 | return 0; // 可能输出 02 或 20 330 | } 331 | ``` 332 | 333 | 类型 3, 赋值运算符的左右操作数求值顺序影响结果: 334 | 335 | ```c 336 | int i = 0, a[10]; 337 | 338 | int g() { 339 | i = i + 1; 340 | return i; 341 | } 342 | 343 | int main() { 344 | a[i] = g(); 345 | putint(a[0]); 346 | putint(a[1]); 347 | return 0; // 可能输出 01 或 10 348 | } 349 | ``` 350 | 351 | 参考 C 语言的 [Order of evaluation](https://en.cppreference.com/w/c/language/eval_order) 和 C++ 语言的 [Order of evaluation](https://en.cppreference.com/w/cpp/language/eval_order), 我们知道: 在 C 语言中, 以上三种类型均为 UB; 而在 C++ 中, 从 C++17 以后, 类型 1 为 UB, 类型 2, 3 为良定义 (参考规则 17, 20). 352 | 353 | SysY 语言采取和 C 语言一样的求值顺序约定, **定义以上三种类型均为 UB**. 354 | --------------------------------------------------------------------------------