libfeihu Blog

谁打印了这个字符串

谁打印了这个字符串

前段时间在调试时遇到一个问题,运行程序出现错误,但并没有足够的信息来定位错误所在。可喜的是控制台上输出了一些可疑信息,只要找到了哪里打印了这些信息便有可能推断错误的原因。然而由于程序过于庞大,不可能一步一步跟踪调试去查找哪条语句执行后输出了这段字符串。尝试在所有的代码中搜索这段字符串也无功而返。后来突发奇想,能否在输出字符串时设置一个条件断点,只要输出的这段信息就中断,这样就可以在中断后找到何处打印了这些可疑信息,进而解决程序的问题了。

经过大量的搜索之后,在Stack Overflow上找到了答案,并且成功的解决了我的问题。被采用答案的作者Anthony Arnold由于十分喜欢这个问题,所以写了一篇关于它的博文。我也特别喜欢这个问题,之前遇到过多次,但都采用别的方式解决,而只有这个答案最完美。同时它综合运用了很多知识,能够给我们的调试带来不少启发。因为网上也没有再找到其它的解决方案,所以我决定翻译此文,后面对原文再进行其它平台的补充。离开学校之后第一次翻译,不好之处欢迎指正。

目录


调试STDOUT

前几天我遇到一个很有趣的Stack Overflow 问题,提问者希望GDB能够在一个特定的字符串写到stdout时中断程序。

除非你至少掌握了一些下面的知识,否则并不容易得到这个问题的答案:

我想出了一种可行的但是不可移植的解决方案,它依赖于x86指令集和Linux的系统调用write(2),所以本文的讨论限制在特定操作系统内核上的特定架构。

例子

我们使用下面的代码(定义在hello.c中)来演示如何用GDB在"Hello World!\n"写入stdout时中断。(feihu注:代码作了一定的修改,增加了另外一个输出字符串的函数,以更好的演示捕获特定字符串)

#include <stdio.h>


void test()
{
    printf("Test\n");
}

int main()
{
    printf("Hello World!\n");
    test();
    return 0;
}

用下面的命令编译链接:

# gcc -g -o hello hello.c

用下面的命令调试:

# gdb hello

在write中设置断点

第一步,我们需要找出如何在有数据被写到stdout时中断程序。我们假设你调试代码的作者没有疯,他们采用了所选语言的标准用法来向stdout写数据(比如C语言中的printf(3)),或者他们直接调用系统调用write(2)

实际上最终printf(3)也调用的是write(2),所以不管采用上面哪种方式都可以。

因此你可以在write(2)系统调用中设置一个断点:

$gdb break write

GDB也许会抱怨它并不知道write函数,但是它可以在将来这个函数有效时再在其中设置这一断点:

Function "write" not defined.
Make breakpoint pending on future shared library load? (y or [n])

这完全没问题,直接输入y即可。

在write中写到STDOUT时设置断点

一旦你能够在write函数中设置断点后,你需要设置一个条件,只有在写到stdout时才中断,这里有一点复杂。看看write(2)帮助页面:第一个参数fd是要写的文件描述符,在Linux中,stdout的文件描述符是1(也许你使用的平台有所不同)。

于是你的第一反应是这样:

$gdb condition 1 fd == 1

但是很遗憾,这不起作用。(feihu注:会出现这样的错误No symbol "fd" in current context.)除非你非常幸运,否则不可能已经加载了write(2)系统调用的调试symbols,这意味着你不能以参数名来访问传给系统调用的参数。

获取系统调用参数

当然,还有其它的方法可以获取传给系统调用的参数,GDB提供了非常完善的手段让你来访问各种汇编寄存器,在这个问题中,我们感兴趣的是Extended Stack Pointer,也就是esp寄存器。(feihu注:这里仅适用于x86 32位系统,x86 64位系统的解决方案请向下看。)

当一个函数调用发生时,栈中储存了:

这意味着当调用write函数时,栈的结构可能像下面一样:

系统调用参数

这是假设地址占4个字节,根据你自己的机器作相应的调整

所以现在你可以像这样设置条件断点:

$gdb break write if *(int *)($esp + 4) == 1

再一次声明,假设地址占4个字节

注意,$esp能够访问ESP寄存器,它将所有的数据都看成是void *。因为GDB不允许直接将void *转换成int型(这么做是对的),所以你需要先将($esp + 4)转换成int *,然后再对其指针取值以获得文件描述符参数的值。

限定到特定字符串

接下来更加复杂一点,它是上一步的扩展,而且,它并不适用于所有的情况。传给printf(3)的字符串并不一定会完整的传给write(2),它可能会被分成更小的块然后一次性的写到文件中,调试stdout时请记住这一点,当然如果用短字符串的话应该没问题。(feihu注:调用printf时并不会每次都调用write,而是会先把数据放到缓冲区,等缓冲区积累到一定量时才会一次性的写到文件中。如果想到立即写到文件中的话,需要调用fflush。)

现在你必须再将这个断点加上一定的限制,当且仅当一个特定的字符串传给write(2)时才中断程序。为了做到这一点,你可以对write的第二个参数buf调用strcmp(3)。从前面的表中可知,$esp + 8指向的就是buf,所以再给断点增加一个条件就变得很简单了:

$gdb break write if *(int *)($esp + 4) == 1 && strcmp("Hello World!\n", *(char **)($esp + 8)) == 0

请记住,$esp + n * sizeof(void *)就代表了函数第n个参数的指针,这表明$esp + 8指向的是一个指向字符串的指针,为了让它能够正确的运行,你需要做一些转换和取值操作。

64位系统的解决方案

