シンプルコンピュータの実装:基本エミュレータの構築

コード管理

本PAを開始する前に、プロジェクトディレクトリで以下のコマンドを実行してブランチを整理してください:

git commit --allow-empty -am "before starting pa1"
git checkout master
git merge pa0
git checkout -b pa1

重要な注意点

  • 実装には深刻なバグが存在する可能性があります
  • アプローチには誤りが含まれている可能性があるため、各自で判断してください

利用上の注意

  • 本ブログの内容は学習参考のみを目的としています
  • 概念を理解した上で、各自で実装することをお勧めします
  • 議論や交流を歓迎します

タスク PA1.1: シングルステップ実行、レジスタ状態表示、メモリスキャンの実装

ソースコードの読解

ソースコードを読む際に、`/ics2024/nemu/scripts/build.mk`にあるMakefileに遭遇しました。ここではMakefileの構文の一部を簡単に紹介します。

パターン置換

OBJS = $(SRCS:%.c=$(OBJ_DIR)/%.o) $(CXXSRC:%.cc=$(OBJ_DIR)/%.o)

CおよびC++ソースファイルのオブジェクトファイルをOBJS変数にマージします。例えば、SRCSが`src/main.c src/utils.c`で、CXXSRCが`src/main.cc src/utils.cc`、OBJ_DIRが`build/obj`の場合、OBJSには`build/obj/main.o build/obj/utils.o`が含まれます。

%:ワイルドカード文字で、任意の文字(空文字を含む)に一致します。例えば、%.cは.cで終わるすべてのファイル名に一致します。

以下のコンパイルパターンを理解しましょう:

# Compilation patterns
$(OBJ_DIR)/%.o: %.c
  @echo + CC $<
  @mkdir -p $(dir $@)
  @$(CC) $(CFLAGS) -c -o $@ $<
  $(call call_fixdep, $(@:.o=.d), $@)

makeプロセスで実行されるコマンドを確認し、逆に`$(CFLAGS)`などの変数の値を理解するために、`make -nB`コマンドを使用できます。これにより、makeプログラムは「コマンドを出力するだけで実行しない」方式でターゲットの構築を強制します。

最初のクライアントプログラムの準備

init_monitor()関数

次に、`nemu/src/monitor/monitor.c`の初期化関数`init_monitor()`に目を向けましょう。この関数はクライアントプログラムをクライアントコンピュータに読み込みます。

init_monitor()関数のコードは以下の通りです:

void init_monitor(int argc, char *argv[]) {
  /* グローバル初期化を実行 */
  
  /* 引数を解析 */
  parse_args(argc, argv);
  
  /* 乱数シードを設定 */
  init_rand();
  
  /* ログファイルを開く */
  init_log(log_file);
  
  /* メモリを初期化 */
  init_mem();
  
  /* デバイスを初期化 */
  IFDEF(CONFIG_DEVICE, init_device());
  
  /* ISAに依存する初期化を実行 */
  init_isa();
  
  /* イメージをメモリにロード。これにより組み込みイメージが上書きされる */
  long img_size = load_img();
  
  /* 差分テストを初期化 */
  init_difftest(diff_so_file, img_size, difftest_port);
  
  /* シンプルデバッガを初期化 */
  init_sdb();
  
#ifndef CONFIG_ISA_loongarch32r
  IFDEF(CONFIG_ITRACE, init_disasm(
    MUXDEF(CONFIG_ISA_x86,     "i686",
    MUXDEF(CONFIG_ISA_mips32,  "mipsel",
    MUXDEF(CONFIG_ISA_riscv,
      MUXDEF(CONFIG_RV64,      "riscv64",
                               "riscv32"),
                               "bad"))) "-pc-linux-gnu"
  ));
#endif
  
  /* ウェルカムメッセージを表示 */
  welcome();
}

引数解析関数`parse_args()`:

static int parse_args(int argc, char *argv[]) {
  const struct option table[] = {
    {"batch"    , no_argument      , NULL, 'b'},
    {"log"      , required_argument, NULL, 'l'},
    {"diff"     , required_argument, NULL, 'd'},
    {"port"     , required_argument, NULL, 'p'},
    {"help"     , no_argument      , NULL, 'h'},
    {0          , 0                , NULL,  0 },
  };
  int o;
  while ( (o = getopt_long(argc, argv, "-bhl:d:p:", table, NULL)) != -1) {
    switch (o) {
      case 'b': sdb_set_batch_mode(); break;
      case 'p': sscanf(optarg, "%d", &difftest_port); break;
      case 'l': log_file = optarg; break;
      case 'd': diff_so_file = optarg; break;
      case 1: img_file = optarg; return 0;
      default:
        printf("Usage: %s [OPTION...] IMAGE [args]\n\n", argv[0]);
        printf("\t-b,--batch              バッチモードで実行\n");
        printf("\t-l,--log=FILE           ログをFILEに出力\n");
        printf("\t-d,--diff=REF_SO        参照REF_SOでDiffTestを実行\n");
        printf("\t-p,--port=PORT          ポートPORTでDiffTestを実行\n");
        printf("\n");
        exit(0);
    }
  }
  return 0;
}

ここで使用されている`getopt_long()`関数の機能と使用方法:

機能:コマンドラインオプションを解析

関数プロトタイプ

int getopt_long(int argc, char * const argv[],
                  const char *optstring,
                  const struct option *longopts, int *longindex);

argv:オプション要素。-で始まり、その後にオプション文字が続きます。関数`getopt_long`を繰り返し呼び出すと、関数は連続してオプション要素を返します。

option構造体のテーブルはオプションテーブルで、その実装方法は以下の通りです:

struct option {
    const char *name;
    int         has_arg;
    int        *flag;
    int         val;
};

nameはオプションの名前;
has_arg:
  0またはno_argumentの場合、引数は不要です。
  1またはrequired_argumentの場合、引数が必要です。
flag:NULLの場合、valを返します。それ以外の場合、valをflagが指す位置に格納し、0を返します。
val:flagがNULLの場合、この値を返します。それ以外の場合、flagが指す変数に格納されます。

戻り値
flagがNULLの場合、option構造体のvalを返します。それ以外の場合は0を返します。

コード全体の分析

オプションテーブルの定義:

const struct option table[] = {
  {"batch"    , no_argument      , NULL, 'b'},
  {"log"      , required_argument, NULL, 'l'},
  {"diff"     , required_argument, NULL, 'd'},
  {"port"     , required_argument, NULL, 'p'},
  {"help"     , no_argument      , NULL, 'h'},
  {0          , 0                , NULL,  0 },
};

ここでは、プログラムがサポートするコマンドラインオプションを含むオプションテーブルを定義しています:

`--batch (-b)`: 引数不要、プログラムをバッチモードに設定します。
`--log=FILE (-l)`: 引数が必要、ログファイルを指定します。
`--diff=REF_SO (-d)`: 引数が必要、参照差分ファイルを指定します。
`--port=PORT (-p)`: 引数が必要、ポート番号を指定します。
`--help (-h)`: 引数不要、ヘルプ情報を表示します。

オプションの解析:

