LLVM RISC-V 后端:从 IR 到 MIR 再到汇编

题图

I will be,
我将存在,
The light softly touching your body,
那道光芒轻柔地触碰你的身体,
I’ll see you when the sun rises up,
旭日升起时我会去见你,
I will be,
我将,
Everything, in everything,
成为万物 融入万物,
When the mist fades away,
当薄雾散尽的时候,
and the path shows its way,
道路显现它的方向,
the divine in your heart,
你心底的圣洁,
It’s calling out to your name,
正呼唤着你的名姓。

—— 编织光流 (For Your Name) · 铁痕电台-MSR/Edine

后端的每一步都在做同一件事:把 IR 里”以后再说”的部分,变成”现在就是这样”。

题图:@QUASARCAKE 的 X 投稿「回家」

引子

上一篇文章谈中端,谈的是”一次改写合不合法”:给定 IR 和它的语义,某个 pass 能不能把它改成另一种写法(上篇管这条判据叫精化)。到了后端,判据不止一条:改写还必须真的能在目标机器上执行。IR 里有一批东西是故意不定的——整数宽度比任何一台机器都多,寄存器无限个、名字随便取,地址是一个可以随意运算的整数,跳转范围不受限,常量想多大就多大。这些”不定”正是中端能优化的原因,也是后端必须解决的问题:机器只接受它有的东西。

于是后端的任务可以精确地写成一句话:给 IR 里每一处目标无关的选择,补上目标相关的具体选择,直到机器指令能被编码为止。补选择的过程分四步,每一步收窄一类可能性;某一步定下的选择后面不再重开,只允许在不推翻它的前提下做局部改写(哪些改写算“不推翻”,下面那张表逐行写了)。

本文按这四步展开,主线是 llc -mtriple=riscv64,RV32 的差异单独标出。文中每一处 file.h:line 都指向 llvm-project 的 026e3f3c 这一版——与上篇同一个 commit。前端语义与中端优化不涉及。

我习惯从代码入手,所以不按”先讲后端框架、再讲各阶段算法”的顺序写:正文第一节先把后端要做的四类选择一次讲完,后面每节展开一类。前置知识那一节列的是上篇已经讲清、本文直接用到的那几个名词,本文不再解释。

前置知识:本文不解释的内容

上篇(《LLVM IR 中端 Pass 的设计目标与运行原理》)已经定义的

名词 在上篇的位置 本文用在哪
pass、New Pass Manager(本文写作 NewPM) pass 在上篇引子的通用术语表,New Pass Manager 在上篇末章 「四类选择」一节里 legacy 与 NewPM 两条流水线的对比
SSA、φ 节点、CFG 上篇 T2 「MIR 是什么」的机器级 SSA、「怎么验证每一步」的 MachineVerifier
格、不动点、worklist、有限高度格 上篇 T1 类型合法化与 vsetvli 插入两处的停机论证
精化(改写合法性的判据)、nsw 标志(引理 0.6) 上篇 T0 引子的判据对比、「寄存器分配之前」的 MIR 标记
GEP 上篇引子的通用术语表(正式使用处在 T5) sum 例子里那条 getelementptr

这些名词在本文出现时,按上篇给的定义理解;本文只交代它们在本文里负责什么。上篇其余部分(T3 的遍历序与自然循环、T4 的 SCEV 与退出次数、T5 的 MemorySSA 与别名分析、T6 的频度与代价、T7 的规范化)本文用不到,不必先读。

oi-wiki:本文只用到“有向图的前驱/后继”这一层最基础的说法,不依赖具体词条;上篇用到的四条图论词条(DFS支配树强连通分量拓扑排序)留在 Reference 里备查。

本文从零讲清的:后端流水线的阶段划分、legacy pass manager 与 NewPM 两条路、TargetMachine 与 Subtarget、DataLayout、ABI 与调用约定、寄存器类、SelectionDAG、指令选择、机器指令与 MIR、虚拟寄存器与物理寄存器、寄存器分配与溢出、伪指令、AsmPrinter 与 MC 层、汇编器、重定位、链接期松弛。除了上面总览表与流水线图里的预告,它们按下面的顺序第一次出现、就地定义;后文再用到时不再展开——只有少数几个跨了好几节的名词(PIC、GOT、代码模型、链接期松弛、Zmmul)在第二次用到时会把简写重述一遍。

后端要收窄的四类选择

先把四类选择列出来,它们就是后文四节的目录。

次序 要选的东西 选完的结果 谁来选、拿什么当输入 选完还能不能改
1 指令集子集、ABI Subtarget 机器配置:-march-mabi-mcpu 不能,整个函数一次定死
2 用哪条机器指令、数据放哪类寄存器 机器指令序列(MIR,虚拟寄存器) 指令选择:一个基本块的 IR 原则上不再重选,只允许保持语义的局部改写(压缩、配对拆分),见机器级优化一节
3 每个虚拟值放哪个物理寄存器 物理寄存器或栈槽 寄存器分配:MIR 与活跃区间 不能,此后一切按物理寄存器写
4 每条指令编成哪些字节、跨模块引用留什么记号 汇编或目标文件 MC 层:AsmPrinter 降出的 MCInst 只有汇编器与链接器能改

四类选择对应四节:机器配置、指令选择、寄存器分配、汇编与目标文件。这里的 ABI 指 application binary interface:函数之间怎么传参、寄存器的用途怎么分、数据怎么对齐,具体选哪一套由 -mabi 决定。调用约定一节在指令选择与寄存器分配之间:它决定寄存器文件里的哪些成员能作什么用,同时约束”选哪条指令”和”哪个虚拟值放哪个物理寄存器”。RVV 向量扩展单独一节,因为它引入了前面几节都没有的东西:长度在编译期未知的寄存器组。

四步之间是单向的:第一步定下的 Subtarget 决定第二步能选哪些指令;第二步定的寄存器类决定第三步能分到哪些物理寄存器;第三步分完寄存器之后,再做指令级优化就必须照顾已经定下的寄存器边界。每一步都是拿前一步的输出当输入,把不确定性继续收窄。

流水线有两条路。默认那条是 legacy pass manager——New Pass Manager 之前那套 pass 调度框架,llc 至今默认走它,下文简称 legacy 路径(llc -mtriple=riscv64 -O2RISCVPassConfig)。-enable-new-pm=1 时走 RISCVCodeGenPassBuilder,阶段划分相同,但那条路上只移植了一部分:没接进来的 pass 逐条写在同一个文件的 TODO 里,按段分:pre-RA 一段从 :128 起到 :138(后文要讲的 RISCVMergeBaseOffsetRISCVZilsdOptimizer 的预分配半、RISCVInsertReadWriteCSRRISCVInsertWriteVXRMRISCVVMV0Elimination 都在这段),post-RA 是 :143RISCVRedundantCopyEliminationRISCVLoadStoreOpt:152(sched2 之前),发射前几批从 :164 起(含 :171RISCVMakeCompressible);还有两件连 TODO 都没有——向量值的分配与 RVV 一节的 RISCVInsertVSETVLI 只挂在 legacy 的两个分配钩子里(addRegAssignAndRewriteOptimized:458:460addRegAssignAndRewriteFast:449:450),NewPM 路径上没有等价物(-enable-new-pm=1 下向量代码不会经过 vsetvli 插入)。构造函数里还记了一处更小的差异:-riscv-enable-sink-fold 传不进 NewPM、恒为默认值(:49)。

1
2
3
4
5
6
7
8
9
IR
├─ IR 级收尾(AtomicExpand、GatherScatterLowering…)与 IR 层 CodeGenPrepare :467 / :511
├─ 指令选择:SelectionDAG → MachineInstr(MIR,虚拟寄存器) :517
├─ 机器 SSA pass(MachineLICM/CSE/Sinking、RISCVOptWInstrs、向量 peephole…) :612
├─ 伪指令展开(pre-RA)与寄存器分配(greedy;RVV 另有单独分配器) :636 / :433
├─ 调度前一批(post-RA 伪指令展开、访存配对,sched2 之前) :556
├─ 发射前一批(复制传播、分支松弛、压缩指令) :565
├─ 发射前二批(move merge、push/pop、atomics 伪指令展开) :584
└─ AsmPrinter → MCInst → 汇编或目标文件

(图里的行号都在 RISCVTargetMachine.cpp 里,指向 legacy 路径的各个 addXXX 钩子。这张图只写阶段,不写每个 pass 的完整清单,也没有逐个标注某个 pass 是 legacy 独有还是两条路都有:这部分差异直接看 RISCVCodeGenPassBuilder.cpp 里的 TODO。)

下面按顺序展开。

机器配置:-march、-mabi 与 Subtarget

一台 RISC-V 机器由什么描述

LLVM 用三元组(triple)描述目标平台:riscv64-unknown-linux-gnuriscv64 是架构,后面三段是厂商、操作系统、环境。三元组决定的是位宽与目标文件格式,并用来校验 -mabi(32 位 ABI 配 64 位三元组会被报一声警告后忽略、退回默认值,RISCVBaseInfo.cpp:54computeTargetABI);没给 -mabi 时,默认 ABI 由 -march 里的扩展算出来——有 Dlp64d、有 Flp64f、两个都没有得 lp64(RV32 同理,RISCVISAInfo.cpp:1084computeDefaultABI)。在 LLVM 内部,一台具体的目标机器由一个 TargetMachine 对象表示:它按函数给出 Subtarget(当前函数的机器配置),而 TargetLowering(类型与操作如何降级、调用约定入口)、TargetInstrInfo(指令信息)、TargetRegisterInfo(寄存器信息)、TargetFrameLowering(栈帧规则)都由 Subtarget 自己持有并构造,TargetMachine 只在最外层创建 AsmPrinter(汇编打印器:流水线最后一站,把机器指令降成 MCInst 交给 MC 层,见「从机器指令到汇编与目标文件」一节);RISC-V 的这一层在 RISCVTargetMachine 里。具体有哪些指令、哪些寄存器,由另外三个输入决定:

  • -march:ISA(instruction set architecture,指令集架构)字符串,例如 rv64i2p1_m2p0_a2p1_f2p2_d2p2_c2p0_v1p0。开头是基础整数指令集(rv32irv64i),其后每个下划线分隔的片段是一个扩展(例子里都是单字母扩展;zmmulzba 这类多字母扩展写在同样的位置),2p1 表示该扩展的版本是 2.1。解析器是 RISCVISAInfo,解析结果是一组 feature bit(每个扩展对应一个开关位)。
  • -mcpu:处理器型号,它给出一组默认的扩展和流水线信息(调度模型:每条指令的延迟与资源占用;指令融合:调度上把相邻两条指令当作一个宏操作来算冒险,并不是真把它们合并成一条指令)。
  • -mabi:ABI 名称,例如 lp64d。它决定浮点参数走整数寄存器还是浮点寄存器(见调用约定一节)。

基础指令集部分给了 RV32/RV64 的差别:rv32i 的通用寄存器宽度是 32 位,rv64i 是 64 位。这个宽度在后端里有一个统一的名字 XLen,对应的机器值类型叫 XLenVT——RV32 上是 i32,RV64 上是 i64RISCVSubtarget.h:231)。后文说”XLen 宽度”指的就是这个宽度。

扩展按名字分组,常用的几组是:M(乘除法)、A(原子:其他线程看不到操作的中间状态)、F/D(单/双精度浮点)、C(16 位压缩指令)、V(向量),以及大量 Z 开头的小扩展(RISCVFeatures.tdFeatureStdExtM 在 223 行、A 在 250 行、F 在 307 行、D 在 315 行、C 在 417 行、V 在 698 行)。没有 Zmmul(只含乘法的子集,M 蕴含它)时变量乘法落库函数,没有 M 时除法也落(RV64 是 __muldi3/__divdi3,RV32 是 __mulsi3/__divsi3)——只有乘数是常数时才会折成移位加;没有 F/D 时浮点类型本身不合法,运算被软化(soften:改写成整数上的位运算加库函数调用)。两处的机制都在指令选择一节。

这些 feature bit 加上 CPU、ABI 一起构成 Subtarget。Subtarget 是”当前函数编译时生效的机器配置”:它按函数缓存,因为同一个模块里不同函数可以带不同的 target-features 属性(RISCVTargetMachine.cpp:197getSubtargetImpl,从函数属性里读 target-cputune-cputarget-features,再看有没有 vscale_range 属性——它给出 vscale(向量长度的运行期缩放因子,正式定义在 RVV 一节)的取值范围,单位是 vscale 而不是 bit)。Subtarget 对象是后面所有阶段查询”这台机器能不能做某件事”的唯一入口,主要成员在 RISCVSubtarget.h:102 起(一批 feature bit 开关由 :107 的宏批量生成,FrameLoweringInstrInfoTLInfo 三个成员在 120–122 行)。

数据在内存里长什么样:DataLayout

