恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
LLVM IR 实战指南:生成、解读与Windows环境下踩坑
首页
资讯中心
/
LLVM IR 实战指南:生成、解读与Windows环境下踩坑
LLVM IR 实战指南:生成、解读与Windows环境下踩坑
发布时间:2026/9/2 19:53:43
简介这是一套使用 C 编写的 LLVM IR 生成演示工程面向编译器设计与底层工具链开发者。项目基于 LLVM 官方接口细致展示模块、函数、基本块与指令的创建流程并覆盖类型定义、函数生成、基本块连接、指令输出等关键环节可帮助读者从零理解静态单赋值中间表示的构造方式。压缩包共 50 个文件其中 cc 源文件与 h 头文件合计 41 个另含 5 个 txt 说明文件、1 个 ll 格式的中间表示示例、1 个 yy 语法文件、1 个项目说明文件以及忽略配置等元信息整包大小仅 23KB。工程内 parsing 与 codegen 等目录划分清晰便于对照源码学习词法/语法解析如何与 LLVM IR 生成相衔接。资源已有 463 人学习适合希望尽快上手 LLVM API 的中高级 C 开发者通过研读构建配置与完整示例读者可快速搭建自己的实验环境并尝试在真实项目中复用类似的 IR 生成思路从而加深对编译器后端优化与跨平台代码生成的理解。 开篇先说个结论LLVM IR 是这个工具链里最值得琢磨的一层因为它卡在“人写的代码”和“机器执行的代码”中间看似抽象却决定了大半的优化和移植逻辑。如果只看 clang 把 C 代码变成 .exe 的过程你大概率错过了真正的核心——中间表示IR才是编译器的舞台。这篇东西主要分享怎么快速生成一份 LLVM IR、怎么读懂它以及 Windows 环境下最容易踩的坑。适合正在学编译原理、准备写自定义语言或想深入了解 Rust/Clang 后端的同学参考。1. LLVM IR 到底是个什么东西1.1 编译流程里的中间一站很多人第一次接触编译器脑子里都是“源代码 - 汇编 - 机器码”这条直线实际上现代编译器基本都是三段式前端负责把高级语言变成中间表示中端在中间表示上做优化后端再把优化后的中间表示变成目标平台汇编。LLVM 的核心就是这个叫 IR 的中间表示C/C/Rust/Swift 这些语言共用同一套 IR 和优化管道。对比一下 GCC 的 GIMPLELLVM IR 的设计目标更偏“第一公民化”它不只是编译器内部的一个临时数据结构而是一种有明确文本格式、稳定语义的表示。意思是你可以用 clang 把 C 代码编成 .ll 文件直接拿文本编辑器打开看也可以把它编成位码 .bc 交给 lli 解释执行甚至改几行 IR 再编回目标平台。这种开放性让 IR 变成了一个独立工具而不是调试器里才能看见的内存状态。理解这一层之后很多困惑就通了。比如为什么 Rust 可以复用 LLVM 的后端生成各种平台代码为什么 clang 对 C 新特性的支持那么激进为什么同一个优化选项在不同语言前端下表现一致——根源都在 IR 这层统一了。1.2 三种表示形式LLVM IR 有严格的等价的三种形态它们在磁盘上长得很不一样但语义一致内存表示编译器和 JIT 运行时里直接操作的数据结构对应 C 里一堆继承自Value、Instruction的类位码Bitcode序列化后的二进制形式一般扩展名是.bc方便存储和链接像rustc生成的 rlib 里就藏着这玩意儿文本表示就是常见的.ll文件人类可读也是调试和研究时最常用的形态。三者之间可以互相转换llvm-as把.ll变成.bcllvm-dis反向操作。实操里最常用的还是直接让 clang 输出文本 IR因为能看到优化前后到底变了什么。还有一个细节值得提IR 的文本语法用的是静态单赋值SSA形式每个变量只能被赋值一次。看%1 add i32 %0, 1这种指令%1这个名字只出现一次。这也是为什么优化器能大胆地做重排、替换和删除因为变量的定义和使用关系在图结构里一目了然不用担心改坏某个引用。2. Windows 下把 LLVM 工具链跑起来2.1 官方安装包装一条龙平时聊 LLVM 大多默认 Linux 环境但不少人在 Windows 上做开发。Windows 下最简单的方案是用官方发布的安装包从 LLVM 官网下载对应版本的.exe安装时勾选“添加 LLVM 到 PATH”装完就能在命令行里敲clang --version了。这里有个版本匹配的坑我初期吃过亏LLVM 的小版本更新很快如果你同时装了 Visual Studio 的某个组件或者 Rust 工具链自带了一份 LLVM系统 PATH 里可能出现多个 clang。命令行where clang能看到具体用的哪个最好统一版本否则经常出现莫名其妙的“未知选项”或 ABI 兼容问题。2.2 免安装的偏好用法有朋友只想要 clang 和 opt不想跑一个几百兆的安装程序。可以试试 LLVM MinGW 这个独立项目它把 clang、lld、compiler-rt 等东西打包成便携目录解压即用内置 Windows 目标的 sysroot对做跨平台构建特别友好。也可以直接用包管理器比如winget install LLVM.LLVM或scoop install llvmscoop 装完之后本质也是 portable 风格的软件包路径非常干净适合频繁换机重装的开发者。免安装模式有一个额外收益不会污染系统 PATH多个工具链版本可以并存在不同目录需要用哪个就临时把目录加进 PATH。像我在做实验时/path/to/llvm/bin和/path/to/rust/bin并存反而能分别控制 clang 和后端版本。2.3 装 Rust 时顺带获得 LLVM很多人买 LLVM 的教程真正动机是装了 Rust 之后想看看 rustc 后端到底做了什么。rustup 安装的 stable 工具链内部确实绑定了 LLVM 的一个特定版本不过它没有暴露 clang 可执行文件你没法直接调rustc --print llvm-version用一个假想的 clang。想看 Rust 的 LLVM IR靠的是rustc --emitllvm-ir这个参数rustc -O --emitllvm-ir add.rs它会生成一个.ll文件内容风格和 clang 生成的基本一致。这就是为什么很多编译器研究者会拿 Rust 和 C 写同一段算法分别生成 IR 来对比两者的优化效果前端完全不同但 IR 层面的优化逻辑是同一套。3. 实操演示从 C 代码到 LLVM IR3.1 准备一份测试代码先从一个最小例子开始保存为add.cint add(int a, int b) { int c a b; return c; }这个函数足够简单不存在太复杂的控制流便于对照源代码和 IR 指令。再用 clang 生成文本形式的 IRclang -S -emit-llvm add.c -o add.ll-S表示不汇编、不链接输出中间文件-emit-llvm告诉 clang 输出的是 LLVM IR 而不是汇编。组合起来就是“把 C 编译到 IR 文本就停手”。老手可能还会加-O0关掉优化这样生成的 IR 更贴近前端原始翻译结果初学者看着不晕。3.2 打开 add.ll 看第一眼用文本编辑器打开add.ll会看到比预期多不少内容。头部通常是一堆target datalayout、target triple、attributes之类的声明紧接着是define开头的函数定义define i32 add(i32 %a, i32 %b) #0 { %c add i32 %a, %b ret i32 %c }逐个解释define定义一个有名字的函数i32是返回值类型add是全局函数符号函数参数用i32 %a的形式声明类型和局部变量名%c add i32 %a, %b是一条算术指令add的操作数类型是i32返回一个新 SSA 值%cret是返回指令。看到#0这种附加组符号先不用管它是对函数属性比如是否内联、栈对齐等的引用。3.3 再深入一点控制流和内存操作光一个加法体现不出 IR 的威力加一段分支逻辑试试int max(int a, int b) { if (a b) { return a; } return b; }同样用clang -S -emit-llvm -O0 max.c -o max.ll生成的 IR 会多出基本块basic block的概念define i32 max(i32 %a, i32 %b) #0 { %1 icmp sgt i32 %a, %b br i1 %1, label %2, label %3 2: ret i32 %a 3: ret i32 %b }icmp sgt是整数比较指令sgt表示带符号大于br是跳转指令根据%1的真假跳往两个标签块。每个以标签开头、以跳转或返回结尾的代码段就是一个基本块。IR 层面的循环、分支最后都归结成这种带标签的跳转图优化器做的很多工作也是在这个控制流图上跑算法。这里有个只适用于-O0的细节在未优化版本里局部变量经常会经过alloca指令放到栈上再通过load/store读写比如max的形参其实会给一个栈地址函数体里先从内存取一次才做比较。这是为了保留源代码的变量语义方便调试器看值。等到-O2这些都被消除直接寄存器传递。如果谁一开始看 IR 觉得啰嗦多半是没意识到这个原因。4. 优化级别不同IR 长什么样4.1 同一段代码-O0 和 -O2 的对比继续拿add函数执行clang -S -emit-llvm -O2 add.c -o add_O2.ll打开后会发现函数体比刚才短了不少尤其-O2下int c a b;这个临时变量直接消失了变成define i32 add(i32 %a, i32 %b) local_unnamed_addr #0 { %1 add i32 %b, %a ret i32 %1 }交换律优化会把两个加法操作数换位置为了利用某些平台的后端指令编码。这还不算什么更夸张的是如果你的函数只有一个调用点而且足够短优化器会做内联调用点直接替换成函数体连函数调用开销都省了。这也是为什么很多性能分析工具里你看到的调用栈和你写的源码结构对不上因为内联早就把函数边界抹掉了。4.2 优化背后做了什么优化器不是真的“聪明”它只是在 IR 上跑了一系列简单规则的组合常量传播、死代码消除、公共子表达式消除、循环不变量外提等等。你从-O2级别得到的 IR实际上是十几道 pass 跑完的结果。比如常量传播int const_expr() { return 42 * 3; }-O0里你能看到mul指令到了-O2直接变成ret i32 126。这个结果倒不是 clang 硬编码的而是优化 pass 发现操作数都是常量就在编译期算完了后面的代码生成阶段根本不需要这条乘法指令。理解这些 pass 对排程序性能问题很有帮助。比如你发现一段代码在-O2下反而变慢通常不是优化器“垃圾”而是代码里有未定义行为变量越界、signed int溢出、指针重叠等。优化器基于“没有 UB”这个假设做了激进的变换比如把某个循环删掉结果因为你踩了 UB程序行为就不可理喻了。看到 IR 从正常变奇怪时第一反应应该是查 UB而不是怪 clang。5. 常见问题与排查技巧5.1 高频报错对照表Windows 上操作 LLVM 工具链最常碰到的几个问题列出来现象原因处理办法clang 不是内部或外部命令LLVM 没加 PATH 或安装未完成检查 PATH重新安装勾选选项error: unable to make temporary file环境变量TMP指向了只读或不存在目录手动创建临时目录并设环境变量LLVM ERROR: symbol not found动态库版本和 clang 版本不匹配使用同一套 LLVM 安装包里的 bin 和 liberror: invalid linker name in argument -fuse-ld...链接器指定错误确认已安装 lld或去掉-fuse-ldlli运行时报LLVM ERROR: An error occurred....bc位码和 lli 版本不一致用llvm-dis转文本统一工具链版本还有一个很经典的 Windows 坑默认 clang 输出的是 COFF 目标文件但如果你想直接拿lli跑.bc文件别忘了它只是解释器不做完整系统调用模拟。如果你的 IR 里调用了外部库函数lli可能没法解析除非用-disable-lazy-compilation或者把依赖库路径准备好。5.2 排查思路与经验遇到 IR 相关报错我一般按这个顺序排查。先确认到底用的是哪个 clang。Windows 下有太多同名程序Visual Studio 自带的 C 工具链可能向 PATH 注入了不同版本的 clang。命令clang --version看完整版本号跟rustc --version里的 LLVM 版本对一下不一致先处理版本。CLion、VS Code 插件、MSYS2 也可能动态修改 PATH。再就是确认你的-emit-llvm参数写的位置。clang 参数顺序有时敏感最好统一把目标相关参数写在前面-S -emit-llvm放一起后面才跟源文件。要是漏了-Sclang 会默认执行完编译到汇编或目标文件并不会输出.ll这也是新手常犯的低级错误。最后检查优化级别。很多“IR 不完整”的疑问其实只是因为-O2把没用到的逻辑删了。比如你只想学 IR 指令集就别在-O2下纠结老老实实用-O0。反过来想理解优化效果就必须盯着-O2的 IR 看。两个级别各有各的用途。6. 延伸IR 生成之后还能怎么玩6.1 拿 lli 执行 IR生成 IR 不只是为了看它确实可以直接执行。LLVM 自带lli工具把 IR 当脚本跑clang -S -emit-llvm add.c -o add.ll lli add.ll echo $?如果add.c里有main函数lli会从main开始执行返回码就是程序的退出码。这种玩法很适合拿来验证你对 IR 语法的修改直接改.ll里某个常数值再lli跑一遍看结果不用重新写 C。对编译器开发者来说等于拥有了一个“免编译期”的快速验证环境。6.2 顺着 rustc 的输出看不同前端的差异如果你装了 Rust可以写个calc.rspub fn add(a: i32, b: i32) - i32 { a b }执行rustc -O --emitllvm-ir calc.rs --crate-type lib生成的.ll文件名会是calc.ll打开能发现函数签名带了一堆 Rust 风格的处理返回值的sret参数甚至 panic 路径都可能出现。这和 clang 生成的add有差异但核心算术指令add仍然是同一个。这里就体现出 LLVM 生态最迷人的一点语言前端百花齐放但到了 IR 这一层大家面对的是同一套优化器、同一套后端、同一套 JIT。如果你正在设计一门小语言不用从头写汇编优化器只要生成正确的 IR就能复用 LLVM 的整套能力。这也是为什么这些年新语言包括 Rust 初期的实现几乎都选择 LLVM 作为后端。我实际操作里最有用的一个习惯是把 clang 生成的-O0IR 当作“语义基准”把-O2IR 当作“性能预期”。写新语言或者给代码做优化时先看基准 IR 是否符合源码逻辑再看优化后 IR 有没有做预期变换。如果这两层都符合基本可以确立优化器没有“自作主张”。这套方法论比单纯跑 benchmark 更接近问题根因建议你试试。本文还有配套的精品资源点击获取