内存问题定位方法 - 内存泄漏

前言

Linux 内存是嵌入式开发人员,需要深入了解的计算机资源。合理的使用内存,有助于提升机器的性能和稳定性。 Linux下内存问题可分为内存泄漏,踩内存,内存溢出,内存碎片,性能调优等。本文主要介绍工作中常用的几类内存问题的原因以及常见排查方法和工具,希望对大家有所帮助。

Linux下经常遇到内存泄漏的问题,尤其对C/C++开发人员来说是一个亘古不变的话题,现在介绍解决Linux内存泄漏问题的方法层出不穷,让人眼花缭乱,但是作为开发人员应该从本质上了解为何会发生内存泄漏,在面对内存泄漏的问题时应当知道相关的技术细节,在解决问题时应当有个固定的排查思路,要善用Linux系统本身提供的工具来定位和解决,而不是一味的通过各种各样不常用的、不熟悉的工具来排查问题,这样不仅耗时,最终不一定能够解决问题。

内存泄漏指在程序运行过程中,分配的内存没有被正确释放,导致内存占用不断增加,最终耗尽系统的可用内存。定位内存泄漏问题通常需要使用内存分析工具,例如Valgrind、GDB或者专门用于内存泄漏检测的库,如mtrace。

wrap malloc

GUN链接器实际提供了一个好用的方法--wrap= symbol。函数名定义为__wrap_symbol ,symbol也是一个函数,那么编译的时候如果添加了链接参数,函数调用symbol时,会调用到__wrap_symbol函数,另外还有一个相关函数__real_symbol,只声明不定义的时候,会对其调用到真正的symbol函数。

举一个简单的例子:

#define _GNU_SOURCE
#include <string.h>
#include <dlfcn.h>
#include <stddef.h>
#include <stdio.h>
#include <execinfo.h>


static void *(*real_malloc)(size_t) = NULL;
static void *(*real_calloc)(size_t,size_t) = NULL;
static void *(*real_realloc)(size_t,size_t) = NULL;
static void (*real_free)(void *) = NULL;



/*init 函数被标记为 __attribute__((constructor)) 属性,这表示它会在库加载时自动调用*/
static void __attribute__((constructor)) init(void)
{
    /* dlfcn 库的 dlsym 函数获取原始 malloc 和 free 函数的地址 */
    real_malloc = (void *(*)(size_t))dlsym(RTLD_NEXT, "malloc");
    real_calloc = (void *(*)(size_t))dlsym(RTLD_NEXT, "calloc");
    real_realloc = (void *(*)(size_t))dlsym(RTLD_NEXT, "realloc");
    real_free = (void (*)(void *))dlsym(RTLD_NEXT,"free");
}

void *malloc(size_t len)
{
    static __thread int no_hook = 0;
    /*hook 是否启用 */
    if (no_hook)
    {
        return (*real_malloc)(len);
    }
    /*__builtin_return_address(0) 获取调用者的地址 */
    void * caller = (void *)(long)__builtin_return_address(0);
    /*将 no_hook 设置为1,以在 printf 调用中禁用进一步的挂钩,防止对 malloc 的递归调用和潜在的无限循环。*/
    no_hook = 1;
    printf("malloc call  from %p,len:%zu\n", caller, len); //printf call malloc internally
    

    void * ret = (*real_malloc)(len);
    return ret;
}

void *calloc(size_t len, size_t size)
{
    static __thread int no_hook = 0;
    /*hook 是否启用 */
    if (no_hook)
    {
        return (*real_calloc)(len, size);
    }
    /*__builtin_return_address(0) 获取调用者的地址 */
    void * caller = (void *)(long)__builtin_return_address(0);
    /*将 no_hook 设置为1,以在 printf 调用中禁用进一步的挂钩,防止对 malloc 的递归调用和潜在的无限循环。*/
    no_hook = 1;
    printf("calloc call  from %p,len:%zu,size:%zu\n",caller, len, size); //printf call malloc internally
    no_hook = 0;

    void * ret = (*real_calloc)(len, size);
    return ret;
}