Subtarget 定的是指令,DataLayout 定的是数据。它由三元组和 ABI 名算出,是一个字符串,例如 RV64 + LP64D 得到 e-m:e-p:64:64-i64:64-i128:128-n32:64-S128TargetDataLayout.cpp:280computeRISCVDataLayout)。这个字符串里每一项都在回答一个后端必须知道的问题:

  • p:64:64:指针 64 位、对齐 8 字节;
  • i64:64:64 位整数对齐 8 字节(RV32 上也是 i64:64,所以 long long 在 RV32 上按 8 字节对齐);
  • i128:128:128 位整数对齐 16 字节;
  • n32:64:32 位和 64 位是原生宽度(RV32 的字符串是 n32);
  • S128:栈对齐 16 字节。ILP32E/LP64E 两个嵌入式 ABI 是 4/8 字节,其余都是 16(RISCVFrameLowering.cpp:38)。

结构体大小、GEP 的字节偏移、以及各处的对齐要求(IR 里的 align 标注够不够、栈对象怎么摆)都按这个字符串算,所以它必须在任何 pass 之前定下来。上面那句”16 字节栈对齐”在调用约定一节的栈帧部分还会用到一次。

寄存器不是一个集合,是一组集合

RISC-V 的寄存器文件有三个:32 个通用寄存器 x0x31(汇编里也写 ABI 名 zerorasp……)、32 个浮点寄存器 f0f31(F/D 扩展才有)、32 个向量寄存器 v0v31(V 扩展才有)。它们不全是一样大:f 寄存器按 F/D 扩展可以是 32 位或 64 位,v 寄存器按 RVV 是可变长的寄存器组(RVV 一节)。

指令选择与寄存器分配不直接面对”32 个寄存器”这个全集,而是面对寄存器类(register class):一个类是一组用途、宽度都相容的寄存器,指令的操作数声明自己属于哪个类。RISC-V 的类定义在 RISCVRegisterInfo.td.td 是 TableGen 的输入文件;TableGen 是 LLVM 的目标描述语言,构建时把 .td 里的声明生成成 C++ 表,后文遇到的寄存器、指令、调度模型都写在 .td 里;GPR 是通用寄存器、FPR 是浮点寄存器、VR 是向量寄存器):GPR:320FPR32:562VR:900。同一批寄存器会派生出多个类:GPRNoX0 去掉恒为零的 x0GPRC 只留能用 2 字节压缩格式编码的那 8 个,GPRJALR 去掉不能用 jalrx1x5(有的微架构把它们当作弹出返回地址栈的提示),再去掉保留寄存器 x0x2x4RISCVRegisterInfo.td:377),FPR32C/FPR64C 同理。

寄存器类不是随便切的:一个类是”某条指令的某个操作数可以换成哪些寄存器”的集合——换进去的每一个都必须让这条指令仍然编得出、语义不变。它既不要求极大也不要求互斥,上面那批派生类就是层层包含的。类越小,分配器的可选范围越小,但指令能选得越省空间。该用哪个类,是后面每一层都要问的问题。

有几个寄存器永远不参与分配,它们在 RISCVRegisterInfo.cpp:172getReservedRegs 里被标出:x0(恒零)、x2(栈指针 sp)、x3(全局指针 gp)、x4(线程指针 tp)、x8(帧指针 fp,只在需要时保留),以及 vlvtypevxrmfrm 这些 CSR(control and status register,控制和状态寄存器,RVV 一节会用),还有 SSP(影子栈指针,在 getReservedRegs 里无条件保留)。

指令的编码形式决定了”能不能选”

RISC-V 基础指令集的每条指令都是 32 位,按立即数在编码里的摆法分成几种形式,TableGen 里用 InstFormat 枚举:R(两个源寄存器加一个目标)、I(一个源寄存器加 12 位立即数)、S(存储,12 位立即数)、B(条件跳转,13 位立即数、最低位恒 0,也就是 ±4 KiB)、U(20 位上立即数,配 lui/auipc)、J(无条件跳转,21 位立即数、最低位恒 0,也就是 ±1 MiB),以及四个寄存器操作数的 R4(浮点融合乘加,三源一目标)。压缩指令另有十四种 16 位形式(InstFormatCRInstFormatCSH),其中基础 C 扩展只占九种(CR/CI/CSS/CIW/CL/CS/CA/CB/CJ);剩下五种全部来自 Zcb(基本压缩指令集的补充:几条 8 位/16 位访存,加一族单寄存器运算)——CLBc.lbuCLH 同时装 c.lhuc.lhCSBc.sbCSHc.shRISCVInstrInfoZc.td:188 起),CUc.not/c.sext.b/c.sext.h/c.zext.b/c.zext.h/c.zext.w 这一族(:139RVZcArith_r,谓词都含 HasStdExtZcbc.zext.w 另需 Zba(地址生成扩展)且只在 RV64、c.zext.h 另需 Zbb(基本位操作扩展))。

形式不是编码细节,它会一路反向影响后端各阶段的决定:

  • 12 位立即数的取值范围是 −2048..2047。任何超出这个范围的加法、访存偏移、地址计算,都不能用一条 addi/ld/sd 解决,得先算出一个中间值。
  • U 型指令只有 20 位。一个 64 位常量至少要 lui 加若干条 addi/slli/add 拼出来,或者放进常量池(constant pool:编译器在函数附近开的一段只读数据区,专放装不进立即数的常量与地址)用 lui+ld 取。在几套拼法之间挑哪一套的是 generateInstSeq:294,判据是条数TmpSeq.size() + 1 < Res.size() 才换),外加一条”换成压缩对更友好”的启发。getInstSeqCost:16 是另一回事:它给一套现成的拼法打加权分(非压缩记 100、可压缩记 70,也就是”两条压缩指令略贵于一条非压缩指令”),全仓只被 getIntMatCost:577)调用,供上层估”这个常量值多少钱”:TTI 的常量代价(RISCVTargetTransformInfo.cpp:144)、select 两条路谁便宜(RISCVISelLowering.cpp:10704)、一串常量能不能合成一次存储(:24819)、换一个等价立即数是否更省(selectImm64IfCheaper:4332)。
  • B 型跳转的范围是 ±4 KiB。函数内的条件跳转一旦超范围,就由后端的 BranchRelaxation 在发射前改写(见汇编与目标文件一节)。
  • 全局地址不能用一条指令直接取。PC 相对寻址要 auipc+addi%pcrel_hi/%pcrel_lo)之类两三条指令的组合;位置无关代码(PIC,position-independent code:加载地址不固定的代码)取外部可见符号还要多经过一层 GOT(global offset table,全局偏移表:存放符号实际地址的表)。用哪一套组合、能不能省掉其中一条,由代码模型(对符号地址能落在多远的假设,见地址一节)与是否 PIC 共同决定——这两项和 -march/-mabi 一样,属于第 1 类选择就定下来的输入。

指令本身在 RISCVInstrInfo.td 里描述:ADD 复用 ALU_rr 模板(:761),”目标与两个源操作数都在 GPR“ 是模板在 :763 声明的,def ADD 本身在 :881ADDI 复用 ALU_ri:749),第二个操作数是 12 位有符号立即数。操作数类型(GPRsimm12)不只是给汇编器打印用的,它同时是后面两节所有”能不能这么选”的判据。

到这里,第 1 类选择(机器配置)讲完了。后面所有阶段都在这个范围内工作:Subtarget 说有哪些指令、DataLayout 说数据多大、寄存器类说操作数能放哪、编码形式说立即数能多大。

指令选择:把 IR 变成机器指令

指令选择要回答的问题是:IR 里的一条或多条指令(一个基本块里的操作序列),对应目标机器上的哪几条机器指令。LLVM 的主要实现是 SelectionDAG:把每个基本块变成一个 DAG(有向无环图),节点是操作,边是数据依赖与顺序依赖(chain:把访存与其他有副作用的节点串成一条序);在 DAG 上做类型与操作的合法化、模式化简,再把节点一条条”选”成机器指令,最后按依赖关系排成机器指令序列。整个过程一次只处理一个基本块,这是它和 IR 优化最大的区别,也是 RISC-V 后端有那么多 RISCVCodeGenPrepare 一类 IR 层补丁的原因(跨基本块的事 DAG 表示不了)。一个具体例子:RVV 的 llvm.vp.merge 合并 i1 掩码时,如果保持 i1 宽度,DAG 只能用一长串掩码指令拼;RISCVCodeGenPrepare 在 IR 层把合并的宽度扩到 i8,后端就能用一条 vmerge.vim 完成(RISCVCodeGenPrepare.cpp:118 附近的注释给了这个例子)。这个改写要看整条 def-use 链和循环结构,DAG 只看一个基本块,做不了。

DAG 的各个阶段

DAG 处理一个基本块的流程在 SelectionDAGISel.cpp:947CodeGenAndEmitDAG 里,顺序是固定的:

阶段 做什么 源码位置
建图 IR 指令变成 DAG 节点,基本块参数变成节点 SelectionDAGBuilder
combine 1 还没做类型合法化时的化简:常量折叠、冗余节点、目标无关的代数化简 :988
类型合法化 把目标不支持的机器类型替换成支持的组合 :1010LegalizeTypes
combine LT 类型合法化之后再化简一轮(这一步有改动时才跑) :1034
向量合法化 把向量操作拆成目标支持的形式 :1051LegalizeVectors
类型合法化 2 向量合法化引入新类型,再补一轮类型合法化 :1068(仅在向量合法化有改动时)
combine LV 向量合法化之后再化简一轮 :1088(紧随上一步)
操作合法化 把目标不支持的操作替换成支持的操作序列或库函数调用 :1108CurDAG->Legalize()
combine 2 合法化之后再化简一轮 :1128
选择 DAG 节点变成机器指令,装进 MachineBasicBlock :1152:1168 的 DAG 调度器

这些阶段不是随便分的,化简之所以反复出现,是因为每个阶段都会产生新的可折叠结构:类型合法化会插入截断与扩展,向量合法化会把向量操作拆开,操作合法化会把一个操作拆成几条基本运算,前一次化简的结论在后一次未必还成立,所以只能各跑一遍。数一下,CodeGenAndEmitDAG 里有四个 combine 调用点(BeforeLegalizeTypesAfterLegalizeTypesAfterLegalizeVectorOpsAfterLegalizeDAG);其中 AfterLegalizeTypesAfterLegalizeVectorOps 只在相应合法化确实改动了 DAG 时才跑,前后两次(BeforeLegalizeTypesAfterLegalizeDAG)无条件跑。还有一句要记住:把 DAG 变成机器指令的只有选择与随后的调度;建图是造 DAG,中间的合法化与化简都只是在改写 DAG。

什么叫”合法”:由寄存器类定义

类型合法化的名字容易误解:它不是说”IR 里的类型合法不合法”,而是说”这个类型能不能直接装进某个寄存器类”。合法的机器类型集合由 Subtarget 构造时调用 addRegisterClass 的一组调用定义:

  • XLenVTGPR:RV32 是 i32,RV64 是 i64。只有这个宽度是合法的,其余宽度靠合法化补:RV32 上 i64 不合法,一次 64 位运算或访存会被拆成两条 32 位操作;RV64 上 i32 也不合法,但这些算术被标成 CustomRISCVISelLowering.cpp:395),类型合法化遇到它们时改走 ReplaceNodeResults:16595customLegalizeToWOpWithSExt,用 addw 这类 32 位指令直接算,而不是整体提升到 i64
  • f16FPR16:只有 Zfh/Zfhmin(半精度浮点扩展)在时才合法;否则 f16 被提升成 f32 算完再截回。
  • f32FPR32F 扩展)、f64FPR64D 扩展)。没有对应的浮点扩展时,浮点类型全部不合法,浮点操作退化成整数上的位运算加库函数调用(Zfinx/Zdinx 把浮点值放进 GPRZhinxminf16 放进 GPRF16——通用寄存器的低 16 位子寄存器组成的类,RISCVRegisterInfo.td:595,是这条的例外)。
  • RVV(V 扩展)把一组 <vscale x N x T> 类型注册成 VR/VRM2/VRM4/VRM8 类(RISCVISelLowering.cpp:226addRegClassForRVV);没有 V 时 IR 里的向量全部被拆成标量(P 扩展——RISC-V 的 packed-SIMD/DSP 扩展——把 v2i32 这类小整数向量放进 GPR,是例外)。