while ( (o = getopt_long(argc, argv, "-bhl:d:p:", table, NULL)) != -1) {

`getopt_long`を使用してコマンドライン引数を解析します。オプションが見つかった場合、oはそのオプションに対応する文字に設定されます。

オプションの処理:

switch (o) {
  case 'b': sdb_set_batch_mode(); break;
  case 'p': sscanf(optarg, "%d", &difftest_port); break;
  case 'l': log_file = optarg; break;
  case 'd': diff_so_file = optarg; break;
  case 1: img_file = optarg; return 0;
  default:
    // 使用法情報を表示
}

-bオプションの場合、`sdb_set_batch_mode()`関数を呼び出します。
-pオプションの場合、sscanfを使用して引数を整数に変換し、`difftest_port`に格納します。
-lおよび-dオプションの場合、対応する引数を`log_file`および`diff_so_file`変数に格納します。
1の場合、これはイメージファイル引数を表し、`img_file`に格納して0を返します。
未知のオプションが検出された場合、使用法情報を表示してプログラムを終了します。

関数は0を返し、解析が成功したことを示します。

init_rand()

`src/utils/timer.c`内の実装:

void init_rand() {
  srand(get_time_internal());
}

`get_time_internal()`関数はマクロ定義に基づいて内部時間を決定します。そして`srand()`関数は疑似乱数関数`rand()`と連携して動作します:

`rand()`関数:疑似乱数を生成します。
`srand(seed)`:seedを起点として乱数列を生成しますが、この列はseed値に関連付けられています。同じseed値の場合、srandを呼び出すと同じ列が生成されます。同じシード値を再設定すると、疑似乱数ジェネレータは同じ初期状態から始まり、生成される乱数列も完全に同じになります。

したがって、ここでの`init_rand`関数の意味は、現在の内部時間(変数)に基づいて列を生成することです。これにより、`rand()`関数を呼び出すたびに、異なる値が生成されます。

void init_log(const char log_file)

FILE *log_fp = NULL;

void init_log(const char *log_file) {
  log_fp = stdout;	// log_fpを標準出力に設定
  if (log_file != NULL) {
    FILE *fp = fopen(log_file, "w");
    Assert(fp, "Can not open '%s'", log_file);
    log_fp = fp;
  }
  Log("Log is written to %s", log_file ? log_file : "stdout");
}

この関数はログ記録の出力先を設定し、ユーザーが指定したファイル名を優先的に考慮します。指定されたファイルを開けない場合、デフォルトでログは標準出力に出力されます。この方法により、ログ出力の先を柔軟に制御できます。

例えば、`init_log("mylog.txt");`を呼び出すと:

`log_fp`は`mylog.txt`ファイルを指します。
`mylog.txt`ファイルを開けない場合、プログラムはエラーメッセージを表示して終了します。
Log関数は"Log is written to mylog.txt"を出力します。

一方、`init_log(NULL);`を呼び出すと:

`log_fp`は標準出力`stdout`を指します。
Log関数は"Log is written to stdout"を出力します。

void init_mem()

static uint8_t *pmem = NULL;
void init_mem() {
#if   defined(CONFIG_PMEM_MALLOC)
  pmem = malloc(CONFIG_MSIZE);
  assert(pmem);
#endif
  IFDEF(CONFIG_MEM_RANDOM, memset(pmem, rand(), CONFIG_MSIZE));
  Log("physical memory area [" FMT_PADDR ", " FMT_PADDR "]", PMEM_LEFT, PMEM_RIGHT);
}

メモリpmemにスペースを割り当てます。

`CONFIG_PMEM_MALLOC`マクロが定義されている場合、`CONFIG_MSIZE`バイトのサイズのスペースをpmemに割り当てます。

`CONFIG_MEM_RANDOM`マクロが定義されている場合、pmemが指すメモリ領域をランダムな値で埋めます(前述の`init_rand()`に関連しています)。rand()はランダムな値を生成し、`CONFIG_MSIZE`はメモリ領域のサイズです。

void init_isa()

`nemu/src/isa/riscv32/init.c`で定義:

// これはuint8_tと一貫性がない
// しかし、配列に直接アクセスしないため問題ない
static const uint32_t img [] = {
  0x00000297,  // auipc t0,0
  0x00028823,  // sb  zero,16(t0)
  0x0102c503,  // lbu a0,16(t0)
  0x00100073,  // ebreak (nemu_trapとして使用)
  0xdeadbeef,  // いくつかのデータ
};

static void restart() {
  /* 初期プログラムカウンタを設定 */
  cpu.pc = RESET_VECTOR;

  /* ゼロレジスタは常に0 */
  cpu.gpr[0] = 0;
}

void init_isa() {
  /* 組み込みイメージをロード */
  memcpy(guest_to_host(RESET_VECTOR), img, sizeof(img));

  /* この仮想コンピュータシステムを初期化 */
  restart();
}

クライアントプログラムimgはriscv32ベースの命令配列であることがわかります。実装されている機能は、pc+16の位置にデータ0を格納し、pc+16のメモリアドレスのデータ(0)をレジスタa0に格納することです。

`void init_isa()`のロジックは、まず組み込みプログラムをメモリの指定された領域に格納することです:

/* 組み込みイメージをロード */
memcpy(guest_to_host(RESET_VECTOR), img, sizeof(img));

このメモリアドレスは`guest_to_host(RESET_VECTOR)`で、固定されたメモリ位置`RESET_VECTOR`です。対応する関数の実装は(`src/memory/paddr.c`)にあります:

static uint8_t *pmem = NULL;

uint8_t* guest_to_host(paddr_t paddr) { return pmem + paddr - CONFIG_MBASE; }

pmemは128MBの物理メモリへのポインタで、このpaddrは将来使用される物理アドレスです。今は深く掘り下げる必要はありません。

入力の`RESET_VECTOR`と対応する`CONFIG_MBASE`の定義はそれぞれ以下の通りです:

#define CONFIG_MSIZE 0x8000000	
#define CONFIG_PC_RESET_OFFSET 0x0 


#define PMEM_LEFT  ((paddr_t)CONFIG_MBASE)					// 0x80000000
#define PMEM_RIGHT ((paddr_t)CONFIG_MBASE + CONFIG_MSIZE - 1)
#define RESET_VECTOR (PMEM_LEFT + CONFIG_PC_RESET_OFFSET)	//0x80000000

したがって、`guest_to_host(RESET_VECTOR)`はメモリアドレスのオフセット0の位置、すなわちpmem[0]へのポインタを返します。

関数`guest_to_host()`のアドレスマッピング:CPUがアクセスする物理メモリアドレスを、pmem内の対応するオフセット位置にマッピングします。

ここで`init_isa()`の最初のステップが何をしたかを要約できます:

組み込みプログラムをNEMUのメモリアドレスのオフセット0の位置に格納します(対応するクライアント物理メモリアドレスは`0x80000000`)。

次に、仮想コンピュータシステムの初期化操作`static void restart()`を見てみましょう。

static void restart() {
  /* 初期プログラムカウンタを設定 */
  cpu.pc = RESET_VECTOR;

  /* ゼロレジスタは常に0 */
  cpu.gpr[0] = 0;
}

static修飾子が関数を修飾する場合、その関数のスコープはそれが定義されているファイル内に制限されます。つまり、この関数はそれが定義されているソースファイル内でのみ呼び出すことができ、他のソースファイルからはアクセスできません。この方法により、関数は他のファイル内の同名の関数との競合を避け、カプセル化を強化します。

関数が実装する機能は、クライアントマシンのpcを物理アドレス0x8000000に設定することです。これにより、対応するNEMUのメモリアドレスはオフセット0の位置、つまり上記でクライアントプログラムを保存した位置になります。そして、0レジスタを常に0に設定します。

したがって、`init_isa()`の結果は:

まず、組み込み(built-in)のクライアントプログラムをメモリのオフセット0の場所に読み込み、次にcpuのpcをこのプログラムの初期アドレスに指します。

load_img()

ISAの初期化後、次のステップはクライアントプログラムをメモリに読み込むことです。

static char *img_file = NULL;

static long load_img() {
  if (img_file == NULL) {
    Log("イメージが指定されていません。デフォルトの組み込みイメージを使用します。");
    return 4096; // 組み込みイメージのサイズ
  }

  FILE *fp = fopen(img_file, "rb");
  Assert(fp, "Can not open '%s'", img_file);

  fseek(fp, 0, SEEK_END);
  long size = ftell(fp);

  Log("The image is %s, size = %ld", img_file, size);

  fseek(fp, 0, SEEK_SET);
  int ret = fread(guest_to_host(RESET_VECTOR), size, 1, fp);
  assert(ret == 1);

  fclose(fp);
  return size;
}

ここで使用されている関数の簡単な紹介:

`fopen(img_file, "rb")`:fopen関数を使用して、指定されたイメージファイルをバイナリモード("rb")で開きます。

ファイルサイズの取得:fseek(fp, 0, SEEK_END)でファイルポインタをファイルの末尾に移動し、その後`ftell(fp)`を使用して現在のファイルポインタの位置を取得し、ファイルサイズを取得します。

ファイルポインタのリセット:`fseek(fp, 0, SEEK_SET)`でファイルポインタをファイルの先頭にリセットし、後続のファイル内容の読み取りを準備します。

ファイル内容のメモリへの読み込み:

size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);