void *realloc(size_t len, size_t size)
{
    static __thread int no_hook = 0;
    /*hook 是否启用 */
    if (no_hook)
    {
        return (*real_realloc)(len, size);
    }
    /*__builtin_return_address(0) 获取调用者的地址 */
    void * caller = (void *)(long)__builtin_return_address(0);
    /*将 no_hook 设置为1,以在 printf 调用中禁用进一步的挂钩,防止对 malloc 的递归调用和潜在的无限循环。*/
    no_hook = 1;
    printf("realloc call  from %p,len:%zu,size:%zu\n",caller, len, size); //printf call malloc internally
    no_hook = 0;

    void * ret = (*real_realloc)(len, size);
    return ret;
}

void free(void *ptr){ 
    void * caller = (void *)(long)__builtin_return_address(0);
    printf("free call %p from %p\n", ptr, caller);
    (*real_free)(ptr);
}

编译命令 gcc -g -O0 -fPIC -shared mymalloc.c -o libmymalloc.so -ldl

#include <stdio.h>
#include <stdlib.h>

int main(){
    printf("main func addr:%p\n", main);
    printf("start malloc\n");
    char * pc1 = malloc(10);
    char * pc2 = malloc(10);
    char * pc3 = calloc(10,1);
    pc2 = realloc(pc2,20);
    printf("start free\n");
    free(pc1);
    free(pc2);
    free(pc3);
}

编译命令 gcc test.c -g -O0 -L/home/zhongyi/code/module/wrapmalloc -lmymalloc -o test

输出

malloc call  from 0x7f55235fc13c,len:1024
free call 0x55ad70263260 from 0x7f552360c29b
main func addr:0x55ad6fdf087a
start malloc
calloc call  from 0x55ad6fdf08d1,len:10,size:1
realloc call  from 0x55ad6fdf08e6,len:94203399256736,size:20
start free
free call 0x55ad70263a80 from 0x55ad6fdf0902
free call 0x55ad70263aa0 from 0x55ad6fdf090e
free call 0x55ad70263ac0 from 0x55ad6fdf091a

测试代码中会打印出调用者的地址,分配的内存地址以及分配的大小。定位内存泄漏问题,最重要的有两点,(1)如何知道内存发生了泄漏。(2)如何定位代码哪一行引起了内存泄漏。

针对(1),我们可以使用链表或者其他数据结构,每次分配内存时,就将分配的内存地址和大小等信息存入链表中,释放时根据内存地址和大小对其相应的节点进行释放,最后在检测链表是否为空来判断是否存在内存泄漏。但是数据结构的缺点是内存发生泄漏时不能明显的展示出来。如果使用文件的方式来表示是否发生了内存泄漏,具体假如使用一个单独的文件夹来存放内存检测组件生成的所有文件,运行程序时先清空文件夹的文件,系统调用一次malloc会生成一个文件,以malloc生成的内存地址为文件名,free时释放malloc对应生成的文件,最后如果文件夹存在文件时,就说明存在内存泄漏(malloc和free不匹配造成的)。

针对(2)可以使用C语言的__FILE__FUNCTION__LINE__宏定义或者builtin_return_address()API定位是哪一行引起了内存泄漏。

mtrace

mtrace(memory trace),是 GNU Glibc 自带的内存问题检测工具,它可以用来协助定位内存泄露问题。它的实现源码在glibc源码的malloc目录下,其基本设计原理为设计一个函数 void mtrace (),函数对 libc 库中的 malloc/free 等函数的调用进行追踪,由此来检测内存是否存在泄漏的情况。

下面我们举一个例子。

#include <mcheck.h>
#include <stdlib.h>
#include <stdio.h>