类型合法化的动作有五类:提升(小的换成大的,例如 i8 算术在 RV64 上用 i64 算完再截断;标量整数的提升一定一步落到合法类型:TargetLoweringBase.cpp:1453 起给每个非法整数类型直接填 TransformToType[IntReg] = LegalIntReg:1462)——LegalIntReg 是“比它宽的、最近的那个合法整数类型”(:1455 起的循环自宽向窄扫,扫到合法者就把它更新一次;RISC-V 上合法整数类型只有 XLenVT 一个,于是填的就是 XLenVT),不留中间台阶;:1110 那条 Promote may not follow Expand or Promote 断言只是配套约束,它禁止的是“提升落到的类型自己还要再被提升”。该断言对向量目标不施约束,向量可以 Promote 之后再 Widen/Scalarize)、拆分(大的拆成小的,例如 RV32 的 i64 拆成两个 i32,向量按元素数折半)、标量化(只有一个元素的向量换成它的元素类型)、放宽(把向量的元素个数补到 2 的幂,例如 <3 x i32> 变成 <4 x i32>;代码只在两种情形放宽,要么元素个数不是 2 的幂、补一次到下一个 2 的幂,要么沿着更宽的向量一直找到合法类型为止,后者找到就停)、软化(浮点类型换成同宽的整数类型,运算改成整数上的位操作加库函数调用——就是机器配置一节说的“没有 F/D 时浮点被软化”:f128i128:1489)、f64i64:1507)、f32i32:1516);f16bf16 不走软化,另有一条 TypeSoftPromoteHalf,目标是 f32:1534:1544))。拆分后的每一半都要重新走一遍合法化,所以类型合法化本身是一个不动点过程——迭代到没有非法类型为止(形式上与上篇 T1 同型:给定合法类型的集合,每一步都在消掉一个非法类型)。停机按一个字典序测度归纳:测度是(是不是整数、是不是标量、元素个数是不是 2 的幂、元素个数、标量位宽),每一位都约定“排在前面的那种更小”(整数 < 浮点、标量 < 向量、是 2 的幂 < 不是 2 的幂)。五类动作分两组看。拆分、标量化、软化这三类都让测度严格下降:拆分让元素个数或标量位宽严格变小,标量化把向量换成它的元素类型(第二位变小),软化把浮点换成同宽整数(第一位变小)。提升与放宽这两支乍看会让测度上升(向量的元素提升把 i1 换成更宽的 i32,放宽把元素个数变大),但只要它们是“沿更宽的类型往上扫”扫出来的,就一步落到合法类型、链条当场结束:合法化查的不是现算的搜索,而是 getTypeConversion:1101computeRegisterProperties 预先建好的表,而建表时那两个搜索都把 isTypeLegal 写进了命中条件——向量元素提升沿更宽的元素类型往上扫,扫到第一个合法的就填进 TransformToType 并停(:1572);放宽同理,沿更宽的同元素向量往上扫,命中条件里同样带 isTypeLegal(SVT):1595)。元素个数不是 2 的幂时,只有补出来的那个 2 的幂类型本身合法才在放宽那一支落定(:1608getPow2VectorType 紧跟一个 isTypeLegal,测度第三位变小);补出来的类型仍不合法,就落到拆分/标量化那一支(:1619:1650),而那一支开头还要再看一次“是不是 2 的幂”:不是,就仍把动作记成放宽到下一个 2 的幂(:1645:1647,第三位变小),是,才真的拆分或标量化(:1631:1644,第四位或第二位变小)。唯一不落在合法类型上的一支是半精度:f16 的表项填的是 f32:1534),位宽变大、又不是合法类型;但 f32 自己的表项只会是“合法”或“软化”(:1516),不会再指回半精度,于是这一支要么一步到合法类型,要么再经软化落到整数——软化把第一位测度降下来,落到整数之后就归入前面那三组,不会再回头。于是不存在无限上升的链:可能的类型只有有限多个,每个类型在有限步内变成合法类型。DAG 里的节点有限,每个节点只在它的类型被替换时重新处理,所以 worklist 一定会空。

操作合法化是第二层:类型都合法了,但目标可能没有对应的操作。LegalizeDAG.cpp:982LegalizeOp 查的是 getOperationAction,它的四个返回值里 Legal 表示不用动,另外三个各对应下面前三条;第四条不是独立的返回值,而是 Expand 拆到底最常见的那个结果:

  • Expand:拆成基本操作的组合。RISC-V 把两组操作设成 Expand,条件不同:没有 Zmmul(只含乘法的子集,M 蕴含它)时 MUL/MULHS/MULHU 在 XLen 上是 Expand(RISCVISelLowering.cpp:404),没有 MSDIV/UDIV/SREM/UREM 在 XLen 上是 Expand(:413);SDIVREM/UDIVREM 则无条件是 Expand(:421)。而这个 Expand 在通用合法化里就是“直接发库调用”,不过要先分清是哪一层在跑。上面三条 setOperationAction 设的是操作动作,XLen 本身又是合法类型,所以触发的是操作合法化:LegalizeOp:1381 写的是 case Expand: if (ExpandNode(Node)) return;,紧跟一个 fallthrough 落到 case LibCall: ConvertNodeToLibcall(Node)——ExpandNode 拆不出来就发库调用。ExpandNode 的 SDIV/UDIV 那一支:4098 一共只有两条路:SDIVREM/UDIVREM 是 legal 或 Custom 时改用它,否则什么也不产出、由那句 fallthrough 送进 ConvertNodeToLibcall:4746,RISC-V 属于后者(它的 SDIVREM 是 Expand);MUL 那一支:4155 先看 SMUL_LOHI/UMUL_LOHI 能不能用(RISC-V 上它们同样是 Expand),再试 expandMUL(..., OnlyLegalOrCustom, ...),因为 MUL 本身不是 legal/custom 而失败,结局相同。类型非法时(例如 RV32 上的 i64)走的是另一套形状相同的函数:ExpandIntRes_MUL:4501ExpandIntRes_SDIV:4877,同样先试展开、失败就 makeLibCall。这一版 LLVM 里两套都已经没有“用高位乘法做 non-restoring 除法”那处通用展开了。用本版 llc 实测:rv64imul i64tail __muldi3sdiv i64tail __divdi3rv64 -mattr=+zmmul,-m 下乘法直接用硬件 mul a0, a0, a1(因为 :404 的条件是 !hasStdExtZmmul()),只有除法仍落 __divdi3——两者都不是移位加序列。
  • Promote:提升到更宽的类型做。
  • Custom:目标自己写 lowering,入口是 RISCVTargetLowering::LowerOperation,返回值类型需要改变时走 ReplaceNodeResults
  • 库函数调用(Expand 的一种结果):记成一次对固定符号的调用,等后面按调用约定落成普通调用。RV32 的 64 位除法、没有 F 时的浮点运算都走这条。

RISC-V 的 Custom lowering 集中在几处:取全局/常量池/跳转表的地址(lowerGlobalAddress:10158)、selectlowerSELECT:10498,有 Zicond(条件运算扩展,提供“条件不成立就把目标写零”的两条指令)时用 czero.eqz/czero.nez,否则落成 RISCVISD::SELECT_CC——分支型 select,在 MIR 里是条件分支加汇合块上的复制)、brcondlowerBRCOND:10876)、可变参数起始(lowerVASTART:10899),以及原子的读写改。这里分三个决定:shouldExpandAtomicRMWInIR:28427 只管“要不要在 IR 层就把它换成 cmpxchg 或掩码原子 intrinsic”(浮点原子、nand 一类硬件没有的操作走这条);留下不展开的,由指令选择的模式直接选成原子内存操作指令(atomic memory operation,amo*),例如 atomicrmw add i32 选成 amoadd.wRISCVInstrInfoA.td:232);硬件没有对应 amo* 的操作先落成 PseudoAtomicLoad*:380),等最后一批伪指令展开时才变成 lr(load-reserved)/sc(store-conditional)循环,Zacas(原子比较交换扩展)提供的 amocas 则走 cmpxchg 那条路。

匹配:从 DAG 节点到机器指令

选择阶段的入口是 RISCVDAGToDAGISel::Select。它的分工写在函数开头那句注释里——“自动生成的 TableGen 选择没处理掉的,都在这里处理”(:1107),所以顺序是先手写、再交给生成的匹配器

手写那一半是 switch 掉一批 TableGen 表达不了的形状:常量物化(:1117 起,里面还含“高位没人读就把立即数改写成能用压缩指令编码的形式”这类改写)、(shl (and X, C2), C) 一类位运算形状(:1298)、有符号位域的抽取与插入(:1425)、访存的非临时提示(:3312)等等。都没接住,才在函数最后一行落到 SelectCode(Node):3320)——生成那一半从这里开始:它跑的是 TableGen 按 .td 里的模式生成的选择表。模式的形式是”DAG 的一小棵树 → 一条指令”,例如 PatGprGpr<add, ADD>RISCVInstrInfo.td:1500)说”两个 GPRadd 可以选成 ADD“;立即数形式靠操作数类型把范围卡在匹配时(PatGprSimm12 展开成 PatGprImm<…, simm12_lo>:1424),用的不是 TableGen 的 PredicateSelectCode 进的是 SelectCodeCommon:3357:它先用一个通用 switch 就地处理掉一批与目标无关的节点(CopyFromReg/CopyToReg/TokenFactor/TargetConstant 等),再跑目标的匹配表;匹配表也接不住时当场报错(CannotYetSelect:4623report_fatal_error,调用点 :4545),没有“退回去再拆一层”这一步——拆分是合法化阶段的事,走到选择这一步,节点的类型与操作都必须已经合法。报 Cannot select 的两种常见原因:目标没给这个(已经合法的)节点写模式,或者合法化漏了它。

地址形式容易被误当成第三件事,其实它也在生成这一侧——是被 TableGen 回调的,不是绕开它的手写代码。.tddef AddrRegImm : ComplexPattern<iPTR, 2, "SelectAddrRegImm">RISCVInstrInfo.td:590)把 SelectAddrRegImm 登记成一个复合模式,生成的匹配器在匹配访存模式时回调它:它把一个任意的地址表达式拆成”一个基址寄存器加一个 12 位以内的偏移”,拆不出来就当场造一条额外的地址计算指令(:3574ADDI:3592ADD)。同族另外三个变体各服务一种非基础的偏移宽度,而且都不是给压缩指令或 Zilsd 用的:SelectAddrRegImm26 给 Qualcomm Xqcilo 的 26 位偏移访存(比 12 位更大:3607 的注释写明)、SelectAddrRegImm9 给 MIPS 厂商扩展的 prefetch(uimm9RISCVInstrInfoXMips.td:24)、SelectAddrRegImmLsb00000Zicbo 的缓存块操作。压缩访存与 Zilsd(把两条相邻 32 位访存合成一条 64 位 ld/sd 的扩展,只在 RV32 上存在,见机器级优化一节)都不是在指令选择这一步被选出来的,两件事都发生在更晚:压缩由发射期调用的 RISCVRVC::compressRISCVAsmPrinter.cpp:276)决定,判据是一张 CompressPat 表——LW GPRC:$rd, BasePtrC:$rs1, uimm7_lsb00:$imm 才压成 c.lwRISCVInstrInfoC.td:700c.sw:712),也就是说选择阶段先按 12 位偏移选出普通 lw,寄存器类与更窄的偏移类型是在压缩时再检查一遍(BasePtrCGPRC 用作访存基址时的包装,RISCVInstrInfo.td:242);Zilsd 的那两条真指令 LD_RV32/SD_RV32.td 里干脆没有选择模式(RISCVInstrInfoZilsd.td:36 那一段只声明指令与汇编形式),配对是 MIR 层的 pass 造出来的:分配前先拼成用两个独立寄存器加 12 位偏移的 PseudoLD_RV32_OPTRISCVZilsdOptimizer.cpp:353),分配后再检查这两个寄存器是否凑成了对、凑成才换成寄存器对操作数 GPRPairRV32 的真指令(RISCVLoadStoreOptimizer.cpp:916GPRPairRV32RISCVInstrInfoZa.td:35,它约束到寄存器类 GPRPairRISCVRegisterInfo.td:458)。地址拆不出来时多出来的这条指令,后面几个 pass(RISCVFoldMemOffsetRISCVMergeBaseOffset)会尝试再折回去。折叠的等式很直接:若中间指令是 addi rd, rs, C、访存是 ld rt, off(rd),折叠后访存变成 ld rt, C+off(rs),两边的有效地址都是 rs + C + off,所以访问的是同一个位置。成立需要两个条件:新偏移 C + off 仍能装进 12 位有符号字段(否则编码不下),且 rd 除了作这个地址没有别的用途(否则删掉它就影响了别的使用者)。两条都满足时指令少一条,省下的寄存器也还回寄存器文件。

选择阶段的产物是机器指令(MachineInstr),操作数是虚拟寄存器(virtual register):一个可以无限编号的临时寄存器,还没绑定到物理寄存器。此时指令可能仍是”伪指令”(pseudo instruction):一条机器指令框架里的占位,名字以 Pseudo 开头,表示”这条操作要做,但具体用哪几条真实指令、需要什么寄存器,等后面再定”。RISC-V 在指令选择阶段产生的伪指令主要有下面几类(后面寄存器分配一节会看到,伪指令按展开时机分成四批,其中还包含条件运算 PseudoCC*、原子操作 PseudoAtomic* 等这里没列出的类):

  • 地址类:PseudoLLA/PseudoLGA/PseudoLA_TLS_IE/PseudoLA_TLS_GDRISCVInstrInfo.td:1965 起;la 助记符对应的 PseudoLA 只由汇编器接受,指令选择在 medlow(小代码模型:假设符号地址装得进 32 位有符号数,定义见地址一节)下直接产出 lui/addiHI/ADD_LO 组合),要等代码模型(对符号地址范围的假设,见地址一节)、重定位(relocation,链接期才填上的地址记号,见汇编与目标文件一节)类型确定后才能展开。
  • 跳转类:PseudoBR:1817)、PseudoCALL:1860)、PseudoTAIL:1918),其中 PseudoBR 在发射时按 TableGen 里写好的展开规则变成 jal x0, ...PseudoCALL/PseudoTAIL 在 ELF 上由 MC 层(LLVM 的机器码层,汇编器与发射器共用,见“从机器指令到汇编与目标文件”一节)一律展开成 auipc+jalr,能不能再压成一条 jal 要到链接期松弛(linker relaxation:链接器在链接时改指令以省空间,见「从机器指令到汇编与目标文件」一节)才知道(MachO 上才是直接发 jal)。
  • 向量配置类:PseudoVSETVLIRISCVInstrInfoVPseudos.td:6090),RVV 一节讲。

