当内核有内存泄漏的时候

内存泄露是一个很容易出现的问题,尤其是对于测试不太充分的代码。怎么判断出现内存泄露了呢?很简单,就跑一些简单的测试,等待足够长时间即可。内存总有耗尽的时候,这时候内核会触发OOM,根据oom_score选择一个进程杀掉。这种时候,多半是有问题了。

或者在某个进程运行的时候,看/proc/meminfo 观察空闲内存部分,一直下降多半是有问题了。这就确定有内存泄露了,可能是用户态进程泄露,也可能是内核泄露。可以有多种方法来分辨:

  • 观察meminfo中的slab项,如果这一项有异常的增长或者只增不减一路狂飙,那八成是内核漏出去了
  • OOM killer会根据oom score选择一个进程杀掉,假如选择了desktop Xserver等等,就说明找不到分更高的进程了,这么front且和用户体验相关的进程nice值是很低的。这都能选上,那就是用户态进程没啥占内存特别高的进程了,差不多也能确定是内核泄露了。
  • OOM killer杀了进程之后,观察OOM打出来的内存信息,并没有收回来多少。内核的内存泄露,不管OOM选择的是哪个进程来杀,内存都收不回来的,oom完事之后系统使用起来依然觉得卡卡的,那就是内核泄露了。

现在确定了内核泄露的方法,接下来怎么找到泄漏点呢,下面会介绍几种调试的方法。在此之前,先简单实现一个有内存泄漏的内核模块。

#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/slab.h>
#include <linux/vmalloc.h>
#include <linux/list.h>
#include <linux/percpu.h>
#include <linux/fdtable.h>

struct list_node {
	long header[25];
	struct list_head list;
	char name[25];
};

static LIST_HEAD(test_list);

/*
 * Some very simple testing. This function needs to be extended for
 * proper testing.
 */
static int __init module_init(void)
{
	struct list_node *list;
	int i;

	pr_info("This is test mode for memleak debug tools!\n");

	/*alloc */
	pr_info("kmalloc(32) = %p\n", kmalloc(32, GFP_KERNEL));
	pr_info("kmalloc(32) = %p\n", kmalloc(32, GFP_KERNEL));
	pr_info("kmalloc(1024) = %p\n", kmalloc(1024, GFP_KERNEL));
	pr_info("kmalloc(1024) = %p\n", kmalloc(1024, GFP_KERNEL));
	pr_info("kmalloc(2048) = %p\n", kmalloc(2048, GFP_KERNEL));
	pr_info("kmalloc(2048) = %p\n", kmalloc(2048, GFP_KERNEL));
	pr_info("kmalloc(4096) = %p\n", kmalloc(4096, GFP_KERNEL));
	pr_info("kmalloc(4096) = %p\n", kmalloc(4096, GFP_KERNEL));

	pr_info("vmalloc(64) = %p\n", vmalloc(64));
	pr_info("vmalloc(64) = %p\n", vmalloc(64));
	pr_info("vmalloc(64) = %p\n", vmalloc(64));
	pr_info("vmalloc(64) = %p\n", vmalloc(64));
	pr_info("vmalloc(64) = %p\n", vmalloc(64));

	/*
	 * create a list have 10 nodes
	 */
	for (i = 0; i < 10; i++) {
		list = kzalloc(sizeof(*list), GFP_KERNEL);
		pr_info("kzalloc(sizeof(*list)) = %p\n", list);
		if (!list)
			return -ENOMEM;
		INIT_LIST_HEAD(&list->list);
		list_add_tail(&list->list, &test_list);
	}

	return 0;
}
module_init(module_init);

static void __exit module_exit(void)
{
	struct test_node *list, *tmp;

	//delete all node in test
	list_for_each_entry_safe(list, tmp, &test_list, list)
		list_del(&list->list);
}
module_exit(module_exit);

MODULE_LICENSE("GPL");

kmemleak

第一个debug的方法也是最简单的——使用Kmemleak,这是一种用于检测内核内存泄漏的工具,可以用来检测目前内核中可能存在的泄露。kmemleak 是一种运行时的工具,它可以在内核运行时进行内存泄漏检测,但也会带来一定的性能开销。因此,通常情况下发行版并不会打开此工具。

Kmemleak使用

  • 先打开Kmemleak的相关配置:
