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

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

统计并读懂 gcc 与 clang 的输出

在 TT Lab 中继续学习

目标

做出一个把 gcc 的 -S 汇编和 clang 的 LLVM IR 按函数切开来数的工具 readasm.py,并用它以数字确认 -O0 与 -O2(-O1)的差别——内联、常量折叠、除法换乘法、尾递归变循环、mem2reg 与 φ。

为什么重要

不知道优化做了什么,遇到性能问题就只能靠猜,遇到只在 -O2 下出现的 bug 就会去怀疑编译器。直接读输出,两者都能确认。评分器会在评分时重新编译改了常量和除数的变体 C,所以你的工具必须按规则去读,才能通过。

规则

어셈블리(gcc -S)   맨 앞이 글자·_·. 인 'name:' 줄이 이름표. '.' 으로 시작하면 안쪽 이름표(.L3 등),
                   아니면 함수의 시작. 들여 쓴 줄 가운데 '.' 으로 시작하지 않는 것이 명령. # 뒤는 주석.
                   명령은 앞뒤 공백을 걷고 명령어와 피연산자 사이 공백을 하나로('movl\t$1, %eax' → 'movl $1, %eax')
functions(asm)     {함수: [명령…]} (이름표·지시어 제외) · count(asm, f) 는 그 수(endbr64 포함)
calls(asm, f)      call·callq 의 대상(나온 순서), '@PLT' 는 뗀다
returns_constant   endbr64 를 뺀 몸체가 [mov(l|q) $값, %eax|%rax ; ret] 이면 그 값, [xor %eax, %eax ; ret] 이면 0, 아니면 None
div_magic(asm, f)  f 안 첫 movabs(q) 의 즉시값을 부호 없는 64비트로 본 '0x' + 16자리 소문자 16진수, 없으면 None
back_edges(asm, f) f 안에서 앞서 나온 안쪽 이름표로 가는 j* 명령의 수
IR(clang -emit-llvm)  'define … @이름(' 부터 맨 앞의 '}' 까지. 이름표 줄('3:')·빈 줄 제외, ';' 뒤는 주석
opcode(명령)       '%5 = add nsw i64 …' → add · 'store …' → store · tail/musttail/notail 은 건너뛴다
opcode_counts      {opcode: 수} · ssa_summary(O0 IR, O1 IR, f) → {"O0": {alloca, load, store, phi}, "O1": {…}}

步骤

  1. 补全 /root/mini/readasm.py 的 asm_lines(asm) 和 functions(asm)。请先做出材料:gcc -O0 -S -fno-asynchronous-unwind-tables /opt/fixtures/mini/real/opt.c -o opt-O0.s(-O2 也一样)。
  2. 补全 mnemonic、count 和 calls——能看到 -O2 下 square 的调用消失了。
  3. 补全 returns_constant——sum_to、always 和 never 在 -O2 下被折叠成了什么。
  4. 补全 div_magic——评分器也会用改了除数的变体来看。
  5. 补全 back_edges——fact 在 -O0 下是 call,在 -O2 下是循环。
  6. 补全 ir_functions、opcode 和 opcode_counts(clang -O0 -S -emit-llvm …,-O1 也一样)。
  7. 补全 ssa_summary。
  8. 把用你的 readasm 读出的值写入 /root/mini/real_report.json:fib_O0 和 fib_O2(opt.c 中 fib 的指令数)、fib_mini(用第 8 个模块的代码生成器为 /opt/fixtures/mini/programs/fib.mini 生成的汇编中 mini_f_fib 的指令数——用 python3 mini.py asm 生成)、sum_to_O2、div10_magic、fact_O2_calls(调用次数)、fact_O2_back_edges、fib_O1_phi(-O1 IR 中 fib 的 phi 个数)。

参考

把汇编切成函数

每一行都先去掉 # 之后的部分,把从行首开始的 'name:' 当作标号。以 '.' 开头的标号是当前函数的内部标号,否则就是新函数的开始。缩进的行里,只有不以 '.' 开头的才是指令,用 split(None, 1) 把空白压缩成一个。

指令数与调用

count 就是 functions 结果的长度(endbr64 也是指令)。calls 收集第一个单词是 call 或 callq 的指令的目标,并去掉 '@' 之后的部分。比较一下 sum_squares 在 -O0 和 -O2 下的调用列表。

折叠成一个常量的函数

只在去掉 endbr64 后,函数体恰好是两条指令、并且第二条是 ret 时才处理。如果第一条指令是 mov(l|q) $值, %eax(或 %rax),结果就是那个值;如果是用 xor 把 %eax 与自己清零,结果就是 0。

用乘法代替除法——魔数

从函数的指令开头往后看,找 movabs(或 movabsq)$数, %寄存器。gcc 用有符号十进制来写,所以要对 2 ** 64 取余,变成无符号数,再用 '0x%016x' 写出来。

递归是否变成了循环

用 asm_lines 依次查看一个函数的各行,把目前出现过的内部标号收集起来。以 j 开头的指令,如果目标是已经出现过的标号,就是向后的跳转。-O2 下的 fact,call 消失了,并且出现了这个数。

把 LLVM IR 切成函数

在 'define … @名字(' 这一行开启函数,在行首为 '}' 的那一行结束。中间的行去掉 ';' 之后的部分,如果是空行或者 '3:' 这样的标号,就去掉。opcode 在有 '=' 时取其后的第一个单词,tail、musttail 和 notail 要跳过。

数出栈格子变成 φ 的过程

对两份 IR 分别求出 opcode_counts,只取 alloca、load、store 和 phi 这四种(没有则为 0)。-O0 的 alloca 在 -O1 下变成 0,并出现 phi,这就是在第 7 个模块里算出的 φ 位置的实物。

把 gcc 与你的编译器并排比较

重新做出材料(gcc -O0/-O2 -S、clang -O1 -emit-llvm、mini.py asm),用你的 readasm 函数计算并写入八个值。不要手工抄数字——评分器会用同样的编译器重新读一遍来对照。