把LLVM后端指令选择啃透:从SelectionDAG到TableGen Pat,一个字节都别想糊弄过去
写在前面
调试LLVM后端的时候,你有没有经历过这种绝望------
IR是对的,.td文件也是照着官方文档抄的,llc一跑,直接Segfault。或者更糟:它不报错,静默生成了完全错误的汇编。
翻遍全网教程,要么是"把LLVM官方的DAG图贴出来然后说一句'如图所示'就完事",要么就是讲完概念就没了,代码入口在哪、TableGen的Pat到底怎么匹配到C++函数的、MatchTable那玩意到底是怎么跑起来的------一概不提。
这篇文章就是来解决这个问题的。基于LLVM后端指令选择的完整流程,从SelectionDAG的构建一路跟到TableGen生成的匹配器代码,每一步都有代码级定位和解释。涉及到的概念如果比较绕,我会补充背景说明(你不需要再去翻别的资料)。
读完这篇文章,你应该能回答这几个问题:
- SelectionDAGISel的入口函数是哪个,调用链长什么样
- SelectionDAGBuilder怎么把一条
add i32 %a, %b变成DAG节点的 - LegalizeTypes和LegalizeDAG到底干了什么,为什么要分两步
- TableGen的
Pat模式本质是什么,MatchTable是怎么被驱动执行的 - 如果TableGen搞不定(比如一条IR指令需要展开成多条机器指令),C++端怎么写Custom Lowering
好,我们开始。
1. 全局视角:LLVM后端的"流水线"
先看一眼整体架构。LLVM后端接收的是已经优化过的LLVM IR,输出的是目标平台的汇编或目标文件。

上图展示了后端的大致流程------LLVM IR进入后端后,经过一系列Pass,最终产出汇编代码。但这个图太"干净"了,看不到内部细节。
把镜头拉近一点:

核心Pass流水线如下:
LLVM IR
→ Instruction Selection(指令选择)
→ Instruction Scheduling(指令调度)
→ Register Allocation(寄存器分配)
→ Instruction Scheduling(再次调度)
→ Code Emission(代码发射)
→ Assembly / Object Code
每个阶段操作的数据结构也不同:
| 阶段 | 数据结构 |
|---|---|
| 指令选择前 | SelectionDAG(有向无环图) |
| 指令选择后 | MachineInstr(MI形式的机器指令) |
| 代码发射 | MCInst(更低层的汇编指令表示) |
Instruction Selection是后端的第一个重头戏,也是本文的核心。它的任务就是把平台无关的LLVM IR,翻译成目标平台(比如RISC-V、ARM、x86)能认的机器指令。
2. 指令选择的代码地图
别一上来就看源码,先搞清楚东西在哪。指令选择的代码分布在LLVM源码树的以下位置:

核心组件分为四大块:
| 组件 | 文件位置 | 职责 |
|---|---|---|
| SelectionDAGBuilder | SelectionDAGBuilder.cpp |
把LLVM IR"降级"为SelectionDAG节点 |
| DAGCombiner | DAGCombiner.cpp |
对DAG做优化和规范化(canonicalization) |
| SelectionDAGToDAG | <Target>ISelDAGToDAG.cpp |
目标平台指令选择(核心匹配逻辑) |
| TableGen生成代码 | <Target>GenDAGISel.inc |
由.td文件自动生成的匹配器状态机 |
另外还有一组关键概念需要区分:
- LegalizeTypes --- 处理"目标平台不支持的数据类型 ",比如把
i64拆成两个i32 - LegalizeDAG (也叫Operation Legalization) --- 处理"目标平台不支持的操作 ",比如目标平台没有
i64乘法指令
这两个步骤在指令选择之前执行,确保后续匹配器面对的都是"合法"的DAG节点。视频中用RISC-V(Cpu0)作为演示后端,这两个步骤体现得非常清楚。
3. 入口:从runOnMachineFunction到SelectionDAG
指令选择的入口链如下:

