為什麼要看模擬器#
理解 CPU 內部運作最直接的方式,是讀一份簡單的處理器模擬器原始碼。本章以開源 RISC-V emulator rv32emu 為對照,先看處理器是怎麼一步步執行組合語言,再從中導出指令管線化(Instruction Pipeline)的概念,以及它帶來的挑戰。
處理器的基本工作流程#
rv32emu 的核心流程大致如下:
- IO、記憶體、虛擬機初始化。
- 進入主迴圈
run()或run_and_trace(),根據設定決定每個 cycle 要執行多少個指令,呼叫rv_step()。 rv_step()從 PC(Program Counter)指向的位置取出指令,判斷類型(load、jump、store、branch⋯)後呼叫對應的op()handler。- handler 把指令解碼、執行、寫回後,PC 往前推進。
op() 預先定義了 RV32I 各類指令的 opcode 對應表(前 5 碼):
static const opcode_t opcodes[] = {
// 000 001 010 011 100 101 110 111
op_load, op_load_fp, NULL, op_misc_mem, op_op_imm, op_auipc, NULL, NULL, // 00
op_store, op_store_fp, NULL, op_amo, op_op, op_lui, NULL, NULL, // 01
op_madd, op_msub, op_nmsub, op_nmadd, op_fp, NULL, NULL, NULL, // 10
op_branch, op_jalr, NULL, op_jal, op_system, NULL, NULL, NULL, // 11
};只定義前 5 碼是因為 RV32I opcode 的後兩碼都是固定的(xxxxx11)。可以在 rv_step() 中看到指令的預處理:
// standard uncompressed instruction
if ((inst & 3) == 3) {
const uint32_t index = (inst & INST_6_2) >> 2;
...
}判斷 inst 是否屬於 RV32I 指令(末兩碼是否為 11),如果是的話就將末兩碼移除並做 right shift。
上述原始碼大量使用 bitwise 操作,再次凸顯位元操作在系統程式中的重要性。
handler 的實際樣貌#
以 op_op_imm() 為例(處理 I-type 整數立即數運算):
static bool op_op_imm(struct riscv_t *rv, uint32_t inst) {
// i-type decode
const int32_t imm = dec_itype_imm(inst);
const uint32_t rd = dec_rd(inst);
const uint32_t rs1 = dec_rs1(inst);
const uint32_t funct3 = dec_funct3(inst);
switch (funct3) {
case 0: // ADDI
rv->X[rd] = (int32_t)(rv->X[rs1]) + imm;
break;
// ...
case 7: // ANDI
rv->X[rd] = rv->X[rs1] & imm;
break;
default:
rv_except_illegal_inst(rv);
return false;
}
rv->PC += 4; // step over instruction
if (rd == rv_reg_zero) // enforce zero register
rv->X[rv_reg_zero] = 0;
return true;
}handler 會把傳入指令解碼成四塊:
funct3:決定要做哪一種操作(ADDI、SLLI、ORI⋯)imm:立即數rs1:來源暫存器rd:目標暫存器
以 ADDI 為例:常數部分為 sign-extended 12-bit,會將 12-bit 做 sign-extension 成 32-bit 後,再與 rs1 暫存器做加法運算,將結果寫入 rd 暫存器。addi rd, rs1, 0 可以被當成 mov 指令使用。
對應到 RISC-V 的經典四階段#
從上面的程式碼,我們可以看到處理器的基本行為:
- Fetch:從 PC 指向的記憶體位置取出指令
- Decode:解碼出 opcode、funct、暫存器與立即數
- Execute:在 ALU 中算出結果
- Write-back:把結果寫回暫存器,PC 推進
什麼是指令管線化?#
rv32emu 是逐步處理 Fetch ➡️ Decode ➡️ Execute ➡️ Write-back。假設每個階段耗費 1ms,則一個指令需要 4ms,10 個指令就需要 40ms。問題是:當其中一個階段在執行時,其他電路都是閒置的,這未免太浪費了。
**指令管線化(pipeline)**就是為了解決這個問題:以指令管線化技術實現,第一個指令仍會耗費 4ms,但之後每 1ms 就能完成一條指令。在一個 5 級管線中,當 Clock Cycle = 2 時,指令 1 已經進入解碼階段(ID),而 Fetch 電路也沒閒著,已經準備將指令 2 從記憶體讀進處理器了。
管線越長越好嗎?#
並非如此。延長管線會帶來幾個問題:
- 週期同步問題:實際上各階段的耗時並不相等。時鐘週期需要以最慢的 stage 為基準,若管線設計過長,反而會因為過長的等待時間造成反效果。
- 電路體積加大:將電路拆成多塊以實現管線,需在每個階層放上大量暫存器以保存前一層的輸出。暫存器在邏輯電路中是用正反器(flip-flop)實現的,越長的管線就會有越多正反器,造成電路體積與廢熱問題。
- 指令延遲問題:使用大量暫存器保存前一階段結果也會間接造成單一指令完成時間(latency)增加。
- 分支預測問題:屬於 Control Hazards 的範疇。一般情況下,當一個指令完成後 PC 會 += 4。但實際上還會有
jump、branch等分支指令。若指令 1 進入 Execute 階段才發現要跳轉,指令 2、3 已經分別在 Decode 和 Fetch 了——這時必須清空管線並把正確的指令重新填入。清除管線是十分浪費效能的(做白工)。隨著管線加長,分支預測就成了關鍵議題。
關於 Hazards 與分支預測,下一章會有更完整的探討。
小結#
- 從
rv32emu的程式碼可以清楚對應到 Fetch / Decode / Execute / Write-back 四階段。 - 管線化讓多個指令可以「重疊執行」,吞吐量大幅提升。
- 但管線越長,單一指令延遲、電路體積、分支預測代價也越大,必須做出取捨。
原文出處#
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10264836