notes/article/ebpf/eBPF内存泄露检测代码实现v3.md

19 KiB
Raw Permalink Blame History

eBPF内存泄露检测代码实现<完整版>

[toc]

说明:

eBPF内存泄露检测代码实现是在 libbpf-bootstrap 框架下开发需要的基础知识请参考之前的ebpf系列视频

本节视频使用的是 Ubuntu18.04 x86-64 平台

目标

  • 支持如下用户态内存分配接口的内存泄露检测
void *malloc(size_t size);
void *calloc(size_t nmemb, size_t size);
void *realloc(void *ptr, size_t size);
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
void *memalign(size_t alignment, size_t size);
void *valloc(size_t size);
void *pvalloc(size_t size);
void *aligned_alloc(size_t alignment, size_t size);
int posix_memalign(void **memptr, size_t alignment, size_t size);
  • 实现可执行文件一启动就开始内存泄露检测

BPF_KRETPROBE 宏展开

因为 malloc uretprobe 中的 bpf_get_stackid 接口涉及到一个 struct pt_regs *ctx 的参数,

info.stack_id = bpf_get_stackid(ctx, &stack_traces, USER_STACKID_FLAGS);

参考 <eBPF示例x86-64平台上的kprobe> 视频中 BPF_KPROBE 宏展开方法,对 BPF_KRETPROBE 宏进行展开:

SEC("uretprobe")
int BPF_KRETPROBE(malloc_exit, void * address)
{
    /* malloc uretuprobe 的处理逻辑 */
}

//////////////////////// 宏展开 ////////////////////////

__attribute__((section("uretprobe"), used))
// 函数的声明
int malloc_exit(struct pt_regs *ctx);
static inline int ____malloc_exit(struct pt_regs *ctx, void * address); 

// 函数的定义
int malloc_exit(struct pt_regs *ctx) {
    return ____malloc_exit(ctx, (void *)((ctx)->ax));
}

static inline int ____malloc_exit(struct pt_regs *ctx, void * address)
{
    /* malloc uretuprobe 的处理逻辑 */
}

/* 对于uprobe和uretprobe
 * struct pt_regs: 保存用户态的寄存器上下文,可以存放用户态接口的参数或者返回值
 * 不同的平台, struct pt_regs 中成员变量的定义不一样:
 * x86-64平台(libbpf-bootstrap/vmlinux/x86/vmlinux.h)
     struct pt_regs {
            long unsigned int r15;
            long unsigned int r14;
            long unsigned int r13;
            long unsigned int r12;
            long unsigned int bp;
            long unsigned int bx;
            long unsigned int r11;
            long unsigned int r10;
            long unsigned int r9;
            long unsigned int r8;
            long unsigned int ax;
            long unsigned int cx;
            long unsigned int dx;
            long unsigned int si;
            long unsigned int di;
            long unsigned int orig_ax;
            long unsigned int ip;
            long unsigned int cs;
            long unsigned int flags;
            long unsigned int sp;
            long unsigned int ss;
    };
 * arm32 平台(libbpf-bootstrap/vmlinux/arm/vmlinux.h)
 	struct pt_regs {
        long unsigned int uregs[18];
    };
 * 
 * 参考头文件: libbpf-bootstrap/libbpf/src/bpf_tracing.h
 * 假设 uprobe 和 uretprobe attach 到 void *malloc(size_t size) 接口:
 * malloc 的第一个参数可以通过 PT_REGS_PARM1(ctx)获取,在 x86-64 平台上:
 *    size = PT_REGS_PARM1(ctx) = ctx->di
 * malloc 的返回值可以通过 PT_REGS_RC(ctx)获取,在 x86-64 平台上:
 *    malloc的返回值 void * address = PT_REGS_RC(ctx) = ctx->ax
 * 使用 PT_REGS_PARM1 和 PT_REGS_RC 宏可以屏蔽平台之间的差异;
 */

提取公共的代码

针对 mallocfree 的内存泄露检测逻辑抽象图:

memleak处理流程-逻辑抽象.jpg

malloc内存分配接口: 分配的内存大小size分配的内存指针ptrstack_id,经过

  • malloc uprobe
  • malloc uretuprobe
  • free uprobe

处理逻辑后,最终输出一个内存统计信息 union combined_alloc_info

现在把malloc uprobemalloc uretuprobefree uprobe 处理逻辑提取成公共代码:

/* 通用的内存分配 uprobe的处理逻辑
 * 内存分配接口(malloc, calloc等)进入后就会被调用
 * size: 分配内存的大小, 比如 malloc 的第一个参数
 */
