contributed by < Risheng1128
>
目標:
c.ebreak
未通過)首先下載 SiFive 公司維護的 RISC-V toolchain riscv-gnu-toolchain ,以下為安裝步驟
xPack GNU RISC-V Embedded GCC 是另一個 toolchain 選擇,針對 MS-Windows, Linux, macOS 等作業系統提供預先編譯的執行檔
結束後就將 toolchain 的路徑加到環境變數
上面步驟都做完後可執行以下命令,測試是否安裝成功
期望輸出
安裝完 toolchain 後可以開始使用 rv32emu 專案
下載 SDL2 library
sudo apt install libsdl2-dev
修改檔案 mk/toolchain.mk
裡的參數 CROSS_COMPILE
為我們使用的 toolchain
可比照 tests/gdbstub.sh
,提供一組 target triplet 清單,讓系統偵測並採用特定標的。
完整修改可參考 Detect toolchain automatically
建立 emulator
期望輸出
以 make check
執行內建的測試標的,以下為期望輸出
rv32emu 是一款 32 位元 RISC-V 指令集模擬器,藉由軟體來模擬 RISC-V 的處理器並表現指令行為。至於要怎麼做一個簡單的 RISC-V 模擬器,可參考 Write a simple RISC-V emulator in plain C
首先探討 rv32emu 的 main
函式,這裡主要著重在 RISC-V 處理器的設計細節,其他部份可以由 src/main
找到
一開始就是單純的解析使用者輸入的命令
讀取使用者輸入的執行檔
建立 RISC-V 模擬器的 I/O 界面
這裡使用到結構 riscv_io_t
,該結構定義在檔案 src/riscv.h
,以下為其定義。基本上每個結構成員都是各種不同的函式指標,而這裡的 w
, s
, b
則是分別存取 word, half 及 byte
建立 RISC-V 模擬器
這邊用到管理整個 RISC-V 模擬器的結構 riscv_t
,該結構定義在檔案 src/riscv_private.h
裡,以下為其定義。基本上這個結構定義了 I/O 介面、通用暫存器及 program counter 等等處理器的硬體
引入記憶體操作的抽象處理,包裝執行檔載入過程,這樣後續 rv32emu
就在該物件上操作:
根據使用者的輸入決定模擬器的執行模式
將指令執行的結果輸出
將已建立的動態記憶體移除
這裡主要探討 rv32emu 是如何執行輸入的執行檔,也就是在下列 main
函式的部份,只討論單純執行的過程,也就是執行函式 run
的情況,如下所示
接著進到函式 run
的實作,基本上就是等待處理器被中止 (halt) ,當處理器被中止後,處理器的狀態 halt
會從 false 更改為 true ,此時函式 rv_has_halted
會回傳 true ,也就會中止迴圈
接著進到 rv32emu 的核心函式 rv_step
,這個函式有非常多的細節,在直接 "意淫" 程式碼之前,先了解整個處理器的運作原理。參考 Writing a simple RISC-V emulator in plain C - the main file 可以看到一個簡化版的處理器,如下所示
基本上從上面的程式碼可以總結處理器的幾個步驟
cpu_fetch
cpu_execute
cpu_execute
cpu.pc += 4;
有了處理器的基本概念,接著就可以開始分析函式 rv_step
,因為函式很長,所以就逐段一一分析
首先透過巨集函式 RV32_HAS
判斷要使用 computed goto 或是使用函式指標的方式,前者使用 label 的地址建立 jump table ,細節可參考〈你所不知道的 C 語言: goto 和流程控制篇〉,而後者則是透過函式呼叫的方式。
這裡主要就以使用 computed goto 的方式為例
接著定義指令的 table ,如果使用 computed goto 的方法,此時儲存的資料就是 label 的地址,至於這個 table 是如何定義呢 ? 參考 The RISC-V Instruction Set Manual Volume I: User-Level ISA 裡的章節 RV32/64G Instruction Set Listings 以及章節 "C" Standard Extension for Compressed Instructions 即可得知
首先是 RV32G 的部份,這裡的 G 是由 IMAFD 這些 extension 所集合而成的,從下圖對照程式碼可以清楚看到整個 table 的定義
接著是 RV32C 的部份,從下表可以看到對於 RV32 、 RV64 及 RV128 架構,這裡的 RVC 是如何作分類的,接著就單純探討 RV32C 的部份
接著根據以下的圖,可以對應程式碼的 table
接著討論巨集函式 DISPATCH
主要做以下幾件事
DISPATCH_RV32C
裡)這裡先解釋 RV32 的部份,對應到上述程式的第 8 ~ 11 行
接著討論 RV32C 的情況,由以下的巨集函式 DISPATCH_RV32C
實作
跳進特定的 label 後,接著會開始執行指令,使用巨集函式 EXEC
來執行各種指令,完整的實作如下所示
用巨集函式 TARGET
將 label 、 EXEC
及 DISATCH
封裝起來
用巨集函式 TARGET
將各種指令做出來,最主要執行的迴圈就是在這裡
最後就使用函式 op_load
作為範例來探討,從註解其實就很清楚了,可以知道 op_load
實際上做了什麼事
func3
來決定目前的指令為何,並做出不同的結果稍微總結一下,基本上整個 rv32emu 的核心就是在於透過巨集函式 DISPATCH
取得指令並跳進對應的 label ,接著使用巨集函式 EXEC
做出對應的執行行為並且對 pc 增加對應寬度的大小,重複執行這些動作直到處理器被中止
TODO: 補齊 ELF 細節
除了理解 rv32emu 的實作細節,也應當深入理解測試模擬器的專案是如何運作的,首先確定目前 rv32emu 使用的測試版本,使用命令 git submodule status
確定,以下為輸出
從 hash 值可以找到對應的版本,如下所示,同時這也是 old-framework-2.x 分支的版本
在實作目標之前,可以先參考已經完成的指令集是怎麼測試,這裡分析 Base Integer Instruction Set 是如何實作,使用命令 make arch-test RISCV_DEVICE=I
來分析
首先輸入命令 make arch-test RISCV_DEVICE=I
後會開始執行 Makefile ,其中以下的程式碼則會使用到檔案 mk/riscv-arch-test.mk
而在檔案 mk/riscv-arch-test.mk
裡,包含負責處理命令的部份,以下為完整程式碼
在第 13 行會使用 git submodule
將子模組 arch-test-target 加到 rv32emu 裡,接著在第 18 及 19 行分別都使用了一次檔案 tests/riscv-arch-test/Makefile
接著來到檔案 tests/riscv-arch-test/Makefile
,首先使用 $(RISCV_DEVICE)
決定要使用命令 all_variant
或是命令 variant
,前者會將所有擁有包含在目錄 tests/arch-test-target/device/rv32i_m
裡的指令集都測試一次,後者則是對特定的指令集做測試
這裡以單純測試 RV32I 為例,會分別照順序執行命令 simulate
以及 verify
,前者的部份則會執行檔案 tests/riscv-arch-test/riscv-test-suite/rv32i_m/I/Makefile
,而後者會執行 shell script riscv-test-env/verify.sh
首先為命令 simulate
的部份,主要是建立並執行每個組合語言的測試資料,可以從檔案 Makefile.include
找到
tests/arch-test-target/device/rv32i_m/I/Makefile.include
的部份可以參考以下程式碼,這個檔案就是用來編譯我們需要的 assembly 並執行 rv32emu 的地方
而命令 verify
的部份則是將目錄 tests/riscv-arch-test/riscv-test-suite/rv32i_m/I/references
裡的各種 .reference_output
檔和位於 build/arch-test/rv32i_m/I
裡的實際執行的各種 .signature.output
輸出檔相互比較,來確定是否測試成功
根據前面的討論 - Makefile 運作流程,可以知道程式最後會在目錄 tests/arch-test-target/device/rv32i_m
裡根據使用者的輸入對應不同的 extension ,並同時執行 rv32emu ,以下為執行 rv32emu 的程式碼
同時也根據前面的討論 - rv32emu 主要運作流程 ,了解 rv32emu 的主要運作原理,如此一來已經對 rv32emu 有一定的認知,接著要了解究竟什麼是 RVC 呢 ?
參考 The RISC-V Instruction Set Manual Volume I: User-Level ISA 的章節 "C" Standard Extension for Compressed Instructions, Version 2.0
基本上 "C" extension 主要就是 RISC-V standard compressed instruction set extension ,顧名思義就是用來壓縮指令的寬度,達到提升指令密度的作用,在資源受限的環境中,這是很重要的特徵。
The C extension can be added to any of the base ISAs (RV32, RV64, RV128), and we use the generic term “RVC” to cover any of these.
接著就直接講到 RVC 的指令類型,基本上類型 CR 、 CI 及 CSS 可以使用 RV32I 任何的暫存器 (x0 ~ x31) ,而類型 CIW 、 CL 、 CS 及 CB 則只能使用 RV32I 裡的其中 8 個暫存器 (x8 ~ x15) ,用下圖來表示
CR, CI, and CSS can use any of the 32 RVI registers, but CIW, CL, CS, and CB are limited to just 8 of them.
至於是只能使用哪 8 個暫存器,則可以透過下圖得知
由於這次目標是要實作指令 c.ebreak
,因此就來好好認識這個指令
指令 c.ebreak
主要是將控制權轉移給 debugger ,可以用來中斷程式的運作,也就是說,觸發中斷點就是用這個指令來執行的,另外從上圖可以發現指令 c.ebreak
和 c.add
共用 opcode ,因此可以將其歸類為 CR 類型
Debuggers can use the C.EBREAK instruction, which expands to ebreak, to cause control to be transferred back to the debugging environment. C.EBREAK shares the opcode with the C.ADD instruction, but with rd and rs2 both zero, thus can also use the CR format.
參考 riscv-isa-sim 裡對指令 c.ebreak
的實作,位於檔案 c_ebreak.h
裡,如下所示
有點難懂…只好來看規格書了!參考 The RISC-V Instruction Set Manual Volume II: Privileged Architecture 的章節 Introduction
可以發現 RISC-V hardware thread (hart) 有一些不同的 priviledge level ,而通常會有三種不同的 priviledge level ,如下表所示
接著解釋這三種模式的差別
有了以上的知識,回到前面的程式碼,可以很清楚的知道, if
的邏輯主要是判斷目前的處理器是否處於 Debug 模式且判斷處理器的 privilege 的狀態
接著就有了兩種情況,分別是進入 Debug 模式以及觸發中斷點,以下為前者在 riscv-isa-sim 的實作
接著是後者的實作,這邊主要擷取執行 Machine 模式的部份,可以看到主要都是對各種 CSR 的暫存器做設定,會在後面的實作仔細介紹各個暫存器的功能
c.ebreak
的測試在 rv32emu 裡,首先看到函式 op_ccr
可以發現原始指令 c.ebreak
的實作,基本上就是直接中止處理器,如下所示
而 on_ebreak
早在一開始就指向函式 rv_halt
了
經過翻閱規格書後,最後的完整實作可以參考 Implement c.ebreak properly ,這邊就講解主要函式 rv_except_breakpoint
,參考 The RISC-V Instruction Set Manual Volume II: Privileged Architecture
首先是程式碼第 7 ~ 8 行的部份,以下為暫存器 mtvec
的 bit map ,基本上有了下圖程式碼就很清楚了,也就是分別擷取 BASE 及 MODE 的部份
而 BASE 及 MODE 的部份可以參考下圖,主要邏輯如下
如此一來就對應到第 19 ~ 25 行
接著變數 code
則代表 Exception Code ,由下表可知, breakpoint exception 的 Exception code 值為 3
暫存器 mepc
的部份,由以下原文可以得知,如果 trap 執行在 M 模式的話,此時暫存器 mepc
的值則是該指令的 virtual address ,在這裡指的就是 rv->PC
When a trap is taken into M-mode, mepc is written with the virtual address of the instruction that was interrupted or that encountered the exception. Otherwise, mepc is never written by the implementation, though it may be explicitly written by software.
暫存器 mepc
的部份,由以下原文可以得知,當發生的 trap 為 breakpoint 時,此時的贊存器 mtval
其值為造成 trap 的指令的 virtual address ,在這裡就是指 rv->PC
If mtval is written with a nonzero value when a breakpoint, address-misaligned, access-fault, or page-fault exception occurs on an instruction fetch, load, or store, then mtval will contain the faulting virtual address.
經過這樣的修改後,目前 Compressed Instructions 已經都測試通過
上述程式碼修改對應到 Issue #60
根據 Avoid duplications in RISC-V exception handlers #61 的敘述,可以清楚了解該 Issue 的目的,由於目前處理 exception 的函式都寫得非常相似,有非常多重複的程式碼,因此可以使用巨集進行改寫,而需要修改的函式如下所示
完整的修改可參考 Avoid duplications in RISC-V exception handlers ,以下為主要的巨集函式
需要注意的地方在於上方的第 12 ~ 17 行,首先第 12 行的部份主要是取得 exception 的 exception code ,這裡建立一個列舉並透過巨集函式 GET_EXCEPTION_CODE
來取得,而參考的表格如下
而列舉的定義如下
接著是第 16 行的部份,可參考 The RISC-V Instruction Set Manual Volume II: Privileged Architecture 裡提到的暫存器 mepc
,其中有一段句子如下
When a trap is taken into M-mode, mepc is written with the virtual address of the instruction that was interrupted or that encountered the exception. Otherwise, mepc is never written by the implementation, though it may be explicitly written by software.
可以得知執行在 Machine 模式發生 trap ,此時的 mepc
會被設定為被中斷或是遇到 exception 的指令地址,而 rv32emu 目前只有執行 Machine 模式,因此可以設定 mepc
為 rv->PC
最後是第 17 行,參考規格書的暫存器 mtval
的敘述,可以發現 mtval
會根據不同的 exception 被設定不同的值,以下做簡單的分類
mtval
will contain the faulting virtual addressmtval
will contain the virtual address of the portion of the access that caused the faultmtval
will contain the virtual address of the portion of the instruction that caused the fault, while mepc will point to the beginning of the instruction這裡就沿用原本的實作,也就是讓不同的指令計算其 mtval
的數值,當發生 exception 時將 mtval
的值用傳進 exception handler 裡
ebreak
指令在通過 c.ebreak
的測試理,已經通過了 RV32C 裡 ebreak
指令的測試,但很奇怪的是 RV32I 裡的 ebreak
指令卻無法通過測試,使用命令 make arch-test RISCV_DEVICE=privilege
做測試,以下為測試結果
既然 ebreak
沒通過,看一下 rv32emu 實際執行了什麼指令,使用命令 riscv64-unknown-elf-objdump -d ebreak.elf
,以下是最關鍵的部份
可以發現最關鍵的指令 ebreak
被編譯成 RV32C 的指令 c.ebreak
,其編碼如下所示
最後在檔案 tests/arch-test-target/device/rv32i_m/privilege/Makefile.inclide
可以找到編譯 privilege 測試檔的編譯器選項,這裡將 RV32C 的部份移除
接著重新觀察新編譯出來的執行檔,一樣使用命令 riscv64-unknown-elf-objdump -d ebreak.elf
,以下為輸出結果
很明顯編譯器已經編譯出我們要的指令,接著看測試結果,如下所示
可以發現像是 misalign-lw
及 misalign-sw
也是因為編譯器選項的關係而沒有通過,但我們最主要要修復的 ebreak 還沒通過
最後發現在原本的實作中,執行完 ebreak
後,多了不需要的步驟,也就是下方程式碼的第 15 行
因此就在 ebreak
執行結束後直接回傳即可,如下所示
最後再次測試,這時的 ebreak 就通過了測試
以上的完整修改可以對應到 Improve compliance for privileged instructions
ecall
指令根據處理 ebreak
指令,對於目前的 privilege instruction 測試,剩下最後一個沒通過的指令 ecall
,先觀察目前的實作
在檔案 src/emulate.c
裡,函式 op_system
負責判斷目前的指令是否為 ecall
接著函式指標 on_ecall
會呼叫函式 syscall_handler
,該函式位於檔案 src/syscall.c
,實作如下
間單來說,目前的實作其實只有取得暫存器 a7
的資料作為 syscall number 並分配給不同的處理函式,然而目前實作缺乏了 control and status registers (CSRs) 的設定
根據 Avoid duplications in RISC-V exception handlers #61 ,可以發現目前紀錄 exception 的 CSR 不同的地方為紀錄 exception code 的暫存器 mcause
以及紀錄 exception 訊息的暫存器 mtval
,以下就著重這兩個部份
首先 mcause
的部份可參考 The RISC-V Instruction Set Manual Volume II: Privileged Architecture 裡 mcause
儲存的資料,可以發現目標為 Environment call from M-mode ,其 exception code 為 11
接著擴充在 rv32emu 裡 exception list 的實作
接著是 mtval
的部份,暫存器 mtval
會根據不同的 exception 而儲存不同的資料,而 ecall
則是對應到以下原文,清楚了解 ecall
exception 發生時, mtval
目前是儲存 0
For other traps, mtval is set to zero, but a future standard may redefine mtval’s setting for other traps.
這裡新增新的函式 ecall_handler
,並且在 rv_except_ecall_M
傳入 0 ,也就是暫存器 mtval
將儲存的數值
完整修改可參考 Implement environment call properly ,以下是對 privilege instruction 的測試,目前完整通過 riscv-arch-test 的 privilege 測試 !
ecall
非預期錯誤問題: 在處理 ecall
指令的實作中,已經完成 privilege instruction 的測試,但當使用命令 make check
來執行 hello.elf
、 puzzle.elf
及 pi.elf
時卻進入無限迴圈
首先要先知道 RISC-V 在處理 exception 時的運作,參考 RISC-V: 中斷與異常處理,其中很重要的一點就是當 exception 發生時, RISC-V 會根據暫存器 mtvec
去修改原本的 program counter ,以執行其 exception handler ,以下是 rv32emu 對應的實作
接著使用通過測試的 ecall.elf
以及未通過的 hello.elf
做比較以找到問題點,分別對兩者都做反組譯
首先是通過測試的 ecall.elf
,以下節錄修改 mtvec
以及執行 ecall
的部份
可以發現在上述程式碼的第 2 ~ 9 行,暫存器 mtvec
被設定為 0x800001dc ,當執行 ecall 時,會根據目前 mtvec
的 base 及 mode 調整新的 pc
另外是 hello.elf
的部份,以下是反組譯的結果
可以很清楚發現,在 hello.elf
並沒有對於 control and status registers (CSRs) 的實作,而這樣會有一個問題,前面有提到 RISC-V 在發生 exception 時會根據暫存器 mtvec
來設定 program counter 以執行 exception handler 。而根據上述的程式碼,執行指令 ecall
的當下,暫存器 mtvec
的資料會維持為 0 ,此時 pc 會被設定為 0 ,會重新抓取第一行的指令,也就重複了這樣的循環,導致產生無限迴圈的問題
知道了問題的原因,接著來修補坑洞吧!
參考 riscv-arch-test 以及 RISC-V Exception and Interrupt implementation ,了解目前的測試檔缺少了 mtvec
的設定,這裡先對 hello.S
做修改,以下是完整的修改
在上述程式碼的第 3 ~ 5 行對 mtvec
先設定預設值,接著第 21 ~ 26 行則是做了一個「虛設」的 trap handler ,主要就是利用 mepc
設定新的 pc 值,再利用指令 mret
將 mepc
的值設定給 pc
經過這樣的擴充後,重新編譯並且用 rv32emu 執行,以下為執行結果,結果正常!
雖然經過上面的修改可以正確執行,但每個執行檔都要經過修改不是個好方法,最後採用一開始就給 mtvec
預設值 0 ,當發生 exception 時,若 mtvec
的值不變,表示該執行檔沒有實作 mtvec
的設定,此時就執行預設的 handler ,完整修改可參考 Implement environment call properly
在解決問題之前,應先熟悉 issue 所提到的 RISC-V 測試框架 — RISCOF ,這裡將安裝方式以及測試放在使用 RISCOF 測試 rv32emu 裡
參考其他簡易模擬器的實作,如 wip-fastrv32 及 refactor-rv32 等等,提升 rv32emu 的效能,使用 coremark 作為效能的評比,以下為不同模擬器實作的效能表現
wip-fastrv32:
refactor-rv32:
rv32emu:
可以發現 rv32emu 的效能還有很大的提升空間
emulate.c
抽離在 refactor-rv32 裡用到了 basic block 的觀念,將準備要執行的指令轉換成一個個的 block ,以減少頻繁對記憶體存取的動作,而 basic block 的範例可以參考 basic blocks in compiler design
為了簡化問題,分成三個步驟實作
將 rv32emu 裡 decode 的部份從 emulate.c
抽離
完整修改可參考 Decouple instruction decoding from emulation unit (#79)
在 rv32emu 裡實作 basic block
完整修改可參考 Introduce basic block
目前發現 rv32emu 在使用 Dhrystone 測試時,其效能和 refactor-rv32
相差甚遠 (兩者的實作邏輯相似) ,以下為測試結果
TODO: 使用 Perf 找到造成 rv32emu 效能較低的原因
rv_emulate
裡使用 computed gotoTODO: 對照 fastrv32 的實作
make arch-test
等等emulate.c
抽離對應論文: Accelerate Cycle-Level Full-System Simulation of Multi-Core RISC-V Systems with Binary Translation → 要記得看
搭配閱讀: ria-jit 重點摘要
TODO:
TODO: