NEMU运行逻辑

这里简单梳理一下NEMU的生命周期内都干了些什么. 从main函数看, 基本可以分成三个阶段:

// 简化版本
int main(int argc, char *argv[]) {
  init_monitor(argc, argv); // 初始化模拟器

  engine_start(); // 开始运行

  return is_exit_status_bad(); // 结束运行, 判断结果
}

初始化

实际上进入init_monitor函数就能很清晰的看到整个初始化过程都做了些什么事. 先由parse_args处理输入参数, 并保存到对应变量, 后续有个任务也需要看懂这个函数才能完成对应操作.

  • init_rand主要是为初始化内存时提供随机值的功能, 内存会被初始化为类似- 0x12121212, 0xa8a8a8a8的格式.
  • init_log会将输出保存到文件.
  • init_mem初始化内存, 本质上就是把一个uint8的数组全部填充为随机值, 随- 机值的功能可以在menuconfig中关闭.
  • init_device比较有意思, 在PA2后续内容中会接触到, 现在只需要知道是- 初始化虚拟设备的就行了.
  • init_isaload_img是初始化isa, 将cpu的各个寄存器都进行赋值, 并将- 程序搬运到内存的指定位置.
  • 后面的几个都是与模拟器调试相关的, difftest用来做对比测试, 不建议提前开- 启, 多练练自行debug的能力.
  • sdb在PA1时应该接触很多了, 略.
  • init_disasm是反汇编, 也就是在使用si命令时打印信息里看到的内容, 比如auipc t0, 0, 在调试程序时很好用.
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();

  IFDEF(CONFIG_ITRACE, init_disasm());

  /* Display welcome message. */
  welcome();
}

运行

直接看engine_start会发现就一个sdb_mainloop函数, 等待用户输入后执行我们在PA1中完成的功能. 在我们实现的所有的功能里, 有一个功能很神秘特殊, 那就是cpu_exec, 可以说这个函数就是整个nemu模拟硬件运行的核心. 通过它就能让程序在程序上运行起来. 直接拆开来看, 结构也很好懂, 简化一下:

void cpu_exec(uint64_t n) {
  // 判断状态
  switch() {
    ...
  }
  // 计时
  uint64_t timer_start = get_time();
  // 执行
  execute(n);
  // 计时
  uint64_t timer_end = get_time();
  // 判断状态
  switch() {
    ...
  }
}

再次剥开execute会发现依然差不多, 无非是判断状态换为了difftest和更新设备. 内部的exec_once才是运行的部分, 再次进入, 看到一大堆用于反编译的代码与isa_exec_once这个函数. 恭喜你, 终于快要看到整个nemu的精髓了.

int isa_exec_once(Decode *s) {
  s->isa.inst.val = inst_fetch(&s->snpc, 4);
  return decode_exec(s);
}

isa_exec_once内总共就干了两件事:

  1. inst_fetch: 获取下一条指令
  2. decode_exec: 执行对应计算

inst_fetch比较简单, 就是根据输入的pc, 返回对应内存地址存储的值返回. decode_exec就比较头疼了, 因为一上来就会看到一大段宏, 直接到头文件中看必定懵逼. 不过不用慌, 先看明白其他部分, 然后一步一步看下去, 理清思路就好.

#define R(i) gpr(i) // 用于获取cpu寄存器值
#define Mr vaddr_read // 读取内存
#define Mw vaddr_write // 写入内存
// 指令类型枚举
enum {
  TYPE_I, TYPE_U, TYPE_S,
  TYPE_N, // none
};

#define src1R() do { *src1 = R(rs1); } while (0) // 获取rs1的值
#define src2R() do { *src2 = R(rs2); } while (0) // 获取rs2的值
// 获取各类型指令中的立即数的值(缺少J, B类型)
#define immI() do { *imm = SEXT(BITS(i, 31, 20), 12); } while(0)
#define immU() do { *imm = SEXT(BITS(i, 31, 12), 20) << 12; } while(0)
#define immS() do { *imm = (SEXT(BITS(i, 31, 25), 7) << 5) | BITS(i, 11, 7); } while(0)

/*
BITS可以将某个值的某一段取出来转为数值, SEXT是对一个数进行符号扩展
*/

static void decode_operand(Decode *s, int *rd, word_t *src1, word_t *src2, word_t *imm, int type) {
  uint32_t i = s->isa.inst.val; // 获取指令
  int rs1 = BITS(i, 19, 15); // 得到rs1的编号
  int rs2 = BITS(i, 24, 20); // 得到rs2的编号
  *rd     = BITS(i, 11, 7); // 得到rd的编号
  switch (type) { // 根据指令类型获取编号对应寄存器的值与立即数的值(缺少J, B类型)
    case TYPE_I: src1R();          immI(); break;
    case TYPE_U:                   immU(); break;
    case TYPE_S: src1R(); src2R(); immS(); break;
  }
}

看明白其他部分后, 哪怕你现在还不能理解那些宏是怎么工作的, 你应该也差不多能明白decode_exec在干什么了吧. 前段部分先定义变量, 初始化s->dnpc(可简单理解为下一条指令)的值为s->snpc(在上一轮执行结束时被设置为s->dnpc + 4).

static int decode_exec(Decode *s) {
  int rd = 0;
  word_t src1 = 0, src2 = 0, imm = 0;
  s->dnpc = s->snpc;

#define INSTPAT_INST(s) ((s)->isa.inst.val)
#define INSTPAT_MATCH(s, name, type, ... /* execute body */ ) { \
  decode_operand(s, &rd, &src1, &src2, &imm, concat(TYPE_, type)); \
  __VA_ARGS__ ; \
}

  INSTPAT_START();
  INSTPAT("??????? ????? ????? ??? ????? 00101 11", auipc  , U, R(rd) = s->pc + imm);
  INSTPAT("??????? ????? ????? 100 ????? 00000 11", lbu    , I, R(rd) = Mr(src1 + imm, 1));
  INSTPAT("??????? ????? ????? 000 ????? 01000 11", sb     , S, Mw(src1 + imm, 1, src2));

  INSTPAT("0000000 00001 00000 000 00000 11100 11", ebreak , N, NEMUTRAP(s->pc, R(10))); // R(10) is $a0
  INSTPAT("??????? ????? ????? ??? ????? ????? ??", inv    , N, INV(s->pc));
  INSTPAT_END();

  R(0) = 0; // reset $zero to 0

  return 0;
}

这一段直接看是没法明白的, 但是我们通过修改scripts/build.mk, 在@$(CC) $(CFLAGS) -c -o $@ $<下方添加一行@$(CC) $(CFLAGS) -E -o $(patsubst %.o, %.i, $@) $<后, 我们就能在build文件夹中找到预编译文件, 也就是宏展开后的结果, 然后就很清晰明了了. 通过pattern_decode获取三个用于判断的标志, 若指令满足条件, 则将指令进行解码获取执行指令所需数据, 并执行我们写好的操作. 最后会跳转到末尾, 也就是INSTPAT_END宏的位置.

// INSTPAT_START宏展开
{ const void ** __instpat_end = &&__instpat_end_;;
// auipc宏展开
do {
  uint64_t key, mask, shift;
  pattern_decode(
    "??????? ????? ????? ??? ????? 00101 11",
    (sizeof("??????? ????? ????? ??? ????? 00101 11") - 1),
    &key,
    &mask,
    &shift
  );
  if ((((uint64_t)((s)->isa.inst.val) >> shift) & mask) == key) { {
      decode_operand(s, &rd, &src1, &src2, &imm, TYPE_U);
      (cpu.gpr[check_reg_idx(rd)]) = s->pc + imm;
    };
    goto *(__instpat_end);
  } 
} while (0);
// INSTPAT_END宏展开
__instpat_end_: ; };

结束

结束的代码比较简单, 就是判断一下nemu的状态然后返回对应的值, 让外部的脚本或程序得到此次模拟是否正常结束.

任务

PA2就开始进入正片了(一些简单的题会被略过), 编程占比开始变大, 需要查看学习的手册也会变多.

运行第一个C程序

跟随手册运行程序, 一定会看到报错. 此时只需要查看cpu-test/build中反汇编文件找到具体是哪条指令, 添加支持后再次运行即可, 反复运行完善就能将除了stringhello-str的程序全部运行成功. 这一段对有些人来说可能有些枯燥, 反复测试、运行、补缺、查手册, 但是能极大的提升你对所选isa的理解程度. 如果卡在一个地方比较久, 不妨完善一下sdb的功能, 提高的工具能力. 或者去重新阅读一下手册, 检查检查是否有什么细节漏下了. 如果你不想看官方的英文原版, 甚至不想看官方文档, 可以试试包老师团队翻译的《RISC-V开放架构设计之道》入门.


提示

来自2024年的补充: 突然发现PA的手册本身变友好了, 很多代码都有了很详细的解析, 要是还是不知道如何添加指令支持, 建议多读读上文与PA的手册

程序, 运行时环境与AM

这一段比较难理解, 但是任务都挺简单的. 这里还是对整个过程梳理一遍.
我们都知道, 一个普通的c语言程序完成之后, 需要编译器编译后才能运行. 在不同的机器, 不同的操作系统上, 普通的二进制程序是不互通的, 你可以试试将linux上的helloworld复制到windows上运行, 你会得到报错(某些情况例如WSL是可以兼容windows的程序, 一些工具也能跨平台运行, 这些不在考虑范围内). 如果我们想要让程序运行在nemu上, 会需要对应的交叉编译器, 也就是可以在x86的linux上编译出riscv32/64程序的编译器.
当我们敲下make ARCH=$ISA-nemu ALL=dummy run时, 整个cpu-test项目做了非常多的事情:

  • ARCH中获取目标平台信息, 假设结果为在nemu上运行的riscv32e程序
  • 获得需要编译的代码文件, 此处为dummy.c
  • 将信息传给${AM_HOME}/Makefile, 即abstract-machine/Makefile

接下来就进入了一个陌生的领域: abstract-machine. 如果你实现inst.c时考虑了riscv32, riscv64, riscv32e三种情况, 得到了一个可以运行三种程序的nemu. 那么你会发现ARCH无论设置为哪一个, 最终都能在nemu上运行. 只要你有时间, 甚至能通过改变ARCH与nemu配置, 运行x86版本. 同样的代码, 仅选项不同, 就能编译成不同架构上的程序, 这就是abstract-machine的基本功能. 在后续实现中你会发现不止这些, abstract-machine还为程序提供了一套运行的环境, 将很多功能进行了抽象. 想要支持新架构, 只需要在abstract-machine添加很少的驱动代码, 完成nemu的指令支持, 就能直接运行已有的程序. 这里还是拿make ARCH=$ISA-nemu ALL=dummy run举例:

  • 得到cpu-test传过来的参数
  • 找到属于riscv32e的脚本
  • 使用klibriscv32enemu的库和dummy.c编译生成结果

am/src中, 存有平台库(platform)与架构库(riscv、x86、mips), klib则是通用库. 一个dummy.c与不同的库就能生成不同的程序, 但功能一样, 很有意思吧.

阅读Makefile, 通过批处理模式运行NEMU

实际上前面已经将基本过程都提到了, 只是没有对照Makefile进行解析. 我继续用make ARCH=$ISA-nemu ALL=dummy run举例, 直接看Makefile比较麻烦, 咱可以喂给ai帮助分析, 不过还有更简单的方法. 使用-nB选项可以只打印出make指令执行的步骤, 但是不实际执行.

/bin/echo -e "NAME = dummy\nSRCS = tests/dummy.c\ninclude ${AM_HOME}/Makefile" > Makefile.dummy
if make -s -f Makefile.dummy ARCH=riscv32e-nemu run; then \
        printf "[%14s] \033[1;32mPASS\033[0m\n" dummy >> .result; \