CONFIG_DEBUG_KMEMLEAK           //kmemleak总开关
CONFIG_DEBUG_KMEMLEAK_DEFAULT_OFF              //是不是默认禁用kmemleak,非必须;
// 打开选项之后,可以通过cmdline kmemleak=on 打开kmemleak;关闭这个选项,也可以通过cmdline kmemleak=off禁用
  • 编译安装内核
    能看到/sys/kernel/debug/kmemleak这个文件,说明kmemleak已经配置好了。

  • make 并insmod写好的test模块

  • 触发内存scan
    模块插入后等几分钟执行echo scan > /sys/kernel/debug/kmemleak触发一次内存scan去找可能存在的内存泄露,这是整个内存的scan,可能需要点时间。

  • 查看结果
    通过cat /sys/kernel/debug/kmemleak查看内存泄漏的结果,这时候可以下面的结果:

# cat /sys/kernel/debug/kmemleak
unreferenced object 0xffff89862ca702e8 (size 32):
comm "modprobe", pid 2088, jiffies 4294680594 (age 375.486s)
    hex dump (first 32 bytes):
    6b 6b 6b 6b 6b 6b 6b 6b 6b 6b 6b 6b 6b 6b 6b 6b  kkkkkkkkkkkkkkkk
    6b 6b 6b 6b 6b 6b 6b 6b 6b 6b 6b 6b 6b 6b 6b a5  kkkkkkkkkkkkkkk.
    backtrace:
    [<00000000e0a73ec7>] 0xffffffffc01d3446
    [<000000000c5d2a46>] do_one_initcall+0x41/0x1df
    [<0000000046db7e0a>] do_init_module+0x55/0x200
    [<00000000542b9814>] load_module+0x203c/0x2480
    [<00000000c2850256>] __do_sys_finit_module+0xba/0xe0
    [<000000006564e7ef>] do_syscall_64+0x43/0x110
    [<000000007c873fa6>] entry_SYSCALL_64_after_hwframe+0x44/0xa9

calltrace最上面一行就是执行malloc的位置,可以根据addr2line或者gdb把地址和代码对应起来。是不是超级简单,超级容易。打开选项重编一个内核,就知道所有可能泄漏的位置。

为什么说可能存在的泄露位置呢,因为Kmemleak存在误报的情况。

kmemleak误报

kmemleak工作流程大概分为三部分:标记分配、检测释放和报告泄露。

  1. 标记分配的内存:kmemleak 会跟踪内核中的动态内存分配,包括 kmalloc、kzalloc、vmalloc 等函数分配的内存块。标记这些分配的内存块,并记录它们的地址和大小。

  2. 检测释放:当内存块被释放时,kmemleak 会记录释放的内存块,并在一段时间后检查是否仍然存在对这些内存块的引用。

  3. 报告泄漏:扫描内存并报告这些内存泄漏,扫描方法如下:

    • 标记所有对象为白色:初始时,kmemleak会将所有的内存对象标记为白色。在后续的扫描中,仍然被标记为白色的对象将被视为孤立对象(orphan)。

    • 扫描内存:从数据段和栈开始扫描内存,检查其中的数值是否与存储在红黑树(rbtree)中的地址相匹配。如果发现指向白色对象的指针,则将该对象添加到灰色列表中。

    • 扫描灰色对象:对灰色对象进行扫描,查找匹配的地址。一些白色对象可能会变为灰色,并被添加到灰色列表的末尾,直到灰色集合完成扫描。

    • 报告孤立对象:剩余的白色对象被视为孤立对象,因为数据段和堆栈里没有指向这块空间的变量/常量,内核认为这块空间未来不会被使用,而且这时候似乎free并不能释放这块空间,白色对象通过 /sys/kernel/debug/kmemleak 报告。

内核中本身有一些内存是不需要释放的,比如说核心相关的部分do_init_call函数,或者此块内存地址可以通过其他的什么方法计算得到(除了container_of,scan的流程是:假如现在扫到栈里面有一个指针指向结构体的某一个成员,整个结构体占的这块空间也会被标记为灰色,这个操作就是兼容container_of的意思),kmemleak仍然会把这块内存当泄露处理。

误报概率非常非常低,绝大部分情况是非常非常好用的!几乎一切内存泄露都逃不过kmemleak的检查,慧眼如炬明察秋毫!

