17、性能分析工具(上):gprof的使用,如何解读调用图与热点函数。
性能调优这件事,说白了就是“先测量,再动手”。
我见过不少工程师,上来就凭直觉改代码,觉得某个循环慢,就加个优化选项,结果折腾半天,性能没提升,反而把逻辑搞乱了。其实,真正高效的优化,第一步永远是——找到瓶颈在哪。
今天我们就聊聊 gprof,这个经典的 GNU 性能分析工具。虽然它不像 perf 那么现代,但胜在简单、直观,特别适合嵌入式环境下的快速分析。我个人习惯在项目早期先用 gprof 跑一遍,心里有个底,再决定要不要上更复杂的工具。
17.1 gprof 的工作原理
gprof 的核心机制,说白了就是“采样 + 插桩”。
编译时加上 -pg 选项,编译器会在每个函数的入口和出口插入监控代码。程序运行时,这些代码会记录函数调用关系、调用次数、以及每个函数的执行时间。
程序退出时,这些数据会写入 gmon.out 文件。然后你用 gprof 命令解析这个文件,就能得到调用图和热点函数列表。
关键点: gprof 是基于“统计采样”的,不是精确计时。它适合分析长时间运行的程序,对于短时间(比如几毫秒)的调用,误差可能较大。
我曾经在一个嵌入式项目中,用 gprof 分析一个通信协议栈。程序跑了大概 10 分钟,gprof 报告显示某个校验函数占了 40% 的 CPU 时间。我一开始还不信,觉得那个函数很简单。后来仔细一看,原来它在循环里被调用了上百万次,每次只做一点点计算,但积少成多,就成了热点。
17.2 如何使用 gprof
使用步骤很简单,就三步:
- 编译时加
-pg选项 - 正常运行程序(程序退出后会自动生成
gmon.out) - 运行
gprof命令生成报告
来看一个具体的例子。假设我们有这样一个简单的程序:
// main.c
#include <stdio.h>
void func_a() {
int i;
for (i = 0; i < 1000000; i++);
}
void func_b() {
int i;
for (i = 0; i < 500000; i++);
func_a();
}
int main() {
func_a();
func_b();
return 0;
}
编译和运行:
gcc -pg -o test main.c
./test
gprof test gmon.out > report.txt
打开 report.txt,你会看到两部分内容:平坦分析(Flat Profile) 和 调用图(Call Graph)。
17.3 解读平坦分析(Flat Profile)
平坦分析,说白了就是一张“谁最耗时”的排行榜。它告诉你每个函数占用了多少 CPU 时间,以及被调用了多少次。
典型的输出格式如下:
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls ms/call ms/call name
60.0 0.06 0.06 2 30.00 30.00 func_a
40.0 0.10 0.04 1 40.00 70.00 func_b
0.0 0.10 0.00 1 0.00 70.00 main
我来解释一下各列的含义:
| 列名 | 含义 |
|---|---|
% time |
该函数占用的 CPU 时间百分比 |
cumulative seconds |
累计时间(从程序开始到该函数结束) |
self seconds |
函数自身执行的时间(不包括子函数) |
calls |
函数被调用的次数 |
self ms/call |
每次调用自身消耗的时间(毫秒) |
total ms/call |
每次调用总消耗时间(包括子函数) |
我的习惯: 先看 % time 列,找到占比最高的函数。如果某个函数占了 30% 以上,那它大概率就是优化目标。然后看 self ms/call,判断这个函数本身是否耗时,还是它的子函数在拖后腿。
17.4 解读调用图(Call Graph)
调用图展示的是函数之间的调用关系,以及每条调用路径上的时间开销。它比平坦分析更详细,能帮你理解“时间到底花在了哪条路径上”。
典型的调用图输出:
Call graph (explanation follows)
granularity: each sample hit covers 2 byte(s) for 10.00% of 0.10 seconds
index % time self children called name
0.06 0.00 2/2 main [2]
[1] 60.0 0.06 0.00 2 func_a [1]
-----------------------------------------------
0.04 0.06 1/1 main [2]
[2] 100.0 0.04 0.06 1 func_b [2]
0.06 0.00 1/1 func_a [1]
-----------------------------------------------
0.00 0.10 1/1 <spontaneous>
[3] 100.0 0.00 0.10 1 main [3]
0.04 0.06 1/1 func_b [2]
0.06 0.00 1/1 func_a [1]
嗯,这里要注意,调用图的格式看起来有点乱,但核心信息就几点:
- 方括号里的数字(如
[1])是函数索引,方便交叉引用。 self列:函数自身消耗的时间。children列:该函数调用的子函数消耗的时间。called列:调用次数,格式是被调用次数/总调用次数。- 缩进关系:缩进的函数是被上一级函数调用的。
举个例子,看 [2] 这一块:
func_b被main调用了 1 次。func_b自身消耗了 0.04 秒。func_b调用了func_a,子函数消耗了 0.06 秒。- 所以
func_b的总时间是 0.04 + 0.06 = 0.10 秒。
避坑指南: 我曾经在解读调用图时,看到某个函数 self 时间很小,但 children 时间很大,就以为它不重要。后来才发现,它调用了大量子函数,这些子函数才是真正的热点。所以,一定要结合 self 和 children 一起看,别只看表面。
17.5 如何找到真正的热点函数
热点函数,就是那些占用 CPU 时间最多的函数。但要注意,热点不一定在“最外层”的函数里。有时候,一个看似简单的函数,因为被调用了成千上万次,反而成了瓶颈。
我的经验是,按以下步骤来定位:
- 先看平坦分析,找到
% time最高的前 3-5 个函数。 - 再看调用图,确认这些函数是被谁调用的,调用路径是否合理。
- 检查
self ms/call,如果某个函数每次调用耗时很长(比如超过 1 毫秒),那它本身可能就是优化目标。 - 检查调用次数,如果某个函数被调用了上百万次,即使每次只花 1 微秒,累计起来也很可观。
核心原则: 优化“调用次数多”的函数,比优化“单次耗时长”的函数,往往收益更大。因为前者是乘法效应,后者是加法效应。
17.6 知识体系图
下面这张图,帮你梳理 gprof 的核心流程和解读思路:
17.7 实战中的注意事项
最后,分享几个我在实际项目中踩过的坑:
- 别忘了加
-pg编译所有文件。 如果只给主文件加,其他文件没有插桩,gprof 会漏掉很多信息。 - 程序正常退出才能生成
gmon.out。 如果程序被kill -9或者崩溃了,数据就没了。我建议在程序退出前加个atexit()回调,确保数据写入。 - 多线程程序慎用 gprof。 gprof 对多线程的支持很弱,它只能分析主线程。如果你用多线程,建议换 perf 或 Valgrind。
- 优化后记得重新编译。 这个听起来像废话,但我真见过有人改完代码忘了重新编译,拿着旧报告分析半天。
小技巧: 如果你觉得 gprof 的输出太乱,可以用 gprof -b 去掉解释性文字,只保留数据部分。或者用 gprof -l 显示行级别的信息,定位到具体代码行。
好了,gprof 的基本用法和解读方法就聊到这里。它虽然老,但胜在简单可靠。下次你遇到性能问题,不妨先跑一遍 gprof,看看热点在哪,再动手优化。相信我,这比瞎猜要高效得多。