Hansen's ink

Back

指令获取#

Spike 使用 MMU(内存管理单元)来加载指令。具体来说,指令获取的过程通过 mmu->load_insn(pc) 函数完成,该函数负责根据程序计数器的地址从内存中读取一条指令。

mmu->load_insn(pc):根据当前程序计数器(pc)的值,MMU 从内存中读取指令。这是一个指令获取函数,它返回一个 insn_fetch_t 结构,其中包含了要执行的指令及其相关的元数据。

Spike 提供了一个指令缓存(ICache)来加速指令获取的过程。缓存中的指令可以避免频繁从内存中读取相同的指令,这提高了仿真的性能。

在快速路径中,Spike 会从指令缓存中直接获取指令:

 auto ic_entry = _mmu->access_icache(pc);
     -> inline icache_entry_t* access_icache(reg_t addr)
       {
         icache_entry_t* entry = &icache[icache_index(addr)];
         if (likely(entry->tag == addr))
           return entry;
         return refill_icache(addr, entry);
       }
 auto fetch = ic_entry->data;
cpp

如果指令缓存未命中或者在慢速路径中,Spike 会直接从内存中加载指令。通过 mmu->load_insn() 函数完成,它负责从内存地址中读取原始的指令二进制数据。

读取指令#

refill_icache 是 Spike 仿真器中用于填充指令缓存(ICache)的函数。它从内存中读取指令,并将指令及其解码结果缓存起来,以便后续访问时可以从缓存中快速获取指令,而不必再次进行内存访问。

refill_icache 的主要功能是:

  • 地址转换(TLB)。
  • 读取不同长度的指令。
  • 解码指令。
  • 将指令存入缓存。
  • 监控触发器或调试器的状态。

指令解码#

processor_t::decode_insn(insn_t insn) 是 Spike 中用于将二进制指令解码为对应的操作函数的核心函数。它会通过查找一个哈希表来快速获取指令对应的处理函数(insn_func_t),如果哈希表未命中,则会通过线性搜索找到相应的指令描述符,并将其缓存起来以供后续查找。

decode_insn 的主要功能是:

  • 从二进制指令中提取出操作码。
  • 查找指令对应的操作函数。
  • 通过哈希表缓存机制加速指令解码。
  • 处理自定义指令集和扩展。

通过哈希表查找指令#

 size_t idx = insn.bits() % OPCODE_CACHE_SIZE;
 auto [hit, desc] = opcode_cache[idx].lookup(insn.bits());
cpp
  • insn.bits():从指令对象中提取出指令的二进制表示。bits() 返回指令的位表示。
  • idx = insn.bits() % OPCODE_CACHE_SIZE:使用指令的位表示对 OPCODE_CACHE_SIZE 取模,计算哈希表中的索引 idx。这个索引指向缓存中的某个槽位。
  • opcode_cache[idx].lookup(insn.bits()):在哈希表(opcode_cache)中查找是否存在该指令。如果存在(hittrue),则直接返回指令描述符 desc

处理自定义指令和标准指令#

如果哈希表中没有找到对应的指令(hitfalse),Spike 将使用线性搜索方法在自定义指令和标准指令列表中寻找匹配的指令

 if (unlikely(!hit)) {
   // fall back to linear search
   auto matching = [insn_bits = insn.bits()](const insn_desc_t &d) {
     return (insn_bits & d.mask) == d.match;
   };
   auto p = std::find_if(custom_instructions.begin(),
                         custom_instructions.end(), matching);
   if (p == custom_instructions.end()) {
     p = std::find_if(instructions.begin(), instructions.end(), matching);
     assert(p != instructions.end());
   }
   desc = &*p;
   opcode_cache[idx].replace(insn.bits(), desc);
 }
cpp
  • 线性搜索:如果缓存未命中,Spike 会在 custom_instructions(自定义指令集)中线性搜索。matching 是一个匿名函数,它通过指令的掩码(mask)和匹配值(match)来匹配指令。
  • std::find_if:标准库函数 find_if 在列表中寻找第一个满足条件的指令描述符。如果没有在自定义指令集中找到,Spike 将搜索标准的 instructions 集合。
  • 缓存更新:一旦找到匹配的指令编码,Spike 将该描述符插入哈希表的缓存中,以加速后续的解码过程。

指令什么时候被添加到队列中?#

