NEMU的指令执行步骤
exec_once()函数覆盖了指令周期的所有阶段: 取指, 译码, 执行, 更新PC
下面学习下函数exec_once()的各个阶段所做的事情
取指
在执行指令之前,需要获取这个指令,我们看下NEMU如何获取一条指令的。
exec_once()接受一个Decode类型的结构体指针s.这个结构体存放“在执行一条指令过程中所需的信息”。
Decode结构体定义在nemu/include/cpu/decode.h中
typedef struct Decode {
vaddr_t pc;
vaddr_t snpc; // static next pc
vaddr_t dnpc; // dynamic next pc
ISADecodeInfo isa;
IFDEF(CONFIG_ITRACE, char logbuf[128]);
} Decode;
这里可以看出,除了指令的地址信息pc、snpc和dnpc,还包括了一个与ISA相关的结构体抽象ISADecodeInfo.
其具体的定义在nemu/src/isa/$ISA/include/isa-def.h中
typedef struct {
union {
uint32_t val;
} inst;
} MUXDEF(CONFIG_RV64, riscv64_ISADecodeInfo, riscv32_ISADecodeInfo);
这个ISADecodeInfo结构体中包含了一个联合体inst,联合体中有一个uint32_t类型的成员val。 RISC-V 32 位架构中,每一条指令的长度都是 32 位,因此这个 val 可以用于存储一条完整的指令。
现在exec_once()函数接收了传入参数s,然后将当前的PC保存到s的成员pc和snpc中。随后调用isa_exec_once()进行指令的执行操作。
函数isa_exec_once()定义在nemu/src/isa/riscv32/inst.c中
int isa_exec_once(Decode *s) {
s->isa.inst.val = inst_fetch(&s->snpc, 4);
return decode_exec(s);
}
因为inst.val是用来存储一条完整指令的变量,所以推测inst_fetch()函数的功能,是用来取指令的。
下面我们看下函数inst_fetch()的定义(nemu/include/cpu/ifetch.h)
static inline uint32_t inst_fetch(vaddr_t *pc, int len) {
uint32_t inst = vaddr_ifetch(*pc, len);
(*pc) += len;
return inst;
}
而函数vaddr_ifetch()的功能就是通过pc所指的客户程序地址,找到对应物理内存中的长度为len的数据。
函数isa_exec_once()将pc->snpc的地址作为参数传入到函数vaddr_ifetch()中,所以函数vaddr_ifetch()取完数据后,会根据len(这里是4)来更新s -> snpc,从而让s -> snpc指向下一条指令。
已经获取的指令,将其存放于结构体s关于ISA信息的isa中。至此,取指令流程结束。
译码
随后s带着指令的信息,传入到函数decode_exec()开始译码,其定义在nemu/src/isa/riscv32/inst.c
static int decode_exec(Decode *s) {
int rd = 0;
word_t src1 = 0, src2 = 0, imm = 0;
s->dnpc = s->snpc;
#define INSTPAT_INST(s) ((s)->isa.inst.val)
#define INSTPAT_MATCH(s, name, type, ... /* execute body */ ) { \
decode_operand(s, &rd, &src1, &src2, &imm, concat(TYPE_, type)); \
__VA_ARGS__ ; \
}
INSTPAT_START();
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("0000000 00001 00000 000 00000 11100 11", ebreak , N, NEMUTRAP(s->pc, R(10))); // R(10) is $a0
INSTPAT("??????? ????? ????? ??? ????? ????? ??", inv , N, INV(s->pc));
INSTPAT_END();
R(0) = 0; // reset $zero to 0
return 0;
}
译码的目的是得到指令的操作和操作对象, 这主要是通过查看指令的opcode来决定的. NEMU通过一个模式字符串来指定指令中的opcode.
因为译码部分研读时候发现细节很多,所以为了理解这部分的内容,我这个小节的规划是:先从宏观角度讲译码做了什么,即译码的功能;随后着眼细节,剖析代码的筋骨纹理,看看译码是怎么实现这些功能的。
来不及解释了,我们开始⭐
功能
首先看下,如何获取指令中的opcode.
NEMU定义了用于识别对应opcode的模式匹配规则INSTPAT(意思是instruction pattern)
INSTPAT(模式字符串, 指令名称, 指令类型, 指令执行操作);
宏展开后,首先调用pattern_decode()函数,将一条包含opcode对应匹配规则的字符串,经过转换,作为opcode的判断参数。
然后再将输入的s中的指令信息s->isa.inst.val,经过位操作后,跟上一步骤中的opcode判断参数进行比对。
如果比对成功,则宣告了指令的操作类型已经确定,指令类型的译码工作已经完成。
指令类型确定后,随后便是对操作对象的译码处理decode_operand()。此函数根据传入的指令类型type来进行操作数的译码,译码结果会被保存起来。
decode_operand(s, &rd, &src1, &src2, &imm, TYPE_U);
以上就是宏观角度,屏蔽掉函数内部的复杂粒度,只概述每个函数的输入输出,从简化译码的逻辑。
但是实际的操作,还是需要依靠复杂的逻辑处理和对应c语言特性才能实现。下面,我们就着手细节,从细节上剖析译码的操作流程。
细节
首先看下如何实现的模式匹配。NEMU可以通过一个模式字符串来指定指令中opcode, 例如在riscv32中有如下模式:
INSTPAT_START();
INSTPAT("??????? ????? ????? ??? ????? 00101 11", auipc, U, R(rd) = s->pc + imm);
// ...
INSTPAT_END();
而定义每一条模式匹配规则的INSTPAT是一个宏,其格式为:
INSTPAT(模式字符串, 指令名称, 指令类型, 指令执行操作);
INSTPAT的各个参数的说明如下:
模式字符串中只允许出现4种字符:
0表示相应的位只能匹配01表示相应的位只能匹配1?表示相应的位可以匹配0或1- 空格是分隔符, 只用于提升模式字符串的可读性, 不参与匹配
指令名称在代码中仅当注释使用, 不参与宏展开;
指令类型用于后续译码过程;
指令执行操作则是通过C代码来模拟指令执行的真正行为.
下面看下INSTPAT宏如何转换为对应的C代码。
我们看下INSTPAT、INSTPAT_START()和INSTPAT_END()其宏定义的具体实现。它们均被定义在nemu/include/cpu/decode.h中。
// --- pattern matching wrappers for decode ---
#define INSTPAT(pattern, ...) do { \
uint64_t key, mask, shift; \
pattern_decode(pattern, STRLEN(pattern), &key, &mask, &shift); \
if ((((uint64_t)INSTPAT_INST(s) >> shift) & mask) == key) { \
INSTPAT_MATCH(s, ##__VA_ARGS__); \
goto *(__instpat_end); \
} \
} while (0)
#define INSTPAT_START(name) { const void ** __instpat_end = &&concat(__instpat_end_, name);
#define INSTPAT_END(name) concat(__instpat_end_, name): ; }
INSTPAT又使用了另外两个宏INSTPAT_INST和INSTPAT_MATCH, 它们在nemu/src/isa/$ISA/inst.c中定义.
#define INSTPAT_INST(s) ((s)->isa.inst.val)
#define INSTPAT_MATCH(s, name, type, ... /* execute body */ ) { \
decode_operand(s, &rd, &src1, &src2, &imm, concat(TYPE_, type)); \
__VA_ARGS__ ; \
}
具体定义如上文所述,下面我们按照其源码,分析下INSTPAT、INSTPAT_START()和INSTPAT_END()的具体逻辑。
首先是宏INSTPAT的各部分含义解析:
-
pattern_decode是一个函数,通过解析pattern(模式)生成key、mask和shift这三个变量。 -
if ((((uint64_t)INSTPAT_INST(s) >> shift) & mask) == key)-
INSTPAT_INST(s)是一个宏,用于从存放指令执行信息的结构体s中提取指令或数据。其逻辑实现为#define INSTPAT_INST(s) ((s)->isa.inst.val) -
(uint64_t)INSTPAT_INST(s)将提取到的指令,转换为64位的整数 -
& mask:通过掩码操作保留需要匹配的位,屏蔽掉其他不相关的位。 -
== key:最后将处理后的结果与key进行比较,判断当前指令或数据是否符合指定模式。
-
-
INSTPAT_MATCH(s, ##__VA_ARGS__)-
当2阶段
if判断条件成立,宏调用INSTPAT_MATCH,执行与该模式匹配的逻辑#define INSTPAT_MATCH(s, name, type, ... /* execute body */ ) { \ decode_operand(s, &rd, &src1, &src2, &imm, concat(TYPE_, type)); \ __VA_ARGS__ ; \ } -
##__VA_ARGS__表示可变参数,允许传递多个参数给INSTPAT_MATCH,使这个宏更灵活。
-
-
goto *(__instpat_end)- 匹配成功,代码跳转到预定义的指针
__instpat_end所指的地址。__instpat_end实现在INSTPAT_START中
- 匹配成功,代码跳转到预定义的指针
总结INSTPAT宏的整体逻辑:
- 通过
pattern_decode()函数解析传入的指令模式pattern,用与生成匹配的key、mask和shift。 - 从
s中提取待处理的指令或数据,并根据shift、mask和key进行模式匹配。 - 如果匹配成功,执行
INSTPAT_MATCH中的操作,并通过goto *(__instpat_end)跳转到预定义的位置,可能是为了跳过某些指令或结束当前匹配过程。
分析完毕INSTPAT的整体逻辑后,再深入分析下函数pattern_decode()和decode_operand()的逻辑。
首先是将模式字符串解析到变量key、mask和shift的函数pattern_decode(),定义在nemu/include/cpu/decode.h中。
static inline void pattern_decode(const char *str, int len,
uint64_t *key, uint64_t *mask, uint64_t *shift) {
uint64_t __key = 0, __mask = 0, __shift = 0;
#define macro(i) \
if ((i) >= len) goto finish; \
else { \
char c = str[i]; \
if (c != ' ') { \
Assert(c == '0' || c == '1' || c == '?', \
"invalid character '%c' in pattern string", c); \
__key = (__key << 1) | (c == '1' ? 1 : 0); \
__mask = (__mask << 1) | (c == '?' ? 0 : 1); \
__shift = (c == '?' ? __shift + 1 : 0); \
} \
}
#define macro2(i) macro(i); macro((i) + 1)
#define macro4(i) macro2(i); macro2((i) + 2)
#define macro8(i) macro4(i); macro4((i) + 4)
#define macro16(i) macro8(i); macro8((i) + 8)
#define macro32(i) macro16(i); macro16((i) + 16)
#define macro64(i) macro32(i); macro32((i) + 32)
macro64(0); // 从索引 0 开始解析字符串
panic("pattern too long"); // 如果解析到这里,表示字符串超长
#undef macro
finish:
*key = __key >> __shift; // 将 __key 右移 __shift 位
*mask = __mask >> __shift; // 将 __mask 右移 __shift 位
*shift = __shift; // 返回移位值
}
里面比较有意思的是宏macro(i)的相关定义,这里学习下。
#define macro(i) \
if ((i) >= len) goto finish; \
else { \
char c = str[i]; \
if (c != ' ') { \
Assert(c == '0' || c == '1' || c == '?', \
"invalid character '%c' in pattern string", c); \
__key = (__key << 1) | (c == '1' ? 1 : 0); \
__mask = (__mask << 1) | (c == '?' ? 0 : 1); \
__shift = (c == '?' ? __shift + 1 : 0); \
} \
}
- 边界检查:如果索引
i超过字符串长度len,则跳转到finish标签,结束解析 - 字符处理:
- 获取字符串中索引为
i的字符c - 如果字符为非空格,继续下面的执行
- 使用宏
Assert()确保字符是0、1或? - 根据字符更新
__key、__mask和__shift1:将__key向左移位并设置最低位为 1,同时在__mask中将相应位设为 10:将__key向左移位并设置最低位为 0,同时在__mask中将相应位设为 1?:不影响__Key,在__mask中对应位设置为0,并增加__shift的计数
- 获取字符串中索引为
这里我们单独拎出__key的处理方法,来看看是怎么根据当前字符c来决定这个参数值的
__key = (__key << 1) | (c == '1' ? 1 : 0);
- 左移操作
__key << 1 - 条件表达式
(c == '1' ? 1 : 0) - 按位或操作
|:按位或是位运算的一种,是将两个数据的二进制表示右对齐后,按位进行运算,两个对应的二进制位中只要一个是1,结果对应位就是1。
举例:如果当前__key值为5(二进制0101)
- 当前字符
c为1,则__key = 1010 | 1 = 1011 - 当前字符
c为0,则__key = 1010 | 0 = 1010
其实这行代码的目的是逐个处理字符串中的字符,并根据字符是否为1,来构建一个二进制数。举一反三,其他两个参数__mask 和 __shift的值获取方式类似。
pattern_decode()中另一个需要学习的点,就是用于简化和加速字符串解析过程的宏扩展。
#define macro2(i) macro(i); macro((i) + 1)
- 顺序执行:带有分号的
macro(i);使得它是一个完整的语句,接着在同一行执行macro((i) + 1)。这两者是顺序执行的,编译器能够正确解析。 - 通过一次宏调用处理两个字符,相比于逐个调用
macro(i),可以减少宏调用的次数,从而提高解析效率。
学习完主要的宏macro(i)后,我们再回头对函数pattern_decode()主要部分进行解析
-
宏
macro(i):将字符串表示的指令模式转换为可以用于比较的位值和掩码 -
macro64(0):从索引0开始,处理从 0 到 63 的字符。 -
panic():尝试处理的字符超出了实际字符串的长度范围,会调用此函数报错。举例字符串如果为
1010,而len为十进制的10。将这个4位的字符串用函数macro4(0)处理时,即使处理完字符串,也会因为if语句不成立,不会跳转到正常的finish部分。所以会调用函数panic()报错。 -
finish:保留__key和__mask的有效位,并将解析过程中遇到的?字符的数量,有效位的偏移量__shift返回
这个pattern_decode()的含金量还是蛮高的,总结下里面值得学习的东西有:
- 宏的使用和递归解析:灵活地处理输入字符串的不同长度
- 将字符串转换为
key和mask:增加新类型的字符或改变解析规则只需调整宏逻辑,而无需重写整个解析过程。 - 错误保护机制
- 代码维护性:体现在宏和
finish。所有解析结果的更新集中在finish:标签处,避免了代码重复。
分析完毕pattern_decode(),再具体分析下函数decode_operand()
INSTPAT的整体逻辑不仅包含模式字符串处理函数pattern_decode(),而且包含另一个匹配操作码行为的函数decode_operand()。
#define src1R() do { *src1 = R(rs1); } while (0)
#define src2R() do { *src2 = R(rs2); } while (0)
#define immI() do { *imm = SEXT(BITS(i, 31, 20), 12); } while(0)
#define immU() do { *imm = SEXT(BITS(i, 31, 12), 20) << 12; } while(0)
#define immS() do { *imm = (SEXT(BITS(i, 31, 25), 7) << 5) | BITS(i, 11, 7); } while(0)
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.val;
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;
}
}
其中用到的宏定义BITS和SEXT在nemu/include/macro.h中定义,分别用于位抽取和符号扩展
#define BITMASK(bits) ((1ull << (bits)) - 1)
#define BITS(x, hi, lo) (((x) >> (lo)) & BITMASK((hi) - (lo) + 1)) // similar to x[hi:lo] in verilog
#define SEXT(x, len) ({ struct { int64_t n : len; } __x = { .n = x }; (uint64_t)__x.n; })
宏的简单介绍:
BITMASK(bits):生成一个由bits个低位为 1 的无符号长整型掩码。例如,BITMASK(3)的结果是0b111,即十进制的 7。BITS(x, hi, lo):获取x的[lo, hi]部分数据SEXT(x, len):将x符号扩展为64位的无符号整数
其中SEXT(x, len)用位域的好处在于:
- 自动处理符号扩展:由于
int64_t n : len是有符号的,当对n进行赋值时,C 语言会自动根据最高位进行符号扩展,这就避免了手动处理符号位的复杂逻辑。 - 高效简洁:这种方式通过位域直接实现了符号扩展,不需要手动移位或其他位操作,代码简洁且高效。
这样函数decode_operand()根据指令的类型type,来进行操作数的译码,并将译码结果记录到函数参数rd, src1, src2和imm中, 它们分别代表目的操作数的寄存器号码, 两个源操作数和立即数.

浙公网安备 33010602011771号