学习调试器的基本原理。
进程
进程:进程是操作系统资源分配的基本单位,操作系统使用进程控制块(PCB)来表示一个进程。进程有属于他自己的堆栈空间,有一定的优先级,每个进程有独一无二的 PID。
fork 函数:Linux 中提供的用于创建新进程的函数。
fork 函数原型: pid_t fork(void),调用 fork 函数的进程称作父进程,被创建的进程称作子进程。如果函数执行成功,父进程返回子进程的 PID,子进程返回 0。如果执行失败,返回负数。
fork 通过完全复制父进程的资源创建子进程,子进程作为父进程的一个副本存在,父子进程几乎是完全相同的。两者都从 fork 之后开始运行。
底层原理:fork 通过系统调用创建新的进程,进程被存放在任务队列(双向循环链表)中,链表中的每一项都是进程描述符(PCB,进程控制块)。内核通过 PID 表示每一个进程,默认最大值为 32768 。可在 /proc/sys/kernel/pid_max 查看。
fork 呼叫系统调用,CPU 控制流转向内核 fork 代码,内核会做 4 件事情:
- 给子进程分配内存和内核线程
- 将父进程的部分数据拷贝到子进程
- 将子进程添加到进程列表
- fork 返回,开始调度执行。
fork 系列函数都是 clone 系统调用的封装,根据不同函数设置不同的调用参数。
fork 生成的子进程和父进程共享代码段,对于数据段采用写时复制技术。
写时复制:对每个内存区域添加一个计数器,每当一个指针引用了这块内存,计数器就加一。每当一个指针释放了内存,计数器就减一。
两个进程(或多个进程)共享一份内存页,这个内存页被系统标记为写保护。当一个进程想要修改页面内的数据,会因为权限问题触发异常。操作系统捕获这个异常,将受保护的内存页复制出来一份到新的页面,并修改保护标记为可写。想要修改内存数据的进程就去使用新复制出来的页面。原来的内存页依然是写保护的,当其他进程试图修改的时候,系统检测共享这个页面的进程数量,确定写页面的进程是否拥有这个内存页?如果是,就把这个内存页标记为对属主可写。
僵尸进程:和现实一样,父进程有义务去监管子进程(父亲对孩子有抚养义务),当子进程执行完毕或者在执行过程中出现某些错误退出之后,父进程需要调用 waitpid 来清理子进程的内核 PCB 结构。如果父进程没有正确的处理子进程相关信息(父亲跑路了),那么子进程会变成僵尸进程(孤儿)。其保存在内核的 PCB 没人来清理。
理论上所有刚刚结束的进程都是僵尸进程,但是它们的父进程会及时的清理这些已经结束的进程(它们的父亲很有责任心),所以正常使用系统的时候很少会看见僵尸进程。某些软件编写存在疏漏,就会出现僵尸进程。
例如父进程调用 fork 启动了一个子进程,子进程完成任务结束,但是父进程既不清理,也不退出,导致子进程变成孤儿。此时系统看不过去了,会将僵尸进程的父进程变更成 init 这个老大哥。因为 init 是所有进程的父进程,会伴随着整个系统的运行,老大哥会在一定时间对僵尸进程进行清理,实际上这就是系统启动完毕之后 init 进程的主要职责。
调试器基本原理
调试器应该具有的功能:
- 追踪进程运行,能够控制执行流。
- 访问进程的堆栈内存,并且可以随时修改。
- 添加断点,能够单步执行进程每条指令
- 随时改变进程代码,并且能够立即产生影响。
调试器还有很多功能,但是具有以上功能的调试器已经可以正常使用了。
现代调试器一般有两种,一是利用 CPU 提供的调试工具,例如 Intel CPU 提供了 INT 3 断点、硬件调试寄存器等。二是针对不同架构实现不同的仿真器,模拟 CPU 、内存等一切外围设施,此时调试器对进程拥有绝对的控制权。
目前几乎所有调试器都采用第一种方式,即使用 CPU 提供的调试工具。
调试器的基本思路:
- 将自身附加到进程
- 注册为调试器
- 读取进程代码段,利用反汇编引擎处理成汇编指令,输出到屏幕
- 根据用户选择的指令插入 INT3 断点
- 操作系统捕获 INT3,寻找注册的调试器
- 捕获信号,将被替换的指令还原
- 单步运行,读取堆栈内存等
以上是基本的调试器实现思路,但是在 Linux 中我们有一个更好用的工具:ptrace
ptrace 是 Linux 提供的系统调用,其角色和 Windows 中的一系列和进程有关 API 类似,我们可以利用 ptrace 访问目标进程的寄存器,控制目标进程的指令执行等。
wait 系统调用:Linux man page 描述如下
1 | wait、waitpid、waitid 这几个系统调用被用于等待调用者的子进程状态改变。 |
wait 系统调用 wait(&status) 相当于 waitpid(-1, &status, 0);
waitpid 暂停调用它的进程,直到接收到子进程的状态改变信号。默认情况下,此系统调用只等待终止进程的出现,但可以通过修改 option 参数来修改其行为。
第一个参数是 PID,取值如下
- PID < -1,waitpid 等待所有进程组 ID 和 PID 绝对值相等的子进程
- PID == -1,等待所有子进程
- PID == 0,等待所有进程组 ID 和调用者 pid 相等的子进程
- PID > 0,等待 pid 和 PID 相等的子进程
waitpid 的 option 有很多,具体可参考 man page。
ptrace 系统调用:Linux man page 描述如下
1 | ptrace 系统调用允许一个进程监视其他进程的内存和寄存器布局,它主要被用于实现断点调试或是追踪系统调用。 |
实现最简单的调试器
那么如何使用系统提供的 ptrace 实现一个最简单的调试器?
根据前面了解到的有关知识,我们可采用 ptrace + waitpid + fork 方式实现父进程对子进程的调试。
基本思路
- 父进程通过 fork 创建子进程
- 子进程利用 PTRACE_TRACEME 通知操作系统,要求其父进程对自己进行追踪。
- 子进程利用 exec 执行其他程序,此时由于 ptrace 的存在,子进程会收到 SIGTRAP 信号而暂停。
- 父进程使用 waitpid 等待,直到子进程暂停,此时父进程可修改子进程的内存或寄存器。
- 父进程完成修改,通过 ptrace 使子进程单步运行,父进程继续使用 waitpid 等待子进程暂停,不断循环。
基于以上思路,我们可以实现一个最简单的统计程序 CPU 指令数量的调试器。
代码如下
1 |
|
执行结果:
1 | Debugger Test |
另外还可以简单修改一下代码
1 |
|
通过 ptrace 的其他工具,我们可以实现读取目标进程寄存器、代码段数据的目的(需要注意被追踪的程序架构)。
1 | Trace: RIP = 0x0040e95c, TEXT = 0x20fd8148 |
- 本文作者: CataLpa
- 本文链接: https://wzt.ac.cn/2020/02/21/dig_debugger/
-
版权声明:
本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。