we choose to go t|

上山砍大树

园龄:5年3个月粉丝:13关注:3

2024-07-23 17:36阅读: 397评论: 0推荐: 1

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

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

在开始愉快的PA之旅之前

🏷️ 对于即将面临的材料描述不充分的问题,PA1这么介绍:

做PA的终极目标是通过构建一个简单完整的计算机系统, 来深入理解程序如何在计算机上运行. 和那些"用递归实现汉诺塔"的程序设计作业不同, 计算机系统比汉诺塔要复杂得多. 这意味着, 通过程序设计作业的训练方式是不足以完成PA的, 只有去尝试理解并掌握计算机系统的每一处细节, 才能一步步完成PA.

所以, 不要再用程序设计作业的风格来抱怨PA讲义写得不清楚, 之所以讲义的描述点到即止, 是为了强迫大家去理清计算机系统的每一处细节, 去推敲每一个模块之间的关系, 也是为了让大家积累对系统足够的了解来面对未知的bug.

这对你来说也许是一种前所未有的训练方式, 所以你也需要拿出全新的态度来接受全新的挑战.

告诉我们,材料可能只告诉了你目的地在哪,至于地图在哪,路线怎么规划,就需要自己探索了🌍。

🎏这里还讲到了通过STFW和RTFM独立解决问题"的最初原的信念, 这种信念可以帮助大家驱散对未知的恐惧

但是说句实在的,我通过man命令调出来的黑乎乎的命令指示,还是相当头晕目眩的😨,有点恐惧于man命令的体量,然后经常付诸于google的中文引擎去搜索对应的命令常用用法。

或许我应该弄明白,如何看懂man operation以及何时应该去看这个吓人的手册,希望经过我的学习,能让我的STFM过程更加高效有趣。😊

更快的编译速度

🌮 小练习:

为了使用ccache, 你还需要进行一些配置的工作. 首先运行如下命令来查看一个命令的所在路径:

which gcc

作为一个RTFM的练习, 接下来你需要阅读man ccache中的内容, 并根据手册的说明, 在.bashrc文件中对某个环境变量进行正确的设置. 如果你的设置正确且生效, 重新运行which gcc, 你将会看到输出变成了/usr/lib/ccache/gcc. 如果你不了解环境变量和.bashrc, STFW.

在目录usr/lib/ccache下,包含了各种编译器链接到ccache的符号链接:

crx@ubuntu:ccache$ ll
total 8
drwxr-xr-x 2 root root 4096 Aug 7 16:21 ./
drwxr-xr-x 112 root root 4096 Aug 7 16:21 ../
lrwxrwxrwx 1 root root 16 Aug 7 16:21 c++ -> ../../bin/ccache*
lrwxrwxrwx 1 root root 16 Aug 7 16:21 c89-gcc -> ../../bin/ccache*
lrwxrwxrwx 1 root root 16 Aug 7 16:21 c99-gcc -> ../../bin/ccache*
lrwxrwxrwx 1 root root 16 Aug 7 16:21 cc -> ../../bin/ccache*
lrwxrwxrwx 1 root root 16 Aug 7 16:21 g++ -> ../../bin/ccache*
lrwxrwxrwx 1 root root 16 Aug 7 16:21 g++-11 -> ../../bin/ccache*
lrwxrwxrwx 1 root root 16 Aug 7 16:21 gcc -> ../../bin/ccache*
lrwxrwxrwx 1 root root 16 Aug 7 16:21 gcc-11 -> ../../bin/ccache*
lrwxrwxrwx 1 root root 16 Aug 7 16:21 x86_64-linux-gnu-g++ -> ../../bin/ccache*
lrwxrwxrwx 1 root root 16 Aug 7 16:21 x86_64-linux-gnu-g++-11 -> ../../bin/ccache*
lrwxrwxrwx 1 root root 16 Aug 7 16:21 x86_64-linux-gnu-gcc -> ../../bin/ccache*
lrwxrwxrwx 1 root root 16 Aug 7 16:21 x86_64-linux-gnu-gcc-11 -> ../../bin/ccache*

所以在调用此目录下的gcc命令时,实际上调用的是ccache,这样就实现了缓存编译的目的。

但是这就需要将此目录设置为PATH变量中,较原版gcc更靠前的位置,而这就牵扯出了环境变量的概念。

简单来说,环境变量相当于shell的配置,让它知道在哪里能找到某些资源。

而环境变量中PATH变量的作用,是让系统快速启动一个应用程序。他告诉操作系统去哪些目录中寻找可执行程序。PATH变量的变量值是多个文件夹的路径。当我们在命令行中输入一个命令的时候,操作系统会按照PATH变量中列出的目录顺序,按序查找这个命令的可执行文件。

PATH变量包含很多文件夹的路径:

crx@ubuntu:~$ echo $PATH
/usr/lib/ccache:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
crx@ubuntu:~$

这里在环境变量的配置文件~/.bashrc中,加入了

export PATH=/usr/lib/ccache:$PATH

这样就会让/usr/lib/ccache目录变量在整个PATH变量中,作为最靠前的变量进行检索,这样使用gcc编译文件的时候,就相当于执行ccache了。

NEMU是什么

NEMU是一个模拟器🖥️ ,模拟出一个计算机的所有硬件。应该是要模拟出一个内存、cpu、缓存等硬件系统。

开天辟地的篇章

从题目理解状态机:

🖌️ 从状态机视角理解程序运行

以上一小节中1+2+...+100的指令序列为例, 尝试画出这个程序的状态机.

这个程序比较简单, 需要更新的状态只包括PCr1, r2这两个寄存器, 因此我们用一个三元组(PC, r1, r2)就可以表示程序的所有状态, 而无需画出内存的具体状态. 初始状态是(0, x, x), 此处的x表示未初始化. 程序PC=0处的指令是mov r1, 0, 执行完之后PC会指向下一条指令, 因此下一个状态是(1, 0, x). 如此类推, 我们可以画出执行前3条指令的状态转移过程:

(0, x, x) -> (1, 0, x) -> (2, 0, 0) -> (3, 0, 1)

请你尝试继续画出这个状态机, 其中程序中的循环只需要画出前两次循环和最后两次循环即可.

答:

(0, x, x) -> (1, 0, x) -> (2, 0, 0) -> (3, 0, 1) -> (4, 1, 1)
-> (2, 1, 1) -> (3, 1, 2) -> (4, 3, 2)
……
-> (2, 1+2+...+98, 98) -> (3, 1+2+...+98, 99) -> (4, 1+2+3+...+99, 99)
-> (2, 1+2+...+99, 99) -> (3, 1+2+...+99, 100) -> (4, 1+2+3+...+99+100, 100)