伪指令是”选择已经做完、编码还没做”的中间态。它们存在的原因和第 4 类选择还没做有关:不把地址写法、跳转范围、向量参数这些后面才能确定的信息定死,前面就能少做无用功。

一个实际的例子。下面这段 IR:

1
2
3
4
5
6
7
target triple = "riscv64-unknown-linux-gnu"
@x = global i64 5

define i64 @h() {
%v = load i64, ptr @x, align 8
ret i64 %v
}

不启用位置无关代码(PIC,position-independent code)时,@x 的地址是绝对值,选成 lui+ld;启用 PIC 时,外部可见的符号要经过 GOT(global offset table,全局偏移表:位置无关代码里存放符号地址的表)取地址,选成 auipc+ld 再从表项里读一次,汇编里带上重定位记号:

1
2
3
4
5
6
7
# 非 PIC                                   # PIC
h: h:
lui a0, %hi(x) .Lpcrel_hi0:
ld a0, %lo(x)(a0) auipc a0, %got_pcrel_hi(x)
ret ld a0, %pcrel_lo(.Lpcrel_hi0)(a0)
ld a0, 0(a0)
ret

取地址那一段两边都是两条指令,但 PIC 多了一次从 GOT 表项读值的 ld,总条数比左边多一条;而且右边 %pcrel_lo 引用的标签必须与上面那条 auipc 配对——这个配对关系是后面汇编器要检查的内容。链接期松弛(linker relaxation:链接器在链接时改指令长度以省空间,细节见汇编与目标文件一节)因链接器而异。以 lld 的 relaxHi20Lo12:992 为例,它对绝对(medlow)序列 lui+ld 做两件事:符号的绝对地址落在 12 位可编码范围内时,lui 整条删掉、访存改成以 x0 为基址的 ld;地址落在 gp±2 KiB 内时同理,只是基址换成 gp(这就是调用约定一节寄存器表里 gp 那一行的兑现处)。GOT 间接那一类它不做——relax:1035 的分支里只有 HI20/LO12CALL/CALL_PLTTPREL_*ALIGN 与 TLSDESC,没有 GOT_HI20。这些松弛都要编译器先用 +relax 放出 R_RISCV_RELAX 标记(relaxable:735 查的就是紧邻的下一条重定位是不是它),链接器才允许改;clang 默认开 -mrelaxRISCV.cpp:142 那个 hasFlag 的默认值是 true),llc 则要显式 -mattr=+relax

调用约定:一半的寄存器先被分掉

指令选完之后就进入寄存器分配,但在此之前,有一批寄存器已经被 ABI 预定了用途。调用约定就是这份预定表。它在 IR 层只留下符号级的痕迹——call 自带 calling convention,聚合体参数带 byval/sret——但”哪个值放哪个物理寄存器”不在 IR 层。到了后端,它影响每一个阶段:指令选择要为参数准备正确的寄存器类,寄存器分配要把参数寄存器当作活的、把调用者保存的寄存器当作被破坏的,函数进出还要保存和恢复被调用者保存的寄存器。

谁负责什么

RISC-V 的 psABI(processor-specific ABI,体系结构相关的 ABI 约定)把每个寄存器都定死了用途(RISCVRegisterInfo.td:105 起的 let RegAltNameIndices 块),后端只认这张表。表里的调用者保存(caller-saved)指调用别的函数之后值可能被改,调用方要用就得自己先存;被调用者保存(callee-saved)指被调用的函数如果要改它,必须先存好、返回前恢复:

别名 编号 用途
zero x0 恒为零,写它等于丢弃
ra x1 返回地址,psABI 记作调用者保存:本函数一发起调用就会冲掉 ra,要用就得自己在序言里存好;叶子函数从不写它,于是也不用存(LLVM 把 X1 列进 CalleeSavedRegs——被调用者保存寄存器表,注意别跟前文的 CSR=控制状态寄存器撞名——再由 determineCalleeSaves 按需保存)
sp x2 栈指针
gp x3 全局指针(用于相对 gp 的地址,省掉取高 20 位的那条 lui/auipc;由链接器松弛兑现,见指令选择一节末尾)
tp x4 线程指针(TLS,thread-local storage,线程局部存储:每个线程自己一份的数据的基址)
t0t2 x5x7 临时寄存器(调用者保存)
s0/fp x8 被调用者保存;作帧指针时用 fp
s1 x9 被调用者保存
a0a7 x10x17 整数参数与返回值
s2s11 x18x27 被调用者保存
t3t6 x28x31 临时寄存器(调用者保存)
fa0fa7 f10f17 浮点参数与返回值(F/D 扩展)
fs0fs11 f8f9f18f27 浮点被调用者保存
ft0ft11 f0f7f28f31 浮点临时寄存器
v0 v0 掩码寄存器:带掩码的向量指令从它读掩码,此时它不能同时当这条指令的目标
v8v23 v8v23 向量参数与返回值(调用者保存)
v1v7v24v31 同左 向量被调用者保存(RISCVCallingConv.td:29CSR_V

被调用者保存的寄存器集合在 TableGen 里汇总成几个 CalleeSavedRegs 列表(RISCVCallingConv.td:18CSR_ILP32_LP64、加上 F/D 版本和 V 版本)。寄存器分配之后,函数会按实际用到的情况决定保存哪些——没用到就不保存,这是栈帧一节要讲的事。

参数和返回值怎么分

整数参数依次用 a0a7,浮点参数用 fa0fa7,两者是两条独立的通道;返回值分别用 a0/a1fa0/fa1。真正麻烦的是浮点和整数之外的类型。RISC-V 后端的文件头把规则写得很直接(RISCVCallingConv.cpp:58):

  • 单个标量从不拆开传给 IR 层,拆分由后端处理;
  • 结构体如果小于等于 2 × XLen,可以拆成两个寄存器传(两个整数、两个浮点、或一整数一浮点,具体取决于字段类型和 ABI);ABI 里浮点参数是否走 f 寄存器由 -mabi 决定(lp64 全走整数,lp64f/lp64d 允许 F/D);
  • 大于 2 × XLen 的结构体不拆,用指针传:IR 里给参数打上 byval(被调用者按值拿到一份拷贝);返回值大于 2 × XLen 时,改为由调用者提供缓冲区,把指向它的 sret 指针当第一个隐式参数传;
  • 可变参数(C 里在 ... 位置传入的实参)按整数 ABI 传,fa0fa7 不使用(这条不在上面那串文件头规则里,是实现写死的:RISCVCallingConv.cpp:463AllowFPRForF64 = !ArgFlags.isVarArg());
  • RV32 上传 f64 时,如果软浮点 ABI 或浮点寄存器已耗尽,拆成两个 a* 寄存器或一半在寄存器、一半在栈上(CC_RISCVAssign2XLen:308 就是这段逻辑)。

两条规则值得说明为什么这么定。聚合体最多占两个寄存器是 psABI 直接定的:不超过 2×XLen 的拆成两个寄存器传,超过就整块按内存传。参数寄存器只有八个(还可能先被软浮点或别的参数占满),而一个聚合体最多只要两个,所以”部分在寄存器、部分在栈上”只会出现在”第二个寄存器不够用”这一个边界上,调用方与被调用方只需要处理这一种分裂。可变参数部分必须走整数通道,是因为被调用者在编译时只看到 ...、不知道每个实参的类型;只有所有可变实参用同一种通道传,va_arg(按顺序取下一个可变参数的宏)才能往下扫,所以浮点实参在可变部分也放进 a*/栈(这也是 fa0fa7 不参与可变参数的原因)。

这些规则的实现是 CC_RISCV_Impl:它按顺序尝试 fa*a*、栈,并处理”参数一半在寄存器、一半在栈上”的边界情况。分配结果落到三个地方:

  • LowerFormalArguments:把进来的物理寄存器(liveins)写进 DAG,让函数体里的参数值可用;栈上的参数变成当前函数的栈帧对象。
  • LowerCall:把实参从虚拟寄存器/栈槽搬进 a*/fa*/栈上传参区,再生成调用指令。
  • LowerReturn:把返回值搬进 a0/a1/fa0/fa1

liveins 是 MIR 里的一个属性列表,写在基本块头上,表示”进入这个块时哪些物理寄存器已经有值”。函数入口块的 liveins 就是 ABI 规定的参数寄存器集合。看一个例子。下面这段 IR 把 p 指向的 ni64 相加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
define i64 @sum(ptr %p, i64 %n) {
entry:
br label %loop

loop:
%i = phi i64 [ 0, %entry ], [ %inc, %loop ]
%s = phi i64 [ 0, %entry ], [ %add, %loop ]
%gep = getelementptr inbounds i64, ptr %p, i64 %i
%v = load i64, ptr %gep, align 8
%add = add nsw i64 %s, %v
%inc = add nuw nsw i64 %i, 1
%cmp = icmp ult i64 %inc, %n
br i1 %cmp, label %loop, label %exit

exit:
ret i64 %add
}

它在 -stop-after=finalize-isel 时的 MIR(只截入口块;# %n# %p 两行注释是本文加的,llc 并不打印 IR 名字):

1
2
3
4
5
6
7
8
body:             |
bb.0.entry:
successors: %bb.1(0x80000000)
liveins: $x10, $x11

%6:gpr = COPY $x11 # %n
%5:gpr = COPY $x10 # %p
...

x10/x11 就是 a0/a1COPY 是机器级复制指令,把物理寄存器里的入参值搬到虚拟寄存器,后面的函数体只跟虚拟寄存器打交道。返回时反向来一次:$x10 = COPY %3,然后 PseudoRET implicit $x10

栈帧

函数要用到的栈空间(放溢出、放局部对象、放传参区)在指令选择后逐渐确定,最后由帧布局定下来。帧相关的规则集中在 RISCVFrameLowering

  • 栈向下增长,最终大小按 ABI 栈对齐(通常是 16 字节)向上取整(determineFrameLayout:533)。
  • 每个栈对象有个抽象的编号 FrameIndex,具体地址在 eliminateFrameIndex 里变成”基址寄存器 + 偏移”。基址是 sp,需要帧指针时是 x8getFrameIndexReference:1620)。需要帧指针的情形包括栈大小在编译期不确定(alloca)、栈对齐超过 ABI 要求、或者函数里用了 __builtin_frame_address
  • 进入函数时按需调整 sp、保存用到的被调用者保存寄存器(determineCalleeSaves:1829 决定保哪些),离开时反向恢复(emitPrologue:987emitEpilogue:1301)。
  • ra 只在函数里真的发生了调用时才需要保存,不是 ABI 强制每层都存。机制是:调用指令会隐式写 ra,确定保存集合时凡是函数里被写到过的被调用者保存寄存器都要存;没有调用的函数里 ra 从不被写,于是就不进集合。用了帧指针的函数是例外,此时 rax8 无条件保存(determineCalleeSaves:1829 里那段 if (hasFP(MF)))。
  • 栈偏移超过 12 位立即数范围时,sp 的调整要拆成多条 addi,帧指针访问也要先算地址。

RV32 与 RV64 的差别在这里体现在数字上:x 寄存器宽度、指针宽度、栈对齐(E ABI 是 4/8)、以及 RV32 上 64 位值的读写拆成两条。

向量寄存器的栈布局单独有一套对齐规则(assignRVVStackObjectOffsets:1943):RVV 的寄存器长度在编译期只是个范围,所以栈上向量区不能按固定字节数摆。规则是:每个对象按 max(8 字节, 对象自身对齐) 对齐、不足 8 字节的分数类型补到 8 字节,整段最小 16 字节;最后按最小 vscale(代码里是 getRealMinVLen() / RVVBitsPerBlock,而 RVVBitsPerBlock = 64,所以是最小 VLEN / 64;VLEN 是一条向量寄存器的位宽,RVV 一节展开)为单位补一次对齐,使所有偏移都是 vscale 的倍数。序言里这段记成“块数 × vlenb”(RVVStackSize / 8 乘运行期读出的 vlenb——vlenb 是 RVV 的 CSR,值等于 VLEN / 8,即一条向量寄存器的字节数),数值上等价于拿实际 vscale 乘一遍。

机器级优化与寄存器分配

MIR 是什么

指令选择的输出是机器指令序列,它有一套自己的表示,叫 Machine IR(MIR):

  • MachineFunction:一个函数的机器表示,持有栈帧信息、函数属性、基本块列表;
  • MachineBasicBlock:基本块,边上带概率,块头可带 liveins
  • MachineInstr:一条机器指令,操作数是寄存器、立即数、栈帧编号、基本块、符号;
  • 虚拟寄存器:可以无限编号的临时寄存器,用 %0%1 这样的编号加寄存器类表示,例如 %5:gpr。寄存器分配前,机器指令里的操作数几乎全是虚拟寄存器。

MIR 有文本序列化格式(MIRLangRef.md),llc -stop-after=<pass> 可以把任意阶段的状态打印出来,-run-pass=<pass> 可以只跑一个 pass(它只吃 .mir 输入:先把 -stop-after 的输出存成 .mir,再喂回去)。这是后端调试最主要的手段。前面 sum 的例子在寄存器分配之前的 MIR(同样是 -stop-after=finalize-isel 的输出,只截循环块,所以 PHI 还在;再往后到分配之前,PHI 就没了):

1
2
3
4
5
6
7
8
9
10
11
body:             |
bb.1.loop:
successors: %bb.2(0x04000000), %bb.1(0x7c000000)

%1:gpr = PHI %5, %bb.0, %4, %bb.1
%2:gpr = PHI %7, %bb.0, %3, %bb.1
%12:gpr = LD %1, 0 :: (load (s64) from %ir.lsr.iv)
%3:gpr = nsw ADD %2, killed %12
%4:gpr = ADDI %1, 8
BNE %4, %0, %bb.1
PseudoBR %bb.2

可以看到 MIR 里仍然有 PHI 节点:机器层在寄存器分配之前保持 SSA 形式,%1:gpr 每个虚拟寄存器只被赋值一次。机器级的 SSA 使得上一篇文章里的 SSA 结论(每条 use 直接指向 def、删指令只要看 use 计数是否为零、且指令本身无副作用)在这里同样成立,机器级 CSE、LICM、DCE 于是能沿用中端那套基于单一定义的判据;还有一层作用:单一定义意味着一个值只对应一个活跃范围(live range),它可能由若干条区间组成(定义在入口、在几段不相交的区域里被使用),但不会再出现“同一个值被重新定义”那种多个活跃范围共享同一虚拟寄存器的情况。每个虚拟寄存器因此只需要记一组区间,两个值是否互相干涉(interfere:两个值的活跃区间有重叠,因而不能分到同一个物理寄存器)退化成“两组区间里有没有重叠的一对”——这就是活跃区间分析能工作的前提(LiveIntervals 算出每个虚拟寄存器的区间:一个 LiveRange 就是一串按程序点排好序的 Segment,每段记着起点、终点与产生它的那个定义;两个 LiveRange 重不重叠由 LiveInterval.h:458overlaps 逐段比较回答)。

寄存器分配之前

在 SSA 形式还成立的时候,机器级会跑一批与中端同名的优化。通用部分在 addMachineSSAOptimization 里(legacy 路径在 TargetPassConfig.cpp:1337,NewPM 路径在 CodeGenPassBuilder.cpp:731,两份清单一致),按顺序是:提前尾复制(EarlyTailDuplicate)、φ 化简(OptimizePHIs,把互相复制的 φ 合并)、按 lifetime 标记合并互不相交的 alloca 栈对象(StackColoring)、局部栈槽分配(LocalStackSlotAllocation)、死指令消除(DeadMachineInstructionElim)、机器组合(MachineCombiner,经 addILPOpts 钩子插入;RISC-V 在 legacy 路径上覆写了这个钩子、默认开启,NewPM 路径没有覆写,所以只有 legacy 下会跑)、机器 LICM、机器 CSE、机器 sinking、peephole,最后再消一次死指令。这些 pass 的算法与中端对应物相同,区别只在于作用对象是机器指令、可以用机器层的额外信息(寄存器类、寻址方式、指令延迟)。

RISC-V 在这批通用 pass 之间插了几个自己的:

  • RISCVOptWInstrs:RV64 专有。RV64 的 32 位运算有两种写法:add(64 位)和 addw(32 位)。设两个操作数分别为 a,ba,baddw 的结果是 sext32((a+b)mod232)\mathrm{sext}_{32}((a+b)\bmod 2^{32})add 的结果是 (a+b)mod264(a+b)\bmod 2^{64};两者模 2322^{32} 相等,差别只在第 31 位以上的位与符号扩展。这个 pass 做两件事:删掉多余的 sext.waddw 的结果本来就是符号扩展过的,再扩一次不变);以及在 *w 与不带 w 的版本之间挑一个——方向由目标决定。默认(或目标偏好不带 w 的指令时),在“所有使用者都只看低 32 位”的前提下去掉 w 后缀(文件头逐个列了 addw/addiw/mulw/slliw 四种,理由各不相同:addwadd 换来的是更大的可压缩寄存器范围,c.add 是 CR 形式、可编 GPRNoX0 的 31 个(RISCVInstrInfoC.td:468),c.addw 是 CA 形式、只有 GPRC 的 8 个(:405);mulw/slliw 是因为 c.mulw/c.slliw 根本不存在,而 c.mul(要 Zcb)与 c.slli 存在;addiw 只为缩小 RV32 与 RV64 的测试差异);反过来显式开启、或目标偏好带 W 的指令时则加上 w 后缀(add/addi/sub/mul、立即数小于 32 的 sllild/lwu)。两个方向的依据都是上面那条”模 2322^{32} 相等”:使用者不读高 32 位,两个结果在所有被观察到的位上完全相同。
  • RISCVVectorPeephole:把全掩码的 vmerge 化简成 vmv,以及若干向量伪指令的等价改写。
  • RISCVVLOptimizer:在 VSETVLI 插入之前,用稀疏数据流算出每条向量指令实际需要的 VL,把虚高的 VL 操作数改小,以减少后面 vsetvli 需要切换的次数(VSETVLI 是设置向量长度与元素类型的指令,VLMAX 是一条向量指令一次能处理的最大元素数,细节见 RVV 一节)。“把已经等于 VLMAX 的 AVL 直接写成 VLMAX 常量(MIR 里是 -1)”是另一个 pass(RISCVVectorPeephole)做的事,它文件头最后举的例子就是这个;两者合起来决定每条向量指令最终的 VL。
  • RISCVFoldMemOffsetRISCVMergeBaseOffset:前面地址拆分时插入的 addi 如果只被访存指令的地址用到,就把它的立即数折进访存指令的 12 位偏移里;全局地址的两条指令序列(auipc+addi)里,addi 的部分也尽量折进后续访存。(两个的挂点不同:RISCVFoldMemOffset 确实在上面那批里,RISCVTargetMachine.cpp:627、插在基类那串之前;RISCVMergeBaseOffset 更晚,在 addPreRegAlloc 里,:639。)

