2024 NJU PA1.1

1. 在native上运行超级玛丽

FCEUX是任天堂游戏的模拟器,最知名的任天堂游戏必然是超级玛丽(怀念逝去的青春)。fceux-am是fceux在nemu上的移植。首先下载游戏文件,并复制到fceux-am/nes/rom中。

克隆am-kernels后,在目录fceux-am中执行以下命令,就能在主机(ARCH=native)运行这个游戏:

make ARCH=native run mainargs=mario

会弹出超级玛丽的游戏界面(README.md文件内有操作说明)

之后将逐步实现支撑此游戏运行的各项功能。

提示

手册中介绍了提升编译速度的多核编译方法,由于我在虚拟机上做的PA,没分配多少资源给虚拟机,就偷懒不做啦。编译耗时不算太长,喜欢折腾的、有空的可以玩一玩。

 

2. NEMU初探

首先,创建一个名为pa1的分支:

git checkout -b pa1
NEMU是一个模拟器,它模拟了计算机的各类硬件,例如CPU、内存等等。
 
NEMU的main函数位于$NEMU_HOME/src/nemu-main.c(目前CONFIG_TARGET_AM未定义,所以此函数实际调用了init_monitor):
int main(int argc, char *argv[]) {
#ifdef CONFIG_TARGET_AM
    am_init_monitor();
#else
    init_monitor();  // 初始化
#endif
    
    engine_start();  // 启动引擎
    return is_exit_status_bad();
}

init_monitor

此函数负责初始化,具体实现位于文件$NEMU_HOME/src/monitor/monitor.c

void init_monitor(int argc, char* argv[]) {
    parse_args(argc, argv);  // 解析参数
    init_rand();  // 初始化随机数种子
    init_log(log_file);  // 打开记录日志的文件
    init_mem();  // 初始化内存
    IFDEF(CONFIG_DEVICE, init_device());  // 初始化设备
    init_isa();  // 执行特定ISA的初始化

    long img_size = load_img();  // 加载镜像文件

    ...
    
    init_sdb();  // 初始化sdb(simple debugger)

    ...

    welcome();
}

随便看看即可,目前不必深究。

engine_start

接下来调用engine_start运行NEMU,此函数定义于$NEMU_HOME/engine/interpreter/init.c
void engine_start() {
#ifdef CONFIG_TARGET_AM
    cpu_exec(-1);
#else
    sdb_mainloop();
#endif
}
在函数sdb_mainloop中,出现了“奇怪”的缩写sdb, 不免让人联想到gdb,确实如此,sdb是simple debugger的缩写。在PA1中,需要实现一个简单的调试器,虽然sdb这个名字还是一如既往地草率。
 
sdb_mainloop定义于$NEMU_HOME/src/monitor/sdb/sdb.c, 不难看出,它的主要功能是与用户交互:读取用户输入→执行用户输入→..., 如此反复,这也是一个debugger的基本功能。当终端输出(nemu)提示符后,用户可以输入各种指令,sdb_mainloop负责解析这些指令,并执行相应功能。
 
具体有哪些指令呢?阅读源码,可以看到指令在cmd_table中查找,而cmd_table就定义在此函数的上方:
static struct {
    ...
} cmd_table [] = {
    { ... },
    { "c", "Continue ...", cmd_c },
    { "q", "Exit NEMU", cmd_q },
    
    /* TODO: Add more commands */
}
可以看到,数组成员的第一项是指令名称,第二项是描述指令功能的字符串,第三项是一个int (char*)型函数,此函数接收一个字符串参数(因为有的指令带有多个参数),很显然,指令功能的具体实现就是它了。注释中的TODO暗示着接下来的工作量之大。
 

3. NEMU如何模拟CPU

框架实现了几个简单的sdb功能,例如指令c, 其功能是让程序继续运行,其实现位于函数cmd_c,可以看到,cmd_c不过是简单地调用了cpu_exec(-1). 这样看来,CPU运行的奥秘就藏在函数cpu_exec中。

