0%

NJU ICS2023 PA开发日记

2024/08/22

从今天开始,正式准备启动这个“项目”啦!(其实已经看过一段时间前面的基础介绍了

希望能够坚持做完!!!(ง •_•)ง

这里主要记录一下做PA过程中遇到的问题和思考(以及内心戏

不过后面一个月要去外地培训了。。。还不知道弄

1. PA1

1.3 RTFSC

getopt_long()函数的作用?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
SYNOPSIS
#include <unistd.h>

int getopt(int argc, char * const argv[],
const char *optstring);

extern char *optarg;
extern int optind, opterr, optopt;

#include <getopt.h>

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

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

DESCRIPTION
The getopt() function parses the command-line arguments. Its arguments argc and argv are the argument count and array as passed to the main() function on program invocation. An element of argv that starts
with '-' (and is not exactly "-" or "--") is an option element. The characters of this element (aside from the initial '-') are option characters. If getopt() is called repeatedly, it returns successively
each of the option characters from each of the option elements.

The variable optind is the index of the next element to be processed in argv. The system initializes this value to 1. The caller can reset it to 1 to restart scanning of the same argv, or when scanning a new argument vector.

If getopt() finds another option character, it returns that character, updating the external variable optind and a static variable nextchar so that the next call to getopt() can resume the scan with the following option character or argv-element.

If there are no more option characters, getopt() returns -1. Then optind is the index in argv of the first argv-element that is not an option.
optstring is a string containing the legitimate option characters. If such a character is followed by a colon, the option requires an argument, so getopt() places a pointer to the following text in the same argv-element, or the text of the following argy-element, in optarg. Two colons mean an option takes an optional arg; if there is text in the current argv-element (i.e., in the same word as the option name itself, for example, "-oarg"), then it is returned in optarg, othenwise optarg is set to zero. This is a GNU extension. If optstring contains W followed by a senicolon, then -W foo is treated as the long option --foo. (The -W option is reserved by POSIX.2 for implementation extensions.) This behavior is a GNU extension, not available with libraries before glibc 2.
By default, getopt() permutes the contents of argy as it scans, so that eventually all the nonoptions are at the end. Two other modes are also implemented. If the first character of optstring is '+' or the environment variable POSIXLY_CORRECT is set, then option processing stops as soon as a nonoption argument is encountered. If the first character of optstring is '-', then each nonoption argv-element is handled as if it were the argument of an option with character code 1. (This is used by prograns that were written to expect options and other argy-elenents in any order and that care about the ordering of the two.) The special argument "--" forces an end of option-scanning regardless of the scanning mode.l

got,用来解析命令行参数的。

1.3.1 准备第一个客户程序

为什么全部都是函数?

阅读init_monitor()函数的代码, 你会发现里面全部都是函数调用. 按道理, 把相应的函数体在init_monitor()中展开也不影响代码的正确性. 相比之下, 在这里使用函数有什么好处呢?

这样有利于抽象内部设计,使得设计更加层次分明

2024/08/23

NEMU是一个用来执行客户程序的程序(模拟计算机),但是NEMU一开始并没有客户程序(OS或其他客户程序),需要有程序将客户程序读入计算机中,NEMU项目src中的monitor就是用来干这个事的(也包含调试的功能sdb)。

monitor中调用init_isa()来进行ISA的一些初始化:

  1. 将一个内置客户程序读入内存中
  2. 初始化虚拟计算机系统(初始化寄存器,restart()函数)

读入客户程序到内存中,读到什么位置?NEMU采用最简单的方式——约定一个位置,可由我们配置,定义在nemu/include/memory/paddr.h中,定义为RESET_VECTOR。

NEMU默认提供128MB的内存,模拟内存定义在src/memory/paddr.c中,定义为pmem

RISC-V32(以及MIPS32)的默认物理内存地址是从0x80000000开始编址的, 将来CPU访问内存时, 会将CPU将要访问的内存地址映射到pmem中的相应偏移位置,这是通过nemu/src/memory/paddr.c中的guest_to_host()函数实现的。

可能这个RESET_VECTOR就相当于真实计算机启动的第一个地址?存放BIOS的位置?

Welcome to riscv32-NEMU!

成功运行!!!

2024.10.26记

果然。。。中途荒废了一个月再捡起来就需要勇气了hhh,

荒废了两个月后,我终于又准备回来了!

2024.12.15记

真的回来了(

2024/12/15

NEMU最开始默认的客户程序定义在init.c函数中,如下:

image-20241215223855685

其中的img就是4条指令+一个数据。也可以在运行NEMU的时候添加一个参数,指定外部的镜像文件加载。

1.3.2 运行第一个客户程序

究竟要执行多久?

cmd_c()函数中, 调用cpu_exec()的时候传入了参数-1, 你知道这是什么意思吗?

A:将-1转换成uint64_t的最大值,即执行0xFFFFFFFFFFFFFFFF这么多条指令(如不是指令自行退出的话)

潜在的威胁 (建议二周目思考)

“调用cpu_exec()的时候传入了参数-1“, 这一做法属于未定义行为吗? 请查阅C99手册确认你的想法。

A:好的,那就二周目再思考(

进入sdb_mainloop()函数后,会通过cmd_table结构体中的函数指针handler)来调用不同的函数,输入c就可以运行内置的img。但是再次输入c的话会提示需要重新运行NEMU才能。。。

sdb_mainloop()函数中的strtok()函数是用来分割字符串的,类似python的split函数,可以指定分割标志。

有意思的是其中的nemu_trap指令,它是为了在NEMU中让客户程序指示执行的结束而加入的, NEMU在ISA手册中选择了一些用于调试的指令, 并将nemu_trap的特殊含义赋予它们。例如在riscv32的手册中, NEMU选择了ebreak指令来充当nemu_trap. 为了表示客户程序是否成功结束, nemu_trap指令还会接收一个表示结束状态的参数。

RTFM:

RISC-V的基础指令格式有如下4种:

image-20241217232430933

RISC-V种,EBREAK指令可以用SYSTEM指令实现,如下:

image-20241218224222172

ECALLEBREAK的功能都是在SYSTEM这个opcode下实现的,ECALLfunct12是0,EBREAKfunct12是1。

其中,EBREAK是用于将控制权返回给调试环境。

NEMU中,EBREAK指令的实现是将nemu_state.state置为NEMU_END,将nemu_state.halt_pc置为当前PC,将nemu_state.halt_ret置为10。

Note:

什么是trap?

问了一下GPT:

Trap 是指在程序执行过程中,处理器遇到某些事件时,将控制权从当前程序转移到操作系统或异常处理程序的一种机制。
它可以由程序主动触发(如系统调用),也可以由硬件自动触发(如异常或中断)。

STFW:操作系统小结(三)- Trap机制 - 知乎

so,基本上就是用来切换执行的代码流的(个人理解)。

谁来指示程序的结束?

在程序设计课上老师告诉你, 当程序执行到main()函数返回处的时候, 程序就退出了, 你对此深信不疑. 但你是否怀疑过, 凭什么程序执行到main()函数的返回处就结束了? 如果有人告诉你, 程序设计课上老师的说法是错的, 你有办法来证明/反驳吗? 如果你对此感兴趣, 请在互联网上搜索相关内容。

STFW:在程序终止前,可能会存在一些清理操作,例如关闭打开的文件、释放分配的内存等。这些清理操作可以在主函数的最后部分执行,调用atexit()函数即可。

有始有终 (建议二周目思考)

对于GNU/Linux上的一个程序, 怎么样才算开始? 怎么样才算是结束? 对于在NEMU中运行的程序, 问题的答案又是什么呢?

与此相关的问题还有: NEMU中为什么要有nemu_trap? 为什么要有monitor?

对于下面的问题,一周目的回答:为了暂停/终止当前程序的执行,将CPU的控制权交给其他程序(OS/monitor),monitor是用来调试的(吧。。

代码中有一些值得注意(学习),直接摘抄过来:

三个对调试有用的宏(在nemu/include/debug.h中定义)

  • Log()printf()的升级版, 专门用来输出调试信息, 同时还会输出使用Log()所在的源文件, 行号和函数. 当输出的调试信息过多的时候, 可以很方便地定位到代码中的相关位置
  • Assert()assert()的升级版, 当测试条件为假时, 在assertion fail之前可以输出一些信息
  • panic()用于输出信息并结束程序, 相当于无条件的assertion fail

内存通过在nemu/src/memory/paddr.c中定义的大数组pmem来模拟. 在客户程序运行的过程中, 总是使用vaddr_read()vaddr_write() (在nemu/src/memory/vaddr.c中定义)来访问模拟的内存. vaddr, paddr分别代表虚拟地址和物理地址.

优美地退出

为了测试大家是否已经理解框架代码, 我们给大家设置一个练习: 如果在运行NEMU之后直接键入q退出, 你会发现终端输出了一些错误信息. 请分析这个错误信息是什么原因造成的, 然后尝试在NEMU中修复它.

终于有一个练习了!

在NEMU中直接输入q和输入c之后再输入q的结果确实不一样!如下:

image-20241224234747535

输入c之后再输入q能够优雅地退出,并没有报错,而直接输入则会报错。通过echo $?查看程序的返回值,发现直接退出的返回值是2,而正常的是0,所以问题出在返回值上。

PS:在menu里开启debug配置后,输入make gdb,可以调试NEMU

一个疑惑是,进入gdb调试后,发现直接退出返回的值是1,并不是2!这是为什么呢?值得思考,怀疑是make程序进行了再次返回,于是直接在命令行运行NEMU而不是通过make命令,得到以下结果:

image-20241225002014939

猜想正确。(还可以深究一下为什么make会在返回值1的基础上返回2?

不过,程序会报错的原因找到了,就是因为返回值不为0,而控制返回值的函数在/src/utils/state.c中的函数is_exit_status_bad()

image-20241225002317465

说明退出时不满足这个条件,返回去看sdb_mainloop()函数中的调用逻辑,运行c命令的话,会执行img程序,会使得nemu_state满足good表达式中的前一个条件,而直接输入q则直接返回-1,不会修改nemu_state的状态,于是不满足good的条件,所以为0,返回1,于是修改调用的cmd_q()函数,设置nemu_state的状态为NEMU_QUIT,即可实现“优雅地退出”。

事实上, TRM的实现已经都蕴含在上述的介绍中了.

  • 存储器是个在nemu/src/memory/paddr.c中定义的大数组
  • PC和通用寄存器都在nemu/src/isa/$ISA/include/isa-def.h中的结构体中定义
  • 加法器在… 嗯, 这部分框架代码有点复杂, 不过它并不影响我们对TRM的理解, 我们还是在PA2里面再介绍它吧
  • TRM的工作方式通过cpu_exec()exec_once()体现

1.4 基础设施

免责声明(不是

后续内容有很多是直接摘抄自NEMU的官方文档,每次都说明的话出来太麻烦且不啰嗦

所以,在这里声明,如有雷同,就是我“抄”过来的(保命

1.4.1. 简易调试器

目标是实现sdb的以下一些功能:

命令 格式 使用举例 说明
帮助(1) help help 打印命令的帮助信息
继续运行(1) c c 继续运行被暂停的程序
退出(1) q q 退出NEMU
单步执行 si [N] si 10 让程序单步执行N条指令后暂停执行, 当N没有给出时, 缺省为1
打印程序状态 info SUBCMD info r info w 打印寄存器状态 打印监视点信息
扫描内存(2) x N EXPR x 10 $esp 求出表达式EXPR的值, 将结果作为起始内存 地址, 以十六进制形式输出连续的N个4字节
表达式求值 p EXPR p $eax + 1 求出表达式EXPR的值, EXPR支持的 运算请见调试中的表达式求值小节
设置监视点 w EXPR w *0x2000 当表达式EXPR的值发生变化时, 暂停程序执行
删除监视点 d N d 2 删除序号为N的监视点

CPU_state的定义在相应isa的include/isa-def.h中:

1
2
3
4
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是程序指针。

解析命令

这里有两个函数很好用:

  • strtok():分割字符串
  • sscanf():输入是字符串,从中获取格式化输入

具体用法可以STFW or RTFM

单步执行

略。

PS:并不是我懒,是我相信你可以的!

打印寄存器

同上

扫描内存

这里读取的是物理内存(pmem)。riscv32的物理地址是从0x80000000开始的,NEMU框架已经提供了一个paddr_read()函数来读取对应的地址数据。

1.4.2. 表达式求值

!!!前排提示!!!

大的要来了!这一小节应该会有难度。

在这里使用如下方法来解决表达式求值的问题:

  1. 首先识别出表达式中的单元
  2. 根据表达式的归纳定义进行递归求值

词法分析

词法分析就是识别出表达式中的单元,它们正式的称呼叫token。例子:

1
"5 + 4 * 3 / 2 - 1"

其中,数字5等,符号+*等都是token(除了空格)。

秘密武器:正则表达式

先只考虑一种简单的情况:算术表达式,即待求值表达式中只允许出现以下的token类型:

  • 十进制整数
  • +, -, *, /
  • (, )
  • 空格串(一个或多个空格)

sdb/expr.c中,下面这个代码:

static Token tokens[32] __attribute__((used)) = {};

中的__attribute__((used))是告诉编译器:即使这个 tokens 数组在程序中好像没被用到,也不要优化掉它,这个是GCC / Clang 编译器的扩展语法。

为什么要加这个?

因为NEMU中有很多变量都是用来被 monitor/debug/反射访问的。

什么是反射访问?

就是程序在运行时,根据名字动态查找、访问内部变量或者函数,而不是硬编码在源代码里。

C 语言本身没有内建反射机制,但可以通过 手动建表 + 符号查找来“模拟反射”。

一个反射访问的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct {
const char* name;
void* addr;
} VarEntry;

VarEntry variables[] = {
{"x", &x},
{"y", &y},
{"z", &z},
};

void* lookup(const char* name) {
for (int i = 0; i < sizeof(variables)/sizeof(variables[0]); ++i) {
if (strcmp(variables[i].name, name) == 0)
return variables[i].addr;
}
return NULL;
}

这样就可以通过变量名字”x”去查找变量x的实际地址了。

为什么要有这样的机制?

为了实现变量的动态访问,特别是NEMU中,例如,sdb_mainloop()函数中处理用户输入,来选择调用不同的函数。或许后面会遇到的,根据用户输入的寄存器名来访问相应的寄存器值。

所以加了 __attribute__((used)) 的静态变量,实际上是要被反射访问的。编译器表面上看不到调用,但是运行时需要靠查表、字符串匹配去访问!

小技巧

又学到一个小技巧:Log("... %.*s", substr_len, substr_start)。底层对应的printf函数。

这里面的%.*s其实对应的是最后的两个参数:substr_len, substr_start,分别表示要打印的字符数和字符串起始地址。这样就可以不用管\0字符在哪里了。

2. PA2

2.4. 基础设施2

消失的符号

我们在am-kernels/tests/cpu-tests/tests/add.c中定义了宏NR_DATA, 同时也在add()函数中定义了局部变量c和形参a, b, 但你会发现在符号表中找不到和它们对应的表项, 为什么会这样? 思考一下, 什么才算是一个符号(symbol)?

A:因为被编译器给优化了

2.4.1. 函数调用的踪迹

需要解析ELF文件,首先可以看一下ELF的格式,使用:man 5 elf

我们要在初始化ftrace时从ELF文件中读出符号表和字符串表,供后续使用。

ELF文件中包括许多的section,其中我们需要用到的section有如下3个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.shstrtab
This section holds section names. This section is of type
SHT_STRTAB. No attribute types are used.

.strtab
This section holds strings, most commonly the strings that
represent the names associated with symbol table entries.
If the file has a loadable segment that includes the symbol
string table, the section's attributes will include the
SHF_ALLOC bit. Otherwise, the bit will be off. This
section is of type SHT_STRTAB.
.symtab
This section holds a symbol table. If the file has a
loadable segment that includes the symbol table, the
section's attributes will include the SHF_ALLOC bit.
Otherwise, the bit will be off. This section is of type
SHT_SYMTAB.
An object file's symbol table holds information needed to locate
and relocate a program's symbolic definitions and references. A
symbol table index is a subscript into this array.

.shstrtab保存的是section name;.strtab保存的是符号表中需要用到的字符串,也就是字符串表;.symtab保存的是符号相关的信息,就是我们说的符号表。

实现ftrace的思路如下:

  1. 提前通过符号表和字符串表解析出来各个函数对应的地址(和范围)
  2. 在执行jaljalr指令的时候,判断跳转的地址是否在第1步中解析出来的函数中。
    1. 如果是,则打印信息
    2. 如果不是,则不打印

ELF文件的最开始是一个ELF Header结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct {
unsigned char e_ident[16];
uint16_t e_type;
uint16_t e_machine;
uint32_t e_version;
Elf64_Addr e_entry;
Elf64_Off e_phoff;
Elf64_Off e_shoff; // <- 节表偏移
uint32_t e_flags;
uint16_t e_ehsize;
uint16_t e_phentsize;
uint16_t e_phnum;
uint16_t e_shentsize;
uint16_t e_shnum;
uint16_t e_shstrndx; // <- shstr在节表中的下标
} Elf64_Ehdr;

其中,e_shoff表示的是section header table(sht)在文件中的字节偏移,可以通过它直接定位到sht。
然后,e_shstrndx是sht中字符串表的下标,可以通过它定位到字符串表。
最后,我们需要找到符号表,找到符号表才能找到相应的函数符号。

其中e_shnum是sht数组的大小,即多少项。e_shentsize是每一项的大小。这两个是用来辅助解析sht的。

我们还需要知道的是,符号表每一项的结构体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct {
uint32_t st_name;
Elf32_Addr st_value;
uint32_t st_size;
unsigned char st_info;
unsigned char st_other;
uint16_t st_shndx;
} Elf32_Sym;

typedef struct {
uint32_t st_name;
unsigned char st_info;
unsigned char st_other;
uint16_t st_shndx;
Elf64_Addr st_value;
uint64_t st_size;
} Elf64_Sym;

其中st_info指示的是这个符号的类型,如果是它的值是STT_FUNC,那么就表示这个符号是一个函数名。

一个小坑需要注意,st_info这个成员不只是符号的类型,还有binding信息,所以还需要通过一个宏ELF32_ST_TYPE(info)来将st_info转换成TYPE类型再和STT_FUNC对比判断。

RISC-V架构中,函数的调用和返回都是通过jaljalr指令来实现的,前者是直接跳转指令,后者是间接跳转指令。

一个典型的ELF文件的大致内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
+---------------------------+
| ELF Header (Elf32_Ehdr) |
+---------------------------+
| Program Header Table (可选)|
+---------------------------+
| Section 1 (.text) |
+---------------------------+
| Section 2 (.data) |
+---------------------------+
| Section 3 (.shstrtab) |
+---------------------------+
| Section ... |
+---------------------------+
| Section Header Table |
+---------------------------+
| Section ... |
+---------------------------+

实现初版的ftrace后,运行得到的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
0x8000000c: call [_trm_init@0x80000258]
0x80000268: call [main@0x800001cc]
0x800001ec: call [f0@0x80000010]
0x8000016c: call [f2@0x800000a4]
0x800000f0: call [f1@0x8000005c]
0x8000016c: call [f2@0x800000a4]
0x800000f0: call [f1@0x8000005c]
0x8000016c: call [f2@0x800000a4]
0x800000f0: call [f1@0x8000005c]
0x8000016c: call [f2@0x800000a4]
0x800000f0: call [f1@0x8000005c]
0x8000016c: call [f2@0x800000a4]
0x800000f0: call [f1@0x8000005c]
0x80000058: ret [f0]
0x80000100: ret [f2]
0x80000184: call [f2@0x800000a4]
0x800000f0: call [f1@0x8000005c]
0x80000058: ret [f0]
0x80000100: ret [f2]
0x800001ac: ret [f3]
0x80000100: ret [f2]
0x80000184: call [f2@0x800000a4]
0x800000f0: call [f1@0x8000005c]
0x8000016c: call [f2@0x800000a4]
0x800000f0: call [f1@0x8000005c]
0x80000058: ret [f0]
0x80000100: ret [f2]
0x80000184: call [f2@0x800000a4]
0x800000f0: call [f1@0x8000005c]
0x80000058: ret [f0]
0x80000100: ret [f2]
0x800001ac: ret [f3]
0x80000100: ret [f2]

不匹配的函数调用和返回​
如果你仔细观察上文recursion的示例输出, 你会发现一些有趣的现象. 具体地, 注释(1)处的ret的函数是和对应的call匹配的, 也就是说, call调用了f2, 而与之对应的ret也是从f2返回; 但注释(2)所指示的一组call和ret的情况却有所不同, call调用了f1, 但却从f0返回; 注释(3)所指示的一组call和ret也出现了类似的现象, call调用了f1, 但却从f3返回。

A:尾调用优化,在函数结尾简单地调用另一个函数的话,会触发编译器的尾调用优化。这里的简单是指仅仅调用另一个函数,而不会在调用后对返回值进行处理,例如f2中的“+9”。所以我们可以观察到,不匹配的call和ret是固定组合:

  1. call f1, ret f3
  2. call f1, ret f0
  3. call f0, ret f3 (这个是最外面一层call ret对)

还有一个“奇怪的”点,你可以观察到,最开始的第一个call f0函数后,call的是f2,而不是代码中的f3。实际上这也是尾调用优化的一个影响,实现尾调用的话,不需要保存返回地址到ra寄存器,而我们实现的ftrace是按照是否保存返回地址到ra判断是否是call指令的,因此,要实现真正的ftrace,还需要对jal/jalr指令进行判断,不能仅仅看是否保存地址到ra。

关于尾调用优化(TCO)的解释,推荐一个视频:

https://www.bilibili.com/video/BV1Pb421Y7uP

冗余的符号表

在Linux下编写一个Hello World程序, 然后使用strip命令丢弃可执行文件中的符号表:

1
2
gcc -o hello hello.c
strip -s hello

readelf查看hello的信息, 你会发现符号表被丢弃了, 此时的hello程序能成功运行吗?

目标文件中也有符号表, 我们同样可以丢弃它:

1
2
gcc -c hello.c
strip -s hello.o

readelf查看hello.o的信息, 你会发现符号表被丢弃了. 尝试对hello.o进行链接:

1
gcc -o hello hello.o

你发现了什么问题? 尝试对比上述两种情况, 并分析其中的原因.

strip掉可执行文件中的符号表依然可以执行,但是strip掉目标文件中的符号表后再链接就会报错:

/usr/bin/ld: error in test.o(.eh_frame); no .eh_frame_hdr table will be created
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o: in function _start': (.text+0x24): undefined reference tomain’
collect2: error: ld returned 1 exit status

这是因为符号表就是用于在链接的时候定位地址

2.4.2. 输入输出

设备初始化的流程:

NEMU(init_monitor)—>NEMU device(init_device)—> NEMU device(各种device init)—>NEMU device IO(add_mmio_map)

如果是make ARCH=riscv32-nemu,那么暂时还没有用到IOE层,AM(ioe_init)—>AM层外设init

设备访问的流程:

对于RISC-V来说,就是直接写地址,然后在paddr.c中会进行地址的判断,判断是否是往MMIO的地址写入,如果是,则调用

vaddr_write—>paddr_write—>mmio_write—>map_write—>host_write(往mmio map的space中写入数据)—>调用设备的callback函数,以serial为例,那就是调用serial_io_handler函数—>serial_putc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
hello.c
printf

putch

outb(SERIAL_PORT)
↓ (store 指令)
NEMU CPU 模拟

MMIO 命中 serial

serial_io_handler

serial_putc

putc(stderr) ← Linux

跑分的bug

实现完RTC的IOE后,运行PA中的3个benchmark,

出现了如下的bug:

1
2
3
4
5
6
7
8
9
10
Dhrystone Benchmark, Version C, Version 2.2
Trying 500000 runs through Dhrystone.
Finished in 0 ms
==================================================
Dhrystone PASS -1 Marks
vs. 100000 Marks (i7-7700K @ 4.20GHz)
[src/cpu/cpu-exec.c:125 cpu_exec] nemu: HIT GOOD TRAP at pc = 0x80000d2c
[src/cpu/cpu-exec.c:92 statistic] host time spent = 8,835,660 us
[src/cpu/cpu-exec.c:93 statistic] total guest instructions = 219,508,565
[src/cpu/cpu-exec.c:94 statistic] simulation frequency = 24,843,482 inst/s

跑分为-1!

原因是要先读取高位rtc才会刷新!

在utils.h中添加dtrace的函数声明如下:

1
2
3
4
5
6
// ----------- dtrace ------------
struct IOMap;

void trace_device_read(paddr_t addr, int len, struct IOMap *map);

void trace_device_write(paddr_t addr, int len, word_t data, struct IOMap *map);

其中,前向声明struct IOMap是必须的,因为这个结构体在map.h中,但是如果直接include “device/map.h”的话,会造成循环引用,从而导致以下错误:

1
2
3
4
5
6
7
8
9
10
11
+ CC src/device/device.c
In file included from /home/doa/Documents/OS/ics2023/nemu/include/device/map.h:19,
from /home/doa/Documents/OS/ics2023/nemu/include/utils.h:20,
from /home/doa/Documents/OS/ics2023/nemu/include/debug.h:21,
from /home/doa/Documents/OS/ics2023/nemu/include/common.h:47,
from src/device/device.c:16:
/home/doa/Documents/OS/ics2023/nemu/include/cpu/difftest.h: In function ‘difftest_check_reg’:
/home/doa/Documents/OS/ics2023/nemu/include/cpu/difftest.h:46:5: error: implicit declaration of function ‘Log’ [-Werror=implicit-function-declaration]
46 | Log("%s is different after executing instruction at pc = " FMT_WORD
| ^~~
cc1: all warnings being treated as errors

同时,需要将map中的结构体定义改为:

1
2
3
4
5
6
7
8
typedef struct IOMap{
const char *name;
// we treat ioaddr_t as paddr_t here
paddr_t low;
paddr_t high;
void *space;
io_callback_t callback;
} IOMap;

即,在struct后面也加一个IOMap名称。这样才能使用前向声明,否则也会报错。

image-20260426210557109

image-20260426210444105

参考blog

nju pa2 - NOSAE - 博客园

NJU PA2日记,我的实现经历和感悟(已完结) – YZS