# Project-1 DEET 实现笔记 > 这东西虽然不需要啥特别的思考(基本上照着实验指导说明写就行了),但是写起来还是费了我一番功夫,故而还是记录一下实现的主要内容和遇到的问题。 ## 总述 这个 project 是去用 `ptrace` 相关的系统调用来实现一个简单的调试器,主要只有断点和继续执行这两个功能,十分的简陋。关键的 crate 包(在实现过程中需要用到的)有以下两个: - `gimli`:DWARF 调试信息的包装库,用来解析编译器塞进可执行文件里面的调试信息,我们(其实是 starter code 的编写者)需要用它来解析指令、行号、函数名等部分的对应关系。原有的 starter code 中用到的部分这个包的 API 发生了一些不兼容的变化,因此需要做一些适配工作。 - `nix`:Linux 系统 API 的封装,核心的 ptrace 便是由这个包提供。 ## 相关知识 1. ptrace 建立的流程:父进程 fork,然后在子进程里面发出 `TRACEME` 请求,然后 `execve` 执行真正的需要被调试的程序。一般而言,这个 `execve` 执行完成后,子进程会立即收到一个 SIGTRAP 然后中断,等待父进程 `waitpid` 取得它的状态,决定下一步要干啥。 2. ptrace 本质上只是一个系统调用, ```c long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data); ``` 只不过 rust 的封装库把它变得更加的符合这门语言的风格。 3. waitpid:wait 系列的函数本质上是获取子进程的运行状态变化(而不是简单的退出)。除此之外,对于一个已经终止的子进程,这个调用还起到通知系统回收资源的作用。 4. 断点原理:最基本的断点是指令断点,原理是通过修改子进程的 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 命令。 主要两个问题: 1. 对 `std::Command` 的应用,除了 spawn 一个新的子进程之外,需要调用 `pre_exec` 使得子进程在 fork 出来之后会先调用 `traceme`,从而建立父进程对子进程的 `ptrace` 控制。 2. `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 手册。