static int gen_alloc_enter(size_t size)
{
	const pid_t pid = bpf_get_current_pid_tgid() >> 32;

	bpf_map_update_elem(&sizes, &pid, &size, BPF_ANY);

	return 0;
}

/* 通用的内存分配 uretprobe的处理逻辑
 * 内存分配接口(malloc, calloc等)返回时就会被调用
 * ctx: struct pt_regs 指针, 参考 BPF_KRETPROBE 的宏展开
 * address: 分配成功的内存指针, 比如 malloc 的返回值
 */
static int gen_alloc_exit2(void *ctx, u64 address)
{
	const u64 addr = (u64)address;
	const pid_t pid = bpf_get_current_pid_tgid() >> 32;
	struct alloc_info info;

	const u64 * size = bpf_map_lookup_elem(&sizes, &pid);
	if (NULL == size) {
		return 0;
	}

	__builtin_memset(&info, 0, sizeof(info));
	info.size = *size;

	bpf_map_delete_elem(&sizes, &pid);

	if (0 != address) {
		info.stack_id = bpf_get_stackid(ctx, &stack_traces, USER_STACKID_FLAGS);

		bpf_map_update_elem(&allocs, &addr, &info, BPF_ANY);

		union combined_alloc_info add_cinfo = {
			.total_size = info.size,
			.number_of_allocs = 1
		};

		union combined_alloc_info * exist_cinfo = bpf_map_lookup_elem(&combined_allocs, &info.stack_id);
		if (NULL == exist_cinfo) {
			bpf_map_update_elem(&combined_allocs, &info.stack_id, &add_cinfo, BPF_NOEXIST);
		}
		else {
			__sync_fetch_and_add(&exist_cinfo->bits, add_cinfo.bits);
		}
	}

	return 0;
}

/* 把 gen_alloc_exit2 接口中的2个参数精简为1个参数 
 * 参考 BPF_KRETPROBE 的宏展开过程
 */
static int gen_alloc_exit(struct pt_regs *ctx)
{
	return gen_alloc_exit2(ctx, PT_REGS_RC(ctx));
}

/* 通用的内存释放 uprobe的处理逻辑
 * 内存释放接口(free, munmap等)进入后就会被调用
 * address: 需要释放的内存指针, 比如 free 的第一个参数
 */
static int gen_free_enter(const void *address)
{
	const u64 addr = (u64)address;

	const struct alloc_info * info = bpf_map_lookup_elem(&allocs, &addr);
	if (NULL == info) {
		return 0;
	}

	union combined_alloc_info * exist_cinfo = bpf_map_lookup_elem(&combined_allocs, &info->stack_id);
	if (NULL == exist_cinfo) {
		return 0;
	}

	const union combined_alloc_info sub_cinfo = {
		.total_size = info->size,
		.number_of_allocs = 1
	};

	__sync_fetch_and_sub(&exist_cinfo->bits, sub_cinfo.bits);

	bpf_map_delete_elem(&allocs, &addr);

	return 0;
}

memleak.bpf.cmallocfreeuprobeuretprobe代码实现:

SEC("uprobe")
int BPF_KPROBE(malloc_enter, size_t size)
{
	return gen_alloc_enter(size);
}

SEC("uretprobe")
int BPF_KRETPROBE(malloc_exit)
{
	return gen_alloc_exit(ctx);
}

SEC("uprobe")
int BPF_KPROBE(free_enter, void * address)
{
	return gen_free_enter(address);
}

用宏来简化 memleak.c (用户态的ebpf)中的 bpf_program__attach_uprobe_opts 接口调用:

参考:https://github.com/eunomia-bpf/bpf-developer-tutorial 中 memleak 的实现

#define __ATTACH_UPROBE(skel, sym_name, prog_name, is_retprobe) \
	do { \
		LIBBPF_OPTS(bpf_uprobe_opts, uprobe_opts, \
				.func_name = #sym_name, \
				.retprobe = is_retprobe); \
		skel->links.prog_name = bpf_program__attach_uprobe_opts( \
				skel->progs.prog_name, \
				attach_pid, \
				binary_path, \
				0, \
				&uprobe_opts); \
	} while (false)

