2.3 組譯與機器碼載入測試#
學習目標#
- 安裝並使用 riscv-gnu-toolchain,把組合語言編譯成 RV32I 機器碼。
- 使用
objdump檢視反組譯結果,對照機器碼與 2.1 節的編碼格式。 - 把機器碼轉成 Verilog
$readmemh可用的十六進位檔,載入指令記憶體。 - 撰寫 testbench,驗證暫存器堆最終內容是否符合預期。
- 使用 Venus / RARS 模擬器交叉驗證程式語意。
2.3.1 工具鏈安裝與組譯流程#
完整的軟體流程是:組合語言(.s)→ 組譯器(riscv64-unknown-elf-as)→ 目標檔(.o)→ 連結器(ld)→ ELF 執行檔 → objcopy 轉 bin/hex → $readmemh 載入。
macOS 可用 Homebrew 安裝:
brew tap riscv-software-src/riscv
brew install riscv-tools
Linux 建議直接下載預編譯版,或自行編譯:
git clone https://github.com/riscv/riscv-gnu-toolchain
cd riscv-gnu-toolchain
./configure --prefix=/opt/riscv --with-arch=rv32i --with-abi=ilp32
make
安裝完成後確認工具存在:
which riscv64-unknown-elf-as riscv64-unknown-elf-ld riscv64-unknown-elf-objdump
2.3.2 測試程式:計算 1+2+…+10#
寫一個涵蓋多種指令格式的測試程式,目的在「每種編碼格式至少測到一條」:
# test_add.s : sum = 1+2+...+10 存到 x10 (a0)
.section .text
.globl _start
_start:
addi x5, x0, 0 # sum = 0
addi x6, x0, 1 # i = 1
addi x7, x0, 10 # limit = 10
loop:
bgt x6, x7, done # if i > 10 goto done
add x5, x5, x6 # sum += i
addi x6, x6, 1 # i++
jal x0, loop # goto loop
done:
addi x10, x5, 0 # a0 = sum
halt:
beq x0, x0, halt # 自我迴圈(等待 testbench 停止)
組譯與連結(假設 I-Mem 位址從 0 開始):
riscv64-unknown-elf-as -march=rv32i -mabi=ilp32 -o test_add.o test_add.s
riscv64-unknown-elf-ld -m elf32lriscv -Ttext=0x0 -o test_add.elf test_add.o
riscv64-unknown-elf-objdump -d test_add.elf
objdump -d 的輸出節錄如下(位址、機器碼、組語三欄並列):
00000000 <_start>:
0: 00000293 li t0,0 # addi x5,x0,0
4: 00100313 li t1,1 # addi x6,x0,1
8: 00a00393 li t2,10 # addi x7,x0,10
0000000c <loop>:
c: 02c34863 blt t2,t1,34 <done>
10: 006282b3 add t0,t0,t1
14: 00130313 addi t1,t1,1
18: ff5ff06f j c <loop> # jal x0,loop
0000001c <done>:
1c: 00028513 mv a0,t0
20: 0000006f j 20 <halt>
對照 2.1 節驗證一條:addi x5, x0, 0 的機器碼是 0x00000293。拆成二進位:imm[11:0]=0、rs1=00000、funct3=000、rd=00101(=5)、opcode=0010011,最低位元組 0x93 正是 0010011 補上高兩位 0 的結果,與 I 型格式完全吻合。jal x0, loop 的 ff5ff06f 則是 J 型:imm[20]=1(負位移),對應向後跳到 0x0C。
2.3.3 產生 Verilog 可載入的 hex 檔#
三種常見作法:
方法一:objcopy + 腳本轉換(最通用)
riscv64-unknown-elf-objcopy -O binary -j .text test_add.elf test_add.bin
xxd -e -c 4 -g 4 test_add.bin | awk '{print $4}' > test_add.hex
xxd 以小端 4 位元組一組輸出,awk 取出每組即為一個字組的 hex。
方法二:objdump 手工擷取:objdump -d 每行的機器碼欄位剪下來即可,適合驗證小程式。
方法三:直接手寫 hex:小測試程式可以手工編碼,例如只用 3 條指令的煙霧測試:
// test_smoke.hex
00A00293 // addi x5, x0, 10
00500313 // addi x6, x0, 5
005282B3 // add x5, x5, x6 -> x5 = 15
2.3.4 指令記憶體與 $readmemh#
修改 2.2 節的 imem.v,讓它載入外部檔案:
// imem.v
module imem (
input wire [31:0] addr, // 位元組位址
output wire [31:0] instr
);
reg [31:0] mem [0:255]; // 256 條指令 = 1KB
initial $readmemh("test_add.hex", mem);
assign instr = mem[addr[31:2]];
endmodule
注意事項:
- 路徑問題:
$readmemh的相對路徑以模擬器啟動目錄為準。用 Icarus Verilog 時建議在專案根目錄執行,或在檔名前加../sim/等。Icarus 可用-grelative-include或直接把 hex 與編譯輸出放同目錄。 - 未初始化位址:若程式比記憶體短,其餘位址是
x,PC 跑進去會讓模擬器輸出全x。可在$readmemh前先initial for (i=0;i<256;i=i+1) mem[i]=32'h00000013;(全部填nop,即addi x0,x0,0),確保跑飛了也不會污染模擬結果。 - 模擬器差異:Vivado 與 Icarus 對
$readmemh的位址範圍語法支援略有差異,可加第二、三個參數指定起始與結束位址。
2.3.5 Testbench:驗證暫存器內容#
// tb_cpu.v
`timescale 1ns/1ps
module tb_cpu;
reg clk = 0, rst = 1;
cpu_top dut (.clk(clk), .rst(rst));
always #5 clk = ~clk; // 100 MHz
initial begin
$dumpfile("tb_cpu.vcd");
$dumpvars(0, tb_cpu);
#12 rst = 0;
// 給足夠週期:迴圈約 3+10*(4)+3 ≈ 46 條指令
#1500;
$display("x5 (sum) = %0d (expect 55)", dut.u_rf.regs[5]);
$display("x6 (i) = %0d (expect 11)", dut.u_rf.regs[6]);
$display("x10(a0) = %0d (expect 55)", dut.u_rf.regs[10]);
if (dut.u_rf.regs[5] == 32'd55 &&
dut.u_rf.regs[6] == 32'd11 &&
dut.u_rf.regs[10] == 32'd55)
$display("PASS");
else
$display("FAIL");
$finish;
end
endmodule
執行(Icarus Verilog):
iverilog -o simv tb_cpu.v cpu_top.v pc.v imem.v dmem.v \
regfile.v alu.v immgen.v control.v aludec.v branch_unit.v
./simv
預期輸出:
x5 (sum) = 55 (expect 55)
x6 (i) = 11 (expect 11)
x10(a0) = 55 (expect 55)
PASS
若結果錯誤,除錯流程建議:(1) 先看波形 gtkwave tb_cpu.vcd,確認前幾條指令的 PC 推進與 regwrite 訊號;(2) 單獨跑 immgen 與 control 的單元測試;(3) 檢查分支是否在正確週期發生(常見錯誤是分支目標用了 pc+4+imm 而不是 pc+imm)。
另一個更穩健的驗證寫法是不依賴等待時間,直接在 halt 迴圈的位址上偵測:
// 等 PC 進入 halt 迴圈(位址 0x20)再檢查
always @(posedge clk)
if (dut.pc == 32'h20) begin
$display("x5 = %0d", dut.u_rf.regs[5]);
$finish;
end
這樣無論程式多長、迴圈跑幾次,testbench 都會在正確時機停止。
2.3.6 與 Venus / RARS 模擬器對照#
在把機器碼餵進自己的 CPU 之前,先在 ISA 模擬器上確認程式語意正確,可以把問題二分為「程式錯」或「硬體錯」。
RARS(RISC-V Assembler and Runtime Simulator,Java 視窗程式):載入 test_add.s,按 Assemble → Run,觀察 Register 面板的 x5/x10。它會模擬完整的暫存器堆與記憶體,也支援 ecall 印出結果。
Venus(線上版):貼上組語、Assemble & Simulate,逐步單步執行並觀察每條指令後的暫存器值。特別適合驗證 B/J 型立即值的目標位址計算。
建議的對照工作流:
- 在 RARS/Venus 執行組語,記下每個關注暫存器的最終值(本例:x5=55, x6=11, x10=55)。
- 用工具鏈組譯同一份
.s,產生 hex 載入自己的 CPU,testbench 檢查相同暫存器的相同值。 - 若兩者不一致,用
objdump逐條對照 Venus 的反組譯,找出第一條「PC 走向不同」的指令——那裡就是 bug。
常見的對照陷阱:
- RARS 預設允許偽指令(
li、mv),組譯結果可能多一條指令,導致分支位移不同;工具鏈-march=rv32i下也會展開偽指令,兩邊的實際機器碼要以objdump為準。 - RARS 的資料段與堆疊位址配置與我們的裸機
-Ttext=0x0不同,凡是使用絕對位址的程式(lui/auipc)要確認連結位址一致。 ecall在我們的 CPU 上沒有實作,測試程式應避免使用,改用「最終暫存器值」做驗收。
本章小結#
- 完整流程:
.s→as→.o→ld→.elf→objcopy/xxd→.hex→$readmemh→ CPU。 objdump -d是連結「編碼理論(2.1)」與「硬體實作(2.2)」的橋樑,逐位元驗證機器碼是基本功夫。- Testbench 以暫存器最終值為驗收標準,並建議用「halt 位址偵測」取代固定等待時間。
- Venus/RARS 提供語意黃金模型,將 ISA 行為與 RTL 行為對照可快速二分問題來源。
實作題#
- 將 2.3.2 的程式改為計算 1+2+…+100,用工具鏈重新組譯,估算所需模擬時脈週期數,並修改 testbench 以 halt 偵測法驗證 x5 = 5050。
- 寫一個 Makefile,包含
assemble(產生 hex)、sim(編譯並執行 testbench)、wave(開啟 gtkwave)三個 target。 - 撰寫一段使用
lui與sw的程式,把 0xDEADBEEF 存到位址 0x100 的資料記憶體,再lw回來存入 x20,驗證你的dmem位元組寫入邏輯。 - 在 Venus 上手動單步執行
jal x1, done與beq x1, x2, -8,記下每步的 PC;再對照你的 CPU 波形中 PC 的變化,若有差異請指出是立即值解碼還是目標計算的問題。 - 把
imem.v改為參數化深度(parameter DEPTH = 256),並讓$readmemh失敗時(找不到檔案)印出錯誤訊息並中止模擬,避免「hex 沒載入卻跑出奇怪的錯誤」的除錯陷阱。