processor_t::processor_t初始化函数中processor_t::register_base_instructions() 函数就是 Spike 仿真器中将指令添加到 instructions 容器中的地方。这也是标准 RISC-V 指令集被注册的关键函数。

   #define DEFINE_INSN(name) \
     if (!name##_overlapping) \
       register_base_insn((insn_desc_t) { \
         name##_match, \
         name##_mask, \
         fast_rv32i_##name, \
         fast_rv64i_##name, \
         fast_rv32e_##name, \
         fast_rv64e_##name, \
         logged_rv32i_##name, \
         logged_rv64i_##name, \
         logged_rv32e_##name, \
         logged_rv64e_##name});
   #include "insn_list.h"
   #undef DEFINE_INSN
cpp
  • DEFINE_INSN:声明与每条指令相关的各种处理函数。每条指令可以有多个变种(例如针对 RV32I、RV64I、RV32E、RV64E 等不同的指令集变体),每个变种都有对应的快速路径(fast)和日志记录(logged)版本。
 DEFINE_INSN(add)
 DEFINE_INSN(addi)
 DEFINE_INSN(addiw)
 DEFINE_INSN(addw)
 DEFINE_INSN(and)
 DEFINE_INSN(andi)
 DEFINE_INSN(auipc)
 DEFINE_INSN(beq)
 DEFINE_INSN(bge)
 DEFINE_INSN(bgeu)
 DEFINE_INSN(blt)
cpp
  • insn_list.h:该文件包含所有标准 RISC-V 指令的列表。通过包含这个文件,Spike 为每条指令声明了其相关的处理函数。
 static uint32_t addi(unsigned int dest, unsigned int src, uint16_t imm) __attribute__ ((unused));
 static uint32_t addi(unsigned int dest, unsigned int src, uint16_t imm)
 {
   return (bits(imm, 11, 0) << 20) |
     (src << 15) |
     (dest << 7) |
     MATCH_ADDI;
 }
cpp

随后会调用processor_t::register_insn函数,将指令添加入指令队列中。

void processor_t::register_insn(insn_desc_t desc, bool is_custom) {
  assert(desc.fast_rv32i && desc.fast_rv64i && desc.fast_rv32e && desc.fast_rv64e &&
         desc.logged_rv32i && desc.logged_rv64i && desc.logged_rv32e && desc.logged_rv64e);

  if (is_custom)
    custom_instructions.push_back(desc);
  else
    instructions.push_back(desc);
}
cpp

获得指令执行函数#

desc->func(xlen, rve, log_commits_enabled);
cpp

desc->func:指令描述符包含了对应指令的处理函数(func),这是一个指令处理函数(insn_func_t 类型)。根据指令描述符,Spike 返回这个指令处理函数。

insn_func_t func(int xlen, bool rve, bool logged) const
  {
    if (logged)
      if (rve)
        return xlen == 64 ? logged_rv64e : logged_rv32e;
      else
        return xlen == 64 ? logged_rv64i : logged_rv32i;
    else
      if (rve)
        return xlen == 64 ? fast_rv64e : fast_rv32e;
      else
        return xlen == 64 ? fast_rv64i : fast_rv32i;
  }
cpp

此时获得的指令执行函数形式类似于fast_rv32i_add,实际上是没有这个函数,而是会通过宏定义的方式来实现指令执行的逻辑。

指令执行#

获得指令的执行函数后Spike就会直接调用。

static inline reg_t execute_insn_fast(processor_t* p, reg_t pc, insn_fetch_t fetch) {
  return fetch.func(p, fetch.insn, pc);
}
cpp

Spike的指令逻辑是在ISA(指令集架构)模块中定义的,具体是在riscv/insns/目录下。

  • 每条指令都有一个对应的C++文件。例如,add指令的编码格式在riscv/insns/add.h文件中定义。Spike使用宏和位运算来解析和生成指令的二进制编码。
  • 指令的解析和执行逻辑是由仿真器内部的解码器来实现的。指令的二进制编码由RISC-V ISA的标准规定,在Spike中,这些编码由解码器解析并映射到相应的指令处理函数。

Spike 使用宏定义的方式来实现指令执行的逻辑。这种方式可以让多条指令共享相似的结构逻辑,并简化重复性代码的编写。例如,add.h 文件中的宏实现可能被多个变种(例如 RV32I 和 RV64I)使用,而不需要为每个变种编写单独的函数。

在构建时,Spike 的编译器会将这些宏展开到实际的指令处理逻辑中。具体的执行流程如下:

  1. 当仿真器解码到 add 指令时,会使用指令表中的描述符(如fast_rv32i_add)找到 add.h 文件中的处理逻辑。
  2. add.h 中的宏会被展开为相应的操作,仿真器将使用这些操作执行加法指令。
// add.h
WRITE_RD(RS1 & RS2);
|
||-> // riscv/decode_macros.h
    #define RS1 READ_REG(insn.rs1())
    #define RS2 READ_REG(insn.rs2())
||-> // riscv/decode_macros.h
    #define WRITE_RD(value) WRITE_REG(insn.rd(), value)
    #define WRITE_REG(reg, value) ({ \
        reg_t wdata = (value); /* value may have side effects */ \
        if (DECODE_MACRO_USAGE_LOGGED) STATE.log_reg_write[(reg) << 4] = {wdata, 0}; \
        CHECK_REG(reg); \
        STATE.XPR.write(reg, wdata); \
      })
cpp

以上就是Spike在运行时指令获取和指令执行的大体流程。

Spike 运行机制详解
https://astro-pure.js.org/blog/simulator/spike-run
Author Hansen W.
Published at August 18, 2024
Comment seems to stuck. Try to refresh?✨