#define __CHECK_PROGRAM(skel, prog_name) \
	do { \
		if (!skel->links.prog_name) { \
			perror("no program attached for " #prog_name); \
			return -errno; \
		} \
	} while (false)

#define __ATTACH_UPROBE_CHECKED(skel, sym_name, prog_name, is_retprobe) \
	do { \
		__ATTACH_UPROBE(skel, sym_name, prog_name, is_retprobe); \
		__CHECK_PROGRAM(skel, prog_name); \
	} while (false)

/* ATTACH_UPROBE_CHECKED 和 ATTACH_UPROBE 宏的区别是:
 * ATTACH_UPROBE_CHECKED 会检查elf文件中(比如 libc.so)中是否存在 uprobe attach 的符号(比如malloc)
 * 如果不存在,返回错误;
 * ATTACH_UPROBE 发现符号不存在时不会返回错误直接跳过这个符号的uprobe attach,继续往下执行;
 */
#define ATTACH_UPROBE(skel, sym_name, prog_name) __ATTACH_UPROBE(skel, sym_name, prog_name, false)
#define ATTACH_URETPROBE(skel, sym_name, prog_name) __ATTACH_UPROBE(skel, sym_name, prog_name, true)

#define ATTACH_UPROBE_CHECKED(skel, sym_name, prog_name) __ATTACH_UPROBE_CHECKED(skel, sym_name, prog_name, false)
#define ATTACH_URETPROBE_CHECKED(skel, sym_name, prog_name) __ATTACH_UPROBE_CHECKED(skel, sym_name, prog_name, true)

bpf_program__attach_uprobe_opts 替换:

int main(int argc, char **argv)
{
    uprobe_opts.func_name = "malloc";
    uprobe_opts.retprobe = false;
    skel->links.malloc_enter = bpf_program__attach_uprobe_opts(skel->progs.malloc_enter,
        attach_pid, binary_path, 0, &uprobe_opts);
    if (!skel->links.malloc_enter) {
        err = -errno;
        fprintf(stderr, "Failed to attach uprobe: %d\n", err);
        goto cleanup;
    }

    uprobe_opts.func_name = "malloc";
    uprobe_opts.retprobe = true;
    skel->links.malloc_exit = bpf_program__attach_uprobe_opts(
        skel->progs.malloc_exit, attach_pid, binary_path, 0, &uprobe_opts);
    if (!skel->links.malloc_exit) {
        err = -errno;
        fprintf(stderr, "Failed to attach uprobe: %d\n", err);
        goto cleanup;
    }

    uprobe_opts.func_name = "free";
    uprobe_opts.retprobe = false;
    skel->links.free_enter = bpf_program__attach_uprobe_opts(skel->progs.free_enter,
        attach_pid, binary_path, 0, &uprobe_opts);
    if (!skel->links.free_enter) {
        err = -errno;
        fprintf(stderr, "Failed to attach uprobe: %d\n", err);
        goto cleanup;
    }
    
    return 0;
}

//////////////////////// 替换为 ////////////////////////

int attach_uprobes(struct memleak_bpf *skel)
{
	ATTACH_UPROBE_CHECKED(skel, malloc, malloc_enter);
	ATTACH_URETPROBE_CHECKED(skel, malloc, malloc_exit);
	ATTACH_UPROBE_CHECKED(skel, free, free_enter);

	return 0;
}

int main(int argc, char **argv)
{
    err = attach_uprobes(skel);
    if (err) {
        fprintf(stderr, "failed to attach uprobes\n");
        goto cleanup;
    }
    
    return 0;
}

修改后的代码,编译验证通过;

posix_memalign 的内存泄露检测实现过程

posix_memalign 接口定义:

int posix_memalign(void **memptr, size_t alignment, size_t size);
void *malloc(size_t size);
//posix_memalign 对应的内存释放接口
void free(void *ptr);

posix_memalignmalloc 对于内存泄露检测来说,最大的区别就是 分配的内存指针 返回方式不一样:

malloc : 通过接口返回值返回 分配的内存指针,直接通过 uretprobe 就可以获取 分配的内存指针;

posix_memalign:把 分配的内存指针 保存到 用户态的指针变量中(memptr),因为 用户态的指针变量(memptr)uprobe中获取,而把 分配的内存指针 保存到用户态的指针变量(memptr)是在 uretprobe 中,所以需要一个 ebpf 的 hash map 来临时存储在uprobe中获取到的 用户态的指针变量(memptr)

memleak处理流程-posix_memalign.jpg

修改测试代码:

static void * alloc_v3(int alloc_size)
{
    void * memptr = NULL;
    posix_memalign(&memptr, 128, 1024);
    return memptr;
}

memleak.bpf.c 中的代码实现:

struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__type(key, u64); // pid
	__type(value, u64); // 用户态指针变量 memptr
	__uint(max_entries, 10240);
} memptrs SEC(".maps");

