LLVM后端指令选择

把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翻译成SelectionDAG
  • CodeGenAndEmitDAG:对构建好的DAG执行Legalize、Combine、指令选择等一系列操作

如果你的后端指令选择出了问题,第一个断点就打在runOnMachineFunction上,顺着往下走。


4. SelectionDAG的构建:IR是怎么变成DAG的

4.1 一条简单指令的"变形记"

假设我们有这么一条LLVM IR:

llvm 复制代码
%add = add i32 %a, %b

SelectionDAGBuilder的处理过程:

  1. 查表取操作数 :在内部NodeMap中查找%a%b对应的SDValue

  2. 确定DAG操作码IR::Add映射为ISD::ADD

  3. 调用DAG.getNode()创建节点

    cpp 复制代码
    SDValue Result = DAG.getNode(ISD::ADD, DL, MVT::i32, Op1, Op2);
  4. CSE(公共子表达式消除)getNode内部会检查是否已存在相同节点,存在则复用

  5. 更新映射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做的事情就是:

  1. 解析所有.td文件中的Pat定义
  2. 构建一棵决策树(通过EmitDAGISelBody
  3. 生成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_CheckOpcodeOPC_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节点时:

  1. MatchTable读取到OPC_CheckOpcode指令,检查当前节点是否为ISD::MUL
  2. 读取OPC_CheckType,检查操作数类型(比如i32
  3. 读取OPC_CheckPredicate,检查目标CPU是否支持整数乘法
  4. 全部通过后,执行OPC_EmitInstruction,创建对应的MULTu机器指令
  5. 执行OPC_CompleteMatch,完成匹配

对于浮点乘法(fmul),过程类似但最终会映射到Cpu0:MFLO节点:


8. 补充:Legalize------为什么需要"合法化"

8.1 Type Legalization

LLVM IR允许任意位宽的类型:i1i17i48i128...

但真实的CPU寄存器只有那么几种:32位、64位。视频中演示的RISC-V(Cpu0)后端只支持i32

Type Legalization要做的就是把这些"不合法"的类型转换成目标平台支持的合法类型。三种策略:

策略 场景 示例
Promote(提升) 类型太小 i1i32(零扩展或符号扩展)
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, 0Xmul X, 2shl X, 1xor X, X0
  • 死代码消除:没人用的节点直接删掉

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

在代码中打断点

推荐断点位置(按调试顺序):

  1. SelectionDAGISel::runOnMachineFunction --- 一切开始的地方
  2. SelectionDAGISel::SelectAllBasicBlocks --- IR到DAG的构建循环
  3. DAGCombiner::Run --- Combine阶段
  4. SelectionDAGISel::Select --- 指令选择入口
  5. 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,顺着SelectAllBasicBlocksCodeGenAndEmitDAGSelectCode一路跟

理解指令选择的核心在于理解数据结构的演变------从LLVM IR到SelectionDAG到MachineInstr,每一步都在丢失抽象、增加平台细节。而TableGen的Pat模式和MatchTable则是把这种"模式匹配"任务自动化的关键工程手段。

踩坑的时候记住一句话:MatchTable不是魔法,它就是个巨大的switch-case自动机。trace一下就能看懂。

相关推荐
hunterandroid2 小时前
Room 数据库迁移:让本地数据升级更稳
android·前端
阿巴斯甜2 小时前
Compose NavHost的使用:
android
某不知名網友2 小时前
C++ 七大排序算法完整讲解
java·算法·排序算法
雁鸣零落2 小时前
浏览器扩展 CaptionGo,在网页视频上显示双语字幕,支持 PC 和手机端使用
android·chrome·edge·firefox·安卓
CodeStats2 小时前
《源纹天书》第一百六十一章至第一百六十五章:开源化宣言、元定义的力量、栈帧重构、容器道场升级、技术债的化身!
java·源纹天书
不良手残2 小时前
MyBatisPlus代码自动生成器
java
SamDeepThinking2 小时前
一次线程池线上故障复盘:四层防线如何避免数据丢失
java·后端·程序员
2501_915106322 小时前
iOS 软件测试工具性能监控、日志分析 KeyMob、Instruments等
android·ios·小程序·https·uni-app·iphone·webview
Cobyte2 小时前
24.Vue Vapor 组件事件 emit 的实现
前端·javascript·vue.js