跳轉到

2.3 組譯與機器碼載入測試#

學習目標#

  1. 安裝並使用 riscv-gnu-toolchain,把組合語言編譯成 RV32I 機器碼。
  2. 使用 objdump 檢視反組譯結果,對照機器碼與 2.1 節的編碼格式。
  3. 把機器碼轉成 Verilog $readmemh 可用的十六進位檔,載入指令記憶體。
  4. 撰寫 testbench,驗證暫存器堆最終內容是否符合預期。
  5. 使用 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

注意事項:

  1. 路徑問題:$readmemh 的相對路徑以模擬器啟動目錄為準。用 Icarus Verilog 時建議在專案根目錄執行,或在檔名前加 ../sim/ 等。Icarus 可用 -grelative-include 或直接把 hex 與編譯輸出放同目錄。
  2. 未初始化位址:若程式比記憶體短,其餘位址是 x,PC 跑進去會讓模擬器輸出全 x。可在 $readmemh 前先 initial for (i=0;i<256;i=i+1) mem[i]=32'h00000013;(全部填 nop,即 addi x0,x0,0),確保跑飛了也不會污染模擬結果。
  3. 模擬器差異: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 型立即值的目標位址計算。

建議的對照工作流:

  1. 在 RARS/Venus 執行組語,記下每個關注暫存器的最終值(本例:x5=55, x6=11, x10=55)。
  2. 用工具鏈組譯同一份 .s,產生 hex 載入自己的 CPU,testbench 檢查相同暫存器的相同值。
  3. 若兩者不一致,用 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 行為對照可快速二分問題來源。

實作題#

  1. 將 2.3.2 的程式改為計算 1+2+…+100,用工具鏈重新組譯,估算所需模擬時脈週期數,並修改 testbench 以 halt 偵測法驗證 x5 = 5050。
  2. 寫一個 Makefile,包含 assemble(產生 hex)、sim(編譯並執行 testbench)、wave(開啟 gtkwave)三個 target。
  3. 撰寫一段使用 lui 與 sw 的程式,把 0xDEADBEEF 存到位址 0x100 的資料記憶體,再 lw 回來存入 x20,驗證你的 dmem 位元組寫入邏輯。
  4. 在 Venus 上手動單步執行 jal x1, done 與 beq x1, x2, -8,記下每步的 PC;再對照你的 CPU 波形中 PC 的變化,若有差異請指出是立即值解碼還是目標計算的問題。
  5. 把 imem.v 改為參數化深度(parameter DEPTH = 256),並讓 $readmemh 失敗時(找不到檔案)印出錯誤訊息並中止模擬,避免「hex 沒載入卻跑出奇怪的錯誤」的除錯陷阱。