调用链大致是:
SelectionDAGISel::runOnMachineFunction(MachineFunction &MF)
→ SelectionDAGISel::SelectAllBasicBlocks()
→ 逐BasicBlock调用 SelectionDAGBuilder::visit(BasicBlock)
→ 逐条指令调用 visit<Opcode>()
→ SelectionDAGISel::CodeGenAndEmitDAG()
→ runSelectionDAG() // 这里触发指令选择
两个关键函数需要记住名字:
SelectAllBasicBlocks:遍历整个函数的每一个BasicBlock,把IR翻译成SelectionDAGCodeGenAndEmitDAG:对构建好的DAG执行Legalize、Combine、指令选择等一系列操作
如果你的后端指令选择出了问题,第一个断点就打在runOnMachineFunction上,顺着往下走。
4. SelectionDAG的构建:IR是怎么变成DAG的
4.1 一条简单指令的"变形记"
假设我们有这么一条LLVM IR:
llvm
%add = add i32 %a, %b
SelectionDAGBuilder的处理过程:
-
查表取操作数 :在内部
NodeMap中查找%a和%b对应的SDValue -
确定DAG操作码 :
IR::Add映射为ISD::ADD -
调用DAG.getNode()创建节点:
cppSDValue Result = DAG.getNode(ISD::ADD, DL, MVT::i32, Op1, Op2); -
CSE(公共子表达式消除) :
getNode内部会检查是否已存在相同节点,存在则复用 -
更新映射 :
NodeMap[&I] = Result
这里面有两个容易忽略的细节:
细节一 :
DAG.getNode()内部做CSE。这意味着如果你的后端生成了重复的DAG节点,它们会在构建阶段就被合并掉,不需要等到后面的优化Pass。
细节二 :NodeMap是同一个BasicBlock内的映射,跨BasicBlock的值通过PHI节点和CopyFromReg/CopyToReg来传递。
4.2 复杂一点的情况:访存操作
对于Load/Store指令,SelectionDAGBuilder会额外处理Chain(依赖链):
store i32 %val, i32* %ptr
这条指令会生成:
- 一个
ISD::STORE节点(表示存储操作本身) - 一个Chain边(
MVT::Other类型),把Store和前面所有可能影响内存的操作串起来
Chain的存在保证了内存操作的顺序不会被后续的调度Pass打乱。你可以把它理解成一个"内存屏障"------Chain上相邻的两个访存操作,不能交换顺序。
4.3 调用约定和Glue
函数调用更复杂,涉及到Glue(粘合边):

Glue的作用是把多个必须"紧挨在一起"的节点粘住(比如乘法的高低位结果、调用指令和它的参数传递指令),防止指令调度把它们拆散。视频中展示的MUL操作就需要用Glue把Lo(低位结果)和Hi(高位结果)粘在一起:
MUL → Lo (结果0)
→ Hi (结果1, 通过Glue连接)
SDNPOutGlue标记的节点会把Glue传递给下一个节点,形成一个不允许被调度器拆散的原子序列。
5. 指令选择的核心循环
构建完DAG后,SelectCode登场。这是TableGen自动生成的核心匹配函数。

核心逻辑是一个巨大的switch语句,对每个DAG节点的操作码做分发:
cpp
void SelectionDAGISel::Select(SDNode *Node) {
switch (Node->getOpcode()) {
case ISD::ADD: // 匹配加法
case ISD::LOAD: // 匹配加载
case ISD::STORE: // 匹配存储
// ... 几十上百个case
default:
SelectCode(Node); // 交给TableGen生成的自动机
}
}
大部分情况下,SelectCode就能搞定。它是一个由TableGen从.td文件自动编译出来的确定性有限自动机(DFA),用MatchTable驱动匹配过程。
6. TableGen Pat模式:用声明式语法描述指令选择
6.1 .td文件长什么样
这是视频中展示的一个典型Pattern定义:

LLVM使用TableGen工具,在.td文件中用声明式语法描述指令和匹配模式。核心语法:
tablegen
// 定义一个Pat模式:
// 当DAG中出现 (add GPR:$src1, GPR:$src2) 时,
// 把它替换为 MY_ADD 机器指令
def : Pat<(add i32:$src1, i32:$src2), (MY_ADD $src1, $src2)>;
TableGen会扫描所有.td文件,生成一个巨大的<Target>GenDAGISel.inc文件,里面包含了完整的匹配器状态机。
6.2 Pat模式的本质
不要把Pat想得太神秘。它本质上就是一组重写规则:
Pattern = (左侧DAG片段) → (右侧机器指令)
TableGen做的事情就是:
- 解析所有
.td文件中的Pat定义 - 构建一棵决策树(通过
EmitDAGISelBody) - 生成C++代码------这个代码就是一个巨大的状态机,逐节点匹配DAG
6.3 指令操作码与汇编格式
除了Pat模式,.td文件还要定义每条指令本身的属性:

包括:
- 操作码 :
OPC_ADD,OPC_LW,OPC_LUI等 - 汇编格式字符串 :比如
"add $dst, $src1, $src2" - 操作数列表:输入/输出寄存器、立即数
- 谓词条件(Predicates):这条指令在哪些CPU特性下可用
7. MatchTable:匹配引擎的内部机制
这是整个指令选择最核心的执行机制,也是视频花了大量篇幅讲的部分。
7.1 MatchTable的结构