int main(int argc, char **argv)
{
    mtrace();  // 开始跟踪

    char *p = (char *)malloc(100);

    free(p);
    p = NULL;

    p = (char *)malloc(100);

    muntrace();   // 结束跟踪,并生成日志信息

    return 0;
}

编译命令,-rdynamic 的意思是来告诉链接器将所有符号导出到动态符号表中。

gcc -g gcc -rdynamic mtrace.c -o mtrace 

mtrace 机制需要我们实际运行一下程序,然后才能生成跟踪的日志,但在实际运行程序之前还有一件要做的事情是需要告诉 mtrace (即前文提到的 hook 函数)生成日志文件的路径。具体的方法是通过定义并导出一个环境变量 "MALLOC_TRACE",如下所示。

export MALLOC_TRACE=./test.log  // 当前目录下

程序运行结束,会在当前目录生成 test.log 文件,打开可以看到一下内容:

➜  mtrace ./mtrace       
➜  mtrace cat test.log
= Start
@ ./mtrace:(main+0x1e)[0x555555554908] + 0x5555557566a0 0x64
@ ./mtrace:(main+0x2e)[0x555555554918] - 0x5555557566a0
@ ./mtrace:(main+0x40)[0x55555555492a] + 0x5555557566a0 0x64
= End

➜  mtrace nm mtrace| grep main     
                 U __libc_start_main@@GLIBC_2.2.5
00000000000008ea T main
➜  mtrace 

从这个文件中可以看出中间三行分别对应源码中的 malloc -> free -> malloc 操作;解读:[0x1e] 是第一次调用 malloc 函数机器码中的地址信息,+ 表示申请内存( - 表示释放),0x5555557566a0是 malloc 函数申请到的地址信息,0x64 表示的是申请的内存大小。由此分析第一次申请已经释放,第二次申请没有释放,存在内存泄漏的问题。main函数的地址可以使用nm命令差点。

通过使用 "addr2line" 命令工具,得到源文件的行数,这里的地址是main+偏移。

➜  mtrace addr2line -e mtrace 0x908         
/home/zhongyi/code/module/mtrace/mtrace.c:9

上述指令仅仅只是可以定位源码位置,但是没法分析存在内存泄漏的问题,因此需要通过下述指令分析日志信息(mtrace + 可执行文件路径 + 日志文件路径)。

➜  mtrace mtrace mtrace test.log

Memory not freed:
-----------------
           Address     Size     Caller
0x00005555557566a0     0x64  at 0x55555555492a

这里的caller 显示的是调用者地址,0x55555555492a 和test.log对应起来可以找到内存泄漏的位置。

➜  mtrace  addr2line -e mtrace 0x92A
/home/zhongyi/code/module/mtrace/mtrace.c:14

小结

mtrace 采用 malloc_hook + return_addr 这两个机制来实现的。

  1. mtrace 会记录所有的分配、释放,包括所有的模块、线程。内存使用记录必将很多,所以官方推荐使用 SIGUSR1SIGUSR2 来进行开启和关闭内存记录功能。
  2. mtrace 记录和分析结果可以看到,内存记录日志只记录到 malloc 层面。而实际项目开发时,很多接口都是封装多层才会实际调用到 malloc,对于上面几层的地址,mtrace 没有记录。而上面几层的调用关系才是追踪内存泄漏问题的关键所在。所以在实际开发的项目中,使用 mtrace 不是一个特别好的方法。这里推荐使用 valgrind 工具进行跑流程的方式追踪内存泄漏。如果想要自己记录内存使用情况,可以考虑以下两种方式:
    • 封装一层内存分配、释放的接口函数来记录内存使用情况。项目开发时,统一使用这个接口来进行内存管理。适用于项目尚未开始。
    • 使用 malloc_hook 的方式进行记录,项目代码可以不变。适合于项目已经比较庞大了。

根据系统信息定位内存泄漏

free

通过free命令,我们对内存的整体使用有个初步了解,并快速是否存在内存泄漏可能。

