编译原理怎么学才有用?别再从词法分析器开始了(附 LLVM 上手步骤和我踩的 6 个坑)

🔑 关键词:编译原理,LLVM,寄存器分配,编译器优化,IR

📖 摘要:从大二写 SQL 解析器的经历讲起,聊聊课本里的编译原理和工业界编译器差了三十年这件事,附我自己的 LLVM 上手顺序和几个实实在在踩过的坑。

大二那年上编译原理课,实验是用 Java 手写一个 SQL 子集的解析器,能过 20 个测例就满分。结果我把 80% 的时间砸在了错误恢复和报错信息上——哪一行、哪个字符、期望什么、实际是什么。这部分龙书里只有薄薄半页,期末也不考。工作以后带实习生,几乎每个人上手第一件事都是写词法分析器,然后卡在「字符串字面量里的转义和行号怎么对齐」这种事上一两天。不是说这不对,只是性价比真的很低,因为这块现在有 ANTLR 4、有 tree-sitter、有 flex/bison,你自己死磕出来的那点东西,跟工业级的差距大概就是「能跑」和「能用」的区别。

图片

真正让我觉得这门课值回票价的,是后来两件事。一是给一个配置 DSL 写代码生成,把 DSL 降到 C;二是调一个线上 bug:我写的一个循环在 -O0 下能跑,-O2 下直接变成死循环。原因是我在 int 上做了有符号溢出,编译器按未定义行为处理,直接把循环条件优化成了常量。那一刻我对 UB 这四个字的理解,比看十遍 cppreference 都深。

课本里的编译器和现实里的,大概差了三十年

龙书(Aho / Sethi / Ullman,第二版加了 Monica Lam,2007 年)到今天还是标准教材,这没问题。但它描述的世界是 Fortran 和 Pascal 时代的编译器,前半本都在讲前端——正则、NFA、DFA、LL(1)、LR(1)、LALR。后端只有寄存器分配那一章勉强算高潮。而现实是,前端早就被工具和库吃干净了。真正庞大、真正难、真正每天都在改的,是 lib/Transforms 那一坨:别名分析、循环变换、向量化、内联启发式、指令调度。

图片

举个我觉得最能说明问题的数字。x86-64 有 16 个通用寄存器(rax rbx rcx rdx rsi rdi rbp rsp 加上 r8 到 r15),但 rsp 是栈指针不能动,rbp 很多时候被当帧指针留着(除非开 -fomit-frame-pointer),所以寄存器分配器手上实际能自由支配的,大概就 14 个左右。而图着色寄存器分配,K 大于等于 3 就是 NP-hard——Chaitin 他们 1981 年那篇论文把寄存器分配规约成图着色的时候,基本就宣判了「最优解别想了,用启发式凑合」。所以你会看到很反直觉的现象:多写一个局部变量,有时候不影响性能,有时候程序直接慢一截,因为 spill 发生了。AArch64 就不一样,31 个通用寄存器 x0 到 x30,压力小得多,同一段 C 代码在手机上和在 PC 上跑出来的差距,经常比你以为的大。

一个可能不太主流的看法

前端是「能学会的」,后端是「学不完的」,所以我一直建议别人:别在词法分析器上耗太久,早点去碰 IR。

理由很实际。前端的知识点是封闭的,正则、文法、自顶向下和自底向上的分析,你花两周能把 LLVM 的 Kaleidoscope 教程跑通,能写个 JSON parser 不炸栈。但后端的每个问题都是开放的:这段循环能不能向量化,取决于别名分析能不能证明两个指针不重叠;内联该不该做,取决于内联之后代码膨胀对指令缓存的影响;这两条指令能不能合并,取决于指令选择时用的 DAG 匹配模式。没有哪个有标准答案。

图片

而且 IR 这个思维本身,可能比编译器本身值钱。把复杂逻辑先降到一层中间表示,再在这层上做变换和优化,这套路你在 React 的 fiber、TVM 的 Relay、数据库的 query plan,甚至网关的路由规则里都能见到。MLIR 2019 年从 LLVM 里分出来搞多层 dialect,本质上就是把这套「多级降级」的思路产品化了。学过编译的人看别的系统的抽象层,眼神是不太一样的。

具体怎么上手:我自己的顺序

下面这几步是我带人时最常用的。

图片

第一步,别用系统自带的编译器。Ubuntu 上 apt install clang-17 llvm-17-dev lld-17,然后 llvm-config-17 --version 确认一下。为什么强调版本?因为 LLVM 的 C++ API 每个大版本几乎都会有破坏性变更,你照着旧教程写的代码在 17 上编不过去是常态,不是你的问题。

