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

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

让 gcc 中途停下,把计算器做两遍

在 TT Lab 中继续学习

目标

让 gcc 在预处理、编译、汇编、链接的每个阶段停下,检查各阶段的产物;再把同一个 RPN 计算用两种方式实现:直接计算的解释器和翻译成 C 再烧制的编译器,并对照两者是否得出同样的答案。

为什么重要

编译器和解释器用的是同样的前端,只在最后一步不同。就是这一步,决定了在什么时候抓错(执行前还是执行中)、速度如何(翻译成本分摊到多少次运行上),以及含义出现偏差的风险。如果用 Python 写的解释器照搬 Python 的除法规则,它与烧制成 C 的那一边在负数上就会得出不同的答案。本实验让你在一个小计算器上亲手遇到这种差异。

RPN 计算器的语言

낱말      정수(부호가 붙을 수 있음, 예: 7, -3) · x(실행할 때 받는 값) · + - * / %
뜻        낱말을 왼쪽부터 읽는다. 수는 스택에 올리고, 연산자는 두 값을 내려(먼저 내린 것이 오른쪽)
          계산한 결과를 올린다. 끝에 값이 정확히 하나 남아야 한다.
나눗셈    C 처럼 0 쪽으로 자른다: -7 / 2 = -3, 나머지의 부호는 나눠지는 수를 따른다: -7 % 2 = -1
예        "x 1 + 2 *" 는 (x + 1) * 2
오류 글자 bad token 'foo' at 3      (1부터 센 낱말 자리)
          stack underflow at 2      (그 연산자의 자리)
          expected one value, got 2 (끝에 남은 값의 수)
          division by zero

步骤

  1. 让 /opt/fixtures/mini/c/hello.c 在每个阶段停下,生成 /root/mini/stages/hello.i(预处理)、hello.s(汇编)、hello.o(目标文件)和 hello(可执行文件)。用 nm hello.o 确认尚未解析的名字(U)。
  2. 补全 /root/mini/rpn.py 的 tokenize(text)——返回按空白切分的单词列表,遇到不认识的单词时抛出 RpnError("bad token 'foo' at 3")。
  3. 补全 evaluate(tokens, x)——用栈直接计算的解释器。错误按上面的原样文字抛出 RpnError。
  4. 补全 check(tokens)——不执行,只跟踪栈深度:没有问题时返回最深时的格数,有问题时抛出与 evaluate 文字相同的 RpnError。除以 0 取决于 x,所以这里不做判断。
  5. 补全 to_c(tokens)——返回做同样计算的 C 程序文本。先用 check 检查,若程序有误,就不写 C,而是抛出 RpnError。x 取自 argv[1],结果输出为一行;除以 0 时输出 runtime error: division by zero 并以 1 退出。
  6. 补全 build(tokens, out)——用 gcc 依次生成 out.c → out.s → out.o → out,并且不删除中间产物。
  7. 把你的解释器与你的编译器相互对照。评分器会用 x 的四个值,把 15 个随机程序在两条路上都跑一遍——请确认在负数的除法和取余上两边不会出现分歧。
  8. 补全 bench(tokens, xs, out),把 /opt/fixtures/mini/rpn/big.rpn 在 x 取 0 到 199 时的测量结果原样写入 /root/mini/rpn_report.json。

参考

让 gcc 停四次

gcc 可以在 -E(预处理)、-S(编译)、-c(汇编)处停下,不加选项则会一直走到链接。把上一阶段的产物交给下一阶段作输入,就能一个阶段一个阶段地分开查看。.o 里还没有 printf 的地址,所以 nm 会把它标为 U。

切成单词

用 text.split() 切分后,逐个判断单词是 x、运算符还是整数。整数是最多带一个符号的数字串('--3' 不是整数)。位置从 1 开始数。

用栈直接计算——解释器

遇到运算符就弹出两个值。先弹出的是右操作数。除法先用 abs 求商,再加上符号,向 0 截断;余数用 a - 商*b 计算,就和 C 一致了。

不执行,只做检查

只数栈的深度,不管值。数加 1,运算符弹出两个、压入一个,所以减 1,并且在此之前要确认深度至少是 2。最深时的值就是 C 数组的大小。

把同样的计算翻译成 C——编译器

C 里同样设一个栈数组和一个格号(sp),每个单词就能原样翻译成一行。数组大小取 check 测得的深度。C 的 / 和 % 本来就向 0 截断,不需要额外处理,但除以 0 会让进程崩溃,所以要在那之前做检查。

C → 汇编 → 目标文件 → 可执行文件

第 1 步手工做过的四个阶段,现在用 subprocess 依次调用(gcc -S、gcc -c、gcc)。只要有一个阶段失败,就把 gcc 错误的最后一行放进 RpnError 抛出。

对照两条路是否得出同样的答案

这一步不用写新代码。评分器会把随机程序分别交给你的 evaluate 和 build 的结果运行。如果没通过,先检查负数除法和余数的符号——解释器如果遵循 Python 的规则,就会与 C 产生分歧。

测量编译什么时候划算

用 time.perf_counter 测三段:每个值各做一次 evaluate、一次 build、每个值各启动一次可执行文件。启动进程的成本可能比计算本身还大,这一点也要用数字体现出来。same 表示两个结果列表是否相同。