前面 MIR 片段里的 nsw 标志、killed 标记、implicit 操作数,连同 RVV 一节才会出现的 undefrenamable,都是 MIR 的一部分(后两个先在这里交代):nsw 与 IR 里的 add nsw 同义(上篇引理 0.6);undef 表示这次读的值未定义,renamable 是分配器留在物理寄存器上的“仍可改名”标记;killed 表示这个操作数里的寄存器在这条指令之后不再被读(最后一次使用),它由 LiveVariables 一类分析算出,TwoAddressInstructionRegScavenger(分配后临时找物理寄存器的兜底器,见「汇编期与链接期的松弛」一节的函数内松弛)到今天还读它(TargetPassConfig.cpp:1502 那条 FIXME 想去的正是这条依赖),MIR 打印也带上它——.s 输出里不带这个标记;分配器判断活跃区间靠的是 LiveIntervals(从 def-use 链加 SlotIndex,即函数里按槽位给指令编的号,算出来),不读这个标记;implicit 表示指令隐式读写某个物理寄存器(CSR、vlvtype 等)。

伪指令分成四批展开

伪指令不是一次性展开的,而是分成四批,按”越晚展开越安全”排:

时机 展开什么 源码
寄存器分配前 地址类 PseudoLLA/PseudoLGA/PseudoLA_TLS_* RISCVExpandPseudoPreRA.cpp:9
寄存器分配后 PseudoMovImm/PseudoMovAddr RISCVExpandPseudoPostRA.cpp:9
发射前 条件运算 PseudoCC*、RV32 的 Zdinx 读写等 RISCVExpandPseudoPreEmit.cpp:9
最后 原子指令 PseudoAtomic*/PseudoMaskedAtomic* RISCVExpandPseudoAtomics.cpp:9

四批的公因子是:展开得越早,展开后的指令越可能被其他 pass 优化;展开得越晚,展开时能用的信息越多,也越不容易被其他 pass 破坏。地址类要在寄存器分配前展开,是因为展开会引入新的寄存器需求,分配器必须能看见它们:PseudoLLA 展开成 auipc+addi 需要一条额外寄存器放中间值,在分配前展开,这条寄存器就是普通虚拟寄存器,会被算进干涉图(干涉图:顶点是虚拟寄存器,边表示两个活跃区间重叠);若留到分配后,寄存器已分完,展开时只能临时找一条空闲寄存器,或者像分支松弛那样在栈上预留一个位置。原子指令放到最后,是因为 lr/sc 循环有前向进展要求(循环体内不能出现可能失败的访存、不能有其他原子操作),中间任何 pass 插入的指令都可能破坏这个要求(RISCVExpandPseudoAtomics.cpp:9 的文件头原话就是这个理由)。

PseudoCALL/PseudoTAIL 不在这四批里:输出汇编文本时它们打印成 call/tail,由后续的汇编器展开;输出目标文件时由 MC 层(expandFunctionCall)直接展开成 auipc+jalr,最后能不能变成一条 jal 由链接器决定。

寄存器分配

寄存器分配要解决的是:无限多的虚拟寄存器,塞进有限的物理寄存器。每个虚拟寄存器有一个活跃区间(live interval):它被定义的点、以及从那里到最后一次使用的点之间所有“还活着”的程序点;两个活跃区间重叠的虚拟寄存器不能分到同一个物理寄存器。这个问题可以原样陈述成图着色:把每个虚拟寄存器看成图的一个顶点,两个虚拟寄存器干涉就连一条边,“能不能用 KK 个物理寄存器”就是“这张干涉图能不能用 KK 种颜色染”(染色:给每个顶点配一种颜色,相邻顶点颜色不同)。“干涉图可以任意图实现”这一步归约、以及由此得到的 NP 完全性,标准出处是 Sethi, Complete Register Allocation Problems, SIAM J. Comput. 1975;把寄存器分配写成图着色、并据此给出“化简—染色—溢出”启发式的是 Chaitin et al., SIGPLAN ‘82。下面只给直觉,不是构造性证明。直觉是:任给一张图,可以把顶点写成若干个值,并用分支/循环安排它们的活跃范围,使得“两个顶点相邻”与“两个值的活跃区间重叠”一一对应;于是“GG 能不能用 KK 色染”就变回“这段代码能不能只用 KK 个寄存器”。精确求解既已 NP 难,工程上就不去求它,转而做启发式。有一条对照值得记:SSA 形式下最优寄存器分配是多项式时间的(Hack & Goos, IPL 2006:SSA 程序的干涉图是弦图(chordal graph:每个长度不小于 4 的环上都有一条弦,把环切成两段更短的环),这类图的最优染色多项式可解)。LLVM 仍然选择先跑 PHIElimination、退出 SSA 之后才分配——它的 RegisterCoalescer 要合并的 COPY 有一大批正是 PHIElimination 把 φ 展开出来的,所以顺序上必须先退出 SSA(这是实现选择,不是理论上的必需:SSA 形式上的合并另有成熟算法,就是上面 Hack & Goos 那篇所属的那条研究线);代价是分配器面对的干涉图不再保证弦性。LLVM 用的是贪心分配器(RAGreedy),它被安排在一串准备工作之后(legacy 路径 TargetPassConfig.cpp:1493addOptimizedRegAlloc,NewPM 路径 CodeGenPassBuilder.cpp:849):

  1. DetectDeadLanes:找出哪些子寄存器位从未被读;
  2. InitUndef:给未定义的读补一个 undef;
  3. ProcessImplicitDefs:把隐式定义转成显式定义;
  4. UnreachableMachineBlockElimLiveVariablesMachineLoopInfo
  5. PHIElimination:把 φ 节点变成前驱块末尾的复制,机器级 SSA 在这里结束。它之后每个虚拟寄存器可以有多个定义点(每个前驱一条复制),”一个值只有一个定义”不再成立:此时一个虚拟寄存器对应一个活跃范围,但它内部要按定义点分段,每一段配一份版本信息(VNInfo,即“活跃区间的版本信息”:标记某一段区间是哪个定义点产生的),分析时把不同版本的区间当作不同的值;机器 CSE 一类靠单一定义做值编号的优化也不能再跑。通用流水线把机器 SSA 优化全部安排在它之前,原因就在这;
  6. TwoAddressInstruction:处理两地址指令(目标寄存器同时是源操作数)——RISC-V 的三操作数格式基本不需要这一步,但通用流水线上仍会跑;
  7. RegisterCoalescer:把 COPY 的两端合并到同一个寄存器,能省掉复制的尽量省掉;
  8. RenameIndependentSubregs:把位域互不干涉的子寄存器拆成独立的虚拟寄存器;
  9. 机器调度(pre-RA scheduling);
  10. 分配本身(RAGreedy),然后是 VirtRegRewriter 把虚拟寄存器全部换成物理寄存器。

