基於五階段流水線的RISC-V CPU模擬器實現


RISC-V是源自Berkeley的開源體系結構和指令集標准。這個模擬器實現的是RISC-V Specification 2.2中所規定RV64I指令集,基於標准的五階段流水線,並且實現了分支預測模塊和虛擬內存模擬。實現一個完整的CPU模擬器可以很好地鍛煉系統編程能力,並且加深對體系結構有關知識的理解。在開始實現前,應當閱讀並深入理解Computer Systems: A Programmer's Perspective中的第四章,或者Computer Organizaton and Design: Hardware/Software Interface中的有關章節。

本模擬器的代碼在GitHub上:https://github.com/hehao98/RISCV-Simulator

一、開發環境

1.1 RISC-V環境的安裝與配置

首先,必須搭建RISC-V相關的編譯、運行和測試環境。簡便起見,本次實驗全部基於RISC-V 64I指令集,參考的指令集標准是RISC-V Specification 2.2。為了配置環境,執行了如下步驟。

  1. 從GitHub上下載了riscv-tools,從中針對Linux平台配置,編譯和安裝了riscv-gnu-toolchain
  2. 為了使用官方模擬器作為參照,從GitHub上下載、編譯和安裝了riscv-qemu

需要特別注意的是,在編譯riscv-gnu-toolchain時,必須指定工具鏈和C語言標准庫所使用的指令集為RV64I,否則在編譯的時候編譯器會使用RV64C、RV64D等擴展指令集。即使設置編譯器編譯時只使用RV64I指令集,編譯器也會鏈接進使用擴展指令集的標准庫函數。因此,為了獲得只使用RV64I標准指令集的ELF程序,必須在riscv-gnu-toolchain中采用如下選項重新編譯

mkdir build; cd build
../configure --with-arch=rv64i --prefix=/path/to/riscv64i
make -j$(nproc)

在編譯時,使用-march=rv64i讓編譯器針對RV64I標准指令集生成ELF程序。

riscv64-unknown-elf-gcc -march=rv64i test/arithmetic.c test/lib.c -o riscv-elf/arithmetic.riscv

1.2 使用的測試程序和測試方法

對一個體系結構模擬器進行測試有一定難度,主要是由於指令數眾多、代碼龐大、從而對模擬器代碼進行100%覆蓋率的測試比較困難。因此,為了便於測試,本模擬器使用了一組由簡單到復雜的測試程序,並且實現了單步調試和打印CPU狀態的接口。此外,為了便於進行調試和性能分析,還實現了記錄執行歷史的模塊,在程序出錯時可以獲得完整的指令執行歷史和內存快照,便於對出錯進行分析。

為了對RISC-V模擬器進行測試,編寫了如下程序(見test/文件夾)。比較復雜的是快速排序、矩陣乘法和求Ackermann函數三個。其中,快速排序和矩陣乘法涉及比較多的指令和數據,求解Ackermann函數涉及非常深的遞歸調用。

lib.c             # 自定義的系統調用實現
helloworld.c      # 最簡單的程序
test_arithmetic.c # 對運算指令的測試
test_branch.c     # 對基本分支的測試
test_syscall.c    # 對系統調用的測試
quicksort.c       # 快速排序
matrixmulti.c     # 矩陣乘法
ackermann.c       # 求解Ackermann函數

所有程序編譯后得到的二進制程序和反編譯得到的匯編代碼均保存在riscv-elfs/文件夾中。

二、設計概述

2.1 開發環境

我測試的模擬器運行環境為Mac OS X,使用的編程語言為C++ 11,構建環境為CMake,編譯器為Apple Clang 10.0.0,編譯使用的Flag為-O2 -Wall。開發使用的工具為VS Code。不過,模擬器代碼盡量避免了使用標准庫以外的平台相關功能,所以應該也能在其他平台和編譯器上編譯運行。

2.2 設計考量

首先,模擬器的運行必須是健壯的。具體地說,必須能夠處理各種非法輸入,包括不正常的訪存,不正常的ELF文件,非法指令,非法的訪存地址等等。編寫細致全面的錯誤處理不僅有助於鍛煉系統編程能力,也有助於在早期發現細微的程序錯誤。

其次,模擬器的實現必須簡單、易於理解和易於調試。此模擬器是一個課程項目級別的模擬器,允許的實現時間有限,因此代碼實現必須簡單,調試系統必須完備,從而盡可能地減少編寫程序和調試程序所需要的時間。

此外,模擬器實現的主要目的是能夠被用於簡單性能評測,因此必須能夠盡可能貼近流水線硬件,並可以擴展出分支預測和緩存模擬等各種功能,便於在真正的程序上實驗和評測流水線的性能,以及各種分支預測和緩存模擬策略。

本次模擬器的實現並不是要做一個成熟可用的工業級體系結構模擬器,也就是說,本次模擬器的實現並不注重性能和功能的全面性。在性能上,對於極端復雜和龐大的程序,模擬器的程序會執行緩慢,也有可能會消耗過多內存,對於模擬器本身的性能優化不在本實驗的范圍內。在功能上,為了實現簡單,本模擬器使用自定義的系統調用,而不是兼容Linux的系統調用,因此,此模擬器只能運行專門為此編譯的RISC-V程序(程序源碼參見test/文件夾)。

2.3 編譯與運行

編譯方法與一個典型的CMake項目一樣,在編譯之前必須先安裝CMake。在Linux或者Mac OS X系統上可以采用如下命令

mkdir build
cd build
cmake ..
make

編譯會得到可執行程序Simulator。該模擬器是一個命令行程序,在命令行上的執行方式是

./Simulator riscv-elf-file-name [-v] [-s] [-d] [-b param]
Parameters: 
        [-v] verbose output 
        [-s] single step
        [-d] dump memory and register trace to dump.txt
        [-b param] branch perdiction strategy, accepted param AT, NT, BTFNT, BPB

其中riscv-elf-file-name對應可執行的RISC-V ELF文件,比如riscv-elf/文件夾下的所有*.riscv文件。一個典型的運行流程和輸出如下

hehaodeMacBook-Pro:build hehao$ ./Simulator ../riscv-elf/ackermann.riscv
Ackermann(0,0) = 1
Ackermann(0,1) = 2
Ackermann(0,2) = 3
Ackermann(0,3) = 4
Ackermann(0,4) = 5
Ackermann(1,0) = 2
Ackermann(1,1) = 3
Ackermann(1,2) = 4
Ackermann(1,3) = 5
Ackermann(1,4) = 6
Ackermann(2,0) = 3
Ackermann(2,1) = 5
Ackermann(2,2) = 7
Ackermann(2,3) = 9
Ackermann(2,4) = 11
Ackermann(3,0) = 5
Ackermann(3,1) = 13
Ackermann(3,2) = 29
Ackermann(3,3) = 61
Ackermann(3,4) = 125
Program exit from an exit() system call
------------ STATISTICS -----------
Number of Instructions: 430754
Number of Cycles: 574548
Avg Cycles per Instrcution: 1.3338
Branch Perdiction Accuacy: 0.5045 (Strategy: Always Not Taken)
Number of Control Hazards: 48010
Number of Data Hazards: 279916
Number of Memory Hazards: 47774
-----------------------------------

在默認的設置下,一開始會首先打印執行的程序的輸出,然后會輸出一組關於CPU執行情況的統計數據。

如果要進行單步調試的話,可以使用-s-v參數

./Simulator ../riscv-elf/ackermann.riscv -s -v

得到的輸出如下

hehaodeMacBook-Pro:build hehao$ ./Simulator ../riscv-elf/ackermann.riscv -s -v
==========ELF Information==========
Type: ELF64
Encoding: Little Endian
ISA: RISC-V(0xf3)
Number of Sections: 19
ID      Name            Address Size
[0]                     0x0     0
[1]     .text           0x100b0 3668
[2]     .rodata         0x10f08 29
[3]     .eh_frame       0x10f28 4
[4]     .init_array     0x11000 8
[5]     .fini_array     0x11008 8
[6]     .data           0x11010 1864
[7]     .sdata          0x11758 24
[8]     .sbss           0x11770 8
[9]     .bss            0x11778 72
[10]    .comment        0x0     26
[11]    .debug_aranges  0x0     48
[12]    .debug_info     0x0     46
[13]    .debug_abbrev   0x0     20
[14]    .debug_line     0x0     222
[15]    .debug_str      0x0     267
[16]    .symtab         0x0     2616
[17]    .strtab         0x0     913
[18]    .shstrtab       0x0     172
Number of Segments: 2
ID      Flags   Address FSize   MSize
[0]     0x5     0x10000 3884    3884
[1]     0x6     0x11000 1904    1984
===================================
Memory Pages: 
0x0-0x400000:
  0x10000-0x11000
  0x11000-0x12000
Fetched instruction 0x00002197 at address 0x100b0
Decode: Bubble
Execute: Bubble
Memory Access: Bubble
WriteBack: Bubble
------------ CPU STATE ------------
PC: 0x100b4
zero: 0x00000000(0) ra: 0x00000000(0) sp: 0x80000000(2147483648) gp: 0x00000000(0) 
tp: 0x00000000(0) t0: 0x00000000(0) t1: 0x00000000(0) t2: 0x00000000(0) 
s0: 0x00000000(0) s1: 0x00000000(0) a0: 0x00000000(0) a1: 0x00000000(0) 
a2: 0x00000000(0) a3: 0x00000000(0) a4: 0x00000000(0) a5: 0x00000000(0) 
a6: 0x00000000(0) a7: 0x00000000(0) s2: 0x00000000(0) s3: 0x00000000(0) 
s4: 0x00000000(0) s5: 0x00000000(0) s6: 0x00000000(0) s7: 0x00000000(0) 
s8: 0x00000000(0) s9: 0x00000000(0) s10: 0x00000000(0) s11: 0x00000000(0) 
t3: 0x00000000(0) t4: 0x00000000(0) t5: 0x00000000(0) t6: 0x00000000(0) 
-----------------------------------
Type d to dump memory in dump.txt, press ENTER to continue: 

在單步調試中,可以輸入d來保存內存快照,使用ENTER前進到下一條指令。命令行顯示的信息包括ELF信息、流水線狀態和CPU寄存器狀態。

使用-v參數並重定向標准輸出可以得到關於流水線執行狀態和寄存器狀態的完整歷史。

此外,可以使用-b參數指定不同的分支預測策略,例如

./Simulator ../riscv-elf/ackermann.riscv -b AT
./Simulator ../riscv-elf/ackermann.riscv -b NT
./Simulator ../riscv-elf/ackermann.riscv -b BTFNT
./Simulator ../riscv-elf/ackermann.riscv -b BPB

其中,AT表示Always Taken,NT表示Not Taken,BTFNT表示Back Taken Forward Not Taken,BPB表示Branch Prediction Buffer。

2.4 代碼架構

模擬器代碼架構的概覽圖見上。模擬器的入口是Main.cpp,其中包含了解析參數、加載ELF文件、初始化模擬器的模塊,並在最后調用模擬器的simulate()函數進入模擬器的執行。除非模擬器執行出錯,否者simulate()函數理論上不會返回。

模擬器本身被設計成一個巨大的類,也就是代碼中的class Simulator(參見Simulator.hSimulator.cpp)。Simulator類中的數據包含了PC、通用寄存器、流水線寄存器、執行歷史記錄器、內存模塊和分支預測模塊,其中,由於內存模塊和分支預測模塊相對比較獨立,因此實現為獨立的兩個類MemoryManagerBranchPredictor

模擬器中最核心的函數是simulate()函數,這個函數對模擬器進行周期級模擬,每次模擬中,會執行fetch()decode()execute()accessMemory()writeBack()五個函數,每個函數會以上一個周期的流水線寄存器作為輸入,並輸出到下一個周期的流水線寄存器。在周期結束時,新的寄存器的內容會被拷貝到作為輸入的寄存器中。在執行過程中,每個函數都會處理有關數據、控制和內存訪問冒險的內容,並且在適當的地方記錄歷史信息。由於之間的交互關系比較復雜,因此在上圖中並沒有畫出。由於相關函數代碼過長,不便於在此貼出,因此關於實現的更多細節請參見src/Simulator.cpp

三、具體設計和實現

3.1 內存管理模塊MemoryManager

MemoryManager的功能是為模擬器提供一個簡單易使用的內存訪問接口,必須支持任意內存大小、內存地址的訪存,還要能檢測到非法內存地址訪問。事實上,這非常類似於操作系統中虛擬內存的機制。因此,MemoryManager的內部實現采用了類似x86體系結構中使用的二級頁表的機制。具體地說,將32位內存空間在邏輯上划分為大小為4KB(2^12)的頁,並且采用內存地址的前10位作為一級頁表的索引,緊接着10位作為二級頁表的索引,最后12位作為一個內存頁里的下標。

頁表結構可以如下聲明

uint8_t **memory[1024];

其中,memory指向一個長度為1024的一級頁表數組,memory[i]指向長度為1024的二級頁表數組,memory[i][j]指向具體的內存頁,memory[i][j][k]可以取出內存地址為(i<<22)|(j<<12)|k的一個字節。可以在需要的時候對memory進行動態內存分配和釋放。模擬器對memory的一個訪存過程的示例如下

uint8_t MemoryManager::getByte(uint32_t addr) {
  if (!this->isAddrExist(addr)) {
    dbgprintf("Byte read to invalid addr 0x%x!\n", addr);
    return false;
  }
  uint32_t i = this->getFirstEntryId(addr);
  uint32_t j = this->getSecondEntryId(addr);
  uint32_t k = this->getPageOffset(addr);
  return this->memory[i][j][k];
}

關於MemoryManager實現的更多信息,參見src/MemoryManager.cpp

3.2 可執行文件的裝載、初始化