root@firefly:~# free
              total        used        free      shared  buff/cache   available
Mem:        3669672      198184     3234064        8228      237424     3432672
Swap:             0           0           0

used占用过高,free很低,存在内存泄漏可能,需要继续向下分析。但是buff/cache和available挺高。这种情况一般不是内存泄漏,而是可用内存被系统缓存起来了。

ps

root@firefly:~# ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.6  0.2 161012  7872 ?        Ss   23:48   0:02 /sbin/init
root         2  0.0  0.0      0     0 ?        S    23:48   0:00 [kthreadd]
root         3  0.0  0.0      0     0 ?        S    23:48   0:00 [ksoftirqd/0]
root         5  0.0  0.0      0     0 ?        S<   23:48   0:00 [kworker/0:0H]
........................
root      1078  0.0  0.0      0     0 ?        S    23:48   0:00 [kworker/u12:5]
root      1082  0.0  0.0      0     0 ?        S<   23:49   0:00 [kworker/2:1H]
root      1089  0.0  0.0   7036  2792 ttyFIQ0  R+   23:53   0:00 ps aux

ps命令可以帮助我们定位分析应用进程内存泄漏。观察RSS列,RSS是这个进程占用的实际物理内存空间。如果有进程RSS占用偏高,则存在内存泄漏可能。

meminfo

root@firefly:~# cat /proc/meminfo 
MemTotal:        3669672 kB
MemFree:         3234148 kB
MemAvailable:    3433028 kB
Buffers:           10336 kB
Cached:           194856 kB
SwapCached:            0 kB
Active:           194580 kB
Inactive:         152744 kB
Active(anon):     142848 kB
Inactive(anon):     7508 kB
Active(file):      51732 kB
Inactive(file):   145236 kB
Unevictable:           0 kB
Mlocked:               0 kB
SwapTotal:             0 kB
SwapFree:              0 kB
Dirty:                 0 kB
Writeback:             0 kB
AnonPages:        142204 kB
Mapped:           116216 kB
Shmem:              8228 kB
Slab:              55612 kB
SReclaimable:      32504 kB
SUnreclaim:        23108 kB
KernelStack:        4912 kB
PageTables:         4440 kB
NFS_Unstable:          0 kB
Bounce:                0 kB
WritebackTmp:          0 kB
CommitLimit:     1834836 kB
Committed_AS:    1408388 kB
VmallocTotal:   258867136 kB
VmallocUsed:           0 kB
VmallocChunk:          0 kB
HugePages_Total:       0
HugePages_Free:        0
HugePages_Rsvd:        0
HugePages_Surp:        0
Hugepagesize:       2048 kB

MemTotal

系统从加电开始到引导完成,firmware/BIOS要保留一些内存,kernel本身要占用一些内存,最后剩下可供kernel支配的内存就是MemTotal。这个值在系统运行期间一般是固定不变的。可参阅解读DMESG中的内存初始化信息。

MemFree

表示系统尚未使用的内存。[MemTotal-MemFree]就是已被用掉的内存。

MemAvailable

有些应用程序会根据系统的可用内存大小自动调整内存申请的多少,所以需要一个记录当前可用内存数量的统计值,MemFree并不适用,因为MemFree不能代表全部可用的内存,系统中有些内存虽然已被使用但是可以回收的,比如cache/buffer、slab都有一部分可以回收,所以这部分可回收的内存加上MemFree才是系统可用的内存,即MemAvailable。/proc/meminfo中的MemAvailable是内核使用特定的算法估算出来的,要注意这是一个估计值,并不精确。

meminfo中对内存的使用进行了详细的统计,我们主要关注Slab和VmllocUsed。

Slab

Slab = SReclaimable + SUnreclaim

如果内存占用偏高,且SReclaimable偏高,则表示可用内存缓存在slab系统内,需要时可回收。

如果内存占用偏高,且SUnreclaim偏高,则表示可能存在内存泄漏。需要进一步观察slabinfo。

