spike指令获取及执行

指令获取

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct insn_fetch_t
{
insn_func_t func;
insn_t insn;
};

struct icache_entry_t {
reg_t tag;
struct icache_entry_t* next;
insn_fetch_t data;
};

insn_fetch_t fetch = mmu->load_insn(pc);
-> inline insn_fetch_t load_insn(reg_t addr)
{
icache_entry_t entry;
return refill_icache(addr, &entry)->data;
}

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

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

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

1
2
3
4
5
6
7
8
9
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;

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

读取指令

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
inline icache_entry_t* refill_icache(reg_t addr, icache_entry_t* entry)
{
if (matched_trigger)
throw *matched_trigger;

auto tlb_entry = translate_insn_addr(addr); // 将虚拟地址(addr)转换为物理地址
// 从内存中读取指令的前两个字节(16位),并将其存入 insn 变量。
insn_bits_t insn = from_le(*(uint16_t*)(tlb_entry.host_offset + addr));
int length = insn_length(insn); // 确定该指令的长度
// 根据 length 的值,Spike 从内存中获取剩余的指令字节,并组装成完整的指令
if (likely(length == 4)) {
insn |= (insn_bits_t)from_le(*(const uint16_t*)translate_insn_addr_to_host(addr + 2)) << 16;
} else if (length == 2) {
// entire instruction already fetched
} else if (length == 6) {
insn |= (insn_bits_t)from_le(*(const uint16_t*)translate_insn_addr_to_host(addr + 2)) << 16;
insn |= (insn_bits_t)from_le(*(const uint16_t*)translate_insn_addr_to_host(addr + 4)) << 32;
} else {
static_assert(sizeof(insn_bits_t) == 8, "insn_bits_t must be uint64_t");
insn |= (insn_bits_t)from_le(*(const uint16_t*)translate_insn_addr_to_host(addr + 2)) << 16;
insn |= (insn_bits_t)from_le(*(const uint16_t*)translate_insn_addr_to_host(addr + 4)) << 32;
insn |= (insn_bits_t)from_le(*(const uint16_t*)translate_insn_addr_to_host(addr + 6)) << 48;
}

insn_fetch_t fetch = {proc->decode_insn(insn), insn}; // 指令解码
entry->tag = addr; // 将缓存条目中的 tag 更新为当前指令的地址(addr),以便后续可以根据地址快速找到这条缓存条目。
entry->next = &icache[icache_index(addr + length)]; // 将 next 指针设置为下一个缓存条目地址
entry->data = fetch; // 将 fetch(包含解码后的指令和原始指令)存入 entry->data 中。
// 跟踪与异常处理
reg_t paddr = tlb_entry.target_offset + addr;;
if (tracer.interested_in_range(paddr, paddr + 1, FETCH)) {
entry->tag = -1;
tracer.trace(paddr, length, FETCH);
}
return entry;
}

refill_icache 的主要功能是:

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

指令解码

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

decode_insn 的主要功能是:

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

通过哈希表查找指令

1
2
size_t idx = insn.bits() % OPCODE_CACHE_SIZE;
auto [hit, desc] = opcode_cache[idx].lookup(insn.bits());
  • 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 将使用线性搜索方法在自定义指令和标准指令列表中寻找匹配的指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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);
}
  • 线性搜索:如果缓存未命中,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 指令集被注册的关键函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#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
  • DEFINE_INSN:声明与每条指令相关的各种处理函数。每条指令可以有多个变种(例如针对 RV32I、RV64I、RV32E、RV64E 等不同的指令集变体),每个变种都有对应的快速路径(fast)和日志记录(logged)版本。
1
2
3
4
5
6
7
8
9
10
11
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)
  • insn_list.h:该文件包含所有标准 RISC-V 指令的列表。通过包含这个文件,Spike 为每条指令声明了其相关的处理函数。
1
2
3
4
5
6
7
8
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;
}

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

1
2
3
4
5
6
7
8
9
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);
}

获得指令执行函数

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
}

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

指令执行

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

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

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 中的宏会被展开为相应的操作,仿真器将使用这些操作执行加法指令。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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); \
})

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


spike指令获取及执行
http://willimt.com/2024/08/02/模拟器/spike指令获取及执行/
作者
Willimt
发布于
2024年8月2日
许可协议