2024 NJU PA2.1

RISC-V32指令集包含几十条指令,例如算术指令add、跳转指令jal等。如何在NEMU中模拟这些指令的功能?这正是本节需要实现的内容。

1. 一条指令的执行

程序计数器PC

从程序员的视角来看,CPU与内存的交互仅限于读写内存,不过,CPU需要区分指令与数据。在CPU中,程序计数器(PC, Program Counter)用于保存指令地址。当执行完一条指令后,PC会被修改,指向下一条指令。

在NEMU中,一条指令的执行可细分为以下几个步骤:

  1. 取指:从PC获取指令地址,再根据此地址从内存中读取指令(在risc-v32中,指令一定是4个字节)
  2. 解码:识别指令,从指令中提取信息;
  3. 执行:用若干行C代码,模拟指令功能;
  4. 更新PC:修改PC,使其指向下一条指令。

流水线

正是由于一条指令的执行可细分为几个更小的步骤,才有了流水线的概念,这些将在后续的计算机组成原理中学到。

 

举个例子,假如某条指令为:00000000 01100011 10000010 10110011. 根据手册描述,发现它与加法指令add能匹配上:

上图中的rd, rs1rs2表示寄存器的编号(或者说索引),它们都是5个比特,这是因为risc-v32有32个寄存器,用5个比特编码即可。

根据上图重新划分指令:0000000 00110 00111 000 00101 0110011,可以得到rs2 = 00110, rs1 = 00111, rd = 00101. 指令add将寄存器rs1rs2的值相加,并将和写入寄存器rd,即R[rd] = R[rs1] + R[rs2]. 在这个例子中,是R[5] = R[6] + R[7], 即t0 = t1 + t2.

2. NEMU模拟一条指令的执行

PA 1.1中,我们从函数cpu_exec开始,定位到函数execute(n), 进一步来到exec_once,最后调用架构相关的isa_exec_once,以执行一条指令. 接下来,我们将深入函数isa_exec_once的内部。

结构体Decode

在此之前,首先回顾下函数exec_once($NEMU_HOME/src/cpu/cpu-exec.c)

复制代码
static void exec_once(Decode *s, vaddr_t pc) {
    s->pc = pc;
    s->snpc = pc;
    isa_exec_once(s);
    cpu.pc = s->dnpc;
    ...
}

以上代码出现了结构体Decode($NEMU_HOME/include/cpu/decode.h), 这个结构体用于保存解码指令时所需的信息:

复制代码
typedef struct Decode {
    vaddr_t pc;  // PC
    vaddr_t snpc;  // static next PC
    vaddr_t dnpc;  // dynamic next PC
    ISADecodeInfo isa;
    IFDEF(CONFIG_ITRACE, char logbuf[128]);
} Decode;

与结构体CPU_state类似,对于不同的体系架构,只有PC是通用的,其它架构相关的信息保存在结构体ISADecodeInfo中。对于risc-v32,此结构体定义于$NEMU_HOME/src/isa/riscv32/include/isa-def.h :

复制代码
typedef struct {
    uint32_t inst;
} MUXDEF(CONFIG_RV64, riscv64_ISADecodeInfo, riscv32_ISADecodeInfo);

可以看到,这个结构体只有一个成员inst, 用于保存指令,相当于指令寄存器(IR, Instruction Register)。

在结构体Decode中,除了pc,还有snpc(static next PC)和dnpc(dynamic next PC).

  • snpc是物理意义上的下一条指令地址,在risc-v32中,它总是等于当前pc+4.
  • dnpc是真正的下一条指令地址,也就是执行完当前指令后,下一条指令的地址。

如果当前指令是跳转指令,那么snpc不等于dnpc;否则,多数情况下,二者是相等的。

接下来,我们将深入risc-v32的isa_exec_once, 弄清楚NEMU如何执行一条指令。

2.1. 取指

risc-v32的isa_exec_once($NEMU_HOME/src/isa/riscv32/inst.c) 定义如下:

复制代码
int isa_exec_once(Decode *s) {
    s->isa.inst = inst_fetch(&s->snpc, 4);
    return decode_exec(s);
}

risc-v32是定长指令集,32位架构下,每条指令是4个字节。因此,这里的inst_fetch模拟了取指令的功能:从当前pc开始读取4个字节(这里的s->snpc可能会让人迷惑,暂时不必在意,之后我会详细解释PC是如何更新的),并保存到s->isa.inst中,最后调用decode_exec(s)解码指令。

2.2. 解码&执行

decode_exec定义于isa_exec_once的上方:

复制代码
static int decode_exec(Decode *s) {
    s->dnpc = s->snpc;
    
    #define INSTPAT_INST(s) ...
    #define INSTPAT_MATCH(s) ...
    
    INSTPAT_START();
    INSTPAT(...);
    INSTPAT(...);
    ...
    INSTPAT_END();
    
    R(0) = 0;
}

这里出现了很多宏,最重要的一个是INSTPAT($NEMU_HOME/include/cpu/decode.h),指令的解码和执行都通过这个宏完成。来看代码提供的几个例子:

