内存泄漏是 C 开发非常经典的问题,目前已经存在很多优秀的内存检测工具,比如强大的 Valgrind。
借助 ebpftrace 我们可以非常简单、直观的找到泄漏的位置,而且非常灵活,自己决定其中的细节。
文中我构建了一个 leak.c
,其中同时存在正确释放的内存和泄漏的内存,模拟一些比较复杂的内存泄漏情况。通过 ebpftrace 对运行中的 leak
程序统计内存的申请和释放过程定位内存泄漏,也就是 probe 两个关键的接口调用:
- malloc
- free
这两个接口的实现在 libc
中,后面我们会找到 leak
连接的准确 libc.so
文件。
leak.c
#include <stdio.h>
#include <stdlib.h>
#include <curses.h>
char * wrapper1(){
return malloc(8);
}
char * wrapper2(){
// leak here
char * p = wrapper1();
return malloc(16);
}
int main(){
char * p = NULL;
for(int i = 0; i < 5; i ++){
p = wrapper2();
free(p);
}
/* wait */
getchar();
return 0;
}
代码非常简单,wrapper2
内调用 wrapper1
申请到的内存没有被返回,当然永远也不会被释放,发生了泄漏;而 wrapper2
自己 malloc
一块内存返回,并在 main 中被正确释放。
wrapper1
中申请的内存泄漏了;wrapper2
中申请的内存没有泄漏;
只需要简单的编译即可使用:
gcc leak.c -o leak -g
我的工作路径是: /home/test
探测 leak 中的 malloc 调用
bpftrace 主要的 probe 类型:
- kprobe/kretprobe,内核探针
- uprobe/uretprobe,用户态探针
本文的目标是 leak
程序,一个用户态进程,显然应当使用 用户态探针:uprobe/uretprobe。
首先通过 uprobe
探测 malloc
调用。
bpftrace -e 'uprobe:/home/test/leak:malloc {printf("malloc call\n")}'
参数格式是 uprobe
:可执行文件
:函数名
,看起来一切都没有问题,但是运行上面的指令会得到这样的输出:
No probes to attach
原因是在 /home/test/leak
中找不到符号 malloc
,我们可以自己确认这一事实:
# nm leak | grep malloc
U malloc@@GLIBC_2.2.5
可以看到 malloc
是一个链接自 GLIBC_2.2.5
的符号,并不是 leak
自身的符号,因此参数中可执行文件名应当改为 GLIBC_2.2.5
对应的 so 文件 (Linux 系统的动态链接库),具体方法稍后介绍。
其实,前面的指令只要修改需要探测的函数名为 leak.c 中定义的函数,也可以正常工作,比如:
# bpftrace -e 'uprobe:/home/test/leak:wrapper1 {printf("wrapper call\n")}'
Attaching 1 probe...
wrapper call
.... 省略一些输出
wrapper call
只不过探测的是 leak
自身的函数:wrapper1
,并不是我们关注的目标。
所以,uprobe 需要指定准确的可执行文件,和该文件中的一个符号(函数)。
需要注意 printf
中的换行符 \n
,如果没有换行符,数据只会在缓存中,只能在 bpftrace 退出时打印出结果,如果希望实时看到输出,就不要忘记它。
让我们继续完成 malloc
的探测,系统中可能存在多个 GLIBC 库,我们必须找到 leak
程序连接的准确文件:
# ldd leak
linux-vdso.so.1 (0x00007ffc0afaa000)
libc.so.6 => /lib64/libc.so.6 (0x00007f55ea078000)
/lib64/ld-linux-x86-64.so.2 (0x00007f55ea43d000)
第二行即 leak
链接的 libc 动态库文件,将前面 bpftrace 参数中 “可执行文件” 替换为 /lib64/libc.so.6
,再次尝试:
bpftrace -e 'uprobe:/lib64/libc.so.6:malloc {printf("malloc call\n")}'
屏幕立刻输出了大量内容,但 leak
还没有开始运行啊?!
其实这些输出是系统中其他进程调用 malloc
引起的,动态库在系统中被所有进程共享,显然我们只关心 leak
程序的 malloc
调用,其他的应当被忽略。
我们需要在此参数基础上,增加 filter
,过滤掉无关进程引起的函数调用事件。
bpftrace 提供了系统变量 comm
表示可执行文件名 (进程名),只需要在上述指令中增加 filter,只处理 comm =="leak"
的 malloc
调用事件:
# bpftrace -e 'uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{printf("malloc call\n")}'
Attaching 1 probe...
bpftrace 提示 Attaching 1 probe...
,成功了,只有 leak
运行时才会触发输出。
不要关闭 bpftrace 所在终端,在另外一个终端运行 leak
,在 bpftrace 所在终端会看到这样的输出:
# bpftrace -e 'uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{printf("malloc call\n")}'
Attaching 1 probe...
malloc call
.... 省略一些输出
malloc call
malloc call
成功探测到 leak
的 malloc
调用!
目前参数已经变得很长了,最好将它们放在一个脚本文件 trace.bt
中,便于编辑。
uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
printf("malloc call\n");
}
再次运行它:
# bpftrace trace.bt
然后运行 leak
程序,会得到相同的结果。
探测 malloc 的 size
bpftrace 的 uprobe 和 kprobe 可以通过内置变量 arg0
、arg1
··· ··· 访问函数参数,对 trace.bt
稍作修改就可以打印 malloc 的参数:
uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
printf("malloc(%d)\n", arg0);
}
malloc 的原型:
void *malloc(size_t size);
第一个参数是整数,直接按 C 语言风格输出就可以。
运行新的 trace.bt
:
# bpftrace trace.bt
Attaching 1 probe...
malloc(8)
malloc(8)
malloc(16)
.... 省略一些输出
malloc(8)
malloc(16)
malloc(1024)
可以看到 malloc
调用和对应的参数,最后一个 malloc(1024)
是 leak
自动创建输出缓冲区内存申请,其他的 malloc
都是 wrapper1
和 wrapper2
中的内存申请,参数与 leak.c
源码一致。
探测 malloc 的返回值
我们更关心的是 malloc
返回的内存地址,需借助 uretprobe 进行探测,函数返回值可通过内置变量 retval
访问。uretprobe 的 filter 与 malloc
参数探测时类似:
uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
printf("malloc(%d)\n", arg0);
}
uretprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
printf("addr = %p\n", retval);
}
再次运行 bpftrace:
# bpftrace trace.bt
Attaching 2 probes...
malloc(8)
malloc(8)
addr = 0xb892a0
addr = 0xb892a0
malloc(16)
.... 省略一些输出
malloc(16)
addr = 0xb89340
malloc(1024)
addr = 0xb89360
此时 trace.bt
中定义了 2 个 probe,分别用于探测 malloc
申请的内存大小和返回的地址。
探测 free 的地址
目前已经可以准确捕获 leak
申请内存的大小和返回地址,下一步探测 free
了哪些地址,只需要对比 malloc
和 free
内存地址集合的差异,就能找到内存泄漏。
参考探测 malloc
的方法,探测 free
调用非常简单,free
的内存地址正好是其第一个参数,我们使用 uprobe
即可,下面继续扩展 trace.bt
:
uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
printf("malloc(%d)\n", arg0);
}
uretprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
printf("alloc addr = %p\n", retval);
}
/* 探测 free 的地址 */
uprobe:/lib64/libc.so.6:free /comm == "leak"/{
printf("free addr = %p\n", arg0);
}
运行 trace.bt
:
# bpftrace trace.bt
Attaching 3 probes...
malloc(8)
malloc(8)
alloc addr = 0x19e72a0
alloc addr = 0x19e72a0
malloc(16)
.... 省略一些输出
alloc addr = 0x19e7340
free addr = 0x19e7340
malloc(1024)
alloc addr = 0x19e7360
探测泄漏地址
现在已经获得了:
malloc
的大小、地址;free
的地址;
只需计算二者地址集合的差,就可以得到泄漏的地址有哪些了。
bpftrace 底层使用的是 eBPF 的 map 作为存储结构,可以简单的看作 K-V 存储,比如:
# bpftrace -e 'BEGIN{@kv["hello"] = 1; @kv["world"] = 2 } END{delete(@kv["world"]); return;}'
Attaching 2 probes...
^C
@kv[hello]: 1
BEGIN
中 在 @kv
新增了 “hello”->1, 'world' ->2 两组值,END
通过 delete
方法移除了 @kv
中的 ‘world’->2。bpftrace 结束时自动输出 @kv
, 其中只剩下 “hello”->1 一组值。
这里注意 3 点:
@kv
在多个 probe 之间共享;- 对
@kv[KEY]
赋值VALUE
即创建对应对 KEY-VALUE; - 调用
delete(@kv[KEY])
函数删除 KEY-VALUE;
那么可以用一个 map @mem
保存 malloc
返回的内存地址,当发生 free
调用时,从 @mem
中删除释放的地址,最后 @mem
中剩余的就是泄漏的内存地址。
uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
printf("malloc(%d)\n", arg0);
@size = arg0;
}
uretprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
printf("alloc addr = %p\n", retval);
/*
@size 在第一个 probe 被赋值, 内存大小。
@mem 中记录的是 内存地址 -> 内存大小
*/
@mem[retval] = @size;
}
uprobe:/lib64/libc.so.6:free /comm == "leak"/{
printf("free addr = %p\n", arg0);
/* arg0 是 free 的内存地址,删除被 free 的地址记录 */
delete(@mem[arg0])
}
可以看到这样的结果:
# bpftrace trace.bt
Attaching 3 probes...
malloc(8)
.... 省略一些输出
alloc addr = 0x225b360
^C
@mem[36025120]: 8
@mem[36025056]: 8
@mem[36024992]: 8
@mem[36025024]: 8
@mem[36025088]: 8
@mem[36025184]: 1024
@size: 1024
可以看到 @mem
中记录了一些内存地址和它们的大小,这些大小都是 8,对比 leak.c
代码:
char * wrapper1(){
return malloc(8);
}
char * wrapper2(){
char * p = wrapper1();
return malloc(16);
}
可知 wrapper1
中的 malloc
发生了泄漏,与预期一致。
准确定位内存泄漏
上一部分已经可以得到有价值结论:发生了内存,并且根据内存大小确定了发生泄漏的位置。
但更普遍的情况下,通过内存地址或内存大小,无法确认泄漏的位置,更可靠的方法是获得内存泄漏时的调用栈。
bpftrace 的内置函数 ustack
可以获得用户态程序当前调用栈,并且可以接受参数指定获取的栈深度,默认为获得最大深度栈。
前文 @mem
中记录的是内存地址->内存大小,我们将其修改为记录内存地址->栈:
uprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
printf("malloc(%d)\n", arg0);
@size = arg0;
}
uretprobe:/lib64/libc.so.6:malloc /comm == "leak"/{
printf("alloc addr = %p\n", retval);
/* 修改了这里 */
// @mem[retval] = @size;
@mem[retval] = ustack();
}
uprobe:/lib64/libc.so.6:free /comm == "leak"/{
printf("free addr = %p\n", arg0);
delete(@mem[arg0])
}
运行结果:
# bpftrace trace.bt
Attaching 3 probes...
malloc(8)
.... 省略一些输出
alloc addr = 0x206a360
^C
@mem[33989280]:
wrapper1+14
wrapper2+18
main+35
__libc_start_main+243
0x5541f689495641d7
.... 省略一些输出
@mem[33989408]:
wrapper1+14
wrapper2+18
main+35
__libc_start_main+243
0x5541f689495641d7
@mem[33989472]:
_IO_file_doallocate+144
@size: 1024
可以看到泄漏的内存都是在 wrapper1+14
申请,已经成功定位内存泄漏的位置。