简易调试器
你应在本节中完成以下任务
- 仔细阅读在线讲义,理解 NEMU 的本质;
- 阅读项目中和本节相关的源代码,了解 NEMU 的运行逻辑;
- 编写寄存器结构/联合体,使 NEMU 运行起来;
- 为建议调试器添加打印寄存器、扫描内存和单步执行功能;
- 回答讲义中的所有思考题。
什么是 TRM
大家都上过程序设计课程,知道程序就是由代码和数据组成。例如一个求 1+2+...+100
的程序,大家不费吹灰之力就可以写出一个程序来完成这件事情。不难理解,数据就是程序处理的对象,代码则描述了程序希望如何处理这些数据。那么为了执行哪怕最简单的程序,最简单的计算机又应该长什么样呢?
不吊胃口,这里我们先给出结论:
结构上:有加法器、存储器、寄存器、程序计数器(Program Counter,PC)
工作方式上:TRM 不断地重复以下过程:从 PC 指示的存储器位置取出指令——执行指令——更新 PC
接下来我们将一一解释每个部件的作用以及为何是这种工作方式。
计算机为了执行程序、处理数据,需要一个运算器。我们常说好的 CPU 能提高机子的运行速度,就是指计算机中的运算器要足够好,计算机运行才会快。现代的运算器已经相当复杂了,所以我们考虑用加法器来替代,毕竟加法是最基本的运算。其次需要考虑的是我们执行的程序以及运算所需的数据要放在哪里,这就是存储器存在的原因。
有时候程序需要对同一个数据进行连续的处理。例如要计算 1+2+...+100
,就要对部分和进行累加,如果每完成一次累加都需要把它写回存储器,然后又把它从存储器中读出来继续加,这样就太不方便了。同时天下也没有免费的午餐,存储器的大容量也是需要付出相应的代价的,那就是速度慢,这是谁也无法违背的材料特性规律。于是我们需要寄存器,可以让 CPU 把正在处理中的数据暂时存放在其中。当然,效率至上,最后的结果还是需要写入存储器中。
为了让计算机能够为我们踏实地工作,我们可以通过指令来控制 CPU,用来指示 CPU 对数据进行何种处理。同时,为了解放人类的双手,我们需要让程序自动控制计算机的执行,我们让 CPU 每执行完一条指令之后,就继续执行下一条。问题又来了,CPU 怎么知道当前执行到那一条指令了呢?为此,我们需要一个程序计数器(PC),其中存放下一条要执行的指令的存放地址,根据这个地址我们就能取出相应的指令。
{% panel style="success", title="存放的是什么?" %}
为什么是存放指令的存放地址而不是指令本身呢?
{% endpanel %}
这样,我们就可以用一段伪代码来表示计算机的工作:
给PC一个初始值;
for(;;){
从PC指示的存储器中取出指令;
执行指令;
更新PC;
}
从此以后,我们只要将一段指令序列放置在存储器中,然后让 PC 指向第一条指令,计算机就会自动执行这一段指令序列,永不停止。实际上,这个想法最初就是来源于”计算机之父“——图灵。直到现在,计算机的设计都离不开这个天才的想法——”存储程序“。为了表达对图灵的敬仰,我们也把上面这个最简单的计算机称为"图灵机"(Turing Machine, TRM)。
再回过头来看看我们之前给出的结论,加法器、存储器、寄存器等都是数电课上学过的部件,也许你会觉得难以置信,你正在面对着的那台无所不能的计算机,就是由数字电路组成的,计算机经过几十年的发展,本质上还是一个只懂 0 和 1 的巨大数字电路。至于为何我们现在能够用 C 语言和计算机方便地交流,计算机是怎么懂 C 语言的,这部分知识将在不久的将来为大家慢慢道来。
什么是NEMU
{% panel style="info", title="什么是 x86?" %}
NEMU
是 PA 的组成部分之一 ,要了解什么是 NEMU ,我们需要先知道什么是 x86
。x86
泛指一系列由因特尔公司开发处理器的架构,该系列较早期的处理器名称是 80x86
,常被称为 i386
或 IA-32
,该架构属于复杂指令集 (CISC)。
{% endpanel %}
PA 的目标之一是要实现 NEMU
——一款经过简化的 x86 全系统模拟器。准确的说,它是 x86 的一个子集,是用 C 语言来编写的,模拟 x86 部分功能的程序。我们将用这个程序来执行其他程序。
上述描述对你来说也许还有些晦涩难懂,让我们来看一个 ATM 机的例子:
ATM 机是一个物理上存在的机器,它的功能需要由物理电路和机械模块来支撑。例如我们在 ATM 机上进行存款操作的时候, ATM 机都会吭哧吭哧地响,所以我们可以确信那是一台机器。另一方面,现在第三方支付平台也非常流行,例如支付宝。事实上,我们可以把支付宝 APP 看成一个虚拟的 ATM 机,在这个虚拟的 ATM 机里面,真实 ATM 机具备的所有功能,包括存款,取款,查询余额,转账等等,都通过支付宝 APP 这个程序来实现。
同样地,NEMU 就是一个虚拟出来的计算机硬件,物理计算机中的基本功能在 NEMU 中都是通过程序来实现的。
要虚拟出一个计算机系统并没有你想象中的那么困难,我们可以把计算机看成由若干个硬件部件组成,这些部件之间相互协助,完成"运行程序"这件事情。在 NEMU 中,每一个硬件部件都由一个程序相关的数据对象来模拟,例如变量,数组,结构体等。而对这些部件的操作则通过对相应数据对象的操作来模拟,例如 NEMU 中使用数组来模拟内存,那么对这个数组进行读写则相当于对内存进行读写。所以我们才说 NEMU 是一个用来执行其它程序的程序。
我们对虚拟机真的陌生吗?不少同学应该才安装了一台 Linux 虚拟机,这里,我们用一个图来表示在一台 Linux 虚拟机上运行一个 helloworld 程序的层级关系:
+---------------------------------+
| "Hello World" program |
+---------------------------------+
| GNU/Linux |
+---------------------------------+
| VirtualBox (Simulated Hardware) |
+---------------------------------+
| Host Operating System |
+---------------------------------+
| Computer hardware |
+---------------------------------+
{% panel style="success", title="贵圈真乱" %}
NEMU 是一款运行在 Linux 系列操作系统上的经过简化的 x86 全系统模拟器,如果告诉你,NEMU 之上也可以运行一个 helloworld 小程序,那么这个时候的层级关系又是什么样子的呢?请你仿照上图,试着画出这个层级关系。
{% endpanel %}
{% panel style="success", title="虚拟机和模拟器的区别" %}
我们说 NEMU 是一款经过简化的 x86 全系统模拟器,那么跟我们平时用的虚拟机有什么区别呢?你可以从模拟器和虚拟机的区别着手思考这个问题。这跟问题非常开放,你可以根据自己的理解进行回答。
{% endpanel %}
NEMU 的威力会让你感到吃惊!它不仅仅能运行 HelloWorld
这样的小程序,在 PA 的后期,你甚至可以在 NEMU 中运行一个中小型游戏。完成 PA 之后,你在程序设计课上对程序的认识会被彻底颠覆,你会觉得计算机不再是一个神秘的黑盒,甚至你会发现创造一个属于自己的计算机不再是遥不可及!
RTFSC
我们的任务就是在 NEMU 中实现一个 TRM。
从现在开始,我们将大量阅读 NEMU 的源代码实现,这里我们给同学们提供一些阅读源代码的有效工具,你可以自己选择是否使用他们。
Notepad++:一个用 C++ 语言编写的开源记事本软件,功能强大。具有代码高亮、语法提示、高亮代码复制等功能,足以替换 Windows 下自带的记事本软件。https://notepad-plus-plus.org/
Visual Studio Code:一个轻量级的、开源跨平台版本的 VS,不用多解释了吧?https://code.visualstudio.com/
Sublime Text:一款跨平台的,具有代码高亮、语法提示、自动完成且反应快速的编辑器软件。http://www.sublimetext.com/
这里特别提出学会使用目录下“全局搜索”功能,便于你很快能够找到某个变量或者函数名的定义或者引用的位置等。熟练掌握一款高效率的编辑器,将帮助你在成功的路上领先他人一步!
还记得我们在 PA0 中在 Github 上拉取的项目吗,我们终于要跟它见面了,首先我们来看一下项目结构:
ics2023
├── init.sh # 初始化脚本
├── Makefile # 用于工程打包提交
├── nanos-lite # 微型操作系统内核
├── navy-apps # 应用程序集
├── nemu # NEMU
└── nexus-am # 抽象计算机
多提一句,不要去修改 PA 的目录名字 ics2023
,否则后续可能会出现莫名其妙的错误。
目前我们只需要关心 NEMU 的内容,其它内容会在将来进行介绍。 NEMU 主要由 4 个模块构成:
- CPU
- memory
- device
- monitor
在上一小节已经简单介绍过了 CPU 和 memory 的功能, device 会在 PA2 中介绍,目前不必关心。monitor位于这个虚拟计算机系统之外,主要用于监视这个虚拟计算机系统是否正确运行。 monitor 从概念上并不属于一个计算机的必要组成部分,但对 NEMU 来说,它是必要的基础设施。它除了负责与 GNU/Linux 进行交互(例如读写文件)之外,还带有调试器的功能,为 NEMU 的调试提供了方便的途径。
代码中 nemu 目录下的源文件组织如下(部分目录下的文件并未列出):
nemu
├── include # 存放全局使用的头文件
| ├── common.h # 公用的头文件
| ├── cpu
| | ├── decode.h # 译码相关
| | ├── exec.h # 执行相关
| | ├── reg.h # 寄存器结构体的定义
| | └── rtl.h # RTL指令
| ├── debug.h # 一些方便调试用的宏
| ├── device # 设备相关
| ├── macro.h # 一些方便的宏定义
| ├── memory # 访问内存相关
| ├── monitor
| | ├── expr.h
| | ├── monitor.h
| | └── watchpoint.h # 监视点相关
| └── nemu.h
├── Makefile # 指示NEMU的编译和链接
├── Makefile.git # git版本控制相关
├── runall.sh # 一键测试脚本
└── src # 源文件
├── cpu
| ├── decode # 译码相关
| ├── exec # 执行相关
| ├── intr.c # 中断处理相关
| └── reg.c # 寄存器相关
├── device # 设备相关
├── main.c
├── memory
| └── memory.c
├── misc
| └── logo.c # "i386"的logo
└── monitor
├── cpu-exec.c # 指令执行的主循环
├── diff-test
├── debug # 简易调试器相关
| ├── expr.c # 表达式求值的实现
| ├── ui.c # 用户界面相关
| └── watchpoint.c # 监视点的实现
└── monitor.c
为了给出一份可以运行的框架代码,代码中实现了 mov
指令的功能,并附带一个 mov
指令序列的默认客户程序。另外,部分代码中会涉及一些硬件细节(例如 nemu/src/cpu/decode/modrm.c
)。在你第一次阅读代码的时候,你需要尽快掌握 NEMU 的框架,而不要纠缠于这些细节。随着 PA 的进行,你会反复回过头来探究这些细节。
{% panel style="success", title="从哪开始阅读代码呢" %}
大致了解上述的目录树之后,你就可以开始阅读代码了。至于从哪里开始,回忆程序设计课的内容, 一个程序从哪里开始执行呢?
{% endpanel %}
前面我们提到,实现 TRM ,离不开寄存器。为了兼容 x86 ,我们选择了一个稍微有点复杂的寄存器结构:
31 16 15 8 7 0
+-----------------+-----------------+-----------------+----------------+
| EAX AH AX AL | 累加器 EAX
+-----------------+-----------------+-----------------+----------------+
| EDX DH DX DL | 数据寄存器 EDX
+-----------------+-----------------+-----------------+----------------+
| ECX CH CX CL | 计数寄存器 ECX
+-----------------+-----------------+-----------------+----------------+
| EBX BH BX BL | 基址寄存器 EBX
+-----------------+-----------------+-----------------+----------------+
| EBP BP | 基址指针 EBP
+-----------------+-----------------+-----------------+----------------+
| ESI SI | 源变址器 ESI
+-----------------+-----------------+-----------------+----------------+
| EDI DI | 目标变址器 EDI
+-----------------+-----------------+-----------------+----------------+
| ESP SP | 堆栈指针 ESP
+-----------------+-----------------+-----------------+----------------+
31 16 15 8 7 0
+-----------------+-----------------+-----------------+----------------+
| EIP IP | 指令指针 EIP
+-----------------+-----------------+-----------------+----------------+
| EFLAGS FLAGS | 标志寄存器
+-----------------+-----------------+-----------------+----------------+
其中:
EAX
,EBX
,ECX
,EDX
,ESP
,EBP
,ESI
,EDI
,EIP
,EFLAGS
是32位寄存器。AX
,BX
,CX
,DX
,BP
,SI
,DI
,SP
,FLAGS
是16位寄存器。AL
,AH
,BL
,BH
,CL
,CH
,DL
,DH
是8位寄存器。
在物理上,它们并不是相互独立的。 例如 EAX
的低 16 位是 AX
,而 AX
又分为 AH
和 AL
。
寄存器的速度很快,但容量却很小,和存储器的特性正好互补,甚至到课程后半段我们还要介绍 Cache ,它们之间或许会交织出新的故事呢,不过目前我们还是顺其自然吧。还记得之前提到的 PC 嘛?它在 x86 中的名字叫作EIP
。
{% panel style="danger", title="任务1:实现正确的寄存器结构体" %}
我们在 PA0 中提到,运行 NEMU 会出现 assertion fail
的错误信息,这是因为框架代码并没有正确地实现用于模拟寄存器的结构体 CPU_state
,现在你需要实现它了(结构体的定义在 nemu/include/cpu/reg.h
中)。现在请你根据我们给出的寄存器结构图,合理使用 C 语言中的 struct
,union
类型来组织寄存器结构。关于i386 寄存器的更多细节,请查阅 i386 手册。如果你还不知道 struct
,union
是什么,请在网上搜索相关信息。提示:使用结构体和匿名 union,相互嵌套。
{% endpanel %}
下面我们将帮助大家理解框架代码,强烈建议同学们一边阅读下述文字,一边阅读相应的框架代码
NEMU 开始执行的时候,首先会调用
init_monitor()
函数(在nemu/src/monitor/monitor.c
中定义)进行一些和 monitor 相关的初始化工作,我们对其中几项初始化工作进行一些说明。reg_test()
函数(在nemu/src/cpu/reg.c
中定义)会生成一些随机的数据,对寄存器实现的正确性进行测试。若不正确,将会触发assertion fail
(可以借此来验证寄存器实现正确与否)。然后通过调用
load_img()
函数(在nemu/src/monitor/monitor.c
中定义)读入带有客户程序的镜像文件。我们知道内存是一种 RAM (Random Access Memory) ,是一种易失性的存储介质,这意味着计算机刚启动的时候,内存中的数据都是无意义的;而 BIOS 是固化在 ROM (Read-Only Memory ) 中的,它是一种非易失性的存储介质, BIOS 中的内容不会因为断电而丢失。因此在真实的计算机系统中,计算机启动后首先会把控制权交给 BIOS , BIOS 经过一系列初始化工作之后,再从磁盘中将有意义的程序读入内存中执行。对这个过程的模拟需要了解很多超出本课程范围的细节,我们在这里做了简化,让 monitor 直接把一个有意义的客户程序镜像guest prog
读入到一个固定的内存位置0x100000
,这个程序是运行 NEMU 的一个参数,在运行 NEMU 的命令中指定,缺省时将把上文提到的mov
程序作为客户程序(参考load_default_img()
函数)。接下来调用
restart()
函数(在nemu/src/monitor/monitor.c
中定义),它模拟了"计算机启动"的功能,进行一些和"计算机启动"相关的初始化工作,一个重要的工作就是将%eip
的初值设置为刚才我们约定的内存位置0x100000
,这样就可以让 CPU 从我们约定的内存位置开始执行程序了。这时内存的布局如下:0 0x100000 ------------------------------------------- | | | | | guest prog | | | | ------------------------------------------- ^ | EIP
接下来的工作包括:
- 调用
init_regex()
函数来编译正则表达式(PA1.2内容) - 调用
init_wp_pool()
函数来初始化监控池(PA1.3内容) - 调用
init_device()
函数来初始化设备(不做要求) - 调用
welcome()
函数输出欢迎信息和 NEMU 的编译时间
- 调用
monitor 的初始化工作结束后, NEMU 会进入用户界面主循环
ui_mainloop()
(在nemu/src/monitor/debug/ui.c
中定义),调用rl_gets()
函数输出 NEMU 的命令提示符:(nemu)
代码已经实现了几个简单的命令,它们的功能和 GDB 是很类似的。输入
c
之后, NEMU 开始进入指令执行的主循环cpu_exec()
(在nemu/src/monitor/cpu-exec.c
中定义)。cpu_exec()
模拟了 CPU 的工作方式:不断执行指令。exec_wrapper()
函数(在nemu/src/cpu/exec/exec.c
中定义) 的功能让 CPU 执行当前%eip
指向的一条指令,然后更新%eip
。已经执行的指令会输出到日志文件log.txt
中,你可以打开log.txt
来查看它们。执行指令的相关代码在
nemu/src/cpu/exec
目录下。其中一个重要的部分定义在nemu/src/cpu/exec/exec.c
文件中的opcode_table
数组,在这个数组中,你可以看到框架代码中都已经实现了哪些指令。其中EMPTY
代表对应的指令还没有实现(也可能是 x86 中不存在该指令)。在以后的 PA 中,随着你实现越来越多的指令,这个数组会逐渐被它们代替,如果方便的话,你可以看看 i386 手册的Appendix A
内容,或许你可以发现不少秘密。关于指令执行的详细解释和exec_wrapper()
相关的内容需要涉及很多细节,目前你不必关心,我们将会在 PA2 中进行解释。NEMU 将不断执行指令,直到遇到以下情况之一,才会退出指令执行的循环:
- 达到要求的循环次数。
- 客户程序执行了
nemu_trap
指令。这是一条特殊的指令,机器码为0xd6
。如果你查阅 i386 手册,你会发现 x86 中并没有这条指令,它是为了在 NEMU 中让客户程序指示执行的结束而加入的。
当你看到 NEMU 输出以下内容时:
nemu:HIT GOOD TRAP at eip=0x00100026
说明客户程序已经成功地结束运行。退出
cpu_exec()
之后,NEMU将返回到ui_mainloop()
,等待用户输入命令。但为了再次运行程序,你需要键入q
退出 NEMU ,然后重新运行。
{% panel style="success", title="究竟要执行多久" %}
在 cmd_c()
函数中,调用 cpu_exec()
的时候传入了参数 -1
,你知道为什么要这么做吗,并说明理由。提示:注意函数传参类型和数据类型转换。
{% endpanel %}
很高兴你坚持看下来并来到这里,或许此刻的你云里雾里,框架中的 C 语言的用法或许你见所未见,但跟着讲义内容来看代码绝壁会比自己蒙头死盯代码理解得更快。在这个过程中,你甚至可能会感慨” C 语言居然还能这么写“。
话说回来,理解框架代码是一个螺旋上升的过程,不同的阶段有不同的重点。你不必因为看不懂某些细节而感到沮丧,更不要试图一次把所有代码全部看明白。讲义中的知识点很多,在实验的不同阶段对同一个知识点的理解也会有所不同。我们建议你在完成相应阶段的任务之后回过头来重新阅读一遍讲义的内容,你很可能会有不一样的收 获。如果你提前看了暂时还用不上的代码,可能会给你的心灵带来不必要的恐惧。
{% panel style="success", title="谁来指示程序的结束? " %}
在程序设计课上老师告诉你,当程序执行到 main()
函数返回处的时候,程序就退出了,你对此深信不疑。但你是否怀疑过,凭什么程序执行到 main()
函数的返回处就结束了?如果有人告诉你,程序设计课上老师的说法是错的,你有办法来证明/反驳吗?如果你对此感兴趣,请在互联网上搜索相关内容。
{% endpanel %}
最后我们聊聊代码中一些值得注意的地方。
- 三个对调试有用的宏(在
nemu/include/debug.h
中定义)Log()
是printf()
的升级版,专门用来输出调试信息,同时还会输出使用Log()
所在的源文件,行号和函数。当输出的调试信息过多的时候,可以很方便地定位到代码中的相关位置。Assert()
是assert()
的升级版,当测试条件为假时,在assertion fail
之前可以输出一些信息。panic()
用于输出信息并结束程序,相当于无条件的assertion fail
。代码中已经给出了使用这三个宏的例子,如果你不知道如何使用它们,RTFSC。
- 内存通过在
nemu/src/memory/memory.c
中定义的大数组pmem
来模拟。在客户程序运行的过程中,总是使用vaddr_read()
和vaddr_write()
访问模拟的内存。vaddr
,paddr
分别代表虚拟地址和物理地址。这些概念在将来会用到,但从现在开始保持接口的一致性可以在将来避免一些不必要的麻烦。 - 除了讲义提及到的,代码中也有很多英文注释可以帮助你理解代码框架。以及,出于对程序代码优化的考虑 ,代码中存有大量的条件编译或许会让你感到心烦,但你必须接受它,因为这能提高程序的可移植性和灵活性 。
事实上,TRM的实现已经都蕴含在上述的介绍中了。
- 存储器是个在
nemu/src/memory/memory.c
中定义的大数组; - PC和通用寄存器都在
nemu/include/cpu/reg.h
中的结构体中定义 ; - 加法器在......嗯,框架代码这部分的内容有点复杂,不过它并不影响我们对 TRM 的理解,我们还是在 PA2 里面再介绍它吧;
- TRM的工作方式通过
cpu_exec()
和exec_wrapper()
体现。
简易调试器
基础设施
基础设施是指支撑项目开发的各种工具和手段。原则上基础设施并不属于课本上知识的范畴,但是作为一个有一定规模的项目,基础设施的好坏甚至会影响到项目的推进,这是你在程序设计课上体会不到的。
{% panel style="info", title="基础设施 - 提高项目开发的效率 " %}
事实上,你已经体会过基础设施给你带来的便利了。我们的框架代码已经提供了 Makefile 来对 NEMU 进行一键编译。Makefile 定义了一系列的规则来指定,哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行更复杂的功能操作。现在我们来假设我们没有提供一键编译的功能,你需要通过手动键入gcc
命令的方式来编译源文件:假设你手动输入一条gcc
命令需要 10 秒的时间(你还需要输入很多编译选项,能用10秒输入完已经是非常快的了),而 NEMU 工程下有 30 个源文件,为了编译出 NEMU 的可执行文件,你需要花费多少时间?然而你还需要在开发 NEMU 的过程中不断进行编译,假设你需要编译 500 次 NEMU 才能完成 PA ,一学期下来,你仅仅花在键入编译命令上的时间有多少?
有的项目即使使用工具也需要花费较多时间来构建。例如硬件开发平台vivado
一般需要花费半小时到一小时不等的时间来生成比特文件,也就是说,你编写完代码之后,可能需要等待一小时之后才能验证你的代码是否正确。这是因为,这个过程不像编译程序这么简单,其中需要处理很多算法上的 NPC 问题。为了生成一个还不错的比特文件,vivado
需要付出 比gcc
更大的代价来解决这些 NPC 问题。这时候基础设施的作用就更加重要了,如果能有工 具可以帮助你一次进行多个方面的验证,就会帮助你节省下来无数个"一小时"。
Google 内部的开发团队非常重视基础设施的建设,他们把可以让一个项目得益的工具称为 Adder ,把可以让多个项目都得益的工具称为 Multiplier 。顾名思义,这些工具可以成倍提高项目开发的效率。在学术界,不少科研工作的目标也是提高开发效率,例如自动 bug 检测和修复、自动化验证、易于开发的编程模型等等。在 PA 中,基础设施也会体现在不同的方面,我们会在将来对其它方面进行讨论。
{% endpanel %}
话说回来,我们要实现的简易调试器是 NEMU 中一项非常重要的基础设施。我们知道 NEMU 是一个用来执行其它客户程序的程序,这意味着, NEMU 可以随时了解客户程序执行的所有信息。然而这些信息对外面的调试器(例如 GDB )来说,是不容易获取的。例如在通过 GDB 调试 NEMU 的时候,你将很难在 NEMU 中运行的客户程序中设置断点,但对于 NEMU 来说,这是一件不太困难的事情。
为了提高调试的效率,同时也作为熟悉框架代码的练习,我们需要在 monitor 中实现一个具有如下功能的简易调试器(相关部分的代码在 nemu/src/monitor/debug
目录下),如果你不清楚命令的格式和功能,请参考如下表格:
命令 | 格式 | 举例说明 |
---|---|---|
帮助(1) | help | help 打印命令的帮助信息 |
继续运行(1) | c | c 继续运行被暂停的程序 |
退出(1) | q | q 退出NEMU |
单步执行(2) | si [N] | si 10 让程序单步执行 10 条指令后暂停执行,当N没有给出时,缺省为 1 |
打印程序状态(2)(3) | info SUBCMD | info r 用于打印寄存器状态(2) ,info w 用于打印监视点信息(3) |
扫描内存(2) | x N EXPR | x 10 $ESP 求出表达式 EXPR 的值,将结果作为起始内存地址,以十六进制形式输出连续的 N 个 4 字节 |
表达式求值(3) | p EXPR | p $eax+1 求出表达式 EXPR 的值,EXPR 支持的运算在 PA1.2 中提及 |
设置监视点(3) | w EXPR | w *0x2000 当表达式 EXPR 的值发生变化时,暂停程序 执行 |
删除监视点(3) | d N | d 2 删除序号为 2 的监视点 |
(1) 这三条命令我们在框架代码中实现;(2) 本节中需要实现的功能;(3) 将在 PA1 的下个阶段实现的功能。
{% panel style="warning", title="总有一天会找上门来的bug " %}
你需要在将来的 PA 中使用这些功能来帮助你进行 NEMU 的调试。如果你的实现是有问题的,将来你有可能会面临以下悲惨的结局:你实现了某个新功能之后,打算对它进行测试,通过 扫描内存的功能来查看一段内存,发现输出并非预期结果。你认为是刚才实现的新功能有问题,于是对它进行调试。经过了几天几夜的调试之后,你泪流满面地发现,原来是扫描内存的功能有 bug!
如果你想避免类似的悲惨结局,你需要在实现一个功能之后对它进行充分的测试。随着时间的推移,发现同一个 bug 所需要的代价会越来越大。
{% endpanel %}
解析命令
NEMU 通过 readline
库与用户交互,使用 readline()
函数从键盘上读入命令。与 gets()
相比,readline()
提供了"行编辑"的功能,最常用的功能就是通过上、下方向键翻阅历史记录。事实上,Shell
程序就是通过 readline
读入命令的。关于 readline
的功能和返回值等信息,请查阅
man readline
从键盘上读入命令后, NEMU 需要解析该命令,然后执行相关的操作。解析命令的目的是识别命令中的参数,例如在 si 10
的命令中识别出 si
和 10
,从而得知这是一条单步执行 10 条指令的命令。解析命令的工作是通过一系列的字符串处理函数来完成的,例如框架代码中的 strtok()
。strtok()
是 C 语言中的标准库函数,如果你从来没有使用过strtok()
,并且打算继续使用框架代码中的 strtok()
来进行命令的解析,请务必查阅
man strtok
另外,cmd_help()
函数中也给出了使用 strtok()
的例子。事实上,字符串处理函数有很,键入以下内容:
man 3 str<TAB><TAB>
其中 <TAB>
代表键盘上的 TAB
键。你会看到很多以 str
开头的函数,其中有你应该很熟悉的 strlen()
, strcpy()
等函数。你最好都先看看这些字符串处理函数的 manual page
,了解一 下它们的功能,因为你很可能会用到其中的某些函数来帮助你解析命令。当然你也可以编写你 自己的字符串处理函数来解析命令。
另外一个值得推荐的字符串处理函数是 sscanf()
,它的功能和 scanf()
很类似,不同的是 sscanf()
可以从字符串中读入格式化的内容,使用它有时候可以很方便地实现字符串的解析。如果你从来没有使用过它们,RTFM,或者到互联网上查阅相关资料。
单步执行
单步执行的功能十分简单,而且框架代码中已经给出了模拟 CPU 执行方式的函数,你只要使用相应的参数去调用它就可以了。如果你仍然不知道要怎么做,RTFSC。推荐自行检查以下几个测试用例要正确通过:si
、si -1
、si 1
、si 10
。
此外,我们还给你提供了实现本功能的思路:
static int cmd_si(char *args){
// TODO: 利用 strtok 读取出 N
...;
// TODO: 然后根据 N 来执行对应的 cpu_exec(N) 操作
cpu_exec(...);
return 0;
}
{% panel style="danger", title="任务2.1:实现单步/指定步数执行功能" %}
你需要实现 si
命令的功能代码,别忘了将 si
命令的处理函数 cmd_si
加入到指令列表中。如果你不知道该命令怎么编写,你可以看看 cmd_c
函数是如何编写的。
{% endpanel %}
{% panel style="danger", title="任务2.2:修改一次打印步数上限" %}
运行两次 si 5
命令和运行一次 si 10
命令,他们的作用都是运行 10 步(即使能够执行的指令并不足 10 条),可是显示在控制台上的输出一样吗?如果不一样, 是什么原因?如何允许 si
命令支持在一次性执行 15 条命令时(即 si 15
)也能把过程中经过的指令都打印出来呢?同理,如果之后执行的命令比较多,有1000000条,你也能做到打印出指令吗(即si 1000000
)? 请你试图阅读代码,尝试找出修改办法。提示:可以从确保是正确实现的 cmd_c
开始阅读。
{% endpanel %}
打印寄存器
打印寄存器就更简单了,执行 info r
之后,直接用 printf()
输出所有寄存器的值即可。如果你不知道要输出什么,你可以参考 GDB 中的输出。
如果你不知道应该输出什么,可以参考下面的输出(三列分别为:寄存器名、十六进制形式、十进制形式):
(nemu) info r
eax 0x00000000 0
edx 0x00000000 0
ecx 0x00000000 0
...
此外,我们还给你提供了实现本功能的思路:
static int cmd_info(char *args){
// 分割字符
...;
// 判断子命令是否是r
if (子命令是 r)
{
// 依次打印所有寄存器
// 这里给个例子:打印出 eax 寄存器的值
printf("%s:\t%8x\t", regsl[0], cpu.gpr[0]._32);
...;
}
else if (子命令是 w)
{
// 这里我们会在 PA1.3 中实现
}
return 0;
}
{% panel style="danger", title="任务3:实现打印寄存器功能" %}
你需要实现 info
命令的 r
子命令的功能代码,别忘了将 info
命令的处理函数 cmd_info
加入到指令列表中。
{% endpanel %}
扫描内存
扫描内存的实现也不难,对命令进行解析之后,先求出表达式的值。但你还没有实现表达式求值的功能,现在可以先实现一个简单的版本:规定表达式EXPR
中只能是一个十六进制数,例如
x 10 0x100000
这样的简化可以让你暂时不必纠缠于表达式求值的细节。解析出待扫描内存的起始地址之后, 你就使用循环将指定长度的内存数据通过十六进制打印出来。如果你不知道要怎么输出,同样的,你可以参考 GDB 中的输出。
实现了扫描内存的功能之后,你可以打印 0x100000
附近的内存,你应该会看到程序的代码和默认镜像进行对比,看看你的实现是否正确。参考实现方法如下:
static int cmd_x(char *args){
//分割字符串,得到起始位置和要读取的次数
...
//循环使用 vaddr_read 函数来读取内存
for(???){
vaddr_read(...); //如何调用,怎么传递参数,请阅读代码
//每次循环将读取到的数据用 printf 打印出来
printf(...); //如果你不知道应该打印什么,可以参考参考输出形式
}
}
参考的输出形式如下(输出数据为示例),不要求必须采用:
(nemu) x 4 0x100000
Address Dword block ... Byte sequence
0x00100000 0x000000b8 ... b8 00 00 00
0x00100004 0x0000bb00 ... 00 bb 00 00
0x00100008 0x00b90000 ... 00 00 b9 00
0x0010000c 0xba000000 ... 00 00 00 ba
{% panel style="danger", title="任务4.1:实现扫描内存功能 " %}
你需要实现 x
命令的功能代码,别忘了将 x
命令的处理函数 cmd_x
加入到指令列表中。
{% endpanel %}
{% panel style="danger", title="任务4.2:转换为字节显示 " %}
在上个任务 4.1 中,你已经能够直接把相应地址的相应数据以整数形式打印出来了,但是在某些状况中并不便于我们观察(如观察指令码),你要设法把一个读出的数据转换为字节顺序打印出来。
{% endpanel %}
{% panel style="success", title="为什么会这样?" %}
经过任务 4.1 和 4.2,你发现本来是顺序存储的数据,为何以 4 字节为单位打印和以 1 字节为单位打印时相比,顺序会不一样?结合你在理论课上所学到的知识,解释一下。(提示:数据的存储)
{% endpanel %}
让我们再多获取点信息,这部分代码都在 nemu/src/monitor/debug/ui.c
下,如果你实现不知道该怎么写,可以参考代码框架中已给出的三条指令的实现。不敢下手?别怕,放手去写!编译运行就知道写得对不对。代码改挂了,就改回来呗;代码改得面目全非,还有 Git 呀!
以上是 PA1.1 的全部内容。