freadを使用してファイル内容を指定されたメモリアドレスに読み込みます。ここではguest_to_host(RESET_VECTOR)を使用して宛先アドレスを計算します。

NEMUが実行するクライアントプログラムimg_fileのソースは2つあります:

  1. NEMU実行時に入力されたクライアントプログラムファイル:`parse_args()`がコマンドラインを解析する際に、非オプション引数(クライアントプログラムファイル)が入力されると、img_fileの値を入力された引数に設定し、すぐに引数の解析を終了して0を返します。
  2. 組み込みのクライアントプログラム。

したがって、NEMUを実行する際にクライアントプログラムファイルが指定されていない場合、組み込みのクライアントプログラムが実行されます。

クライアントプログラムファイルが指定されている場合、そのクライアントプログラムが取得され、このプログラムが上記と同じメモリ位置0x80000000(pmem[0])にロードされ、プログラムのサイズが返されます。

以上で、Monitorの初期化作業が完了です!その機能はISAとデフォルトプログラムを設定し、メモリとCPUの状態を初期化することです。

最初のクライアントプログラムの実行

Monitorの初期化作業が終了した後、main()関数は`engine_start()`関数(`nemu/src/engine/interpreter/init.c`で定義)を呼び出してユーザーとのコマンド対話を実装します。