64位系统需要采用RDIRSI寄存器。(feihu注:对于32位系统,所有的函数参数是写在栈里面的,所以可以用前面介绍的办法。但是64位系统中函数的参数并未存放在栈中,它提供了更多的寄存器用于存放参数,请戳这里。)

$gdb break write if 1 == $rdi && strcmp((char *)($rsi), "Hello World!\n") == 0

注意这里没有了指针的转换操作,因为寄存器里面存的不是指向栈中元素的指针,它们存的是值本身。

总结

经过上面的一系列步骤之后,你可以得到一个移植性不好的解决方案,在一些特定的平台和架构下,可以使用GDB在一个特定的字符串写到stdout时中断程序。

如果你有一个更好的解决方案,或者仅仅是另外一个替代方案,请在下面留言,并且/或者直接在Stack Overflow上回答这个问题。还有,如果你有一种方案可以工作在其它平台或者架构下,请一定也给我留言。


再进一步

原文的翻译就到上面,但是这里还可以再做一些改进。比如上面的strcmp函数可以用下面的函数来代替:

Windows上的解决方案

随后我想,能不能在Windows平台上也使用类似的方法,最终也成功了(x86-64位操作系统下的Win32和64位程序)。

对于所有的写文件操作来说,Linux最终都会调用到它的POSIX API write函数,Windows和Linux不一样,它提供的API是WriteFile,最终Windows上的调用都会用到它。但是,不像开源的Linux可以调试write,Windows无法调试WriteFile函数,所以也无法在WriteFile处设置断点。

但微软公开了部分VC的源码,所以我们可以在给出的源代码部分找到最终调用WriteFile的地方,在那一层设置断点即可。经过调试,我发现最后是_write_nolock调用了WriteFile,这个函数位于your\VS\Folder\VC\crt\src\write.c,函数原型如下:

/* now define version that doesn't lock/unlock, validate fh */
int __cdecl _write_nolock (
        int fh,
        const void *buf,
        unsigned cnt
        )

对比Linux的write系统调用:

#include <unistd.h>


ssize_t write(int fd, const void *buf, size_t count); 

每个参数都可以对应的起来,所以完全可以参照上面的方法来处理,在_write_nolock中设置一个条件断点即可,只有一些细节不一样。

可移植方案

很神奇的是Windows下面,在设置条件断点时可以直接使用变量名,而且在Win32和x64下面都可以。这样的话调试就变得简单了很多:

  1. _write_nolock处增加一个断点

    注意:Win32和x64在这里有些许不同,Win32可以直接将函数名作为断点的位置,而x64如果直接设置在函数名处是无法中断的,我调试了一下发现原因在于x64的函数入口处会给这些参数赋值,所以在赋完值之前这些参数名还是无法使用的。我们这里可以有一个work around:不在函数的入口处设置断点,设置在函数的第一行,此时参数已经初始化,所以可以正常使用了。(即不用函数名作为断点的位置,而是用文件名+行号;或者直接打开这个文件,在相应的位置设置断点即可。)

  2. 设置条件:

     fh == 1 && strstr((char *)buf, "Hello World") != 0
    

注意:但是这里有一个问题,我测试了printfstd::cout,对于前者,所有的字符串一次都写到了_write_nolock中,然而std::cout是一次传一个字符,这样也就无法使用后面比较字符串这个条件了。

只适用Win32的方案

当然,这里我们也可以采用前面介绍的方法,通过寄存器来设置断点的条件。同样由于平台的差异,这里分Win32和x64来讨论。

对于Win32程序,和前面介绍的Linux下使用GDB的方法基本一致。注意到函数前面的__cdecl,这种调用约定表明:

可以参考这里。微软的网站上还有一个例子来展示函数调用时参数在栈中的情况,结果请看这里,它给出的每一种调用约定下寄存器和栈的使用情况。

知道这些之后,设置一个条件断点就变得非常容易了:

  1. _write_nolock处设置一个断点
  2. 增加条件:

     *(int *)($esp + 4) == 1 && strstr(*(char **)($esp + 8), "Hello") != 0
    

同前面介绍的Linux下的方法一样,条件的第一部分是对fh,只有当向stdout写数据时才中断。第二部分是针对buf,当其中含有特定的字符串时满足中断条件。

只适用x64的方案

从x86到x64有两个重要的改变,一是地址容量从32位变成了64位,二是增加了一些64位寄存器。因为这些寄存器的增加,x64就只使用__fastcall这种方式作为调用约定,这种方式会将前四个参数放到寄存器中,如果有更多参数的话,会存到栈中。

关于参数传递可以参考这里,函数调用时前四个参数会依次放到:RCXRDXR8R9当中,因此增加条件断点也变得很容易:

  1. _write_nolock处设置一个断点:

    注意:这里直接在入口设置即可,不需要像前面可移植方案中介绍的那样设置到函数的第一行,因为寄存器在入口处已经有了正确的值。

  2. 设置条件:

     $rcx == 1 && strstr((char *)$rdx, "Hello") != 0
    

由于esp是将所有的数据都看成是void *,所以需要做一定的转换才可以使用。而这里的寄存器存的是参数的值,所以不需要这个转换。所以rcx本身就是fh的值,而rdx中存的是一个指针,将它转成char *之后即可表示字符串。

写在结尾

同样,这个方法也有一定的局限性,比如对于std::cout,我还没有找到好的解决办法。

我也将这后面Windows上的方法发到Stack Overflow原文的评论中,算是给这个问题的一个扩展吧。如果你有更好的解决方案,请给我们留言或者直接在Stack Overflow回答。

(全文完)

feihu

2014.01.16 于 Shenzhen