:從匯編器到QEMU)
RISC-V這幾年最吸引我的一點就是“加指令”這件事從芯片廠商手里真正交到了每一個做處理器或加速器的團隊手里。你可以在自己擅長的算法領(lǐng)域定義一條專屬指令把三條普通指令才能完成的運算壓進一條省下功耗和流水線開銷。但幾乎所有剛接觸這一塊的人都會撞上同一個尷尬RTL里解碼邏輯寫好了匯編器卻不認(rèn)識你的新指令C編譯器也不知道怎么生成它objdump把它反匯編成一段神秘數(shù)字最后連這條指令到底跑沒跑對都說不清。這里面的隱形門檻就是RISC-V自定義擴展的工具鏈適配。這篇文章我想完整記錄一次RISC-V自定義擴展工具鏈適配實戰(zhàn)從指令編碼設(shè)計開始到GNU匯編器/反匯編器怎么認(rèn)出新指令再到GCC如何暴露給C/C最后通過QEMU模擬器讓新指令真正跑起來并得到正確結(jié)果。整個過程會用到實際的源碼位置、構(gòu)建命令和可復(fù)現(xiàn)代碼適合正在做自主CPU、DSA加速器或者想落地自定義指令的軟硬件團隊也適合想搞明白RISC-V工具鏈內(nèi)部機制的純軟件同學(xué)。我會把每一步為什么要這么做也講清楚不只是給一份能用的操作手冊。1. 從指令設(shè)計到工具鏈適配的整體思路1.1 難點在RTL之外工具鏈?zhǔn)且徽麠l流水線很多人誤以為“自定義一條指令”最難的部分是處理器前端解碼和執(zhí)行邏輯畢竟RTL里每條指令的判定、取值、回寫都得自己寫。但從我實際做過幾個擴展指令集的經(jīng)驗看RTL只是執(zhí)行端真正讓人頭疼的是“讓整條軟件工具鏈都認(rèn)識這個指令”。工程師日常寫C/C編譯器先把它變成匯編文本匯編器再把匯編變成機器碼鏈接器把機器碼布局進elf反匯編器又要能從機器碼還原成可讀的匯編模擬器或處理器則負(fù)責(zé)執(zhí)行這些機器碼。任何一個環(huán)節(jié)不認(rèn)識你的自定義指令整條鏈路就斷在當(dāng)前環(huán)節(jié)。最常見的是GCC已經(jīng)把C函數(shù)翻譯成了類似cx_add a2, a0, a1的匯編行結(jié)果GASGNU Assembler報unrecognized opcode編譯直接失敗或者匯編器勉強能編但objdump反匯編時把指令解碼成.word 0x0000000b之類的裸數(shù)字調(diào)試體驗急轉(zhuǎn)直下。所以工具鏈適配本質(zhì)上不是改一個文件而是要打通匯編器、反匯編器、編譯器后端外加一個能執(zhí)行這條指令的模擬器或硬件模型。1.2 適配層次從匯編級到編譯器自動生成我習(xí)慣把自定義擴展的工具鏈適配分成三個遞進的層次具體做到哪一層取決于你的項目階段和應(yīng)用需求。第一層是“匯編級支持”只改binutils讓GAS和objdump認(rèn)識新指令。工程師可以在.S匯編文件里直接寫新指令但C代碼里需要用內(nèi)聯(lián)匯編片段嵌入。這是最省力的方案適合指令還在驗證階段、語義可能頻繁調(diào)整的時候。第二層是“編譯器內(nèi)建函數(shù)builtin支持”在GCC后端增加內(nèi)建函數(shù)比如__builtin_riscv_cx_add(a, b)。C/C工程師不需要關(guān)心寄存器約束怎么寫把自定義指令當(dāng)作一個普通函數(shù)調(diào)用即可。這一層開始涉及機器描述、內(nèi)建函數(shù)簽名注冊改動開始變多但使用體驗好不少。第三層是“編譯器自動生成”讓GCC在優(yōu)化過程中識別出特定代碼模式自己決定要不要替換成自定義指令。比如看到a b且兩個操作數(shù)都是從寄存器讀的就自動生成你的加乘融合指令。這個方向涉及模式匹配、指令成本模型復(fù)雜度最高一般放到指令集穩(wěn)定之后再做。我的建議很明確第一次做自定義擴展先把前兩層跑通用QEMU或者RTL驗證指令語義等確認(rèn)編碼和功能都沒問題再考慮要不要做第三層。一上來就撲到自動生成上多半會被GCC的機器描述細(xì)節(jié)拖死。2. 指令編碼方案設(shè)計從自定義操作碼空間開始2.1 RISC-V操作碼空間里自定義指令該放在哪在設(shè)計指令語義之前先把編碼格式定下來。RISC-V指令集有一個很大好處它顯式預(yù)留了四個自定義操作碼區(qū)域分別叫CUSTOM-0、CUSTOM-1、CUSTOM-2、CUSTOM-3對應(yīng)inst[6:0]為0001011、0101011、1011011、1111011。標(biāo)準(zhǔn)指令肯定不會落進這四個區(qū)域所以你把自定義指令放在這里將來標(biāo)準(zhǔn)指令集再怎么演進也不用擔(dān)心跟別人撞車。選擇哪個區(qū)域通常是任意的但我一般會把“寄存器-寄存器類運算”和“寄存器-立即數(shù)類運算”分散到不同區(qū)域或者用funct3再細(xì)分。這樣解碼器一眼就能看出指令大類不需要靠funct7做很深的二次判斷RTL解碼更簡單時序上也有余量。另一個考慮是盡量讓自定義指令沿用標(biāo)準(zhǔn)R型和I型指令的字段布局比如rd占inst[11:7]、rs1占inst[19:15]、rs2占inst[24:20]。原因不只是符合習(xí)慣更重要的是GNU工具鏈的許多操作數(shù)解析和寄存器編號邏輯都已經(jīng)按這些字段位置寫好你順?biāo)浦劬湍軓?fù)用改動量會小很多。2.2 示例擴展CX的編碼定義與MATCH/MASK理解為了后面演示具體改動我定義一個很小的示例擴展叫CX擴展包含三條指令指令格式語義編碼要點cx_add rd, rs1, rs2R型rd rs1 rs2opcodeCUSTOM-0, funct3000, funct70000000cx_clz rd, rs1R型rd 計算rs1前導(dǎo)零個數(shù)opcodeCUSTOM-0, funct3001, funct70000000cx_addi rd, rs1, immI型rd rs1 符號擴展immopcodeCUSTOM-0, funct3000三條都放在CUSTOM-0區(qū)域用funct3區(qū)分不同功能。cx_add和cx_addi的語義與標(biāo)準(zhǔn)ADD/ADDI一致但會被編碼成完全不同的機器碼不會讓流水線里的標(biāo)準(zhǔn)加法單元和自定義邏輯混淆。接下來理解一個關(guān)鍵概念工具鏈內(nèi)部描述一個指令時離不開MATCH和MASK兩個宏??梢园阉斫獬伞鞍矙z對照單”MATCH是指令編碼中必須匹配的固定位MASK則標(biāo)記哪些位參與匹配。舉例來說R型指令的opcode固定、funct3固定、funct7固定但rd、rs1、rs2三個字段隨指令變化所以在匹配時要把它們屏蔽掉。標(biāo)準(zhǔn)MASK_R_TYPE大約是0xfe00707f它讓opcode、funct3、funct7參與比較而寄存器字段不參與。MATCH_CX_ADD要設(shè)計成把opcode固定為0001011、funct3固定為000、funct7固定為0000000其余字段為零。用這些宏匯編器在解析cx_add時能生成正確的編碼反匯編器也能根據(jù)機器碼反向查到對應(yīng)的指令助記符。3. binutils適配讓匯編器和反匯編器認(rèn)出新指令3.1 改哪幾個文件riscv-opc.h與riscv-opc.cGNU工具鏈里RISC-V的匯編器和反匯編器共用同一張指令表這個表位于opcodes/riscv-opc.c而指令的MATCH和MASK宏一般定義在include/opcode/riscv.h有時也在riscv-opc.h里。所以適配的第一步是去這兩處登記新指令。在include/opcode/riscv.h中仿照現(xiàn)有指令添加宏定義#define MATCH_CX_ADD 0x0000000b #define MASK_CX_ADD 0xfe00707f #define MATCH_CX_CLZ 0x0200000b #define MASK_CX_CLZ 0xfe00707f然后在opcodes/riscv-opc.c的指令表數(shù)組中加入對應(yīng)的表項。表項里的字段比較多核心的是助記符、操作數(shù)字符串、match/mask和指令屬性{cx_add, D,s,t, d,s,t, MATCH_CX_ADD, MASK_CX_ADD, 0, 0, 0, 0}, {cx_clz, D,s, d,s, MATCH_CX_CLZ, MASK_CX_CLZ, 0, 0, 0, 0},這里的d表示目標(biāo)寄存器rds表示源寄存器rs1t表示源寄存器rs2。不同版本的binutils可能字段順序略有差別建議直接打開你當(dāng)前源碼里的riscv-opc.c對照現(xiàn)有ADD指令的寫法抄格式。特別提醒一點指令助記符命名盡量避開標(biāo)準(zhǔn)指令和常見偽指令比如不要用move、not這類名字免得匯編器在歧義處理時產(chǎn)生不可預(yù)期的行為。3.2 重建工具鏈并驗證匯編/反匯編結(jié)果修改完成后需要重新構(gòu)建binutils。如果你用的是riscv-gnu-toolchain整套源碼樹可以只重建binutils子目錄不需要每次都完整編譯整個交叉編譯器cd riscv-gnu-toolchain/binutils mkdir -p build cd build ../configure --targetriscv64-unknown-elf --prefix/opt/riscv-cx make -j$(nproc) make install我代碼里特意用了新路徑/opt/riscv-cx防止污染系統(tǒng)里已有工具鏈。接下來寫一段最小的匯編測試.text .globl _start _start: li a0, 0x1234 li a1, 0x5678 cx_add a2, a0, a1 cx_clz a3, a0 ebreak然后手動調(diào)用新匯編器和反匯編器/opt/riscv-cx/bin/riscv64-unknown-elf-as -marchrv64gc test_cx.s -o test_cx.o /opt/riscv-cx/bin/riscv64-unknown-elf-objdump -d test_cx.o正常的輸出里應(yīng)該能在對應(yīng)偏移位置看到cx_add a2, a0, a1和cx_clz a3, a0而不是.word 0x...。如果看到Error: unrecognized opcode cx_add先檢查你調(diào)用的as是不是新安裝的版本which as經(jīng)常會把舊路徑頂?shù)角懊嫫浯螜z查指令表是否真的插進了riscv_opcodes數(shù)組而不是誤寫進了別的數(shù)組。3.3 先用手寫機器碼驗證CPU.insn偽指令的小技巧這里要分享一個我很早以前就養(yǎng)成的工作習(xí)慣在還沒有改binutils或者剛改完但環(huán)境還沒就緒的時候不需要干等可以直接用GAS的.insn偽指令手動編碼自定義指令先驗證處理器或模擬器是否認(rèn)識這條指令。比如我定義的cx_add a2, a0, a1對應(yīng)R型編碼opcode0001011、funct3000、funct70000000可以用如下偽指令表示.insn r 0x0b, 0, 0, a2, a0, a1.insn的格式按“模板 opcode funct3 funct7 寄存器操作數(shù)”展開。用這種方式可以在不依賴任何自定義工具鏈改動的情況下生成一條準(zhǔn)確的機器碼非常適合拿去和RTL仿真比對、和設(shè)計文檔逐位核對。我常說先把這條.insn生成的編碼和設(shè)計文檔對上再去改工具鏈能把bug范圍縮小一大半。4. GCC編譯適配從內(nèi)聯(lián)匯編到內(nèi)建函數(shù)4.1 最快速方案內(nèi)聯(lián)匯編直接驅(qū)動自定義指令如果現(xiàn)在只有binutils改好了而GCC還沒有做任何適配C代碼仍然可以馬上用起來辦法就是內(nèi)聯(lián)匯編。這是實踐中讓自定義指令“先跑起來”的最短路徑。static inline long cx_add(long a, long b) { long out; asm volatile(cx_add %0, %1, %2 : r(out) : r(a), r(b)); return out; } long test(long a, long b) { return cx_add(a, b); }用修改后的GCC編譯這段代碼關(guān)鍵點是約束字符串r和r它告訴GCC把變量分配在通用寄存器里。asm volatile是必須的因為cx_add在編譯器看來語義未知如果沒有volatile優(yōu)化器可能認(rèn)為這段內(nèi)聯(lián)匯編沒有副作用在-O2下直接把它刪掉。把這段C代碼編譯成目標(biāo)文件后用新objdump看riscv64-unknown-elf-gcc -O2 -marchrv64gc -c test_cx.c -o test_cx.o riscv64-unknown-elf-objdump -d test_cx.o如果一切正常能看到test函數(shù)的函數(shù)體里出現(xiàn)了cx_add指令。這個方案的缺點是GCC不理解這條指令的語義既不能做常量傳播也不能優(yōu)化掉冗余計算指令里的立即數(shù)也不能直接用C變量靈活拼接。所以它只適合早期驗證不適合產(chǎn)品代碼。4.2 工程化方案GCC內(nèi)建函數(shù)與RTL模式要讓自定義指令在C代碼里獲得比較自然的調(diào)用體驗需要在GCC后端增加內(nèi)建函數(shù)和機器描述規(guī)則。入口文件主要是gcc/config/riscv/riscv.md和gcc/config/riscv/riscv-builtins.cc不同GCC版本文件結(jié)構(gòu)有差別13.x是這個名字。在riscv.md里新增一個模式使用unspec來告訴編譯器“這是一個有特殊語義的操作不要隨意刪除和重排”(define_insn cx_addmode [(set (match_operand:X 0 register_operand r) (unspec:X [(match_operand:X 1 register_operand r) (match_operand:X 2 register_operand r)] UNSPEC_CX_ADD))] cx_add\t%0,%1,%2)mode表示同時匹配SI和DI模式也就是32位和64位場景。隨后在內(nèi)建函數(shù)注冊表中增加對應(yīng)的定義把它暴露成類似__builtin_riscv_cx_add的接口。這個過程需要注意riscv.md里的模式名、unspec枚舉值、內(nèi)建函數(shù)映射宏必須一一對應(yīng)否則編譯器會報“internal consistency check”這類讓人摸不著頭腦的錯誤。重新編譯GCC后C代碼可以這么寫long test_cx(long a, long b) { return __builtin_riscv_cx_add(a, b); }這樣編譯器會自己完成寄存器的分配和指令的發(fā)射不再需要C程序員關(guān)心內(nèi)聯(lián)匯編的約束細(xì)節(jié)。相比內(nèi)聯(lián)匯編內(nèi)建函數(shù)方案更適合做庫函數(shù)也更容易被后續(xù)的向量化或者指令融合優(yōu)化復(fù)用。4.3 要不要讓編譯器自動生成自定義指令第三個層次是讓GCC自動識別代碼模式并生成自定義指令這是最誘人但也是最容易陷進去的部分。要讓編譯器“看到a b就自動用cx_add”不是簡單改一條define_insn就行的。GCC有自己的一套模式識別機制你必須把自定義指令描述成具有明確語義的RTL表達式比如(plus:SI (match_operand...) (match_operand...))同時設(shè)定好成本模型讓GCC在指令選擇階段認(rèn)為它比標(biāo)準(zhǔn)加法更優(yōu)。這里有一個業(yè)內(nèi)常見的坑如果把自定義指令描述成純粹的加法表達式GCC在很多優(yōu)化pass里會把它識別成和普通add等價的節(jié)點然后出于合并、復(fù)制傳播等原因把你的指令替換回普通加法。結(jié)果就是編譯器死活不生成你的自定義指令或者只在極其特殊的條件下生成。所以大多數(shù)團隊長期停留在“內(nèi)建函數(shù)”這一層只有指令語義復(fù)雜到標(biāo)準(zhǔn)指令怎么都覆蓋不了或者性能敏感度極高的時候才值得投入人力做自動生成。我的判斷標(biāo)準(zhǔn)很簡單先用內(nèi)建函數(shù)拿到收益再考慮自動生成。5. 讓新指令真正跑起來從二進制到模擬器驗證5.1 寫一個最小測試程序并交叉編譯工具鏈改完了編譯器也能生成自定義指令了接下來要解決“真正跑起來”的問題。我建議寫一個非常小的裸機測試程序不打任何操作系統(tǒng)避免底層庫干擾long cx_add(long a, long b); void _start(void) { volatile long x 0x1234; volatile long y 0x5678; volatile long z; z cx_add(x, y); if (z ! (0x1234 0x5678)) while (1); }然后交叉編譯、鏈接riscv64-unknown-elf-gcc -O2 -marchrv64gc -nostdlib -Wl,-Ttext0x80000000 test_run.c -o test_run.elf riscv64-unknown-elf-objcopy -O binary test_run.elf test_run.bin如果直接把test_run.elf丟給QEMU跑大概率得到一個illegal instruction異常。原因很簡單QEMU的RISC-V模擬器解碼到CUSTOM-0區(qū)域時發(fā)現(xiàn)這不是它認(rèn)識的指令直接按非法指令處理。這時候有兩條路改QEMU模擬器或者繞過模擬器直接上RTL仿真/FPGA。5.2 QEMU適配給模擬器補上指令語義QEMU是一個非常好的指令語義驗證平臺改它的成本通常遠低于跑一次RTL綜合仿真。需要改兩個地方指令解碼表target/riscv/insn32.decode和翻譯執(zhí)行函數(shù)target/riscv/translate.c。在insn32.decode里按現(xiàn)有R型指令的格式添加cx_add 0000000 ..... ..... 000 ..... 0001011 r cx_clz 0000001 ..... ..... 000 ..... 0001011 r然后在translate.c中實現(xiàn)對應(yīng)的翻譯函數(shù)static bool trans_cx_add(DisasContext *ctx, arg_cx_add *a) { TCGv dest tcg_temp_new(); tcg_gen_add_tl(dest, cpu_gpr[a-rs1], cpu_gpr[a-rs2]); gen_set_gpr(ctx, a-rd, dest); return true; } static bool trans_cx_clz(DisasContext *ctx, arg_cx_clz *a) { TCGv dest tcg_temp_new(); tcg_gen_clzi_tl(dest, cpu_gpr[a-rs1], TARGET_LONG_BITS); gen_set_gpr(ctx, a-rd, dest); return true; }這里tcg_gen_add_tl對應(yīng)加法語義tcg_gen_clzi_tl對應(yīng)前導(dǎo)零計數(shù)語義。改完之后重新編譯QEMU再用前面的test_run.elf運行。如果在測試程序里加入一個輸出串口、或者用QEMU的gdbstub斷點查看寄存器值能確認(rèn)cx_add的結(jié)果等于真實加法結(jié)果。我特別建議在QEMU適配階段寫一個“黃金參考模型”用普通C函數(shù)計算同樣的結(jié)果模擬器執(zhí)行完自定義指令之后對比寄存器值是否和黃金參考一致。這樣你驗證的不只是“不崩潰”而是“真正算對了”。5.3 多引擎對照RTL仿真/FPGA驗證的銜接等你確認(rèn)了工具鏈、QEMU都沒問題接下來才是RTL仿真和FPGA驗證。這一步的關(guān)鍵是保證編碼表一稿統(tǒng)一。我見過太多案例工具鏈和RTL各用一版編碼兩邊單獨測都沒問題但對不上號。建議在項目初期就建立一張編碼對照表至少包含助記符、opcode、funct3、funct7、rd/rs1/rs2的bit區(qū)間、QEMU解碼字段、GNU binutils的MATCH/MASK值。這張表必須是所有軟件和硬件團隊共同維護的唯一事實來源。然后在RTL驗證的testbench里直接用工具鏈生成的可執(zhí)行二進制作為激勵而不是手寫測試向量。這樣才能從源頭保證“軟件編譯出來的指令”和“硬件執(zhí)行到的指令”完全一致。6. 常見問題與排查技巧實錄6.1 匯編期問題速查現(xiàn)象可能原因排查/解決Error: unrecognized opcode cx_add調(diào)用的as不是新安裝的版本或者PATH順序不對which as直接用/opt/riscv-cx/bin/riscv64-unknown-elf-as的絕對路徑重試unknown architectural extension指令表項的subset字段指向了未知擴展名將指令表項的擴展屬性置空或改到已有擴展上bad instruction、操作數(shù)不合法操作數(shù)字符串中的字段類型寫錯比如把t寫成了立即數(shù)字段對照現(xiàn)有R型指令仔細(xì)檢查riscv-opc.c里的args寫法objdump仍顯示.word 0x...MASK沒把變化的寄存器字段屏蔽掉確認(rèn)MASK是否等于0xfe00707f只保留opcode/funct3/funct7參與匹配6.2 編譯期與運行期問題速查現(xiàn)象可能原因排查/解決GCC輸出unrecognized opcode但GAS能識別GCC生成了自定義匯編文本但調(diào)用的匯編器是舊版本重新構(gòu)建GCC并確認(rèn)新的as在PATH中inconsistent operand constraints in asm內(nèi)聯(lián)匯編約束字符串不匹配確保輸出用r輸入用r不要使用內(nèi)存約束-O2下自定義指令消失內(nèi)聯(lián)匯編被當(dāng)成無副作用的純計算刪除使用asm volatile或者改用GCC內(nèi)建函數(shù)QEMU運行報illegal instructionQEMU還沒有適配該指令的翻譯函數(shù)在insn32.decode和translate.c中補齊實現(xiàn)模擬器計算結(jié)果不對自定義指令的編碼或語義與設(shè)計不一致使用.insn偽指令固定編碼逐bit比對并用黃金參考模型做對比6.3 一條讓我返工多次的經(jīng)驗教訓(xùn)最后說一個讓我印象最深的坑。有一次我在做自定義擴展RTL、匯編器、QEMU三端都改完了各自看起來也正常但聯(lián)調(diào)時數(shù)值總是差一位。查了將近一天最后發(fā)現(xiàn)是funct7的位段寫錯了位置我把inst[31:25]的字段誤放到了inst[14:12]的位置。偏偏那段指令設(shè)計文檔里畫表格時不夠醒目RTL工程師和工具鏈工程師各自照著文檔實現(xiàn)于是三端按同一個錯誤文檔各自“正確”運行直到聯(lián)調(diào)才暴露。從那次以后我給自己立了一條規(guī)矩任何自定義擴展的第一步都先用手寫.insn偽指令固定編碼對著設(shè)計文檔逐位核對拿模擬器或FPGA跑通最簡單的加法指令之后才允許任何人動工具鏈。這個習(xí)慣聽著笨但確實幫我避開了很多因為編碼位段理解不一致導(dǎo)致的連環(huán)返工。工具鏈適配本身不難難的是讓所有端都在同一個編碼版本上工作。這一點比學(xué)會改文件、跑構(gòu)建命令要重要得多。