Skip to content
This repository has been archived by the owner on Jul 23, 2024. It is now read-only.

Latest commit

 

History

History
1059 lines (936 loc) · 45.9 KB

File metadata and controls

1059 lines (936 loc) · 45.9 KB

Spike 硬件体系

即使已经魔改了spike好一段时间了,但是仍然对整个其模拟的硬件体系很模糊 (为啥不弄个官方一点的文档呢😪);这里稍作总结,供大家参考。注意由于硬件端的模拟都在spike上,此次讨论的代码根目录为 riscv-isa-sim

分而治之

由于整个spike是基于 c++ 11 std 完成的,并严格遵守了面向对象编程,为了能够不在源代码迷宫中撞来撞去,我们还是先分各个模块来理解它的实现

Processor

说到硬件模拟,处理器总会最先被人想到——如何设计的寄存器?如何完成指令运算,甚至更复杂一点,有没有对流水线进行模拟?我们将一一探讨。

  • 处理器相关的代码位于 riscv/processor.hriscv/processor.cc

我们看到名为processor_t的类,整个代码比较长,我们将分几段来分析

// this class represents one processor in a RISC-V machine.
class processor_t : public abstract_device_t {
public:
    processor_t(const char* isa, const char* priv, const char* varch,
              simif_t* sim, uint32_t id, bool halt_on_reset=false);
    ~processor_t();
......

首先,我们可以看到,该类继承 abstrat_device_t 类,这个类实现非常简单,仅仅提供了名为loadstore的虚函数,具体定义在riscv/devices.h中。

再者,对于构造函数,我们分析其传入的参数

  • const char* isa: 指令集,默认为 RV64IMAFDC
  • const char* priv: 特权,默认是 MSU
  • const char* varch: vector 寄存器的相关设置,默认是 vlen:128,elen:64,slen:128
  • simif_t* sim: simif_t类指针,后面汇总分析
  • uint32_t id: 该处理器的id,自然与之前文章所提到的 mhardit 相关
  • bool halt_on_reset: 是否启动时停止,用于调试器的连接

well,内容并不多哈,我们接着先分析一下构造函数的内容

processor_t::processor_t(const char* isa, const char* priv, const char* varch,
                         simif_t* sim, uint32_t id, bool halt_on_reset)
  : debug(false), halt_request(false), sim(sim), ext(NULL), id(id), xlen(0),
  histogram_enabled(false), log_commits_enabled(false),
  halt_on_reset(halt_on_reset), last_pc(1), executions(1)
{
  VU.p = this;
  parse_isa_string(isa);
  parse_priv_string(priv);
  parse_varch_string(varch);
  等的接口;我们来看看其方法的具体实现。
  

bcd_t::bcd_t() { register_command(0, std::bind(&bcd_t::handle_read, this, _1), "read"); register_command(1, std::bind(&bcd_t::handle_write, this, _1), "write"); }

void bcd_t::handle_read(command_t cmd) { pending_reads.push(cmd); }

void bcd_t::handle_write(command_t cmd) { canonical_terminal_t::write(cmd.payload()); }

void bcd_t::tick() { int ch; if (!pending_reads.empty() && (ch = canonical_terminal_t::read()) != -1) { pending_reads.front().respond(0x100 | ch); pending_reads.pop(); } }

mmu = new mmu_t(sim, this);

disassembler = new disassembler_t(max_xlen);
if (ext)
  for (auto disasm_insn : ext->get_disasms())
    disassembler->add_insn(disasm_insn);

reset();
}

那些直接进行的赋值之中涉及的成员变量,我们就在后面讲解。这里可以看到,函数首先调用了相关的parse函数来设置好isa, priv以及varch。随之,在register_base_instructions()中注册了处理器可以执行的指令;更有趣的是我们看到了对mmu_t的初始化,这也表明了后续我们分析的内存管理单元mmu_t的粒度是pre-processor的。

有趣的是,这里还设置了名为disassembler的成员,我们后续再看看它干了啥

最后调用reset()函数完成对许多寄存器以及控制值的清零。

由于我们这里重在从看硬件的视角去看实现,一些细节就暂时的忽略掉了哈,整个逻辑不一定连贯

好啦,转到认真的分析,谈到risc-v处理器,让人感兴趣的自然就是

  1. 寄存器
  2. 指令执行
  3. 特权级别 这样的话题了,我们分着看看spike的实现

寄存器

我们看到processor_t类中,有一个名为state的私有成员变量,其声明如下

// architectural state of a RISC-V hart
struct state_t
{
  void reset(reg_t max_isa);
  static const int num_triggers = 4;
  reg_t pc;
  regfile_t<reg_t, NXPR, true> XPR;
  regfile_t<freg_t, NFPR, false> FPR;
  // control and status registers
  reg_t prv;    // TODO: Can this be an enum instead?
  reg_t misa;
  reg_t mstatus;
  reg_t mepc;
.......
};

哇,就感觉一堆寄存器甩到脸上,这个结构体承载了所有的,与处理器状态相关的量;

  • 整型寄存器
regfile_t<reg_t, NXPR, true> XPR;

其中regfile_t是模板类型,其构造使用的reg_t为输入给该模板的类型,NXPR表示模板类单元的数量,true表示存在零寄存器; 简单的说,用一个数组来理解即可。

常量和定义在riscv/decode.h

const int NXPR = 32;
const int NFPR = 32;
const int NVPR = 32;
const int NCSR = 4096;
  • 浮点型寄存器
regfile_t<freg_t, NFPR, false> FPR;

不重复赘述了。注意在64位架构下,freg_t是128位的float