复制代码
INSTPAT("??????? ????? ????? ??? ????? 00101 11", auipc  , U, R(rd) = s->pc + imm);
INSTPAT("??????? ????? ????? 100 ????? 00000 11", lbu    , I, R(rd) = Mr(src1 + imm, 1));
INSTPAT("??????? ????? ????? 000 ????? 01000 11", sb     , S, Mw(src1+imm, 1, src2));

INSTPAT的功能大致如下:

复制代码
INSTPAT(pattern, 指令名, 指令类型, 指令功能C代码) {
    if (s->isa.inst与pattern匹配) { // s->isa.inst保存了当前指令
        int rd = 0;
        word_t src1 = 0, src2 = 0, imm = 0;
        decode_operand(s, &rd, &src1, &src2, &imm, 指令类型);
        指令功能C代码;
    }
}

首先是用于匹配的模式串pattern, 它可以包含0, 1, ?和空格。其中,空格会被忽略,?是通配符,可以匹配0或1. 例如,对于前面用来举例的add指令,它的模式串可以写为0000000 ????? ????? 000 ????? 0110011.

从指令中提取信息的功能位于函数decode_operand

复制代码
static void decode_operand(Decode *s, int *rd, word_t *src1, word_t *src2, word_t *imm, int type) {
    uint32_t i = s->isa.inst;
    int rs1 = BITS(i, 19, 15);
    int rs2 = BITS(i, 24, 20);
    *rd     = BITS(i, 11, 7);
    switch (type) {
        case TYPE_I: src1R();          immI(); break;
        case TYPE_U:                   immU(); break;
        case TYPE_S: src1R(); src2R(); immS(); break;
        case TYPE_N: break;                                                                                
        default: panic("unsupported type = %d", type);
    }
}

risc-v32的一条指令最多涉及3个寄存器和1个立即数。另外,如果指令需要用到寄存器rd,那么它的编号一定位于指令的第7~11个比特;如果指令用到寄存器rs1, 那么它的编号一定位于指令的第15~19个比特…… 基于这一特点,我们可以轻松地从指令中提取所需信息。

例如,I型指令涉及到寄存器rd, rs1和立即数。我们通过宏src1R()提取寄存器rs1的值,并保存到变量src1中;通过宏immI()提取I型指令的立即数,保存到变量imm中;寄存器rd的编号被保存到变量rd中。提取完成后,就可以直接使用这些变量实现I型指令的功能。

 

来看指令lbu的实现:

复制代码
INSTPAT("??????? ????? ????? 100 ????? 00000 11", lbu, I, R(rd) = Mr(src1 + imm, 1))

首先根据手册描述,写出lbu的模式串;接下来是指令名称;之后是指令格式,lbu属于I型指令,字母I会被宏展开为TYPE_I, 并在函数decode_operand中执行case TYPE_I的那部分代码,即case TYPE_I: src1R();          immI(); 这使得变量src1imm被赋值;最后一行代码R(rd) = Mr(src1 + imm, 1)实现了lbu的具体功能:src1(编号为rs1的寄存器保存的值)加上立即数imm得到内存地址,从这个地址读取1字节,写入寄存器rd.

2.3. 更新PC

我们仔细梳理下在NEMU中,PC是如何更新的。

 

首先,在函数execute中:

复制代码
static void execute(uint64_t n) {
    Decode s;
    for (; n > 0; n--) {
        exec_once(&s, cpu.pc);
        ...
    }
}

每次调用exec_once时,当前指令的地址都是通过cpu.pc指定的。

 

接下来,在函数exec_once中:

复制代码
static void exec_once(Decode *s, vaddr_t pc) {
    s->pc = pc;
    s->snpc = pc;
    isa_exec_once(s);
    cpu.pc = s->dnpc;
    ...
}

调用函数isa_exec_once后,cpu.pc被设置为s->dnpc. 因此,需要确保调用isa_exec_once后,s->dnpc指向下一条待执行的指令。

 

最后,在isa_exec_once中:

复制代码
int isa_exec_once(Decode *s) {
    s->isa.inst = inst_fetch(&s->snpc, 4);
    return decode_exec(s);
}

请注意,调用inst_fetch时,传入的是&s->snpc. 调用此函数后,s->snpc会加上4. 这也是为什么函数decode_exec的第一行是s->dnpc = s->snpc, 这表示一般情况下s->dnpc == s->snpc == s->pc + 4. 而对于那些需要修改PC的操作(例如跳转指令jal), 你应当修改s->dnpc的值。

指令未匹配的处理机制

最后一条匹配的指令是INSTPAT("??????? ????? ????? ??? ????? ????? ??", inv    , N, INV(s->pc)); 它的模式串全是?,因此总能匹配上那些没被其它规则匹配上的指令。

3. PA2.1

完成这一小节需要反复查看RISC-V官方手册卷一、卷二,网络抽风的朋友可以在这里下载。另外,你还可以下载一个“有趣的东西”