然而接下来来了一个这样的场景:假如我现在的系统不方便换内核,比如说生产服务器或者拿不到内核源码,这下怎么办呢,因为性能问题Kmemleak是绝对不会在任何一个非debug发行版上开启的,这就引入了第二个方式——有没有什么方法可以在不修改内核代码的情况下拿到内核运行的状态呢。

Kprobe

当然有这样的方法,甚至不止一种方法,ebpf和kprobe都可以做到这样的事。Ebpf比kprobe更灵活功能更强大,看内存泄露直需要跟踪内存分配和回收,kprobe也足够了,本节暂时介绍一下kprobe的使用后面再说ebpf的事。

Kprobe是 Linux 内核中的一种动态跟踪工具,允许用户在内核函数的入口或出口处插入跟踪点,以便监视函数的调用和返回。通过 kprobe,用户可以在内核函数执行前后执行自定义的处理逻辑,例如收集性能数据、调试代码或进行安全监控。

不是所有的架构都支持kprobe,目前支持的有:

i386、x86_64 (AMD-64, EM64T) 、ppc64
ia64 (不支持指令槽slot1上probe)、sparc64 (暂时只实现了kprobe,不支持kretprobe)
arm、ppc、mips、s390、parisc

绝大部分Linux发行版都是打开kprobe选项的,当然也可以自己check一次,cat /boot/config-XXX | grep CONFIG_KPROBE。使用 kprobe 的一般步骤包括以下几个方面:

  • 选择跟踪点:确定要在哪个内核函数的入口或出口处设置跟踪点。
  • 编写处理逻辑:编写处理函数,用于在跟踪点触发时执行的自定义逻辑。
  • 注册 kprobe:将处理函数与所选的内核函数的入口或出口进行关联,以创建跟踪点。
  • 触发跟踪:当内核函数执行时,跟踪点会触发处理函数的执行,从而实现对函数调用和返回的监视和处理。

在内存泄露的场景下,我们可以通过跟踪kmalloc和kfree函数。创建一个全局链表,在kmalloc函数出口里新增链表节点记录申请到的虚拟地址,pid,size和堆栈,在kfree函数里按照pid和虚拟地址匹配删除链表节点,用户可以触发打印链表内容,这样能模拟一个kmemleak类似的工具。

但是有一个这个问题,kmalloc会调用非常非常多次,全局链表会很大很大,可能导致内存耗尽oom随机带走幸运进程,因为kprobe自身占用了太多内存了。或者可以在每一个kmalloc和kfree里面打印出来申请和释放掉的节点内容,而不是用链表维护起来,打印的内容存储在文档里,最后通过脚本处理一下文档内容,找到之后malloc没有free的内存块地址,就能避免占用内存问题了。

下面是实现代码:

#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/kprobes.h>
#include <linux/slab.h>

#define MAX_SYMBOL_LEN  64
static char symbol_malloc[MAX_SYMBOL_LEN] = "__kmalloc";
static char symbol_free[MAX_SYMBOL_LEN] = "kfree";
static char test_name = "";

static int kmalloc_ret_handler(struct kretprobe_instance *ri, struct pt_regs *regs);

/* 注册两个kprobe,一个用来跟踪kfree,一个用来跟踪kmalloc */
static struct kprobe kp_malloc = {
	.symbol_name    = symbol_malloc,
};

static struct kprobe kp_free = {
	.symbol_name    = symbol_free,
};

//注册一个kretprobe,用来打印kmalloc返回的地址,pid以及堆栈
static struct kretprobe kmalloc_kretprobe = {
	.handler                = kmalloc_ret_handler,
	/* Probe up to 20 instances concurrently. */
	.maxactive              = 20,
};

/* 在kmalloc函数入口处,打印本次申请内存的大小以及堆栈*/
static int kmalloc_pre(struct kprobe *p, struct pt_regs *regs)
{

	//为了避免海量打印,过滤一下测试进程的进程名
	//如果不是特定场景下的内存泄露的话,if可以不需要
	if(!strcmp(current->comm, test_name)) {
		pr_info("%s :<%s> pre_handler: size=%#llx, pid=%d \n",current->comm, symbol_malloc, regs->regs[0], current->pid);
		dump_stack();
	}
	
	return 0;
}


