TT Lab
开始
学习 学习路径 课程

编译器 — 从头到尾亲手打造一门小语言

负责翻译的程序与负责执行的程序

在 TT Lab 中继续学习

一句话总结

编译器是把程序不经执行、直接翻译成另一种语言的程序,解释器则是一边读取程序一边立刻执行的程序。两者共用同样的前端(把字符切成词法单元,把词法单元组成树,再检查树的含义),到最后才分道扬镳:是“现在就计算”,还是“留下以后再计算的代码”。

为什么需要它

CPU 读不懂 x = (a + 1) * 2。它能读的只有 add、imul 这样的指令和寄存器编号。人写起来顺手的文字与机器能运行的指令之间有一段距离,总得有人来填平,而填平的时机有两种。

实际工作中,两者是混在一起的。Python 先把源码编译成字节码(__pycache__ 里的 .pyc),再解释执行这些字节码。Java 由 javac 生成字节码,JVM 起初解释执行,只把经常运行的方法编译成机器码(JIT)。所以比起“这门语言是不是编译型语言”,“哪个阶段在什么时候做”才是更准确的问题。

工作原理

编译器不是一整块,而是一串阶段。前一个阶段的输出就是后一个阶段的输入,本课程的模块也完全按这条流水线展开。

소스 글자 ──렉서──▶ 토큰 ──파서──▶ 트리(AST) ──의미 분석──▶ 검사된 트리
   (2모듈)            (3모듈)              (4모듈)
                                              │
            ┌─────────────────────────────────┼──────────────────────────┐
            ▼                                 ▼                          ▼
     트리를 걸으며 실행               바이트코드 + 스택 VM        중간 표현 → 최적화 → x86-64
        (5모듈)                            (6모듈)                  (7·8·9모듈)

gcc 也把同样的流水线拆成几个程序来运行。平时被一行 gcc hello.c 遮住了,看不见,但可以通过选项让它在每个阶段停下来。

停下来的选项 做了什么 产物
-E 预处理——展开 #include,替换 #define .i(仍是 C)
-S 编译——把 C 变成汇编 .s(文本)
-c 汇编——把汇编变成机器码 .o(可重定位目标文件)
(无) 链接——把目标文件和库连接在一起 可执行文件

目标文件里还留着地址尚未确定的名字。hello.o 调用的 printf 在 libc 中,所以 nm hello.o 会在这个名字前打印 U(undefined)。填上这个空缺的是链接器。只在链接阶段才会出现的错误(“undefined reference to …”)之所以单独存在,原因就在这里——编译只看一个文件,链接则看全部。

解释器和编译器是否遵守同一种含义,也需要单独确认。本实验的计算器像 C 一样把除法向 0 截断(-7 / 2 得 -3)。Python 的 // 则向下取整(-4),所以用 Python 写解释器时如果用了 //,同一个程序在解释器和编译出的可执行文件里会得出不同的答案。两种实现的含义是否一致,不会自动保证,只有实际运行并对照才能知道。本课程的评分器在几乎每个步骤都用随机输入与参考实现做对照,原因也是一样的。

在现场相遇的样子

贯穿本课程要做的语言叫“mini”。它只有 64 位整数和布尔值、let、print、if、while、fn、return,以及带优先级的二十来个运算符,仅此而已。它虽小,用来把词法分析器到 x86-64 代码串成一条线却绰绰有余,而且评分器能把每个阶段都真正运行一遍。

下一项实验要做什么

对 /opt/fixtures/mini/c/hello.c,用 gcc 在每个阶段停下,生成四个产物,再用 nm 查看目标文件里的空缺。然后在一个 rpn.py 里把 RPN 计算器做两遍:一个是切分成词法单元后用栈直接计算的解释器,一个是不执行、只跟踪栈深度的检查器,再加一个写出做同样计算的 C 代码、交给 gcc 烧制的编译器。最后用随机程序对照两条路是否得出同样的答案,并测量哪一边在什么时候更快。