PA1.1 - 开天辟地的篇章: 最简单的计算机
!代码管理
在进行本PA前, 请在工程目录下执行以下命令进行分支整理:
git commit --allow-empty -am "before starting pa1"
git checkout master
git merge pa0
git checkout -b pa1
⚠️ 重要声明
- 本人水平有限,实现的PA可能有可怕的bug
- 本人思路可能有误,需要各位自行判别
📚 使用须知
- 本博客内容仅供学习参考
- 建议理解思路后独立实现
- 欢迎交流讨论
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_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[]) {
/* 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_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
// 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的值就不必去深究怎么获得的了。后面的内容,也等到后续学习了关于译码的相关问题再来解决。

事实上, TRM的实现是如此的简单, 以至于框架代码已经实现它了. 接下来让我们看看, 构成TRM的那些数字电路, 在NEMU的C代码中都是何方神圣. 为了方便叙述, 我们将在NEMU中模拟的计算机称为"客户(guest)计算机", 在NEMU中运行的程序称为"客户程序".
浙公网安备 33010602011771号