基础设施: 简易调试器
基础设施是指支撑项目开发的各种工具和手段. 原则上基础设施并不属于课本上知识的范畴, 但是作为一个有一定规模的项目, 基础设施的好坏甚至会影响到项目的推进, 这是你在程序设计课上体会不到的.
事实上, 你已经体会过基础设施给你带来的便利了.
我们的框架代码已经提供了Makefile来对NEMU进行一键编译.
现在我们来假设我们没有提供一键编译的功能, 你需要通过手动键入gcc
命令的方式来编译源文件:
假设你手动输入一条gcc
命令需要10秒的时间(你还需要输入很多编译选项, 能用10秒输入完已经是非常快的了),
而NEMU工程下有30个源文件, 为了编译出NEMU的可执行文件, 你需要花费多少时间?
然而你还需要在开发NEMU的过程中不断进行编译, 假设你需要编译500次NEMU才能完成PA,
一学期下来, 你仅仅花在键入编译命令上的时间有多少?
有的项目即使使用工具也需要花费较多时间来构建.
例如硬件开发平台vivado
一般需要花费半小时到一小时不等的时间来生成比特文件,
也就是说, 你编写完代码之后, 可能需要等待一小时之后才能验证你的代码是否正确.
这是因为, 这个过程不像编译程序这么简单, 其中需要处理很多算法上的NPC问题.
为了生成一个还不错的比特文件, vivado
需要付出比gcc
更大的代价来解决这些NPC问题.
这时候基础设施的作用就更加重要了, 如果能有工具可以帮助你一次进行多个方面的验证,
就会帮助你节省下来无数个"一小时".
Google内部的开发团队非常重视基础设施的建设, 他们把可以让一个项目得益的工具称为Adder, 把可以让多个项目都得益的工具称为Multiplier. 顾名思义, 这些工具可以成倍提高项目开发的效率. 在学术界, 不少科研工作的目标也是提高开发效率, 例如自动bug检测和修复, 自动化验证, 易于开发的编程模型等等. 在PA中, 基础设施也会体现在不同的方面, 我们会在将来对其它方面进行讨论.
你将来肯定会参与比PA更大的项目, 如何提高项目开发的效率也是一个很重要的问题. 希望在完成PA的过程中, 你能够对基础设施有新的认识: 有代码的地方, 就有基础设施. 随着知识的积累, 将来的你或许也会投入到这些未知的领域当中, 为全世界的开发者作出自己的贡献.
简易调试器是NEMU中一项非常重要的基础设施. 我们知道NEMU是一个用来执行其它客户程序的程序, 这意味着, NEMU可以随时了解客户程序执行的所有信息. 然而这些信息对外面的调试器(例如GDB)来说, 是不容易获取的. 例如在通过GDB调试NEMU的时候, 你将很难在NEMU中运行的客户程序中设置断点, 但对于NEMU来说, 这是一件不太困难的事情.
为了提高调试的效率, 同时也作为熟悉框架代码的练习,
我们需要在monitor中实现一个具有如下功能的简易调试器
(相关部分的代码在nemu/src/monitor/debug
目录下),
如果你不清楚命令的格式和功能, 请参考如下表格:
命令 | 格式 | 使用举例 | 说明 |
---|---|---|---|
帮助(1) | help |
help |
打印命令的帮助信息 |
继续运行(1) | c |
c |
继续运行被暂停的程序 |
退出(1) | q |
q |
退出NEMU |
单步执行 | si [N] |
si 10 |
让程序单步执行N 条指令后暂停执行, 当 N 没有给出时, 缺省为1 |
打印程序状态 | info SUBCMD |
info r info w |
打印寄存器状态 打印监视点信息 |
表达式求值 | p EXPR |
p $eax + 1 |
求出表达式EXPR 的值, EXPR 支持的运算请见调试中的表达式求值小节 |
扫描内存(2) | x N EXPR |
x 10 $esp |
求出表达式EXPR 的值, 将结果作为起始内存地址, 以十六进制形式输出连续的 N 个4字节 |
设置监视点 | w EXPR |
w *0x2000 |
当表达式EXPR 的值发生变化时, 暂停程序执行 |
删除监视点 | d N |
d 2 |
删除序号为N 的监视点 |
备注:
- (1) 命令已实现
- (2) 与GDB相比, 我们在这里做了简化, 更改了命令的格式
你需要在将来的PA中使用这些功能来帮助你进行NEMU的调试. 如果你的实现是有问题的, 将来你有可能会面临以下悲惨的结局: 你实现了某个新功能之后, 打算对它进行测试, 通过扫描内存的功能来查看一段内存, 发现输出并非预期结果. 你认为是刚才实现的新功能有问题, 于是对它进行调试. 经过了几天几夜的调试之后, 你泪流满面地发现, 原来是扫描内存的功能有bug!
如果你想避免类似的悲惨结局, 你需要在实现一个功能之后对它进行充分的测试. 随着时间的推移, 发现同一个bug所需要的代价会越来越大.
解析命令
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.
打印寄存器
打印寄存器就更简单了, 执行info r
之后, 直接用printf()
输出所有寄存器的值即可.
如果你从来没有使用过printf()
, 请到互联网上搜索相关资料.
如果你不知道要输出什么, 你可以参考GDB中的输出.
扫描内存
扫描内存的实现也不难, 对命令进行解析之后, 先求出表达式的值.
但你还没有实现表达式求值的功能, 现在可以先实现一个简单的版本:
规定表达式EXPR
中只能是一个十六进制数, 例如
x 10 0x100000
这样的简化可以让你暂时不必纠缠于表达式求值的细节. 解析出待扫描内存的起始地址之后, 你就使用循环将指定长度的内存数据通过十六进制打印出来. 如果你不知道要怎么输出, 同样的, 你可以参考GDB中的输出.
实现了扫描内存的功能之后, 你可以打印0x100000
附近的内存,
你应该会看到程序的代码, 和默认镜像进行对比, 看看你的实现是否正确.
熟悉了NEMU的框架之后, 这些功能实现起来都很简单, 同时我们对输出的格式不作硬性规定, 就当做是熟悉GNU/Linux编程的一次练习吧.
不知道如何下手? 嗯, 看来你需要再阅读一遍RTFSC小节的内容了.
不敢下手? 别怕, 放手去写! 编译运行就知道写得对不对.
代码改挂了, 就改回来呗; 代码改得面目全非, 还有git
呀!
PA1阶段1到此结束.