void sdb_mainloop();

void engine_start() {
#ifdef CONFIG_TARGET_AM
  cpu_exec(-1);
#else
  /* ユーザーからコマンドを受信 */
  sdb_mainloop();
#endif
}

関数`sdb_mainloop()`(`nemu/src/monitor/sdb/sdb.c`で定義)を確認しましょう。

static int is_batch_mode = false;

void sdb_mainloop() {
  // バッチモードの場合、実行完了後にすぐにシンプルデバッガ(Simple Debugger)のメインループを終了
  if (is_batch_mode) {
    cmd_c(NULL);
    return;
  }
  // 入力からコマンドと引数を取得
  for (char *str; (str = rl_gets()) != NULL; ) {
    char *str_end = str + strlen(str);	// 終端文字 '\0'

    /* 最初のトークンをコマンドとして抽出 */
    char *cmd = strtok(str, " ");	// strの最初のスペースまでの文字をコマンドとして扱う
    if (cmd == NULL) { continue; }

    /* 残りの文字列を引数として扱う
     * これらはさらに解析が必要な場合がある
     */
    char *args = cmd + strlen(cmd) + 1;	// スペース後の文字列を引数として扱う
    if (args >= str_end) {
      args = NULL;
    }

#ifdef CONFIG_DEVICE
    extern void sdl_clear_event_queue();
    sdl_clear_event_queue();
#endif

    int i;
    for (i = 0; i < NR_CMD; i ++) {
      if (strcmp(cmd, cmd_table[i].name) == 0) {
        if (cmd_table[i].handler(args) < 0) { return; }	// コマンドを実行
        break;
      }
    }

    if (i == NR_CMD) { printf("Unknown command '%s'\n", cmd); }
  }
}

理解が難しい部分を分析してみましょう:

strtok()

STFSC:man 3 strtok

#include 
char *strtok(char *str, const char *delim);

strtokは、区切り文字delimに基づいて文字列strを分割する関数です。

最初の呼び出し時には、解析する文字列strを指定する必要があります。その後、同じ文字列strを继续して解析する場合、このstrはNULLでなければなりません。

文字列をスキャンし、区切り文字集合delimまたはヌルバイト'\0'が見つかると、それらをすべて文字列終端のヌルバイト'\0'で上書きします。(⚠️ これにより元の文字列が変更されます)

strtok()を呼び出すたびに、次のトークン文字列へのポインタが返されます(このトークンは終端記号'\0'を含みますが、区切り文字delimは含まれません。なぜなら、その時点で区切り文字は終端記号に置き換えられているためです)。

同じ文字列strに対してstrtok()を連続して呼び出すと、関数は関数ポインタを維持し、このポインタが次のトークンの開始位置を決定します。関数ポインタにより、strtok()を呼び出すたびに、先頭からではなく、前回の分割が終了した開始位置から次のトークンを探し始めることが保証されます。

最初の呼び出し時、関数ポインタは文字列strの最初のバイトを指します。

