UML软件工程组织

 

 

IBM Rational Purify 的高级特性: 利用 Purify 进行程序调试

2008-08-14 作者:Satish Chandra Gupta,Anand Gaurav 来源:IBM  

 
本文内容包括:
IBM ®Rational® Purify®是一个能够精确检测内存泄露错误的工具,否则要检测和确定这些错误是相当困难的。它监控并分析一个程序是如何使用内存情况,并发出附有源代码错误的报告,精确修复到这这个错误的原因和具体位置。在这篇文章中,您将学习如何熟练地利用 Rational Purify API 和带有调试器的观察点来分析内存中的错误。

内存错误是软件错误中最难分析和修复的错误类型之一,因为这个内存泄露源和错误的表现是分离很远的,使我们很难将导致错误的原因和最终的效果联系起来。此外,这些错误通常出现在和特别的条件下,因此很难复制它们。一般情况下,这些错误是由您的程序、三元函数,以及操作系统之间复杂的关系造成的。因此,仅仅通过检查源代码是很难预测和理解这些错误发生的可能性和发生的情景的。通常,它需要相当多的调试和调查来了解导致内存错误的程序逻辑和设计。

IBM® Rational® Purify®是一个高级内存调试工具,可以帮您快速并准确定位导致内存泄露错误的原因。要使用这个工具,您首先要利用 Purity 来测试您的程序。然后,当您运行这个测试过的程序时,Purify 就会通过您的程序详细检查内存访问和操控,鉴定将要发生的内存错误。这样大大减少了调试时间和复杂性。

您可以学习各种内存错误的类型,以及通过阅读 参考资料 中的 IBM developerWorks 文章“要在一个“渗漏的”船中导航“C”吗?试试 Purify”(参考资料)了解如何利用 Purify 来检测它们。如果您对 Purify 和内存错误都十分熟悉,您可以快速浏览或者跳过那篇文章。

Purify 拥有几个独特的高级特性能高帮助您调试内存错误。在这篇文章中,那您将首先学习 Purify 中的应用程序编程接口 (API)以及如何在调试器中使用它们。然后您将学习 API 对于内存观察点的特殊性。

应用程序编程接口

Purify 提供了各种各样您可以从您的程序或者调试器中条用的 API,用来进一步帮助您调试内存问题。例如,您可以利用purify_what_colors函数来找出内存范围的状态:

int purify_what_colors (char *addr, unsigned int size);

Purify 能够跟踪您程序中内存使用的每个字节的状态情况,并用四种颜色来代表不同的状态:红色,黄色,绿色和蓝色。最初,所有的内存都是红色,这表示内存还没有被分配也没有初始化。当您分配内存以后,它就变成黄色,这表明已经被分配但是没有初始化。当您初始化一个内存单元后,它就会变成绿色,这表明内存被分配和初始化。当您释放内存时,它就变成蓝色,这表明开始被分配后来被释放。这个生命周期显示在下面的图 1中。

  • 合法读取或者写入内存则标记为绿色。
  • 写入到黄色内存中是合法的,但是从黄色内存中读取是不和合法的。当您操作错误时,Purify 会发出一个 Uninitialized Memory Read (UMR) 错误报告。
  • 读取或者写入蓝色和红色内存是不合法的。

当调试一个内存泄露时,您可以从调试器中调用purify_what_colorsAPI 来查看有趣的内存单元的 Purify 颜色状态。这个例子显示了一个八个字节缓冲单元的颜色状态,在这里只有开始两个已经被初始化:

(gdb) print purify_what_colors(buf, sizeof(buf)+1) 
color codes of 9 bytes at 0xffffe820: GGYYYYYYR

API 打印出了 sizeof(buf)+1 bytes 的内存状态,从内存地址 buf 开始。每个内存字节的内存状态都用下面的字母来表示 RYG,或者 B。这些字母相应的子目分别代表的是红色,黄色,绿色以及蓝色。

图 1. 内存单元的生命周期
截图

利用带有调试器的 Rational Purify