第二步,看 IR,而且要看两份对比。先 clang-17 -O0 -S -emit-llvm a.c -o a.O0.ll,再 clang-17 -O2 -S -emit-llvm a.c -o a.O2.ll。一个 200 行的 C 文件,-O0 出来的 .ll 大概能有 1500 到 2000 行,-O2 之后可能只剩三分之一。重点看 alloca 是怎么变成 SSA 值的,这就是 mem2reg 这个 pass 干的活。

第三步,把 pass 流水线整个打出来看看:opt-17 -passes='default<O2>' -print-pipeline-passes a.O0.ll -o /dev/null。我第一次看这东西,输出滚了一屏多,密密麻麻几百个 pass 名字。那之后我就不太信「开了 O2 就会变快」这种话了。

图片

第四步,自己写一个 pass。LLVM 官方的 Hello World pass 核心代码大概 40 行,编成 .so,用 clang-17 -fpass-plugin=./libHello.so 或者 opt-17 -load-pass-plugin=./libHello.so -passes=hello 加载。我建议第一个 pass 就干一件事:统计每个函数里 basic block 的数量然后打印。简单,但足够把 pass 注册和运行时机搞明白。

第五步,godbolt.org。左边写 C,右边同时看 GCC 14 和 Clang 17 在 -O2 下的汇编,勾上 Intel syntax 会好读不少。有两件事我经常在上面看:switch 的分支多到一定程度会被编译成跳转表;还有 -O2 下简单的 max 函数会被编成一条 cmov,带分支的写法就不会。

第六步,用 flex/bison 写一遍,然后去感受移进-归约冲突。你会看到 bison 报 shift/reduce conflict,然后加个 %left 或者 %expect 把它按下去。我当年就是靠这个才真的看懂了 LR 分析表里那一格为什么是冲突。

几个我实实在在踩过的坑

图片

  • Kaleidoscope 教程跟你本地 LLVM 版本对不上,是最常见的卡点。比如 CreateCall 的签名在某几个版本之后从传 Value 变成了传 FunctionType,网上抄来的代码直接编不过。这时候别搜博客,去 LLVM 官方的 doxygen 文档按你的版本号查。
  • 自己编译 LLVM 的时候,链接器一定要换掉,加 -DLLVM_USE_LINKER=lld。我最早用默认的 bfd ld,链接阶段跑了快一个小时还把内存吃爆,换成 lld 之后十几分钟就结束了。
  • 内存给到 16G 以上再动手,-DLLVM_ENABLE_PROJECTS="clang" 加上 ninja -j8,不然 OOM 会教你做人。
  • -O0 和 -O2 用的根本不是同一个寄存器分配器。LLVM 在 -O0 下走 RegAllocFast,就是「能塞进寄存器就塞,塞不进就扔栈上」的简单版本;-O2 下默认是 RegAllocGreedy,更早的版本还用过线性扫描。所以在 -O0 下测出来的性能,跟优化后基本没有可比性。
  • 别拿「代码行数」衡量编译器复杂度。clang 的前端读起来比 lib/Transforms 舒服太多,因为前端是照着标准写的,后端是照着一堆启发式和实测数据调的。

最后说一件跟性能无关的事

Ken Thompson 1984 年那篇图灵奖演讲叫《Reflections on Trusting Trust》,里面讲了个思想实验:如果你用的 C 编译器被人动过手脚,它能识别出自己正在编译 login 程序,然后往里插一个后门;更狠的是,当你拿它去编译一份干净的编译器源码时,它会把「插后门」这个能力也一并传下去。所以你去读源码,什么也找不到。当年大家都当它是思想实验。2024 年 3 月,xz 后门(CVE-2024-3094)被发现,Andres Freund 本来是在查 sshd 的登录延迟,最后追到了 liblzma 里的混淆代码,那东西通过 glibc 的 ifunc 劫持了 sshd 的 RSA 验证路径,绕过了源码审计,主要藏在构建脚本和测试数据里。学过编译的人看这条新闻,反应和普通人不太一样——你会知道工具链本身也是可以被攻击的代码,而且它的攻击面比大多数人想象的大得多。所以这门课值不值得学?我的答案是值得,但你得清楚课本只覆盖了大约三分之一,剩下那三分之二在 LLVM 的源码里,在 godbolt 的汇编里,在你被 -O2 干碎的那个死循环里。

🏷️ 标签: