RISC-V流水線CPU模擬器(c語言實現)
2020 年秋季學期計算機體系結構
Project 04——RISC-V流水線處理器
2020年11月27日
一、時序模擬和功能模擬分離
該RISC-V流水線處理器分為兩部分:功能模擬部分,時序模擬部分。
功能時序分離的優勢有兩點:
- 不同功能模組化,減小耦合性,可以增強可擴充套件性。
- 有效降低流水線實現的複雜度和工作量。
具體實現上,功能模擬部分大體沿用之前編寫的單/多週期CPU,在其基礎上改進,加上了與時序模擬部分相互通訊的介面,將進行時序模擬所需要的資訊輸出到buffer檔案中;而時序部分讀取buffer檔案,通過功能模擬部分所提供的資訊,計算流水線的時序資訊,並統計輸出。
接下來是時序模擬的設計框架
二、各級流水線執行順序
雖然實際各級流水線是同時執行,但由於C語言的限制,所以需要選擇一個順序。
IF->WB
若直接執行會違反時序,如果需要實現則要將每個階段分成兩部分:取資料和執行,兩部分分階段執行。
流水線暫存器後的段先取指令,全部取到指令後再順次執行。
不足是會造成各階段的割裂,帶來一些不必要的麻煩。
WB->IF
符合時序要求,從後往前,在一個階段內連續完成從上一個流水線暫存器取值、執行、寫入下一個流水線暫存器。處理順序也是按照指令流入的先後順序進行,是相對理想的實現方案。
各級流水線大體執行框架如下:
void control() { if(hazard_type == xxx){//發現衝突,且需要暫停處理 WB(); MEM(); EX(); ID(); IF(); } else{//無需暫停處理(無衝突或可資料定向) WB(); MEM(); EX(); ID(); IF(); } return ; }
三、各段完成的操作
取指IF
PC的更新
NPC的選擇:NPC來源有ID段的跳轉地址、PC+4、PC+2
獲取指令:根據此時的PC,從指令儲存器中讀入指令,統一讀入32位,冗餘欄位在後續過程中不會被使用,不會造成影響
指令長度確認:確定指令是否為壓縮指令,如果是則會將PC+4傳入多路選擇器,反之將PC+2傳入
框架如下:(僅表示IF的邏輯,實際實現中不會出現)
void IF() { LL insn = read_mem_longlong(PC); if(JPC != -1){//JPC有效 NPC = JPC; } else if(insn&3 == 3){//32位指令 NPC = PC + 4; } else{//壓縮指令 NPC = PC + 2; } //寫入流水線暫存器 IFIDReg.insn = insn; IFIDReg.C = (insn&3!=3); return ; }
譯碼ID
從指令中提取相應資訊:提取出各個欄位,生成出相應的立即數。採取的是固定欄位譯碼,在實際電路中各資料通路是並行的,固定欄位可以降低整體複雜度。
生成控制訊號:根據指令型別生成相應的控制訊號(如果需要的話),並寫入流水線暫存器
判斷分支轉移:測試分支條件暫存器,以儘快完成分支轉移是否成功的檢測
計算分支目標:為避免結構相關,使用一個新的加法器部件(而非ALU)進行分支目標地址的計算
在ID段處理分支指令可以減少流水線的暫停帶來的效率損失
框架如下:(僅表示ID的邏輯,實際實現中不會出現)
void ID()
{
//取值
ULL insn = IFIDReg.insn;
bool C = IFIDReg.C;
//譯碼並寫入流水暫存器
insn = (int)insn;
IDEXReg.OPCODE = insn & 0x7f;
IDEXReg.RD = (insn >> 7) & 0x1f;
IDEXReg.RS1 = (insn >> 15) & 0x1f;
IDEXReg.RS2 = (insn >> 20) & 0x1f;
IDEXReg.IMM_I = ((int)insn) >> 20;
IDEXReg.UIMM_I = (unsigned int)(insn) >> 20;
IDEXReg.FUNC3 = (insn >> 12) & 0x7;
IDEXReg.FUNC7 = (insn >> 25) & 0x7f;
IDEXReg.SHAMT = (insn >> 20) & 0x3f;
IDEXReg.IMM_J = ( (insn >> 11) & 0xfff00000 ) | ( insn & 0xff000 ) | ( (insn >> 9) & 0x800 ) | ( ((insn >> 21) & 0x3ff) << 1 );
IDEXReg.UIMM_J = ( (insn >> 11) & 0x100000 ) | ( insn & 0xff000 ) | ( (insn >> 9) & 0x800 ) | ( ((insn >> 21) & 0x3ff) << 1 );
IDEXReg.IMM_U = (insn & 0xfffff000) >> 12;
IDEXReg.IMM_B = ( ((insn >> 8) & 0xf) << 1 ) | ( ((insn >> 25) & 0x3f) << 5 ) | ( ((insn >> 7) & 0x1 ) << 11) | ((insn >> 20) & 0xfffff000);
IDEXReg.UIMM_B = ( ((insn >> 8) & 0xf) << 1 ) | ( ((insn >> 25) & 0x3f) << 5 ) | ( ((insn >> 7) & 0x1 ) << 11) | ((insn >> 20) & 0x1000);
IDEXReg.IMM_S = ((insn >> 7) & 0x1f) | ((insn >> 25) << 5);
IDEXReg.UIMM_S = (((insn >> 7) & 0x1f) | ((insn >> 25) << 5))&0x00000fff;
IDEXReg.IMM12 = (insn >> 20) & 0xfff;
IDEXReg.IMM5L = (insn >> 7) & 0x1f;
IDEXReg.IMM7 = (insn >> 25) & 0x7f;
IDEXReg.IMM5H = (insn >> 27) & 0x1f;
IDEXReg.FMT = (insn >> 25) & 0x3;
IDEXReg.RM = (insn >> 12) & 0x7;
IDEXReg.RS3 = IMM5H;
IDEXReg.WIDTH = RM;
IDEXReg.OP_16 = insn & 0x3;
IDEXReg.RS2_16 = (insn >> 2) & 0x1f;
IDEXReg.RS1_16 = (insn >> 7) & 0x1f;
IDEXReg.RD_16 = RS1_16;
IDEXReg.FUNC6_16 = (insn >> 10) & 0x3f;
IDEXReg.FUNC4_16 = (insn >> 12) & 0xf;
IDEXReg.FUNC3_16 = (insn >> 13) & 0x7;
IDEXReg.FUNC2_16 = (insn >> 5) & 0x3;
IDEXReg.OFFSETL_16 = (insn >> 2) & 0x1f;
IDEXReg.OFFSETH_16 = (insn >> 10) & 0x7;
IDEXReg.IMML_CI = RS2_16;
IDEXReg.IMMH_CI = (insn >> 12) & 0x1;
IDEXReg.IMM_CSS = (insn >> 7) & 0x3f;
IDEXReg.IMM_CIW = (insn >> 5) & 0xff;
IDEXReg.IMML_CLS = FUNC2_16;
IDEXReg.IMMH_CLS = OFFSETH_16;
IDEXReg.RDLA_16 = (insn >> 2) & 0x7;
IDEXReg.RS2A_16 = RDLA_16;
IDEXReg.RDHA_16 = (insn >> 7) & 0x7;
IDEXReg.RS1A_16 = RDHA_16;
IDEXReg.TARGET_16 = (insn >> 2) & 0x7ff;
IDEXReg.CSR = (((insn>>20)<<20)>>20);
IDEXReg.ZIMM = ((insn>>15)&0x1f) & 0xffffffff;
//提前處理分支指令
LL RS1 = get_longlong(IDEXReg.RS1);
LL RS2 = get_longlong(IDEXReg.RS2);
ULL uRS1 = get_ulonglong(IDEXReg.RS1);
ULL uRS2 = get_ulonglong(IDEXReg.RS2);
JPC = -1;
if(C){
//略
}
else{
switch (IDEXReg.OPCODE)
{
case 111://jal
JPC = PC + IDEXReg.IMM_J;
break;
case 103://jalr
JPC = (RS1 + IDEXReg.IMM_I) & (~1);
break;
case 99://branch
switch (IDEXReg.FUNC3)
{
case 0://beq
if(RS1 == RS2) JPC = PC + IDEXReg.IMM_B;
break;
case 1://bne
if(RS1 != RS2) JPC = PC + IDEXReg.IMM_B;
break;
case 4://blt
if(RS1 < RS2) JPC = PC + IDEXReg.IMM_B;
break;
case 5://bge
if(RS1 >= RS2) JPC = PC + IDEXReg.IMM_B;
break;
case 6://bltu
if(uRS1 < uRS2) JPC = PC + IDEXReg.IMM_B;
break;
case 7://bgeu
if(uRS1 >= uRS2) JPC = PC + IDEXReg.IMM_B;
break;
default:
printf("ERROR: No such instruction!\n");
break;
}
break;
default:
printf("ERROR: No such instruction!\n");
break;
}
}
//判斷訪存行為型別,略——這裡直接賦值true
IDEXReg.W_R = true;
return ;
}
執行EX
ALU單元:從流水線暫存器讀取控制訊號,並根據控制訊號選擇相應的運算元、立即數並進行處理,得到結果ALUoutput
傳遞控制訊號:為訪存段確定訪存行為(讀取 or 寫入),將控制訊號寫入流水線暫存器
大體框架如下:(僅表示ID的邏輯,實際實現中不會出現)
void EX()
{
//讀取流水線暫存器IDEXReg,與ID段的寫入類似,重複且過於冗長,此處省略
ULL insn = IDEXReg.insn;
bool C = IDEXReg.C;
bool W_R = IDEXReg.W_R;
LL aluoutput;
if(c){
//.......
}
else{
switch(OPCODE){
case R_type: break;
case B_type: break;
//......
default:
printf("ERROR: No such instruction!\n");
break;
}
}
//寫入流水線暫存器EXMEMReg
}
訪存MEM
確定訪存地址:由流水線暫存器讀取
確定訪存行為:根據流水線暫存器中讀取的控制訊號來確定
執行訪存動作:根據地址和行為,具體執行相應操作。如果是讀取記憶體的行為,結果放入ReadData,並向後傳遞。
邏輯框架較簡單,不在此贅述。
寫回WB
選擇資料和暫存器:根據控制訊號選擇正確的資料,確定目的暫存器
寫回暫存器:將所選資料寫回相應暫存器
邏輯框架較簡單,不在此贅述。
四、流水線暫停
檢測機制
衝突控制單元:將各種冒險的檢測集中在衝突控制單元處理。衝突控制單元是一個處理和傳遞全域性控制資訊的部件,從各流水線暫存器中讀取資料,進行分析,若發現存在冒險,則生成全域性控制訊號,控制各部件進行相應操作,以解決冒險。
控制訊號:控制訊號的更新不按照固定時鐘週期更新,而是依據各級流水線執行情況動態執行。需要在各級流水線均完成自己的處理任務,並將資料寫入下一級流水線暫存器後,才能開始新一次的處理,處理過程包括從各級流水線暫存器讀取最新資料,然後更新控制訊號,並即時傳遞給各部件,然後等待流水線下一次的流動。
暫停
暫停:部分衝突可以通過資料重定向來解決,但有些衝突則必須進行暫停處理。暫停的控制是由衝突控制單元通過傳遞控制訊號來完成,當某一級流水線接收到暫停的訊號後,就不從上一個流水線暫存器取值。當然,當某一級流水線被暫停時,它前面的各級流水線也會被暫停,這一點依然是由衝突控制單元來保證。
插入氣泡:被暫停的連續幾級流水線的最後一級,它需要向下一個流水線暫存器寫入NOP指令(亦可採用其他執行空操作的訊號機制),否則下一級流水線將重複操作,在執行某些指令的情況下會造成錯誤。這個過程就是插入氣泡。
實現
衝突檢測的實現分為兩部分,分別處理資料衝突和控制衝突。
資料衝突:因為一條指令在ID段可得到所有譯碼資訊,包括源暫存器。而資料衝突的產生,正是後面指令是源暫存器與前面指令的目的暫存器相同,造成的若不處理會帶來錯誤的相關。所以所有資料衝突最早可以在ID段確定下來,故資料衝突的檢測,是在ID段出現新指令後,將其資訊與前面幾條指令相匹配,檢測是否存在衝突,且標記衝突型別。衝突型別標記用於後面的衝突處理。在出現多個衝突同時出現時,將判斷衝突是否可合併,可合併是指ID段源暫存器和EX,MEM,WB段中的多個同時出現了衝突,且衝突的暫存器相同,這樣實際只需要將最靠近ID段的作為衝突即可,因為ID段需要的是最新的資訊。而不可合併的衝突,都會計入統計中。
控制衝突:該方案尚未採用分支預測機制,在每一個分支跳轉處都暫停一個週期,作為延遲槽。具體實現是在單/多週期流水線中,在向buffer中輸出資訊時,若當前指令是分支跳轉指令,則在後面再輸出一個特定的nop指令,這樣在TimingSimulator讀取buffer時,相當於已經對控制衝突進行了處理,只需正常處理nop指令即可。
五、計時和計數
時間驅動
優點:可以確定每一個時鐘週期整個CPU的狀態資訊。
缺點:可能過於陷入細節,各級流水線執行速度的差異將增加整體的複雜性。
事件驅動
優點:可以將每一級流水線內部的操作視為原子操作,降低複雜度,隱藏很多不必要的細節。
缺點:難以任意地跟蹤檢視週期級CPU的狀態資訊。
此處選擇事件驅動的方式。
實現:維護一個全域性的事件佇列,即此時流水線中正在執行的指令,每一條指令的執行視為一個事件。在佇列中的每個事件有一些屬性:該指令的名稱、源暫存器、目的暫存器、指令執行開始時間、指令執行結束時間,在每一級流水線處理所需要的時間。其中,指令在各階段結束時間的更新,需要由佇列根據所有事件來統一確定,否則會因為各指令的執行週期數差異造成混亂。
事件佇列結構大致如下:
struct event{
int name;
int rs1;
int rs2;
int rd;
int csrd;
int time_start;
int time_cost[5];
int time_end;
};
typedef struct event event;
event queue[5];
還有一些配套的函式方法(具體見程式碼)。
計數
計數分為兩部分。一是在事件佇列每次正常出佇列時檢視被彈出事件的資訊,並進行持續地統計。統計各型別指令數量、執行週期數等。二是與衝突控制單元資料互動,每次出現數據冒險和控制冒險就進行記錄。還有一些功能模擬時的必要資訊,通過buffer進行傳遞。
統計資訊
統計資訊暫時分為4部分:
- 所執行的所有指令時序資訊,每一條包括:指令名稱,開始時間,結束時間,總耗時。
- 各種指令的執行數量,並從高到低進行了排序,便於檢視哪些指令出現頻率高。
- 衝突計數,統計了各型別資料衝突的數量,以及控制衝突的數量。
- 計算了CPI。
注意,這裡的資料衝突是流水線事件佇列每一次更新後進行檢測,且均以ID段為中心,有些需要暫停的衝突,在暫停之後,會變成僅需要資料定向的衝突,此時會統計為兩次衝突。如果需要更換統計模式,僅記為一次衝突的話,對統計結果進行簡單的減法即可(因為這樣的多次統計必然是一一對應的)。
六、時序模擬部分總框架
逐級向下進行部分關鍵函式的展示(為簡略,省去了細節,具體實現請閱讀程式碼)
int main()
{
init();
TimingSimulator();
print();
return 0;
}
void init(){
memset(count, 0, sizeof(count));
memset(hazard_cnt, 0, sizeof(hazard_cnt));
for(int i=0; i<5; i++){
queue[i].name = nop;
queue[i].rs1 = -1;
queue[i].rs2 = -1;
queue[i].rd = -1;
queue[i].csrd = -1;
queue[i].time_start = queue[i].time_end = 0;
for(int j=0; j<5; j++)
queue[i].time_cost[j] = 0;
}
}
void TimingSimulator()
{
freopen("buffer.txt", "r", stdin);
freopen("Timing.txt", "w", stdout);
while(1){
hazard();
out = control();
input();
output();
}
cycles = out.time_end;
}
具體程式碼會在該課程結束後上傳......