用一个 unsigned 函数观察 LLVM IR 的类型、值和目标信息,实测文本与 bitcode 转换、验证器和错误输入,说明 IR 提供的复用边界。
IR解决的是共同表示问题
把 C++ 直接翻译成某种机器码,当然可以。但如果每一种语言都各自实现一套优化和每一种目标的代码生成,重复工作会很快增加。中间表示给这些工作提供了一个共同接口:先把语言结构转换成有明确语义的表示,再在其上分析、变换并生成目标代码。
在 LLVM 中,常见的 LLVM IR 可以存在于内存对象、可读文本和 bitcode 中。它既不是原源码的逐字翻译,也不是某台 CPU 的机器指令。理解 IR 的重点,是读出计算、控制流和约束,而不是把每个符号都翻译回 C++。
先生成一份足够小的IR
实验环境:Ubuntu 24.04.4(WSL2,x86-64),Clang/LLVM 18.1.3,GNU binutils 2.42。
保存 twice.cpp:
extern "C" unsigned twice(unsigned x) { return x * 2u + 0u; }clang++-18 -std=c++17 -O1 -S -emit-llvm twice.cpp -o twice.ll
cat twice.ll本次函数部分如下。这里只节选函数,完整文件还包含目标信息、属性定义和元数据:
define dso_local noundef i32 @twice(i32 noundef %0) local_unnamed_addr #0 {
%2 = shl i32 %0, 1
ret i32 %2
}先读计算:函数接收一个 32 位整数值,左移一位,再返回这个结果。原源码中的“加零”没有出现在这份 O1 输出里。这是本次实际输出,不意味着 IR 必须永远只有两条指令;换成 O0,可能先看到 alloca、store 和 load。
从类型、值和指令开始读
| 片段 | 在这个例子里的含义 |
|---|---|
| @twice | 全局函数符号 |
| i32 | 32位整数类型;单靠它不区分有符号与无符号 |
| %0、%2 | 局部值的编号,不是物理寄存器号 |
| shl i32 %0, 1 | 把该整数值左移一位 |
| ret i32 %2 | 返回刚得到的值 |
| #0 | 引用文件后面的属性组,阅读时不能忽略 |
例如有符号与无符号除法使用不同指令,某些算术指令还可以带不溢出等标志。不能因为 C++ 里写了 unsigned,就猜所有相关语义都藏在 i32 这个词里。也不要手动给指令补上看起来“更利于优化”的标志:它们会改变允许假定的条件。
本例选择无符号乘二:在本机 unsigned 为 32 位的条件下,它的回绕与这条无额外溢出标志的左移相符。这个具体例子容易核对,不足以证明任意有符号运算、浮点表达式或向量运算都可以照样替换。
文本和bitcode:内容表示可以切换
llvm-as-18 twice.ll -o twice.bc
llvm-dis-18 twice.bc -o roundtrip.ll
file twice.ll twice.bc
opt-18 -passes=verify -disable-output twice.ll这四步本次均成功:file 将 twice.bc 识别为 LLVM IR bitcode,验证器接受 twice.ll。llvm-as / llvm-dis 改变的是保存方式,不是执行函数,也不是把它变成目标汇编。文本往返后,文件标识、注释或打印形式可能变化;不能只靠逐字相同判断语义。
版本也要记录。遇到无法读取的 bitcode,先核对生成与读取工具的版本和兼容规则。把文件改名为 .ll 不会把二进制编码转换成文本。
验证通过能证明到哪一步
保存下面这份故意写错的 bad.ll:
define i32 @bad(i32 %x) {
entry:
ret i64 %x
}llvm-as-18 bad.ll -o bad.bc本次工具拒绝该输入:%x 定义为 i32,却被当成 i64 返回。这个检查比“看起来像 IR”严格得多,但仍不能证明程序算出了我们想要的答案。把乘二误写为乘三也可能是合法 IR,必须再靠语义审查和运行测试发现。
因此至少分开问三件事:文件能否读取;IR结构与类型是否满足规则;生成程序是否满足要求。性能还要另测,不能从“验证通过”顺便推断。
有中间层,不等于与目标完全无关
clang++-18 --target=aarch64-linux-gnu -O1 -S -emit-llvm twice.cpp -o aarch64.ll对这个没有头文件依赖的小函数,本次成功生成了 AArch64 IR。比较两个完整文件可以看到 target triple、data layout 和属性等差异。目标信息影响类型布局、对齐、调用约定与目标相关操作的解释。这里没有运行 AArch64 程序,也没有完成带系统库的交叉链接。
这解释了一个常见误解:不能只改 target triple,就宣称某份 IR 可以安全迁往任意硬件。真正的接入还要核对语言语义、地址空间、运行库与 ABI;设备专用 intrinsic 也可能形成额外依赖。IR提供可复用的接口,接口本身仍有契约。
下一步该观察什么
面对一份陌生 IR,先找函数的输入和返回,再跟踪一个值如何产生、如何被使用,最后补读属性与目标信息。这个函数没有分支,所以沿两条指令就能读完;下一篇增加 if/else,看同一个源级变量怎样跨路径表达。
定义良好、行为等价与目标合法,是三道不同检查
考虑两个函数都接收i32并返回i32,一个计算x+1,另一个计算x+2。二者都可能满足IR结构与类型规则,但并不实现同一函数。验证器能拒绝类型不配,却不知道用户希望加几。
再看算术标志。无附加标志的i32加法按位宽形成相应结果;nsw或nuw增加不发生相应溢出的约束,违背时会产生poison。它们不是“请编译器更努力优化”的提示,而是会参与语义推导的承诺。
一个简单IR变换因此至少要回答:输入在哪个域定义良好,输出是否保留允许行为,属性是否仍满足,目标能否降低对应操作。验证通过只覆盖其中结构性部分,差分测试又只覆盖被选的输入,二者应互补而非争夺一个“已正确”标签。
相关具体语义可查LLVM 18.1的poison说明与各指令章节。本文不把教学函数省略的属性当成可在真实代码中任意删除的内容。
IR的抽象层次,决定哪些事实必须显式保留
源级局部变量可能消失为值,类与异常可能降低为调用、控制流和运行时协议;反过来,IR仍保留类型、内存、副作用和目标约束。它不是源码备份,也不是抹掉语义后只剩计算图。
在不透明指针表示下,ptr本身不携带被指向元素类型,不意味着load、store、getelementptr等操作失去类型与布局规则。读指针程序时要沿使用位置恢复对象大小、对齐和地址计算依据,不能只看ptr三个字母。
对于目标相关模块,随手改triple与data layout可能让同一条地址计算具有不同布局含义。若要跨目标复用,必须核对哪些操作、属性、运行库与ABI依赖原目标。把一个无头文件小函数生成另一目标IR,与完整工程移植,是不同证据等级。
2026-10-18内容修订:补语义/验证/合法性分工及不透明指针阅读要点。本次为文献与语义审校,未新增实验;首次发布日期、原网址及历史运行版本保持。
来源、验证范围与阅读路线
本期实测采用18.1.3;下列版本化文档来自18.1系列的18.1.8文档站,命令输出以本文实测为准。持续更新的官方页面用于补充原理,不据此改写旧版运行结果。
LLVM18.1.3实测文本/bitcode往返、verify均通过;错误类型IR被拒绝;AArch64仅IR生成。展示函数来自原始文件,省略模块头和属性组有明确标记。
上一篇 · 下一篇:用一段分支程序理解 BasicBlock、CFG 与 SSA · 文章归档 · 知识体系
勘误:2026-10-07,初版。发现问题时请附工具版本、命令与关键输出;确认后在此记录修改。
写下你的评论
要发表评论,您必须先登录。