从原理上来说,CPU的功能无非是一条接一条地执行指令。但是请注意,NEMU模拟了不同的体系架构,而不同架构的指令千差万别,因此需要将一条指令的具体执行通过某个函数抽象出来,这个函数正是isa_exec_once.

cpu_exec的执行流程如下:

cpu_exec

cpu_exec(n)的主要功能是执行n条指令,这一功能交给函数execute(n)完成。多了一层封装,还做了另外两件事情:
  1. 在调用execute(n)前后检查NEMU的运行状态(即nemu_state.state);
  2. 计算调用execute(n)耗费的时间(保存在变量g_timer中),以测量CPU的性能。
为何g_timerg_开头?
这是一种古老的命名方式,以g_开头表示此变量为全局(global)变量。类似地,在C++中,也有人习惯将类的成员以m_开头,以区分其它类型的变量。
值得注意的是调用execute(n)后,对NEMU状态的检查。记住这里的"HIT GOOD TRAP"以及"HIT BAD TRAP", 以后你会爱上这两句话的。

execute

此函数本质上就是一个循环:循环n次,每次调用函数exec_once,执行一条指令. 这里出现了变量cpu.pc, 无论哪种体系架构,CPU必然有PC寄存器,因此这种抽象是架构无关的。
 
每次调用exec_once后还会检查NEMU的状态(if (nemu_state.state != NEMU_RUNNING))。这个很容易理解:可能程序没执行到n条指令就结束了(例如,程序本身就没有n条指令),在这种情况下,程序结束运行后应当跳出循环。

exec_once

函数exec_once用于执行单条指令,此函数的主要功能是调用isa_exec_once
 
指令的执行与体系架构高度相关:x86的exec_once与riscv32的exec_once必然有着不同的实现。NEMU将此过程抽象为函数exec_once,与指令集相关的代码写在函数isa_exec_once中,之后根据编译的配置链接相应的代码。(例如,我们选择的是riscv32指令集,只要实现基于riscv32的isa_exec_once,并在编译时链接此函数即可。)
 
exec_once的功能很简单:
  1. 设置结构体Decode中成员pc的值;
  2. 调用体系架构相关的函数isa_exec_once(s).
头文件在哪里?
文件nemu/src/cpu/cpu-exec.c包含了头文件cpu/cpu.h, cpu/decode.h, cpu/difftest.hlocale.h,那么这些头文件的具体位置在哪儿呢?查看目录nemu/include,这些头文件都在此目录中(locale.h除外,暂时不去管它)。例如头文件cpu/cpu.h,对应的就是文件nemu/include/cpu/cpu.h.
 
