基础设施: 简易调试器
基础设施 - 提高项目开发的效率
在PA中, 基础设施是指支撑项目开发的各种工具和手段. 原则上基础设施并不属于课本知识的范畴, 但是作为一个有一定规模的项目, 基础设施的好坏会影响到项目的推进, 甚至决定项目的成败, 这是你在程序设计课上体会不到的.
事实上, 你已经体会过基础设施给你带来的便利了. 我们的框架代码已经提供了Makefile来对NEMU进行一键编译. 假设我们并没有提供一键编译的功能, 你需要通过手动键入gcc
命令的方式来编译源文件: 假设你手动输入一条gcc
命令需要10秒的时间(你还需要输入很多编译选项, 能用10秒输入完已经是非常快的了), 而NEMU工程下有30个源文件, 为了编译出NEMU的可执行文件, 你需要花费多少时间? 然而你还需要在开发NEMU的过程中不断进行编译, 假设你需要编译500次NEMU才能完成PA, 一学期下来, 你仅仅花在键入编译命令上的时间有多少?
有的项目即使使用工具也需要花费较多时间来构建. 例如硬件开发平台vivado
/quartus
一般需要花费半小时到一小时不等的时间来生成比特文件, 也就是说, 你编写完代码之后, 可能需要等待一小时之后才能验证你的代码是否正确. 这是因为, 这个过程不像编译程序这么简单, 其中需要处理很多算法上的NPC问题. 为了生成一个质量还不错的比特文件, 硬件开发工具需要付出比gcc
更大的代价来解决这些NPC问题. 这时候基础设施的作用就更加重要了, 如果能有工具可以帮助你一次进行多个方面的验证, 就会帮助你节省下来无数个"一小时".
Google内部的开发团队非常重视基础设施的建设, 他们把可以让一个项目得益的工具称为Adder, 把可以让多个项目得益的工具称为Multiplier. 顾名思义, 这些工具可以成倍提高项目开发的效率. 在学术界, 不少科研工作的目标也是提高开发效率, 例如bug自动检测和修复, 自动化验证, 易于开发的编程模型等等. 在PA中, 基础设施也会体现在不同的方面, 我们会在将来对其它方面进行讨论.
你将来肯定会参与比PA更大的项目, 如何提高项目开发的效率也是一个很重要的问题. 希望在完成PA的过程中, 你能够对基础设施有新的认识: 有代码的地方, 就有基础设施. 随着知识的积累, 将来的你或许也会投入到这些未知的领域当中, 为全世界的开发者作出自己的贡献.
真实故事
yzh的小组里曾经发生过一件由于基础设施不完善而导致在论文投稿截止前科研成果质量不佳的事件.
这一科研工作需要运行各种不同的测试来验证效果, 在两台4核8线程的PC机上运行完所有的测试需要花费约24小时. 每次对设计进行改动之后, 都需要重新运行所有测试, 可以说是, 改一行代码, 要过24小时后才能得到结果. 事实上, 在投稿截止3个月之前, 我们已经得知有一台112核的服务器可以使用. 如果在这台服务器上部署测试环境, 预计可以使测试总时间减少到原来的1/5 (多核服务器的主频比PC机低1倍, 架构也落后两代, 1/5是一个综合考虑之后的数字).
但是带领这一项目的同学并没有意识到基础设施的重要性: 他一直在PC机上进行测试. 事实上, 测试总时间减少到原来的1/5, 其实意味着改进设计的机会是原来的5倍. 结果到投稿截止之前, 设计还在修改, 测试还在反复运行, 最终只能无奈地采用一个质量有待提高的设计版本提交论文.
简易调试器(Simple Debugger, sdb)是NEMU中一项非常重要的基础设施. 我们知道NEMU是一个用来执行其它客户程序的程序, 这意味着, NEMU可以随时了解客户程序执行的所有信息. 然而这些信息对外面的调试器(例如GDB)来说, 是不容易获取的. 例如在通过GDB调试NEMU的时候, 你将很难在NEMU中运行的客户程序中设置断点, 但对于NEMU来说, 这是一件不太困难的事情.
为了提高调试的效率, 同时也作为熟悉框架代码的练习, 我们需要在monitor中实现一个具有如下功能的简易调试器 (相关部分的代码在nemu/src/monitor/sdb/
目录下), 如果你不清楚命令的格式和功能, 请参考如下表格:
命令 | 格式 | 使用举例 | 说明 |
---|---|---|---|
帮助(1) | help | help | 打印命令的帮助信息 |
继续运行(1) | c | c | 继续运行被暂停的程序 |
退出(1) | q | q | 退出NEMU |
单步执行 | si [N] | si 10 | 让程序单步执行N 条指令后暂停执行, 当 N 没有给出时, 缺省为1 |
打印程序状态 | info SUBCMD | info r info w | 打印寄存器状态 打印监视点信息 |
扫描内存(2) | x N EXPR | x 10 $esp | 求出表达式EXPR 的值, 将结果作为起始内存地址, 以十六进制形式输出连续的 N 个4字节 |
表达式求值 | p EXPR | p $eax + 1 | 求出表达式EXPR 的值, EXPR 支持的运算请见调试中的表达式求值小节 |
设置监视点 | w EXPR | w *0x2000 | 当表达式EXPR 的值发生变化时, 暂停程序执行 |
删除监视点 | d N | d 2 | 删除序号为N 的监视点 |
备注:
- (1) 命令已实现
- (2) 与GDB相比, 我们在这里做了简化, 更改了命令的格式
总有一天会找上门来的bug
你需要在将来的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, 了解一下它们的功能, 因为你很可能会用到其中的某些函数来帮助你解析命令. 当然你也可以编写你自己的字符串处理函数来解析命令.
如何测试字符串处理函数?
你可能会抑制不住编码的冲动: 与其RTFM, 还不如自己写. 如果真是这样, 你可以考虑一下, 你会如何测试自己编写的字符串处理函数?
如果你愿意RTFM, 也不妨思考一下这个问题, 因为你会在PA2中遇到类似的问题.
另外一个值得推荐的字符串处理函数是sscanf()
, 它的功能和scanf()
很类似, 不同的是sscanf()
可以从字符串中读入格式化的内容, 使用它有时候可以很方便地实现字符串的解析. 如果你从来没有使用过它们, RTFM, 或者STFW.
单步执行
单步执行的功能十分简单, 而且框架代码中已经给出了模拟CPU执行方式的函数, 你只要使用相应的参数去调用它就可以了. 如果你仍然不知道要怎么做, RTFSC.
打印寄存器
打印寄存器就更简单了. 不过既然寄存器的结构是ISA相关的, 我们希望能为简易调试器屏蔽ISA的差异. 框架代码已经为大家准备了如下的API:
// nemu/src/isa/$ISA/reg.c
void isa_reg_display(void);
执行info r
之后, 就调用isa_reg_display()
, 在里面直接通过printf()
输出所有寄存器的值即可. 如果你从来没有使用过printf()
, 请RTFM或者STFW. 如果你不知道要输出什么, 你可以参考GDB中的输出.
扫描内存
扫描内存的实现也不难, 对命令进行解析之后, 先求出表达式的值. 但你还没有实现表达式求值的功能, 现在可以先实现一个简单的版本: 规定表达式EXPR
中只能是一个十六进制数, 例如
x 10 0x80000000
这样的简化可以让你暂时不必纠缠于表达式求值的细节. 解析出待扫描内存的起始地址之后, 就可以使用循环将指定长度的内存数据通过十六进制打印出来. 如果你不知道要怎么输出, 同样的, 你可以参考GDB中的输出. 问题是, 我们要如何访问客户计算机的内存数据呢? (答案早就说了喂)
实现了扫描内存的功能之后, 你可以打印0x80000000
或者0x100000
附近的内存, 你应该会看到程序的代码, 和内置客户程序的内容进行对比, 检查你的实现是否正确.
实现单步执行, 打印寄存器, 扫描内存
熟悉了NEMU的框架之后, 这些功能实现起来都很简单, 同时我们对输出的格式不作硬性规定, 就当做是熟悉GNU/Linux编程的一次练习吧.
NEMU默认会把单步执行的指令打印出来(这里面埋了一些坑, 你需要RTFSC看看指令是在哪里被打印的), 这样你就可以验证单步执行的效果了.
不知道如何下手? 嗯, 看来你需要再阅读一遍RTFSC小节的内容了. 如果你已经忘记了某些注意事项, 重新去阅读一遍也是应该的.
我怕代码写错了啊, 怎么办?
2014年图灵奖得主Michael Stonebraker在一次访谈中提到, 他当时花了5年时间开发了世界上第一个关系数据库系统Ingres, 其中90%的时间用于将它运行起来. 也就是说, 在开发过程中, 有90%的时间系统都是运行不起来的, 是有bug的, 需要调试.
所以, 接受现实吧: 代码出错是很正常的, 你需要从当年程序设计实验里感受到的那种"代码可以一次编译通过成功运行"的幻觉中清醒过来. 重要的是, 我们需要使用正确的方法和工具来帮助我们测试和调试, 最终让程序运行起来. 一个例子是版本控制工具git
, 它可以跟踪代码的变化, 从而发现bug是何时引入的, 而且能够在必要的时候回退到上一个程序可以运行的版本.
总之, 只有掌握正确的方法和工具, 才能真正驱散心中对bug的恐惧.
温馨提示
PA1阶段1到此结束.