
1. 先弄明白一件事自定义指令要从源码跑到CPU要过五道关卡我最近一个项目需要在RISC-V核上做信号处理加速标准ISA里翻遍了都找不到一条合适的乘累加指令于是决定走自定义扩展这条路。刚开始我以为工作量重心在RTL编写上结果真正把时间耗干的是工具链适配。原因很简单你写的一行汇编或者一句C代码中间要经过编译、汇编、链接、加载执行这么多层每一层都有自己维护的指令信息只要有一层不认识这条新指令整个流程就卡死指令写得再好也跑不起来。1.1 工具链里每一层都有自己的指令表以我这篇文章要讲的自定义指令为例它的功能是mac32 rd, rs1, rs2语义是rd rd rs1 * rs2。这条指令从源代码变成CPU实际执行的语义至少要穿过五道关卡GCC编译阶段C代码里的内建函数builtin要能展开成GCC内部的RTL表示再匹配到新指令模板生成汇编文本里的mac32助记符。GAS汇编阶段汇编器要认识mac32并且能把它编码成正确的32位机器码。链接阶段纯寄存器指令基本不涉及重定位但指令一旦带立即数或者标签链接器也得跟得上编码格式。反汇编和调试阶段objdump、gdb要把这32位机器码反解回mac32否则你根本没法debug看汇编全是一堆.word。模拟器和真机执行阶段Spike、QEMU或者RTL仿真器要真正解码并执行这条指令把结果算出来。这里经常有人搞反顺序先去改GCC。实际操作中一定要先做第2阶段也就是binutils适配。原因很直接GCC生成的汇编最终还是要交给GAS处理GAS不认这条指令的话编译器层面做了再多工作都白搭。所以正确顺序永远是binutils先行GCC跟进模拟器最后验证。1.2 RISC-V自定义扩展的天然优势预留编码空间与开放扩展机制RISC-V指令集设计里专门留了四个Custom扩展操作码区域这一点对做自定义指令的人来说太友好了等于官方把一块地圈好了给你种不用去抢标准指令的地盘主操作码名称0x0BCUSTOM-00x2BCUSTOM-10x5BCUSTOM-20x7BCUSTOM-3这四个区域都遵循标准R-type或I-type的字段布局低7位是主操作码中间是rd、rs1、rs2、funct3高位是funct7。你可以把扩展挂到-march字符串上用自定义扩展名去标识整个流程都是标准化的做完了也不会跟后续标准扩展冲突。1.3 正式开工前的快速验证用.insn伪指令试水在动手改任何工具链源码之前我强烈建议先用GAS自带的.insn伪指令验证一下编码方案是否可行。比如我要验证的这条指令可以直接写.insn r 0x2b, 0, 0x41, a0, a1, a2这句话的意思是这是一条R-type指令主操作码0x2bfunct3为0funct7为0x41目标寄存器a0两个源操作数分别是a1、a2。汇编器会把它编码成一个合法的32位指令字你可以拿这段汇编去跑仿真或者看生成的机器码。.insn的局限在于反汇编器和C编译器依然不认知所以它只能用来做早期冒烟验证真正要落地还是得走完整的工具链适配流程。2. 动手前把编码规划做扎实以一条MAC32指令为例这一节是整个项目的地基。编码方案一旦定下来后面的binutils和GCC改动全都要围绕它展开。我的建议是花一天时间把编码表格和掩码算清楚省得后面改源码改到一半发现编码冲突那不是几行代码的事是整个流程要重来。2.1 选择CUSTOM-1编码空间并定义字段我选择了CUSTOM-1主操作码0x2B作为这条指令的归属空间。为什么选0x2B而不选0x0B纯属个人偏好CUSTOM-0通常被很多现有SoC案例占用了我习惯把第一个扩展留给CUSTOM-1方便以后扩展第二、第三条指令时在0x0B或者0x5B上分开管理。指令格式定义如下字段位段值说明funct731:250x41自定义功能码用于区分同空间下的不同指令rs224:20任意第二个源寄存器rs119:15任意第一个源寄存器funct314:120x0扩展功能选择rd11:7任意目标寄存器同时是累加输入opcode6:00x2BCUSTOM-1设计成rd同时承担输入累加值和输出结果也就是破坏性累加这是经过考虑的。标准R-type只有三个寄存器字段如果要做非破坏性的四操作数版本寄存器放不下。现实中DSP类的MAC指令基本都是累加器语义破坏性累加完全够用而且编译器适配时约束也更好写。2.2 MATCH与MASK的计算这一步错了后面全白做binutils和GCC识别一条新指令靠的是两个宏MATCH_MAC32和MASK_MAC32。前者是这条指令固定字段拼出来的期望值后者是把所有固定字段的位都置1、可变字段的位都置0用来判断实际指令字中哪些位需要参与比对。计算过程如下// MATCH固定字段按位拼装 // funct7 0x41左移25位 // funct3 0x0左移12位 // opcode 0x2B占用低7位 #define MATCH_MAC32 0x8200002B // MASK固定字段全部置1 // funct7占[31:25]掩码是0x7F 25 0xFE000000 // funct3占[14:12]掩码是0x7 12 0x00007000 // opcode占[6:0]掩码是0x7F 0x0000007F #define MASK_MAC32 0xFE00707F验证一下mac32 a0, a1, a2对应的机器码应该是多少算一下0x82000000 // funct7 0x41 | 0x00C00000 // rs2 a2 x12 | 0x00058000 // rs1 a1 x11 | 0x00000000 // funct3 0 | 0x00000500 // rd a0 x10 | 0x0000002B // opcode 0x2B 0x82C5852B这个数在后面的所有测试里都会反复用到。如果MATCH和MASK算错最典型的症状就是汇编器报unrecognized opcode或者反汇编器把其他无关指令错误识别成mac32。所以这一步值得多做几组手算验证拿不同的寄存器组合算几遍。2.3 操作数格式设计如何影响后续适配工作量确定指令操作数时我给binutils指令表设计的操作数字符串是d,s,t这三个字母在RISC-V的GAS里有约定含义d表示目标寄存器rds表示第一个源寄存器rs1t表示第二个源寄存器rs2。这样写的好处是GAS的通用解析器能直接按标准R-type格式去匹配寄存器操作数我完全不用改动汇编器的操作数解析流程。如果后面要加带立即数的指令比如移位立即数版本操作数字符串就要加O或者j这类立即数字母同时要关注立即数的编码方式是符号扩展还是零扩展这又会牵扯到MASK的覆盖范围。所以设计指令时操作数越贴近标准格式工具链适配成本越低。这是很多第一次做自定义扩展的人没意识到的RTL里怎么方便怎么来工具链这里编码越规整越好。3. binutils适配让汇编器编得出、反汇编器读得懂binutils适配的目标就两个GAS能编码它objdump能解码它。对应的改动集中在两个文件include/opcode/riscv.h和opcodes/riscv-opc.c。整个过程不复杂但一步都不能跳。3.1 在riscv.h中登记指令的MATCH与MASK第一步是在include/opcode/riscv.h里加上之前算好的两个宏通常放在其他自定义指令宏定义附近找一段显式#define MATCH_*和#define MASK_*的区域跟着加就行了#define MATCH_MAC32 0x8200002B #define MASK_MAC32 0xFE00707F这个头文件是所有binutils模块共用的指令定义源头riscv-opc.c会引用它。有些新版本binutils里指令描述会有自动生成的辅助函数但自定义指令目前还是要手工维护宏定义。3.2 在riscv-opc.c指令表中挂上新条目第二步是打开opcodes/riscv-opc.c在 riscv_opcodes 这个数组里添加一条描述{mac32, 0, INSN_CLASS_I, d,s,t, MATCH_MAC32, MASK_MAC32, match_opcode, 0},逐个字段解释一下。第一个字符串是助记符汇编和反汇编都用它显示。第二个0是指令长度相关的标志32位基础指令填0即可。第三个INSN_CLASS_I是这条指令所属的ISA扩展类别后面单开一节讲为什么我先用了这个而不是自定义类别。第四个d,s,t就是操作数格式告诉GAS这条指令该怎么解析和打印。最后跟的是MATCH、MASK、匹配函数和扩展属性。对于只需要纯寄存器操作数的指令到这里GAS部分就结束了不用碰gas/config/tc-riscv.c。因为d,s,t这种标准操作数格式已经被GAS的通用解析逻辑覆盖了它还自己处理了寄存器名校验、非法寄存器组合检查这些事情。3.3 重建binutils并验证汇编反汇编闭环配置过binutils源码树的话只需要重编相关组件cd build-binutils make all-gas make all-ld make all-objdump make install我习惯单独编gas和objdump因为这次不动ld和gdb没必要全量编译。安装完之后写一个最小的测试汇编文件.globl _start _start: mac32 a0, a1, a2 mac32 t0, t1, t2 mac32 s0, s1, s2用riscv64-unknown-elf-as汇编它然后看生成的机器码riscv64-unknown-elf-as -o test.o test.s riscv64-unknown-elf-objdump -d test.o预期输出里应该能看到三条mac32指令被正确反汇编出来第一条的机器码就是之前手算过的82c5852b。这一环通了binutils的适配就完成了最重要的一半。如果汇编阶段报 unrecognized opcode优先检查riscv.h里的宏定义和riscv-opc.c里操作数格式有没有写错多半是这两个地方的问题。3.4 INSN_CLASS的选择图省事还是讲规范我一开始直接用了INSN_CLASS_I也就是把mac32挂在了基础整数指令集上。这样做有个问题即使你的-march字符串里没有声明任何自定义扩展甚至只写了rv32i汇编器也照样接受mac32。从规范角度讲这是不严谨的但从快速验证角度讲它省去了很多前置工作。更规范的做法是新增一个指令类别。在riscv-opc.c里INSN_CLASS_*枚举和字符串映射表中加一个INSN_CLASS_MAC32同时修改riscv子集匹配逻辑让-marchrv32imac32这样的配置字符串能正确关联。这部分改动会更复杂涉及tc-riscv.c中isa spec的校验逻辑。我的建议是第一版先挂INSN_CLASS_I跑通全流程把精力放在后续的GCC适配和模拟器验证上等整体方案稳定了再回头补严格的ISA类别管理这个次序能让你少走很多弯路。4. GCC集成内建函数与RTL模板的配合binutils跑通之后我们可以在汇编层面用mac32了但C程序还不能直接用。要让C代码里能显式使用这条指令最省事的方式是提供内建函数builtin。至于让编译器自动把a b * c这种表达式匹配成mac32那是更进一步的工作我建议分阶段做。4.1 确定范围第一版只做内建函数不做自动生成自动指令生成涉及GCC的combine和peephole优化逻辑要处理数据依赖、寄存器存活分析、指令成本模型这些事情复杂度陡增。而内建函数只需要在GCC的前端和后端各注册一个入口让C代码里能直接调用编译器负责把它降级成一条mac32指令。这对DSP类项目通常已经够用了毕竟什么时候做乘累加是算法代码决定的程序员显式调用比编译器自动猜测更可控。4.2 在riscv.md中定义指令模板GCC后端用机器描述文件riscv.md来定义指令的RTL模板。我在文件的合适位置加了一个define_insn(define_insn mac32si3 [(set (match_operand:SI 0 register_operand r) (plus:SI (mult:SI (match_operand:SI 1 register_operand r) (match_operand:SI 2 register_operand r)) (match_operand:SI 3 register_operand 0)))] mac32\t%0,%1,%2 [(set_attr type arith)])这里面最关键的是第4个操作数match_operand:SI 3的约束是0它表示操作数3必须和操作数0分配同一个物理寄存器。为什么要这样因为mac32的编码里只有三个寄存器字段rd既当来源又当结果。如果不加这个匹配约束GCC可能会给输入累加值分配一个寄存器、给输出目标又分配另一个寄存器俩寄存器不一样生成的汇编就完全错了甚至可能因为ressource分配不出来而直接报错。4.3 注册内建函数并处理约束问题在riscv-builtins.cc的builtin描述表里添加一条{mac32, RISCV_BUILTIN_DIRECT, NORMAL, CODE_FOR_mac32si3, 0},同时在riscv-ftypes.def里定义它的函数原型RISCV_FTYPE (SI, SI, SI, SI)这里四个SI对应四个整型参数返回值、累加器、rs1、rs2。注册完成后重建GCCC代码里就可以调用了int foo(int acc, int x, int y) { return __builtin_riscv_mac32(acc, x, y); }编译之后用objdump看一下生成的汇编正常情况下会看到mac32 a0, a1, a2对应机器码82c5852b。如果看到的是mul加add两条指令说明模板的匹配约束没生效或者builtin注册方式不对编译器没能把调用对应到mac32模板上。4.4 GCC版本差异带来的注意点GCC的RISC-V后端每个大版本之间builtin的描述方式都不太一样。GCC 10到GCC 13riscv-builtins.cc里的表格格式变过两三次。如果你用的版本和我示例里的对不上别硬抄去打开源码看一眼当前版本的描述结构照葫芦画瓢。这属于正常的源码适配工作不是报错不用慌。5. 让指令真正执行起来Spike模拟器验证全流程到这一步工具链层面的适配已经完成汇编器能编码、反汇编器能解码、C编译器能生成。但指令到底算得对不对还得让它真实执行一遍。我选择Spike来验证没直接用FPGA或者QEMU原因很实际。5.1 为什么选Spike而不是一上来就上FPGA可以做执行验证的路径有几种我列一下对比方案修改难度运行速度调试能力适用阶段Spike低几个.c和.h文件慢但可接受强自带单步和寄存器查看指令语义验证QEMU中要改反汇编和解码两个层面快中配合gdb可以跑操作系统级程序FPGA原型高要重新综合布线极快低内部信号观察麻烦最终流片前验证硬件仿真器高周期级模型慢强性能验证第一版验证阶段我就关心一件事这条指令的语义对不对寄存器算出来的值符不符合预期。Spike改起来最轻而且它的指令执行框架就是为ISA研究人员设计的加一条指令的成本极低。5.2 在Spike中添加自定义指令解码与执行语义Spike的源码里每条指令的执行语义放在riscv/insns/目录下的一个.h文件里。我为mac32创建了riscv/insns/mac32.h内容非常简单{ WRITE_RD(RS1 * RS2 RD); }这里的RD、RS1、RS2对应解码出来的寄存器编号WRITE_RD宏负责把结果写回目标寄存器。然后在riscv/encoding.h里加上和binutils侧相同的宏定义#define MATCH_MAC32 0x8200002B #define MASK_MAC32 0xFE00707F最后要把这条指令注册进Spike的指令名表里。Spike新版本中指令定义通过一个xmacro列表管理在riscv/riscv_insn_list.h或者对应的宏展开文件里加入DECLARE_INSN(mac32, MATCH_MAC32, MASK_MAC32)之类的声明。如果你的Spike版本比较老可能需要把riscv/insns/mac32.h一行路径加进Makefile的insn列表里。具体位置查一下当前版本源码里其他指令是怎么被包含的照着做就行。5.3 最小裸机测试程序与执行结果确认测试程序不需要链接任何库纯汇编写一个最小启动文件就行.global _start _start: li t0, 3 li t1, 5 li t2, 2 mac32 t0, t1, t2 # t0应该等于 3 5 * 2 13 li t3, 13 bne t0, t3, fail li a0, 0 j finish fail: li a0, 1 finish: # 通过tohost接口向Spike传递退出状态 li t0, 0x80001000 slli a0, a0, 1 addi a0, a0, 1 sw a0, 0(t0) 1: j 1b之所以不用ecall退出是因为在Spike的裸机环境下ecall会触发环境调用异常需要额外配置trap handler麻烦。用tohost方式直接告诉Spike仿真结束以及退出码是多少是最干净的做法。编译运行riscv64-unknown-elf-as -o test.o test.S riscv64-unknown-elf-ld -Ttext0x80000000 -o test.elf test.o spike -d test.elf用-d进入调试模式后单步执行几轮再用reg t0查看寄存器值。看到t0 0xd也就是13说明指令语义执行正确。值得一提的是我在Spike调试模式下还特意确认了中间那条mac32的机器码确实是82c5852b寄存器换成t0、t1、t2之后编码不同但结构和预期一致这一步让我确信不只是语义对了编码也完全对得上。QEMU侧我没有在验证阶段正儿八经地改因为它的解码和执行分散在多个文件里改起来比Spike重不少。如果后续需要在带操作系统的环境里跑这条指令再回头补QEMU也不迟Spike验证通过已经能够证明指令语义本身没有问题。6. 踩坑实录适配过程中最消耗时间的四个问题任何工具链适配都不可能一次跑通我这次也不例外。下面这四个问题花了我最多时间有些是低级错误但正是因为低级才更容易被忽略值得单独记录下来。6.1 反汇编结果对不上MASK覆盖了不该覆盖的位第一次改完binutils测试时汇编器已经能接受mac32了但objdump的结果非常诡异明明只写了一条mac32反汇编出来却把下一个地址的指令也识别成了mac32。排查链路从检查指令字开始把目标文件dump成原始字节再跟期望编码一位一位比对最后定位到问题出在MASK上。我一开始写的MASK是0xFE007FFF把低12位全置1了这导致rd字段的低5位被当作固定位参与匹配于是多个不同rd值的编码都被判别为mac32反汇编器自然就串位了。正确的MASK里rd、rs1、rs2这些可变字段的位必须置0。这种错误光看宏定义不一定能发现因为汇编器编码时用的是MATCH去拼装指令字MATCH对了就能编过但反汇编器解码用的是MASK去过滤可变位MASK错了就全乱了。6.2 GCC把乘加拆成了两条指令跑通内建函数之后我编译那个C测试函数打开汇编一看mac32无影无踪取而代之的是一条mul加上一条add。这个问题是我上面说的约束0在捣乱——我最初写的模板里第3个操作数没有匹配约束编译器老实按照普通三操作数指令去处理但是它发现没有可用的寄存器编码位置放第三个输入于是干脆放弃匹配整个模板退回到通用的乘法和加法指令组合方案。排查方法很简单加上-S参数看GCC生成的汇编文本看到muladd就基本确定是模板匹配失败。修复也很直接给第3个操作数加上0约束让GCC知道累加输入和目标输出必须共用寄存器然后重新编译验证mac32就回来了。6.3 链接阶段出现relaxation相关告警在用纯汇编测试文件做链接时遇到过一个关于relaxation的告警。RISC-V的链接器默认开启linker relaxation它会把某些伪指令和寻址方式在链接阶段优化掉。虽然mac32是纯寄存器指令本身不参与relaxation但我测试文件里如果恰好有对标签的引用linker在relaxation过程中碰到无法处理的指令序列时会输出告警。我的处理方式是在测试汇编文件头部显式关闭relaxation.option norelax这纯粹是测试阶段省事的做法。如果你最终要交付的是一个正经扩展relaxation和相关ABI的兼容问题需要系统性解决不能靠关闭功能绕过去。但第一版验证时关掉它节省了我大量排查时间。6.4 在Spike里运行静默失败MATCH宏冲突与注册遗漏Spike侧踩过两个坑。第一个是riscv/encoding.h里的MATCH宏和binutils侧的宏定义冲突——我最初在两个文件里写了值相同的宏但Spike源码中同一个宏被多个头文件间接引用导致重定义编译错误。解决方式是检查所有宏定义的位置把自定义指令的MATCH和MASK统一收敛到一个地方避免重复定义。第二个坑是注册了指令文件但忘记把它加进构建列表结果Spike编译正常运行时遇到mac32直接报非法指令异常没有任何提示信息。这个问题的排查思路就是编译通过不代表运行时能解码Spike的指令解码表是构建期根据指令列表生成的漏掉注册就会出现这种编译没问题、运行就崩的情况。对照已有的指令注册格式把mac32加进指令列表并重新编译之后运行就正常了。整个过程走下来我的体会是自定义指令适配没有什么秘诀就是把每一层的指令信息都补齐每补齐一层就做一个闭环测试千万别想着一步到位。binutils改完测汇编GCC改完测编译Spike改完测执行层层递进出错时才能在最小范围内定位。这次为mac32打通的这套流程后面再加新指令时基本就是复制粘贴的活工具链这条路走通一次后面就通了。