SEC("uprobe")
int BPF_KPROBE(posix_memalign_enter, void **memptr, size_t alignment, size_t size)
{
	const u64 memptr64 = (u64)(size_t)memptr;
	const u64 pid = bpf_get_current_pid_tgid() >> 32;
	bpf_map_update_elem(&memptrs, &pid, &memptr64, BPF_ANY);

	return gen_alloc_enter(size);
}

SEC("uretprobe")
int BPF_KRETPROBE(posix_memalign_exit)
{
	const u64 pid = bpf_get_current_pid_tgid() >> 32;
	u64 *memptr64;
	void *addr;

	memptr64 = bpf_map_lookup_elem(&memptrs, &pid);
	if (!memptr64)
		return 0;

	bpf_map_delete_elem(&memptrs, &pid);

    //通过 bpf_probe_read_user 读取保存在用户态指针变量(memptr64)中的 分配成功的内存指针
	if (bpf_probe_read_user(&addr, sizeof(void*), (void*)(size_t)*memptr64))
		return 0;

	const u64 addr64 = (u64)(size_t)addr;

	return gen_alloc_exit2(ctx, addr64);
}

memleak.c 中的代码实现:

// 在 attach_uprobes 接口中增加代码:
int attach_uprobes(struct memleak_bpf *skel)
{
    /* ...... */
	ATTACH_UPROBE_CHECKED(skel, posix_memalign, posix_memalign_enter);
	ATTACH_URETPROBE_CHECKED(skel, posix_memalign, posix_memalign_exit);
    /* ...... */
}

内存泄露检测的完整实现

//void *malloc(size_t size);
void *calloc(size_t nmemb, size_t size);
void *realloc(void *ptr, size_t size);
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
void *memalign(size_t alignment, size_t size);
void *valloc(size_t size);
void *pvalloc(size_t size);
void *aligned_alloc(size_t alignment, size_t size);
//int posix_memalign(void **memptr, size_t alignment, size_t size);

剩下的内存分配接口和 malloc 相差不大,都是通过返回值来获取分配成功的内存指针,就不一一讲解了;

memleak.bpf.c 文件中增加如下代码uprobe 和 uretprobe 处理逻辑调用的都是之前定义的通用接口:

SEC("uprobe")
int BPF_KPROBE(calloc_enter, size_t nmemb, size_t size)
{
	return gen_alloc_enter(nmemb * size);
}

SEC("uretprobe")
int BPF_KRETPROBE(calloc_exit)
{
	return gen_alloc_exit(ctx);
}

SEC("uprobe")
int BPF_KPROBE(realloc_enter, void *ptr, size_t size)
{
	gen_free_enter(ptr);

	return gen_alloc_enter(size);
}

SEC("uretprobe")
int BPF_KRETPROBE(realloc_exit)
{
	return gen_alloc_exit(ctx);
}

SEC("uprobe")
int BPF_KPROBE(mmap_enter, void *address, size_t size)
{
	return gen_alloc_enter(size);
}

SEC("uretprobe")
int BPF_KRETPROBE(mmap_exit)
{
	return gen_alloc_exit(ctx);
}

SEC("uprobe")
int BPF_KPROBE(munmap_enter, void *address)
{
	return gen_free_enter(address);
}

SEC("uprobe")
int BPF_KPROBE(aligned_alloc_enter, size_t alignment, size_t size)
{
	return gen_alloc_enter(size);
}

SEC("uretprobe")
int BPF_KRETPROBE(aligned_alloc_exit)
{
	return gen_alloc_exit(ctx);
}

SEC("uprobe")
int BPF_KPROBE(valloc_enter, size_t size)
{
	return gen_alloc_enter(size);
}

SEC("uretprobe")
int BPF_KRETPROBE(valloc_exit)
{
	return gen_alloc_exit(ctx);
}

SEC("uprobe")
int BPF_KPROBE(memalign_enter, size_t alignment, size_t size)
{
	return gen_alloc_enter(size);
}

SEC("uretprobe")
int BPF_KRETPROBE(memalign_exit)
{
	return gen_alloc_exit(ctx);
}

SEC("uprobe")
int BPF_KPROBE(pvalloc_enter, size_t size)
{
	return gen_alloc_enter(size);
}

SEC("uretprobe")
int BPF_KRETPROBE(pvalloc_exit)
{
	return gen_alloc_exit(ctx);
}

