we choose to go to the moon!😉|

上山砍大树

园龄:5年3个月粉丝:13关注:3

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;

这里可以看出,除了指令的地址信息pcsnpcdnpc,还包括了一个与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的成员pcsnpc中。随后调用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
  • ?表示相应的位可以匹配01
  • 空格是分隔符, 只用于提升模式字符串的可读性, 不参与匹配

指令名称在代码中仅当注释使用, 不参与宏展开;

指令类型用于后续译码过程;

指令执行操作则是通过C代码来模拟指令执行的真正行为.

下面看下INSTPAT宏如何转换为对应的C代码。

我们看下INSTPATINSTPAT_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_INSTINSTPAT_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__ ; \
}

具体定义如上文所述,下面我们按照其源码,分析下INSTPATINSTPAT_START()INSTPAT_END()的具体逻辑。

首先是宏INSTPAT的各部分含义解析:

  1. pattern_decode 是一个函数,通过解析 pattern(模式)生成 keymaskshift 这三个变量。

  2. 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 进行比较,判断当前指令或数据是否符合指定模式。

  3. 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,使这个宏更灵活。

  4. goto *(__instpat_end)

    • 匹配成功,代码跳转到预定义的指针__instpat_end所指的地址。__instpat_end实现在INSTPAT_START

总结INSTPAT宏的整体逻辑:

  1. 通过pattern_decode()函数解析传入的指令模式pattern,用与生成匹配的 keymaskshift
  2. s 中提取待处理的指令或数据,并根据 shiftmaskkey 进行模式匹配。
  3. 如果匹配成功,执行 INSTPAT_MATCH 中的操作,并通过 goto *(__instpat_end) 跳转到预定义的位置,可能是为了跳过某些指令或结束当前匹配过程。

分析完毕INSTPAT的整体逻辑后,再深入分析下函数pattern_decode()decode_operand()的逻辑。

首先是将模式字符串解析到变量keymaskshift的函数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()确保字符是01?
    • 根据字符更新__key__mask__shift
      • 1:将 __key 向左移位并设置最低位为 1,同时在 __mask 中将相应位设为 1
      • 0:将 __key 向左移位并设置最低位为 0,同时在 __mask 中将相应位设为 1
      • ?:不影响__Key,在__mask中对应位设置为0,并增加__shift的计数

这里我们单独拎出__key的处理方法,来看看是怎么根据当前字符c来决定这个参数值的

__key = (__key << 1) | (c == '1' ? 1 : 0);
  1. 左移操作__key << 1
  2. 条件表达式(c == '1' ? 1 : 0)
  3. 按位或操作|:按位或是位运算的一种,是将两个数据的二进制表示右对齐后,按位进行运算,两个对应的二进制位中只要一个是1,结果对应位就是1。

举例:如果当前__key值为5(二进制0101

  • 当前字符c1,则__key = 1010 | 1 = 1011
  • 当前字符c0,则__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()的含金量还是蛮高的,总结下里面值得学习的东西有:

  1. 宏的使用和递归解析:灵活地处理输入字符串的不同长度
  2. 将字符串转换为keymask:增加新类型的字符或改变解析规则只需调整宏逻辑,而无需重写整个解析过程。
  3. 错误保护机制
  4. 代码维护性:体现在宏和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;
}
}

其中用到的宏定义BITSSEXTnemu/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)用位域的好处在于:

  1. 自动处理符号扩展:由于 int64_t n : len 是有符号的,当对 n 进行赋值时,C 语言会自动根据最高位进行符号扩展,这就避免了手动处理符号位的复杂逻辑。
  2. 高效简洁:这种方式通过位域直接实现了符号扩展,不需要手动移位或其他位操作,代码简洁且高效。

这样函数decode_operand()根据指令的类型type,来进行操作数的译码,并将译码结果记录到函数参数rd, src1, src2imm中, 它们分别代表目的操作数的寄存器号码, 两个源操作数和立即数.

本文作者:上山砍大树

本文链接:https://www.cnblogs.com/shangshankandashu/p/18440484

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   上山砍大树  阅读(142)  评论(2编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起