提示

建议每实现一条指令,就立即进行测试。不仅仅是便于debug,也是因为有的指令目前不需要实现。例如,risc-v32有好几个乘法指令,我们只需要实现其中的两个。

运行第一个测试用例

按照手册指示,安装编译工具:

复制代码
apt-get install g++-riscv64-linux-gnu binutils-riscv64-linux-gnu

进入目录am-kernels/tests/cpu-tests,在此目录下运行如下命令:

复制代码
make ARCH=riscv32-nemu ALL=dummy run

ALL=dummy表示用于测试的文件是tests/dummy.c. 启动NEMU后,输入c得到如下输出:

根据报错信息,位于0x80000000处的指令没有实现。

 

当前目录下,能找到一个名为build的子目录,其中有个名为dummy-riscv32-nemu.txt的文件,它保存了反汇编dummy.c的结果。打开它,可以看到:

这说明指令li未实现。

实现第一条指令li

打开官方手册卷一,在靠后的章节能找到一张名为Instruction Set Listings的表:

仔细过了一遍,大危机!第一条需要实现的指令li就找不到了!li用于加载一个立即数,它是条伪指令,根据加载立即数的大小,被翻译为不同的指令:

因此,实现指令li,就是要实现指令luiaddi.

复制代码
INSTPAT("??????? ????? ????? ??? ????? 01101 11", lui    , U, R(rd) = imm);
INSTPAT("??????? ????? ????? 000 ????? 00100 11", addi   , I, R(rd) = src1 + imm);

完成后,重复之前的步骤,这次报错的位置位于0x8000000c

添加J型指令jal

同样地,打开文件build/dummy-riscv32-nemu.txt, 发现需要实现的指令是jal.

jal属于J型指令,目前代码中还未定义此类型的指令,我们需要添加J型指令的定义:

复制代码
enum {
    TYPE_I, TYPE_U, TYPE_S,
    TYPE_J, // 添加J型指令的定义                                                                                  
    TYPE_N, // none
};

J型指令的格式如下:

可以看到,J型指令的立即数被拆得稀碎。一般来说,拆成这样有两种原因:一是电路设计上的方便;二是保证与旧设备的兼容。risc-v作为新生儿,原因自然是前者。我们需要仿照immI的实现,定义宏immJ, 从指令中拼出立即数部分,实现如下:

复制代码
#define immJ() do { *imm = (SEXT(BITS(i, 31, 31), 1) << 20) | (BITS(i, 19, 12) << 12) \
    | (BITS(i, 20, 20) << 11) | (BITS(i, 30, 25) << 5) | (BITS(i, 24, 21) << 1);  } while(0)

do { ... } while(0)

在Linux内核代码中能见到这种写法,这样写,就能在宏定义的后面加上分号,看上去更像是函数调用。

之后,在函数decode_operand中,添加处理J型指令的逻辑:

复制代码
switch (type) {
    case TYPE_I: src1R();          immI(); break;
    ...
    case TYPE_J:                   immJ(); break;  // J型指令只需要提取立即数
    ...
}

做好准备工作后,查找手册(或者查看cheatsheet),指令jal的功能是设置目标寄存器和PC,实现如下:

复制代码
INSTPAT("??????? ????? ????? ??? ????? 11011 11", jal    , J, R(rd) = s->pc + 4, s->dnpc = s->pc + imm);

实现完成后,重复以上编译步骤,这次的报错出现在0x80000024:

通过测试用例dummy

很好,我们离成功更近一步!接下来需要实现的指令是sw,这个很简单,根据指令sb的实现照葫芦画瓢,把1改成4即可:

复制代码
INSTPAT("??????? ????? ????? 010 ????? 01000 11", sw     , S, Mw(src1 + imm, 4, src2));

接下来是位于0x80000014的指令ret(终于要结束了)ret同样是条伪指令,需要实现的指令是jalr

复制代码
INSTPAT("??????? ????? ????? 000 ????? 11001 11", jalr   , I, R(rd) = s->pc + 4, s->dnpc = ((src1 + imm) >> 1) << 1);

再次编译运行,恭喜!顺利通过测试用例dummy:

通过全部测试用例

接下来,你需要将ALL=dummy中的dummy替换为tests目录下的其它测试用例,按照上述步骤实现指令功能。

如何避免输入c

打开$AM_HOME/scripts/platform/nemu.mk, 在NEMUFLAGS后加上-b, 即

复制代码
NEMUFLAGS += -l $(shell dirname $(IMAGE).elf)/nemu-log.txt -b

这样就不需要输入c了。

全部完成后,运行:

复制代码
make ARCH=riscv32-nemu run

如果输出如下:

那么恭喜你,完成了PA 2.1. 总的来说代码量并不大,但需要查很多东西,可能会比较烦。整个流程过下来,还是很有收获的。

posted @   overxus  阅读(133)  评论(0编辑  收藏  举报
评论
收藏
关注
推荐
深色
回顶
展开
点击右上角即可分享
微信分享提示