二、ptrace使用示例
下面通过一个简单例子来说明 ptrace() 系统调用的使用,这个例子主要介绍怎么使用 ptrace() 系统调用获取当前被调试(追踪)进程的各个寄存器的值,代码如下(ptrace.c):
#include <sys/ptrace.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> #include <sys/user.h> #include <stdio.h> int main() { pid_t child; struct user_regs_struct regs; child = fork(); // 创建一个子进程 if(child == 0) { // 子进程 ptrace(PTRACE_TRACEME, 0, NULL, NULL); // 表示当前进程进入被追踪状态 execl("/bin/ls", "ls", NULL); // 执行 `/bin/ls` 程序 } else { // 父进程 wait(NULL); // 等待子进程发送一个 SIGCHLD 信号 ptrace(PTRACE_GETREGS, child, NULL, ®s); // 获取子进程的各个寄存器的值 printf("Register: rdi[%ld], rsi[%ld], rdx[%ld], rax[%ld], orig_rax[%ld]\n", regs.rdi, regs.rsi, regs.rdx,regs.rax, regs.orig_rax); // 打印寄存器的值 ptrace(PTRACE_CONT, child, NULL, NULL); // 继续运行子进程 sleep(1); } return 0; }
通过命令 gcc ptrace.c -o ptrace 编译并运行上面的程序会输出如下结果:
Register: rdi[0], rsi[0], rdx[0], rax[0], orig_rax[59] ptrace ptrace.c
上面结果的第一行是由父进程输出的,主要是打印了子进程执行 /bin/ls 程序后各个寄存器的值。而第二行是由子进程输出的,主要是打印了执行 /bin/ls 程序后输出的结果。
下面解释一下上面程序的执行流程:
- 主进程调用 fork() 系统调用创建一个子进程。
- 子进程调用 ptrace(PTRACE_TRACEME,...) 把自己设置为被追踪状态,并且调用 execl() 执行 /bin/ls 程序。
- 被设置为追踪(TRACE)状态的子进程执行 execl() 的程序后,会向父进程发送 SIGCHLD 信号,并且暂停自身的执行。
- 父进程通过调用 wait() 接收子进程发送过来的信号,并且开始追踪子进程。
- 父进程通过调用 ptrace(PTRACE_GETREGS, child, ...) 来获取到子进程各个寄存器的值,并且打印寄存器的值。
- 父进程通过调用 ptrace(PTRACE_CONT, child, ...) 让子进程继续执行下去。
从上面的例子可以知道,通过向 ptrace()
函数的 request
参数传入不同的值时,就有不同的效果。比如传入 PTRACE_TRACEME
就可以让进程进入被追踪状态,而传入 PTRACE_GETREGS
时,就可以获取被追踪的子进程各个寄存器的值等。
三、调试工具
3.1基础知识
我将介绍 Linux 上调试器实现的主要构建块 -ptrace系统调用。本文中的所有代码都是在32位Ubuntu机器上开发的。请注意,该代码在很大程度上是特定于平台的,尽管将其移植到其他平台应该不会太困难。
动机
要了解我们要做什么,请尝试想象调试器需要什么才能完成其工作。调试器可以启动某个进程并对其进行调试,或者将其自身附加到现有进程。它可以单步执行代码、设置断点并运行它们、检查变量值和堆栈跟踪。许多调试器具有高级功能,例如在调试进程的地址空间中执行表达式和调用函数,甚至动态更改进程的代码并观察效果。
尽管现代调试器是复杂的野兽,但令人惊讶的是它们的构建基础却如此简单。调试器一开始只提供操作系统和编译器/链接器提供的一些基本服务,其余的只是简单的编程问题。
Linux调试——ptrace
Linux 调试器的瑞士军刀是ptrace系统调用。它是一种多功能且相当复杂的工具,允许一个进程控制另一个进程的执行并窥探其内部结构。ptrace需要一本中等大小的书才能完整解释,这就是为什么我只在示例中重点介绍它的一些实际用途。
单步执行流程的代码
我现在将开发一个在“跟踪”模式下运行进程的示例,其中我们将单步执行其代码 - 由 CPU 执行的机器代码(汇编指令)。我将分部分展示示例代码,逐一进行解释,在文章末尾,您将找到一个下载完整 C 文件的链接,您可以编译、执行和使用该文件。高级计划是编写代码,将其分为一个执行用户提供的命令的子进程和一个跟踪子进程的父进程。
主要功能:
int main ( int argc, char ** argv) { pid_t 子进程pid; if (argc < 2 ) { fprintf(stderr, "需要一个程序名称作为参数\n" ); 返回- 1; } child_pid = fork(); 如果(child_pid == 0) run_target(argv[ 1 ]); 否则 如果(child_pid > 0 ) run_debugger(child_pid); 否则{ perror( “分叉” ); 返回- 1; } 返回 0; }
非常简单:我们使用fork 启动一个新的子进程。后续条件的if分支运行子进程(此处称为“目标”),else if分支运行父进程(此处称为“调试器”)。
这是目标进程:
void run_target ( const char * 程序名) { procmsg( "目标已启动。将运行 '%s'\n" , 程序名); /* 允许跟踪该进程 */ if (ptrace(PTRACE_TRACEME, 0 , 0 , 0 ) < 0 ) { perror( “ptrace” ); 返回; } /* 用给定的程序替换该进程的映像 */ execl(programname, programname, 0 ); }
这里最有趣的一行是ptrace调用。ptrace是这样声明的(在sys/ptrace.h中):
long ptrace( enum __ptrace_request 请求, pid_t pid, void *addr, void *data);
第一个参数是request ,它可能是许多预定义的PTRACE_*常量之一。第二个参数指定某些请求的进程 ID。第三个和第四个参数是地址和数据指针,用于内存操作。上面代码片段中的 ptrace 调用发出PTRACE_TRACEME请求,这意味着该子进程请求操作系统内核让其父进程跟踪它。手册页中的请求描述非常清楚:
表示该进程将被其父进程跟踪。传递给该进程的任何信号(SIGKILL 除外)都会导致该进程停止,并通过 wait() 通知其父进程。 此外,此进程对 exec() 的所有后续调用都会导致向其发送 SIGTRAP,从而使父进程有机会在新程序开始执行之前获得控制权。如果进程的父进程不希望跟踪它,则它可能不应该发出此请求。 (pid、addr 和 data 被忽略。)
我在这个例子中强调了我们感兴趣的部分。请注意, run_target在ptrace之后执行的下一件事是使用execl调用作为参数提供给它的程序。正如突出显示的部分所解释的,这会导致操作系统内核在开始执行execl中的程序并向父进程发送信号之前停止该进程。
因此,时机成熟了,看看父母会做什么:
无效 run_debugger(pid_t child_pid) { int wait_status; 无符号icounter = 0; procmsg( “调试器已启动\n” ); /* 等待子进程停止执行第一个指令 */ 等待(&等待状态); while (WIFSTOPPED(wait_status)) { icounter++; /* 让子进程执行另一条指令 */ if (ptrace(PTRACE_SINGLESTEP, child_pid, 0 , 0 ) < 0 ) { perror( “ptrace” ); 返回; } /* 等待子进程停止执行下一条指令 */ 等待(&等待状态); } procmsg( "子进程执行了 %u 条指令\n" , icounter); }
想一下上面的内容,一旦子进程开始执行exec调用,它将停止并发送SIGTRAP信号。这里的父级在第一个等待调用中等待这种情况发生。一旦发生有趣的事情, wait将返回,并且父进程检查是否是因为子进程被停止(如果子进程通过传递信号而停止,则WIFSTOPPED返回 true)。
父母接下来要做的事情是本文最有趣的部分。它通过PTRACE_SINGLESTEP请求调用ptrace,并为其提供子进程 ID。它的作用是告诉操作系统 -请重新启动子进程,但在执行下一条指令后停止它。同样,父进程等待子进程停止并继续循环。当wait调用发出的信号不是关于子进程停止时,循环将终止。在跟踪器正常运行期间,这将是告诉父进程子进程已退出的信号(WIFEXITED将返回 true)。
请注意,icounter计算子进程执行的指令数量。因此,我们的简单示例实际上做了一些有用的事情 - 在命令行上给定程序名称,它会执行该程序并报告从开始运行到结束所需的 CPU 指令量。让我们看看它的实际效果。
试运行
我编译了以下简单程序并在跟踪器下运行它:
#include <stdio.h> int 主函数() { printf( "你好,世界!\n" ); 返回 0; }
令我惊讶的是,跟踪器运行了很长时间,并报告执行了超过 100,000 条指令。对于一个简单的printf调用?是什么赋予了?答案很有趣。默认情况下, Linux 上的gcc动态地将程序链接到 C 运行时库。这意味着,执行任何程序时首先运行的事情之一就是查找所需共享库的动态库加载器。这是相当多的代码 - 请记住,我们的基本跟踪器会查看每条指令,不仅仅是主函数,而是整个过程。
因此,当我使用-static标志链接测试程序时(并验证可执行文件的重量增加了约 500KB,这对于 C 运行时的静态链接来说是合乎逻辑的),跟踪仅报告了 7,000 条左右的指令。这仍然很多,但如果您还记得libc初始化仍然必须在main之前运行,并且清理必须在main之后运行,那就完全有意义了。此外,printf是一个复杂的函数。
仍然不满意,我想看到一些可测试的东西- 即我可以解释执行的每条指令的整个运行。当然,这可以通过汇编代码来完成。所以我拍了这个版本的《你好,世界!》并组装它:
节.文本 ;必须为链接器声明 _start 符号 (ld) 全局_start _开始: ;为 sys_write 系统调用准备参数: ; - eax:系统调用号(sys_write) ; - ebx:文件描述符(标准输出) ; - ecx:指向字符串的指针 ; - edx:字符串长度 mov edx,仅 mov ecx, 消息 移动 ebx, 1 移动 eax, 4 ;执行sys_write系统调用 整数0x80 ;执行sys_exit 移动eax, 1 整数0x80 .data 节 msg db '你好,世界!',0xa len equ $ - 味精
果然。现在跟踪器报告执行了 7 条指令,这是我可以轻松验证的。
深入指令流
通过汇编编写的程序,我可以向您介绍ptrace的另一个强大用途- 仔细检查所跟踪进程的状态。这是run_debugger函数的另一个版本:
无效 run_debugger(pid_t child_pid) { int wait_status; 无符号icounter = 0; procmsg( “调试器已启动\n” ); /* 等待子进程停止执行第一个指令 */ 等待(&等待状态); while (WIFSTOPPED(wait_status)) { icounter++; struct user_regs_struct regs; ptrace(PTRACE_GETREGS, child_pid, 0 , ®s); 无符号指令 = ptrace(PTRACE_PEEKTEXT, child_pid, regs.eip, 0 ); procmsg( "icounter = %u.EIP = 0x%08x.instr = 0x%08x\n" , icounter、regs.eip、instr); /* 让子进程执行另一条指令 */ if (ptrace(PTRACE_SINGLESTEP, child_pid, 0 , 0 ) < 0 ) { perror( “ptrace” ); 返回; } /* 等待子进程停止执行下一条指令 */ 等待(&等待状态); } procmsg( "子进程执行了 %u 条指令\n" , icounter); }
唯一的区别在于while循环的前几行。有两个新的ptrace调用。第一个将进程寄存器的值读入结构中。user_regs_struct在sys/user.h中定义。现在这是有趣的部分 - 如果你查看这个头文件,靠近顶部的评论说:
/* 这个文件的全部目的是为了 GDB 和 GDB 而已。 不要过度解读它。除非知道自己在做什么,否则不要将其用于 GDB 以外的任何用途 。 */
现在,我不了解你的情况,但这让我觉得我们走在正确的轨道上:-)无论如何,回到这个例子。一旦我们在regs中拥有了所有寄存器,我们就可以通过使用PTRACE_PEEKTEXT调用ptrace来查看进程的当前指令,并将regs.eip(x86 上的扩展指令指针)作为地址传递给它。我们返回的是指令。让我们看看这个新的跟踪器在我们的汇编代码片段上运行:
$ simple_tracer 追踪_helloworld [5700]调试器已启动 [5701]目标开始了。将运行“traced_helloworld” [5700] icounter = 1。EIP = 0x08048080。指令 = 0x00000eba [5700] icounter = 2。EIP = 0x08048085。指令 = 0x0490a0b9 [5700] icounter = 3。EIP = 0x0804808a。指令 = 0x000001bb [5700] icounter = 4。EIP = 0x0804808f。指令 = 0x000004b8 [5700] icounter = 5。EIP = 0x08048094。指令 = 0x01b880cd 你好世界! [5700] icounter = 6。EIP = 0x08048096。指令 = 0x000001b8 [5700] icounter = 7。EIP = 0x0804809b。指令 = 0x000080cd 【5700】孩子执行了7条指令
好的,现在除了icounter之外,我们还可以看到指令指针以及它在每一步指向的指令。如何验证这是否正确?通过在可执行文件上使用objdump-d :
$ objdump -d traced_helloworld traced_helloworld:文件格式 elf32-i386 .text 节的反汇编: 08048080 <.文本>: 8048080: ba 0e 00 00 00 mov $0xe,%edx 8048085:b9 a0 90 04 08 mov $0x80490a0,%ecx 804808a: bb 01 00 00 00 mov $0x1,%ebx 804808f: b8 04 00 00 00 mov $0x4,%eax 8048094:cd 80 int $0x80 8048096:b8 01 00 00 00 mov $0x1,%eax 804809b:cd 80 int $0x80
这和我们的跟踪输出之间的对应关系很容易观察到。
附加到正在运行的进程
如您所知,调试器还可以附加到已经运行的进程。现在您不会惊讶地发现这也是通过ptrace完成的,它可以获取PTRACE_ATTACH请求。我不会在这里展示代码示例,因为考虑到我们已经完成的代码,它应该很容易实现。出于教育目的,这里采用的方法更方便(因为我们可以在子进程开始时停止它)。
代码
本文中介绍的简单跟踪器的完整 C 源代码(更高级的指令打印版本)可在此处获取。它可以在gcc 4.4 版本上使用-Wall -pedantic --std=c99进行干净地编译。这部分内容并没有涉及太多内容——我们距离拥有真正的调试器还很远。然而,我希望它已经让调试过程至少不再那么神秘了。ptrace确实是一个多功能的系统调用,具有多种功能,到目前为止我们只采样了其中的一些。
单步执行代码很有用,但仅限于一定程度。采取 C “你好,世界!”我上面演示的示例。要进入main,可能需要执行数千条 C 运行时初始化代码指令。这不太方便。理想情况下,我们希望能够在main 的入口处放置一个断点,然后从那里开始执行。很公平,在本系列的下一部分中,我打算展示断点是如何实现的。
3.2断点
断点是调试的两个主要支柱之一,另一个支柱能够检查被调试进程内存中的值。我们已经在该系列的第 1 部分中看到了另一个支柱的预览,但断点仍然很神秘。到本文结束时,他们将不再是这样。
软件中断
为了在 x86 架构上实现断点,需要使用软件中断(也称为“陷阱”)。在深入讨论细节之前,我想先解释一下中断和陷阱的一般概念。
CPU 具有单个执行流,一条一条地执行指令[1]。为了处理 IO 和硬件定时器等异步事件,CPU 使用中断。硬件中断通常是附有特殊“响应电路”的专用电信号。该电路注意到中断的激活,并使CPU停止当前的执行,保存其状态,并跳转到中断处理程序例程所在的预定义地址。当处理程序完成其工作时,CPU 从停止处恢复执行。
软件中断在原理上类似,但在实践中有些不同。 CPU 支持允许软件模拟中断的特殊指令。当执行这样的指令时,CPU 将其视为中断 - 停止其正常执行流程,保存其状态并跳转到处理程序例程。这些“陷阱”使得现代操作系统的许多奇迹(任务调度、虚拟内存、内存保护、调试)得以有效实现。
一些编程错误(例如除以 0)也会被 CPU 视为陷阱,并且通常称为“异常”。这里硬件和软件之间的界限变得模糊,因为很难说这种异常是真正的硬件中断还是软件中断。但我已经离主题太远了,所以是时候回到断点了。
理论上int 3
写完上一节后,我现在可以简单地说,断点是通过一个名为int 3的特殊陷阱在 CPU 上实现的。int是 x86 术语,意为“陷阱指令”——调用预定义的中断处理程序。 x86支持int指令,其8位操作数指定发生的中断编号,因此理论上支持256个陷阱。前 32 个由 CPU 为其自身保留,而第 3 个是我们在这里感兴趣的 - 它称为“调试器陷阱”。
话不多说,我将引用圣经本身:
INT 3 指令生成一个特殊的单字节操作码 (CC),用于调用调试异常处理程序。 (这个单字节形式很有价值,因为它可以用来用断点替换任何指令的第一个字节,包括其他单字节指令,而无需覆盖其他代码)。
括号中的部分很重要,但现在解释还为时过早。我们将在本文后面讨论这个问题。
实践中的 int 3
是的,了解事物背后的理论固然很好,但这到底意味着什么呢?我们如何使用int 3来实现断点呢?或者解释一下常见的编程问答术语 -请告诉我代码!
实际上,这确实非常简单。一旦您的进程执行int 3指令,操作系统就会停止它。在 Linux 上(这是我们在本文中关注的内容),它会向进程发送一个信号 - SIGTRAP。
这就是全部内容——诚实!现在回想一下本系列的第一部分,跟踪(调试器)进程会收到其子进程(或其附加的用于调试的进程)获得的所有信号的通知,并且您可以开始了解我们要去的地方。
就这样,不再有计算机体系结构 101 jabber。现在是示例和代码的时候了。
手动设置断点
我现在将展示在程序中设置断点的代码。我将用于此演示的目标程序如下:
节.文本 ;必须为链接器声明 _start 符号 (ld) 全局_start _开始: ;为 sys_write 系统调用准备参数: ; - eax:系统调用号(sys_write) ; - ebx:文件描述符(标准输出) ; - ecx:指向字符串的指针 ; - edx:字符串长度 mov edx,只有 1 mov ecx, 消息1 移动 ebx, 1 移动 eax, 4 ;执行sys_write系统调用 整数0x80 ;现在打印另一条消息 移动edx,len2 mov ecx, 消息2 移动 ebx, 1 移动 eax, 4 整数0x80 ;执行sys_exit 移动eax, 1 整数0x80 .data 节 msg1 db '你好,',0xa len1 equ $ - msg1 msg2 db '世界!', 0xa len2 equ $ - msg2
我现在使用汇编语言,是为了避免我们进入 C 代码时出现的编译问题和符号。上面列出的程序所做的只是在一行上打印“Hello”,然后打印“world!”在下一行。它与上一篇文章中演示的程序非常相似。
我想在第一个打印输出之后、第二个打印输出之前设置一个断点。假设就在mov edx, len2指令上的第一个int 0x80 [4]之后。首先,我们需要知道该指令映射到什么地址。运行objdump -d:
Traced_printer2:文件格式 elf32-i386 部分: Algn 中的 Idx 名称大小 VMA LMA 文件 0.文本00000033 08048080 08048080 00000080 2**4 内容、分配、加载、只读、代码 1.数据0000000e 080490b4 080490b4 000000b4 2**2 内容、分配、加载、数据 .text 节的反汇编: 08048080 <.文本>: 8048080: ba 07 00 00 00 mov $0x7,%edx 8048085:b9 b4 90 04 08 mov $0x80490b4,%ecx 804808a: bb 01 00 00 00 mov $0x1,%ebx 804808f: b8 04 00 00 00 mov $0x4,%eax 8048094:cd 80 int $0x80 8048096: ba 07 00 00 00 mov $0x7,%edx 804809b: b9 bb 90 04 08 mov $0x80490bb,%ecx 80480a0: bb 01 00 00 00 移动 $0x1,%ebx 80480a5: b8 04 00 00 00 mov $0x4,%eax 80480aa:cd 80 int $0x80 80480ac: b8 01 00 00 00 mov $0x1,%eax 80480b1:cd 80 int $0x80
所以,我们要设置断点的地址是0x8048096。等等,这不是真正的调试器的工作方式,对吗?真正的调试器在代码行和函数上设置断点,而不是在某些裸内存地址上设置断点?非常正确。但我们距离目标还很远 - 要像真正的调试器一样设置断点,我们仍然必须首先介绍符号和调试信息,并且需要本系列中的另一部分或两部分来讨论这些主题。现在,我们必须处理裸内存地址。
说到这里我真的很想再跑题了,所以你有两个选择。如果您确实有兴趣了解为什么地址是 0x8048096 以及它的含义,请阅读下一节。如果没有,并且您只想继续处理断点,则可以安全地跳过它。
使用 int 3 在调试器中设置断点
要在跟踪进程中的某个目标地址处设置断点,调试器将执行以下操作:
- 记住目标地址存储的数据
- 将目标地址的第一个字节替换为 int 3 指令
然后,当调试器要求操作系统运行该进程(如我们在上一篇文章中看到的PTRACE_CONT)时,该进程将运行并最终遇到 int 3 ,在那里它将停止,操作系统将向其发送一个信号。这是调试器再次介入的地方,接收到其子进程(或跟踪进程)已停止的信号。然后它可以:
- 将目标地址处的int 3指令替换为原指令
- 将跟踪进程的指令指针回滚 1。这是必需的,因为指令指针现在指向int 3之后,并且已经执行了它。
- 允许用户以某种方式与进程交互,因为进程仍然在所需的目标地址处停止。这是调试器允许您查看变量值、调用堆栈等的部分。
- 当用户想要继续运行时,调试器将负责将断点放回目标地址(因为它在步骤 1 中被删除),除非用户要求取消断点。
让我们看看其中一些步骤如何转换为实际代码。我们将使用第 1 部分中介绍的调试器“模板”(分叉子进程并跟踪它)。无论如何,本文末尾有一个指向此示例的完整源代码的链接。
/* 获取并显示子进程的指令指针 */ ptrace(PTRACE_GETREGS, child_pid, 0 , ®s); procmsg( "子进程已启动。EIP = 0x%08x\n" , regs.eip); /* 查看我们感兴趣的地址处的字 */ unsigned addr = 0x8048096 ; 无符号数据 = ptrace(PTRACE_PEEKTEXT, child_pid, ( void *)addr, 0 ); procmsg( "原始数据位于 0x%08x: 0x%08x\n" , addr, data);
此处,调试器从跟踪的进程中获取指令指针,并检查当前位于 0x8048096 处的字。当运行跟踪本文开头列出的汇编程序时,将打印:
[13028] 孩子开始了。电子IP = 0x08048080 [13028] 0x08048096处的原始数据:0x000007ba
到目前为止,一切都很好。下一个:
/* 将陷阱指令 'int 3' 写入地址 */ unsigned data_with_trap = (data & 0xFFFFFF00 ) | 0xCC; ptrace(PTRACE_POKETEXT, child_pid, ( void *)addr, ( void *)data_with_trap); /* 再看看那里有什么... */ unsigned readback_data = ptrace(PTRACE_PEEKTEXT, child_pid, ( void *)addr, 0 ); procmsg( "陷阱后,数据位于 0x%08x: 0x%08x\n" , addr, readback_data);
注意int 3是如何插入到目标地址的。这打印:
[13028] 陷阱后,数据位于 0x08048096:0x000007cc
再次,正如预期的那样 - 0xba被替换为0xcc。调试器现在运行子进程并等待它在断点处停止:
/* 让子进程运行到断点并等待它 ** 到达它 */ ptrace(PTRACE_CONT, child_pid, 0 , 0 ); 等待(&等待状态); 如果(WIFSTOPPED(等待状态)){ procmsg( "孩子收到一个信号:%s\n" , strsignal(WSTOPSIG(wait_status))); } 否则{ perror( “等待” ); 返回; } /* 查看子进程现在在哪里 */ ptrace(PTRACE_GETREGS, child_pid, 0 , ®s); procmsg( "子进程停在 EIP = 0x%08x\n" , regs.eip);
这打印:
你好, [13028] 孩子收到信号:跟踪/断点陷阱 [13028] 子进程停止在 EIP = 0x08048097
请注意在断点之前打印的“Hello”——与我们计划的完全一样。另请注意子进程停止的位置 - 就在单字节陷阱指令之后。
最后,正如前面所解释的,为了让孩子继续奔跑,我们必须做一些工作。我们用原始指令替换陷阱,并让进程继续从它运行。
/* 通过恢复目标地址处之前的数据来移除断点 ,并将 EIP 回退 1,以 ** 让 CPU 执行那里的原始指令 。 */ ptrace(PTRACE_POKETEXT, child_pid, ( void *)addr, ( void *)data); regs.eip -= 1 ; ptrace(PTRACE_SETREGS, child_pid, 0 , ®s); /* 子进程现在可以继续运行 */ ptrace(PTRACE_CONT, child_pid, 0 , 0 );
这使得子打印出“世界!”并按计划退出。
请注意,我们在这里不恢复断点。这可以通过以单步模式执行原始指令,然后放回陷阱,然后才执行PTRACE_CONT来完成。本文后面演示的调试库实现了这一点。
有关 int 3 的更多信息
现在是回来检查int 3和英特尔手册中那个奇怪的注释的好时机。又是这样:
这种单字节形式很有价值,因为它可以用来用断点替换任何指令的第一个字节,包括其他单字节指令,而无需覆盖其他代码
x86 上的int指令占用两个字节 - 0xcd后跟中断号[6]。 int 3可以被编码为cd 03,但是有一个为其保留的特殊单字节指令 - 0xcc。
为什么这样?因为这允许我们插入断点而无需覆盖多个指令。这很重要。考虑这个示例代码:
..一些代码.. 富杰 十进制 富: 呼叫栏 ..一些代码..
假设我们想在dec eax上放置一个断点。这恰好是一条单字节指令(操作码为0x48)。如果替换断点指令的长度超过 1 个字节,我们将被迫覆盖下一条指令 ( call ) 的一部分,这会使其出现乱码,并可能产生完全无效的结果。但是jz foo 的分支是什么?然后, CPU不会在dec eax处停止,而是直接执行其后的无效指令。
对int 3使用特殊的 1 字节编码可以解决这个问题。由于 1 字节是 x86 上一条指令可以得到的最短指令,因此我们保证只有我们想要中断的指令才会改变。
封装一些血淋淋的细节
上一节的代码示例中显示的许多低级细节可以轻松封装在方便的 API 后面。我已经将一些封装到一个名为debuglib的小型实用程序库中- 它的代码可以在文章末尾下载。在这里,我只想演示一个其用法的示例,但有所不同。我们将跟踪用 C 编写的程序