学习调试器的基本原理。
进程
进程:进程是操作系统资源分配的基本单位,操作系统使用进程控制块(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 2 3
| wait、waitpid、waitid 这几个系统调用被用于等待调用者的子进程状态改变。 状态改变意味着子进程终止、子进程收到信号停止或是子进程收到信号重新运行, 对于终止的子进程,父进程调用 waitpid 允许操作系统对这个子进程进行资源回收,否则子进程将会变成僵尸进程。
|
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 2 3 4 5 6
| ptrace 系统调用允许一个进程监视其他进程的内存和寄存器布局,它主要被用于实现断点调试或是追踪系统调用。 被追踪的进程首先需要被追踪器附加,在一个多线程的进程中,每个线程都可以被一个追踪器附加,或是不被附加而正常运行。所以被追踪的通常是“线程”而不是“进程”。 一个进程可以通过 fork 创建子进程,在子进程中使用 PTRACE_TRACEME 请求父进程对其进行追踪。 在追踪过程中,被追踪进程每次接收到信号的时候就会暂停(SIGKILL 信号除外),追踪者可使用 waitpid 等系统调用获取到子进程暂停这一事实,当子进程处于暂停状态的过程中,追踪者可以使用 ptrace 系列工具对子进程进行各种修改,追踪者可选择继续执行子进程,甚至给子进程传递不同的信号。 如果 PTRACE_O_TRACEEXEC 选项没有生效,那么被追踪进程每次调用 execve 的时候,都会收到 SIGTRAP 信号,给追踪者获取它控制权的机会。 追踪者结束追踪之后可通过 PTRACE_DETACH 使子进程继续正常运行。
|
实现最简单的调试器
那么如何使用系统提供的 ptrace 实现一个最简单的调试器?
根据前面了解到的有关知识,我们可采用 ptrace + waitpid + fork 方式实现父进程对子进程的调试。
基本思路
- 父进程通过 fork 创建子进程
- 子进程利用 PTRACE_TRACEME 通知操作系统,要求其父进程对自己进行追踪。
- 子进程利用 exec 执行其他程序,此时由于 ptrace 的存在,子进程会收到 SIGTRAP 信号而暂停。
- 父进程使用 waitpid 等待,直到子进程暂停,此时父进程可修改子进程的内存或寄存器。
- 父进程完成修改,通过 ptrace 使子进程单步运行,父进程继续使用 waitpid 等待子进程暂停,不断循环。
基于以上思路,我们可以实现一个最简单的统计程序 CPU 指令数量的调试器。
代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
| #include <stdio.h> #include <stdarg.h> #include <syscall.h> #include <sys/ptrace.h> #include <sys/types.h> #include <sys/wait.h> #include <sys/reg.h> #include <sys/user.h> #include <unistd.h> #include <errno.h>
void startProcess(char* fileName){ printf("Process starting!\n"); if(ptrace(PTRACE_TRACEME, 0, 0, 0) < 0){ printf("ptrace Error!1\n"); return; }
execl(fileName, fileName, 0, NULL); }
void MyDebugger(pid_t childPid){ int wait_status; unsigned int icounter = 0; wait(&wait_status); while(WIFSTOPPED(wait_status)){ icounter++; if(ptrace(PTRACE_SINGLESTEP, childPid, 0, 0) < 0){ printf("ptrace Error!2\n"); return; } wait(&wait_status); }
printf("Target run %d instructions!\n", icounter); }
int main(int argc, char** argv){ printf("Debugger Test\n"); pid_t pid = 0; if(argc != 2){ printf("Please set a filename!\n"); return 0; } pid = fork(); if(pid < 0){ printf("fork failed!\n"); return -1; } else if(pid == 0){ printf("Child process running!\n"); startProcess(argv[1]); } else if(pid > 0){ printf("Current chind PID: %d\n", pid); printf("Current parent PID: %d\n", getpid()); MyDebugger(pid); }
}
|
执行结果:
1 2 3 4 5 6 7
| Debugger Test Current chind PID: 7726 Current parent PID: 7725 Child process running! Process starting! Hello World! Target run 11919 instructions!
|
另外还可以简单修改一下代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| #include <stdio.h> #include <stdarg.h> #include <syscall.h> #include <sys/ptrace.h> #include <sys/types.h> #include <sys/wait.h> #include <sys/reg.h> #include <sys/user.h> #include <unistd.h> #include <errno.h>
void startProcess(char* fileName){ printf("Process starting!\n"); if(ptrace(PTRACE_TRACEME, 0, 0, 0) < 0){ printf("ptrace Error!1\n"); return; }
execl(fileName, fileName, 0, NULL); }
void MyDebugger(pid_t childPid){ int wait_status; unsigned int icounter = 0; wait(&wait_status); while(WIFSTOPPED(wait_status)){ icounter++; struct user_regs_struct regs; ptrace(PTRACE_GETREGS, childPid, 0, ®s); unsigned long long int ins = ptrace(PTRACE_PEEKTEXT, childPid, regs.rip, 0); printf("Trace: RIP = 0x%08x, TEXT = 0x%08x\n", regs.rip, ins); if(ptrace(PTRACE_SINGLESTEP, childPid, 0, 0) < 0){ printf("ptrace Error!2\n"); return; } wait(&wait_status); }
printf("Target run %d instructions!\n", icounter); }
int main(int argc, char** argv){ printf("Debugger Test\n"); pid_t pid = 0; if(argc != 2){ printf("Please set a filename!\n"); return 0; } pid = fork(); if(pid < 0){ printf("fork failed!\n"); return -1; } else if(pid == 0){ printf("Child process running!\n"); startProcess(argv[1]); } else if(pid > 0){ printf("Current chind PID: %d\n", pid); printf("Current parent PID: %d\n", getpid()); MyDebugger(pid); }
}
|
通过 ptrace 的其他工具,我们可以实现读取目标进程寄存器、代码段数据的目的(需要注意被追踪的程序架构)。
1 2 3 4 5 6 7 8 9 10 11 12 13
| Trace: RIP = 0x0040e95c, TEXT = 0x20fd8148 Trace: RIP = 0x0040e963, TEXT = 0xdf89f072 Trace: RIP = 0x0040e965, TEXT = 0x34e8df89 Trace: RIP = 0x0040e967, TEXT = 0x030034e8 Trace: RIP = 0x0043e9a0, TEXT = 0x49d76348 Trace: RIP = 0x0043e9a3, TEXT = 0xd0c1c749 Trace: RIP = 0x0043e9aa, TEXT = 0x00e7b841 Trace: RIP = 0x0043e9b0, TEXT = 0x00003cbe Trace: RIP = 0x0043e9b5, TEXT = 0x0f6619eb Trace: RIP = 0x0043e9d0, TEXT = 0x44d78948 Trace: RIP = 0x0043e9d3, TEXT = 0x0fc08944 Trace: RIP = 0x0043e9d6, TEXT = 0x3d48050f Target run 11919 instructions!
|