展指令工具鏈適配全流程詳解)
做RISC-V的團(tuán)隊遲早會碰到這么一件事自己定了一條新指令硬件仿真已經(jīng)跑通了軟件這邊卻不知道怎么讓這條指令真正被執(zhí)行起來。嚴(yán)格說任何指令集落地都繞不開工具鏈適配而RISC-V因為允許自定義擴(kuò)展指令這個問題幾乎每個做CPU、做SoC、做算法加速的工程師都會遇到。這篇文章就以一條自定義擴(kuò)展指令為例把從指令編碼設(shè)計、binutils修改、GCC接入到QEMU模擬器驗證的完整流程攤開講清楚重點說清每一步為什么這么做以及哪些地方容易踩坑。適合剛接觸RISC-V工具鏈、做芯片驗證和嵌入式軟件方向的工程師參考。1. 整體思路自定義指令為什么繞不開工具鏈很多同學(xué)第一次接觸RISC-V自定義擴(kuò)展時第一反應(yīng)是“硬件都做好了寫個匯編不就行了”。確實手寫匯編調(diào)用是一條路但項目一旦進(jìn)入C/C開發(fā)階段編譯器根本不認(rèn)識你的新指令。如果不做工具鏈適配整個軟件棧只能把新指令藏在手寫的匯編文件里能跑但沒法優(yōu)化、沒法做內(nèi)聯(lián)、沒法大規(guī)模使用。這個項目要解決的問題很明確讓一條新的RISC-V自定義指令能夠被GNU工具鏈完整支持并且能在模擬器里實際跑起來驗證從匯編、編譯、鏈接到執(zhí)行的整條鏈路。1.1 項目目標(biāo)與示例指令為了讓說明不過于抽象我定義了一條示例指令custadd rd, rs1, rs2。語義很簡單就是執(zhí)行rd rs1 rs2。實際項目中你可以把加法換成任何自定義運算比如飽和加、查表、位提取、復(fù)數(shù)乘法等等工具鏈適配的流程是完全一樣的。這條指令不用標(biāo)準(zhǔn)整數(shù)指令的opcode而是使用RISC-V規(guī)范中留給用戶自定義的custom區(qū)域。RISC-V指令集中有custom-0、custom-1、custom-2、custom-3四個保留opcode區(qū)域分別對應(yīng)0x0b、0x2b、0x5b、0x7b。我選用custom-0區(qū)域格式為R-type字段劃分如下表位段字段值說明31-25funct70x00自定義功能碼24-20rs2任意寄存器第二源操作數(shù)19-15rs1任意寄存器第一源操作數(shù)14-12funct30x0自定義功能碼11-7rd任意寄存器目標(biāo)寄存器6-0opcode0x0bcustom-0區(qū)域1.2 工具鏈適配到底要改哪些東西要完整支持一條新指令至少要打通四層匯編器和反匯編器讓custadd a0, a1, a2能被寫成二進(jìn)制也能被反匯編出來對應(yīng)binutils中的gas和objdump。編譯器讓C/C代碼能通過內(nèi)聯(lián)匯編或內(nèi)建函數(shù)產(chǎn)生這條指令對應(yīng)GCC。模擬器或運行環(huán)境讓程序真正執(zhí)行到這條指令時不報illegal instruction對應(yīng)QEMU、Spike等。運行時庫大多數(shù)情況下不需要改除非這條新指令承擔(dān)了原子操作、內(nèi)存屏障、系統(tǒng)調(diào)用等基礎(chǔ)功能。這里要特別強調(diào)一點工具鏈適配不要一上來就想“全鏈路做完”。我的經(jīng)驗是先改binutils讓指令能匯編和反匯編再接入GCC最后才模擬器驗證。每做一步都立刻做一個小測試確認(rèn)前一步穩(wěn)定了再往后走不然問題混在一起極難排查。2. 指令編碼設(shè)計與binutils適配實操binutils是整個工具鏈的底座。匯編器要認(rèn)識字符串形式的指令反匯編器要認(rèn)識二進(jìn)制形式的指令核心就是一張指令描述表。修改binutils也是整個適配過程中最有技術(shù)含量、最容易出錯的部分。2.1 指令編碼設(shè)計從硬件到工具的“契約”在動工具鏈之前先把指令編碼定死。這張編碼表不僅是硬件和軟件之間的契約也是調(diào)試時判斷工具鏈改得對不對的依據(jù)。前面已經(jīng)確定了custadd的編碼現(xiàn)在要把它轉(zhuǎn)換成binutils需要的兩個關(guān)鍵宏MATCH_CUSTADD和MASK_CUSTADD。MATCH是這條指令的固定模板值。固定位就是funct70、funct30、opcode0x0b低7位拼起來是0x0000000b。MASK表示哪些位必須和模板一致。rs1、rs2、rd是匯編時自由填寫的所以對應(yīng)的位段要置0表示“不關(guān)心”其余固定位段都置1表示“必須匹配”。計算一下opcode占第6到第0位共7位全部需要匹配對應(yīng)掩碼0x7ffunct3占第14到第12位全部需要匹配對應(yīng)掩碼0x7000funct7占第31到第25位全部需要匹配對應(yīng)掩碼0xfe000000。合起來就是0xfe00707f。提示很多人在這步直接把整個32位都作為mask那樣匯編器會強制rs1、rs2、rd也匹配固定值結(jié)果就是只有某幾個寄存器能用其他組合一律報illegal operands。2.2 修改opcode表讓匯編器和反匯編器認(rèn)識新指令以binutils 2.42為例需要改的文件主要是這幾個include/opcode/riscv.h聲明指令屬性一般不需要動但如果你要給指令掛一個新的ISA擴(kuò)展名就需要在這里加枚舉或宏。opcodes/riscv-opc.c核心文件指令表在這里。gas/config/tc-riscv.c匯編器配置處理ISA子集與指令的關(guān)系。在riscv-opc.c中所有指令都以結(jié)構(gòu)體數(shù)組的形式組織custadd可以這樣加在riscv_opcodes[]里{custadd, d,s,t, I, MATCH_CUSTADD, MASK_CUSTADD, match_opcode, 0 },各個字段的含義依次是指令名、操作數(shù)類型、指令所屬ISA子集、匹配值、掩碼、匹配函數(shù)和屬性。其中d,s,t表示這是一條寄存器型指令d是目標(biāo)寄存器rds是rs1t是rs2。關(guān)于ISA子集字段實際項目中有兩種做法。我的建議是驗證階段先掛到I上也就是基礎(chǔ)整數(shù)指令集這樣匯編器不會因為-march里沒寫xcustom就拒絕這條指令。正式交付時再改成規(guī)范的xcustom擴(kuò)展名同時去riscv_ext_version_table之類的擴(kuò)展版本表里注冊。不同版本的binutils注冊位置不太一樣有的是bfd/elfxx-riscv.c有的是opcodes/riscv-opc.c直接搜索riscv_ext_version關(guān)鍵字就能找到。如果你走規(guī)范路線還要在gas/config/tc-riscv.c的riscv_multi_subset_supports函數(shù)里加一個分支告訴匯編器“當(dāng)檢測到xcustom擴(kuò)展時允許這條指令通過檢查”case INSN_CLASS_XCUSTOM: return riscv_subset_supports (xcustom);2.3 編譯binutils并驗證匯編結(jié)果修改完代碼后需要獨立編譯binutils。這一步不需要編譯整個GCC工具鏈速度會快很多。推薦用獨立build目錄cd binutils-gdb mkdir build cd build ../configure --targetriscv64-unknown-elf --disable-gdb --disable-gdbserver --disable-libdecnumber --disable-readline --disable-sim make -j$(nproc)編譯完成后先用一個最小的匯編文件驗證.text .globl _start _start: custadd a0, a1, a2然后依次執(zhí)行riscv64-unknown-elf-as -marchrv64gc -o test.o test.s riscv64-unknown-elf-objdump -d test.o如果一切正常objdump會反匯編出custadd a0,a1,a2。如果你看到illegal operands或者unrecognized opcode先別急著懷疑是opcode表沒生效先查兩個方向一是MATCH/MASK算錯沒有二是操作數(shù)字符串寫錯沒有。比如把d,s,t寫成d,t,s匯編出的結(jié)果表面看是custadd a0,a1,a2但二進(jìn)制編碼里rs1和rs2已經(jīng)完全對調(diào)了。這里還有個小技巧寄存器名是大小寫敏感的a0和A0是兩回事。在腳本里批量生成測試向量時千萬別用Python的upper()函數(shù)去轉(zhuǎn)換寄存器名否則你會看到一串莫名其妙的報錯。3. GCC層的接入內(nèi)建函數(shù)與內(nèi)聯(lián)匯編binutils能識別指令只是第一步。真正開發(fā)時要面對的問題是怎么讓C語言代碼高效地使用這條指令有兩條路線各有適用場景。3.1 兩條可選的接入路線一條是內(nèi)聯(lián)匯編路線改動最小幾乎不碰GCC源碼適合指令數(shù)量少、使用點固定的場景。另一條是GCC內(nèi)建函數(shù)路線需要修改GCC源碼讓編譯器把某個內(nèi)建函數(shù)映射到這條指令適合算法庫、向量庫這類需要頻繁調(diào)用且希望參與編譯優(yōu)化的場景。我個人的工程判斷是如果只是驗證CPU功能或者只有幾處固定代碼用新指令內(nèi)聯(lián)匯編足夠了。如果新指令要作為算法原語被大量使用比如加解密加速指令、神經(jīng)網(wǎng)絡(luò)量化指令那就必須做內(nèi)建函數(shù)否則每次調(diào)用都要寫一坨asm代碼根本沒法維護(hù)。兩條路線的對比如下對比項內(nèi)聯(lián)匯編GCC內(nèi)建函數(shù)修改范圍只改業(yè)務(wù)代碼GCC源碼業(yè)務(wù)代碼驗證周期分鐘級小時級優(yōu)化能力基本不參與優(yōu)化可以參與指令調(diào)度、常量傳播適用場景快速驗證、少量使用核心算法原語、庫封裝3.2 內(nèi)聯(lián)匯編方式使用內(nèi)聯(lián)匯編有一個關(guān)鍵優(yōu)勢其實連binutils那步都可以跳過。因為RISC-V的GNU匯編器從2.36版本開始提供了.insn偽指令可以直接手動指定指令編碼。比如static inline uint64_t custadd(uint64_t a, uint64_t b) { uint64_t d; asm volatile(.insn r 0x0b, 0, 0, %0, %1, %2 : r(d) : r(a), r(b)); return d; }這段代碼的含義是手動組裝一條R-type指令opcode是0x0bfunct3是0funct7是0第一個操作數(shù)是輸出寄存器后兩個是輸入寄存器。這樣就算binutils的指令表里沒有custadd代碼也能編譯通過。但要注意.insn寫法有個明顯缺點反匯編器看到這條二進(jìn)制時根本不認(rèn)識它會顯示成.word 0xXXXXXXXX。所以在驗證階段我會先用.insn快速跑通業(yè)務(wù)邏輯等binutils的opcode表改完之后再把代碼切回custadd %0, %1, %2這種可讀寫法static inline uint64_t custadd(uint64_t a, uint64_t b) { uint64_t d; asm volatile(custadd %0, %1, %2 : r(d) : r(a), r(b)); return d; }注意asm volatile里的volatile不是可有可無的。如果你確定這條指令沒有隱藏副作用去掉它可以讓編譯器更好地調(diào)度但只要這條指令會訪問內(nèi)存、寫狀態(tài)寄存器或者有數(shù)據(jù)依賴之外的副作用就必須保留否則編譯器在優(yōu)化時可能直接把它刪掉。3.3 GCC內(nèi)建函數(shù)擴(kuò)展如果你決定走內(nèi)建函數(shù)路線核心工作分兩步一是告訴GCC這條指令的RTL模板長什么樣二是注冊一個內(nèi)建函數(shù)名。在GCC的RISC-V后端里指令模板定義在gcc/config/riscv/riscv.md中。custadd可以這樣加(define_insn custadddi3 [(set (match_operand:DI 0 register_operand r) (unspec:DI [(match_operand:DI 1 register_operand r) (match_operand:DI 2 register_operand r)] UNSPEC_CUSTADD))] custadd\t%0,%1,%2 [(set_attr type arith)])然后在一個合適的頭文件里新增UNSPEC_CUSTADD枚舉值在riscv-builtins.cc中注冊內(nèi)建函數(shù)描述。這一步的具體接口會隨GCC版本變化我給不了完全統(tǒng)一的代碼但大方向是一樣的編譯器在展開__builtin_custom_custadd(a, b)時會匹配到上面這個RTL模板最終輸出匯編字符串custadd %0,%1,%2。很多人會問能不能直接用define_insn_and_split或者define_expand都可以但要看你的指令是否有復(fù)雜語義。如果只是單條指令直接映射define_insn最省事。如果你希望編譯器在特定條件下自動生成這條指令比如把ab在某些優(yōu)化級別下替換成custadd那就要寫更復(fù)雜的組合優(yōu)化模式難度會高一個量級。3.4 編譯驗證GCC改動修改完GCC后需要重新編譯命令和編譯binutils類似cd gcc mkdir build cd build ../configure --targetriscv64-unknown-elf --prefix/opt/riscv --disable-bootstrap --enable-languagesc make -j$(nproc)然后寫一個簡單的測試#include stdint.h static inline uint64_t custadd(uint64_t a, uint64_t b) { uint64_t d; asm volatile(custadd %0, %1, %2 : r(d) : r(a), r(b)); return d; } int main(void) { uint64_t a 0x12345678; uint64_t b 0x11111111; uint64_t sum custadd(a, b); return sum 0x23456789 ? 0 : 1; }編譯后反匯編riscv64-unknown-elf-gcc -O2 -static -o test test.c riscv64-unknown-elf-objdump -d test在函數(shù)體里應(yīng)該能看到custadd a0,a1,a2這條指令。4. 模擬器驗證讓指令真正跑起來工具鏈改了編譯出的匯編也能看到指令了下一步是在執(zhí)行環(huán)境里讓它真正跑起來。這里以QEMU為例因為QEMU的用戶態(tài)模擬器qemu-riscv64跑測試程序非常方便非常適合工具鏈適配后的快速驗證。4.1 QEMU解碼器修改QEMU對RISC-V指令的解碼分成兩層一層是指令解碼文件負(fù)責(zé)根據(jù)二進(jìn)制pattern匹配到某個“翻譯函數(shù)”另一層是翻譯函數(shù)負(fù)責(zé)把這個指令翻譯成TCG中間碼最終生成宿主機(jī)的機(jī)器碼。先改解碼文件target/riscv/insn32.decode增加一行CUSTADD 0000000 ..... ..... 000 ..... 0001011 r這里的r是R-type指令的標(biāo)準(zhǔn)解碼模板0000000是funct7000是funct30001011是opcode這和我們前面設(shè)計的編碼完全對應(yīng)。然后在target/riscv/translate.c中增加對應(yīng)的翻譯函數(shù)static bool trans_custadd(DisasContext *ctx, arg_custadd *a) { TCGv dst dest_gpr(ctx, a-rd); TCGv src1 get_gpr(ctx, a-rs1, EXT_NONE); TCGv src2 get_gpr(ctx, a-rs2, EXT_NONE); tcg_gen_add_tl(dst, src1, src2); gen_set_gpr(ctx, a-rd, dst); return true; }這段代碼的邏輯很直白取rd、rs1、rs2三個寄存器做一次加法把結(jié)果寫回rd。如果你的自定義指令不是純加法而是需要查表、做移位、做飽和運算就在translate.c里用對應(yīng)的TCG指令實現(xiàn)。QEMU支持的TCG算子很全絕大多數(shù)算術(shù)邏輯操作都能直接映射。4.2 完整跑一個測試程序QEMU修改完成后重新編譯qemu-riscv64。然后在binutils和GCC都支持新指令的前提下編譯前面那段測試代碼并運行riscv64-unknown-elf-gcc -O2 -static -o test test.c qemu-riscv64 ./test echo $?如果程序返回0說明custadd執(zhí)行正確。如果想觀察指令級別的執(zhí)行情況可以用QEMU的調(diào)試輸出qemu-riscv64 -d in_asm,cpu ./test 21 | head -80在dump出來的匯編流里你應(yīng)該能看到custadd這一行被實際執(zhí)行。這里我要提醒一個大坑QEMU對自定義指令的decode是和opcode表嚴(yán)格匹配的哪怕你只改了一個位匹配失敗都會直接走illegal instruction路徑。如果你在QEMU里看到Illegal instruction (core dumped)第一件事不是查翻譯函數(shù)而是用objdump -d test.elf確認(rèn)二進(jìn)制編碼再和insn32.decode里的pattern逐位比對。很多時候問題出在GCC編譯時用了壓縮指令擴(kuò)展Linux下默認(rèn)的-marchrv64gc開了C擴(kuò)展如果代碼被壓縮成16位指令QEMU走的完全是另一套解碼路徑。4.3 其他驗證手段Spike與FPGA除了QEMUSpike也是常用的RISC-V參考模擬器。Spike的指令執(zhí)行邏輯集中在riscv/processor.cc和riscv/insn_t::execute()中需要在指令分發(fā)的函數(shù)里加上對custadd的處理。Spike的優(yōu)點是模型和RTL行為更貼近很多團(tuán)隊用它做ISS與RTL對拍缺點是跑完整Linux啟動比較麻煩通常只跑bare-metal測試。如果你有FPGA環(huán)境也可以把編譯好的test.elf直接下到板子上跑。這時驗證的重點就變成了“工具鏈編碼”、“模擬器執(zhí)行”、“RTL解碼”三方是否一致。我的經(jīng)驗是先用QEMU快速確認(rèn)工具鏈輸出沒問題再上FPGA因為FPGA上定位工具鏈問題非常痛苦每一次修改編譯都要燒錄迭代太慢。5. 常見問題與排查技巧實錄這個項目做完我把遇到的高頻問題整理成了一張速查表。下面這些問題基本覆蓋了工具鏈適配過程中90%的坑?,F(xiàn)象可能原因排查方向匯編報illegal operands操作數(shù)字符串寫錯或者指令被ISA擴(kuò)展門控檢查opcode表里d,s,t與-march參數(shù)反匯編不識別顯示.wordopcode表沒有匹配到或MATCH/MASK錯誤用objdump -d看編碼逐位比對GCC編譯報unsupported ISA extension-march里寫了工具鏈不認(rèn)識的xcustom先掛I擴(kuò)展或注冊擴(kuò)展名GCC優(yōu)化后指令消失沒有volatile或內(nèi)建函數(shù)RTL模板沒生效加volatile反匯編檢查QEMU報illegal instructiondecode pattern和實際編碼不一致objdump導(dǎo)出編碼逐位核對運行結(jié)果寄存器錯亂rs1/rs2在opcode表中順序?qū)懛磳φ站幋a反推確認(rèn)d,s,t順序5.1 指令匹配和掩碼類問題MATCH和MASK是這整套適配里最需要細(xì)心的地方。有一個很容易掉的坑MASK并不等于所有固定位都置1。你要讓rs1、rs2、rd這些字段可變就必須把對應(yīng)的掩碼位置0。曾經(jīng)有人圖省事把MASK寫成全1結(jié)果只有rd0、rs10、rs20這一種組合能匯編通過其他組合全部報illegal operands排查了很久才發(fā)現(xiàn)是掩碼問題。還有一個隱蔽的問題RISC-V的opcode是低7位但在很多宏定義和反匯編器的實現(xiàn)里指令的低兩位其實是固定為11表示32位指令。所以你從datasheet上看到的opcode0x0b實際二進(jìn)制指令最右邊兩位永遠(yuǎn)是11。寫decode pattern時要注意不能想當(dāng)然地認(rèn)為opcode字段就是指令最低的7位全部可變。5.2 ISA子集與工具鏈版本差異把自定義指令注冊成xcustom擴(kuò)展這件事不同工具鏈版本的差異非常大。老版本binutils可能只要在opcode表里加一行新版本還要求你同步維護(hù)多層子集依賴關(guān)系。我在升級binutils版本時遇到過xcustom擴(kuò)展被要求顯式聲明依賴i擴(kuò)展的情況否則匯編器直接拒絕。最穩(wěn)妥的辦法是查看當(dāng)前版本自帶的riscv-opc.c里已有的擴(kuò)展是怎么注冊的照葫蘆畫瓢抄一遍。5.3 內(nèi)聯(lián)匯編的寄存器約束用內(nèi)聯(lián)匯編時GCC的寄存器約束r通常沒什么問題但如果你寫的是32位指令要特別小心數(shù)據(jù)類型的寬度。在RV64下一個uint32_t變量傳給r約束時GCC有可能只保證低32位有效高32位是未定義的。自定義指令如果依賴完整的64位源操作數(shù)類型一律用uint64_t或unsigned long避免踩到未定義行為的坑。5.4 幾個提高成功率的小習(xí)慣最后分享幾個我實際做這個項目時覺得特別有用的習(xí)慣。第一個是每次只改一層。改完binutils先測匯編和反匯編改完GCC先測內(nèi)聯(lián)匯編和內(nèi)建函數(shù)最后才碰QEMU。如果一次改了所有層出問題根本不知道是編譯器發(fā)錯了指令還是模擬器翻譯錯了指令。第二個是保留一個golden reference。在動手改工具鏈之前先用.insn偽指令寫一個最小測試程序計算出預(yù)期結(jié)果比如0x12345678 0x11111111 0x23456789。后面每一步適配都用同一個測試程序跑一遍確保每一步的改動沒有破壞之前的成果。第三個是善用反匯編工具。objdump -d和llvm-objdump -d看到的結(jié)果不完全一樣尤其在自定義擴(kuò)展支持不完整時LLVM可能直接把指令顯示成.word而GNU objdump已經(jīng)能識別。對比這兩者能幫你快速判斷問題出在哪個工具鏈。如果是LLVM工具鏈也要支持流程類似只不過改的是TableGen的.td文件指令描述語法完全不同那又是一套獨立的適配工作了。