6.1 RISC-V 綜合實例:單週期與多週期核心#
本章講解專案裡兩顆真的會上 FPGA 的 RISC-V 核心: 單週期的
RiscvCore(examples/src/rv32i.veryl)與多週期的RvCore(examples/src/rvsoc.veryl,RV32IMA + Zicsr)。 這裡看的是微架構與原理;怎麼用v2c編譯 C、怎麼在 IDE 操作, 請參考 manual 的 10-riscv-c.md。
6.1.1 為什麼要有兩顆核心#
同一個指令集可以有非常不同的硬體實作,這兩顆核心正好構成一個教學光譜的兩端:
RiscvCore(rv32i.veryl) | RvCore(rvsoc.veryl) | |
|---|---|---|
| 微架構 | 單週期(一拍一指令) | 多週期狀態機 |
| 指令集 | RV32I(無 fence/ecall/ebreak/CSR) | RV32IMA + Zicsr |
| 記憶體 | 組合邏輯 ROM + 外部 dmem | 同步 RAM(4 位元組通道) |
| 週期數 | 每指令 1 | 2 起步(load 3、mul/div 35、trap 3) |
| 用途 | 教學、綜合下限測試 | 真正跑 C 程式的 SoC |
單週期核心的意義不是「快」,而是每拍都完成一個指令——它把 取指、解碼、ALU、記憶體全部塞進一個組合路徑,Fmax 自然很低, 但它是理解資料路徑最乾淨的教材。多週期核心則把這條長路徑切開, 換取時脈頻率,代價是每個指令要 2 拍以上。
6.1.2 單週期核心:一拍到底的資料路徑#
rv32i.veryl 只有 416 行,三個模組:Alu、RegFile、RiscvCore。 暫存器檔是真正的觸發器陣列:
module RegFile #(
...
) (
i_clk : input clock ,
i_we : input logic ,
i_waddr: input logic<5>,
i_wdata: input logic<32>,
i_raddr1: input logic<5>,
o_rdata1: output logic<32>,
...
) {
var rf: logic<32>[32];
always_ff (i_clk, i_rst) {
if_reset {
rf = '{default: 0};
} else if i_we && i_waddr != 0 {
rf[i_waddr] = i_wdata; // x0 永遠是 0:寫入直接被擋掉
}
}
assign o_rdata1 = rf[i_raddr1]; // 組合讀取:同一拍讀、同一拍寫可見
...
}
單週期核心的關鍵在於:寫回與讀取在同一拍。rf 是觸發器, 組合讀取讀到的是「上一拍寫入後」的值,所以資料冒險(data hazard) 天然不存在——這就是單週期微架構最大的教學價值。
flowchart LR
PC[pc] --> IMEM[指令 ROM<br/>組合邏輯] --> DEC[解碼<br/>opcode/funct3/funct7]
DEC --> RF[(RegFile<br/>32×32)]
DEC --> IMM[立即數<br/>組合]
RF --> ALU[ALU]
IMM --> ALU
ALU --> WB[寫回多工器]
ALU --> ADDR[位址]
ADDR --> DMEM[dmem 陣列]
PC -->|"pc + 4"| PC6.1.3 多週期核心:FETCH / DECODE / EXEC / MEM / MULDIV / WFI#
rvsoc.veryl 的 RvCore 是本專案真正跑 C 程式的 CPU。它的狀態機 定義在模組最上方(rvsoc.veryl:46-51):
const FETCH : logic<3> = 3'd0;
const DECODE: logic<3> = 3'd1;
const EXEC : logic<3> = 3'd2;
const MEM : logic<3> = 3'd3;
const MULDIV: logic<3> = 3'd4;
const WFI : logic<3> = 3'd5;
var state : logic<3>;
var pc : logic<32>;
var ir : logic<32>; // 執行中的指令
var lsaddr: logic<32>; // load / AMO 的位址(Mem 用)
週期數(與 ISS 完全一致,見 6.4 節):
| 狀態路徑 | 週期數 | 例子 |
|---|---|---|
| FETCH→DECODE→EXEC→(回到 DECODE) | 2 | add, lui, beq |
| …→MEM→DECODE | 3 | lw, sw, lr.w, amoadd |
| …→MULDIV(32 拍循環)→DECODE | 35 | mul, div |
| …→WFI→(喚醒)→DECODE | 直到中斷 | wfi |
| …→FETCH(跳 mtvec) | 3 | 任何 trap |
為什麼最少是 2 拍而不是 1?因為記憶體是同步讀取(SyncRam): FETCH 拍送出 o_raddr = pc,i_rdata 要到下一拍才有資料, 所以 DECODE 拍讀的是 i_rdata:
// Decode 那一拍指令還在 i_rdata 上,之後在 ir
let cur: logic<32> = if state == DECODE ? i_rdata : ir;
暫存器檔也用同一招——兩份相同的同步讀取 RAM 當兩個讀取埠:
inst u_rf1: SyncRam #(ADDR_W: 5, DATA_W: 32) (
i_we : rf_we ,
i_waddr: rd_idx ,
i_wdata: rf_wd ,
i_re : 1'b1 ,
i_raddr: cur[19:15],
o_rdata: rs1_raw ,
);
x0 不能依賴 RAM 的內容(模擬時初值是 x),所以在 DECODE 拍 另外記下「讀的是不是 x0」:
var rs1_zero: logic;
let rs1_data: logic<32> = if rs1_zero ? 32'd0 : rs1_raw;
狀態轉移#
stateDiagram-v2
[*] --> FETCH: 重設
FETCH --> DECODE: o_re=1<br/>下一拍 i_rdata 到
DECODE --> EXEC: ir ← i_rdata<br/>imm ← imm_d
EXEC --> DECODE: 純運算 / 分支<br/>(下一指令的讀取已送出)
EXEC --> MEM: load / LR / AMO
EXEC --> MULDIV: mul/div(33 拍反覆)
EXEC --> WFI: wfi 且無 pending 中斷
EXEC --> FETCH: trap(pc ← mtvec)
MEM --> DECODE: load 資料寫回<br/>LR 記下保留位址
MULDIV --> DECODE: md_cnt == 32
WFI --> DECODE: pending(喚醒)注意 EXEC → DECODE(而不是 FETCH)這條邊:EXEC 拍已經把下一個 指令的讀取送出去了(o_raddr = exec_rd,見 rvsoc.veryl:466), 所以從 EXEC 回到 DECODE 直接讀到新指令。這是「每指令 2 拍」的關鍵。
MULDIV:一個 33 位元加減法器跑 33 拍#
乘除法不使用組合乘法器(那會毀掉 Fmax),而是反覆式:
- 乘法:{部分積, 乘數} 64 位元,每拍條件加
md_b再右移; - 除法:{餘數, 商},每拍左移後試減。
let md_sh : logic<64> = {md_p[62:0], 1'b0};
let md_div : logic = md_f3[2];
let md_x : logic<33> = {1'b0, if md_div ? md_sh[63:32] : md_p[63:32]};
let md_y : logic<33> = if md_div ? ~{1'b0, md_b} : if md_p[0] ? {1'b0, md_b} : 33'd0;
let md_sum : logic<33> = md_x + md_y + {32'd0, md_div}; // 減法 = 加反相再加一
除以 0 不觸發例外(RV 規格:商 = −1、餘數 = 被除數), 由 md_bz && md_f3[2:1] == 2'b10 直接選 0xffff_ffff。
6.1.4 Speculative fetch 與 trap gating#
RvCore 最巧妙(也最容易出錯)的地方:EXEC 拍就送出下一個 讀取位址,即使這個指令可能陷入例外。註解(rvsoc.veryl:438-440) 說得很直白:
Exec 送出的下一個讀取位址:不等陷阱(陷入時改走 Fetch,從 mtvec 重新取指令);分支條件 take 只進最後一個多工器。
也就是說:
flowchart LR
subgraph EXEC 拍
ALUOUT["alu_out<br/>(load 位址)"] --> MUX
PC4["pc+4 / mepc"] --> MUX
BT["pc + imm<br/>(分支目標)"] --> MUX
MUX -->|"exec_pc"| ORE[o_raddr / o_re]
end
ORE -->|"下一拍的指令"| I["i_rdata"]-
投機取指(speculative fetch):陷入例外的指令也照樣送出 讀取(除了 load——周邊的讀取有副作用,陷入的 load 不能讀, 所以
o_re = !trap_fast,見rvsoc.veryl:468)。例外發生時 pc 直接改成mtvec,那筆投機讀回的資料被丟棄, 重新從 handler 取指。省下的邏輯是「先判陷阱再送位址」 的額外一拍或串聯閘。 -
trap_fast與trap的分層:並非所有陷阱都能在 EXEC 拍 立刻判定。分支目標沒對齊要等比較器take的結果, 所以把它從trap_fast拆出來:
// trap_fast:不用等分支比較器的陷阱(決定暫存器、記憶體、CSR 寫不寫);
// trap = 再加上分支目標沒對齊(只決定下一個 pc)
var trap_fast : logic;
var trap_cause: logic<5>; // {interrupt, code}
let trap: logic = trap_fast || bad_branch;
trap_fast 閘住所有有狀態效果的動作(寫暫存器、寫記憶體、 寫 CSR、寫 minstret),bad_branch 只影響下一個 pc。 這讓關鍵路徑上不需要等 32 位元分支比較——比較 imm[1] 和 take 就夠了(bad_branch,rvsoc.veryl:194)。
- 位址對齊檢查只用 2 位元:
addr_lo = rs1_data[1:0] + imm[1:0]是一個 2 位元加法器,和 32 位元 ALU 平行運算:
let addr_lo : logic<2> = rs1_data[1:0] + imm[1:0];
let bad_jal : logic = (opcode == Opcode::Jal && imm[1]) || (opcode == Opcode::Jalr && addr_lo[1]);
let bad_branch: logic = opcode == Opcode::Branch && imm[1] && take;
6.1.5 A 擴充在 store 致能路徑上的位置#
LR/SC 的地址比較刻意不用 ALU。AMO 的位址就是 rs1 (沒有偏移量),所以:
let sc_ok: logic = resv_v && resv_a == rs1_data; // A 擴充的位址就是 rs1(不經過 ALU,路徑短)
sc_ok 在 EXEC 拍直接決定 o_wstrb(store 致能):
o_wstrb = if op_store || (is_sc && sc_ok) ? store_strb : 4'd0;
這條比較在 o_wstrb 的路徑上,而 o_wstrb 又驅動整個 SoC 的 RAM 寫入——它是全設計最長的組合路徑之一。這也是為什麼 整個 SoC(含 8 KB RAM、UART、VGA)剛好能跑 12 MHz: 把比較挪到 ALU 輸出之後就會跌破 12 MHz(見 6.3 節)。
6.1.6 綜合規模#
用 cargo test board_speed(veryl-eda/src/tests.rs:699)可以在 自己機器上量到真實數字。典型的相對量級(以 LUT 數為標尺):
| 設計 | 大約 iCE40 資源 | 意義 |
|---|---|---|
| Blinky | 幾十個 LUT | 下限 |
| RiscvSoc(單週期 + ROM) | 數百 LUT | 教學用下限 |
| RvBoard(多週期 RV32IMA,RAM 在 block RAM) | 約千餘 LUT + 8 個 block RAM | 真正可用 |
單週期核心的邏輯深(ROM 組合讀 + 解碼 + ALU + dmem 寫), 但每指令 1 拍;多週期核心每指令 ≥ 2 拍,Fmax 卻能站上 12 MHz 而單週期不行。這正是「以拍數換頻率」最直觀的例子。
6.1.7 為什麼這樣設計#
- 同步 RAM 而非組合讀:iCE40 的 block RAM 只有同步讀; 想上 FPGA 就必須接受「位址進去、資料下一拍出來」。 多週期狀態機是順應硬體,不是為了賣弄。
- 立即數在 DECODE 拍解好存進
imm:EXEC 拍就不用重複 移位拼接邏輯(imm_d的組合樹只在 DECODE 用)。 - CSR 位址先轉 4 位元編號再選值:12 位元比較器 → 4 位元 多工器,比直接 12 位元多工器小很多(
rvsoc.veryl:209-233)。 - mtval 唯讀 0:規格允許(mtval 是 optional),省下保存 出錯位址的觸發器。
- mcycle / time 都讀 SoC 的 mtime:一個計時器,兩個名字, 且
mcycle的時間基準和中斷(mtimecmp)天然一致。
6.1.8 本章重點#
- 單週期
RiscvCore把整條路徑塞進一拍,資料冒險天然不存在, 是理解資料路徑的教材。 - 多週期
RvCore六狀態:FETCH / DECODE / EXEC / MEM / MULDIV / WFI; 每指令 2 拍起,load/AMO 3 拍,乘除 35 拍,trap 3 拍。 - 同步 RAM 意味著「取指要一拍」,所以最少 2 拍; EXEC → DECODE 的邊(下一指令讀取已送出)是 2 拍的關鍵。
- speculative fetch +
trap_fast/trap分層:投機讀取換簡單控制, 有副作用的動作全部被trap_fast閘住。 sc_ok的位址比較在 store 致能路徑上,是 12 MHz 的關鍵瓶頸。