2.3 RISC-V 組合語言實作#
學習目標#
- 認識 RISC-V 的暫存器檔案、慣例與基本指令類別。
- 理解 RISC-V 六種指令編碼格式(R/I/S/B/U/J),並能手動編碼一條指令。
- 能閱讀與撰寫基本的 RISC-V 組合語言:算術、記憶體存取、分支與函式呼叫。
- 透過翻譯 C 程式到組語,理解編譯器如何使用 ISA——銜接 2.2 節「複雜度交給編譯器」的 RISC 哲學。
2.3.1 RISC-V 的暫存器檔案#
RISC-V 提供整數暫存器 32 個,編號 x0 到 x31,每個 32 位元(RV32)或 64 位元(RV64)。x0 永遠是零——寫入無效,讀取得 0。這不是浪費一個暫存器,而是精心的設計:許多常見操作因此有了「免費指令」(見 2.3.3)。其餘暫存器依據呼叫慣例(calling convention)分配角色:
| 編號 | 別名 | 用途 | 呼叫時是否需保存 |
|---|---|---|---|
| x0 | zero | 常數 0 | — |
| x1 | ra | 函式返回位址 | 呼叫者保存 |
| x2 | sp | 堆疊指標 | 被呼叫者保存 |
| x5–x7, x28–x31 | t0–t6 | 暫存值 | 呼叫者保存 |
| x8 | s0/fp | 保存值 / 框架指標 | 被呼叫者保存 |
| x9, x18–x27 | s1–s11 | 保存值 | 被呼叫者保存 |
| x10–x17 | a0–a7 | 函式參數 / 返回值 | 呼叫者保存 |
「呼叫者保存」(caller-saved,t 系、a 系)表示跨函式呼叫後內容可能被破壞;「被呼叫者保存」(callee-saved,s 系)表示函式若要用它,必須先備份、返回前還原。這個分工讓編譯器能做出最省的暫存器配置——RISC 的「把複雜度交給編譯器」在此體現。
2.3.2 指令編碼格式:32 位元的精密佈局#
每條基礎指令固定 32 位元。RISC-V 定義六種格式,所有欄位位置經過精心安排,使解碼電路最小化:
| 格式 | 31–25 | 24–20 | 19–15 | 14–12 | 11–7 | 6–0 | 用途 |
|---|---|---|---|---|---|---|---|
| R | funct7 | rs2 | rs1 | funct3 | rd | opcode | 暫存器運算 |
| I | imm[11:0] | rs1 | funct3 | rd | opcode | 運算含立即值、載入、jalr | |
| S | imm[11:5] | rs2 | rs1 | funct3 | imm[4:0] | opcode | 儲存 |
| B | imm[12|10:5] | rs2 | rs1 | funct3 | imm[4:1|11] | opcode | 分支 |
| U | imm[31:12] | rd | opcode | lui, auipc | |||
| J | imm[20|10:1] | rd | imm[19:12] | jal |
關鍵觀察:
- opcode(7 位元)決定指令大類;
funct3/funct7再細分小類。 - rs1、rs2(各 5 位元)永遠在同一位置——解碼器不需要看 opcode 就能先讀暫存器,縮短關鍵路徑。
- rd(5 位元)也固定,對應 2.1 節 ALU 之後「寫回哪個暫存器」的 MUX 控制訊號。
- 立即值欄位看起來零亂(尤其 B/J 格式),但這是刻意安排:把立即值的各個位元旋轉到固定位置,使符號擴充電路(第 11 位元的複製)只需接少數幾條線,硬體大幅簡化。這是「ISA 為硬體讓步」的教科書級範例。
手動編碼範例:add x10, x11, x12(即 add a0, a1, a2)。 查表:opcode = 0110011,funct3 = 000,funct7 = 0000000,rd = 10 = 01010,rs1 = 11 = 01011,rs2 = 12 = 01100:
funct7 rs2 rs1 funct3 rd opcode
0000000 01100 01011 000 01010 0110011 → 0x00C58533
2.3.3 核心整數指令(RV32I)#
RISC-V 基礎指令集僅 47 條。本節列出最常用的部分,並特別注意「用 x0 變把戲」之處:
算術(R 型): add rd, rs1, rs2 sub rd, rs1, rs2
sll/srl/sra rd, rs1, rs2 (位移)
slt rd, rs1, rs2 (小於則設 1)
邏輯: and / or / xor rd, rs1, rs2
立即值版(I 型): addi rd, rs1, imm andi / ori / xori rd, rs1, imm
記憶體: lw rd, offset(rs1) (載入字組)
sw rs2, offset(rs1) (儲存字組)
高位立即值: lui rd, imm20 (rd = imm20 << 12)
auipc rd, imm20 (rd = pc + imm20 << 12)
分支: beq / bne / blt / bge / bltu / bgeu rs1, rs2, label
無條件跳躍: jal rd, label (跳躍並存返回位址)
jalr rd, offset(rs1) (間接跳躍)
注意三個精巧之處:
- 沒有
mov指令:搬移寫成addi rd, rs, 0。 - 沒有
neg指令:取負寫成sub rd, x0, rs(二補數: \(0 - x = -x\) )。 - 沒有
nop指令:空操作是addi x0, x0, 0。
這些「偽指令(pseudo-instructions)」讓組語好讀,組譯器自動翻譯成基礎指令。比較 sub 與 addi:減法必須保留 R 型(硬體),減常數則走 I 型(編譯器常用)——兩者編碼格式不同,但 2.1 節的 ALU 硬體可以共用。
2.3.4 資料路徑全景:指令如何被執行#
把本章知識接起來。一條指令的執行經過五個階段(此處為單週期視角,第 4 章將管線化):
flowchart LR
IF["1. 取指 IF<br/>PC → 指令記憶體"] --> ID["2. 解碼 ID<br/>暫存器檔案讀取<br/>(rs1, rs2 固定位置!)"]
ID --> EX["3. 執行 EX<br/>ALU (加法/位移/比較)<br/>分支位址計算"]
EX --> ME["4. 記憶體 MEM<br/>載入/儲存"]
ME --> WB["5. 寫回 WB<br/>結果 → rd<br/>(MUX 選擇來源)"]
WB -->|"PC ← PC+4 或<br/>分支目標"| IF控制訊號正對應本章所學:RegWrite(暫存器寫入致能)、ALUSrc(I 型指令第二運算元選立即值——一個 MUX)、MemtoReg(寫回資料選 ALU 結果或記憶體——另一個 MUX)、Branch/Jump(PC 更新來源的 MUX)。指令編碼格式中固定的 rs1/rs2/rd 位置,正是為了讓 ID 階段能與解碼並行。
2.3.5 實作一:C 函式翻譯成 RISC-V 組語#
考慮 C 函式:
int add3(int a, int b, int c) {
return a + b + c;
}
編譯結果(a, b, c 依慣例在 a0, a1, a2):
add3:
add a0, a0, a1 # a0 = a + b
add a0, a0, a2 # a0 = a + b + c
ret # = jalr x0, 0(ra),跳回 ra 並不存返回位址
兩條算術、一條返回。注意返回值依慣例放在 a0。
再來一個含記憶體與分支的例子——計算陣列總和:
int sum(int *array, int n) {
int total = 0;
for (int i = 0; i < n; i++)
total += array[i];
return total;
}
# a0 = array 位址, a1 = n
sum:
li t0, 0 # total = 0 (偽指令: addi t0, x0, 0)
li t1, 0 # i = 0
loop:
bge t1, a1, done # if (i >= n) 跳出迴圈
slli t2, t1, 2 # t2 = i * 4 (每個 int 4 位元組;位移比乘法快)
add t2, a0, t2 # t2 = &array[i]
lw t3, 0(t2) # t3 = array[i]
add t0, t0, t3 # total += array[i]
addi t1, t1, 1 # i++
j loop # (偽指令: jal x0, loop)
done:
mv a0, t0 # (偽指令: addi a0, t0, 0)
ret
請逐行對照編碼格式:slli 是 I 型(位移量在 imm 欄)、lw 是 I 型(位移 0)、bge 是 B 型(13 位元立即值)。「乘以 4」編譯成左移 2 位而非乘法指令,是編譯器典型的最佳化——乘 4 在位元觀點就是左移兩位,這再次呼應 2.1 節的二進位本質。
2.3.6 實作二:函式呼叫與堆疊#
遞迴函式需要堆疊。以階乘為例:
int fact(int n) {
if (n < 1) return 1;
return n * fact(n - 1);
}
fact:
blt a0, a1, ... # 需與 1 比較:先看 n < 1
li t0, 1
blt a0, t0, base # if (n < 1) goto base
addi sp, sp, -16 # 開堆疊框架(16 位元組對齊)
sw ra, 12(sp) # 備份返回位址(ra 是 caller-saved,遞迴會覆蓋)
sw s0, 8(sp)
mv s0, a0 # 保存 n
addi a0, a0, -1 # n - 1
jal ra, fact # 遞迴呼叫
mul a0, a0, s0 # n * fact(n-1) (M 擴充)
lw ra, 12(sp) # 還原
lw s0, 8(sp)
addi sp, sp, 16 # 釋放框架
ret
base:
li a0, 1
ret
要點:sp 必須 16 位元組對齊;ra 在遞迴呼叫前必須存入堆疊,否則第二次呼叫會覆蓋返回位址;s0 是 callee-saved,若使用必須還原。這正是 2.3.1 慣例表的實戰意義。
2.3.7 上手工具鏈#
本書使用兩種方式實際執行程式:
- RARS(RISC-V Assembler and Runtime Simulator):圖形化模擬器,適合教學,可觀察每個暫存器與記憶體。
- 真實工具鏈:
riscv64-unknown-elf-gcc編譯、qemu-riscv64執行:
riscv64-unknown-elf-gcc -march=rv64i -mabi=lp64 -O1 -S sum.c # 產生組語
riscv64-unknown-elf-objdump -d a.out # 反組譯對照
qemu-riscv64 ./a.out # 模擬執行
建議讀者用 -O1 與 -O3 各編譯一次第 2.3.5 節的 sum,觀察編譯器如何做迴圈展開與強度削減——你會親眼看到 2.2 節「RISC 把複雜度交給編譯器」的具體樣貌。
本章小結#
- RISC-V 有 32 個整數暫存器,
x0恆為零;呼叫慣例將暫存器分為 caller-saved 與 callee-saved,是函式呼叫正確性的基礎。 - 六種編碼格式(R/I/S/B/U/J)共享固定的 rs1/rs2/rd 與 opcode 位置,使解碼器最小化;立即值位元的「旋轉」是犧牲可讀性換取硬體簡化的設計。
- 許多直覺上「該有」的指令(mov、neg、nop)其實都是 x0 與基礎指令的偽裝——指令越少、硬體越簡、管線越順。
- C 到組語的翻譯揭示了 ISA 的真實用途:編譯器是 ISA 的主要客戶,位移取代乘法、暫存器配置、堆疊框架管理都由編譯器決策。
- 資料路徑(IF→ID→EX→MEM→WB)中的每個 MUX 對應一個控制訊號,本章的組語知識與 2.1 的邏輯電路、2.2 的 ISA 哲學在此合流。
習題#
- 將
sub x5, x6, x0翻譯成等價的偽指令,並寫出它的 R 型編碼(32 位元二進位與十六進位)。 - 手動編碼
lw x11, 8(x2):查 opcode(0000011)、funct3(010),寫出 I 型各位元欄位與最終 32 位元值。 - 將下列 C 迴圈翻譯成 RISC-V 組語: 並指出哪些行是偽指令、對應哪條基礎指令。
for (int i = 0; i < 100; i++) a[i] = a[i] * 2; // a 為 int 陣列,位址在 a0 - 在 2.3.6 的
fact中,若刪去sw ra, 12(sp)與lw ra, 12(sp)會發生什麼事?請以fact(3)的呼叫序列具體說明。 - 為什麼 RISC-V 分支立即值(B 格式)的位元要交錯排列?請說明這種安排如何簡化硬體,並對照 2.1 節「布林代數化簡換取電路簡化」的論點。
- 用 RARS 執行 2.3.5 的
sum,輸入陣列{5, -3, 12, 0, 7},追蹤每迴圈迭代的t0, t1, t2, t3值,並畫出前三迭代的暫存器變化表。