贪心分配器按溢出代价给区间排序:代价高、冲突多的先分;分不到就先尝试抢占(evict)优先级更低的区间、把它挪走,再不行把这个区间拆成几段让每段落到不同寄存器或与其他区间错开,最后才整段溢出——把值写回栈(溢出,spill),用的时候再读回来(reload)。为什么”溢出”是一个总逃得掉的出口,靠的是两个常数上界。第一个:一条指令同时占用的寄存器操作数有上界(RISC-V 基础的整数指令最多三个:两个源加一个目标;最宽的融合乘加是上面提到的 R4 形式、三个源加一个目标,而且属于浮点寄存器;再多的是 RVV 的带掩码伪指令——一个目标加 passthrough(给这条指令本轮不处理的那些元素位置提供初值的操作数,正式说明在 RVV 一节)、两个源、一个掩码,寄存器操作数一共五个,其中目标与 passthrough 是绑定的同一个寄存器,所以同时占用的物理寄存器是四个)。第二个:通用寄存器类里有几十条可分配的物理寄存器GPR 的 32 个里 x0spgptp 恒被保留,要帧指针时再加 x8,实际可分配 27–28 条;FPR 的 32 条全可分配;RVV 的组类和 GPRC/VMV0 这类专用子类除外——后者是绑死单个寄存器或只剩几个成员的类,见 RVV 一节)。有了这两个上界,分配器总可以退化成”只保留当前指令要用的值在寄存器里、其余全部 spill”,寄存器压力于是有常数上界、总找得到解。还差一环:执行 spill/reload 自己也要一条寄存器。这一环由兜底机制补上——RegScavenger 反向扫一条空闲寄存器,扫不到就用帧里预留的应急槽(「汇编期与链接期的松弛」一节的函数内松弛用的是同一套)。例外只有两类:某个寄存器类里的寄存器全被保留(例如全部留给内联汇编或特殊寄存器),或者一条指令要求同时占用的操作数超过该类的可分配数量,此时分配器报错,而不是悄悄生成错代码。溢出不改变值:同一个栈槽只被活跃区间互不重叠的值复用,每个定义点之后写、最后一次使用之前读,所以读回来的就是这次写进去的值;能重新算出结果的指令则直接重算(rematerialization,例如重放一条 addi,比读一次内存便宜),重算的前提是这条指令没有副作用,且它的操作数在重算的位置仍然可用。溢出的位置由分配器创建栈槽,加入 MachineFrameInfoMachineFrameInfo 是机器函数里记录“这个函数有哪些栈对象、多大、怎么对齐”的容器),后面帧布局时统一安排。

RISC-V 在这里有三点自己的东西:

  • 寄存器类的分类。不同寄存器类之间不能互换:一条指令的操作数声明了类,就不能改用别的类的寄存器。GPR、FPR、VR 分属三个独立的寄存器文件,分配器分别处理;同一条指令选错了寄存器类,后面没有补救手段。这一层本身是目标无关的:通用分配器只按类的约束求解,不为某个类开后门(下一条例外);但类怎么划、划多大是目标在 .td 里定的,划分直接决定分配的难度。
  • RVV 用单独的分配器。RVV 的寄存器可以组成 2 个、4 个、8 个一组(LMUL,寄存器组倍数,见 RVV 一节),组内编号要求连续且起始编号对齐;寄存器类因此派生出 VRM2/VRM4/VRM8 这类超寄存器(super register:把一组物理寄存器当成一个分配单位,组内各成员是它的子寄存器)来表示整组。RISC-V 后端为向量值单独跑一轮分配:单独的分配器(默认 greedy) 就是把 onlyAllocateRVVReg:334 这个谓词交给通用分配器(:363–372),非 RVV 寄存器仍走默认分配器。两轮分配的顺序是:先让 RVV 分配器把所有向量值定型并替换成物理寄存器,再跑通用分配器;这样通用分配器看到的向量值已经是占住一组寄存器的固定占用,只需避开这些寄存器给整数与浮点值分配。拆成两轮是为了让向量值先定型成物理寄存器、好让下一节的 RISCVInsertVSETVLI 在 RVV 那一轮分配之后插入 vsetvli(引入 RVV 分配器的提交 ac4868ea3c49 写的就是“为 post-RA 的 vsetvli 插入做准备”;这条时序在 legacy 路径上,NewPM 路径的缺项见前文流水线说明)。
  • 保留寄存器spgptpfp、CSR、ssp)在分配时被移出可分配集合,这是 getReservedRegs 的直接效果。

分配完虚拟寄存器就不存在了,MIR 里只剩物理寄存器和 COPY。之后的优化必须按物理寄存器考虑,能做的只剩”在不改变寄存器分配结果的前提下化简指令”:

  • MachineCopyPropagation 消掉多余的复制;
  • StackSlotColoring 合并 spill 槽(与寄存器分配共用一个 pass 阶段)——注意它和上面那个 StackColoring 是两个 pass:那个合局部变量(alloca)对象,这个合溢出槽;
  • MachineLICM 把 reload 提到循环外;
  • 后置调度(post-RA scheduler)按指令延迟重排,但不改变寄存器占用;
  • 帧的插入(栈帧一节的进入/离开代码);
  • RISCVRedundantCopyElimination 删掉 beqz/bnez 目标块开头的”把 x0 复制过去”;
  • RISCVMoveMergerRISCVPushPopOptimizerZcmp 扩展的 cm.push/cm.pop 把多条保存/恢复合成一条(这一段由帧布局在进出函数时生成,需要 Zcmp);RISCVMoveMerger 把成对的寄存器移动合并成 Zcmp 的 cm.mva01s/cm.mvsa01RISCVPushPopOptimizer 则把 cm.pop 后面紧接的 ret 合成一条 cm.popret,返回值是常数 0 时去掉那条 a0 = x0 复制、改用 cm.popretz
  • RISCVMakeCompressible:如果有可压缩的寄存器空闲,把访存指令的基址/数据先复制过去,让整条指令能用 2 字节压缩格式;或者把过大的偏移拆成”先复制基址再加小偏移”。收益按字节数算:复制一条寄存器用 c.mv/c.li 花 2 字节,此后每有一条指令因此能压缩就省 2 字节,所以同一个寄存器至少被两条访存指令用到时才净赚——文件头那句 “only applied if there are enough uses … for code size to be reduced”(:63)说的就是这个不等式,但两个门槛的具体数字在代码里::397:400CopyCost(默认 1)与 BaseCost(默认 3),判定是 MIs.size() <= CopyCost 就放弃。偏移过大的情形门槛高一档:调整基址的那条 addi 自己就是非压缩的 4 字节,每条访存省 2 字节,所以要至少三条访存才净赚,对应同一处多出来的那个条件 RegImm.Imm != 0 && MIs.size() < BaseCost
  • RISCVZilsdOptimizerRISCVLoadStoreOptimizer:把两条相邻的 32 位 load/store 合成一条 64 位的 ld/sdZilsd 扩展,只在 RV32 上有意义:指令的谓词是 [HasStdExtZilsd, IsRV32],见 RISCVInstrInfoZilsd.td:36;两个 pass 的入口条件也都带 !is64Bit()RISCVLoadStoreOptimizer.cpp:141;实测 llc -mtriple=riscv64 -mattr=+zilsd 直接报 “‘zilsd’ is only supported for ‘rv32’”)。RISCVZilsdOptimizer 在寄存器分配前把访存排在一起、并给出“应该用哪对连续寄存器”的提示(预分配阶段);分配之后由 RISCVLoadStoreOptimizer 检查寄存器是否真的凑成了对,没凑成就把不合法的那条拆回去(后分配阶段就合并进这个 pass,文件头把它单列为 “Post-allocation Zilsd decomposition”)。注意 RISCVLoadStoreOptimizer 不只服务 Zilsd:它同时还做 MIPS 风格与 Qualcomm Xqcilsm 的访存配对(:126useMIPSLoadStorePairs() || hasVendorXqcilsm()),那两条走 tryToPairLdStInst,与 ZilsdfixInvalidRegPairOp 是两个分支。

到这里第 3 类选择(寄存器)讲完了。函数里每个值要么在某个物理寄存器里,要么在某个栈槽里,不再有”还没定”的值。

RVV:长度在编译期未知的寄存器组

前面五节的所有结论都建立在一个前提上:一个值的大小在编译期是定的,放得下就在寄存器里,放不下就拆。RVV(RISC-V Vector,V 扩展)去掉了这个前提:一条向量指令处理多少个元素,由运行期的 vtype(元素宽度 SEW、寄存器组倍数 LMUL)和 vl(本次处理的元素数)决定,而不是由指令编码决定。RVV 规范要求向量寄存器长度 VLEN 是 2 的幂、上界 65536;下界分两档——嵌入式子集 Zve32x 只要求 32(对应 FeatureStdExtZvl32bZvl 系列一路排到 65536),面向应用处理器的完整 V 扩展才要求 128(:698FeatureStdExtV 蕴含 [Zvl128b, Zve64d])。LLVM 编译期取的下界卡在两档之间:断言只要求 64–65536(RISCVTargetMachine.cpp:221 一带,注释里留着“支持 VLEN=32 后再改”的 FIXME;官方文档 RISCVUsage.md:304 也写明 “LLVM currently assumes a minimum VLEN … of 64 bits”),具体取值由 -riscv-v-vector-bits-min/-max(单位是 bit)或函数属性 vscale_range(N, M)(单位是 vscale,乘上 RVVBitsPerBlock = 64 才是 bit)收窄。同一份二进制在不同 VLEN 的机器上跑,每条向量指令处理的元素数不同,但结果都正确。

三件在编译期未知的事

RVV 把一个额外的状态层插进了机器模型,由三个东西描述:

  • SEW:元素宽度(8/16/32/64 位);
  • LMUL:一条向量指令占几个寄存器(1、2、4、8,或分数 1/2、1/4、1/8),寄存器组内编号必须连续且起始编号按组大小对齐;
  • vl:当前指令处理多少个元素,最大值为 VLMAX = VLEN/SEW × LMUL

这三个值由 vsetvli/vsetivli 指令写进 vtypevl 两个 CSR,之后的向量指令隐式读取它们。也就是说,两条相同的 vadd.vv 在不同 vtype/vl 下做的事情不同,而 vtype/vl 是隐含的:指令编码里没有它们。这个设计让 RISC-V 的向量代码长度与向量长度无关(循环不需要按 VLEN 展开),代价是编译器必须显式维护这层状态。

vtype 还带两个策略位(RISCVInstrInfoVPseudos.td:27 的文件头有完整说明):

  • tail policyvl 之后的元素是保持不变(undisturbed)还是 agnostic;
  • mask policy:被掩码关闭的元素是保持不变还是 agnostic。

agnostic 不等于”任意写”:规范里它指”保留原值,或把这些 lane(lane:向量里的一个元素位置)写成全 1”,而且每个 lane 可以独立选。LLVM 在这两态之外还多记一个规范里没有的 Undefined 态:伪指令上那个给出非活跃 lane 初值的 passthrough 操作数是 IMPLICIT_DEF(MIR 的“未定义值”)或 $noreg(MIR 里“这里没有寄存器”的占位)时,这些 lane 才是真·任意——可以随便改而不影响程序语义。

LLVM 伪指令的命名与操作数划分由 RISCVInstrInfoVPseudos.td:9 的文件头说明,大原则是“影响寄存器类的字段用一族伪指令(写进名字),不影响寄存器类的字段用 dummy 操作数传递”:

  • 影响寄存器类的一族:LMUL_M1/_M2/_M4/_M8,以及分数 LMUL 的 _MF2/_MF4/_MF8)——M1 的虚拟寄存器属于单寄存器类,M2 就属于两寄存器一组的类;masked/unmasked(_MASK 后缀)也会改变寄存器重叠约束,因此也是一族。
  • 用操作数传递的:vlsew、策略位。后面 vadd 那个 MIR 例子里的 4 /* vl */5 /* e32 */1 /* ta, mu */ 就是这些操作数。注意 _E32 这个后缀同样出现在名字里,看上去与上面那条大原则矛盾:SEW 属于“不影响寄存器类”的一类,为何又进了名字?原因是同一条真实指令要按 SEW 拆成多条 TableGen 记录:反查表 RISCVVInversePseudosTable:599 起)的主键是 (BaseInstr, VLMul, SEW, IsAltFmt):604),名字里的 _E{sew} 就是这个主键的可见部分,而且只在 SEW 确实相关时才加(:2097!if(sew, …):581 的注释:“SEW = 0 表示这条伪指令不区分 SEW”)。拆开是为了让谓词调度信息能按 SEW 挂上去:整数 SEW=64 需要 HasVInstructionsI64:802GetVTypePredicates),调度类也可以带上具体 SEW(:2082SchedBinary<…, mx, e>)。SEW 的数值另外还作为操作数再传一遍,供 VSETVLI 插入等 pass 使用。

可扩展向量类型与容器类型