只要在编译时将nemu/include指定为查找头文件的目录,当编译器遇到尖括号括起的文件时(例如这里的#include <cpu/cpu.h>), 就会在这些指定的目录中寻找匹配的文件。

isa_exec_once

架构相关的代码都保存在目录nemu/src/isa中,查看此目录,里面包含mips32、riscv32等子目录。由于我们需要实现riscv32指令集,具体的实现应该在目录riscv32中。果然,在nemu/src/isa/riscv32/inst.c中找到了函数isa_exec_once.

“歪门邪道”之grep大法

子目录riscv32包含一定数量的文件,如何得知函数isa_exec_once一定在文件inst.c中?其实,我使用了一个名为grep的查找工具。它可以查找一个字符串在哪些文件中,例如:
grep -r "isa_exec_once"
选项-r表示从当前目录开始,递归查找子目录。查找的结果如下:
就这么几个文件中出现了isa_exec_once, 哪个是我们需要的就一目了然啦。
 
用grep搜索字符串"TODO",从而对未来的工作量有一个初步的认识:
唉,伟大无需多言,一行TODO,一行辛酸泪。
 
言归正传,打开文件$NEMU_HOME/src/isa/riscv32/inst.c,在文件底部找到riscv32中函数isa_exec_once的具体实现:
int isa_exec_once(Decode *s) {
    s->isa.inst.val = inst_fetch(&s->snpc, 4);
    return decode_exec(s);
}
 
首先是函数inst_fetch, inst应当是instruction(指令)一词的缩写,fetch有提取的意思,所以此函数的功能就是取指令。在头文件$NEMU_HOME/include/cpu/ifetch.h中找到了此函数的具体实现,仅3行代码:
static inline uint32_t inst_fetch(vaddr_t *pc, int len) {
    uint32_t inst = vaddr_ifetch(*pc, len);
    (*pc) += len;
    return inst;
}
多么简洁!或许只有经历了x86变长指令的苦,才能体会到riscv32定长指令的甜。
 
通过inst_fetch读取指令后,接下来需要解码指令,此功能位于函数decode_exec:它向传入的结构体Decode写入一些值,暂时不管它。
提示
冗长的文字很容易让人摸不着头绪。但只要记住以下两点:
  1. CPU的功能是一条接一条地执行指令;
  2. NEMU对不同的体系结构加以抽象,因此包含架构无关、架构相关这两部分的代码。
或许对理解NEMU有所帮助。

4. PA 1.1

$NEMU_HOME下运行make menuconfig,启用Build Options → Enable debug information:

选项前出现*表示选中此项,选中后保存并退出,之后make clean & make run即可。

启用Enable debug information后有何不同?

打开$NEMU_HOME/Kconfig,在118行左右,当此项被启用后,CC_DEBUG将被置为y. 最后导致Makefile中的CFLAGS_BUILD += $(if $(CONFIG_CC_DEBUG),-Og -ggdb3,)被启用,即启用编译器的调试选项。

 

PA1主要实现调试工具,在PA1.1中,需要实现的sdb指令有以下3个:

指令名称 功能 示例 备注
si [N] 执行N条指令 si 5 未指定N时,默认为1
info [cmd]

info r: 输出寄存器信息

info w: 输出监视点信息

   
x [N] [expr] 计算expr的值,将其作为起始地址,输出从此地址开始的4*N个字节 x 10 $esp  

具体实现于文件$NEMU_HOME/src/monitor/sdb/sdb.c中。

si: 单步执行

调用cpu_exec即可,无非多了个处理参数的步骤。首先,在cmd_table中添加一行:

cmd_table [] = {
    ...
    
    /* TODO: Add more commands */    
    { "si", "Step into n instructions", cmd_si},
};

之后实现函数cmd_si:

static int cmd_si(char *args) {                                                                               
    int n_inst = 1;  // 默认执行1条指令
    if (args != NULL) {  // 用户指定了参数
        if (sscanf(args, "%d", &n_inst) < 1) {
            printf("error: invalid input %s, expecting an integer\n", args);
            return 0;
        }
        if (n_inst <= 0) {
            printf("error: invalid input %s, expecting a positive integer\n", args);
            return 0;
        }
    }   
    
    cpu_exec(n_inst);  // 执行n_inst条指令
    return 0;
}

以上代码仅供参考,它并没有严格检查输出,例如输入si 2.2,最终会解释为si 2. 使用atoi之类的函数亦可。

重新编译NEMU,测试si是否正确实现:

info r: 输出寄存器信息

si的实现一样,首先在cmd_table中添加一行:

{ "info", "Print program status", cmd_info},

之后实现函数cmd_info即可。目前我们只需要实现info r(之后还需要实现info w).

info r用于查看寄存器,可问题是,不同架构的寄存器连名字都不一样。为了抽象不同架构,NEMU将打印寄存器这一功能封装为函数isa_reg_display,只需要在cmd_info中调用此函数即可:

static int cmd_info(char *args) {
    if (strcmp(args, "r") == 0) {
        // info r: 打印寄存器状态
        isa_reg_display();
    }
    else {
        printf("error: invalid command %s\n", args);
    }
    
    return 0;                                                                                                   
}

但天底下哪有那么好的事情,我们需要自行实现riscv32版本的isa_reg_display. 在此之前,需要简单了解riscv32的CPU定义。

riscv32的CPU结构体

不同架构的CPU差别极大,想要将它们抽象成一个通用的结构体基本上不可能。因此,在NEMU中,仅仅声明了CPU_state结构体,并没有给出定义,编译时将此声明链接到指定架构的结构体定义。例如,在riscv32中,此结构体将会链接到结构体riscv32_CPU_state(定义于$NEMU_HOME/src/isa/riscv32/include/isa-def.h):

typedef struct {
    word_t gpr[MUXDEF(CONFIG_RVE, 16, 32)];
    vaddr_t pc;
} MUXDEF(CONFIG_RV64, riscv64_CPU_state, riscv32_CPU_state);

目前只定义了通用寄存器(gpr数组,general purpose register)和PC寄存器(pc).

 

isa_reg_display定义于文件$NEMU_HOME/src/isa/riscv32/reg.c. 此文件中,定义了寄存器名称的字符串数组regs, 以及通过寄存器名称获取寄存器值的辅助函数isa_reg_str2val,首先实现此函数:

word_t isa_reg_str2val(const char *s, bool *success) {
    for (int i = 0; i < sizeof(regs) / sizeof(const char*); i++) {
        if (strcmp(s, regs[i]) == 0) {
            *success = true;
            /* 变量cpu定义于$NEMU_HOME/src/cpu/cpu-exec.c: CPU_state cpu = {};
               声明见文件$NEMU_HOME/include/isa.h: extern CPU_state cpu; */
            return cpu.gpr[i];  // 数组regs的声明顺序与riscv32的定义一致
        }
    }

    *success = false;
    return 0;
}

有了这个函数,很容易实现isa_reg_display:

void isa_reg_display() {
    bool success = false;
    for (int i = 0; i < sizeof(regs) / sizeof(const char*); i++) {                                            
        word_t val = isa_reg_str2val(regs[i], &success);
        printf("%-16s0x%-16x%d\n", regs[i], val, val); // 为了输出美观
    }
}

x: 扫描内存(简化版)

扫描内存功能比较复杂,因为涉及到表达式求值。目前只需要实现它的简化版本:假设表达式为十六进制。

和之前一样,在cmd_table中填入项,并实现函数cmd_x:

word_t vaddr_read(vaddr_t, int);  // 编译时出错,所以添加了对vaddr_read的声明


static int cmd_x(char *args) {
	char* args_end = args + strlen(args);
	
    // 读取第一个参数
	char* arg_word = strtok(NULL, " ");
	if (arg_word == NULL) {
		printf("error: missing arguments\n");
		return 0;
	}
	int n_word = 0;
	if (sscanf(arg_word, "%d", &n_word) != 1) {
		printf("error: first argument should be an integer, but got %s\n", arg_word);
		return 0;
	}
    
    // 读取第二个参数
	char* arg_expr = arg_word + strlen(arg_word) + 1;
	if (arg_expr >= args_end) {
		printf("error: missing arguments\n");
		return 0;
	}
	word_t start_addr = 0;  // 内存首地址
	if (sscanf(arg_expr, "%x", &start_addr) != 1) {
		printf("error: require a hex expression, but got %s\n", arg_expr);
		return 0;
	}
    
    // 读取字节并输出
	for (int i = 0; i < n_word; i++) {
		word_t current_addr = start_addr + i * 4;
		word_t val = vaddr_read(current_addr, 4);
		printf("\033[34m0x%08x\033[0m: 0x%08x\n", current_addr, val);
	}
	return 0;
}

以上代码很大一部分用于错误检查,也可以写得简单些,不过之后输入指令时就要小心点。

提示

这里解析参数时,没有使用sscanf, 主要因为参数expr中可能包含空格,例如%esp +    3这种,用了sdb_mainloop中使用过的strtok. 此函数内部维护了一个static变量,首次调用时需要指定字符串;在之后的调用中,将第一个参数指定为NULL即可。

编译后,输出大致如下:

posted @ 2024-10-02 16:41  overxus  阅读(93)  评论(0编辑  收藏  举报