《嵌入式Linux内存与性能详解》笔记3——动态库内存优化

羽林君 2023-12-24 00:03

一、前言

在编程中,我们有时会使用到静态库和动态库,而静态库是链接到程序之中的,基本上静态库的优化与进程类似。但动态库是加载在内存空间中的,是在运行时链接的。它的优化方式比较不同,本文我们就简单地讲述一下动态库如何优化

二、动态库

对于 动态库 而言,它仅包括 2 个段:

  • 只读的代码段

  • 可修改的数据段

堆段栈段 只有进程中才有。所以,如果你在动态库的函数里分配了一块内存,这段内存将被算在调用该函数的进程的 堆段 中。

系统在处理 动态库代码段、数据段 时和 程序代码段、数据段 一样:

  • 代码段 由于其内容是对每个进程都是一样的,它在系统中是唯一的,系统只为其分配一块内存,多个进程之间共享。

  • 数据段 由于其内容对每个进程是不一样的,所以在链接到进程空间后,系统会为每个进程创建相应的数据段。

2.1、BSS段

进程中BSS 会被开辟在 堆段 中。但在 动态库 中则是另外开辟一段 内存段 来存放这些数据,我们看下面的代码:

/* test.c */#include  #include  
int bss[1024*128]={0}; void funca() { printf("bss:%p\n",bss); return; } /* main.c */#include #include #include #include #include #include #include
int funca(); int main() { int pid=getpid(); printf("pid:%d\n",pid); funca(); pause(); return 0;}


  • 动态库编译:arm-linux-gnueabihf-gcc -shared -fPIC test.c -o libtest.so

  • 主程序编译:arm-linux-gnueabihf-gcc -L./ -ltest main.c -o main

编译完成后记得把 动态库和主程序 一起拷贝到嵌入式设备上,其中具体的动态库查找路径相信各位读者都有些许了解,我们运行后得到下面的结果:

运行结果

smaps.png


可以看到,我们的进程中有专门的内存段来存放 动态库 的数据,可以看到其中有 2 个段各自占据了 1 个物理页

再看看如果在进程中引用动态库中的数据的情况,代码如下:


/* test.c */#include  #include  
int bss[1024*128]={0}; void funca() { printf("bss:%p\n",bss); return; } /* main.c */#include #include #include #include #include #include #include
extern int bss[1024*128]; int funca(); int main() { int pid=getpid(); printf("pid:%d\n",pid); funca(); pause(); return 0;}


运行结果


smps

我们发现 地址不在动态库的段中,而是在程序的堆栈里面。这样来看,即使我们没使用该数据,但还是会占用我们的物理页。所以我们尽量少引用动态库中的全局变量。一旦引用,不论其是否使用,都将会占用物理内存。同时还会增加系统启动时,内存复制的代价,会导致性能的下降。

2.1、动态库数据段对进程的影响

上一小节说了,如果全局变量声明在共享库中,在进程中使用,则该变量被复制到进程的数据段, 同时修改使用该变量的指向。那么接下来看看 动态库数据段 会对 进程数据段 产生什么影响?
我们通过 3 个实例来验证这个问题,每个实例都分别给出:代码运行结果文件大小堆段信息动态库数据段信息

  1. 声明在bss段,但在进程中使用



/* test.c */#include  #include  
int bss[1024*8]={0}; void funca() { printf("dynamic bss = %p\n",bss); return ; }
/* main.c */#include #include #include #include #include #include #include
extern int bss[1024*8]; //32KBint* funca();int main() { int pid=getpid(); int i = 0; funca(); for(i = 0; i < 1024*8; i++) bss[i] = 99; printf("program bss[0] = %d\n",bss[0]); printf("pid:%d bss:%p\n",pid,bss); pause(); return 0;}

运行结果


文件大小


堆段信息


动态库数据段信息


我们可以看到,在 动态库的数据段 中并没有占用内存,仅仅是在 进程堆段 中占用内存。

  1. 声明在bss段,但在进程中不使用


/* test.c */#include  #include  
int bss[1024*8]={0}; void funca() { printf("dynamic bss = %p\n",bss); return ; }
/* main.c */#include #include #include #include #include #include #include
extern int bss[1024*8]; //32KBint* funca();int main() { int pid=getpid(); int i = 0; funca(); printf("pid:%d bss:%p\n",pid,bss); pause(); return 0;}

运行结果


文件大小


堆段信息


动态库数据段信息

该实例在主程序中引用了 动态库的数据,但没有修改数据的内容。可以看到其结论与第一个实例相同,也就是说明 在动态库中的数据一旦被主程序引用,那么主程序就会在堆段开辟内存并将动态库中的内容拷贝到堆段内存中

  1. 声明在data段

