minicc 课程主页
这门课的目标很直接:带你把一个迷你 C 编译器真正拆开、看懂、再亲手做出来。你不会只停在“知道编译器大概分成词法、语法、语义、代码生成”这种概念层面,而是会沿着命令行驱动、词法分析、抽象语法树、语义检查和 RISC-V 代码生成这条主线,一段一段把编译器接起来。
如果你以前学过一些编译原理名词,却总觉得它们飘在 PPT 和文法规则里,没有真正落到代码里,这门课就是为这个缺口准备的。课程的主线一直保持同一个节奏:先把这一章为什么存在讲清楚,再进入对应 lab,在代码骨架里把这一章真正关键的部分补出来,然后用可观察的输出确认你做对了。
你最后会做出什么
学完整条主线后,你应当能独立解释并亲手走完下面这条链路:
- 从命令行读取一个
input.c - 把源文件切成 token 流
- 用递归下降解析器把 token 流变成 AST
- 用符号表和作用域规则做语义检查
- 把语义合法的 AST 生成为 RISC-V 32 汇编
- 用交叉工具链汇编、链接,再交给
qemu-riscv32运行
更重要的是,你会知道每一步为什么要存在,它和前一步、后一步是什么关系,以及一旦某一步写错,错误会在什么地方暴露出来。
学习路线
第一次进入课程时,不要急着跳到词法分析或语法分析。按这个顺序走:
- 先读 Chapter 0,把工具链检查清楚,并拉取 lab 仓库。
- 在本地 clone
https://github.com/Luyoung0001/miniCompiler_lab.git,运行第一次 smoke test。 - 再进入 Practice 与实验说明,确认这一门课的 lab 怎么使用。
- 从 Chapter 1 开始正式课程。
- 每读完一章,立刻去
miniCompiler_lab/labs/...里的对应 lab 完成代码。
讲义在网页上阅读,代码在 miniCompiler_lab 仓库里完成。你后面可以在这个 lab 仓库里持续提交自己的实现。
如果你只想记住最短路径,那就是:
Chapter 0 -> Practice 说明 -> Chapter 1 -> Lab01 -> Chapter 2 -> Lab02 -> ... -> Chapter 5 -> Lab05
主线章节
| 阶段 | Chapter | 对应 Lab | 你会解决的问题 |
|---|---|---|---|
| 起步 | Chapter 0 | 准备阶段 | 把工具链、仓库和 smoke test 进入可工作的状态 |
| 驱动 | Chapter 1 | lab01-step1 |
编译器最小驱动、命令行、文件读取、固定汇编输出 |
| 前端 | Chapter 2 | lab02-step2 |
把源代码切成 token 流 |
| 前端 | Chapter 3 | lab03-step3 |
递归下降解析器与 AST |
| 中端 | Chapter 4 | lab04-step4 |
符号表、作用域、语义错误检测 |
| 后端 | Chapter 5 | lab05-step5 |
生成 RISC-V 汇编并在 QEMU 中运行 |
附录
附录不属于主线,只有在你需要时再跳回来看:
现在就开始
如果你准备开始这门课,下一步就是:
Chapter 0 — 出发前的准备
预计时间:45 ~ 90 分钟
本章目标:确认gcc、RISC-V 工具链、QEMU 和 lab 仓库都已经处于可继续推进的状态
正式课程会从下一章开始进入编译器本身,但在那之前,你得先把起跑线摆正。这里的“准备”不是为了多看一页导言,而是为了确保你后面每次读 chapter、做 lab、看到测试输出时,都知道自己到底在看什么、改什么、以及哪里出了问题。
做完这一章,你还不需要已经懂词法分析、AST 或 RISC-V 调用约定。你只需要进入一个很明确的工作状态:机器上的工具是通的,miniCompiler_lab 已经在本地,第一次 smoke test 已经跑过,接下来可以直接进入 Chapter 1。
0.1 课程网页和 lab 仓库分别干什么
先把两个角色分清楚,不然后面很容易改错地方。
- 课程网页:讲解知识点、给出章节顺序、解释每个 lab 为什么存在。
miniCompiler_lab:学员实践仓库。后面的每个 lab 都在这里完成。
也就是说,这门课的节奏是:先读 chapter,再进入 lab 仓库里对应的实验目录,补这一章该写的代码。你不需要在一个完整工程里到处找入口,当前 chapter 会告诉你应该进入哪个 labs/... 目录。
0.2 这一章结束时,你应该达到什么状态
本章完成后,你应当同时满足下面四条:
- 你的机器上能找到
gcc - 你的机器上能找到
riscv64-unknown-linux-gnu-gcc或兼容前缀的 RISC-V 工具链 - 你的机器上能找到
qemu-riscv32 - 你已经把
miniCompiler_lab拉取到本地 - 你已经在
miniCompiler_lab/labs/lab01-step1/跑过第一次 smoke test
最后一条尤其重要。因为这门课不是“下载一堆讲义,然后自己猜实践边界”,而是要求你从一份已经准备好的实验骨架出发,沿着章节逐步完成编译器。
0.3 先检查最小工具链
请先在终端执行:
gcc --version
riscv64-unknown-linux-gnu-gcc --version
qemu-riscv32 --version
如果三条命令都能打印版本信息,说明你的基础环境已经够用了。
如果这里出现 command not found,先不要继续读后面的章节。优先把工具补齐:
- Ubuntu / Debian:
sudo apt install gcc-riscv64-unknown-elf qemu-user
或者使用仓库 README 里给出的 Linux 发行版对应包名。
此刻你不需要深入研究交叉编译器内部怎么工作。先记住一件事:这门课的最终目标不是只在主机上打印一些调试信息,而是要把生成的 RISC-V 汇编交给工具链和 QEMU 真正跑起来。
0.4 拉取 lab 仓库
课程网页负责讲解,lab 仓库负责让你写代码。现在打开一个你准备用来放实验的目录,执行:
git clone https://github.com/Luyoung0001/miniCompiler_lab.git
cd miniCompiler_lab
0.5 看一眼 lab 仓库,不要急着深入
在 miniCompiler_lab 根目录执行:
pwd
ls
你应该至少能看到这些内容:
README.mdlabs/scripts/
这里先不要急着把所有源文件都翻完。你现在真正需要建立的,只是一个非常粗的地图:
labs/是你真正下手写代码的地方- 每个
labs/labXX-.../都有自己的TASK.md、Makefile和framework/student.c scripts/bootstrap-practice.sh是 Chapter 0 的统一检查入口
后面每章都会把这张粗地图收紧到当前这一章相关的 2~4 个文件上。
0.6 运行第一次 practice smoke test
现在进入课程实践区,执行:
bash scripts/bootstrap-practice.sh
这个脚本会做三件事:
- 检查
gcc、RISC-V 工具链、QEMU 是否存在 - 进入
labs/lab01-step1/ - 自动执行一次
make clean && make test
如果你想手动确认它最后做了什么,也可以自己继续执行:
cd labs/lab01-step1
make clean && make test
0.7 第一次看到 FAIL,为什么反而是正常的
第一次进入 Lab01,你看到的通常不是全通过,而是若干个 [FAIL]。这并不意味着环境损坏,恰恰说明三件事已经成立:
- lab 骨架已经能编译
- 自动验证程序已经能运行
- 本章对应的待实现函数确实还没写,所以失败被清楚地暴露出来了
这门课真正的出发点不是“你已经拥有一个完成的编译器”,而是“你已经进入一个可以逐章把它做出来的状态”。
0.8 如果 smoke test 跑不起来,先查什么
如果这里没有跑到预期输出,先按这个顺序排查:
| 现象 | 先检查什么 |
|---|---|
gcc: command not found |
主机编译器没有装好 |
riscv64-unknown-linux-gnu-gcc: command not found |
交叉工具链没装好 |
qemu-riscv32: command not found |
QEMU 用户态模拟器没装好 |
No such file or directory |
当前目录是不是 miniCompiler_lab 仓库根目录 |
make 阶段失败 |
先看是编译错误、链接错误,还是验证程序报错 |
如果你只是第一次看到若干 [FAIL],那不属于故障,不要在这里过度排错,直接进入下一章即可。
0.9 本章小结
这一章没有让你开始写 lexer,也没有带你看文法规则。它只完成了一件更基础的事:把你准确送到正式课程入口。
现在你已经知道:
- 工具链是否齐全
- 课程网页和 lab 仓库分别扮演什么角色
- 第一个 lab 在哪里
- 第一次 smoke test 为什么会失败,以及为什么这正是正常现象
这就够了。真正的知识点,从下一章开始。
0.10 下一步
现在直接进入:
Practice 与实验说明
从 Chapter 1 开始,这门课就不再只是阅读材料,而是要求你真的动手写代码。后面的每一章都会配一个对应 lab。chapter 负责把这章为什么存在、这个概念到底解决什么问题讲清楚,lab 负责把这一章真正该补的代码边界交给你。
你可以把这里理解成“实践入口”,但不要把它当成题单。
先把 lab 仓库拉到本地
读课程网页时,你只需要拉取这个 lab 仓库:
git clone https://github.com/Luyoung0001/miniCompiler_lab.git
cd miniCompiler_lab
bash scripts/bootstrap-practice.sh
后面所有需要你修改的代码,都在这个仓库里。课程网页会用 labs/... 描述实验位置;这些路径都以 miniCompiler_lab 仓库根目录为起点。
lab 仓库地址是:
https://github.com/Luyoung0001/miniCompiler_lab.git
你应该怎样使用这里
最推荐的节奏始终是:
- 先读对应 chapter
- 再进入对应 lab,读
TASK.md - 只修改本章指定的
framework/student.c - 运行
make clean && make test
这个顺序很重要。因为这门课不是让你只对着测试输出来猜实现,而是希望你先知道“为什么现在轮到这件事”,再去写代码。
这套 practice 怎样组织
labs/ 把每一章真正关键的子问题单独抽出来,做成边界清晰的 scaffold。你不需要在一个庞大的完整工程里到处找入口,只需要跟着当前 chapter,进入对应 lab,完成 framework/student.c 里的 TODO。
也就是说:
- 你会在
labs/lab02-step2/framework/student.c里完成词法分析这一章的核心逻辑 - 你每完成一章,就用该 lab 的
make clean && make test验证一次
这样做的好处是,lab 的代码范围会小很多,你能更快把注意力放在这一章真正想教的点上。
目录长什么样
最重要的是 labs/:
labs/
├── lab01-step1/
├── lab02-step2/
├── lab03-step3/
├── lab04-step4/
└── lab05-step5/
每个 lab 都是一个最小练习单元。典型结构是:
lab02-step2/
├── Makefile
├── TASK.md
├── sample.c
└── framework/
├── student.c
├── student.h
├── verify.c
└── verify.h
这几份文件的角色很固定:
TASK.md:这一章实验到底要做什么framework/student.c:你主要修改的文件framework/verify.c:自动验证程序,不改Makefile:这一章的编译与测试入口
五个 lab 分别在练什么
| Lab | 对应 Chapter | 练习主题 | 主要验证现象 |
|---|---|---|---|
lab01-step1 |
Chapter 1 | 命令行参数、文件读取、固定汇编输出 | 能解析参数、读文件、写出最小汇编骨架 |
lab02-step2 |
Chapter 2 | 词法分析器基础 | token 流类型和值正确 |
lab03-step3 |
Chapter 3 | 递归下降解析与 AST | AST 结构符合程序层次 |
lab04-step4 |
Chapter 4 | 符号表与语义检查 | 能报出未定义变量、重复定义等错误 |
lab05-step5 |
Chapter 5 | RISC-V 代码生成 | 汇编能生成并在 QEMU 中运行 |
推荐工作流
从 Lab01 开始,后面每一章都尽量保持同样的工作法:
- 读 chapter,先建立这一章的直觉
- 打开对应
TASK.md - 只改指定的
student.c - 运行
make clean && make test - 对照验证输出和常见失败继续修正
不要一上来就把整个 student.c 一次性写完。对于编译器项目来说,最危险的不是“代码短”,而是“看起来每一行都彼此有关”。把一章的任务拆开、跑通、验证,才是最稳的推进方式。
现在从哪里开始
如果你刚完成 Chapter 0,下一步就是:
- 读 Chapter 1
- 打开 Lab01 的任务说明
- 开始修改
labs/lab01-step1/framework/student.c
Chapter 1 — step_1 编译器最小驱动与文件 I/O
对应实践:Lab01 - step_1 最小驱动 主要修改文件:
miniCompiler_lab/labs/lab01-step1/framework/student.c
很多人一开始学编译器,脑子里最先冒出来的都是“文法”“AST”“代码生成”。但真正落到一个可运行项目时,第一步反而没有那么学术。你得先解决一个更朴素的问题:这个程序到底怎样从命令行拿到输入文件,怎样把源文件读进内存,又怎样把第一份汇编文件写出去。
这不是绕远路。恰恰相反,这是编译器项目最早的生存线。如果连这一层都没有,后面的 lexer、parser、semantic pass 就只能停在函数级玩具里,永远没有机会被串到一次真实的“读源文件 -> 产出目标代码”的工作流里。
1.1 为什么正式课程要从这里开始
第一章的编译器还没有真的理解 C 语言。它甚至不会分析 token,更不会构建 AST。它做的事情非常克制:处理命令行参数、读取输入文件,然后输出一份固定的 RISC-V 汇编模板。
表面上看,这很“简陋”;但从课程设计角度看,它刚好建立了后面整门课的工作地面:
- 你第一次得到一个真正的
minicc input.c -o out.s - 你第一次看到“编译器输出的不是可执行文件,而是汇编”
- 你第一次把 C 源程序和后续 RISC-V 工具链串起来
等这条最短链路打通以后,后面每一章就不再是孤立的算法练习,而是在同一个编译器外壳里不断替换和增强内部能力。
1.2 这一章结束后你应当会什么
做完这一章,你至少应当能回答下面几个问题:
- 为什么一个编译器项目在最早阶段就需要命令行参数和文件 I/O
input.c -> out.s这条链路里,编译器此刻负责哪一段,交叉工具链又负责哪一段-o、-v这种命令行选项为什么值得在第一章就实现- 为什么即使输出的是固定汇编模板,这一步仍然是“真实编译器工作流”的一部分
然后在实践里,你会把这四个认识压缩成三件具体事情:
- 解析命令行
- 读取源文件
- 写出最小汇编骨架
1.3 先看本章数据流
先别急着写代码,先把这一章的数据流看清楚:
命令行参数 -> 输入文件路径 -> 读取源码文本 -> 写出固定汇编模板
进入 lab 之后,你会在 framework/student.c 里补完三个函数。不要试图一上来把所有细节都写满,而要先带着三个问题去读 TASK.md 和验证程序:
- 参数解析怎样记录输入文件、输出文件和详细模式
- 文件读取怎样把整份源文件读成一块连续内存
- 汇编输出函数怎样往输出文件里写一份最小的 RISC-V 程序
在这一章里,这三件事比“它生成的汇编到底聪不聪明”更重要。因为此刻我们要建立的是编译器作为一个程序怎样活起来,而不是立刻建立“它怎样理解语言”。
1.4 固定汇编输出为什么不是假动作
第一次看到固定输出时,很多同学会下意识问一句:既然它还没分析源代码,这不就是“假装在编译”吗?
这句话只对了一半。
它当然还没有进入真正的语言理解阶段,但它已经完成了三件非常真实的工程动作:
- 接受一个源文件作为输入
- 产生一个汇编文件作为输出
- 把输出交给后续工具链继续汇编、链接和运行
这一步的意义,不在于它生成的汇编多聪明,而在于它第一次把编译器前端程序和目标平台工具链接到同一条流水线上。
只要这条线已经连上,后面你给它换成真正的 token 流、AST、语义检查、代码生成,整个项目依然站得住。
1.5 这一章的 practice 边界
现在进入本章实践:
- lab 路径:
miniCompiler_lab/labs/lab01-step1/ - 主要修改:
framework/student.c - 验证命令:
cd labs/lab01-step1
make clean && make test
这一章的 lab 没有让你重写整份 minicc.c,而是把最关键的三段抽了出来:
student_parse_argsstudent_read_filestudent_emit_stub_program
这样的拆法是刻意的。它把“最小驱动”压成了三个可以单独验证的动作。只要你先把这三步的意图看明白,后面的代码就不会显得杂乱。
1.6 你在写的不是“打印字符串”,而是在搭编译器出口
student_emit_stub_program 看起来最容易被误解。因为它做的事情好像只是往文件里 fprintf 几行文本。
但这里真正值得你建立的直觉是:
对编译器来说,汇编文件不是日志,不是调试输出,而是它对外部世界交付的正式产物。
也就是说,从这一章开始,你就应该把“生成文本”理解成“生成目标代码”。哪怕此刻这份目标代码还很简单,它的角色已经确定了。
后面课程再往前走,你做的并不是把“输出文件”这个概念推翻重来,而只是把固定模板逐步替换成真正从 AST 推导出来的汇编。
1.7 做 lab 时最容易犯的三个误解
第一种误解是:觉得参数解析只是边角料。
不是。没有清晰的参数入口,后面你很难区分“编译器坏了”还是“输入命令不对”。
第二种误解是:觉得读文件只是模板代码。
也不是。后面 lexer 扫描的整条 token 流,起点就是这里读进来的字符缓冲区。
第三种误解是:觉得固定汇编输出没有学习价值。
还是不对。它第一次把“编译器输出是汇编”这件事 concretely 放到了你手上。
1.8 本章小结
这一章故意没有立刻进入词法分析。因为在你开始理解语言之前,先得有一个真正能接收输入、读文件、写出结果的编译器外壳。
只要这个外壳已经站住,后面的 lexer、parser、semantic、codegen 就不再是分裂的小题,而是不断填进同一个项目身体里的器官。
1.9 下一步
现在去做:
- Lab01 - step_1 最小驱动
- 跑通
make clean && make test - 然后继续进入 Chapter 2 — 词法分析器
Chapter 2 — step_2 词法分析器:把源码切成 token 流
对应实践:Lab02 - step_2 词法分析器 主要修改文件:
miniCompiler_lab/labs/lab02-step2/framework/student.c
到了这一章,编译器第一次开始真正“看懂一点源码”。但注意,这里的“看懂”还非常有限。词法分析器并不知道一条 if 语句是否合法,也不知道 x + y * z 的优先级是什么。它只负责做一件事:把一串连续字符切成一串更有结构的 token。
这一步之所以重要,是因为后面的语法分析器不适合直接面对原始字符流。对 parser 来说,if、return、123、+、{ 这些词法单元已经比单个字符更接近语言结构。
2.1 为什么上一章的能力已经不够了
在 Chapter 1 里,编译器已经能读文件、写输出,但它对输入内容本身几乎一无所知。无论你给它的是 return 0;,还是一段完全无意义的字符串,它都只会照样吐出固定汇编模板。
这说明第一章建立的是编译器壳子,而不是语言前端。
从这一章开始,项目第一次需要回答这样的问题:
- 这个单词是关键字还是普通标识符?
==和=为什么不能当成同一个东西?- 注释和空白为什么不应该进入后面的语法阶段?
这几个问题看起来零碎,但其实都指向同一件事:先把字符流整理成 token 流。
2.2 先建立一个正确直觉:token 不是“单词”,而是分类后的片段
很多初学者会把 token 粗糙地理解成“源码里的一个词”。这个说法不算完全错,但不够准确。
更好的理解是:
token 是源码中一个被识别、被分类、并且足以交给下一阶段继续处理的片段。
例如在下面这段代码里:
if (sum > 25) {
return 1;
}
词法分析器看到的不是一句完整控制流语句,而是一段段被分类的片段:
if-> 关键字(-> 左括号sum-> 标识符>-> 比较运算符25-> 数字字面量)-> 右括号{-> 左花括号return-> 关键字1-> 数字;-> 分号}-> 右花括号
一旦这一步完成,后面的 parser 就不用再去猜字符组合,而是可以直接在 token 层面谈结构。
2.3 这一章最该抓住的三类动作
词法分析器看起来分支很多,但先不要被所有 token 类型压住。它最核心的动作可以压成三类:
读取当前位置 -> 判断片段类型 -> 生成一个 token 并推进光标
做 lab 时也按这个顺序理解:
- 状态推进:
peek、peek_next、advance - 片段扫描:标识符、数字、字符串
- 主分发入口:
lexer_next
如果你一开始就陷进所有 case 分支里,很容易被细节淹没。更有效的观察方式是先看“它怎样移动光标,怎样跳过不重要的东西,怎样把重要东西打包成 token”。
2.4 空白和注释为什么要在这一层就处理掉
这一章最容易被低估的函数之一,是跳过空白与注释的逻辑。
这件事之所以应该发生在 lexer,而不是 parser,原因很简单:空格、换行、注释属于书写层面的噪声,不属于语言结构本身。对语法分析来说,return 0; 和
return 0; // comment
如果语义完全一样,那就不应该让 parser 去反复应付这些干扰。
也就是说,lexer 的一项重要职责,不只是“认出有用的 token”,还包括“提前丢掉后面阶段不该关心的东西”。
2.5 关键字和标识符为什么要区分
从字符形状上看,if 和 sum 很像。它们都满足“字母开头,后面跟字母、数字或下划线”。
如果你只靠字符规则扫描,它们都会先落在“像标识符”的这类片段里。真正让 if 变成关键字的,不是扫描方式变了,而是你在扫描完成后,又查了一次关键字表。
这一步非常值得你建立直觉,因为它会反复出现:编译器经常先按一个宽规则识别,再按更具体的语义做二次分类。
2.6 本章 practice 边界
这一章的 practice 路径是:
miniCompiler_lab/labs/lab02-step2/
主要修改文件:
framework/student.c
验证命令:
cd labs/lab02-step2
make clean && make test
本章 lab 留给你的不是整份 lexer,而是三段最有代表性的骨架:
student_skip_whitespacestudent_scan_identifierstudent_scan_number
这三个函数刚好覆盖了 lexer 最核心的三类动作:
- 跳过无用噪声
- 扫描名字类 token
- 扫描数字类 token
只要你把这三件事写稳,lexer_next 这类主分发逻辑就会突然变得清晰很多。
2.7 本章最该盯住的验证现象
这一章不要只盯着“测试通过没通过”。你更应该观察:
- token 类型是否对
- token 的值是否对
- 注释和空白是否真的被跳过去了
if、return这类词是否被识别成关键字,而不是普通标识符
只要这四类现象稳定,说明你的 lexer 已经具备把字符流交给 parser 的资格。
2.8 本章小结
词法分析器做的不是“理解程序含义”,而是先把连续字符整理成更适合继续推理的 token 流。它是语法分析的前门,没有这一步,后面的 AST 根本无从谈起。
2.9 下一步
现在去完成:
- Lab02 - step_2 词法分析器
- 观察 token 序列是否和讲义预期一致
- 然后继续进入 Chapter 3 — 语法分析器与 AST
Chapter 3 — step_3 递归下降解析器与抽象语法树
对应实践:Lab03 - step_3 语法分析与 AST 主要修改文件:
miniCompiler_lab/labs/lab03-step3/framework/student.c
到了这一章,编译器终于要开始处理“结构”了。token 流虽然已经比字符流干净很多,但它本质上仍然是一条线。而程序不是一条线。表达式有优先级,if 有条件和分支,函数定义有函数名和函数体,代码块里还会再套语句。
词法分析器负责把原材料切好;语法分析器负责把这些片段重新组装成有层次的树。这棵树,就是 AST。
3.1 为什么 token 流还不够
看下面这段代码:
int sum = x + y * z;
如果你只停在 token 流层面,它只是:
INT IDENT ASSIGN IDENT PLUS IDENT STAR IDENT SEMICOLON
但这串 token 里没有直接告诉你一个关键事实:这里应该先算 y * z,再和 x 做加法。
这说明 token 流只保存了“顺序”,还没有保存“结构关系”。而 parser 的工作,就是把这种结构关系补出来。
3.2 AST 为什么叫“抽象”语法树
很多人第一次看到 AST,会疑惑:为什么不是把源码原样存下来,而要“抽象”?
原因是编译器后面的阶段不需要保留所有书写细节。它真正关心的是:
- 这是一个变量声明,还是一个返回语句
- 这是一个二元运算,操作符是什么
- 这是一个
if,条件、then 分支、else 分支分别是什么
像多余空格、注释、甚至某些书写层面的细枝末节,都已经不重要了。AST 保留的是对后续语义检查和代码生成有意义的结构,而不是源码的排版外观。
3.3 递归下降为什么适合这门课
这门课选择的 parser 风格是递归下降。理由不是它“理论上最强”,而是它特别适合让你把语法结构和代码结构一一对应起来。
递归下降解析器里常见的一组函数:
parse_primaryparse_factorparse_termparse_comparisonparse_statementparse_function
它们并不是随手分出来的。它们正对应着“从最小表达式单元一路往上包”的过程。
也就是说,递归下降的一个巨大优点是:你几乎能直接从函数名看出 parser 正在哪一层结构上工作。
3.4 优先级不是额外补丁,而是函数层次本身
这一章特别值得建立一个直觉:表达式优先级并不是最后临时修一修,而是 parser 分层时就应该体现出来。
例如:
parse_factor处理乘除parse_term在它上面处理加减parse_comparison再往上处理<、>、<=、>=
这种分层一旦搭好,优先级几乎是自然长出来的,而不是靠后期打补丁硬拼出来的。
所以当你在 lab 里补 parse_primary 或观察 parse_if 的结构时,不要只把它当成几个函数分支。你真正该看到的是:语法层次正在通过函数调用层次显影。
3.5 parser 和 AST 为什么要分成两件事
这一章要同时抓住两类逻辑:
parser:怎样识别结构
AST:怎样保存结构
parser 负责“怎样认结构”,AST 负责“怎样把结构装进节点里”。
阅读时先问自己两个问题:
- 当 parser 识别出一个结构时,它会创建哪种 AST 节点
- 这类节点在后续语义分析和代码生成里为什么有用
只要你始终盯着“识别 -> 建树”这条线,parser 和 AST 就不会裂成两份互不相干的知识。
3.6 本章 practice 边界
这一章的 practice 路径是:
miniCompiler_lab/labs/lab03-step3/
主要修改文件:
framework/student.c
验证命令:
cd labs/lab03-step3
make clean && make test
本章 lab 故意没有把整份 parser 都掏给你,而是把最有代表性的三个结构节点留成了 TODO:
student_parse_primarystudent_parse_ifstudent_parse_function
为什么是这三个?
parse_primary是表达式树真正开始生长的地方parse_if是控制流结构第一次出现的地方parse_function把整个程序的最外层骨架搭起来
只要你把这三个点理解透,递归下降解析器的整体轮廓就已经建立起来了。
3.7 本章最值得观察的现象
你在这一章验证时,最该看的是 AST 的形状,而不是单个 token。
尤其要确认:
- 程序根节点是不是
Program main函数是不是被挂在函数定义节点下面if的条件和分支是不是分开保存x + y * z这种表达式的树形是不是体现了优先级
只要树形关系对了,后面的语义分析和代码生成才有可靠的输入。
3.8 本章小结
这一章让编译器第一次拥有“结构感”。从这里开始,输入不再只是 token 列表,而是已经能表达程序层次关系的 AST。后面无论做类型检查还是生成汇编,本质上都建立在这棵树上。
3.9 下一步
现在去完成:
- Lab03 - step_3 语法分析与 AST
- 对照验证输出,确认 AST 形状正确
- 然后继续进入 Chapter 4 — 符号表与语义分析
Chapter 4 — step_4 符号表、作用域与语义分析
对应实践:Lab04 - step_4 语义分析 主要修改文件:
miniCompiler_lab/labs/lab04-step4/framework/student.c
到了这里,编译器已经能把源码变成 AST。但“能建树”不代表“程序合法”。一份语法完全正确的程序,仍然可能犯很多错误:使用了没定义的变量、同一作用域里重复定义变量、把不兼容的类型硬塞到一起。
这些问题已经不是词法问题,也不是语法问题。它们属于语义分析阶段。
4.1 为什么 AST 正确还不够
看下面这段代码:
int main() {
int x = 10;
int sum = x + undefined_var;
int x = 20;
return 0;
}
从 token 角度看,它很正常。
从 AST 角度看,它也完全能建出来。
但你一眼就知道它有问题:
undefined_var没有定义- 同一个作用域里
x被重复定义
这正是语义分析存在的理由。它要回答的问题不再是“这段代码像不像 C”,而是“这段代码在语言规则下是否合法”。
4.2 符号表到底解决什么问题
编译器要判断一个名字是否合法,最基本就得知道两件事:
- 这个名字有没有出现过
- 它在哪个作用域里出现过
符号表就是为这个问题准备的。
你可以把它理解成编译器在分析过程里维护的一本“登记册”。每当看到变量声明或函数定义,就把名字记进去;每当看到标识符使用,就去查这本册子。如果查不到,就是未定义;如果当前作用域里已经有同名条目,就是重复定义。
4.3 作用域为什么不能只靠一个平面表
如果程序永远没有代码块,那么一个平面链表也许勉强够用。但只要有 { ... }、函数体、条件分支里的局部声明,问题就变了。
例如:
int main() {
int x = 1;
{
int x = 2;
return x;
}
}
这里内层的 x 并不是非法重复定义,而是一个新的、遮蔽外层名字的局部变量。
这说明编译器必须能区分“当前作用域”和“父作用域”。
所以本章的符号表不能是单张平面表,而应该是带 parent 指针的作用域链。进入新块时进入新作用域,离开时再退回父作用域。这种结构会一路影响到后面的代码生成,因为变量栈偏移本身也依赖作用域中的符号信息。
4.4 语义分析器真正做的事
语义分析并不是对整棵 AST 做一次抽象的“检查”。它更像一次带上下文的遍历:
- 看到变量声明时,检查当前作用域能不能加这个名字
- 看到标识符时,查符号表看它有没有定义
- 看到赋值时,确认左边名字存在、右边表达式类型合理
- 进入 block 时切换作用域
- 离开 block 时恢复作用域
也就是说,语义分析的关键不在“遍历”本身,而在“遍历时手里一直带着符号表上下文”。
4.5 现在最值得看的错误输出
当语义分析发现错误时,你应当期待类似这样的输出:
语义错误 [7:19]: 未定义的变量 'undefined_var'
语义错误 [10:9]: 变量 'x' 重复定义
编译失败: 2 个语义错误
这组输出很重要,因为它第一次让你看到编译器不只是“生成东西”,还要“拒绝不合法输入”。对于真实编译器来说,这不是附加功能,而是核心职责之一。
4.6 本章 practice 边界
这一章的 practice 路径是:
miniCompiler_lab/labs/lab04-step4/
主要修改文件:
framework/student.c
验证命令:
cd labs/lab04-step4
make clean && make test
这一章 lab 会把语义分析压成三个核心动作:
student_symtab_addstudent_symtab_lookupstudent_semantic_analyze
这三个动作正好覆盖了语义阶段最关键的闭环:
- 记录名字
- 查找名字
- 带着符号上下文走完整棵树并统计错误
4.7 本章最容易误判的地方
这一章最常见的误判有两个。
第一种是把“父作用域里存在同名变量”也当成重复定义。
这是不对的。重复定义说的是当前作用域里重复,不是全局禁止重名。
第二种是查找变量时只查当前作用域。
这也不对。很多合法引用本来就应该向父作用域回溯。
如果这两个边界没想清楚,你的语义分析器很容易在简单样例上看起来能跑,但一遇到嵌套 block 就开始乱报错。
4.8 本章小结
从这一章开始,编译器第一次真正站到“语言规则裁判”的位置上。AST 只是结构,语义分析才决定这棵树到底能不能进入下一阶段。
后面的代码生成之所以值得信任,前提正是:输入给它的 AST 已经经过语义筛查,不再是“长得像程序”的任何东西。
4.9 下一步
现在去完成:
- Lab04 - step_4 语义分析
- 观察未定义变量和重复定义是否被正确报出
- 然后进入 Chapter 5 — RISC-V 代码生成
Chapter 5 — step_5 RISC-V 代码生成与 QEMU 运行
对应实践:Lab05 - step_5 代码生成 主要修改文件:
miniCompiler_lab/labs/lab05-step5/framework/student.c
前面的章节一直在做“理解程序”这件事:读文件、切 token、建 AST、做语义检查。到了这一章,编译器终于要把这种理解变成对目标机器真正有用的东西:RISC-V 汇编。
这一步和前面最大的不一样在于:从这里开始,你写错一行逻辑,错误不一定只体现在“树形不对”或“报错信息不对”,而可能直接变成程序运行结果错误、返回值不对、甚至汇编根本汇不起来。
5.1 为什么代码生成不是“把 AST 翻译成文本”这么简单
表面上看,代码生成器做的事情好像只是 fprintf 汇编字符串。
但它真正同时要处理的事情有四类:
- 表达式结果该落在哪个寄存器里
- 临时结果需要怎样压栈和弹栈
- 局部变量在当前栈帧中的偏移是多少
if、while、return这些控制流要怎样变成跳转和标签
也就是说,代码生成器不是“会打印文本”就够了,而是必须带着一个很明确的目标机器执行模型去打印文本。
5.2 这一章最重要的直觉:栈帧不是附属细节
在代码生成这一章里,你会不断遇到这些名字:
sps0ra- 栈偏移
- prologue / epilogue
这些东西不是汇编里的仪式感摆设,而是局部变量、函数调用和返回过程真正赖以成立的基础设施。
如果你不知道变量 sum 在栈帧里离 s0 偏移多少,你就没法把 Ident: sum 变成正确的 lw 指令。
如果你不知道返回地址该如何保存和恢复,函数调用一多,程序流程就会直接乱掉。
所以这一章要建立的不是“几条汇编模板”,而是“AST 节点怎样落到具体机器状态”。
5.3 先看一个最真实的闭环
从这一章开始,验证不再只是“结构对不对”,还要看生成的汇编能不能被工具链接受。你会反复遇到这个闭环:
source.c -> token -> AST -> semantic check -> RISC-V asm -> qemu
这里有两件非常关键的事情:
- 编译器真的生成了一份 RISC-V 汇编
- 这份汇编真的被汇编、链接并交给
qemu-riscv32跑了起来
前面所有章节,都是在为这里铺路。
5.4 表达式代码生成为什么总绕不开栈
当你生成 x + y * z 这样的表达式时,不可能把所有中间结果都同时留在一个寄存器里。所以常见做法是:
- 先算右边
- 暂时压栈
- 再算左边
- 把右边弹出来放到另一个寄存器
- 执行具体运算
你在 lab 里接触到的 addi sp, sp, -4、sw、lw、addi sp, sp, 4,本质上都在做这件事。
这类代码第一次看会觉得繁琐,但只要你抓住“一个子表达式结果要暂存,栈就是最直接的临时仓库”这个直觉,很多看似机械的指令序列就会变得顺眼。
5.5 if 和 while 为什么离不开标签
高级语言里的 if 和 while 写起来像结构,落到汇编里却只能变成跳转。
这就是为什么代码生成器要维护 label_count,不断生成 .L1、.L2 这种局部标签。
在 AST 里你看到的是:
- 条件
- then 分支
- else 分支
在汇编里你最终要安排的是:
- 条件为假时跳去哪里
- then 分支执行完后跳去哪里
- 循环体结束后回到哪里
也就是说,控制流代码生成本质上是在把树结构重新压平为带标签的跳转图。
5.6 本章 practice 边界
这一章的 practice 路径是:
miniCompiler_lab/labs/lab05-step5/
主要修改文件:
framework/student.c
验证命令:
cd labs/lab05-step5
make clean && make test
这一章的 lab 会把代码生成器收缩成三块最关键的骨架:
student_get_var_offsetstudent_gen_exprstudent_gen_stmt
这三块刚好覆盖了后端最关键的三个视角:
- 变量在栈里的位置怎么找到
- 表达式怎么落成寄存器与指令
- 语句和控制流怎么落成跳转和函数结构
5.7 这一章真正的验证,不是“打印对了”,而是“跑起来了”
这一章一定要把验证目标盯准。
如果你只是看输出文件里“好像有几条 add、lw、sw”,那还不够。
真正值得相信的验证是:
- 汇编可以通过
as和ld qemu-riscv32能运行它- 程序退出码和预期一致
因为只有到这一步,编译器生成的代码才算真的接触到了目标机器语义。
5.8 本章小结
这一章把整门课真正收口了。前面的前端和语义阶段负责把程序理解清楚,这一章负责把这种理解变成目标机器能够执行的汇编。
到这里,minicc 已经不再只是一个“会打印 token 和 AST 的教学程序”,而是一个可以把 C 子集编译到 RISC-V 并实际运行的迷你编译器。
5.9 下一步
现在去完成:
- Lab05 - step_5 代码生成
- 让生成的汇编在
qemu-riscv32中真正跑起来 - 然后回看附录,补齐你还想深入的部分