LLVM 用一个专门的值类型表示”长度在编译期未知的向量”:<vscale x N x T>,其中 vscale 是运行期确定的缩放因子。RISC-V 的定义是 vscale = VLEN / 64RISCV::RVVBitsPerBlock = 64RISCVTargetParser.h:69),所以 <vscale x 2 x i32> 在 VLEN=128 时是 4 个 i32,在 VLEN=256 时是 8 个。取 64 作基准,是因为可扩展类型需要一个与具体 VLEN 无关的已知最小块:<vscale x 1 x i64> 正好是 LMUL=1、SEW=64 时一条向量寄存器的容量,其它元素宽度都按 64 位一块折算,vscale 就与 LMUL 的定义对上了。IR 里可以直接写这种类型(可扩展向量),也可以写固定长度的 <4 x i32>。固定长度的向量怎么落到 RVV 上,是 useRVVForFixedLengthVectorVTgetContainerForFixedLengthVector 决定的:固定长度向量在 DAG 里被放进一个可扩展的”容器”类型,运算做完再抽出来,抽出时把长度限制回原来的固定值。需要容器的原因是 RVV 指令的操作粒度是整条向量寄存器,没有”恰好 4 个 float”这样的指令形式;都放进容器后,运算可以用同一套可扩展类型的模式选指令,不必为每种固定长度单独写一套规则。这套转换只在 V 扩展存在、且长度确实能被 RVV 高效处理时才启用。

vscale 的取值不是一个具体数,而是一个范围。-mattr=+v 默认的最小 VLEN 是 128(zvl128b),vscale_range 属性或 -riscv-v-vector-bits-min/-max 能把它收窄。收窄的价值在代价模型上:getVScaleForTuningRISCVTargetTransformInfo.cpp:453)把未知的 vscale 换成某个代表值来估成本和决定展开因子。

插入 vsetvli:一个前向数据流问题

RVV 后端里最核心的 pass 是 RISCVInsertVSETVLI。它的任务很直接:给每条向量指令补上它需要的 vsetvli,同时又不能每条都补——连续的向量指令如果要求的 SEW、LMUL、策略、vl 都一样,就应该共用一条。它把”每个程序点上 vtype/vl 是什么”做成一个数据流问题。

状态是什么。每个程序点上的 vtype/vl 用一个状态表示,字段是:AVL(application vector length,最近一次 vsetvli 拿到的那个参数:一个立即数、一个虚拟寄存器连同它的版本号,或者 VLMAX;寄存器带版本号是为了区分”同一个寄存器号被重新定义过”)、SEW 与 LMUL(vtype 的元素宽度与寄存器组倍数,如果只关心两者的比,就只保留这个比)、tail 与 mask 两个策略位,外加一个保底值 Unknown(表示这里的 vtype/vl 在编译期无法确定)。两个状态可以比较:一个状态如果能满足一条指令的要求(要求的字段它都有且取值相容),这条指令就不需要新的 vsetvli

三个阶段

  • 第一阶段,逐块摘要:从基本块当前的入口状态出发,逐条指令更新状态——若当前状态与这条指令的要求不兼容,就把状态改成指令要求的值;块走完得到出口状态。因为兼容性判定会看入口状态,入口一变出口就要重算。
  • 第二阶段,块间传播:块入口状态是全部前驱出口状态的合并——完全相同就保留;只有 AVL 与 VLMAX 相同就只保留 SEW/LMUL 比;否则记 Unknown。按 worklist 反复传播到不再变化。合并只会让状态更保守,状态取值只有有限多个,所以这个迭代必然停(上篇 T1 的有限高度格)。代码在第 2、3 阶段之间还插了一步部分冗余消除,把多个前驱能共用的 vsetvli 提前。
  • 第三阶段,插入:从块入口状态出发逐条走;状态兼容就继续,不兼容就生成一条 PseudoVSETVLI/PseudoVSETIVLI 把要求的状态写出来,并把跟踪的状态更新成它要求的值。之后再做一轮块内合并与死代码消除,把相邻的重复 vsetvli 合并掉。

为什么不会漏插。第二阶段的合并是保守的:合并出来的状态在每条前驱路径上都被满足(两个前驱都确定的字段才保留,只要有一个不确定就记 Unknown)。第三阶段只在状态与要求不兼容时才插,插完后状态就与要求一致。沿任意一条执行路径归纳:每条向量指令执行前,最后一次写入 vtype/vl 的状态都满足这条指令的要求;把状态记成 Unknown 只会让插入变多,不会让该插的插不上。

这个 pass 的位置在 RVV 寄存器已经分配并重写之后、通用寄存器分配之前(legacy 路径的时序):它跑的时候向量值已经是物理寄存器,整数/浮点值可能还是虚拟寄存器。看一个例子,下面这个函数只做两个 <4 x float> 的加法:

1
2
3
4
define <4 x float> @vadd(<4 x float> %a, <4 x float> %b) {
%r = fadd <4 x float> %a, %b
ret <4 x float> %r
}

-stop-after=riscv-isel 时 MIR 里还没有 vsetvli,伪指令上带着 vl/SEW/策略:

1
2
3
4
5
6
7
bb.0 (%ir-block.0):
liveins: $v8, $v9
%1:vr = COPY $v9
%0:vr = COPY $v8
%2:vr = nofpexcept PseudoVFADD_VV_M1_E32 $noreg, %0, %1, 7 /* frm=dyn */, 4 /* vl */, 5 /* e32 */, 1 /* ta, mu */, implicit $frm
$v8 = COPY %2
PseudoRET implicit $v8

(MIR 打印器会把操作数名字注解在值后面,注解由 RISCVInstrInfo.cpp:4012createMIROperandComment 按操作数类型生成:7 是浮点舍入模式 frm=dyn4 是这条指令的 vl。)%2 这条伪指令带着 vl=4e32ta,mu 这些操作数;$frm 是浮点舍入模式 CSR(由另一个 pass 插入读写)。跑完这个 pass,vsetivli 出现了:向量指令上补了 implicit $vl, implicit $vtype,运行期的长度改由这两个 CSR 提供;那个显式的 4 /* vl */ 仍然在,它只是编译期留下的 AVL 记录,供 pass 反推这条指令的要求:

1
2
3
4
5
bb.0 (%ir-block.0):
liveins: $v8, $v9
dead $x0 = PseudoVSETIVLI 4, 208 /* e32, m1, ta, ma */, implicit-def $vl, implicit-def $vtype
renamable $v8 = nofpexcept PseudoVFADD_VV_M1_E32 undef renamable $v8, killed renamable $v8, killed renamable $v9, 7 /* frm=dyn */, 4 /* vl */, 5 /* e32 */, 1 /* ta, mu */, implicit $frm, implicit $vl, implicit $vtype
PseudoRET implicit $v8

三个 MIR 记号顺带交代:implicit-def $vl 是隐式定义——这条指令写 $vl,但它不作为显式操作数出现;dead $x0dead 表示这个定义没人读,vsetivli 要的只是写 CSR,目标落到恒零的 x0 正好丢弃;第一段 MIR 里的 nofpexcept 是机器层标志,说这条指令不会触发浮点异常(来自 IR 上的 nnan/ninf 一类快速数学标志)。

最后汇编出来,向量部分就两行(后面还跟着一条 ret):

1
2
vsetivli	zero, 4, e32, m1, ta, ma
vfadd.vv v8, v8, v9

RVV 的其余专属 pass

下面这批里,RISCVInsertReadWriteCSRRISCVInsertWriteVXRMRISCVVMV0Elimination 与前面的 RVV 分配器、VSETVLI 插入一样只挂在 legacy 流水线上(NewPM 路径上依次对应 RISCVCodeGenPassBuilder.cpp:132:133:138 三条 TODO);最后两个(RISCVVectorPeepholeRISCVVLOptimizer)两条路都跑:NewPM 把 RISCVVLOptimizer 接在 :113RISCVVectorPeephole 接在 :116

  • RISCVVMV0Elimination:RVV 规定掩码操作数必须放在 v0。LLVM 在指令选择时把掩码表示成一个单例寄存器类 VMV0 的虚拟寄存器(RISCVRegisterInfo.td:941),方便在 MIR 上优化掩码;分配前再由这个 pass 换成一到多个”复制到 $v0“的序列。触发点是寄存器合并(coalescing):它会把 copy 折进 vmv0,于是同一条指令里出现两个 vmv0 操作数——文件头举的例子是 %x:vrnov0 = PseudoVADD_VV_M1_MASK %0:vrnov0, %1:vr, %2:vmv0, %3:vmv0vrnov0 是”不含 v0 的向量寄存器类”)。分配器的模型是”每个虚拟寄存器的活跃区间映到一个物理寄存器、重叠的两个区间不能映到同一个”,而 VMV0 这个类里只有 $v0 一个成员:同一条指令上的两个 vmv0 虚拟寄存器,活跃区间必然在这条指令处重叠,这对干涉于是无解。这个 pass 的办法是提前把它们复制成物理寄存器 $v0,等式约束就退化成普通的物理寄存器使用,分配器只需给这些复制安排先后。
  • RISCVInsertWriteVXRM:向量定点运算的舍入模式由 vxrm CSR 决定。这个 pass 用一前一后两个数据流分析(一个算”哪里有值可用”,一个算”哪里需要值”)尽量少写 vxrm
  • RISCVInsertReadWriteCSR:带静态舍入模式的浮点向量指令会隐式写 frm,这个 pass 在指令前后保存和恢复 frm
  • RISCVVectorPeepholeRISCVVLOptimizer:前面「寄存器分配之前」一节已列。

到这里 RVV 对第 2 类(指令)与第 3 类(寄存器)选择的额外影响讲完了。向量寄存器组仍然遵循寄存器分配一节的规则,只是”一个虚拟寄存器”对应的是”一组物理寄存器”,并且这一组的大小由 LMUL 决定。

从机器指令到汇编与目标文件

第 4 类选择是编码:把每条机器指令变成字节,把跨模块的引用变成重定位记号。这一段的公共名字是 MC 层:llc 的最后一步把 MIR 交给 AsmPrinter,AsmPrinter 把 MachineInstr 降成 MCInst(一条指令的 opcode 加一串 MCOperand;MCOperand 只有三种:立即数、寄存器号、符号表达式 MCExpr——符号地址恰恰还没解析,留给下面的 fixup),再交给 MCStreamer(CodeGenerator.md:579)。MCStreamer 是个抽象接口,实现不止两个;llc 这条路上用到的是两种,对应两种输出:

  • 输出汇编文本时走 MCAsmStreamer,每条 MCInst 直接打印成一行汇编(RISCVInstPrinter);
  • 输出目标文件时走 MCELFStreamer,每条 MCInst 交给 RISCVMCCodeEmitter 编码成字节,产生的重定位请求交给 RISCVAsmBackend/RISCVELFObjectWriter 变成 .o 里的重定位项。

RISCVAsmPrinter::emitInstructionRISCVAsmPrinter.cpp:387)是这条路的入口;模块开始时它还要输出模块级信息(emitStartOfAsmFile:634):模块的 target-abiriscv-isa 元数据、.attribute arch 等。

汇编文本里有什么

一次 llc -mtriple=riscv64 -O2 输出的 .s 大致长这样(对应前面出现过的 sum):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
	.attribute	4, 16
.attribute 5, "rv64i2p1"
.text
.globl sum
.p2align 2
.type sum,@function
sum:
.cfi_startproc
mv a2, a0
li a0, 0
seqz a3, a1
add a1, a1, a3
slli a1, a1, 3
add a1, a2, a1
.LBB0_1:
ld a3, 0(a2)
addi a2, a2, 8
add a0, a0, a3
bne a2, a1, .LBB0_1
ret
.Lfunc_end0:
.size sum, .Lfunc_end0-sum
.cfi_endproc

.text.globl.p2align.type.size 是给汇编器与链接器的指令,不产生机器码;.cfi_startproc/.cfi_endproc 是调用帧信息(CFI)的标记,调试器与异常处理用它们展开栈帧。

汇编文本里还会出现一类只在文本层面成立的助记符:mvliretnopcalltailla。有的是一条真实指令的别名(mv rd, rs 就是 addi rd, rs, 0ret 就是 jalr x0, ra, 0nop 就是 addi x0, x0, 0);有的要展开成多条(call/tail/la,以及手写汇编里的大立即数 li——编译器自己输出的 li 只可能是 12 位以内那条别名,不需要展开)。展开发生在哪里取决于输出:输出汇编文本时由后续的汇编器(GNU as,即 GNU 汇编器,或 llvm-mc)展开 call/la 这类,输出目标文件时由 MC 层展开(RISCVMCCodeEmitter::expandFunctionCall:169 一类函数);llvm-mc 走的就是同一套 MC 展开,不是第二份实现。两种输出在语义上等价。

汇编器一侧的实现分两步:解析与匹配(RISCVAsmParser),以及匹配之后的指令级处理(processInstruction:4398)。processInstruction 处理的就是伪指令:li 展开成一串整数物化指令(RISCVAsmParser.cpp:4436emitLoadImm:3741RISCVMatInt::generateMCInstSeq,产出 lui/addi/slli/srli/bseti 以及若干扩展专用形式(add.uw/slli.uw/pli.*/qc.li)的组合;汇编器这一侧没有常量池,常量池那条路属于编译器,定义见机器配置一节),la/lla 展开成地址序列(emitLoadAddress:3811:非 PIC 走 PC 相对的 lla,PIC 走 GOT 间接的 lga);call/tail 不在 processInstruction 里,输出目标文件时由 MC 层的 expandFunctionCall 展开成 auipc+jalr。汇编器同时还要处理 .option arch.attribute arch 这类指令,让同一份 .s 里不同函数可以用不同的扩展——这在 MIR 层是 Subtarget 按函数取不同值,在汇编层则靠 .option push/pop 表达。

