资讯动态

LLVM TableGen实战指南:从DSL语法到自动生成后端代码

发布时间:2026/10/14 4:34:36 来源:尧图企业网站定制
写编译器后端的人最烦的事情是什么我猜十有八九是“重复”。同一条指令的编码信息要在指令选择器里描述一次在汇编器里写一次在反汇编器里再写一次最后还要在机器模型里维护一份只要漏掉一处生成出来的代码能错得你怀疑人生。LLVM解决这个问题的答案就是TableGen一套专门用来描述目标机器信息的声明式语言配合llvm-tblgen工具自动生成C代码。这篇文章是这个系列的第57期我想把TableGen从设计哲学到语法基础、从实战案例到调试技巧尽量一次讲透。先说清楚TableGen到底解决什么问题它不负责实现算法不负责做代码优化它只负责“描述”。你用.td文件描述寄存器、指令格式、指令选择模式、调用约定、特征开关llvm-tblgen根据这些描述生成对应的C头文件和.cpp文件供后端各个模块使用。简单说它像是一个给编译器后端专用的模板引擎只不过这个模板的语法是为“描述硬件结构”量身定做的。这篇文章适合正在写LLVM后端、或者想搞清楚.td文件背后原理的人也适合对DSL设计感兴趣、想观察一门语言如何解决实际问题的人。我会把后端开发中容易被忽略的语法细节和工程化经验一并拆开讲。1. TableGen的设计哲学为什么后端开发离不开它1.1 没有TableGen的日子是怎么过的先想象一下没有TableGen的年代。你要给一个简单的RISC处理器写后端光指令编码这一件事就需要你手工维护几张表一张表告诉指令选择器什么DAG节点对应什么指令一张表告诉汇编器助记符怎么编码成二进制一张表告诉反汇编器二进制怎么解码成助记符。这三张表内容高度重叠但格式完全不同一旦新增一条指令你得同步改三个地方。麻烦的还不只是重复劳动更可怕的是不一致某次你只改了指令选择器那张表忘了改汇编器生成的代码在合法路径上跑得好好的一到汇编阶段就悄悄出错这种bug定位成本非常高。TableGen正是冲着这个痛点来的把信息集中在一处用一套描述语言表达“有什么东西、字段是什么、怎么继承、怎么复用”然后由工具自动展开成多份目标代码。这样指令选择、汇编、反汇编、寄存器分配、机器模型等模块看到的都是同一份“事实来源”不一致的问题从根上被消灭。1.2 它不是模板引擎也不是C第一次看到.td文件的人很容易把它误解成某种C模板或者XML配置。实际上TableGen是一门独立的领域专用语言有自己的语法、类型系统和内置函数。它和C模板最大的区别在于C模板在编译期进行类型计算和代码生成而TableGen的输入是“描述”输出是“代码生成器的输入数据”。你可以把它想成Excel表格的加强版每一行是一条记录Record每一列是一个字段Field不同的是TableGen不止有表格还有类的继承、参数化、条件判断、字符串拼接、类型转换等能力。这些能力让它可以描述非常复杂的硬件结构而不只是死板的二维表。1.3 它真正送给你的东西声明式描述与自动生成后端代码里最无聊、最容易出错的部分就是那些高度重复的结构化数据比如寄存器列表、指令列表、调度模型。把这些交给TableGen之后你得到的不仅仅是少写代码的轻松更是一套可追踪、可检错的系统。llvm-tblgen会帮你检查类型是否匹配、必填字段是否缺失、继承关系是否冲突很多低级错误在构建期就被拦下来了而不是等到生成的代码跑起来之后才暴露。另一个容易被忽略的好处是可移植性。同一份.td文件可以喂给不同的TableGen后端生成指令选择器代码、汇编器匹配表、反汇编器解码表、目标特征查询函数。你想新增一个输出只需要写一个TableGen后端不用改动任何描述文件。这种“一处描述、多方消费”的架构是LLVM多个后端能共享大量基础设施的重要原因。2. 语法基石Record、Class、Multiclass与方程2.1 Record和Field最小的信息单元TableGen里的基本单位是Record你可以把Record理解成一条带类型的结构化记录。每个Record由若干个Field组成Field有名字和类型。最常见的类型有bit、int、string、bitsn、listT、dag以及代码片段类型code。比如我要描述一个通用寄存器可以写成def R0 : Registerr0 { let Encoding 0; let DwarfRegNum [0]; }这里的R0就是一个Record它继承了Register这个类并且覆盖了Encoding和DwarfRegNum两个字段。注意let关键字它的作用是在这个Record内部给继承来的字段赋值。字段还分两种一种是“可覆盖的”field一种是“不可覆盖的”默认情况下绝大多数字段都可以通过let重新赋值这也是TableGen和普通声明式语言不太一样的地方它允许子类里对细节做局部调整。bitsn类型可能初看有点绕它表示一个固定长度为n的位串。在指令编码描述里特别常见比如bits32表示32位指令编码。这类字段支持按位赋值也支持用?表示未知值未知值通常由后续的后端逻辑来填充比如在指令选择阶段再确定某些位。2.2 Class模板化描述的标准姿势Class是TableGen实现复用的核心机制和面向对象里的类类似但又不太一样。Class可以定义字段、定义默认值、声明参数然后被def或另一个class继承。看个例子class Instructiondag outs, dag ins, string asm, listdag pattern : InstructionBase { dag OutOperandList outs; dag InOperandList ins; string AsmString asm; listdag Pattern pattern; }这里定义了一个名为Instruction的类它带四个参数并且把参数赋给对应的字段。之后定义具体指令时你只需要写def ADD : Instruction(outs GPR:$rd), (ins GPR:$rs1, GPR:$rs2), add $rd, $rs1, $rs2, [(set GPR:$rd, (add GPR:$rs1, GPR:$rs2))];四个参数分别描述了输出操作数、输入操作数、汇编助记符、选择模式信息密度很高比C写结构体初始化直观得多。Class还能继承其他Class比如InstructionBase可以定义所有指令都有的字段Instruction再基于它做扩展这样设计可以让每种目标机器的指令描述保持一致的风格。2.3 Multiclass与defm一条命令生产一族指令Class解决的是单条指令的定义但真实指令集里大量指令是成族出现的典型的就是加法指令不同位宽下可能有ADD、ADDW不同操作数类型又可能有浮点加法、矢量加法。如果每条都写一次def信息重复不说还容易漏掉某一条。Multiclass就是为这个场景设计的。它允许你一次定义多个Record然后用defm扩展出所有变体multiclass AddLikedag outs, dag ins, string asm, listdag pattern { def _32 : Instructionouts, ins, asm, pattern; def _64 : Instructionouts, ins, asm#.64, pattern; } defm ADD : AddLike(outs GPR64:$rd), (ins GPR64:$rs1, GPR64:$rs2), add, [(set GPR64:$rd, (add GPR64:$rs1, GPR64:$rs2))];执行defm之后实际会生成两个RecordADD_32和ADD_64。注意asm#.64里的#操作符它负责字符串拼接这是Multiclass里很常用的技巧。当然defm嵌套defm、在Multiclass内部使用foreach也都是支持的组合起来表达能力很强。2.4 let、foreach与内置函数描述语言的“编程能力”let除了可以在def块内部使用也可以用在defm或class定义的外层对整个作用域生效。例如let Predicates [Is64Bit] in { defm ADD : AddLike...; defm SUB : SubLike...; }这样多条指令可以一次性统一设置Predicates字段不用每条单独写。foreach的用法和普通语言里的循环类似可以用来批量生成列表字段或者批量定义Record。内置函数数量也不少常用的有字符串操作的!strconcat、类型转换的!castT、条件判断的!if、列表操作的!listconcat、整数比较的!eq和!lt。这些函数让TableGen有条件描述不同配置比如根据特征开关决定某条指令是否生成。注意TableGen的内置函数都以!开头这是它和别的语言很不一样的风格。初学时容易把!if理解成C语言的三目运算符但它的求值时机和类型推导规则有区别建议多跑几个例子体会。3. 实战环节从零写一个目标机器的核心描述3.1 先定一个小目标设计最小指令集理论说多了容易飘这里拿一个我实际用过的极简RISC风格指令集来示范。它只有两条计算指令和一条内存加载指令ADD、SUB、LOAD。寄存器共8个分别是x0到x7每个寄存器64位。假设指令编码格式固定为32位操作码占8位两个源寄存器各占4位一个目的寄存器占4位剩余位保留。写TableGen之前我建议先把指令编码表在纸上画清楚哪怕只是草稿。很多人直接上手写.td结果字段对不上调起来很痛苦。先把操作码、字段位宽、寄存器编号约定列出来后面写描述就是“翻译”的过程。3.2 定义寄存器与寄存器类寄存器描述的第一步是定义单个寄存器foreach I 0-7 in { def R#I : Registerx#I { let Encoding I; let DwarfRegNum [I]; } }这里用了foreach配合字符串拼接直接生成了R0到R7这8个寄存器Record。注意def R#I里的#在Record名字里也能做拼接这是TableGen的一个语法糖。有了寄存器之后需要把它们归入一个寄存器类告诉LLVM这组寄存器在指令里是可以互换的def GPR : RegisterClassGPR64, [i64], 8, (sequence R%u, 0, 7) { let Size 64; }这行的关键信息有三个寄存器类是GPR64支持的参数类型是i64分配基准对齐是8字节。(sequence R%u, 0, 7)这种写法表示从R0到R7依次展开等价于手动列出8个寄存器。这样定义之后指令描述里写GPR:$rdLLVM就能知道这里接受的是一个64位通用寄存器。3.3 定义指令格式与指令编码指令格式的本质是把指令的各个字段按位宽组织成编码。TableGen里常用bits32加let的方式描述class InstRdag outs, dag ins, string asm, bits8 opcode : Instructionouts, ins, asm, [] { bits32 Inst; bits8 Opcode opcode; bits4 Rs1; bits4 Rs2; bits4 Rd; let Inst{7-0} Opcode; let Inst{11-8} Rd; let Inst{15-12} Rs1; let Inst{19-16} Rs2; }这里把Opcode、Rd、Rs1、Rs2分别赋值到Inst的不同位段生成了完整的32位指令编码。这个类设计好之后定义具体指令就非常简洁def ADD : InstR(outs GPR:$rd), (ins GPR:$rs1, GPR:$rs2), add $rd, $rs1, $rs2, 0x01; def SUB : InstR(outs GPR:$rd), (ins GPR:$rs1, GPR:$rs2), sub $rd, $rs1, $rs2, 0x02; def LOAD : InstR(outs GPR:$rd), (ins GPR:$rs1), load $rd, $rs1, 0x03 { let Rs2 0; }注意LOAD里覆盖了的Rs2因为加载指令只用到一个源寄存器另一个固定填0。如果设计指令格式时没有给字段赋予默认值那么所有指令的定义都得写满字段容易出错所以基类里尽量把可选字段设成默认值再用let去覆盖特殊值。3.4 用Multiclass批量生成变体真实项目里指令往往有32位和64位两种版本乘法、寻址模式也会产生很多变体。我在这个示例里再扩展一步同时生成32位和64位的算术指令。multiclass ArithRbits8 opcode, string asm, SDNode opnode { def _32 : InstR(outs GPR32:$rd), (ins GPR32:$rs1, GPR32:$rs2), !strconcat(asm, $rd, $rs1, $rs2), opcode; def _64 : InstR(outs GPR64:$rd), (ins GPR64:$rs1, GPR64:$rs2), !if(!eq(opcode, 0x01), !strconcat(addw, $rd, $rs1, $rs2), !strconcat(asm, $rd, $rs1, $rs2)), opcode; } defm ADD : ArithR0x01, add, add; defm SUB : ArithR0x02, sub, sub;这里有个小技巧!if里长度不一样的字符串拼接会让代码变得难读所以我通常在Multiclass内部尽量少做复杂条件嵌套而是把差异提到上层。实际项目中变体多的时候建议拆成两个Multiclass一个管32位版本一个管64位版本复用度更高调试起来也更清晰。3.5 跑一次llvm-tblgen看看输出描述写完怎么验证直接用llvm-tblgen命令把它解析成可读形式llvm-tblgen MyTarget.td -print-records这个命令会把所有展开后的Record以可读格式打印出来包括继承来的字段、默认值、覆盖结果。第一次跑的时候你会立刻发现很多问题比如某个字段拼写错误被当成新字段、bits位段赋值冲突等。这个命令是我日常开发里用得最多的调试工具没有之一。如果要查看生成的指令选择匹配表可以用llvm-tblgen MyTarget.td -gen-dag-isel它会输出DAG匹配相关的C代码片段。同理-gen-asm-matcher生成汇编匹配代码-gen-disassembler生成反汇编解码代码。不同后端开关输出的内容差异很大建议每个都仔细看一下能帮助你理解.td文件里的每一个字段最终到了哪里。4. 调试与集成让TableGen真正跑进构建流程4.1 把解析结果可视化TableGen调错最忌讳的是“猜”。一会儿觉得是Multiclass继承写错了一会儿怀疑是变量名冲突排查半天没有头绪。我的建议是先用-print-records看解析结果把问题限定在“描述层面”还是“生成层面”。如果是生成层面出错也就是某个TableGen后端代码出错那就得去读.inc文件。.inc文件是TableGen生成的C代码片段通常可以在构建目录下找到。我经常在生成文件里直接加static_assert或者临时打印来验证逻辑因为TableGen生成的代码在正常构建流程里不会保留太多额外调试信息。这种做法虽然土但对定位问题非常有效。4.2 常见报错与背后的原因TableGen的报错风格比较“干”经常是Expected ...这种刚开始容易懵。我整理几个高频报错Field X does not exist in class Y这个最常见往往是拼写错误或是在继承的类里访问了一个只在别的类里定义的字段。解决方案是先确认类继承链上有没有这个字段别指望TableGen帮你自动忽略未知字段。Type mismatch赋值时类型对不上比如把int直接赋给bits4或者把string塞进listRegister。处理方法是显式用!cast做类型转换。Undefined reference to Z你用Z定义了某个依赖但TableGen按顺序解析时还没看到Z。TableGen要求依赖在前比如在Multiclass里引用一个寄存器该寄存器必须在这个Multiclass之前定义。Class X cannot be used as a record你忘了用def而直接拿Class当值用。Class只是模板不是record不能直接出现在字段里。这些报错其实都指向一个共性TableGen对类型的敏感性比很多人想象的高。所以我的建议是每定义一个新类先跑一次-print-records确认基本解析通过再往里面加字段小步迭代比一口气写一个大文件要稳。4.3 在CMake里正确接线TableGen描述文件要真正参与到构建里需要通过CMake的tablegen宏来生成.inc文件。典型的写法是set(LLVM_TARGET_DEFINITIONS MyTarget.td) tablegen(LLVM MyTargetGenRegisterInfo.inc -gen-register-info) tablegen(LLVM MyTargetGenInstrInfo.inc -gen-instr-info) tablegen(LLVM MyTargetGenMCCodeEmitter.inc -gen-emitter) tablegen(LLVM MyTargetGenAsmWriter.inc -gen-asm-writer)关键是LLVM_TARGET_DEFINITIONS这个变量它决定了默认的输入.td文件。多个后端并存时要确保每个后端的构建目录里都重新执行了这些tablegen命令否则你会遇到“明明改了.td文件但生成的.inc文件没变”的诡异情况。另外add_llvm_target之类的宏依赖TableGen生成的文件做依赖追踪所以尽量不要把多个后端的TableGen输出混在同一个CMake目标里。我踩过的坑是两个后端共享一个MyTarget.td但一个构建目标忘记声明依赖结果另一个目标先编译导致生成文件缺失新手查这个会查很久。4.4 构建性能与描述文件的演化TableGen在大项目里展开量很惊人一个简单的Multiclass套上两层foreach可能膨胀出成百上千个Record。某些复杂后端的.td文件有几十万行生成记录构建时间动辄以分钟计。这时候我一般做两件事一是尽量少用“超级Multiclass”把大而全的复用改成小而精的组合虽然描述上会啰嗦一点但展开量可控二是善用-print-records做阶段性检查不要等到整个文件写到几千行才去验证。还有一个经验之谈TableGen描述文件也是代码需要做模块化。我通常会拆成几个文件Registers.td只放寄存器定义Instructions.td放指令定义CallingConv.td放调用约定顶部用一个include xxx.td汇总。这样多人协作时冲突少单一的MyTarget.td也不会膨胀到没法读。模块化之后定位问题也快因为每个文件职责单一改错什么字段很容易发现。5. 常见问题与排查技巧实录5.1 高频问题速查表| 现象 | 可能原因 | 排查建议 | | --- | --- | --- | --- | | 生成的寄存器列表缺少某个寄存器 |foreach范围写错或Record名冲突 |-print-records里搜寄存器名确认是否存在 | | 指令编码全是0 |bits位段没有赋值或赋值变量类型不匹配 | 检查基类里Inst字段的位段赋值 | | 汇编器不识别新指令 | 汇编助记符字段长度或格式不一致 | 用-gen-asm-matcher查看生成的匹配字符串 | | 反汇编器解码错乱 | 操作码或保留位没有对齐 | 对比指令编码表检查let覆盖是否正确 | | 构建时报缺少Gen*.inc文件 | CMake里没调用对应tablegen宏 | 在构建目录里搜索是否生成了该文件 | | 同一名称重复定义 | 两个def用了同样的名字 | 检查include文件是否被重复包含 |这个速查表是我项目里最常对照的清单每次新手同事来问问题基本都能在上面找到对应行。5.2 几个跳坑后的独门技巧第一个技巧给关键字段加上“哨兵值”。比如bits32 Inst我会在基类里先赋一个明显不合理的默认值比如0xDEADBEEF。这样一旦某个指令没有正确覆盖编码字段生成结果里就会出现0xDEADBEEF这种奇怪值一眼就能看出来而不是默默生成一个全零的错误指令。第二个技巧善用Record名字里的#拼接。命名时把字段编码信息也拼进去比如def ADD_01 : ...这样打印出Records时名字本身就暴露了操作码。调试时不用来回对照纸质编码表效率提升明显。第三个技巧不急着改.td文件先看一下生成的C代码。很多问题在TableGen阶段没有报错因为语法上成立但语义上不符合预期。比如某些SubtargetFeature的字段拼错导致特征开关不生效TableGen不会报错只有生成的目标查询代码里才会看出端倪。这时候打开生成的.inc文件直接对比特征查询逻辑定位很快。5.3 工程接入时的几条建议一是把所有.td文件纳入代码评审范围。TableGen描述虽然不像C那样直接跑但它的影响面往往比单个C文件更大一次基础类字段修改可能影响全部指令定义。二是坚持用-print-records做CI检查哪怕只是简单的“确保文件能解析通过”也好过提交一个语法都跑不过的描述文件。三是针对比较大的后端考虑写一小段自动化测试验证生成结果里包含预期指令这个用Python脚本或者Shell grep都能做成本不高但能挡住不少低级错误。个人经验与一点心得我最初接触TableGen时完全没有把它当作一门正经语言看待总觉得“这不就是个配置文件吗”。直到某天需要给一个模拟CPU写后端一条指令要在指令选择、汇编、反汇编三个模块里同步修改改得焦头烂额之后我才真正理解了它的价值所在。TableGen的本质是把后端开发里的“信息定义”和“信息消费”分离让定义只写一遍让消费通过工具自动发生。这件事听起来简单但实际做起来需要极强的抽象能力你既要对目标指令集有清晰认识又要对TableGen的类层次设计有全局观否则很容易写出一个大而不当、难以维护的描述集合。最后分享一个小习惯我每写一个类或Multiclass都会在同一段代码下面补一个极小的defm测试哪怕这个测试只是定义一条指令我也会立刻跑-print-records确认展开结果。这个习惯帮我拦截了至少一半的后期调试压力。等到整个后端的TableGen框架稳定下来你再回头审视那些自动生成的代码会有一种很踏实的感觉繁琐的重复已经交给工具而你的精力可以放在真正需要判断和设计的地方。这也是我为什么认为每个LLVM后端开发者都值得花一个下午把TableGen语法和它的工作原理认真吃透。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