/* test.c */#include  #include  
int bss[1024*8]={1}; void funca() { printf("dynamic bss = %p\n",bss); return ; }
/* main.c */#include #include #include #include #include #include #include
extern int bss[1024*8]; //32KBint* funca();int main() { int pid=getpid(); int i = 0; funca(); printf("program bss[0] = %d\n",bss[0]); printf("pid:%d bss:%p\n",pid,bss); pause(); return 0;}

    运行结果


    文件大小


    堆段信息


    动态库数据段信息

    该实例就有点不同了,动态库数据被初始化了。我们可以看到 动态库 的文件大小比前 2 个实例的都要 ,而且 动态库数据段 也占了内存,且内存大小与 动态库数据一样大。这说明了 动态库数据段如果不是定义在BSS段,则会在动态库中也占用物理内存,同时程序也会引用动态库的数据,导致一份数据使用了 2 次

    2.2、代码段

    2.2.1、符号解析

    1. 动态库的代码段是如何在装载时需要链接到程序之中的呢?
    答案是这个过程是通过 .rel.dyn(.relocation.dynamic).rel.plt((.relocation.prosedure linkage talbe)) 这 2 个 节区 来完成的。
    下面是代码:


    /* main.c */#include  #include  #include  #include  void a(); extern int b; int main() {         a();         pid_t pid=getpid();         printf("pid:%d b=%d %p\n",pid,b,&b);         pause();         return 0; }/* test.c */#include  #include  int b=10; int c=1; void a() {         printf("function a b:%p c:%p\n",&b,&c);         b++;         return; }


    将它们编译之后使用 readelf -r main 来读取 主程序 的重定位信息:

    main重定位信息


    我们可以看到 变量b.rel.dyn节区,而 外部引用函数 比如printf函数a 则是在 .rel.plt节区
    在看看 动态库 的重定位信息:


    动态库.rel.dyn.rel.plt 也有对应的符号信息,这样 程序动态库 之间可以通过这些信息完成内存的分配和数据的装载。这里不展开讨论该过程,有兴趣的读者请查阅其他资料了解


    2. 程序运行结果分析
    下面是程序的运行结果及相关信息:

    程序运行结果


    maps信息


    我们发现 变量b 是在 进程数据段 中。而 变量c 则是在 动态库数据段 中。
    在前面 主程序重定位信息中,我们发现 变量bR_ARM_COPY 属性。
    引用原文就是说:
    符号表索引规定符号应该既存在于当前目标文件中,也存在于某个共享目标中。在执行过程中,动态链接器把与共享目标的符号相关的数据复制到由偏移给出的位置。其意思就是说在加载 动态库 后,主程序 会将具有该 属性 的变量拷贝到自己的 数据段,并 修改动态库中该数据的指向

    所以原文有以下结论:

    • 如果全局变量声明在共享库中,在进程中使用,则该变量被复制到进程的数据段,同时修改使用该变量的共享库的指向

    • 如果该变量在共享库中声明,在共享库中使用,则该变量位于声明它的共享库的数据段中。

    2.2.2、函数导出的影响

    动态库 中定义函数,默认是会导出该函数的符号,导出符号需要一些代价,下面表格列出了导出符号是所增加的影响:

    节区类型代价(单位字节)
    hash4
    .dynsym16
    dynstr函数名长度+1
    gnu.version2
    text仅针对函数来说,按函数代码长度增长
    data仅针对变量来说,按数据长读增长

    为了避免导出多余的符号,我们可以通过 --version-script 选项来指 脚本,通过编写脚本来定义导出的符号。
    PS:有关链接脚本的编写及使用,因为篇幅原因这里不展开讲述,请感兴趣的读者自行查阅

    2.3、动态库的优化

    动态库代码段 是共享的,所以不会随着 依赖进程 的增加而增加。但 动态库数据段 是随着 依赖进程 的增加而线性增长,所以我们把重点放在 数据段 的优化上。

    2.3.1、减少无用的动态库

    loader 在加载动态库时需要做很多事情,

    • 代码段:做重定位工作,至少占用一个物理页面;

    • 数据段:首先要做重定位的工作。映射数据段时要在后一个页面将未填充完的剩余字节填充为 0,其少要占用一个物理页面。

    综上所述,链接一个无用的共享库至少损失了 8k 的物理内存。对于每个不管是直接依赖还是间接依赖该动态库的进程,都将损失 8K 的物理内存。

    如何检测无用的动态库?

    1. 使用 readelf 读取 动态库dynamic节,获取其所直接依赖的动态库。(如果使用 ldd,结果会包含 间接依赖 的动态库,对我们的结果 有害无异) 。

    2. 读取 动态库 中所有 weakundefine 的符号。

    3. 读取该 动态库 直接依赖的其他动态库 所有已经定义的符号。

    4. 将上面的结果 2 种符号取个交集,剔除一些系统的符号,如果交集不为空,我们就认为两个库有 实际的依赖关系,否则就是链接了无用的动态库。

    2.3.2、合并动态库

    假设 动态库A 的数据段为 1K动态库B 的数据段也是 1K。进程加载这两个动态库就需要 2 个页面 8K 内存。如果将这两个动态库合并到一起,那么合并后动态库的数据段所占内存为 2K,进程加载后置需要 1个页面4K内存。这样节省出一个 物理页面

    对于 C语言 来说,合并后进程可能会因此引入很多无用的内容,但在加载动态库时只为 数据段 分配了一块虚存,没有用到的全局变量并不会占用更多的内存。因此,对于 C语言 写的动态库来讲,动态库的合并会通过 合并数据段 来节省内存。

    原文的总结如下:

    • 对于 C语言 编写的动态库,合并可以达到优化 数据段 的目的。

    • 对于 C++语言 编写的动态库,可以考虑将一些不常用的功能分拆,使用 dlopen 的方式加 载,来节省内存。防止由于动态库之间的合并,导致进程加载很多无关的内容。

    • 对于 经常一同出现的动态库,可以合并。

    2.3.3、仅被依赖一次的动态库

    假设 动态库A 只被 动态库B进程B 所依赖,那么说明 动态库A动态库(或进程)B 是 同时出现的,那么可以将 动态库A 做成一个静态库,然后将其与 动态库(或进程)B 进行合并,从内存的角度来讲这是有益无害的。

    如何查找仅使用一次的动态库?

    1. 使用 readelf 读取动态库的 dynamic节,找到其所有直接依赖的动态库。

    2. 为每一个 动态库 建立一个 依赖于它的动态库和进程表

    3. 如果 表长度1 的动态库,即是是仅被依赖一次的动态库。

    2.3.4、使用 dlopen 来控制动态库的生存周

    在启动程序后,会发现进程加载了很多的库。但有些动态库是很少用到的,甚至可能只用 1 次。而进程在启动后就全部加载进来,每个库至少 8k(4k 代码段,4K 数据段)内存。这样会浪费内存,而且动态的数据段一旦 加载跟使用 后将伴随整个进程的声明周期。

    linux 提供了 dlopen机制 来解决动态库的动态加载问题,其流程为:

    1. 进程会加载该共享库的 代码段数据段,同时为这个 共享库计数加 1。

    2. 进程查找该共享库的 dynamic节,查看其所依赖的共享库。

    3. 检查 所依赖库 是否已经被加载。如果已被加载,则为这个 共享库计数 加 1。如果未被加载,则加载其 代码段数据段,然后为这个 共享库计数 加 1。

    4. 接着递归查找 依赖库 所依赖的库并执行前面的操作,最终进程会为每个加载的共享库维护一个依赖的计数。

    同时,linux 也提供了 dclose 来卸载动态库,其流程为:

    1. 首先将该 共享库的计数 减 *18,如果该 共享库依赖计数0,则 卸载 该共享库。

    2. dynamic节 中,查找其 所依赖的共享库

    3. 为每个 依赖的共享库 的计数减 1,如果该共享库依赖计数为 0,则卸载该共享库。

    4. 重复上面的步骤。

    PS:dlopen的使用方法请各位读者自行查阅

    dlopen 的优点如下在于:

    1. 可以在程序启动的时候,减少加载库的数量 以加快进程的 启动速度减少加载库的内存

    2. 为进程提供了卸载共享库的机会,以此动态回收共享库 代码段和数据段所占用的内存

    dlopen 本身也有 动态库嵌套 的问题,下面举个例子来说明。
    假设有 进程P共享库 liba.solibb.solibc.solibd.so
    它们的依赖关系为:

    • liba.so 依赖 libb.so

    • libc.so:   依赖 libd.so

    流程如下:

    1. 进程P 通过 dlopen 加载 liba.so。此时 liba.solibb.so 都会被加载到进程中。

    2. 进程P 调用 liba.so 的库函数,该库函数会调用 dlopen 加载 libc.so。此时会将 libc.so
      libd.so 加载到进程中。

    3. 进程P 调用 dlclose 来关闭 liba.so。但dlclose 查找其所依赖的库时只能查到 libb.so,所以它将关闭 libb.so 以及 liba.so。

    4. 进程P 关闭了 liba.so,如果 liba.so 没有关闭 libc.so* 和 libd.so 的函数,那么 libc.so* 和 libd.so 将会一直存在内存中,导致共享库没有完全卸载。

    所以我们需要一个时机,在我们 dlclose 动态库时,将该动态库调用 dlopen 打开的动态库关闭。其实有点类似于 C++ 的构造函数和析构函数。如果我们可以为动态库实现一个 析构函数,在卸载动态库时会去自动卸载其所依赖的库,那么就可以解决这个嵌套问题。

    linux 的动态库为我们提供了这样的功能:

    • 动态库在加载时会执行 void attribute ((constructor)) open_func(void),其中 open_func 为函数名,可自行定义。

    • 动态库在卸载时会执行 void attribute ((destructor)) close_func(void),其中 close_func 为函数名,可自行定义。

    PS:在编译时不能使用 "-nonstartfiles" 或 "-nostdlib" 选项,否则,构建与析构函数有可能不能正常执行
    下面提供一段比较简单例程

    /* libfunc.c */int _add(int a,int b){    return (a + b);}
    int _sub(int a, int b){ return (a - b);}
    int _mul(int a, int b){ return (a * b);}
    int _div(int a, int b){ return (a / b);}
    /* libtest.c */#include #include
    #define SO_LIB_PATH "/usr/lib/libfunc.so"
    typedef int (*func)(int, int);
    static void* so_handle = NULL;static func add_func = NULL;static func sub_func = NULL;static func mul_func = NULL;static func div_func = NULL;
    void __attribute__ ((constructor)) open_func(void){ printf("[%s](%d)constructor share object now\n", __func__, __LINE__); so_handle = dlopen(SO_LIB_PATH, RTLD_NOW); if(NULL == so_handle) { printf("[%s](%d)error: %s\n", __func__, __LINE__, dlerror()); } else { add_func = dlsym(so_handle, "_add"); if(add_func == NULL) { printf("[%s](%d)open add error:%s\n", __func__, __LINE__, dlerror()); } sub_func = dlsym(so_handle, "_sub"); if(sub_func == NULL) { printf("[%s](%d)open sub error:%s\n", __func__, __LINE__, dlerror()); } mul_func = dlsym(so_handle, "_mul"); if(mul_func == NULL) { printf("[%s](%d)open mul error:%s\n", __func__, __LINE__, dlerror()); } div_func = dlsym(so_handle, "_div"); if(div_func == NULL) { printf("[%s](%d)open div error:%s\n", __func__, __LINE__, dlerror()); } }
    }void __attribute__ ((destructor)) close_func(void){ printf("[%s](%d)destructor share object now\n", __func__, __LINE__); dlclose(so_handle);}
    int add(int a,int b){ return add_func(a, b);}
    int sub(int a, int b){ return sub_func(a, b);}
    int mul(int a, int b){ return mul_func(a, b);}
    int div(int a, int b){ return div_func(a, b);}/* main.c */#include #include #include #include int add(int a,int b);int main() { printf("[%s](%d)result = %d\n", __func__, __LINE__, add(1, 4)); return 0;}


    使用下面的语句进行编译:

    arm-linux-gnueabihf-gcc -L./ -ltest main.c -o mainarm-linux-gnueabihf-gcc -shared -fPIC -ldl libfunc.c -o libfunc.soarm-linux-gnueabihf-gcc -shared -fPIC -ldl libtest.c -o libtest.so

    将动态库拷贝到设备的 /usr/lib 目录下再运行主程序,就可以看到下面的运行结果:

    运行结果


    2.4、内存调试

    可能在编写程序时会遇到 内存泄露 或者 大量使用内存 的情况。我们也有一些手段来解决这个问题,比如使用 mtracemtrace 的使用方法在前面的文章有说过,但这种方法有 2 个缺点:

    • 程序需要把 log 写到 flash 文件中,读写 flash 是比较慢的

    • mtrace 中试图根据调用 malloc 的代码指针,解析出对应的函数,这会导致进程的速度变 得很慢

    以上的缺点导致 mtrace 无法调试大型项目的内存问题,所以我们需要另外找方法来调试。幸好 libc 提供了 malloc和free 的钩子函数,这样我们就可以自己来实现对内存的调试

    2.4.1 malloc和free的钩子函数

    glibc 中,提供了 mallocfreereallocmemalign钩子函数 。你可以按照 钩子函数 的原型定义自己的函数,并在 glibc 中设置相应的钩子函数,这样 glibc 在处理函数时,会调用你的钩子函数,从而获得相应的信息。
    利用这个机制,我们可以从 钩子函数中 获取一些信息以帮助我们进行内存调试。先看看 钩子函数 的原型:

    • extern void (*__MALLOC_HOOK_VOLATILE __free_hook) (void *__ptr, const void*caller)

    • extern void *(*__MALLOC_HOOK_VOLATILE __malloc_hook)(size_t __size, const void *caller)

    可以看到 钩子函数 会获取 调用者caller函数指针,根据这个特性就这个进行内存调试跟分析了。

    2.4.1.1 获取调用者指针

    下面我们直接看一段代码:


    #include  #include  #include #include 
    static void* (* old_malloc_hook) (size_t,const void *); static void (* old_free_hook)(void *,const void *); static void my_init_hook(void); static void* my_malloc_hook(size_t,const void*); static void my_free_hook(void*,const void *);
    static void my_init_hook(void) { old_malloc_hook = __malloc_hook; old_free_hook = __free_hook; __malloc_hook = my_malloc_hook; __free_hook = my_free_hook; }
    static void* my_malloc_hook(size_t size, const void *caller){ void *result; __malloc_hook = old_malloc_hook; result = malloc(size); old_malloc_hook = __malloc_hook; printf("%p + %p %#x\n", caller, result, (unsigned long int)size); __malloc_hook = my_malloc_hook; return result; }
    static void my_free_hook(void *ptr, const void *caller) { __free_hook = old_free_hook; free(ptr); old_free_hook = __free_hook; printf("%p - %p\n", caller, ptr); __free_hook = my_free_hook; }
    char *p[10];
    int func1() { int i = 0; for(i = 0; i < 10; i++) { p[i] = (char *)malloc(i); } return 0; }
    int func2() { int i = 0; for(i = 0; i < 9; i++) { free(p[i]); } return 0; }
    int main() { my_init_hook(); func1(); func2(); return 0; }


    上面的代码在编译时可能会出现大量的 警告,可以使用编译选项 -Wno-deprecated-declarations 来屏蔽这些警告。

    代码先在 func1 中连续开辟 10 块内存,在 func2 中释放 9 块内存,遗漏了一块。我们打印的 log 分别罗列出了:调用者+/-号(表示开辟/释放)开辟结果 以及 内存块大小。我们等下回根据这些信息来获取 内存泄露 的地方。

    注意:钩子函数的返回值就是原函数的返回值,所以我们必须在钩子函数里面实现于原函数一样的功能,一般情况下我们可以直接在钩子函数里面调用原函数,但是在调用之前我们必须把钩子函数给还原回去,不然会陷入无条件终止的递归,最终程序崩溃

    我们看看运行结果:

    运行结果

    上面的运行结果是在调用 mallo/free 少的情况下进行的。如果在多的情况下我们可以手动编写脚本来帮助我们进行分析。原书中使用的是 perl 脚本,但笔者使用的是 python 脚本。所以笔者在模糊看懂原文的脚本的基础上使用 python 重新编写。下面为笔者所写的脚本:


    import osimport sysif __name__ == '__main__':    file_name = sys.argv[1]    bin_name = sys.argv[2]    file = open(file_name)
    mem_dict = {} caller_dict = {} while True: line = file.readline() if not line: break items = line.split() index = items[2] if '+' is items[1]: size = int(items[3], 16) mem_dict[index] = size caller_dict[index] = int(items[0], 16) elif '-' is items[1]: mem_dict.pop(index) caller_dict.pop(index) for key in mem_dict.keys():        print("caller=", hex(caller_dict[key]), "addr =", key, "size =", mem_dict[key])


    使用上面的脚本时我们需要传入参数:打印的log,所以再运行 执行文件时 记得把 log 重定向到文件。下面是脚本运行结果:

    运行结果


    如图所示,使用脚本可以更加快速地帮助我们筛选出结果,这样我们就可以简单明了的看出是哪里开辟的内存没有释放掉。
    脚本的原理是:每个 caller 都有对应的 +(开辟)-(释放),如果有一个 caller-号 没有被消掉,那么就根据这个 内存块地址 找到相应的 caller


    2.4.1.2 获取调用者函数名

    我们除了获取 调用者指针 外,还可以再借助 nm工具 获取调用者的函数名,这样我们就可以更加清晰地知道是哪个函数开辟的内存没有被释放,我们再看看下面修改的脚本代码:


    import osimport sysdef getsym(caller_addr, bin_name):    bin_file = open(bin_name)    bin_file.close()    nm_return = os.popen("arm-linux-gnueabihf-nm -n " + bin_name)    caller_name_dict = {}    while True:        nm_line = nm_return.readline()        if nm_line:            nm_items = nm_line.split()            if 2 == len(nm_items):                continue            if caller_addr > int(nm_items[0], 16):                result = abs(caller_addr - int(nm_items[0], 16))                caller_name_dict[result] = nm_items[2]        else:            break    sort_result = sorted(caller_name_dict.items(), key = lambda item: item[0])    symbol = sort_result[0][1]    return symbol 
    if __name__ == '__main__': file_name = sys.argv[1] bin_name = sys.argv[2] file = open(file_name)
    mem_dict = {} caller_dict = {} while True: line = file.readline() if not line: break items = line.split() index = items[2] if '+' is items[1]: size = int(items[3], 16) mem_dict[index] = size caller_dict[index] = int(items[0], 16) elif '-' is items[1]: mem_dict.pop(index) caller_dict.pop(index) for key in mem_dict.keys(): print("caller=", hex(caller_dict[key]), "addr =", key, "size =", mem_dict[key]) caller_name = getsym(caller_dict[key], bin_name) print("leak caller =", caller_name)


    使用上面的脚本时我们需要传入 2 个参数:

    • 打印log

    • 执行文件路径

    脚本的原理在上一小节的基础上加入下面的条件:
    小于 caller 并且距离 caller 最近的函数地址就是函数,我们可以通过 nm工具 获取每个函数所处的位置,找到满足上述条件的函数就是目标函数了。

    我们看一下运行结果:

    运行结果

    2.4.1.3 获取调用者行数

    确定调用的函数后,我们再更加一步可以获取到代码的 行数。这一步骤要求 我们需要在编译的时候使用 -g 选项加入调试信息
    从上一小节我们知道调用的 caller地址0x10917。那么我们可以使用 addr2line 的工具来获取到具体函数,如下图所示:

    从图中可知,在代码的 145行 开辟的内存没有进行释放。

    2.4.1.3 获取多个层次的调用者

    实际环境 中,并没有如此简单的 函数调用层次。我们可能是在调用了 4、5 次才最终开辟内存空间的,那么我们就需要获取逐层的 调用者 以方便我们定位内存。
    要知道多个 调用者 的信息,我们就需要使用 backtrace 函数。该函数可以打出多个 函数调用处的纸质恩,其原理是 函数栈帧,有兴趣的同学可以自行了解一下。
    下面我们看一段代码:


    #include  #include  #include #include #include 
    static void* (* old_malloc_hook) (size_t,const void *); static void (* old_free_hook)(void *,const void *); static void my_init_hook(void); static void* my_malloc_hook(size_t,const void*); static void my_free_hook(void*,const void *);
    static void my_init_hook(void) { old_malloc_hook = __malloc_hook; old_free_hook = __free_hook; __malloc_hook = my_malloc_hook; __free_hook = my_free_hook; }
    static void* my_malloc_hook(size_t size, const void *caller){ void *result; __malloc_hook = old_malloc_hook; result = malloc(size); void *array[10]; size_t size1; size1 = backtrace(array, 10); switch(size1) { case 1: printf("%p + %p %#x %p\n",caller,result,(unsigned int)size,array[0]); break; case 2: printf("%p + %p %#x %p %p\n",caller,result,(unsigned int)size,array[0], array[1]); break; case 3: printf("%p + %p %#x %p %p %p\n",caller,result,(unsigned int)size,array[0],array[1],array[2]); break; case 4: printf("%p + %p %#x %p %p %p %p\n",caller,result,(unsigned int)size,array[0],array[1],array[2],array[3]); break; case 5: printf("%p + %p %#x %p %p %p %p %p\n",caller,result,(unsigned int)size,array[0],array[1],array[2],array[3],array[4]); break; }
    __malloc_hook = my_malloc_hook; return result; }
    static void my_free_hook(void *ptr, const void *caller) {
    __free_hook = old_free_hook; free(ptr); old_free_hook = __free_hook; printf("%p - %p\n", caller, ptr); __free_hook = my_free_hook; }
    char *p[10];
    int func1() { int i = 0; for(i = 0; i < 10; i++) { p[i] = (char *)malloc(i); } return 0; }
    int func2() { int i = 0; for(i = 0; i < 10; i++) { free(p[i]); } return 0; }
    void func3() { func1(); }
    int main() { my_init_hook(); func1(); func2(); func3(); return 0; }


    注意:使用 backtrace 函数要注意下面 2 点:

    • 按照笔者理解,backtrace函数也会调用 malloc,所以在调用 backtrace之前我们需要将malloc的钩子函数还原回原来的

    • 如果是在 ARM 架构下运行程序,需要在编译时使用-rdynamic -funwind-tables -ffunction-sections 选项,不然无法使用backtrace

    • 编译时不能使用 -O 优化选项

    上面的代码中 func1 开辟的内存块会在 func2 中被释放掉,但在最后 func3 会调用 func1 在开辟多 10 块内存

    根据上面的代码,我们在打印是就可以获取到多个 调用者 的信息了,我们看一下运行结果:

    运行结果


    我们可以看到最后的 caller地址 比较不一样,我们可以使用 maps 来查看这个指针所在的段,这里笔者就不赘述了。
    我们继续修改一下脚本,使用 python 来帮助我们获取 调用者函数名,如下所示:


    import osimport sysdef getsym(caller_addr, bin_name):    bin_file = open(bin_name)    bin_file.close()    nm_return = os.popen("arm-linux-gnueabihf-nm -n " + bin_name)    caller_name_dict = {}    while True:        nm_line = nm_return.readline()        if nm_line:            nm_items = nm_line.split()            if 2 == len(nm_items):                continue            if caller_addr > int(nm_items[0], 16):                result = abs(caller_addr - int(nm_items[0], 16))                caller_name_dict[result] = nm_items[2]        else:            break    sort_result = sorted(caller_name_dict.items(), key = lambda item: item[0])    # print(sort_result[0])    symbol = sort_result[0][1]    return symbol 
    if __name__ == '__main__': file_name = sys.argv[1] bin_name = sys.argv[2] file = open(file_name)
    mem_dict = {} caller_dict = {} while True: line = file.readline() if not line: break items = line.split() index = items[2] if '+' is items[1]: size = int(items[3], 16) mem_dict[index] = line elif '-' is items[1]: mem_dict.pop(index) for key in mem_dict.keys(): leak_line = mem_dict[key] caller = getsym(int(leak_line.split()[0], 16), bin_name) print("caller=", caller, "addr =", key, "size =", mem_dict[key].split()[3]) for key in mem_dict.keys(): leak_line = mem_dict[key] items_list = leak_line.split() print("==========================split line==========================") for i in range(4, len(items_list)): leak_func = getsym(int(items_list[i], 16), bin_name) print("func =", leak_func, "addr =", items_list[i])


    运行结果

    运行结果中 caller地址0xb6e627f0 的,其 调用函数名 是错的。因为该 调用地址my_malloc_hook 并不在同一个 代码段。同理,我们可以使用 maps 来确定该地址所在的 代码段

    2.4.1.4 获取动态库中的调用者

    如果 caller 是在动态库中,我们需要结合其他工具来查看。
    先看下面一段代码:


    /* libtest.c */#include  #include  char *p[10]; 
    int func1() { int i = 0; for(i = 0; i < 10; i++) { p[i] = (char *)malloc(i); } return 0; }
    int func2() { int i = 0; for(i = 0; i < 10; i++) { free(p[i]); } return 0; }
    void func3() { func1(); }
    /* main.c */#include #include #include #include #include
    static void* (* old_malloc_hook) (size_t,const void *); static void (* old_free_hook)(void *,const void *); static void my_init_hook(void); static void* my_malloc_hook(size_t,const void*); static void my_free_hook(void*,const void *); int func1();int func2();
    static void my_init_hook(void) { old_malloc_hook = __malloc_hook; old_free_hook = __free_hook; __malloc_hook = my_malloc_hook; __free_hook = my_free_hook; }
    static void* my_malloc_hook(size_t size, const void *caller){ void *result; __malloc_hook = old_malloc_hook; result = malloc(size); void *array[10]; size_t size1; size1 = backtrace(array, 10); switch(size1) { case 1: printf("%p + %p %#x %p\n",caller,result,(unsigned int)size,array[0]); break; case 2: printf("%p + %p %#x %p %p\n",caller,result,(unsigned int)size,array[0], array[1]); break; case 3: printf("%p + %p %#x %p %p %p\n",caller,result,(unsigned int)size,array[0],array[1],array[2]); break; case 4: printf("%p + %p %#x %p %p %p %p\n",caller,result,(unsigned int)size,array[0],array[1],array[2],array[3]); break; case 5: printf("%p + %p %#x %p %p %p %p %p\n",caller,result,(unsigned int)size,array[0],array[1],array[2],array[3],array[4]); break; }
    __malloc_hook = my_malloc_hook; return result; }
    static void my_free_hook(void *ptr, const void *caller) {
    __free_hook = old_free_hook; free(ptr); old_free_hook = __free_hook; printf("%p - %p\n", caller, ptr); __free_hook = my_free_hook; }

    int main() { my_init_hook(); func1(); func2(); printf("pid = %d\n", getpid()); pause(); return 0; }


    注意:这里为了使用 backtrace,在编译动态库和主程序的时候都需要加入前面所讲编译选项,不然backtrace会出现无效的现象
    我们看一下运行结果:

    运行结果


    我们再使用 nm工具 查看 动态库:如下所示:

    nm结果


    我们发现其中的 caller(0xb6fc5690) 与调用函数的地址相去甚远。这主要是 因为进程在加载动态库时,其基地址是随机 的,所以导致具体函数地址的变化。

    我们再根据 进程ID 查看 maps属性,如下所示:

    maps属性


    可以看到 动态库起始地址b6fc5000,我们将我们的 caller(0xb6fc5690) 减去该值就是 0x690,再对比 nm工具 的结果就发现是 func1函数


    通过 malloc和free的钩子函数,我们可以清晰的找到每一块内存是在哪里开辟和释放,我们通过这些信息来过滤掉那些 在开辟后会被释放掉的内存块,余留下来的则是一些 未经释放, 一直存在的内存块。但是由于程序或者业务的原因,我们有时需要让一些内存块常驻于内存,这也将加大我们的分析难度。同理我们还是可以通过获取 log,通过调用信息来找到常驻内存块,以此解决问题。

    2.4.2 栈的回溯

    前面我们使用了 backtrace 来回溯各个调用函数,那么这里面的原理就是 栈帧回溯 了。
    在讲述 栈帧结构 之前,我们需要先讲述一下 APCS(ARM Procedure Call Standard)。这是 ARM汇编 一种编译约定,可以简单地理解为它规定了 函数调用的过程使用什么寄存器存放什么变量,比如 规定 r0 - r3 作为前四个参数的传入寄存器外APCS 也规定了 栈帧 的结构。
    再次之上我们在说一下 ARM体系 的几个功能性寄存器:

    • PC:程序指针,寄存器 r15

    • LR:链接寄存器,寄存器为 r14,在栈帧中存放返回值

    • SP:堆栈寄存器,寄存器为 r12

    • FP:堆栈寄存器,寄存器为 r11

    栈帧的结构如下图所示:

    栈帧结构

    以 SP 和 FP 所指的内存段即为一个栈帧。比如在图中,func2栈帧 中有 SPFP 分别存放了 func1 的 *栈帧起点栈帧终点,这段内存就是 funct1的栈帧

    我们知道 栈是向下增长的。所以从图可知,当 func1 函数调用 func2 时。func2 会先将 PCLRSPFP 四个寄存器先压入栈。
    其中 SPFP 的值分别指向 func1函数栈帧的两个边界
    LR 的值保存的是 func2 调用结束之后的返回值
    PC 的值表示的是当前 func1 执行到的指令地址。按照笔者的理解,即放置的是 返回func1后开始执行的指令地址

    通过这种 栈帧结构 ,我们可以很容易就追溯到每个函数的调用地址了,所以它提供了一种 追溯程序的方式,可以来反向跟踪调用的函数。

    参考链接

    dlopen用法:https://www.cnblogs.com/qianqiannian/p/11762646.html
    采用dlopen、dlsym、dlclose加载动态链接库:https://www.cnblogs.com/Anker/p/3746802.html
    汇编中.word的具体用途:https://blog.csdn.net/weixin_42108004/article/details/84593201
    ARM Linux BackTrace:https://www.veryarm.com/29007.html
    ARM下C语言栈帧机制:https://www.jianshu.com/p/91c5dc0a8bb9

    来源:https://www.jianshu.com/p/00f1ee273956

       ‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧  END  ‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧‧

    推荐阅读

    【1】jetson nano开发使用的基础详细分享

    【2】Linux开发coredump文件分析实战分享

    【3】CPU中的程序是怎么运行起来的 必读

    【4】cartographer环境建立以及建图测试

    【5】设计模式之简单工厂模式、工厂模式、抽象工厂模式的对比

    本公众号全部原创干货已整理成一个目录,回复[ 资源 ]即可获得。


    羽林君 某嵌入式程序猿分享技术、生活、人生云云文字。如有诗云:去年今日此门中,人面桃花相映红。人面不知何处去,桃花依旧笑春风。
    评论
    • 在物联网领域中,无线射频技术作为设备间通信的核心手段,已深度渗透工业自动化、智慧城市及智能家居等多元场景。然而,随着物联网设备接入规模的不断扩大,如何降低运维成本,提升通信数据的传输速度和响应时间,实现更广泛、更稳定的覆盖已成为当前亟待解决的系统性难题。SoC无线收发模块-RFM25A12在此背景下,华普微创新推出了一款高性能、远距离与高性价比的Sub-GHz无线SoC收发模块RFM25A12,旨在提升射频性能以满足行业中日益增长与复杂的设备互联需求。值得一提的是,RFM25A12还支持Wi-S
      华普微HOPERF 2025-02-28 09:06 143浏览
    • 请移步 gitee 仓库 https://gitee.com/Newcapec_cn/LiteOS-M_V5.0.2-Release_STM32F103_CubeMX/blob/main/Docs/%E5%9F%BA%E4%BA%8ESTM32F103RCT6%E7%A7%BB%E6%A4%8DLiteOS-M-V5.0.2-Release.md基于STM32F103RCT6移植LiteOS-M-V5.0.2-Release下载源码kernel_liteos_m: OpenHarmon
      逮到一只程序猿 2025-02-27 08:56 195浏览
    • Matter 协议,原名 CHIP(Connected Home over IP),是由苹果、谷歌、亚马逊和三星等科技巨头联合ZigBee联盟(现连接标准联盟CSA)共同推出的一套基于IP协议的智能家居连接标准,旨在打破智能家居设备之间的 “语言障碍”,实现真正的互联互通。然而,目标与现实之间总有落差,前期阶段的Matter 协议由于设备支持类型有限、设备生态协同滞后以及设备通信协议割裂等原因,并未能彻底消除智能家居中的“设备孤岛”现象,但随着2025年的到来,这些现象都将得到完美的解决。近期,
      华普微HOPERF 2025-02-27 10:32 214浏览
    • 应用趋势与客户需求,AI PC的未来展望随着人工智能(AI)技术的日益成熟,AI PC(人工智能个人电脑)逐渐成为消费者和企业工作中的重要工具。这类产品集成了最新的AI处理器,如NPU、CPU和GPU,并具备许多智能化功能,为用户带来更高效且直观的操作体验。AI PC的目标是提升工作和日常生活的效率,通过深度学习与自然语言处理等技术,实现更流畅的多任务处理、实时翻译、语音助手、图像生成等功能,满足现代用户对生产力和娱乐的双重需求。随着各行各业对数字转型需求的增长,AI PC也开始在各个领域中显示
      百佳泰测试实验室 2025-02-27 14:08 255浏览
    •           近日受某专业机构邀请,参加了官方举办的《广东省科技创新条例》宣讲会。在与会之前,作为一名技术工作者一直认为技术的法例都是保密和侵权方面的,而潜意识中感觉法律有束缚创新工作的进行可能。通过一个上午学习新法,对广东省的科技创新有了新的认识。广东是改革的前沿阵地,是科技创新的沃土,企业是创新的主要个体。《广东省科技创新条例》是广东省为促进科技创新、推动高质量发展而制定的地方性法规,主要内容包括: 总则:明确立法目
      广州铁金刚 2025-02-28 10:14 103浏览
    • 在2024年的科技征程中,具身智能的发展已成为全球关注的焦点。从实验室到现实应用,这一领域正以前所未有的速度推进,改写着人类与机器的互动边界。这一年,我们见证了具身智能技术的突破与变革,它不仅落地各行各业,带来新的机遇,更在深刻影响着我们的生活方式和思维方式。随着相关技术的飞速发展,具身智能不再仅仅是一个技术概念,更像是一把神奇的钥匙。身后的众多行业,无论愿意与否,都像是被卷入一场伟大变革浪潮中的船只,注定要被这股汹涌的力量重塑航向。01为什么是具身智能?为什么在中国?最近,中国具身智能行业的进
      艾迈斯欧司朗 2025-02-28 15:45 221浏览
    • 一、VSM的基本原理震动样品磁强计(Vibrating Sample Magnetometer,简称VSM)是一种灵敏且高效的磁性测量仪器。其基本工作原理是利用震动样品在探测线圈中引起的变化磁场来产生感应电压,这个感应电压与样品的磁矩成正比。因此,通过测量这个感应电压,我们就能够精确地确定样品的磁矩。在VSM中,被测量的样品通常被固定在一个震动头上,并以一定的频率和振幅震动。这种震动在探测线圈中引起了变化的磁通量,从而产生了一个交流电信号。这个信号的幅度和样品的磁矩有着直接的关系。因此,通过仔细
      锦正茂科技 2025-02-28 13:30 100浏览
    • 美国加州CEC能效跟DOE能效有什么区别?CEC/DOE是什么关系?美国加州CEC能效跟DOE能效有什么区别?CEC/DOE是什么关系?‌美国加州CEC能效认证与美国DOE能效认证在多个方面存在显著差异‌。认证范围和适用地区‌CEC能效认证‌:仅适用于在加利福尼亚州销售的电器产品。CEC认证的范围包括制冷设备、房间空调、中央空调、便携式空调、加热器、热水器、游泳池加热器、卫浴配件、光源、应急灯具、交通信号模块、灯具、洗碗机、洗衣机、干衣机、烹饪器具、电机和压缩机、变压器、外置电源、消费类电子设备
      张工nx808593 2025-02-27 18:04 120浏览
    • 振动样品磁强计是一种用于测量材料磁性的精密仪器,广泛应用于科研、工业检测等领域。然而,其测量准确度会受到多种因素的影响,下面我们将逐一分析这些因素。一、温度因素温度是影响振动样品磁强计测量准确度的重要因素之一。随着温度的变化,材料的磁性也会发生变化,从而影响测量结果的准确性。因此,在进行磁性测量时,应确保恒温环境,以减少温度波动对测量结果的影响。二、样品制备样品的制备过程同样会影响振动样品磁强计的测量准确度。样品的形状、尺寸和表面处理等因素都会对测量结果产生影响。为了确保测量准确度,应严格按照规
      锦正茂科技 2025-02-28 14:05 134浏览
    • 更多生命体征指标风靡的背后都只有一个原因:更多人将健康排在人生第一顺位!“AGEs,也就是晚期糖基化终末产物,英文名Advanced Glycation End-products,是存在于我们体内的一种代谢产物” 艾迈斯欧司朗亚太区健康监测高级市场经理王亚琴说道,“相信业内的朋友都会有关注,最近该指标的热度很高,它可以用来评估人的生活方式是否健康。”据悉,AGEs是可穿戴健康监测领域的一个“萌新”指标,近来备受关注。如果站在学术角度来理解它,那么AGEs是在非酶促条件下,蛋白质、氨基酸
      艾迈斯欧司朗 2025-02-27 14:50 400浏览
    • RGB灯光无法同步?细致的动态光效设定反而成为产品客诉来源!随着科技的进步和消费者需求变化,电脑接口设备单一功能性已无法满足市场需求,因此在产品上增加「动态光效」的形式便应运而生,藉此吸引消费者目光。这种RGB灯光效果,不仅能增强电脑周边产品的视觉吸引力,还能为用户提供个性化的体验,展现独特自我风格。如今,笔记本电脑、键盘、鼠标、鼠标垫、耳机、显示器等多种电脑接口设备多数已配备动态光效。这些设备的灯光效果会随着音乐节奏、游戏情节或使用者的设置而变化。想象一个画面,当一名游戏玩家,按下电源开关,整
      百佳泰测试实验室 2025-02-27 14:15 137浏览
    • 构建巨量的驾驶场景时,测试ADAS和AD系统面临着巨大挑战,如传统的实验设计(Design of Experiments, DoE)方法难以有效覆盖识别驾驶边缘场景案例,但这些边缘案例恰恰是进一步提升自动驾驶系统性能的关键。一、传统解决方案:静态DoE标准的DoE方案旨在系统性地探索场景的参数空间,从而确保能够实现完全的测试覆盖范围。但在边缘案例,比如暴露在潜在安全风险的场景或是ADAS系统性能极限场景时,DoE方案通常会失效,让我们看一些常见的DoE方案:1、网格搜索法(Grid)实现原理:将
      康谋 2025-02-27 10:00 252浏览
    •         近日,广电计量在聚焦离子束(FIB)领域编写的专业著作《聚焦离子束:失效分析》正式出版,填补了国内聚焦离子束领域实践性专业书籍的空白,为该领域的技术发展与知识传播提供了重要助力。         随着芯片技术不断发展,芯片的集成度越来越高,结构也日益复杂。这使得传统的失效分析方法面临巨大挑战。FIB技术的出现,为芯片失效分析带来了新的解决方案。它能够在纳米尺度上对芯片进行精确加工和分析。当芯
      广电计量 2025-02-28 09:15 116浏览
    • 1,微软下载免费Visual Studio Code2,安装C/C++插件,如果无法直接点击下载, 可以选择手动install from VSIX:ms-vscode.cpptools-1.23.6@win32-x64.vsix3,安装C/C++编译器MniGW (MinGW在 Windows 环境下提供类似于 Unix/Linux 环境下的开发工具,使开发者能够轻松地在 Windows 上编写和编译 C、C++ 等程序.)4,C/C++插件扩展设置中添加Include Path 5,
      黎查 2025-02-28 14:39 140浏览
    我要评论
    0
    点击右上角,分享到朋友圈 我知道啦
    请使用浏览器分享功能 我知道啦