当某个进程崩溃时,日志文件(/var/log/messages)中就会给出附加的信息,包括程序终止原因、故障地址,以及包含程序状态字(PSW)、通用寄存器和访问寄存器的简要寄存器转储。
图 1表明程序(名为“simple”)以一个程序中断代码 0x10 终止(操作系统原理表明这是一个段转换错误),而故障地址为 0。毫无疑问,有人使用了空指针。现在我们知道发生了什么,下面需要弄清它发生在何处。
User Debug 日志条目所提供的信息可用于确定程序的崩溃位置。一些可用的工具可帮助解决您可能会遇到的各种程序终止问题。我们将在本文中逐步介绍那些工具。
首先,让我们检查一下该日志条目中的用户 PSW。该 PSW 包含指令地址、状态码以及关于机器状态的其他信息。眼下,我们仅关心指令地址(第 33 至第 63 位)。为简化起见,让我们假设用户 PSW 是 070dc000 80400618。记住,我们是在考察一个 ESA/390(31 位寻址)PSW。第 32 位不是指令地址的一部分,它是指示 31 位寻址模式的标志,但是在研究 PSW 值时必须处理它。为了获得实际的指令指针,可把 PSW 的第二个字减去 0x80000000。结果是一个指令地址 0x400618。为了定位代码,您需要可执行文件中的一些信息。
首先使用 readelf 来打印一些程序头信息。图 2显示了 readelf -l simple 的结果(记住“simple”是我们的测试程序的名称)。在 Program Headers 部分,第一个 LOAD 行提供了关于程序从哪里加载的信息。在 Flg 列,该段被标记为 R(read)E(executable)。VirtAddr 是程序开始加载的地址。MemSiz 是正在被加载到这个段中的代码长度。把它加到 VirtAddr 上,这个程序的基本地址范围就是 0x400000-0x400990。程序发生崩溃的指令地址为 0x400618,在程序的加载范围之内。现在我们知道了问题直接发生在代码中。
如果可执行文件包括调试符号,那么确定哪一行代码导致了问题是可以做到的。对该地址和可执行文件使用 addr2line 程序,如下所示:
addr2line -e simple 0x400618 |
将返回:
/home/devuser/simple.c:34 |
要研究该问题,可以检查第 34 行。
对于 图 1中原始的程序崩溃,PSW 为 070dc000 c00ab738。要获得指令地址,可减去 0x80000000。结果为 0x400ab738。这个地址并不准确地落在我们的小程序之内。那么,它是什么呢?是来自共享库的代码。如果对可执行文件运行 ldd 命令(ldd simple),将会返回程序运行所需的共享对象的列表,以及该库在那里可用的地址。
libc.so.6 => /lib/libc.so.6 (0x40021000) |
该指令地址对应于加载 libc.so.6 的地址。在我们的简单测试案例中,只需要两个共享对象。其他应用程序可能需要更多共享对象,这使得 ldd 的输出更加复杂。我们将以 perl 作为例子。 输入:
ldd /usr/bin/perl |
将得到:
libnsl.so.1 => /lib/libnsl.so.1 (0x40021000) |
所需要的一切都在那里了,但是我发现对于这个进程,下面的内容读起来更快一点:
ldd /usr/bin/perl | awk '{print? $4 "" $3 }' | sort |
(0x40000000) /lib/ld.so.1 |
现在我们来确定崩溃发生在 libc 中的何处。假设 libc.so.6 的加载地址是 0x40021000,从指令地址 0x400ab738 减去它,结果为 0x8a738。这是进入 libc.so.6 的偏移。
使用 nm 命令,从 libc.so.6 转储符号,然后尝试确定该地址位于哪个函数中。对于 libc.so.6,nm 将生成 7,000 多行输出。通过对计算得出的偏移部分执行 grep(正则表达式查找程序)可以削减必须检查的数据量。输入:
nm /lib/libc.so.6 | sort | grep 0008a |
将返回 66 行,在该输出的中间,我们会发现:
0008a6fc T memcpy |
该偏移落在 memcpy 中的某个位置。在此例中,一个空指针被当作目标地址传递给了 memcpy。我们在何处调用的 memcpy 呢?问得好。我们可以通过检查输出在日志文件中的寄存器转储来确定目标区域。寄存器 14 包含执行某个函数调用时的返回地址。根据 图 1,R14 是 0x8040066e,它在截去高位之后产生一个地址 0x40066e。这个地址落在我们的程序范围之内,因此可以运行 addr2line 来确定该地址在何处。输入:
addr2line -e simple 0x40066e |
将返回:
/home/devuser/simple.c:36 |
这是我们调用 memcpy 之后的那一行。关于 addr2line 的一点补充:如果可执行文件中没有包括调试符号,您将获得 --:0 作为响应。
最后,set args 为程序设置命令行参数。您也可以在执行 run 时指定命令行参数,但是 set args 将使参数在 run 的多次执行中都有效。
当程序意外地终止时,内核会尝试产生一个核心文件,以图判断发生了什么错误。然而,核心文件通常不是在默认设置值下产生的。这可以使用 ulimit 命令来改变。ulimit -c unlimited 帮助确保您获得应用程序的完整核心文件。
虽然核心文件当前仅提供多线程应用程序中的有限的值,不过 2.5 版的开发内核已开始处理这个问题。预计 2.6 版的内核中会提供一些理想的线程改进。
图 2突出显示了一系列便利的 post mortem 命令。 图 3简要显示了一个核心程序的完整运行过程。同样,我们使用了 simple 程序。 但不是手动加载程序和核心文件,而是从命令行调入:
gdb simple core |
在加载符号之后,gdb 将指出程序在何处终止。注意当前帧 #0 包含前一节中计算的地址。gdb 将在 31 位系统上截去高位,仅显示指令地址。 还要注意帧 #1 包含 gpr14 中的返回地址。
接着往下看,i f 提供了关于当前堆栈帧的信息。在堆栈帧中往上移到 main,这就是我们离开该帧的地方(即调用 memcpy 的地方)。简单的 i locals 提供了传递给 memcpy 的变量的值,其中一个变量 boink.boik 的值为 0x0。使用 ptype 来检查变量类型,这样将确认它是一个整型指针,并且如果目的是为了拷贝内容到其中,它就不应该是 0x0。最后一个选项是使用 print,通过一个星号(*)来解除指针引用,以便接收值。
![]() ![]() |
![]()
|
先前,我曾提到当您在源代码级调试优化过的代码时,gdb 可能变得有点棘手。编译器优化一些代码的执行顺序以最大化性能。 图 4显示了这样一个例子。您可以看到行号如何从 32 切换到 30 然后又切换回 32。
如何处理这种情况呢?使用 si 和 ni(next instruction;它类似 si,但是会跳过子例程调用)将非常有帮助。 在这个层次上,很好理解 zArchitecture 是有所帮助的。
图 5显示了为调试而对程序进行的设置。首先在 main()的地址处设置一个断点,然后设置一个 display。display 是一个表达式,它在每次代码停止执行时打印有关信息。在此例中,display 被设置为显示当前指令地址处的指令。/i 是打印为反汇编代码的格式,而当前指令指针在值/寄存器(value/register)$pswa 中。
单步调试代码,可以明显看出每条机器指令都与一行 c 代码相关联。 前四行与第 27 行(即函数 main 的开头)相关联。 前四行是典型的函数引入操作,它们保存寄存器、堆栈指针并调整堆栈。当关联的行号变为 32 时,我们就设置好了对 do_one_thing() 的函数调用。
当 display 在工作时,它显示 x /i 作为实际数据显示之前的命令。x 是检查内存的命令。/i 是以指令格式来格式化;/x 将以 16 进制格式来格式化;而 /a 将以 16 进制来格式化。然而,您应该在尽可能的地方把该值看作是地址,并解析符号名称。
当在指令级工作时,设置一些显示可能是有所帮助的。您可以将所有 display 命令放在一个文件中,并在命令行上使用 -x 选项来指定它。 图 6包含了工作在汇编程序级时通常使用的 display 命令。
这个命令打印全部 PSW 值、所有通用寄存器和从当前指令地址开始的下 10 行机器代码。 图 7显示了当我们在 main() 处中断时的结果。可以看到,在其中一些寄存器所指向的地方,/a 格式解析是如何使得理解正在发生的事情更加容易的。
![]() ![]() |
![]()
|
对于一些可用于 Linux 应用程序调试的基本工具以及调试过程本身,本文中的信息应该为您提供了有用的入门信息。