从程序结构进入LLVM IR
编译器入口:读懂眼前的LLVM IR
从一条add读到分支与内存,再按实际O0/O2文件核对位宽、属性和执行证据。
先完成SYS-00:位宽与接口、基础17:编译链接,再开始本页。
完整G0仍须有自己的独立通过记录,阅读本页不会代替验收。
新教学IR用本机Clang构建并由C++调用;非法SSA只验证拒绝,poison/undef不取正常数值输出。既有O0/O2是Apple clang version 17.0.0 (clang-1700.0.13.5)的arm64 macOS产物;这个Apple版本标签不等于上游LLVM 17。新执行与复用旧记录分别保存,不声称GPU、MLIR、自定义pass或全部输入正确。。时间是设计估算,按完整小节推进;用自己的输入、代码、输出与疑问记录学习过程。
这一页要能做到
- 逐个读出define、@、%、i32、基本块与add/mul/ret,区分值编号与机器寄存器。
- 沿两条路径选择phi,并能指出普通使用不受定义支配的错误。
- 同时维护SSA值表与内存状态表,解释store为何没有给原SSA值重新赋值。
- 按固定位宽解释shl与回绕,区分poison、属性承诺和普通整数结果。
- 读出实际O0/O2函数头、函数体与属性组,区分旧证据复用和新改写验证。
先预测,再运行;一次只改一个条件,最后离开示例独立完成,并复述原因。下面的源码与下载是同一份文件,图解和完整文字可逐步对照。
开始前:先读懂眼前几行,再打开完整产物
这页接在基础17的编译流程与SYS-00的位宽模型之后,用5个完整小节、预计5核心小时,补齐现有编译实验需要的IR读法。先预测简单值,再看分支、内存与边界,最后读真实Clang生成的函数头和属性。
本页有两类材料:我们手写的短教学IR,以及现有实验真正生成的O0/O2文件。手写例子不是伪装的编译器输出;实际产物也不为了排版而重命名或改写。网页片段和下载引用同一份文件。非法例子只交给验证器检查,不生成可运行的“错误答案”。
LLVM IR是一种有类型的中间表示,把优化需要的计算、控制流与存储关系写得更明确。读懂它不等于已经实现编译器、优化pass或GPU后端。本页先把IR读法讲清,再回到第49章的AST与编译器主题应用;不要求先读完那一章才能开始。
I1 · define i32 @...怎样一句句读?
先用输入5计算“加1,再乘2”。用普通数值推演得到6、12;现在把同一过程写成LLVM IR。以下是单独编写的教学函数,本节只要求预测5和0这两个不会碰到表示边界的输入。
| 记号 | 在本例中怎样读 |
|---|---|
define |
这里开始定义一个函数,有具体函数体 |
i32 |
值由32位表示;这个类型名本身不区分C++的signed/unsigned |
@twice_next |
模块中的函数名;@用于全局标识符 |
i32 %x |
参数名为%x,参数类型是i32 |
entry: |
一个基本块的名字,冒号是块标签的写法 |
%next = add i32 %x, 1 |
用i32加法计算%x与1,把结果命名为%next |
%result = mul i32 %next, 2 |
同一类型下计算乘法,把结果命名为%result |
ret i32 %result |
返回一个i32结果,结束这次函数执行 |
%是局部值或块标识符的前缀,不是C++的取余运算符,也不是机器寄存器编号。%next = ...给计算结果定义一个名字;它不是以后可以反复赋值的C++局部变量。花括号包住函数体,逗号分开操作数;add i32中的类型同时约束这两个整数操作数,不是只修饰离它最近的那个。
一份LLVM模块(module)可以包含多个函数及其属性。函数里的基本块(basic block)是一段没有中途分支的连续指令,末尾用分支或返回这样的终结指令决定下一步。entry只是我们选的名字,不是只能用这几个字母。以分号;开头的内容是IR注释,不参与计算;下载文件中的片段标记也只是注释。
define i32 @twice_next(i32 %x) {
entry:
%next = add i32 %x, 1
%result = mul i32 %next, 2
ret i32 %result
}
| 执行位置 | 输入%x=5 | 换成%x=0 |
|---|---|---|
| 参数已经给定 | 5 | 0 |
| 定义%next | 6 | 1 |
| 定义%result | 12 | 2 |
| ret返回 | 12 | 2 |
每个SSA值名在它所在的函数中只有一次定义,这叫静态单赋值(Static Single Assignment,SSA)。另一份函数可以使用同样的局部名字;循环的动态执行也不等于在IR文本中重复定义同一个名字。后面的指令沿名字找到前面的定义,形成“定义→使用”的关系。本节先追一条直线;下一节会解释为什么“名字没有重复”仍不足以让有分支的程序合法。
名字、类型与一次定义
%x是局部SSA名字,i32是32位整数类型。本例设x为5。
%next = add i32 %x, 1生成新值6;原%x仍为5。
%result = mul i32 %next, 2得到12;两个此前名字的值都保留。
ret i32 %result返回12,ret没有定义新的SSA值。
阅读完整推演文字
- 1 · 接收参数
函数:@twice_next;参数:i32 %x = 5
%x是局部SSA名字,i32是32位整数类型。本例设x为5。
- 2 · 计算一个新值
%x:5;%next:6
%next = add i32 %x, 1生成新值6;原%x仍为5。
- 3 · 再生成一个值
%x:5;%next:6;%result:12
%result = mul i32 %next, 2得到12;两个此前名字的值都保留。
- 4 · 返回结果
返回类型:i32;返回值:12
ret i32 %result返回12,ret没有定义新的SSA值。
常见误读是把%next看成C++变量,然后以为下一行乘法会把它从6改成12。实际新增的是%result=12,%next仍表示6。先在纸上保存两行值,不要用一格反复擦写。这条规则只说SSA名字,稍后会看到内存可以被多次写入。
I2 · 分支汇合时,phi选择哪一个值?
现在定义一个新任务:正数加1;否则减1。输入3走正路得到4,输入-2走另一条路得到-3。我们用同一个add加上-1表示减1,不需要先引入新的减法指令。
icmp做整数比较。icmp sgt i32 %x, 0中的sgt表示按有符号解释做“大于”比较,结果类型是i1,本例用true/false表示两个结果。这里才明确选择有符号比较:i32相同的位模式若交给无符号比较,判断可能不同。输入-2在这个有符号比较下小于0,不能仅因它的高位为1就走正数分支。
br i1 %positive_test, label %positive, label %other根据条件选择一个目标块:真到第一个,假到第二个。label表示后面给的是基本块目标;定义块标签时写positive:,引用它时写%positive。br label %join则无条件跳到join。函数执行不会为了知道结果而先把两条分支都跑一次。
在汇合块,phi i32 [ %plus, %positive ], [ %minus, %other ]列出“候选值、前驱块”的配对。方括号包住一组,逗号分隔值与来路;根据刚刚从哪个前驱块进入,选择那一组的值。它不是按值的大小选择,也不是取两组的平均。phi放在这个基本块的普通指令之前。
define i32 @signed_step(i32 %x) {
entry:
%positive_test = icmp sgt i32 %x, 0
br i1 %positive_test, label %positive, label %other
positive:
%plus = add i32 %x, 1
br label %join
other:
%minus = add i32 %x, -1
br label %join
join:
%result = phi i32 [ %plus, %positive ], [ %minus, %other ]
ret i32 %result
}
| 输入 | 比较 | 实际经过的分支 | 本次有意义的分支结果 | 汇合结果 |
|---|---|---|---|---|
| 3 | 3大于0,为真 | positive | %plus=4 | phi选4 |
| -2 | -2大于0,为假 | other | %minus=-3 | phi选-3 |
| 0 | 0大于0,为假 | other | %minus=-1 | phi选-1 |
执行3时,不需要先计算另一条路径的%minus。执行-2时,也不能把没有经过的正路结果拿到普通指令里使用。把两条路径的值都写在纸上做比较可以帮助学习,但不能把这张分析表说成程序在一次运行中执行了全部分支。
名字唯一,为什么仍可能非法?
对普通指令的操作数,定义必须支配(dominate)使用:从函数入口出发,任何能到达这次使用的控制路径,都必须先经过该定义。同一个块内也要先定义再使用。若%plus只在正路定义,而汇合处普通add直接使用%plus,另一条路径就没有执行过它的定义。即使你本次只想输入3,函数整体的这条结构要求也不能因此取消。
phi的规则按进入边理解:来自正路的候选值需要在正路进入汇合的那条边上有效,来自另一条路的候选值在另一条边上有效。不能要求%plus也在另一条路先算过;也不能把phi的特殊选边规则套到普通add上。LLVM良构性与phi规则
下面故意保留一个非支配使用,供验证器拒绝。先找出哪条入口到使用的路径绕过了定义,再查看诊断;不要给非法函数指定一个正常输出值。
; Intentionally invalid SSA: do not execute this module.
define i32 @bad_join(i32 %x) {
entry:
%positive_test = icmp sgt i32 %x, 0
br i1 %positive_test, label %positive, label %other
positive:
%plus = add i32 %x, 1
br label %join
other:
br label %join
join:
%result = add i32 %plus, 0
ret i32 %result
}
正确的修复取决于原任务:若每条路径本来就要用同一个值,可以把合适的定义放在分支之前;若两条路径要贡献不同值,应像上面的合法函数那样用phi表达来路。不是在测试里删掉负数输入就修好了IR。
phi按进入的那条边选择
icmp sgt把x按有符号数与0比较,br只进入一条分支;不是无条件计算两边。
正路产生%plus=4,从positive进入join,phi选择这一条边的值。
另一路产生%minus=-3,从other进入join,phi选择-3。
坏模块在join普通使用%plus;从other到达时未执行%plus定义,因此违反支配要求。
阅读完整推演文字
- 1 · 先选一条路径
entry:x > 0 ?;positive:add x, 1;other:add x, -1
icmp sgt把x按有符号数与0比较,br只进入一条分支;不是无条件计算两边。
- 2 · 输入3
positive:%plus = 4;join:phi选%plus
正路产生%plus=4,从positive进入join,phi选择这一条边的值。
- 3 · 输入-2
other:%minus = -3;join:phi选%minus
另一路产生%minus=-3,从other进入join,phi选择-3。
- 4 · 对照坏模块
other:没有%plus;join:普通add使用%plus
坏模块在join普通使用%plus;从other到达时未执行%plus定义,因此违反支配要求。
I3 · SSA值不变,内存为什么还能改变?
先读四个新记号。ptr是这里使用的不携带所指元素类型的指针类型;实际读写哪种值,由load/store上的类型说明。alloca i32, align 4申请能存放一个i32的局部存储位置,得到ptr值;对齐4表示满足4字节对齐要求,不是“分配4个int”。这块局部存储随本次函数调用结束而释放,不能靠返回它的地址延长寿命。
store i32 %x, ptr %slot, align 4把%x写入这个位置;store不产生一个需要命名的结果。%first = load i32, ptr %slot, align 4从位置中取出当时的i32值,定义新的SSA值%first。load/store的对齐标注是访问条件,不是自动修复任意地址的操作。
alloca只给空间,不能当作初始值0。因此我们先store再load,不去运行未初始化读取并把碰巧看到的位记为答案。指针%slot可以始终指向同一位置,而那个位置里的数先是7、后是9;已经load出来的%first仍是7。
define i32 @read_twice(i32 %x) {
entry:
%slot = alloca i32, align 4
store i32 %x, ptr %slot, align 4
%first = load i32, ptr %slot, align 4
store i32 9, ptr %slot, align 4
%second = load i32, ptr %slot, align 4
%result = add i32 %first, %second
ret i32 %result
}
对输入7,分开记两张账:
| 指令进展 | 新增或保留的SSA值 | %slot指向的内存 |
|---|---|---|
| alloca之后 | %slot是指针;%x=7 | 尚未存入本例的值,不读取 |
| 第一次store之后 | %x仍为7,%slot仍指原位置 | 7 |
| 第一次load之后 | %first=7 | 7 |
| store常量9之后 | %first仍为7 | 9 |
| 第二次load之后 | %second=9 | 9 |
| add并返回 | %result=16 | 9,随后函数返回结束本地存储寿命 |
一根指针,两次读出的不同值
alloca产生%slot指针,局部存储尚未初始化;本例不在此时load。
store参数7,第一次load得到%first=7;%slot仍指向同一格。
第二次store写9,改变内存格;先前%first仍是7,没有被重新定义。
第二次load产生新的%second=9,结果%first+%second为16。只把store9改10时,第二次读变10,结果17。
阅读完整推演文字
- 1 · 只分配位置
SSA表:%slot → 存储位置;内存格:未初始化
alloca产生%slot指针,局部存储尚未初始化;本例不在此时load。
- 2 · 写7,再读
%slot:同一地址;内存格:7;%first:7
store参数7,第一次load得到%first=7;%slot仍指向同一格。
- 3 · 改内存
%slot:同一地址;内存格:9;%first:仍7
第二次store写9,改变内存格;先前%first仍是7,没有被重新定义。
- 4 · 再读并相加
%first:7;%second:9;%result:16
第二次load产生新的%second=9,结果%first+%second为16。只把store9改10时,第二次读变10,结果17。
只把第二次store的9改成10,再按同一张表预测:第一次load的%first仍是7,第二次%second变10,和变17。若你把%first也改成10,就是把已经取得的SSA数值当成了会自动更新的借用视图。这个单条件对照稍后与小型调用入口一起验证。
把两张账用于真实O0函数体
现有实验真正的源码使用unsigned,计算x * 2 + 3 + x * 0;它的测试文件用static_assert确认unsigned恰好有32个值位。不能把源码说成写了uint32_t;本实验得到相同固定宽度合同的方式,是实际类型加检查。现在先令传入参数%0=7,只读下面真实文件的函数体,不提前要求解释尚未展示的函数属性。
%2 = alloca i32, align 4
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca i32, align 4
store i32 %0, ptr %2, align 4
%6 = load i32, ptr %2, align 4
%7 = mul i32 %6, 2
store i32 %7, ptr %3, align 4
%8 = load i32, ptr %3, align 4
%9 = add i32 %8, 3
store i32 %9, ptr %4, align 4
%10 = load i32, ptr %2, align 4
%11 = mul i32 %10, 0
store i32 %11, ptr %5, align 4
%12 = load i32, ptr %4, align 4
%13 = load i32, ptr %5, align 4
%14 = add i32 %12, %13
ret i32 %14
这里%2、%3、%4、%5是四个alloca得到的指针,不是四个算术结果。按store顺序,它们保存7、14、17、0;随后的load分别定义当次取得的整数。最终%14是17,ret返回它。参数%0之后看起来跳过了%1,是因为真实IR中无名基本块也参与编号;这个隐式入口块占了编号1。不要把它猜成“被删掉的1号机器寄存器”。
把数字名字换成纸上的“参数格、翻倍格、偏移格、乘零格”有助于理解,但网页里的代码仍保留编译器真正给出的名字。优化后这些存储是否还需要存在,取决于行为能否保持,不能仅凭“源码声明过四个变量”要求它们永远留在内存中。
I4 · i32的边界、移位与额外承诺
先把正常的固定宽度值算清。对本页没有额外标志的i32 add/mul,结果保留32位,按模4294967296表示;没有自动提升成无限大的数学整数。i32仍不自行声明有符号或无符号;本实验从32位unsigned参数传入,并按无符号数解释最终结果。
shl i32 %x, 1把32位模式左移1位,低位补0,移出高位丢弃。本例有效移位量小于32。真实O2函数体是左移1、加3、返回;先不显示函数头上的额外属性,那些在下一节才整行读。
%2 = shl i32 %0, 1
%3 = add i32 %2, 3
ret i32 %3
| 输入按unsigned显示 | 左移1位后的32位值 | 再加3后的32位值 |
|---|---|---|
| 0 | 0 | 3 |
| 1 | 2 | 5 |
| 7 | 14 | 17 |
| 2147483648,高位为1 | 0 | 3 |
| 4294967295,全部位为1 | 4294967294 | 1 |
最后一行相加的数学结果是4294967297,保留32位后是1;不能给它附上有符号C++溢出的规则。这张表说明本页这个无标志IR函数的位模式行为,不证明任意“乘2换移位”都合法。
现在回访I1的twice_next:输入4294967295,加1先回绕到0,再乘2得到0。I3的read_twice对同样输入加9,结果回绕到8;只把9改成10则得到9。这些是不同函数各自的运算,不要把真实transform的1套给它们。
poison、undef不是普通整数答案
LLVM的poison表示某些语义条件被破坏后产生的特殊状态。对i32执行移位量32会产生poison;它不是普通结果0,不是自动抛出的C++异常,也不意味着验证器一定把这条IR当语法错误拒绝。poison可沿一些运算传播,在特定使用处导致未定义行为,例如把它用作条件分支的条件。本页不执行这种程序来收集“数值答案”。
undef也不能当作“固定为0的未知变量”。它允许未指定的位值选择,不能假定多个使用位置总是选择相同值;它与poison有不同语义。这里只学习在看到它们时停止给普通整数输出下结论,不展开完整的LLVM未定义行为体系。LLVM的未定义行为说明
nsw是no signed wrap,nuw是no unsigned wrap。这些是指令上的额外不回绕承诺;例如add nsw i32承诺不会发生按有符号解释的溢出,违反时会产生poison,而不是在原来的正常模运算答案旁边加一条警告。add nuw i32 4294967295, 1违反无符号不回绕承诺,不能再把0当作此指令的正常结果。
本页真实O2的shl与add都没有这些标志。添加标志会加强适用条件,不是无害的注释修改。判断一个优化是否正确,必须同时看输入约定、位宽、指令及其标志;只在少数输入上输出相同,不足以证明所有输入上的语义等价。
真实O2的32位结果
无标记的shl左移1位得到14,再add3得到17。
2147483648左移1时高位移出32位范围,结果0;再加3为3。
4294967295左移1为4294967294,再加3按32位回绕为1。
i32移位量达到32会产生poison,不给它正常整数输出;这不是本章非法SSA的编译拒绝理由。
阅读完整推演文字
- 1 · 普通输入7
%0:7;%2:14;%3:17
无标记的shl左移1位得到14,再add3得到17。
- 2 · 最高位为1
%0:2147483648;%2:0;%3:3
2147483648左移1时高位移出32位范围,结果0;再加3为3。
- 3 · 全部位为1
%0:4294967295;%2:4294967294;%3:1
4294967295左移1为4294967294,再加3按32位回绕为1。
- 4 · 移位量32另有合同
操作:shl i32 x, 32;结果类别:poison;本章操作:只解释,不执行
i32移位量达到32会产生poison,不给它正常整数输出;这不是本章非法SSA的编译拒绝理由。
I5 · 现在读完整函数头,再运行实验
前面已经逐个讲过普通指令,最后把属性接上。下面两份函数由已有实验的Apple Clang17工具链真正产生;Apple的版本号不应直接当成上游LLVM同号版本。实际文件与源码身份来自保留的运行记录,未来工具链可以生成不同文本,不能用排版是否相同判断函数正确。
| 真实函数头里的部分 | 本题需要的读法 |
|---|---|
参数上的noundef |
参数值不能含undefined或poison位;违反这项约定会导致未定义行为 |
返回类型前的noundef |
同样的要求用于返回值;本次O2有,O0函数返回处没有 |
local_unnamed_addr |
函数地址在本模块内不重要;不是把函数体或名字删除,也不代表它没有副作用 |
#0 |
引用本模块的第0组属性;不是输入参数、行号或要计算的整数 |
attributes #0 = { ... } |
给出上面引用的整组属性;两份文件各有自己的#0,不能合并成同一组 |
noundef既不是“值不为空”,也不是“函数不抛异常”。它对值表示中的undefined/poison位提出条件;不能因为我们前面的有效输入没有触发它,就把它当成无意义装饰。函数参数和返回属性
真实O0
define i32 @transform(i32 noundef %0) #0 {
%2 = alloca i32, align 4
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca i32, align 4
store i32 %0, ptr %2, align 4
%6 = load i32, ptr %2, align 4
%7 = mul i32 %6, 2
store i32 %7, ptr %3, align 4
%8 = load i32, ptr %3, align 4
%9 = add i32 %8, 3
store i32 %9, ptr %4, align 4
%10 = load i32, ptr %2, align 4
%11 = mul i32 %10, 0
store i32 %11, ptr %5, align 4
%12 = load i32, ptr %4, align 4
%13 = load i32, ptr %5, align 4
%14 = add i32 %12, %13
ret i32 %14
}
展开这份O0实际的属性组
attributes #0 = { mustprogress noinline nounwind optnone ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "probe-stack"="__chkstk_darwin" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+bti,+ccdp,+ccidx,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8.5a,+v8a,+zcm,+zcz" }真实O2
define noundef i32 @transform(i32 noundef %0) local_unnamed_addr #0 {
%2 = shl i32 %0, 1
%3 = add i32 %2, 3
ret i32 %3
}
展开这份O2实际的属性组
attributes #0 = { mustprogress nofree norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "probe-stack"="__chkstk_darwin" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+bti,+ccdp,+ccidx,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8.5a,+v8a,+zcm,+zcz" }O0组里的optnone限制大多数优化,noinline禁止把此函数内联到调用处。O2里的memory(none)描述这个函数不访问内存;这是看这份优化后函数时的一项行为属性,不是“所有O2函数都不访问内存”。对本题数值预测,要求你读参数、函数体和上表列出的属性;其余目标CPU、features、栈保护与展开信息不要求逐项推演,但原始文件完整保留。它们仍然有各自的含义和约束,不能称为可随意删除的无效文字。
对输入7,O0通过局部存储得到17,O2通过左移与加法得到17。对于其余四个边界输入,两边也都符合I4的表。这里除了有限测试,还有一个针对当前unsigned合同的推导:乘0恒为0,乘2与合法左移1在此固定宽度下给出相同位模式,再加3得到同一个模运算结果。这不自动覆盖换成其他类型、加上额外标志或引入副作用的程序。
怎样把手写IR接到C++调用入口?
IR文件不是.cpp源码。Clang能读取.ll并生成目标文件,再让C++驱动程序调用其中的函数。extern "C"给C++声明指定C语言链接,以便与这些简单的IR全局函数名配合;它不把函数变成C源码或GPU kernel,也不是跨任意平台保证一套固定机器调用约定。这里在同一个工具链、目标平台和匹配类型声明下链接。
#include <array>
#include <iostream>
#include <limits>
extern "C" unsigned twice_next(unsigned);
extern "C" int signed_step(int);
extern "C" unsigned read_twice(unsigned);
int main() {
static_assert(std::numeric_limits<unsigned>::digits == 32);
static_assert(std::numeric_limits<int>::digits == 31);
const std::array<unsigned, 3> straight_inputs{0U, 5U, 4294967295U};
for (const unsigned x : straight_inputs) {
std::cout << "straight(" << x << ")=" << twice_next(x) << '\n';
}
const std::array<int, 3> branch_inputs{3, -2, 0};
for (const int x : branch_inputs) {
std::cout << "branch(" << x << ")=" << signed_step(x) << '\n';
}
const std::array<unsigned, 3> memory_inputs{7U, 0U, 4294967295U};
for (const unsigned x : memory_inputs) {
std::cout << "memory(" << x << ")=" << read_twice(x) << '\n';
}
// The independent runner compares all nine lines against specified results.
}
这个小入口只调用已经教过的函数并打印结果;它不是通过重复调用被测函数产生“期望值”。测试脚本另外比较已推演的结果,并对内存例的第二次store做9→10的单条件变体。返回/打印类型必须与函数的意图匹配:正负分支用有符号整数理解,位宽边界用已确认32位的unsigned理解。
从本页下载teaching.ll和check-teaching.cpp,放进同一文件夹,在终端进入该文件夹。先编译IR,再编译与链接调用入口;不要把带main的几个独立测试文件全部链接成同一个程序。手写IR未指定目标三元组,Clang按本机目标编译时可能提示覆盖模块目标;下面的-Wno-override-module只关闭这一类目标选择提示,不关闭语法或验证器错误。
clang++ -Wno-override-module -c teaching.ll -o teaching.o
clang++ -std=c++20 check-teaching.cpp teaching.o -o check-teaching
./check-teaching
若你本机生成的目标与本书记录不同,以自己的编译器诊断和实际记录为准。非法SSA的检查只验证拒绝;正确性拒绝和执行输出是两类不同证据,不能为了看到输出而强行执行被拒绝的程序。
要检查I2的反例,再下载bad-dominance.ll。在本页使用的Clang中,-Xclang把紧随其后的选项传给内部编译阶段,-llvm-verify-each启用LLVM验证检查;这是Clang专用选项,不能照搬给GCC。
clang++ -Wno-override-module -Xclang -llvm-verify-each -c bad-dominance.ll -o bad-dominance.o
预期诊断指出Instruction does not dominate all uses!,即定义没有支配所有使用。文件找不到、编译器不认识选项或其他报错都不满足这个预期。此命令不应成功产出可用目标文件;不要继续链接或运行这个反例。
独立变体:改成乘4再加1
下面是独立变体,没有覆盖旧实验源码。它使用同样的32位unsigned合同计算x * 4 + 1。先手算五个输入0、1、7、2147483648、4294967295,应分别得到1、5、29、1、4294967293。
extern "C" unsigned transform_four(unsigned x) {
return x * 4U + 1U;
}
检查入口复用SYS-00 S1的固定宽度类型,选择std::uint64_t保存这一次独立算术期望。先把输入转换到这个更宽的类型,再乘4加1,最后转回已经确认32位的unsigned。最大中间结果17179869181能放进uint64_t,转换按模4294967296得到期望值;它不是再调用一次transform_four当oracle。把这条独立的算术过程先写在纸上,再读检查入口。
#include <array>
#include <cstdint>
#include <iostream>
#include <limits>
extern "C" unsigned transform_four(unsigned);
int main() {
static_assert(std::numeric_limits<unsigned>::digits == 32);
const std::array<unsigned, 5> inputs{0U, 1U, 7U, 2147483648U, 4294967295U};
for (const unsigned x : inputs) {
const std::uint64_t wide{static_cast<std::uint64_t>(x) * 4U + 1U};
const unsigned expected{static_cast<unsigned>(wide)};
const unsigned actual{transform_four(x)};
if (actual != expected) {
std::cerr << "mismatch for " << x << ": " << actual
<< " expected " << expected << '\n';
return 1;
}
std::cout << x << " -> " << actual << '\n';
}
}
旧实验的oracle还用0xffffffffu与宽结果按位与。先读这个字面量:前缀0x表示十六进制,每位用0–9或a–f表示0–15,恰好对应4个二进制位;f表示15,即四个位全1。连续8个f表示32个位全1,数值为4294967295;末尾u表示无符号整数。与64位值运算时,这个掩码的高32位为0、低32位为1,用SYS已教的按位与筛出当前问题要保留的位。它不是字符串,也不表示小数或地址本身。
编译器选项-S -emit-llvm组合要求输出可读IR,-S单独使用则输出目标汇编。-O0与-O2选择优化级别;不能因此承诺新变体必定排成指定行数或只用某条指令。记录你实际生成的函数及五个输出,再比较哪些中间操作消失。
clang++ -std=c++20 -O2 -S -emit-llvm arithmetic-four.cpp -o four-O2.ll
clang++ -std=c++20 arithmetic-four.cpp check-four.cpp -o check-four
./check-four
旧O0/O2记录仍是旧源码的结果;新变体需要自己的结果记录。网站修改README或讲解位置,也不会把旧执行时间改成“刚刚重新运行”。原始IR在本页下载区可取到完整文件,包含函数体之外的实际属性和模块信息。
本页实际验证到哪里?
2026-09-08在macOS15.5 / arm64 / Apple Clang17执行:合法教学IR、单次store变体和乘4加1变体分别通过Debug、Release与ASan/UBSan模式,共9次运行,输出与本页推导相符;另生成新变体的O0/O2 IR,并确认非法支配例被验证器拒绝。sanitizer记录涵盖C++驱动与这些模式下的构建、运行,不证明手写IR每条指令都已插入检查。新变体IR只生成并保存,没有另做IR回编译运行;旧实验已保留的回编译记录仍按其原范围复用。
收尾:带着明确的读法回到实验
离开答案,分别画出SSA值表和内存表;用3与-2走完phi的两条来路;解释为什么非法支配不能靠删掉测试输入修复;再算真实transform最大32位输入的结果1。能够把这些过程说清,再进入编译实验任务复现、改写与记录。
本页覆盖的是这些真实文件所需的最小读法。它没有运行MLIR、设备代码或自定义优化pass,也不会把教材示例自动算成个人项目验收。
轮到你独立完成
先保存自己的预测和实现,再按需展开提示与答案。
练习1
设read_twice参数为7,逐行写出%slot、%first、%second与内存格状态。只把第二次store的9改为10,哪些SSA值改变?先预测,再用独立派生文件验证,保留原teaching.ll。
查看提示
- 把SSA名字表和内存格分开,不把store看作给%slot重新赋值。
- 第一次load已经完成;后来的store只能影响后面的读取。
对照答案与推理
alloca后%slot指向未初始化格;store参数后格为7;第一次load令%first=7;再store9后格为9;第二次load令%second=9,返回16。改成store10后%slot不变、%first仍7,格变10、%second变10,结果17。别给未初始化格预设0,别运行未初始化读取来猜值。
练习2
独立把x*2+3改成x*4+1:先列0、1、7、2147483648、4294967295的32位结果,再更新新源与独立oracle/输出夹具并生成自己的O0/O2。为何不能预先写死优化后必须是某三行?
查看提示
- 在更宽的中间类型里算期望,再按32位结果转换。
- 比较函数行为与实际产物;指令选择可能随编译器和目标变化。
对照答案与推理
五行结果为1、5、29、1、4294967293。本桥arithmetic-four/check-four提供独立对照,原实验文件不变。生成IR应记录工具版本和源身份;不同合法IR可以实现同样行为,较短也不等于性能更好。保留边界检查,不能为消除失败而只删除断言或随意改期望。
把理解说出来
以下问题应用本页已讲授的合同;依据技能主题编写,并非公司内部真题。
读码与预测
define i32 @twice_next(i32 %x)中的@、%、i32各表示什么?x=5时两条算术指令各生成什么?
对照推理与英文回答
@twice_next是全局函数名,%x是局部SSA参数名,i32表示32位整数,类型本身不附带C++的signed/unsigned含义。add生成%next=6,mul生成%result=12,ret返回12。
The at-sign names a global function; the percent-sign names a local SSA value. i32 is a thirty-two-bit integer type, not a C++ signedness declaration. For five, add produces six, multiply produces twelve, and ret returns twelve.分支与支配
signed_step(-2)经过哪条边,phi选谁?坏模块中的%plus只定义一次,为何仍非法?
对照推理与英文回答
有符号-2大于0为false,进入other,%minus=-3,从other进入join时phi选-3。坏模块的join用普通add读取只在positive定义的%plus;存在other到join的路径没有该定义,一次定义并不足以满足支配要求。
Negative two follows the other edge, which computes negative three; the phi selects that incoming value. In the invalid module, a normal use of plus is reachable without its definition. A unique name alone does not establish dominance.内存与找错
%slot没有被重新赋值,为何两次load分别得到7与9?align 4是否在初始化格子?
对照推理与英文回答
%slot是保持不变的地址值,store改变它指向的内存。两次load分别产生新的SSA值%first与%second。align 4描述对齐条件,不写入初始值;本例先store再load。
The pointer value stays fixed while stores change its memory. Each load defines a new SSA value. Alignment describes an alignment condition; it does not initialize memory. The example stores before reading.位宽与异常状态
真实O2对4294967295返回什么?把移位量1改32是否得到普通0,或必然被编译器拒绝?
对照推理与英文回答
先左移1得到4294967294,再加3回绕得到1。移位量32产生poison,不能赋予普通0结果,也不是必然的语法拒绝。poison可传播,某些使用使行为未定义;本课不执行该路径、不把它当C++异常。
The original returns one after thirty-two-bit wraparound. A shift count of thirty-two produces poison, not an ordinary zero and not necessarily a parse error. Poison may propagate and certain uses make behavior undefined; it is not a C++ exception.真实产物与证据
O0的%2为何不是遗漏了%1?五输入一致是否证明所有输入正确?Apple Clang 17是否就是上游LLVM 17?
对照推理与英文回答
%0是参数,隐式未命名入口块占%1,下一结果从%2编号;数字不是机器寄存器编号。五输入记录覆盖指定样本,全部输入的结论另需推导。Apple版本字符串不能直接视为相同编号的上游LLVM版本。
The unnamed entry block uses slot one after parameter zero, so the next unnamed result is two. These are not hardware register numbers. Five cases are sampled evidence, not exhaustive proof. Apple's version label is not the upstream LLVM release number.属性追问
真实O2函数头的两个noundef、local_unnamed_addr与#0分别要求读懂什么?为何不能把属性都叫装饰?
对照推理与英文回答
两个noundef分别约束返回值与参数:值表示含undefined或poison位会违反合同而导致UB。local_unnamed_addr表示函数地址在本模块内不重要;#0引用模块中的attributes #0组。属性影响合法优化和生成行为;本题只读相应窄合同,目标features等长串明确不属于逐项数值预测,并非无效信息。
The two noundef positions constrain the return value and the argument; undefined or poison value bits violate that contract. Local unnamed address means the function address is insignificant within the module. Hash zero references an attribute group. These constraints matter even when target-specific details are outside this exercise.同源示例下载
- teaching.ll三个有效函数:计算、分支与两次读取
- bad-dominance.ll必须拒绝:正路定义却在汇合处普通使用
- check-teaching.cpp有效教学IR的C++调用入口
- arithmetic-four.cpp独立改写:x乘4再加1
- check-four.cpp改写的独立宽整数oracle
- O0.ll复用原始产物:O0.ll
- O2.ll复用原始产物:O2.ll
手写IR、预期拒绝的反例、配套C++和原始编译产物分别标注;按正文指定步骤使用,不把所有文件都当作独立可运行程序。
资料与查证
- LLVM:标识符与函数 ↗
Identifiers与Functions:@/%名称、无名参数/基本块/临时值共用编号;函数、块与终结指令。持续更新文档,不将Apple工具版本标签映射成上游发行编号。
- LLVM:良构性与支配 ↗
Well-Formedness:解析可接受的语法与合法SSA不同,普通操作数的定义必须支配使用;实际拒绝另由本桥保存的verifier诊断证明。
- LLVM:icmp、br与phi ↗
phi Instruction的Syntax与Semantics,以及icmp/br章节;phi按进入块的前驱边取值,sgt选择有符号比较语义。
- LLVM:alloca、load与store ↗
alloca/load/store的Syntax、Arguments与Semantics;本例先写后读,align 4是对齐信息,不是初值。
- LLVM:shl与无回绕承诺 ↗
shl Instruction:移位量与位宽、移出、nsw/nuw条件及poison;本例真实O2的shl/add没有这些标记。
- LLVM:poison与undef ↗
Poison Values与Undefined Values:状态不是普通固定整数,传播与导致UB的使用分开。没有用运行一个不受约束结果来指定正常输出。
- LLVM:noundef参数与返回合同 ↗
Parameter Attributes中的noundef:值表示含undefined/poison位导致未定义行为,不是“不会抛异常”;返回与参数位置分别读。
- LLVM:函数地址属性 ↗
Functions中的local_unnamed_addr:地址在本模块内不重要;不等于函数没有名字、没有地址或仅模块内可调用。
- LLVM:属性组与函数属性 ↗
#0引用模块属性组;Function Attributes核对optnone、noinline、memory(none)等。目标CPU/features、栈保护与展开字段在本题非数值预测范围。
- Clang:编译阶段选项 ↗
Stage Selection Options:-S、-c与-emit-llvm;本桥保存实际命令和工具版本,不把输出文字相同当全部正确性要求。