眼鏡止水

FPGA、ネットワーク機器、料理、そしてメガネ女子。

【RISC-V】ISAシミュレータSpikeの構成を調査する

概要

自作CPUの動作検証において、「正しい」モデルとの比較が有効です。 本記事では、RISC-VのISAシミュレータであるSpikeの構成を調査します。 これにより、将来的にVerilatorのテストベンチと統合する方法を模索します。

本編

(注) 本記事では、Spikeのcommit b0d762を用います。 また、解析にも自身がないため、本記事のご利用は参考程度にとどめることをお勧めします。

Spikeの実行ファイルの構成

SpikeのREADMEに従ってビルドを行うと、spike実行ファイルが生成されます。 初めに、このファイルのソースを確認します。

  • riscv-isa-sim/Makefile.in
# Build programs which will be installed

$(2)_install_prog_objs := $$(patsubst %.cc, %.o, $$($(2)_install_prog_srcs))
$(2)_install_prog_deps := $$(patsubst %.o, %.d, $$($(2)_install_prog_objs))
$(2)_install_prog_exes := $$(patsubst %.cc, %, $$($(2)_install_prog_srcs))

$$($(2)_install_prog_objs) : %.o : %.cc $$($(2)_gen_hdrs)
    $(COMPILE) -c $$<

$$($(2)_install_prog_exes) : % : %.o $$($(2)_prog_libnames)
    $(LINK) $$($(2)_LDFLAGS) -o $$@ $$< $$($(2)_prog_libnames) $(LIBS)
  • riscv-isa-sim/spike_main/spike_main.mk.in
spike_main_install_prog_srcs = \
    spike.cc \
    spike-log-parser.cc \
    xspike.cc \
    termios-xspike.cc \

これらのファイルによると、spike実行ファイルの主なソースはriscv-isa-sim/spike_main/spike.ccだと読めます。 このファイルがmain関数を定義しています。

main関数では、引数のパースや諸々の初期化を行った後、sim_tクラスをインスタンス化します。 その後、そのインスタンスsrunメソッドを実行します。

  • riscv-isa-sim/spike_main/spike.cc
