首页 > 技术文章 > RISC-V流水线CPU模拟器(c语言实现)

gaozhenyu 2020-12-21 15:26 原文

2020 年秋季学期计算机体系结构

Project 04——RISC-V流水线处理器

2020年11月27日

一、时序模拟和功能模拟分离

该RISC-V流水线处理器分为两部分:功能模拟部分,时序模拟部分。

功能时序分离的优势有两点:

  1. 不同功能模块化,减小耦合性,可以增强可扩展性。
  2. 有效降低流水线实现的复杂度和工作量。

具体实现上,功能模拟部分大体沿用之前编写的单/多周期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部分:

  1. 所执行的所有指令时序信息,每一条包括:指令名称,开始时间,结束时间,总耗时。
  2. 各种指令的执行数量,并从高到低进行了排序,便于查看哪些指令出现频率高。
  3. 冲突计数,统计了各类型数据冲突的数量,以及控制冲突的数量。
  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;
}

具体代码会在该课程结束后上传......

推荐阅读