MatchTable本质上是一个字节码数组,里面每条"指令"代表一个匹配步骤。核心字段:
| 字段 | 含义 |
|---|---|
Opcode |
当前要执行的操作(如OPC_CheckOpcode、OPC_CheckPredicate等) |
MatcherIndex |
当前匹配步骤的索引 |
FailIndex |
匹配失败时跳转到哪个步骤 |
NextIndex |
下一步的索引 |
执行引擎通过一个while循环不断从MatchTable中读取指令并执行:
cpp
while (true) {
unsigned Opcode = MatcherTable[MatcherIndex++];
switch (Opcode) {
case OPC_CheckOpcode: // 检查DAG节点的操作码
case OPC_CheckPredicate: // 检查谓词条件
case OPC_CheckType: // 检查数据类型
case OPC_EmitInstruction: // 发射匹配到的机器指令
case OPC_CompleteMatch: // 匹配成功,退出
// ...
}
}
如果某一步匹配失败,执行引擎会跳到FailIndex指向的位置,尝试另一条匹配路径。
7.2 PredicateTable
除了MatchTable,还有一个PredicateTable:

它存储了目标CPU的特性信息 ------比如这条指令需要FPU支持吗?需要SSE2吗?匹配引擎在执行OPC_CheckPredicate时,会查询PredicateTable来验证当前CPU是否满足条件。
例如,一条浮点乘法指令可能要求HasFPU谓词为真。如果目标CPU没有FPU,匹配器就会跳过这条规则。
7.3 一个具体的匹配过程:MUL指令

当匹配器遇到一个ISD::MUL节点时:
- MatchTable读取到
OPC_CheckOpcode指令,检查当前节点是否为ISD::MUL - 读取
OPC_CheckType,检查操作数类型(比如i32) - 读取
OPC_CheckPredicate,检查目标CPU是否支持整数乘法 - 全部通过后,执行
OPC_EmitInstruction,创建对应的MULTu机器指令 - 执行
OPC_CompleteMatch,完成匹配
对于浮点乘法(fmul),过程类似但最终会映射到Cpu0:MFLO节点:

8. 补充:Legalize------为什么需要"合法化"
8.1 Type Legalization
LLVM IR允许任意位宽的类型:i1、i17、i48、i128...
但真实的CPU寄存器只有那么几种:32位、64位。视频中演示的RISC-V(Cpu0)后端只支持i32。
Type Legalization要做的就是把这些"不合法"的类型转换成目标平台支持的合法类型。三种策略:
| 策略 | 场景 | 示例 |
|---|---|---|
| Promote(提升) | 类型太小 | i1 → i32(零扩展或符号扩展) |
| Expand(展开) | 类型太大 | i64 → 2×i32(拆成高低两部分) |
| SoftFloat(软浮点) | 无硬件浮点支持 | 浮点运算替换为库函数调用 |
8.2 Operation Legalization(LegalizeDAG)
类型合法了,操作本身不一定合法。举个典型例子:你的目标平台有i32寄存器,但没有i32的乘法指令(一些小嵌入式CPU就是这样)。
这时候Operation Legalization会把不支持的操作用一系列支持的操作替代。比如:
i32 mul → 展开为移位+加法的序列(模拟乘法)
更彻底的情况------如果整个浮点运算都不支持,所有浮点指令都会被替换为__addsf3、__mulsf3等软浮点库函数调用。
8.3 DAG Combiner:规范化与优化
在Legalize的前后各跑一次DAGCombiner,它做的事很简单:
- 规范化 (Canonicalization):把等价的DAG模式统一成一种形式,减少匹配器需要处理的模式数量。比如
add 5, X统一成add X, 5 - 代数化简 :
add X, 0→X;mul X, 2→shl X, 1;xor X, X→0 - 死代码消除:没人用的节点直接删掉
Combine的存在是为了让后续的指令选择(Pattern Matching)更省事------匹配器只需要写一种规范形式就够了,不需要考虑所有的等价变体。
9. 进阶:当TableGen不够用------Custom C++ Lowering
Pat模式优雅、声明式、不容易出错------但它有天花板。以下场景TableGen搞不定:
- 一条IR指令需要展开成多条机器指令 (比如把一个64位立即数加载拆成
LUI+ADDI) - 匹配条件涉及运行时计算(比如需要根据立即数的位模式决定用哪种指令)
- 需要修改DAG拓扑(删除/替换有多个use的节点)
这时候就得写C++代码。LLVM提供了两个钩子:
9.1 ComplexPattern
用于复杂的操作数匹配 。比如地址模式:你的平台支持基址+偏移寻址,但偏移量必须是4的倍数------这种条件TableGen的声明式模式不好表达,写C++判断就简单多了。
cpp
bool SelectAddr(SDValue Addr, SDValue &Base, SDValue &Offset) {
if (Addr.getOpcode() == ISD::ADD) {
// 检查偏移量是否是4的倍数
if (auto *CN = dyn_cast<ConstantSDNode>(Addr.getOperand(1))) {
if (CN->getZExtValue() % 4 == 0) {
Base = Addr.getOperand(0);
Offset = Addr.getOperand(1);
return true;
}
}
}
return false;
}
在.td文件中声明:
tablegen
def addr : ComplexPattern<iPTR, 2, "SelectAddr">;
9.2 重写Select函数
更彻底的方式------重写SelectionDAGISel::Select函数,在手写的C++代码里直接创建机器指令:
cpp
void MyTargetDAGToDAGISel::Select(SDNode *Node) {
if (Node->getOpcode() == ISD::Constant) {
uint64_t Val = cast<ConstantSDNode>(Node)->getZExtValue();
if (Val > 0xFFFF) {
// 手动拆成两条指令: LUI + ADDI
SDNode *LUI = CurDAG->getMachineNode(MY_LUI, DL, MVT::i32,
CurDAG->getTargetConstant(Val >> 12, DL, MVT::i32));
SDNode *ADDI = CurDAG->getMachineNode(MY_ADDI, DL, MVT::i32,
SDValue(LUI, 0),
CurDAG->getTargetConstant(Val & 0xFFF, DL, MVT::i32));
ReplaceNode(Node, ADDI);
return;
}
}
// 搞不定的交给TableGen自动机
SelectCode(Node);
}
关键原则:先试TableGen Pat,不行再用ComplexPattern,最后才考虑手写Select。 声明式代码比手写匹配逻辑更容易维护,也不容易出bug。
10. 完整流程串联
把前面的东西串起来,LLVM后端指令选择的完整执行顺序如下:
1. SelectionDAGBuilder::visit(BasicBlock)
------ 逐条IR指令转换为SelectionDAG节点
2. LegalizeTypes
------ 非法类型处理(Promote / Expand / SoftFloat)
3. DAGCombiner (第一轮)
------ 规范化 + 优化
4. LegalizeDAG(Operation Legalization)
------ 非法操作替换为合法操作序列
5. DAGCombiner (第二轮)
------ 对新产生的DAG子图再次优化
6. SelectionDAGISel::Select
------ 核心指令选择:
- 先走手写C++的Select逻辑(Custom Lowering)
- 再走TableGen生成的SelectCode(MatchTable状态机)
7. Scheduler
------ 指令调度(可能会因为Glue约束而保持某些指令的顺序)
8. 输出MachineInstr
------ 进入寄存器分配等后续Pass
11. 调试技巧
视频中讲师多次打开源码演示,要理解LLVM后端,必须学会自己debug。几个实用技巧:
打印DAG
bash
llc -view-dag-combine1-dags input.ll # 第一轮Combine后的DAG
llc -view-dag-combine2-dags input.ll # 第二轮Combine后的DAG
llc -view-isel-dags input.ll # 指令选择前的DAG
这会生成.dot文件,用Graphviz打开可以看到可视化的DAG图。
只打印文本(不需要Graphviz)
bash
llc -debug-only=isel input.ll # 指令选择调试信息
llc -debug-only=dagcombine input.ll # DAGCombiner调试信息
停在一个特定阶段
bash
llc -print-before=instruction-select input.ll
llc -print-after=instruction-select input.ll
在代码中打断点
推荐断点位置(按调试顺序):
SelectionDAGISel::runOnMachineFunction--- 一切开始的地方SelectionDAGISel::SelectAllBasicBlocks--- IR到DAG的构建循环DAGCombiner::Run--- Combine阶段SelectionDAGISel::Select--- 指令选择入口SelectionDAGISel::SelectCode--- TableGen自动机入口
12. 小结
这篇博客跟着视频,把LLVM后端指令选择从构建到匹配完整走了一遍。核心要点:
- IR → DAG:SelectionDAGBuilder逐BasicBlock逐指令构建,复杂情况用Chain(内存依赖)和Glue(原子序列)来处理
- Legalize:Type Legalization + Operation Legalization 两轮清理,确保匹配器只看到合法节点
- DAGCombine:规范化+化简,减少匹配器的工作量
- Pattern Matching:TableGen的Pat声明式模式 → 编译成MatchTable字节码 → SelectCode状态机执行
- Custom Lowering:Pat搞不定的,用ComplexPattern或重写Select函数手动处理
- 入口定位 :出问题先打
runOnMachineFunction,顺着SelectAllBasicBlocks→CodeGenAndEmitDAG→SelectCode一路跟
理解指令选择的核心在于理解数据结构的演变------从LLVM IR到SelectionDAG到MachineInstr,每一步都在丢失抽象、增加平台细节。而TableGen的Pat模式和MatchTable则是把这种"模式匹配"任务自动化的关键工程手段。
踩坑的时候记住一句话:MatchTable不是魔法,它就是个巨大的switch-case自动机。trace一下就能看懂。