如果VmallocUsed内存占用偏高,则存在内存泄漏可能。需要进一步分析vmallocinfo。

vmallocinfo

root@firefly:~# cat /proc/vmallocinfo 
0xffffff8008000000-0xffffff8008011000   69632 of_iomap+0x48/0x5c phys=fee00000 ioremap
0xffffff8008012000-0xffffff8008014000    8192 of_iomap+0x48/0x5c phys=ff750000 ioremap
0xffffff8008014000-0xffffff8008016000    8192 of_iomap+0x48/0x5c phys=ff760000 ioremap
..............................
0xffffffbdbff72000-0xffffffbdbfff0000  516096 pcpu_get_vm_areas+0x0/0x500 vmalloc

通过vmalloc分配的内存都统计在/proc/meminfo的 VmallocUsed 值中,但是要注意这个值不止包括了分配的物理内存,还统计了VM_IOREMAP、VM_MAP等操作的值,譬如VM_IOREMAP是把IO地址映射到内核空间、并未消耗物理内存,所以我们要把它们排除在外。从物理内存分配的角度,我们只关心VM_ALLOC操作,这可以从/proc/vmallocinfo中的vmalloc记录看到。

注:/proc/vmallocinfo中能看到vmalloc来自哪个调用者(caller),那是vmalloc()记录下来的,相应的源代码可见:

mm/vmalloc.c: vmalloc > __vmalloc_node_flags > __vmalloc_node > __vmalloc_node_range > __get_vm_area_node > setup_vmalloc_vm

通过运行和销毁程序,我们就可以看到对应的内存的分配和释放情况。正常来说,程序运行时,我们会看到vmalloc记录了所有调用vmalloc的调用栈及其分配到的虚拟内存地址。而当销毁时,这些内存都会被释放掉,而消失在proc中。

如果当我们运行并退出程序后,vmallocinfo中还存在着我们程序中的函数分配信息,那么基本上可以确认,就是这个函数所分配的内存没有释放。

监控脚本

内存泄漏往往是长时间才会出现的,因此,可以尝试添加一些监控脚本,观察系统内存的变化。

#!/bin/bash

interval=600
function  MonitorInit()
{
	current_time=$(date +"%Y-%m-%d %H:%M:%S")
	echo "$current_time MonitorInit!"
}

function  MemoryInformationMonitoring()
{
	while true; do
	  echo "cat /proc/zoneinfo"
	  cat /proc/zoneinfo

	  echo "cat /proc/pagetypeinfo"
	  cat /proc/pagetypeinfo
	  
	  echo "cat /proc/meminfo"
	  cat /proc/meminfo
	 
	  echo "cat /proc/buddyinfo"
	  cat /proc/buddyinfo
	 
	  echo "cat /proc/slabinfo"
	  cat /proc/slabinfo
	 
	  echo "cat /proc/vmallocinfo"
	  cat /proc/vmallocinfo

	  echo "cat /proc/vmstat"
	  cat /proc/vmstat
	  
	  echo "cat /proc/self/statm"
	  cat /proc/self/statm
	  
	  echo "cat  /proc/self/maps"
	  cat  /proc/self/maps
	  
	  echo "cat /proc/swaps"
	  cat /proc/swaps 

	  sleep $interval
	done
}
function  ProcessInformationMonitoring()
{
	PROCESS=$1
	PID=$(ps | grep $PROCESS | grep -v 'grep' | awk '{print $1;}')

	if [ "$PID" != "" ]; then  
		cat /proc/$PID/status
		sleep $interval
	fi

}
  
MonitorInit
ProcessInformationMonitoring process_test
MemoryInformationMonitoring 

常用工具

valgrind

mtrace

Kmemleak

perf

ASAN

KASAN

KFENCE

后续逐一补充…….

posted @ 2023-12-22 22:30  学习,积累,成长  阅读(336)  评论(0编辑  收藏  举报