//在kmalloc函数返回的时候,打印申请到的虚拟地址和进程名
static int kmalloc_ret_handler(struct kretprobe_instance *ri, struct pt_regs *regs)
{
        unsigned long retval = regs_return_value(regs);

	if(!strcmp(current->comm, test_name)) {
        	pr_info("%s : %s returned %#llx\n",current->comm, symbol_malloc, retval);
	}

        return 0;
}

//在kfree函数入口处打印即将释放的地址
static int kfree_pre(struct kprobe *p, struct pt_regs *regs)
{
	if(!strcmp(current->comm, test_name) ) {
			pr_info("%s :<%s> pre_handler: free addr : %#llx\n",current->comm, symbol_free, regs->regs[0]);
	}
	
	return 0;
}

//kprobe跟踪失败打印失败原因和地址
static int handler_fault(struct kprobe *p, struct pt_regs *regs, int trapnr)
{
	pr_info("fault_handler: p->addr = 0x%p, trap #%dn", p->addr, trapnr);

	return 0;
}

static int __init kprobe_init(void)
{
	int ret;
	kp_malloc.pre_handler = kmalloc_pre;
	kp_malloc.fault_handler = handler_fault;

	kp_free.pre_handler = kfree_pre;
	kp_free.fault_handler = handler_fault;

	kmalloc_kretprobe.kp.symbol_name = symbol_malloc;

//注册kmalloc kprobe
	ret = register_kprobe(&kp_malloc);
	if (ret < 0) {
		pr_err("register_kprobe failed, returned %d\n", ret);
		return ret;
	}
	pr_info("%s: Planted kprobe at %p\n", symbol_malloc, kp_malloc.addr);

//注册kfree kprobe
	ret = register_kprobe(&kp_free);
	if (ret < 0) {
		pr_err("register_kprobe failed, returned %d\n", ret);
		return ret;
	}
	pr_info("%s : Planted kprobe at %p\n", symbol_free, kp_free.addr);

	//注册kmalloc kretprobe
    ret = register_kretprobe(&kmalloc_kretprobe);
    if (ret < 0) {
            pr_err("register_kretprobe failed, returned %d\n", ret);
                return -1;
    }
    pr_info("Planted return probe at %s: %p\n",kmalloc_kretprobe.kp.symbol_name, kmalloc_kretprobe.kp.addr);

	return 0;
}

static void __exit kprobe_exit(void)
{
	unregister_kprobe(&kp_malloc);
	pr_info("kprobe at %p unregistered\n", kp_malloc.addr);

	unregister_kprobe(&kp_free);
	pr_info("kprobe at %p unregistered\n", kp_free.addr);

        unregister_kretprobe(&kmalloc_kretprobe);
        pr_info("kretprobe at %p unregistered\n", kmalloc_kretprobe.kp.addr);

        /* nmissed > 0 suggests that maxactive was set too low. */
        pr_info("Missed probing %d instances of %s\n",
        kmalloc_kretprobe.nmissed, kmalloc_kretprobe.kp.symbol_name);	
}

module_init(kprobe_init)
module_exit(kprobe_exit)
MODULE_LICENSE("GPL");

demo里面注册了两个kprobe,一个kretprobe,分别用来打印kmalloc申请的堆栈、申请内存大小、申请进程pid、申请到的虚拟地址和释放内存的虚拟地址等等。

之后make&insmod kprobe模块,运行测试程序,内核日志保存到文件中,通过bash脚本处理,得到可能存在内存泄露的地址大小和申请堆栈。

有这样几个注意事项:

  • 增加进程名过滤的时候,一定要非常谨慎。为了减少打印,在malloc和free函数入口处都过滤了进程名,就是只针对这某一个进程分析所有的内存申请和释放的情况。但是现在程序基本上的逻辑是布满了work queue和多进程的,把相对比较耗时的kfree或者kmalloc放在工作队列或者子进程里是非常常见的,所以有一些内存块的操作kprobe并没有打印出来,造成内存泄露误报或者少报。在过滤进程的时候,请尽量加上全部子进程或者workqueue的内容。

虽然比kmemleak麻烦,但是某些场景下这个方法确实也是非常好用!讲一句题外话,kprobe能做的事情非常非常多,我也用来分析过死锁,很犀利。

  • 17
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值