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
表示相应的位只能匹配0
1
表示相应的位只能匹配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
和__shift
1
:将__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
中, 它们分别代表目的操作数的寄存器号码, 两个源操作数和立即数.
本文作者:上山砍大树
本文链接:https://www.cnblogs.com/shangshankandashu/p/18440484
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步