这样将PC、r1和r2的值作为状态机的状态,然后执行程序的流程体现在状态的转移过程,就可以很好地理解其实程序执行无非也是状态的转移:从一个状态(时序逻辑),在组合逻辑(代码流程)的作用下,计算并转移到下一时钟周期的新状态。

RTFSC

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

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

模式替换

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

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

如果 SRCSsrc/main.c src/utils.c,而 CXXSRCsrc/main.cc src/utils.cc,且 OBJ_DIRbuild/obj,则OBJS 将包含 build/obj/main.o build/obj/utils.o 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程序以"只输出命令但不执行"的方式强制构建目标. 运行后, 你可以看到很多形如

echo + CC src/memory/paddr.c
mkdir -p /home/crx/study/ics2023/nemu/build/obj-riscv32-nemu-interpreter/src/memory/
gcc -O2 -MMD -Wall -Werror
-I/home/crx/study/ics2023/nemu/include
-I/home/crx/study/ics2023/nemu/src/engine/interpreter
-I/home/crx/study/ics2023/nemu/src/isa/riscv32/include
-O2
-DITRACE_COND=true
-D__GUEST_ISA__=riscv32
-c -o
/home/crx/study/ics2023/nemu/build/obj-riscv32-nemu-interpreter/src/memory/paddr.o src/memory/paddr.c
/home/crx/study/ics2023/nemu/tools/fixdep/build/fixdep /home/crx/study/ics2023/nemu/build/obj-riscv32-nemu-interpreter/src/memory/paddr.d /home/crx/study/ics2023/nemu/build/obj-riscv32-nemu-interpreter/src/memory/paddr.o unused > /home/crx/study/ics2023/nemu/build/obj-riscv32-nemu-interpreter/src/memory/paddr.d.tmp
mv /home/crx/study/ics2023/nemu/build/obj-riscv32-nemu-interpreter/src/memory/paddr.d.tmp /home/crx/study/ics2023/nemu/build/obj-riscv32-nemu-interpreter/src/memory/paddr.d

这样可以得出对应变量的值

$< -> src/memory/paddr.c
$@ -> /home/crx/study/ics2023/nemu/build/obj-riscv32-nemu-interpreter/src/memory/paddr.o
$(CC) -> gcc
$(CFLAGS) ->
-MMD -Wall -Werror
-I/home/crx/study/ics2023/nemu/include
-I/home/crx/study/ics2023/nemu/src/engine/interpreter
-I/home/crx/study/ics2023/nemu/src/isa/riscv32/include
-O2
-DITRACE_COND=true
-D__GUEST_ISA__=riscv32

$(CFLAGS)前面的定义为

CFLAGS += -fPIC -fvisibility=hidden
CFLAGS := -O2 -MMD -Wall -Werror $(INCLUDES) $(CFLAGS)

所以可以推测

$(INCLUDES) ->
-I/home/crx/study/ics2023/nemu/include
-I/home/crx/study/ics2023/nemu/src/engine/interpreter
-I/home/crx/study/ics2023/nemu/src/isa/riscv32/include

$(INCLUDE)

INC_PATH := $(WORK_DIR)/include $(INC_PATH)
INCLUDES = $(addprefix -I, $(INC_PATH))

所以$(INC_PATH)

$(INC_PATH) ->
/home/crx/study/ics2023/nemu/include
/home/crx/study/ics2023/nemu/src/engine/interpreter
/home/crx/study/ics2023/nemu/src/isa/riscv32/include

这里就是简单地从编译结果去理解源变量的意义。

准备第一个客户程序

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(),其功能和使用方法是:

  1. 功能:解析命令行选项

  2. 函数原型:

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

    argv:选项元素。以-开头,然后紧跟一个选项字符。当重复调用函数getopt_long的时候,函数会连续返回选项元素

  3. 对于里面的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 指向的变量中

  4. optstring

    • 包含了合法的option characters。该字符前包含一个破折号-

    • 如果选项字符后面包含一个冒号,则说明这个选项需要一个参数。此时,函数会将一个字符串指针optarg指向这个参数。这个参数既可以与选项字符在同一个元素中,例如-oarg;也可以与选项分开存放,例如-o arg

    • 如果选项后面包含两个冒号。表示这个选项可以有一个可选的参数。此时假设optstringo::,表示 -o 选项可以带有可选参数。

      • 如果选项和参数是在同一个单词中的if there is text in the current argv-element ,例如命令行参数是 -oarg,则 optarg 将会被设置为 "arg"

      • 如果命令行参数是 -o,则 optarg 将被设置为 NULL,因为没有在同一个单词内附加参数。

    • 函数在浏览参数的时候,默认会将所有的非选项元素(不以-开头的参数)放在末尾。但是有两种浏览选项的模式也可以执行:

      • 如果optstring开头是+,则在遇到第一个非选项的时候,会停止

      • 如果optstring开头是-,则每一个非选项参数元素,都被对应为参数1

        在这里,函数getopt_longoptstring开头是-,所以如果输入非选项元素(不带-的参数),则会将此参数赋值给img_file,然后立即结束选项解析。

  5. 返回值

    当flag是NULL的时候,返回option结构体中的val;否则返回0。
    代码整体剖析:

  6. 选项表的定义

    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): 无需参数,显示帮助信息。
  7. 解析选项

    while ( (o = getopt_long(argc, argv, "-bhl:d:p:", table, NULL)) != -1) {

    使用 getopt_long 来解析命令行参数。如果找到选项,o 将会被赋值为该选项的对应字符。

  8. 处理选项

    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。
    • 如果遇到未知选项,则显示用法信息并退出程序。
  9. 返回值

    函数返回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的时候,如果没有指定客户程序文件,则会执行内置的客户程序。

如果指定了客户程序文件,则会获取此客户程序并且将此程序加载到上文相同的内存位置0x80000000pmem[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()

strtok()

STFSCman 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只能为非空字符串):

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

写一个小程序来实践下

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
char str[] = "1a/bbb///:/;:/cc;xxx:yyy:";
char *token = strtok(str, ":/;");
while(token != NULL) {
printf("token is %s\n", token);
token = strtok(NULL, ":/;");
}
printf("str = %s\n", str);
return 0;
}

输出为

token is 1a
token is bbb
token is cc
token is xxx
token is yyy
str = 1a
  • 分隔符集合dlim包含了许多分割字符,对于待解析的字符串,遇到集合中的任意一个字符都要被分割。
  • 第一次调用strtok() 时,函数内部会保存一个指向该字符串中下一个标记位置的指针,以供后续调用。
  • 第一次调用strtok()时,会将str分割为第一个token字符串la
  • 后续要继续解析这个字符串时,需要将str替换为NULL,这样再次调用时,就会从上一次解析完毕的地方开始再新一轮的解析。
  • 替换后,str字符串中的分隔符会被终止空字符\0替换掉

这样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的值就不必去深究怎么获得的了。后面的内容,也等到后续学习了关于译码的相关问题再来解决。

优美地退出

make run启动nemu后直接输入q退出,得到如下最后一行的错误:

Welcome to riscv32-NEMU!
For help, type "help"
(nemu) q
make: *** [/home/crx/study/ics2023/nemu/scripts/native.mk:38: run] Error 1

这里因为调用q的函数cmd_q的时候,返回了一个-1,结果报错。所以我们需要考虑主函数main()在调用完engine_start()后,对于其返回值的处理。

我们先回顾下,nemu的主函数nemu-main.c

#include <common.h>
void init_monitor(int, char *[]);
void am_init_monitor();
void engine_start();
int is_exit_status_bad();
int main(int argc, char *argv[]) {
/* Initialize the monitor. */
#ifdef CONFIG_TARGET_AM
am_init_monitor();
#else
init_monitor(argc, argv);
#endif
/* Start engine. */
engine_start();
return is_exit_status_bad();
}

可以看出,初始化监视器并且程序开始运行后,最终函数的返回语句为return is_exit_status_bad().

所以我们看下这个返回值函数is_exit_status_bad的源码:

#include <utils.h>
NEMUState nemu_state = { .state = NEMU_STOP };
int is_exit_status_bad() {
int good = (nemu_state.state == NEMU_END && nemu_state.halt_ret == 0) ||
(nemu_state.state == NEMU_QUIT);
return !good;
}

可以看出要修改的地方就是函数cmd_q

static int cmd_q(char *args) {
nemu_state.state = NEMU_QUIT;
return -1;
}

这样就可以解决报错问题了。

基础设施

单步执行

描述:让程序单步执行N条指令后暂停执行,当N没有给出时, 缺省为1

格式:si [N]

举例:si 10

代码实现:

uint64_t get_steps_from_args() {
char *num = strtok(NULL, " ");
/* no argument given */
if (num == NULL) {
return 1;
}
uint64_t result = 0;
int i = 0;
/* Loop through the string*/
while(num[i] != '\0') {
// Check if the character is a digit (0-9)
if (num[i] >= '0' && num[i] <= '9') {
result = result * 10 + (num[i] - '0');
} else {
//Return 1 if a non-digit character is found
return 1;
}
i++;
}
return result;
}
static int cmd_step(char *args) {
/* get the num of steps from args*/
uint64_t n = get_steps_from_args();
cpu_exec(n);
return 0;
}

这里用到了函数get_steps_from_args来处理参数:

  • 如果输入非法字符,则返回1
  • 如果输入为NULL,则返回1
  • 输入为数字字符串,将其转换为对应的数字,并返回

这个函数的有意思的两点:

  • strtok()函数:第一次调用处在函数sdb_mainlooop()

    char *cmd = strtok(str, " ");
    char *args = cmd + strlen(cmd) + 1;

    此时调用完函数strtok()后,若想继续解析后面的符号(token),则直接使用

    char *num = strtok(NULL, " ");
  • 将数字字符串转换为对应的10进制数据

打印寄存器

描述:打印寄存器状态

格式:info SUBCMD

举例:info r

执行info r之后, 就调用isa_reg_display(), 在里面直接通过printf()输出所有寄存器的值即可.

代码已经准备了如下的API:

// nemu/src/isa/$ISA/reg.c
void isa_reg_display(void);

🇶🇦

  • risv32寄存器在哪里定义

    nemu/src/isa/risv32/reg.c中被定义为字符串数组

  • risv32寄存器的值是怎么修改和使用的?

    这时需要回忆之前iniy_isa()的相关功能:第一步,将内置的客户程序读入到内存中;第二步就是初始化寄存器。

    例如将0号寄存器置为存放0的状态:

    cpu.gpr[0] = 0;
  • 如何获取risv32的寄存器的值?

    遍历cpu中寄存器值即可

    寄存器结构体CPU_state的定义放在nemu/src/isa/$ISA/include/isa-def.h中, 并在nemu/src/cpu/cpu-exec.c中定义一个全局变量cpu.

    查看CPU_state

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

    此问题可转换为:遍历cpu结构体中寄存器gpr和程序指针pc的值

首先实现risv32的isa_reg_displayAPI:

#define gpr(idx) (cpu.gpr[check_reg_idx(idx)])
void isa_reg_display() {
for(int i = 0; i < MUXDEF(CONFIG_RVE, 16, 32); i++) {
if (gpr(i) == 0) {
printf("%-3s = %d\n", regs[i], gpr(i));
}
else {
printf("%-3s = 0x%08x\n", regs[i], gpr(i));
}
}
}

然后再从sdb.c中调用此函数

static int cmd_info(char *args) {
char *arg = strtok(NULL, " ");
if (arg == NULL || strcmp(arg, "r") == 0) {
isa_reg_display();
}
return 0;
}

运行nemu后,输入c执行完毕内置程序,此时打印寄存器的值:

(nemu) info
$0 = 0
ra = 0
sp = 0
gp = 0
tp = 0
t0 = 0x80000000
t1 = 0
t2 = 0
...

[!IMPORTANT]

为什么执行内置程序后,寄存器t0被设置为0x80000000了?

这里就需要结合risv32的指令集来解释了

static const uint32_t img [] = {
0x00000297, // auipc t0,0 -- 将t0的值设置为当前pc(0x80000000)的值 (Add Upper Immediate to PC)
0x00028823, // sb zero,16(t0)
0x0102c503, // lbu a0,16(t0)
0x00100073, // ebreak (used as nemu_trap)
0xdeadbeef, // some data
};

这样就可以解释为何执行完毕内置程序后,寄存器的值为0x80000000了。

扫描内存

描述:输入起始内存地址,以十六进制行书输出连续的N个4字节。

格式:x N addr

举例:x 10 0x80000000

分析:

上面分析函数init_mem()init_isa()时,可以得出几个结论:

  1. 客户程序的物理地址,将会被映射到模拟出的内存数组pmem

  2. 物理地址到内存数组pmem的映射规则是

    uint8_t* guest_to_host(paddr_t paddr) { return pmem + paddr - CONFIG_MBASE; }

