概要
自作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
クラスをインスタンス化します。
その後、そのインスタンスs
のrun
メソッドを実行します。
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_t
のmemif_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_addr
やfromhost_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_t
のmem
メンバに格納する
Spikeのメインループ
main
関数がs.run
メソッドを実行し、その中で呼ばれるhtif_t::run
メソッドにSpikeのメインループがあります。
なお、sim_t
はhtif_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_t
のidle
メソッドが呼び出されます。
さらに、その中でプロセッサのステップを実行します。
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
のループで以下の処理を行います。
- 保留中の割り込みの処理
- LPAD命令の処理? (
check_if_lpad_required()
) - 命令フェッチ・デコード・実行
- 例外などの処理
特に、3は以下のような流れで行われます。 あくまで概要ですので、詳細はご自身で確認することをお勧めします。
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()
) - 適宜ページフォルトなどを通知する
- 命令TLBを検索する (
- 得られたポインタを用いて16bit単位でフェッチする
- 命令キャッシュには命令をデコードして得られるコールバック関数を保存する (
decode_insn()
)
- フェッチとデコードした命令のコールバック関数を実行し、
pc
を更新する - 条件 (命令キャッシュミスなど?) が成立するまで、2に戻って繰り返す
- 条件 (ステップ数経過など?) が成立するまで、1に戻って繰り返す
- 終了
命令キャッシュにミスしたときだけTLBにアクセスしているため、VIVT方式のように感じます。
興味深いのは、TLBにホスト上のアドレスを保持している点です。
アドレス変換によって物理アドレスpaddr
を計算し、それに対応するホスト上のアドレスhost_addr
をaddr_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.