banner
^_^

PA1实验记录

Scroll down

PA1 实验报告

2024.9.13

开始PA1之旅!

首先试着玩了超级玛丽,测试了按键,一切ok!

ccache:

  • 配置:在.bashrc中设置环境变量:export PATH=/user/lib/ccache:$PATH,然后重新打开终端
  • 刚开始失败了,发现问题是我在ccache后面又加了/ggc
  • 先清除编译结果, 然后重新编译并统计时间。你会发现这次编译时间反而比之前要更长一些, 这是因为除了需要开展正常的编译工作之外, ccache还需要花时间把目标文件存起来. 接下来再次清除编辑结果, 重新编译并统计时间, 你会发现第二次编译的速度有了非常明显的提升! 这说明ccache确实跳过了完全重复的编译过程, 发挥了加速的作用. 如果和多线程编译共同使用, 编译速度还能进一步加快!
  • 时间变化:58s——14s

NEMU是什么

我们可以把实现NEMU的过程看成是开发一个支付宝APP. 不同的是, 支付宝具备的是真实ATM机的功能, 是用来交易的; 而NEMU具备的是物理计算机系统的功能, 是用来执行程序的. 因此我们说, NEMU是一个用来执行其它程序的程序.

ISA是什么

  • 螺母和螺钉的例子(同规格才能匹配,使用)
  • 程序&计算机同理:如果一个程序要在特定架构的计算机上运行, 那么这个程序和计算机就必须符合同一套规范。

因此, ISA的本质就是类似这样的规范. 所以ISA的存在形式既不是硬件电路, 也不是软件代码, 而是一本规范手册

  • 选了riscv32

2024.9.14

开天辟地的篇章

什么是编程模型

编程模型是用于描述和组织计算机程序的一种抽象概念。它是指编程语言和相关工具的组合,用于表示程序的结构、行为和数据。编程模型提供了一种方式,使程序员能够实现特定的功能和逻辑,并将其转化为可执行的计算机程序。

常见的几种编程模型:
1、并发模型,2、面向对象模型,3、函数式模型,4、逻辑编程模型

最简单的计算机

  • 存储器->CPU(运行程序)->寄存器(暂时存储处理的数据)
  • PC程序计数器(指示要执行的指令的位置)-> 指令(指示CPU工作)

必做:尝试理解计算机如何计算

  • r1和r2是寄存器
  • r1用来存放加后的结果(当前和)
  • r2用来存放当前的加数(1——100逐个递增)
  • 当r2=100时已经加完,进入pc5,死循环

计算机是个状态机

  • 一部分由所有时序逻辑部件(存储器, 计数器, 寄存器) 构成
  • 另一部分则是剩余的组合逻辑部件(如加法器等)
  • 从状态机模型的视角来理解计算机的工作过程: 在每个时钟周期到来的时候, 计算机根据当前时序逻辑部件的状态, 在组合逻辑部件的作用下, 计算出并转移到下一时钟周期的新状态.

那么程序呢?

必做:从状态机视角理解程序运行

(0,x,x)->(1,0,x)->(2,0,0)->(3,0,1)->(4,1,1)->(3,1,2)->(4,3,2)->(3,3,3)->(4,6,3)->…->(3,4851,99)->(4,4950,99)->(3,4950,100)->(3,5050,100)

2024.9.16

RTFSC

开始初探框架代码了,小紧张和好奇中

只关心和当前进度相关的模块!

1
2
3
4
5
6
7
8
ics2024
├── abstract-machine # 抽象计算机
├── am-kernels # 基于抽象计算机开发的应用程序
├── fceux-am # 红白机模拟器
├── init.sh # 初始化脚本
├── Makefile # 用于工程打包提交
├── nemu # NEMU
└── README.md

目前只关注nemu子项目内容即可

NEMU主要由4模块组成:monitor, CPU, memory, 设备。

Monitor(监视器)模块

  • 是为了方便地监控客户计算机的运行状态而引入的.
  • 它除了负责与GNU/Linux进行交互(例如读入客户程序)之外, 还带有调试器的功能, 为NEMU的调试提供了方便的途径.
  • 从概念上来说, monitor并不属于一个计算机的必要组成部分, 但对NEMU来说, 它是必要的基础设施.

为了支持不同的ISA, 框架代码把NEMU分成两部分:

  • NEMU把ISA相关的代码专门放在nemu/src/isa/目录下, 并通过nemu/include/isa.h提供ISA相关API的声明. (抽象的思想)
  • 这样以后, nemu/src/isa/之外的其它代码就展示了NEMU的基本框架.

配置系统和项目构建