本模擬器的可執行文件加載部分采用了GitHub上的開源庫ELFIO(https://github.com/serge1/ELFIO),由於這個庫只有頭文件,所以導入工程相當容易,相關頭文件在include/文件夾下。

使用這個庫進行ELF文件加載相當容易

// Read ELF file
ELFIO::elfio reader;
if (!reader.load(elfFile)) {
  fprintf(stderr, "Fail to load ELF file %s!\n", elfFile);
  return -1;
}

加載ELF文件進內存的代碼如下,直接按照ELF文件頭的信息將每個數據段拷貝到指定的內存位置即可,唯一需要注意的是文件內數據長度可能小於指定的內存長度,需要用0填充。值得一提的是本模擬器在設計時並未考慮支持32位以上的內存,因為內存占用如此之大的用戶程序是比較罕見的,在我們用的測試程序中不會出現這種情況。

void loadElfToMemory(ELFIO::elfio *reader, MemoryManager *memory) {
  ELFIO::Elf_Half seg_num = reader->segments.size();
  for (int i = 0; i < seg_num; ++i) {
    const ELFIO::segment *pseg = reader->segments[i];

    uint64_t fullmemsz = pseg->get_memory_size();
    uint64_t fulladdr = pseg->get_virtual_address();
    // Our 32bit simulator cannot handle this
    if (fulladdr + fullmemsz > 0xFFFFFFFF) {
      dbgprintf(
          "ELF address space larger than 32bit! Seg %d has max addr of 0x%lx\n",
          i, fulladdr + fullmemsz);
      exit(-1);
    }

    uint32_t filesz = pseg->get_file_size();
    uint32_t memsz = pseg->get_memory_size();
    uint32_t addr = (uint32_t)pseg->get_virtual_address();

    for (uint32_t p = addr; p < addr + memsz; ++p) {
      if (!memory->isPageExist(p)) {
        memory->addPage(p);
      }

      if (p < addr + filesz) {
        memory->setByte(p, pseg->get_data()[p - addr]);
      } else {
        memory->setByte(p, 0);
      }
    }
  }
}

最后,需要在模擬器初始化時手動設置PC的值。模擬器還需要很多其他的初始化操作,具體可以參考src/Main.cpp

simulator.pc = reader.get_entry();

3.3 指令語義的解析和控制信號的處理

本小節中涉及代碼由於普遍過長,且存在非常強的相互依賴,單獨貼出可能難以理解,因此不會在此直接貼出代碼,具體內容請參見src/Simulator.cpp

指令的取值過程參見Simulator::fetch()函數,由於RV64I指令集都是4字節定長,所以實現起來非常簡單。

指令的解碼過程參見Simulator::decode()函數,其中絕大多數內容都是對RISC-V Specification 2.2中規定的指令編碼的直接翻譯。在解碼過程中,為了便於調試,decode()函數會按照RISC-V匯編格式翻譯出指令字符串。此外,decode函數會模仿硬件實現在指令中抽象出op1op2dest等幾個共有的域。分支預測模塊會在解碼階段做出預測判斷。

指令的執行過程參見Simulator::execute()函數,這個函數簡單粗暴地根據指令類型直接執行相應的行為。在結尾,會根據當前指令和解碼階段的情況,檢測數據冒險、控制冒險和內存訪問冒險,並作出相應的操作。在這個階段,跳轉指令會得到是否跳轉的結果,並在預測錯誤的情況下在流水線寄存器中插入對應的Bubble。

指令的訪存過程參見Simulator::memoryAccess()函數,這個函數首先執行內存讀寫操作,並且檢測數據冒險和轉發數據。在檢測數據冒險時,既需要考慮到一般的數據冒險,也必須考慮到上個周期因為內存訪問冒險而流水線Stall的情況,此外,也必須考慮數據轉發的優先級,memoryAccess()作為后面的指令,數據轉發的優先級是低於execute()的,否則可能會出現較老的數據被轉發並覆蓋新數據的情況。

指令的寫回過程參見Simulator::writeBack()函數,這個函數將執行結果寫回寄存器,並且類似之前的情況處理相關的數據冒險。

流水線寄存器的控制信號設置如下,注意其中fReg表示的是下一個周期開始時,從取值階段傳輸到解碼階段的數據,以此類推。

出現的情況 fReg dReg eReg mReg
分支預測錯誤 Bubble Bubble Normal Normal
內存訪問冒險 Stall Stall Bubble Normal
預測跳轉 Bubble Normal Normal Normal

有一種情況需要特別說明,就是分支預測器的情況。在當前的模擬器設計中,由於到了解碼階段結束才得知跳轉指令的存在,因此如果預測跳轉的話必須向流水線中插入一個Bubble,才能確保取指階段取出的是跳轉后的指令。這不會增加分支預測錯誤的開銷,但是會使得預測正確的開銷多了一個周期。如果要改進這個設計的話,必須將分支預測模塊轉移到取指階段實現。

3.4 系統調用和庫函數接口的處理

本模擬器使用自定義的系統調用接口。系統調用的ecall指令會使用a0a7寄存器,其中a7寄存器保存的是系統調用號,a0寄存器保存的是系統調用參數,返回值會保存在a0寄存器中。為了能讓系統調用指令能被集成進當前的流水線,ecall指令只支持一個返回值和一個參數。所有系統調用的語義見下表。

系統調用名稱 系統調用號 參數 返回值
輸出字符串 0 字符串起始地址
輸出字符 1 字符的值
輸出數字 2 數字的值
退出程序 3
讀入字符 4 讀入的字符
讀入數字 5 讀入的數字

對應的系統調用接口如下

void print_d(int num);
void print_s(const char *str);
void print_c(char ch);
void exit_proc();
char read_char();
long long read_num();

具體的實現需要使用內聯匯編,請參考test/lib.c

3.5 性能計數相關模塊

在當前模擬器架構下,對於模擬器進行性能統計只需在代碼里適當的地方加入統計代碼即可。數據統計模塊的定義如下

struct History {
  uint32_t instCount;
  uint32_t cycleCount;
  uint32_t predictedBranch; // Number of branch that is predicted successfully
  uint32_t unpredictedBranch; // Number of branch that is not predicted successfully
  uint32_t dataHazardCount;
  uint32_t controlHazardCount;
  uint32_t memoryHazardCount;
  std::vector<std::string> instRecord;
  std::vector<std::string> regRecord;
  std::string memoryDump;
} history;

其中,最后三個數據項用於記載CPU的執行歷史,便於在調試的時候使用。為了防止模擬器占用過多內存,instRecordregRecord當內容多於100000條時會被清空,memoryDump只會在要求生成內存快照時被使用。

3.6 調試接口

由於對CPU模擬器的調試相對比較困難,CPU模擬器的調試接口和錯誤執行接口必須被非常小心地設計,以便於盡可能早地發現程序中的Bug。在當前模擬器的代碼中,存在大量對模擬器狀態和輸入值合法性的檢查,以便盡可能早地發現錯誤。Simulator類中存在專門的錯誤處理函數panic()

void Simulator::panic(const char *format, ...) {
  char buf[BUFSIZ];
  va_list args;
  va_start(args, format);
  vsprintf(buf, format, args);
  fprintf(stderr, "%s", buf);
  va_end(args);
  this->dumpHistory();
  fprintf(stderr, "Execution history and memory dump in dump.txt\n");
  exit(-1);
}

此外,模擬器還支持單步調試和verbose輸出的功能,使用-s-v參數即可開啟單步調試模式。使用-v參數並重定向標准輸出可以得到寄存器狀態和流水線狀態的完整執行歷史並在事后進行分析。一條典型的CPU執行狀態記錄如下

Fetched instruction 0x00000593 at address 0x100c4
Decoded instruction 0x40a60633 as sub a2,a2,a0
Execute: addi
  Forward Data a2 to Decode op1
Memory Access: addi
  Forward Data a0 to Decode op2
WriteBack: addi
------------ CPU STATE ------------
PC: 0x100c8
zero: 0x00000000(0) ra: 0x00000000(0) sp: 0x80000000(2147483648) gp: 0x00011f58(73560) 
tp: 0x00000000(0) t0: 0x00000000(0) t1: 0x00000000(0) t2: 0x00000000(0) 
s0: 0x00000000(0) s1: 0x00000000(0) a0: 0x00000000(0) a1: 0x00000000(0) 
a2: 0x00000000(0) a3: 0x00000000(0) a4: 0x00000000(0) a5: 0x00000000(0) 
a6: 0x00000000(0) a7: 0x00000000(0) s2: 0x00000000(0) s3: 0x00000000(0) 
s4: 0x00000000(0) s5: 0x00000000(0) s6: 0x00000000(0) s7: 0x00000000(0) 
s8: 0x00000000(0) s9: 0x00000000(0) s10: 0x00000000(0) s11: 0x00000000(0) 
t3: 0x00000000(0) t4: 0x00000000(0) t5: 0x00000000(0) t6: 0x00000000(0) 
-----------------------------------

3.7 實現中遇到的坑

在整個實現中,我在第一階段的單周期指令級模擬的實現並沒有遇到什么問題,但是流水線相關的模擬中,遇到了幾個相當微妙的錯誤。

  1. 一個根本的困難在於我們對流水線的模擬程序本質上還是線性執行的,並不能像硬件那樣多階段並行執行。因此,必須非常小心地設計五個階段的代碼的執行流和對數據結構的訪問,才能模擬出硬件的效果。
  2. 當多個階段發現數據冒險並向前轉發數據時,必須優先傳送更新的數據。在模擬器中,由於相關階段的執行順序是執行->訪存->寫回,因此會存在前面的階段向前轉發的數據被后面的階段的舊數據覆蓋的可能。對於這種情況,模擬器中必須加以特別的判定。
  3. 分支預測模塊應當在解碼階段根據預測結果修改PC的值,但是,如果這個跳轉指令是被錯誤取進來,並且應該在之后被Bubble的話怎么辦?必須想辦法恢復被修改的PC值,或者延遲寫入預測的PC值。
  4. 也是由於代碼是順序執行的,因此當執行階段發現訪存指令,而解碼階段的指令依賴訪存數據並導致內存冒險時,必須非常小心地設計整個執行過程和數據訪問流程,才能模擬出正確的結果。
  5. 用於系統調用的ecall指令也會導致數據冒險!並且產生數據冒險的條目,取決於這個系統調用的參數數量和其對應的寄存器!當前的系統調用會依賴的寄存器有a0a7兩個,因此剛好能作為op1op2塞入流水線,但是如果系統調用需要的參數更多,實現將會變得更為復雜。
  6. zero寄存器是一個相當獨特的存在,理論上他任何時候值應該都是0,所以進行數據轉發的時候必須處處特判零寄存器,如果向零寄存器里的值進行數據轉發就會導致非常難以發現的錯誤。

四、功能測試與性能評測

4.1 模擬器的功能正確性測試

我自己編寫的測試程序見下表,注意所有的程序都需要和test/lib.c一起編譯。

代碼文件 對應的ELF文件
test/helloworld.c riscv-elf/helloworld.riscv
test/test_arithmetic.c riscv-elf/test_arithmetic.riscv
test/test_syscall.c riscv-elf/test_syscall.riscv
test/test_branch.c riscv-elf/test_branch.riscv
test/quicksort.c riscv-elf/quicksort.riscv
test/matrixmulti.c riscv-elf/matrixmulti.riscv
test/ackermann.c riscv-elf/ackermann.riscv

每個代碼文件的功能描述如下

代碼文件 功能描述
test/helloworld.c 最簡單的Hello, World
test/test_arithmetic.c 測試一組算術運算
test/test_syscall.c 測試全部的系統調用
test/test_branch.c 測試條件和循環語句
test/quicksort.c 分別對10和100個元素進行快速排序
test/matrixmulti.c 10*10矩陣乘法
test/ackermann.c 求解一組Ackermann函數的值

如果模擬器程序Simulator在項目中的build/目錄下,可以運行如下命令,得到運行結果,來驗證模擬器的正確性。注意test_syscall.riscv程序中存在用戶輸入的部分。

./Simulator ../riscv-elf/helloworld.riscv
./Simulator ../riscv-elf/test_arithmetic.riscv
./Simulator ../riscv-elf/test_syscall.riscv
./Simulator ../riscv-elf/test_branch.riscv
./Simulator ../riscv-elf/quicksort.riscv
./Simulator ../riscv-elf/matrixmulti.riscv
./Simulator ../riscv-elf/ackermann.riscv

得到的執行結果如下

hehaodeMacBook-Pro:build hehao$ ./Simulator ../riscv-elf/helloworld.riscv
Hello, World!
Program exit from an exit() system call
------------ STATISTICS -----------
Number of Instructions: 141
Number of Cycles: 188
Avg Cycles per Instrcution: 1.3333
Branch Perdiction Accuacy: 0.5833 (Strategy: Always Not Taken)
Number of Control Hazards: 23
Number of Data Hazards: 73
Number of Memory Hazards: 1
-----------------------------------
hehaodeMacBook-Pro:build hehao$ ./Simulator ../riscv-elf/test_arithmetic.riscv
30
-10
370350
411
49380
771
Program exit from an exit() system call
------------ STATISTICS -----------
Number of Instructions: 508
Number of Cycles: 703
Avg Cycles per Instrcution: 1.3839
Branch Perdiction Accuacy: 0.4268 (Strategy: Always Not Taken)
Number of Control Hazards: 91
Number of Data Hazards: 224
Number of Memory Hazards: 13
-----------------------------------
hehaodeMacBook-Pro:build hehao$ ./Simulator ../riscv-elf/test_syscall.riscv
This is string from print_s()
123456abc
Enter a number: 123456
The number is: 123456
Enter a character: g
The character is: g
Program exit from an exit() system call
------------ STATISTICS -----------
Number of Instructions: 350
Number of Cycles: 461
Avg Cycles per Instrcution: 1.3171
Branch Perdiction Accuacy: 0.5833 (Strategy: Always Not Taken)
Number of Control Hazards: 53
Number of Data Hazards: 178
Number of Memory Hazards: 5
-----------------------------------
hehaodeMacBook-Pro:build hehao$ ./Simulator ../riscv-elf/quicksort.riscv
Prev A: 5 3 5 6 7 1 3 5 6 1 
Sorted A: 1 1 3 3 5 5 5 6 6 7 
Prev B: 100 99 98 97 96 95 94 93 92 91 90 89 88 87 86 85 84 83 82 81 80 79 78 77 76 75 74 73 72 71 70 69 68 67 66 65 64 63 62 61 60 59 58 57 56 55 54 53 52 51 50 49 48 47 46 45 44 43 42 41 40 39 38 37 36 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 
Sorted B: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 
Program exit from an exit() system call
------------ STATISTICS -----------
Number of Instructions: 103671
Number of Cycles: 141697
Avg Cycles per Instrcution: 1.3668
Branch Perdiction Accuacy: 0.4926 (Strategy: Always Not Taken)
Number of Control Hazards: 7314
Number of Data Hazards: 86448
Number of Memory Hazards: 23398
-----------------------------------
hehaodeMacBook-Pro:build hehao$ ./Simulator ../riscv-elf/matrixmulti.riscv
The content of A is: 
0 0 0 0 0 0 0 0 0 0 
1 1 1 1 1 1 1 1 1 1 
2 2 2 2 2 2 2 2 2 2 
3 3 3 3 3 3 3 3 3 3 
4 4 4 4 4 4 4 4 4 4 
5 5 5 5 5 5 5 5 5 5 
6 6 6 6 6 6 6 6 6 6 
7 7 7 7 7 7 7 7 7 7 
8 8 8 8 8 8 8 8 8 8 
9 9 9 9 9 9 9 9 9 9 
The content of B is: 
0 1 2 3 4 5 6 7 8 9 
0 1 2 3 4 5 6 7 8 9 
0 1 2 3 4 5 6 7 8 9 
0 1 2 3 4 5 6 7 8 9 
0 1 2 3 4 5 6 7 8 9 
0 1 2 3 4 5 6 7 8 9 
0 1 2 3 4 5 6 7 8 9 
0 1 2 3 4 5 6 7 8 9 
0 1 2 3 4 5 6 7 8 9 
0 1 2 3 4 5 6 7 8 9 
The content of C=A*B is: 
0 0 0 0 0 0 0 0 0 0 
0 10 20 30 40 50 60 70 80 90 
0 20 40 60 80 100 120 140 160 180 
0 30 60 90 120 150 180 210 240 270 
0 40 80 120 160 200 240 280 320 360 
0 50 100 150 200 250 300 350 400 450 
0 60 120 180 240 300 360 420 480 540 
0 70 140 210 280 350 420 490 560 630 
0 80 160 240 320 400 480 560 640 720 
0 90 180 270 360 450 540 630 720 810 
Program exit from an exit() system call
------------ STATISTICS -----------
Number of Instructions: 225441
Number of Cycles: 318532
Avg Cycles per Instrcution: 1.4129
Branch Perdiction Accuacy: 0.3765 (Strategy: Always Not Taken)
Number of Control Hazards: 40678
Number of Data Hazards: 110957
Number of Memory Hazards: 11735
-----------------------------------
hehaodeMacBook-Pro:build hehao$ ./Simulator ../riscv-elf/ackermann.riscv
Ackermann(0,0) = 1
Ackermann(0,1) = 2
Ackermann(0,2) = 3
Ackermann(0,3) = 4
Ackermann(0,4) = 5
Ackermann(1,0) = 2
Ackermann(1,1) = 3
Ackermann(1,2) = 4
Ackermann(1,3) = 5
Ackermann(1,4) = 6
Ackermann(2,0) = 3
Ackermann(2,1) = 5
Ackermann(2,2) = 7
Ackermann(2,3) = 9
Ackermann(2,4) = 11
Ackermann(3,0) = 5
Ackermann(3,1) = 13
Ackermann(3,2) = 29
Ackermann(3,3) = 61
Ackermann(3,4) = 125
Program exit from an exit() system call
------------ STATISTICS -----------
Number of Instructions: 430754
Number of Cycles: 574548
Avg Cycles per Instrcution: 1.3338
Branch Perdiction Accuacy: 0.5045 (Strategy: Always Not Taken)
Number of Control Hazards: 48010
Number of Data Hazards: 279916
Number of Memory Hazards: 47774
-----------------------------------

4.2 運行給定的5個測試程序

4.2.1 原始的執行結果

給定的5個程序在test-inclass/文件夾中,有如下5個

add.c
mul-div.c
n!.c
qsort.c
simple-function.c

類似之前的執行方式,得到如下原始運行結果

hehaodeMacBook-Pro:build hehao$ ./Simulator ../test-inclass/add.riscv
Program exit from an exit() system call
------------ STATISTICS -----------
Number of Instructions: 876
Number of Cycles: 1183
Avg Cycles per Instrcution: 1.3505
Branch Perdiction Accuacy: 0.4639 (Strategy: Always Not Taken)
Number of Control Hazards: 124
Number of Data Hazards: 433
Number of Memory Hazards: 58
-----------------------------------
hehaodeMacBook-Pro:build hehao$ ./Simulator ../test-inclass/mul-div.riscv
Program exit from an exit() system call
------------ STATISTICS -----------
Number of Instructions: 901
Number of Cycles: 1208
Avg Cycles per Instrcution: 1.3407
Branch Perdiction Accuacy: 0.4639 (Strategy: Always Not Taken)
Number of Control Hazards: 124
Number of Data Hazards: 463
Number of Memory Hazards: 58
-----------------------------------
hehaodeMacBook-Pro:build hehao$ ./Simulator ../test-inclass/n\!.riscv
Program exit from an exit() system call
------------ STATISTICS -----------
Number of Instructions: 1112
Number of Cycles: 1525
Avg Cycles per Instrcution: 1.3714
Branch Perdiction Accuacy: 0.4661 (Strategy: Always Not Taken)
Number of Control Hazards: 189
Number of Data Hazards: 515
Number of Memory Hazards: 34
-----------------------------------
hehaodeMacBook-Pro:build hehao$ ./Simulator ../test-inclass/qsort.riscv
Program exit from an exit() system call
------------ STATISTICS -----------
Number of Instructions: 19427
Number of Cycles: 25328
Avg Cycles per Instrcution: 1.3038
Branch Perdiction Accuacy: 0.4701 (Strategy: Always Not Taken)
Number of Control Hazards: 1363
Number of Data Hazards: 14156
Number of Memory Hazards: 3174
-----------------------------------
hehaodeMacBook-Pro:build hehao$ ./Simulator ../test-inclass/simple-function.riscv
Program exit from an exit() system call
------------ STATISTICS -----------
Number of Instructions: 886
Number of Cycles: 1197
Avg Cycles per Instrcution: 1.3510
Branch Perdiction Accuacy: 0.4639 (Strategy: Always Not Taken)
Number of Control Hazards: 126
Number of Data Hazards: 438
Number of Memory Hazards: 58
-----------------------------------

從這些原始數據中可以分析得到要求的結果,下面會對這些結果進行總結。

4.2.2 動態執行的指令數

程序名 執行的指令數
add.riscv 876
mul-div.riscv 901
n!.riscv 1112
qsort.riscv 19427
simple-function.riscv 886

4.2.3 執行周期數和平均CPI

程序名 執行周期數 平均CPI
add.riscv 1183 1.3505
mul-div.riscv 1208 1.3407
n!.riscv 1525 1.3714
qsort.riscv 25328 1.3038
simple-function.riscv 1197 1.3510

可以發現,對於各種類型的程序,本模擬器流水線實現的平均CPI在1.33左右,和單周期相比能實現大約3.76倍的指令吞吐量。

4.2.4 不同類型的冒險統計

程序名 數據冒險 控制冒險 內存訪問冒險
add.riscv 433 124 58
mul-div.riscv 463 124 58
n!.riscv 515 189 34
qsort.riscv 14156 1363 3174
simple-function.riscv 438 126 58

五、其它內容

5.1 分支預測模塊

分支預測模塊是一個相對比較獨立的模塊,因此單獨實現為BranchPredictor類。BranchPredictor類需要指定一個分支預測有關的策略,並保存與這個策略有關的數據結構。本模擬器實現了如下幾種策略

策略名稱 策略說明
NT Always Not Taken
AT Always Taken
BTFNT Back Taken Forward Not Taken
BPB Branch Prediction Buffer

其中,Branch Prediction Buffer采用"Computer Organization and Design: Hardware/Software Interface"中所介紹的四狀態,兩位歷史信息的方法。具體地說,使用內存后12位作為索引維護一個長度為4096的直接映射高速緩存,用於存儲分支指令的地址。對於一個緩存條目,其狀態為以下四個狀態之一:Strong Taken, Weak Taken, Weak Not Taken, Strong Not Taken. 狀態轉換圖如下

具體實現如下

bool BranchPredictor::predict(uint32_t pc, uint32_t insttype, int64_t op1,
                              int64_t op2, int64_t offset) {
  switch (this->strategy) {
  case NT:
    return false;
  case AT:
    return true;
  case BTFNT: {
    if (offset >= 0) {
      return false;
    } else {
      return true;
    }
  }
  break;
  case BPB: {
    PredictorState state = this->predbuf[pc % PRED_BUF_SIZE];
    if (state == STRONG_TAKEN || state == WEAK_TAKEN) {
      return true;
    } else if (state == STRONG_NOT_TAKEN || state == WEAK_NOT_TAKEN) {
      return false;
    } else {
      dbgprintf("Strange Prediction Buffer!\n");
    }   
  }
  break;
  default:
    dbgprintf("Unknown Branch Perdiction Strategy!\n");
    break;
  }
  return false;
}

void BranchPredictor::update(uint32_t pc, bool branch) {
  int id = pc % PRED_BUF_SIZE;
  PredictorState state = this->predbuf[id];
  if (branch) {
    if (state == STRONG_NOT_TAKEN) {
      this->predbuf[id] = WEAK_NOT_TAKEN;
    } else if (state == WEAK_NOT_TAKEN) {
      this->predbuf[id] = WEAK_TAKEN;
    } else if (state == WEAK_TAKEN) {
      this->predbuf[id] = STRONG_TAKEN;
    } // do nothing if STRONG_TAKEN
  } else { // not taken
    if (state == STRONG_TAKEN) {
      this->predbuf[id] = WEAK_TAKEN;
    } else if (state == WEAK_TAKEN) {
      this->predbuf[id] = WEAK_NOT_TAKEN;
    } else if (state == WEAK_NOT_TAKEN) {
      this->predbuf[id] = STRONG_NOT_TAKEN;
    } // do noting if STRONG_NOT_TAKEN
  }
}

並且在解碼階段和執行階段添加有關分支預測的代碼

// Sumulator::decode()
bool predictedBranch = false;
if (isBranch(insttype)) {
  predictedBranch = this->branchPredictor->predict(this->fReg.pc, insttype,
                                                   op1, op2, offset);
  if (predictedBranch) {
    this->predictedPC = this->fReg.pc + offset;
    this->anotherPC = this->fReg.pc + 4;
    this->fRegNew.bubble = true;
  } else {
     this->anotherPC = this->fReg.pc + offset;
  }
}
// Simulator::execute()
if (isBranch(inst)) {
  ...
  // this->dReg.pc: fetch original inst addr, not the modified one
  this->branchPredictor->update(this->dReg.pc, branch);
}

需要注意的是將PC修改為預測器預測的PC的時機,必須要在一個周期的結束時,也就是simulate()函數中循環的末尾處。

// The Branch perdiction happens here to avoid strange bugs in branch prediction
if (!this->dReg.bubble && !this->dReg.stall && !this->fReg.stall && this->dReg.predictedBranch) {
  this->pc = this->predictedPC;
}

這樣即可完成分支預測模塊的實現,並且很容易能夠擴展出新的分支預測策略。

5.2 分支預測模塊的性能評測

有趣的是,有了這個分支預測模塊之后,我們可以對不同分支預測策略的性能進行評測。下面的表格是一個對分支預測准確率的簡單統計。

評測程序 Always Taken BTFNT Prediction Buffer
helloworld.riscv 0.4706 0.7059 0.4706
quicksort.riscv 0.5075 0.9506 0.9587
matrixmult.riscv 0.6235 0.6325 0.6275
ackermann.riscv 0.4955 0.5053 0.9593

我們可以看到,對於helloworld程序,由於程序過於簡單,其中絕大多數指令只會被執行一次,所以基於歷史信息的Prediction Buffer方法退化到了Always Taken方法(因為默認預測是選擇跳轉),而基於程序結構的經驗性判斷方法BTFNT反而取得了最高的准確率。

對於快速排序評測程序,我們發現Prediction Buffer和BTFNT都取得了極其高的預測准確率。這是因為排序元素較多(100個),並且絕大多數情況下都在反復執行很少的一段代碼。由於這些代碼絕大多數都滿足向前會跳轉的性質,所以BTFNT方法的准確率很高。由於循環的執行長度非常長(約100次),所以基於歷史信息的Predicton Buffer能夠很好地獲得較高的預測准確性。

對於矩陣乘法程序,三個分支預測算法的表現非常接近。這可能是由於矩陣乘法中每次循環的執行長度都很短(10個元素),限制了BTFNT和Prediction Buffer的性能。

對於Ackermann函數求解程序,其中完全沒有循環語句,只有函數遞歸調用和條件判斷語句,絕大多數的分支指令都在遞歸調用的函數內,因此,這時基於歷史信息的Prediction Buffer就能發揮出最大威力,得出相當高的預測准確率,而BTFNT在此則相對比較受限了,如果遞歸函數內剛好兩個if語句,一個if語句是向前跳轉,一個if語句是向后跳轉,而兩條語句在大多數情況下都是跳轉,那么BTFNT的准確率就會在50%左右徘徊。

5.3 意見和建議

  1. 編寫RISC-V CPU模擬器極大地鍛煉了我的系統編程能力。雖然在編寫的過程中遇到了一些難以解決的Bug,但在解決它們的過程中,使我收獲了很多Debug經驗,並且更加深刻地認識到了編寫健壯和包含完備錯誤處理程序的重要性。
  2. 在配置RISC-V環境的過程中,我發現RISC-V工具鏈存在一些文檔缺失的問題,有時會遇到默認配置比較奇怪或者一些參數過時的問題,為安裝相關工具造成了一些困難。我希望要是能在每次Lab發布前,能給出配置環境的一些有關教程就更好了。
  3. 計算機體系結構課教的體系結構是MIPS,不知道為什么Lab卻要做RISC-V,有一點增加了學習成本和完成Lab的時間。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM