分类: LINUX
2013-05-06 20:32:48
用 Graphviz 可视化函数调用
M. Tim Jones, 资深软件工程师, Emulex
简介: 花一些时间遍历一下源代码,可以向您展现所有的函数调用过程;但是如果函数指针非常复杂,或者代码太长且晦涩难懂,那么这个过程就可能更加困难了。本文将向您介绍如何使用开源软件和一些定制的代码来构建一个动态的图形函数调用生成器。
可以将以图形形式查看应用程序的调用过程看作是一个学习经历。这样做可以帮助您理解应用程序的内部行为,并获得有关程序优化方面的信息。例如,通过对那些经常调用的函数进行优化,您就可以用最少的努力来获得最佳的性能。另外,调用跟踪还可以判断用户函数的最大调用深度,这可以用来对调用栈使用的内存进行有效限制(在嵌入式系统中,这是非常重要的一个考虑因素)。
为了捕获并显示调用图,您需要 4 个元素:GNU 编译器工具链、Addr2line 工具、定制的中间代码和一个名为 Graphviz 的代码。Addr2line 工具可以识别函数、给定地址的源代码行数和可执行映像。定制的中间代码是一个非常简单的工具,它可以减少对图形规范的地址跟踪。Graphviz 工具可以生成图形映像。整个过程如图 1 所示。
图 1. 搜集、简化和可视化跟踪路径的过程
数据搜集:捕获函数调用路径
要收集一个函数调用的踪迹,您需要确定每个函数在应用程序中调用的时间。在过去,都是通过在函数的入口处和退出处插入一个惟一的符号来手工检测每个函数的。这个过程非常繁琐,而且很容易出错,通常需要对源代码进行大量的修改。
幸运的是,GNU 编译器工具链(也称为 gcc)提供了一种自动检测应用程序中的各个函数的方法。在执行应用程序时,就可以收集相关的分析数据。您只需要提供两个特殊的分析函数即可。其中一个函数在每次执行想要跟踪的函数时都会调用;而另外一个函数则在每次退出想要跟踪的函数时调用(参见清单 1)。这两个函数都是特别指定的,因此,编译器可以识别它们。
清单 1. GNU 的入口和出口配置函数
void __cyg_profile_func_enter( void *func_address, void *call_site ) __attribute__ ((no_instrument_function)); void __cyg_profile_func_exit ( void *func_address, void *call_site ) __attribute__ ((no_instrument_function));
|
避免使用特殊的检测函数
您或许会产生疑惑,如果 gcc 就是我们需要的检测函数,那么为什么它不检测 __cyg_* 分析函数呢?gcc 的开发者曾思考过这个问题,他们提供了一个名为no_instrument_function 的函数属性,这个函数属性可以应用于函数原型,禁止对它们进行检测。不要将这个函数属性应用到分析函数上,这样会导致无限递归分析循环和大量的无用数据。
在调用一个检测函数时,__cyg_profile_func_enter 同时也会被调用,并以 func_address 形式传递调用的函数地址,以及从中调用该函数的call_site 形式的地址。反之,当一个函数退出时,也会调用__cyg_profile_func_exit 函数,并传递 func_address 形式的函数地址,以及函数从中退出的真实地址,该地址的表示形式为 call_site。
在这些分析函数中,您可以记录下地址对,以供以后再进行分析使用。要请求 gcc 所有的检测函数,每个文件都必须使用 -finstrument-functions 和 -g 选项进行编译,这样可以保留调试符号。
因此,现在您就可以为 gcc 提供一些分析函数了,这些函数可以透明地插入应用程序中的函数入口点和函数退出点。但在调用分析函数时,又应该怎样处理所提供的地址呢?您有很多选择,但是为了简便起见,可以将这个地址简单地写入一个文件,要注意哪个地址是函数的入口地址,哪个地址是函数的出口地址(参见清单 2)。
注意:在清单 2 中并没有使用调用 Callsite 信息,因为这些信息对于分析程序来说是不必要的。
清单 2. 分析函数
void __cyg_profile_func_enter( void *this, void *callsite ) { /* Function Entry Address */ fprintf(fp, "E%p\n", (int *)this); } void __cyg_profile_func_exit( void *this, void *callsite ) { /* Function Exit Address */ fprintf(fp, "X%p\n", (int *)this); }
|
现在您可以搜集分析数据了,但是您应该在什么地方打开或关闭您的跟踪输出文件呢?到现在为止,还不需要为了进行分析而对源程序进行任何修改。因此,您该如何检测整个应用程序(包括 main 函数)而不用对分析数据的输出结果进行初始化呢?gcc 的开发者也考虑过这个问题,它们为 main 函数的 constructor 函数和 destructor 函数提供了一些碰巧能够满足这个要求一些方法。constructor函数是在调用 main 函数之前调用的,而 destructor 函数则是在应用程序退出时调用的。
要创建 constructor 和 destructor 函数,则需要声明两个函数,然后对这两个函数应用 constructor 和 destructor 函数属性。在constructor 函数中,会打开一个新的跟踪文件,分析数据的地址跟踪就是写入这个文件的;在 destructor 函数中,会关闭这个跟踪文件(参见清单 3)。
清单 3. 分析 constructor 和 destructor 函数
/* Constructor and Destructor Prototypes */ void main_constructor( void ) __attribute__ ((no_instrument_function, constructor)); void main_destructor( void ) __attribute__ ((no_instrument_function, destructor)); /* Output trace file pointer */ static FILE *fp; void main_constructor( void ) { fp = fopen( "trace.txt", "w" ); if (fp == NULL) exit(-1); } void main_deconstructor( void ) { fclose( fp ); }
|
如果编译分析函数(在 instrument.c)并将它们与目标应用程序链接在一起,然后再执行目标应用程序,结果会生成一个应用程序的调用追踪,追踪记录被写入 trace.txt 文件。跟踪文件与调用的应用程序处于相同的目录中。最终结果是,您可能会得到一个其中满是地址的非常大的文件。为了能够让这些数据更有意义,您可以使用一个不太出名的叫做 Addr2line 的 GNU 工具。
使用 Addr2line 将函数地址解析为函数名
Addr2line 工具(它是标准的 GNU Binutils 中的一部分)是一个可以将指令的地址和可执行映像转换成文件名、函数名和源代码行数的工具。这种功能对于将跟踪地址转换成更有意义的内容来说简直是太棒了。
要了解这个过程是怎样工作的,我们可以试验一个简单的交互式的例子。(我直接从 shell 中进行操作,因为这是最简单地展示这个过程的方法,如清单 4 所示。)这个示例 C 文件(test.c)是通过 cat 一个简单的应用程序实现的(也就是说,将标准输出的文本重定向到一个文件中)。然后使用 gcc 来编译这个文件,它会传递一些特殊的选项。首先,要(使用 -Wl 选项)通知链接器生成一个映像文件,并(使用 -g 选项)通知编译器生成调试符号。最终生成可执行文件 test。得到新的可执行应用程序之后,您就可以使用grep 工具在映像文件中查找 main 来寻找它的地址了。使用这个地址和 Addr2line 工具,就可以判断出函数名(main)、源文件(/home/mtj/test/test.c)以及它在源文件中的行号(4)。
在调用 Addr2line 工具时,要使用 -e 选项来指定可执行映像是 test。通过使用 -f 选项,可以告诉工具输出函数名。
清单 4. addr2line 的一个交互式例子
$ cat >> test.c
#include int main() { printf("Hello World\n"); return 0; }
$ gcc -Wl,-Map=test.map -g -o test test.c $ grep main test.map 0x08048258 __libc_start_main@@GLIBC_2.0 0x08048258 main $ addr2line 0x08048258 -e test -f main /home/mtj/test/test.c:4 $
|
Addr2line 和调试器
Addr2line 工具提供了基本的符号调试信息,不过 GNU Debugger (GDB)使用的是其他一些内部方法。
精简函数跟踪数据
现在您有了一个可以搜集函数函数地址的追踪数据的方法,还可以使用 Addr2line 工具将地址转换为函数名。然而,从应用程序中产生大量的跟踪数据之后,如何对这些数据进行精简,从而使其更有意义呢?这就是使用一些定制的中间代码在开源工具之间建立联系的地方。本文提供了这个工具(Pvtrace)的带有注释的完整代码,包括如何编译和使用该工具的一些说明。(有关的更多信息,请参阅 下载 一节。)
回想一下图 1 中的内容,在执行设置了检测函数的应用程序时,会创建一个名为 trace.txt 的文本文件。这个人们可以读取的文件中包含了一系列地址信息 —— 每行一个地址,每行都有一个前缀字符。如果前缀是 E,那么这个地址就是一个函数的入口地址(也就是说,您正在调用这个函数)。如果前缀是一个 X 字符,那么这个地址就是一个出口地址(也就是说,您正在从这个函数中退出)。
因此,如果在跟踪文件中有一个入口地址(A)紧跟着另外一个入口地址(B),那么您就可以推断是 A 调用了 B。如果一个入口地址(A)后面跟着一个出口地址(A),那么就说明这个函数(A)被调用后就直接返回了。当涉及大量的调用链时,就很难分析究竟是谁调用了谁,因此,一种简单的解决方案是维护一个整个地址的堆栈。每次在跟踪文件中碰到一个入口地址时,就将其压入堆栈。栈顶的地址就代表最后一次被调用的函数(也就是当前的活动函数)。如果后面紧接着是另外一个入口地址,这说明堆栈中的地址调用了这个刚从跟踪文件处读出的地址。在碰到退出函数时,当前的活动函数就会返回,并释放栈顶元素。这会将上下文返到回前一个函数,由此,就可以产生正确的调用链过程。
图 2 介绍了这个概念,以及精简数据的方法。在分析跟踪文件中的调用链时,会构建一个连通矩阵,用来表示哪个函数调用了其他哪些函数。这个矩阵的行表示调用函数的地址,列表示被调用的地址。对于每个调用对来说,行与列的交叉点不断进行累加(调用次数)。当处理完整个跟踪文件时,其结果是该应用程序的整个调用历史的一个非常简单的表示,其中包含了调用的次数。
图 2. 对跟踪数据进行处理和精简,并生成矩阵格式
编译并安装工具
在下载并解压 Pvtrace 工具之后,只需在子目录中输入make 命令,就可以编译 Pvtrace 工具了。也可以使用下面的代码将这个工具安装到 /usr/local/bin 目录中:
$ unzip pvtrace.zip -d pvtrace
$ cd pvtrace
$ make
$ make install
现在我们已经构建了简化的函数连通性矩阵,接下来应该构建图形的表示了。让我们深入研究 Graphviz,了便理解如何从连通矩阵生成一个调用图。
使用 Graphviz
Graphviz 或 Graph Visualization 是由 AT&T 开发的一个开源的图形可视化工具。它提供了多种画图能力,但是我们重点关注的是它使用 Dot 语言直连图的能力。在本文中,我们将简单介绍如何使用 Dot 来创建一个图形,并展示如何将分析数据转换成 Graphviz 可以使用的规范。(请参阅 参考资料 一节,以获得有关下载这个开源软件的信息。)
Dot 使用的图形规范
使用 Dot 语言,您可以指定三种对象:图、节点和边。为了让您理解这些对象的含义,我们将构建一个例子来展示这些元素的用法。
清单 5 给出了一个简单的定向图(directed graph),其中包含 3 个节点。第一行声明这个图为 G,并且声明了该图的类型(digraph)。接下来的三行代码用于创建该图的节点,这些节点分别名为 node1、node2 和 node3。节点是在它们的名字出现在图规范中时创建的。边是在在两个节点使用边操作(->)连接在一起时创建的,如第 6 行到第 8 行所示。我还对边使用了一个可选的属性 label,用它来表示边在图中的名称。最后,在第 9 行完成对该图规范的定义。
清单 5. 使用 Dot 符号表示的示例图(test.dot)
1: digraph G { 2: node1; 3: node2; 4: node3; 5: 6: node1 -> node2 [label="edge_1_2"]; 7: node1 -> node3 [label="edge_1_3"]; 8: node2 -> node3 [label="edge_2_3"]; 9: }
|
要将这个 .dot 文件转换成一个图形映像,则需要使用 Dot 工具,这个工具是在 Graphviz 包中提供的。清单 6 介绍了这种转换。
清单 6. 使用 Dot 来创建 JPG 映像
$ dot -Tjpg test.dot -o test.jpg $
|
在这段代码中,我告诉 Dot 使用 test.dot 图形规范,并生成一个 JPG 图像,将其保存在文件 test.jpg 中。所生成的图像如图 3 所示。在此处,我使用了 JPG 格式,但是 Dot 工具也可以支持其他格式,其中包括 GIF、PNG 和 postscript。
图 3. Dot 创建的示例图
Dot 语言还可以支持其他一些选项,包括外形、颜色和很多属性。但是就我们想要实现的功能而言,这个选项就足够了。
综合
现在我们已经看到了整个过程的各个阶段了,下面可以采用一个例子来展示如何将这些阶段合并在一起了。现在,您应该已经展开并安装了 Pvtrace 工具,然后还需要将 instrument.c 文件复制到工作源代码目录中。
在这个例子中,我使用了一个源文件 test.c 进行检测。清单 7 给出了整个过程。在第 3 行中,我使用检测源(instrument.c)来构建(编译并连接)应用程序。然后在第 4 行执行 test,再使用 ls 命令验证已经生成了 trace.txt 文件。在第 8 行,我调用了 Pvtrace 工具,并提供这个映像文件作为它惟一的参数。映像名是必需的,这样 Addr2line(在 Pvtrace 中调用)就可以访问这个映像中的调试信息。在第 9 行中,我又执行了一次 ls 命令,以确保 Pvtrace 生成了 graph.dot 文件。最后,在第 12 行,使用 Dot 将这个图形规范转换成一个 JPG 图形映像。
清单 7. 创建调用跟踪图的整个过程
1: $ ls 2: instrument.c test.c 3: $ $ gcc -g -finstrument-functions test.c instrument.c -o test 4: $ ./test 5: $ ls 6: instrument.c test.c 7: test trace.txt 8: $ pvtrace test 9: $ ls 10: graph.dot test trace.txt 11: instrument.c test.c 12: $ dot -Tjpg graph.dot -o graph.jpg 13: $ ls 14: graph.dot instrument.c test.c 15: graph.jpg test trace.txt 16: $
|
这个过程的示例输出如图 4 所示。这个示例图是从使用 Q 学习的一个简单增强式学习应用程序中得到的。
图 4. 示例应用程序的跟踪结果
您也可以使用这种方法对更大的应用程序进行分析。我要展示的最后一个例子是 Gzip 工具。我简单地将 instrument.c 加入 Gzip 的 Makefile 中,作为其依赖的一个源文件,然后编译 Gzip,并使用它生成一个跟踪文件。这个图形太大了,不太容易进行更详细的分析,但是下图表示了 Gzip 对一个小文件进行压缩时的处理过程。
图 5. Gzip 跟踪结果
结束语
使用开源软件和少量的中间代码,只需要花很少的时间就可以开发出非常有用的项目。通过使用对应用程序进行分析的几个 GNU 编译器扩展,可以使用 Addr2line 工具进行地址转换,并对 Graphviz 应用程序进行图形可视化,然后您就可以得到一个程序,该程序可以对应用程序进行分析,并展示一个说明调用链的定向图。通过图形来查看一个应用程序的调用链对于理解应用程序的内部行为来说非常重要。在正确了解调用链及其各自的频率之后,这些知识可能对调试和优化应用程序非常有用。