8.5 KiB
Project-1 DEET 实现笔记
这东西虽然不需要啥特别的思考(基本上照着实验指导说明写就行了),但是写起来还是费了我一番功夫,故而还是记录一下实现的主要内容和遇到的问题。
总述
这个 project 是去用 ptrace 相关的系统调用来实现一个简单的调试器,主要只有断点和继续执行这两个功能,十分的简陋。关键的 crate 包(在实现过程中需要用到的)有以下两个:
gimli:DWARF 调试信息的包装库,用来解析编译器塞进可执行文件里面的调试信息,我们(其实是 starter code 的编写者)需要用它来解析指令、行号、函数名等部分的对应关系。原有的 starter code 中用到的部分这个包的 API 发生了一些不兼容的变化,因此需要做一些适配工作。nix:Linux 系统 API 的封装,核心的 ptrace 便是由这个包提供。
相关知识
- ptrace 建立的流程:父进程 fork,然后在子进程里面发出
TRACEME请求,然后execve执行真正的需要被调试的程序。一般而言,这个execve执行完成后,子进程会立即收到一个 SIGTRAP 然后中断,等待父进程waitpid取得它的状态,决定下一步要干啥。 - ptrace 本质上只是一个系统调用,
只不过 rust 的封装库把它变得更加的符合这门语言的风格。long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data); - waitpid:wait 系列的函数本质上是获取子进程的运行状态变化(而不是简单的退出)。除此之外,对于一个已经终止的子进程,这个调用还起到通知系统回收资源的作用。
- 断点原理:最基本的断点是指令断点,原理是通过修改子进程的 TEXT 段内存,把需要断点的指令的第一个字节(指令地址对应的字节)设置为
0xCC,这个是一条 SYSCALL,然后执行到这里的时候就陷入内核了,然后内核检查一下发现是调试用的,就把进程停下来并返回一个 SIGTRAP。
每个 MileStone 的说明
Milestone 0
看代码,这个好像没啥可说的,就大概看一眼每个文件是在干什么。实际上在做实验的过程中,只需要关注 debugger_command.rs debugger.rs inferior.rs 这三个文件而已。
debugger_command.rs:解析输入的动作命令并将参数打包进 enum 里面debugger.rs:程序的主要逻辑,一个大循环解析每次的命令并执行inferior.rs:每次运行的子进程的封装,实现一些直接操作进程的方法
Milestone 1
实现新建被调试的子进程并运行。也就是实现 run 命令。
主要两个问题:
- 对
std::Command的应用,除了 spawn 一个新的子进程之外,需要调用pre_exec使得子进程在 fork 出来之后会先调用traceme,从而建立父进程对子进程的ptrace控制。 wait相关的问题。在创建进程后,首先需要 wait 一下来确保子进程被 TRAP 住了(虽然不确认也问题不大);然后就是在正式运行之后,需要 wait 到子进程停下来(无论是断点还是退出还是爆炸之类的),这个 wait 完了会返回几种状态,需要进行处理。
Milestone 2
实现中断的继续执行以及进程清理。这里的中断是 Ctrl+C 对于 ptrace 进程的特殊结果(一般是发一个 SIGTERM,而这里发的则是 SIGINT 使得它停下来)而不是断点。因此只需要简单的调用 ptrace 的 cont 就行。这里的 cont 操作和前面 run 用到的代码一样,因此可以封装成一个内部方法,这件事情我写到后面才想起来,一开始直接复制了两段一样的代码,简直像个傻子。
主要问题是:它要求实现一个功能,在中断之后再执行 run 命令,需要 kill 掉中断的进程然后重新起一个。问题在于我也不知道在 ptrace 的 kill 之后要调用一个 wait 啊。如果不调用 wait 会导致僵尸进程(因为死掉之后的 wait 主要是为了通知系统清理子进程),直到父进程结束才会被清理。更离谱的是, wait 返回的结果居然是 Signal(SIGKILL) 而不是一个 Exit(137),如果 assert 的话就会爆炸。
如果用 ps 命令看的话,僵尸进程会在后面写一个 defunct。
Milestone 3
实现 backtrace。这个没有什么需要自己调的东西,把讲义上写的东西实现了就好。对于一个已经写过 OS 的人来说,栈回溯实在算不上什么难度,连伪代码都给出来了。
Milestone 4
在程序中断的时候,打印停下来的文件和行数。更简单了,wait 在返回 Signal 的时候,还会返回中断时的指令指针,直接调用 dwarf 的 get_line_from_addr 就行。
Milestone 5
实现断点设置和停止。也没有什么需要自己调的东西,对着讲义写就完事了。
不过这里可能是由于编译器版本的问题(也有可能是我自己的目录太深了?),无法打印源代码文件名和行号对应信息。原因是,有一部分信息本来是直接写在字段里面的,结果在我这里给挪到了单独的字符串区域 .debug_line_str 然后原来的地方填了个 offset,所以有一部分信息解读不出来。我于是就更新了一下 gimli 依赖库的版本,然后参考了手册和其他部分的实现,把这部分的解析给补上了,这样就能打印行号和文件名了。
Milestone 6
实现断点后继续执行。这是整个 project 的最难写的部分(虽然主要原因是我前面写的太随意导致我重构了一部分代码)。继续执行的原理不难理解,就是在断点之后先把被替换成 0xcc 的指令恢复,然后单步执行一条指令,然后把 0xcc 再插回去,最后 continue。
第一个问题,因为这里恢复指令需要记录原有指令被替换掉的字节,如果没有 run 的话,内存里面是啥也没有的,因此需要等 run 里面把子进程加载之后才能去记录这些内容,导致写的有点冗余。我没改前一个 milestone 的 breakpoint vector,直接新加了一个 hashmap,在 run 的时候首先遍历 vector,把里面的断点打进去然后把换出来的指令数据存进 map 里面。如果是中断状态下打断点,就直接打进去,不需要 continue 的时候再一起插一次断点了。不过这就引出了第二个问题。
第二个问题,我想把插入断点并记录 map 这个操作封装一个内部方法,然后在 run 之前就需要把 vector 里面的所有断点都做一次这个操作。用 for addr in &self.vec 遍历的时候,编译器报错了,它告诉我不能在这个里面调用具有 (&mut self) 参数的方法。会报错 &self 不可变借用,而函数需要一个可变借用。形象的解释就是:它怕我们在函数里修改 self.vec 从而导致出错,虽然实际上写的时候并不会去修改这个东西,但是在调用方法的时候,会要求完整取得引用的所有权。但是我们把这个函数里面的代码 inline 出来就没问题了(你这里不还是 self 的可变引用吗),这个好像和 self 的特殊性质以及 NLL 有关,我没细究,太复杂了。
第三个问题,因为在 continue 之前,需要判断上一次停下来是不是因为断点,所以需要记录上一次 wait 的状态结果。本来我是用 inferior 的 None 来标志程序已经结束的,这样已经记录了上次状态,感觉这个 None 判断就有点多余,应该直接用状态来判断(不过我懒得重构了)
Milestone 7
这个就没啥东西了,把 break 处理代码那边多写两个 if 就完事了。
附加任务?
没写,因为感觉意义不大(从练习 rust 的角度而言,如果是从一个 debugger 的角度,前面相当于啥也没有啊),不过看了一眼。
写一下 next line 指令的思路:因为我们有行号和指令的对应表,因此我们只需要不停地指令单步执行,直到下一条指令(其实就是 wait 返回的 %rip)不在同一行了。但是有可能会出现一行代码对应的指令不是连续的这种问题,不过好像 gdb 都没有很好的解决方案,所以无所谓了。
print 的思路:dwarf 里面给了每个函数对应的变量表,看上去只要查表打印就行了,但是实际情况会比较麻烦一些,比如要判断作用域,比如复合结构的解析(结构体成员、指针等)等等,需要去深入研究一下 API 和 DWARF 手册。