為什麼要看模擬器#

理解 CPU 內部運作最直接的方式,是讀一份簡單的處理器模擬器原始碼。本章以開源 RISC-V emulator rv32emu 為對照,先看處理器是怎麼一步步執行組合語言,再從中導出指令管線化(Instruction Pipeline)的概念,以及它帶來的挑戰。

處理器的基本工作流程#

rv32emu 的核心流程大致如下:

  1. IO、記憶體、虛擬機初始化。
  2. 進入主迴圈 run()run_and_trace(),根據設定決定每個 cycle 要執行多少個指令,呼叫 rv_step()
  3. rv_step() 從 PC(Program Counter)指向的位置取出指令,判斷類型(load、jump、store、branch⋯)後呼叫對應的 op() handler。
  4. 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。但實際上還會有 jumpbranch 等分支指令。若指令 1 進入 Execute 階段才發現要跳轉,指令 2、3 已經分別在 Decode 和 Fetch 了——這時必須清空管線並把正確的指令重新填入。清除管線是十分浪費效能的(做白工)。隨著管線加長,分支預測就成了關鍵議題。

關於 Hazards 與分支預測,下一章會有更完整的探討。

小結#

  • rv32emu 的程式碼可以清楚對應到 Fetch / Decode / Execute / Write-back 四階段。
  • 管線化讓多個指令可以「重疊執行」,吞吐量大幅提升。
  • 但管線越長,單一指令延遲、電路體積、分支預測代價也越大,必須做出取捨。

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10264836