PA1.1 - 开天辟地的篇章: 最简单的计算机

!代码管理

在进行本PA前, 请在工程目录下执行以下命令进行分支整理:

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

⚠️ 重要声明

  1. 本人水平有限,实现的PA可能有可怕的bug
  2. 本人思路可能有误,需要各位自行判别

📚 使用须知

  • 本博客内容仅供学习参考
  • 建议理解思路后独立实现
  • 欢迎交流讨论

task PA1.1: 实现单步执行, 打印寄存器状态, 扫描内存

RTFSC

📑在阅读f*** source code的时候,遇到了makefile文件:/ics2024/nemu/scripts/build.mk

其中涉及到的某些Makefile的语法这里简单介绍下,方便阅读。

模式替换

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

将C和C++源文件的对象文件,合并到OBJ变量中。/memory/paddr.c

如果 SRCS 是 src/main.c src/utils.c,而 CXXSRC 是 src/main.cc src/utils.cc,且 OBJ_DIRbuild/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[]) {
  /* Perform some global initialization. */
 
  /* Parse arguments. */
  parse_args(argc, argv);
 
  /* Set random seed. */
  init_rand();
 
  /* Open the log file. */
  init_log(log_file);
 
  /* Initialize memory. */
  init_mem();
 
  /* Initialize devices. */
  IFDEF(CONFIG_DEVICE, init_device());
 
  /* Perform ISA dependent initialization. */
  init_isa();
 
  /* Load the image to memory. This will overwrite the built-in image. */
  long img_size = load_img();
 
  /* Initialize differential testing. */
  init_difftest(diff_so_file, img_size, difftest_port);
 
  /* Initialize the simple debugger. */
  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
 
  /* Display welcome message. */
  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              run with batch mode\n");
        printf("\t-l,--log=FILE           output log to FILE\n");
        printf("\t-d,--diff=REF_SO        run DiffTest with reference REF_SO\n");
        printf("\t-p,--port=PORT          run DiffTest with port PORT\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结构体的table,是选项表,其实现方法为

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

name是选项的名称;
has_arg:
​ 若为0或者no_argment,则不需要参数。
​ 若为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_filediff_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

// this is not consistent with uint8_t
// but it is ok since we do not access the array directly
static const uint32_t img [] = {
  0x00000297,  // auipc t0,0
  0x00028823,  // sb  zero,16(t0)
  0x0102c503,  // lbu a0,16(t0)
  0x00100073,  // ebreak (used as nemu_trap)
  0xdeadbeef,  // some data
};
 
static void restart() {
  /* Set the initial program counter. */
  cpu.pc = RESET_VECTOR;
 
  /* The zero register is always 0. */
  cpu.gpr[0] = 0;
}

void init_isa() {
  /* Load built-in image. */
  memcpy(guest_to_host(RESET_VECTOR), img, sizeof(img));
 
  /* Initialize this virtual computer system. */
  restart();
}

💻可以看出客户程序img是一个基于risv32的指令数组。实现的功能是在pc+16的位置,存储数据0;并将pc+16内存地址处的数据(0)存放到寄存器a0中。

void init_isa()的逻辑是,首先将内置的程序存放到内存指定区域:

/* Load built-in image. */
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() {
  /* Set the initial program counter. */
  cpu.pc = RESET_VECTOR;
 
  /* The zero register is always 0. */
  cpu.gpr[0] = 0;
}

当 static 修饰一个函数时,该函数的作用域限制在定义它的文件内。也就是说,这个函数只能在定义它的源文件中调用,其他源文件无法访问。通过这种方式,函数可以避免与其他文件中的同名函数冲突,增强封装性。

函数实现的功能,就是将客户机的pc设置为物理地址0x8000000,这样对应NEMU的内存地址是偏移量为0的位置,即为上文中保存客户程序的位置。并且将0寄存器设置永远为0。

这样init_isa()的结果就是:

首先将内置的(bulit-in)客户程序读取到内存偏移为0的地方,然后将cpu的pc指向这个程序的初始地址。

load_img()

当初始化ISA后,下一步就是将客户程序读取到内存中。

static char *img_file = NULL;
 
static long load_img() {
  if (img_file == NULL) {
    Log("No image is given. Use the default build-in image.");
    return 4096; // built-in image size
  }
 
  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") 打开指定的镜像文件

获取文件大小:使用 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,来源有两个:

运行NEMU时输入的客户程序文件img_file = optarg; return 0;:parse_args()在解析命令行的时候,如果输入非选项参数——客户程序文件,则将img_file的值置为输入的参数,然后立即终止解析参数并且返回0
内置的客户程序
所以运行NEMU的时候,如果没有指定客户程序文件,则会执行内置的客户程序。

如果指定了客户程序文件,则会获取此客户程序并且将此程序加载到上文相同的内存位置0x80000000即pmem[0],并且返回这个程序的大小。

ok,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
  /* Receive commands from user. */
  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'
 
    /* extract the first token as the command */
    char *cmd = strtok(str, " ");	// 将str的第一个空格之前的字符作为 命令
    if (cmd == NULL) { continue; }
 
    /* treat the remaining string as the arguments,
     * which may need further parsing
     */
    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 <string.h>
char *strtok(char *str, const char *delim);

strtok是一个按照分隔符delim将字符串str分割的函数

第一次调用时候,需要指定解析字符串str;如果后面想继续解析字符串str,这个str就必须是NULL

扫描字符串若发现有分隔符集合delim或者空字节'\0',就会将其统一覆盖成字符串终止的空字节'\0'。(⚠️ 这样就会修改原字符串)

每次调用strtok(),都会返回指向下一个token字符串的指针( 此token带有终止符号'\0',但是不包括分隔符delim,因为此时的分隔符已经被终止符号替代)。

对同一个字符串str进行连续调用strtok(),这时候函数会维护一个函数指针,这个指针决定了指向下一个token的起始位置。函数指针确保了每次调用strtok()的时候,都会从上一次拆分结束的起始位置开始查找下一个token,而不是从头开始。

第一次调用的时候,函数指针会指向字符串str的第一个字节。

处理字符串str中分隔符的思路(确保token只能为非空字符串):

开头和结尾的分隔符会被忽略
连续多个分隔符会被视为单个分隔符处理

这样sdb_mainloop()的作用, 是对客户计算机的运行状态进行监控和调试。

模拟cpu执行

// 译码相关代码
typedef struct Decode {
  vaddr_t pc;
  vaddr_t snpc; // static next pc
  vaddr_t dnpc; // dynamic next pc
  ISADecodeInfo isa;
  IFDEF(CONFIG_ITRACE, char logbuf[128]);
} Decode;
 
 
static void exec_once(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'; // the upstream llvm does not support 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个字节为一组,按照%02x的格式保存到指针p中。

因为现在对于Decode的结构不是很了解,所以这里inst的值就不必去深究怎么获得的了。后面的内容,也等到后续学习了关于译码的相关问题再来解决。

posted @ 2025-11-22 22:51  mo686  阅读(15)  评论(0)    收藏  举报