  • 特权寄存器和向量寄存器 没有完全统计,但多数的特权寄存器也位于state之中;特权寄存器的细节还是阅读risc-v的privilege手册; 向量寄存器的话,用不到就先不去管了

指令执行

寄存器就介绍介绍放哪也过于粗糙了,自然,因为寄存器无法离开指令而存在,所以要讨论到指令执行才能合适的了解对寄存器的使用。 开门见山的说,所有的指令的主要实现都放置于riscv/insns文件夹下,如果想自己添加指令,多数情况下依葫芦画瓢即可。 如 add 指令,如下

// riscv/insns/add.h
WRITE_RD(sext_xlen(RS1 + RS2));

逻辑相当简单,其中WRITE_RDRS1RS2均为riscv/decode.h中定义的宏,用于解析指令字段并访问之前我们讨论过的整型寄存器。

我想你可能会有疑惑,怎么就一行代码?咋编译呢? 实际上这里实现是主体内容,代码具体的内容要经过加工,我们查看代码的模板

// riscv/insn_template.h
// See LICENSE for license details.

#include "arith.h"
#include "mmu.h"
#include "softfloat.h"
#include "internals.h"
#include "specialize.h"
#include "tracer.h"
#include <assert.h>

/* ---------------------------------------- */
// riscv/insn_template.cc
// See LICENSE for license details.
#include "insn_template.h"

reg_t rv32_NAME(processor_t* p, insn_t insn, reg_t pc)
{
  int xlen = 32;
  reg_t npc = sext_xlen(pc + insn_length(OPCODE));
  #include "insns/NAME.h"
  trace_opcode(p, OPCODE, insn);
  return npc;
}

reg_t rv64_NAME(processor_t* p, insn_t insn, reg_t pc)
{
  int xlen = 64;
  reg_t npc = sext_xlen(pc + insn_length(OPCODE));
  #include "insns/NAME.h"
  trace_opcode(p, OPCODE, insn);
  return npc;
}

在实际build的过程,Makefile将提取这些指令头文件合成实际的cc文件用于编译,这是非常聪明的设计了。

指令设计弄懂了,但还不知道咋运行来着,我们回到处理器的代码之中。

指令的执行,其主体实现由通用函数execute_insn来完成

static reg_t execute_insn(processor_t* p, reg_t pc, insn_fetch_t fetch)
{
  commit_log_stash_privilege(p);
  reg_t npc = fetch.func(p, fetch.insn, pc);    // <= 这里完成了指令的执行
......

看样子执行和名为fetch的,类型为insn_fetch_t这一参数有关,通过查看源代码发现对其的调用都由step()函数完成

// fetch/decode/execute loop
void processor_t::step(size_t n)
{
    ......

该部分代码十分冗杂,如其注释所言,其完成代码的fetch,decode和执行几个步骤;因为指令fetch还需要了解内存管理,我们这里暂时延缓。主要看看代码的decode与execute。

不妨倒着来看,首先看看这个insns_fetch_t结构,其定义在riscv/mmu.h

struct insn_fetch_t
{
  insn_func_t func;
  insn_t insn;
};

该结构体之中的insn_t是一个巨大的结构体,用于描述指令集下指令的构成,如怎样提取这个指令之中的rd等,提取opcode等等,声明在riscv/decode.h之中。 而这里的成员insn_func_t,如其名,应该是处理这个指令的函数的指针,其定义在riscv/processor.h之中

typedef reg_t (*insn_func_t)(processor_t*, insn_t, reg_t);

或许你已经发现了,这个函数的模样和我们前面举例子add时提出的模板同出一辙呀,这里就回答了如何联系到指针的实现这一个问题。

如何decode呢?虽然在没有分析fetch的时候分析解码会有点牵强,我们就简单看看;在指令的fetch过程中,有这样一行代码

insn_fetch_t fetch = {proc->decode_insn(insn), insn};

哈,最终还是回到了我们的processor_t类,我们接着分析一下这的解码函数

// insns/processor.cc
insn_func_t processor_t::decode_insn(insn_t insn)
{
  // look up opcode in hash table
  size_t idx = insn.bits() % OPCODE_CACHE_SIZE;
  insn_desc_t desc = opcode_cache[idx];
  if (unlikely(insn.bits() != desc.match)) {
    // fall back to linear search
    insn_desc_t* p = &instructions[0];
    while ((insn.bits() & p->mask) != p->match)
      p++;
    desc = *p;
    if (p->mask != 0 && p > &instructions[0]) {
      if (p->match != (p-1)->match && p->match != (p+1)->match) {
        // move to front of opcode list to reduce miss penalty
        while (--p >= &instructions[0])
          *(p+1) = *p;
        instructions[0] = desc;
      }
    }
    opcode_cache[idx] = desc;
    opcode_cache[idx].match = insn.bits();
  }
  return xlen == 64 ? desc.rv64 : desc.rv32;
}

简单看了看代码就可以发现,关键是一个名为descinsn_desc_t类型变量,而且该变量似乎是通过哈希表来寻找的。首先了解一下结构体

// riscv/processor.h
struct insn_desc_t
{
  insn_bits_t match;
  insn_bits_t mask;
  insn_func_t rv32;
  insn_func_t rv64;
};

如其名,结构体存储着分别给32、64位处理的函数指针(这里的match与mask在上个提到函数的哈希表之中被用到) 同时呢,这个有趣的opcode_cache则是在最最开始我们提及的构造函数中的register_base_instructions()中的相关函数build_opcode_map();所完成。其过程是通过将编译时产生的指令列表 include 到代码中完成各个指令的注册来实现的。

特权级别

通过阅读risc-v的手册,我们可以了解到risc-v的特权设计

普通的指令即用户的程序和软件是运行在最低的用户模式的,而其他两种特权模式是运行最可信代码的机器模式(machine code)与为像Linux, FreeBSD等操作系统提供的监管者模式(supervisor mode)

如何变化特权级!听起来很难,毕竟不同的特权级有着不同的访问权限呀!

好吧,毕竟是软件模拟,特权级的变化两句话就搞定了

// riscv/processor.cc
void processor_t::set_privilege(reg_t prv)
{
  mmu->flush_tlb();
  state.prv = legalize_privilege(prv);
}

其中首先将TLB给清空,然后legalize_privilege实现了一些检查。

反向看看哪儿调用了这些函数,实现是通过在指令执行过程中完成的trap的捕获完成的

void processor_t::take_trap(trap_t& t, reg_t epc)
{
    ......

具体的代码就不在这里分析了,简单而言代码将在这里检查trap的原因,来决定是否需要完成特权级的转化(自然有特权级别的升高也有特权级别的降低)

存储

之前做系统相关的实验的时候,研究ARM相关的手册,其中怎样做mapping,怎样管理内存都是很重要的。前面我们也提到了处理器processor_t在构造函数时会完成对于名为mmu_t单元的初始化,那么这里我们就先看看这个 MMU (Memory Management Unit)

// riscv/mmu.h
// this class implements a processor's port into the virtual memory system.
// an MMU and instruction cache are maintained for simulator performance.
class mmu_t
{
public:
    mmu_t(simif_t* sim, processor_t* proc);
    ~mmu_t();

注释里面总会有具有价值的信息,这里说明了spike实现了指令层次的cache以用于加速

还是和之前一样,我们可以先看一下其构造函数

mmu_t::mmu_t(simif_t* sim, processor_t* proc)
 : sim(sim), proc(proc),
  check_triggers_fetch(false),
  check_triggers_load(false),
  check_triggers_store(false),
  matched_trigger(NULL)
{
  flush_tlb();
  yield_load_reservation();
}

非常简单干练的构造函数,完成了成员变量的赋值之后就清空TLB,然后有一个名为yield_load_reservation的函数,我们留给后面去分析 整体来说,对于MMU我们关心的是

  1. load
  2. store
  3. fetch和指令 icache 当然,(1)包括了我们比较关心的地址的映射过程,我们就先从load讲起

LOAD

查看汇编指令ld, lw, lh, lb的实现,我们可以得到以下内容

WRITE_RD(MMU.load_int64(RS1 + insn.i_imm()));   // ld
WRITE_RD(MMU.load_int32(RS1 + insn.i_imm()));   // lw
WRITE_RD(MMU.load_int16(RS1 + insn.i_imm()));   // lh
WRITE_RD(MMU.load_int8(RS1 + insn.i_imm()));    // lb

一目了然,MMU中的load_xxxx函数是用于访问虚拟内存的接口,而其实现,我们可以在riscv/mmu.h中找到

// template for functions that load an aligned value from memory
  #define load_func(type) \
    inline type##_t load_##type(reg_t addr) { \
      if (unlikely(addr & (sizeof(type##_t)-1))) \
        return misaligned_load(addr, sizeof(type##_t)); \
      reg_t vpn = addr >> PGSHIFT; \
      size_t size = sizeof(type##_t); \
      if (likely(tlb_load_tag[vpn % TLB_ENTRIES] == vpn)) { \
        if (proc) READ_MEM(addr, size); \
        return from_le(*(type##_t*)(tlb_data[vpn % TLB_ENTRIES].host_offset + addr)); \
      } \
      if (unlikely(tlb_load_tag[vpn % TLB_ENTRIES] == (vpn | TLB_CHECK_TRIGGERS))) { \
        type##_t data = from_le(*(type##_t*)(tlb_data[vpn % TLB_ENTRIES].host_offset + addr)); \
        if (!matched_trigger) { \
          matched_trigger = trigger_exception(OPERATION_LOAD, addr, data); \
          if (matched_trigger) \
            throw *matched_trigger; \
        } \
        if (proc) READ_MEM(addr, size); \
        return data; \
      } \
      type##_t res; \
      load_slow_path(addr, sizeof(type##_t), (uint8_t*)&res); \
      if (proc) READ_MEM(addr, size); \
      return from_le(res); \
    }

为了粒度不同时不写重复代码,spike的设计中巧妙的利用了宏来定义整个代码,后续只要使用宏来完成函数声明即可; 函数首先对地址的对齐进行了检查,若不对齐,则通过misaligned_load来进行加;不对称的情况一般而言编译器是不会让它出现的,那么出现时候要不就是黑科技要不就是被黑了吧。 我们还是倒着看,发现函数的最下面逻辑是load_slow_path(),后面我们会分析,调用该函数说明要完成一次完整的TLB Walk来完成地址转化,也就意味着此时TLB中没有cache这个虚拟地址;从而我们可以反向推理,前面两种情况均是可以在TLB中直接找到转化地址的。 首先看看第一个分支

if (likely(tlb_load_tag[vpn % TLB_ENTRIES] == vpn)) {
    if (proc) READ_MEM(addr, size);
    return from_le(*(type##_t*)(tlb_data[vpn % TLB_ENTRIES].host_offset + addr));

果然是到tlb_load_tag这个结构中取出了了TLB Tag然后与此次的地址进行比对;这里有一句突兀的if (proc) READ_MEM(addr, size);,其含义是当spike允许commit功能时会完成内存读写轨迹的一个记录工作。这个点比较细,我们可以之后的文章中分析。

既然TLB中已经有了此虚拟地址对应的物理地址,接着就可以读取数据并返回了吧。代码中通过一句话就完成了这个工作,

return from_le(*(type##_t*)(tlb_data[vpn % TLB_ENTRIES].host_offset + addr));

这里的from_le()是用于处理大小端问题的,暂时可以先放一边。而整个取对应位置值的工作,是直接计算拿到TLB中的名为host_offset的值后,与对应的虚拟地址求和,接着直接使用指针解引用拿到对应的值

这里要搞清楚的几个概念是,由于是软件模拟内存的转化,从被模拟的角度看,是存在有virtual address与physcial address两种内存的形式。对于真正的物理机器而言,数据是存储在physicall address的视窗下的。而从用于模拟的软件来看,实际的数据在host机器的内存的虚拟地址上进行存储(有点绕 :D),所以会有一个host address的概念。转化好的physical address要进一步转化成host address才能拿到对应的数据

有趣的是第二个分支,

if (unlikely(tlb_load_tag[vpn % TLB_ENTRIES] == (vpn | TLB_CHECK_TRIGGERS))) { 
  type##_t data = from_le(*(type##_t*)(tlb_data[vpn % TLB_ENTRIES].host_offset + addr)); 
  if (!matched_trigger) { 
    matched_trigger = trigger_exception(OPERATION_LOAD, addr, data); 
    if (matched_trigger) 
      throw *matched_trigger; 
  } 
  if (proc) READ_MEM(addr, size); 
  return data; 
} 

由于最开始我没有去认真理解likelyunlikely导致了对于程序的理解有误,其本身与程序的语义无关而是编译器的优化功能;其中likely__builtin_expect((x), 1)的封装,表示此条件大概率为真;而unlikely__builtin_expect((x), 0)的封装,表示此条件大概率为假。借助开发者使用的这些修饰,编译器可以将概率较大的分支作为判断后的代码从而减少跳转指令带来的开销。

之前也说道了,这个分支表达的应该也是可以直接从TLB中拿到数据,那么与之前的分支有何不同呢,关键是TLB_CHECK_TRIGGERS这个值,如果在TLB中的TAG有这个值的mark话,根据源代码的注释,其表达如下含义

If a TLB tag has TLB_CHECK_TRIGGERS set, then the MMU must check for a trigger match before completing an access.

跟踪了以下源代码并查阅了一下手册,发现此功能主要与RISC-V的Debug/Trace Registers相关,这里也涉及到了调试的部分,我们未来再进行分析。

Perfect!两种通过TLB进行直接访问的方式咱已经看完了,接着看看slow_path的具体实现好了。

void mmu_t::load_slow_path(reg_t addr, reg_t len, uint8_t* bytes)
{
  reg_t paddr = translate(addr, len, LOAD);

  if (auto host_addr = sim->addr_to_mem(paddr)) {
    memcpy(bytes, host_addr, len);
    if (tracer.interested_in_range(paddr, paddr + PGSIZE, LOAD))
      tracer.trace(paddr, len, LOAD);
    else
      refill_tlb(addr, paddr, host_addr, LOAD);
  } else if (!mmio_load(paddr, len, bytes)) {
    throw trap_load_access_fault(addr);
  }
.....

首当其冲的自然就是translate()函数,我们看它的实现

reg_t mmu_t::translate(reg_t addr, reg_t len, access_type type)
{
  if (!proc)
    return addr;
  reg_t mode = proc->state.prv;
  if (type != FETCH) {
    if (!proc->state.debug_mode && get_field(proc->state.mstatus, MSTATUS_MPRV))
      mode = get_field(proc->state.mstatus, MSTATUS_MPP);
  }
  reg_t paddr = walk(addr, type, mode) | (addr & (PGSIZE-1));
  if (!pmp_ok(paddr, len, type, mode))
    throw_access_exception(addr, type);
  return paddr;
}

代码逻辑上,首先如果没有实例化处理器指针的话,就直接返回该地址(是不是意味着MMU没有打开呢)完成一定的检查之后,会调用子函数walk进行转化,转换结束后,同进行pmp(physical-memory protection)检查。最后才进行返回。walkpmp都比较硬件体系相关了,最好是结合相关手册一起食用,这里重在分析模拟器实现,就先略过了。

草草的看过load_slow_path()translate()后,下一步函数进入一个判断 if (auto host_addr = sim->addr_to_mem(paddr)) {,并且就代码去理解的话,这一条件为真的话,即会到对应的物理位置中去取得目标值,如果这一条件为假,那么代码将通过mmio_load(paddr, len, bytes)来完成值的读取。

代码逻辑是清晰的,但这几个陌生的函数,我们要放到后续的分析之中。 XD

STORE

STORE相较于LOAD其实就是反着来的一个过程,在理解了LOAD的实现后,STORE其实也非常清晰;

读者可以自行阅读riscv/mmu.h (store_func)riscv/mmu.cc (store_slow_path)来完成整个store过程的讨论。

FETCH

指令fetch的整个代码实现与load,store当然还是有显著的区别——首先fetch相对来说是不需要为不同粒度进行不同实现的(哦,当然这里忽略compressed指令)在处理器层面的分析上,我们简化了对fetch的描述,这里我们将整个流程给分析清楚

首先看到processor_t:step()中使用insn_fetch_t fetch = mmu->load_insn(pc);来完成slow_path下的指令加载(如单步跟踪模式),首先看看这个函数

// riscv/mmu.h
inline insn_fetch_t load_insn(reg_t addr)
{
  icache_entry_t entry;
  return refill_icache(addr, &entry)->data;
}

接着往下看这个名为refill_icache()的函数,由于代码相比于之前的长一些,我们分段来分析

// riscv/mmu.h
inline icache_entry_t* refill_icache(reg_t addr, icache_entry_t* entry)
{
  auto tlb_entry = translate_insn_addr(addr);
  insn_bits_t insn = from_le(*(uint16_t*)(tlb_entry.host_offset + addr));
  int length = insn_length(insn);
.....

首先,我们可以发现指令的地址translate有着自己的函数,而不是与store, load复用,这么做即有着抽象ITLB的作用,而且本身也通过内联优化来进行加速,具体的代码读者可以自行阅读。

  if (likely(length == 4)) {
    insn |= (insn_bits_t)from_le(*(const int16_t*)translate_insn_addr_to_host(addr + 2)) << 16;
  } else if (length == 2) {
    insn = (int16_t)insn;
  } else if (length == 6) {
    insn |= (insn_bits_t)from_le(*(const int16_t*)translate_insn_addr_to_host(addr + 4)) << 32;
    insn |= (insn_bits_t)from_le(*(const uint16_t*)translate_insn_addr_to_host(addr + 2)) << 16;
  } else {
    static_assert(sizeof(insn_bits_t) == 8, "insn_bits_t must be uint64_t");
    insn |= (insn_bits_t)from_le(*(const int16_t*)translate_insn_addr_to_host(addr + 6)) << 48;
    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 + 2)) << 16;
  }

接着呢,该函数利用取得的代码来分析长度,以决定是否要对指令进行进一步的处理。(一般而言指令都应该是4bytes的,这里需要关注到一些特殊的指令集合)

  insn_fetch_t fetch = {proc->decode_insn(insn), insn};
  entry->tag = addr;
  entry->next = &icache[icache_index(addr + length)];
  entry->data = fetch;

  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;
}

随后,这里将处理好的指令进行解码,也和我们之前对处理器的分析一致。随后,指令的地址与实际内容将填充到ITLB之中,方便下一次的访问。

Well,为了加速,processor_t:step()其实也具有一般的快速的访问方法,即使用icache,代码如下

// This figures out where to jump to in the switch statement
size_t idx = _mmu->icache_index(pc);

// This gets the cached decoded instruction from the MMU. If the MMU
// does not have the current pc cached, it will refill the MMU and
// return the correct entry. ic_entry->data.func is the C++ function
// corresponding to the instruction.
auto ic_entry = _mmu->access_icache(pc);

与slowpath不同的情况的是,其不一定每次都执行refill_icache操作,以此实现了加速。

至此,store, load与fetch均分析完毕;比较有意思的 ITLB 的存在因为可以减少指令decode的次数已实现加速。

设备

唔,终于到设备了。其实处理器、以及内存管理部分的模拟的代码还是较为直接的。在我对模拟器的修改过程中,主要也是设备层面的一些东西会有困扰——毕竟设备这种东西最需要的就是datasheet呀,目录下啥也没有,很多东西真的抓瞎哦。

好啦,如果提到设备,哪些东西我们会关心呢?

  1. 数据总线
  2. 只读存储器
  3. 主存
  4. 中断控制器

安啦,打起精神继续研究,整个有关于设备的关键代码聚集在riscv/devices.hriscv/devices.cc中 其中,最顶层的抽象类定义如下

// riscv/devices.h
class abstract_device_t {
public:
  virtual bool load(reg_t addr, size_t len, uint8_t* bytes) = 0;
  virtual bool store(reg_t addr, size_t len, const uint8_t* bytes) = 0;
  virtual ~abstract_device_t() {}
};

这个抽象设备定义了纯虚的loadstore函数,交给其派生的设备类来完成。

数据总线 Databus

自然,想要理解模拟器实现的databus的代码内涵,我们首先需要了解数据总线本身,而维基百科在普及知识方面总不会让人失望: 链接

总线(Bus)是指计算机组件间规范化的交换数据(data)的方式,即以一种通用的方式为各组件提供数据传送和控制逻辑。从另一个角度来看,如果说主板(Mother Board)是一座城市,那么总线就像是城市里的公共汽车(bus),能按照固定行车路线,传输来回不停运作的比特(bit)

由于总线存在的重要性,也合理解释了其在代码结构上置于靠前的原因,我们先查看类的声明。

class bus_t : public abstract_device_t {
public:
 	bool load(reg_t addr, size_t len, uint8_t* bytes);
  bool store(reg_t addr, size_t len, const uint8_t* bytes);
	void add_device(reg_t addr, abstract_device_t* dev);
	std::pair<reg_t, abstract_device_t*> find_device(reg_t addr);
private:
	std::map<reg_t, abstract_device_t*> devices;
};

可以看到,bus_t类相较于父类添加了add_device方法,find_device方法与名为devices的私有map成员。

我们接着一个一个的进行查看。

void bus_t::add_device(reg_t addr, abstract_device_t* dev)
{
  // Searching devices via lower_bound/upper_bound
  // implicitly relies on the underlying std::map 
  // container to sort the keys and provide ordered
  // iteration over this sort, which it does. (python's
  // SortedDict is a good analogy)
  devices[addr] = dev;
}

当一段代码的注释比代码内容要多的时候,这一部分代码一定很重要 ;D 实际上,上面的代码就是简单地将抽象设备指针加入数据总线的私有成员devices之中。 而这个注释呢,其实是解释后续find device所使用的方法——利用map的lower_bound与upper_bound,这和python的SortedDict较为类似。

std::pair<reg_t, abstract_device_t*> bus_t::find_device(reg_t addr)
{
  // See comments in bus_t::load
  auto it = devices.upper_bound(addr);
  if (devices.empty() || it == devices.begin()) {
    return std::make_pair((reg_t)0, (abstract_device_t*)NULL);
  }
  it--;
  return std::make_pair(it->first, it->second);
}

find_device的逻辑虽然简单,但让我们先看明白它要返回的目标类型,即std::pair<reg_t, abstract_device_t*>,这个键值对类型实际上也刚刚是databus的内部成员对象devices map存储的类型。 函数通过devicesupper_bound与提供的地址来进行设备寻找,注意,当没有找到此设备时,它将构造并返回一个空的pair.

我们接着同时分析loadstore的过程

bool bus_t::load(reg_t addr, size_t len, uint8_t* bytes)
{
  // Find the device with the base address closest to but
  // less than addr (price-is-right search)
  auto it = devices.upper_bound(addr);
  if (devices.empty() || it == devices.begin()) {
    // Either the bus is empty, or there weren't 
    // any items with a base address <= addr
    return false;
  }
  // Found at least one item with base address <= addr
  // The iterator points to the device after this, so
  // go back by one item.
  it--;
  return it->second->load(addr - it->first, len, bytes);
}
bool bus_t::store(reg_t addr, size_t len, const uint8_t* bytes)
{
  // See comments in bus_t::load
  auto it = devices.upper_bound(addr);
  if (devices.empty() || it == devices.begin()) {
    return false;
  }
  it--;
  return it->second->store(addr - it->first, len, bytes);
}

有意思的是,loadstore中都由类似find中使用的逻辑,同时由于upper_bound返回的是第一个大于该键的对象,所以对象需要执行it--后,才能继续对应的设备的loadstore函数。

总而言之,bus_t会是上层访问下层设备的封装,我们继续分析更具体的设备类型

只读存储器 ROM

实际上,ROM的设计本身会很简单,我们将声明与定义一起进行分析

// riscv/devices.h
class rom_device_t : public abstract_device_t {
public:
	  rom_device_t(std::vector<char> data);
  	bool load(reg_t addr, size_t len, uint8_t* bytes);
  	bool store(reg_t addr, size_t len, const uint8_t* bytes);
  	const std::vector<char>& contents() { return data; }
private:
  	std::vector<char> data;
};

// riscv/rom.cc
rom_device_t::rom_device_t(std::vector<char> data)
  : data(data)
{
}

bool rom_device_t::load(reg_t addr, size_t len, uint8_t* bytes)
{
  if (addr + len > data.size())
    return false;
  memcpy(bytes, &data[addr], len);
  return true;
}

bool rom_device_t::store(reg_t addr, size_t len, const uint8_t* bytes)
{
  return false;
}

可以看到,rom_device_t类主要是增设了名为data的vector对象,并提供名为contents()的访问方法。 load实现将利用输入的地址,从data中取出数据,而因为设备是只读的,store只需要做错误返回即可。

好吧,简单虽简单,但我们更要知道哪儿用到了只读存储的功能,在之前的讲述spike启动Linux的文章中,我们分析了spike在跳往bbl相关函数之前所构造的,有关设备树的代码

std::vector<char> rom((char*)reset_vec, (char*)reset_vec + sizeof(reset_vec));

dts = make_dts(INSNS_PER_RTC_TICK, CPU_HZ, initrd_start, initrd_end, procs, mems);
std::string dtb = dts_compile(dts);

rom.insert(rom.end(), dtb.begin(), dtb.end());
const int align = 0x1000;
rom.resize((rom.size() + align - 1) / align * align);

boot_rom.reset(new rom_device_t(rom));
bus.add_device(DEFAULT_RSTVEC, boot_rom.get());

代码中先使用跳往reset_vec前的准备代码构造一个向量,随后将向量中加入dts相关的数据。随后,我们看到利用rom向量实例化了rom_device_t类并赋值给boot_rom智能指针,并且将其添加到了总线之中。这里add_device中使用的地址DEFAULT_RSTVECriscv/encoding.h中定义,其值为0x00001000,对应着此rom_device_t在总线中的基地址。

well,这就很清楚哈~

主存 Memory

内存是要驻留运行时数据的关键设备,我们接着对其进行分析。首先我们查看其声明

class mem_t : public abstract_device_t {
public:
  mem_t(size_t size) : len(size) {
    if (!size)
      throw std::runtime_error("zero bytes of target memory requested");
    data = (char*)calloc(1, size);
    if (!data)
      throw std::runtime_error("couldn't allocate " + std::to_string(size) + " bytes of target memory");
  }
  mem_t(const mem_t& that) = delete;
  ~mem_t() { free(data); }
  bool load(reg_t addr, size_t len, uint8_t* bytes) { return false; }
  bool store(reg_t addr, size_t len, const uint8_t* bytes) { return false; }
  char* contents() { return data; }
  size_t size() { return len; }

private:
  char* data;
  size_t len;
};

memory(mem_t)与rom(rom_t)有很多相似,如添加了data和contents,不过还额外增加了size来使得访问更加方便。

这里的 =delete 完成的是默认构造函数功能的显示禁用函数,见例子

内存的实现这么简单自然也很合理,毕竟就只需要读写的功能就够了。我们来看看spike是在哪儿完成内存的实例化的。

实力化流程为spike_main/spike.cc main()内通过用户设置调用spike_main/make_mems(),默认的情况下将设置物理内存为2048MB也就是2GB空间。 实例化的mems将继续在sim对象的实例化过程中加入bus_t之中,默认情况下,即仅有一块连续的memory时,内存在总线中的起始物理地址为0x80000000,定义在riscv/encoding/h

Clint中断器

同理解databus一样,我们首先要理解Clint中断的内涵才能探讨其代码的实现,参考riscv的手册,有这样的概括

The Core-Local Interrupt Controller (CLIC) is designed to provide low-latency, vectored, pre-emptive interrupts for RISC-V systems.

虽然手册里面嘟嘟嘟说了一大堆,不过 Talk is cheap, show me the code,分析代码来看这里实现的内功并不多,我们看下声明

// riscv/devices.h
class clint_t : public abstract_device_t {
public:
	clint_t(std::vector<processor_t*>&, uint64_t freq_hz, bool real_time);
	bool load(reg_t addr, size_t len, uint8_t* bytes);
  bool store(reg_t addr, size_t len, const uint8_t* bytes);
  size_t size() { return CLINT_SIZE; }
  void increment(reg_t inc);
private:
	typedef uint64_t mtime_t;
	typedef uint64_t mtimecmp_t;
	typedef uint32_t msip_t;
	std::vector<processor_t*>& procs;
	uint64_t freq_hz;
	bool real_time;
	uint64_t real_time_ref_secs;
	uint64_t real_time_ref_usecs;
	mtime_t mtime;
	std::vector<mtimecmp_t> mtimecmp;
};

相比起前面几个设备而言,添加了不少的成员,自然,意味着其复杂性较高。看完声明看定义,先看构造函数如何

// riscv/clint.cc
clint_t::clint_t(std::vector<processor_t*>& procs, uint64_t freq_hz, bool real_time)
  : procs(procs), freq_hz(freq_hz), real_time(real_time), mtime(0), mtimecmp(procs.size())
{
  struct timeval base;

  gettimeofday(&base, NULL);

  real_time_ref_secs = base.tv_sec;
  real_time_ref_usecs = base.tv_usec;
}

这里的timeval是linux下的结构体表述,这儿初始化的作用大概就是对一些变量进行赋值而已。

void clint_t::increment(reg_t inc)
{
  if (real_time) {
   struct timeval now;
   uint64_t diff_usecs;

   gettimeofday(&now, NULL);
   diff_usecs = ((now.tv_sec - real_time_ref_secs) * 1000000) + (now.tv_usec - real_time_ref_usecs);
   mtime = diff_usecs * freq_hz / 1000000;
  } else {
    mtime += inc;
  }
  for (size_t i = 0; i < procs.size(); i++) {
    procs[i]->state.mip &= ~MIP_MTIP;
    if (mtime >= mtimecmp[i])
      procs[i]->state.mip |= MIP_MTIP;
  }
}

increment听起来真的很时钟哈,在执行逻辑step()中也表明每次指令执行,时钟都随之变化

clint->increment(INTERLEAVE / INSNS_PER_RTC_TICK);

代码的逻辑(当然不是real_time来模拟时),就是简单的增加成员mtime的值,随后给各个处理器的时钟中断状态state.mip进行处理。如当前时钟超过对呀processor的mtimecmp值时,给state.mip的MTIP bit置位。

The MTIP, STIP, UTIP bits correspond to timer interrupt-pending bits for machine, supervisor, and user timer interrupts,

具体有哪些中断相关的寄存器被模拟了(即使值都是由processor state维护的),我们可以阅读代码中的声明中的这个部分

// riscv/devices.hh
typedef uint64_t mtime_t;
typedef uint64_t mtimecmp_t;
typedef uint32_t msip_t;

再查看一下riscv/clint.cc中具体的loadstore实现,就可以清楚clint中断控制器实现了mtime, mtimecmpmsip 这几个寄存器,依靠其完成时钟中断的模拟。具体的loadstore实现就是根据地址来读写,这里就略过分析了。

我们找找看clint的实例化,其同mems一样是在sim_t对象实例化时来完成的,代码如下

clint.reset(new clint_t(procs, CPU_HZ / INSNS_PER_RTC_TICK, real_time_clint));
bus.add_device(CLINT_BASE, clint.get());

具体的CPU的频率什么的我们就不关心了,其中CLINT_BASE值为0x02000000,定义在riscv/encoding.h

其他设备

如果只从 riscv/devices.h 中分析,上一字节其实已经将涉及的设备完全介绍了。 当然,疑惑仍存在?为啥没有看到I/O设备呢?毕竟没有I/O的计算机可不完整呀?

当然,但从模拟器角度来看,像console, block devices以及network devices这样的,他们与memory, rom不同,它们要依靠host机器接口来完成。比如输入输出字符,是要反映到host机器的行为上来的。

为了处理这种情况,risc-v的software stack实现了fesvr机制,即frontend server,前端服务器。

这样的命名,可能是抽象的把接口作为服务来提供把;

riscv-fesvr提供各类功能,概括如下

  • Facilitates communication between a host machine and a RISC-V target
  • ELF loading, peripheral device emulation over HTIF
  • HTIF (Host Target InterFace): Communication bus for test hardware
  • Some other FPGA support

好的,我们看到了一个有趣的而且相关的概念,HTIF(Host Target InterFace),它实际上就负责主机程序与被模拟对象的通信。官方说明中,其用于实现

  • Non-blocking FIFO Interface, communicates non-zero values
  • Existing driver/device implementations: (console, block-devices, networking)

So far so good! 我没找到了想要的东西,接着就得看如何实现啦。

首先我们看一下bbl中的实现(注意这里的代码根目录要改变咯 :P)

// riscv-pk/machine/htif.h
#if __riscv_xlen == 64
# define TOHOST_CMD(dev, cmd, payload) \
  (((uint64_t)(dev) << 56) | ((uint64_t)(cmd) << 48) | (uint64_t)(payload))
#else
# define TOHOST_CMD(dev, cmd, payload) ({ \
  if ((dev) || (cmd)) __builtin_trap(); \
  (payload); })
#endif
#define FROMHOST_DEV(fromhost_value) ((uint64_t)(fromhost_value) >> 56)
#define FROMHOST_CMD(fromhost_value) ((uint64_t)(fromhost_value) << 8 >> 56)
#define FROMHOST_DATA(fromhost_value) ((uint64_t)(fromhost_value) << 16 >> 16)
extern uintptr_t htif;
void query_htif(uintptr_t dtb);
void htif_console_putchar(uint8_t);
int htif_console_getchar();
void htif_poweroff() __attribute__((noreturn));
void htif_syscall(uintptr_t);

可以看到,这部分代码首先封装了随后要使用的TOHOST_CMDFROMHOST_DEVFROMHOST_CMDFROMHOST_DATA这些宏,我们等会来分析它们。此外,定义了一个uintptr_t类型的htif的变量,以及其他的方法。由于整个内容都很重要,我们接着仔细的跟踪分析

// riscv-pk/machine/htif.cc
void htif_console_putchar(uint8_t ch)
{
#if __riscv_xlen == 32
  // HTIF devices are not supported on RV32, so proxy a write system call
  volatile uint64_t magic_mem[8];
  magic_mem[0] = SYS_write;
  magic_mem[1] = 1;
  magic_mem[2] = (uintptr_t)&ch;
  magic_mem[3] = 1;
  do_tohost_fromhost(0, 0, (uintptr_t)magic_mem);
#else
  spinlock_lock(&htif_lock);
    __set_tohost(1, 1, ch);
  spinlock_unlock(&htif_lock);
#endif
}

代码中已经标注,如果是32位的情况,HTIF设备不被支持只能通过proxy write sysacll完成;而64位的情况则会在锁的保护下调用__set_tohost(),有趣,我们看看它的实现

static void __set_tohost(uintptr_t dev, uintptr_t cmd, uintptr_t data)
{
  while (tohost)
    __check_fromhost();
  tohost = TOHOST_CMD(dev, cmd, data);
}

发现其只有在变量tohost为零时才能脱离死循环,随后为tohost赋予一个CMD值;

putchar()就这样了,那么getchar()呢?

int htif_console_getchar()
{
#if __riscv_xlen == 32
  // HTIF devices are not supported on RV32
  return -1;
#endif
  spinlock_lock(&htif_lock);
    __check_fromhost();
    int ch = htif_console_buf;
    if (ch >= 0) {
      htif_console_buf = -1;
      __set_tohost(1, 0, 0);
    }
  spinlock_unlock(&htif_lock);
  return ch - 1;
}

可以看到32位情况下将直接返回,而默认的64位架构下,首先会检查一下fromhost,然后将从htif_console_buf中取得一个字符,当字符非零时,将此buf置空然后即又调用一次__set_tohost

这里涉及到的变量,关键的是如下两位

volatile uint64_t tohost __attribute__((section(".htif")));
volatile uint64_t fromhost __attribute__((section(".htif")));

在变量定义的时候,我们能够看到__attribute__((section(".htif"))),这些变量在程序中会放置到一个名为*.htif*的段中。这个段有啥神奇的?我们查看bbl构建时候的链接脚本

// riscv-pk/bbl.lds
/*--------------------------------------------------------------------*/
/* HTIF, isolated onto separate page                                  */
/*--------------------------------------------------------------------*/
.htif :
{
  PROVIDE( __htif_base = . );
  *(.htif)
}
. = ALIGN(0x1000);

可以发现,这是一个特殊的段🤔️根据手册上给出的描述

In RV64 implmentations, two 64-bit communication registers in CSR space: (1) fromhost: Host writes, RISC-V target reads. (2) RISC-V target writes, Host reads.

我们再看一下这个值的编码与解码

#define TOHOST_CMD(dev, cmd, payload) 
  (((uint64_t)(dev) << 56) | ((uint64_t)(cmd) << 48) | (uint64_t)(payload))

可以看到tohost编码情况

0x H H H H H H H H H H H H H H H H
   | | | | |                     |  
   \ / \ / \                     /
   dev cmd         payload 

由于putchar实际只需要8bit的payload,这里48bit算是完全够了。

OKAY,target端的模拟基本很清楚啦,我们可以随后看看host端如何操作。在先前的讨论的Linux启动中描述的spike部分中,我们知道程序运行在htif::run()在调用start()后,主线程会持续留在一个死循环中,如下

while (!signal_exit && exitcode == 0)
{
  if (auto tohost = from_le(mem.read_uint64(tohost_addr))) {
    mem.write_uint64(tohost_addr, 0);
    command_t cmd(mem, tohost, fromhost_callback);
    device_list.handle_command(cmd);
  } else {
    idle();
  }
  device_list.tick();
  if (!fromhost_queue.empty() && mem.read_uint64(fromhost_addr) == 0) {
    mem.write_uint64(fromhost_addr, to_le(fromhost_queue.front()));
    fromhost_queue.pop();
  }
}

well我们可以大概看到这个主循环的逻辑,即先从tohost_add中取得target所设置的tohost变量的值,将这个值组成command_t结构体,然后交由device_list去处理;处理完之后会调用device_listtick方法;随后,会检查fromhost_queue队列并决定是否要向fromhost_addr中写入目标的值。

device_list?这里的设备指哪些呢,我们看一下初始化的时候,即sim_t对象初始化时完成父类构造函数时

// riscv-isa-sim/fesvr/htif.cc
htif_t::htif_t(int argc, char** argv) : htif_t()
{
  parse_arguments(argc, argv);
  register_devices();   // <- here
}
....
void htif_t::register_devices()
{
  device_list.register_device(&syscall_proxy);
  device_list.register_device(&bcd);
  for (auto d : dynamic_devices)
    device_list.register_device(d);
}

下面的dynamic_devices是用户可以额外添加的拓展的设备(未来spike应该会支持网络设备咯)我们可以先看看前两者syscall_proxybcd;前文有提到,syscall_proxy主要呢是为了支持pk而设计的,由于我们重点考虑bbl linux,其自身有系统调用实现,多数情况下无需代理。所以就单纯看看bcd好了。

// riscv-isa-sim/fesvr/device.h
class bcd_t : public device_t
{
public:
  bcd_t();
  const char* identity() { return "bcd"; }
  void tick();
private:
  void handle_read(command_t cmd);
  void handle_write(command_t cmd);
  std::queue<command_t> pending_reads;
};

我们找到类的声明,其派生自device_t类,该类内部提供了如void handle_command(command_t cmd);等的接口;我们来看看其方法的具体实现。

bcd_t::bcd_t()
{
  register_command(0, std::bind(&bcd_t::handle_read, this, _1), "read");
  register_command(1, std::bind(&bcd_t::handle_write, this, _1), "write");
}
void bcd_t::handle_read(command_t cmd)
{
  pending_reads.push(cmd);
}
void bcd_t::handle_write(command_t cmd)
{
  canonical_terminal_t::write(cmd.payload());
}
void bcd_t::tick()
{
  int ch;
  if (!pending_reads.empty() && (ch = canonical_terminal_t::read()) != -1)
  {
    pending_reads.front().respond(0x100 | ch);
    pending_reads.pop();
  }
}

哈哈,我们可以看到整个逻辑是非常非常清晰的。在构造函数中,其通过register_commanddevice_list中注册了自己的读写实现。其handle_read实现实际上就是想等待队列pending_reads中加入命令,这个命令将在tick()中完成执行,其实际上是调用了canonical_terminal_tread()方法,我们进一步看其实现

int canonical_terminal_t::read()
{
  struct pollfd pfd;
  pfd.fd = 0;
  pfd.events = POLLIN;
  int ret = poll(&pfd, 1, 0);
  if (ret <= 0 || !(pfd.revents & POLLIN))
    return -1;
  unsigned char ch;
  ret = ::read(0, &ch, 1);
  return ret <= 0 ? -1 : ch;
}

well,这里已经看到了host端的系统调用read了,同时canonical_terminal_tpollfs结构来管理句柄,以保证执行的准确性。 知道了readwrite自然也不远啦。我们可以看到bcd_t的write实际上是执行了canonical_terminal_t::write(),而该实现就是借助了host端的write方法,如下。

void canonical_terminal_t::write(char ch)
{
  if (::write(1, &ch, 1) != 1)
    abort();
}

这样,我们算是清楚了console的设备实现啦。阅读riscv-isa-sim/fesver/device.h其实还能找到一个disk_t类,不过近期的disk功能已经不被支持,我们或许在其他文章中来探讨探讨。

支持,分而治之的整个硬件体系积木已经都讨论过了。接下来,我们试着从high level的角度来整体查看体系实现。

合而御之

hardware

@TODO 概括描述这些类之间的联系和方法