大多数情况下,Purify 提供了足够关于一个内存错误的信息,您可以根据这些信息来鉴定这个原因并进行修复。但是有些时候,您可能需要将这个信息作为一个起始点,要调试这个程序来知道其中的原因。例如,让我们假设 Purify 报告了一个令您吃惊的 UMR 错误,因为您确实看到一个初始化您的程序的语句。很显然,存在一个控制路径,在这个路径中要么是初始化语句没有执行,要么是对于一个内存缓冲的指针,这个指针被重新交付给另一个可能没有被初始化的内存缓冲。

在这种情况下,您需要调试您的程序来找到准确的原因。让人高兴的是,不是调试您的程序,而是调试仪表化的 Purify 程序。Purify 议题内存错误报告仅仅是错误发生之前的报告。这样就允许您检查和分析相关的变量和调试器中内存的状态。Purify 还为检查这个内存单元的状态提供了 API。

这里有两种为仪表化 Purify 程序使用调试器的方法:

  • 首先,在调试器下开始这个仪表化程序,并将一个观察点置于 purify_stop_herePurify API 函数中。这个调试器将在每个 Purify 错误消息出现时停止:
    (gdb) break purify_stop_here
    (dbx) stop in purify_stop_here
    (xdb) b purify_stop_here
  • 另一种选择是,您可以通过 Purify GUI 中的 Options > JIT Debug 菜单配置实时(JIT) 调试(请看图 2),并选择您感兴趣的错误类型。无论何时报告一个选择类型的 Purify 错误,Purify 都会调用这个调试器并将它附加到您的运行应用程序中。
图 2. Purify JIT 调试对话框
截图

当您在一个调试器中,您可以利用各种 Purify API 函数来研究各种内存单元的状态类型:f

  • purify_what_colors(char *addr, unsigned int size):
    打印开始于内存地址 (addr) 的字节 Size的内存状态,正如先前在应用程序编程接口部分所解释的那样。
  • purify_describe(void *addr):
    打印关于 addr 单元内存的特殊细节,包括它的储存单元(堆叠,累计,文本)以及,如果它累计了大量内存,还有它的分配调用链和释放历史。
  • purify_assert_is_readable(const char *addr, int size):
    模拟阅读开始于addr的size字节,产生读取将会导致的任何 Purify 错误,根据错误调用purify_stop_here。如果错误已被删除,将会返回0,如果没有错误没删除,则返回1
  • purify_assert_is_writable(const char *addr, int size):
    模拟写入开始于 addr 的 size 字节,产生写入将会导致的任何 Purify 错误,根据错误调用 purify_stop_here。如果错误已被删除,将会返回0,如果错误没有被删除,将会返回1

当您进行调试时,您可能想要集中在一些代码片断上,这样就不会对程序控制到达代码片段之前报告的 Purify 错误感兴趣。Purify 提供 API 来关闭或者开启错误报告(注意内存使用监控没有关闭):

  1. 利用一个调试器,将一个观察点 main 函数上 (或者置于您想要关闭 Purify 错误报告的程序单元上),然后运行仪表化程序。
  2. 当调试器在一个观察点停止时,键入这个命令:
    (gdb) print purify_stop_checking()
     
  3. 将一个观察点置于您想要重新开始 Purify 错误报告的程序单元,并继续运行这个程序。
  4. 当这个调试器停在一个观察点时,键入这个命令:
    (gdb) print purify_start_checking()

内存观察点

Purify提供了您可以从调试器中调用的范围较大的内存观察点集合,从而帮您在您的程序中调试内存泄露问题。显示在列表 1中的代码展示了一个内存渗漏和一个不断摆动的指针。

列表 1.带有一个内存渗漏和一个不断摆动的指针的代码 (mem_errors.c)
 
                
 1  #include <stdio.h>
 2  
 3  char *namestr;
 4  
 5  void foo() {
 6      namestr = (char *) strdup("Rational PurifyPlus");
 7      printf("Product = %s\n", namestr);
 8      free(namestr); /* free the memory allocated by strdup */
 9  }
10  
11  void main() {
12      namestr = (char *)malloc(20 * sizeof(char));
13      foo();
14      strcpy(namestr, "IBM");
15      printf("Company = %s\n", namestr);
16      free(namestr);
17  }

有趣的是,如果您分别看看这个方法 main() 或者 foo(),这两个函数看起来都是正确的。这个方法 main() 分配内存,调用 foo(),使用分配的内存,然后释放它并退出。这个方法 foo() 调用程序 strdup(),它分配内存,使用这个内存,然后释放它。然而,它是这两个函数的相互作用,利用一个全球性的指针变量叫做namestr,它会导致渗漏和不断摆动的指针。当 strdup() 在 foo() 中被调用,这个 namestr变量值就会被覆写,从而丢失 main() 中内存分配的指针,导致渗漏。在 main 中,当从 foo() 返回后,namestr实际上时一个摆动的指针,因为 foo()已经在返回前释放了那个内存。

在这个简单的例子中,通过检查代码很容易就可以认出问题的存在。但是在较大且有复杂控制流程的程序中就不可能了,在这样的程序中有问题的函数将存在于不同的函数库中。也就是当 Purify 和它的内存观察点 API 变得方便的时候。

这里讨论了您如何能够净化这个程序:

$ purify cc -g mem_errors.c -o mem_errors.pure

当您运行这个被净化的程序时,Purify 将报告下面的这些错误 (也请看图 3):

  • MLK (memory leak) for the memory allocated for namestr in the function main
  • FMW (Free Memory Write) at the strcpy() call in the function main
  • FMR (Free Memory Read) at the printf() call in the function main
  • FUM (Freeing Unallocated Memory) at the free() call in the function main
图 3. 在 mem_errors.c 中由 Purify 报告的内存错误
截图

对于这个小型例子来说,通过使用伴随 Purify 错误提供的信息可以很容易地修复这个程序错误。但是对于一个复杂的程序,一个类似于 foo() 这样的函数可能会从各种单元中调用,并可能在一个循环圈中,通过使用 Purify 内存观察点 API 来调试这个程序将会十分有用。

这个观察点特征使您要求 Purify 特别关注一个内存的区域,只要那个内存被读取时就会发出一个报告(WPR: Watch Point Read),写入(WPW: Watch Point Write),或者释放(WPF: Watch Point Free)。用这种方法,您可以回答这样的问题,比如:“这个变量在这个程序的什么位置被编写?”或者“这个变量在什么位置被使用?”以及在这个累计的内存中,“哪个函数释放了这个内存?”

这里是您如何能够净化程序并在一个调试器下运行这个程序 (gdb在这里被用来描述这个过程,但是您可以利用任何您喜欢的调试器):

$ purify cc -g mem_errors.c -o mem_errors.pure 
$ gdb mem_errors.pure

查看一个 Purify 报告内存渗漏和 FMR、FMW, 或者 FUM,您可能会问到几个有趣的问题:

  • 假设 namestr 变量是一个分配在 main() 中的一个内存指针,为什么 namestr 开始指向其它一些内存?
  • 在什么地方 namestr 与其它地址数值一起被编写,从而丢失分配在 main() 中的最后一个内存指针,最终导致渗漏吗?
  • 当这个内存分配在 main() 后,namestr 变量会在什么地方被使用和编写呢?

您可以通过仅仅将一个观察点置于 main() 函数中 malloc调用程序之后就可以回答这些问题(第 13行),然后利用 Purify 来设置一个观察点于 &namestr 上,从而跟踪所有发生在 namestr 变量的编写操作。无论何时地址被改写到 namestr 变量,这个 Purify 观察点都将显示一个 WPW (Watch Point Write) 消息在 Purify 浏览器中:

$ gdb mem_errors.pure
(gdb) break 13
Breakpoint 1 at 0x10000aec: file mem_errors.c, line 13.
(gdb) run
Starting program: mem_errors.pure 
Breakpoint 1, main () at mem_errors.c:13
13          foo();
(gdb) print purify_watch_n(&namestr, 4, "w")
$1 = 1
(gdb) continue

这个 purify_watch_n() 函数使这个内存单元的地址被监控(&namestr),读取的大小 (4个字节,指针的大小)以及观察点模式 (r,编写的大小模式 w,以及读取和编写的大小和模式 rw)。当一个新地址储存于 namestr 时, Purify 将在这个浏览器中显示一个 WPW (Watch Point Write) 结果。对于扩展,看起来是这个消息:

WPW: Watch point write:
  * This is occurring while in:
        foo            [mem_errors.c:6]
        main           [mem_errors.c:13]
        __start        [mem_errors.pure]
  * Watchpoint 1
  * Writing 4 bytes to 0x20103b38 in the initialized data section.
  * Value changing from  537934728 (0x20103b88, " \020;\210")
                     to  537934968 (0x20103c78, " \020")
  * Address 0x20103b38 is global variable "namestr".
    This is defined in mem_errors.pure.

这个消息表明用 foo() 第 6 行中的方法,另一个地址储存在 namestr 中。甚至在这个内存释放之前,namestr 值就一就已经被变更,那也是为什么 Purify 报告一个 MLK (内存渗漏) 错误的原因。现在我们该调试 FMR (Free Memory Read) 和 FMW (Free Memory Write) 错误的原因了。当报告 FMR 或者 FMW 时,Purify 也详细说明了这个内存在什么地方被分配。对于这个例子,Purify 意味着 FMW 和 FMR 错误以 main() 方式分别在 14 和 1 行发生,由于访问的已经释放内存是以 foo() 方式分配在 strdup() 命令中的第6行的。因此,您需要跟踪内存板块上所有分配在第6行的读取和写入行为 (而不是这个指针)。您可以通过将一个读取-写入观察点置于strdup()调用程序之后来操作:

(gdb) break 7
Breakpoint 2 at 0x10000c38: file mem_errors.c, line 7.
(gdb) continue
Continuing.
Breakpoint 2, foo () at mem_errors.c:7
7           printf("Product = %s\n", namestr);
(gdb) print purify_watch_n(namestr, 20, "rw")
$2 = 2

因为这是一个读取-写入观察点,任何尝试读取或者修改这个被 namestr 指针指向的内存板块内容的行为,都将分别触发一个 WPR 或者 WPW 消息。注意使用这里的 namestr 和先前使用的 &namestr 的区别。先前的例子是关注支持 namestr 指针本身的内存;因此这个被关注区域是由 &namestr 提供的。想法,第二个例子是关注的是 namestr 指向的内存。

WPR 显示在 printf() 调用程序的第7行,WPF (Watch Point Free) 显示在free()调用程序的第8行。这两个都在预料之中,但是现在,当报告 WPF 之后,您应该更加小心地遵循这个控制路径。

事实上,您可以为 Purify 在 purify_stop_here 设置一个观察点(正如先前所阐述的那样,在“使用带有调试器的 Purify”部分),在每次运到错误的时候停止 (或者消息)。任何对这个内存的访问从此以后都将是错误,因为这个内存由 namestr 指向的内存已经释放。因此,在逐步检测这个代码时,在第14行将产生 一个 WPW 消息,因为这个已经被释放的内存正通过一个 strcpy() 调用命令被写入到那个消息中。这个 WPW 阐述了 Purify 报告的 FMW (Free Memory Write):

(gdb) next
main () at mem_errors.c:14
14          strcpy(namestr, "IBM");
(gdb) next
15         printf("Company = %s\n", namestr);

同样,在逐步检测过程的第15行将会产生一个 WPR 消息,因此也是一个 FMR 错误。这个大约在这个程序末尾的第16行中报告的 FUM 错误现在也显而易见了,因为这个 namestr 内存(一个新值 strdup 分配)已经在 foo 函数的第18行被释放了(这里会有一个 WPF 消息被报告)。

图 4显示了一个 Purify 窗口和所有这些观察点错误的样本。总的来说:内存观察点将帮助您跟踪特定内存板块的使用情况。利用它们和这个调试器将帮助您跟踪带有程序执行的内存,这表明一个内存错误。

图 4. 由 Purify 报告的观察点消息
截图

Purify 观察点可以为特定内存地址报告以下的消息:

  • Reads
  • Writes
  • Allocation
  • De-allocation
  • 在函数入口变成范围作用域
  • 在函数出口退出作用域

这里有几个方便使用的观察点 API 函数 (表格 1)。最简单的是purify_watch(addr),它在特定地址的四个字节上设置了一个读取-写入观察点。设置观察点的API 将返回一个整数型值,即刚刚设置的观察点数字。您可以使这个整数值传递到watchpoint_remove并清除它。所有便利函数都等价与使用带有合适地址,大小,以及类型的purify_watch_n。

表格 1. API 对设置观察点
 
观察点 API 描述
purify_watch_n(addr, size, type) 设置一个类型的观察点 typesize 字节之上,开始于 addr。设置 Type 来读取 ("r"),写入("w"),或者读取和写入("rw")。
purify_watch(addr)
purify_watch_1(addr)
purify_watch_2(addr)
purify_watch_4(addr)
purify_watch_8(addr)
观察四个字节(或者这个指示的数字)开始于地址,类型("rw")。
purify_watch_r(addr)
purify_watch_r_1(addr)
purify_watch_r_2(addr)
purify_watch_r_4(addr)
purify_watch_r_8(addr)
观察四个字节(或者这个指示的数字)开始于地址,类型("r")。
purify_watch_w(addr)
purify_watch_w_1(addr)
purify_watch_w_2(addr)
purify_watch_w_4(addr)
purify_watch_w_8(addr)
观察四个字节(或者这个指示的数字)开始于地址,类型("w")。
purify_watch_rw(addr)
purify_watch_rw_1(addr)
purify_watch_rw_2(addr)
purify_watch_rw_4(addr)
purify_watch_rw_8(addr)
观察四个字节(或者这个指示的数字)开始于地址,类型("rw")。

获取关于观察点的信息并清除它们,您可以利用下面的 API:

  • purify_watch_info() ,它显示了所有活跃的 Purify 内存观察点
  • purify_watch_remove(int watchpoint_no) ,它清除了这个带有特定数字的观察点
  • purify_watch_remove_all() ,它清除了所有的观察点

利用您程序中的 Purify API

除了使用这个调试器中的 Purify API 外,您还可以将它们嵌入程序来校验错误和报告额外的信息。在那种情况下,即使您在一个自动测试系统中运行您的净化程序,当一个错误发生时,它将额外的消息转储到 Purify 日志中,那将帮助您鉴定这个问题。

有两种将 Purifying API 嵌入到您程序中的方法:

  • 利用 #ifdef 保护
  • 链接 Purify 存根

利用 #ifdef 保护

正如 列表 2 的例子所显示的那样,您可以通过将它们与 #ifdef定义保护包围在一起,从而在您的程序中保护 Purify API 调用程序。用这种方法,您不需要更改这个源代码来构建这个净化的利用 Purify API 的可执行程序,也不需要构建一个您当作产品运输的可执行程序。

这个例子有一个 strncpy 工具,在这个例子中源和目的字符串首先被分别核查,使其能够被读取和写入。如果任何一个测试失败,就会有一个适当的消息都会通过调用 purify_printf 打印在 Purify 控制台或者日志中。然后 purify_describe就会被调用,它会打印详细的关于这个内存地址的信息,包括它的储存单元(堆、栈和文本)以及,对于栈的内存,还有位于它分配时间的命令链和它的free() 命令历史。最后,purify_what_colors 被调用来打印这个内存缓冲的颜色。只有当没有错误被发现时才会执行复制的操作。

列表 2:mystring.c 的部分文件,利用了带有保护的 API
 
                
#ifdef PURIFY
#include <purify.h>
/*
 * The purify.h file has needed API declaration.
 */
#endif

void mystrncpy(char* dest, const char* src, int length) {
#ifdef PURIFY
    if (!purify_assert_is_readable(src, length)) {
        purify_printf("strncat: caller gave bad source");
        purify_describe(src);
        purify_what_colors(src, length);
    } else if (!purify_assert_is_writable(dest, length)) {
        purify_printf("strncat: caller gave bad destination");
        purify_describe(dest);
        purify_what_colors(dest, length);
    } else {
#endif
        /*
         * skip: copy n bytes from src to dest only if safe
         */
#ifdef PURIFY
    }
#endif
}

int main() {
    /* skip: main body that calls mystrncpy */
}

makefile 显示在 列表 3中,显示了您如何能够通过利用 -DPURIFY 标记来打开 Purify API 调用,从而为构建一个净化版本的可执行文件 (参见 mystring.pure 的规则),并且将它与 Purify API 数据库链接起来。

列表 3. 构建 mystring 和 mystring.pure 的部分 makefile
 
                
#
# makefile to build mystring programs, and its purified versions
#

# ... skip ...

# Purify header and API lib locations
PURIFYINCLUDE = -I`purify -print-home-dir`
# For 64-bit program, replace lib32 by lib64 in following:
PURIFYAPILIB  = `purify -print-home-dir`/lib32/libpurify_stubs.a 

# ... skip ...

mystring : mystring.c
	$(CC) $(FLAGS) -o $@ $?

mystring.pure : mystring.c
	purify $(CC) $(FLAGS) -g -DPURIFY $(PURIFYINCLUDE) -o $@ $? \
		$(PURIFYAPILIB)

# ... skip ...

链接到 Purify 存根

使用一个防护装置的缺点是,您必须重新编译整个程序。在这个例子中,只有一个 C 文件被使用,但是在较大的系统中,通常是各种数据库被建立,最终连接到这个可执行文件中。在这种情形下,如果您想要净化您的程序,您必须重新编译所有使用保护装置的 C 文件。

另一个可选的方法是,通过更改列表 3中的规则,总是连接到您的附有 libpurify_stubs.a 的应用程序上,从而构建mystring:

mystring : mystring.c
	$(CC) $(FLAGS) -o $@ $? $(PURIFYAPILIB)
            

这个 libpurify_stubs.a 是一个很小的数据库,它有供 Purify API 函数所用的空白存根。当您测试您的程序时,Purify 将会提供整个真实的 API 函数定义,而存根会被忽视。

您可以用 if(purify_is_running()) 来包围多个 Purify API ,从而使 Purify 函数命令不会在您未测试的程序中变得缓慢(请看列表 4)。

列表 4. 使用带有保护装置的 Purify API 的部分mystring.c文件
 
                
#include <purify.h>
/*
 * The purify.h file has needed API declaration.
 */

void mystrncpy(char* dest, const char* src, int length) {
if (purify_is_running()) {
        if (!purify_assert_is_readable(src, length)) {
            purify_printf("strncat: caller gave bad source"); 
            purify_describe(src);
            purify_what_colors(src, length);
        } else if (!purify_assert_is_writable(dest, length)) {
            purify_printf("strncat: caller gave bad destination"); 
            purify_describe(dest);
            purify_what_colors(dest, length);
        }
}

    /*
     * skip: copy n bytes from src to dest only if safe
     */
}

int main() {
    /* skip: main body that calls mystrncpy */
}

使用 #ifdef 和 Purify 存根的区别是:前者需要您利用 –DPURIFY 信号重新编译您的程序,而后者包含一个调用 purify_is_running 的运行时间成本(那可以忽略不计),并将您的程序以产品代码的形式连接到 Purify 的空白存根。利用这个适合您需求的方法。

总结

在这篇文中,您学习了 Purify 中关于内存颜色的概念、API,以及内存观察点。您可以使用这些来自调试器的 API,或者反过来,您可以将它们嵌入到您的程序中。无论怎样,利用 Purify API 和内存观察点的帮助,您可以更有效地在您的程序中调试内存错误。

参考资料

学习 获得产品和技术 讨论
 

组织简介 | 联系我们 |   Copyright 2002 ®  UML软件工程组织 京ICP备10020922号

京公海网安备110108001071号