文字列str内の区切り文字を処理するアプローチ(トークンが空でない文字列のみであることを確認):

  • 先頭と末尾の区切り文字は無視されます
  • 連続する複数の区切り文字は単一の区切り文字として扱われます

したがって、`sdb_mainloop()`の役割は、クライアントコンピュータの実行状態を監視およびデバッグすることです。

CPU実行のシミュレーション

// デコード関連のコード
typedef struct Decode {
  vaddr_t pc;
  vaddr_t snpc; // 静的次PC
  vaddr_t dnpc; // 動的次PC
  ISADecodeInfo isa;
  IFDEF(CONFIG_ITRACE, char logbuf[128]);
} Decode;


static void execute_single_step(Decode *s, vaddr_t pc) {
  s->pc = pc;
  s->snpc = pc;
  isa_exec_once(s);
  cpu.pc = s->dnpc;
#ifdef CONFIG_ITRACE
  char *p = s->logbuf;
  p += snprintf(p, sizeof(s->logbuf), FMT_WORD ":", s->pc);	//FMT_WORD : "0x%08x"
  int ilen = s->snpc - s->pc;
  int i;
  uint8_t *inst = (uint8_t *)&s->isa.inst.val;
  for (i = ilen - 1; i >= 0; i --) {
    p += snprintf(p, 4, " %02x", inst[i]);
  }
  int ilen_max = MUXDEF(CONFIG_ISA_x86, 8, 4);
  int space_len = ilen_max - ilen;
  if (space_len < 0) space_len = 0;
  space_len = space_len * 3 + 1;
  memset(p, ' ', space_len);
  p += space_len;

#ifndef CONFIG_ISA_loongarch32r
  void disassemble(char *str, int size, uint64_t pc, uint8_t *code, int nbyte);
  disassemble(p, s->logbuf + sizeof(s->logbuf) - p,
      MUXDEF(CONFIG_ISA_x86, s->snpc, s->pc), (uint8_t *)&s->isa.inst.val, ilen);
#else
  p[0] = '\0'; // アップストリームのllvmはloongarch32rをサポートしていない
#endif
#endif
}

関数`snprintf()`の使い方:

int snprintf(char *str, size_t size, const char *format, ...);

パラメータの説明

str: 生成されたフォーマットされた文字列を格納するターゲットバッファへのポインタ。
size: ターゲットバッファのサイズ。snprintfは最大でsize - 1文字を書き込み、自動的に最後にヌル文字'\0'を追加します。
format: フォーマット文字列。printfのフォーマット文字列と同じです。
...: 可変引数。フォーマット文字列内の変数を指定します。

戻り値

成功した場合、snprintfは書き込まれる文字列の長さ(終端のヌル文字を除く)を返します。

戻り値がsize以上の場合、出力が切り捨てられたことを示し、より大きなバッファが必要かもしれません。

以下を分析します:

 char *p = s->logbuf;
  p += snprintf(p, sizeof(s->logbuf), FMT_WORD ":", s->pc);//FMT_WORD : "0x%08x"

現在のプログラムカウンタs->pcの値(メモリアドレス)を、"0x%08x"の形式でポインタpが指すlogbuf[128]バッファに保存します。

例:

s->pcの値が0x80000000で、FMT_WORDが0x%08xに展開される場合:

`snprintf(p, sizeof(s->logbuf), "0x%08x:", s->pc);`を実行した後、pの内容は"0x80000000:"になります。
pは現在"0x80000000:"の次の位置を指し、後続の操作でバッファに内容を追加し続けることができます。

次に、対応するアドレスからデータを読み取ります:

int ilen = s->snpc - s->pc;
  int i;
  uint8_t *inst = (uint8_t *)&s->isa.inst.val;
  for (i = ilen - 1; i >= 0; i --) {
    p += snprintf(p, 4, " %02x", inst[i]);
  }

現在のpcが指すアドレスのデータを、4バイトを1グループとして、%02xの形式でポインタpに保存します。

現在、Decode構造について十分に理解していないため、instの値がどのように取得されるかを深く掘り下げる必要はありません。後続の内容も、デコード関連の問題を学習した後に解決します。

タグ: エミュレータ RISC-V コンピュータアーキテクチャ システムプログラミング NEMU

6月5日 19:54 投稿