2024 NJU PA2.1
RISC-V32指令集包含几十条指令,例如算术指令add
、跳转指令jal
等。如何在NEMU中模拟这些指令的功能?这正是本节需要实现的内容。
1. 一条指令的执行
程序计数器PC
从程序员的视角来看,CPU与内存的交互仅限于读写内存,不过,CPU需要区分指令与数据。在CPU中,程序计数器(PC, Program Counter)用于保存指令地址。当执行完一条指令后,PC会被修改,指向下一条指令。
在NEMU中,一条指令的执行可细分为以下几个步骤:
- 取指:从PC获取指令地址,再根据此地址从内存中读取指令(在risc-v32中,指令一定是4个字节);
- 解码:识别指令,从指令中提取信息;
- 执行:用若干行C代码,模拟指令功能;
- 更新PC:修改PC,使其指向下一条指令。
流水线
正是由于一条指令的执行可细分为几个更小的步骤,才有了流水线的概念,这些将在后续的计算机组成原理中学到。
举个例子,假如某条指令为:00000000 01100011 10000010 10110011
. 根据手册描述,发现它与加法指令add
能匹配上:
上图中的rd
, rs1
和rs2
表示寄存器的编号(或者说索引),它们都是5个比特,这是因为risc-v32有32个寄存器,用5个比特编码即可。
根据上图重新划分指令:0000000 00110 00111 000 00101 0110011
,可以得到rs2 = 00110, rs1 = 00111, rd = 00101
. 指令add
将寄存器rs1
和rs2
的值相加,并将和写入寄存器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();
这使得变量src1
和imm
被赋值;最后一行代码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
,就是要实现指令lui
和addi
.
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. 总的来说代码量并不大,但需要查很多东西,可能会比较烦。整个流程过下来,还是很有收获的。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步