int main(int argc, char** argv)
{
...
  sim_t s(&cfg, halted,
      mems, plugin_device_factories, htif_args, dm_config, log_path, dtb_enabled, dtb_file,
      socket,
      cmd_file,
      instructions);
...
  auto return_code = s.run();

このsim_tクラスはプロセッサ、メモリ、CLINT、PLIC、そしてUARTなどのインスタンスを保持します。 特に、プロセッサはprocessor_tクラスに実装されているようです。 したがって、このsim_tクラスが仮想的なSoCを構成していると思われます。

  • riscv-isa-sim/riscv/sim.h
// this class encapsulates the processors and memory in a RISC-V machine.
class sim_t : public htif_t, public simif_t
{

引数のパースとELFファイルのロード

続いて、spike実行ファイルの引数がどのようにパースされるかを調査します。 これにより、アプリケーションのELFファイルがプロセッサから読めるようになるまでの経路を理解します。

Spikeのmain関数はoption_parser_tクラスをインスタンス化し、parseメソッドに引数を渡します。

  • riscv-isa-sim/spike_main/spike.cc
int main(int argc, char** argv)
{
...
  option_parser_t parser;
  parser.help(&suggest_help);
  parser.option('h', "help", 0, [&](const char UNUSED *s){help(0);});
  parser.option('d', 0, 0, [&](const char UNUSED *s){debug = true;});
...
  auto argv1 = parser.parse(argv);
  std::vector<std::string> htif_args(argv1, (const char*const*)argv + argc);

このメソッドは、ハイフン-から始まる引数を受け、変数に格納します。 実装はriscv-isa-sim/fesvr/option_parser.ccにあります。

一方で、アプリケーションのELFファイルは-から始まるオプションではなく、直接ファイル名を指定します。 例えば、rv32ui-p-addiというアプリケーションを実行するには、以下のようにします。

spike --isa=rv32i rv32ui-p-addi

アプリケーションのパスのパース () は、以下の経路で行われます。 初めに、htif_argsというベクタに格納されます。 そして、sim_tのコンストラクタに第5引数として渡されます。

  • riscv-isa-sim/spike_main/spike.cc
int main(int argc, char** argv)
{
...
  auto argv1 = parser.parse(argv);
  std::vector<std::string> htif_args(argv1, (const char*const*)argv + argc);
...
  sim_t s(&cfg, halted,
      mems, plugin_device_factories, htif_args, dm_config, log_path, dtb_enabled, dtb_file,
      socket,
      cmd_file,
      instructions);

sim_tのコンストラクタの第5引数は、htif_tクラスのコンストラクタにそのまま渡されます。

  • riscv-isa-sim/riscv/sim.cc
sim_t::sim_t(const cfg_t *cfg, bool halted,
             std::vector<std::pair<reg_t, abstract_mem_t*>> mems,
             const std::vector<device_factory_sargs_t>& plugin_device_factories,
             const std::vector<std::string>& args,
             const debug_module_config_t &dm_config,
             const char *log_path,
             bool dtb_enabled, const char *dtb_file,
             bool socket_enabled,
             FILE *cmd_file, // needed for command line option --cmd
             std::optional<unsigned long long> instruction_limit)
  : htif_t(args),

htif_tのコンストラクタは、関数オーバーロードにより3種類定義されています。 今回利用するものは引数の型がconst std::vector<std::string>&のものです。 このコンストラクタは内部でparse_argumentsメソッドとregister_devicesメソッドを呼びます。

  • riscv-isa-sim/fesvr/htif.cc
htif_t::htif_t(const std::vector<std::string>& args) : htif_t()
{
  int argc = args.size() + 1;
  std::vector<char*>argv(argc);
  argv[0] = (char *) "htif";
  for (unsigned int i = 0; i < args.size(); i++) {
    argv[i+1] = (char *) args[i].c_str();
  }
  //Set line size as 16 by default.
  line_size = 16;
  parse_arguments(argc, &argv[0]);
  register_devices();
}

parse_argumentsメソッドがELFファイル名をtargsベクタ (htif_tのメンバ) に保存します。

  • riscv-isa-sim/fesvr/htif.cc
void htif_t::parse_arguments(int argc, char ** argv)
{
...
  while (optind < argc)
    targs.push_back(argv[optind++]);

さて、このファイル名はこの先sim_tのコンストラクタから触れられることはありません。 main関数が呼ぶs.runメソッド (sim_t::run) の先で使用されます。 以下に、関数呼び出しの流れがわかるソースを示します。

  • riscv-isa-sim/spike_main/spike.cc
int main(int argc, char** argv)
{
...
  sim_t s(&cfg, halted,
...
  auto return_code = s.run();
  • riscv-isa-sim/riscv/sim.cc
int sim_t::run()
{
  if (!debug && log)
    set_procs_debug(true);

  htif_t::set_expected_xlen(harts[0]->get_isa().get_max_xlen());

  // htif_t::run() will repeatedly call back into sim_t::idle(), each
  // invocation of which will advance target time
  return htif_t::run();
}
  • riscv-isa-sim/fesvr/htif.cc
int htif_t::run()
{
  start();
void htif_t::start()
{
  if (!targs.empty()) {
    if (targs[0] != "none") {
      try {
        load_program();
      } catch (const incompat_xlen & err) {
        fprintf(stderr, "Error: cannot execute %d-bit program on RV%d hart\n", err.actual_xlen, err.expected_xlen);
        exit(1);
      }
      reset();
void htif_t::load_program()
{
  std::map<std::string, uint64_t> symbols = load_payload(targs[0], &entry, load_offset);

  load_symbols(symbols);

  for (auto payload : payloads) {
    reg_t dummy_entry;
    load_payload(payload, &dummy_entry, 0);
  }

  return;
}

load_payload関数はC++初心者の自分が説明するのは困難な込み入った実装になっていますが、最終的にhtif_tmemif_t memメンバにELFの内容を書き込み、シンボルの一覧を返します。 処理はriscv-isa-sim/fesvr/elfloader.ccが実装するload_elf関数が担います。

  • riscv-isa-sim/fesvr/htif.cc
std::map<std::string, uint64_t> htif_t::load_payload(const std::string& payload, reg_t* entry, reg_t load_offset)
{
...
    return load_elf(path.c_str(), &preload_aware_memif, entry, load_offset, expected_xlen);
  • riscv-isa-sim/fesvr/elfloader.cc
std::map<std::string, uint64_t> load_elf(const char* fn, memif_t* memif, reg_t* entry,
                                         reg_t load_offset, unsigned required_xlen = 0)
{
...
    for (unsigned i = 0; i < bswap(eh->e_phnum); i++) {                        \
      if (bswap(ph[i].p_type) == PT_LOAD && bswap(ph[i].p_memsz)) {            \
        reg_t load_addr = bswap(ph[i].p_paddr) + load_offset;                  \
        if (bswap(ph[i].p_filesz)) {                                           \
          assert(size >= bswap(ph[i].p_offset) + bswap(ph[i].p_filesz));       \
          memif->write(load_addr, bswap(ph[i].p_filesz),                       \
                       (uint8_t*)buf + bswap(ph[i].p_offset));                 \
        }                                                                      \
        if (size_t pad = bswap(ph[i].p_memsz) - bswap(ph[i].p_filesz)) {       \
          zeros.resize(pad);                                                   \
          memif->write(load_addr + bswap(ph[i].p_filesz), pad, zeros.data());  \
        }                                                                      \
      }                                                                        \
    }                                                                          \
...
  return symbols;

その後、load_symbols関数によってシンボル情報をもとにtohost_addrfromhost_addrなどを設定し、ELFファイルのロードが完了します。

以上より、htif.memがELFファイルの情報を保持していると考えられます。 したがって、Spikeのプロセッサはその領域をメモリとして扱っていると予想できます。

ここまでのまとめ

  • sim_tのコンストラクタがhtif_tをインスタンス化する
  • htif_tのコンストラクタが引数をパースし、メンバに保持する
  • main() -> sim_t::run() -> htif_t::run() -> htif_t::start() -> htif_t::load_program() -> load_payload()とコールする
  • load_payload()がELFファイルをロードし、htif_tmemメンバに格納する

Spikeのメインループ

main関数がs.runメソッドを実行し、その中で呼ばれるhtif_t::runメソッドにSpikeのメインループがあります。 なお、sim_thtif_tを継承しているため、このrunメソッドが使えます。

  • riscv-isa-sim/riscv/sim.cc
int sim_t::run()
{
  if (!debug && log)
    set_procs_debug(true);

  htif_t::set_expected_xlen(harts[0]->get_isa().get_max_xlen());

  // htif_t::run() will repeatedly call back into sim_t::idle(), each
  // invocation of which will advance target time
  return htif_t::run();
}
  • riscv-isa-sim/fesvr/htif.cc
int htif_t::run()
{
...
  while (!should_exit())
  {
...
      if (tohost != 0) {
        command_t cmd(mem, tohost, fromhost_callback);
        device_list.handle_command(cmd);
      } else {
        idle();
      }

      device_list.tick();

メインループでは、tohostに0以外の値が書かれるまでidleメソッドの呼び出しを繰り返します。 このidleメソッドはhtif_tでは仮想関数として定義されており、継承先のsim_tidleメソッドが呼び出されます。 さらに、その中でプロセッサのステップを実行します。

  • riscv-isa-sim/riscv/sim.cc
void sim_t::idle()
{
  if (done())
    return;

  if (debug || ctrlc_pressed)
    interactive();
  else {
    if (instruction_limit.has_value()) {
      if (*instruction_limit < INTERLEAVE) {
        // Final step.
        step(*instruction_limit);
        htif_exit(0);
        *instruction_limit = 0;
        return;
      }
      *instruction_limit -= INTERLEAVE;
    }
    step(INTERLEAVE);
  }

  if (remote_bitbang)
    remote_bitbang->tick();
}
void sim_t::step(size_t n)
{
  for (size_t i = 0, steps = 0; i < n; i += steps)
  {
    steps = std::min(n - i, INTERLEAVE - current_step);
    procs[current_proc]->step(steps);

    current_step += steps;
    if (current_step == INTERLEAVE)
    {
      current_step = 0;
      procs[current_proc]->get_mmu()->yield_load_reservation();
      if (++current_proc == procs.size()) {
        current_proc = 0;
        reg_t rtc_ticks = INTERLEAVE / INSNS_PER_RTC_TICK;
        for (auto &dev : devices) dev->tick(rtc_ticks);
      }
    }
  }
}
  • riscv-isa-sim/riscv/execute.cc
// fetch/decode/execute loop
void processor_t::step(size_t n)
{

ここまでのまとめ

  • htif_t::run()がメインループ
  • メインループでsim_t::idle()を呼び出し、その先でプロセッサのステップ関数processor_t::step(size_t n)が呼ばれる

プロセッサのステップ関数は何をしているか

プロセッサのステップ関数processor_t::step(size_t n)は、指定されたステップ数nのループで以下の処理を行います。

  1. 保留中の割り込みの処理
  2. LPAD命令の処理? (check_if_lpad_required())
  3. 命令フェッチ・デコード・実行
  4. 例外などの処理

特に、3は以下のような流れで行われます。 あくまで概要ですので、詳細はご自身で確認することをお勧めします。

  1. pcを用いて命令キャッシュにアクセス (access_icache(pc))
    • ヒットしたらそのエントリを返すメソッド
    • ミスしたら命令キャッシュを補充 (refill_icache())する
      • 命令TLBを検索する (fetch_insn_parcel() -> access_tlb())
      • ヒットならホスト (Spikeを実行しているコンピュータ?) 上のアドレス (insn_parcel_t* host_addr) のポインタを返す
      • ミスならページウォークしてTLBを補充し (fetch_slow_path() -> refill_tlb())、ホスト上のアドレスのポインタを返す (sim_t::addr_to_mem())
      • 適宜ページフォルトなどを通知する
    • 得られたポインタを用いて16bit単位でフェッチする
    • 命令キャッシュには命令をデコードして得られるコールバック関数を保存する (decode_insn())
  2. フェッチとデコードした命令のコールバック関数を実行し、pcを更新する
  3. 条件 (命令キャッシュミスなど?) が成立するまで、2に戻って繰り返す
  4. 条件 (ステップ数経過など?) が成立するまで、1に戻って繰り返す
  5. 終了

命令キャッシュにミスしたときだけTLBにアクセスしているため、VIVT方式のように感じます。

興味深いのは、TLBにホスト上のアドレスを保持している点です。 アドレス変換によって物理アドレスpaddrを計算し、それに対応するホスト上のアドレスhost_addraddr_to_memメソッドによって取得します。 これにより、メモリアクセスの実装が単純になると考えられます。

  • riscv-isa-sim/riscv/mmu.cc
mmu_t::insn_parcel_t mmu_t::fetch_slow_path(reg_t vaddr)
{
...
  if (!tlb_hit) {
    paddr = translate(access_info, sizeof(insn_parcel_t));
    host_addr = (uintptr_t)sim->addr_to_mem(paddr);

    refill_tlb(vaddr, paddr, (char*)host_addr, FETCH);
  }
  • riscv-isa-sim/riscv/sim.h
class sim_t : public htif_t, public simif_t
{
...
  // If padd corresponds to memory (as opposed to an I/O device), return a
  // host pointer corresponding to paddr.
  // For these purposes, only memories that include the entire base page
  // surrounding paddr are considered; smaller memories are treated as I/O.
  virtual char* addr_to_mem(reg_t paddr) override;
  • riscv-isa-sim/riscv/sim.cc
char* sim_t::addr_to_mem(reg_t paddr) {
  if (!paddr_ok(paddr))
    return NULL;
  auto desc = bus.find_device(paddr >> PGSHIFT << PGSHIFT, PGSIZE);
  if (auto mem = dynamic_cast<abstract_mem_t*>(desc.second))
    return mem->contents(paddr - desc.first);
  return NULL;
}

addr_to_memメソッドは仮想関数contentsを実行します。 この関数の実体は、例えばmem_tクラスの場合、以下の実装です。

char* mem_t::contents(reg_t addr) {
  reg_t ppn = addr >> PGSHIFT, pgoff = addr % PGSIZE;
  auto search = sparse_memory_map.find(ppn);
  if (search == sparse_memory_map.end()) {
    auto res = (char*)calloc(PGSIZE, 1);
    if (res == nullptr)
      throw std::bad_alloc();
    sparse_memory_map[ppn] = res;
    return res + pgoff;
  }
  return search->second + pgoff;
}

結局何だったのか

記事の着地点がアヤシくなってきたため、一旦このあたりで切り上げましょう。 「将来的にVerilatorのテストベンチと統合する」という目的を達成する方法を考えます。

調査から、SoCの解像度でのシミュレーションにはsim_tクラスを用いることが最適と思われます。 コンストラクタの引数にELFファイルのパスを渡してやれば、そのファイルを実行できます。 また、SoCの構成 (メモリマップなど) も比較的容易に調整できるようです。

一方で、より軽量なシミュレーションのために、processor_tなどの必要最低限のクラスのみをインスタンス化することも可能でしょう。 その場合は、simif_tなどを継承してsim_tのサブセット的なクラスを作ることが好ましいと考えられます。

今後、本記事の調査をもとに、Verilatorのテストベンチとの統合を試みます。

ライセンス

Spikeのライセンスを以下に示します。

Copyright (c) 2010-2017, The Regents of the University of California
(Regents).  All Rights Reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright
   notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
   notice, this list of conditions and the following disclaimer in the
   documentation and/or other materials provided with the distribution.
3. Neither the name of the Regents nor the
   names of its contributors may be used to endorse or promote products
   derived from this software without specific prior written permission.

IN NO EVENT SHALL REGENTS BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT,
SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING
OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF REGENTS HAS
BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

REGENTS SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE SOFTWARE AND ACCOMPANYING DOCUMENTATION, IF ANY, PROVIDED
HEREUNDER IS PROVIDED "AS IS". REGENTS HAS NO OBLIGATION TO PROVIDE
MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.