memleak.c 文件中的 attach_uprobes 接口中增加如下代码:

int attach_uprobes(struct memleak_bpf *skel)
{
    /* ...... */

	ATTACH_UPROBE_CHECKED(skel, calloc, calloc_enter);
	ATTACH_URETPROBE_CHECKED(skel, calloc, calloc_exit);

	ATTACH_UPROBE_CHECKED(skel, realloc, realloc_enter);
	ATTACH_URETPROBE_CHECKED(skel, realloc, realloc_exit);

	ATTACH_UPROBE_CHECKED(skel, mmap, mmap_enter);
	ATTACH_URETPROBE_CHECKED(skel, mmap, mmap_exit);

	ATTACH_UPROBE_CHECKED(skel, memalign, memalign_enter);
	ATTACH_URETPROBE_CHECKED(skel, memalign, memalign_exit);

	ATTACH_UPROBE_CHECKED(skel, free, free_enter);
	ATTACH_UPROBE_CHECKED(skel, munmap, munmap_enter);

	// the following probes are intentinally allowed to fail attachment

	// deprecated in libc.so bionic
	ATTACH_UPROBE(skel, valloc, valloc_enter);
	ATTACH_URETPROBE(skel, valloc, valloc_exit);

	// deprecated in libc.so bionic
	ATTACH_UPROBE(skel, pvalloc, pvalloc_enter);
	ATTACH_URETPROBE(skel, pvalloc, pvalloc_exit);

	// added in C11
	ATTACH_UPROBE(skel, aligned_alloc, aligned_alloc_enter);
	ATTACH_URETPROBE(skel, aligned_alloc, aligned_alloc_exit);
    
    /* ...... */
}

修改后的代码,编译验证通过;

C++中的new可以被内存泄露检测吗

测试代码:

static void * alloc_v3(int alloc_size)
{
    void * ptr = new char[alloc_size];
    
    return ptr;
}

// main 函数中 
// free(ptr); 
// 改为
// delete [] (char *)ptr;

检测结果:

stack_id=0x3d21 with outstanding allocations: total_size=8 nr_allocs=2
  0 [<00007f71f1a1a298>] operator new(unsigned long)+0x18
  1 [<000055deb2ba2741>] alloc_v2(int)+0x15 test_memleak.cpp:33
  2 [<000055deb2ba2760>] alloc_v1(int)+0x15 test_memleak.cpp:40
  3 [<000055deb2ba27a0>] main+0x36 test_memleak.cpp:53
  4 [<00007f71f15b7c87>] __libc_start_main+0xe7
  5 [<05a6258d4c544155>]

打印的堆栈不完整,没看到调用 new 的接口,也没有看到 new 对应的文件名和行号,用 ldd 命令查看测试程序使用的libstdc++库:

# ldd test_memleak
	linux-vdso.so.1 (0x00007fff74deb000)
	libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f50e191e000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f50e152d000)
	libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f50e118f000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f50e1ea9000)
	libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f50e0f77000)

# ls -la /usr/lib/x86_64-linux-gnu/libstdc++.so.6
lrwxrwxrwx 1 root root 19 3月  10  2020 /usr/lib/x86_64-linux-gnu/libstdc++.so.6 -> libstdc++.so.6.0.25

# file /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.25
/usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.25: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, BuildID[sha1]=f2119a44a99758114620c8e9d8e243d7094f77f6, stripped

## 可以看到 libstdc++.so.6.0.25 被striped掉了堆栈不完整应该是这个原因

如何实现可执行文件一启动就开始内存泄露检测?

memleak处理流程-内存泄露启动流程.jpg

上图左边就是现在的内存泄露检测启动流程;

如何实现上图右边的启动流程?

参考 https://www.kernel.org/doc/html/latest/trace/ftrace.html 中的 Single thread tracing 章节,写个内存泄露检测的启动脚本start_memleak.sh

#!/bin/bash

# 使用示例: 
# sh start_memleak.sh ./test/test_memleak

# $$ 表示脚本运行的当前进程ID号使用示例中的 test_memleak 启动时就会继承这个进程ID
# 启动内存泄露检测工具 memleak 时,就可以预先知道测试程序 test_memleak 的进程ID
sudo ./memleak $$ &

# $@ 表示传给脚本的所有参数的列表,即使用示例中的 ./test/test_memleak
exec "$@"

这个shell脚本就可以先启动 memleak 再启动测试程序 test_memleak

因为 memleak 用的是sudo,且在后台运行,结束后需要执行 sudo killall memleakmemleak 进程退出;