编码与重定位

RISCVFixupKinds.h:19 里列着 RISC-V 的 fixup 类型:fixup_riscv_hi20fixup_riscv_lo12_ifixup_riscv_lo12_sfixup_riscv_pcrel_hi20fixup_riscv_pcrel_lo12_ifixup_riscv_pcrel_lo12_sfixup_riscv_jalfixup_riscv_branch等等。fixup 是”这条指令里有一个位置需要等符号地址确定后再填”的记录。汇编器在指令里留下 fixup:本地符号在布局完成后就填上,跨模块符号则转成重定位,由链接器在链接期填。

fixup 到目标文件里变成重定位(relocation),由 RISCVELFObjectWriter 转换:fixup_riscv_hi20 变成 R_RISCV_HI20fixup_riscv_pcrel_hi20 变成 R_RISCV_PCREL_HI20,跳转变成 R_RISCV_JAL/R_RISCV_BRANCH,调用变成 R_RISCV_CALL_PLT,等等。重定位在链接期由链接器处理,它需要知道每条重定位的语义:HI20 填的是符号地址的高 20 位(加上符号地址低 12 位带来的进位),LO12 填的是低 12 位,PCREL_HI20 是”相对当前 PC 的高 20 位”,PCREL_LO12 是”相对配对的那条 auipc 的低 12 位”。后两者必须成对出现,这个配对关系就是 %pcrel_lo 后面必须跟一个标签、标签必须贴在配对 auipc 上的原因(指令选择一节里那个 PIC 例子里 .Lpcrel_hi0 的作用)。

汇编期与链接期的松弛

RISC-V 的指令长度不固定(32 位或 16 位),链接器可以在链接时把一条 32 位指令换成 16 位的等价形式,或者把 auipc+jalr 这对压成一条 jal,前提是新的偏移还在范围内。这套机制叫链接期松弛(linker relaxation),它让编译器不必为了省空间付出代价,只要在标记重定位上表个态即可(R_RISCV_RELAX 说“这条指令我允许你改”,R_RISCV_ALIGN 说“这里要对齐,填充字节数你重算”)。以最常见的一条为例——把调用序列 auipc ra, %pcrel_hi(f) + jalr ra, ra, %pcrel_lo(...) 压成一条 jal ra, f:原序列里 auipcra 设成 PC + (hi << 12)jalr 跳到 ra + lo(等于 f),并把 ra 设成它后面那条指令的地址;压成一条 jal 后,调用整体短了 4 字节,后面的指令跟着前移,而 jalra 设成它后面那条指令的地址——也就是前移后的同一个返回点,所以”返回到哪里”仍然指向同一条指令,”跳去 f”不变。能压的前提是 f - PC 能装进 JAL 的 21 位有符号字段(±1 MiB),并且 f 在链接期可定。松弛能进行的前提是链接器被允许改动指令长度:汇编器只在可能被改短的指令与对齐处留下标记,链接器只动带标记的地方。缩短一条指令会让它后面所有符号的位置前移,所以链接器要反复收缩与重算,直到没有新的指令可以缩;对齐填充也不能写成固定字节数,要按新的位置重新对齐。这也是为什么编译器不能把”两条指令之间的字节距离”当常数:链接后任何一段距离都可能因为别处的松弛而变化(缩短指令让后面前移,重新对齐又可能把填充加回去),调试信息、异常处理表与跳转表要用符号差而不是硬编码偏移;R_RISCV_ALIGN 标的就是对齐点——编译器只告诉链接器”这里要对齐到多少”,而不写死填充字节数,链接器收缩之后按新位置重算。

编译器侧要做两件事:

  • 函数内的松弛:目标文件还没交给链接器,函数内部的跳转是否超出范围是编译器自己知道的,由 BranchRelaxation 处理。无条件分支超出 ±1 MiB 时,替换成 PseudoJump(它是 isCodeGenOnly = 0 的伪指令,在 -S 输出里打印成 jump,由 MC 层(-filetype=obj)或 llvm-mc 展开成 auipc+jalr——jump 是 LLVM 专有助记符,GNU as 不认;展开后是 PC 相对,覆盖 32 位范围;展开需要一条临时寄存器,由临时寄存器分配器 RegScavenger 提供,找不到就先把值存到栈上预留的位置)。条件分支超出 ±4 KiB 时,把条件取反,让它跳过一条跳向原目标的无条件分支。为什么等价:设原分支是”条件 c 成立就跳 T”,改写后是”c 不成立就跳过下一句,否则执行下一句的无条件跳转去 T”;c 成立时走跳转、去 T,c 不成立时跳过跳转、继续原后继块,两条路径与原程序一一对应。取反后条件分支只需跳过一条指令,距离必然够近;远距离由无条件分支承担,无条件分支再超出 ±1 MiB 就按上一条换成 PseudoJump。若条件无法取反,则新建一个紧邻的块放无条件分支,条件分支改为跳向这个近块,同样保持两条路径的对应。RISC-V 的 B 型范围只有 ±4 KiB,所以这一步经常触发。
  • 汇编期的松弛RISCVAsmBackend 在汇编期间就能确定一部分符号,发现短跳转的目标超出它的范围时先把它放大(mayNeedRelaxation:471relaxInstruction:248,例如把 C.J 放大成 JAL)。对齐处也要处理:relaxAlign:331 说,链接器松弛会让后面的指令位置移动,原来用来对齐的固定字节数可能不再合适,所以对齐处必须保留可变长度的方案。

地址、代码模型与 TLS

RISC-V 的地址生成没有 x86 那样的单一 PC 相对寻址,也没有单条指令的绝对地址加载,所以”一个全局符号的地址怎么算”变成了一组预先定义好的模式,由代码模型(对符号地址能落在多远的假设)和重定位类型选择。三支分支就写在 lowerGlobalAddress 的那个 switch:10104 里(Small:10107Medium:10123Large:10147,最后这一支本文不展开):

  • medlowCodeModel::Small):假设符号地址能装进 32 位有符号数(低 2 GiB 或高 2 GiB),静态链接时用 lui+addi 的绝对序列(%hi/%lo);
  • medanyCodeModel::Medium):换成假设符号落在当前 PC 的 ±2 GiB 内(lowerGlobalAddress:10142 的注释原话是 “within any 2GiB range”),用 auipc+addi 的 PC 相对序列(%pcrel_hi/%pcrel_lo);
  • 位置无关(PIC):外部可见符号要经过 GOT,用 %got_pcrel_hi 取 GOT 表项的地址,再从 GOT 表项里读符号地址(指令选择一节 PIC 例子里的三条指令);
  • TLS:线程局部变量的四种模型(local-exec、initial-exec、local-dynamic、general-dynamic)各有自己的地址序列,用 %tprel_hi/%tprel_lo%tls_ie_pcrel_hi%tls_gd_pcrel_hi 等说明符表达,这些都列在 RISCVMCExpr.cpp:23 的解析表里。

这些模式在指令选择的 lowerGlobalAddress(指令选择一节)里选,在 MC 层变成重定位,在链接期才真正确定数值。这解释了为什么 RISC-V 后端有那么多与地址有关的 pass:地址不是一条指令,而是一段要尽可能短的指令序列,每一段都值得再缩短一次。

第 4 类选择(编码)到这里讲完。程序从 IR 出发,经过四次收窄,变成了可以被汇编器、链接器、加载器处理的目标文件。

怎么验证每一步

上面每一步都是可观察的。常用的手段:

  • 固定住机器配置:llc -mtriple=riscv64 -mattr=+m,+a,+f,+d,+c -mcpu=sifive-u74
  • 只看某个阶段的状态:-stop-after=<pass>(在该 pass 跑完之后停下)、-stop-before=<pass>(跑之前停下)、-print-before=<pass>-print-after=<pass>,输出是 MIR;
  • 只跑一个 pass:-run-pass=<pass>,比如 -run-pass=riscv-insert-vsetvli 单独调 VSETVLI 插入;
  • 看 SelectionDAG 的中间形态:-debug-only=isel,或者 -view-dag-combine1-dags 一类 -view-* 选项——两者都要在开启断言的构建里(那批 view-*-dags 选项整段写在 SelectionDAGISel.cpp:147#ifndef NDEBUG 里,release 构建下 llc 直接报 “Unknown command line argument”);
  • 校验机器指令的合法性:NewPM 路径在流水线末尾默认跑 MachineVerifier(CodeGenPassBuilder.cpp:274),legacy 路径要显式加 -verify-machineinstrsTargetPassConfig.cpp:814addVerifyPassEXPENSIVE_CHECKS 构建下 legacy 也会默认开)。它检查的不是语义正确,而是形态正确:虚拟寄存器是否都有类、操作数是否满足寄存器类与操作数约束、活跃区间与 spill slot 活性是否自洽、φ 节点与 CFG 是否一致(栈帧编号的消除由 PEI(Prologue/Epilogue Insertion,序言/跋插入 pass,在函数入口出口生成保存恢复代码,并把 FrameIndex 落成“基址 + 偏移”)与 AsmPrinter 保证,“操作数与调度模型是否匹配”属于另一套 -verify-misched,都不在 MachineVerifier 里);
  • 看机器码的调度与吞吐:llvm-mca -mtriple=riscv64 -mcpu=...,它用同一套调度模型给出一段汇编的流水线占用。

把这几条与本文的章节对应起来:-stop-after=finalize-isel 对应指令选择一节的输出,-stop-after=greedy 对应寄存器分配一节的输出,-stop-after=riscv-insert-vsetvli 对应 RVV 一节,-filetype=asm/-filetype=obj 对应从机器指令到汇编与目标文件一节。

总结 · 每一步定下的选择都不能再改

全文的结论可以写成几句话。后端的全部工作是给 IR 里目标无关的选择补上目标相关的答案;答案分四层,从上到下依次是机器配置、指令、寄存器、编码。四层单向依赖:机器配置决定能选哪些指令,指令决定能选哪些寄存器类,寄存器分配的结果决定后面还能做哪些指令级优化。每层的输出都是下一层的输入;某层定完之后,后面不再重开这层的选择,只允许做尊重已有选择的局部改写(压缩指令、访存配对、分支松弛,以及链接器那一侧的收缩)——这就是后端流水线顺序不能随意调换的原因,也是伪指令、MIR 这些中间表示存在的意义:在信息还不全的时候先不把选择做完。

RISC-V 的个性在于可选做法受编码约束更紧:基础指令固定 32 位、立即数与跳转范围小、地址要两三条指令、压缩指令另有一套范围、向量长度在编译期未知、链接器还能反过来改指令。这些特点让后端多了一批专属 pass,但并没有改变上面那四层结构;所有的专属 pass 都在某层的内部做优化,不跨层改变已经定下的东西。

As we walk side by side,
当我们并肩前行,
The script is fading away,
既定的命运已然淡去,
And the frozen dawn streams forth,
冰封的黎明破晓,
The weight of yesterday,
那些过去的幻影,
Will never hold me again,
再也困不住我,
Cause when the tides turn and sway,
因为前路迷茫时,
We’ll know our way,
我们会找到方向,
Don’t need to be afraid,
不必害怕,
Set sail and row away,
尽管向前走吧,
Never felt so safe,
从未如此安心过,
With you, I can step into the new,
有你,我就能走进崭新的篇章。

—— Innocence · 塞壬唱片-MSR/ユリカリパブリック/松林凜

Reference

源码基线

  • llvm-project@026e3f3c:RISC-V 后端在 llvm/lib/Target/RISCV/,通用代码生成在 llvm/lib/CodeGen/llvm/lib/Passes/,MC 层在 llvm/lib/MC/,链接器那一侧在 lld/ELF/Arch/RISCV.cpp,driver 的默认开关在 clang/lib/Driver/ToolChains/Arch/RISCV.cpp;文中每一处 file:line 都指向该 commit。
  • 流水线两条路:legacy(默认)NewPMllc -enable-new-pm=1);通用阶段在 CodeGenPassBuilder.cpp

官方文档

论文

本文引到的 RISC-V 源码入口(按章节)

章节 文件
机器配置 RISCVFeatures.tdRISCVSubtarget.cppRISCVISAInfo.cppRISCVRegisterInfo.tdRISCVMatInt.cppRISCVInstrInfoZc.td
指令选择 SelectionDAGISel.cppLegalizeTypes.cppLegalizeDAG.cppRISCVISelLowering.cppRISCVISelDAGToDAG.cppRISCVInstrInfo.tdRISCVInstrInfoA.tdRISCVInstrInfoC.tdRISCVInstrInfoZilsd.tdTargetLoweringBase.cppLegalizeIntegerTypes.cpp
调用约定与栈帧 RISCVCallingConv.cppRISCVCallingConv.tdRISCVFrameLowering.cpp
MIR 与寄存器分配 TargetPassConfig.cppCodeGenPassBuilder.cppRISCVOptWInstrs.cppRISCVExpandPseudo*.cpp
RVV RISCVInstrInfoVPseudos.tdRISCVInsertVSETVLI.cppRISCVVMV0Elimination.cpp
MC 层 RISCVAsmPrinter.cppRISCVMCCodeEmitter.cppRISCVAsmBackend.cppRISCVELFObjectWriter.cpp

其他