paddr_t的定义(include/common.h

typedef MUXDEF(PMEM64, uint64_t, uint32_t) paddr_t;

这里paddr_t代指物理地址,而在risv32系统上,相当于unit32_t

思路:

  • 地址转换:输入物理内存地址paddr,通过函数guest_to_host()找到对应的数组地址
  • N:根据gdb输出内存地址的惯例,输出数据每4个字节占一行

实现:

在调用函数vaddr_read()来获取内存数据时,需要将对应的头文件/include/memory/paddr.h包含进来。

#include "memory/vaddr.h"
static int string_to_num(char *str) {
int i = 0;
int result = 0;
/* Loop through the string*/
while(str[i] != '\0') {
// Check if the character is a digit (0-9)
if (str[i] >= '0' && str[i] <= '9') {
result = result * 10 + (str[i] - '0');
} else {
printf("Warning: %s is not a num!\n", str);
return 0;
}
i++;
}
return result;
}
vaddr_t hex_string_to_vaddr(const char* hex_str) {
vaddr_t result = 0;
sscanf(hex_str, "%x", &result);
return result;
}
static int cmd_scan_memory(char *args) {
char *lines_str = strtok(NULL, " ");
if (lines_str == NULL) {
printf("Please input N!\n");
return 0;
}
char *addr_str = strtok(NULL, " ");
if (addr_str == NULL) {
printf("Please input address!\n");
return 0;
}
int lines = string_to_num(lines_str); //1,2,3...N
vaddr_t addr_hex = hex_string_to_vaddr(addr_str);
if (addr_hex < CONFIG_MBASE) {
printf("Invalid address!\n");
return 0;
}
for (int i = 0; i < lines; i++) {
printf(FMT_WORD ":", addr_hex + i*4);
for (int j = 0; j < 4; j++) {
uint8_t data = vaddr_read(addr_hex + 4*i + j, 1);
printf(" %02x", data);
}
printf("\n");
}
return 0;
}

表达式求值

识别十进制的算数表达式的正则表达式:

  • 十进制整数:[0-9]+
  • +, -, *, /[+\-*/]
  • (, )[()]
  • 空格串: +

⚠️在这里,正则表达式还要作为C语言的字符串进行操作,所以还要符合C语言的转义字符。

例如正则表达式对于匹配一个+符号的表达式为:\+;但是由于要作为c语言的字符串储存,所以需要写为\\+l来抵消特殊字符\的特定功能。

POSIX regex functions

编译正择表达式的函数:regcomp

匹配正则表达式函数:regexec

函数原型

#include <regex.h>
typedef struct {
regoff_t rm_so;
regoff_t rm_eo;
} regmatch_t;
int regcomp(regex_t *preg, const char *regex, int cflags);
int regexec(const regex_t *preg, const char *string, size_t nmatch,
regmatch_t pmatch[], int eflags);

函数功能:

  • regcomp():将正则表达式regex(例如\d+匹配任意数量的数字)编译,用于后面的函数regexec()做匹配。preg是一个指向正则表达式缓冲区模式存储区域的指针。

  • regexec():通过编译好的正则表达式模式缓冲区已存在的范例,对不含终止符\0的字符串string进行匹配。

    nmatchpmatch 数组中的元素个数,即你希望捕获的匹配组数。

    pmatch:用于存储匹配位置的数组

    返回值为0,则匹配成功。

  • 用于存储匹配位置的数组regmatch_t:

    • rm_so: 匹配的起始位置(下标)
    • rm_eo: 匹配的结束位置(下标,超出最后一个匹配字符的位置)

遇到的问题:

POSIX元字符

因为我在写关于数字的正则表达式时候,采用了并不是POSIX模式匹配的元字符\\d+,导致一直编译不过。

后来查询了POSIX的支持的元字符,发现只能用[0-9]+来匹配十进制数字。

strncpy()

函数strncpy()

char *strncpy(char *dest, const char *src, size_t n);
// simple implementation
char *
strncpy(char *dest, const char *src, size_t n)
{
size_t i;
for (i = 0; i < n && src[i] != '\0'; i++)
dest[i] = src[i];
for ( ; i < n; i++)
dest[i] = '\0';
return dest;
}

用这个函数是想控制输入的字符串长度。

如果dest12345src+-n2,则函数调用完毕后,dest的值为+-345

递归求值

递归求值的逻辑

  1. 根据token来分析表达式属于BNF中的哪一种情况
  2. 求值

待解决问题

  • 检查左右括号是否匹配:static bool check_parentheses(p, q)
  • 确定表达式类型:寻找主运算符,将长表达式分裂为两个子表达式
    • 只有[+\-*/]才可能是主运算符
    • 主运算符的优先级在表达式中的优先级是最低的
    • 多个运算符优先级都是最低,根据结合性,最后被结合的运算符才是主运算符
  • 错误处理:表达式不合法时候的错误处理

函数描述

  • 检查表达式左右括号是否匹配static bool check_parentheses(p, q)
    • 输入:pq分别指示这个表达式的开始位置和结束位置
    • 输出:若表达式被一对匹配括号包围,同时括号内部的左右括号也均匹配,则输出true;否则输出false
    • 逻辑:
      • 第一步,检查pq位置是否分别为()。如果是,则取出括号内部的子表达式进行第二步判断;如果不是,则记录false
      • 第二步,检查括号内部的子表达式中,左右括号是否全部匹配。如果表达式中左右括号全部匹配,则返回true,否则返回false(例如(1+2)-(3+4),将会在第二步中取得子表达式1+2)-(3+4,证明不符合要求)
    • 注意:此方法仅用来判断一个表达式是否被括号包围。如果是则拆除左右括号进行下一步运算,如果不是则交给其他步骤处理。至于拆除后的括号是否为正确的表达式,不在此方法讨论。
  • 确定表达式类型
    • 判断是否为括号匹配表达式:assert_expression(p,q)
      • 如果表达式中的左右括号不能全部匹配,则assert(0)
    • 确定主运算符
      • 如果运算符被()包围,则不记录此运算符
      • 如果下一个运算符是+-,则将记录的运算符替换为为下一个运算符
      • 如果下一个运算符是*/,且记录的运算符也是*/,则将下一个运算符赋给记录的运算符

在这里其实我还是对“如何保证该表达式合法”有疑惑,所以翻阅了下cs61a中关于scheme实现表达式求值的资料,看看能否解决这个疑惑。

  • 将表达式看做数据!(将表达式转化为tokens数组)

  • 表达式解析:包含了词法分析:将输入的字符串分解为最小的语法单元tokens(类似num或者+-*/);和语法分析:将tokens构建为表达式树

    词法分析会产生tokens序列(函数make_tokens()

    语法分析会消耗掉tokens序列(这里好像并没有将其转换为表达式的方法,而是将表达式的判断放入了计算函数expr()中)

  • 语法分析是树递归函数,按照所有大的表达式可以被分解为小的表达式。递归产生的结构化表达式会被计算器消耗掉

  • 计算器:输入的表达式两个合法的语法格式就是数字和调用表达式(括号包围的表达式)

    数字计算:直接返回本身值

    调用表达式:需要函数来处理

那么现在思考“如何计算表达式的值”这个问题:

  • 如果表达式是一个数字,那么自我运算返回数字本身
  • 如果表达式是一个括号包围的表达式,则计算括号内部的表达式
  • 如果是一个表达式,则计算表达式的值

实现这些功能后,现在还有一个负数求值的功能需要实现,例如计算

"-1 + 1"
"1 + -1"
"--1" /* 我们不实现自减运算, 这里应该解释成 -(-1) = 1 */

考虑两个问题:

  • 负号和减号都是-, 如何区分它们?
  • 负号是个单目运算符, 分裂的时候需要注意什么?

我的回答:

  • 判断-前面是不是另一个表达式即可。如果-前面是一个表达式,那么-为减号;若前面不是一个表达式(运算符),则-为符号(例如-1主运算符为-,但是前面不是一个表达式,则-为负数)
  • --1的主运算符是第二个-,而主运算符的前一个表达式不是一个表达式(是一个运算符-

既然要对-前面的表达式进行判断,从而决定是负号还是减号。所以我们需要对求值的val1 = eval(p, op - 1)进行处理:

if (p > q) {
/* Bad expression */
if(tokens[p].type == '-'){
return 0;
}
}

这样做的结果就是,对于类似-3的负数,确定好主运算符-号后,立刻判断-前面并不是表达式,所以就相当于将其转换成0-3的场景。

对于--1的处理方法,可以从确定主运算符的方法locate_main_operator()入手。将--x其转换为-(expr)的样式即可。这样就可以用上面的方式,将表达式进一步转换为0-(expe),其中expr=-x

简单来说,就是将两个连续的-运算符的场景,在判断主运算符函数中,单独归为一类。

表达式生成器

设计生成表达式的函数gen_rand_expr():输出合法的表达式,并将表达式保存到缓冲区buf

void gen_rand_expr() {
switch (choose(3)) {
case 0: gen_num(); break;
case 1: gen('('); gen_rand_expr(); gen(')'); break;
default: gen_rand_expr(); gen_rand_op(); gen_rand_expr(); break;
}
}

其中涉及到四个函数:

  • 随机整数生成函数gen_num()
  • 生成指定字符函数gen(char)
  • 生成随机运算符函数gen_rand_op()
  • 生成一个小于n的随机数函数uint32_t choose(uint32_t n)

前三个函数均将生成的结果,存放到buf中。

实现完毕这几个函数后,编译运行nemu/tools/gen-expr/gen-expr.c即可实现生成表达式和结果。

结果出现了

unsigned result = (101)((11)-291)+((241))/(540)*(116)/(150)-(990-208/368/549-((70))+(64))*566;

(expr1)(expr2)的错误场景。分析是因为在每一次生成表达式的时候,都会用concat()函数附在已存在的表达式上,这里第一个(expr1)就是第一次生成的表达式,而第二次生成的表达式(expr2)则由于没有清空buf[]导致直接附在了表达式1(expr1)的后面导致了错误。

所以这里的解决方法就是在每一次生成表达式之前,将buf置空(我这里是直接将首字符设置为\0的方法)

但是仍有几个问题需要考虑:

  • 如何生成长表达式, 同时不会使buf溢出?

    答:在buf中添加任何数据之前,需要判断新添数据和原有数据的长度之和,是否超出buf的限制长度。如果超出,则不进行添加数据。

  • 如果生成的表达式有除0行为, 你编写的表达式生成器的行为又会怎么样呢?

    答:会提示错误 warning: division by zero [-Wdiv-by-zero]

  • 如何过滤求值过程中有除0行为的表达式?

    答:暂时不考虑了。因为后续测试中,会在除以0的情况报错,所以这里简单地处理下:如果运算符是除号/,则将后面的表达式强制改为一个非零的数字。

遇到的问题

但是计算的时候,遇到这样的计算还是会出问题:

例如负溢出:0 - 19 = 4294967277。这样的原因是由于计算结果均由uint32_t的32位无符号整型变量保存,数字4294967277的十六进制为0xFFFF FFED,加上19(0x13)正好是0x1 0000 0000超出了uint32_t的最大表示范围,所以截取其低32位即为0。这样其实并不会产生问题,产生问题的其实是负数的除法场景:

-19 / 3 = ?

在有符号整型int32_t运算的时候,计算结果为-6(0xFFFF FFFA);

如果是先将两个数转换为对应的无符号整数uint32_t,然后再进行计算,就会让除法表达式转换为4,294,967,277 / 3 = 1,431,655,759。所以需要保证计算的时候,进行有符号的计算。

解决方法:将计算表达式值的函数calc_apply()的输入、输出参数均转化为有符号整型int32_t。函数eval()中获取计算函数calc_apply()的结果时,将其解释为一个uint32_t的无符号整型变量即可。这样就不会出现计算负数除法的时候,先将负数转换为一个大的正数,然后做计算的错误场景。

还有另一个问题

make_tokens()函数,当字符为空格时,tokens[]不会记录。所以数组的长度记录nr_token也不能更新,直接略过这个空格场景。

另外一个问题是头文件的路径问题:

在A机器上的头文件路径为/home/crx/study/ics2023/nemu/src/monitor/sdb/sdb.h,所以我在A机上的C代码中,直接引用对应的头文件

#include "/home/crx/study/ics2023/nemu/src/monitor/sdb/sdb.h"

然而我在B机器上编译运行的时候,因为这个头文件并不在对应的目录中,所以会报错。

分析下头文件sdb.h固定在项目目录/src/monitor/sdb/下面,是可以固定的。而前面的/home/crx/study/ics2023/nemu/恰好就是PA0阶段设置的环境变量$NEMU_HOME

所以解决思路就是,将/home/crx/study/ics2023/nemu/src/monitor/sdb/作为头文件的索引地址,在编译的时候作为额外的头文件查找路径即可。

解决方案:在Makefile中,添加一行指定编译选项

CFLAGS += -I$(NEMU_HOME)/src/monitor/sdb

作用是告诉编译器在编译时查找头文件时,除了默认的系统目录,还要在 $(NEMU_HOME)/src/monitor/sdb 目录下查找头文件

监视点

需求分析:

  • 扩展表达式求值功能
  • 实现监视点
  • 断点

扩展表达式求值功能

表达式求值的最终结果是uint32_t类型。

主线任务需要实现:

  • 16进制数据的表示
  • 打印寄存器的值
  • 指针的解引用
  • 逻辑运算==!=&&

支线任务:

  • 修理代码:根据指针的解引用,修改负数的判断规则(难!预计两小时)
  • 修改代码:将数字字符串转换为十进制数字的方法,改为用函数sscanf()的样式(删除函数string_to_num
  • 优化:或许可以man 3 strtol来寻求更好地字符串转长数字的方法

第一个十六进制数据的表达式的相关思考。

问1:是否可以用正则表达式识别16进制?(0x12..9ABCDE

分析:0x开头,随后是任意数量的十六进制数字(从数字0-9和大小写不区分的A-F)

得出的结论是:0x[0-9A-Fa-f]+

并且十六进制的正则表达式规则TK_HEX,需要放在正则表达式TK_NUM的前面,否则正则表达式识别0x...的时候,会优先选择数字匹配0,然后遇到符号x报错。

问2:识别出此表达式为16进制表达式后,在哪处理此值?

答:在eval()函数的流程else if(p == q),判断表达式为数字(包括10进制和16进制),调用函数将其转换为uint32_t的整数数据。

需要实现一个函数:将数字字符串转换为无符号整型变量

输入:一个结构体token元素,按照结构体里面的type属性分为10进制字符串和16进制字符串

处理:按照对应的type类型,判断按照哪种进制处理字符串数据,并且调用函数将字符串转换为无符号整型变量

输出:无符号整型变量

附:在实现这个函数的时候,我希望后期维护性提升。例如后面需要支持8进制数据的时候,仅需要简单处理一下,而不用修改大量代码。

所以用到了函数指针,创建一个函数指针的数组,根据token的type确定对应的函数调用。

另一个问题:将字符串转为10进制、16进制的方法?

答:使用sscanf(),将16进制字符串转化为数字存储。函数原型如下:

int sscanf(const char *str, const char *format, ...);
  • str:要扫描的输入字符串。
  • format:格式控制字符串,用于指定如何解析输入字符串。
  • ...:一系列变量的地址,这些变量将接收解析出来的数据。

返回值:sscanf 成功返回转换的项数。如果返回值为1,表示成功从字符串中读取了一个十六进制数

第二个打印寄存器的值,判断依据是表达式以$开头。然后获取寄存器的值,以uint32_t的形式打印出来。获取寄存器的值,是ISA相关的功能,框架代码给了一个结构:

// nemu/src/isa/$ISA/reg.c
word_t isa_reg_str2val(const char *s, bool *success);

它用于返回名字为s的寄存器的值, 并设置success指示是否成功.

打印寄存器值的相关思考

  1. 寄存器的正则表达式

    答:以字符$开头,后面跟着寄存器的名称,是任意数量的字符和数字的拼装。所以正则表达式为\$[0-9a-z-A-Z]+

  2. 寄存器表达式的求值

    答:调用函数isa_reg_str2val()来获取返回名字为s的寄存器的值。s是去掉符号$后的寄存器名称字符串。

  3. 为了让代码更简约,能做点什么?

    答:寄存器表达式也是一个单独的token保存:token.type = TK_REGtoken.str = $reg_name_string.

    所以在计算寄存器表达式的时候,希望也可以用统一的函数convert_token_to_unsigned_num()来处理代表寄存器表达式的token。

    而框架给的API原型为

    word_t isa_reg_str2val(const char *s, bool *success);

    而我想实现的获取寄存器值的函数get_register_value()的输入参数仅有代表寄存器名称的s。所以需要在函数get_register_value()中,首先声明一个布尔变量,然后和变量名一起作为参数调用框架的API。

    然后根据布尔变量的结果,来确定是否返回获取的值。

  4. 怎么实现这个API?

    答:首先找到这个获取寄存器值API的位置nemu/src/isa/riscv32/reg.c。步骤是:

    • 根据寄存器名称,获取寄存器索引reg_idx_by_name()
    • 打印寄存器的值

    risv-32的体系下,cpu寄存器是一个大小为32的数组。现在已经在对应risv-32的体系(nemu/src/isa/riscv32/local-include/reg.h)下,实现了根据索引(0-31)来获取寄存器值的方法gpr(idx)nemu/src/isa/riscv32/local-include/reg.h)。

    所以我们可以实现”根据寄存器的名称获取寄存器编号“的方法,来间接获取寄存器的值。我们暂且将这个方法命名为static int reg_idx_by_name(const char *name)

第三个指针的解引用,判断依据是表达式以*开头,并且不是乘号的作用。

在表达式求值之前的expr()流程中,提前将*代表的运算类型确定好。这样就会简化求值函数eval()的处理复杂度:仅仅关注于计算,而不是处理运算符。

但是我在判断-的类型时,并没有在expr()中实现这个功能,反而是在寻找主运算符的函数locate_main_operator()eval()中通过一些trick,实现了负数的运算。

这里先按照PA手册实现解引用的判断,写完后,负数的运算再作为支线任务解决。

随后再调用函数eval()来处理对应表达式的值。

指针解引用的思考:

  1. 如何判断*是乘法还是解引用的符号?

    答:看*前一个token的类型。如果星号前面是表达式开始的标识符,那么证明*前面并没有表达式,所以此时*只有解引用的功能。反之则是乘法的作用。

    而表达式开始的标志,要么是token下标为0;要么是token的类型为左括号(

  2. 在哪里判断*的作用?

    答:在方法expr()中。eval()函数只管按照已有的表达式结构计算,不参与符号的判断。

  3. 思考:既然已经判断了*的类型为解引用,那我该怎么计算解引用表达式?(同负数)

    答:例如*0x1234 + 678这个表达式。

    正常的四则运算只能确认此表达式的主运算符为+。然后分别计算表达式1:*0x1234和表达式2:678的值。

    首先判断前缀是否为特殊运算符(指针的解引用和负数,以及后面可能需要的自增、取地址、按位取反等)。如果是,则进入特殊运算流程。

    特殊运算流程:

    • 找到第一个表达式:确认前缀运算符后,第一个表达式0x1234
    • 调用解引用函数处理表达式结果:根据前缀运算符,调用相应的函数进行处理第一个表达式运算后的值,得到特殊运算的结果deference(0x1234)

    计算解引用表达式后,再与后面的678相加得出结果。

    同理计算表达式:*(0x1234+0x12) + 56

    步骤依旧是:

    • 找到第一个表达式:(0x1234 + 0x12)
    • 然后调用解引用函数获取对应地址的值
  4. 需要实现的函数或者方法。

    • int locate_first_operator(int p, int q):从p,q下标中获取第一个运算符的位置
    • 前缀表达式函数策略:利用函数指针来实现

实现监视点

主线任务:

  • 理解gdb工具中的监视点功能,补充pa监视点的结构体参数typedef struct watchpoint {} WP;
  • 实现监视点池的管理
  • 实现监视点的功能:输入待监视的表达式,实现对应的监视功能。

支线任务:

  • 了解static关键字在定义变量时候的作用
  • 了解为什么会在定义监视点池的时候,使用关键字static,可以不使用吗,为什么?
  • 尽可能将潜在的问题转换为error,降低调试难度,所做的努力记录。
  • 写一个测试监视点功能的方法。
  • 尝试编写一个触发段错误的程序, 然后在GDB中运行它. 你发现GDB能为你提供哪些有用的信息吗?

主线任务思考:

  1. gdb工具中的监视点的信息(结构体要存储的信息)

    • Num:监视点的编号(int NO
    • Address:被监视的内存地址(uint32_t address
    • What:被监视的变量或表达式(char *expr
  2. WP* new_wp()

    功能描述:

    可以从函数init_wp_pool()看出,free_使用的是不带头节点的链表结构。那我们后面遵循的就是针对“不带头结点的”链表操作方法。

    判断free_链表是否为空,如果不为空,则返回一个空闲的监视点结构(第一个节点)。如果为空直接assert(0).

    删除free_链表的首节点。

    head链表尾部新增这个空闲的监视点。

  3. void free_wp(WP *wp)流程

    free_末尾新增节点wp

    head链表中,删除节点wp

  4. 函数trace_and_difftest()的功能

    • 检测所有监测点,判断是否其表达式的值发生了变化watchpoint_check_changes()

      • 如果发生变化:

        输出触发监视点的信息给用户(放在函数watchpoint_check_changes()中,因为遍历链表的性能开销较大);

        则设置暂停效果;

        并返回到主循环sdb_mainloop()

      • 如果没有变化,则不做任何处理。

  5. 监视点的相关功能

    • 函数void expr_watchpoint_create(char *expr):输入表达式expr,则通过``new_wp()`申请空闲监视点结构,并记录表达式。

针对思考2、3点,需要实现不带头结点链表的几个操作方法:

  • 删除链表的头结点:delete_head()
  • 链表尾部插入节点:insert_tail()
  • 删除链表中间节点:delete_wp()

针对思考点5,需要实现的一些功能:

  • 补充DATA参数:加入一个old_value,作为对应表达式expr的历史计算结果。
  • 实现函数void expr_watchpoint_create(char *expr):输入表达式expr,随后通过new_wp()申请空闲监视点结构,并在新申请的监视点中记录此表达式
  • 更新old_value
    • 在函数expr_watchpoint_create()调用记录输入的表达式expr时,将此表达式的计算结果作为old_value的初始值。
    • 如果发生了表达式结果result不等于初始值old_value,则将old_value的值替换为result

watchpoint.c需要实现的函数接口:

  • void expr_watchpoint_create(char *e)
    • 描述:输入表达式expr,若有空闲节点,并且表达式合法,则新增一个含有此表达式的监测点;
    • 逻辑:
      • 校验表达式e的合法性
      • 申请空闲监视点结构wp(通过``new_wp()`)
      • 新申请的监视点中记录此表达式
      • 将表达式的计算值赋值给old_value
    • 输入:字符串类型的表达式expr
    • bool watchpoint_check_changes()
    • 描述:检查是否有监视点的表达式计算值发生了变化
    • 逻辑:
      • 遍历所有监测点
      • 获取每一个监测点中表达式
      • 执行表达式,获取新的计算结果并保存到new_value
      • 比对监测点的旧值,若发生变化,则输出新值和旧值的对比,并将旧值替换为新值
    • 输出:返回是否监测点的表达式的计算值发生了变化

实现这两个函数后,需要新增一个watchpoint.h来声明这两个函数。

遇到的问题思考:

  1. 函数delete_wp(WP **head, WP *key_wp)中,如果key_wp是头指针,为什么不能用*head = *head->next;来删除头结点?

    答:*head->next 实际上是错误的语法,因为运算符优先级的问题。

    • -> 运算符的优先级高于 *,因此 *head->next 会被解析为 *(head->next)
    • 然而,head 是一个指向指针的指针(WP**),并不包含 next 字段,因此这种语法会导致编译错误。
  2. 在定义监视点池的时候, static在此处的含义是什么? 为什么要在此处使用它?

    答:设置静态全局变量,这样变量名wp_poolhead以及free_的可见性会被限制在本文件watchpoint.c中。如果其他文件也可能用head来表示另一个链表的头结点,用静态全局变量的做法避免了变量名冲突。

  3. 尽可能将潜在的问题转换为error,降低调试难度,所做的努力记录

    答:已经将所有指针索引之前,都加了判空处理。

  4. 如何在trace_and_difftest()中,返回到sdb_mainloop()循环中等待用户的命令?

    答:函数trace_and_difftest()nemu的状态置为NEMU_STOP后,会返回其被调用的函数execute()中。而主函数就有处理不正常状态的判断语句

    if (nemu_state.state != NEMU_RUNNING) break;

    而此函数一旦返回,则会回到函数cpu_exec()中。经过后面的流程,自然回到函数sdb_mainloop()中。

断点

主线任务:

  • 利用监视点,实现断点功能。

    w $pc == ADDR

支线任务:

  • 阅读“断点的工作原理
    • 完成后解锁:如果把断点设置在指令的非首字节(中间或末尾), 会发生什么? 你可以在GDB中尝试一下, 然后思考并解释其中的缘由.

其实本质就是实现TK_EQ的表达式计算功能。

TODO实现这个表达式的时候,我发现如果将{"!=", TK_NEQ},放在判断数字的开头,会发生不能正常判断数字的问题

rule {
const char *regex;
int token_type;
} rules[] = {
{"\\(", '('},
{"\\)", ')'},
{" +", TK_NOTYPE}, // spaces
{"\\*", '*'}, // multi
{"\\/", '/'}, // div
{"\\+", '+'}, // plus
{"\\-", '-'}, // sub
{"==", TK_EQ}, // equal
{"!=", TK_NEQ}, // not equal
{"(0x|0X)[0-9a-fA-F]+", TK_HEX}, // hexadecimal
{"\\$[0-9a-zA-Z]+", TK_REG}, // register
{"[0-9]+", TK_NUM}, // decimal
{"&&", TK_AND}, // AND
};

此时如果将其放在末尾,则能解决这个问题,这个原因我现在还不是很了解。

另一个问题是:

我该怎么提升TK_EQ的计算优先权。ok,已经解决。

现在面临的问题:

  • 为什么断点w $pc == 0x80000004的时候,输入c继续执行,并不会触发监视点?

  • 为什么w $pc会导致程序一直能够继续执行停不下来?

  • 修改负数和解引用等前缀表达式的判定问题(expr函数)

  1. 为什么断点w $pc == 0x80000004的时候,输入c继续执行,并不会触发监视点?

    答:

    难道程序在地址0x80000004没有指令要执行?

    经过单步调试发现,地址0x80000004存在指令

    0x80000004: 00 02 88 23 sb zero, 16(t0)

    所以明明$pc会指向这个地址,为什么没有触发监视点呢?

    猜测监视点的表达式计算有问题了,需要去watchpoint.c中再看看。

    在检测监视点表达式的值是否发生变化的函数watchpoint_check_changes()里,及时打印旧值和新值的结果

    printf("wp:old_value = %d, new_value = %d\n", current->data->old_value, result);

    随后编译运行nemu,单步执行,并且设置观测点w $pc == 0x80000004.

    结果是,$pc在执行到程序的末尾0x8000000c之前,居然没有更新过$pc的值。

    所以要看下单步执行时,$pc的更新问题。这个需要回到cpu-exec.c中的单步执行函数exec_once()中排查问题。

    OK,$pc应该是实时更新的cpu.pc,而不是cpu_state.halt_pc。后面这个变量很明显是终止后的pc值。

    在获取寄存器值的位置nemu/src/isa/riscv32/reg.c将所有动态的$pc值获取方法,由cpu_state.halt_pc改为cpu.pc后,此问题已解决。

  2. 为什么w $pc会导致程序一直能够继续执行停不下来?

    答:为什么设置这个监视点后,执行到程序结尾

    0x8000000c: 00 10 00 73 ebreak

    仍然停不下来?

    分析监视点设置后,会对程序的正常执行造成的影响。按照pa的指导手册来看:

    若发生了变化, 程序就因触发了监视点而暂停下来, 你需要将nemu_state.state变量设置为NEMU_STOP来达到暂停的效果. 最后输出一句话提示用户触发了监视点, 并返回到sdb_mainloop()循环中等待用户的命令.

    这里我们需要分析触发监视点w $pc后,即使pc指向的不是指令,依然能够执行的原因。

    那么思考下,正常没有设置这个触发点,程序执行到0x8000000c时候,能够停下来的原因。

    回顾RTFSC中,客户程序执行到ebreak命令后,它指示了程序的结束。下面我们看看正常执行到结束指令ebreak后,是怎么让程序停止继续执行的。

    首先是使用单步执行命令si直到程序结束,看下函数的执行流程。

    单步执行到程序正常结束的流程:

    1. nemu/src/monitor/sdb/sdb.c函数cmd_step()中,调用函数cpu_exec()

    2. 函数cpu_exec()(nemu/src/cpu/cpu-exec.c中):

      首先,如果nemu的状态为NEMU_ENDNEMU_ABORT的时候,cpu便不能再执行指令了。但是如果不是这两个(例如中断的NEMU_STOP),在单步执行指令的时候,会先将nemu的状态设置为NEMU_RUNNING

      然后调用execute()

      调用此函数后,cpu_exec()函数会再次检测nemu的状态,依旧与上一步的状态判断相同。

    3. 函数execute()

      首先执行函数exec_once(&s, cpu.pc),单步执行一条指令。

      随后调用trace_and_difftest()来检查观测点。这里我们没有设置观测点,所以直接忽略这个函数。

    4. 函数exec_once():调用函数isa_exec_once(s)来执行当前指令s

    5. 函数isa_exec_once()(文件nemu/src/isa/riscv32/inst.c):

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

      首先从下一条指令snpc指向的内存地址中,取出长度为4个字节指令的值。

      随后调用decode_exec()解析指令.

      static int decode_exec(Decode *s) {
      int rd = 0;
      word_t src1 = 0, src2 = 0, imm = 0;
      s->dnpc = s->snpc;
      // ……
      INSTPAT("0000000 00001 00000 000 00000 11100 11", ebreak , N, NEMUTRAP(s->pc, R(10))); // R(10) is $a0
      // ……
      return 0;
      }

      遇到指令ebreak,会执行NEMUTRAP(s->pc, R(10))(在include/cpu/cpu.h

      #define NEMUTRAP(thispc, code) set_nemu_state(NEMU_END, thispc, code)

      这样函数isa_exec_once()执行到含有ebreak指令的地址时,会将nemu的状态置为NEMU_END

      所以执行完毕后,一路返回到函数cpu_exec(),此时函数调用完毕execute()后,会再次检测nemu的状态。此时状态为NEMU_END,则会正常停止程序的执行。

    那我们分析下当设置检测点w $pc的同时,执行程序中断指令ebreak会发生什么:

    1. 函数isa_exec_once()执行到含有ebreak指令的地址时,会将nemu的状态置为NEMU_END

    2. 返回到函数execute(),此时调用trace_and_difftest()来检查观测点表达式值是否发生变化。

      也就是此刻,观测点w $pc的条件成立,即$pc的值发生了变化。

      此时函数trace_and_difftest()会将原本ebreak已经实现的停止运行状态NEMU_END修改为检查点成立的中断状态NEMU_STOP

    3. 因为修改后的NEMU_STOP状态不能终止程序流程,所以会一直不断执行pc指向地址处的指令,即使pc指向的地址处指令不合法。

    因为执行程序终止的指令ebreak也会修改$pc的值,所以我们需要执行完毕后打印监测点的变化。

    所以解决方案就是在函数trace_and_difftest()中,加入对异常执行状态state != NEMU_RUUNNING的判断。

    观测点值变化打印,只有在程序执行指令后,nemu状态仍为正常状态(NEMU_RUUNNING)时,才能中断程序。其他异常情况,不做处理,因为中断的优先级低于终止的优先级。

现在想实现:如果指令触发了监测点,同时输出此指令的值。类似于单步执行时候打印pc所指地址的值。

有个问题好奇:

为什么单步执行si调用exec_once()能输出此地址的指令值;但是执行c命令,同样地也是执行exec_once(),但是这时候并不会输出每次执行指令的值?

答:因为命令c执行exec_once()的时候,会传入一个大于MAX_INST_TO_PRINT的值,导致g_print_stepfalse

if (g_print_step) { IFDEF(CONFIG_ITRACE, puts(_this->logbuf)); }

这样就不会输出已经执行的指令信息。

知道这个原理以后,我们可以简单地在trace_and_difftest()中,检测触发监测点后,打印此即可。

至此,从7月23号到9月24号,用时两个月,番茄TODO的有效时长为106小时50分钟。
补一下终结的场景:

  1. 工作环境
    工作环境
  2. 番茄todo(从7月23号到9月24号)
    番茄pa1

PA1完结散花。🌸

本文作者:上山砍大树

本文链接:https://www.cnblogs.com/shangshankandashu/articles/18319133

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   上山砍大树  阅读(397)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起