配置系统kconfig

  • 位于nemu/tools/kconfig, 它来源于GNU/Linux项目中的kconfig
  • 定义了一套简单的语言,可以用来编写“配置描述文件”(文件名都为Kconfig
    • 配置选项的属性(eg类型、默认值)
    • 不同配置选项之间的关系
    • 配置选项的层次关系
  • 目前只需要关注:
    • nemu/include/generated/autoconf.h, 阅读C代码时使用
    • nemu/include/config/auto.conf, 阅读Makefile时使用

项目构建和Makefile

NEMU的Makefile具备功能:

  • 与配置系统进行关联
    • 由于包含nemu/include/config/auto.conf, 与kconfig生成的变量进行关联.
    • 所以当make menuconfig更改配置选项后,Makefile也可能变化
  • 文件列表(filelist)
    通过文件列表 (filelist) 决定最终参与编译的源文件
  1. 定义变量:
    • SRCS-y:候选的源文件集合。
    • SRCS-BLACKLIST-y:不参与编译的源文件黑名单集合。
    • DIRS-y:参与编译的目录集合,该目录下的所有文件都会被加入到 SRCS-y 中。
    • DIRS-BLACKLIST-y:不参与编译的目录集合,该目录下的所有文件都会被加入到 SRCS-BLACKLIST-y 中。
  2. filelist.mk 文件:
    • 在 nemu/src 及其子目录下存在
    • 这些文件会根据 menuconfig 的配置对上述 4 个变量进行维护。
  3. Makefile 包含所有 filelist.mk 文件:
    • Makefile 会包含项目中的所有 filelist.mk 文件。
    • 对上述 4 个变量的追加定义进行汇总。
  4. 过滤源文件:
    最终会过滤出在 SRCS-y 中但不在 SRCS-BLACKLIST-y 中的源文件,作为最终参与编译的源文件集合。
  5. 与 menuconfig 配置关联:
    • 这些变量还可以与 menuconfig 的配置结果中的布尔选项进行关联。
    • 实现效果:在 menuconfig 中选中 TARGET_AM 时,nemu/src/monitor/sdb 目录下的所有文件都不会参与编译。

编译和链接

Makefile的编译规则在nemu/scripts/build.mk中定义:

1
2
3
4
5
$(OBJ_DIR)/%.o: %.c
@echo + CC $<
@mkdir -p $(dir $@)
@$(CC) $(CFLAGS) -c -o $@ $<
$(call call_fixdep, $(@:.o=.d), $@)
  • 其中关于$@$<等符号的含义:
    在 Makefile 中,$@$<自动变量,用于表示规则中的目标和依赖文件。具体含义如下:

    • $@:表示规则中的目标文件。eg, $@ 表示 $(OBJ_DIR)/%.o,即目标对象文件。
    • $<:表示规则中的第一个依赖文件。eg,$< 表示 %.c,即源文件。
  • 键入make -nB, 它会让make程序以”只输出命令但不执行”的方式强制构建目标.

  • 由输出内容反推得到

1
2
3
4
$(CC) -> gcc
$@ -> /home/user/ics2024/nemu/build/obj-riscv32-nemu-interpreter/src/utils/timer.o
$< -> src/utils/timer.c
$(CFLAGS) -> 剩下的内容
  • $(CFLAGS)的值:
1
2
3
4
5
-O2 -MMD -Wall -Werror -I/home/user/ics2024/nemu/include
-I/home/user/ics2024/nemu/src/engine/interpreter
-I/home/user/ics2024/nemu/src/isa/riscv32/include -O2
-D__GUEST_ISA__
=riscv32
- 总结: $(CFLAGS) 的值是通过在 Makefile 中逐步添加各种编译选项、包含路径和宏定义等形成的。是一个在C编译过程中使用的变量,定义了编译器的选项和标志

准备第一个客户程序

首先要用minitor把客户程序读入客户计算机中

NEMU开始运行时:

  • 调用init_monitor()函数
  • 宏:一种预处理指令,它允许程序员定义一个名称,该名称可以代表一段代码或一个值。
    • #define 宏名 替换文本
  • 在init_monitor()中使用函数调用的好处:
    • 代码可读性+代码复用+模块化+调试和维护+抽象和封装+减少代码耦合
  • 参数的处理过程
    • parse_args()中调用了一个你也许不太熟悉的函数getopt_long()
    • 框架代码通过它来对参数进行解析, 具体的行为可以查阅man 3 getopt_long.
    • getopt_long() 是一个用于解析命令行选项的函数,支持长选项和短选项。
  • 这些参数从哪来?
    • 参数是从命令行传递给程序的。
    • 在 main 函数中,通过 argc 和 argv 获取这些参数。
    • 使用 getopt_long 函数解析这些参数,并根据选项表 table 处理每个选项。

接下来

monitor会调用init_isa()函数(在nemu/src/isa/$ISA/init.c中定义), 来进行一些ISA相关的初始化工作.

  1. 将一个内置的客户程序读入到内存
    BIOS(Basic Input/Output System,基本输入输出系统)
  2. 初始化寄存器:restart()函数
    把寄存器结构体CPU_state的定义放在nemu/src/isa/$ISA/include/isa-def.h中, 并在nemu/src/cpu/cpu-exec.c中定义一个全局变量cpu. 初始化寄存器的一个重要工作就是设置cpu.pc的初值, 我们需要将它设置成刚才加载客户程序的内存位置, 这样就可以让CPU从我们约定的内存位置开始执行客户程序了. 对于mips32和riscv32, 它们的0号寄存器总是存放0, 因此我们也需要对其进行初始化.
  • 地址映射:mips32和riscv32的物理地址均从0x80000000开始. 因此对于mips32和riscv32, 其CONFIG_MBASE将会被定义成0x80000000.
  1. 在nemu/目录下编译并运行NEMU:make run

练习:解决错误信息

1
riscv32-nemu-interpreter: src/monitor/monitor.c:36: welcome: Assertion `0' failed.
  • 删除或注释掉 src/monitor/monitor.c 文件中 welcome 函数中的 assert(0); 这一行代码,并保存修改后的文件
  • 回到nemu/,重新编译NEMUmake
  • make run运行NEMU

运行第一个客户程序

  1. 在cmd_c()函数中, 调用cpu_exec()的时候传入了参数-1, 你知道这是什么意思吗?
    • 传入的参数 -1 通常表示让 CPU 执行无限多的指令,直到遇到某个停止条件(如断点或程序结束)。这是在模拟器中常见的一种用法,用于启动 CPU 并让其持续运行。
  2. NEMU将不断执行指令, 直到遇到以下情况之一, 才会退出指令执行的循环:
    • 达到要求的循环次数.
    • 客户程序执行了nemu_trap指令。 riscv32的手册中, NEMU选择了ebreak指令来充当nemu_trap.
  3. 为了表示客户程序是否成功结束, nemu_trap指令还会接收一个表示结束状态的参数,主要包括:
    • HIT GOOD TRAP - 客户程序正确地结束执行
    • HIT BAD TRAP - 客户程序错误地结束执行
    • ABORT - 客户程序意外终止, 并未结束执行
  4. 当你看到NEMU输出类似以下的内容时(不同ISA的pc输出值会有所不同):
1
nemu: HIT GOOD TRAP at pc = 0x8000000c

说明客户程序已经成功地结束运行.
NEMU会在cpu_exec()函数的最后打印执行的指令数目花费的时间, 并计算出指令执行的频率.

  1. 思考:谁来指示程序的结束?

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

    • 当 main() 函数返回时,实际上会调用 exit() 函数,exit() 函数会执行一些清理工作并通知操作系统程序已经完成。操作系统会进行一些清理工作,例如释放进程占用的资源,并将返回值传递给父进程(通常是 shell)。
    • (证明/反驳) 通过查看标准库实现、使用调试器和编写自定义 exit() 函数,可以更深入地理解程序退出的机制。
    • 返回值传递——>调用exit()函数——>操作系统接管
  2. 代码中一些值得注意的地方

  • 三个对调试有用的宏(在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中定义)来访问模拟的内存.

必做题:优美地退出

如果在运行NEMU之后直接键入q退出, 你会发现终端输出了一些错误信息. 请分析这个错误信息是什么原因造成的, 然后尝试在NEMU中修复它.

1
2
(nemu) q
make: *** [/home/xiaoyao/ics2024/nemu/scripts/native.mk:38:run] 错误 1
  • 可能原因
  1. 退出状态未正确设置:

    在处理 q 命令时,可能没有正确设置退出状态,导致程序没有正常退出。

  2. 资源未正确释放:

    在退出时,可能有一些资源(如内存、文件句柄等)未正确释放,导致程序异常退出。

  3. 退出处理函数中的错误:

    在处理 q 命令的函数中,可能存在一些未处理的错误或异常。

  • 解决(1)
  1. 先检查cmd-q函数:
    • 在哪?用grep -rnw '/home/xiaoyao/ics2024/nemu/' -e 'cmd_q'找到
  2. 发现为
1
2
3
static int cmd_q(char *args) {
return -1;
}
  1. 根据经验猜测,改为
1
2
3
static int cmd_q(char *args) {
return 0;
}

尝试,变成了按q无法退出!!

  1. 分析,原本是返回-1
    在主循环sdb_mainloop()中,处理退出部分为
1
2
3
4
5
6
7
8
9
10
11
12
#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); }
}

故发现当输入q,cmd_q函数被调用,如果返回0,则不会结束,循环继续执行;如果返回小于0的数(比如-1),循环会退出,从而结束程序。

所以不是return -1的错!

解决(2)

  • 决定用gdb调试
  • find . -type f -executable -exec ls -l {} \;找了一下可执行文件,gdb它是gdb ./build/riscv32-nemu-interpreter
  1. gdb ./build/riscv32-nemu-interpreter
  2. 设置断点(gdb) break cmd_q
  3. 运行程序: 启动NEMU并运行到断点处。(gdb) run
  4. 在NEMU界面,输入q,触发cmd_q函数
1
2
3
Breakpoint 1, cmd_q (args=0x0) at src/monitor/sdb/sdb.c:51
warning: Source file is more recent than executable.
51 static int cmd_q(char *args) {
  1. 查看返回值:在cmd_q函数里单步执行,并查看返回值
1
2
3
4
(gdb) step  # 进入cmd_q函数
(gdb) next # 执行到返回语句
53 return -1;

  1. (gdb) finish # 执行完cmd_q函数并返回调用点
1
2
3
4
Run till exit from #0  cmd_q (args=0x0) at src/monitor/sdb/sdb.c:53
0x0000555555557f41 in sdb_mainloop () at src/monitor/sdb/sdb.c:129
129 if (cmd_table[i].handler(args) < 0) { return; }
Value returned is $1 = -1

发现调用处是sdb_mainloop () at src/monitor/sdb/sdb.c:129

  1. 设置断点: 在 cmd_q 函数和 sdb_mainloop 函数的第 129 行设置断点。
    (gdb) break src/monitor/sdb/sdb.c:129
    再次run和q查看
  2. 查看调用栈
1
2
3
4
5
(gdb) backtrace
#0 sdb_mainloop () at src/monitor/sdb/sdb.c:129
#1 0x00005555555565c1 in engine_start () at src/engine/interpreter/init.c:25
#2 0x00005555555565a0 in main (argc=<optimized out>, argv=<optimized out>)
at src/nemu-main.c:32

发现在sdb_mainloop函数退出后,控制转移到main函数
9. 对main.c:32断点,再run,输入q,然后几个next后发现is_exit_status_bad()

1
2
3
(gdb) next
main (argc=<optimized out>, argv=<optimized out>) at src/nemu-main.c:34
34 return is_exit_status_bad();
  1. 查看
1
2
3
4
5
6
7
8
9
10
11
(gdb) list is_exit_status_bad
15
16 #include <utils.h>
17
18 NEMUState nemu_state = { .state = NEMU_STOP };
19
20 int is_exit_status_bad() {
21 int good = (nemu_state.state == NEMU_END && nemu_state.halt_ret == 0) ||
22 (nemu_state.state == NEMU_QUIT);
23 return !good;
24 }

!good应该为0才能正确退出,根据报错知道这里返回的是1,即good为0,说明无条件为真;而cmd_q并没有改变nemu_state.state,为STOP,所以加一行在cmd_q,

1
nemu_state.state = NEMU_QUIT;

现在就能正常退出啦!!

多认识GDB的一些命令和操作

比如:

  • 单步执行进入你感兴趣的函数(gdb) step
  • 单步执行跳过你不感兴趣的函数(例如库函数)(gdb) next
  • 运行到函数末尾(gdb) finish
  • 打印变量或寄存器的值
1
2
(gdb) print variable_name
(gdb) info registers
  • 扫描内存(gdb) x /Nfu address
  • 查看调用栈(gdb) backtrace
  • 设置断点(gdb) break
1
2
(gdb) break filename:line_number
(gdb) break function_name
  • 设置监视点(gdb) watch variable_name

2024.9.22

基础设施:简易调试器

  • 基础设施-提高项目开发的效率
    • 在PA中, 基础设施是指支撑项目开发的各种工具和手段.
    • 比如:我们的框架代码已经提供了Makefile来对NEMU进行一键编译. 假设我们并没有提供一键编译的功能, 你需要通过手动键入gcc命令的方式来编译源文件。
  • 需要在monitor中实现一个具有如下功能(表格中)的简易调试器 (相关部分的代码在nemu/src/monitor/sdb/目录下)

哇要开始写代码了,紧张中

解析命令

  1. 从键盘上读入命令:为了让简易调试器易于使用, NEMU通过readline库与用户交互, 使用readline()函数从键盘上读入命令.
  2. 解析该命令,然后执行相关的操作:
  • 目的:识别命令中的参数
  • 通过一系列的字符串处理函数来完成
    • eg.strtok()
    • strtok的工作原理是通过指定的分割符将字符串分割开来,并返回每个标记的指针
    • cmd_help()函数中有使用strtok()的例子
  • 事实上, 字符串处理函数有很多, 键入以下内容:
1
man 3 str<TAB><TAB>

其中代表键盘上的TAB键;看到很多以str开头的函数!

  • 推荐的字符串处理函数:sscanf()
    • 功能和scanf()很类似, 但 sscanf 从字符串而不是标准输入读取数据。它的定义在 <stdio.h> 头文件中。
    • 函数原型:
    1
    int sscanf(const char *str, const char *format, ...);
    • 参数
      • str:要读取的字符串。
      • format:格式控制字符串,指定如何解析输入。
      • ...:指向存储读取值的变量的指针。
    • 返回值:成功读取并赋值的项数。如果没有匹配项或发生读取错误,则返回 EOF。

要实现:

1. 帮助
2. 继续运行
3. 退出

  1. 单步执行 si [N] si 10 让程序单步执行N条指令后暂停执行,
    当N没有给出时, 缺省为1

  2. 打印程序状态 info SUBCMD info r
    info w 打印寄存器状态
    打印监视点信息

  3. 扫描内存(2) x N EXPR x 10 $esp 求出表达式EXPR的值, 将结果作为起始内存
    地址, 以十六进制形式输出连续的N个4字节

  4. 表达式求值 p EXPR p $eax + 1 求出表达式EXPR的值, EXPR支持的
    运算请见调试中的表达式求值小节

  5. 设置监视点 w EXPR w *0x2000 当表达式EXPR的值发生变化时, 暂停程序执行

  6. 删除监视点 d N d 2 删除序号为N的监视点

单步执行

单步执行的功能十分简单, 而且框架代码中已经给出了模拟CPU执行方式的函数, 你只要使用相应的参数去调用它就可以了. 如果你仍然不知道要怎么做, RTFSC.

(用了下sscanf)

打印寄存器

  • info SUBCMD
  • 例子:
    • info r打印寄存器状态
  • 寄存器结构是ISA相关的
  • 去看框架代码准备的api:
1
2
// nemu/src/isa/$ISA/reg.c
void isa_reg_display(void);
  • 执行info r之后, 就调用isa_reg_display(), 在里面直接通过printf()输出所有寄存器的值即可.
  • 打印完之后还打印了特殊寄存器——程序计数器(PC)

扫描内存

  1. 对命令进行解析
  2. 求出表达式的值(下一节来实现,目前先实现一个简单版本!)
    • 规定表达式EXPR中只能是一个十六进制数, 例如
  • 如何访问计算机的内存数据?
    -通过虚拟地址读取函数 vaddr_read 来实现。这个函数可以读取指定地址的内存数据,并返回相应的值。
  1. 解析出待扫描内存的起始地址之后, 就可以使用循环将指定长度的内存数据通过十六进制打印出来.
  2. 检验:打印0x80000000的内存, 你应该会看到程序的代码, 和内置客户程序的内容进行对比。

debug记录

(特别好笑)

  • 原代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//单步执行
static int cmd_si(char *args) {
char *arg = strtok(NULL, " ");
int step;
if(arg==NULL){
step=1;
}else{
step=sscanf(arg,"%d",&step);
Log("step=%d",step);
}

cpu_exec(step);
return 0;
}
  • 检验:
    si 5,但step=1,原因是这一行
1
step=sscanf(arg,"%d",&step);
  • 纠错:
    因为 sscanf 函数的使用不当。sscanf 函数返回的是成功读取的项目数而不是读取的值。因此,当输入 si 5 时,sscanf(arg, "%d", &step) 返回的是 1(表示成功读取了一个整数),而不是 5。
  • 改正:
1
sscanf(arg,"%d",&step);

表达式求值

数学表达式求值

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

词法分析(识别出表达式中的单元)

  • 单元:token
  • "0x80100000+ ($a0 +5)*4 - *( $t1 + 8) + number"
    它包含更多的功能, 例如十六进制整数(0x80100000), 小括号, 访问寄存器($a0), 指针解引用(第二个*), 访问变量(number). 事实上, 这种复杂的表达式在调试过程中经常用到, 而且你需要在空格数目不固定(0个或多个)的情况下仍然能正确识别出其中的token.
    • 正则表达式
  • sdb.c中调用了init_regex(),来自./expr.c
  1. 定义更多的 token 类型:例如数字、操作符、括号等。
  2. 编写正则表达式规则:为每种 token 类型编写相应的正则表达式。
  3. 记录 token 信息:在成功识别出 token 后,将其信息记录到 tokens 数组中。

调试检查

  1. 使用 assert() 设置检查点:确保关键变量和指针的有效性。
  2. 使用 printf() 查看程序执行情况:检查代码的可达性和变量的值。
  3. 使用 GDB 观察程序的状态和行为:设置断点、打印变量、监视点和函数调用栈。
  • 我的方法是在sdb.c里添加命令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static int cmd_expr(char *args) {
if (args == NULL) {
printf("Usage: expr <expression>\n");
return 0;
}

bool success;
word_t result = expr(args, &success);
if (success) {
printf("Result: %u\n", result);
} else {
printf("Expression evaluation failed.\n");
}

return 0;
}

然后make run,再expr 1+2-3之类的,发现有Log,说明成功了。

assert(0)的作用

  1. 预防性的错误检查
  • assert是判断括号内的内容是否为真,为假就报错。
    • 0为假,所以assert(0)永远都报错
  • 在认为不可能执行到的情况下加一句assert(0),如果运行到此,则代码逻辑或条件就可能有问题。
  1. 没写完,放个assert(0),调试运行到此时报错中断,好知道成员函数还没写完。
  2. 设置断言
1
2
int x = SomeFunc(y);
ASSERT(x>=0);//如果x为负,则断言失败
  • 可将断言用于
    • 捕捉逻辑错误:在程序逻辑必须为真的条件上设置断言,无影响除非错;
    • 检查操作结果
    • 测试错误类型

递归求值

解决方案

  • BNF定义
  • 长表达式是由短表达式构成的
  • 先对短表达式求值,再对长表达式求值(分治法)

如何在一个token表达式中寻找主运算符:

  • 非运算符的token不是主运算符.
  • 出现在一对括号中的token不是主运算符. 注意到这里不会出现有括号包围整个表达式的情况, 因为这种情况已经在check_parentheses()相应的if块中被处理了.
  • 主运算符的优先级在表达式中是最低的. 这是因为主运算符是最后一步才进行的运算符.
  • 当有多个运算符的优先级都是最低时, 根据结合性, 最后被结合的运算符才是主运算符. 一个例子是1 + 2 + 3, 它的主运算符应该是右边的+.

注意

  • 需要注意的是, 上述框架中并没有进行错误处理, 在求值过程中发现表达式不合法的时候, 应该给上层函数返回一个表示出错的标识, 告诉上层函数”求值的结果是无效的”.
  • 为了方便统一, 我们认为所有结果都是uint32_t类型.

想法心得

  • 可能出错的地方用了assert来处理,退出并输出错误信息
  • 把加减法和乘除法的优先级搞反了,同时存在时,加减法为主
  • 除法,除数为0要注意!

实现带有负数的算术表达式的求值

在上述实现中, 我们并没有考虑负数的问题, 例如

1
2
"1 + -1"
"--1" /* 我们不实现自减运算, 这里应该解释成 -(-1) = 1 */
  • 它们会被判定为不合法的表达式. 为了实现负数的功能, 你需要考虑两个问题:
    • 负号和减号都是-, 如何区分它们?
    • 负号是个单目运算符, 分裂的时候需要注意什么?

调试

  • 4294967295(-1)
    因为现在还是无符号数阶段,所以计算结果不会小于0,先不考虑计算结果小于0了

9.24

如何测试你的代码——随机测试

  • 生成表达式的框架
1
2
3
4
5
6
7
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;
}
}
- ``uint32_t choose(uint32_t n)``:生成一个小于0的随机数
  • 生成表达式的结果:
    • 进行的都是无符号运算
    • 数据宽度都是32bit
    • 溢出后不处理
  • 把这些表达式塞到如下C程序的源文件里面:
1
2
3
4
5
6
#include <stdio.h>
int main() {
unsigned result = ???; // 把???替换成表达式
printf("%u", result);
return 0;
}
  • 然后用gcc编译它并执行, 让它输出表达式的结果, 这不就是我们想要的”计算器”吗?
    • 框架代码(在nemu/tools/gen-expr/gen-expr.c中).
    • 你需要实现其中的void gen_rand_expr()函数, 将随机生成的表达式输出到缓冲区buf中. main函数中的代码会调用你实现的gen_rand_expr(), 然后把buf中的随机表达式放入上述C程序的代码中.
    • 剩下的事情就是编译运行这个C程序了, 代码中使用了system()和popen()等库函数来实现这一功能.
    • 最后, 框架代码将这个C程序的打印结果和之前随机生成的表达式一同输出, 这样就生成了一组测试用例.

实现表达式生成器

生成随机表达式——gen_rand_expr()函数

将表达式插入到 C 程序模板中

使用 sprintf 将生成的表达式插入到 C 程序模板中。

编译和运行 C 程序

使用 system() 函数编译生成的 C 程序,并使用 popen() 函数运行该程序以获取计算结果。

输出结果

将计算结果和生成的表达式一起输出。

  • 表达式生成器如何获得C程序的打印结果?
    • 生成随机表达式:gen_rand_expr(0) 函数生成随机表达式,并将其存储在缓冲区 buf 中。
    • 将表达式插入到 C 程序模板中:使用 sprintf(code_buf, code_format, buf) 将生成的表达式插入到 C 程序模板中,并将结果存储在 code_buf 中。
    • 写入临时文件:将生成的 C 程序写入到临时文件 /tmp/.code.c 中。
    • 编译生成的 C 程序:使用 system(“gcc /tmp/.code.c -o /tmp/.expr”) 命令编译生成的 C 程序。
    • 运行生成的程序并获取结果:使用 popen(“/tmp/.expr”, “r”) 运行生成的程序,并通过 fscanf(fp, “%u”, &result) 从程序的输出中读取计算结果。

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

  • 采用了:若’/‘下一个是0,则删掉重新生成
  • 但是还是会有warning:当被除的式子值为0时。怎么办?
1
2
3
/tmp/.code.c:2:46: warning: division by zero [-Wdiv-by-zero]
2 | int main() { unsigned result = (53/ (88+(32/ (12/ 47 )/71 )) -(10*17 + 86/ 8 +(31/ 82 ) ) )/46; printf("%u", result); return 0; }
| ^
  • 为什么要用无符号整数:
    • 避免溢出问题:无符号整数在溢出时会回绕到零,而有符号整数在溢出时可能会导致未定义行为。使用无符号整数可以避免这种未定义行为。
    • 一致性:无符号整数的行为在所有平台上都是一致的,而有符号整数的行为可能会因平台而异。
    • 简化处理:无符号整数的处理相对简单,不需要考虑负数的情况。
  • 除0的确切行为:
    如果生成的表达式有除0行为, 你编写的表达式生成器的行为又会怎么样呢?
  • 过滤除0行为的表达式:
    乍看之下这个问题不好解决, 因为框架代码只负责生成表达式, 而检测除0行为至少要对表达式进行求值. 结合前两个蓝框题的回答(前提是你对它们的理解都足够深入了), 你就会找到解决方案了, 而且解决方案不唯一喔!

改造NEMU的main()函数

  • 读取输入文件中的测试表达式。
  • 调用 expr() 函数对表达式进行求值。
  • 与预期结果进行比较。
  • 修改 tokens 数组的大小以容纳长表达式。
    • #define MAX_TOKENS 65536
    • expr.c里面
      • static Token tokens[65536] attribute((used)) = {};
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
//读取input文件中的测试表达式
FILE *input_fp = fopen("/home/xiaoyao/ics2024/nemu/tools/gen-expr/input", "r");
if(input_fp == NULL) {
printf("No input file found\n");
return 0;
}
char line_buf[MAX_TOKENS + 128];

while (fgets(line_buf, sizeof(line_buf), input_fp) != NULL) {
// 去掉换行符
size_t len = strlen(line_buf);
if (len > 0 && line_buf[len - 1] == '\n') {
line_buf[len - 1] = '\0';
}

// 找到数字后的第一个空格
char *space_pos = strchr(line_buf, ' ');
if (space_pos == NULL) {
fprintf(stderr, "Invalid input format: %s\n", line_buf);
continue;
}

// 分割结果和表达式
*space_pos = '\0';
char *expr_str = space_pos + 1;

// 解析表达式的预期结果
uint32_t expected_result = strtoul(line_buf, NULL, 10);

// 调试信息
printf("Parsed expression: %s\n", expr_str);

// 调用expr函数计算表达式的值
bool success = true;
word_t ans = expr(expr_str, &success);

// 与预期结果比较
if (success) {
if (ans == expected_result) {
printf("PASS: %s = %u\n", expr_str, ans);
} else {
printf("FAIL: %s = %u (expected %u)\n", expr_str, ans, expected_result);
}
} else {
printf("ERROR: Failed to evaluate expression: %s\n", expr_str);
}
}

fclose(input_fp);

注意:

  • 先测试,再启动engine,否则就无法测试,直接进入交互模式啦
  • 分割是按照第一个数字后的空格
  • token数组大小,其实是改expr.c处

1.2结束!

监视点

(9.30)

  • 监视点的功能是监视一个表达式的值何时发生变化. 如果你从来没有使用过监视点, 请在GDB中体验一下它的作用.

扩展表达式求值的功能

  • 我们用BNF来说明需要扩展哪些功能:
1
2
3
4
5
6
7
8
9
10
11
12
<expr> ::= <decimal-number>
| <hexadecimal-number> # 以"0x"开头
| <reg_name> # 以"$"开头
| "(" <expr> ")"
| <expr> "+" <expr>
| <expr> "-" <expr>
| <expr> "*" <expr>
| <expr> "/" <expr>
| <expr> "==" <expr>
| <expr> "!=" <expr>
| <expr> "&&" <expr>
| "*" <expr> # 指针解引用

它们的功能和C语言中运算符的功能是一致的, 包括优先级和结合性, 如有疑问, 请查阅相关资料.

  • 关于获取寄存器的值, 这显然是一个ISA相关的功能. 框架代码已经准备了如下的API:
1
2
// nemu/src/isa/$ISA/reg.c
word_t isa_reg_str2val(const char *s, bool *success);

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

  • 注意:指针解引用的识别
    • 区分乘法和指针解引用*
    • 实际上, 我们只要看*前一个token的类型, 我们就可以决定这个*是乘法还是指针解引用了
    • 思考certain type
1
2
3
4
5
6
7
8
9
10
11
12
13
14
    if (!make_token(e)) {
*success = false;
return 0;
}

/* TODO: Implement code to evaluate the expression. */

for (i = 0; i < nr_token; i ++) {
if (tokens[i].type == '*' && (i == 0 || tokens[i - 1].type == certain type) ) {
tokens[i].type = DEREF;
}
}

return eval(?, ?);

ps:此框架也可以处理负数

额外说明:

  • 所有结果都是uint32_t类型.
  • 指针也没有类型, 进行指针解引用的时候, 我们总是从客户计算机的内存中读出一个uint32_t类型的整数.

必做:扩展表达式求值的功能

  • 要做:
    1. 获取寄存器的值
    2. ==、!=、&&、<=
    3. 十六进制常数
    4. 指针解引用
  1. 获取寄存器的值
1
2
// nemu/src/isa/$ISA/reg.c
word_t isa_reg_str2val(const char *s, bool *success);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
word_t isa_reg_str2val(const char *s, bool *success) {
//遍历所有寄存器
for (int i=0;i<32;i++){
if(strcmp(s,regs[i])==0){
*success=true;
return cpu.gpr[i];
}
}
//检查是否是PC
if (strcmp(s,"pc")==0){
*success=true;
return cpu.pc;
}
//没有找到
*success=false;
return 0;
}
  1. ==,!=,&&
    1. 扩展正则表达式规则
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    {" +", TK_NOTYPE},    // spaces
    {"\\+", '+'}, // plus
    {"==", TK_EQ}, // equal
    {"!=", TK_NEG}, // not equal
    {"-", '-'}, // minus
    {"\\*", '*'}, // multiply
    {"/", '/'}, // divide
    {"\\(", '('}, // left parenthesis
    {"\\)", ')'}, // right parenthesis
    {"[0-9]+", TK_NUM}, // number
    {"0[xX][0-9a-fA-F]+", TK_HEX}, // hexadecimal number
    {"\\$[a-zA-Z0-9]+", TK_REG}, // register name
    {"&&", TK_AND} // and
    1. 改优先级
    2. 改计算处eval
  2. 十六进制常数(2中已经解决)
  3. 指针解引用
    1. 在eval中
    1
    2
    3
    4
    if (tokens[op].type == TK_DEREF) {
    word_t val = eval(op + 1, q);
    return *(word_t *)val;
    }
    1. expr修改
    2. 发现不对,报错
    1
    2
    3
    4
    5
    6
    src/monitor/sdb/expr.c: In function ‘eval’:
    src/monitor/sdb/expr.c:260:16: error: cast to pointer from integer of different size [-Werror=int-to-pointer-cast]
    260 | return *((word_t *)val);
    | ^
    cc1: all warnings being treated as errors
    make: *** [/home/xiaoyao/ics2024/nemu/scripts/build.mk:34:/home/xiaoyao/ics2024/nemu/build/obj-riscv32-nemu-interpreter/src/monitor/sdb/expr.o] 错误 1

    类型不匹配,改成了用eturn paddr_read((paddr_t)val, sizeof(word_t));

  • make run时发现计算突然出错了,上次还是没完全弄好
    • 原来是这次添加新的优先级时搞错了

实现监视点

  • 最好使用链表将监视点的信息组织起来.
  • 框架代码中已经定义好了监视点的结构体(在nemu/src/monitor/sdb/watchpoint.c中):
1
2
3
4
5
6
7
typedef struct watchpoint {
int NO;//表示监视点的序号
struct watchpoint *next;

/* TODO: Add more members if necessary */

} WP;
  • 要做:根据对监视点工作原理的理解在结构体中增加必要的成员.
  • “池”:管理监视点结构体
1
2
static WP wp_pool[NR_WP] = {};
static WP *head = NULL, *free_ = NULL;
  • 代码中定义了监视点结构的池wp_pool, 还有两个链表headfree_
    • 其中head用于组织使用中的监视点结构,
    • free_用于组织空闲的监视点结构,
    • init_wp_pool()函数会对两个链表进行初始化.

必做:实现监视点的管理

  • 需要编写以下两个函数(你可以根据你的需要修改函数的参数和返回值):
1
2
WP* new_wp();
void free_wp(WP *wp);
  1. 其中new_wp()从free_链表中返回一个空闲的监视点结构, free_wp()wp归还到free_链表中, 这两个函数会作为监视点池的接口被其它函数调用.
  2. 需要注意的是, 调用new_wp()时可能会出现没有空闲监视点结构的情况, 为了简单起见, 此时可以通过assert(0)马上终止程序. 框架代码中定义了32个监视点结构, 一般情况下应该足够使用, 如果你需要更多的监视点结构, 你可以修改NR_WP宏的值.
  3. 这两个函数里面都需要执行一些链表插入, 删除的操作, 对链表操作不熟悉的同学来说, 这可以作为一次链表的练习.

过程

  1. 修改watchpoint结构体

  2. 实现new_wp函数

    • new_wp 函数从 free_ 链表中返回一个空闲的监视点结构,并将其从 free_ 链表中移除,添加到 head 链表中。
    1. if (free_ == NULL) { assert(0 && "No free watchpoints available"); } 检查是否有空闲的监视点,如果没有则终止程序。
    2. WP *wp = free_; 从空闲链表中取出第一个监视点。
    3. free_ = free_->next; 更新空闲链表头指针指向下一个空闲监视点。
    4. wp->next = head;分配的监视点的next指针指向当前使用中的链表头
    5. head = wp; 更新使用中的链表头指针,指向新分配的监视点。
    6. return wp; 返回新分配的监视点。
  3. 实现free_up函数:

    • free_wp 函数将一个使用中的监视点head链表中移除,并将其添加到空闲链表free_中。
    1. if (head == NULL || wp == NULL) { assert(0 && "No watchpoints busy"); } 检查是否有使用中的监视点,如果没有则终止程序。
    2. if (wp == head) { head = head->next; } 如果要释放的监视点是使用中的链表头,则更新链表头指针
    1
    2
    3
    4
    5
    6
    7
    8
    9
    else { 
    WP *prev = head;
    while (prev->next != NULL && prev->next != wp) {
    prev = prev->next;
    }
    if (prev->next == wp) {
    prev->next = wp->next;
    }
    }

    否则,遍历使用中的链表找到要释放的监视点,并将其从链表中移除
    4. wp->next = free_; free_ = wp; 将释放的监视点添加到空闲链表头

温故而知新

  • 框架代码中定义wp_pool等变量的时候使用了关键字static, static在此处的含义是什么? 为什么要在此处使用它?
  • ——静态全局变量,作用域仅限于当前文件
  • static 关键字的含义
    1. 文件作用域:当 static 关键字用于全局变量或函数时,它会将这些变量或函数的作用域限制在定义它们的源文件中。
    2. 静态存储期:static 变量具有静态存储期,这意味着它们在程序的整个生命周期内都存在,并且在程序启动时初始化。
  • 为什么要在此处使用 static
    • 在 watchpoint.c 文件中使用 static 关键字有以下几个原因:
    1. 封装:将 wp_pool、head 和 free_ 变量的作用域限制在 watchpoint.c 文件中,可以防止它们被其他源文件意外访问或修改。
    2. 避免命名冲突:使用 static 关键字可以避免与其他源文件中定义的同名变量或函数发生命名冲突。

必做:实现监视点

由于监视点的功能需要在cpu_exec()的每次循环中都进行检查, 这会对NEMU的性能带来较为明显的开销. 我们可以把监视点的检查放在trace_and_difftest()中, 并用一个新的宏 CONFIG_WATCHPOINT把检查监视点的代码包起来; 然后在nemu/Kconfig中为监视点添加一个开关选项, 最后通过menuconfig打开这个选项, 从而激活监视点的功能. 当你不需要使用监视点时, 可以在menuconfig中关闭这个开关选项来提高NEMU的性能.

要实现的功能:

  1. 当用户给出一个待监视表达式时
    • 通过new_wp()申请一个空闲的监视点结构, 并将表达式记录下来.
    • 然后在trace_and_difftest()函数(在nemu/src/cpu/cpu-exec.c中定义)的最后扫描所有的监视点, 每当cpu_exec()的循环执行完一条指令, 都会调用一次trace_and_difftest()函数.
    • 在扫描监视点的过程中, 你需要对监视点的相应表达式进行求值(你之前已经实现表达式求值的功能了), 并比较它们的值有没有发生变化; 若发生了变化, 程序就因触发了监视点而暂停下来, 你需要将nemu_state.state变量设置为NEMU_STOP来达到暂停的效果.
    • 最后输出一句话提示用户触发了监视点, 并返回sdb_mainloop()循环中等待用户的命令.
  2. 使用info w命令来打印使用中的监视点信息, 至于要打印什么, 你可以参考GDB中info watchpoints的运行结果.
  3. 使用d命令删除监视点, 你只需要释放相应的监视点结构即可.

过程

  1. 设置监视点
    • watchpoint.c
    • 调用new_wp函数
  2. 实现d命令删除监视点
    • watchpoint.c
    • 调用free_wp函数
    • watchpoint.c中添加cmd_d命令
    • 并将cmd_d添加到cmd_table中:
  3. 实现info w命令打印监视点信息
    • 在```watchpoint.c中添加info_watchpoints``
    • cmd_info中调用info_watchpoints
  4. cpu-exec.c/添加检查监视点的代码:
    • watchpoint.c添加check_watchpoints函数(注意nemu_state.state=NEMU_STOP)
    • cpu-exec.c/trace_and_difftest添加IFDEF(CONFIG_WATCHPOINT, check_watchpoints());
  5. nemu/Kconfig中添加监视点开关选项
    1
    2
    3
    4
    5
    6
    config WATCHPOINT
    bool "Enable watchpoint"
    default y
    help
    Enable watchpoint functionality. This will allow you to set watchpoints
    and monitor changes to expressions during execution.

调试

  1. 第一个:
  • 我发现只剩一个监视点时,删除就会产生
1
2
3
(nemu) d 0
Deleting watchpoint 0
riscv32-nemu-interpreter: src/monitor/sdb/watchpoint.c:58: free_wp: Assertion `0 && "No watchpoints busy"' failed.
  • 多个监视点时,删除就可以正常进行
    • 问题可能出在 free_wp 函数在处理链表时没有正确处理只有一个监视点的情况。我们需要确保在删除最后一个监视点时,链表头部和尾部都被正确更新。
  • 解决:当删除最后一个监视点时,head 变为 NULL。把free_wp的“检查head是否为NULL”从和“wp是否为NULL”一样改为如下:
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
void free_wp(WP *wp) { // 把wp放回free_链表
if (wp == NULL) {
assert(0 && "No watchpoints busy");
}
if (head == NULL) {
printf("Freeing watchpoint %d\n", wp->NO);
wp->next = free_;
free_ = wp;
return;
}
if (wp == head) {
head = head->next;
} else {
WP *prev = head;
while (prev->next != NULL && prev->next != wp) {
prev = prev->next;
}
if (prev->next == wp) {
prev->next = wp->next;
}
}
printf("Freeing watchpoint %d\n", wp->NO);
wp->next = free_;
free_ = wp;
}
  1. 无法监视$pc、$0、ra等寄存器
1
2
3
(nemu) w $pc
riscv32-nemu-interpreter: src/monitor/sdb/expr.c:236: eval: Assertion `0&&"Invalid register name"' failed.
make: *** [/home/xiaoyao/ics2024/nemu/scripts/native.mk:38: run] 已中止
  • 正则表达式出错
    • 原本:{“\$[a-zA-Z0-9]+”, TK_REG}, // register name
    • 改后:{“\$?[a-zA-Z0-9]+”, TK_REG}, // register name(加了个?表示不一定有$)
  1. 没有正确输出old value,初始值设置出错
1
2
3
4
5
Executed instruction at pc = 0x80000000, next pc = 0x80000004
0x80000000: 00 00 02 97 auipc t0, 0
Watchpoint 0 triggered: $pc
Old value = 4(应该是2147483648)
New value = 2147483652
  • 改正:
1
2
3
4
5
6
7
8
9
10
11
12
void set_watchpoint(const char *exprression,word_t value){//设置监视点
WP *wp=new_wp();
strncpy(wp->expr,exprression,sizeof(wp->expr)-1);
wp->expr[sizeof(wp->expr)-1]='\0';

bool success=false;
wp->value=expr((char *)exprression,&success);
assert(success);

// wp->value=value;
printf("Watchpoint %d: %s\n", wp->NO, wp->expr);
}

调试工具与原理

10.7

  • 可能会碰到段错误:
  • 一些软件工程相关的概念:
    • Fault: 实现错误的代码, 例如if (p = NULL)
    • Error: 程序执行时不符合预期的状态, 例如p被错误地赋值成NULL
    • Failure: 能直接观测到的错误, 例如程序触发了段错误
  • 调试:从failure一步一步回溯寻找fault的过程
  • 调试之所以不容易:
    • fault不一定马上触发error
    • 触发了error也不一定马上转变成可观测的failure
    • error会像滚雪球一般越积越多, 当我们观测到failure的时候, 其实已经距离fault非常遥远了
  • 相应的策略:
    • 尽可能把fault转变成error. 这其实就是测试做的事情, 所以我们在上一节中加入了表达式生成器的内容, 来帮助大家进行测试, 后面的实验内容也会提供丰富的测试用例. 但并不是有了测试用例就能把所有fault都转变成error了, 因为这取决于测试的覆盖度. 要设计出一套全覆盖的测试并不是一件简单的事情, 越是复杂的系统, 全覆盖的测试就越难设计. 但是, 如何提高测试的覆盖度, 是学术界一直以来都在关注的问题.

你会如何测试你的监视点实现?

  • 见“必做:实现监视点”的“调试”部分
  • 一些有用的调试工具:
    • -Wall,-Werror
    • assert():在运行时刻把error直接转变成failure
    • prontf()
    • GDB

强大的GDB

如果你遇到了段错误, 你很可能会想知道究竟是哪一行代码触发了段错误. 尝试编写一个触发段错误的程序, 然后在GDB中运行它. 你发现GDB能为你提供哪些有用的信息吗?

  1. 编写一个触发段错误的程序。例如尝试访问一个空指针:
1
2
3
4
5
6
7
8
#include <stdio.h>

int main() {
int *ptr = NULL;
// 访问空指针,触发段错误
printf("%d\n", *ptr);
return 0;
}
  • 上述代码保存为segfault.c。
  1. 编译程序
1
gcc -g -o segfault segfault.c
  1. 使用GDB调试
  2. 启动 GDB:
    gdb ./segfault
  3. 运行程序:在 GDB 提示符下,输入 run 命令运行程序:
    (gdb) run
1
2
3
4
5
6
7
8
(gdb) run
Starting program: /home/xiaoyao/Desktop/segfault
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Program received signal SIGSEGV, Segmentation fault.
0x0000555555555161 in main () at segfault.c:5
5 printf("%d\n", *p);//访问空指针,触发段错误
  1. 查看段错误信息:当程序触发段错误时,GDB 会停止执行,并显示段错误发生的位置。你可以使用 backtrace 命令查看调用堆栈:
    (gdb) backtrace显示调用堆栈中的所有函数,帮助确定段错误发生的确切位置。
1
2
(gdb) backtrace
#0 0x0000555555555161 in main () at segfault.c:5
  1. 查看代码行:
    (gdb) list使用 list 命令查看段错误发生的代码行:
1
2
3
4
5
6
7
8
(gdb) list
1 #include<stdio.h>
2
3 int main() {
4 int *p = NULL;
5 printf("%d\n", *p);//访问空指针,触发段错误
6 return 0;
7 }
  1. 检查变量:
    (gdb) print p使用 print 命令检查变量的值。例如,检查指针p的值:
1
2
(gdb) print p
$1 = (int *) 0x0
  • 总结:GDB调试段错误可提供的有用信息:
    1. 段错误发生的位置
    • GDB 会在段错误发生时停止程序执行,并显示段错误发生的确切位置,包括文件名和行号。
    1. 调用堆栈
    • 使用 backtrace 命令可以查看调用堆栈,显示函数调用的层次结构,帮助你确定段错误发生时的调用路径。
    1. 变量的值
    • 使用 print 命令可以查看变量的值,帮助你检查指针是否为空或变量是否有异常值。
    1. 代码上下文
    • 使用 list 命令可以查看段错误发生的代码行及其上下文,帮助你理解代码的执行逻辑。
    1. 寄存器状态
    • 使用 info registers 命令可以查看 CPU 寄存器的当前状态,帮助你检查寄存器的值是否正确。
    1. 内存内容
    • 使用 x 命令可以查看内存内容,帮助你检查内存地址是否有效或内存内容是否正确。

sanitizer - 一种底层的assert

  • 手动在这些访问(非法访存)之前添加assert(), 太麻烦了.
  • 让编译器支持这个功能的是一个叫Address Sanitizer的工具, 它可以自动地在指针和数组的访问之前插入用来检查是否越界的代码. GCC提供了一个-fsanitize=address的编译选项来启用它. menuconfig已经为大家准备好相应选项了, 你只需要打开它:
1
2
Build Options
[*] Enable address sanitizer
  • 然后清除编译结果并重新编译即可.
    -对每一次访存进行检查会带来额外的性能开销.可以在无需调试的时候将其关闭.
  • 事实上, 除了地址越界的错误之外, Address Sanitizer还能检查use-after-free的错误 (即”释放从堆区申请的空间后仍然继续使用”的错误), 你知道它是如何实现这一功能的吗?
    • AddressSanitizer 通过在内存分配和释放操作中插入钩子函数、使用影子内存记录内存状态、在每次内存访问时检查内存状态等机制,有效地检测 use-after-free 错误。
让Address Sanitizer输出更精确的出错位置

如果在添加GDB调试信息的情况下打开Address Sanitizer, 其报错信息还会指出发生错误的具体代码位置, 为问题的定位提供便利.

更多的sanitizer

事实上, GCC还支持更多的sanitizer, 它们可以检查各种不同的错误, 你可以在man gcc中查阅-fsanitize相关的选项. 如果你的程序在各种sanitizer开启的情况下仍然能正确工作, 就说明你的程序还是有一定质量的.

总结:关于调试

  • 总是使用-Wall和-Werror
  • 尽可能多地在代码中插入assert()
  • 调试时先启用sanitizer
  • assert()无法捕捉到error时, 通过printf()输出可疑的变量, 期望能观测到error
  • printf()不易观测error时, 通过GDB理解程序的精确行为

断点

  • 断点的功能是让程序暂停下来, 从而方便查看程序某一时刻的状态。
  • 事实上, 我们可以很容易地用监视点来模拟断点的功能:
1
w $pc == ADDR

其中ADDR为设置断点的地址. 这样程序执行到ADDR的位置时就会暂停下来.

  • 断点机制的工作原理
    在调试器中设置断点的典型步骤如下:
    • 保存原始指令:调试器保存断点位置的原始指令。
    • 插入断点指令:调试器在断点位置插入 int3 指令。
    • 执行程序:程序运行到断点位置时,会触发 int3 指令,导致程序中断并转到调试器。
    • 恢复原始指令:调试器在断点位置恢复原始指令,并调整程序计数器(PC)以继续执行。

一点也不能长?

  • x86的int3指令不带任何操作数, 操作码为1个字节, 因此指令的长度是1个字节. 这是必须的吗? 假设有一种x86体系结构的变种my-x86, 除了int3指令的长度变成了2个字节之外, 其余指令和x86相同. 在my-x86中, 上述文章中的断点机制还可以正常工作吗? 为什么?
  • 在 x86 架构中,int3 指令是一个单字节的指令,操作码为 0xCC。它通常用于设置断点,因为它的长度为 1 字节,可以方便地插入到代码中而不会影响其他指令的执行。
  • 假设在一种变种架构 my-x86 中,int3 指令的长度变成了 2 个字节,而其他指令和 x86 相同,那么断点机制可能会受到影响。
  • 在 my-x86 中的影响
    在 my-x86 中,int3 指令的长度变成了 2 个字节,这会对上述断点机制产生以下影响:
    1. 指令长度的变化:
    • 在 x86 中,int3 指令是 1 个字节,可以方便地插入到任何位置。
    • 在 my-x86 中,int3 指令是 2 个字节,这意味着插入断点时需要考虑指令对齐和覆盖的问题。
    1. 覆盖原始指令:
    • 在 x86 中,插入 1 字节的 int3 指令通常不会覆盖多条指令。
    • 在 my-x86 中,插入 2 字节的 int3 指令可能会覆盖部分原始指令,导致恢复原始指令时出现问题。
    1. 程序计数器的调整:
    • 在 x86 中,恢复原始指令后,程序计数器(PC)只需调整 1 字节。
    • 在 my-x86 中,恢复原始指令后,程序计数器(PC)需要调整 2 字节,这可能需要额外的处理逻辑。
  • 解决方案
    为了在 my-x86 中正确实现断点机制,可以考虑以下解决方案:
    1. 调整断点插入逻辑:
    • 在插入断点时,确保 int3 指令不会覆盖多条原始指令。如果覆盖了多条指令,需要保存所有被覆盖的指令,并在恢复时正确恢复。
    1. 调整程序计数器:
    • 在恢复原始指令后,正确调整程序计数器(PC),使其跳过 2 字节的 int3 指令。

随心所欲的断点

  • 如果把断点设置在指令的非首字节(中间或末尾), 会发生什么? 你可以在GDB中尝试一下, 然后思考并解释其中的缘由.
  • 当程序运行到断点位置时,GDB 会暂停程序执行,并显示断点位置的汇编代码。然而,由于断点设置在指令的中间或末尾,CPU 将无法正确解码该指令,可能会导致以下几种情况:
    1. 非法指令异常:
    • CPU 尝试解码从中间开始的指令时,可能会将其解释为非法指令,从而触发非法指令异常。
    1. 错误的指令执行:
    • CPU 可能会错误地解码和执行部分指令,导致程序行为异常或崩溃。
    1. 程序崩溃:
    • 由于指令解码错误,程序可能会崩溃,并显示段错误或其他异常。

NEMU的前世今生

  • 你已经对NEMU的工作方式有所了解了. 事实上在NEMU诞生之前, NEMU曾经有一段时间并不叫NEMU, 而是叫NDB(NJU Debugger), 后来由于某种原因才改名为NEMU. 如果你想知道这一段史前的秘密, 你首先需要了解这样一个问题: 模拟器(Emulator)和调试器(Debugger)有什么不同? 更具体地, 和NEMU相比, GDB到底是如何调试程序的?
  • 模拟器(如 NEMU)和调试器(如 GDB)在功能和用途上有显著的不同。
    • NEMU 作为模拟器,主要用于模拟目标硬件的行为。
    • GDB 作为调试器,主要用于调试和分析程序的运行。
  • GDB 通过操作系统提供的调试接口控制程序的执行,而 NEMU 通过软件模拟目标硬件的行为。

如何阅读手册

学会使用目录+逐步细化搜索范围

必做:尝试通过目录定位关注的问题

  • 假设你现在需要了解一个叫selector的概念, 请通过i386手册的目录确定你需要阅读手册中的哪些地方. 即使你选择的ISA并不是x86, 也可以尝试去查阅这个概念.

必答题

  • 程序是个状态机(1)

  • 理解基础设施

    • 花75小时
      • 450 次调试 * 600 秒/次 = 270000 秒 = 75小时
    • 简易调试器可以帮助节省50小时
      • 450次调试 * 200 秒/次 = 90000 秒 = 25小时
  • RTFM 理解了科学查阅手册的方法之后, 请你尝试在你选择的ISA手册中查阅以下问题所在的位置, 把需要阅读的范围写到你的实验报告里面:riscv32

    • riscv32有哪几种指令格式?
      • 2.2 Instruction Formats(基本指令格式)
      • 四种,R/I/S/U
    • LUI指令的行为是什么?
      • 2.4: Integer Computational Instructions 中的 LUI (Load Upper Immediate) 子章节。
    • mstatus寄存器的结构是怎么样的?
      • 3.1: Control and Status Registers (CSRs) 中的 mstatus Register 子章节。
  • shell命令

    • 完成PA1的内容之后, nemu/目录下的所有.c和.h和文件总共有多少行代码?
      • 296394 总计
      • debug之后296412
    • 你是使用什么命令得到这个结果的?
      • find nemu/ -name "*.c" -o -name "*.h" | xargs wc -l
    • 和框架代码相比, 你在PA1中编写了多少行代码? (Hint: 目前pa0分支中记录的正好是做PA1之前的状态, 思考一下应该如何回到”过去”?)
      • git checkout pa0切换到pa0分支
      • find nemu/ -name "*.c" -o -name "*.h" | xargs wc -l
      • 295794(pa0)
      • 所以600行
    • 你可以把这条命令写入Makefile中, 随着实验进度的推进, 你可以很方便地统计工程的代码行数, 例如敲入make count就会自动运行统计代码行数的命令.
      • make count却报错【缺少分割符】,改了默认的“空格:2”为“制表符长度:4”,ok
      1
      2
      3
      4
      5
      6
      # 统计代码行数
      count:
      @echo "Total lines of code:"
      @find . -name "*.c" -o -name "*.h" | xargs cat | wc -l
      @echo "Total lines of code (excluding empty lines):"
      @find . -name "*.c" -o -name "*.h" | xargs grep -v '^\s*$$' | wc -l
    • 再来个难一点的, 除去空行之外, nemu/目录下的所有.c和.h文件总共有多少行代码?
      • 259683(debug之后259700
      • find nemu/ -name "*.c" -o -name "*.h" | xargs grep -v '^\s*$' | wc -l
  • RTFM 打开nemu/scripters/build.mk文件, 你会在CFLAGS变量中看到gcc的一些编译选项. 请解释gcc中的-Wall和-Werror有什么作用? 为什么要使用-Wall和-Werror?
    CFLAGS := -O2 -MMD -Wall -Werror $(INCLUDES) $(CFLAGS)

    • man gcc可知:
    1
    2
    -Wall
    This enables all the warnings about constructions that some users consider questionable, and that are easy to avoid (or modify to prevent the warning), even in conjunction with macros.
    1
    2
    -Werror
    Make all warnings into errors.
    • -Wall作用:-Wall选项用于启用所有常见的警告。这些警告可以帮助开发者发现代码中的潜在问题和不良实践。启用-Wall后,编译器会报告以下类型的警告(但不限于):
      • 未使用的变量
      • 未使用的函数
      • 未初始化的变量
      • 可疑的类型转换
      • 可能的数组越界
      • 其他潜在的编程错误
    • -Werror`作用:-Werror``选项将所有警告视为错误。这意味着如果编译过程中出现任何警告,编译器将停止编译,并报告这些警告为错误。这样可以确保代码在编译时没有任何警告,从而提高代码的质量和可靠性。
    • 为什么要使用 -Wall 和 -Werror
      1. 提高代码质量:启用 -Wall 可以帮助开发者发现代码中的潜在问题和不良实践,从而提高代码的质量。
      2. 强制修复警告:使用 -Werror 可以确保所有警告都被视为错误,强制开发者在提交代码之前修复所有警告。这有助于保持代码库的整洁和高质量。
      3. 减少潜在错误:许多警告可能会导致运行时错误或难以调试的问题。通过修复这些警告,可以减少潜在的运行时错误。
      4. 一致性:在团队开发中,使用 -Wall 和 -Werror 可以确保所有开发者都遵循相同的编码标准,保持代码的一致性。
  • 10.9凌晨,终于Accepted,PA1花了45h左右,收获良多,继续加油!

If you like my blog, you can approve me by scanning the QR code below.

Other Articles
Article table of contents TOP
  1. 1. PA1 实验报告
    1. 1.1. 2024.9.13
  2. 2. 开始PA1之旅!
    1. 2.0.1. ccache:
    2. 2.0.2. NEMU是什么
    3. 2.0.3. ISA是什么
  3. 2.1. 2024.9.14
  • 3. 开天辟地的篇章
    1. 3.0.1. 什么是编程模型
    2. 3.0.2. 最简单的计算机
    3. 3.0.3. 必做:尝试理解计算机如何计算
    4. 3.0.4. 计算机是个状态机
    5. 3.0.5. 必做:从状态机视角理解程序运行
  • 3.1. 2024.9.16
  • 4. RTFSC
    1. 4.1. 配置系统和项目构建
      1. 4.1.1. 配置系统kconfig
      2. 4.1.2. 项目构建和Makefile
      3. 4.1.3. 编译和链接
    2. 4.2. 准备第一个客户程序
    3. 4.3. 运行第一个客户程序
    4. 4.4. 2024.9.22
  • 5. 基础设施:简易调试器
    1. 5.1. 解析命令
    2. 5.2. 要实现:
    3. 5.3. 单步执行
    4. 5.4. 打印寄存器
    5. 5.5. 扫描内存
    6. 5.6. debug记录
  • 6. 表达式求值
    1. 6.1. 数学表达式求值
      1. 6.1.1. 词法分析(识别出表达式中的单元)
      2. 6.1.2. 递归求值
      3. 6.1.3. 实现带有负数的算术表达式的求值
      4. 6.1.4. 9.24
      5. 6.1.5. 如何测试你的代码——随机测试
    2. 6.2. 实现表达式生成器
      1. 6.2.1. 如何过滤求值过程中有除0行为的表达式?
      2. 6.2.2. 改造NEMU的main()函数
  • 7. 监视点
    1. 7.1. 扩展表达式求值的功能
      1. 7.1.1. 必做:扩展表达式求值的功能
    2. 7.2. 实现监视点
      1. 7.2.1. 必做:实现监视点的管理
      2. 7.2.2. 必做:实现监视点
      3. 7.2.3. 调试工具与原理
      4. 7.2.4. 断点
  • 8. 如何阅读手册
    1. 8.1. 学会使用目录+逐步细化搜索范围
      1. 8.1.1. 必做:尝试通过目录定位关注的问题
    2. 8.2. 必答题
  • Please enter keywords to search