跟踪 C 程序
到目前为止,为了简单起见,我主要关注汇编语言目标。现在是时候更上一层楼,看看我们如何跟踪用 C 编写的程序了。
事实证明,情况并没有太大不同 - 只是找到放置断点的位置有点困难。考虑这个简单的程序:
#include <stdio.h> 无效 do_stuff () { printf( “你好,” ); } int 主函数() { for ( int i = 0 ; i < 4 ; ++i) 做东西(); printf( "世界!\n" ); 返回 0; }
假设我想在do_stuff的入口处放置一个断点。我将使用老朋友objdump来反汇编可执行文件,但其中有很多内容。特别是,查看文本部分有点无用,因为它包含很多我目前不感兴趣的 C 运行时初始化代码。因此,让我们在转储中查找do_stuff :
080483e4 <do_stuff>: 80483e4: 55 推 %ebp 80483e5: 89 e5 mov %esp,%ebp 80483e7: 83 ec 18 子 $0x18,%esp 80483ea: c7 04 24 f0 84 04 08 movl $0x80484f0,(%esp) 80483f1: e8 22 ff ff ff 呼叫 8048318 <puts@plt> 80483f6:c9离开 80483f7:c3 ret
好吧,我们将断点放置在 0x080483e4 处,这是do_stuff的第一条指令。此外,由于该函数是在循环中调用的,因此我们希望一直在断点处停止,直到循环结束。我们将使用debuglib库来简化此操作。这是完整的调试器功能:
无效 run_debugger(pid_t child_pid) { procmsg( “调试器已启动\n” ); /* 等待子进程在执行第一条指令时停止 */ wait( 0 ); procmsg( "子进程现在的 EIP = 0x%08x\n" , get_child_eip(child_pid)); /* 创建断点并运行到它*/ debug_breakpoint* bp = create_breakpoint(child_pid, ( void *) 0x080483e4 ); procmsg( "已创建断点\n" ); ptrace(PTRACE_CONT, child_pid, 0 , 0 ); 等待(0); /* 只要子进程没有退出就循环 */ while ( 1 ) { /* 子进程在断点处停止。恢复其 ** 执行,直到退出或 再次遇到 ** 断点。 */ procmsg( "子进程在断点处停止。EIP = 0x%08X\n" , get_child_eip(child_pid)); procmsg( “正在恢复\n” ); int rc =resume_from_breakpoint(child_pid, bp); 如果(rc== 0){ procmsg( "子进程退出\n" ); 打破; } 否则 if (rc == 1 ) { 继续; } 否则{ procmsg( “意外:%d\n”,rc); 打破; } } cleanup_breakpoint(bp); }
我们不必亲自修改 EIP 和目标进程的内存空间,而只需使用create_breakpoint、resume_from_breakpoint和cleanup_breakpoint。让我们看看跟踪上面显示的简单 C 代码时会打印什么:
$ bp_use_lib traced_c_loop [13363] 调试器已启动 [13364] 目标开始。将运行“traced_c_loop” [13363] 孩子现在在 EIP = 0x00a37850 [13363] 断点已创建 [13363] 孩子停在断点处。电子IP = 0x080483E5 [13363] 恢复 你好, [13363] 孩子停在断点处。电子IP = 0x080483E5 [13363] 恢复 你好, [13363] 孩子停在断点处。电子IP = 0x080483E5 [13363] 恢复 你好, [13363] 孩子停在断点处。电子IP = 0x080483E5 [13363] 恢复 你好, 世界! [13363] 孩子退出了
如预期的那样!
代码
这是这部分的完整源代码文件。在档案中您会发现:
- debuglib.h 和 debuglib.c - 用于封装调试器的一些内部工作的简单库
- bp_manual.c - 本文首先介绍的设置断点的“手动”方式。将debuglib库用于某些样板代码。
- bp_use_lib.c - 将debuglib用于其大部分代码,如用于跟踪 C 程序中的循环的第二个代码示例中所示。
我们已经介绍了如何在调试器中实现断点。虽然不同操作系统的实现细节有所不同,但当您使用 x86 时,它基本上都是同一主题的变体 - 将int 3替换为我们希望进程停止的指令。
也就是说,我确信有些读者,就像我一样,对于指定要中断的原始内存地址不会感到兴奋。我们想说“在do_stuff上中断”,甚至“在do_stuff中的这一行上中断”并让调试器执行此操作。
3.3调试信息
现代编译器可以很好地将高级代码(具有良好的缩进和嵌套控制结构以及任意类型的变量)转换为一大堆称为机器代码的位,其唯一目的是在计算机上尽可能快地运行目标CPU。大多数 C 代码行都会被转换成多个机器代码指令。变量被塞到各处——堆栈中、寄存器中,或者完全优化掉。结构和对象甚至不存在于生成的代码中——它们只是一个抽象,被转换为硬编码的偏移量到内存缓冲区中。
那么,当您要求调试器在某个函数的入口处中断时,调试器如何知道在哪里停止呢?当您向它询问变量的值时,它如何设法找到要显示的内容?答案是——调试信息。
调试信息由编译器与机器代码一起生成。它是可执行程序与原始源代码之间关系的表示。该信息被编码为预定义的格式并与机器代码一起存储。多年来,针对不同平台和可执行文件发明了许多此类格式。由于本文的目的不是调查这些格式的历史,而是展示它们的工作原理,因此我们必须做出一些决定。这个东西就是 DWARF,它现在几乎普遍用作 Linux 和其他 Unix-y 平台上的 ELF 可执行文件的调试信息格式。
ELF中的矮人
根据其维基百科页面,DWARF 是与 ELF 一起设计的,尽管理论上它也可以嵌入其他目标文件格式中[1]。
DWARF 是一种复杂的格式,建立在针对各种体系结构和操作系统的先前格式的多年经验之上。它必须很复杂,因为它解决了一个非常棘手的问题 - 将任何高级语言的调试信息呈现给调试器,提供对任意平台和 ABI 的支持。要充分解释它,需要的不仅仅是这篇不起眼的文章,而且说实话,我对它所有的阴暗角落都没有足够的了解,无论如何也无法参与这样的努力。在本文中,我将采取更实际的方法,展示足够多的 DWARF 来解释调试信息在实际中是如何工作的。
ELF 文件中的调试部分
首先让我们看一下 DWARF 信息在 ELF 文件中的位置。 ELF 定义了每个目标文件中可能存在的任意部分。节头表定义存在哪些节及其名称。不同的工具以特殊的方式处理不同的部分 - 例如链接器正在寻找某些部分,调试器则寻找其他部分。
我们将使用从此 C 源代码构建的可执行文件进行本文中的实验,并将其编译为tracedprog2:
#include <stdio.h> 无效 do_stuff ( int my_arg) { int my_local = my_arg + 2 ; 整数我; for (i = 0 ; i < my_local; ++i) printf( "i = %d\n" , i); } int 主函数() { do_stuff( 2 ); 返回 0; }
使用objdump-h从 ELF 可执行文件中转储节头,我们会注意到几个名称以.debug_开头的节- 这些是 DWARF 调试节:
26.debug_aranges 00000020 00000000 00000000 00001037 内容,只读,调试 27.debug_pubnames 00000028 00000000 00000000 00001057 内容,只读,调试 28.debug_info 000000cc 00000000 00000000 0000107f 内容,只读,调试 29.debug_abbrev 0000008a 00000000 00000000 0000114b 内容,只读,调试 30.debug_line 0000006b 00000000 00000000 000011d5 内容,只读,调试 31.debug_frame 00000044 00000000 00000000 00001240 内容,只读,调试 32.debug_str 000000ae 00000000 00000000 00001284 内容,只读,调试 33.debug_loc 00000058 00000000 00000000 00001332 内容,只读,调试
这里每个部分看到的第一个数字是它的大小,最后一个数字是它在 ELF 文件中开始的偏移量。调试器使用此信息从可执行文件中读取该部分。现在让我们看一些在 DWARF 中查找有用调试信息的实际示例。
寻找函数
调试时我们要做的最基本的事情之一就是在某个函数处放置断点,期望调试器在其入口处中断。为了能够执行此功能,调试器必须在高级代码中的函数名称与机器代码中该函数的指令开始的地址之间具有某种映射。
可以通过查看.debug_info部分从 DWARF 获取此信息。在我们进一步讨论之前,先介绍一些背景知识。 DWARF 中的基本描述实体称为调试信息条目 (DIE)。每个 DIE 都有一个标签 - 它的类型和一组属性。 DIE 通过兄弟链接和子链接相互链接,并且属性值可以指向其他 DIE。
让我们运行:
objdump --dwarf=info tracedprog2
输出相当长,对于这个例子,我们只关注这些行:
<1><71>:缩写编号:5(DW_TAG_子程序) <72> DW_AT_外部:1 <73> DW_AT_name : (...): do_stuff <77> DW_AT_decl_file:1 <78> DW_AT_decl_line:4 <79> DW_AT_原型:1 <7a> DW_AT_low_pc:0x8048604 <7e> DW_AT_high_pc:0x804863e <82> DW_AT_frame_base : 0x0(位置列表) <86> DW_AT_sibling:<0xb3> <1><b3>:缩写编号:9(DW_TAG_子程序) <b4> DW_AT_external:1 <b5> DW_AT_name : (...): 主要 <b9> DW_AT_decl_file:1 <ba> DW_AT_decl_line : 14 <bb> DW_AT_type : <0x4b> <bf> DW_AT_low_pc:0x804863e <c3> DW_AT_high_pc:0x804865a <c7> DW_AT_frame_base : 0x2c(位置列表)
有两个条目(DIE)标记为DW_TAG_subprogram,这是 DWARF 行话中的一个函数。请注意,有一个do_stuff条目和一个main条目。有几个有趣的属性,但我们感兴趣的是DW_AT_low_pc。这是函数开始处的程序计数器( x86 中的EIP)值。请注意,do_stuff的值为0x8048604。现在让我们通过运行objdump-d来看看该地址在可执行文件的反汇编中是什么:
08048604 <do_stuff>: 8048604:55推ebp 8048605: 89 e5 mov ebp,esp 8048607: 83 ec 28 子 esp,0x28 804860a: 8b 45 08 mov eax,DWORD PTR [ebp+0x8] 804860d: 83 c0 02 添加 eax,0x2 8048610: 89 45 f4 mov DWORD PTR [ebp-0xc],eax 8048613: c7 45 (...) mov DWORD PTR [ebp-0x10],0x0 804861a: eb 18 jmp 8048634 <do_stuff+0x30> 804861c: b8 20 (...) mov eax,0x8048720 8048621:8b 55 f0 mov edx,DWORD PTR [ebp-0x10] 8048624: 89 54 24 04 mov DWORD PTR [esp+0x4],edx 8048628: 89 04 24 mov DWORD PTR [esp],eax 804862b: e8 04 (...) 调用 8048534 <printf@plt> 8048630: 83 45 f0 01 添加 DWORD PTR [ebp-0x10],0x1 8048634:8b 45 f0 mov eax,DWORD PTR [ebp-0x10] 8048637:3b 45 f4 cmp eax,DWORD PTR [ebp-0xc] 804863a: 7c e0 jl 804861c <do_stuff+0x18> 804863c:c9 离开 804863d:c3 右
事实上,0x8048604是do_stuff的开头,因此调试器可以在函数及其在可执行文件中的位置之间建立映射。
寻找变量
假设我们确实停在do_stuff内的断点处。我们想让调试器向我们显示my_local变量的值。它怎么知道在哪里可以找到它?事实证明,这比查找函数要棘手得多。变量可以位于全局存储中、堆栈中,甚至寄存器中。此外,具有相同名称的变量在不同的词法作用域中可以具有不同的值。调试信息必须能够反映所有这些变化,DWARF 确实做到了。
我不会涵盖所有可能性,但作为示例,我将演示调试器如何在do_stuff中找到my_local。让我们从.debug_info开始,再次查看do_stuff的条目,这次还查看它的几个子条目:
<1><71>:缩写编号:5(DW_TAG_子程序) <72> DW_AT_外部:1 <73> DW_AT_name : (...): do_stuff <77> DW_AT_decl_file:1 <78> DW_AT_decl_line:4 <79> DW_AT_原型:1 <7a> DW_AT_low_pc:0x8048604 <7e> DW_AT_high_pc:0x804863e <82> DW_AT_frame_base : 0x0(位置列表) <86> DW_AT_sibling:<0xb3> <2><8a>:缩写编号:6(DW_TAG_formal_parameter) <8b> DW_AT_name : (...): my_arg <8f> DW_AT_decl_file:1 <90> DW_AT_decl_line:4 <91> DW_AT_类型:<0x4b> <95> DW_AT_位置:(...)(DW_OP_fbreg:0) <2><98>:缩写编号:7 (DW_TAG_variable) <99> DW_AT_name : (...): my_local <9d> DW_AT_decl_file:1 <9e> DW_AT_decl_line : 6 <9f> DW_AT_type:<0x4b> <a3> DW_AT_location : (...) (DW_OP_fbreg: -20) <2><a6>:缩写编号:8 (DW_TAG_variable) <a7> DW_AT_名称:i <a9> DW_AT_decl_file:1 <aa> DW_AT_decl_line : 7 <ab> DW_AT_type : <0x4b> <af> DW_AT_location : (...) (DW_OP_fbreg: -24)
请注意每个条目中尖括号内的第一个数字。这是嵌套级别 - 在此示例中,带有<2> 的条目是带有<1>的条目的子项。所以我们知道变量my_local(由DW_TAG_variable标签标记)是do_stuff函数的子函数。调试器还对变量的类型感兴趣,以便能够正确显示它。在my_local的情况下,类型指向另一个 DIE - <0x4b>。如果我们在objdump的输出中查找它,我们会看到它是一个带符号的 4 字节整数。
为了在执行进程的内存映像中实际定位变量,调试器将查看DW_AT_location属性。对于my_local,它显示DW_OP_fbreg: -20。这意味着该变量存储在距其包含函数的DW_AT_frame_base属性的偏移量 -20 处 - 这是该函数的框架的基础。
do_stuff的DW_AT_frame_base属性的值为0x0 (位置列表),这意味着该值实际上必须在位置列表部分中查找。我们来看一下:
$ objdump --dwarf=loc Tracedprog2 tracedprog2:文件格式 elf32-i386 .debug_loc 部分的内容: 偏移开始结束表达式 00000000 08048604 08048605 (DW_OP_breg4: 4 ) 00000000 08048605 08048607 (DW_OP_breg4: 8 ) 00000000 08048607 0804863e (DW_OP_breg5:8) 00000000 <列表结束> 0000002c 0804863e 0804863f (DW_OP_breg4: 4 ) 0000002c 0804863f 08048641(DW_OP_breg4:8) 0000002c 08048641 0804865a(DW_OP_breg5:8) 0000002c <列表结束>
我们感兴趣的位置信息是第一个[4]。对于调试器所在的每个地址,它指定当前帧基址,从该基址计算变量的偏移量作为寄存器的偏移量。对于 x86,bpreg4指esp,bpreg5指ebp。
再次查看do_stuff的前几条指令是有教育意义的:
08048604 <do_stuff>: 8048604:55推ebp 8048605: 89 e5 mov ebp,esp 8048607: 83 ec 28 子 esp,0x28 804860a: 8b 45 08 mov eax,DWORD PTR [ebp+0x8] 804860d: 83 c0 02 添加 eax,0x2 8048610: 89 45 f4 mov DWORD PTR [ebp-0xc],eax
请注意,ebp仅在执行第二条指令后才变得相关,实际上,对于前两个地址,基址是根据上面列出的位置信息中的esp计算的。一旦ebp有效,就可以方便地计算相对于它的偏移量,因为它保持不变,而esp随着数据从堆栈中压入和弹出而不断移动。
那么my_local给我们带来了什么呢?我们只对0x8048610指令之后的值感兴趣(在eax中计算后,它的值被放置在内存中),因此调试器将使用DW_OP_breg5: 8帧基数来查找它。现在是时候回顾一下my_local的DW_AT_location属性 为DW_OP_fbreg: -20。让我们计算一下:距框架基数 -20,即ebp + 8。我们得到ebp - 12。现在再次查看反汇编并注意数据从eax移至何处 - 事实上,ebp - 12是my_local的存储位置。
查找行号
当我们谈到在调试信息中查找函数时,我有点作弊。当我们调试 C 源代码并在函数中放置断点时,我们通常对第一条机器代码指令不感兴趣[5]。我们真正感兴趣的是该函数的第一行C 代码行。
这就是为什么 DWARF 对 C 源代码中的行和可执行文件中的机器代码地址之间的完整映射进行编码。此信息包含在.debug_line部分中,可以以可读形式提取,如下所示:
$ objdump --dwarf=decodedline tracedprog2 tracedprog2:文件格式 elf32-i386 .debug_line 部分调试内容的解码转储: CU:/home/eliben/eli/eliben-code/debugger/tracedprog2.c: 文件名 行号 起始地址 跟踪prog2.c 5 0x8048604 跟踪prog2.c 6 0x804860a 跟踪prog2.c 9 0x8048613 跟踪prog2.c 10 0x804861c 跟踪prog2.c 9 0x8048630 追踪prog2.c 11 0x804863c 跟踪prog2.c 15 0x804863e 追踪prog2.c 16 0x8048647 追踪prog2.c 17 0x8048653 追踪prog2.c 18 0x8048658
不难看出这些信息、C 源代码和反汇编转储之间的对应关系。第 5 行指向do_stuff - 0x8040604的入口点。下一行 6 是调试器在被要求中断do_stuff时真正应该停止的地方,它指向0x804860a,即函数序言后面的位置。此线路信息可以轻松实现线路和地址之间的双向映射:
- 当要求在某一行放置断点时,调试器将使用它来查找应该将陷阱放置在哪个地址(还记得上一篇文章中我们的朋友int 3吗?)
- 当指令导致分段错误时,调试器将使用它来查找发生该错误的源代码行。
libdwarf - 以编程方式使用 DWARF
使用命令行工具访问 DWARF 信息虽然有用,但并不完全令人满意。作为程序员,我们想知道如何编写可以读取格式并从中提取我们需要的内容的实际代码。
当然,一种方法是获取 DWARF 规范并开始破解。现在,还记得每个人都说你永远不应该手动解析 HTML 而应该使用库吗?嗯,对于 DWARF 来说情况更糟。 DWARF比 HTML 复杂得多。我在这里展示的只是冰山一角,让事情变得更加困难的是,大部分信息都以非常紧凑和压缩的方式编码在实际的目标文件中[6]。
因此,我们将采取另一条路并使用库来与 DWARF 一起工作。我知道有两个主要的库(加上一些不太完整的库):
- BFD ( libbfd ) 由GNU binutils使用,包括在本文中发挥重要作用的objdump 、 ld(GNU 链接器)和as(GNU 汇编器)。
- libdwarf - 与其老大哥libelf一起用于 Solaris 和 FreeBSD 操作系统上的工具。
我选择libdwarf而不是 BFD,因为它对我来说似乎不那么神秘,而且它的许可证更自由(LGPL与GPL)。
由于libdwarf本身相当复杂,因此需要大量代码来操作。我不会在这里展示所有这些代码,但您可以自己下载并运行它。要编译此文件,您需要安装libelf和libdwarf,并将-lelf和-ldwarf标志传递给链接器。
演示的程序采用可执行文件并打印其中的函数名称及其入口点。以下是它为我们在本文中使用的 C 程序生成的结果:
$ dwarf_get_func_addr 追踪prog2 DW_TAG_子程序:'do_stuff' 低电脑:0x08048604 高电脑:0x0804863e DW_TAG_子程序:'主' 低电脑:0x0804863e 高电脑:0x0804865a
libdwarf的文档(链接在本文的参考资料部分)非常好,并且通过一些努力,您应该可以毫无问题地使用它从 DWARF 部分中提取本文中演示的任何其他信息。
调试信息原则上是一个简单的概念。实现细节可能很复杂,但最终重要的是我们现在知道调试器如何找到它所需的有关编译其跟踪的可执行文件的原始源代码的信息。有了这些信息,调试器就在用户的世界(根据代码行和数据结构进行思考)和可执行文件的世界(只是寄存器和内存中的一堆机器代码指令和数据)之间建立了桥梁。
四、ptrace实现原理
本文使用的 Linux 2.4.16 版本的内核,看懂本文需要的基础:进程调度,内存管理和信号处理相关知识。
调用 ptrace() 系统函数时会触发调用内核的 sys_ptrace() 函数,由于不同的 CPU 架构有着不同的调试方式,所以 Linux 为每种不同的 CPU 架构实现了不同的 sys_ptrace() 函数,而本文主要介绍的是 X86 CPU 的调试方式,所以 sys_ptrace() 函数所在文件是 linux-2.4.16/arch/i386/kernel/ptrace.c。
sys_ptrace() 函数的主体是一个 switch 语句,会传入的 request 参数不同进行不同的操作,如下:
asmlinkage int sys_ptrace(long request, long pid, long addr, long data) { struct task_struct *child; struct user *dummy = NULL; int i, ret; ... read_lock(&tasklist_lock); child = find_task_by_pid(pid); // 获取 pid 对应的进程 task_struct 对象 if (child) get_task_struct(child); read_unlock(&tasklist_lock); if (!child) goto out; if (request == PTRACE_ATTACH) { ret = ptrace_attach(child); goto out_tsk; } ... switch (request) { case PTRACE_PEEKTEXT: case PTRACE_PEEKDATA: ... case PTRACE_PEEKUSR: ... case PTRACE_POKETEXT: case PTRACE_POKEDATA: ... case PTRACE_POKEUSR: ... case PTRACE_SYSCALL: case PTRACE_CONT: ... case PTRACE_KILL: ... case PTRACE_SINGLESTEP: ... case PTRACE_DETACH: ... } out_tsk: free_task_struct(child); out: unlock_kernel(); return ret; }
从上面的代码可以看出,sys_ptrace() 函数首先根据进程的 pid 获取到进程的 task_struct 对象。然后根据传入不同的 request 参数在 switch 语句中进行不同的操作。
ptrace() 支持的所有 request 操作定义在 linux-2.4.16/include/linux/ptrace.h 文件中,如下:
#define PTRACE_TRACEME 0 #define PTRACE_PEEKTEXT 1 #define PTRACE_PEEKDATA 2 #define PTRACE_PEEKUSR 3 #define PTRACE_POKETEXT 4 #define PTRACE_POKEDATA 5 #define PTRACE_POKEUSR 6 #define PTRACE_CONT 7 #define PTRACE_KILL 8 #define PTRACE_SINGLESTEP 9 #define PTRACE_ATTACH 0x10 #define PTRACE_DETACH 0x11 #define PTRACE_SYSCALL 24 #define PTRACE_GETREGS 12 #define PTRACE_SETREGS 13 #define PTRACE_GETFPREGS 14 #define PTRACE_SETFPREGS 15 #define PTRACE_GETFPXREGS 18 #define PTRACE_SETFPXREGS 19 #define PTRACE_SETOPTIONS 21
由于 ptrace() 提供的操作比较多,所以本文只会挑选一些比较有代表性的操作进行解说,比如 PTRACE_TRACEME、PTRACE_SINGLESTEP、PTRACE_PEEKTEXT、PTRACE_PEEKDATA 和 PTRACE_CONT 等,而其他的操作,有兴趣的朋友可以自己去分析其实现原理。
进入被追踪模式(PTRACE_TRACEME操作)
当要调试一个进程时,需要使进程进入被追踪模式,怎么使进程进入被追踪模式呢?有两个方法:
- 被调试的进程调用 ptrace(PTRACE_TRACEME, ...) 来使自己进入被追踪模式。
- 调试进程(如GDB)调用 ptrace(PTRACE_ATTACH, pid, ...) 来使指定的进程进入被追踪模式。
第一种方式是进程自己主动进入被追踪模式,而第二种是进程被动进入被追踪模式。
被调试的进程必须进入被追踪模式才能进行调试,因为 Linux 会对被追踪的进程进行一些特殊的处理。下面我们主要介绍第一种进入被追踪模式的实现,就是 PTRACE_TRACEME 的操作过程,代码如下:
asmlinkage int sys_ptrace(long request, long pid, long addr, long data) { ... if (request == PTRACE_TRACEME) { if (current->ptrace & PT_PTRACED) goto out; current->ptrace |= PT_PTRACED; // 标志 PTRACE 状态 ret = 0; goto out; } ... }
从上面的代码可以发现,ptrace() 对 PTRACE_TRACEME 的处理就是把当前进程标志为 PTRACE 状态。
当然事情不会这么简单,因为当一个进程被标记为 PTRACE 状态后,当调用 exec() 函数去执行一个外部程序时,将会暂停当前进程的运行,并且发送一个 SIGCHLD 给父进程。父进程接收到 SIGCHLD 信号后就可以对被调试的进程进行调试。
我们来看看 exec() 函数是怎样实现上述功能的,exec() 函数的执行过程为 sys_execve() -> do_execve() -> load_elf_binary():
static int load_elf_binary(struct linux_binprm * bprm, struct pt_regs * regs) { ... if (current->ptrace & PT_PTRACED) send_sig(SIGTRAP, current, 0); ... }
从上面代码可以看出,当进程被标记为 PTRACE 状态时,执行 exec() 函数后便会发送一个 SIGTRAP 的信号给当前进程。
我们再来看看,进程是怎么处理 SIGTRAP 信号的。信号是通过 do_signal() 函数进行处理的,而对 SIGTRAP 信号的处理逻辑如下:
nt do_signal(struct pt_regs *regs, sigset_t *oldset) { for (;;) { unsigned long signr; spin_lock_irq(¤t->sigmask_lock); signr = dequeue_signal(¤t->blocked, &info); spin_unlock_irq(¤t->sigmask_lock); // 如果进程被标记为 PTRACE 状态 if ((current->ptrace & PT_PTRACED) && signr != SIGKILL) { /* 让调试器运行 */ current->exit_code = signr; current->state = TASK_STOPPED; // 让自己进入停止运行状态 notify_parent(current, SIGCHLD); // 发送 SIGCHLD 信号给父进程 schedule(); // 让出CPU的执行权限 ... } } }
里面的代码主要做了3件事:
- 如果当前进程被标记为 PTRACE 状态,那么就使自己进入停止运行状态。
- 发送 SIGCHLD 信号给父进程。
- 让出 CPU 的执行权限,使 CPU 执行其他进程。
执行以上过程后,被追踪进程便进入了调试模式,过程如下图:
父进程(调试进程)接收到 SIGCHLD 信号后,表示被调试进程已经标记为被追踪状态并且停止运行,那么调试进程就可以开始进行调试了。
获取被调试进程的内存数据(PTRACE_PEEKTEXT / PTRACE_PEEKDATA)
调试进程(如GDB)可以通过调用 ptrace(PTRACE_PEEKDATA, pid, addr, data) 来获取被调试进程 addr 处虚拟内存地址的数据,但每次只能读取一个大小为 4字节的数据。
我们来看看 ptrace() 对 PTRACE_PEEKDATA 操作的处理过程,代码如下:
asmlinkage int sys_ptrace(long request, long pid, long addr, long data) { ... switch (request) { case PTRACE_PEEKTEXT: case PTRACE_PEEKDATA: { unsigned long tmp; int copied; copied = access_process_vm(child, addr, &tmp, sizeof(tmp), 0); ret = -EIO; if (copied != sizeof(tmp)) break; ret = put_user(tmp, (unsigned long *)data); break; } ... }
从上面代码可以看出,对 PTRACE_PEEKTEXT
和 PTRACE_PEEKDATA
的处理是相同的,主要是通过调用 access_process_vm()
函数来读取被调试进程 addr
处的虚拟内存地址的数据。
access_process_vm()
函数的实现主要涉及到 内存管理
相关的知识,可以参考我以前对内存管理分析的文章,这里主要大概说明一下 access_process_vm()
的原理。
我们知道每个进程都有个 mm_struct
的内存管理对象,而 mm_struct
对象有个表示虚拟内存与物理内存映射关系的页目录的指针 pgd
。如下
struct mm_struct { ... pgd_t *pgd; /* 页目录指针 */ ... }
而 access_process_vm()
函数就是通过进程的页目录来找到 addr
虚拟内存地址映射的物理内存地址,然后把此物理内存地址处的数据复制到 data
变量中。如下图所示:
access_process_vm() 函数的实现这里就不分析了,有兴趣的读者可以参考我之前对内存管理分析的文章自行进行分析。
单步调试模式(PTRACE_SINGLESTEP)
单步调试是一个比较有趣的功能,当把被调试进程设置为单步调试模式后,被调试进程没执行一条CPU指令都会停止执行,并且向父进程(调试进程)发送一个 SIGCHLD 信号。
我们来看看 ptrace() 函数对 PTRACE_SINGLESTEP 操作的处理过程,代码如下:
asmlinkage int sys_ptrace(long request, long pid, long addr, long data) { ... switch (request) { case PTRACE_SINGLESTEP: { /* set the trap flag. */ long tmp; ... tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG; put_stack_long(child, EFL_OFFSET, tmp); child->exit_code = data; /* give it a chance to run. */ wake_up_process(child); ret = 0; break; } ... }
要把被调试的进程设置为单步调试模式,英特尔的 X86 CPU 提供了一个硬件的机制,就是通过把 eflags
寄存器的 Trap Flag
设置为1即可。
当把 eflags
寄存器的 Trap Flag
设置为1后,CPU 每执行一条指令便会产生一个异常,然后会触发 Linux 的异常处理,Linux 便会发送一个 SIGTRAP
信号给被调试的进程。eflags
寄存器的各个标志如下图:
从上图可知,eflags
寄存器的第8位就是单步调试模式的标志。
所以 ptrace()
函数的以下2行代码就是设置 eflags
进程的单步调试标志:
tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG; put_stack_long(child, EFL_OFFSET, tmp);
而 get_stack_long(proccess, offset) 函数用于获取进程栈 offset 处的值,而 EFL_OFFSET 偏移量就是 eflags 寄存器的值。所以上面两行代码的意思就是:
- 获取进程的
eflags
寄存器的值,并且设置Trap Flag
标志。 - 把新的值设置到进程的
eflags
寄存器中。
设置完 eflags
寄存器的值后,就调用 wake_up_process()
函数把被调试的进程唤醒,让其进入运行状态。单步调试过程如下图:
处于单步调试模式时,被调试进程每执行一条指令都会触发一次 SIGTRAP 信号,而被调试进程处理 SIGTRAP 信号时会发送一个 SIGCHLD 信号给父进程(调试进程),并且让自己停止执行。
而父进程(调试进程)接收到 SIGCHLD 后,就可以对被调试的进程进行各种操作,比如读取被调试进程内存的数据和寄存器的数据,或者通过调用 ptrace(PTRACE_CONT, child,...) 来让被调试进程进行运行等。
由于 ptrace() 的功能十分强大,所以本文只能抛砖引玉,没能对其所有功能进行分析。另外断点功能并不是通过 ptrace() 函数实现的,而是通过 int3 指令来实现的,在 Eli Bendersky 大神的文章有介绍。而对于 ptrace() 的所有功能,只能读者自己慢慢看代码来体会了。