让 gcc 中途停下,把计算器做两遍
目标
让 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
步骤
- 让
/opt/fixtures/mini/c/hello.c在每个阶段停下,生成/root/mini/stages/hello.i(预处理)、hello.s(汇编)、hello.o(目标文件)和hello(可执行文件)。用nm hello.o确认尚未解析的名字(U)。 - 补全
/root/mini/rpn.py的tokenize(text)——返回按空白切分的单词列表,遇到不认识的单词时抛出RpnError("bad token 'foo' at 3")。 - 补全
evaluate(tokens, x)——用栈直接计算的解释器。错误按上面的原样文字抛出RpnError。 - 补全
check(tokens)——不执行,只跟踪栈深度:没有问题时返回最深时的格数,有问题时抛出与 evaluate 文字相同的RpnError。除以 0 取决于 x,所以这里不做判断。 - 补全
to_c(tokens)——返回做同样计算的 C 程序文本。先用check检查,若程序有误,就不写 C,而是抛出RpnError。x 取自argv[1],结果输出为一行;除以 0 时输出runtime error: division by zero并以 1 退出。 - 补全
build(tokens, out)——用 gcc 依次生成out.c→out.s→out.o→out,并且不删除中间产物。 - 把你的解释器与你的编译器相互对照。评分器会用 x 的四个值,把 15 个随机程序在两条路上都跑一遍——请确认在负数的除法和取余上两边不会出现分歧。
- 补全
bench(tokens, xs, out),把/opt/fixtures/mini/rpn/big.rpn在 x 取 0 到 199 时的测量结果原样写入/root/mini/rpn_report.json。
参考
- 工作位置是
/root/mini,开始实验时已经铺好了rpn.py的骨架(函数名和参数请保持不变——评分器按名字调用)。如果删掉了,用bash /opt/lab/minic/prepare.sh cc-pipeline-lab恢复。 - 常见错误:除法直接使用 Python 的
//和%,从 0 开始数运算符的位置,在 check 中真的去计算、把除以 0 当成错误抛出。 - 会话从 60 分钟开始,可以用+时间延长,结束后
/root/mini会消失。想保留的代码请另外转存。
让 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 表示两个结果列表是否相同。