else \
        printf "[%14s] \033[1;31m***FAIL***\033[0m\n" dummy >> .result; \
fi
rm -f Makefile.dummy
echo "test list [1 item(s)]:" dummy
cat .result
rm .result

这样看稍微有些杂乱, 但是已经比较好理解了. 你也可以直接执行一下看看这些命令都会发生什么. 就比如第一行, 会向Makefile.dummy文件写入:

NAME = dummy
SRCS = tests/dummy.c
include abstract-machine/Makefile

第二行是一段shell脚本, 根据条件打印结果, 仔细看就只有判断条件本身比较重要. 在有Makefile.dummy的前提下, 运行make -s -f Makefile.dummy ARCH=riscv32e-nemu run, 你会发现正确运行了dummy程序, 只是少了PASS的提示信息, Makefile.dummy也残留下来没被删除. 正好对应了后面的命令. 到这里你也应该知道接下来该干什么了吧. 继续使用-nB选项, 我们得到了超多输出, 但是不要慌, 我们可以用管道将输出信息转为文件: make -s -f Makefile.dummy ARCH=riscv32e-nemu run -nB > log. 打开文件还是很难理解查看, 使用工具(sed)整理一下, 比如删掉对我们理解没有帮助的mkdir信息, 将空格转为回车加缩进的形式, 重复项只保留一个等:

// 简单整理
# Building dummy-run [riscv32e-nemu]
make -s -C abstract-machine/am archive

# Building am-archive [riscv32e-nemu]
riscv64-linux-gnu-gcc -std=gnu11 -O2 -MMD -Wall -Werror -Iabstract-machine/am/src -Iabstract-machine/am/include -Iabstract-machine/am/include/ -Iabstract-machine/klib/include/ -D__ISA__=\"riscv32e\" -D__ISA_RISCV32E__ -D__ARCH__=riscv32e-nemu -D__ARCH_RISCV32E_NEMU -D__PLATFORM__=nemu -D__PLATFORM_NEMU -DARCH_H=\"arch/riscv.h\" -fno-asynchronous-unwind-tables -fno-builtin -fno-stack-protector -Wno-main -U_FORTIFY_SOURCE -fvisibility=hidden -fno-pic -march=rv64g -mcmodel=medany -mstrict-align -march=rv32em_zicsr -mabi=ilp32e   -static -fdata-sections -ffunction-sections -Iabstract-machine/am/src/platform/nemu/include -DMAINARGS_MAX_LEN=64 -DMAINARGS_PLACEHOLDER=\""The insert-arg rule in Makefile will insert mainargs here."\" -DISA_H=\"riscv/riscv.h\" -c -o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/trm.o abstract-machine/am/src/platform/nemu/trm.c

echo + AR "->" build/am-riscv32e-nemu.a
riscv64-linux-gnu-ar rcs abstract-machine/am/build/am-riscv32e-nemu.a abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/trm.o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/ioe/ioe.o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/ioe/timer.o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/ioe/input.o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/ioe/gpu.o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/ioe/audio.o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/ioe/disk.o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/mpe.o abstract-machine/am/build/riscv32e-nemu/src/riscv/nemu/start.o abstract-machine/am/build/riscv32e-nemu/src/riscv/nemu/cte.o abstract-machine/am/build/riscv32e-nemu/src/riscv/nemu/trap.o abstract-machine/am/build/riscv32e-nemu/src/riscv/nemu/vme.o
make -s -C abstract-machine/klib archive

# Building klib-archive [riscv32e-nemu]
riscv64-linux-gnu-gcc -std=gnu11 -O2 -MMD -Wall -Werror -Iabstract-machine/klib/include -Iabstract-machine/am/include/ -Iabstract-machine/klib/include/ -D__ISA__=\"riscv32e\" -D__ISA_RISCV32E__ -D__ARCH__=riscv32e-nemu -D__ARCH_RISCV32E_NEMU -D__PLATFORM__=nemu -D__PLATFORM_NEMU -DARCH_H=\"arch/riscv.h\" -fno-asynchronous-unwind-tables -fno-builtin -fno-stack-protector -Wno-main -U_FORTIFY_SOURCE -fvisibility=hidden -fno-pic -march=rv64g -mcmodel=medany -mstrict-align -march=rv32em_zicsr -mabi=ilp32e   -static -fdata-sections -ffunction-sections -Iabstract-machine/am/src/platform/nemu/include -DMAINARGS_MAX_LEN=64 -DMAINARGS_PLACEHOLDER=\""The insert-arg rule in Makefile will insert mainargs here."\" -DISA_H=\"riscv/riscv.h\" -c -o abstract-machine/klib/build/riscv32e-nemu/src/string.o abstract-machine/klib/src/string.c

...

整个文件中, 可以看到几行比较重要的命令

  • 链接命令
    • 程序: dummy.o
    • am库和nemu库: am-riscv32e-nemu.a
    • klib库: klib-riscv32e-nemu.a
riscv64-linux-gnu-ld
  -z noexecstack
  -Tabstract-machine/scripts/linker.ld
  -melf64lriscv
  --defsym=_pmem_start=0x80000000
  --defsym=_entry_offset=0x0
  --gc-sections
  -e _start
  -melf32lriscv
  -o am-kernels/tests/cpu-tests/build/dummy-riscv32e-nemu.elf
  --start-group
  am-kernels/tests/cpu-tests/build/riscv32e-nemu/tests/dummy.o
  abstract-machine/am/build/am-riscv32e-nemu.a
  abstract-machine/klib/build/klib-riscv32e-nemu.a
  --end-group
  • 运行nemu
make
  -C
  nemu
  ISA=riscv32e
  run
  ARGS="-l am-kernels/tests/cpu-tests/build/nemu-log.txt -e am-kernels/tests/cpu-tests/build/  dummy-riscv32e-nemu.elf -b"
  IMG=am-kernels/tests/cpu-tests/build/dummy-riscv32e-nemu.bin

nemu/build/riscv32-nemu-interpreter
  -l am-kernels/tests/cpu-tests/build/nemu-log.txt
  -e am-kernels/tests/cpu-tests/build/dummy-riscv32e-nemu.elf
  -b // 这一项需要自行实现 默认没有
  am-kernels/tests/cpu-tests/build/dummy-riscv32e-nemu.bin

这样看就非常清晰了, 将各个部分编译链接后, 传给nemu, nemu得到程序文件后存入数组, 就完成了整个流程. 所以实际上只需要在传输的信息中加上“批处理”的选项. 找到scripts/platform/nemu.mk中关于nemu选项的部分.

NEMUFLAGS += -l $(shell dirname $(IMAGE).elf)/nemu-log.txt
NEMUFLAGS += -e $(IMAGE).elf
NEMUFLAGS += 批处理选项

这里填什么实际上在nemu的src/monitor/monitor.c中有很明显的提示, 大部分都帮你实现好了.

// 已添加好的批处理选项
const struct option table[] = {
    {"batch"    , no_argument      , NULL, 'b'}, // batch -> 批处理
    {"log"      , required_argument, NULL, 'l'},
    {"diff"     , required_argument, NULL, 'd'},
    {"port"     , required_argument, NULL, 'p'},
    {"help"     , no_argument      , NULL, 'h'},
    {"elf"      , required_argument, NULL, 'e'},
    {0          , 0                , NULL,  0 },
  };
// 已设置好的批处理模式的函数
sdb_set_batch_mode();

接下来应该做什么我就不继续了, 很简单的. 实际上是我懒

实现字符串处理函数

比较简单, 现在的ai都能帮你完美完成, 略.

实现printf

这个推荐看看jyy老师的这节课C 标准库设计与实现. 不用看完, 只用看C标准库中实现printf的思路, 在视频的48:08.